Compare commits

..

76 Commits

Author SHA1 Message Date
e0ad7855d3 converted GetOccupancyParam.toJson to an expression method. 2025-05-21 10:59:04 +03:00
ecf588cfcb reverted to dynamic endpoint. 2025-05-21 10:58:21 +03:00
c9d15d102b fixes in OccupancyChart for a more pleasant UI. 2025-05-21 10:57:53 +03:00
02b07cfdb6 Merge branch 'dev' of https://github.com/SyncrowIOT/web into SP-1510-occupancy_chart_api_integration 2025-05-21 10:23:56 +03:00
0a94557eee SP-1510-Occupancy Chart API Integration. 2025-05-21 10:23:31 +03:00
4f8d1c4ffd Merge pull request #195 from SyncrowIOT/charts-reworks
Charts reworks
2025-05-21 10:22:55 +03:00
000fe70663 format. 2025-05-21 09:59:50 +03:00
4257f7f0f3 Corrected color of titles in charts. 2025-05-21 09:55:17 +03:00
b2bf3866a9 Deleted pubspec.lock, and added it to .gitignore. 2025-05-21 09:09:32 +03:00
fd2a09cada Deleted unused FakeEnergyConsumptionPerDeviceService. 2025-05-20 14:22:23 +03:00
4c2802acfc date picker decorations matched with design. 2025-05-20 14:20:16 +03:00
15343be258 show space uuid in analytics devices dropdown. 2025-05-20 14:11:25 +03:00
c21842cc6d removed overflow and fixed sizing and text drawing of PowerClampEnergyStatusWidget. 2025-05-20 13:56:00 +03:00
4326559e14 shows OccupancyHeatMapBox instead of a Placeholder in vertical srcollable AnalyticsOccupancyView. 2025-05-20 13:51:04 +03:00
4ded7d5202 Merge pull request #194 from SyncrowIOT/SP-1448-FE-Use-SliderValueSelector-widget-for-all-slider-widgets-in-Web-Routine
add step parameter in onTapFunction.
2025-05-19 11:37:56 +03:00
0d45a155e3 add step parameter in onTapFunction.
Add dialogType parameter in WaterHeaterPresenceSensor and CeilingSensorDialog.
Update step parameter in FlushValueSelectorWidget.
Update step parameter in FunctionBloc and WaterHeaterFunctions.
Update step, unit, min, and max parameters in ACFunction subclasses.
2025-05-19 11:22:15 +03:00
625f737791 SP-1506 rework
Remove extra line.

The colors of the data on X axis and Y axis are not identical to design.

Display days only on the X axis.

When the bar chart loads, we see it coming from the top (check the attached video).
2025-05-19 11:08:26 +03:00
494ae1c941 SP-1495 reworks.
1. Overlapping line not removed.
2. The colors of the data on X axis and Y axis are not identical to design.
3. Day 1 and 2 are missing on the X axis.
4. When the chart loads, we see it coming from the top right corner (check the attached video).
5. Display all available devices even if they have no data and make the chart empty state.
2025-05-19 10:52:44 +03:00
f67d0e2912 SP-1494 reworks.
1. When the chart loads, we see it coming from the top right corner (check the attached video).
2. Day 1 is missing on the X axis.
3. Overlapping line not removed.
2025-05-19 10:17:48 +03:00
17aad13b2a Merge pull request #193 from SyncrowIOT/feature/make_analytics_date_picker_not_show_future_dates
Feature/make_analytics_date_picker_not_show_future_dates
2025-05-15 16:58:25 +03:00
a849c1dafb removed unused import. 2025-05-15 16:31:11 +03:00
3e3e17019a format. 2025-05-15 16:22:54 +03:00
b1bae3cb15 fixed overflow bug on charts. 2025-05-15 15:59:02 +03:00
051bf657ed Changed background color of analytics date pickers to match the design language of the platform. 2025-05-15 15:29:09 +03:00
5191c1e456 Performed selection validation, and made future dates disabled. 2025-05-15 15:28:36 +03:00
7a073f10aa Merge pull request #189 from SyncrowIOT/1495-calendar-bugfixes
1495 calendar bugfixes
2025-05-15 14:31:11 +03:00
900d47faae Merge pull request #190 from SyncrowIOT/SP-1506-FE-implement-chart-per-phase
SP-1506-FE-chart per phase api integration.
2025-05-15 14:30:58 +03:00
e35a7fdc70 Merge pull request #192 from SyncrowIOT/bugfix/charts-horizontal-lines
bugfix/charts-horizontal-lines
2025-05-15 14:30:37 +03:00
d80f5e1f3a Refactor energy consumption charts to enhance grid data configuration
Updated the grid data for EnergyConsumptionByPhasesChart, EnergyConsumptionPerDeviceChart, and TotalEnergyConsumptionChart to include horizontal line visibility and set a horizontal interval of 250. Removed unused phasesJson constant from TotalEnergyConsumptionChart for cleaner code.
2025-05-15 14:25:13 +03:00
c07b53107e SP-1506-FE-chart per phase api integration. 2025-05-15 10:51:09 +03:00
39d125ac7e loads energy management data on date changed. 2025-05-15 10:11:55 +03:00
ad15d0e138 loads occupancy chart on date changed. 2025-05-15 10:08:41 +03:00
e6d272a60d loads heatmap data on calendar change. 2025-05-15 10:06:13 +03:00
8dfe8d10d4 removed requestType from query parameters of RemoteOccupancyAnalyticsDevicesService._makeRequest. 2025-05-15 10:01:43 +03:00
5279020d08 Merge pull request #188 from SyncrowIOT/1495-energy-consumption-per-device-api-integration
1495-energy-consumption-per-device-api-integration.
2025-05-15 09:32:15 +03:00
da481536c4 1495-energy-consumption-per-device-api-integration. 2025-05-14 16:55:28 +03:00
f21366268a Merge pull request #187 from SyncrowIOT/SP-1509-FE-Implement-devices-status-based-on-the-selected-device-from-the-dropdown-list
Sp 1509 fe implement devices status based on the selected device from the dropdown list
2025-05-14 16:18:51 +03:00
c3aef736fd Merge pull request #186 from SyncrowIOT/1511-occupancy-heat-map-tooltip
1511-occupancy-heat-map-tooltip.
2025-05-14 16:18:08 +03:00
887ac58f40 fixed import. 2025-05-14 15:59:40 +03:00
c709477500 some refactors to further clarify intent. 2025-05-14 15:55:12 +03:00
63e7b3faa2 resets selection and clears data. 2025-05-14 15:47:07 +03:00
0e61e52bf8 Connected devices to widgets, and is currently making the necessary and correct api calls for everything to function properly. 2025-05-14 15:35:22 +03:00
7515b347ce analytics devices integtation. 2025-05-14 15:03:30 +03:00
3dfbcb5935 connect device dropdown to bloc. 2025-05-14 14:31:28 +03:00
4fd4a9b5bf loads analytics devices on sidebar selection. 2025-05-14 13:03:51 +03:00
14fa1b355e Added a uuid property to AnalyticsDevice. 2025-05-14 12:50:27 +03:00
78d4e58996 Added selected device state/event, and clear data event to AnalyticsDevicesBloc. 2025-05-14 12:50:16 +03:00
23b9cb5b78 Injected AnalyticsDevicesBloc into AnalyticsPage. 2025-05-14 12:42:51 +03:00
401d0a9788 Created AnalyticsDevicesBloc. 2025-05-14 12:41:44 +03:00
ac2b0d3fac Created an initial remote implementation of AnalyticsDevicesService. 2025-05-14 12:38:07 +03:00
3be7a377c0 Created AnalyticsDevicesService interface. 2025-05-14 12:37:52 +03:00
e4ee456384 Created empty AnalyticsDevice model. 2025-05-14 12:37:44 +03:00
f02c5d71ba Created GetAnalyticsDevicesParam. 2025-05-14 12:26:16 +03:00
d45ff262c7 Merge branch 'dev' into 1511-occupancy-heat-map-tooltip 2025-05-14 12:05:34 +03:00
ad227febc1 Merge pull request #185 from SyncrowIOT/SP-1512-FE-Apply-Responsive-Behavior-for-Dashboard-Layout-and-Sidebar-Collapse
Sp 1512 fe apply responsive behavior for dashboard layout and sidebar collapse
2025-05-14 12:04:41 +03:00
a9d6c6f4ee 1511-occupancy-heat-map-tooltip. 2025-05-14 12:03:47 +03:00
4d9e57c8b5 Created and connected a remote implementation that fetches the heat map occupancy per space from the API. 2025-05-14 10:51:37 +03:00
d1bb8da484 Updated OccupancyHeatMapModel model with what the api returns, and only used the necessary fields that the api returns for this feature to work. 2025-05-14 10:51:19 +03:00
300f9ae358 Matched the GetOccupancyHeatMapParam with what the API expects and removed the communityId since it is no longer necessary for the api, and renamed spaceId to spaceUuid for more clarity. 2025-05-14 10:49:32 +03:00
c1dab3400b removed a force unwrap from OccupancyHeatMap._maxValue to avoid any bugs. 2025-05-14 10:48:28 +03:00
46815585cb Fixed error in AnalyticsErrorWidget where it used to add the default error message to the errorMessage. 2025-05-14 10:47:54 +03:00
7f9d044f7e Merge pull request #184 from SyncrowIOT/SP-1530-FE-Add-card-for-the-water-heater-in-the-routine-web
add water heater operational values to routines
2025-05-14 09:20:07 +03:00
996a847a27 Refactor water heater value selector widget 2025-05-14 09:16:04 +03:00
5645fb7826 Merge pull request #182 from SyncrowIOT/SP-1519-FE-Handle-Loading-Skeletons-and-No-Data-Error-States
Sp 1519 fe handle loading skeletons and no data error states
2025-05-13 16:55:54 +03:00
e8f7c29652 Applies correct business logic of the sidebar. 2025-05-13 16:46:34 +03:00
36c5712c79 add water heater operational values to routines 2025-05-13 16:24:08 +03:00
c7fef11aec Fixed typo Tab to run to Tap to run. 2025-05-12 12:06:37 +03:00
ef29d78d70 Clears data when needed. 2025-05-12 10:02:56 +03:00
cd9941f544 Doesn't load occupancy data on initState in AnalyticsOccupancyView. 2025-05-12 10:02:08 +03:00
71aa64ba9e Merge pull request #181 from SyncrowIOT/bugfix/analytics_expansion_bugfix
bugfix/analytics_expansion_bugfix.
2025-05-12 09:22:12 +03:00
2262d3b2ba bugfix/analytics_expansion_bugfix. 2025-05-12 09:20:01 +03:00
b7ef9da35d Sp 1513 fe implement device dropdown and live status card presence vacancy (#179)
* Called the widget of presence sensor status widgets.

* Enahnced `PowerClampEnergyDataDeviceDropdown` design and made it a dropdown.

* connected the realtime feature to the occupancy side bar, but with a mock id.

* revert default tab to energyManagement.
2025-05-11 16:59:15 +03:00
49e93329c8 Sp 1511 fe build occupancy heat map weekly monthly intensity view (#178)
* set the default tab to occupancy for ease of development.

* Implemented an initial design for the occupancy chart.

* Add Occupacy model and service for occupancy data handling.

* Created `OccupancyBloc`.

* Implemented the sidebar of Occupancy view.

* Moved `OccupancyEndSideBar` widget to its own file.

* Removed unnecessary widgets.

* Matched the `OccupancyChart` with the figma design.

* Added `AnalyticsDateFilterButton` to `OccupancyChartBox`.

* Hides `AnalyticsDateFilterButton` that is in the page header, when the selected tab isn't `AnalyticsPageTab.energyManagement`.

* Added animation to`AnalyticsDateFilterButton`.

* modified the implementation of `FakeOccupacyService` to clamp all the generated values to less than a 100.

* Injected `OccupancyBloc` into `AnalyticsPage`.

* Made `OccupancyChart` read its data from `OccupancyBloc`.

* Refactor AnalyticsCommunitiesSidebar to load data based on selected tab and implement loadEnergyManagementData method

* Refactor Analytics views to use StatefulWidget and load data in initState

* Created `OccupancyHeatMapModel`.

* Add FakeOccupancyHeatMapService implementation.

* Created `OccupancyHeatMapBloc`.

* Injected `OccupancyHeatMapBloc` into `AnalyticsPage`.

* Add OccupancyHeatMapBox widget and integrate into AnalyticsOccupancyView

* Matching the heat map with the design, and added week days.

* Made the HeatMap cells have a dashed border.

* shows months.

* responsiveness.

* Integrate OccupancyHeatMapBloc and update OccupancyHeatMapBox to display heat map data with error handling

* Integrate OccupancyHeatMapBloc and update OccupancyHeatMapBox to display heat map data with error handling

* made the heatmap loading fast af by using painters instead of individually creating a widget for every single event.

* Extracted `OccupancyHeatMapMonths` into its own widgte.

* Moved `OccupancyHeatMapMonths` to its own file.

* Adjusted design of `OccupancyHeatMapMonths`.

* Adjust layout flex properties for `OccupancyEndSideBar` and its parent column in `AnalyticsOccupancyView`.

* moved `OccupancyPaintItem` to `OccupancyPainter`s file.

* removed comments from `OccupancyPainter`.

* used color.withValues instead of .withOpacity.

* re-added `OccupancyHeatMapGradient`.

* Revert initial tab to `energyManagement`.

* Made datepicker dynamic for multiple states.

* Add year picker functionality to date filter button and implement dynamic date selection

* Align date filter button to the end in occupancy chart and heat map boxes for improved UI consistency.

* Enahnced color of border in `OccupancyPainter`.

* Add ClearOccupancyHeatMapEvent to reset heat map state and update occupancy data helper to trigger event on empty selections

* show percentage of value in tool tip of `OccupancyChart`.
2025-05-11 16:58:13 +03:00
d6f0b53b59 Sp 1494 api integration (#180)
* SP-1494-api-integration.

* fixed left stide titles intervals in total energy consumption chart.

* Adjusted tooltip and title intervals in energy management charts to improve accuracy by incrementing displayed values by one.

* Refactor AnalyticsCommunitiesSidebar to use AnalyticsSpaceTreeView and enhance community/space selection handling

* Gave every tab its own selection logic using the strategy design pattern, along with clearing the selection when changing tabes to avoid collision between features.
2025-05-11 16:46:00 +03:00
7154693379 SP-1495-fix-deployment by wrapping ChartsLoadingWidget.CircularProgressIndicator with a padding instead of adding padding as a property of CircularProgressIndicator. (#175) 2025-05-08 16:32:50 +03:00
2e2bc99501 Merge pull request #176 from SyncrowIOT/SP-1510-FE-Build-Occupancy-Bar-Chart-Monthly-Consumption-View
Sp 1510 fe build occupancy bar chart monthly consumption view
2025-05-08 16:32:21 +03:00
122 changed files with 4837 additions and 2174 deletions

View File

@ -1,10 +0,0 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
- package-ecosystem: "pub"
directory: "/"
schedule:
interval: "daily"

View File

@ -1,26 +0,0 @@
<!--
Thanks for contributing!
Provide a description of your changes below and a general summary in the title
Please look at the following checklist to ensure that your PR can be accepted quickly:
-->
## Jira Ticket
[SP-0000](https://syncrow.atlassian.net/browse/SP-0000)
## Description
<!--- Describe your changes in detail -->
## Type of Change
<!--- Put an `x` in all the boxes that apply: -->
- [ ] ✨ New feature (non-breaking change which adds functionality)
- [ ] 🛠️ Bug fix (non-breaking change which fixes an issue)
- [ ] ❌ Breaking change (fix or feature that would cause existing functionality to change)
- [ ] 🧹 Code refactor
- [ ] ✅ Build configuration change
- [ ] 📝 Documentation
- [ ] 🗑️ Chore

1
.gitignore vendored
View File

@ -30,6 +30,7 @@ migrate_working_dir/
.pub-cache/
.pub/
/build/
pubspec.lock
# Symbolication related
app.*.symbols

View File

@ -10,6 +10,7 @@
analyzer:
errors:
constant_identifier_names: ignore
overridden_fields: ignore
include: package:flutter_lints/flutter.yaml
linter:

View File

@ -0,0 +1,12 @@
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_7305_15779)">
<path d="M17.0872 11.5142C17.0872 13.2025 16.427 14.8021 15.2211 15.9954C14.0278 17.2014 12.4283 17.8615 10.7399 17.8615C9.05141 17.8615 7.45185 17.2014 6.25856 15.9954C5.05262 14.8021 4.39249 13.2025 4.39249 11.5142C4.39249 9.82574 5.05266 8.22618 6.25856 7.03289C7.45185 5.8269 9.05141 5.16681 10.7399 5.16681C11.8063 5.16681 12.8471 5.43337 13.7866 5.95388L11.2984 8.97523H21.0861L18.6486 0L16.2113 2.97053C14.5737 1.91691 12.6948 1.35835 10.7398 1.35835C8.02314 1.35835 5.47142 2.41197 3.55459 4.32888C1.63765 6.24578 0.583984 8.79747 0.583984 11.5142C0.583984 14.2309 1.63765 16.7825 3.55459 18.6994C5.47146 20.6163 8.0231 21.67 10.7398 21.67C13.4565 21.67 16.0082 20.6163 17.925 18.6994C19.8419 16.7825 20.8956 14.2309 20.8956 11.5142V10.8794H17.0872V11.5142Z" fill="#77DD00"/>
<path d="M17.0876 10.8799H20.8961V11.5146C20.8961 14.2313 19.8424 16.7829 17.9254 18.6998C16.0086 20.6168 13.4569 21.6704 10.7402 21.6704V17.862C12.4287 17.862 14.0282 17.2019 15.2215 15.9959C16.4275 14.8026 17.0876 13.203 17.0876 11.5147V10.8799H17.0876Z" fill="#66BB00"/>
<path d="M13.787 5.95388C12.8475 5.43333 11.8066 5.16681 10.7402 5.16681V1.35835C12.6952 1.35835 14.5741 1.91691 16.2117 2.97057L18.6491 0L21.0866 8.97523H11.2989L13.787 5.95388Z" fill="#66BB00"/>
</g>
<defs>
<clipPath id="clip0_7305_15779">
<rect width="21.67" height="21.67" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -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;
}

View File

@ -0,0 +1,71 @@
class AnalyticsDevice {
const AnalyticsDevice({
required this.uuid,
required this.name,
this.createdAt,
this.updatedAt,
this.deviceTuyaUuid,
this.isActive,
this.productDevice,
this.spaceUuid,
});
final String uuid;
final String name;
final DateTime? createdAt;
final DateTime? updatedAt;
final String? deviceTuyaUuid;
final bool? isActive;
final ProductDevice? productDevice;
final String? spaceUuid;
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,
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(),
);
}
}
class ProductDevice {
const ProductDevice({
this.uuid,
this.createdAt,
this.updatedAt,
this.catName,
this.prodId,
this.name,
this.prodType,
});
final String? uuid;
final DateTime? createdAt;
final DateTime? updatedAt;
final String? catName;
final String? prodId;
final String? name;
final String? prodType;
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,
catName: json['catName'] as String?,
prodId: json['prodId'] as String?,
name: json['name'] as String?,
prodType: json['prodType'] as String?,
);
}
}

View File

@ -0,0 +1,32 @@
import 'package:equatable/equatable.dart';
class Occupacy extends Equatable {
final DateTime date;
final String occupancy;
final String spaceUuid;
final int occupiedSeconds;
const Occupacy({
required this.date,
required this.occupancy,
required this.spaceUuid,
required this.occupiedSeconds,
});
factory Occupacy.fromJson(Map<String, dynamic> json) {
return Occupacy(
date: DateTime.parse(json['event_date'] as String? ?? '${DateTime.now()}'),
occupancy: (json['occupancy_percentage'] ?? 0).toString(),
spaceUuid: json['space_uuid'] as String? ?? '',
occupiedSeconds: json['occupied_seconds'] as int? ?? 0,
);
}
@override
List<Object?> get props => [
date,
occupancy,
spaceUuid,
occupiedSeconds,
];
}

View File

@ -0,0 +1,28 @@
import 'package:equatable/equatable.dart';
class OccupancyHeatMapModel extends Equatable {
final String uuid;
final DateTime eventDate;
final int countTotalPresenceDetected;
const OccupancyHeatMapModel({
required this.uuid,
required this.eventDate,
required this.countTotalPresenceDetected,
});
factory OccupancyHeatMapModel.fromJson(Map<String, dynamic> json) {
return OccupancyHeatMapModel(
uuid: json['uuid'] as String? ?? '',
eventDate: DateTime.parse(
json['event_date'] as String? ?? '${DateTime.now()}',
),
countTotalPresenceDetected: json['count_total_presence_detected'] as int? ?? 0,
);
}
@override
List<Object?> get props => [uuid, eventDate, countTotalPresenceDetected];
}

View File

@ -1,27 +1,66 @@
import 'package:equatable/equatable.dart';
class PhasesEnergyConsumption extends Equatable {
final int month;
final double phaseA;
final double phaseB;
final double phaseC;
final String uuid;
final DateTime createdAt;
final DateTime updatedAt;
final String deviceUuid;
final DateTime date;
final double energyConsumedKw;
final double energyConsumedA;
final double energyConsumedB;
final double energyConsumedC;
const PhasesEnergyConsumption({
required this.month,
required this.phaseA,
required this.phaseB,
required this.phaseC,
required this.uuid,
required this.createdAt,
required this.updatedAt,
required this.deviceUuid,
required this.date,
required this.energyConsumedKw,
required this.energyConsumedA,
required this.energyConsumedB,
required this.energyConsumedC,
});
@override
List<Object?> get props => [month, phaseA, phaseB, phaseC];
List<Object?> get props => [
uuid,
createdAt,
updatedAt,
deviceUuid,
date,
energyConsumedKw,
energyConsumedA,
energyConsumedB,
energyConsumedC,
];
factory PhasesEnergyConsumption.fromJson(Map<String, dynamic> json) {
return PhasesEnergyConsumption(
month: json['month'] as int,
phaseA: (json['phaseA'] as num).toDouble(),
phaseB: (json['phaseB'] as num).toDouble(),
phaseC: (json['phaseC'] as num).toDouble(),
uuid: json['uuid'] as String,
createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: DateTime.parse(json['updatedAt'] as String),
deviceUuid: json['deviceUuid'] as String,
date: DateTime.parse(json['date'] as String),
energyConsumedKw: double.parse(json['energyConsumedKw']),
energyConsumedA: double.parse(json['energyConsumedA']),
energyConsumedB: double.parse(json['energyConsumedB']),
energyConsumedC: double.parse(json['energyConsumedC']),
);
}
Map<String, dynamic> toJson() {
return {
'uuid': uuid,
'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(),
'deviceUuid': deviceUuid,
'date': date.toIso8601String().split('T')[0],
'energyConsumedKw': energyConsumedKw.toString(),
'energyConsumedA': energyConsumedA.toString(),
'energyConsumedB': energyConsumedB.toString(),
'energyConsumedC': energyConsumedC.toString(),
};
}
}

View File

@ -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<AnalyticsDatePickerEvent, DateTime> {
AnalyticsDatePickerBloc() : super(DateTime.now()) {
class AnalyticsDatePickerBloc
extends Bloc<AnalyticsDatePickerEvent, AnalyticsDatePickerState> {
AnalyticsDatePickerBloc() : super(AnalyticsDatePickerState()) {
on<UpdateAnalyticsDatePickerEvent>(_onUpdateAnalyticsDatePickerEvent);
}
void _onUpdateAnalyticsDatePickerEvent(
UpdateAnalyticsDatePickerEvent event,
Emitter<DateTime> emit,
Emitter<AnalyticsDatePickerState> emit,
) {
emit(event.date);
emit(
state.copyWith(
monthlyDate: event.montlyDate ?? state.monthlyDate,
yearlyDate: event.yearlyDate ?? state.yearlyDate,
),
);
}
}

View File

@ -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<Object?> get props => [date];
List<Object?> get props => [montlyDate, yearlyDate];
}

View File

@ -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<Object?> get props => [monthlyDate, yearlyDate];
}

View File

@ -0,0 +1,69 @@
import 'package:bloc/bloc.dart';
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';
part 'analytics_devices_event.dart';
part 'analytics_devices_state.dart';
class AnalyticsDevicesBloc
extends Bloc<AnalyticsDevicesEvent, AnalyticsDevicesState> {
AnalyticsDevicesBloc(
this._analyticsDevicesService,
) : super(const AnalyticsDevicesState()) {
on<LoadAnalyticsDevicesEvent>(_onLoadAnalyticsDevices);
on<SelectAnalyticsDeviceEvent>(_onSelectAnalyticsDevice);
on<ClearAnalyticsDeviceEvent>(_onClearAnalyticsDevice);
}
final AnalyticsDevicesService _analyticsDevicesService;
Future<void> _onLoadAnalyticsDevices(
LoadAnalyticsDevicesEvent event,
Emitter<AnalyticsDevicesState> emit,
) async {
emit(const AnalyticsDevicesState(status: AnalyticsDevicesStatus.loading));
try {
final devices = await _analyticsDevicesService.getDevices(event.param);
emit(
AnalyticsDevicesState(
status: AnalyticsDevicesStatus.loaded,
devices: devices,
selectedDevice: devices.firstOrNull,
),
);
if (devices.isNotEmpty) {
event.onSuccess(devices.first);
}
} catch (e) {
emit(
AnalyticsDevicesState(
status: AnalyticsDevicesStatus.failure,
errorMessage: e.toString(),
),
);
}
}
void _onSelectAnalyticsDevice(
SelectAnalyticsDeviceEvent event,
Emitter<AnalyticsDevicesState> emit,
) {
emit(
AnalyticsDevicesState(
selectedDevice: event.device,
devices: state.devices,
errorMessage: state.errorMessage,
status: state.status,
),
);
}
void _onClearAnalyticsDevice(
ClearAnalyticsDeviceEvent event,
Emitter<AnalyticsDevicesState> emit,
) {
emit(const AnalyticsDevicesState());
}
}

View File

@ -0,0 +1,31 @@
part of 'analytics_devices_bloc.dart';
sealed class AnalyticsDevicesEvent extends Equatable {
const AnalyticsDevicesEvent();
@override
List<Object> get props => [];
}
final class LoadAnalyticsDevicesEvent extends AnalyticsDevicesEvent {
const LoadAnalyticsDevicesEvent({required this.param, required this.onSuccess});
final GetAnalyticsDevicesParam param;
final void Function(AnalyticsDevice device) onSuccess;
@override
List<Object> get props => [param];
}
final class SelectAnalyticsDeviceEvent extends AnalyticsDevicesEvent {
const SelectAnalyticsDeviceEvent(this.device);
final AnalyticsDevice device;
@override
List<Object> get props => [device];
}
final class ClearAnalyticsDeviceEvent extends AnalyticsDevicesEvent {
const ClearAnalyticsDeviceEvent();
}

View File

@ -0,0 +1,20 @@
part of 'analytics_devices_bloc.dart';
enum AnalyticsDevicesStatus { initial, loading, loaded, failure }
final class AnalyticsDevicesState extends Equatable {
const AnalyticsDevicesState({
this.status = AnalyticsDevicesStatus.initial,
this.devices = const [],
this.errorMessage,
this.selectedDevice,
});
final AnalyticsDevicesStatus status;
final List<AnalyticsDevice> devices;
final AnalyticsDevice? selectedDevice;
final String? errorMessage;
@override
List<Object?> get props => [status, devices, errorMessage, selectedDevice];
}

View File

@ -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<SpaceModel> spaces,
);
void onSpaceSelected(
BuildContext context,
CommunityModel community,
SpaceModel space,
);
void onChildSpaceSelected(
BuildContext context,
CommunityModel community,
SpaceModel child,
);
void clearData(BuildContext context);
}

View File

@ -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(),
};
}
}

View File

@ -0,0 +1,80 @@
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<SpaceModel> spaces,
) {
// Add to space tree bloc first
context.read<SpaceTreeBloc>().add(
OnCommunitySelected(
community.uuid,
spaces,
),
);
final spaceTreeState = context.read<SpaceTreeBloc>().state;
if (spaceTreeState.selectedCommunities.contains(community.uuid)) {
clearData(context);
return;
}
FetchEnergyManagementDataHelper.loadEnergyManagementData(
context,
communityId: community.uuid,
spaceId: spaces.isNotEmpty ? spaces.first.uuid ?? '' : '',
);
}
@override
void onSpaceSelected(
BuildContext context,
CommunityModel community,
SpaceModel space,
) {
context.read<SpaceTreeBloc>().add(
OnSpaceSelected(
community,
space.uuid ?? '',
space.children,
),
);
final spaceTreeState = context.read<SpaceTreeBloc>().state;
if (spaceTreeState.selectedCommunities.contains(community.uuid) ||
spaceTreeState.selectedSpaces.contains(space.uuid)) {
clearData(context);
return;
}
FetchEnergyManagementDataHelper.loadEnergyManagementData(
context,
communityId: community.uuid,
spaceId: space.uuid ?? '',
);
}
@override
void onChildSpaceSelected(
BuildContext context,
CommunityModel community,
SpaceModel child,
) {
// Do nothing else as per original implementation
}
@override
void clearData(BuildContext context) {
context.read<SpaceTreeBloc>().add(const SpaceTreeClearSelectionEvent());
FetchEnergyManagementDataHelper.clearAllData(context);
}
}

View File

@ -0,0 +1,84 @@
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<SpaceModel> spaces,
) {
context.read<SpaceTreeBloc>().add(
OnCommunitySelected(
community.uuid,
spaces.isNotEmpty ? [spaces.first] : [],
),
);
final spaceTreeState = context.read<SpaceTreeBloc>().state;
if (spaceTreeState.selectedCommunities.contains(community.uuid)) {
clearData(context);
return;
}
FetchOccupancyDataHelper.loadOccupancyData(
context,
communityId: community.uuid,
spaceId: spaces.isNotEmpty ? spaces.first.uuid ?? '' : '',
);
}
@override
void onSpaceSelected(
BuildContext context,
CommunityModel community,
SpaceModel space,
) {
final spaceTreeBloc = context.read<SpaceTreeBloc>();
final selectedSpacesIds = spaceTreeBloc.state.selectedSpaces;
final isSpaceSelected = selectedSpacesIds.contains(space.uuid);
if (selectedSpacesIds.isEmpty) {
spaceTreeBloc.add(OnCommunitySelected(community.uuid, [space]));
} else if (isSpaceSelected) {
spaceTreeBloc.add(const SpaceTreeClearSelectionEvent());
} else {
spaceTreeBloc
..add(const SpaceTreeClearSelectionEvent())
..add(OnSpaceSelected(community, space.uuid ?? '', []));
}
final spaceTreeState = context.read<SpaceTreeBloc>().state;
if (spaceTreeState.selectedCommunities.contains(community.uuid) ||
spaceTreeState.selectedSpaces.contains(space.uuid)) {
clearData(context);
return;
}
FetchOccupancyDataHelper.loadOccupancyData(
context,
communityId: community.uuid,
spaceId: space.uuid ?? '',
);
}
@override
void onChildSpaceSelected(
BuildContext context,
CommunityModel community,
SpaceModel child,
) {
// Do nothing
}
@override
void clearData(BuildContext context) {
context.read<SpaceTreeBloc>().add(const SpaceTreeClearSelectionEvent());
FetchOccupancyDataHelper.clearAllData(context);
}
}

View File

@ -1,5 +1,7 @@
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_devices/analytics_devices_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';
@ -8,19 +10,39 @@ import 'package:syncrow_web/pages/analytics/modules/energy_management/blocs/ener
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/blocs/total_energy_consumption/total_energy_consumption_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/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/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/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/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';
import 'package:syncrow_web/web_layout/web_scaffold.dart';
class AnalyticsPage extends StatelessWidget {
class AnalyticsPage extends StatefulWidget {
const AnalyticsPage({super.key});
@override
State<AnalyticsPage> createState() => _AnalyticsPageState();
}
class _AnalyticsPageState extends State<AnalyticsPage> {
late final HTTPService _httpService;
@override
void initState() {
super.initState();
_httpService = HTTPService();
}
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
@ -30,22 +52,22 @@ class AnalyticsPage extends StatelessWidget {
),
BlocProvider(
create: (context) => TotalEnergyConsumptionBloc(
FakeTotalEnergyConsumptionService(),
RemoteTotalEnergyConsumptionService(_httpService),
),
),
BlocProvider(
create: (context) => EnergyConsumptionByPhasesBloc(
FakeEnergyConsumptionByPhasesService(),
RemoteEnergyConsumptionByPhasesService(_httpService),
),
),
BlocProvider(
create: (context) => EnergyConsumptionPerDeviceBloc(
FakeEnergyConsumptionPerDeviceService(),
RemoteEnergyConsumptionPerDeviceService(_httpService),
),
),
BlocProvider(
create: (context) => PowerClampInfoBloc(
RemotePowerClampInfoService(HTTPService()),
RemotePowerClampInfoService(_httpService),
),
),
BlocProvider<RealtimeDeviceChangesBloc>(
@ -53,6 +75,25 @@ class AnalyticsPage extends StatelessWidget {
FirebaseRealtimeDeviceService(),
),
),
BlocProvider(
create: (context) => OccupancyBloc(
RemoteOccupancyService(_httpService),
),
),
BlocProvider(
create: (context) => OccupancyHeatMapBloc(
RemoteOccupancyHeatMapService(_httpService),
),
),
BlocProvider(create: (context) => AnalyticsDatePickerBloc()),
BlocProvider(
create: (context) => AnalyticsDevicesBloc(
AnalyticsDevicesServiceDelegate(
RemoteOccupancyAnalyticsDevicesService(_httpService),
RemoteEnergyManagementAnalyticsDevicesService(_httpService),
),
),
),
],
child: const AnalyticsPageForm(),
);

View File

@ -1,55 +1,29 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_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/helpers/fetch_energy_management_data_helper.dart';
import 'package:syncrow_web/pages/space_tree/view/space_tree_view.dart';
import 'package:syncrow_web/pages/analytics/modules/analytics/blocs/analytics_tab/analytics_tab_bloc.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});
@override
Widget build(BuildContext context) {
return Builder(
builder: (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) {
FetchEnergyManagementDataHelper.fetchEnergyManagementData(
context,
);
FetchEnergyManagementDataHelper.loadRealtimeDeviceChanges(
context,
);
context.read<PowerClampInfoBloc>().add(
const ClearPowerClampInfoEvent(),
);
final (selectedCommunities, selectedSpaces) =
FetchEnergyManagementDataHelper
.getSelectedCommunitiesAndSpaces(context);
if (selectedCommunities.isEmpty && selectedSpaces.isEmpty) {
context.read<PowerClampInfoBloc>().add(
const ClearPowerClampInfoEvent(),
);
} else {
FetchEnergyManagementDataHelper.loadPowerClampInfo(
context,
);
}
}
},
);
},
isSide: false,
),
);
},
final selectedTab = context.watch<AnalyticsTabBloc>().state;
final strategy = AnalyticsDataLoadingStrategyFactory.getStrategy(selectedTab);
return Expanded(
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);
},
),
);
}
}

View File

@ -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<AnalyticsDateFilterButton> {
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<AnalyticsDatePickerBloc>().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;
}
}

View File

@ -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<AnalyticsTabBloc>().add(
onPressed: () {
AnalyticsDataLoadingStrategyFactory.getStrategy(tab).clearData(context);
context.read<AnalyticsTabBloc>().add(
UpdateAnalyticsTabEvent(tab),
),
);
},
child: Text(
tab.title,
textAlign: TextAlign.center,

View File

@ -1,9 +1,12 @@
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/pages/space_tree/bloc/space_tree_bloc.dart';
import 'package:syncrow_web/utils/style.dart';
class AnalyticsPageTabsAndChildren extends StatelessWidget {
@ -38,9 +41,7 @@ class AnalyticsPageTabsAndChildren extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.center,
children: [
...AnalyticsPageTab.values.map(
(tab) => AnimatedSwitcher(
switchInCurve: Curves.easeIn,
duration: const Duration(milliseconds: 200),
(tab) => _buildAnimation(
child: AnalyticsPageTabButton(
key: ValueKey(selectedTab),
tab: tab,
@ -53,12 +54,42 @@ class AnalyticsPageTabsAndChildren extends StatelessWidget {
),
),
const Spacer(),
const Expanded(
flex: 2,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: AlignmentDirectional.centerEnd,
child: AnalyticsDateFilterButton(),
Visibility(
key: ValueKey(selectedTab),
visible: selectedTab == AnalyticsPageTab.energyManagement,
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 ?? '',
);
}
},
selectedDate: context
.watch<AnalyticsDatePickerBloc>()
.state
.monthlyDate,
),
),
),
),
],
@ -67,14 +98,18 @@ class AnalyticsPageTabsAndChildren extends StatelessWidget {
),
Expanded(
flex: 8,
child: AnimatedSwitcher(
switchInCurve: Curves.easeIn,
duration: const Duration(milliseconds: 200),
child: selectedTab.child,
),
child: _buildAnimation(child: selectedTab.child),
),
],
),
);
}
Widget _buildAnimation({required Widget child}) {
return AnimatedSwitcher(
switchInCurve: Curves.easeIn,
duration: const Duration(milliseconds: 200),
child: child,
);
}
}

View File

