From 9f40941390eb48d66ea3864be46ae7a034ab2597 Mon Sep 17 00:00:00 2001 From: 2phonebabykeem Date: Wed, 17 Jun 2026 20:12:06 +0300 Subject: [PATCH 1/5] feat(mobile): forecast details sheet and guidance Expand forecast overview with day/hourly detail, guidance, time scope, and shared AQI chip; wire analytics and map entry points. Co-authored-by: Cursor --- .../assets/images/forecast/droplets.svg | 4 + src/mobile/assets/images/forecast/rain.svg | 3 + .../assets/images/forecast/thermometer.svg | 3 + src/mobile/assets/images/forecast/wind.svg | 3 + .../dashboard/models/forecast_guidance.dart | 104 +++++ .../pages/forecast_overview_page.dart | 440 +++++++++++++----- .../components/swipeable_analytics_card.dart | 72 +-- .../dashboard/utils/forecast_met_icons.dart | 10 + .../utils/measurement_location_utils.dart | 40 ++ .../app/dashboard/widgets/analytics_card.dart | 81 +--- .../dashboard/widgets/analytics_details.dart | 9 +- .../widgets/analytics_specifics.dart | 32 +- .../widgets/expanded_analytics_card.dart | 46 +- .../widgets/forecast_day_detail_card.dart | 136 ++---- .../widgets/forecast_day_selector.dart | 28 +- .../widgets/forecast_guidance_section.dart | 95 ++++ .../widgets/forecast_hourly_section.dart | 105 +++-- .../dashboard/widgets/forecast_met_row.dart | 79 ++-- .../widgets/forecast_time_scope_selector.dart | 71 +++ .../widgets/nearby_measurement_card.dart | 74 +-- .../lib/src/app/map/pages/map_page.dart | 15 +- .../app/map/widgets/map_air_quality_card.dart | 14 +- .../app/shared/widgets/aqi_category_chip.dart | 32 ++ src/mobile/lib/src/meta/utils/colors.dart | 142 +++++- .../lib/src/meta/utils/forecast_utils.dart | 39 +- src/mobile/pubspec.yaml | 1 + 26 files changed, 1132 insertions(+), 546 deletions(-) create mode 100644 src/mobile/assets/images/forecast/droplets.svg create mode 100644 src/mobile/assets/images/forecast/rain.svg create mode 100644 src/mobile/assets/images/forecast/thermometer.svg create mode 100644 src/mobile/assets/images/forecast/wind.svg create mode 100644 src/mobile/lib/src/app/dashboard/models/forecast_guidance.dart create mode 100644 src/mobile/lib/src/app/dashboard/utils/forecast_met_icons.dart create mode 100644 src/mobile/lib/src/app/dashboard/utils/measurement_location_utils.dart create mode 100644 src/mobile/lib/src/app/dashboard/widgets/forecast_guidance_section.dart create mode 100644 src/mobile/lib/src/app/dashboard/widgets/forecast_time_scope_selector.dart create mode 100644 src/mobile/lib/src/app/shared/widgets/aqi_category_chip.dart diff --git a/src/mobile/assets/images/forecast/droplets.svg b/src/mobile/assets/images/forecast/droplets.svg new file mode 100644 index 0000000000..68ef84e0c3 --- /dev/null +++ b/src/mobile/assets/images/forecast/droplets.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/mobile/assets/images/forecast/rain.svg b/src/mobile/assets/images/forecast/rain.svg new file mode 100644 index 0000000000..525378529c --- /dev/null +++ b/src/mobile/assets/images/forecast/rain.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/mobile/assets/images/forecast/thermometer.svg b/src/mobile/assets/images/forecast/thermometer.svg new file mode 100644 index 0000000000..716a182497 --- /dev/null +++ b/src/mobile/assets/images/forecast/thermometer.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/mobile/assets/images/forecast/wind.svg b/src/mobile/assets/images/forecast/wind.svg new file mode 100644 index 0000000000..c1c8d47d47 --- /dev/null +++ b/src/mobile/assets/images/forecast/wind.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/mobile/lib/src/app/dashboard/models/forecast_guidance.dart b/src/mobile/lib/src/app/dashboard/models/forecast_guidance.dart new file mode 100644 index 0000000000..5657ee866b --- /dev/null +++ b/src/mobile/lib/src/app/dashboard/models/forecast_guidance.dart @@ -0,0 +1,104 @@ +import 'package:airqo/src/app/dashboard/models/forecast_response.dart'; + +/// API-sourced guidance for the forecast modal guidance panel. +class ForecastGuidance { + final String? message; + final String? trendMessage; + + const ForecastGuidance({ + this.message, + this.trendMessage, + }); + + bool get hasContent => _nonEmpty(message) || _nonEmpty(trendMessage); + + static bool _nonEmpty(String? value) => + value != null && value.trim().isNotEmpty; +} + +ForecastGuidance guidanceFromForecast(Forecast forecast) { + return ForecastGuidance( + message: _trimOrNull(forecast.aqiLabel), + trendMessage: _trimOrNull(forecast.trendMessage), + ); +} + +ForecastGuidance guidanceFromHourlyEntry(HourlyForecastEntry entry) { + return ForecastGuidance( + message: _trimOrNull(entry.aqiLabel), + trendMessage: _trimOrNull(entry.trendMessage), + ); +} + +String? _trimOrNull(String? value) { + if (value == null) return null; + final trimmed = value.trim(); + return trimmed.isEmpty ? null : trimmed; +} + +/// Normalized reading fields for the shared forecast detail card. +class ForecastReadingSnapshot { + final double pm25; + final String aqiCategory; + final String aqiColor; + final double? forecastConfidence; + final ForecastMet? met; + + const ForecastReadingSnapshot({ + required this.pm25, + required this.aqiCategory, + required this.aqiColor, + this.forecastConfidence, + this.met, + }); + + factory ForecastReadingSnapshot.fromDaily(Forecast forecast) { + return ForecastReadingSnapshot( + pm25: forecast.pm25, + aqiCategory: forecast.aqiCategory, + aqiColor: forecast.aqiColor, + forecastConfidence: forecast.forecastConfidence, + met: forecast.met, + ); + } + + factory ForecastReadingSnapshot.fromHourly(HourlyForecastEntry entry) { + return ForecastReadingSnapshot( + pm25: entry.pm25Mean, + aqiCategory: entry.aqiCategory, + aqiColor: entry.aqiColor, + forecastConfidence: entry.forecastConfidence, + met: entry.met, + ); + } +} + +List hourlyEntriesForDate( + HourlyForecastResponse? response, + DateTime date, +) { + if (response == null) return []; + final dateStr = _fmtDate(date.toLocal()); + return response.forecasts + .where((e) => _fmtDate(e.time.toLocal()) == dateStr) + .toList(); +} + +String _fmtDate(DateTime dt) { + return '${dt.year.toString().padLeft(4, '0')}-' + '${dt.month.toString().padLeft(2, '0')}-' + '${dt.day.toString().padLeft(2, '0')}'; +} + +int defaultHourlyIndex(List 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; +} diff --git a/src/mobile/lib/src/app/dashboard/pages/forecast_overview_page.dart b/src/mobile/lib/src/app/dashboard/pages/forecast_overview_page.dart index aa8619ced2..74839af370 100644 --- a/src/mobile/lib/src/app/dashboard/pages/forecast_overview_page.dart +++ b/src/mobile/lib/src/app/dashboard/pages/forecast_overview_page.dart @@ -1,31 +1,90 @@ import 'package:airqo/src/app/dashboard/bloc/forecast/forecast_bloc.dart'; +import 'package:airqo/src/app/dashboard/models/airquality_response.dart'; +import 'package:airqo/src/app/dashboard/models/forecast_guidance.dart'; +import 'package:airqo/src/app/dashboard/models/forecast_response.dart'; +import 'package:airqo/src/app/dashboard/utils/measurement_location_utils.dart'; import 'package:airqo/src/app/dashboard/widgets/forecast_day_detail_card.dart'; import 'package:airqo/src/app/dashboard/widgets/forecast_day_selector.dart'; +import 'package:airqo/src/app/dashboard/widgets/forecast_guidance_section.dart'; import 'package:airqo/src/app/dashboard/widgets/forecast_hourly_section.dart'; -import 'package:airqo/src/app/dashboard/widgets/forecast_met_row.dart'; +import 'package:airqo/src/app/dashboard/widgets/forecast_time_scope_selector.dart'; import 'package:airqo/src/app/shared/widgets/loading_widget.dart'; import 'package:airqo/src/meta/utils/colors.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:intl/intl.dart'; class ForecastOverviewPage extends StatefulWidget { final String siteId; final String siteName; + final String locationDescription; + final Measurement? measurement; const ForecastOverviewPage({ super.key, required this.siteId, required this.siteName, + required this.locationDescription, + this.measurement, }); + static Future showForMeasurement( + BuildContext context, { + required Measurement measurement, + String? fallbackLocationName, + }) { + final siteId = measurement.siteDetails?.id; + if (siteId == null) return Future.value(); + + return show( + context, + siteId: siteId, + siteName: measurementDisplayName( + measurement, + fallbackLocationName: fallbackLocationName, + ), + locationDescription: measurementLocationDescription(measurement), + measurement: measurement, + ); + } + + static Future show( + BuildContext context, { + required String siteId, + required String siteName, + required String locationDescription, + Measurement? measurement, + }) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useRootNavigator: true, + useSafeArea: false, + backgroundColor: Colors.transparent, + builder: (sheetContext) { + return SizedBox( + height: MediaQuery.sizeOf(sheetContext).height, + child: ForecastOverviewPage( + siteId: siteId, + siteName: siteName, + locationDescription: locationDescription, + measurement: measurement, + ), + ); + }, + ); + } + @override State createState() => _ForecastOverviewPageState(); } class _ForecastOverviewPageState extends State { int _selectedDayIndex = 0; - final _scrollController = ScrollController(); + int _selectedHourIndex = 0; + ForecastTimeScope _timeScope = ForecastTimeScope.daily; + ScrollController? _scrollController; final _todayStr = DateFormat('yyyy-MM-dd').format(DateTime.now()); @override @@ -34,16 +93,32 @@ class _ForecastOverviewPageState extends State { context.read().add(LoadForecast(widget.siteId)); } - @override - void dispose() { - _scrollController.dispose(); - super.dispose(); + void _selectDay(int index) { + setState(() { + _selectedDayIndex = index; + _selectedHourIndex = 0; + }); + _scrollToTop(); } - void _selectDay(int index) { - setState(() => _selectedDayIndex = index); - if (_scrollController.hasClients) { - _scrollController.animateTo( + void _selectHour(int index) { + setState(() => _selectedHourIndex = index); + _scrollToTop(); + } + + void _setTimeScope(ForecastTimeScope scope) { + if (_timeScope == scope) return; + setState(() { + _timeScope = scope; + _selectedHourIndex = 0; + }); + _scrollToTop(); + } + + void _scrollToTop() { + final controller = _scrollController; + if (controller != null && controller.hasClients) { + controller.animateTo( 0, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, @@ -51,81 +126,185 @@ class _ForecastOverviewPageState extends State { } } + void _syncTodayIndex(List forecasts) { + if (_selectedDayIndex == 0) { + final todayIdx = + forecasts.indexWhere((f) => _fmtDate(f.time) == _todayStr); + if (todayIdx > 0) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => setState(() => _selectedDayIndex = todayIdx)); + } + } + } + + void _syncHourIndex(List entries, DateTime day) { + if (_timeScope != ForecastTimeScope.hourly || entries.isEmpty) return; + final defaultIdx = defaultHourlyIndex(entries, day); + if (_selectedHourIndex == 0 && defaultIdx != 0) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => setState(() => _selectedHourIndex = defaultIdx)); + } + } + @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; - return Scaffold( - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - appBar: AppBar( - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '7-Day Forecast', - style: Theme.of(context) - .textTheme - .titleMedium - ?.copyWith(fontWeight: FontWeight.w600), - ), - Text( - widget.siteName, - style: Theme.of(context) - .textTheme - .bodySmall - ?.copyWith(color: AppColors.boldHeadlineColor), - ), - ], + final bg = AppSurfaceColors.sheet(context); + final nameColor = Theme.of(context).textTheme.headlineSmall?.color; + final locationColor = AppTextColors.muted(context); + + return DraggableScrollableSheet( + initialChildSize: 0.88, + minChildSize: 0.5, + maxChildSize: 0.95, + expand: false, + builder: (ctx, scrollController) { + _scrollController = scrollController; + return Container( + decoration: BoxDecoration( + color: bg, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _dragHandle(context), + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 12, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.siteName, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 24, + color: nameColor, + ), + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Row( + children: [ + SvgPicture.asset( + 'assets/images/shared/location_pin.svg', + width: 14, + height: 14, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + widget.locationDescription, + style: TextStyle( + fontSize: 14, + color: locationColor, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: Icon( + Icons.close, + color: AppTextColors.modalCloseIcon(context), + ), + visualDensity: VisualDensity.compact, + ), + ], + ), + ), + const SizedBox(height: 12), + Expanded( + child: BlocConsumer( + listenWhen: (_, curr) => + curr is ForecastLoaded && curr.siteId == widget.siteId, + listener: (context, state) { + if (state is ForecastLoaded && + state.hourlyResponse == null) { + context + .read() + .add(LoadHourlyForecast(widget.siteId)); + } + }, + builder: (context, state) { + if (state is ForecastLoading) { + return _skeleton(context, scrollController); + } + if (state is ForecastNetworkError) { + return _error(context, state.message, isNetwork: true); + } + if (state is ForecastLoadingError) { + return _error(context, state.message); + } + if (state is ForecastLoaded && + state.siteId == widget.siteId) { + return _content(context, state, isDark, scrollController); + } + if (state is HourlyForecastLoading && + state.siteId == widget.siteId) { + return _content( + context, + ForecastLoaded(state.dailyResponse, + siteId: widget.siteId), + isDark, + scrollController, + hourlyLoading: true, + ); + } + if (state is HourlyForecastError && + state.siteId == widget.siteId) { + return _content( + context, + ForecastLoaded(state.dailyResponse, + siteId: widget.siteId), + isDark, + scrollController, + hourlyError: state.message, + ); + } + return _skeleton(context, scrollController); + }, + ), + ), + ], + ), + ); + }, + ); + } + + Widget _dragHandle(BuildContext context) { + final handleColor = AppTextColors.muted(context); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Center( + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: handleColor.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), ), ), - body: BlocConsumer( - listenWhen: (_, curr) => - curr is ForecastLoaded && curr.siteId == widget.siteId, - listener: (context, state) { - if (state is ForecastLoaded && state.hourlyResponse == null) { - context - .read() - .add(LoadHourlyForecast(widget.siteId)); - } - }, - builder: (context, state) { - if (state is ForecastLoading) return _skeleton(context); - if (state is ForecastNetworkError) { - return _error(context, state.message, isNetwork: true); - } - if (state is ForecastLoadingError) { - return _error(context, state.message); - } - if (state is ForecastLoaded && state.siteId == widget.siteId) { - return _content(context, state, isDark); - } - if (state is HourlyForecastLoading && - state.siteId == widget.siteId) { - return _content( - context, - ForecastLoaded(state.dailyResponse, siteId: widget.siteId), - isDark, - hourlyLoading: true, - ); - } - if (state is HourlyForecastError && - state.siteId == widget.siteId) { - return _content( - context, - ForecastLoaded(state.dailyResponse, siteId: widget.siteId), - isDark, - hourlyError: state.message, - ); - } - return _skeleton(context); - }, - ), ); } Widget _content( BuildContext context, ForecastLoaded state, - bool isDark, { + bool isDark, + ScrollController scrollController, { bool hourlyLoading = false, String? hourlyError, }) { @@ -134,56 +313,104 @@ class _ForecastOverviewPageState extends State { return const Center(child: Text('No forecast data available.')); } - if (_selectedDayIndex == 0) { - final todayIdx = - forecasts.indexWhere((f) => _fmtDate(f.time) == _todayStr); - if (todayIdx > 0) { - WidgetsBinding.instance - .addPostFrameCallback((_) => setState(() => _selectedDayIndex = todayIdx)); - } - } + _syncTodayIndex(forecasts); final idx = _selectedDayIndex.clamp(0, forecasts.length - 1); final day = forecasts[idx]; + final selectedDateLabel = DateFormat('EEEE, MMMM d').format(day.time); + final hourEntries = + hourlyEntriesForDate(state.hourlyResponse, day.time); + _syncHourIndex(hourEntries, day.time); + + final hourIdx = hourEntries.isEmpty + ? 0 + : _selectedHourIndex.clamp(0, hourEntries.length - 1); + final selectedHour = + hourEntries.isNotEmpty ? hourEntries[hourIdx] : null; return SingleChildScrollView( - controller: _scrollController, + controller: scrollController, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - ForecastDaySelector( - forecasts: forecasts, - selectedIndex: idx, - todayStr: _todayStr, - onSelected: _selectDay, - isDark: isDark, + Text( + selectedDateLabel, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 18, + color: Theme.of(context).textTheme.headlineSmall?.color, + ), ), - const SizedBox(height: 20), - ForecastDayDetailCard(forecast: day, isDark: isDark), - const SizedBox(height: 20), - ForecastMetRow(met: day.met), - const SizedBox(height: 20), - ForecastHourlySection( - siteId: widget.siteId, - selectedDate: day.time, - hourlyResponse: state.hourlyResponse, - isLoading: hourlyLoading, - errorMessage: hourlyError, - isDark: isDark, + const SizedBox(height: 12), + ForecastTimeScopeSelector( + selected: _timeScope, + onSelected: _setTimeScope, + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: AppSurfaceColors.sheetPanelDecoration(context), + child: ForecastDaySelector( + forecasts: forecasts, + selectedIndex: idx, + todayStr: _todayStr, + onSelected: _selectDay, + isDark: isDark, + onInsetPanel: true, + ), ), + const SizedBox(height: 20), + if (_timeScope == ForecastTimeScope.daily) ...[ + ForecastDayDetailCard( + reading: ForecastReadingSnapshot.fromDaily(day), + isDark: isDark, + ), + const SizedBox(height: 20), + ForecastGuidanceSection.fromForecast(day), + ] else ...[ + Container( + padding: const EdgeInsets.all(12), + decoration: AppSurfaceColors.sheetPanelDecoration(context), + child: ForecastHourlySection( + siteId: widget.siteId, + selectedDate: day.time, + hourlyResponse: state.hourlyResponse, + isLoading: hourlyLoading, + errorMessage: hourlyError, + isDark: isDark, + onInsetPanel: true, + selectedHourIndex: hourIdx, + onHourSelected: _selectHour, + ), + ), + if (selectedHour != null) ...[ + const SizedBox(height: 20), + ForecastDayDetailCard( + reading: ForecastReadingSnapshot.fromHourly(selectedHour), + isDark: isDark, + ), + const SizedBox(height: 20), + ForecastGuidanceSection.fromHourly(selectedHour), + ], + ], const SizedBox(height: 32), ], ), ); } - Widget _skeleton(BuildContext context) { + Widget _skeleton(BuildContext context, ScrollController scrollController) { return SingleChildScrollView( + controller: scrollController, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + ShimmerContainer(height: 22, width: 180, borderRadius: 8), + const SizedBox(height: 12), + ShimmerContainer(height: 44, width: 200, borderRadius: 22), + const SizedBox(height: 16), Row( children: List.generate( 7, @@ -198,13 +425,10 @@ class _ForecastOverviewPageState extends State { ), const SizedBox(height: 20), ShimmerContainer( - height: 200, width: double.infinity, borderRadius: 16), - const SizedBox(height: 20), - ShimmerContainer( - height: 80, width: double.infinity, borderRadius: 16), + height: 240, width: double.infinity, borderRadius: 16), const SizedBox(height: 20), ShimmerContainer( - height: 130, width: double.infinity, borderRadius: 16), + height: 120, width: double.infinity, borderRadius: 16), ], ), ); @@ -223,7 +447,7 @@ class _ForecastOverviewPageState extends State { ? Icons.wifi_off_rounded : Icons.error_outline_rounded, size: 56, - color: AppColors.boldHeadlineColor, + color: AppTextColors.muted(context), ), const SizedBox(height: 16), Text( diff --git a/src/mobile/lib/src/app/dashboard/pages/location_selection/components/swipeable_analytics_card.dart b/src/mobile/lib/src/app/dashboard/pages/location_selection/components/swipeable_analytics_card.dart index 2a2cf3de85..bbea95a383 100644 --- a/src/mobile/lib/src/app/dashboard/pages/location_selection/components/swipeable_analytics_card.dart +++ b/src/mobile/lib/src/app/dashboard/pages/location_selection/components/swipeable_analytics_card.dart @@ -2,9 +2,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:loggy/loggy.dart'; import 'package:airqo/src/app/dashboard/models/airquality_response.dart'; -import 'package:airqo/src/app/dashboard/widgets/analytics_details.dart'; +import 'package:airqo/src/app/dashboard/pages/forecast_overview_page.dart'; import 'package:airqo/src/app/shared/widgets/translated_text.dart'; import 'package:airqo/src/meta/utils/colors.dart'; +import 'package:airqo/src/meta/utils/forecast_utils.dart'; import 'package:airqo/src/meta/utils/utils.dart'; import 'dart:async'; @@ -165,18 +166,14 @@ class _SwipeableAnalyticsCardState extends State ); } - void _showAnalyticsDetails() { + void _openForecast() { if (_isDeleteVisible || _dragOffset < 0) return; - showBottomSheet( - backgroundColor: Colors.transparent, - context: context, - builder: (context) { - return AnalyticsDetails( - measurement: widget.measurement, - fallbackLocationName: widget.fallbackLocationName, - ); - }); + ForecastOverviewPage.showForMeasurement( + context, + measurement: widget.measurement, + fallbackLocationName: widget.fallbackLocationName, + ); } String _getLocationDescription(Measurement measurement) { @@ -209,32 +206,7 @@ class _SwipeableAnalyticsCardState extends State } Color _getAqiColor(Measurement measurement) { - if (measurement.aqiColor != null) { - try { - final colorStr = measurement.aqiColor!.replaceAll('#', ''); - return Color(int.parse('0xFF$colorStr')); - } catch (e) { - loggy.warning('Failed to parse AQI color: ${measurement.aqiColor}'); - } - } - - switch (measurement.aqiCategory?.toLowerCase() ?? '') { - case 'good': - return Colors.green; - case 'moderate': - return Colors.yellow.shade700; - case 'unhealthy for sensitive groups': - case 'u4sg': - return Colors.orange; - case 'unhealthy': - return Colors.red; - case 'very unhealthy': - return Colors.purple; - case 'hazardous': - return Colors.brown; - default: - return AppColors.primaryColor; - } + return getAppAqiCategoryColor(measurement.aqiCategory ?? ''); } void _handleRemove() { @@ -308,7 +280,7 @@ class _SwipeableAnalyticsCardState extends State final shakeOffset = (_shakeAnimation?.value ?? 0.0) * 15.0; return GestureDetector( - onTap: _showAnalyticsDetails, + onTap: _openForecast, onLongPress: _showHelpTooltip, onHorizontalDragStart: (details) { if (_showTooltip) { @@ -344,17 +316,7 @@ class _SwipeableAnalyticsCardState extends State offset: Offset(_dragOffset + shakeOffset, 0), child: Container( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: Theme.of(context).cardColor, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.1), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), + decoration: AppSurfaceColors.elevatedCardDecoration(context), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -390,10 +352,10 @@ class _SwipeableAnalyticsCardState extends State const SizedBox(height: 4), Row( children: [ - Icon( - Icons.location_on, - size: 14, - color: AppColors.primaryColor, + SvgPicture.asset( + 'assets/images/shared/location_pin.svg', + width: 14, + height: 14, ), const SizedBox(width: 4), Expanded( @@ -423,9 +385,7 @@ class _SwipeableAnalyticsCardState extends State ), Divider( thickness: .5, - color: Theme.of(context).brightness == Brightness.dark - ? AppColors.dividerColordark - : AppColors.dividerColorlight, + color: Theme.of(context).dividerColor, ), Padding( padding: const EdgeInsets.only( diff --git a/src/mobile/lib/src/app/dashboard/utils/forecast_met_icons.dart b/src/mobile/lib/src/app/dashboard/utils/forecast_met_icons.dart new file mode 100644 index 0000000000..5a8c956ee8 --- /dev/null +++ b/src/mobile/lib/src/app/dashboard/utils/forecast_met_icons.dart @@ -0,0 +1,10 @@ +/// AirQo Icons (aero-glyphs) weather assets bundled for forecast met tiles. +/// Source: @airqo/icons-react — AqThermometer01, AqDroplets01, AqWind01, AqCloudRaining01 +class ForecastMetIcons { + const ForecastMetIcons._(); + + static const temperature = 'assets/images/forecast/thermometer.svg'; + static const humidity = 'assets/images/forecast/droplets.svg'; + static const wind = 'assets/images/forecast/wind.svg'; + static const rain = 'assets/images/forecast/rain.svg'; +} diff --git a/src/mobile/lib/src/app/dashboard/utils/measurement_location_utils.dart b/src/mobile/lib/src/app/dashboard/utils/measurement_location_utils.dart new file mode 100644 index 0000000000..6ef368e016 --- /dev/null +++ b/src/mobile/lib/src/app/dashboard/utils/measurement_location_utils.dart @@ -0,0 +1,40 @@ +import 'package:airqo/src/app/dashboard/models/airquality_response.dart'; + +String measurementDisplayName( + Measurement measurement, { + String? fallbackLocationName, +}) { + return measurement.siteDetails?.searchName ?? + measurement.siteDetails?.name ?? + fallbackLocationName ?? + '---'; +} + +String measurementLocationDescription(Measurement measurement) { + final siteDetails = measurement.siteDetails; + if (siteDetails == null) return 'Unknown location'; + + final locationParts = []; + + if (siteDetails.city != null && siteDetails.city!.isNotEmpty) { + locationParts.add(siteDetails.city!); + } else if (siteDetails.town != null && siteDetails.town!.isNotEmpty) { + locationParts.add(siteDetails.town!); + } + + if (siteDetails.region != null && siteDetails.region!.isNotEmpty) { + locationParts.add(siteDetails.region!); + } else if (siteDetails.county != null && siteDetails.county!.isNotEmpty) { + locationParts.add(siteDetails.county!); + } + + if (siteDetails.country != null && siteDetails.country!.isNotEmpty) { + locationParts.add(siteDetails.country!); + } + + return locationParts.isNotEmpty + ? locationParts.join(', ') + : siteDetails.locationName ?? + siteDetails.formattedName ?? + 'Unknown location'; +} diff --git a/src/mobile/lib/src/app/dashboard/widgets/analytics_card.dart b/src/mobile/lib/src/app/dashboard/widgets/analytics_card.dart index cc7fcf2a8a..768fca8a9a 100644 --- a/src/mobile/lib/src/app/dashboard/widgets/analytics_card.dart +++ b/src/mobile/lib/src/app/dashboard/widgets/analytics_card.dart @@ -2,8 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:loggy/loggy.dart'; import 'package:airqo/src/app/dashboard/models/airquality_response.dart'; -import 'package:airqo/src/app/dashboard/widgets/analytics_details.dart'; +import 'package:airqo/src/app/dashboard/pages/forecast_overview_page.dart'; import 'package:airqo/src/meta/utils/colors.dart'; +import 'package:airqo/src/meta/utils/forecast_utils.dart'; import 'package:airqo/src/meta/utils/utils.dart'; class AnalyticsCard extends StatelessWidget with UiLoggy { @@ -12,16 +13,12 @@ class AnalyticsCard extends StatelessWidget with UiLoggy { const AnalyticsCard(this.measurement, {super.key, this.fallbackLocationName}); - void _showAnalyticsDetails(BuildContext context, Measurement measurement) { - showBottomSheet( - backgroundColor: Colors.transparent, - context: context, - builder: (context) { - return AnalyticsDetails( - measurement: measurement, - fallbackLocationName: fallbackLocationName, - ); - }); + void _openForecast(BuildContext context) { + ForecastOverviewPage.showForMeasurement( + context, + measurement: measurement, + fallbackLocationName: fallbackLocationName, + ); } String _getLocationDescription(Measurement measurement) { @@ -55,51 +52,17 @@ class AnalyticsCard extends StatelessWidget with UiLoggy { Color _getAqiColor(Measurement measurement) { - if (measurement.aqiColor != null) { - try { - final colorStr = measurement.aqiColor!.replaceAll('#', ''); - return Color(int.parse('0xFF$colorStr')); - } catch (e) { - loggy.warning('Failed to parse AQI color: ${measurement.aqiColor}'); - } - } - - switch (measurement.aqiCategory?.toLowerCase() ?? '') { - case 'good': - return Colors.green; - case 'moderate': - return Colors.yellow.shade700; - case 'unhealthy for sensitive groups': - case 'u4sg': - return Colors.orange; - case 'unhealthy': - return Colors.red; - case 'very unhealthy': - return Colors.purple; - case 'hazardous': - return Colors.brown; - default: - return AppColors.primaryColor; - } + return getAppAqiCategoryColor(measurement.aqiCategory ?? ''); } @override Widget build(BuildContext context) { + final locationColor = AppTextColors.muted(context); return InkWell( - onTap: () => _showAnalyticsDetails(context, measurement), + onTap: () => _openForecast(context), child: Container( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: Theme.of(context).cardColor, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.1), - blurRadius: 4, - offset: Offset(0, 2), - ), - ], - ), + decoration: AppSurfaceColors.elevatedCardDecoration(context), child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -137,10 +100,10 @@ class AnalyticsCard extends StatelessWidget with UiLoggy { SizedBox(height: 4), Row( children: [ - Icon( - Icons.location_on, - size: 14, - color: AppColors.primaryColor, + SvgPicture.asset( + 'assets/images/shared/location_pin.svg', + width: 14, + height: 14, ), SizedBox(width: 4), Expanded( @@ -148,11 +111,7 @@ class AnalyticsCard extends StatelessWidget with UiLoggy { _getLocationDescription(measurement), style: TextStyle( fontSize: 14, - color: Theme.of(context) - .textTheme - .bodyMedium - ?.color - ?.withValues(alpha: 0.7), + color: locationColor, ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -168,11 +127,7 @@ class AnalyticsCard extends StatelessWidget with UiLoggy { ], ), ), - Divider( - thickness: .5, - color: Theme.of(context).brightness == Brightness.dark - ? AppColors.dividerColordark - : AppColors.dividerColorlight), + Divider(thickness: .5, color: Theme.of(context).dividerColor), Padding( padding: const EdgeInsets.only( left: 16, right: 16, bottom: 16, top: 4), diff --git a/src/mobile/lib/src/app/dashboard/widgets/analytics_details.dart b/src/mobile/lib/src/app/dashboard/widgets/analytics_details.dart index a70d0c4f38..612e8535b2 100644 --- a/src/mobile/lib/src/app/dashboard/widgets/analytics_details.dart +++ b/src/mobile/lib/src/app/dashboard/widgets/analytics_details.dart @@ -1,5 +1,6 @@ import 'package:airqo/src/app/dashboard/models/airquality_response.dart'; import 'package:airqo/src/app/dashboard/widgets/analytics_specifics.dart'; +import 'package:airqo/src/meta/utils/colors.dart'; import 'package:flutter/material.dart'; class AnalyticsDetails extends StatefulWidget { @@ -60,13 +61,7 @@ class _AnalyticsDetailsState extends State { controller: _controller, builder: (BuildContext context, ScrollController scrollController) { return DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(context).scaffoldBackgroundColor, - borderRadius: BorderRadius.only( - topLeft: Radius.circular(12), - topRight: Radius.circular(12), - ), - ), + decoration: AppSurfaceColors.sheetDecoration(context), // Avoid scroll/route focus stealing IME back from the dismissed // map search field on Android after this sheet attaches. child: FocusScope( diff --git a/src/mobile/lib/src/app/dashboard/widgets/analytics_specifics.dart b/src/mobile/lib/src/app/dashboard/widgets/analytics_specifics.dart index 88a98a1260..fe58834b5a 100644 --- a/src/mobile/lib/src/app/dashboard/widgets/analytics_specifics.dart +++ b/src/mobile/lib/src/app/dashboard/widgets/analytics_specifics.dart @@ -1,10 +1,12 @@ import 'package:airqo/src/app/dashboard/models/airquality_response.dart'; import 'package:airqo/src/app/dashboard/pages/forecast_overview_page.dart'; +import 'package:airqo/src/app/dashboard/utils/measurement_location_utils.dart'; import 'package:airqo/src/app/dashboard/widgets/expanded_analytics_card.dart'; import 'package:airqo/src/app/dashboard/widgets/analytics_forecast_widget.dart'; import 'package:airqo/src/meta/utils/colors.dart'; import 'package:airqo/src/app/shared/widgets/translated_text.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; class AnalyticsSpecifics extends StatefulWidget { final Measurement measurement; @@ -105,7 +107,7 @@ class _AnalyticsSpecificsState extends State { onTap: () => Navigator.pop(context), child: Icon( Icons.close, - color: nameColor, + color: AppTextColors.modalCloseIcon(context), ), ) ], @@ -113,10 +115,10 @@ class _AnalyticsSpecificsState extends State { const SizedBox(height: 4), Row( children: [ - Icon( - Icons.location_on, - size: 14, - color: AppColors.primaryColor, + SvgPicture.asset( + 'assets/images/shared/location_pin.svg', + width: 14, + height: 14, ), const SizedBox(width: 4), Expanded( @@ -148,18 +150,18 @@ class _AnalyticsSpecificsState extends State { if (widget.measurement.siteDetails?.id != null) TextButton( onPressed: () { - Navigator.push( + ForecastOverviewPage.show( context, - MaterialPageRoute( - builder: (_) => ForecastOverviewPage( - siteId: widget.measurement.siteDetails!.id!, - siteName: widget.measurement.siteDetails - ?.searchName ?? - widget.measurement.siteDetails?.name ?? - widget.fallbackLocationName ?? - '', - ), + siteId: widget.measurement.siteDetails!.id!, + siteName: measurementDisplayName( + widget.measurement, + fallbackLocationName: + widget.fallbackLocationName, + ), + locationDescription: measurementLocationDescription( + widget.measurement, ), + measurement: widget.measurement, ); }, child: TranslatedText( diff --git a/src/mobile/lib/src/app/dashboard/widgets/expanded_analytics_card.dart b/src/mobile/lib/src/app/dashboard/widgets/expanded_analytics_card.dart index 83c5fc7730..1a87ec0ec9 100644 --- a/src/mobile/lib/src/app/dashboard/widgets/expanded_analytics_card.dart +++ b/src/mobile/lib/src/app/dashboard/widgets/expanded_analytics_card.dart @@ -4,6 +4,7 @@ import 'package:loggy/loggy.dart'; import 'package:airqo/src/app/dashboard/models/airquality_response.dart'; import 'package:airqo/src/app/dashboard/widgets/analytics_details.dart'; import 'package:airqo/src/meta/utils/colors.dart'; +import 'package:airqo/src/meta/utils/forecast_utils.dart'; import 'package:airqo/src/meta/utils/utils.dart'; import 'package:airqo/src/app/shared/widgets/translated_text.dart'; @@ -33,32 +34,7 @@ class _ExpandedAnalyticsCardState extends State } Color _getAqiColor(Measurement measurement) { - if (measurement.aqiColor != null) { - try { - final colorStr = measurement.aqiColor!.replaceAll('#', ''); - return Color(int.parse('0xFF$colorStr')); - } catch (e) { - loggy.warning('Failed to parse AQI color: ${measurement.aqiColor}'); - } - } - - switch (measurement.aqiCategory?.toLowerCase() ?? '') { - case 'good': - return Colors.green; - case 'moderate': - return Colors.yellow.shade700; - case 'unhealthy for sensitive groups': - case 'u4sg': - return Colors.orange; - case 'unhealthy': - return Colors.red; - case 'very unhealthy': - return Colors.purple; - case 'hazardous': - return Colors.brown; - default: - return AppColors.primaryColor; - } + return getAppAqiCategoryColor(measurement.aqiCategory ?? ''); } String _getHealthTipTagline() { @@ -119,17 +95,7 @@ class _ExpandedAnalyticsCardState extends State onTap: () => _showAnalyticsDetails(context, widget.measurement), child: Container( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: Theme.of(context).cardColor, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.1), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), + decoration: AppSurfaceColors.elevatedCardDecoration(context), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -281,11 +247,7 @@ class _ExpandedAnalyticsCardState extends State ], ), ), - Divider( - thickness: 0.5, - color: Theme.of(context).brightness == Brightness.dark - ? AppColors.dividerColordark - : AppColors.dividerColorlight), + Divider(thickness: 0.5, color: Theme.of(context).dividerColor), Padding( padding: const EdgeInsets.symmetric( horizontal: 16, diff --git a/src/mobile/lib/src/app/dashboard/widgets/forecast_day_detail_card.dart b/src/mobile/lib/src/app/dashboard/widgets/forecast_day_detail_card.dart index caa9863fb0..cd7ee2aa07 100644 --- a/src/mobile/lib/src/app/dashboard/widgets/forecast_day_detail_card.dart +++ b/src/mobile/lib/src/app/dashboard/widgets/forecast_day_detail_card.dart @@ -1,41 +1,55 @@ -import 'package:airqo/src/app/dashboard/models/forecast_response.dart'; +import 'package:airqo/src/app/dashboard/models/forecast_guidance.dart'; +import 'package:airqo/src/app/dashboard/widgets/forecast_met_row.dart'; +import 'package:airqo/src/app/shared/widgets/aqi_category_chip.dart'; import 'package:airqo/src/meta/utils/colors.dart'; import 'package:airqo/src/meta/utils/forecast_utils.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import 'package:intl/intl.dart'; class ForecastDayDetailCard extends StatelessWidget { - final Forecast forecast; + final ForecastReadingSnapshot reading; final bool isDark; const ForecastDayDetailCard({ super.key, - required this.forecast, + required this.reading, required this.isDark, }); @override Widget build(BuildContext context) { - final aqiColor = hexToColor(forecast.aqiColor); - final aqiTextColor = readableAqiColor(aqiColor); + final hasMet = reading.met != null && + (reading.met!.airTemperature != null || + reading.met!.relativeHumidity != null || + reading.met!.windSpeed != null || + reading.met!.precipitationAmount != null); + + final isLight = Theme.of(context).brightness == Brightness.light; return Container( padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: isDark ? AppColors.darkHighlight : Colors.white, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: isDark ? 0.3 : 0.06), - blurRadius: 12, - offset: const Offset(0, 4), - ), - ], - ), + decoration: AppSurfaceColors.sheetPanelDecoration(context, radius: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row( + children: [ + SvgPicture.asset( + isLight + ? 'assets/images/shared/pm_rating_white.svg' + : 'assets/images/shared/pm_rating.svg', + ), + const SizedBox(width: 2), + Text( + 'PM2.5', + style: TextStyle( + color: Theme.of(context).textTheme.headlineSmall?.color, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + const SizedBox(height: 12), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -43,18 +57,11 @@ class ForecastDayDetailCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - DateFormat('EEEE, MMM d').format(forecast.time), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppColors.boldHeadlineColor, - ), - ), - const SizedBox(height: 8), Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( - forecast.pm25.toStringAsFixed(1), + reading.pm25.toStringAsFixed(1), style: Theme.of(context) .textTheme .titleLarge @@ -71,79 +78,34 @@ class ForecastDayDetailCard extends StatelessWidget { 'µg/m³', style: TextStyle( fontSize: 13, - color: AppColors.boldHeadlineColor, + color: AppTextColors.muted(context), ), ), ), ], ), const SizedBox(height: 10), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: aqiColor.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: aqiTextColor.withValues(alpha: 0.5), - width: 1), - ), - child: Text( - forecast.aqiCategory, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: aqiTextColor, - ), - ), - ), - if (forecast.forecastConfidence != null) ...[ - const SizedBox(height: 14), - ForecastConfidenceBar( - confidence: forecast.forecastConfidence!), - ], + AqiCategoryChip(category: reading.aqiCategory), ], ), ), const SizedBox(width: 16), SvgPicture.asset( - getForecastAirQualityIcon(forecast.aqiCategory), + getForecastAirQualityIcon(reading.aqiCategory), width: 72, height: 72, ), ], ), - if (forecast.aqiLabel != null) ...[ - const SizedBox(height: 14), - const Divider(height: 1), - const SizedBox(height: 12), - Text( - forecast.aqiLabel!, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppColors.boldHeadlineColor, - ), - ), + if (reading.forecastConfidence != null) ...[ + const SizedBox(height: 16), + ForecastConfidenceBar(confidence: reading.forecastConfidence!), ], - if (forecast.trendMessage != null) ...[ - const SizedBox(height: 8), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.trending_flat_rounded, - size: 14, color: AppColors.primaryColor), - const SizedBox(width: 4), - Expanded( - child: Text( - forecast.trendMessage!, - style: TextStyle( - fontSize: 11, - color: AppColors.primaryColor, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), + if (hasMet) ...[ + const SizedBox(height: 16), + Divider(height: 1, color: AppSurfaceColors.border(context)), + const SizedBox(height: 16), + ForecastMetRow(met: reading.met, insetOnPanel: true), ], ], ), @@ -160,7 +122,7 @@ class ForecastConfidenceBar extends StatelessWidget { Widget build(BuildContext context) { final clamped = confidence.clamp(0.0, 100.0); return Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -168,14 +130,16 @@ class ForecastConfidenceBar extends StatelessWidget { Text( 'Forecast confidence', style: TextStyle( - fontSize: 11, color: AppColors.boldHeadlineColor), + fontSize: 11, + color: AppTextColors.subtitle(context), + ), ), Text( '${clamped.round()}%', style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: AppColors.boldHeadlineColor, + color: AppTextColors.muted(context), ), ), ], @@ -186,7 +150,9 @@ class ForecastConfidenceBar extends StatelessWidget { child: LinearProgressIndicator( value: clamped / 100, minHeight: 6, - backgroundColor: AppColors.dividerColorlight, + backgroundColor: Theme.of(context).brightness == Brightness.dark + ? AppColors.dividerColordark + : AppColors.boldHeadlineColor4.withValues(alpha: 0.25), valueColor: AlwaysStoppedAnimation(AppColors.primaryColor), ), diff --git a/src/mobile/lib/src/app/dashboard/widgets/forecast_day_selector.dart b/src/mobile/lib/src/app/dashboard/widgets/forecast_day_selector.dart index 96a4af04db..cc7b167cc4 100644 --- a/src/mobile/lib/src/app/dashboard/widgets/forecast_day_selector.dart +++ b/src/mobile/lib/src/app/dashboard/widgets/forecast_day_selector.dart @@ -11,6 +11,7 @@ class ForecastDaySelector extends StatelessWidget { final String todayStr; final ValueChanged onSelected; final bool isDark; + final bool onInsetPanel; const ForecastDaySelector({ super.key, @@ -19,6 +20,7 @@ class ForecastDaySelector extends StatelessWidget { required this.todayStr, required this.onSelected, required this.isDark, + this.onInsetPanel = false, }); @override @@ -30,13 +32,24 @@ class ForecastDaySelector extends StatelessWidget { final isToday = DateFormat('yyyy-MM-dd').format(f.time) == todayStr; Color bgColor; + Border? chipBorder; if (isActive) { bgColor = AppColors.primaryColor; } else if (isToday) { - bgColor = AppColors.primaryColor.withValues(alpha: 0.08); + bgColor = isDark + ? AppSurfaceColors.panelChip(context) + : AppColors.primaryColor.withValues(alpha: 0.08); + chipBorder = Border.all( + color: AppColors.primaryColor.withValues(alpha: 0.6), + width: 1.5, + ); } else { - bgColor = - isDark ? AppColors.darkHighlight : AppColors.highlightColor; + bgColor = onInsetPanel + ? AppSurfaceColors.panelChip(context) + : AppSurfaceColors.nested(context); + if (isDark) { + chipBorder = Border.all(color: AppSurfaceColors.border(context)); + } } return Expanded( @@ -50,12 +63,7 @@ class ForecastDaySelector extends StatelessWidget { decoration: BoxDecoration( color: bgColor, borderRadius: BorderRadius.circular(12), - border: isToday && !isActive - ? Border.all( - color: - AppColors.primaryColor.withValues(alpha: 0.6), - width: 1.5) - : null, + border: chipBorder, ), child: Column( mainAxisSize: MainAxisSize.min, @@ -92,7 +100,7 @@ class ForecastDaySelector extends StatelessWidget { ? Colors.white70 : isToday ? AppColors.primaryColor - : AppColors.boldHeadlineColor, + : AppTextColors.muted(context), ), ), const SizedBox(height: 2), diff --git a/src/mobile/lib/src/app/dashboard/widgets/forecast_guidance_section.dart b/src/mobile/lib/src/app/dashboard/widgets/forecast_guidance_section.dart new file mode 100644 index 0000000000..b09c72e115 --- /dev/null +++ b/src/mobile/lib/src/app/dashboard/widgets/forecast_guidance_section.dart @@ -0,0 +1,95 @@ +import 'package:airqo/src/app/dashboard/models/forecast_guidance.dart'; +import 'package:airqo/src/app/dashboard/models/forecast_response.dart'; +import 'package:airqo/src/app/shared/widgets/translated_text.dart'; +import 'package:airqo/src/meta/utils/colors.dart'; +import 'package:flutter/material.dart'; + +class ForecastGuidanceSection extends StatelessWidget { + final ForecastGuidance guidance; + + const ForecastGuidanceSection({super.key, required this.guidance}); + + factory ForecastGuidanceSection.fromForecast(Forecast forecast) { + return ForecastGuidanceSection(guidance: guidanceFromForecast(forecast)); + } + + factory ForecastGuidanceSection.fromHourly(HourlyForecastEntry entry) { + return ForecastGuidanceSection(guidance: guidanceFromHourlyEntry(entry)); + } + + @override + Widget build(BuildContext context) { + if (!guidance.hasContent) { + return const SizedBox.shrink(); + } + + final message = guidance.message; + final trendMessage = guidance.trendMessage; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: AppSurfaceColors.sheetPanelDecoration(context, radius: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.medical_services_outlined, + color: Colors.red, + size: 24, + ), + const SizedBox(width: 8), + Flexible( + child: TranslatedText( + 'Air quality guidance', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Theme.of(context).textTheme.bodyMedium?.color, + ), + ), + ), + ], + ), + if (message != null) ...[ + const SizedBox(height: 16), + Text( + message, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + color: Theme.of(context).textTheme.bodyLarge?.color, + ), + ), + ], + if (trendMessage != null) ...[ + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.trending_flat_rounded, + size: 14, + color: AppColors.primaryColor, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + trendMessage, + style: TextStyle( + fontSize: 13, + color: AppColors.primaryColor, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ], + ], + ), + ); + } +} diff --git a/src/mobile/lib/src/app/dashboard/widgets/forecast_hourly_section.dart b/src/mobile/lib/src/app/dashboard/widgets/forecast_hourly_section.dart index b9ad0a1631..6b75b1e8ef 100644 --- a/src/mobile/lib/src/app/dashboard/widgets/forecast_hourly_section.dart +++ b/src/mobile/lib/src/app/dashboard/widgets/forecast_hourly_section.dart @@ -15,6 +15,9 @@ class ForecastHourlySection extends StatelessWidget { final bool isLoading; final String? errorMessage; final bool isDark; + final bool onInsetPanel; + final int selectedHourIndex; + final ValueChanged? onHourSelected; const ForecastHourlySection({ super.key, @@ -24,6 +27,9 @@ class ForecastHourlySection extends StatelessWidget { this.isLoading = false, this.errorMessage, required this.isDark, + this.onInsetPanel = false, + this.selectedHourIndex = 0, + this.onHourSelected, }); @override @@ -35,14 +41,6 @@ class ForecastHourlySection extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Hourly Forecast', - style: Theme.of(context) - .textTheme - .titleSmall - ?.copyWith(fontWeight: FontWeight.w600), - ), - const SizedBox(height: 12), if (isLoading) SizedBox( height: 100, @@ -57,12 +55,16 @@ class ForecastHourlySection extends StatelessWidget { ), ) else if (errorMessage != null) - _ErrorRow(siteId: siteId, isDark: isDark) + _ErrorRow(siteId: siteId, onInsetPanel: onInsetPanel) else _HourlyList( - hourlyResponse: hourlyResponse!, - selectedDate: selectedDate, - isDark: isDark), + hourlyResponse: hourlyResponse!, + selectedDate: selectedDate, + isDark: isDark, + onInsetPanel: onInsetPanel, + selectedHourIndex: selectedHourIndex, + onHourSelected: onHourSelected, + ), ], ); } @@ -70,17 +72,18 @@ class ForecastHourlySection extends StatelessWidget { class _ErrorRow extends StatelessWidget { final String siteId; - final bool isDark; + final bool onInsetPanel; - const _ErrorRow({required this.siteId, required this.isDark}); + const _ErrorRow({required this.siteId, this.onInsetPanel = false}); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( + color: AppSurfaceColors.nested(context), borderRadius: BorderRadius.circular(12), - color: isDark ? AppColors.darkHighlight : AppColors.highlightColor, + border: Border.all(color: AppSurfaceColors.border(context)), ), child: Row( children: [ @@ -90,7 +93,7 @@ class _ErrorRow extends StatelessWidget { child: Text( 'Hourly data unavailable', style: TextStyle( - fontSize: 13, color: AppColors.boldHeadlineColor), + fontSize: 13, color: AppTextColors.muted(context)), ), ), TextButton( @@ -109,13 +112,23 @@ class _HourlyList extends StatelessWidget { final HourlyForecastResponse hourlyResponse; final DateTime selectedDate; final bool isDark; + final bool onInsetPanel; + final int selectedHourIndex; + final ValueChanged? onHourSelected; const _HourlyList({ required this.hourlyResponse, required this.selectedDate, required this.isDark, + this.onInsetPanel = false, + required this.selectedHourIndex, + this.onHourSelected, }); + Color _surfaceColor(BuildContext context) => onInsetPanel + ? AppSurfaceColors.panelChip(context) + : AppSurfaceColors.nested(context); + @override Widget build(BuildContext context) { final selectedDateStr = @@ -129,18 +142,19 @@ class _HourlyList extends StatelessWidget { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( + color: _surfaceColor(context), borderRadius: BorderRadius.circular(12), - color: isDark ? AppColors.darkHighlight : AppColors.highlightColor, + border: Border.all(color: AppSurfaceColors.border(context)), ), child: Row( children: [ Icon(Icons.schedule_rounded, - size: 16, color: AppColors.boldHeadlineColor), + size: 16, color: AppTextColors.muted(context)), const SizedBox(width: 8), Text( 'No hourly data available for this day.', style: TextStyle( - fontSize: 13, color: AppColors.boldHeadlineColor), + fontSize: 13, color: AppTextColors.muted(context)), ), ], ), @@ -158,7 +172,15 @@ class _HourlyList extends StatelessWidget { final entry = dayEntries[i]; final isNow = entry.time.toLocal().hour == now.hour && selectedDateStr == nowStr; - return _HourlyChip(entry: entry, isNow: isNow, isDark: isDark); + final isSelected = onHourSelected != null + ? i == selectedHourIndex.clamp(0, dayEntries.length - 1) + : isNow; + return _HourlyChip( + entry: entry, + isSelected: isSelected, + onInsetPanel: onInsetPanel, + onTap: onHourSelected != null ? () => onHourSelected!(i) : null, + ); }, ), ); @@ -167,31 +189,41 @@ class _HourlyList extends StatelessWidget { class _HourlyChip extends StatelessWidget { final HourlyForecastEntry entry; - final bool isNow; - final bool isDark; + final bool isSelected; + final bool onInsetPanel; + final VoidCallback? onTap; - const _HourlyChip( - {required this.entry, required this.isNow, required this.isDark}); + const _HourlyChip({ + required this.entry, + required this.isSelected, + this.onInsetPanel = false, + this.onTap, + }); @override Widget build(BuildContext context) { - return Container( + final isDarkTheme = Theme.of(context).brightness == Brightness.dark; + final inactiveTextColor = + isDarkTheme ? Colors.white : AppTextColors.headline(context); + final inactiveMetaColor = isDarkTheme + ? Colors.white.withValues(alpha: 0.85) + : AppTextColors.muted(context); + + final chip = Container( width: 64, margin: const EdgeInsets.only(right: 8), padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 6), decoration: BoxDecoration( - color: isNow + color: isSelected ? AppColors.primaryColor - : isDark - ? AppColors.darkHighlight - : Colors.white, + : onInsetPanel + ? AppSurfaceColors.panelChip(context) + : AppSurfaceColors.nested(context), borderRadius: BorderRadius.circular(12), border: Border.all( - color: isNow + color: isSelected ? AppColors.primaryColor - : isDark - ? AppColors.dividerColordark - : AppColors.dividerColorlight, + : AppSurfaceColors.border(context), ), ), child: Column( @@ -202,7 +234,7 @@ class _HourlyChip extends StatelessWidget { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: isNow ? Colors.white : AppColors.boldHeadlineColor, + color: isSelected ? Colors.white : inactiveMetaColor, ), ), const SizedBox(height: 5), @@ -217,11 +249,14 @@ class _HourlyChip extends StatelessWidget { style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, - color: isNow ? Colors.white : null, + color: isSelected ? Colors.white : inactiveTextColor, ), ), ], ), ); + + if (onTap == null) return chip; + return GestureDetector(onTap: onTap, child: chip); } } diff --git a/src/mobile/lib/src/app/dashboard/widgets/forecast_met_row.dart b/src/mobile/lib/src/app/dashboard/widgets/forecast_met_row.dart index 67dabc96ca..25114a7b08 100644 --- a/src/mobile/lib/src/app/dashboard/widgets/forecast_met_row.dart +++ b/src/mobile/lib/src/app/dashboard/widgets/forecast_met_row.dart @@ -1,11 +1,18 @@ import 'package:airqo/src/app/dashboard/models/forecast_response.dart'; +import 'package:airqo/src/app/dashboard/utils/forecast_met_icons.dart'; import 'package:airqo/src/meta/utils/colors.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; class ForecastMetRow extends StatelessWidget { final ForecastMet? met; + final bool insetOnPanel; - const ForecastMetRow({super.key, this.met}); + const ForecastMetRow({ + super.key, + this.met, + this.insetOnPanel = false, + }); @override Widget build(BuildContext context) { @@ -14,33 +21,29 @@ class ForecastMetRow extends StatelessWidget { final items = <_MetItem>[ if (met!.airTemperature != null) _MetItem( - icon: Icons.thermostat_rounded, + iconAsset: ForecastMetIcons.temperature, label: 'Temp', value: '${met!.airTemperature!.round()}°C', - color: const Color(0xFFFF6D00), ), if (met!.relativeHumidity != null) _MetItem( - icon: Icons.water_drop_outlined, + iconAsset: ForecastMetIcons.humidity, label: 'Humidity', value: '${met!.relativeHumidity!.round()}%', - color: const Color(0xFF1565C0), ), if (met!.windSpeed != null) _MetItem( - icon: Icons.air_rounded, + iconAsset: ForecastMetIcons.wind, label: met!.windDirectionCompass != null ? 'Wind ${met!.windDirectionCompass}' : 'Wind', value: '${met!.windSpeed!.toStringAsFixed(1)} m/s', - color: const Color(0xFF00897B), ), if (met!.precipitationAmount != null) _MetItem( - icon: Icons.umbrella_rounded, + iconAsset: ForecastMetIcons.rain, label: 'Rain', value: '${met!.precipitationAmount!.toStringAsFixed(1)} mm', - color: const Color(0xFF5C6BC0), ), ]; @@ -48,60 +51,84 @@ class ForecastMetRow extends StatelessWidget { return Row( children: items - .map((item) => Expanded(child: _MetTile(item: item))) + .map( + (item) => Expanded( + child: _MetTile(item: item, insetOnPanel: insetOnPanel), + ), + ) .toList(), ); } } class _MetItem { - final IconData icon; + final String iconAsset; final String label; final String value; - final Color color; const _MetItem({ - required this.icon, + required this.iconAsset, required this.label, required this.value, - required this.color, }); } class _MetTile extends StatelessWidget { final _MetItem item; + final bool insetOnPanel; - const _MetTile({required this.item}); + const _MetTile({ + required this.item, + required this.insetOnPanel, + }); @override Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; + final tileBg = insetOnPanel + ? AppSurfaceColors.panelChip(context) + : AppSurfaceColors.nested(context); + final iconColor = AppColors.pmcolorlight; + final useLightText = + insetOnPanel && Theme.of(context).brightness == Brightness.dark; + final valueColor = + useLightText ? Colors.white : AppTextColors.headline(context); + final labelColor = useLightText + ? Colors.white.withValues(alpha: 0.85) + : AppTextColors.subtitle(context); + return Container( margin: const EdgeInsets.symmetric(horizontal: 4), padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), decoration: BoxDecoration( - color: isDark ? AppColors.darkHighlight : Colors.white, + color: tileBg, borderRadius: BorderRadius.circular(12), - border: Border.all( - color: isDark - ? AppColors.dividerColordark - : AppColors.dividerColorlight, - ), + border: Border.all(color: AppSurfaceColors.border(context)), ), child: Column( children: [ - Icon(item.icon, size: 22, color: item.color), + SvgPicture.asset( + item.iconAsset, + width: 22, + height: 22, + colorFilter: ColorFilter.mode(iconColor, BlendMode.srcIn), + ), const SizedBox(height: 6), Text( item.value, - style: - const TextStyle(fontWeight: FontWeight.w600, fontSize: 13), + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 13, + color: valueColor, + ), ), Text( item.label, style: TextStyle( - fontSize: 10, color: AppColors.boldHeadlineColor), + fontSize: 10, + color: labelColor, + ), overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, ), ], ), diff --git a/src/mobile/lib/src/app/dashboard/widgets/forecast_time_scope_selector.dart b/src/mobile/lib/src/app/dashboard/widgets/forecast_time_scope_selector.dart new file mode 100644 index 0000000000..f11fb656e2 --- /dev/null +++ b/src/mobile/lib/src/app/dashboard/widgets/forecast_time_scope_selector.dart @@ -0,0 +1,71 @@ +import 'package:airqo/src/meta/utils/colors.dart'; +import 'package:flutter/material.dart'; + +enum ForecastTimeScope { daily, hourly } + +class ForecastTimeScopeSelector extends StatelessWidget { + final ForecastTimeScope selected; + final ValueChanged onSelected; + + const ForecastTimeScopeSelector({ + super.key, + required this.selected, + required this.onSelected, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + _pill( + context, + label: 'Daily', + isSelected: selected == ForecastTimeScope.daily, + onTap: () => onSelected(ForecastTimeScope.daily), + ), + const SizedBox(width: 8), + _pill( + context, + label: 'Hourly', + isSelected: selected == ForecastTimeScope.hourly, + onTap: () => onSelected(ForecastTimeScope.hourly), + ), + ], + ); + } + + Widget _pill( + BuildContext context, { + required String label, + required bool isSelected, + required VoidCallback onTap, + }) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return GestureDetector( + onTap: onTap, + child: Container( + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(40), + color: isSelected + ? AppColors.primaryColor + : (isDark + ? AppSurfaceColors.nested(context) + : AppColors.dividerColorlight), + ), + alignment: Alignment.center, + child: Text( + label, + style: TextStyle( + color: isSelected + ? Colors.white + : (isDark ? Colors.white : AppColors.boldHeadlineColor4), + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, + fontSize: 14, + ), + ), + ), + ); + } +} diff --git a/src/mobile/lib/src/app/dashboard/widgets/nearby_measurement_card.dart b/src/mobile/lib/src/app/dashboard/widgets/nearby_measurement_card.dart index 3a282875c0..41debd24a2 100644 --- a/src/mobile/lib/src/app/dashboard/widgets/nearby_measurement_card.dart +++ b/src/mobile/lib/src/app/dashboard/widgets/nearby_measurement_card.dart @@ -2,8 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:loggy/loggy.dart'; import 'package:airqo/src/app/dashboard/models/airquality_response.dart'; -import 'package:airqo/src/app/dashboard/widgets/analytics_details.dart'; +import 'package:airqo/src/app/dashboard/pages/forecast_overview_page.dart'; import 'package:airqo/src/meta/utils/colors.dart'; +import 'package:airqo/src/meta/utils/forecast_utils.dart'; import 'package:airqo/src/meta/utils/utils.dart'; class NearbyMeasurementCard extends StatelessWidget with UiLoggy { @@ -16,16 +17,12 @@ class NearbyMeasurementCard extends StatelessWidget with UiLoggy { this.fallbackLocationName, }); - void _showAnalyticsDetails(BuildContext context, Measurement measurement) { - showBottomSheet( - backgroundColor: Colors.transparent, - context: context, - builder: (context) { - return AnalyticsDetails( - measurement: measurement, - fallbackLocationName: fallbackLocationName, - ); - }); + void _openForecast(BuildContext context) { + ForecastOverviewPage.showForMeasurement( + context, + measurement: measurement, + fallbackLocationName: fallbackLocationName, + ); } String _getLocationDescription(Measurement measurement) { @@ -58,51 +55,16 @@ class NearbyMeasurementCard extends StatelessWidget with UiLoggy { } Color _getAqiColor(Measurement measurement) { - if (measurement.aqiColor != null) { - try { - final colorStr = measurement.aqiColor!.replaceAll('#', ''); - return Color(int.parse('0xFF$colorStr')); - } catch (e) { - loggy.warning('Failed to parse AQI color: ${measurement.aqiColor}'); - } - } - - switch (measurement.aqiCategory?.toLowerCase() ?? '') { - case 'good': - return Colors.green; - case 'moderate': - return Colors.yellow.shade700; - case 'unhealthy for sensitive groups': - case 'u4sg': - return Colors.orange; - case 'unhealthy': - return Colors.red; - case 'very unhealthy': - return Colors.purple; - case 'hazardous': - return Colors.brown; - default: - return AppColors.primaryColor; - } + return getAppAqiCategoryColor(measurement.aqiCategory ?? ''); } @override Widget build(BuildContext context) { return InkWell( - onTap: () => _showAnalyticsDetails(context, measurement), + onTap: () => _openForecast(context), child: Container( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: Theme.of(context).cardColor, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.1), - blurRadius: 4, - offset: Offset(0, 2), - ), - ], - ), + decoration: AppSurfaceColors.elevatedCardDecoration(context), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -138,10 +100,10 @@ class NearbyMeasurementCard extends StatelessWidget with UiLoggy { SizedBox(height: 4), Row( children: [ - Icon( - Icons.location_on, - size: 14, - color: AppColors.primaryColor, + SvgPicture.asset( + 'assets/images/shared/location_pin.svg', + width: 14, + height: 14, ), SizedBox(width: 4), Expanded( @@ -169,11 +131,7 @@ class NearbyMeasurementCard extends StatelessWidget with UiLoggy { ], ), ), - Divider( - thickness: .5, - color: Theme.of(context).brightness == Brightness.dark - ? AppColors.dividerColordark - : AppColors.dividerColorlight), + Divider(thickness: .5, color: Theme.of(context).dividerColor), Padding( padding: const EdgeInsets.only( left: 16, right: 16, bottom: 16, top: 4), diff --git a/src/mobile/lib/src/app/map/pages/map_page.dart b/src/mobile/lib/src/app/map/pages/map_page.dart index 8cc703e219..a87fd977b3 100644 --- a/src/mobile/lib/src/app/map/pages/map_page.dart +++ b/src/mobile/lib/src/app/map/pages/map_page.dart @@ -1,7 +1,7 @@ import 'package:airqo/src/app/dashboard/bloc/dashboard/dashboard_bloc.dart'; import 'package:airqo/src/app/dashboard/models/airquality_response.dart'; import 'package:airqo/src/app/dashboard/pages/location_selection/utils/location_helpers.dart'; -import 'package:airqo/src/app/dashboard/widgets/analytics_details.dart'; +import 'package:airqo/src/app/dashboard/pages/forecast_overview_page.dart'; import 'package:airqo/src/app/map/bloc/map_bloc.dart'; import 'package:airqo/src/app/map/controllers/map_camera_controller.dart'; import 'package:airqo/src/app/map/utils/map_marker_builder.dart'; @@ -120,9 +120,7 @@ class _MapScreenState extends State SystemChannels.textInput.invokeMethod('TextInput.hide'); } - void _showAnalyticsDetails(Measurement measurement) { - // showBottomSheet (not showModalBottomSheet) avoids a lingering scrim; - // we also drop IME so the draggable sheet isn't pushed by the keyboard. + void _showForecastModal(Measurement measurement) { _hideTextInputOverlay(); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; @@ -131,10 +129,9 @@ class _MapScreenState extends State Future.delayed(const Duration(milliseconds: 48), () { if (!mounted) return; _hideTextInputOverlay(); - showBottomSheet( - context: context, - backgroundColor: Colors.transparent, - builder: (_) => AnalyticsDetails(measurement: measurement), + ForecastOverviewPage.showForMeasurement( + context, + measurement: measurement, ); }); }); @@ -456,7 +453,7 @@ class _MapScreenState extends State measurement: _selectedCardMeasurement, onDismiss: () => setState(() => _selectedCardMeasurement = null), onViewForecast: () => - _showAnalyticsDetails(_selectedCardMeasurement!), + _showForecastModal(_selectedCardMeasurement!), ), ], ); diff --git a/src/mobile/lib/src/app/map/widgets/map_air_quality_card.dart b/src/mobile/lib/src/app/map/widgets/map_air_quality_card.dart index e7db6ba12e..c0ad9b47e4 100644 --- a/src/mobile/lib/src/app/map/widgets/map_air_quality_card.dart +++ b/src/mobile/lib/src/app/map/widgets/map_air_quality_card.dart @@ -63,18 +63,8 @@ class MapAirQualityCard extends StatelessWidget { borderRadius: BorderRadius.circular(12), child: BackdropFilter( filter: ui.ImageFilter.blur(sigmaX: 12, sigmaY: 12), - child: Container( - decoration: BoxDecoration( - color: isDark ? AppColors.darkHighlight : Colors.white, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.10), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), + child: Container( + decoration: AppSurfaceColors.elevatedCardDecoration(context), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, diff --git a/src/mobile/lib/src/app/shared/widgets/aqi_category_chip.dart b/src/mobile/lib/src/app/shared/widgets/aqi_category_chip.dart new file mode 100644 index 0000000000..4e35cf640e --- /dev/null +++ b/src/mobile/lib/src/app/shared/widgets/aqi_category_chip.dart @@ -0,0 +1,32 @@ +import 'package:airqo/src/meta/utils/forecast_utils.dart'; +import 'package:flutter/material.dart'; + +/// AQI category pill — uses canonical app colors (ignores API hex). +class AqiCategoryChip extends StatelessWidget { + final String category; + + const AqiCategoryChip({ + super.key, + required this.category, + }); + + @override + Widget build(BuildContext context) { + 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, + ), + ), + ); + } +} diff --git a/src/mobile/lib/src/meta/utils/colors.dart b/src/mobile/lib/src/meta/utils/colors.dart index 49279ebedd..55fd1cabc0 100644 --- a/src/mobile/lib/src/meta/utils/colors.dart +++ b/src/mobile/lib/src/meta/utils/colors.dart @@ -34,6 +34,124 @@ class AppColors { } +/// Theme-aware text and feedback colors — white primary / grey secondary in dark mode. +class AppTextColors { + const AppTextColors._(); + + static bool isDark(BuildContext context) => + Theme.of(context).brightness == Brightness.dark; + + /// Primary titles: location names, readings, sheet titles. + static Color headline(BuildContext context) => + isDark(context) ? Colors.white : AppColors.boldHeadlineColor4; + + /// Secondary labels and supporting copy. + static Color muted(BuildContext context) => isDark(context) + ? AppColors.boldHeadlineColor2 + : AppColors.boldHeadlineColor3; + + /// Captions, metadata, section subtitles. + static Color subtitle(BuildContext context) => isDark(context) + ? AppColors.secondaryHeadlineColor2 + : AppColors.secondaryHeadlineColor4; + + static Color successBackground(BuildContext context) => isDark(context) + ? const Color(0xff57D175).withValues(alpha: 0.18) + : const Color(0xffEAF3DE); + + static Color successForeground(BuildContext context) => isDark(context) + ? Colors.white + : const Color(0xff27500A); + + static Color errorBackground(BuildContext context) => isDark(context) + ? const Color(0xffE24B4A).withValues(alpha: 0.18) + : const Color(0xffFCEBEB); + + static Color errorForeground(BuildContext context) => + isDark(context) ? Colors.white : const Color(0xffE24B4A); + + static Color sheetBackground(BuildContext context) => + AppSurfaceColors.sheet(context); + + /// Close icon on modal sheets — white in dark mode. + static Color modalCloseIcon(BuildContext context) => + isDark(context) ? Colors.white : AppColors.boldHeadlineColor4; +} + +/// Theme-aware surfaces and borders — one palette for cards, sheets, and modals. +class AppSurfaceColors { + const AppSurfaceColors._(); + + static bool isDark(BuildContext context) => + Theme.of(context).brightness == Brightness.dark; + + /// Bottom sheets, modals, and full-screen overlay pages. + static Color sheet(BuildContext context) => + isDark(context) ? AppColors.darkHighlight : Colors.white; + + /// Cards and elevated panels on the scaffold background. + static Color card(BuildContext context) => + isDark(context) ? AppColors.darkHighlight : Colors.white; + + /// Inset wells inside cards and sheets (icon chips, stat tiles). + static Color nested(BuildContext context) => + isDark(context) ? AppColors.darkThemeBackground : AppColors.highlightColor; + + /// Standard dividers and card borders. + static Color border(BuildContext context) => + isDark(context) ? AppColors.dividerColordark : AppColors.dividerColorlight; + + static BorderSide borderSide(BuildContext context, {double width = 1}) => + BorderSide(color: border(context), width: width); + + static BoxDecoration elevatedCardDecoration( + BuildContext context, { + double radius = 12, + }) { + final dark = isDark(context); + return BoxDecoration( + color: card(context), + borderRadius: BorderRadius.circular(radius), + border: Border.all(color: border(context)), + boxShadow: dark + ? null + : [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.06), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ); + } + + static BoxDecoration sheetDecoration( + BuildContext context, { + double topRadius = 20, + }) { + return BoxDecoration( + color: sheet(context), + borderRadius: BorderRadius.vertical(top: Radius.circular(topRadius)), + ); + } + + /// Inset panel on modal/sheet surfaces — darker than [sheet] for contrast. + static BoxDecoration sheetPanelDecoration( + BuildContext context, { + double radius = 16, + }) { + return BoxDecoration( + color: nested(context), + borderRadius: BorderRadius.circular(radius), + border: Border.all(color: border(context)), + ); + } + + /// Inactive chips/buttons sitting on a nested panel. + static Color panelChip(BuildContext context) => + isDark(context) ? AppColors.darkHighlight : Colors.white; +} + class AppTheme { static final ThemeData lightTheme = ThemeData( splashColor: Colors.transparent, @@ -80,22 +198,34 @@ class AppTheme { primaryColor: const Color(0xff145FFF), scaffoldBackgroundColor: const Color(0xff1C1D20), cardColor: const Color(0xff2E2F33), - dividerColor: const Color(0xffE1E7EC), + dividerColor: AppColors.dividerColordark, highlightColor: const Color(0xff2E2F33), textTheme: TextTheme( headlineLarge: TextStyle( - color: const Color(0xff9EA3AA), + color: Colors.white, fontWeight: FontWeight.bold, ), headlineMedium: TextStyle( - color: const Color(0xff60646C), + color: AppColors.boldHeadlineColor2, ), headlineSmall: TextStyle( - color: const Color(0xff7A7F87), + color: Colors.white, + fontWeight: FontWeight.w700, ), - titleMedium: TextStyle(color: const Color(0xffE2E3E5)), + titleMedium: TextStyle(color: Colors.white), titleLarge: TextStyle( - fontSize: 40, fontWeight: FontWeight.w700, color: Colors.white), + fontSize: 40, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + bodyLarge: TextStyle( + color: AppColors.boldHeadlineColor2, + fontWeight: FontWeight.w500, + ), + bodyMedium: TextStyle(color: AppColors.secondaryHeadlineColor2), + bodySmall: TextStyle(color: AppColors.secondaryHeadlineColor2), + labelLarge: TextStyle(color: AppColors.boldHeadlineColor2), + labelMedium: TextStyle(color: AppColors.secondaryHeadlineColor2), ), ); } diff --git a/src/mobile/lib/src/meta/utils/forecast_utils.dart b/src/mobile/lib/src/meta/utils/forecast_utils.dart index d4bdc21aea..abcf70133d 100644 --- a/src/mobile/lib/src/meta/utils/forecast_utils.dart +++ b/src/mobile/lib/src/meta/utils/forecast_utils.dart @@ -40,21 +40,32 @@ String getForecastAirQualityIcon(String aqiCategory) { } } -Color getForecastAirQualityColor(String aqiCategory) { - switch (aqiCategory) { - case "Good": - return const Color(0xFF00C853); - case "Moderate": - return const Color(0xFFFFD600); - case "Unhealthy for Sensitive Groups": - return const Color(0xFFFF6D00); - case "Unhealthy": - return const Color(0xFFDD2C00); - case "Very Unhealthy": - return const Color(0xFF6A1B9A); - case "Hazardous": - return const Color(0xFF4E0000); +String normalizeAqiCategory(String category) { + return category.trim().toLowerCase(); +} + +/// Canonical app AQI palette — matches map markers and air quality indicators. +/// Always prefer this over API-supplied hex values for visual consistency. +Color getAppAqiCategoryColor(String category) { + switch (normalizeAqiCategory(category)) { + case 'good': + return const Color(0xFF34C759); + case 'moderate': + return const Color(0xFFFDC412); + case 'unhealthy for sensitive groups': + case 'u4sg': + return const Color(0xFFFF851F); + case 'unhealthy': + return const Color(0xFFFE726B); + case 'very unhealthy': + return const Color(0xFFC78AE8); + case 'hazardous': + return const Color(0xFFD95BA3); default: return const Color(0xFF9E9E9E); } } + +Color getForecastAirQualityColor(String aqiCategory) { + return getAppAqiCategoryColor(aqiCategory); +} diff --git a/src/mobile/pubspec.yaml b/src/mobile/pubspec.yaml index f5a3a7e297..d3d332620b 100644 --- a/src/mobile/pubspec.yaml +++ b/src/mobile/pubspec.yaml @@ -122,6 +122,7 @@ flutter: - assets/data/data.json - assets/data/new_data.json - assets/images/shared/airquality_indicators/ + - assets/images/forecast/ - assets/map_styles/ - .env.prod - .env.dev From 6c77ebbcb62d8cd52c5a7aa3a4fc669391e04b70 Mon Sep 17 00:00:00 2001 From: 2phonebabykeem Date: Wed, 17 Jun 2026 20:17:10 +0300 Subject: [PATCH 2/5] feat(mobile): Learn tab v2, forecast details sheet, and UI polish 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 --- src/mobile/android/app/build.gradle | 2 +- .../assets/images/dashboard/sun_icon.svg | 3 + .../components/location_list_view.dart | 8 +- .../dashboard/widgets/dashboard_app_bar.dart | 82 ++- .../widgets/unmatched_site_card.dart | 9 +- .../exposure/widgets/declared_place_card.dart | 16 +- .../exposure_place_name_text_field.dart | 11 + .../learn/formatting/learn_display_text.dart | 75 +++ .../app/learn/models/learn_activity_kind.dart | 6 + .../learn/models/learn_course_structure.dart | 448 +++++++++++++ .../learn/models/learn_lesson_activity.dart | 107 ++++ .../models/learn_lesson_continuation.dart | 31 + .../src/app/learn/models/learn_quiz_type.dart | 7 + .../lib/src/app/learn/pages/kya_page.dart | 340 +++++----- .../learn/pages/learn_course_detail_page.dart | 207 ++++++ .../app/learn/pages/learn_surveys_page.dart | 98 ++- .../lib/src/app/learn/pages/lesson_page.dart | 247 ++----- .../learn_lesson_experience_service.dart | 215 +++++++ .../services/learn_progress_service.dart | 219 +++++++ .../services/learn_quiz_scoring_service.dart | 104 +++ .../app/learn/theme/learn_design_tokens.dart | 175 +++++ .../experience/learn_article_activity.dart | 198 ++++++ .../learn_article_audio_player.dart | 110 ++++ .../experience/learn_course_certificate.dart | 215 +++++++ .../experience/learn_experience_shell.dart | 154 +++++ .../experience/learn_image_activity.dart | 80 +++ .../experience/learn_lesson_experience.dart | 229 +++++++ .../experience/learn_lesson_finish_pane.dart | 106 +++ .../experience/learn_level_unlock_pane.dart | 180 ++++++ .../experience/learn_quiz_activity.dart | 58 ++ .../experience/learn_quiz_free_text.dart | 110 ++++ .../experience/learn_quiz_multi_choice.dart | 112 ++++ .../widgets/experience/learn_quiz_option.dart | 190 ++++++ .../experience/learn_quiz_ranking.dart | 176 +++++ .../experience/learn_quiz_single_choice.dart | 94 +++ .../experience/learn_video_activity.dart | 189 ++++++ .../learn/widgets/kya_lesson_container.dart | 200 ++++-- .../learn/widgets/learn_bottom_sheets.dart | 333 ++++++++++ .../learn/widgets/learn_completion_sheet.dart | 135 ++++ .../widgets/learn_course_portrait_card.dart | 149 +++++ .../learn/widgets/learn_dashboard_header.dart | 37 ++ .../widgets/learn_lesson_activities.dart | 211 ++++++ .../learn/widgets/learn_lesson_confetti.dart | 67 ++ .../app/learn/widgets/learn_lesson_image.dart | 36 ++ .../learn/widgets/learn_lesson_list_row.dart | 219 +++++++ .../learn/widgets/learn_lesson_thumbnail.dart | 245 +++++++ .../widgets/learn_level_summary_card.dart | 241 +++++++ .../widgets/learn_sheet_button_styles.dart | 61 ++ .../app/learn/widgets/learn_unit_chip.dart | 187 ++++++ .../app/surveys/pages/survey_list_page.dart | 572 +---------------- .../src/app/surveys/widgets/survey_card.dart | 22 +- .../surveys/widgets/survey_list_content.dart | 606 ++++++++++++++++++ src/mobile/pubspec.lock | 240 +++++-- src/mobile/pubspec.yaml | 6 + 54 files changed, 7035 insertions(+), 1143 deletions(-) create mode 100644 src/mobile/assets/images/dashboard/sun_icon.svg create mode 100644 src/mobile/lib/src/app/learn/formatting/learn_display_text.dart create mode 100644 src/mobile/lib/src/app/learn/models/learn_activity_kind.dart create mode 100644 src/mobile/lib/src/app/learn/models/learn_course_structure.dart create mode 100644 src/mobile/lib/src/app/learn/models/learn_lesson_activity.dart create mode 100644 src/mobile/lib/src/app/learn/models/learn_lesson_continuation.dart create mode 100644 src/mobile/lib/src/app/learn/models/learn_quiz_type.dart create mode 100644 src/mobile/lib/src/app/learn/pages/learn_course_detail_page.dart create mode 100644 src/mobile/lib/src/app/learn/services/learn_lesson_experience_service.dart create mode 100644 src/mobile/lib/src/app/learn/services/learn_progress_service.dart create mode 100644 src/mobile/lib/src/app/learn/services/learn_quiz_scoring_service.dart create mode 100644 src/mobile/lib/src/app/learn/theme/learn_design_tokens.dart create mode 100644 src/mobile/lib/src/app/learn/widgets/experience/learn_article_activity.dart create mode 100644 src/mobile/lib/src/app/learn/widgets/experience/learn_article_audio_player.dart create mode 100644 src/mobile/lib/src/app/learn/widgets/experience/learn_course_certificate.dart create mode 100644 src/mobile/lib/src/app/learn/widgets/experience/learn_experience_shell.dart create mode 100644 src/mobile/lib/src/app/learn/widgets/experience/learn_image_activity.dart create mode 100644 src/mobile/lib/src/app/learn/widgets/experience/learn_lesson_experience.dart create mode 100644 src/mobile/lib/src/app/learn/widgets/experience/learn_lesson_finish_pane.dart create mode 100644 src/mobile/lib/src/app/learn/widgets/experience/learn_level_unlock_pane.dart create mode 100644 src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_activity.dart create mode 100644 src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_free_text.dart create mode 100644 src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_multi_choice.dart create mode 100644 src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_option.dart create mode 100644 src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_ranking.dart create mode 100644 src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_single_choice.dart create mode 100644 src/mobile/lib/src/app/learn/widgets/experience/learn_video_activity.dart create mode 100644 src/mobile/lib/src/app/learn/widgets/learn_bottom_sheets.dart create mode 100644 src/mobile/lib/src/app/learn/widgets/learn_completion_sheet.dart create mode 100644 src/mobile/lib/src/app/learn/widgets/learn_course_portrait_card.dart create mode 100644 src/mobile/lib/src/app/learn/widgets/learn_dashboard_header.dart create mode 100644 src/mobile/lib/src/app/learn/widgets/learn_lesson_activities.dart create mode 100644 src/mobile/lib/src/app/learn/widgets/learn_lesson_confetti.dart create mode 100644 src/mobile/lib/src/app/learn/widgets/learn_lesson_image.dart create mode 100644 src/mobile/lib/src/app/learn/widgets/learn_lesson_list_row.dart create mode 100644 src/mobile/lib/src/app/learn/widgets/learn_lesson_thumbnail.dart create mode 100644 src/mobile/lib/src/app/learn/widgets/learn_level_summary_card.dart create mode 100644 src/mobile/lib/src/app/learn/widgets/learn_sheet_button_styles.dart create mode 100644 src/mobile/lib/src/app/learn/widgets/learn_unit_chip.dart create mode 100644 src/mobile/lib/src/app/surveys/widgets/survey_list_content.dart diff --git a/src/mobile/android/app/build.gradle b/src/mobile/android/app/build.gradle index 58e5d5b28f..0202156fec 100644 --- a/src/mobile/android/app/build.gradle +++ b/src/mobile/android/app/build.gradle @@ -98,7 +98,7 @@ android { applicationId "com.airqo.app" // You can update the following values to match your application needs. // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. - minSdkVersion flutter.minSdkVersion + minSdkVersion 24 targetSdkVersion 36 versionCode flutterVersionCode.toInteger() versionName flutterVersionName diff --git a/src/mobile/assets/images/dashboard/sun_icon.svg b/src/mobile/assets/images/dashboard/sun_icon.svg new file mode 100644 index 0000000000..2df9297bdf --- /dev/null +++ b/src/mobile/assets/images/dashboard/sun_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/mobile/lib/src/app/dashboard/pages/location_selection/components/location_list_view.dart b/src/mobile/lib/src/app/dashboard/pages/location_selection/components/location_list_view.dart index 7b7ca3d2f7..a581669e03 100644 --- a/src/mobile/lib/src/app/dashboard/pages/location_selection/components/location_list_view.dart +++ b/src/mobile/lib/src/app/dashboard/pages/location_selection/components/location_list_view.dart @@ -395,10 +395,10 @@ class LocationListView extends StatelessWidget with UiLoggy { ), child: Row( children: [ - Icon( - Icons.location_on, - size: 18, - color: Theme.of(context).textTheme.headlineSmall?.color, + SvgPicture.asset( + 'assets/images/shared/location_pin.svg', + width: 18, + height: 18, ), const SizedBox(width: 8), TranslatedText( diff --git a/src/mobile/lib/src/app/dashboard/widgets/dashboard_app_bar.dart b/src/mobile/lib/src/app/dashboard/widgets/dashboard_app_bar.dart index ebd14f1642..166d7ec56d 100644 --- a/src/mobile/lib/src/app/dashboard/widgets/dashboard_app_bar.dart +++ b/src/mobile/lib/src/app/dashboard/widgets/dashboard_app_bar.dart @@ -37,6 +37,11 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget { ); } + Color _appBarIconBackground(BuildContext context) => + Theme.of(context).brightness == Brightness.dark + ? AppColors.darkHighlight + : AppColors.dividerColorlight; + Widget _buildThemeToggle(BuildContext context) { final themeBloc = context.read(); final isDarkMode = Theme.of(context).brightness == Brightness.dark; @@ -46,20 +51,41 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget { }, child: CircleAvatar( radius: 24, - backgroundColor: Theme.of(context).brightness == Brightness.dark - ? AppColors.darkHighlight - : AppColors.lightHighlight, + backgroundColor: _appBarIconBackground(context), child: Center( - child: SvgPicture.asset( + child: _buildAppBarIcon( + context, isDarkMode - ? "assets/images/dashboard/Dark_icon.svg" - : "assets/images/dashboard/Light_icon.svg", + ? 'assets/images/dashboard/sun_icon.svg' + : 'assets/images/dashboard/theme_toggle.svg', + size: 20, ), ), ), ); } + Color _appBarIconColor(BuildContext context) => + Theme.of(context).brightness == Brightness.dark + ? Colors.white + : Colors.black; + + Widget _buildAppBarIcon( + BuildContext context, + String asset, { + double size = 22, + }) { + return SvgPicture.asset( + asset, + height: size, + width: size, + colorFilter: ColorFilter.mode( + _appBarIconColor(context), + BlendMode.srcIn, + ), + ); + } + Widget _buildUserAvatar(BuildContext context) { return BlocBuilder( builder: (context, authState) { @@ -82,13 +108,12 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget { ); }, child: CircleAvatar( - backgroundColor: Theme.of(context).highlightColor, + backgroundColor: _appBarIconBackground(context), radius: 24, child: Center( - child: SvgPicture.asset( - "assets/icons/user_icon.svg", - height: 22, - width: 22, + child: _buildAppBarIcon( + context, + 'assets/icons/user_icon.svg', ), ), ), @@ -129,12 +154,14 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget { }, child: CircleAvatar( radius: 24, - backgroundColor: Theme.of(context).brightness == Brightness.dark - ? AppColors.darkHighlight - : AppColors.dividerColorlight, + backgroundColor: _appBarIconBackground(context), child: ClipOval( - child: - _buildProfilePicture(profilePicture, firstName, lastName), + child: _buildProfilePicture( + context, + profilePicture, + firstName, + lastName, + ), ), ), ), @@ -164,14 +191,11 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget { }, child: CircleAvatar( radius: 24, - backgroundColor: Theme.of(context).brightness == Brightness.dark - ? AppColors.darkHighlight - : AppColors.dividerColorlight, + backgroundColor: _appBarIconBackground(context), child: Center( - child: SvgPicture.asset( - "assets/icons/user_icon.svg", - height: 22, - width: 22, + child: _buildAppBarIcon( + context, + 'assets/icons/user_icon.svg', ), ), ), @@ -189,7 +213,11 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget { } Widget _buildProfilePicture( - String? profilePicture, String? firstName, String? lastName) { + BuildContext context, + String? profilePicture, + String? firstName, + String? lastName, + ) { String firstNameSafe = firstName ?? ""; String lastNameSafe = lastName ?? ""; @@ -204,11 +232,7 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget { } Widget fallbackWidget = Center( - child: SvgPicture.asset( - "assets/icons/user_icon.svg", - height: 22, - width: 22, - ), + child: _buildAppBarIcon(context, 'assets/icons/user_icon.svg'), ); if (initials.isEmpty && diff --git a/src/mobile/lib/src/app/dashboard/widgets/unmatched_site_card.dart b/src/mobile/lib/src/app/dashboard/widgets/unmatched_site_card.dart index 175c370b90..37a08f00b5 100644 --- a/src/mobile/lib/src/app/dashboard/widgets/unmatched_site_card.dart +++ b/src/mobile/lib/src/app/dashboard/widgets/unmatched_site_card.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:airqo/src/app/dashboard/models/user_preferences_model.dart'; -import 'package:airqo/src/meta/utils/colors.dart'; class UnmatchedSiteCard extends StatefulWidget { final SelectedSite site; @@ -147,10 +146,10 @@ class _UnmatchedSiteCardState extends State { SizedBox(height: 4), Row( children: [ - Icon( - Icons.location_on, - size: 14, - color: AppColors.primaryColor, + SvgPicture.asset( + 'assets/images/shared/location_pin.svg', + width: 14, + height: 14, ), SizedBox(width: 4), Expanded( diff --git a/src/mobile/lib/src/app/exposure/widgets/declared_place_card.dart b/src/mobile/lib/src/app/exposure/widgets/declared_place_card.dart index 4ca7f7b19b..7f29b3444a 100644 --- a/src/mobile/lib/src/app/exposure/widgets/declared_place_card.dart +++ b/src/mobile/lib/src/app/exposure/widgets/declared_place_card.dart @@ -38,8 +38,8 @@ class DeclaredPlaceCard extends StatelessWidget { final hoursColor = isDark ? AppColors.boldHeadlineColor2 : AppColors.boldHeadlineColor3; // Inset elements (icon, divider) use the dark scaffold bg so they sink into the card - final iconBg = isDark ? AppColors.darkThemeBackground : AppColors.dividerColorlight; - final dividerColor = isDark ? AppColors.dividerColordark : AppColors.dividerColorlight; + final iconBg = AppSurfaceColors.nested(context); + final dividerColor = AppSurfaceColors.border(context); final isAbsent = place.isAbsentOn(dayOfView); final window = place.windowFor(dayOfView); @@ -58,17 +58,7 @@ class DeclaredPlaceCard extends StatelessWidget { return Container( margin: const EdgeInsets.only(bottom: 12), - decoration: BoxDecoration( - color: Theme.of(context).cardColor, - borderRadius: BorderRadius.circular(10), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.10), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), + decoration: AppSurfaceColors.elevatedCardDecoration(context, radius: 10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/src/mobile/lib/src/app/exposure/widgets/exposure_place_name_text_field.dart b/src/mobile/lib/src/app/exposure/widgets/exposure_place_name_text_field.dart index 6a36b93b47..0c7b60c24f 100644 --- a/src/mobile/lib/src/app/exposure/widgets/exposure_place_name_text_field.dart +++ b/src/mobile/lib/src/app/exposure/widgets/exposure_place_name_text_field.dart @@ -8,6 +8,9 @@ class ExposurePlaceNameTextField extends StatefulWidget { final String? hintText; final bool isDark; final TextCapitalization textCapitalization; + final int? maxLines; + final int? minLines; + final bool enabled; const ExposurePlaceNameTextField({ super.key, @@ -15,6 +18,9 @@ class ExposurePlaceNameTextField extends StatefulWidget { this.hintText, required this.isDark, this.textCapitalization = TextCapitalization.words, + this.maxLines = 1, + this.minLines, + this.enabled = true, }); static const _borderIdle = Color(0xFFD0D5DD); @@ -93,10 +99,15 @@ class _ExposurePlaceNameTextFieldState extends State child: TextField( controller: widget.controller, focusNode: _focusNode, + enabled: widget.enabled, + maxLines: widget.maxLines, + minLines: widget.minLines, cursorColor: AppColors.primaryColor, cursorWidth: 1, cursorHeight: 24, textCapitalization: widget.textCapitalization, + textAlignVertical: + (widget.maxLines ?? 1) > 1 ? TextAlignVertical.top : TextAlignVertical.center, style: TextStyle( fontFamily: 'Inter', fontSize: 16, diff --git a/src/mobile/lib/src/app/learn/formatting/learn_display_text.dart b/src/mobile/lib/src/app/learn/formatting/learn_display_text.dart new file mode 100644 index 0000000000..a8e7d186b0 --- /dev/null +++ b/src/mobile/lib/src/app/learn/formatting/learn_display_text.dart @@ -0,0 +1,75 @@ +/// Short display strings for [TranslatedText] — kept simple for Luganda/Kiswahili. +String learnDisplayTitle(String apiTitle) => apiTitle.trim(); + +String learnUnitHeader(int unitIndex, String unitTitle) { + final num = (unitIndex + 1).toString().padLeft(2, '0'); + return 'UNIT $num: ${learnDisplayTitle(unitTitle)}'; +} + +String learnLessonLabel(int lessonIndex) { + return 'LESSON ${(lessonIndex + 1).toString().padLeft(2, '0')}'; +} + +String learnLessonHeaderLine(int lessonIndex, String lessonTitle) { + return '${learnLessonLabel(lessonIndex)}: ${learnDisplayTitle(lessonTitle)}'; +} + +String learnActivityOrdinal(int activityIndex) { + const ordinals = [ + 'ONE', + 'TWO', + 'THREE', + 'FOUR', + 'FIVE', + 'SIX', + 'SEVEN', + 'EIGHT', + ]; + return ordinals[activityIndex.clamp(0, ordinals.length - 1)]; +} + +String learnActivityTypeHeader(int activityIndex, String typeKey) { + return 'ACTIVITY ${learnActivityOrdinal(activityIndex)}: $typeKey'; +} + +String learnActivityProgressLabel(int activityIndex, int totalActivities) { + return 'Activity ${activityIndex + 1} of $totalActivities'; +} + +String learnLessonUnitContext(int lessonIndex, String unitPlainTitle) { + return '${learnLessonLabel(lessonIndex)} · ${learnDisplayTitle(unitPlainTitle)}'; +} + +String learnLessonCompleteSubtitle(int lessonIndex, String unitPlainTitle) { + final num = (lessonIndex + 1).toString().padLeft(2, '0'); + return 'Lesson $num complete: ${learnDisplayTitle(unitPlainTitle)}'; +} + +String learnCourseCompletionTime(DateTime completedAt) { + final day = completedAt.day; + final month = _monthName(completedAt.month); + final year = completedAt.year; + return 'Completed $day $month $year'; +} + +String learnCourseCompletionRarityLabel({int completionPercent = 17}) { + return 'Only $completionPercent% of AirQo users have completed this course'; +} + +String _monthName(int month) { + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + return months[month - 1]; +} diff --git a/src/mobile/lib/src/app/learn/models/learn_activity_kind.dart b/src/mobile/lib/src/app/learn/models/learn_activity_kind.dart new file mode 100644 index 0000000000..9911d90811 --- /dev/null +++ b/src/mobile/lib/src/app/learn/models/learn_activity_kind.dart @@ -0,0 +1,6 @@ +enum LearnActivityKind { + notes, + image, + video, + quiz, +} diff --git a/src/mobile/lib/src/app/learn/models/learn_course_structure.dart b/src/mobile/lib/src/app/learn/models/learn_course_structure.dart new file mode 100644 index 0000000000..4950fb3d97 --- /dev/null +++ b/src/mobile/lib/src/app/learn/models/learn_course_structure.dart @@ -0,0 +1,448 @@ +import 'package:airqo/src/app/learn/models/learn_lesson_continuation.dart'; +import 'package:airqo/src/app/learn/models/lesson_response_model.dart'; +import 'package:airqo/src/app/learn/services/learn_lesson_experience_service.dart'; +import 'package:airqo/src/app/learn/services/learn_progress_service.dart'; + +class LearnLessonSlot { + final String catalogId; + final String plainTitleKey; + final KyaLesson? apiLesson; + + const LearnLessonSlot({ + required this.catalogId, + required this.plainTitleKey, + this.apiLesson, + }); + + String get progressKey => catalogId; + + bool get hasContent => apiLesson != null && apiLesson!.tasks.isNotEmpty; + + /// Scripted demo flow activity count. + int get activityCount => LearnLessonExperienceService.demoActivityCount; +} + +class LearnUnitViewModel { + final String id; + final String title; + final String plainTitleKey; + final List lessons; + + const LearnUnitViewModel({ + required this.id, + required this.title, + required this.plainTitleKey, + required this.lessons, + }); + + int get lessonCount => lessons.length; + + int get totalLessons => lessons.length; + + int completedLessons(LearnProgressService progress) { + var count = 0; + for (final lesson in lessons) { + if (progress.isLessonComplete(lesson.progressKey)) count++; + } + return count; + } +} + +class LearnCourseViewModel { + final String id; + final int courseNumber; + final String title; + final String plainTitleKey; + final List units; + + const LearnCourseViewModel({ + required this.id, + required this.courseNumber, + required this.title, + required this.plainTitleKey, + required this.units, + }); + + int get totalLessons => + units.fold(0, (sum, u) => sum + u.lessons.length); + + int completedLessons(LearnProgressService progress) { + var count = 0; + for (final unit in units) { + for (final lesson in unit.lessons) { + if (progress.isLessonComplete(lesson.progressKey)) count++; + } + } + return count; + } +} + +class LearnStageInfo { + final String name; + final int index; + + const LearnStageInfo({required this.name, required this.index}); +} + +enum LearnUnitStatus { locked, inProgress, completed } + +/// Client-side catalog: maps flat [KyaLesson] API data into Course → Unit → Lesson. +class LearnCatalog { + static const stages = [ + LearnStageInfo(name: 'Curious', index: 0), + LearnStageInfo(name: 'Aware', index: 1), + LearnStageInfo(name: 'Observer', index: 2), + LearnStageInfo(name: 'Champion', index: 3), + LearnStageInfo(name: 'Defender', index: 4), + ]; + + static List buildFromLessons(List apiLessons) { + var apiIndex = 0; + + LearnLessonSlot slot(String catalogId, String title) { + KyaLesson? api; + if (apiIndex < apiLessons.length) { + api = apiLessons[apiIndex++]; + } + return LearnLessonSlot( + catalogId: catalogId, + plainTitleKey: title, + apiLesson: api, + ); + } + + List unitsForCourse( + String courseId, + List<({String title, String plain, List lessons})> defs, + ) { + return List.generate(defs.length, (u) { + final def = defs[u]; + return LearnUnitViewModel( + id: '${courseId}_u${u + 1}', + title: def.title, + plainTitleKey: def.plain, + lessons: List.generate(def.lessons.length, (l) { + return slot('${courseId}_u${u + 1}_l$l', def.lessons[l]); + }), + ); + }); + } + + const course1Units = [ + ( + title: 'Air basics', + plain: 'Air basics', + lessons: ['What is air quality', 'Why air matters', 'About AirQo'], + ), + ( + title: 'Sources', + plain: 'Pollution sources', + lessons: ['Cooking smoke', 'Road pollution', 'Open burning'], + ), + ( + title: 'Health', + plain: 'Health and air', + lessons: ['Eyes and lungs', 'Children', 'Stay safe'], + ), + ( + title: 'AQI scale', + plain: 'Air numbers', + lessons: ['Good and bad days', 'Read the numbers', 'Color codes'], + ), + ( + title: 'Action', + plain: 'Take action', + lessons: ['At home', 'In community', 'Share knowledge'], + ), + ]; + + const course2Units = [ + ( + title: 'AQI basics', + plain: 'Air numbers', + lessons: ['AQI basics', 'Color codes', 'Daily patterns'], + ), + ( + title: 'Forecasts', + plain: 'Air forecasts', + lessons: ['Read forecasts', 'Morning and evening', 'Weekend trends'], + ), + ( + title: 'Sensors', + plain: 'Air sensors', + lessons: ['Low-cost sensors', 'Calibration', 'Sensor network'], + ), + ( + title: 'Maps', + plain: 'Air maps', + lessons: ['Read the map', 'Nearest site', 'Compare places'], + ), + ( + title: 'Alerts', + plain: 'Air alerts', + lessons: ['Alert levels', 'Notifications', 'Stay safe'], + ), + ]; + + const course3Units = [ + ( + title: 'At home', + plain: 'At home', + lessons: ['Ventilation', 'Safe cooking', 'Indoor air'], + ), + ( + title: 'Travel', + plain: 'On the move', + lessons: ['Walking routes', 'Public transport', 'Masks'], + ), + ( + title: 'Community', + plain: 'Community', + lessons: ['Talk to neighbors', 'Schools', 'Local leaders'], + ), + ( + title: 'Advocacy', + plain: 'Advocacy', + lessons: ['Share data', 'Report problems', 'Campaigns'], + ), + ( + title: 'Next steps', + plain: 'Next steps', + lessons: ['Review progress', 'Teach a friend', 'Stay informed'], + ), + ]; + + const course4Units = [ + ( + title: 'Leadership', + plain: 'Leadership', + lessons: ['Lead by example', 'Host a talk', 'Build a team'], + ), + ( + title: 'Data', + plain: 'Air data', + lessons: ['Local trends', 'Hotspots', 'Tell the story'], + ), + ( + title: 'Partners', + plain: 'Partners', + lessons: ['Schools and NGOs', 'Health workers', 'Government'], + ), + ( + title: 'Momentum', + plain: 'Keep going', + lessons: ['Monthly check-ins', 'Celebrate wins', 'Plan ahead'], + ), + ( + title: 'Champion', + plain: 'Air champion', + lessons: ['Mentor someone', 'Run a campaign', 'Become a champion'], + ), + ]; + + return [ + LearnCourseViewModel( + id: 'syn_course_1', + courseNumber: 1, + title: 'Know your air', + plainTitleKey: 'Know your air', + units: unitsForCourse('syn_course_1', course1Units), + ), + LearnCourseViewModel( + id: 'syn_course_2', + courseNumber: 2, + title: 'Read the air', + plainTitleKey: 'Read the air', + units: unitsForCourse('syn_course_2', course2Units), + ), + LearnCourseViewModel( + id: 'syn_course_3', + courseNumber: 3, + title: 'Take action', + plainTitleKey: 'Take action', + units: unitsForCourse('syn_course_3', course3Units), + ), + LearnCourseViewModel( + id: 'syn_course_4', + courseNumber: 4, + title: 'Air champion', + plainTitleKey: 'Air champion', + units: unitsForCourse('syn_course_4', course4Units), + ), + ]; + } + + static bool isCourseUnlocked( + List courses, + int courseIndex, + LearnProgressService progress, + ) { + if (courseIndex == 0) return true; + final prior = courses[courseIndex - 1]; + return prior.completedLessons(progress) >= prior.totalLessons; + } + + static bool isUnitUnlocked( + LearnCourseViewModel course, + int unitIndex, + LearnProgressService progress, + ) { + if (unitIndex == 0) return true; + final priorUnit = course.units[unitIndex - 1]; + return priorUnit.completedLessons(progress) >= priorUnit.totalLessons; + } + + static bool isLessonUnlocked( + LearnUnitViewModel unit, + int lessonIndex, + LearnProgressService progress, + ) { + if (lessonIndex == 0) return true; + final prior = unit.lessons[lessonIndex - 1]; + return progress.isLessonComplete(prior.progressKey); + } + + static LearnUnitStatus unitStatus( + LearnCourseViewModel course, + LearnUnitViewModel unit, + int unitIndex, + LearnProgressService progress, + ) { + if (!isUnitUnlocked(course, unitIndex, progress)) { + return LearnUnitStatus.locked; + } + if (unit.completedLessons(progress) >= unit.totalLessons) { + return LearnUnitStatus.completed; + } + return LearnUnitStatus.inProgress; + } + + static int defaultSelectedUnitIndex( + LearnCourseViewModel course, + LearnProgressService progress, + ) { + for (var i = 0; i < course.units.length; i++) { + if (!isUnitUnlocked(course, i, progress)) continue; + if (course.units[i].completedLessons(progress) < + course.units[i].totalLessons) { + return i; + } + } + for (var i = 0; i < course.units.length; i++) { + if (isUnitUnlocked(course, i, progress)) return i; + } + return 0; + } + + static bool isLessonAccessible( + LearnCourseViewModel course, + LearnUnitViewModel unit, + int unitIndex, + int lessonIndex, + LearnProgressService progress, + ) { + if (!isUnitUnlocked(course, unitIndex, progress)) return false; + if (!isLessonUnlocked(unit, lessonIndex, progress)) return false; + return true; + } + + static LearnStageInfo currentStage( + List courses, + LearnProgressService progress, + ) { + final maxPts = progress.maxPoints(courses); + final pts = progress.totalPoints(courses); + if (maxPts > 0 && pts > 0) { + final ratio = pts / maxPts; + final idx = (ratio * stages.length).floor().clamp(0, stages.length - 1); + return stages[idx]; + } + + final total = courses.fold(0, (s, c) => s + c.totalLessons); + final done = courses.fold(0, (s, c) => s + c.completedLessons(progress)); + if (total == 0) return stages.first; + final ratio = done / total; + final idx = (ratio * stages.length).floor().clamp(0, stages.length - 1); + return stages[idx]; + } + + static int catalogCompletedLessons( + List courses, + LearnProgressService progress, + ) { + return courses.fold(0, (s, c) => s + c.completedLessons(progress)); + } + + static int catalogTotalLessons(List courses) { + return courses.fold(0, (s, c) => s + c.totalLessons); + } + + /// Picks one of the most distinct KYA lesson images for each course card. + static String? courseCoverImage( + LearnCourseViewModel course, + List apiLessons, + ) { + if (apiLessons.isEmpty) return null; + + final uniqueImages = []; + for (final lesson in apiLessons) { + if (lesson.image.isNotEmpty && !uniqueImages.contains(lesson.image)) { + uniqueImages.add(lesson.image); + } + for (final task in lesson.tasks) { + if (task.image.isNotEmpty && !uniqueImages.contains(task.image)) { + uniqueImages.add(task.image); + } + } + } + + if (uniqueImages.isEmpty) return null; + if (uniqueImages.length == 1) return uniqueImages.first; + + final courseIdx = (course.courseNumber - 1).clamp(0, 3); + if (uniqueImages.length >= 4) { + final step = (uniqueImages.length - 1) / 3; + return uniqueImages[(courseIdx * step).round()]; + } + return uniqueImages[courseIdx % uniqueImages.length]; + } + + static LearnLessonContinuation? continuationFor( + LearnCourseViewModel course, + LearnUnitViewModel unit, + int unitIndex, + int lessonIndex, + List allCourses, + ) { + if (lessonIndex + 1 < unit.lessons.length) { + final next = unit.lessons[lessonIndex + 1]; + return LearnLessonContinuation( + nextSlot: next, + unitPlainTitle: unit.plainTitleKey, + courseTitle: course.title, + lessonNumberInUnit: lessonIndex + 2, + lessonsInUnit: unit.lessons.length, + learnCourseId: course.id, + unitIndex: unitIndex, + lessonIndex: lessonIndex + 1, + ); + } + if (unitIndex + 1 < course.units.length) { + final nextUnit = course.units[unitIndex + 1]; + if (nextUnit.lessons.isNotEmpty) { + return LearnLessonContinuation( + nextSlot: nextUnit.lessons.first, + unitPlainTitle: nextUnit.plainTitleKey, + courseTitle: course.title, + lessonNumberInUnit: 1, + lessonsInUnit: nextUnit.lessons.length, + learnCourseId: course.id, + unitIndex: unitIndex + 1, + lessonIndex: 0, + isNextUnit: true, + ); + } + } + return null; + } +} diff --git a/src/mobile/lib/src/app/learn/models/learn_lesson_activity.dart b/src/mobile/lib/src/app/learn/models/learn_lesson_activity.dart new file mode 100644 index 0000000000..cdc7386aca --- /dev/null +++ b/src/mobile/lib/src/app/learn/models/learn_lesson_activity.dart @@ -0,0 +1,107 @@ +enum LearnActivityType { article, video, image, quiz } + +enum LearnQuizFormat { singleChoice, multiChoice, ranking, freeText } + +class LearnArticlePayload { + final String body; + final String? audioText; + + const LearnArticlePayload({ + required this.body, + this.audioText, + }); +} + +class LearnVideoPayload { + final String videoUrl; + final String description; + final String? posterUrl; + + const LearnVideoPayload({ + required this.videoUrl, + required this.description, + this.posterUrl, + }); +} + +class LearnImagePayload { + final String imageUrl; + final String caption; + final String? subtitle; + + const LearnImagePayload({ + required this.imageUrl, + required this.caption, + this.subtitle, + }); +} + +class LearnQuizPayload { + final LearnQuizFormat format; + final String question; + final List options; + final int? correctIndex; + final Set? correctIndices; + final List? correctOrder; + final String correctFeedback; + final String incorrectFeedback; + final String? hint; + + const LearnQuizPayload({ + required this.format, + required this.question, + this.options = const [], + this.correctIndex, + this.correctIndices, + this.correctOrder, + this.correctFeedback = 'Correct!', + this.incorrectFeedback = 'Not quite — try reviewing the lesson.', + this.hint, + }); +} + +class LearnLessonActivity { + final int index; + final LearnActivityType type; + final String title; + final LearnArticlePayload? article; + final LearnVideoPayload? video; + final LearnImagePayload? image; + final LearnQuizPayload? quiz; + + const LearnLessonActivity({ + required this.index, + required this.type, + required this.title, + this.article, + this.video, + this.image, + this.quiz, + }); + + LearnQuizPayload? get quizPayload => quiz; +} + +class LearnLessonResult { + final int stars; + final int pointsEarned; + final double quizScoreRatio; + final String? freeTextResponse; + + const LearnLessonResult({ + required this.stars, + required this.pointsEarned, + required this.quizScoreRatio, + this.freeTextResponse, + }); +} + +class LearnQuizGrade { + final bool isCorrect; + final String feedback; + + const LearnQuizGrade({ + required this.isCorrect, + required this.feedback, + }); +} diff --git a/src/mobile/lib/src/app/learn/models/learn_lesson_continuation.dart b/src/mobile/lib/src/app/learn/models/learn_lesson_continuation.dart new file mode 100644 index 0000000000..78462f3359 --- /dev/null +++ b/src/mobile/lib/src/app/learn/models/learn_lesson_continuation.dart @@ -0,0 +1,31 @@ +import 'package:airqo/src/app/learn/models/learn_course_structure.dart'; +import 'package:airqo/src/app/learn/models/lesson_response_model.dart'; + +/// Optional next-lesson CTA shown on the lesson finish pane. +class LearnLessonContinuation { + final LearnLessonSlot nextSlot; + final String unitPlainTitle; + final String courseTitle; + final int lessonNumberInUnit; + final int lessonsInUnit; + final String learnCourseId; + final int unitIndex; + final int lessonIndex; + final bool isNextUnit; + + const LearnLessonContinuation({ + required this.nextSlot, + required this.unitPlainTitle, + required this.courseTitle, + required this.lessonNumberInUnit, + required this.lessonsInUnit, + required this.learnCourseId, + required this.unitIndex, + required this.lessonIndex, + this.isNextUnit = false, + }); + + KyaLesson? get nextLesson => nextSlot.apiLesson; + + String get nextProgressKey => nextSlot.progressKey; +} diff --git a/src/mobile/lib/src/app/learn/models/learn_quiz_type.dart b/src/mobile/lib/src/app/learn/models/learn_quiz_type.dart new file mode 100644 index 0000000000..e79a23a267 --- /dev/null +++ b/src/mobile/lib/src/app/learn/models/learn_quiz_type.dart @@ -0,0 +1,7 @@ +enum LearnQuizType { + singleChoice, + multiChoice, + spotIt, + ranking, + freeText, +} diff --git a/src/mobile/lib/src/app/learn/pages/kya_page.dart b/src/mobile/lib/src/app/learn/pages/kya_page.dart index a6f78de131..6daef5c041 100644 --- a/src/mobile/lib/src/app/learn/pages/kya_page.dart +++ b/src/mobile/lib/src/app/learn/pages/kya_page.dart @@ -1,10 +1,18 @@ +import 'package:airqo/src/app/dashboard/widgets/dashboard_app_bar.dart'; +import 'package:airqo/src/app/learn/models/lesson_response_model.dart'; import 'package:airqo/src/app/learn/bloc/kya_bloc.dart'; +import 'package:airqo/src/app/learn/models/learn_course_structure.dart'; import 'package:airqo/src/app/learn/pages/learn_surveys_page.dart'; -import 'package:airqo/src/app/learn/widgets/kya_lesson_container.dart'; -import 'package:airqo/src/app/shared/services/feature_flag_service.dart'; +import 'package:airqo/src/app/learn/services/learn_progress_service.dart'; +import 'package:airqo/src/app/learn/theme/learn_design_tokens.dart'; +import 'package:airqo/src/app/learn/widgets/learn_bottom_sheets.dart'; +import 'package:airqo/src/app/learn/widgets/learn_course_portrait_card.dart'; +import 'package:airqo/src/app/learn/widgets/learn_dashboard_header.dart'; +import 'package:airqo/src/app/learn/widgets/learn_level_summary_card.dart'; +import 'package:airqo/src/app/learn/widgets/learn_sheet_button_styles.dart'; import 'package:airqo/src/app/shared/widgets/loading_widget.dart'; -import 'package:airqo/src/meta/utils/colors.dart'; import 'package:airqo/src/app/shared/widgets/translated_text.dart'; +import 'package:airqo/src/meta/utils/colors.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:loggy/loggy.dart'; @@ -12,7 +20,6 @@ import 'package:loggy/loggy.dart'; class KyaPage extends StatefulWidget { final int initialIndex; - // Static notifier to control the active tab externally (e.g. from survey banner) static final ValueNotifier tabIndexNotifier = ValueNotifier(0); const KyaPage({super.key, this.initialIndex = 0}); @@ -25,9 +32,8 @@ class _KyaPageState extends State with UiLoggy { KyaBloc? kyaBloc; bool _isRetrying = false; late int _selectedIndex; - - bool get _surveysEnabled => - FeatureFlagService.instance.isEnabled(AppFeatureFlag.surveys); + final _progress = LearnProgressService.instance; + String? _lastSeedFingerprint; @override void initState() { @@ -35,6 +41,7 @@ class _KyaPageState extends State with UiLoggy { _selectedIndex = widget.initialIndex; KyaPage.tabIndexNotifier.addListener(_onExternalTabChange); kyaBloc = context.read()..add(LoadLessons()); + _progress.ensureInitialized(); } @override @@ -45,211 +52,224 @@ class _KyaPageState extends State with UiLoggy { void _onExternalTabChange() { if (mounted) { - setState(() { - _selectedIndex = KyaPage.tabIndexNotifier.value; - }); + setState(() => _selectedIndex = KyaPage.tabIndexNotifier.value); } } void _retryLoading() { - setState(() { - _isRetrying = true; - }); + setState(() => _isRetrying = true); kyaBloc?.add(LoadLessons(forceRefresh: true)); Future.delayed(const Duration(seconds: 2), () { if (mounted) setState(() => _isRetrying = false); }); } + void _onLessonsReady( + List courses, + List apiLessons, + ) { + final fingerprint = + '${apiLessons.length}:${apiLessons.map((l) => l.id).join('|')}'; + if (_lastSeedFingerprint == fingerprint) return; + _lastSeedFingerprint = fingerprint; + _progress.ensurePilotLearnDemosV3(courses: courses); + } + @override Widget build(BuildContext context) { - return SafeArea( - child: Scaffold( - body: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( + return Scaffold( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + appBar: const DashboardAppBar(), + body: ValueListenableBuilder( + valueListenable: _progress.revision, + builder: (context, _, __) { + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SizedBox(height: 16), - TranslatedText( - "Know Your Air", - style: TextStyle( - fontSize: 28, - fontWeight: FontWeight.w700, - color: AppColors.boldHeadlineColor, - ), - ), - const SizedBox(height: 8), - TranslatedText( - "👋 Welcome! to \"Know Your Air,\" you'll learn about AirQo and air quality.", - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w500, - color: Color(0xff7A7F87), - ), + const LearnDashboardHeader(), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: _buildTabSelector(), ), - const SizedBox(height: 16), - _buildTabSelector(), - const SizedBox(height: 16), Expanded( child: _selectedIndex == 0 - ? _buildLessonsContent() + ? _buildCoursesContent() : const LearnSurveysPage(), ), ], - ), - ), + ); + }, ), ); } Widget _buildTabSelector() { - if (!_surveysEnabled) { - // No tab selector needed — just show the Lessons pill as before - return Row( - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 16), - height: 38, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(40), - color: AppColors.primaryColor, - ), - child: const Center( - child: TranslatedText( - "Lessons", - style: TextStyle(color: Colors.white), - ), - ), - ), - ], - ); - } - return Row( children: [ - _buildTab("Lessons", 0), + _pill('Courses', selected: _selectedIndex == 0, onTap: () => setState(() => _selectedIndex = 0)), const SizedBox(width: 8), - _buildTab("Surveys", 1), + _pill('Surveys', selected: _selectedIndex == 1, onTap: () => setState(() => _selectedIndex = 1)), ], ); } - Widget _buildTab(String label, int index) { - final isSelected = _selectedIndex == index; + Widget _pill(String label, {required bool selected, VoidCallback? onTap}) { + final isDark = LearnDesignTokens.isDark(context); return GestureDetector( - onTap: () => setState(() => _selectedIndex = index), + onTap: onTap, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16), - height: 38, + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 20), decoration: BoxDecoration( - borderRadius: BorderRadius.circular(40), - color: isSelected ? AppColors.primaryColor : Colors.transparent, - border: isSelected - ? null - : Border.all(color: AppColors.primaryColor, width: 1), + borderRadius: BorderRadius.circular(LearnDesignTokens.tabPillRadius), + color: selected + ? AppColors.primaryColor + : (isDark ? AppColors.darkHighlight : AppColors.dividerColorlight), ), - child: Center( - child: TranslatedText( - label, - style: TextStyle( - color: isSelected ? Colors.white : AppColors.primaryColor, - fontWeight: FontWeight.w600, - ), + alignment: Alignment.center, + child: TranslatedText( + label, + style: TextStyle( + color: selected + ? Colors.white + : (isDark ? Colors.white : Colors.black87), + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + fontSize: 14, ), ), ), ); } - Widget _buildLessonsContent() { + Widget _buildCoursesContent() { return BlocBuilder( builder: (context, state) { if (state is LessonsLoading || _isRetrying) { - return ListView(children: [ - ShimmerContainer(height: 200, borderRadius: 8, width: double.infinity), - const SizedBox(height: 16), - ShimmerContainer(height: 200, borderRadius: 8, width: double.infinity), - ]); - } else if (state is LessonsLoaded) { - return ListView.builder( - itemCount: state.model.kyaLessons.length, - itemBuilder: (context, index) => - KyaLessonContainer(state.model.kyaLessons[index]), + return ListView( + padding: const EdgeInsets.all(16), + children: const [ + ShimmerContainer(height: 120, borderRadius: 12, width: double.infinity), + SizedBox(height: 16), + ShimmerContainer(height: 200, borderRadius: 12, width: double.infinity), + ], ); - } else if (state is LessonsLoadingError) { - if (state.cachedModel != null) { - return ListView.builder( - itemCount: state.cachedModel!.kyaLessons.length, - itemBuilder: (context, index) => - KyaLessonContainer(state.cachedModel!.kyaLessons[index]), - ); - } - return RefreshIndicator( - onRefresh: () async => _retryLoading(), - child: ListView( - children: [ - const SizedBox(height: 100), - Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - state.isOffline ? Icons.cloud_off : Icons.error_outline, - size: 64, - color: Colors.grey, - ), - const SizedBox(height: 16), - TranslatedText( - "Unable to load content", - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Theme.of(context).textTheme.headlineMedium?.color, - ), - ), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 32.0), - child: TranslatedText( - state.isOffline - ? "Please check your connection and try again" - : "Something went wrong. Please try again later", - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 16, - color: Theme.of(context).textTheme.bodyMedium?.color, - ), - ), + } + + final List apiLessons = switch (state) { + LessonsLoaded s => s.model.kyaLessons, + LessonsLoadingError s => s.cachedModel?.kyaLessons ?? const [], + _ => const [], + }; + + if (state is LessonsLoadingError && apiLessons.isEmpty) { + return _buildErrorState(state); + } + + final courses = LearnCatalog.buildFromLessons(apiLessons); + if (state is LessonsLoaded || apiLessons.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _onLessonsReady(courses, apiLessons); + }); + } + + final stage = LearnCatalog.currentStage(courses, _progress); + final completed = LearnCatalog.catalogCompletedLessons(courses, _progress); + final total = LearnCatalog.catalogTotalLessons(courses); + final points = _progress.totalPoints(courses); + final maxPoints = _progress.maxPoints(courses); + + return CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: LearnLevelSummaryCard( + stage: stage, + completedLessons: completed, + totalLessons: total, + earnedPoints: points, + maxPoints: maxPoints, + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: TranslatedText( + 'COURSES FOR YOU', + style: LearnDesignTokens.slbl(context), + ), + ), + ), + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 32), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 0.72, + ), + delegate: SliverChildBuilderDelegate( + (context, index) { + final course = courses[index]; + final locked = !LearnCatalog.isCourseUnlocked( + courses, + index, + _progress, + ); + return LearnCoursePortraitCard( + course: course, + locked: locked, + coverImageUrl: LearnCatalog.courseCoverImage( + course, + apiLessons, ), - const SizedBox(height: 24), - ElevatedButton.icon( - onPressed: _retryLoading, - icon: const Icon(Icons.refresh), - label: const TranslatedText('Try Again'), - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primaryColor, - foregroundColor: Colors.white, - ), + onTap: () => LearnBottomSheets.showCourseDetail( + context, + course: course, + allCourses: courses, ), - ], - ), + ); + }, + childCount: courses.length, ), - ], + ), ), - ); - } - return const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CircularProgressIndicator(), - SizedBox(height: 16), - TranslatedText("Loading know your air content..."), - ], - ), + ], ); }, ); } + + Widget _buildErrorState(LessonsLoadingError state) { + return RefreshIndicator( + onRefresh: () async => _retryLoading(), + child: ListView( + children: [ + const SizedBox(height: 100), + Center( + child: Column( + children: [ + Icon( + state.isOffline ? Icons.cloud_off : Icons.error_outline, + size: 64, + color: Colors.grey, + ), + const SizedBox(height: 16), + const TranslatedText( + 'Unable to load content', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: _retryLoading, + icon: const Icon(Icons.refresh), + label: const TranslatedText('Try Again'), + style: learnExposurePrimaryButtonStyle(), + ), + ], + ), + ), + ], + ), + ); + } } diff --git a/src/mobile/lib/src/app/learn/pages/learn_course_detail_page.dart b/src/mobile/lib/src/app/learn/pages/learn_course_detail_page.dart new file mode 100644 index 0000000000..2852c51490 --- /dev/null +++ b/src/mobile/lib/src/app/learn/pages/learn_course_detail_page.dart @@ -0,0 +1,207 @@ +import 'package:airqo/src/app/learn/formatting/learn_display_text.dart'; +import 'package:airqo/src/app/learn/models/learn_course_structure.dart'; +import 'package:airqo/src/app/learn/services/learn_progress_service.dart'; +import 'package:airqo/src/app/learn/theme/learn_design_tokens.dart'; +import 'package:airqo/src/app/learn/widgets/learn_lesson_list_row.dart'; +import 'package:airqo/src/app/learn/widgets/learn_unit_chip.dart'; +import 'package:airqo/src/app/shared/widgets/translated_text.dart'; +import 'package:airqo/src/meta/utils/colors.dart'; +import 'package:flutter/material.dart'; + +typedef LearnLessonTapCallback = void Function( + LearnCourseViewModel course, + LearnUnitViewModel unit, + int unitIndex, + int lessonIndex, + LearnLessonSlot slot, +); + +class LearnCourseDetailPage extends StatefulWidget { + final LearnCourseViewModel course; + final List allCourses; + final LearnLessonTapCallback onLessonTap; + + const LearnCourseDetailPage({ + super.key, + required this.course, + required this.allCourses, + required this.onLessonTap, + }); + + @override + State createState() => _LearnCourseDetailPageState(); +} + +class _LearnCourseDetailPageState extends State { + late int _selectedUnitIndex; + + @override + void initState() { + super.initState(); + _selectedUnitIndex = LearnCatalog.defaultSelectedUnitIndex( + widget.course, + LearnProgressService.instance, + ); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: LearnProgressService.instance.revision, + builder: (context, _, __) => _buildSheet(context), + ); + } + + Widget _buildSheet(BuildContext context) { + final bg = LearnDesignTokens.sheetBg(context); + final titleColor = LearnDesignTokens.headline(context); + final subtitleColor = LearnDesignTokens.subtitle(context); + final progress = LearnProgressService.instance; + final course = widget.course; + final completed = course.completedLessons(progress); + final total = course.totalLessons; + final selectedUnit = course.units[_selectedUnitIndex.clamp( + 0, + course.units.length - 1, + )]; + final unitUnlocked = LearnCatalog.isUnitUnlocked( + course, + _selectedUnitIndex, + progress, + ); + + return DraggableScrollableSheet( + initialChildSize: 0.88, + minChildSize: 0.5, + maxChildSize: 0.95, + expand: false, + builder: (ctx, scrollCtrl) { + return Container( + decoration: BoxDecoration( + color: bg, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + LearnDesignTokens.dragHandle(context), + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 12, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Course ${course.courseNumber}', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + color: subtitleColor, + ), + ), + const SizedBox(height: 2), + TranslatedText( + course.plainTitleKey, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + height: 1.2, + color: titleColor, + ), + ), + const SizedBox(height: 4), + Text( + '$completed of $total lessons complete', + style: TextStyle(fontSize: 13, color: subtitleColor), + ), + ], + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: Icon( + Icons.close, + color: AppTextColors.modalCloseIcon(context), + ), + visualDensity: VisualDensity.compact, + ), + ], + ), + ), + const SizedBox(height: 12), + LearnUnitChipRow( + course: course, + selectedUnitIndex: _selectedUnitIndex, + onUnitSelected: (index) { + setState(() => _selectedUnitIndex = index); + }, + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 10, 20, 0), + child: TranslatedText( + learnUnitHeader( + _selectedUnitIndex, + selectedUnit.plainTitleKey, + ), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 0.6, + color: subtitleColor, + ), + ), + ), + const SizedBox(height: 12), + Expanded( + child: ListView.builder( + controller: scrollCtrl, + padding: const EdgeInsets.fromLTRB(20, 0, 20, 32), + itemCount: selectedUnit.lessons.length, + itemBuilder: (context, lessonIndex) { + final slot = selectedUnit.lessons[lessonIndex]; + final lessonUnlocked = unitUnlocked && + LearnCatalog.isLessonUnlocked( + selectedUnit, + lessonIndex, + progress, + ); + final complete = + progress.isLessonComplete(slot.progressKey); + final ratio = progress.lessonProgressRatio( + slot.progressKey, + slot.activityCount, + ); + final locked = !lessonUnlocked; + final canOpen = lessonUnlocked; + + return LearnLessonListRow( + slot: slot, + unitIndex: _selectedUnitIndex, + lessonIndex: lessonIndex, + locked: locked, + complete: complete, + progressRatio: ratio, + onOpen: canOpen + ? () => widget.onLessonTap( + course, + selectedUnit, + _selectedUnitIndex, + lessonIndex, + slot, + ) + : null, + ); + }, + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/src/mobile/lib/src/app/learn/pages/learn_surveys_page.dart b/src/mobile/lib/src/app/learn/pages/learn_surveys_page.dart index b0b1d92873..6ecc5a1392 100644 --- a/src/mobile/lib/src/app/learn/pages/learn_surveys_page.dart +++ b/src/mobile/lib/src/app/learn/pages/learn_surveys_page.dart @@ -1,13 +1,15 @@ +import 'package:airqo/src/app/learn/widgets/learn_sheet_button_styles.dart'; import 'package:airqo/src/app/shared/widgets/translated_text.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:collection/collection.dart'; import 'package:airqo/src/app/surveys/bloc/survey_bloc.dart'; import 'package:airqo/src/app/surveys/models/survey_model.dart'; import 'package:airqo/src/app/surveys/models/survey_response_model.dart'; import 'package:airqo/src/app/surveys/pages/survey_detail_page.dart'; import 'package:airqo/src/meta/utils/colors.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +/// Surveys sub-tab inside Learn — matches the research-app experience. class LearnSurveysPage extends StatefulWidget { const LearnSurveysPage({super.key}); @@ -16,7 +18,6 @@ class LearnSurveysPage extends StatefulWidget { } class _LearnSurveysPageState extends State { - @override void initState() { super.initState(); @@ -64,16 +65,15 @@ class _LearnSurveysPageState extends State { context.read().add(const LoadSurveys(forceRefresh: true)); }, child: SingleChildScrollView( - padding: const EdgeInsets.only(bottom: 16), + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SizedBox(height: 16), - // Survey list ...state.surveys.map((survey) { final userResponse = state.userResponses .firstWhereOrNull((r) => r.surveyId == survey.id); - + return Padding( padding: const EdgeInsets.only(bottom: 12), child: _buildSurveyCardForLearn( @@ -82,7 +82,6 @@ class _LearnSurveysPageState extends State { ), ); }), - const SizedBox(height: 16), ], ), ), @@ -93,25 +92,11 @@ class _LearnSurveysPageState extends State { required Survey survey, SurveyResponse? userResponse, }) { - final theme = Theme.of(context); - final isDarkMode = theme.brightness == Brightness.dark; - return GestureDetector( onTap: () => _navigateToSurveyDetail(survey, userResponse), child: Container( - margin: const EdgeInsets.symmetric(vertical: 4), width: double.infinity, - decoration: BoxDecoration( - color: theme.cardColor, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.1), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), + decoration: AppSurfaceColors.elevatedCardDecoration(context), child: Padding( padding: const EdgeInsets.all(16), child: Column( @@ -126,7 +111,7 @@ class _LearnSurveysPageState extends State { style: TextStyle( fontWeight: FontWeight.w700, fontSize: 16, - color: isDarkMode ? Colors.white : AppColors.boldHeadlineColor4, + color: AppTextColors.headline(context), ), maxLines: 2, overflow: TextOverflow.ellipsis, @@ -134,12 +119,15 @@ class _LearnSurveysPageState extends State { ), if (userResponse == null) Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), decoration: BoxDecoration( color: AppColors.primaryColor, borderRadius: BorderRadius.circular(12), ), - child: TranslatedText( + child: const TranslatedText( 'New', style: TextStyle( color: Colors.white, @@ -150,12 +138,15 @@ class _LearnSurveysPageState extends State { ) else if (userResponse.isCompleted) Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), decoration: BoxDecoration( color: Colors.green, borderRadius: BorderRadius.circular(12), ), - child: TranslatedText( + child: const TranslatedText( 'Completed', style: TextStyle( color: Colors.white, @@ -166,12 +157,15 @@ class _LearnSurveysPageState extends State { ) else if (userResponse.isInProgress) Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), decoration: BoxDecoration( color: Colors.orange, borderRadius: BorderRadius.circular(12), ), - child: TranslatedText( + child: const TranslatedText( 'In Progress', style: TextStyle( color: Colors.white, @@ -188,7 +182,7 @@ class _LearnSurveysPageState extends State { survey.description, style: TextStyle( fontSize: 14, - color: isDarkMode ? Colors.grey[300] : Colors.grey[600], + color: AppTextColors.muted(context), ), maxLines: 2, overflow: TextOverflow.ellipsis, @@ -200,28 +194,28 @@ class _LearnSurveysPageState extends State { Icon( Icons.schedule, size: 14, - color: isDarkMode ? Colors.grey[400] : Colors.grey[600], + color: AppTextColors.subtitle(context), ), const SizedBox(width: 4), Text( survey.estimatedTimeString, style: TextStyle( fontSize: 12, - color: isDarkMode ? Colors.grey[400] : Colors.grey[600], + color: AppTextColors.subtitle(context), ), ), const SizedBox(width: 16), Icon( Icons.quiz_outlined, size: 14, - color: isDarkMode ? Colors.grey[400] : Colors.grey[600], + color: AppTextColors.subtitle(context), ), const SizedBox(width: 4), Text( '${survey.questions.length} questions', style: TextStyle( fontSize: 12, - color: isDarkMode ? Colors.grey[400] : Colors.grey[600], + color: AppTextColors.subtitle(context), ), ), const Spacer(), @@ -275,7 +269,8 @@ class _LearnSurveysPageState extends State { Text( state.message, style: theme.textTheme.bodyMedium?.copyWith( - color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.7), + color: + theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.7), ), textAlign: TextAlign.center, ), @@ -284,13 +279,7 @@ class _LearnSurveysPageState extends State { onPressed: () { context.read().add(const LoadSurveys()); }, - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primaryColor, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), + style: learnExposurePrimaryButtonStyle(), child: const TranslatedText('Try Again'), ), ], @@ -301,7 +290,7 @@ class _LearnSurveysPageState extends State { Widget _buildEmptyState() { final theme = Theme.of(context); - + return Center( child: Padding( padding: const EdgeInsets.all(32), @@ -325,14 +314,17 @@ class _LearnSurveysPageState extends State { TranslatedText( 'Check back later for new research surveys.', style: theme.textTheme.bodyMedium?.copyWith( - color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.7), + color: + theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.7), ), textAlign: TextAlign.center, ), const SizedBox(height: 24), TextButton( onPressed: () { - context.read().add(const LoadSurveys(forceRefresh: true)); + context + .read() + .add(const LoadSurveys(forceRefresh: true)); }, child: const TranslatedText('Refresh'), ), @@ -342,18 +334,22 @@ class _LearnSurveysPageState extends State { ); } - void _navigateToSurveyDetail(Survey survey, SurveyResponse? existingResponse) { - Navigator.of(context).push( + void _navigateToSurveyDetail( + Survey survey, + SurveyResponse? existingResponse, + ) { + Navigator.of(context) + .push( MaterialPageRoute( builder: (context) => SurveyDetailPage( survey: survey, existingResponse: existingResponse, ), ), - ).then((_) { + ) + .then((_) { if (!mounted) return; context.read().add(const LoadSurveys()); }); } } - diff --git a/src/mobile/lib/src/app/learn/pages/lesson_page.dart b/src/mobile/lib/src/app/learn/pages/lesson_page.dart index 598e5b529b..0ebc33aa6c 100644 --- a/src/mobile/lib/src/app/learn/pages/lesson_page.dart +++ b/src/mobile/lib/src/app/learn/pages/lesson_page.dart @@ -1,208 +1,65 @@ -import 'package:airqo/src/app/auth/pages/register_page.dart'; +import 'package:airqo/src/app/learn/models/learn_course_structure.dart'; +import 'package:airqo/src/app/learn/models/learn_lesson_continuation.dart'; import 'package:airqo/src/app/learn/models/lesson_response_model.dart'; -import 'package:airqo/src/app/learn/pages/lesson_finished.dart'; -import 'package:airqo/src/app/shared/widgets/translated_text.dart'; -import 'package:flutter_card_swiper/flutter_card_swiper.dart'; +import 'package:airqo/src/app/learn/widgets/experience/learn_lesson_experience.dart'; import 'package:flutter/material.dart'; - -class LessonPage extends StatefulWidget { - final KyaLesson lesson; - - const LessonPage(this.lesson, {super.key}); - @override - State createState() => _LessonPageState(); -} - -class _LessonPageState extends State { - CardSwiperController? controller; - - int currentIndex = 0; - - void changeIndex(int index) { - setState(() { - currentIndex = index; - }); - } - - bool finished = false; - - @override - void initState() { - controller = CardSwiperController(); - super.initState(); - } - - @override - void dispose() { - controller!.dispose(); - super.dispose(); - } +/// Hosts [LearnLessonExperience] inside a modal sheet or full-screen route. +class LessonPage extends StatelessWidget { + final LearnLessonSlot slot; + final KyaLesson? apiLesson; + final LearnCourseViewModel course; + final int unitIndex; + final int lessonIndex; + final String unitPlainTitle; + final int lessonNumberInUnit; + final int lessonsInUnit; + final List? allCourses; + final LearnLessonContinuation? continuation; + final bool presentedAsModalSheet; + + const LessonPage({ + super.key, + required this.slot, + required this.course, + required this.unitIndex, + required this.lessonIndex, + required this.unitPlainTitle, + this.apiLesson, + this.lessonNumberInUnit = 1, + this.lessonsInUnit = 1, + this.allCourses, + this.continuation, + this.presentedAsModalSheet = false, + }); @override Widget build(BuildContext context) { - return Container( - color: Theme.of(context).scaffoldBackgroundColor, - child: Scaffold( - appBar: AppBar( - title: TranslatedText("Lesson"), - centerTitle: true, - ), - body: finished - ? LessonFinishedWidget() - : Column( - children: [ - SizedBox( - height: 6, - child: StepperWidget( - green: true, - currentIndex: currentIndex, - count: widget.lesson.tasks.length)), - Expanded( - flex: 2, - child: SizedBox( - height: 400, - child: CardSwiper( - allowedSwipeDirection: AllowedSwipeDirection.none(), - onSwipe: (previousIndex, idx, direction) async { - changeIndex(idx!); - - if (currentIndex == widget.lesson.tasks.length - 1) { - await Future.delayed(Duration(seconds: 3)); - - setState(() { - finished = true; - }); - } - - return true; - }, - onUndo: (previousIndex, idx, direction) { - changeIndex(idx); - return true; - }, - controller: controller, - cardsCount: widget.lesson.tasks.length, - cardBuilder: (context, index, percentThresholdX, - percentThresholdY) => - CardContent(data: widget.lesson.tasks[index]), - ), - ), - ), - Expanded( - flex: 1, - child: Column( - children: [ - SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - GestureDetector( - onTap: () => controller!.undo(), - child: Container( - height: 62, - width: 78, - decoration: BoxDecoration( - borderRadius: - BorderRadius.circular(40), - color: - Theme.of(context).highlightColor), - child: Center( - child: Icon( - Icons.arrow_back_ios, - color: Colors.black, - size: 17, - ), - ), - ), - ), - SizedBox(width: 8), - GestureDetector( - onTap: () => controller! - .swipe(CardSwiperDirection.left), - child: Container( - height: 62, - width: 78, - decoration: BoxDecoration( - borderRadius: - BorderRadius.circular(40), - color: Color(0xff57D175)), - child: Center( - child: Icon( - Icons.arrow_forward_ios, - color: Colors.black, - size: 17, - ), - ), - ), - ), - ]) - ], - ), - ) - ], - ), - ), + final experience = LearnLessonExperience( + slot: slot, + apiLesson: apiLesson ?? slot.apiLesson, + course: course, + unitIndex: unitIndex, + lessonIndex: lessonIndex, + unitPlainTitle: unitPlainTitle, + lessonNumberInUnit: lessonNumberInUnit, + lessonsInUnit: lessonsInUnit, + allCourses: allCourses, + continuation: continuation, + completionContext: context, + onClose: () => Navigator.of(context).pop(), ); - } -} -class CardContent extends StatelessWidget { - final Task data; - const CardContent({super.key, required this.data}); + if (presentedAsModalSheet) { + return SizedBox.expand(child: experience); + } - @override - Widget build(BuildContext context) { - return Container( - width: double.infinity, - decoration: BoxDecoration( - image: DecorationImage( - image: NetworkImage(data.image), fit: BoxFit.cover), - color: Colors.blue, - borderRadius: BorderRadius.circular(8)), - alignment: Alignment.center, - child: Column( - children: [ - Spacer(), - Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Color(0xff57D175), - borderRadius: BorderRadius.only( - bottomRight: Radius.circular(8), - bottomLeft: Radius.circular(8))), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TranslatedText( - data.title, - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.w700, - color: Colors.black), - ), - SizedBox(height: 8), - TranslatedText( - data.content, - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w500, - color: Colors.black), - ), - SizedBox(height: 4), - ], - ), - ) - ], + return Scaffold( + appBar: AppBar( + title: const Text('Lesson'), + centerTitle: true, ), + body: experience, ); } } - -class CardContentData { - final String title; - final String text; - - const CardContentData({required this.title, required this.text}); -} diff --git a/src/mobile/lib/src/app/learn/services/learn_lesson_experience_service.dart b/src/mobile/lib/src/app/learn/services/learn_lesson_experience_service.dart new file mode 100644 index 0000000000..9659247cfe --- /dev/null +++ b/src/mobile/lib/src/app/learn/services/learn_lesson_experience_service.dart @@ -0,0 +1,215 @@ +import 'package:airqo/src/app/learn/formatting/learn_display_text.dart'; +import 'package:airqo/src/app/learn/models/learn_course_structure.dart'; +import 'package:airqo/src/app/learn/models/learn_lesson_activity.dart'; +import 'package:airqo/src/app/learn/models/lesson_response_model.dart'; + +class LearnLessonExperienceService { + const LearnLessonExperienceService._(); + + static const demoActivityCount = 7; + + static const _demoVideoUrl = + 'https://www.youtube.com/watch?v=U5F-F2AHH7s'; + + static List buildDemoScript({ + required String lessonTitle, + required String unitTitle, + required LearnLessonSlot slot, + KyaLesson? apiLesson, + }) { + final topic = learnDisplayTitle(lessonTitle.isNotEmpty + ? lessonTitle + : slot.plainTitleKey); + final unit = learnDisplayTitle(unitTitle); + final imageUrl = _resolveImage(slot, apiLesson); + final articleBody = _resolveArticleBody(slot, apiLesson, topic, unit); + + return [ + LearnLessonActivity( + index: 0, + type: LearnActivityType.article, + title: 'About $topic', + article: LearnArticlePayload( + body: articleBody, + audioText: articleBody, + ), + ), + LearnLessonActivity( + index: 1, + type: LearnActivityType.video, + title: 'Watch: $topic', + video: LearnVideoPayload( + videoUrl: _demoVideoUrl, + description: + 'A short overview of $topic and why it matters for the air around you in $unit.', + posterUrl: imageUrl, + ), + ), + LearnLessonActivity( + index: 2, + type: LearnActivityType.image, + title: 'Spot it: $topic', + image: LearnImagePayload( + imageUrl: imageUrl, + caption: 'Look for signs of $topic near your home.', + subtitle: + 'Community photo — notice smoke, dust, or traffic that affects daily air quality.', + ), + ), + LearnLessonActivity( + index: 3, + type: LearnActivityType.quiz, + title: 'Quick check', + quiz: LearnQuizPayload( + format: LearnQuizFormat.singleChoice, + question: 'What is the main pollution source in this lesson?', + options: [ + 'Vehicle exhaust', + topic, + 'Factory chimney', + 'Crop burning', + ], + correctIndex: 1, + correctFeedback: 'Correct! $topic is the focus of this lesson.', + incorrectFeedback: + 'Not quite — re-read the article and try again.', + ), + ), + LearnLessonActivity( + index: 4, + type: LearnActivityType.quiz, + title: 'Multiple choice', + quiz: LearnQuizPayload( + format: LearnQuizFormat.multiChoice, + question: 'Which activities can raise PM2.5 near your home?', + options: [ + 'Burning charcoal', + 'Using solar panels', + 'Smoking indoors', + 'Sweeping dry floors', + ], + correctIndices: {0, 2, 3}, + correctFeedback: + 'Correct! Charcoal, smoking, and sweeping all release particles.', + incorrectFeedback: + 'Charcoal, smoking, and sweeping release particles. Solar panels do not.', + ), + ), + LearnLessonActivity( + index: 5, + type: LearnActivityType.quiz, + title: 'Ranking', + quiz: LearnQuizPayload( + format: LearnQuizFormat.ranking, + question: 'Order these from most to least impact on indoor air.', + options: [ + 'Charcoal cooking', + 'Crop burning smoke', + 'Vehicle exhaust', + 'Road dust', + ], + correctOrder: [0, 1, 2, 3], + correctFeedback: 'Great job — that order matches typical indoor impact.', + incorrectFeedback: + 'Almost! Charcoal and crop smoke usually have the biggest indoor impact.', + ), + ), + LearnLessonActivity( + index: 6, + type: LearnActivityType.quiz, + title: 'Your thoughts', + quiz: LearnQuizPayload( + format: LearnQuizFormat.freeText, + question: + 'Name one thing near your home that affects your air quality.', + options: const [], + correctFeedback: + 'Noted! Top answers from your area: open burning, road dust, charcoal smoke.', + ), + ), + ]; + } + + static String activityTypeKey(LearnLessonActivity activity) { + switch (activity.type) { + case LearnActivityType.article: + return 'ARTICLE'; + case LearnActivityType.video: + return 'VIDEO'; + case LearnActivityType.image: + return 'IMAGE'; + case LearnActivityType.quiz: + return 'QUIZ'; + } + } + + static String activityLabel(LearnLessonActivity activity) { + switch (activity.type) { + case LearnActivityType.article: + return 'Reading'; + case LearnActivityType.video: + return 'Video'; + case LearnActivityType.image: + return 'Image'; + case LearnActivityType.quiz: + return _quizLabel(activity.quiz?.format); + } + } + + static String _quizLabel(LearnQuizFormat? format) { + switch (format) { + case LearnQuizFormat.singleChoice: + return 'Quiz · Single choice'; + case LearnQuizFormat.multiChoice: + return 'Quiz · Multiple choice'; + case LearnQuizFormat.ranking: + return 'Quiz · Ranking'; + case LearnQuizFormat.freeText: + return 'Quiz · Your words'; + case null: + return 'Quiz'; + } + } + + static bool isCourseFinalLesson( + LearnCourseViewModel course, + int unitIndex, + int lessonIndex, + ) { + if (course.units.isEmpty) return false; + final lastUnitIndex = course.units.length - 1; + if (unitIndex != lastUnitIndex) return false; + final lastUnit = course.units[lastUnitIndex]; + return lessonIndex == lastUnit.lessons.length - 1; + } + + static String _resolveImage(LearnLessonSlot slot, KyaLesson? apiLesson) { + if (apiLesson != null && apiLesson.image.isNotEmpty) { + return apiLesson.image; + } + if (apiLesson != null) { + for (final task in apiLesson.tasks) { + if (task.image.isNotEmpty) return task.image; + } + } + return ''; + } + + static String _resolveArticleBody( + LearnLessonSlot slot, + KyaLesson? apiLesson, + String topic, + String unit, + ) { + if (apiLesson != null && apiLesson.tasks.isNotEmpty) { + final parts = apiLesson.tasks + .where((t) => t.content.trim().isNotEmpty) + .map((t) => t.content.trim()) + .toList(); + if (parts.isNotEmpty) return parts.join('\n\n'); + } + return 'In $unit, understanding $topic helps you read the air around you. ' + 'Small particles in smoke and dust can reach your lungs before you feel anything. ' + 'Knowing what to look for is the first step to protecting yourself and your family.'; + } +} diff --git a/src/mobile/lib/src/app/learn/services/learn_progress_service.dart b/src/mobile/lib/src/app/learn/services/learn_progress_service.dart new file mode 100644 index 0000000000..c9e943754e --- /dev/null +++ b/src/mobile/lib/src/app/learn/services/learn_progress_service.dart @@ -0,0 +1,219 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:airqo/src/app/learn/models/learn_course_structure.dart'; + +/// Local persistence for Learn tab progress (SharedPreferences). +class LearnProgressService { + LearnProgressService._(); + static final LearnProgressService instance = LearnProgressService._(); + + static const _pilotSeedKey = 'learn_pilot_seeded_v3'; + static const _stepPrefix = 'learn_step_'; + static const _completePrefix = 'learn_complete_'; + static const _courseDemoKey = 'learn_course_demo_shown_'; + static const _starsPrefix = 'learn_stars_'; + static const _pointsPrefix = 'learn_points_'; + static const _quizScorePrefix = 'learn_quiz_score_'; + static const _freeTextPrefix = 'learn_free_text_'; + + final ValueNotifier revision = ValueNotifier(0); + SharedPreferences? _prefs; + + Future ensureInitialized() async { + _prefs ??= await SharedPreferences.getInstance(); + } + + void _notify() => revision.value++; + + int furthestStep(String lessonKey) { + return _prefs?.getInt('$_stepPrefix$lessonKey') ?? 0; + } + + Future recordLessonFurthestStep(String lessonKey, int step) async { + await ensureInitialized(); + final current = furthestStep(lessonKey); + if (step > current) { + await _prefs!.setInt('$_stepPrefix$lessonKey', step); + _notify(); + } + } + + bool isLessonComplete(String lessonKey) { + return _prefs?.getBool('$_completePrefix$lessonKey') ?? false; + } + + Future markLessonComplete(String lessonKey) async { + await ensureInitialized(); + await _prefs!.setBool('$_completePrefix$lessonKey', true); + _notify(); + } + + double lessonProgressRatio(String lessonKey, int totalSteps) { + if (isLessonComplete(lessonKey)) return 1; + if (totalSteps <= 0) return 0; + return (furthestStep(lessonKey) / totalSteps).clamp(0.0, 1.0); + } + + int lessonStars(String lessonKey) { + return _prefs?.getInt('$_starsPrefix$lessonKey') ?? 0; + } + + int lessonPoints(String lessonKey) { + return _prefs?.getInt('$_pointsPrefix$lessonKey') ?? 0; + } + + double lessonQuizScore(String lessonKey) { + return _prefs?.getDouble('$_quizScorePrefix$lessonKey') ?? 0; + } + + String? lessonFreeText(String lessonKey) { + return _prefs?.getString('$_freeTextPrefix$lessonKey'); + } + + Future saveLessonResult({ + required String lessonKey, + required int stars, + required int points, + required double quizScoreRatio, + String? freeText, + }) async { + await ensureInitialized(); + await _prefs!.setInt('$_starsPrefix$lessonKey', stars); + await _prefs!.setInt('$_pointsPrefix$lessonKey', points); + await _prefs!.setDouble('$_quizScorePrefix$lessonKey', quizScoreRatio); + if (freeText != null && freeText.trim().isNotEmpty) { + await _prefs!.setString('$_freeTextPrefix$lessonKey', freeText.trim()); + } + _notify(); + } + + int totalPoints(List courses) { + var total = 0; + for (final course in courses) { + for (final unit in course.units) { + for (final lesson in unit.lessons) { + total += lessonPoints(lesson.progressKey); + } + } + } + return total; + } + + /// Max points if every lesson earned 3 stars (30 pts each). + int maxPoints(List courses) { + return catalogTotalLessons(courses) * 30; + } + + static int catalogTotalLessons(List courses) { + return courses.fold(0, (s, c) => s + c.totalLessons); + } + + bool hasShownCourseDemo(String courseId) { + return _prefs?.getBool('$_courseDemoKey$courseId') ?? false; + } + + Future markCourseDemoShown(String courseId) async { + await ensureInitialized(); + await _prefs!.setBool('$_courseDemoKey$courseId', true); + } + + /// Pre-seeds pilot progress: course 1 complete, course 2 in progress, 3–4 locked. + /// In debug builds this re-applies on every launch so prototype states stay visible. + Future ensurePilotLearnDemosV3({ + required List courses, + }) async { + await ensureInitialized(); + if (courses.isEmpty) return; + + _syncLegacyApiProgressToCatalog(courses); + + final alreadySeeded = _prefs!.getBool(_pilotSeedKey) == true; + if (!kDebugMode && alreadySeeded) return; + + if (kDebugMode) { + await _clearLearnProgressKeys(); + } else if (alreadySeeded) { + return; + } + + Future markComplete(String key) => + _prefs!.setBool('$_completePrefix$key', true); + + Future markInProgress(String key, int step) => + _prefs!.setInt('$_stepPrefix$key', step); + + Future seedResult(String key, {int stars = 3}) async { + await markComplete(key); + await _prefs!.setInt('$_starsPrefix$key', stars); + await _prefs!.setInt('$_pointsPrefix$key', stars * 10); + await _prefs!.setDouble('$_quizScorePrefix$key', stars / 3); + } + + // Course 1 — fully completed. + for (final unit in courses.first.units) { + for (final lesson in unit.lessons) { + await seedResult(lesson.progressKey); + } + } + + // Course 2 — in progress (two done, third partially started). + if (courses.length > 1) { + final lessons = + courses[1].units.expand((unit) => unit.lessons).toList(); + for (var i = 0; i < lessons.length && i <= 2; i++) { + final key = lessons[i].progressKey; + if (i < 2) { + await seedResult(key, stars: i == 0 ? 3 : 2); + } else { + await markInProgress(key, 2); + } + } + } + + await _prefs!.setBool(_pilotSeedKey, true); + _notify(); + } + + Future _clearLearnProgressKeys() async { + final keys = _prefs!.getKeys().where( + (key) => + key.startsWith(_stepPrefix) || + key.startsWith(_completePrefix) || + key.startsWith(_courseDemoKey) || + key.startsWith(_starsPrefix) || + key.startsWith(_pointsPrefix) || + key.startsWith(_quizScorePrefix) || + key.startsWith(_freeTextPrefix) || + key == _pilotSeedKey || + key == 'learn_pilot_seeded_v2', + ); + for (final key in keys) { + await _prefs!.remove(key); + } + } + + /// Copies progress stored under legacy API ids onto stable catalog ids. + void _syncLegacyApiProgressToCatalog(List courses) { + for (final course in courses) { + for (final unit in course.units) { + for (final slot in unit.lessons) { + final api = slot.apiLesson; + if (api == null) continue; + final catalogKey = slot.catalogId; + final apiKey = api.id; + if (catalogKey == apiKey) continue; + + if (isLessonComplete(apiKey) && !isLessonComplete(catalogKey)) { + _prefs!.setBool('$_completePrefix$catalogKey', true); + } + final apiStep = furthestStep(apiKey); + final catalogStep = furthestStep(catalogKey); + if (apiStep > catalogStep) { + _prefs!.setInt('$_stepPrefix$catalogKey', apiStep); + } + } + } + } + } +} diff --git a/src/mobile/lib/src/app/learn/services/learn_quiz_scoring_service.dart b/src/mobile/lib/src/app/learn/services/learn_quiz_scoring_service.dart new file mode 100644 index 0000000000..94924c3c62 --- /dev/null +++ b/src/mobile/lib/src/app/learn/services/learn_quiz_scoring_service.dart @@ -0,0 +1,104 @@ +import 'package:airqo/src/app/learn/models/learn_lesson_activity.dart'; + +class LearnQuizScoringService { + const LearnQuizScoringService._(); + + static LearnQuizGrade gradeSingleChoice( + LearnQuizPayload quiz, + int? selectedIndex, + ) { + if (selectedIndex == null) { + return const LearnQuizGrade( + isCorrect: false, + feedback: 'Please select an answer.', + ); + } + final correct = selectedIndex == quiz.correctIndex; + return LearnQuizGrade( + isCorrect: correct, + feedback: correct ? quiz.correctFeedback : quiz.incorrectFeedback, + ); + } + + static LearnQuizGrade gradeMultiChoice( + LearnQuizPayload quiz, + Set selectedIndices, + ) { + final expected = quiz.correctIndices ?? {}; + if (selectedIndices.isEmpty) { + return const LearnQuizGrade( + isCorrect: false, + feedback: 'Please select at least one answer.', + ); + } + final correct = selectedIndices.length == expected.length && + selectedIndices.containsAll(expected); + return LearnQuizGrade( + isCorrect: correct, + feedback: correct ? quiz.correctFeedback : quiz.incorrectFeedback, + ); + } + + static LearnQuizGrade gradeRanking( + LearnQuizPayload quiz, + List submittedOrder, + ) { + final expected = quiz.correctOrder ?? []; + if (submittedOrder.length != expected.length) { + return const LearnQuizGrade( + isCorrect: false, + feedback: 'Please rank all items.', + ); + } + final correct = _listsEqual(submittedOrder, expected); + return LearnQuizGrade( + isCorrect: correct, + feedback: correct ? quiz.correctFeedback : quiz.incorrectFeedback, + ); + } + + static LearnQuizGrade gradeFreeText(String response) { + final trimmed = response.trim(); + if (trimmed.isEmpty) { + return const LearnQuizGrade( + isCorrect: false, + feedback: 'Please share a thought before continuing.', + ); + } + return const LearnQuizGrade( + isCorrect: true, + feedback: 'Thanks for sharing — your response has been saved.', + ); + } + + static LearnLessonResult computeLessonResult({ + required List gradedQuizResults, + String? freeTextResponse, + }) { + final graded = gradedQuizResults.where((r) => r).length; + final totalGraded = gradedQuizResults.length; + final ratio = totalGraded == 0 ? 1.0 : graded / totalGraded; + + final stars = graded >= totalGraded && totalGraded > 0 + ? 3 + : graded >= (totalGraded / 2).ceil() + ? 2 + : 1; + final points = stars * 10; + + return LearnLessonResult( + stars: stars, + pointsEarned: points, + quizScoreRatio: ratio, + freeTextResponse: freeTextResponse, + ); + } + + static bool _listsEqual(List a, List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } +} diff --git a/src/mobile/lib/src/app/learn/theme/learn_design_tokens.dart b/src/mobile/lib/src/app/learn/theme/learn_design_tokens.dart new file mode 100644 index 0000000000..2640d52fbe --- /dev/null +++ b/src/mobile/lib/src/app/learn/theme/learn_design_tokens.dart @@ -0,0 +1,175 @@ +import 'package:airqo/src/meta/utils/colors.dart'; +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +/// Design tokens for the Learn tab — aligned with Exposure + HTML prototype. +class LearnDesignTokens { + const LearnDesignTokens._(); + + static const Color success = Color(0xff57D175); + static const Color error = Color(0xffE24B4A); + static const Color disabled = Color(0xffB0B5BC); + static const Color footerGradient = Color(0xC2121212); + + static Color successBg(BuildContext context) => + AppTextColors.successBackground(context); + + static Color successText(BuildContext context) => + AppTextColors.successForeground(context); + + static Color errorBg(BuildContext context) => + AppTextColors.errorBackground(context); + + static Color errorText(BuildContext context) => + AppTextColors.errorForeground(context); + + static bool isDark(BuildContext context) => + Theme.of(context).brightness == Brightness.dark; + + static Color primary(BuildContext context) => AppColors.primaryColor; + + static Color headline(BuildContext context) => AppTextColors.headline(context); + + static Color muted(BuildContext context) => AppTextColors.muted(context); + + static Color subtitle(BuildContext context) => AppTextColors.subtitle(context); + + static Color divider(BuildContext context) => AppSurfaceColors.border(context); + + static Color sheetBg(BuildContext context) => AppSurfaceColors.sheet(context); + + static Color nestedSurface(BuildContext context) => AppSurfaceColors.nested(context); + + static Color iconBg(BuildContext context) => isDark(context) + ? AppColors.darkThemeBackground + : AppColors.dividerColorlight; + + static Color screenBg(BuildContext context) => + Theme.of(context).scaffoldBackgroundColor; + + static Color cardBg(BuildContext context) => AppSurfaceColors.card(context); + + static Color sheetTitleColor(BuildContext context) => headline(context); + + static Color sheetSubtitleColor(BuildContext context) => muted(context); + + static TextStyle completionTitle(BuildContext context) => TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + height: 1.15, + color: sheetTitleColor(context), + ); + + static TextStyle completionSubtitle(BuildContext context) => TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + height: 1.3, + color: sheetSubtitleColor(context), + ); + + static TextStyle completionCaption(BuildContext context) => TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + height: 1.35, + color: sheetSubtitleColor(context), + ); + + static const double sheetTopRadius = 20; + static const double portraitCardRadius = 16; + static const double activityCardRadius = 12; + static const double pillRadius = 40; + static const double tabPillRadius = 30; + static const double horizontalPadding = 16; + + static Widget dragHandle(BuildContext context) { + final handleColor = muted(context); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Center( + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: handleColor.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + ); + } + + static Widget lessonProgressBar( + BuildContext context, { + required double value, + bool complete = false, + }) { + return ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: value.clamp(0.0, 1.0), + minHeight: 3, + backgroundColor: divider(context), + color: complete ? success : primary(context), + ), + ); + } + + static TextStyle slbl(BuildContext context) => TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + color: muted(context), + ); + + static TextStyle lessonLabel(BuildContext context) => TextStyle( + fontSize: 9, + fontWeight: FontWeight.w600, + letterSpacing: 0.4, + color: muted(context), + ); + + static TextStyle lessonTitle(BuildContext context) => TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + height: 1.15, + color: headline(context), + ); + + static TextStyle sectionSubtitle(BuildContext context) => TextStyle( + fontSize: 18, + fontWeight: FontWeight.w500, + height: 1.35, + color: subtitle(context), + ); + + static TextStyle activitySubtitle(BuildContext context) => TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: muted(context), + ); + + static const IconData completedCheckIcon = Icons.check_circle_outline; + + static Widget completedCheckIconWidget({double size = 18}) { + return Icon( + completedCheckIcon, + size: size, + color: success, + ); + } + + static Widget lockedContentBlur({ + required Widget child, + required BorderRadius borderRadius, + }) { + return ClipRRect( + borderRadius: borderRadius, + child: ImageFiltered( + imageFilter: ImageFilter.blur(sigmaX: 1.2, sigmaY: 1.2), + child: Opacity(opacity: 0.72, child: child), + ), + ); + } +} diff --git a/src/mobile/lib/src/app/learn/widgets/experience/learn_article_activity.dart b/src/mobile/lib/src/app/learn/widgets/experience/learn_article_activity.dart new file mode 100644 index 0000000000..86f0bb6fb5 --- /dev/null +++ b/src/mobile/lib/src/app/learn/widgets/experience/learn_article_activity.dart @@ -0,0 +1,198 @@ +import 'dart:async'; + +import 'package:airqo/src/app/learn/models/learn_lesson_activity.dart'; +import 'package:airqo/src/app/learn/theme/learn_design_tokens.dart'; +import 'package:airqo/src/app/learn/widgets/experience/learn_article_audio_player.dart'; +import 'package:airqo/src/app/learn/widgets/experience/learn_experience_shell.dart'; +import 'package:airqo/src/app/shared/widgets/translated_text.dart'; +import 'package:airqo/src/meta/utils/colors.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_tts/flutter_tts.dart'; + +class LearnArticleActivity extends StatefulWidget { + final LearnArticlePayload payload; + final String activityTypeLabel; + final VoidCallback onContinue; + + const LearnArticleActivity({ + super.key, + required this.payload, + required this.activityTypeLabel, + required this.onContinue, + }); + + @override + State createState() => _LearnArticleActivityState(); +} + +class _LearnArticleActivityState extends State { + static const _speechRate = 0.45; + + final _scrollController = ScrollController(); + final _tts = FlutterTts(); + Timer? _progressTimer; + bool _canContinue = false; + bool _isListening = false; + Duration _elapsed = Duration.zero; + late Duration _totalDuration; + late String _spokenText; + int _highlightStart = -1; + int _highlightEnd = -1; + + @override + void initState() { + super.initState(); + _spokenText = widget.payload.audioText ?? widget.payload.body; + _totalDuration = Duration( + seconds: LearnArticleAudioPlayer.estimateDurationSeconds( + _spokenText, + speechRate: _speechRate, + ), + ); + _scrollController.addListener(_checkScroll); + WidgetsBinding.instance.addPostFrameCallback((_) => _checkScroll()); + _initTts(); + } + + Future _initTts() async { + await _tts.setSpeechRate(_speechRate); + await _tts.setVolume(1); + await _tts.setPitch(1); + _tts.setCompletionHandler(_onPlaybackEnded); + _tts.setCancelHandler(_onPlaybackEnded); + _tts.setProgressHandler((text, start, end, word) { + if (!mounted) return; + setState(() { + _highlightStart = start; + _highlightEnd = end; + }); + }); + } + + void _onPlaybackEnded() { + _progressTimer?.cancel(); + if (!mounted) return; + setState(() { + _isListening = false; + _elapsed = _totalDuration; + _highlightStart = -1; + _highlightEnd = -1; + }); + } + + void _startProgressTimer() { + _progressTimer?.cancel(); + _progressTimer = Timer.periodic(const Duration(seconds: 1), (_) { + if (!mounted || !_isListening) return; + setState(() { + if (_elapsed < _totalDuration) { + _elapsed += const Duration(seconds: 1); + } + }); + }); + } + + void _checkScroll() { + if (!_scrollController.hasClients) return; + final atEnd = _scrollController.position.pixels >= + _scrollController.position.maxScrollExtent - 24; + if (atEnd != _canContinue) setState(() => _canContinue = atEnd); + } + + Future _toggleListen() async { + if (_isListening) { + await _tts.stop(); + _progressTimer?.cancel(); + setState(() { + _isListening = false; + _highlightStart = -1; + _highlightEnd = -1; + }); + return; + } + setState(() { + _isListening = true; + _elapsed = Duration.zero; + _highlightStart = -1; + _highlightEnd = -1; + }); + _startProgressTimer(); + await _tts.speak(_spokenText); + } + + TextStyle _bodyStyle(BuildContext context) { + return TextStyle( + fontSize: 14, + height: 1.55, + color: LearnDesignTokens.headline(context), + ); + } + + Widget _buildArticleBody(BuildContext context) { + final baseStyle = _bodyStyle(context); + if (!_isListening || _highlightStart < 0 || _highlightEnd <= _highlightStart) { + return TranslatedText( + widget.payload.body, + style: baseStyle, + ); + } + + final text = _spokenText; + final safeStart = _highlightStart.clamp(0, text.length); + final safeEnd = _highlightEnd.clamp(safeStart, text.length); + + return Text.rich( + TextSpan( + style: baseStyle, + children: [ + if (safeStart > 0) TextSpan(text: text.substring(0, safeStart)), + TextSpan( + text: text.substring(safeStart, safeEnd), + style: baseStyle.copyWith( + backgroundColor: AppColors.primaryColor.withValues(alpha: 0.28), + color: AppColors.primaryColor, + fontWeight: FontWeight.w600, + ), + ), + if (safeEnd < text.length) TextSpan(text: text.substring(safeEnd)), + ], + ), + ); + } + + @override + void dispose() { + _progressTimer?.cancel(); + _tts.stop(); + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return LearnActivityCardShell( + activityTypeLabel: widget.activityTypeLabel, + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + controller: _scrollController, + padding: const EdgeInsets.fromLTRB(16, 10, 16, 0), + child: _buildArticleBody(context), + ), + ), + LearnArticleAudioPlayer( + isPlaying: _isListening, + elapsed: _elapsed, + total: _totalDuration, + onToggle: _toggleListen, + ), + LearnExperienceBottomBar( + primaryEnabled: _canContinue, + onPrimary: widget.onContinue, + ), + ], + ), + ); + } +} diff --git a/src/mobile/lib/src/app/learn/widgets/experience/learn_article_audio_player.dart b/src/mobile/lib/src/app/learn/widgets/experience/learn_article_audio_player.dart new file mode 100644 index 0000000000..56c826a0b5 --- /dev/null +++ b/src/mobile/lib/src/app/learn/widgets/experience/learn_article_audio_player.dart @@ -0,0 +1,110 @@ +import 'package:airqo/src/app/learn/theme/learn_design_tokens.dart'; +import 'package:airqo/src/meta/utils/colors.dart'; +import 'package:flutter/material.dart'; + +/// AirQo-style listen control for article TTS with theme-aware tray colors. +class LearnArticleAudioPlayer extends StatelessWidget { + final bool isPlaying; + final Duration elapsed; + final Duration total; + final VoidCallback onToggle; + + const LearnArticleAudioPlayer({ + super.key, + required this.isPlaying, + required this.elapsed, + required this.total, + required this.onToggle, + }); + + static int estimateDurationSeconds(String text, {double speechRate = 0.45}) { + final words = + text.trim().split(RegExp(r'\s+')).where((w) => w.isNotEmpty).length; + if (words == 0) return 0; + final wpm = 150 * speechRate; + return ((words / wpm) * 60).ceil().clamp(3, 900); + } + + static String formatDuration(Duration d) { + final m = d.inMinutes.remainder(60).toString().padLeft(2, '0'); + final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); + return '$m:$s'; + } + + @override + Widget build(BuildContext context) { + final trayBg = LearnDesignTokens.nestedSurface(context); + final textColor = LearnDesignTokens.muted(context); + final trackColor = LearnDesignTokens.divider(context); + final isDark = LearnDesignTokens.isDark(context); + final progress = total.inSeconds > 0 + ? (elapsed.inSeconds / total.inSeconds).clamp(0.0, 1.0) + : 0.0; + + return Container( + margin: const EdgeInsets.fromLTRB(16, 0, 16, 12), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: trayBg, + borderRadius: BorderRadius.circular(12), + border: isDark + ? null + : Border.all(color: LearnDesignTokens.divider(context)), + ), + child: Row( + children: [ + Material( + color: AppColors.primaryColor, + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onToggle, + customBorder: const CircleBorder(), + child: SizedBox( + width: 40, + height: 40, + child: Icon( + isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded, + color: Colors.white, + size: 22, + ), + ), + ), + ), + const SizedBox(width: 12), + Text( + formatDuration(elapsed), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: textColor, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + const SizedBox(width: 8), + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: isPlaying || progress > 0 ? progress : null, + minHeight: 4, + backgroundColor: trackColor, + color: AppColors.primaryColor, + ), + ), + ), + const SizedBox(width: 8), + Text( + formatDuration(total), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: textColor, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), + ); + } +} diff --git a/src/mobile/lib/src/app/learn/widgets/experience/learn_course_certificate.dart b/src/mobile/lib/src/app/learn/widgets/experience/learn_course_certificate.dart new file mode 100644 index 0000000000..4c97edf1a9 --- /dev/null +++ b/src/mobile/lib/src/app/learn/widgets/experience/learn_course_certificate.dart @@ -0,0 +1,215 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:airqo/src/app/learn/formatting/learn_display_text.dart'; +import 'package:airqo/src/app/learn/models/learn_course_structure.dart'; +import 'package:airqo/src/app/learn/theme/learn_design_tokens.dart'; +import 'package:airqo/src/app/learn/widgets/learn_completion_sheet.dart'; +import 'package:airqo/src/app/learn/widgets/learn_sheet_button_styles.dart'; +import 'package:airqo/src/app/shared/widgets/translated_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; + +class LearnCourseCertificatePane extends StatefulWidget { + final LearnCourseViewModel course; + final LearnStageInfo stage; + final VoidCallback onDone; + final DateTime completedAt; + + LearnCourseCertificatePane({ + super.key, + required this.course, + required this.stage, + required this.onDone, + DateTime? completedAt, + }) : completedAt = completedAt ?? DateTime.now(); + + @override + State createState() => + _LearnCourseCertificatePaneState(); +} + +class _LearnCourseCertificatePaneState extends State { + static const _appDownloadUrl = 'https://airqo.net/explore-data'; + + final _certificateKey = GlobalKey(); + bool _sharing = false; + + String get _shareMessage => + 'I completed ${learnDisplayTitle(widget.course.plainTitleKey)} course on AirQo Learn! ' + 'Download the AirQo app and complete the course yourself: $_appDownloadUrl'; + + Future _shareCertificate() async { + setState(() => _sharing = true); + try { + final boundary = _certificateKey.currentContext?.findRenderObject() + as RenderRepaintBoundary?; + if (boundary == null) return; + final image = await boundary.toImage(pixelRatio: 3); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + if (byteData == null) return; + final pngBytes = byteData.buffer.asUint8List(); + + final dir = await getTemporaryDirectory(); + final file = File( + '${dir.path}/airqo_course_${widget.course.id}_certificate.png', + ); + await file.writeAsBytes(pngBytes); + + await Share.shareXFiles( + [XFile(file.path)], + text: _shareMessage, + ); + } finally { + if (mounted) setState(() => _sharing = false); + } + } + + @override + Widget build(BuildContext context) { + return LearnCompletionSheet.body( + context: context, + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: LearnDesignTokens.successBg(context), + shape: BoxShape.circle, + ), + child: const Icon( + LearnDesignTokens.completedCheckIcon, + size: 28, + color: LearnDesignTokens.success, + ), + ), + const SizedBox(height: 14), + TranslatedText( + 'Course complete!', + style: LearnDesignTokens.completionTitle(context), + ), + const SizedBox(height: 4), + TranslatedText( + learnDisplayTitle(widget.course.plainTitleKey), + textAlign: TextAlign.center, + style: LearnDesignTokens.completionSubtitle(context), + ), + const SizedBox(height: 16), + RepaintBoundary( + key: _certificateKey, + child: _CertificateCard( + courseTitle: widget.course.plainTitleKey, + stageName: widget.stage.name, + ), + ), + const SizedBox(height: 12), + Text( + learnCourseCompletionTime(widget.completedAt), + textAlign: TextAlign.center, + style: LearnDesignTokens.completionCaption(context), + ), + const SizedBox(height: 4), + TranslatedText( + '(${learnCourseCompletionRarityLabel()})', + textAlign: TextAlign.center, + style: LearnDesignTokens.completionCaption(context), + ), + ], + ), + actions: [ + ElevatedButton( + onPressed: _sharing ? null : _shareCertificate, + style: learnExposurePrimaryButtonStyle(enabled: !_sharing), + child: TranslatedText( + _sharing ? 'Preparing...' : 'Share certificate', + ), + ), + const SizedBox(height: 8), + OutlinedButton( + onPressed: widget.onDone, + style: learnExposureSecondaryButtonStyle(context), + child: const TranslatedText('Done'), + ), + ], + ); + } +} + +class _CertificateCard extends StatelessWidget { + final String courseTitle; + final String stageName; + + const _CertificateCard({ + required this.courseTitle, + required this.stageName, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: const Color(0xFFFEFCF3), + border: Border.all(color: const Color(0xFFC8B87A), width: 2), + borderRadius: BorderRadius.circular(6), + ), + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFDDD0A0)), + borderRadius: BorderRadius.circular(4), + ), + child: Column( + children: [ + const Text( + 'AIRQO', + style: TextStyle( + fontSize: 12, + letterSpacing: 2, + fontWeight: FontWeight.w700, + color: Color(0xFF8A7340), + ), + ), + Container( + margin: const EdgeInsets.symmetric(vertical: 8), + height: 1, + width: 32, + color: const Color(0xFFC8B87A), + ), + const Text( + 'CERTIFICATE OF COMPLETION', + style: TextStyle( + fontSize: 9, + letterSpacing: 0.5, + color: Color(0xFFA08C50), + ), + ), + const SizedBox(height: 16), + TranslatedText( + courseTitle, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: Color(0xFF2E2F33), + ), + ), + const SizedBox(height: 10), + TranslatedText( + stageName, + style: const TextStyle( + fontSize: 12, + color: Color(0xFF7A7F87), + ), + ), + ], + ), + ), + ); + } +} diff --git a/src/mobile/lib/src/app/learn/widgets/experience/learn_experience_shell.dart b/src/mobile/lib/src/app/learn/widgets/experience/learn_experience_shell.dart new file mode 100644 index 0000000000..d96033d57f --- /dev/null +++ b/src/mobile/lib/src/app/learn/widgets/experience/learn_experience_shell.dart @@ -0,0 +1,154 @@ +import 'package:airqo/src/app/learn/models/learn_course_structure.dart'; +import 'package:airqo/src/app/learn/theme/learn_design_tokens.dart'; +import 'package:airqo/src/app/learn/widgets/learn_lesson_thumbnail.dart'; +import 'package:airqo/src/app/learn/widgets/learn_sheet_button_styles.dart'; +import 'package:airqo/src/app/shared/widgets/translated_text.dart'; +import 'package:flutter/material.dart'; + +class LearnExperienceShell extends StatelessWidget { + final LearnLessonSlot slot; + final int unitIndex; + final int lessonIndex; + final String lessonTitle; + final String activityName; + final int currentStep; + final int totalSteps; + final String activityProgressLabel; + final Widget body; + final Widget bottomBar; + final VoidCallback? onClose; + final bool showDragHandle; + + const LearnExperienceShell({ + super.key, + required this.slot, + required this.unitIndex, + required this.lessonIndex, + required this.lessonTitle, + required this.activityName, + required this.currentStep, + required this.totalSteps, + required this.activityProgressLabel, + required this.body, + required this.bottomBar, + this.onClose, + this.showDragHandle = true, + }); + + @override + Widget build(BuildContext context) { + final progress = totalSteps > 0 ? currentStep / totalSteps : 0.0; + const horizontalPadding = LearnLessonExperienceBanner.horizontalInset; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (showDragHandle) + LearnDesignTokens.dragHandle(context), + Padding( + padding: EdgeInsets.fromLTRB( + horizontalPadding, + 8, + horizontalPadding, + 8, + ), + child: LearnLessonExperienceBanner( + slot: slot, + unitIndex: unitIndex, + lessonIndex: lessonIndex, + lessonTitle: lessonTitle, + activityName: activityName, + progress: progress, + activityProgressLabel: activityProgressLabel, + ), + ), + Expanded(child: body), + bottomBar, + ], + ); + } +} + +class LearnExperienceBottomBar extends StatelessWidget { + final String primaryLabel; + final VoidCallback? onPrimary; + final bool primaryEnabled; + + const LearnExperienceBottomBar({ + super.key, + this.primaryLabel = 'Continue', + this.onPrimary, + this.primaryEnabled = true, + }); + + @override + Widget build(BuildContext context) { + return SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.fromLTRB( + LearnLessonExperienceBanner.horizontalInset, + 8, + LearnLessonExperienceBanner.horizontalInset, + 16, + ), + child: ElevatedButton( + onPressed: primaryEnabled ? onPrimary : null, + style: learnExposurePrimaryButtonStyle(enabled: primaryEnabled), + child: TranslatedText(primaryLabel), + ), + ), + ); + } +} + +class LearnActivityCardShell extends StatelessWidget { + final String activityTypeLabel; + final Widget child; + + const LearnActivityCardShell({ + super.key, + required this.activityTypeLabel, + required this.child, + }); + + @override + Widget build(BuildContext context) { + const horizontalInset = LearnLessonExperienceBanner.horizontalInset; + const topRadius = Radius.circular(LearnDesignTokens.activityCardRadius); + final divider = LearnDesignTokens.divider(context); + + return Container( + margin: const EdgeInsets.fromLTRB(horizontalInset, 0, horizontalInset, 0), + decoration: BoxDecoration( + color: LearnDesignTokens.cardBg(context), + borderRadius: const BorderRadius.only( + topLeft: topRadius, + topRight: topRadius, + ), + border: Border( + top: BorderSide(color: divider), + left: BorderSide(color: divider), + right: BorderSide(color: divider), + ), + ), + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 0), + child: Text( + activityTypeLabel, + style: LearnDesignTokens.slbl(context).copyWith( + letterSpacing: 0.8, + fontWeight: FontWeight.w700, + ), + ), + ), + Expanded(child: child), + ], + ), + ); + } +} diff --git a/src/mobile/lib/src/app/learn/widgets/experience/learn_image_activity.dart b/src/mobile/lib/src/app/learn/widgets/experience/learn_image_activity.dart new file mode 100644 index 0000000000..17b6eec813 --- /dev/null +++ b/src/mobile/lib/src/app/learn/widgets/experience/learn_image_activity.dart @@ -0,0 +1,80 @@ +import 'package:airqo/src/app/learn/models/learn_lesson_activity.dart'; +import 'package:airqo/src/app/learn/theme/learn_design_tokens.dart'; +import 'package:airqo/src/app/learn/widgets/experience/learn_experience_shell.dart'; +import 'package:airqo/src/app/learn/widgets/learn_lesson_image.dart'; +import 'package:airqo/src/app/shared/widgets/translated_text.dart'; +import 'package:flutter/material.dart'; + +class LearnImageActivity extends StatelessWidget { + final LearnImagePayload payload; + final String activityTypeLabel; + final VoidCallback onContinue; + + const LearnImageActivity({ + super.key, + required this.payload, + required this.activityTypeLabel, + required this.onContinue, + }); + + @override + Widget build(BuildContext context) { + return LearnActivityCardShell( + activityTypeLabel: activityTypeLabel, + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (payload.imageUrl.isNotEmpty) + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: LearnLessonImage( + url: payload.imageUrl, + height: 180, + ), + ) + else + Container( + height: 180, + width: double.infinity, + decoration: BoxDecoration( + color: const Color(0xFFC8D8F8), + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.center, + child: const Icon(Icons.image_outlined, size: 48), + ), + const SizedBox(height: 12), + TranslatedText( + payload.caption, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: LearnDesignTokens.headline(context), + ), + ), + if (payload.subtitle != null) ...[ + const SizedBox(height: 8), + TranslatedText( + payload.subtitle!, + style: TextStyle( + fontSize: 14, + height: 1.5, + color: LearnDesignTokens.muted(context), + ), + ), + ], + ], + ), + ), + ), + LearnExperienceBottomBar(onPrimary: onContinue), + ], + ), + ); + } +} diff --git a/src/mobile/lib/src/app/learn/widgets/experience/learn_lesson_experience.dart b/src/mobile/lib/src/app/learn/widgets/experience/learn_lesson_experience.dart new file mode 100644 index 0000000000..1661e441fb --- /dev/null +++ b/src/mobile/lib/src/app/learn/widgets/experience/learn_lesson_experience.dart @@ -0,0 +1,229 @@ +import 'package:airqo/src/app/learn/formatting/learn_display_text.dart'; +import 'package:airqo/src/app/learn/models/learn_course_structure.dart'; +import 'package:airqo/src/app/learn/models/learn_lesson_activity.dart'; +import 'package:airqo/src/app/learn/models/learn_lesson_continuation.dart'; +import 'package:airqo/src/app/learn/models/lesson_response_model.dart'; +import 'package:airqo/src/app/learn/services/learn_lesson_experience_service.dart'; +import 'package:airqo/src/app/learn/services/learn_progress_service.dart'; +import 'package:airqo/src/app/learn/services/learn_quiz_scoring_service.dart'; +import 'package:airqo/src/app/learn/widgets/experience/learn_article_activity.dart'; +import 'package:airqo/src/app/learn/widgets/experience/learn_experience_shell.dart'; +import 'package:airqo/src/app/learn/widgets/experience/learn_image_activity.dart'; +import 'package:airqo/src/app/learn/widgets/experience/learn_quiz_activity.dart'; +import 'package:airqo/src/app/learn/widgets/experience/learn_video_activity.dart'; +import 'package:airqo/src/app/learn/widgets/learn_bottom_sheets.dart'; +import 'package:flutter/material.dart'; + +class LearnLessonExperience extends StatefulWidget { + final LearnLessonSlot slot; + final KyaLesson? apiLesson; + final LearnCourseViewModel course; + final int unitIndex; + final int lessonIndex; + final String unitPlainTitle; + final int lessonNumberInUnit; + final int lessonsInUnit; + final List? allCourses; + final LearnLessonContinuation? continuation; + final VoidCallback onClose; + final BuildContext completionContext; + + const LearnLessonExperience({ + super.key, + required this.slot, + required this.course, + required this.unitIndex, + required this.lessonIndex, + required this.unitPlainTitle, + required this.lessonNumberInUnit, + required this.lessonsInUnit, + required this.onClose, + required this.completionContext, + this.apiLesson, + this.allCourses, + this.continuation, + }); + + @override + State createState() => _LearnLessonExperienceState(); +} + +class _LearnLessonExperienceState extends State { + late final List _script; + late int _activityIndex; + final _progress = LearnProgressService.instance; + final List _gradedResults = []; + String? _freeTextResponse; + LearnLessonResult? _result; + bool _presentingCompletion = false; + + @override + void initState() { + super.initState(); + final lessonTitle = widget.apiLesson?.title ?? widget.slot.plainTitleKey; + _script = LearnLessonExperienceService.buildDemoScript( + lessonTitle: lessonTitle, + unitTitle: widget.unitPlainTitle, + slot: widget.slot, + apiLesson: widget.apiLesson, + ); + final saved = _progress.furthestStep(widget.slot.progressKey); + _activityIndex = saved.clamp(0, _script.length - 1); + if (_progress.isLessonComplete(widget.slot.progressKey)) { + _result = LearnLessonResult( + stars: _progress.lessonStars(widget.slot.progressKey).clamp(1, 3), + pointsEarned: _progress.lessonPoints(widget.slot.progressKey), + quizScoreRatio: _progress.lessonQuizScore(widget.slot.progressKey), + freeTextResponse: _progress.lessonFreeText(widget.slot.progressKey), + ); + _presentingCompletion = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _presentCompletionSheet(); + }); + } + } + + bool get _isCourseFinal => LearnLessonExperienceService.isCourseFinalLesson( + widget.course, + widget.unitIndex, + widget.lessonIndex, + ); + + LearnLessonActivity get _current => _script[_activityIndex]; + + Future _advanceActivity() async { + await _progress.recordLessonFurthestStep( + widget.slot.progressKey, + _activityIndex + 1, + ); + if (_activityIndex >= _script.length - 1) { + await _completeLesson(); + } else { + setState(() => _activityIndex++); + } + } + + Future _completeLesson() async { + final result = LearnQuizScoringService.computeLessonResult( + gradedQuizResults: _gradedResults, + freeTextResponse: _freeTextResponse, + ); + await _progress.markLessonComplete(widget.slot.progressKey); + await _progress.saveLessonResult( + lessonKey: widget.slot.progressKey, + stars: result.stars, + points: result.pointsEarned, + quizScoreRatio: result.quizScoreRatio, + freeText: result.freeTextResponse, + ); + _result = result; + _presentCompletionSheet(); + } + + void _presentCompletionSheet() { + if (!mounted || _result == null) return; + + final callerContext = widget.completionContext; + final result = _result!; + final isCourseFinal = _isCourseFinal; + widget.onClose(); + + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!callerContext.mounted) return; + if (isCourseFinal) { + final stage = LearnCatalog.currentStage( + widget.allCourses ?? [widget.course], + _progress, + ); + await LearnBottomSheets.showCourseComplete( + callerContext, + course: widget.course, + stage: stage, + ); + return; + } + + await LearnBottomSheets.showLessonComplete( + callerContext, + result: result, + lessonNumberInUnit: widget.lessonNumberInUnit, + lessonsInUnit: widget.lessonsInUnit, + unitPlainTitle: widget.unitPlainTitle, + continuation: widget.continuation, + allCourses: widget.allCourses, + ); + }); + } + + void _recordQuizGrade(LearnQuizGrade grade) { + if (_current.type != LearnActivityType.quiz) return; + if (_current.quiz?.format == LearnQuizFormat.freeText) return; + _gradedResults.add(grade.isCorrect); + } + + Widget _buildActivityBody() { + final activity = _current; + final typeLabel = learnActivityTypeHeader( + _activityIndex, + LearnLessonExperienceService.activityTypeKey(activity), + ); + switch (activity.type) { + case LearnActivityType.article: + return LearnArticleActivity( + payload: activity.article!, + activityTypeLabel: typeLabel, + onContinue: _advanceActivity, + ); + case LearnActivityType.video: + return LearnVideoActivity( + payload: activity.video!, + activityTypeLabel: typeLabel, + onContinue: _advanceActivity, + ); + case LearnActivityType.image: + return LearnImageActivity( + payload: activity.image!, + activityTypeLabel: typeLabel, + onContinue: _advanceActivity, + ); + case LearnActivityType.quiz: + return LearnQuizActivity( + quiz: activity.quiz!, + activityTypeLabel: typeLabel, + onGraded: _recordQuizGrade, + onFreeText: (text) => _freeTextResponse = text, + onContinue: _advanceActivity, + ); + } + } + + @override + Widget build(BuildContext context) { + if (_presentingCompletion) { + return const SizedBox.shrink(); + } + + final lessonTitle = + learnDisplayTitle(widget.apiLesson?.title ?? widget.slot.plainTitleKey); + + return SizedBox.expand( + child: LearnExperienceShell( + slot: widget.slot, + unitIndex: widget.unitIndex, + lessonIndex: widget.lessonIndex, + lessonTitle: lessonTitle, + activityName: _current.title, + currentStep: _activityIndex + 1, + totalSteps: _script.length, + activityProgressLabel: learnActivityProgressLabel( + _activityIndex, + _script.length, + ), + onClose: widget.onClose, + body: _buildActivityBody(), + bottomBar: const SizedBox.shrink(), + ), + ); + } +} diff --git a/src/mobile/lib/src/app/learn/widgets/experience/learn_lesson_finish_pane.dart b/src/mobile/lib/src/app/learn/widgets/experience/learn_lesson_finish_pane.dart new file mode 100644 index 0000000000..6e741f403f --- /dev/null +++ b/src/mobile/lib/src/app/learn/widgets/experience/learn_lesson_finish_pane.dart @@ -0,0 +1,106 @@ +import 'package:airqo/src/app/learn/formatting/learn_display_text.dart'; +import 'package:airqo/src/app/learn/models/learn_lesson_activity.dart'; +import 'package:airqo/src/app/learn/theme/learn_design_tokens.dart'; +import 'package:airqo/src/app/learn/widgets/learn_completion_sheet.dart'; +import 'package:airqo/src/app/learn/widgets/learn_sheet_button_styles.dart'; +import 'package:airqo/src/app/shared/widgets/translated_text.dart'; +import 'package:flutter/material.dart'; + +class LearnLessonFinishPane extends StatelessWidget { + final LearnLessonResult result; + final int lessonNumberInUnit; + final int lessonsInUnit; + final String? unitPlainTitle; + final VoidCallback onDone; + final VoidCallback? onNext; + final bool isNextUnit; + + const LearnLessonFinishPane({ + super.key, + required this.result, + required this.lessonNumberInUnit, + required this.lessonsInUnit, + this.unitPlainTitle, + required this.onDone, + this.onNext, + this.isNextUnit = false, + }); + + String get _primaryLabel { + if (onNext == null) return 'Done'; + return isNextUnit ? 'Next unit' : 'Next lesson'; + } + + @override + Widget build(BuildContext context) { + final unitTitle = unitPlainTitle; + final hasNext = onNext != null; + final captionStyle = LearnDesignTokens.completionCaption(context); + + return LearnCompletionSheet.compactBody( + context: context, + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: LearnDesignTokens.successBg(context), + shape: BoxShape.circle, + ), + child: const Icon( + LearnDesignTokens.completedCheckIcon, + size: 28, + color: LearnDesignTokens.success, + ), + ), + const SizedBox(height: 14), + TranslatedText( + 'Lesson completed', + style: LearnDesignTokens.completionTitle(context), + ), + const SizedBox(height: 4), + if (unitTitle != null && unitTitle.isNotEmpty) + TranslatedText( + learnLessonCompleteSubtitle( + lessonNumberInUnit - 1, + unitTitle, + ), + textAlign: TextAlign.center, + style: LearnDesignTokens.completionSubtitle(context), + ), + const SizedBox(height: 8), + Text( + '${result.pointsEarned} points earned', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: LearnDesignTokens.primary(context), + ), + ), + const SizedBox(height: 2), + Text( + '$lessonNumberInUnit of $lessonsInUnit lessons in this unit', + textAlign: TextAlign.center, + style: captionStyle, + ), + ], + ), + actions: [ + if (hasNext) + ElevatedButton( + onPressed: onNext, + style: learnExposurePrimaryButtonStyle(), + child: TranslatedText(_primaryLabel), + ) + else + OutlinedButton( + onPressed: onDone, + style: learnExposureSecondaryButtonStyle(context), + child: TranslatedText(_primaryLabel), + ), + ], + ); + } +} diff --git a/src/mobile/lib/src/app/learn/widgets/experience/learn_level_unlock_pane.dart b/src/mobile/lib/src/app/learn/widgets/experience/learn_level_unlock_pane.dart new file mode 100644 index 0000000000..4e7c4fa0e0 --- /dev/null +++ b/src/mobile/lib/src/app/learn/widgets/experience/learn_level_unlock_pane.dart @@ -0,0 +1,180 @@ +import 'package:airqo/src/app/learn/models/learn_course_structure.dart'; +import 'package:airqo/src/app/learn/theme/learn_design_tokens.dart'; +import 'package:airqo/src/app/learn/widgets/learn_lesson_thumbnail.dart'; +import 'package:airqo/src/app/learn/widgets/learn_sheet_button_styles.dart'; +import 'package:airqo/src/app/shared/widgets/translated_text.dart'; +import 'package:flutter/material.dart'; + +class LearnLevelUnlockPane extends StatelessWidget { + final LearnStageInfo previousStage; + final LearnStageInfo newStage; + final VoidCallback onContinue; + + const LearnLevelUnlockPane({ + super.key, + required this.previousStage, + required this.newStage, + required this.onContinue, + }); + + @override + Widget build(BuildContext context) { + final iconBg = LearnDesignTokens.iconBg(context); + + return SingleChildScrollView( + padding: EdgeInsets.fromLTRB( + LearnLessonExperienceBanner.horizontalInset, + 28, + LearnLessonExperienceBanner.horizontalInset, + 16 + MediaQuery.paddingOf(context).bottom, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: iconBg, + shape: BoxShape.circle, + ), + child: Icon( + Icons.emoji_events_outlined, + size: 28, + color: LearnDesignTokens.primary(context), + ), + ), + const SizedBox(height: 14), + TranslatedText( + 'Level unlocked', + style: LearnDesignTokens.lessonTitle(context), + ), + const SizedBox(height: 4), + TranslatedText( + 'You reached a new stage in your air quality journey.', + textAlign: TextAlign.center, + style: LearnDesignTokens.activitySubtitle(context), + ), + const SizedBox(height: 16), + _LevelTransitionRow( + previousStage: previousStage, + newStage: newStage, + ), + const SizedBox(height: 20), + ElevatedButton( + onPressed: onContinue, + style: learnExposurePrimaryButtonStyle(), + child: const TranslatedText('Continue'), + ), + ], + ), + ); + } +} + +class _LevelTransitionRow extends StatelessWidget { + final LearnStageInfo previousStage; + final LearnStageInfo newStage; + + const _LevelTransitionRow({ + required this.previousStage, + required this.newStage, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: LearnDesignTokens.cardBg(context), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: LearnDesignTokens.divider(context)), + ), + child: Row( + children: [ + Expanded( + child: _StageChip( + stage: previousStage, + highlighted: false, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Icon( + Icons.arrow_forward_rounded, + size: 18, + color: LearnDesignTokens.muted(context), + ), + ), + Expanded( + child: _StageChip( + stage: newStage, + highlighted: true, + ), + ), + ], + ), + ); + } +} + +class _StageChip extends StatelessWidget { + final LearnStageInfo stage; + final bool highlighted; + + const _StageChip({ + required this.stage, + required this.highlighted, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: highlighted + ? const Color(0xffE8F0FF) + : LearnDesignTokens.successBg(context), + border: Border.all( + color: highlighted + ? LearnDesignTokens.primary(context) + : LearnDesignTokens.success, + width: highlighted ? 2 : 1.5, + ), + ), + child: Center( + child: highlighted + ? Text( + '${stage.index + 1}', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: LearnDesignTokens.primary(context), + ), + ) + : LearnDesignTokens.completedCheckIconWidget(size: 16), + ), + ), + const SizedBox(height: 6), + Text( + stage.name, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + fontWeight: highlighted ? FontWeight.w700 : FontWeight.w500, + color: highlighted + ? LearnDesignTokens.primary(context) + : LearnDesignTokens.muted(context), + ), + ), + ], + ); + } +} diff --git a/src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_activity.dart b/src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_activity.dart new file mode 100644 index 0000000000..38859b9d39 --- /dev/null +++ b/src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_activity.dart @@ -0,0 +1,58 @@ +import 'package:airqo/src/app/learn/models/learn_lesson_activity.dart'; +import 'package:airqo/src/app/learn/widgets/experience/learn_quiz_free_text.dart'; +import 'package:airqo/src/app/learn/widgets/experience/learn_quiz_multi_choice.dart'; +import 'package:airqo/src/app/learn/widgets/experience/learn_quiz_ranking.dart'; +import 'package:airqo/src/app/learn/widgets/experience/learn_quiz_single_choice.dart'; +import 'package:flutter/material.dart'; + +class LearnQuizActivity extends StatelessWidget { + final LearnQuizPayload quiz; + final String activityTypeLabel; + final ValueChanged onGraded; + final ValueChanged? onFreeText; + final VoidCallback onContinue; + + const LearnQuizActivity({ + super.key, + required this.quiz, + required this.activityTypeLabel, + required this.onGraded, + required this.onContinue, + this.onFreeText, + }); + + @override + Widget build(BuildContext context) { + switch (quiz.format) { + case LearnQuizFormat.singleChoice: + return LearnQuizSingleChoiceActivity( + quiz: quiz, + activityTypeLabel: activityTypeLabel, + onGraded: onGraded, + onContinue: onContinue, + ); + case LearnQuizFormat.multiChoice: + return LearnQuizMultiChoiceActivity( + quiz: quiz, + activityTypeLabel: activityTypeLabel, + onGraded: onGraded, + onContinue: onContinue, + ); + case LearnQuizFormat.ranking: + return LearnQuizRankingActivity( + quiz: quiz, + activityTypeLabel: activityTypeLabel, + onGraded: onGraded, + onContinue: onContinue, + ); + case LearnQuizFormat.freeText: + return LearnQuizFreeTextActivity( + quiz: quiz, + activityTypeLabel: activityTypeLabel, + onGraded: onGraded, + onResponse: onFreeText ?? (_) {}, + onContinue: onContinue, + ); + } + } +} diff --git a/src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_free_text.dart b/src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_free_text.dart new file mode 100644 index 0000000000..74c2833ad9 --- /dev/null +++ b/src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_free_text.dart @@ -0,0 +1,110 @@ +import 'package:airqo/src/app/exposure/widgets/exposure_place_name_text_field.dart'; +import 'package:airqo/src/app/learn/models/learn_lesson_activity.dart'; +import 'package:airqo/src/app/learn/services/learn_quiz_scoring_service.dart'; +import 'package:airqo/src/app/learn/theme/learn_design_tokens.dart'; +import 'package:airqo/src/app/learn/widgets/experience/learn_experience_shell.dart'; +import 'package:airqo/src/app/learn/widgets/experience/learn_quiz_option.dart'; +import 'package:airqo/src/app/shared/widgets/translated_text.dart'; +import 'package:flutter/material.dart'; + +class LearnQuizFreeTextActivity extends StatefulWidget { + final LearnQuizPayload quiz; + final String activityTypeLabel; + final ValueChanged onGraded; + final ValueChanged onResponse; + final VoidCallback onContinue; + + const LearnQuizFreeTextActivity({ + super.key, + required this.quiz, + required this.activityTypeLabel, + required this.onGraded, + required this.onResponse, + required this.onContinue, + }); + + @override + State createState() => + _LearnQuizFreeTextActivityState(); +} + +class _LearnQuizFreeTextActivityState extends State { + final _controller = TextEditingController(); + bool _submitted = false; + LearnQuizGrade? _grade; + + @override + void initState() { + super.initState(); + _controller.addListener(() => setState(() {})); + } + + void _submit() { + final grade = LearnQuizScoringService.gradeFreeText(_controller.text); + if (_controller.text.trim().isEmpty) return; + widget.onResponse(_controller.text.trim()); + setState(() { + _submitted = true; + _grade = grade; + }); + widget.onGraded(grade); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + return LearnActivityCardShell( + activityTypeLabel: widget.activityTypeLabel, + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TranslatedText( + widget.quiz.question, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: LearnDesignTokens.headline(context), + ), + ), + const SizedBox(height: 12), + ExposurePlaceNameTextField( + controller: _controller, + hintText: 'Type here...', + isDark: isDark, + enabled: !_submitted, + maxLines: 4, + minLines: 4, + textCapitalization: TextCapitalization.sentences, + ), + if (_grade != null) + LearnQuizFeedbackBanner( + message: _grade!.feedback, + isCorrect: true, + ), + ], + ), + ), + ), + LearnExperienceBottomBar( + primaryLabel: _submitted ? 'Complete lesson' : 'Submit', + primaryEnabled: + _submitted || _controller.text.trim().isNotEmpty, + onPrimary: _submitted ? widget.onContinue : _submit, + ), + ], + ), + ); + } +} diff --git a/src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_multi_choice.dart b/src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_multi_choice.dart new file mode 100644 index 0000000000..fa72906b1e --- /dev/null +++ b/src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_multi_choice.dart @@ -0,0 +1,112 @@ +import 'package:airqo/src/app/learn/models/learn_lesson_activity.dart'; +import 'package:airqo/src/app/learn/services/learn_quiz_scoring_service.dart'; +import 'package:airqo/src/app/learn/theme/learn_design_tokens.dart'; +import 'package:airqo/src/app/learn/widgets/experience/learn_experience_shell.dart'; +import 'package:airqo/src/app/learn/widgets/experience/learn_quiz_option.dart'; +import 'package:airqo/src/app/shared/widgets/translated_text.dart'; +import 'package:flutter/material.dart'; + +class LearnQuizMultiChoiceActivity extends StatefulWidget { + final LearnQuizPayload quiz; + final String activityTypeLabel; + final ValueChanged onGraded; + final VoidCallback onContinue; + + const LearnQuizMultiChoiceActivity({ + super.key, + required this.quiz, + required this.activityTypeLabel, + required this.onGraded, + required this.onContinue, + }); + + @override + State createState() => + _LearnQuizMultiChoiceActivityState(); +} + +class _LearnQuizMultiChoiceActivityState + extends State { + final Set _selected = {}; + bool _submitted = false; + LearnQuizGrade? _grade; + + void _toggle(int index) { + setState(() { + if (_selected.contains(index)) { + _selected.remove(index); + } else { + _selected.add(index); + } + }); + } + + void _submit() { + final grade = LearnQuizScoringService.gradeMultiChoice( + widget.quiz, + _selected, + ); + setState(() { + _submitted = true; + _grade = grade; + }); + widget.onGraded(grade); + } + + @override + Widget build(BuildContext context) { + final correct = widget.quiz.correctIndices ?? {}; + + return LearnActivityCardShell( + activityTypeLabel: widget.activityTypeLabel, + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TranslatedText( + widget.quiz.question, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: LearnDesignTokens.headline(context), + ), + ), + const SizedBox(height: 4), + TranslatedText( + 'Select all that apply', + style: LearnDesignTokens.activitySubtitle(context), + ), + const SizedBox(height: 12), + ...List.generate(widget.quiz.options.length, (i) { + return LearnQuizOptionTile( + label: widget.quiz.options[i], + selected: _selected.contains(i), + revealed: _submitted, + isCorrectOption: correct.contains(i), + showCheckbox: true, + onTap: () => _toggle(i), + ); + }), + if (_grade != null) + LearnQuizFeedbackBanner( + message: _grade!.feedback, + isCorrect: _grade!.isCorrect, + ), + ], + ), + ), + ), + LearnExperienceBottomBar( + primaryLabel: _submitted ? 'Continue' : 'Check answers', + primaryEnabled: _submitted || _selected.isNotEmpty, + onPrimary: _submitted ? widget.onContinue : _submit, + ), + ], + ), + ); + } +} diff --git a/src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_option.dart b/src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_option.dart new file mode 100644 index 0000000000..abe9ae1452 --- /dev/null +++ b/src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_option.dart @@ -0,0 +1,190 @@ +import 'package:airqo/src/app/learn/theme/learn_design_tokens.dart'; +import 'package:airqo/src/app/shared/widgets/translated_text.dart'; +import 'package:airqo/src/meta/utils/colors.dart'; +import 'package:flutter/material.dart'; + +class LearnQuizOptionTile extends StatelessWidget { + final String label; + final bool selected; + final bool revealed; + final bool isCorrectOption; + final bool showCheckbox; + final VoidCallback? onTap; + + const LearnQuizOptionTile({ + super.key, + required this.label, + required this.selected, + this.revealed = false, + this.isCorrectOption = false, + this.showCheckbox = false, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + Color borderColor = theme.dividerColor; + Color bg = Colors.transparent; + + if (revealed) { + if (isCorrectOption) { + borderColor = LearnDesignTokens.success; + bg = LearnDesignTokens.successBg(context); + } else if (selected) { + borderColor = LearnDesignTokens.error; + bg = LearnDesignTokens.errorBg(context); + } + } else if (selected) { + borderColor = AppColors.primaryColor; + bg = AppColors.primaryColor.withValues(alpha: 0.05); + } + + return Container( + margin: const EdgeInsets.only(bottom: 10), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: revealed ? null : onTap, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + border: Border.all( + color: borderColor, + width: selected || (revealed && isCorrectOption) ? 2 : 1, + ), + borderRadius: BorderRadius.circular(8), + color: bg, + ), + child: Row( + children: [ + _LeadingIndicator( + selected: selected, + revealed: revealed, + isCorrectOption: isCorrectOption, + showCheckbox: showCheckbox, + ), + const SizedBox(width: 12), + Expanded( + child: TranslatedText( + label, + style: TextStyle( + fontSize: 14, + fontWeight: + selected ? FontWeight.w600 : FontWeight.normal, + color: LearnDesignTokens.headline(context), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _LeadingIndicator extends StatelessWidget { + final bool selected; + final bool revealed; + final bool isCorrectOption; + final bool showCheckbox; + + const _LeadingIndicator({ + required this.selected, + required this.revealed, + required this.isCorrectOption, + required this.showCheckbox, + }); + + @override + Widget build(BuildContext context) { + if (showCheckbox) { + return Container( + width: 20, + height: 20, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + 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, + ), + child: (selected || (revealed && isCorrectOption)) + ? Icon( + Icons.check, + size: 12, + color: isCorrectOption || selected ? Colors.white : Colors.white, + ) + : null, + ); + } + + return Container( + width: 20, + height: 20, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: selected || (revealed && isCorrectOption) + ? LearnDesignTokens.success + : Theme.of(context).dividerColor, + width: 2, + ), + color: selected || (revealed && isCorrectOption) + ? LearnDesignTokens.success + : Colors.transparent, + ), + child: (selected || (revealed && isCorrectOption)) + ? const Icon(Icons.check, size: 12, color: Colors.white) + : null, + ); + } +} + +class LearnQuizFeedbackBanner extends StatelessWidget { + final String message; + final bool isCorrect; + + const LearnQuizFeedbackBanner({ + super.key, + required this.message, + required this.isCorrect, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + margin: const EdgeInsets.only(top: 8, bottom: 4), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isCorrect + ? LearnDesignTokens.successBg(context) + : LearnDesignTokens.errorBg(context), + borderRadius: BorderRadius.circular(8), + ), + child: TranslatedText( + message, + style: TextStyle( + fontSize: 13, + color: isCorrect + ? LearnDesignTokens.successText(context) + : LearnDesignTokens.errorText(context), + height: 1.4, + ), + ), + ); + } +} diff --git a/src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_ranking.dart b/src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_ranking.dart new file mode 100644 index 0000000000..925a717c8b --- /dev/null +++ b/src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_ranking.dart @@ -0,0 +1,176 @@ +import 'package:airqo/src/app/learn/models/learn_lesson_activity.dart'; +import 'package:airqo/src/app/learn/services/learn_quiz_scoring_service.dart'; +import 'package:airqo/src/app/learn/theme/learn_design_tokens.dart'; +import 'package:airqo/src/app/learn/widgets/experience/learn_experience_shell.dart'; +import 'package:airqo/src/app/learn/widgets/experience/learn_quiz_option.dart'; +import 'package:airqo/src/app/shared/widgets/translated_text.dart'; +import 'package:flutter/material.dart'; + +class LearnQuizRankingActivity extends StatefulWidget { + final LearnQuizPayload quiz; + final String activityTypeLabel; + final ValueChanged onGraded; + final VoidCallback onContinue; + + const LearnQuizRankingActivity({ + super.key, + required this.quiz, + required this.activityTypeLabel, + required this.onGraded, + required this.onContinue, + }); + + @override + State createState() => + _LearnQuizRankingActivityState(); +} + +class _LearnQuizRankingActivityState extends State { + late List _order; + bool _submitted = false; + LearnQuizGrade? _grade; + + @override + void initState() { + super.initState(); + _order = List.generate(widget.quiz.options.length, (i) => i); + } + + void _submit() { + final grade = LearnQuizScoringService.gradeRanking(widget.quiz, _order); + setState(() { + _submitted = true; + _grade = grade; + }); + widget.onGraded(grade); + } + + Widget _rankRow(int listIndex, int displayIndex, int optionIndex) { + return ReorderableDelayedDragStartListener( + key: ValueKey(optionIndex), + index: listIndex, + child: Container( + margin: const EdgeInsets.only(bottom: 8), + constraints: const BoxConstraints(minHeight: 56), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + decoration: BoxDecoration( + border: Border.all(color: LearnDesignTokens.divider(context)), + borderRadius: BorderRadius.circular(8), + color: LearnDesignTokens.cardBg(context), + ), + child: Row( + children: [ + Icon(Icons.drag_handle, color: LearnDesignTokens.muted(context)), + const SizedBox(width: 8), + Container( + width: 22, + height: 22, + alignment: Alignment.center, + decoration: BoxDecoration( + color: LearnDesignTokens.divider(context), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '${displayIndex + 1}', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: LearnDesignTokens.headline(context), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: TranslatedText( + widget.quiz.options[optionIndex], + style: TextStyle( + fontSize: 14, + color: LearnDesignTokens.headline(context), + ), + ), + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return LearnActivityCardShell( + activityTypeLabel: widget.activityTypeLabel, + child: Column( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TranslatedText( + widget.quiz.question, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: LearnDesignTokens.headline(context), + ), + ), + const SizedBox(height: 12), + Expanded( + child: Theme( + data: Theme.of(context).copyWith( + canvasColor: Colors.transparent, + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + ), + child: ReorderableListView.builder( + buildDefaultDragHandles: false, + itemCount: _order.length, + proxyDecorator: (child, index, animation) { + return AnimatedBuilder( + animation: animation, + builder: (context, child) { + return Material( + type: MaterialType.transparency, + elevation: 0, + color: Colors.transparent, + shadowColor: Colors.transparent, + child: child, + ); + }, + child: child, + ); + }, + onReorder: (oldIndex, newIndex) { + if (_submitted) return; + setState(() { + if (newIndex > oldIndex) newIndex -= 1; + final item = _order.removeAt(oldIndex); + _order.insert(newIndex, item); + }); + }, + itemBuilder: (context, index) { + final optionIndex = _order[index]; + return _rankRow(index, index, optionIndex); + }, + ), + ), + ), + if (_grade != null) + LearnQuizFeedbackBanner( + message: _grade!.feedback, + isCorrect: _grade!.isCorrect, + ), + ], + ), + ), + ), + LearnExperienceBottomBar( + primaryLabel: _submitted ? 'Continue' : 'Check order', + onPrimary: _submitted ? widget.onContinue : _submit, + ), + ], + ), + ); + } +} diff --git a/src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_single_choice.dart b/src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_single_choice.dart new file mode 100644 index 0000000000..bcb247ffa4 --- /dev/null +++ b/src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_single_choice.dart @@ -0,0 +1,94 @@ +import 'package:airqo/src/app/learn/models/learn_lesson_activity.dart'; +import 'package:airqo/src/app/learn/services/learn_quiz_scoring_service.dart'; +import 'package:airqo/src/app/learn/theme/learn_design_tokens.dart'; +import 'package:airqo/src/app/learn/widgets/experience/learn_experience_shell.dart'; +import 'package:airqo/src/app/learn/widgets/experience/learn_quiz_option.dart'; +import 'package:airqo/src/app/shared/widgets/translated_text.dart'; +import 'package:flutter/material.dart'; + +class LearnQuizSingleChoiceActivity extends StatefulWidget { + final LearnQuizPayload quiz; + final String activityTypeLabel; + final ValueChanged onGraded; + final VoidCallback onContinue; + + const LearnQuizSingleChoiceActivity({ + super.key, + required this.quiz, + required this.activityTypeLabel, + required this.onGraded, + required this.onContinue, + }); + + @override + State createState() => + _LearnQuizSingleChoiceActivityState(); +} + +class _LearnQuizSingleChoiceActivityState + extends State { + int? _selected; + bool _submitted = false; + LearnQuizGrade? _grade; + + void _submit() { + final grade = LearnQuizScoringService.gradeSingleChoice( + widget.quiz, + _selected, + ); + setState(() { + _submitted = true; + _grade = grade; + }); + widget.onGraded(grade); + } + + @override + Widget build(BuildContext context) { + return LearnActivityCardShell( + activityTypeLabel: widget.activityTypeLabel, + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TranslatedText( + widget.quiz.question, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: LearnDesignTokens.headline(context), + ), + ), + const SizedBox(height: 12), + ...List.generate(widget.quiz.options.length, (i) { + return LearnQuizOptionTile( + label: widget.quiz.options[i], + selected: _selected == i, + revealed: _submitted, + isCorrectOption: i == widget.quiz.correctIndex, + onTap: () => setState(() => _selected = i), + ); + }), + if (_grade != null) + LearnQuizFeedbackBanner( + message: _grade!.feedback, + isCorrect: _grade!.isCorrect, + ), + ], + ), + ), + ), + LearnExperienceBottomBar( + primaryLabel: _submitted ? 'Continue' : 'Submit', + primaryEnabled: _submitted || _selected != null, + onPrimary: _submitted ? widget.onContinue : _submit, + ), + ], + ), + ); + } +} diff --git a/src/mobile/lib/src/app/learn/widgets/experience/learn_video_activity.dart b/src/mobile/lib/src/app/learn/widgets/experience/learn_video_activity.dart new file mode 100644 index 0000000000..62d44bf032 --- /dev/null +++ b/src/mobile/lib/src/app/learn/widgets/experience/learn_video_activity.dart @@ -0,0 +1,189 @@ +import 'package:airqo/src/app/learn/models/learn_lesson_activity.dart'; +import 'package:airqo/src/app/learn/theme/learn_design_tokens.dart'; +import 'package:airqo/src/app/learn/widgets/experience/learn_experience_shell.dart'; +import 'package:airqo/src/app/shared/widgets/translated_text.dart'; +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:video_player/video_player.dart'; +import 'package:youtube_player_iframe/youtube_player_iframe.dart'; + +class LearnVideoActivity extends StatefulWidget { + final LearnVideoPayload payload; + final String activityTypeLabel; + final VoidCallback onContinue; + + const LearnVideoActivity({ + super.key, + required this.payload, + required this.activityTypeLabel, + required this.onContinue, + }); + + @override + State createState() => _LearnVideoActivityState(); +} + +class _LearnVideoActivityState extends State { + VideoPlayerController? _directController; + YoutubePlayerController? _youtubeController; + bool _ready = false; + String? _error; + + @override + void initState() { + super.initState(); + _initPlayer(); + } + + Future _initPlayer() async { + final url = widget.payload.videoUrl; + final youtubeId = _extractYoutubeId(url); + try { + if (youtubeId != null) { + _youtubeController = YoutubePlayerController.fromVideoId( + videoId: youtubeId, + autoPlay: false, + params: const YoutubePlayerParams( + showFullscreenButton: true, + mute: false, + ), + ); + } else if (_isDirectVideo(url)) { + _directController = VideoPlayerController.networkUrl(Uri.parse(url)); + await _directController!.initialize(); + _directController!.setLooping(true); + } else { + _error = 'Unsupported video URL'; + } + } catch (e) { + _error = 'Could not load video'; + } + if (mounted) setState(() => _ready = true); + } + + @override + void dispose() { + _directController?.dispose(); + _youtubeController?.close(); + super.dispose(); + } + + Future _openExternal() async { + final uri = Uri.tryParse(widget.payload.videoUrl); + if (uri == null) return; + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + + @override + Widget build(BuildContext context) { + return LearnActivityCardShell( + activityTypeLabel: widget.activityTypeLabel, + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: 16 / 9, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: _buildPlayer(), + ), + ), + const SizedBox(height: 12), + TranslatedText( + widget.payload.description, + style: TextStyle( + fontSize: 14, + height: 1.5, + color: LearnDesignTokens.muted(context), + ), + ), + ], + ), + ), + ), + LearnExperienceBottomBar( + primaryLabel: 'Continue', + onPrimary: widget.onContinue, + ), + ], + ), + ); + } + + Widget _buildPlayer() { + if (!_ready) { + return Container( + color: const Color(0xFFC8D8F8), + alignment: Alignment.center, + child: const CircularProgressIndicator(strokeWidth: 2), + ); + } + + if (_error != null) { + return Container( + color: const Color(0xFFC8D8F8), + alignment: Alignment.center, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.play_circle_outline, size: 48), + const SizedBox(height: 8), + TranslatedText(_error!), + TextButton( + onPressed: _openExternal, + child: const TranslatedText('Open in browser'), + ), + ], + ), + ); + } + + if (_youtubeController != null) { + return YoutubePlayer(controller: _youtubeController!); + } + + if (_directController != null && _directController!.value.isInitialized) { + return Stack( + alignment: Alignment.center, + children: [ + VideoPlayer(_directController!), + if (!_directController!.value.isPlaying) + IconButton( + iconSize: 56, + color: Colors.white, + onPressed: () => setState(() { + _directController!.play(); + }), + icon: const Icon(Icons.play_circle_fill), + ), + ], + ); + } + + return Container(color: const Color(0xFFC8D8F8)); + } + + static String? _extractYoutubeId(String url) { + final uri = Uri.tryParse(url); + if (uri == null) return null; + if (uri.host.contains('youtu.be')) { + return uri.pathSegments.isNotEmpty ? uri.pathSegments.first : null; + } + if (uri.host.contains('youtube.com')) { + return uri.queryParameters['v']; + } + return null; + } + + static bool _isDirectVideo(String url) { + final lower = url.toLowerCase(); + return lower.endsWith('.mp4') || + lower.endsWith('.mov') || + lower.contains('commondatastorage.googleapis.com'); + } +} diff --git a/src/mobile/lib/src/app/learn/widgets/kya_lesson_container.dart b/src/mobile/lib/src/app/learn/widgets/kya_lesson_container.dart index 9db4242f84..7aa22c3cba 100644 --- a/src/mobile/lib/src/app/learn/widgets/kya_lesson_container.dart +++ b/src/mobile/lib/src/app/learn/widgets/kya_lesson_container.dart @@ -1,7 +1,7 @@ import 'package:airqo/src/app/learn/models/lesson_response_model.dart'; -import 'package:airqo/src/app/learn/pages/lesson_page.dart'; -import 'package:airqo/src/app/shared/widgets/loading_widget.dart'; +import 'package:airqo/src/app/learn/widgets/learn_bottom_sheets.dart'; import 'package:airqo/src/app/shared/widgets/translated_text.dart'; +import 'package:airqo/src/meta/utils/colors.dart'; import 'package:flutter/material.dart'; class KyaLessonContainer extends StatelessWidget { @@ -11,87 +11,151 @@ class KyaLessonContainer extends StatelessWidget { @override Widget build(BuildContext context) { + final taskCount = kyaLesson.tasks.length; + return GestureDetector( - onTap: () => - Navigator.of(context).push(MaterialPageRoute(builder: (context) { - return LessonPage(kyaLesson); - })), + onTap: () => LearnBottomSheets.showLesson(context, lesson: kyaLesson), child: Container( - margin: const EdgeInsets.symmetric(vertical: 8), - decoration: BoxDecoration(), - width: MediaQuery.of(context).size.width, - height: 288, + margin: const EdgeInsets.symmetric(vertical: 8), + width: double.infinity, + height: 288, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), child: Stack( children: [ - SizedBox( - width: double.infinity, - height: double.infinity, - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.network( - kyaLesson.image, - fit: BoxFit.cover, - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) return child; - return ShimmerContainer( - height: double.infinity, - width: double.infinity, - borderRadius: 8, - ); - }, - errorBuilder: (context, error, stackTrace) => Container( - color: Theme.of(context).highlightColor, - child: const Center(child: Icon(Icons.broken_image, size: 48)), + // Background image + Positioned.fill( + child: Image.network( + kyaLesson.image, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container( + color: AppColors.primaryColor.withValues(alpha: 0.15), + child: const Icon(Icons.image_not_supported, + size: 48, color: Colors.grey), + ), + ), + ), + // Dark gradient overlay for readability + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withValues(alpha: 0.35), + ], + stops: const [0.4, 1.0], ), ), ), ), - Container( - alignment: Alignment.bottomLeft, - padding: const EdgeInsets.all(8), + // Info card bottom-left + Positioned( + left: 12, + bottom: 12, + child: _LessonInfoCard( + title: kyaLesson.title, + taskCount: taskCount, + context: context, + ), + ), + // Arrow button bottom-right + Positioned( + right: 12, + bottom: 12, child: Container( - padding: const EdgeInsets.all(16), + height: 44, + width: 44, decoration: BoxDecoration( - color: Theme.of(context).brightness == Brightness.dark - ? Color(0xff34373B) - : Colors.white, - borderRadius: BorderRadius.circular(4)), - width: 240, - constraints: const BoxConstraints(minHeight: 84, maxHeight: 140), - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Flexible( - child: TranslatedText(kyaLesson.title, - style: const TextStyle( - fontWeight: FontWeight.w600, fontSize: 16)), - ), - const SizedBox(height: 8), - Row( - children: [ - Container( - height: 32, - width: 38, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(40), - color: Color(0xff57D175)), - child: Center( - child: Icon( - Icons.arrow_forward_ios, - color: Colors.black, - size: 17, - ), - ), - ), - ], + shape: BoxShape.circle, + color: const Color(0xff57D175), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.15), + blurRadius: 6, + offset: const Offset(0, 2), ), ], ), + child: const Icon( + Icons.play_arrow_rounded, + color: Colors.black, + size: 22, + ), ), - ) + ), + ], + ), + ), + ), + ); + } +} + +class _LessonInfoCard extends StatelessWidget { + final String title; + final int taskCount; + final BuildContext context; + + const _LessonInfoCard({ + required this.title, + required this.taskCount, + required this.context, + }); + + @override + Widget build(BuildContext _) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + constraints: const BoxConstraints(maxWidth: 220, minHeight: 72), + decoration: BoxDecoration( + color: Theme.of(context).brightness == Brightness.dark + ? const Color(0xff34373B) + : Colors.white, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 4, + offset: const Offset(0, 1), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + TranslatedText( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 15), + ), + const SizedBox(height: 4), + Row( + children: [ + const Icon(Icons.menu_book_rounded, size: 13, color: Colors.grey), + const SizedBox(width: 4), + TranslatedText( + '$taskCount ${taskCount == 1 ? "card" : "cards"}', + style: const TextStyle(fontSize: 12, color: Colors.grey), + ), ], - )), + ), + ], + ), ); } } diff --git a/src/mobile/lib/src/app/learn/widgets/learn_bottom_sheets.dart b/src/mobile/lib/src/app/learn/widgets/learn_bottom_sheets.dart new file mode 100644 index 0000000000..a920c9857a --- /dev/null +++ b/src/mobile/lib/src/app/learn/widgets/learn_bottom_sheets.dart @@ -0,0 +1,333 @@ +import 'package:airqo/src/app/learn/models/learn_course_structure.dart'; +import 'package:airqo/src/app/learn/models/learn_lesson_activity.dart'; +import 'package:airqo/src/app/learn/models/learn_lesson_continuation.dart'; +import 'package:airqo/src/app/learn/models/lesson_response_model.dart'; +import 'package:airqo/src/app/learn/pages/learn_course_detail_page.dart'; +import 'package:airqo/src/app/learn/theme/learn_design_tokens.dart'; +import 'package:airqo/src/app/learn/widgets/experience/learn_course_certificate.dart'; +import 'package:airqo/src/app/learn/widgets/experience/learn_lesson_experience.dart'; +import 'package:airqo/src/app/learn/widgets/experience/learn_lesson_finish_pane.dart'; +import 'package:airqo/src/app/learn/widgets/experience/learn_level_unlock_pane.dart'; +import 'package:airqo/src/app/learn/widgets/learn_completion_sheet.dart'; +import 'package:airqo/src/app/learn/widgets/learn_lesson_confetti.dart'; +import 'package:flutter/material.dart'; + +class LearnBottomSheets { + LearnBottomSheets._(); + + static Widget _lessonCompletionStack(BuildContext sheetContext, Widget pane) { + return LearnCompletionSheet.compactShell( + context: sheetContext, + child: Stack( + clipBehavior: Clip.none, + children: [ + pane, + const Positioned( + top: 0, + left: 0, + right: 0, + height: 100, + child: LearnLessonConfetti(), + ), + ], + ), + ); + } + + static Widget _courseCompletionStack(BuildContext sheetContext, Widget pane) { + return LearnCompletionSheet.shell( + context: sheetContext, + child: Stack( + fit: StackFit.expand, + children: [ + pane, + const LearnLessonConfetti(), + ], + ), + ); + } + + static Future showLessonComplete( + BuildContext context, { + required LearnLessonResult result, + required int lessonNumberInUnit, + required int lessonsInUnit, + String? unitPlainTitle, + LearnLessonContinuation? continuation, + List? allCourses, + }) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useRootNavigator: true, + backgroundColor: Colors.transparent, + builder: (sheetContext) { + return _lessonCompletionStack( + sheetContext, + LearnLessonFinishPane( + result: result, + lessonNumberInUnit: lessonNumberInUnit, + lessonsInUnit: lessonsInUnit, + unitPlainTitle: unitPlainTitle, + onDone: () => Navigator.of(sheetContext).pop(), + onNext: continuation == null + ? null + : () { + Navigator.of(sheetContext).pop(); + if (!context.mounted) return; + _openNextLesson( + context, + continuation: continuation, + allCourses: allCourses, + ); + }, + isNextUnit: continuation?.isNextUnit ?? false, + ), + ); + }, + ); + } + + static Future showCourseComplete( + BuildContext context, { + required LearnCourseViewModel course, + required LearnStageInfo stage, + }) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useRootNavigator: true, + backgroundColor: Colors.transparent, + builder: (sheetContext) { + return _courseCompletionStack( + sheetContext, + LearnCourseCertificatePane( + course: course, + stage: stage, + onDone: () => Navigator.of(sheetContext).pop(), + ), + ); + }, + ); + } + + static Future _openNextLesson( + BuildContext context, { + required LearnLessonContinuation continuation, + List? allCourses, + }) async { + final courses = allCourses; + if (courses == null) return; + + final course = courses.firstWhere((c) => c.id == continuation.learnCourseId); + final unit = course.units[continuation.unitIndex]; + final chain = LearnCatalog.continuationFor( + course, + unit, + continuation.unitIndex, + continuation.lessonIndex, + courses, + ); + + await showLessonExperience( + context, + slot: continuation.nextSlot, + course: course, + unitIndex: continuation.unitIndex, + lessonIndex: continuation.lessonIndex, + unitPlainTitle: continuation.unitPlainTitle, + lessonNumberInUnit: continuation.lessonNumberInUnit, + lessonsInUnit: continuation.lessonsInUnit, + allCourses: courses, + continuation: chain, + ); + } + + static Future showLevelUnlockDemo(BuildContext context) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (sheetContext) { + return DecoratedBox( + decoration: BoxDecoration( + color: LearnCompletionSheet.sheetBackground(sheetContext), + borderRadius: const BorderRadius.vertical( + top: Radius.circular(LearnDesignTokens.sheetTopRadius), + ), + ), + child: LearnLevelUnlockPane( + previousStage: LearnCatalog.stages[0], + newStage: LearnCatalog.stages[1], + onContinue: () => Navigator.of(sheetContext).pop(), + ), + ); + }, + ); + } + + static Future showCourseDetail( + BuildContext context, { + required LearnCourseViewModel course, + required List allCourses, + }) { + final callerContext = context; + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: false, + backgroundColor: Colors.transparent, + builder: (sheetContext) { + return SizedBox( + height: MediaQuery.sizeOf(sheetContext).height, + child: LearnCourseDetailPage( + course: course, + allCourses: allCourses, + onLessonTap: (course, unit, unitIndex, lessonIndex, slot) async { + final continuation = LearnCatalog.continuationFor( + course, + unit, + unitIndex, + lessonIndex, + allCourses, + ); + await Navigator.of(sheetContext).maybePop(); + if (!callerContext.mounted) return; + await showLessonExperience( + callerContext, + slot: slot, + course: course, + unitIndex: unitIndex, + lessonIndex: lessonIndex, + unitPlainTitle: unit.plainTitleKey, + lessonNumberInUnit: lessonIndex + 1, + lessonsInUnit: unit.lessons.length, + allCourses: allCourses, + continuation: continuation, + ); + }, + ), + ); + }, + ); + } + + static Future showLessonExperience( + BuildContext context, { + required LearnLessonSlot slot, + required LearnCourseViewModel course, + required int unitIndex, + required int lessonIndex, + required String unitPlainTitle, + int lessonNumberInUnit = 1, + int lessonsInUnit = 1, + List? allCourses, + LearnLessonContinuation? continuation, + }) { + final callerContext = context; + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useRootNavigator: true, + backgroundColor: Colors.transparent, + useSafeArea: false, + builder: (sheetContext) { + final sheetHeight = MediaQuery.sizeOf(sheetContext).height * 0.92; + return SizedBox( + height: sheetHeight, + width: double.infinity, + child: DecoratedBox( + decoration: BoxDecoration( + color: LearnDesignTokens.sheetBg(sheetContext), + borderRadius: const BorderRadius.vertical( + top: Radius.circular(LearnDesignTokens.sheetTopRadius), + ), + ), + child: ClipRRect( + borderRadius: const BorderRadius.vertical( + top: Radius.circular(LearnDesignTokens.sheetTopRadius), + ), + child: LearnLessonExperience( + slot: slot, + apiLesson: slot.apiLesson, + course: course, + unitIndex: unitIndex, + lessonIndex: lessonIndex, + unitPlainTitle: unitPlainTitle, + lessonNumberInUnit: lessonNumberInUnit, + lessonsInUnit: lessonsInUnit, + allCourses: allCourses, + continuation: continuation, + completionContext: callerContext, + onClose: () => Navigator.of(sheetContext).pop(), + ), + ), + ), + ); + }, + ); + } + + /// Legacy entry for flat KYA list cards. + static Future showLesson( + BuildContext context, { + required KyaLesson lesson, + String? progressKey, + String? unitPlainTitle, + int lessonNumberInUnit = 1, + int lessonsInUnit = 1, + LearnLessonContinuation? continuation, + List? allCourses, + }) { + final slot = LearnLessonSlot( + catalogId: progressKey ?? lesson.id, + plainTitleKey: lesson.title, + apiLesson: lesson, + ); + + if (allCourses != null && continuation != null) { + final course = + allCourses.firstWhere((c) => c.id == continuation.learnCourseId); + return showLessonExperience( + context, + slot: slot, + course: course, + unitIndex: continuation.unitIndex, + lessonIndex: continuation.lessonIndex, + unitPlainTitle: unitPlainTitle ?? continuation.unitPlainTitle, + lessonNumberInUnit: lessonNumberInUnit, + lessonsInUnit: lessonsInUnit, + allCourses: allCourses, + continuation: continuation, + ); + } + + final legacyCourse = LearnCourseViewModel( + id: 'legacy_kya', + courseNumber: 1, + title: lesson.title, + plainTitleKey: lesson.title, + units: [ + LearnUnitViewModel( + id: 'legacy_u1', + title: 'Lesson', + plainTitleKey: unitPlainTitle ?? 'Lesson', + lessons: [slot], + ), + ], + ); + + return showLessonExperience( + context, + slot: slot, + course: legacyCourse, + unitIndex: 0, + lessonIndex: 0, + unitPlainTitle: unitPlainTitle ?? 'Lesson', + lessonNumberInUnit: lessonNumberInUnit, + lessonsInUnit: lessonsInUnit, + allCourses: const [], + continuation: continuation, + ); + } +} diff --git a/src/mobile/lib/src/app/learn/widgets/learn_completion_sheet.dart b/src/mobile/lib/src/app/learn/widgets/learn_completion_sheet.dart new file mode 100644 index 0000000000..a98c499405 --- /dev/null +++ b/src/mobile/lib/src/app/learn/widgets/learn_completion_sheet.dart @@ -0,0 +1,135 @@ +import 'package:airqo/src/app/learn/theme/learn_design_tokens.dart'; +import 'package:airqo/src/meta/utils/colors.dart'; +import 'package:flutter/material.dart'; + +/// Fixed-height completion sheets aligned with Exposure modal styling. +class LearnCompletionSheet { + LearnCompletionSheet._(); + + static const heightFactor = 0.7; + static const horizontalPadding = 20.0; + + static Color sheetBackground(BuildContext context) => + AppSurfaceColors.sheet(context); + + /// Wraps content at its natural height — used for lesson complete and level unlock. + static Widget compactShell({ + required BuildContext context, + required Widget child, + }) { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.viewPaddingOf(context).bottom, + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: sheetBackground(context), + borderRadius: const BorderRadius.vertical( + top: Radius.circular(LearnDesignTokens.sheetTopRadius), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + LearnDesignTokens.dragHandle(context), + child, + ], + ), + ), + ); + } + + static Widget compactBody({ + required BuildContext context, + required Widget content, + required List actions, + }) { + return Padding( + padding: const EdgeInsets.fromLTRB( + horizontalPadding, + 0, + horizontalPadding, + 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + content, + const SizedBox(height: 16), + for (var i = 0; i < actions.length; i++) ...[ + if (i > 0) const SizedBox(height: 8), + actions[i], + ], + ], + ), + ); + } + + static Widget shell({ + required BuildContext context, + required Widget child, + }) { + final sheetHeight = MediaQuery.sizeOf(context).height * heightFactor; + + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.viewPaddingOf(context).bottom, + ), + child: SizedBox( + height: sheetHeight, + child: DecoratedBox( + decoration: BoxDecoration( + color: sheetBackground(context), + borderRadius: const BorderRadius.vertical( + top: Radius.circular(LearnDesignTokens.sheetTopRadius), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + LearnDesignTokens.dragHandle(context), + Expanded(child: child), + ], + ), + ), + ), + ); + } + + static Widget body({ + required BuildContext context, + required Widget content, + required List actions, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB( + horizontalPadding, + 0, + horizontalPadding, + 8, + ), + child: content, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB( + horizontalPadding, + 0, + horizontalPadding, + 16, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: actions, + ), + ), + ], + ); + } +} diff --git a/src/mobile/lib/src/app/learn/widgets/learn_course_portrait_card.dart b/src/mobile/lib/src/app/learn/widgets/learn_course_portrait_card.dart new file mode 100644 index 0000000000..d8c61f0103 --- /dev/null +++ b/src/mobile/lib/src/app/learn/widgets/learn_course_portrait_card.dart @@ -0,0 +1,149 @@ +import 'package:airqo/src/app/learn/models/learn_course_structure.dart'; +import 'package:airqo/src/app/learn/services/learn_progress_service.dart'; +import 'package:airqo/src/app/learn/theme/learn_design_tokens.dart'; +import 'package:airqo/src/app/shared/widgets/translated_text.dart'; +import 'package:flutter/material.dart'; + +class LearnCoursePortraitCard extends StatelessWidget { + static const _footerHeight = 92.0; + + final LearnCourseViewModel course; + final bool locked; + final String? coverImageUrl; + final VoidCallback? onTap; + + const LearnCoursePortraitCard({ + super.key, + required this.course, + required this.locked, + this.coverImageUrl, + this.onTap, + }); + + bool _hasPartialProgress(LearnProgressService progress) { + for (final unit in course.units) { + for (final lesson in unit.lessons) { + if (progress.furthestStep(lesson.progressKey) > 0 && + !progress.isLessonComplete(lesson.progressKey)) { + return true; + } + } + } + return false; + } + + String _statusLabel({ + required bool isComplete, + required bool isInProgress, + }) { + if (isComplete) return 'Completed'; + if (isInProgress) return 'In progress'; + return 'Not started'; + } + + @override + Widget build(BuildContext context) { + final progress = LearnProgressService.instance; + final completed = course.completedLessons(progress); + final total = course.totalLessons; + final ratio = total > 0 ? completed / total : 0.0; + final isComplete = !locked && total > 0 && completed >= total; + final isInProgress = !locked && + !isComplete && + (completed > 0 || _hasPartialProgress(progress)); + final progressColor = isComplete + ? LearnDesignTokens.success + : LearnDesignTokens.primary(context); + final radius = BorderRadius.circular(LearnDesignTokens.portraitCardRadius); + final topRadius = BorderRadius.vertical( + top: Radius.circular(LearnDesignTokens.portraitCardRadius), + ); + + Widget card = Container( + decoration: BoxDecoration( + borderRadius: radius, + color: LearnDesignTokens.cardBg(context), + border: Border.all(color: LearnDesignTokens.divider(context)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ClipRRect( + borderRadius: topRadius, + child: coverImageUrl != null && coverImageUrl!.isNotEmpty + ? Image.network( + coverImageUrl!, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + errorBuilder: (_, __, ___) => Container( + color: LearnDesignTokens.nestedSurface(context), + ), + ) + : Container(color: LearnDesignTokens.nestedSurface(context)), + ), + ), + SizedBox( + height: _footerHeight, + child: Padding( + padding: const EdgeInsets.fromLTRB(10, 8, 10, 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Course ${course.courseNumber}', + style: LearnDesignTokens.lessonLabel(context), + ), + const SizedBox(height: 2), + Expanded( + child: TranslatedText( + course.plainTitleKey, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: LearnDesignTokens.headline(context), + height: 1.2, + ), + ), + ), + ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: ratio, + minHeight: 3, + backgroundColor: LearnDesignTokens.divider(context), + color: progressColor, + ), + ), + const SizedBox(height: 4), + TranslatedText( + _statusLabel( + isComplete: isComplete, + isInProgress: isInProgress, + ), + style: LearnDesignTokens.activitySubtitle(context), + ), + ], + ), + ), + ), + ], + ), + ); + + if (locked) { + card = LearnDesignTokens.lockedContentBlur( + borderRadius: radius, + child: card, + ); + } + + return GestureDetector( + onTap: locked ? null : onTap, + child: card, + ); + } +} diff --git a/src/mobile/lib/src/app/learn/widgets/learn_dashboard_header.dart b/src/mobile/lib/src/app/learn/widgets/learn_dashboard_header.dart new file mode 100644 index 0000000000..182707f3ae --- /dev/null +++ b/src/mobile/lib/src/app/learn/widgets/learn_dashboard_header.dart @@ -0,0 +1,37 @@ +import 'package:airqo/src/app/learn/theme/learn_design_tokens.dart'; +import 'package:airqo/src/app/shared/widgets/translated_text.dart'; +import 'package:flutter/material.dart'; + +class LearnDashboardHeader extends StatelessWidget { + const LearnDashboardHeader({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB( + LearnDesignTokens.horizontalPadding, + 8, + LearnDesignTokens.horizontalPadding, + 4, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TranslatedText( + 'Learn', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.w700, + color: LearnDesignTokens.headline(context), + ), + ), + const SizedBox(height: 4), + TranslatedText( + 'Practical courses on air quality, health risks, and steps you can take to breathe safer.', + style: LearnDesignTokens.sectionSubtitle(context), + ), + ], + ), + ); + } +} diff --git a/src/mobile/lib/src/app/learn/widgets/learn_lesson_activities.dart b/src/mobile/lib/src/app/learn/widgets/learn_lesson_activities.dart new file mode 100644 index 0000000000..11230ca676 --- /dev/null +++ b/src/mobile/lib/src/app/learn/widgets/learn_lesson_activities.dart @@ -0,0 +1,211 @@ +import 'package:airqo/src/app/learn/widgets/learn_lesson_image.dart'; +import 'package:airqo/src/app/learn/widgets/learn_sheet_button_styles.dart'; +import 'package:airqo/src/app/learn/theme/learn_design_tokens.dart'; +import 'package:airqo/src/app/shared/widgets/translated_text.dart'; +import 'package:flutter/material.dart'; + +class LearnStepDualButtons extends StatelessWidget { + final String primaryLabel; + final VoidCallback? onPrimary; + final String? secondaryLabel; + final VoidCallback? onSecondary; + final bool primaryEnabled; + + const LearnStepDualButtons({ + super.key, + this.primaryLabel = 'Continue', + this.onPrimary, + this.secondaryLabel, + this.onSecondary, + this.primaryEnabled = true, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + ElevatedButton( + onPressed: primaryEnabled ? onPrimary : null, + style: learnExposurePrimaryButtonStyle(enabled: primaryEnabled), + child: TranslatedText(primaryLabel), + ), + if (secondaryLabel != null) ...[ + const SizedBox(height: 8), + OutlinedButton( + onPressed: onSecondary, + style: learnExposureSecondaryButtonStyle(context), + child: TranslatedText(secondaryLabel!), + ), + ], + ], + ); + } +} + +class LearnNotesActivityCard extends StatefulWidget { + final String title; + final String body; + final VoidCallback onContinue; + + const LearnNotesActivityCard({ + super.key, + required this.title, + required this.body, + required this.onContinue, + }); + + @override + State createState() => _LearnNotesActivityCardState(); +} + +class _LearnNotesActivityCardState extends State { + final _controller = ScrollController(); + bool _canContinue = false; + + @override + void initState() { + super.initState(); + _controller.addListener(_checkScroll); + WidgetsBinding.instance.addPostFrameCallback((_) => _checkScroll()); + } + + void _checkScroll() { + if (!_controller.hasClients) return; + final atEnd = _controller.position.pixels >= + _controller.position.maxScrollExtent - 24; + if (atEnd != _canContinue) setState(() => _canContinue = atEnd); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return _LearnStepShell( + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + controller: _controller, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TranslatedText( + widget.title, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: LearnDesignTokens.headline(context), + ), + ), + const SizedBox(height: 8), + TranslatedText( + widget.body, + style: TextStyle( + fontSize: 14, + height: 1.5, + color: LearnDesignTokens.muted(context), + ), + ), + const SizedBox(height: 80), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: LearnStepDualButtons( + primaryEnabled: _canContinue, + onPrimary: widget.onContinue, + ), + ), + ], + ), + ); + } +} + +class LearnImageActivityCard extends StatelessWidget { + final String title; + final String body; + final String imageUrl; + final VoidCallback onContinue; + + const LearnImageActivityCard({ + super.key, + required this.title, + required this.body, + required this.imageUrl, + required this.onContinue, + }); + + @override + Widget build(BuildContext context) { + return _LearnStepShell( + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: LearnLessonImage(url: imageUrl, height: 180), + ), + const SizedBox(height: 12), + TranslatedText( + title, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: LearnDesignTokens.headline(context), + ), + ), + const SizedBox(height: 8), + TranslatedText( + body, + style: TextStyle( + fontSize: 14, + height: 1.5, + color: LearnDesignTokens.muted(context), + ), + ), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: LearnStepDualButtons(onPrimary: onContinue), + ), + ], + ), + ); + } +} + +class _LearnStepShell extends StatelessWidget { + final Widget child; + + const _LearnStepShell({required this.child}); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.fromLTRB(16, 0, 16, 16), + decoration: BoxDecoration( + color: LearnDesignTokens.cardBg(context), + borderRadius: BorderRadius.circular(LearnDesignTokens.activityCardRadius), + border: Border.all(color: LearnDesignTokens.divider(context)), + ), + clipBehavior: Clip.antiAlias, + child: child, + ); + } +} diff --git a/src/mobile/lib/src/app/learn/widgets/learn_lesson_confetti.dart b/src/mobile/lib/src/app/learn/widgets/learn_lesson_confetti.dart new file mode 100644 index 0000000000..00717b5e07 --- /dev/null +++ b/src/mobile/lib/src/app/learn/widgets/learn_lesson_confetti.dart @@ -0,0 +1,67 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; + +class LearnLessonConfetti extends StatefulWidget { + const LearnLessonConfetti({super.key}); + + @override + State createState() => _LearnLessonConfettiState(); +} + +class _LearnLessonConfettiState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + final _random = Random(); + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + )..forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return Stack( + 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)); + return Positioned( + left: left * MediaQuery.of(context).size.width, + top: -20 + t * 120, + child: Opacity( + opacity: 1 - t, + child: Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: [ + const Color(0xff145FFF), + const Color(0xff57D175), + const Color(0xffE24B4A), + ][_random.nextInt(3)], + borderRadius: BorderRadius.circular(1), + ), + ), + ), + ); + }), + ); + }, + ), + ); + } +} diff --git a/src/mobile/lib/src/app/learn/widgets/learn_lesson_image.dart b/src/mobile/lib/src/app/learn/widgets/learn_lesson_image.dart new file mode 100644 index 0000000000..9df9964b2d --- /dev/null +++ b/src/mobile/lib/src/app/learn/widgets/learn_lesson_image.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +class LearnLessonImage extends StatelessWidget { + final String url; + final double height; + + const LearnLessonImage({ + super.key, + required this.url, + this.height = 200, + }); + + @override + Widget build(BuildContext context) { + if (url.isEmpty) { + return Container( + height: height, + color: Theme.of(context).highlightColor, + alignment: Alignment.center, + child: const Icon(Icons.image_not_supported), + ); + } + return Image.network( + url, + height: height, + width: double.infinity, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container( + height: height, + color: Theme.of(context).highlightColor, + alignment: Alignment.center, + child: const Icon(Icons.broken_image_outlined), + ), + ); + } +} diff --git a/src/mobile/lib/src/app/learn/widgets/learn_lesson_list_row.dart b/src/mobile/lib/src/app/learn/widgets/learn_lesson_list_row.dart new file mode 100644 index 0000000000..74be28814e --- /dev/null +++ b/src/mobile/lib/src/app/learn/widgets/learn_lesson_list_row.dart @@ -0,0 +1,219 @@ +import 'package:airqo/src/app/learn/formatting/learn_display_text.dart'; +import 'package:airqo/src/app/learn/models/learn_course_structure.dart'; +import 'package:airqo/src/app/learn/theme/learn_design_tokens.dart'; +import 'package:airqo/src/app/learn/widgets/learn_lesson_thumbnail.dart'; +import 'package:airqo/src/app/shared/widgets/translated_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +/// Exposure-style lesson row for the course detail sheet. +class LearnLessonListRow extends StatelessWidget { + static const _lessonIcon = 'assets/icons/learn_icon.svg'; + static const _chevronIcon = 'assets/icons/chevron-right.svg'; + static const _iconSize = 36.0; + static const _iconGap = 12.0; + + final LearnLessonSlot slot; + final int unitIndex; + final int lessonIndex; + final bool locked; + final bool complete; + final double progressRatio; + final VoidCallback? onOpen; + + const LearnLessonListRow({ + super.key, + required this.slot, + required this.unitIndex, + required this.lessonIndex, + required this.locked, + required this.complete, + required this.progressRatio, + this.onOpen, + }); + + String _activityLabel(int count) { + return count == 1 ? '1 activity' : '$count activities'; + } + + String _statusLabel() { + if (complete) return 'Completed'; + if (progressRatio > 0) return 'In progress'; + return 'Not started'; + } + + @override + Widget build(BuildContext context) { + final titleColor = LearnDesignTokens.headline(context); + final subtitleColor = LearnDesignTokens.subtitle(context); + final iconBg = LearnDesignTokens.iconBg(context); + final dividerColor = LearnDesignTokens.divider(context); + final activities = slot.activityCount; + final barValue = complete ? 1.0 : progressRatio.clamp(0.0, 1.0); + final progressColor = complete + ? LearnDesignTokens.success + : LearnDesignTokens.primary(context); + final radius = BorderRadius.circular(10); + final topRadius = const BorderRadius.vertical(top: Radius.circular(10)); + + Widget row = Container( + margin: const EdgeInsets.only(bottom: 10), + decoration: BoxDecoration( + color: LearnDesignTokens.cardBg(context), + borderRadius: radius, + border: Border.all(color: dividerColor), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + clipBehavior: Clip.antiAlias, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: locked ? null : onOpen, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ClipRRect( + borderRadius: topRadius, + child: LearnLessonThumbnail( + slot: slot, + unitIndex: unitIndex, + lessonIndex: lessonIndex, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 12, 14), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: _iconSize, + height: _iconSize, + decoration: BoxDecoration( + color: complete + ? LearnDesignTokens.success + : iconBg, + shape: BoxShape.circle, + ), + child: Center( + child: locked + ? Icon(Icons.lock_outline, + size: 16, color: subtitleColor) + : complete + ? const Icon( + LearnDesignTokens.completedCheckIcon, + size: 18, + color: Colors.white, + ) + : SvgPicture.asset( + _lessonIcon, + width: 16, + height: 16, + colorFilter: ColorFilter.mode( + LearnDesignTokens.headline(context), + BlendMode.srcIn, + ), + ), + ), + ), + const SizedBox(width: _iconGap), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + learnLessonLabel(lessonIndex), + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + letterSpacing: 0.4, + color: subtitleColor, + ), + ), + const SizedBox(height: 2), + TranslatedText( + slot.plainTitleKey, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + height: 1.25, + color: locked + ? titleColor.withValues(alpha: 0.45) + : titleColor, + ), + ), + const SizedBox(height: 6), + if (locked) + TranslatedText( + 'Complete the previous lesson to unlock', + style: TextStyle( + fontSize: 12, color: subtitleColor), + ) + else + Text( + _activityLabel(activities), + style: TextStyle( + fontSize: 12, color: subtitleColor), + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: barValue, + minHeight: 3, + backgroundColor: dividerColor, + color: progressColor, + ), + ), + const SizedBox(height: 4), + if (!locked) + TranslatedText( + _statusLabel(), + style: LearnDesignTokens.activitySubtitle(context), + ), + ], + ), + ), + if (!locked) + GestureDetector( + onTap: onOpen, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.only(left: 4, top: 4), + child: SvgPicture.asset( + _chevronIcon, + width: 18, + height: 18, + colorFilter: ColorFilter.mode( + LearnDesignTokens.headline(context), + BlendMode.srcIn, + ), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + + if (locked) { + row = LearnDesignTokens.lockedContentBlur( + borderRadius: radius, + child: row, + ); + } + + return row; + } +} diff --git a/src/mobile/lib/src/app/learn/widgets/learn_lesson_thumbnail.dart b/src/mobile/lib/src/app/learn/widgets/learn_lesson_thumbnail.dart new file mode 100644 index 0000000000..0864c42946 --- /dev/null +++ b/src/mobile/lib/src/app/learn/widgets/learn_lesson_thumbnail.dart @@ -0,0 +1,245 @@ +import 'package:airqo/src/app/learn/formatting/learn_display_text.dart'; +import 'package:airqo/src/app/learn/models/learn_course_structure.dart'; +import 'package:airqo/src/app/shared/widgets/translated_text.dart'; +import 'package:flutter/material.dart'; + +/// Full-width header gradient for lesson cards. +class LearnLessonThumbnail extends StatelessWidget { + static const height = 72.0; + + /// Non-semantic palettes: USG orange, hazardous, light blue. + static const _gradients = [ + ( + light: Color(0xFFFFC170), + dark: Color(0xFFFF851F), + number: Color(0xFF9A4E00), + ), + ( + light: Color(0xFFF0B1D8), + dark: Color(0xFFD95BA3), + number: Color(0xFF8B2868), + ), + ( + light: Color(0xFFBFE7FF), + dark: Color(0xFF5EB3F5), + number: Color(0xFF1A5F8F), + ), + ]; + + final LearnLessonSlot slot; + final int unitIndex; + final int lessonIndex; + + const LearnLessonThumbnail({ + super.key, + required this.slot, + required this.unitIndex, + required this.lessonIndex, + }); + + static int gradientIndex(int unitIndex, int lessonIndex) { + return (lessonIndex + unitIndex) % _gradients.length; + } + + static LinearGradient gradientFor(int unitIndex, int lessonIndex) { + final palette = _gradients[gradientIndex(unitIndex, lessonIndex)]; + return LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [palette.light, palette.dark], + ); + } + + static Color numberColorFor(int unitIndex, int lessonIndex) { + return _gradients[gradientIndex(unitIndex, lessonIndex)].number; + } + + static Color progressTrackColorFor(int unitIndex, int lessonIndex) { + return Colors.white.withValues(alpha: 0.35); + } + + /// Light mode: dark accent from the gradient palette. Dark mode: white text. + static Color contentColorFor( + BuildContext context, + int unitIndex, + int lessonIndex, + ) { + final isDark = Theme.of(context).brightness == Brightness.dark; + if (isDark) return Colors.white; + return numberColorFor(unitIndex, lessonIndex); + } + + static const Color progressFillColor = Colors.white; + + @override + Widget build(BuildContext context) { + final contentColor = + LearnLessonThumbnail.contentColorFor(context, unitIndex, lessonIndex); + + return SizedBox( + width: double.infinity, + height: height, + child: Stack( + fit: StackFit.expand, + children: [ + DecoratedBox( + decoration: BoxDecoration( + gradient: gradientFor(unitIndex, lessonIndex), + ), + ), + Positioned( + top: 8, + right: 16, + child: Text( + '${lessonIndex + 1}', + style: TextStyle( + fontSize: 44, + fontWeight: FontWeight.w800, + height: 1, + color: contentColor, + ), + ), + ), + ], + ), + ); + } +} + +/// Lesson flow header banner — gradient, number, lesson tag, activity title, progress. +class LearnLessonExperienceBanner extends StatelessWidget { + static const height = 132.0; + static const radius = 10.0; + static const horizontalInset = 12.0; + + final LearnLessonSlot slot; + final int unitIndex; + final int lessonIndex; + final String lessonTitle; + final String activityName; + final double progress; + final String activityProgressLabel; + + const LearnLessonExperienceBanner({ + super.key, + required this.slot, + required this.unitIndex, + required this.lessonIndex, + required this.lessonTitle, + required this.activityName, + required this.progress, + required this.activityProgressLabel, + }); + + @override + Widget build(BuildContext context) { + final contentColor = LearnLessonThumbnail.contentColorFor( + context, + unitIndex, + lessonIndex, + ); + final trackColor = + LearnLessonThumbnail.progressTrackColorFor(unitIndex, lessonIndex); + + return ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: SizedBox( + width: double.infinity, + height: height, + child: Stack( + fit: StackFit.expand, + children: [ + DecoratedBox( + decoration: BoxDecoration( + gradient: LearnLessonThumbnail.gradientFor( + unitIndex, + lessonIndex, + ), + ), + ), + Positioned( + top: 6, + right: 14, + child: Text( + '${lessonIndex + 1}', + style: TextStyle( + fontSize: 44, + fontWeight: FontWeight.w800, + height: 1, + color: contentColor, + ), + ), + ), + Positioned( + left: 14, + right: 14, + bottom: 14, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + '${learnLessonLabel(lessonIndex)}: ', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + color: contentColor, + ), + ), + TranslatedText( + lessonTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + height: 1.2, + color: contentColor, + ), + ), + ], + ), + const SizedBox(height: 4), + TranslatedText( + activityName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w700, + height: 1.15, + color: contentColor, + ), + ), + const SizedBox(height: 10), + ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: progress.clamp(0.0, 1.0), + minHeight: 3, + backgroundColor: trackColor, + color: LearnLessonThumbnail.progressFillColor, + ), + ), + const SizedBox(height: 6), + Text( + activityProgressLabel, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: contentColor, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/src/mobile/lib/src/app/learn/widgets/learn_level_summary_card.dart b/src/mobile/lib/src/app/learn/widgets/learn_level_summary_card.dart new file mode 100644 index 0000000000..16c5dbe0aa --- /dev/null +++ b/src/mobile/lib/src/app/learn/widgets/learn_level_summary_card.dart @@ -0,0 +1,241 @@ +import 'package:airqo/src/app/learn/models/learn_course_structure.dart'; +import 'package:airqo/src/app/learn/theme/learn_design_tokens.dart'; +import 'package:airqo/src/app/learn/widgets/learn_bottom_sheets.dart'; +import 'package:airqo/src/app/shared/widgets/translated_text.dart'; +import 'package:airqo/src/meta/utils/colors.dart'; +import 'package:flutter/material.dart'; + +class LearnLevelSummaryCard extends StatefulWidget { + final LearnStageInfo stage; + final int completedLessons; + final int totalLessons; + final int earnedPoints; + final int maxPoints; + + const LearnLevelSummaryCard({ + super.key, + required this.stage, + required this.completedLessons, + required this.totalLessons, + this.earnedPoints = 0, + this.maxPoints = 0, + }); + + @override + State createState() => _LearnLevelSummaryCardState(); +} + +class _LearnLevelSummaryCardState extends State { + bool _expanded = false; + + @override + Widget build(BuildContext context) { + final progress = widget.maxPoints > 0 + ? widget.earnedPoints / widget.maxPoints + : widget.totalLessons > 0 + ? widget.completedLessons / widget.totalLessons + : 0.0; + final iconBg = LearnDesignTokens.iconBg(context); + final stages = LearnCatalog.stages; + + return GestureDetector( + onTap: () => setState(() => _expanded = !_expanded), + child: Container( + margin: const EdgeInsets.fromLTRB(16, 8, 16, 0), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: LearnDesignTokens.cardBg(context), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: LearnDesignTokens.divider(context)), + ), + child: Column( + children: [ + Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: iconBg, + borderRadius: BorderRadius.circular(20), + ), + alignment: Alignment.center, + child: Icon( + Icons.emoji_events_outlined, + size: 20, + color: AppColors.primaryColor, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TranslatedText( + 'YOUR LEVEL', + style: TextStyle( + fontSize: 9, + letterSpacing: 1, + fontWeight: FontWeight.w600, + color: LearnDesignTokens.muted(context), + ), + ), + TranslatedText( + widget.stage.name, + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w700, + color: LearnDesignTokens.headline(context), + ), + ), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + TranslatedText( + widget.maxPoints > 0 + ? '${widget.earnedPoints} / ${widget.maxPoints} points' + : '${widget.completedLessons} / ${widget.totalLessons} lessons', + style: LearnDesignTokens.activitySubtitle(context), + ), + GestureDetector( + onTap: () { + if (_expanded) { + setState(() => _expanded = false); + return; + } + setState(() => _expanded = true); + LearnBottomSheets.showLevelUnlockDemo(context); + }, + behavior: HitTestBehavior.opaque, + child: TranslatedText( + _expanded ? 'Hide levels' : 'See all levels', + style: TextStyle( + fontSize: 10, + color: LearnDesignTokens.primary(context), + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ], + ), + const SizedBox(height: 10), + ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: progress, + minHeight: 3, + backgroundColor: LearnDesignTokens.divider(context), + color: LearnDesignTokens.primary(context), + ), + ), + if (_expanded) ...[ + const SizedBox(height: 12), + Divider(color: LearnDesignTokens.divider(context)), + const SizedBox(height: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var i = 0; i < stages.length; i++) ...[ + if (i > 0) + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 13), + child: Container( + height: 1.5, + color: stages[i - 1].index < widget.stage.index + ? LearnDesignTokens.success + : LearnDesignTokens.divider(context), + ), + ), + ), + _LevelNode( + stage: stages[i], + active: stages[i].index == widget.stage.index, + done: stages[i].index < widget.stage.index, + ), + ], + ], + ), + ], + ], + ), + ), + ); + } +} + +class _LevelNode extends StatelessWidget { + final LearnStageInfo stage; + final bool active; + final bool done; + + const _LevelNode({ + required this.stage, + required this.active, + required this.done, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: active ? 44 : 40, + child: Column( + children: [ + Container( + width: active ? 28 : 26, + height: active ? 28 : 26, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: done + ? LearnDesignTokens.successBg(context) + : active + ? const Color(0xffE8F0FF) + : Theme.of(context).highlightColor, + border: Border.all( + color: done + ? LearnDesignTokens.success + : active + ? LearnDesignTokens.primary(context) + : LearnDesignTokens.divider(context), + width: active ? 2 : 1.5, + ), + ), + child: Center( + child: done + ? LearnDesignTokens.completedCheckIconWidget(size: 14) + : Text( + '${stage.index + 1}', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: active + ? LearnDesignTokens.primary(context) + : LearnDesignTokens.disabled, + ), + ), + ), + ), + const SizedBox(height: 4), + Text( + stage.name, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 8, + color: active + ? LearnDesignTokens.primary(context) + : LearnDesignTokens.muted(context), + fontWeight: active ? FontWeight.w600 : FontWeight.normal, + ), + ), + ], + ), + ); + } +} diff --git a/src/mobile/lib/src/app/learn/widgets/learn_sheet_button_styles.dart b/src/mobile/lib/src/app/learn/widgets/learn_sheet_button_styles.dart new file mode 100644 index 0000000000..b34d28d6d5 --- /dev/null +++ b/src/mobile/lib/src/app/learn/widgets/learn_sheet_button_styles.dart @@ -0,0 +1,61 @@ +import 'package:airqo/src/app/learn/theme/learn_design_tokens.dart'; +import 'package:airqo/src/meta/utils/colors.dart'; +import 'package:flutter/material.dart'; + +/// Matches Exposure sheet CTAs (label picker, dashboard empty state). +const double learnPrimaryButtonRadius = 12; + +const TextStyle learnPrimaryButtonTextStyle = TextStyle( + fontFamily: 'Inter', + fontSize: 15, + fontWeight: FontWeight.w600, +); + +const TextStyle learnSecondaryButtonTextStyle = TextStyle( + fontFamily: 'Inter', + fontSize: 15, + fontWeight: FontWeight.w600, +); + +ButtonStyle learnExposurePrimaryButtonStyle({bool enabled = true}) { + return ElevatedButton.styleFrom( + backgroundColor: + enabled ? AppColors.primaryColor : AppColors.dividerColorlight, + foregroundColor: enabled ? Colors.white : AppColors.boldHeadlineColor3, + elevation: 0, + minimumSize: const Size(double.infinity, 48), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(learnPrimaryButtonRadius), + ), + textStyle: learnPrimaryButtonTextStyle, + ); +} + +ButtonStyle learnExposureSecondaryButtonStyle(BuildContext context) { + return OutlinedButton.styleFrom( + foregroundColor: LearnDesignTokens.headline(context), + side: BorderSide(color: LearnDesignTokens.divider(context)), + minimumSize: const Size(double.infinity, 48), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(learnPrimaryButtonRadius), + ), + textStyle: learnSecondaryButtonTextStyle, + ); +} + +/// Neutral filled button used on Exposure-style sheets (e.g. Done on completion). +ButtonStyle learnExposureNeutralButtonStyle(BuildContext context) { + return ElevatedButton.styleFrom( + backgroundColor: LearnDesignTokens.nestedSurface(context), + foregroundColor: LearnDesignTokens.headline(context), + elevation: 0, + minimumSize: const Size(double.infinity, 48), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(learnPrimaryButtonRadius), + ), + textStyle: learnSecondaryButtonTextStyle, + ); +} diff --git a/src/mobile/lib/src/app/learn/widgets/learn_unit_chip.dart b/src/mobile/lib/src/app/learn/widgets/learn_unit_chip.dart new file mode 100644 index 0000000000..8eb65ddfda --- /dev/null +++ b/src/mobile/lib/src/app/learn/widgets/learn_unit_chip.dart @@ -0,0 +1,187 @@ +import 'package:airqo/src/app/learn/models/learn_course_structure.dart'; +import 'package:airqo/src/app/learn/services/learn_progress_service.dart'; +import 'package:airqo/src/app/learn/theme/learn_design_tokens.dart'; +import 'package:airqo/src/app/shared/widgets/translated_text.dart'; +import 'package:airqo/src/meta/utils/colors.dart'; +import 'package:flutter/material.dart'; + +class LearnUnitChip extends StatelessWidget { + final LearnUnitViewModel unit; + final LearnUnitStatus status; + final bool selected; + final VoidCallback onTap; + + const LearnUnitChip({ + super.key, + required this.unit, + required this.status, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final isDark = LearnDesignTokens.isDark(context); + final strokeColor = isDark + ? AppColors.boldHeadlineColor2.withValues(alpha: 0.35) + : AppColors.dividerColorlight; + final inactiveTextColor = LearnDesignTokens.headline(context); + final bg = selected + ? AppColors.primaryColor + : (isDark ? AppColors.darkHighlight : Colors.white); + final textColor = selected ? Colors.white : inactiveTextColor; + final iconColor = selected + ? Colors.white + : _statusColor(status, inactiveTextColor); + + return GestureDetector( + onTap: onTap, + child: Container( + height: 28, + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(20), + border: selected ? null : Border.all(color: strokeColor), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _StatusIcon(status: status, color: iconColor), + const SizedBox(width: 6), + TranslatedText( + unit.plainTitleKey, + style: TextStyle( + fontSize: 12, + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + color: textColor, + ), + ), + ], + ), + ), + ); + } + + static Color _statusColor(LearnUnitStatus status, Color fallback) { + switch (status) { + case LearnUnitStatus.completed: + return LearnDesignTokens.success; + case LearnUnitStatus.inProgress: + return AppColors.primaryColor; + case LearnUnitStatus.locked: + return fallback.withValues(alpha: 0.55); + } + } +} + +class _StatusIcon extends StatelessWidget { + final LearnUnitStatus status; + final Color color; + + const _StatusIcon({ + required this.status, + required this.color, + }); + + @override + Widget build(BuildContext context) { + final IconData icon; + switch (status) { + case LearnUnitStatus.locked: + icon = Icons.lock_outline; + case LearnUnitStatus.completed: + icon = LearnDesignTokens.completedCheckIcon; + case LearnUnitStatus.inProgress: + icon = Icons.timelapse; + } + + return Icon(icon, size: 14, color: color); + } +} + +class LearnUnitChipRow extends StatefulWidget { + final LearnCourseViewModel course; + final int selectedUnitIndex; + final ValueChanged onUnitSelected; + + const LearnUnitChipRow({ + super.key, + required this.course, + required this.selectedUnitIndex, + required this.onUnitSelected, + }); + + @override + State createState() => _LearnUnitChipRowState(); +} + +class _LearnUnitChipRowState extends State { + late final List _chipKeys; + + @override + void initState() { + super.initState(); + _chipKeys = List.generate(widget.course.units.length, (_) => GlobalKey()); + } + + @override + void didUpdateWidget(LearnUnitChipRow oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.selectedUnitIndex != widget.selectedUnitIndex) { + _scrollToSelectedChip(); + } + } + + void _scrollToSelectedChip() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final index = widget.selectedUnitIndex.clamp( + 0, + widget.course.units.length - 1, + ); + final context = _chipKeys[index].currentContext; + if (context == null) return; + Scrollable.ensureVisible( + context, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + alignment: 0.5, + ); + }); + } + + @override + Widget build(BuildContext context) { + final progress = LearnProgressService.instance; + + return SizedBox( + height: 32, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 20), + itemCount: widget.course.units.length, + separatorBuilder: (_, __) => const SizedBox(width: 6), + itemBuilder: (context, unitIndex) { + final unit = widget.course.units[unitIndex]; + final status = LearnCatalog.unitStatus( + widget.course, + unit, + unitIndex, + progress, + ); + + return KeyedSubtree( + key: _chipKeys[unitIndex], + child: LearnUnitChip( + unit: unit, + status: status, + selected: unitIndex == widget.selectedUnitIndex, + onTap: () => widget.onUnitSelected(unitIndex), + ), + ); + }, + ), + ); + } +} diff --git a/src/mobile/lib/src/app/surveys/pages/survey_list_page.dart b/src/mobile/lib/src/app/surveys/pages/survey_list_page.dart index dadfaa12b2..df5b88be00 100644 --- a/src/mobile/lib/src/app/surveys/pages/survey_list_page.dart +++ b/src/mobile/lib/src/app/surveys/pages/survey_list_page.dart @@ -1,14 +1,9 @@ -import 'package:airqo/src/app/shared/widgets/translated_text.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:collection/collection.dart'; -import 'package:airqo/src/app/surveys/bloc/survey_bloc.dart'; -import 'package:airqo/src/app/surveys/models/survey_model.dart'; -import 'package:airqo/src/app/surveys/models/survey_response_model.dart'; -import 'package:airqo/src/app/surveys/widgets/survey_card.dart'; -import 'package:airqo/src/app/surveys/pages/survey_detail_page.dart'; import 'package:airqo/src/app/shared/services/analytics_service.dart'; +import 'package:airqo/src/app/surveys/bloc/survey_bloc.dart'; +import 'package:airqo/src/app/surveys/widgets/survey_list_content.dart'; import 'package:airqo/src/meta/utils/colors.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; class SurveyListPage extends StatefulWidget { const SurveyListPage({super.key}); @@ -21,17 +16,9 @@ class _SurveyListPageState extends State { @override void initState() { super.initState(); - _loadSurveys(); - // Track survey list page view AnalyticsService().trackSurveyListViewed(); } - void _loadSurveys() { - if (mounted) { - context.read().add(const LoadSurveys()); - } - } - @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -64,556 +51,15 @@ class _SurveyListPageState extends State { : AppColors.boldHeadlineColor4, ), onPressed: () { - context.read().add(const LoadSurveys(forceRefresh: true)); + context + .read() + .add(const LoadSurveys(forceRefresh: true)); }, tooltip: 'Refresh surveys', ), ], ), - body: BlocBuilder( - builder: (context, state) { - if (state is SurveyLoading) { - return _buildLoadingState(); - } else if (state is SurveysLoaded) { - return _buildSurveysLoadedState(state); - } else if (state is SurveyError) { - return _buildErrorState(state); - } else { - return _buildEmptyState(); - } - }, - ), - ); - } - - Widget _buildLoadingState() { - return Center( - child: CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(AppColors.primaryColor), - ), - ); - } - - Widget _buildSurveysLoadedState(SurveysLoaded state) { - if (state.surveys.isEmpty) { - return _buildEmptyState(); - } - - return RefreshIndicator( - onRefresh: () async { - context.read().add(const LoadSurveys(forceRefresh: true)); - }, - child: LayoutBuilder( - builder: (context, constraints) { - final isTablet = constraints.maxWidth > 768; - final isDesktop = constraints.maxWidth > 1024; - final horizontalPadding = isDesktop ? 32.0 : (isTablet ? 24.0 : 16.0); - final maxContentWidth = isDesktop ? 800.0 : double.infinity; - - return SingleChildScrollView( - child: Center( - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: maxContentWidth), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header section - Padding( - padding: EdgeInsets.symmetric( - horizontal: horizontalPadding, - vertical: isTablet ? 24.0 : 16.0, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Available Surveys', - style: TextStyle( - fontSize: isTablet ? 22.0 : 20.0, - fontWeight: FontWeight.w600, - color: Theme.of(context).brightness == Brightness.dark - ? Colors.white - : AppColors.boldHeadlineColor4, - fontFamily: 'Inter', - ), - ), - SizedBox(height: isTablet ? 12.0 : 8.0), - Text( - 'Help us understand how air quality affects your daily life. Your responses are confidential and help improve air quality research.', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).brightness == Brightness.dark - ? AppColors.secondaryHeadlineColor2 - : AppColors.boldHeadlineColor, - height: 1.5, - fontSize: isTablet ? 16.0 : 14.0, - ), - ), - SizedBox(height: isTablet ? 20.0 : 12.0), - _buildResearchImpactCard(state), - SizedBox(height: isTablet ? 24.0 : 16.0), - - // Statistics card - _buildStatsCard(state), - ], - ), - ), - - // Survey list - Padding( - padding: EdgeInsets.symmetric(horizontal: horizontalPadding), - child: Column( - children: state.surveys.map((survey) { - final userResponse = state.userResponses - .firstWhereOrNull((r) => r.surveyId == survey.id); - - return Padding( - padding: EdgeInsets.only(bottom: isTablet ? 16.0 : 12.0), - child: SurveyCard( - survey: survey, - showProgress: userResponse != null && userResponse.isInProgress, - completionPercentage: userResponse?.getCompletionPercentage(survey.questions.length), - onTap: () => _navigateToSurveyDetail(survey, userResponse), - ), - ); - }).toList(), - ), - ), - - // Bottom spacing - SizedBox(height: MediaQuery.of(context).padding.bottom + 16), - ], - ), - ), - ), - ); - }, - ), - ); - } - - Widget _buildResearchImpactCard(SurveysLoaded state) { - final theme = Theme.of(context); - final isDark = theme.brightness == Brightness.dark; - final completedCount = state.userResponses.where((r) => r.isCompleted).length; - - // Calculate estimated research impact based on completed surveys - final totalResponses = completedCount * 1000; // Simulated community data - final researchProgress = (completedCount * 8.3).clamp(0.0, 100.0); // Progress simulation - - return LayoutBuilder( - builder: (context, constraints) { - final isTablet = constraints.maxWidth > 768; - - return Container( - padding: EdgeInsets.all(isTablet ? 20.0 : 16.0), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - AppColors.primaryColor.withValues(alpha: isDark ? 0.15 : 0.08), - (isDark ? Colors.blue.shade800 : Colors.blue.shade50).withValues(alpha: 0.1), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(isTablet ? 16.0 : 12.0), - border: Border.all( - color: AppColors.primaryColor.withValues(alpha: isDark ? 0.4 : 0.2), - width: 1, - ), - boxShadow: [ - BoxShadow( - color: AppColors.primaryColor.withValues(alpha: 0.08), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - padding: EdgeInsets.all(isTablet ? 10.0 : 8.0), - decoration: BoxDecoration( - color: AppColors.primaryColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Icon( - Icons.analytics, - color: AppColors.primaryColor, - size: isTablet ? 24.0 : 20.0, - ), - ), - SizedBox(width: isTablet ? 12.0 : 8.0), - Expanded( - child: Text( - 'Your Research Contribution', - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - color: AppColors.primaryColor, - fontSize: isTablet ? 16.0 : 14.0, - ), - ), - ), - ], - ), - SizedBox(height: isTablet ? 16.0 : 12.0), - if (completedCount > 0) ...[ - Text( - 'You\'ve contributed $completedCount responses, joining ${(totalResponses / 1000).round()}k+ participants', - style: theme.textTheme.bodySmall?.copyWith( - color: isDark - ? AppColors.secondaryHeadlineColor2 - : AppColors.boldHeadlineColor, - fontSize: isTablet ? 15.0 : 13.0, - height: 1.4, - ), - ), - SizedBox(height: isTablet ? 12.0 : 8.0), - Row( - children: [ - Expanded( - child: Container( - height: isTablet ? 8.0 : 6.0, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(4), - ), - child: LinearProgressIndicator( - value: researchProgress / 100, - backgroundColor: isDark - ? AppColors.dividerColordark - : AppColors.dividerColorlight, - valueColor: AlwaysStoppedAnimation(AppColors.primaryColor), - borderRadius: BorderRadius.circular(4), - ), - ), - ), - SizedBox(width: isTablet ? 16.0 : 12.0), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: AppColors.primaryColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - '${researchProgress.round()}%', - style: theme.textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.w700, - color: AppColors.primaryColor, - fontSize: isTablet ? 13.0 : 12.0, - ), - ), - ), - ], - ), - SizedBox(height: isTablet ? 8.0 : 6.0), - Text( - 'Research Progress: Building cleaner air insights', - style: theme.textTheme.bodySmall?.copyWith( - color: isDark - ? AppColors.secondaryHeadlineColor2.withValues(alpha: 0.8) - : AppColors.boldHeadlineColor.withValues(alpha: 0.8), - fontSize: isTablet ? 12.0 : 11.0, - fontStyle: FontStyle.italic, - ), - ), - ] else ...[ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: isDark - ? AppColors.darkThemeBackground.withValues(alpha: 0.5) - : Colors.white.withValues(alpha: 0.7), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: isDark - ? AppColors.dividerColordark - : AppColors.dividerColorlight, - width: 1, - ), - ), - child: Row( - children: [ - Icon( - Icons.lightbulb_outline, - color: isDark ? Colors.amber.shade300 : Colors.amber.shade700, - size: isTablet ? 20.0 : 16.0, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - 'Complete your first survey to see how you\'re contributing to air quality research!', - style: theme.textTheme.bodySmall?.copyWith( - color: isDark - ? AppColors.secondaryHeadlineColor2 - : AppColors.boldHeadlineColor, - fontSize: isTablet ? 14.0 : 12.0, - height: 1.3, - ), - ), - ), - ], - ), - ), - ], - ], - ), - ); - }, - ); - } - - Widget _buildStatsCard(SurveysLoaded state) { - final theme = Theme.of(context); - final completedCount = state.userResponses.where((r) => r.isCompleted).length; - final inProgressCount = state.userResponses.where((r) => r.isInProgress).length; - - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: theme.highlightColor, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.1), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: LayoutBuilder( - builder: (context, constraints) { - final isTablet = constraints.maxWidth > 768; - final isNarrow = constraints.maxWidth < 400; - - return isNarrow - ? Column( - children: [ - Row( - children: [ - Expanded( - child: _buildStatItem( - icon: Icons.check_circle, - label: 'Completed', - value: completedCount.toString(), - color: Colors.green, - isCompact: true, - ), - ), - const SizedBox(width: 16), - Expanded( - child: _buildStatItem( - icon: Icons.pending, - label: 'In Progress', - value: inProgressCount.toString(), - color: Colors.orange, - isCompact: true, - ), - ), - ], - ), - const SizedBox(height: 16), - _buildStatItem( - icon: Icons.quiz, - label: 'Available', - value: state.surveys.length.toString(), - color: AppColors.primaryColor, - isCompact: true, - ), - ], - ) - : Row( - children: [ - Expanded( - child: _buildStatItem( - icon: Icons.check_circle, - label: 'Completed', - value: completedCount.toString(), - color: Colors.green, - isTablet: isTablet, - ), - ), - SizedBox(width: isTablet ? 32.0 : 24.0), - Expanded( - child: _buildStatItem( - icon: Icons.pending, - label: 'In Progress', - value: inProgressCount.toString(), - color: Colors.orange, - isTablet: isTablet, - ), - ), - SizedBox(width: isTablet ? 32.0 : 24.0), - Expanded( - child: _buildStatItem( - icon: Icons.quiz, - label: 'Available', - value: state.surveys.length.toString(), - color: AppColors.primaryColor, - isTablet: isTablet, - ), - ), - ], - ); - }, - ), + body: const SurveyListContent(), ); } - - Widget _buildStatItem({ - required IconData icon, - required String label, - required String value, - required Color color, - bool isTablet = false, - bool isCompact = false, - }) { - final theme = Theme.of(context); - final isDark = theme.brightness == Brightness.dark; - - return Column( - children: [ - Container( - padding: EdgeInsets.all(isTablet ? 12.0 : 8.0), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(isTablet ? 12.0 : 8.0), - ), - child: Icon( - icon, - color: color, - size: isTablet ? 28.0 : (isCompact ? 20.0 : 24.0) - ), - ), - SizedBox(height: isTablet ? 8.0 : 6.0), - Text( - value, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - color: color, - fontSize: isTablet ? 20.0 : (isCompact ? 16.0 : 18.0), - ), - ), - SizedBox(height: isTablet ? 4.0 : 2.0), - Text( - label, - style: theme.textTheme.bodySmall?.copyWith( - color: isDark - ? AppColors.secondaryHeadlineColor2.withValues(alpha: 0.8) - : AppColors.boldHeadlineColor.withValues(alpha: 0.8), - fontSize: isTablet ? 13.0 : (isCompact ? 11.0 : 12.0), - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ); - } - - Widget _buildErrorState(SurveyError state) { - final theme = Theme.of(context); - - return Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.error_outline, - size: 64, - color: Colors.red.withValues(alpha: 0.5), - ), - const SizedBox(height: 16), - Text( - 'Oops! Something went wrong', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - Text( - state.message, - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.7), - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 24), - ElevatedButton( - onPressed: () { - context.read().add(const LoadSurveys()); - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primaryColor, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - child: const TranslatedText('Try Again'), - ), - ], - ), - ), - ); - } - - Widget _buildEmptyState() { - final theme = Theme.of(context); - - return Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.quiz_outlined, - size: 64, - color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.3), - ), - const SizedBox(height: 16), - TranslatedText( - 'No surveys available', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - TranslatedText( - 'Check back later for new research surveys.', - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.7), - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 24), - TextButton( - onPressed: () { - context.read().add(const LoadSurveys(forceRefresh: true)); - }, - child: const TranslatedText('Refresh'), - ), - ], - ), - ), - ); - } - - void _navigateToSurveyDetail(Survey survey, SurveyResponse? existingResponse) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => SurveyDetailPage( - survey: survey, - existingResponse: existingResponse, - ), - ), - ); - } -} \ No newline at end of file +} diff --git a/src/mobile/lib/src/app/surveys/widgets/survey_card.dart b/src/mobile/lib/src/app/surveys/widgets/survey_card.dart index 3ba5205e77..45a0f2f731 100644 --- a/src/mobile/lib/src/app/surveys/widgets/survey_card.dart +++ b/src/mobile/lib/src/app/surveys/widgets/survey_card.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:airqo/src/app/surveys/models/survey_model.dart'; import 'package:airqo/src/meta/utils/colors.dart'; @@ -128,12 +129,13 @@ class SurveyCard extends StatelessWidget { } Widget _buildSurveyIcon(BuildContext context) { - IconData iconData; + IconData? iconData; Color iconColor; + bool useLocationPin = false; switch (survey.trigger.type) { case SurveyTriggerType.locationBased: - iconData = Icons.location_on; + useLocationPin = true; iconColor = Colors.blue; break; case SurveyTriggerType.airQualityThreshold: @@ -160,10 +162,18 @@ class SurveyCard extends StatelessWidget { color: iconColor.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), - child: Icon( - iconData, - color: iconColor, - size: 20, + child: Center( + child: useLocationPin + ? SvgPicture.asset( + 'assets/images/shared/location_pin.svg', + width: 20, + height: 20, + ) + : Icon( + iconData, + color: iconColor, + size: 20, + ), ), ); } diff --git a/src/mobile/lib/src/app/surveys/widgets/survey_list_content.dart b/src/mobile/lib/src/app/surveys/widgets/survey_list_content.dart new file mode 100644 index 0000000000..3bd4c959ac --- /dev/null +++ b/src/mobile/lib/src/app/surveys/widgets/survey_list_content.dart @@ -0,0 +1,606 @@ +import 'package:airqo/src/app/shared/widgets/translated_text.dart'; +import 'package:airqo/src/app/surveys/bloc/survey_bloc.dart'; +import 'package:airqo/src/app/surveys/models/survey_model.dart'; +import 'package:airqo/src/app/surveys/models/survey_response_model.dart'; +import 'package:airqo/src/app/surveys/pages/survey_detail_page.dart'; +import 'package:airqo/src/app/surveys/widgets/survey_card.dart'; +import 'package:airqo/src/meta/utils/colors.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// Scrollable research surveys list — shared by the Learn tab and standalone page. +class SurveyListContent extends StatefulWidget { + const SurveyListContent({super.key}); + + @override + State createState() => _SurveyListContentState(); +} + +class _SurveyListContentState extends State { + @override + void initState() { + super.initState(); + _loadSurveys(); + } + + void _loadSurveys() { + if (mounted) { + context.read().add(const LoadSurveys()); + } + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state is SurveyLoading) { + return _buildLoadingState(); + } else if (state is SurveysLoaded) { + return _buildSurveysLoadedState(state); + } else if (state is SurveyError) { + return _buildErrorState(state); + } else { + return _buildEmptyState(); + } + }, + ); + } + + Widget _buildLoadingState() { + return Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(AppColors.primaryColor), + ), + ); + } + + Widget _buildSurveysLoadedState(SurveysLoaded state) { + if (state.surveys.isEmpty) { + return _buildEmptyState(); + } + + return RefreshIndicator( + onRefresh: () async { + context.read().add(const LoadSurveys(forceRefresh: true)); + }, + child: LayoutBuilder( + builder: (context, constraints) { + final isTablet = constraints.maxWidth > 768; + final isDesktop = constraints.maxWidth > 1024; + final horizontalPadding = + isDesktop ? 32.0 : (isTablet ? 24.0 : 16.0); + final maxContentWidth = isDesktop ? 800.0 : double.infinity; + + return SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxContentWidth), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.symmetric( + horizontal: horizontalPadding, + vertical: isTablet ? 24.0 : 16.0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Available Surveys', + style: TextStyle( + fontSize: isTablet ? 22.0 : 20.0, + fontWeight: FontWeight.w600, + color: Theme.of(context).brightness == + Brightness.dark + ? Colors.white + : AppColors.boldHeadlineColor4, + fontFamily: 'Inter', + ), + ), + SizedBox(height: isTablet ? 12.0 : 8.0), + Text( + 'Help us understand how air quality affects your daily life. Your responses are confidential and help improve air quality research.', + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: Theme.of(context).brightness == + Brightness.dark + ? AppColors.secondaryHeadlineColor2 + : AppColors.boldHeadlineColor, + height: 1.5, + fontSize: isTablet ? 16.0 : 14.0, + ), + ), + SizedBox(height: isTablet ? 20.0 : 12.0), + _buildResearchImpactCard(state), + SizedBox(height: isTablet ? 24.0 : 16.0), + _buildStatsCard(state), + ], + ), + ), + Padding( + padding: + EdgeInsets.symmetric(horizontal: horizontalPadding), + child: Column( + children: state.surveys.map((survey) { + final userResponse = state.userResponses + .firstWhereOrNull((r) => r.surveyId == survey.id); + + return Padding( + padding: EdgeInsets.only( + bottom: isTablet ? 16.0 : 12.0, + ), + child: SurveyCard( + survey: survey, + showProgress: userResponse != null && + userResponse.isInProgress, + completionPercentage: userResponse + ?.getCompletionPercentage( + survey.questions.length), + onTap: () => _navigateToSurveyDetail( + survey, + userResponse, + ), + ), + ); + }).toList(), + ), + ), + SizedBox( + height: MediaQuery.of(context).padding.bottom + 16, + ), + ], + ), + ), + ), + ); + }, + ), + ); + } + + Widget _buildResearchImpactCard(SurveysLoaded state) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final completedCount = + state.userResponses.where((r) => r.isCompleted).length; + final totalResponses = completedCount * 1000; + final researchProgress = (completedCount * 8.3).clamp(0.0, 100.0); + + return LayoutBuilder( + builder: (context, constraints) { + final isTablet = constraints.maxWidth > 768; + + return Container( + padding: EdgeInsets.all(isTablet ? 20.0 : 16.0), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + AppColors.primaryColor.withValues(alpha: isDark ? 0.15 : 0.08), + (isDark ? Colors.blue.shade800 : Colors.blue.shade50) + .withValues(alpha: 0.1), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(isTablet ? 16.0 : 12.0), + border: Border.all( + color: + AppColors.primaryColor.withValues(alpha: isDark ? 0.4 : 0.2), + ), + boxShadow: [ + BoxShadow( + color: AppColors.primaryColor.withValues(alpha: 0.08), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: EdgeInsets.all(isTablet ? 10.0 : 8.0), + decoration: BoxDecoration( + color: AppColors.primaryColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.analytics, + color: AppColors.primaryColor, + size: isTablet ? 24.0 : 20.0, + ), + ), + SizedBox(width: isTablet ? 12.0 : 8.0), + Expanded( + child: Text( + 'Your Research Contribution', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: AppColors.primaryColor, + fontSize: isTablet ? 16.0 : 14.0, + ), + ), + ), + ], + ), + SizedBox(height: isTablet ? 16.0 : 12.0), + if (completedCount > 0) ...[ + Text( + 'You\'ve contributed $completedCount responses, joining ${(totalResponses / 1000).round()}k+ participants', + style: theme.textTheme.bodySmall?.copyWith( + color: isDark + ? AppColors.secondaryHeadlineColor2 + : AppColors.boldHeadlineColor, + fontSize: isTablet ? 15.0 : 13.0, + height: 1.4, + ), + ), + SizedBox(height: isTablet ? 12.0 : 8.0), + Row( + children: [ + Expanded( + child: SizedBox( + height: isTablet ? 8.0 : 6.0, + child: LinearProgressIndicator( + value: researchProgress / 100, + backgroundColor: isDark + ? AppColors.dividerColordark + : AppColors.dividerColorlight, + valueColor: AlwaysStoppedAnimation( + AppColors.primaryColor, + ), + borderRadius: BorderRadius.circular(4), + ), + ), + ), + SizedBox(width: isTablet ? 16.0 : 12.0), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: AppColors.primaryColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '${researchProgress.round()}%', + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w700, + color: AppColors.primaryColor, + fontSize: isTablet ? 13.0 : 12.0, + ), + ), + ), + ], + ), + SizedBox(height: isTablet ? 8.0 : 6.0), + Text( + 'Research Progress: Building cleaner air insights', + style: theme.textTheme.bodySmall?.copyWith( + color: isDark + ? AppColors.secondaryHeadlineColor2.withValues(alpha: 0.8) + : AppColors.boldHeadlineColor.withValues(alpha: 0.8), + fontSize: isTablet ? 12.0 : 11.0, + fontStyle: FontStyle.italic, + ), + ), + ] else ...[ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isDark + ? AppColors.darkThemeBackground.withValues(alpha: 0.5) + : Colors.white.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isDark + ? AppColors.dividerColordark + : AppColors.dividerColorlight, + ), + ), + child: Row( + children: [ + Icon( + Icons.lightbulb_outline, + color: isDark + ? Colors.amber.shade300 + : Colors.amber.shade700, + size: isTablet ? 20.0 : 16.0, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Complete your first survey to see how you\'re contributing to air quality research!', + style: theme.textTheme.bodySmall?.copyWith( + color: isDark + ? AppColors.secondaryHeadlineColor2 + : AppColors.boldHeadlineColor, + fontSize: isTablet ? 14.0 : 12.0, + height: 1.3, + ), + ), + ), + ], + ), + ), + ], + ], + ), + ); + }, + ); + } + + Widget _buildStatsCard(SurveysLoaded state) { + final theme = Theme.of(context); + final completedCount = + state.userResponses.where((r) => r.isCompleted).length; + final inProgressCount = + state.userResponses.where((r) => r.isInProgress).length; + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.highlightColor, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: LayoutBuilder( + builder: (context, constraints) { + final isTablet = constraints.maxWidth > 768; + final isNarrow = constraints.maxWidth < 400; + + return isNarrow + ? Column( + children: [ + Row( + children: [ + Expanded( + child: _buildStatItem( + icon: Icons.check_circle, + label: 'Completed', + value: completedCount.toString(), + color: Colors.green, + isCompact: true, + ), + ), + const SizedBox(width: 16), + Expanded( + child: _buildStatItem( + icon: Icons.pending, + label: 'In Progress', + value: inProgressCount.toString(), + color: Colors.orange, + isCompact: true, + ), + ), + ], + ), + const SizedBox(height: 16), + _buildStatItem( + icon: Icons.quiz, + label: 'Available', + value: state.surveys.length.toString(), + color: AppColors.primaryColor, + isCompact: true, + ), + ], + ) + : Row( + children: [ + Expanded( + child: _buildStatItem( + icon: Icons.check_circle, + label: 'Completed', + value: completedCount.toString(), + color: Colors.green, + isTablet: isTablet, + ), + ), + SizedBox(width: isTablet ? 32.0 : 24.0), + Expanded( + child: _buildStatItem( + icon: Icons.pending, + label: 'In Progress', + value: inProgressCount.toString(), + color: Colors.orange, + isTablet: isTablet, + ), + ), + SizedBox(width: isTablet ? 32.0 : 24.0), + Expanded( + child: _buildStatItem( + icon: Icons.quiz, + label: 'Available', + value: state.surveys.length.toString(), + color: AppColors.primaryColor, + isTablet: isTablet, + ), + ), + ], + ); + }, + ), + ); + } + + Widget _buildStatItem({ + required IconData icon, + required String label, + required String value, + required Color color, + bool isTablet = false, + bool isCompact = false, + }) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + + return Column( + children: [ + Container( + padding: EdgeInsets.all(isTablet ? 12.0 : 8.0), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(isTablet ? 12.0 : 8.0), + ), + child: Icon( + icon, + color: color, + size: isTablet ? 28.0 : (isCompact ? 20.0 : 24.0), + ), + ), + SizedBox(height: isTablet ? 8.0 : 6.0), + Text( + value, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + color: color, + fontSize: isTablet ? 20.0 : (isCompact ? 16.0 : 18.0), + ), + ), + SizedBox(height: isTablet ? 4.0 : 2.0), + Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + color: isDark + ? AppColors.secondaryHeadlineColor2.withValues(alpha: 0.8) + : AppColors.boldHeadlineColor.withValues(alpha: 0.8), + fontSize: isTablet ? 13.0 : (isCompact ? 11.0 : 12.0), + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ); + } + + Widget _buildErrorState(SurveyError state) { + final theme = Theme.of(context); + + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 64, + color: Colors.red.withValues(alpha: 0.5), + ), + const SizedBox(height: 16), + Text( + 'Oops! Something went wrong', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + state.message, + style: theme.textTheme.bodyMedium?.copyWith( + color: + theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.7), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: () { + context.read().add(const LoadSurveys()); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryColor, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const TranslatedText('Try Again'), + ), + ], + ), + ), + ); + } + + Widget _buildEmptyState() { + final theme = Theme.of(context); + + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.quiz_outlined, + size: 64, + color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.3), + ), + const SizedBox(height: 16), + const TranslatedText( + 'No surveys available', + style: TextStyle(fontWeight: FontWeight.w600), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + TranslatedText( + 'Check back later for new research surveys.', + style: theme.textTheme.bodyMedium?.copyWith( + color: + theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.7), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + TextButton( + onPressed: () { + context + .read() + .add(const LoadSurveys(forceRefresh: true)); + }, + child: const TranslatedText('Refresh'), + ), + ], + ), + ), + ); + } + + void _navigateToSurveyDetail( + Survey survey, + SurveyResponse? existingResponse, + ) { + Navigator.of(context) + .push( + MaterialPageRoute( + builder: (context) => SurveyDetailPage( + survey: survey, + existingResponse: existingResponse, + ), + ), + ) + .then((_) { + if (!mounted) return; + context.read().add(const LoadSurveys()); + }); + } +} diff --git a/src/mobile/pubspec.lock b/src/mobile/pubspec.lock index 062f6d14c5..f4ecb12293 100644 --- a/src/mobile/pubspec.lock +++ b/src/mobile/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" + sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f url: "https://pub.dev" source: hosted - version: "93.0.0" + version: "85.0.0" _flutterfire_internals: dependency: transitive description: @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: analyzer - sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b + sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d" url: "https://pub.dev" source: hosted - version: "10.0.1" + version: "7.7.1" args: dependency: transitive description: @@ -85,18 +85,18 @@ packages: dependency: transitive description: name: build - sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10 + sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7" url: "https://pub.dev" source: hosted - version: "4.0.6" + version: "2.5.4" build_config: dependency: transitive description: name: build_config - sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.1.2" build_daemon: dependency: transitive description: @@ -105,14 +105,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62 + url: "https://pub.dev" + source: hosted + version: "2.5.4" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6" + sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792" url: "https://pub.dev" source: hosted - version: "2.15.0" + version: "9.1.2" built_collection: dependency: transitive description: @@ -133,10 +149,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" checked_yaml: dependency: transitive description: @@ -245,10 +261,10 @@ packages: dependency: transitive description: name: dart_style - sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2" + sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" url: "https://pub.dev" source: hosted - version: "3.1.7" + version: "3.1.1" dbus: dependency: transitive description: @@ -309,10 +325,10 @@ packages: dependency: transitive description: name: fake_async - sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" url: "https://pub.dev" source: hosted - version: "1.3.3" + version: "1.3.2" ffi: dependency: transitive description: @@ -584,6 +600,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_tts: + dependency: "direct main" + description: + name: flutter_tts + sha256: ce5eb209b40e95f2f4a1397116c87ab2fcdff32257d04ed7a764e75894c03775 + url: "https://pub.dev" + source: hosted + version: "4.2.5" flutter_web_auth_2: dependency: "direct main" description: @@ -889,10 +913,10 @@ packages: dependency: "direct main" description: name: intl - sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf url: "https://pub.dev" source: hosted - version: "0.20.2" + version: "0.19.0" io: dependency: transitive description: @@ -921,10 +945,10 @@ packages: dependency: "direct dev" description: name: json_serializable - sha256: "5b89c1e32ae3840bb20a1b3434e3a590173ad3cb605896fb0f60487ce2f8104e" + sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c url: "https://pub.dev" source: hosted - version: "6.11.4" + version: "6.9.5" jwt_decoder: dependency: "direct main" description: @@ -937,26 +961,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec url: "https://pub.dev" source: hosted - version: "11.0.2" + version: "10.0.8" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 url: "https://pub.dev" source: hosted - version: "3.0.10" + version: "3.0.9" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.0.1" lints: dependency: transitive description: @@ -993,26 +1017,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.16.0" mime: dependency: transitive description: @@ -1025,10 +1049,10 @@ packages: dependency: "direct main" description: name: mockito - sha256: eff30d002f0c8bf073b6f929df4483b543133fcafce056870163587b03f1d422 + sha256: "4546eac99e8967ea91bae633d2ca7698181d008e95fa4627330cf903d573277a" url: "https://pub.dev" source: hosted - version: "5.6.4" + version: "5.4.6" mocktail: dependency: transitive description: @@ -1118,7 +1142,7 @@ packages: source: hosted version: "1.1.0" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" @@ -1293,6 +1317,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + screenshot: + dependency: "direct main" + description: + name: screenshot + sha256: "63817697a7835e6ce82add4228e15d233b74d42975c143ad8cfe07009fab866b" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da + url: "https://pub.dev" + source: hosted + version: "10.1.4" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b + url: "https://pub.dev" + source: hosted + version: "5.0.2" shared_preferences: dependency: "direct main" description: @@ -1406,18 +1454,18 @@ packages: dependency: transitive description: name: source_gen - sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02 + sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" url: "https://pub.dev" source: hosted - version: "4.2.3" + version: "2.0.0" source_helper: dependency: transitive description: name: source_helper - sha256: "4227d54ceefd0bb8ca4c8fcb96e1719dc53f1ee1b6e2ca9d7a6069da160e4eae" + sha256: a447acb083d3a5ef17f983dd36201aeea33fedadb3228fa831f2f0c92f0f3aca url: "https://pub.dev" source: hosted - version: "1.3.12" + version: "1.3.7" source_map_stack_trace: dependency: transitive description: @@ -1502,26 +1550,26 @@ packages: dependency: transitive description: name: test - sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" + sha256: "301b213cd241ca982e9ba50266bd3f5bd1ea33f1455554c5abb85d1be0e2d87e" url: "https://pub.dev" source: hosted - version: "1.30.0" + version: "1.25.15" test_api: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.4" test_core: dependency: transitive description: name: test_core - sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" + sha256: "84d17c3486c8dfdbe5e12a50c8ae176d15e2a771b96909a9442b40173649ccaa" url: "https://pub.dev" source: hosted - version: "0.6.16" + version: "0.6.8" timezone: dependency: transitive description: @@ -1530,6 +1578,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.10.1" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.dev" + source: hosted + version: "1.0.2" typed_data: dependency: transitive description: @@ -1654,10 +1710,50 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.1.4" + video_player: + dependency: "direct main" + description: + name: video_player + sha256: "096bc28ce10d131be80dfb00c223024eb0fba301315a406728ab43dd99c45bdf" + url: "https://pub.dev" + source: hosted + version: "2.10.1" + video_player_android: + dependency: transitive + description: + name: video_player_android + sha256: a8dc4324f67705de057678372bedb66cd08572fe7c495605ac68c5f503324a39 + url: "https://pub.dev" + source: hosted + version: "2.8.15" + video_player_avfoundation: + dependency: transitive + description: + name: video_player_avfoundation + sha256: f9a780aac57802b2892f93787e5ea53b5f43cc57dc107bee9436458365be71cd + url: "https://pub.dev" + source: hosted + version: "2.8.4" + video_player_platform_interface: + dependency: transitive + description: + name: video_player_platform_interface + sha256: "57c5d73173f76d801129d0531c2774052c5a7c11ccb962f1830630decd9f24ec" + url: "https://pub.dev" + source: hosted + version: "6.6.0" + video_player_web: + dependency: transitive + description: + name: video_player_web + sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb" + url: "https://pub.dev" + source: hosted + version: "2.4.0" vm_service: dependency: transitive description: @@ -1706,6 +1802,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + webview_flutter: + dependency: transitive + description: + name: webview_flutter + sha256: c3e4fe614b1c814950ad07186007eff2f2e5dd2935eba7b9a9a1af8e5885f1ba + url: "https://pub.dev" + source: hosted + version: "4.13.0" + webview_flutter_android: + dependency: transitive + description: + name: webview_flutter_android + sha256: "9a25f6b4313978ba1c2cda03a242eea17848174912cfb4d2d8ee84a556f248e3" + url: "https://pub.dev" + source: hosted + version: "4.10.1" + webview_flutter_platform_interface: + dependency: transitive + description: + name: webview_flutter_platform_interface + sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0" + url: "https://pub.dev" + source: hosted + version: "2.14.0" + webview_flutter_wkwebview: + dependency: transitive + description: + name: webview_flutter_wkwebview + sha256: fb46db8216131a3e55bcf44040ca808423539bc6732e7ed34fb6d8044e3d512f + url: "https://pub.dev" + source: hosted + version: "3.23.0" win32: dependency: transitive description: @@ -1754,6 +1882,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.3" + youtube_player_iframe: + dependency: "direct main" + description: + name: youtube_player_iframe + sha256: "66020f7756accfb22b3297565d845f9bef14249c730dd51e1ec648fa155fb24a" + url: "https://pub.dev" + source: hosted + version: "5.2.1" + youtube_player_iframe_web: + dependency: transitive + description: + name: youtube_player_iframe_web + sha256: "05222a228937932e7ee7a6171e8020fee4cd23d1c7bf6b4128c569484338c593" + url: "https://pub.dev" + source: hosted + version: "3.1.1" sdks: - dart: ">=3.10.0 <4.0.0" + dart: ">=3.7.0 <4.0.0" flutter: ">=3.29.0" diff --git a/src/mobile/pubspec.yaml b/src/mobile/pubspec.yaml index d3d332620b..8cbe17b6aa 100644 --- a/src/mobile/pubspec.yaml +++ b/src/mobile/pubspec.yaml @@ -86,6 +86,12 @@ dependencies: device_info_plus: ^10.1.0 uuid: ^4.5.1 flutter_web_auth_2: ^5.0.0 + flutter_tts: ^4.2.0 + video_player: ^2.9.2 + youtube_player_iframe: ^5.2.1 + share_plus: ^10.1.4 + screenshot: ^3.0.0 + path_provider: ^2.1.5 dev_dependencies: From c086d7b73d9b183728013dd3b70754277896b903 Mon Sep 17 00:00:00 2001 From: 2phonebabykeem Date: Wed, 17 Jun 2026 20:34:00 +0300 Subject: [PATCH 3/5] fix(android): resolve minSdk conflict with staging for flutter_tts 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 --- src/mobile/android/app/build.gradle | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/mobile/android/app/build.gradle b/src/mobile/android/app/build.gradle index 0202156fec..5b76ef6a2e 100644 --- a/src/mobile/android/app/build.gradle +++ b/src/mobile/android/app/build.gradle @@ -98,7 +98,8 @@ android { applicationId "com.airqo.app" // You can update the following values to match your application needs. // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. - minSdkVersion 24 + // flutter_tts requires API 24+; honour Flutter's minSdk when higher. + minSdkVersion Math.max(flutter.minSdkVersion, 24) targetSdkVersion 36 versionCode flutterVersionCode.toInteger() versionName flutterVersionName From 7393982f501fb78d8d5c10be86cac4ddd5fbe3f9 Mon Sep 17 00:00:00 2001 From: Mozart299 Date: Wed, 17 Jun 2026 20:37:17 +0300 Subject: [PATCH 4/5] fix(android): update minSdkVersion to use flutter.minSdkVersion fix(learn): add missing break statements in status icon switch case chore(deps): update package versions in pubspec.lock --- src/mobile/android/app/build.gradle | 2 +- .../app/learn/widgets/learn_unit_chip.dart | 3 + src/mobile/pubspec.lock | 118 +++++++----------- 3 files changed, 51 insertions(+), 72 deletions(-) diff --git a/src/mobile/android/app/build.gradle b/src/mobile/android/app/build.gradle index 0202156fec..58e5d5b28f 100644 --- a/src/mobile/android/app/build.gradle +++ b/src/mobile/android/app/build.gradle @@ -98,7 +98,7 @@ android { applicationId "com.airqo.app" // You can update the following values to match your application needs. // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. - minSdkVersion 24 + minSdkVersion flutter.minSdkVersion targetSdkVersion 36 versionCode flutterVersionCode.toInteger() versionName flutterVersionName diff --git a/src/mobile/lib/src/app/learn/widgets/learn_unit_chip.dart b/src/mobile/lib/src/app/learn/widgets/learn_unit_chip.dart index 8eb65ddfda..51544c4e51 100644 --- a/src/mobile/lib/src/app/learn/widgets/learn_unit_chip.dart +++ b/src/mobile/lib/src/app/learn/widgets/learn_unit_chip.dart @@ -90,10 +90,13 @@ class _StatusIcon extends StatelessWidget { switch (status) { case LearnUnitStatus.locked: icon = Icons.lock_outline; + break; case LearnUnitStatus.completed: icon = LearnDesignTokens.completedCheckIcon; + break; case LearnUnitStatus.inProgress: icon = Icons.timelapse; + break; } return Icon(icon, size: 14, color: color); diff --git a/src/mobile/pubspec.lock b/src/mobile/pubspec.lock index f4ecb12293..6a696c4419 100644 --- a/src/mobile/pubspec.lock +++ b/src/mobile/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f + sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" url: "https://pub.dev" source: hosted - version: "85.0.0" + version: "93.0.0" _flutterfire_internals: dependency: transitive description: @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: analyzer - sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d" + sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b url: "https://pub.dev" source: hosted - version: "7.7.1" + version: "10.0.1" args: dependency: transitive description: @@ -85,18 +85,18 @@ packages: dependency: transitive description: name: build - sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7" + sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10 url: "https://pub.dev" source: hosted - version: "2.5.4" + version: "4.0.6" build_config: dependency: transitive description: name: build_config - sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.3.0" build_daemon: dependency: transitive description: @@ -105,30 +105,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" - build_resolvers: - dependency: transitive - description: - name: build_resolvers - sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62 - url: "https://pub.dev" - source: hosted - version: "2.5.4" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53" + sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6" url: "https://pub.dev" source: hosted - version: "2.5.4" - build_runner_core: - dependency: transitive - description: - name: build_runner_core - sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792" - url: "https://pub.dev" - source: hosted - version: "9.1.2" + version: "2.15.0" built_collection: dependency: transitive description: @@ -149,10 +133,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -261,10 +245,10 @@ packages: dependency: transitive description: name: dart_style - sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" + sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2" url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.1.7" dbus: dependency: transitive description: @@ -325,10 +309,10 @@ packages: dependency: transitive description: name: fake_async - sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.3.3" ffi: dependency: transitive description: @@ -913,10 +897,10 @@ packages: dependency: "direct main" description: name: intl - sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" url: "https://pub.dev" source: hosted - version: "0.19.0" + version: "0.20.2" io: dependency: transitive description: @@ -945,10 +929,10 @@ packages: dependency: "direct dev" description: name: json_serializable - sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c + sha256: "5b89c1e32ae3840bb20a1b3434e3a590173ad3cb605896fb0f60487ce2f8104e" url: "https://pub.dev" source: hosted - version: "6.9.5" + version: "6.11.4" jwt_decoder: dependency: "direct main" description: @@ -961,26 +945,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "10.0.8" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.9" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" lints: dependency: transitive description: @@ -1017,26 +1001,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -1049,10 +1033,10 @@ packages: dependency: "direct main" description: name: mockito - sha256: "4546eac99e8967ea91bae633d2ca7698181d008e95fa4627330cf903d573277a" + sha256: eff30d002f0c8bf073b6f929df4483b543133fcafce056870163587b03f1d422 url: "https://pub.dev" source: hosted - version: "5.4.6" + version: "5.6.4" mocktail: dependency: transitive description: @@ -1454,18 +1438,18 @@ packages: dependency: transitive description: name: source_gen - sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" + sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02 url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "4.2.3" source_helper: dependency: transitive description: name: source_helper - sha256: a447acb083d3a5ef17f983dd36201aeea33fedadb3228fa831f2f0c92f0f3aca + sha256: "4227d54ceefd0bb8ca4c8fcb96e1719dc53f1ee1b6e2ca9d7a6069da160e4eae" url: "https://pub.dev" source: hosted - version: "1.3.7" + version: "1.3.12" source_map_stack_trace: dependency: transitive description: @@ -1550,26 +1534,26 @@ packages: dependency: transitive description: name: test - sha256: "301b213cd241ca982e9ba50266bd3f5bd1ea33f1455554c5abb85d1be0e2d87e" + sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" url: "https://pub.dev" source: hosted - version: "1.25.15" + version: "1.30.0" test_api: dependency: transitive description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.4" + version: "0.7.10" test_core: dependency: transitive description: name: test_core - sha256: "84d17c3486c8dfdbe5e12a50c8ae176d15e2a771b96909a9442b40173649ccaa" + sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" url: "https://pub.dev" source: hosted - version: "0.6.8" + version: "0.6.16" timezone: dependency: transitive description: @@ -1578,14 +1562,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.10.1" - timing: - dependency: transitive - description: - name: timing - sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" - url: "https://pub.dev" - source: hosted - version: "1.0.2" typed_data: dependency: transitive description: @@ -1710,10 +1686,10 @@ packages: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" video_player: dependency: "direct main" description: @@ -1899,5 +1875,5 @@ packages: source: hosted version: "3.1.1" sdks: - dart: ">=3.7.0 <4.0.0" + dart: ">=3.10.0 <4.0.0" flutter: ">=3.29.0" From 4eb64c6bbcc654570b0152d5d7ace3bc7f8e0839 Mon Sep 17 00:00:00 2001 From: 2phonebabykeem Date: Wed, 17 Jun 2026 21:09:53 +0300 Subject: [PATCH 5/5] fix(mobile): address PR review for learn quiz UX and scoring 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 --- .../pages/forecast_overview_page.dart | 12 ++-- .../learn/pages/learn_course_detail_page.dart | 56 ++++++++++++++++--- .../services/learn_quiz_scoring_service.dart | 20 ++++--- .../widgets/experience/learn_quiz_option.dart | 54 ++++++++++-------- .../learn/widgets/learn_bottom_sheets.dart | 13 ++++- 5 files changed, 109 insertions(+), 46 deletions(-) diff --git a/src/mobile/lib/src/app/dashboard/pages/forecast_overview_page.dart b/src/mobile/lib/src/app/dashboard/pages/forecast_overview_page.dart index 74839af370..deb54aec54 100644 --- a/src/mobile/lib/src/app/dashboard/pages/forecast_overview_page.dart +++ b/src/mobile/lib/src/app/dashboard/pages/forecast_overview_page.dart @@ -131,8 +131,10 @@ class _ForecastOverviewPageState extends State { final todayIdx = forecasts.indexWhere((f) => _fmtDate(f.time) == _todayStr); if (todayIdx > 0) { - WidgetsBinding.instance.addPostFrameCallback( - (_) => setState(() => _selectedDayIndex = todayIdx)); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + setState(() => _selectedDayIndex = todayIdx); + }); } } } @@ -141,8 +143,10 @@ class _ForecastOverviewPageState extends State { if (_timeScope != ForecastTimeScope.hourly || entries.isEmpty) return; final defaultIdx = defaultHourlyIndex(entries, day); if (_selectedHourIndex == 0 && defaultIdx != 0) { - WidgetsBinding.instance.addPostFrameCallback( - (_) => setState(() => _selectedHourIndex = defaultIdx)); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + setState(() => _selectedHourIndex = defaultIdx); + }); } } diff --git a/src/mobile/lib/src/app/learn/pages/learn_course_detail_page.dart b/src/mobile/lib/src/app/learn/pages/learn_course_detail_page.dart index 2852c51490..f25a0de08f 100644 --- a/src/mobile/lib/src/app/learn/pages/learn_course_detail_page.dart +++ b/src/mobile/lib/src/app/learn/pages/learn_course_detail_page.dart @@ -60,13 +60,51 @@ class _LearnCourseDetailPageState extends State { final course = widget.course; final completed = course.completedLessons(progress); final total = course.totalLessons; - final selectedUnit = course.units[_selectedUnitIndex.clamp( - 0, - course.units.length - 1, - )]; + + if (course.units.isEmpty) { + return DraggableScrollableSheet( + initialChildSize: 0.88, + minChildSize: 0.5, + maxChildSize: 0.95, + expand: false, + builder: (ctx, scrollCtrl) { + return Container( + decoration: BoxDecoration( + color: bg, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: ListView( + controller: scrollCtrl, + padding: const EdgeInsets.all(20), + children: [ + LearnDesignTokens.dragHandle(context), + TranslatedText( + course.plainTitleKey, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + color: titleColor, + ), + ), + const SizedBox(height: 12), + TranslatedText( + 'No units available for this course yet.', + style: TextStyle(fontSize: 14, color: subtitleColor), + ), + ], + ), + ); + }, + ); + } + + final safeUnitIndex = + _selectedUnitIndex.clamp(0, course.units.length - 1); + final selectedUnit = course.units[safeUnitIndex]; final unitUnlocked = LearnCatalog.isUnitUnlocked( course, - _selectedUnitIndex, + safeUnitIndex, progress, ); @@ -135,7 +173,7 @@ class _LearnCourseDetailPageState extends State { const SizedBox(height: 12), LearnUnitChipRow( course: course, - selectedUnitIndex: _selectedUnitIndex, + selectedUnitIndex: safeUnitIndex, onUnitSelected: (index) { setState(() => _selectedUnitIndex = index); }, @@ -144,7 +182,7 @@ class _LearnCourseDetailPageState extends State { padding: const EdgeInsets.fromLTRB(20, 10, 20, 0), child: TranslatedText( learnUnitHeader( - _selectedUnitIndex, + safeUnitIndex, selectedUnit.plainTitleKey, ), style: TextStyle( @@ -180,7 +218,7 @@ class _LearnCourseDetailPageState extends State { return LearnLessonListRow( slot: slot, - unitIndex: _selectedUnitIndex, + unitIndex: safeUnitIndex, lessonIndex: lessonIndex, locked: locked, complete: complete, @@ -189,7 +227,7 @@ class _LearnCourseDetailPageState extends State { ? () => widget.onLessonTap( course, selectedUnit, - _selectedUnitIndex, + safeUnitIndex, lessonIndex, slot, ) diff --git a/src/mobile/lib/src/app/learn/services/learn_quiz_scoring_service.dart b/src/mobile/lib/src/app/learn/services/learn_quiz_scoring_service.dart index 94924c3c62..c14ea191e3 100644 --- a/src/mobile/lib/src/app/learn/services/learn_quiz_scoring_service.dart +++ b/src/mobile/lib/src/app/learn/services/learn_quiz_scoring_service.dart @@ -75,16 +75,20 @@ class LearnQuizScoringService { required List gradedQuizResults, String? freeTextResponse, }) { - final graded = gradedQuizResults.where((r) => r).length; + final correct = gradedQuizResults.where((r) => r).length; final totalGraded = gradedQuizResults.length; - final ratio = totalGraded == 0 ? 1.0 : graded / totalGraded; + final ratio = totalGraded == 0 ? 1.0 : correct / totalGraded; - final stars = graded >= totalGraded && totalGraded > 0 - ? 3 - : graded >= (totalGraded / 2).ceil() - ? 2 - : 1; - final points = stars * 10; + // Points come directly from correct answers (10 per correct quiz). + final points = correct * 10; + + final stars = totalGraded == 0 + ? 1 + : correct == totalGraded + ? 3 + : correct >= (totalGraded / 2).ceil() + ? 2 + : 1; return LearnLessonResult( stars: stars, diff --git a/src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_option.dart b/src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_option.dart index abe9ae1452..09e45feea4 100644 --- a/src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_option.dart +++ b/src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_option.dart @@ -101,6 +101,26 @@ class _LeadingIndicator extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(context); + Color borderColor = theme.dividerColor; + Color fillColor = Colors.transparent; + final showCheck = revealed + ? isCorrectOption || selected + : selected; + + if (revealed) { + if (isCorrectOption) { + borderColor = LearnDesignTokens.success; + fillColor = LearnDesignTokens.success; + } else if (selected) { + borderColor = LearnDesignTokens.error; + fillColor = LearnDesignTokens.error; + } + } else if (selected) { + borderColor = AppColors.primaryColor; + fillColor = AppColors.primaryColor; + } + if (showCheckbox) { return Container( width: 20, @@ -108,25 +128,15 @@ class _LeadingIndicator extends StatelessWidget { decoration: BoxDecoration( borderRadius: BorderRadius.circular(4), border: Border.all( - color: selected || (revealed && isCorrectOption) - ? (isCorrectOption || selected - ? LearnDesignTokens.success - : LearnDesignTokens.error) - : Theme.of(context).dividerColor, - width: 2, + color: borderColor, + width: selected || (revealed && isCorrectOption) ? 2 : 1, ), - color: selected || (revealed && isCorrectOption) - ? (isCorrectOption - ? LearnDesignTokens.success - : LearnDesignTokens.error) + color: showCheck && fillColor != Colors.transparent + ? fillColor : Colors.transparent, ), - child: (selected || (revealed && isCorrectOption)) - ? Icon( - Icons.check, - size: 12, - color: isCorrectOption || selected ? Colors.white : Colors.white, - ) + child: showCheck + ? const Icon(Icons.check, size: 12, color: Colors.white) : null, ); } @@ -137,16 +147,14 @@ class _LeadingIndicator extends StatelessWidget { decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( - color: selected || (revealed && isCorrectOption) - ? LearnDesignTokens.success - : Theme.of(context).dividerColor, - width: 2, + color: borderColor, + width: selected || (revealed && isCorrectOption) ? 2 : 1, ), - color: selected || (revealed && isCorrectOption) - ? LearnDesignTokens.success + color: showCheck && fillColor != Colors.transparent + ? fillColor : Colors.transparent, ), - child: (selected || (revealed && isCorrectOption)) + child: showCheck ? const Icon(Icons.check, size: 12, color: Colors.white) : null, ); diff --git a/src/mobile/lib/src/app/learn/widgets/learn_bottom_sheets.dart b/src/mobile/lib/src/app/learn/widgets/learn_bottom_sheets.dart index a920c9857a..2749b747b0 100644 --- a/src/mobile/lib/src/app/learn/widgets/learn_bottom_sheets.dart +++ b/src/mobile/lib/src/app/learn/widgets/learn_bottom_sheets.dart @@ -119,7 +119,16 @@ class LearnBottomSheets { final courses = allCourses; if (courses == null) return; - final course = courses.firstWhere((c) => c.id == continuation.learnCourseId); + final courseIndex = + courses.indexWhere((c) => c.id == continuation.learnCourseId); + if (courseIndex == -1) return; + + final course = courses[courseIndex]; + if (continuation.unitIndex < 0 || + continuation.unitIndex >= course.units.length) { + return; + } + final unit = course.units[continuation.unitIndex]; final chain = LearnCatalog.continuationFor( course, @@ -326,7 +335,7 @@ class LearnBottomSheets { unitPlainTitle: unitPlainTitle ?? 'Lesson', lessonNumberInUnit: lessonNumberInUnit, lessonsInUnit: lessonsInUnit, - allCourses: const [], + allCourses: null, continuation: continuation, ); }