diff --git a/openspec/changes/integrate-course-api/.openspec.yaml b/openspec/changes/integrate-course-api/.openspec.yaml new file mode 100644 index 00000000..4e618347 --- /dev/null +++ b/openspec/changes/integrate-course-api/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-19 diff --git a/openspec/changes/integrate-course-api/design.md b/openspec/changes/integrate-course-api/design.md new file mode 100644 index 00000000..0075788f --- /dev/null +++ b/openspec/changes/integrate-course-api/design.md @@ -0,0 +1,44 @@ +## Context + +The Study tab previously showed static mock data. This change replaces it with live data from the Testpress API, using an offline-first pattern: fetch from API → upsert into Drift → UI observes Drift stream. + +## Goals / Non-Goals + +**Goals:** +- Fetch and display real courses from `https://lmsdemo.testpress.in/api/v3/courses/`. +- Persist fetched courses in local Drift DB as the single source of truth for the UI. +- Support infinite scroll with API-driven pagination. +- Show a full-screen loader only when the local cache is completely empty (first ever visit). +- Display cached data immediately on subsequent tab visits. +- Only fetch data after the user is authenticated. + +**Non-Goals:** +- Migrating `totalDuration` to `totalContents` (deferred to a separate change). +- Updating other tabs (Explore, Home) to use live data. + +## Decisions + +### 1. Centralized Networking +All HTTP communication goes through `NetworkProvider` (in `network_provider.dart`), which encapsulates the underlying `Dio` instance to prevent library leakage. It uses a **list-based interceptor injection** model rather than rigid factories. Real-world features like authentication are enabled by explicitly plugging in an **`AuthInterceptor`** during instantiation. The `AuthInterceptor` accepts a **getToken callback** rather than a direct dependency on local storage, decoupling the networking layer from storage implementation details. It standardizes headers (including `User-Agent` and `Authorization`) and translates errors into `ApiException`. + +### 2. Pagination Strategy +The `CourseList` notifier manages session state (`nextPage`, `hasMore`) using a reusable **`PaginationService`**. The service extracts the next page number from the API's `next` field. The UI triggers `loadMore()` via the notifier when the scroll position is within 500px of the list bottom. + +### 3. Caching and Loading Behavior +- **First visit with empty cache**: Show `AppLoadingIndicator` while the first page loads. +- **Revisit with cached data**: Render cached courses instantly from Drift. Background sync happens silently. +- **Pagination loader**: A small bottom spinner appears while fetching subsequent pages. +- **Smart Parsing**: `CourseDto` handles both raw API (snake_case) and internal (camelCase) formats, while `PaginatedResponseDto` isolates backend-specific quirks (like nested Testpress result maps). + +### 4. Dev Auth +`AuthProvider` accepts the credentials "222"/"222" for development access. Centered on `NetworkProvider`, the authenticated Dio instance includes the `Authorization` header for all requests, enabling authenticated API calls without requiring a full production login flow at this stage. + +### 5. Repository Sync Flow +The **`CourseList`** notifier handles the business logic for syncing: +1. Guard against concurrent calls (internal `_activeSync` Future). +2. Call the stateless `CourseRepository.fetchAndPersistCourses(page)`. +3. Repository: Fetches from the API and upserts results into Drift. +4. Notifier: Uses `PaginationService` to update its state (`nextPage`, `hasMore`) based on the API response. + +### 6. Domain Isolation +As part of architectural refinement, all course-specific domain models (`CourseDto`, `ChapterDto`, `LessonDto`, etc.) are moved from `package:core` to their respective domain package: `package:courses`. This ensures that `package:core` remains a lean platform SDK (Design System, Networking, etc.) and is not polluted with domain-specific entities. diff --git a/openspec/changes/integrate-course-api/proposal.md b/openspec/changes/integrate-course-api/proposal.md new file mode 100644 index 00000000..e437c563 --- /dev/null +++ b/openspec/changes/integrate-course-api/proposal.md @@ -0,0 +1,24 @@ +# Proposal: Integrate Course List API + +Integrate the real course list API into the Study tab, replacing mock data with an offline-first repository implementation. + +## Motivation + +The Study tab was displaying static mock data. This change wires up the real Testpress backend (`https://lmsdemo.testpress.in/api/v3/courses/`) while preserving the offline-first architecture (Drift/SQLite cache). + +## What Changes + +1. **Networking**: Introduce `NetworkProvider` and `ApiException` in `packages/core/lib/network` to centralize all HTTP logic. `HttpDataSource.getCourses` uses these to fetch paginated data from the Testpress API. +2. **Authentication**: The app transitions to an authenticated state upon successful login via the Testpress API. All API requests include an `Authorization` header via `NetworkProvider`. +3. **Pagination**: `CourseRepository.refreshCourses` tracks `_nextPage` and `_hasMore` using the API's `next` field. The UI triggers the next fetch when the user scrolls near the bottom of the list. +4. **Caching**: The Study tab shows cached courses from Drift immediately on revisit. A full-screen loader only appears on the very first visit when the local database is empty. + +## Scoped Out + +- `totalDuration` → `totalContents` field migration is deferred to a separate change. +- Other tabs (Explore, Home) are not wired to live data in this change. + +## Impact + +- `packages/core`: `HttpDataSource`, `NetworkProvider`, `ApiException`, `PaginatedResponseDto`, `AuthProvider`. +- `packages/courses`: `CourseRepository`, `StudyScreen`, `course_list_provider`. diff --git a/openspec/changes/integrate-course-api/specs/course-api/spec.md b/openspec/changes/integrate-course-api/specs/course-api/spec.md new file mode 100644 index 00000000..a985dd6b --- /dev/null +++ b/openspec/changes/integrate-course-api/specs/course-api/spec.md @@ -0,0 +1,61 @@ +## Requirements + +### Real Course API Integration +The system SHALL fetch real course data from `https://lmsdemo.testpress.in/api/v3/courses/` and persist it into the local Drift database. + +#### Scenario: Fetching courses on Study tab entry +- **WHEN** the user is authenticated and opens the Study tab +- **THEN** the system makes a GET request to `/api/v3/courses/` +- **AND** the response is mapped to `CourseDto` and upserted into the Drift `CoursesTable` +- **AND** the UI observes the Drift stream and reflects the updated data + +--- + +### Paginated Fetching +The system SHALL support incremental loading of courses. + +#### Scenario: First page load +- **WHEN** the user opens the Study tab for the first time in a session +- **THEN** the system fetches page 1 (`page=1&page_size=10`) + +#### Scenario: Loading subsequent pages +- **WHEN** the user scrolls to within 500px of the bottom of the course list +- **AND** there are more pages available (API `next` field is not null) +- **THEN** the system fetches the next page and appends results to the Drift DB + +--- + +### Loading Experience +The system SHALL surface loading state appropriately without blocking the UI unnecessarily. + +#### Scenario: First visit with empty cache +- **WHEN** the user opens the Study tab AND no courses are in the local DB +- **AND** the initial sync is in progress +- **THEN** the UI shows a full-screen `AppLoadingIndicator` + +#### Scenario: Revisiting with cached data +- **WHEN** the user navigates back to the Study tab +- **AND** courses already exist in the Drift DB +- **THEN** the UI immediately shows cached courses with no blocking loader + +#### Scenario: Pagination in progress +- **WHEN** a next-page fetch is in progress +- **THEN** a small loader appears at the bottom of the course list + +--- + +### Auth Gate +The system SHALL NOT fetch courses before the user is authenticated. + +#### Scenario: Unauthenticated access +- **WHEN** the user is not logged in +- **THEN** no request is made to `/api/v3/courses/` + +--- + +### Authorization Header +All API requests SHALL include the `Authorization` header. + +#### Scenario: Making an authenticated API call +- **WHEN** `NetworkProvider` makes any HTTP request +- **THEN** the `Authorization` header is present on the request diff --git a/openspec/changes/integrate-course-api/tasks.md b/openspec/changes/integrate-course-api/tasks.md new file mode 100644 index 00000000..8a4c75f6 --- /dev/null +++ b/openspec/changes/integrate-course-api/tasks.md @@ -0,0 +1,32 @@ +## 1. Networking and Authentication + +- [x] 1.1 Implement centralized networking in `NetworkProvider` (in `network_provider.dart`) with base URL, interceptors, and shared error handling. +- [x] 1.2 Create `ApiException` to standardize HTTP error propagation. +- [x] 1.3 Implement `HttpDataSource.getCourses` with `page` and `page_size` query parameters. +- [x] 1.4 Update `MockDataSource.getCourses` signature to return `PaginatedResponseDto`. +- [x] 1.5 Update `AuthProvider` to accept dev credentials ("222"/"222") and transition to authenticated state. +- [x] 1.6 Refactor `NetworkProvider` to use a flexible interceptor-injection model during instantiation. +- [x] 1.7 Abstract `AuthInterceptor` to use a `getToken` callback instead of a direct `AuthLocalDataSource` dependency. +- [x] 1.8 Remove redundant `AuthException.fromDio` and `dio` library dependency from `auth_exception.dart`. + +## 2. Data Layer and Synchronization + +- [x] 2.1 Update `CourseRepository.refreshCourses` to support paginated fetching using the API `next` field. +- [x] 2.2 Add `PaginatedResponseDto` model in `packages/core` to handle paginated API responses. +- [x] 2.3 Export `PaginatedResponseDto` from the `core/data/data.dart` barrel file. +- [x] 2.4 Gate all course API calls behind auth check — no network calls before login. + +## 3. UI + +- [x] 3.1 Show `AppLoadingIndicator` only on first entry when local DB cache is empty. +- [x] 3.2 Display cached courses instantly on subsequent tab visits without any blocking loader. +- [x] 3.3 Show pagination loader at the bottom of the course list while fetching the next page. +- [x] 3.4 Trigger next-page fetch when user scrolls within 500px of the list bottom. + +## 4. Architectural Refinement (Session Decoupling) + +- [x] 4.1 Extract pagination logic from Repository into dedicated `PaginationService` in `packages/core`. +- [x] 4.2 Decouple session state (`nextPage`, `hasMore`) from `CourseRepository` into `CourseList` notifier. +- [x] 4.3 Isolate backend-specific quirks (Testpress nested lists) in `PaginatedResponseDto`. +- [x] 4.4 Separate `isInitialSyncing` from `isMoreSyncing` and add background error reporting via `syncErrorProvider`. +- [x] 4.5 Delete redundant `RemoteCourseDto` and merge parsing logic into `CourseDto.fromJson`. diff --git a/packages/core/lib/data/auth/auth_api_service.dart b/packages/core/lib/data/auth/auth_api_service.dart index 81924b72..1d1e18d3 100644 --- a/packages/core/lib/data/auth/auth_api_service.dart +++ b/packages/core/lib/data/auth/auth_api_service.dart @@ -1,7 +1,7 @@ import 'package:dio/dio.dart'; - import '../../network/api_endpoints.dart'; import '../../network/network_provider.dart'; +import '../exceptions/api_exception.dart'; import 'types/auth_exception.dart'; class AuthApiResult { @@ -11,9 +11,10 @@ class AuthApiResult { } class AuthApiService { - final Dio _dio; + final NetworkProvider _networkProvider; - AuthApiService({Dio? dio}) : _dio = dio ?? NetworkProvider.create(); + AuthApiService({NetworkProvider? networkProvider}) + : _networkProvider = networkProvider ?? NetworkProvider(); Future loginWithPassword({ required String username, @@ -47,17 +48,14 @@ class AuthApiService { required String phoneNumber, String? email, }) async { - final response = await _post( - ApiEndpoints.verifyOtp, - { - 'otp': _parseOtp(otp), - ..._buildOtpIdentityPayload( - phoneNumber: phoneNumber, - countryCode: null, - email: email, - ), - }, - ); + final response = await _post(ApiEndpoints.verifyOtp, { + 'otp': _parseOtp(otp), + ..._buildOtpIdentityPayload( + phoneNumber: phoneNumber, + countryCode: null, + email: email, + ), + }); return _parseSession(response); } @@ -80,7 +78,7 @@ class AuthApiService { String? authToken, }) async { try { - final response = await _dio.post>( + final response = await _networkProvider.post>( path, data: payload, options: Options( @@ -92,8 +90,8 @@ class AuthApiService { ); return response.data ?? {}; - } on DioException catch (error) { - throw AuthException.fromDio(error); + } on ApiException catch (error) { + throw AuthException.fromApiException(error); } } diff --git a/packages/core/lib/data/auth/auth_interceptor.dart b/packages/core/lib/data/auth/auth_interceptor.dart new file mode 100644 index 00000000..bddb250e --- /dev/null +++ b/packages/core/lib/data/auth/auth_interceptor.dart @@ -0,0 +1,25 @@ +import 'dart:async'; +import 'package:dio/dio.dart'; + +/// Interceptor that adds a JWT token to all requests if one is available. +/// Uses a delegate [tokenProvider] to fetch the token, decoupling it from storage logic. +class AuthInterceptor extends Interceptor { + final FutureOr Function() _getToken; + + AuthInterceptor({required FutureOr Function() getToken}) + : _getToken = getToken; + + @override + void onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + final token = await _getToken(); + + if (token != null && token.isNotEmpty) { + options.headers['Authorization'] = 'JWT $token'; + } + + return handler.next(options); + } +} diff --git a/packages/core/lib/data/auth/auth_provider.g.dart b/packages/core/lib/data/auth/auth_provider.g.dart index e8f315fa..3b4bb213 100644 --- a/packages/core/lib/data/auth/auth_provider.g.dart +++ b/packages/core/lib/data/auth/auth_provider.g.dart @@ -6,7 +6,7 @@ part of 'auth_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$authHash() => r'5606fb1acd3875bc766e934844170d2a15b4be1b'; +String _$authHash() => r'd09795ff0f32aecc4452ed3d267a19cf81bf7a69'; /// See also [Auth]. @ProviderFor(Auth) diff --git a/packages/core/lib/data/auth/types/auth_exception.dart b/packages/core/lib/data/auth/types/auth_exception.dart index 7ffb1d41..6b4f20ca 100644 --- a/packages/core/lib/data/auth/types/auth_exception.dart +++ b/packages/core/lib/data/auth/types/auth_exception.dart @@ -1,6 +1,6 @@ -import 'package:dio/dio.dart'; import '../../exceptions/api_exception.dart'; + /// Represents authentication-related errors inside the domain. /// Extends [ApiException] for unified error handling across the application. class AuthException extends ApiException { @@ -11,9 +11,9 @@ class AuthException extends ApiException { }); factory AuthException.network({ - String message = "We couldn't connect. Please check your internet and try again.", - }) => - AuthException(message, type: ApiErrorType.noInternet); + String message = + "We couldn't connect. Please check your internet and try again.", + }) => AuthException(message, type: ApiErrorType.noInternet); factory AuthException.invalidCredentials({ String message = 'The username or password you entered is incorrect.', @@ -22,43 +22,112 @@ class AuthException extends ApiException { factory AuthException.unauthorized({ String message = "It looks like you don't have access to this feature.", - }) => - AuthException(message, type: ApiErrorType.forbidden, statusCode: 403); + }) => AuthException(message, type: ApiErrorType.forbidden, statusCode: 403); factory AuthException.validation({ - String message = 'Something looks wrong with the info you provided. Please check and try again.', + String message = + 'Something looks wrong with the info you provided. Please check and try again.', int? statusCode, - }) => - AuthException( - message, - type: ApiErrorType.badRequest, - statusCode: statusCode, - ); + }) => AuthException( + message, + type: ApiErrorType.badRequest, + statusCode: statusCode, + ); factory AuthException.malformedResponse({ - String message = 'We are having trouble communicating with our servers. Please try again later.', - }) => - AuthException(message, type: ApiErrorType.malformedResponse); + String message = + 'We are having trouble communicating with our servers. Please try again later.', + }) => AuthException(message, type: ApiErrorType.malformedResponse); factory AuthException.unknown({ - String message = 'Oops! Something went wrong on our end. Please try again in a moment.', + String message = + 'Oops! Something went wrong on our end. Please try again in a moment.', int? statusCode, - }) => - AuthException(message, type: ApiErrorType.unknown, statusCode: statusCode); - - factory AuthException.fromDio(DioException error) { - // Leverage the base ApiException parsing logic - final apiException = ApiException.fromDio(error); - - // We can then customize behavior for specific Auth status codes if needed - if (apiException.statusCode == 401) { - return AuthException.invalidCredentials(); + }) => AuthException( + message, + type: ApiErrorType.unknown, + statusCode: statusCode, + ); + + factory AuthException.fromApiException(ApiException error) { + if (error.message.contains('Connection') || + error.message.contains('No internet')) { + return AuthException.network(); + } + + final statusCode = error.statusCode; + final backendMessage = _extractApiMessage(error.data); + + // Only use backend messages for status 400 (Bad Request) + if (statusCode == 400) { + return AuthException.validation( + message: + backendMessage ?? + 'Something looks wrong with the info you provided.', + statusCode: statusCode, + ); + } + + // Specific handler for Throttling + if (statusCode == 429) { + return AuthException.unknown( + message: 'Please take a short break and try again in a moment.', + statusCode: statusCode, + ); } - - return AuthException( - apiException.message, - type: apiException.type, - statusCode: apiException.statusCode, - ); + + // For all other codes, use the user-friendly factory defaults + if (statusCode == 401) { + return AuthException.invalidCredentials(); + } + if (statusCode == 403) { + return AuthException.unauthorized(); + } + if (statusCode == 422) { + return AuthException.validation(statusCode: statusCode); + } + + return AuthException.unknown(statusCode: statusCode); + } + + static String? _extractApiMessage(dynamic responseData) { + if (responseData == null) return null; + if (responseData is String && responseData.trim().isNotEmpty) { + return responseData.trim(); + } + if (responseData is! Map) return null; + + final map = responseData.cast(); + + final detail = map['detail']?.toString().trim(); + if (detail != null && detail.isNotEmpty) return detail; + + final message = map['message']?.toString().trim(); + if (message != null && message.isNotEmpty) return message; + + final error = map['error']?.toString().trim(); + if (error != null && error.isNotEmpty) return error; + + final nonFieldErrors = map['non_field_errors']; + if (nonFieldErrors is List && nonFieldErrors.isNotEmpty) { + final first = nonFieldErrors.first?.toString().trim(); + if (first != null && first.isNotEmpty) return first; + } + + for (final entry in map.entries) { + final value = entry.value; + if (value is List && value.isNotEmpty) { + final first = value.first?.toString().trim(); + if (first != null && first.isNotEmpty) return first; + } + if (value is String && value.trim().isNotEmpty) { + return value.trim(); + } + } + + return null; } + + @override + String toString() => message; } diff --git a/packages/core/lib/data/config/app_config.dart b/packages/core/lib/data/config/app_config.dart index f25d7dfb..94152ef8 100644 --- a/packages/core/lib/data/config/app_config.dart +++ b/packages/core/lib/data/config/app_config.dart @@ -4,10 +4,9 @@ class AppConfig { /// Global flag to toggle between Mock and HTTP data sources. /// Controlled via --dart-define=USE_MOCK=false static const bool useMockData = - bool.fromEnvironment('USE_MOCK', defaultValue: true); + bool.fromEnvironment('USE_MOCK', defaultValue: false); - /// Base URL for HTTP API calls. - /// Reads from a --dart-define=API_BASE_URL=... at build/run time. + /// Base URL for HTTP API calls; used when [useMockData] is `false`. static const String apiBaseUrl = String.fromEnvironment( 'API_BASE_URL', defaultValue: 'https://lmsdemo.testpress.in', diff --git a/packages/core/lib/data/data.dart b/packages/core/lib/data/data.dart index 19e937cd..2eae4532 100644 --- a/packages/core/lib/data/data.dart +++ b/packages/core/lib/data/data.dart @@ -17,6 +17,7 @@ export 'models/user_dto.dart'; export 'models/settings_models.dart'; export 'models/study_momentum_dto.dart'; export 'models/explore_models.dart'; +export 'models/paginated_response_dto.dart'; // Database export 'db/app_database.dart'; diff --git a/packages/core/lib/data/db/app_database.dart b/packages/core/lib/data/db/app_database.dart index 2994c94c..01672834 100644 --- a/packages/core/lib/data/db/app_database.dart +++ b/packages/core/lib/data/db/app_database.dart @@ -31,7 +31,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); @override - int get schemaVersion => 6; + int get schemaVersion => 7; @override MigrationStrategy get migration => MigrationStrategy( @@ -42,29 +42,17 @@ class AppDatabase extends _$AppDatabase { await m.createTable(coursesTable); } - // Helper to add columns only if they don't already exist. - // This prevents crashes like "duplicate column name" during development migrations. - Future addColumnSafely(GeneratedColumn col) async { - final res = await customSelect( - 'PRAGMA table_info(${lessonsTable.actualTableName})', - ).get(); - final existingColumns = res.map((r) => r.read('name')); - if (!existingColumns.contains(col.name)) { - await m.addColumn(lessonsTable, col); - } - } - if (from < 3) { // Phase-2: Add new columns to lessonsTable without deleting existing data - await addColumnSafely(lessonsTable.contentJson); - await addColumnSafely(lessonsTable.subtitle); - await addColumnSafely(lessonsTable.subjectName); - await addColumnSafely(lessonsTable.subjectIndex); - await addColumnSafely(lessonsTable.lessonNumber); - await addColumnSafely(lessonsTable.totalLessons); + await m.addColumn(lessonsTable, lessonsTable.contentJson); + await m.addColumn(lessonsTable, lessonsTable.subtitle); + await m.addColumn(lessonsTable, lessonsTable.subjectName); + await m.addColumn(lessonsTable, lessonsTable.subjectIndex); + await m.addColumn(lessonsTable, lessonsTable.lessonNumber); + await m.addColumn(lessonsTable, lessonsTable.totalLessons); } if (from < 4) { - await addColumnSafely(lessonsTable.isBookmarked); + await m.addColumn(lessonsTable, lessonsTable.isBookmarked); } if (from < 5) { await m.createTable(appSettingsTable); @@ -92,8 +80,12 @@ class AppDatabase extends _$AppDatabase { ); } } - }, - ); + + if (from < 7) { + await m.addColumn(coursesTable, coursesTable.image); + } + }, + ); // ── App Settings ───────────────────────────────────────────────────────── diff --git a/packages/core/lib/data/db/app_database.g.dart b/packages/core/lib/data/db/app_database.g.dart index fdccbbd5..334f6907 100644 --- a/packages/core/lib/data/db/app_database.g.dart +++ b/packages/core/lib/data/db/app_database.g.dart @@ -95,6 +95,15 @@ class $CoursesTableTable extends CoursesTable type: DriftSqlType.int, requiredDuringInsert: true, ); + static const VerificationMeta _imageMeta = const VerificationMeta('image'); + @override + late final GeneratedColumn image = GeneratedColumn( + 'image', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); @override List get $columns => [ id, @@ -105,6 +114,7 @@ class $CoursesTableTable extends CoursesTable progress, completedLessons, totalLessons, + image, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -187,6 +197,12 @@ class $CoursesTableTable extends CoursesTable } else if (isInserting) { context.missing(_totalLessonsMeta); } + if (data.containsKey('image')) { + context.handle( + _imageMeta, + image.isAcceptableOrUnknown(data['image']!, _imageMeta), + ); + } return context; } @@ -228,6 +244,10 @@ class $CoursesTableTable extends CoursesTable DriftSqlType.int, data['${effectivePrefix}total_lessons'], )!, + image: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}image'], + ), ); } @@ -247,6 +267,7 @@ class CoursesTableData extends DataClass final int progress; final int completedLessons; final int totalLessons; + final String? image; const CoursesTableData({ required this.id, required this.title, @@ -256,6 +277,7 @@ class CoursesTableData extends DataClass required this.progress, required this.completedLessons, required this.totalLessons, + this.image, }); @override Map toColumns(bool nullToAbsent) { @@ -268,6 +290,9 @@ class CoursesTableData extends DataClass map['progress'] = Variable(progress); map['completed_lessons'] = Variable(completedLessons); map['total_lessons'] = Variable(totalLessons); + if (!nullToAbsent || image != null) { + map['image'] = Variable(image); + } return map; } @@ -281,6 +306,9 @@ class CoursesTableData extends DataClass progress: Value(progress), completedLessons: Value(completedLessons), totalLessons: Value(totalLessons), + image: image == null && nullToAbsent + ? const Value.absent() + : Value(image), ); } @@ -298,6 +326,7 @@ class CoursesTableData extends DataClass progress: serializer.fromJson(json['progress']), completedLessons: serializer.fromJson(json['completedLessons']), totalLessons: serializer.fromJson(json['totalLessons']), + image: serializer.fromJson(json['image']), ); } @override @@ -312,6 +341,7 @@ class CoursesTableData extends DataClass 'progress': serializer.toJson(progress), 'completedLessons': serializer.toJson(completedLessons), 'totalLessons': serializer.toJson(totalLessons), + 'image': serializer.toJson(image), }; } @@ -324,6 +354,7 @@ class CoursesTableData extends DataClass int? progress, int? completedLessons, int? totalLessons, + Value image = const Value.absent(), }) => CoursesTableData( id: id ?? this.id, title: title ?? this.title, @@ -333,6 +364,7 @@ class CoursesTableData extends DataClass progress: progress ?? this.progress, completedLessons: completedLessons ?? this.completedLessons, totalLessons: totalLessons ?? this.totalLessons, + image: image.present ? image.value : this.image, ); CoursesTableData copyWithCompanion(CoursesTableCompanion data) { return CoursesTableData( @@ -354,6 +386,7 @@ class CoursesTableData extends DataClass totalLessons: data.totalLessons.present ? data.totalLessons.value : this.totalLessons, + image: data.image.present ? data.image.value : this.image, ); } @@ -367,7 +400,8 @@ class CoursesTableData extends DataClass ..write('totalDuration: $totalDuration, ') ..write('progress: $progress, ') ..write('completedLessons: $completedLessons, ') - ..write('totalLessons: $totalLessons') + ..write('totalLessons: $totalLessons, ') + ..write('image: $image') ..write(')')) .toString(); } @@ -382,6 +416,7 @@ class CoursesTableData extends DataClass progress, completedLessons, totalLessons, + image, ); @override bool operator ==(Object other) => @@ -394,7 +429,8 @@ class CoursesTableData extends DataClass other.totalDuration == this.totalDuration && other.progress == this.progress && other.completedLessons == this.completedLessons && - other.totalLessons == this.totalLessons); + other.totalLessons == this.totalLessons && + other.image == this.image); } class CoursesTableCompanion extends UpdateCompanion { @@ -406,6 +442,7 @@ class CoursesTableCompanion extends UpdateCompanion { final Value progress; final Value completedLessons; final Value totalLessons; + final Value image; final Value rowid; const CoursesTableCompanion({ this.id = const Value.absent(), @@ -416,6 +453,7 @@ class CoursesTableCompanion extends UpdateCompanion { this.progress = const Value.absent(), this.completedLessons = const Value.absent(), this.totalLessons = const Value.absent(), + this.image = const Value.absent(), this.rowid = const Value.absent(), }); CoursesTableCompanion.insert({ @@ -427,6 +465,7 @@ class CoursesTableCompanion extends UpdateCompanion { this.progress = const Value.absent(), this.completedLessons = const Value.absent(), required int totalLessons, + this.image = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), title = Value(title), @@ -443,6 +482,7 @@ class CoursesTableCompanion extends UpdateCompanion { Expression? progress, Expression? completedLessons, Expression? totalLessons, + Expression? image, Expression? rowid, }) { return RawValuesInsertable({ @@ -454,6 +494,7 @@ class CoursesTableCompanion extends UpdateCompanion { if (progress != null) 'progress': progress, if (completedLessons != null) 'completed_lessons': completedLessons, if (totalLessons != null) 'total_lessons': totalLessons, + if (image != null) 'image': image, if (rowid != null) 'rowid': rowid, }); } @@ -467,6 +508,7 @@ class CoursesTableCompanion extends UpdateCompanion { Value? progress, Value? completedLessons, Value? totalLessons, + Value? image, Value? rowid, }) { return CoursesTableCompanion( @@ -478,6 +520,7 @@ class CoursesTableCompanion extends UpdateCompanion { progress: progress ?? this.progress, completedLessons: completedLessons ?? this.completedLessons, totalLessons: totalLessons ?? this.totalLessons, + image: image ?? this.image, rowid: rowid ?? this.rowid, ); } @@ -509,6 +552,9 @@ class CoursesTableCompanion extends UpdateCompanion { if (totalLessons.present) { map['total_lessons'] = Variable(totalLessons.value); } + if (image.present) { + map['image'] = Variable(image.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -526,6 +572,7 @@ class CoursesTableCompanion extends UpdateCompanion { ..write('progress: $progress, ') ..write('completedLessons: $completedLessons, ') ..write('totalLessons: $totalLessons, ') + ..write('image: $image, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -3671,6 +3718,7 @@ typedef $$CoursesTableTableCreateCompanionBuilder = Value progress, Value completedLessons, required int totalLessons, + Value image, Value rowid, }); typedef $$CoursesTableTableUpdateCompanionBuilder = @@ -3683,6 +3731,7 @@ typedef $$CoursesTableTableUpdateCompanionBuilder = Value progress, Value completedLessons, Value totalLessons, + Value image, Value rowid, }); @@ -3734,6 +3783,11 @@ class $$CoursesTableTableFilterComposer column: $table.totalLessons, builder: (column) => ColumnFilters(column), ); + + ColumnFilters get image => $composableBuilder( + column: $table.image, + builder: (column) => ColumnFilters(column), + ); } class $$CoursesTableTableOrderingComposer @@ -3784,6 +3838,11 @@ class $$CoursesTableTableOrderingComposer column: $table.totalLessons, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get image => $composableBuilder( + column: $table.image, + builder: (column) => ColumnOrderings(column), + ); } class $$CoursesTableTableAnnotationComposer @@ -3828,6 +3887,9 @@ class $$CoursesTableTableAnnotationComposer column: $table.totalLessons, builder: (column) => column, ); + + GeneratedColumn get image => + $composableBuilder(column: $table.image, builder: (column) => column); } class $$CoursesTableTableTableManager @@ -3869,6 +3931,7 @@ class $$CoursesTableTableTableManager Value progress = const Value.absent(), Value completedLessons = const Value.absent(), Value totalLessons = const Value.absent(), + Value image = const Value.absent(), Value rowid = const Value.absent(), }) => CoursesTableCompanion( id: id, @@ -3879,6 +3942,7 @@ class $$CoursesTableTableTableManager progress: progress, completedLessons: completedLessons, totalLessons: totalLessons, + image: image, rowid: rowid, ), createCompanionCallback: @@ -3891,6 +3955,7 @@ class $$CoursesTableTableTableManager Value progress = const Value.absent(), Value completedLessons = const Value.absent(), required int totalLessons, + Value image = const Value.absent(), Value rowid = const Value.absent(), }) => CoursesTableCompanion.insert( id: id, @@ -3901,6 +3966,7 @@ class $$CoursesTableTableTableManager progress: progress, completedLessons: completedLessons, totalLessons: totalLessons, + image: image, rowid: rowid, ), withReferenceMapper: (p0) => p0 diff --git a/packages/core/lib/data/db/tables/courses_table.dart b/packages/core/lib/data/db/tables/courses_table.dart index 098e95fd..55a3975b 100644 --- a/packages/core/lib/data/db/tables/courses_table.dart +++ b/packages/core/lib/data/db/tables/courses_table.dart @@ -10,6 +10,7 @@ class CoursesTable extends Table { IntColumn get progress => integer().withDefault(const Constant(0))(); IntColumn get completedLessons => integer().withDefault(const Constant(0))(); IntColumn get totalLessons => integer()(); + TextColumn get image => text().nullable()(); @override Set get primaryKey => {id}; diff --git a/packages/core/lib/data/exceptions/api_exception.dart b/packages/core/lib/data/exceptions/api_exception.dart index 1d246f8f..e4d8089c 100644 --- a/packages/core/lib/data/exceptions/api_exception.dart +++ b/packages/core/lib/data/exceptions/api_exception.dart @@ -32,44 +32,52 @@ enum ApiErrorType { unknown, } -/// Base class for all API-related exceptions in the application. class ApiException implements Exception { final String message; final ApiErrorType type; final int? statusCode; + final dynamic data; + final dynamic error; const ApiException( this.message, { this.type = ApiErrorType.unknown, this.statusCode, + this.data, + this.error, }); - factory ApiException.fromDio(DioException error) { + factory ApiException.fromDioException(DioException error) { if (error.type == DioExceptionType.connectionError) { - return const ApiException( + return ApiException( 'We couldn\'t connect. Please check your internet and try again.', type: ApiErrorType.noInternet, + error: error.error, ); } if (error.type == DioExceptionType.connectionTimeout || error.type == DioExceptionType.sendTimeout || error.type == DioExceptionType.receiveTimeout) { - return const ApiException( + return ApiException( 'The request timed out. Please try again.', type: ApiErrorType.timeout, + error: error.error, ); } if (error.type == DioExceptionType.badResponse) { final statusCode = error.response?.statusCode; - final backendMessage = _extractApiMessage(error.response?.data); + final data = error.response?.data; + final backendMessage = _extractApiMessage(data); if (statusCode == 400 || statusCode == 422) { return ApiException( backendMessage ?? 'Something looks wrong with the info you provided.', type: ApiErrorType.badRequest, statusCode: statusCode, + data: data, + error: error.error, ); } @@ -78,6 +86,8 @@ class ApiException implements Exception { backendMessage ?? 'You are not authorized to perform this action.', type: ApiErrorType.unauthorized, statusCode: statusCode, + data: data, + error: error.error, ); } @@ -86,6 +96,8 @@ class ApiException implements Exception { backendMessage ?? 'It looks like you don\'t have access to this feature.', type: ApiErrorType.forbidden, statusCode: statusCode, + data: data, + error: error.error, ); } @@ -94,6 +106,8 @@ class ApiException implements Exception { backendMessage ?? 'The requested resource was not found.', type: ApiErrorType.notFound, statusCode: statusCode, + data: data, + error: error.error, ); } @@ -102,6 +116,8 @@ class ApiException implements Exception { 'Please take a short break and try again in a moment.', type: ApiErrorType.rateLimited, statusCode: statusCode, + data: data, + error: error.error, ); } @@ -110,6 +126,8 @@ class ApiException implements Exception { 'The server is having trouble. Please try again later.', type: ApiErrorType.serverError, statusCode: statusCode, + data: data, + error: error.error, ); } @@ -117,12 +135,15 @@ class ApiException implements Exception { backendMessage ?? 'Oops! Something went wrong. Please try again.', type: ApiErrorType.unknown, statusCode: statusCode, + data: data, + error: error.error, ); } return ApiException( 'Oops! Something went wrong on our end. Please try again in a moment.', type: ApiErrorType.unknown, + error: error.error, ); } @@ -165,5 +186,5 @@ class ApiException implements Exception { } @override - String toString() => message; + String toString() => 'ApiException: $message (Type: $type, Status: $statusCode)'; } diff --git a/packages/core/lib/data/models/course_dto.dart b/packages/core/lib/data/models/course_dto.dart index 782ac1d5..4ccaf3e6 100644 --- a/packages/core/lib/data/models/course_dto.dart +++ b/packages/core/lib/data/models/course_dto.dart @@ -1,6 +1,9 @@ import 'chapter_dto.dart'; /// Course DTO — plain Dart object transferred from DataSource to Drift and back to UI. +/// +/// Field mapping is based on the Testpress `/api/v3/courses/` response contract, +/// which uses snake_case keys. If the API contract changes, update ONLY this file. class CourseDto { final String id; final String title; @@ -15,6 +18,7 @@ class CourseDto { final int progress; // 0–100 final int completedLessons; final int totalLessons; + final String? image; final List chapters; const CourseDto({ @@ -26,6 +30,7 @@ class CourseDto { required this.progress, required this.completedLessons, required this.totalLessons, + this.image, this.chapters = const [], }); @@ -38,6 +43,7 @@ class CourseDto { int? progress, int? completedLessons, int? totalLessons, + String? image, List? chapters, }) { return CourseDto( @@ -49,20 +55,22 @@ class CourseDto { progress: progress ?? this.progress, completedLessons: completedLessons ?? this.completedLessons, totalLessons: totalLessons ?? this.totalLessons, + image: image ?? this.image, chapters: chapters ?? this.chapters, ); } factory CourseDto.fromJson(Map json) { return CourseDto( - id: json['id'] as String, - title: json['title'] as String, - colorIndex: json['colorIndex'] as int, - chapterCount: json['chapterCount'] as int, - totalDuration: json['totalDuration'] as String, - progress: json['progress'] as int, - completedLessons: json['completedLessons'] as int, - totalLessons: json['totalLessons'] as int, + id: (json['id'] as Object).toString(), + title: json['title'] as String? ?? 'Untitled Course', + colorIndex: json['color_index'] as int? ?? 0, + chapterCount: json['chapters_count'] as int? ?? 0, + totalDuration: json['total_duration'] as String? ?? '', + progress: json['progress'] as int? ?? 0, + completedLessons: json['completed_lessons_count'] as int? ?? 0, + totalLessons: json['total_lessons_count'] as int? ?? 0, + image: json['image'] as String?, chapters: (json['chapters'] as List?) ?.map((e) => ChapterDto.fromJson(e as Map)) .toList() ?? @@ -80,6 +88,7 @@ class CourseDto { 'progress': progress, 'completedLessons': completedLessons, 'totalLessons': totalLessons, + 'image': image, 'chapters': chapters.map((e) => e.toJson()).toList(), }; } diff --git a/packages/core/lib/data/models/paginated_response_dto.dart b/packages/core/lib/data/models/paginated_response_dto.dart new file mode 100644 index 00000000..d9df225a --- /dev/null +++ b/packages/core/lib/data/models/paginated_response_dto.dart @@ -0,0 +1,49 @@ +/// DTO representing a standard DRF paginated response from the Testpress API. +/// +/// The API always returns: +/// ```json +/// { +/// "count": 42, +/// "next": "https://.../?page=2", +/// "previous": null, +/// "results": [ ... ] +/// } +/// ``` +class PaginatedResponseDto { + final List results; + final String? next; + final String? previous; + final int count; + + PaginatedResponseDto({ + required this.results, + this.next, + this.previous, + this.count = 0, + }); + + factory PaginatedResponseDto.fromJson( + Map json, + T Function(Map) fromJsonT, + ) { + var rawResults = json['results']; + + // Testpress Quirk: Sometimes 'results' is a Map with a nested List (e.g. 'courses'). + if (rawResults is Map) { + final nestedList = rawResults.values.firstWhere( + (v) => v is List, + orElse: () => [], + ); + rawResults = nestedList; + } + + final rawList = rawResults as List? ?? []; + + return PaginatedResponseDto( + results: rawList.map((e) => fromJsonT(e as Map)).toList(), + next: json['next'] as String?, + previous: json['previous'] as String?, + count: (json['count'] as int?) ?? rawList.length, + ); + } +} diff --git a/packages/core/lib/data/services/pagination_service.dart b/packages/core/lib/data/services/pagination_service.dart new file mode 100644 index 00000000..03dc496d --- /dev/null +++ b/packages/core/lib/data/services/pagination_service.dart @@ -0,0 +1,61 @@ +import 'package:core/data/models/paginated_response_dto.dart'; + +/// Represents the progress of a paginated session. +class PaginationState { + final int nextPage; + final bool hasMore; + + const PaginationState({ + this.nextPage = 1, + this.hasMore = true, + }); + + PaginationState copyWith({ + int? nextPage, + bool? hasMore, + }) { + return PaginationState( + nextPage: nextPage ?? this.nextPage, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +/// A stateless service that handles the common logic for paginated APIs. +/// +/// It follows the DRF standard where a 'next' URL provides the link +/// to the subsequent page. +class PaginationService { + const PaginationService(); + + /// Calculates the next [PaginationState] from an API response. + PaginationState calculateNextState({ + required PaginatedResponseDto response, + required int currentPage, + }) { + final nextUrl = response.next; + final hasMore = nextUrl != null; + + if (!hasMore) { + return PaginationState(nextPage: currentPage, hasMore: false); + } + + // Attempt to extract the page number from the URL + final nextPage = _extractPageFromUrl(nextUrl); + + return PaginationState( + nextPage: nextPage ?? currentPage, // Stay on current page if parsing fails + hasMore: nextPage != null, // Treat as no more if we can't parse the URL + ); + } + + int? _extractPageFromUrl(String url) { + try { + final uri = Uri.parse(url); + final pageStr = uri.queryParameters['page']; + return pageStr != null ? int.tryParse(pageStr) : null; + } catch (_) { + return null; + } + } +} diff --git a/packages/core/lib/data/sources/data_source.dart b/packages/core/lib/data/sources/data_source.dart index ad40dce2..9b3f222f 100644 --- a/packages/core/lib/data/sources/data_source.dart +++ b/packages/core/lib/data/sources/data_source.dart @@ -1,10 +1,12 @@ import 'package:core/data/data.dart'; +import '../models/paginated_response_dto.dart'; /// Abstract data source — implemented by [MockDataSource] and [HttpDataSource]. /// Repositories call these methods to populate the local Drift DB. abstract class DataSource { /// Fetch all courses available to the current user. - Future> getCourses(); + Future> getCourses( + {int page = 1, int pageSize = 10}); /// Fetch chapters for a specific course. Future> getChapters(String courseId); diff --git a/packages/core/lib/data/sources/data_source_provider.dart b/packages/core/lib/data/sources/data_source_provider.dart index 904f4016..d924bbe5 100644 --- a/packages/core/lib/data/sources/data_source_provider.dart +++ b/packages/core/lib/data/sources/data_source_provider.dart @@ -1,13 +1,28 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../auth/auth_interceptor.dart'; +import '../auth/auth_provider.dart'; import '../config/app_config.dart'; +import '../../network/network_provider.dart'; import 'data_source.dart'; import 'mock_data_source.dart'; import 'http_data_source.dart'; part 'data_source_provider.g.dart'; +/// Provides a centralized, authenticated network provider. +@Riverpod(keepAlive: true) +NetworkProvider network(Ref ref) { + return NetworkProvider( + interceptors: [ + AuthInterceptor( + getToken: () => ref.read(authRepositoryProvider).getToken(), + ), + ], + ); +} + /// Provides the active [DataSource] based on [AppConfig.useMockData]. /// Swap to real HTTP source by building with: --dart-define=USE_MOCK=false @Riverpod(keepAlive: true) @@ -15,5 +30,5 @@ DataSource dataSource(Ref ref) { if (AppConfig.useMockData) { return const MockDataSource(); } - return const HttpDataSource(); + return HttpDataSource(networkProvider: ref.watch(networkProvider)); } 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..e4106f92 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,26 @@ part of 'data_source_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$dataSourceHash() => r'a2598f99a41663a124db4e3da9fc59b49aafc8ca'; +String _$networkHash() => r'5a761b4c14aa20ce8bfc8dc94776f4d4cd1d52c6'; + +/// Provides a centralized, authenticated network provider. +/// +/// Copied from [network]. +@ProviderFor(network) +final networkProvider = Provider.internal( + network, + name: r'networkProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$networkHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef NetworkRef = ProviderRef; +String _$dataSourceHash() => r'765da6784083066b1746b739b61e156ab83648f8'; /// 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..2badb917 100644 --- a/packages/core/lib/data/sources/http_data_source.dart +++ b/packages/core/lib/data/sources/http_data_source.dart @@ -1,17 +1,37 @@ import 'package:core/data/data.dart'; +import 'package:core/network/api_endpoints.dart'; +import '../models/paginated_response_dto.dart'; +import '../../network/network_provider.dart'; import 'data_source.dart'; -/// HTTP data source stub — to be implemented when a real backend is available. -/// All methods throw [UnimplementedError] to surface accidental usage in tests. +/// Active HTTP data source for remote API communication. +/// Uses [NetworkProvider] for centralized HTTP requests and error handling. /// -/// Activate via: flutter run --dart-define=USE_MOCK=false +/// Only methods backed by a real API endpoint are implemented here. +/// Un-integrated methods throw [UnimplementedError] to signal they need work. class HttpDataSource implements DataSource { - const HttpDataSource(); + final NetworkProvider _networkProvider; + + HttpDataSource({required NetworkProvider networkProvider}) + : _networkProvider = networkProvider; @override - Future> getCourses() => throw UnimplementedError( - 'HttpDataSource.getCourses is not yet implemented. Use MockDataSource.', - ); + Future> getCourses({ + int page = 1, + int pageSize = 10, + }) async { + final response = await _networkProvider.get( + ApiEndpoints.courses, + queryParameters: {'page': page, 'page_size': pageSize}, + ); + + return PaginatedResponseDto.fromJson( + response.data as Map, + CourseDto.fromJson, + ); + } + + // ── Not yet integrated — Implement as needed ── @override Future> getChapters(String courseId) => diff --git a/packages/core/lib/data/sources/mock_data_source.dart b/packages/core/lib/data/sources/mock_data_source.dart index 4b514bcf..c8e7a991 100644 --- a/packages/core/lib/data/sources/mock_data_source.dart +++ b/packages/core/lib/data/sources/mock_data_source.dart @@ -2,7 +2,7 @@ import 'package:core/data/data.dart'; import 'data_source.dart'; import 'mock_data.dart'; -/// In-process mock data source with hardcoded JEE/NEET coaching institute data. +/// Static mock data source for development and testing. /// Implements [DataSource]; no network calls are made. /// Data is derived from the React reference design. class MockDataSource implements DataSource { @@ -13,11 +13,29 @@ class MockDataSource implements DataSource { // ───────────────────────────────────────────────────────────────────────── @override - Future> getCourses() async => [ + Future> getCourses({ + int page = 1, + int pageSize = 10, + }) async { + final results = page <= 3 ? _getMockCourses(page) : []; + final next = page < 3 + ? 'https://lmsdemo.testpress.in/api/v3/courses/?page=${page + 1}' + : null; + + return PaginatedResponseDto( + results: results, + next: next, + count: 15, // Total simulated courses across 3 pages + ); + } + + List _getMockCourses(int page) { + if (page == 1) { + return [ const CourseDto( id: 'jee-main-2026', title: 'JEE Main 2026', - colorIndex: 0, // indigo + colorIndex: 0, chapterCount: 12, totalDuration: '180 hrs', progress: 34, @@ -27,7 +45,7 @@ class MockDataSource implements DataSource { const CourseDto( id: 'neet-2026', title: 'NEET 2026', - colorIndex: 4, // rose + colorIndex: 4, chapterCount: 10, totalDuration: '160 hrs', progress: 18, @@ -37,7 +55,7 @@ class MockDataSource implements DataSource { const CourseDto( id: 'jee-advanced-2026', title: 'JEE Advanced 2026', - colorIndex: 3, // violet + colorIndex: 3, chapterCount: 8, totalDuration: '120 hrs', progress: 5, @@ -47,7 +65,7 @@ class MockDataSource implements DataSource { const CourseDto( id: 'biology-neet-2026', title: 'NEET Biology Mastery', - colorIndex: 2, // emerald + colorIndex: 2, chapterCount: 15, totalDuration: '200 hrs', progress: 45, @@ -57,7 +75,7 @@ class MockDataSource implements DataSource { const CourseDto( id: 'english-core-2026', title: 'CBSE English Core', - colorIndex: 5, // pink + colorIndex: 5, chapterCount: 6, totalDuration: '40 hrs', progress: 10, @@ -65,6 +83,45 @@ class MockDataSource implements DataSource { totalLessons: 20, ), ]; + } else if (page == 2) { + return [ + const CourseDto( + id: 'maths-foundation', + title: 'Maths Foundation 2025', + colorIndex: 1, + chapterCount: 15, + totalDuration: '100 hrs', + progress: 0, + completedLessons: 0, + totalLessons: 50, + ), + const CourseDto( + id: 'physics-mastery', + title: 'Physics Mastery 2025', + colorIndex: 6, + chapterCount: 20, + totalDuration: '150 hrs', + progress: 12, + completedLessons: 10, + totalLessons: 80, + ), + ]; + } else if (page == 3) { + return [ + const CourseDto( + id: 'chemistry-revision', + title: 'Chemistry Quick Revision', + colorIndex: 7, + chapterCount: 5, + totalDuration: '20 hrs', + progress: 100, + completedLessons: 20, + totalLessons: 20, + ), + ]; + } + return []; + } // ───────────────────────────────────────────────────────────────────────── // Chapters diff --git a/packages/core/lib/network/api_endpoints.dart b/packages/core/lib/network/api_endpoints.dart index 6237b0f0..eb917745 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 courses = '/api/v3/courses/'; } diff --git a/packages/core/lib/network/network_provider.dart b/packages/core/lib/network/network_provider.dart index 484d2327..59578b21 100644 --- a/packages/core/lib/network/network_provider.dart +++ b/packages/core/lib/network/network_provider.dart @@ -1,12 +1,27 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:dio/dio.dart'; import '../data/config/app_config.dart'; +import '../data/exceptions/api_exception.dart'; import 'user_agent_interceptor.dart'; +/// Centralized networking service for Testpress API communication. +/// Encapsulates [Dio] to handle headers, interceptors, and error translation. class NetworkProvider { - NetworkProvider._(); + final Dio _dio; - static Dio create({String? baseUrl, Map? headers}) { + /// Creates a [NetworkProvider] with optional [interceptors]. + /// + /// This allowing for flexible "on-demand" configuration of features like + /// authentication or logging without rigid factory builders. + NetworkProvider({List interceptors = const []}) + : _dio = _createBaseDio() { + _dio.interceptors.addAll(interceptors); + } + + /// Internal factory for basic [Dio] configuration. + static Dio _createBaseDio({String? baseUrl, Map? headers}) { final dio = Dio( BaseOptions( baseUrl: baseUrl ?? AppConfig.apiBaseUrl, @@ -18,8 +33,52 @@ class NetworkProvider { ), ); + // Standard interceptors dio.interceptors.add(UserAgentInterceptor()); + // Debug logging + if (kDebugMode) { + dio.interceptors.add(LogInterceptor( + requestHeader: true, + requestBody: true, + responseHeader: true, + responseBody: true, + )); + } + return dio; } + + /// Perform a GET request with unified error handling. + Future> get( + String path, { + Map? queryParameters, + Options? options, + }) async { + try { + return await _dio.get(path, + queryParameters: queryParameters, options: options); + } on DioException catch (e) { + throw ApiException.fromDioException(e); + } catch (e) { + throw ApiException('Unexpected error: ${e.toString()}', error: e); + } + } + + /// Perform a POST request with unified error handling. + Future> post( + String path, { + dynamic data, + Map? queryParameters, + Options? options, + }) async { + try { + return await _dio.post(path, + data: data, queryParameters: queryParameters, options: options); + } on DioException catch (e) { + throw ApiException.fromDioException(e); + } catch (e) { + throw ApiException('Unexpected error: ${e.toString()}', error: e); + } + } } diff --git a/packages/core/lib/widgets/app_scroll.dart b/packages/core/lib/widgets/app_scroll.dart index 400ea802..9acd5c24 100644 --- a/packages/core/lib/widgets/app_scroll.dart +++ b/packages/core/lib/widgets/app_scroll.dart @@ -6,15 +6,17 @@ import '../design/design_provider.dart'; /// Provides consistent scrolling behavior without Material or Cupertino /// scroll physics differences. class AppScroll extends StatelessWidget { - const AppScroll({super.key, required this.children, this.padding}); + const AppScroll({super.key, required this.children, this.padding, this.controller}); final List children; final EdgeInsetsGeometry? padding; + final ScrollController? controller; @override Widget build(BuildContext context) { final design = Design.of(context); return SingleChildScrollView( + controller: controller, padding: padding ?? EdgeInsets.all(design.spacing.screenPadding), physics: const BouncingScrollPhysics(), child: Column( diff --git a/packages/core/pubspec.yaml b/packages/core/pubspec.yaml index 87327f47..5d85d160 100644 --- a/packages/core/pubspec.yaml +++ b/packages/core/pubspec.yaml @@ -25,8 +25,9 @@ dependencies: riverpod_annotation: ^2.3.5 path_provider: ^2.1.4 path: ^1.9.0 - dio: ^5.9.0 + dio: ^5.9.2 flutter_secure_storage: ^9.2.2 + dio_web_adapter: ^2.1.2 dev_dependencies: flutter_test: diff --git a/packages/courses/lib/courses.dart b/packages/courses/lib/courses.dart index f18c86e0..2a9de681 100644 --- a/packages/courses/lib/courses.dart +++ b/packages/courses/lib/courses.dart @@ -33,7 +33,6 @@ export 'screens/video_lesson_detail_page.dart'; export 'providers/lesson_detail_provider.dart'; export 'providers/course_list_provider.dart'; export 'providers/lesson_providers.dart'; -export 'providers/enrollment_provider.dart'; export 'providers/recent_activity_provider.dart'; export 'providers/dashboard_providers.dart'; export 'models/lesson_content.dart'; diff --git a/packages/courses/lib/providers/course_detail_provider.dart b/packages/courses/lib/providers/course_detail_provider.dart index f85a92c3..a0b899b6 100644 --- a/packages/courses/lib/providers/course_detail_provider.dart +++ b/packages/courses/lib/providers/course_detail_provider.dart @@ -1,6 +1,5 @@ import 'package:core/data/data.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'enrollment_provider.dart'; import 'course_list_provider.dart'; part 'course_detail_provider.g.dart'; @@ -11,8 +10,8 @@ part 'course_detail_provider.g.dart'; /// to build a complete [CourseDto] hierarchy. @riverpod Future courseDetail(CourseDetailRef ref, String courseId) async { - final enrollment = await ref.watch(enrollmentProvider.future); - final course = enrollment.where((c) => c.id == courseId).firstOrNull; + final courses = await ref.watch(courseListProvider.future); + final course = courses.where((c) => c.id == courseId).firstOrNull; if (course == null) return null; // Watch chapters for this course diff --git a/packages/courses/lib/providers/course_list_provider.dart b/packages/courses/lib/providers/course_list_provider.dart index 6546ff8c..b8762bb2 100644 --- a/packages/courses/lib/providers/course_list_provider.dart +++ b/packages/courses/lib/providers/course_list_provider.dart @@ -1,6 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:core/data/data.dart'; +import 'package:core/data/services/pagination_service.dart'; import '../repositories/course_repository.dart'; part 'course_list_provider.g.dart'; @@ -12,13 +13,100 @@ Future courseRepository(Ref ref) async { return CourseRepository(db, source); } -/// Stream provider for the full course list. -/// On first watch: triggers a refresh from DataSource → Drift. -/// Thereafter: streams live updates from the Drift DB. -@riverpod -Stream> courseList(CourseListRef ref) async* { - final repo = await ref.watch(courseRepositoryProvider.future); - yield* repo.watchCourses(); +/// Track if initial fetch has been done in the current session. +final _hasFetchedOnce = StateProvider((ref) => false); + +/// State provider for initial sync status (first time loading courses). +final isInitialSyncingProvider = StateProvider((ref) => false); + +/// State provider for "Load More" sync status (pagination footer). +final isMoreSyncingProvider = StateProvider((ref) => false); + +/// Captures the last error that occurred during any sync operation. +final syncErrorProvider = StateProvider((ref) => null); + +@Riverpod(keepAlive: true) +class CourseList extends _$CourseList { + PaginationState _syncState = const PaginationState(); + Future? _activeSync; + + @override + Stream> build() async* { + final repo = await ref.watch(courseRepositoryProvider.future); + yield* repo.watchCourses().map( + (rows) => rows.map((row) => repo.rowToCourseDto(row)).toList(), + ); + } + + /// Explicit initialization: ensures the first sync happens one time per session. + /// Call this when the Study tab is entered. + Future initialize() async { + // Wait for auth state to be resolved (e.g. if still checking token storage) + final auth = await ref.read(authProvider.future); + if (auth == null) return; // Auth gate — explicit + + if (ref.read(_hasFetchedOnce)) return; + if (_activeSync != null) return _activeSync; + + _activeSync = _performSync(isReset: true); + try { + await _activeSync; + ref.read(_hasFetchedOnce.notifier).state = true; + } finally { + _activeSync = null; + } + } + + /// Loads the next page from the API. + Future loadMore() async { + if (!_syncState.hasMore || _activeSync != null) return; + + _activeSync = _performSync(isReset: false); + try { + await _activeSync; + } finally { + _activeSync = null; + } + } + + Future _performSync({required bool isReset}) async { + // Reset any previous errors + ref.read(syncErrorProvider.notifier).state = null; + + if (isReset) { + _syncState = const PaginationState(); + ref.read(isInitialSyncingProvider.notifier).state = true; + } else { + ref.read(isMoreSyncingProvider.notifier).state = true; + } + + try { + final repo = await ref.read(courseRepositoryProvider.future); + final response = await repo.fetchAndPersistCourses( + page: _syncState.nextPage, + ); + + // Explicit logic to mark completeness if no results + if (response.results.isEmpty) { + _syncState = _syncState.copyWith(hasMore: false); + } else { + const pagination = PaginationService(); + _syncState = pagination.calculateNextState( + response: response, + currentPage: _syncState.nextPage, + ); + } + } catch (e) { + // Capture the error but don't rethrow (so stream from DB is still visible) + ref.read(syncErrorProvider.notifier).state = e; + } finally { + if (isReset) { + ref.read(isInitialSyncingProvider.notifier).state = false; + } else { + ref.read(isMoreSyncingProvider.notifier).state = false; + } + } + } } /// Provider for a specific course's chapters. @@ -26,7 +114,9 @@ Stream> courseList(CourseListRef ref) async* { Stream> courseChapters( CourseChaptersRef ref, String courseId) async* { final repo = await ref.watch(courseRepositoryProvider.future); - yield* repo.watchChapters(courseId); + yield* repo.watchChapters(courseId).map( + (rows) => rows.map((row) => repo.rowToChapterDto(row)).toList(), + ); } /// Provider for a specific chapter's lessons. @@ -34,5 +124,7 @@ Stream> courseChapters( Stream> chapterLessons( ChapterLessonsRef ref, String chapterId) async* { final repo = await ref.watch(courseRepositoryProvider.future); - yield* repo.watchLessons(chapterId); + yield* repo.watchLessons(chapterId).map( + (rows) => rows.map((row) => repo.rowToLessonDto(row)).toList(), + ); } diff --git a/packages/courses/lib/providers/course_list_provider.g.dart b/packages/courses/lib/providers/course_list_provider.g.dart index 339322b9..ce49886c 100644 --- a/packages/courses/lib/providers/course_list_provider.g.dart +++ b/packages/courses/lib/providers/course_list_provider.g.dart @@ -6,7 +6,7 @@ part of 'course_list_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$courseRepositoryHash() => r'62b30446b43101d61052e9c469030f2209080475'; +String _$courseRepositoryHash() => r'e1bdd96c7b7bed6af0671f20ac537712a3a6265d'; /// See also [courseRepository]. @ProviderFor(courseRepository) @@ -23,27 +23,6 @@ final courseRepositoryProvider = FutureProvider.internal( @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element typedef CourseRepositoryRef = FutureProviderRef; -String _$courseListHash() => r'e09726800afe30e7af81d8346fc28e7ad237069a'; - -/// Stream provider for the full course list. -/// On first watch: triggers a refresh from DataSource → Drift. -/// Thereafter: streams live updates from the Drift DB. -/// -/// Copied from [courseList]. -@ProviderFor(courseList) -final courseListProvider = AutoDisposeStreamProvider>.internal( - courseList, - name: r'courseListProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$courseListHash, - dependencies: null, - allTransitiveDependencies: null, -); - -@Deprecated('Will be removed in 3.0. Use Ref instead') -// ignore: unused_element -typedef CourseListRef = AutoDisposeStreamProviderRef>; String _$courseChaptersHash() => r'0209694cd811fd004339910f9895f84971f0e5c9'; /// Copied from Dart SDK @@ -331,5 +310,27 @@ class _ChapterLessonsProviderElement String get chapterId => (origin as ChapterLessonsProvider).chapterId; } +String _$courseListHash() => r'00a58458218fa35c2a1402581558519673b3eb40'; + +/// Stream notifier for the full course list. +/// +/// - `build()` streams courses from the local Drift DB. +/// - `initialize()` ensures the first page is fetched from API one time. +/// - `loadMore()` loads subsequent pages on user scroll. +/// +/// Copied from [CourseList]. +@ProviderFor(CourseList) +final courseListProvider = + StreamNotifierProvider>.internal( + CourseList.new, + name: r'courseListProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$courseListHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$CourseList = StreamNotifier>; // 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/courses/lib/providers/enrollment_provider.dart b/packages/courses/lib/providers/enrollment_provider.dart deleted file mode 100644 index 31c710df..00000000 --- a/packages/courses/lib/providers/enrollment_provider.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -import 'package:core/data/data.dart'; -import 'course_list_provider.dart'; - -part 'enrollment_provider.g.dart'; - -/// Provider for the list of courses the user is currently enrolled in. -@riverpod -Stream> enrollment(Ref ref) async* { - final repo = await ref.watch(courseRepositoryProvider.future); - - // Surface errors/loading via AsyncValue automatically - yield* repo.watchCourses(); -} diff --git a/packages/courses/lib/providers/enrollment_provider.g.dart b/packages/courses/lib/providers/enrollment_provider.g.dart deleted file mode 100644 index 2c373681..00000000 --- a/packages/courses/lib/providers/enrollment_provider.g.dart +++ /dev/null @@ -1,29 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'enrollment_provider.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$enrollmentHash() => r'693ae1fcbe2ffe3fda36c1e380b223095a327abd'; - -/// Provider for the list of courses the user is currently enrolled in. -/// -/// Copied from [enrollment]. -@ProviderFor(enrollment) -final enrollmentProvider = AutoDisposeStreamProvider>.internal( - enrollment, - name: r'enrollmentProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$enrollmentHash, - dependencies: null, - allTransitiveDependencies: null, -); - -@Deprecated('Will be removed in 3.0. Use Ref instead') -// ignore: unused_element -typedef EnrollmentRef = AutoDisposeStreamProviderRef>; -// 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/courses/lib/providers/lesson_detail_provider.dart b/packages/courses/lib/providers/lesson_detail_provider.dart index 42e349db..f7da32ab 100644 --- a/packages/courses/lib/providers/lesson_detail_provider.dart +++ b/packages/courses/lib/providers/lesson_detail_provider.dart @@ -12,9 +12,10 @@ part 'lesson_detail_provider.g.dart'; @riverpod Future lessonDetail(LessonDetailRef ref, String lessonId) async { final repository = await ref.watch(courseRepositoryProvider.future); - final lessonDto = await repository.getLesson(lessonId); + final lessonRow = await repository.getLesson(lessonId); + if (lessonRow == null) return null; - if (lessonDto == null) return null; + final lessonDto = repository.rowToLessonDto(lessonRow); return Lesson( id: lessonDto.id, diff --git a/packages/courses/lib/providers/lesson_providers.dart b/packages/courses/lib/providers/lesson_providers.dart index 26e550c1..42cf40ae 100644 --- a/packages/courses/lib/providers/lesson_providers.dart +++ b/packages/courses/lib/providers/lesson_providers.dart @@ -1,6 +1,6 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:core/data/data.dart'; -import 'enrollment_provider.dart'; +import 'course_list_provider.dart'; part 'lesson_providers.g.dart'; @@ -8,7 +8,7 @@ part 'lesson_providers.g.dart'; /// This allows for efficient O(N) filtering in the UI. @riverpod List allLessons(AllLessonsRef ref) { - final courses = ref.watch(enrollmentProvider).asData?.value ?? []; + final courses = ref.watch(courseListProvider).asData?.value ?? []; return courses.expand((course) { return course.chapters.expand((chapter) => chapter.lessons); }).toList(); diff --git a/packages/courses/lib/repositories/course_repository.dart b/packages/courses/lib/repositories/course_repository.dart index 60d96d1e..dce55f58 100644 --- a/packages/courses/lib/repositories/course_repository.dart +++ b/packages/courses/lib/repositories/course_repository.dart @@ -15,26 +15,29 @@ class CourseRepository { // ── Courses ────────────────────────────────────────────────────────────── /// Live stream of all courses from the local DB (single source of truth). - Stream> watchCourses() { - return _db.watchAllCourses().map( - (rows) => rows.map(_rowToCourseDto).toList(), - ); + Stream> watchCourses() { + return _db.watchAllCourses(); } - /// Fetch courses from [DataSource] and persist to local DB. - Future> refreshCourses() async { - final courses = await _source.getCourses(); - final companions = courses.map(_courseDtoToCompanion).toList(); - await _db.upsertCourses(companions); - return courses; + /// Fetch courses for a specific [page] from [DataSource] and persist to local DB. + Future> fetchAndPersistCourses({ + int page = 1, + }) async { + final response = await _source.getCourses(page: page); + + if (response.results.isNotEmpty) { + final companions = + response.results.map(_courseDtoToCompanion).toList(); + await _db.upsertCourses(companions); + } + + return response; } // ── Chapters ───────────────────────────────────────────────────────────── - Stream> watchChapters(String courseId) { - return _db - .watchChaptersForCourse(courseId) - .map((rows) => rows.map(_rowToChapterDto).toList()); + Stream> watchChapters(String courseId) { + return _db.watchChaptersForCourse(courseId); } Future> refreshChapters(String courseId) async { @@ -46,10 +49,8 @@ class CourseRepository { // ── Lessons ─────────────────────────────────────────────────────────────── - Stream> watchLessons(String chapterId) { - return _db - .watchLessonsForChapter(chapterId) - .map((rows) => rows.map(_rowToLessonDto).toList()); + Stream> watchLessons(String chapterId) { + return _db.watchLessonsForChapter(chapterId); } Future> refreshLessons(String chapterId) async { @@ -60,16 +61,13 @@ class CourseRepository { } /// Direct fetch of a lesson by ID. - Future getLesson(String id) async { - final row = await _db.getLessonById(id); - return row != null ? _rowToLessonDto(row) : null; + Future getLesson(String id) async { + return await _db.getLessonById(id); } /// Watch a single lesson by its ID. - Stream watchLesson(String id) { - return _db - .watchLesson(id) - .map((row) => row != null ? _rowToLessonDto(row) : null); + Stream watchLesson(String id) { + return _db.watchLesson(id); } /// Toggles the bookmark status locally. @@ -104,7 +102,7 @@ class CourseRepository { // Mapping helpers // ───────────────────────────────────────────────────────────────────────── - CourseDto _rowToCourseDto(CoursesTableData row) => CourseDto( + CourseDto rowToCourseDto(CoursesTableData row) => CourseDto( id: row.id, title: row.title, colorIndex: row.colorIndex, @@ -113,6 +111,7 @@ class CourseRepository { progress: row.progress, completedLessons: row.completedLessons, totalLessons: row.totalLessons, + image: row.image, ); CoursesTableCompanion _courseDtoToCompanion(CourseDto dto) => @@ -125,9 +124,10 @@ class CourseRepository { progress: Value(dto.progress), completedLessons: Value(dto.completedLessons), totalLessons: dto.totalLessons, + image: Value(dto.image), ); - ChapterDto _rowToChapterDto(ChaptersTableData row) => ChapterDto( + ChapterDto rowToChapterDto(ChaptersTableData row) => ChapterDto( id: row.id, courseId: row.courseId, title: row.title, @@ -146,7 +146,7 @@ class CourseRepository { orderIndex: dto.orderIndex, ); - LessonDto _rowToLessonDto(LessonsTableData row) => LessonDto( + LessonDto rowToLessonDto(LessonsTableData row) => LessonDto( id: row.id, chapterId: row.chapterId, title: row.title, diff --git a/packages/courses/lib/screens/study_screen.dart b/packages/courses/lib/screens/study_screen.dart index 69fcae9b..e06bfebf 100644 --- a/packages/courses/lib/screens/study_screen.dart +++ b/packages/courses/lib/screens/study_screen.dart @@ -2,7 +2,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:core/core.dart'; import 'package:core/data/data.dart'; -import '../providers/enrollment_provider.dart'; +import '../providers/course_list_provider.dart'; import '../providers/lesson_providers.dart'; import '../providers/recent_activity_provider.dart'; import '../widgets/course_card.dart'; @@ -21,15 +21,35 @@ class StudyScreen extends ConsumerStatefulWidget { class _StudyScreenState extends ConsumerState { final TextEditingController _searchController = TextEditingController(); + final ScrollController _scrollController = ScrollController(); final Set _selectedTypes = {}; String _searchQuery = ''; + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + + // Explicitly trigger the initial sync when the screen is first loaded. + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(courseListProvider.notifier).initialize(); + }); + } + @override void dispose() { _searchController.dispose(); + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); super.dispose(); } + void _onScroll() { + if (_scrollController.position.extentAfter < 500) { + ref.read(courseListProvider.notifier).loadMore(); + } + } + void _toggleType(LessonType type) { setState(() { if (_selectedTypes.contains(type)) { @@ -45,30 +65,30 @@ class _StudyScreenState extends ConsumerState { final design = Design.of(context); final l10n = L10n.of(context); - final enrollmentAsync = ref.watch(enrollmentProvider); + final coursesAsync = ref.watch(courseListProvider); + final isInitialSyncing = ref.watch(isInitialSyncingProvider); + final isMoreSyncing = ref.watch(isMoreSyncingProvider); final allLessons = ref.watch(allLessonsProvider); final resumeAsync = ref.watch(recentActivityProvider); + final syncError = ref.watch(syncErrorProvider); - return Stack( - children: [ - Positioned.fill( - child: enrollmentAsync.when( - data: (courses) { - final filteredCourses = _filterCourses(courses); - final filteredLessons = _filterLessons(allLessons); + final showInitialLoader = + isInitialSyncing && coursesAsync.valueOrNull?.isEmpty == true; - return AppScroll( - padding: EdgeInsets.zero, - children: [ - // Top Header Section (White Background) - Container( - color: design.colors.card, // Pure white in light mode - padding: EdgeInsets.fromLTRB( - design.spacing.md, - design.spacing.md, - design.spacing.md, - design.spacing.md, - ), + return DecoratedBox( + decoration: BoxDecoration(color: design.colors.canvas), + child: Stack( + children: [ + Positioned.fill( + child: CustomScrollView( + controller: _scrollController, + physics: const BouncingScrollPhysics(), + slivers: [ + // 1. Static Header Section + SliverToBoxAdapter( + child: Container( + color: design.colors.card, + padding: EdgeInsets.all(design.spacing.md), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -77,8 +97,6 @@ class _StudyScreenState extends ConsumerState { color: design.colors.textPrimary, ), SizedBox(height: design.spacing.md), - - // Search Bar AppSearchBar( controller: _searchController, hintText: l10n.studySearchHint, @@ -87,151 +105,212 @@ class _StudyScreenState extends ConsumerState { backgroundColor: design.colors.surfaceVariant, ), SizedBox(height: design.spacing.md), - - // Filter Chips - GridView.count( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - crossAxisCount: 2, - mainAxisSpacing: design.spacing.sm, - crossAxisSpacing: design.spacing.sm, - childAspectRatio: 4.5, - padding: EdgeInsets.zero, // Remove grid padding - children: [ - ContentTypeFilterChip( - label: l10n.filterVideo, - icon: LucideIcons.playCircle, - isSelected: _selectedTypes.contains( - LessonType.video, - ), - onTap: () => _toggleType(LessonType.video), - baseColor: design.study.video.background, - accentColor: design.study.video.foreground, - darkAccentColor: design.study.video.foreground, - ), - ContentTypeFilterChip( - label: l10n.filterLesson, - icon: LucideIcons.fileText, - isSelected: _selectedTypes.contains( - LessonType.pdf, - ), - onTap: () => _toggleType(LessonType.pdf), - baseColor: design.study.pdf.background, - accentColor: design.study.pdf.foreground, - darkAccentColor: design.study.pdf.foreground, - ), - ContentTypeFilterChip( - label: l10n.filterAssessment, - icon: LucideIcons.clipboardCheck, - isSelected: _selectedTypes.contains( - LessonType.assessment, - ), - onTap: () => _toggleType(LessonType.assessment), - baseColor: design.study.assessment.background, - accentColor: design.study.assessment.foreground, - darkAccentColor: - design.study.assessment.foreground, - ), - ContentTypeFilterChip( - label: l10n.filterTest, - icon: LucideIcons.shieldCheck, - isSelected: _selectedTypes.contains( - LessonType.test, + _buildFilterChips(context, design, l10n), + if (syncError != null) ...[ + SizedBox(height: design.spacing.md), + ClipRRect( + borderRadius: design.radius.card, + child: Container( + color: design.colors.error, + padding: EdgeInsets.all(design.spacing.sm), + child: Row( + children: [ + Icon( + LucideIcons.alertCircle, + color: const Color(0xFFFFFFFF), + size: 16, + ), + SizedBox(width: design.spacing.sm), + Expanded( + child: AppText.body( + 'Sync issues: $syncError', + color: const Color(0xFFFFFFFF), + ), + ), + ], ), - onTap: () => _toggleType(LessonType.test), - baseColor: design.study.test.background, - accentColor: design.study.test.foreground, - darkAccentColor: design.study.test.foreground, ), - ], - ), + ), + ], ], ), ), + ), - // Separator touching edges - Container(height: 1, color: design.colors.divider), + // Separator + SliverToBoxAdapter( + child: Container( + height: 1, + color: design.colors.divider, + ), + ), - // Content Section (Canvas Background) - Container( - color: design.colors.canvas, - padding: EdgeInsets.fromLTRB( - design.spacing.md, - design.spacing.md, - design.spacing.md, - design.spacing.md, + // Content Title + SliverPadding( + padding: EdgeInsets.all(design.spacing.md), + sliver: SliverToBoxAdapter( + child: AppText.title( + l10n.studyYourCoursesTitle, + color: design.colors.textPrimary, ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (_selectedTypes.isEmpty) ...[ - AppText.title( - l10n.studyYourCoursesTitle, - color: design.colors.textPrimary, - ), - SizedBox(height: design.spacing.md), - ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: filteredCourses.length, - padding: EdgeInsets.zero, - itemBuilder: (context, index) { - final c = filteredCourses[index]; - return Padding( - padding: EdgeInsets.only( - bottom: design.spacing.md, - ), - child: CourseCard( - course: c, - onTap: () => context.push( - '/study/course/${c.id}/chapters', + ), + ), + + // 2. Dynamic Content Section + if (showInitialLoader) + const SliverFillRemaining( + hasScrollBody: false, + child: Center(child: AppLoadingIndicator()), + ) + else + ...coursesAsync.when( + data: (courses) { + final filteredCourses = _filterCourses(courses); + final filteredLessons = _filterLessons(allLessons); + + return [ + if (_selectedTypes.isEmpty) + SliverPadding( + padding: EdgeInsets.symmetric( + horizontal: design.spacing.md, + ), + sliver: SliverList( + delegate: SliverChildBuilderDelegate(( + context, + index, + ) { + final c = filteredCourses[index]; + return Padding( + padding: EdgeInsets.only( + bottom: design.spacing.md, ), - ), - ); - }, - ), - ] else ...[ - AppText.title( - l10n.studyYourCoursesTitle, - color: design.colors.textPrimary, + child: CourseCard( + course: c, + onTap: () => context.push( + '/study/course/${c.id}/chapters', + ), + ), + ); + }, childCount: filteredCourses.length), + ), + ) + else + SliverPadding( + padding: EdgeInsets.symmetric( + horizontal: design.spacing.md, + ), + sliver: SliverList( + delegate: SliverChildBuilderDelegate(( + context, + index, + ) { + final l = filteredLessons[index]; + return _LessonListItem(lesson: l); + }, childCount: filteredLessons.length), + ), ), - SizedBox(height: design.spacing.md), - ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: filteredLessons.length, - padding: EdgeInsets.zero, - itemBuilder: (context, index) { - final l = filteredLessons[index]; - return _LessonListItem(lesson: l); - }, + + if (isMoreSyncing) + SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.only( + bottom: design.spacing.md, + ), + child: const Center( + child: AppLoadingIndicator(), + ), + ), ), - ], - // Bottom padding for resume card - const SizedBox(height: 80), - ], - ), + ]; + }, + loading: () => [ + const SliverToBoxAdapter(child: SizedBox.shrink()), + ], + error: (e, _) => [ + SliverFillRemaining( + hasScrollBody: false, + child: AppErrorView( + message: 'Initialization failed: $e', + onRetry: () => ref + .read(courseListProvider.notifier) + .initialize(), + ), + ), + ], ), - ], - ); - }, - loading: () => const Center(child: AppLoadingIndicator()), - error: (e, _) => Center(child: AppText.body('Error: $e')), + + const SliverToBoxAdapter( + child: SizedBox(height: 120), + ), + ], + ), ), - ), + resumeAsync.when( + data: (activity) => activity != null + ? Positioned( + bottom: design.spacing.md, + left: design.spacing.md, + right: design.spacing.md, + child: StudyResumeCard(activity: activity, onResume: () {}), + ) + : const SizedBox.shrink(), + loading: () => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), + ), + ], + ), + ); + } - // Resume Card (Sticky bottom) - resumeAsync.when( - data: (activity) => activity != null - ? Positioned( - bottom: design.spacing.md, - left: design.spacing.md, - right: design.spacing.md, - child: StudyResumeCard(activity: activity, onResume: () {}), - ) - : const SizedBox.shrink(), - loading: () => const SizedBox.shrink(), - error: (_, _) => const SizedBox.shrink(), + Widget _buildFilterChips( + BuildContext context, + DesignConfig design, + AppLocalizations l10n, + ) { + return GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 2, + mainAxisSpacing: design.spacing.sm, + crossAxisSpacing: design.spacing.sm, + childAspectRatio: 4.5, + padding: EdgeInsets.zero, + children: [ + ContentTypeFilterChip( + label: l10n.filterVideo, + icon: LucideIcons.playCircle, + isSelected: _selectedTypes.contains(LessonType.video), + onTap: () => _toggleType(LessonType.video), + baseColor: design.study.video.background, + accentColor: design.study.video.foreground, + darkAccentColor: design.study.video.foreground, + ), + ContentTypeFilterChip( + label: l10n.filterLesson, + icon: LucideIcons.fileText, + isSelected: _selectedTypes.contains(LessonType.pdf), + onTap: () => _toggleType(LessonType.pdf), + baseColor: design.study.pdf.background, + accentColor: design.study.pdf.foreground, + darkAccentColor: design.study.pdf.foreground, + ), + ContentTypeFilterChip( + label: l10n.filterAssessment, + icon: LucideIcons.clipboardCheck, + isSelected: _selectedTypes.contains(LessonType.assessment), + onTap: () => _toggleType(LessonType.assessment), + baseColor: design.study.assessment.background, + accentColor: design.study.assessment.foreground, + darkAccentColor: design.study.assessment.foreground, + ), + ContentTypeFilterChip( + label: l10n.filterTest, + icon: LucideIcons.shieldCheck, + isSelected: _selectedTypes.contains(LessonType.test), + onTap: () => _toggleType(LessonType.test), + baseColor: design.study.test.background, + accentColor: design.study.test.foreground, + darkAccentColor: design.study.test.foreground, ), ], ); diff --git a/packages/courses/lib/widgets/course_card.dart b/packages/courses/lib/widgets/course_card.dart index 203730f9..5069201d 100644 --- a/packages/courses/lib/widgets/course_card.dart +++ b/packages/courses/lib/widgets/course_card.dart @@ -27,18 +27,33 @@ class CourseCard extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Left Icon Box + // Left Icon/Image Box Container( width: 48, height: 48, decoration: BoxDecoration( - color: design.shortcutPalette.atIndex(1).background, + color: (course.image != null && course.image!.isNotEmpty) + ? null + : design.shortcutPalette.atIndex(1).background, borderRadius: BorderRadius.circular(design.radius.md), ), - child: Icon( - LucideIcons.bookOpen, - color: design.shortcutPalette.atIndex(1).foreground, - size: 24, + child: ClipRRect( + borderRadius: BorderRadius.circular(design.radius.md), + child: (course.image != null && course.image!.isNotEmpty) + ? Image.network( + course.image!, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => Icon( + LucideIcons.bookOpen, + color: design.shortcutPalette.atIndex(1).foreground, + size: 24, + ), + ) + : Icon( + LucideIcons.bookOpen, + color: design.shortcutPalette.atIndex(1).foreground, + size: 24, + ), ), ), SizedBox(width: design.spacing.md), diff --git a/packages/testpress/lib/providers/initialization_provider.dart b/packages/testpress/lib/providers/initialization_provider.dart index 6ba33941..49ac8e00 100644 --- a/packages/testpress/lib/providers/initialization_provider.dart +++ b/packages/testpress/lib/providers/initialization_provider.dart @@ -16,7 +16,8 @@ Future appInitialization(AppInitializationRef ref) async { // Initialize core data in background try { // 1. Refresh the list of enrolled courses from the network/mock source - final courses = await courseRepo.refreshCourses(); + final response = await courseRepo.fetchAndPersistCourses(page: 1); + final courses = response.results; // 2. Refresh chapters and lessons for every enrolled course // This ensures the entire study curriculum is available offline/locally.