diff --git a/.github/workflows/deploy-frontend-azurepreview.yml b/.github/workflows/deploy-frontend-azurepreview.yml index df498cb2de..77737ae7cd 100644 --- a/.github/workflows/deploy-frontend-azurepreview.yml +++ b/.github/workflows/deploy-frontend-azurepreview.yml @@ -116,7 +116,7 @@ jobs: - uses: actions/checkout@v4 with: repository: ${{ github.event.pull_request.head.repo.full_name }} - ref: ${{ github.event.pull_request.head.ref }} + ref: ${{ github.event.pull_request.head.sha }} - name: Azure Login uses: azure/login@v2 @@ -135,8 +135,8 @@ jobs: --build-arg NEXT_PUBLIC_HCAPTCHA_SITE_KEY=${{ secrets.WEBSITE_STAGE_NEXT_PUBLIC_HCAPTCHA_SITE_KEY }} \ --build-arg NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN=${{ secrets.WEBSITE_STAGE_NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN }} \ --build-arg NEXT_PUBLIC_SITE_URL=${{ secrets.WEBSITE_STAGE_NEXT_PUBLIC_SITE_URL }} \ - --tag ${{ env.REGISTRY_URL }}/website-pr-preview:${{ github.sha }} - docker push ${{ env.REGISTRY_URL }}/website-pr-preview:${{ github.sha }} + --tag ${{ env.REGISTRY_URL }}/website-pr-preview:${{ github.event.pull_request.head.sha }} + docker push ${{ env.REGISTRY_URL }}/website-pr-preview:${{ github.event.pull_request.head.sha }} - name: Deploy Container App run: | @@ -147,7 +147,7 @@ jobs: --name $APP \ --resource-group ${{ env.RG }} \ --environment ${{ env.PR_PREVIEW_ENV }} \ - --image ${{ env.REGISTRY_URL }}/website-pr-preview:${{ github.sha }} \ + --image ${{ env.REGISTRY_URL }}/website-pr-preview:${{ github.event.pull_request.head.sha }} \ --target-port 8080 \ --ingress external \ --registry-server ${{ env.REGISTRY_URL }} \ @@ -199,7 +199,7 @@ jobs: uses: actions/checkout@v4 with: repository: ${{ github.event.pull_request.head.repo.full_name }} - ref: ${{ github.event.pull_request.head.ref }} + ref: ${{ github.event.pull_request.head.sha }} - name: Azure Login uses: azure/login@v2 @@ -217,8 +217,8 @@ jobs: - name: Build image run: | - docker build -t ${{ env.REGISTRY_URL }}/beacon-pr-preview:${{ github.sha }} src/beacon - docker push ${{ env.REGISTRY_URL }}/beacon-pr-preview:${{ github.sha }} + docker build -t ${{ env.REGISTRY_URL }}/beacon-pr-preview:${{ github.event.pull_request.head.sha }} src/beacon + docker push ${{ env.REGISTRY_URL }}/beacon-pr-preview:${{ github.event.pull_request.head.sha }} - name: Deploy Container App run: | @@ -231,7 +231,7 @@ jobs: --name $APP \ --resource-group ${{ env.RG }} \ --environment ${{ env.PR_PREVIEW_ENV }} \ - --image ${{ env.REGISTRY_URL }}/beacon-pr-preview:${{ github.sha }} \ + --image ${{ env.REGISTRY_URL }}/beacon-pr-preview:${{ github.event.pull_request.head.sha }} \ --target-port 3000 \ --ingress external \ --registry-server ${{ env.REGISTRY_URL }} \ @@ -283,7 +283,7 @@ jobs: uses: actions/checkout@v4 with: repository: ${{ github.event.pull_request.head.repo.full_name }} - ref: ${{ github.event.pull_request.head.ref }} + ref: ${{ github.event.pull_request.head.sha }} - uses: azure/login@v2 with: @@ -300,8 +300,8 @@ jobs: - name: Build image run: | - docker build -t ${{ env.REGISTRY_URL }}/calibrate-app-pr-preview:${{ github.sha }} src/calibrate - docker push ${{ env.REGISTRY_URL }}/calibrate-app-pr-preview:${{ github.sha }} + docker build -t ${{ env.REGISTRY_URL }}/calibrate-app-pr-preview:${{ github.event.pull_request.head.sha }} src/calibrate + docker push ${{ env.REGISTRY_URL }}/calibrate-app-pr-preview:${{ github.event.pull_request.head.sha }} - name: Deploy Container App run: | @@ -314,7 +314,7 @@ jobs: --name $APP \ --resource-group ${{ env.RG }} \ --environment ${{ env.PR_PREVIEW_ENV }} \ - --image ${{ env.REGISTRY_URL }}/calibrate-app-pr-preview:${{ github.sha }} \ + --image ${{ env.REGISTRY_URL }}/calibrate-app-pr-preview:${{ github.event.pull_request.head.sha }} \ --target-port 80 \ --ingress external \ --registry-server ${{ env.REGISTRY_URL }} \ @@ -366,7 +366,7 @@ jobs: uses: actions/checkout@v4 with: repository: ${{ github.event.pull_request.head.repo.full_name }} - ref: ${{ github.event.pull_request.head.ref }} + ref: ${{ github.event.pull_request.head.sha }} - uses: azure/login@v2 with: @@ -400,8 +400,8 @@ jobs: - name: Build image run: | - docker build -t ${{ env.REGISTRY_URL }}/analytics-platform-pr-preview:${{ github.sha }} src/platform - docker push ${{ env.REGISTRY_URL }}/analytics-platform-pr-preview:${{ github.sha }} + docker build -t ${{ env.REGISTRY_URL }}/analytics-platform-pr-preview:${{ github.event.pull_request.head.sha }} src/platform + docker push ${{ env.REGISTRY_URL }}/analytics-platform-pr-preview:${{ github.event.pull_request.head.sha }} - name: Deploy Container App run: | @@ -426,7 +426,7 @@ jobs: --name $APP \ --resource-group ${{ env.RG }} \ --environment ${{ env.PR_PREVIEW_ENV }} \ - --image ${{ env.REGISTRY_URL }}/analytics-platform-pr-preview:${{ github.sha }} \ + --image ${{ env.REGISTRY_URL }}/analytics-platform-pr-preview:${{ github.event.pull_request.head.sha }} \ --target-port 3000 \ --ingress external \ --registry-server ${{ env.REGISTRY_URL }} \ @@ -494,7 +494,7 @@ jobs: uses: actions/checkout@v4 with: repository: ${{ github.event.pull_request.head.repo.full_name }} - ref: ${{ github.event.pull_request.head.ref }} + ref: ${{ github.event.pull_request.head.sha }} - uses: azure/login@v2 with: @@ -528,8 +528,8 @@ jobs: - name: Build image run: | - docker build -t ${{ env.REGISTRY_URL }}/vertex-pr-preview:${{ github.sha }} src/vertex - docker push ${{ env.REGISTRY_URL }}/vertex-pr-preview:${{ github.sha }} + docker build -t ${{ env.REGISTRY_URL }}/vertex-pr-preview:${{ github.event.pull_request.head.sha }} src/vertex + docker push ${{ env.REGISTRY_URL }}/vertex-pr-preview:${{ github.event.pull_request.head.sha }} - name: Deploy Container App run: | @@ -554,7 +554,7 @@ jobs: --name $APP \ --resource-group ${{ env.RG }} \ --environment ${{ env.PR_PREVIEW_ENV }} \ - --image ${{ env.REGISTRY_URL }}/vertex-pr-preview:${{ github.sha }} \ + --image ${{ env.REGISTRY_URL }}/vertex-pr-preview:${{ github.event.pull_request.head.sha }} \ --target-port 3000 \ --ingress external \ --registry-server ${{ env.REGISTRY_URL }} \ @@ -628,7 +628,7 @@ jobs: uses: actions/checkout@v4 with: repository: ${{ github.event.pull_request.head.repo.full_name }} - ref: ${{ github.event.pull_request.head.ref }} + ref: ${{ github.event.pull_request.head.sha }} - uses: azure/login@v2 with: @@ -645,8 +645,8 @@ jobs: - name: Build image run: | - docker build -t ${{ env.REGISTRY_URL }}/docs-pr-preview:${{ github.sha }} src/docs-website - docker push ${{ env.REGISTRY_URL }}/docs-pr-preview:${{ github.sha }} + docker build -t ${{ env.REGISTRY_URL }}/docs-pr-preview:${{ github.event.pull_request.head.sha }} src/docs-website + docker push ${{ env.REGISTRY_URL }}/docs-pr-preview:${{ github.event.pull_request.head.sha }} - name: Deploy Container App run: | @@ -659,7 +659,7 @@ jobs: --name $APP \ --resource-group ${{ env.RG }} \ --environment ${{ env.PR_PREVIEW_ENV }} \ - --image ${{ env.REGISTRY_URL }}/docs-pr-preview:${{ github.sha }} \ + --image ${{ env.REGISTRY_URL }}/docs-pr-preview:${{ github.event.pull_request.head.sha }} \ --target-port 3000 \ --ingress external \ --registry-server ${{ env.REGISTRY_URL }} \ diff --git a/k8s/vertex/k8s-aks/airqo-vertex/values-prod.yaml b/k8s/vertex/k8s-aks/airqo-vertex/values-prod.yaml index 682aacc1f0..ce245faa2b 100644 --- a/k8s/vertex/k8s-aks/airqo-vertex/values-prod.yaml +++ b/k8s/vertex/k8s-aks/airqo-vertex/values-prod.yaml @@ -1,7 +1,7 @@ replicaCount: 2 image: repository: airqoacr.azurecr.io/airqo-vertex - tag: prod-1a8efe83-1783586974 + tag: prod-2055e3b0-1783593952 pullPolicy: IfNotPresent imagePullSecrets: [] nameOverride: '' 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/nearby_view.dart b/src/mobile/lib/src/app/dashboard/widgets/nearby_view.dart index 081369ecad..2daf7fec9b 100644 --- a/src/mobile/lib/src/app/dashboard/widgets/nearby_view.dart +++ b/src/mobile/lib/src/app/dashboard/widgets/nearby_view.dart @@ -88,13 +88,12 @@ class _NearbyViewState extends State with UiLoggy { if (position != null && mounted) { setState(() { _userPosition = position; - _isLoading = false; }); - + // Update nearby locations with this position await _updateNearbyLocations(); } - + // Then get current position for more accuracy try { position = await Geolocator.getCurrentPosition( @@ -103,11 +102,10 @@ class _NearbyViewState extends State with UiLoggy { timeLimit: Duration(seconds: 5), ), ); - + if (mounted) { setState(() { _userPosition = position; - _isLoading = false; }); } @@ -210,6 +208,13 @@ class _NearbyViewState extends State with UiLoggy { final state = context.read().state; if (state is! DashboardLoaded || state.response.measurements == null) { + // Dashboard loaded but has no measurements: nothing to filter, stop + // showing the loading skeleton so the empty state can render. + if (state is DashboardLoaded && mounted) { + setState(() { + _isLoading = false; + }); + } return; } @@ -279,6 +284,7 @@ class _NearbyViewState extends State with UiLoggy { if (mounted) { setState(() { _nearbyMeasurementsWithDistance = visibleMeasurements; + _isLoading = false; }); } } @@ -349,6 +355,10 @@ class _NearbyViewState extends State with UiLoggy { } void _retry() { + setState(() { + _isLoading = true; + _errorMessage = null; + }); _initializeLocationAndData(); } @@ -377,32 +387,26 @@ class _NearbyViewState extends State with UiLoggy { } } }, - child: BlocSelector( - selector: (state) { + child: BlocBuilder( + builder: (context, state) { + // Local init (cache read, location fetch, distance filtering) may + // still be running even though the dashboard data is already + // loaded; keep showing the skeleton until it produces a result. + // This must be computed in the builder (not a BlocSelector) so + // local setState calls re-evaluate it against current fields. + final bool isLoading; if (state is DashboardLoaded) { - return { - 'isLoading': false, - 'error': null, - }; + isLoading = _isLoading && _nearbyMeasurementsWithDistance.isEmpty; } else if (state is DashboardLoadingError) { - return { - 'isLoading': false, - 'error': state.message, - }; + isLoading = false; } else if (state is DashboardLoading) { - return { - 'isLoading': _nearbyMeasurementsWithDistance.isEmpty, - 'error': null, - }; + isLoading = _isLoading && _nearbyMeasurementsWithDistance.isEmpty; + } else { + isLoading = _isLoading && _nearbyMeasurementsWithDistance.isEmpty; } - return { - 'isLoading': _isLoading && _nearbyMeasurementsWithDistance.isEmpty, - 'error': _errorMessage, - }; - }, - builder: (context, selectedState) { - final isLoading = selectedState['isLoading'] as bool; - final error = selectedState['error'] as String? ?? _errorMessage; + final error = + (state is DashboardLoadingError ? state.message : null) ?? + _errorMessage; if (error != null && error.contains('permission') && _nearbyMeasurementsWithDistance.isEmpty) { return NearbyViewEmptyState( 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._(); diff --git a/src/website/README.md b/src/website/README.md index 42b06fe0ef..e56cc0ae9a 100644 --- a/src/website/README.md +++ b/src/website/README.md @@ -1,6 +1,6 @@ # Website -![codecov](https://codecov.io/gh/airqo-platform/AirQo-frontend/graph/badge.svg?token=LsBcFL42rz) ![passing](https://img.shields.io/badge/passing-803%20%E2%80%94%20100%25-brightgreen?style=flat-square) ![failing](https://img.shields.io/badge/failing-1-brightgreen?style=flat-square) ![coverage](https://img.shields.io/badge/coverage-84%25-brightgreen?style=flat-square) ![Node.js](https://img.shields.io/badge/Node.js-20+-339933?style=flat-square&logo=node.js&logoColor=white) ![TypeScript](https://img.shields.io/badge/TypeScript-5.5-blue?style=flat-square&logo=typescript&logoColor=white) +[![CI](https://github.com/airqo-platform/AirQo-frontend/actions/workflows/website-ci.yml/badge.svg)](https://github.com/airqo-platform/AirQo-frontend/actions/workflows/website-ci.yml) [![codecov](https://codecov.io/gh/airqo-platform/AirQo-frontend/graph/badge.svg?token=LsBcFL42rz)](https://codecov.io/gh/airqo-platform/AirQo-frontend) [![Node.js](https://img.shields.io/badge/Node.js-20+-339933?style=flat-square&logo=node.js&logoColor=white)](https://nodejs.org/) [![TypeScript](https://img.shields.io/badge/TypeScript-5.5-blue?style=flat-square&logo=typescript&logoColor=white)](https://www.typescriptlang.org/) **Website** is the AirQo marketing and analytics application for air quality data visualization. Built with Next.js 14, React 18, TypeScript, Tailwind CSS, Redux Toolkit, and TanStack React Query. The live website is at [airqo.net](https://airqo.net). diff --git a/src/website/src/app/api/v2/[...slug]/route.ts b/src/website/src/app/api/v2/[...slug]/route.ts index 9c6246d1e7..0628e8f955 100644 --- a/src/website/src/app/api/v2/[...slug]/route.ts +++ b/src/website/src/app/api/v2/[...slug]/route.ts @@ -62,26 +62,11 @@ export async function PUT( return handleRequest(request, params.slug, 'PUT'); } -export async function DELETE( - request: NextRequest, - { params }: { params: { slug: string[] } }, -) { - return handleRequest(request, params.slug, 'DELETE'); -} - async function handleRequest( request: NextRequest, slug: string[], method: string, ) { - if (!API_TOKEN) { - console.error('API_TOKEN environment variable is required but not found'); - return NextResponse.json( - { error: 'Server configuration error' }, - { status: 500 }, - ); - } - try { // Build the external API URL const path = slug.join('/'); @@ -109,6 +94,14 @@ async function handleRequest( finalPath = `api/v2/${cleanPath}`; } + if (!API_TOKEN) { + console.error('API_TOKEN environment variable is required but not found'); + return NextResponse.json( + { error: 'Server configuration error' }, + { status: 500 }, + ); + } + // The client passes a proxy path that maps cleanly to the backend route. const externalUrl = `${API_BASE_URL}/${finalPath}`; @@ -142,7 +135,7 @@ async function handleRequest( // Get request body if it exists let body: string | undefined; - if (method === 'POST' || method === 'PUT' || method === 'PATCH') { + if (method === 'POST' || method === 'PUT') { try { body = await request.text(); } catch { diff --git a/src/website/src/features/faces-of-clean-air/FacesOfCleanAirPage.tsx b/src/website/src/features/faces-of-clean-air/FacesOfCleanAirPage.tsx index 4b13d98331..059e2c8ea0 100644 --- a/src/website/src/features/faces-of-clean-air/FacesOfCleanAirPage.tsx +++ b/src/website/src/features/faces-of-clean-air/FacesOfCleanAirPage.tsx @@ -4,7 +4,6 @@ import { AnimatePresence, motion, type PanInfo, - useMotionTemplate, useMotionValue, useReducedMotion, useSpring, @@ -30,7 +29,7 @@ import { } from '@/config/cleanAirForumConfig'; import { usePollingWithVisibility } from '@/hooks/usePollingWithVisibility'; import { facesOfCleanAirService } from '@/services/external'; -import type { CleanAirSubmission } from '@/services/external/faces-of-clean-air.service'; +import type { CleanAirSubmissionWithId } from '@/services/external/faces-of-clean-air.service'; const AIRQO_LOGO_URL = '/assets/images/white-logo.png'; @@ -41,9 +40,8 @@ const SWIPE_DISTANCE_THRESHOLD = 70; const SWIPE_VELOCITY_THRESHOLD = 500; const MOBILE_MEDIA_QUERY = '(max-width: 639px)'; -const EVENT_LABEL = 'Africa CLEAN-Air Forum'; +const EVENT_LABEL = 'Africa Clean Air Forum'; const EVENT_LOCATION_AND_YEAR = 'Pretoria 2026'; -const EVENT_DATES_BADGE = '14TH-16TH JULY'; const SMOOTH_SPRING = { stiffness: 150, @@ -53,11 +51,6 @@ const SMOOTH_SPRING = { type FetchState = 'idle' | 'loading' | 'success' | 'error'; -type CategoryStyle = { - label: string; - className: string; -}; - function useMediaQuery(query: string): boolean { const [matches, setMatches] = useState(false); @@ -79,79 +72,6 @@ function useMediaQuery(query: string): boolean { return matches; } -function formatPm25(value: number | null | undefined): string { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return '--'; - } - - return value.toFixed(1); -} - -function getFallbackCategory(pm25: number): CategoryStyle { - if (pm25 <= 12) { - return { - label: 'Good', - className: 'bg-emerald-100 text-emerald-700', - }; - } - - if (pm25 <= 35.4) { - return { - label: 'Moderate', - className: 'bg-amber-100 text-amber-700', - }; - } - - if (pm25 <= 55.4) { - return { - label: 'Sensitive', - className: 'bg-orange-100 text-orange-700', - }; - } - - return { - label: 'Unhealthy', - className: 'bg-red-100 text-red-700', - }; -} - -function getCategoryStyle( - category: string | null | undefined, - pm25: number, -): CategoryStyle { - const normalizedCategory = category?.trim().toLowerCase(); - - if (!normalizedCategory) { - return getFallbackCategory(pm25); - } - - if (normalizedCategory.includes('good')) { - return { - label: category!, - className: 'bg-emerald-100 text-emerald-700', - }; - } - - if (normalizedCategory.includes('moderate')) { - return { - label: category!, - className: 'bg-amber-100 text-amber-700', - }; - } - - if (normalizedCategory.includes('sensitive')) { - return { - label: category!, - className: 'bg-orange-100 text-orange-700', - }; - } - - return { - label: category!, - className: 'bg-red-100 text-red-700', - }; -} - function AmbientBackground({ reduceMotion }: { reduceMotion: boolean | null }) { return (
@@ -214,7 +134,7 @@ function AmbientBackground({ reduceMotion }: { reduceMotion: boolean | null }) { {/* Right atmospheric glow */}