@ -45,7 +45,7 @@ class _MonthPickerWidgetState extends State<MonthPickerWidget> {
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: Theme.of(context).colorScheme.surface,
backgroundColor: ColorsManager.whiteColors,
child: Container(
padding: const EdgeInsetsDirectional.all(20),
width: 320,
@ -121,6 +121,7 @@ class _MonthPickerWidgetState extends State<MonthPickerWidget> {
}
Row _buildYearSelector() {
final currentYear = DateTime.now().year;
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
@ -134,17 +135,35 @@ class _MonthPickerWidgetState extends State<MonthPickerWidget> {
),
const Spacer(),
IconButton(
onPressed: () => setState(() => _currentYear = _currentYear - 1),
onPressed: () {
setState(() {
_currentYear = _currentYear - 1;
});
},
icon: const Icon(
Icons.chevron_left,
color: ColorsManager.grey700,
),
),
IconButton(
onPressed: () => setState(() => _currentYear = _currentYear + 1),
icon: const Icon(
onPressed: _currentYear < currentYear
? () {
setState(() {
_currentYear = _currentYear + 1;
// Clear selected month if it becomes invalid in the new year
if (_currentYear == currentYear &&
_selectedMonth != null &&
_selectedMonth! > DateTime.now().month - 1) {
_selectedMonth = null;
}
});
}
: null,
icon: Icon(
Icons.chevron_right,
color: ColorsManager.grey700,
color: _currentYear < currentYear
? ColorsManager.grey700
: ColorsManager.grey700.withValues(alpha: 0.3),
),
),
],
@ -152,11 +171,13 @@ class _MonthPickerWidgetState extends State<MonthPickerWidget> {
}
Widget _buildMonthsGrid() {
final currentDate = DateTime.now();
final isCurrentYear = _currentYear == currentDate.year;
return GridView.builder(
shrinkWrap: true,
itemCount: 12,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
childAspectRatio: 2.5,
@ -165,25 +186,43 @@ class _MonthPickerWidgetState extends State<MonthPickerWidget> {
),
itemBuilder: (context, index) {
final isSelected = _selectedMonth == index;
final isFutureMonth = isCurrentYear && index > currentDate.month - 1;
return InkWell(
onTap: () => setState(() => _selectedMonth = index),
child: Container(
alignment: Alignment.center,
onTap: isFutureMonth ? null : () => setState(() => _selectedMonth = index),
child: DecoratedBox(
decoration: BoxDecoration(
color: isSelected
? ColorsManager.vividBlue.withValues(alpha: 0.7)
: const Color(0xFFEDF2F7),
borderRadius:
isSelected ? BorderRadius.circular(15) : BorderRadius.zero,
color: const Color(0xFFEDF2F7),
borderRadius: BorderRadius.only(
topLeft: index % 3 == 0 ? const Radius.circular(16) : Radius.zero,
bottomLeft: index % 3 == 0 ? const Radius.circular(16) : Radius.zero,
topRight: index % 3 == 2 ? const Radius.circular(16) : Radius.zero,
bottomRight:
index % 3 == 2 ? const Radius.circular(16) : Radius.zero,
),
),
child: Text(
_monthNames[index],
style: context.textTheme.titleSmall?.copyWith(
fontSize: 12,
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: isSelected
? ColorsManager.whiteColors
: ColorsManager.blackColor.withValues(alpha: 0.8),
fontWeight: FontWeight.w500,
? ColorsManager.vividBlue.withValues(alpha: 0.7)
: isFutureMonth
? ColorsManager.grey700.withValues(alpha: 0.1)
: const Color(0xFFEDF2F7),
borderRadius:
isSelected ? BorderRadius.circular(15) : BorderRadius.zero,
),
child: Text(
_monthNames[index],
style: context.textTheme.titleSmall?.copyWith(
fontSize: 12,
color: isSelected
? ColorsManager.whiteColors
: isFutureMonth
? ColorsManager.blackColor.withValues(alpha: 0.3)
: ColorsManager.blackColor.withValues(alpha: 0.8),
fontWeight: FontWeight.w500,
),
),
),
),

View File

@ -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<SpaceModel> spaces,
)? onSelectCommunity;
final void Function(
CommunityModel community,
SpaceModel space,
)? onSelectSpace;
final void Function(
CommunityModel community,
SpaceModel child,
)? onSelectChildSpace;
@override
State<AnalyticsSpaceTreeView> createState() => _AnalyticsSpaceTreeViewState();
}
class _AnalyticsSpaceTreeViewState extends State<AnalyticsSpaceTreeView> {
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<SpaceTreeBloc, SpaceTreeState>(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<SpaceTreeBloc>().add(
SearchQueryEvent(query),
),
),
const SizedBox(height: 16),
Expanded(
child: state.isSearching
? const Center(child: CircularProgressIndicator())
: SidebarCommunitiesList(
onScrollToEnd: () {
if (!state.paginationIsLoading) {
context.read<SpaceTreeBloc>().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<SpaceTreeBloc>().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<SpaceTreeBloc>().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<Widget> _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<SpaceTreeBloc>().add(
OnSpaceExpanded(community.uuid, child.uuid ?? ''),
);
},
children: _buildNestedSpaces(context, state, child, community),
);
}).toList();
}
}

View File

@ -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';
class YearPickerWidget extends StatefulWidget {
const YearPickerWidget({
super.key,
required this.selectedDate,
required this.onDateSelected,
});
final DateTime selectedDate;
final ValueChanged<DateTime>? onDateSelected;
@override
State<YearPickerWidget> createState() => _YearPickerWidgetState();
}
class _YearPickerWidgetState extends State<YearPickerWidget> {
late int _currentYear;
static final years = List.generate(
DateTime.now().year - (DateTime.now().year - 5) + 1,
(index) => (2020 + index),
).where((year) => year <= DateTime.now().year).toList();
@override
void initState() {
super.initState();
_currentYear = widget.selectedDate.year;
}
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: ColorsManager.whiteColors,
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(),
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: DecoratedBox(
decoration: BoxDecoration(
color: const Color(0xFFEDF2F7),
borderRadius: BorderRadius.only(
topLeft: index % 3 == 0 ? const Radius.circular(16) : Radius.zero,
bottomLeft: index % 3 == 0 ? const Radius.circular(16) : Radius.zero,
topRight: index % 3 == 2 ? const Radius.circular(16) : Radius.zero,
bottomRight:
index % 3 == 2 ? const Radius.circular(16) : Radius.zero,
),
),
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,
),
),
),
),
);
},
);
}
}

View File

@ -1,20 +0,0 @@
import 'package:syncrow_web/pages/analytics/models/phases_energy_consumption.dart';
abstract final class EnergyConsumptionByPhasesChartHelper {
const EnergyConsumptionByPhasesChartHelper._();
static const fakeData = <PhasesEnergyConsumption>[
PhasesEnergyConsumption(month: 1, phaseA: 200, phaseB: 300, phaseC: 400),
PhasesEnergyConsumption(month: 2, phaseA: 300, phaseB: 400, phaseC: 500),
PhasesEnergyConsumption(month: 3, phaseA: 400, phaseB: 500, phaseC: 600),
PhasesEnergyConsumption(month: 4, phaseA: 100, phaseB: 100, phaseC: 100),
PhasesEnergyConsumption(month: 5, phaseA: 300, phaseB: 400, phaseC: 500),
PhasesEnergyConsumption(month: 6, phaseA: 300, phaseB: 100, phaseC: 400),
PhasesEnergyConsumption(month: 7, phaseA: 300, phaseB: 100, phaseC: 400),
PhasesEnergyConsumption(month: 8, phaseA: 500, phaseB: 100, phaseC: 100),
PhasesEnergyConsumption(month: 9, phaseA: 500, phaseB: 100, phaseC: 200),
PhasesEnergyConsumption(month: 10, phaseA: 100, phaseB: 50, phaseC: 50),
PhasesEnergyConsumption(month: 11, phaseA: 600, phaseB: 750, phaseC: 130),
PhasesEnergyConsumption(month: 12, phaseA: 100, phaseB: 80, phaseC: 100),
];
}

View File

@ -21,12 +21,13 @@ abstract final class EnergyManagementChartsHelper {
reservedSize: 32,
showTitles: true,
maxIncluded: true,
minIncluded: true,
getTitlesWidget: (value, meta) => Padding(
padding: const EdgeInsetsDirectional.only(top: 20.0),
child: Text(
value.toString(),
style: context.textTheme.bodySmall?.copyWith(
color: ColorsManager.greyColor,
color: ColorsManager.lightGreyColor,
fontSize: 12,
),
),
@ -36,7 +37,8 @@ abstract final class EnergyManagementChartsHelper {
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
maxIncluded: true,
maxIncluded: false,
minIncluded: true,
interval: leftTitlesInterval,
reservedSize: 110,
getTitlesWidget: (value, meta) => Padding(
@ -91,31 +93,38 @@ abstract final class EnergyManagementChartsHelper {
);
}
static FlBorderData borderData() {
return FlBorderData(
show: true,
border: const Border.symmetric(
horizontal: BorderSide(
color: ColorsManager.greyColor,
style: BorderStyle.solid,
width: 1,
),
),
);
}
static FlGridData gridData() {
return const FlGridData(
return FlGridData(
show: true,
drawVerticalLine: false,
drawHorizontalLine: true,
horizontalInterval: 250,
getDrawingHorizontalLine: (value) {
return FlLine(
color: ColorsManager.greyColor,
strokeWidth: 1,
dashArray: value == 0 ? null : [5, 5],
);
},
);
}
static FlBorderData borderData() {
return FlBorderData(
border: const Border(
bottom: BorderSide(
color: ColorsManager.greyColor,
style: BorderStyle.solid,
),
),
show: true,
);
}
static LineTouchData lineTouchData() {
return LineTouchData(
handleBuiltInTouches: true,
touchSpotThreshold: 2,
touchSpotThreshold: 16,
touchTooltipData: EnergyManagementChartsHelper.lineTouchTooltipData(),
);
}

View File

@ -1,52 +1,80 @@
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_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/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';
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/params/get_analytics_devices_param.dart';
import 'package:syncrow_web/pages/analytics/params/get_energy_consumption_by_phases_param.dart';
import 'package:syncrow_web/pages/analytics/params/get_energy_consumption_per_device_param.dart';
import 'package:syncrow_web/pages/analytics/params/get_total_energy_consumption_param.dart';
import 'package:syncrow_web/pages/space_tree/bloc/space_tree_bloc.dart';
abstract final class FetchEnergyManagementDataHelper {
const FetchEnergyManagementDataHelper._();
static void fetchEnergyManagementData(
BuildContext context, {
DateTime? selectedDate,
}) {
final (selectedCommunities, selectedSpaces) =
getSelectedCommunitiesAndSpaces(context);
// static const String _powerClampId = 'cb71d6ad-6e29-4eaa-ae3e-1a0d1c5f60fa';
static AnalyticsDevice? getSelectedDevice(BuildContext context) {
return context.read<AnalyticsDevicesBloc>().state.selectedDevice;
}
if (selectedCommunities.isEmpty && selectedSpaces.isEmpty) {
static void loadEnergyManagementData(
BuildContext context, {
required String communityId,
required String spaceId,
DateTime? selectedDate,
bool shouldFetchAnalyticsDevices = true,
}) {
if (communityId.isEmpty && spaceId.isEmpty) {
clearAllData(context);
return;
}
loadTotalEnergyConsumption(context);
loadEnergyConsumptionByPhases(context);
loadEnergyConsumptionPerDevice(context);
return;
}
static (List<String> selectedCommunities, List<String> selectedSpaces)
getSelectedCommunitiesAndSpaces(BuildContext context) {
final spaceTreeState = context.read<SpaceTreeBloc>().state;
final selectedCommunities = spaceTreeState.selectedCommunities;
final selectedSpaces = spaceTreeState.selectedSpaces;
return (selectedCommunities, selectedSpaces);
final datePickerState = context.read<AnalyticsDatePickerBloc>().state;
final selectedDate0 = selectedDate ?? datePickerState.monthlyDate;
if (shouldFetchAnalyticsDevices) {
loadAnalyticsDevices(
context,
communityUuid: communityId,
spaceUuid: spaceId,
selectedDate: selectedDate0,
);
loadRealtimeDeviceChanges(context);
loadPowerClampInfo(context);
}
loadTotalEnergyConsumption(
context,
selectedDate: selectedDate0,
communityId: communityId,
spaceId: spaceId,
);
final selectedDevice = getSelectedDevice(context);
if (selectedDevice case final AnalyticsDevice device) {
loadEnergyConsumptionByPhases(
context,
powerClampUuid: device.uuid,
selectedDate: selectedDate0,
);
}
loadEnergyConsumptionPerDevice(
context,
communityId: communityId,
spaceId: spaceId,
selectedDate: selectedDate0,
);
}
static void loadEnergyConsumptionByPhases(
BuildContext context, {
required String powerClampUuid,
DateTime? selectedDate,
}) {
final param = GetEnergyConsumptionByPhasesParam(
startDate: selectedDate,
spaceId: '',
date: selectedDate,
powerClampUuid: powerClampUuid,
);
context.read<EnergyConsumptionByPhasesBloc>().add(
LoadEnergyConsumptionByPhasesEvent(param: param),
@ -56,46 +84,94 @@ abstract final class FetchEnergyManagementDataHelper {
static void loadTotalEnergyConsumption(
BuildContext context, {
DateTime? selectedDate,
required String communityId,
required String spaceId,
}) {
final (selectedCommunities, selectedSpaces) =
getSelectedCommunitiesAndSpaces(context);
final param = GetTotalEnergyConsumptionParam(
spaceId: selectedCommunities.firstOrNull,
startDate: selectedDate,
spaceId: spaceId,
communityId: communityId,
monthDate: selectedDate,
);
context.read<TotalEnergyConsumptionBloc>().add(
TotalEnergyConsumptionLoadEvent(param: param),
);
}
static void loadEnergyConsumptionPerDevice(BuildContext context) {
const param = GetEnergyConsumptionPerDeviceParam();
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(
const LoadEnergyConsumptionPerDeviceEvent(param),
LoadEnergyConsumptionPerDeviceEvent(param),
);
}
static void loadPowerClampInfo(BuildContext context) {
context.read<PowerClampInfoBloc>().add(
const LoadPowerClampInfoEvent('cb71d6ad-6e29-4eaa-ae3e-1a0d1c5f60fa'),
final selectedDevice = getSelectedDevice(context);
if (selectedDevice case final AnalyticsDevice device) {
context.read<PowerClampInfoBloc>().add(
LoadPowerClampInfoEvent(device.uuid),
);
}
}
static void loadRealtimeDeviceChanges(
BuildContext context, {
String? deviceUuid,
}) {
final selectedDevice = getSelectedDevice(context);
context.read<RealtimeDeviceChangesBloc>().add(
RealtimeDeviceChangesStarted(deviceUuid ?? selectedDevice?.uuid ?? ''),
);
}
static void loadRealtimeDeviceChanges(BuildContext context) {
context.read<RealtimeDeviceChangesBloc>().add(
const RealtimeDeviceChangesStarted('cb71d6ad-6e29-4eaa-ae3e-1a0d1c5f60fa'),
static void loadAnalyticsDevices(
BuildContext context, {
required String communityUuid,
required String spaceUuid,
required DateTime selectedDate,
}) {
context.read<AnalyticsDevicesBloc>().add(
LoadAnalyticsDevicesEvent(
onSuccess: (device) {
context.read<PowerClampInfoBloc>().add(
LoadPowerClampInfoEvent(device.uuid),
);
loadEnergyConsumptionByPhases(
context,
powerClampUuid: device.uuid,
selectedDate: selectedDate,
);
context.read<RealtimeDeviceChangesBloc>().add(
RealtimeDeviceChangesStarted(device.uuid),
);
},
param: GetAnalyticsDevicesParam(
communityUuid: communityUuid,
spaceUuid: spaceUuid,
deviceTypes: ['PC'],
requestType: AnalyticsDeviceRequestType.energyManagement,
),
),
);
}
static void clearAllData(BuildContext context) {
context.read<PowerClampInfoBloc>().add(
const ClearPowerClampInfoEvent(),
);
context.read<RealtimeDeviceChangesBloc>().add(
const RealtimeDeviceChangesClosed(),
);
context.read<PowerClampInfoBloc>().add(
const ClearPowerClampInfoEvent(),
);
context.read<EnergyConsumptionPerDeviceBloc>().add(
const ClearEnergyConsumptionPerDeviceEvent(),
);
@ -107,5 +183,6 @@ abstract final class FetchEnergyManagementDataHelper {
context.read<EnergyConsumptionByPhasesBloc>().add(
const ClearEnergyConsumptionByPhasesEvent(),
);
context.read<AnalyticsDevicesBloc>().add(const ClearAnalyticsDeviceEvent());
}
}

View File

@ -1,13 +1,35 @@
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 StatelessWidget {
class AnalyticsEnergyManagementView extends StatefulWidget {
const AnalyticsEnergyManagementView({super.key});
static const _padding = EdgeInsetsDirectional.all(32);
@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) {
return LayoutBuilder(

View File

@ -0,0 +1,108 @@
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/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});
final ValueChanged<AnalyticsDevice> onChanged;
@override
Widget build(BuildContext context) {
return BlocBuilder<AnalyticsDevicesBloc, AnalyticsDevicesState>(
builder: (context, state) {
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: ColorsManager.greyColor,
width: 1,
),
),
child: Visibility(
visible: state.devices.isNotEmpty,
replacement: _buildNoDevicesFound(context),
child: _buildDevicesDropdown(context, state),
),
);
},
);
}
static const _defaultPadding = EdgeInsetsDirectional.symmetric(
horizontal: 20,
vertical: 2,
);
Widget _buildNoDevicesFound(BuildContext context) {
return Padding(
padding: _defaultPadding,
child: Text(
'no devices found',
style: _getTextStyle(context),
),
);
}
Widget _buildDevicesDropdown(BuildContext context, AnalyticsDevicesState state) {
final spaceUuid = state.selectedDevice?.spaceUuid;
return DropdownButton<AnalyticsDevice?>(
value: state.selectedDevice,
isDense: true,
borderRadius: BorderRadius.circular(16),
dropdownColor: ColorsManager.whiteColors,
underline: const SizedBox.shrink(),
icon: const RotatedBox(
quarterTurns: 1,
child: Icon(Icons.chevron_right, size: 16),
),
style: _getTextStyle(context),
padding: _defaultPadding,
selectedItemBuilder: (context) {
return state.devices.map((e) => Text(e.name)).toList();
},
items: state.devices.map((e) {
return DropdownMenuItem(
value: e,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
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,
),
),
),
],
),
);
}).toList(),
onChanged: (value) {
if (value case final AnalyticsDevice device) {
context.read<AnalyticsDevicesBloc>().add(
SelectAnalyticsDeviceEvent(device),
);
onChanged.call(device);
}
},
);
}
TextStyle? _getTextStyle(BuildContext context) {
return context.textTheme.labelSmall?.copyWith(
color: ColorsManager.textPrimaryColor,
fontWeight: FontWeight.w700,
fontSize: 14,
);
}
}

View File

@ -1,6 +1,6 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:syncrow_web/pages/analytics/helpers/get_month_name_from_int.dart';
import 'package:intl/intl.dart';
import 'package:syncrow_web/pages/analytics/models/phases_energy_consumption.dart';
import 'package:syncrow_web/pages/analytics/modules/energy_management/helpers/energy_management_charts_helper.dart';
import 'package:syncrow_web/utils/color_manager.dart';
@ -18,7 +18,10 @@ class EnergyConsumptionByPhasesChart extends StatelessWidget {
Widget build(BuildContext context) {
return BarChart(
BarChartData(
gridData: EnergyManagementChartsHelper.gridData(),
gridData: EnergyManagementChartsHelper.gridData().copyWith(
checkToShowHorizontalLine: (value) => true,
horizontalInterval: 250,
),
borderData: EnergyManagementChartsHelper.borderData(),
barTouchData: _barTouchData(context),
titlesData: _titlesData(context),
@ -31,25 +34,29 @@ class EnergyConsumptionByPhasesChart extends StatelessWidget {
barRods: [
BarChartRodData(
color: ColorsManager.vividBlue.withValues(alpha: 0.1),
toY: data.phaseA + data.phaseB + data.phaseC,
toY: data.energyConsumedA +
data.energyConsumedB +
data.energyConsumedC,
rodStackItems: [
BarChartRodStackItem(
0,
data.phaseA,
data.energyConsumedA,
ColorsManager.vividBlue.withValues(alpha: 0.8),
),
BarChartRodStackItem(
data.phaseA,
data.phaseA + data.phaseB,
data.energyConsumedA,
data.energyConsumedA + data.energyConsumedB,
ColorsManager.vividBlue.withValues(alpha: 0.4),
),
BarChartRodStackItem(
data.phaseA + data.phaseB,
data.phaseA + data.phaseB + data.phaseC,
data.energyConsumedA + data.energyConsumedB,
data.energyConsumedA +
data.energyConsumedB +
data.energyConsumedC,
ColorsManager.vividBlue.withValues(alpha: 0.15),
),
],
width: 16,
width: 8,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(8),
topRight: Radius.circular(8),
@ -59,6 +66,7 @@ class EnergyConsumptionByPhasesChart extends StatelessWidget {
);
}).toList(),
),
duration: Duration.zero,
);
}
@ -91,18 +99,27 @@ class EnergyConsumptionByPhasesChart extends StatelessWidget {
}) {
final data = energyData;
final month = data[group.x.toInt()].month.getMonthName;
final phaseA = data[group.x.toInt()].phaseA;
final phaseB = data[group.x.toInt()].phaseB;
final phaseC = data[group.x.toInt()].phaseC;
final date = DateFormat('dd/MM/yyyy').format(data[group.x.toInt()].date);
final phaseA = data[group.x.toInt()].energyConsumedA;
final phaseB = data[group.x.toInt()].energyConsumedB;
final phaseC = data[group.x.toInt()].energyConsumedC;
final total = data[group.x.toInt()].energyConsumedKw;
return BarTooltipItem(
'$month\n',
'$date\n',
context.textTheme.bodyMedium!.copyWith(
color: ColorsManager.blackColor,
fontSize: 14,
),
textAlign: TextAlign.start,
children: [
TextSpan(
text: 'Total: $total\n',
style: context.textTheme.bodySmall?.copyWith(
color: ColorsManager.blackColor,
fontSize: 12,
),
),
TextSpan(
text: 'Phase A: $phaseA\n',
style: context.textTheme.bodySmall?.copyWith(
@ -144,9 +161,9 @@ class EnergyConsumptionByPhasesChart extends StatelessWidget {
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, _) {
final month = energyData[value.toInt()].month.getMonthName;
final month = DateFormat('d').format(energyData[value.toInt()].date);
return FittedBox(
alignment: AlignmentDirectional.bottomCenter,
alignment: AlignmentDirectional.center,
fit: BoxFit.scaleDown,
child: RotatedBox(
quarterTurns: 3,
@ -160,7 +177,7 @@ class EnergyConsumptionByPhasesChart extends StatelessWidget {
),
);
},
reservedSize: 36,
reservedSize: 18,
),
);

View File

@ -16,7 +16,11 @@ class EnergyConsumptionPerDeviceChart extends StatelessWidget {
context,
leftTitlesInterval: 250,
),
gridData: EnergyManagementChartsHelper.gridData(),
gridData: EnergyManagementChartsHelper.gridData().copyWith(
checkToShowHorizontalLine: (value) => true,
horizontalInterval: 250,
),
borderData: EnergyManagementChartsHelper.borderData(),
lineTouchData: EnergyManagementChartsHelper.lineTouchData(),
lineBarsData: chartData.map((e) {
@ -33,7 +37,7 @@ class EnergyConsumptionPerDeviceChart extends StatelessWidget {
);
}).toList(),
),
duration: Durations.extralong1,
duration: Duration.zero,
curve: Curves.easeIn,
);
}

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/analytics/modules/analytics/blocs/analytics_devices/analytics_devices_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/widgets/chart_title.dart';
import 'package:syncrow_web/pages/analytics/modules/energy_management/widgets/energy_consumption_per_device_chart.dart';
@ -46,6 +47,7 @@ class EnergyConsumptionPerDeviceChartBox extends StatelessWidget {
flex: 2,
child: EnergyConsumptionPerDeviceDevicesList(
chartData: state.chartData,
devices: context.watch<AnalyticsDevicesBloc>().state.devices,
),
),
],

View File

@ -1,10 +1,16 @@
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';
class EnergyConsumptionPerDeviceDevicesList extends StatelessWidget {
const EnergyConsumptionPerDeviceDevicesList({required this.chartData, super.key});
const EnergyConsumptionPerDeviceDevicesList({
required this.chartData,
required this.devices,
super.key,
});
final List<AnalyticsDevice> devices;
final List<DeviceEnergyDataModel> chartData;
@override
@ -16,45 +22,60 @@ class EnergyConsumptionPerDeviceDevicesList extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.end,
children: chartData.map((e) => _buildDeviceCell(context, e)).toList(),
children: devices.map((e) => _buildDeviceCell(context, e)).toList(),
),
);
}
Widget _buildDeviceCell(BuildContext context, DeviceEnergyDataModel device) {
return 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,
Widget _buildDeviceCell(BuildContext context, AnalyticsDevice device) {
final deviceColor = chartData
.firstWhere(
(element) => element.deviceId == device.uuid,
orElse: () => const DeviceEnergyDataModel(
energy: [],
deviceName: '',
deviceId: '',
color: Colors.red,
),
)
.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,
),
),
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.center,
child: Row(
spacing: 6,
children: [
CircleAvatar(
radius: 4,
backgroundColor: device.color,
),
Text(
device.deviceName,
textAlign: TextAlign.center,
style: const TextStyle(
color: ColorsManager.blackColor,
fontWeight: FontWeight.w400,
fontSize: 14,
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,
),
),
],
),
),
),
);

View File

@ -1,36 +0,0 @@
import 'package:flutter/material.dart';
import 'package:syncrow_web/utils/color_manager.dart';
class PowerClampEnergyDataDeviceDropdown extends StatelessWidget {
const PowerClampEnergyDataDeviceDropdown({super.key});
static final _color = ColorsManager.blackColor.withValues(alpha: 0.8);
@override
Widget build(BuildContext context) {
return TextButton(
style: TextButton.styleFrom(
foregroundColor: _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,
),
),
child: const Text(
'Device 1',
style: TextStyle(
fontWeight: FontWeight.w700,
),
),
onPressed: () {},
);
}
}

View File

@ -1,10 +1,13 @@
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_data_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/modules/energy_management/widgets/power_clamp_phases_data_widget.dart';
import 'package:syncrow_web/pages/analytics/widgets/analytics_error_widget.dart';
@ -50,7 +53,8 @@ class PowerClampEnergyDataWidget extends StatelessWidget {
),
const SizedBox(height: 6),
SelectableText(
state.powerClampModel?.productUuid ?? 'N/A',
context.watch<AnalyticsDevicesBloc>().state.selectedDevice?.uuid ??
'N/A',
style: context.textTheme.bodySmall?.copyWith(
color: ColorsManager.blackColor,
fontWeight: FontWeight.w400,
@ -107,7 +111,7 @@ class PowerClampEnergyDataWidget extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
flex: 2,
flex: 3,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: AlignmentDirectional.centerStart,
@ -122,11 +126,25 @@ class PowerClampEnergyDataWidget extends StatelessWidget {
),
),
const Spacer(),
const Expanded(
Expanded(
flex: 2,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: AlignmentDirectional.centerEnd,
child: PowerClampEnergyDataDeviceDropdown(),
child: AnalyticsDeviceDropdown(
onChanged: (value) {
FetchEnergyManagementDataHelper.loadEnergyConsumptionByPhases(
context,
powerClampUuid: value.uuid,
selectedDate:
context.read<AnalyticsDatePickerBloc>().state.monthlyDate,
);
FetchEnergyManagementDataHelper.loadRealtimeDeviceChanges(
context,
deviceUuid: value.uuid,
);
},
),
),
),
],

View File

@ -48,6 +48,9 @@ class PowerClampEnergyStatusWidget extends StatelessWidget {
fontWeight: FontWeight.w400,
fontSize: 16,
),
softWrap: true,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
trailing: Text.rich(
TextSpan(

View File

@ -4,15 +4,6 @@ import 'package:syncrow_web/pages/analytics/models/energy_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';
// energy_consumption_chart will return id, name and consumption
const phasesJson = {
"1": {
"phaseOne": 1000,
"phaseTwo": 2000,
"phaseThree": 3000,
}
};
class TotalEnergyConsumptionChart extends StatelessWidget {
const TotalEnergyConsumptionChart({required this.chartData, super.key});
@ -25,14 +16,17 @@ class TotalEnergyConsumptionChart extends StatelessWidget {
LineChartData(
titlesData: EnergyManagementChartsHelper.titlesData(
context,
leftTitlesInterval: 5000,
leftTitlesInterval: 250,
),
gridData: EnergyManagementChartsHelper.gridData().copyWith(
checkToShowHorizontalLine: (value) => true,
horizontalInterval: 250,
),
gridData: EnergyManagementChartsHelper.gridData(),
borderData: EnergyManagementChartsHelper.borderData(),
lineTouchData: EnergyManagementChartsHelper.lineTouchData(),
lineBarsData: _lineBarsData,
),
duration: Durations.extralong1,
duration: Duration.zero,
curve: Curves.easeIn,
),
);
@ -49,7 +43,7 @@ class TotalEnergyConsumptionChart extends StatelessWidget {
.entries
.map(
(entry) => FlSpot(
entry.key.toDouble(),
entry.value.date.day.toDouble(),
entry.value.value,
),
)

View File

@ -0,0 +1,37 @@
import 'package:bloc/bloc.dart';
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';
part 'occupancy_event.dart';
part 'occupancy_state.dart';
class OccupancyBloc extends Bloc<OccupancyEvent, OccupancyState> {
OccupancyBloc(this._occupacyService) : super(const OccupancyState()) {
on<LoadOccupancyEvent>(_onLoadOccupancyEvent);
on<ClearOccupancyEvent>(_onClearOccupancyEvent);
}
final OccupacyService _occupacyService;
Future<void> _onLoadOccupancyEvent(
LoadOccupancyEvent event,
Emitter<OccupancyState> emit,
) async {
emit(state.copyWith(status: OccupancyStatus.loading));
try {
final chartData = await _occupacyService.load(event.param);
emit(state.copyWith(chartData: chartData, status: OccupancyStatus.loaded));
} catch (e) {
emit(state.copyWith(status: OccupancyStatus.failure, errorMessage: '$e'));
}
}
void _onClearOccupancyEvent(
ClearOccupancyEvent event,
Emitter<OccupancyState> emit,
) {
emit(const OccupancyState());
}
}

View File

@ -0,0 +1,21 @@
part of 'occupancy_bloc.dart';
sealed class OccupancyEvent extends Equatable {
const OccupancyEvent();
@override
List<Object> get props => [];
}
final class LoadOccupancyEvent extends OccupancyEvent {
const LoadOccupancyEvent(this.param);
final GetOccupancyParam param;
@override
List<Object> get props => [param];
}
final class ClearOccupancyEvent extends OccupancyEvent {
const ClearOccupancyEvent();
}

View File

@ -0,0 +1,30 @@
part of 'occupancy_bloc.dart';
enum OccupancyStatus { initial, loading, loaded, failure }
final class OccupancyState extends Equatable {
const OccupancyState({
this.chartData = const [],
this.status = OccupancyStatus.initial,
this.errorMessage,
});
final List<Occupacy> chartData;
final OccupancyStatus status;
final String? errorMessage;
OccupancyState copyWith({
List<Occupacy>? chartData,
OccupancyStatus? status,
String? errorMessage,
}) {
return OccupancyState(
chartData: chartData ?? this.chartData,
status: status ?? this.status,
errorMessage: errorMessage ?? this.errorMessage,
);
}
@override
List<Object?> get props => [chartData, status, errorMessage];
}

View File

@ -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<OccupancyHeatMapEvent, OccupancyHeatMapState> {
OccupancyHeatMapBloc(
this._occupancyHeatMapService,
) : super(const OccupancyHeatMapState()) {
on<LoadOccupancyHeatMapEvent>(_onLoadOccupancyHeatMapEvent);
on<ClearOccupancyHeatMapEvent>(_onClearOccupancyHeatMapEvent);
}
final OccupancyHeatMapService _occupancyHeatMapService;
Future<void> _onLoadOccupancyHeatMapEvent(
LoadOccupancyHeatMapEvent event,
Emitter<OccupancyHeatMapState> 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<OccupancyHeatMapState> emit,
) {
emit(const OccupancyHeatMapState());
}
}

View File

@ -0,0 +1,21 @@
part of 'occupancy_heat_map_bloc.dart';
sealed class OccupancyHeatMapEvent extends Equatable {
const OccupancyHeatMapEvent();
@override
List<Object> get props => [];
}
final class LoadOccupancyHeatMapEvent extends OccupancyHeatMapEvent {
const LoadOccupancyHeatMapEvent(this.param);
final GetOccupancyHeatMapParam param;
@override
List<Object> get props => [param];
}
final class ClearOccupancyHeatMapEvent extends OccupancyHeatMapEvent {
const ClearOccupancyHeatMapEvent();
}

View File

@ -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<OccupancyHeatMapModel> heatMapData;
OccupancyHeatMapState copyWith({
OccupancyHeatMapStatus? status,
List<OccupancyHeatMapModel>? heatMapData,
String? errorMessage,
}) {
return OccupancyHeatMapState(
status: status ?? this.status,
heatMapData: heatMapData ?? this.heatMapData,
errorMessage: errorMessage ?? this.errorMessage,
);
}
@override
List<Object?> get props => [status, errorMessage, heatMapData];
}

View File

@ -0,0 +1,111 @@
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_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/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_analytics_devices_param.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 {
const FetchOccupancyDataHelper._();
static void loadOccupancyData(
BuildContext context, {
required String communityId,
required String spaceId,
}) {
if (communityId.isEmpty && spaceId.isEmpty) {
clearAllData(context);
return;
}
final datePickerState = context.read<AnalyticsDatePickerBloc>().state;
loadAnalyticsDevices(context, communityUuid: communityId, spaceUuid: spaceId);
final selectedDevice = context.read<AnalyticsDevicesBloc>().state.selectedDevice;
loadOccupancyChartData(
context,
spaceUuid: spaceId,
date: datePickerState.monthlyDate,
);
loadHeatMapData(context, spaceUuid: spaceId, year: datePickerState.yearlyDate);
if (selectedDevice case final AnalyticsDevice device) {
context.read<RealtimeDeviceChangesBloc>()
..add(const RealtimeDeviceChangesClosed())
..add(
RealtimeDeviceChangesStarted(device.uuid),
);
}
}
static void loadHeatMapData(
BuildContext context, {
required String spaceUuid,
required DateTime year,
}) {
context.read<OccupancyHeatMapBloc>().add(
LoadOccupancyHeatMapEvent(
GetOccupancyHeatMapParam(spaceUuid: spaceUuid, year: year),
),
);
}
static void loadOccupancyChartData(
BuildContext context, {
required String spaceUuid,
required DateTime date,
}) {
context.read<OccupancyBloc>().add(
LoadOccupancyEvent(
GetOccupancyParam(
monthDate: '${date.year}-${date.month.toString().padLeft(2, '0')}',
spaceUuid: spaceUuid,
),
),
);
}
static void loadAnalyticsDevices(
BuildContext context, {
required String communityUuid,
required String spaceUuid,
}) {
context.read<AnalyticsDevicesBloc>().add(
LoadAnalyticsDevicesEvent(
param: GetAnalyticsDevicesParam(
communityUuid: communityUuid,
spaceUuid: spaceUuid,
deviceTypes: ['WPS', 'CPS'],
requestType: AnalyticsDeviceRequestType.occupancy,
),
onSuccess: (device) {
context.read<RealtimeDeviceChangesBloc>()
..add(const RealtimeDeviceChangesClosed())
..add(RealtimeDeviceChangesStarted(device.uuid));
},
),
);
}
static void clearAllData(BuildContext context) {
context.read<OccupancyBloc>().add(
const ClearOccupancyEvent(),
);
context.read<OccupancyHeatMapBloc>().add(
const ClearOccupancyHeatMapEvent(),
);
context.read<RealtimeDeviceChangesBloc>().add(
const RealtimeDeviceChangesClosed(),
);
context.read<AnalyticsDevicesBloc>().add(
const ClearAnalyticsDeviceEvent(),
);
}
}

View File

@ -1,12 +1,56 @@
import 'package:flutter/material.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 StatelessWidget {
const AnalyticsOccupancyView({super.key});
static const _padding = EdgeInsetsDirectional.all(32);
@override
Widget build(BuildContext context) {
return const Center(
child: Text('AnalyticsOccupancyView is Working!'),
final height = MediaQuery.sizeOf(context).height;
return LayoutBuilder(
builder: (context, constraints) {
final isMediumOrLess = constraints.maxWidth <= 900;
if (isMediumOrLess) {
return SingleChildScrollView(
padding: _padding,
child: Column(
spacing: 32,
children: [
SizedBox(height: height * 0.46, child: const OccupancyEndSideBar()),
SizedBox(height: height * 0.5, child: const OccupancyChartBox()),
SizedBox(height: height * 0.5, child: const OccupancyHeatMapBox()),
],
),
);
}
return SingleChildScrollView(
child: Container(
padding: _padding,
height: height * 0.9,
child: const Row(
spacing: 32,
children: [
Expanded(
flex: 5,
child: Column(
spacing: 20,
children: [
Expanded(child: OccupancyChartBox()),
Expanded(child: OccupancyHeatMapBox()),
],
),
),
Expanded(flex: 2, child: OccupancyEndSideBar()),
],
),
),
);
},
);
}
}

View File

@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart';
class HeatMapTooltip extends StatelessWidget {
const HeatMapTooltip({
required this.date,
required this.value,
super.key,
});
final DateTime date;
final int value;
@override
Widget build(BuildContext context) {
return FittedBox(
fit: BoxFit.scaleDown,
alignment: AlignmentDirectional.topStart,
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: ColorsManager.grey700,
borderRadius: BorderRadius.circular(3),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Text(
DateFormat('MMM d, yyyy').format(date),
style: context.textTheme.bodySmall?.copyWith(
fontSize: 12,
fontWeight: FontWeight.w700,
color: ColorsManager.whiteColors,
),
),
const Divider(height: 2, thickness: 1),
Text(
'$value Occupants',
style: context.textTheme.bodySmall?.copyWith(
fontSize: 10,
fontWeight: FontWeight.w500,
color: ColorsManager.whiteColors,
),
),
],
),
),
);
}
}

View File

