Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions openspec/changes/dto-hierarchy-flattening/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-30
30 changes: 30 additions & 0 deletions openspec/changes/dto-hierarchy-flattening/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
## Context

The current `CourseDto` and `ChapterDto` models were originally scaffolded with nested collections (`List<ChapterDto>` and `List<LessonDto>`). 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.
24 changes: 24 additions & 0 deletions openspec/changes/dto-hierarchy-flattening/proposal.md
Original file line number Diff line number Diff line change
@@ -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<ChapterDto> chapters` from `CourseDto`. **BREAKING**
- **REMOVE**: `List<LessonDto> 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
<!-- None -->

## 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.
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions openspec/changes/dto-hierarchy-flattening/tasks.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 19 additions & 0 deletions packages/core/lib/data/db/app_database.dart
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,25 @@ class AppDatabase extends _$AppDatabase {
Future<void> upsertLessons(List<LessonsTableCompanion> rows) =>
batch((b) => b.insertAllOnConflictUpdate(lessonsTable, rows));

/// Watch all lessons in the database.
Stream<List<LessonsTableData>> watchAllLessons() => select(lessonsTable).watch();

/// Watch all lessons belonging to a specific course.
Stream<List<LessonsTableData>> 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<List<LiveClassesTableData>> watchAllLiveClasses() =>
Expand Down
10 changes: 0 additions & 10 deletions packages/core/lib/data/models/chapter_dto.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import 'lesson_dto.dart';

/// Chapter DTO — one chapter within a course.
class ChapterDto {
Expand All @@ -8,7 +7,6 @@ class ChapterDto {
final int lessonCount;
final int assessmentCount;
final int orderIndex;
final List<LessonDto> lessons;

const ChapterDto({
required this.id,
Expand All @@ -17,7 +15,6 @@ class ChapterDto {
required this.lessonCount,
required this.assessmentCount,
required this.orderIndex,
this.lessons = const [],
});

ChapterDto copyWith({
Expand All @@ -27,7 +24,6 @@ class ChapterDto {
int? lessonCount,
int? assessmentCount,
int? orderIndex,
List<LessonDto>? lessons,
}) {
return ChapterDto(
id: id ?? this.id,
Expand All @@ -36,7 +32,6 @@ class ChapterDto {
lessonCount: lessonCount ?? this.lessonCount,
assessmentCount: assessmentCount ?? this.assessmentCount,
orderIndex: orderIndex ?? this.orderIndex,
lessons: lessons ?? this.lessons,
);
}

Expand All @@ -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<dynamic>?)
?.map((e) => LessonDto.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
);
}

Expand All @@ -63,7 +54,6 @@ class ChapterDto {
'lessonCount': lessonCount,
'assessmentCount': assessmentCount,
'orderIndex': orderIndex,
'lessons': lessons.map((e) => e.toJson()).toList(),
};
}
}
10 changes: 0 additions & 10 deletions packages/core/lib/data/models/course_dto.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import 'chapter_dto.dart';

/// Course DTO — plain Dart object transferred from DataSource to Drift and back to UI.
class CourseDto {
Expand All @@ -15,7 +14,6 @@ class CourseDto {
final int progress; // 0–100
final int completedLessons;
final int totalLessons;
final List<ChapterDto> chapters;

const CourseDto({
required this.id,
Expand All @@ -26,7 +24,6 @@ class CourseDto {
required this.progress,
required this.completedLessons,
required this.totalLessons,
this.chapters = const [],
});

CourseDto copyWith({
Expand All @@ -38,7 +35,6 @@ class CourseDto {
int? progress,
int? completedLessons,
int? totalLessons,
List<ChapterDto>? chapters,
}) {
return CourseDto(
id: id ?? this.id,
Expand All @@ -49,7 +45,6 @@ class CourseDto {
progress: progress ?? this.progress,
completedLessons: completedLessons ?? this.completedLessons,
totalLessons: totalLessons ?? this.totalLessons,
chapters: chapters ?? this.chapters,
);
}

Expand All @@ -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<dynamic>?)
?.map((e) => ChapterDto.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
);
}

Expand All @@ -80,7 +71,6 @@ class CourseDto {
'progress': progress,
'completedLessons': completedLessons,
'totalLessons': totalLessons,
'chapters': chapters.map((e) => e.toJson()).toList(),
};
}
}
48 changes: 9 additions & 39 deletions packages/courses/lib/providers/chapter_detail_provider.dart
Original file line number Diff line number Diff line change
@@ -1,48 +1,18 @@
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<Chapter?> chapterDetail(
Stream<ChapterDto?> 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(),
);
) async* {
final repo = await ref.watch(courseRepositoryProvider.future);
yield* repo.watchChapters(courseId).map(
(chapters) => chapters.where((c) => c.id == chapterId).firstOrNull,
);
}
32 changes: 13 additions & 19 deletions packages/courses/lib/providers/chapter_detail_provider.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading