diff --git a/src/mobile/lib/main.dart b/src/mobile/lib/main.dart index 1ffdcb9f89..ce85a63c68 100644 --- a/src/mobile/lib/main.dart +++ b/src/mobile/lib/main.dart @@ -260,8 +260,9 @@ class _DeciderState extends State with WidgetsBindingObserver { } void _initLearnGuestSession() { - LearnSyncService.instance.ensureGuestSession().then((_) { - LearnSyncService.instance.syncPendingProgress(); + LearnSyncService.instance.ensureGuestSession().then((_) async { + await LearnSyncService.instance.syncPendingProgress(); + await LearnSyncService.instance.hydrateLocalProgress(); }).catchError((_) {}); } @@ -327,6 +328,8 @@ class _DeciderState extends State with WidgetsBindingObserver { if (token != null) { LearnSyncService.instance .linkProgressToAccount(token) + .then((_) => LearnSyncService.instance + .hydrateLocalProgress(authToken: token)) .catchError((_) {}); } }).catchError((_) {}); diff --git a/src/mobile/lib/src/app/learn/formatting/learn_display_text.dart b/src/mobile/lib/src/app/learn/formatting/learn_display_text.dart index a8e7d186b0..4899dd7616 100644 --- a/src/mobile/lib/src/app/learn/formatting/learn_display_text.dart +++ b/src/mobile/lib/src/app/learn/formatting/learn_display_text.dart @@ -25,7 +25,10 @@ String learnActivityOrdinal(int activityIndex) { 'SEVEN', 'EIGHT', ]; - return ordinals[activityIndex.clamp(0, ordinals.length - 1)]; + if (activityIndex >= 0 && activityIndex < ordinals.length) { + return ordinals[activityIndex]; + } + return '${activityIndex + 1}'; } String learnActivityTypeHeader(int activityIndex, String typeKey) { @@ -52,10 +55,6 @@ String learnCourseCompletionTime(DateTime completedAt) { return 'Completed $day $month $year'; } -String learnCourseCompletionRarityLabel({int completionPercent = 17}) { - return 'Only $completionPercent% of AirQo users have completed this course'; -} - String _monthName(int month) { const months = [ 'Jan', diff --git a/src/mobile/lib/src/app/learn/models/learn_course_structure.dart b/src/mobile/lib/src/app/learn/models/learn_course_structure.dart index 565225169a..5d41b1cab2 100644 --- a/src/mobile/lib/src/app/learn/models/learn_course_structure.dart +++ b/src/mobile/lib/src/app/learn/models/learn_course_structure.dart @@ -1,6 +1,7 @@ import 'package:airqo/src/app/learn/models/learn_lesson_continuation.dart'; import 'package:airqo/src/app/learn/models/learn_v2_catalog.dart'; import 'package:airqo/src/app/learn/services/learn_progress_service.dart'; +import 'package:flutter/foundation.dart' show visibleForTesting; class LearnLessonSlot { final String catalogId; @@ -85,14 +86,70 @@ class LearnStageInfo { enum LearnUnitStatus { locked, inProgress, completed } +/// Immutable snapshot of server-driven catalog metadata: the stage ladder +/// and the catalog-wide max points. Built from a fetched catalog response; +/// [fallback] carries the built-in defaults for before the first fetch. +class LearnCatalogMeta { + final List stages; + + /// Catalog-wide `max_points` from the server, or null if not provided. + final int? maxPoints; + + const LearnCatalogMeta._(this.stages, this.maxPoints); + + static const LearnCatalogMeta fallback = LearnCatalogMeta._( + [ + LearnStageInfo(name: 'Curious', index: 0), + LearnStageInfo(name: 'Aware', index: 1), + LearnStageInfo(name: 'Observer', index: 2), + LearnStageInfo(name: 'Champion', index: 3), + LearnStageInfo(name: 'Defender', index: 4), + ], + null, + ); + + factory LearnCatalogMeta.fromCatalog(LearnV2CatalogResponse catalog) { + final sorted = [...catalog.stages] + ..sort((a, b) => a.index.compareTo(b.index)); + final stages = [ + for (final s in sorted) + if (s.name.isNotEmpty) LearnStageInfo(name: s.name, index: s.index), + ]; + return LearnCatalogMeta._( + stages.isNotEmpty ? List.unmodifiable(stages) : fallback.stages, + catalog.maxPoints > 0 ? catalog.maxPoints : null, + ); + } +} + class LearnCatalog { - static const stages = [ - LearnStageInfo(name: 'Curious', index: 0), - LearnStageInfo(name: 'Aware', index: 1), - LearnStageInfo(name: 'Observer', index: 2), - LearnStageInfo(name: 'Champion', index: 3), - LearnStageInfo(name: 'Defender', index: 4), - ]; + /// Current metadata snapshot — swapped wholesale by [applyCatalogMeta], + /// never mutated in place. A static because widgets deep in the tree + /// (bottom sheets, lesson experience) consult it without a catalog in + /// scope; the snapshot itself is immutable. + static LearnCatalogMeta meta = LearnCatalogMeta.fallback; + + /// Stage ladder — the catalog response's `stages` when available, else the + /// built-in default. + static List get stages => meta.stages; + + /// Adopts server-driven metadata (stage names/order, max points) from a + /// fetched catalog so the app tracks backend changes without a release. + static void applyCatalogMeta(LearnV2CatalogResponse catalog) { + meta = LearnCatalogMeta.fromCatalog(catalog); + } + + @visibleForTesting + static void resetMeta() => meta = LearnCatalogMeta.fallback; + + /// Max attainable points — the server's catalog-wide value when known, + /// otherwise computed locally from the graded quiz count. + static int maxPoints( + List courses, + LearnProgressService progress, + ) { + return meta.maxPoints ?? progress.maxPoints(courses); + } static List buildFromV2Catalog( List v2Courses) { @@ -209,7 +266,7 @@ class LearnCatalog { List courses, LearnProgressService progress, ) { - final maxPts = progress.maxPoints(courses); + final maxPts = maxPoints(courses, progress); final pts = progress.totalPoints(courses); if (maxPts > 0 && pts > 0) { final ratio = pts / maxPts; diff --git a/src/mobile/lib/src/app/learn/models/learn_lesson_activity.dart b/src/mobile/lib/src/app/learn/models/learn_lesson_activity.dart index 0cb4de617e..ba82631e50 100644 --- a/src/mobile/lib/src/app/learn/models/learn_lesson_activity.dart +++ b/src/mobile/lib/src/app/learn/models/learn_lesson_activity.dart @@ -82,6 +82,10 @@ class LearnQuizPayload { class LearnLessonActivity { final int index; + + /// Backend `_id` of the activity — required by the progress API so the + /// server can verify quiz answers against the catalog. + final String activityId; final LearnActivityType type; final String title; final LearnArticlePayload? article; @@ -91,6 +95,7 @@ class LearnLessonActivity { const LearnLessonActivity({ required this.index, + this.activityId = '', required this.type, required this.title, this.article, @@ -119,9 +124,15 @@ class LearnLessonResult { class LearnQuizGrade { final bool isCorrect; final String feedback; + final int? selectedIndex; + final List? selectedIndices; + final List? selectedOrder; const LearnQuizGrade({ required this.isCorrect, required this.feedback, + this.selectedIndex, + this.selectedIndices, + this.selectedOrder, }); } diff --git a/src/mobile/lib/src/app/learn/models/learn_progress_models.dart b/src/mobile/lib/src/app/learn/models/learn_progress_models.dart new file mode 100644 index 0000000000..4b649bfdac --- /dev/null +++ b/src/mobile/lib/src/app/learn/models/learn_progress_models.dart @@ -0,0 +1,109 @@ +/// JSON numbers may arrive as int or double; never throw on either. +int _asInt(dynamic value, [int fallback = 0]) => + value is int ? value : (value is num ? value.toInt() : fallback); + +/// One quiz activity's attempt, as sent to PUT /learn/progress/lessons/:id. +class QuizAttemptData { + final String activityId; + final String format; + final int? selectedIndex; + final List? selectedIndices; + final List? selectedOrder; + final bool isCorrect; + + const QuizAttemptData({ + required this.activityId, + required this.format, + this.selectedIndex, + this.selectedIndices, + this.selectedOrder, + required this.isCorrect, + }); + + Map toJson() => { + 'activity_id': activityId, + 'format': format, + if (selectedIndex != null) 'selected_index': selectedIndex, + if (selectedIndices != null) 'selected_indices': selectedIndices, + if (selectedOrder != null) 'selected_order': selectedOrder, + 'is_correct': isCorrect, + }; + + static List? _intList(dynamic value) => value is List + ? value.whereType().toList() + : null; + + factory QuizAttemptData.fromJson(Map json) => + QuizAttemptData( + activityId: json['activity_id']?.toString() ?? '', + format: json['format'] as String? ?? '', + selectedIndex: + json['selected_index'] is int ? json['selected_index'] as int : null, + selectedIndices: _intList(json['selected_indices']), + selectedOrder: _intList(json['selected_order']), + isCorrect: json['is_correct'] as bool? ?? false, + ); +} + +/// One lesson's progress as returned by GET /learn/progress. +class LearnServerLessonProgress { + final bool completed; + final int? furthestActivityIndex; + final int stars; + final int pointsEarned; + final double? quizScoreRatio; + final String? freeTextResponse; + + const LearnServerLessonProgress({ + required this.completed, + this.furthestActivityIndex, + required this.stars, + required this.pointsEarned, + this.quizScoreRatio, + this.freeTextResponse, + }); + + factory LearnServerLessonProgress.fromJson(Map json) => + LearnServerLessonProgress( + completed: json['completed'] as bool? ?? false, + furthestActivityIndex: json['furthest_activity_index'] is num + ? _asInt(json['furthest_activity_index']) + : null, + stars: _asInt(json['stars']), + pointsEarned: _asInt(json['points_earned']), + quizScoreRatio: json['quiz_score_ratio'] is num + ? (json['quiz_score_ratio'] as num).toDouble() + : null, + freeTextResponse: json['free_text_response'] as String?, + ); +} + +/// Account/guest progress as returned by GET /learn/progress. The API returns +/// `lessons` as an object keyed by lesson id, not an array. +class LearnServerProgress { + final int totalPoints; + final Map lessons; + + const LearnServerProgress({ + required this.totalPoints, + required this.lessons, + }); + + factory LearnServerProgress.fromJson(Map json) { + final rawLessons = json['lessons']; + final lessons = {}; + if (rawLessons is Map) { + for (final entry in rawLessons.entries) { + if (entry.value is Map) { + lessons[entry.key.toString()] = LearnServerLessonProgress.fromJson( + Map.from(entry.value as Map), + ); + } + } + } + return LearnServerProgress( + totalPoints: _asInt(json['total_points']), + lessons: lessons, + ); + } +} diff --git a/src/mobile/lib/src/app/learn/models/learn_v2_catalog.dart b/src/mobile/lib/src/app/learn/models/learn_v2_catalog.dart index 3b73b63d39..a85abbb6a8 100644 --- a/src/mobile/lib/src/app/learn/models/learn_v2_catalog.dart +++ b/src/mobile/lib/src/app/learn/models/learn_v2_catalog.dart @@ -3,17 +3,25 @@ import 'dart:convert'; LearnV2CatalogResponse learnV2CatalogResponseFromJson(String str) => LearnV2CatalogResponse.fromJson(json.decode(str)); +/// JSON numbers may arrive as int or double; never throw on either. +int _asInt(dynamic value, [int fallback = 0]) => + value is int ? value : (value is num ? value.toInt() : fallback); + String learnV2CatalogResponseToJson(LearnV2CatalogResponse data) => json.encode(data.toJson()); class LearnV2CatalogResponse { final bool success; final String catalogVersion; + final List stages; + final int maxPoints; final List courses; const LearnV2CatalogResponse({ required this.success, required this.catalogVersion, + this.stages = const [], + this.maxPoints = 0, required this.courses, }); @@ -21,6 +29,14 @@ class LearnV2CatalogResponse { LearnV2CatalogResponse( success: json['success'] ?? false, catalogVersion: json['catalog_version'] ?? '', + stages: json['stages'] is List + ? (json['stages'] as List) + .whereType() + .map((s) => + LearnV2Stage.fromJson(Map.from(s))) + .toList() + : [], + maxPoints: _asInt(json['max_points']), courses: json['courses'] != null ? (json['courses'] as List) .map((c) => LearnV2Course.fromJson(c as Map)) @@ -31,10 +47,26 @@ class LearnV2CatalogResponse { Map toJson() => { 'success': success, 'catalog_version': catalogVersion, + 'stages': stages.map((s) => s.toJson()).toList(), + 'max_points': maxPoints, 'courses': courses.map((c) => c.toJson()).toList(), }; } +class LearnV2Stage { + final int index; + final String name; + + const LearnV2Stage({required this.index, required this.name}); + + factory LearnV2Stage.fromJson(Map json) => LearnV2Stage( + index: _asInt(json['index']), + name: json['name'] as String? ?? '', + ); + + Map toJson() => {'index': index, 'name': name}; +} + class LearnV2Course { final String id; final int courseNumber; @@ -54,7 +86,7 @@ class LearnV2Course { factory LearnV2Course.fromJson(Map json) => LearnV2Course( id: json['_id'] as String? ?? json['id'] as String? ?? '', - courseNumber: json['course_number'] as int? ?? 0, + courseNumber: _asInt(json['course_number']), title: json['title'] as String? ?? '', plainTitleKey: json['plain_title_key'] as String? ?? json['title'] as String? ?? '', @@ -160,7 +192,7 @@ class LearnV2Activity { LearnV2Activity( id: json['_id'] as String? ?? json['id'] as String? ?? '', type: json['type'] as String? ?? '', - order: json['order'] as int? ?? 0, + order: _asInt(json['order']), payload: Map.from(json['payload'] as Map? ?? {}), ); diff --git a/src/mobile/lib/src/app/learn/pages/kya_page.dart b/src/mobile/lib/src/app/learn/pages/kya_page.dart index c0ca65016b..e39fc3d418 100644 --- a/src/mobile/lib/src/app/learn/pages/kya_page.dart +++ b/src/mobile/lib/src/app/learn/pages/kya_page.dart @@ -163,6 +163,7 @@ class _KyaPageState extends State with UiLoggy { return _buildErrorState(state); } + if (catalog != null) LearnCatalog.applyCatalogMeta(catalog); final courses = catalog != null ? LearnCatalog.buildFromV2Catalog(catalog.courses) : const []; @@ -177,7 +178,7 @@ class _KyaPageState extends State with UiLoggy { final completed = LearnCatalog.catalogCompletedLessons(courses, _progress); final total = LearnCatalog.catalogTotalLessons(courses); final points = _progress.totalPoints(courses); - final maxPoints = _progress.maxPoints(courses); + final maxPoints = LearnCatalog.maxPoints(courses, _progress); return CustomScrollView( slivers: [ diff --git a/src/mobile/lib/src/app/learn/pages/lesson_finished.dart b/src/mobile/lib/src/app/learn/pages/lesson_finished.dart deleted file mode 100644 index 2e6d4cdf3d..0000000000 --- a/src/mobile/lib/src/app/learn/pages/lesson_finished.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_svg/flutter_svg.dart'; -import 'package:airqo/src/app/shared/widgets/translated_text.dart'; - -class LessonFinishedWidget extends StatelessWidget { - const LessonFinishedWidget({super.key}); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - "\ud83d\udc4b\ud83c\udffb ", - style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700), - ), - TranslatedText( - "Great Job !", - style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700), - ), - ], - ), - TranslatedText( - "You can now teach your friends to learn a thing about Air Pollution", - textAlign: TextAlign.center, - style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500)), - SizedBox(height: 64), - // SmallRoundedButton( - // label: "Share", - // imagePath: "assets/images/shared/share_icon.svg", - // ), - // SizedBox(height: 16), - // SmallRoundedButton( - // label: "Rate the App", - // imagePath: "assets/images/shared/bookmark_icon.svg", - // ), - ], - ), - ); - } -} - -class SmallRoundedButton extends StatelessWidget { - final String label; - final String imagePath; - const SmallRoundedButton( - {super.key, required this.label, required this.imagePath}); - - @override - Widget build(BuildContext context) { - return Container( - height: 49, - width: 144, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(200), - color: Theme.of(context).highlightColor), - child: Center( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SvgPicture.asset(imagePath), - SizedBox(width: 4), - Text(label), - ], - ), - ), - ); - } -} diff --git a/src/mobile/lib/src/app/learn/pages/lesson_page.dart b/src/mobile/lib/src/app/learn/pages/lesson_page.dart deleted file mode 100644 index 913675b5d1..0000000000 --- a/src/mobile/lib/src/app/learn/pages/lesson_page.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'package:airqo/src/app/learn/models/learn_course_structure.dart'; -import 'package:airqo/src/app/learn/models/learn_lesson_continuation.dart'; -import 'package:airqo/src/app/learn/widgets/experience/learn_lesson_experience.dart'; -import 'package:flutter/material.dart'; - -/// Hosts [LearnLessonExperience] inside a modal sheet or full-screen route. -class LessonPage extends StatelessWidget { - final LearnLessonSlot slot; - final LearnCourseViewModel course; - final int unitIndex; - final int lessonIndex; - final String unitPlainTitle; - final int lessonNumberInUnit; - final int lessonsInUnit; - final List? allCourses; - final LearnLessonContinuation? continuation; - final bool presentedAsModalSheet; - - const LessonPage({ - super.key, - required this.slot, - required this.course, - required this.unitIndex, - required this.lessonIndex, - required this.unitPlainTitle, - this.lessonNumberInUnit = 1, - this.lessonsInUnit = 1, - this.allCourses, - this.continuation, - this.presentedAsModalSheet = false, - }); - - @override - Widget build(BuildContext context) { - final experience = LearnLessonExperience( - slot: slot, - course: course, - unitIndex: unitIndex, - lessonIndex: lessonIndex, - unitPlainTitle: unitPlainTitle, - lessonNumberInUnit: lessonNumberInUnit, - lessonsInUnit: lessonsInUnit, - allCourses: allCourses, - continuation: continuation, - completionContext: context, - onClose: () => Navigator.of(context).pop(), - ); - - if (presentedAsModalSheet) { - return SizedBox.expand(child: experience); - } - - return Scaffold( - appBar: AppBar( - title: const Text('Lesson'), - centerTitle: true, - ), - body: experience, - ); - } -} diff --git a/src/mobile/lib/src/app/learn/repository/learn_repository.dart b/src/mobile/lib/src/app/learn/repository/learn_repository.dart index d38f082a2a..1f41553efe 100644 --- a/src/mobile/lib/src/app/learn/repository/learn_repository.dart +++ b/src/mobile/lib/src/app/learn/repository/learn_repository.dart @@ -3,6 +3,7 @@ import 'package:airqo/src/app/learn/models/learn_v2_catalog.dart'; import 'package:airqo/src/app/shared/repository/base_repository.dart'; import 'package:airqo/src/app/shared/services/cache_manager.dart'; import 'package:airqo/src/meta/utils/api_utils.dart'; +import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:loggy/loggy.dart'; @@ -16,24 +17,38 @@ abstract class LearnRepository extends BaseRepository { class LearnRepositoryImpl extends LearnRepository with UiLoggy { static final LearnRepositoryImpl _instance = LearnRepositoryImpl._internal(); factory LearnRepositoryImpl() => _instance; - LearnRepositoryImpl._internal(); - - final CacheManager _cacheManager = CacheManager(); + LearnRepositoryImpl._internal({ + CacheManager? cacheManager, + String? Function()? tokenProvider, + }) : _cacheManager = cacheManager ?? CacheManager(), + _tokenProvider = tokenProvider ?? _envToken; + + /// Non-singleton constructor for tests — injectable cache and token source. + @visibleForTesting + LearnRepositoryImpl.test({ + CacheManager? cacheManager, + String? Function()? tokenProvider, + }) : this._internal(cacheManager: cacheManager, tokenProvider: tokenProvider); + + final CacheManager _cacheManager; + final String? Function() _tokenProvider; static const String _catalogCacheKey = 'learn_v2_catalog'; + static String? _envToken() => dotenv.env['AIRQO_API_TOKEN']; + @override bool get isOffline => !_cacheManager.isConnected; - String? _getToken() => dotenv.env['AIRQO_API_TOKEN']; + String? _getToken() => _tokenProvider(); Future _fetchAndCache( {Duration timeout = const Duration(seconds: 20)}) async { + // The catalog endpoint is public; pass the token when configured but + // don't fail without it. final token = _getToken(); - if (token == null) throw StateError('AIRQO_API_TOKEN is not configured'); - final response = await createGetRequest( ApiUtils.learnCatalog, - {'token': token}, + {if (token != null) 'token': token}, ).timeout(timeout); if (response.statusCode != 200) { diff --git a/src/mobile/lib/src/app/learn/services/learn_api_client.dart b/src/mobile/lib/src/app/learn/services/learn_api_client.dart new file mode 100644 index 0000000000..17f6148303 --- /dev/null +++ b/src/mobile/lib/src/app/learn/services/learn_api_client.dart @@ -0,0 +1,145 @@ +import 'dart:convert'; + +import 'package:airqo/src/app/learn/models/learn_progress_models.dart'; +import 'package:airqo/src/meta/utils/api_utils.dart'; +import 'package:http/http.dart' as http; +import 'package:loggy/loggy.dart'; + +/// The identity a Learn progress call is made under. The server resolves the +/// JWT identity first when present, else the device/guest headers. +class LearnCallerIdentity { + final String deviceId; + final String? guestId; + final String? authToken; + + const LearnCallerIdentity({ + required this.deviceId, + this.guestId, + this.authToken, + }); +} + +/// Transport for the Learn progress endpoints — request/response shape only, +/// no flow decisions (buffering, retries, merging live in LearnSyncService). +class LearnApiClient with UiLoggy { + LearnApiClient({http.Client? client, String? baseUrl}) + : _client = client ?? http.Client(), + _baseUrl = baseUrl ?? ApiUtils.baseUrl; + + final http.Client _client; + final String _baseUrl; + + Uri _uri(String path) => Uri.parse('$_baseUrl$path'); + + Map _headers(LearnCallerIdentity identity) => { + 'Content-Type': 'application/json', + 'Accept': '*/*', + 'X-Device-Id': identity.deviceId, + if (identity.guestId != null) 'X-Guest-Id': identity.guestId!, + if (identity.authToken != null) + 'Authorization': 'JWT ${identity.authToken}', + 'User-Agent': ApiUtils.mobileUserAgent, + }; + + /// POST /sessions/anonymous. Returns the guest id, or null on a non-2xx + /// response. Network errors propagate to the caller. + Future createAnonymousSession({ + required LearnCallerIdentity identity, + required String platform, + required String appVersion, + }) async { + final response = await _client + .post( + _uri(ApiUtils.learnSessions), + body: json.encode({ + 'device_id': identity.deviceId, + 'platform': platform, + 'app_version': appVersion, + }), + headers: _headers(identity), + ) + .timeout(const Duration(seconds: 10)); + + if (response.statusCode < 200 || response.statusCode >= 300) return null; + final body = json.decode(response.body) as Map; + return body['guest_id'] as String? ?? body['session_id'] as String?; + } + + /// POST /progress/link. Returns true on a 2xx response. + Future linkGuestProgress({ + required LearnCallerIdentity identity, + required String guestId, + }) async { + final response = await _client + .post( + _uri(ApiUtils.learnProgressLink), + body: json.encode({ + 'device_id': identity.deviceId, + 'guest_id': guestId, + }), + headers: _headers(identity), + ) + .timeout(const Duration(seconds: 10)); + return response.statusCode >= 200 && response.statusCode < 300; + } + + /// PUT /progress/lessons/:lesson_id. Returns the raw response body on a + /// 2xx response, or null otherwise (caller decides whether to buffer). + Future putLessonProgress({ + required LearnCallerIdentity identity, + required String lessonId, + required Map body, + }) async { + final response = await _client + .put( + _uri('${ApiUtils.learnProgress}/${Uri.encodeComponent(lessonId)}'), + body: json.encode(body), + headers: _headers(identity), + ) + .timeout(const Duration(seconds: 10)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + loggy.warning('Lesson progress PUT failed (${response.statusCode})'); + return null; + } + return response.body; + } + + /// POST /progress/sync. Returns true on a 2xx response. + Future postSyncUpdates({ + required LearnCallerIdentity identity, + required List updates, + }) async { + final response = await _client + .post( + _uri(ApiUtils.learnProgressSync), + body: json.encode({ + 'device_id': identity.deviceId, + 'updates': updates, + }), + headers: _headers(identity), + ) + .timeout(const Duration(seconds: 15)); + return response.statusCode >= 200 && response.statusCode < 300; + } + + /// GET /progress. Returns parsed progress, or null on a non-2xx response. + Future getProgress({ + required LearnCallerIdentity identity, + }) async { + final response = await _client + .get( + _uri(ApiUtils.learnProgressFetch), + headers: _headers(identity), + ) + .timeout(const Duration(seconds: 10)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + loggy.warning('Learn progress fetch failed (${response.statusCode})'); + return null; + } + return LearnServerProgress.fromJson( + json.decode(response.body) as Map, + ); + } +} diff --git a/src/mobile/lib/src/app/learn/services/learn_lesson_completion_service.dart b/src/mobile/lib/src/app/learn/services/learn_lesson_completion_service.dart new file mode 100644 index 0000000000..9c1f1fe595 --- /dev/null +++ b/src/mobile/lib/src/app/learn/services/learn_lesson_completion_service.dart @@ -0,0 +1,65 @@ +import 'package:airqo/src/app/learn/models/learn_lesson_activity.dart'; +import 'package:airqo/src/app/learn/services/learn_progress_service.dart'; +import 'package:airqo/src/app/learn/services/learn_quiz_scoring_service.dart'; +import 'package:airqo/src/app/learn/services/learn_sync_service.dart'; +import 'package:loggy/loggy.dart'; + +/// Orchestrates finishing a lesson: scoring, local persistence, session +/// cleanup, and the server progress report. Extracted from the lesson +/// experience widget so the UI layer holds no business logic and the flow +/// can be tested with fakes. +class LearnLessonCompletionService with UiLoggy { + LearnLessonCompletionService({ + LearnProgressService? progress, + LearnSyncService? sync, + }) : _progress = progress ?? LearnProgressService.instance, + _sync = sync ?? LearnSyncService.instance; + + final LearnProgressService _progress; + final LearnSyncService _sync; + + /// Scores the lesson, saves the best-result locally, clears the in-lesson + /// session state, and reports completion to the server (fire-and-forget — + /// failures are buffered by the sync service for later flush). + Future completeLesson({ + required String lessonKey, + required List quizAttempts, + required int furthestActivityIndex, + String? combinedFreeText, + }) async { + // Free-text responses are saved locally and excluded from scoring. + final gradedResults = quizAttempts + .where((a) => a.format != LearnQuizFormat.freeText.apiKey) + .map((a) => a.isCorrect) + .toList(); + final result = LearnQuizScoringService.computeLessonResult( + gradedQuizResults: gradedResults, + freeTextResponse: combinedFreeText, + ); + + await _progress.markLessonComplete(lessonKey); + await _progress.saveLessonResult( + lessonKey: lessonKey, + stars: result.stars, + points: result.pointsEarned, + quizScoreRatio: result.quizScoreRatio, + freeText: result.freeTextResponse, + ); + await _progress.clearLessonSession(lessonKey); + + final correctCount = gradedResults.where((r) => r).length; + loggy.info('Lesson $lessonKey: completed — ' + '$correctCount/${gradedResults.length} quizzes correct, ' + '${result.stars} star(s), ${result.pointsEarned} points'); + + _sync + .reportCompletion( + lessonKey, + furthestActivityIndex: furthestActivityIndex, + quizAttempts: List.unmodifiable(quizAttempts), + freeTextResponse: result.freeTextResponse, + ) + .catchError((_) {}); + return result; + } +} diff --git a/src/mobile/lib/src/app/learn/services/learn_lesson_experience_service.dart b/src/mobile/lib/src/app/learn/services/learn_lesson_experience_service.dart index 019855a4f9..ae215361b8 100644 --- a/src/mobile/lib/src/app/learn/services/learn_lesson_experience_service.dart +++ b/src/mobile/lib/src/app/learn/services/learn_lesson_experience_service.dart @@ -26,6 +26,7 @@ class LearnLessonExperienceService { if (body.isEmpty) return null; return LearnLessonActivity( index: index, + activityId: v2.id, type: LearnActivityType.article, title: p['title'] as String? ?? 'Read', article: LearnArticlePayload( @@ -38,6 +39,7 @@ class LearnLessonExperienceService { if (url.isEmpty) return null; return LearnLessonActivity( index: index, + activityId: v2.id, type: LearnActivityType.video, title: p['title'] as String? ?? 'Watch', video: LearnVideoPayload( @@ -51,6 +53,7 @@ class LearnLessonExperienceService { if (url.isEmpty) return null; return LearnLessonActivity( index: index, + activityId: v2.id, type: LearnActivityType.image, title: p['title'] as String? ?? 'Look', image: LearnImagePayload( @@ -64,6 +67,7 @@ class LearnLessonExperienceService { if (quiz == null) return null; return LearnLessonActivity( index: index, + activityId: v2.id, type: LearnActivityType.quiz, title: p['title'] as String? ?? 'Quiz', quiz: quiz, @@ -89,6 +93,39 @@ class LearnLessonExperienceService { final rawOrder = p['correct_order'] as List?; final correctOrder = rawOrder?.map(_toInt).whereType().toList(); + // Reject quizzes whose answer key is missing or inconsistent with the + // options — they can never be graded correct and would always mark the + // user wrong. + switch (format) { + case LearnQuizFormat.singleChoice: + if (options.length < 2 || + correctIndex == null || + correctIndex < 0 || + correctIndex >= options.length) { + return null; + } + break; + case LearnQuizFormat.multiChoice: + if (options.length < 2 || + correctIndices == null || + correctIndices.isEmpty || + correctIndices.any((i) => i < 0 || i >= options.length)) { + return null; + } + break; + case LearnQuizFormat.ranking: + if (options.length < 2 || + correctOrder == null || + correctOrder.length != options.length || + correctOrder.toSet().length != options.length || + correctOrder.any((i) => i < 0 || i >= options.length)) { + return null; + } + break; + case LearnQuizFormat.freeText: + break; + } + return LearnQuizPayload( format: format, question: question, @@ -103,6 +140,20 @@ class LearnLessonExperienceService { ); } + /// Memoized per lesson instance — this runs inside widget builds, and a + /// catalog refresh produces new lesson objects, naturally invalidating it. + static final Expando _gradedQuizCountCache = Expando(); + + /// Number of gradeable (non-free-text) quizzes in a lesson, using the same + /// parsing rules as the lesson experience — the basis for max points. + static int gradedQuizCount(LearnV2Lesson lesson) { + return _gradedQuizCountCache[lesson] ??= buildFromV2Lesson(lesson: lesson) + .where((a) => + a.type == LearnActivityType.quiz && + a.quiz?.format != LearnQuizFormat.freeText) + .length; + } + static String activityTypeKey(LearnLessonActivity activity) { switch (activity.type) { case LearnActivityType.article: diff --git a/src/mobile/lib/src/app/learn/services/learn_progress_service.dart b/src/mobile/lib/src/app/learn/services/learn_progress_service.dart index 7276d37ecf..f906210780 100644 --- a/src/mobile/lib/src/app/learn/services/learn_progress_service.dart +++ b/src/mobile/lib/src/app/learn/services/learn_progress_service.dart @@ -1,13 +1,20 @@ +import 'dart:convert'; + import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:airqo/src/app/learn/models/learn_course_structure.dart'; +import 'package:airqo/src/app/learn/services/learn_lesson_experience_service.dart'; /// Local persistence for Learn tab progress (SharedPreferences). class LearnProgressService { LearnProgressService._(); static final LearnProgressService instance = LearnProgressService._(); + /// Non-singleton constructor for tests — injects the prefs store directly. + @visibleForTesting + LearnProgressService.withPrefs(SharedPreferences prefs) : _prefs = prefs; + static const _pilotSeedKey = 'learn_pilot_seeded_v3'; static const _pilotCleanupDoneKey = 'learn_pilot_cleanup_v1'; static const _stepPrefix = 'learn_step_'; @@ -17,6 +24,8 @@ class LearnProgressService { static const _pointsPrefix = 'learn_points_'; static const _quizScorePrefix = 'learn_quiz_score_'; static const _freeTextPrefix = 'learn_free_text_'; + static const _sessionAttemptsPrefix = 'learn_session_attempts_'; + static const _sessionFreeTextPrefix = 'learn_session_freetext_'; final ValueNotifier revision = ValueNotifier(0); SharedPreferences? _prefs; @@ -72,6 +81,8 @@ class LearnProgressService { return _prefs?.getString('$_freeTextPrefix$lessonKey'); } + /// Saves a lesson result, keeping the best score across replays so a + /// weaker replay never lowers previously earned stars/points. Future saveLessonResult({ required String lessonKey, required int stars, @@ -80,15 +91,126 @@ class LearnProgressService { String? freeText, }) async { await ensureInitialized(); - await _prefs!.setInt('$_starsPrefix$lessonKey', stars); - await _prefs!.setInt('$_pointsPrefix$lessonKey', points); - await _prefs!.setDouble('$_quizScorePrefix$lessonKey', quizScoreRatio); + final hasPrevious = _prefs!.containsKey('$_pointsPrefix$lessonKey'); + final isBetter = points > lessonPoints(lessonKey) || + (points == lessonPoints(lessonKey) && stars > lessonStars(lessonKey)); + if (!hasPrevious || isBetter) { + await _prefs!.setInt('$_starsPrefix$lessonKey', stars); + await _prefs!.setInt('$_pointsPrefix$lessonKey', points); + await _prefs!.setDouble('$_quizScorePrefix$lessonKey', quizScoreRatio); + } if (freeText != null && freeText.trim().isNotEmpty) { await _prefs!.setString('$_freeTextPrefix$lessonKey', freeText.trim()); } _notify(); } + /// Merges one lesson's server-side progress into local storage, keeping + /// whichever side is further along. Returns true if anything changed. + /// Callers batch-merging several lessons should call [notifyChanged] once + /// afterwards. + Future mergeServerLesson({ + required String lessonKey, + required bool completed, + int? furthestStep, + required int stars, + required int points, + double? quizScoreRatio, + String? freeTextResponse, + }) async { + await ensureInitialized(); + var changed = false; + if (completed && !isLessonComplete(lessonKey)) { + await _prefs!.setBool('$_completePrefix$lessonKey', true); + changed = true; + } + if (furthestStep != null && furthestStep > this.furthestStep(lessonKey)) { + await _prefs!.setInt('$_stepPrefix$lessonKey', furthestStep); + changed = true; + } + final localPoints = lessonPoints(lessonKey); + final localStars = lessonStars(lessonKey); + if (points > localPoints || + (points == localPoints && stars > localStars)) { + await _prefs!.setInt('$_starsPrefix$lessonKey', stars); + await _prefs!.setInt('$_pointsPrefix$lessonKey', points); + if (quizScoreRatio != null) { + await _prefs! + .setDouble('$_quizScorePrefix$lessonKey', quizScoreRatio); + } + changed = true; + } + // Free text isn't scored — restore the server copy only when this device + // has none, so a newer local answer is never clobbered. + final serverFreeText = freeTextResponse?.trim(); + if (serverFreeText != null && + serverFreeText.isNotEmpty && + (lessonFreeText(lessonKey)?.isEmpty ?? true)) { + await _prefs!.setString('$_freeTextPrefix$lessonKey', serverFreeText); + changed = true; + } + return changed; + } + + void notifyChanged() => _notify(); + + // ---- In-lesson session state (quiz attempts, free-text drafts) ---------- + // Persisted per lesson so quitting mid-lesson and resuming keeps earlier + // answers; cleared on completion or replay. + + List> sessionQuizAttempts(String lessonKey) { + final raw = _prefs?.getString('$_sessionAttemptsPrefix$lessonKey'); + if (raw == null) return const []; + try { + return (json.decode(raw) as List) + .map((e) => Map.from(e as Map)) + .toList(); + } catch (_) { + return const []; + } + } + + Future saveSessionQuizAttempts( + String lessonKey, + List> attempts, + ) async { + await ensureInitialized(); + await _prefs! + .setString('$_sessionAttemptsPrefix$lessonKey', json.encode(attempts)); + } + + Map sessionFreeTextResponses(String lessonKey) { + final raw = _prefs?.getString('$_sessionFreeTextPrefix$lessonKey'); + if (raw == null) return {}; + try { + final decoded = json.decode(raw) as Map; + return { + for (final entry in decoded.entries) + if (int.tryParse(entry.key) != null) + int.parse(entry.key): entry.value.toString(), + }; + } catch (_) { + return {}; + } + } + + Future saveSessionFreeTextResponses( + String lessonKey, + Map responses, + ) async { + await ensureInitialized(); + await _prefs!.setString( + '$_sessionFreeTextPrefix$lessonKey', + json.encode(responses.map((k, v) => MapEntry(k.toString(), v))), + ); + } + + Future clearLessonSession(String lessonKey) async { + await ensureInitialized(); + await _prefs!.remove('$_sessionAttemptsPrefix$lessonKey'); + await _prefs!.remove('$_sessionFreeTextPrefix$lessonKey'); + } + int totalPoints(List courses) { var total = 0; for (final course in courses) { @@ -101,9 +223,21 @@ class LearnProgressService { return total; } - /// Max points if every lesson earned 3 stars (30 pts each). + /// Max points if every gradeable quiz were answered correctly (10 pts + /// each) — mirrors how points are actually awarded in + /// LearnQuizScoringService.computeLessonResult. int maxPoints(List courses) { - return catalogTotalLessons(courses) * 30; + var total = 0; + for (final course in courses) { + for (final unit in course.units) { + for (final lesson in unit.lessons) { + final v2 = lesson.v2Lesson; + if (v2 == null) continue; + total += LearnLessonExperienceService.gradedQuizCount(v2) * 10; + } + } + } + return total; } static int catalogTotalLessons(List courses) { @@ -141,6 +275,8 @@ class LearnProgressService { key.startsWith(_pointsPrefix) || key.startsWith(_quizScorePrefix) || key.startsWith(_freeTextPrefix) || + key.startsWith(_sessionAttemptsPrefix) || + key.startsWith(_sessionFreeTextPrefix) || key == _pilotSeedKey || key == 'learn_pilot_seeded_v2', ); diff --git a/src/mobile/lib/src/app/learn/services/learn_quiz_scoring_service.dart b/src/mobile/lib/src/app/learn/services/learn_quiz_scoring_service.dart index c14ea191e3..c8159e0f54 100644 --- a/src/mobile/lib/src/app/learn/services/learn_quiz_scoring_service.dart +++ b/src/mobile/lib/src/app/learn/services/learn_quiz_scoring_service.dart @@ -17,6 +17,7 @@ class LearnQuizScoringService { return LearnQuizGrade( isCorrect: correct, feedback: correct ? quiz.correctFeedback : quiz.incorrectFeedback, + selectedIndex: selectedIndex, ); } @@ -36,6 +37,7 @@ class LearnQuizScoringService { return LearnQuizGrade( isCorrect: correct, feedback: correct ? quiz.correctFeedback : quiz.incorrectFeedback, + selectedIndices: selectedIndices.toList()..sort(), ); } @@ -54,6 +56,7 @@ class LearnQuizScoringService { return LearnQuizGrade( isCorrect: correct, feedback: correct ? quiz.correctFeedback : quiz.incorrectFeedback, + selectedOrder: List.of(submittedOrder), ); } diff --git a/src/mobile/lib/src/app/learn/services/learn_sync_service.dart b/src/mobile/lib/src/app/learn/services/learn_sync_service.dart index 768c68a18a..7b20887696 100644 --- a/src/mobile/lib/src/app/learn/services/learn_sync_service.dart +++ b/src/mobile/lib/src/app/learn/services/learn_sync_service.dart @@ -1,31 +1,18 @@ +import 'dart:async'; import 'dart:convert'; -import 'package:airqo/src/app/shared/repository/base_repository.dart'; +import 'dart:io' show Platform; + +import 'package:airqo/src/app/auth/services/auth_helper.dart'; +import 'package:airqo/src/app/learn/models/learn_progress_models.dart'; +import 'package:airqo/src/app/learn/services/learn_api_client.dart'; +import 'package:airqo/src/app/learn/services/learn_progress_service.dart'; +import 'package:airqo/src/app/shared/services/cache_manager.dart'; import 'package:airqo/src/app/shared/utils/device_id_manager.dart'; -import 'package:airqo/src/meta/utils/api_utils.dart'; -import 'package:http/http.dart' as http; import 'package:loggy/loggy.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; -class QuizAttemptData { - final String activityId; - final String format; - final int? selectedIndex; - final bool isCorrect; - - const QuizAttemptData({ - required this.activityId, - required this.format, - this.selectedIndex, - required this.isCorrect, - }); - - Map toJson() => { - 'activity_id': activityId, - 'format': format, - if (selectedIndex != null) 'selected_index': selectedIndex, - 'is_correct': isCorrect, - }; -} +export 'package:airqo/src/app/learn/models/learn_progress_models.dart'; // --------------------------------------------------------------------------- // Private: offline progress buffer — append / drain / clear SharedPrefs list. @@ -68,45 +55,99 @@ class _ProgressBuffer with UiLoggy { // --------------------------------------------------------------------------- abstract class LearnSyncService { - static final LearnSyncService instance = _LearnSyncServiceImpl._(); + /// App-wide instance. Deliberately settable so tests (or a future DI + /// container) can substitute an implementation before widgets read it. + static LearnSyncService instance = LearnSyncServiceImpl(); Future ensureGuestSession(); Future linkProgressToAccount(String authToken); Future reportCompletion( String lessonId, { - required int totalActivities, + required int furthestActivityIndex, List quizAttempts, String? freeTextResponse, }); Future syncPendingProgress(); + Future fetchProgress({String? authToken}); + Future hydrateLocalProgress({String? authToken}); } -class _LearnSyncServiceImpl extends BaseRepository - with UiLoggy - implements LearnSyncService { - _LearnSyncServiceImpl._(); +/// Orchestrates Learn progress sync: guest-session lifecycle, buffering of +/// failed reports, offline flush, and server→local hydration. Transport +/// lives in [LearnApiClient]; local persistence in [LearnProgressService]. +class LearnSyncServiceImpl with UiLoggy implements LearnSyncService { + LearnSyncServiceImpl({ + LearnApiClient? api, + LearnProgressService? progress, + CacheManager? cacheManager, + Future Function()? authTokenProvider, + Future Function()? deviceIdProvider, + Future Function()? prefsProvider, + Future Function()? appVersionProvider, + }) : _api = api ?? LearnApiClient(), + _progress = progress ?? LearnProgressService.instance, + _cacheManager = cacheManager ?? CacheManager(), + _authTokenProvider = authTokenProvider ?? _defaultAuthToken, + _deviceIdProvider = deviceIdProvider ?? DeviceIdManager.getDeviceId, + _prefsProvider = prefsProvider ?? SharedPreferences.getInstance, + _appVersionProvider = appVersionProvider ?? _defaultAppVersion; static const _guestIdKey = 'learn_guest_id'; + final LearnApiClient _api; + final LearnProgressService _progress; + final CacheManager _cacheManager; + final Future Function() _authTokenProvider; + final Future Function() _deviceIdProvider; + final Future Function() _prefsProvider; + final Future Function() _appVersionProvider; + SharedPreferences? _prefs; _ProgressBuffer? _buffer; + StreamSubscription? _connectivitySub; + + static Future _defaultAuthToken() async { + try { + return await AuthHelper.refreshTokenIfNeeded(); + } catch (_) { + return null; + } + } + + static Future _defaultAppVersion() async { + try { + return (await PackageInfo.fromPlatform()).version; + } catch (_) { + return 'unknown'; + } + } + + /// Flushes buffered progress as soon as connectivity returns instead of + /// waiting for the next app launch. Lives for the app's lifetime (the + /// service is a singleton), so the subscription is never cancelled. + void _ensureReconnectFlush() { + _connectivitySub ??= _cacheManager.connectionChange.listen((type) { + if (type != ConnectionType.none) { + syncPendingProgress().catchError((_) {}); + } + }); + } Future _ensurePrefs() async { if (_prefs != null) return; - _prefs = await SharedPreferences.getInstance(); + _prefs = await _prefsProvider(); _buffer = _ProgressBuffer(_prefs!); } - Future> _guestHeaders() async { - final deviceId = await DeviceIdManager.getDeviceId(); - final guestId = _prefs?.getString(_guestIdKey); - return { - 'Content-Type': 'application/json', - 'Accept': '*/*', - 'X-Device-Id': deviceId, - if (guestId != null) 'X-Guest-Id': guestId, - 'User-Agent': ApiUtils.mobileUserAgent, - }; + /// Identity for progress calls: device + guest headers plus, when the user + /// is logged in, the JWT — the server resolves the JWT identity first, so + /// logged-in progress is attributed to the account unambiguously. + Future _identity({String? authToken}) async { + return LearnCallerIdentity( + deviceId: await _deviceIdProvider(), + guestId: _prefs?.getString(_guestIdKey), + authToken: authToken ?? await _authTokenProvider(), + ); } // ---- Guest session management ------------------------------------------ @@ -114,29 +155,18 @@ class _LearnSyncServiceImpl extends BaseRepository @override Future ensureGuestSession() async { await _ensurePrefs(); + _ensureReconnectFlush(); if (_prefs!.getString(_guestIdKey) != null) return; try { - final deviceId = await DeviceIdManager.getDeviceId(); - final response = await http.post( - Uri.parse('${ApiUtils.baseUrl}${ApiUtils.learnSessions}'), - body: json.encode({'device_id': deviceId, 'platform': 'mobile'}), - headers: { - 'Content-Type': 'application/json', - 'Accept': '*/*', - 'X-Device-Id': deviceId, - 'User-Agent': ApiUtils.mobileUserAgent, - }, - ).timeout(const Duration(seconds: 10)); - - if (response.statusCode >= 200 && response.statusCode < 300) { - final body = json.decode(response.body) as Map; - final guestId = - body['guest_id'] as String? ?? body['session_id'] as String?; - if (guestId != null) { - await _prefs!.setString(_guestIdKey, guestId); - loggy.info('Guest Learn session created'); - } + final guestId = await _api.createAnonymousSession( + identity: LearnCallerIdentity(deviceId: await _deviceIdProvider()), + platform: Platform.isIOS ? 'ios' : 'android', + appVersion: await _appVersionProvider(), + ); + if (guestId != null) { + await _prefs!.setString(_guestIdKey, guestId); + loggy.info('Guest Learn session created'); } } catch (e) { loggy.warning('Failed to create guest Learn session: $e'); @@ -150,20 +180,14 @@ class _LearnSyncServiceImpl extends BaseRepository if (guestId == null) return; try { - final deviceId = await DeviceIdManager.getDeviceId(); - final response = await http.post( - Uri.parse('${ApiUtils.baseUrl}${ApiUtils.learnProgressLink}'), - body: json.encode({'guest_id': guestId}), - headers: { - 'Content-Type': 'application/json', - 'Accept': '*/*', - 'Authorization': 'JWT $authToken', - 'X-Device-Id': deviceId, - 'User-Agent': ApiUtils.mobileUserAgent, - }, - ).timeout(const Duration(seconds: 10)); - - if (response.statusCode >= 200 && response.statusCode < 300) { + final linked = await _api.linkGuestProgress( + identity: LearnCallerIdentity( + deviceId: await _deviceIdProvider(), + authToken: authToken, + ), + guestId: guestId, + ); + if (linked) { await syncPendingProgress(); await _prefs!.remove(_guestIdKey); loggy.info('Guest Learn progress linked to account'); @@ -178,34 +202,32 @@ class _LearnSyncServiceImpl extends BaseRepository @override Future reportCompletion( String lessonId, { - required int totalActivities, + required int furthestActivityIndex, List quizAttempts = const [], String? freeTextResponse, }) async { await _ensurePrefs(); + final freeText = freeTextResponse?.trim(); final body = { - 'total_activities': totalActivities, + 'furthest_activity_index': furthestActivityIndex, 'completed': true, if (quizAttempts.isNotEmpty) 'quiz_attempts': quizAttempts.map((q) => q.toJson()).toList(), - if (freeTextResponse != null && freeTextResponse.isNotEmpty) - 'free_text_response': freeTextResponse, + if (freeText != null && freeText.isNotEmpty) + 'free_text_response': freeText, }; try { - final headers = await _guestHeaders(); - final response = await http.put( - Uri.parse('${ApiUtils.baseUrl}${ApiUtils.learnProgress}/${Uri.encodeComponent(lessonId)}'), - body: json.encode(body), - headers: headers, - ).timeout(const Duration(seconds: 10)); - - if (response.statusCode >= 200 && response.statusCode < 300) { - loggy.info('Reported completion for lesson $lessonId'); + final responseBody = await _api.putLessonProgress( + identity: await _identity(), + lessonId: lessonId, + body: body, + ); + if (responseBody != null) { + _logServerProgress(lessonId, responseBody); } else { - loggy.warning( - 'Progress report failed (${response.statusCode}), buffering'); + loggy.warning('Progress report failed, buffering'); await _buffer!.append(lessonId, body); } } catch (e) { @@ -214,6 +236,70 @@ class _LearnSyncServiceImpl extends BaseRepository } } + // ---- Progress restore --------------------------------------------------- + + @override + Future fetchProgress({String? authToken}) async { + await _ensurePrefs(); + try { + final progress = await _api.getProgress( + identity: await _identity(authToken: authToken), + ); + if (progress != null) { + loggy.info('Fetched Learn progress: ${progress.lessons.length} ' + 'lesson(s), ${progress.totalPoints} total pts'); + } + return progress; + } catch (e) { + loggy.warning('Learn progress fetch error: $e'); + return null; + } + } + + /// Fetches server-side progress and merges it into local storage, + /// keeping the best of each lesson (never downgrades local progress). + @override + Future hydrateLocalProgress({String? authToken}) async { + final server = await fetchProgress(authToken: authToken); + if (server == null || server.lessons.isEmpty) return; + + await _progress.ensureInitialized(); + var merged = 0; + for (final entry in server.lessons.entries) { + final lesson = entry.value; + final changed = await _progress.mergeServerLesson( + lessonKey: entry.key, + completed: lesson.completed, + // Server stores the furthest activity *index*; local furthest step + // is a count of completed steps. + furthestStep: lesson.furthestActivityIndex != null + ? lesson.furthestActivityIndex! + 1 + : null, + stars: lesson.stars, + points: lesson.pointsEarned, + quizScoreRatio: lesson.quizScoreRatio, + freeTextResponse: lesson.freeTextResponse, + ); + if (changed) merged++; + } + if (merged > 0) _progress.notifyChanged(); + loggy.info('Hydrated Learn progress from server: ' + '$merged of ${server.lessons.length} lesson(s) updated locally'); + } + + void _logServerProgress(String lessonId, String responseBody) { + try { + final data = json.decode(responseBody) as Map; + final stage = data['current_stage']; + loggy.info('Reported completion for lesson $lessonId — server says ' + '${data['stars']} star(s), ${data['points_earned']} pts earned, ' + '${data['total_points']} total pts' + '${stage is Map ? ', stage ${stage['name']}' : ''}'); + } catch (_) { + loggy.info('Reported completion for lesson $lessonId'); + } + } + // ---- Offline sync ------------------------------------------------------- @override @@ -223,14 +309,11 @@ class _LearnSyncServiceImpl extends BaseRepository if (pending == null || pending.isEmpty) return; try { - final headers = await _guestHeaders(); - final response = await http.post( - Uri.parse('${ApiUtils.baseUrl}${ApiUtils.learnProgressSync}'), - body: json.encode({'progress': pending}), - headers: headers, - ).timeout(const Duration(seconds: 15)); - - if (response.statusCode >= 200 && response.statusCode < 300) { + final synced = await _api.postSyncUpdates( + identity: await _identity(), + updates: pending, + ); + if (synced) { await _buffer!.clear(); loggy.info('Synced ${pending.length} pending Learn progress entries'); } diff --git a/src/mobile/lib/src/app/learn/widgets/experience/learn_article_activity.dart b/src/mobile/lib/src/app/learn/widgets/experience/learn_article_activity.dart index 86f0bb6fb5..c669271945 100644 --- a/src/mobile/lib/src/app/learn/widgets/experience/learn_article_activity.dart +++ b/src/mobile/lib/src/app/learn/widgets/experience/learn_article_activity.dart @@ -58,8 +58,8 @@ class _LearnArticleActivityState extends State { await _tts.setSpeechRate(_speechRate); await _tts.setVolume(1); await _tts.setPitch(1); - _tts.setCompletionHandler(_onPlaybackEnded); - _tts.setCancelHandler(_onPlaybackEnded); + _tts.setCompletionHandler(() => _onPlaybackEnded(completed: true)); + _tts.setCancelHandler(() => _onPlaybackEnded(completed: false)); _tts.setProgressHandler((text, start, end, word) { if (!mounted) return; setState(() { @@ -69,12 +69,14 @@ class _LearnArticleActivityState extends State { }); } - void _onPlaybackEnded() { + void _onPlaybackEnded({required bool completed}) { _progressTimer?.cancel(); if (!mounted) return; setState(() { _isListening = false; - _elapsed = _totalDuration; + // A user stop restarts from the beginning next time, so the bar + // resets; only natural completion shows a full bar. + _elapsed = completed ? _totalDuration : Duration.zero; _highlightStart = -1; _highlightEnd = -1; }); @@ -103,8 +105,10 @@ class _LearnArticleActivityState extends State { if (_isListening) { await _tts.stop(); _progressTimer?.cancel(); + if (!mounted) return; setState(() { _isListening = false; + _elapsed = Duration.zero; _highlightStart = -1; _highlightEnd = -1; }); @@ -128,9 +132,19 @@ class _LearnArticleActivityState extends State { ); } + /// Highlighting swaps the rendered text for [_spokenText], so it is only + /// safe when the spoken text is the article body itself — otherwise the + /// visible article would change while listening. + bool get _canHighlight => + widget.payload.audioText == null || + widget.payload.audioText == widget.payload.body; + Widget _buildArticleBody(BuildContext context) { final baseStyle = _bodyStyle(context); - if (!_isListening || _highlightStart < 0 || _highlightEnd <= _highlightStart) { + if (!_canHighlight || + !_isListening || + _highlightStart < 0 || + _highlightEnd <= _highlightStart) { return TranslatedText( widget.payload.body, style: baseStyle, diff --git a/src/mobile/lib/src/app/learn/widgets/experience/learn_course_certificate.dart b/src/mobile/lib/src/app/learn/widgets/experience/learn_course_certificate.dart index 4c97edf1a9..c4210b5a74 100644 --- a/src/mobile/lib/src/app/learn/widgets/experience/learn_course_certificate.dart +++ b/src/mobile/lib/src/app/learn/widgets/experience/learn_course_certificate.dart @@ -112,12 +112,6 @@ class _LearnCourseCertificatePaneState extends State textAlign: TextAlign.center, style: LearnDesignTokens.completionCaption(context), ), - const SizedBox(height: 4), - TranslatedText( - '(${learnCourseCompletionRarityLabel()})', - textAlign: TextAlign.center, - style: LearnDesignTokens.completionCaption(context), - ), ], ), actions: [ diff --git a/src/mobile/lib/src/app/learn/widgets/experience/learn_experience_shell.dart b/src/mobile/lib/src/app/learn/widgets/experience/learn_experience_shell.dart index d96033d57f..0be0768fb3 100644 --- a/src/mobile/lib/src/app/learn/widgets/experience/learn_experience_shell.dart +++ b/src/mobile/lib/src/app/learn/widgets/experience/learn_experience_shell.dart @@ -3,6 +3,7 @@ import 'package:airqo/src/app/learn/theme/learn_design_tokens.dart'; import 'package:airqo/src/app/learn/widgets/learn_lesson_thumbnail.dart'; import 'package:airqo/src/app/learn/widgets/learn_sheet_button_styles.dart'; import 'package:airqo/src/app/shared/widgets/translated_text.dart'; +import 'package:airqo/src/meta/utils/colors.dart'; import 'package:flutter/material.dart'; class LearnExperienceShell extends StatelessWidget { @@ -45,6 +46,22 @@ class LearnExperienceShell extends StatelessWidget { children: [ if (showDragHandle) LearnDesignTokens.dragHandle(context), + if (onClose != null) + Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.only(top: 4, right: 4), + child: IconButton( + onPressed: onClose, + icon: Icon( + Icons.close, + color: AppTextColors.modalCloseIcon(context), + ), + visualDensity: VisualDensity.compact, + tooltip: 'Close lesson', + ), + ), + ), Padding( padding: EdgeInsets.fromLTRB( horizontalPadding, diff --git a/src/mobile/lib/src/app/learn/widgets/experience/learn_lesson_experience.dart b/src/mobile/lib/src/app/learn/widgets/experience/learn_lesson_experience.dart index 2a6662a6c6..6bb5a6134c 100644 --- a/src/mobile/lib/src/app/learn/widgets/experience/learn_lesson_experience.dart +++ b/src/mobile/lib/src/app/learn/widgets/experience/learn_lesson_experience.dart @@ -2,9 +2,9 @@ import 'package:airqo/src/app/learn/formatting/learn_display_text.dart'; import 'package:airqo/src/app/learn/models/learn_course_structure.dart'; import 'package:airqo/src/app/learn/models/learn_lesson_activity.dart'; import 'package:airqo/src/app/learn/models/learn_lesson_continuation.dart'; +import 'package:airqo/src/app/learn/services/learn_lesson_completion_service.dart'; import 'package:airqo/src/app/learn/services/learn_lesson_experience_service.dart'; import 'package:airqo/src/app/learn/services/learn_progress_service.dart'; -import 'package:airqo/src/app/learn/services/learn_quiz_scoring_service.dart'; import 'package:airqo/src/app/learn/services/learn_sync_service.dart'; import 'package:airqo/src/app/learn/widgets/experience/learn_article_activity.dart'; import 'package:airqo/src/app/learn/widgets/experience/learn_experience_shell.dart'; @@ -12,7 +12,9 @@ import 'package:airqo/src/app/learn/widgets/experience/learn_image_activity.dart import 'package:airqo/src/app/learn/widgets/experience/learn_quiz_activity.dart'; import 'package:airqo/src/app/learn/widgets/experience/learn_video_activity.dart'; import 'package:airqo/src/app/learn/widgets/learn_bottom_sheets.dart'; +import 'package:airqo/src/app/shared/widgets/translated_text.dart'; import 'package:flutter/material.dart'; +import 'package:loggy/loggy.dart'; class LearnLessonExperience extends StatefulWidget { final LearnLessonSlot slot; @@ -27,6 +29,10 @@ class LearnLessonExperience extends StatefulWidget { final VoidCallback onClose; final BuildContext completionContext; + /// Injectable for tests; default to the app-wide instances. + final LearnProgressService? progressService; + final LearnLessonCompletionService? completionService; + const LearnLessonExperience({ super.key, required this.slot, @@ -40,19 +46,25 @@ class LearnLessonExperience extends StatefulWidget { required this.completionContext, this.allCourses, this.continuation, + this.progressService, + this.completionService, }); @override State createState() => _LearnLessonExperienceState(); } -class _LearnLessonExperienceState extends State { +class _LearnLessonExperienceState extends State + with UiLoggy { late final List _script; late int _activityIndex; - final _progress = LearnProgressService.instance; - final List _gradedResults = []; + late final LearnProgressService _progress = + widget.progressService ?? LearnProgressService.instance; + late final LearnLessonCompletionService _completion = + widget.completionService ?? + LearnLessonCompletionService(progress: _progress); final List _quizAttempts = []; - String? _freeTextResponse; + final Map _freeTextResponses = {}; LearnLessonResult? _result; @override @@ -63,16 +75,34 @@ class _LearnLessonExperienceState extends State { lesson: widget.slot.v2Lesson!, ) : const []; - // Already-complete lessons replay from step 0. - // In-progress lessons resume from their furthest step. - if (_progress.isLessonComplete(widget.slot.progressKey)) { + // Already-complete lessons replay from step 0 with a fresh session. + // In-progress lessons resume from their furthest step with earlier + // answers restored, so the final score reflects the whole lesson. + final key = widget.slot.progressKey; + if (_progress.isLessonComplete(key)) { _activityIndex = 0; + _progress.clearLessonSession(key); + loggy.info( + 'Lesson $key: replaying from start (${_script.length} activities)'); } else { - final saved = _progress.furthestStep(widget.slot.progressKey); + final saved = _progress.furthestStep(key); _activityIndex = _script.isEmpty ? 0 : saved.clamp(0, _script.length - 1); + _quizAttempts.addAll( + _progress.sessionQuizAttempts(key).map(QuizAttemptData.fromJson), + ); + _freeTextResponses.addAll(_progress.sessionFreeTextResponses(key)); + loggy.info('Lesson $key: opening at activity ${_activityIndex + 1}' + '/${_script.length}, restored ${_quizAttempts.length} quiz ' + 'attempt(s), ${_freeTextResponses.length} free-text response(s)'); } } + String? get _combinedFreeText { + if (_freeTextResponses.isEmpty) return null; + final keys = _freeTextResponses.keys.toList()..sort(); + return keys.map((k) => _freeTextResponses[k]).join('\n\n'); + } + bool get _isCourseFinal => LearnLessonExperienceService.isCourseFinalLesson( widget.course, widget.unitIndex, @@ -90,29 +120,21 @@ class _LearnLessonExperienceState extends State { await _completeLesson(); } else { setState(() => _activityIndex++); + loggy.info('Lesson ${widget.slot.progressKey}: advanced to activity ' + '${_activityIndex + 1}/${_script.length} ' + '(${_current.type.name})'); } } Future _completeLesson() async { - final result = LearnQuizScoringService.computeLessonResult( - gradedQuizResults: _gradedResults, - freeTextResponse: _freeTextResponse, - ); - await _progress.markLessonComplete(widget.slot.progressKey); - await _progress.saveLessonResult( + _result = await _completion.completeLesson( lessonKey: widget.slot.progressKey, - stars: result.stars, - points: result.pointsEarned, - quizScoreRatio: result.quizScoreRatio, - freeText: result.freeTextResponse, - ); - LearnSyncService.instance.reportCompletion( - widget.slot.progressKey, - totalActivities: _script.length, quizAttempts: List.unmodifiable(_quizAttempts), - freeTextResponse: _freeTextResponse, - ).catchError((_) {}); - _result = result; + // Report the catalog index of the last activity — the script can be + // shorter than the catalog list when invalid activities are skipped. + furthestActivityIndex: _script.isNotEmpty ? _script.last.index : 0, + combinedFreeText: _combinedFreeText, + ); _presentCompletionSheet(); } @@ -153,13 +175,43 @@ class _LearnLessonExperienceState extends State { void _recordQuizGrade(LearnQuizGrade grade) { if (_current.type != LearnActivityType.quiz) return; - if (_current.quiz?.format == LearnQuizFormat.freeText) return; - _gradedResults.add(grade.isCorrect); - _quizAttempts.add(QuizAttemptData( - activityId: _current.index.toString(), + final attempt = QuizAttemptData( + // The API needs the catalog `_id` for server-side verification; fall + // back to the positional index for legacy content without ids. + activityId: _current.activityId.isNotEmpty + ? _current.activityId + : _current.index.toString(), format: _current.quiz!.format.apiKey, + selectedIndex: grade.selectedIndex, + selectedIndices: grade.selectedIndices, + selectedOrder: grade.selectedOrder, isCorrect: grade.isCorrect, - )); + ); + final existing = + _quizAttempts.indexWhere((a) => a.activityId == attempt.activityId); + if (existing >= 0) { + _quizAttempts[existing] = attempt; + } else { + _quizAttempts.add(attempt); + } + loggy.info('Lesson ${widget.slot.progressKey}: quiz activity ' + '${_current.index} (${attempt.format}) graded ' + '${attempt.isCorrect ? 'correct' : 'incorrect'}' + '${attempt.selectedIndex != null ? ', selected index ${attempt.selectedIndex}' : ''}'); + _progress.saveSessionQuizAttempts( + widget.slot.progressKey, + _quizAttempts.map((a) => a.toJson()).toList(), + ); + } + + void _recordFreeText(String text) { + _freeTextResponses[_current.index] = text; + loggy.info('Lesson ${widget.slot.progressKey}: free-text response ' + 'recorded for activity ${_current.index} (${text.length} chars)'); + _progress.saveSessionFreeTextResponses( + widget.slot.progressKey, + Map.of(_freeTextResponses), + ); } Widget _buildActivityBody() { @@ -192,7 +244,7 @@ class _LearnLessonExperienceState extends State { quiz: activity.quiz!, activityTypeLabel: typeLabel, onGraded: _recordQuizGrade, - onFreeText: (text) => _freeTextResponse = text, + onFreeText: _recordFreeText, onContinue: _advanceActivity, ); } @@ -200,7 +252,7 @@ class _LearnLessonExperienceState extends State { @override Widget build(BuildContext context) { - if (_script.isEmpty) return const SizedBox.shrink(); + if (_script.isEmpty) return _buildEmptyState(context); final lessonTitle = learnDisplayTitle( widget.slot.v2Lesson?.title ?? widget.slot.plainTitleKey, @@ -220,9 +272,48 @@ class _LearnLessonExperienceState extends State { _script.length, ), onClose: widget.onClose, - body: _buildActivityBody(), + showDragHandle: false, + body: KeyedSubtree( + key: ValueKey(_activityIndex), + child: _buildActivityBody(), + ), bottomBar: const SizedBox.shrink(), ), ); } + + Widget _buildEmptyState(BuildContext context) { + return SizedBox.expand( + child: Column( + children: [ + Expanded( + child: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.menu_book_outlined, + size: 48, + color: Theme.of(context).disabledColor, + ), + const SizedBox(height: 12), + const TranslatedText( + 'This lesson is not available yet. Please check back later.', + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ), + LearnExperienceBottomBar( + primaryLabel: 'Close', + onPrimary: widget.onClose, + ), + ], + ), + ); + } } diff --git a/src/mobile/lib/src/app/learn/widgets/experience/learn_lesson_finish_pane.dart b/src/mobile/lib/src/app/learn/widgets/experience/learn_lesson_finish_pane.dart index 6e741f403f..d20ad7325d 100644 --- a/src/mobile/lib/src/app/learn/widgets/experience/learn_lesson_finish_pane.dart +++ b/src/mobile/lib/src/app/learn/widgets/experience/learn_lesson_finish_pane.dart @@ -26,10 +26,7 @@ class LearnLessonFinishPane extends StatelessWidget { this.isNextUnit = false, }); - String get _primaryLabel { - if (onNext == null) return 'Done'; - return isNextUnit ? 'Next unit' : 'Next lesson'; - } + String get _primaryLabel => isNextUnit ? 'Next unit' : 'Next lesson'; @override Widget build(BuildContext context) { @@ -70,6 +67,20 @@ class LearnLessonFinishPane extends StatelessWidget { textAlign: TextAlign.center, style: LearnDesignTokens.completionSubtitle(context), ), + const SizedBox(height: 10), + Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(3, (i) { + final earned = i < result.stars; + return Icon( + earned ? Icons.star_rounded : Icons.star_outline_rounded, + size: 28, + color: earned + ? const Color(0xFFFFB300) + : LearnDesignTokens.muted(context), + ); + }), + ), const SizedBox(height: 8), Text( '${result.pointsEarned} points earned', @@ -88,18 +99,19 @@ class LearnLessonFinishPane extends StatelessWidget { ], ), actions: [ - if (hasNext) + if (hasNext) ...[ ElevatedButton( onPressed: onNext, style: learnExposurePrimaryButtonStyle(), child: TranslatedText(_primaryLabel), - ) - else - OutlinedButton( - onPressed: onDone, - style: learnExposureSecondaryButtonStyle(context), - child: TranslatedText(_primaryLabel), ), + const SizedBox(height: 8), + ], + OutlinedButton( + onPressed: onDone, + style: learnExposureSecondaryButtonStyle(context), + child: const TranslatedText('Done'), + ), ], ); } diff --git a/src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_ranking.dart b/src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_ranking.dart index 925a717c8b..a2b1195356 100644 --- a/src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_ranking.dart +++ b/src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_ranking.dart @@ -4,6 +4,7 @@ import 'package:airqo/src/app/learn/theme/learn_design_tokens.dart'; import 'package:airqo/src/app/learn/widgets/experience/learn_experience_shell.dart'; import 'package:airqo/src/app/learn/widgets/experience/learn_quiz_option.dart'; import 'package:airqo/src/app/shared/widgets/translated_text.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; class LearnQuizRankingActivity extends StatefulWidget { @@ -28,12 +29,23 @@ class LearnQuizRankingActivity extends StatefulWidget { class _LearnQuizRankingActivityState extends State { late List _order; bool _submitted = false; + bool _reordered = false; LearnQuizGrade? _grade; @override void initState() { super.initState(); _order = List.generate(widget.quiz.options.length, (i) => i); + // Present the items shuffled so the correct order is never pre-filled. + if (_order.length > 1) { + final expected = widget.quiz.correctOrder ?? List.of(_order); + _order.shuffle(); + if (listEquals(_order, expected)) { + final tmp = _order[0]; + _order[0] = _order[1]; + _order[1] = tmp; + } + } } void _submit() { @@ -60,7 +72,13 @@ class _LearnQuizRankingActivityState extends State { ), child: Row( children: [ - Icon(Icons.drag_handle, color: LearnDesignTokens.muted(context)), + ReorderableDragStartListener( + index: listIndex, + child: Icon( + Icons.drag_handle, + color: LearnDesignTokens.muted(context), + ), + ), const SizedBox(width: 8), Container( width: 22, @@ -115,6 +133,11 @@ class _LearnQuizRankingActivityState extends State { color: LearnDesignTokens.headline(context), ), ), + const SizedBox(height: 4), + TranslatedText( + 'Drag the handle to arrange the items in order', + style: LearnDesignTokens.activitySubtitle(context), + ), const SizedBox(height: 12), Expanded( child: Theme( @@ -147,6 +170,7 @@ class _LearnQuizRankingActivityState extends State { if (newIndex > oldIndex) newIndex -= 1; final item = _order.removeAt(oldIndex); _order.insert(newIndex, item); + _reordered = true; }); }, itemBuilder: (context, index) { @@ -167,6 +191,7 @@ class _LearnQuizRankingActivityState extends State { ), LearnExperienceBottomBar( primaryLabel: _submitted ? 'Continue' : 'Check order', + primaryEnabled: _submitted || _reordered, onPrimary: _submitted ? widget.onContinue : _submit, ), ], diff --git a/src/mobile/lib/src/app/learn/widgets/experience/learn_video_activity.dart b/src/mobile/lib/src/app/learn/widgets/experience/learn_video_activity.dart index 62d44bf032..76c3a61a1e 100644 --- a/src/mobile/lib/src/app/learn/widgets/experience/learn_video_activity.dart +++ b/src/mobile/lib/src/app/learn/widgets/experience/learn_video_activity.dart @@ -51,7 +51,6 @@ class _LearnVideoActivityState extends State { } else if (_isDirectVideo(url)) { _directController = VideoPlayerController.networkUrl(Uri.parse(url)); await _directController!.initialize(); - _directController!.setLooping(true); } else { _error = 'Unsupported video URL'; } @@ -61,6 +60,19 @@ class _LearnVideoActivityState extends State { if (mounted) setState(() => _ready = true); } + void _toggleDirectPlayback() { + final controller = _directController; + if (controller == null) return; + if (controller.value.isPlaying) { + controller.pause(); + } else { + if (controller.value.position >= controller.value.duration) { + controller.seekTo(Duration.zero); + } + controller.play(); + } + } + @override void dispose() { _directController?.dispose(); @@ -148,20 +160,39 @@ class _LearnVideoActivityState extends State { } if (_directController != null && _directController!.value.isInitialized) { - return Stack( - alignment: Alignment.center, - children: [ - VideoPlayer(_directController!), - if (!_directController!.value.isPlaying) - IconButton( - iconSize: 56, - color: Colors.white, - onPressed: () => setState(() { - _directController!.play(); - }), - icon: const Icon(Icons.play_circle_fill), + final controller = _directController!; + return GestureDetector( + onTap: _toggleDirectPlayback, + child: Stack( + alignment: Alignment.center, + children: [ + VideoPlayer(controller), + // Only the small overlay rebuilds on controller ticks; a + // whole-activity setState listener would rebuild at frame rate. + ValueListenableBuilder( + valueListenable: controller, + builder: (context, value, _) { + if (value.isPlaying) return const SizedBox.shrink(); + return IconButton( + iconSize: 56, + color: Colors.white, + onPressed: _toggleDirectPlayback, + icon: const Icon(Icons.play_circle_fill), + ); + }, ), - ], + Positioned( + left: 0, + right: 0, + bottom: 0, + child: VideoProgressIndicator( + controller, + allowScrubbing: true, + padding: const EdgeInsets.only(top: 8), + ), + ), + ], + ), ); } @@ -175,7 +206,13 @@ class _LearnVideoActivityState extends State { return uri.pathSegments.isNotEmpty ? uri.pathSegments.first : null; } if (uri.host.contains('youtube.com')) { - return uri.queryParameters['v']; + final v = uri.queryParameters['v']; + if (v != null && v.isNotEmpty) return v; + final segments = uri.pathSegments; + if (segments.length >= 2 && + const {'shorts', 'embed', 'live', 'v'}.contains(segments.first)) { + return segments[1]; + } } return null; } diff --git a/src/mobile/lib/src/app/learn/widgets/kya_swiper.dart b/src/mobile/lib/src/app/learn/widgets/kya_swiper.dart deleted file mode 100644 index ddbbe0ce3e..0000000000 --- a/src/mobile/lib/src/app/learn/widgets/kya_swiper.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:flutter/material.dart'; - -class KyaSwiper extends StatelessWidget { - const KyaSwiper({super.key}); - - @override - Widget build(BuildContext context) { - return const Placeholder(); - } -} \ No newline at end of file diff --git a/src/mobile/lib/src/app/learn/widgets/learn_bottom_sheets.dart b/src/mobile/lib/src/app/learn/widgets/learn_bottom_sheets.dart index c12a3ce359..4a88e8e2db 100644 --- a/src/mobile/lib/src/app/learn/widgets/learn_bottom_sheets.dart +++ b/src/mobile/lib/src/app/learn/widgets/learn_bottom_sheets.dart @@ -239,6 +239,10 @@ class LearnBottomSheets { useRootNavigator: true, backgroundColor: Colors.transparent, useSafeArea: false, + // Lessons close via the explicit X button only — a stray swipe or + // scrim tap would silently discard the activity in progress. + isDismissible: false, + enableDrag: false, builder: (sheetContext) { final sheetHeight = MediaQuery.sizeOf(sheetContext).height * 0.92; return SizedBox( diff --git a/src/mobile/lib/src/app/learn/widgets/learn_lesson_activities.dart b/src/mobile/lib/src/app/learn/widgets/learn_lesson_activities.dart deleted file mode 100644 index 11230ca676..0000000000 --- a/src/mobile/lib/src/app/learn/widgets/learn_lesson_activities.dart +++ /dev/null @@ -1,211 +0,0 @@ -import 'package:airqo/src/app/learn/widgets/learn_lesson_image.dart'; -import 'package:airqo/src/app/learn/widgets/learn_sheet_button_styles.dart'; -import 'package:airqo/src/app/learn/theme/learn_design_tokens.dart'; -import 'package:airqo/src/app/shared/widgets/translated_text.dart'; -import 'package:flutter/material.dart'; - -class LearnStepDualButtons extends StatelessWidget { - final String primaryLabel; - final VoidCallback? onPrimary; - final String? secondaryLabel; - final VoidCallback? onSecondary; - final bool primaryEnabled; - - const LearnStepDualButtons({ - super.key, - this.primaryLabel = 'Continue', - this.onPrimary, - this.secondaryLabel, - this.onSecondary, - this.primaryEnabled = true, - }); - - @override - Widget build(BuildContext context) { - return Column( - children: [ - ElevatedButton( - onPressed: primaryEnabled ? onPrimary : null, - style: learnExposurePrimaryButtonStyle(enabled: primaryEnabled), - child: TranslatedText(primaryLabel), - ), - if (secondaryLabel != null) ...[ - const SizedBox(height: 8), - OutlinedButton( - onPressed: onSecondary, - style: learnExposureSecondaryButtonStyle(context), - child: TranslatedText(secondaryLabel!), - ), - ], - ], - ); - } -} - -class LearnNotesActivityCard extends StatefulWidget { - final String title; - final String body; - final VoidCallback onContinue; - - const LearnNotesActivityCard({ - super.key, - required this.title, - required this.body, - required this.onContinue, - }); - - @override - State createState() => _LearnNotesActivityCardState(); -} - -class _LearnNotesActivityCardState extends State { - final _controller = ScrollController(); - bool _canContinue = false; - - @override - void initState() { - super.initState(); - _controller.addListener(_checkScroll); - WidgetsBinding.instance.addPostFrameCallback((_) => _checkScroll()); - } - - void _checkScroll() { - if (!_controller.hasClients) return; - final atEnd = _controller.position.pixels >= - _controller.position.maxScrollExtent - 24; - if (atEnd != _canContinue) setState(() => _canContinue = atEnd); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return _LearnStepShell( - child: Column( - children: [ - Expanded( - child: SingleChildScrollView( - controller: _controller, - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TranslatedText( - widget.title, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: LearnDesignTokens.headline(context), - ), - ), - const SizedBox(height: 8), - TranslatedText( - widget.body, - style: TextStyle( - fontSize: 14, - height: 1.5, - color: LearnDesignTokens.muted(context), - ), - ), - const SizedBox(height: 80), - ], - ), - ), - ), - Padding( - padding: const EdgeInsets.all(16), - child: LearnStepDualButtons( - primaryEnabled: _canContinue, - onPrimary: widget.onContinue, - ), - ), - ], - ), - ); - } -} - -class LearnImageActivityCard extends StatelessWidget { - final String title; - final String body; - final String imageUrl; - final VoidCallback onContinue; - - const LearnImageActivityCard({ - super.key, - required this.title, - required this.body, - required this.imageUrl, - required this.onContinue, - }); - - @override - Widget build(BuildContext context) { - return _LearnStepShell( - child: Column( - children: [ - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: LearnLessonImage(url: imageUrl, height: 180), - ), - const SizedBox(height: 12), - TranslatedText( - title, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: LearnDesignTokens.headline(context), - ), - ), - const SizedBox(height: 8), - TranslatedText( - body, - style: TextStyle( - fontSize: 14, - height: 1.5, - color: LearnDesignTokens.muted(context), - ), - ), - ], - ), - ), - ), - Padding( - padding: const EdgeInsets.all(16), - child: LearnStepDualButtons(onPrimary: onContinue), - ), - ], - ), - ); - } -} - -class _LearnStepShell extends StatelessWidget { - final Widget child; - - const _LearnStepShell({required this.child}); - - @override - Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.fromLTRB(16, 0, 16, 16), - decoration: BoxDecoration( - color: LearnDesignTokens.cardBg(context), - borderRadius: BorderRadius.circular(LearnDesignTokens.activityCardRadius), - border: Border.all(color: LearnDesignTokens.divider(context)), - ), - clipBehavior: Clip.antiAlias, - child: child, - ); - } -} diff --git a/src/mobile/lib/src/app/shared/utils/device_id_manager.dart b/src/mobile/lib/src/app/shared/utils/device_id_manager.dart index 84234e3a97..775ef37f3d 100644 --- a/src/mobile/lib/src/app/shared/utils/device_id_manager.dart +++ b/src/mobile/lib/src/app/shared/utils/device_id_manager.dart @@ -16,7 +16,9 @@ class DeviceIdManager { } return deviceId; } catch (_) { - return 'temp-${DateTime.now().millisecondsSinceEpoch}'; + // The Learn API requires a UUIDv4 device_id, so even the + // non-persistent fallback must be a valid UUID. + return _uuid.v4(); } } diff --git a/src/mobile/lib/src/meta/utils/api_utils.dart b/src/mobile/lib/src/meta/utils/api_utils.dart index cef8d8ae24..3ec478bc47 100644 --- a/src/mobile/lib/src/meta/utils/api_utils.dart +++ b/src/mobile/lib/src/meta/utils/api_utils.dart @@ -18,6 +18,8 @@ class ApiUtils { static String learnProgress = "/api/v2/devices/learn/progress/lessons"; + static String learnProgressFetch = "/api/v2/devices/learn/progress"; + static String learnProgressSync = "/api/v2/devices/learn/progress/sync"; static String learnProgressLink = "/api/v2/devices/learn/progress/link"; diff --git a/src/mobile/test/app/learn/bloc/kya_bloc_test.dart b/src/mobile/test/app/learn/bloc/kya_bloc_test.dart index eb7e8c7357..4172468286 100644 --- a/src/mobile/test/app/learn/bloc/kya_bloc_test.dart +++ b/src/mobile/test/app/learn/bloc/kya_bloc_test.dart @@ -18,6 +18,7 @@ void main() { setUp(() { mockRepository = MockLearnRepository(); + when(mockRepository.isOffline).thenReturn(false); kyaBloc = KyaBloc(mockRepository); }); diff --git a/src/mobile/test/app/learn/models/learn_course_structure_test.dart b/src/mobile/test/app/learn/models/learn_course_structure_test.dart new file mode 100644 index 0000000000..26e17780e9 --- /dev/null +++ b/src/mobile/test/app/learn/models/learn_course_structure_test.dart @@ -0,0 +1,77 @@ +import 'package:airqo/src/app/learn/models/learn_course_structure.dart'; +import 'package:airqo/src/app/learn/models/learn_v2_catalog.dart'; +import 'package:airqo/src/app/learn/services/learn_progress_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + tearDown(LearnCatalog.resetMeta); + + LearnV2CatalogResponse catalogWith({ + List stages = const [], + int maxPoints = 0, + }) { + return LearnV2CatalogResponse( + success: true, + catalogVersion: '2026-06-01', + stages: stages, + maxPoints: maxPoints, + courses: const [], + ); + } + + group('LearnCatalogMeta.fromCatalog', () { + test('sorts server stages by index and drops empty names', () { + final meta = LearnCatalogMeta.fromCatalog(catalogWith( + stages: const [ + LearnV2Stage(index: 2, name: 'Observer'), + LearnV2Stage(index: 0, name: 'Curious'), + LearnV2Stage(index: 1, name: ''), + ], + maxPoints: 2400, + )); + + expect(meta.stages.map((s) => s.name), ['Curious', 'Observer']); + expect(meta.maxPoints, 2400); + }); + + test('falls back to the built-in ladder when the server sends none', () { + final meta = LearnCatalogMeta.fromCatalog(catalogWith()); + + expect(meta.stages, LearnCatalogMeta.fallback.stages); + expect(meta.stages.first.name, 'Curious'); + expect(meta.maxPoints, isNull, reason: 'max_points 0 means unknown'); + }); + }); + + group('LearnCatalog meta swap', () { + test('applyCatalogMeta swaps the snapshot and resetMeta restores it', () { + LearnCatalog.applyCatalogMeta(catalogWith( + stages: const [LearnV2Stage(index: 0, name: 'Novice')], + maxPoints: 100, + )); + + expect(LearnCatalog.stages.single.name, 'Novice'); + expect(LearnCatalog.meta.maxPoints, 100); + + LearnCatalog.resetMeta(); + expect(LearnCatalog.stages, LearnCatalogMeta.fallback.stages); + }); + + test('maxPoints prefers the server value over local computation', + () async { + SharedPreferences.setMockInitialValues({}); + final progress = LearnProgressService.withPrefs( + await SharedPreferences.getInstance(), + ); + + // No server value: falls back to local computation (0 for no courses). + expect(LearnCatalog.maxPoints(const [], progress), 0); + + LearnCatalog.applyCatalogMeta(catalogWith(maxPoints: 2400)); + expect(LearnCatalog.maxPoints(const [], progress), 2400); + }); + }); +} diff --git a/src/mobile/test/app/learn/models/learn_progress_models_test.dart b/src/mobile/test/app/learn/models/learn_progress_models_test.dart new file mode 100644 index 0000000000..a8b5c4daaf --- /dev/null +++ b/src/mobile/test/app/learn/models/learn_progress_models_test.dart @@ -0,0 +1,44 @@ +import 'package:airqo/src/app/learn/models/learn_progress_models.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('LearnServerProgress.fromJson', () { + test('accepts numeric fields sent as doubles', () { + final progress = LearnServerProgress.fromJson({ + 'success': true, + 'total_points': 210.0, + 'lessons': { + 'lesson-1': { + 'completed': true, + 'furthest_activity_index': 3.0, + 'stars': 3.0, + 'points_earned': 30.0, + 'quiz_score_ratio': 1, + }, + }, + }); + + expect(progress.totalPoints, 210); + final lesson = progress.lessons['lesson-1']!; + expect(lesson.furthestActivityIndex, 3); + expect(lesson.stars, 3); + expect(lesson.pointsEarned, 30); + expect(lesson.quizScoreRatio, 1.0); + }); + + test('defaults missing or invalid numeric fields', () { + final progress = LearnServerProgress.fromJson({ + 'total_points': 'many', + 'lessons': { + 'lesson-1': {'stars': null, 'points_earned': 'x'}, + }, + }); + + expect(progress.totalPoints, 0); + final lesson = progress.lessons['lesson-1']!; + expect(lesson.furthestActivityIndex, isNull); + expect(lesson.stars, 0); + expect(lesson.pointsEarned, 0); + }); + }); +} diff --git a/src/mobile/test/app/learn/models/learn_v2_catalog_test.dart b/src/mobile/test/app/learn/models/learn_v2_catalog_test.dart new file mode 100644 index 0000000000..e9001dda32 --- /dev/null +++ b/src/mobile/test/app/learn/models/learn_v2_catalog_test.dart @@ -0,0 +1,61 @@ +import 'dart:convert'; + +import 'package:airqo/src/app/learn/models/learn_v2_catalog.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('LearnV2CatalogResponse.fromJson', () { + test('accepts numeric fields sent as doubles', () { + final response = learnV2CatalogResponseFromJson(json.encode({ + 'success': true, + 'catalog_version': '2026-06-01', + 'max_points': 2400.0, + 'stages': [ + {'index': 1.0, 'name': 'Aware'}, + ], + 'courses': [ + { + '_id': 'c1', + 'course_number': 2.0, + 'title': 'Course', + 'units': [ + { + '_id': 'u1', + 'title': 'Unit', + 'lessons': [ + { + '_id': 'l1', + 'title': 'Lesson', + 'activities': [ + {'_id': 'a1', 'type': 'article', 'order': 3.0}, + ], + }, + ], + }, + ], + }, + ], + })); + + expect(response.maxPoints, 2400); + expect(response.stages.single.index, 1); + expect(response.courses.single.courseNumber, 2); + expect( + response.courses.single.units.single.lessons.single.activities.single + .order, + 3, + ); + }); + + test('defaults missing or non-numeric fields to zero', () { + final response = learnV2CatalogResponseFromJson(json.encode({ + 'success': true, + 'catalog_version': 'v', + 'max_points': 'lots', + 'courses': [], + })); + + expect(response.maxPoints, 0); + }); + }); +} diff --git a/src/mobile/test/app/learn/services/learn_api_client_test.dart b/src/mobile/test/app/learn/services/learn_api_client_test.dart new file mode 100644 index 0000000000..4656d632ad --- /dev/null +++ b/src/mobile/test/app/learn/services/learn_api_client_test.dart @@ -0,0 +1,223 @@ +import 'dart:convert'; + +import 'package:airqo/src/app/learn/services/learn_api_client.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +void main() { + group('LearnApiClient', () { + const baseUrl = 'https://api.test'; + const identity = LearnCallerIdentity(deviceId: 'device-123'); + + LearnApiClient clientReturning( + http.Response response, { + List? requests, + }) { + return LearnApiClient( + baseUrl: baseUrl, + client: MockClient((request) async { + requests?.add(request); + return response; + }), + ); + } + + group('createAnonymousSession', () { + test('posts device payload and returns guest_id', () async { + final requests = []; + final client = clientReturning( + http.Response(json.encode({'success': true, 'guest_id': 'g_1'}), 201), + requests: requests, + ); + + final guestId = await client.createAnonymousSession( + identity: identity, + platform: 'android', + appVersion: '1.2.3', + ); + + expect(guestId, 'g_1'); + final request = requests.single; + expect(request.url.path, '/api/v2/devices/learn/sessions/anonymous'); + expect(request.headers['X-Device-Id'], 'device-123'); + final body = json.decode(request.body) as Map; + expect(body['device_id'], 'device-123'); + expect(body['platform'], 'android'); + expect(body['app_version'], '1.2.3'); + }); + + test('falls back to session_id and returns null on failure', () async { + final fallback = clientReturning( + http.Response(json.encode({'session_id': 's_9'}), 200), + ); + expect( + await fallback.createAnonymousSession( + identity: identity, + platform: 'ios', + appVersion: '1.0.0', + ), + 's_9', + ); + + final failing = clientReturning(http.Response('oops', 500)); + expect( + await failing.createAnonymousSession( + identity: identity, + platform: 'ios', + appVersion: '1.0.0', + ), + isNull, + ); + }); + }); + + group('headers', () { + test('include guest id and JWT when the identity carries them', + () async { + final requests = []; + final client = clientReturning( + http.Response(json.encode({'success': true}), 200), + requests: requests, + ); + + await client.putLessonProgress( + identity: const LearnCallerIdentity( + deviceId: 'device-123', + guestId: 'g_1', + authToken: 'tok', + ), + lessonId: 'lesson-1', + body: const {'completed': true}, + ); + + final headers = requests.single.headers; + expect(headers['X-Device-Id'], 'device-123'); + expect(headers['X-Guest-Id'], 'g_1'); + expect(headers['Authorization'], 'JWT tok'); + }); + + test('omit guest id and JWT for a bare device identity', () async { + final requests = []; + final client = clientReturning( + http.Response(json.encode({'success': true}), 200), + requests: requests, + ); + + await client.getProgress(identity: identity); + + final headers = requests.single.headers; + expect(headers.containsKey('X-Guest-Id'), isFalse); + expect(headers.containsKey('Authorization'), isFalse); + }); + }); + + group('putLessonProgress', () { + test('returns response body on 2xx and null otherwise', () async { + final requests = []; + final ok = clientReturning( + http.Response(json.encode({'stars': 3}), 200), + requests: requests, + ); + final body = await ok.putLessonProgress( + identity: identity, + lessonId: 'lesson/1', + body: const {'completed': true}, + ); + expect(json.decode(body!), {'stars': 3}); + // Lesson id is URI-encoded into the path. + expect(requests.single.url.path, + '/api/v2/devices/learn/progress/lessons/lesson%2F1'); + + final failing = clientReturning(http.Response('nope', 404)); + expect( + await failing.putLessonProgress( + identity: identity, + lessonId: 'lesson-1', + body: const {}, + ), + isNull, + ); + }); + }); + + group('getProgress', () { + test('parses lessons keyed by lesson id', () async { + final client = clientReturning(http.Response( + json.encode({ + 'success': true, + 'total_points': 40, + 'lessons': { + 'lesson-1': { + 'completed': true, + 'furthest_activity_index': 3, + 'stars': 3, + 'points_earned': 30, + 'quiz_score_ratio': 1, + 'free_text_response': 'because air matters', + }, + }, + }), + 200, + )); + + final progress = await client.getProgress(identity: identity); + + expect(progress!.totalPoints, 40); + final lesson = progress.lessons['lesson-1']!; + expect(lesson.completed, isTrue); + expect(lesson.furthestActivityIndex, 3); + expect(lesson.stars, 3); + expect(lesson.pointsEarned, 30); + expect(lesson.quizScoreRatio, 1.0); + expect(lesson.freeTextResponse, 'because air matters'); + }); + + test('returns null on non-2xx', () async { + final client = clientReturning(http.Response('bad', 400)); + expect(await client.getProgress(identity: identity), isNull); + }); + }); + + group('linkGuestProgress / postSyncUpdates', () { + test('report success from the status code', () async { + final requests = []; + final ok = clientReturning( + http.Response(json.encode({'success': true}), 200), + requests: requests, + ); + + expect( + await ok.linkGuestProgress( + identity: const LearnCallerIdentity( + deviceId: 'device-123', authToken: 'tok'), + guestId: 'g_1', + ), + isTrue, + ); + final linkBody = json.decode(requests.single.body); + expect(linkBody, {'device_id': 'device-123', 'guest_id': 'g_1'}); + + expect( + await ok.postSyncUpdates( + identity: identity, + updates: const [ + {'lesson_id': 'lesson-1', 'completed': true}, + ], + ), + isTrue, + ); + + final failing = clientReturning(http.Response('bad', 400)); + expect( + await failing.linkGuestProgress(identity: identity, guestId: 'g_1'), + isFalse, + ); + expect( + await failing.postSyncUpdates(identity: identity, updates: const []), + isFalse, + ); + }); + }); + }); +} diff --git a/src/mobile/test/app/learn/services/learn_lesson_completion_service_test.dart b/src/mobile/test/app/learn/services/learn_lesson_completion_service_test.dart new file mode 100644 index 0000000000..e46305a04d --- /dev/null +++ b/src/mobile/test/app/learn/services/learn_lesson_completion_service_test.dart @@ -0,0 +1,119 @@ +import 'package:airqo/src/app/learn/services/learn_lesson_completion_service.dart'; +import 'package:airqo/src/app/learn/services/learn_progress_service.dart'; +import 'package:airqo/src/app/learn/services/learn_sync_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _RecordingSyncService implements LearnSyncService { + String? reportedLessonId; + int? reportedFurthestIndex; + List? reportedAttempts; + String? reportedFreeText; + + @override + Future reportCompletion( + String lessonId, { + required int furthestActivityIndex, + List quizAttempts = const [], + String? freeTextResponse, + }) async { + reportedLessonId = lessonId; + reportedFurthestIndex = furthestActivityIndex; + reportedAttempts = quizAttempts; + reportedFreeText = freeTextResponse; + } + + @override + Future ensureGuestSession() async {} + @override + Future linkProgressToAccount(String authToken) async {} + @override + Future syncPendingProgress() async {} + @override + Future fetchProgress({String? authToken}) async => + null; + @override + Future hydrateLocalProgress({String? authToken}) async {} +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late LearnProgressService progress; + late _RecordingSyncService sync; + late LearnLessonCompletionService service; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + progress = LearnProgressService.withPrefs( + await SharedPreferences.getInstance(), + ); + sync = _RecordingSyncService(); + service = LearnLessonCompletionService(progress: progress, sync: sync); + }); + + const attempts = [ + QuizAttemptData( + activityId: 'a1', format: 'single_choice', isCorrect: true), + QuizAttemptData( + activityId: 'a2', format: 'multi_choice', isCorrect: false), + // Free text is excluded from grading even though marked correct. + QuizAttemptData(activityId: 'a3', format: 'free_text', isCorrect: true), + ]; + + test('scores graded attempts only and persists the result', () async { + await progress.saveSessionQuizAttempts('l1', [ + {'activity_id': 'a1'}, + ]); + + final result = await service.completeLesson( + lessonKey: 'l1', + quizAttempts: attempts, + furthestActivityIndex: 5, + combinedFreeText: 'my thought', + ); + + // 1 of 2 graded correct: 10 points, ratio 0.5, 2 stars. + expect(result.pointsEarned, 10); + expect(result.quizScoreRatio, 0.5); + expect(result.stars, 2); + expect(result.freeTextResponse, 'my thought'); + + expect(progress.isLessonComplete('l1'), isTrue); + expect(progress.lessonPoints('l1'), 10); + expect(progress.lessonStars('l1'), 2); + expect(progress.lessonQuizScore('l1'), 0.5); + expect(progress.lessonFreeText('l1'), 'my thought'); + // In-lesson session state is cleared on completion. + expect(progress.sessionQuizAttempts('l1'), isEmpty); + }); + + test('reports completion to the sync service', () async { + await service.completeLesson( + lessonKey: 'l1', + quizAttempts: attempts, + furthestActivityIndex: 5, + combinedFreeText: 'my thought', + ); + + expect(sync.reportedLessonId, 'l1'); + expect(sync.reportedFurthestIndex, 5); + // All attempts go to the server, including the free-text one — the + // backend excludes free text from scoring itself. + expect(sync.reportedAttempts, hasLength(3)); + expect(sync.reportedFreeText, 'my thought'); + }); + + test('a lesson without graded quizzes earns one star and no points', + () async { + final result = await service.completeLesson( + lessonKey: 'l2', + quizAttempts: const [], + furthestActivityIndex: 2, + ); + + expect(result.stars, 1); + expect(result.pointsEarned, 0); + expect(result.quizScoreRatio, 1.0); + }); +} diff --git a/src/mobile/test/app/learn/services/learn_progress_service_test.dart b/src/mobile/test/app/learn/services/learn_progress_service_test.dart new file mode 100644 index 0000000000..a74c8003a5 --- /dev/null +++ b/src/mobile/test/app/learn/services/learn_progress_service_test.dart @@ -0,0 +1,114 @@ +import 'package:airqo/src/app/learn/services/learn_progress_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late LearnProgressService progress; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + progress = LearnProgressService.withPrefs( + await SharedPreferences.getInstance(), + ); + }); + + group('saveLessonResult', () { + test('keeps the best result across replays', () async { + await progress.saveLessonResult( + lessonKey: 'l1', + stars: 3, + points: 30, + quizScoreRatio: 1.0, + ); + await progress.saveLessonResult( + lessonKey: 'l1', + stars: 1, + points: 10, + quizScoreRatio: 0.3, + freeText: 'newer thought', + ); + + expect(progress.lessonStars('l1'), 3); + expect(progress.lessonPoints('l1'), 30); + expect(progress.lessonQuizScore('l1'), 1.0); + // Free text is not scored — the latest submission always wins. + expect(progress.lessonFreeText('l1'), 'newer thought'); + }); + + test('a better replay overwrites the stored result', () async { + await progress.saveLessonResult( + lessonKey: 'l1', + stars: 1, + points: 10, + quizScoreRatio: 0.3, + ); + await progress.saveLessonResult( + lessonKey: 'l1', + stars: 3, + points: 30, + quizScoreRatio: 1.0, + ); + + expect(progress.lessonStars('l1'), 3); + expect(progress.lessonPoints('l1'), 30); + expect(progress.lessonQuizScore('l1'), 1.0); + }); + }); + + group('mergeServerLesson', () { + test('adopts the server result when it is better, including the ratio', + () async { + await progress.saveLessonResult( + lessonKey: 'l1', + stars: 1, + points: 10, + quizScoreRatio: 0.3, + ); + + final changed = await progress.mergeServerLesson( + lessonKey: 'l1', + completed: true, + furthestStep: 4, + stars: 3, + points: 30, + quizScoreRatio: 1.0, + freeTextResponse: 'from server', + ); + + expect(changed, isTrue); + expect(progress.isLessonComplete('l1'), isTrue); + expect(progress.furthestStep('l1'), 4); + expect(progress.lessonStars('l1'), 3); + expect(progress.lessonPoints('l1'), 30); + expect(progress.lessonQuizScore('l1'), 1.0); + expect(progress.lessonFreeText('l1'), 'from server'); + }); + + test('never downgrades a better local result', () async { + await progress.saveLessonResult( + lessonKey: 'l1', + stars: 3, + points: 30, + quizScoreRatio: 1.0, + freeText: 'local answer', + ); + + await progress.mergeServerLesson( + lessonKey: 'l1', + completed: true, + stars: 1, + points: 10, + quizScoreRatio: 0.3, + freeTextResponse: 'stale server answer', + ); + + expect(progress.lessonStars('l1'), 3); + expect(progress.lessonPoints('l1'), 30); + expect(progress.lessonQuizScore('l1'), 1.0); + // A locally present free text is never clobbered by the server copy. + expect(progress.lessonFreeText('l1'), 'local answer'); + }); + }); +} diff --git a/src/mobile/test/app/learn/services/learn_sync_service_test.dart b/src/mobile/test/app/learn/services/learn_sync_service_test.dart new file mode 100644 index 0000000000..421898f9e7 --- /dev/null +++ b/src/mobile/test/app/learn/services/learn_sync_service_test.dart @@ -0,0 +1,209 @@ +import 'dart:convert'; + +import 'package:airqo/src/app/learn/services/learn_api_client.dart'; +import 'package:airqo/src/app/learn/services/learn_progress_service.dart'; +import 'package:airqo/src/app/learn/services/learn_sync_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const baseUrl = 'https://api.test'; + + late SharedPreferences prefs; + late LearnProgressService progress; + late List requests; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + progress = LearnProgressService.withPrefs(prefs); + requests = []; + }); + + LearnSyncServiceImpl buildService({ + required http.Response Function(http.Request) handler, + String? authToken, + }) { + return LearnSyncServiceImpl( + api: LearnApiClient( + baseUrl: baseUrl, + client: MockClient((request) async { + requests.add(request); + return handler(request); + }), + ), + progress: progress, + authTokenProvider: () async => authToken, + deviceIdProvider: () async => 'device-123', + prefsProvider: () async => prefs, + appVersionProvider: () async => '1.0.0', + ); + } + + group('ensureGuestSession', () { + test('stores the guest id and skips the API once one exists', () async { + final service = buildService( + handler: (_) => http.Response( + json.encode({'success': true, 'guest_id': 'g_1'}), 201), + ); + + await service.ensureGuestSession(); + expect(prefs.getString('learn_guest_id'), 'g_1'); + expect(requests, hasLength(1)); + + await service.ensureGuestSession(); + expect(requests, hasLength(1), reason: 'no second session request'); + }); + + test('survives a failed session request', () async { + final service = buildService( + handler: (_) => http.Response('down', 500), + ); + + await service.ensureGuestSession(); + expect(prefs.getString('learn_guest_id'), isNull); + }); + }); + + group('reportCompletion', () { + test('sends JWT and guest identity with the full body', () async { + await prefs.setString('learn_guest_id', 'g_1'); + final service = buildService( + authToken: 'tok', + handler: (_) => + http.Response(json.encode({'success': true, 'stars': 3}), 200), + ); + + await service.reportCompletion( + 'lesson-1', + furthestActivityIndex: 4, + quizAttempts: const [ + QuizAttemptData( + activityId: 'a1', format: 'single_choice', isCorrect: true), + ], + freeTextResponse: ' my answer ', + ); + + final request = requests.single; + expect(request.method, 'PUT'); + expect(request.url.path, + '/api/v2/devices/learn/progress/lessons/lesson-1'); + expect(request.headers['Authorization'], 'JWT tok'); + expect(request.headers['X-Guest-Id'], 'g_1'); + expect(request.headers['X-Device-Id'], 'device-123'); + final body = json.decode(request.body) as Map; + expect(body['furthest_activity_index'], 4); + expect(body['completed'], isTrue); + expect(body['quiz_attempts'], hasLength(1)); + expect(body['free_text_response'], 'my answer'); + // Nothing buffered on success. + expect(prefs.getString('learn_pending_sync'), isNull); + }); + + test('buffers on failure and syncPendingProgress flushes the buffer', + () async { + var failPut = true; + final service = buildService( + handler: (request) { + if (request.method == 'PUT' && failPut) { + return http.Response('down', 500); + } + return http.Response(json.encode({'success': true}), 200); + }, + ); + + await service.reportCompletion( + 'lesson-1', + furthestActivityIndex: 4, + freeTextResponse: 'saved offline', + ); + + final buffered = + json.decode(prefs.getString('learn_pending_sync')!) as List; + expect(buffered, hasLength(1)); + expect(buffered.first['lesson_id'], 'lesson-1'); + expect(buffered.first['free_text_response'], 'saved offline'); + + failPut = false; + await service.syncPendingProgress(); + + final syncRequest = requests.last; + expect(syncRequest.url.path, '/api/v2/devices/learn/progress/sync'); + final syncBody = json.decode(syncRequest.body) as Map; + expect(syncBody['device_id'], 'device-123'); + expect(syncBody['updates'], hasLength(1)); + expect(prefs.getString('learn_pending_sync'), isNull, + reason: 'buffer cleared after a successful flush'); + }); + }); + + group('hydrateLocalProgress', () { + test('merges server lessons into the local store', () async { + final service = buildService( + handler: (_) => http.Response( + json.encode({ + 'success': true, + 'total_points': 30, + 'lessons': { + 'lesson-1': { + 'completed': true, + 'furthest_activity_index': 3, + 'stars': 3, + 'points_earned': 30, + 'quiz_score_ratio': 1, + 'free_text_response': 'server copy', + }, + }, + }), + 200, + ), + ); + + final before = progress.revision.value; + await service.hydrateLocalProgress(); + + expect(progress.isLessonComplete('lesson-1'), isTrue); + // Server index 3 → local step count 4. + expect(progress.furthestStep('lesson-1'), 4); + expect(progress.lessonStars('lesson-1'), 3); + expect(progress.lessonPoints('lesson-1'), 30); + expect(progress.lessonQuizScore('lesson-1'), 1.0); + expect(progress.lessonFreeText('lesson-1'), 'server copy'); + expect(progress.revision.value, greaterThan(before), + reason: 'listeners notified after a merge'); + }); + }); + + group('linkProgressToAccount', () { + test('links, flushes pending progress, and clears the guest id', + () async { + await prefs.setString('learn_guest_id', 'g_1'); + final service = buildService( + handler: (_) => http.Response(json.encode({'success': true}), 200), + ); + + await service.linkProgressToAccount('tok'); + + final linkRequest = requests.single; + expect(linkRequest.url.path, '/api/v2/devices/learn/progress/link'); + expect(linkRequest.headers['Authorization'], 'JWT tok'); + expect(json.decode(linkRequest.body), + {'device_id': 'device-123', 'guest_id': 'g_1'}); + expect(prefs.getString('learn_guest_id'), isNull); + }); + + test('keeps the guest id when linking fails', () async { + await prefs.setString('learn_guest_id', 'g_1'); + final service = buildService( + handler: (_) => http.Response('bad', 400), + ); + + await service.linkProgressToAccount('tok'); + expect(prefs.getString('learn_guest_id'), 'g_1'); + }); + }); +}