diff --git a/app/ios/Podfile.lock b/app/ios/Podfile.lock index d4376a5d..32821c26 100644 --- a/app/ios/Podfile.lock +++ b/app/ios/Podfile.lock @@ -4,6 +4,8 @@ PODS: - Flutter (1.0.0) - flutter_secure_storage (6.0.0): - Flutter + - image_picker_ios (0.0.1): + - Flutter - package_info_plus (0.4.5): - Flutter - sqlite3 (3.51.1): @@ -41,6 +43,7 @@ DEPENDENCIES: - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - Flutter (from `Flutter`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) + - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`) - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`) @@ -57,6 +60,8 @@ EXTERNAL SOURCES: :path: Flutter flutter_secure_storage: :path: ".symlinks/plugins/flutter_secure_storage/ios" + image_picker_ios: + :path: ".symlinks/plugins/image_picker_ios/ios" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" sqlite3_flutter_libs: @@ -70,6 +75,7 @@ SPEC CHECKSUMS: device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 + image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 sqlite3: 8d708bc63e9f4ce48f0ad9d6269e478c5ced1d9b sqlite3_flutter_libs: d13b8b3003f18f596e542bcb9482d105577eff41 diff --git a/app/linux/flutter/generated_plugin_registrant.cc b/app/linux/flutter/generated_plugin_registrant.cc index feb2279f..b61ca246 100644 --- a/app/linux/flutter/generated_plugin_registrant.cc +++ b/app/linux/flutter/generated_plugin_registrant.cc @@ -6,10 +6,14 @@ #include "generated_plugin_registrant.h" +#include #include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); diff --git a/app/linux/flutter/generated_plugins.cmake b/app/linux/flutter/generated_plugins.cmake index c8626287..a8c56e2a 100644 --- a/app/linux/flutter/generated_plugins.cmake +++ b/app/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux flutter_secure_storage_linux sqlite3_flutter_libs ) diff --git a/app/macos/Flutter/GeneratedPluginRegistrant.swift b/app/macos/Flutter/GeneratedPluginRegistrant.swift index c88c9ddc..f69ca690 100644 --- a/app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,6 +6,7 @@ import FlutterMacOS import Foundation import device_info_plus +import file_selector_macos import flutter_secure_storage_macos import package_info_plus import sqlite3_flutter_libs @@ -14,6 +15,7 @@ import wakelock_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) diff --git a/app/pubspec.lock b/app/pubspec.lock index 4bca724d..d44becc9 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -87,6 +87,14 @@ packages: relative: true source: path version: "0.0.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" crypto: dependency: transitive description: @@ -197,6 +205,38 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" flutter: dependency: "direct main" description: flutter @@ -215,6 +255,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 + url: "https://pub.dev" + source: hosted + version: "2.0.33" flutter_riverpod: dependency: "direct main" description: @@ -337,6 +385,70 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image_picker: + dependency: transitive + description: + name: image_picker + sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: eda9b91b7e266d9041084a42d605a74937d996b87083395c5e47835916a86156 + url: "https://pub.dev" + source: hosted + version: "0.8.13+14" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" intl: dependency: transitive description: diff --git a/app/windows/flutter/generated_plugin_registrant.cc b/app/windows/flutter/generated_plugin_registrant.cc index 1c886807..89c9c26b 100644 --- a/app/windows/flutter/generated_plugin_registrant.cc +++ b/app/windows/flutter/generated_plugin_registrant.cc @@ -6,10 +6,13 @@ #include "generated_plugin_registrant.h" +#include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); Sqlite3FlutterLibsPluginRegisterWithRegistrar( diff --git a/app/windows/flutter/generated_plugins.cmake b/app/windows/flutter/generated_plugins.cmake index f4f47a93..1bfb0cc2 100644 --- a/app/windows/flutter/generated_plugins.cmake +++ b/app/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_windows flutter_secure_storage_windows sqlite3_flutter_libs ) diff --git a/openspec/changes/integrate-user-api/.openspec.yaml b/openspec/changes/integrate-user-api/.openspec.yaml new file mode 100644 index 00000000..0a325460 --- /dev/null +++ b/openspec/changes/integrate-user-api/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-23 diff --git a/openspec/changes/integrate-user-api/design.md b/openspec/changes/integrate-user-api/design.md new file mode 100644 index 00000000..09774055 --- /dev/null +++ b/openspec/changes/integrate-user-api/design.md @@ -0,0 +1,23 @@ +## Context + +The `AuthProvider` now uniquely manages the session state as a **Boolean** (`isLoggedIn`). All user metadata (profile, display name, avatar) must be resolved through a separate, reactive pipeline using `UserRepository` and `UserProvider`, which stream raw **`UsersTableData`** from the Drift database. + +## Goals + +- **Decoupled Auth**: `AuthProvider` (in `core`) only provides the login status (`bool`). +- **Reactive Profile**: Create `UserProvider` (in `profile`) to stream `UsersTableData?`. +- **Database-First**: Cache fetched profile in the `UsersTable` via `UserRepository`. +- **API Integration**: Fetch from `/api/v2.5/me/` and persist to Drift. + +## Decisions + +1. **AuthProvider is Boolean Only** + - Holds `AsyncData` representing whether an auth token exists and is valid. + - All router and guard logic depends on this boolean. + +2. **UserProvider streams UsersTableData?** + - Monitors the session status. + - Watches the specific user row in `UsersTable` for reactive UI updates. + +3. **Drift Caching** + - Profile data is persisted to the local database to support offline viewing and consistent data flow across domain packages. diff --git a/openspec/changes/integrate-user-api/proposal.md b/openspec/changes/integrate-user-api/proposal.md new file mode 100644 index 00000000..a918bb4b --- /dev/null +++ b/openspec/changes/integrate-user-api/proposal.md @@ -0,0 +1,27 @@ +## Why + +The app uses a hardcoded `mockCurrentUser` after login. `AuthProvider` stores the token but never fetches real user details from the backend. Profile screens display stale mock data, and edits are local-only. We need to integrate `/api/v2.5/me/` to fetch and update real user profiles. + +## What Changes + +- Add `getProfile()` and `updateProfile()` to `DataSource` / `HttpDataSource` for `GET` and `PATCH` on `/api/v2.5/me/`. +- Expand `UserDto` with fields the app uses from the API response, plus `fromJson` for parsing. +- Extend `UserRepository` to orchestrate profile fetch and update via `DataSource`. +- Update `AuthProvider.build()` to fetch real user from `UserRepository` instead of returning `mockCurrentUser`. +- Replace `AuthProvider.updateProfile(UserDto)` with an async method that delegates to `UserRepository`. +- Wire Edit Profile screen to handle async save with loading and error feedback. +- Decouple `courses` domain from `profile` by moving `UserProgressRepository` to the `core` package. + +## Capabilities + +### New Capabilities +- `user-profile-api`: Network integration for fetching and updating user profile via `/api/v2.5/me/`, flowing through `DataSource` → `UserRepository` → `AuthProvider`. + +### Modified Capabilities +- `domain-isolation`: The `courses` package now relies exclusively on `core` for identity state and progress data, preventing boundary violations. + +## Impact + +- Affected code: `DataSource`, `HttpDataSource`, `MockDataSource`, `UserDto`, `UserRepository`, `UserProgressRepository`, `AuthProvider`. +- Dependencies: `dio` (already present). +- Behavior: After login or session restore, the app displays real user data. Profile edits are persisted server-side. diff --git a/openspec/changes/integrate-user-api/specs/user-profile-api/spec.md b/openspec/changes/integrate-user-api/specs/user-profile-api/spec.md new file mode 100644 index 00000000..45046368 --- /dev/null +++ b/openspec/changes/integrate-user-api/specs/user-profile-api/spec.md @@ -0,0 +1,92 @@ +## ADDED Requirements + +### Requirement: Fetch user profile on session restore +The system SHALL fetch the authenticated user's profile from `GET /api/v2.5/me/` whenever a valid token is present. + +#### Scenario: Successful profile fetch after login +- **WHEN** the user successfully logs in (password or OTP) +- **THEN** the system MUST call `GET /api/v2.5/me/` with the stored auth token +- **AND** parse the response into a `UserDto` and persist it to the local database via `UserRepository` +- **AND** ensure the `userProvider` (Stream) emits the updated profile data + +#### Scenario: Successful profile fetch on app startup +- **WHEN** the app starts and a stored auth token is found +- **THEN** the system MUST call `GET /api/v2.5/me/` to restore the user session +- **AND** the `authProvider` state MUST transition to `data(true)` +- **AND** the `userProvider` MUST emit the fetched `UserDto` (from cache or network) + +#### Scenario: Profile fetch fails with invalid token +- **WHEN** the `GET /api/v2.5/me/` call returns 401 +- **THEN** the system MUST clear the stored token +- **AND** the `authProvider` state MUST transition to `data(false)` (unauthenticated) +- **AND** the `userProvider` MUST emit `null` + +#### Scenario: Profile fetch fails with network error +- **WHEN** the `GET /api/v2.5/me/` call fails due to a network error +- **THEN** the system MUST set `authProvider` state to `error` +- **AND** the app MUST show a retry option + +--- + +### Requirement: Update user profile via API +The system SHALL persist profile edits to the backend via `PATCH /api/v2.5/me/`. + +#### Scenario: Successful profile update +- **WHEN** the user submits valid edits on the Edit Profile screen +- **THEN** the system MUST send a `PATCH /api/v2.5/me/` request with the changed fields +- **AND** the local database MUST be updated with the `UserDto` parsed from the server response +- **AND** the `userProvider` MUST automatically emit the new profile data +- **AND** the system MUST navigate back to the Profile screen + +#### Scenario: Profile update fails +- **WHEN** the `PATCH /api/v2.5/me/` request fails +- **THEN** the system MUST display the error message to the user on the Edit Profile screen +- **AND** the form data MUST be preserved so the user can retry + +#### Scenario: Loading state during save +- **WHEN** the profile update request is in progress +- **THEN** the Save button MUST show a loading indicator +- **AND** form inputs MUST be disabled to prevent duplicate submissions + +--- + +### Requirement: UserDto field mapping +The system SHALL parse the `/me/` API response into a `UserDto` using only the fields the app needs. + +#### Scenario: Field mapping from API response +- **WHEN** the `/me/` response JSON is received +- **THEN** the system MUST map the following fields: + - `id` → `UserDto.id` + - `display_name` → `UserDto.name` + - `first_name` → `UserDto.firstName` + - `last_name` → `UserDto.lastName` + - `email` → `UserDto.email` + - `phone` → `UserDto.phone` + - `username` → `UserDto.username` + - `medium_image` → `UserDto.avatar` +- **AND** all other fields from the API response MUST be ignored + +--- + +### Requirement: Profile data flows through DataSource and UserRepository +The system SHALL route profile operations through the existing `DataSource` → `UserRepository` pipeline. + +#### Scenario: DataSource interface contract +- **WHEN** a profile operation is requested +- **THEN** `DataSource` MUST expose `getProfile(String token)` returning `UserDto` +- **AND** `DataSource` MUST expose `updateProfile(String token, Map data)` returning `UserDto` + +#### Scenario: HttpDataSource implementation +- **WHEN** `HttpDataSource.getProfile()` is called +- **THEN** it MUST send `GET /api/v2.5/me/` with `Authorization: JWT ` header +- **AND** parse the response using `UserDto.fromJson` + +#### Scenario: MockDataSource implementation +- **WHEN** `MockDataSource.getProfile()` is called +- **THEN** it MUST return `mockCurrentUser` after a simulated delay + +#### Scenario: UserRepository orchestration +- **WHEN** `UserRepository.fetchProfile(token)` is called +- **THEN** it MUST delegate to `DataSource.getProfile(token)` and return the result +- **WHEN** `UserRepository.updateProfile(token, data)` is called +- **THEN** it MUST delegate to `DataSource.updateProfile(token, data)` and return the updated `UserDto` diff --git a/openspec/changes/integrate-user-api/tasks.md b/openspec/changes/integrate-user-api/tasks.md new file mode 100644 index 00000000..8d52828b --- /dev/null +++ b/openspec/changes/integrate-user-api/tasks.md @@ -0,0 +1,33 @@ +## 1. Auth & Session Refactor + +- [x] 1.1 Update `AuthProvider.build()` to return `AsyncData` (using `repo.isUserLoggedIn()`). +- [x] 1.2 Update `app_router.dart` redirection logic to correctly use `isLoggedIn == true`. +- [x] 1.3 Simplify `AuthLocalDataSource` (Boolean Token-only storage). +- [x] 1.4 Implement atomic `saveToken` in `AuthRepository`. +- [x] 1.5 Update `auth_repository_test.dart` to match atomic session logic. + +## 2. Shared Meta & Persistence (Single-Record DB) + +- [x] 2.1 Add `getProfile()` to `DataSource` interface and implement in `HttpDataSource`. +- [x] 2.2 Implement `watchCurrentUser()` in `UserRepository` for ID-less Drift caching. +- [x] 2.3 Implement `refreshProfile()` in `UserRepository` to sync DB with API. +- [x] 2.4 Extract `UserProgressRepository` to `core` for domain-agnostic progress tracking. +- [x] 2.5 Refactor `courses/recent_activity_provider.dart` to rely on `core` exclusively. + +## 3. UserProvider Implementation (The Brain) + +- [x] 3.1 Restore `userRepositoryProvider` in `packages/profile`. +- [x] 3.2 Implement `UserProvider` to watch `authProvider` and yield `Stream`. +- [x] 3.3 Add "Refresh-on-Watch" orchestration to `UserProvider`. + +## 4. UI Wiring (Decoupled & Reactive) + +- [x] 4.1 Update `PaidActiveHomeScreen` to use `userProvider` for greeting. +- [x] 4.2 Update `PaidActiveProfileScreen` (`ProfilePage`) to use `userProvider`. +- [x] 4.3 Update `EditProfileScreen` to use `userProvider` for its initial state. +- [x] 4.4 Ensure `appInitializationProvider` clears `UsersTable` on logout (via `AuthProvider.logout()`). + +## 5. Validation + +- [x] 5.1 Run `flutter analyze` on `packages/core` and `packages/profile`. +- [x] 5.2 Verify login → background fetch → Home/Profile reactive update from DB. diff --git a/packages/core/lib/data/auth/auth_provider.dart b/packages/core/lib/data/auth/auth_provider.dart index 135a3d42..bdb9cba6 100644 --- a/packages/core/lib/data/auth/auth_provider.dart +++ b/packages/core/lib/data/auth/auth_provider.dart @@ -1,10 +1,9 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; -import '../models/user_dto.dart'; -import '../sources/mock_data.dart'; import 'auth_api_service.dart'; import 'auth_local_data_source.dart'; import 'auth_repository.dart'; +import '../db/database_provider.dart'; part 'auth_provider.g.dart'; @@ -73,6 +72,10 @@ class Auth extends _$Auth { Future logout() async { try { await _repository.logout(); + + final db = await ref.read(appDatabaseProvider.future); + await db.clearUserData(); + state = const AsyncData(false); } catch (e) { state = const AsyncData(false); @@ -80,3 +83,10 @@ class Auth extends _$Auth { } } } + + +@Riverpod(keepAlive: true) +Stream userId(UserIdRef ref) async* { + final db = await ref.watch(appDatabaseProvider.future); + yield* db.select(db.usersTable).watchSingleOrNull().map((user) => user?.id); +} diff --git a/packages/core/lib/data/auth/auth_provider.g.dart b/packages/core/lib/data/auth/auth_provider.g.dart index e8f315fa..2ffc207f 100644 --- a/packages/core/lib/data/auth/auth_provider.g.dart +++ b/packages/core/lib/data/auth/auth_provider.g.dart @@ -6,7 +6,24 @@ part of 'auth_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$authHash() => r'5606fb1acd3875bc766e934844170d2a15b4be1b'; +String _$userIdHash() => r'3eba5f054510dd368b6da24ced0813b0cc1317e9'; + +/// See also [userId]. +@ProviderFor(userId) +final userIdProvider = StreamProvider.internal( + userId, + name: r'userIdProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$userIdHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef UserIdRef = StreamProviderRef; +String _$authHash() => r'e40e98d4feab4981f9a8db786cf63e8aec59c08c'; /// See also [Auth]. @ProviderFor(Auth) diff --git a/packages/core/lib/data/auth/auth_repository.dart b/packages/core/lib/data/auth/auth_repository.dart index 502b87ec..b87c31a2 100644 --- a/packages/core/lib/data/auth/auth_repository.dart +++ b/packages/core/lib/data/auth/auth_repository.dart @@ -1,6 +1,5 @@ import 'auth_api_service.dart'; import 'auth_local_data_source.dart'; -import 'types/auth_exception.dart'; class AuthRepository { final AuthApiService _apiService; @@ -59,12 +58,11 @@ class AuthRepository { } Future logout() async { - final token = await getToken(); - + final token = await _localDataSource.getToken(); try { await _apiService.logout(authToken: token); - } on AuthException { - // Local cleanup still must happen. + } catch (_) { + // Still logout locally if API fails } finally { await clearToken(); } diff --git a/packages/core/lib/data/data.dart b/packages/core/lib/data/data.dart index 19e937cd..88c73555 100644 --- a/packages/core/lib/data/data.dart +++ b/packages/core/lib/data/data.dart @@ -1,7 +1,6 @@ /// Unified Foundation for the Cortex App. /// This barrel file re-exports all shared models, database infrastructure, /// auth providers, and repositories. -library core.data; // Config export 'config/app_config.dart'; @@ -34,8 +33,8 @@ export 'sources/data_source_provider.dart'; export 'sources/study_momentum_provider.dart'; // Repositories -export 'repositories/user_repository.dart'; export 'repositories/forum_repository.dart'; +export 'repositories/user_progress_repository.dart'; export 'repositories/repository_providers.dart'; // Infra & Mocks diff --git a/packages/core/lib/data/db/app_database.dart b/packages/core/lib/data/db/app_database.dart index 2994c94c..b9f99e80 100644 --- a/packages/core/lib/data/db/app_database.dart +++ b/packages/core/lib/data/db/app_database.dart @@ -12,6 +12,7 @@ import 'tables/live_classes_table.dart'; import 'tables/forum_threads_table.dart'; import 'tables/user_progress_table.dart'; import 'tables/app_settings_table.dart'; +import 'tables/users_table.dart'; import 'package:core/data/data.dart'; part 'app_database.g.dart'; @@ -25,13 +26,14 @@ part 'app_database.g.dart'; ForumThreadsTable, UserProgressTable, AppSettingsTable, + UsersTable, ], ) class AppDatabase extends _$AppDatabase { AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); @override - int get schemaVersion => 6; + int get schemaVersion => 7; @override MigrationStrategy get migration => MigrationStrategy( @@ -92,9 +94,29 @@ class AppDatabase extends _$AppDatabase { ); } } + if (from < 7) { + await m.createTable(usersTable); + } }, ); + // ── User Profiling ─────────────────────────────────────────────────────── + + /// Fetch a user from the local cache. + Future getUserById(String id) => + (select(usersTable)..where((t) => t.id.equals(id))).getSingleOrNull(); + + /// Watch a user's metadata for live updates. + Stream watchUserById(String id) => + (select(usersTable)..where((t) => t.id.equals(id))).watchSingleOrNull(); + + /// Insert or replace user metadata. + Future upsertUser(UsersTableCompanion companion) => + into(usersTable).insertOnConflictUpdate(companion); + + + Future clearUserData() => delete(usersTable).go(); + // ── App Settings ───────────────────────────────────────────────────────── /// Fetch the singleton settings row. diff --git a/packages/core/lib/data/db/app_database.g.dart b/packages/core/lib/data/db/app_database.g.dart index fdccbbd5..39722949 100644 --- a/packages/core/lib/data/db/app_database.g.dart +++ b/packages/core/lib/data/db/app_database.g.dart @@ -3630,6 +3630,566 @@ class AppSettingsTableCompanion extends UpdateCompanion { } } +class $UsersTableTable extends UsersTable + with TableInfo<$UsersTableTable, UsersTableData> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $UsersTableTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _nameMeta = const VerificationMeta('name'); + @override + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _usernameMeta = const VerificationMeta( + 'username', + ); + @override + late final GeneratedColumn username = GeneratedColumn( + 'username', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _firstNameMeta = const VerificationMeta( + 'firstName', + ); + @override + late final GeneratedColumn firstName = GeneratedColumn( + 'first_name', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _lastNameMeta = const VerificationMeta( + 'lastName', + ); + @override + late final GeneratedColumn lastName = GeneratedColumn( + 'last_name', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _emailMeta = const VerificationMeta('email'); + @override + late final GeneratedColumn email = GeneratedColumn( + 'email', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _phoneMeta = const VerificationMeta('phone'); + @override + late final GeneratedColumn phone = GeneratedColumn( + 'phone', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _avatarMeta = const VerificationMeta('avatar'); + @override + late final GeneratedColumn avatar = GeneratedColumn( + 'avatar', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _joinedDateMeta = const VerificationMeta( + 'joinedDate', + ); + @override + late final GeneratedColumn joinedDate = GeneratedColumn( + 'joined_date', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + name, + username, + firstName, + lastName, + email, + phone, + avatar, + joinedDate, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'users_table'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('name')) { + context.handle( + _nameMeta, + name.isAcceptableOrUnknown(data['name']!, _nameMeta), + ); + } + if (data.containsKey('username')) { + context.handle( + _usernameMeta, + username.isAcceptableOrUnknown(data['username']!, _usernameMeta), + ); + } + if (data.containsKey('first_name')) { + context.handle( + _firstNameMeta, + firstName.isAcceptableOrUnknown(data['first_name']!, _firstNameMeta), + ); + } + if (data.containsKey('last_name')) { + context.handle( + _lastNameMeta, + lastName.isAcceptableOrUnknown(data['last_name']!, _lastNameMeta), + ); + } + if (data.containsKey('email')) { + context.handle( + _emailMeta, + email.isAcceptableOrUnknown(data['email']!, _emailMeta), + ); + } + if (data.containsKey('phone')) { + context.handle( + _phoneMeta, + phone.isAcceptableOrUnknown(data['phone']!, _phoneMeta), + ); + } + if (data.containsKey('avatar')) { + context.handle( + _avatarMeta, + avatar.isAcceptableOrUnknown(data['avatar']!, _avatarMeta), + ); + } + if (data.containsKey('joined_date')) { + context.handle( + _joinedDateMeta, + joinedDate.isAcceptableOrUnknown(data['joined_date']!, _joinedDateMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + UsersTableData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return UsersTableData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + ), + username: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}username'], + ), + firstName: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}first_name'], + ), + lastName: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}last_name'], + ), + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + ), + phone: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}phone'], + ), + avatar: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}avatar'], + ), + joinedDate: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}joined_date'], + ), + ); + } + + @override + $UsersTableTable createAlias(String alias) { + return $UsersTableTable(attachedDatabase, alias); + } +} + +class UsersTableData extends DataClass implements Insertable { + final String id; + final String? name; + final String? username; + final String? firstName; + final String? lastName; + final String? email; + final String? phone; + final String? avatar; + final DateTime? joinedDate; + const UsersTableData({ + required this.id, + this.name, + this.username, + this.firstName, + this.lastName, + this.email, + this.phone, + this.avatar, + this.joinedDate, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + if (!nullToAbsent || name != null) { + map['name'] = Variable(name); + } + if (!nullToAbsent || username != null) { + map['username'] = Variable(username); + } + if (!nullToAbsent || firstName != null) { + map['first_name'] = Variable(firstName); + } + if (!nullToAbsent || lastName != null) { + map['last_name'] = Variable(lastName); + } + if (!nullToAbsent || email != null) { + map['email'] = Variable(email); + } + if (!nullToAbsent || phone != null) { + map['phone'] = Variable(phone); + } + if (!nullToAbsent || avatar != null) { + map['avatar'] = Variable(avatar); + } + if (!nullToAbsent || joinedDate != null) { + map['joined_date'] = Variable(joinedDate); + } + return map; + } + + UsersTableCompanion toCompanion(bool nullToAbsent) { + return UsersTableCompanion( + id: Value(id), + name: name == null && nullToAbsent ? const Value.absent() : Value(name), + username: username == null && nullToAbsent + ? const Value.absent() + : Value(username), + firstName: firstName == null && nullToAbsent + ? const Value.absent() + : Value(firstName), + lastName: lastName == null && nullToAbsent + ? const Value.absent() + : Value(lastName), + email: email == null && nullToAbsent + ? const Value.absent() + : Value(email), + phone: phone == null && nullToAbsent + ? const Value.absent() + : Value(phone), + avatar: avatar == null && nullToAbsent + ? const Value.absent() + : Value(avatar), + joinedDate: joinedDate == null && nullToAbsent + ? const Value.absent() + : Value(joinedDate), + ); + } + + factory UsersTableData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return UsersTableData( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + username: serializer.fromJson(json['username']), + firstName: serializer.fromJson(json['firstName']), + lastName: serializer.fromJson(json['lastName']), + email: serializer.fromJson(json['email']), + phone: serializer.fromJson(json['phone']), + avatar: serializer.fromJson(json['avatar']), + joinedDate: serializer.fromJson(json['joinedDate']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'username': serializer.toJson(username), + 'firstName': serializer.toJson(firstName), + 'lastName': serializer.toJson(lastName), + 'email': serializer.toJson(email), + 'phone': serializer.toJson(phone), + 'avatar': serializer.toJson(avatar), + 'joinedDate': serializer.toJson(joinedDate), + }; + } + + UsersTableData copyWith({ + String? id, + Value name = const Value.absent(), + Value username = const Value.absent(), + Value firstName = const Value.absent(), + Value lastName = const Value.absent(), + Value email = const Value.absent(), + Value phone = const Value.absent(), + Value avatar = const Value.absent(), + Value joinedDate = const Value.absent(), + }) => UsersTableData( + id: id ?? this.id, + name: name.present ? name.value : this.name, + username: username.present ? username.value : this.username, + firstName: firstName.present ? firstName.value : this.firstName, + lastName: lastName.present ? lastName.value : this.lastName, + email: email.present ? email.value : this.email, + phone: phone.present ? phone.value : this.phone, + avatar: avatar.present ? avatar.value : this.avatar, + joinedDate: joinedDate.present ? joinedDate.value : this.joinedDate, + ); + UsersTableData copyWithCompanion(UsersTableCompanion data) { + return UsersTableData( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + username: data.username.present ? data.username.value : this.username, + firstName: data.firstName.present ? data.firstName.value : this.firstName, + lastName: data.lastName.present ? data.lastName.value : this.lastName, + email: data.email.present ? data.email.value : this.email, + phone: data.phone.present ? data.phone.value : this.phone, + avatar: data.avatar.present ? data.avatar.value : this.avatar, + joinedDate: data.joinedDate.present + ? data.joinedDate.value + : this.joinedDate, + ); + } + + @override + String toString() { + return (StringBuffer('UsersTableData(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('username: $username, ') + ..write('firstName: $firstName, ') + ..write('lastName: $lastName, ') + ..write('email: $email, ') + ..write('phone: $phone, ') + ..write('avatar: $avatar, ') + ..write('joinedDate: $joinedDate') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + name, + username, + firstName, + lastName, + email, + phone, + avatar, + joinedDate, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is UsersTableData && + other.id == this.id && + other.name == this.name && + other.username == this.username && + other.firstName == this.firstName && + other.lastName == this.lastName && + other.email == this.email && + other.phone == this.phone && + other.avatar == this.avatar && + other.joinedDate == this.joinedDate); +} + +class UsersTableCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value username; + final Value firstName; + final Value lastName; + final Value email; + final Value phone; + final Value avatar; + final Value joinedDate; + final Value rowid; + const UsersTableCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.username = const Value.absent(), + this.firstName = const Value.absent(), + this.lastName = const Value.absent(), + this.email = const Value.absent(), + this.phone = const Value.absent(), + this.avatar = const Value.absent(), + this.joinedDate = const Value.absent(), + this.rowid = const Value.absent(), + }); + UsersTableCompanion.insert({ + required String id, + this.name = const Value.absent(), + this.username = const Value.absent(), + this.firstName = const Value.absent(), + this.lastName = const Value.absent(), + this.email = const Value.absent(), + this.phone = const Value.absent(), + this.avatar = const Value.absent(), + this.joinedDate = const Value.absent(), + this.rowid = const Value.absent(), + }) : id = Value(id); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? username, + Expression? firstName, + Expression? lastName, + Expression? email, + Expression? phone, + Expression? avatar, + Expression? joinedDate, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (username != null) 'username': username, + if (firstName != null) 'first_name': firstName, + if (lastName != null) 'last_name': lastName, + if (email != null) 'email': email, + if (phone != null) 'phone': phone, + if (avatar != null) 'avatar': avatar, + if (joinedDate != null) 'joined_date': joinedDate, + if (rowid != null) 'rowid': rowid, + }); + } + + UsersTableCompanion copyWith({ + Value? id, + Value? name, + Value? username, + Value? firstName, + Value? lastName, + Value? email, + Value? phone, + Value? avatar, + Value? joinedDate, + Value? rowid, + }) { + return UsersTableCompanion( + id: id ?? this.id, + name: name ?? this.name, + username: username ?? this.username, + firstName: firstName ?? this.firstName, + lastName: lastName ?? this.lastName, + email: email ?? this.email, + phone: phone ?? this.phone, + avatar: avatar ?? this.avatar, + joinedDate: joinedDate ?? this.joinedDate, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (username.present) { + map['username'] = Variable(username.value); + } + if (firstName.present) { + map['first_name'] = Variable(firstName.value); + } + if (lastName.present) { + map['last_name'] = Variable(lastName.value); + } + if (email.present) { + map['email'] = Variable(email.value); + } + if (phone.present) { + map['phone'] = Variable(phone.value); + } + if (avatar.present) { + map['avatar'] = Variable(avatar.value); + } + if (joinedDate.present) { + map['joined_date'] = Variable(joinedDate.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('UsersTableCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('username: $username, ') + ..write('firstName: $firstName, ') + ..write('lastName: $lastName, ') + ..write('email: $email, ') + ..write('phone: $phone, ') + ..write('avatar: $avatar, ') + ..write('joinedDate: $joinedDate, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + abstract class _$AppDatabase extends GeneratedDatabase { _$AppDatabase(QueryExecutor e) : super(e); $AppDatabaseManager get managers => $AppDatabaseManager(this); @@ -3646,6 +4206,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { late final $AppSettingsTableTable appSettingsTable = $AppSettingsTableTable( this, ); + late final $UsersTableTable usersTable = $UsersTableTable(this); @override Iterable> get allTables => allSchemaEntities.whereType>(); @@ -3658,6 +4219,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { forumThreadsTable, userProgressTable, appSettingsTable, + usersTable, ]; } @@ -5537,6 +6099,284 @@ typedef $$AppSettingsTableTableProcessedTableManager = AppSettingsTableData, PrefetchHooks Function() >; +typedef $$UsersTableTableCreateCompanionBuilder = + UsersTableCompanion Function({ + required String id, + Value name, + Value username, + Value firstName, + Value lastName, + Value email, + Value phone, + Value avatar, + Value joinedDate, + Value rowid, + }); +typedef $$UsersTableTableUpdateCompanionBuilder = + UsersTableCompanion Function({ + Value id, + Value name, + Value username, + Value firstName, + Value lastName, + Value email, + Value phone, + Value avatar, + Value joinedDate, + Value rowid, + }); + +class $$UsersTableTableFilterComposer + extends Composer<_$AppDatabase, $UsersTableTable> { + $$UsersTableTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get username => $composableBuilder( + column: $table.username, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get firstName => $composableBuilder( + column: $table.firstName, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastName => $composableBuilder( + column: $table.lastName, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get email => $composableBuilder( + column: $table.email, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get phone => $composableBuilder( + column: $table.phone, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get avatar => $composableBuilder( + column: $table.avatar, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get joinedDate => $composableBuilder( + column: $table.joinedDate, + builder: (column) => ColumnFilters(column), + ); +} + +class $$UsersTableTableOrderingComposer + extends Composer<_$AppDatabase, $UsersTableTable> { + $$UsersTableTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get username => $composableBuilder( + column: $table.username, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get firstName => $composableBuilder( + column: $table.firstName, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastName => $composableBuilder( + column: $table.lastName, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get email => $composableBuilder( + column: $table.email, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get phone => $composableBuilder( + column: $table.phone, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get avatar => $composableBuilder( + column: $table.avatar, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get joinedDate => $composableBuilder( + column: $table.joinedDate, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$UsersTableTableAnnotationComposer + extends Composer<_$AppDatabase, $UsersTableTable> { + $$UsersTableTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get name => + $composableBuilder(column: $table.name, builder: (column) => column); + + GeneratedColumn get username => + $composableBuilder(column: $table.username, builder: (column) => column); + + GeneratedColumn get firstName => + $composableBuilder(column: $table.firstName, builder: (column) => column); + + GeneratedColumn get lastName => + $composableBuilder(column: $table.lastName, builder: (column) => column); + + GeneratedColumn get email => + $composableBuilder(column: $table.email, builder: (column) => column); + + GeneratedColumn get phone => + $composableBuilder(column: $table.phone, builder: (column) => column); + + GeneratedColumn get avatar => + $composableBuilder(column: $table.avatar, builder: (column) => column); + + GeneratedColumn get joinedDate => $composableBuilder( + column: $table.joinedDate, + builder: (column) => column, + ); +} + +class $$UsersTableTableTableManager + extends + RootTableManager< + _$AppDatabase, + $UsersTableTable, + UsersTableData, + $$UsersTableTableFilterComposer, + $$UsersTableTableOrderingComposer, + $$UsersTableTableAnnotationComposer, + $$UsersTableTableCreateCompanionBuilder, + $$UsersTableTableUpdateCompanionBuilder, + ( + UsersTableData, + BaseReferences<_$AppDatabase, $UsersTableTable, UsersTableData>, + ), + UsersTableData, + PrefetchHooks Function() + > { + $$UsersTableTableTableManager(_$AppDatabase db, $UsersTableTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$UsersTableTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$UsersTableTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$UsersTableTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value name = const Value.absent(), + Value username = const Value.absent(), + Value firstName = const Value.absent(), + Value lastName = const Value.absent(), + Value email = const Value.absent(), + Value phone = const Value.absent(), + Value avatar = const Value.absent(), + Value joinedDate = const Value.absent(), + Value rowid = const Value.absent(), + }) => UsersTableCompanion( + id: id, + name: name, + username: username, + firstName: firstName, + lastName: lastName, + email: email, + phone: phone, + avatar: avatar, + joinedDate: joinedDate, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + Value name = const Value.absent(), + Value username = const Value.absent(), + Value firstName = const Value.absent(), + Value lastName = const Value.absent(), + Value email = const Value.absent(), + Value phone = const Value.absent(), + Value avatar = const Value.absent(), + Value joinedDate = const Value.absent(), + Value rowid = const Value.absent(), + }) => UsersTableCompanion.insert( + id: id, + name: name, + username: username, + firstName: firstName, + lastName: lastName, + email: email, + phone: phone, + avatar: avatar, + joinedDate: joinedDate, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$UsersTableTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $UsersTableTable, + UsersTableData, + $$UsersTableTableFilterComposer, + $$UsersTableTableOrderingComposer, + $$UsersTableTableAnnotationComposer, + $$UsersTableTableCreateCompanionBuilder, + $$UsersTableTableUpdateCompanionBuilder, + ( + UsersTableData, + BaseReferences<_$AppDatabase, $UsersTableTable, UsersTableData>, + ), + UsersTableData, + PrefetchHooks Function() + >; class $AppDatabaseManager { final _$AppDatabase _db; @@ -5555,4 +6395,6 @@ class $AppDatabaseManager { $$UserProgressTableTableTableManager(_db, _db.userProgressTable); $$AppSettingsTableTableTableManager get appSettingsTable => $$AppSettingsTableTableTableManager(_db, _db.appSettingsTable); + $$UsersTableTableTableManager get usersTable => + $$UsersTableTableTableManager(_db, _db.usersTable); } diff --git a/packages/core/lib/data/db/tables/users_table.dart b/packages/core/lib/data/db/tables/users_table.dart new file mode 100644 index 00000000..558e67c9 --- /dev/null +++ b/packages/core/lib/data/db/tables/users_table.dart @@ -0,0 +1,17 @@ +import 'package:drift/drift.dart'; + +/// Database table for storing user profile metadata. +class UsersTable extends Table { + TextColumn get id => text()(); + TextColumn get name => text().nullable()(); // Maps to display_name + TextColumn get username => text().nullable()(); + TextColumn get firstName => text().nullable()(); + TextColumn get lastName => text().nullable()(); + TextColumn get email => text().nullable()(); + TextColumn get phone => text().nullable()(); + TextColumn get avatar => text().nullable()(); // URL string + DateTimeColumn get joinedDate => dateTime().nullable()(); + + @override + Set get primaryKey => {id}; +} diff --git a/packages/core/lib/data/models/user_dto.dart b/packages/core/lib/data/models/user_dto.dart index 3ce984c7..ff7838a7 100644 --- a/packages/core/lib/data/models/user_dto.dart +++ b/packages/core/lib/data/models/user_dto.dart @@ -1,9 +1,14 @@ import 'package:flutter/foundation.dart'; +import 'package:drift/drift.dart' as drift; +import '../db/app_database.dart'; @immutable class UserDto { final String id; final String name; + final String? username; + final String? firstName; + final String? lastName; final String? email; final String? phone; final String? avatar; @@ -13,6 +18,9 @@ class UserDto { const UserDto({ required this.id, required this.name, + this.username, + this.firstName, + this.lastName, this.email, this.phone, this.avatar, @@ -20,12 +28,71 @@ class UserDto { this.joinedDate, }); + factory UserDto.fromJson(Map json) { + return UserDto( + id: json['id'].toString(), + name: (json['display_name'] as String?) ?? '', + username: json['username'] as String?, + firstName: json['first_name'] as String?, + lastName: json['last_name'] as String?, + email: json['email'] as String?, + phone: json['phone'] as String?, + avatar: json['medium_image'] as String?, + joinedDate: json['joined_date'] != null + ? DateTime.tryParse(json['joined_date'].toString()) + : null, + ); + } + + factory UserDto.fromTableData(UsersTableData data) { + return UserDto( + id: data.id, + name: data.name ?? '', + username: data.username, + firstName: data.firstName, + lastName: data.lastName, + email: data.email, + phone: data.phone, + avatar: data.avatar, + joinedDate: data.joinedDate, + ); + } + + UserDto copyWith({ + String? id, + String? name, + String? username, + String? firstName, + String? lastName, + String? email, + String? phone, + String? avatar, + bool? isPro, + DateTime? joinedDate, + }) { + return UserDto( + id: id ?? this.id, + name: name ?? this.name, + username: username ?? this.username, + firstName: firstName ?? this.firstName, + lastName: lastName ?? this.lastName, + email: email ?? this.email, + phone: phone ?? this.phone, + avatar: avatar ?? this.avatar, + isPro: isPro ?? this.isPro, + joinedDate: joinedDate ?? this.joinedDate, + ); + } + @override bool operator ==(Object other) { if (identical(this, other)) return true; return other is UserDto && other.id == id && other.name == name && + other.username == username && + other.firstName == firstName && + other.lastName == lastName && other.email == email && other.phone == phone && other.avatar == avatar && @@ -37,6 +104,9 @@ class UserDto { int get hashCode => Object.hash( id, name, + username, + firstName, + lastName, email, phone, avatar, @@ -44,3 +114,22 @@ class UserDto { joinedDate, ); } + +/// Extension to bridge [UserDto] metadata into individual database rows. +/// This allows for easy persistence into [UsersTable]. +extension UserDtoPersistence on UserDto { + UsersTableCompanion toCompanion() { + return UsersTableCompanion( + id: drift.Value(id), + name: drift.Value(name), + username: drift.Value(username), + firstName: drift.Value(firstName), + lastName: drift.Value(lastName), + email: drift.Value(email), + phone: drift.Value(phone), + avatar: drift.Value(avatar), + joinedDate: drift.Value(joinedDate), + ); + } +} + diff --git a/packages/core/lib/data/repositories/repository_providers.dart b/packages/core/lib/data/repositories/repository_providers.dart index dbc3b86e..72511f5e 100644 --- a/packages/core/lib/data/repositories/repository_providers.dart +++ b/packages/core/lib/data/repositories/repository_providers.dart @@ -1,32 +1,24 @@ - -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'user_repository.dart'; import 'forum_repository.dart'; +import 'user_progress_repository.dart'; import '../db/database_provider.dart'; import '../sources/data_source_provider.dart'; part 'repository_providers.g.dart'; - - -/// Provides the [UserRepository]. +/// Provides the [ForumRepository]. @Riverpod(keepAlive: true) -Future userRepository(Ref ref) async { +Future forumRepository(ForumRepositoryRef ref) async { final db = await ref.watch(appDatabaseProvider.future); final source = ref.watch(dataSourceProvider); - return UserRepository(db, source); + return ForumRepository(db, source); } -/// Provides the [ForumRepository]. +/// Provides the [UserProgressRepository]. @Riverpod(keepAlive: true) -Future forumRepository(Ref ref) async { +Future userProgressRepository(UserProgressRepositoryRef ref) async { final db = await ref.watch(appDatabaseProvider.future); final source = ref.watch(dataSourceProvider); - return ForumRepository(db, source); + return UserProgressRepository(db, source); } - - - - diff --git a/packages/core/lib/data/repositories/repository_providers.g.dart b/packages/core/lib/data/repositories/repository_providers.g.dart index f60549c5..77447b11 100644 --- a/packages/core/lib/data/repositories/repository_providers.g.dart +++ b/packages/core/lib/data/repositories/repository_providers.g.dart @@ -6,26 +6,7 @@ part of 'repository_providers.dart'; // RiverpodGenerator // ************************************************************************** -String _$userRepositoryHash() => r'9af46bc5bae2f75b3745cb12c2fe3d66c4677ff3'; - -/// Provides the [UserRepository]. -/// -/// Copied from [userRepository]. -@ProviderFor(userRepository) -final userRepositoryProvider = FutureProvider.internal( - userRepository, - name: r'userRepositoryProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$userRepositoryHash, - dependencies: null, - allTransitiveDependencies: null, -); - -@Deprecated('Will be removed in 3.0. Use Ref instead') -// ignore: unused_element -typedef UserRepositoryRef = FutureProviderRef; -String _$forumRepositoryHash() => r'4e31756cf2e5f08d1bece9c0384065892634a1f1'; +String _$forumRepositoryHash() => r'085755f0b01a618f5faaf049e8ed0dfe66ae4989'; /// Provides the [ForumRepository]. /// @@ -44,5 +25,26 @@ final forumRepositoryProvider = FutureProvider.internal( @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element typedef ForumRepositoryRef = FutureProviderRef; +String _$userProgressRepositoryHash() => + r'd0097a2fea2156b62b890f7b0823fe893a865562'; + +/// Provides the [UserProgressRepository]. +/// +/// Copied from [userProgressRepository]. +@ProviderFor(userProgressRepository) +final userProgressRepositoryProvider = + FutureProvider.internal( + userProgressRepository, + name: r'userProgressRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$userProgressRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef UserProgressRepositoryRef = FutureProviderRef; // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/packages/core/lib/data/repositories/user_repository.dart b/packages/core/lib/data/repositories/user_progress_repository.dart similarity index 92% rename from packages/core/lib/data/repositories/user_repository.dart rename to packages/core/lib/data/repositories/user_progress_repository.dart index 8a74c9c6..fbf9cf1f 100644 --- a/packages/core/lib/data/repositories/user_repository.dart +++ b/packages/core/lib/data/repositories/user_progress_repository.dart @@ -1,15 +1,15 @@ import 'package:drift/drift.dart'; import '../db/app_database.dart'; -import 'package:core/data/data.dart'; +import '../models/user_progress_dto.dart'; import '../sources/data_source.dart'; /// Repository for user progress tracking. -class UserRepository { +class UserProgressRepository { final AppDatabase _db; final DataSource _source; - UserRepository(this._db, this._source); + UserProgressRepository(this._db, this._source); Stream> watchProgress(String userId) { return _db.watchProgressForUser(userId).map( diff --git a/packages/core/lib/data/sources/data_source.dart b/packages/core/lib/data/sources/data_source.dart index ad40dce2..1618bdfc 100644 --- a/packages/core/lib/data/sources/data_source.dart +++ b/packages/core/lib/data/sources/data_source.dart @@ -33,4 +33,9 @@ abstract class DataSource { /// Fetch courses specifically formatted for the Discovery/Explore page. Future> getDiscoveryCourses(); + /// Fetch the authenticated user's profile. + Future getProfile(); + + /// Update the authenticated user's profile with the given fields (supports multipart image updates). + Future updateProfile(Map data); } diff --git a/packages/core/lib/data/sources/data_source_provider.dart b/packages/core/lib/data/sources/data_source_provider.dart index 904f4016..c3e0e7c0 100644 --- a/packages/core/lib/data/sources/data_source_provider.dart +++ b/packages/core/lib/data/sources/data_source_provider.dart @@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../config/app_config.dart'; +import '../auth/auth_provider.dart'; import 'data_source.dart'; import 'mock_data_source.dart'; import 'http_data_source.dart'; @@ -15,5 +16,6 @@ DataSource dataSource(Ref ref) { if (AppConfig.useMockData) { return const MockDataSource(); } - return const HttpDataSource(); + + return HttpDataSource(getToken: () => ref.watch(authRepositoryProvider).getToken()); } diff --git a/packages/core/lib/data/sources/data_source_provider.g.dart b/packages/core/lib/data/sources/data_source_provider.g.dart index 9b55f996..ac5e3674 100644 --- a/packages/core/lib/data/sources/data_source_provider.g.dart +++ b/packages/core/lib/data/sources/data_source_provider.g.dart @@ -6,7 +6,7 @@ part of 'data_source_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$dataSourceHash() => r'a2598f99a41663a124db4e3da9fc59b49aafc8ca'; +String _$dataSourceHash() => r'f13ddf742fb7cd4159d0b9fdeb67e8cbc934ca5c'; /// Provides the active [DataSource] based on [AppConfig.useMockData]. /// Swap to real HTTP source by building with: --dart-define=USE_MOCK=false diff --git a/packages/core/lib/data/sources/http_data_source.dart b/packages/core/lib/data/sources/http_data_source.dart index 925c84bf..df797035 100644 --- a/packages/core/lib/data/sources/http_data_source.dart +++ b/packages/core/lib/data/sources/http_data_source.dart @@ -1,12 +1,22 @@ +import 'package:dio/dio.dart'; import 'package:core/data/data.dart'; -import 'data_source.dart'; +import '../../network/api_endpoints.dart'; +import '../../network/network_provider.dart'; +import '../../network/auth_interceptor.dart'; /// HTTP data source stub — to be implemented when a real backend is available. /// All methods throw [UnimplementedError] to surface accidental usage in tests. /// /// Activate via: flutter run --dart-define=USE_MOCK=false class HttpDataSource implements DataSource { - const HttpDataSource(); + final Future Function() getToken; + + HttpDataSource({required this.getToken}); + + late final Dio _dio = NetworkProvider.create() + ..interceptors.add( + AuthInterceptor(getToken), + ); @override Future> getCourses() => throw UnimplementedError( @@ -63,4 +73,36 @@ class HttpDataSource implements DataSource { throw UnimplementedError( 'HttpDataSource.getDiscoveryCourses is not yet implemented.', ); + + @override + Future getProfile() async { + return NetworkProvider.perform( + _dio.get(ApiEndpoints.userProfile), + fromJson: UserDto.fromJson, + ); + } + + @override + Future updateProfile( + Map data, + ) async { + final dynamic body; + // If the data contains a 'photo' key with a file path, we use FormData for multipart upload + if (data.containsKey('photo') && data['photo'] is String) { + final map = Map.from(data); + map['photo'] = await MultipartFile.fromFile( + map['photo'] as String, + filename: 'profile_image.jpg', + ); + body = FormData.fromMap(map); + } else { + body = data; + } + + return NetworkProvider.perform( + _dio.patch(ApiEndpoints.userProfile, data: body), + fromJson: UserDto.fromJson, + ); + } } + diff --git a/packages/core/lib/data/sources/mock_data.dart b/packages/core/lib/data/sources/mock_data.dart index 5539d2c8..63f1b344 100644 --- a/packages/core/lib/data/sources/mock_data.dart +++ b/packages/core/lib/data/sources/mock_data.dart @@ -1,9 +1,11 @@ import 'package:core/data/data.dart'; /// Mock current user -final mockCurrentUser = UserDto( +var mockCurrentUser = UserDto( id: 'user_1', name: 'Arjun Sharma', + firstName: 'Arjun', + lastName: 'Sharma', email: 'arjun.sharma@example.com', phone: '+91 9876543210', avatar: diff --git a/packages/core/lib/data/sources/mock_data_source.dart b/packages/core/lib/data/sources/mock_data_source.dart index 4b514bcf..de8a8f12 100644 --- a/packages/core/lib/data/sources/mock_data_source.dart +++ b/packages/core/lib/data/sources/mock_data_source.dart @@ -854,4 +854,29 @@ class MockDataSource implements DataSource { @override Future> getDiscoveryCourses() async => mockDiscoveryCourses; + + // ───────────────────────────────────────────────────────────────────────── + // User Profile + // ───────────────────────────────────────────────────────────────────────── + + @override + Future getProfile() async { + await Future.delayed(const Duration(milliseconds: 300)); + return mockCurrentUser; + } + + @override + Future updateProfile( + Map data, + ) async { + await Future.delayed(const Duration(milliseconds: 500)); + final updated = mockCurrentUser.copyWith( + name: data['display_name'] as String? ?? mockCurrentUser.name, + firstName: data['first_name'] as String? ?? mockCurrentUser.firstName, + lastName: data['last_name'] as String? ?? mockCurrentUser.lastName, + phone: data['phone'] as String? ?? mockCurrentUser.phone, + ); + mockCurrentUser = updated; + return updated; + } } diff --git a/packages/core/lib/generated/l10n/app_localizations.dart b/packages/core/lib/generated/l10n/app_localizations.dart index 10878ba8..a53366b2 100644 --- a/packages/core/lib/generated/l10n/app_localizations.dart +++ b/packages/core/lib/generated/l10n/app_localizations.dart @@ -2253,6 +2253,30 @@ abstract class AppLocalizations { /// **'Study Tips'** String get exploreFilterStudyTips; + /// No description provided for @editProfileFirstNameLabel. + /// + /// In en, this message translates to: + /// **'First Name'** + String get editProfileFirstNameLabel; + + /// No description provided for @editProfileFirstNameHint. + /// + /// In en, this message translates to: + /// **'Enter your first name'** + String get editProfileFirstNameHint; + + /// No description provided for @editProfileLastNameLabel. + /// + /// In en, this message translates to: + /// **'Last Name'** + String get editProfileLastNameLabel; + + /// No description provided for @editProfileLastNameHint. + /// + /// In en, this message translates to: + /// **'Enter your last name'** + String get editProfileLastNameHint; + /// No description provided for @labelMock. /// /// In en, this message translates to: diff --git a/packages/core/lib/generated/l10n/app_localizations_ar.dart b/packages/core/lib/generated/l10n/app_localizations_ar.dart index a8e04755..2a2b55d8 100644 --- a/packages/core/lib/generated/l10n/app_localizations_ar.dart +++ b/packages/core/lib/generated/l10n/app_localizations_ar.dart @@ -1191,6 +1191,18 @@ class AppLocalizationsAr extends AppLocalizations { @override String get exploreFilterStudyTips => 'نصائح الدراسة'; + @override + String get editProfileFirstNameLabel => 'الاسم الأول'; + + @override + String get editProfileFirstNameHint => 'أدخل اسمك الأول'; + + @override + String get editProfileLastNameLabel => 'اسم العائلة'; + + @override + String get editProfileLastNameHint => 'أدخل اسم عائلتك'; + @override String get labelMock => 'تجريبي'; diff --git a/packages/core/lib/generated/l10n/app_localizations_en.dart b/packages/core/lib/generated/l10n/app_localizations_en.dart index 1388ab66..6021bcb7 100644 --- a/packages/core/lib/generated/l10n/app_localizations_en.dart +++ b/packages/core/lib/generated/l10n/app_localizations_en.dart @@ -1193,6 +1193,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get exploreFilterStudyTips => 'Study Tips'; + @override + String get editProfileFirstNameLabel => 'First Name'; + + @override + String get editProfileFirstNameHint => 'Enter your first name'; + + @override + String get editProfileLastNameLabel => 'Last Name'; + + @override + String get editProfileLastNameHint => 'Enter your last name'; + @override String get labelMock => 'Mock'; diff --git a/packages/core/lib/generated/l10n/app_localizations_ml.dart b/packages/core/lib/generated/l10n/app_localizations_ml.dart index b9237d5d..be3fbe7d 100644 --- a/packages/core/lib/generated/l10n/app_localizations_ml.dart +++ b/packages/core/lib/generated/l10n/app_localizations_ml.dart @@ -1203,6 +1203,18 @@ class AppLocalizationsMl extends AppLocalizations { @override String get exploreFilterStudyTips => 'പഠന നുറുങ്ങുകൾ'; + @override + String get editProfileFirstNameLabel => 'ആദ്യ പേര്'; + + @override + String get editProfileFirstNameHint => 'നിങ്ങളുടെ ആദ്യ പേര് നൽകുക'; + + @override + String get editProfileLastNameLabel => 'അവസാന പേര്'; + + @override + String get editProfileLastNameHint => 'നിങ്ങളുടെ അവസാന പേര് നൽകുക'; + @override String get labelMock => 'മോക്ക്'; diff --git a/packages/core/lib/l10n/app_ar.arb b/packages/core/lib/l10n/app_ar.arb index 7c6ca84f..79383874 100644 --- a/packages/core/lib/l10n/app_ar.arb +++ b/packages/core/lib/l10n/app_ar.arb @@ -405,6 +405,10 @@ "exploreFilterShortLessons": "دروس قصيرة", "exploreFilterPopular": "شائع", "exploreFilterStudyTips": "نصائح الدراسة", + "editProfileFirstNameLabel": "الاسم الأول", + "editProfileFirstNameHint": "أدخل اسمك الأول", + "editProfileLastNameLabel": "اسم العائلة", + "editProfileLastNameHint": "أدخل اسم عائلتك", "labelMock": "تجريبي", "labelPractice": "ممارسة" } diff --git a/packages/core/lib/l10n/app_en.arb b/packages/core/lib/l10n/app_en.arb index 0a3c6fb9..e1454314 100644 --- a/packages/core/lib/l10n/app_en.arb +++ b/packages/core/lib/l10n/app_en.arb @@ -584,6 +584,10 @@ "exploreFilterShortLessons": "Short Lessons", "exploreFilterPopular": "Popular", "exploreFilterStudyTips": "Study Tips", + "editProfileFirstNameLabel": "First Name", + "editProfileFirstNameHint": "Enter your first name", + "editProfileLastNameLabel": "Last Name", + "editProfileLastNameHint": "Enter your last name", "labelMock": "Mock", "labelPractice": "Practice" } diff --git a/packages/core/lib/l10n/app_ml.arb b/packages/core/lib/l10n/app_ml.arb index ae5ae881..0dc7a9e6 100644 --- a/packages/core/lib/l10n/app_ml.arb +++ b/packages/core/lib/l10n/app_ml.arb @@ -405,6 +405,10 @@ "exploreFilterShortLessons": "ചെറിയ പാഠങ്ങൾ", "exploreFilterPopular": "ജനപ്രിയം", "exploreFilterStudyTips": "പഠന നുറുങ്ങുകൾ", + "editProfileFirstNameLabel": "ആദ്യ പേര്", + "editProfileFirstNameHint": "നിങ്ങളുടെ ആദ്യ പേര് നൽകുക", + "editProfileLastNameLabel": "അവസാന പേര്", + "editProfileLastNameHint": "നിങ്ങളുടെ അവസാന പേര് നൽകുക", "labelMock": "മോക്ക്", "labelPractice": "പ്രാക്ടീസ്" } diff --git a/packages/core/lib/network/api_endpoints.dart b/packages/core/lib/network/api_endpoints.dart index 6237b0f0..094f5220 100644 --- a/packages/core/lib/network/api_endpoints.dart +++ b/packages/core/lib/network/api_endpoints.dart @@ -6,4 +6,5 @@ class ApiEndpoints { static const String verifyOtp = '/api/v2.5/auth/otp-login/'; static const String logout = '/api/v2.5/auth/logout/'; static const String resetPassword = '/api/v2.3/password/reset/'; + static const String userProfile = '/api/v2.5/me/'; } diff --git a/packages/core/lib/network/auth_interceptor.dart b/packages/core/lib/network/auth_interceptor.dart new file mode 100644 index 00000000..569cc77f --- /dev/null +++ b/packages/core/lib/network/auth_interceptor.dart @@ -0,0 +1,21 @@ +import 'package:dio/dio.dart'; + +/// Attaches the JWT authentication token to the Authorization header. +/// Fetches the token asynchronously from storage to ensure it's always fresh. +class AuthInterceptor extends Interceptor { + final Future Function() getToken; + + const AuthInterceptor(this.getToken); + + @override + void onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + final token = await getToken(); + if (token != null && token.isNotEmpty) { + options.headers['Authorization'] = 'JWT $token'; + } + handler.next(options); + } +} diff --git a/packages/core/lib/network/network_provider.dart b/packages/core/lib/network/network_provider.dart index 484d2327..400e9ff6 100644 --- a/packages/core/lib/network/network_provider.dart +++ b/packages/core/lib/network/network_provider.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import '../data/config/app_config.dart'; +import '../data/exceptions/api_exception.dart'; import 'user_agent_interceptor.dart'; class NetworkProvider { @@ -22,4 +23,20 @@ class NetworkProvider { return dio; } + + /// Orchestrates a network request with standardized error handling. + /// Converts [DioException] into our semantic [ApiException]. + static Future perform( + Future>> request, { + required T Function(Map) fromJson, + }) async { + try { + final response = await request; + return fromJson(response.data ?? {}); + } on DioException catch (error) { + throw ApiException.fromDio(error); + } catch (e) { + throw ApiException('An unexpected error occurred: $e'); + } + } } diff --git a/packages/core/lib/network/user_agent_helper.dart b/packages/core/lib/network/user_agent_helper.dart index fae4c6b3..53be15fe 100644 --- a/packages/core/lib/network/user_agent_helper.dart +++ b/packages/core/lib/network/user_agent_helper.dart @@ -17,17 +17,17 @@ class UserAgentHelper { if (Platform.isAndroid) { final androidInfo = await deviceInfo.androidInfo; - return '${androidInfo.model} (Android ${androidInfo.version.release}) v$appVersion'; + return 'android-app/$appVersion (${androidInfo.model}; Android ${androidInfo.version.release})'; } - + if (Platform.isIOS) { final iosInfo = await deviceInfo.iosInfo; - return 'ios-app ${iosInfo.utsname.machine} (iOS ${iosInfo.systemVersion}) v$appVersion'; + return 'ios-app/$appVersion (${iosInfo.utsname.machine}; iOS ${iosInfo.systemVersion})'; } - - return 'ios-app ($Platform) v$appVersion'; + + return 'flutter-app/$appVersion ($Platform)'; } catch (_) { - return 'ios-app/1.0.0'; + return 'flutter-app/1.0.0'; } } } diff --git a/packages/core/lib/widgets/app_button.dart b/packages/core/lib/widgets/app_button.dart index ec8d1346..ad142d40 100644 --- a/packages/core/lib/widgets/app_button.dart +++ b/packages/core/lib/widgets/app_button.dart @@ -4,6 +4,7 @@ import '../navigation/app_route.dart'; import '../accessibility/app_semantics.dart'; import '../accessibility/app_focusable.dart'; import 'app_text.dart'; +import 'app_loading_indicator.dart'; /// Platform-neutral button widget with semantic variants. class AppButton extends StatelessWidget { @@ -22,6 +23,7 @@ class AppButton extends StatelessWidget { this.trailing, this.borderColor, this.labelStyle, + this.loading = false, }); final String label; @@ -37,6 +39,7 @@ class AppButton extends StatelessWidget { final Widget? leading; final Widget? trailing; final TextStyle? labelStyle; + final bool loading; // Semantic constructors const AppButton.primary({ @@ -53,6 +56,7 @@ class AppButton extends StatelessWidget { this.trailing, this.borderColor, this.labelStyle, + this.loading = false, }) : variant = AppButtonVariant.primary; const AppButton.secondary({ @@ -69,12 +73,13 @@ class AppButton extends StatelessWidget { this.trailing, this.borderColor, this.labelStyle, + this.loading = false, }) : variant = AppButtonVariant.secondary; @override Widget build(BuildContext context) { final design = Design.of(context); - final isDisabled = onPressed == null && onNavigate == null; + final isDisabled = (onPressed == null && onNavigate == null) || loading; final effectiveBackgroundColor = backgroundColor ?? @@ -134,23 +139,42 @@ class AppButton extends StatelessWidget { color: effectiveForegroundColor, size: design.iconSize.md, ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, + child: Stack( + alignment: Alignment.center, children: [ - if (leading != null) ...[ - leading!, - SizedBox(width: design.spacing.sm), - ], - AppText.labelBold( - label, - color: effectiveForegroundColor, - style: labelStyle, + // Original Content (Hidden but keeps size if not loading) + Opacity( + opacity: loading ? 0.0 : 1.0, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + if (leading != null) ...[ + leading!, + SizedBox(width: design.spacing.sm), + ], + AppText.labelBold( + label, + color: effectiveForegroundColor, + style: labelStyle, + ), + if (trailing != null) ...[ + SizedBox(width: design.spacing.sm), + trailing!, + ], + ], + ), ), - if (trailing != null) ...[ - SizedBox(width: design.spacing.sm), - trailing!, - ], + + // Loading Indicator + if (loading) + SizedBox( + height: 20, + width: 20, + child: AppLoadingIndicator( + color: effectiveForegroundColor, + ), + ), ], ), ), diff --git a/packages/core/lib/widgets/app_loading_indicator.dart b/packages/core/lib/widgets/app_loading_indicator.dart index f4b05620..5318b1a8 100644 --- a/packages/core/lib/widgets/app_loading_indicator.dart +++ b/packages/core/lib/widgets/app_loading_indicator.dart @@ -2,7 +2,9 @@ import 'package:flutter/widgets.dart'; import '../design/design_provider.dart'; class AppLoadingIndicator extends StatefulWidget { - const AppLoadingIndicator({super.key}); + final Color? color; + + const AppLoadingIndicator({super.key, this.color}); @override State createState() => _AppLoadingIndicatorState(); @@ -38,7 +40,9 @@ class _AppLoadingIndicatorState extends State turns: _controller, child: CustomPaint( size: const Size(20, 20), - painter: _LoadingIndicatorPainter(color: design.colors.primary), + painter: _LoadingIndicatorPainter( + color: widget.color ?? design.colors.primary, + ), ), ), ), diff --git a/packages/courses/lib/providers/recent_activity_provider.dart b/packages/courses/lib/providers/recent_activity_provider.dart index 90b39085..64d878bf 100644 --- a/packages/courses/lib/providers/recent_activity_provider.dart +++ b/packages/courses/lib/providers/recent_activity_provider.dart @@ -1,5 +1,4 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; - import 'package:core/data/data.dart'; import 'course_list_provider.dart'; @@ -26,15 +25,18 @@ class RecentActivityVo { /// Provider for the most recently accessed lesson (for the Resume card). @riverpod Stream recentActivity(RecentActivityRef ref) async* { - final userRepo = await ref.watch(userRepositoryProvider.future); + final userProgressRepo = await ref.watch(userProgressRepositoryProvider.future); final courseRepo = await ref.watch(courseRepositoryProvider.future); - final isLoggedIn = ref.watch(authProvider).asData?.value ?? false; - if (!isLoggedIn) { + + final userIdStream = ref.watch(userIdProvider); + final userId = userIdStream.value; + + if (userId == null) { yield null; return; } - yield* userRepo.watchProgress(mockCurrentUser.id).asyncMap((list) async { + yield* userProgressRepo.watchProgress(userId).asyncMap((list) async { if (list.isEmpty) return null; // Sort by lastAccessedAt descending diff --git a/packages/courses/lib/providers/recent_activity_provider.g.dart b/packages/courses/lib/providers/recent_activity_provider.g.dart index a80420f3..56818d85 100644 --- a/packages/courses/lib/providers/recent_activity_provider.g.dart +++ b/packages/courses/lib/providers/recent_activity_provider.g.dart @@ -6,7 +6,7 @@ part of 'recent_activity_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$recentActivityHash() => r'a07d62021414035d098cfc4ad9c0b1adaeb99247'; +String _$recentActivityHash() => r'dd30320375d550af00aa965be15aa01bb4a2fd26'; /// Provider for the most recently accessed lesson (for the Resume card). /// diff --git a/packages/profile/lib/profile.dart b/packages/profile/lib/profile.dart index 2f30cfe0..07ec0fdb 100644 --- a/packages/profile/lib/profile.dart +++ b/packages/profile/lib/profile.dart @@ -14,6 +14,7 @@ export 'screens/certificate_preview_screen.dart'; export 'screens/app_settings_screen.dart'; export 'screens/edit_profile_screen.dart'; export 'providers/profile_providers.dart'; +export 'providers/user_provider.dart'; export 'providers/notification_preferences_provider.dart'; export 'providers/certificates_provider.dart'; export 'providers/settings_providers.dart'; diff --git a/packages/profile/lib/providers/profile_providers.dart b/packages/profile/lib/providers/profile_providers.dart index 5e6f33d1..b3e59e41 100644 --- a/packages/profile/lib/providers/profile_providers.dart +++ b/packages/profile/lib/providers/profile_providers.dart @@ -1,11 +1,19 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:core/data/data.dart'; +import '../repositories/user_repository.dart'; import '../data/profile_mock_data.dart'; import '../models/recent_activity_dto.dart'; part 'profile_providers.g.dart'; +@riverpod +Future userRepository(Ref ref) async { + final db = await ref.watch(appDatabaseProvider.future); + final source = ref.watch(dataSourceProvider); + return UserRepository(db, source); +} + @riverpod Future> profileRecentActivity(Ref ref) async { await Future.delayed(const Duration(milliseconds: 400)); diff --git a/packages/profile/lib/providers/profile_providers.g.dart b/packages/profile/lib/providers/profile_providers.g.dart index 705d5bda..68819029 100644 --- a/packages/profile/lib/providers/profile_providers.g.dart +++ b/packages/profile/lib/providers/profile_providers.g.dart @@ -6,6 +6,24 @@ part of 'profile_providers.dart'; // RiverpodGenerator // ************************************************************************** +String _$userRepositoryHash() => r'dd0b45d11cae8f7ff468fbbc0d89d30eb9c8eb33'; + +/// See also [userRepository]. +@ProviderFor(userRepository) +final userRepositoryProvider = + AutoDisposeFutureProvider.internal( + userRepository, + name: r'userRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$userRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef UserRepositoryRef = AutoDisposeFutureProviderRef; String _$profileRecentActivityHash() => r'e70b9a9072f04f1b120c0a1eb7c80ee239b04cc8'; diff --git a/packages/profile/lib/providers/user_provider.dart b/packages/profile/lib/providers/user_provider.dart new file mode 100644 index 00000000..02c08b4d --- /dev/null +++ b/packages/profile/lib/providers/user_provider.dart @@ -0,0 +1,48 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:core/data/db/app_database.dart'; +import 'package:core/data/auth/auth_provider.dart'; +import 'profile_providers.dart'; + +part 'user_provider.g.dart'; + +/// Reactive provider that exposes the current user's profile metadata from the database. +/// Automatically triggers a background refresh from the network whenever it's watched. +@riverpod +Stream user(UserRef ref) async* { + final userRepository = await ref.watch(userRepositoryProvider.future); + final isLoggedIn = ref.watch(authProvider).asData?.value ?? false; + + if (!isLoggedIn) { + yield null; + return; + } + + // Refresh the profile in the background - the stream will update once it's saved. + userRepository.refreshProfile().ignore(); + + yield* userRepository.watchCurrentUser(); +} + +/// Controller used to trigger profile-related actions like updates. +@riverpod +class UserActionsController extends _$UserActionsController { + @override + void build() {} + + /// Persists profile updates to the backend and updates the local cache. + Future updateProfile({ + String? firstName, + String? lastName, + String? phone, + String? photo, + }) async { + final userRepository = await ref.read(userRepositoryProvider.future); + + await userRepository.updateProfile({ + if (firstName != null) 'first_name': firstName, + if (lastName != null) 'last_name': lastName, + if (phone != null) 'phone': phone, + if (photo != null) 'photo': photo, + }); + } +} diff --git a/packages/profile/lib/providers/user_provider.g.dart b/packages/profile/lib/providers/user_provider.g.dart new file mode 100644 index 00000000..a65fb9b5 --- /dev/null +++ b/packages/profile/lib/providers/user_provider.g.dart @@ -0,0 +1,49 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'user_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$userHash() => r'bb6951f483be1b4a94b5488905ddf3d83a7a73e6'; + +/// Reactive provider that exposes the current user's profile metadata from the database. +/// Automatically triggers a background refresh from the network whenever it's watched. +/// +/// Copied from [user]. +@ProviderFor(user) +final userProvider = AutoDisposeStreamProvider.internal( + user, + name: r'userProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$userHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef UserRef = AutoDisposeStreamProviderRef; +String _$userActionsControllerHash() => + r'd13e232a5b1ed0d9e549f75bf621a6339d831916'; + +/// Controller used to trigger profile-related actions like updates. +/// +/// Copied from [UserActionsController]. +@ProviderFor(UserActionsController) +final userActionsControllerProvider = + AutoDisposeNotifierProvider.internal( + UserActionsController.new, + name: r'userActionsControllerProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$userActionsControllerHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$UserActionsController = AutoDisposeNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/packages/profile/lib/repositories/user_repository.dart b/packages/profile/lib/repositories/user_repository.dart new file mode 100644 index 00000000..34ec55c4 --- /dev/null +++ b/packages/profile/lib/repositories/user_repository.dart @@ -0,0 +1,32 @@ +import 'package:core/data/data.dart'; + +/// Repository for managing user profile identity and metadata. +class UserRepository { + final AppDatabase _db; + final DataSource _source; + + UserRepository(this._db, this._source); + + /// Provides a reactive stream of the logged-in user profile from the database. + Stream watchCurrentUser() { + return _db.select(_db.usersTable).watchSingleOrNull(); + } + + /// Fetches the cached profile metadata from the local database. + Future getCurrentProfile() async { + return _db.select(_db.usersTable).getSingleOrNull(); + } + + /// Fetches the profile from the network and updates the local cache. + Future refreshProfile() async { + final user = await _source.getProfile(); + await _db.upsertUser(user.toCompanion()); + return user; + } + + /// Persists profile changes to the backend and updates the local cache. + Future updateProfile(Map data) async { + final updated = await _source.updateProfile(data); + await _db.upsertUser(updated.toCompanion()); + } +} diff --git a/packages/profile/lib/screens/edit_profile_screen.dart b/packages/profile/lib/screens/edit_profile_screen.dart index a39a52bb..d24d5ad0 100644 --- a/packages/profile/lib/screens/edit_profile_screen.dart +++ b/packages/profile/lib/screens/edit_profile_screen.dart @@ -1,8 +1,12 @@ +import 'package:flutter/foundation.dart' show Uint8List; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:core/core.dart'; import 'package:core/data/data.dart'; +import '../providers/user_provider.dart'; + const double _kHeaderContentHeight = 60.0; class EditProfileScreen extends ConsumerStatefulWidget { @@ -13,50 +17,82 @@ class EditProfileScreen extends ConsumerStatefulWidget { } class _EditProfileScreenState extends ConsumerState { - late TextEditingController _nameController; + late TextEditingController _firstNameController; + late TextEditingController _lastNameController; late TextEditingController _emailController; late TextEditingController _phoneController; - String? _nameError; + String? _firstNameError; + String? _lastNameError; + String? _selectedAvatarPath; + Uint8List? _selectedAvatarBytes; + bool _isSaving = false; + String? _errorMessage; @override void initState() { super.initState(); - final isLoggedIn = ref.read(authProvider).asData?.value ?? false; - final user = isLoggedIn ? mockCurrentUser : null; - if (user != null) { - _nameController = TextEditingController(text: user.name); - _emailController = TextEditingController(text: user.email ?? ''); - _phoneController = TextEditingController(text: user.phone ?? ''); - } else { - _nameController = TextEditingController(); - _emailController = TextEditingController(); - _phoneController = TextEditingController(); + final user = ref.read(userProvider).value; + + _firstNameController = TextEditingController(text: user?.firstName ?? ''); + _lastNameController = TextEditingController(text: user?.lastName ?? ''); + _emailController = TextEditingController(text: user?.email ?? ''); + _phoneController = TextEditingController(text: user?.phone ?? ''); + } + + Future _pickAndUploadImage() async { + final picker = ImagePicker(); + final XFile? image = await picker.pickImage(source: ImageSource.gallery); + + if (image != null) { + final bytes = await image.readAsBytes(); + setState(() { + _selectedAvatarPath = image.path; + _selectedAvatarBytes = bytes; + }); } } @override void dispose() { - _nameController.dispose(); + _firstNameController.dispose(); + _lastNameController.dispose(); _emailController.dispose(); _phoneController.dispose(); super.dispose(); } - void _validateAndSave() { + void _validateAndSave() async { final l10n = L10n.of(context); setState(() { - _nameError = _nameController.text.trim().isEmpty + _firstNameError = _firstNameController.text.trim().isEmpty + ? l10n.editProfileErrorNameEmpty + : null; + _lastNameError = _lastNameController.text.trim().isEmpty ? l10n.editProfileErrorNameEmpty : null; }); - if (_nameError == null) { + if (_firstNameError == null && _lastNameError == null) { final isLoggedIn = ref.read(authProvider).asData?.value ?? false; if (!isLoggedIn) return; - - // Note: In an auth-only branch, we defer reactive updates to UserRepository. - // For now, we're simply popping back. - context.pop(true); + + if (_isSaving) return; + setState(() => _isSaving = true); + + try { + await ref.read(userActionsControllerProvider.notifier).updateProfile( + firstName: _firstNameController.text.trim(), + lastName: _lastNameController.text.trim(), + phone: _phoneController.text.trim(), + photo: _selectedAvatarPath, + ); + + if (mounted) context.pop(true); + } catch (e) { + if (mounted) setState(() => _errorMessage = e.toString()); + } finally { + if (mounted) setState(() => _isSaving = false); + } } } @@ -84,16 +120,34 @@ class _EditProfileScreenState extends ConsumerState { ), children: [ AppText.headline(l10n.editProfileTitle), + if (_errorMessage != null) ...[ + SizedBox(height: design.spacing.md), + AppText.bodySmall(_errorMessage!, color: design.colors.error), + ], SizedBox(height: design.spacing.xl), _buildAvatarSection(design, l10n), SizedBox(height: design.spacing.xxl), AppTextField( - label: l10n.editProfileNameLabel, - hintText: l10n.editProfileNameHint, - controller: _nameController, - errorText: _nameError, + label: l10n.editProfileFirstNameLabel, + hintText: l10n.editProfileFirstNameHint, + controller: _firstNameController, + errorText: _firstNameError, + onChanged: (_) { + if (_firstNameError != null) { + setState(() => _firstNameError = null); + } + }, + ), + SizedBox(height: design.spacing.lg), + AppTextField( + label: l10n.editProfileLastNameLabel, + hintText: l10n.editProfileLastNameHint, + controller: _lastNameController, + errorText: _lastNameError, onChanged: (_) { - if (_nameError != null) setState(() => _nameError = null); + if (_lastNameError != null) { + setState(() => _lastNameError = null); + } }, ), SizedBox(height: design.spacing.lg), @@ -174,6 +228,7 @@ class _EditProfileScreenState extends ConsumerState { child: AppButton( label: l10n.editProfileSave, onPressed: _validateAndSave, + loading: _isSaving, padding: EdgeInsets.symmetric(horizontal: design.spacing.lg), backgroundColor: design.colors.accent2, foregroundColor: design.colors.onPrimary, @@ -187,9 +242,8 @@ class _EditProfileScreenState extends ConsumerState { } Widget _buildAvatarSection(DesignConfig design, dynamic l10n) { - final isLoggedIn = ref.watch(authProvider).asData?.value ?? false; - if (!isLoggedIn) return const SizedBox.shrink(); - final user = mockCurrentUser; + final user = ref.watch(userProvider).value; + if (user == null) return const SizedBox.shrink(); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -198,57 +252,66 @@ class _EditProfileScreenState extends ConsumerState { SizedBox(height: design.spacing.sm), Row( children: [ - Stack( - children: [ - Container( - width: 80, - height: 80, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: design.colors.accent2, - ), - clipBehavior: Clip.antiAlias, - child: user.avatar != null && user.avatar!.isNotEmpty - ? Image.network( - user.avatar!, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => _buildInitialsAvatar(user.name, design), - ) - : _buildInitialsAvatar(user.name, design), - ), - Positioned( - bottom: 0, - right: 0, - child: Container( - width: 28, - height: 28, + GestureDetector( + onTap: _pickAndUploadImage, + child: Stack( + children: [ + Container( + width: 80, + height: 80, decoration: BoxDecoration( - color: design.colors.accent2, shape: BoxShape.circle, - border: Border.all(color: design.colors.card, width: 2), - boxShadow: [ - BoxShadow( - color: design.colors.shadow, - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], + color: design.colors.accent2, ), - child: Center( - child: Icon( - LucideIcons.camera, - size: 14, - color: design.colors.onPrimary, + clipBehavior: Clip.antiAlias, + child: _selectedAvatarBytes != null + ? Image.memory( + _selectedAvatarBytes!, + fit: BoxFit.cover, + ) + : (user.avatar != null && user.avatar!.isNotEmpty + ? Image.network( + user.avatar!, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + _buildInitialsAvatar(user.name ?? '', design), + ) + : _buildInitialsAvatar(user.name ?? '', design)), + ), + Positioned( + bottom: 0, + right: 0, + child: Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: design.colors.accent2, + shape: BoxShape.circle, + border: Border.all(color: design.colors.card, width: 2), + boxShadow: [ + BoxShadow( + color: design.colors.shadow, + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Center( + child: Icon( + LucideIcons.camera, + size: 14, + color: design.colors.onPrimary, + ), ), ), ), - ), - ], + ], + ), ), SizedBox(width: design.spacing.lg), AppButton( label: l10n.editProfileChangePhoto, - onPressed: () {}, + onPressed: _pickAndUploadImage, backgroundColor: design.colors.surfaceVariant, foregroundColor: design.colors.textPrimary, height: 36, diff --git a/packages/profile/lib/screens/paid_active_profile_screen.dart b/packages/profile/lib/screens/paid_active_profile_screen.dart index 1155dcd2..5483d315 100644 --- a/packages/profile/lib/screens/paid_active_profile_screen.dart +++ b/packages/profile/lib/screens/paid_active_profile_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:core/core.dart'; import 'package:core/data/data.dart'; import '../providers/profile_providers.dart'; +import '../providers/user_provider.dart'; import '../widgets/paid_active_profile_header.dart'; import '../widgets/paid_active_profile_snapshot.dart'; import '../widgets/paid_active_enrolled_courses_section.dart'; @@ -30,7 +31,7 @@ class ProfilePage extends ConsumerWidget { final isLoggedIn = ref.watch(authProvider).asData?.value ?? false; if (!isLoggedIn) return const SizedBox.shrink(); - final user = mockCurrentUser; + final userAsync = ref.watch(userProvider); final statsAsync = ref.watch(studyMomentumProvider); final enrolledCoursesAsync = ref.watch(profileEnrollmentProvider); final recentActivityAsync = ref.watch(profileRecentActivityProvider); @@ -56,13 +57,17 @@ class ProfilePage extends ConsumerWidget { SizedBox(height: design.spacing.md), // Profile Header Area - ProfileHeader( - name: user.name, - avatarUrl: user.avatar, - joinedDate: user.joinedDate, - onEditProfileTap: - onEditProfile ?? - () => context.pushNamed('profile-edit'), + userAsync.when( + data: (user) => ProfileHeader( + name: user?.name ?? '', + avatarUrl: user?.avatar ?? '', + joinedDate: user?.joinedDate, + onEditProfileTap: + onEditProfile ?? + () => context.pushNamed('profile-edit'), + ), + loading: () => const SizedBox(height: 100), + error: (err, __) => const SizedBox(height: 100), ), SizedBox(height: design.spacing.xl), diff --git a/packages/profile/pubspec.yaml b/packages/profile/pubspec.yaml index 45c622f7..79e9294f 100644 --- a/packages/profile/pubspec.yaml +++ b/packages/profile/pubspec.yaml @@ -16,6 +16,7 @@ dependencies: riverpod_annotation: ^2.3.5 drift: ^2.21.0 intl: ^0.20.2 + image_picker: ^1.1.2 dev_dependencies: flutter_test: diff --git a/packages/testpress/lib/providers/initialization_provider.dart b/packages/testpress/lib/providers/initialization_provider.dart index 6ba33941..ff75cc8c 100644 --- a/packages/testpress/lib/providers/initialization_provider.dart +++ b/packages/testpress/lib/providers/initialization_provider.dart @@ -1,6 +1,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:core/data/data.dart'; import 'package:courses/courses.dart'; +import 'package:profile/profile.dart'; part 'initialization_provider.g.dart'; @@ -9,6 +10,7 @@ part 'initialization_provider.g.dart'; @riverpod Future appInitialization(AppInitializationRef ref) async { final userRepo = await ref.watch(userRepositoryProvider.future); + final userProgressRepo = await ref.watch(userProgressRepositoryProvider.future); final courseRepo = await ref.watch(courseRepositoryProvider.future); final isLoggedIn = ref.watch(authProvider).asData?.value ?? false; @@ -28,10 +30,11 @@ Future appInitialization(AppInitializationRef ref) async { } } - // 3. Refresh user progress to see what was recently completed + // 3. Refresh user profile and progress to see what was recently completed // This allows the Resume Card to find the most recent lesson in the fully-populated DB. if (isLoggedIn) { - await userRepo.refreshProgress(mockCurrentUser.id); + final user = await userRepo.refreshProfile(); + await userProgressRepo.refreshProgress(user.id); } } catch (e) { // Initialization errors are handled here or surfaced to the listener diff --git a/packages/testpress/lib/providers/initialization_provider.g.dart b/packages/testpress/lib/providers/initialization_provider.g.dart index ac768466..84b01b4f 100644 --- a/packages/testpress/lib/providers/initialization_provider.g.dart +++ b/packages/testpress/lib/providers/initialization_provider.g.dart @@ -6,7 +6,7 @@ part of 'initialization_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$appInitializationHash() => r'ffbf6433da0212f2a952a8c3a15def2f5c07d100'; +String _$appInitializationHash() => r'fb8a568a4ee1e9cb84a8745482bae57161161a1e'; /// Provider that handles app-wide data initialization and refresh logic. /// This prevents side effects within UI-driven data providers. diff --git a/packages/testpress/lib/screens/dashboard/paid_active_home_screen.dart b/packages/testpress/lib/screens/dashboard/paid_active_home_screen.dart index 5e704807..c2a435b7 100644 --- a/packages/testpress/lib/screens/dashboard/paid_active_home_screen.dart +++ b/packages/testpress/lib/screens/dashboard/paid_active_home_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart' show Scaffold; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:core/core.dart'; import 'package:core/data/data.dart' as dto; +import 'package:profile/profile.dart'; import 'package:courses/courses.dart'; class PaidActiveHomeScreen extends ConsumerWidget { @@ -16,8 +17,9 @@ class PaidActiveHomeScreen extends ConsumerWidget { final pendingAssignments = ref.watch(pendingAssignmentsProvider); final upcomingTests = ref.watch(upcomingTestsProvider); final momentum = ref.watch(dto.studyMomentumProvider); - // For now, we assume if we are on this screen, we are logged in. - final user = dto.mockCurrentUser; + + final userAsync = ref.watch(userProvider); + final heroBanners = ref.watch(heroBannersProvider); final promotionBanners = ref.watch(promotionBannersProvider); final topLearners = ref.watch(topLearnersProvider); @@ -43,7 +45,13 @@ class PaidActiveHomeScreen extends ConsumerWidget { child: AppScroll( padding: EdgeInsets.symmetric(vertical: design.spacing.md), children: [ - HomeGreetingSection(userName: user.name), + userAsync.when( + data: (user) => HomeGreetingSection( + userName: user?.name ?? '', + ), + loading: () => const HomeGreetingSection(userName: '...'), + error: (e, s) => const HomeGreetingSection(userName: ''), + ), heroBanners.when( data: (data) => HeroBannerCarousel(