Skip to content

feat(mobile): Learn tab v2, forecast details sheet, and UI polish#3664

Merged
Baalmart merged 7 commits into
stagingfrom
feature/forecast-details-sheet
Jun 17, 2026
Merged

feat(mobile): Learn tab v2, forecast details sheet, and UI polish#3664
Baalmart merged 7 commits into
stagingfrom
feature/forecast-details-sheet

Conversation

@2phonebabykeem

@2phonebabykeem 2phonebabykeem commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Rebuilds the Learn tab with scripted lesson activities (article, video, image, quiz types), course detail sheets, progress persistence, and completion flows
  • Replaces the two-step analytics flow with a single-tap forecast details sheet (daily/hourly scope, API guidance, met row, day/hour selectors)
  • Adds theme-aware surface/text tokens and dashboard UI polish (app bar icons, AirQo location pins, precise location styling, inactive chip contrast)

Test plan

  • Hot restart mobile app and open Learn tab — browse courses, start a lesson, complete quiz activities
  • Tap a dashboard air quality card — verify forecast modal opens with Daily/Hourly tabs, day chips, guidance panel
  • Toggle dark mode — check app bar icons, location line on cards, and inactive time scope chip styling
  • Verify location pin icons across dashboard cards, survey card, and location list
  • Run dart analyze under src/mobile

Summary by CodeRabbit

Release Notes

  • New Features

    • Forecasts now open in a draggable daily/hourly bottom-sheet with guidance and an hour picker.
    • Learn now has a course catalog with structured lessons, multiple quiz types (including free-text), progression, and completion certificates with sharing.
    • Surveys gained a refreshed list experience with a contribution/impact section.
  • UI & Design

    • Improved theme-aware styling across cards/sheets.
    • Replaced location icons with consistent SVG pins and updated AQI/category visuals.
    • Added richer forecast “MET” presentation and inset panel styling.
  • Updates

    • Enhanced learning media support (TTS, video, screenshots) and added forecast image assets.

2phonebabykeem and others added 2 commits June 17, 2026 20:12
Expand forecast overview with day/hourly detail, guidance, time scope, and shared AQI chip; wire analytics and map entry points.

Co-authored-by: Cursor <cursoragent@cursor.com>
Combine the Learn lesson experience rebuild, single-tap forecast modal with
daily/hourly guidance, theme-aware surfaces, and dashboard color updates
(location pins, app bar icons, precise location styling).

Co-authored-by: Cursor <cursoragent@cursor.com>
@coderabbitai

coderabbitai Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0fced38f-09ca-42d3-8c24-c76d06d83b20

📥 Commits

Reviewing files that changed from the base of the PR and between 2399444 and d39e47b.

