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._();