From 7a6252f14a0c422e5bbc3037fa1c1c6df99afb61 Mon Sep 17 00:00:00 2001 From: Peter Kyeyune Date: Wed, 8 Jan 2025 16:25:54 +0300 Subject: [PATCH] Add air quality forecasting functionality and improve analytics components --- .../dashboard/models/forecast_response.dart | 57 +++++- .../app/dashboard/pages/dashboard_page.dart | 3 +- .../app/dashboard/widgets/analytics_card.dart | 4 +- .../widgets/analytics_forecast_widget.dart | 190 +++++++++--------- .../widgets/analytics_specifics.dart | 9 +- .../lib/src/meta/utils/forecast_utils.dart | 40 ++++ mobile-v3/lib/src/meta/utils/utils.dart | 2 +- 7 files changed, 190 insertions(+), 115 deletions(-) create mode 100644 mobile-v3/lib/src/meta/utils/forecast_utils.dart diff --git a/mobile-v3/lib/src/app/dashboard/models/forecast_response.dart b/mobile-v3/lib/src/app/dashboard/models/forecast_response.dart index ac7b09631d..86e675c80f 100644 --- a/mobile-v3/lib/src/app/dashboard/models/forecast_response.dart +++ b/mobile-v3/lib/src/app/dashboard/models/forecast_response.dart @@ -1,15 +1,17 @@ -import 'package:airqo/src/app/dashboard/models/airquality_response.dart'; class ForecastResponse { + Map aqiRanges; List forecasts; ForecastResponse({ + required this.aqiRanges, required this.forecasts, }); factory ForecastResponse.fromJson(Map json) => ForecastResponse( - forecasts: List.from( - json["forecasts"].map((x) => Forecast.fromJson(x))), + aqiRanges: Map.from(json["aqi_ranges"].map((key, value) => MapEntry(key, AqiRange.fromJson(value)))), + + forecasts: List.from(json["forecasts"].map((x) => Forecast.fromJson(x))), ); Map toJson() => { @@ -17,20 +19,55 @@ class ForecastResponse { }; } +class AqiRange { + final String aqiCategory; + final String aqiColor; + final String aqiColorName; + final String label; + final double? max; + final double min; + + AqiRange({ + required this.aqiCategory, + required this.aqiColor, + required this.aqiColorName, + required this.label, + required this.max, + required this.min, + }); + + factory AqiRange.fromJson(Map json) { + return AqiRange( + aqiCategory: json['aqi_category'], + aqiColor: json['aqi_color'], + aqiColorName: json['aqi_color_name'], + label: json['label'], + max: json['max'] != null ? json['max'].toDouble() : null, + min: json['min'].toDouble(), + ); + } +} + class Forecast { - Measurement measurement; - double pm25; - DateTime time; + final String aqiCategory; + final String aqiColor; + final String aqiColorName; + final double pm25; + final DateTime time; Forecast({ - required this.measurement, + required this.aqiCategory, + required this.aqiColor, + required this.aqiColorName, required this.pm25, required this.time, }); factory Forecast.fromJson(Map json) => Forecast( - measurement: Measurement.fromJson(json["measurement"]), - pm25: json["pm2_5"]?.toDouble(), + aqiCategory: json["aqi_category"], + aqiColor: json["aqi_color"], + aqiColorName: json["aqi_color_name"], + pm25: json["pm2_5"]?.toDouble(), time: DateTime.parse(json["time"]), ); @@ -38,4 +75,4 @@ class Forecast { "pm2_5": pm25, "time": time.toIso8601String(), }; -} +} \ No newline at end of file diff --git a/mobile-v3/lib/src/app/dashboard/pages/dashboard_page.dart b/mobile-v3/lib/src/app/dashboard/pages/dashboard_page.dart index 41311e74ac..c9c4a77146 100644 --- a/mobile-v3/lib/src/app/dashboard/pages/dashboard_page.dart +++ b/mobile-v3/lib/src/app/dashboard/pages/dashboard_page.dart @@ -4,7 +4,6 @@ import 'package:airqo/src/app/dashboard/widgets/countries_chip.dart'; import 'package:airqo/src/app/dashboard/widgets/dashboard_loading.dart'; import 'package:airqo/src/app/other/theme/bloc/theme_bloc.dart'; import 'package:airqo/src/app/profile/bloc/user_bloc.dart'; -import 'package:airqo/src/app/profile/pages/profile_page.dart'; import 'package:airqo/src/app/shared/pages/error_page.dart'; import 'package:airqo/src/app/shared/widgets/loading_widget.dart'; import 'package:airqo/src/app/shared/widgets/page_padding.dart'; @@ -308,4 +307,4 @@ class CountryModel { final String countryName; const CountryModel(this.flag, this.countryName); -} +} \ No newline at end of file diff --git a/mobile-v3/lib/src/app/dashboard/widgets/analytics_card.dart b/mobile-v3/lib/src/app/dashboard/widgets/analytics_card.dart index 30e4425b85..6613987f3a 100644 --- a/mobile-v3/lib/src/app/dashboard/widgets/analytics_card.dart +++ b/mobile-v3/lib/src/app/dashboard/widgets/analytics_card.dart @@ -70,7 +70,7 @@ class AnalyticsCard extends StatelessWidget { SizedBox( child: Center( child: SvgPicture.asset( - getAirQualityIcon(measurement, measurement.pm25!.value ?? 10), + getAirQualityIcon(measurement, measurement.pm25!.value!), height: 96, width: 96, ), @@ -110,4 +110,4 @@ class AnalyticsCard extends StatelessWidget { ), ); } -} +} \ No newline at end of file diff --git a/mobile-v3/lib/src/app/dashboard/widgets/analytics_forecast_widget.dart b/mobile-v3/lib/src/app/dashboard/widgets/analytics_forecast_widget.dart index b7f28528d5..1e5d782038 100644 --- a/mobile-v3/lib/src/app/dashboard/widgets/analytics_forecast_widget.dart +++ b/mobile-v3/lib/src/app/dashboard/widgets/analytics_forecast_widget.dart @@ -1,103 +1,103 @@ -// import 'package:airqo/src/app/dashboard/bloc/forecast/forecast_bloc.dart'; -// import 'package:airqo/src/app/shared/widgets/loading_widget.dart'; -// import 'package:airqo/src/meta/utils/colors.dart'; -// import 'package:airqo/src/meta/utils/utils.dart'; -// import 'package:flutter/material.dart'; -// import 'package:flutter_bloc/flutter_bloc.dart'; -// import 'package:flutter_svg/flutter_svg.dart'; -// import 'package:intl/intl.dart'; +import 'package:airqo/src/app/dashboard/bloc/forecast/forecast_bloc.dart'; +import 'package:airqo/src/app/shared/widgets/loading_widget.dart'; +import 'package:airqo/src/meta/utils/colors.dart'; +import 'package:airqo/src/meta/utils/forecast_utils.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:intl/intl.dart'; -// class AnalyticsForecastWidget extends StatefulWidget { -// final String siteId; -// const AnalyticsForecastWidget({super.key, required this.siteId}); +class AnalyticsForecastWidget extends StatefulWidget { + final String siteId; + const AnalyticsForecastWidget({super.key, required this.siteId}); -// @override -// State createState() => -// _AnalyticsForecastWidgetState(); -// } + @override + State createState() => + _AnalyticsForecastWidgetState(); +} -// class _AnalyticsForecastWidgetState extends State { -// ForecastBloc? forecastBloc; +class _AnalyticsForecastWidgetState extends State { + ForecastBloc? forecastBloc; -// @override -// void initState() { -// forecastBloc = context.read() -// ..add(LoadForecast(widget.siteId)); -// super.initState(); -// } + @override + void initState() { + forecastBloc = context.read() + ..add(LoadForecast(widget.siteId)); + super.initState(); + } -// @override -// Widget build(BuildContext context) { -// return BlocBuilder( -// builder: (context, state) { -// if (state is ForecastLoaded) { -// return Row( -// children: state.response.forecasts -// .map((e) => ForeCastChip( -// active: false, -// date: DateFormat.d().format(e.time), -// day: DateFormat.E().format(e.time)[0], -// imagePath: getAirQualityIcon(e.measurement, e.pm25), -// )) -// .toList()); -// } else if (state is ForecastLoading) { -// return Row( -// mainAxisAlignment: MainAxisAlignment.spaceAround, -// children: List.generate(7, (index) { -// return ShimmerContainer( -// height: 47 + 45, borderRadius: 22, width: 40); -// })); -// } + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state is ForecastLoaded) { + return Row( + children: state.response.forecasts + .map((e) => ForeCastChip( + active: false, + date: DateFormat.d().format(e.time), + day: DateFormat.E().format(e.time)[0], + imagePath: getForecastAirQualityIcon(e.pm25, state.response.aqiRanges), + )) + .toList()); + } else if (state is ForecastLoading) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: List.generate(7, (index) { + return ShimmerContainer( + height: 47 + 45, borderRadius: 22, width: 40); + })); + } -// return Container( -// child: Center( -// child: Text(state.toString()), -// ), -// ); -// }, -// ); -// } -// } + return Container( + child: Center( + child: Text(state.toString()), + ), + ); + }, + ); + } +} -// class ForeCastChip extends StatelessWidget { -// final bool active; -// final String day; -// final String imagePath; -// final String date; -// const ForeCastChip( -// {super.key, -// required this.active, -// required this.imagePath, -// required this.date, -// required this.day}); +class ForeCastChip extends StatelessWidget { + final bool active; + final String day; + final String imagePath; + final String date; + const ForeCastChip( + {super.key, + required this.active, + required this.imagePath, + required this.date, + required this.day}); -// @override -// Widget build(BuildContext context) { -// return Expanded( -// child: Container( -// decoration: BoxDecoration( -// color: active -// ? AppColors.primaryColor -// : Theme.of(context).highlightColor, -// borderRadius: BorderRadius.circular(22)), -// padding: const EdgeInsets.symmetric(vertical: 8), -// margin: const EdgeInsets.symmetric(horizontal: 5), -// height: 47 + 45, -// child: Column( -// mainAxisAlignment: MainAxisAlignment.spaceBetween, -// children: [ -// Text(day), -// Text(date), -// SizedBox( -// child: Center( -// child: SvgPicture.asset( -// imagePath, -// height: 26, -// width: 26, -// ), -// ), -// ), -// ])), -// ); -// } -// } + @override + Widget build(BuildContext context) { + return Expanded( + child: Container( + decoration: BoxDecoration( + color: active + ? AppColors.primaryColor + : Theme.of(context).highlightColor, + borderRadius: BorderRadius.circular(22)), + padding: const EdgeInsets.symmetric(vertical: 8), + margin: const EdgeInsets.symmetric(horizontal: 5), + height: 47 + 45, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(day), + Text(date), + SizedBox( + child: Center( + child: SvgPicture.asset( + imagePath, + height: 26, + width: 26, + ), + ), + ), + ])), + ); + } +} \ No newline at end of file diff --git a/mobile-v3/lib/src/app/dashboard/widgets/analytics_specifics.dart b/mobile-v3/lib/src/app/dashboard/widgets/analytics_specifics.dart index 69090964a3..5c93e6ceca 100644 --- a/mobile-v3/lib/src/app/dashboard/widgets/analytics_specifics.dart +++ b/mobile-v3/lib/src/app/dashboard/widgets/analytics_specifics.dart @@ -3,7 +3,6 @@ import 'package:airqo/src/app/dashboard/widgets/analytics_card.dart'; import 'package:airqo/src/app/dashboard/widgets/analytics_forecast_widget.dart'; import 'package:airqo/src/meta/utils/colors.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_svg/flutter_svg.dart'; class AnalyticsSpecifics extends StatefulWidget { final Measurement measurement; @@ -104,9 +103,9 @@ class _AnalyticsSpecificsState extends State { ], ), SizedBox(height: 16), - // AnalyticsForecastWidget( - // siteId: widget.measurement.siteDetails!.id!, - // ), + AnalyticsForecastWidget( + siteId: widget.measurement.siteDetails!.id!, + ), SizedBox(height: 16), ], ), @@ -185,4 +184,4 @@ class _AnalyticsSpecificsState extends State { ), ); } -} +} \ No newline at end of file diff --git a/mobile-v3/lib/src/meta/utils/forecast_utils.dart b/mobile-v3/lib/src/meta/utils/forecast_utils.dart new file mode 100644 index 0000000000..02748c591e --- /dev/null +++ b/mobile-v3/lib/src/meta/utils/forecast_utils.dart @@ -0,0 +1,40 @@ +import 'package:airqo/src/app/dashboard/models/forecast_response.dart'; + +String getForecastAirQualityIcon(double value, Map aqiRanges) { + switch (getAirQuality(value, aqiRanges)) { + case "Good": + return "assets/images/shared/airquality_indicators/good.svg"; + + case "Moderate": + return "assets/images/shared/airquality_indicators/moderate.svg"; + + case "Unhealthy": + return "assets/images/shared/airquality_indicators/unhealthy.svg"; + + case "Unhealthy for Sensitive Groups": + return "assets/images/shared/airquality_indicators/unhealthy-sensitive.svg"; + + case "Very Unhealthy": + return "assets/images/shared/airquality_indicators/very-unhealthy.svg"; + + case "Hazardous": + return "assets/images/shared/airquality_indicators/hazardous.svg"; + + + case "Unavailable": + return "assets/images/shared/airquality_indicators/unavailable.svg"; + + + default: + return ""; + } +} + +String getAirQuality(double value, Map aqiRanges) { + for (var range in aqiRanges.values) { + if (value >= range.min && (range.max == null || value <= range.max!)) { + return range.aqiCategory; + } + } + return "Unavailable"; +} \ No newline at end of file diff --git a/mobile-v3/lib/src/meta/utils/utils.dart b/mobile-v3/lib/src/meta/utils/utils.dart index 4c627e8d4b..71a7380789 100644 --- a/mobile-v3/lib/src/meta/utils/utils.dart +++ b/mobile-v3/lib/src/meta/utils/utils.dart @@ -70,4 +70,4 @@ String _getDynamicAirQuality(AqiRanges aqiRanges, double value) { } return "Unavailable"; -} +} \ No newline at end of file