Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/mobile/assets/icons/alert-circle.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ class _AirQualityShareSheetState extends State<AirQualityShareSheet> {

String? _inlineMessage;
bool _inlineIsError = false;
bool _inlineIsLoading = false;
String? _inlineActionLabel;
VoidCallback? _inlineOnAction;
Timer? _inlineMessageTimer;
Expand Down Expand Up @@ -146,12 +147,19 @@ class _AirQualityShareSheetState extends State<AirQualityShareSheet> {
/// 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,
}) {
Expand All @@ -169,8 +177,7 @@ class _AirQualityShareSheetState extends State<AirQualityShareSheet> {

// 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),
));
Expand All @@ -179,9 +186,11 @@ class _AirQualityShareSheetState extends State<AirQualityShareSheet> {
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),
Expand Down Expand Up @@ -318,6 +327,7 @@ class _AirQualityShareSheetState extends State<AirQualityShareSheet> {
InlineMessageBanner(
message: _inlineMessage!,
isError: _inlineIsError,
loading: _inlineIsLoading,
actionLabel: _inlineActionLabel,
onAction: _inlineOnAction == null
? null
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';

import 'package:airqo/src/app/dashboard/models/airquality_response.dart';
Expand Down Expand Up @@ -33,11 +34,23 @@ class CleanAirForumCameraScreen extends StatefulWidget {

class _CleanAirForumCameraScreenState extends State<CleanAirForumCameraScreen>
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<CameraDescription> _cameras = const [];
int _cameraIndex = 0;
Future<void>? _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
Expand Down Expand Up @@ -84,25 +97,44 @@ class _CleanAirForumCameraScreenState extends State<CleanAirForumCameraScreen>
}

Future<void> _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(() {});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Future<void> _switchCamera() async {
Expand All @@ -120,29 +152,47 @@ class _CleanAirForumCameraScreenState extends State<CleanAirForumCameraScreen>

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));
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ class _CleanAirForumFilterTabState extends State<CleanAirForumFilterTab> {
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.
Expand Down Expand Up @@ -205,6 +206,7 @@ class _CleanAirForumFilterTabState extends State<CleanAirForumFilterTab> {
if (file != null && mounted) {
widget.onSelfieChanged(file);
AnalyticsService().trackCafSelfieCaptured();
if (widget.consentToDisplay) unawaited(_submitCurrentSelfieToWall());
}
} catch (_) {
widget.onMessage("Couldn't open the camera.", isError: true);
Expand Down Expand Up @@ -238,36 +240,49 @@ class _CleanAirForumFilterTabState extends State<CleanAirForumFilterTab> {
method: 'share_sheet',
);
}

if (widget.consentToDisplay) {
unawaited(_submitToConferenceWall(imageBytes));
}
} catch (_) {
widget.onMessage("Couldn't share the filter. Try again.", isError: true);
} finally {
if (mounted) setState(() => _isSharingFilter = false);
}
}

/// 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<void> _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<void> _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.
Expand All @@ -284,7 +299,6 @@ class _CleanAirForumFilterTabState extends State<CleanAirForumFilterTab> {
);
} finally {
_isSendingToWall = false;
if (mounted) setState(() {});
}
}

Expand Down Expand Up @@ -372,26 +386,6 @@ class _CleanAirForumFilterTabState extends State<CleanAirForumFilterTab> {
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),
),
],
),
],
],
],
);
Expand Down Expand Up @@ -426,7 +420,10 @@ class _CleanAirForumFilterTabState extends State<CleanAirForumFilterTab> {
value: widget.consentToDisplay,
onChanged: (value) {
widget.onConsentChanged(value);
if (value) AnalyticsService().trackCafWallConsentGiven();
if (value) {
AnalyticsService().trackCafWallConsentGiven();
unawaited(_submitCurrentSelfieToWall());
}
},
),
],
Expand Down
Loading
Loading