⛔ Files ignored due to path filters (1)
  • src/mobile/pubspec.lock is excluded by !**/*.lock
📒 Files selected for processing (7)
  • src/mobile/lib/src/app/dashboard/pages/forecast_overview_page.dart
  • src/mobile/lib/src/app/dashboard/widgets/analytics_specifics.dart
  • src/mobile/lib/src/app/learn/pages/learn_course_detail_page.dart
  • src/mobile/lib/src/app/learn/services/learn_quiz_scoring_service.dart
  • src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_option.dart
  • src/mobile/lib/src/app/learn/widgets/learn_bottom_sheets.dart
  • src/mobile/pubspec.yaml
🚧 Files skipped from review as they are similar to previous changes (5)
  • src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_option.dart
  • src/mobile/pubspec.yaml
  • src/mobile/lib/src/app/dashboard/widgets/analytics_specifics.dart
  • src/mobile/lib/src/app/learn/widgets/learn_bottom_sheets.dart
  • src/mobile/lib/src/app/dashboard/pages/forecast_overview_page.dart

📝 Walkthrough

Walkthrough

This PR adds a draggable forecast sheet with daily and hourly views, introduces a Learn catalog with persisted progress and bottom-sheet lesson flow, and extracts survey list rendering into a reusable widget while updating shared styling and SVG-based icons.

Changes

Forecast modal and dashboard styling

Layer / File(s) Summary
Shared forecast helpers
src/mobile/lib/src/meta/utils/colors.dart, src/mobile/lib/src/meta/utils/forecast_utils.dart, src/mobile/lib/src/app/dashboard/utils/measurement_location_utils.dart, src/mobile/lib/src/app/dashboard/utils/forecast_met_icons.dart, src/mobile/lib/src/app/shared/widgets/aqi_category_chip.dart, src/mobile/pubspec.yaml
Adds shared AQI, location, surface, and asset helpers for forecast display and styling.
Forecast models and guidance
src/mobile/lib/src/app/dashboard/models/forecast_guidance.dart
Adds forecast guidance, reading snapshots, hourly filtering, and initial hourly index selection.
Forecast sheet and panels
src/mobile/lib/src/app/dashboard/pages/forecast_overview_page.dart, src/mobile/lib/src/app/dashboard/widgets/forecast_*
Refactors the forecast overview sheet, daily/hourly selectors, guidance panel, detail card, hourly chips, and MET rows around the new snapshot data.
Forecast entry points
src/mobile/lib/src/app/dashboard/widgets/analytics_*, src/mobile/lib/src/app/dashboard/widgets/nearby_measurement_card.dart, src/mobile/lib/src/app/dashboard/pages/location_selection/..., src/mobile/lib/src/app/map/..., src/mobile/lib/src/app/exposure/widgets/declared_place_card.dart
Routes forecast access through ForecastOverviewPage and updates related cards, icons, dividers, and sheet decorations to the shared styling helpers.

Learn catalog and lesson experience

Layer / File(s) Summary
Learn contracts and tokens
src/mobile/lib/src/app/learn/formatting/*, src/mobile/lib/src/app/learn/models/*, src/mobile/lib/src/app/learn/services/*, src/mobile/lib/src/app/learn/theme/*, src/mobile/lib/src/app/learn/widgets/learn_sheet_button_styles.dart
Adds Learn catalog/view-model types, lesson activity models, continuation metadata, persisted progress, quiz scoring, display formatting helpers, design tokens, and shared button styles.
Learn dashboard and course navigation
src/mobile/lib/src/app/learn/pages/kya_page.dart, src/mobile/lib/src/app/learn/pages/learn_course_detail_page.dart, src/mobile/lib/src/app/learn/widgets/learn_*, src/mobile/lib/src/app/learn/widgets/kya_lesson_container.dart
Rebuilds the Learn landing page around course cards and progress, adds course detail sheets with unit and lesson lists, and routes lesson entry through Learn bottom sheets.
Lesson sheets and shared shells
src/mobile/lib/src/app/learn/pages/lesson_page.dart, src/mobile/lib/src/app/learn/widgets/learn_bottom_sheets.dart, src/mobile/lib/src/app/learn/widgets/learn_completion_sheet.dart, src/mobile/lib/src/app/learn/widgets/experience/learn_experience_shell.dart, src/mobile/lib/src/app/learn/widgets/learn_lesson_*
Turns LessonPage into a host for lesson experience, adds modal sheet helpers and completion layouts, and introduces shared lesson banners, shells, image, and confetti widgets.
Interactive lesson activities
src/mobile/lib/src/app/learn/widgets/experience/*, src/mobile/lib/src/app/exposure/widgets/exposure_place_name_text_field.dart
Adds article, image, video, and quiz activity widgets, tracks lesson progression and results, and shows lesson, level, and course completion panes including certificate sharing.

Survey list extraction and Learn survey tab refresh

Layer / File(s) Summary
Survey list content component
src/mobile/lib/src/app/surveys/pages/survey_list_page.dart, src/mobile/lib/src/app/surveys/widgets/survey_list_content.dart
Moves survey loading and loaded/error/empty rendering into SurveyListContent while SurveyListPage becomes a wrapper with the existing refresh action.
Survey card and Learn survey styling
src/mobile/lib/src/app/learn/pages/learn_surveys_page.dart, src/mobile/lib/src/app/surveys/widgets/survey_card.dart
Updates Learn survey-tab styling to use shared colors and buttons, and renders a location SVG icon for location-based survey triggers.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant ForecastCard
  participant ForecastOverviewPage
  participant ForecastBloc
  participant ForecastWidgets
  User->>ForecastCard: open forecast
  ForecastCard->>ForecastOverviewPage: showForMeasurement / show
  ForecastOverviewPage->>ForecastBloc: load forecast state
  ForecastBloc-->>ForecastOverviewPage: daily and hourly data
  ForecastOverviewPage->>ForecastWidgets: render daily or hourly view
Loading
sequenceDiagram
  participant User
  participant KyaPage
  participant LearnBottomSheets
  participant LearnLessonExperience
  participant LearnProgressService
  User->>KyaPage: open course and lesson
  KyaPage->>LearnBottomSheets: showCourseDetail
  LearnBottomSheets->>LearnLessonExperience: showLessonExperience
  LearnLessonExperience->>LearnProgressService: restore and record progress
  LearnLessonExperience->>LearnBottomSheets: show completion sheet
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Poem

Forecast sheets now glide in calm array,
Learn charts a path for each new day.
Surveys got a cleaner, tidier face,
and SVG pins found their place.
Progress, sheets, and quizzes sing —
small UI threads, one broader ring.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/forecast-details-sheet

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a major mobile UX update by rebuilding the Learn tab into a scripted, progress-aware lesson experience and replacing the analytics details flow with a forecast details sheet, alongside theme-aware color/surface tokens and icon/UI polish.

Changes:

  • Introduces Learn tab v2 UI components, lesson experience activities (article/video/image/quiz), and local progress persistence + completion flows.
  • Replaces multi-step analytics details with a single-tap forecast overview/details sheet (daily/hourly scope, selectors, guidance, met row).
  • Adds theme-aware surface/text tokens and updates various cards/icons for consistent styling (including new forecast SVG assets).

Reviewed changes

Copilot reviewed 73 out of 79 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/mobile/pubspec.yaml Adds TTS/video/share/screenshot deps and forecast assets folder
src/mobile/lib/src/meta/utils/forecast_utils.dart Canonical AQI category color mapping + normalization
src/mobile/lib/src/meta/utils/colors.dart Adds theme-aware text/surface token helpers; tweaks dark theme text/dividers
src/mobile/lib/src/app/surveys/widgets/survey_card.dart Uses SVG location pin for location-based surveys
src/mobile/lib/src/app/shared/widgets/aqi_category_chip.dart New AQI category chip using app palette
src/mobile/lib/src/app/map/widgets/map_air_quality_card.dart Uses shared elevated card decoration
src/mobile/lib/src/app/map/pages/map_page.dart Opens forecast overview sheet instead of analytics details
src/mobile/lib/src/app/learn/widgets/learn_unit_chip.dart New unit chip UI + scrolling chip row
src/mobile/lib/src/app/learn/widgets/learn_sheet_button_styles.dart Centralizes Learn sheet CTA button styles
src/mobile/lib/src/app/learn/widgets/learn_level_summary_card.dart Adds level summary/progress card with expandable stages
src/mobile/lib/src/app/learn/widgets/learn_lesson_thumbnail.dart Adds gradient lesson thumbnails + experience banner
src/mobile/lib/src/app/learn/widgets/learn_lesson_list_row.dart Adds Exposure-style lesson rows w/ lock/progress states
src/mobile/lib/src/app/learn/widgets/learn_lesson_image.dart Simple network image widget with placeholders/errors
src/mobile/lib/src/app/learn/widgets/learn_lesson_confetti.dart Adds lightweight confetti animation
src/mobile/lib/src/app/learn/widgets/learn_lesson_activities.dart Adds generic “step shell” and notes/image activity cards
src/mobile/lib/src/app/learn/widgets/learn_dashboard_header.dart New Learn tab header copy + styling
src/mobile/lib/src/app/learn/widgets/learn_course_portrait_card.dart New course portrait card w/ progress and lock blur
src/mobile/lib/src/app/learn/widgets/learn_completion_sheet.dart Standardizes Learn completion sheet shells/bodies
src/mobile/lib/src/app/learn/widgets/kya_lesson_container.dart Updates lesson card UI and opens lesson via bottom sheet
src/mobile/lib/src/app/learn/widgets/experience/learn_video_activity.dart Adds video activity (YouTube iframe + direct video player)
src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_single_choice.dart Adds single-choice quiz activity UI
src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_ranking.dart Adds ranking quiz activity UI (reorderable list)
src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_option.dart Adds reusable quiz option tile + feedback banner
src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_multi_choice.dart Adds multi-choice quiz activity UI
src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_free_text.dart Adds free-text quiz activity UI
src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_activity.dart Routes quiz payload to the correct quiz activity widget
src/mobile/lib/src/app/learn/widgets/experience/learn_level_unlock_pane.dart Adds “level unlocked” pane UI
src/mobile/lib/src/app/learn/widgets/experience/learn_lesson_finish_pane.dart Adds lesson completion pane UI + continuation CTA
src/mobile/lib/src/app/learn/widgets/experience/learn_lesson_experience.dart Orchestrates scripted lesson activities + completion flow
src/mobile/lib/src/app/learn/widgets/experience/learn_image_activity.dart Adds image activity UI for lesson experience
src/mobile/lib/src/app/learn/widgets/experience/learn_experience_shell.dart Shared experience shell + bottom bar + activity card shell
src/mobile/lib/src/app/learn/widgets/experience/learn_course_certificate.dart Adds certificate capture + share flow
src/mobile/lib/src/app/learn/widgets/experience/learn_article_audio_player.dart Adds article TTS audio control tray UI
src/mobile/lib/src/app/learn/widgets/experience/learn_article_activity.dart Adds article activity w/ TTS and word highlighting
src/mobile/lib/src/app/learn/theme/learn_design_tokens.dart Adds Learn-specific design tokens aligned with new surfaces/text
src/mobile/lib/src/app/learn/services/learn_quiz_scoring_service.dart Adds quiz grading + star/points computation
src/mobile/lib/src/app/learn/services/learn_progress_service.dart Adds SharedPreferences persistence for Learn progress/results
src/mobile/lib/src/app/learn/services/learn_lesson_experience_service.dart Builds demo lesson scripts + activity labels/helpers
src/mobile/lib/src/app/learn/pages/lesson_page.dart Refactors LessonPage to host LearnLessonExperience
src/mobile/lib/src/app/learn/pages/learn_surveys_page.dart Restyles Learn surveys list + retry button styling
src/mobile/lib/src/app/learn/pages/learn_course_detail_page.dart Adds course detail sheet w/ unit chips and lesson list
src/mobile/lib/src/app/learn/models/learn_quiz_type.dart Adds quiz type enum (new model)
src/mobile/lib/src/app/learn/models/learn_lesson_continuation.dart Adds next-lesson continuation model
src/mobile/lib/src/app/learn/models/learn_lesson_activity.dart Adds activity/quiz payload models + result/grade types
src/mobile/lib/src/app/learn/models/learn_activity_kind.dart Adds activity kind enum (new model)
src/mobile/lib/src/app/learn/formatting/learn_display_text.dart Adds display formatting helpers for Learn UI strings
src/mobile/lib/src/app/exposure/widgets/exposure_place_name_text_field.dart Adds multi-line + enabled support for reuse in free-text quiz
src/mobile/lib/src/app/exposure/widgets/declared_place_card.dart Uses shared surface tokens for nested/border/elevation
src/mobile/lib/src/app/dashboard/widgets/unmatched_site_card.dart Uses SVG location pin icon
src/mobile/lib/src/app/dashboard/widgets/nearby_measurement_card.dart Opens forecast sheet; uses app AQI palette + surface tokens
src/mobile/lib/src/app/dashboard/widgets/forecast_time_scope_selector.dart Adds daily/hourly scope selector pill UI
src/mobile/lib/src/app/dashboard/widgets/forecast_met_row.dart Adds met SVG icons + inset panel styling
src/mobile/lib/src/app/dashboard/widgets/forecast_hourly_section.dart Adds selectable hourly chips + inset panel styling
src/mobile/lib/src/app/dashboard/widgets/forecast_guidance_section.dart Adds guidance panel widget
src/mobile/lib/src/app/dashboard/widgets/forecast_day_selector.dart Updates day chips for inset/dark mode styling
src/mobile/lib/src/app/dashboard/widgets/forecast_day_detail_card.dart Refactors to shared snapshot model + met/confidence UI
src/mobile/lib/src/app/dashboard/widgets/expanded_analytics_card.dart Uses app AQI palette + shared surface/divider styling
src/mobile/lib/src/app/dashboard/widgets/dashboard_app_bar.dart Theme-aware app bar icon backgrounds + recolored SVG icons
src/mobile/lib/src/app/dashboard/widgets/analytics_specifics.dart Updates location pin and opens forecast overview via helper utils
src/mobile/lib/src/app/dashboard/widgets/analytics_details.dart Uses shared sheet decoration token
src/mobile/lib/src/app/dashboard/widgets/analytics_card.dart Opens forecast sheet; uses app AQI palette + surface tokens
src/mobile/lib/src/app/dashboard/utils/measurement_location_utils.dart Adds helpers for consistent measurement display/description
src/mobile/lib/src/app/dashboard/utils/forecast_met_icons.dart Maps met icon asset paths for forecast tiles
src/mobile/lib/src/app/dashboard/pages/location_selection/components/swipeable_analytics_card.dart Opens forecast sheet; uses app AQI palette + surface tokens
src/mobile/lib/src/app/dashboard/pages/location_selection/components/location_list_view.dart Uses SVG location pin icon
src/mobile/lib/src/app/dashboard/models/forecast_guidance.dart Adds guidance + forecast snapshot helpers + hourly index selection
src/mobile/assets/images/forecast/wind.svg Adds forecast met icon asset
src/mobile/assets/images/forecast/thermometer.svg Adds forecast met icon asset
src/mobile/assets/images/forecast/rain.svg Adds forecast met icon asset
src/mobile/assets/images/forecast/droplets.svg Adds forecast met icon asset
src/mobile/assets/images/dashboard/sun_icon.svg Adds dashboard icon asset
src/mobile/android/app/build.gradle Raises Android minSdkVersion to 24

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/mobile/lib/src/app/learn/widgets/learn_unit_chip.dart
Comment on lines +1 to +3
import 'package:airqo/src/app/learn/theme/learn_design_tokens.dart';
import 'package:airqo/src/meta/utils/colors.dart';
import 'package:flutter/material.dart';
Comment on lines +110 to +122
border: Border.all(
color: selected || (revealed && isCorrectOption)
? (isCorrectOption || selected
? LearnDesignTokens.success
: LearnDesignTokens.error)
: Theme.of(context).dividerColor,
width: 2,
),
color: selected || (revealed && isCorrectOption)
? (isCorrectOption
? LearnDesignTokens.success
: LearnDesignTokens.error)
: Colors.transparent,
Comment on lines +139 to +147
border: Border.all(
color: selected || (revealed && isCorrectOption)
? LearnDesignTokens.success
: Theme.of(context).dividerColor,
width: 2,
),
color: selected || (revealed && isCorrectOption)
? LearnDesignTokens.success
: Colors.transparent,
Comment on lines +82 to +86
final stars = graded >= totalGraded && totalGraded > 0
? 3
: graded >= (totalGraded / 2).ceil()
? 2
: 1;

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 14

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/mobile/lib/src/app/map/pages/map_page.dart (1)

123-137: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Prevent stacked forecast sheets on rapid repeated taps.

This flow schedules delayed modal opens without an in-flight guard; quick repeated taps can queue multiple showForMeasurement calls and stack sheets.

Suggested fix
+ bool _isOpeningForecast = false;
+
- void _showForecastModal(Measurement measurement) {
+ Future<void> _showForecastModal(Measurement measurement) async {
+   if (_isOpeningForecast) return;
+   _isOpeningForecast = true;
    _hideTextInputOverlay();
-   WidgetsBinding.instance.addPostFrameCallback((_) {
-     if (!mounted) return;
-     _hideTextInputOverlay();
-
-     Future<void>.delayed(const Duration(milliseconds: 48), () {
-       if (!mounted) return;
-       _hideTextInputOverlay();
-       ForecastOverviewPage.showForMeasurement(
-         context,
-         measurement: measurement,
-       );
-     });
-   });
+   try {
+     await Future<void>.delayed(const Duration(milliseconds: 48));
+     if (!mounted) return;
+     _hideTextInputOverlay();
+     await ForecastOverviewPage.showForMeasurement(
+       context,
+       measurement: measurement,
+     );
+   } finally {
+     _isOpeningForecast = false;
+   }
  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mobile/lib/src/app/map/pages/map_page.dart` around lines 123 - 137, The
_showForecastModal method lacks an in-flight guard that prevents multiple
concurrent forecast modals from being displayed when the user taps rapidly. Add
a boolean flag (e.g., _isForecastModalShowing) to track whether a forecast modal
is currently open, set it to true before calling
ForecastOverviewPage.showForMeasurement in the delayed callback, and reset it to
false when the modal is dismissed. Check this flag at the start of
_showForecastModal to return early if a modal is already in flight, preventing
queued sheets from stacking on rapid repeated taps.
🟡 Minor comments (11)
src/mobile/lib/src/app/dashboard/utils/measurement_location_utils.dart-7-10 (1)

7-10: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Trim input strings before fallback selection.

Whitespace-only values currently pass isNotEmpty checks and can render blank display names/descriptions instead of falling back.

Suggested fix
 String measurementDisplayName(
   Measurement measurement, {
   String? fallbackLocationName,
 }) {
-  return measurement.siteDetails?.searchName ??
-      measurement.siteDetails?.name ??
-      fallbackLocationName ??
-      '---';
+  String? clean(String? value) {
+    final trimmed = value?.trim();
+    return (trimmed == null || trimmed.isEmpty) ? null : trimmed;
+  }
+
+  return clean(measurement.siteDetails?.searchName) ??
+      clean(measurement.siteDetails?.name) ??
+      clean(fallbackLocationName) ??
+      '---';
 }

Also applies to: 19-39

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mobile/lib/src/app/dashboard/utils/measurement_location_utils.dart`
around lines 7 - 10, The issue is that whitespace-only strings (containing only
spaces) currently pass null coalescing checks and display as blank instead of
falling back to the next value. In the location name selection logic that uses
the chain of null coalescing operators with measurement.siteDetails?.searchName,
measurement.siteDetails?.name, fallbackLocationName, and the fallback '---', you
need to trim whitespace from each string value before the null coalescing
operation. This ensures that strings containing only whitespace are treated as
empty and the fallback chain continues to the next option. Apply this fix to all
similar patterns in the file (including the occurrences mentioned at lines
19-39).
src/mobile/lib/src/meta/utils/forecast_utils.dart-43-71 (1)

43-71: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Normalize icon category mapping the same way as color mapping.

You added normalized category handling and u4sg support for colors, but icon selection still relies on raw label matching. That can show a colored chip with an “unavailable” icon for the same category input.

Suggested fix
 String getForecastAirQualityIcon(String aqiCategory) {
-  switch (aqiCategory) {
-    case "Good":
+  switch (normalizeAqiCategory(aqiCategory)) {
+    case "good":
       return "assets/images/shared/airquality_indicators/good.svg";
-    case "Moderate":
+    case "moderate":
       return "assets/images/shared/airquality_indicators/moderate.svg";
-    case "Unhealthy for Sensitive Groups":
+    case "unhealthy for sensitive groups":
+    case "u4sg":
       return "assets/images/shared/airquality_indicators/unhealthy-sensitive.svg";
-    case "Unhealthy":
+    case "unhealthy":
       return "assets/images/shared/airquality_indicators/unhealthy.svg";
-    case "Very Unhealthy":
+    case "very unhealthy":
       return "assets/images/shared/airquality_indicators/very-unhealthy.svg";
-    case "Hazardous":
+    case "hazardous":
       return "assets/images/shared/airquality_indicators/hazardous.svg";
     default:
       return "assets/images/shared/airquality_indicators/unavailable.svg";
   }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mobile/lib/src/meta/utils/forecast_utils.dart` around lines 43 - 71, The
getAppAqiCategoryColor function properly normalizes the input category using
normalizeAqiCategory before matching against case statements, but the icon
selection logic (likely in a separate function that handles icon mapping for the
same category parameter) is not applying the same normalization. Find the icon
selection function that maps AQI categories to icons and update it to normalize
the input category string using the same normalizeAqiCategory function before
performing case matching, ensuring consistent handling of whitespace, casing,
and aliases like 'u4sg' across both color and icon selection.
src/mobile/lib/src/app/dashboard/widgets/forecast_hourly_section.dart-73-87 (1)

73-87: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Apply onInsetPanel in _ErrorRow surface styling

_ErrorRow accepts onInsetPanel, but the container color always uses AppSurfaceColors.nested(context). In inset mode, this breaks the intended panel visual contract.

Suggested fix
   decoration: BoxDecoration(
-    color: AppSurfaceColors.nested(context),
+    color: onInsetPanel
+        ? AppSurfaceColors.panelChip(context)
+        : AppSurfaceColors.nested(context),
     borderRadius: BorderRadius.circular(12),
     border: Border.all(color: AppSurfaceColors.border(context)),
   ),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mobile/lib/src/app/dashboard/widgets/forecast_hourly_section.dart` around
lines 73 - 87, The _ErrorRow class accepts the onInsetPanel parameter but does
not use it when styling the container. Currently, the container color in the
decoration property is hardcoded to AppSurfaceColors.nested(context). Modify the
color assignment in the Container's decoration to conditionally check the
onInsetPanel value: when onInsetPanel is true, apply the appropriate inset panel
surface color from AppSurfaceColors, otherwise use the nested color.
src/mobile/lib/src/app/dashboard/widgets/analytics_card.dart-16-21 (1)

16-21: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard against a silent no-op when forecast site id is missing.

Line 17 calls ForecastOverviewPage.showForMeasurement(...), which returns early when measurement.siteDetails?.id is null. In that state the card remains tappable but does nothing.

Suggested fix
  void _openForecast(BuildContext context) {
+   if (measurement.siteDetails?.id == null) {
+     // Optional: show a brief message instead of a silent no-op.
+     return;
+   }
    ForecastOverviewPage.showForMeasurement(
      context,
      measurement: measurement,
      fallbackLocationName: fallbackLocationName,
    );
  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mobile/lib/src/app/dashboard/widgets/analytics_card.dart` around lines 16
- 21, The _openForecast method calls ForecastOverviewPage.showForMeasurement
without first validating that measurement.siteDetails?.id is not null, resulting
in a silent no-op when the id is missing while the card remains tappable. Add a
null-check guard at the beginning of the _openForecast method to verify that
measurement.siteDetails?.id exists before attempting to call
ForecastOverviewPage.showForMeasurement; if the id is null, either prevent the
card from being tappable in the first place or show appropriate feedback to the
user that the forecast cannot be displayed.
src/mobile/lib/src/app/learn/pages/kya_page.dart-59-65 (1)

59-65: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Refresh callback lifecycle is detached from RefreshIndicator and can end too early.

onRefresh currently completes immediately because _retryLoading() is void, and the error-state ListView may not always allow pull-to-refresh when content is shorter than the viewport.

Suggested patch
-  void _retryLoading() {
+  Future<void> _retryLoading() async {
     setState(() => _isRetrying = true);
     kyaBloc?.add(LoadLessons(forceRefresh: true));
-    Future.delayed(const Duration(seconds: 2), () {
-      if (mounted) setState(() => _isRetrying = false);
-    });
+    await Future<void>.delayed(const Duration(seconds: 2));
+    if (mounted) setState(() => _isRetrying = false);
   }
@@
-      onRefresh: () async => _retryLoading(),
+      onRefresh: _retryLoading,
       child: ListView(
+        physics: const AlwaysScrollableScrollPhysics(),
         children: [

Also applies to: 243-246

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mobile/lib/src/app/learn/pages/kya_page.dart` around lines 59 - 65, The
`_retryLoading()` method is declared as void, so the RefreshIndicator cannot
await its completion and the refresh callback finishes immediately without
waiting for the actual data load. Change the return type of `_retryLoading()`
from void to Future<void>, and ensure the method returns a Future that only
completes when the lessons have finished loading (by waiting for the kyaBloc to
emit a loaded state) rather than just waiting for the arbitrary 2-second delay.
This allows the RefreshIndicator to properly track the refresh lifecycle and
keep the spinner visible until the actual data fetch is complete.
src/mobile/lib/src/app/learn/widgets/learn_course_portrait_card.dart-49-50 (1)

49-50: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Clamp course progress ratio before rendering the progress bar.

If stored progress exceeds current lesson count after content updates, completed / total can go above 1.0. Bound the value before passing it to LinearProgressIndicator.

Suggested patch
-    final ratio = total > 0 ? completed / total : 0.0;
+    final rawRatio = total > 0 ? completed / total : 0.0;
+    final ratio = rawRatio.clamp(0.0, 1.0).toDouble();

Also applies to: 115-115

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mobile/lib/src/app/learn/widgets/learn_course_portrait_card.dart` around
lines 49 - 50, The ratio variable calculated from completed / total can exceed
1.0 when stored progress exceeds the current lesson count after content updates.
Clamp the ratio value to be between 0.0 and 1.0 at the point where it is
calculated (in the ternary expression that computes ratio) to ensure the
LinearProgressIndicator receives a valid progress value that does not exceed
1.0. Apply the same clamping logic to the other occurrence of this ratio
calculation mentioned in the comment.
src/mobile/lib/src/app/learn/widgets/learn_level_summary_card.dart-33-37 (1)

33-37: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Clamp progress before passing it to LinearProgressIndicator.

Line 33-37 computes a raw ratio that can go above 1.0 (or below 0.0 with bad upstream values). That can break expected indicator behavior.

Suggested fix
-    final progress = widget.maxPoints > 0
+    final rawProgress = widget.maxPoints > 0
         ? widget.earnedPoints / widget.maxPoints
         : widget.totalLessons > 0
             ? widget.completedLessons / widget.totalLessons
             : 0.0;
+    final progress = rawProgress.clamp(0.0, 1.0).toDouble();

Also applies to: 129-134

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mobile/lib/src/app/learn/widgets/learn_level_summary_card.dart` around
lines 33 - 37, The progress calculation that divides either earnedPoints by
maxPoints or completedLessons by totalLessons can produce values outside the
valid range of 0.0 to 1.0 due to bad upstream data. Clamp the calculated
progress value to ensure it stays within the valid bounds before passing it to
LinearProgressIndicator. Apply this fix to both occurrences of the progress
variable calculation (the first one in the conditional expression starting with
widget.maxPoints > 0, and the second similar calculation that also applies to
this same pattern). Use Dart's clamp method or min/max operations to constrain
the progress value between 0.0 and 1.0.
src/mobile/lib/src/app/learn/widgets/learn_lesson_confetti.dart-38-55 (1)

38-55: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Precompute confetti particles instead of randomizing every frame.

Generating left/delay/color during each rebuild makes particles jump frame-to-frame. Persist those values once (e.g., in initState) and only animate t.

Suggested fix
 class _LearnLessonConfettiState extends State<LearnLessonConfetti>
     with SingleTickerProviderStateMixin {
   late final AnimationController _controller;
   final _random = Random();
+  late final List<_Particle> _particles;

   `@override`
   void initState() {
     super.initState();
+    _particles = List.generate(
+      18,
+      (_) => _Particle(
+        left: _random.nextDouble(),
+        delay: _random.nextDouble() * 0.4,
+        color: [
+          const Color(0xff145FFF),
+          const Color(0xff57D175),
+          const Color(0xffE24B4A),
+        ][_random.nextInt(3)],
+      ),
+    );
     _controller = AnimationController(
       vsync: this,
       duration: const Duration(milliseconds: 1200),
     )..forward();
   }
@@
-            children: List.generate(18, (i) {
-              final left = _random.nextDouble();
-              final delay = _random.nextDouble() * 0.4;
-              final t = ((_controller.value - delay).clamp(0.0, 1.0));
+            children: _particles.map((p) {
+              final t = ((_controller.value - p.delay).clamp(0.0, 1.0));
               return Positioned(
-                left: left * MediaQuery.of(context).size.width,
+                left: p.left * MediaQuery.of(context).size.width,
                 top: -20 + t * 120,
                 child: Opacity(
                   opacity: 1 - t,
                   child: Container(
@@
-                      color: [
-                        const Color(0xff145FFF),
-                        const Color(0xff57D175),
-                        const Color(0xffE24B4A),
-                      ][_random.nextInt(3)],
+                      color: p.color,
                       borderRadius: BorderRadius.circular(1),
                     ),
                   ),
                 ),
               );
-            }),
+            }).toList(),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mobile/lib/src/app/learn/widgets/learn_lesson_confetti.dart` around lines
38 - 55, The confetti particles are being randomized on every frame in the
List.generate callback, causing visual jitter. Instead of recalculating left
position, delay, and color selection within the build method, precompute these
values once during initialization (such as in initState or a late final field).
Store the 18 particle configurations (left, delay, color index) in a list, then
in the List.generate within children, retrieve the precomputed values from that
list instead of calling _random.nextDouble() and _random.nextInt(3) on each
rebuild. Keep only the animation calculation (the t variable derived from
_controller.value) dynamic to allow smooth animation.
src/mobile/lib/src/app/learn/widgets/experience/learn_video_activity.dart-183-188 (1)

183-188: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Direct-video URL detection misses valid URLs with query strings.

On Line 185–186, endsWith('.mp4' / '.mov') runs on the full URL, so common signed links like .../file.mp4?alt=media are rejected and end up in the unsupported flow.

💡 Proposed fix
   static bool _isDirectVideo(String url) {
-    final lower = url.toLowerCase();
-    return lower.endsWith('.mp4') ||
-        lower.endsWith('.mov') ||
-        lower.contains('commondatastorage.googleapis.com');
+    final uri = Uri.tryParse(url);
+    final path = (uri?.path ?? '').toLowerCase();
+    final host = (uri?.host ?? '').toLowerCase();
+    return path.endsWith('.mp4') ||
+        path.endsWith('.mov') ||
+        host.contains('commondatastorage.googleapis.com');
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mobile/lib/src/app/learn/widgets/experience/learn_video_activity.dart`
around lines 183 - 188, The _isDirectVideo method checks if the URL ends with
.mp4 or .mov on the full URL string, which fails when query parameters are
present (e.g., file.mp4?alt=media). Extract the path component of the URL by
removing query string parameters before performing the endsWith checks on the
lower variable. You can split the URL at the question mark character to isolate
just the path portion, then apply the .mp4 and .mov extension checks to that
extracted path.
src/mobile/lib/src/app/exposure/widgets/exposure_place_name_text_field.dart-102-104 (1)

102-104: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Clear focus when disabling the field to avoid stale focused styling.

With the current logic, a focused field can stay visually “active” after enabled becomes false (Line 102), because _focused is only driven by the focus listener.

Suggested fix
 class _ExposurePlaceNameTextFieldState extends State<ExposurePlaceNameTextField> {
   late final FocusNode _focusNode;
   bool _focused = false;

+  `@override`
+  void didUpdateWidget(covariant ExposurePlaceNameTextField oldWidget) {
+    super.didUpdateWidget(oldWidget);
+    if (oldWidget.enabled && !widget.enabled && _focusNode.hasFocus) {
+      _focusNode.unfocus();
+    }
+  }
+
   `@override`
   void initState() {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mobile/lib/src/app/exposure/widgets/exposure_place_name_text_field.dart`
around lines 102 - 104, When the TextField's enabled property changes to false,
the _focused state is not being cleared, leaving the visual focus styling active
on a disabled field. In the didUpdateWidget method (or add one if it doesn't
exist) in the ExposureTextFieldWidget class, check if widget.enabled has changed
from true to false, and when it does, clear the focus by setting _focused to
false and calling _focusNode.unfocus() to ensure the focused styling is removed
from the disabled field.
src/mobile/lib/src/app/exposure/widgets/exposure_place_name_text_field.dart-109-110 (1)

109-110: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Multiline alignment check should account for maxLines: null and minLines.

The current condition (Line 110) can center-align text for multiline configurations (e.g., unbounded maxLines or minLines > 1).

Suggested fix
-          textAlignVertical:
-              (widget.maxLines ?? 1) > 1 ? TextAlignVertical.top : TextAlignVertical.center,
+          textAlignVertical: ((widget.maxLines == null && (widget.minLines ?? 1) > 1) ||
+                  (widget.maxLines ?? 1) > 1)
+              ? TextAlignVertical.top
+              : TextAlignVertical.center,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mobile/lib/src/app/exposure/widgets/exposure_place_name_text_field.dart`
around lines 109 - 110, The textAlignVertical condition in the
exposure_place_name_text_field.dart widget doesn't properly detect all multiline
configurations. The current check (widget.maxLines ?? 1) > 1 fails to account
for maxLines: null (unbounded lines) and the minLines property. Modify the
condition to check if maxLines is null OR maxLines > 1 OR widget.minLines > 1 to
determine multiline status, returning TextAlignVertical.top for multiline and
TextAlignVertical.center otherwise. This ensures text is top-aligned for all
multiline scenarios including unbounded maxLines and cases where minLines
indicates multiple lines.
🧹 Nitpick comments (3)
src/mobile/lib/src/app/dashboard/models/forecast_guidance.dart (1)

93-104: ⚡ Quick win

Prefer nearest-hour default instead of exact-hour-only match.

Exact-hour matching often returns 0 when entries are offset (e.g., every 3 hours), which defaults users to the earliest slot for today. Picking the nearest current-time entry gives a better first state.

Suggested fix
 int defaultHourlyIndex(List<HourlyForecastEntry> entries, DateTime selectedDay) {
   if (entries.isEmpty) return 0;
   final now = DateTime.now();
   final todayStr = _fmtDate(now);
   final dayStr = _fmtDate(selectedDay.toLocal());
   if (dayStr != todayStr) return 0;

-  for (var i = 0; i < entries.length; i++) {
-    if (entries[i].time.toLocal().hour == now.hour) return i;
-  }
-  return 0;
+  var bestIdx = 0;
+  var bestDelta = entries.first.time.toLocal().difference(now).inMinutes.abs();
+  for (var i = 1; i < entries.length; i++) {
+    final delta = entries[i].time.toLocal().difference(now).inMinutes.abs();
+    if (delta < bestDelta) {
+      bestDelta = delta;
+      bestIdx = i;
+    }
+  }
+  return bestIdx;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mobile/lib/src/app/dashboard/models/forecast_guidance.dart` around lines
93 - 104, The defaultHourlyIndex function currently only returns a matching
index when there is an exact hour match between an entry's hour and the current
hour. This fails when forecast entries are offset (e.g., every 3 hours), causing
the function to default to 0. Instead of comparing the entry hour exactly to the
current hour, modify the loop to track which entry has the time nearest to the
current time and return the index of that closest entry, regardless of whether
it matches the exact current hour.
src/mobile/lib/src/app/learn/widgets/learn_unit_chip.dart (1)

90-97: Switch statement is safe as written in Dart 3.3.2+; consider modernizing to switch expression.

In Dart 3.0 and later, non-empty switch cases implicitly complete and jump to the end of the switch statement—explicit breaks are no longer required. Your code is functionally correct as written. However, switch expressions are a modern alternative that would improve clarity and readability. Converting is optional stylistic improvement, not a correctness fix.

Optional refactoring
-    final IconData icon;
-    switch (status) {
-      case LearnUnitStatus.locked:
-        icon = Icons.lock_outline;
-      case LearnUnitStatus.completed:
-        icon = LearnDesignTokens.completedCheckIcon;
-      case LearnUnitStatus.inProgress:
-        icon = Icons.timelapse;
-    }
+    final icon = switch (status) {
+      LearnUnitStatus.locked => Icons.lock_outline,
+      LearnUnitStatus.completed => LearnDesignTokens.completedCheckIcon,
+      LearnUnitStatus.inProgress => Icons.timelapse,
+    };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mobile/lib/src/app/learn/widgets/learn_unit_chip.dart` around lines 90 -
97, Modernize the switch statement that assigns values to the icon variable
based on status to use a switch expression instead. Convert the traditional
switch statement with cases for LearnUnitStatus.locked,
LearnUnitStatus.completed, and LearnUnitStatus.inProgress to a switch expression
that directly returns the icon value for each case, which is more concise and
readable in Dart 3.0 and later. This eliminates the need for individual case
assignments and makes the code clearer.
src/mobile/lib/src/app/learn/widgets/experience/learn_level_unlock_pane.dart (1)

140-143: ⚡ Quick win

Use a theme token instead of a hardcoded highlight background color.

Line 141 introduces a fixed light color, which breaks the otherwise token-driven theming in this widget and can drift from dark-mode palette updates.

Suggested refactor
-            color: highlighted
-                ? const Color(0xffE8F0FF)
-                : LearnDesignTokens.successBg(context),
+            color: highlighted
+                ? LearnDesignTokens.primary(context).withValues(alpha: 0.12)
+                : LearnDesignTokens.successBg(context),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mobile/lib/src/app/learn/widgets/experience/learn_level_unlock_pane.dart`
around lines 140 - 143, In the learn_level_unlock_pane.dart file, the
highlighted color condition uses a hardcoded color value `const
Color(0xffE8F0FF)` instead of a theme token. Replace this hardcoded color with
an appropriate LearnDesignTokens method call (similar to how
LearnDesignTokens.successBg(context) is used for the non-highlighted state) to
ensure the color respects the token-driven theming system and properly updates
for dark mode support.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/mobile/lib/src/app/dashboard/pages/forecast_overview_page.dart`:
- Around line 129-147: The setState calls scheduled in both _syncTodayIndex and
_syncHourIndex methods via addPostFrameCallback are not guarded against the
widget being disposed before the callback executes. Guard each setState call by
wrapping it with an if (mounted) check to ensure the widget is still mounted
before calling setState. This needs to be applied to the setState call inside
the addPostFrameCallback lambda in both _syncTodayIndex (where _selectedDayIndex
is updated) and _syncHourIndex (where _selectedHourIndex is updated).

In `@src/mobile/lib/src/app/learn/models/learn_lesson_activity.dart`:
- Around line 72-80: The LearnLessonActivity constructor does not validate that
the type field matches the provided payload fields, allowing invalid states like
type equals quiz but quiz is null, which causes runtime failures later. Add
assertion checks in the LearnLessonActivity constructor that validate each
payload field is non-null when its corresponding type is selected, such as
asserting that article is not null when type is article, video is not null when
type is video, image is not null when type is image, and quiz is not null when
type is quiz. This ensures invariants are enforced at construction time rather
than deferring failures to runtime UI and scoring paths.

In `@src/mobile/lib/src/app/learn/models/learn_quiz_type.dart`:
- Around line 1-7: The LearnQuizType enum includes a spotIt value that is not
handled in the corresponding LearnQuizFormat enum used by the scoring and model
flow. To fix this mismatch, either implement full support for spotIt by adding
it as a case/branch in all locations where LearnQuizFormat is pattern-matched or
evaluated (including the scoring logic and model flow), or remove spotIt from
the LearnQuizType enum entirely if it is not yet ready for release. Ensure that
whichever approach you choose is applied consistently across all related code
that handles quiz type processing.

In `@src/mobile/lib/src/app/learn/pages/learn_course_detail_page.dart`:
- Around line 61-71: The code crashes when course.units is empty because it
attempts to access course.units at an index without checking if the list exists.
Additionally, the implementation inconsistently uses a clamped index for
selectedUnit while passing the raw _selectedUnitIndex to
LearnCatalog.isUnitUnlocked and callback arguments, creating a mismatch between
the safe index and the one used downstream. Add a guard to check if course.units
is empty before attempting to access it, then use the same clamped and validated
index consistently throughout all downstream operations including the
LearnCatalog.isUnitUnlocked call and any callback arguments, replacing the raw
_selectedUnitIndex references with the validated clamped index. Apply this fix
to all affected locations throughout the file where this pattern occurs.

In `@src/mobile/lib/src/app/learn/services/learn_progress_service.dart`:
- Line 129: The method _syncLegacyApiProgressToCatalog currently fires
preference writes (setBool and setInt calls) without awaiting them, which allows
the seeding flow to proceed before persistence completes. Convert
_syncLegacyApiProgressToCatalog to an async method with a return type of
Future<void>, await all setBool and setInt preference write calls within the
method body, and then update the call site where _syncLegacyApiProgressToCatalog
is invoked to await the result to ensure the migration persists completely
before proceeding.

In `@src/mobile/lib/src/app/learn/services/learn_quiz_scoring_service.dart`:
- Around line 80-87: The stars calculation logic fails to award full credit for
no-quiz lessons because when gradedQuizResults.isEmpty, totalGraded is 0 and the
first condition (graded >= totalGraded && totalGraded > 0) evaluates to false,
falling through to the second condition which incorrectly returns 2 stars. Fix
this by modifying the first condition to properly handle the case where
totalGraded is 0 (representing a lesson with no quiz), such as checking if the
ratio equals 1.0 or by removing the totalGraded > 0 restriction to allow 0
totalGraded to award 3 stars when appropriate, ensuring that content-only
lessons receive full credit (3 stars).

In `@src/mobile/lib/src/app/learn/widgets/experience/learn_article_activity.dart`:
- Around line 61-63: The cancel handler is incorrectly wired to the same
_onPlaybackEnded callback as the completion handler, which forces the elapsed
time to the total duration. When the user pauses or stops playback in the
pause/stop method around line 104, the _elapsed value gets set to the complete
duration, making the progress appear finished instead of paused. Create a
separate cancel handler (perhaps named _onPlaybackCancelled) that properly
handles the pause/stop state without forcing _elapsed to completion, and wire
this new handler to setCancelHandler on line 62 instead of using
_onPlaybackEnded.

In
`@src/mobile/lib/src/app/learn/widgets/experience/learn_lesson_experience.dart`:
- Around line 70-71: Add a guard clause to check if the _script list is empty
before attempting to clamp and index into it in the learn_lesson_experience.dart
file. After the line where _script is set (via buildDemoScript), check if
_script.isEmpty and handle this case explicitly (such as returning early or
setting a default value) before executing the clamp operation on _activityIndex
with _script.length - 1. This same guard pattern should be applied to the
similar code block mentioned at lines 93-94 to prevent index out of bounds
errors when accessing _current.
- Around line 55-57: The _gradedResults list is session-local and loses data
when a lesson is resumed, causing computeLessonResult to calculate incorrect
completion scores with incomplete grading history. Persist _gradedResults (along
with _freeTextResponse and _result) to storage when the lesson state is saved,
and restore these values from storage when the lesson is resumed before
computeLessonResult is called. Apply this persistence and restoration pattern
consistently across all state management points in the learn_lesson_experience
widget (including the locations around lines 107-119 and 159-163 that handle
quiz state transitions and lesson result computation).

In `@src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_option.dart`:
- Around line 104-151: The leading indicator styling incorrectly uses the
selected state to determine success styling, causing wrong selected options to
display as correct (green with checkmark) after answers are revealed. In both
the checkbox and radio button branches of the widget, replace conditions that
use selected || (revealed && isCorrectOption) for color and styling with logic
that only shows success styling when revealed && isCorrectOption is true, not
based on the selected flag. The selected flag should only affect styling during
the active selection phase, not during the reveal phase where only
isCorrectOption should determine if something appears correct or incorrect.

In `@src/mobile/lib/src/app/learn/widgets/learn_bottom_sheets.dart`:
- Around line 122-124: The `_openNextLesson` method uses unsafe direct access
that can crash if the catalog changes between sheet open and user tap. Add error
handling to the `firstWhere` call searching for the course by
`continuation.learnCourseId` (it could return no matches), and validate that
`continuation.unitIndex` is within bounds of `course.units` before direct index
access. If either validation fails, return early or handle gracefully since the
continuation data is now stale. Apply the same defensive checks to the similar
code block mentioned in the additional reference (lines 132-143).
- Around line 329-330: In the legacy lesson path where allCourses is currently
set to const [], change it to pass null instead. This will allow the downstream
fallback logic (allCourses ?? [course]) to properly apply and maintain the
current-course context for completion and stage logic, rather than bypassing it
with an empty list.

In `@src/mobile/lib/src/app/learn/widgets/learn_unit_chip.dart`:
- Around line 120-126: The `_chipKeys` list is initialized once in the
`initState()` method based on the initial `widget.course.units.length`, but if
the course units list changes after initialization, the `_chipKeys` list will
become out of sync, causing index out of bounds errors when accessed at line
175. Additionally, the clamping logic around lines 139-142 can produce invalid
bounds when the units list is empty. To fix this, add a `didUpdateWidget()`
method to check if `widget.course.units.length` has changed and regenerate the
`_chipKeys` list accordingly. Also, guard the scroll clamping operations in the
139-142 range to safely handle empty unit lists by checking if
`widget.course.units.isNotEmpty` before applying the clamp operation to ensure
valid bounds.

In `@src/mobile/lib/src/app/shared/widgets/aqi_category_chip.dart`:
- Around line 15-28: In the AqiCategoryChip widget, the text color in the
TextStyle is using the same base color as the background tint, which reduces
contrast and readability for bright AQI categories like Moderate. Modify the
TextStyle color property to use a contrast-adjusted version of the color instead
of the raw color value. You may need to create a helper function that takes a
color and returns a darker or higher-contrast variant suitable for use as
foreground text while keeping the background decoration unchanged.

---

Outside diff comments:
In `@src/mobile/lib/src/app/map/pages/map_page.dart`:
- Around line 123-137: The _showForecastModal method lacks an in-flight guard
that prevents multiple concurrent forecast modals from being displayed when the
user taps rapidly. Add a boolean flag (e.g., _isForecastModalShowing) to track
whether a forecast modal is currently open, set it to true before calling
ForecastOverviewPage.showForMeasurement in the delayed callback, and reset it to
false when the modal is dismissed. Check this flag at the start of
_showForecastModal to return early if a modal is already in flight, preventing
queued sheets from stacking on rapid repeated taps.

---

Minor comments:
In `@src/mobile/lib/src/app/dashboard/utils/measurement_location_utils.dart`:
- Around line 7-10: The issue is that whitespace-only strings (containing only
spaces) currently pass null coalescing checks and display as blank instead of
falling back to the next value. In the location name selection logic that uses
the chain of null coalescing operators with measurement.siteDetails?.searchName,
measurement.siteDetails?.name, fallbackLocationName, and the fallback '---', you
need to trim whitespace from each string value before the null coalescing
operation. This ensures that strings containing only whitespace are treated as
empty and the fallback chain continues to the next option. Apply this fix to all
similar patterns in the file (including the occurrences mentioned at lines
19-39).

In `@src/mobile/lib/src/app/dashboard/widgets/analytics_card.dart`:
- Around line 16-21: The _openForecast method calls
ForecastOverviewPage.showForMeasurement without first validating that
measurement.siteDetails?.id is not null, resulting in a silent no-op when the id
is missing while the card remains tappable. Add a null-check guard at the
beginning of the _openForecast method to verify that measurement.siteDetails?.id
exists before attempting to call ForecastOverviewPage.showForMeasurement; if the
id is null, either prevent the card from being tappable in the first place or
show appropriate feedback to the user that the forecast cannot be displayed.

In `@src/mobile/lib/src/app/dashboard/widgets/forecast_hourly_section.dart`:
- Around line 73-87: The _ErrorRow class accepts the onInsetPanel parameter but
does not use it when styling the container. Currently, the container color in
the decoration property is hardcoded to AppSurfaceColors.nested(context). Modify
the color assignment in the Container's decoration to conditionally check the
onInsetPanel value: when onInsetPanel is true, apply the appropriate inset panel
surface color from AppSurfaceColors, otherwise use the nested color.

In `@src/mobile/lib/src/app/exposure/widgets/exposure_place_name_text_field.dart`:
- Around line 102-104: When the TextField's enabled property changes to false,
the _focused state is not being cleared, leaving the visual focus styling active
on a disabled field. In the didUpdateWidget method (or add one if it doesn't
exist) in the ExposureTextFieldWidget class, check if widget.enabled has changed
from true to false, and when it does, clear the focus by setting _focused to
false and calling _focusNode.unfocus() to ensure the focused styling is removed
from the disabled field.
- Around line 109-110: The textAlignVertical condition in the
exposure_place_name_text_field.dart widget doesn't properly detect all multiline
configurations. The current check (widget.maxLines ?? 1) > 1 fails to account
for maxLines: null (unbounded lines) and the minLines property. Modify the
condition to check if maxLines is null OR maxLines > 1 OR widget.minLines > 1 to
determine multiline status, returning TextAlignVertical.top for multiline and
TextAlignVertical.center otherwise. This ensures text is top-aligned for all
multiline scenarios including unbounded maxLines and cases where minLines
indicates multiple lines.

In `@src/mobile/lib/src/app/learn/pages/kya_page.dart`:
- Around line 59-65: The `_retryLoading()` method is declared as void, so the
RefreshIndicator cannot await its completion and the refresh callback finishes
immediately without waiting for the actual data load. Change the return type of
`_retryLoading()` from void to Future<void>, and ensure the method returns a
Future that only completes when the lessons have finished loading (by waiting
for the kyaBloc to emit a loaded state) rather than just waiting for the
arbitrary 2-second delay. This allows the RefreshIndicator to properly track the
refresh lifecycle and keep the spinner visible until the actual data fetch is
complete.

In `@src/mobile/lib/src/app/learn/widgets/experience/learn_video_activity.dart`:
- Around line 183-188: The _isDirectVideo method checks if the URL ends with
.mp4 or .mov on the full URL string, which fails when query parameters are
present (e.g., file.mp4?alt=media). Extract the path component of the URL by
removing query string parameters before performing the endsWith checks on the
lower variable. You can split the URL at the question mark character to isolate
just the path portion, then apply the .mp4 and .mov extension checks to that
extracted path.

In `@src/mobile/lib/src/app/learn/widgets/learn_course_portrait_card.dart`:
- Around line 49-50: The ratio variable calculated from completed / total can
exceed 1.0 when stored progress exceeds the current lesson count after content
updates. Clamp the ratio value to be between 0.0 and 1.0 at the point where it
is calculated (in the ternary expression that computes ratio) to ensure the
LinearProgressIndicator receives a valid progress value that does not exceed
1.0. Apply the same clamping logic to the other occurrence of this ratio
calculation mentioned in the comment.

In `@src/mobile/lib/src/app/learn/widgets/learn_lesson_confetti.dart`:
- Around line 38-55: The confetti particles are being randomized on every frame
in the List.generate callback, causing visual jitter. Instead of recalculating
left position, delay, and color selection within the build method, precompute
these values once during initialization (such as in initState or a late final
field). Store the 18 particle configurations (left, delay, color index) in a
list, then in the List.generate within children, retrieve the precomputed values
from that list instead of calling _random.nextDouble() and _random.nextInt(3) on
each rebuild. Keep only the animation calculation (the t variable derived from
_controller.value) dynamic to allow smooth animation.

In `@src/mobile/lib/src/app/learn/widgets/learn_level_summary_card.dart`:
- Around line 33-37: The progress calculation that divides either earnedPoints
by maxPoints or completedLessons by totalLessons can produce values outside the
valid range of 0.0 to 1.0 due to bad upstream data. Clamp the calculated
progress value to ensure it stays within the valid bounds before passing it to
LinearProgressIndicator. Apply this fix to both occurrences of the progress
variable calculation (the first one in the conditional expression starting with
widget.maxPoints > 0, and the second similar calculation that also applies to
this same pattern). Use Dart's clamp method or min/max operations to constrain
the progress value between 0.0 and 1.0.

In `@src/mobile/lib/src/meta/utils/forecast_utils.dart`:
- Around line 43-71: The getAppAqiCategoryColor function properly normalizes the
input category using normalizeAqiCategory before matching against case
statements, but the icon selection logic (likely in a separate function that
handles icon mapping for the same category parameter) is not applying the same
normalization. Find the icon selection function that maps AQI categories to
icons and update it to normalize the input category string using the same
normalizeAqiCategory function before performing case matching, ensuring
consistent handling of whitespace, casing, and aliases like 'u4sg' across both
color and icon selection.

---

Nitpick comments:
In `@src/mobile/lib/src/app/dashboard/models/forecast_guidance.dart`:
- Around line 93-104: The defaultHourlyIndex function currently only returns a
matching index when there is an exact hour match between an entry's hour and the
current hour. This fails when forecast entries are offset (e.g., every 3 hours),
causing the function to default to 0. Instead of comparing the entry hour
exactly to the current hour, modify the loop to track which entry has the time
nearest to the current time and return the index of that closest entry,
regardless of whether it matches the exact current hour.

In
`@src/mobile/lib/src/app/learn/widgets/experience/learn_level_unlock_pane.dart`:
- Around line 140-143: In the learn_level_unlock_pane.dart file, the highlighted
color condition uses a hardcoded color value `const Color(0xffE8F0FF)` instead
of a theme token. Replace this hardcoded color with an appropriate
LearnDesignTokens method call (similar to how
LearnDesignTokens.successBg(context) is used for the non-highlighted state) to
ensure the color respects the token-driven theming system and properly updates
for dark mode support.

In `@src/mobile/lib/src/app/learn/widgets/learn_unit_chip.dart`:
- Around line 90-97: Modernize the switch statement that assigns values to the
icon variable based on status to use a switch expression instead. Convert the
traditional switch statement with cases for LearnUnitStatus.locked,
LearnUnitStatus.completed, and LearnUnitStatus.inProgress to a switch expression
that directly returns the icon value for each case, which is more concise and
readable in Dart 3.0 and later. This eliminates the need for individual case
assignments and makes the code clearer.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d055258d-63fe-453a-93c1-44e27a9a22a9

📥 Commits

Reviewing files that changed from the base of the PR and between 33a7f1f and 6c77ebb.

⛔ Files ignored due to path filters (6)
  • src/mobile/assets/images/dashboard/sun_icon.svg is excluded by !**/*.svg
  • src/mobile/assets/images/forecast/droplets.svg is excluded by !**/*.svg
  • src/mobile/assets/images/forecast/rain.svg is excluded by !**/*.svg
  • src/mobile/assets/images/forecast/thermometer.svg is excluded by !**/*.svg
  • src/mobile/assets/images/forecast/wind.svg is excluded by !**/*.svg
  • src/mobile/pubspec.lock is excluded by !**/*.lock
📒 Files selected for processing (73)
  • src/mobile/android/app/build.gradle
  • src/mobile/lib/src/app/dashboard/models/forecast_guidance.dart
  • src/mobile/lib/src/app/dashboard/pages/forecast_overview_page.dart
  • src/mobile/lib/src/app/dashboard/pages/location_selection/components/location_list_view.dart
  • src/mobile/lib/src/app/dashboard/pages/location_selection/components/swipeable_analytics_card.dart
  • src/mobile/lib/src/app/dashboard/utils/forecast_met_icons.dart
  • src/mobile/lib/src/app/dashboard/utils/measurement_location_utils.dart
  • src/mobile/lib/src/app/dashboard/widgets/analytics_card.dart
  • src/mobile/lib/src/app/dashboard/widgets/analytics_details.dart
  • src/mobile/lib/src/app/dashboard/widgets/analytics_specifics.dart
  • src/mobile/lib/src/app/dashboard/widgets/dashboard_app_bar.dart
  • src/mobile/lib/src/app/dashboard/widgets/expanded_analytics_card.dart
  • src/mobile/lib/src/app/dashboard/widgets/forecast_day_detail_card.dart
  • src/mobile/lib/src/app/dashboard/widgets/forecast_day_selector.dart
  • src/mobile/lib/src/app/dashboard/widgets/forecast_guidance_section.dart
  • src/mobile/lib/src/app/dashboard/widgets/forecast_hourly_section.dart
  • src/mobile/lib/src/app/dashboard/widgets/forecast_met_row.dart
  • src/mobile/lib/src/app/dashboard/widgets/forecast_time_scope_selector.dart
  • src/mobile/lib/src/app/dashboard/widgets/nearby_measurement_card.dart
  • src/mobile/lib/src/app/dashboard/widgets/unmatched_site_card.dart
  • src/mobile/lib/src/app/exposure/widgets/declared_place_card.dart
  • src/mobile/lib/src/app/exposure/widgets/exposure_place_name_text_field.dart
  • src/mobile/lib/src/app/learn/formatting/learn_display_text.dart
  • src/mobile/lib/src/app/learn/models/learn_activity_kind.dart
  • src/mobile/lib/src/app/learn/models/learn_course_structure.dart
  • src/mobile/lib/src/app/learn/models/learn_lesson_activity.dart
  • src/mobile/lib/src/app/learn/models/learn_lesson_continuation.dart
  • src/mobile/lib/src/app/learn/models/learn_quiz_type.dart
  • src/mobile/lib/src/app/learn/pages/kya_page.dart
  • src/mobile/lib/src/app/learn/pages/learn_course_detail_page.dart
  • src/mobile/lib/src/app/learn/pages/learn_surveys_page.dart
  • src/mobile/lib/src/app/learn/pages/lesson_page.dart
  • src/mobile/lib/src/app/learn/services/learn_lesson_experience_service.dart
  • src/mobile/lib/src/app/learn/services/learn_progress_service.dart
  • src/mobile/lib/src/app/learn/services/learn_quiz_scoring_service.dart
  • src/mobile/lib/src/app/learn/theme/learn_design_tokens.dart
  • src/mobile/lib/src/app/learn/widgets/experience/learn_article_activity.dart
  • src/mobile/lib/src/app/learn/widgets/experience/learn_article_audio_player.dart
  • src/mobile/lib/src/app/learn/widgets/experience/learn_course_certificate.dart
  • src/mobile/lib/src/app/learn/widgets/experience/learn_experience_shell.dart
  • src/mobile/lib/src/app/learn/widgets/experience/learn_image_activity.dart
  • src/mobile/lib/src/app/learn/widgets/experience/learn_lesson_experience.dart
  • src/mobile/lib/src/app/learn/widgets/experience/learn_lesson_finish_pane.dart
  • src/mobile/lib/src/app/learn/widgets/experience/learn_level_unlock_pane.dart
  • src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_activity.dart
  • src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_free_text.dart
  • src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_multi_choice.dart
  • src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_option.dart
  • src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_ranking.dart
  • src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_single_choice.dart
  • src/mobile/lib/src/app/learn/widgets/experience/learn_video_activity.dart
  • src/mobile/lib/src/app/learn/widgets/kya_lesson_container.dart
  • src/mobile/lib/src/app/learn/widgets/learn_bottom_sheets.dart
  • src/mobile/lib/src/app/learn/widgets/learn_completion_sheet.dart
  • src/mobile/lib/src/app/learn/widgets/learn_course_portrait_card.dart
  • src/mobile/lib/src/app/learn/widgets/learn_dashboard_header.dart
  • src/mobile/lib/src/app/learn/widgets/learn_lesson_activities.dart
  • src/mobile/lib/src/app/learn/widgets/learn_lesson_confetti.dart
  • src/mobile/lib/src/app/learn/widgets/learn_lesson_image.dart
  • src/mobile/lib/src/app/learn/widgets/learn_lesson_list_row.dart
  • src/mobile/lib/src/app/learn/widgets/learn_lesson_thumbnail.dart
  • src/mobile/lib/src/app/learn/widgets/learn_level_summary_card.dart
  • src/mobile/lib/src/app/learn/widgets/learn_sheet_button_styles.dart
  • src/mobile/lib/src/app/learn/widgets/learn_unit_chip.dart
  • src/mobile/lib/src/app/map/pages/map_page.dart
  • src/mobile/lib/src/app/map/widgets/map_air_quality_card.dart
  • src/mobile/lib/src/app/shared/widgets/aqi_category_chip.dart
  • src/mobile/lib/src/app/surveys/pages/survey_list_page.dart
  • src/mobile/lib/src/app/surveys/widgets/survey_card.dart
  • src/mobile/lib/src/app/surveys/widgets/survey_list_content.dart
  • src/mobile/lib/src/meta/utils/colors.dart
  • src/mobile/lib/src/meta/utils/forecast_utils.dart
  • src/mobile/pubspec.yaml

Comment thread src/mobile/lib/src/app/dashboard/pages/forecast_overview_page.dart
Comment on lines +72 to +80
const LearnLessonActivity({
required this.index,
required this.type,
required this.title,
this.article,
this.video,
this.image,
this.quiz,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Enforce LearnLessonActivity payload invariants at construction.

Right now, type and payload fields can drift (for example type: quiz with quiz == null), which defers failures to runtime UI/scoring paths. Add constructor asserts so invalid states are rejected immediately.

Proposed fix
 class LearnLessonActivity {
@@
   const LearnLessonActivity({
     required this.index,
     required this.type,
     required this.title,
     this.article,
     this.video,
     this.image,
     this.quiz,
-  });
+  }) : assert(
+         (article != null ? 1 : 0) +
+                 (video != null ? 1 : 0) +
+                 (image != null ? 1 : 0) +
+                 (quiz != null ? 1 : 0) ==
+             1,
+         'Exactly one payload must be provided.',
+       ),
+       assert(
+         (type == LearnActivityType.article && article != null) ||
+             (type == LearnActivityType.video && video != null) ||
+             (type == LearnActivityType.image && image != null) ||
+             (type == LearnActivityType.quiz && quiz != null),
+         'Payload must match LearnActivityType.',
+       );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const LearnLessonActivity({
required this.index,
required this.type,
required this.title,
this.article,
this.video,
this.image,
this.quiz,
});
const LearnLessonActivity({
required this.index,
required this.type,
required this.title,
this.article,
this.video,
this.image,
this.quiz,
}) : assert(
(article != null ? 1 : 0) +
(video != null ? 1 : 0) +
(image != null ? 1 : 0) +
(quiz != null ? 1 : 0) ==
1,
'Exactly one payload must be provided.',
),
assert(
(type == LearnActivityType.article && article != null) ||
(type == LearnActivityType.video && video != null) ||
(type == LearnActivityType.image && image != null) ||
(type == LearnActivityType.quiz && quiz != null),
'Payload must match LearnActivityType.',
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mobile/lib/src/app/learn/models/learn_lesson_activity.dart` around lines
72 - 80, The LearnLessonActivity constructor does not validate that the type
field matches the provided payload fields, allowing invalid states like type
equals quiz but quiz is null, which causes runtime failures later. Add assertion
checks in the LearnLessonActivity constructor that validate each payload field
is non-null when its corresponding type is selected, such as asserting that
article is not null when type is article, video is not null when type is video,
image is not null when type is image, and quiz is not null when type is quiz.
This ensures invariants are enforced at construction time rather than deferring
failures to runtime UI and scoring paths.

Comment on lines +1 to +7
enum LearnQuizType {
singleChoice,
multiChoice,
spotIt,
ranking,
freeText,
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Align quiz-type enums before release.

spotIt exists here, but the introduced scoring/model flow uses LearnQuizFormat without a spotIt branch. That mismatch creates an unsupported quiz type path. Either implement spotIt end-to-end or remove it from the public enum until supported.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mobile/lib/src/app/learn/models/learn_quiz_type.dart` around lines 1 - 7,
The LearnQuizType enum includes a spotIt value that is not handled in the
corresponding LearnQuizFormat enum used by the scoring and model flow. To fix
this mismatch, either implement full support for spotIt by adding it as a
case/branch in all locations where LearnQuizFormat is pattern-matched or
evaluated (including the scoring logic and model flow), or remove spotIt from
the LearnQuizType enum entirely if it is not yet ready for release. Ensure that
whichever approach you choose is applied consistently across all related code
that handles quiz type processing.

Comment thread src/mobile/lib/src/app/learn/pages/learn_course_detail_page.dart
await ensureInitialized();
if (courses.isEmpty) return;

_syncLegacyApiProgressToCatalog(courses);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify this method is currently synchronous while using async SharedPreferences writes.
rg -n -C3 "void _syncLegacyApiProgressToCatalog|_prefs!\.set(Bool|Int)\(" src/mobile/lib/src/app/learn/services/learn_progress_service.dart

Repository: airqo-platform/AirQo-frontend

Length of output: 2660


Make _syncLegacyApiProgressToCatalog async and await preference writes.

The method currently fires setBool and setInt calls without awaiting them, allowing the seeding flow to proceed before migration persistence completes. This risks partial data integrity. The method should return Future<void> and await all preference writes to match the pattern used consistently throughout the rest of the file.

Proposed fix
-    _syncLegacyApiProgressToCatalog(courses);
+    await _syncLegacyApiProgressToCatalog(courses);
@@
-  void _syncLegacyApiProgressToCatalog(List<LearnCourseViewModel> courses) {
+  Future<void> _syncLegacyApiProgressToCatalog(
+    List<LearnCourseViewModel> courses,
+  ) async {
@@
-            _prefs!.setBool('$_completePrefix$catalogKey', true);
+            await _prefs!.setBool('$_completePrefix$catalogKey', true);
@@
-            _prefs!.setInt('$_stepPrefix$catalogKey', apiStep);
+            await _prefs!.setInt('$_stepPrefix$catalogKey', apiStep);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
_syncLegacyApiProgressToCatalog(courses);
await _syncLegacyApiProgressToCatalog(courses);
Suggested change
_syncLegacyApiProgressToCatalog(courses);
Future<void> _syncLegacyApiProgressToCatalog(
List<LearnCourseViewModel> courses,
) async {
// ... method body ...
// Inside the method body:
await _prefs!.setBool('$_completePrefix$catalogKey', true);
// and
await _prefs!.setInt('$_stepPrefix$catalogKey', apiStep);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mobile/lib/src/app/learn/services/learn_progress_service.dart` at line
129, The method _syncLegacyApiProgressToCatalog currently fires preference
writes (setBool and setInt calls) without awaiting them, which allows the
seeding flow to proceed before persistence completes. Convert
_syncLegacyApiProgressToCatalog to an async method with a return type of
Future<void>, await all setBool and setInt preference write calls within the
method body, and then update the call site where _syncLegacyApiProgressToCatalog
is invoked to await the result to ensure the migration persists completely
before proceeding.

Comment on lines +122 to +124
final course = courses.firstWhere((c) => c.id == continuation.learnCourseId);
final unit = course.units[continuation.unitIndex];
final chain = LearnCatalog.continuationFor(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Harden _openNextLesson against stale continuation data.

If the catalog changes between sheet open and tap, firstWhere and direct index access can throw and crash this flow.

Suggested fix
-    final course = courses.firstWhere((c) => c.id == continuation.learnCourseId);
-    final unit = course.units[continuation.unitIndex];
+    final courseIndex = courses.indexWhere(
+      (c) => c.id == continuation.learnCourseId,
+    );
+    if (courseIndex < 0) return;
+    final course = courses[courseIndex];
+    if (continuation.unitIndex < 0 ||
+        continuation.unitIndex >= course.units.length) {
+      return;
+    }
+    final unit = course.units[continuation.unitIndex];
+    if (continuation.lessonIndex < 0 ||
+        continuation.lessonIndex >= unit.lessons.length) {
+      return;
+    }

Also applies to: 132-143

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mobile/lib/src/app/learn/widgets/learn_bottom_sheets.dart` around lines
122 - 124, The `_openNextLesson` method uses unsafe direct access that can crash
if the catalog changes between sheet open and user tap. Add error handling to
the `firstWhere` call searching for the course by `continuation.learnCourseId`
(it could return no matches), and validate that `continuation.unitIndex` is
within bounds of `course.units` before direct index access. If either validation
fails, return early or handle gracefully since the continuation data is now
stale. Apply the same defensive checks to the similar code block mentioned in
the additional reference (lines 132-143).

Comment thread src/mobile/lib/src/app/learn/widgets/learn_bottom_sheets.dart Outdated
Comment on lines +120 to +126
late final List<GlobalKey> _chipKeys;

@override
void initState() {
super.initState();
_chipKeys = List.generate(widget.course.units.length, (_) => GlobalKey());
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Recreate _chipKeys on unit-list changes and guard empty-unit scroll.

Line 125 builds keys once from the initial unit count. If course.units.length changes, Line 175 can index past _chipKeys. Also, Line 139-142 can clamp with an invalid upper bound when units are empty.

Suggested fix
-class _LearnUnitChipRowState extends State<LearnUnitChipRow> {
-  late final List<GlobalKey> _chipKeys;
+class _LearnUnitChipRowState extends State<LearnUnitChipRow> {
+  late List<GlobalKey> _chipKeys;

   `@override`
   void initState() {
     super.initState();
     _chipKeys = List.generate(widget.course.units.length, (_) => GlobalKey());
   }

   `@override`
   void didUpdateWidget(LearnUnitChipRow oldWidget) {
     super.didUpdateWidget(oldWidget);
+    if (oldWidget.course.units.length != widget.course.units.length) {
+      _chipKeys = List.generate(widget.course.units.length, (_) => GlobalKey());
+    }
     if (oldWidget.selectedUnitIndex != widget.selectedUnitIndex) {
       _scrollToSelectedChip();
     }
   }

   void _scrollToSelectedChip() {
     WidgetsBinding.instance.addPostFrameCallback((_) {
       if (!mounted) return;
+      if (widget.course.units.isEmpty || _chipKeys.isEmpty) return;
       final index = widget.selectedUnitIndex.clamp(
         0,
         widget.course.units.length - 1,
       );

Also applies to: 129-143, 174-176

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mobile/lib/src/app/learn/widgets/learn_unit_chip.dart` around lines 120 -
126, The `_chipKeys` list is initialized once in the `initState()` method based
on the initial `widget.course.units.length`, but if the course units list
changes after initialization, the `_chipKeys` list will become out of sync,
causing index out of bounds errors when accessed at line 175. Additionally, the
clamping logic around lines 139-142 can produce invalid bounds when the units
list is empty. To fix this, add a `didUpdateWidget()` method to check if
`widget.course.units.length` has changed and regenerate the `_chipKeys` list
accordingly. Also, guard the scroll clamping operations in the 139-142 range to
safely handle empty unit lists by checking if `widget.course.units.isNotEmpty`
before applying the clamp operation to ensure valid bounds.

Comment on lines +15 to +28
final color = getAppAqiCategoryColor(category);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(20),
),
child: Text(
category,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: color,
),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Increase label contrast for bright AQI categories.

Using the same hue for both text and tinted background can make “Moderate” and similar categories hard to read. Use a contrast-adjusted foreground color for the text.

Suggested fix
   `@override`
   Widget build(BuildContext context) {
     final color = getAppAqiCategoryColor(category);
+    final textColor = readableAqiColor(color);
     return Container(
       padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
       decoration: BoxDecoration(
         color: color.withValues(alpha: 0.15),
         borderRadius: BorderRadius.circular(20),
       ),
       child: Text(
         category,
         style: TextStyle(
           fontSize: 14,
           fontWeight: FontWeight.w600,
-          color: color,
+          color: textColor,
         ),
       ),
     );
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
final color = getAppAqiCategoryColor(category);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(20),
),
child: Text(
category,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: color,
),
final color = getAppAqiCategoryColor(category);
final textColor = readableAqiColor(color);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(20),
),
child: Text(
category,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: textColor,
),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mobile/lib/src/app/shared/widgets/aqi_category_chip.dart` around lines 15
- 28, In the AqiCategoryChip widget, the text color in the TextStyle is using
the same base color as the background tint, which reduces contrast and
readability for bright AQI categories like Moderate. Modify the TextStyle color
property to use a contrast-adjusted version of the color instead of the raw
color value. You may need to create a helper function that takes a color and
returns a darker or higher-contrast variant suitable for use as foreground text
while keeping the background decoration unchanged.

2phonebabykeem and others added 3 commits June 17, 2026 20:34
Use Math.max(flutter.minSdkVersion, 24) so we keep staging's dynamic
Flutter default while enforcing the API 24 floor required by flutter_tts.

Co-authored-by: Cursor <cursoragent@cursor.com>
fix(learn): add missing break statements in status icon switch case
chore(deps): update package versions in pubspec.lock
2phonebabykeem and others added 2 commits June 17, 2026 21:09
Fix quiz option indicators after reveal, guard forecast sheet setState,
score points from correct answers only, pass null allCourses on legacy
lessons, harden next-lesson navigation, and guard empty course units.

Co-authored-by: Cursor <cursoragent@cursor.com>
Resolve conflicts in analytics_specifics (keep share UI from staging
with forecast sheet navigation and location pin styling) and
regenerate pubspec.lock with merged learn dependencies.

Co-authored-by: Cursor <cursoragent@cursor.com>
@Baalmart
Baalmart requested a review from Mozart299 June 17, 2026 18:47
@Baalmart
Baalmart merged commit b773fa4 into staging Jun 17, 2026
25 of 27 checks passed
@Baalmart
Baalmart deleted the feature/forecast-details-sheet branch June 17, 2026 19:24
@Baalmart Baalmart mentioned this pull request Jun 17, 2026
13 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants