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); + }); + }); +}