@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import 'package:syncrow_web/pages/analytics/modules/occupancy/widgets/heat_map_tooltip.dart';
import 'package:syncrow_web/pages/analytics/modules/occupancy/widgets/occupancy_painter.dart';
class InteractiveHeatMap extends StatefulWidget {
const InteractiveHeatMap({
required this.items,
required this.maxValue,
required this.cellSize,
super.key,
});
final List<OccupancyPaintItem> items;
final int maxValue;
final double cellSize;
@override
State<InteractiveHeatMap> createState() => _InteractiveHeatMapState();
}
class _InteractiveHeatMapState extends State<InteractiveHeatMap> {
OccupancyPaintItem? _hoveredItem;
OverlayEntry? _overlayEntry;
final LayerLink _layerLink = LayerLink();
@override
void dispose() {
_removeOverlay();
_overlayEntry?.dispose();
super.dispose();
}
void _removeOverlay() {
_overlayEntry?.remove();
_overlayEntry = null;
}
void _showTooltip(OccupancyPaintItem item, Offset localPosition) {
_removeOverlay();
final column = item.index ~/ 7;
final row = item.index % 7;
final x = column * widget.cellSize;
final y = row * widget.cellSize;
_overlayEntry = OverlayEntry(
builder: (context) => Positioned(
child: CompositedTransformFollower(
link: _layerLink,
offset: Offset(x + widget.cellSize, y),
child: Material(
color: Colors.transparent,
child: Transform.translate(
offset: Offset(-(widget.cellSize * 2.5), -50),
child: HeatMapTooltip(date: item.date, value: item.value),
),
),
),
),
);
Overlay.of(context).insert(_overlayEntry!);
}
@override
Widget build(BuildContext context) {
return CompositedTransformTarget(
link: _layerLink,
child: MouseRegion(
onHover: (event) {
final column = event.localPosition.dx ~/ widget.cellSize;
final row = event.localPosition.dy ~/ widget.cellSize;
final index = column * 7 + row;
if (index >= 0 && index < widget.items.length) {
final item = widget.items[index];
if (_hoveredItem != item) {
setState(() => _hoveredItem = item);
_showTooltip(item, event.localPosition);
}
} else {
_removeOverlay();
setState(() => _hoveredItem = null);
}
},
onExit: (_) {
_removeOverlay();
setState(() => _hoveredItem = null);
},
child: CustomPaint(
isComplex: true,
size: _painterSize,
painter: OccupancyPainter(
items: widget.items,
maxValue: widget.maxValue,
hoveredItem: _hoveredItem,
),
),
),
);
}
Size get _painterSize {
final height = 7 * widget.cellSize;
final width = widget.items.length ~/ 7 * widget.cellSize;
return Size(width, height);
}
}

View File

@ -0,0 +1,153 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:syncrow_web/pages/analytics/models/occupacy.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 OccupancyChart extends StatelessWidget {
const OccupancyChart({required this.chartData, super.key});
final List<Occupacy> chartData;
static const _chartWidth = 16.0;
@override
Widget build(BuildContext context) {
return BarChart(
BarChartData(
maxY: 100.0,
gridData: EnergyManagementChartsHelper.gridData().copyWith(
checkToShowHorizontalLine: (value) => true,
horizontalInterval: 20,
),
borderData: EnergyManagementChartsHelper.borderData(),
barTouchData: _barTouchData(context),
titlesData: _titlesData(context).copyWith(
leftTitles: _titlesData(context).leftTitles.copyWith(
sideTitles: _titlesData(context).leftTitles.sideTitles.copyWith(
maxIncluded: true,
minIncluded: true,
),
),
),
barGroups: List.generate(chartData.length, (index) {
final actual = chartData[index];
final occupancyValue = double.parse(actual.occupancy);
return BarChartGroupData(
x: index,
barsSpace: 0,
groupVertically: true,
barRods: [
BarChartRodData(
toY: 100.0,
fromY: occupancyValue == 0 ? occupancyValue : occupancyValue + 2.5,
color: ColorsManager.graysColor,
width: _chartWidth,
borderRadius: BorderRadius.circular(10),
),
BarChartRodData(
toY: occupancyValue,
color: ColorsManager.vividBlue.withValues(alpha: 0.8),
width: _chartWidth,
borderRadius: BorderRadius.circular(10),
),
],
);
}),
),
);
}
BarTouchData _barTouchData(BuildContext context) {
return BarTouchData(
touchTooltipData: BarTouchTooltipData(
getTooltipColor: (touchTooltipItem) => ColorsManager.whiteColors,
tooltipBorder: const BorderSide(
color: ColorsManager.semiTransparentBlack,
),
tooltipRoundedRadius: 16,
tooltipPadding: const EdgeInsets.all(8),
getTooltipItem: (group, groupIndex, rod, rodIndex) => getTooltipItem(
context: context,
group: group,
groupIndex: groupIndex,
rod: rod,
rodIndex: rodIndex,
),
),
);
}
BarTooltipItem? getTooltipItem({
required BuildContext context,
required BarChartGroupData group,
required int groupIndex,
required BarChartRodData rod,
required int rodIndex,
}) {
final data = chartData;
final occupancyValue = double.parse(data[group.x.toInt()].occupancy);
final percentage = '${(occupancyValue).toStringAsFixed(0)}%';
return BarTooltipItem(
percentage,
context.textTheme.bodyMedium!.copyWith(
color: ColorsManager.blackColor,
fontSize: 14,
),
);
}
FlTitlesData _titlesData(BuildContext context) {
final titlesData = EnergyManagementChartsHelper.titlesData(
context,
leftTitlesInterval: 250,
);
final leftTitles = titlesData.leftTitles.copyWith(
sideTitles: titlesData.leftTitles.sideTitles.copyWith(
reservedSize: 70,
interval: 20,
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.greyColor,
),
),
),
),
),
);
final bottomTitles = AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, _) => FittedBox(
alignment: AlignmentDirectional.bottomCenter,
fit: BoxFit.scaleDown,
child: Text(
(value + 1).toString(),
style: context.textTheme.bodySmall?.copyWith(
color: ColorsManager.greyColor,
fontSize: 8,
),
),
),
reservedSize: 36,
),
);
return titlesData.copyWith(
leftTitles: leftTitles,
bottomTitles: bottomTitles,
);
}
}

View File

@ -0,0 +1,76 @@
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/pages/space_tree/bloc/space_tree_bloc.dart';
import 'package:syncrow_web/utils/style.dart';
class OccupancyChartBox extends StatelessWidget {
const OccupancyChartBox({super.key});
@override
Widget build(BuildContext context) {
final spaceTreeState = context.watch<SpaceTreeBloc>().state;
return BlocBuilder<OccupancyBloc, OccupancyState>(
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')),
),
),
const Spacer(),
Expanded(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: AlignmentDirectional.centerEnd,
child: AnalyticsDateFilterButton(
onDateSelected: (DateTime value) {
context.read<AnalyticsDatePickerBloc>().add(
UpdateAnalyticsDatePickerEvent(montlyDate: value),
);
if (spaceTreeState.selectedSpaces.isNotEmpty) {
FetchOccupancyDataHelper.loadOccupancyChartData(
context,
spaceUuid:
spaceTreeState.selectedSpaces.firstOrNull ?? '',
date: value,
);
}
},
selectedDate: context
.watch<AnalyticsDatePickerBloc>()
.state
.monthlyDate,
),
),
),
],
),
const Divider(height: 0),
Expanded(child: OccupancyChart(chartData: state.chartData)),
],
),
);
},
);
}
}

View File

@ -0,0 +1,142 @@
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/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 {
const OccupancyEndSideBar({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<RealtimeDeviceChangesBloc, RealtimeDeviceChangesState>(
builder: (context, state) {
return Container(
decoration: subSectionContainerDecoration.copyWith(
borderRadius: BorderRadius.circular(30),
),
padding: const EdgeInsetsDirectional.all(32),
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),
SizedBox(
height: MediaQuery.sizeOf(context).height * 0.2,
child: PowerClampEnergyStatusWidget(
status: [
PowerClampEnergyStatus(
iconPath: Assets.presenceState,
title: 'Presence Status',
value: _valueFromCode(
'presence_state',
state.deviceStatusList,
),
unit: '',
),
PowerClampEnergyStatus(
iconPath: Assets.presenceTimeIcon,
title: 'Presence Time',
value:
'${_valueFromCode('none_body_time', state.deviceStatusList)} Min',
unit: '',
),
PowerClampEnergyStatus(
iconPath: Assets.currentDistanceIcon,
title: 'Detection Distance',
value:
'${_valueFromCode('space_move_val', state.deviceStatusList)} M',
unit: '',
),
],
),
),
const SizedBox(height: 20),
],
),
);
},
);
}
String _valueFromCode(
String code,
List<Status> status, {
String? defaultValue,
}) {
final value = status
.firstWhere(
(e) => e.code == code,
orElse: () => Status(code: '--', value: '--'),
)
.value
.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,
),
),
),
),
],
);
}
}

View File

@ -0,0 +1,83 @@
import 'dart:math' as math show max;
import 'package:flutter/material.dart';
import 'package:syncrow_web/pages/analytics/modules/occupancy/widgets/interactive_heat_map.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<DateTime, int> heatMapData;
static const _cellSize = 16.0;
static const _totalWeeks = 53;
int get _maxValue => heatMapData.isNotEmpty
? heatMapData.keys.map((key) => heatMapData[key] ?? 0).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<OccupancyPaintItem> _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, date: date);
});
}
@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),
SizedBox(
width: _totalWeeks * _cellSize,
height: 7 * _cellSize,
child: InteractiveHeatMap(
items: paintItems,
maxValue: _maxValue,
cellSize: _cellSize,
),
),
],
),
),
),
],
),
),
const SizedBox(height: 20),
OccupancyHeatMapGradient(maxValue: _maxValue),
],
);
}
}

View File

@ -0,0 +1,86 @@
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/pages/space_tree/bloc/space_tree_bloc.dart';
import 'package:syncrow_web/utils/style.dart';
class OccupancyHeatMapBox extends StatelessWidget {
const OccupancyHeatMapBox({super.key});
@override
Widget build(BuildContext context) {
final spaceTreeState = context.watch<SpaceTreeBloc>().state;
return BlocBuilder<OccupancyHeatMapBloc, OccupancyHeatMapState>(
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<AnalyticsDatePickerBloc>().add(
UpdateAnalyticsDatePickerEvent(yearlyDate: value),
);
if (spaceTreeState.selectedSpaces.isNotEmpty) {
FetchOccupancyDataHelper.loadHeatMapData(
context,
spaceUuid:
spaceTreeState.selectedSpaces.firstOrNull ?? '',
year: value,
);
}
},
datePickerType: DatePickerType.year,
selectedDate: context
.watch<AnalyticsDatePickerBloc>()
.state
.yearlyDate,
),
),
),
],
),
const Divider(height: 0),
Expanded(
child: OccupancyHeatMap(
heatMapData: state.heatMapData.asMap().map(
(_, value) => MapEntry(
value.eventDate,
value.countTotalPresenceDetected,
),
),
),
),
],
),
);
},
);
}
}

View File

@ -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,
),
),
);
}),
);
}
}

View File

@ -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<Color> _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(),
),
),
),
),
],
);
}
}

View File

@ -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),
),
),
),
),
);
}),
],
),
);
}
}

View File

@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
import 'package:syncrow_web/utils/color_manager.dart';
class OccupancyPaintItem {
final int index;
final int value;
final DateTime date;
const OccupancyPaintItem({
required this.index,
required this.value,
required this.date,
});
}
class OccupancyPainter extends CustomPainter {
OccupancyPainter({
required this.items,
required this.maxValue,
this.hoveredItem,
});
final List<OccupancyPaintItem> items;
final int maxValue;
final OccupancyPaintItem? hoveredItem;
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;
final Paint hoveredBorderPaint = Paint()
..color = Colors.black
..style = PaintingStyle.stroke
..strokeWidth = 1.5;
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);
// Highlight the hovered item
if (hoveredItem != null && hoveredItem!.index == item.index) {
canvas.drawRect(rect, hoveredBorderPaint);
} else {
_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 OccupancyPainter oldDelegate) =>
oldDelegate.hoveredItem != hoveredItem;
}

View File

@ -0,0 +1,22 @@
enum AnalyticsDeviceRequestType { energyManagement, occupancy }
class GetAnalyticsDevicesParam {
final String? spaceUuid;
final List<String> deviceTypes;
final String? communityUuid;
final AnalyticsDeviceRequestType requestType;
const GetAnalyticsDevicesParam({
required this.requestType,
required this.spaceUuid,
required this.deviceTypes,
required this.communityUuid,
});
Map<String, dynamic> toJson() {
return <String, dynamic>{
if (spaceUuid != null) 'spaceUuid': spaceUuid,
if (communityUuid != null) 'communityUuid': communityUuid,
};
}
}

View File

@ -1,24 +1,20 @@
import 'package:equatable/equatable.dart';
class GetEnergyConsumptionByPhasesParam extends Equatable {
final DateTime? startDate;
final DateTime? endDate;
final String? spaceId;
final String powerClampUuid;
final DateTime? date;
const GetEnergyConsumptionByPhasesParam({
this.startDate,
this.endDate,
this.spaceId,
required this.powerClampUuid,
this.date,
});
Map<String, dynamic> toJson() {
return {
'startDate': startDate?.toIso8601String(),
'endDate': endDate?.toIso8601String(),
'spaceId': spaceId,
'monthDate': '${date?.year}-${date?.month.toString().padLeft(2, '0')}',
};
}
@override
List<Object?> get props => [startDate, endDate, spaceId];
List<Object?> get props => [powerClampUuid, date];
}

View File

@ -1,3 +1,19 @@
class GetEnergyConsumptionPerDeviceParam {
const 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,
'groupByDevice': true,
};
}

View File

@ -0,0 +1,13 @@
class GetOccupancyHeatMapParam {
final DateTime year;
final String spaceUuid;
const GetOccupancyHeatMapParam({
required this.year,
required this.spaceUuid,
});
Map<String, dynamic> toJson() {
return {'year': year.year};
}
}

View File

@ -0,0 +1,11 @@
class GetOccupancyParam {
final String monthDate;
final String? spaceUuid;
GetOccupancyParam({
required this.monthDate,
required this.spaceUuid,
});
Map<String, dynamic> toJson() => {'monthDate': monthDate};
}

View File

@ -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<String, dynamic> toJson() {
return {
'startDate': startDate?.toIso8601String(),
'endDate': endDate?.toIso8601String(),
'spaceId': spaceId,
'monthDate':
'${monthDate?.year}-${monthDate?.month.toString().padLeft(2, '0')}',
if (spaceId == null || spaceId == null) 'spaceUuid': spaceId,
'communityUuid': communityId,
'groupByDevice': false,
};
}
}

View File

@ -0,0 +1,6 @@
import 'package:syncrow_web/pages/analytics/models/analytics_device.dart';
import 'package:syncrow_web/pages/analytics/params/get_analytics_devices_param.dart';
abstract interface class AnalyticsDevicesService {
Future<List<AnalyticsDevice>> getDevices(GetAnalyticsDevicesParam param);
}

View File

@ -0,0 +1,24 @@
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';
class AnalyticsDevicesServiceDelegate implements AnalyticsDevicesService {
const AnalyticsDevicesServiceDelegate(
this._occupancyService,
this._energyManagementService,
);
final AnalyticsDevicesService _occupancyService;
final AnalyticsDevicesService _energyManagementService;
@override
Future<List<AnalyticsDevice>> getDevices(
GetAnalyticsDevicesParam param,
) {
return switch (param.requestType) {
AnalyticsDeviceRequestType.occupancy => _occupancyService.getDevices(param),
AnalyticsDeviceRequestType.energyManagement =>
_energyManagementService.getDevices(param),
};
}
}

View File

@ -0,0 +1,36 @@
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/http_service.dart';
final class RemoteEnergyManagementAnalyticsDevicesService
implements AnalyticsDevicesService {
const RemoteEnergyManagementAnalyticsDevicesService(this._httpService);
final HTTPService _httpService;
@override
Future<List<AnalyticsDevice>> getDevices(GetAnalyticsDevicesParam param) async {
try {
final response = await _httpService.get(
path: '/devices-space-community/recursive-child',
queryParameters: param.toJson()
..addAll({'productType': param.deviceTypes.first}),
expectedResponseModel: (response) {
final json = response as Map<String, dynamic>;
final dailyData = json['data'] as List<dynamic>? ?? <dynamic>[];
final result = dailyData.map(
(json) => AnalyticsDevice.fromJson(json as Map<String, dynamic>),
);
return result.toList();
},
);
return response;
} catch (e) {
throw Exception('Failed to load total energy consumption: $e');
}
}
}

View File

@ -0,0 +1,61 @@
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/http_service.dart';
class RemoteOccupancyAnalyticsDevicesService implements AnalyticsDevicesService {
const RemoteOccupancyAnalyticsDevicesService(this._httpService);
final HTTPService _httpService;
@override
Future<List<AnalyticsDevice>> getDevices(GetAnalyticsDevicesParam param) async {
try {
final requests = await Future.wait<List<AnalyticsDevice>>(
param.deviceTypes.map((e) {
final mappedParam = GetAnalyticsDevicesParam(
requestType: AnalyticsDeviceRequestType.occupancy,
spaceUuid: param.spaceUuid,
deviceTypes: [e],
communityUuid: param.communityUuid,
);
return _makeRequest(mappedParam);
}).toList(),
);
final result = requests.map((e) => e.first).toList();
return result;
} catch (e) {
throw Exception('Failed to load total energy consumption: $e');
}
}
Future<List<AnalyticsDevice>> _makeRequest(GetAnalyticsDevicesParam param) async {
try {
final projectUuid = await ProjectManager.getProjectUUID();
final response = await _httpService.get(
path:
'/projects/$projectUuid/communities/${param.communityUuid}/spaces/${param.spaceUuid}/devices',
queryParameters: {
'communityUuid': param.communityUuid,
'spaceUuid': param.spaceUuid,
'productType': param.deviceTypes.first,
},
expectedResponseModel: (response) {
final json = response as Map<String, dynamic>;
final dailyData = json['data'] as List<dynamic>? ?? <dynamic>[];
final result = dailyData.map(
(json) => AnalyticsDevice.fromJson(json as Map<String, dynamic>),
);
return result.toList();
},
);
return response;
} catch (e) {
rethrow;
}
}
}

View File

@ -1,29 +0,0 @@
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';
class FakeEnergyConsumptionByPhasesService
implements EnergyConsumptionByPhasesService {
@override
Future<List<PhasesEnergyConsumption>> load(
GetEnergyConsumptionByPhasesParam param,
) {
return Future.delayed(
const Duration(milliseconds: 500),
() => const [
PhasesEnergyConsumption(month: 1, phaseA: 200, phaseB: 300, phaseC: 400),
PhasesEnergyConsumption(month: 2, phaseA: 300, phaseB: 400, phaseC: 500),
PhasesEnergyConsumption(month: 3, phaseA: 400, phaseB: 500, phaseC: 600),
PhasesEnergyConsumption(month: 4, phaseA: 100, phaseB: 100, phaseC: 100),
PhasesEnergyConsumption(month: 5, phaseA: 300, phaseB: 400, phaseC: 500),
PhasesEnergyConsumption(month: 6, phaseA: 300, phaseB: 100, phaseC: 400),
PhasesEnergyConsumption(month: 7, phaseA: 300, phaseB: 100, phaseC: 400),
PhasesEnergyConsumption(month: 8, phaseA: 500, phaseB: 100, phaseC: 100),
PhasesEnergyConsumption(month: 9, phaseA: 500, phaseB: 100, phaseC: 200),
PhasesEnergyConsumption(month: 10, phaseA: 100, phaseB: 50, phaseC: 50),
PhasesEnergyConsumption(month: 11, phaseA: 600, phaseB: 750, phaseC: 130),
PhasesEnergyConsumption(month: 12, phaseA: 100, phaseB: 100, phaseC: 100),
],
);
}
}

