diff --git a/lib/pages/analytics/helpers/dashed_border_painter.dart b/lib/pages/analytics/helpers/dashed_border_painter.dart new file mode 100644 index 00000000..410cadfd --- /dev/null +++ b/lib/pages/analytics/helpers/dashed_border_painter.dart @@ -0,0 +1,56 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +class DashedBorderPainter extends CustomPainter { + final double dashWidth; + final double dashSpace; + final Color color; + + DashedBorderPainter({ + this.dashWidth = 4.0, + this.dashSpace = 2.0, + this.color = Colors.black, + }); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..strokeWidth = 0.5 + ..style = PaintingStyle.stroke; + + final Path topPath = Path() + ..moveTo(0, 0) + ..lineTo(size.width, 0); + + final Path bottomPath = Path() + ..moveTo(0, size.height) + ..lineTo(size.width, size.height); + + final dashedTopPath = _createDashedPath(topPath, dashWidth, dashSpace); + final dashedBottomPath = _createDashedPath(bottomPath, dashWidth, dashSpace); + + canvas.drawPath(dashedTopPath, paint); + canvas.drawPath(dashedBottomPath, paint); + } + + Path _createDashedPath(Path source, double dashWidth, double dashSpace) { + final Path dashedPath = Path(); + for (PathMetric pathMetric in source.computeMetrics()) { + double distance = 0.0; + while (distance < pathMetric.length) { + final double nextDistance = distance + dashWidth; + dashedPath.addPath( + pathMetric.extractPath(distance, nextDistance), + Offset.zero, + ); + distance = nextDistance + dashSpace; + } + } + return dashedPath; + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} diff --git a/lib/pages/analytics/models/occupancy_heat_map_model.dart b/lib/pages/analytics/models/occupancy_heat_map_model.dart new file mode 100644 index 00000000..52016cc9 --- /dev/null +++ b/lib/pages/analytics/models/occupancy_heat_map_model.dart @@ -0,0 +1,22 @@ +import 'package:equatable/equatable.dart'; + +class OccupancyHeatMapModel extends Equatable { + final DateTime date; + + final int occupancy; + + const OccupancyHeatMapModel({ + required this.date, + required this.occupancy, + }); + + factory OccupancyHeatMapModel.fromJson(Map json) { + return OccupancyHeatMapModel( + date: DateTime.parse(json['date'] as String), + occupancy: json['occupancy'] as int, + ); + } + + @override + List get props => [date, occupancy]; +} diff --git a/lib/pages/analytics/modules/analytics/blocs/analytics_date_picker_bloc/analytics_date_picker_bloc.dart b/lib/pages/analytics/modules/analytics/blocs/analytics_date_picker_bloc/analytics_date_picker_bloc.dart index b848f79f..fa170cd0 100644 --- a/lib/pages/analytics/modules/analytics/blocs/analytics_date_picker_bloc/analytics_date_picker_bloc.dart +++ b/lib/pages/analytics/modules/analytics/blocs/analytics_date_picker_bloc/analytics_date_picker_bloc.dart @@ -2,16 +2,23 @@ import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; part 'analytics_date_picker_event.dart'; +part 'analytics_date_picker_state.dart'; -class AnalyticsDatePickerBloc extends Bloc { - AnalyticsDatePickerBloc() : super(DateTime.now()) { +class AnalyticsDatePickerBloc + extends Bloc { + AnalyticsDatePickerBloc() : super(AnalyticsDatePickerState()) { on(_onUpdateAnalyticsDatePickerEvent); } void _onUpdateAnalyticsDatePickerEvent( UpdateAnalyticsDatePickerEvent event, - Emitter emit, + Emitter emit, ) { - emit(event.date); + emit( + state.copyWith( + monthlyDate: event.montlyDate ?? state.monthlyDate, + yearlyDate: event.yearlyDate ?? state.yearlyDate, + ), + ); } } diff --git a/lib/pages/analytics/modules/analytics/blocs/analytics_date_picker_bloc/analytics_date_picker_event.dart b/lib/pages/analytics/modules/analytics/blocs/analytics_date_picker_bloc/analytics_date_picker_event.dart index 4fdb265e..6153aca1 100644 --- a/lib/pages/analytics/modules/analytics/blocs/analytics_date_picker_bloc/analytics_date_picker_event.dart +++ b/lib/pages/analytics/modules/analytics/blocs/analytics_date_picker_bloc/analytics_date_picker_event.dart @@ -8,10 +8,11 @@ sealed class AnalyticsDatePickerEvent extends Equatable { } final class UpdateAnalyticsDatePickerEvent extends AnalyticsDatePickerEvent { - const UpdateAnalyticsDatePickerEvent(this.date); + const UpdateAnalyticsDatePickerEvent({this.montlyDate, this.yearlyDate}); - final DateTime date; + final DateTime? montlyDate; + final DateTime? yearlyDate; @override - List get props => [date]; + List get props => [montlyDate, yearlyDate]; } diff --git a/lib/pages/analytics/modules/analytics/blocs/analytics_date_picker_bloc/analytics_date_picker_state.dart b/lib/pages/analytics/modules/analytics/blocs/analytics_date_picker_bloc/analytics_date_picker_state.dart new file mode 100644 index 00000000..ffcd4e40 --- /dev/null +++ b/lib/pages/analytics/modules/analytics/blocs/analytics_date_picker_bloc/analytics_date_picker_state.dart @@ -0,0 +1,25 @@ +part of 'analytics_date_picker_bloc.dart'; + +final class AnalyticsDatePickerState extends Equatable { + AnalyticsDatePickerState({ + DateTime? monthlyDate, + DateTime? yearlyDate, + }) : monthlyDate = monthlyDate ?? DateTime.now(), + yearlyDate = yearlyDate ?? DateTime.now(); + + final DateTime monthlyDate; + final DateTime yearlyDate; + + AnalyticsDatePickerState copyWith({ + DateTime? monthlyDate, + DateTime? yearlyDate, + }) { + return AnalyticsDatePickerState( + monthlyDate: monthlyDate ?? this.monthlyDate, + yearlyDate: yearlyDate ?? this.yearlyDate, + ); + } + + @override + List get props => [monthlyDate, yearlyDate]; +} diff --git a/lib/pages/analytics/modules/analytics/strategies/analytics_data_loading_strategy.dart b/lib/pages/analytics/modules/analytics/strategies/analytics_data_loading_strategy.dart new file mode 100644 index 00000000..2c2194ba --- /dev/null +++ b/lib/pages/analytics/modules/analytics/strategies/analytics_data_loading_strategy.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.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'; + +abstract class AnalyticsDataLoadingStrategy { + void onCommunitySelected( + BuildContext context, + CommunityModel community, + List spaces, + ); + void onSpaceSelected( + BuildContext context, + CommunityModel community, + SpaceModel space, + ); + void onChildSpaceSelected( + BuildContext context, + CommunityModel community, + SpaceModel child, + ); + void clearData(BuildContext context); +} diff --git a/lib/pages/analytics/modules/analytics/strategies/analytics_data_loading_strategy_factory.dart b/lib/pages/analytics/modules/analytics/strategies/analytics_data_loading_strategy_factory.dart new file mode 100644 index 00000000..8b8bb60f --- /dev/null +++ b/lib/pages/analytics/modules/analytics/strategies/analytics_data_loading_strategy_factory.dart @@ -0,0 +1,14 @@ +import 'package:syncrow_web/pages/analytics/modules/analytics/enums/analytics_page_tab.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'; + +abstract final class AnalyticsDataLoadingStrategyFactory { + const AnalyticsDataLoadingStrategyFactory._(); + static AnalyticsDataLoadingStrategy getStrategy(AnalyticsPageTab tab) { + return switch (tab) { + AnalyticsPageTab.energyManagement => EnergyManagementDataLoadingStrategy(), + AnalyticsPageTab.occupancy => OccupancyDataLoadingStrategy(), + }; + } +} diff --git a/lib/pages/analytics/modules/analytics/strategies/energy_management_data_loading_strategy.dart b/lib/pages/analytics/modules/analytics/strategies/energy_management_data_loading_strategy.dart new file mode 100644 index 00000000..60ffb68f --- /dev/null +++ b/lib/pages/analytics/modules/analytics/strategies/energy_management_data_loading_strategy.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:syncrow_web/pages/analytics/modules/analytics/strategies/analytics_data_loading_strategy.dart'; +import 'package:syncrow_web/pages/analytics/modules/energy_management/helpers/fetch_energy_management_data_helper.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'; + +class EnergyManagementDataLoadingStrategy implements AnalyticsDataLoadingStrategy { + @override + void onCommunitySelected( + BuildContext context, + CommunityModel community, + List spaces, + ) { + context.read().add( + OnCommunitySelected( + community.uuid, + spaces, + ), + ); + FetchEnergyManagementDataHelper.loadEnergyManagementData(context); + } + + @override + void onSpaceSelected( + BuildContext context, + CommunityModel community, + SpaceModel space, + ) { + context.read().add( + OnSpaceSelected( + community, + space.uuid ?? '', + space.children, + ), + ); + FetchEnergyManagementDataHelper.loadEnergyManagementData(context); + } + + @override + void onChildSpaceSelected( + BuildContext context, + CommunityModel community, + SpaceModel child, + ) { + context.read().add( + OnSpaceSelected( + community, + child.uuid ?? '', + child.children, + ), + ); + FetchEnergyManagementDataHelper.loadEnergyManagementData(context); + } + + @override + void clearData(BuildContext context) { + context.read().add(const SpaceTreeClearSelectionEvent()); + FetchEnergyManagementDataHelper.clearAllData(context); + } +} diff --git a/lib/pages/analytics/modules/analytics/strategies/occupancy_data_loading_strategy.dart b/lib/pages/analytics/modules/analytics/strategies/occupancy_data_loading_strategy.dart new file mode 100644 index 00000000..bd7e7b13 --- /dev/null +++ b/lib/pages/analytics/modules/analytics/strategies/occupancy_data_loading_strategy.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:syncrow_web/pages/analytics/modules/analytics/strategies/analytics_data_loading_strategy.dart'; +import 'package:syncrow_web/pages/analytics/modules/occupancy/helpers/fetch_occupancy_data_helper.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'; + +class OccupancyDataLoadingStrategy implements AnalyticsDataLoadingStrategy { + @override + void onCommunitySelected( + BuildContext context, + CommunityModel community, + List spaces, + ) { + context.read().add( + OnCommunitySelected( + community.uuid, + spaces, + ), + ); + FetchOccupancyDataHelper.loadOccupancyData(context); + } + + @override + void onSpaceSelected( + BuildContext context, + CommunityModel community, + SpaceModel space, + ) { + context.read().add( + OnSpaceSelected( + community, + space.uuid ?? '', + space.children, + ), + ); + FetchOccupancyDataHelper.loadOccupancyData(context); + } + + @override + void onChildSpaceSelected( + BuildContext context, + CommunityModel community, + SpaceModel child, + ) { + context.read().add( + OnSpaceSelected( + community, + child.uuid ?? '', + child.children, + ), + ); + FetchOccupancyDataHelper.loadOccupancyData(context); + } + + @override + void clearData(BuildContext context) { + context.read().add(const SpaceTreeClearSelectionEvent()); + } +} diff --git a/lib/pages/analytics/modules/analytics/views/analytics_page.dart b/lib/pages/analytics/modules/analytics/views/analytics_page.dart index cec50fe5..354455e9 100644 --- a/lib/pages/analytics/modules/analytics/views/analytics_page.dart +++ b/lib/pages/analytics/modules/analytics/views/analytics_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_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_tab/analytics_tab_bloc.dart'; import 'package:syncrow_web/pages/analytics/modules/analytics/widgets/analytics_communities_sidebar.dart'; import 'package:syncrow_web/pages/analytics/modules/analytics/widgets/analytics_page_tabs_and_children.dart'; @@ -9,12 +10,14 @@ import 'package:syncrow_web/pages/analytics/modules/energy_management/blocs/powe 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/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/energy_consumption_by_phases/fake_energy_consumption_by_phases_service.dart'; import 'package:syncrow_web/pages/analytics/services/energy_consumption_per_device/fake_energy_consumption_per_device_service.dart'; import 'package:syncrow_web/pages/analytics/services/occupacy/fake_occupacy_service.dart'; +import 'package:syncrow_web/pages/analytics/services/occupancy_heat_map/fake_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/realtime_device_service/firebase_realtime_device_service.dart'; -import 'package:syncrow_web/pages/analytics/services/total_energy_consumption/fake_total_energy_consumption_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'; import 'package:syncrow_web/services/api/http_service.dart'; import 'package:syncrow_web/utils/theme/responsive_text_theme.dart'; @@ -32,7 +35,7 @@ class AnalyticsPage extends StatelessWidget { ), BlocProvider( create: (context) => TotalEnergyConsumptionBloc( - FakeTotalEnergyConsumptionService(), + RemoteTotalEnergyConsumptionService(HTTPService()), ), ), BlocProvider( @@ -56,6 +59,10 @@ class AnalyticsPage extends StatelessWidget { ), ), BlocProvider(create: (context) => OccupancyBloc(FakeOccupacyService())), + BlocProvider( + create: (context) => OccupancyHeatMapBloc(FakeOccupancyHeatMapService()), + ), + BlocProvider(create: (context) => AnalyticsDatePickerBloc()), ], child: const AnalyticsPageForm(), ); diff --git a/lib/pages/analytics/modules/analytics/widgets/analytics_communities_sidebar.dart b/lib/pages/analytics/modules/analytics/widgets/analytics_communities_sidebar.dart index a46c2c1e..7edf056b 100644 --- a/lib/pages/analytics/modules/analytics/widgets/analytics_communities_sidebar.dart +++ b/lib/pages/analytics/modules/analytics/widgets/analytics_communities_sidebar.dart @@ -1,10 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_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'; -import 'package:syncrow_web/pages/analytics/modules/energy_management/helpers/fetch_energy_management_data_helper.dart'; -import 'package:syncrow_web/pages/analytics/modules/occupancy/helpers/fetch_occupancy_data_helper.dart'; -import 'package:syncrow_web/pages/space_tree/view/space_tree_view.dart'; +import 'package:syncrow_web/pages/analytics/modules/analytics/strategies/analytics_data_loading_strategy_factory.dart'; +import 'package:syncrow_web/pages/analytics/modules/analytics/widgets/sidebar/analytics_space_tree_view.dart'; class AnalyticsCommunitiesSidebar extends StatelessWidget { const AnalyticsCommunitiesSidebar({super.key}); @@ -13,32 +11,27 @@ class AnalyticsCommunitiesSidebar extends StatelessWidget { Widget build(BuildContext context) { return Builder( builder: (context) { + final selectedTab = context.read().state; + final strategy = + AnalyticsDataLoadingStrategyFactory.getStrategy(selectedTab); + + // Clear data when tab changes + strategy.clearData(context); + return Expanded( - child: SpaceTreeView( - title: const Text('Communities'), - shouldDisableDeselectingChildrenOfSelectedParent: true, - onSelect: () { - /// Necessary to wait for the state to update before fethcing the data. - Future.delayed(const Duration(milliseconds: 100), () { - if (context.mounted) _loadBasedOnSelectedTab(context); - }); + child: AnalyticsSpaceTreeView( + onSelectCommunity: (community, spaces) { + strategy.onCommunitySelected(context, community, spaces); + }, + onSelectSpace: (community, space) { + strategy.onSpaceSelected(context, community, space); + }, + onSelectChildSpace: (community, child) { + strategy.onChildSpaceSelected(context, community, child); }, - isSide: false, ), ); }, ); } - - void _loadBasedOnSelectedTab(BuildContext context) { - final selectedTab = context.read().state; - return switch (selectedTab) { - AnalyticsPageTab.energyManagement => - FetchEnergyManagementDataHelper.loadEnergyManagementData(context), - AnalyticsPageTab.occupancy => - FetchOccupancyDataHelper.loadOccupancyData(context), - // ignore: unreachable_switch_case - _ => () {}, - }; - } } diff --git a/lib/pages/analytics/modules/analytics/widgets/analytics_date_filter_button.dart b/lib/pages/analytics/modules/analytics/widgets/analytics_date_filter_button.dart index 19a72566..af70cd86 100644 --- a/lib/pages/analytics/modules/analytics/widgets/analytics_date_filter_button.dart +++ b/lib/pages/analytics/modules/analytics/widgets/analytics_date_filter_button.dart @@ -1,15 +1,24 @@ import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_svg/svg.dart'; import 'package:intl/intl.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/widgets/month_picker_widget.dart'; -import 'package:syncrow_web/pages/analytics/modules/energy_management/helpers/fetch_energy_management_data_helper.dart'; +import 'package:syncrow_web/pages/analytics/modules/analytics/widgets/year_picker_widget.dart'; import 'package:syncrow_web/utils/color_manager.dart'; import 'package:syncrow_web/utils/constants/assets.dart'; +enum DatePickerType { month, year } + class AnalyticsDateFilterButton extends StatefulWidget { - const AnalyticsDateFilterButton({super.key}); + const AnalyticsDateFilterButton({ + required this.selectedDate, + required this.onDateSelected, + this.datePickerType = DatePickerType.month, + super.key, + }); + + final DateTime selectedDate; + final void Function(DateTime)? onDateSelected; + final DatePickerType datePickerType; static final _color = ColorsManager.blackColor.withValues(alpha: 0.8); @@ -19,79 +28,69 @@ class AnalyticsDateFilterButton extends StatefulWidget { } class _AnalyticsDateFilterButtonState extends State { - late final AnalyticsDatePickerBloc _analyticsDatePickerBloc; - @override - void initState() { - _analyticsDatePickerBloc = AnalyticsDatePickerBloc(); - super.initState(); - } - - @override - void dispose() { - _analyticsDatePickerBloc.close(); - super.dispose(); - } - @override Widget build(BuildContext context) { - return BlocProvider.value( - value: _analyticsDatePickerBloc, - child: Builder(builder: (context) { - final selectedDate = context.watch().state; - return TextButton.icon( - style: TextButton.styleFrom( - foregroundColor: AnalyticsDateFilterButton._color, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: const BorderSide( - color: ColorsManager.greyColor, - width: 1, - ), - ), - backgroundColor: ColorsManager.transparentColor, - padding: const EdgeInsets.symmetric( - horizontal: 32, - vertical: 16, - ), + return TextButton.icon( + style: TextButton.styleFrom( + foregroundColor: AnalyticsDateFilterButton._color, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: const BorderSide( + color: ColorsManager.greyColor, + width: 1, ), - icon: SvgPicture.asset( - Assets.blankCalendar, - height: 20, - width: 20, - colorFilter: - ColorFilter.mode(AnalyticsDateFilterButton._color, BlendMode.srcIn), - ), - label: Text( - _formatDate(selectedDate), - style: const TextStyle( - fontWeight: FontWeight.w700, - ), - ), - onPressed: () { - showDialog( - context: context, - builder: (_) => MonthPickerWidget( - selectedDate: selectedDate, - onDateSelected: (value) { - _analyticsDatePickerBloc.add( - UpdateAnalyticsDatePickerEvent(value), - ); - FetchEnergyManagementDataHelper.fetchEnergyManagementData( - context, - selectedDate: value, - ); - }, - ), - ); + ), + backgroundColor: ColorsManager.transparentColor, + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 16, + ), + ), + icon: SvgPicture.asset( + Assets.blankCalendar, + height: 20, + width: 20, + colorFilter: + ColorFilter.mode(AnalyticsDateFilterButton._color, BlendMode.srcIn), + ), + label: Text( + _formatDate(widget.selectedDate), + style: const TextStyle( + fontWeight: FontWeight.w700, + ), + ), + onPressed: () { + showDialog( + context: context, + builder: (_) { + return switch (widget.datePickerType) { + DatePickerType.month => MonthPickerWidget( + selectedDate: widget.selectedDate, + onDateSelected: (value) { + widget.onDateSelected?.call(value); + }, + ), + DatePickerType.year => YearPickerWidget( + selectedDate: widget.selectedDate, + onDateSelected: (value) { + widget.onDateSelected?.call(value); + }, + ), + }; }, ); - }), + }, ); } String _formatDate(DateTime? date) { - final formatter = DateFormat('MMMM yyyy'); - final formattedDate = formatter.format(date ?? DateTime.now()); + final formatterBasedOnDatePickerType = switch (widget.datePickerType) { + DatePickerType.month => DateFormat('MMMM yyyy'), + DatePickerType.year => DateFormat('yyyy'), + }; + final formattedDate = formatterBasedOnDatePickerType.format( + date ?? DateTime.now(), + ); return formattedDate; } } diff --git a/lib/pages/analytics/modules/analytics/widgets/analytics_page_tab_button.dart b/lib/pages/analytics/modules/analytics/widgets/analytics_page_tab_button.dart index fb0983fa..9ff98ef2 100644 --- a/lib/pages/analytics/modules/analytics/widgets/analytics_page_tab_button.dart +++ b/lib/pages/analytics/modules/analytics/widgets/analytics_page_tab_button.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_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'; +import 'package:syncrow_web/pages/analytics/modules/analytics/strategies/analytics_data_loading_strategy_factory.dart'; import 'package:syncrow_web/utils/color_manager.dart'; class AnalyticsPageTabButton extends StatelessWidget { @@ -17,9 +18,12 @@ class AnalyticsPageTabButton extends StatelessWidget { @override Widget build(BuildContext context) { return TextButton( - onPressed: () => context.read().add( + onPressed: () { + AnalyticsDataLoadingStrategyFactory.getStrategy(tab).clearData(context); + context.read().add( UpdateAnalyticsTabEvent(tab), - ), + ); + }, child: Text( tab.title, textAlign: TextAlign.center, diff --git a/lib/pages/analytics/modules/analytics/widgets/analytics_page_tabs_and_children.dart b/lib/pages/analytics/modules/analytics/widgets/analytics_page_tabs_and_children.dart index 20d00d83..4d9954d1 100644 --- a/lib/pages/analytics/modules/analytics/widgets/analytics_page_tabs_and_children.dart +++ b/lib/pages/analytics/modules/analytics/widgets/analytics_page_tabs_and_children.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_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_tab/analytics_tab_bloc.dart'; import 'package:syncrow_web/pages/analytics/modules/analytics/enums/analytics_page_tab.dart'; import 'package:syncrow_web/pages/analytics/modules/analytics/widgets/analytics_date_filter_button.dart'; import 'package:syncrow_web/pages/analytics/modules/analytics/widgets/analytics_page_tab_button.dart'; +import 'package:syncrow_web/pages/analytics/modules/energy_management/helpers/fetch_energy_management_data_helper.dart'; import 'package:syncrow_web/utils/style.dart'; class AnalyticsPageTabsAndChildren extends StatelessWidget { @@ -55,12 +57,28 @@ class AnalyticsPageTabsAndChildren extends StatelessWidget { child: Visibility( key: ValueKey(selectedTab), visible: selectedTab == AnalyticsPageTab.energyManagement, - child: const Expanded( + child: Expanded( flex: 2, child: FittedBox( fit: BoxFit.scaleDown, alignment: AlignmentDirectional.centerEnd, - child: AnalyticsDateFilterButton(), + child: AnalyticsDateFilterButton( + onDateSelected: (DateTime value) { + context.read().add( + UpdateAnalyticsDatePickerEvent( + montlyDate: value), + ); + FetchEnergyManagementDataHelper + .fetchEnergyManagementData( + context, + selectedDate: value, + ); + }, + selectedDate: context + .watch() + .state + .monthlyDate, + ), ), ), ), diff --git a/lib/pages/analytics/modules/analytics/widgets/sidebar/analytics_space_tree_view.dart b/lib/pages/analytics/modules/analytics/widgets/sidebar/analytics_space_tree_view.dart new file mode 100644 index 00000000..f900a040 --- /dev/null +++ b/lib/pages/analytics/modules/analytics/widgets/sidebar/analytics_space_tree_view.dart @@ -0,0 +1,188 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:syncrow_web/common/widgets/search_bar.dart'; +import 'package:syncrow_web/common/widgets/sidebar_communities_list.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/space_tree/bloc/space_tree_state.dart'; +import 'package:syncrow_web/pages/space_tree/view/custom_expansion.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'; +import 'package:syncrow_web/utils/color_manager.dart'; +import 'package:syncrow_web/utils/extension/build_context_x.dart'; + +class AnalyticsSpaceTreeView extends StatefulWidget { + const AnalyticsSpaceTreeView({ + super.key, + this.onSelectCommunity, + this.onSelectSpace, + this.onSelectChildSpace, + }); + + final void Function( + CommunityModel community, + List spaces, + )? onSelectCommunity; + final void Function( + CommunityModel community, + SpaceModel space, + )? onSelectSpace; + final void Function( + CommunityModel community, + SpaceModel child, + )? onSelectChildSpace; + + @override + State createState() => _AnalyticsSpaceTreeViewState(); +} + +class _AnalyticsSpaceTreeViewState extends State { + late final ScrollController _scrollController; + + @override + void initState() { + _scrollController = ScrollController(); + super.initState(); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder(builder: (context, state) { + final communities = state.searchQuery.isNotEmpty + ? state.filteredCommunity + : state.communityList; + return Container( + height: MediaQuery.sizeOf(context).height, + decoration: const BoxDecoration(color: ColorsManager.whiteColors), + child: state is SpaceTreeLoadingState + ? const Center(child: CircularProgressIndicator()) + : Column( + children: [ + Container( + alignment: AlignmentDirectional.centerStart, + padding: const EdgeInsets.all(24), + child: DefaultTextStyle( + style: context.textTheme.titleMedium!.copyWith( + color: ColorsManager.blackColor, + fontSize: 20, + ), + child: const Text('Communities'), + ), + ), + CustomSearchBar( + onSearchChanged: (query) => context.read().add( + SearchQueryEvent(query), + ), + ), + const SizedBox(height: 16), + Expanded( + child: state.isSearching + ? const Center(child: CircularProgressIndicator()) + : SidebarCommunitiesList( + onScrollToEnd: () { + if (!state.paginationIsLoading) { + context.read().add( + PaginationEvent( + state.paginationModel, + state.communityList, + ), + ); + } + }, + scrollController: _scrollController, + communities: communities, + itemBuilder: (context, index) { + return CustomExpansionTileSpaceTree( + title: communities[index].name, + isSelected: state.selectedCommunities + .contains(communities[index].uuid), + isSoldCheck: state.selectedCommunities + .contains(communities[index].uuid), + onExpansionChanged: () => + context.read().add( + OnCommunityExpanded( + communities[index].uuid, + ), + ), + isExpanded: state.expandedCommunities.contains( + communities[index].uuid, + ), + onItemSelected: () => widget.onSelectCommunity?.call( + communities[index], + communities[index].spaces, + ), + children: communities[index].spaces.map( + (space) { + return CustomExpansionTileSpaceTree( + title: space.name, + isExpanded: + state.expandedSpaces.contains(space.uuid), + onItemSelected: () => + widget.onSelectSpace?.call( + communities[index], + space, + ), + onExpansionChanged: () => + context.read().add( + OnSpaceExpanded( + communities[index].uuid, + space.uuid ?? '', + ), + ), + isSelected: state.selectedSpaces + .contains(space.uuid) || + state.soldCheck.contains(space.uuid), + isSoldCheck: + state.soldCheck.contains(space.uuid), + children: _buildNestedSpaces( + context, + state, + space, + communities[index], + ), + ); + }, + ).toList(), + ); + }, + ), + ), + if (state.paginationIsLoading) const CircularProgressIndicator(), + ], + ), + ); + }); + } + + List _buildNestedSpaces( + BuildContext context, + SpaceTreeState state, + SpaceModel space, + CommunityModel community, + ) { + return space.children.map((child) { + return CustomExpansionTileSpaceTree( + isSelected: state.selectedSpaces.contains(child.uuid) || + state.soldCheck.contains(child.uuid), + isSoldCheck: state.soldCheck.contains(child.uuid), + title: child.name, + isExpanded: state.expandedSpaces.contains(child.uuid), + onItemSelected: () { + widget.onSelectChildSpace?.call(community, child); + }, + onExpansionChanged: () { + context.read().add( + OnSpaceExpanded(community.uuid, child.uuid ?? ''), + ); + }, + children: _buildNestedSpaces(context, state, child, community), + ); + }).toList(); + } +} diff --git a/lib/pages/analytics/modules/analytics/widgets/year_picker_widget.dart b/lib/pages/analytics/modules/analytics/widgets/year_picker_widget.dart new file mode 100644 index 00000000..4c7bb748 --- /dev/null +++ b/lib/pages/analytics/modules/analytics/widgets/year_picker_widget.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; +import 'package:syncrow_web/utils/color_manager.dart'; +import 'package:syncrow_web/utils/extension/build_context_x.dart'; + +class YearPickerWidget extends StatefulWidget { + const YearPickerWidget({ + super.key, + required this.selectedDate, + required this.onDateSelected, + }); + + final DateTime selectedDate; + final ValueChanged? onDateSelected; + + @override + State createState() => _YearPickerWidgetState(); +} + +class _YearPickerWidgetState extends State { + late int _currentYear; + + static final years = List.generate( + DateTime.now().year - 2020 + 1, + (index) => (2020 + index), + ); + + @override + void initState() { + super.initState(); + _currentYear = widget.selectedDate.year; + } + + @override + Widget build(BuildContext context) { + return Dialog( + backgroundColor: Theme.of(context).colorScheme.surface, + child: Container( + padding: const EdgeInsetsDirectional.all(20), + width: 320, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildMonthsGrid(), + const SizedBox(height: 20), + Row( + spacing: 12, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FilledButton( + onPressed: () => Navigator.pop(context), + style: FilledButton.styleFrom( + fixedSize: const Size(106, 40), + backgroundColor: const Color(0xFFEDF2F7), + padding: const EdgeInsetsDirectional.symmetric( + vertical: 10, + horizontal: 16, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + ), + child: Text( + 'Cancel', + style: context.textTheme.titleSmall?.copyWith( + fontSize: 14, + fontWeight: FontWeight.w600, + color: ColorsManager.grey700, + ), + ), + ), + FilledButton( + onPressed: () { + Navigator.pop(context); + final date = DateTime(_currentYear); + widget.onDateSelected?.call(date); + }, + style: FilledButton.styleFrom( + fixedSize: const Size(106, 40), + backgroundColor: ColorsManager.vividBlue.withValues( + alpha: 0.7, + ), + padding: const EdgeInsetsDirectional.symmetric( + vertical: 10, + horizontal: 16, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + ), + child: Text( + 'Done', + style: context.textTheme.titleSmall?.copyWith( + fontSize: 14, + fontWeight: FontWeight.w600, + color: ColorsManager.whiteColors, + ), + ), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildMonthsGrid() { + return GridView.builder( + shrinkWrap: true, + itemCount: years.length, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.all(8), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + childAspectRatio: 2.5, + mainAxisSpacing: 8, + mainAxisExtent: 30, + ), + itemBuilder: (context, index) { + final isSelected = _currentYear == years[index]; + return InkWell( + onTap: () => setState(() => _currentYear = years[index]), + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: isSelected + ? ColorsManager.vividBlue.withValues(alpha: 0.7) + : const Color(0xFFEDF2F7), + borderRadius: + isSelected ? BorderRadius.circular(15) : BorderRadius.zero, + ), + child: Text( + years[index].toString(), + style: context.textTheme.titleSmall?.copyWith( + fontSize: 12, + color: isSelected + ? ColorsManager.whiteColors + : ColorsManager.blackColor.withValues(alpha: 0.8), + fontWeight: FontWeight.w500, + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/pages/analytics/modules/energy_management/helpers/energy_management_charts_helper.dart b/lib/pages/analytics/modules/energy_management/helpers/energy_management_charts_helper.dart index 7e948d21..5c56d229 100644 --- a/lib/pages/analytics/modules/energy_management/helpers/energy_management_charts_helper.dart +++ b/lib/pages/analytics/modules/energy_management/helpers/energy_management_charts_helper.dart @@ -24,7 +24,7 @@ abstract final class EnergyManagementChartsHelper { getTitlesWidget: (value, meta) => Padding( padding: const EdgeInsetsDirectional.only(top: 20.0), child: Text( - value.toString(), + (value + 1).toString(), style: context.textTheme.bodySmall?.copyWith( color: ColorsManager.greyColor, fontSize: 12, @@ -70,7 +70,7 @@ abstract final class EnergyManagementChartsHelper { static List getTooltipItems(List touchedSpots) { return touchedSpots.map((spot) { return LineTooltipItem( - getToolTipLabel(spot.x, spot.y), + getToolTipLabel(spot.x + 1, spot.y), const TextStyle( color: ColorsManager.textPrimaryColor, fontWeight: FontWeight.w600, diff --git a/lib/pages/analytics/modules/energy_management/helpers/fetch_energy_management_data_helper.dart b/lib/pages/analytics/modules/energy_management/helpers/fetch_energy_management_data_helper.dart index 4f5e3b5b..ba4f972e 100644 --- a/lib/pages/analytics/modules/energy_management/helpers/fetch_energy_management_data_helper.dart +++ b/lib/pages/analytics/modules/energy_management/helpers/fetch_energy_management_data_helper.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_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/energy_management/blocs/energy_consumption_by_phases/energy_consumption_by_phases_bloc.dart'; import 'package:syncrow_web/pages/analytics/modules/energy_management/blocs/energy_consumption_per_device/energy_consumption_per_device_bloc.dart'; import 'package:syncrow_web/pages/analytics/modules/energy_management/blocs/power_clamp_info/power_clamp_info_bloc.dart'; @@ -24,9 +25,13 @@ abstract final class FetchEnergyManagementDataHelper { clearAllData(context); return; } + final datePickerState = context.read().state; - loadTotalEnergyConsumption(context); - loadEnergyConsumptionByPhases(context); + loadTotalEnergyConsumption(context, selectedDate: datePickerState.monthlyDate); + loadEnergyConsumptionByPhases( + context, + selectedDate: datePickerState.monthlyDate, + ); loadEnergyConsumptionPerDevice(context); return; } @@ -36,7 +41,8 @@ abstract final class FetchEnergyManagementDataHelper { FetchEnergyManagementDataHelper.getSelectedCommunitiesAndSpaces(context); if (selectedCommunities.isEmpty && selectedSpaces.isEmpty) return; - FetchEnergyManagementDataHelper.fetchEnergyManagementData(context); + FetchEnergyManagementDataHelper.fetchEnergyManagementData(context, + selectedDate: DateTime.now()); FetchEnergyManagementDataHelper.loadRealtimeDeviceChanges(context); context.read().add(const ClearPowerClampInfoEvent()); if (selectedCommunities.isEmpty && selectedSpaces.isEmpty) { @@ -79,7 +85,8 @@ abstract final class FetchEnergyManagementDataHelper { final param = GetTotalEnergyConsumptionParam( spaceId: selectedCommunities.firstOrNull, - startDate: selectedDate, + communityId: selectedCommunities.firstOrNull, + monthDate: selectedDate, ); context.read().add( TotalEnergyConsumptionLoadEvent(param: param), diff --git a/lib/pages/analytics/modules/energy_management/widgets/total_energy_consumption_chart.dart b/lib/pages/analytics/modules/energy_management/widgets/total_energy_consumption_chart.dart index ddf016be..70170180 100644 --- a/lib/pages/analytics/modules/energy_management/widgets/total_energy_consumption_chart.dart +++ b/lib/pages/analytics/modules/energy_management/widgets/total_energy_consumption_chart.dart @@ -23,10 +23,7 @@ class TotalEnergyConsumptionChart extends StatelessWidget { return Expanded( child: LineChart( LineChartData( - titlesData: EnergyManagementChartsHelper.titlesData( - context, - leftTitlesInterval: 5000, - ), + titlesData: EnergyManagementChartsHelper.titlesData(context), gridData: EnergyManagementChartsHelper.gridData(), borderData: EnergyManagementChartsHelper.borderData(), lineTouchData: EnergyManagementChartsHelper.lineTouchData(), diff --git a/lib/pages/analytics/modules/occupancy/blocs/occupancy_heat_map/occupancy_heat_map_bloc.dart b/lib/pages/analytics/modules/occupancy/blocs/occupancy_heat_map/occupancy_heat_map_bloc.dart new file mode 100644 index 00000000..5d5cb914 --- /dev/null +++ b/lib/pages/analytics/modules/occupancy/blocs/occupancy_heat_map/occupancy_heat_map_bloc.dart @@ -0,0 +1,49 @@ +import 'package:bloc/bloc.dart'; +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'; + +part 'occupancy_heat_map_event.dart'; +part 'occupancy_heat_map_state.dart'; + +class OccupancyHeatMapBloc + extends Bloc { + OccupancyHeatMapBloc( + this._occupancyHeatMapService, + ) : super(const OccupancyHeatMapState()) { + on(_onLoadOccupancyHeatMapEvent); + on(_onClearOccupancyHeatMapEvent); + } + final OccupancyHeatMapService _occupancyHeatMapService; + + Future _onLoadOccupancyHeatMapEvent( + LoadOccupancyHeatMapEvent event, + Emitter emit, + ) async { + emit(state.copyWith(status: OccupancyHeatMapStatus.loading)); + try { + final occupancyHeatMap = await _occupancyHeatMapService.load(event.param); + emit( + state.copyWith( + status: OccupancyHeatMapStatus.loaded, + heatMapData: occupancyHeatMap, + ), + ); + } catch (e) { + emit( + state.copyWith( + status: OccupancyHeatMapStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } + + void _onClearOccupancyHeatMapEvent( + ClearOccupancyHeatMapEvent event, + Emitter emit, + ) { + emit(const OccupancyHeatMapState()); + } +} diff --git a/lib/pages/analytics/modules/occupancy/blocs/occupancy_heat_map/occupancy_heat_map_event.dart b/lib/pages/analytics/modules/occupancy/blocs/occupancy_heat_map/occupancy_heat_map_event.dart new file mode 100644 index 00000000..a1e0559a --- /dev/null +++ b/lib/pages/analytics/modules/occupancy/blocs/occupancy_heat_map/occupancy_heat_map_event.dart @@ -0,0 +1,21 @@ +part of 'occupancy_heat_map_bloc.dart'; + +sealed class OccupancyHeatMapEvent extends Equatable { + const OccupancyHeatMapEvent(); + + @override + List get props => []; +} + +final class LoadOccupancyHeatMapEvent extends OccupancyHeatMapEvent { + const LoadOccupancyHeatMapEvent(this.param); + + final GetOccupancyHeatMapParam param; + + @override + List get props => [param]; +} + +final class ClearOccupancyHeatMapEvent extends OccupancyHeatMapEvent { + const ClearOccupancyHeatMapEvent(); +} diff --git a/lib/pages/analytics/modules/occupancy/blocs/occupancy_heat_map/occupancy_heat_map_state.dart b/lib/pages/analytics/modules/occupancy/blocs/occupancy_heat_map/occupancy_heat_map_state.dart new file mode 100644 index 00000000..b477fed1 --- /dev/null +++ b/lib/pages/analytics/modules/occupancy/blocs/occupancy_heat_map/occupancy_heat_map_state.dart @@ -0,0 +1,30 @@ +part of 'occupancy_heat_map_bloc.dart'; + +enum OccupancyHeatMapStatus { initial, loading, loaded, failure } + +final class OccupancyHeatMapState extends Equatable { + const OccupancyHeatMapState({ + this.status = OccupancyHeatMapStatus.initial, + this.heatMapData = const [], + this.errorMessage, + }); + + final OccupancyHeatMapStatus status; + final String? errorMessage; + final List heatMapData; + + OccupancyHeatMapState copyWith({ + OccupancyHeatMapStatus? status, + List? heatMapData, + String? errorMessage, + }) { + return OccupancyHeatMapState( + status: status ?? this.status, + heatMapData: heatMapData ?? this.heatMapData, + errorMessage: errorMessage ?? this.errorMessage, + ); + } + + @override + List get props => [status, errorMessage, heatMapData]; +} diff --git a/lib/pages/analytics/modules/occupancy/helpers/fetch_occupancy_data_helper.dart b/lib/pages/analytics/modules/occupancy/helpers/fetch_occupancy_data_helper.dart index 4141a5c3..65805e77 100644 --- a/lib/pages/analytics/modules/occupancy/helpers/fetch_occupancy_data_helper.dart +++ b/lib/pages/analytics/modules/occupancy/helpers/fetch_occupancy_data_helper.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_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/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/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/params/get_occupancy_heat_map_param.dart'; import 'package:syncrow_web/pages/analytics/params/get_occupancy_param.dart'; abstract final class FetchOccupancyDataHelper { @@ -11,14 +14,36 @@ abstract final class FetchOccupancyDataHelper { static void loadOccupancyData(BuildContext context) { final (selectedCommunities, selectedSpaces) = FetchEnergyManagementDataHelper.getSelectedCommunitiesAndSpaces(context); - if (selectedCommunities.isEmpty && selectedSpaces.isEmpty) return; + if (selectedCommunities.isEmpty && selectedSpaces.isEmpty) { + context.read().add( + const ClearOccupancyEvent(), + ); + context.read().add( + const ClearOccupancyHeatMapEvent(), + ); + return; + } + + final datePickerState = context.read().state; context.read().add( LoadOccupancyEvent( GetOccupancyParam( - monthDate: '04-2022', - spaceUuid: '', - communityUuid: '', + monthDate: + '${datePickerState.monthlyDate.year}-${datePickerState.monthlyDate.month}', + spaceUuid: selectedSpaces.firstOrNull, + communityUuid: selectedCommunities.first, + ), + ), + ); + + context.read().add( + LoadOccupancyHeatMapEvent( + GetOccupancyHeatMapParam( + spaceId: selectedSpaces.isNotEmpty ? selectedSpaces.first : '', + communityId: + selectedCommunities.isNotEmpty ? selectedCommunities.first : '', + year: datePickerState.yearlyDate, ), ), ); diff --git a/lib/pages/analytics/modules/occupancy/views/analytics_occupancy_view.dart b/lib/pages/analytics/modules/occupancy/views/analytics_occupancy_view.dart index 762026ab..56ad500e 100644 --- a/lib/pages/analytics/modules/occupancy/views/analytics_occupancy_view.dart +++ b/lib/pages/analytics/modules/occupancy/views/analytics_occupancy_view.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:syncrow_web/pages/analytics/modules/occupancy/helpers/fetch_occupancy_data_helper.dart'; import 'package:syncrow_web/pages/analytics/modules/occupancy/widgets/occupancy_chart_box.dart'; import 'package:syncrow_web/pages/analytics/modules/occupancy/widgets/occupancy_end_side_bar.dart'; +import 'package:syncrow_web/pages/analytics/modules/occupancy/widgets/occupancy_heat_map_box.dart'; class AnalyticsOccupancyView extends StatefulWidget { const AnalyticsOccupancyView({super.key}); @@ -42,21 +43,21 @@ class _AnalyticsOccupancyViewState extends State { return SingleChildScrollView( child: Container( padding: AnalyticsOccupancyView._padding, - height: height * 1, + height: height * 0.9, child: const Row( spacing: 32, children: [ Expanded( - flex: 2, + flex: 5, child: Column( spacing: 20, children: [ Expanded(child: OccupancyChartBox()), - Expanded(child: Placeholder()), + Expanded(child: OccupancyHeatMapBox()), ], ), ), - Expanded(child: OccupancyEndSideBar()), + Expanded(flex: 2, child: OccupancyEndSideBar()), ], ), ), diff --git a/lib/pages/analytics/modules/occupancy/widgets/occupancy_chart.dart b/lib/pages/analytics/modules/occupancy/widgets/occupancy_chart.dart index fccc5efc..dc1357bc 100644 --- a/lib/pages/analytics/modules/occupancy/widgets/occupancy_chart.dart +++ b/lib/pages/analytics/modules/occupancy/widgets/occupancy_chart.dart @@ -80,10 +80,11 @@ class OccupancyChart extends StatelessWidget { }) { final data = chartData; - final month = double.parse(data[group.x.toInt()].occupancy).toStringAsFixed(2); + final occupancyValue = double.parse(data[group.x.toInt()].occupancy); + final percentage = '${(occupancyValue * 100).toStringAsFixed(0)}%'; return BarTooltipItem( - month, + percentage, context.textTheme.bodyMedium!.copyWith( color: ColorsManager.blackColor, fontSize: 14, diff --git a/lib/pages/analytics/modules/occupancy/widgets/occupancy_chart_box.dart b/lib/pages/analytics/modules/occupancy/widgets/occupancy_chart_box.dart index d051cb8d..b9fcbeeb 100644 --- a/lib/pages/analytics/modules/occupancy/widgets/occupancy_chart_box.dart +++ b/lib/pages/analytics/modules/occupancy/widgets/occupancy_chart_box.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_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/widgets/analytics_date_filter_button.dart'; import 'package:syncrow_web/pages/analytics/modules/energy_management/widgets/chart_title.dart'; import 'package:syncrow_web/pages/analytics/modules/occupancy/blocs/occupancy/occupancy_bloc.dart'; +import 'package:syncrow_web/pages/analytics/modules/occupancy/helpers/fetch_occupancy_data_helper.dart'; import 'package:syncrow_web/pages/analytics/modules/occupancy/widgets/occupancy_chart.dart'; import 'package:syncrow_web/pages/analytics/widgets/analytics_error_widget.dart'; import 'package:syncrow_web/utils/style.dart'; @@ -23,9 +25,9 @@ class OccupancyChartBox extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ AnalyticsErrorWidget(state.errorMessage), - const Row( + Row( children: [ - Expanded( + const Expanded( flex: 3, child: FittedBox( alignment: AlignmentDirectional.centerStart, @@ -33,11 +35,23 @@ class OccupancyChartBox extends StatelessWidget { child: ChartTitle(title: Text('Occupancy')), ), ), - Spacer(), + const Spacer(), Expanded( child: FittedBox( fit: BoxFit.scaleDown, - child: AnalyticsDateFilterButton(), + alignment: AlignmentDirectional.centerEnd, + child: AnalyticsDateFilterButton( + onDateSelected: (DateTime value) { + context.read().add( + UpdateAnalyticsDatePickerEvent(montlyDate: value), + ); + FetchOccupancyDataHelper.loadOccupancyData(context); + }, + selectedDate: context + .watch() + .state + .monthlyDate, + ), ), ), ], diff --git a/lib/pages/analytics/modules/occupancy/widgets/occupancy_heat_map.dart b/lib/pages/analytics/modules/occupancy/widgets/occupancy_heat_map.dart new file mode 100644 index 00000000..6fd4b259 --- /dev/null +++ b/lib/pages/analytics/modules/occupancy/widgets/occupancy_heat_map.dart @@ -0,0 +1,84 @@ +import 'dart:math' as math show max; + +import 'package:flutter/material.dart'; +import 'package:syncrow_web/pages/analytics/modules/occupancy/widgets/occupancy_heat_map_days.dart'; +import 'package:syncrow_web/pages/analytics/modules/occupancy/widgets/occupancy_heat_map_gradient.dart'; +import 'package:syncrow_web/pages/analytics/modules/occupancy/widgets/occupancy_heat_map_months.dart'; +import 'package:syncrow_web/pages/analytics/modules/occupancy/widgets/occupancy_painter.dart'; +import 'package:syncrow_web/utils/color_manager.dart'; + +class OccupancyHeatMap extends StatelessWidget { + const OccupancyHeatMap({required this.heatMapData, super.key}); + final Map heatMapData; + + static const _cellSize = 16.0; + static const _totalWeeks = 53; + + int get _maxValue => heatMapData.isNotEmpty + ? heatMapData.keys.map((key) => heatMapData[key]!).reduce(math.max) + : 0; + + DateTime _getStartingDate() { + final jan1 = DateTime(DateTime.now().year, 1, 1); + final startOfWeek = jan1.subtract(Duration(days: jan1.weekday - 1)); + return startOfWeek; + } + + List _generatePaintItems(DateTime startDate) { + return List.generate(_totalWeeks * 7, (index) { + final date = startDate.add(Duration(days: index)); + final value = heatMapData[date] ?? 0; + return OccupancyPaintItem(index: index, value: value); + }); + } + + @override + Widget build(BuildContext context) { + final startDate = _getStartingDate(); + final paintItems = _generatePaintItems(startDate); + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + OccupancyHeatMapMonths(startDate: startDate, cellSize: _cellSize), + Container( + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide(color: ColorsManager.grayBorder), + top: BorderSide(color: ColorsManager.grayBorder), + ), + ), + width: double.infinity, + child: Row( + children: [ + Expanded( + child: FittedBox( + fit: BoxFit.fill, + child: Row( + children: [ + const OccupancyHeatMapDays(cellSize: _cellSize), + CustomPaint( + size: const Size(_totalWeeks * _cellSize, 7 * _cellSize), + child: CustomPaint( + isComplex: true, + size: const Size(_totalWeeks * _cellSize, 7 * _cellSize), + painter: OccupancyPainter( + items: paintItems, + maxValue: _maxValue, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + const SizedBox(height: 20), + OccupancyHeatMapGradient(maxValue: _maxValue), + ], + ); + } +} diff --git a/lib/pages/analytics/modules/occupancy/widgets/occupancy_heat_map_box.dart b/lib/pages/analytics/modules/occupancy/widgets/occupancy_heat_map_box.dart new file mode 100644 index 00000000..fa6eef47 --- /dev/null +++ b/lib/pages/analytics/modules/occupancy/widgets/occupancy_heat_map_box.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_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/widgets/analytics_date_filter_button.dart'; +import 'package:syncrow_web/pages/analytics/modules/energy_management/widgets/chart_title.dart'; +import 'package:syncrow_web/pages/analytics/modules/occupancy/blocs/occupancy_heat_map/occupancy_heat_map_bloc.dart'; +import 'package:syncrow_web/pages/analytics/modules/occupancy/helpers/fetch_occupancy_data_helper.dart'; +import 'package:syncrow_web/pages/analytics/modules/occupancy/widgets/occupancy_heat_map.dart'; +import 'package:syncrow_web/pages/analytics/widgets/analytics_error_widget.dart'; +import 'package:syncrow_web/utils/style.dart'; + +class OccupancyHeatMapBox extends StatelessWidget { + const OccupancyHeatMapBox({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Container( + padding: const EdgeInsets.all(30), + decoration: containerWhiteDecoration, + child: Column( + spacing: 20, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AnalyticsErrorWidget(state.errorMessage), + Row( + children: [ + const Expanded( + flex: 3, + child: FittedBox( + alignment: AlignmentDirectional.centerStart, + fit: BoxFit.scaleDown, + child: ChartTitle(title: Text('Occupancy Heat Map')), + ), + ), + const Spacer(), + Expanded( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: AlignmentDirectional.centerEnd, + child: AnalyticsDateFilterButton( + onDateSelected: (DateTime value) { + context.read().add( + UpdateAnalyticsDatePickerEvent(yearlyDate: value), + ); + FetchOccupancyDataHelper.loadOccupancyData(context); + }, + datePickerType: DatePickerType.year, + selectedDate: context + .watch() + .state + .yearlyDate, + ), + ), + ), + ], + ), + const Divider(height: 0), + Expanded( + child: OccupancyHeatMap( + heatMapData: state.heatMapData.asMap().map( + (_, value) => MapEntry(value.date, value.occupancy), + ), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/pages/analytics/modules/occupancy/widgets/occupancy_heat_map_days.dart b/lib/pages/analytics/modules/occupancy/widgets/occupancy_heat_map_days.dart new file mode 100644 index 00000000..ff754581 --- /dev/null +++ b/lib/pages/analytics/modules/occupancy/widgets/occupancy_heat_map_days.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:syncrow_web/utils/color_manager.dart'; +import 'package:syncrow_web/utils/extension/build_context_x.dart'; + +class OccupancyHeatMapDays extends StatelessWidget { + const OccupancyHeatMapDays({ + required this.cellSize, + this.textColor = ColorsManager.blackColor, + super.key, + }); + + final double cellSize; + final Color textColor; + + static const _weekDayLabels = [ + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + 'Sunday', + ]; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: List.generate(7, (i) { + final dayLabel = _weekDayLabels[i]; + return Container( + height: cellSize, + alignment: AlignmentDirectional.centerStart, + margin: const EdgeInsetsDirectional.all(0.5).add( + const EdgeInsetsDirectional.only(end: 4), + ), + padding: const EdgeInsets.only(right: 6), + child: Text( + dayLabel, + textAlign: TextAlign.start, + style: context.textTheme.bodySmall?.copyWith( + color: textColor, + fontSize: 8, + fontWeight: FontWeight.w500, + ), + ), + ); + }), + ); + } +} diff --git a/lib/pages/analytics/modules/occupancy/widgets/occupancy_heat_map_gradient.dart b/lib/pages/analytics/modules/occupancy/widgets/occupancy_heat_map_gradient.dart new file mode 100644 index 00000000..53f1e6c9 --- /dev/null +++ b/lib/pages/analytics/modules/occupancy/widgets/occupancy_heat_map_gradient.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:syncrow_web/utils/color_manager.dart'; + +class OccupancyHeatMapGradient extends StatelessWidget { + const OccupancyHeatMapGradient({super.key, required this.maxValue}); + + final int maxValue; + List _heatMapColors() { + if (maxValue == 0) { + return [ + ColorsManager.vividBlue.withValues(alpha: 0), + ColorsManager.vividBlue.withValues(alpha: 0), + ]; + } + return List.generate( + maxValue + 1, + (index) => ColorsManager.vividBlue.withValues(alpha: index / maxValue), + ); + } + + @override + Widget build(BuildContext context) { + return Row( + children: [ + const Spacer(), + Tooltip( + message: 'Min: 0 - Max: $maxValue', + child: Container( + width: 150, + height: 20, + decoration: BoxDecoration( + border: Border.all( + color: ColorsManager.grayBorder, + width: 1, + ), + gradient: LinearGradient( + begin: AlignmentDirectional.centerEnd, + end: AlignmentDirectional.centerStart, + colors: _heatMapColors(), + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/pages/analytics/modules/occupancy/widgets/occupancy_heat_map_months.dart b/lib/pages/analytics/modules/occupancy/widgets/occupancy_heat_map_months.dart new file mode 100644 index 00000000..cbb01a7b --- /dev/null +++ b/lib/pages/analytics/modules/occupancy/widgets/occupancy_heat_map_months.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:syncrow_web/pages/analytics/modules/occupancy/widgets/occupancy_heat_map_days.dart'; +import 'package:syncrow_web/utils/color_manager.dart'; + +class OccupancyHeatMapMonths extends StatelessWidget { + const OccupancyHeatMapMonths({ + required this.startDate, + required this.cellSize, + super.key, + }); + + final DateTime startDate; + final double cellSize; + + @override + Widget build(BuildContext context) { + return Container( + height: 48, + width: double.infinity, + color: Colors.transparent, + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + OccupancyHeatMapDays( + cellSize: cellSize / 3, + textColor: Colors.transparent, + ), + ...List.generate(12, (monthIndex) { + final monthStartDate = DateTime(startDate.year, monthIndex + 1, 1); + final monthName = DateFormat.MMM().format(monthStartDate); + return Expanded( + child: RotatedBox( + quarterTurns: 3, + child: Container( + padding: EdgeInsetsDirectional.zero, + margin: EdgeInsetsDirectional.zero, + decoration: const BoxDecoration( + border: Border( + top: BorderSide(color: ColorsManager.borderColor), + ), + ), + width: cellSize * 4, + child: Padding( + padding: const EdgeInsets.only(left: 4, top: 2), + child: Text( + monthName, + style: const TextStyle(fontSize: 8), + ), + ), + ), + ), + ); + }), + ], + ), + ); + } +} diff --git a/lib/pages/analytics/modules/occupancy/widgets/occupancy_painter.dart b/lib/pages/analytics/modules/occupancy/widgets/occupancy_painter.dart new file mode 100644 index 00000000..8cdb61e4 --- /dev/null +++ b/lib/pages/analytics/modules/occupancy/widgets/occupancy_painter.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:syncrow_web/utils/color_manager.dart'; + +class OccupancyPaintItem { + final int index; + final int value; + + const OccupancyPaintItem({required this.index, required this.value}); +} + +class OccupancyPainter extends CustomPainter { + OccupancyPainter({ + required this.items, + required this.maxValue, + }); + + final List items; + final int maxValue; + + static const double cellSize = 16.0; + + @override + void paint(Canvas canvas, Size size) { + final Paint fillPaint = Paint(); + final Paint borderPaint = Paint() + ..color = ColorsManager.grayBorder.withValues(alpha: 0.4) + ..style = PaintingStyle.stroke; + + for (final item in items) { + final column = item.index ~/ 7; + final row = item.index % 7; + + final x = column * cellSize; + final y = row * cellSize; + + fillPaint.color = _getColor(item.value); + final rect = Rect.fromLTWH(x, y, cellSize, cellSize); + canvas.drawRect(rect, fillPaint); + + _drawDashedLine( + canvas, + Offset(x, y), + Offset(x + cellSize, y), + borderPaint, + ); + _drawDashedLine( + canvas, + Offset(x, y + cellSize), + Offset(x + cellSize, y + cellSize), + borderPaint, + ); + + canvas.drawLine(Offset(x, y), Offset(x, y + cellSize), borderPaint); + canvas.drawLine( + Offset(x + cellSize, y), Offset(x + cellSize, y + cellSize), borderPaint); + } + } + + void _drawDashedLine(Canvas canvas, Offset start, Offset end, Paint paint) { + const double dashWidth = 2.0; + const double dashSpace = 4.0; + final double totalLength = (end - start).distance; + final Offset direction = (end - start) / (end - start).distance; + + double currentLength = 0.0; + while (currentLength < totalLength) { + final Offset dashStart = start + direction * currentLength; + final double nextLength = currentLength + dashWidth; + final Offset dashEnd = + start + direction * (nextLength < totalLength ? nextLength : totalLength); + canvas.drawLine(dashStart, dashEnd, paint); + currentLength = nextLength + dashSpace; + } + } + + Color _getColor(int value) { + if (maxValue == 0) return ColorsManager.vividBlue.withValues(alpha: 0); + final opacity = value.clamp(0, maxValue) / maxValue; + return ColorsManager.vividBlue.withValues(alpha: opacity); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} diff --git a/lib/pages/analytics/params/get_occupancy_heat_map_param.dart b/lib/pages/analytics/params/get_occupancy_heat_map_param.dart new file mode 100644 index 00000000..f6649f78 --- /dev/null +++ b/lib/pages/analytics/params/get_occupancy_heat_map_param.dart @@ -0,0 +1,19 @@ +class GetOccupancyHeatMapParam { + final DateTime year; + final String communityId; + final String spaceId; + + const GetOccupancyHeatMapParam({ + required this.year, + required this.communityId, + required this.spaceId, + }); + + Map toJson() { + return { + 'year': year.toIso8601String(), + 'communityId': communityId, + 'spaceId': spaceId, + }; + } +} diff --git a/lib/pages/analytics/params/get_total_energy_consumption_param.dart b/lib/pages/analytics/params/get_total_energy_consumption_param.dart index 47b75cb8..9f76db9b 100644 --- a/lib/pages/analytics/params/get_total_energy_consumption_param.dart +++ b/lib/pages/analytics/params/get_total_energy_consumption_param.dart @@ -1,19 +1,21 @@ class GetTotalEnergyConsumptionParam { - final DateTime? startDate; - final DateTime? endDate; + final DateTime? monthDate; final String? spaceId; + final String? communityId; const GetTotalEnergyConsumptionParam({ - this.startDate, - this.endDate, + this.monthDate, this.spaceId, + this.communityId, }); Map toJson() { return { - 'startDate': startDate?.toIso8601String(), - 'endDate': endDate?.toIso8601String(), - 'spaceId': spaceId, + 'monthDate': + '${monthDate?.year}-${monthDate?.month.toString().padLeft(2, '0')}', + if (communityId == null || communityId!.isEmpty) 'spaceUuid': spaceId, + 'communityUuid': communityId, + 'groupByDevice': false, }; } } diff --git a/lib/pages/analytics/services/occupancy_heat_map/fake_occupancy_heat_map_service.dart b/lib/pages/analytics/services/occupancy_heat_map/fake_occupancy_heat_map_service.dart new file mode 100644 index 00000000..852e9e8c --- /dev/null +++ b/lib/pages/analytics/services/occupancy_heat_map/fake_occupancy_heat_map_service.dart @@ -0,0 +1,25 @@ +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'; + +class FakeOccupancyHeatMapService implements OccupancyHeatMapService { + @override + Future> load(GetOccupancyHeatMapParam param) { + return Future.delayed(const Duration(milliseconds: 200), () { + final now = DateTime.now(); + final startOfYear = DateTime(now.year, 1, 1); + final endOfYear = DateTime(now.year, 12, 31); + final daysInYear = endOfYear.difference(startOfYear).inDays + 1; + + final List data = List.generate( + daysInYear, + (index) => OccupancyHeatMapModel( + date: startOfYear.add(Duration(days: index)), + occupancy: ((index + 1) * 10) % 100, + ), + ); + + return data; + }); + } +} diff --git a/lib/pages/analytics/services/occupancy_heat_map/occupancy_heat_map_service.dart b/lib/pages/analytics/services/occupancy_heat_map/occupancy_heat_map_service.dart new file mode 100644 index 00000000..1a54c0a9 --- /dev/null +++ b/lib/pages/analytics/services/occupancy_heat_map/occupancy_heat_map_service.dart @@ -0,0 +1,6 @@ +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'; + +abstract interface class OccupancyHeatMapService { + Future> load(GetOccupancyHeatMapParam param); +} diff --git a/lib/pages/analytics/services/total_energy_consumption/fake_total_energy_consumption_service.dart b/lib/pages/analytics/services/total_energy_consumption/fake_total_energy_consumption_service.dart deleted file mode 100644 index a4a8a62d..00000000 --- a/lib/pages/analytics/services/total_energy_consumption/fake_total_energy_consumption_service.dart +++ /dev/null @@ -1,19 +0,0 @@ -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'; - -class FakeTotalEnergyConsumptionService implements TotalEnergyConsumptionService { - @override - Future> load( - GetTotalEnergyConsumptionParam param, - ) { - return Future.value( - List.generate(30, (index) { - return EnergyDataModel( - date: DateTime(2025, 1, index + 1), - value: 20000 + (index * 1000) % 5000, - ); - }), - ); - } -} diff --git a/lib/pages/analytics/services/total_energy_consumption/remote_total_energy_consumption_service.dart b/lib/pages/analytics/services/total_energy_consumption/remote_total_energy_consumption_service.dart index 193404ca..8c3041eb 100644 --- a/lib/pages/analytics/services/total_energy_consumption/remote_total_energy_consumption_service.dart +++ b/lib/pages/analytics/services/total_energy_consumption/remote_total_energy_consumption_service.dart @@ -14,20 +14,37 @@ class RemoteTotalEnergyConsumptionService implements TotalEnergyConsumptionServi ) async { try { final response = await _httpService.get( - path: 'endpoint', + path: '/power-clamp/historical', showServerMessage: true, - expectedResponseModel: (data) { - final json = data as Map? ?? {}; - final mappedData = json['data'] as List? ?? []; - return mappedData.map((e) { - final jsonData = e as Map; - return EnergyDataModel.fromJson(jsonData); - }).toList(); - }, + queryParameters: param.toJson(), + expectedResponseModel: _TotalEnergyConsumptionResponseMapper.map, ); + return response; } catch (e) { throw Exception('Failed to load total energy consumption: $e'); } } } + +abstract final class _TotalEnergyConsumptionResponseMapper { + const _TotalEnergyConsumptionResponseMapper._(); + + static List map(dynamic data) { + final json = data as Map? ?? {}; + final dailyData = json['data'] as List? ?? []; + + return dailyData.map((dayData) { + final date = dayData['date'] as String; + final energyValue = double.tryParse( + dayData['total_energy_consumed_kw'] as String? ?? '0', + ) ?? + 0.0; + + return EnergyDataModel( + date: DateTime.parse(date), + value: energyValue, + ); + }).toList(); + } +}