Prepared for aqi distribution API Integration.

This commit is contained in:
Faris Armoush
2025-06-02 16:13:58 +03:00
parent 7bc9079212
commit 8e11749ed7
13 changed files with 296 additions and 213 deletions

View File

@ -1,24 +1,24 @@
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
import 'package:syncrow_web/utils/color_manager.dart';
class AirQualityDataModel {
class AirQualityDataModel extends Equatable {
const AirQualityDataModel({
required this.date,
this.good,
this.moderate,
this.poor,
this.unhealthy,
this.severe,
this.hazardous,
required this.data,
});
final DateTime date;
final double? good;
final double? moderate;
final double? poor;
final double? unhealthy;
final double? severe;
final double? hazardous;
final List<AirQualityPercentageData> data;
factory AirQualityDataModel.fromJson(Map<String, dynamic> json) {
return AirQualityDataModel(
date: DateTime.parse(json['date'] as String),
data: (json['data'] as List<dynamic>)
.map((e) => AirQualityPercentageData.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
static final Map<String, Color> metricColors = {
'good': ColorsManager.goodGreen.withValues(alpha: 0.7),
@ -28,4 +28,30 @@ class AirQualityDataModel {
'severe': ColorsManager.severePink.withValues(alpha: 0.7),
'hazardous': ColorsManager.hazardousPurple.withValues(alpha: 0.7),
};
@override
List<Object?> get props => [date, data];
}
class AirQualityPercentageData extends Equatable {
const AirQualityPercentageData({
required this.type,
required this.name,
required this.percentage,
});
final String type;
final String name;
final double percentage;
factory AirQualityPercentageData.fromJson(Map<String, dynamic> json) {
return AirQualityPercentageData(
type: json['type'] as String? ?? '',
name: json['name'] as String? ?? '',
percentage: (json['percentage'] as num?)?.toDouble() ?? 0,
);
}
@override
List<Object?> get props => [type, name, percentage];
}

View File

@ -1,6 +1,7 @@
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:syncrow_web/pages/analytics/models/air_quality_data_model.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/widgets/aqi_type_dropdown.dart';
import 'package:syncrow_web/pages/analytics/params/get_air_quality_distribution_param.dart';
import 'package:syncrow_web/pages/analytics/services/air_quality_distribution/air_quality_distribution_service.dart';
@ -9,13 +10,14 @@ part 'air_quality_distribution_state.dart';
class AirQualityDistributionBloc
extends Bloc<AirQualityDistributionEvent, AirQualityDistributionState> {
final AirQualityDistributionService _service;
final AirQualityDistributionService _aqiDistributionService;
AirQualityDistributionBloc(
this._service,
this._aqiDistributionService,
) : super(const AirQualityDistributionState()) {
on<LoadAirQualityDistribution>(_onLoadAirQualityDistribution);
on<ClearAirQualityDistribution>(_onClearAirQualityDistribution);
on<UpdateAqiTypeEvent>(_onUpdateAqiTypeEvent);
}
Future<void> _onLoadAirQualityDistribution(
@ -23,16 +25,15 @@ class AirQualityDistributionBloc
Emitter<AirQualityDistributionState> emit,
) async {
try {
emit(
const AirQualityDistributionState(
status: AirQualityDistributionStatus.loading,
),
emit(state.copyWith(status: AirQualityDistributionStatus.loading));
final result = await _aqiDistributionService.getAirQualityDistribution(
event.param,
);
final result = await _service.getAirQualityDistribution(event.param);
emit(
AirQualityDistributionState(
state.copyWith(
status: AirQualityDistributionStatus.success,
chartData: result,
filteredChartData: _arrangeChartDataByType(result, state.selectedAqiType),
),
);
} catch (e) {
@ -40,6 +41,7 @@ class AirQualityDistributionBloc
AirQualityDistributionState(
status: AirQualityDistributionStatus.failure,
errorMessage: e.toString(),
selectedAqiType: state.selectedAqiType,
),
);
}
@ -51,4 +53,29 @@ class AirQualityDistributionBloc
) async {
emit(const AirQualityDistributionState());
}
void _onUpdateAqiTypeEvent(
UpdateAqiTypeEvent event,
Emitter<AirQualityDistributionState> emit,
) {
emit(
state.copyWith(
selectedAqiType: event.aqiType,
filteredChartData: _arrangeChartDataByType(state.chartData, event.aqiType),
),
);
}
List<AirQualityDataModel> _arrangeChartDataByType(
List<AirQualityDataModel> data,
AqiType aqiType,
) {
final filteredData = data.map(
(data) => AirQualityDataModel(
date: data.date,
data: data.data.where((value) => value.type == aqiType.code).toList(),
),
);
return filteredData.toList();
}
}

View File

@ -16,6 +16,15 @@ final class LoadAirQualityDistribution extends AirQualityDistributionEvent {
List<Object> get props => [param];
}
final class UpdateAqiTypeEvent extends AirQualityDistributionEvent {
const UpdateAqiTypeEvent(this.aqiType);
final AqiType aqiType;
@override
List<Object> get props => [aqiType];
}
final class ClearAirQualityDistribution extends AirQualityDistributionEvent {
const ClearAirQualityDistribution();
}

View File

@ -8,16 +8,36 @@ enum AirQualityDistributionStatus {
}
class AirQualityDistributionState extends Equatable {
final AirQualityDistributionStatus status;
final List<AirQualityDataModel> chartData;
final String? errorMessage;
const AirQualityDistributionState({
this.status = AirQualityDistributionStatus.initial,
this.chartData = const [],
this.filteredChartData = const [],
this.errorMessage,
this.selectedAqiType = AqiType.aqi,
});
final AirQualityDistributionStatus status;
final List<AirQualityDataModel> chartData;
final List<AirQualityDataModel> filteredChartData;
final String? errorMessage;
final AqiType selectedAqiType;
AirQualityDistributionState copyWith({
AirQualityDistributionStatus? status,
List<AirQualityDataModel>? chartData,
List<AirQualityDataModel>? filteredChartData,
String? errorMessage,
AqiType? selectedAqiType,
}) {
return AirQualityDistributionState(
status: status ?? this.status,
chartData: chartData ?? this.chartData,
filteredChartData: filteredChartData ?? this.filteredChartData,
errorMessage: errorMessage ?? this.errorMessage,
selectedAqiType: selectedAqiType ?? this.selectedAqiType,
);
}
@override
List<Object?> get props => [status, chartData, errorMessage];
List<Object?> get props => [status, chartData, errorMessage, selectedAqiType];
}

View File

@ -14,8 +14,10 @@ abstract final class FetchAirQualityDataHelper {
static void loadAirQualityData(
BuildContext context, {
required DateTime date,
required String communityUuid,
required String spaceUuid,
bool shouldFetchAnalyticsDevices = true,
}) {
final date = context.read<AnalyticsDatePickerBloc>().state.monthlyDate;
loadAnalyticsDevices(

View File

@ -16,6 +16,11 @@ class AqiDistributionChart extends StatelessWidget {
@override
Widget build(BuildContext context) {
final sortedData = List<AirQualityDataModel>.from(chartData)
..sort(
(a, b) => a.date.compareTo(b.date),
);
return BarChart(
BarChartData(
maxY: 100.1,
@ -25,45 +30,29 @@ class AqiDistributionChart extends StatelessWidget {
borderData: EnergyManagementChartsHelper.borderData(),
barTouchData: _barTouchData(context),
titlesData: _titlesData(context),
barGroups: _buildBarGroups(),
barGroups: _buildBarGroups(sortedData),
),
duration: Duration.zero,
);
}
List<BarChartGroupData> _buildBarGroups() {
return List.generate(chartData.length, (index) {
final data = chartData[index];
List<BarChartGroupData> _buildBarGroups(List<AirQualityDataModel> sortedData) {
return List.generate(sortedData.length, (index) {
final data = sortedData[index];
final stackItems = <BarChartRodData>[];
double currentY = 0;
bool isFirstElement = true;
if (data.good != null) {
stackItems.add(
BarChartRodData(
fromY: currentY,
toY: currentY + data.good!,
color: AirQualityDataModel.metricColors['good']!,
borderRadius: isFirstElement
? const BorderRadius.only(
topLeft: Radius.circular(22),
topRight: Radius.circular(22),
)
// ignore: dead_code
: _barBorderRadius,
width: _barWidth,
),
);
currentY += data.good! + _rodStackItemsSpacing;
isFirstElement = false;
}
// Sort data by type to ensure consistent order
final sortedPercentageData = List<AirQualityPercentageData>.from(data.data)
..sort((a, b) => a.type.compareTo(b.type));
if (data.moderate != null) {
for (final percentageData in sortedPercentageData) {
stackItems.add(
BarChartRodData(
fromY: currentY,
toY: currentY + data.moderate!,
color: AirQualityDataModel.metricColors['moderate']!,
toY: currentY + percentageData.percentage ,
color: AirQualityDataModel.metricColors[percentageData.name]!,
borderRadius: isFirstElement
? const BorderRadius.only(
topLeft: Radius.circular(22),
@ -73,83 +62,7 @@ class AqiDistributionChart extends StatelessWidget {
width: _barWidth,
),
);
currentY += data.moderate! + _rodStackItemsSpacing;
isFirstElement = false;
}
if (data.poor != null) {
stackItems.add(
BarChartRodData(
fromY: currentY,
toY: currentY + data.poor!,
color: AirQualityDataModel.metricColors['poor']!,
borderRadius: isFirstElement
? const BorderRadius.only(
topLeft: Radius.circular(22),
topRight: Radius.circular(22),
)
: _barBorderRadius,
width: _barWidth,
),
);
currentY += data.poor! + _rodStackItemsSpacing;
isFirstElement = false;
}
if (data.unhealthy != null) {
stackItems.add(
BarChartRodData(
fromY: currentY,
toY: currentY + data.unhealthy!,
color: AirQualityDataModel.metricColors['unhealthy']!,
borderRadius: isFirstElement
? const BorderRadius.only(
topLeft: Radius.circular(22),
topRight: Radius.circular(22),
)
: _barBorderRadius,
width: _barWidth,
),
);
currentY += data.unhealthy! + _rodStackItemsSpacing;
isFirstElement = false;
}
if (data.severe != null) {
stackItems.add(
BarChartRodData(
fromY: currentY,
toY: currentY + data.severe!,
color: AirQualityDataModel.metricColors['severe']!,
borderRadius: isFirstElement
? const BorderRadius.only(
topLeft: Radius.circular(22),
topRight: Radius.circular(22),
)
: _barBorderRadius,
width: _barWidth,
),
);
currentY += data.severe! + _rodStackItemsSpacing;
isFirstElement = false;
}
if (data.hazardous != null) {
stackItems.add(
BarChartRodData(
fromY: currentY,
toY: currentY + data.hazardous!,
color: AirQualityDataModel.metricColors['hazardous']!,
borderRadius: isFirstElement
? const BorderRadius.only(
topLeft: Radius.circular(22),
topRight: Radius.circular(22),
)
: _barBorderRadius,
width: _barWidth,
),
);
currentY += data.hazardous! + _rodStackItemsSpacing;
currentY += percentageData.percentage + _rodStackItemsSpacing;
isFirstElement = false;
}
@ -180,44 +93,14 @@ class AqiDistributionChart extends StatelessWidget {
fontSize: 12,
);
if (data.good != null) {
children.add(TextSpan(
text: '\nGOOD: ${data.good!.toStringAsFixed(1)}%',
style: textStyle,
));
}
// Sort data by type to ensure consistent order
final sortedPercentageData = List<AirQualityPercentageData>.from(data.data)
..sort((a, b) => a.type.compareTo(b.type));
if (data.moderate != null) {
for (final percentageData in sortedPercentageData) {
children.add(TextSpan(
text: '\nMODERATE: ${data.moderate!.toStringAsFixed(1)}%',
style: textStyle,
));
}
if (data.poor != null) {
children.add(TextSpan(
text: '\nPOOR: ${data.poor!.toStringAsFixed(1)}%',
style: textStyle,
));
}
if (data.unhealthy != null) {
children.add(TextSpan(
text: '\nUNHEALTHY: ${data.unhealthy!.toStringAsFixed(1)}%',
style: textStyle,
));
}
if (data.severe != null) {
children.add(TextSpan(
text: '\nSEVERE: ${data.severe!.toStringAsFixed(1)}%',
style: textStyle,
));
}
if (data.hazardous != null) {
children.add(TextSpan(
text: '\nHAZARDOUS: ${data.hazardous!.toStringAsFixed(1)}%',
text:
'\n${percentageData.type.toUpperCase()}: ${percentageData.percentage.toStringAsFixed(1)}%',
style: textStyle,
));
}

View File

@ -32,7 +32,9 @@ class AqiDistributionChartBox extends StatelessWidget {
const SizedBox(height: 10),
const Divider(),
const SizedBox(height: 20),
Expanded(child: AqiDistributionChart(chartData: state.chartData)),
Expanded(
child: AqiDistributionChart(chartData: state.filteredChartData),
),
],
),
);

View File

@ -1,4 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/blocs/air_quality_distribution/air_quality_distribution_bloc.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/widgets/aqi_type_dropdown.dart';
import 'package:syncrow_web/pages/analytics/modules/energy_management/widgets/chart_title.dart';
import 'package:syncrow_web/pages/analytics/widgets/charts_loading_widget.dart';
@ -16,7 +18,7 @@ class AqiDistributionChartTitle extends StatelessWidget {
const Expanded(
flex: 3,
child: FittedBox(
fit: BoxFit.scaleDown,
fit: BoxFit.scaleDown,
alignment: AlignmentDirectional.centerStart,
child: ChartTitle(
title: Text('Distribution over Air Quality Index'),
@ -27,7 +29,13 @@ class AqiDistributionChartTitle extends StatelessWidget {
alignment: AlignmentDirectional.centerEnd,
fit: BoxFit.scaleDown,
child: AqiTypeDropdown(
onChanged: (value) {},
onChanged: (value) {
if (value != null) {
context
.read<AirQualityDistributionBloc>()
.add(UpdateAqiTypeEvent(value));
}
},
),
),
],

View File

@ -3,7 +3,6 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/blocs/range_of_aqi/range_of_aqi_bloc.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/widgets/range_of_aqi_chart.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/widgets/range_of_aqi_chart_title.dart';
import 'package:syncrow_web/pages/analytics/params/get_range_of_aqi_param.dart';
import 'package:syncrow_web/pages/analytics/widgets/analytics_error_widget.dart';
import 'package:syncrow_web/utils/style.dart';
@ -27,18 +26,8 @@ class RangeOfAqiChartBox extends StatelessWidget {
AnalyticsErrorWidget(state.errorMessage),
const SizedBox(height: 10),
],
GestureDetector(
onTap: () {
context.read<RangeOfAqiBloc>().add(LoadRangeOfAqiEvent(
GetRangeOfAqiParam(
spaceUuid: '123',
date: DateTime.now().subtract(const Duration(days: 30)),
),
));
},
child: RangeOfAqiChartTitle(
isLoading: state.status == RangeOfAqiStatus.loading,
),
RangeOfAqiChartTitle(
isLoading: state.status == RangeOfAqiStatus.loading,
),
const SizedBox(height: 10),
const Divider(),

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/helpers/fetch_air_quality_data_helper.dart';
import 'package:syncrow_web/pages/analytics/modules/analytics/blocs/analytics_date_picker_bloc/analytics_date_picker_bloc.dart';
import 'package:syncrow_web/pages/analytics/modules/analytics/strategies/analytics_data_loading_strategy.dart';
import 'package:syncrow_web/pages/space_tree/bloc/space_tree_bloc.dart';
import 'package:syncrow_web/pages/space_tree/bloc/space_tree_event.dart';
@ -39,6 +40,7 @@ final class AirQualityDataLoadingStrategy implements AnalyticsDataLoadingStrateg
context,
communityUuid: community.uuid,
spaceUuid: space.uuid ?? '',
date: context.read<AnalyticsDatePickerBloc>().state.monthlyDate,
);
}

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/helpers/fetch_air_quality_data_helper.dart';
import 'package:syncrow_web/pages/analytics/modules/analytics/blocs/analytics_date_picker_bloc/analytics_date_picker_bloc.dart';
import 'package:syncrow_web/pages/analytics/modules/analytics/blocs/analytics_tab/analytics_tab_bloc.dart';
import 'package:syncrow_web/pages/analytics/modules/analytics/enums/analytics_page_tab.dart';
@ -56,33 +57,16 @@ class AnalyticsPageTabsAndChildren extends StatelessWidget {
const Spacer(),
Visibility(
key: ValueKey(selectedTab),
visible: selectedTab == AnalyticsPageTab.energyManagement,
visible: selectedTab == AnalyticsPageTab.energyManagement ||
selectedTab == AnalyticsPageTab.airQuality,
child: Expanded(
flex: 2,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: AlignmentDirectional.centerEnd,
child: AnalyticsDateFilterButton(
onDateSelected: (DateTime value) {
context.read<AnalyticsDatePickerBloc>().add(
UpdateAnalyticsDatePickerEvent(montlyDate: value),
);
final spaceTreeState =
context.read<SpaceTreeBloc>().state;
if (spaceTreeState.selectedSpaces.isNotEmpty) {
FetchEnergyManagementDataHelper
.loadEnergyManagementData(
context,
shouldFetchAnalyticsDevices: false,
selectedDate: value,
communityId:
spaceTreeState.selectedCommunities.firstOrNull ??
'',
spaceId:
spaceTreeState.selectedSpaces.firstOrNull ?? '',
);
}
onDateSelected: (value) {
_onDateChanged(context, value, selectedTab);
},
selectedDate: context
.watch<AnalyticsDatePickerBloc>()
@ -112,4 +96,73 @@ class AnalyticsPageTabsAndChildren extends StatelessWidget {
child: child,
);
}
void _onDateChanged(
BuildContext context,
DateTime date,
AnalyticsPageTab selectedTab,
) {
context.read<AnalyticsDatePickerBloc>().add(
UpdateAnalyticsDatePickerEvent(montlyDate: date),
);
final spaceTreeState = context.read<SpaceTreeBloc>().state;
final communities = spaceTreeState.selectedCommunities;
final spaces = spaceTreeState.selectedSpaces;
if (spaceTreeState.selectedSpaces.isNotEmpty) {
switch (selectedTab) {
case AnalyticsPageTab.energyManagement:
_onEnergyManagementDateChanged(
context,
date: date,
communityUuid: communities.firstOrNull ?? '',
spaceUuid: spaces.firstOrNull ?? '',
);
break;
case AnalyticsPageTab.airQuality:
_onAirQualityDateChanged(
context,
date: date,
communityUuid: communities.firstOrNull ?? '',
spaceUuid: spaces.firstOrNull ?? '',
);
default:
break;
}
}
}
void _onEnergyManagementDateChanged(
BuildContext context, {
required DateTime date,
required String communityUuid,
required String spaceUuid,
}) {
context.read<AnalyticsDatePickerBloc>().add(
UpdateAnalyticsDatePickerEvent(montlyDate: date),
);
FetchEnergyManagementDataHelper.loadEnergyManagementData(
context,
shouldFetchAnalyticsDevices: false,
selectedDate: date,
communityId: communityUuid,
spaceId: spaceUuid,
);
}
void _onAirQualityDateChanged(
BuildContext context, {
required DateTime date,
required String communityUuid,
required String spaceUuid,
}) {
FetchAirQualityDataHelper.loadAirQualityData(
context,
date: date,
communityUuid: communityUuid,
spaceUuid: spaceUuid,
shouldFetchAnalyticsDevices: false,
);
}
}

View File

@ -1,6 +1,7 @@
import 'dart:math';
import 'package:syncrow_web/pages/analytics/models/air_quality_data_model.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/widgets/aqi_type_dropdown.dart';
import 'package:syncrow_web/pages/analytics/params/get_air_quality_distribution_param.dart';
import 'package:syncrow_web/pages/analytics/services/air_quality_distribution/air_quality_distribution_service.dart';
@ -19,30 +20,56 @@ class FakeAirQualityDistributionService implements AirQualityDistributionService
final values = _generateRandomPercentages();
final nullMask = List.generate(6, (_) => _shouldBeNull());
// If all values are null, force at least one to be non-null
if (nullMask.every((isNull) => isNull)) {
nullMask[_random.nextInt(6)] = false;
}
// Redistribute percentages among non-null values
final nonNullValues = _redistributePercentages(values, nullMask);
return AirQualityDataModel(
date: date,
good: nullMask[0] ? null : nonNullValues[0],
moderate: nullMask[1] ? null : nonNullValues[1],
poor: nullMask[2] ? null : nonNullValues[2],
unhealthy: nullMask[3] ? null : nonNullValues[3],
severe: nullMask[4] ? null : nonNullValues[4],
hazardous: nullMask[5] ? null : nonNullValues[5],
data: [
AirQualityPercentageData(
type: AqiType.aqi.code,
percentage: nonNullValues[0],
name: 'good',
),
AirQualityPercentageData(
name: 'moderate',
type: AqiType.co2.code,
percentage: nonNullValues[1],
),
AirQualityPercentageData(
name: 'poor',
percentage: nonNullValues[2],
type: AqiType.hcho.code,
),
AirQualityPercentageData(
name: 'unhealthy',
percentage: nonNullValues[3],
type: AqiType.pm10.code,
),
AirQualityPercentageData(
name: 'severe',
type: AqiType.pm25.code,
percentage: nonNullValues[4],
),
AirQualityPercentageData(
name: 'hazardous',
percentage: nonNullValues[5],
type: AqiType.co2.code,
),
],
);
}),
);
}
List<double> _redistributePercentages(
List<double> originalValues, List<bool> nullMask) {
// Calculate total of non-null values
List<double> originalValues,
List<bool> nullMask,
) {
double nonNullSum = 0;
for (int i = 0; i < originalValues.length; i++) {
if (!nullMask[i]) {
@ -50,7 +77,6 @@ class FakeAirQualityDistributionService implements AirQualityDistributionService
}
}
// Redistribute percentages to maintain 100% total
return List.generate(originalValues.length, (i) {
if (nullMask[i]) return 0;
return (originalValues[i] / nonNullSum * 100).roundToDouble();

View File

@ -0,0 +1,36 @@
import 'package:syncrow_web/pages/analytics/models/air_quality_data_model.dart';
import 'package:syncrow_web/pages/analytics/params/get_air_quality_distribution_param.dart';
import 'package:syncrow_web/pages/analytics/services/air_quality_distribution/air_quality_distribution_service.dart';
import 'package:syncrow_web/services/api/http_service.dart';
class RemoteAirQualityDistributionService implements AirQualityDistributionService {
RemoteAirQualityDistributionService(this._httpService);
final HTTPService _httpService;
@override
Future<List<AirQualityDataModel>> getAirQualityDistribution(
GetAirQualityDistributionParam param,
) async {
try {
final response = await _httpService.get(
path: 'endpoint',
queryParameters: {
'spaceUuid': param.spaceUuid,
'date': param.date.toIso8601String(),
},
expectedResponseModel: (data) {
final json = data as Map<String, dynamic>? ?? {};
final mappedData = json['data'] as List<dynamic>? ?? [];
return mappedData.map((e) {
final jsonData = e as Map<String, dynamic>;
return AirQualityDataModel.fromJson(jsonData);
}).toList();
},
);
return response;
} catch (e) {
throw Exception('Failed to load energy consumption per phase: $e');
}
}
}