View File

@ -15,8 +15,9 @@ final class RemoteEnergyConsumptionByPhasesService
) async {
try {
final response = await _httpService.get(
path: 'endpoint',
path: '/power-clamp/${param.powerClampUuid}/historical',
showServerMessage: true,
queryParameters: param.toJson(),
expectedResponseModel: (data) {
final json = data as Map<String, dynamic>? ?? {};
final mappedData = json['data'] as List<dynamic>? ?? [];
@ -28,7 +29,7 @@ final class RemoteEnergyConsumptionByPhasesService
);
return response;
} catch (e) {
throw Exception('Failed to load energy consumption per device: $e');
throw Exception('Failed to load energy consumption per phase: $e');
}
}
}

View File

@ -1,39 +0,0 @@
import 'dart:math' as math show Random;
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';
class FakeEnergyConsumptionPerDeviceService
implements EnergyConsumptionPerDeviceService {
@override
Future<List<DeviceEnergyDataModel>> load(
GetEnergyConsumptionPerDeviceParam param,
) {
final random = math.Random();
return Future.delayed(const Duration(milliseconds: 500), () {
return [
(Colors.redAccent, 1),
(Colors.lightBlueAccent, 2),
(Colors.purpleAccent, 3),
].map((e) {
final (color, index) = e;
return DeviceEnergyDataModel(
color: color,
energy: List.generate(30, (i) => i)
.map(
(index) => EnergyDataModel(
date: DateTime(2025, 1, index + 1),
value: random.nextInt(100) + (index * 100),
),
)
.toList(),
deviceName: 'Device $index',
deviceId: 'device_$index',
);
}).toList();
});
}
}

View File

@ -1,4 +1,6 @@
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/http_service.dart';
@ -15,16 +17,10 @@ class RemoteEnergyConsumptionPerDeviceService
) async {
try {
final response = await _httpService.get(
path: 'endpoint',
path: '/power-clamp/historical',
showServerMessage: true,
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 DeviceEnergyDataModel.fromJson(jsonData);
}).toList();
},
queryParameters: param.toJson(),
expectedResponseModel: _EnergyConsumptionPerDeviceMapper.map,
);
return response;
} catch (e) {
@ -32,3 +28,30 @@ class RemoteEnergyConsumptionPerDeviceService
}
}
}
abstract final class _EnergyConsumptionPerDeviceMapper {
const _EnergyConsumptionPerDeviceMapper._();
static List<DeviceEnergyDataModel> map(dynamic data) {
final json = data as Map<String, dynamic>? ?? {};
final mappedData = json['data'] as List<dynamic>? ?? [];
return mappedData.map((e) {
final deviceData = e as Map<String, dynamic>;
final energyData = deviceData['data'] as List<dynamic>;
return DeviceEnergyDataModel(
deviceId: deviceData['deviceUuid'] as String,
deviceName: deviceData['deviceName'] as String,
color: Color((DateTime.now().microsecondsSinceEpoch +
deviceData['deviceUuid'].hashCode) |
0xFF000000),
energy: energyData.map((data) {
final energyJson = data as Map<String, dynamic>;
return EnergyDataModel(
date: DateTime.parse(energyJson['date'] as String),
value: double.parse(energyJson['total_energy_consumed_kw'] as String),
);
}).toList(),
);
}).toList();
}
}

View File

@ -0,0 +1,6 @@
import 'package:syncrow_web/pages/analytics/models/occupacy.dart';
import 'package:syncrow_web/pages/analytics/params/get_occupancy_param.dart';
abstract interface class OccupacyService {
Future<List<Occupacy>> load(GetOccupancyParam param);
}

View File

@ -0,0 +1,32 @@
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/http_service.dart';
final class RemoteOccupancyService implements OccupacyService {
const RemoteOccupancyService(this._httpService);
final HTTPService _httpService;
@override
Future<List<Occupacy>> load(GetOccupancyParam param) async {
try {
final response = await _httpService.get(
path: '/occupancy/duration/space/${param.spaceUuid}',
showServerMessage: true,
queryParameters: param.toJson(),
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 Occupacy.fromJson(jsonData);
}).toList();
},
);
return response;
} catch (e) {
throw Exception('Failed to load energy consumption per phase: $e');
}
}
}

View File

@ -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<List<OccupancyHeatMapModel>> load(GetOccupancyHeatMapParam param);
}

View File

@ -0,0 +1,35 @@
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/http_service.dart';
final class RemoteOccupancyHeatMapService implements OccupancyHeatMapService {
const RemoteOccupancyHeatMapService(this._httpService);
final HTTPService _httpService;
@override
Future<List<OccupancyHeatMapModel>> load(GetOccupancyHeatMapParam param) async {
try {
final response = await _httpService.get(
path: '/occupancy/heat-map/space/${param.spaceUuid}',
showServerMessage: true,
queryParameters: param.toJson(),
expectedResponseModel: (response) {
final json = response as Map<String, dynamic>;
final dailyData = json['data'] as List<dynamic>? ?? <dynamic>[];
final result = dailyData.map(
(json) => OccupancyHeatMapModel.fromJson(json as Map<String, dynamic>),
);
return result.toList();
},
);
return response;
} catch (e) {
throw Exception('Failed to load total energy consumption:');
}
}
}

View File

@ -23,7 +23,7 @@ class FirebaseRealtimeDeviceService implements RealtimeDeviceService {
return Status(
code: status['code']?.toString() ?? '',
value: num.tryParse(status['value']?.toString() ?? '0'),
value: status['value']?.toString() ?? '',
);
}).toList();
});

View File

@ -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<List<EnergyDataModel>> load(
GetTotalEnergyConsumptionParam param,
) {
return Future.value(
List.generate(30, (index) {
return EnergyDataModel(
date: DateTime(2025, 1, index + 1),
value: 20000 + (index * 1000) % 5000,
);
}),
);
}
}

View File

@ -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<String, dynamic>? ?? {};
final mappedData = json['data'] as List<dynamic>? ?? [];
return mappedData.map((e) {
final jsonData = e as Map<String, dynamic>;
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<EnergyDataModel> map(dynamic data) {
final json = data as Map<String, dynamic>? ?? {};
final dailyData = json['data'] as List<dynamic>? ?? [];
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();
}
}

View File

@ -12,7 +12,7 @@ class AnalyticsErrorWidget extends StatelessWidget {
return Visibility(
visible: errorMessage != null || (errorMessage?.isNotEmpty ?? false),
child: Text(
'$errorMessage ?? "Something went wrong"',
errorMessage ?? 'Something went wrong',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodySmall?.copyWith(

View File

@ -5,6 +5,7 @@ class ChartsLoadingWidget extends StatelessWidget {
required this.isLoading,
super.key,
});
final bool isLoading;
@override

View File

@ -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/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';
import 'package:syncrow_web/utils/constants/assets.dart';
@ -358,7 +359,10 @@ SOS
case 'NCPS':
return [
FlushPresenceDelayFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'IF',),
deviceId: uuid ?? '',
deviceName: name ?? '',
type: 'IF',
),
FlushIlluminanceFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'IF'),
@ -378,6 +382,17 @@ SOS
FlushTriggerLevelFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'THEN'),
];
case 'WH':
return [
WHRestartStatusFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'IF'),
WHSwitchFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'BOTH'),
TimerConfirmTimeFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'BOTH'),
BacklightFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'IF'),
];
default:
return [];

View File

@ -143,6 +143,19 @@ class HomeBloc extends Bloc<HomeEvent, HomeState> {
color: ColorsManager.primaryColor,
),
HomeItemModel(
title: 'Syncrow Analytics',
icon: Assets.devicesIcon,
active: true,
onPress: (context) {
context.read<SpaceTreeBloc>().add(ClearCachedData());
BlocProvider.of<RoutineBloc>(context)
.add(const TriggerSwitchTabsEvent(isRoutineTab: false));
context.go(RoutesConst.analytics);
},
color: ColorsManager.primaryColor,
),
// HomeItemModel(
// title: 'Move in',
// icon: Assets.moveinIcon,

View File

@ -19,6 +19,7 @@ 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 UsersPage extends StatelessWidget {
UsersPage({super.key});

View File

@ -26,8 +26,10 @@ class FunctionBloc extends Bloc<FunctionBlocEvent, FunctionBlocState> {
functionCode: event.functionData.functionCode,
operationName: event.functionData.operationName,
value: event.functionData.value ?? existingData.value,
valueDescription: event.functionData.valueDescription ?? existingData.valueDescription,
valueDescription: event.functionData.valueDescription ??
existingData.valueDescription,
condition: event.functionData.condition ?? existingData.condition,
step: event.functionData.step ?? existingData.step,
);
} else {
functions.clear();
@ -59,8 +61,10 @@ class FunctionBloc extends Bloc<FunctionBlocEvent, FunctionBlocState> {
);
}
FutureOr<void> _onSelectFunction(SelectFunction event, Emitter<FunctionBlocState> emit) {
FutureOr<void> _onSelectFunction(
SelectFunction event, Emitter<FunctionBlocState> emit) {
emit(state.copyWith(
selectedFunction: event.functionCode, selectedOperationName: event.operationName));
selectedFunction: event.functionCode,
selectedOperationName: event.operationName));
}
}

View File

