diff --git a/lib/pages/analytics/models/analytics_device.dart b/lib/pages/analytics/models/analytics_device.dart index eaac8b2b..3340a41d 100644 --- a/lib/pages/analytics/models/analytics_device.dart +++ b/lib/pages/analytics/models/analytics_device.dart @@ -8,6 +8,8 @@ class AnalyticsDevice { this.isActive, this.productDevice, this.spaceUuid, + this.latitude, + this.longitude, }); final String uuid; @@ -18,6 +20,8 @@ class AnalyticsDevice { final bool? isActive; final ProductDevice? productDevice; final String? spaceUuid; + final double? latitude; + final double? longitude; factory AnalyticsDevice.fromJson(Map json) { return AnalyticsDevice( @@ -35,6 +39,8 @@ class AnalyticsDevice { ? ProductDevice.fromJson(json['productDevice'] as Map) : null, spaceUuid: json['spaceUuid'] as String?, + latitude: json['lat'] != null ? double.parse(json['lat'] as String) : null, + longitude: json['lon'] != null ? double.parse(json['lon'] as String) : null, ); } } diff --git a/lib/pages/analytics/models/device_location_info.dart b/lib/pages/analytics/models/device_location_info.dart new file mode 100644 index 00000000..aef7eebb --- /dev/null +++ b/lib/pages/analytics/models/device_location_info.dart @@ -0,0 +1,55 @@ +import 'package:equatable/equatable.dart'; + +class DeviceLocationInfo extends Equatable { + const DeviceLocationInfo({ + this.airQuality, + this.humidity, + this.city, + this.country, + this.address, + this.temperature, + }); + + final double? airQuality; + final double? humidity; + final String? city; + final String? country; + final String? address; + final double? temperature; + + factory DeviceLocationInfo.fromJson(Map json) { + return DeviceLocationInfo( + airQuality: json['aqi'] as double?, + humidity: json['humidity'] as double?, + temperature: json['temperature'] as double?, + ); + } + + DeviceLocationInfo copyWith({ + double? airQuality, + double? humidity, + String? city, + String? country, + String? address, + double? temperature, + }) { + return DeviceLocationInfo( + airQuality: airQuality ?? this.airQuality, + humidity: humidity ?? this.humidity, + city: city ?? this.city, + country: country ?? this.country, + address: address ?? this.address, + temperature: temperature ?? this.temperature, + ); + } + + @override + List get props => [ + airQuality, + humidity, + city, + country, + address, + temperature, + ]; +} diff --git a/lib/pages/analytics/modules/air_quality/blocs/device_location/device_location_bloc.dart b/lib/pages/analytics/modules/air_quality/blocs/device_location/device_location_bloc.dart new file mode 100644 index 00000000..4f41eb0c --- /dev/null +++ b/lib/pages/analytics/modules/air_quality/blocs/device_location/device_location_bloc.dart @@ -0,0 +1,50 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:syncrow_web/pages/analytics/models/device_location_info.dart'; +import 'package:syncrow_web/pages/analytics/params/get_device_location_data_param.dart'; +import 'package:syncrow_web/pages/analytics/services/device_location/device_location_service.dart'; + +part 'device_location_event.dart'; +part 'device_location_state.dart'; + +class DeviceLocationBloc extends Bloc { + DeviceLocationBloc( + this._deviceLocationService, + ) : super(const DeviceLocationState()) { + on(_onLoadDeviceLocation); + on(_onClearDeviceLocation); + } + + final DeviceLocationService _deviceLocationService; + + Future _onLoadDeviceLocation( + LoadDeviceLocationEvent event, + Emitter emit, + ) async { + emit(const DeviceLocationState(status: DeviceLocationStatus.loading)); + + try { + final locationInfo = await _deviceLocationService.get(event.param); + emit( + DeviceLocationState( + status: DeviceLocationStatus.success, + locationInfo: locationInfo, + ), + ); + } catch (e) { + emit( + DeviceLocationState( + status: DeviceLocationStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } + + void _onClearDeviceLocation( + ClearDeviceLocationEvent event, + Emitter emit, + ) { + emit(const DeviceLocationState()); + } +} diff --git a/lib/pages/analytics/modules/air_quality/blocs/device_location/device_location_event.dart b/lib/pages/analytics/modules/air_quality/blocs/device_location/device_location_event.dart new file mode 100644 index 00000000..37137e4a --- /dev/null +++ b/lib/pages/analytics/modules/air_quality/blocs/device_location/device_location_event.dart @@ -0,0 +1,21 @@ +part of 'device_location_bloc.dart'; + +sealed class DeviceLocationEvent extends Equatable { + const DeviceLocationEvent(); + + @override + List get props => []; +} + +final class LoadDeviceLocationEvent extends DeviceLocationEvent { + const LoadDeviceLocationEvent(this.param); + + final GetDeviceLocationDataParam param; + + @override + List get props => [param]; +} + +final class ClearDeviceLocationEvent extends DeviceLocationEvent { + const ClearDeviceLocationEvent(); +} diff --git a/lib/pages/analytics/modules/air_quality/blocs/device_location/device_location_state.dart b/lib/pages/analytics/modules/air_quality/blocs/device_location/device_location_state.dart new file mode 100644 index 00000000..8f66ad28 --- /dev/null +++ b/lib/pages/analytics/modules/air_quality/blocs/device_location/device_location_state.dart @@ -0,0 +1,18 @@ +part of 'device_location_bloc.dart'; + +enum DeviceLocationStatus { initial, loading, success, failure } + +final class DeviceLocationState extends Equatable { + const DeviceLocationState({ + this.status = DeviceLocationStatus.initial, + this.locationInfo, + this.errorMessage, + }); + + final DeviceLocationStatus status; + final DeviceLocationInfo? locationInfo; + final String? errorMessage; + + @override + List get props => [status, locationInfo, errorMessage]; +} diff --git a/lib/pages/analytics/modules/air_quality/helpers/fetch_air_quality_data_helper.dart b/lib/pages/analytics/modules/air_quality/helpers/fetch_air_quality_data_helper.dart index e212dedf..cb37484c 100644 --- a/lib/pages/analytics/modules/air_quality/helpers/fetch_air_quality_data_helper.dart +++ b/lib/pages/analytics/modules/air_quality/helpers/fetch_air_quality_data_helper.dart @@ -1,12 +1,14 @@ 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/blocs/device_location/device_location_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/analytics/blocs/analytics_date_picker_bloc/analytics_date_picker_bloc.dart'; import 'package:syncrow_web/pages/analytics/modules/analytics/blocs/analytics_devices/analytics_devices_bloc.dart'; import 'package:syncrow_web/pages/analytics/modules/energy_management/blocs/realtime_device_changes/realtime_device_changes_bloc.dart'; import 'package:syncrow_web/pages/analytics/params/get_air_quality_distribution_param.dart'; import 'package:syncrow_web/pages/analytics/params/get_analytics_devices_param.dart'; +import 'package:syncrow_web/pages/analytics/params/get_device_location_data_param.dart'; import 'package:syncrow_web/pages/analytics/params/get_range_of_aqi_param.dart'; abstract final class FetchAirQualityDataHelper { @@ -48,6 +50,8 @@ abstract final class FetchAirQualityDataHelper { const ClearAirQualityDistribution(), ); context.read().add(const ClearRangeOfAqiEvent()); + + context.read().add(const ClearDeviceLocationEvent()); } static void loadAnalyticsDevices( @@ -61,12 +65,21 @@ abstract final class FetchAirQualityDataHelper { communityUuid: communityUuid, spaceUuid: spaceUuid, deviceTypes: ['AQI'], - requestType: AnalyticsDeviceRequestType.energyManagement, + requestType: AnalyticsDeviceRequestType.occupancy, ), onSuccess: (device) { context.read() ..add(const RealtimeDeviceChangesClosed()) ..add(RealtimeDeviceChangesStarted(device.uuid)); + + context.read().add( + LoadDeviceLocationEvent( + GetDeviceLocationDataParam( + latitude: device.latitude ?? 0, + longitude: device.longitude ?? 0, + ), + ), + ); }, ), ); diff --git a/lib/pages/analytics/modules/air_quality/widgets/aqi_device_info.dart b/lib/pages/analytics/modules/air_quality/widgets/aqi_device_info.dart index f3773c29..ebe88614 100644 --- a/lib/pages/analytics/modules/air_quality/widgets/aqi_device_info.dart +++ b/lib/pages/analytics/modules/air_quality/widgets/aqi_device_info.dart @@ -72,55 +72,54 @@ class AqiDeviceInfo extends StatelessWidget { return Container( decoration: secondarySection.copyWith(boxShadow: const []), padding: const EdgeInsetsDirectional.all(20), - child: Expanded( - child: Column( - spacing: 6, - children: [ - const AirQualityEndSideLiveIndicator(), - AirQualityEndSideGaugeAndInfo( - aqiLevel: status - .firstWhere( - (e) => e.code == 'air_quality_index', - orElse: () => Status(code: 'air_quality_index', value: ''), - ) - .value - .toString(), - temperature: int.parse(tempValue), - humidity: int.parse(humidityValue), - ), - const SizedBox(height: 20), - AqiSubValueWidget( - range: (0, 999), - label: AqiType.pm25.value, - value: pm25Value, - unit: AqiType.pm25.unit, - ), - AqiSubValueWidget( - range: (0, 999), - label: AqiType.pm10.value, - value: pm10Value, - unit: AqiType.pm10.unit, - ), - AqiSubValueWidget( - range: (0, 5), - label: AqiType.hcho.value, - value: ch2oValue, - unit: AqiType.hcho.unit, - ), - AqiSubValueWidget( - range: (0, 999), - label: AqiType.tvoc.value, - value: tvocValue, - unit: AqiType.tvoc.unit, - ), - AqiSubValueWidget( - range: (0, 5000), - label: AqiType.co2.value, - value: co2Value, - unit: AqiType.co2.unit, - ), - ], - ), + child: Column( + spacing: 6, + mainAxisSize: MainAxisSize.max, + children: [ + const AirQualityEndSideLiveIndicator(), + AirQualityEndSideGaugeAndInfo( + aqiLevel: status + .firstWhere( + (e) => e.code == 'air_quality_index', + orElse: () => Status(code: 'air_quality_index', value: ''), + ) + .value + .toString(), + temperature: int.parse(tempValue), + humidity: int.parse(humidityValue), + ), + const SizedBox(height: 20), + AqiSubValueWidget( + range: (0, 999), + label: AqiType.pm25.value, + value: pm25Value, + unit: AqiType.pm25.unit, + ), + AqiSubValueWidget( + range: (0, 999), + label: AqiType.pm10.value, + value: pm10Value, + unit: AqiType.pm10.unit, + ), + AqiSubValueWidget( + range: (0, 5), + label: AqiType.hcho.value, + value: ch2oValue, + unit: AqiType.hcho.unit, + ), + AqiSubValueWidget( + range: (0, 999), + label: AqiType.tvoc.value, + value: tvocValue, + unit: AqiType.tvoc.unit, + ), + AqiSubValueWidget( + range: (0, 5000), + label: AqiType.co2.value, + value: co2Value, + unit: AqiType.co2.unit, + ), + ], ), ); }, diff --git a/lib/pages/analytics/modules/air_quality/widgets/aqi_location.dart b/lib/pages/analytics/modules/air_quality/widgets/aqi_location.dart index 3f1d1f09..2503874f 100644 --- a/lib/pages/analytics/modules/air_quality/widgets/aqi_location.dart +++ b/lib/pages/analytics/modules/air_quality/widgets/aqi_location.dart @@ -6,7 +6,34 @@ import 'package:syncrow_web/utils/extension/build_context_x.dart'; import 'package:syncrow_web/utils/style.dart'; class AqiLocation extends StatelessWidget { - const AqiLocation({super.key}); + const AqiLocation({ + required this.city, + required this.country, + required this.address, + super.key, + }); + + final String? city; + final String? country; + final String? address; + + String _getFormattedLocation() { + if (city == null && country == null && address == null) { + return 'N/A'; + } + + final parts = []; + + if (city != null) parts.add(city!); + if (address != null) parts.add(address!); + final locationPart = parts.join(', '); + + if (country != null) { + return locationPart.isEmpty ? country! : '$locationPart - $country'; + } + + return locationPart; + } @override Widget build(BuildContext context) { @@ -24,7 +51,7 @@ class AqiLocation extends StatelessWidget { _buildLocationPin(), Expanded( child: Text( - 'Business Bay, Dubai - UAE', + _getFormattedLocation(), style: context.textTheme.bodySmall?.copyWith( color: ColorsManager.textPrimaryColor, fontWeight: FontWeight.w400, diff --git a/lib/pages/analytics/modules/air_quality/widgets/aqi_location_info.dart b/lib/pages/analytics/modules/air_quality/widgets/aqi_location_info.dart index f8e087b8..983f76b2 100644 --- a/lib/pages/analytics/modules/air_quality/widgets/aqi_location_info.dart +++ b/lib/pages/analytics/modules/air_quality/widgets/aqi_location_info.dart @@ -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/device_location/device_location_bloc.dart'; import 'package:syncrow_web/pages/analytics/modules/air_quality/widgets/aqi_location.dart'; import 'package:syncrow_web/pages/analytics/modules/air_quality/widgets/aqi_location_info_cell.dart'; import 'package:syncrow_web/utils/constants/assets.dart'; @@ -9,37 +11,46 @@ class AqiLocationInfo extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( - decoration: secondarySection.copyWith(boxShadow: const []), - padding: const EdgeInsetsDirectional.all(20), - child: const Column( - spacing: 8, - children: [ - AqiLocation(), - Expanded( - child: Row( - spacing: 8, - children: [ - AqiLocationInfoCell( - label: 'Temperature', - value: ' 25°', - svgPath: Assets.aqiTemperature, + return BlocBuilder( + builder: (context, state) { + final info = state.locationInfo; + return Container( + decoration: secondarySection.copyWith(boxShadow: const []), + padding: const EdgeInsetsDirectional.all(20), + child: Column( + spacing: 8, + children: [ + AqiLocation( + city: info?.city, + country: info?.country, + address: info?.address, + ), + Expanded( + child: Row( + spacing: 8, + children: [ + AqiLocationInfoCell( + label: 'Temperature', + value: ' ${info?.temperature?.roundToDouble() ?? '--'}°', + svgPath: Assets.aqiTemperature, + ), + AqiLocationInfoCell( + label: 'Humidity', + value: '${info?.humidity?.roundToDouble() ?? '--'}%', + svgPath: Assets.aqiHumidity, + ), + AqiLocationInfoCell( + label: 'Air Quality', + value: ' ${info?.airQuality?.roundToDouble() ?? '--'}', + svgPath: Assets.aqiAirQuality, + ), + ], ), - AqiLocationInfoCell( - label: 'Humidity', - value: '25%', - svgPath: Assets.aqiHumidity, - ), - AqiLocationInfoCell( - label: 'Air Quality', - value: ' 120', - svgPath: Assets.aqiAirQuality, - ), - ], - ), + ), + ], ), - ], - ), + ); + }, ); } } diff --git a/lib/pages/analytics/modules/analytics/views/analytics_page.dart b/lib/pages/analytics/modules/analytics/views/analytics_page.dart index 575aa862..1ecd9aa3 100644 --- a/lib/pages/analytics/modules/analytics/views/analytics_page.dart +++ b/lib/pages/analytics/modules/analytics/views/analytics_page.dart @@ -1,6 +1,8 @@ +import 'package:dio/dio.dart'; 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/blocs/device_location/device_location_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/analytics/blocs/analytics_date_picker_bloc/analytics_date_picker_bloc.dart'; import 'package:syncrow_web/pages/analytics/modules/analytics/blocs/analytics_devices/analytics_devices_bloc.dart'; @@ -18,6 +20,8 @@ import 'package:syncrow_web/pages/analytics/services/air_quality_distribution/fa import 'package:syncrow_web/pages/analytics/services/analytics_devices/analytics_devices_service_delagate.dart'; import 'package:syncrow_web/pages/analytics/services/analytics_devices/remote_energy_management_analytics_devices_service.dart'; import 'package:syncrow_web/pages/analytics/services/analytics_devices/remote_occupancy_analytics_devices_service.dart'; +import 'package:syncrow_web/pages/analytics/services/device_location/device_location_details_service_decorator.dart'; +import 'package:syncrow_web/pages/analytics/services/device_location/remote_device_location_service.dart'; import 'package:syncrow_web/pages/analytics/services/energy_consumption_by_phases/remote_energy_consumption_by_phases_service.dart'; import 'package:syncrow_web/pages/analytics/services/energy_consumption_per_device/remote_energy_consumption_per_device_service.dart'; import 'package:syncrow_web/pages/analytics/services/occupacy/remote_occupancy_service.dart'; @@ -108,6 +112,18 @@ class _AnalyticsPageState extends State { FakeAirQualityDistributionService(), ), ), + BlocProvider( + create: (context) => DeviceLocationBloc( + DeviceLocationDetailsServiceDecorator( + RemoteDeviceLocationService(_httpService), + Dio( + BaseOptions( + baseUrl: 'https://nominatim.openstreetmap.org/', + ), + ), + ), + ), + ), ], child: const AnalyticsPageForm(), ); diff --git a/lib/pages/analytics/params/get_device_location_data_param.dart b/lib/pages/analytics/params/get_device_location_data_param.dart new file mode 100644 index 00000000..29427d10 --- /dev/null +++ b/lib/pages/analytics/params/get_device_location_data_param.dart @@ -0,0 +1,11 @@ +class GetDeviceLocationDataParam { + const GetDeviceLocationDataParam({ + required this.latitude, + required this.longitude, + }); + + final double latitude; + final double longitude; + + Map toJson() => {'lat': latitude, 'lon': longitude}; +} diff --git a/lib/pages/analytics/services/device_location/device_location_details_service_decorator.dart b/lib/pages/analytics/services/device_location/device_location_details_service_decorator.dart new file mode 100644 index 00000000..0239bcb7 --- /dev/null +++ b/lib/pages/analytics/services/device_location/device_location_details_service_decorator.dart @@ -0,0 +1,40 @@ +import 'package:dio/dio.dart'; +import 'package:syncrow_web/pages/analytics/models/device_location_info.dart'; +import 'package:syncrow_web/pages/analytics/params/get_device_location_data_param.dart'; +import 'package:syncrow_web/pages/analytics/services/device_location/device_location_service.dart'; + +class DeviceLocationDetailsServiceDecorator implements DeviceLocationService { + const DeviceLocationDetailsServiceDecorator(this._decoratee, this._dio); + + final DeviceLocationService _decoratee; + final Dio _dio; + + @override + Future get(GetDeviceLocationDataParam param) async { + try { + final deviceLocationInfo = await _decoratee.get(param); + final response = await _dio.get>( + 'reverse', + queryParameters: { + 'format': 'json', + 'lat': param.latitude, + 'lon': param.longitude, + }, + ); + + final data = response.data; + if (data != null) { + final addressData = data['address'] as Map; + return deviceLocationInfo.copyWith( + city: addressData['city'], + country: addressData['country_code'].toString().toUpperCase(), + address: addressData['state'], + ); + } + + return deviceLocationInfo; + } catch (e) { + throw Exception('Failed to load device location info: ${e.toString()}'); + } + } +} diff --git a/lib/pages/analytics/services/device_location/device_location_service.dart b/lib/pages/analytics/services/device_location/device_location_service.dart new file mode 100644 index 00000000..d28b4a7b --- /dev/null +++ b/lib/pages/analytics/services/device_location/device_location_service.dart @@ -0,0 +1,6 @@ +import 'package:syncrow_web/pages/analytics/models/device_location_info.dart'; +import 'package:syncrow_web/pages/analytics/params/get_device_location_data_param.dart'; + +abstract interface class DeviceLocationService { + Future get(GetDeviceLocationDataParam param); +} diff --git a/lib/pages/analytics/services/device_location/remote_device_location_service.dart b/lib/pages/analytics/services/device_location/remote_device_location_service.dart new file mode 100644 index 00000000..b8820180 --- /dev/null +++ b/lib/pages/analytics/services/device_location/remote_device_location_service.dart @@ -0,0 +1,37 @@ +import 'package:dio/dio.dart'; +import 'package:syncrow_web/pages/analytics/models/device_location_info.dart'; +import 'package:syncrow_web/pages/analytics/params/get_device_location_data_param.dart'; +import 'package:syncrow_web/pages/analytics/services/device_location/device_location_service.dart'; +import 'package:syncrow_web/services/api/http_service.dart'; + +class RemoteDeviceLocationService implements DeviceLocationService { + const RemoteDeviceLocationService(this._httpService); + + final HTTPService _httpService; + + static const _defaultErrorMessage = 'Failed to load device location'; + + @override + Future get(GetDeviceLocationDataParam param) async { + try { + final response = await _httpService.get( + path: '/weather', + queryParameters: param.toJson(), + expectedResponseModel: (data) { + final response = data as Map; + final location = response['data'] as Map; + + return DeviceLocationInfo.fromJson(location); + }, + ); + return response; + } on DioException catch (e) { + final message = e.response?.data as Map?; + final error = message?['error'] as Map?; + final errorMessage = error?['error'] as String? ?? ''; + throw Exception(errorMessage); + } catch (e) { + throw Exception('$_defaultErrorMessage: $e'); + } + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 2d09f0bb..edd003cf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -61,6 +61,7 @@ dependencies: firebase_crashlytics: ^4.3.2 firebase_database: ^11.3.2 bloc: ^9.0.0 + geocoding: ^4.0.0 gauge_indicator: ^0.4.3