Compare commits

..

75 Commits

Author SHA1 Message Date
78f42dacf6 Adjust ConditionToggle widget dimensions and colors for improved UI consistency 2025-06-01 14:37:42 +03:00
0a9d53e5bd Refactor ConditionToggle widget to display icons with corresponding conditions 2025-05-29 10:48:12 +03:00
010960c89b Merge pull request #208 from SyncrowIOT/SP-1603-FE-Freeze-First-Row-in-All-Table-Views-Across-the-Platform
Refactor table layout to accommodate dynamic table size
2025-05-28 16:57:56 +03:00
fccf395c38 Update function names to follow consistent naming convention in name_filter.dart and users_page.dart 2025-05-28 16:56:51 +03:00
7c65b874eb Refactor table layout to accommodate dynamic table size 2025-05-28 16:40:44 +03:00
25db6ec687 Created pull_request_template.md . 2025-05-28 14:24:03 +03:00
7f5d2ca6ea Merge pull request #206 from SyncrowIOT/SP-1592-FE-Build-AQI-Breakdown-Percentage-Chart-with-Standard-Color-Codes
SP-1592-FE-Build-AQI-Breakdown-Percentage-Chart-with-Standard-Color-Codes
2025-05-27 15:17:10 +03:00
5a5173c19b Merge branch 'dev' of https://github.com/SyncrowIOT/web into SP-1592-FE-Build-AQI-Breakdown-Percentage-Chart-with-Standard-Color-Codes 2025-05-27 15:16:37 +03:00
83363b4c50 Made RangeOfAqiChart._lines colors use ColorsManager colors instead of statically defining them in the widget itself using Hex codes. 2025-05-27 15:15:29 +03:00
6ebdc59966 Merge pull request #207 from SyncrowIOT/Fix-Bugs-Related-TextForm-Routine
Add 'PC' device to routine
2025-05-27 14:56:11 +03:00
5f3a0c74ac Merge branch 'dev' of https://github.com/SyncrowIOT/web into SP-1592-FE-Build-AQI-Breakdown-Percentage-Chart-with-Standard-Color-Codes 2025-05-27 14:06:48 +03:00
03009ed276 made a RangeOfAqiChart._lines a getter. 2025-05-27 13:21:42 +03:00
a1142eb38c gave range of aqi chart a tooltip that shows the necessary data. 2025-05-27 13:17:05 +03:00
1aa7bf2162 fixed charts clipping overflow in chart. 2025-05-27 12:37:36 +03:00
043820f84f does not emit an entirely new state when we already have chart data on loading. 2025-05-27 12:33:48 +03:00
d90d3d4026 added loading state to range of aqi chart. 2025-05-27 12:29:06 +03:00
3ac5254abf fixed bug in total energy consumption chart. 2025-05-27 12:26:21 +03:00
f5d926f5a2 modify left side titles. 2025-05-27 12:21:59 +03:00
c1d6db8bba Merge branch 'dev' into Fix-Bugs-Related-TextForm-Routine 2025-05-27 09:56:07 +03:00
50fc5f9562 Add 'PC' device to routine 2025-05-27 09:54:21 +03:00
1b0d8d446c modified flex's values. 2025-05-27 09:47:06 +03:00
8a5173f429 made font size of AqiTypeDropdown slightly smaller. 2025-05-27 09:36:35 +03:00
bee8652d03 responsivness 2025-05-26 16:59:44 +03:00
9546d7bdd1 fixed titles widget for bottom title. 2025-05-26 16:56:38 +03:00
cb4956f915 made range of aqi fake data random and not linear. 2025-05-26 16:56:25 +03:00
ec7b0aa078 shows AnalyticsErrorWidget and spacing under it only when there is an error. 2025-05-26 15:58:34 +03:00
296b03e1aa shows month data instead of index on bottom titles of RangeOfAqiChart. 2025-05-26 15:54:56 +03:00
177c7f1030 Responsiveness of RangeOfAqiChartTitle. 2025-05-26 15:49:38 +03:00
3746c36a71 Merge pull request #205 from SyncrowIOT/sp-1493-data-foramtting-2.0
sp-1493-data-formatting-2.0.
2025-05-26 15:19:18 +03:00
0b4337fb6c sp-1493-data-formatting-2.0. 2025-05-26 15:17:29 +03:00
171dc52e28 Created AqiTypeDropdown. 2025-05-26 15:10:30 +03:00
642d8e9591 Merge pull request #204 from SyncrowIOT/SP-1493-data-formatting
SP-1493-data-formatting
2025-05-26 14:24:46 +03:00
5a8ef578c3 SP-1493-data-formatting 2025-05-26 14:16:43 +03:00
63ca98895f moved RangeOfAqiChartTitle. 2025-05-26 13:27:12 +03:00
7e54cfdccd Implemented min, max, average informative cells to RangeOfAqiChartBox. 2025-05-26 13:25:14 +03:00
fb4d44450f Disabled animation in RangeOfAqiChart. 2025-05-26 11:25:12 +03:00
12e4285b14 removed unnecessary Stack widget from RangeOfAqiChart. 2025-05-26 11:24:53 +03:00
82adbcf4df loads and clears aqi range data in FetchAirQualityDataHelper. 2025-05-26 11:24:00 +03:00
7305d511bc Added spaceUuid to GetRangeOfAqiParam model. 2025-05-26 11:23:33 +03:00
61acaa17c5 fixed typo. 2025-05-26 11:22:11 +03:00
4af81bcc10 make the aqi range chart read its data from RangeOfAqiBloc. 2025-05-26 11:22:05 +03:00
d4dd7a19ba make the generated fake aqi range data, look better on the chart. 2025-05-26 11:21:42 +03:00
9ab906d24c Injected RangeOfAqiBloc into AnalyticsPage. 2025-05-26 11:10:23 +03:00
5c57143ea5 Created RangeOfAqiBloc along with its events, and state. 2025-05-26 11:09:45 +03:00
4a3085e1b4 Created RangeOfAqiService along with its fake implementation until the API is ready. 2025-05-26 11:00:57 +03:00
eb8ba1806c Created GetRangeOfAqiParam model. 2025-05-26 10:59:07 +03:00
902419f9c4 Created RangeOfAqi model. 2025-05-26 10:58:05 +03:00
926bcd9a5d Extracted lines data into a helper method for ease of readability. 2025-05-26 10:48:04 +03:00
33f9add78a Extracted some logic of RangeOfAqiChart into a helper class. 2025-05-26 10:41:36 +03:00
563a3e1cf5 Refactored RangeOfAqiChart to consolidate line chart creation into a reusable method, improving code maintainability and reducing duplication. 2025-05-26 10:31:21 +03:00
791b71276a populated linear data for RangeOfAqiChart, for a more pleasant dev experience and debugging. 2025-05-26 10:29:38 +03:00
24e3eb2311 extracted titlesData into a private factory method to enahnce readability. 2025-05-26 10:18:15 +03:00
82006e9aaf Implemented the side titles of RangeOfAqiChart. 2025-05-26 10:12:52 +03:00
cedef666f6 Merge pull request #202 from SyncrowIOT/SP-1493-rework
SP-1493 rework
2025-05-26 10:03:10 +03:00
a10d998ec6 Merge pull request #203 from SyncrowIOT/SP-1513-rework
SP-1513-rework
2025-05-26 10:02:49 +03:00
ed50ac03d3 Merge pull request #201 from SyncrowIOT/SP-1492-landing_page_analytics_button_design
SP-1492-landing_page_analytics_button_design
2025-05-26 09:57:23 +03:00
cd2eb46f49 Implemented the overall design of RangeOfAqiChart, whats left is 100% matching it with the figma design. 2025-05-26 09:50:53 +03:00
39351a710d Added aqi info colors to ColorsManager. 2025-05-25 12:18:09 +03:00
c8fe4e3baa Created an initial version of RangeOfAqiChart. 2025-05-25 12:01:45 +03:00
12deceb7d3 SP-1513-rework 2025-05-25 11:35:01 +03:00
9d27ed2dc5 SP-1506 rework, coloring and padding. 2025-05-25 11:13:24 +03:00
a878b9328a SP-1493 rework, can select a subspace in sidebar even when the space has no child-spaces. 2025-05-25 11:06:36 +03:00
0f9227a6f5 Merge pull request #200 from SyncrowIOT/SP-1591-FE-Implement-Space-Level-Structure-Selection-and-Air-Quality-Device-Dropdown
Sp 1591 fe implement space level structure selection and air quality device dropdown
2025-05-22 15:59:19 +03:00
5b13962d41 removed unnecessary * 1 calculation of height. 2025-05-22 15:57:03 +03:00
8c53d5322a SP-1591 2025-05-22 15:53:18 +03:00
af4d37939b Merge branch 'dev' of https://github.com/SyncrowIOT/web into SP-1591-FE-Implement-Space-Level-Structure-Selection-and-Air-Quality-Device-Dropdown 2025-05-22 15:48:47 +03:00
d43c1847ff SP-1591 2025-05-22 15:44:19 +03:00
4c5b390887 Fixed typos. 2025-05-22 15:42:49 +03:00
5eeac01666 cannot select a community in AirQualityDataLoadingStrategy. 2025-05-22 15:35:04 +03:00
717d698378 can select child spaces with children in AirQualityDataLoadingStrategy. 2025-05-22 15:23:42 +03:00
9adbbb9a2d Integrated and implemented devices dropdown into the newly created widget AirQualityEndSideWidget. 2025-05-22 15:19:50 +03:00
e792dbd72f SP-1591/ Implement business logic in AirQualityDataLoadingStrategy for community structure loading strategy. 2025-05-22 14:58:42 +03:00
d2eea33714 Prepared AirQualityView layout and structure with PlaceHolder widgets. 2025-05-22 12:24:13 +03:00
5a61647fe4 Prepared and created the necessary component for the air quality loading strategy for the side bar selection, and for loading data in different parts of the UI. 2025-05-21 16:49:30 +03:00
568b6be354 Created AirQualityView widget for the new Air Quality analytics module. 2025-05-21 16:46:38 +03:00
56 changed files with 2633 additions and 526 deletions

30
.github/pull_request_template.md vendored Normal file
View File

