From a4d82cbbf494a458f5de959ded819f9bb6e74c55 Mon Sep 17 00:00:00 2001 From: Mozart299 Date: Wed, 1 Jul 2026 19:50:28 +0300 Subject: [PATCH 1/9] Fix Learn guest mode UX, survey auth guard, and dashboard country tab shimmer - Remove pilot demo seed from production builds; add one-time migration to clear previously seeded fake progress for all users - Stop auto-showing completion sheet when reopening an already-complete lesson; completed lessons now replay from step 0 - Guard getSurveyResponses API sync behind auth check to avoid noisy auth errors and wasted requests for guest users - Reduce country detection location timeout from 10s to 5s - Downgrade nearby-view early-return log from warning to info - Show shimmer placeholders in the country tab row while dashboard data is loading instead of rendering an empty row --- .../services/location_service_mananger.dart | 2 +- .../app/dashboard/widgets/nearby_view.dart | 2 +- .../app/dashboard/widgets/view_selector.dart | 37 ++++++++++++------- .../lib/src/app/learn/pages/kya_page.dart | 9 ++++- .../services/learn_progress_service.dart | 18 +++++++++ .../experience/learn_lesson_experience.dart | 24 +++--------- .../surveys/repository/survey_repository.dart | 7 ++++ 7 files changed, 64 insertions(+), 35 deletions(-) diff --git a/src/mobile/lib/src/app/dashboard/services/location_service_mananger.dart b/src/mobile/lib/src/app/dashboard/services/location_service_mananger.dart index 38ff8a9772..bc6d85e1ce 100644 --- a/src/mobile/lib/src/app/dashboard/services/location_service_mananger.dart +++ b/src/mobile/lib/src/app/dashboard/services/location_service_mananger.dart @@ -201,7 +201,7 @@ class LocationServiceManager with UiLoggy { final position = await Geolocator.getCurrentPosition( locationSettings: const LocationSettings( - timeLimit: Duration(seconds: 10), + timeLimit: Duration(seconds: 5), ), ); final placemarks = diff --git a/src/mobile/lib/src/app/dashboard/widgets/nearby_view.dart b/src/mobile/lib/src/app/dashboard/widgets/nearby_view.dart index 45016eca23..151d7b0aad 100644 --- a/src/mobile/lib/src/app/dashboard/widgets/nearby_view.dart +++ b/src/mobile/lib/src/app/dashboard/widgets/nearby_view.dart @@ -204,7 +204,7 @@ class _NearbyViewState extends State with UiLoggy { Future _updateNearbyLocations() async { if (_userPosition == null) { - loggy.warning('Cannot filter nearby locations: user position unavailable'); + loggy.info('Skipping nearby filter: position not yet available'); return; } diff --git a/src/mobile/lib/src/app/dashboard/widgets/view_selector.dart b/src/mobile/lib/src/app/dashboard/widgets/view_selector.dart index 4d0391f453..f8e78aa617 100644 --- a/src/mobile/lib/src/app/dashboard/widgets/view_selector.dart +++ b/src/mobile/lib/src/app/dashboard/widgets/view_selector.dart @@ -1,5 +1,6 @@ import 'package:airqo/src/app/dashboard/models/country_model.dart'; import 'package:airqo/src/app/shared/widgets/country_button'; +import 'package:airqo/src/app/shared/widgets/loading_widget.dart'; import 'package:airqo/src/app/shared/widgets/translated_text.dart'; import 'package:airqo/src/app/shared/widgets/translated_tooltip.dart'; import 'package:flutter/material.dart'; @@ -169,20 +170,28 @@ class _ViewSelectorState extends State { ), ], SizedBox(width: 8), - ...sortedCountries.map((country) => Padding( - padding: const EdgeInsets.only(right: 8), - child: _buildCountryButton( - context, - flag: country.flag, - name: country.countryName, - isSelected: widget.currentView == DashboardView.country && - widget.selectedCountry == country.countryName, - onTap: () => widget.onViewChanged(DashboardView.country, - country: country.countryName), - isUserCountry: widget.userCountry?.toLowerCase() == - country.countryName.toLowerCase(), - ), - )), + if (widget.activeCountries == null) ...[ + const SizedBox(width: 8), + ShimmerContainer(height: 40, borderRadius: 30, width: 90), + const SizedBox(width: 8), + ShimmerContainer(height: 40, borderRadius: 30, width: 110), + const SizedBox(width: 8), + ShimmerContainer(height: 40, borderRadius: 30, width: 100), + ] else + ...sortedCountries.map((country) => Padding( + padding: const EdgeInsets.only(right: 8), + child: _buildCountryButton( + context, + flag: country.flag, + name: country.countryName, + isSelected: widget.currentView == DashboardView.country && + widget.selectedCountry == country.countryName, + onTap: () => widget.onViewChanged(DashboardView.country, + country: country.countryName), + isUserCountry: widget.userCountry?.toLowerCase() == + country.countryName.toLowerCase(), + ), + )), ], ), ); 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 6daef5c041..bdff01205c 100644 --- a/src/mobile/lib/src/app/learn/pages/kya_page.dart +++ b/src/mobile/lib/src/app/learn/pages/kya_page.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:airqo/src/app/dashboard/widgets/dashboard_app_bar.dart'; import 'package:airqo/src/app/learn/models/lesson_response_model.dart'; import 'package:airqo/src/app/learn/bloc/kya_bloc.dart'; @@ -72,7 +73,13 @@ class _KyaPageState extends State with UiLoggy { '${apiLessons.length}:${apiLessons.map((l) => l.id).join('|')}'; if (_lastSeedFingerprint == fingerprint) return; _lastSeedFingerprint = fingerprint; - _progress.ensurePilotLearnDemosV3(courses: courses); + + _progress.clearPilotSeedIfNeeded(); + _progress.syncLegacyApiProgress(courses); + + if (kDebugMode) { + _progress.ensurePilotLearnDemosV3(courses: courses); + } } @override 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 c9e943754e..a600e658b5 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 @@ -9,6 +9,7 @@ class LearnProgressService { static final LearnProgressService instance = LearnProgressService._(); static const _pilotSeedKey = 'learn_pilot_seeded_v3'; + static const _pilotCleanupDoneKey = 'learn_pilot_cleanup_v1'; static const _stepPrefix = 'learn_step_'; static const _completePrefix = 'learn_complete_'; static const _courseDemoKey = 'learn_course_demo_shown_'; @@ -109,6 +110,23 @@ class LearnProgressService { return courses.fold(0, (s, c) => s + c.totalLessons); } + /// One-time migration: removes any progress data that was seeded by the + /// pilot demo seeder, so real users start with a clean slate. + Future clearPilotSeedIfNeeded() async { + await ensureInitialized(); + if (_prefs!.getBool(_pilotCleanupDoneKey) == true) return; + if (_prefs!.getBool(_pilotSeedKey) == true) { + await _clearLearnProgressKeys(); + } + await _prefs!.setBool(_pilotCleanupDoneKey, true); + _notify(); + } + + /// Runs the legacy-API → catalog-id migration for all users. + void syncLegacyApiProgress(List courses) { + _syncLegacyApiProgressToCatalog(courses); + } + bool hasShownCourseDemo(String courseId) { return _prefs?.getBool('$_courseDemoKey$courseId') ?? false; } 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 1661e441fb..c7b69a11b3 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 @@ -55,7 +55,6 @@ class _LearnLessonExperienceState extends State { final List _gradedResults = []; String? _freeTextResponse; LearnLessonResult? _result; - bool _presentingCompletion = false; @override void initState() { @@ -67,20 +66,13 @@ class _LearnLessonExperienceState extends State { slot: widget.slot, apiLesson: widget.apiLesson, ); - final saved = _progress.furthestStep(widget.slot.progressKey); - _activityIndex = saved.clamp(0, _script.length - 1); + // Already-complete lessons start from step 0 so the user can replay. + // In-progress lessons resume from their furthest step. if (_progress.isLessonComplete(widget.slot.progressKey)) { - _result = LearnLessonResult( - stars: _progress.lessonStars(widget.slot.progressKey).clamp(1, 3), - pointsEarned: _progress.lessonPoints(widget.slot.progressKey), - quizScoreRatio: _progress.lessonQuizScore(widget.slot.progressKey), - freeTextResponse: _progress.lessonFreeText(widget.slot.progressKey), - ); - _presentingCompletion = true; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - _presentCompletionSheet(); - }); + _activityIndex = 0; + } else { + final saved = _progress.furthestStep(widget.slot.progressKey); + _activityIndex = saved.clamp(0, _script.length - 1); } } @@ -200,10 +192,6 @@ class _LearnLessonExperienceState extends State { @override Widget build(BuildContext context) { - if (_presentingCompletion) { - return const SizedBox.shrink(); - } - final lessonTitle = learnDisplayTitle(widget.apiLesson?.title ?? widget.slot.plainTitleKey); diff --git a/src/mobile/lib/src/app/surveys/repository/survey_repository.dart b/src/mobile/lib/src/app/surveys/repository/survey_repository.dart index bb167220f9..5064d475d4 100644 --- a/src/mobile/lib/src/app/surveys/repository/survey_repository.dart +++ b/src/mobile/lib/src/app/surveys/repository/survey_repository.dart @@ -177,6 +177,13 @@ class SurveyRepository extends BaseRepository with UiLoggy { : cachedResponses; try { + final userId = + await AuthHelper.getCurrentUserId(suppressGuestWarning: true); + if (userId == null) { + // Guest users have no server-side responses to sync. + return responses; + } + final queryParams = {}; if (surveyId != null) { queryParams['surveyId'] = surveyId; From 9c6dfc8f9d1138b2b736163277316b471fee7c5c Mon Sep 17 00:00:00 2001 From: Mozart299 Date: Wed, 1 Jul 2026 21:26:13 +0300 Subject: [PATCH 2/9] Migrate Learn feature from retired KYA API to Learn v2 catalog Replaces GET /api/v2/devices/kya/lessons with GET /api/v2/devices/learn/catalog. Adds LearnV2CatalogResponse model, LearnRepositoryImpl, and LearnSyncService for guest session management and offline progress buffering. Real lesson activities from the v2 API are now used in the experience widget when available, falling back to the demo script for any lessons without v2 content. Guest progress is reported fire-and- forget and buffered locally when offline for sync on reconnect. --- src/mobile/lib/main.dart | 14 +- .../lib/src/app/learn/bloc/kya_bloc.dart | 38 ++-- .../lib/src/app/learn/bloc/kya_event.dart | 6 +- .../lib/src/app/learn/bloc/kya_state.dart | 14 +- .../learn/models/learn_course_structure.dart | 51 ++++- .../models/learn_lesson_continuation.dart | 3 - .../app/learn/models/learn_v2_catalog.dart | 173 ++++++++++++++++ .../lib/src/app/learn/pages/kya_page.dart | 35 ++-- .../app/learn/repository/kya_repository.dart | 7 +- .../learn/repository/learn_repository.dart | 149 ++++++++++++++ .../learn_lesson_experience_service.dart | 111 ++++++++++ .../learn/services/learn_sync_service.dart | 189 ++++++++++++++++++ .../experience/learn_lesson_experience.dart | 58 +++++- src/mobile/lib/src/meta/utils/api_utils.dart | 10 +- 14 files changed, 785 insertions(+), 73 deletions(-) create mode 100644 src/mobile/lib/src/app/learn/models/learn_v2_catalog.dart create mode 100644 src/mobile/lib/src/app/learn/repository/learn_repository.dart create mode 100644 src/mobile/lib/src/app/learn/services/learn_sync_service.dart diff --git a/src/mobile/lib/main.dart b/src/mobile/lib/main.dart index 756bd36b96..a21e93d1b1 100644 --- a/src/mobile/lib/main.dart +++ b/src/mobile/lib/main.dart @@ -14,7 +14,8 @@ import 'package:airqo/src/app/dashboard/repository/dashboard_repository.dart'; import 'package:airqo/src/app/dashboard/repository/forecast_repository.dart'; import 'package:airqo/src/app/dashboard/repository/country_repository.dart'; import 'package:airqo/src/app/learn/bloc/kya_bloc.dart'; -import 'package:airqo/src/app/learn/repository/kya_repository.dart'; +import 'package:airqo/src/app/learn/repository/learn_repository.dart'; +import 'package:airqo/src/app/learn/services/learn_sync_service.dart'; import 'package:airqo/src/app/map/bloc/map_bloc.dart'; import 'package:airqo/src/app/map/repository/map_repository.dart'; import 'package:airqo/src/app/other/places/bloc/google_places_bloc.dart'; @@ -89,7 +90,7 @@ void main() async { authRepository: authImpl, socialAuthRepository: authImpl, userRepository: UserImpl(), - kyaRepository: KyaImpl(), + kyaRepository: LearnRepositoryImpl(), themeRepository: ThemeImpl(), mapRepository: MapImpl(), forecastRepository: ForecastImpl(), @@ -113,7 +114,7 @@ class AirqoMobile extends StatelessWidget { final UserRepository userRepository; final MapRepository mapRepository; final ThemeRepository themeRepository; - final KyaRepository kyaRepository; + final LearnRepository kyaRepository; final GooglePlacesRepository googlePlacesRepository; final DashboardRepository dashboardRepository; @@ -248,9 +249,16 @@ class _DeciderState extends State with WidgetsBindingObserver { WidgetsBinding.instance.addPostFrameCallback((_) { AutoUpdateService().initialize(navigatorKey); _fetchCountries(); + _initLearnGuestSession(); }); } + void _initLearnGuestSession() { + LearnSyncService.instance.ensureGuestSession().then((_) { + LearnSyncService.instance.syncPendingProgress(); + }).catchError((_) {}); + } + void _fetchCountries() async { try { await CountryRepository().fetchCountries(); diff --git a/src/mobile/lib/src/app/learn/bloc/kya_bloc.dart b/src/mobile/lib/src/app/learn/bloc/kya_bloc.dart index 68a13c470f..4c3f0347cf 100644 --- a/src/mobile/lib/src/app/learn/bloc/kya_bloc.dart +++ b/src/mobile/lib/src/app/learn/bloc/kya_bloc.dart @@ -1,5 +1,5 @@ -import 'package:airqo/src/app/learn/models/lesson_response_model.dart'; -import 'package:airqo/src/app/learn/repository/kya_repository.dart'; +import 'package:airqo/src/app/learn/models/learn_v2_catalog.dart'; +import 'package:airqo/src/app/learn/repository/learn_repository.dart'; import 'package:airqo/src/app/shared/services/cache_manager.dart'; import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; @@ -9,7 +9,7 @@ part 'kya_event.dart'; part 'kya_state.dart'; class KyaBloc extends Bloc with UiLoggy { - final KyaRepository repository; + final LearnRepository repository; final CacheManager _cacheManager = CacheManager(); KyaBloc(this.repository) : super(KyaInitial()) { @@ -17,20 +17,21 @@ class KyaBloc extends Bloc with UiLoggy { on(_onRefreshLessons); } - Future _onLoadLessons(LoadLessons event, Emitter emit) async { + Future _onLoadLessons( + LoadLessons event, Emitter emit) async { try { emit(LessonsLoading()); - final LessonResponseModel model = - await repository.fetchLessons(forceRefresh: event.forceRefresh); + final LearnV2CatalogResponse model = + await repository.fetchCatalog(forceRefresh: event.forceRefresh); emit(LessonsLoaded(model)); } catch (e) { - loggy.error('Error loading lessons: $e'); + loggy.error('Error loading Learn catalog: $e'); - // Try to get cached data directly from the repository try { - final LessonResponseModel? cachedModel = await _getCachedLessonsData(); + final LearnV2CatalogResponse? cachedModel = + await _getCachedCatalog(); emit(LessonsLoadingError( message: e.toString(), @@ -38,7 +39,7 @@ class KyaBloc extends Bloc with UiLoggy { isOffline: !_cacheManager.isConnected, )); } catch (cacheError) { - loggy.error('Error fetching cached lessons: $cacheError'); + loggy.error('Error fetching cached catalog: $cacheError'); emit(LessonsLoadingError( message: e.toString(), isOffline: !_cacheManager.isConnected, @@ -52,19 +53,18 @@ class KyaBloc extends Bloc with UiLoggy { emit(LessonsRefreshing(currentModel: event.currentModel)); try { - final LessonResponseModel model = - await repository.fetchLessons(forceRefresh: true); + final LearnV2CatalogResponse model = + await repository.fetchCatalog(forceRefresh: true); emit(LessonsLoaded(model)); } catch (e) { - loggy.error('Error refreshing lessons: $e'); + loggy.error('Error refreshing Learn catalog: $e'); if (event.currentModel != null) { - // Return to loaded state with existing data emit(LessonsLoaded(event.currentModel!)); } else { - // Try to get cached data - final LessonResponseModel? cachedModel = await _getCachedLessonsData(); + final LearnV2CatalogResponse? cachedModel = + await _getCachedCatalog(); emit(LessonsLoadingError( message: e.toString(), @@ -75,11 +75,11 @@ class KyaBloc extends Bloc with UiLoggy { } } - Future _getCachedLessonsData() async { + Future _getCachedCatalog() async { try { - return await (repository as KyaImpl).getCachedLessonsData(); + return await (repository as LearnRepositoryImpl).getCachedCatalog(); } catch (e) { - loggy.error('Error getting cached lessons data: $e'); + loggy.error('Error getting cached Learn catalog: $e'); return null; } } diff --git a/src/mobile/lib/src/app/learn/bloc/kya_event.dart b/src/mobile/lib/src/app/learn/bloc/kya_event.dart index 3d39c4409a..1b8f8477e5 100644 --- a/src/mobile/lib/src/app/learn/bloc/kya_event.dart +++ b/src/mobile/lib/src/app/learn/bloc/kya_event.dart @@ -18,10 +18,10 @@ class LoadLessons extends KyaEvent { } class RefreshLessons extends KyaEvent { - final LessonResponseModel? currentModel; - + final LearnV2CatalogResponse? currentModel; + const RefreshLessons({this.currentModel}); - + @override List get props => [currentModel]; } \ No newline at end of file diff --git a/src/mobile/lib/src/app/learn/bloc/kya_state.dart b/src/mobile/lib/src/app/learn/bloc/kya_state.dart index 71bb64c556..fa66e22525 100644 --- a/src/mobile/lib/src/app/learn/bloc/kya_state.dart +++ b/src/mobile/lib/src/app/learn/bloc/kya_state.dart @@ -12,27 +12,27 @@ class KyaInitial extends KyaState {} class LessonsLoading extends KyaState {} class LessonsRefreshing extends KyaState { - final LessonResponseModel? currentModel; - + final LearnV2CatalogResponse? currentModel; + const LessonsRefreshing({this.currentModel}); - + @override List get props => [currentModel]; } class LessonsLoaded extends KyaState { - final LessonResponseModel model; + final LearnV2CatalogResponse model; final bool fromCache; - + const LessonsLoaded(this.model, {this.fromCache = false}); - + @override List get props => [model, fromCache]; } class LessonsLoadingError extends KyaState { final String message; - final LessonResponseModel? cachedModel; + final LearnV2CatalogResponse? cachedModel; final bool isOffline; const LessonsLoadingError({ 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 4950fb3d97..0a1ba5bc85 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,4 +1,5 @@ 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/models/lesson_response_model.dart'; import 'package:airqo/src/app/learn/services/learn_lesson_experience_service.dart'; import 'package:airqo/src/app/learn/services/learn_progress_service.dart'; @@ -7,19 +8,24 @@ class LearnLessonSlot { final String catalogId; final String plainTitleKey; final KyaLesson? apiLesson; + final LearnV2Lesson? v2Lesson; const LearnLessonSlot({ required this.catalogId, required this.plainTitleKey, this.apiLesson, + this.v2Lesson, }); String get progressKey => catalogId; - bool get hasContent => apiLesson != null && apiLesson!.tasks.isNotEmpty; + bool get hasContent => v2Lesson != null + ? v2Lesson!.activities.isNotEmpty + : (apiLesson != null && apiLesson!.tasks.isNotEmpty); - /// Scripted demo flow activity count. - int get activityCount => LearnLessonExperienceService.demoActivityCount; + int get activityCount => v2Lesson != null + ? v2Lesson!.activities.length + : LearnLessonExperienceService.demoActivityCount; } class LearnUnitViewModel { @@ -53,6 +59,7 @@ class LearnCourseViewModel { final int courseNumber; final String title; final String plainTitleKey; + final String? coverImageUrl; final List units; const LearnCourseViewModel({ @@ -60,6 +67,7 @@ class LearnCourseViewModel { required this.courseNumber, required this.title, required this.plainTitleKey, + this.coverImageUrl, required this.units, }); @@ -272,6 +280,43 @@ class LearnCatalog { ]; } + static List buildFromV2Catalog( + List v2Courses) { + return v2Courses.map((course) { + final coverImage = course.coverImageUrl ?? _deriveCoverImage(course); + return LearnCourseViewModel( + id: course.id, + courseNumber: course.courseNumber, + title: course.title, + plainTitleKey: course.plainTitleKey, + coverImageUrl: coverImage, + units: course.units.map((unit) { + return LearnUnitViewModel( + id: unit.id, + title: unit.title, + plainTitleKey: unit.title, + lessons: unit.lessons + .map((lesson) => LearnLessonSlot( + catalogId: lesson.id, + plainTitleKey: lesson.title, + v2Lesson: lesson, + )) + .toList(), + ); + }).toList(), + ); + }).toList(); + } + + static String? _deriveCoverImage(LearnV2Course course) { + for (final unit in course.units) { + for (final lesson in unit.lessons) { + if (lesson.coverImageUrl != null) return lesson.coverImageUrl; + } + } + return null; + } + static bool isCourseUnlocked( List courses, int courseIndex, diff --git a/src/mobile/lib/src/app/learn/models/learn_lesson_continuation.dart b/src/mobile/lib/src/app/learn/models/learn_lesson_continuation.dart index 78462f3359..0e5b6c1c44 100644 --- a/src/mobile/lib/src/app/learn/models/learn_lesson_continuation.dart +++ b/src/mobile/lib/src/app/learn/models/learn_lesson_continuation.dart @@ -1,5 +1,4 @@ import 'package:airqo/src/app/learn/models/learn_course_structure.dart'; -import 'package:airqo/src/app/learn/models/lesson_response_model.dart'; /// Optional next-lesson CTA shown on the lesson finish pane. class LearnLessonContinuation { @@ -25,7 +24,5 @@ class LearnLessonContinuation { this.isNextUnit = false, }); - KyaLesson? get nextLesson => nextSlot.apiLesson; - String get nextProgressKey => nextSlot.progressKey; } 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 new file mode 100644 index 0000000000..3b73b63d39 --- /dev/null +++ b/src/mobile/lib/src/app/learn/models/learn_v2_catalog.dart @@ -0,0 +1,173 @@ +import 'dart:convert'; + +LearnV2CatalogResponse learnV2CatalogResponseFromJson(String str) => + LearnV2CatalogResponse.fromJson(json.decode(str)); + +String learnV2CatalogResponseToJson(LearnV2CatalogResponse data) => + json.encode(data.toJson()); + +class LearnV2CatalogResponse { + final bool success; + final String catalogVersion; + final List courses; + + const LearnV2CatalogResponse({ + required this.success, + required this.catalogVersion, + required this.courses, + }); + + factory LearnV2CatalogResponse.fromJson(Map json) => + LearnV2CatalogResponse( + success: json['success'] ?? false, + catalogVersion: json['catalog_version'] ?? '', + courses: json['courses'] != null + ? (json['courses'] as List) + .map((c) => LearnV2Course.fromJson(c as Map)) + .toList() + : [], + ); + + Map toJson() => { + 'success': success, + 'catalog_version': catalogVersion, + 'courses': courses.map((c) => c.toJson()).toList(), + }; +} + +class LearnV2Course { + final String id; + final int courseNumber; + final String title; + final String plainTitleKey; + final String? coverImageUrl; + final List units; + + const LearnV2Course({ + required this.id, + required this.courseNumber, + required this.title, + required this.plainTitleKey, + this.coverImageUrl, + required this.units, + }); + + factory LearnV2Course.fromJson(Map json) => LearnV2Course( + id: json['_id'] as String? ?? json['id'] as String? ?? '', + courseNumber: json['course_number'] as int? ?? 0, + title: json['title'] as String? ?? '', + plainTitleKey: + json['plain_title_key'] as String? ?? json['title'] as String? ?? '', + coverImageUrl: json['cover_image_url'] as String?, + units: json['units'] != null + ? (json['units'] as List) + .map((u) => LearnV2Unit.fromJson(u as Map)) + .toList() + : [], + ); + + Map toJson() => { + '_id': id, + 'course_number': courseNumber, + 'title': title, + 'plain_title_key': plainTitleKey, + 'cover_image_url': coverImageUrl, + 'units': units.map((u) => u.toJson()).toList(), + }; +} + +class LearnV2Unit { + final String id; + final String title; + final List lessons; + + const LearnV2Unit({ + required this.id, + required this.title, + required this.lessons, + }); + + factory LearnV2Unit.fromJson(Map json) => LearnV2Unit( + id: json['_id'] as String? ?? json['id'] as String? ?? '', + title: json['title'] as String? ?? '', + lessons: json['lessons'] != null + ? (json['lessons'] as List) + .map((l) => LearnV2Lesson.fromJson(l as Map)) + .toList() + : [], + ); + + Map toJson() => { + '_id': id, + 'title': title, + 'lessons': lessons.map((l) => l.toJson()).toList(), + }; +} + +class LearnV2Lesson { + final String id; + final String title; + final String? coverImageUrl; + final String completionMessage; + final List activities; + + const LearnV2Lesson({ + required this.id, + required this.title, + this.coverImageUrl, + required this.completionMessage, + required this.activities, + }); + + factory LearnV2Lesson.fromJson(Map json) { + final rawActivities = json['activities'] as List? ?? []; + final activities = rawActivities + .map((a) => LearnV2Activity.fromJson(a as Map)) + .toList() + ..sort((a, b) => a.order.compareTo(b.order)); + return LearnV2Lesson( + id: json['_id'] as String? ?? json['id'] as String? ?? '', + title: json['title'] as String? ?? '', + coverImageUrl: json['cover_image_url'] as String?, + completionMessage: json['completion_message'] as String? ?? '', + activities: activities, + ); + } + + Map toJson() => { + '_id': id, + 'title': title, + 'cover_image_url': coverImageUrl, + 'completion_message': completionMessage, + 'activities': activities.map((a) => a.toJson()).toList(), + }; +} + +class LearnV2Activity { + final String id; + final String type; + final int order; + final Map payload; + + const LearnV2Activity({ + required this.id, + required this.type, + required this.order, + required this.payload, + }); + + factory LearnV2Activity.fromJson(Map json) => + LearnV2Activity( + id: json['_id'] as String? ?? json['id'] as String? ?? '', + type: json['type'] as String? ?? '', + order: json['order'] as int? ?? 0, + payload: Map.from(json['payload'] as Map? ?? {}), + ); + + Map toJson() => { + '_id': id, + 'type': type, + 'order': order, + 'payload': payload, + }; +} 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 bdff01205c..3a101ccee6 100644 --- a/src/mobile/lib/src/app/learn/pages/kya_page.dart +++ b/src/mobile/lib/src/app/learn/pages/kya_page.dart @@ -1,6 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:airqo/src/app/dashboard/widgets/dashboard_app_bar.dart'; -import 'package:airqo/src/app/learn/models/lesson_response_model.dart'; +import 'package:airqo/src/app/learn/models/learn_v2_catalog.dart'; import 'package:airqo/src/app/learn/bloc/kya_bloc.dart'; import 'package:airqo/src/app/learn/models/learn_course_structure.dart'; import 'package:airqo/src/app/learn/pages/learn_surveys_page.dart'; @@ -65,17 +65,12 @@ class _KyaPageState extends State with UiLoggy { }); } - void _onLessonsReady( - List courses, - List apiLessons, - ) { - final fingerprint = - '${apiLessons.length}:${apiLessons.map((l) => l.id).join('|')}'; + void _onLessonsReady(List courses) { + final fingerprint = courses.map((c) => c.id).join('|'); if (_lastSeedFingerprint == fingerprint) return; _lastSeedFingerprint = fingerprint; _progress.clearPilotSeedIfNeeded(); - _progress.syncLegacyApiProgress(courses); if (kDebugMode) { _progress.ensurePilotLearnDemosV3(courses: courses); @@ -162,20 +157,23 @@ class _KyaPageState extends State with UiLoggy { ); } - final List apiLessons = switch (state) { - LessonsLoaded s => s.model.kyaLessons, - LessonsLoadingError s => s.cachedModel?.kyaLessons ?? const [], - _ => const [], + final LearnV2CatalogResponse? catalog = switch (state) { + LessonsLoaded s => s.model, + LessonsLoadingError s => s.cachedModel, + _ => null, }; - if (state is LessonsLoadingError && apiLessons.isEmpty) { + if (state is LessonsLoadingError && catalog == null) { return _buildErrorState(state); } - final courses = LearnCatalog.buildFromLessons(apiLessons); - if (state is LessonsLoaded || apiLessons.isNotEmpty) { + final courses = catalog != null + ? LearnCatalog.buildFromV2Catalog(catalog.courses) + : const []; + + if (state is LessonsLoaded || catalog != null) { WidgetsBinding.instance.addPostFrameCallback((_) { - _onLessonsReady(courses, apiLessons); + _onLessonsReady(courses); }); } @@ -225,10 +223,7 @@ class _KyaPageState extends State with UiLoggy { return LearnCoursePortraitCard( course: course, locked: locked, - coverImageUrl: LearnCatalog.courseCoverImage( - course, - apiLessons, - ), + coverImageUrl: course.coverImageUrl, onTap: () => LearnBottomSheets.showCourseDetail( context, course: course, diff --git a/src/mobile/lib/src/app/learn/repository/kya_repository.dart b/src/mobile/lib/src/app/learn/repository/kya_repository.dart index b6baefaa1a..0dcfd03fd5 100644 --- a/src/mobile/lib/src/app/learn/repository/kya_repository.dart +++ b/src/mobile/lib/src/app/learn/repository/kya_repository.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:convert'; import 'package:airqo/src/app/learn/models/lesson_response_model.dart'; import 'package:airqo/src/app/shared/repository/base_repository.dart'; -import 'package:airqo/src/meta/utils/api_utils.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:http/http.dart'; import 'package:loggy/loggy.dart'; @@ -20,6 +19,8 @@ class KyaImpl extends KyaRepository with UiLoggy { final CacheManager _cacheManager = CacheManager(); static const String _lessonsCacheKey = 'kya_lessons'; + // Retired API endpoint kept here so kya_repository.dart compiles without _fetchLessonsPath. + static const String _fetchLessonsPath = '/api/v2/devices/kya/lessons'; @override Future fetchLessons({bool forceRefresh = false}) async { @@ -65,7 +66,7 @@ class KyaImpl extends KyaRepository with UiLoggy { } Response response = - await createGetRequest(ApiUtils.fetchLessons, {"token": token}) + await createGetRequest(_fetchLessonsPath, {"token": token}) .timeout( const Duration(seconds: 15), onTimeout: () { @@ -162,7 +163,7 @@ class KyaImpl extends KyaRepository with UiLoggy { } Response response = - await createGetRequest(ApiUtils.fetchLessons, {"token": token}) + await createGetRequest(_fetchLessonsPath, {"token": token}) .timeout(const Duration(seconds: 30)); if (response.statusCode == 200) { diff --git a/src/mobile/lib/src/app/learn/repository/learn_repository.dart b/src/mobile/lib/src/app/learn/repository/learn_repository.dart new file mode 100644 index 0000000000..1454a6fb65 --- /dev/null +++ b/src/mobile/lib/src/app/learn/repository/learn_repository.dart @@ -0,0 +1,149 @@ +import 'dart:convert'; +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_dotenv/flutter_dotenv.dart'; +import 'package:loggy/loggy.dart'; + +abstract class LearnRepository extends BaseRepository { + Future fetchCatalog({bool forceRefresh = false}); + Future clearCache(); +} + +class LearnRepositoryImpl extends LearnRepository with UiLoggy { + static final LearnRepositoryImpl _instance = LearnRepositoryImpl._internal(); + factory LearnRepositoryImpl() => _instance; + LearnRepositoryImpl._internal(); + + final CacheManager _cacheManager = CacheManager(); + static const String _catalogCacheKey = 'learn_v2_catalog'; + + @override + Future fetchCatalog( + {bool forceRefresh = false}) async { + loggy.info('Fetching Learn v2 catalog (forceRefresh: $forceRefresh)'); + + final cachedData = await _cacheManager.get( + boxName: CacheBoxName.location, + key: _catalogCacheKey, + fromJson: (json) => learnV2CatalogResponseFromJson(jsonEncode(json)), + ); + + final refreshPolicy = RefreshPolicy( + wifiInterval: const Duration(days: 1), + mobileInterval: const Duration(days: 3), + ); + + final shouldRefresh = _cacheManager.shouldRefresh( + boxName: CacheBoxName.location, + key: _catalogCacheKey, + policy: refreshPolicy, + cachedData: cachedData, + forceRefresh: forceRefresh, + ); + + if (cachedData != null && !shouldRefresh) { + loggy.info('Using cached Learn v2 catalog'); + if (_cacheManager.isConnected && !forceRefresh) { + _refreshInBackground(); + } + return cachedData.data; + } + + if (_cacheManager.isConnected) { + try { + loggy.info('Fetching fresh Learn v2 catalog from API'); + + final token = dotenv.env['AIRQO_API_TOKEN']; + final queryParams = + token != null ? {'token': token} : {}; + + final response = + await createGetRequest(ApiUtils.learnCatalog, queryParams) + .timeout(const Duration(seconds: 20)); + + if (response.statusCode == 200) { + final catalog = learnV2CatalogResponseFromJson(response.body); + await _cacheManager.put( + boxName: CacheBoxName.location, + key: _catalogCacheKey, + data: catalog, + toJson: (data) => jsonDecode(learnV2CatalogResponseToJson(data)), + etag: response.headers['etag'], + ); + loggy.info( + 'Learn v2 catalog cached (${catalog.courses.length} courses)'); + return catalog; + } else { + loggy.warning( + 'Learn catalog API returned ${response.statusCode}'); + if (cachedData != null) return cachedData.data; + throw Exception( + 'Failed to fetch Learn catalog: ${response.statusCode}'); + } + } catch (e) { + loggy.error('Error fetching Learn catalog: $e'); + if (cachedData != null) { + loggy.info('Using stale cached Learn catalog due to error'); + return cachedData.data; + } + rethrow; + } + } else { + if (cachedData != null) { + loggy.info('Using cached Learn catalog in offline mode'); + return cachedData.data; + } + throw Exception( + 'Unable to load Learn catalog. Please check your connection.'); + } + } + + Future getCachedCatalog() async { + try { + final cached = await _cacheManager.get( + boxName: CacheBoxName.location, + key: _catalogCacheKey, + fromJson: (json) => learnV2CatalogResponseFromJson(jsonEncode(json)), + ); + return cached?.data; + } catch (e) { + loggy.error('Error retrieving cached Learn catalog: $e'); + return null; + } + } + + Future _refreshInBackground() async { + try { + loggy.info('Background refresh of Learn v2 catalog'); + final token = dotenv.env['AIRQO_API_TOKEN']; + final queryParams = + token != null ? {'token': token} : {}; + final response = + await createGetRequest(ApiUtils.learnCatalog, queryParams) + .timeout(const Duration(seconds: 30)); + if (response.statusCode == 200) { + final catalog = learnV2CatalogResponseFromJson(response.body); + await _cacheManager.put( + boxName: CacheBoxName.location, + key: _catalogCacheKey, + data: catalog, + toJson: (data) => jsonDecode(learnV2CatalogResponseToJson(data)), + etag: response.headers['etag'], + ); + loggy.info('Background Learn catalog refresh done'); + } + } catch (e) { + loggy.error('Background Learn catalog refresh failed: $e'); + } + } + + @override + Future clearCache() async { + await _cacheManager.delete( + boxName: CacheBoxName.location, + key: _catalogCacheKey, + ); + } +} 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 9659247cfe..35067f1b2d 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 @@ -1,6 +1,7 @@ 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_v2_catalog.dart'; import 'package:airqo/src/app/learn/models/lesson_response_model.dart'; class LearnLessonExperienceService { @@ -130,6 +131,116 @@ class LearnLessonExperienceService { ]; } + static List buildFromV2Lesson({ + required LearnV2Lesson lesson, + }) { + final activities = []; + for (var i = 0; i < lesson.activities.length; i++) { + final v2 = lesson.activities[i]; + final activity = _v2ActivityToLearnActivity(v2, i); + if (activity != null) activities.add(activity); + } + return activities; + } + + static LearnLessonActivity? _v2ActivityToLearnActivity( + LearnV2Activity v2, int index) { + final p = v2.payload; + switch (v2.type) { + case 'article': + final body = p['body'] as String? ?? ''; + if (body.isEmpty) return null; + return LearnLessonActivity( + index: index, + type: LearnActivityType.article, + title: p['title'] as String? ?? 'Read', + article: LearnArticlePayload( + body: body, + audioText: p['audio_text'] as String?, + ), + ); + case 'video': + final url = p['url'] as String? ?? ''; + if (url.isEmpty) return null; + return LearnLessonActivity( + index: index, + type: LearnActivityType.video, + title: p['title'] as String? ?? 'Watch', + video: LearnVideoPayload( + videoUrl: url, + description: p['description'] as String? ?? '', + posterUrl: p['poster_url'] as String?, + ), + ); + case 'image': + final url = p['url'] as String? ?? ''; + if (url.isEmpty) return null; + return LearnLessonActivity( + index: index, + type: LearnActivityType.image, + title: p['title'] as String? ?? 'Look', + image: LearnImagePayload( + imageUrl: url, + caption: p['caption'] as String? ?? '', + subtitle: p['subtitle'] as String?, + ), + ); + case 'quiz': + final quiz = _parseV2Quiz(p); + if (quiz == null) return null; + return LearnLessonActivity( + index: index, + type: LearnActivityType.quiz, + title: p['title'] as String? ?? 'Quiz', + quiz: quiz, + ); + default: + return null; + } + } + + static LearnQuizPayload? _parseV2Quiz(Map p) { + final question = p['question'] as String? ?? ''; + if (question.isEmpty) return null; + + final format = _parseQuizFormat(p['format'] as String? ?? ''); + final rawOptions = p['options'] as List? ?? []; + final options = rawOptions.map((o) => o.toString()).toList(); + final correctIndex = p['correct_index'] as int?; + final rawIndices = p['correct_indices'] as List?; + final correctIndices = rawIndices?.map((i) => i as int).toSet(); + final rawOrder = p['correct_order'] as List?; + final correctOrder = rawOrder?.map((i) => i as int).toList(); + + return LearnQuizPayload( + format: format, + question: question, + options: options, + correctIndex: correctIndex, + correctIndices: correctIndices, + correctOrder: correctOrder, + correctFeedback: p['correct_feedback'] as String? ?? 'Correct!', + incorrectFeedback: p['incorrect_feedback'] as String? ?? + 'Not quite — try reviewing the lesson.', + hint: p['hint'] as String?, + ); + } + + static LearnQuizFormat _parseQuizFormat(String raw) { + switch (raw) { + case 'single_choice': + return LearnQuizFormat.singleChoice; + case 'multi_choice': + return LearnQuizFormat.multiChoice; + case 'ranking': + return LearnQuizFormat.ranking; + case 'free_text': + return LearnQuizFormat.freeText; + default: + return LearnQuizFormat.singleChoice; + } + } + static String activityTypeKey(LearnLessonActivity activity) { switch (activity.type) { case LearnActivityType.article: 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 new file mode 100644 index 0000000000..a48e6675f0 --- /dev/null +++ b/src/mobile/lib/src/app/learn/services/learn_sync_service.dart @@ -0,0 +1,189 @@ +import 'dart:convert'; +import 'package:airqo/src/app/shared/repository/base_repository.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: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, + }; +} + +class LearnSyncService extends BaseRepository with UiLoggy { + static final LearnSyncService instance = LearnSyncService._(); + LearnSyncService._(); + + static const _guestIdKey = 'learn_guest_id'; + static const _pendingSyncKey = 'learn_pending_sync'; + + SharedPreferences? _prefs; + + Future _ensurePrefs() async { + _prefs ??= await SharedPreferences.getInstance(); + } + + 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, + }; + } + + Future ensureGuestSession() async { + await _ensurePrefs(); + 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: $guestId'); + } + } + } catch (e) { + loggy.warning('Failed to create guest Learn session: $e'); + } + } + + Future reportCompletion( + String lessonId, { + required int totalActivities, + List quizAttempts = const [], + String? freeTextResponse, + }) async { + await _ensurePrefs(); + + final body = { + 'total_activities': totalActivities, + 'completed': true, + if (quizAttempts.isNotEmpty) + 'quiz_attempts': quizAttempts.map((q) => q.toJson()).toList(), + if (freeTextResponse != null && freeTextResponse.isNotEmpty) + 'free_text_response': freeTextResponse, + }; + + try { + final headers = await _guestHeaders(); + final response = await http.put( + Uri.parse('${ApiUtils.baseUrl}${ApiUtils.learnProgress}/$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'); + } else { + loggy.warning( + 'Progress report failed (${response.statusCode}), buffering'); + await _bufferPending(lessonId, body); + } + } catch (e) { + loggy.warning('Progress report error: $e — buffering'); + await _bufferPending(lessonId, body); + } + } + + Future syncPendingProgress() async { + await _ensurePrefs(); + final raw = _prefs!.getString(_pendingSyncKey); + if (raw == null) return; + + try { + final pending = json.decode(raw) as List; + if (pending.isEmpty) return; + + 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) { + await _prefs!.remove(_pendingSyncKey); + loggy.info('Synced ${pending.length} pending Learn progress entries'); + } + } catch (e) { + loggy.warning('Pending Learn progress sync failed: $e'); + } + } + + Future linkProgressToAccount(String authToken) async { + await _ensurePrefs(); + final guestId = _prefs!.getString(_guestIdKey); + 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) { + await _prefs!.remove(_guestIdKey); + loggy.info('Guest Learn progress linked to account'); + } + } catch (e) { + loggy.warning('Failed to link guest Learn progress: $e'); + } + } + + Future _bufferPending( + String lessonId, Map body) async { + try { + final raw = _prefs!.getString(_pendingSyncKey); + final pending = + raw != null ? json.decode(raw) as List : []; + pending.add({'lesson_id': lessonId, ...body}); + await _prefs!.setString(_pendingSyncKey, json.encode(pending)); + } catch (e) { + loggy.error('Failed to buffer pending Learn progress: $e'); + } + } +} 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 c7b69a11b3..8a45834aba 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 @@ -6,6 +6,7 @@ import 'package:airqo/src/app/learn/models/lesson_response_model.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'; import 'package:airqo/src/app/learn/widgets/experience/learn_image_activity.dart'; @@ -53,26 +54,34 @@ class _LearnLessonExperienceState extends State { late int _activityIndex; final _progress = LearnProgressService.instance; final List _gradedResults = []; + final List _quizAttempts = []; String? _freeTextResponse; LearnLessonResult? _result; @override void initState() { super.initState(); - final lessonTitle = widget.apiLesson?.title ?? widget.slot.plainTitleKey; - _script = LearnLessonExperienceService.buildDemoScript( - lessonTitle: lessonTitle, - unitTitle: widget.unitPlainTitle, - slot: widget.slot, - apiLesson: widget.apiLesson, - ); - // Already-complete lessons start from step 0 so the user can replay. + if (widget.slot.v2Lesson != null) { + _script = LearnLessonExperienceService.buildFromV2Lesson( + lesson: widget.slot.v2Lesson!, + ); + } else { + final lessonTitle = + widget.apiLesson?.title ?? widget.slot.plainTitleKey; + _script = LearnLessonExperienceService.buildDemoScript( + lessonTitle: lessonTitle, + unitTitle: widget.unitPlainTitle, + slot: widget.slot, + apiLesson: widget.apiLesson, + ); + } + // Already-complete lessons replay from step 0. // In-progress lessons resume from their furthest step. if (_progress.isLessonComplete(widget.slot.progressKey)) { _activityIndex = 0; } else { final saved = _progress.furthestStep(widget.slot.progressKey); - _activityIndex = saved.clamp(0, _script.length - 1); + _activityIndex = _script.isEmpty ? 0 : saved.clamp(0, _script.length - 1); } } @@ -109,6 +118,12 @@ class _LearnLessonExperienceState extends State { quizScoreRatio: result.quizScoreRatio, freeText: result.freeTextResponse, ); + LearnSyncService.instance.reportCompletion( + widget.slot.progressKey, + totalActivities: _script.length, + quizAttempts: List.unmodifiable(_quizAttempts), + freeTextResponse: _freeTextResponse, + ).catchError((_) {}); _result = result; _presentCompletionSheet(); } @@ -152,6 +167,24 @@ class _LearnLessonExperienceState extends State { if (_current.type != LearnActivityType.quiz) return; if (_current.quiz?.format == LearnQuizFormat.freeText) return; _gradedResults.add(grade.isCorrect); + _quizAttempts.add(QuizAttemptData( + activityId: _current.index.toString(), + format: _quizFormatKey(_current.quiz!.format), + isCorrect: grade.isCorrect, + )); + } + + String _quizFormatKey(LearnQuizFormat format) { + switch (format) { + case LearnQuizFormat.singleChoice: + return 'single_choice'; + case LearnQuizFormat.multiChoice: + return 'multi_choice'; + case LearnQuizFormat.ranking: + return 'ranking'; + case LearnQuizFormat.freeText: + return 'free_text'; + } } Widget _buildActivityBody() { @@ -192,8 +225,11 @@ class _LearnLessonExperienceState extends State { @override Widget build(BuildContext context) { - final lessonTitle = - learnDisplayTitle(widget.apiLesson?.title ?? widget.slot.plainTitleKey); + final lessonTitle = learnDisplayTitle( + widget.slot.v2Lesson?.title ?? + widget.apiLesson?.title ?? + widget.slot.plainTitleKey, + ); return SizedBox.expand( child: LearnExperienceShell( diff --git a/src/mobile/lib/src/meta/utils/api_utils.dart b/src/mobile/lib/src/meta/utils/api_utils.dart index 319167eb6d..cef8d8ae24 100644 --- a/src/mobile/lib/src/meta/utils/api_utils.dart +++ b/src/mobile/lib/src/meta/utils/api_utils.dart @@ -12,7 +12,15 @@ class ApiUtils { static String sitesSearch = "/api/v2/devices/sites/summary"; - static String fetchLessons = "/api/v2/devices/kya/lessons"; + static String learnCatalog = "/api/v2/devices/learn/catalog"; + + static String learnSessions = "/api/v2/devices/learn/sessions/anonymous"; + + static String learnProgress = "/api/v2/devices/learn/progress/lessons"; + + static String learnProgressSync = "/api/v2/devices/learn/progress/sync"; + + static String learnProgressLink = "/api/v2/devices/learn/progress/link"; static String fetchDailyForecasts = "/api/v2/predict/daily-forecasting"; From dea10cd683dd900e88c19f92ec9c8a031c97db10 Mon Sep 17 00:00:00 2001 From: Mozart299 Date: Wed, 1 Jul 2026 21:33:13 +0300 Subject: [PATCH 3/9] Fix SOLID violations in Learn v2 implementation - LSP/DIP: Add getCachedCatalog() to LearnRepository interface so KyaBloc no longer needs a downcast to LearnRepositoryImpl to access it. - DRP: Consolidate LearnQuizFormat <-> API string mapping onto the enum itself (apiKey getter + fromApiKey factory). Removes duplicate switch statements from LearnLessonExperienceService and LearnLessonExperience. - SRP: Extract _ProgressBuffer from LearnSyncService to separate offline buffering from session management and progress reporting. --- .../lib/src/app/learn/bloc/kya_bloc.dart | 2 +- .../learn/models/learn_lesson_activity.dart | 22 ++- .../learn/repository/learn_repository.dart | 2 + .../learn_lesson_experience_service.dart | 17 +-- .../learn/services/learn_sync_service.dart | 136 +++++++++++------- .../experience/learn_lesson_experience.dart | 15 +- 6 files changed, 109 insertions(+), 85 deletions(-) diff --git a/src/mobile/lib/src/app/learn/bloc/kya_bloc.dart b/src/mobile/lib/src/app/learn/bloc/kya_bloc.dart index 4c3f0347cf..27b47f9685 100644 --- a/src/mobile/lib/src/app/learn/bloc/kya_bloc.dart +++ b/src/mobile/lib/src/app/learn/bloc/kya_bloc.dart @@ -77,7 +77,7 @@ class KyaBloc extends Bloc with UiLoggy { Future _getCachedCatalog() async { try { - return await (repository as LearnRepositoryImpl).getCachedCatalog(); + return await repository.getCachedCatalog(); } catch (e) { loggy.error('Error getting cached Learn catalog: $e'); return null; 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 cdc7386aca..0cb4de617e 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 @@ -1,6 +1,26 @@ enum LearnActivityType { article, video, image, quiz } -enum LearnQuizFormat { singleChoice, multiChoice, ranking, freeText } +enum LearnQuizFormat { + singleChoice, + multiChoice, + ranking, + freeText; + + String get apiKey => switch (this) { + LearnQuizFormat.singleChoice => 'single_choice', + LearnQuizFormat.multiChoice => 'multi_choice', + LearnQuizFormat.ranking => 'ranking', + LearnQuizFormat.freeText => 'free_text', + }; + + static LearnQuizFormat fromApiKey(String raw) => switch (raw) { + 'single_choice' => LearnQuizFormat.singleChoice, + 'multi_choice' => LearnQuizFormat.multiChoice, + 'ranking' => LearnQuizFormat.ranking, + 'free_text' => LearnQuizFormat.freeText, + _ => LearnQuizFormat.singleChoice, + }; +} class LearnArticlePayload { final String body; 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 1454a6fb65..cfb2c37a94 100644 --- a/src/mobile/lib/src/app/learn/repository/learn_repository.dart +++ b/src/mobile/lib/src/app/learn/repository/learn_repository.dart @@ -8,6 +8,7 @@ import 'package:loggy/loggy.dart'; abstract class LearnRepository extends BaseRepository { Future fetchCatalog({bool forceRefresh = false}); + Future getCachedCatalog(); Future clearCache(); } @@ -100,6 +101,7 @@ class LearnRepositoryImpl extends LearnRepository with UiLoggy { } } + @override Future getCachedCatalog() async { try { final cached = await _cacheManager.get( 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 35067f1b2d..619e48a780 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 @@ -203,7 +203,7 @@ class LearnLessonExperienceService { final question = p['question'] as String? ?? ''; if (question.isEmpty) return null; - final format = _parseQuizFormat(p['format'] as String? ?? ''); + final format = LearnQuizFormat.fromApiKey(p['format'] as String? ?? ''); final rawOptions = p['options'] as List? ?? []; final options = rawOptions.map((o) => o.toString()).toList(); final correctIndex = p['correct_index'] as int?; @@ -226,21 +226,6 @@ class LearnLessonExperienceService { ); } - static LearnQuizFormat _parseQuizFormat(String raw) { - switch (raw) { - case 'single_choice': - return LearnQuizFormat.singleChoice; - case 'multi_choice': - return LearnQuizFormat.multiChoice; - case 'ranking': - return LearnQuizFormat.ranking; - case 'free_text': - return LearnQuizFormat.freeText; - default: - return LearnQuizFormat.singleChoice; - } - } - static String activityTypeKey(LearnLessonActivity activity) { switch (activity.type) { case LearnActivityType.article: 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 a48e6675f0..9cbdec6c47 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 @@ -27,17 +27,57 @@ class QuizAttemptData { }; } +// --------------------------------------------------------------------------- +// Private: offline progress buffer — append / drain / clear SharedPrefs list. +// --------------------------------------------------------------------------- + +class _ProgressBuffer with UiLoggy { + static const _key = 'learn_pending_sync'; + final SharedPreferences _prefs; + + _ProgressBuffer(this._prefs); + + Future append(String lessonId, Map body) async { + try { + final raw = _prefs.getString(_key); + final pending = raw != null ? json.decode(raw) as List : []; + pending.add({'lesson_id': lessonId, ...body}); + await _prefs.setString(_key, json.encode(pending)); + } catch (e) { + loggy.error('Failed to buffer pending Learn progress: $e'); + } + } + + List? drain() { + final raw = _prefs.getString(_key); + if (raw == null) return null; + try { + return json.decode(raw) as List; + } catch (_) { + return null; + } + } + + Future clear() => _prefs.remove(_key); +} + +// --------------------------------------------------------------------------- +// Public service — three distinct concerns composed here. +// --------------------------------------------------------------------------- + class LearnSyncService extends BaseRepository with UiLoggy { static final LearnSyncService instance = LearnSyncService._(); LearnSyncService._(); static const _guestIdKey = 'learn_guest_id'; - static const _pendingSyncKey = 'learn_pending_sync'; SharedPreferences? _prefs; + _ProgressBuffer? _buffer; Future _ensurePrefs() async { - _prefs ??= await SharedPreferences.getInstance(); + if (_prefs != null) return; + _prefs = await SharedPreferences.getInstance(); + _buffer = _ProgressBuffer(_prefs!); } Future> _guestHeaders() async { @@ -52,6 +92,8 @@ class LearnSyncService extends BaseRepository with UiLoggy { }; } + // ---- Guest session management ------------------------------------------ + Future ensureGuestSession() async { await _ensurePrefs(); if (_prefs!.getString(_guestIdKey) != null) return; @@ -71,8 +113,8 @@ class LearnSyncService extends BaseRepository with UiLoggy { 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?; + 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: $guestId'); @@ -83,6 +125,36 @@ class LearnSyncService extends BaseRepository with UiLoggy { } } + Future linkProgressToAccount(String authToken) async { + await _ensurePrefs(); + final guestId = _prefs!.getString(_guestIdKey); + 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) { + await _prefs!.remove(_guestIdKey); + loggy.info('Guest Learn progress linked to account'); + } + } catch (e) { + loggy.warning('Failed to link guest Learn progress: $e'); + } + } + + // ---- Progress reporting ------------------------------------------------- + Future reportCompletion( String lessonId, { required int totalActivities, @@ -113,23 +185,22 @@ class LearnSyncService extends BaseRepository with UiLoggy { } else { loggy.warning( 'Progress report failed (${response.statusCode}), buffering'); - await _bufferPending(lessonId, body); + await _buffer!.append(lessonId, body); } } catch (e) { loggy.warning('Progress report error: $e — buffering'); - await _bufferPending(lessonId, body); + await _buffer!.append(lessonId, body); } } + // ---- Offline sync ------------------------------------------------------- + Future syncPendingProgress() async { await _ensurePrefs(); - final raw = _prefs!.getString(_pendingSyncKey); - if (raw == null) return; + final pending = _buffer!.drain(); + if (pending == null || pending.isEmpty) return; try { - final pending = json.decode(raw) as List; - if (pending.isEmpty) return; - final headers = await _guestHeaders(); final response = await http.post( Uri.parse('${ApiUtils.baseUrl}${ApiUtils.learnProgressSync}'), @@ -138,52 +209,11 @@ class LearnSyncService extends BaseRepository with UiLoggy { ).timeout(const Duration(seconds: 15)); if (response.statusCode >= 200 && response.statusCode < 300) { - await _prefs!.remove(_pendingSyncKey); + await _buffer!.clear(); loggy.info('Synced ${pending.length} pending Learn progress entries'); } } catch (e) { loggy.warning('Pending Learn progress sync failed: $e'); } } - - Future linkProgressToAccount(String authToken) async { - await _ensurePrefs(); - final guestId = _prefs!.getString(_guestIdKey); - 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) { - await _prefs!.remove(_guestIdKey); - loggy.info('Guest Learn progress linked to account'); - } - } catch (e) { - loggy.warning('Failed to link guest Learn progress: $e'); - } - } - - Future _bufferPending( - String lessonId, Map body) async { - try { - final raw = _prefs!.getString(_pendingSyncKey); - final pending = - raw != null ? json.decode(raw) as List : []; - pending.add({'lesson_id': lessonId, ...body}); - await _prefs!.setString(_pendingSyncKey, json.encode(pending)); - } catch (e) { - loggy.error('Failed to buffer pending Learn progress: $e'); - } - } } 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 8a45834aba..0daf68902a 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 @@ -169,24 +169,11 @@ class _LearnLessonExperienceState extends State { _gradedResults.add(grade.isCorrect); _quizAttempts.add(QuizAttemptData( activityId: _current.index.toString(), - format: _quizFormatKey(_current.quiz!.format), + format: _current.quiz!.format.apiKey, isCorrect: grade.isCorrect, )); } - String _quizFormatKey(LearnQuizFormat format) { - switch (format) { - case LearnQuizFormat.singleChoice: - return 'single_choice'; - case LearnQuizFormat.multiChoice: - return 'multi_choice'; - case LearnQuizFormat.ranking: - return 'ranking'; - case LearnQuizFormat.freeText: - return 'free_text'; - } - } - Widget _buildActivityBody() { final activity = _current; final typeLabel = learnActivityTypeHeader( From d230c9e9394e5e3fcd4b57cd67bd4199b76ba1b0 Mon Sep 17 00:00:00 2001 From: Mozart299 Date: Wed, 1 Jul 2026 21:56:22 +0300 Subject: [PATCH 4/9] Apply review fixes to Learn v2 migration - Sign-in: call LearnSyncService.linkProgressToAccount on AuthLoaded so guest progress transfers to the signed-in account. - kya_page: map LessonsRefreshing to its currentModel in catalog switch so the course grid stays populated during background refresh. - LearnRepository: fail-fast when AIRQO_API_TOKEN is absent in both fetchCatalog and _refreshInBackground instead of sending an empty token. - Quiz parsing: use _toInt() helper with int.tryParse fallback for correct_index/correct_indices/correct_order to tolerate string-typed values. - LearnSyncService: redact guest session ID from success log. - LearnSyncService: flush pending buffer before clearing guestId in linkProgressToAccount so offline completions are not orphaned. - LearnSyncService: URI-encode lessonId in the progress PUT path. - LearnLessonExperience: guard empty _script in build() to prevent RangeError when a v2 lesson has no activities. --- src/mobile/lib/main.dart | 7 +++++++ src/mobile/lib/src/app/learn/pages/kya_page.dart | 1 + .../app/learn/repository/learn_repository.dart | 15 +++++++++++---- .../services/learn_lesson_experience_service.dart | 9 ++++++--- .../app/learn/services/learn_sync_service.dart | 5 +++-- .../experience/learn_lesson_experience.dart | 2 ++ 6 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/mobile/lib/main.dart b/src/mobile/lib/main.dart index a21e93d1b1..2107b14b1c 100644 --- a/src/mobile/lib/main.dart +++ b/src/mobile/lib/main.dart @@ -317,6 +317,13 @@ class _DeciderState extends State with WidgetsBindingObserver { if (!_hasRequestedUserLoad) { _hasRequestedUserLoad = true; context.read().add(LoadUser()); + AuthHelper.refreshTokenIfNeeded().then((token) { + if (token != null) { + LearnSyncService.instance + .linkProgressToAccount(token) + .catchError((_) {}); + } + }).catchError((_) {}); } } else if (authState is GuestUser || authState is AuthLoadingError || 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 3a101ccee6..0cab478dcb 100644 --- a/src/mobile/lib/src/app/learn/pages/kya_page.dart +++ b/src/mobile/lib/src/app/learn/pages/kya_page.dart @@ -160,6 +160,7 @@ class _KyaPageState extends State with UiLoggy { final LearnV2CatalogResponse? catalog = switch (state) { LessonsLoaded s => s.model, LessonsLoadingError s => s.cachedModel, + LessonsRefreshing s => s.currentModel, _ => null, }; 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 cfb2c37a94..89d304b06b 100644 --- a/src/mobile/lib/src/app/learn/repository/learn_repository.dart +++ b/src/mobile/lib/src/app/learn/repository/learn_repository.dart @@ -57,8 +57,12 @@ class LearnRepositoryImpl extends LearnRepository with UiLoggy { loggy.info('Fetching fresh Learn v2 catalog from API'); final token = dotenv.env['AIRQO_API_TOKEN']; - final queryParams = - token != null ? {'token': token} : {}; + if (token == null) { + loggy.error('AIRQO_API_TOKEN is not configured'); + if (cachedData != null) return cachedData.data; + throw StateError('AIRQO_API_TOKEN is not configured'); + } + final queryParams = {'token': token}; final response = await createGetRequest(ApiUtils.learnCatalog, queryParams) @@ -120,8 +124,11 @@ class LearnRepositoryImpl extends LearnRepository with UiLoggy { try { loggy.info('Background refresh of Learn v2 catalog'); final token = dotenv.env['AIRQO_API_TOKEN']; - final queryParams = - token != null ? {'token': token} : {}; + if (token == null) { + loggy.error('AIRQO_API_TOKEN is not configured for background refresh'); + return; + } + final queryParams = {'token': token}; final response = await createGetRequest(ApiUtils.learnCatalog, queryParams) .timeout(const Duration(seconds: 30)); 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 619e48a780..7306c694d7 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 @@ -199,6 +199,9 @@ class LearnLessonExperienceService { } } + static int? _toInt(dynamic v) => + v is int ? v : (v is String ? int.tryParse(v) : null); + static LearnQuizPayload? _parseV2Quiz(Map p) { final question = p['question'] as String? ?? ''; if (question.isEmpty) return null; @@ -206,11 +209,11 @@ class LearnLessonExperienceService { final format = LearnQuizFormat.fromApiKey(p['format'] as String? ?? ''); final rawOptions = p['options'] as List? ?? []; final options = rawOptions.map((o) => o.toString()).toList(); - final correctIndex = p['correct_index'] as int?; + final correctIndex = _toInt(p['correct_index']); final rawIndices = p['correct_indices'] as List?; - final correctIndices = rawIndices?.map((i) => i as int).toSet(); + final correctIndices = rawIndices?.map(_toInt).whereType().toSet(); final rawOrder = p['correct_order'] as List?; - final correctOrder = rawOrder?.map((i) => i as int).toList(); + final correctOrder = rawOrder?.map(_toInt).whereType().toList(); return LearnQuizPayload( format: format, 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 9cbdec6c47..6be5c22c7a 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 @@ -117,7 +117,7 @@ class LearnSyncService extends BaseRepository with UiLoggy { body['guest_id'] as String? ?? body['session_id'] as String?; if (guestId != null) { await _prefs!.setString(_guestIdKey, guestId); - loggy.info('Guest Learn session created: $guestId'); + loggy.info('Guest Learn session created'); } } } catch (e) { @@ -145,6 +145,7 @@ class LearnSyncService extends BaseRepository with UiLoggy { ).timeout(const Duration(seconds: 10)); if (response.statusCode >= 200 && response.statusCode < 300) { + await syncPendingProgress(); await _prefs!.remove(_guestIdKey); loggy.info('Guest Learn progress linked to account'); } @@ -175,7 +176,7 @@ class LearnSyncService extends BaseRepository with UiLoggy { try { final headers = await _guestHeaders(); final response = await http.put( - Uri.parse('${ApiUtils.baseUrl}${ApiUtils.learnProgress}/$lessonId'), + Uri.parse('${ApiUtils.baseUrl}${ApiUtils.learnProgress}/${Uri.encodeComponent(lessonId)}'), body: json.encode(body), headers: headers, ).timeout(const Duration(seconds: 10)); 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 0daf68902a..94c44cd7ec 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 @@ -212,6 +212,8 @@ class _LearnLessonExperienceState extends State { @override Widget build(BuildContext context) { + if (_script.isEmpty) return const SizedBox.shrink(); + final lessonTitle = learnDisplayTitle( widget.slot.v2Lesson?.title ?? widget.apiLesson?.title ?? From b1eb1462ec2f6b4ed0147687ab4f0301c5e06ee2 Mon Sep 17 00:00:00 2001 From: Mozart299 Date: Wed, 1 Jul 2026 22:14:16 +0300 Subject: [PATCH 5/9] Remove all Learn v2 dead code and legacy KYA layer Delete kya_repository.dart, kya_lesson_container.dart, lesson_response_model.dart. Strip buildFromLessons, buildDemoScript, courseCoverImage, syncLegacyApiProgress, ensurePilotLearnDemosV3, showLesson (legacy), and all apiLesson references. Update bloc tests to use LearnRepository / LearnV2CatalogResponse. --- .../learn/models/learn_course_structure.dart | 222 +---------- .../learn/models/lesson_response_model.dart | 114 ------ .../lib/src/app/learn/pages/kya_page.dart | 5 - .../lib/src/app/learn/pages/lesson_page.dart | 4 - .../app/learn/repository/kya_repository.dart | 202 ---------- .../learn_lesson_experience_service.dart | 155 -------- .../services/learn_progress_service.dart | 85 ----- .../experience/learn_lesson_experience.dart | 26 +- .../learn/widgets/kya_lesson_container.dart | 161 -------- .../learn/widgets/learn_bottom_sheets.dart | 64 ---- .../test/app/learn/bloc/kya_bloc_test.dart | 346 ++++-------------- src/mobile/test/widget_test.dart | 8 +- 12 files changed, 80 insertions(+), 1312 deletions(-) delete mode 100644 src/mobile/lib/src/app/learn/models/lesson_response_model.dart delete mode 100644 src/mobile/lib/src/app/learn/repository/kya_repository.dart delete mode 100644 src/mobile/lib/src/app/learn/widgets/kya_lesson_container.dart 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 0a1ba5bc85..565225169a 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,31 +1,23 @@ 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/models/lesson_response_model.dart'; -import 'package:airqo/src/app/learn/services/learn_lesson_experience_service.dart'; import 'package:airqo/src/app/learn/services/learn_progress_service.dart'; class LearnLessonSlot { final String catalogId; final String plainTitleKey; - final KyaLesson? apiLesson; final LearnV2Lesson? v2Lesson; const LearnLessonSlot({ required this.catalogId, required this.plainTitleKey, - this.apiLesson, this.v2Lesson, }); String get progressKey => catalogId; - bool get hasContent => v2Lesson != null - ? v2Lesson!.activities.isNotEmpty - : (apiLesson != null && apiLesson!.tasks.isNotEmpty); + bool get hasContent => v2Lesson != null && v2Lesson!.activities.isNotEmpty; - int get activityCount => v2Lesson != null - ? v2Lesson!.activities.length - : LearnLessonExperienceService.demoActivityCount; + int get activityCount => v2Lesson?.activities.length ?? 0; } class LearnUnitViewModel { @@ -71,8 +63,7 @@ class LearnCourseViewModel { required this.units, }); - int get totalLessons => - units.fold(0, (sum, u) => sum + u.lessons.length); + int get totalLessons => units.fold(0, (sum, u) => sum + u.lessons.length); int completedLessons(LearnProgressService progress) { var count = 0; @@ -94,7 +85,6 @@ class LearnStageInfo { enum LearnUnitStatus { locked, inProgress, completed } -/// Client-side catalog: maps flat [KyaLesson] API data into Course → Unit → Lesson. class LearnCatalog { static const stages = [ LearnStageInfo(name: 'Curious', index: 0), @@ -104,182 +94,6 @@ class LearnCatalog { LearnStageInfo(name: 'Defender', index: 4), ]; - static List buildFromLessons(List apiLessons) { - var apiIndex = 0; - - LearnLessonSlot slot(String catalogId, String title) { - KyaLesson? api; - if (apiIndex < apiLessons.length) { - api = apiLessons[apiIndex++]; - } - return LearnLessonSlot( - catalogId: catalogId, - plainTitleKey: title, - apiLesson: api, - ); - } - - List unitsForCourse( - String courseId, - List<({String title, String plain, List lessons})> defs, - ) { - return List.generate(defs.length, (u) { - final def = defs[u]; - return LearnUnitViewModel( - id: '${courseId}_u${u + 1}', - title: def.title, - plainTitleKey: def.plain, - lessons: List.generate(def.lessons.length, (l) { - return slot('${courseId}_u${u + 1}_l$l', def.lessons[l]); - }), - ); - }); - } - - const course1Units = [ - ( - title: 'Air basics', - plain: 'Air basics', - lessons: ['What is air quality', 'Why air matters', 'About AirQo'], - ), - ( - title: 'Sources', - plain: 'Pollution sources', - lessons: ['Cooking smoke', 'Road pollution', 'Open burning'], - ), - ( - title: 'Health', - plain: 'Health and air', - lessons: ['Eyes and lungs', 'Children', 'Stay safe'], - ), - ( - title: 'AQI scale', - plain: 'Air numbers', - lessons: ['Good and bad days', 'Read the numbers', 'Color codes'], - ), - ( - title: 'Action', - plain: 'Take action', - lessons: ['At home', 'In community', 'Share knowledge'], - ), - ]; - - const course2Units = [ - ( - title: 'AQI basics', - plain: 'Air numbers', - lessons: ['AQI basics', 'Color codes', 'Daily patterns'], - ), - ( - title: 'Forecasts', - plain: 'Air forecasts', - lessons: ['Read forecasts', 'Morning and evening', 'Weekend trends'], - ), - ( - title: 'Sensors', - plain: 'Air sensors', - lessons: ['Low-cost sensors', 'Calibration', 'Sensor network'], - ), - ( - title: 'Maps', - plain: 'Air maps', - lessons: ['Read the map', 'Nearest site', 'Compare places'], - ), - ( - title: 'Alerts', - plain: 'Air alerts', - lessons: ['Alert levels', 'Notifications', 'Stay safe'], - ), - ]; - - const course3Units = [ - ( - title: 'At home', - plain: 'At home', - lessons: ['Ventilation', 'Safe cooking', 'Indoor air'], - ), - ( - title: 'Travel', - plain: 'On the move', - lessons: ['Walking routes', 'Public transport', 'Masks'], - ), - ( - title: 'Community', - plain: 'Community', - lessons: ['Talk to neighbors', 'Schools', 'Local leaders'], - ), - ( - title: 'Advocacy', - plain: 'Advocacy', - lessons: ['Share data', 'Report problems', 'Campaigns'], - ), - ( - title: 'Next steps', - plain: 'Next steps', - lessons: ['Review progress', 'Teach a friend', 'Stay informed'], - ), - ]; - - const course4Units = [ - ( - title: 'Leadership', - plain: 'Leadership', - lessons: ['Lead by example', 'Host a talk', 'Build a team'], - ), - ( - title: 'Data', - plain: 'Air data', - lessons: ['Local trends', 'Hotspots', 'Tell the story'], - ), - ( - title: 'Partners', - plain: 'Partners', - lessons: ['Schools and NGOs', 'Health workers', 'Government'], - ), - ( - title: 'Momentum', - plain: 'Keep going', - lessons: ['Monthly check-ins', 'Celebrate wins', 'Plan ahead'], - ), - ( - title: 'Champion', - plain: 'Air champion', - lessons: ['Mentor someone', 'Run a campaign', 'Become a champion'], - ), - ]; - - return [ - LearnCourseViewModel( - id: 'syn_course_1', - courseNumber: 1, - title: 'Know your air', - plainTitleKey: 'Know your air', - units: unitsForCourse('syn_course_1', course1Units), - ), - LearnCourseViewModel( - id: 'syn_course_2', - courseNumber: 2, - title: 'Read the air', - plainTitleKey: 'Read the air', - units: unitsForCourse('syn_course_2', course2Units), - ), - LearnCourseViewModel( - id: 'syn_course_3', - courseNumber: 3, - title: 'Take action', - plainTitleKey: 'Take action', - units: unitsForCourse('syn_course_3', course3Units), - ), - LearnCourseViewModel( - id: 'syn_course_4', - courseNumber: 4, - title: 'Air champion', - plainTitleKey: 'Air champion', - units: unitsForCourse('syn_course_4', course4Units), - ), - ]; - } - static List buildFromV2Catalog( List v2Courses) { return v2Courses.map((course) { @@ -422,36 +236,6 @@ class LearnCatalog { return courses.fold(0, (s, c) => s + c.totalLessons); } - /// Picks one of the most distinct KYA lesson images for each course card. - static String? courseCoverImage( - LearnCourseViewModel course, - List apiLessons, - ) { - if (apiLessons.isEmpty) return null; - - final uniqueImages = []; - for (final lesson in apiLessons) { - if (lesson.image.isNotEmpty && !uniqueImages.contains(lesson.image)) { - uniqueImages.add(lesson.image); - } - for (final task in lesson.tasks) { - if (task.image.isNotEmpty && !uniqueImages.contains(task.image)) { - uniqueImages.add(task.image); - } - } - } - - if (uniqueImages.isEmpty) return null; - if (uniqueImages.length == 1) return uniqueImages.first; - - final courseIdx = (course.courseNumber - 1).clamp(0, 3); - if (uniqueImages.length >= 4) { - final step = (uniqueImages.length - 1) / 3; - return uniqueImages[(courseIdx * step).round()]; - } - return uniqueImages[courseIdx % uniqueImages.length]; - } - static LearnLessonContinuation? continuationFor( LearnCourseViewModel course, LearnUnitViewModel unit, diff --git a/src/mobile/lib/src/app/learn/models/lesson_response_model.dart b/src/mobile/lib/src/app/learn/models/lesson_response_model.dart deleted file mode 100644 index d88a30b94a..0000000000 --- a/src/mobile/lib/src/app/learn/models/lesson_response_model.dart +++ /dev/null @@ -1,114 +0,0 @@ -// To parse this JSON data, do -// -// final lessonResponseModel = lessonResponseModelFromJson(jsonString); - -import 'dart:convert'; - -LessonResponseModel lessonResponseModelFromJson(String str) => LessonResponseModel.fromJson(json.decode(str)); - -String lessonResponseModelToJson(LessonResponseModel data) => json.encode(data.toJson()); - -class LessonResponseModel { - bool success; - String message; - List kyaLessons; - - LessonResponseModel({ - required this.success, - required this.message, - required this.kyaLessons, - }); - - factory LessonResponseModel.fromJson(Map json) => LessonResponseModel( - success: json["success"], - message: json["message"], - kyaLessons: List.from(json["kya_lessons"].map((x) => KyaLesson.fromJson(x))), - ); - - Map toJson() => { - "success": success, - "message": message, - "kya_lessons": List.from(kyaLessons.map((x) => x.toJson())), - }; -} - -class KyaLesson { - String id; - String title; - String completionMessage; - String image; - List tasks; - - KyaLesson({ - required this.id, - required this.title, - required this.completionMessage, - required this.image, - required this.tasks, - }); - - factory KyaLesson.fromJson(Map json) => KyaLesson( - id: json["_id"], - title: json["title"], - completionMessage: json["completion_message"], - image: json["image"], - tasks: List.from(json["tasks"].map((x) => Task.fromJson(x))), - ); - - Map toJson() => { - "_id": id, - "title": title, - "completion_message": completionMessage, - "image": image, - "tasks": List.from(tasks.map((x) => x.toJson())), - }; -} - - -class Task { - String id; - String title; - String content; - String image; - DateTime createdAt; - DateTime updatedAt; - int v; - String kyaLesson; - int taskPosition; - - Task({ - required this.id, - required this.title, - required this.content, - required this.image, - required this.createdAt, - required this.updatedAt, - required this.v, - required this.kyaLesson, - required this.taskPosition, - }); - - factory Task.fromJson(Map json) => Task( - id: json["_id"], - title: json["title"], - content: json["content"], - image: json["image"], - createdAt: DateTime.parse(json["createdAt"]), - updatedAt: DateTime.parse(json["updatedAt"]), - v: json["__v"], - kyaLesson: json["kya_lesson"], - taskPosition: json["task_position"], - ); - - Map toJson() => { - "_id": id, - "title": title, - "content": content, - "image": image, - "createdAt": createdAt.toIso8601String(), - "updatedAt": updatedAt.toIso8601String(), - "__v": v, - "kya_lesson": kyaLesson, - "task_position": taskPosition, - }; -} 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 0cab478dcb..c0ca65016b 100644 --- a/src/mobile/lib/src/app/learn/pages/kya_page.dart +++ b/src/mobile/lib/src/app/learn/pages/kya_page.dart @@ -1,4 +1,3 @@ -import 'package:flutter/foundation.dart'; import 'package:airqo/src/app/dashboard/widgets/dashboard_app_bar.dart'; import 'package:airqo/src/app/learn/models/learn_v2_catalog.dart'; import 'package:airqo/src/app/learn/bloc/kya_bloc.dart'; @@ -71,10 +70,6 @@ class _KyaPageState extends State with UiLoggy { _lastSeedFingerprint = fingerprint; _progress.clearPilotSeedIfNeeded(); - - if (kDebugMode) { - _progress.ensurePilotLearnDemosV3(courses: courses); - } } @override diff --git a/src/mobile/lib/src/app/learn/pages/lesson_page.dart b/src/mobile/lib/src/app/learn/pages/lesson_page.dart index 0ebc33aa6c..913675b5d1 100644 --- a/src/mobile/lib/src/app/learn/pages/lesson_page.dart +++ b/src/mobile/lib/src/app/learn/pages/lesson_page.dart @@ -1,13 +1,11 @@ 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/models/lesson_response_model.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 KyaLesson? apiLesson; final LearnCourseViewModel course; final int unitIndex; final int lessonIndex; @@ -25,7 +23,6 @@ class LessonPage extends StatelessWidget { required this.unitIndex, required this.lessonIndex, required this.unitPlainTitle, - this.apiLesson, this.lessonNumberInUnit = 1, this.lessonsInUnit = 1, this.allCourses, @@ -37,7 +34,6 @@ class LessonPage extends StatelessWidget { Widget build(BuildContext context) { final experience = LearnLessonExperience( slot: slot, - apiLesson: apiLesson ?? slot.apiLesson, course: course, unitIndex: unitIndex, lessonIndex: lessonIndex, diff --git a/src/mobile/lib/src/app/learn/repository/kya_repository.dart b/src/mobile/lib/src/app/learn/repository/kya_repository.dart deleted file mode 100644 index 0dcfd03fd5..0000000000 --- a/src/mobile/lib/src/app/learn/repository/kya_repository.dart +++ /dev/null @@ -1,202 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'package:airqo/src/app/learn/models/lesson_response_model.dart'; -import 'package:airqo/src/app/shared/repository/base_repository.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:http/http.dart'; -import 'package:loggy/loggy.dart'; -import 'package:airqo/src/app/shared/services/cache_manager.dart'; - -abstract class KyaRepository extends BaseRepository { - Future fetchLessons({bool forceRefresh = false}); - Future clearCache(); -} - -class KyaImpl extends KyaRepository with UiLoggy { - static final KyaImpl _instance = KyaImpl._internal(); - factory KyaImpl() => _instance; - KyaImpl._internal(); - - final CacheManager _cacheManager = CacheManager(); - static const String _lessonsCacheKey = 'kya_lessons'; - // Retired API endpoint kept here so kya_repository.dart compiles without _fetchLessonsPath. - static const String _fetchLessonsPath = '/api/v2/devices/kya/lessons'; - - @override - Future fetchLessons({bool forceRefresh = false}) async { - loggy.info('Fetching KYA lessons (forceRefresh: $forceRefresh)'); - - final cachedData = await _cacheManager.get( - boxName: CacheBoxName.location, - key: _lessonsCacheKey, - fromJson: (json) => lessonResponseModelFromJson(jsonEncode(json)), - ); - - final refreshPolicy = RefreshPolicy( - wifiInterval: const Duration(days: 7), - mobileInterval: const Duration(days: 14), - ); - - final shouldRefresh = _cacheManager.shouldRefresh( - boxName: CacheBoxName.location, - key: _lessonsCacheKey, - policy: refreshPolicy, - cachedData: cachedData, - forceRefresh: forceRefresh, - ); - - if (cachedData != null && !shouldRefresh) { - loggy.info('Using cached lessons data (${cachedData.timestamp})'); - - if (_cacheManager.isConnected && !forceRefresh) { - _refreshInBackground(); - } - - return cachedData.data; - } - - if (_cacheManager.isConnected) { - try { - loggy.info('Fetching fresh lessons data from API'); - - final token = dotenv.env['AIRQO_API_TOKEN']; - if (token == null) { - loggy.error('AIRQO_API_TOKEN is not configured'); - throw StateError('Missing API token'); - } - - Response response = - await createGetRequest(_fetchLessonsPath, {"token": token}) - .timeout( - const Duration(seconds: 15), - onTimeout: () { - loggy.warning('API request timed out after 15 seconds'); - throw TimeoutException('Request timed out'); - }, - ); - - if (response.statusCode == 200) { - try { - final lessonResponseModel = - lessonResponseModelFromJson(response.body); - - String? etag = response.headers['etag']; - await _cacheManager.put( - boxName: CacheBoxName.location, - key: _lessonsCacheKey, - data: lessonResponseModel, - toJson: (data) => jsonDecode(lessonResponseModelToJson(data)), - etag: etag, - ); - - loggy.info('Successfully fetched and cached lessons data'); - return lessonResponseModel; - } catch (parseError) { - loggy.error('Error parsing API response: $parseError'); - - if (cachedData != null) { - loggy.info('Using cached data due to parsing error'); - return cachedData.data; - } - - throw Exception('Failed to parse API response: $parseError'); - } - } else { - loggy.warning( - 'API returned error status code: ${response.statusCode}'); - - if (cachedData != null) { - loggy.info( - 'Using cached data due to API error (status: ${response.statusCode})'); - return cachedData.data; - } - - throw Exception( - 'Failed to fetch lessons data: ${response.statusCode}'); - } - } catch (e) { - loggy.error('Error fetching lessons data: $e'); - - if (cachedData != null) { - loggy.info('Using stale cached data due to error: $e'); - return cachedData.data; - } - - rethrow; - } - } else { - if (cachedData != null) { - loggy.info('Using cached lessons data in offline mode'); - return cachedData.data; - } - - throw Exception( - 'Unable to load lessons. Please check your connection and try again.'); - } - } - - Future getCachedLessonsData() async { - try { - final cachedData = await _cacheManager.get( - boxName: CacheBoxName.location, - key: _lessonsCacheKey, - fromJson: (json) => lessonResponseModelFromJson(jsonEncode(json)), - ); - - if (cachedData != null) { - return cachedData.data; - } - return null; - } catch (e) { - loggy.error('Error retrieving cached lessons data: $e'); - return null; - } - } - - Future _refreshInBackground() async { - try { - loggy.info('Starting background refresh of lessons data'); - final token = dotenv.env['AIRQO_API_TOKEN']; - if (token == null) { - loggy.error('AIRQO_API_TOKEN is not configured for background refresh'); - return; - } - - Response response = - await createGetRequest(_fetchLessonsPath, {"token": token}) - .timeout(const Duration(seconds: 30)); - - if (response.statusCode == 200) { - final lessonResponseModel = lessonResponseModelFromJson(response.body); - - String? etag = response.headers['etag']; - await _cacheManager.put( - boxName: CacheBoxName.location, - key: _lessonsCacheKey, - data: lessonResponseModel, - toJson: (data) => jsonDecode(lessonResponseModelToJson(data)), - etag: etag, - ); - - loggy.info('Background refresh of lessons data completed successfully'); - } else { - loggy.warning('Background refresh failed: ${response.statusCode}'); - } - } catch (e) { - loggy.error('Error in background refresh: $e'); - } - } - - @override - Future clearCache() async { - try { - await _cacheManager.delete( - boxName: CacheBoxName.location, - key: _lessonsCacheKey, - ); - loggy.info('Lessons cache cleared'); - } catch (e) { - loggy.error('Error clearing lessons cache: $e'); - } - } -} 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 7306c694d7..019855a4f9 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 @@ -1,136 +1,10 @@ -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_v2_catalog.dart'; -import 'package:airqo/src/app/learn/models/lesson_response_model.dart'; class LearnLessonExperienceService { const LearnLessonExperienceService._(); - static const demoActivityCount = 7; - - static const _demoVideoUrl = - 'https://www.youtube.com/watch?v=U5F-F2AHH7s'; - - static List buildDemoScript({ - required String lessonTitle, - required String unitTitle, - required LearnLessonSlot slot, - KyaLesson? apiLesson, - }) { - final topic = learnDisplayTitle(lessonTitle.isNotEmpty - ? lessonTitle - : slot.plainTitleKey); - final unit = learnDisplayTitle(unitTitle); - final imageUrl = _resolveImage(slot, apiLesson); - final articleBody = _resolveArticleBody(slot, apiLesson, topic, unit); - - return [ - LearnLessonActivity( - index: 0, - type: LearnActivityType.article, - title: 'About $topic', - article: LearnArticlePayload( - body: articleBody, - audioText: articleBody, - ), - ), - LearnLessonActivity( - index: 1, - type: LearnActivityType.video, - title: 'Watch: $topic', - video: LearnVideoPayload( - videoUrl: _demoVideoUrl, - description: - 'A short overview of $topic and why it matters for the air around you in $unit.', - posterUrl: imageUrl, - ), - ), - LearnLessonActivity( - index: 2, - type: LearnActivityType.image, - title: 'Spot it: $topic', - image: LearnImagePayload( - imageUrl: imageUrl, - caption: 'Look for signs of $topic near your home.', - subtitle: - 'Community photo — notice smoke, dust, or traffic that affects daily air quality.', - ), - ), - LearnLessonActivity( - index: 3, - type: LearnActivityType.quiz, - title: 'Quick check', - quiz: LearnQuizPayload( - format: LearnQuizFormat.singleChoice, - question: 'What is the main pollution source in this lesson?', - options: [ - 'Vehicle exhaust', - topic, - 'Factory chimney', - 'Crop burning', - ], - correctIndex: 1, - correctFeedback: 'Correct! $topic is the focus of this lesson.', - incorrectFeedback: - 'Not quite — re-read the article and try again.', - ), - ), - LearnLessonActivity( - index: 4, - type: LearnActivityType.quiz, - title: 'Multiple choice', - quiz: LearnQuizPayload( - format: LearnQuizFormat.multiChoice, - question: 'Which activities can raise PM2.5 near your home?', - options: [ - 'Burning charcoal', - 'Using solar panels', - 'Smoking indoors', - 'Sweeping dry floors', - ], - correctIndices: {0, 2, 3}, - correctFeedback: - 'Correct! Charcoal, smoking, and sweeping all release particles.', - incorrectFeedback: - 'Charcoal, smoking, and sweeping release particles. Solar panels do not.', - ), - ), - LearnLessonActivity( - index: 5, - type: LearnActivityType.quiz, - title: 'Ranking', - quiz: LearnQuizPayload( - format: LearnQuizFormat.ranking, - question: 'Order these from most to least impact on indoor air.', - options: [ - 'Charcoal cooking', - 'Crop burning smoke', - 'Vehicle exhaust', - 'Road dust', - ], - correctOrder: [0, 1, 2, 3], - correctFeedback: 'Great job — that order matches typical indoor impact.', - incorrectFeedback: - 'Almost! Charcoal and crop smoke usually have the biggest indoor impact.', - ), - ), - LearnLessonActivity( - index: 6, - type: LearnActivityType.quiz, - title: 'Your thoughts', - quiz: LearnQuizPayload( - format: LearnQuizFormat.freeText, - question: - 'Name one thing near your home that affects your air quality.', - options: const [], - correctFeedback: - 'Noted! Top answers from your area: open burning, road dust, charcoal smoke.', - ), - ), - ]; - } - static List buildFromV2Lesson({ required LearnV2Lesson lesson, }) { @@ -282,33 +156,4 @@ class LearnLessonExperienceService { return lessonIndex == lastUnit.lessons.length - 1; } - static String _resolveImage(LearnLessonSlot slot, KyaLesson? apiLesson) { - if (apiLesson != null && apiLesson.image.isNotEmpty) { - return apiLesson.image; - } - if (apiLesson != null) { - for (final task in apiLesson.tasks) { - if (task.image.isNotEmpty) return task.image; - } - } - return ''; - } - - static String _resolveArticleBody( - LearnLessonSlot slot, - KyaLesson? apiLesson, - String topic, - String unit, - ) { - if (apiLesson != null && apiLesson.tasks.isNotEmpty) { - final parts = apiLesson.tasks - .where((t) => t.content.trim().isNotEmpty) - .map((t) => t.content.trim()) - .toList(); - if (parts.isNotEmpty) return parts.join('\n\n'); - } - return 'In $unit, understanding $topic helps you read the air around you. ' - 'Small particles in smoke and dust can reach your lungs before you feel anything. ' - 'Knowing what to look for is the first step to protecting yourself and your family.'; - } } 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 a600e658b5..7276d37ecf 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 @@ -122,11 +122,6 @@ class LearnProgressService { _notify(); } - /// Runs the legacy-API → catalog-id migration for all users. - void syncLegacyApiProgress(List courses) { - _syncLegacyApiProgressToCatalog(courses); - } - bool hasShownCourseDemo(String courseId) { return _prefs?.getBool('$_courseDemoKey$courseId') ?? false; } @@ -136,63 +131,6 @@ class LearnProgressService { await _prefs!.setBool('$_courseDemoKey$courseId', true); } - /// Pre-seeds pilot progress: course 1 complete, course 2 in progress, 3–4 locked. - /// In debug builds this re-applies on every launch so prototype states stay visible. - Future ensurePilotLearnDemosV3({ - required List courses, - }) async { - await ensureInitialized(); - if (courses.isEmpty) return; - - _syncLegacyApiProgressToCatalog(courses); - - final alreadySeeded = _prefs!.getBool(_pilotSeedKey) == true; - if (!kDebugMode && alreadySeeded) return; - - if (kDebugMode) { - await _clearLearnProgressKeys(); - } else if (alreadySeeded) { - return; - } - - Future markComplete(String key) => - _prefs!.setBool('$_completePrefix$key', true); - - Future markInProgress(String key, int step) => - _prefs!.setInt('$_stepPrefix$key', step); - - Future seedResult(String key, {int stars = 3}) async { - await markComplete(key); - await _prefs!.setInt('$_starsPrefix$key', stars); - await _prefs!.setInt('$_pointsPrefix$key', stars * 10); - await _prefs!.setDouble('$_quizScorePrefix$key', stars / 3); - } - - // Course 1 — fully completed. - for (final unit in courses.first.units) { - for (final lesson in unit.lessons) { - await seedResult(lesson.progressKey); - } - } - - // Course 2 — in progress (two done, third partially started). - if (courses.length > 1) { - final lessons = - courses[1].units.expand((unit) => unit.lessons).toList(); - for (var i = 0; i < lessons.length && i <= 2; i++) { - final key = lessons[i].progressKey; - if (i < 2) { - await seedResult(key, stars: i == 0 ? 3 : 2); - } else { - await markInProgress(key, 2); - } - } - } - - await _prefs!.setBool(_pilotSeedKey, true); - _notify(); - } - Future _clearLearnProgressKeys() async { final keys = _prefs!.getKeys().where( (key) => @@ -211,27 +149,4 @@ class LearnProgressService { } } - /// Copies progress stored under legacy API ids onto stable catalog ids. - void _syncLegacyApiProgressToCatalog(List courses) { - for (final course in courses) { - for (final unit in course.units) { - for (final slot in unit.lessons) { - final api = slot.apiLesson; - if (api == null) continue; - final catalogKey = slot.catalogId; - final apiKey = api.id; - if (catalogKey == apiKey) continue; - - if (isLessonComplete(apiKey) && !isLessonComplete(catalogKey)) { - _prefs!.setBool('$_completePrefix$catalogKey', true); - } - final apiStep = furthestStep(apiKey); - final catalogStep = furthestStep(catalogKey); - if (apiStep > catalogStep) { - _prefs!.setInt('$_stepPrefix$catalogKey', apiStep); - } - } - } - } - } } 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 94c44cd7ec..2a6662a6c6 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,7 +2,6 @@ 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/models/lesson_response_model.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'; @@ -17,7 +16,6 @@ import 'package:flutter/material.dart'; class LearnLessonExperience extends StatefulWidget { final LearnLessonSlot slot; - final KyaLesson? apiLesson; final LearnCourseViewModel course; final int unitIndex; final int lessonIndex; @@ -40,7 +38,6 @@ class LearnLessonExperience extends StatefulWidget { required this.lessonsInUnit, required this.onClose, required this.completionContext, - this.apiLesson, this.allCourses, this.continuation, }); @@ -61,20 +58,11 @@ class _LearnLessonExperienceState extends State { @override void initState() { super.initState(); - if (widget.slot.v2Lesson != null) { - _script = LearnLessonExperienceService.buildFromV2Lesson( - lesson: widget.slot.v2Lesson!, - ); - } else { - final lessonTitle = - widget.apiLesson?.title ?? widget.slot.plainTitleKey; - _script = LearnLessonExperienceService.buildDemoScript( - lessonTitle: lessonTitle, - unitTitle: widget.unitPlainTitle, - slot: widget.slot, - apiLesson: widget.apiLesson, - ); - } + _script = widget.slot.v2Lesson != null + ? LearnLessonExperienceService.buildFromV2Lesson( + 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)) { @@ -215,9 +203,7 @@ class _LearnLessonExperienceState extends State { if (_script.isEmpty) return const SizedBox.shrink(); final lessonTitle = learnDisplayTitle( - widget.slot.v2Lesson?.title ?? - widget.apiLesson?.title ?? - widget.slot.plainTitleKey, + widget.slot.v2Lesson?.title ?? widget.slot.plainTitleKey, ); return SizedBox.expand( diff --git a/src/mobile/lib/src/app/learn/widgets/kya_lesson_container.dart b/src/mobile/lib/src/app/learn/widgets/kya_lesson_container.dart deleted file mode 100644 index 7aa22c3cba..0000000000 --- a/src/mobile/lib/src/app/learn/widgets/kya_lesson_container.dart +++ /dev/null @@ -1,161 +0,0 @@ -import 'package:airqo/src/app/learn/models/lesson_response_model.dart'; -import 'package:airqo/src/app/learn/widgets/learn_bottom_sheets.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 KyaLessonContainer extends StatelessWidget { - final KyaLesson kyaLesson; - - const KyaLessonContainer(this.kyaLesson, {super.key}); - - @override - Widget build(BuildContext context) { - final taskCount = kyaLesson.tasks.length; - - return GestureDetector( - onTap: () => LearnBottomSheets.showLesson(context, lesson: kyaLesson), - child: Container( - margin: const EdgeInsets.symmetric(vertical: 8), - width: double.infinity, - height: 288, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.08), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Stack( - children: [ - // Background image - Positioned.fill( - child: Image.network( - kyaLesson.image, - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => Container( - color: AppColors.primaryColor.withValues(alpha: 0.15), - child: const Icon(Icons.image_not_supported, - size: 48, color: Colors.grey), - ), - ), - ), - // Dark gradient overlay for readability - Positioned.fill( - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Colors.black.withValues(alpha: 0.35), - ], - stops: const [0.4, 1.0], - ), - ), - ), - ), - // Info card bottom-left - Positioned( - left: 12, - bottom: 12, - child: _LessonInfoCard( - title: kyaLesson.title, - taskCount: taskCount, - context: context, - ), - ), - // Arrow button bottom-right - Positioned( - right: 12, - bottom: 12, - child: Container( - height: 44, - width: 44, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: const Color(0xff57D175), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.15), - blurRadius: 6, - offset: const Offset(0, 2), - ), - ], - ), - child: const Icon( - Icons.play_arrow_rounded, - color: Colors.black, - size: 22, - ), - ), - ), - ], - ), - ), - ), - ); - } -} - -class _LessonInfoCard extends StatelessWidget { - final String title; - final int taskCount; - final BuildContext context; - - const _LessonInfoCard({ - required this.title, - required this.taskCount, - required this.context, - }); - - @override - Widget build(BuildContext _) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), - constraints: const BoxConstraints(maxWidth: 220, minHeight: 72), - decoration: BoxDecoration( - color: Theme.of(context).brightness == Brightness.dark - ? const Color(0xff34373B) - : Colors.white, - borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.08), - blurRadius: 4, - offset: const Offset(0, 1), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - TranslatedText( - title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 15), - ), - const SizedBox(height: 4), - Row( - children: [ - const Icon(Icons.menu_book_rounded, size: 13, color: Colors.grey), - const SizedBox(width: 4), - TranslatedText( - '$taskCount ${taskCount == 1 ? "card" : "cards"}', - style: const TextStyle(fontSize: 12, color: Colors.grey), - ), - ], - ), - ], - ), - ); - } -} 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 2749b747b0..c12a3ce359 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 @@ -1,7 +1,6 @@ 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/models/lesson_response_model.dart'; import 'package:airqo/src/app/learn/pages/learn_course_detail_page.dart'; import 'package:airqo/src/app/learn/theme/learn_design_tokens.dart'; import 'package:airqo/src/app/learn/widgets/experience/learn_course_certificate.dart'; @@ -258,7 +257,6 @@ class LearnBottomSheets { ), child: LearnLessonExperience( slot: slot, - apiLesson: slot.apiLesson, course: course, unitIndex: unitIndex, lessonIndex: lessonIndex, @@ -277,66 +275,4 @@ class LearnBottomSheets { ); } - /// Legacy entry for flat KYA list cards. - static Future showLesson( - BuildContext context, { - required KyaLesson lesson, - String? progressKey, - String? unitPlainTitle, - int lessonNumberInUnit = 1, - int lessonsInUnit = 1, - LearnLessonContinuation? continuation, - List? allCourses, - }) { - final slot = LearnLessonSlot( - catalogId: progressKey ?? lesson.id, - plainTitleKey: lesson.title, - apiLesson: lesson, - ); - - if (allCourses != null && continuation != null) { - final course = - allCourses.firstWhere((c) => c.id == continuation.learnCourseId); - return showLessonExperience( - context, - slot: slot, - course: course, - unitIndex: continuation.unitIndex, - lessonIndex: continuation.lessonIndex, - unitPlainTitle: unitPlainTitle ?? continuation.unitPlainTitle, - lessonNumberInUnit: lessonNumberInUnit, - lessonsInUnit: lessonsInUnit, - allCourses: allCourses, - continuation: continuation, - ); - } - - final legacyCourse = LearnCourseViewModel( - id: 'legacy_kya', - courseNumber: 1, - title: lesson.title, - plainTitleKey: lesson.title, - units: [ - LearnUnitViewModel( - id: 'legacy_u1', - title: 'Lesson', - plainTitleKey: unitPlainTitle ?? 'Lesson', - lessons: [slot], - ), - ], - ); - - return showLessonExperience( - context, - slot: slot, - course: legacyCourse, - unitIndex: 0, - lessonIndex: 0, - unitPlainTitle: unitPlainTitle ?? 'Lesson', - lessonNumberInUnit: lessonNumberInUnit, - lessonsInUnit: lessonsInUnit, - allCourses: null, - continuation: continuation, - ); - } } 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 0430370974..eb7e8c7357 100644 --- a/src/mobile/test/app/learn/bloc/kya_bloc_test.dart +++ b/src/mobile/test/app/learn/bloc/kya_bloc_test.dart @@ -4,20 +4,20 @@ import 'package:mockito/mockito.dart'; import 'package:mockito/annotations.dart'; import 'dart:async'; import 'package:airqo/src/app/learn/bloc/kya_bloc.dart'; -import 'package:airqo/src/app/learn/repository/kya_repository.dart'; -import 'package:airqo/src/app/learn/models/lesson_response_model.dart'; +import 'package:airqo/src/app/learn/repository/learn_repository.dart'; +import 'package:airqo/src/app/learn/models/learn_v2_catalog.dart'; // Generate mocks -@GenerateMocks([KyaRepository]) +@GenerateMocks([LearnRepository]) import 'kya_bloc_test.mocks.dart'; void main() { group('KyaBloc', () { - late MockKyaRepository mockRepository; + late MockLearnRepository mockRepository; late KyaBloc kyaBloc; setUp(() { - mockRepository = MockKyaRepository(); + mockRepository = MockLearnRepository(); kyaBloc = KyaBloc(mockRepository); }); @@ -30,47 +30,23 @@ void main() { }); group('LoadLessons', () { - final mockLessonResponse = LessonResponseModel( + final mockCatalog = LearnV2CatalogResponse( success: true, - message: 'Lessons loaded successfully', - kyaLessons: [ - KyaLesson( - id: 'lesson-1', - title: 'Understanding Air Quality', - completionMessage: 'Not completed', - image: 'https://example.com/lesson1.jpg', - tasks: [ - Task( - id: 'task-1', - title: 'Introduction to PM2.5', - content: 'Learn about particulate matter and its health effects', - image: 'https://example.com/task1.jpg', - createdAt: DateTime.parse('2024-01-15T12:00:00Z'), - updatedAt: DateTime.parse('2024-01-15T12:00:00Z'), - v: 0, - kyaLesson: 'lesson-1', - taskPosition: 1, - ), - ], + catalogVersion: 'v1', + courses: [ + LearnV2Course( + id: 'course-1', + courseNumber: 1, + title: 'Know Your Air', + plainTitleKey: 'Know Your Air', + units: [], ), - KyaLesson( - id: 'lesson-2', - title: 'Health Effects of Air Pollution', - completionMessage: 'Completed', - image: 'https://example.com/lesson2.jpg', - tasks: [ - Task( - id: 'task-2', - title: 'Respiratory Health', - content: 'Understanding how air pollution affects your lungs', - image: 'https://example.com/task2.jpg', - createdAt: DateTime.parse('2024-01-15T12:00:00Z'), - updatedAt: DateTime.parse('2024-01-15T12:00:00Z'), - v: 0, - kyaLesson: 'lesson-2', - taskPosition: 1, - ), - ], + LearnV2Course( + id: 'course-2', + courseNumber: 2, + title: 'Read the Air', + plainTitleKey: 'Read the Air', + units: [], ), ], ); @@ -78,25 +54,26 @@ void main() { blocTest( 'emits [LessonsLoading, LessonsLoaded] when LoadLessons succeeds', build: () { - when(mockRepository.fetchLessons(forceRefresh: anyNamed('forceRefresh'))) - .thenAnswer((_) async => mockLessonResponse); + when(mockRepository.fetchCatalog(forceRefresh: anyNamed('forceRefresh'))) + .thenAnswer((_) async => mockCatalog); return kyaBloc; }, act: (bloc) => bloc.add(LoadLessons()), expect: () => [ LessonsLoading(), - LessonsLoaded(mockLessonResponse), + LessonsLoaded(mockCatalog), ], verify: (_) { - verify(mockRepository.fetchLessons(forceRefresh: false)).called(1); + verify(mockRepository.fetchCatalog(forceRefresh: false)).called(1); }, ); blocTest( 'emits [LessonsLoading, LessonsLoadingError] when LoadLessons fails', build: () { - when(mockRepository.fetchLessons(forceRefresh: anyNamed('forceRefresh'))) + when(mockRepository.fetchCatalog(forceRefresh: anyNamed('forceRefresh'))) .thenThrow(Exception('Network error')); + when(mockRepository.getCachedCatalog()).thenAnswer((_) async => null); return kyaBloc; }, act: (bloc) => bloc.add(LoadLessons()), @@ -106,119 +83,75 @@ void main() { .having((state) => state.message, 'message', contains('Network error')), ], verify: (_) { - verify(mockRepository.fetchLessons(forceRefresh: false)).called(1); + verify(mockRepository.fetchCatalog(forceRefresh: false)).called(1); }, ); blocTest( - 'emits [LessonsLoading, LessonsLoaded] with empty lessons when repository returns empty list', + 'emits [LessonsLoading, LessonsLoaded] with empty courses when repository returns empty catalog', build: () { - final emptyResponse = LessonResponseModel( + final emptyCatalog = LearnV2CatalogResponse( success: true, - message: 'No lessons available', - kyaLessons: [], + catalogVersion: 'v1', + courses: [], ); - when(mockRepository.fetchLessons(forceRefresh: anyNamed('forceRefresh'))) - .thenAnswer((_) async => emptyResponse); + when(mockRepository.fetchCatalog(forceRefresh: anyNamed('forceRefresh'))) + .thenAnswer((_) async => emptyCatalog); return kyaBloc; }, act: (bloc) => bloc.add(LoadLessons()), expect: () => [ LessonsLoading(), isA() - .having((state) => state.model.kyaLessons, 'lessons', isEmpty), + .having((state) => state.model.courses, 'courses', isEmpty), ], ); blocTest( - 'handles API response with success false', + 'handles force refresh parameter correctly', build: () { - final failedResponse = LessonResponseModel( - success: false, - message: 'Failed to load lessons', - kyaLessons: [], - ); - when(mockRepository.fetchLessons(forceRefresh: anyNamed('forceRefresh'))) - .thenAnswer((_) async => failedResponse); + when(mockRepository.fetchCatalog(forceRefresh: true)) + .thenAnswer((_) async => mockCatalog); return kyaBloc; }, - act: (bloc) => bloc.add(LoadLessons()), + act: (bloc) => bloc.add(LoadLessons(forceRefresh: true)), expect: () => [ LessonsLoading(), - isA() - .having((state) => state.model.success, 'success', false) - .having((state) => state.model.message, 'message', 'Failed to load lessons'), + LessonsLoaded(mockCatalog), ], + verify: (_) { + verify(mockRepository.fetchCatalog(forceRefresh: true)).called(1); + }, ); blocTest( 'handles timeout exception', build: () { - when(mockRepository.fetchLessons(forceRefresh: anyNamed('forceRefresh'))) + when(mockRepository.fetchCatalog(forceRefresh: anyNamed('forceRefresh'))) .thenThrow(TimeoutException('Request timeout', Duration(seconds: 30))); + when(mockRepository.getCachedCatalog()).thenAnswer((_) async => null); return kyaBloc; }, act: (bloc) => bloc.add(LoadLessons()), expect: () => [ LessonsLoading(), - isA() - .having((state) => state.message, 'message', contains('timeout')), - ], - ); - - blocTest( - 'handles HTTP exception', - build: () { - when(mockRepository.fetchLessons(forceRefresh: anyNamed('forceRefresh'))) - .thenThrow(Exception('HTTP 500: Internal Server Error')); - return kyaBloc; - }, - act: (bloc) => bloc.add(LoadLessons()), - expect: () => [ - LessonsLoading(), - isA() - .having((state) => state.message, 'message', contains('500')), - ], - ); - - blocTest( - 'handles force refresh parameter correctly', - build: () { - when(mockRepository.fetchLessons(forceRefresh: true)) - .thenAnswer((_) async => mockLessonResponse); - return kyaBloc; - }, - act: (bloc) => bloc.add(LoadLessons(forceRefresh: true)), - expect: () => [ - LessonsLoading(), - LessonsLoaded(mockLessonResponse), + isA(), ], - verify: (_) { - verify(mockRepository.fetchLessons(forceRefresh: true)).called(1); - }, ); }); group('Multiple LoadLessons events', () { - final mockResponse = LessonResponseModel( + final mockCatalog = LearnV2CatalogResponse( success: true, - message: 'Success', - kyaLessons: [ - KyaLesson( - id: 'lesson-1', - title: 'Test Lesson', - completionMessage: 'Not completed', - image: 'test.jpg', - tasks: [], - ), - ], + catalogVersion: 'v1', + courses: [], ); blocTest( 'handles multiple rapid LoadLessons events correctly', build: () { - when(mockRepository.fetchLessons(forceRefresh: anyNamed('forceRefresh'))) - .thenAnswer((_) async => mockResponse); + when(mockRepository.fetchCatalog(forceRefresh: anyNamed('forceRefresh'))) + .thenAnswer((_) async => mockCatalog); return kyaBloc; }, act: (bloc) { @@ -228,14 +161,14 @@ void main() { }, expect: () => [ LessonsLoading(), - LessonsLoaded(mockResponse), + LessonsLoaded(mockCatalog), LessonsLoading(), - LessonsLoaded(mockResponse), + LessonsLoaded(mockCatalog), LessonsLoading(), - LessonsLoaded(mockResponse), + LessonsLoaded(mockCatalog), ], verify: (_) { - verify(mockRepository.fetchLessons(forceRefresh: false)).called(3); + verify(mockRepository.fetchCatalog(forceRefresh: false)).called(3); }, ); }); @@ -244,12 +177,13 @@ void main() { blocTest( 'maintains state consistency during error recovery', build: () { - when(mockRepository.fetchLessons(forceRefresh: anyNamed('forceRefresh'))) + when(mockRepository.fetchCatalog(forceRefresh: anyNamed('forceRefresh'))) .thenThrow(Exception('First call fails')); + when(mockRepository.getCachedCatalog()).thenAnswer((_) async => null); return kyaBloc; }, act: (bloc) { - bloc.add(LoadLessons()); // This will fail + bloc.add(LoadLessons()); }, expect: () => [ LessonsLoading(), @@ -260,77 +194,32 @@ void main() { blocTest( 'recovers from error on subsequent call', build: () { - when(mockRepository.fetchLessons(forceRefresh: anyNamed('forceRefresh'))) - .thenAnswer((_) async => LessonResponseModel( + when(mockRepository.fetchCatalog(forceRefresh: anyNamed('forceRefresh'))) + .thenAnswer((_) async => LearnV2CatalogResponse( success: true, - message: 'Recovery successful', - kyaLessons: [], + catalogVersion: 'v1', + courses: [], )); return kyaBloc; }, act: (bloc) { - bloc.add(LoadLessons()); // This will succeed - }, - expect: () => [ - LessonsLoading(), - isA() - .having((state) => state.model.message, 'message', 'Recovery successful'), - ], - ); - }); - - group('Edge cases', () { - blocTest( - 'handles null response gracefully', - build: () { - when(mockRepository.fetchLessons(forceRefresh: anyNamed('forceRefresh'))) - .thenAnswer((_) async => throw Exception('Null response')); - return kyaBloc; - }, - act: (bloc) => bloc.add(LoadLessons()), - expect: () => [ - LessonsLoading(), - isA(), - ], - ); - - blocTest( - 'handles lesson with missing required fields', - build: () { - final responseWithIncompleteLesson = LessonResponseModel( - success: true, - message: 'Success', - kyaLessons: [ - KyaLesson( - id: 'incomplete-lesson', - title: 'Incomplete Lesson', - completionMessage: '', // Empty completion message - image: '', // Empty image - tasks: [], // Empty tasks - ), - ], - ); - when(mockRepository.fetchLessons(forceRefresh: anyNamed('forceRefresh'))) - .thenAnswer((_) async => responseWithIncompleteLesson); - return kyaBloc; + bloc.add(LoadLessons()); }, - act: (bloc) => bloc.add(LoadLessons()), expect: () => [ LessonsLoading(), isA() - .having((state) => state.model.kyaLessons.length, 'lessons count', 1) - .having((state) => state.model.kyaLessons.first.title, 'lesson title', 'Incomplete Lesson') - .having((state) => state.model.kyaLessons.first.completionMessage, 'completion message', ''), + .having((state) => state.model.success, 'success', true), ], ); }); group('Error handling with cached data', () { blocTest( - 'emits LessonsLoadingError when fetch fails', + 'emits LessonsLoadingError when fetch fails and no cache', build: () { - when(mockRepository.fetchLessons(forceRefresh: anyNamed('forceRefresh'))) + when(mockRepository.fetchCatalog(forceRefresh: anyNamed('forceRefresh'))) .thenThrow(Exception('Network failed')); + when(mockRepository.getCachedCatalog()).thenAnswer((_) async => null); return kyaBloc; }, act: (bloc) => bloc.add(LoadLessons()), @@ -341,106 +230,5 @@ void main() { ], ); }); - - group('LoadLessons event variants', () { - blocTest( - 'handles LoadLessons with forceRefresh true', - build: () { - when(mockRepository.fetchLessons(forceRefresh: true)) - .thenAnswer((_) async => LessonResponseModel( - success: true, - message: 'Force refreshed', - kyaLessons: [], - )); - return kyaBloc; - }, - act: (bloc) => bloc.add(LoadLessons(forceRefresh: true)), - expect: () => [ - LessonsLoading(), - isA() - .having((state) => state.model.message, 'message', 'Force refreshed'), - ], - verify: (_) { - verify(mockRepository.fetchLessons(forceRefresh: true)).called(1); - verifyNever(mockRepository.fetchLessons(forceRefresh: false)); - }, - ); - - blocTest( - 'handles LoadLessons with forceRefresh false (default)', - build: () { - when(mockRepository.fetchLessons(forceRefresh: false)) - .thenAnswer((_) async => LessonResponseModel( - success: true, - message: 'Normal load', - kyaLessons: [], - )); - return kyaBloc; - }, - act: (bloc) => bloc.add(LoadLessons()), // Default forceRefresh is false - expect: () => [ - LessonsLoading(), - isA() - .having((state) => state.model.message, 'message', 'Normal load'), - ], - verify: (_) { - verify(mockRepository.fetchLessons(forceRefresh: false)).called(1); - }, - ); - }); - - group('Task model validation', () { - blocTest( - 'handles lessons with complex task structures', - build: () { - final complexResponse = LessonResponseModel( - success: true, - message: 'Complex lessons loaded', - kyaLessons: [ - KyaLesson( - id: 'complex-lesson', - title: 'Complex Lesson with Multiple Tasks', - completionMessage: 'In progress', - image: 'https://example.com/complex.jpg', - tasks: [ - Task( - id: 'task-1', - title: 'First Task', - content: 'Content for first task', - image: 'https://example.com/task1.jpg', - createdAt: DateTime.parse('2024-01-15T10:00:00Z'), - updatedAt: DateTime.parse('2024-01-15T11:00:00Z'), - v: 1, - kyaLesson: 'complex-lesson', - taskPosition: 1, - ), - Task( - id: 'task-2', - title: 'Second Task', - content: 'Content for second task', - image: 'https://example.com/task2.jpg', - createdAt: DateTime.parse('2024-01-15T12:00:00Z'), - updatedAt: DateTime.parse('2024-01-15T13:00:00Z'), - v: 2, - kyaLesson: 'complex-lesson', - taskPosition: 2, - ), - ], - ), - ], - ); - when(mockRepository.fetchLessons(forceRefresh: anyNamed('forceRefresh'))) - .thenAnswer((_) async => complexResponse); - return kyaBloc; - }, - act: (bloc) => bloc.add(LoadLessons()), - expect: () => [ - LessonsLoading(), - isA() - .having((state) => state.model.kyaLessons.length, 'lessons count', 1) - .having((state) => state.model.kyaLessons.first.tasks.length, 'tasks count', 2), - ], - ); - }); }); -} \ No newline at end of file +} diff --git a/src/mobile/test/widget_test.dart b/src/mobile/test/widget_test.dart index 3da0e64403..ef3c33b8da 100644 --- a/src/mobile/test/widget_test.dart +++ b/src/mobile/test/widget_test.dart @@ -3,7 +3,7 @@ import 'package:airqo/src/app/auth/repository/auth_repository.dart'; import 'package:airqo/src/app/auth/repository/social_auth_repository.dart'; import 'package:airqo/src/app/dashboard/repository/dashboard_repository.dart'; import 'package:airqo/src/app/dashboard/repository/forecast_repository.dart'; -import 'package:airqo/src/app/learn/repository/kya_repository.dart'; +import 'package:airqo/src/app/learn/repository/learn_repository.dart'; import 'package:airqo/src/app/map/repository/map_repository.dart'; import 'package:airqo/src/app/other/places/repository/google_places_repository.dart'; import 'package:airqo/src/app/other/theme/repository/theme_repository.dart'; @@ -132,7 +132,7 @@ void main() { authRepository: TestAuthRepository(), socialAuthRepository: TestSocialAuthRepository(), userRepository: UserImpl(), - kyaRepository: KyaImpl(), + kyaRepository: LearnRepositoryImpl(), themeRepository: ThemeImpl(), mapRepository: MapImpl(), forecastRepository: ForecastImpl(), @@ -158,7 +158,7 @@ void main() { authRepository: TestAuthRepository(), socialAuthRepository: TestSocialAuthRepository(), userRepository: UserImpl(), - kyaRepository: KyaImpl(), + kyaRepository: LearnRepositoryImpl(), themeRepository: ThemeImpl(), mapRepository: MapImpl(), forecastRepository: ForecastImpl(), @@ -168,7 +168,7 @@ void main() { expect(widget.authRepository, isA()); expect(widget.userRepository, isA()); - expect(widget.kyaRepository, isA()); + expect(widget.kyaRepository, isA()); expect(widget.themeRepository, isA()); expect(widget.mapRepository, isA()); expect(widget.forecastRepository, isA()); From 886e97af0a45bca39fc076ad3dd1044dcfc2b65a Mon Sep 17 00:00:00 2001 From: Mozart299 Date: Wed, 1 Jul 2026 22:19:49 +0300 Subject: [PATCH 6/9] Fix remaining flutter analyze errors Remove const from non-const ColorFilter.mode in analytics_specifics.dart. Drop redundant null-coalesce on non-nullable return in feature_flag_service.dart. --- .../lib/src/app/dashboard/widgets/analytics_specifics.dart | 2 +- .../lib/src/app/shared/services/feature_flag_service.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mobile/lib/src/app/dashboard/widgets/analytics_specifics.dart b/src/mobile/lib/src/app/dashboard/widgets/analytics_specifics.dart index b2ee3d9d83..bfd377f714 100644 --- a/src/mobile/lib/src/app/dashboard/widgets/analytics_specifics.dart +++ b/src/mobile/lib/src/app/dashboard/widgets/analytics_specifics.dart @@ -130,7 +130,7 @@ class _AnalyticsSpecificsState extends State { 'assets/icons/share-icon.svg', width: 18, height: 18, - colorFilter: const ColorFilter.mode( + colorFilter: ColorFilter.mode( AppColors.primaryColor, BlendMode.srcIn, ), diff --git a/src/mobile/lib/src/app/shared/services/feature_flag_service.dart b/src/mobile/lib/src/app/shared/services/feature_flag_service.dart index b5e4f2be56..74a37430e4 100644 --- a/src/mobile/lib/src/app/shared/services/feature_flag_service.dart +++ b/src/mobile/lib/src/app/shared/services/feature_flag_service.dart @@ -29,7 +29,7 @@ class FeatureFlagService with UiLoggy { try { await Posthog().reloadFeatureFlags(); for (final flag in AppFeatureFlag.values) { - _flags[flag] = await Posthog().isFeatureEnabled(flag.key) ?? false; + _flags[flag] = await Posthog().isFeatureEnabled(flag.key); } loggy.info('Feature flags reloaded: $_flags'); } catch (e, stackTrace) { From 68e1ffb426a1c1976c0c87722ebe71af9e06f414 Mon Sep 17 00:00:00 2001 From: Mozart299 Date: Wed, 1 Jul 2026 22:30:12 +0300 Subject: [PATCH 7/9] Fix SOLID violations in Learn v2 layer DIP: add isOffline to LearnRepository interface so KyaBloc no longer instantiates CacheManager directly. DRP: extract _fetchAndCache/_getToken helpers in LearnRepositoryImpl to eliminate duplicated token+request logic between fetchCatalog and _refreshInBackground. DIP: introduce abstract LearnSyncService interface; rename impl to _LearnSyncServiceImpl so callers depend on the abstraction. --- .../lib/src/app/learn/bloc/kya_bloc.dart | 8 +- .../learn/repository/learn_repository.dart | 104 +++++++----------- .../learn/services/learn_sync_service.dart | 27 ++++- 3 files changed, 68 insertions(+), 71 deletions(-) diff --git a/src/mobile/lib/src/app/learn/bloc/kya_bloc.dart b/src/mobile/lib/src/app/learn/bloc/kya_bloc.dart index 27b47f9685..db222f03c3 100644 --- a/src/mobile/lib/src/app/learn/bloc/kya_bloc.dart +++ b/src/mobile/lib/src/app/learn/bloc/kya_bloc.dart @@ -1,6 +1,5 @@ import 'package:airqo/src/app/learn/models/learn_v2_catalog.dart'; import 'package:airqo/src/app/learn/repository/learn_repository.dart'; -import 'package:airqo/src/app/shared/services/cache_manager.dart'; import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:loggy/loggy.dart'; @@ -10,7 +9,6 @@ part 'kya_state.dart'; class KyaBloc extends Bloc with UiLoggy { final LearnRepository repository; - final CacheManager _cacheManager = CacheManager(); KyaBloc(this.repository) : super(KyaInitial()) { on(_onLoadLessons); @@ -36,13 +34,13 @@ class KyaBloc extends Bloc with UiLoggy { emit(LessonsLoadingError( message: e.toString(), cachedModel: cachedModel, - isOffline: !_cacheManager.isConnected, + isOffline: repository.isOffline, )); } catch (cacheError) { loggy.error('Error fetching cached catalog: $cacheError'); emit(LessonsLoadingError( message: e.toString(), - isOffline: !_cacheManager.isConnected, + isOffline: repository.isOffline, )); } } @@ -69,7 +67,7 @@ class KyaBloc extends Bloc with UiLoggy { emit(LessonsLoadingError( message: e.toString(), cachedModel: cachedModel, - isOffline: !_cacheManager.isConnected, + isOffline: repository.isOffline, )); } } 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 89d304b06b..d38f082a2a 100644 --- a/src/mobile/lib/src/app/learn/repository/learn_repository.dart +++ b/src/mobile/lib/src/app/learn/repository/learn_repository.dart @@ -10,6 +10,7 @@ abstract class LearnRepository extends BaseRepository { Future fetchCatalog({bool forceRefresh = false}); Future getCachedCatalog(); Future clearCache(); + bool get isOffline; } class LearnRepositoryImpl extends LearnRepository with UiLoggy { @@ -20,6 +21,36 @@ class LearnRepositoryImpl extends LearnRepository with UiLoggy { final CacheManager _cacheManager = CacheManager(); static const String _catalogCacheKey = 'learn_v2_catalog'; + @override + bool get isOffline => !_cacheManager.isConnected; + + String? _getToken() => dotenv.env['AIRQO_API_TOKEN']; + + Future _fetchAndCache( + {Duration timeout = const Duration(seconds: 20)}) async { + final token = _getToken(); + if (token == null) throw StateError('AIRQO_API_TOKEN is not configured'); + + final response = await createGetRequest( + ApiUtils.learnCatalog, + {'token': token}, + ).timeout(timeout); + + if (response.statusCode != 200) { + throw Exception('Learn catalog API returned ${response.statusCode}'); + } + + final catalog = learnV2CatalogResponseFromJson(response.body); + await _cacheManager.put( + boxName: CacheBoxName.location, + key: _catalogCacheKey, + data: catalog, + toJson: (data) => jsonDecode(learnV2CatalogResponseToJson(data)), + etag: response.headers['etag'], + ); + return catalog; + } + @override Future fetchCatalog( {bool forceRefresh = false}) async { @@ -31,62 +62,29 @@ class LearnRepositoryImpl extends LearnRepository with UiLoggy { fromJson: (json) => learnV2CatalogResponseFromJson(jsonEncode(json)), ); - final refreshPolicy = RefreshPolicy( - wifiInterval: const Duration(days: 1), - mobileInterval: const Duration(days: 3), - ); - final shouldRefresh = _cacheManager.shouldRefresh( boxName: CacheBoxName.location, key: _catalogCacheKey, - policy: refreshPolicy, + policy: RefreshPolicy( + wifiInterval: const Duration(days: 1), + mobileInterval: const Duration(days: 3), + ), cachedData: cachedData, forceRefresh: forceRefresh, ); if (cachedData != null && !shouldRefresh) { loggy.info('Using cached Learn v2 catalog'); - if (_cacheManager.isConnected && !forceRefresh) { - _refreshInBackground(); - } + if (!isOffline && !forceRefresh) _refreshInBackground(); return cachedData.data; } - if (_cacheManager.isConnected) { + if (!isOffline) { try { loggy.info('Fetching fresh Learn v2 catalog from API'); - - final token = dotenv.env['AIRQO_API_TOKEN']; - if (token == null) { - loggy.error('AIRQO_API_TOKEN is not configured'); - if (cachedData != null) return cachedData.data; - throw StateError('AIRQO_API_TOKEN is not configured'); - } - final queryParams = {'token': token}; - - final response = - await createGetRequest(ApiUtils.learnCatalog, queryParams) - .timeout(const Duration(seconds: 20)); - - if (response.statusCode == 200) { - final catalog = learnV2CatalogResponseFromJson(response.body); - await _cacheManager.put( - boxName: CacheBoxName.location, - key: _catalogCacheKey, - data: catalog, - toJson: (data) => jsonDecode(learnV2CatalogResponseToJson(data)), - etag: response.headers['etag'], - ); - loggy.info( - 'Learn v2 catalog cached (${catalog.courses.length} courses)'); - return catalog; - } else { - loggy.warning( - 'Learn catalog API returned ${response.statusCode}'); - if (cachedData != null) return cachedData.data; - throw Exception( - 'Failed to fetch Learn catalog: ${response.statusCode}'); - } + final catalog = await _fetchAndCache(); + loggy.info('Learn v2 catalog cached (${catalog.courses.length} courses)'); + return catalog; } catch (e) { loggy.error('Error fetching Learn catalog: $e'); if (cachedData != null) { @@ -123,26 +121,8 @@ class LearnRepositoryImpl extends LearnRepository with UiLoggy { Future _refreshInBackground() async { try { loggy.info('Background refresh of Learn v2 catalog'); - final token = dotenv.env['AIRQO_API_TOKEN']; - if (token == null) { - loggy.error('AIRQO_API_TOKEN is not configured for background refresh'); - return; - } - final queryParams = {'token': token}; - final response = - await createGetRequest(ApiUtils.learnCatalog, queryParams) - .timeout(const Duration(seconds: 30)); - if (response.statusCode == 200) { - final catalog = learnV2CatalogResponseFromJson(response.body); - await _cacheManager.put( - boxName: CacheBoxName.location, - key: _catalogCacheKey, - data: catalog, - toJson: (data) => jsonDecode(learnV2CatalogResponseToJson(data)), - etag: response.headers['etag'], - ); - loggy.info('Background Learn catalog refresh done'); - } + await _fetchAndCache(timeout: const Duration(seconds: 30)); + loggy.info('Background Learn catalog refresh done'); } catch (e) { loggy.error('Background Learn catalog refresh failed: $e'); } 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 6be5c22c7a..00fe5bed3a 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 @@ -62,12 +62,27 @@ class _ProgressBuffer with UiLoggy { } // --------------------------------------------------------------------------- -// Public service — three distinct concerns composed here. +// Public interface — callers depend on this, not on the impl. // --------------------------------------------------------------------------- -class LearnSyncService extends BaseRepository with UiLoggy { - static final LearnSyncService instance = LearnSyncService._(); - LearnSyncService._(); +abstract class LearnSyncService { + static final LearnSyncService instance = _LearnSyncServiceImpl._(); + + Future ensureGuestSession(); + Future linkProgressToAccount(String authToken); + Future reportCompletion( + String lessonId, { + required int totalActivities, + List quizAttempts, + String? freeTextResponse, + }); + Future syncPendingProgress(); +} + +class _LearnSyncServiceImpl extends BaseRepository + with UiLoggy + implements LearnSyncService { + _LearnSyncServiceImpl._(); static const _guestIdKey = 'learn_guest_id'; @@ -94,6 +109,7 @@ class LearnSyncService extends BaseRepository with UiLoggy { // ---- Guest session management ------------------------------------------ + @override Future ensureGuestSession() async { await _ensurePrefs(); if (_prefs!.getString(_guestIdKey) != null) return; @@ -125,6 +141,7 @@ class LearnSyncService extends BaseRepository with UiLoggy { } } + @override Future linkProgressToAccount(String authToken) async { await _ensurePrefs(); final guestId = _prefs!.getString(_guestIdKey); @@ -156,6 +173,7 @@ class LearnSyncService extends BaseRepository with UiLoggy { // ---- Progress reporting ------------------------------------------------- + @override Future reportCompletion( String lessonId, { required int totalActivities, @@ -196,6 +214,7 @@ class LearnSyncService extends BaseRepository with UiLoggy { // ---- Offline sync ------------------------------------------------------- + @override Future syncPendingProgress() async { await _ensurePrefs(); final pending = _buffer!.drain(); From 83b0c188b11aab7a84a20c807d55d06c3a747e68 Mon Sep 17 00:00:00 2001 From: Peter Kyeyune Date: Wed, 1 Jul 2026 23:24:31 +0300 Subject: [PATCH 8/9] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../lib/src/app/shared/services/feature_flag_service.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mobile/lib/src/app/shared/services/feature_flag_service.dart b/src/mobile/lib/src/app/shared/services/feature_flag_service.dart index 74a37430e4..b5e4f2be56 100644 --- a/src/mobile/lib/src/app/shared/services/feature_flag_service.dart +++ b/src/mobile/lib/src/app/shared/services/feature_flag_service.dart @@ -29,7 +29,7 @@ class FeatureFlagService with UiLoggy { try { await Posthog().reloadFeatureFlags(); for (final flag in AppFeatureFlag.values) { - _flags[flag] = await Posthog().isFeatureEnabled(flag.key); + _flags[flag] = await Posthog().isFeatureEnabled(flag.key) ?? false; } loggy.info('Feature flags reloaded: $_flags'); } catch (e, stackTrace) { From 4d31bf0081e5a25af00c96cba7b7d57326e7eb97 Mon Sep 17 00:00:00 2001 From: Peter Kyeyune Date: Wed, 1 Jul 2026 23:25:15 +0300 Subject: [PATCH 9/9] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/mobile/lib/src/app/learn/services/learn_sync_service.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 00fe5bed3a..768c68a18a 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 @@ -53,7 +53,9 @@ class _ProgressBuffer with UiLoggy { if (raw == null) return null; try { return json.decode(raw) as List; - } catch (_) { + } catch (e) { + loggy.warning('Corrupt Learn progress buffer, clearing: $e'); + _prefs.remove(_key); return null; } }