diff --git a/src/mobile/android/app/build.gradle b/src/mobile/android/app/build.gradle
index 58e5d5b28f..b74d07fe6c 100644
--- a/src/mobile/android/app/build.gradle
+++ b/src/mobile/android/app/build.gradle
@@ -77,7 +77,7 @@ if (releaseKeystorePropertiesFile.exists()) {
android {
namespace "com.airqo.app"
compileSdk 36
- ndkVersion flutter.ndkVersion
+ ndkVersion "27.0.12077973"
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_9
@@ -98,7 +98,7 @@ android {
applicationId "com.airqo.app"
// You can update the following values to match your application needs.
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration.
- minSdkVersion flutter.minSdkVersion
+ minSdkVersion 24
targetSdkVersion 36
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
diff --git a/src/mobile/android/app/src/main/AndroidManifest.xml b/src/mobile/android/app/src/main/AndroidManifest.xml
index 2bc3392bcc..69ad3274bf 100644
--- a/src/mobile/android/app/src/main/AndroidManifest.xml
+++ b/src/mobile/android/app/src/main/AndroidManifest.xml
@@ -3,6 +3,9 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/mobile/assets/icons/camera.svg b/src/mobile/assets/icons/camera.svg
new file mode 100644
index 0000000000..7bc3d75ed8
--- /dev/null
+++ b/src/mobile/assets/icons/camera.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/mobile/assets/icons/check-circle.svg b/src/mobile/assets/icons/check-circle.svg
new file mode 100644
index 0000000000..6e4a308e8c
--- /dev/null
+++ b/src/mobile/assets/icons/check-circle.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/mobile/assets/icons/copy.svg b/src/mobile/assets/icons/copy.svg
new file mode 100644
index 0000000000..fe67efd39e
--- /dev/null
+++ b/src/mobile/assets/icons/copy.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/mobile/assets/icons/gallery.svg b/src/mobile/assets/icons/gallery.svg
new file mode 100644
index 0000000000..8fda5620ef
--- /dev/null
+++ b/src/mobile/assets/icons/gallery.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/mobile/assets/images/shared/airqo_icon_mark.svg b/src/mobile/assets/images/shared/airqo_icon_mark.svg
new file mode 100644
index 0000000000..b137a1bda0
--- /dev/null
+++ b/src/mobile/assets/images/shared/airqo_icon_mark.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/mobile/lib/src/app/dashboard/utils/air_quality_card_utils.dart b/src/mobile/lib/src/app/dashboard/utils/air_quality_card_utils.dart
new file mode 100644
index 0000000000..165f2b7e08
--- /dev/null
+++ b/src/mobile/lib/src/app/dashboard/utils/air_quality_card_utils.dart
@@ -0,0 +1,148 @@
+import 'dart:convert';
+
+import 'package:airqo/src/app/dashboard/models/airquality_response.dart';
+import 'package:airqo/src/meta/utils/colors.dart';
+import 'package:flutter/material.dart';
+
+/// Shared rendering helpers for the air-quality share/filter/sticker cards so
+/// they all present identical values (color, category label, sanitized
+/// text) without duplicating logic in every widget.
+
+/// Windows-1252 maps bytes 0x80-0x9F to these code points (smart quotes,
+/// em-dashes, etc.) instead of leaving them as Latin-1 control codes. When
+/// UTF-8 text is mis-decoded as Windows-1252, these are the code points
+/// that show up — e.g. "it's" becomes "it’s". Plain `latin1.encode`
+/// can't reverse these (they're outside Latin-1's 0-255 range entirely),
+/// so it throws and the mojibake silently survives.
+const Map _windows1252HighBytes = {
+ 0x20AC: 0x80, // €
+ 0x201A: 0x82, // ‚
+ 0x0192: 0x83, // ƒ
+ 0x201E: 0x84, // „
+ 0x2026: 0x85, // …
+ 0x2020: 0x86, // †
+ 0x2021: 0x87, // ‡
+ 0x02C6: 0x88, // ˆ
+ 0x2030: 0x89, // ‰
+ 0x0160: 0x8A, // Š
+ 0x2039: 0x8B, // ‹
+ 0x0152: 0x8C, // Œ
+ 0x017D: 0x8E, // Ž
+ 0x2018: 0x91, // '
+ 0x2019: 0x92, // '
+ 0x201C: 0x93, // "
+ 0x201D: 0x94, // "
+ 0x2022: 0x95, // •
+ 0x2013: 0x96, // –
+ 0x2014: 0x97, // —
+ 0x02DC: 0x98, // ˜
+ 0x2122: 0x99, // ™
+ 0x0161: 0x9A, // š
+ 0x203A: 0x9B, // ›
+ 0x0153: 0x9C, // œ
+ 0x017E: 0x9E, // ž
+ 0x0178: 0x9F, // Ÿ
+};
+
+/// Fixes mojibake that occasionally shows up in API text fields — UTF-8
+/// bytes that got shown as Latin-1/Windows-1252 text, e.g. "Café" instead
+/// of "Café" or "it’s" instead of "it's".
+///
+/// Only rewrites strings matching one of the classic mojibake signatures
+/// below ('Ã' or 'Â' immediately followed by another character, or the
+/// smart-quote/dash prefix 'â€') — a bare 'â' is left alone, since that's a
+/// legitimate letter in its own right (e.g. "château", "âge").
+String sanitizeCardText(String value) {
+ final looksLikeMojibake =
+ value.contains('Ã') || value.contains('â€') || value.contains('Â');
+ if (!looksLikeMojibake) return value;
+
+ final bytes = [];
+ for (final unit in value.codeUnits) {
+ if (unit <= 0xFF) {
+ bytes.add(unit);
+ continue;
+ }
+ final mappedByte = _windows1252HighBytes[unit];
+ if (mappedByte == null) return value; // Not a reversible mojibake byte.
+ bytes.add(mappedByte);
+ }
+
+ try {
+ return utf8.decode(bytes);
+ } catch (_) {
+ return value;
+ }
+}
+
+/// Resolves the AQI accent color for a measurement, preferring the color
+/// returned by the API and falling back to a category lookup.
+Color getMeasurementAqiColor(Measurement measurement) {
+ if (measurement.aqiColor != null) {
+ try {
+ final colorString = measurement.aqiColor!.replaceAll('#', '');
+ return Color(int.parse('0xFF$colorString'));
+ } catch (_) {}
+ }
+
+ switch (measurement.aqiCategory?.toLowerCase() ?? '') {
+ case 'good':
+ return const Color(0xFF179D5B);
+ case 'moderate':
+ return const Color(0xFFC79000);
+ case 'unhealthy for sensitive groups':
+ case 'u4sg':
+ return const Color(0xFFE17827);
+ case 'unhealthy':
+ return const Color(0xFFD63A45);
+ case 'very unhealthy':
+ return const Color(0xFF7540B5);
+ case 'hazardous':
+ return const Color(0xFF6D4C41);
+ default:
+ return AppColors.primaryColor;
+ }
+}
+
+/// Shortens verbose AQI category labels so they fit on compact card pills.
+String aqiCategoryLabel(String category) {
+ switch (category.toLowerCase()) {
+ case 'unhealthy for sensitive groups':
+ return 'Sensitive Groups';
+ case 'very unhealthy':
+ return 'Very Unhealthy';
+ default:
+ return category;
+ }
+}
+
+/// Light pastel background for an AQI status pill, derived by blending the
+/// category color into white — matches the Figma "Label/Status/Good"
+/// component style (solid pale bg + solid saturated text) rather than a
+/// translucent overlay, which reads as muddy over photos.
+Color aqiPillBackground(Color categoryColor) {
+ return Color.alphaBlend(categoryColor.withValues(alpha: 0.18), Colors.white);
+}
+
+/// Resolves the circular AQI "wheel" icon (ring + face) used on the share
+/// card, filter, and sticker templates — same asset set used across the
+/// dashboard and map so the icon always matches the category color.
+String getMeasurementAqiIconAsset(Measurement measurement) {
+ switch (measurement.aqiCategory?.toLowerCase() ?? '') {
+ case 'good':
+ return 'assets/images/shared/airquality_indicators/good.svg';
+ case 'moderate':
+ return 'assets/images/shared/airquality_indicators/moderate.svg';
+ case 'unhealthy for sensitive groups':
+ case 'u4sg':
+ return 'assets/images/shared/airquality_indicators/unhealthy-sensitive.svg';
+ case 'unhealthy':
+ return 'assets/images/shared/airquality_indicators/unhealthy.svg';
+ case 'very unhealthy':
+ return 'assets/images/shared/airquality_indicators/very-unhealthy.svg';
+ case 'hazardous':
+ return 'assets/images/shared/airquality_indicators/hazardous.svg';
+ default:
+ return 'assets/images/shared/airquality_indicators/unavailable.svg';
+ }
+}
diff --git a/src/mobile/lib/src/app/dashboard/utils/clean_air_forum_branding.dart b/src/mobile/lib/src/app/dashboard/utils/clean_air_forum_branding.dart
new file mode 100644
index 0000000000..5abeb4b6dd
--- /dev/null
+++ b/src/mobile/lib/src/app/dashboard/utils/clean_air_forum_branding.dart
@@ -0,0 +1,130 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_svg/flutter_svg.dart';
+
+/// Branding for the Africa Clean Air Forum selfie filter, based on the
+/// "AQ/CAF" Figma templates (file `Z0OLd2awVqgZhytJULgO8L`, node 169:534 /
+/// 169:491).
+///
+/// All templates were designed on a 1080x1080 canvas — [kCafReferenceWidth]
+/// — so every size/gap in [CleanAirForumFilterCard] and
+/// [CleanAirForumStickerFrame] is expressed as a fraction of that reference
+/// and multiplied by the widget's actual rendered width. This keeps the
+/// layout proportionally identical to the Figma design no matter what
+/// resolution the card is previewed or captured at.
+class CleanAirForumBrand {
+ const CleanAirForumBrand._();
+
+ /// "Vibrant sky blue" — matches the Africa Clean Air Network palette.
+ static const Color skyBlue = Color(0xFF1E9BE0);
+
+ /// "Nature green" — matches the Africa Clean Air Network palette.
+ static const Color natureGreen = Color(0xFF2FA84F);
+
+ /// Deep teal used for the filter card's bottom scrim, matching the Figma
+ /// "AQ/CAF" template.
+ static const Color scrimTeal = Color(0xFF005257);
+
+ /// Text color for the "Shared from the AirQo app" pill/caption, matching
+ /// the Figma template exactly (distinct from [scrimTeal]).
+ static const Color sharedCaptionText = Color(0xFF1F3D3D);
+
+ /// Host city + year shown under the "Clean Air Forum" wordmark.
+ static const String edition = 'Pretoria 2026';
+
+ /// Event date range shown in the corner of the filter card.
+ static const String dateRange = '13TH-16TH JULY';
+}
+
+/// Reference canvas width the Figma "AQ/CAF" templates were designed at.
+/// Multiply this against any Figma pixel value, divided by this constant,
+/// to get a proportionally-correct size for a card of a given width.
+const double kCafReferenceWidth = 1080.0;
+
+/// AirQo house-mark icon, recolorable for use on photos/colored backgrounds.
+///
+/// The "airqo" wordmark is cut out of the shape as negative space (rather
+/// than drawn), so whatever sits behind the icon shows through the letters
+/// — matching the Figma logo lockup exactly.
+class AirQoIconMark extends StatelessWidget {
+ final double size;
+ final Color color;
+
+ const AirQoIconMark({super.key, this.size = 28, this.color = Colors.white});
+
+ /// Intrinsic aspect ratio of the source asset (143.38 x 97).
+ static const double aspectRatio = 97 / 143.38;
+
+ @override
+ Widget build(BuildContext context) {
+ return SvgPicture.asset(
+ 'assets/images/shared/airqo_icon_mark.svg',
+ width: size,
+ height: size * aspectRatio,
+ colorFilter: ColorFilter.mode(color, BlendMode.srcIn),
+ );
+ }
+}
+
+/// AirQo icon + "Clean Air Forum" wordmark lockup shown top-left on the
+/// selfie filter card: logo mark, a vertical divider, then the title/edition
+/// text — matches the Figma "AQ/CAF" header exactly (node 169:538).
+///
+/// [scale] is the card's rendered width divided by [kCafReferenceWidth];
+/// every size below is a Figma design pixel value multiplied by it.
+class CleanAirForumBrandHeader extends StatelessWidget {
+ final double scale;
+
+ const CleanAirForumBrandHeader({super.key, required this.scale});
+
+ @override
+ Widget build(BuildContext context) {
+ final iconSize = 143.38 * scale;
+ final gap = 17 * scale;
+ final dividerWidth = (4 * scale).clamp(1.0, double.infinity);
+ final dividerHeight = 98 * scale;
+ final titleFontSize = 35.974 * scale;
+
+ return Row(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ AirQoIconMark(size: iconSize),
+ SizedBox(width: gap),
+ Container(width: dividerWidth, height: dividerHeight, color: Colors.white),
+ SizedBox(width: gap),
+ Flexible(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'Clean Air Forum',
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ color: Colors.white,
+ fontSize: titleFontSize,
+ height: 1.1,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ if (CleanAirForumBrand.edition.isNotEmpty)
+ Text(
+ CleanAirForumBrand.edition,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ color: Colors.white,
+ fontSize: titleFontSize,
+ height: 1.1,
+ fontStyle: FontStyle.italic,
+ fontWeight: FontWeight.w400,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ );
+ }
+}
diff --git a/src/mobile/lib/src/app/dashboard/widgets/air_quality_share_card.dart b/src/mobile/lib/src/app/dashboard/widgets/air_quality_share_card.dart
index 6868ea03d6..82a46dbb3f 100644
--- a/src/mobile/lib/src/app/dashboard/widgets/air_quality_share_card.dart
+++ b/src/mobile/lib/src/app/dashboard/widgets/air_quality_share_card.dart
@@ -1,6 +1,6 @@
-import 'dart:convert';
-
import 'package:airqo/src/app/dashboard/models/airquality_response.dart';
+import 'package:airqo/src/app/dashboard/utils/air_quality_card_utils.dart';
+import 'package:airqo/src/app/dashboard/utils/measurement_location_utils.dart';
import 'package:airqo/src/meta/utils/colors.dart';
import 'package:flutter/material.dart';
@@ -16,28 +16,31 @@ class AirQualityShareCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
- final locationName = _sanitizeText(
- measurement.siteDetails?.searchName ??
- measurement.siteDetails?.name ??
- fallbackLocationName ??
- 'AirQo location',
+ final locationName = sanitizeCardText(
+ measurementDisplayName(
+ measurement,
+ fallbackLocationName: fallbackLocationName ?? 'AirQo location',
+ ),
);
- final locationDescription = _sanitizeText(
+ final locationDescription = sanitizeCardText(
_getLocationDescription(measurement),
);
- final category = _categoryLabel(
- _sanitizeText(measurement.aqiCategory ?? 'Unavailable'),
+ final category = aqiCategoryLabel(
+ sanitizeCardText(measurement.aqiCategory ?? 'Unavailable'),
);
final pm25Value = measurement.pm25?.value;
- final healthTip = _sanitizeText(
+ final healthTip = sanitizeCardText(
measurement.healthTips?.firstOrNull?.tagLine ??
measurement.healthTips?.firstOrNull?.description ??
'Stay informed and plan your outdoor time wisely.',
);
- final categoryColor = _getAqiColor(measurement);
+ final categoryColor = getMeasurementAqiColor(measurement);
final isDark = Theme.of(context).brightness == Brightness.dark;
- final cardTextColor = isDark ? Colors.white : const Color(0xFF122033);
- final secondaryTextColor = cardTextColor.withValues(alpha: 0.72);
+ // Always white per the design system — the card background is a
+ // colored gradient regardless of app theme, so text stays white in
+ // both light and dark mode for consistent contrast.
+ const cardTextColor = Colors.white;
+ final secondaryTextColor = cardTextColor.withValues(alpha: 0.82);
final titleFontSize = _titleFontSize(locationName);
final categoryFontSize = _categoryFontSize(category);
@@ -52,7 +55,7 @@ class AirQualityShareCard extends StatelessWidget {
colors: [
categoryColor.withValues(alpha: isDark ? 0.75 : 0.92),
AppColors.primaryColor,
- isDark ? const Color(0xFF111827) : const Color(0xFFF4F8FF),
+ const Color(0xFF10233D),
],
stops: const [0.0, 0.58, 1.0],
),
@@ -122,7 +125,7 @@ class AirQualityShareCard extends StatelessWidget {
vertical: 8,
),
decoration: BoxDecoration(
- color: Colors.white.withValues(alpha: isDark ? 0.12 : 0.84),
+ color: categoryColor.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(999),
),
child: Center(
@@ -132,7 +135,7 @@ class AirQualityShareCard extends StatelessWidget {
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: TextStyle(
- color: isDark ? Colors.white : categoryColor,
+ color: categoryColor,
fontSize: categoryFontSize,
height: 1.0,
fontWeight: FontWeight.w700,
@@ -181,7 +184,7 @@ class AirQualityShareCard extends StatelessWidget {
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
- color: Colors.white.withValues(alpha: isDark ? 0.08 : 0.82),
+ color: Colors.white.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(18),
),
child: Text(
@@ -235,33 +238,6 @@ class AirQualityShareCard extends StatelessWidget {
return parts.join(', ');
}
- Color _getAqiColor(Measurement measurement) {
- if (measurement.aqiColor != null) {
- try {
- final colorString = measurement.aqiColor!.replaceAll('#', '');
- return Color(int.parse('0xFF$colorString'));
- } catch (_) {}
- }
-
- switch (measurement.aqiCategory?.toLowerCase() ?? '') {
- case 'good':
- return const Color(0xFF179D5B);
- case 'moderate':
- return const Color(0xFFC79000);
- case 'unhealthy for sensitive groups':
- case 'u4sg':
- return const Color(0xFFE17827);
- case 'unhealthy':
- return const Color(0xFFD63A45);
- case 'very unhealthy':
- return const Color(0xFF7540B5);
- case 'hazardous':
- return const Color(0xFF6D4C41);
- default:
- return AppColors.primaryColor;
- }
- }
-
double _titleFontSize(String title) {
if (title.length <= 12) return 30;
if (title.length <= 20) return 27;
@@ -274,27 +250,4 @@ class AirQualityShareCard extends StatelessWidget {
if (category.length <= 16) return 11;
return 10;
}
-
- String _categoryLabel(String category) {
- switch (category.toLowerCase()) {
- case 'unhealthy for sensitive groups':
- return 'Sensitive Groups';
- case 'very unhealthy':
- return 'Very Unhealthy';
- default:
- return category;
- }
- }
-
- String _sanitizeText(String value) {
- if (!value.contains('Ã') && !value.contains('Â') && !value.contains('â')) {
- return value;
- }
-
- try {
- return utf8.decode(latin1.encode(value));
- } catch (_) {
- return value;
- }
- }
}
diff --git a/src/mobile/lib/src/app/dashboard/widgets/air_quality_share_sheet.dart b/src/mobile/lib/src/app/dashboard/widgets/air_quality_share_sheet.dart
index 24973d7b03..3fc302cb0f 100644
--- a/src/mobile/lib/src/app/dashboard/widgets/air_quality_share_sheet.dart
+++ b/src/mobile/lib/src/app/dashboard/widgets/air_quality_share_sheet.dart
@@ -1,12 +1,24 @@
+import 'dart:async';
+import 'dart:io';
import 'dart:typed_data';
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/widgets/clean_air_forum_camera_screen.dart';
+import 'package:airqo/src/app/dashboard/widgets/clean_air_forum_filter_card.dart';
+import 'package:airqo/src/app/dashboard/widgets/clean_air_forum_sticker_frame.dart';
+import 'package:airqo/src/app/learn/theme/learn_design_tokens.dart';
+import 'package:airqo/src/app/learn/widgets/learn_sheet_button_styles.dart';
import 'package:airqo/src/app/shared/services/air_quality_share_service.dart';
+import 'package:airqo/src/app/shared/services/clean_air_forum_submission_service.dart';
+import 'package:airqo/src/app/shared/widgets/custom_switch.dart';
import 'package:airqo/src/meta/utils/colors.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
+import 'package:flutter_svg/flutter_svg.dart';
+import 'package:image_picker/image_picker.dart';
+import 'package:pasteboard/pasteboard.dart';
Future showAirQualityShareSheet(
BuildContext context, {
@@ -18,7 +30,6 @@ Future showAirQualityShareSheet(
context: context,
useRootNavigator: true,
isScrollControlled: true,
- showDragHandle: true,
backgroundColor: Colors.transparent,
builder: (_) => AirQualityShareSheet(
measurement: measurement,
@@ -28,6 +39,25 @@ Future showAirQualityShareSheet(
);
}
+enum _ShareTab { filter, card, sticker }
+
+enum _SelfieSource { liveCamera, gallery }
+
+/// Describes one share tab so adding a future filter/format is a single
+/// entry here rather than a new copy-pasted chip + switch branch.
+class _ShareTabSpec {
+ final _ShareTab tab;
+ final String label;
+
+ const _ShareTabSpec({required this.tab, required this.label});
+}
+
+const List<_ShareTabSpec> _shareTabSpecs = [
+ _ShareTabSpec(tab: _ShareTab.filter, label: 'Forum filter'),
+ _ShareTabSpec(tab: _ShareTab.card, label: 'Card'),
+ _ShareTabSpec(tab: _ShareTab.sticker, label: 'IG sticker'),
+];
+
class AirQualityShareSheet extends StatefulWidget {
final Measurement measurement;
final String? fallbackLocationName;
@@ -45,26 +75,69 @@ class AirQualityShareSheet extends StatefulWidget {
}
class _AirQualityShareSheetState extends State {
+ // The Clean Air Forum is imminent, so lead with the branded filter tab.
+ _ShareTab _tab = _ShareTab.filter;
+
final GlobalKey _cardKey = GlobalKey();
+ final GlobalKey _filterKey = GlobalKey();
+ final GlobalKey _stickerKey = GlobalKey();
+
+ final ImagePicker _picker = ImagePicker();
+
+ File? _selfieFile;
+ bool _consentToDisplay = false;
+
+ bool _isPickingSelfie = false;
bool _isSharingCard = false;
+ bool _isSharingFilter = false;
+ bool _isCopyingSticker = false;
+ bool _stickerCopied = false;
- Future _shareCard() async {
- if (_isSharingCard) return;
+ String? _inlineMessage;
+ Timer? _inlineMessageTimer;
+
+ @override
+ void dispose() {
+ _inlineMessageTimer?.cancel();
+ super.dispose();
+ }
- setState(() {
- _isSharingCard = true;
+ Future _captureBoundary(GlobalKey key) async {
+ final pixelRatio =
+ MediaQuery.of(context).devicePixelRatio.clamp(2.0, 3.0).toDouble();
+
+ await Future.delayed(const Duration(milliseconds: 16));
+
+ final boundary =
+ key.currentContext?.findRenderObject() as RenderRepaintBoundary?;
+ if (boundary == null) return null;
+
+ final image = await boundary.toImage(pixelRatio: pixelRatio);
+ final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
+
+ return byteData?.buffer.asUint8List();
+ }
+
+ /// Shows an inline banner rather than a `SnackBar` — this sheet is a
+ /// modal route above the app's own Scaffold, so a SnackBar raised via
+ /// `ScaffoldMessenger` renders underneath it and is never actually seen.
+ void _showMessage(String message) {
+ if (!mounted) return;
+ _inlineMessageTimer?.cancel();
+ setState(() => _inlineMessage = message);
+ _inlineMessageTimer = Timer(const Duration(seconds: 3), () {
+ if (mounted) setState(() => _inlineMessage = null);
});
+ }
+
+ Future _shareCard() async {
+ if (_isSharingCard) return;
+ setState(() => _isSharingCard = true);
try {
- final imageBytes = await _captureCard();
+ final imageBytes = await _captureBoundary(_cardKey);
if (imageBytes == null) {
- if (!mounted) return;
- ScaffoldMessenger.of(context).showSnackBar(
- const SnackBar(
- content:
- Text('Could not prepare the share card. Please try again.'),
- ),
- );
+ _showMessage('Could not prepare the share card. Please try again.');
return;
}
@@ -75,111 +148,700 @@ class _AirQualityShareSheetState extends State {
sharePositionOrigin: widget.sharePositionOrigin,
);
} catch (_) {
+ _showMessage('Could not share the card. Please try again.');
+ } finally {
+ if (mounted) setState(() => _isSharingCard = false);
+ }
+ }
+
+ Future _pickSelfie(ImageSource source) async {
+ if (_isPickingSelfie) return;
+ setState(() => _isPickingSelfie = true);
+
+ try {
+ final picked = await _picker.pickImage(
+ source: source,
+ maxWidth: 1440,
+ imageQuality: 90,
+ preferredCameraDevice: CameraDevice.front,
+ );
+ if (picked == null) return;
if (!mounted) return;
- ScaffoldMessenger.of(context).showSnackBar(
- const SnackBar(
- content: Text('Could not share the card. Please try again.'),
+
+ setState(() => _selfieFile = File(picked.path));
+ } catch (_) {
+ _showMessage('Could not access the camera/gallery. Please try again.');
+ } finally {
+ if (mounted) setState(() => _isPickingSelfie = false);
+ }
+ }
+
+ Future _showSelfieSourceSheet() async {
+ final choice = await showModalBottomSheet<_SelfieSource>(
+ context: context,
+ backgroundColor: Colors.transparent,
+ builder: (sheetContext) {
+ // Same flush-to-bottom treatment as the main sheet — fold the
+ // safe-area inset into the content's own padding rather than
+ // leaving a transparent gap below the sheet.
+ final bottomSafeArea = MediaQuery.paddingOf(sheetContext).bottom;
+
+ return DecoratedBox(
+ decoration: BoxDecoration(
+ color: AppSurfaceColors.sheet(sheetContext),
+ borderRadius: const BorderRadius.vertical(
+ top: Radius.circular(LearnDesignTokens.sheetTopRadius),
+ ),
+ ),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ LearnDesignTokens.dragHandle(sheetContext),
+ Padding(
+ padding: EdgeInsets.only(bottom: 8 + bottomSafeArea),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ _SelfieSourceTile(
+ iconAsset: 'assets/icons/camera.svg',
+ title: 'Take photo',
+ subtitle: 'See the branding while you frame your shot',
+ onTap: () => Navigator.of(sheetContext)
+ .pop(_SelfieSource.liveCamera),
+ ),
+ _SelfieSourceTile(
+ iconAsset: 'assets/icons/gallery.svg',
+ title: 'Choose from gallery',
+ onTap: () =>
+ Navigator.of(sheetContext).pop(_SelfieSource.gallery),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ },
+ );
+
+ switch (choice) {
+ case _SelfieSource.liveCamera:
+ await _openLiveCamera();
+ break;
+ case _SelfieSource.gallery:
+ await _pickSelfie(ImageSource.gallery);
+ break;
+ case null:
+ break;
+ }
+ }
+
+ Future _openLiveCamera() async {
+ if (_isPickingSelfie) return;
+ setState(() => _isPickingSelfie = true);
+
+ try {
+ final file = await Navigator.of(context).push(
+ MaterialPageRoute(
+ fullscreenDialog: true,
+ builder: (_) => CleanAirForumCameraScreen(
+ measurement: widget.measurement,
+ fallbackLocationName: widget.fallbackLocationName,
+ ),
),
);
+ if (file != null && mounted) {
+ setState(() => _selfieFile = file);
+ }
+ } catch (_) {
+ _showMessage('Could not open the live camera. Please try again.');
} finally {
- if (mounted) {
- setState(() {
- _isSharingCard = false;
- });
+ if (mounted) setState(() => _isPickingSelfie = false);
+ }
+ }
+
+ Future _shareFilter() async {
+ if (_isSharingFilter || _selfieFile == null) return;
+ setState(() => _isSharingFilter = true);
+
+ try {
+ final imageBytes = await _captureBoundary(_filterKey);
+ if (imageBytes == null) {
+ _showMessage('Could not prepare the filter. Please try again.');
+ return;
}
+
+ await AirQualityShareService.shareCleanAirForumFilter(
+ imageBytes,
+ widget.measurement,
+ fallbackLocationName: widget.fallbackLocationName,
+ sharePositionOrigin: widget.sharePositionOrigin,
+ );
+
+ if (_consentToDisplay) {
+ unawaited(_submitToConferenceWall(imageBytes));
+ }
+ } catch (_) {
+ _showMessage('Could not share the filter. Please try again.');
+ } finally {
+ if (mounted) setState(() => _isSharingFilter = false);
}
}
- Future _captureCard() async {
- final pixelRatio =
- MediaQuery.of(context).devicePixelRatio.clamp(2.0, 3.0).toDouble();
+ Future _submitToConferenceWall(Uint8List imageBytes) async {
+ try {
+ await CleanAirForumSubmissionService.instance.submitSelfie(
+ imageBytes: imageBytes,
+ measurement: widget.measurement,
+ fallbackLocationName: widget.fallbackLocationName,
+ );
+ _showMessage('Sent to the Clean Air Forum screen!');
+ } catch (_) {
+ _showMessage(
+ 'Your photo shared fine, but sending it to the conference screen '
+ "didn't work. Please try again.",
+ );
+ }
+ }
- await Future.delayed(const Duration(milliseconds: 16));
+ /// Copies the sticker straight to the system clipboard so it can be
+ /// pasted directly into an Instagram Story (or anywhere else) without
+ /// going through the share sheet or saving a file first.
+ Future _copySticker() async {
+ if (_isCopyingSticker) return;
+ setState(() => _isCopyingSticker = true);
- final boundary =
- _cardKey.currentContext?.findRenderObject() as RenderRepaintBoundary?;
- if (boundary == null) return null;
+ try {
+ final imageBytes = await _captureBoundary(_stickerKey);
+ if (imageBytes == null) {
+ _showMessage('Could not prepare the sticker. Please try again.');
+ return;
+ }
- final image = await boundary.toImage(pixelRatio: pixelRatio);
- final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
+ await Pasteboard.writeImage(imageBytes);
- return byteData?.buffer.asUint8List();
+ // writeImage resolves successfully on iOS even when the image failed
+ // to decode there (UIPasteboard.general.image just gets set to nil) —
+ // read the clipboard back to confirm something was actually written
+ // before declaring success.
+ final clipboardImage = await Pasteboard.image;
+ if (clipboardImage == null || clipboardImage.isEmpty) {
+ _showMessage('Could not copy the sticker. Please try again.');
+ return;
+ }
+
+ _showMessage('Copied! Paste it into your Instagram Story.');
+ if (mounted) {
+ setState(() => _stickerCopied = true);
+ Timer(const Duration(seconds: 2), () {
+ if (mounted) setState(() => _stickerCopied = false);
+ });
+ }
+ } catch (_) {
+ _showMessage('Could not copy the sticker. Please try again.');
+ } finally {
+ if (mounted) setState(() => _isCopyingSticker = false);
+ }
}
@override
Widget build(BuildContext context) {
- final textColor = Theme.of(context).textTheme.headlineSmall?.color;
- final subtitleColor =
- Theme.of(context).textTheme.bodyMedium?.color?.withValues(alpha: 0.72);
+ // showModalBottomSheet does not resize its content for the keyboard on
+ // its own — without this, the keyboard simply overlays the bottom of
+ // the sheet (hiding the name field) instead of the sheet shrinking to
+ // make room above it.
+ final keyboardInset = MediaQuery.viewInsetsOf(context).bottom;
+ final maxHeight = MediaQuery.sizeOf(context).height * 0.92;
+ // Extend the sheet's own background flush to the bottom of the screen
+ // (per request) instead of leaving a transparent gap below it — the
+ // safe-area inset is folded into the *content's* bottom padding instead.
+ final bottomSafeArea = MediaQuery.paddingOf(context).bottom;
- return SafeArea(
- child: Padding(
- padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
+ return Padding(
+ padding: EdgeInsets.only(bottom: keyboardInset),
+ child: ConstrainedBox(
+ constraints: BoxConstraints(maxHeight: maxHeight),
child: DecoratedBox(
decoration: BoxDecoration(
- color: Theme.of(context).cardColor,
- borderRadius: BorderRadius.circular(28),
- ),
- child: Padding(
- padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
- child: Column(
- mainAxisSize: MainAxisSize.min,
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- 'Share card',
- style: TextStyle(
- fontSize: 20,
- fontWeight: FontWeight.w700,
- color: textColor,
- ),
- ),
- const SizedBox(height: 6),
- Text(
- 'Preview the card before sending it.',
- style: TextStyle(
- fontSize: 14,
- color: subtitleColor,
- ),
- ),
- const SizedBox(height: 16),
- RepaintBoundary(
- key: _cardKey,
- child: AirQualityShareCard(
- measurement: widget.measurement,
- fallbackLocationName: widget.fallbackLocationName,
+ color: AppSurfaceColors.sheet(context),
+ borderRadius: const BorderRadius.vertical(
+ top: Radius.circular(LearnDesignTokens.sheetTopRadius),
+ ),
+ ),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ LearnDesignTokens.dragHandle(context),
+ // `Flexible` (not `Expanded`) so short tabs (Card, Sticker)
+ // hug their content instead of stretching, while tall content
+ // (Filter tab + keyboard open) is properly bounded and
+ // becomes scrollable rather than overflowing past the sheet.
+ Flexible(
+ child: SingleChildScrollView(
+ padding: EdgeInsets.fromLTRB(20, 0, 20, 24 + bottomSafeArea),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Text('Share',
+ style: LearnDesignTokens.completionTitle(context)),
+ const SizedBox(height: 4),
+ Text(
+ _subtitleForTab(_tab),
+ style: LearnDesignTokens.completionSubtitle(context),
+ ),
+ const SizedBox(height: 12),
+ _buildTabSelector(),
+ if (_inlineMessage != null) ...[
+ const SizedBox(height: 12),
+ _InlineMessageBanner(message: _inlineMessage!),
+ ],
+ const SizedBox(height: 20),
+ _buildTabContent(),
+ ],
),
),
- const SizedBox(height: 16),
- SizedBox(
- width: double.infinity,
- child: ElevatedButton.icon(
- onPressed: _isSharingCard ? null : _shareCard,
- icon: _isSharingCard
- ? const SizedBox(
- width: 16,
- height: 16,
- child: CircularProgressIndicator(strokeWidth: 2),
- )
- : const Icon(Icons.ios_share_outlined),
- label: Text(
- _isSharingCard ? 'Preparing card...' : 'Share card',
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+
+ String _subtitleForTab(_ShareTab tab) {
+ switch (tab) {
+ case _ShareTab.filter:
+ return 'Add a selfie for a Clean Air Forum branded filter.';
+ case _ShareTab.card:
+ return 'Preview the card before sending it.';
+ case _ShareTab.sticker:
+ return 'A transparent sticker for your Instagram Story.';
+ }
+ }
+
+ Widget _buildTabSelector() {
+ return Row(
+ children: [
+ for (final spec in _shareTabSpecs) ...[
+ if (spec != _shareTabSpecs.first) const SizedBox(width: 8),
+ Expanded(
+ child: _TabChip(
+ label: spec.label,
+ selected: _tab == spec.tab,
+ onTap: () => setState(() => _tab = spec.tab),
+ ),
+ ),
+ ],
+ ],
+ );
+ }
+
+ Widget _buildTabContent() {
+ switch (_tab) {
+ case _ShareTab.filter:
+ return _buildFilterTab();
+ case _ShareTab.card:
+ return _buildCardTab();
+ case _ShareTab.sticker:
+ return _buildStickerTab();
+ }
+ }
+
+ Widget _buildCardTab() {
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ RepaintBoundary(
+ key: _cardKey,
+ child: AirQualityShareCard(
+ measurement: widget.measurement,
+ fallbackLocationName: widget.fallbackLocationName,
+ ),
+ ),
+ const SizedBox(height: 16),
+ _ShareButton(
+ label: _isSharingCard ? 'Preparing card...' : 'Share card',
+ loading: _isSharingCard,
+ onPressed: _isSharingCard ? null : _shareCard,
+ ),
+ ],
+ );
+ }
+
+ Widget _buildFilterTab() {
+ final hasSelfie = _selfieFile != null;
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ // The template is always visible — even before a selfie is picked —
+ // so it stays previewable while switching between share tabs. Full
+ // width (no artificial max-width cap) so it matches the Card tab's
+ // size for a consistent look across all three share formats.
+ RepaintBoundary(
+ key: _filterKey,
+ child: CleanAirForumFilterCard(
+ selfieFile: _selfieFile,
+ measurement: widget.measurement,
+ fallbackLocationName: widget.fallbackLocationName,
+ ),
+ ),
+ const SizedBox(height: 16),
+ if (hasSelfie)
+ OutlinedButton.icon(
+ onPressed: _isPickingSelfie ? null : _showSelfieSourceSheet,
+ style: learnExposureSecondaryButtonStyle(context),
+ icon: SvgPicture.asset(
+ 'assets/icons/camera.svg',
+ width: 18,
+ height: 18,
+ colorFilter: ColorFilter.mode(
+ LearnDesignTokens.headline(context),
+ BlendMode.srcIn,
+ ),
+ ),
+ label: const Text('Change photo'),
+ )
+ else
+ ElevatedButton.icon(
+ onPressed: _isPickingSelfie ? null : _showSelfieSourceSheet,
+ style: learnExposurePrimaryButtonStyle(enabled: !_isPickingSelfie),
+ icon: _isPickingSelfie
+ ? const SizedBox(
+ width: 16,
+ height: 16,
+ child: CircularProgressIndicator(
+ strokeWidth: 2,
+ color: Colors.white,
),
- style: ElevatedButton.styleFrom(
- backgroundColor: AppColors.primaryColor,
- foregroundColor: Colors.white,
- minimumSize: const Size.fromHeight(52),
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(18),
- ),
+ )
+ : SvgPicture.asset(
+ 'assets/icons/camera.svg',
+ width: 18,
+ height: 18,
+ colorFilter: ColorFilter.mode(
+ _isPickingSelfie
+ ? AppColors.boldHeadlineColor3
+ : Colors.white,
+ BlendMode.srcIn,
),
),
+ label: const Text('Take selfie'),
+ ),
+ if (hasSelfie) ...[
+ const SizedBox(height: 12),
+ _buildConsentSection(),
+ const SizedBox(height: 16),
+ _ShareButton(
+ label: _isSharingFilter ? 'Preparing filter...' : 'Share filter',
+ loading: _isSharingFilter,
+ onPressed: _isSharingFilter ? null : _shareFilter,
+ ),
+ ],
+ ],
+ );
+ }
+
+ Widget _buildConsentSection() {
+ return Row(
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'Display my photo on the conference screen',
+ style: TextStyle(
+ fontSize: 14,
+ fontWeight: FontWeight.w600,
+ color: LearnDesignTokens.headline(context),
),
- ],
+ ),
+ const SizedBox(height: 2),
+ Text(
+ 'Shown live on the Clean Air Forum wall display.',
+ style: LearnDesignTokens.completionCaption(context),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(width: 12),
+ CustomSwitch(
+ value: _consentToDisplay,
+ onChanged: (value) => setState(() => _consentToDisplay = value),
+ ),
+ ],
+ );
+ }
+
+ Widget _buildStickerTab() {
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ _CheckerboardBackground(
+ borderRadius: 26,
+ child: RepaintBoundary(
+ key: _stickerKey,
+ child: CleanAirForumStickerFrame(
+ measurement: widget.measurement,
+ fallbackLocationName: widget.fallbackLocationName,
),
),
),
+ const SizedBox(height: 12),
+ Text(
+ 'Copy it and paste it straight into your Instagram Story or any photo.',
+ textAlign: TextAlign.center,
+ style: LearnDesignTokens.completionCaption(context),
+ ),
+ const SizedBox(height: 16),
+ ElevatedButton.icon(
+ onPressed: _isCopyingSticker ? null : _copySticker,
+ style: _stickerCopied
+ ? ElevatedButton.styleFrom(
+ backgroundColor: LearnDesignTokens.success,
+ foregroundColor: Colors.white,
+ elevation: 0,
+ minimumSize: const Size(double.infinity, 48),
+ padding: const EdgeInsets.symmetric(vertical: 14),
+ shape: RoundedRectangleBorder(
+ borderRadius:
+ BorderRadius.circular(learnPrimaryButtonRadius),
+ ),
+ textStyle: learnPrimaryButtonTextStyle,
+ )
+ : learnExposurePrimaryButtonStyle(enabled: !_isCopyingSticker),
+ icon: _isCopyingSticker
+ ? const SizedBox(
+ width: 16,
+ height: 16,
+ child: CircularProgressIndicator(
+ strokeWidth: 2,
+ color: Colors.white,
+ ),
+ )
+ : SvgPicture.asset(
+ _stickerCopied
+ ? 'assets/icons/check-circle.svg'
+ : 'assets/icons/copy.svg',
+ width: 18,
+ height: 18,
+ colorFilter:
+ const ColorFilter.mode(Colors.white, BlendMode.srcIn),
+ ),
+ label: Text(
+ _isCopyingSticker
+ ? 'Copying...'
+ : _stickerCopied
+ ? 'Copied!'
+ : 'Copy sticker',
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+class _InlineMessageBanner extends StatelessWidget {
+ final String message;
+
+ const _InlineMessageBanner({required this.message});
+
+ @override
+ Widget build(BuildContext context) {
+ return DecoratedBox(
+ decoration: BoxDecoration(
+ color: LearnDesignTokens.nestedSurface(context),
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
+ child: Text(
+ message,
+ style: TextStyle(
+ fontSize: 13,
+ fontWeight: FontWeight.w600,
+ color: LearnDesignTokens.headline(context),
+ ),
+ ),
),
);
}
}
+
+class _SelfieSourceTile extends StatelessWidget {
+ final String iconAsset;
+ final String title;
+ final String? subtitle;
+ final VoidCallback onTap;
+
+ const _SelfieSourceTile({
+ required this.iconAsset,
+ required this.title,
+ this.subtitle,
+ required this.onTap,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return ListTile(
+ onTap: onTap,
+ leading: CircleAvatar(
+ backgroundColor: LearnDesignTokens.iconBg(context),
+ child: SvgPicture.asset(
+ iconAsset,
+ width: 20,
+ height: 20,
+ colorFilter: ColorFilter.mode(
+ LearnDesignTokens.headline(context),
+ BlendMode.srcIn,
+ ),
+ ),
+ ),
+ title: Text(
+ title,
+ style: TextStyle(
+ fontWeight: FontWeight.w600,
+ color: LearnDesignTokens.headline(context),
+ ),
+ ),
+ subtitle: subtitle == null
+ ? null
+ : Text(subtitle!,
+ style: LearnDesignTokens.completionCaption(context)),
+ );
+ }
+}
+
+/// Matches the pill-shaped view/country selector used on the dashboard, map,
+/// and learn tab (see `ViewSelector._buildViewButton`).
+class _TabChip extends StatelessWidget {
+ final String label;
+ final bool selected;
+ final VoidCallback onTap;
+
+ const _TabChip({
+ required this.label,
+ required this.selected,
+ required this.onTap,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final isDark = Theme.of(context).brightness == Brightness.dark;
+
+ return GestureDetector(
+ onTap: onTap,
+ child: Container(
+ padding: const EdgeInsets.symmetric(vertical: 12),
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ color: selected
+ ? AppColors.primaryColor
+ : (isDark
+ ? AppColors.darkHighlight
+ : AppColors.dividerColorlight),
+ borderRadius: BorderRadius.circular(30),
+ ),
+ child: Text(
+ label,
+ textAlign: TextAlign.center,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ fontWeight: FontWeight.w500,
+ color: selected
+ ? Colors.white
+ : (isDark ? Colors.white : Colors.black87),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _ShareButton extends StatelessWidget {
+ final String label;
+ final bool loading;
+ final VoidCallback? onPressed;
+
+ const _ShareButton({
+ required this.label,
+ required this.loading,
+ required this.onPressed,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return ElevatedButton.icon(
+ onPressed: onPressed,
+ style: learnExposurePrimaryButtonStyle(enabled: onPressed != null),
+ icon: loading
+ ? const SizedBox(
+ width: 16,
+ height: 16,
+ child: CircularProgressIndicator(
+ strokeWidth: 2,
+ color: Colors.white,
+ ),
+ )
+ : SvgPicture.asset(
+ 'assets/icons/share-icon.svg',
+ width: 18,
+ height: 18,
+ colorFilter:
+ const ColorFilter.mode(Colors.white, BlendMode.srcIn),
+ ),
+ label: Text(label),
+ );
+ }
+}
+
+/// Simple checkerboard backdrop shown only in-app (not part of the captured
+/// image) so users can see the sticker preview really is transparent.
+class _CheckerboardBackground extends StatelessWidget {
+ final Widget child;
+ final double borderRadius;
+
+ const _CheckerboardBackground({required this.child, this.borderRadius = 0});
+
+ @override
+ Widget build(BuildContext context) {
+ return ClipRRect(
+ borderRadius: BorderRadius.circular(borderRadius),
+ child: CustomPaint(
+ painter: _CheckerboardPainter(),
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: child,
+ ),
+ ),
+ );
+ }
+}
+
+class _CheckerboardPainter extends CustomPainter {
+ static const double _tile = 10;
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ final light = Paint()..color = const Color(0xFFEDEDED);
+ final dark = Paint()..color = const Color(0xFFD8D8D8);
+
+ canvas.drawRect(Offset.zero & size, light);
+
+ for (double y = 0; y < size.height; y += _tile) {
+ for (double x = 0; x < size.width; x += _tile) {
+ final isDark = ((x / _tile).floor() + (y / _tile).floor()) % 2 == 0;
+ if (isDark) {
+ canvas.drawRect(Rect.fromLTWH(x, y, _tile, _tile), dark);
+ }
+ }
+ }
+ }
+
+ @override
+ bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
+}
diff --git a/src/mobile/lib/src/app/dashboard/widgets/clean_air_forum_camera_screen.dart b/src/mobile/lib/src/app/dashboard/widgets/clean_air_forum_camera_screen.dart
new file mode 100644
index 0000000000..b856ce8790
--- /dev/null
+++ b/src/mobile/lib/src/app/dashboard/widgets/clean_air_forum_camera_screen.dart
@@ -0,0 +1,482 @@
+import 'dart:io';
+
+import 'package:airqo/src/app/dashboard/models/airquality_response.dart';
+import 'package:airqo/src/app/dashboard/utils/air_quality_card_utils.dart';
+import 'package:airqo/src/app/dashboard/utils/clean_air_forum_branding.dart';
+import 'package:airqo/src/app/dashboard/utils/measurement_location_utils.dart';
+import 'package:camera/camera.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:permission_handler/permission_handler.dart';
+
+/// Live camera preview with the Clean Air Forum filter chrome drawn on top
+/// (Snapchat-style), so users can see roughly how the branded card will
+/// look — and where to place their face — before they tap the shutter.
+///
+/// Returns the captured photo as a [File] via `Navigator.pop`, or `null` if
+/// the user backed out or the camera wasn't available (callers should fall
+/// back to the system camera/gallery picker in that case).
+class CleanAirForumCameraScreen extends StatefulWidget {
+ final Measurement measurement;
+ final String? fallbackLocationName;
+
+ const CleanAirForumCameraScreen({
+ super.key,
+ required this.measurement,
+ this.fallbackLocationName,
+ });
+
+ @override
+ State createState() =>
+ _CleanAirForumCameraScreenState();
+}
+
+class _CleanAirForumCameraScreenState extends State
+ with WidgetsBindingObserver {
+ CameraController? _controller;
+ List _cameras = const [];
+ int _cameraIndex = 0;
+ Future? _initializeFuture;
+ bool _isCapturing = false;
+ String? _errorMessage;
+
+ @override
+ void initState() {
+ super.initState();
+ WidgetsBinding.instance.addObserver(this);
+ SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
+ _initializeFuture = _setUp();
+ }
+
+ Future _setUp() async {
+ PermissionStatus status;
+ try {
+ status = await Permission.camera.request();
+ } catch (_) {
+ setState(() => _errorMessage = 'Could not request camera permission.');
+ return;
+ }
+
+ if (!status.isGranted) {
+ setState(() => _errorMessage =
+ 'Camera permission was denied. Enable it in system settings to '
+ 'use the live filter preview.');
+ return;
+ }
+
+ try {
+ _cameras = await availableCameras();
+ } catch (_) {
+ setState(() => _errorMessage = 'No camera is available on this device.');
+ return;
+ }
+
+ if (_cameras.isEmpty) {
+ setState(() => _errorMessage = 'No camera is available on this device.');
+ return;
+ }
+
+ final frontIndex = _cameras
+ .indexWhere((c) => c.lensDirection == CameraLensDirection.front);
+ _cameraIndex = frontIndex != -1 ? frontIndex : 0;
+
+ await _openCamera(_cameraIndex);
+ }
+
+ Future _openCamera(int index) async {
+ final previous = _controller;
+ _controller = CameraController(
+ _cameras[index],
+ ResolutionPreset.high,
+ enableAudio: false,
+ imageFormatGroup: ImageFormatGroup.jpeg,
+ );
+ try {
+ await _controller!.initialize();
+ } catch (_) {
+ final failed = _controller;
+ _controller = null;
+ await failed?.dispose();
+ if (mounted) setState(() => _errorMessage = 'Could not start the camera.');
+ return;
+ } finally {
+ await previous?.dispose();
+ }
+ if (mounted) setState(() {});
+ }
+
+ Future _switchCamera() async {
+ if (_cameras.length < 2 || _controller == null) return;
+ _cameraIndex = (_cameraIndex + 1) % _cameras.length;
+ setState(() {});
+ await _openCamera(_cameraIndex);
+ }
+
+ Future _capture() async {
+ final controller = _controller;
+ if (controller == null || !controller.value.isInitialized || _isCapturing) {
+ return;
+ }
+
+ setState(() => _isCapturing = true);
+ try {
+ final file = await controller.takePicture();
+ if (!mounted) return;
+ Navigator.of(context).pop(File(file.path));
+ } catch (_) {
+ if (mounted) setState(() => _isCapturing = false);
+ }
+ }
+
+ @override
+ void didChangeAppLifecycleState(AppLifecycleState state) {
+ final controller = _controller;
+ if (controller == null || !controller.value.isInitialized) return;
+
+ if (state == AppLifecycleState.inactive) {
+ // Clear the field before disposing — otherwise it keeps pointing at a
+ // disposed controller (dispose() doesn't flip isInitialized back to
+ // false), so a rebuild triggered before _openCamera() finishes on
+ // resume could still try to paint CameraPreview against it.
+ _controller = null;
+ if (mounted) setState(() {});
+ controller.dispose();
+ } else if (state == AppLifecycleState.resumed) {
+ _openCamera(_cameraIndex);
+ }
+ }
+
+ @override
+ void dispose() {
+ WidgetsBinding.instance.removeObserver(this);
+ SystemChrome.setPreferredOrientations(DeviceOrientation.values);
+ _controller?.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ backgroundColor: Colors.black,
+ body: SafeArea(
+ child: FutureBuilder(
+ future: _initializeFuture,
+ builder: (context, snapshot) {
+ if (_errorMessage != null) {
+ return _ErrorState(message: _errorMessage!);
+ }
+ final controller = _controller;
+ if (controller == null || !controller.value.isInitialized) {
+ return const Center(
+ child: CircularProgressIndicator(color: Colors.white),
+ );
+ }
+
+ return Stack(
+ fit: StackFit.expand,
+ children: [
+ Center(
+ child: AspectRatio(
+ aspectRatio: 1,
+ // Not mirrored — shows the same orientation as the
+ // photo that actually gets saved, so there's no
+ // surprise flip between preview and result.
+ child: ClipRect(
+ // CameraPreview stretches to fill whatever box it's
+ // given rather than preserving its native aspect
+ // ratio, which warps the feed (fisheye-looking) when
+ // that box (our 1:1 square) doesn't match the
+ // sensor's ratio. Size it at its true aspect ratio
+ // first, then cover-crop into the square so nothing
+ // gets stretched.
+ child: FittedBox(
+ fit: BoxFit.cover,
+ child: SizedBox(
+ width: 100,
+ height: 100 * controller.value.aspectRatio,
+ child: CameraPreview(controller),
+ ),
+ ),
+ ),
+ ),
+ ),
+ Center(
+ child: AspectRatio(
+ aspectRatio: 1,
+ child: _FilterGuideOverlay(
+ measurement: widget.measurement,
+ fallbackLocationName: widget.fallbackLocationName,
+ ),
+ ),
+ ),
+ Positioned(
+ top: 8,
+ left: 8,
+ child: _RoundIconButton(
+ icon: Icons.close_rounded,
+ onTap: () => Navigator.of(context).pop(),
+ ),
+ ),
+ if (_cameras.length > 1)
+ Positioned(
+ top: 8,
+ right: 8,
+ child: _RoundIconButton(
+ icon: Icons.flip_camera_ios_rounded,
+ onTap: _switchCamera,
+ ),
+ ),
+ Positioned(
+ left: 0,
+ right: 0,
+ bottom: 28,
+ child: Center(
+ child: GestureDetector(
+ onTap: _capture,
+ child: Container(
+ width: 76,
+ height: 76,
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ border: Border.all(color: Colors.white, width: 4),
+ ),
+ padding: const EdgeInsets.all(4),
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ color: _isCapturing ? Colors.white38 : Colors.white,
+ ),
+ child: _isCapturing
+ ? const Padding(
+ padding: EdgeInsets.all(18),
+ child: CircularProgressIndicator(
+ strokeWidth: 2,
+ ),
+ )
+ : null,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ],
+ );
+ },
+ ),
+ ),
+ );
+ }
+}
+
+/// Semi-transparent replica of the [CleanAirForumFilterCard] chrome, drawn
+/// over the live preview so users know where the brand header, AQI panel,
+/// and face guide will land.
+class _FilterGuideOverlay extends StatelessWidget {
+ final Measurement measurement;
+ final String? fallbackLocationName;
+
+ const _FilterGuideOverlay({
+ required this.measurement,
+ this.fallbackLocationName,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final locationName = sanitizeCardText(
+ measurementDisplayName(
+ measurement,
+ fallbackLocationName: fallbackLocationName,
+ ),
+ );
+ final category = aqiCategoryLabel(
+ sanitizeCardText(measurement.aqiCategory ?? 'Unavailable'),
+ );
+ final pm25Value = measurement.pm25?.value;
+ final categoryColor = getMeasurementAqiColor(measurement);
+
+ return LayoutBuilder(
+ builder: (context, constraints) {
+ final scale = constraints.maxWidth / kCafReferenceWidth;
+
+ return IgnorePointer(
+ child: Stack(
+ fit: StackFit.expand,
+ children: [
+ Positioned(
+ left: 0,
+ right: 0,
+ bottom: 0,
+ height: constraints.maxWidth * 0.62,
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ colors: [
+ CleanAirForumBrand.scrimTeal.withValues(alpha: 0),
+ CleanAirForumBrand.scrimTeal.withValues(alpha: 0.75),
+ ],
+ ),
+ ),
+ ),
+ ),
+ // Oval face guide so users know where to center themselves.
+ Center(
+ child: FractionallySizedBox(
+ widthFactor: 0.52,
+ heightFactor: 0.42,
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ border: Border.all(
+ color: Colors.white.withValues(alpha: 0.55),
+ width: 2,
+ ),
+ ),
+ ),
+ ),
+ ),
+ Positioned(
+ top: 53 * scale,
+ left: 44 * scale,
+ right: 44 * scale,
+ child: CleanAirForumBrandHeader(scale: scale),
+ ),
+ Positioned(
+ left: 44 * scale,
+ right: 31 * scale,
+ bottom: 44 * scale,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ locationName,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ color: Colors.white,
+ fontSize: 68.949 * scale,
+ fontWeight: FontWeight.w800,
+ ),
+ ),
+ SizedBox(height: 17 * scale),
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.end,
+ children: [
+ Text(
+ pm25Value != null
+ ? pm25Value.toStringAsFixed(1)
+ : '--',
+ style: TextStyle(
+ color: Colors.white,
+ fontSize: 92.14 * scale,
+ fontWeight: FontWeight.w800,
+ ),
+ ),
+ SizedBox(width: 8 * scale),
+ Padding(
+ padding: EdgeInsets.only(bottom: 10 * scale),
+ child: Text(
+ 'PM2.5 µg/m³',
+ style: TextStyle(
+ color: Colors.white,
+ fontSize: 21.021 * scale,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ),
+ SizedBox(width: 12 * scale),
+ Padding(
+ padding: EdgeInsets.only(bottom: 12 * scale),
+ child: Container(
+ padding: EdgeInsets.symmetric(
+ horizontal: 25.333 * scale,
+ vertical: 4.75 * scale,
+ ),
+ decoration: BoxDecoration(
+ color: aqiPillBackground(categoryColor),
+ borderRadius: BorderRadius.circular(999),
+ ),
+ child: Text(
+ category,
+ style: TextStyle(
+ color: categoryColor,
+ fontSize: 22.167 * scale,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ },
+ );
+ }
+}
+
+class _RoundIconButton extends StatelessWidget {
+ final IconData icon;
+ final VoidCallback onTap;
+
+ const _RoundIconButton({required this.icon, required this.onTap});
+
+ @override
+ Widget build(BuildContext context) {
+ return Material(
+ color: Colors.black.withValues(alpha: 0.4),
+ shape: const CircleBorder(),
+ child: InkWell(
+ customBorder: const CircleBorder(),
+ onTap: onTap,
+ child: Padding(
+ padding: const EdgeInsets.all(10),
+ child: Icon(icon, color: Colors.white, size: 22),
+ ),
+ ),
+ );
+ }
+}
+
+class _ErrorState extends StatelessWidget {
+ final String message;
+
+ const _ErrorState({required this.message});
+
+ @override
+ Widget build(BuildContext context) {
+ return Center(
+ child: Padding(
+ padding: const EdgeInsets.all(24),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Icon(Icons.videocam_off_rounded,
+ color: Colors.white70, size: 40),
+ const SizedBox(height: 16),
+ Text(
+ message,
+ textAlign: TextAlign.center,
+ style: const TextStyle(color: Colors.white70, fontSize: 14),
+ ),
+ const SizedBox(height: 20),
+ OutlinedButton(
+ style: OutlinedButton.styleFrom(
+ foregroundColor: Colors.white,
+ side: const BorderSide(color: Colors.white54),
+ ),
+ onPressed: () => Navigator.of(context).pop(),
+ child: const Text('Close'),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/src/mobile/lib/src/app/dashboard/widgets/clean_air_forum_filter_card.dart b/src/mobile/lib/src/app/dashboard/widgets/clean_air_forum_filter_card.dart
new file mode 100644
index 0000000000..5fb1d6aaf6
--- /dev/null
+++ b/src/mobile/lib/src/app/dashboard/widgets/clean_air_forum_filter_card.dart
@@ -0,0 +1,313 @@
+import 'dart:io';
+
+import 'package:airqo/src/app/dashboard/models/airquality_response.dart';
+import 'package:airqo/src/app/dashboard/utils/air_quality_card_utils.dart';
+import 'package:airqo/src/app/dashboard/utils/clean_air_forum_branding.dart';
+import 'package:airqo/src/app/dashboard/utils/measurement_location_utils.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_svg/flutter_svg.dart';
+
+/// A full-bleed selfie photo with an AirQo + Clean Air Forum branded overlay
+/// (location, live AQI value/category, event dates) anchored near the
+/// bottom — matches the "AQ/CAF" Figma template exactly (file
+/// `Z0OLd2awVqgZhytJULgO8L`, node 169:534), including its 1080x1080
+/// reference proportions (see [kCafReferenceWidth]).
+///
+/// If [selfieFile] is null a neutral placeholder avatar is shown instead so
+/// the template stays visible/previewable before the user picks a photo.
+///
+/// Meant to be wrapped in a `RepaintBoundary` and captured as a single PNG
+/// for sharing.
+class CleanAirForumFilterCard extends StatelessWidget {
+ final File? selfieFile;
+ final Measurement measurement;
+ final String? fallbackLocationName;
+
+ const CleanAirForumFilterCard({
+ super.key,
+ required this.selfieFile,
+ required this.measurement,
+ this.fallbackLocationName,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final locationName = sanitizeCardText(
+ measurementDisplayName(
+ measurement,
+ fallbackLocationName: fallbackLocationName,
+ ),
+ );
+ final locationDescription = sanitizeCardText(
+ measurementLocationDescription(measurement),
+ );
+ final category = aqiCategoryLabel(
+ sanitizeCardText(measurement.aqiCategory ?? 'Unavailable'),
+ );
+ final pm25Value = measurement.pm25?.value;
+ final categoryColor = getMeasurementAqiColor(measurement);
+
+ return AspectRatio(
+ aspectRatio: 1,
+ child: LayoutBuilder(
+ builder: (context, constraints) {
+ final width = constraints.maxWidth;
+ final scale = width / kCafReferenceWidth;
+
+ return ClipRRect(
+ borderRadius: BorderRadius.circular(32 * scale),
+ child: Stack(
+ fit: StackFit.expand,
+ children: [
+ selfieFile != null
+ ? Image.file(selfieFile!, fit: BoxFit.cover)
+ : _SelfiePlaceholder(scale: scale),
+ // Soft top scrim so the brand header stays legible over
+ // bright photos.
+ const DecoratedBox(
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ colors: [Colors.black38, Colors.transparent],
+ stops: [0.0, 0.3],
+ ),
+ ),
+ ),
+ // Teal scrim rising from the bottom, approximating the
+ // Figma radial-gradient wash behind the AQI panel.
+ Positioned(
+ left: 0,
+ right: 0,
+ bottom: 0,
+ height: width * 0.62,
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ colors: [
+ CleanAirForumBrand.scrimTeal.withValues(alpha: 0),
+ CleanAirForumBrand.scrimTeal.withValues(alpha: 0.5),
+ CleanAirForumBrand.scrimTeal.withValues(alpha: 0.94),
+ ],
+ stops: const [0.0, 0.5, 1.0],
+ ),
+ ),
+ ),
+ ),
+ Positioned(
+ top: 53 * scale,
+ left: 44 * scale,
+ right: 44 * scale,
+ child: CleanAirForumBrandHeader(scale: scale),
+ ),
+ Positioned(
+ left: 44 * scale,
+ right: 31 * scale,
+ bottom: 44 * scale,
+ child: _BottomPanel(
+ scale: scale,
+ categoryColor: categoryColor,
+ locationName: locationName,
+ locationDescription: locationDescription,
+ pm25Value: pm25Value,
+ category: category,
+ ),
+ ),
+ ],
+ ),
+ );
+ },
+ ),
+ );
+ }
+}
+
+/// Stand-in for the selfie photo before one is picked. Deliberately neutral
+/// (no decorative gradient/icon) so the rest of the stack — header, scrim,
+/// bottom panel — previews exactly as the final shared image will look.
+class _SelfiePlaceholder extends StatelessWidget {
+ final double scale;
+
+ const _SelfiePlaceholder({required this.scale});
+
+ @override
+ Widget build(BuildContext context) {
+ return ColoredBox(
+ color: const Color(0xFF2B3238),
+ child: Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ SvgPicture.asset(
+ 'assets/icons/camera.svg',
+ width: 64 * scale,
+ height: 64 * scale,
+ colorFilter: ColorFilter.mode(
+ Colors.white.withValues(alpha: 0.4),
+ BlendMode.srcIn,
+ ),
+ ),
+ SizedBox(height: 12 * scale),
+ Text(
+ 'Your photo here',
+ style: TextStyle(
+ color: Colors.white.withValues(alpha: 0.5),
+ fontSize: 22 * scale,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _BottomPanel extends StatelessWidget {
+ final double scale;
+ final Color categoryColor;
+ final String locationName;
+ final String locationDescription;
+ final double? pm25Value;
+ final String category;
+
+ const _BottomPanel({
+ required this.scale,
+ required this.categoryColor,
+ required this.locationName,
+ required this.locationDescription,
+ required this.pm25Value,
+ required this.category,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final pm25 = pm25Value;
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ locationName,
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ color: Colors.white,
+ fontSize: 68.949 * scale,
+ height: 1.05,
+ fontWeight: FontWeight.w800,
+ ),
+ ),
+ if (locationDescription.isNotEmpty) ...[
+ SizedBox(height: 8 * scale),
+ Text(
+ locationDescription,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ color: Colors.white,
+ fontSize: 22.701 * scale,
+ height: 1.2,
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ ],
+ SizedBox(height: 17 * scale),
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.end,
+ children: [
+ Text(
+ pm25 != null ? pm25.toStringAsFixed(1) : '--',
+ style: TextStyle(
+ color: Colors.white,
+ fontSize: 92.14 * scale,
+ height: 1.0,
+ fontWeight: FontWeight.w800,
+ ),
+ ),
+ SizedBox(width: 8 * scale),
+ Padding(
+ padding: EdgeInsets.only(bottom: 10 * scale),
+ child: Text(
+ 'PM2.5 µg/m³',
+ style: TextStyle(
+ color: Colors.white,
+ fontSize: 21.021 * scale,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ),
+ SizedBox(width: 12 * scale),
+ Padding(
+ padding: EdgeInsets.only(bottom: 12 * scale),
+ child: Container(
+ padding: EdgeInsets.symmetric(
+ horizontal: 25.333 * scale,
+ vertical: 4.75 * scale,
+ ),
+ decoration: BoxDecoration(
+ color: aqiPillBackground(categoryColor),
+ borderRadius: BorderRadius.circular(999),
+ ),
+ child: Text(
+ category,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ color: categoryColor,
+ fontSize: 22.167 * scale,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ SizedBox(height: 38.679 * scale),
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ Flexible(
+ child: Container(
+ padding: EdgeInsets.symmetric(
+ horizontal: 18 * scale,
+ vertical: 10 * scale,
+ ),
+ decoration: BoxDecoration(
+ color: Colors.white,
+ borderRadius: BorderRadius.circular(999),
+ ),
+ child: Text(
+ 'Shared from the AirQo app',
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ color: CleanAirForumBrand.sharedCaptionText,
+ fontSize: 22.642 * scale,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ),
+ ),
+ if (CleanAirForumBrand.dateRange.isNotEmpty) ...[
+ const Spacer(),
+ Text(
+ CleanAirForumBrand.dateRange,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ color: Colors.white,
+ fontSize: 35.974 * scale,
+ fontWeight: FontWeight.w800,
+ ),
+ ),
+ ],
+ ],
+ ),
+ ],
+ );
+ }
+}
diff --git a/src/mobile/lib/src/app/dashboard/widgets/clean_air_forum_sticker_frame.dart b/src/mobile/lib/src/app/dashboard/widgets/clean_air_forum_sticker_frame.dart
new file mode 100644
index 0000000000..c8dba49bcb
--- /dev/null
+++ b/src/mobile/lib/src/app/dashboard/widgets/clean_air_forum_sticker_frame.dart
@@ -0,0 +1,182 @@
+import 'package:airqo/src/app/dashboard/models/airquality_response.dart';
+import 'package:airqo/src/app/dashboard/utils/air_quality_card_utils.dart';
+import 'package:airqo/src/app/dashboard/utils/clean_air_forum_branding.dart';
+import 'package:airqo/src/app/dashboard/utils/measurement_location_utils.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_svg/flutter_svg.dart';
+
+/// A compact, transparent-background "sticker" — no photo, no card chrome —
+/// meant to be saved and dropped onto the user's own Instagram Story
+/// (Strava run-stat style). Matches the Figma "AQ/CAF Transparent" template
+/// exactly (file `Z0OLd2awVqgZhytJULgO8L`, node 169:491), including its
+/// 1080x1080 reference proportions (see [kCafReferenceWidth]).
+///
+/// Wrap in a `RepaintBoundary` and capture with `toImage`/`toByteData` using
+/// PNG format to preserve the transparent background.
+class CleanAirForumStickerFrame extends StatelessWidget {
+ final Measurement measurement;
+ final String? fallbackLocationName;
+
+ const CleanAirForumStickerFrame({
+ super.key,
+ required this.measurement,
+ this.fallbackLocationName,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final locationName = sanitizeCardText(
+ measurementDisplayName(
+ measurement,
+ fallbackLocationName: fallbackLocationName,
+ ),
+ );
+ final locationDescription = sanitizeCardText(
+ measurementLocationDescription(measurement),
+ );
+ final category = aqiCategoryLabel(
+ sanitizeCardText(measurement.aqiCategory ?? 'Unavailable'),
+ );
+ final pm25Value = measurement.pm25?.value;
+ final categoryColor = getMeasurementAqiColor(measurement);
+ final iconAsset = getMeasurementAqiIconAsset(measurement);
+
+ return AspectRatio(
+ aspectRatio: 1,
+ child: LayoutBuilder(
+ builder: (context, constraints) {
+ final scale = constraints.maxWidth / kCafReferenceWidth;
+
+ return Padding(
+ padding: EdgeInsets.symmetric(horizontal: 60 * scale),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ mainAxisSize: MainAxisSize.max,
+ children: [
+ Text(
+ locationName,
+ textAlign: TextAlign.center,
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ color: Colors.white,
+ fontSize: 68.949 * scale,
+ height: 1.05,
+ shadows: _textShadow,
+ fontWeight: FontWeight.w800,
+ ),
+ ),
+ if (locationDescription.isNotEmpty) ...[
+ SizedBox(height: 10 * scale),
+ Text(
+ locationDescription,
+ textAlign: TextAlign.center,
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ color: Colors.white,
+ fontSize: 22.701 * scale,
+ height: 1.2,
+ shadows: _textShadow,
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ ],
+ SizedBox(height: 30 * scale),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ crossAxisAlignment: CrossAxisAlignment.end,
+ children: [
+ Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.baseline,
+ textBaseline: TextBaseline.alphabetic,
+ children: [
+ Text(
+ pm25Value != null
+ ? pm25Value.toStringAsFixed(1)
+ : '--',
+ style: TextStyle(
+ color: Colors.white,
+ fontSize: 92.14 * scale,
+ height: 1.0,
+ shadows: _textShadow,
+ fontWeight: FontWeight.w800,
+ ),
+ ),
+ SizedBox(width: 6 * scale),
+ Padding(
+ padding: EdgeInsets.only(bottom: 6 * scale),
+ child: Text(
+ 'PM2.5\nµg/m³',
+ style: TextStyle(
+ color: Colors.white,
+ fontSize: 21.021 * scale,
+ height: 1.1,
+ shadows: _textShadow,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ),
+ ],
+ ),
+ SizedBox(height: 15 * scale),
+ Container(
+ padding: EdgeInsets.symmetric(
+ horizontal: 29.146 * scale,
+ vertical: 5.465 * scale,
+ ),
+ decoration: BoxDecoration(
+ color: aqiPillBackground(categoryColor),
+ borderRadius: BorderRadius.circular(999),
+ ),
+ child: Text(
+ category,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ color: categoryColor,
+ fontSize: 25.502 * scale,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ),
+ ],
+ ),
+ SizedBox(width: 45 * scale),
+ SvgPicture.asset(
+ iconAsset,
+ width: 195.598 * scale,
+ height: 195.598 * scale,
+ ),
+ ],
+ ),
+ SizedBox(height: 30 * scale),
+ AirQoIconMark(size: 45 * scale),
+ SizedBox(height: 10 * scale),
+ Text(
+ 'Shared from the AirQo app',
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ color: Colors.white,
+ fontSize: 18.853 * scale,
+ shadows: _textShadow,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ],
+ ),
+ );
+ },
+ ),
+ );
+ }
+
+ static const List _textShadow = [
+ Shadow(color: Colors.black38, blurRadius: 10, offset: Offset(0, 2)),
+ ];
+}
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 0c7b60c24f..b7cc2b3937 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
@@ -11,6 +11,7 @@ class ExposurePlaceNameTextField extends StatefulWidget {
final int? maxLines;
final int? minLines;
final bool enabled;
+ final FocusNode? focusNode;
const ExposurePlaceNameTextField({
super.key,
@@ -21,6 +22,7 @@ class ExposurePlaceNameTextField extends StatefulWidget {
this.maxLines = 1,
this.minLines,
this.enabled = true,
+ this.focusNode,
});
static const _borderIdle = Color(0xFFD0D5DD);
@@ -34,21 +36,25 @@ class ExposurePlaceNameTextField extends StatefulWidget {
}
class _ExposurePlaceNameTextFieldState extends State {
- late final FocusNode _focusNode;
+ FocusNode? _internalFocusNode;
bool _focused = false;
+ FocusNode get _focusNode => widget.focusNode ?? (_internalFocusNode ??= FocusNode());
+
@override
void initState() {
super.initState();
- _focusNode = FocusNode();
- _focusNode.addListener(() {
- setState(() => _focused = _focusNode.hasFocus);
- });
+ _focusNode.addListener(_handleFocusChange);
+ }
+
+ void _handleFocusChange() {
+ setState(() => _focused = _focusNode.hasFocus);
}
@override
void dispose() {
- _focusNode.dispose();
+ _focusNode.removeListener(_handleFocusChange);
+ _internalFocusNode?.dispose();
super.dispose();
}
diff --git a/src/mobile/lib/src/app/shared/services/air_quality_share_service.dart b/src/mobile/lib/src/app/shared/services/air_quality_share_service.dart
index 25084a6c10..7cb5716ef2 100644
--- a/src/mobile/lib/src/app/shared/services/air_quality_share_service.dart
+++ b/src/mobile/lib/src/app/shared/services/air_quality_share_service.dart
@@ -7,6 +7,10 @@ import 'package:share_plus/share_plus.dart';
class AirQualityShareService {
const AirQualityShareService._();
+ /// Included in shared card/filter messages so app installs from shares
+ /// can be tracked back to this feature.
+ static const String appLink = 'https://www.airqo.net/explore-data';
+
static Future shareMeasurement(
Measurement measurement, {
String? fallbackLocationName,
@@ -29,20 +33,72 @@ class AirQualityShareService {
Measurement measurement, {
String? fallbackLocationName,
Rect? sharePositionOrigin,
+ }) {
+ return _shareImage(
+ imageBytes,
+ fileName: 'airqo-air-quality-card.png',
+ text: buildShareMessage(
+ measurement,
+ fallbackLocationName: fallbackLocationName,
+ ),
+ sharePositionOrigin: sharePositionOrigin,
+ );
+ }
+
+ /// Shares the composited selfie + Clean Air Forum branded card.
+ static Future shareCleanAirForumFilter(
+ Uint8List imageBytes,
+ Measurement measurement, {
+ String? fallbackLocationName,
+ Rect? sharePositionOrigin,
+ }) {
+ final locationName = measurement.siteDetails?.searchName ??
+ fallbackLocationName ??
+ 'this location';
+
+ return _shareImage(
+ imageBytes,
+ fileName: 'clean-air-forum-filter.png',
+ text: "I'm checking the air quality in $locationName with AirQo at "
+ 'the Clean Air Forum! 🌍💨\n\n'
+ 'Join me on the app here: $appLink',
+ subject: 'Clean Air Forum x AirQo',
+ sharePositionOrigin: sharePositionOrigin,
+ );
+ }
+
+ /// Shares the transparent branding sticker meant to be pasted onto an
+ /// Instagram Story (or similar) as an overlay.
+ static Future shareStickerFrame(
+ Uint8List imageBytes, {
+ Rect? sharePositionOrigin,
+ }) {
+ return _shareImage(
+ imageBytes,
+ fileName: 'clean-air-forum-sticker.png',
+ text: 'Add this to your Instagram Story! 🌍💨 #CleanAirForum #AirQo',
+ subject: 'Clean Air Forum x AirQo',
+ sharePositionOrigin: sharePositionOrigin,
+ );
+ }
+
+ static Future _shareImage(
+ Uint8List imageBytes, {
+ required String fileName,
+ required String text,
+ String subject = 'Air quality update from AirQo',
+ Rect? sharePositionOrigin,
}) {
return Share.shareXFiles(
[
XFile.fromData(
imageBytes,
mimeType: 'image/png',
- name: 'airqo-air-quality-card.png',
+ name: fileName,
),
],
- text: buildShareMessage(
- measurement,
- fallbackLocationName: fallbackLocationName,
- ),
- subject: 'Air quality update from AirQo',
+ text: text,
+ subject: subject,
sharePositionOrigin: sharePositionOrigin,
);
}
@@ -67,7 +123,7 @@ class AirQualityShareService {
if (locationDescription.isNotEmpty) 'Location: $locationDescription',
if (healthTip != null && healthTip.isNotEmpty) healthTip,
'',
- 'Shared from the AirQo app.',
+ 'Join me on the app here: $appLink',
];
return lines.join('\n');
diff --git a/src/mobile/lib/src/app/shared/services/clean_air_forum_submission_service.dart b/src/mobile/lib/src/app/shared/services/clean_air_forum_submission_service.dart
new file mode 100644
index 0000000000..e43bb516c8
--- /dev/null
+++ b/src/mobile/lib/src/app/shared/services/clean_air_forum_submission_service.dart
@@ -0,0 +1,137 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:typed_data';
+
+import 'package:airqo/src/app/dashboard/models/airquality_response.dart';
+import 'package:flutter_dotenv/flutter_dotenv.dart';
+import 'package:http/http.dart' as http;
+import 'package:http_parser/http_parser.dart';
+import 'package:loggy/loggy.dart';
+
+/// Handles opt-in submissions of a user's Clean Air Forum selfie filter
+/// image to the conference "wall" display screen.
+///
+/// This currently talks to a **temporary mock API hosted in the AirQo
+/// website app** (`src/website/src/app/api/clean-air-forum/selfies`), built
+/// so the feature is fully demoable end-to-end before the real AirQo
+/// backend has equivalent endpoints. Swapping to the real backend later
+/// should only require changing [_baseUrl] (and the path, if different) —
+/// the request contract is designed to stay stable across that swap.
+///
+/// The `x-clean-air-forum-secret` header sent below is a **weak, interim
+/// deterrent only** — it's a static value bundled into the app binary, so
+/// it can be extracted and replayed by anyone who decompiles the app. It's
+/// good enough to block opportunistic abuse of a short-lived mock API, but
+/// the real backend swap should replace it with a proper request-specific
+/// mechanism (e.g. a short-lived token the backend mints per request/
+/// session) rather than carrying this static secret forward.
+class CleanAirForumSubmissionService with UiLoggy {
+ CleanAirForumSubmissionService._();
+
+ static final CleanAirForumSubmissionService instance =
+ CleanAirForumSubmissionService._();
+
+ static String get _baseUrl =>
+ (dotenv.env['CLEAN_AIR_FORUM_API_URL'] ?? 'https://airqo.net')
+ .trim()
+ .replaceAll(RegExp(r'/+$'), '');
+
+ /// The forum edition submissions are grouped under. Kept in sync with the
+ /// website wall page's "current event" config.
+ static String get defaultEventId =>
+ dotenv.env['CLEAN_AIR_FORUM_EVENT_ID'] ?? 'clean-air-forum';
+
+ /// Uploads [imageBytes] (the composited filter card PNG) to Cloudinary,
+ /// then posts it (plus AQI metadata) to the conference wall submissions
+ /// endpoint.
+ ///
+ /// This is a best-effort background action: callers should catch errors
+ /// and surface a soft warning without blocking the user's personal share
+ /// action, which must always succeed independently of this call.
+ Future submitSelfie({
+ required Uint8List imageBytes,
+ required Measurement measurement,
+ String? fallbackLocationName,
+ String? displayName,
+ String? eventId,
+ }) async {
+ final imageUrl = await _uploadToCloudinary(imageBytes);
+
+ final uri = Uri.parse('$_baseUrl/api/clean-air-forum/selfies');
+ final locationName = measurement.siteDetails?.searchName ??
+ measurement.siteDetails?.name ??
+ fallbackLocationName;
+ final submissionSecret = dotenv.env['CLEAN_AIR_FORUM_API_SECRET'];
+
+ final response = await http
+ .post(
+ uri,
+ headers: {
+ 'Content-Type': 'application/json',
+ // Lets the wall's API tell this app's submissions apart from
+ // anyone who finds the endpoint — must match the website's
+ // CLEAN_AIR_FORUM_SUBMISSION_SECRET.
+ if (submissionSecret != null && submissionSecret.isNotEmpty)
+ 'x-clean-air-forum-secret': submissionSecret,
+ },
+ body: jsonEncode({
+ 'eventId': eventId ?? defaultEventId,
+ 'imageUrl': imageUrl,
+ 'locationName': locationName,
+ 'pm25Value': measurement.pm25?.value,
+ 'aqiCategory': measurement.aqiCategory,
+ if (displayName != null && displayName.trim().isNotEmpty)
+ 'displayName': displayName.trim(),
+ }),
+ )
+ .timeout(const Duration(seconds: 30));
+
+ if (response.statusCode < 200 || response.statusCode >= 300) {
+ throw Exception(
+ 'Clean Air Forum submission failed: '
+ '${response.statusCode} ${response.body}',
+ );
+ }
+
+ loggy.info('Submitted selfie to Clean Air Forum wall: $imageUrl');
+ }
+
+ Future _uploadToCloudinary(Uint8List imageBytes) async {
+ final cloudName = dotenv.env['NEXT_PUBLIC_CLOUDINARY_NAME'] ?? '';
+ final uploadPreset = dotenv.env['NEXT_PUBLIC_CLOUDINARY_PRESET'] ?? '';
+ final cloudinaryUrl =
+ 'https://api.cloudinary.com/v1_1/$cloudName/image/upload';
+
+ final request = http.MultipartRequest('POST', Uri.parse(cloudinaryUrl))
+ ..fields['upload_preset'] = uploadPreset
+ ..fields['folder'] = 'clean_air_forum_selfies'
+ ..files.add(
+ http.MultipartFile.fromBytes(
+ 'file',
+ imageBytes,
+ filename: 'clean-air-forum-selfie.png',
+ contentType: MediaType('image', 'png'),
+ ),
+ );
+
+ final streamedResponse = await request.send().timeout(
+ const Duration(seconds: 60),
+ onTimeout: () => throw TimeoutException('Image upload timed out'),
+ );
+ final response = await http.Response.fromStream(streamedResponse);
+
+ if (response.statusCode != 200) {
+ throw Exception(
+ 'Cloudinary upload failed: ${response.statusCode}, ${response.body}',
+ );
+ }
+
+ final data = jsonDecode(response.body) as Map;
+ final url = data['secure_url'] as String?;
+ if (url == null) {
+ throw Exception('Cloudinary response did not include a secure_url');
+ }
+
+ return url;
+ }
+}
diff --git a/src/mobile/pubspec.lock b/src/mobile/pubspec.lock
index afb9077f7b..1b5a149c85 100644
--- a/src/mobile/pubspec.lock
+++ b/src/mobile/pubspec.lock
@@ -5,10 +5,10 @@ packages:
dependency: transitive
description:
name: _fe_analyzer_shared
- sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d"
+ sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f
url: "https://pub.dev"
source: hosted
- version: "93.0.0"
+ version: "85.0.0"
_flutterfire_internals:
dependency: transitive
description:
@@ -21,10 +21,10 @@ packages:
dependency: transitive
description:
name: analyzer
- sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b
+ sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d"
url: "https://pub.dev"
source: hosted
- version: "10.0.1"
+ version: "7.7.1"
args:
dependency: transitive
description:
@@ -85,18 +85,18 @@ packages:
dependency: transitive
description:
name: build
- sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10
+ sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7"
url: "https://pub.dev"
source: hosted
- version: "4.0.6"
+ version: "2.5.4"
build_config:
dependency: transitive
description:
name: build_config
- sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71"
+ sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33"
url: "https://pub.dev"
source: hosted
- version: "1.3.0"
+ version: "1.1.2"
build_daemon:
dependency: transitive
description:
@@ -105,14 +105,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.1"
+ build_resolvers:
+ dependency: transitive
+ description:
+ name: build_resolvers
+ sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.5.4"
build_runner:
dependency: "direct dev"
description:
name: build_runner
- sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6"
+ sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.5.4"
+ build_runner_core:
+ dependency: transitive
+ description:
+ name: build_runner_core
+ sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792"
url: "https://pub.dev"
source: hosted
- version: "2.15.0"
+ version: "9.1.2"
built_collection:
dependency: transitive
description:
@@ -129,14 +145,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "8.12.6"
+ camera:
+ dependency: "direct main"
+ description:
+ name: camera
+ sha256: "87a27e0553e3432119c1c2f6e4b9a1bbf7d2c660552b910bfa59185a9facd632"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.11.2+1"
+ camera_android_camerax:
+ dependency: transitive
+ description:
+ name: camera_android_camerax
+ sha256: "58b8fe843a3c83fd1273c00cb35f5a8ae507f6cc9b2029bcf7e2abba499e28d8"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.6.19+1"
+ camera_avfoundation:
+ dependency: transitive
+ description:
+ name: camera_avfoundation
+ sha256: "951ef122d01ebba68b7a54bfe294e8b25585635a90465c311b2f875ae72c412f"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.9.21+2"
+ camera_platform_interface:
+ dependency: transitive
+ description:
+ name: camera_platform_interface
+ sha256: "98cfc9357e04bad617671b4c1f78a597f25f08003089dd94050709ae54effc63"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.12.0"
+ camera_web:
+ dependency: transitive
+ description:
+ name: camera_web
+ sha256: "595f28c89d1fb62d77c73c633193755b781c6d2e0ebcd8dc25b763b514e6ba8f"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.3.5"
characters:
dependency: transitive
description:
name: characters
- sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
+ sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev"
source: hosted
- version: "1.4.1"
+ version: "1.4.0"
checked_yaml:
dependency: transitive
description:
@@ -245,10 +301,10 @@ packages:
dependency: transitive
description:
name: dart_style
- sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2"
+ sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb"
url: "https://pub.dev"
source: hosted
- version: "3.1.7"
+ version: "3.1.1"
dbus:
dependency: transitive
description:
@@ -309,10 +365,10 @@ packages:
dependency: transitive
description:
name: fake_async
- sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
+ sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc"
url: "https://pub.dev"
source: hosted
- version: "1.3.3"
+ version: "1.3.2"
ffi:
dependency: transitive
description:
@@ -897,10 +953,10 @@ packages:
dependency: "direct main"
description:
name: intl
- sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
+ sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
url: "https://pub.dev"
source: hosted
- version: "0.20.2"
+ version: "0.19.0"
io:
dependency: transitive
description:
@@ -929,10 +985,10 @@ packages:
dependency: "direct dev"
description:
name: json_serializable
- sha256: "5b89c1e32ae3840bb20a1b3434e3a590173ad3cb605896fb0f60487ce2f8104e"
+ sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c
url: "https://pub.dev"
source: hosted
- version: "6.11.4"
+ version: "6.9.5"
jwt_decoder:
dependency: "direct main"
description:
@@ -945,26 +1001,26 @@ packages:
dependency: transitive
description:
name: leak_tracker
- sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
+ sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec
url: "https://pub.dev"
source: hosted
- version: "11.0.2"
+ version: "10.0.8"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
- sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
+ sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
url: "https://pub.dev"
source: hosted
- version: "3.0.10"
+ version: "3.0.9"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
- sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
+ sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
url: "https://pub.dev"
source: hosted
- version: "3.0.2"
+ version: "3.0.1"
lints:
dependency: transitive
description:
@@ -1001,26 +1057,26 @@ packages:
dependency: transitive
description:
name: matcher
- sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
+ sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev"
source: hosted
- version: "0.12.19"
+ version: "0.12.17"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
- sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
+ sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev"
source: hosted
- version: "0.13.0"
+ version: "0.11.1"
meta:
dependency: transitive
description:
name: meta
- sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
+ sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
url: "https://pub.dev"
source: hosted
- version: "1.17.0"
+ version: "1.16.0"
mime:
dependency: transitive
description:
@@ -1033,10 +1089,10 @@ packages:
dependency: "direct main"
description:
name: mockito
- sha256: eff30d002f0c8bf073b6f929df4483b543133fcafce056870163587b03f1d422
+ sha256: "4546eac99e8967ea91bae633d2ca7698181d008e95fa4627330cf903d573277a"
url: "https://pub.dev"
source: hosted
- version: "5.6.4"
+ version: "5.4.6"
mocktail:
dependency: transitive
description:
@@ -1101,6 +1157,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.2.1"
+ pasteboard:
+ dependency: "direct main"
+ description:
+ name: pasteboard
+ sha256: fedbe8da188d2f713aa8b01260737342e6e1087534a3ab26e1a719f8d3e8f32f
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.5.0"
path:
dependency: transitive
description:
@@ -1438,18 +1502,18 @@ packages:
dependency: transitive
description:
name: source_gen
- sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02
+ sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b"
url: "https://pub.dev"
source: hosted
- version: "4.2.3"
+ version: "2.0.0"
source_helper:
dependency: transitive
description:
name: source_helper
- sha256: "4227d54ceefd0bb8ca4c8fcb96e1719dc53f1ee1b6e2ca9d7a6069da160e4eae"
+ sha256: a447acb083d3a5ef17f983dd36201aeea33fedadb3228fa831f2f0c92f0f3aca
url: "https://pub.dev"
source: hosted
- version: "1.3.12"
+ version: "1.3.7"
source_map_stack_trace:
dependency: transitive
description:
@@ -1526,26 +1590,26 @@ packages:
dependency: transitive
description:
name: test
- sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
+ sha256: "301b213cd241ca982e9ba50266bd3f5bd1ea33f1455554c5abb85d1be0e2d87e"
url: "https://pub.dev"
source: hosted
- version: "1.30.0"
+ version: "1.25.15"
test_api:
dependency: transitive
description:
name: test_api
- sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
+ sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
url: "https://pub.dev"
source: hosted
- version: "0.7.10"
+ version: "0.7.4"
test_core:
dependency: transitive
description:
name: test_core
- sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
+ sha256: "84d17c3486c8dfdbe5e12a50c8ae176d15e2a771b96909a9442b40173649ccaa"
url: "https://pub.dev"
source: hosted
- version: "0.6.16"
+ version: "0.6.8"
timezone:
dependency: transitive
description:
@@ -1554,6 +1618,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.10.1"
+ timing:
+ dependency: transitive
+ description:
+ name: timing
+ sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.2"
typed_data:
dependency: transitive
description:
@@ -1678,10 +1750,10 @@ packages:
dependency: transitive
description:
name: vector_math
- sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
+ sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
url: "https://pub.dev"
source: hosted
- version: "2.2.0"
+ version: "2.1.4"
video_player:
dependency: "direct main"
description:
@@ -1867,5 +1939,5 @@ packages:
source: hosted
version: "3.1.2"
sdks:
- dart: ">=3.10.0 <4.0.0"
+ dart: ">=3.7.0 <4.0.0"
flutter: ">=3.29.0"
diff --git a/src/mobile/pubspec.yaml b/src/mobile/pubspec.yaml
index 074cc68ee7..796f5c0e8a 100644
--- a/src/mobile/pubspec.yaml
+++ b/src/mobile/pubspec.yaml
@@ -64,6 +64,7 @@ dependencies:
permission_handler: ^11.4.0
geolocator: ^13.0.2
image_picker: ^1.1.2
+ camera: ^0.11.0+2
http_parser: ^4.0.2
shared_preferences: ^2.5.3
google_mlkit_translation: ^0.13.0
@@ -92,6 +93,7 @@ dependencies:
share_plus: ^10.1.4
screenshot: ^3.0.0
path_provider: ^2.1.5
+ pasteboard: ^0.5.0
dev_dependencies:
diff --git a/src/mobile/test/app/dashboard/utils/air_quality_card_utils_test.dart b/src/mobile/test/app/dashboard/utils/air_quality_card_utils_test.dart
new file mode 100644
index 0000000000..b1cd6fce81
--- /dev/null
+++ b/src/mobile/test/app/dashboard/utils/air_quality_card_utils_test.dart
@@ -0,0 +1,30 @@
+import 'package:airqo/src/app/dashboard/utils/air_quality_card_utils.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ group('sanitizeCardText', () {
+ test('fixes simple Latin-1 mojibake', () {
+ expect(sanitizeCardText('Café'), 'Café');
+ });
+
+ test('fixes Windows-1252 smart-quote mojibake', () {
+ expect(sanitizeCardText('it’s'), 'it’s');
+ });
+
+ test('fixes Windows-1252 em-dash mojibake', () {
+ expect(sanitizeCardText('9am–5pm'), '9am–5pm');
+ });
+
+ test('leaves plain ASCII text unchanged', () {
+ const value = 'Nairobi, Kenya';
+ expect(sanitizeCardText(value), value);
+ });
+
+ test('leaves legitimate accented text unchanged', () {
+ // A bare 'â' is a real letter (French/Portuguese/Vietnamese etc.),
+ // not a mojibake artifact — must not be "fixed" into garbage.
+ const value = 'Château, âge';
+ expect(sanitizeCardText(value), value);
+ });
+ });
+}