@ -0,0 +1,30 @@
<!--
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
<!-- Add your Jira ticket number as a link (e.g., [PROJ-123](https://jira.company.com/browse/PROJ-123)) -->
## Status
**READY/IN DEVELOPMENT/HOLD**
## 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

View File

@ -0,0 +1,10 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 6.90237L13.6328 13.1837L6.60156 6.1524L2.32422 10.418L1.50391 9.59769L6.60156 4.48828L13.6328 11.5195L19.1797 6.08206L20 6.90237Z" fill="#64E1DC"/>
<path d="M20 13.1133L19.1797 13.9336L13.6328 8.49615L7.77344 14.3555L5.42969 12.0118L2.32422 15.1055L1.50391 14.2852L5.42969 10.3477L7.77344 12.6914L13.6328 6.83203L20 13.1133Z" fill="#FDBF00"/>
<path d="M20 6.90234L13.6328 13.1836L10.1172 9.668V8.00388L13.6328 11.5195L19.1797 6.08203L20 6.90234Z" fill="#00C8C8"/>
<path d="M20 13.1133L19.1797 13.9336L13.6328 8.49615L10.1172 12.0118V10.3477L13.6328 6.83203L20 13.1133Z" fill="#FF9100"/>
<path d="M19.1714 17.625V18.7813L17.7184 18.7821L10.1172 18.7851L1.32812 18.7891V0.75H2.5V17.625H19.1714Z" fill="#676E74"/>
<path d="M3.0127 2.37976L1.91406 1.50024L0.732422 2.37976L0 1.46423L1.91406 0L3.74512 1.46423L3.0127 2.37976Z" fill="#676E74"/>
<path d="M19.1714 17.625V18.7813L17.7176 18.7824L17.7184 18.7821L10.1172 18.7851V17.625H19.1714Z" fill="#474F54"/>
<path d="M19.9998 18.2108L18.1205 19.9999L17.292 19.1714L17.7174 18.7823L17.7182 18.782L18.3427 18.2108L17.7565 17.6249L17.292 17.1604L18.1205 16.332L19.9998 18.2108Z" fill="#474F54"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,18 @@
import 'package:equatable/equatable.dart';
class RangeOfAqi extends Equatable {
final double min;
final double avg;
final double max;
final DateTime date;
const RangeOfAqi({
required this.min,
required this.avg,
required this.max,
required this.date,
});
@override
List<Object?> get props => [min, avg, max, date];
}

View File

@ -0,0 +1,42 @@
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:syncrow_web/pages/analytics/models/range_of_aqi.dart';
import 'package:syncrow_web/pages/analytics/params/get_range_of_aqi_param.dart';
import 'package:syncrow_web/pages/analytics/services/range_of_aqi/range_of_aqi_service.dart';
part 'range_of_aqi_event.dart';
part 'range_of_aqi_state.dart';
class RangeOfAqiBloc extends Bloc<RangeOfAqiEvent, RangeOfAqiState> {
RangeOfAqiBloc(this._rangeOfAqiService) : super(const RangeOfAqiState()) {
on<LoadRangeOfAqiEvent>(_onLoadRangeOfAqiEvent);
on<ClearRangeOfAqiEvent>(_onClearRangeOfAqiEvent);
}
final RangeOfAqiService _rangeOfAqiService;
Future<void> _onLoadRangeOfAqiEvent(
LoadRangeOfAqiEvent event,
Emitter<RangeOfAqiState> emit,
) async {
emit(
RangeOfAqiState(
status: RangeOfAqiStatus.loading,
rangeOfAqi: state.rangeOfAqi,
),
);
try {
final rangeOfAqi = await _rangeOfAqiService.load(event.param);
emit(RangeOfAqiState(status: RangeOfAqiStatus.loaded, rangeOfAqi: rangeOfAqi));
} catch (e) {
emit(RangeOfAqiState(status: RangeOfAqiStatus.failure, errorMessage: '$e'));
}
}
void _onClearRangeOfAqiEvent(
ClearRangeOfAqiEvent event,
Emitter<RangeOfAqiState> emit,
) {
emit(const RangeOfAqiState());
}
}

View File

@ -0,0 +1,21 @@
part of 'range_of_aqi_bloc.dart';
sealed class RangeOfAqiEvent extends Equatable {
const RangeOfAqiEvent();
@override
List<Object> get props => [];
}
class LoadRangeOfAqiEvent extends RangeOfAqiEvent {
const LoadRangeOfAqiEvent(this.param);
final GetRangeOfAqiParam param;
@override
List<Object> get props => [param];
}
class ClearRangeOfAqiEvent extends RangeOfAqiEvent {
const ClearRangeOfAqiEvent();
}

View File

@ -0,0 +1,18 @@
part of 'range_of_aqi_bloc.dart';
enum RangeOfAqiStatus { initial, loading, loaded, failure }
final class RangeOfAqiState extends Equatable {
const RangeOfAqiState({
this.rangeOfAqi = const [],
this.status = RangeOfAqiStatus.initial,
this.errorMessage,
});
final RangeOfAqiStatus status;
final List<RangeOfAqi> rangeOfAqi;
final String? errorMessage;
@override
List<Object?> get props => [status, rangeOfAqi, errorMessage];
}

View File

@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/blocs/range_of_aqi/range_of_aqi_bloc.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/widgets/aqi_type_dropdown.dart';
import 'package:syncrow_web/pages/analytics/modules/analytics/blocs/analytics_date_picker_bloc/analytics_date_picker_bloc.dart';
import 'package:syncrow_web/pages/analytics/modules/analytics/blocs/analytics_devices/analytics_devices_bloc.dart';
import 'package:syncrow_web/pages/analytics/modules/energy_management/blocs/realtime_device_changes/realtime_device_changes_bloc.dart';
import 'package:syncrow_web/pages/analytics/params/get_analytics_devices_param.dart';
import 'package:syncrow_web/pages/analytics/params/get_range_of_aqi_param.dart';
abstract final class FetchAirQualityDataHelper {
const FetchAirQualityDataHelper._();
static void loadAirQualityData(
BuildContext context, {
required String communityUuid,
required String spaceUuid,
}) {
final date = context.read<AnalyticsDatePickerBloc>().state.monthlyDate;
loadAnalyticsDevices(
context,
communityUuid: communityUuid,
spaceUuid: spaceUuid,
);
loadRangeOfAqi(
context,
spaceUuid: spaceUuid,
date: date,
aqiType: AqiType.aqi,
);
}
static void clearAllData(BuildContext context) {
context.read<AnalyticsDevicesBloc>().add(
const ClearAnalyticsDeviceEvent(),
);
context.read<RealtimeDeviceChangesBloc>().add(
const RealtimeDeviceChangesClosed(),
);
context.read<RangeOfAqiBloc>().add(const ClearRangeOfAqiEvent());
}
static void loadAnalyticsDevices(
BuildContext context, {
required String communityUuid,
required String spaceUuid,
}) {
context.read<AnalyticsDevicesBloc>().add(
LoadAnalyticsDevicesEvent(
param: GetAnalyticsDevicesParam(
communityUuid: communityUuid,
spaceUuid: spaceUuid,
deviceTypes: ['AQI'],
requestType: AnalyticsDeviceRequestType.energyManagement,
),
onSuccess: (device) {
context.read<RealtimeDeviceChangesBloc>()
..add(const RealtimeDeviceChangesClosed())
..add(RealtimeDeviceChangesStarted(device.uuid));
},
),
);
}
static void loadRangeOfAqi(
BuildContext context, {
required String spaceUuid,
required DateTime date,
required AqiType aqiType,
}) {
context.read<RangeOfAqiBloc>().add(
LoadRangeOfAqiEvent(
GetRangeOfAqiParam(
date: date,
spaceUuid: spaceUuid,
aqiType: aqiType,
),
),
);
}
}

View File

@ -0,0 +1,115 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:syncrow_web/pages/analytics/models/range_of_aqi.dart';
import 'package:syncrow_web/pages/analytics/modules/energy_management/helpers/energy_management_charts_helper.dart';
import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart';
abstract final class RangeOfAqiChartsHelper {
const RangeOfAqiChartsHelper._();
static const gradientData = <(Color color, String label)>[
(ColorsManager.goodGreen, 'Good'),
(ColorsManager.moderateYellow, 'Moderate'),
(ColorsManager.poorOrange, 'Poor'),
(ColorsManager.unhealthyRed, 'Unhealthy'),
(ColorsManager.severePink, 'Severe'),
(ColorsManager.hazardousPurple, 'Hazardous'),
];
static FlTitlesData titlesData(BuildContext context, List<RangeOfAqi> data) {
final titlesData = EnergyManagementChartsHelper.titlesData(context);
return titlesData.copyWith(
bottomTitles: titlesData.bottomTitles.copyWith(
sideTitles: titlesData.bottomTitles.sideTitles.copyWith(
getTitlesWidget: (value, meta) => Padding(
padding: const EdgeInsetsDirectional.only(top: 20.0),
child: Text(
data.isNotEmpty ? data[value.toInt()].date.day.toString() : '',
style: context.textTheme.bodySmall?.copyWith(
fontSize: 12,
color: ColorsManager.lightGreyColor,
),
),
),
),
),
leftTitles: titlesData.leftTitles.copyWith(
sideTitles: titlesData.leftTitles.sideTitles.copyWith(
reservedSize: 70,
interval: 50,
maxIncluded: false,
getTitlesWidget: (value, meta) {
final text = value >= 300 ? '301+' : value.toInt().toString();
return Padding(
padding: const EdgeInsetsDirectional.only(end: 12),
child: FittedBox(
alignment: AlignmentDirectional.centerStart,
fit: BoxFit.scaleDown,
child: Text(
text,
style: context.textTheme.bodySmall?.copyWith(
fontSize: 12,
color: ColorsManager.lightGreyColor,
),
),
),
);
},
),
),
);
}
static List<LineTooltipItem?> getTooltipItems(
List<LineBarSpot> touchedSpots,
List<RangeOfAqi> chartData,
) {
return touchedSpots.asMap().entries.map((entry) {
final index = entry.key;
final spot = entry.value;
final label = switch (spot.barIndex) {
0 => 'Max',
1 => 'Avg',
2 => 'Min',
_ => '',
};
final date = DateFormat('dd/MM').format(chartData[spot.x.toInt()].date);
return LineTooltipItem(
index == 0 ? '$date\n' : '',
const TextStyle(
color: ColorsManager.textPrimaryColor,
fontWeight: FontWeight.w600,
fontSize: 12,
),
children: [
TextSpan(text: '$label: ${spot.y.toStringAsFixed(0)}'),
],
);
}).toList();
}
static LineTouchData lineTouchData(
List<RangeOfAqi> chartData,
) {
return LineTouchData(
touchTooltipData: LineTouchTooltipData(
getTooltipColor: (touchTooltipItem) => ColorsManager.whiteColors,
tooltipBorder: const BorderSide(
color: ColorsManager.semiTransparentBlack,
),
tooltipRoundedRadius: 16,
showOnTopOfTheChartBoxArea: false,
tooltipPadding: const EdgeInsets.all(8),
getTooltipItems: (touchedSpots) => RangeOfAqiChartsHelper.getTooltipItems(
touchedSpots,
chartData,
),
),
);
}
}

View File

@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/widgets/air_quality_end_side_widget.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/widgets/range_of_aqi_chart_box.dart';
class AirQualityView extends StatelessWidget {
const AirQualityView({super.key});
static const _padding = EdgeInsetsDirectional.all(32);
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final isMediumOrLess = constraints.maxWidth <= 900;
final height = MediaQuery.sizeOf(context).height;
if (isMediumOrLess) {
return SingleChildScrollView(
padding: _padding,
child: Column(
spacing: 32,
children: [
SizedBox(
height: height * 1.2,
child: const AirQualityEndSideWidget(),
),
SizedBox(height: height * 0.5, child: const RangeOfAqiChartBox()),
SizedBox(height: height * 0.5, child: const Placeholder()),
],
),
);
}
return SingleChildScrollView(
child: Container(
padding: _padding,
height: height,
child: const Column(
children: [
Expanded(
child: Row(
spacing: 32,
children: [
Expanded(
flex: 5,
child: Column(
spacing: 20,
children: [
Expanded(child: RangeOfAqiChartBox()),
Expanded(child: Placeholder()),
],
),
),
Expanded(flex: 2, child: AirQualityEndSideWidget()),
],
),
),
],
),
),
);
},
);
}
}

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_devices/analytics_devices_bloc.dart';
import 'package:syncrow_web/pages/analytics/modules/energy_management/helpers/fetch_energy_management_data_helper.dart';
import 'package:syncrow_web/pages/analytics/modules/energy_management/widgets/analytics_device_dropdown.dart';
import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart';
import 'package:syncrow_web/utils/style.dart';
class AirQualityEndSideWidget extends StatelessWidget {
const AirQualityEndSideWidget({super.key});
@override
Widget build(BuildContext context) {
return Container(
decoration: subSectionContainerDecoration.copyWith(
borderRadius: BorderRadius.circular(30),
),
padding: const EdgeInsetsDirectional.all(32),
child: 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,
),
),
],
),
);
}
Widget _buildHeader(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
flex: 3,
child: FittedBox(
alignment: AlignmentDirectional.centerStart,
child: SelectableText(
'AQI Sensor',
style: context.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
color: ColorsManager.vividBlue.withValues(alpha: 0.6),
fontSize: 18,
),
),
),
),
const Spacer(),
Expanded(
flex: 4,
child: FittedBox(
alignment: AlignmentDirectional.centerEnd,
child: AnalyticsDeviceDropdown(
onChanged: (value) {
context.read<AnalyticsDevicesBloc>().add(
SelectAnalyticsDeviceEvent(value),
);
FetchEnergyManagementDataHelper.loadRealtimeDeviceChanges(
context,
deviceUuid: value.uuid,
);
},
),
),
),
],
);
}
}

View File

@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart';
enum AqiType {
aqi('AQI'),
pm25('PM2.5'),
pm10('PM10'),
hcho('HCHO'),
tvoc('TVOC'),
co2('CO2'),
c6h6('C6H6');
final String value;
const AqiType(this.value);
}
class AqiTypeDropdown extends StatefulWidget {
const AqiTypeDropdown({super.key, required this.onChanged});
final ValueChanged<AqiType?> onChanged;
@override
State<AqiTypeDropdown> createState() => _AqiTypeDropdownState();
}
class _AqiTypeDropdownState extends State<AqiTypeDropdown> {
AqiType? _selectedItem = AqiType.aqi;
void _updateSelectedItem(AqiType? item) => setState(() => _selectedItem = item);
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: ColorsManager.greyColor,
width: 1,
),
),
child: DropdownButton<AqiType?>(
value: _selectedItem,
isDense: true,
borderRadius: BorderRadius.circular(16),
dropdownColor: ColorsManager.whiteColors,
underline: const SizedBox.shrink(),
icon: const RotatedBox(
quarterTurns: 1,
child: Icon(Icons.chevron_right, size: 24),
),
style: _getTextStyle(context),
padding: const EdgeInsetsDirectional.symmetric(
horizontal: 12,
vertical: 2,
),
items: AqiType.values
.map((e) => DropdownMenuItem(value: e, child: Text(e.value)))
.toList(),
onChanged: (value) {
_updateSelectedItem(value);
widget.onChanged(value);
},
),
);
}
TextStyle? _getTextStyle(BuildContext context) {
return context.textTheme.labelSmall?.copyWith(
color: ColorsManager.textPrimaryColor,
fontWeight: FontWeight.w700,
fontSize: 12,
);
}
}

View File

@ -0,0 +1,97 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:syncrow_web/pages/analytics/models/range_of_aqi.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/helpers/range_of_aqi_charts_helper.dart';
import 'package:syncrow_web/pages/analytics/modules/energy_management/helpers/energy_management_charts_helper.dart';
import 'package:syncrow_web/utils/color_manager.dart';
class RangeOfAqiChart extends StatelessWidget {
final List<RangeOfAqi> chartData;
const RangeOfAqiChart({
super.key,
required this.chartData,
});
List<(List<double> values, Color color, Color? dotColor)> get _lines => [
(
chartData.map((e) => e.max).toList(),
ColorsManager.maxPurple,
ColorsManager.maxPurpleDot,
),
(
chartData.map((e) => e.avg).toList(),
Colors.white,
null,
),
(
chartData.map((e) => e.min).toList(),
ColorsManager.minBlue,
ColorsManager.minBlueDot,
),
];
@override
Widget build(BuildContext context) {
return LineChart(
LineChartData(
minY: 0,
maxY: 301,
clipData: const FlClipData.vertical(),
gridData: EnergyManagementChartsHelper.gridData(horizontalInterval: 50),
titlesData: RangeOfAqiChartsHelper.titlesData(context, chartData),
borderData: EnergyManagementChartsHelper.borderData(),
lineTouchData: RangeOfAqiChartsHelper.lineTouchData(chartData),
betweenBarsData: [
BetweenBarsData(
fromIndex: 0,
toIndex: 2,
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
stops: [0.0, 0.2, 0.4, 0.6, 0.8, 1.0],
colors: RangeOfAqiChartsHelper.gradientData.map((e) {
final (color, _) = e;
return color.withValues(alpha: 0.6);
}).toList(),
),
),
],
lineBarsData: _lines.map((e) {
final (values, color, dotColor) = e;
return _buildLine(values: values, color: color, dotColor: dotColor);
}).toList(),
),
duration: Duration.zero,
);
}
FlDotData _buildDotData(Color color) {
return FlDotData(
show: true,
getDotPainter: (_, __, ___, ____) => FlDotCirclePainter(
radius: 2,
color: ColorsManager.whiteColors,
strokeWidth: 2,
strokeColor: color,
),
);
}
LineChartBarData _buildLine({
required List<double> values,
required Color color,
Color? dotColor,
}) {
const invisibleDot = FlDotData(show: false);
return LineChartBarData(
spots: List.generate(values.length, (i) => FlSpot(i.toDouble(), values[i])),
isCurved: true,
color: color,
barWidth: 4,
isStrokeCapRound: true,
dotData: dotColor != null ? _buildDotData(dotColor) : invisibleDot,
belowBarData: BarAreaData(show: false),
);
}
}

