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..551264ca --- /dev/null +++ b/openspec/changes/integrate-course-api/proposal.md @@ -0,0 +1,25 @@ +# 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 Alignment**: Align all network request orchestration via `performNetworkRequest`. Consistent error handling is ensured via `ApiException.fromDioException`. +2. **UI Decomposition**: To maintain maintainability and stay below the 250-line file limit, the following new widgets were created: + - `StudyFilterBar`: Specialized widget for complex, animated content type filters. + - `StudyContentList`: Handles the switching between course and lesson lists, including search and pagination states. + +## 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`, `AuthException`. +- `packages/courses`: `CourseRepository`, `StudyScreen`, `CourseList` provider, `lesson_detail_provider`. +- `packages/courses/widgets`: `StudyFilterBar`, `StudyContentList`, `LessonListItem`. 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/config/app_config.dart b/packages/core/lib/data/config/app_config.dart index f25d7dfb..392bce73 100644 --- a/packages/core/lib/data/config/app_config.dart +++ b/packages/core/lib/data/config/app_config.dart @@ -4,7 +4,7 @@ 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. diff --git a/packages/core/lib/data/data.dart b/packages/core/lib/data/data.dart index 88c73555..7ca7720f 100644 --- a/packages/core/lib/data/data.dart +++ b/packages/core/lib/data/data.dart @@ -16,6 +16,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.g.dart b/packages/core/lib/data/db/app_database.g.dart index 212dfaa5..5fa2d291 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(); @@ -4230,6 +4277,7 @@ typedef $$CoursesTableTableCreateCompanionBuilder = Value progress, Value completedLessons, required int totalLessons, + Value image, Value rowid, }); typedef $$CoursesTableTableUpdateCompanionBuilder = @@ -4242,6 +4290,7 @@ typedef $$CoursesTableTableUpdateCompanionBuilder = Value progress, Value completedLessons, Value totalLessons, + Value image, Value rowid, }); @@ -4293,6 +4342,11 @@ class $$CoursesTableTableFilterComposer column: $table.totalLessons, builder: (column) => ColumnFilters(column), ); + + ColumnFilters get image => $composableBuilder( + column: $table.image, + builder: (column) => ColumnFilters(column), + ); } class $$CoursesTableTableOrderingComposer @@ -4343,6 +4397,11 @@ class $$CoursesTableTableOrderingComposer column: $table.totalLessons, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get image => $composableBuilder( + column: $table.image, + builder: (column) => ColumnOrderings(column), + ); } class $$CoursesTableTableAnnotationComposer @@ -4387,6 +4446,9 @@ class $$CoursesTableTableAnnotationComposer column: $table.totalLessons, builder: (column) => column, ); + + GeneratedColumn get image => + $composableBuilder(column: $table.image, builder: (column) => column); } class $$CoursesTableTableTableManager @@ -4428,6 +4490,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, @@ -4438,6 +4501,7 @@ class $$CoursesTableTableTableManager progress: progress, completedLessons: completedLessons, totalLessons: totalLessons, + image: image, rowid: rowid, ), createCompanionCallback: @@ -4450,6 +4514,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, @@ -4460,6 +4525,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/models/course_dto.dart b/packages/core/lib/data/models/course_dto.dart index 782ac1d5..322242c6 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['contents_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 1618bdfc..45419c2d 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/http_data_source.dart b/packages/core/lib/data/sources/http_data_source.dart index 7f2ed5c5..7e291802 100644 --- a/packages/core/lib/data/sources/http_data_source.dart +++ b/packages/core/lib/data/sources/http_data_source.dart @@ -13,9 +13,21 @@ class HttpDataSource implements DataSource { HttpDataSource({required Dio dio}) : _dio = dio; @override - Future> getCourses() => throw UnimplementedError( - 'HttpDataSource.getCourses is not yet implemented. Use MockDataSource.', - ); + Future> getCourses({ + int page = 1, + int pageSize = 10, + }) async { + return performNetworkRequest( + _dio.get( + ApiEndpoints.courseList, + queryParameters: {'page': page, 'page_size': pageSize}, + ), + fromJson: (json) => PaginatedResponseDto.fromJson( + json, + (item) => CourseDto.fromJson(item), + ), + ); + } @override Future> getChapters(String courseId) => @@ -31,8 +43,8 @@ class HttpDataSource implements DataSource { @override Future> getLiveClasses() => throw UnimplementedError( - 'HttpDataSource.getLiveClasses is not yet implemented.', - ); + 'HttpDataSource.getLiveClasses is not yet implemented.', + ); @override Future> getForumThreads(String courseId) => @@ -54,13 +66,13 @@ class HttpDataSource implements DataSource { @override Future> getStudyTips() => throw UnimplementedError( - 'HttpDataSource.getStudyTips is not yet implemented.', - ); + 'HttpDataSource.getStudyTips is not yet implemented.', + ); @override Future> getShortLessons() => throw UnimplementedError( - 'HttpDataSource.getShortLessons is not yet implemented.', - ); + 'HttpDataSource.getShortLessons is not yet implemented.', + ); @override Future> getDiscoveryCourses() => @@ -77,9 +89,7 @@ class HttpDataSource implements DataSource { } @override - Future updateProfile( - Map data, - ) async { + 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) { diff --git a/packages/core/lib/data/sources/mock_data_source.dart b/packages/core/lib/data/sources/mock_data_source.dart index 84f8d9fc..a4880655 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,7 +13,25 @@ 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', @@ -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 094f5220..726e8bb3 100644 --- a/packages/core/lib/network/api_endpoints.dart +++ b/packages/core/lib/network/api_endpoints.dart @@ -7,4 +7,5 @@ class ApiEndpoints { 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/'; + static const String courseList = '/api/v3/courses/'; } 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 ec735906..c6e9c625 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 a7bab156..ccf3595b 100644 --- a/packages/courses/lib/courses.dart +++ b/packages/courses/lib/courses.dart @@ -32,6 +32,5 @@ export 'screens/video_lesson_detail_screen.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'; 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_detail_provider.g.dart b/packages/courses/lib/providers/course_detail_provider.g.dart index 1da28b20..8257d13a 100644 --- a/packages/courses/lib/providers/course_detail_provider.g.dart +++ b/packages/courses/lib/providers/course_detail_provider.g.dart @@ -6,7 +6,7 @@ part of 'course_detail_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$courseDetailHash() => r'efaccc3ef8321cc774541333cd60a71e4c247874'; +String _$courseDetailHash() => r'927296910cefd5d09631d83082bcb74bd94b87be'; /// Copied from Dart SDK class _SystemHash { diff --git a/packages/courses/lib/providers/course_list_provider.dart b/packages/courses/lib/providers/course_list_provider.dart index 6546ff8c..6c72a76f 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(); +/// Tracks if the initial API sync for the course list was completed in this session. +final _wasInitialSyncDone = StateProvider((ref) => false); + +/// Tracks if the first page of courses is currently being synced from the network. +final isSyncingInitialPage = StateProvider((ref) => false); + +/// Tracks if additional pages (pagination) are currently being synced. +final isSyncingMoreResults = StateProvider((ref) => false); + +/// Detailed error object from the most recent course sync operation. +final courseListSyncError = StateProvider((ref) => null); + +@Riverpod(keepAlive: true) +class CourseList extends _$CourseList { + PaginationState _paginationTracker = const PaginationState(); + Future? _pendingSyncRequest; + + @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(_wasInitialSyncDone)) return; + if (_pendingSyncRequest != null) return _pendingSyncRequest; + + _pendingSyncRequest = _performSync(isReset: true); + try { + await _pendingSyncRequest; + ref.read(_wasInitialSyncDone.notifier).state = true; + } finally { + _pendingSyncRequest = null; + } + } + + /// Loads the next page from the API. + Future loadMore() async { + if (!_paginationTracker.hasMore || _pendingSyncRequest != null) return; + + _pendingSyncRequest = _performSync(isReset: false); + try { + await _pendingSyncRequest; + } finally { + _pendingSyncRequest = null; + } + } + + Future _performSync({required bool isReset}) async { + // Reset any previous errors + ref.read(courseListSyncError.notifier).state = null; + + if (isReset) { + _paginationTracker = const PaginationState(); + ref.read(isSyncingInitialPage.notifier).state = true; + } else { + ref.read(isSyncingMoreResults.notifier).state = true; + } + + try { + final repo = await ref.read(courseRepositoryProvider.future); + final response = await repo.refreshCourses( + page: _paginationTracker.nextPage, + ); + + // Explicit logic to mark completeness if no results + if (response.results.isEmpty) { + _paginationTracker = _paginationTracker.copyWith(hasMore: false); + } else { + const pagination = PaginationService(); + _paginationTracker = pagination.calculateNextState( + response: response, + currentPage: _paginationTracker.nextPage, + ); + } + } catch (e) { + // Capture the error but don't rethrow (so stream from DB is still visible) + ref.read(courseListSyncError.notifier).state = e; + } finally { + if (isReset) { + ref.read(isSyncingInitialPage.notifier).state = false; + } else { + ref.read(isSyncingMoreResults.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..f19612be 100644 --- a/packages/courses/lib/providers/course_list_provider.g.dart +++ b/packages/courses/lib/providers/course_list_provider.g.dart @@ -23,28 +23,7 @@ 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'; +String _$courseChaptersHash() => r'a1cf6c296397d53753ee81c92c7318be1788b8a6'; /// Copied from Dart SDK class _SystemHash { @@ -198,7 +177,7 @@ class _CourseChaptersProviderElement String get courseId => (origin as CourseChaptersProvider).courseId; } -String _$chapterLessonsHash() => r'ebc358051bfc9dec93d19ae70774d72609a16e3d'; +String _$chapterLessonsHash() => r'ce0c119a5b40df61a52c1451589e19c174b7c00e'; /// Provider for a specific chapter's lessons. /// @@ -331,5 +310,21 @@ class _ChapterLessonsProviderElement String get chapterId => (origin as ChapterLessonsProvider).chapterId; } +String _$courseListHash() => r'90fdf39ff3628f22fb77c401187d23eeb54aee12'; + +/// See also [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_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/providers/lesson_providers.g.dart b/packages/courses/lib/providers/lesson_providers.g.dart index 175a94a3..4ea68fbe 100644 --- a/packages/courses/lib/providers/lesson_providers.g.dart +++ b/packages/courses/lib/providers/lesson_providers.g.dart @@ -6,7 +6,7 @@ part of 'lesson_providers.dart'; // RiverpodGenerator // ************************************************************************** -String _$allLessonsHash() => r'0f0d5e4507484e7bd07c2d474a40b4e190d492ba'; +String _$allLessonsHash() => r'2e3abd56f29f24bcccd2ec4671b99e843582e019'; /// Flattens the course/chapter hierarchy into a single stream of all lessons. /// This allows for efficient O(N) filtering in the UI. diff --git a/packages/courses/lib/repositories/course_repository.dart b/packages/courses/lib/repositories/course_repository.dart index 4d7cdfe4..dbbe7ec1 100644 --- a/packages/courses/lib/repositories/course_repository.dart +++ b/packages/courses/lib/repositories/course_repository.dart @@ -14,26 +14,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> refreshCourses({ + 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 { @@ -45,10 +48,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 { @@ -61,14 +62,12 @@ 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; + return row != null ? rowToLessonDto(row) : null; } /// 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. @@ -103,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, @@ -112,6 +111,7 @@ class CourseRepository { progress: row.progress, completedLessons: row.completedLessons, totalLessons: row.totalLessons, + image: row.image, ); CoursesTableCompanion _courseDtoToCompanion(CourseDto dto) => @@ -124,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, @@ -145,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..5691080e 100644 --- a/packages/courses/lib/screens/study_screen.dart +++ b/packages/courses/lib/screens/study_screen.dart @@ -2,12 +2,12 @@ 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'; -import '../widgets/content_type_filter_chip.dart'; import '../widgets/study_resume_card.dart'; +import '../widgets/study_filter_bar.dart'; +import '../widgets/study_content_list.dart'; /// The main Study screen for paid active users. /// @@ -21,21 +21,42 @@ class StudyScreen extends ConsumerStatefulWidget { class _StudyScreenState extends ConsumerState { final TextEditingController _searchController = TextEditingController(); - final Set _selectedTypes = {}; + final ScrollController _scrollController = ScrollController(); + final Set _activeTypeFilters = {}; 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 _toggleType(LessonType type) { + void _onScroll() { + const scrollThreshold = 500; + if (_scrollController.position.extentAfter < scrollThreshold) { + ref.read(courseListProvider.notifier).loadMore(); + } + } + + void _toggleTypeFilter(LessonType type) { setState(() { - if (_selectedTypes.contains(type)) { - _selectedTypes.remove(type); + if (_activeTypeFilters.contains(type)) { + _activeTypeFilters.remove(type); } else { - _selectedTypes.add(type); + _activeTypeFilters.add(type); } }); } @@ -45,30 +66,28 @@ class _StudyScreenState extends ConsumerState { final design = Design.of(context); final l10n = L10n.of(context); - final enrollmentAsync = ref.watch(enrollmentProvider); + final enrolledCoursesState = ref.watch(courseListProvider); + final isSyncingInitial = ref.watch(isSyncingInitialPage); + final isSyncingMore = ref.watch(isSyncingMoreResults); final allLessons = ref.watch(allLessonsProvider); - final resumeAsync = ref.watch(recentActivityProvider); - - return Stack( - children: [ - Positioned.fill( - child: enrollmentAsync.when( - data: (courses) { - final filteredCourses = _filterCourses(courses); - final filteredLessons = _filterLessons(allLessons); - - 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, - ), + final recentActivityState = ref.watch(recentActivityProvider); + final activeSyncError = ref.watch(courseListSyncError); + + + 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,277 +96,97 @@ class _StudyScreenState extends ConsumerState { color: design.colors.textPrimary, ), SizedBox(height: design.spacing.md), - - // Search Bar AppSearchBar( controller: _searchController, hintText: l10n.studySearchHint, - onChanged: (val) => - setState(() => _searchQuery = val), + onChanged: (newQuery) => + setState(() => _searchQuery = newQuery), 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, - ), - onTap: () => _toggleType(LessonType.test), - baseColor: design.study.test.background, - accentColor: design.study.test.foreground, - darkAccentColor: design.study.test.foreground, - ), - ], + StudyFilterBar( + activeTypeFilters: _activeTypeFilters, + onTypeToggled: _toggleTypeFilter, ), - ], - ), - ), - - // Separator touching edges - 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, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (_selectedTypes.isEmpty) ...[ - AppText.title( - l10n.studyYourCoursesTitle, - color: design.colors.textPrimary, - ), + if (activeSyncError != null) ...[ 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', + 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, ), - ), - ); - }, - ), - ] else ...[ - AppText.title( - l10n.studyYourCoursesTitle, - color: design.colors.textPrimary, - ), - 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); - }, + SizedBox(width: design.spacing.sm), + Expanded( + child: AppText.body( + 'Sync issues: $activeSyncError', + color: const Color(0xFFFFFFFF), + ), + ), + ], + ), + ), ), ], - // Bottom padding for resume card - const SizedBox(height: 80), ], ), ), - ], - ); - }, - loading: () => const Center(child: AppLoadingIndicator()), - error: (e, _) => Center(child: AppText.body('Error: $e')), - ), - ), - - // 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(), - ), - ], - ); - } - - List _filterCourses(List courses) { - if (_searchQuery.isEmpty) return courses; - return courses - .where( - (c) => c.title.toLowerCase().contains(_searchQuery.toLowerCase()), - ) - .toList(); - } - - List _filterLessons(List lessons) { - if (_selectedTypes.isEmpty) return []; - - return lessons.where((lesson) { - if (!_selectedTypes.contains(lesson.type)) return false; - if (_searchQuery.isEmpty) return true; - return lesson.title.toLowerCase().contains(_searchQuery.toLowerCase()); - }).toList(); - } -} - -class _LessonListItem extends StatelessWidget { - const _LessonListItem({required this.lesson}); - final LessonDto lesson; - - @override - Widget build(BuildContext context) { - final design = Design.of(context); - final l10n = L10n.of(context); - - IconData icon; - ShortcutColors typeTheme; - switch (lesson.type) { - case LessonType.video: - icon = LucideIcons.playCircle; - typeTheme = design.study.video; - break; - case LessonType.pdf: - icon = LucideIcons.fileText; - typeTheme = design.study.pdf; - break; - case LessonType.assessment: - icon = LucideIcons.clipboardCheck; - typeTheme = design.study.assessment; - break; - case LessonType.test: - icon = LucideIcons.shieldCheck; - typeTheme = design.study.test; - break; - } - - final color = typeTheme.foreground; - final backgroundColor = typeTheme.background; + ), - return AppSemantics.button( - label: l10n.openDetailedLesson(lesson.title), - onTap: () { - final route = switch (lesson.type) { - LessonType.video => '/study/video/${lesson.id}', - LessonType.pdf => '/study/lesson/${lesson.id}', - LessonType.assessment => '/study/assessment/${lesson.id}', - LessonType.test => '/study/test/${lesson.id}', - }; - context.push(route); - }, - child: AppFocusable( - onTap: () { - final route = switch (lesson.type) { - LessonType.video => '/study/video/${lesson.id}', - LessonType.pdf => '/study/lesson/${lesson.id}', - LessonType.assessment => '/study/assessment/${lesson.id}', - LessonType.test => '/study/test/${lesson.id}', - }; - context.push(route); - }, - borderRadius: design.radius.card, - child: AppCard( - child: Row( - children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: backgroundColor, - borderRadius: BorderRadius.circular(design.radius.md), + // Separator + SliverToBoxAdapter( + child: Container( + height: 1, + color: design.colors.divider, + ), ), - child: Icon(icon, color: color, size: 20), - ), - SizedBox(width: design.spacing.md), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AppText.label( - lesson.title, + + // Content Title + SliverPadding( + padding: EdgeInsets.all(design.spacing.md), + sliver: SliverToBoxAdapter( + child: AppText.title( + l10n.studyYourCoursesTitle, color: design.colors.textPrimary, ), - AppText.caption( - '${lesson.type.name.toUpperCase()} · ${lesson.duration}', - color: design.colors.textSecondary, - ), - ], + ), + ), + + // 2. Dynamic Content Section + StudyContentList( + enrolledCoursesState: enrolledCoursesState, + isSyncingInitial: isSyncingInitial, + isSyncingMore: isSyncingMore, + allLessons: allLessons, + activeTypeFilters: _activeTypeFilters, + searchQuery: _searchQuery, + ), + + const SliverToBoxAdapter( + child: SizedBox(height: 120), ), - ), - Icon( - LucideIcons.chevronRight, - color: design.colors.textSecondary, - size: 20, - ), - ], + ], + ), + ), + recentActivityState.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(), ), - ), + ], ), ); } 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/courses/lib/widgets/study_content_list.dart b/packages/courses/lib/widgets/study_content_list.dart new file mode 100644 index 00000000..bb1be235 --- /dev/null +++ b/packages/courses/lib/widgets/study_content_list.dart @@ -0,0 +1,132 @@ +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/course_list_provider.dart'; +import '../widgets/course_card.dart'; +import '../widgets/lesson_list_item.dart'; + +class StudyContentList extends ConsumerWidget { + final AsyncValue> enrolledCoursesState; + final bool isSyncingInitial; + final bool isSyncingMore; + final List allLessons; + final Set activeTypeFilters; + final String searchQuery; + + const StudyContentList({ + super.key, + required this.enrolledCoursesState, + required this.isSyncingInitial, + required this.isSyncingMore, + required this.allLessons, + required this.activeTypeFilters, + required this.searchQuery, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final design = Design.of(context); + + final showInitialLoader = + isSyncingInitial && enrolledCoursesState.valueOrNull?.isEmpty == true; + + if (showInitialLoader) { + return const SliverFillRemaining( + hasScrollBody: false, + child: Center(child: AppLoadingIndicator()), + ); + } + + return enrolledCoursesState.when( + data: (courses) { + final filteredCourses = _filterCourses(courses); + final filteredLessons = _filterLessons(allLessons); + + return SliverMainAxisGroup( + slivers: [ + if (activeTypeFilters.isEmpty) + SliverPadding( + padding: EdgeInsets.symmetric(horizontal: design.spacing.md), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final course = filteredCourses[index]; + return Padding( + padding: EdgeInsets.only(bottom: design.spacing.md), + child: CourseCard( + course: course, + onTap: () => context.push( + '/study/course/${course.id}/chapters', + ), + ), + ); + }, + childCount: filteredCourses.length, + ), + ), + ) + else + SliverPadding( + padding: EdgeInsets.symmetric(horizontal: design.spacing.md), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final lesson = filteredLessons[index]; + return LessonListItem( + lesson: lesson, + onTap: () { + final route = switch (lesson.type) { + LessonType.video => '/study/video/${lesson.id}', + LessonType.pdf => '/study/lesson/${lesson.id}', + LessonType.assessment => '/study/assessment/${lesson.id}', + LessonType.test => '/study/test/${lesson.id}', + }; + context.push(route); + }, + ); + }, + childCount: filteredLessons.length, + ), + ), + ), + if (isSyncingMore) + SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.only(bottom: design.spacing.md), + child: const Center(child: AppLoadingIndicator()), + ), + ), + ], + ); + }, + loading: () => const SliverToBoxAdapter(child: SizedBox.shrink()), + error: (e, _) => SliverFillRemaining( + hasScrollBody: false, + child: AppErrorView( + message: 'Initialization failed: $e', + onRetry: () => ref.read(courseListProvider.notifier).initialize(), + ), + ), + ); + } + + List _filterCourses(List courses) { + if (searchQuery.isEmpty) return courses; + return courses + .where( + (course) => course.title.toLowerCase().contains(searchQuery.toLowerCase()), + ) + .toList(); + } + + List _filterLessons(List lessons) { + if (activeTypeFilters.isEmpty) return []; + + return lessons.where((lesson) { + if (!activeTypeFilters.contains(lesson.type)) return false; + if (searchQuery.isEmpty) return true; + return lesson.title.toLowerCase().contains(searchQuery.toLowerCase()); + }).toList(); + } +} diff --git a/packages/courses/lib/widgets/study_filter_bar.dart b/packages/courses/lib/widgets/study_filter_bar.dart new file mode 100644 index 00000000..ff75fc66 --- /dev/null +++ b/packages/courses/lib/widgets/study_filter_bar.dart @@ -0,0 +1,54 @@ +import 'package:flutter/widgets.dart'; +import 'package:core/core.dart'; +import 'package:core/data/data.dart'; +import 'content_type_filter_chip.dart'; + +class StudyFilterBar extends StatelessWidget { + final Set activeTypeFilters; + final ValueChanged onTypeToggled; + + const StudyFilterBar({ + super.key, + required this.activeTypeFilters, + required this.onTypeToggled, + }); + + @override + Widget build(BuildContext context) { + final design = Design.of(context); + final l10n = L10n.of(context); + + final filterConfigs = [ + (LessonType.video, l10n.filterVideo, LucideIcons.playCircle, design.study.video), + (LessonType.pdf, l10n.filterLesson, LucideIcons.fileText, design.study.pdf), + (LessonType.assessment, l10n.filterAssessment, LucideIcons.clipboardCheck, design.study.assessment), + (LessonType.test, l10n.filterTest, LucideIcons.shieldCheck, design.study.test), + ]; + + 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: filterConfigs.map((config) { + final type = config.$1; + final label = config.$2; + final icon = config.$3; + final theme = config.$4; + + return ContentTypeFilterChip( + label: label, + icon: icon, + isSelected: activeTypeFilters.contains(type), + onTap: () => onTypeToggled(type), + baseColor: theme.background, + accentColor: theme.foreground, + darkAccentColor: theme.foreground, + ); + }).toList(), + ); + } +} diff --git a/packages/testpress/lib/providers/initialization_provider.dart b/packages/testpress/lib/providers/initialization_provider.dart index ff75cc8c..b880e894 100644 --- a/packages/testpress/lib/providers/initialization_provider.dart +++ b/packages/testpress/lib/providers/initialization_provider.dart @@ -18,7 +18,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.refreshCourses(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.