diff --git a/src/mobile/lib/main.dart b/src/mobile/lib/main.dart index 756bd36b96..2107b14b1c 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(); @@ -309,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/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/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/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/bloc/kya_bloc.dart b/src/mobile/lib/src/app/learn/bloc/kya_bloc.dart index 68a13c470f..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/lesson_response_model.dart'; -import 'package:airqo/src/app/learn/repository/kya_repository.dart'; -import 'package:airqo/src/app/shared/services/cache_manager.dart'; +import 'package:airqo/src/app/learn/models/learn_v2_catalog.dart'; +import 'package:airqo/src/app/learn/repository/learn_repository.dart'; import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:loggy/loggy.dart'; @@ -9,39 +8,39 @@ part 'kya_event.dart'; part 'kya_state.dart'; class KyaBloc extends Bloc with UiLoggy { - final KyaRepository repository; - final CacheManager _cacheManager = CacheManager(); + final LearnRepository repository; KyaBloc(this.repository) : super(KyaInitial()) { on(_onLoadLessons); 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(), cachedModel: cachedModel, - isOffline: !_cacheManager.isConnected, + isOffline: repository.isOffline, )); } catch (cacheError) { - loggy.error('Error fetching cached lessons: $cacheError'); + loggy.error('Error fetching cached catalog: $cacheError'); emit(LessonsLoadingError( message: e.toString(), - isOffline: !_cacheManager.isConnected, + isOffline: repository.isOffline, )); } } @@ -52,34 +51,33 @@ 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(), cachedModel: cachedModel, - isOffline: !_cacheManager.isConnected, + isOffline: repository.isOffline, )); } } } - Future _getCachedLessonsData() async { + Future _getCachedCatalog() async { try { - return await (repository as KyaImpl).getCachedLessonsData(); + return await repository.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..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,25 +1,23 @@ 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/models/learn_v2_catalog.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 => apiLesson != null && apiLesson!.tasks.isNotEmpty; + bool get hasContent => v2Lesson != null && v2Lesson!.activities.isNotEmpty; - /// Scripted demo flow activity count. - int get activityCount => LearnLessonExperienceService.demoActivityCount; + int get activityCount => v2Lesson?.activities.length ?? 0; } class LearnUnitViewModel { @@ -53,6 +51,7 @@ class LearnCourseViewModel { final int courseNumber; final String title; final String plainTitleKey; + final String? coverImageUrl; final List units; const LearnCourseViewModel({ @@ -60,11 +59,11 @@ class LearnCourseViewModel { required this.courseNumber, required this.title, required this.plainTitleKey, + this.coverImageUrl, 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; @@ -86,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), @@ -96,180 +94,41 @@ 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, + 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(); + } - 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]); - }), - ); - }); + static String? _deriveCoverImage(LearnV2Course course) { + for (final unit in course.units) { + for (final lesson in unit.lessons) { + if (lesson.coverImageUrl != null) return lesson.coverImageUrl; + } } - - 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), - ), - ]; + return null; } static bool isCourseUnlocked( @@ -377,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/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/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/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 6daef5c041..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,5 +1,5 @@ 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'; @@ -64,15 +64,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.ensurePilotLearnDemosV3(courses: courses); + + _progress.clearPilotSeedIfNeeded(); } @override @@ -155,20 +152,24 @@ 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, + LessonsRefreshing s => s.currentModel, + _ => 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); }); } @@ -218,10 +219,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/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 b6baefaa1a..0000000000 --- a/src/mobile/lib/src/app/learn/repository/kya_repository.dart +++ /dev/null @@ -1,201 +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:airqo/src/meta/utils/api_utils.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'; - - @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(ApiUtils.fetchLessons, {"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(ApiUtils.fetchLessons, {"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/repository/learn_repository.dart b/src/mobile/lib/src/app/learn/repository/learn_repository.dart new file mode 100644 index 0000000000..d38f082a2a --- /dev/null +++ b/src/mobile/lib/src/app/learn/repository/learn_repository.dart @@ -0,0 +1,138 @@ +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 getCachedCatalog(); + Future clearCache(); + bool get isOffline; +} + +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 + 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 { + loggy.info('Fetching Learn v2 catalog (forceRefresh: $forceRefresh)'); + + final cachedData = await _cacheManager.get( + boxName: CacheBoxName.location, + key: _catalogCacheKey, + fromJson: (json) => learnV2CatalogResponseFromJson(jsonEncode(json)), + ); + + final shouldRefresh = _cacheManager.shouldRefresh( + boxName: CacheBoxName.location, + key: _catalogCacheKey, + 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 (!isOffline && !forceRefresh) _refreshInBackground(); + return cachedData.data; + } + + if (!isOffline) { + try { + loggy.info('Fetching fresh Learn v2 catalog from API'); + 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) { + 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.'); + } + } + + @override + 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'); + await _fetchAndCache(timeout: const Duration(seconds: 30)); + 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..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,133 +1,106 @@ -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/lesson_response_model.dart'; +import 'package:airqo/src/app/learn/models/learn_v2_catalog.dart'; class LearnLessonExperienceService { const LearnLessonExperienceService._(); - static const demoActivityCount = 7; + 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 const _demoVideoUrl = - 'https://www.youtube.com/watch?v=U5F-F2AHH7s'; + static int? _toInt(dynamic v) => + v is int ? v : (v is String ? int.tryParse(v) : null); - 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); + static LearnQuizPayload? _parseV2Quiz(Map p) { + final question = p['question'] as String? ?? ''; + if (question.isEmpty) return null; + + final format = LearnQuizFormat.fromApiKey(p['format'] as String? ?? ''); + final rawOptions = p['options'] as List? ?? []; + final options = rawOptions.map((o) => o.toString()).toList(); + final correctIndex = _toInt(p['correct_index']); + final rawIndices = p['correct_indices'] as List?; + final correctIndices = rawIndices?.map(_toInt).whereType().toSet(); + final rawOrder = p['correct_order'] as List?; + final correctOrder = rawOrder?.map(_toInt).whereType().toList(); - 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.', - ), - ), - ]; + 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 String activityTypeKey(LearnLessonActivity activity) { @@ -183,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 c9e943754e..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 @@ -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,18 @@ 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(); + } + bool hasShownCourseDemo(String courseId) { return _prefs?.getBool('$_courseDemoKey$courseId') ?? false; } @@ -118,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) => @@ -193,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/services/learn_sync_service.dart b/src/mobile/lib/src/app/learn/services/learn_sync_service.dart new file mode 100644 index 0000000000..768c68a18a --- /dev/null +++ b/src/mobile/lib/src/app/learn/services/learn_sync_service.dart @@ -0,0 +1,241 @@ +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, + }; +} + +// --------------------------------------------------------------------------- +// 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 (e) { + loggy.warning('Corrupt Learn progress buffer, clearing: $e'); + _prefs.remove(_key); + return null; + } + } + + Future clear() => _prefs.remove(_key); +} + +// --------------------------------------------------------------------------- +// Public interface — callers depend on this, not on the impl. +// --------------------------------------------------------------------------- + +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'; + + SharedPreferences? _prefs; + _ProgressBuffer? _buffer; + + Future _ensurePrefs() async { + if (_prefs != null) return; + _prefs = await SharedPreferences.getInstance(); + _buffer = _ProgressBuffer(_prefs!); + } + + Future> _guestHeaders() async { + final deviceId = await DeviceIdManager.getDeviceId(); + final guestId = _prefs?.getString(_guestIdKey); + return { + 'Content-Type': 'application/json', + 'Accept': '*/*', + 'X-Device-Id': deviceId, + if (guestId != null) 'X-Guest-Id': guestId, + 'User-Agent': ApiUtils.mobileUserAgent, + }; + } + + // ---- Guest session management ------------------------------------------ + + @override + 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'); + } + } + } catch (e) { + loggy.warning('Failed to create guest Learn session: $e'); + } + } + + @override + 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 syncPendingProgress(); + 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 ------------------------------------------------- + + @override + 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}/${Uri.encodeComponent(lessonId)}'), + body: json.encode(body), + headers: headers, + ).timeout(const Duration(seconds: 10)); + + if (response.statusCode >= 200 && response.statusCode < 300) { + loggy.info('Reported completion for lesson $lessonId'); + } else { + loggy.warning( + 'Progress report failed (${response.statusCode}), buffering'); + await _buffer!.append(lessonId, body); + } + } catch (e) { + loggy.warning('Progress report error: $e — buffering'); + await _buffer!.append(lessonId, body); + } + } + + // ---- Offline sync ------------------------------------------------------- + + @override + Future syncPendingProgress() async { + await _ensurePrefs(); + final pending = _buffer!.drain(); + if (pending == null || pending.isEmpty) return; + + try { + final headers = await _guestHeaders(); + final response = await http.post( + Uri.parse('${ApiUtils.baseUrl}${ApiUtils.learnProgressSync}'), + body: json.encode({'progress': pending}), + headers: headers, + ).timeout(const Duration(seconds: 15)); + + if (response.statusCode >= 200 && response.statusCode < 300) { + await _buffer!.clear(); + loggy.info('Synced ${pending.length} pending Learn progress entries'); + } + } catch (e) { + loggy.warning('Pending Learn progress sync failed: $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 1661e441fb..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,10 +2,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_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'; +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'; @@ -16,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; @@ -39,7 +38,6 @@ class LearnLessonExperience extends StatefulWidget { required this.lessonsInUnit, required this.onClose, required this.completionContext, - this.apiLesson, this.allCourses, this.continuation, }); @@ -53,34 +51,25 @@ class _LearnLessonExperienceState extends State { late int _activityIndex; final _progress = LearnProgressService.instance; final List _gradedResults = []; + final List _quizAttempts = []; String? _freeTextResponse; LearnLessonResult? _result; - bool _presentingCompletion = false; @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, - ); - final saved = _progress.furthestStep(widget.slot.progressKey); - _activityIndex = saved.clamp(0, _script.length - 1); + _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)) { - _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 = _script.isEmpty ? 0 : saved.clamp(0, _script.length - 1); } } @@ -117,6 +106,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(); } @@ -160,6 +155,11 @@ 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: _current.quiz!.format.apiKey, + isCorrect: grade.isCorrect, + )); } Widget _buildActivityBody() { @@ -200,12 +200,11 @@ class _LearnLessonExperienceState extends State { @override Widget build(BuildContext context) { - if (_presentingCompletion) { - return const SizedBox.shrink(); - } + if (_script.isEmpty) return const SizedBox.shrink(); - final lessonTitle = - learnDisplayTitle(widget.apiLesson?.title ?? widget.slot.plainTitleKey); + final lessonTitle = learnDisplayTitle( + widget.slot.v2Lesson?.title ?? widget.slot.plainTitleKey, + ); return SizedBox.expand( child: LearnExperienceShell( 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/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; 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"; 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());