View File

@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/blocs/range_of_aqi/range_of_aqi_bloc.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/widgets/range_of_aqi_chart.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/widgets/range_of_aqi_chart_title.dart';
import 'package:syncrow_web/pages/analytics/widgets/analytics_error_widget.dart';
import 'package:syncrow_web/utils/style.dart';
class RangeOfAqiChartBox extends StatelessWidget {
const RangeOfAqiChartBox({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<RangeOfAqiBloc, RangeOfAqiState>(
builder: (context, state) {
return Container(
padding: const EdgeInsetsDirectional.all(30),
decoration: subSectionContainerDecoration.copyWith(
borderRadius: BorderRadius.circular(30),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (state.errorMessage != null) ...[
AnalyticsErrorWidget(state.errorMessage),
const SizedBox(height: 10),
],
RangeOfAqiChartTitle(
isLoading: state.status == RangeOfAqiStatus.loading,
),
const SizedBox(height: 10),
const Divider(),
const SizedBox(height: 20),
Expanded(child: RangeOfAqiChart(chartData: state.rangeOfAqi)),
],
),
);
},
);
}
}

View File

@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/helpers/fetch_air_quality_data_helper.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/widgets/aqi_type_dropdown.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/chart_informative_cell.dart';
import 'package:syncrow_web/pages/analytics/modules/energy_management/widgets/chart_title.dart';
import 'package:syncrow_web/pages/analytics/widgets/charts_loading_widget.dart';
import 'package:syncrow_web/pages/space_tree/bloc/space_tree_bloc.dart';
class RangeOfAqiChartTitle extends StatelessWidget {
const RangeOfAqiChartTitle({required this.isLoading, super.key});
final bool isLoading;
static const List<(Color color, String title, bool hasBorder)> _colors = [
(Color(0xFF962DFF), 'Max', false),
(Color(0xFF93AAFD), 'Min', false),
(Colors.transparent, 'Avg', true),
];
@override
Widget build(BuildContext context) {
return Row(
children: [
ChartsLoadingWidget(isLoading: isLoading),
const Expanded(
flex: 3,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: AlignmentDirectional.centerStart,
child: ChartTitle(title: Text('Range of AQI')),
),
),
const Spacer(flex: 3),
..._colors.map(
(e) {
final (color, title, hasBorder) = e;
return Expanded(
child: IntrinsicHeight(
child: FittedBox(
fit: BoxFit.fitWidth,
alignment: AlignmentDirectional.centerStart,
child: Padding(
padding: const EdgeInsetsDirectional.only(end: 16),
child: ChartInformativeCell(
title: Text(title),
color: color,
hasBorder: hasBorder,
),
),
),
),
);
},
),
const Spacer(),
Expanded(
flex: 2,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: AlignmentDirectional.centerEnd,
child: AqiTypeDropdown(
onChanged: (value) {
final spaceTreeState = context.read<SpaceTreeBloc>().state;
final spaceUuid = spaceTreeState.selectedSpaces.firstOrNull;
if (spaceUuid == null) return;
FetchAirQualityDataHelper.loadRangeOfAqi(
context,
spaceUuid: spaceUuid,
date: context.read<AnalyticsDatePickerBloc>().state.monthlyDate,
aqiType: value ?? AqiType.aqi,
);
},
),
),
),
],
);
}
}

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/views/air_quality_view.dart';
import 'package:syncrow_web/pages/analytics/modules/energy_management/views/analytics_energy_management_view.dart';
import 'package:syncrow_web/pages/analytics/modules/occupancy/views/analytics_occupancy_view.dart';
@ -10,6 +11,10 @@ enum AnalyticsPageTab {
occupancy(
title: 'Occupancy',
child: AnalyticsOccupancyView(),
),
airQuality(
title: 'Air Quality',
child: AirQualityView(),
);
const AnalyticsPageTab({

View File

@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/helpers/fetch_air_quality_data_helper.dart';
import 'package:syncrow_web/pages/analytics/modules/analytics/strategies/analytics_data_loading_strategy.dart';
import 'package:syncrow_web/pages/space_tree/bloc/space_tree_bloc.dart';
import 'package:syncrow_web/pages/space_tree/bloc/space_tree_event.dart';
import 'package:syncrow_web/pages/spaces_management/all_spaces/model/community_model.dart';
import 'package:syncrow_web/pages/spaces_management/all_spaces/model/space_model.dart';
final class AirQualityDataLoadingStrategy implements AnalyticsDataLoadingStrategy {
@override
void onCommunitySelected(
BuildContext context,
CommunityModel community,
List<SpaceModel> spaces,
) {
// Do nothing
}
@override
void onSpaceSelected(
BuildContext context,
CommunityModel community,
SpaceModel space,
) {
final spaceTreeBloc = context.read<SpaceTreeBloc>();
final isSpaceSelected = spaceTreeBloc.state.selectedSpaces.contains(space.uuid);
if (isSpaceSelected) {
clearData(context);
return;
}
spaceTreeBloc
..add(const SpaceTreeClearSelectionEvent())
..add(OnSpaceSelected(community, space.uuid ?? '', []));
FetchAirQualityDataHelper.loadAirQualityData(
context,
communityUuid: community.uuid,
spaceUuid: space.uuid ?? '',
);
}
@override
void onChildSpaceSelected(
BuildContext context,
CommunityModel community,
SpaceModel child,
) {
return onSpaceSelected(context, community, child);
}
@override
void clearData(BuildContext context) {
context.read<SpaceTreeBloc>().add(const SpaceTreeClearSelectionEvent());
FetchAirQualityDataHelper.clearAllData(context);
}
}

View File

@ -1,4 +1,5 @@
import 'package:syncrow_web/pages/analytics/modules/analytics/enums/analytics_page_tab.dart';
import 'package:syncrow_web/pages/analytics/modules/analytics/strategies/air_quality_data_loading_strategy.dart';
import 'package:syncrow_web/pages/analytics/modules/analytics/strategies/analytics_data_loading_strategy.dart';
import 'package:syncrow_web/pages/analytics/modules/analytics/strategies/energy_management_data_loading_strategy.dart';
import 'package:syncrow_web/pages/analytics/modules/analytics/strategies/occupancy_data_loading_strategy.dart';
@ -9,6 +10,7 @@ abstract final class AnalyticsDataLoadingStrategyFactory {
return switch (tab) {
AnalyticsPageTab.energyManagement => EnergyManagementDataLoadingStrategy(),
AnalyticsPageTab.occupancy => OccupancyDataLoadingStrategy(),
AnalyticsPageTab.airQuality => AirQualityDataLoadingStrategy(),
};
}
}

View File

@ -68,9 +68,7 @@ class EnergyManagementDataLoadingStrategy implements AnalyticsDataLoadingStrateg
CommunityModel community,
SpaceModel child,
) {
if (child.children.isNotEmpty) {
return onSpaceSelected(context, community, child);
}
return onSpaceSelected(context, community, child);
}
@override

View File

@ -48,7 +48,7 @@ class OccupancyDataLoadingStrategy implements AnalyticsDataLoadingStrategy {
CommunityModel community,
SpaceModel child,
) {
onSpaceSelected(context, community, child);
return onSpaceSelected(context, community, child);
}
@override

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/blocs/range_of_aqi/range_of_aqi_bloc.dart';
import 'package:syncrow_web/pages/analytics/modules/analytics/blocs/analytics_date_picker_bloc/analytics_date_picker_bloc.dart';
import 'package:syncrow_web/pages/analytics/modules/analytics/blocs/analytics_devices/analytics_devices_bloc.dart';
import 'package:syncrow_web/pages/analytics/modules/analytics/blocs/analytics_tab/analytics_tab_bloc.dart';
@ -20,6 +21,7 @@ import 'package:syncrow_web/pages/analytics/services/energy_consumption_per_devi
import 'package:syncrow_web/pages/analytics/services/occupacy/remote_occupancy_service.dart';
import 'package:syncrow_web/pages/analytics/services/occupancy_heat_map/remote_occupancy_heat_map_service.dart';
import 'package:syncrow_web/pages/analytics/services/power_clamp_info/remote_power_clamp_info_service.dart';
import 'package:syncrow_web/pages/analytics/services/range_of_aqi/fake_range_of_aqi_service.dart';
import 'package:syncrow_web/pages/analytics/services/realtime_device_service/firebase_realtime_device_service.dart';
import 'package:syncrow_web/pages/analytics/services/total_energy_consumption/remote_total_energy_consumption_service.dart';
import 'package:syncrow_web/pages/device_managment/shared/navigate_home_grid_view.dart';
@ -94,6 +96,11 @@ class _AnalyticsPageState extends State<AnalyticsPage> {
),
),
),
BlocProvider(
create: (context) => RangeOfAqiBloc(
FakeRangeOfAqiService(),
),
),
],
child: const AnalyticsPageForm(),
);

View File

@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:syncrow_web/utils/color_manager.dart';
class ChartInformativeCell extends StatelessWidget {
const ChartInformativeCell({
super.key,
required this.title,
required this.color,
this.hasBorder = false,
});
final Widget title;
final Color color;
final bool hasBorder;
@override
Widget build(BuildContext context) {
return Container(
height: MediaQuery.sizeOf(context).height * 0.0385,
padding: const EdgeInsetsDirectional.symmetric(
vertical: 8,
horizontal: 12,
),
decoration: BoxDecoration(
borderRadius: BorderRadiusDirectional.circular(8),
border: Border.all(
color: ColorsManager.greyColor,
width: 1,
),
),
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.center,
child: Row(
spacing: 6,
children: [
Container(
height: 8,
width: 8,
decoration: BoxDecoration(
color: color,
border: Border.all(color: ColorsManager.grayBorder),
shape: BoxShape.circle,
),
),
DefaultTextStyle(
style: const TextStyle(
color: ColorsManager.blackColor,
fontWeight: FontWeight.w400,
fontSize: 14,
),
child: title,
),
],
),
),
);
}
}

View File

