diff --git a/src/mobile/assets/icons/alert-circle.svg b/src/mobile/assets/icons/alert-circle.svg new file mode 100644 index 0000000000..2738d61f97 --- /dev/null +++ b/src/mobile/assets/icons/alert-circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/mobile/lib/src/app/dashboard/utils/clean_air_forum_branding.dart b/src/mobile/lib/src/app/dashboard/utils/clean_air_forum_branding.dart index 155aec68b1..dfcf2e55f8 100644 --- a/src/mobile/lib/src/app/dashboard/utils/clean_air_forum_branding.dart +++ b/src/mobile/lib/src/app/dashboard/utils/clean_air_forum_branding.dart @@ -29,13 +29,13 @@ class CleanAirForumBrand { static const Color sharedCaptionText = Color(0xFF1F3D3D); /// Wordmark shown top-left on the selfie filter card. - static const String title = 'Africa CLEAN - Air Forum'; + static const String title = 'Africa Clean Air Forum'; /// Host city + year shown under the [title] wordmark. static const String edition = 'Pretoria 2026'; /// Event date range shown in the corner of the filter card. - static const String dateRange = '14TH-16TH JULY'; + static const String dateRange = '13TH-16TH JULY'; } /// Reference canvas width the Figma "AQ/CAF" templates were designed at. 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 dec85f6fd0..43acab9b1d 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 @@ -98,6 +98,7 @@ class _AirQualityShareSheetState extends State { String? _inlineMessage; bool _inlineIsError = false; + bool _inlineIsLoading = false; String? _inlineActionLabel; VoidCallback? _inlineOnAction; Timer? _inlineMessageTimer; @@ -146,12 +147,19 @@ class _AirQualityShareSheetState extends State { /// modal route above the app's own Scaffold, so a SnackBar raised via /// `ScaffoldMessenger` renders underneath it and is never actually seen. /// + /// [loading] keeps the banner up indefinitely (no auto-dismiss timer) — + /// callers are expected to replace it with a follow-up success/error + /// message once the operation finishes, rather than leaving it stuck. A + /// non-null [actionLabel] (e.g. Retry) does the same — an action that + /// vanishes before the user can tap it isn't actually usable. + /// /// 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, + bool loading = false, String? actionLabel, VoidCallback? onAction, }) { @@ -169,8 +177,7 @@ class _AirQualityShareSheetState extends State { // The banner self-dismisses, so screen-reader users would miss it // entirely without an explicit announcement. - unawaited(SemanticsService.sendAnnouncement( - View.of(context), + unawaited(SemanticsService.announce( message, Directionality.of(context), )); @@ -179,9 +186,11 @@ class _AirQualityShareSheetState extends State { setState(() { _inlineMessage = message; _inlineIsError = isError; + _inlineIsLoading = loading; _inlineActionLabel = actionLabel; _inlineOnAction = onAction; }); + if (loading || actionLabel != null) return; // Errors and longer messages linger; short confirmations clear fast. final duration = Duration( milliseconds: (2500 + message.length * 35).clamp(3000, 6500), @@ -318,6 +327,7 @@ class _AirQualityShareSheetState extends State { InlineMessageBanner( message: _inlineMessage!, isError: _inlineIsError, + loading: _inlineIsLoading, actionLabel: _inlineActionLabel, onAction: _inlineOnAction == null ? null diff --git a/src/mobile/lib/src/app/dashboard/widgets/clean_air_forum_camera_screen.dart b/src/mobile/lib/src/app/dashboard/widgets/clean_air_forum_camera_screen.dart index b856ce8790..103ae14890 100644 --- a/src/mobile/lib/src/app/dashboard/widgets/clean_air_forum_camera_screen.dart +++ b/src/mobile/lib/src/app/dashboard/widgets/clean_air_forum_camera_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:airqo/src/app/dashboard/models/airquality_response.dart'; @@ -33,11 +34,23 @@ class CleanAirForumCameraScreen extends StatefulWidget { class _CleanAirForumCameraScreenState extends State with WidgetsBindingObserver { + // A hung native camera session (e.g. after a lock-screen interruption + // mid-capture) must not be able to strand the UI on the loading spinner + // or the shutter button forever — every call into the camera plugin is + // bounded by this. + static const _cameraOpTimeout = Duration(seconds: 10); + CameraController? _controller; List _cameras = const []; int _cameraIndex = 0; Future? _initializeFuture; bool _isCapturing = false; + // Retry-on-failure and lifecycle-resume can both call _openCamera close + // together; without this, the second call would see the first's nulled + // _controller and start its own CameraController before the first has + // finished disposing/initializing — two sessions open at once, which the + // Camera2 HAL rejects. + bool _isOpeningCamera = false; String? _errorMessage; @override @@ -84,25 +97,44 @@ class _CleanAirForumCameraScreenState extends State } Future _openCamera(int index) async { - final previous = _controller; - _controller = CameraController( - _cameras[index], - ResolutionPreset.high, - enableAudio: false, - imageFormatGroup: ImageFormatGroup.jpeg, - ); + // Only one open/teardown sequence may run at a time — see + // _isOpeningCamera's doc comment. + if (_isOpeningCamera) return; + _isOpeningCamera = true; try { - await _controller!.initialize(); - } catch (_) { - final failed = _controller; + // The previous session must be fully torn down *before* a new one + // starts initializing — the Camera2 HAL only allows one open session + // per device, so overlapping old/new controllers (as when switching + // front/back) throws CAMERA_ERROR "Error configuring streams: Broken + // pipe" instead of cleanly handing off. + final previous = _controller; _controller = null; - await failed?.dispose(); - if (mounted) setState(() => _errorMessage = 'Could not start the camera.'); - return; - } finally { + if (mounted) setState(() {}); await previous?.dispose(); + + final controller = CameraController( + _cameras[index], + ResolutionPreset.high, + enableAudio: false, + imageFormatGroup: ImageFormatGroup.jpeg, + ); + try { + await controller.initialize().timeout(_cameraOpTimeout); + } catch (_) { + // Don't await this dispose — if initialize() just timed out because + // the native session is stuck, dispose() on that same stuck session + // could hang too, and we'd be right back to an unrecoverable spinner. + unawaited(controller.dispose()); + if (mounted) { + setState(() => _errorMessage = 'Could not start the camera.'); + } + return; + } + _controller = controller; + if (mounted) setState(() {}); + } finally { + _isOpeningCamera = false; } - if (mounted) setState(() {}); } Future _switchCamera() async { @@ -120,29 +152,47 @@ class _CleanAirForumCameraScreenState extends State setState(() => _isCapturing = true); try { - final file = await controller.takePicture(); + final file = await controller.takePicture().timeout(_cameraOpTimeout); if (!mounted) return; Navigator.of(context).pop(File(file.path)); } catch (_) { - if (mounted) setState(() => _isCapturing = false); + if (!mounted) return; + setState(() => _isCapturing = false); + // A failed/timed-out capture (e.g. the screen locking mid-shot) can + // leave the native session in a state that still looks live in the + // preview but can no longer actually shoot — force a fresh session + // rather than leaving a dead-but-looks-fine camera screen. + unawaited(_openCamera(_cameraIndex)); } } @override void didChangeAppLifecycleState(AppLifecycleState state) { - final controller = _controller; - if (controller == null || !controller.value.isInitialized) return; - if (state == AppLifecycleState.inactive) { + final controller = _controller; + if (controller == null || !controller.value.isInitialized) return; // Clear the field before disposing — otherwise it keeps pointing at a // disposed controller (dispose() doesn't flip isInitialized back to // false), so a rebuild triggered before _openCamera() finishes on // resume could still try to paint CameraPreview against it. _controller = null; if (mounted) setState(() {}); - controller.dispose(); + unawaited(controller.dispose()); } else if (state == AppLifecycleState.resumed) { - _openCamera(_cameraIndex); + // The above guard used to run unconditionally, before this branch + // check — so once `inactive` nulled `_controller`, the very next + // `resumed` event read that null and bailed out immediately, never + // reopening the camera. That left the screen stuck on the loading + // spinner forever after any lock/unlock, not just the capture-mid- + // interruption case that surfaced it. + final controller = _controller; + // _openCamera indexes into _cameras directly — without this guard, a + // resume after permission denial or on a cameraless device (both + // leave _cameras empty and _controller null) would crash. + if (_cameras.isNotEmpty && + (controller == null || !controller.value.isInitialized)) { + unawaited(_openCamera(_cameraIndex)); + } } } 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 ca8bed0f60..9c32063256 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 @@ -93,6 +93,7 @@ class _CleanAirForumFilterTabState extends State { if (!mounted) return; widget.onSelfieChanged(File(picked.path)); + if (widget.consentToDisplay) unawaited(_submitCurrentSelfieToWall()); } on PlatformException catch (e) { // "Try again" is wrong advice when access is denied — the fix lives // in system settings, so link straight there. @@ -205,6 +206,7 @@ class _CleanAirForumFilterTabState extends State { if (file != null && mounted) { widget.onSelfieChanged(file); AnalyticsService().trackCafSelfieCaptured(); + if (widget.consentToDisplay) unawaited(_submitCurrentSelfieToWall()); } } catch (_) { widget.onMessage("Couldn't open the camera.", isError: true); @@ -238,10 +240,6 @@ class _CleanAirForumFilterTabState extends State { method: 'share_sheet', ); } - - if (widget.consentToDisplay) { - unawaited(_submitToConferenceWall(imageBytes)); - } } catch (_) { widget.onMessage("Couldn't share the filter. Try again.", isError: true); } finally { @@ -249,25 +247,42 @@ class _CleanAirForumFilterTabState extends State { } } + /// Captures the current filter card and sends it to the wall — triggered + /// the moment consent is given (or a new/changed photo is picked while + /// already consented), rather than being bundled into the personal + /// "Share" action. Piggybacking on share made consent look like it should + /// be enough on its own, but nothing was actually sent to the wall until + /// the user *also* shared the filter externally. + Future _submitCurrentSelfieToWall() async { + if (widget.selfieFile == null) return; + if (_isSendingToWall) return; + final imageBytes = await captureShareBoundary(context, _filterKey); + if (imageBytes == null) { + widget.onMessage("Couldn't prepare the filter. Try again.", + isError: true); + return; + } + await _submitToConferenceWall(imageBytes); + } + 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. + // and repeated shares must not enqueue duplicate wall submissions. + // Not rendered directly — the sheet's inline banner (via onMessage's + // `loading: true`) is the only visible sign of progress now. if (_isSendingToWall) return; _isSendingToWall = true; - if (mounted) setState(() {}); + widget.onMessage('Sending to the wall…', loading: true); try { - final wallName = await _submissionService.submitSelfie( + // The API's displayName can be the user's email when they're logged + // in — never surface it here, since the wall itself hides identity. + 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!", - ); + widget.onMessage("You're on the forum wall!"); } catch (e) { // Report only the failure category — raw messages can carry API // response details that don't belong in analytics. @@ -284,7 +299,6 @@ class _CleanAirForumFilterTabState extends State { ); } finally { _isSendingToWall = false; - if (mounted) setState(() {}); } } @@ -372,26 +386,6 @@ 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), - ), - ], - ), - ], ], ], ); @@ -426,7 +420,10 @@ class _CleanAirForumFilterTabState extends State { value: widget.consentToDisplay, onChanged: (value) { widget.onConsentChanged(value); - if (value) AnalyticsService().trackCafWallConsentGiven(); + if (value) { + AnalyticsService().trackCafWallConsentGiven(); + unawaited(_submitCurrentSelfieToWall()); + } }, ), ], 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 173dc50449..a0c31e51e5 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 @@ -34,11 +34,14 @@ Future captureShareBoundary( } /// Signature the share tabs use to surface a short status line in the -/// sheet's inline banner. [actionLabel]/[onAction] add a single tap action +/// sheet's inline banner. [loading] shows a spinner and keeps the banner +/// up until replaced by the next message (success/error), instead of +/// auto-dismissing. [actionLabel]/[onAction] add a single tap action /// (e.g. Retry, Settings). typedef ShareSheetMessenger = void Function( String message, { bool isError, + bool loading, String? actionLabel, VoidCallback? onAction, }); @@ -46,6 +49,7 @@ typedef ShareSheetMessenger = void Function( class InlineMessageBanner extends StatelessWidget { final String message; final bool isError; + final bool loading; final String? actionLabel; final VoidCallback? onAction; @@ -53,37 +57,80 @@ class InlineMessageBanner extends StatelessWidget { super.key, required this.message, this.isError = false, + this.loading = false, this.actionLabel, this.onAction, }); @override Widget build(BuildContext context) { - final errorColor = Theme.of(context).colorScheme.error; + // Three tones: in-progress messages stay neutral (nothing to celebrate + // or warn about yet); success/error match the Figma "Alert" component + // (WM-Rebrand-Jam, node 187:2721) — bordered card, tone-matched icon, + // and action colour drawn from the same tone rather than a flat brand + // blue, since the reference never mixes an unrelated colour into an + // alert's own family. + final Color contentColor; + final Color backgroundColor; + final Color? borderColor; + final String? iconAsset; + if (loading) { + contentColor = LearnDesignTokens.headline(context); + backgroundColor = LearnDesignTokens.nestedSurface(context); + borderColor = null; + iconAsset = null; + } else if (isError) { + contentColor = AppAlertColors.errorForeground(context); + backgroundColor = AppAlertColors.errorBackground(context); + borderColor = AppAlertColors.errorBorder(context); + iconAsset = 'assets/icons/alert-circle.svg'; + } else { + contentColor = AppAlertColors.successForeground(context); + backgroundColor = AppAlertColors.successBackground(context); + borderColor = AppAlertColors.successBorder(context); + iconAsset = 'assets/icons/check-circle.svg'; + } return DecoratedBox( decoration: BoxDecoration( - color: isError - ? errorColor.withValues(alpha: 0.10) - : LearnDesignTokens.nestedSurface(context), + color: backgroundColor, borderRadius: BorderRadius.circular(12), + border: borderColor == null ? null : Border.all(color: borderColor), ), child: Padding( - padding: EdgeInsets.symmetric( - horizontal: 14, - vertical: actionLabel == null ? 10 : 4, - ), + // Same padding regardless of the action button — it's sized with + // minimumSize: Size.zero and no vertical padding of its own, so it + // never needed the old shrunk padding; that just made banners with + // an action (e.g. Retry) noticeably thinner than plain ones. + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), child: Row( children: [ + if (loading) ...[ + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(contentColor), + ), + ), + const SizedBox(width: 10), + ] else if (iconAsset != null) ...[ + SvgPicture.asset( + iconAsset, + width: 16, + height: 16, + colorFilter: ColorFilter.mode(contentColor, BlendMode.srcIn), + ), + const SizedBox(width: 10), + ], Expanded( child: Text( message, style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, - color: isError - ? errorColor - : LearnDesignTokens.headline(context), + color: contentColor, ), ), ), @@ -91,7 +138,19 @@ class InlineMessageBanner extends StatelessWidget { const SizedBox(width: 8), TextButton( onPressed: onAction, - child: Text(actionLabel!), + style: TextButton.styleFrom( + foregroundColor: contentColor, + padding: const EdgeInsets.symmetric(horizontal: 4), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Text( + actionLabel!, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), ), ], ], diff --git a/src/mobile/lib/src/meta/utils/colors.dart b/src/mobile/lib/src/meta/utils/colors.dart index 55fd1cabc0..9518b9773f 100644 --- a/src/mobile/lib/src/meta/utils/colors.dart +++ b/src/mobile/lib/src/meta/utils/colors.dart @@ -78,6 +78,39 @@ class AppTextColors { isDark(context) ? Colors.white : AppColors.boldHeadlineColor4; } +/// Bordered status-banner palette matching the Figma "Alert" component +/// (WM-Rebrand-Jam, node 187:2721) — distinct from [AppTextColors]'s older +/// error/success pair, which predates this rebrand's colour scale. Only the +/// tones currently in use (Error, Success) are defined here. +class AppAlertColors { + const AppAlertColors._(); + + static bool isDark(BuildContext context) => + Theme.of(context).brightness == Brightness.dark; + + static Color errorBackground(BuildContext context) => isDark(context) + ? const Color(0xffB42318).withValues(alpha: 0.16) + : const Color(0xffFFFBFA); // Error/25 + + static Color errorBorder(BuildContext context) => isDark(context) + ? const Color(0xffB42318).withValues(alpha: 0.4) + : const Color(0xffFDA29B); // Error/300 + + static Color errorForeground(BuildContext context) => + isDark(context) ? Colors.white : const Color(0xffB42318); // Error/700 + + static Color successBackground(BuildContext context) => isDark(context) + ? const Color(0xff027A48).withValues(alpha: 0.16) + : const Color(0xffF6FEF9); // Success/25 + + static Color successBorder(BuildContext context) => isDark(context) + ? const Color(0xff027A48).withValues(alpha: 0.4) + : const Color(0xff6CE9A6); // Success/300 + + static Color successForeground(BuildContext context) => + isDark(context) ? Colors.white : const Color(0xff027A48); // Success/700 +} + /// Theme-aware surfaces and borders — one palette for cards, sheets, and modals. class AppSurfaceColors { const AppSurfaceColors._();