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 4f05ddbdc6..dec85f6fd0 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,24 +1,20 @@ 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_filter_tab.dart'; import 'package:airqo/src/app/dashboard/widgets/clean_air_forum_sticker_frame.dart'; +import 'package:airqo/src/app/dashboard/widgets/share_sheet_widgets.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/analytics_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/semantics.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import 'package:image_picker/image_picker.dart'; import 'package:pasteboard/pasteboard.dart'; import 'package:share_plus/share_plus.dart'; @@ -58,8 +54,6 @@ enum _ShareTab { const _ShareTab({required this.label, required this.analyticsName}); } -enum _SelfieSource { liveCamera, gallery } - class AirQualityShareSheet extends StatefulWidget { final Measurement measurement; @@ -69,12 +63,17 @@ class AirQualityShareSheet extends StatefulWidget { final String? fallbackLocationName; final Rect? sharePositionOrigin; + /// Injected for tests — defaults to the app-wide + /// [CleanAirForumSubmissionService.instance] (DIP). + final CleanAirForumSubmissionService? submissionService; + const AirQualityShareSheet({ super.key, required this.measurement, required this.source, this.fallbackLocationName, this.sharePositionOrigin, + this.submissionService, }); @override @@ -86,23 +85,27 @@ class _AirQualityShareSheetState extends State { _ShareTab _tab = _ShareTab.filter; final GlobalKey _cardKey = GlobalKey(); - final GlobalKey _filterKey = GlobalKey(); final GlobalKey _stickerKey = GlobalKey(); - final ImagePicker _picker = ImagePicker(); - + // Lifted from the filter tab: its subtree is unmounted on tab switch, so + // the picked selfie and consent choice live here to survive switching. File? _selfieFile; bool _consentToDisplay = false; - bool _isPickingSelfie = false; bool _isSharingCard = false; - bool _isSharingFilter = false; bool _isCopyingSticker = false; bool _stickerCopied = false; String? _inlineMessage; + bool _inlineIsError = false; + String? _inlineActionLabel; + VoidCallback? _inlineOnAction; Timer? _inlineMessageTimer; + /// Captured while mounted so late results (the background wall + /// submission) can still surface after the sheet is closed. + ScaffoldMessengerState? _rootMessenger; + bool _cafFilterTabTracked = false; @override @@ -112,6 +115,12 @@ class _AirQualityShareSheetState extends State { if (_tab == _ShareTab.filter) _trackCafFilterTabOpened(); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _rootMessenger = ScaffoldMessenger.maybeOf(context); + } + /// caf_filter_tab_opened confirms discovery of the forum filter, so it /// fires once per sheet open — whether the tab was the default or tapped. void _trackCafFilterTabOpened() { @@ -133,30 +142,51 @@ class _AirQualityShareSheetState extends State { super.dispose(); } - 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; + /// + /// If the sheet has already been closed (the background wall submission + /// can finish after dismissal), the result falls back to a root SnackBar + /// instead of being silently dropped. + void _showMessage( + String message, { + bool isError = false, + String? actionLabel, + VoidCallback? onAction, + }) { + if (!mounted) { + _rootMessenger?.showSnackBar( + SnackBar( + content: Text(message), + action: actionLabel == null || onAction == null + ? null + : SnackBarAction(label: actionLabel, onPressed: onAction), + ), + ); + return; + } + + // The banner self-dismisses, so screen-reader users would miss it + // entirely without an explicit announcement. + unawaited(SemanticsService.sendAnnouncement( + View.of(context), + message, + Directionality.of(context), + )); + _inlineMessageTimer?.cancel(); - setState(() => _inlineMessage = message); - _inlineMessageTimer = Timer(const Duration(seconds: 3), () { + setState(() { + _inlineMessage = message; + _inlineIsError = isError; + _inlineActionLabel = actionLabel; + _inlineOnAction = onAction; + }); + // Errors and longer messages linger; short confirmations clear fast. + final duration = Duration( + milliseconds: (2500 + message.length * 35).clamp(3000, 6500), + ); + _inlineMessageTimer = Timer(duration, () { if (mounted) setState(() => _inlineMessage = null); }); } @@ -166,9 +196,9 @@ class _AirQualityShareSheetState extends State { setState(() => _isSharingCard = true); try { - final imageBytes = await _captureBoundary(_cardKey); + final imageBytes = await captureShareBoundary(context, _cardKey); if (imageBytes == null) { - _showMessage('Could not prepare the share card. Please try again.'); + _showMessage("Couldn't share the card. Try again.", isError: true); return; } @@ -185,178 +215,12 @@ class _AirQualityShareSheetState extends State { ); } } catch (_) { - _showMessage('Could not share the card. Please try again.'); + _showMessage("Couldn't share the card. Try again.", isError: true); } 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; - - 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: - AnalyticsService().trackCafSelfieSourceSelected(source: 'live_camera'); - await _openLiveCamera(); - break; - case _SelfieSource.gallery: - AnalyticsService().trackCafSelfieSourceSelected(source: '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( - settings: const RouteSettings(name: 'clean_air_forum_camera'), - fullscreenDialog: true, - builder: (_) => CleanAirForumCameraScreen( - measurement: widget.measurement, - fallbackLocationName: widget.fallbackLocationName, - ), - ), - ); - if (file != null && mounted) { - setState(() => _selfieFile = file); - AnalyticsService().trackCafSelfieCaptured(); - } - } catch (_) { - _showMessage('Could not open the live camera. Please try again.'); - } finally { - 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; - } - - final result = await AirQualityShareService.shareCleanAirForumFilter( - imageBytes, - widget.measurement, - fallbackLocationName: widget.fallbackLocationName, - sharePositionOrigin: widget.sharePositionOrigin, - ); - if (result.status == ShareResultStatus.success) { - AnalyticsService().trackCafFilterShared(); - AnalyticsService().trackShareCompleted( - format: _ShareTab.filter.analyticsName, - method: 'share_sheet', - ); - } - - if (_consentToDisplay) { - unawaited(_submitToConferenceWall(imageBytes)); - } - } catch (_) { - _showMessage('Could not share the filter. Please try again.'); - } finally { - if (mounted) setState(() => _isSharingFilter = false); - } - } - - Future _submitToConferenceWall(Uint8List imageBytes) async { - try { - await CleanAirForumSubmissionService.instance.submitSelfie( - imageBytes: imageBytes, - measurement: widget.measurement, - fallbackLocationName: widget.fallbackLocationName, - ); - AnalyticsService().trackCafWallSubmissionSent(); - _showMessage('Sent to the Clean Air Forum screen!'); - } catch (e) { - // Report only the exception type — raw messages can carry API - // response details that don't belong in analytics. - AnalyticsService() - .trackCafWallSubmissionFailed(error: e.runtimeType.toString()); - _showMessage( - 'Your photo shared fine, but sending it to the conference screen ' - "didn't work. Please try again.", - ); - } - } - /// 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. @@ -365,9 +229,9 @@ class _AirQualityShareSheetState extends State { setState(() => _isCopyingSticker = true); try { - final imageBytes = await _captureBoundary(_stickerKey); + final imageBytes = await captureShareBoundary(context, _stickerKey); if (imageBytes == null) { - _showMessage('Could not prepare the sticker. Please try again.'); + _showMessage("Couldn't copy the sticker. Try again.", isError: true); return; } @@ -379,7 +243,7 @@ class _AirQualityShareSheetState extends State { // before declaring success. final clipboardImage = await Pasteboard.image; if (clipboardImage == null || clipboardImage.isEmpty) { - _showMessage('Could not copy the sticker. Please try again.'); + _showMessage("Couldn't copy the sticker. Try again.", isError: true); return; } @@ -387,7 +251,7 @@ class _AirQualityShareSheetState extends State { format: _ShareTab.sticker.analyticsName, method: 'clipboard_copy', ); - _showMessage('Copied! Paste it into your Instagram Story.'); + _showMessage('Copied! Paste it into your Story.'); if (mounted) { setState(() => _stickerCopied = true); Timer(const Duration(seconds: 2), () { @@ -395,7 +259,7 @@ class _AirQualityShareSheetState extends State { }); } } catch (_) { - _showMessage('Could not copy the sticker. Please try again.'); + _showMessage("Couldn't copy the sticker. Try again.", isError: true); } finally { if (mounted) setState(() => _isCopyingSticker = false); } @@ -451,7 +315,18 @@ class _AirQualityShareSheetState extends State { _buildTabSelector(), if (_inlineMessage != null) ...[ const SizedBox(height: 12), - _InlineMessageBanner(message: _inlineMessage!), + InlineMessageBanner( + message: _inlineMessage!, + isError: _inlineIsError, + actionLabel: _inlineActionLabel, + onAction: _inlineOnAction == null + ? null + : () { + final action = _inlineOnAction!; + setState(() => _inlineMessage = null); + action(); + }, + ), ], const SizedBox(height: 20), _buildTabContent(), @@ -483,7 +358,7 @@ class _AirQualityShareSheetState extends State { for (final tab in _ShareTab.values) ...[ if (tab != _ShareTab.values.first) const SizedBox(width: 8), Expanded( - child: _TabChip( + child: ShareTabChip( label: tab.label, selected: _tab == tab, onTap: () => _selectTab(tab), @@ -497,7 +372,19 @@ class _AirQualityShareSheetState extends State { Widget _buildTabContent() { switch (_tab) { case _ShareTab.filter: - return _buildFilterTab(); + return CleanAirForumFilterTab( + measurement: widget.measurement, + fallbackLocationName: widget.fallbackLocationName, + sharePositionOrigin: widget.sharePositionOrigin, + analyticsFormat: _ShareTab.filter.analyticsName, + selfieFile: _selfieFile, + onSelfieChanged: (file) => setState(() => _selfieFile = file), + consentToDisplay: _consentToDisplay, + onConsentChanged: (value) => + setState(() => _consentToDisplay = value), + onMessage: _showMessage, + submissionService: widget.submissionService, + ); case _ShareTab.card: return _buildCardTab(); case _ShareTab.sticker: @@ -517,7 +404,7 @@ class _AirQualityShareSheetState extends State { ), ), const SizedBox(height: 16), - _ShareButton( + ShareActionButton( label: _isSharingCard ? 'Preparing card...' : 'Share card', loading: _isSharingCard, onPressed: _isSharingCard ? null : _shareCard, @@ -526,121 +413,11 @@ class _AirQualityShareSheetState extends State { ); } - 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, - ), - ) - : 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); - if (value) AnalyticsService().trackCafWallConsentGiven(); - }, - ), - ], - ); - } - Widget _buildStickerTab() { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _CheckerboardBackground( + CheckerboardBackground( borderRadius: 26, child: RepaintBoundary( key: _stickerKey, @@ -703,205 +480,3 @@ class _AirQualityShareSheetState extends State { ); } } - -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_filter_tab.dart b/src/mobile/lib/src/app/dashboard/widgets/clean_air_forum_filter_tab.dart new file mode 100644 index 0000000000..ca8bed0f60 --- /dev/null +++ b/src/mobile/lib/src/app/dashboard/widgets/clean_air_forum_filter_tab.dart @@ -0,0 +1,479 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:airqo/src/app/dashboard/models/airquality_response.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/share_sheet_widgets.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/analytics_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/services.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:share_plus/share_plus.dart'; + +enum _SelfieSource { liveCamera, gallery } + +/// The "Forum filter" tab of the share sheet: selfie acquisition (live +/// camera or gallery), branded filter preview, the share action, and the +/// opt-in submission to the conference wall display. +/// +/// The selfie and consent values are lifted to the parent sheet — this +/// widget is unmounted when the user switches share tabs, so holding them +/// here would lose them on every switch. +class CleanAirForumFilterTab extends StatefulWidget { + final Measurement measurement; + final String? fallbackLocationName; + final Rect? sharePositionOrigin; + + /// Stable `format` value reported on share_completed; owned by the parent + /// sheet's tab definitions so all tabs stay consistent. + final String analyticsFormat; + + final File? selfieFile; + final ValueChanged onSelfieChanged; + final bool consentToDisplay; + final ValueChanged onConsentChanged; + + /// Surfaces status/error text in the sheet's inline banner. + final ShareSheetMessenger onMessage; + + /// Injected for tests — defaults to the app-wide + /// [CleanAirForumSubmissionService.instance] (DIP). + final CleanAirForumSubmissionService? submissionService; + + const CleanAirForumFilterTab({ + super.key, + required this.measurement, + required this.analyticsFormat, + required this.selfieFile, + required this.onSelfieChanged, + required this.consentToDisplay, + required this.onConsentChanged, + required this.onMessage, + this.fallbackLocationName, + this.sharePositionOrigin, + this.submissionService, + }); + + @override + State createState() => _CleanAirForumFilterTabState(); +} + +class _CleanAirForumFilterTabState extends State { + final GlobalKey _filterKey = GlobalKey(); + final ImagePicker _picker = ImagePicker(); + + bool _isPickingSelfie = false; + bool _isSharingFilter = false; + bool _isSendingToWall = false; + + CleanAirForumSubmissionService get _submissionService => + widget.submissionService ?? CleanAirForumSubmissionService.instance; + + 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; + + widget.onSelfieChanged(File(picked.path)); + } on PlatformException catch (e) { + // "Try again" is wrong advice when access is denied — the fix lives + // in system settings, so link straight there. + if (e.code.contains('denied')) { + widget.onMessage( + source == ImageSource.camera + ? 'Camera access is off.' + : 'Photo access is off.', + isError: true, + actionLabel: 'Settings', + onAction: openAppSettings, + ); + } else { + widget.onMessage( + source == ImageSource.camera + ? "Couldn't open the camera." + : "Couldn't open the gallery.", + isError: true, + ); + } + } catch (_) { + widget.onMessage( + source == ImageSource.camera + ? "Couldn't open the camera." + : "Couldn't open the gallery.", + isError: true, + ); + } 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: + AnalyticsService().trackCafSelfieSourceSelected(source: 'live_camera'); + await _openLiveCamera(); + break; + case _SelfieSource.gallery: + AnalyticsService().trackCafSelfieSourceSelected(source: '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( + settings: const RouteSettings(name: 'clean_air_forum_camera'), + fullscreenDialog: true, + builder: (_) => CleanAirForumCameraScreen( + measurement: widget.measurement, + fallbackLocationName: widget.fallbackLocationName, + ), + ), + ); + if (file != null && mounted) { + widget.onSelfieChanged(file); + AnalyticsService().trackCafSelfieCaptured(); + } + } catch (_) { + widget.onMessage("Couldn't open the camera.", isError: true); + } finally { + if (mounted) setState(() => _isPickingSelfie = false); + } + } + + Future _shareFilter() async { + if (_isSharingFilter || widget.selfieFile == null) return; + setState(() => _isSharingFilter = true); + + try { + final imageBytes = await captureShareBoundary(context, _filterKey); + if (imageBytes == null) { + widget.onMessage("Couldn't share the filter. Try again.", + isError: true); + return; + } + + final result = await AirQualityShareService.shareCleanAirForumFilter( + imageBytes, + widget.measurement, + fallbackLocationName: widget.fallbackLocationName, + sharePositionOrigin: widget.sharePositionOrigin, + ); + if (result.status == ShareResultStatus.success) { + AnalyticsService().trackCafFilterShared(); + AnalyticsService().trackShareCompleted( + format: widget.analyticsFormat, + method: 'share_sheet', + ); + } + + if (widget.consentToDisplay) { + unawaited(_submitToConferenceWall(imageBytes)); + } + } catch (_) { + widget.onMessage("Couldn't share the filter. Try again.", isError: true); + } finally { + if (mounted) setState(() => _isSharingFilter = false); + } + } + + Future _submitToConferenceWall(Uint8List imageBytes) async { + // Retry (which can fire from the root SnackBar even after dismissal) + // and repeated shares must not enqueue duplicate wall submissions, so + // the flag is set outside setState and checked before every start. + if (_isSendingToWall) return; + _isSendingToWall = true; + if (mounted) setState(() {}); + try { + final wallName = await _submissionService.submitSelfie( + imageBytes: imageBytes, + measurement: widget.measurement, + fallbackLocationName: widget.fallbackLocationName, + ); + AnalyticsService().trackCafWallSubmissionSent(); + widget.onMessage( + wallName == null + ? "You're on the forum wall!" + : "You're on the wall as $wallName!", + ); + } catch (e) { + // Report only the failure category — raw messages can carry API + // response details that don't belong in analytics. + AnalyticsService().trackCafWallSubmissionFailed( + error: e is SelfieSubmissionException + ? e.kind.name + : e.runtimeType.toString(), + ); + widget.onMessage( + _wallFailureMessage(e), + isError: true, + actionLabel: 'Retry', + onAction: () => _submitToConferenceWall(imageBytes), + ); + } finally { + _isSendingToWall = false; + if (mounted) setState(() {}); + } + } + + String _wallFailureMessage(Object e) { + if (e is SelfieSubmissionException) { + switch (e.kind) { + case SelfieSubmissionFailure.offline: + return "You're offline — couldn't reach the wall."; + case SelfieSubmissionFailure.timeout: + return "Slow connection — couldn't reach the wall."; + case SelfieSubmissionFailure.server: + break; + } + } + return "Couldn't send to the wall."; + } + + @override + Widget build(BuildContext context) { + final hasSelfie = widget.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: widget.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, + ), + ) + : 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), + ShareActionButton( + label: _isSharingFilter ? 'Preparing filter...' : 'Share filter', + loading: _isSharingFilter, + onPressed: _isSharingFilter ? null : _shareFilter, + ), + // The wall submission runs in the background after the share — + // without this the user gets no sign anything is still happening. + if (_isSendingToWall) ...[ + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 10), + Text( + 'Sending to the wall…', + style: LearnDesignTokens.completionCaption(context), + ), + ], + ), + ], + ], + ], + ); + } + + 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: widget.consentToDisplay, + onChanged: (value) { + widget.onConsentChanged(value); + if (value) AnalyticsService().trackCafWallConsentGiven(); + }, + ), + ], + ); + } +} + +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)), + ); + } +} diff --git a/src/mobile/lib/src/app/dashboard/widgets/share_sheet_widgets.dart b/src/mobile/lib/src/app/dashboard/widgets/share_sheet_widgets.dart new file mode 100644 index 0000000000..173dc50449 --- /dev/null +++ b/src/mobile/lib/src/app/dashboard/widgets/share_sheet_widgets.dart @@ -0,0 +1,239 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +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/meta/utils/colors.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +/// Rasterizes the [RepaintBoundary] under [key] into PNG bytes for sharing. +/// The device-pixel ratio is clamped so share images stay sharp without +/// ballooning memory on high-density screens. +Future captureShareBoundary( + BuildContext context, + GlobalKey key, +) async { + final pixelRatio = + MediaQuery.of(context).devicePixelRatio.clamp(2.0, 3.0).toDouble(); + + await Future.delayed(const Duration(milliseconds: 16)); + + final renderObject = key.currentContext?.findRenderObject(); + final boundary = renderObject is RenderRepaintBoundary ? renderObject : null; + if (boundary == null) return null; + + final image = await boundary.toImage(pixelRatio: pixelRatio); + try { + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + return byteData?.buffer.asUint8List(); + } finally { + image.dispose(); + } +} + +/// Signature the share tabs use to surface a short status line in the +/// sheet's inline banner. [actionLabel]/[onAction] add a single tap action +/// (e.g. Retry, Settings). +typedef ShareSheetMessenger = void Function( + String message, { + bool isError, + String? actionLabel, + VoidCallback? onAction, +}); + +class InlineMessageBanner extends StatelessWidget { + final String message; + final bool isError; + final String? actionLabel; + final VoidCallback? onAction; + + const InlineMessageBanner({ + super.key, + required this.message, + this.isError = false, + this.actionLabel, + this.onAction, + }); + + @override + Widget build(BuildContext context) { + final errorColor = Theme.of(context).colorScheme.error; + + return DecoratedBox( + decoration: BoxDecoration( + color: isError + ? errorColor.withValues(alpha: 0.10) + : LearnDesignTokens.nestedSurface(context), + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: 14, + vertical: actionLabel == null ? 10 : 4, + ), + child: Row( + children: [ + Expanded( + child: Text( + message, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: isError + ? errorColor + : LearnDesignTokens.headline(context), + ), + ), + ), + if (actionLabel != null) ...[ + const SizedBox(width: 8), + TextButton( + onPressed: onAction, + child: Text(actionLabel!), + ), + ], + ], + ), + ), + ); + } +} + +/// Matches the pill-shaped view/country selector used on the dashboard, map, +/// and learn tab (see `ViewSelector._buildViewButton`). +class ShareTabChip extends StatelessWidget { + final String label; + final bool selected; + final VoidCallback onTap; + + const ShareTabChip({ + super.key, + 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 ShareActionButton extends StatelessWidget { + final String label; + final bool loading; + final VoidCallback? onPressed; + + const ShareActionButton({ + super.key, + 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({ + super.key, + 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/shared/services/analytics_service.dart b/src/mobile/lib/src/app/shared/services/analytics_service.dart index cec2215692..21a82f84c5 100644 --- a/src/mobile/lib/src/app/shared/services/analytics_service.dart +++ b/src/mobile/lib/src/app/shared/services/analytics_service.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:posthog_flutter/posthog_flutter.dart'; import 'package:loggy/loggy.dart'; import 'package:airqo/src/app/shared/services/feature_flag_service.dart'; @@ -16,11 +17,27 @@ export 'package:airqo/src/app/shared/services/analytics/analytics_survey_events. /// super properties. Domain-specific event methods are extensions — see the /// files under `analytics/`. class AnalyticsService with UiLoggy { - static final AnalyticsService _instance = AnalyticsService._internal(); + static AnalyticsService _instance = AnalyticsService._internal(); factory AnalyticsService() => _instance; AnalyticsService._internal(); + /// For test fakes/spies to extend — production code must go through the + /// factory so the whole app shares one instance. + @visibleForTesting + AnalyticsService.forTesting(); + + /// Swap the shared instance for a fake in tests. Every `AnalyticsService()` + /// call site resolves through this single choke point, so overriding it + /// here gives all widgets a test seam without changing their convention. + @visibleForTesting + static set instance(AnalyticsService value) { + if (!kDebugMode) { + throw UnsupportedError('AnalyticsService.instance is for testing only'); + } + _instance = value; + } + Future trackEvent( String eventName, { Map? properties, 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 index e43bb516c8..4f68854aec 100644 --- 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 @@ -1,45 +1,52 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'dart:typed_data'; import 'package:airqo/src/app/dashboard/models/airquality_response.dart'; +import 'package:airqo/src/app/shared/repository/token_refresher.dart'; +import 'package:airqo/src/meta/utils/api_utils.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'; +/// Why a wall submission failed — lets the UI show a short, accurate +/// message and analytics record a real failure category. +enum SelfieSubmissionFailure { offline, timeout, server } + +class SelfieSubmissionException implements Exception { + final SelfieSubmissionFailure kind; + final String detail; + + const SelfieSubmissionException(this.kind, this.detail); + + @override + String toString() => 'SelfieSubmissionException(${kind.name}): $detail'; +} + /// Handles opt-in submissions of a user's Clean Air Forum selfie filter -/// image to the conference "wall" display screen. +/// image to the conference "wall" display screen, via the AirQo backend's +/// `POST /api/v2/users/selfies` endpoint. /// -/// 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. +/// Submissions work anonymously — the backend generates a display name and +/// avatar when none is supplied. When the user is logged in, their JWT is +/// attached so the wall shows their real name instead of a generated one. class CleanAirForumSubmissionService with UiLoggy { - CleanAirForumSubmissionService._(); + /// Injected refresher — defaults to [DefaultTokenRefresher]. + /// Swap out in tests without touching production code (DIP). + final TokenRefresher _tokenRefresher; - static final CleanAirForumSubmissionService instance = - CleanAirForumSubmissionService._(); + CleanAirForumSubmissionService({TokenRefresher? tokenRefresher}) + : _tokenRefresher = tokenRefresher ?? const DefaultTokenRefresher(); - static String get _baseUrl => - (dotenv.env['CLEAN_AIR_FORUM_API_URL'] ?? 'https://airqo.net') - .trim() - .replaceAll(RegExp(r'/+$'), ''); + static final CleanAirForumSubmissionService instance = + CleanAirForumSubmissionService(); - /// The forum edition submissions are grouped under. Kept in sync with the - /// website wall page's "current event" config. + /// The forum edition submissions are grouped under — must match the + /// `eventId` the conference wall queries, or submissions never appear. static String get defaultEventId => - dotenv.env['CLEAN_AIR_FORUM_EVENT_ID'] ?? 'clean-air-forum'; + dotenv.env['CLEAN_AIR_FORUM_EVENT_ID'] ?? 'clean-air-forum-2026'; /// Uploads [imageBytes] (the composited filter card PNG) to Cloudinary, /// then posts it (plus AQI metadata) to the conference wall submissions @@ -47,8 +54,13 @@ class CleanAirForumSubmissionService with UiLoggy { /// /// 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({ + /// action, which must always succeed independently of this call. Failures + /// are thrown as [SelfieSubmissionException] so callers can message by + /// cause. + /// + /// Returns the display name the wall used for this submission, when the + /// API provides one (it generates a name for anonymous submitters). + Future submitSelfie({ required Uint8List imageBytes, required Measurement measurement, String? fallbackLocationName, @@ -57,43 +69,73 @@ class CleanAirForumSubmissionService with UiLoggy { }) async { final imageUrl = await _uploadToCloudinary(imageBytes); - final uri = Uri.parse('$_baseUrl/api/clean-air-forum/selfies'); + final uri = Uri.parse('${ApiUtils.baseUrl}/api/v2/users/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)); + // Only attach a token the refresher considers valid — a stale one would + // 401 the whole request, whereas an anonymous submission always works. + final userToken = await _tokenRefresher.refreshTokenIfNeeded(); + + final http.Response response; + try { + response = await http + .post( + uri, + headers: { + 'Content-Type': 'application/json', + 'Accept': '*/*', + 'User-Agent': ApiUtils.mobileUserAgent, + if (userToken != null) 'Authorization': 'JWT $userToken', + }, + // Optional fields are omitted rather than sent as null — the API + // treats them as "leave out if unknown", not nullable. + body: jsonEncode({ + 'eventId': eventId ?? defaultEventId, + 'imageUrl': imageUrl, + if (locationName != null) 'locationName': locationName, + if (measurement.pm25?.value != null) + 'pm25Value': measurement.pm25?.value, + if (measurement.aqiCategory != null) + 'aqiCategory': measurement.aqiCategory, + if (displayName != null && displayName.trim().isNotEmpty) + 'displayName': displayName.trim(), + }), + ) + .timeout(const Duration(seconds: 30)); + } on SocketException catch (e) { + throw SelfieSubmissionException( + SelfieSubmissionFailure.offline, e.message); + } on http.ClientException catch (e) { + throw SelfieSubmissionException( + SelfieSubmissionFailure.offline, e.message); + } on TimeoutException { + throw const SelfieSubmissionException( + SelfieSubmissionFailure.timeout, 'submission timed out'); + } if (response.statusCode < 200 || response.statusCode >= 300) { - throw Exception( - 'Clean Air Forum submission failed: ' - '${response.statusCode} ${response.body}', + throw SelfieSubmissionException( + SelfieSubmissionFailure.server, + 'submission failed: status ${response.statusCode}', ); } - loggy.info('Submitted selfie to Clean Air Forum wall: $imageUrl'); + loggy.info('Submitted selfie to Clean Air Forum wall'); + return _displayNameFrom(response.body); + } + + /// Pulls `created_selfie.displayName` out of the submission response, or + /// null if the payload doesn't carry one. + String? _displayNameFrom(String body) { + try { + final data = jsonDecode(body); + final created = data is Map ? data['created_selfie'] : null; + final name = created is Map ? created['displayName'] : null; + return name is String && name.trim().isNotEmpty ? name.trim() : null; + } catch (_) { + return null; + } } Future _uploadToCloudinary(Uint8List imageBytes) async { @@ -114,22 +156,46 @@ class CleanAirForumSubmissionService with UiLoggy { ), ); - final streamedResponse = await request.send().timeout( - const Duration(seconds: 60), - onTimeout: () => throw TimeoutException('Image upload timed out'), - ); - final response = await http.Response.fromStream(streamedResponse); + final http.Response response; + try { + final streamedResponse = + await request.send().timeout(const Duration(seconds: 60)); + response = await http.Response.fromStream(streamedResponse); + } on SocketException catch (e) { + throw SelfieSubmissionException( + SelfieSubmissionFailure.offline, 'upload: ${e.message}'); + } on http.ClientException catch (e) { + throw SelfieSubmissionException( + SelfieSubmissionFailure.offline, 'upload: ${e.message}'); + } on TimeoutException { + throw const SelfieSubmissionException( + SelfieSubmissionFailure.timeout, 'image upload timed out'); + } if (response.statusCode != 200) { - throw Exception( - 'Cloudinary upload failed: ${response.statusCode}, ${response.body}', + throw SelfieSubmissionException( + SelfieSubmissionFailure.server, + 'upload failed: status ${response.statusCode}', ); } - 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'); + // A 200 with a malformed body is still an upload failure — callers rely + // on every failure surfacing as a SelfieSubmissionException. + final Object? data; + try { + data = jsonDecode(response.body); + } on FormatException { + throw const SelfieSubmissionException( + SelfieSubmissionFailure.server, + 'upload response was not valid JSON', + ); + } + final url = data is Map ? data['secure_url'] : null; + if (url is! String || url.isEmpty) { + throw const SelfieSubmissionException( + SelfieSubmissionFailure.server, + 'upload response had no secure_url', + ); } return url;