@ -38,7 +38,7 @@ abstract final class EnergyManagementChartsHelper {
sideTitles: SideTitles(
showTitles: true,
maxIncluded: false,
minIncluded: true,
minIncluded: false,
interval: leftTitlesInterval,
reservedSize: 110,
getTitlesWidget: (value, meta) => Padding(
@ -50,7 +50,7 @@ abstract final class EnergyManagementChartsHelper {
value.formatNumberToKwh,
style: context.textTheme.bodySmall?.copyWith(
fontSize: 12,
color: ColorsManager.greyColor,
color: ColorsManager.lightGreyColor,
),
),
),
@ -93,12 +93,14 @@ abstract final class EnergyManagementChartsHelper {
);
}
static FlGridData gridData() {
static FlGridData gridData({
double horizontalInterval = 250,
}) {
return FlGridData(
show: true,
drawVerticalLine: false,
drawHorizontalLine: true,
horizontalInterval: 250,
horizontalInterval: horizontalInterval,
getDrawingHorizontalLine: (value) {
return FlLine(
color: ColorsManager.greyColor,

View File

@ -18,6 +18,7 @@ class EnergyConsumptionByPhasesChart extends StatelessWidget {
Widget build(BuildContext context) {
return BarChart(
BarChartData(
gridData: EnergyManagementChartsHelper.gridData().copyWith(
checkToShowHorizontalLine: (value) => true,
horizontalInterval: 250,
@ -170,7 +171,7 @@ class EnergyConsumptionByPhasesChart extends StatelessWidget {
child: Text(
month,
style: context.textTheme.bodySmall?.copyWith(
color: ColorsManager.greyColor,
color: ColorsManager.lightGreyColor,
fontSize: 11,
),
),

View File

@ -19,10 +19,12 @@ class EnergyConsumptionByPhasesChartBox extends StatelessWidget {
decoration: secondarySection,
child: Column(
mainAxisSize: MainAxisSize.min,
spacing: 20,
children: [
AnalyticsErrorWidget(state.errorMessage),
EnergyConsumptionByPhasesTitle(isLoading: state.status == EnergyConsumptionByPhasesStatus.loading,),
EnergyConsumptionByPhasesTitle(
isLoading: state.status == EnergyConsumptionByPhasesStatus.loading,
),
const SizedBox(height: 20),
Expanded(
child: EnergyConsumptionByPhasesChart(
energyData: state.chartData,

View File

@ -12,6 +12,7 @@ class EnergyConsumptionPerDeviceChart extends StatelessWidget {
Widget build(BuildContext context) {
return LineChart(
LineChartData(
clipData: const FlClipData.vertical(),
titlesData: EnergyManagementChartsHelper.titlesData(
context,
leftTitlesInterval: 250,

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:syncrow_web/pages/analytics/models/analytics_device.dart';
import 'package:syncrow_web/pages/analytics/models/device_energy_data_model.dart';
import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/pages/analytics/modules/analytics/widgets/chart_informative_cell.dart';
class EnergyConsumptionPerDeviceDevicesList extends StatelessWidget {
const EnergyConsumptionPerDeviceDevicesList({
@ -42,42 +42,7 @@ class EnergyConsumptionPerDeviceDevicesList extends StatelessWidget {
return Tooltip(
message: '${device.name}\n${device.productDevice?.uuid ?? ''}',
child: Container(
height: MediaQuery.sizeOf(context).height * 0.0365,
padding: const EdgeInsetsDirectional.symmetric(
vertical: 8,
horizontal: 12,
),
decoration: BoxDecoration(
borderRadius: BorderRadiusDirectional.circular(8),
border: Border.all(
color: ColorsManager.greyColor,
width: 1,
),
),
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.center,
child: Row(
spacing: 6,
children: [
CircleAvatar(
radius: 4,
backgroundColor: deviceColor,
),
Text(
device.name,
textAlign: TextAlign.center,
style: const TextStyle(
color: ColorsManager.blackColor,
fontWeight: FontWeight.w400,
fontSize: 14,
),
),
],
),
),
),
child: ChartInformativeCell(title: Text(device.name), color: deviceColor),
);
}
}

View File

@ -69,13 +69,18 @@ class PowerClampEnergyDataWidget extends StatelessWidget {
PowerClampEnergyStatus(
iconPath: Assets.powerActiveIcon,
title: 'Active',
value: _valueFromCode('EnergyConsumed', generalDataPoints),
value: _valueFromCode('ActivePower', generalDataPoints),
unit: 'W',
),
PowerClampEnergyStatus(
iconPath: Assets.voltMeterIcon,
title: 'Current',
value: _valueFromCode('Current', generalDataPoints),
value: _valueFromCode('Current', generalDataPoints)
.replaceAllMapped(
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
(Match m) => '${m[1]}.',
)
.replaceAll('.0', ''),
unit: 'A',
),
PowerClampEnergyStatus(

View File

@ -55,7 +55,7 @@ class PowerClampPhasesDataWidget extends StatelessWidget {
iconPath: Assets.powerActiveIcon,
title: 'Active Power',
value: _valueFromCode(
code: 'ReactivePower$phaseSuffix',
code: 'ActivePower$phaseSuffix',
points: phase?.dataPoints,
),
unit: 'W',
@ -125,7 +125,48 @@ class PowerClampPhasesDataWidget extends StatelessWidget {
(e) => e.code == code,
orElse: () => DataPoint(value: '--'),
);
final value = element?.value;
if (code.contains('Current')) {
return _formatCurrentValue(value?.toString());
}
if (code.contains('PowerFactor')) {
return _formatPowerFactor(value?.toString());
}
if (code.contains('Voltage')) {
return _formatVoltage(value?.toString());
}
return value?.toString() ?? '--';
}
return element?.value.toString() ?? '--';
String _formatCurrentValue(String? value) {
if (value == null) return '--';
String str = value;
if (str.isEmpty || str == '--') return '--';
str = str.replaceAll(RegExp(r'[^0-9]'), '');
if (str.isEmpty) return '--';
if (str.length == 1) return '${str[0]}.0';
return '${str[0]}.${str.substring(1)}';
}
String _formatPowerFactor(String? value) {
if (value == null) return '--';
String str = value;
if (str.isEmpty || str == '--') return '--';
str = str.replaceAll(RegExp(r'[^0-9]'), '');
if (str.isEmpty) return '--';
final intValue = int.tryParse(str);
if (intValue == null) return '--';
final doubleValue = intValue / 100;
return doubleValue.toStringAsFixed(2);
}
String _formatVoltage(String? value) {
if (value == null) return '--';
String str = value;
if (str.isEmpty || str == '--') return '--';
str = str.replaceAll(RegExp(r'[^0-9]'), '');
if (str.isEmpty) return '--';
if (str.length == 1) return '0.${str[0]}';
return '${str.substring(0, str.length - 1)}.${str.substring(str.length - 1)}';
}
}

View File

@ -14,6 +14,7 @@ class TotalEnergyConsumptionChart extends StatelessWidget {
return Expanded(
child: LineChart(
LineChartData(
clipData: const FlClipData.vertical(),
titlesData: EnergyManagementChartsHelper.titlesData(
context,
leftTitlesInterval: 250,
@ -28,6 +29,7 @@ class TotalEnergyConsumptionChart extends StatelessWidget {
),
duration: Duration.zero,
curve: Curves.easeIn,
),
);
}
@ -35,9 +37,6 @@ class TotalEnergyConsumptionChart extends StatelessWidget {
List<LineChartBarData> get _lineBarsData {
return [
LineChartBarData(
preventCurveOvershootingThreshold: 0.1,
curveSmoothness: 0.55,
preventCurveOverShooting: true,
spots: chartData
.asMap()
.entries

View File

@ -0,0 +1,18 @@
import 'package:equatable/equatable.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/widgets/aqi_type_dropdown.dart';
class GetRangeOfAqiParam extends Equatable {
final DateTime date;
final String spaceUuid;
final AqiType aqiType;
const GetRangeOfAqiParam(
{
required this.date,
required this.spaceUuid,
required this.aqiType,
});
@override
List<Object?> get props => [date, spaceUuid];
}

View File

@ -0,0 +1,30 @@
import 'package:syncrow_web/pages/analytics/models/range_of_aqi.dart';
import 'package:syncrow_web/pages/analytics/params/get_range_of_aqi_param.dart';
import 'package:syncrow_web/pages/analytics/services/range_of_aqi/range_of_aqi_service.dart';
class FakeRangeOfAqiService implements RangeOfAqiService {
@override
Future<List<RangeOfAqi>> load(GetRangeOfAqiParam param) async {
return await Future.delayed(const Duration(milliseconds: 800), () {
final random = DateTime.now().millisecondsSinceEpoch;
return List.generate(30, (index) {
final date = DateTime(2025, 5, 1).add(Duration(days: index));
final min = ((random + index * 17) % 200).toDouble();
final avgDelta = ((random + index * 23) % 50).toDouble() + 20;
final maxDelta = ((random + index * 31) % 50).toDouble() + 30;
final avg = (min + avgDelta).clamp(0.0, 301.0);
final max = (avg + maxDelta).clamp(0.0, 301.0);
return RangeOfAqi(
min: min,
avg: avg,
max: max,
date: date,
);
});
});
}
}

View File

@ -0,0 +1,6 @@
import 'package:syncrow_web/pages/analytics/models/range_of_aqi.dart';
import 'package:syncrow_web/pages/analytics/params/get_range_of_aqi_param.dart';
abstract interface class RangeOfAqiService {
Future<List<RangeOfAqi>> load(GetRangeOfAqiParam param);
}

View File

@ -21,6 +21,7 @@ class DynamicTable extends StatefulWidget {
final List<String>? initialSelectedIds;
final int uuidIndex;
final Function(dynamic selectedRows)? onSelectionChanged;
final Function(int rowIndex)? onSettingsPressed;
const DynamicTable({
super.key,
required this.headers,
@ -37,6 +38,7 @@ class DynamicTable extends StatefulWidget {
this.initialSelectedIds,
required this.uuidIndex,
this.onSelectionChanged,
this.onSettingsPressed,
});
@override
@ -48,11 +50,20 @@ class _DynamicTableState extends State<DynamicTable> {
bool _selectAll = false;
final ScrollController _verticalScrollController = ScrollController();
final ScrollController _horizontalScrollController = ScrollController();
late ScrollController _horizontalHeaderScrollController;
late ScrollController _horizontalBodyScrollController;
@override
void initState() {
super.initState();
_initializeSelection();
_horizontalHeaderScrollController = ScrollController();
_horizontalBodyScrollController = ScrollController();
// Synchronize horizontal scrolling
_horizontalBodyScrollController.addListener(() {
_horizontalHeaderScrollController
.jumpTo(_horizontalBodyScrollController.offset);
});
}
@override
@ -102,101 +113,87 @@ class _DynamicTableState extends State<DynamicTable> {
context.read<DeviceManagementBloc>().add(UpdateSelection(_selectedRows));
}
@override
void dispose() {
_horizontalHeaderScrollController.dispose();
_horizontalBodyScrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
decoration: widget.cellDecoration,
child: Scrollbar(
controller: _verticalScrollController,
thumbVisibility: true,
trackVisibility: true,
child: Scrollbar(
controller: _horizontalScrollController,
thumbVisibility: true,
trackVisibility: true,
notificationPredicate: (notif) => notif.depth == 1,
child: SingleChildScrollView(
controller: _verticalScrollController,
child: Column(
children: [
Container(
decoration: widget.headerDecoration ??
const BoxDecoration(color: ColorsManager.boxColor),
child: SingleChildScrollView(
controller: _horizontalScrollController,
scrollDirection: Axis.horizontal,
physics: const NeverScrollableScrollPhysics(),
controller: _horizontalHeaderScrollController,
child: SizedBox(
width: widget.size.width,
child: Column(
child: Row(
children: [
Container(
decoration: widget.headerDecoration ??
const BoxDecoration(
color: ColorsManager.boxColor,
),
child: Row(
children: [
if (widget.withCheckBox) _buildSelectAllCheckbox(),
...List.generate(widget.headers.length, (index) {
return _buildTableHeaderCell(
widget.headers[index], index);
})
//...widget.headers.map((header) => _buildTableHeaderCell(header)),
],
),
),
widget.isEmpty
? SizedBox(
height: widget.size.height * 0.5,
width: widget.size.width,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Column(
children: [
SvgPicture.asset(Assets.emptyTable),
const SizedBox(
height: 15,
),
Text(
widget.tableName == 'AccessManagement'
? 'No Password '
: 'No Devices',
style: Theme.of(context)
.textTheme
.bodySmall!
.copyWith(
color:
ColorsManager.grayColor),
)
],
),
],
),
],
),
)
: Column(
children:
List.generate(widget.data.length, (index) {
final row = widget.data[index];
return Row(
children: [
if (widget.withCheckBox)
_buildRowCheckbox(
index, widget.size.height * 0.08),
...row.map((cell) => _buildTableCell(
cell.toString(),
widget.size.height * 0.08)),
],
);
}),
),
if (widget.withCheckBox) _buildSelectAllCheckbox(),
...List.generate(widget.headers.length, (index) {
return _buildTableHeaderCell(
widget.headers[index], index);
}),
],
),
),
),
),
),
Expanded(
child: Scrollbar(
controller: _verticalScrollController,
thumbVisibility: true,
trackVisibility: true,
child: SingleChildScrollView(
controller: _verticalScrollController,
child: Scrollbar(
controller: _horizontalBodyScrollController,
thumbVisibility: false,
trackVisibility: false,
notificationPredicate: (notif) => notif.depth == 1,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
controller: _horizontalBodyScrollController,
child: SizedBox(
width: widget.size.width,
child: widget.isEmpty
? _buildEmptyState()
: Column(
children:
List.generate(widget.data.length, (rowIndex) {
final row = widget.data[rowIndex];
return Row(
children: [
if (widget.withCheckBox)
_buildRowCheckbox(
rowIndex, widget.size.height * 0.08),
...row.asMap().entries.map((entry) {
return _buildTableCell(
entry.value.toString(),
widget.size.height * 0.08,
rowIndex: rowIndex,
columnIndex: entry.key,
);
}).toList(),
],
);
}),
),
),
),
),
),
),
),
],
),
);
}
@ -218,6 +215,32 @@ class _DynamicTableState extends State<DynamicTable> {
);
}
Widget _buildEmptyState() => Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Column(
children: [
SvgPicture.asset(Assets.emptyTable),
const SizedBox(height: 15),
Text(
widget.tableName == 'AccessManagement'
? 'No Password '
: 'No Devices',
style: Theme.of(context)
.textTheme
.bodySmall!
.copyWith(color: ColorsManager.grayColor),
)
],
),
],
),
],
);
Widget _buildRowCheckbox(int index, double size) {
return Container(
width: 50,
@ -272,13 +295,23 @@ class _DynamicTableState extends State<DynamicTable> {
);
}
Widget _buildTableCell(String content, double size) {
Widget _buildTableCell(
String content,
double size, {
required int rowIndex,
required int columnIndex,
}) {
bool isBatteryLevel = content.endsWith('%');
double? batteryLevel;
if (isBatteryLevel) {
batteryLevel = double.tryParse(content.replaceAll('%', '').trim());
}
bool isSettingsColumn = widget.headers[columnIndex] == 'Settings';
if (isSettingsColumn) {
return _buildSettingsIcon(rowIndex, size);
}
Color? statusColor;
switch (content) {
@ -330,4 +363,23 @@ class _DynamicTableState extends State<DynamicTable> {
),
);
}
Widget _buildSettingsIcon(int rowIndex, double size) {
return Container(
height: size,
width: 120,
padding: const EdgeInsets.all(5.0),
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(color: ColorsManager.boxDivider, width: 1.0),
),
color: Colors.white,
),
alignment: Alignment.center,
child: IconButton(
icon: SvgPicture.asset(Assets.settings),
onPressed: () => widget.onSettingsPressed?.call(rowIndex),
),
);
}
}

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/pc/energy_clamp_functions.dart';
import 'package:syncrow_web/pages/routines/models/water_heater/water_heater_functions.dart';
import 'package:syncrow_web/pages/routines/models/wps/wps_functions.dart';
import 'package:syncrow_web/pages/routines/widgets/routine_dialogs/ceiling_sensor/ceiling_sensor_helper.dart';
@ -248,6 +249,8 @@ SOS
tempIcon = Assets.waterLeakNormal;
} else if (type == DeviceType.NCPS) {
tempIcon = Assets.sensors;
} else if (type == DeviceType.PC) {
tempIcon = Assets.powerClamp;
} else {
tempIcon = Assets.logoHorizontal;
}
@ -393,6 +396,59 @@ SOS
BacklightFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'IF'),
];
case 'PC':
return [
TotalEnergyConsumedStatusFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'IF'),
TotalActivePowerConsumedStatusFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'IF'),
VoltagePhaseSequenceDetectionFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'IF'),
TotalCurrentStatusFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'IF'),
FrequencyStatusFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'IF'),
// Phase A
EnergyConsumedAStatusFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'IF'),
ActivePowerAStatusFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'IF'),
VoltageAStatusFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'IF'),
PowerFactorAStatusFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'IF'),
CurrentAStatusFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'IF'),
// Phase B
EnergyConsumedBStatusFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'IF'),
ActivePowerBStatusFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'IF'),
VoltageBStatusFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'IF'),
CurrentBStatusFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'IF'),
PowerFactorBStatusFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'IF'),
// Phase C
EnergyConsumedCStatusFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'IF'),
ActivePowerCStatusFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'IF'),
VoltageCStatusFunction(
deviceId: uuid ?? '', deviceName: name ?? '', type: 'IF'),
CurrentCStatusFunction(
deviceId: uuid ?? '',
deviceName: name ?? '',
type: 'IF'),
PowerFactorCStatusFunction(
deviceId: uuid ?? '',
deviceName: name ?? '',
type: 'IF'),
];
default:
return [];
@ -526,5 +582,6 @@ SOS
"GD": DeviceType.GarageDoor,
"WL": DeviceType.WaterLeak,
"NCPS": DeviceType.NCPS,
"PC": DeviceType.PC,
};
}

View File

@ -5,7 +5,7 @@ import 'package:syncrow_web/utils/constants/assets.dart';
Future<void> showNameMenu({
required BuildContext context,
Function()? aToZTap,
Function()? zToaTap,
Function()? zToATap,
String? isSelected,
}) async {
final RenderBox overlay =
@ -46,7 +46,7 @@ Future<void> showNameMenu({
),
),
PopupMenuItem(
onTap: zToaTap,
onTap: zToATap,
child: ListTile(
leading: Image.asset(
Assets.ZtoAIcon,

View File

@ -95,7 +95,7 @@ class _TableRow extends StatelessWidget {
],
),
if (!isLast)
Divider(
const Divider(
height: 1,
thickness: 1,
color: ColorsManager.boxDivider,
@ -110,12 +110,14 @@ class DynamicTableScreen extends StatefulWidget {
final List<String> titles;
final List<List<Widget>> rows;
final void Function(int columnIndex)? onFilter;
final double tableSize;
const DynamicTableScreen({
required this.titles,
required this.rows,
required this.onFilter,
Key? key,
required this.tableSize,
}) : super(key: key);
@override
@ -205,7 +207,8 @@ class _DynamicTableScreenState extends State<DynamicTableScreen> {
bottomRight: Radius.circular(15),
),
),
child: Column(
child: ListView(
shrinkWrap: true,
children: [
for (int rowIndex = 0; rowIndex < widget.rows.length; rowIndex++)
_TableRow(
@ -253,7 +256,7 @@ class _DynamicTableScreenState extends State<DynamicTableScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(),
_buildBody(),
Container(height: widget.tableSize - 37, child: _buildBody()),
],
),
),

View File

@ -27,7 +27,8 @@ class UsersPage extends StatelessWidget {
Widget build(BuildContext context) {
final TextEditingController searchController = TextEditingController();
Widget actionButton({bool isActive = false, required String title, Function()? onTap}) {
Widget actionButton(
{bool isActive = false, required String title, Function()? onTap}) {
return InkWell(
onTap: onTap,
child: Padding(
@ -60,7 +61,8 @@ class UsersPage extends StatelessWidget {
: ColorsManager.disabledPink.withOpacity(0.5),
),
child: Padding(
padding: const EdgeInsets.only(left: 10, right: 10, bottom: 5, top: 5),
padding:
const EdgeInsets.only(left: 10, right: 10, bottom: 5, top: 5),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
@ -84,12 +86,15 @@ class UsersPage extends StatelessWidget {
}
Widget changeIconStatus(
{required String userId, required String status, required Function()? onTap}) {
{required String userId,
required String status,
required Function()? onTap}) {
return Center(
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.only(left: 5, right: 5, bottom: 5, top: 5),
padding:
const EdgeInsets.only(left: 5, right: 5, bottom: 5, top: 5),
child: SvgPicture.asset(
status == "invited"
? Assets.invitedIcon
@ -114,8 +119,7 @@ class UsersPage extends StatelessWidget {
padding: const EdgeInsets.all(20),
child: Align(
alignment: Alignment.topCenter,
child: ListView(
shrinkWrap: true,
child: Column(
children: [
Row(
children: [
@ -188,292 +192,325 @@ class UsersPage extends StatelessWidget {
),
],
),
const SizedBox(height: 25),
DynamicTableScreen(
onFilter: (columnIndex) {
if (columnIndex == 0) {
showNameMenu(
context: context,
isSelected: _blocRole.currentSortOrder,
aToZTap: () {
context.read<UserTableBloc>().add(const SortUsersByNameAsc());
},
zToaTap: () {
context.read<UserTableBloc>().add(const SortUsersByNameDesc());
},
);
}
if (columnIndex == 2) {
final Map<String, bool> checkboxStates = {
for (var item in _blocRole.jobTitle)
item: _blocRole.selectedJobTitles.contains(item),
};
final RenderBox overlay =
Overlay.of(context).context.findRenderObject() as RenderBox;
const SizedBox(height: 20),
Container(
height: screenSize.height * 0.65,
child: DynamicTableScreen(
tableSize: screenSize.height * 0.65,
onFilter: (columnIndex) {
if (columnIndex == 0) {
showNameMenu(
context: context,
isSelected: _blocRole.currentSortOrder,
aToZTap: () {
context
.read<UserTableBloc>()
.add(const SortUsersByNameAsc());
},
zToATap: () {
context
.read<UserTableBloc>()
.add(const SortUsersByNameDesc());
},
);
}
if (columnIndex == 2) {
final Map<String, bool> checkboxStates = {
for (var item in _blocRole.jobTitle)
item: _blocRole.selectedJobTitles.contains(item),
};
final RenderBox overlay = Overlay.of(context)
.context
.findRenderObject() as RenderBox;
showPopUpFilterMenu(
position: RelativeRect.fromLTRB(
overlay.size.width / 5.3,
240,
overlay.size.width / 4,
0,
),
list: _blocRole.jobTitle,
context: context,
checkboxStates: checkboxStates,
isSelected: _blocRole.currentSortJopTitle,
onOkPressed: () {
searchController.clear();
_blocRole.add(FilterClearEvent());
final selectedItems = checkboxStates.entries
.where((entry) => entry.value)
.map((entry) => entry.key)
.toList();
Navigator.of(context).pop();
_blocRole.add(FilterUsersByJobEvent(
selectedJob: selectedItems,
sortOrder: _blocRole.currentSortJopTitle,
));
},
onSortAtoZ: (v) {
_blocRole.currentSortJopTitle = v;
},
onSortZtoA: (v) {
_blocRole.currentSortJopTitle = v;
},
);
}
showPopUpFilterMenu(
position: RelativeRect.fromLTRB(
overlay.size.width / 5.3,
240,
overlay.size.width / 4,
0,
),
list: _blocRole.jobTitle,
context: context,
checkboxStates: checkboxStates,
isSelected: _blocRole.currentSortJopTitle,
onOkPressed: () {
searchController.clear();
_blocRole.add(FilterClearEvent());
final selectedItems = checkboxStates.entries
.where((entry) => entry.value)
.map((entry) => entry.key)
.toList();
Navigator.of(context).pop();
_blocRole.add(FilterUsersByJobEvent(
selectedJob: selectedItems,
sortOrder: _blocRole.currentSortJopTitle,
));
},
onSortAtoZ: (v) {
_blocRole.currentSortJopTitle = v;
},
onSortZtoA: (v) {
_blocRole.currentSortJopTitle = v;
},
);
}
if (columnIndex == 3) {
final Map<String, bool> checkboxStates = {
for (var item in _blocRole.roleTypes)
item: _blocRole.selectedRoles.contains(item),
};
final RenderBox overlay =
Overlay.of(context).context.findRenderObject() as RenderBox;
showPopUpFilterMenu(
position: RelativeRect.fromLTRB(
overlay.size.width / 4,
240,
overlay.size.width / 4,
0,
),
list: _blocRole.roleTypes,
context: context,
checkboxStates: checkboxStates,
isSelected: _blocRole.currentSortRole,
onOkPressed: () {
searchController.clear();
_blocRole.add(FilterClearEvent());
final selectedItems = checkboxStates.entries
.where((entry) => entry.value)
.map((entry) => entry.key)
.toList();
Navigator.of(context).pop();
context.read<UserTableBloc>().add(FilterUsersByRoleEvent(
selectedRoles: selectedItems,
sortOrder: _blocRole.currentSortRole));
},
onSortAtoZ: (v) {
_blocRole.currentSortRole = v;
},
onSortZtoA: (v) {
_blocRole.currentSortRole = v;
},
);
}
if (columnIndex == 4) {
showDateFilterMenu(
context: context,
isSelected: _blocRole.currentSortOrder,
aToZTap: () {
context.read<UserTableBloc>().add(const DateNewestToOldestEvent());
},
zToaTap: () {
context.read<UserTableBloc>().add(const DateOldestToNewestEvent());
},
);
}
if (columnIndex == 6) {
final Map<String, bool> checkboxStates = {
for (var item in _blocRole.createdBy)
item: _blocRole.selectedCreatedBy.contains(item),
};
final RenderBox overlay =
Overlay.of(context).context.findRenderObject() as RenderBox;
showPopUpFilterMenu(
position: RelativeRect.fromLTRB(
overlay.size.width / 1,
240,
overlay.size.width / 4,
0,
),
list: _blocRole.createdBy,
context: context,
checkboxStates: checkboxStates,
isSelected: _blocRole.currentSortCreatedBy,
onOkPressed: () {
searchController.clear();
_blocRole.add(FilterClearEvent());
final selectedItems = checkboxStates.entries
.where((entry) => entry.value)
.map((entry) => entry.key)
.toList();
Navigator.of(context).pop();
_blocRole.add(FilterUsersByCreatedEvent(
selectedCreatedBy: selectedItems,
sortOrder: _blocRole.currentSortCreatedBy));
},
onSortAtoZ: (v) {
_blocRole.currentSortCreatedBy = v;
},
onSortZtoA: (v) {
_blocRole.currentSortCreatedBy = v;
},
);
}
if (columnIndex == 7) {
final Map<String, bool> checkboxStates = {
for (var item in _blocRole.status)
item: _blocRole.selectedStatuses.contains(item),
};
if (columnIndex == 3) {
final Map<String, bool> checkboxStates = {
for (var item in _blocRole.roleTypes)
item: _blocRole.selectedRoles.contains(item),
};
final RenderBox overlay = Overlay.of(context)
.context
.findRenderObject() as RenderBox;
showPopUpFilterMenu(
position: RelativeRect.fromLTRB(
overlay.size.width / 4,
240,
overlay.size.width / 4,
0,
),
list: _blocRole.roleTypes,
context: context,
checkboxStates: checkboxStates,
isSelected: _blocRole.currentSortRole,
onOkPressed: () {
searchController.clear();
_blocRole.add(FilterClearEvent());
final selectedItems = checkboxStates.entries
.where((entry) => entry.value)
.map((entry) => entry.key)
.toList();
Navigator.of(context).pop();
context.read<UserTableBloc>().add(
FilterUsersByRoleEvent(
selectedRoles: selectedItems,
sortOrder: _blocRole.currentSortRole));
},
onSortAtoZ: (v) {
_blocRole.currentSortRole = v;
},
onSortZtoA: (v) {
_blocRole.currentSortRole = v;
},
);
}
if (columnIndex == 4) {
showDateFilterMenu(
context: context,
isSelected: _blocRole.currentSortOrder,
aToZTap: () {
context
.read<UserTableBloc>()
.add(const DateNewestToOldestEvent());
},
zToaTap: () {
context
.read<UserTableBloc>()
.add(const DateOldestToNewestEvent());
},
);
}
if (columnIndex == 6) {
final Map<String, bool> checkboxStates = {
for (var item in _blocRole.createdBy)
item: _blocRole.selectedCreatedBy.contains(item),
};
final RenderBox overlay = Overlay.of(context)
.context
.findRenderObject() as RenderBox;
showPopUpFilterMenu(
position: RelativeRect.fromLTRB(
overlay.size.width / 1,
240,
overlay.size.width / 4,
0,
),
list: _blocRole.createdBy,
context: context,
checkboxStates: checkboxStates,
isSelected: _blocRole.currentSortCreatedBy,
onOkPressed: () {
searchController.clear();
_blocRole.add(FilterClearEvent());
final selectedItems = checkboxStates.entries
.where((entry) => entry.value)
.map((entry) => entry.key)
.toList();
Navigator.of(context).pop();
_blocRole.add(FilterUsersByCreatedEvent(
selectedCreatedBy: selectedItems,
sortOrder: _blocRole.currentSortCreatedBy));
},
onSortAtoZ: (v) {
_blocRole.currentSortCreatedBy = v;
},
onSortZtoA: (v) {
_blocRole.currentSortCreatedBy = v;
},
);
}
if (columnIndex == 7) {
final Map<String, bool> checkboxStates = {
for (var item in _blocRole.status)
item: _blocRole.selectedStatuses.contains(item),
};
final RenderBox overlay =
Overlay.of(context).context.findRenderObject() as RenderBox;
showPopUpFilterMenu(
position: RelativeRect.fromLTRB(
overlay.size.width / 0,
240,
overlay.size.width / 5,
0,
final RenderBox overlay = Overlay.of(context)
.context
.findRenderObject() as RenderBox;
showPopUpFilterMenu(
position: RelativeRect.fromLTRB(
overlay.size.width / 0,
240,
overlay.size.width / 5,
0,
),
list: _blocRole.status,
context: context,
checkboxStates: checkboxStates,
isSelected: _blocRole.currentSortStatus,
onOkPressed: () {
searchController.clear();
_blocRole.add(FilterClearEvent());
final selectedItems = checkboxStates.entries
.where((entry) => entry.value)
.map((entry) => entry.key)
.toList();
Navigator.of(context).pop();
_blocRole.add(FilterUsersByDeActevateEvent(
selectedActivate: selectedItems,
sortOrder: _blocRole.currentSortStatus));
},
onSortAtoZ: (v) {
_blocRole.currentSortStatus = v;
},
onSortZtoA: (v) {
_blocRole.currentSortStatus = v;
},
);
}
if (columnIndex == 8) {
showDeActivateFilterMenu(
context: context,
isSelected: _blocRole.currentSortOrderDate,
aToZTap: () {
context
.read<UserTableBloc>()
.add(const DateNewestToOldestEvent());
},
zToaTap: () {
context
.read<UserTableBloc>()
.add(const DateOldestToNewestEvent());
},
);
}
},
titles: const [
"Full Name",
"Email Address",
"Job Title",
"Role",
"Creation Date",
"Creation Time",
"Created By",
"Status",
"De/Activate",
"Action"
],
rows: state.users.map((user) {
return [
Text('${user.firstName} ${user.lastName}'),
Text(user.email),
Text(user.jobTitle),
Text(user.roleType ?? ''),
Text(user.createdDate ?? ''),
Text(user.createdTime ?? ''),
Text(user.invitedBy),
status(
status: user.isEnabled == false
? 'disabled'
: user.status,
),
list: _blocRole.status,
context: context,
checkboxStates: checkboxStates,
isSelected: _blocRole.currentSortStatus,
onOkPressed: () {
searchController.clear();
_blocRole.add(FilterClearEvent());
final selectedItems = checkboxStates.entries
.where((entry) => entry.value)
.map((entry) => entry.key)
.toList();
Navigator.of(context).pop();
_blocRole.add(FilterUsersByDeActevateEvent(
selectedActivate: selectedItems,
sortOrder: _blocRole.currentSortStatus));
},
onSortAtoZ: (v) {
_blocRole.currentSortStatus = v;
},
onSortZtoA: (v) {
_blocRole.currentSortStatus = v;
},
);
}
if (columnIndex == 8) {
showDeActivateFilterMenu(
context: context,
isSelected: _blocRole.currentSortOrderDate,
aToZTap: () {
context.read<UserTableBloc>().add(const DateNewestToOldestEvent());
},
zToaTap: () {
context.read<UserTableBloc>().add(const DateOldestToNewestEvent());
},
);
}
},
titles: const [
"Full Name",
"Email Address",
"Job Title",
"Role",
"Creation Date",
"Creation Time",
"Created By",
"Status",
"De/Activate",
"Action"
],
rows: state.users.map((user) {
return [
Text('${user.firstName} ${user.lastName}'),
Text(user.email),
Text(user.jobTitle),
Text(user.roleType ?? ''),
Text(user.createdDate ?? ''),
Text(user.createdTime ?? ''),
Text(user.invitedBy),
status(
status: user.isEnabled == false ? 'disabled' : user.status,
),
changeIconStatus(
status: user.isEnabled == false ? 'disabled' : user.status,
userId: user.uuid,
onTap: user.status != "invited"
? () {
context.read<UserTableBloc>().add(ChangeUserStatus(
userId: user.uuid,
newStatus:
user.isEnabled == false ? 'disabled' : user.status));
}
: null,
),
Row(
children: [
user.isEnabled != false
? actionButton(
isActive: true,
title: "Edit",
onTap: () {
context.read<SpaceTreeBloc>().add(ClearCachedData());
showDialog(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return EditUserDialog(userId: user.uuid);
},
).then((v) {
if (v != null) {
changeIconStatus(
status: user.isEnabled == false
? 'disabled'
: user.status,
userId: user.uuid,
onTap: user.status != "invited"
? () {
context.read<UserTableBloc>().add(
ChangeUserStatus(
userId: user.uuid,
newStatus: user.isEnabled == false
? 'disabled'
: user.status));
}
: null,
),
Row(
children: [
user.isEnabled != false
? actionButton(
isActive: true,
title: "Edit",
onTap: () {
context
.read<SpaceTreeBloc>()
.add(ClearCachedData());
showDialog(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return EditUserDialog(
userId: user.uuid);
},
).then((v) {
if (v != null) {
_blocRole.add(const GetUsers());
if (v != null) {
_blocRole.add(const GetUsers());
}
}
});
},
)
: actionButton(
title: "Edit",
),
actionButton(
title: "Delete",
onTap: () {
showDialog(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return DeleteUserDialog(
onTapDelete: () async {
try {
_blocRole.add(DeleteUserEvent(
user.uuid, context));
await Future.delayed(
const Duration(seconds: 2));
return true;
} catch (e) {
return false;
}
});
},
)
: actionButton(
title: "Edit",
),
actionButton(
title: "Delete",
onTap: () {
showDialog(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return DeleteUserDialog(onTapDelete: () async {
try {
_blocRole.add(DeleteUserEvent(user.uuid, context));
await Future.delayed(const Duration(seconds: 2));
return true;
} catch (e) {
return false;
}
});
},
).then((v) {
if (v != null) {
_blocRole.add(const GetUsers());
}
});
},
),
],
),
];
}).toList(),
).then((v) {
if (v != null) {
_blocRole.add(const GetUsers());
}
});
},
),
],
),
];
}).toList(),
),
),
Padding(
padding: const EdgeInsets.all(8.0),
@ -486,14 +523,20 @@ class UsersPage extends StatelessWidget {
visiblePagesCount: 4,
buttonRadius: 10,
selectedButtonColor: ColorsManager.secondaryColor,
buttonUnSelectedBorderColor: ColorsManager.grayBorder,
lastPageIcon: const Icon(Icons.keyboard_double_arrow_right),
firstPageIcon: const Icon(Icons.keyboard_double_arrow_left),
totalPages:
(_blocRole.totalUsersCount.length / _blocRole.itemsPerPage).ceil(),
buttonUnSelectedBorderColor:
ColorsManager.grayBorder,
lastPageIcon:
const Icon(Icons.keyboard_double_arrow_right),
firstPageIcon:
const Icon(Icons.keyboard_double_arrow_left),
totalPages: (_blocRole.totalUsersCount.length /
_blocRole.itemsPerPage)
.ceil(),
currentPage: _blocRole.currentPage,
onPageChanged: (int pageNumber) {
context.read<UserTableBloc>().add(ChangePage(pageNumber));
context
.read<UserTableBloc>()
.add(ChangePage(pageNumber));
},
),
),

View File

@ -7,6 +7,7 @@ import 'package:syncrow_web/pages/routines/widgets/routine_dialogs/ceiling_senso
import 'package:syncrow_web/pages/routines/widgets/routine_dialogs/flush_presence_sensor/flush_presence_sensor.dart';
import 'package:syncrow_web/pages/routines/widgets/routine_dialogs/gateway/gateway_helper.dart';
import 'package:syncrow_web/pages/routines/widgets/routine_dialogs/one_gang_switch_dialog.dart';
import 'package:syncrow_web/pages/routines/widgets/routine_dialogs/power_clamp_enargy/energy_clamp_dialog.dart';
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';
@ -137,6 +138,16 @@ class DeviceDialogHelper {
device: data['device'],
);
case 'PC':
return EnergyClampDialog.showEnergyClampFunctionsDialog(
context: context,
functions: functions,
uniqueCustomId: data['uniqueCustomId'],
deviceSelectedFunctions: deviceSelectedFunctions,
dialogType: dialogType,
device: data['device'],
);
default:
return null;
}

View File

@ -9,7 +9,6 @@ abstract class DeviceFunction<T> {
final double? max;
final double? min;
DeviceFunction({
required this.deviceId,
required this.deviceName,
@ -114,4 +113,28 @@ class DeviceFunctionData {
max.hashCode ^
min.hashCode;
}
DeviceFunctionData copyWith({
String? entityId,
String? functionCode,
String? operationName,
String? condition,
dynamic value,
double? step,
String? unit,
double? max,
double? min,
}) {
return DeviceFunctionData(
entityId: entityId ?? this.entityId,
functionCode: functionCode ?? this.functionCode,
operationName: operationName ?? this.operationName,
condition: condition ?? this.condition,
value: value ?? this.value,
step: step ?? this.step,
unit: unit ?? this.unit,
max: max ?? this.max,
min: min ?? this.min,
);
}
}

View File

@ -0,0 +1,416 @@
import 'package:syncrow_web/pages/device_managment/power_clamp/models/power_clamp_batch_model.dart';
import 'package:syncrow_web/pages/routines/models/device_functions.dart';
import 'package:syncrow_web/pages/routines/models/pc/enrgy_clamp_operational_value.dart';
import 'package:syncrow_web/utils/constants/assets.dart';
abstract class EnergyClampFunctions extends DeviceFunction<PowerClampModel1> {
final String type;
EnergyClampFunctions({
required super.deviceId,
required super.deviceName,
required super.code,
required super.operationName,
required super.icon,
required this.type,
super.step,
super.unit,
super.max,
super.min,
});
List<EnergyClampOperationalValue> getOperationalValues();
}
// General & shared
class TotalEnergyConsumedStatusFunction extends EnergyClampFunctions {
TotalEnergyConsumedStatusFunction({
required super.deviceId,
required super.deviceName,
required super.type,
}) : super(
code: 'EnergyConsumed',
operationName: 'Total Energy Consumed',
icon: Assets.energyConsumedIcon,
min: 0.00,
max: 20000000.00,
step: 1,
unit: "kWh",
);
@override
List<EnergyClampOperationalValue> getOperationalValues() => [];
}
class TotalActivePowerConsumedStatusFunction extends EnergyClampFunctions {
TotalActivePowerConsumedStatusFunction({
required super.deviceId,
required super.deviceName,
required super.type,
}) : super(
code: 'ActivePower',
operationName: 'Total Active Power',
icon: Assets.powerActiveIcon,
min: -19800000,
max: 19800000,
step: 0.1,
unit: "kW",
);
@override
List<EnergyClampOperationalValue> getOperationalValues() => [];
}
class VoltagePhaseSequenceDetectionFunction extends EnergyClampFunctions {
VoltagePhaseSequenceDetectionFunction({
required super.deviceId,
required super.deviceName,
required super.type,
}) : super(
code: 'voltage_phase_seq',
operationName: 'Voltage phase sequence detection',
icon: Assets.voltageIcon,
);
@override
List<EnergyClampOperationalValue> getOperationalValues() => [
EnergyClampOperationalValue(
icon: Assets.voltageIcon, description: '0', value: '0'),
EnergyClampOperationalValue(
icon: Assets.voltageIcon, description: '1', value: '1'),
EnergyClampOperationalValue(
icon: Assets.voltageIcon, description: '2', value: '2'),
EnergyClampOperationalValue(
icon: Assets.voltageIcon, description: '3', value: '3'),
EnergyClampOperationalValue(
icon: Assets.voltageIcon, description: '4', value: '4'),
EnergyClampOperationalValue(
icon: Assets.voltageIcon, description: '5', value: '5'),
];
}
class TotalCurrentStatusFunction extends EnergyClampFunctions {
TotalCurrentStatusFunction({
required super.deviceId,
required super.deviceName,
required super.type,
}) : super(
code: 'Current',
operationName: 'Total Current',
icon: Assets.voltMeterIcon,
min: 0.000,
max: 9000.000,
step: 1,
unit: "A",
);
@override
List<EnergyClampOperationalValue> getOperationalValues() => [];
}
class FrequencyStatusFunction extends EnergyClampFunctions {
FrequencyStatusFunction({
required super.deviceId,
required super.deviceName,
required super.type,
}) : super(
code: 'Frequency',
operationName: 'Frequency',
icon: Assets.frequencyIcon,
min: 0,
max: 80,
step: 1,
unit: "Hz",
);
@override
List<EnergyClampOperationalValue> getOperationalValues() => [];
}
// Phase A
class EnergyConsumedAStatusFunction extends EnergyClampFunctions {
EnergyConsumedAStatusFunction({
required super.deviceId,
required super.deviceName,
required super.type,
}) : super(
code: 'EnergyConsumedA',
operationName: 'Energy Consumed A',
icon: Assets.energyConsumedIcon,
min: 0.00,
max: 20000000.00,
step: 1,
unit: "kWh",
);
@override
List<EnergyClampOperationalValue> getOperationalValues() => [];
}
class ActivePowerAStatusFunction extends EnergyClampFunctions {
ActivePowerAStatusFunction({
required super.deviceId,
required super.deviceName,
required super.type,
}) : super(
code: 'ActivePowerA',
operationName: 'Active Power A',
icon: Assets.powerActiveIcon,
min: 200,
max: 300,
step: 1,
unit: "kW",
);
@override
List<EnergyClampOperationalValue> getOperationalValues() => [];
}
class VoltageAStatusFunction extends EnergyClampFunctions {
VoltageAStatusFunction({
required super.deviceId,
required super.deviceName,
required super.type,
}) : super(
code: 'VoltageA',
operationName: 'Voltage A',
icon: Assets.voltageIcon,
min: 0.0,
max: 500,
step: 1,
unit: "V",
);
@override
List<EnergyClampOperationalValue> getOperationalValues() => [];
}
class PowerFactorAStatusFunction extends EnergyClampFunctions {
PowerFactorAStatusFunction({
required super.deviceId,
required super.deviceName,
required super.type,
}) : super(
code: 'PowerFactorA',
operationName: 'Power Factor A',
icon: Assets.speedoMeter,
min: 0.00,
max: 1.00,
step: 0.1,
unit: "",
);
@override
List<EnergyClampOperationalValue> getOperationalValues() => [];
}
class CurrentAStatusFunction extends EnergyClampFunctions {
CurrentAStatusFunction({
required super.deviceId,
required super.deviceName,
required super.type,
}) : super(
code: 'CurrentA',
operationName: 'Current A',
icon: Assets.voltMeterIcon,
min: 0.000,
max: 3000.000,
step: 1,
unit: "A",
);
@override
List<EnergyClampOperationalValue> getOperationalValues() => [];
}
// Phase B
class EnergyConsumedBStatusFunction extends EnergyClampFunctions {
EnergyConsumedBStatusFunction({
required super.deviceId,
required super.deviceName,
required super.type,
}) : super(
code: 'EnergyConsumedB',
operationName: 'Energy Consumed B',
icon: Assets.energyConsumedIcon,
min: 0.00,
max: 20000000.00,
step: 1,
unit: "kWh",
);
@override
List<EnergyClampOperationalValue> getOperationalValues() => [];
}
class ActivePowerBStatusFunction extends EnergyClampFunctions {
ActivePowerBStatusFunction({
required super.deviceId,
required super.deviceName,
required super.type,
}) : super(
code: 'ActivePowerB',
operationName: 'Active Power B',
icon: Assets.powerActiveIcon,
min: -6600000,
max: 6600000,
step: 1,
unit: "kW",
);
@override
List<EnergyClampOperationalValue> getOperationalValues() => [];
}
class VoltageBStatusFunction extends EnergyClampFunctions {
VoltageBStatusFunction({
required super.deviceId,
required super.deviceName,
required super.type,
}) : super(
code: 'VoltageB',
operationName: 'Voltage B',
icon: Assets.voltageIcon,
min: 0.0,
max: 500,
step: 1,
unit: "V",
);
@override
List<EnergyClampOperationalValue> getOperationalValues() => [];
}
class CurrentBStatusFunction extends EnergyClampFunctions {
CurrentBStatusFunction({
required super.deviceId,
required super.deviceName,
required super.type,
}) : super(
code: 'CurrentB',
operationName: 'Current B',
icon: Assets.voltMeterIcon,
min: 0.000,
max: 3000.000,
step: 1,
unit: "A",
);
@override
List<EnergyClampOperationalValue> getOperationalValues() => [];
}
class PowerFactorBStatusFunction extends EnergyClampFunctions {
PowerFactorBStatusFunction({
required super.deviceId,
required super.deviceName,
required super.type,
}) : super(
code: 'PowerFactorB',
operationName: 'Power Factor B',
icon: Assets.speedoMeter,
min: 0.0,
max: 1.0,
step: 0.1,
unit: "",
);
@override
List<EnergyClampOperationalValue> getOperationalValues() => [];
}
// Phase C
class EnergyConsumedCStatusFunction extends EnergyClampFunctions {
EnergyConsumedCStatusFunction({
required super.deviceId,
required super.deviceName,
required super.type,
}) : super(
code: 'EnergyConsumedC',
operationName: 'Energy Consumed C',
icon: Assets.energyConsumedIcon,
min: 0.00,
max: 20000000.00,
step: 1,
unit: "kWh",
);
@override
List<EnergyClampOperationalValue> getOperationalValues() => [];
}
class ActivePowerCStatusFunction extends EnergyClampFunctions {
ActivePowerCStatusFunction({
required super.deviceId,
required super.deviceName,
required super.type,
}) : super(
code: 'ActivePowerC',
operationName: 'Active Power C',
icon: Assets.powerActiveIcon,
min: -6600000,
max: 6600000,
step: 1,
unit: "kW",
);
@override
List<EnergyClampOperationalValue> getOperationalValues() => [];
}
class VoltageCStatusFunction extends EnergyClampFunctions {
VoltageCStatusFunction({
required super.deviceId,
required super.deviceName,
required super.type,
}) : super(
code: 'VoltageC',
operationName: 'Voltage C',
icon: Assets.voltageIcon,
min: 0.00,
max: 500,
step: 0.1,
unit: "V",
);
@override
List<EnergyClampOperationalValue> getOperationalValues() => [];
}
class CurrentCStatusFunction extends EnergyClampFunctions {
CurrentCStatusFunction({
required super.deviceId,
required super.deviceName,
required super.type,
}) : super(
code: 'CurrentC',
operationName: 'Current C',
icon: Assets.voltMeterIcon,
min: 0.000,
max: 3000.000,
step: 0.1,
unit: "A",
);
@override
List<EnergyClampOperationalValue> getOperationalValues() => [];
}
class PowerFactorCStatusFunction extends EnergyClampFunctions {
PowerFactorCStatusFunction({
required super.deviceId,
required super.deviceName,
required super.type,
}) : super(
code: 'PowerFactorC',
operationName: 'Power Factor C',
icon: Assets.speedoMeter,
min: 0.00,
max: 1.00,
step: 0.1,
unit: "",
);
@override
List<EnergyClampOperationalValue> getOperationalValues() => [];
}

View File

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

View File

@ -12,22 +12,53 @@ class ConditionToggle extends StatelessWidget {
});
static const _conditions = ["<", "==", ">"];
static const _icons = [
Icons.chevron_left,
Icons.drag_handle,
Icons.chevron_right
];
@override
Widget build(BuildContext context) {
return ToggleButtons(
onPressed: (index) => onChanged(_conditions[index]),
borderRadius: const BorderRadius.all(Radius.circular(8)),
selectedBorderColor: ColorsManager.primaryColorWithOpacity,
selectedColor: Colors.white,
fillColor: ColorsManager.primaryColorWithOpacity,
color: ColorsManager.primaryColorWithOpacity,
constraints: const BoxConstraints(
minHeight: 40.0,
minWidth: 40.0,
final selectedIndex = _conditions.indexOf(currentCondition ?? "==");
return Container(
height: 30,
width: MediaQuery.of(context).size.width * 0.1,
decoration: BoxDecoration(
color: ColorsManager.softGray.withOpacity(0.5),
borderRadius: BorderRadius.circular(50),
),
clipBehavior: Clip.antiAlias,
child: Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(_conditions.length, (index) {
final isSelected = index == selectedIndex;
return Expanded(
child: InkWell(
onTap: () => onChanged(_conditions[index]),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.ease,
decoration: BoxDecoration(
color:
isSelected ? ColorsManager.vividBlue : Colors.transparent,
),
child: Center(
child: Icon(
_icons[index],
size: 20,
color: isSelected
? ColorsManager.whiteColors
: ColorsManager.blackColor,
weight: isSelected ? 700 : 500,
),
),
),
),
);
}),
),
isSelected: _conditions.map((c) => c == (currentCondition ?? "==")).toList(),
children: _conditions.map((c) => Text(c)).toList(),
);
}
}

View File

@ -40,6 +40,7 @@ class CustomRoutinesTextbox extends StatefulWidget {
class _CustomRoutinesTextboxState extends State<CustomRoutinesTextbox> {
late final TextEditingController _controller;
bool hasError = false;
String? errorMessage;
@ -55,29 +56,63 @@ class _CustomRoutinesTextboxState extends State<CustomRoutinesTextbox> {
}
}
bool _isInitialized = false;
@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;
_initializeController();
}
void _initializeController() {
final decimalPlaces = getDecimalPlaces(widget.stepIncreaseAmount);
final dynamic initialValue = widget.initialValue;
double parsedValue;
if (initialValue is num) {
parsedValue = initialValue.toDouble();
} else if (initialValue is String) {
parsedValue = double.tryParse(initialValue) ?? widget.sliderRange.$1;
} else {
initialValue = double.tryParse(widget.displayedValue) ?? 0.0;
parsedValue = widget.sliderRange.$1;
}
_controller = TextEditingController(
text: initialValue.toStringAsFixed(decimalPlaces),
text: parsedValue.toStringAsFixed(decimalPlaces),
);
_isInitialized = true;
}
@override
void dispose() {
_controller.dispose();
super.dispose();
void didUpdateWidget(CustomRoutinesTextbox oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.initialValue != oldWidget.initialValue && _isInitialized) {
final decimalPlaces = getDecimalPlaces(widget.stepIncreaseAmount);
final dynamic initialValue = widget.initialValue;
double newValue;
if (initialValue is num) {
newValue = initialValue.toDouble();
} else if (initialValue is String) {
newValue = double.tryParse(initialValue) ?? widget.sliderRange.$1;
} else {
newValue = widget.sliderRange.$1;
}
final newValueText = newValue.toStringAsFixed(decimalPlaces);
if (_controller.text != newValueText) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_controller.text = newValueText;
_controller.selection =
TextSelection.collapsed(offset: _controller.text.length);
});
}
}
}
void _validateInput(String value) {
final doubleValue = double.tryParse(value);
if (doubleValue == null) {
@ -121,18 +156,6 @@ class _CustomRoutinesTextboxState extends State<CustomRoutinesTextbox> {
}
}
@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;
@ -227,9 +250,15 @@ class _CustomRoutinesTextboxState extends State<CustomRoutinesTextbox> {
color: ColorsManager.blackColor,
),
keyboardType: TextInputType.number,
inputFormatters: widget.withSpecialChar == true
? [FilteringTextInputFormatter.digitsOnly]
: null,
inputFormatters: [
FilteringTextInputFormatter.allow(
widget.withSpecialChar
? RegExp(r'^-?\d*\.?\d{0,' +
decimalPlaces.toString() +
r'}$')
: RegExp(r'\d+'),
),
],
decoration: const InputDecoration(
border: InputBorder.none,
isDense: true,
@ -268,8 +297,9 @@ class _CustomRoutinesTextboxState extends State<CustomRoutinesTextbox> {
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
child: Wrap(
alignment: WrapAlignment.spaceBetween,
direction: Axis.horizontal,
children: [
Text(
'Min. ${widget.sliderRange.$1.toInt()}${widget.unit}',
@ -279,6 +309,9 @@ class _CustomRoutinesTextboxState extends State<CustomRoutinesTextbox> {
fontWeight: FontWeight.w400,
),
),
const SizedBox(
width: 50,
),
Text(
'Max. ${widget.sliderRange.$2.toInt()}${widget.unit}',
style: context.textTheme.bodySmall?.copyWith(

View File

@ -78,9 +78,9 @@ class IfContainer extends StatelessWidget {
'CPS',
'NCPS',
'WH',
'PC',
].contains(state.ifItems[index]
['productType'])) {
context.read<RoutineBloc>().add(
AddToIfContainer(
state.ifItems[index], false));
@ -137,8 +137,18 @@ class IfContainer extends StatelessWidget {
context
.read<RoutineBloc>()
.add(AddToIfContainer(mutableData, false));
} else if (!['AC', '1G', '2G', '3G', 'WPS', 'GW', 'CPS', 'NCPS','WH']
.contains(mutableData['productType'])) {
} else if (![
'AC',
'1G',
'2G',
'3G',
'WPS',
'GW',
'CPS',
'NCPS',
'WH',
'PC',
].contains(mutableData['productType'])) {
context
.read<RoutineBloc>()
.add(AddToIfContainer(mutableData, false));

View File

@ -27,6 +27,7 @@ class _RoutineDevicesState extends State<RoutineDevices> {
'CPS',
'NCPS',
'WH',
'PC',
};
@override

View File

@ -74,25 +74,24 @@ class ACHelper {
SizedBox(
width: selectedFunction != null ? 320 : 360,
child: _buildFunctionsList(
context: context,
acFunctions: acFunctions,
device: device,
onFunctionSelected:
(functionCode, operationName) {
RoutineTapFunctionHelper.onTapFunction(
context,
functionCode: functionCode,
functionOperationName: operationName,
functionValueDescription:
selectedFunctionData.valueDescription,
deviceUuid: device?.uuid,
codesToAddIntoFunctionsWithDefaultValue: [
'temp_set',
'temp_current',
],
defaultValue: 0);
},
),
context: context,
acFunctions: acFunctions,
onFunctionSelected:
(functionCode, operationName) {
RoutineTapFunctionHelper.onTapFunction(
context,
functionCode: functionCode,
functionOperationName: operationName,
functionValueDescription:
selectedFunctionData
.valueDescription,
deviceUuid: device?.uuid,
codesToAddIntoFunctionsWithDefaultValue: [
'temp_set',
'temp_current',
],
defaultValue: 0);
}),
),
// Value selector
if (selectedFunction != null)
@ -150,7 +149,6 @@ class ACHelper {
required BuildContext context,
required List<ACFunction> acFunctions,
required Function(String, String) onFunctionSelected,
required AllDevicesModel? device,
}) {
return ListView.separated(
shrinkWrap: false,
@ -193,7 +191,6 @@ class ACHelper {
);
}
/// Build value selector for AC functions dialog
static Widget _buildValueSelector({
required BuildContext context,
required String selectedFunction,
@ -207,19 +204,19 @@ class ACHelper {
acFunctions.firstWhere((f) => f.code == selectedFunction);
if (selectedFunction == 'temp_set' || selectedFunction == 'temp_current') {
// Convert stored integer value to display value
final displayValue =
(selectedFunctionData?.value ?? selectedFn.min ?? 0) / 10;
(selectedFunctionData?.value ?? selectedFn.min!) / 10;
final minValue = selectedFn.min! / 10;
final maxValue = selectedFn.max! / 10;
return CustomRoutinesTextbox(
withSpecialChar: true,
dividendOfRange: maxValue,
currentCondition: selectedFunctionData?.condition,
dialogType: selectedFn.type,
sliderRange: (minValue, maxValue),
displayedValue: displayValue.toStringAsFixed(1),
initialValue: displayValue.toDouble(),
displayedValue: displayValue.toString(),
initialValue: displayValue,
unit: selectedFn.unit!,
onConditionChanged: (condition) => context.read<FunctionBloc>().add(
AddFunction(
@ -228,7 +225,7 @@ class ACHelper {
functionCode: selectedFunction,
operationName: selectedFn.operationName,
condition: condition,
value: 0,
value: (displayValue * 10).round(),
step: selectedFn.step,
unit: selectedFn.unit,
max: selectedFn.max,
@ -236,28 +233,33 @@ class ACHelper {
),
),
),
onTextChanged: (value) => context.read<FunctionBloc>().add(
AddFunction(
functionData: DeviceFunctionData(
entityId: device?.uuid ?? '',
functionCode: selectedFunction,
operationName: selectedFn.operationName,
value: (value * 10).round(), // Store as integer
condition: selectedFunctionData?.condition,
step: selectedFn.step,
unit: selectedFn.unit,
max: selectedFn.max,
min: selectedFn.min,
onTextChanged: (value) {
final numericValue = double.tryParse(value.toString()) ?? minValue;
context.read<FunctionBloc>().add(
AddFunction(
functionData: DeviceFunctionData(
entityId: device?.uuid ?? '',
functionCode: selectedFunction,
operationName: selectedFn.operationName,
value: (numericValue * 10).round(),
condition: selectedFunctionData?.condition,
step: selectedFn.step,
unit: selectedFn.unit,
max: selectedFn.max,
min: selectedFn.min,
),
),
),
),
stepIncreaseAmount: selectedFn.step! / 10, // Convert step for display
);
},
stepIncreaseAmount: selectedFn.step! / 10,
);
}
// Rest of your existing code for other value selectors
final values = selectedFn.getOperationalValues();
return _buildOperationalValuesList(
context: context,
values: selectedFn.getOperationalValues(),
values: values,
selectedValue: selectedFunctionData?.value,
device: device,
operationName: operationName,
@ -311,7 +313,7 @@ class ACHelper {
// );
// }
// /// Build condition toggle for AC functions dialog
/// Build condition toggle for AC functions dialog
// static Widget _buildConditionToggle(
// BuildContext context,
// String? currentCondition,

View File

@ -6,7 +6,6 @@ import 'package:syncrow_web/pages/routines/models/ceiling_presence_sensor_functi
import 'package:syncrow_web/pages/routines/models/device_functions.dart';
import 'package:syncrow_web/pages/routines/widgets/custom_routines_textbox.dart';
import 'package:syncrow_web/pages/routines/widgets/routine_dialogs/ceiling_sensor/cps_slider_helpers.dart';
import 'package:syncrow_web/pages/routines/widgets/slider_value_selector.dart';
class CpsDialogSliderSelector extends StatelessWidget {
const CpsDialogSliderSelector({
@ -33,7 +32,7 @@ class CpsDialogSliderSelector extends StatelessWidget {
@override
Widget build(BuildContext context) {
return CustomRoutinesTextbox(
withSpecialChar: false,
withSpecialChar: true,
currentCondition: selectedFunctionData.condition,
dialogType: dialogType,
sliderRange:

View File

@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:syncrow_web/pages/device_managment/all_devices/models/devices_model.dart';
import 'package:syncrow_web/pages/routines/bloc/functions_bloc/functions_bloc_bloc.dart';
import 'package:syncrow_web/pages/routines/models/device_functions.dart';
import 'package:syncrow_web/pages/routines/models/pc/enrgy_clamp_operational_value.dart';
class EnergyOperationalValuesList extends StatelessWidget {
final List<EnergyClampOperationalValue> values;
final dynamic selectedValue;
final AllDevicesModel? device;
final String operationName;
final String selectCode;
const EnergyOperationalValuesList({
required this.values,
required this.selectedValue,
required this.device,
required this.operationName,
required this.selectCode,
super.key,
});
@override
Widget build(BuildContext context) {
return ListView.builder(
padding: const EdgeInsets.all(20),
itemCount: values.length,
itemBuilder: (context, index) => _buildValueItem(context, values[index]),
);
}
Widget _buildValueItem(
BuildContext context, EnergyClampOperationalValue value) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildValueIcon(context, value),
Expanded(child: _buildValueDescription(value)),
_buildValueRadio(context, value),
],
),
);
}
Widget _buildValueIcon(context, EnergyClampOperationalValue value) {
return Column(
children: [
SvgPicture.asset(value.icon, width: 25, height: 25),
],
);
}
Widget _buildValueDescription(EnergyClampOperationalValue value) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Text(value.description),
);
}
Widget _buildValueRadio(context, EnergyClampOperationalValue value) {
return Radio<dynamic>(
value: value.value,
groupValue: selectedValue,
onChanged: (_) => _selectValue(context, value.value),
);
}
void _selectValue(BuildContext context, dynamic value) {
context.read<FunctionBloc>().add(
AddFunction(
functionData: DeviceFunctionData(
entityId: device?.uuid ?? '',
functionCode: selectCode,
operationName: operationName,
value: value,
),
),
);
}
}

View File

@ -0,0 +1,246 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:syncrow_web/pages/device_managment/all_devices/models/devices_model.dart';
import 'package:syncrow_web/pages/routines/bloc/functions_bloc/functions_bloc_bloc.dart';
import 'package:syncrow_web/pages/routines/bloc/routine_bloc/routine_bloc.dart';
import 'package:syncrow_web/pages/routines/models/device_functions.dart';
import 'package:syncrow_web/pages/routines/models/pc/energy_clamp_functions.dart';
import 'package:syncrow_web/pages/routines/widgets/dialog_footer.dart';
import 'package:syncrow_web/pages/routines/widgets/dialog_header.dart';
import 'package:syncrow_web/pages/routines/widgets/routine_dialogs/helpers/routine_tap_function_helper.dart';
import 'package:syncrow_web/pages/routines/widgets/routine_dialogs/power_clamp_enargy/energy_value_selector_widget.dart';
import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart';
class EnergyClampDialog extends StatefulWidget {
final List<DeviceFunction> functions;
final AllDevicesModel? device;
final List<DeviceFunctionData>? deviceSelectedFunctions;
final String? uniqueCustomId;
final String? dialogType;
final bool removeComparetors;
const EnergyClampDialog({
super.key,
required this.functions,
this.device,
this.deviceSelectedFunctions,
this.uniqueCustomId,
this.dialogType,
this.removeComparetors = false,
});
static Future<Map<String, dynamic>?> showEnergyClampFunctionsDialog({
required BuildContext context,
required List<DeviceFunction> functions,
AllDevicesModel? device,
List<DeviceFunctionData>? deviceSelectedFunctions,
String? uniqueCustomId,
String? dialogType,
bool removeComparetors = false,
}) async {
return showDialog<Map<String, dynamic>?>(
context: context,
builder: (context) => EnergyClampDialog(
functions: functions,
device: device,
deviceSelectedFunctions: deviceSelectedFunctions,
uniqueCustomId: uniqueCustomId,
removeComparetors: removeComparetors,
dialogType: dialogType,
),
);
}
@override
State<EnergyClampDialog> createState() => _EnergyClampDialogState();
}
class _EnergyClampDialogState extends State<EnergyClampDialog> {
late final List<EnergyClampFunctions> _functions;
@override
void initState() {
super.initState();
_functions =
widget.functions.whereType<EnergyClampFunctions>().where((function) {
if (widget.dialogType == 'THEN') {
return function.type == 'THEN' || function.type == 'BOTH';
}
return function.type == 'IF' || function.type == 'BOTH';
}).toList();
}
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => FunctionBloc()
..add(InitializeFunctions(widget.deviceSelectedFunctions ?? [])),
child: _buildDialogContent(),
);
}
Widget _buildDialogContent() {
return AlertDialog(
contentPadding: EdgeInsets.zero,
content: BlocBuilder<FunctionBloc, FunctionBlocState>(
builder: (context, state) {
final selectedFunction = state.selectedFunction;
return Container(
width: selectedFunction != null ? 600 : 360,
height: 450,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
),
padding: const EdgeInsets.only(top: 20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const DialogHeader('Energy Clamp Conditions'),
Expanded(child: _buildMainContent(context, state)),
_buildDialogFooter(context, state),
],
),
);
},
),
);
}
Widget _buildMainContent(BuildContext context, FunctionBlocState state) {
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildFunctionList(context, state),
if (state.selectedFunction != null) _buildValueSelector(context, state),
],
);
}
Widget _buildFunctionList(BuildContext context, FunctionBlocState state) {
final selectedFunction = state.selectedFunction;
final selectedFunctionData = state.addedFunctions.firstWhere(
(f) => f.functionCode == selectedFunction,
orElse: () => DeviceFunctionData(
entityId: '',
functionCode: selectedFunction ?? '',
operationName: '',
value: null,
),
);
return SizedBox(
width: 360,
child: ListView.separated(
shrinkWrap: false,
physics: const AlwaysScrollableScrollPhysics(),
itemCount: _functions.length,
separatorBuilder: (context, index) => const Padding(
padding: EdgeInsets.symmetric(horizontal: 40.0),
child: Divider(color: ColorsManager.dividerColor),
),
itemBuilder: (context, index) {
final function = _functions[index];
return ListTile(
leading: SvgPicture.asset(
function.icon,
width: 24,
height: 24,
placeholderBuilder: (context) => const SizedBox(
width: 24,
height: 24,
),
),
title: Text(
function.operationName,
style: context.textTheme.bodyMedium,
),
trailing: const Icon(
Icons.arrow_forward_ios,
size: 16,
color: ColorsManager.textGray,
),
onTap: () => RoutineTapFunctionHelper.onTapFunction(
context,
functionCode: function.code,
functionOperationName: function.operationName,
functionValueDescription: selectedFunctionData.valueDescription,
deviceUuid: widget.device?.uuid,
codesToAddIntoFunctionsWithDefaultValue: [
'VoltageA',
'CurrentA',
'ActivePowerA',
'PowerFactorA',
'ReactivePowerA',
'EnergyConsumedA',
'VoltageB',
'CurrentB',
'ActivePowerB',
'PowerFactorB',
'ReactivePowerB',
'EnergyConsumedB',
'VoltageC',
'CurrentC',
'ActivePowerC',
'PowerFactorC',
'ReactivePowerC',
'EnergyConsumedC',
'EnergyConsumed',
'Current',
'ActivePower',
'ReactivePower',
'Frequency',
],
),
);
},
),
);
}
Widget _buildValueSelector(BuildContext context, FunctionBlocState state) {
final selectedFunction = state.selectedFunction!;
final functionData = state.addedFunctions.firstWhere(
(f) => f.functionCode == selectedFunction,
orElse: () => DeviceFunctionData(
entityId: '',
functionCode: selectedFunction,
operationName: state.selectedOperationName ?? '',
value: null,
),
);
return Expanded(
child: EnergyValueSelectorWidget(
selectedFunction: selectedFunction,
functionData: functionData,
functions: _functions,
device: widget.device,
dialogType: widget.dialogType!,
removeComparators: widget.removeComparetors,
),
);
}
Widget _buildDialogFooter(BuildContext context, FunctionBlocState state) {
return DialogFooter(
onCancel: () => Navigator.pop(context),
onConfirm: state.addedFunctions.isNotEmpty
? () {
context.read<RoutineBloc>().add(
AddFunctionToRoutine(
state.addedFunctions,
widget.uniqueCustomId!,
),
);
Navigator.pop(
context,
{'deviceId': widget.functions.first.deviceId},
);
}
: null,
isConfirmEnabled: state.selectedFunction != null,
);
}
}

View File

@ -0,0 +1,85 @@
import 'package:flutter/material.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/functions_bloc/functions_bloc_bloc.dart';
import 'package:syncrow_web/pages/routines/models/device_functions.dart';
import 'package:syncrow_web/pages/routines/models/pc/energy_clamp_functions.dart';
import 'package:syncrow_web/pages/routines/widgets/custom_routines_textbox.dart';
import 'package:syncrow_web/pages/routines/widgets/routine_dialogs/power_clamp_enargy/enargy_operational_values_list.dart';
class EnergyValueSelectorWidget extends StatelessWidget {
final String selectedFunction;
final DeviceFunctionData functionData;
final List<EnergyClampFunctions> functions;
final AllDevicesModel? device;
final String dialogType;
final bool removeComparators;
const EnergyValueSelectorWidget({
required this.selectedFunction,
required this.functionData,
required this.functions,
required this.device,
required this.dialogType,
required this.removeComparators,
super.key,
});
@override
Widget build(BuildContext context) {
final selectedFn =
functions.firstWhere((f) => f.code == selectedFunction);
final values = selectedFn.getOperationalValues();
final step = selectedFn.step ?? 1.0;
final _unit = selectedFn.unit ?? '';
final (double, double) sliderRange =
(selectedFn.min ?? 0.0, selectedFn.max ?? 100.0);
if (_isSliderFunction(selectedFunction)) {
return CustomRoutinesTextbox(
withSpecialChar: false,
currentCondition: functionData.condition,
dialogType: dialogType,
sliderRange: sliderRange,
displayedValue: functionData.value,
initialValue: functionData.value ?? 0.0,
onConditionChanged: (condition) => context.read<FunctionBloc>().add(
AddFunction(
functionData: DeviceFunctionData(
entityId: device?.uuid ?? '',
functionCode: selectedFunction,
operationName: functionData.operationName,
condition: condition,
value: functionData.value ?? 0,
),
),
),
onTextChanged: (value) => context.read<FunctionBloc>().add(
AddFunction(
functionData: DeviceFunctionData(
entityId: device?.uuid ?? '',
functionCode: selectedFunction,
operationName: functionData.operationName,
value: value.toInt(),
condition: functionData.condition,
),
),
),
unit: _unit,
dividendOfRange: 1,
stepIncreaseAmount: step,
);
}
return EnergyOperationalValuesList(
values: values,
selectedValue: functionData.value,
device: device,
operationName: selectedFn.operationName,
selectCode: selectedFunction,
);
}
bool _isSliderFunction(String function) =>
!['voltage_phase_seq'].contains(function);
}

View File

@ -33,7 +33,7 @@ class WpsValueSelectorWidget extends StatelessWidget {
if (_isSliderFunction(selectedFunction)) {
return CustomRoutinesTextbox(
withSpecialChar: false,
withSpecialChar: true,
currentCondition: functionData.condition,
dialogType: dialogType,
sliderRange: sliderRange,

View File

@ -242,7 +242,8 @@ class ThenContainer extends StatelessWidget {
'GW',
'CPS',
"NCPS",
"WH"
"WH",
'PC',
].contains(mutableData['productType'])) {
context.read<RoutineBloc>().add(AddToThenContainer(mutableData));
}

View File

@ -38,7 +38,7 @@ class VisitorPasswordDialog extends StatelessWidget {
if (visitorBloc.passwordStatus!.failedOperations.isNotEmpty)
Column(
children: [
const Text('Failed Devises'),
const Text('Failed Devices'),
SizedBox(
width: 200,
height: 50,
@ -63,7 +63,7 @@ class VisitorPasswordDialog extends StatelessWidget {
if (visitorBloc.passwordStatus!.successOperations.isNotEmpty)
Column(
children: [
const Text('Success Devises'),
const Text('Success Devices'),
SizedBox(
width: 200,
height: 50,

View File

@ -73,4 +73,14 @@ abstract class ColorsManager {
static const Color vividBlue = Color(0xFF023DFE);
static const Color semiTransparentRed = Color(0x99FF0000);
static const Color grey700 = Color(0xFF2D3748);
static const Color goodGreen = Color(0xFF0CEC16);
static const Color moderateYellow = Color(0xFFFAC96C);
static const Color poorOrange = Color(0xFFEC7400);
static const Color unhealthyRed = Color(0xFFD40000);
static const Color severePink = Color(0xFFD40094);
static const Color hazardousPurple = Color(0xFFBA01FD);
static const Color maxPurple = Color(0xFF962DFF);
static const Color maxPurpleDot = Color(0xFF5F00BD);
static const Color minBlue = Color(0xFF93AAFD);
static const Color minBlueDot = Color(0xFF023DFE);
}

View File

@ -448,5 +448,8 @@ class Assets {
static const String indentLevelIcon = 'assets/icons/indent_level_icon.svg';
static const String triggerLevelIcon = 'assets/icons/trigger_level_icon.svg';
static const String blankCalendar = 'assets/icons/blank_calendar.svg';
static const String refreshStatusIcon = 'assets/icons/refresh_status_icon.svg';
static const String refreshStatusIcon =
'assets/icons/refresh_status_icon.svg';
static const String energyConsumedIcon =
'assets/icons/energy_consumed_icon.svg';
}

View File

@ -19,6 +19,7 @@ enum DeviceType {
WaterLeak,
NCPS,
DoorSensor,
PC,
Other,
}
/*
@ -59,4 +60,5 @@ Map<String, DeviceType> devicesTypesMap = {
'GD': DeviceType.GarageDoor,
'WL': DeviceType.WaterLeak,
'NCPS': DeviceType.NCPS,
'PC': DeviceType.PC,
};