Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
4 changes: 2 additions & 2 deletions src/mobile/android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ if (releaseKeystorePropertiesFile.exists()) {
android {
namespace "com.airqo.app"
compileSdk 36
ndkVersion flutter.ndkVersion
ndkVersion "27.0.12077973"

compileOptions {
sourceCompatibility JavaVersion.VERSION_1_9
Expand All @@ -98,7 +98,7 @@ android {
applicationId "com.airqo.app"
// You can update the following values to match your application needs.
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration.
minSdkVersion flutter.minSdkVersion
minSdkVersion 24
targetSdkVersion 36
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
Expand Down
13 changes: 13 additions & 0 deletions src/mobile/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.camera.front" android:required="false" />
<application
android:label="AirQo"
android:name="${applicationName}"
Expand Down Expand Up @@ -43,6 +46,16 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<!-- Required by pasteboard to write images to the Android clipboard. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility?hl=en and
Expand Down
13 changes: 13 additions & 0 deletions src/mobile/android/app/src/main/res/xml/provider_paths.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<!-- pasteboard's writeImage writes to context.cacheDir, which needs a
cache-path entry — the external-path alone (from pasteboard's own
README) doesn't cover it, causing FileProvider to throw "failed to
find configured root" for that file. -->
<cache-path
name="cache"
path="." />
<external-path
name="external_files"
path="." />
</paths>
4 changes: 4 additions & 0 deletions src/mobile/assets/icons/camera.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src/mobile/assets/icons/check-circle.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src/mobile/assets/icons/copy.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src/mobile/assets/icons/gallery.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src/mobile/assets/images/shared/airqo_icon_mark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
94 changes: 94 additions & 0 deletions src/mobile/lib/src/app/dashboard/utils/air_quality_card_utils.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import 'dart:convert';

import 'package:airqo/src/app/dashboard/models/airquality_response.dart';
import 'package:airqo/src/meta/utils/colors.dart';
import 'package:flutter/material.dart';

/// Shared rendering helpers for the air-quality share/filter/sticker cards so
/// they all present identical values (color, category label, sanitized
/// text) without duplicating logic in every widget.

/// Fixes mojibake that occasionally shows up in API text fields.
String sanitizeCardText(String value) {
if (!value.contains('Ã') && !value.contains('Â') && !value.contains('â')) {
return value;
}

try {
return utf8.decode(latin1.encode(value));
} catch (_) {
return value;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

/// Resolves the AQI accent color for a measurement, preferring the color
/// returned by the API and falling back to a category lookup.
Color getMeasurementAqiColor(Measurement measurement) {
if (measurement.aqiColor != null) {
try {
final colorString = measurement.aqiColor!.replaceAll('#', '');
return Color(int.parse('0xFF$colorString'));
} catch (_) {}
}

switch (measurement.aqiCategory?.toLowerCase() ?? '') {
case 'good':
return const Color(0xFF179D5B);
case 'moderate':
return const Color(0xFFC79000);
case 'unhealthy for sensitive groups':
case 'u4sg':
return const Color(0xFFE17827);
case 'unhealthy':
return const Color(0xFFD63A45);
case 'very unhealthy':
return const Color(0xFF7540B5);
case 'hazardous':
return const Color(0xFF6D4C41);
default:
return AppColors.primaryColor;
}
}

/// Shortens verbose AQI category labels so they fit on compact card pills.
String aqiCategoryLabel(String category) {
switch (category.toLowerCase()) {
case 'unhealthy for sensitive groups':
return 'Sensitive Groups';
case 'very unhealthy':
return 'Very Unhealthy';
default:
return category;
}
}

/// Light pastel background for an AQI status pill, derived by blending the
/// category color into white — matches the Figma "Label/Status/Good"
/// component style (solid pale bg + solid saturated text) rather than a
/// translucent overlay, which reads as muddy over photos.
Color aqiPillBackground(Color categoryColor) {
return Color.alphaBlend(categoryColor.withValues(alpha: 0.18), Colors.white);
}

/// Resolves the circular AQI "wheel" icon (ring + face) used on the share
/// card, filter, and sticker templates — same asset set used across the
/// dashboard and map so the icon always matches the category color.
String getMeasurementAqiIconAsset(Measurement measurement) {
switch (measurement.aqiCategory?.toLowerCase() ?? '') {
case 'good':
return 'assets/images/shared/airquality_indicators/good.svg';
case 'moderate':
return 'assets/images/shared/airquality_indicators/moderate.svg';
case 'unhealthy for sensitive groups':
case 'u4sg':
return 'assets/images/shared/airquality_indicators/unhealthy-sensitive.svg';
case 'unhealthy':
return 'assets/images/shared/airquality_indicators/unhealthy.svg';
case 'very unhealthy':
return 'assets/images/shared/airquality_indicators/very-unhealthy.svg';
case 'hazardous':
return 'assets/images/shared/airquality_indicators/hazardous.svg';
default:
return 'assets/images/shared/airquality_indicators/unavailable.svg';
}
}
130 changes: 130 additions & 0 deletions src/mobile/lib/src/app/dashboard/utils/clean_air_forum_branding.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';

/// Branding for the Africa Clean Air Forum selfie filter, based on the
/// "AQ/CAF" Figma templates (file `Z0OLd2awVqgZhytJULgO8L`, node 169:534 /
/// 169:491).
///
/// All templates were designed on a 1080x1080 canvas — [kCafReferenceWidth]
/// — so every size/gap in [CleanAirForumFilterCard] and
/// [CleanAirForumStickerFrame] is expressed as a fraction of that reference
/// and multiplied by the widget's actual rendered width. This keeps the
/// layout proportionally identical to the Figma design no matter what
/// resolution the card is previewed or captured at.
class CleanAirForumBrand {
const CleanAirForumBrand._();

/// "Vibrant sky blue" — matches the Africa Clean Air Network palette.
static const Color skyBlue = Color(0xFF1E9BE0);

/// "Nature green" — matches the Africa Clean Air Network palette.
static const Color natureGreen = Color(0xFF2FA84F);

/// Deep teal used for the filter card's bottom scrim, matching the Figma
/// "AQ/CAF" template.
static const Color scrimTeal = Color(0xFF005257);

/// Text color for the "Shared from the AirQo app" pill/caption, matching
/// the Figma template exactly (distinct from [scrimTeal]).
static const Color sharedCaptionText = Color(0xFF1F3D3D);

/// Host city + year shown under the "Clean Air Forum" wordmark.
static const String edition = 'Pretoria 2026';

/// Event date range shown in the corner of the filter card.
static const String dateRange = '13TH-16TH JULY';
}

/// Reference canvas width the Figma "AQ/CAF" templates were designed at.
/// Multiply this against any Figma pixel value, divided by this constant,
/// to get a proportionally-correct size for a card of a given width.
const double kCafReferenceWidth = 1080.0;

/// AirQo house-mark icon, recolorable for use on photos/colored backgrounds.
///
/// The "airqo" wordmark is cut out of the shape as negative space (rather
/// than drawn), so whatever sits behind the icon shows through the letters
/// — matching the Figma logo lockup exactly.
class AirQoIconMark extends StatelessWidget {
final double size;
final Color color;

const AirQoIconMark({super.key, this.size = 28, this.color = Colors.white});

/// Intrinsic aspect ratio of the source asset (143.38 x 97).
static const double aspectRatio = 97 / 143.38;

@override
Widget build(BuildContext context) {
return SvgPicture.asset(
'assets/images/shared/airqo_icon_mark.svg',
width: size,
height: size * aspectRatio,
colorFilter: ColorFilter.mode(color, BlendMode.srcIn),
);
}
}

/// AirQo icon + "Clean Air Forum" wordmark lockup shown top-left on the
/// selfie filter card: logo mark, a vertical divider, then the title/edition
/// text — matches the Figma "AQ/CAF" header exactly (node 169:538).
///
/// [scale] is the card's rendered width divided by [kCafReferenceWidth];
/// every size below is a Figma design pixel value multiplied by it.
class CleanAirForumBrandHeader extends StatelessWidget {
final double scale;

const CleanAirForumBrandHeader({super.key, required this.scale});

@override
Widget build(BuildContext context) {
final iconSize = 143.38 * scale;
final gap = 17 * scale;
final dividerWidth = (4 * scale).clamp(1.0, double.infinity);
final dividerHeight = 98 * scale;
final titleFontSize = 35.974 * scale;

return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
AirQoIconMark(size: iconSize),
SizedBox(width: gap),
Container(width: dividerWidth, height: dividerHeight, color: Colors.white),
SizedBox(width: gap),
Flexible(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Clean Air Forum',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: titleFontSize,
height: 1.1,
fontWeight: FontWeight.w700,
),
),
if (CleanAirForumBrand.edition.isNotEmpty)
Text(
CleanAirForumBrand.edition,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: titleFontSize,
height: 1.1,
fontStyle: FontStyle.italic,
fontWeight: FontWeight.w400,
),
),
],
),
),
],
);
}
}
Loading
Loading