From e57026c9dc73cbb651d509b24405ad4a620f3621 Mon Sep 17 00:00:00 2001 From: syed-tp Date: Tue, 31 Mar 2026 11:54:57 +0530 Subject: [PATCH 1/2] refactor: flatten CourseDto and ChapterDto by removing nested collections and updating associated providers --- .../dto-hierarchy-flattening/.openspec.yaml | 2 + .../dto-hierarchy-flattening/design.md | 30 +++++++++++++ .../dto-hierarchy-flattening/proposal.md | 24 ++++++++++ .../specs/dto-flattening/spec.md | 25 +++++++++++ .../changes/dto-hierarchy-flattening/tasks.md | 17 +++++++ packages/core/lib/data/db/app_database.dart | 3 ++ .../core/lib/data/models/chapter_dto.dart | 10 ----- packages/core/lib/data/models/course_dto.dart | 10 ----- .../providers/chapter_detail_provider.dart | 45 +++---------------- .../providers/chapter_detail_provider.g.dart | 32 ++++++------- .../lib/providers/course_detail_provider.dart | 45 ++++++------------- .../providers/course_detail_provider.g.dart | 34 ++++---------- .../lib/providers/lesson_providers.dart | 13 +++--- .../lib/providers/lesson_providers.g.dart | 9 ++-- .../lib/repositories/course_repository.dart | 7 +++ .../lib/screens/chapter_detail_page.dart | 30 ++++++++++--- .../lib/screens/chapters_list_page.dart | 6 ++- .../courses/lib/screens/study_screen.dart | 3 +- 18 files changed, 189 insertions(+), 156 deletions(-) create mode 100644 openspec/changes/dto-hierarchy-flattening/.openspec.yaml create mode 100644 openspec/changes/dto-hierarchy-flattening/design.md create mode 100644 openspec/changes/dto-hierarchy-flattening/proposal.md create mode 100644 openspec/changes/dto-hierarchy-flattening/specs/dto-flattening/spec.md create mode 100644 openspec/changes/dto-hierarchy-flattening/tasks.md diff --git a/openspec/changes/dto-hierarchy-flattening/.openspec.yaml b/openspec/changes/dto-hierarchy-flattening/.openspec.yaml new file mode 100644 index 00000000..fbf76661 --- /dev/null +++ b/openspec/changes/dto-hierarchy-flattening/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-30 diff --git a/openspec/changes/dto-hierarchy-flattening/design.md b/openspec/changes/dto-hierarchy-flattening/design.md new file mode 100644 index 00000000..a4f1b325 --- /dev/null +++ b/openspec/changes/dto-hierarchy-flattening/design.md @@ -0,0 +1,30 @@ +## Context + +The current `CourseDto` and `ChapterDto` models were originally scaffolded with nested collections (`List` and `List`). However, the backend API for this project is strictly flat (using separate endpoints for chapters and lessons). Keeping these nested fields in the DTO layer creates architectural confusion and deviates from the "Single Source of Truth" in the local Drift database, which is also flat. + +## Goals / Non-Goals + +**Goals:** +- Remove all nested collection fields from `CourseDto` and `ChapterDto`. +- Simplify `fromJson` and `toJson` methods to focus on flat property mapping. +- Update `MockDataSource` to provide flat, non-nested objects for courses and chapters. + +**Non-Goals:** +- Modifying the underlying Drift database schema (it is already flat). +- Changing the `CourseRepository` stream logic (it already uses ID-based filtering for chapters and lessons). + +## Decisions + +### Decision 1: Complete Removal of Nested Fields +We will completely remove the `chapters` and `lessons` fields from the DTO constructors and properties rather than making them nullable. +- **Rationale**: If we made them nullable, developers might still be tempted to use them, leading to null-pointer bugs or empty UI states. Total removal enforces the correct use of the `DataSource` methods (`getChapters`, `getLessons`). +- **Alternative**: Keeping them as optional. Rejected because it maintains the "False Expectation" trap. + +### Decision 2: Tolerant JSON Parsing +The `fromJson` methods will be updated to purely map properties from the JSON map without iterating over nested results. +- **Rationale**: If the backend happens to send nested data (e.g., during a transitional phase), the DTO will simply ignore those keys, preventing parsing errors while maintaining its own internal consistency. + +## Risks / Trade-offs + +- **Risk: Breaking UI (Compile-time)**: Any UI code that was previously accessing `course.chapters` will fail to compile. +- **Mitigation**: This is desirable as it forces the UI to use the `Repository` streams (`watchChapters`), which is the correct pattern. diff --git a/openspec/changes/dto-hierarchy-flattening/proposal.md b/openspec/changes/dto-hierarchy-flattening/proposal.md new file mode 100644 index 00000000..a0fcf67e --- /dev/null +++ b/openspec/changes/dto-hierarchy-flattening/proposal.md @@ -0,0 +1,24 @@ +## Why + +The current `CourseDto` and `ChapterDto` models contain nested `List` fields (chapters in courses, lessons in chapters) that are never populated by the flat backend. This creates false expectations for developers, leads to potential data inconsistency between the local cache and objects, and adds unnecessary complexity to the network models. + +## What Changes + +- **REMOVE**: `List chapters` from `CourseDto`. **BREAKING** +- **REMOVE**: `List lessons` from `ChapterDto`. **BREAKING** +- **UPDATE**: `fromJson` and `toJson` methods in both DTOs to reflect the flat structure. +- **UPDATE**: `MockDataSource` to return flat object structures without attempting to inject nested data. + +## Capabilities + +### New Capabilities +- `dto-flattening`: Defines the strict flat structure for course and chapter DTOs, ensuring they accurately reflect the backend's "Single Object" API design. + +### Modified Capabilities + + +## Impact + +- **Affected Files**: `packages/core/lib/data/models/course_dto.dart`, `packages/core/lib/data/models/chapter_dto.dart`, `packages/core/lib/data/sources/mock_data_source.dart`. +- **APIs**: No changes to external backend APIs, but internal DTO construction and consumption will change. +- **Dependencies**: No new dependencies. diff --git a/openspec/changes/dto-hierarchy-flattening/specs/dto-flattening/spec.md b/openspec/changes/dto-hierarchy-flattening/specs/dto-flattening/spec.md new file mode 100644 index 00000000..890f80e3 --- /dev/null +++ b/openspec/changes/dto-hierarchy-flattening/specs/dto-flattening/spec.md @@ -0,0 +1,25 @@ +## ADDED Requirements + +### Requirement: Flat Course DTO Structure +The `CourseDto` SHALL NOT contain a nested list of chapters. It MUST only contain basic course metadata (id, title, color, chapterCount, totalDuration, progress, completedLessons, totalLessons). + +#### Scenario: Simplified Course Creation +- **WHEN** a `CourseDto` is instantiated +- **THEN** it SHALL NOT accept or store a `chapters` collection +- **AND** it SHALL provide exactly the flat fields required for the course list + +### Requirement: Flat Chapter DTO Structure +The `ChapterDto` SHALL NOT contain a nested list of lessons. It MUST only contain basic chapter metadata (id, courseId, title, lessonCount, assessmentCount, orderIndex). + +#### Scenario: Simplified Chapter Creation +- **WHEN** a `ChapterDto` is instantiated +- **THEN** it SHALL NOT accept or store a `lessons` collection +- **AND** it SHALL provide exactly the flat fields required for the chapter list + +### Requirement: JSON Serialization Compatibility +The `CourseDto` and `ChapterDto` SHALL ignore `chapters` and `lessons` keys during JSON parsing to ensure forward compatibility with legacy or nested backend responses. + +#### Scenario: Aggressive JSON Cleaning +- **WHEN** a JSON payload containing nested `chapters` or `lessons` is passed to `fromJson` +- **THEN** the parsing SHOULD succeed without trying to map the nested collections +- **AND** the resulting DTO object SHALL be flat diff --git a/openspec/changes/dto-hierarchy-flattening/tasks.md b/openspec/changes/dto-hierarchy-flattening/tasks.md new file mode 100644 index 00000000..9c35e922 --- /dev/null +++ b/openspec/changes/dto-hierarchy-flattening/tasks.md @@ -0,0 +1,17 @@ +## 1. DTO Structural Flattening + +- [x] 1.1 Remove `chapters` field from `CourseDto` in `packages/core/lib/data/models/course_dto.dart` and update constructor/copyWith. +- [x] 1.2 Update `CourseDto.fromJson` and `toJson` to remove nested list handling. +- [x] 1.3 Remove `lessons` field from `ChapterDto` in `packages/core/lib/data/models/chapter_dto.dart` and update constructor/copyWith. +- [x] 1.4 Update `ChapterDto.fromJson` and `toJson` to remove nested list handling. + +## 2. Mock Data Adaptation + +- [x] 2.1 Update `MockDataSource.getCourses()` in `packages/core/lib/data/sources/mock_data_source.dart` to return flat `CourseDto` objects. +- [x] 2.2 Update `MockDataSource.getChapters()` and specific chapter sub-methods to return flat `ChapterDto` objects. +- [x] 2.3 Refactor internal mock data helpers to ensure all collections are returned via their respective `get...` methods. + +## 3. Propagation & Cleanup + +- [x] 3.1 Update `CourseRepository` in `packages/courses/lib/repositories/course_repository.dart` to ensure it only uses ID-based fetching. +- [x] 3.2 Verify project-wide compilation and fix any broken UI references. diff --git a/packages/core/lib/data/db/app_database.dart b/packages/core/lib/data/db/app_database.dart index 494965da..eec8eeb8 100644 --- a/packages/core/lib/data/db/app_database.dart +++ b/packages/core/lib/data/db/app_database.dart @@ -225,6 +225,9 @@ class AppDatabase extends _$AppDatabase { Future upsertLessons(List rows) => batch((b) => b.insertAllOnConflictUpdate(lessonsTable, rows)); + /// Watch all lessons in the database. + Stream> watchAllLessons() => select(lessonsTable).watch(); + // ── Live Classes ────────────────────────────────────────────────────────── Stream> watchAllLiveClasses() => diff --git a/packages/core/lib/data/models/chapter_dto.dart b/packages/core/lib/data/models/chapter_dto.dart index ed91c389..64dc1736 100644 --- a/packages/core/lib/data/models/chapter_dto.dart +++ b/packages/core/lib/data/models/chapter_dto.dart @@ -1,4 +1,3 @@ -import 'lesson_dto.dart'; /// Chapter DTO — one chapter within a course. class ChapterDto { @@ -8,7 +7,6 @@ class ChapterDto { final int lessonCount; final int assessmentCount; final int orderIndex; - final List lessons; const ChapterDto({ required this.id, @@ -17,7 +15,6 @@ class ChapterDto { required this.lessonCount, required this.assessmentCount, required this.orderIndex, - this.lessons = const [], }); ChapterDto copyWith({ @@ -27,7 +24,6 @@ class ChapterDto { int? lessonCount, int? assessmentCount, int? orderIndex, - List? lessons, }) { return ChapterDto( id: id ?? this.id, @@ -36,7 +32,6 @@ class ChapterDto { lessonCount: lessonCount ?? this.lessonCount, assessmentCount: assessmentCount ?? this.assessmentCount, orderIndex: orderIndex ?? this.orderIndex, - lessons: lessons ?? this.lessons, ); } @@ -48,10 +43,6 @@ class ChapterDto { lessonCount: json['lessonCount'] as int, assessmentCount: json['assessmentCount'] as int, orderIndex: json['orderIndex'] as int, - lessons: (json['lessons'] as List?) - ?.map((e) => LessonDto.fromJson(e as Map)) - .toList() ?? - const [], ); } @@ -63,7 +54,6 @@ class ChapterDto { 'lessonCount': lessonCount, 'assessmentCount': assessmentCount, 'orderIndex': orderIndex, - 'lessons': lessons.map((e) => e.toJson()).toList(), }; } } diff --git a/packages/core/lib/data/models/course_dto.dart b/packages/core/lib/data/models/course_dto.dart index 782ac1d5..d042cc09 100644 --- a/packages/core/lib/data/models/course_dto.dart +++ b/packages/core/lib/data/models/course_dto.dart @@ -1,4 +1,3 @@ -import 'chapter_dto.dart'; /// Course DTO — plain Dart object transferred from DataSource to Drift and back to UI. class CourseDto { @@ -15,7 +14,6 @@ class CourseDto { final int progress; // 0–100 final int completedLessons; final int totalLessons; - final List chapters; const CourseDto({ required this.id, @@ -26,7 +24,6 @@ class CourseDto { required this.progress, required this.completedLessons, required this.totalLessons, - this.chapters = const [], }); CourseDto copyWith({ @@ -38,7 +35,6 @@ class CourseDto { int? progress, int? completedLessons, int? totalLessons, - List? chapters, }) { return CourseDto( id: id ?? this.id, @@ -49,7 +45,6 @@ class CourseDto { progress: progress ?? this.progress, completedLessons: completedLessons ?? this.completedLessons, totalLessons: totalLessons ?? this.totalLessons, - chapters: chapters ?? this.chapters, ); } @@ -63,10 +58,6 @@ class CourseDto { progress: json['progress'] as int, completedLessons: json['completedLessons'] as int, totalLessons: json['totalLessons'] as int, - chapters: (json['chapters'] as List?) - ?.map((e) => ChapterDto.fromJson(e as Map)) - .toList() ?? - const [], ); } @@ -80,7 +71,6 @@ class CourseDto { 'progress': progress, 'completedLessons': completedLessons, 'totalLessons': totalLessons, - 'chapters': chapters.map((e) => e.toJson()).toList(), }; } } diff --git a/packages/courses/lib/providers/chapter_detail_provider.dart b/packages/courses/lib/providers/chapter_detail_provider.dart index 5f687bb3..eb99181d 100644 --- a/packages/courses/lib/providers/chapter_detail_provider.dart +++ b/packages/courses/lib/providers/chapter_detail_provider.dart @@ -1,48 +1,17 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; -import '../models/course_content.dart'; -import 'course_detail_provider.dart'; +import 'package:core/data/data.dart'; +import 'course_list_provider.dart'; part 'chapter_detail_provider.g.dart'; -/// Provider that fetches a specific chapter with its lessons. -/// This provider maps the underlying DTOs to the [Chapter] domain model. +/// Fetches a specific chapter by ID within a course context. @riverpod -Future chapterDetail( +Future chapterDetail( ChapterDetailRef ref, String courseId, String chapterId, ) async { - final courseDto = await ref.watch(courseDetailProvider(courseId).future); - if (courseDto == null) return null; - - final chapterDto = courseDto.chapters - .where((c) => c.id == chapterId) - .firstOrNull; - if (chapterDto == null) return null; - - return Chapter( - id: chapterDto.id, - title: chapterDto.title, - lessonCount: chapterDto.lessonCount, - assessmentCount: chapterDto.assessmentCount, - courseTitle: courseDto.title, - lessons: chapterDto.lessons - .map( - (l) => Lesson( - id: l.id, - title: l.title, - type: l.type, - progressStatus: l.progressStatus, - duration: l.duration, - isLocked: l.isLocked, - subtitle: l.subtitle, - subjectName: l.subjectName, - subjectIndex: l.subjectIndex, - lessonNumber: l.lessonNumber, - totalLessons: l.totalLessons, - contentUrl: l.contentUrl, - ), - ) - .toList(), - ); + final repo = await ref.watch(courseRepositoryProvider.future); + final chapters = await repo.watchChapters(courseId).first; + return chapters.where((c) => c.id == chapterId).firstOrNull; } diff --git a/packages/courses/lib/providers/chapter_detail_provider.g.dart b/packages/courses/lib/providers/chapter_detail_provider.g.dart index e862c9d9..201014b4 100644 --- a/packages/courses/lib/providers/chapter_detail_provider.g.dart +++ b/packages/courses/lib/providers/chapter_detail_provider.g.dart @@ -6,7 +6,7 @@ part of 'chapter_detail_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$chapterDetailHash() => r'489780e50683a7269b2a5049c66d9e34a2e92a9e'; +String _$chapterDetailHash() => r'd91b4c4b073a8fdc22882e400cbd2246c4d49df6'; /// Copied from Dart SDK class _SystemHash { @@ -29,26 +29,22 @@ class _SystemHash { } } -/// Provider that fetches a specific chapter with its lessons. -/// This provider maps the underlying DTOs to the [Chapter] domain model. +/// Fetches a specific chapter by ID within a course context. /// /// Copied from [chapterDetail]. @ProviderFor(chapterDetail) const chapterDetailProvider = ChapterDetailFamily(); -/// Provider that fetches a specific chapter with its lessons. -/// This provider maps the underlying DTOs to the [Chapter] domain model. +/// Fetches a specific chapter by ID within a course context. /// /// Copied from [chapterDetail]. -class ChapterDetailFamily extends Family> { - /// Provider that fetches a specific chapter with its lessons. - /// This provider maps the underlying DTOs to the [Chapter] domain model. +class ChapterDetailFamily extends Family> { + /// Fetches a specific chapter by ID within a course context. /// /// Copied from [chapterDetail]. const ChapterDetailFamily(); - /// Provider that fetches a specific chapter with its lessons. - /// This provider maps the underlying DTOs to the [Chapter] domain model. + /// Fetches a specific chapter by ID within a course context. /// /// Copied from [chapterDetail]. ChapterDetailProvider call(String courseId, String chapterId) { @@ -77,13 +73,11 @@ class ChapterDetailFamily extends Family> { String? get name => r'chapterDetailProvider'; } -/// Provider that fetches a specific chapter with its lessons. -/// This provider maps the underlying DTOs to the [Chapter] domain model. +/// Fetches a specific chapter by ID within a course context. /// /// Copied from [chapterDetail]. -class ChapterDetailProvider extends AutoDisposeFutureProvider { - /// Provider that fetches a specific chapter with its lessons. - /// This provider maps the underlying DTOs to the [Chapter] domain model. +class ChapterDetailProvider extends AutoDisposeFutureProvider { + /// Fetches a specific chapter by ID within a course context. /// /// Copied from [chapterDetail]. ChapterDetailProvider(String courseId, String chapterId) @@ -117,7 +111,7 @@ class ChapterDetailProvider extends AutoDisposeFutureProvider { @override Override overrideWith( - FutureOr Function(ChapterDetailRef provider) create, + FutureOr Function(ChapterDetailRef provider) create, ) { return ProviderOverride( origin: this, @@ -135,7 +129,7 @@ class ChapterDetailProvider extends AutoDisposeFutureProvider { } @override - AutoDisposeFutureProviderElement createElement() { + AutoDisposeFutureProviderElement createElement() { return _ChapterDetailProviderElement(this); } @@ -158,7 +152,7 @@ class ChapterDetailProvider extends AutoDisposeFutureProvider { @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element -mixin ChapterDetailRef on AutoDisposeFutureProviderRef { +mixin ChapterDetailRef on AutoDisposeFutureProviderRef { /// The parameter `courseId` of this provider. String get courseId; @@ -167,7 +161,7 @@ mixin ChapterDetailRef on AutoDisposeFutureProviderRef { } class _ChapterDetailProviderElement - extends AutoDisposeFutureProviderElement + extends AutoDisposeFutureProviderElement with ChapterDetailRef { _ChapterDetailProviderElement(super.provider); diff --git a/packages/courses/lib/providers/course_detail_provider.dart b/packages/courses/lib/providers/course_detail_provider.dart index f85a92c3..5bf6a127 100644 --- a/packages/courses/lib/providers/course_detail_provider.dart +++ b/packages/courses/lib/providers/course_detail_provider.dart @@ -1,38 +1,14 @@ -import 'package:core/data/data.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'enrollment_provider.dart'; +import 'package:core/data/data.dart'; import 'course_list_provider.dart'; part 'course_detail_provider.g.dart'; -/// Provider that fetches a specific course with its full curriculum (chapters and lessons). -/// -/// This provider composes lower-level data providers from the `data` package -/// to build a complete [CourseDto] hierarchy. +/// Fetches a single course by its ID. @riverpod Future courseDetail(CourseDetailRef ref, String courseId) async { - final enrollment = await ref.watch(enrollmentProvider.future); - final course = enrollment.where((c) => c.id == courseId).firstOrNull; - if (course == null) return null; - - // Watch chapters for this course - final chapters = await ref.watch(courseChaptersProvider(courseId).future); - - // Watch lessons for each chapter and combine them - final chaptersWithLessons = await Future.wait( - chapters.map((chapter) async { - final lessons = await ref.watch( - chapterLessonsProvider(chapter.id).future, - ); - return chapter.copyWith( - lessons: lessons - .map((l) => l.copyWith(chapterTitle: chapter.title)) - .toList(), - ); - }), - ); - - return course.copyWith(chapters: chaptersWithLessons); + final enrollment = await ref.watch(courseListProvider.future); + return enrollment.where((c) => c.id == courseId).firstOrNull; } /// A provider that flattens all lessons for a specific course into a single list. @@ -42,8 +18,13 @@ Future> allCourseLessons( AllCourseLessonsRef ref, String courseId, ) async { - final course = await ref.watch(courseDetailProvider(courseId).future); - if (course == null) return []; - - return course.chapters.expand((chapter) => chapter.lessons).toList(); + final repo = await ref.watch(courseRepositoryProvider.future); + final chapters = await repo.refreshChapters(courseId); + + final allLessons = []; + for (final chapter in chapters) { + final lessons = await repo.refreshLessons(chapter.id); + allLessons.addAll(lessons); + } + return allLessons; } diff --git a/packages/courses/lib/providers/course_detail_provider.g.dart b/packages/courses/lib/providers/course_detail_provider.g.dart index 1da28b20..16082cb0 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'717e7a0eaa53c6e03157f7695d69f4c6d92a614b'; /// Copied from Dart SDK class _SystemHash { @@ -29,34 +29,22 @@ class _SystemHash { } } -/// Provider that fetches a specific course with its full curriculum (chapters and lessons). -/// -/// This provider composes lower-level data providers from the `data` package -/// to build a complete [CourseDto] hierarchy. +/// Fetches a single course by its ID. /// /// Copied from [courseDetail]. @ProviderFor(courseDetail) const courseDetailProvider = CourseDetailFamily(); -/// Provider that fetches a specific course with its full curriculum (chapters and lessons). -/// -/// This provider composes lower-level data providers from the `data` package -/// to build a complete [CourseDto] hierarchy. +/// Fetches a single course by its ID. /// /// Copied from [courseDetail]. class CourseDetailFamily extends Family> { - /// Provider that fetches a specific course with its full curriculum (chapters and lessons). - /// - /// This provider composes lower-level data providers from the `data` package - /// to build a complete [CourseDto] hierarchy. + /// Fetches a single course by its ID. /// /// Copied from [courseDetail]. const CourseDetailFamily(); - /// Provider that fetches a specific course with its full curriculum (chapters and lessons). - /// - /// This provider composes lower-level data providers from the `data` package - /// to build a complete [CourseDto] hierarchy. + /// Fetches a single course by its ID. /// /// Copied from [courseDetail]. CourseDetailProvider call(String courseId) { @@ -85,17 +73,11 @@ class CourseDetailFamily extends Family> { String? get name => r'courseDetailProvider'; } -/// Provider that fetches a specific course with its full curriculum (chapters and lessons). -/// -/// This provider composes lower-level data providers from the `data` package -/// to build a complete [CourseDto] hierarchy. +/// Fetches a single course by its ID. /// /// Copied from [courseDetail]. class CourseDetailProvider extends AutoDisposeFutureProvider { - /// Provider that fetches a specific course with its full curriculum (chapters and lessons). - /// - /// This provider composes lower-level data providers from the `data` package - /// to build a complete [CourseDto] hierarchy. + /// Fetches a single course by its ID. /// /// Copied from [courseDetail]. CourseDetailProvider(String courseId) @@ -177,7 +159,7 @@ class _CourseDetailProviderElement String get courseId => (origin as CourseDetailProvider).courseId; } -String _$allCourseLessonsHash() => r'3746368636296b244a8128a159dfa96f3fa69684'; +String _$allCourseLessonsHash() => r'50533a249938b15f630a603ef40aa76641c90ca1'; /// A provider that flattens all lessons for a specific course into a single list. /// Used for filtering lessons by type across the entire course. diff --git a/packages/courses/lib/providers/lesson_providers.dart b/packages/courses/lib/providers/lesson_providers.dart index 26e550c1..7553b204 100644 --- a/packages/courses/lib/providers/lesson_providers.dart +++ b/packages/courses/lib/providers/lesson_providers.dart @@ -1,15 +1,14 @@ 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'; /// Flattens the course/chapter hierarchy into a single stream of all lessons. -/// This allows for efficient O(N) filtering in the UI. +/// +/// This allows for efficient O(N) filtering in the UI across the entire local cache. @riverpod -List allLessons(AllLessonsRef ref) { - final courses = ref.watch(enrollmentProvider).asData?.value ?? []; - return courses.expand((course) { - return course.chapters.expand((chapter) => chapter.lessons); - }).toList(); +Stream> allLessons(AllLessonsRef ref) async* { + final repo = await ref.watch(courseRepositoryProvider.future); + yield* repo.watchAllLessons(); } diff --git a/packages/courses/lib/providers/lesson_providers.g.dart b/packages/courses/lib/providers/lesson_providers.g.dart index 175a94a3..095be67f 100644 --- a/packages/courses/lib/providers/lesson_providers.g.dart +++ b/packages/courses/lib/providers/lesson_providers.g.dart @@ -6,14 +6,15 @@ part of 'lesson_providers.dart'; // RiverpodGenerator // ************************************************************************** -String _$allLessonsHash() => r'0f0d5e4507484e7bd07c2d474a40b4e190d492ba'; +String _$allLessonsHash() => r'5545b3e72aa94aae83d09e13f6245baecd94f039'; /// Flattens the course/chapter hierarchy into a single stream of all lessons. -/// This allows for efficient O(N) filtering in the UI. +/// +/// This allows for efficient O(N) filtering in the UI across the entire local cache. /// /// Copied from [allLessons]. @ProviderFor(allLessons) -final allLessonsProvider = AutoDisposeProvider>.internal( +final allLessonsProvider = AutoDisposeStreamProvider>.internal( allLessons, name: r'allLessonsProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') @@ -25,6 +26,6 @@ final allLessonsProvider = AutoDisposeProvider>.internal( @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element -typedef AllLessonsRef = AutoDisposeProviderRef>; +typedef AllLessonsRef = 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/repositories/course_repository.dart b/packages/courses/lib/repositories/course_repository.dart index 4d7cdfe4..d5d47558 100644 --- a/packages/courses/lib/repositories/course_repository.dart +++ b/packages/courses/lib/repositories/course_repository.dart @@ -45,6 +45,13 @@ class CourseRepository { // ── Lessons ─────────────────────────────────────────────────────────────── + /// Watch all lessons in the database. + Stream> watchAllLessons() { + return _db.watchAllLessons().map( + (rows) => rows.map(_rowToLessonDto).toList(), + ); + } + Stream> watchLessons(String chapterId) { return _db .watchLessonsForChapter(chapterId) diff --git a/packages/courses/lib/screens/chapter_detail_page.dart b/packages/courses/lib/screens/chapter_detail_page.dart index dc7b20e6..6e3dd500 100644 --- a/packages/courses/lib/screens/chapter_detail_page.dart +++ b/packages/courses/lib/screens/chapter_detail_page.dart @@ -1,6 +1,8 @@ import 'package:core/core.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:core/data/data.dart'; +import '../providers/course_list_provider.dart'; import '../providers/chapter_detail_provider.dart'; import '../models/course_content.dart'; import '../widgets/chapter_status_filter_bar.dart'; @@ -28,8 +30,9 @@ class ChapterDetailPage extends ConsumerWidget { final design = Design.of(context); final l10n = L10n.of(context); - // Watch the chapter detail data + // Watch the chapter detail data and its lessons separately final chapterAsync = ref.watch(chapterDetailProvider(courseId, chapterId)); + final lessonsAsync = ref.watch(chapterLessonsProvider(chapterId)); // Check status filter state final activeStatusFilter = ref.watch(chapterStatusFilterProvider); @@ -42,7 +45,8 @@ class ChapterDetailPage extends ConsumerWidget { return Center(child: AppText.body(l10n.chapterNotFound)); } - final filteredLessons = chapter.lessons.where((l) { + final allLessons = lessonsAsync.valueOrNull ?? []; + final filteredLessons = allLessons.where((l) { switch (activeStatusFilter) { case ChapterStatusFilter.running: return l.progressStatus != LessonProgressStatus.notStarted; @@ -51,7 +55,21 @@ class ChapterDetailPage extends ConsumerWidget { case ChapterStatusFilter.history: return l.progressStatus == LessonProgressStatus.completed; } - }).toList(); + }).map((l) => Lesson( + id: l.id, + title: l.title, + type: l.type, + progressStatus: l.progressStatus, + duration: l.duration, + isLocked: l.isLocked, + isBookmarked: l.isBookmarked, + subtitle: l.subtitle, + subjectName: l.subjectName, + subjectIndex: l.subjectIndex, + lessonNumber: l.lessonNumber, + totalLessons: l.totalLessons, + contentUrl: l.contentUrl, + )).toList(); return Column( children: [ @@ -115,12 +133,10 @@ class ChapterDetailPage extends ConsumerWidget { Widget _buildHeaderContents( BuildContext context, DesignConfig design, - Chapter chapter, + ChapterDto chapter, ) { final l10n = L10n.of(context); - final displayTitle = chapter.courseTitle != null - ? '${chapter.courseTitle} - ${chapter.title}' - : chapter.title; + final displayTitle = chapter.title; final safeArea = MediaQuery.of(context).padding; diff --git a/packages/courses/lib/screens/chapters_list_page.dart b/packages/courses/lib/screens/chapters_list_page.dart index 32dfd775..8d11ee3c 100644 --- a/packages/courses/lib/screens/chapters_list_page.dart +++ b/packages/courses/lib/screens/chapters_list_page.dart @@ -2,6 +2,7 @@ import 'package:core/core.dart'; import 'package:core/data/data.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../providers/course_list_provider.dart'; import '../providers/course_detail_provider.dart'; import '../widgets/chapters_filter_tab_bar.dart'; import '../widgets/chapter_curriculum_item.dart'; @@ -30,8 +31,9 @@ class _ChaptersListPageState extends ConsumerState { Widget build(BuildContext context) { final design = Design.of(context); - // Watch course detail with nested chapters + // Watch course detail (flat) and chapters separately final courseAsync = ref.watch(courseDetailProvider(widget.courseId)); + final chaptersAsync = ref.watch(courseChaptersProvider(widget.courseId)); final allLessonsAsync = ref.watch( allCourseLessonsProvider(widget.courseId), ); @@ -44,7 +46,7 @@ class _ChaptersListPageState extends ConsumerState { return const Center(child: AppText.body('Course not found')); } - final chapters = course.chapters; + final chapters = chaptersAsync.valueOrNull ?? []; final filteredLessons = allLessonsAsync.maybeWhen( data: (lessons) => _filterLessons(lessons, _activeFilter), orElse: () => [], diff --git a/packages/courses/lib/screens/study_screen.dart b/packages/courses/lib/screens/study_screen.dart index 69fcae9b..e30cbd9d 100644 --- a/packages/courses/lib/screens/study_screen.dart +++ b/packages/courses/lib/screens/study_screen.dart @@ -46,7 +46,7 @@ class _StudyScreenState extends ConsumerState { final l10n = L10n.of(context); final enrollmentAsync = ref.watch(enrollmentProvider); - final allLessons = ref.watch(allLessonsProvider); + final allLessonsAsync = ref.watch(allLessonsProvider); final resumeAsync = ref.watch(recentActivityProvider); return Stack( @@ -55,6 +55,7 @@ class _StudyScreenState extends ConsumerState { child: enrollmentAsync.when( data: (courses) { final filteredCourses = _filterCourses(courses); + final allLessons = allLessonsAsync.valueOrNull ?? []; final filteredLessons = _filterLessons(allLessons); return AppScroll( From 8d8db78d7d4ae91343b81d0d68d031f1278daf03 Mon Sep 17 00:00:00 2001 From: syed-tp Date: Tue, 31 Mar 2026 16:09:57 +0530 Subject: [PATCH 2/2] refactor: migrate course and chapter detail providers to StreamProvider for real-time updates --- packages/core/lib/data/db/app_database.dart | 16 +++++++++++++ .../providers/chapter_detail_provider.dart | 9 +++---- .../providers/chapter_detail_provider.g.dart | 12 +++++----- .../lib/providers/course_detail_provider.dart | 21 +++++++--------- .../providers/course_detail_provider.g.dart | 24 +++++++++---------- .../lib/repositories/course_repository.dart | 7 ++++++ .../lib/screens/chapter_detail_page.dart | 21 ++++++++++++---- 7 files changed, 72 insertions(+), 38 deletions(-) diff --git a/packages/core/lib/data/db/app_database.dart b/packages/core/lib/data/db/app_database.dart index eec8eeb8..0b7daa0b 100644 --- a/packages/core/lib/data/db/app_database.dart +++ b/packages/core/lib/data/db/app_database.dart @@ -228,6 +228,22 @@ class AppDatabase extends _$AppDatabase { /// Watch all lessons in the database. Stream> watchAllLessons() => select(lessonsTable).watch(); + /// Watch all lessons belonging to a specific course. + Stream> watchLessonsForCourse(String courseId) { + final query = select(lessonsTable).join([ + innerJoin( + chaptersTable, + chaptersTable.id.equalsExp(lessonsTable.chapterId), + ), + ]) + ..where(chaptersTable.courseId.equals(courseId)) + ..orderBy([OrderingTerm.asc(lessonsTable.orderIndex)]); + + return query.watch().map((rows) { + return rows.map((row) => row.readTable(lessonsTable)).toList(); + }); + } + // ── Live Classes ────────────────────────────────────────────────────────── Stream> watchAllLiveClasses() => diff --git a/packages/courses/lib/providers/chapter_detail_provider.dart b/packages/courses/lib/providers/chapter_detail_provider.dart index eb99181d..f1fe3b76 100644 --- a/packages/courses/lib/providers/chapter_detail_provider.dart +++ b/packages/courses/lib/providers/chapter_detail_provider.dart @@ -6,12 +6,13 @@ part 'chapter_detail_provider.g.dart'; /// Fetches a specific chapter by ID within a course context. @riverpod -Future chapterDetail( +Stream chapterDetail( ChapterDetailRef ref, String courseId, String chapterId, -) async { +) async* { final repo = await ref.watch(courseRepositoryProvider.future); - final chapters = await repo.watchChapters(courseId).first; - return chapters.where((c) => c.id == chapterId).firstOrNull; + yield* repo.watchChapters(courseId).map( + (chapters) => chapters.where((c) => c.id == chapterId).firstOrNull, + ); } diff --git a/packages/courses/lib/providers/chapter_detail_provider.g.dart b/packages/courses/lib/providers/chapter_detail_provider.g.dart index 201014b4..6d842705 100644 --- a/packages/courses/lib/providers/chapter_detail_provider.g.dart +++ b/packages/courses/lib/providers/chapter_detail_provider.g.dart @@ -6,7 +6,7 @@ part of 'chapter_detail_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$chapterDetailHash() => r'd91b4c4b073a8fdc22882e400cbd2246c4d49df6'; +String _$chapterDetailHash() => r'4acdf57fb550b68e4e91830b25c740bad52db918'; /// Copied from Dart SDK class _SystemHash { @@ -76,7 +76,7 @@ class ChapterDetailFamily extends Family> { /// Fetches a specific chapter by ID within a course context. /// /// Copied from [chapterDetail]. -class ChapterDetailProvider extends AutoDisposeFutureProvider { +class ChapterDetailProvider extends AutoDisposeStreamProvider { /// Fetches a specific chapter by ID within a course context. /// /// Copied from [chapterDetail]. @@ -111,7 +111,7 @@ class ChapterDetailProvider extends AutoDisposeFutureProvider { @override Override overrideWith( - FutureOr Function(ChapterDetailRef provider) create, + Stream Function(ChapterDetailRef provider) create, ) { return ProviderOverride( origin: this, @@ -129,7 +129,7 @@ class ChapterDetailProvider extends AutoDisposeFutureProvider { } @override - AutoDisposeFutureProviderElement createElement() { + AutoDisposeStreamProviderElement createElement() { return _ChapterDetailProviderElement(this); } @@ -152,7 +152,7 @@ class ChapterDetailProvider extends AutoDisposeFutureProvider { @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element -mixin ChapterDetailRef on AutoDisposeFutureProviderRef { +mixin ChapterDetailRef on AutoDisposeStreamProviderRef { /// The parameter `courseId` of this provider. String get courseId; @@ -161,7 +161,7 @@ mixin ChapterDetailRef on AutoDisposeFutureProviderRef { } class _ChapterDetailProviderElement - extends AutoDisposeFutureProviderElement + extends AutoDisposeStreamProviderElement with ChapterDetailRef { _ChapterDetailProviderElement(super.provider); diff --git a/packages/courses/lib/providers/course_detail_provider.dart b/packages/courses/lib/providers/course_detail_provider.dart index 5bf6a127..97c77438 100644 --- a/packages/courses/lib/providers/course_detail_provider.dart +++ b/packages/courses/lib/providers/course_detail_provider.dart @@ -6,25 +6,22 @@ part 'course_detail_provider.g.dart'; /// Fetches a single course by its ID. @riverpod -Future courseDetail(CourseDetailRef ref, String courseId) async { +Stream courseDetail(CourseDetailRef ref, String courseId) async* { final enrollment = await ref.watch(courseListProvider.future); - return enrollment.where((c) => c.id == courseId).firstOrNull; + yield enrollment.where((c) => c.id == courseId).firstOrNull; + + yield* ref.watch(courseListProvider.stream).map( + (list) => list.where((c) => c.id == courseId).firstOrNull, + ); } /// A provider that flattens all lessons for a specific course into a single list. /// Used for filtering lessons by type across the entire course. @riverpod -Future> allCourseLessons( +Stream> allCourseLessons( AllCourseLessonsRef ref, String courseId, -) async { +) async* { final repo = await ref.watch(courseRepositoryProvider.future); - final chapters = await repo.refreshChapters(courseId); - - final allLessons = []; - for (final chapter in chapters) { - final lessons = await repo.refreshLessons(chapter.id); - allLessons.addAll(lessons); - } - return allLessons; + yield* repo.watchLessonsForCourse(courseId); } diff --git a/packages/courses/lib/providers/course_detail_provider.g.dart b/packages/courses/lib/providers/course_detail_provider.g.dart index 16082cb0..6f2a9799 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'717e7a0eaa53c6e03157f7695d69f4c6d92a614b'; +String _$courseDetailHash() => r'6621ae9d8760f88e33dde40dfb6b3825bf095dd6'; /// Copied from Dart SDK class _SystemHash { @@ -76,7 +76,7 @@ class CourseDetailFamily extends Family> { /// Fetches a single course by its ID. /// /// Copied from [courseDetail]. -class CourseDetailProvider extends AutoDisposeFutureProvider { +class CourseDetailProvider extends AutoDisposeStreamProvider { /// Fetches a single course by its ID. /// /// Copied from [courseDetail]. @@ -108,7 +108,7 @@ class CourseDetailProvider extends AutoDisposeFutureProvider { @override Override overrideWith( - FutureOr Function(CourseDetailRef provider) create, + Stream Function(CourseDetailRef provider) create, ) { return ProviderOverride( origin: this, @@ -125,7 +125,7 @@ class CourseDetailProvider extends AutoDisposeFutureProvider { } @override - AutoDisposeFutureProviderElement createElement() { + AutoDisposeStreamProviderElement createElement() { return _CourseDetailProviderElement(this); } @@ -145,13 +145,13 @@ class CourseDetailProvider extends AutoDisposeFutureProvider { @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element -mixin CourseDetailRef on AutoDisposeFutureProviderRef { +mixin CourseDetailRef on AutoDisposeStreamProviderRef { /// The parameter `courseId` of this provider. String get courseId; } class _CourseDetailProviderElement - extends AutoDisposeFutureProviderElement + extends AutoDisposeStreamProviderElement with CourseDetailRef { _CourseDetailProviderElement(super.provider); @@ -159,7 +159,7 @@ class _CourseDetailProviderElement String get courseId => (origin as CourseDetailProvider).courseId; } -String _$allCourseLessonsHash() => r'50533a249938b15f630a603ef40aa76641c90ca1'; +String _$allCourseLessonsHash() => r'4dc7030e7770d8eda9985b414e91c70f1da989c5'; /// A provider that flattens all lessons for a specific course into a single list. /// Used for filtering lessons by type across the entire course. @@ -214,7 +214,7 @@ class AllCourseLessonsFamily extends Family>> { /// /// Copied from [allCourseLessons]. class AllCourseLessonsProvider - extends AutoDisposeFutureProvider> { + extends AutoDisposeStreamProvider> { /// A provider that flattens all lessons for a specific course into a single list. /// Used for filtering lessons by type across the entire course. /// @@ -247,7 +247,7 @@ class AllCourseLessonsProvider @override Override overrideWith( - FutureOr> Function(AllCourseLessonsRef provider) create, + Stream> Function(AllCourseLessonsRef provider) create, ) { return ProviderOverride( origin: this, @@ -264,7 +264,7 @@ class AllCourseLessonsProvider } @override - AutoDisposeFutureProviderElement> createElement() { + AutoDisposeStreamProviderElement> createElement() { return _AllCourseLessonsProviderElement(this); } @@ -284,13 +284,13 @@ class AllCourseLessonsProvider @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element -mixin AllCourseLessonsRef on AutoDisposeFutureProviderRef> { +mixin AllCourseLessonsRef on AutoDisposeStreamProviderRef> { /// The parameter `courseId` of this provider. String get courseId; } class _AllCourseLessonsProviderElement - extends AutoDisposeFutureProviderElement> + extends AutoDisposeStreamProviderElement> with AllCourseLessonsRef { _AllCourseLessonsProviderElement(super.provider); diff --git a/packages/courses/lib/repositories/course_repository.dart b/packages/courses/lib/repositories/course_repository.dart index d5d47558..e69c0845 100644 --- a/packages/courses/lib/repositories/course_repository.dart +++ b/packages/courses/lib/repositories/course_repository.dart @@ -51,6 +51,13 @@ class CourseRepository { (rows) => rows.map(_rowToLessonDto).toList(), ); } + + /// Watch all lessons belonging to a specific course. + Stream> watchLessonsForCourse(String courseId) { + return _db.watchLessonsForCourse(courseId).map( + (rows) => rows.map(_rowToLessonDto).toList(), + ); + } Stream> watchLessons(String chapterId) { return _db diff --git a/packages/courses/lib/screens/chapter_detail_page.dart b/packages/courses/lib/screens/chapter_detail_page.dart index 6e3dd500..bb7a1509 100644 --- a/packages/courses/lib/screens/chapter_detail_page.dart +++ b/packages/courses/lib/screens/chapter_detail_page.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:core/data/data.dart'; import '../providers/course_list_provider.dart'; import '../providers/chapter_detail_provider.dart'; +import '../providers/course_detail_provider.dart'; import '../models/course_content.dart'; import '../widgets/chapter_status_filter_bar.dart'; import '../widgets/chapter_content_item.dart'; @@ -33,6 +34,7 @@ class ChapterDetailPage extends ConsumerWidget { // Watch the chapter detail data and its lessons separately final chapterAsync = ref.watch(chapterDetailProvider(courseId, chapterId)); final lessonsAsync = ref.watch(chapterLessonsProvider(chapterId)); + final courseAsync = ref.watch(courseDetailProvider(courseId)); // Check status filter state final activeStatusFilter = ref.watch(chapterStatusFilterProvider); @@ -45,8 +47,11 @@ class ChapterDetailPage extends ConsumerWidget { return Center(child: AppText.body(l10n.chapterNotFound)); } - final allLessons = lessonsAsync.valueOrNull ?? []; - final filteredLessons = allLessons.where((l) { + final allLessons = lessonsAsync.valueOrNull; + if (allLessons == null && lessonsAsync.isLoading) { + return const Center(child: AppLoadingIndicator()); + } + final filteredLessons = (allLessons ?? []).where((l) { switch (activeStatusFilter) { case ChapterStatusFilter.running: return l.progressStatus != LessonProgressStatus.notStarted; @@ -85,7 +90,12 @@ class ChapterDetailPage extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildHeaderContents(context, design, chapter), + _buildHeaderContents( + context, + design, + chapter, + courseAsync.valueOrNull?.title, + ), const ChapterStatusFilterBar(), ], ), @@ -134,9 +144,12 @@ class ChapterDetailPage extends ConsumerWidget { BuildContext context, DesignConfig design, ChapterDto chapter, + String? courseTitle, ) { final l10n = L10n.of(context); - final displayTitle = chapter.title; + final displayTitle = courseTitle != null + ? '$courseTitle - ${chapter.title}' + : chapter.title; final safeArea = MediaQuery.of(context).padding;