From ba364176a392e86d50ebc51c0a9b5b1e5723ae1e Mon Sep 17 00:00:00 2001 From: mozart Date: Wed, 8 Jul 2026 15:00:59 +0300 Subject: [PATCH 1/9] feat(mobile): point Clean Air Forum selfie submissions at real backend Swap the temporary website mock API for the new POST /api/v2/users/selfies endpoint. Drops the interim shared-secret header and attaches the user's JWT when a valid one is available so their real name shows on the wall; anonymous submissions continue to work unchanged. --- .../clean_air_forum_submission_service.dart | 42 +++++++------------ 1 file changed, 15 insertions(+), 27 deletions(-) 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..1dd1314257 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 @@ -3,39 +3,26 @@ import 'dart:convert'; 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'; /// 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._(); 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 => @@ -57,22 +44,23 @@ 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']; + // 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 const DefaultTokenRefresher().refreshTokenIfNeeded(); 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, + 'Accept': '*/*', + 'User-Agent': ApiUtils.mobileUserAgent, + if (userToken != null) 'Authorization': 'JWT $userToken', }, body: jsonEncode({ 'eventId': eventId ?? defaultEventId, From 3301ddaf9a78dfb06896a3931e0bb6d8bb4f5dc6 Mon Sep 17 00:00:00 2001 From: mozart Date: Wed, 8 Jul 2026 15:03:10 +0300 Subject: [PATCH 2/9] refactor(mobile): inject TokenRefresher into selfie submission service Mirrors the BaseRepository DIP pattern so tests can swap the refresher, and tightens the eventId doc to state the wall-matching constraint. --- .../clean_air_forum_submission_service.dart | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) 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 1dd1314257..479d248332 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 @@ -18,13 +18,18 @@ import 'package:loggy/loggy.dart'; /// 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; + + CleanAirForumSubmissionService({TokenRefresher? tokenRefresher}) + : _tokenRefresher = tokenRefresher ?? const DefaultTokenRefresher(); static final CleanAirForumSubmissionService instance = - CleanAirForumSubmissionService._(); + 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'; @@ -50,8 +55,7 @@ class CleanAirForumSubmissionService with UiLoggy { fallbackLocationName; // 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 const DefaultTokenRefresher().refreshTokenIfNeeded(); + final userToken = await _tokenRefresher.refreshTokenIfNeeded(); final response = await http .post( From c5cf27bf6b1e677f4cf22a05f23f3a8bb24d826e Mon Sep 17 00:00:00 2001 From: mozart Date: Wed, 8 Jul 2026 15:07:37 +0300 Subject: [PATCH 3/9] fix(mobile): align selfie submission body with backend contract Omit optional fields instead of sending explicit nulls, and default the event ID to clean-air-forum-2026 to match the wall's current event. --- .../services/clean_air_forum_submission_service.dart | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) 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 479d248332..289848886c 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 @@ -31,7 +31,7 @@ class CleanAirForumSubmissionService with UiLoggy { /// 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 @@ -66,12 +66,16 @@ class CleanAirForumSubmissionService with UiLoggy { '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, - 'locationName': locationName, - 'pm25Value': measurement.pm25?.value, - 'aqiCategory': measurement.aqiCategory, + 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(), }), From 3427ee08ac8aa9f6371d0e5cafde836e679c7f81 Mon Sep 17 00:00:00 2001 From: mozart Date: Wed, 8 Jul 2026 15:14:17 +0300 Subject: [PATCH 4/9] refactor(mobile): allow injecting submission service into share sheet Lets widget tests exercise the consent-to-wall flow with a fake service instead of real HTTP; production behavior is unchanged (defaults to the shared instance). --- .../app/dashboard/widgets/air_quality_share_sheet.dart | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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..4dbddaaef2 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 @@ -69,12 +69,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 @@ -336,9 +341,12 @@ class _AirQualityShareSheetState extends State { } } + CleanAirForumSubmissionService get _submissionService => + widget.submissionService ?? CleanAirForumSubmissionService.instance; + Future _submitToConferenceWall(Uint8List imageBytes) async { try { - await CleanAirForumSubmissionService.instance.submitSelfie( + await _submissionService.submitSelfie( imageBytes: imageBytes, measurement: widget.measurement, fallbackLocationName: widget.fallbackLocationName, From 6a79c89d9fd2c649801965a95bf0fec67bf820be Mon Sep 17 00:00:00 2001 From: mozart Date: Wed, 8 Jul 2026 15:30:17 +0300 Subject: [PATCH 5/9] refactor(mobile): split share sheet and add analytics test seam - AnalyticsService: the singleton behind the factory is now swappable via a visibleForTesting setter, giving every existing AnalyticsService() call site a test seam without changing the call convention. - Extract the forum filter tab (selfie acquisition, consent, wall submission) into clean_air_forum_filter_tab.dart; selfie and consent state stay lifted in the sheet so they survive tab switches. - Move shared presentational widgets and the RepaintBoundary capture helper into share_sheet_widgets.dart. No behavior changes; air_quality_share_sheet.dart drops from 916 to 420 lines. --- .../widgets/air_quality_share_sheet.dart | 541 +----------------- .../widgets/clean_air_forum_filter_tab.dart | 399 +++++++++++++ .../widgets/share_sheet_widgets.dart | 195 +++++++ .../shared/services/analytics_service.dart | 14 +- 4 files changed, 630 insertions(+), 519 deletions(-) create mode 100644 src/mobile/lib/src/app/dashboard/widgets/clean_air_forum_filter_tab.dart create mode 100644 src/mobile/lib/src/app/dashboard/widgets/share_sheet_widgets.dart 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 4dbddaaef2..89d0023667 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,19 @@ 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_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 +53,6 @@ enum _ShareTab { const _ShareTab({required this.label, required this.analyticsName}); } -enum _SelfieSource { liveCamera, gallery } - class AirQualityShareSheet extends StatefulWidget { final Measurement measurement; @@ -91,17 +84,14 @@ 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; @@ -138,22 +128,6 @@ 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. @@ -171,7 +145,7 @@ 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.'); return; @@ -196,175 +170,6 @@ class _AirQualityShareSheetState extends State { } } - 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); - } - } - - CleanAirForumSubmissionService get _submissionService => - widget.submissionService ?? CleanAirForumSubmissionService.instance; - - Future _submitToConferenceWall(Uint8List imageBytes) async { - try { - await _submissionService.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. @@ -373,7 +178,7 @@ 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.'); return; @@ -459,7 +264,7 @@ class _AirQualityShareSheetState extends State { _buildTabSelector(), if (_inlineMessage != null) ...[ const SizedBox(height: 12), - _InlineMessageBanner(message: _inlineMessage!), + InlineMessageBanner(message: _inlineMessage!), ], const SizedBox(height: 20), _buildTabContent(), @@ -491,7 +296,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), @@ -505,7 +310,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: @@ -525,7 +342,7 @@ class _AirQualityShareSheetState extends State { ), ), const SizedBox(height: 16), - _ShareButton( + ShareActionButton( label: _isSharingCard ? 'Preparing card...' : 'Share card', loading: _isSharingCard, onPressed: _isSharingCard ? null : _shareCard, @@ -534,121 +351,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, @@ -711,205 +418,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..ed3f39f9e6 --- /dev/null +++ b/src/mobile/lib/src/app/dashboard/widgets/clean_air_forum_filter_tab.dart @@ -0,0 +1,399 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +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_svg/flutter_svg.dart'; +import 'package:image_picker/image_picker.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 ValueChanged 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; + + 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)); + } catch (_) { + widget.onMessage('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) { + widget.onSelfieChanged(file); + AnalyticsService().trackCafSelfieCaptured(); + } + } catch (_) { + widget.onMessage('Could not open the live camera. Please try again.'); + } 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('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: widget.analyticsFormat, + method: 'share_sheet', + ); + } + + if (widget.consentToDisplay) { + unawaited(_submitToConferenceWall(imageBytes)); + } + } catch (_) { + widget.onMessage('Could not share the filter. Please try again.'); + } finally { + if (mounted) setState(() => _isSharingFilter = false); + } + } + + Future _submitToConferenceWall(Uint8List imageBytes) async { + try { + await _submissionService.submitSelfie( + imageBytes: imageBytes, + measurement: widget.measurement, + fallbackLocationName: widget.fallbackLocationName, + ); + AnalyticsService().trackCafWallSubmissionSent(); + widget.onMessage('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()); + widget.onMessage( + 'Your photo shared fine, but sending it to the conference screen ' + "didn't work. Please try again.", + ); + } + } + + @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, + ), + ], + ], + ); + } + + 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..7f860c32f1 --- /dev/null +++ b/src/mobile/lib/src/app/dashboard/widgets/share_sheet_widgets.dart @@ -0,0 +1,195 @@ +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 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(); +} + +class InlineMessageBanner extends StatelessWidget { + final String message; + + const InlineMessageBanner({super.key, 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), + ), + ), + ), + ); + } +} + +/// 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..20d7230df1 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,22 @@ 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) => _instance = value; + Future trackEvent( String eventName, { Map? properties, From 861d3b62a9bd962a4a6fe41112723528fd60efce Mon Sep 17 00:00:00 2001 From: mozart Date: Wed, 8 Jul 2026 15:48:30 +0300 Subject: [PATCH 6/9] feat(mobile): friendlier share-sheet errors and wall submission UX - Typed SelfieSubmissionException (offline/timeout/server) so failures get short, accurate messages and analytics record a real category. - Permission denials link to Settings instead of saying 'try again'. - Wall submission shows an in-flight status row, offers Retry on failure, and reports the display name the wall used ('You're on the wall as X!'). - If the sheet is closed before the background submission finishes, the result falls back to a root SnackBar instead of being dropped. - Inline banner: error styling, optional action button, duration scaled to message length, and a screen-reader announcement. - All messages shortened. --- .../widgets/air_quality_share_sheet.dart | 84 +++++++++-- .../widgets/clean_air_forum_filter_tab.dart | 100 +++++++++++-- .../widgets/share_sheet_widgets.dart | 61 ++++++-- .../clean_air_forum_submission_service.dart | 134 +++++++++++++----- 4 files changed, 308 insertions(+), 71 deletions(-) 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 89d0023667..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 @@ -13,6 +13,7 @@ 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/meta/utils/colors.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:pasteboard/pasteboard.dart'; import 'package:share_plus/share_plus.dart'; @@ -96,8 +97,15 @@ class _AirQualityShareSheetState extends State { 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 @@ -107,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() { @@ -131,11 +145,48 @@ class _AirQualityShareSheetState extends State { /// 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); }); } @@ -147,7 +198,7 @@ class _AirQualityShareSheetState extends State { try { 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; } @@ -164,7 +215,7 @@ 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); } @@ -180,7 +231,7 @@ class _AirQualityShareSheetState extends State { try { 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; } @@ -192,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; } @@ -200,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), () { @@ -208,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); } @@ -264,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(), 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 index ed3f39f9e6..d639ab48dc 100644 --- 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 @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:io'; -import 'dart:typed_data'; import 'package:airqo/src/app/dashboard/models/airquality_response.dart'; import 'package:airqo/src/app/dashboard/widgets/clean_air_forum_camera_screen.dart'; @@ -14,8 +13,10 @@ import 'package:airqo/src/app/shared/services/clean_air_forum_submission_service 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 } @@ -42,7 +43,7 @@ class CleanAirForumFilterTab extends StatefulWidget { final ValueChanged onConsentChanged; /// Surfaces status/error text in the sheet's inline banner. - final ValueChanged onMessage; + final ShareSheetMessenger onMessage; /// Injected for tests — defaults to the app-wide /// [CleanAirForumSubmissionService.instance] (DIP). @@ -72,6 +73,7 @@ class _CleanAirForumFilterTabState extends State { bool _isPickingSelfie = false; bool _isSharingFilter = false; + bool _isSendingToWall = false; CleanAirForumSubmissionService get _submissionService => widget.submissionService ?? CleanAirForumSubmissionService.instance; @@ -91,8 +93,33 @@ class _CleanAirForumFilterTabState extends State { 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('Could not access the camera/gallery. Please try again.'); + widget.onMessage( + source == ImageSource.camera + ? "Couldn't open the camera." + : "Couldn't open the gallery.", + isError: true, + ); } finally { if (mounted) setState(() => _isPickingSelfie = false); } @@ -180,7 +207,7 @@ class _CleanAirForumFilterTabState extends State { AnalyticsService().trackCafSelfieCaptured(); } } catch (_) { - widget.onMessage('Could not open the live camera. Please try again.'); + widget.onMessage("Couldn't open the camera.", isError: true); } finally { if (mounted) setState(() => _isPickingSelfie = false); } @@ -193,7 +220,8 @@ class _CleanAirForumFilterTabState extends State { try { final imageBytes = await captureShareBoundary(context, _filterKey); if (imageBytes == null) { - widget.onMessage('Could not prepare the filter. Please try again.'); + widget.onMessage("Couldn't share the filter. Try again.", + isError: true); return; } @@ -215,31 +243,57 @@ class _CleanAirForumFilterTabState extends State { unawaited(_submitToConferenceWall(imageBytes)); } } catch (_) { - widget.onMessage('Could not share the filter. Please try again.'); + widget.onMessage("Couldn't share the filter. Try again.", isError: true); } finally { if (mounted) setState(() => _isSharingFilter = false); } } Future _submitToConferenceWall(Uint8List imageBytes) async { + if (mounted) setState(() => _isSendingToWall = true); try { - await _submissionService.submitSelfie( + final wallName = await _submissionService.submitSelfie( imageBytes: imageBytes, measurement: widget.measurement, fallbackLocationName: widget.fallbackLocationName, ); AnalyticsService().trackCafWallSubmissionSent(); - widget.onMessage('Sent to the Clean Air Forum screen!'); + widget.onMessage( + wallName == null + ? "You're on the forum wall!" + : "You're on the wall as $wallName!", + ); } catch (e) { - // Report only the exception type — raw messages can carry API + // Report only the failure category — raw messages can carry API // response details that don't belong in analytics. - AnalyticsService() - .trackCafWallSubmissionFailed(error: e.runtimeType.toString()); + AnalyticsService().trackCafWallSubmissionFailed( + error: e is SelfieSubmissionException + ? e.kind.name + : e.runtimeType.toString(), + ); widget.onMessage( - 'Your photo shared fine, but sending it to the conference screen ' - "didn't work. Please try again.", + _wallFailureMessage(e), + isError: true, + actionLabel: 'Retry', + onAction: () => _submitToConferenceWall(imageBytes), ); + } finally { + if (mounted) setState(() => _isSendingToWall = false); + } + } + + 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 @@ -312,6 +366,26 @@ class _CleanAirForumFilterTabState extends State { 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), + ), + ], + ), + ], ], ], ); 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 index 7f860c32f1..b388ddd393 100644 --- a/src/mobile/lib/src/app/dashboard/widgets/share_sheet_widgets.dart +++ b/src/mobile/lib/src/app/dashboard/widgets/share_sheet_widgets.dart @@ -30,27 +30,68 @@ Future captureShareBoundary( return byteData?.buffer.asUint8List(); } +/// 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}); + 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: LearnDesignTokens.nestedSurface(context), + color: isError + ? errorColor.withValues(alpha: 0.10) + : 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), - ), + 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!), + ), + ], + ], ), ), ); 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 289848886c..27e4c62c1c 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,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'dart:typed_data'; import 'package:airqo/src/app/dashboard/models/airquality_response.dart'; @@ -10,6 +11,20 @@ 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, via the AirQo backend's /// `POST /api/v2/users/selfies` endpoint. @@ -39,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,39 +77,65 @@ class CleanAirForumSubmissionService with UiLoggy { // 401 the whole request, whereas an anonymous submission always works. final userToken = await _tokenRefresher.refreshTokenIfNeeded(); - final 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)); + 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'); + 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 { @@ -110,22 +156,36 @@ 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'); + throw const SelfieSubmissionException( + SelfieSubmissionFailure.server, + 'upload response had no secure_url', + ); } return url; From bad74e8a60d8abd9cb4f2caa57d36c08449b3243 Mon Sep 17 00:00:00 2001 From: mozart Date: Wed, 8 Jul 2026 16:18:33 +0300 Subject: [PATCH 7/9] fix(mobile): harden share capture and wall submission edge cases - guard _submitToConferenceWall against concurrent Retry/share taps - dispose the captured ui.Image after PNG encoding - stop logging the uploaded selfie URL - surface malformed Cloudinary 200 responses as SelfieSubmissionException --- .../widgets/clean_air_forum_filter_tab.dart | 10 ++++++++-- .../dashboard/widgets/share_sheet_widgets.dart | 9 ++++++--- .../clean_air_forum_submission_service.dart | 18 ++++++++++++++---- 3 files changed, 28 insertions(+), 9 deletions(-) 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 index d639ab48dc..ca8bed0f60 100644 --- 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 @@ -250,7 +250,12 @@ class _CleanAirForumFilterTabState extends State { } Future _submitToConferenceWall(Uint8List imageBytes) async { - if (mounted) setState(() => _isSendingToWall = true); + // 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, @@ -278,7 +283,8 @@ class _CleanAirForumFilterTabState extends State { onAction: () => _submitToConferenceWall(imageBytes), ); } finally { - if (mounted) setState(() => _isSendingToWall = false); + _isSendingToWall = false; + if (mounted) setState(() {}); } } 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 index b388ddd393..cd07fe7a14 100644 --- a/src/mobile/lib/src/app/dashboard/widgets/share_sheet_widgets.dart +++ b/src/mobile/lib/src/app/dashboard/widgets/share_sheet_widgets.dart @@ -25,9 +25,12 @@ Future captureShareBoundary( 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(); + 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 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 27e4c62c1c..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 @@ -121,7 +121,7 @@ class CleanAirForumSubmissionService with UiLoggy { ); } - loggy.info('Submitted selfie to Clean Air Forum wall: $imageUrl'); + loggy.info('Submitted selfie to Clean Air Forum wall'); return _displayNameFrom(response.body); } @@ -179,9 +179,19 @@ class CleanAirForumSubmissionService with UiLoggy { ); } - final data = jsonDecode(response.body) as Map; - final url = data['secure_url'] as String?; - if (url == null) { + // 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', From a5b7872632f5f0943964d6b85777dbb74455e56a Mon Sep 17 00:00:00 2001 From: Peter Kyeyune Date: Wed, 8 Jul 2026 16:33:21 +0300 Subject: [PATCH 8/9] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../lib/src/app/dashboard/widgets/share_sheet_widgets.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index cd07fe7a14..173dc50449 100644 --- a/src/mobile/lib/src/app/dashboard/widgets/share_sheet_widgets.dart +++ b/src/mobile/lib/src/app/dashboard/widgets/share_sheet_widgets.dart @@ -20,8 +20,8 @@ Future captureShareBoundary( await Future.delayed(const Duration(milliseconds: 16)); - final boundary = - key.currentContext?.findRenderObject() as RenderRepaintBoundary?; + final renderObject = key.currentContext?.findRenderObject(); + final boundary = renderObject is RenderRepaintBoundary ? renderObject : null; if (boundary == null) return null; final image = await boundary.toImage(pixelRatio: pixelRatio); From 3d9e1fea682431b6d0f5e92c13a09669fd21fcad Mon Sep 17 00:00:00 2001 From: Peter Kyeyune Date: Wed, 8 Jul 2026 16:33:53 +0300 Subject: [PATCH 9/9] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../lib/src/app/shared/services/analytics_service.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 20d7230df1..21a82f84c5 100644 --- a/src/mobile/lib/src/app/shared/services/analytics_service.dart +++ b/src/mobile/lib/src/app/shared/services/analytics_service.dart @@ -31,7 +31,12 @@ class AnalyticsService with UiLoggy { /// 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) => _instance = value; + static set instance(AnalyticsService value) { + if (!kDebugMode) { + throw UnsupportedError('AnalyticsService.instance is for testing only'); + } + _instance = value; + } Future trackEvent( String eventName, {