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/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..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 @@ -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,189 @@ 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((_) { + if (!mounted) return; + 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((_) { + if (!mounted) return; + 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 +317,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 +429,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 +451,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/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/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 0e4371fa62..f40febf651 100644 --- a/src/mobile/lib/src/app/dashboard/widgets/analytics_specifics.dart +++ b/src/mobile/lib/src/app/dashboard/widgets/analytics_specifics.dart @@ -4,6 +4,7 @@ import 'dart:ui' as ui; import 'package:airqo/src/app/dashboard/models/airquality_response.dart'; import 'package:airqo/src/app/dashboard/widgets/air_quality_share_card.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/app/shared/services/air_quality_share_service.dart'; @@ -11,6 +12,7 @@ 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/rendering.dart'; +import 'package:flutter_svg/flutter_svg.dart'; enum _ShareAction { quickText, card } @@ -247,7 +249,7 @@ class _AnalyticsSpecificsState extends State { onTap: () => Navigator.pop(context), child: Icon( Icons.close, - color: nameColor, + color: AppTextColors.modalCloseIcon(context), ), ), ], @@ -257,10 +259,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( @@ -292,18 +294,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/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/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/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..f25a0de08f --- /dev/null +++ b/src/mobile/lib/src/app/learn/pages/learn_course_detail_page.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/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; + + 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, + safeUnitIndex, + 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: safeUnitIndex, + onUnitSelected: (index) { + setState(() => _selectedUnitIndex = index); + }, + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 10, 20, 0), + child: TranslatedText( + learnUnitHeader( + safeUnitIndex, + 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: safeUnitIndex, + lessonIndex: lessonIndex, + locked: locked, + complete: complete, + progressRatio: ratio, + onOpen: canOpen + ? () => widget.onLessonTap( + course, + selectedUnit, + safeUnitIndex, + 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..c14ea191e3 --- /dev/null +++ b/src/mobile/lib/src/app/learn/services/learn_quiz_scoring_service.dart @@ -0,0 +1,108 @@ +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 correct = gradedQuizResults.where((r) => r).length; + final totalGraded = gradedQuizResults.length; + final ratio = totalGraded == 0 ? 1.0 : correct / totalGraded; + + // 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, + 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..09e45feea4 --- /dev/null +++ b/src/mobile/lib/src/app/learn/widgets/experience/learn_quiz_option.dart @@ -0,0 +1,198 @@ +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) { + 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, + height: 20, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: borderColor, + width: selected || (revealed && isCorrectOption) ? 2 : 1, + ), + color: showCheck && fillColor != Colors.transparent + ? fillColor + : Colors.transparent, + ), + child: showCheck + ? const Icon(Icons.check, size: 12, color: Colors.white) + : null, + ); + } + + return Container( + width: 20, + height: 20, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: borderColor, + width: selected || (revealed && isCorrectOption) ? 2 : 1, + ), + color: showCheck && fillColor != Colors.transparent + ? fillColor + : Colors.transparent, + ), + child: showCheck + ? 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..2749b747b0 --- /dev/null +++ b/src/mobile/lib/src/app/learn/widgets/learn_bottom_sheets.dart @@ -0,0 +1,342 @@ +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 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, + 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: null, + 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..51544c4e51 --- /dev/null +++ b/src/mobile/lib/src/app/learn/widgets/learn_unit_chip.dart @@ -0,0 +1,190 @@ +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; + break; + case LearnUnitStatus.completed: + icon = LearnDesignTokens.completedCheckIcon; + break; + case LearnUnitStatus.inProgress: + icon = Icons.timelapse; + break; + } + + 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/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/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/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.lock b/src/mobile/pubspec.lock index 1262273700..1beca1b87d 100644 --- a/src/mobile/pubspec.lock +++ b/src/mobile/pubspec.lock @@ -5,34 +5,34 @@ 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: name: _flutterfire_internals - sha256: bda3b7b55958bfd867addc40d067b4b11f7b8846d57671f5b5a6e7f9a56fe3ad + sha256: "78f98c1f9c4dbbd22c2bb7b7f17c4a5c06150e8b2cb791a0947979ad0d3dabd5" url: "https://pub.dev" source: hosted - version: "1.3.69" + version: "1.3.73" analyzer: 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: name: args - sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 url: "https://pub.dev" source: hosted - version: "2.6.0" + version: "2.7.0" async: dependency: transitive description: @@ -45,10 +45,10 @@ packages: dependency: "direct main" description: name: battery_plus - sha256: a0409fe7d21905987eb1348ad57c634f913166f14f0c8936b73d3f5940fac551 + sha256: "03d5a6bb36db9d2b977c548f6b0262d5a84c4d5a4cfee2edac4a91d57011b365" url: "https://pub.dev" source: hosted - version: "6.2.1" + version: "6.2.3" battery_plus_platform_interface: dependency: transitive description: @@ -85,34 +85,50 @@ 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: name: build_daemon - sha256: "79b2aef6ac2ed00046867ed354c88778c9c0f029df8a20fe10b5436826721ef9" + sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 url: "https://pub.dev" source: hosted - version: "4.0.2" + version: "4.1.1" + 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.15.0" + 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" built_collection: dependency: transitive description: @@ -125,18 +141,18 @@ packages: dependency: transitive description: name: built_value - sha256: "0730c18c770d05636a8f945c32a4d7d81cb6e0f0148c8db4ad12e7748f7e49af" + sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56" url: "https://pub.dev" source: hosted - version: "8.12.5" + version: "8.12.6" characters: 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: @@ -165,10 +181,10 @@ packages: dependency: transitive description: name: code_builder - sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" url: "https://pub.dev" source: hosted - version: "4.10.1" + version: "4.11.1" collection: dependency: "direct main" description: @@ -181,18 +197,18 @@ packages: dependency: "direct main" description: name: connectivity_plus - sha256: e0817759ec6d2d8e57eb234e6e57d2173931367a865850c7acea40d4b4f9c27d + sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec url: "https://pub.dev" source: hosted - version: "6.1.1" + version: "6.1.5" connectivity_plus_platform_interface: dependency: transitive description: name: connectivity_plus_platform_interface - sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204" + sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.1.0" convert: dependency: transitive description: @@ -205,26 +221,26 @@ packages: dependency: transitive description: name: coverage - sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d" url: "https://pub.dev" source: hosted - version: "1.15.0" + version: "1.15.1" cross_file: dependency: transitive description: name: cross_file - sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + sha256: "942a4791cd385a68ccb3b32c71c427aba508a1bb949b86dff2adbe4049f16239" url: "https://pub.dev" source: hosted - version: "0.3.4+2" + version: "0.3.5" crypto: dependency: transitive description: name: crypto - sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf url: "https://pub.dev" source: hosted - version: "3.0.6" + version: "3.0.7" csslib: dependency: transitive description: @@ -245,26 +261,26 @@ 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: name: dbus - sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac" + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.12" desktop_webview_window: dependency: transitive description: name: desktop_webview_window - sha256: "57cf20d81689d5cbb1adfd0017e96b669398a669d927906073b0e42fc64111c0" + sha256: b6fdae2cbf9571879b1761c12f27facaf82e22d0bdc74d049907c2a09a432957 url: "https://pub.dev" source: hosted - version: "0.2.3" + version: "0.3.0" device_info_plus: dependency: "direct main" description: @@ -301,26 +317,26 @@ packages: dependency: "direct main" description: name: equatable - sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" url: "https://pub.dev" source: hosted - version: "2.0.7" + version: "2.0.8" fake_async: 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: name: ffi - sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.2.0" file: dependency: transitive description: @@ -341,18 +357,18 @@ packages: dependency: transitive description: name: file_selector_macos - sha256: "271ab9986df0c135d45c3cdb6bd0faa5db6f4976d3e4b437cf7d0f258d941bfc" + sha256: "19124ff4a3d8864fdc62072b6a2ef6c222d55a3404fe14893a3c02744907b60c" url: "https://pub.dev" source: hosted - version: "0.9.4+2" + version: "0.9.4+4" file_selector_platform_interface: dependency: transitive description: name: file_selector_platform_interface - sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" url: "https://pub.dev" source: hosted - version: "2.6.2" + version: "2.7.0" file_selector_windows: dependency: transitive description: @@ -365,50 +381,50 @@ packages: dependency: "direct main" description: name: firebase_core - sha256: d5a94b884dcb1e6d3430298e94bfe002238094cdfd5e29202d536ee2120f9158 + sha256: d2625088d8f8836a7a74a7eb94a5372d70ad88382602ba2dcc02805c294d0d16 url: "https://pub.dev" source: hosted - version: "4.7.0" + version: "4.11.0" firebase_core_platform_interface: dependency: transitive description: name: firebase_core_platform_interface - sha256: "0ecda14c1bfc9ed8cac303dd0f8d04a320811b479362a9a4efb14fd331a473ce" + sha256: "913e7c96ef83a80ad7e1c3f8a059167b3de23b5d5e07fa3ed8f11abe24de98b6" url: "https://pub.dev" source: hosted - version: "6.0.3" + version: "7.1.0" firebase_core_web: dependency: transitive description: name: firebase_core_web - sha256: dc5096257cd67292d34d78ceeb90836f02a4be921b5f3934311a02bb2376118c + sha256: "30ba3ae56f5beb2cea836033201570612c911661889f815eca73b6056c7b55bf" url: "https://pub.dev" source: hosted - version: "3.6.0" + version: "3.9.0" firebase_messaging: dependency: "direct main" description: name: firebase_messaging - sha256: e5c93e8e7a9b0513f94bb684d2cf100e32e7dcdf2949574386b1955fc9a9b96a + sha256: f2d1734d167e07746949ada91a288a9b14a03be470eec89ee249ed90c8d4db44 url: "https://pub.dev" source: hosted - version: "16.2.0" + version: "16.4.0" firebase_messaging_platform_interface: dependency: transitive description: name: firebase_messaging_platform_interface - sha256: "8cbb7d842e5071bba836452aff262f7db4b14bb3a0d00c1896cf176df886d65a" + sha256: e10f6d521e7ed663d0ea2f4ec7de4c6729f8c2ce25d32faf6d6b4219da8515c2 url: "https://pub.dev" source: hosted - version: "4.7.9" + version: "4.9.0" firebase_messaging_web: dependency: transitive description: name: firebase_messaging_web - sha256: "8750bacf50573c0383535fc3f9c58c6a2f9dff5320a16a82c30631b9dad894f1" + sha256: "7ab45dfaf8efcd1a769baa9b8debbd0da281f5d3fc07274296b564396a980292" url: "https://pub.dev" source: hosted - version: "4.1.5" + version: "4.2.1" fixnum: dependency: transitive description: @@ -434,10 +450,10 @@ packages: dependency: "direct main" description: name: flutter_card_swiper - sha256: "1eacbfab31b572223042e03409726553aec431abe48af48c8d591d376d070d3d" + sha256: "895c6974729b51cf73a35f1b58ab57a0af3293131319e2cbccac3bc57ffcd69f" url: "https://pub.dev" source: hosted - version: "7.0.2" + version: "7.2.0" flutter_dotenv: dependency: "direct main" description: @@ -503,10 +519,10 @@ packages: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: "9b78450b89f059e96c9ebb355fa6b3df1d6b330436e0b885fb49594c41721398" + sha256: c2fe1001710127dfa7da89977a08d591398370d099aacdaa6d44da7eb14b8476 url: "https://pub.dev" source: hosted - version: "2.0.23" + version: "2.0.31" flutter_secure_storage: dependency: "direct main" description: @@ -559,10 +575,10 @@ packages: dependency: "direct main" description: name: flutter_spinkit - sha256: d2696eed13732831414595b98863260e33e8882fc069ee80ec35d4ac9ddb0472 + sha256: "77850df57c00dc218bfe96071d576a8babec24cf58b2ed121c83cca4a2fdce7f" url: "https://pub.dev" source: hosted - version: "5.2.1" + version: "5.2.2" flutter_sticky_header: dependency: "direct main" description: @@ -575,23 +591,31 @@ packages: dependency: "direct main" description: name: flutter_svg - sha256: "54900a1a1243f3c4a5506d853a2b5c2dbc38d5f27e52a52618a8054401431123" + sha256: "055de8921be7b8e8b98a233c7a5ef84b3a6fcc32f46f1ebf5b9bb3576d108355" url: "https://pub.dev" source: hosted - version: "2.0.16" + version: "2.2.2" flutter_test: dependency: "direct dev" 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: name: flutter_web_auth_2 - sha256: d354998934ddc338e69b999b2abaeb33c6fd09999d3a5f92ead1a6b49b49712e + sha256: "8f9303471dcd96670878c9b7c0c4e14c37595b2add67465f6a868f17a5872dfc" url: "https://pub.dev" source: hosted - version: "5.0.2" + version: "5.0.3" flutter_web_auth_2_platform_interface: dependency: transitive description: @@ -633,10 +657,10 @@ packages: dependency: transitive description: name: geocoding_ios - sha256: "94ddba60387501bd1c11e18dca7c5a9e8c645d6e3da9c38b9762434941870c24" + sha256: "18ab1c8369e2b0dcb3a8ccc907319334f35ee8cf4cfef4d9c8e23b13c65cb825" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.1.0" geocoding_platform_interface: dependency: transitive description: @@ -649,106 +673,106 @@ packages: dependency: "direct main" description: name: geolocator - sha256: d2ec66329cab29cb297d51d96c067d457ca519dca8589665fa0b82ebacb7dbe4 + sha256: f62bcd90459e63210bbf9c35deb6a51c521f992a78de19a1fe5c11704f9530e2 url: "https://pub.dev" source: hosted - version: "13.0.2" + version: "13.0.4" geolocator_android: dependency: transitive description: name: geolocator_android - sha256: "7aefc530db47d90d0580b552df3242440a10fe60814496a979aa67aa98b1fd47" + sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d url: "https://pub.dev" source: hosted - version: "4.6.1" + version: "4.6.2" geolocator_apple: dependency: transitive description: name: geolocator_apple - sha256: c4ecead17985ede9634f21500072edfcb3dba0ef7b97f8d7bc556d2d722b3ba3 + sha256: "853803d6bb1713c094e935b4a5ae5f19c0308acf81da13fa9ff84fb4c70c0b73" url: "https://pub.dev" source: hosted - version: "2.3.9" + version: "2.3.14" geolocator_platform_interface: dependency: transitive description: name: geolocator_platform_interface - sha256: "386ce3d9cce47838355000070b1d0b13efb5bc430f8ecda7e9238c8409ace012" + sha256: cdb082e4f048b69da244117b7914cc60d2a8897546ffaa4f2529c786ded7aee2 url: "https://pub.dev" source: hosted - version: "4.2.4" + version: "4.2.8" geolocator_web: dependency: transitive description: name: geolocator_web - sha256: "2ed69328e05cd94e7eb48bb0535f5fc0c0c44d1c4fa1e9737267484d05c29b5e" + sha256: "19e485a0f8d6a88abcf9c53cba3a4105e14b7435ed8ac1c108c067b938fe8429" url: "https://pub.dev" source: hosted - version: "4.1.1" + version: "4.1.4" geolocator_windows: dependency: transitive description: name: geolocator_windows - sha256: "53da08937d07c24b0d9952eb57a3b474e29aae2abf9dd717f7e1230995f13f0e" + sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" url: "https://pub.dev" source: hosted - version: "0.2.3" + version: "0.2.5" glob: dependency: transitive description: name: glob - sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" google_maps: dependency: transitive description: name: google_maps - sha256: "4d6e199c561ca06792c964fa24b2bac7197bf4b401c2e1d23e345e5f9939f531" + sha256: "5d410c32112d7c6eb7858d359275b2aa04778eed3e36c745aeae905fb2fa6468" url: "https://pub.dev" source: hosted - version: "8.1.1" + version: "8.2.0" google_maps_flutter: dependency: "direct main" description: name: google_maps_flutter - sha256: "209856c8e5571626afba7182cf634b2910069dc567954e76ec3e3fb37f5e9db3" + sha256: "819985697596a42e1054b5feb2f407ba1ac92262e02844a40168e742b9f36dca" url: "https://pub.dev" source: hosted - version: "2.10.0" + version: "2.14.0" google_maps_flutter_android: dependency: transitive description: name: google_maps_flutter_android - sha256: bccf64ccbb2ea672dc62a61177b315a340af86b0228564484b023657544a3fd5 + sha256: "7c7ff5b883b27bfdd0d52d91d89faf00858a6c1b33aeca0dc80faca64f389983" url: "https://pub.dev" source: hosted - version: "2.14.11" + version: "2.18.3" google_maps_flutter_ios: dependency: transitive description: name: google_maps_flutter_ios - sha256: "6f798adb0aa1db5adf551f2e39e24bd06c8c0fbe4de912fb2d9b5b3f48147b02" + sha256: ca02463b19a9abc7d31fcaf22631d021d647107467f741b917a69fa26659fd75 url: "https://pub.dev" source: hosted - version: "2.13.2" + version: "2.15.5" google_maps_flutter_platform_interface: dependency: transitive description: name: google_maps_flutter_platform_interface - sha256: a951981c22d790848efb9f114f81794945bc5c06bc566238a419a92f110af6cb + sha256: f4b9b44f7b12a1f6707ffc79d082738e0b7e194bf728ee61d2b3cdf5fdf16081 url: "https://pub.dev" source: hosted - version: "2.9.5" + version: "2.14.0" google_maps_flutter_web: dependency: transitive description: name: google_maps_flutter_web - sha256: ff39211bd25d7fad125d19f757eba85bd154460907cd4d135e07e3d0f98a4130 + sha256: d416602944e1859f3cbbaa53e34785c223fa0a11eddb34a913c964c5cbb5d8cf url: "https://pub.dev" source: hosted - version: "0.5.10" + version: "0.5.14+3" google_mlkit_commons: dependency: transitive description: @@ -793,106 +817,106 @@ packages: dependency: transitive description: name: html - sha256: "1fc58edeaec4307368c60d59b7e15b9d658b57d7f3125098b6294153c75337ec" + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" url: "https://pub.dev" source: hosted - version: "0.15.5" + version: "0.15.6" http: dependency: "direct main" description: name: http - sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" url: "https://pub.dev" source: hosted - version: "1.2.2" + version: "1.6.0" http_multi_server: dependency: transitive description: name: http_multi_server - sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "3.2.2" http_parser: dependency: "direct main" description: name: http_parser - sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" url: "https://pub.dev" source: hosted - version: "4.0.2" + version: "4.1.2" image_picker: dependency: "direct main" description: name: image_picker - sha256: "021834d9c0c3de46bf0fe40341fa07168407f694d9b2bb18d532dc1261867f7a" + sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.2.1" image_picker_android: dependency: transitive description: name: image_picker_android - sha256: "82652a75e3dd667a91187769a6a2cc81bd8c111bbead698d8e938d2b63e5e89a" + sha256: "28f3987ca0ec702d346eae1d90eda59603a2101b52f1e234ded62cff1d5cfa6e" url: "https://pub.dev" source: hosted - version: "0.8.12+21" + version: "0.8.13+1" image_picker_for_web: dependency: transitive description: name: image_picker_for_web - sha256: "717eb042ab08c40767684327be06a5d8dbb341fe791d514e4b92c7bbe1b7bb83" + sha256: "40c2a6a0da15556dc0f8e38a3246064a971a9f512386c3339b89f76db87269b6" url: "https://pub.dev" source: hosted - version: "3.0.6" + version: "3.1.0" image_picker_ios: dependency: transitive description: name: image_picker_ios - sha256: "05da758e67bc7839e886b3959848aa6b44ff123ab4b28f67891008afe8ef9100" + sha256: eb06fe30bab4c4497bad449b66448f50edcc695f1c59408e78aa3a8059eb8f0e url: "https://pub.dev" source: hosted - version: "0.8.12+2" + version: "0.8.13" image_picker_linux: dependency: transitive description: name: image_picker_linux - sha256: "34a65f6740df08bbbeb0a1abd8e6d32107941fd4868f67a507b25601651022c9" + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" url: "https://pub.dev" source: hosted - version: "0.2.1+2" + version: "0.2.2" image_picker_macos: dependency: transitive description: name: image_picker_macos - sha256: "1b90ebbd9dcf98fb6c1d01427e49a55bd96b5d67b8c67cf955d60a5de74207c1" + sha256: d58cd9d67793d52beefd6585b12050af0a7663c0c2a6ece0fb110a35d6955e04 url: "https://pub.dev" source: hosted - version: "0.2.1+2" + version: "0.2.2" image_picker_platform_interface: dependency: transitive description: name: image_picker_platform_interface - sha256: "886d57f0be73c4b140004e78b9f28a8914a09e50c2d816bdd0520051a71236a0" + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" url: "https://pub.dev" source: hosted - version: "2.10.1" + version: "2.11.1" image_picker_windows: dependency: transitive description: name: image_picker_windows - sha256: "6ad07afc4eb1bc25f3a01084d28520496c4a3bb0cb13685435838167c9dcedeb" + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae url: "https://pub.dev" source: hosted - version: "0.2.1+1" + version: "0.2.2" intl: 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,42 +961,42 @@ 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: name: lints - sha256: "3315600f3fb3b135be672bf4a178c55f274bebe368325ae18462c89ac1e3b413" + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 url: "https://pub.dev" source: hosted - version: "5.0.0" + version: "5.1.1" logger: dependency: "direct main" description: name: logger - sha256: be4b23575aac7ebf01f225a241eb7f6b5641eeaf43c6a8613510fc2f8cf187d1 + sha256: "25aee487596a6257655a1e091ec2ae66bc30e7af663592cc3a27e6591e05035c" url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.7.0" logging: 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,18 +1049,18 @@ 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: name: mocktail - sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" + sha256: "5e1bf53cc7baa8062a33b84424deb61513858ea05c601b8509e683815b5914aa" url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "1.0.5" modal_bottom_sheet: dependency: "direct main" description: @@ -1073,26 +1097,26 @@ packages: dependency: transitive description: name: package_config - sha256: "92d4488434b520a62570293fbd33bb556c7d49230791c1b4bbd973baf6d2dc67" + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.2.0" package_info_plus: dependency: "direct main" description: name: package_info_plus - sha256: "739e0a5c3c4055152520fa321d0645ee98e932718b4c8efeeb51451968fe0790" + sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" url: "https://pub.dev" source: hosted - version: "8.1.3" + version: "8.3.1" package_info_plus_platform_interface: dependency: transitive description: name: package_info_plus_platform_interface - sha256: a5ef9986efc7bf772f2696183a3992615baa76c1ffb1189318dd8803778fb05b + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.2.1" path: 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" @@ -1129,18 +1153,18 @@ packages: dependency: transitive description: name: path_provider_android - sha256: "4adf4fd5423ec60a29506c76581bc05854c55e3a0b72d35bb28d661c9686edf2" + sha256: "3b4c1fc3aa55ddc9cd4aa6759984330d5c8e66aa7702a6223c61540dc6380c37" url: "https://pub.dev" source: hosted - version: "2.2.15" + version: "2.2.19" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" path_provider_linux: dependency: transitive description: @@ -1185,10 +1209,10 @@ packages: dependency: transitive description: name: permission_handler_apple - sha256: f84a188e79a35c687c132a0a0556c254747a08561e99ab933f12f6ca71ef3c98 + sha256: "79dfa1df734798aa3cfdad166d3a3698c206d8813de13516ea1071b5d7e2f420" url: "https://pub.dev" source: hosted - version: "9.4.6" + version: "9.4.10" permission_handler_html: dependency: transitive description: @@ -1217,10 +1241,10 @@ packages: dependency: transitive description: name: petitparser - sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" url: "https://pub.dev" source: hosted - version: "6.0.2" + version: "6.1.0" pin_code_fields: dependency: "direct main" description: @@ -1249,42 +1273,42 @@ packages: dependency: transitive description: name: pool - sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" url: "https://pub.dev" source: hosted - version: "1.5.1" + version: "1.5.2" posthog_flutter: dependency: "direct main" description: name: posthog_flutter - sha256: "50a3a41b946635c4dcae289d58a27b76dc41dffeb7b8fa907020c05697c13bcb" + sha256: e17d3ca8b3b755246ff606f57fc250bd0d37c1cc179a5894787320f5bd6513e7 url: "https://pub.dev" source: hosted - version: "5.23.1" + version: "5.27.0" provider: dependency: transitive description: name: provider - sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" url: "https://pub.dev" source: hosted - version: "6.1.2" + version: "6.1.5+1" pub_semver: dependency: transitive description: name: pub_semver - sha256: "7b3cfbf654f3edd0c6298ecd5be782ce997ddf0e00531b9464b55245185bbbbd" + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.2.0" pubspec_parse: dependency: transitive description: name: pubspec_parse - sha256: c799b721d79eb6ee6fa56f00c04b472dcd44a30d258fac2174a6ec57302678f8 + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.5.0" sanitize_html: dependency: transitive description: @@ -1293,6 +1317,14 @@ 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: @@ -1321,10 +1353,10 @@ packages: dependency: transitive description: name: shared_preferences_android - sha256: "9f9f3d372d4304723e6136663bb291c0b93f5e4c8a4a6314347f481a33bda2b1" + sha256: bd14436108211b0d4ee5038689a56d4ae3620fd72fd6036e113bf1345bc74d9e url: "https://pub.dev" source: hosted - version: "2.4.7" + version: "2.4.13" shared_preferences_foundation: dependency: transitive description: @@ -1369,10 +1401,10 @@ packages: dependency: transitive description: name: shelf - sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.2" shelf_packages_handler: dependency: transitive description: @@ -1393,10 +1425,10 @@ packages: dependency: transitive description: name: shelf_web_socket - sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67 + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.0" shimmer: dependency: "direct main" description: @@ -1414,26 +1446,26 @@ packages: dependency: "direct main" description: name: smooth_page_indicator - sha256: "3b28b0c545fa67ed9e5997d9f9720d486f54c0c607e056a1094544e36934dff3" + sha256: b21ebb8bc39cf72d11c7cfd809162a48c3800668ced1c9da3aade13a32cf6c1c url: "https://pub.dev" source: hosted - version: "1.2.0+3" + version: "1.2.1" source_gen: 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: @@ -1458,14 +1490,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.1" - sprintf: - dependency: transitive - description: - name: sprintf - sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" - url: "https://pub.dev" - source: hosted - version: "7.0.0" stack_trace: dependency: transitive description: @@ -1486,10 +1510,10 @@ packages: dependency: transitive description: name: stream_transform - sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f" + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.1" string_scanner: dependency: transitive description: @@ -1502,10 +1526,10 @@ packages: dependency: "direct main" description: name: synchronized - sha256: "69fe30f3a8b04a0be0c15ae6490fc859a78ef4c43ae2dd5e8a623d45bfcf9225" + sha256: "0669c70faae6270521ee4f05bffd2919892d42d1276e6c495be80174b6bc0ef6" url: "https://pub.dev" source: hosted - version: "3.3.0+3" + version: "3.3.1" term_glyph: dependency: transitive description: @@ -1518,26 +1542,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: @@ -1546,6 +1570,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: @@ -1566,26 +1598,26 @@ packages: dependency: "direct main" description: name: url_launcher - sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 url: "https://pub.dev" source: hosted - version: "6.3.1" + version: "6.3.2" url_launcher_android: dependency: transitive description: name: url_launcher_android - sha256: "8582d7f6fe14d2652b4c45c9b6c14c0b678c2af2d083a11b604caeba51930d79" + sha256: "81777b08c498a292d93ff2feead633174c386291e35612f8da438d6e92c4447e" url: "https://pub.dev" source: hosted - version: "6.3.16" + version: "6.3.20" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb" + sha256: d80b3f567a617cb923546034cc94bfe44eb15f989fe670b37f26abdb9d939cb7 url: "https://pub.dev" source: hosted - version: "6.3.3" + version: "6.3.4" url_launcher_linux: dependency: transitive description: @@ -1598,10 +1630,10 @@ packages: dependency: transitive description: name: url_launcher_macos - sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" + sha256: c043a77d6600ac9c38300567f33ef12b0ef4f4783a2c1f00231d2b1941fea13f url: "https://pub.dev" source: hosted - version: "3.2.2" + version: "3.2.3" url_launcher_platform_interface: dependency: transitive description: @@ -1630,10 +1662,10 @@ packages: dependency: "direct main" description: name: uuid - sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" url: "https://pub.dev" source: hosted - version: "4.5.1" + version: "4.5.3" value_layout_builder: dependency: transitive description: @@ -1646,34 +1678,74 @@ packages: dependency: transitive description: name: vector_graphics - sha256: "27d5fefe86fb9aace4a9f8375b56b3c292b64d8c04510df230f849850d912cb7" + sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 url: "https://pub.dev" source: hosted - version: "1.1.15" + version: "1.1.19" vector_graphics_codec: dependency: transitive description: name: vector_graphics_codec - sha256: "2430b973a4ca3c4dbc9999b62b8c719a160100dcbae5c819bae0cacce32c9cdb" + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" url: "https://pub.dev" source: hosted - version: "1.1.12" + version: "1.1.13" vector_graphics_compiler: dependency: transitive description: name: vector_graphics_compiler - sha256: "1b4b9e706a10294258727674a340ae0d6e64a7231980f9f9a3d12e4b42407aad" + sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc url: "https://pub.dev" source: hosted - version: "1.1.16" + version: "1.1.19" vector_math: 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: @@ -1686,34 +1758,34 @@ packages: dependency: transitive description: name: watcher - sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.2.1" web: dependency: transitive description: name: web - sha256: cd3543bd5798f6ad290ea73d210f423502e71900302dde696f8bff84bf89a1cb + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" web_socket: dependency: transitive description: name: web_socket - sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" url: "https://pub.dev" source: hosted - version: "0.1.6" + version: "1.0.1" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f" + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.3" webkit_inspection_protocol: dependency: transitive description: @@ -1722,14 +1794,46 @@ 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: name: win32 - sha256: "154360849a56b7b67331c21f09a386562d88903f90a1099c5987afc1912e1f29" + sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba" url: "https://pub.dev" source: hosted - version: "5.10.0" + version: "5.13.0" win32_registry: dependency: transitive description: @@ -1742,10 +1846,10 @@ packages: dependency: transitive description: name: window_to_front - sha256: "7aef379752b7190c10479e12b5fd7c0b9d92adc96817d9e96c59937929512aee" + sha256: "14fad8984db4415e2eeb30b04bb77140b180e260d6cb66b26de126a8657a9241" url: "https://pub.dev" source: hosted - version: "0.0.3" + version: "0.0.4" xdg_directories: dependency: transitive description: @@ -1770,6 +1874,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.3" + youtube_player_iframe: + dependency: "direct main" + description: + name: youtube_player_iframe + sha256: "6690da91591d14b32a6884eb6ae270ea4cc946a748d577d89d18cde565be689f" + url: "https://pub.dev" + source: hosted + version: "5.2.2" + youtube_player_iframe_web: + dependency: transitive + description: + name: youtube_player_iframe_web + sha256: "333901d008634f2ea67ef27aba8d597567e4ff45f393290b948a739654ab6dca" + url: "https://pub.dev" + source: hosted + version: "3.1.2" 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 4faac89280..074cc68ee7 100644 --- a/src/mobile/pubspec.yaml +++ b/src/mobile/pubspec.yaml @@ -78,7 +78,6 @@ dependencies: plugin_platform_interface: ^2.1.8 url_launcher: ^6.3.1 flutter_secure_storage: ^9.2.2 - share_plus: ^10.0.2 posthog_flutter: ^5.9.0 firebase_core: ^4.4.0 firebase_messaging: ^16.1.1 @@ -87,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: @@ -123,6 +128,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