@ -1,9 +1,10 @@
import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/common/bloc/project_manager.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/device_managment/all_devices/models/devices_model.dart';
import 'package:syncrow_web/pages/routines/bloc/automation_scene_trigger_bloc/automation_status_update.dart';
import 'package:syncrow_web/pages/routines/bloc/create_routine_bloc/create_routine_bloc.dart';
@ -89,8 +90,8 @@ class RoutineBloc extends Bloc<RoutineEvent, RoutineState> {
final updatedIfItems = List<Map<String, dynamic>>.from(state.ifItems);
// Find the index of the item in teh current itemsList
int index = updatedIfItems
.indexWhere((map) => map['uniqueCustomId'] == event.item['uniqueCustomId']);
int index =
updatedIfItems.indexWhere((map) => map['uniqueCustomId'] == event.item['uniqueCustomId']);
// Replace the map if the index is valid
if (index != -1) {
updatedIfItems[index] = event.item;
@ -99,11 +100,9 @@ class RoutineBloc extends Bloc<RoutineEvent, RoutineState> {
}
if (event.isTabToRun) {
emit(state.copyWith(
ifItems: updatedIfItems, isTabToRun: true, isAutomation: false));
emit(state.copyWith(ifItems: updatedIfItems, isTabToRun: true, isAutomation: false));
} else {
emit(state.copyWith(
ifItems: updatedIfItems, isTabToRun: false, isAutomation: true));
emit(state.copyWith(ifItems: updatedIfItems, isTabToRun: false, isAutomation: true));
}
}
@ -111,8 +110,8 @@ class RoutineBloc extends Bloc<RoutineEvent, RoutineState> {
final currentItems = List<Map<String, dynamic>>.from(state.thenItems);
// Find the index of the item in teh current itemsList
int index = currentItems
.indexWhere((map) => map['uniqueCustomId'] == event.item['uniqueCustomId']);
int index =
currentItems.indexWhere((map) => map['uniqueCustomId'] == event.item['uniqueCustomId']);
// Replace the map if the index is valid
if (index != -1) {
currentItems[index] = event.item;
@ -123,8 +122,7 @@ class RoutineBloc extends Bloc<RoutineEvent, RoutineState> {
emit(state.copyWith(thenItems: currentItems));
}
void _onAddFunctionsToRoutine(
AddFunctionToRoutine event, Emitter<RoutineState> emit) {
void _onAddFunctionsToRoutine(AddFunctionToRoutine event, Emitter<RoutineState> emit) {
try {
if (event.functions.isEmpty) return;
@ -174,20 +172,17 @@ class RoutineBloc extends Bloc<RoutineEvent, RoutineState> {
BuildContext context = NavigationService.navigatorKey.currentContext!;
var createRoutineBloc = context.read<CreateRoutineBloc>();
final projectUuid = await ProjectManager.getProjectUUID() ?? '';
if (createRoutineBloc.selectedSpaceId == '' &&
createRoutineBloc.selectedCommunityId == '') {
if (createRoutineBloc.selectedSpaceId == '' && createRoutineBloc.selectedCommunityId == '') {
var spaceBloc = context.read<SpaceTreeBloc>();
for (var communityId in spaceBloc.state.selectedCommunities) {
List<String> spacesList =
spaceBloc.state.selectedCommunityAndSpaces[communityId] ?? [];
List<String> spacesList = spaceBloc.state.selectedCommunityAndSpaces[communityId] ?? [];
for (var spaceId in spacesList) {
scenes
.addAll(await SceneApi.getScenes(spaceId, communityId, projectUuid));
scenes.addAll(await SceneApi.getScenes(spaceId, communityId, projectUuid));
}
}
} else {
scenes.addAll(await SceneApi.getScenes(createRoutineBloc.selectedSpaceId,
createRoutineBloc.selectedCommunityId, projectUuid));
scenes.addAll(await SceneApi.getScenes(
createRoutineBloc.selectedSpaceId, createRoutineBloc.selectedCommunityId, projectUuid));
}
emit(state.copyWith(
@ -204,8 +199,7 @@ class RoutineBloc extends Bloc<RoutineEvent, RoutineState> {
}
}
Future<void> _onLoadAutomation(
LoadAutomation event, Emitter<RoutineState> emit) async {
Future<void> _onLoadAutomation(LoadAutomation event, Emitter<RoutineState> emit) async {
emit(state.copyWith(isLoading: true, errorMessage: null));
List<ScenesModel> automations = [];
final projectId = await ProjectManager.getProjectUUID() ?? '';
@ -213,22 +207,17 @@ class RoutineBloc extends Bloc<RoutineEvent, RoutineState> {
BuildContext context = NavigationService.navigatorKey.currentContext!;
var createRoutineBloc = context.read<CreateRoutineBloc>();
try {
if (createRoutineBloc.selectedSpaceId == '' &&
createRoutineBloc.selectedCommunityId == '') {
if (createRoutineBloc.selectedSpaceId == '' && createRoutineBloc.selectedCommunityId == '') {
var spaceBloc = context.read<SpaceTreeBloc>();
for (var communityId in spaceBloc.state.selectedCommunities) {
List<String> spacesList =
spaceBloc.state.selectedCommunityAndSpaces[communityId] ?? [];
List<String> spacesList = spaceBloc.state.selectedCommunityAndSpaces[communityId] ?? [];
for (var spaceId in spacesList) {
automations.addAll(
await SceneApi.getAutomation(spaceId, communityId, projectId));
automations.addAll(await SceneApi.getAutomation(spaceId, communityId, projectId));
}
}
} else {
automations.addAll(await SceneApi.getAutomation(
createRoutineBloc.selectedSpaceId,
createRoutineBloc.selectedCommunityId,
projectId));
createRoutineBloc.selectedSpaceId, createRoutineBloc.selectedCommunityId, projectId));
}
emit(state.copyWith(
automations: automations,
@ -244,16 +233,14 @@ class RoutineBloc extends Bloc<RoutineEvent, RoutineState> {
}
}
FutureOr<void> _onSearchRoutines(
SearchRoutines event, Emitter<RoutineState> emit) async {
FutureOr<void> _onSearchRoutines(SearchRoutines event, Emitter<RoutineState> emit) async {
emit(state.copyWith(isLoading: true, errorMessage: null));
await Future.delayed(const Duration(seconds: 1));
emit(state.copyWith(isLoading: false, errorMessage: null));
emit(state.copyWith(searchText: event.query));
}
FutureOr<void> _onAddSelectedIcon(
AddSelectedIcon event, Emitter<RoutineState> emit) {
FutureOr<void> _onAddSelectedIcon(AddSelectedIcon event, Emitter<RoutineState> emit) {
emit(state.copyWith(selectedIcon: event.icon));
}
@ -267,8 +254,7 @@ class RoutineBloc extends Bloc<RoutineEvent, RoutineState> {
return actions.last['deviceId'] == 'delay';
}
Future<void> _onCreateScene(
CreateSceneEvent event, Emitter<RoutineState> emit) async {
Future<void> _onCreateScene(CreateSceneEvent event, Emitter<RoutineState> emit) async {
try {
// Check if first action is delay
// if (_isFirstActionDelay(state.thenItems)) {
@ -357,8 +343,7 @@ class RoutineBloc extends Bloc<RoutineEvent, RoutineState> {
}
}
Future<void> _onCreateAutomation(
CreateAutomationEvent event, Emitter<RoutineState> emit) async {
Future<void> _onCreateAutomation(CreateAutomationEvent event, Emitter<RoutineState> emit) async {
try {
final projectUuid = await ProjectManager.getProjectUUID() ?? '';
if (state.routineName == null || state.routineName!.isEmpty) {
@ -471,8 +456,7 @@ class RoutineBloc extends Bloc<RoutineEvent, RoutineState> {
actions: actions,
);
final result =
await SceneApi.createAutomation(createAutomationModel, projectUuid);
final result = await SceneApi.createAutomation(createAutomationModel, projectUuid);
if (result['success']) {
add(ResetRoutineState());
add(const LoadAutomation());
@ -493,21 +477,17 @@ class RoutineBloc extends Bloc<RoutineEvent, RoutineState> {
}
}
FutureOr<void> _onRemoveDragCard(
RemoveDragCard event, Emitter<RoutineState> emit) {
FutureOr<void> _onRemoveDragCard(RemoveDragCard event, Emitter<RoutineState> emit) {
if (event.isFromThen) {
final thenItems = List<Map<String, dynamic>>.from(state.thenItems);
final selectedFunctions =
Map<String, List<DeviceFunctionData>>.from(state.selectedFunctions);
final selectedFunctions = Map<String, List<DeviceFunctionData>>.from(state.selectedFunctions);
thenItems.removeAt(event.index);
selectedFunctions.remove(event.key);
emit(state.copyWith(
thenItems: thenItems, selectedFunctions: selectedFunctions));
emit(state.copyWith(thenItems: thenItems, selectedFunctions: selectedFunctions));
} else {
final ifItems = List<Map<String, dynamic>>.from(state.ifItems);
final selectedFunctions =
Map<String, List<DeviceFunctionData>>.from(state.selectedFunctions);
final selectedFunctions = Map<String, List<DeviceFunctionData>>.from(state.selectedFunctions);
ifItems.removeAt(event.index);
selectedFunctions.remove(event.key);
@ -530,13 +510,11 @@ class RoutineBloc extends Bloc<RoutineEvent, RoutineState> {
));
}
FutureOr<void> _onEffectiveTimeEvent(
EffectiveTimePeriodEvent event, Emitter<RoutineState> emit) {
FutureOr<void> _onEffectiveTimeEvent(EffectiveTimePeriodEvent event, Emitter<RoutineState> emit) {
emit(state.copyWith(effectiveTime: event.effectiveTime));
}
FutureOr<void> _onSetRoutineName(
SetRoutineName event, Emitter<RoutineState> emit) {
FutureOr<void> _onSetRoutineName(SetRoutineName event, Emitter<RoutineState> emit) {
emit(state.copyWith(
routineName: event.name,
));
@ -558,7 +536,7 @@ class RoutineBloc extends Bloc<RoutineEvent, RoutineState> {
// 'entityId': 'tab_to_run',
// 'uniqueCustomId': const Uuid().v4(),
// 'deviceId': 'tab_to_run',
// 'title': 'Tab to run',
// 'title': 'Tap to run',
// 'productType': 'tab_to_run',
// 'imagePath': Assets.tabToRun,
// }
@ -663,8 +641,7 @@ class RoutineBloc extends Bloc<RoutineEvent, RoutineState> {
// return (thenItems, ifItems, currentFunctions);
// }
Future<void> _onGetSceneDetails(
GetSceneDetails event, Emitter<RoutineState> emit) async {
Future<void> _onGetSceneDetails(GetSceneDetails event, Emitter<RoutineState> emit) async {
try {
emit(state.copyWith(
isLoading: true,
@ -713,10 +690,9 @@ class RoutineBloc extends Bloc<RoutineEvent, RoutineState> {
deviceCards[deviceId] = {
'entityId': action.entityId,
'deviceId': action.actionExecutor == 'delay' ? 'delay' : action.entityId,
'uniqueCustomId':
action.type == 'automation' || action.actionExecutor == 'delay'
? action.entityId
: const Uuid().v4(),
'uniqueCustomId': action.type == 'automation' || action.actionExecutor == 'delay'
? action.entityId
: const Uuid().v4(),
'title': action.actionExecutor == 'delay'
? 'Delay'
: action.type == 'automation'
@ -756,8 +732,7 @@ class RoutineBloc extends Bloc<RoutineEvent, RoutineState> {
),
);
// emit(state.copyWith(automationActionExecutor: action.actionExecutor));
} else if (action.executorProperty != null &&
action.actionExecutor != 'delay') {
} else if (action.executorProperty != null && action.actionExecutor != 'delay') {
final functions = matchingDevice?.functions ?? [];
final functionCode = action.executorProperty?.functionCode;
for (DeviceFunction function in functions) {
@ -796,7 +771,7 @@ class RoutineBloc extends Bloc<RoutineEvent, RoutineState> {
'entityId': 'tab_to_run',
'uniqueCustomId': const Uuid().v4(),
'deviceId': 'tab_to_run',
'title': 'Tab to run',
'title': 'Tap to run',
'productType': 'tab_to_run',
'imagePath': Assets.tabToRun,
}
@ -823,8 +798,7 @@ class RoutineBloc extends Bloc<RoutineEvent, RoutineState> {
}
}
FutureOr<void> _onResetRoutineState(
ResetRoutineState event, Emitter<RoutineState> emit) {
FutureOr<void> _onResetRoutineState(ResetRoutineState event, Emitter<RoutineState> emit) {
emit(state.copyWith(
ifItems: [],
thenItems: [],
@ -857,8 +831,7 @@ class RoutineBloc extends Bloc<RoutineEvent, RoutineState> {
var spaceBloc = context.read<SpaceTreeBloc>();
if (state.isTabToRun) {
await SceneApi.deleteScene(
unitUuid: spaceBloc.state.selectedSpaces[0],
sceneId: state.sceneId ?? '');
unitUuid: spaceBloc.state.selectedSpaces[0], sceneId: state.sceneId ?? '');
} else {
await SceneApi.deleteAutomation(
unitUuid: spaceBloc.state.selectedSpaces[0],
@ -903,8 +876,7 @@ class RoutineBloc extends Bloc<RoutineEvent, RoutineState> {
// }
// }
FutureOr<void> _fetchDevices(
FetchDevicesInRoutine event, Emitter<RoutineState> emit) async {
FutureOr<void> _fetchDevices(FetchDevicesInRoutine event, Emitter<RoutineState> emit) async {
emit(state.copyWith(isLoading: true));
try {
final projectUuid = await ProjectManager.getProjectUUID() ?? '';
@ -913,21 +885,17 @@ class RoutineBloc extends Bloc<RoutineEvent, RoutineState> {
var createRoutineBloc = context.read<CreateRoutineBloc>();
var spaceBloc = context.read<SpaceTreeBloc>();
if (createRoutineBloc.selectedSpaceId == '' &&
createRoutineBloc.selectedCommunityId == '') {
if (createRoutineBloc.selectedSpaceId == '' && createRoutineBloc.selectedCommunityId == '') {
for (var communityId in spaceBloc.state.selectedCommunities) {
List<String> spacesList =
spaceBloc.state.selectedCommunityAndSpaces[communityId] ?? [];
List<String> spacesList = spaceBloc.state.selectedCommunityAndSpaces[communityId] ?? [];
for (var spaceId in spacesList) {
devices.addAll(await DevicesManagementApi()
.fetchDevices(communityId, spaceId, projectUuid));
devices.addAll(
await DevicesManagementApi().fetchDevices(communityId, spaceId, projectUuid));
}
}
} else {
devices.addAll(await DevicesManagementApi().fetchDevices(
createRoutineBloc.selectedCommunityId,
createRoutineBloc.selectedSpaceId,
projectUuid));
createRoutineBloc.selectedCommunityId, createRoutineBloc.selectedSpaceId, projectUuid));
}
emit(state.copyWith(isLoading: false, devices: devices));
@ -936,8 +904,7 @@ class RoutineBloc extends Bloc<RoutineEvent, RoutineState> {
}
}
FutureOr<void> _onUpdateScene(
UpdateScene event, Emitter<RoutineState> emit) async {
FutureOr<void> _onUpdateScene(UpdateScene event, Emitter<RoutineState> emit) async {
try {
// Check if first action is delay
// if (_isFirstActionDelay(state.thenItems)) {
@ -1004,8 +971,7 @@ class RoutineBloc extends Bloc<RoutineEvent, RoutineState> {
actions: actions,
);
final result =
await SceneApi.updateScene(createSceneModel, state.sceneId ?? '');
final result = await SceneApi.updateScene(createSceneModel, state.sceneId ?? '');
if (result['success']) {
add(ResetRoutineState());
add(const LoadScenes());
@ -1024,8 +990,7 @@ class RoutineBloc extends Bloc<RoutineEvent, RoutineState> {
}
}
FutureOr<void> _onUpdateAutomation(
UpdateAutomation event, Emitter<RoutineState> emit) async {
FutureOr<void> _onUpdateAutomation(UpdateAutomation event, Emitter<RoutineState> emit) async {
try {
if (state.routineName == null || state.routineName!.isEmpty) {
emit(state.copyWith(
@ -1141,8 +1106,8 @@ class RoutineBloc extends Bloc<RoutineEvent, RoutineState> {
if (result['success']) {
add(ResetRoutineState());
add(const LoadAutomation());
add(const LoadScenes());
add(LoadAutomation());
add(LoadScenes());
} else {
emit(state.copyWith(
isLoading: false,
@ -1326,13 +1291,10 @@ class RoutineBloc extends Bloc<RoutineEvent, RoutineState> {
}
}
final ifItems =
deviceIfCards.values.where((card) => card['type'] == 'condition').toList();
final ifItems = deviceIfCards.values.where((card) => card['type'] == 'condition').toList();
final thenItems = deviceThenCards.values
.where((card) =>
card['type'] == 'action' ||
card['type'] == 'automation' ||
card['type'] == 'scene')
card['type'] == 'action' || card['type'] == 'automation' || card['type'] == 'scene')
.toList();
emit(state.copyWith(
@ -1354,8 +1316,7 @@ class RoutineBloc extends Bloc<RoutineEvent, RoutineState> {
}
}
Future<void> _onSceneTrigger(
SceneTrigger event, Emitter<RoutineState> emit) async {
Future<void> _onSceneTrigger(SceneTrigger event, Emitter<RoutineState> emit) async {
emit(state.copyWith(loadingSceneId: event.sceneId));
try {
@ -1400,24 +1361,21 @@ class RoutineBloc extends Bloc<RoutineEvent, RoutineState> {
event.automationStatusUpdate.spaceUuid, event.communityId, projectId);
// Remove from loading set safely
final updatedLoadingIds = {...state.loadingAutomationIds!}
..remove(event.automationId);
final updatedLoadingIds = {...state.loadingAutomationIds!}..remove(event.automationId);
emit(state.copyWith(
automations: updatedAutomations,
loadingAutomationIds: updatedLoadingIds,
));
} else {
final updatedLoadingIds = {...state.loadingAutomationIds!}
..remove(event.automationId);
final updatedLoadingIds = {...state.loadingAutomationIds!}..remove(event.automationId);
emit(state.copyWith(
loadingAutomationIds: updatedLoadingIds,
errorMessage: 'Update failed',
));
}
} catch (e) {
final updatedLoadingIds = {...state.loadingAutomationIds!}
..remove(event.automationId);
final updatedLoadingIds = {...state.loadingAutomationIds!}..remove(event.automationId);
emit(state.copyWith(
loadingAutomationIds: updatedLoadingIds,
errorMessage: 'Update error: ${e.toString()}',

View File

@ -10,6 +10,7 @@ import 'package:syncrow_web/pages/routines/widgets/routine_dialogs/one_gang_swit
import 'package:syncrow_web/pages/routines/widgets/routine_dialogs/three_gang_switch_dialog.dart';
import 'package:syncrow_web/pages/routines/widgets/routine_dialogs/two_gang_switch_dialog.dart';
import 'package:syncrow_web/pages/routines/widgets/routine_dialogs/wall_sensor/wall_presence_sensor.dart';
import 'package:syncrow_web/pages/routines/widgets/routine_dialogs/water_heater/water_heater_presence_sensor.dart';
class DeviceDialogHelper {
static Future<Map<String, dynamic>?> showDeviceDialog({
@ -126,6 +127,15 @@ class DeviceDialogHelper {
dialogType: dialogType,
device: data['device'],
);
case 'WH':
return WaterHeaterDialogRoutines.showWHFunctionsDialog(
context: context,
functions: functions,
uniqueCustomId: data['uniqueCustomId'],
deviceSelectedFunctions: deviceSelectedFunctions,
dialogType: dialogType,
device: data['device'],
);
default:
return null;

View File

@ -162,7 +162,7 @@ class SaveRoutineHelper {
width: 24,
height: 24,
),
title: const Text('Tab to run'),
title: const Text('Tap to run'),
),
if (state.isAutomation)
...state.ifItems.map((item) {

View File

@ -14,6 +14,10 @@ abstract class ACFunction extends DeviceFunction<AcStatusModel> {
required super.operationName,
required super.icon,
required this.type,
super.step,
super.unit,
super.max,
super.min,
});
List<ACOperationalValue> getOperationalValues();
@ -75,26 +79,24 @@ class ModeFunction extends ACFunction {
}
class TempSetFunction extends ACFunction {
final int min;
final int max;
final int step;
TempSetFunction(
{required super.deviceId, required super.deviceName, required type})
: min = 160,
max = 300,
step = 1,
super(
TempSetFunction({
required super.deviceId,
required super.deviceName,
required super.type,
}) : super(
code: 'temp_set',
operationName: 'Set Temperature',
icon: Assets.assetsTempreture,
type: type,
min: 200,
max: 300,
step: 1,
unit: "°C",
);
@override
List<ACOperationalValue> getOperationalValues() {
List<ACOperationalValue> values = [];
for (int temp = min; temp <= max; temp += step) {
for (int temp = min!.toInt(); temp <= max!; temp += step!.toInt()) {
values.add(ACOperationalValue(
icon: Assets.assetsTempreture,
description: "${temp / 10}°C",
@ -104,7 +106,6 @@ class TempSetFunction extends ACFunction {
return values;
}
}
class LevelFunction extends ACFunction {
LevelFunction(
{required super.deviceId, required super.deviceName, required type})
@ -166,9 +167,10 @@ class ChildLockFunction extends ACFunction {
}
class CurrentTempFunction extends ACFunction {
final int min;
final int max;
final int step;
final double min;
final double max;
final double step;
final String unit = "°C";
CurrentTempFunction(
{required super.deviceId, required super.deviceName, required type})
@ -185,7 +187,7 @@ class CurrentTempFunction extends ACFunction {
@override
List<ACOperationalValue> getOperationalValues() {
List<ACOperationalValue> values = [];
for (int temp = min; temp <= max; temp += step) {
for (int temp = min.toInt(); temp <= max; temp += step.toInt()) {
values.add(ACOperationalValue(
icon: Assets.currentTemp,
description: "${temp / 10}°C",

View File

@ -6,10 +6,12 @@ class CpsOperationalValue {
final String description;
final dynamic value;
CpsOperationalValue({
required this.icon,
required this.description,
required this.value,
});
}
@ -94,9 +96,9 @@ final class CpsSensitivityFunction extends CpsFunctions {
icon: Assets.sensitivity,
);
final int min;
final int max;
final int step;
final double min;
final double max;
final double step;
static const _images = <String>[
Assets.sensitivityFeature1,
@ -115,10 +117,10 @@ final class CpsSensitivityFunction extends CpsFunctions {
@override
List<CpsOperationalValue> getOperationalValues() {
final values = <CpsOperationalValue>[];
for (var value = min; value <= max; value += step) {
for (var value = min; value <= max; value += step.toInt()) {
values.add(
CpsOperationalValue(
icon: _images[value],
icon: _images[value.toInt()],
description: '$value',
value: value,
),
@ -142,9 +144,9 @@ final class CpsMovingSpeedFunction extends CpsFunctions {
icon: Assets.speedoMeter,
);
final int min;
final int max;
final int step;
final double min;
final double max;
final double step;
@override
List<CpsOperationalValue> getOperationalValues() {
@ -173,9 +175,9 @@ final class CpsSpatialStaticValueFunction extends CpsFunctions {
icon: Assets.spatialStaticValue,
);
final int min;
final int max;
final int step;
final double min;
final double max;
final double step;
@override
List<CpsOperationalValue> getOperationalValues() {
@ -204,9 +206,9 @@ final class CpsSpatialMotionValueFunction extends CpsFunctions {
icon: Assets.spatialMotionValue,
);
final int min;
final int max;
final int step;
final double min;
final double max;
final double step;
@override
List<CpsOperationalValue> getOperationalValues() {
@ -375,9 +377,9 @@ final class CpsPresenceJudgementThrsholdFunction extends CpsFunctions {
icon: Assets.presenceJudgementThrshold,
);
final int min;
final int max;
final int step;
final double min;
final double max;
final double step;
@override
List<CpsOperationalValue> getOperationalValues() {
@ -406,9 +408,9 @@ final class CpsMotionAmplitudeTriggerThresholdFunction extends CpsFunctions {
icon: Assets.presenceJudgementThrshold,
);
final int min;
final int max;
final int step;
final double min;
final double max;
final double step;
@override
List<CpsOperationalValue> getOperationalValues() {

View File

@ -4,6 +4,11 @@ abstract class DeviceFunction<T> {
final String code;
final String operationName;
final String icon;
final double? step;
final String? unit;
final double? max;
final double? min;
DeviceFunction({
required this.deviceId,
@ -11,6 +16,10 @@ abstract class DeviceFunction<T> {
required this.code,
required this.operationName,
required this.icon,
this.step,
this.unit,
this.max,
this.min,
});
}
@ -22,6 +31,10 @@ class DeviceFunctionData {
final dynamic value;
final String? condition;
final String? valueDescription;
final double? step;
final String? unit;
final double? max;
final double? min;
DeviceFunctionData({
required this.entityId,
@ -31,6 +44,10 @@ class DeviceFunctionData {
required this.value,
this.condition,
this.valueDescription,
this.step,
this.unit,
this.max,
this.min,
});
Map<String, dynamic> toJson() {
@ -42,6 +59,10 @@ class DeviceFunctionData {
'value': value,
if (condition != null) 'condition': condition,
if (valueDescription != null) 'valueDescription': valueDescription,
if (step != null) 'step': step,
if (unit != null) 'unit': unit,
if (max != null) 'max': max,
if (min != null) 'min': min,
};
}
@ -54,6 +75,10 @@ class DeviceFunctionData {
value: json['value'],
condition: json['condition'],
valueDescription: json['valueDescription'],
step: json['step']?.toDouble(),
unit: json['unit'],
max: json['max']?.toDouble(),
min: json['min']?.toDouble(),
);
}
@ -68,7 +93,11 @@ class DeviceFunctionData {
other.operationName == operationName &&
other.value == value &&
other.condition == condition &&
other.valueDescription == valueDescription;
other.valueDescription == valueDescription &&
other.step == step &&
other.unit == unit &&
other.max == max &&
other.min == min;
}
@override
@ -79,6 +108,10 @@ class DeviceFunctionData {
operationName.hashCode ^
value.hashCode ^
condition.hashCode ^
valueDescription.hashCode;
valueDescription.hashCode ^
step.hashCode ^
unit.hashCode ^
max.hashCode ^
min.hashCode;
}
}

View File

@ -20,12 +20,11 @@ abstract class FlushFunctions
}
class FlushPresenceDelayFunction extends FlushFunctions {
final int min;
FlushPresenceDelayFunction({
required super.deviceId,
required super.deviceName,
required super.type,
}) : min = 0,
}) :
super(
code: FlushMountedPresenceSensorModel.codePresenceState,
operationName: 'Presence State',
@ -50,9 +49,9 @@ class FlushPresenceDelayFunction extends FlushFunctions {
}
class FlushSensiReduceFunction extends FlushFunctions {
final int min;
final int max;
final int step;
final double min;
final double max;
final double step;
FlushSensiReduceFunction({
required super.deviceId,
@ -80,8 +79,8 @@ class FlushSensiReduceFunction extends FlushFunctions {
}
class FlushNoneDelayFunction extends FlushFunctions {
final int min;
final int max;
final double min;
final double max;
final String unit;
FlushNoneDelayFunction({
@ -110,9 +109,9 @@ class FlushNoneDelayFunction extends FlushFunctions {
}
class FlushIlluminanceFunction extends FlushFunctions {
final int min;
final int max;
final int step;
final double min;
final double max;
final double step;
FlushIlluminanceFunction({
required super.deviceId,
@ -130,7 +129,7 @@ class FlushIlluminanceFunction extends FlushFunctions {
@override
List<FlushOperationalValue> getOperationalValues() {
List<FlushOperationalValue> values = [];
for (int lux = min; lux <= max; lux += step) {
for (int lux = min.toInt(); lux <= max; lux += step.toInt()) {
values.add(FlushOperationalValue(
icon: Assets.IlluminanceIcon,
description: "$lux Lux",
@ -142,9 +141,9 @@ class FlushIlluminanceFunction extends FlushFunctions {
}
class FlushOccurDistReduceFunction extends FlushFunctions {
final int min;
final int max;
final int step;
final double min;
final double max;
final double step;
FlushOccurDistReduceFunction({
required super.deviceId,
@ -173,9 +172,9 @@ class FlushOccurDistReduceFunction extends FlushFunctions {
// ==== then functions ====
class FlushSensitivityFunction extends FlushFunctions {
final int min;
final int max;
final int step;
final double min;
final double max;
final double step;
FlushSensitivityFunction({
required super.deviceId,
@ -203,9 +202,9 @@ class FlushSensitivityFunction extends FlushFunctions {
}
class FlushNearDetectionFunction extends FlushFunctions {
final int min;
final double min;
final double max;
final int step;
final double step;
final String unit;
FlushNearDetectionFunction({
@ -225,7 +224,7 @@ class FlushNearDetectionFunction extends FlushFunctions {
@override
List<FlushOperationalValue> getOperationalValues() {
final values = <FlushOperationalValue>[];
for (var value = min; value <= max; value += step) {
for (var value = min.toDouble(); value <= max; value += step) {
values.add(FlushOperationalValue(
icon: Assets.nobodyTime,
description: '$value $unit',
@ -237,9 +236,9 @@ class FlushNearDetectionFunction extends FlushFunctions {
}
class FlushMaxDetectDistFunction extends FlushFunctions {
final int min;
final int max;
final int step;
final double min;
final double max;
final double step;
final String unit;
FlushMaxDetectDistFunction({
@ -259,7 +258,7 @@ class FlushMaxDetectDistFunction extends FlushFunctions {
@override
List<FlushOperationalValue> getOperationalValues() {
final values = <FlushOperationalValue>[];
for (var value = min; value <= max; value += step) {
for (var value = min; value <= max; value += step.toInt()) {
values.add(FlushOperationalValue(
icon: Assets.nobodyTime,
description: '$value $unit',
@ -271,9 +270,9 @@ class FlushMaxDetectDistFunction extends FlushFunctions {
}
class FlushTargetConfirmTimeFunction extends FlushFunctions {
final int min;
final int max;
final int step;
final double min;
final double max;
final double step;
final String unit;
FlushTargetConfirmTimeFunction({
@ -293,7 +292,7 @@ class FlushTargetConfirmTimeFunction extends FlushFunctions {
@override
List<FlushOperationalValue> getOperationalValues() {
final values = <FlushOperationalValue>[];
for (var value = min; value <= max; value += step) {
for (var value = min.toDouble(); value <= max; value += step) {
values.add(FlushOperationalValue(
icon: Assets.nobodyTime,
description: '$value $unit',
@ -305,9 +304,9 @@ class FlushTargetConfirmTimeFunction extends FlushFunctions {
}
class FlushDisappeDelayFunction extends FlushFunctions {
final int min;
final int max;
final int step;
final double min;
final double max;
final double step;
final String unit;
FlushDisappeDelayFunction({
@ -327,7 +326,7 @@ class FlushDisappeDelayFunction extends FlushFunctions {
@override
List<FlushOperationalValue> getOperationalValues() {
final values = <FlushOperationalValue>[];
for (var value = min; value <= max; value += step) {
for (var value = min.toDouble(); value <= max; value += step) {
values.add(FlushOperationalValue(
icon: Assets.nobodyTime,
description: '$value $unit',
@ -339,9 +338,9 @@ class FlushDisappeDelayFunction extends FlushFunctions {
}
class FlushIndentLevelFunction extends FlushFunctions {
final int min;
final int max;
final int step;
final double min;
final double max;
final double step;
final String unit;
FlushIndentLevelFunction({
@ -361,7 +360,7 @@ class FlushIndentLevelFunction extends FlushFunctions {
@override
List<FlushOperationalValue> getOperationalValues() {
final values = <FlushOperationalValue>[];
for (var value = min; value <= max; value += step) {
for (var value = min.toDouble(); value <= max; value += step) {
values.add(FlushOperationalValue(
icon: Assets.nobodyTime,
description: '$value $unit',
@ -373,9 +372,9 @@ class FlushIndentLevelFunction extends FlushFunctions {
}
class FlushTriggerLevelFunction extends FlushFunctions {
final int min;
final int max;
final int step;
final double min;
final double max;
final double step;
final String unit;
FlushTriggerLevelFunction({
@ -395,7 +394,7 @@ class FlushTriggerLevelFunction extends FlushFunctions {
@override
List<FlushOperationalValue> getOperationalValues() {
final values = <FlushOperationalValue>[];
for (var value = min; value <= max; value += step) {
for (var value = min.toDouble(); value <= max; value += step) {
values.add(FlushOperationalValue(
icon: Assets.nobodyTime,
description: '$value $unit',

View File

@ -0,0 +1,130 @@
import 'package:syncrow_web/pages/device_managment/water_heater/models/water_heater_status_model.dart';
import 'package:syncrow_web/pages/routines/models/device_functions.dart';
import 'package:syncrow_web/pages/routines/models/water_heater/water_heater_operational_value.dart';
import 'package:syncrow_web/utils/constants/assets.dart';
abstract class WaterHeaterFunctions
extends DeviceFunction<WaterHeaterStatusModel> {
final String type;
WaterHeaterFunctions({
required super.deviceId,
required super.deviceName,
required super.code,
required super.operationName,
required super.icon,
required this.type,
});
List<WaterHeaterOperationalValue> getOperationalValues();
}
class WHRestartStatusFunction extends WaterHeaterFunctions {
WHRestartStatusFunction({
required super.deviceId,
required super.deviceName,
required super.type,
}) : super(
code: 'relay_status',
operationName: 'Restart Status',
icon: Assets.refreshStatusIcon,
);
@override
List<WaterHeaterOperationalValue> getOperationalValues() {
return [
WaterHeaterOperationalValue(
icon: Assets.assetsAcPowerOFF,
description: 'Power OFF',
value: "off",
),
WaterHeaterOperationalValue(
icon: Assets.assetsAcPower,
description: 'Power ON',
value: 'on',
),
WaterHeaterOperationalValue(
icon: Assets.refreshStatusIcon,
description: "Restart Memory",
value: 'memory',
),
];
}
}
class WHSwitchFunction extends WaterHeaterFunctions {
WHSwitchFunction({
required super.deviceId,
required super.deviceName,
required super.type,
}) : super(
code: 'switch_1',
operationName: 'Switch',
icon: Assets.assetsAcPower,
);
@override
List<WaterHeaterOperationalValue> getOperationalValues() {
return [
WaterHeaterOperationalValue(
icon: Assets.assetsAcPower,
description: 'ON',
value: true,
),
WaterHeaterOperationalValue(
icon: Assets.assetsAcPowerOFF,
description: 'OFF',
value: false,
),
];
}
}
class TimerConfirmTimeFunction extends WaterHeaterFunctions {
TimerConfirmTimeFunction({
required super.deviceId,
required super.deviceName,
required super.type,
}) : super(
code: 'countdown_1',
operationName: 'Timer',
icon: Assets.targetConfirmTimeIcon,
);
@override
List<WaterHeaterOperationalValue> getOperationalValues() {
final values = <WaterHeaterOperationalValue>[];
return values;
}
}
class BacklightFunction extends WaterHeaterFunctions {
BacklightFunction({
required super.deviceId,
required super.deviceName,
required super.type,
}) :
super(
code: 'switch_backlight',
operationName: 'Backlight',
icon: Assets.indicator,
);
@override
List<WaterHeaterOperationalValue> getOperationalValues() {
return [
WaterHeaterOperationalValue(
icon: Assets.assetsAcPower,
description: 'ON',
value: true,
),
WaterHeaterOperationalValue(
icon: Assets.assetsAcPowerOFF,
description: 'OFF',
value: false,
),
];
}
}

View File

@ -0,0 +1,11 @@
class WaterHeaterOperationalValue {
final String icon;
final String description;
final dynamic value;
WaterHeaterOperationalValue({
required this.icon,
required this.description,
required this.value,
});
}

View File

@ -4,7 +4,7 @@ import 'package:syncrow_web/pages/routines/models/wps/wps_operational_value.dart
import 'package:syncrow_web/utils/constants/assets.dart';
abstract class WpsFunctions extends DeviceFunction<WallSensorModel> {
final String type;
final String type;
WpsFunctions({
required super.deviceId,
@ -13,6 +13,10 @@ abstract class WpsFunctions extends DeviceFunction<WallSensorModel> {
required super.operationName,
required super.icon,
required this.type,
super.step,
super.unit,
super.max,
super.min,
});
List<WpsOperationalValue> getOperationalValues();
@ -20,9 +24,13 @@ abstract class WpsFunctions extends DeviceFunction<WallSensorModel> {
// For far_detection (75-600cm in 75cm steps)
class FarDetectionFunction extends WpsFunctions {
final int min;
final int max;
final int step;
final double min;
@override
final double max;
@override
final double step;
@override
final String unit;
FarDetectionFunction(
@ -41,7 +49,7 @@ class FarDetectionFunction extends WpsFunctions {
@override
List<WpsOperationalValue> getOperationalValues() {
final values = <WpsOperationalValue>[];
for (var value = min; value <= max; value += step) {
for (var value = min; value <= max; value += step.toInt()) {
values.add(WpsOperationalValue(
icon: Assets.currentDistanceIcon,
description: '$value $unit',
@ -54,9 +62,9 @@ class FarDetectionFunction extends WpsFunctions {
// For presence_time (0-65535 minutes)
class PresenceTimeFunction extends WpsFunctions {
final int min;
final int max;
final int step;
final double min;
final double max;
final double step;
final String unit;
PresenceTimeFunction(
@ -86,9 +94,9 @@ class PresenceTimeFunction extends WpsFunctions {
// For motion_sensitivity_value (1-5 levels)
class MotionSensitivityFunction extends WpsFunctions {
final int min;
final int max;
final int step;
final double min;
final double max;
final double step;
MotionSensitivityFunction(
{required super.deviceId, required super.deviceName, required type})
@ -116,9 +124,9 @@ class MotionSensitivityFunction extends WpsFunctions {
}
class MotionLessSensitivityFunction extends WpsFunctions {
final int min;
final int max;
final int step;
final double min;
final double max;
final double step;
MotionLessSensitivityFunction(
{required super.deviceId, required super.deviceName, required type})
@ -171,8 +179,8 @@ class IndicatorFunction extends WpsFunctions {
}
class NoOneTimeFunction extends WpsFunctions {
final int min;
final int max;
final double min;
final double max;
final String unit;
NoOneTimeFunction(
@ -225,9 +233,9 @@ class PresenceStateFunction extends WpsFunctions {
}
class CurrentDistanceFunction extends WpsFunctions {
final int min;
final int max;
final int step;
final double min;
final double max;
final double step;
CurrentDistanceFunction(
{required super.deviceId, required super.deviceName, required type})
@ -244,11 +252,10 @@ class CurrentDistanceFunction extends WpsFunctions {
@override
List<WpsOperationalValue> getOperationalValues() {
List<WpsOperationalValue> values = [];
for (int cm = min; cm <= max; cm += step) {
for (int cm = min.toInt(); cm <= max; cm += step.toInt()) {
values.add(WpsOperationalValue(
icon: Assets.assetsTempreture,
description: "${cm}CM",
value: cm,
));
}
@ -257,9 +264,9 @@ class CurrentDistanceFunction extends WpsFunctions {
}
class IlluminanceValueFunction extends WpsFunctions {
final int min;
final int max;
final int step;
final double min;
final double max;
final double step;
IlluminanceValueFunction({
required super.deviceId,
@ -277,7 +284,7 @@ class IlluminanceValueFunction extends WpsFunctions {
@override
List<WpsOperationalValue> getOperationalValues() {
List<WpsOperationalValue> values = [];
for (int lux = min; lux <= max; lux += step) {
for (int lux = min.toInt(); lux <= max; lux += step.toInt()) {
values.add(WpsOperationalValue(
icon: Assets.IlluminanceIcon,
description: "$lux Lux",

View File

@ -29,11 +29,11 @@ class ConditionsRoutinesDevicesView extends StatelessWidget {
children: [
DraggableCard(
imagePath: Assets.tabToRun,
title: 'Tab to run',
title: 'Tap to run',
deviceData: {
'deviceId': 'tab_to_run',
'type': 'trigger',
'name': 'Tab to run',
'name': 'Tap to run',
},
),
DraggableCard(

View File

@ -0,0 +1,297 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:syncrow_web/pages/routines/widgets/condition_toggle.dart';
import 'package:syncrow_web/pages/routines/widgets/slider_value_selector.dart';
import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart';
class CustomRoutinesTextbox extends StatefulWidget {
final String? currentCondition;
final String dialogType;
final (double, double) sliderRange;
final dynamic displayedValue;
final dynamic initialValue;
final void Function(String condition) onConditionChanged;
final void Function(double value) onTextChanged;
final String unit;
final double dividendOfRange;
final double stepIncreaseAmount;
final bool withSpecialChar;
const CustomRoutinesTextbox({
required this.dialogType,
required this.sliderRange,
required this.displayedValue,
required this.initialValue,
required this.onConditionChanged,
required this.onTextChanged,
required this.currentCondition,
required this.unit,
required this.dividendOfRange,
required this.stepIncreaseAmount,
required this.withSpecialChar,
super.key,
});
@override
State<CustomRoutinesTextbox> createState() => _CustomRoutinesTextboxState();
}
class _CustomRoutinesTextboxState extends State<CustomRoutinesTextbox> {
late final TextEditingController _controller;
bool hasError = false;
String? errorMessage;
int getDecimalPlaces(double step) {
String stepStr = step.toString();
if (stepStr.contains('.')) {
List<String> parts = stepStr.split('.');
String decimalPart = parts[1];
decimalPart = decimalPart.replaceAll(RegExp(r'0+$'), '');
return decimalPart.isEmpty ? 0 : decimalPart.length;
} else {
return 0;
}
}
@override
void initState() {
super.initState();
int decimalPlaces = getDecimalPlaces(widget.stepIncreaseAmount);
double initialValue;
if (widget.initialValue != null &&
widget.initialValue is num &&
(widget.initialValue as num) == 0) {
initialValue = 0.0;
} else {
initialValue = double.tryParse(widget.displayedValue) ?? 0.0;
}
_controller = TextEditingController(
text: initialValue.toStringAsFixed(decimalPlaces),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _validateInput(String value) {
final doubleValue = double.tryParse(value);
if (doubleValue == null) {
setState(() {
errorMessage = "Invalid number";
hasError = true;
});
return;
}
final min = widget.sliderRange.$1;
final max = widget.sliderRange.$2;
if (doubleValue < min) {
setState(() {
errorMessage = "Value must be at least $min";
hasError = true;
});
} else if (doubleValue > max) {
setState(() {
errorMessage = "Value must be at most $max";
hasError = true;
});
} else {
int decimalPlaces = getDecimalPlaces(widget.stepIncreaseAmount);
int factor = pow(10, decimalPlaces).toInt();
int scaledStep = (widget.stepIncreaseAmount * factor).round();
int scaledValue = (doubleValue * factor).round();
if (scaledValue % scaledStep != 0) {
setState(() {
errorMessage = "must be a multiple of ${widget.stepIncreaseAmount}";
hasError = true;
});
} else {
setState(() {
errorMessage = null;
hasError = false;
});
}
}
}
@override
void didUpdateWidget(CustomRoutinesTextbox oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.initialValue != oldWidget.initialValue) {
if (widget.initialValue != null &&
widget.initialValue is num &&
(widget.initialValue as num) == 0) {
int decimalPlaces = getDecimalPlaces(widget.stepIncreaseAmount);
_controller.text = 0.0.toStringAsFixed(decimalPlaces);
}
}
}
void _correctAndUpdateValue(String value) {
final doubleValue = double.tryParse(value) ?? 0.0;
int decimalPlaces = getDecimalPlaces(widget.stepIncreaseAmount);
double rounded = (doubleValue / widget.stepIncreaseAmount).round() *
widget.stepIncreaseAmount;
rounded = rounded.clamp(widget.sliderRange.$1, widget.sliderRange.$2);
rounded = double.parse(rounded.toStringAsFixed(decimalPlaces));
setState(() {
hasError = false;
errorMessage = null;
});
_controller.text = rounded.toStringAsFixed(decimalPlaces);
_controller.selection = TextSelection.fromPosition(
TextPosition(offset: _controller.text.length),
);
widget.onTextChanged(rounded);
}
@override
Widget build(BuildContext context) {
int decimalPlaces = getDecimalPlaces(widget.stepIncreaseAmount);
List<TextInputFormatter> formatters = [];
if (decimalPlaces == 0) {
formatters.add(FilteringTextInputFormatter.digitsOnly);
} else {
formatters.add(FilteringTextInputFormatter.allow(
RegExp(r'^\d*\.?\d{0,' + decimalPlaces.toString() + r'}$'),
));
}
formatters.add(RangeInputFormatter(
min: widget.sliderRange.$1,
max: widget.sliderRange.$2,
));
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (widget.dialogType == 'IF')
ConditionToggle(
currentCondition: widget.currentCondition,
onChanged: widget.onConditionChanged,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 35, vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(
'Step: ${widget.stepIncreaseAmount}',
style: context.textTheme.bodySmall?.copyWith(
color: ColorsManager.grayColor,
fontSize: 10,
fontWeight: FontWeight.w400,
),
),
],
),
),
Center(
child: Container(
width: 170,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFFF8F8F8),
borderRadius: BorderRadius.circular(20),
border: hasError
? Border.all(color: Colors.red, width: 1)
: Border.all(
color: ColorsManager.lightGrayBorderColor, width: 1),
boxShadow: [
BoxShadow(
color: ColorsManager.blackColor.withOpacity(0.05),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: Row(
children: [
Expanded(
child: TextFormField(
controller: _controller,
style: context.textTheme.bodyLarge?.copyWith(
fontSize: 20,
fontWeight: FontWeight.bold,
color: ColorsManager.blackColor,
),
keyboardType: TextInputType.number,
inputFormatters: widget.withSpecialChar == true
? [FilteringTextInputFormatter.digitsOnly]
: null,
decoration: const InputDecoration(
border: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.zero,
),
onChanged: _validateInput,
onFieldSubmitted: _correctAndUpdateValue,
onTapOutside: (_) =>
_correctAndUpdateValue(_controller.text),
),
),
const SizedBox(width: 12),
Text(
widget.unit,
style: context.textTheme.bodyMedium?.copyWith(
fontSize: 20,
fontWeight: FontWeight.bold,
color: ColorsManager.vividBlue,
),
),
],
),
),
),
if (errorMessage != null)
Padding(
padding: const EdgeInsets.only(top: 2.0),
child: Text(
errorMessage!,
style: context.textTheme.bodySmall?.copyWith(
color: Colors.red,
fontSize: 10,
),
),
),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Min. ${widget.sliderRange.$1.toInt()}${widget.unit}',
style: context.textTheme.bodySmall?.copyWith(
color: ColorsManager.grayColor,
fontSize: 10,
fontWeight: FontWeight.w400,
),
),
Text(
'Max. ${widget.sliderRange.$2.toInt()}${widget.unit}',
style: context.textTheme.bodySmall?.copyWith(
color: ColorsManager.grayColor,
fontSize: 10,
fontWeight: FontWeight.w400,
),
),
],
),
),
const SizedBox(height: 16),
],
);
}
}

View File

@ -43,7 +43,7 @@ class IfContainer extends StatelessWidget {
children: [
DraggableCard(
imagePath: Assets.tabToRun,
title: 'Tab to run',
title: 'Tap to run',
deviceData: {},
),
],
@ -76,7 +76,8 @@ class IfContainer extends StatelessWidget {
'WPS',
'GW',
'CPS',
'NCPS'
'NCPS',
'WH',
].contains(state.ifItems[index]
['productType'])) {
@ -136,7 +137,7 @@ class IfContainer extends StatelessWidget {
context
.read<RoutineBloc>()
.add(AddToIfContainer(mutableData, false));
} else if (!['AC', '1G', '2G', '3G', 'WPS', 'GW', 'CPS', 'NCPS']
} else if (!['AC', '1G', '2G', '3G', 'WPS', 'GW', 'CPS', 'NCPS','WH']
.contains(mutableData['productType'])) {
context
.read<RoutineBloc>()

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