Merge branch 'dev' into space_managment_refactoring

This commit is contained in:
raf-dev1
2025-06-11 11:12:52 +03:00
committed by GitHub
214 changed files with 8413 additions and 2903 deletions
@@ -0,0 +1,57 @@
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
import 'package:syncrow_web/utils/color_manager.dart';
class AirQualityDataModel extends Equatable {
const AirQualityDataModel({
required this.date,
required this.data,
});
final DateTime date;
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),
'moderate': ColorsManager.moderateYellow.withValues(alpha: 0.7),
'poor': ColorsManager.poorOrange.withValues(alpha: 0.7),
'unhealthy': ColorsManager.unhealthyRed.withValues(alpha: 0.7),
'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];
}
@@ -8,6 +8,8 @@ class AnalyticsDevice {
this.isActive,
this.productDevice,
this.spaceUuid,
this.latitude,
this.longitude,
});
final String uuid;
@@ -18,22 +20,27 @@ class AnalyticsDevice {
final bool? isActive;
final ProductDevice? productDevice;
final String? spaceUuid;
final double? latitude;
final double? longitude;
factory AnalyticsDevice.fromJson(Map<String, dynamic> json) {
return AnalyticsDevice(
uuid: json['uuid'] as String,
name: json['name'] as String,
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt'] as String) : null,
updatedAt: json['updatedAt'] != null ? DateTime.parse(json['updatedAt'] as String) : null,
createdAt: json['createdAt'] != null
? DateTime.parse(json['createdAt'] as String)
: null,
updatedAt: json['updatedAt'] != null
? DateTime.parse(json['updatedAt'] as String)
: null,
deviceTuyaUuid: json['deviceTuyaUuid'] as String?,
isActive: json['isActive'] as bool?,
productDevice: json['productDevice'] != null
? ProductDevice.fromJson(json['productDevice'] as Map<String, dynamic>)
: null,
spaceUuid: (json['spaces'] as List<dynamic>?)
?.map((e) => e['uuid'])
.firstOrNull
?.toString(),
productDevice: json['productDevice'] != null
? ProductDevice.fromJson(json['productDevice'] as Map<String, dynamic>)
: 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,
);
}
}
@@ -60,8 +67,12 @@ class ProductDevice {
factory ProductDevice.fromJson(Map<String, dynamic> json) {
return ProductDevice(
uuid: json['uuid'] as String?,
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt'] as String) : null,
updatedAt: json['updatedAt'] != null ? DateTime.parse(json['updatedAt'] as String) : null,
createdAt: json['createdAt'] != null
? DateTime.parse(json['createdAt'] as String)
: null,
updatedAt: json['updatedAt'] != null
? DateTime.parse(json['updatedAt'] as String)
: null,
catName: json['catName'] as String?,
prodId: json['prodId'] as String?,
name: json['name'] as String?,
@@ -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<String, dynamic> 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<Object?> get props => [
airQuality,
humidity,
city,
country,
address,
temperature,
];
}
@@ -0,0 +1,49 @@
import 'package:equatable/equatable.dart';
class RangeOfAqi extends Equatable {
final DateTime date;
final List<RangeOfAqiValue> data;
const RangeOfAqi({
required this.data,
required this.date,
});
factory RangeOfAqi.fromJson(Map<String, dynamic> json) {
return RangeOfAqi(
date: DateTime.parse(json['date'] as String),
data: (json['data'] as List<dynamic>)
.map((e) => RangeOfAqiValue.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
@override
List<Object?> get props => [data, date];
}
class RangeOfAqiValue extends Equatable {
final String type;
final double min;
final double average;
final double max;
const RangeOfAqiValue({
required this.type,
required this.min,
required this.average,
required this.max,
});
factory RangeOfAqiValue.fromJson(Map<String, dynamic> json) {
return RangeOfAqiValue(
type: json['type'] as String,
min: (json['min'] as num).toDouble(),
average: (json['average'] as num).toDouble(),
max: (json['max'] as num).toDouble(),
);
}
@override
List<Object?> get props => [type, min, average, max];
}
@@ -0,0 +1,81 @@
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';
part 'air_quality_distribution_event.dart';
part 'air_quality_distribution_state.dart';
class AirQualityDistributionBloc
extends Bloc<AirQualityDistributionEvent, AirQualityDistributionState> {
final AirQualityDistributionService _aqiDistributionService;
AirQualityDistributionBloc(
this._aqiDistributionService,
) : super(const AirQualityDistributionState()) {
on<LoadAirQualityDistribution>(_onLoadAirQualityDistribution);
on<ClearAirQualityDistribution>(_onClearAirQualityDistribution);
on<UpdateAqiTypeEvent>(_onUpdateAqiTypeEvent);
}
Future<void> _onLoadAirQualityDistribution(
LoadAirQualityDistribution event,
Emitter<AirQualityDistributionState> emit,
) async {
try {
emit(state.copyWith(status: AirQualityDistributionStatus.loading));
final result = await _aqiDistributionService.getAirQualityDistribution(
event.param,
);
emit(
state.copyWith(
status: AirQualityDistributionStatus.success,
chartData: result,
filteredChartData: _arrangeChartDataByType(result, state.selectedAqiType),
),
);
} catch (e) {
emit(
AirQualityDistributionState(
status: AirQualityDistributionStatus.failure,
errorMessage: e.toString(),
selectedAqiType: state.selectedAqiType,
),
);
}
}
Future<void> _onClearAirQualityDistribution(
ClearAirQualityDistribution event,
Emitter<AirQualityDistributionState> emit,
) 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();
}
}
@@ -0,0 +1,30 @@
part of 'air_quality_distribution_bloc.dart';
sealed class AirQualityDistributionEvent extends Equatable {
const AirQualityDistributionEvent();
@override
List<Object> get props => [];
}
final class LoadAirQualityDistribution extends AirQualityDistributionEvent {
final GetAirQualityDistributionParam param;
const LoadAirQualityDistribution(this.param);
@override
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();
}
@@ -0,0 +1,43 @@
part of 'air_quality_distribution_bloc.dart';
enum AirQualityDistributionStatus {
initial,
loading,
success,
failure,
}
class AirQualityDistributionState extends Equatable {
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, selectedAqiType];
}
@@ -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<DeviceLocationEvent, DeviceLocationState> {
DeviceLocationBloc(
this._deviceLocationService,
) : super(const DeviceLocationState()) {
on<LoadDeviceLocationEvent>(_onLoadDeviceLocation);
on<ClearDeviceLocationEvent>(_onClearDeviceLocation);
}
final DeviceLocationService _deviceLocationService;
Future<void> _onLoadDeviceLocation(
LoadDeviceLocationEvent event,
Emitter<DeviceLocationState> 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<DeviceLocationState> emit,
) {
emit(const DeviceLocationState());
}
}
@@ -0,0 +1,21 @@
part of 'device_location_bloc.dart';
sealed class DeviceLocationEvent extends Equatable {
const DeviceLocationEvent();
@override
List<Object?> get props => [];
}
final class LoadDeviceLocationEvent extends DeviceLocationEvent {
const LoadDeviceLocationEvent(this.param);
final GetDeviceLocationDataParam param;
@override
List<Object?> get props => [param];
}
final class ClearDeviceLocationEvent extends DeviceLocationEvent {
const ClearDeviceLocationEvent();
}
@@ -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<Object?> get props => [status, locationInfo, errorMessage];
}
@@ -0,0 +1,80 @@
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:syncrow_web/pages/analytics/models/range_of_aqi.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/widgets/aqi_type_dropdown.dart';
import 'package:syncrow_web/pages/analytics/params/get_range_of_aqi_param.dart';
import 'package:syncrow_web/pages/analytics/services/range_of_aqi/range_of_aqi_service.dart';
part 'range_of_aqi_event.dart';
part 'range_of_aqi_state.dart';
class RangeOfAqiBloc extends Bloc<RangeOfAqiEvent, RangeOfAqiState> {
RangeOfAqiBloc(this._rangeOfAqiService) : super(const RangeOfAqiState()) {
on<LoadRangeOfAqiEvent>(_onLoadRangeOfAqiEvent);
on<ClearRangeOfAqiEvent>(_onClearRangeOfAqiEvent);
on<UpdateAqiTypeEvent>(_onUpdateAqiTypeEvent);
}
final RangeOfAqiService _rangeOfAqiService;
Future<void> _onLoadRangeOfAqiEvent(
LoadRangeOfAqiEvent event,
Emitter<RangeOfAqiState> emit,
) async {
emit(
state.copyWith(status: RangeOfAqiStatus.loading),
);
try {
final rangeOfAqi = await _rangeOfAqiService.load(event.param);
emit(
state.copyWith(
status: RangeOfAqiStatus.loaded,
rangeOfAqi: rangeOfAqi,
filteredRangeOfAqi: _arrangeChartDataByType(
rangeOfAqi,
state.selectedAqiType,
),
),
);
} catch (e) {
emit(
state.copyWith(
status: RangeOfAqiStatus.failure,
errorMessage: '$e',
),
);
}
}
void _onUpdateAqiTypeEvent(
UpdateAqiTypeEvent event,
Emitter<RangeOfAqiState> emit,
) {
emit(
state.copyWith(
selectedAqiType: event.aqiType,
filteredRangeOfAqi: _arrangeChartDataByType(state.rangeOfAqi, event.aqiType),
),
);
}
List<RangeOfAqi> _arrangeChartDataByType(
List<RangeOfAqi> rangeOfAqi,
AqiType aqiType,
) {
final filteredRangeOfAqi = rangeOfAqi.map(
(data) => RangeOfAqi(
date: data.date,
data: data.data.where((value) => value.type == aqiType.code).toList(),
),
);
return filteredRangeOfAqi.toList();
}
void _onClearRangeOfAqiEvent(
ClearRangeOfAqiEvent event,
Emitter<RangeOfAqiState> emit,
) {
emit(const RangeOfAqiState());
}
}
@@ -0,0 +1,30 @@
part of 'range_of_aqi_bloc.dart';
sealed class RangeOfAqiEvent extends Equatable {
const RangeOfAqiEvent();
@override
List<Object> get props => [];
}
class LoadRangeOfAqiEvent extends RangeOfAqiEvent {
const LoadRangeOfAqiEvent(this.param);
final GetRangeOfAqiParam param;
@override
List<Object> get props => [param];
}
class UpdateAqiTypeEvent extends RangeOfAqiEvent {
const UpdateAqiTypeEvent(this.aqiType);
final AqiType aqiType;
@override
List<Object> get props => [aqiType];
}
class ClearRangeOfAqiEvent extends RangeOfAqiEvent {
const ClearRangeOfAqiEvent();
}
@@ -0,0 +1,39 @@
part of 'range_of_aqi_bloc.dart';
enum RangeOfAqiStatus { initial, loading, loaded, failure }
final class RangeOfAqiState extends Equatable {
const RangeOfAqiState({
this.rangeOfAqi = const [],
this.filteredRangeOfAqi = const [],
this.status = RangeOfAqiStatus.initial,
this.errorMessage,
this.selectedAqiType = AqiType.aqi,
});
final RangeOfAqiStatus status;
final List<RangeOfAqi> rangeOfAqi;
final List<RangeOfAqi> filteredRangeOfAqi;
final String? errorMessage;
final AqiType selectedAqiType;
RangeOfAqiState copyWith({
RangeOfAqiStatus? status,
List<RangeOfAqi>? rangeOfAqi,
List<RangeOfAqi>? filteredRangeOfAqi,
String? errorMessage,
AqiType? selectedAqiType,
}) {
return RangeOfAqiState(
status: status ?? this.status,
rangeOfAqi: rangeOfAqi ?? this.rangeOfAqi,
filteredRangeOfAqi: filteredRangeOfAqi ?? this.filteredRangeOfAqi,
errorMessage: errorMessage ?? this.errorMessage,
selectedAqiType: selectedAqiType ?? this.selectedAqiType,
);
}
@override
List<Object?> get props =>
[status, rangeOfAqi, filteredRangeOfAqi, errorMessage, selectedAqiType];
}
@@ -0,0 +1,114 @@
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 {
const 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(
context,
communityUuid: communityUuid,
spaceUuid: spaceUuid,
);
loadRangeOfAqi(
context,
spaceUuid: spaceUuid,
date: date,
);
loadAirQualityDistribution(
context,
spaceUuid: spaceUuid,
date: date,
);
}
static void clearAllData(BuildContext context) {
context.read<AnalyticsDevicesBloc>().add(
const ClearAnalyticsDeviceEvent(),
);
context.read<RealtimeDeviceChangesBloc>().add(
const RealtimeDeviceChangesClosed(),
);
context.read<AirQualityDistributionBloc>().add(
const ClearAirQualityDistribution(),
);
context.read<RangeOfAqiBloc>().add(const ClearRangeOfAqiEvent());
context.read<DeviceLocationBloc>().add(const ClearDeviceLocationEvent());
}
static void loadAnalyticsDevices(
BuildContext context, {
required String communityUuid,
required String spaceUuid,
}) {
context.read<AnalyticsDevicesBloc>().add(
LoadAnalyticsDevicesEvent(
param: GetAnalyticsDevicesParam(
communityUuid: communityUuid,
spaceUuid: spaceUuid,
deviceTypes: ['AQI'],
requestType: AnalyticsDeviceRequestType.occupancy,
),
onSuccess: (device) {
context.read<RealtimeDeviceChangesBloc>()
..add(const RealtimeDeviceChangesClosed())
..add(RealtimeDeviceChangesStarted(device.uuid));
context.read<DeviceLocationBloc>().add(
LoadDeviceLocationEvent(
GetDeviceLocationDataParam(
latitude: device.latitude ?? 0,
longitude: device.longitude ?? 0,
),
),
);
},
),
);
}
static void loadRangeOfAqi(
BuildContext context, {
required String spaceUuid,
required DateTime date,
}) {
context.read<RangeOfAqiBloc>().add(
LoadRangeOfAqiEvent(
GetRangeOfAqiParam(
date: date,
spaceUuid: spaceUuid,
),
),
);
}
static void loadAirQualityDistribution(
BuildContext context, {
required String spaceUuid,
required DateTime date,
}) {
context.read<AirQualityDistributionBloc>().add(
LoadAirQualityDistribution(
GetAirQualityDistributionParam(spaceUuid: spaceUuid, date: date),
),
);
}
}
@@ -0,0 +1,115 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:syncrow_web/pages/analytics/models/range_of_aqi.dart';
import 'package:syncrow_web/pages/analytics/modules/energy_management/helpers/energy_management_charts_helper.dart';
import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart';
abstract final class RangeOfAqiChartsHelper {
const RangeOfAqiChartsHelper._();
static const gradientData = <(Color color, String label)>[
(ColorsManager.goodGreen, 'Good'),
(ColorsManager.moderateYellow, 'Moderate'),
(ColorsManager.poorOrange, 'Poor'),
(ColorsManager.unhealthyRed, 'Unhealthy'),
(ColorsManager.severePink, 'Severe'),
(ColorsManager.hazardousPurple, 'Hazardous'),
];
static FlTitlesData titlesData(BuildContext context, List<RangeOfAqi> data) {
final titlesData = EnergyManagementChartsHelper.titlesData(context);
return titlesData.copyWith(
bottomTitles: titlesData.bottomTitles.copyWith(
sideTitles: titlesData.bottomTitles.sideTitles.copyWith(
getTitlesWidget: (value, meta) => Padding(
padding: const EdgeInsetsDirectional.only(top: 20.0),
child: Text(
data.isNotEmpty ? data[value.toInt()].date.day.toString() : '',
style: context.textTheme.bodySmall?.copyWith(
fontSize: 12,
color: ColorsManager.lightGreyColor,
),
),
),
),
),
leftTitles: titlesData.leftTitles.copyWith(
sideTitles: titlesData.leftTitles.sideTitles.copyWith(
reservedSize: 70,
interval: 50,
maxIncluded: false,
getTitlesWidget: (value, meta) {
final text = value >= 300 ? '301+' : value.toInt().toString();
return Padding(
padding: const EdgeInsetsDirectional.only(end: 12),
child: FittedBox(
alignment: AlignmentDirectional.centerStart,
fit: BoxFit.scaleDown,
child: Text(
text,
style: context.textTheme.bodySmall?.copyWith(
fontSize: 12,
color: ColorsManager.lightGreyColor,
),
),
),
);
},
),
),
);
}
static List<LineTooltipItem?> getTooltipItems(
List<LineBarSpot> touchedSpots,
List<RangeOfAqi> chartData,
) {
return touchedSpots.asMap().entries.map((entry) {
final index = entry.key;
final spot = entry.value;
final label = switch (spot.barIndex) {
0 => 'Max',
1 => 'Avg',
2 => 'Min',
_ => '',
};
final date = DateFormat('dd/MM').format(chartData[spot.x.toInt()].date);
return LineTooltipItem(
index == 0 ? '$date\n' : '',
const TextStyle(
color: ColorsManager.textPrimaryColor,
fontWeight: FontWeight.w600,
fontSize: 12,
),
children: [
TextSpan(text: '$label: ${spot.y.toStringAsFixed(0)}'),
],
);
}).toList();
}
static LineTouchData lineTouchData(
List<RangeOfAqi> chartData,
) {
return LineTouchData(
touchTooltipData: LineTouchTooltipData(
getTooltipColor: (touchTooltipItem) => ColorsManager.whiteColors,
tooltipBorder: const BorderSide(
color: ColorsManager.semiTransparentBlack,
),
tooltipRoundedRadius: 16,
showOnTopOfTheChartBoxArea: false,
tooltipPadding: const EdgeInsets.all(8),
getTooltipItems: (touchedSpots) => RangeOfAqiChartsHelper.getTooltipItems(
touchedSpots,
chartData,
),
),
);
}
}
@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/widgets/air_quality_end_side_widget.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/widgets/aqi_distribution_chart_box.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/widgets/range_of_aqi_chart_box.dart';
class AirQualityView extends StatelessWidget {
const AirQualityView({super.key});
static const _padding = EdgeInsetsDirectional.all(32);
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final isMediumOrLess = constraints.maxWidth <= 900;
final height = MediaQuery.sizeOf(context).height;
if (isMediumOrLess) {
return SingleChildScrollView(
padding: _padding,
child: Column(
spacing: 32,
children: [
SizedBox(
height: height * 1.2,
child: const AirQualityEndSideWidget(),
),
SizedBox(
height: height * 0.5,
child: const RangeOfAqiChartBox(),
),
SizedBox(
height: height * 0.5,
child: const AqiDistributionChartBox(),
),
],
),
);
}
return SingleChildScrollView(
child: Container(
padding: _padding,
height: height * 1.1,
child: const Column(
children: [
Expanded(
child: Row(
spacing: 32,
children: [
Expanded(
flex: 10,
child: Column(
spacing: 20,
children: [
Expanded(child: RangeOfAqiChartBox()),
Expanded(child: AqiDistributionChartBox()),
],
),
),
Expanded(flex: 6, child: AirQualityEndSideWidget()),
],
),
),
],
),
),
);
},
);
}
}
@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/widgets/aqi_gauge.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/widgets/aqi_humidity_and_temperature.dart';
class AirQualityEndSideGaugeAndInfo extends StatelessWidget {
const AirQualityEndSideGaugeAndInfo({
super.key,
required this.temperature,
required this.humidity,
required this.aqiLevel,
});
final int temperature;
final int humidity;
final String aqiLevel;
@override
Widget build(BuildContext context) {
return Expanded(
flex: 2,
child: Row(
children: [
Expanded(
flex: 2,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [Expanded(child: AqiGauge(aqi: aqi))],
),
),
const Spacer(),
Expanded(
child: Padding(
padding: const EdgeInsetsDirectional.only(end: 20),
child: AqiHumidityAndTemperature(
temperature: temperature,
humidity: humidity,
),
),
),
],
),
);
}
double get aqi => switch (aqiLevel) {
'level_1' => 25.0,
'level_2' => 75.0,
'level_3' => 125.0,
_ => 0.0,
};
}
@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart';
class AirQualityEndSideLiveIndicator extends StatelessWidget {
const AirQualityEndSideLiveIndicator({super.key});
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Entrance',
style: context.textTheme.bodySmall?.copyWith(
color: ColorsManager.textPrimaryColor,
fontWeight: FontWeight.w400,
fontSize: 14,
),
),
const Spacer(),
CircleAvatar(
backgroundColor: ColorsManager.green.withValues(
alpha: 0.5,
),
radius: 2,
),
const SizedBox(width: 4),
Text(
'Live',
style: context.textTheme.bodySmall?.copyWith(
color: ColorsManager.green.withValues(alpha: 0.5),
fontWeight: FontWeight.w400,
fontSize: 8,
),
),
],
);
}
}
@@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/widgets/aqi_device_info.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/widgets/aqi_location_info.dart';
import 'package:syncrow_web/pages/analytics/widgets/analytics_sidebar_header.dart';
import 'package:syncrow_web/utils/style.dart';
class AirQualityEndSideWidget extends StatelessWidget {
const AirQualityEndSideWidget({super.key});
@override
Widget build(BuildContext context) {
return Container(
decoration: subSectionContainerDecoration.copyWith(
borderRadius: BorderRadius.circular(30),
),
padding: const EdgeInsetsDirectional.all(32),
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AnalyticsSidebarHeader(title: 'AQI Sensor'),
Expanded(flex: 15, child: AqiDeviceInfo()),
SizedBox(height: 20),
Expanded(flex: 6, child: AqiLocationInfo()),
],
),
);
}
}
@@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/widgets/air_quality_end_side_gauge_and_info.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/widgets/air_quality_end_side_live_indicator.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/widgets/aqi_sub_value_widget.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/widgets/aqi_type_dropdown.dart';
import 'package:syncrow_web/pages/analytics/modules/energy_management/blocs/realtime_device_changes/realtime_device_changes_bloc.dart';
import 'package:syncrow_web/pages/device_managment/all_devices/models/device_status.dart';
import 'package:syncrow_web/utils/style.dart';
class AqiDeviceInfo extends StatelessWidget {
const AqiDeviceInfo({super.key});
String _getValueForStatus(
List<Status> deviceStatusList,
String code, {
double defaultValue = 0,
String Function(int value)? formatter,
}) {
try {
final foundStatus = deviceStatusList.firstWhere((e) => e.code == code);
final value = foundStatus.value.toString();
final intValue = int.parse(value);
return formatter != null ? formatter(intValue) : intValue.toString();
} catch (e) {
return defaultValue.toString();
}
}
@override
Widget build(BuildContext context) {
return BlocBuilder<RealtimeDeviceChangesBloc, RealtimeDeviceChangesState>(
builder: (context, state) {
final status = state.deviceStatusList;
final humidityValue = _getValueForStatus(
status,
'humidity_value',
formatter: (value) => value.toStringAsFixed(0),
);
final tempValue = _getValueForStatus(
status,
'temp_current',
formatter: (value) => (value / 10).toStringAsFixed(0),
);
final pm25Value = _getValueForStatus(
status,
'pm25_value',
formatter: (value) => value.toString().padLeft(3, '0'),
);
final pm10Value = _getValueForStatus(
status,
'pm10',
formatter: (value) => value.toString().padLeft(3, '0'),
);
final co2Value = _getValueForStatus(
status,
'co2_value',
formatter: (value) => value.toString().padLeft(4, '0'),
);
final ch2oValue = _getValueForStatus(
status,
'ch2o_value',
formatter: (value) => (value / 100).toStringAsFixed(2),
);
final tvocValue = _getValueForStatus(
status,
'tvoc_value',
formatter: (value) => (value / 100).toStringAsFixed(2),
);
return Container(
decoration: secondarySection.copyWith(boxShadow: const []),
padding: const EdgeInsetsDirectional.all(20),
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,
),
],
),
);
},
);
}
}
@@ -0,0 +1,174 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:syncrow_web/pages/analytics/models/air_quality_data_model.dart';
import 'package:syncrow_web/pages/analytics/modules/energy_management/helpers/energy_management_charts_helper.dart';
import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart';
class AqiDistributionChart extends StatelessWidget {
const AqiDistributionChart({super.key, required this.chartData});
final List<AirQualityDataModel> chartData;
static const _rodStackItemsSpacing = 0.4;
static const _barWidth = 13.0;
static final _barBorderRadius = BorderRadius.circular(22);
@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,
gridData: EnergyManagementChartsHelper.gridData(
horizontalInterval: 20,
),
borderData: EnergyManagementChartsHelper.borderData(),
barTouchData: _barTouchData(context),
titlesData: _titlesData(context),
barGroups: _buildBarGroups(sortedData),
),
duration: Duration.zero,
);
}
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;
// Sort data by type to ensure consistent order
final sortedPercentageData = List<AirQualityPercentageData>.from(data.data)
..sort((a, b) => a.type.compareTo(b.type));
for (final percentageData in sortedPercentageData) {
stackItems.add(
BarChartRodData(
fromY: currentY,
toY: currentY + percentageData.percentage ,
color: AirQualityDataModel.metricColors[percentageData.name]!,
borderRadius: isFirstElement
? const BorderRadius.only(
topLeft: Radius.circular(22),
topRight: Radius.circular(22),
)
: _barBorderRadius,
width: _barWidth,
),
);
currentY += percentageData.percentage + _rodStackItemsSpacing;
isFirstElement = false;
}
return BarChartGroupData(
x: index,
barRods: stackItems,
groupVertically: true,
);
});
}
BarTouchData _barTouchData(BuildContext context) {
return BarTouchData(
touchTooltipData: BarTouchTooltipData(
getTooltipColor: (_) => ColorsManager.whiteColors,
tooltipBorder: const BorderSide(
color: ColorsManager.semiTransparentBlack,
),
tooltipRoundedRadius: 16,
tooltipPadding: const EdgeInsets.all(8),
getTooltipItem: (group, groupIndex, rod, rodIndex) {
final data = chartData[group.x.toInt()];
final List<TextSpan> children = [];
final textStyle = context.textTheme.bodySmall?.copyWith(
color: ColorsManager.blackColor,
fontSize: 12,
);
// Sort data by type to ensure consistent order
final sortedPercentageData = List<AirQualityPercentageData>.from(data.data)
..sort((a, b) => a.type.compareTo(b.type));
for (final percentageData in sortedPercentageData) {
children.add(TextSpan(
text:
'\n${percentageData.type.toUpperCase()}: ${percentageData.percentage.toStringAsFixed(1)}%',
style: textStyle,
));
}
return BarTooltipItem(
DateFormat('dd/MM/yyyy').format(data.date),
context.textTheme.bodyMedium!.copyWith(
color: ColorsManager.blackColor,
fontSize: 16,
fontWeight: FontWeight.w600,
),
children: children,
);
},
),
);
}
FlTitlesData _titlesData(BuildContext context) {
final titlesData = EnergyManagementChartsHelper.titlesData(
context,
leftTitlesInterval: 20,
);
final leftTitles = titlesData.leftTitles.copyWith(
sideTitles: titlesData.leftTitles.sideTitles.copyWith(
reservedSize: 70,
interval: 20,
maxIncluded: false,
minIncluded: true,
getTitlesWidget: (value, meta) => Padding(
padding: const EdgeInsetsDirectional.only(end: 12),
child: FittedBox(
alignment: AlignmentDirectional.centerStart,
fit: BoxFit.scaleDown,
child: Text(
'${value.toStringAsFixed(0)}%',
style: context.textTheme.bodySmall?.copyWith(
fontSize: 12,
color: ColorsManager.lightGreyColor,
),
),
),
),
),
);
final bottomTitles = AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, _) => FittedBox(
alignment: AlignmentDirectional.bottomCenter,
fit: BoxFit.scaleDown,
child: Text(
chartData[value.toInt()].date.day.toString(),
style: context.textTheme.bodySmall?.copyWith(
color: ColorsManager.lightGreyColor,
fontSize: 8,
),
),
),
reservedSize: 36,
),
);
return titlesData.copyWith(
leftTitles: leftTitles,
bottomTitles: bottomTitles,
);
}
}
@@ -0,0 +1,44 @@
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_distribution_chart.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/widgets/aqi_distribution_chart_title.dart';
import 'package:syncrow_web/pages/analytics/widgets/analytics_error_widget.dart';
import 'package:syncrow_web/utils/style.dart';
class AqiDistributionChartBox extends StatelessWidget {
const AqiDistributionChartBox({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<AirQualityDistributionBloc, AirQualityDistributionState>(
builder: (context, state) {
return Container(
padding: const EdgeInsetsDirectional.all(30),
decoration: subSectionContainerDecoration.copyWith(
borderRadius: BorderRadius.circular(30),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (state.errorMessage != null) ...[
AnalyticsErrorWidget(state.errorMessage),
const SizedBox(height: 10),
],
AqiDistributionChartTitle(
isLoading: state.status == AirQualityDistributionStatus.loading,
),
const SizedBox(height: 10),
const Divider(),
const SizedBox(height: 20),
Expanded(
child: AqiDistributionChart(chartData: state.filteredChartData),
),
],
),
);
},
);
}
}
@@ -0,0 +1,44 @@
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';
class AqiDistributionChartTitle extends StatelessWidget {
const AqiDistributionChartTitle({required this.isLoading, super.key});
final bool isLoading;
@override
Widget build(BuildContext context) {
return Row(
children: [
ChartsLoadingWidget(isLoading: isLoading),
const Expanded(
flex: 3,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: AlignmentDirectional.centerStart,
child: ChartTitle(
title: Text('Distribution over Air Quality Index'),
),
),
),
FittedBox(
alignment: AlignmentDirectional.centerEnd,
fit: BoxFit.scaleDown,
child: AqiTypeDropdown(
onChanged: (value) {
if (value != null) {
context
.read<AirQualityDistributionBloc>()
.add(UpdateAqiTypeEvent(value));
}
},
),
),
],
);
}
}
@@ -0,0 +1,115 @@
import 'package:flutter/material.dart';
import 'package:gauge_indicator/gauge_indicator.dart';
import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart';
class AqiGauge extends StatelessWidget {
const AqiGauge({super.key, required this.aqi});
final double aqi;
static const _minRange = 0.0;
static const _goodRange = 50.0;
static const _moderateRange = 100.0;
static const _maxRange = 150.0;
@override
Widget build(BuildContext context) {
final (status, statusColor) = _getStatusData(aqi);
return AnimatedRadialGauge(
value: aqi,
debug: false,
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
initialValue: 0,
alignment: Alignment.bottomCenter,
builder: (context, child, value) {
return Align(
alignment: AlignmentDirectional.bottomCenter,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: AlignmentDirectional.bottomCenter,
child: Text.rich(
TextSpan(
text: 'Air Quality\n',
style: context.textTheme.bodySmall?.copyWith(
color: ColorsManager.textPrimaryColor,
fontWeight: FontWeight.w400,
fontSize: 12,
),
children: [
TextSpan(
text: status,
style: context.textTheme.bodySmall?.copyWith(
color: _darkenColor(statusColor),
fontWeight: FontWeight.w400,
fontSize: 30,
),
),
],
),
textAlign: TextAlign.center,
),
),
);
},
axis: GaugeAxis(
progressBar: const GaugeProgressBar.basic(color: Colors.transparent),
style: const GaugeAxisStyle(
cornerRadius: Radius.circular(16),
thickness: 14,
segmentSpacing: 4,
),
min: _minRange,
max: _maxRange,
pointer: GaugePointer.circle(
position: const GaugePointerPosition.surface(),
radius: MediaQuery.sizeOf(context).width * 0.004,
color: ColorsManager.whiteColors,
border: GaugePointerBorder(
width: 6,
color: statusColor,
),
shadow: const BoxShadow(
color: ColorsManager.blackColor,
blurRadius: 6,
offset: Offset(0, 2),
),
),
segments: const [
GaugeSegment(
from: _minRange,
to: _goodRange,
cornerRadius: Radius.circular(16),
color: ColorsManager.goodGreen,
),
GaugeSegment(
from: _goodRange + 1,
to: _moderateRange,
cornerRadius: Radius.circular(16),
color: ColorsManager.moderateYellow,
),
GaugeSegment(
from: _moderateRange + 1,
to: _maxRange,
cornerRadius: Radius.circular(16),
color: ColorsManager.poorOrange,
),
],
),
);
}
(String status, Color color) _getStatusData(double value) {
return switch (value) {
<= _goodRange => ('Good', ColorsManager.goodGreen),
<= _moderateRange => ('Moderate', ColorsManager.moderateYellow),
_ => ('Poor', ColorsManager.poorOrange),
};
}
Color _darkenColor(Color color) {
final black = Colors.black.withValues(alpha: 0.8);
return Color.lerp(color, black, 0.4)!;
}
}
@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/utils/constants/assets.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart';
class AqiHumidityAndTemperature extends StatelessWidget {
const AqiHumidityAndTemperature({
required this.temperature,
required this.humidity,
super.key,
});
final int temperature;
final int humidity;
static const iconSize = 12.0;
static const colorFilter = ColorFilter.mode(
ColorsManager.textPrimaryColor,
BlendMode.srcIn,
);
@override
Widget build(BuildContext context) {
return FittedBox(
fit: BoxFit.scaleDown,
alignment: AlignmentDirectional.centerStart,
child: DefaultTextStyle(
style: context.textTheme.bodySmall!.copyWith(
color: ColorsManager.textPrimaryColor,
fontWeight: FontWeight.w400,
fontSize: 16,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildIconAndValue(Assets.temperatureAqiSidebar, '$temperature°C'),
const SizedBox(height: 10),
_buildIconAndValue(Assets.humidityAqiSidebar, '$humidity%'),
],
),
),
);
}
Widget _buildIconAndValue(String icon, String value) {
return Row(
children: [
SvgPicture.asset(
icon,
height: iconSize,
width: iconSize,
colorFilter: colorFilter,
),
const SizedBox(width: 4),
Text(value),
],
);
}
}
@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/utils/constants/assets.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart';
import 'package:syncrow_web/utils/style.dart';
class AqiLocation extends StatelessWidget {
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 = <String>[];
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) {
return Container(
decoration: subSectionContainerDecoration.copyWith(
boxShadow: const [],
borderRadius: BorderRadius.circular(10),
),
padding: const EdgeInsetsDirectional.all(10),
child: Row(
spacing: 10,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
_buildLocationPin(),
Expanded(
child: Text(
_getFormattedLocation(),
style: context.textTheme.bodySmall?.copyWith(
color: ColorsManager.textPrimaryColor,
fontWeight: FontWeight.w400,
fontSize: 12,
),
),
),
],
),
);
}
Widget _buildLocationPin() {
return SvgPicture.asset(
Assets.locationPin,
height: 12,
width: 12,
alignment: AlignmentDirectional.centerStart,
colorFilter: const ColorFilter.mode(
ColorsManager.vividBlue,
BlendMode.srcIn,
),
);
}
}
@@ -0,0 +1,56 @@
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';
import 'package:syncrow_web/utils/style.dart';
class AqiLocationInfo extends StatelessWidget {
const AqiLocationInfo({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<DeviceLocationBloc, DeviceLocationState>(
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,
),
],
),
),
],
),
);
},
);
}
}
@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart';
class AqiLocationInfoCell extends StatelessWidget {
const AqiLocationInfoCell({
required this.label,
required this.value,
required this.svgPath,
super.key,
});
final String label;
final String value;
final String svgPath;
@override
Widget build(BuildContext context) {
return Expanded(
child: Container(
decoration: BoxDecoration(
color: ColorsManager.whiteColors,
borderRadius: BorderRadius.circular(12),
),
child: Stack(
children: [
Align(
alignment: AlignmentDirectional.topStart,
child: Padding(
padding: const EdgeInsetsDirectional.all(10),
child: SizedBox(
height: 24,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: AlignmentDirectional.topStart,
child: Text(
label,
style: context.textTheme.bodySmall?.copyWith(
color: ColorsManager.textPrimaryColor,
fontWeight: FontWeight.w400,
fontSize: 12,
),
),
),
),
),
),
Align(
alignment: AlignmentDirectional.bottomEnd,
child: Padding(
padding: const EdgeInsetsDirectional.all(10),
child: SizedBox(
height: 40,
width: 120,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: AlignmentDirectional.bottomEnd,
child: Text(
value,
style: context.textTheme.bodySmall?.copyWith(
color: ColorsManager.vividBlue.withValues(alpha: 0.7),
fontWeight: FontWeight.w700,
fontSize: 24,
),
),
),
),
),
),
Align(
alignment: AlignmentDirectional.bottomStart,
child: SizedBox.square(
dimension: MediaQuery.sizeOf(context).width * 0.45,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: AlignmentDirectional.bottomStart,
child: SvgPicture.asset(svgPath),
),
),
),
],
),
),
);
}
}
@@ -0,0 +1,158 @@
import 'package:flutter/material.dart';
import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart';
final class _AqiRange {
const _AqiRange({required this.max, required this.color});
final double max;
final Color color;
}
class AqiSubValueWidget extends StatelessWidget {
const AqiSubValueWidget({
required this.label,
required this.value,
required this.unit,
required this.range,
super.key,
});
final String label;
final String value;
final String unit;
final (double min, double max) range;
double get _parsedValue => double.parse(value);
static const List<_AqiRange> _ranges = [
_AqiRange(max: 12, color: ColorsManager.goodGreen),
_AqiRange(max: 35, color: ColorsManager.poorOrange),
_AqiRange(max: 55, color: ColorsManager.poorOrange),
_AqiRange(max: 150, color: ColorsManager.unhealthyRed),
_AqiRange(max: 250, color: ColorsManager.severePink),
_AqiRange(max: 500, color: ColorsManager.hazardousPurple),
];
static List<_AqiRange> _getRangesForValue((double min, double max) range) {
final (double min, double max) = range;
final rangeSize = (max - min) / 6;
return [
_AqiRange(max: min + rangeSize, color: ColorsManager.goodGreen),
_AqiRange(max: min + (rangeSize * 2), color: ColorsManager.poorOrange),
_AqiRange(max: min + (rangeSize * 3), color: ColorsManager.poorOrange),
_AqiRange(max: min + (rangeSize * 4), color: ColorsManager.unhealthyRed),
_AqiRange(max: min + (rangeSize * 5), color: ColorsManager.severePink),
_AqiRange(max: min, color: ColorsManager.hazardousPurple),
];
}
int _getActiveSegmentByRange(double value, (double min, double max) range) {
final ranges = _getRangesForValue(range);
for (int i = 0; i < ranges.length; i++) {
if (value <= ranges[i].max) return i;
}
return ranges.length - 1;
}
@override
Widget build(BuildContext context) {
final activeSegment = _getActiveSegmentByRange(_parsedValue, range);
return Expanded(
child: Container(
padding: const EdgeInsetsDirectional.all(10),
decoration: BoxDecoration(
color: ColorsManager.whiteColors,
borderRadius: BorderRadius.circular(12),
),
child: Row(
spacing: MediaQuery.sizeOf(context).width * 0.0075,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildLabel(context),
_buildSegmentedBar(activeSegment),
_buildValueAndUnit(context),
],
),
),
);
}
Widget _buildValueAndUnit(BuildContext context) {
return Expanded(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: AlignmentDirectional.centerEnd,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
value,
style: context.textTheme.titleMedium?.copyWith(
color: ColorsManager.blackColor,
fontWeight: FontWeight.w400,
fontSize: 14,
),
),
Text(
unit,
style: context.textTheme.bodySmall?.copyWith(
color: ColorsManager.blackColor,
fontWeight: FontWeight.w400,
fontSize: 10,
),
),
],
),
),
);
}
Widget _buildSegmentedBar(int activeSegment) {
return Expanded(
flex: 4,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
spacing: 4,
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(_ranges.length, (index) {
final isActive = index == activeSegment;
final color = _ranges[index].color.withValues(
alpha: isActive ? 1.0 : 0.25,
);
return Expanded(
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
curve: Curves.linear,
height: 5,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(4),
),
),
);
}),
),
),
);
}
Widget _buildLabel(BuildContext context) {
return Expanded(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: AlignmentDirectional.centerStart,
child: Text(
label,
style: context.textTheme.bodySmall?.copyWith(
color: ColorsManager.blackColor,
fontWeight: FontWeight.w400,
fontSize: 14,
),
),
),
);
}
}
@@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart';
enum AqiType {
aqi('AQI', '', 'aqi'),
pm25('PM2.5', 'µg/m³', 'pm25'),
pm10('PM10', 'µg/m³', 'pm10'),
hcho('HCHO', 'mg/m³', 'hcho'),
tvoc('TVOC', 'µg/m³', 'tvoc'),
co2('CO2', 'ppm', 'co2');
const AqiType(this.value, this.unit, this.code);
final String value;
final String unit;
final String code;
}
class AqiTypeDropdown extends StatefulWidget {
const AqiTypeDropdown({super.key, required this.onChanged});
final ValueChanged<AqiType?> onChanged;
@override
State<AqiTypeDropdown> createState() => _AqiTypeDropdownState();
}
class _AqiTypeDropdownState extends State<AqiTypeDropdown> {
AqiType? _selectedItem = AqiType.aqi;
void _updateSelectedItem(AqiType? item) => setState(() => _selectedItem = item);
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: ColorsManager.greyColor,
width: 1,
),
),
child: DropdownButton<AqiType?>(
value: _selectedItem,
isDense: true,
borderRadius: BorderRadius.circular(16),
dropdownColor: ColorsManager.whiteColors,
underline: const SizedBox.shrink(),
icon: const RotatedBox(
quarterTurns: 1,
child: Icon(Icons.chevron_right, size: 24),
),
style: _getTextStyle(context),
padding: const EdgeInsetsDirectional.symmetric(
horizontal: 12,
vertical: 2,
),
items: AqiType.values
.map((e) => DropdownMenuItem(value: e, child: Text(e.value)))
.toList(),
onChanged: (value) {
_updateSelectedItem(value);
widget.onChanged(value);
},
),
);
}
TextStyle? _getTextStyle(BuildContext context) {
return context.textTheme.labelSmall?.copyWith(
color: ColorsManager.textPrimaryColor,
fontWeight: FontWeight.w700,
fontSize: 12,
);
}
}
@@ -0,0 +1,111 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:syncrow_web/pages/analytics/models/range_of_aqi.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/helpers/range_of_aqi_charts_helper.dart';
import 'package:syncrow_web/pages/analytics/modules/energy_management/helpers/energy_management_charts_helper.dart';
import 'package:syncrow_web/utils/color_manager.dart';
class RangeOfAqiChart extends StatelessWidget {
final List<RangeOfAqi> chartData;
const RangeOfAqiChart({
super.key,
required this.chartData,
});
List<(List<double> values, Color color, Color? dotColor)> get _lines {
final sortedData = List<RangeOfAqi>.from(chartData)
..sort((a, b) => a.date.compareTo(b.date));
return [
(
sortedData.map((e) {
final value = e.data.firstOrNull;
return value?.max ?? 0;
}).toList(),
ColorsManager.maxPurple,
ColorsManager.maxPurpleDot,
),
(
sortedData.map((e) {
final value = e.data.firstOrNull;
return value?.average ?? 0;
}).toList(),
Colors.white,
null,
),
(
sortedData.map((e) {
final value = e.data.firstOrNull;
return value?.min ?? 0;
}).toList(),
ColorsManager.minBlue,
ColorsManager.minBlueDot,
),
];
}
@override
Widget build(BuildContext context) {
return LineChart(
LineChartData(
minY: 0,
maxY: 301,
clipData: const FlClipData.vertical(),
gridData: EnergyManagementChartsHelper.gridData(horizontalInterval: 50),
titlesData: RangeOfAqiChartsHelper.titlesData(context, chartData),
borderData: EnergyManagementChartsHelper.borderData(),
lineTouchData: RangeOfAqiChartsHelper.lineTouchData(chartData),
betweenBarsData: [
BetweenBarsData(
fromIndex: 0,
toIndex: 2,
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
stops: [0.0, 0.2, 0.4, 0.6, 0.8, 1.0],
colors: RangeOfAqiChartsHelper.gradientData.map((e) {
final (color, _) = e;
return color.withValues(alpha: 0.6);
}).toList(),
),
),
],
lineBarsData: _lines.map((e) {
final (values, color, dotColor) = e;
return _buildLine(values: values, color: color, dotColor: dotColor);
}).toList(),
),
duration: Duration.zero,
);
}
FlDotData _buildDotData(Color color) {
return FlDotData(
show: true,
getDotPainter: (_, __, ___, ____) => FlDotCirclePainter(
radius: 2,
color: ColorsManager.whiteColors,
strokeWidth: 2,
strokeColor: color,
),
);
}
LineChartBarData _buildLine({
required List<double> values,
required Color color,
Color? dotColor,
}) {
const invisibleDot = FlDotData(show: false);
return LineChartBarData(
spots: List.generate(values.length, (i) => FlSpot(i.toDouble(), values[i])),
isCurved: true,
color: color,
barWidth: 4,
isStrokeCapRound: true,
dotData: dotColor != null ? _buildDotData(dotColor) : invisibleDot,
belowBarData: BarAreaData(show: false),
);
}
}
@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
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/widgets/analytics_error_widget.dart';
import 'package:syncrow_web/utils/style.dart';
class RangeOfAqiChartBox extends StatelessWidget {
const RangeOfAqiChartBox({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<RangeOfAqiBloc, RangeOfAqiState>(
builder: (context, state) {
return Container(
padding: const EdgeInsetsDirectional.all(30),
decoration: subSectionContainerDecoration.copyWith(
borderRadius: BorderRadius.circular(30),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (state.errorMessage != null) ...[
AnalyticsErrorWidget(state.errorMessage),
const SizedBox(height: 10),
],
RangeOfAqiChartTitle(
isLoading: state.status == RangeOfAqiStatus.loading,
),
const SizedBox(height: 10),
const Divider(),
const SizedBox(height: 20),
Expanded(child: RangeOfAqiChart(chartData: state.filteredRangeOfAqi)),
],
),
);
},
);
}
}
@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
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/aqi_type_dropdown.dart';
import 'package:syncrow_web/pages/analytics/modules/analytics/widgets/chart_informative_cell.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';
import 'package:syncrow_web/pages/space_tree/bloc/space_tree_bloc.dart';
class RangeOfAqiChartTitle extends StatelessWidget {
const RangeOfAqiChartTitle({
required this.isLoading,
super.key,
});
final bool isLoading;
static const List<(Color color, String title, bool hasBorder)> _colors = [
(Color(0xFF962DFF), 'Max', false),
(Color(0xFF93AAFD), 'Min', false),
(Colors.transparent, 'Avg', true),
];
@override
Widget build(BuildContext context) {
return Row(
children: [
ChartsLoadingWidget(isLoading: isLoading),
const Expanded(
flex: 3,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: AlignmentDirectional.centerStart,
child: ChartTitle(title: Text('Range of AQI')),
),
),
const Spacer(flex: 3),
..._colors.map(
(e) {
final (color, title, hasBorder) = e;
return Expanded(
child: IntrinsicHeight(
child: FittedBox(
fit: BoxFit.fitWidth,
alignment: AlignmentDirectional.centerStart,
child: Padding(
padding: const EdgeInsetsDirectional.only(end: 16),
child: ChartInformativeCell(
title: Text(title),
color: color,
hasBorder: hasBorder,
),
),
),
),
);
},
),
const Spacer(),
Expanded(
flex: 2,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: AlignmentDirectional.centerEnd,
child: AqiTypeDropdown(
onChanged: (value) {
final spaceTreeState = context.read<SpaceTreeBloc>().state;
final spaceUuid = spaceTreeState.selectedSpaces.firstOrNull;
if (spaceUuid == null) return;
if (value != null) {
context.read<RangeOfAqiBloc>().add(UpdateAqiTypeEvent(value));
}
},
),
),
),
],
);
}
}
@@ -3,6 +3,7 @@ import 'package:equatable/equatable.dart';
import 'package:syncrow_web/pages/analytics/models/analytics_device.dart';
import 'package:syncrow_web/pages/analytics/params/get_analytics_devices_param.dart';
import 'package:syncrow_web/pages/analytics/services/analytics_devices/analytics_devices_service.dart';
import 'package:syncrow_web/services/api/api_exception.dart';
part 'analytics_devices_event.dart';
part 'analytics_devices_state.dart';
@@ -36,6 +37,13 @@ class AnalyticsDevicesBloc
if (devices.isNotEmpty) {
event.onSuccess(devices.first);
}
} on APIException catch (e) {
emit(
AnalyticsDevicesState(
status: AnalyticsDevicesStatus.failure,
errorMessage: e.message,
),
);
} catch (e) {
emit(
AnalyticsDevicesState(
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/views/air_quality_view.dart';
import 'package:syncrow_web/pages/analytics/modules/energy_management/views/analytics_energy_management_view.dart';
import 'package:syncrow_web/pages/analytics/modules/occupancy/views/analytics_occupancy_view.dart';
@@ -10,6 +11,10 @@ enum AnalyticsPageTab {
occupancy(
title: 'Occupancy',
child: AnalyticsOccupancyView(),
),
airQuality(
title: 'Air Quality',
child: AirQualityView(),
);
const AnalyticsPageTab({
@@ -0,0 +1,54 @@
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';
import 'package:syncrow_web/pages/spaces_management/all_spaces/model/community_model.dart';
import 'package:syncrow_web/pages/spaces_management/all_spaces/model/space_model.dart';
final class AirQualityDataLoadingStrategy implements AnalyticsDataLoadingStrategy {
@override
void onCommunitySelected(
BuildContext context,
CommunityModel community,
List<SpaceModel> spaces,
) {
// Do nothing
}
@override
void onSpaceSelected(
BuildContext context,
CommunityModel community,
SpaceModel space,
) {
final spaceTreeBloc = context.read<SpaceTreeBloc>();
final isSpaceSelected = spaceTreeBloc.state.selectedSpaces.contains(space.uuid);
final hasSelectedSpaces = spaceTreeBloc.state.selectedSpaces.isNotEmpty;
if (hasSelectedSpaces) clearData(context);
if (isSpaceSelected) return;
spaceTreeBloc
..add(const SpaceTreeClearSelectionEvent())
..add(OnSpaceSelected(community, space.uuid ?? '', []));
FetchAirQualityDataHelper.loadAirQualityData(
context,
communityUuid: community.uuid,
spaceUuid: space.uuid ?? '',
date: context.read<AnalyticsDatePickerBloc>().state.monthlyDate,
);
}
@override
void clearData(BuildContext context) {
context.read<SpaceTreeBloc>().add(
const AnalyticsClearAllSpaceTreeSelectionsEvent(),
);
FetchAirQualityDataHelper.clearAllData(context);
}
}
@@ -13,10 +13,5 @@ abstract class AnalyticsDataLoadingStrategy {
CommunityModel community,
SpaceModel space,
);
void onChildSpaceSelected(
BuildContext context,
CommunityModel community,
SpaceModel child,
);
void clearData(BuildContext context);
}
@@ -1,4 +1,5 @@
import 'package:syncrow_web/pages/analytics/modules/analytics/enums/analytics_page_tab.dart';
import 'package:syncrow_web/pages/analytics/modules/analytics/strategies/air_quality_data_loading_strategy.dart';
import 'package:syncrow_web/pages/analytics/modules/analytics/strategies/analytics_data_loading_strategy.dart';
import 'package:syncrow_web/pages/analytics/modules/analytics/strategies/energy_management_data_loading_strategy.dart';
import 'package:syncrow_web/pages/analytics/modules/analytics/strategies/occupancy_data_loading_strategy.dart';
@@ -9,6 +10,7 @@ abstract final class AnalyticsDataLoadingStrategyFactory {
return switch (tab) {
AnalyticsPageTab.energyManagement => EnergyManagementDataLoadingStrategy(),
AnalyticsPageTab.occupancy => OccupancyDataLoadingStrategy(),
AnalyticsPageTab.airQuality => AirQualityDataLoadingStrategy(),
};
}
}
@@ -14,24 +14,14 @@ class EnergyManagementDataLoadingStrategy implements AnalyticsDataLoadingStrateg
CommunityModel community,
List<SpaceModel> spaces,
) {
context.read<SpaceTreeBloc>().add(
OnCommunitySelected(
community.uuid,
spaces,
),
);
final spaceTreeBloc = context.read<SpaceTreeBloc>();
final isCommunitySelected =
spaceTreeBloc.state.selectedCommunities.contains(community.uuid);
final spaceTreeState = context.read<SpaceTreeBloc>().state;
if (spaceTreeState.selectedCommunities.contains(community.uuid)) {
if (isCommunitySelected) {
clearData(context);
return;
}
FetchEnergyManagementDataHelper.loadEnergyManagementData(
context,
communityId: community.uuid,
spaceId: spaces.isNotEmpty ? spaces.first.uuid ?? '' : '',
);
}
@override
@@ -40,21 +30,31 @@ class EnergyManagementDataLoadingStrategy implements AnalyticsDataLoadingStrateg
CommunityModel community,
SpaceModel space,
) {
context.read<SpaceTreeBloc>().add(
OnSpaceSelected(
community,
space.uuid ?? '',
space.children,
),
);
final spaceTreeBloc = context.read<SpaceTreeBloc>();
final isSpaceSelected = spaceTreeBloc.state.selectedSpaces.contains(space.uuid);
final hasSelectedSpaces = spaceTreeBloc.state.selectedSpaces.isNotEmpty;
final spaceTreeState = context.read<SpaceTreeBloc>().state;
if (spaceTreeState.selectedCommunities.contains(community.uuid) ||
spaceTreeState.selectedSpaces.contains(space.uuid)) {
clearData(context);
if (isSpaceSelected) {
final firstSelectedSpace = spaceTreeBloc.state.selectedSpaces.first;
final isTheFirstSelectedSpace = firstSelectedSpace == space.uuid;
if (isTheFirstSelectedSpace) {
clearData(context);
}
return;
}
if (hasSelectedSpaces) {
clearData(context);
}
spaceTreeBloc.add(
OnSpaceSelected(
community,
space.uuid ?? '',
space.children,
),
);
FetchEnergyManagementDataHelper.loadEnergyManagementData(
context,
communityId: community.uuid,
@@ -62,20 +62,11 @@ class EnergyManagementDataLoadingStrategy implements AnalyticsDataLoadingStrateg
);
}
@override
void onChildSpaceSelected(
BuildContext context,
CommunityModel community,
SpaceModel child,
) {
if (child.children.isNotEmpty) {
return onSpaceSelected(context, community, child);
}
}
@override
void clearData(BuildContext context) {
context.read<SpaceTreeBloc>().add(const SpaceTreeClearSelectionEvent());
context.read<SpaceTreeBloc>().add(
const AnalyticsClearAllSpaceTreeSelectionsEvent(),
);
FetchEnergyManagementDataHelper.clearAllData(context);
}
}
@@ -26,10 +26,10 @@ class OccupancyDataLoadingStrategy implements AnalyticsDataLoadingStrategy {
final spaceTreeBloc = context.read<SpaceTreeBloc>();
final isSpaceSelected = spaceTreeBloc.state.selectedSpaces.contains(space.uuid);
if (isSpaceSelected) {
clearData(context);
return;
}
final hasSelectedSpaces = spaceTreeBloc.state.selectedSpaces.isNotEmpty;
if (hasSelectedSpaces) clearData(context);
if (isSpaceSelected) return;
spaceTreeBloc
..add(const SpaceTreeClearSelectionEvent())
@@ -42,18 +42,11 @@ class OccupancyDataLoadingStrategy implements AnalyticsDataLoadingStrategy {
);
}
@override
void onChildSpaceSelected(
BuildContext context,
CommunityModel community,
SpaceModel child,
) {
onSpaceSelected(context, community, child);
}
@override
void clearData(BuildContext context) {
context.read<SpaceTreeBloc>().add(const SpaceTreeClearSelectionEvent());
context.read<SpaceTreeBloc>().add(
const AnalyticsClearAllSpaceTreeSelectionsEvent(),
);
FetchOccupancyDataHelper.clearAllData(context);
}
}
@@ -1,5 +1,9 @@
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';
import 'package:syncrow_web/pages/analytics/modules/analytics/blocs/analytics_tab/analytics_tab_bloc.dart';
@@ -12,14 +16,18 @@ import 'package:syncrow_web/pages/analytics/modules/energy_management/blocs/real
import 'package:syncrow_web/pages/analytics/modules/energy_management/blocs/total_energy_consumption/total_energy_consumption_bloc.dart';
import 'package:syncrow_web/pages/analytics/modules/occupancy/blocs/occupancy/occupancy_bloc.dart';
import 'package:syncrow_web/pages/analytics/modules/occupancy/blocs/occupancy_heat_map/occupancy_heat_map_bloc.dart';
import 'package:syncrow_web/pages/analytics/services/air_quality_distribution/fake_air_quality_distribution_service.dart';
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';
import 'package:syncrow_web/pages/analytics/services/occupancy_heat_map/remote_occupancy_heat_map_service.dart';
import 'package:syncrow_web/pages/analytics/services/power_clamp_info/remote_power_clamp_info_service.dart';
import 'package:syncrow_web/pages/analytics/services/range_of_aqi/fake_range_of_aqi_service.dart';
import 'package:syncrow_web/pages/analytics/services/realtime_device_service/firebase_realtime_device_service.dart';
import 'package:syncrow_web/pages/analytics/services/total_energy_consumption/remote_total_energy_consumption_service.dart';
import 'package:syncrow_web/pages/device_managment/shared/navigate_home_grid_view.dart';
@@ -94,6 +102,28 @@ class _AnalyticsPageState extends State<AnalyticsPage> {
),
),
),
BlocProvider(
create: (context) => RangeOfAqiBloc(
FakeRangeOfAqiService(),
),
),
BlocProvider(
create: (context) => AirQualityDistributionBloc(
FakeAirQualityDistributionService(),
),
),
BlocProvider(
create: (context) => DeviceLocationBloc(
DeviceLocationDetailsServiceDecorator(
RemoteDeviceLocationService(_httpService),
Dio(
BaseOptions(
baseUrl: 'https://nominatim.openstreetmap.org/',
),
),
),
),
),
],
child: const AnalyticsPageForm(),
);
@@ -21,7 +21,7 @@ class AnalyticsCommunitiesSidebar extends StatelessWidget {
strategy.onSpaceSelected(context, community, space);
},
onSelectChildSpace: (community, child) {
strategy.onChildSpaceSelected(context, community, child);
strategy.onSpaceSelected(context, community, child);
},
),
);
@@ -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,
);
}
}
@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:syncrow_web/utils/color_manager.dart';
class ChartInformativeCell extends StatelessWidget {
const ChartInformativeCell({
super.key,
required this.title,
required this.color,
this.hasBorder = false,
});
final Widget title;
final Color color;
final bool hasBorder;
@override
Widget build(BuildContext context) {
return Container(
height: MediaQuery.sizeOf(context).height * 0.0385,
padding: const EdgeInsetsDirectional.symmetric(
vertical: 8,
horizontal: 12,
),
decoration: BoxDecoration(
borderRadius: BorderRadiusDirectional.circular(8),
border: Border.all(
color: ColorsManager.greyColor,
width: 1,
),
),
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.center,
child: Row(
spacing: 6,
children: [
Container(
height: 8,
width: 8,
decoration: BoxDecoration(
color: color,
border: Border.all(color: ColorsManager.grayBorder),
shape: BoxShape.circle,
),
),
DefaultTextStyle(
style: const TextStyle(
color: ColorsManager.blackColor,
fontWeight: FontWeight.w400,
fontSize: 14,
),
child: title,
),
],
),
),
);
}
}
@@ -3,6 +3,7 @@ import 'package:equatable/equatable.dart';
import 'package:syncrow_web/pages/analytics/models/phases_energy_consumption.dart';
import 'package:syncrow_web/pages/analytics/params/get_energy_consumption_by_phases_param.dart';
import 'package:syncrow_web/pages/analytics/services/energy_consumption_by_phases/energy_consumption_by_phases_service.dart';
import 'package:syncrow_web/services/api/api_exception.dart';
part 'energy_consumption_by_phases_event.dart';
part 'energy_consumption_by_phases_state.dart';
@@ -31,6 +32,13 @@ class EnergyConsumptionByPhasesBloc
chartData: chartData,
),
);
} on APIException catch (e) {
emit(
state.copyWith(
status: EnergyConsumptionByPhasesStatus.failure,
errorMessage: e.message,
),
);
} catch (e) {
emit(
state.copyWith(
@@ -3,6 +3,7 @@ import 'package:equatable/equatable.dart';
import 'package:syncrow_web/pages/analytics/models/device_energy_data_model.dart';
import 'package:syncrow_web/pages/analytics/params/get_energy_consumption_per_device_param.dart';
import 'package:syncrow_web/pages/analytics/services/energy_consumption_per_device/energy_consumption_per_device_service.dart';
import 'package:syncrow_web/services/api/api_exception.dart';
part 'energy_consumption_per_device_event.dart';
part 'energy_consumption_per_device_state.dart';
@@ -13,7 +14,8 @@ class EnergyConsumptionPerDeviceBloc
this._energyConsumptionPerDeviceService,
) : super(const EnergyConsumptionPerDeviceState()) {
on<LoadEnergyConsumptionPerDeviceEvent>(_onLoadEnergyConsumptionPerDeviceEvent);
on<ClearEnergyConsumptionPerDeviceEvent>(_onClearEnergyConsumptionPerDeviceEvent);
on<ClearEnergyConsumptionPerDeviceEvent>(
_onClearEnergyConsumptionPerDeviceEvent);
}
final EnergyConsumptionPerDeviceService _energyConsumptionPerDeviceService;
@@ -31,6 +33,13 @@ class EnergyConsumptionPerDeviceBloc
chartData: chartData,
),
);
} on APIException catch (e) {
emit(
state.copyWith(
status: EnergyConsumptionPerDeviceStatus.failure,
errorMessage: e.message,
),
);
} catch (e) {
emit(
state.copyWith(
@@ -3,6 +3,7 @@ import 'package:equatable/equatable.dart';
import 'package:syncrow_web/pages/analytics/services/power_clamp_info/power_clamp_info_service.dart';
import 'package:syncrow_web/pages/device_managment/all_devices/models/device_status.dart';
import 'package:syncrow_web/pages/device_managment/power_clamp/models/power_clamp_model.dart';
import 'package:syncrow_web/services/api/api_exception.dart';
part 'power_clamp_info_event.dart';
part 'power_clamp_info_state.dart';
@@ -31,6 +32,13 @@ class PowerClampInfoBloc extends Bloc<PowerClampInfoEvent, PowerClampInfoState>
powerClampModel: powerClampModel,
),
);
} on APIException catch (e) {
emit(
state.copyWith(
status: PowerClampInfoStatus.error,
errorMessage: e.message,
),
);
} catch (e) {
emit(
state.copyWith(
@@ -3,6 +3,7 @@ import 'package:equatable/equatable.dart';
import 'package:syncrow_web/pages/analytics/models/energy_data_model.dart';
import 'package:syncrow_web/pages/analytics/params/get_total_energy_consumption_param.dart';
import 'package:syncrow_web/pages/analytics/services/total_energy_consumption/total_energy_consumption_service.dart';
import 'package:syncrow_web/services/api/api_exception.dart';
part 'total_energy_consumption_event.dart';
part 'total_energy_consumption_state.dart';
@@ -31,6 +32,13 @@ class TotalEnergyConsumptionBloc
status: TotalEnergyConsumptionStatus.loaded,
),
);
} on APIException catch (e) {
emit(
state.copyWith(
errorMessage: e.message,
status: TotalEnergyConsumptionStatus.failure,
),
);
} catch (e) {
emit(
state.copyWith(
@@ -50,7 +50,7 @@ abstract final class EnergyManagementChartsHelper {
value.formatNumberToKwh,
style: context.textTheme.bodySmall?.copyWith(
fontSize: 12,
color: ColorsManager.greyColor,
color: ColorsManager.lightGreyColor,
),
),
),
@@ -93,12 +93,14 @@ abstract final class EnergyManagementChartsHelper {
);
}
static FlGridData gridData() {
static FlGridData gridData({
double horizontalInterval = 250,
}) {
return FlGridData(
show: true,
drawVerticalLine: false,
drawHorizontalLine: true,
horizontalInterval: 250,
horizontalInterval: horizontalInterval,
getDrawingHorizontalLine: (value) {
return FlLine(
color: ColorsManager.greyColor,
@@ -16,7 +16,6 @@ import 'package:syncrow_web/pages/analytics/params/get_total_energy_consumption_
abstract final class FetchEnergyManagementDataHelper {
const FetchEnergyManagementDataHelper._();
// static const String _powerClampId = 'cb71d6ad-6e29-4eaa-ae3e-1a0d1c5f60fa';
static AnalyticsDevice? getSelectedDevice(BuildContext context) {
return context.read<AnalyticsDevicesBloc>().state.selectedDevice;
}
@@ -48,7 +47,6 @@ abstract final class FetchEnergyManagementDataHelper {
loadTotalEnergyConsumption(
context,
selectedDate: selectedDate0,
communityId: communityId,
spaceId: spaceId,
);
final selectedDevice = getSelectedDevice(context);
@@ -61,7 +59,6 @@ abstract final class FetchEnergyManagementDataHelper {
}
loadEnergyConsumptionPerDevice(
context,
communityId: communityId,
spaceId: spaceId,
selectedDate: selectedDate0,
);
@@ -84,12 +81,10 @@ abstract final class FetchEnergyManagementDataHelper {
static void loadTotalEnergyConsumption(
BuildContext context, {
DateTime? selectedDate,
required String communityId,
required String spaceId,
}) {
final param = GetTotalEnergyConsumptionParam(
spaceId: spaceId,
communityId: communityId,
monthDate: selectedDate,
);
context.read<TotalEnergyConsumptionBloc>().add(
@@ -100,12 +95,10 @@ abstract final class FetchEnergyManagementDataHelper {
static void loadEnergyConsumptionPerDevice(
BuildContext context, {
DateTime? selectedDate,
required String communityId,
required String spaceId,
}) {
final param = GetEnergyConsumptionPerDeviceParam(
spaceId: spaceId,
communityId: communityId,
monthDate: selectedDate,
);
context.read<EnergyConsumptionPerDeviceBloc>().add(
@@ -1,34 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/analytics/modules/energy_management/helpers/fetch_energy_management_data_helper.dart';
import 'package:syncrow_web/pages/analytics/modules/energy_management/widgets/energy_consumption_per_device_chart_box.dart';
import 'package:syncrow_web/pages/analytics/modules/energy_management/widgets/power_clamp_energy_data_widget.dart';
import 'package:syncrow_web/pages/analytics/modules/energy_management/widgets/total_energy_consumption_chart_box.dart';
import 'package:syncrow_web/pages/space_tree/bloc/space_tree_bloc.dart';
class AnalyticsEnergyManagementView extends StatefulWidget {
class AnalyticsEnergyManagementView extends StatelessWidget {
const AnalyticsEnergyManagementView({super.key});
@override
State<AnalyticsEnergyManagementView> createState() =>
_AnalyticsEnergyManagementViewState();
}
class _AnalyticsEnergyManagementViewState
extends State<AnalyticsEnergyManagementView> {
@override
void initState() {
final spaceTreeBloc = context.read<SpaceTreeBloc>();
final communityId = spaceTreeBloc.state.selectedCommunities.firstOrNull;
final spaceId = spaceTreeBloc.state.selectedSpaces.firstOrNull;
FetchEnergyManagementDataHelper.loadEnergyManagementData(
context,
communityId: communityId ?? '',
spaceId: spaceId ?? '',
);
super.initState();
}
static const _padding = EdgeInsetsDirectional.all(32);
@override
Widget build(BuildContext context) {
@@ -6,9 +6,14 @@ import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart';
class AnalyticsDeviceDropdown extends StatelessWidget {
const AnalyticsDeviceDropdown({required this.onChanged, super.key});
const AnalyticsDeviceDropdown({
required this.onChanged,
this.showSpaceUuid = false,
super.key,
});
final ValueChanged<AnalyticsDevice> onChanged;
final bool showSpaceUuid;
@override
Widget build(BuildContext context) {
@@ -72,17 +77,18 @@ class AnalyticsDeviceDropdown extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
Text(e.name),
if (spaceUuid != null)
FittedBox(
fit: BoxFit.scaleDown,
alignment: AlignmentDirectional.centerStart,
child: Text(
spaceUuid,
style: _getTextStyle(context)?.copyWith(
fontSize: 10,
if (showSpaceUuid)
if (spaceUuid != null)
FittedBox(
fit: BoxFit.scaleDown,
alignment: AlignmentDirectional.centerStart,
child: Text(
spaceUuid,
style: _getTextStyle(context)?.copyWith(
fontSize: 10,
),
),
),
),
],
),
);
@@ -18,6 +18,7 @@ class EnergyConsumptionByPhasesChart extends StatelessWidget {
Widget build(BuildContext context) {
return BarChart(
BarChartData(
gridData: EnergyManagementChartsHelper.gridData().copyWith(
checkToShowHorizontalLine: (value) => true,
horizontalInterval: 250,
@@ -170,7 +171,7 @@ class EnergyConsumptionByPhasesChart extends StatelessWidget {
child: Text(
month,
style: context.textTheme.bodySmall?.copyWith(
color: ColorsManager.greyColor,
color: ColorsManager.lightGreyColor,
fontSize: 11,
),
),
@@ -19,10 +19,12 @@ class EnergyConsumptionByPhasesChartBox extends StatelessWidget {
decoration: secondarySection,
child: Column(
mainAxisSize: MainAxisSize.min,
spacing: 20,
children: [
AnalyticsErrorWidget(state.errorMessage),
EnergyConsumptionByPhasesTitle(isLoading: state.status == EnergyConsumptionByPhasesStatus.loading,),
EnergyConsumptionByPhasesTitle(
isLoading: state.status == EnergyConsumptionByPhasesStatus.loading,
),
const SizedBox(height: 20),
Expanded(
child: EnergyConsumptionByPhasesChart(
energyData: state.chartData,
@@ -12,6 +12,7 @@ class EnergyConsumptionPerDeviceChart extends StatelessWidget {
Widget build(BuildContext context) {
return LineChart(
LineChartData(
clipData: const FlClipData.vertical(),
titlesData: EnergyManagementChartsHelper.titlesData(
context,
leftTitlesInterval: 250,
@@ -23,7 +23,6 @@ class EnergyConsumptionPerDeviceChartBox extends StatelessWidget {
),
padding: const EdgeInsets.all(30),
child: Column(
spacing: 20,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AnalyticsErrorWidget(state.errorMessage),
@@ -52,7 +51,9 @@ class EnergyConsumptionPerDeviceChartBox extends StatelessWidget {
),
],
),
const SizedBox(height: 20),
const Divider(height: 0),
const SizedBox(height: 20),
Expanded(
child: EnergyConsumptionPerDeviceChart(chartData: state.chartData),
),
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:syncrow_web/pages/analytics/models/analytics_device.dart';
import 'package:syncrow_web/pages/analytics/models/device_energy_data_model.dart';
import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/pages/analytics/modules/analytics/widgets/chart_informative_cell.dart';
class EnergyConsumptionPerDeviceDevicesList extends StatelessWidget {
const EnergyConsumptionPerDeviceDevicesList({
@@ -41,43 +41,8 @@ class EnergyConsumptionPerDeviceDevicesList extends StatelessWidget {
.color;
return Tooltip(
message: '${device.name}\n${device.productDevice?.uuid ?? ''}',
child: Container(
height: MediaQuery.sizeOf(context).height * 0.0365,
padding: const EdgeInsetsDirectional.symmetric(
vertical: 8,
horizontal: 12,
),
decoration: BoxDecoration(
borderRadius: BorderRadiusDirectional.circular(8),
border: Border.all(
color: ColorsManager.greyColor,
width: 1,
),
),
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.center,
child: Row(
spacing: 6,
children: [
CircleAvatar(
radius: 4,
backgroundColor: deviceColor,
),
Text(
device.name,
textAlign: TextAlign.center,
style: const TextStyle(
color: ColorsManager.blackColor,
fontWeight: FontWeight.w400,
fontSize: 14,
),
),
],
),
),
),
message: '${device.name}\n${device.spaceUuid ?? ''}',
child: ChartInformativeCell(title: Text(device.name), color: deviceColor),
);
}
}
@@ -2,19 +2,16 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/analytics/models/power_clamp_energy_status.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/power_clamp_info/power_clamp_info_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/modules/energy_management/helpers/fetch_energy_management_data_helper.dart';
import 'package:syncrow_web/pages/analytics/modules/energy_management/widgets/analytics_device_dropdown.dart';
import 'package:syncrow_web/pages/analytics/modules/energy_management/widgets/energy_consumption_by_phases_chart_box.dart';
import 'package:syncrow_web/pages/analytics/modules/energy_management/widgets/power_clamp_energy_status_widget.dart';
import 'package:syncrow_web/pages/analytics/modules/energy_management/widgets/power_clamp_phases_data_widget.dart';
import 'package:syncrow_web/pages/analytics/widgets/analytics_error_widget.dart';
import 'package:syncrow_web/pages/analytics/widgets/analytics_sidebar_header.dart';
import 'package:syncrow_web/pages/device_managment/power_clamp/models/power_clamp_model.dart';
import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/utils/constants/assets.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart';
import 'package:syncrow_web/utils/style.dart';
class PowerClampEnergyDataWidget extends StatelessWidget {
@@ -42,26 +39,18 @@ class PowerClampEnergyDataWidget extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AnalyticsErrorWidget(state.errorMessage),
_buildHeader(context),
Text(
'Device ID:',
style: context.textTheme.bodySmall?.copyWith(
color: ColorsManager.textPrimaryColor,
fontWeight: FontWeight.w400,
fontSize: 12,
),
AnalyticsSidebarHeader(
title: 'Smart Power Clamp',
showSpaceUuidInDevicesDropdown: true,
onChanged: (device) {
FetchEnergyManagementDataHelper.loadEnergyConsumptionByPhases(
context,
powerClampUuid: device.uuid,
selectedDate:
context.read<AnalyticsDatePickerBloc>().state.monthlyDate,
);
},
),
const SizedBox(height: 6),
SelectableText(
context.watch<AnalyticsDevicesBloc>().state.selectedDevice?.uuid ??
'N/A',
style: context.textTheme.bodySmall?.copyWith(
color: ColorsManager.blackColor,
fontWeight: FontWeight.w400,
fontSize: 12,
),
),
const Divider(),
Expanded(
flex: 2,
child: PowerClampEnergyStatusWidget(
@@ -69,13 +58,18 @@ class PowerClampEnergyDataWidget extends StatelessWidget {
PowerClampEnergyStatus(
iconPath: Assets.powerActiveIcon,
title: 'Active',
value: _valueFromCode('EnergyConsumed', generalDataPoints),
value: _valueFromCode('ActivePower', generalDataPoints),
unit: 'W',
),
PowerClampEnergyStatus(
iconPath: Assets.voltMeterIcon,
title: 'Current',
value: _valueFromCode('Current', generalDataPoints),
value: _valueFromCode('Current', generalDataPoints)
.replaceAllMapped(
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
(Match m) => '${m[1]}.',
)
.replaceAll('.0', ''),
unit: 'A',
),
PowerClampEnergyStatus(
@@ -106,51 +100,6 @@ class PowerClampEnergyDataWidget extends StatelessWidget {
);
}
Widget _buildHeader(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
flex: 3,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: AlignmentDirectional.centerStart,
child: SelectableText(
'Smart Power Clamp',
style: context.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
color: ColorsManager.vividBlue.withValues(alpha: 0.6),
fontSize: 18,
),
),
),
),
const Spacer(),
Expanded(
flex: 2,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: AlignmentDirectional.centerEnd,
child: AnalyticsDeviceDropdown(
onChanged: (value) {
FetchEnergyManagementDataHelper.loadEnergyConsumptionByPhases(
context,
powerClampUuid: value.uuid,
selectedDate:
context.read<AnalyticsDatePickerBloc>().state.monthlyDate,
);
FetchEnergyManagementDataHelper.loadRealtimeDeviceChanges(
context,
deviceUuid: value.uuid,
);
},
),
),
),
],
);
}
String _valueFromCode(String code, List<DataPoint> points) {
return points
.firstWhere((e) => e.code == code, orElse: () => DataPoint(value: '--'))
@@ -55,7 +55,7 @@ class PowerClampPhasesDataWidget extends StatelessWidget {
iconPath: Assets.powerActiveIcon,
title: 'Active Power',
value: _valueFromCode(
code: 'ReactivePower$phaseSuffix',
code: 'ActivePower$phaseSuffix',
points: phase?.dataPoints,
),
unit: 'W',
@@ -125,7 +125,48 @@ class PowerClampPhasesDataWidget extends StatelessWidget {
(e) => e.code == code,
orElse: () => DataPoint(value: '--'),
);
final value = element?.value;
if (code.contains('Current')) {
return _formatCurrentValue(value?.toString());
}
if (code.contains('PowerFactor')) {
return _formatPowerFactor(value?.toString());
}
if (code.contains('Voltage')) {
return _formatVoltage(value?.toString());
}
return value?.toString() ?? '--';
}
return element?.value.toString() ?? '--';
String _formatCurrentValue(String? value) {
if (value == null) return '--';
String str = value;
if (str.isEmpty || str == '--') return '--';
str = str.replaceAll(RegExp(r'[^0-9]'), '');
if (str.isEmpty) return '--';
if (str.length == 1) return '${str[0]}.0';
return '${str[0]}.${str.substring(1)}';
}
String _formatPowerFactor(String? value) {
if (value == null) return '--';
String str = value;
if (str.isEmpty || str == '--') return '--';
str = str.replaceAll(RegExp(r'[^0-9]'), '');
if (str.isEmpty) return '--';
final intValue = int.tryParse(str);
if (intValue == null) return '--';
final doubleValue = intValue / 100;
return doubleValue.toStringAsFixed(2);
}
String _formatVoltage(String? value) {
if (value == null) return '--';
String str = value;
if (str.isEmpty || str == '--') return '--';
str = str.replaceAll(RegExp(r'[^0-9]'), '');
if (str.isEmpty) return '--';
if (str.length == 1) return '0.${str[0]}';
return '${str.substring(0, str.length - 1)}.${str.substring(str.length - 1)}';
}
}
@@ -14,6 +14,7 @@ class TotalEnergyConsumptionChart extends StatelessWidget {
return Expanded(
child: LineChart(
LineChartData(
clipData: const FlClipData.vertical(),
titlesData: EnergyManagementChartsHelper.titlesData(
context,
leftTitlesInterval: 250,
@@ -28,6 +29,7 @@ class TotalEnergyConsumptionChart extends StatelessWidget {
),
duration: Duration.zero,
curve: Curves.easeIn,
),
);
}
@@ -35,9 +37,6 @@ class TotalEnergyConsumptionChart extends StatelessWidget {
List<LineChartBarData> get _lineBarsData {
return [
LineChartBarData(
preventCurveOvershootingThreshold: 0.1,
curveSmoothness: 0.55,
preventCurveOverShooting: true,
spots: chartData
.asMap()
.entries
@@ -19,7 +19,6 @@ class TotalEnergyConsumptionChartBox extends StatelessWidget {
),
padding: const EdgeInsets.all(30),
child: Column(
spacing: 20,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AnalyticsErrorWidget(state.errorMessage),
@@ -39,7 +38,9 @@ class TotalEnergyConsumptionChartBox extends StatelessWidget {
const Spacer(flex: 4),
],
),
const SizedBox(height: 20),
const Divider(),
const SizedBox(height: 20),
TotalEnergyConsumptionChart(chartData: state.chartData),
],
),
@@ -3,6 +3,7 @@ import 'package:equatable/equatable.dart';
import 'package:syncrow_web/pages/analytics/models/occupacy.dart';
import 'package:syncrow_web/pages/analytics/params/get_occupancy_param.dart';
import 'package:syncrow_web/pages/analytics/services/occupacy/occupacy_service.dart';
import 'package:syncrow_web/services/api/api_exception.dart';
part 'occupancy_event.dart';
part 'occupancy_state.dart';
@@ -23,6 +24,8 @@ class OccupancyBloc extends Bloc<OccupancyEvent, OccupancyState> {
try {
final chartData = await _occupacyService.load(event.param);
emit(state.copyWith(chartData: chartData, status: OccupancyStatus.loaded));
} on APIException catch (e) {
emit(state.copyWith(status: OccupancyStatus.failure, errorMessage: e.message));
} catch (e) {
emit(state.copyWith(status: OccupancyStatus.failure, errorMessage: '$e'));
}
@@ -3,6 +3,7 @@ import 'package:equatable/equatable.dart';
import 'package:syncrow_web/pages/analytics/models/occupancy_heat_map_model.dart';
import 'package:syncrow_web/pages/analytics/params/get_occupancy_heat_map_param.dart';
import 'package:syncrow_web/pages/analytics/services/occupancy_heat_map/occupancy_heat_map_service.dart';
import 'package:syncrow_web/services/api/api_exception.dart';
part 'occupancy_heat_map_event.dart';
part 'occupancy_heat_map_state.dart';
@@ -30,6 +31,13 @@ class OccupancyHeatMapBloc
heatMapData: occupancyHeatMap,
),
);
} on APIException catch (e) {
emit(
state.copyWith(
status: OccupancyHeatMapStatus.failure,
errorMessage: e.message,
),
);
} catch (e) {
emit(
state.copyWith(
@@ -16,7 +16,7 @@ class OccupancyChart extends StatelessWidget {
Widget build(BuildContext context) {
return BarChart(
BarChartData(
maxY: 100.0,
maxY: 100.001,
gridData: EnergyManagementChartsHelper.gridData().copyWith(
checkToShowHorizontalLine: (value) => true,
horizontalInterval: 20,
@@ -134,7 +134,7 @@ class OccupancyChart extends StatelessWidget {
alignment: AlignmentDirectional.bottomCenter,
fit: BoxFit.scaleDown,
child: Text(
(value + 1).toString(),
chartData[value.toInt()].date.day.toString(),
style: context.textTheme.bodySmall?.copyWith(
color: ColorsManager.greyColor,
fontSize: 8,
@@ -22,7 +22,6 @@ class OccupancyChartBox extends StatelessWidget {
padding: const EdgeInsets.all(30),
decoration: containerWhiteDecoration,
child: Column(
spacing: 20,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -65,7 +64,9 @@ class OccupancyChartBox extends StatelessWidget {
),
],
),
const Divider(height: 0),
const SizedBox(height: 20),
const Divider(),
const SizedBox(height: 20),
Expanded(child: OccupancyChart(chartData: state.chartData)),
],
),
@@ -1,15 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/analytics/models/power_clamp_energy_status.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/modules/energy_management/helpers/fetch_energy_management_data_helper.dart';
import 'package:syncrow_web/pages/analytics/modules/energy_management/widgets/analytics_device_dropdown.dart';
import 'package:syncrow_web/pages/analytics/modules/energy_management/widgets/power_clamp_energy_status_widget.dart';
import 'package:syncrow_web/pages/analytics/widgets/analytics_sidebar_header.dart';
import 'package:syncrow_web/pages/device_managment/all_devices/models/device_status.dart';
import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/utils/constants/assets.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart';
import 'package:syncrow_web/utils/style.dart';
class OccupancyEndSideBar extends StatelessWidget {
@@ -27,28 +23,7 @@ class OccupancyEndSideBar extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(context),
Text(
'Device ID:',
style: context.textTheme.bodySmall?.copyWith(
color: ColorsManager.textPrimaryColor,
fontWeight: FontWeight.w400,
fontSize: 12,
),
),
const SizedBox(height: 6),
SelectableText(
context.watch<AnalyticsDevicesBloc>().state.selectedDevice?.uuid ??
'N/A',
style: context.textTheme.bodySmall?.copyWith(
color: ColorsManager.blackColor,
fontWeight: FontWeight.w400,
fontSize: 12,
),
),
const SizedBox(height: 10),
const Divider(height: 1, color: ColorsManager.greyColor),
const SizedBox(height: 50),
const AnalyticsSidebarHeader(title: 'Presnce Sensor'),
SizedBox(
height: MediaQuery.sizeOf(context).height * 0.2,
child: PowerClampEnergyStatusWidget(
@@ -101,42 +76,4 @@ class OccupancyEndSideBar extends StatelessWidget {
.toString();
return value == 'null' ? defaultValue ?? '--' : value;
}
Widget _buildHeader(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
flex: 3,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: AlignmentDirectional.centerStart,
child: SelectableText(
'Presnce Sensor',
style: context.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
color: ColorsManager.vividBlue.withValues(alpha: 0.6),
fontSize: 18,
),
),
),
),
const Spacer(),
Expanded(
flex: 2,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: AlignmentDirectional.centerEnd,
child: AnalyticsDeviceDropdown(
onChanged: (value) =>
FetchEnergyManagementDataHelper.loadRealtimeDeviceChanges(
context,
deviceUuid: value.uuid,
),
),
),
),
],
);
}
}
@@ -22,7 +22,6 @@ class OccupancyHeatMapBox extends StatelessWidget {
padding: const EdgeInsets.all(30),
decoration: containerWhiteDecoration,
child: Column(
spacing: 20,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -66,7 +65,9 @@ class OccupancyHeatMapBox extends StatelessWidget {
),
],
),
const Divider(height: 0),
const SizedBox(height: 20),
const Divider(),
const SizedBox(height: 20),
Expanded(
child: OccupancyHeatMap(
heatMapData: state.heatMapData.asMap().map(
@@ -0,0 +1,9 @@
class GetAirQualityDistributionParam {
final DateTime date;
final String spaceUuid;
const GetAirQualityDistributionParam({
required this.date,
required this.spaceUuid,
});
}
@@ -0,0 +1,11 @@
class GetDeviceLocationDataParam {
const GetDeviceLocationDataParam({
required this.latitude,
required this.longitude,
});
final double latitude;
final double longitude;
Map<String, dynamic> toJson() => {'lat': latitude, 'lon': longitude};
}
@@ -2,18 +2,15 @@ class GetEnergyConsumptionPerDeviceParam {
const GetEnergyConsumptionPerDeviceParam({
this.monthDate,
this.spaceId,
this.communityId,
});
final DateTime? monthDate;
final String? spaceId;
final String? communityId;
Map<String, dynamic> toJson() => {
'monthDate':
'${monthDate?.year}-${monthDate?.month.toString().padLeft(2, '0')}',
if (spaceId == null || spaceId == null) 'spaceUuid': spaceId,
'communityUuid': communityId,
if (spaceId != null) 'spaceUuid': spaceId,
'groupByDevice': true,
};
}
@@ -0,0 +1,14 @@
import 'package:equatable/equatable.dart';
class GetRangeOfAqiParam extends Equatable {
final DateTime date;
final String spaceUuid;
const GetRangeOfAqiParam({
required this.date,
required this.spaceUuid,
});
@override
List<Object?> get props => [date, spaceUuid];
}
@@ -1,20 +1,17 @@
class GetTotalEnergyConsumptionParam {
final DateTime? monthDate;
final String? spaceId;
final String? communityId;
const GetTotalEnergyConsumptionParam({
this.monthDate,
this.spaceId,
this.communityId,
});
Map<String, dynamic> toJson() {
return {
'monthDate':
'${monthDate?.year}-${monthDate?.month.toString().padLeft(2, '0')}',
if (spaceId == null || spaceId == null) 'spaceUuid': spaceId,
'communityUuid': communityId,
if (spaceId != null) 'spaceUuid': spaceId,
'groupByDevice': false,
};
}
@@ -0,0 +1,8 @@
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';
abstract interface class AirQualityDistributionService {
Future<List<AirQualityDataModel>> getAirQualityDistribution(
GetAirQualityDistributionParam param,
);
}
@@ -0,0 +1,95 @@
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';
class FakeAirQualityDistributionService implements AirQualityDistributionService {
final _random = Random();
@override
Future<List<AirQualityDataModel>> getAirQualityDistribution(
GetAirQualityDistributionParam param,
) async {
return Future.delayed(
const Duration(milliseconds: 400),
() => List.generate(30, (index) {
final date = DateTime(2025, 5, 1).add(Duration(days: index));
final values = _generateRandomPercentages();
final nullMask = List.generate(6, (_) => _shouldBeNull());
if (nullMask.every((isNull) => isNull)) {
nullMask[_random.nextInt(6)] = false;
}
final nonNullValues = _redistributePercentages(values, nullMask);
return AirQualityDataModel(
date: date,
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,
) {
double nonNullSum = 0;
for (int i = 0; i < originalValues.length; i++) {
if (!nullMask[i]) {
nonNullSum += originalValues[i];
}
}
return List.generate(originalValues.length, (i) {
if (nullMask[i]) return 0;
return (originalValues[i] / nonNullSum * 100).roundToDouble();
});
}
bool _shouldBeNull() => _random.nextDouble() < 0.6;
List<double> _generateRandomPercentages() {
final values = List.generate(6, (_) => _random.nextDouble());
final sum = values.reduce((a, b) => a + b);
return values.map((value) => (value / sum * 100).roundToDouble()).toList();
}
}
@@ -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');
}
}
}
@@ -1,6 +1,8 @@
import 'package:dio/dio.dart';
import 'package:syncrow_web/pages/analytics/models/analytics_device.dart';
import 'package:syncrow_web/pages/analytics/params/get_analytics_devices_param.dart';
import 'package:syncrow_web/pages/analytics/services/analytics_devices/analytics_devices_service.dart';
import 'package:syncrow_web/services/api/api_exception.dart';
import 'package:syncrow_web/services/api/http_service.dart';
final class RemoteEnergyManagementAnalyticsDevicesService
@@ -9,6 +11,8 @@ final class RemoteEnergyManagementAnalyticsDevicesService
final HTTPService _httpService;
static const _defaultErrorMessage = 'Failed to load analytics devices';
@override
Future<List<AnalyticsDevice>> getDevices(GetAnalyticsDevicesParam param) async {
try {
@@ -29,8 +33,14 @@ final class RemoteEnergyManagementAnalyticsDevicesService
);
return response;
} on DioException catch (e) {
final message = e.response?.data as Map<String, dynamic>?;
final error = message?['error'] as Map<String, dynamic>?;
final errorMessage = error?['error'] as String? ?? '';
final formattedErrorMessage = [_defaultErrorMessage, errorMessage].join(': ');
throw APIException(formattedErrorMessage);
} catch (e) {
throw Exception('Failed to load total energy consumption: $e');
throw APIException('$_defaultErrorMessage: $e');
}
}
}
@@ -1,7 +1,9 @@
import 'package:dio/dio.dart';
import 'package:syncrow_web/pages/analytics/models/analytics_device.dart';
import 'package:syncrow_web/pages/analytics/params/get_analytics_devices_param.dart';
import 'package:syncrow_web/pages/analytics/services/analytics_devices/analytics_devices_service.dart';
import 'package:syncrow_web/pages/common/bloc/project_manager.dart';
import 'package:syncrow_web/services/api/api_exception.dart';
import 'package:syncrow_web/services/api/http_service.dart';
class RemoteOccupancyAnalyticsDevicesService implements AnalyticsDevicesService {
@@ -9,6 +11,8 @@ class RemoteOccupancyAnalyticsDevicesService implements AnalyticsDevicesService
final HTTPService _httpService;
static const _defaultErrorMessage = 'Failed to load analytics devices';
@override
Future<List<AnalyticsDevice>> getDevices(GetAnalyticsDevicesParam param) async {
try {
@@ -26,8 +30,15 @@ class RemoteOccupancyAnalyticsDevicesService implements AnalyticsDevicesService
final result = requests.map((e) => e.first).toList();
return result;
} on DioException catch (e) {
final message = e.response?.data as Map<String, dynamic>?;
final error = message?['error'] as Map<String, dynamic>?;
final errorMessage = error?['error'] as String? ?? '';
final formattedErrorMessage = [_defaultErrorMessage, errorMessage].join(': ');
throw APIException(formattedErrorMessage);
} catch (e) {
throw Exception('Failed to load total energy consumption: $e');
final formattedErrorMessage = [_defaultErrorMessage, e.toString()].join(': ');
throw APIException(formattedErrorMessage);
}
}
@@ -54,8 +65,14 @@ class RemoteOccupancyAnalyticsDevicesService implements AnalyticsDevicesService
},
);
return response;
} on DioException catch (e) {
final message = e.response?.data as Map<String, dynamic>?;
final error = message?['error'] as Map<String, dynamic>?;
final errorMessage = error?['error'] as String? ?? '';
final formattedErrorMessage = [_defaultErrorMessage, errorMessage].join(': ');
throw APIException(formattedErrorMessage);
} catch (e) {
rethrow;
throw APIException('$_defaultErrorMessage: $e');
}
}
}
@@ -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<DeviceLocationInfo> get(GetDeviceLocationDataParam param) async {
try {
final deviceLocationInfo = await _decoratee.get(param);
final response = await _dio.get<Map<String, dynamic>>(
'reverse',
queryParameters: {
'format': 'json',
'lat': param.latitude,
'lon': param.longitude,
},
);
final data = response.data;
if (data != null) {
final addressData = data['address'] as Map<String, dynamic>;
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()}');
}
}
}
@@ -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<DeviceLocationInfo> get(GetDeviceLocationDataParam param);
}
@@ -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<DeviceLocationInfo> get(GetDeviceLocationDataParam param) async {
try {
final response = await _httpService.get(
path: '/weather',
queryParameters: param.toJson(),
expectedResponseModel: (data) {
final response = data as Map<String, dynamic>;
final location = response['data'] as Map<String, dynamic>;
return DeviceLocationInfo.fromJson(location);
},
);
return response;
} on DioException catch (e) {
final message = e.response?.data as Map<String, dynamic>?;
final error = message?['error'] as Map<String, dynamic>?;
final errorMessage = error?['error'] as String? ?? '';
throw Exception(errorMessage);
} catch (e) {
throw Exception('$_defaultErrorMessage: $e');
}
}
}
@@ -1,6 +1,8 @@
import 'package:dio/dio.dart';
import 'package:syncrow_web/pages/analytics/models/phases_energy_consumption.dart';
import 'package:syncrow_web/pages/analytics/params/get_energy_consumption_by_phases_param.dart';
import 'package:syncrow_web/pages/analytics/services/energy_consumption_by_phases/energy_consumption_by_phases_service.dart';
import 'package:syncrow_web/services/api/api_exception.dart';
import 'package:syncrow_web/services/api/http_service.dart';
final class RemoteEnergyConsumptionByPhasesService
@@ -9,6 +11,8 @@ final class RemoteEnergyConsumptionByPhasesService
final HTTPService _httpService;
static const _defaultErrorMessage = 'Failed to load energy consumption per phase';
@override
Future<List<PhasesEnergyConsumption>> load(
GetEnergyConsumptionByPhasesParam param,
@@ -28,8 +32,15 @@ final class RemoteEnergyConsumptionByPhasesService
},
);
return response;
} on DioException catch (e) {
final message = e.response?.data as Map<String, dynamic>?;
final error = message?['error'] as Map<String, dynamic>?;
final errorMessage = error?['error'] as String? ?? '';
final formattedErrorMessage = [_defaultErrorMessage, errorMessage].join(': ');
throw APIException(formattedErrorMessage);
} catch (e) {
throw Exception('Failed to load energy consumption per phase: $e');
final formattedErrorMessage = [_defaultErrorMessage, '$e'].join(': ');
throw APIException(formattedErrorMessage);
}
}
}
@@ -1,8 +1,10 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:syncrow_web/pages/analytics/models/device_energy_data_model.dart';
import 'package:syncrow_web/pages/analytics/models/energy_data_model.dart';
import 'package:syncrow_web/pages/analytics/params/get_energy_consumption_per_device_param.dart';
import 'package:syncrow_web/pages/analytics/services/energy_consumption_per_device/energy_consumption_per_device_service.dart';
import 'package:syncrow_web/services/api/api_exception.dart';
import 'package:syncrow_web/services/api/http_service.dart';
class RemoteEnergyConsumptionPerDeviceService
@@ -11,6 +13,8 @@ class RemoteEnergyConsumptionPerDeviceService
final HTTPService _httpService;
static const _defaultErrorMessage = 'Failed to load energy consumption per device';
@override
Future<List<DeviceEnergyDataModel>> load(
GetEnergyConsumptionPerDeviceParam param,
@@ -23,8 +27,15 @@ class RemoteEnergyConsumptionPerDeviceService
expectedResponseModel: _EnergyConsumptionPerDeviceMapper.map,
);
return response;
} on DioException catch (e) {
final message = e.response?.data as Map<String, dynamic>?;
final error = message?['error'] as Map<String, dynamic>?;
final errorMessage = error?['error'] as String? ?? '';
final formattedErrorMessage = [_defaultErrorMessage, errorMessage].join(': ');
throw APIException(formattedErrorMessage);
} catch (e) {
throw Exception('Failed to load energy consumption per device: $e');
final formattedErrorMessage = [_defaultErrorMessage, '$e'].join(': ');
throw APIException(formattedErrorMessage);
}
}
}
@@ -1,6 +1,8 @@
import 'package:dio/dio.dart';
import 'package:syncrow_web/pages/analytics/models/occupacy.dart';
import 'package:syncrow_web/pages/analytics/params/get_occupancy_param.dart';
import 'package:syncrow_web/pages/analytics/services/occupacy/occupacy_service.dart';
import 'package:syncrow_web/services/api/api_exception.dart';
import 'package:syncrow_web/services/api/http_service.dart';
final class RemoteOccupancyService implements OccupacyService {
@@ -8,6 +10,8 @@ final class RemoteOccupancyService implements OccupacyService {
final HTTPService _httpService;
static const _defaultErrorMessage = 'Failed to load occupancy';
@override
Future<List<Occupacy>> load(GetOccupancyParam param) async {
try {
@@ -25,8 +29,15 @@ final class RemoteOccupancyService implements OccupacyService {
},
);
return response;
} on DioException catch (e) {
final message = e.response?.data as Map<String, dynamic>?;
final error = message?['error'] as Map<String, dynamic>?;
final errorMessage = error?['error'] as String? ?? '';
final formattedErrorMessage = [_defaultErrorMessage, errorMessage].join(': ');
throw APIException(formattedErrorMessage);
} catch (e) {
throw Exception('Failed to load energy consumption per phase: $e');
final formattedErrorMessage = [_defaultErrorMessage, '$e'].join(': ');
throw APIException(formattedErrorMessage);
}
}
}
}
@@ -1,6 +1,8 @@
import 'package:dio/dio.dart';
import 'package:syncrow_web/pages/analytics/models/occupancy_heat_map_model.dart';
import 'package:syncrow_web/pages/analytics/params/get_occupancy_heat_map_param.dart';
import 'package:syncrow_web/pages/analytics/services/occupancy_heat_map/occupancy_heat_map_service.dart';
import 'package:syncrow_web/services/api/api_exception.dart';
import 'package:syncrow_web/services/api/http_service.dart';
final class RemoteOccupancyHeatMapService implements OccupancyHeatMapService {
@@ -8,6 +10,8 @@ final class RemoteOccupancyHeatMapService implements OccupancyHeatMapService {
final HTTPService _httpService;
static const _defaultErrorMessage = 'Failed to load occupancy heat map';
@override
Future<List<OccupancyHeatMapModel>> load(GetOccupancyHeatMapParam param) async {
try {
@@ -28,8 +32,15 @@ final class RemoteOccupancyHeatMapService implements OccupancyHeatMapService {
);
return response;
} on DioException catch (e) {
final message = e.response?.data as Map<String, dynamic>?;
final error = message?['error'] as Map<String, dynamic>?;
final errorMessage = error?['error'] as String? ?? '';
final formattedErrorMessage = [_defaultErrorMessage, errorMessage].join(': ');
throw APIException(formattedErrorMessage);
} catch (e) {
throw Exception('Failed to load total energy consumption:');
final formattedErrorMessage = [_defaultErrorMessage, '$e'].join(': ');
throw APIException(formattedErrorMessage);
}
}
}
@@ -1,5 +1,7 @@
import 'package:dio/dio.dart';
import 'package:syncrow_web/pages/analytics/services/power_clamp_info/power_clamp_info_service.dart';
import 'package:syncrow_web/pages/device_managment/power_clamp/models/power_clamp_model.dart';
import 'package:syncrow_web/services/api/api_exception.dart';
import 'package:syncrow_web/services/api/http_service.dart';
final class RemotePowerClampInfoService implements PowerClampInfoService {
@@ -7,6 +9,8 @@ final class RemotePowerClampInfoService implements PowerClampInfoService {
final HTTPService _httpService;
static const _defaultErrorMessage = 'Failed to fetch power clamp info';
@override
Future<PowerClampModel> getInfo(String deviceId) async {
try {
@@ -20,8 +24,15 @@ final class RemotePowerClampInfoService implements PowerClampInfoService {
},
);
return response;
} on DioException catch (e) {
final message = e.response?.data as Map<String, dynamic>?;
final error = message?['error'] as Map<String, dynamic>?;
final errorMessage = error?['error'] as String? ?? '';
final formattedErrorMessage = [_defaultErrorMessage, errorMessage].join(': ');
throw APIException(formattedErrorMessage);
} catch (e) {
throw Exception('Failed to fetch power clamp info: $e');
final formattedErrorMessage = [_defaultErrorMessage, '$e'].join(': ');
throw APIException(formattedErrorMessage);
}
}
}
@@ -0,0 +1,36 @@
import 'package:syncrow_web/pages/analytics/models/range_of_aqi.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/widgets/aqi_type_dropdown.dart';
import 'package:syncrow_web/pages/analytics/params/get_range_of_aqi_param.dart';
import 'package:syncrow_web/pages/analytics/services/range_of_aqi/range_of_aqi_service.dart';
class FakeRangeOfAqiService implements RangeOfAqiService {
@override
Future<List<RangeOfAqi>> load(GetRangeOfAqiParam param) async {
return await Future.delayed(const Duration(milliseconds: 800), () {
final random = DateTime.now().millisecondsSinceEpoch;
return List.generate(30, (index) {
final date = DateTime(2025, 5, 1).add(Duration(days: index));
final min = ((random + index * 17) % 200).toDouble();
final avgDelta = ((random + index * 23) % 50).toDouble() + 20;
final maxDelta = ((random + index * 31) % 50).toDouble() + 30;
final avg = (min + avgDelta).clamp(0.0, 301.0);
final max = (avg + maxDelta).clamp(0.0, 301.0);
return RangeOfAqi(
data: [
RangeOfAqiValue(type: AqiType.aqi.code, min: min, average: avg, max: max),
RangeOfAqiValue(type: AqiType.pm25.code, min: min, average: avg, max: max),
RangeOfAqiValue(type: AqiType.pm10.code, min: min, average: avg, max: max),
RangeOfAqiValue(type: AqiType.hcho.code, min: min, average: avg, max: max),
RangeOfAqiValue(type: AqiType.tvoc.code, min: min, average: avg, max: max),
RangeOfAqiValue(type: AqiType.co2.code, min: min, average: avg, max: max),
],
date: date,
);
});
});
}
}
@@ -0,0 +1,6 @@
import 'package:syncrow_web/pages/analytics/models/range_of_aqi.dart';
import 'package:syncrow_web/pages/analytics/params/get_range_of_aqi_param.dart';
abstract interface class RangeOfAqiService {
Future<List<RangeOfAqi>> load(GetRangeOfAqiParam param);
}
@@ -0,0 +1,34 @@
import 'package:syncrow_web/pages/analytics/models/range_of_aqi.dart';
import 'package:syncrow_web/pages/analytics/params/get_range_of_aqi_param.dart';
import 'package:syncrow_web/pages/analytics/services/range_of_aqi/range_of_aqi_service.dart';
import 'package:syncrow_web/services/api/http_service.dart';
final class RemoteRangeOfAqiService implements RangeOfAqiService {
const RemoteRangeOfAqiService(this._httpService);
final HTTPService _httpService;
@override
Future<List<RangeOfAqi>> load(GetRangeOfAqiParam 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 RangeOfAqi.fromJson(jsonData);
}).toList();
},
);
return response;
} catch (e) {
throw Exception('Failed to load energy consumption per phase: $e');
}
}
}
@@ -1,6 +1,8 @@
import 'package:dio/dio.dart';
import 'package:syncrow_web/pages/analytics/models/energy_data_model.dart';
import 'package:syncrow_web/pages/analytics/params/get_total_energy_consumption_param.dart';
import 'package:syncrow_web/pages/analytics/services/total_energy_consumption/total_energy_consumption_service.dart';
import 'package:syncrow_web/services/api/api_exception.dart';
import 'package:syncrow_web/services/api/http_service.dart';
class RemoteTotalEnergyConsumptionService implements TotalEnergyConsumptionService {
@@ -8,6 +10,8 @@ class RemoteTotalEnergyConsumptionService implements TotalEnergyConsumptionServi
final HTTPService _httpService;
static const _defaultErrorMessage = 'Failed to load total energy consumption';
@override
Future<List<EnergyDataModel>> load(
GetTotalEnergyConsumptionParam param,
@@ -21,8 +25,15 @@ class RemoteTotalEnergyConsumptionService implements TotalEnergyConsumptionServi
);
return response;
} on DioException catch (e) {
final message = e.response?.data as Map<String, dynamic>?;
final error = message?['error'] as Map<String, dynamic>?;
final errorMessage = error?['error'] as String? ?? '';
final formattedErrorMessage = [_defaultErrorMessage, errorMessage].join(': ');
throw APIException(formattedErrorMessage);
} catch (e) {
throw Exception('Failed to load total energy consumption: $e');
final formattedErrorMessage = [_defaultErrorMessage, '$e'].join(': ');
throw APIException(formattedErrorMessage);
}
}
}
@@ -11,14 +11,17 @@ class AnalyticsErrorWidget extends StatelessWidget {
Widget build(BuildContext context) {
return Visibility(
visible: errorMessage != null || (errorMessage?.isNotEmpty ?? false),
child: Text(
errorMessage ?? 'Something went wrong',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodySmall?.copyWith(
color: ColorsManager.red,
fontWeight: FontWeight.w400,
fontSize: 8,
child: Padding(
padding: const EdgeInsetsDirectional.only(bottom: 10),
child: Text(
errorMessage ?? 'Something went wrong',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodySmall?.copyWith(
color: ColorsManager.red,
fontWeight: FontWeight.w400,
fontSize: 8,
),
),
),
);
@@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/analytics/models/analytics_device.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/helpers/fetch_energy_management_data_helper.dart';
import 'package:syncrow_web/pages/analytics/modules/energy_management/widgets/analytics_device_dropdown.dart';
import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart';
class AnalyticsSidebarHeader extends StatelessWidget {
const AnalyticsSidebarHeader({
required this.title,
this.showSpaceUuidInDevicesDropdown = false,
this.onChanged,
super.key,
});
final String title;
final bool showSpaceUuidInDevicesDropdown;
final void Function(AnalyticsDevice device)? onChanged;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
flex: 2,
child: FittedBox(
alignment: AlignmentDirectional.centerStart,
fit: BoxFit.scaleDown,
child: SelectableText(
title,
style: context.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
color: ColorsManager.vividBlue.withValues(alpha: 0.6),
fontSize: 18,
),
),
),
),
const Spacer(),
Expanded(
flex: 2,
child: FittedBox(
alignment: AlignmentDirectional.centerEnd,
fit: BoxFit.scaleDown,
child: AnalyticsDeviceDropdown(
showSpaceUuid: showSpaceUuidInDevicesDropdown,
onChanged: (value) {
context.read<AnalyticsDevicesBloc>().add(
SelectAnalyticsDeviceEvent(value),
);
FetchEnergyManagementDataHelper.loadRealtimeDeviceChanges(
context,
deviceUuid: value.uuid,
);
onChanged?.call(value);
},
),
),
),
],
),
Text(
'Device ID:',
style: context.textTheme.bodySmall?.copyWith(
color: ColorsManager.textPrimaryColor,
fontWeight: FontWeight.w400,
fontSize: 12,
),
),
const SizedBox(height: 6),
SelectableText(
context.watch<AnalyticsDevicesBloc>().state.selectedDevice?.uuid ?? 'N/A',
style: context.textTheme.bodySmall?.copyWith(
color: ColorsManager.blackColor,
fontWeight: FontWeight.w400,
fontSize: 12,
),
),
const SizedBox(height: 10),
const Divider(height: 1, color: ColorsManager.greyColor),
const SizedBox(height: 24),
],
);
}
}
+17 -14
View File
@@ -13,6 +13,7 @@ import 'package:syncrow_web/pages/common/bloc/project_manager.dart';
import 'package:syncrow_web/pages/home/bloc/home_bloc.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';
import 'package:syncrow_web/services/api/api_exception.dart';
import 'package:syncrow_web/services/auth_api.dart';
import 'package:syncrow_web/utils/constants/strings_manager.dart';
import 'package:syncrow_web/utils/helpers/shared_preferences_helper.dart';
@@ -99,7 +100,8 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
emit(const TimerState(isButtonEnabled: true, remainingTime: 0));
}
Future<void> changePassword(ChangePasswordEvent event, Emitter<AuthState> emit) async {
Future<void> changePassword(
ChangePasswordEvent event, Emitter<AuthState> emit) async {
emit(LoadingForgetState());
try {
var response = await AuthenticationAPI.verifyOtp(
@@ -113,14 +115,14 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
emit(const TimerState(isButtonEnabled: true, remainingTime: 0));
emit(SuccessForgetState());
}
} on DioException catch (e) {
final errorData = e.response!.data;
String errorMessage = errorData['error']['message'] ?? 'something went wrong';
} on APIException catch (e) {
final errorMessage = e.message;
validate = errorMessage;
emit(AuthInitialState());
}
}
String? validateCode(String? value) {
if (value == null || value.isEmpty) {
return 'Code is required';
@@ -149,6 +151,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
static UserModel? user;
bool showValidationMessage = false;
void _login(LoginButtonPressed event, Emitter<AuthState> emit) async {
emit(AuthLoading());
if (isChecked) {
@@ -161,25 +164,24 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
token = await AuthenticationAPI.loginWithEmail(
model: LoginWithEmailModel(
email: event.username,
email: event.username.toLowerCase(),
password: event.password,
),
);
} on DioException catch (e) {
final errorData = e.response!.data;
String errorMessage = errorData['error']['message'];
if (errorMessage == "Access denied for web platform") {
validate = errorMessage;
} else {
validate = 'Invalid Credentials!';
}
} on APIException catch (e) {
validate = e.message;
emit(LoginInitial());
return;
} catch (e) {
validate = 'Something went wrong';
emit(LoginInitial());
return;
}
if (token.accessTokenIsNotEmpty) {
FlutterSecureStorage storage = const FlutterSecureStorage();
await storage.write(key: Token.loginAccessTokenKey, value: token.accessToken);
await storage.write(
key: Token.loginAccessTokenKey, value: token.accessToken);
const FlutterSecureStorage().write(
key: UserModel.userUuidKey,
value: Token.decodeToken(token.accessToken)['uuid'].toString());
@@ -195,6 +197,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
}
}
checkBoxToggle(
CheckBoxEvent event,
Emitter<AuthState> emit,
+184 -83
View File
@@ -21,6 +21,7 @@ class DynamicTable extends StatefulWidget {
final List<String>? initialSelectedIds;
final int uuidIndex;
final Function(dynamic selectedRows)? onSelectionChanged;
final Function(int rowIndex)? onSettingsPressed;
const DynamicTable({
super.key,
required this.headers,
@@ -37,6 +38,7 @@ class DynamicTable extends StatefulWidget {
this.initialSelectedIds,
required this.uuidIndex,
this.onSelectionChanged,
this.onSettingsPressed,
});
@override
@@ -48,11 +50,20 @@ class _DynamicTableState extends State<DynamicTable> {
bool _selectAll = false;
final ScrollController _verticalScrollController = ScrollController();
final ScrollController _horizontalScrollController = ScrollController();
late ScrollController _horizontalHeaderScrollController;
late ScrollController _horizontalBodyScrollController;
@override
void initState() {
super.initState();
_initializeSelection();
_horizontalHeaderScrollController = ScrollController();
_horizontalBodyScrollController = ScrollController();
// Synchronize horizontal scrolling
_horizontalBodyScrollController.addListener(() {
_horizontalHeaderScrollController
.jumpTo(_horizontalBodyScrollController.offset);
});
}
@override
@@ -102,101 +113,90 @@ class _DynamicTableState extends State<DynamicTable> {
context.read<DeviceManagementBloc>().add(UpdateSelection(_selectedRows));
}
@override
void dispose() {
_horizontalHeaderScrollController.dispose();
_horizontalBodyScrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
decoration: widget.cellDecoration,
child: Scrollbar(
controller: _verticalScrollController,
thumbVisibility: true,
trackVisibility: true,
child: Scrollbar(
controller: _horizontalScrollController,
thumbVisibility: true,
trackVisibility: true,
notificationPredicate: (notif) => notif.depth == 1,
child: SingleChildScrollView(
controller: _verticalScrollController,
child: Column(
children: [
Container(
decoration: widget.headerDecoration ??
const BoxDecoration(color: ColorsManager.boxColor),
child: SingleChildScrollView(
controller: _horizontalScrollController,
scrollDirection: Axis.horizontal,
physics: const NeverScrollableScrollPhysics(),
controller: _horizontalHeaderScrollController,
child: SizedBox(
width: widget.size.width,
child: Column(
child: Row(
children: [
Container(
decoration: widget.headerDecoration ??
const BoxDecoration(
color: ColorsManager.boxColor,
),
child: Row(
children: [
if (widget.withCheckBox) _buildSelectAllCheckbox(),
...List.generate(widget.headers.length, (index) {
return _buildTableHeaderCell(
widget.headers[index], index);
})
//...widget.headers.map((header) => _buildTableHeaderCell(header)),
],
),
),
widget.isEmpty
? SizedBox(
height: widget.size.height * 0.5,
width: widget.size.width,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Column(
children: [
SvgPicture.asset(Assets.emptyTable),
const SizedBox(
height: 15,
),
Text(
widget.tableName == 'AccessManagement'
? 'No Password '
: 'No Devices',
style: Theme.of(context)
.textTheme
.bodySmall!
.copyWith(
color:
ColorsManager.grayColor),
)
],
),
],
),
],
),
)
: Column(
children:
List.generate(widget.data.length, (index) {
final row = widget.data[index];
return Row(
children: [
if (widget.withCheckBox)
_buildRowCheckbox(
index, widget.size.height * 0.08),
...row.map((cell) => _buildTableCell(
cell.toString(),
widget.size.height * 0.08)),
],
);
}),
),
if (widget.withCheckBox) _buildSelectAllCheckbox(),
...List.generate(widget.headers.length, (index) {
return _buildTableHeaderCell(
widget.headers[index], index);
}),
],
),
),
),
),
),
Expanded(
child: Scrollbar(
controller: _verticalScrollController,
thumbVisibility: true,
trackVisibility: true,
child: SingleChildScrollView(
controller: _verticalScrollController,
child: Scrollbar(
controller: _horizontalBodyScrollController,
thumbVisibility: false,
trackVisibility: false,
notificationPredicate: (notif) => notif.depth == 1,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
controller: _horizontalBodyScrollController,
child: Container(
color: ColorsManager.whiteColors,
child: SizedBox(
width: widget.size.width,
child: widget.isEmpty
? _buildEmptyState()
: Column(
children: List.generate(widget.data.length,
(rowIndex) {
final row = widget.data[rowIndex];
return Row(
children: [
if (widget.withCheckBox)
_buildRowCheckbox(rowIndex,
widget.size.height * 0.08),
...row.asMap().entries.map((entry) {
return _buildTableCell(
entry.value.toString(),
widget.size.height * 0.08,
rowIndex: rowIndex,
columnIndex: entry.key,
);
}).toList(),
],
);
}),
),
),
),
),
),
),
),
),
],
),
);
}
@@ -218,6 +218,32 @@ class _DynamicTableState extends State<DynamicTable> {
);
}
Widget _buildEmptyState() => Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Column(
children: [
SvgPicture.asset(Assets.emptyTable),
const SizedBox(height: 15),
Text(
widget.tableName == 'AccessManagement'
? 'No Password '
: 'No Devices',
style: Theme.of(context)
.textTheme
.bodySmall!
.copyWith(color: ColorsManager.grayColor),
)
],
),
],
),
],
);
Widget _buildRowCheckbox(int index, double size) {
return Container(
width: 50,
@@ -272,13 +298,28 @@ class _DynamicTableState extends State<DynamicTable> {
);
}
Widget _buildTableCell(String content, double size) {
Widget _buildTableCell(
String content,
double size, {
required int rowIndex,
required int columnIndex,
}) {
bool isBatteryLevel = content.endsWith('%');
double? batteryLevel;
if (isBatteryLevel) {
batteryLevel = double.tryParse(content.replaceAll('%', '').trim());
}
bool isSettingsColumn = widget.headers[columnIndex] == 'Settings';
if (isSettingsColumn) {
return buildSettingsIcon(
width: 120,
height: 60,
iconSize: 40,
onTap: () => widget.onSettingsPressed?.call(rowIndex),
);
}
Color? statusColor;
switch (content) {
@@ -330,4 +371,64 @@ class _DynamicTableState extends State<DynamicTable> {
),
);
}
Widget buildSettingsIcon(
{double width = 120,
double height = 60,
double iconSize = 40,
VoidCallback? onTap}) {
return Column(
children: [
Container(
padding: const EdgeInsets.only(top: 10, bottom: 15, left: 10),
margin: const EdgeInsets.only(right: 15),
decoration: const BoxDecoration(
color: ColorsManager.whiteColors,
border: Border(
bottom: BorderSide(
color: ColorsManager.boxDivider,
width: 1.0,
),
),
),
width: width,
child: Padding(
padding: const EdgeInsets.only(
right: 16.0,
left: 17.0,
),
child: Container(
width: 50,
decoration: BoxDecoration(
color: const Color(0xFFF7F8FA),
borderRadius: BorderRadius.circular(height / 2),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.17),
blurRadius: 14,
offset: const Offset(0, 4),
),
],
),
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Center(
child: SvgPicture.asset(
Assets.settings, // ضع المسار الصحيح هنا
width: 40,
height: 22,
color: ColorsManager
.primaryColor, // نفس لون الأيقونة في الصورة
),
),
),
),
),
),
),
],
);
}
}
+149 -191
View File
@@ -1,21 +1,27 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:firebase_database/firebase_database.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/device_managment/all_devices/models/device_status.dart';
import 'package:syncrow_web/pages/device_managment/ac/bloc/ac_event.dart';
import 'package:syncrow_web/pages/device_managment/ac/bloc/ac_state.dart';
import 'package:syncrow_web/pages/device_managment/ac/model/ac_model.dart';
import 'package:syncrow_web/pages/device_managment/all_devices/models/device_status.dart';
import 'package:syncrow_web/services/batch_control_devices_service.dart';
import 'package:syncrow_web/services/control_device_service.dart';
import 'package:syncrow_web/services/devices_mang_api.dart';
class AcBloc extends Bloc<AcsEvent, AcsState> {
late AcStatusModel deviceStatus;
final String deviceId;
Timer? _timer;
final ControlDeviceService controlDeviceService;
final BatchControlDevicesService batchControlDevicesService;
Timer? _countdownTimer;
AcBloc({required this.deviceId}) : super(AcsInitialState()) {
AcBloc({
required this.deviceId,
required this.controlDeviceService,
required this.batchControlDevicesService,
}) : super(AcsInitialState()) {
on<AcFetchDeviceStatusEvent>(_onFetchAcStatus);
on<AcFetchBatchStatusEvent>(_onFetchAcBatchStatus);
on<AcControlEvent>(_onAcControl);
@@ -34,14 +40,14 @@ class AcBloc extends Bloc<AcsEvent, AcsState> {
int scheduledMinutes = 0;
FutureOr<void> _onFetchAcStatus(
AcFetchDeviceStatusEvent event, Emitter<AcsState> emit) async {
AcFetchDeviceStatusEvent event,
Emitter<AcsState> emit,
) async {
emit(AcsLoadingState());
try {
final status =
await DevicesManagementApi().getDeviceStatus(event.deviceId);
final status = await DevicesManagementApi().getDeviceStatus(event.deviceId);
deviceStatus = AcStatusModel.fromJson(event.deviceId, status.status);
if (deviceStatus.countdown1 != 0) {
// Convert API value to minutes
final totalMinutes = deviceStatus.countdown1 * 6;
scheduledHours = totalMinutes ~/ 60;
scheduledMinutes = totalMinutes % 60;
@@ -62,30 +68,24 @@ class AcBloc extends Bloc<AcsEvent, AcsState> {
}
}
_listenToChanges(deviceId) {
void _listenToChanges(deviceId) {
try {
DatabaseReference ref =
FirebaseDatabase.instance.ref('device-status/$deviceId');
Stream<DatabaseEvent> stream = ref.onValue;
final ref = FirebaseDatabase.instance.ref('device-status/$deviceId');
final stream = ref.onValue;
stream.listen((DatabaseEvent event) async {
if (event.snapshot.value == null) return;
if (_timer != null) {
await Future.delayed(const Duration(seconds: 1));
}
Map<dynamic, dynamic> usersMap =
event.snapshot.value as Map<dynamic, dynamic>;
List<Status> statusList = [];
usersMap['status'].forEach((element) {
statusList
.add(Status(code: element['code'], value: element['value']));
statusList.add(Status(code: element['code'], value: element['value']));
});
deviceStatus =
AcStatusModel.fromJson(usersMap['productUuid'], statusList);
deviceStatus = AcStatusModel.fromJson(usersMap['productUuid'], statusList);
if (!isClosed) {
add(AcStatusUpdated(deviceStatus));
}
@@ -93,146 +93,44 @@ class AcBloc extends Bloc<AcsEvent, AcsState> {
} catch (_) {}
}
void _onAcStatusUpdated(AcStatusUpdated event, Emitter<AcsState> emit) {
void _onAcStatusUpdated(
AcStatusUpdated event,
Emitter<AcsState> emit,
) {
deviceStatus = event.deviceStatus;
emit(ACStatusLoaded(status: deviceStatus));
}
FutureOr<void> _onAcControl(
AcControlEvent event, Emitter<AcsState> emit) async {
final oldValue = _getValueByCode(event.code);
_updateLocalValue(event.code, event.value, emit);
AcControlEvent event,
Emitter<AcsState> emit,
) async {
emit(AcsLoadingState());
_updateDeviceFunctionFromCode(event.code, event.value);
emit(ACStatusLoaded(status: deviceStatus));
await _runDebounce(
isBatch: false,
deviceId: event.deviceId,
code: event.code,
value: event.value,
oldValue: oldValue,
emit: emit,
);
}
try {
final success = await controlDeviceService.controlDevice(
deviceUuid: event.deviceId,
status: Status(code: event.code, value: event.value),
);
Future<void> _runDebounce({
required dynamic deviceId,
required String code,
required dynamic value,
required dynamic oldValue,
required Emitter<AcsState> emit,
required bool isBatch,
}) async {
late String id;
if (deviceId is List) {
id = deviceId.first;
} else {
id = deviceId;
}
if (_timer != null) {
_timer!.cancel();
}
_timer = Timer(const Duration(seconds: 1), () async {
try {
late bool response;
if (isBatch) {
response = await DevicesManagementApi()
.deviceBatchControl(deviceId, code, value);
} else {
response = await DevicesManagementApi()
.deviceControl(deviceId, Status(code: code, value: value));
}
if (!response) {
_revertValueAndEmit(id, code, oldValue, emit);
}
} catch (e) {
if (e is DioException && e.response != null) {
debugPrint('Error response: ${e.response?.data}');
}
_revertValueAndEmit(id, code, oldValue, emit);
if (!success) {
emit(const AcsFailedState(error: 'Failed to control device'));
}
});
}
void _revertValueAndEmit(
String deviceId, String code, dynamic oldValue, Emitter<AcsState> emit) {
_updateLocalValue(code, oldValue, emit);
emit(ACStatusLoaded(status: deviceStatus));
}
void _updateLocalValue(String code, dynamic value, Emitter<AcsState> emit) {
switch (code) {
case 'switch':
if (value is bool) {
deviceStatus = deviceStatus.copyWith(acSwitch: value);
}
break;
case 'temp_set':
if (value is int) {
deviceStatus = deviceStatus.copyWith(tempSet: value);
}
break;
case 'mode':
if (value is String) {
deviceStatus = deviceStatus.copyWith(
modeString: value,
);
}
break;
case 'level':
if (value is String) {
deviceStatus = deviceStatus.copyWith(
fanSpeedsString: value,
);
}
break;
case 'child_lock':
if (value is bool) {
deviceStatus = deviceStatus.copyWith(childLock: value);
}
case 'countdown_time':
if (value is int) {
deviceStatus = deviceStatus.copyWith(countdown1: value);
}
break;
default:
break;
}
emit(ACStatusLoaded(status: deviceStatus));
}
dynamic _getValueByCode(String code) {
switch (code) {
case 'switch':
return deviceStatus.acSwitch;
case 'temp_set':
return deviceStatus.tempSet;
case 'mode':
return deviceStatus.modeString;
case 'level':
return deviceStatus.fanSpeedsString;
case 'child_lock':
return deviceStatus.childLock;
case 'countdown_time':
return deviceStatus.countdown1;
default:
return null;
} catch (e) {
emit(AcsFailedState(error: e.toString()));
}
}
FutureOr<void> _onFetchAcBatchStatus(
AcFetchBatchStatusEvent event, Emitter<AcsState> emit) async {
AcFetchBatchStatusEvent event,
Emitter<AcsState> emit,
) async {
emit(AcsLoadingState());
try {
final status =
await DevicesManagementApi().getBatchStatus(event.devicesIds);
deviceStatus =
AcStatusModel.fromJson(event.devicesIds.first, status.status);
final status = await DevicesManagementApi().getBatchStatus(event.devicesIds);
deviceStatus = AcStatusModel.fromJson(event.devicesIds.first, status.status);
emit(ACStatusLoaded(status: deviceStatus));
} catch (e) {
emit(AcsFailedState(error: e.toString()));
@@ -240,25 +138,32 @@ class AcBloc extends Bloc<AcsEvent, AcsState> {
}
FutureOr<void> _onAcBatchControl(
AcBatchControlEvent event, Emitter<AcsState> emit) async {
final oldValue = _getValueByCode(event.code);
_updateLocalValue(event.code, event.value, emit);
AcBatchControlEvent event,
Emitter<AcsState> emit,
) async {
emit(AcsLoadingState());
_updateDeviceFunctionFromCode(event.code, event.value);
emit(ACStatusLoaded(status: deviceStatus));
await _runDebounce(
isBatch: true,
deviceId: event.devicesIds,
code: event.code,
value: event.value,
oldValue: oldValue,
emit: emit,
);
try {
final success = await batchControlDevicesService.batchControlDevices(
uuids: event.devicesIds,
code: event.code,
value: event.value,
);
if (!success) {
emit(const AcsFailedState(error: 'Failed to control devices'));
}
} catch (e) {
emit(AcsFailedState(error: e.toString()));
}
}
FutureOr<void> _onFactoryReset(
AcFactoryResetEvent event, Emitter<AcsState> emit) async {
Future<void> _onFactoryReset(
AcFactoryResetEvent event,
Emitter<AcsState> emit,
) async {
emit(AcsLoadingState());
try {
final response = await DevicesManagementApi().factoryReset(
@@ -275,9 +180,11 @@ class AcBloc extends Bloc<AcsEvent, AcsState> {
}
}
void _onClose(OnClose event, Emitter<AcsState> emit) {
void _onClose(
OnClose event,
Emitter<AcsState> emit,
) {
_countdownTimer?.cancel();
_timer?.cancel();
}
void _handleIncreaseTime(IncreaseTimeEvent event, Emitter<AcsState> emit) {
@@ -300,7 +207,10 @@ class AcBloc extends Bloc<AcsEvent, AcsState> {
));
}
void _handleDecreaseTime(DecreaseTimeEvent event, Emitter<AcsState> emit) {
void _handleDecreaseTime(
DecreaseTimeEvent event,
Emitter<AcsState> emit,
) {
if (state is! ACStatusLoaded) return;
final currentState = state as ACStatusLoaded;
int totalMinutes = (scheduledHours * 60) + scheduledMinutes;
@@ -315,7 +225,9 @@ class AcBloc extends Bloc<AcsEvent, AcsState> {
}
Future<void> _handleToggleTimer(
ToggleScheduleEvent event, Emitter<AcsState> emit) async {
ToggleScheduleEvent event,
Emitter<AcsState> emit,
) async {
if (state is! ACStatusLoaded) return;
final currentState = state as ACStatusLoaded;
@@ -331,37 +243,44 @@ class AcBloc extends Bloc<AcsEvent, AcsState> {
try {
final scaledValue = totalMinutes ~/ 6;
await _runDebounce(
isBatch: false,
deviceId: deviceId,
code: 'countdown_time',
value: scaledValue,
oldValue: scaledValue,
emit: emit,
final success = await controlDeviceService.controlDevice(
deviceUuid: deviceId,
status: Status(code: 'countdown_time', value: scaledValue),
);
_startCountdownTimer(emit);
emit(currentState.copyWith(isTimerActive: timerActive));
if (success) {
_startCountdownTimer(emit);
emit(currentState.copyWith(isTimerActive: timerActive));
} else {
timerActive = false;
emit(const AcsFailedState(error: 'Failed to set timer'));
}
} catch (e) {
timerActive = false;
emit(AcsFailedState(error: e.toString()));
}
} else {
await _runDebounce(
isBatch: false,
deviceId: deviceId,
code: 'countdown_time',
value: 0,
oldValue: 0,
emit: emit,
);
_countdownTimer?.cancel();
scheduledHours = 0;
scheduledMinutes = 0;
emit(currentState.copyWith(
isTimerActive: timerActive,
scheduledHours: 0,
scheduledMinutes: 0,
));
try {
final success = await controlDeviceService.controlDevice(
deviceUuid: deviceId,
status: Status(code: 'countdown_time', value: 0),
);
if (success) {
_countdownTimer?.cancel();
scheduledHours = 0;
scheduledMinutes = 0;
emit(currentState.copyWith(
isTimerActive: timerActive,
scheduledHours: 0,
scheduledMinutes: 0,
));
} else {
emit(const AcsFailedState(error: 'Failed to stop timer'));
}
} catch (e) {
emit(AcsFailedState(error: e.toString()));
}
}
}
@@ -385,7 +304,10 @@ class AcBloc extends Bloc<AcsEvent, AcsState> {
});
}
void _handleUpdateTimer(UpdateTimerEvent event, Emitter<AcsState> emit) {
void _handleUpdateTimer(
UpdateTimerEvent event,
Emitter<AcsState> emit,
) {
if (state is ACStatusLoaded) {
final currentState = state as ACStatusLoaded;
emit(currentState.copyWith(
@@ -400,7 +322,6 @@ class AcBloc extends Bloc<AcsEvent, AcsState> {
ApiCountdownValueEvent event, Emitter<AcsState> emit) {
if (state is ACStatusLoaded) {
final totalMinutes = event.apiValue * 6;
final scheduledHours = totalMinutes ~/ 60;
scheduledMinutes = totalMinutes % 60;
_startCountdownTimer(
emit,
@@ -409,6 +330,43 @@ class AcBloc extends Bloc<AcsEvent, AcsState> {
}
}
void _updateDeviceFunctionFromCode(String code, dynamic value) {
switch (code) {
case 'switch':
if (value is bool) {
deviceStatus = deviceStatus.copyWith(acSwitch: value);
}
break;
case 'temp_set':
if (value is int) {
deviceStatus = deviceStatus.copyWith(tempSet: value);
}
break;
case 'mode':
if (value is String) {
deviceStatus = deviceStatus.copyWith(modeString: value);
}
break;
case 'level':
if (value is String) {
deviceStatus = deviceStatus.copyWith(fanSpeedsString: value);
}
break;
case 'child_lock':
if (value is bool) {
deviceStatus = deviceStatus.copyWith(childLock: value);
}
break;
case 'countdown_time':
if (value is int) {
deviceStatus = deviceStatus.copyWith(countdown1: value);
}
break;
default:
break;
}
}
@override
Future<void> close() {
add(OnClose());
@@ -0,0 +1,18 @@
import 'package:syncrow_web/pages/device_managment/ac/bloc/ac_bloc.dart';
import 'package:syncrow_web/pages/device_managment/factories/device_bloc_dependencies_factory.dart';
abstract final class AcBlocFactory {
const AcBlocFactory._();
static AcBloc create({
required String deviceId,
}) {
return AcBloc(
deviceId: deviceId,
controlDeviceService:
DeviceBlocDependenciesFactory.createControlDeviceService(),
batchControlDevicesService:
DeviceBlocDependenciesFactory.createBatchControlDevicesService(),
);
}
}
@@ -3,12 +3,12 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/device_managment/ac/bloc/ac_bloc.dart';
import 'package:syncrow_web/pages/device_managment/ac/bloc/ac_event.dart';
import 'package:syncrow_web/pages/device_managment/ac/bloc/ac_state.dart';
import 'package:syncrow_web/pages/device_managment/ac/factories/ac_bloc_factory.dart';
import 'package:syncrow_web/pages/device_managment/ac/view/batch_control_list/batch_ac_mode.dart';
import 'package:syncrow_web/pages/device_managment/ac/view/batch_control_list/batch_current_temp.dart';
import 'package:syncrow_web/pages/device_managment/ac/view/batch_control_list/batch_fan_speed.dart';
import 'package:syncrow_web/pages/device_managment/all_devices/models/factory_reset_model.dart';
import 'package:syncrow_web/pages/device_managment/shared/batch_control/factory_reset.dart';
// import 'package:syncrow_web/pages/device_managment/shared/batch_control/firmware_update.dart';
import 'package:syncrow_web/pages/device_managment/shared/toggle_widget.dart';
import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/utils/constants/assets.dart';
@@ -26,8 +26,9 @@ class AcDeviceBatchControlView extends StatelessWidget with HelperResponsiveLayo
final isLarge = isLargeScreenSize(context);
final isMedium = isMediumScreenSize(context);
return BlocProvider(
create: (context) =>
AcBloc(deviceId: devicesIds.first)..add(AcFetchBatchStatusEvent(devicesIds)),
create: (context) => AcBlocFactory.create(
deviceId: devicesIds.first,
)..add(AcFetchBatchStatusEvent(devicesIds)),
child: BlocBuilder<AcBloc, AcsState>(
builder: (context, state) {
if (state is ACStatusLoaded) {
@@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/device_managment/ac/bloc/ac_bloc.dart';
import 'package:syncrow_web/pages/device_managment/ac/bloc/ac_event.dart';
import 'package:syncrow_web/pages/device_managment/ac/bloc/ac_state.dart';
import 'package:syncrow_web/pages/device_managment/ac/factories/ac_bloc_factory.dart';
import 'package:syncrow_web/pages/device_managment/ac/view/control_list/ac_mode.dart';
import 'package:syncrow_web/pages/device_managment/ac/view/control_list/current_temp.dart';
import 'package:syncrow_web/pages/device_managment/ac/view/control_list/fan_speed.dart';
@@ -24,8 +25,9 @@ class AcDeviceControlsView extends StatelessWidget with HelperResponsiveLayout {
final isMedium = isMediumScreenSize(context);
return BlocProvider(
create: (context) => AcBloc(deviceId: device.uuid!)
..add(AcFetchDeviceStatusEvent(device.uuid!)),
create: (context) => AcBlocFactory.create(
deviceId: device.uuid!,
)..add(AcFetchDeviceStatusEvent(device.uuid!)),
child: BlocBuilder<AcBloc, AcsState>(
builder: (context, state) {
final acBloc = BlocProvider.of<AcBloc>(context);
@@ -12,6 +12,7 @@ import 'package:syncrow_web/pages/routines/models/gang_switches/one_gang_switch/
import 'package:syncrow_web/pages/routines/models/gang_switches/three_gang_switch/three_gang_switch.dart';
import 'package:syncrow_web/pages/routines/models/gang_switches/two_gang_switch/two_gang_switch.dart';
import 'package:syncrow_web/pages/routines/models/gateway.dart';
import 'package:syncrow_web/pages/routines/models/pc/energy_clamp_functions.dart';
import 'package:syncrow_web/pages/routines/models/water_heater/water_heater_functions.dart';
import 'package:syncrow_web/pages/routines/models/wps/wps_functions.dart';
import 'package:syncrow_web/pages/routines/widgets/routine_dialogs/ceiling_sensor/ceiling_sensor_helper.dart';
@@ -248,6 +249,8 @@ SOS
tempIcon = Assets.waterLeakNormal;
} else if (type == DeviceType.NCPS) {
tempIcon = Assets.sensors;
} else if (type == DeviceType.PC) {
tempIcon = Assets.powerClamp;
} else {
tempIcon = Assets.logoHorizontal;
}
@@ -393,6 +396,59 @@ SOS
BacklightFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'IF'),
];
case 'PC':
return [
TotalEnergyConsumedStatusFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'IF'),
TotalActivePowerConsumedStatusFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'IF'),
VoltagePhaseSequenceDetectionFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'IF'),
TotalCurrentStatusFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'IF'),
FrequencyStatusFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'IF'),
// Phase A
EnergyConsumedAStatusFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'IF'),
ActivePowerAStatusFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'IF'),
VoltageAStatusFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'IF'),
PowerFactorAStatusFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'IF'),
CurrentAStatusFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'IF'),
// Phase B
EnergyConsumedBStatusFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'IF'),
ActivePowerBStatusFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'IF'),
VoltageBStatusFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'IF'),
CurrentBStatusFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'IF'),
PowerFactorBStatusFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'IF'),
// Phase C
EnergyConsumedCStatusFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'IF'),
ActivePowerCStatusFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'IF'),
VoltageCStatusFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'IF'),
CurrentCStatusFunction(
deviceId: uuid ?? '',
deviceName: name ?? '',
type: 'IF'),
PowerFactorCStatusFunction(
deviceId: uuid ?? '',
deviceName: name ?? '',
type: 'IF'),
];
default:
return [];
@@ -526,5 +582,6 @@ SOS
"GD": DeviceType.GarageDoor,
"WL": DeviceType.WaterLeak,
"NCPS": DeviceType.NCPS,
"PC": DeviceType.PC,
};
}
@@ -40,17 +40,18 @@ class DeviceManagementPage extends StatelessWidget with HelperResponsiveLayout {
style: TextButton.styleFrom(
backgroundColor: null,
),
onPressed: () {
BlocProvider.of<CreateRoutineBloc>(context)
.add(const ResetSelectedEvent());
onPressed: !state.routineTab
? null
: () {
BlocProvider.of<CreateRoutineBloc>(context)
.add(const ResetSelectedEvent());
context
.read<RoutineBloc>()
.add(const TriggerSwitchTabsEvent(isRoutineTab: false));
context
.read<DeviceManagementBloc>()
.add(FetchDevices(context));
},
context.read<RoutineBloc>().add(
const TriggerSwitchTabsEvent(isRoutineTab: false));
context
.read<DeviceManagementBloc>()
.add(FetchDevices(context));
},
child: Text(
'Devices',
style: context.textTheme.titleMedium?.copyWith(
@@ -66,14 +67,15 @@ class DeviceManagementPage extends StatelessWidget with HelperResponsiveLayout {
style: TextButton.styleFrom(
backgroundColor: null,
),
onPressed: () {
BlocProvider.of<CreateRoutineBloc>(context)
.add(const ResetSelectedEvent());
onPressed: state.routineTab
? null
: () {
BlocProvider.of<CreateRoutineBloc>(context)
.add(const ResetSelectedEvent());
context
.read<RoutineBloc>()
.add(const TriggerSwitchTabsEvent(isRoutineTab: true));
},
context.read<RoutineBloc>().add(
const TriggerSwitchTabsEvent(isRoutineTab: true));
},
child: Text(
'Routines',
style: context.textTheme.titleMedium?.copyWith(
@@ -95,7 +97,7 @@ class DeviceManagementPage extends StatelessWidget with HelperResponsiveLayout {
return const RoutinesView();
}
if (state.createRoutineView) {
return CreateNewRoutineView();
return const CreateNewRoutineView();
}
return BlocBuilder<DeviceManagementBloc, DeviceManagementState>(
@@ -6,9 +6,11 @@ import 'package:syncrow_web/pages/common/filter/filter_widget.dart';
import 'package:syncrow_web/pages/device_managment/all_devices/bloc/device_mgmt_bloc/device_managment_bloc.dart';
import 'package:syncrow_web/pages/device_managment/all_devices/models/devices_model.dart';
import 'package:syncrow_web/pages/device_managment/all_devices/widgets/device_search_filters.dart';
import 'package:syncrow_web/pages/device_managment/device_setting/device_settings_panel.dart';
import 'package:syncrow_web/pages/device_managment/shared/device_batch_control_dialog.dart';
import 'package:syncrow_web/pages/device_managment/shared/device_control_dialog.dart';
import 'package:syncrow_web/pages/space_tree/view/space_tree_view.dart';
import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/utils/format_date_time.dart';
import 'package:syncrow_web/utils/helpers/responsice_layout_helper/responsive_layout_helper.dart';
import 'package:syncrow_web/utils/style.dart';
@@ -58,7 +60,8 @@ class DeviceManagementBody extends StatelessWidget with HelperResponsiveLayout {
'Low Battery ($lowBatteryCount)',
];
final buttonLabel = (selectedDevices.length > 1) ? 'Batch Control' : 'Control';
final buttonLabel =
(selectedDevices.length > 1) ? 'Batch Control' : 'Control';
return Row(
children: [
@@ -105,18 +108,23 @@ class DeviceManagementBody extends StatelessWidget with HelperResponsiveLayout {
if (selectedDevices.length == 1) {
showDialog(
context: context,
builder: (context) => DeviceControlDialog(
builder: (context) =>
DeviceControlDialog(
device: selectedDevices.first,
),
);
} else if (selectedDevices.length > 1) {
final productTypes = selectedDevices
.map((device) => device.productType)
.toSet();
} else if (selectedDevices.length >
1) {
final productTypes =
selectedDevices
.map((device) =>
device.productType)
.toSet();
if (productTypes.length == 1) {
showDialog(
context: context,
builder: (context) => DeviceBatchControlDialog(
builder: (context) =>
DeviceBatchControlDialog(
devices: selectedDevices,
),
);
@@ -130,7 +138,9 @@ class DeviceManagementBody extends StatelessWidget with HelperResponsiveLayout {
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
color: isControlButtonEnabled ? Colors.white : Colors.grey,
color: isControlButtonEnabled
? Colors.white
: Colors.grey,
),
),
),
@@ -166,29 +176,40 @@ class DeviceManagementBody extends StatelessWidget with HelperResponsiveLayout {
'Installation Date and Time',
'Status',
'Last Offline Date and Time',
'Settings'
],
data: devicesToShow.map((device) {
final combinedSpaceNames = device.spaces != null
? device.spaces!.map((space) => space.spaceName).join(' > ') +
? device.spaces!
.map((space) => space.spaceName)
.join(' > ') +
(device.community != null
? ' > ${device.community!.name}'
: '')
: (device.community != null ? device.community!.name : '');
: (device.community != null
? device.community!.name
: '');
return [
device.name ?? '',
device.productName ?? '',
device.uuid ?? '',
(device.spaces != null && device.spaces!.isNotEmpty)
(device.spaces != null &&
device.spaces!.isNotEmpty)
? device.spaces![0].spaceName
: '',
combinedSpaceNames,
device.batteryLevel != null ? '${device.batteryLevel}%' : '-',
formatDateTime(DateTime.fromMillisecondsSinceEpoch(
(device.createTime ?? 0) * 1000)),
device.batteryLevel != null
? '${device.batteryLevel}%'
: '-',
formatDateTime(
DateTime.fromMillisecondsSinceEpoch(
(device.createTime ?? 0) * 1000)),
device.online == true ? 'Online' : 'Offline',
formatDateTime(DateTime.fromMillisecondsSinceEpoch(
(device.updateTime ?? 0) * 1000)),
formatDateTime(
DateTime.fromMillisecondsSinceEpoch(
(device.updateTime ?? 0) * 1000)),
'Settings',
];
}).toList(),
onSelectionChanged: (selectedRows) {
@@ -202,6 +223,10 @@ class DeviceManagementBody extends StatelessWidget with HelperResponsiveLayout {
.map((device) => device.uuid!)
.toList(),
isEmpty: devicesToShow.isEmpty,
onSettingsPressed: (rowIndex) {
final device = devicesToShow[rowIndex];
showDeviceSettingsSidebar(context, device);
},
),
),
)
@@ -213,4 +238,37 @@ class DeviceManagementBody extends StatelessWidget with HelperResponsiveLayout {
},
);
}
void showDeviceSettingsSidebar(BuildContext context, AllDevicesModel device) {
showGeneralDialog(
context: context,
barrierDismissible: true,
barrierLabel: "Device Settings",
transitionDuration: const Duration(milliseconds: 300),
pageBuilder: (context, anim1, anim2) {
return Align(
alignment: Alignment.centerRight,
child: Material(
child: Container(
width: MediaQuery.of(context).size.width * 0.3,
color: ColorsManager.whiteColors,
child: DeviceSettingsPanel(
device: device,
onClose: () => Navigator.of(context).pop(),
),
),
),
);
},
transitionBuilder: (context, anim1, anim2, child) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(1, 0),
end: Offset.zero,
).animate(anim1),
child: child,
);
},
);
}
}

Some files were not shown because too many files have changed in this diff Show More