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/integrate-course-api/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-19
44 changes: 44 additions & 0 deletions openspec/changes/integrate-course-api/design.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 24 additions & 0 deletions openspec/changes/integrate-course-api/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Proposal: Integrate Course List API

Integrate the real course list API into the Study tab, replacing mock data with an offline-first repository implementation.

## Motivation

The Study tab was displaying static mock data. This change wires up the real Testpress backend (`https://lmsdemo.testpress.in/api/v3/courses/`) while preserving the offline-first architecture (Drift/SQLite cache).

## What Changes

1. **Networking**: Introduce `NetworkProvider` and `ApiException` in `packages/core/lib/network` to centralize all HTTP logic. `HttpDataSource.getCourses` uses these to fetch paginated data from the Testpress API.
2. **Authentication**: The app transitions to an authenticated state upon successful login via the Testpress API. All API requests include an `Authorization` header via `NetworkProvider`.
3. **Pagination**: `CourseRepository.refreshCourses` tracks `_nextPage` and `_hasMore` using the API's `next` field. The UI triggers the next fetch when the user scrolls near the bottom of the list.
4. **Caching**: The Study tab shows cached courses from Drift immediately on revisit. A full-screen loader only appears on the very first visit when the local database is empty.

## Scoped Out

- `totalDuration` → `totalContents` field migration is deferred to a separate change.
- Other tabs (Explore, Home) are not wired to live data in this change.

## Impact

- `packages/core`: `HttpDataSource`, `NetworkProvider`, `ApiException`, `PaginatedResponseDto`, `AuthProvider`.
- `packages/courses`: `CourseRepository`, `StudyScreen`, `course_list_provider`.
61 changes: 61 additions & 0 deletions openspec/changes/integrate-course-api/specs/course-api/spec.md
Original file line number Diff line number Diff line change
@@ -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
32 changes: 32 additions & 0 deletions openspec/changes/integrate-course-api/tasks.md
Original file line number Diff line number Diff line change
@@ -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<CourseDto>`.
- [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<T>` 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`.
32 changes: 15 additions & 17 deletions packages/core/lib/data/auth/auth_api_service.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import 'package:dio/dio.dart';

import '../../network/api_endpoints.dart';
import '../../network/network_provider.dart';
import '../exceptions/api_exception.dart';
import 'types/auth_exception.dart';

class AuthApiResult {
Expand All @@ -11,9 +11,10 @@ class AuthApiResult {
}

class AuthApiService {
final Dio _dio;
final NetworkProvider _networkProvider;

AuthApiService({Dio? dio}) : _dio = dio ?? NetworkProvider.create();
AuthApiService({NetworkProvider? networkProvider})
: _networkProvider = networkProvider ?? NetworkProvider();

Future<AuthApiResult> loginWithPassword({
required String username,
Expand Down Expand Up @@ -47,17 +48,14 @@ class AuthApiService {
required String phoneNumber,
String? email,
}) async {
final response = await _post(
ApiEndpoints.verifyOtp,
{
'otp': _parseOtp(otp),
..._buildOtpIdentityPayload(
phoneNumber: phoneNumber,
countryCode: null,
email: email,
),
},
);
final response = await _post(ApiEndpoints.verifyOtp, {
'otp': _parseOtp(otp),
..._buildOtpIdentityPayload(
phoneNumber: phoneNumber,
countryCode: null,
email: email,
),
});

return _parseSession(response);
}
Expand All @@ -80,7 +78,7 @@ class AuthApiService {
String? authToken,
}) async {
try {
final response = await _dio.post<Map<String, dynamic>>(
final response = await _networkProvider.post<Map<String, dynamic>>(
path,
data: payload,
options: Options(
Expand All @@ -92,8 +90,8 @@ class AuthApiService {
);

return response.data ?? <String, dynamic>{};
} on DioException catch (error) {
throw AuthException.fromDio(error);
} on ApiException catch (error) {
throw AuthException.fromApiException(error);
}
}

Expand Down
25 changes: 25 additions & 0 deletions packages/core/lib/data/auth/auth_interceptor.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import 'dart:async';
import 'package:dio/dio.dart';

/// Interceptor that adds a JWT token to all requests if one is available.
/// Uses a delegate [tokenProvider] to fetch the token, decoupling it from storage logic.
class AuthInterceptor extends Interceptor {
final FutureOr<String?> Function() _getToken;

AuthInterceptor({required FutureOr<String?> Function() getToken})
: _getToken = getToken;

@override
void onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
final token = await _getToken();

if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'JWT $token';
}

return handler.next(options);
}
}
2 changes: 1 addition & 1 deletion packages/core/lib/data/auth/auth_provider.g.dart

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

Loading