feat(mobile): Learn tab v2, forecast details sheet, and UI polish#3664
Conversation
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>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (7)
🚧 Files skipped from review as they are similar to previous changes (5)
📝 WalkthroughWalkthroughThis 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. ChangesForecast modal and dashboard styling
Learn catalog and lesson experience
Survey list extraction and Learn survey tab refresh
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
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
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Poem
✨ Finishing Touches🧪 Generate unit tests (beta)
|
There was a problem hiding this comment.
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.
| import 'package:airqo/src/app/learn/theme/learn_design_tokens.dart'; | ||
| import 'package:airqo/src/meta/utils/colors.dart'; | ||
| import 'package:flutter/material.dart'; |
| 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, |
| border: Border.all( | ||
| color: selected || (revealed && isCorrectOption) | ||
| ? LearnDesignTokens.success | ||
| : Theme.of(context).dividerColor, | ||
| width: 2, | ||
| ), | ||
| color: selected || (revealed && isCorrectOption) | ||
| ? LearnDesignTokens.success | ||
| : Colors.transparent, |
| final stars = graded >= totalGraded && totalGraded > 0 | ||
| ? 3 | ||
| : graded >= (totalGraded / 2).ceil() | ||
| ? 2 | ||
| : 1; |
There was a problem hiding this comment.
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 winPrevent stacked forecast sheets on rapid repeated taps.
This flow schedules delayed modal opens without an in-flight guard; quick repeated taps can queue multiple
showForMeasurementcalls 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 winTrim input strings before fallback selection.
Whitespace-only values currently pass
isNotEmptychecks 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 winNormalize icon category mapping the same way as color mapping.
You added normalized category handling and
u4sgsupport 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 winApply
onInsetPanelin_ErrorRowsurface styling
_ErrorRowacceptsonInsetPanel, but the container color always usesAppSurfaceColors.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 winGuard against a silent no-op when forecast site id is missing.
Line 17 calls
ForecastOverviewPage.showForMeasurement(...), which returns early whenmeasurement.siteDetails?.idis 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 winRefresh callback lifecycle is detached from
RefreshIndicatorand can end too early.
onRefreshcurrently completes immediately because_retryLoading()isvoid, and the error-stateListViewmay 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 winClamp course progress ratio before rendering the progress bar.
If stored progress exceeds current lesson count after content updates,
completed / totalcan go above 1.0. Bound the value before passing it toLinearProgressIndicator.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 winClamp progress before passing it to
LinearProgressIndicator.Line 33-37 computes a raw ratio that can go above
1.0(or below0.0with 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 winPrecompute confetti particles instead of randomizing every frame.
Generating
left/delay/colorduring each rebuild makes particles jump frame-to-frame. Persist those values once (e.g., ininitState) and only animatet.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 winDirect-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=mediaare 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 winClear focus when disabling the field to avoid stale focused styling.
With the current logic, a focused field can stay visually “active” after
enabledbecomes false (Line 102), because_focusedis 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 winMultiline alignment check should account for
maxLines: nullandminLines.The current condition (Line 110) can center-align text for multiline configurations (e.g., unbounded
maxLinesorminLines > 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 winPrefer 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 winUse 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
⛔ Files ignored due to path filters (6)
src/mobile/assets/images/dashboard/sun_icon.svgis excluded by!**/*.svgsrc/mobile/assets/images/forecast/droplets.svgis excluded by!**/*.svgsrc/mobile/assets/images/forecast/rain.svgis excluded by!**/*.svgsrc/mobile/assets/images/forecast/thermometer.svgis excluded by!**/*.svgsrc/mobile/assets/images/forecast/wind.svgis excluded by!**/*.svgsrc/mobile/pubspec.lockis excluded by!**/*.lock
📒 Files selected for processing (73)
src/mobile/android/app/build.gradlesrc/mobile/lib/src/app/dashboard/models/forecast_guidance.dartsrc/mobile/lib/src/app/dashboard/pages/forecast_overview_page.dartsrc/mobile/lib/src/app/dashboard/pages/location_selection/components/location_list_view.dartsrc/mobile/lib/src/app/dashboard/pages/location_selection/components/swipeable_analytics_card.dartsrc/mobile/lib/src/app/dashboard/utils/forecast_met_icons.dartsrc/mobile/lib/src/app/dashboard/utils/measurement_location_utils.dartsrc/mobile/lib/src/app/dashboard/widgets/analytics_card.dartsrc/mobile/lib/src/app/dashboard/widgets/analytics_details.dartsrc/mobile/lib/src/app/dashboard/widgets/analytics_specifics.dartsrc/mobile/lib/src/app/dashboard/widgets/dashboard_app_bar.dartsrc/mobile/lib/src/app/dashboard/widgets/expanded_analytics_card.dartsrc/mobile/lib/src/app/dashboard/widgets/forecast_day_detail_card.dartsrc/mobile/lib/src/app/dashboard/widgets/forecast_day_selector.dartsrc/mobile/lib/src/app/dashboard/widgets/forecast_guidance_section.dartsrc/mobile/lib/src/app/dashboard/widgets/forecast_hourly_section.dartsrc/mobile/lib/src/app/dashboard/widgets/forecast_met_row.dartsrc/mobile/lib/src/app/dashboard/widgets/forecast_time_scope_selector.dartsrc/mobile/lib/src/app/dashboard/widgets/nearby_measurement_card.dartsrc/mobile/lib/src/app/dashboard/widgets/unmatched_site_card.dartsrc/mobile/lib/src/app/exposure/widgets/declared_place_card.dartsrc/mobile/lib/src/app/exposure/widgets/exposure_place_name_text_field.dartsrc/mobile/lib/src/app/learn/formatting/learn_display_text.dartsrc/mobile/lib/src/app/learn/models/learn_activity_kind.dartsrc/mobile/lib/src/app/learn/models/learn_course_structure.dartsrc/mobile/lib/src/app/learn/models/learn_lesson_activity.dartsrc/mobile/lib/src/app/learn/models/learn_lesson_continuation.dartsrc/mobile/lib/src/app/learn/models/learn_quiz_type.dartsrc/mobile/lib/src/app/learn/pages/kya_page.dartsrc/mobile/lib/src/app/learn/pages/learn_course_detail_page.dartsrc/mobile/lib/src/app/learn/pages/learn_surveys_page.dartsrc/mobile/lib/src/app/learn/pages/lesson_page.dartsrc/mobile/lib/src/app/learn/services/learn_lesson_experience_service.dartsrc/mobile/lib/src/app/learn/services/learn_progress_service.dartsrc/mobile/lib/src/app/learn/services/learn_quiz_scoring_service.dartsrc/mobile/lib/src/app/learn/theme/learn_design_tokens.dartsrc/mobile/lib/src/app/learn/widgets/experience/learn_article_activity.dartsrc/mobile/lib/src/app/learn/widgets/experience/learn_article_audio_player.dartsrc/mobile/lib/src/app/learn/widgets/experience/learn_course_certificate.dartsrc/mobile/lib/src/app/learn/widgets/experience/learn_experience_shell.dartsrc/mobile/lib/src/app/learn/widgets/experience/learn_image_activity.dartsrc/mobile/lib/src/app/learn/widgets/experience/learn_lesson_experience.dartsrc/mobile/lib/src/app/learn/widgets/experience/learn_lesson_finish_pane.dartsrc/mobile/lib/src/app/learn/widgets/experience/learn_level_unlock_pane.dartsrc/mobile/lib/src/app/learn/widgets/experience/learn_quiz_activity.dartsrc/mobile/lib/src/app/learn/widgets/experience/learn_quiz_free_text.dartsrc/mobile/lib/src/app/learn/widgets/experience/learn_quiz_multi_choice.dartsrc/mobile/lib/src/app/learn/widgets/experience/learn_quiz_option.dartsrc/mobile/lib/src/app/learn/widgets/experience/learn_quiz_ranking.dartsrc/mobile/lib/src/app/learn/widgets/experience/learn_quiz_single_choice.dartsrc/mobile/lib/src/app/learn/widgets/experience/learn_video_activity.dartsrc/mobile/lib/src/app/learn/widgets/kya_lesson_container.dartsrc/mobile/lib/src/app/learn/widgets/learn_bottom_sheets.dartsrc/mobile/lib/src/app/learn/widgets/learn_completion_sheet.dartsrc/mobile/lib/src/app/learn/widgets/learn_course_portrait_card.dartsrc/mobile/lib/src/app/learn/widgets/learn_dashboard_header.dartsrc/mobile/lib/src/app/learn/widgets/learn_lesson_activities.dartsrc/mobile/lib/src/app/learn/widgets/learn_lesson_confetti.dartsrc/mobile/lib/src/app/learn/widgets/learn_lesson_image.dartsrc/mobile/lib/src/app/learn/widgets/learn_lesson_list_row.dartsrc/mobile/lib/src/app/learn/widgets/learn_lesson_thumbnail.dartsrc/mobile/lib/src/app/learn/widgets/learn_level_summary_card.dartsrc/mobile/lib/src/app/learn/widgets/learn_sheet_button_styles.dartsrc/mobile/lib/src/app/learn/widgets/learn_unit_chip.dartsrc/mobile/lib/src/app/map/pages/map_page.dartsrc/mobile/lib/src/app/map/widgets/map_air_quality_card.dartsrc/mobile/lib/src/app/shared/widgets/aqi_category_chip.dartsrc/mobile/lib/src/app/surveys/pages/survey_list_page.dartsrc/mobile/lib/src/app/surveys/widgets/survey_card.dartsrc/mobile/lib/src/app/surveys/widgets/survey_list_content.dartsrc/mobile/lib/src/meta/utils/colors.dartsrc/mobile/lib/src/meta/utils/forecast_utils.dartsrc/mobile/pubspec.yaml
| const LearnLessonActivity({ | ||
| required this.index, | ||
| required this.type, | ||
| required this.title, | ||
| this.article, | ||
| this.video, | ||
| this.image, | ||
| this.quiz, | ||
| }); |
There was a problem hiding this comment.
🛠️ 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.
| 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.
| enum LearnQuizType { | ||
| singleChoice, | ||
| multiChoice, | ||
| spotIt, | ||
| ranking, | ||
| freeText, | ||
| } |
There was a problem hiding this comment.
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.
| await ensureInitialized(); | ||
| if (courses.isEmpty) return; | ||
|
|
||
| _syncLegacyApiProgressToCatalog(courses); |
There was a problem hiding this comment.
🧩 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.dartRepository: 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.
| _syncLegacyApiProgressToCatalog(courses); | |
| await _syncLegacyApiProgressToCatalog(courses); |
| _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.
| final course = courses.firstWhere((c) => c.id == continuation.learnCourseId); | ||
| final unit = course.units[continuation.unitIndex]; | ||
| final chain = LearnCatalog.continuationFor( |
There was a problem hiding this comment.
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).
| late final List<GlobalKey> _chipKeys; | ||
|
|
||
| @override | ||
| void initState() { | ||
| super.initState(); | ||
| _chipKeys = List.generate(widget.course.units.length, (_) => GlobalKey()); | ||
| } |
There was a problem hiding this comment.
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.
| 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, | ||
| ), |
There was a problem hiding this comment.
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.
| 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.
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
…irqo-platform/AirQo-frontend into feature/forecast-details-sheet
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>
Summary
Test plan
dart analyzeundersrc/mobileSummary by CodeRabbit
Release Notes
New Features
UI & Design
Updates