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