Compare commits

..

41 Commits

Author SHA1 Message Date
23cfee1490 fix curtain name in curtain if then containers dialogs 2025-06-29 11:12:28 +03:00
479aa4a091 Sp 1713 implement empty state (#285)
<!--
  Thanks for contributing!

Provide a description of your changes below and a general summary in the
title

Please look at the following checklist to ensure that your PR can be
accepted quickly:
-->

## Jira Ticket
[SP-1713](https://syncrow.atlassian.net/browse/SP-1713)

## Description

Implemented non selected space state
Implemented an initial version of the canvas.

## Type of Change

<!--- Put an `x` in all the boxes that apply: -->

- [x]  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 


[SP-1713]:
https://syncrow.atlassian.net/browse/SP-1713?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
2025-06-23 16:27:42 +03:00
75efc595b4 reverted to old import to avoid confusion with QA team. 2025-06-23 16:22:11 +03:00
8bc7a3daa2 Implemented space management canvas. 2025-06-23 15:45:49 +03:00
03a6c5474b SP-1768-FE-The-white-are-in-empty-devices-table-should-take-the-whole-table-size-not-just-the-top (#282)
<!--
  Thanks for contributing!

Provide a description of your changes below and a general summary in the
title

Please look at the following checklist to ensure that your PR can be
accepted quickly:
-->

## Jira Ticket
[SP-1768](https://syncrow.atlassian.net/browse/SP-1768)

## Description

<!--- Describe your changes in detail -->
fix white container take only a small part of the device table

## Type of Change

<!--- Put an `x` in all the boxes that apply: -->

- [ ]  New feature (non-breaking change which adds functionality)
- [x] 🛠️ 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 


[SP-1768]:
https://syncrow.atlassian.net/browse/SP-1768?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
2025-06-23 11:14:32 +03:00
ada7daf179 Switched from using Text to SelectableText in CreateCommunityDialog. 2025-06-23 10:13:30 +03:00
4bdb487094 doesnt show a snackbar when creating a community fails, since we show the error message in the dialog itself. 2025-06-23 10:11:23 +03:00
f8e4c89cdb uses correct error message that the api sends in RemoteCreateCommunityService. 2025-06-23 10:11:03 +03:00
7d4cdba0ef Connected templates view into SpaceManagementBody, while applying the correct UI principals if what to show what when? 2025-06-23 10:06:59 +03:00
a78b5993a9 Created SpaceManagementTemplatesView widget. 2025-06-23 10:05:53 +03:00
0e7109a19e Created CommunityTemplateCell widget. 2025-06-23 10:02:15 +03:00
ff3d5cd996 Created a helper class to show create community dialog, since this dialog can be shown from two different widgets. 2025-06-23 10:02:02 +03:00
5f30a5a61b Refactor empty state widget to use a container for better layout control 2025-06-23 10:01:01 +03:00
0712e6d64b Sp 1593 reworks (#277)
<!--
  Thanks for contributing!

Provide a description of your changes below and a general summary in the
title

Please look at the following checklist to ensure that your PR can be
accepted quickly:
-->

## Jira Ticket
[SP-1593](https://syncrow.atlassian.net/browse/SP-1593)

## Description

1. AQI Distribution chart bars when all values are empty.
2. Min element in Y axis of Range of AQI chart is visible.
3. Matched AQI chart titles to have the same size for consistency.
4. Allowed `RangeOfAqiValue` model's values to be nullable, and they
fallback to `0` when null.
5. Implemented AQI Legend.
6. Increased the size of AQI Distribution chart's tooltip.
7. Improved alignment of location cell.
8. Doesn't fetch devices on date change.

## Type of Change

<!--- Put an `x` in all the boxes that apply: -->

- [x]  New feature (non-breaking change which adds functionality)
- [x] 🛠️ 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 


[SP-1593]:
https://syncrow.atlassian.net/browse/SP-1593?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
2025-06-23 09:48:17 +03:00
a493ae08ce SP-1710-FE-Create-Sidebar (#278)
<!--
  Thanks for contributing!

Provide a description of your changes below and a general summary in the
title

Please look at the following checklist to ensure that your PR can be
accepted quickly:
-->

## Jira Ticket
[SP-1710](https://syncrow.atlassian.net/browse/SP-1710)

## Description

1. Implemented Space Management Community Side Tree.
2. Implemented Creating a new community feature.

## Type of Change

<!--- Put an `x` in all the boxes that apply: -->

- [x]  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 


[SP-1710]:
https://syncrow.atlassian.net/browse/SP-1710?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
2025-06-23 09:36:30 +03:00
27349a6cc0 Implemented PR notes by extracting widgets into their own classes. 2025-06-23 09:24:53 +03:00
d17d4184be fix it and add lock to open when press (as loved simple animation) (#280)
with adding the timer as circle

<!--
  Thanks for contributing!

Provide a description of your changes below and a general summary in the
title

Please look at the following checklist to ensure that your PR can be
accepted quickly:
-->

## Jira Ticket
[SP-1567](https://syncrow.atlassian.net/browse/SP-1567)

## Description

alll is goood it is now listening to changes and when unlockRequest up
to 0 and under 30 it is start to give permission

## Type of Change

<!--- Put an `x` in all the boxes that apply: -->

- [ ]  New feature (non-breaking change which adds functionality)
- [x] 🛠️ 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 


[SP-1567]:
https://syncrow.atlassian.net/browse/SP-1567?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
2025-06-23 08:19:22 +03:00
41d4fbb555 Extracted pagination data into a generic DTO. 2025-06-22 16:00:20 +03:00
fccb5cbbab SP-1606-FE-Fix-Search-Function-on-Devices-Screen-to-Support-All-Device-Types-Flush-Mounted-Sensor (#281)
…ctionality

<!--
  Thanks for contributing!

Provide a description of your changes below and a general summary in the
title

Please look at the following checklist to ensure that your PR can be
accepted quickly:
-->

## Jira Ticket
[SP-1606](https://syncrow.atlassian.net/browse/SP-1606)

## Description

<!--- Describe your changes in detail -->
search by product name and device name 

## Type of Change

<!--- Put an `x` in all the boxes that apply: -->

- [ ]  New feature (non-breaking change which adds functionality)
- [x] 🛠️ 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 


[SP-1606]:
https://syncrow.atlassian.net/browse/SP-1606?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
2025-06-22 15:49:19 +03:00
48d7ab430f refactor: rename productName to deviceNameOrProductName in search functionality 2025-06-22 15:35:46 +03:00
28ac911f3f Accomodated for null values in SpaceModel. 2025-06-22 15:30:47 +03:00
a793cc3967 fix it and add lock to open when press (as loved simple animation)
with adding the timer as circle
2025-06-22 15:24:53 +03:00
09446844b0 reverted initializing the new space management page in the router, to avoid any confusion with the QA team. 2025-06-22 15:11:38 +03:00
f02788eaa5 implemented create community feature. 2025-06-22 14:58:38 +03:00
614db4333c Refactor ScheduleBloc and related components to use dynamic category … (#279)
<!--
  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:
-->



## Description

<!--- Describe your changes in detail -->
Fix scheduling Bugs

## Type of Change

<!--- Put an `x` in all the boxes that apply: -->

- [ ]  New feature (non-breaking change which adds functionality)
- [x] 🛠️ 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
2025-06-22 14:44:18 +03:00
b79ab06d95 shows a loading indicator when loading. 2025-06-22 12:58:45 +03:00
8494f0a8f1 matched community and space models with API. 2025-06-22 12:38:54 +03:00
65ed94eb08 debounce and refactored CommunitiesBloc. 2025-06-22 12:01:32 +03:00
51c088d998 made communities paginatable. 2025-06-22 11:11:25 +03:00
2f233db332 implemented space management side bar. 2025-06-22 11:04:39 +03:00
1f82e84115 doesnt fetch devices on date change. 2025-06-22 10:55:41 +03:00
23c3bf11f9 Improved alignment of AqiLocationInfoCell. 2025-06-19 15:38:28 +03:00
5201a65a97 matched sizes of bottom titles in aqi charts. 2025-06-19 15:19:58 +03:00
e4cc5fce50 Increased the size of AqiDistributionChart tooltip. 2025-06-19 15:18:18 +03:00
8dea89db0e Implemented AQI legend. 2025-06-19 15:12:54 +03:00
ad5ada9d55 allowed RangeOfAqiValue values to be nullable, and if they were null they fallback to zero. 2025-06-19 14:24:49 +03:00
7172a0e3fb Matched aqi charts title's to have the same size no matter what the window size is. 2025-06-19 14:23:39 +03:00
78898968e8 include min in RangeOfAqiChartsHelper.titlesData.leftTitles. 2025-06-19 14:23:04 +03:00
666c64231f hides bars in AqiDistributionChart where all values are zero. 2025-06-19 14:22:37 +03:00
20d044f2e5 Merge branch 'dev' of https://github.com/SyncrowIOT/web into SP-1710-FE-Create-Sidebar 2025-06-18 09:44:35 +03:00
8caee32822 Initialized new SpaceManagementPage. 2025-06-18 09:39:49 +03:00
60 changed files with 2306 additions and 317 deletions

View File

@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
class AppLoadingIndicator extends StatelessWidget {
const AppLoadingIndicator({super.key});
@override
Widget build(BuildContext context) {
return const Center(child: CircularProgressIndicator());
}
}

View File

@ -38,9 +38,9 @@ class RangeOfAqiValue extends Equatable {
factory RangeOfAqiValue.fromJson(Map<String, dynamic> json) { factory RangeOfAqiValue.fromJson(Map<String, dynamic> json) {
return RangeOfAqiValue( return RangeOfAqiValue(
type: json['type'] as String, type: json['type'] as String,
min: (json['min'] as num).toDouble(), min: (json['min'] as num? ?? 0).toDouble(),
average: (json['average'] as num).toDouble(), average: (json['average'] as num? ?? 0).toDouble(),
max: (json['max'] as num).toDouble(), max: (json['max'] as num? ?? 0).toDouble(),
); );
} }

View File

@ -24,11 +24,13 @@ abstract final class FetchAirQualityDataHelper {
}) { }) {
final date = context.read<AnalyticsDatePickerBloc>().state.monthlyDate; final date = context.read<AnalyticsDatePickerBloc>().state.monthlyDate;
final aqiType = context.read<AirQualityDistributionBloc>().state.selectedAqiType; final aqiType = context.read<AirQualityDistributionBloc>().state.selectedAqiType;
loadAnalyticsDevices( if (shouldFetchAnalyticsDevices) {
context, loadAnalyticsDevices(
communityUuid: communityUuid, context,
spaceUuid: spaceUuid, communityUuid: communityUuid,
); spaceUuid: spaceUuid,
);
}
loadRangeOfAqi( loadRangeOfAqi(
context, context,
spaceUuid: spaceUuid, spaceUuid: spaceUuid,

View File

@ -23,6 +23,7 @@ abstract final class RangeOfAqiChartsHelper {
return titlesData.copyWith( return titlesData.copyWith(
bottomTitles: titlesData.bottomTitles.copyWith( bottomTitles: titlesData.bottomTitles.copyWith(
sideTitles: titlesData.bottomTitles.sideTitles.copyWith( sideTitles: titlesData.bottomTitles.sideTitles.copyWith(
reservedSize: 36,
getTitlesWidget: (value, meta) => Padding( getTitlesWidget: (value, meta) => Padding(
padding: const EdgeInsetsDirectional.only(top: 20.0), padding: const EdgeInsetsDirectional.only(top: 20.0),
child: Text( child: Text(
@ -40,6 +41,7 @@ abstract final class RangeOfAqiChartsHelper {
reservedSize: 70, reservedSize: 70,
interval: 50, interval: 50,
maxIncluded: false, maxIncluded: false,
minIncluded: true,
getTitlesWidget: (value, meta) { getTitlesWidget: (value, meta) {
final text = value >= 300 ? '301+' : value.toInt().toString(); final text = value >= 300 ? '301+' : value.toInt().toString();
return Padding( return Padding(

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; 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/air_quality_end_side_widget.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/widgets/aqi_distribution_chart_box.dart'; import 'package:syncrow_web/pages/analytics/modules/air_quality/widgets/aqi_distribution_chart_box.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/widgets/aqi_legend.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/widgets/range_of_aqi_chart_box.dart'; import 'package:syncrow_web/pages/analytics/modules/air_quality/widgets/range_of_aqi_chart_box.dart';
class AirQualityView extends StatelessWidget { class AirQualityView extends StatelessWidget {
@ -20,6 +21,10 @@ class AirQualityView extends StatelessWidget {
child: Column( child: Column(
spacing: 32, spacing: 32,
children: [ children: [
SizedBox(
height: height * 0.1,
child: const AqiLegend(),
),
SizedBox( SizedBox(
height: height * 1.2, height: height * 1.2,
child: const AirQualityEndSideWidget(), child: const AirQualityEndSideWidget(),
@ -40,7 +45,7 @@ class AirQualityView extends StatelessWidget {
return SingleChildScrollView( return SingleChildScrollView(
child: Container( child: Container(
padding: _padding, padding: _padding,
height: height * 1.1, height: height * 1.2,
child: const Column( child: const Column(
children: [ children: [
Expanded( Expanded(
@ -52,8 +57,9 @@ class AirQualityView extends StatelessWidget {
child: Column( child: Column(
spacing: 20, spacing: 20,
children: [ children: [
Expanded(child: RangeOfAqiChartBox()), Expanded(flex: 2, child: AqiLegend()),
Expanded(child: AqiDistributionChartBox()), Expanded(flex: 12, child: RangeOfAqiChartBox()),
Expanded(flex: 12, child: AqiDistributionChartBox()),
], ],
), ),
), ),

View File

@ -32,8 +32,13 @@ class AqiDistributionChart extends StatelessWidget {
} }
List<BarChartGroupData> _buildBarGroups() { List<BarChartGroupData> _buildBarGroups() {
return List.generate(chartData.length, (index) { final groups = <BarChartGroupData>[];
final data = chartData[index]; for (var i = 0; i < chartData.length; i++) {
final data = chartData[i];
final isAllZero = data.data.every((d) => d.percentage == 0);
if (isAllZero) {
continue;
}
final stackItems = <BarChartRodData>[]; final stackItems = <BarChartRodData>[];
double currentY = 0; double currentY = 0;
var isFirstElement = true; var isFirstElement = true;
@ -56,13 +61,15 @@ class AqiDistributionChart extends StatelessWidget {
currentY += percentageData.percentage + _rodStackItemsSpacing; currentY += percentageData.percentage + _rodStackItemsSpacing;
isFirstElement = false; isFirstElement = false;
} }
groups.add(
return BarChartGroupData( BarChartGroupData(
x: index, x: i,
barRods: stackItems, barRods: stackItems,
groupVertically: true, groupVertically: true,
),
); );
}); }
return groups;
} }
BarTouchData _barTouchData(BuildContext context) { BarTouchData _barTouchData(BuildContext context) {
@ -73,6 +80,7 @@ class AqiDistributionChart extends StatelessWidget {
color: ColorsManager.semiTransparentBlack, color: ColorsManager.semiTransparentBlack,
), ),
tooltipRoundedRadius: 16, tooltipRoundedRadius: 16,
maxContentWidth: 500,
tooltipPadding: const EdgeInsets.all(8), tooltipPadding: const EdgeInsets.all(8),
getTooltipItem: (group, groupIndex, rod, rodIndex) { getTooltipItem: (group, groupIndex, rod, rodIndex) {
final data = chartData[group.x]; final data = chartData[group.x];
@ -81,10 +89,13 @@ class AqiDistributionChart extends StatelessWidget {
final textStyle = context.textTheme.bodySmall?.copyWith( final textStyle = context.textTheme.bodySmall?.copyWith(
color: ColorsManager.blackColor, color: ColorsManager.blackColor,
fontSize: 8, fontSize: 11,
); );
for (final percentageData in data.data) { for (final percentageData in data.data) {
if (percentageData.percentage == 0) {
continue;
}
final percentage = percentageData.percentage.toStringAsFixed(1); final percentage = percentageData.percentage.toStringAsFixed(1);
final type = percentageData.type[0].toUpperCase() + final type = percentageData.type[0].toUpperCase() +
percentageData.type.substring(1).replaceAll('_', ' '); percentageData.type.substring(1).replaceAll('_', ' ');
@ -98,7 +109,7 @@ class AqiDistributionChart extends StatelessWidget {
DateFormat('dd/MM/yyyy').format(data.date), DateFormat('dd/MM/yyyy').format(data.date),
context.textTheme.bodyMedium!.copyWith( context.textTheme.bodyMedium!.copyWith(
color: ColorsManager.blackColor, color: ColorsManager.blackColor,
fontSize: 9, fontSize: 12,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
textAlign: TextAlign.start, textAlign: TextAlign.start,
@ -118,7 +129,6 @@ class AqiDistributionChart extends StatelessWidget {
final leftTitles = titlesData.leftTitles.copyWith( final leftTitles = titlesData.leftTitles.copyWith(
sideTitles: titlesData.leftTitles.sideTitles.copyWith( sideTitles: titlesData.leftTitles.sideTitles.copyWith(
reservedSize: 70, reservedSize: 70,
interval: 20,
maxIncluded: false, maxIncluded: false,
minIncluded: true, minIncluded: true,
getTitlesWidget: (value, meta) => Padding( getTitlesWidget: (value, meta) => Padding(
@ -140,7 +150,7 @@ class AqiDistributionChart extends StatelessWidget {
final bottomTitles = AxisTitles( final bottomTitles = AxisTitles(
sideTitles: SideTitles( sideTitles: SideTitles(
showTitles: true, showTitles: chartData.isNotEmpty,
getTitlesWidget: (value, _) => FittedBox( getTitlesWidget: (value, _) => FittedBox(
alignment: AlignmentDirectional.bottomCenter, alignment: AlignmentDirectional.bottomCenter,
fit: BoxFit.scaleDown, fit: BoxFit.scaleDown,
@ -148,7 +158,7 @@ class AqiDistributionChart extends StatelessWidget {
chartData[value.toInt()].date.day.toString(), chartData[value.toInt()].date.day.toString(),
style: context.textTheme.bodySmall?.copyWith( style: context.textTheme.bodySmall?.copyWith(
color: ColorsManager.lightGreyColor, color: ColorsManager.lightGreyColor,
fontSize: 8, fontSize: 12,
), ),
), ),
), ),

View File

@ -19,7 +19,7 @@ class AqiDistributionChartTitle extends StatelessWidget {
children: [ children: [
ChartsLoadingWidget(isLoading: isLoading), ChartsLoadingWidget(isLoading: isLoading),
const Expanded( const Expanded(
flex: 3, flex: 4,
child: FittedBox( child: FittedBox(
fit: BoxFit.scaleDown, fit: BoxFit.scaleDown,
alignment: AlignmentDirectional.centerStart, alignment: AlignmentDirectional.centerStart,
@ -28,23 +28,26 @@ class AqiDistributionChartTitle extends StatelessWidget {
), ),
), ),
), ),
FittedBox( Expanded(
alignment: AlignmentDirectional.centerEnd, flex: 2,
fit: BoxFit.scaleDown, child: FittedBox(
child: AqiTypeDropdown( alignment: AlignmentDirectional.centerEnd,
onChanged: (value) { fit: BoxFit.scaleDown,
if (value != null) { child: AqiTypeDropdown(
final bloc = context.read<AirQualityDistributionBloc>(); onChanged: (value) {
try { if (value != null) {
final param = _makeLoadAqiDistributionParam(context, value); final bloc = context.read<AirQualityDistributionBloc>();
bloc.add(LoadAirQualityDistribution(param)); try {
} catch (_) { final param = _makeLoadAqiDistributionParam(context, value);
return; bloc.add(LoadAirQualityDistribution(param));
} finally { } catch (_) {
bloc.add(UpdateAqiTypeEvent(value)); return;
} finally {
bloc.add(UpdateAqiTypeEvent(value));
}
} }
} },
}, ),
), ),
), ),
], ],

View File

@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:syncrow_web/pages/analytics/modules/air_quality/helpers/range_of_aqi_charts_helper.dart';
import 'package:syncrow_web/pages/analytics/modules/analytics/widgets/chart_informative_cell.dart';
import 'package:syncrow_web/utils/style.dart';
class AqiLegend extends StatelessWidget {
const AqiLegend({super.key});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsetsDirectional.all(20),
decoration: subSectionContainerDecoration.copyWith(
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 16,
children: RangeOfAqiChartsHelper.gradientData.map((e) {
return Flexible(
flex: 4,
child: FittedBox(
fit: BoxFit.fill,
child: ChartInformativeCell(
color: e.$1,
title: FittedBox(
fit: BoxFit.fill,
child: Text(e.$2),
),
height: null,
),
),
);
}).toList(),
),
);
}
}

View File

@ -47,36 +47,37 @@ class AqiLocationInfoCell extends StatelessWidget {
), ),
), ),
Align( Align(
alignment: AlignmentDirectional.bottomEnd, alignment: AlignmentDirectional.bottomCenter,
child: Padding( child: Row(
padding: const EdgeInsetsDirectional.all(10), crossAxisAlignment: CrossAxisAlignment.end,
child: SizedBox( children: [
height: 40, Expanded(
width: 120, child: SvgPicture.asset(
child: FittedBox( svgPath,
fit: BoxFit.scaleDown, fit: BoxFit.scaleDown,
alignment: AlignmentDirectional.bottomEnd, alignment: AlignmentDirectional.bottomStart,
child: Text( ),
value, ),
style: context.textTheme.bodySmall?.copyWith( Expanded(
color: ColorsManager.vividBlue.withValues(alpha: 0.7), child: FittedBox(
fontWeight: FontWeight.w700, fit: BoxFit.scaleDown,
fontSize: 24, alignment: AlignmentDirectional.bottomEnd,
child: Padding(
padding: const EdgeInsetsDirectional.all(10),
child: Text(
value,
style: context.textTheme.bodySmall?.copyWith(
color: ColorsManager.vividBlue.withValues(
alpha: 0.7,
),
fontWeight: FontWeight.w700,
fontSize: 24,
),
),
), ),
), ),
), ),
), ],
),
),
Align(
alignment: AlignmentDirectional.bottomStart,
child: SizedBox.square(
dimension: MediaQuery.sizeOf(context).width * 0.45,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: AlignmentDirectional.bottomStart,
child: SvgPicture.asset(svgPath),
),
), ),
), ),
], ],

View File

@ -7,16 +7,18 @@ class ChartInformativeCell extends StatelessWidget {
required this.title, required this.title,
required this.color, required this.color,
this.hasBorder = false, this.hasBorder = false,
this.height,
}); });
final Widget title; final Widget title;
final Color color; final Color color;
final bool hasBorder; final bool hasBorder;
final double? height;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
height: MediaQuery.sizeOf(context).height * 0.0385, height: height ?? MediaQuery.sizeOf(context).height * 0.0385,
padding: const EdgeInsetsDirectional.symmetric( padding: const EdgeInsetsDirectional.symmetric(
vertical: 8, vertical: 8,
horizontal: 12, horizontal: 12,

View File

@ -179,31 +179,36 @@ class _DynamicTableState extends State<DynamicTable> {
); );
} }
Widget _buildEmptyState() => Column( Widget _buildEmptyState() => Container(
mainAxisAlignment: MainAxisAlignment.center, height: widget.size.height,
children: [ color: ColorsManager.whiteColors,
Row( child: Column(
crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center, children: [
children: [ Row(
Column( crossAxisAlignment: CrossAxisAlignment.center,
children: [ mainAxisAlignment: MainAxisAlignment.center,
SvgPicture.asset(Assets.emptyTable), children: [
const SizedBox(height: 15), Column(
Text( children: [
widget.tableName == 'AccessManagement' SvgPicture.asset(Assets.emptyTable),
? 'No Password ' const SizedBox(height: 15),
: 'No Devices', Text(
style: Theme.of(context) widget.tableName == 'AccessManagement'
.textTheme ? 'No Password '
.bodySmall! : 'No Devices',
.copyWith(color: ColorsManager.grayColor), style: Theme.of(context)
) .textTheme
], .bodySmall!
), .copyWith(color: ColorsManager.grayColor),
], )
), ],
], ),
],
),
SizedBox(height: widget.size.height * 0.5),
],
),
); );
Widget _buildSelectAllCheckbox() { Widget _buildSelectAllCheckbox() {
return Container( return Container(

View File

@ -40,17 +40,18 @@ class DeviceManagementBloc
List<AllDevicesModel> devices = []; List<AllDevicesModel> devices = [];
_devices.clear(); _devices.clear();
var spaceBloc = event.context.read<SpaceTreeBloc>(); var spaceBloc = event.context.read<SpaceTreeBloc>();
final projectUuid = await ProjectManager.getProjectUUID() ?? ''; final projectUuid = await ProjectManager.getProjectUUID() ?? '';
if (spaceBloc.state.selectedCommunities.isEmpty) { if (spaceBloc.state.selectedCommunities.isEmpty) {
devices = await DevicesManagementApi().fetchDevices('', '', projectUuid); devices =
await DevicesManagementApi().fetchDevices('', '', projectUuid);
} else { } else {
for (var community in spaceBloc.state.selectedCommunities) { for (var community in spaceBloc.state.selectedCommunities) {
List<String> spacesList = List<String> spacesList =
spaceBloc.state.selectedCommunityAndSpaces[community] ?? []; spaceBloc.state.selectedCommunityAndSpaces[community] ?? [];
for (var space in spacesList) { for (var space in spacesList) {
devices.addAll(await DevicesManagementApi().fetchDevices( devices.addAll(await DevicesManagementApi()
community, space, projectUuid)); .fetchDevices(community, space, projectUuid));
} }
} }
} }
@ -100,7 +101,7 @@ class DeviceManagementBloc
)); ));
if (currentProductName.isNotEmpty) { if (currentProductName.isNotEmpty) {
add(SearchDevices(productName: currentProductName)); add(SearchDevices(deviceNameOrProductName: currentProductName));
} }
} }
} }
@ -269,34 +270,41 @@ class DeviceManagementBloc
return 'All'; return 'All';
} }
} }
void _onSearchDevices( void _onSearchDevices(
SearchDevices event, Emitter<DeviceManagementState> emit) { SearchDevices event, Emitter<DeviceManagementState> emit) {
if ((event.community == null || event.community!.isEmpty) && if ((event.community == null || event.community!.isEmpty) &&
(event.unitName == null || event.unitName!.isEmpty) && (event.unitName == null || event.unitName!.isEmpty) &&
(event.productName == null || event.productName!.isEmpty)) { (event.deviceNameOrProductName == null ||
event.deviceNameOrProductName!.isEmpty)) {
currentProductName = ''; currentProductName = '';
if (state is DeviceManagementFiltered) { _filteredDevices = List.from(_devices);
add(FilterDevices(_getFilterFromIndex(_selectedIndex))); emit(DeviceManagementLoaded(
} else { devices: _devices,
return; selectedIndex: _selectedIndex,
} onlineCount: _onlineCount,
offlineCount: _offlineCount,
lowBatteryCount: _lowBatteryCount,
selectedDevice: null,
isControlButtonEnabled: false,
));
return;
} }
if (event.deviceNameOrProductName == currentProductName &&
if (event.productName == currentProductName &&
event.community == currentCommunity && event.community == currentCommunity &&
event.unitName == currentUnitName && event.unitName == currentUnitName &&
event.searchField) { event.searchField) {
return; return;
} }
currentProductName = event.productName ?? ''; currentProductName = event.deviceNameOrProductName ?? '';
currentCommunity = event.community; currentCommunity = event.community;
currentUnitName = event.unitName; currentUnitName = event.unitName;
List<AllDevicesModel> devicesToSearch = _filteredDevices; List<AllDevicesModel> devicesToSearch = _devices;
if (devicesToSearch.isNotEmpty) { if (devicesToSearch.isNotEmpty) {
final searchText = event.deviceNameOrProductName?.toLowerCase() ?? '';
final filteredDevices = devicesToSearch.where((device) { final filteredDevices = devicesToSearch.where((device) {
final matchesCommunity = event.community == null || final matchesCommunity = event.community == null ||
event.community!.isEmpty || event.community!.isEmpty ||
@ -304,31 +312,25 @@ class DeviceManagementBloc
?.toLowerCase() ?.toLowerCase()
.contains(event.community!.toLowerCase()) ?? .contains(event.community!.toLowerCase()) ??
false); false);
final matchesUnit = event.unitName == null || final matchesUnit = event.unitName == null ||
event.unitName!.isEmpty || event.unitName!.isEmpty ||
(device.spaces != null && (device.spaces != null &&
device.spaces!.isNotEmpty && device.spaces!.any((space) =>
device.spaces![0].spaceName! space.spaceName != null &&
.toLowerCase() space.spaceName!
.contains(event.unitName!.toLowerCase())); .toLowerCase()
final matchesProductName = event.productName == null || .contains(event.unitName!.toLowerCase())));
event.productName!.isEmpty ||
(device.name
?.toLowerCase()
.contains(event.productName!.toLowerCase()) ??
false);
final matchesDeviceName = event.productName == null ||
event.productName!.isEmpty ||
(device.categoryName
?.toLowerCase()
.contains(event.productName!.toLowerCase()) ??
false);
return matchesCommunity && final matchesSearchText = searchText.isEmpty ||
matchesUnit && (device.name?.toLowerCase().contains(searchText) ?? false) ||
(matchesProductName || matchesDeviceName); (device.productName?.toLowerCase().contains(searchText) ?? false);
return matchesCommunity && matchesUnit && matchesSearchText;
}).toList(); }).toList();
_filteredDevices = filteredDevices;
emit(DeviceManagementFiltered( emit(DeviceManagementFiltered(
filteredDevices: filteredDevices, filteredDevices: filteredDevices,
selectedIndex: _selectedIndex, selectedIndex: _selectedIndex,

View File

@ -38,18 +38,18 @@ class SelectedFilterChanged extends DeviceManagementEvent {
class SearchDevices extends DeviceManagementEvent { class SearchDevices extends DeviceManagementEvent {
final String? community; final String? community;
final String? unitName; final String? unitName;
final String? productName; final String? deviceNameOrProductName;
final bool searchField; final bool searchField;
const SearchDevices({ const SearchDevices({
this.community, this.community,
this.unitName, this.unitName,
this.productName, this.deviceNameOrProductName,
this.searchField = false, this.searchField = false,
}); });
@override @override
List<Object?> get props => [community, unitName, productName]; List<Object?> get props => [community, unitName, deviceNameOrProductName];
} }
class SelectDevice extends DeviceManagementEvent { class SelectDevice extends DeviceManagementEvent {

View File

@ -53,7 +53,7 @@ class _DeviceSearchFiltersState extends State<DeviceSearchFilters>
controller: controller, controller: controller,
onSubmitted: () { onSubmitted: () {
final searchDevicesEvent = SearchDevices( final searchDevicesEvent = SearchDevices(
productName: _productNameController.text, deviceNameOrProductName: _productNameController.text,
unitName: _unitNameController.text, unitName: _unitNameController.text,
searchField: true, searchField: true,
); );
@ -68,7 +68,7 @@ class _DeviceSearchFiltersState extends State<DeviceSearchFilters>
onSearch: () => context.read<DeviceManagementBloc>().add( onSearch: () => context.read<DeviceManagementBloc>().add(
SearchDevices( SearchDevices(
unitName: _unitNameController.text, unitName: _unitNameController.text,
productName: _productNameController.text, deviceNameOrProductName: _productNameController.text,
searchField: true, searchField: true,
), ),
), ),

View File

@ -1,5 +1,3 @@
// ignore_for_file: invalid_use_of_visible_for_testing_member
import 'dart:async'; import 'dart:async';
import 'package:firebase_database/firebase_database.dart'; import 'package:firebase_database/firebase_database.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
@ -16,45 +14,38 @@ class DoorLockBloc extends Bloc<DoorLockEvent, DoorLockState> {
DoorLockBloc({required this.deviceId}) : super(DoorLockInitial()) { DoorLockBloc({required this.deviceId}) : super(DoorLockInitial()) {
on<DoorLockFetchStatus>(_onFetchDeviceStatus); on<DoorLockFetchStatus>(_onFetchDeviceStatus);
//on<DoorLockControl>(_onDoorLockControl);
on<UpdateLockEvent>(_updateLock); on<UpdateLockEvent>(_updateLock);
on<DoorLockFactoryReset>(_onFactoryReset); on<DoorLockFactoryReset>(_onFactoryReset);
on<StatusUpdated>(_onStatusUpdated); on<StatusUpdated>(_onStatusUpdated);
} }
_listenToChanges(deviceId) { void _listenToChanges(String deviceId) {
try { try {
DatabaseReference ref = final ref = FirebaseDatabase.instance.ref('device-status/$deviceId');
FirebaseDatabase.instance.ref('device-status/$deviceId'); ref.onValue.listen((event) {
Stream<DatabaseEvent> stream = ref.onValue; final data = event.snapshot.value;
if (data is Map) {
final statusData = data['status'] as List<dynamic>? ?? [];
final statusList = statusData.map((item) {
return Status(code: item['code'], value: item['value']);
}).toList();
stream.listen((DatabaseEvent event) { final model =
Map<dynamic, dynamic> usersMap = DoorLockStatusModel.fromJson(data['productUuid'], statusList);
event.snapshot.value as Map<dynamic, dynamic>; if (!isClosed) {
add(StatusUpdated(model));
List<Status> statusList = []; }
usersMap['status'].forEach((element) {
statusList
.add(Status(code: element['code'], value: element['value']));
});
deviceStatus =
DoorLockStatusModel.fromJson(usersMap['productUuid'], statusList);
if (!isClosed) {
add(StatusUpdated(deviceStatus));
} }
}); });
} catch (_) {} } catch (_) {}
} }
void _onStatusUpdated(StatusUpdated event, Emitter<DoorLockState> emit) { void _onStatusUpdated(StatusUpdated event, Emitter<DoorLockState> emit) {
emit(DoorLockStatusLoading());
deviceStatus = event.deviceStatus; deviceStatus = event.deviceStatus;
emit(DoorLockStatusLoaded(deviceStatus)); emit(DoorLockStatusLoaded(deviceStatus));
} }
FutureOr<void> _onFetchDeviceStatus( Future<void> _onFetchDeviceStatus(
DoorLockFetchStatus event, Emitter<DoorLockState> emit) async { DoorLockFetchStatus event, Emitter<DoorLockState> emit) async {
emit(DoorLockStatusLoading()); emit(DoorLockStatusLoading());
try { try {
@ -63,14 +54,13 @@ class DoorLockBloc extends Bloc<DoorLockEvent, DoorLockState> {
deviceStatus = deviceStatus =
DoorLockStatusModel.fromJson(event.deviceId, status.status); DoorLockStatusModel.fromJson(event.deviceId, status.status);
_listenToChanges(event.deviceId); _listenToChanges(event.deviceId);
emit(DoorLockStatusLoaded(deviceStatus)); emit(DoorLockStatusLoaded(deviceStatus));
} catch (e) { } catch (e) {
emit(DoorLockControlError(e.toString())); emit(DoorLockControlError(e.toString()));
} }
} }
FutureOr<void> _updateLock( Future<void> _updateLock(
UpdateLockEvent event, Emitter<DoorLockState> emit) async { UpdateLockEvent event, Emitter<DoorLockState> emit) async {
final oldValue = deviceStatus.normalOpenSwitch; final oldValue = deviceStatus.normalOpenSwitch;
deviceStatus = deviceStatus.copyWith(normalOpenSwitch: !oldValue); deviceStatus = deviceStatus.copyWith(normalOpenSwitch: !oldValue);
@ -78,7 +68,6 @@ class DoorLockBloc extends Bloc<DoorLockEvent, DoorLockState> {
try { try {
final response = await DevicesManagementApi.openDoorLock(deviceId); final response = await DevicesManagementApi.openDoorLock(deviceId);
if (!response) { if (!response) {
_revertValueAndEmit(deviceId, 'normal_open_switch', oldValue, emit); _revertValueAndEmit(deviceId, 'normal_open_switch', oldValue, emit);
} }
@ -88,35 +77,8 @@ class DoorLockBloc extends Bloc<DoorLockEvent, DoorLockState> {
} }
} }
Future<void> _runDebounce({ void _revertValueAndEmit(String deviceId, String code, dynamic oldValue,
required String deviceId, Emitter<DoorLockState> emit) {
required String code,
required dynamic value,
required dynamic oldValue,
required Emitter<DoorLockState> emit,
}) async {
if (_timer != null) {
_timer!.cancel();
}
_timer = Timer(const Duration(seconds: 1), () async {
try {
final response = await DevicesManagementApi()
.deviceControl(deviceId, Status(code: code, value: value));
if (!response) {
_revertValueAndEmit(deviceId, code, oldValue, emit);
}
} catch (e) {
_revertValueAndEmit(deviceId, code, oldValue, emit);
}
});
}
void _revertValueAndEmit(
String deviceId,
String code,
dynamic oldValue,
Emitter<DoorLockState> emit,
) {
_updateLocalValue(code, oldValue); _updateLocalValue(code, oldValue);
emit(DoorLockStatusLoaded(deviceStatus)); emit(DoorLockStatusLoaded(deviceStatus));
emit(const DoorLockControlError('Failed to control the device.')); emit(const DoorLockControlError('Failed to control the device.'));
@ -124,34 +86,23 @@ class DoorLockBloc extends Bloc<DoorLockEvent, DoorLockState> {
void _updateLocalValue(String code, dynamic value) { void _updateLocalValue(String code, dynamic value) {
switch (code) { switch (code) {
case 'reverse_lock':
if (value is bool) {
deviceStatus = deviceStatus.copyWith(reverseLock: value);
}
break;
case 'normal_open_switch': case 'normal_open_switch':
if (value is bool) { if (value is bool) {
deviceStatus = deviceStatus.copyWith(normalOpenSwitch: value); deviceStatus = deviceStatus.copyWith(normalOpenSwitch: value);
} }
break; break;
case 'reverse_lock':
if (value is bool) {
deviceStatus = deviceStatus.copyWith(reverseLock: value);
}
break;
default: default:
break; break;
} }
emit(DoorLockStatusLoaded(deviceStatus)); emit(DoorLockStatusLoaded(deviceStatus));
} }
dynamic _getValueByCode(String code) { Future<void> _onFactoryReset(
switch (code) {
case 'reverse_lock':
return deviceStatus.reverseLock;
case 'normal_open_switch':
return deviceStatus.normalOpenSwitch;
default:
return null;
}
}
FutureOr<void> _onFactoryReset(
DoorLockFactoryReset event, Emitter<DoorLockState> emit) async { DoorLockFactoryReset event, Emitter<DoorLockState> emit) async {
emit(DoorLockStatusLoading()); emit(DoorLockStatusLoading());
try { try {

View File

@ -8,7 +8,7 @@ import 'package:syncrow_web/pages/device_managment/door_lock/models/door_lock_st
import 'package:syncrow_web/utils/color_manager.dart'; import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/utils/constants/assets.dart'; import 'package:syncrow_web/utils/constants/assets.dart';
class DoorLockButton extends StatefulWidget { class DoorLockButton extends StatelessWidget {
const DoorLockButton({ const DoorLockButton({
super.key, super.key,
required this.doorLock, required this.doorLock,
@ -18,70 +18,28 @@ class DoorLockButton extends StatefulWidget {
final AllDevicesModel doorLock; final AllDevicesModel doorLock;
final DoorLockStatusModel smartDoorModel; final DoorLockStatusModel smartDoorModel;
@override double _calculateProgress() {
State<DoorLockButton> createState() => final value = smartDoorModel.unlockRequest;
_DoorLockButtonState(smartDoorModel: smartDoorModel); if (value <= 0 || value > 30) return 0;
} return value / 30.0;
class _DoorLockButtonState extends State<DoorLockButton>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _animation;
DoorLockStatusModel smartDoorModel;
_DoorLockButtonState({required this.smartDoorModel});
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
);
_animation = Tween<double>(begin: 0, end: 1).animate(_animationController)
..addListener(() {
setState(() {});
});
if (smartDoorModel.unlockRequest > 0) {
_animationController.reverse(from: 1);
}
}
@override
void didUpdateWidget(covariant DoorLockButton oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.smartDoorModel.normalOpenSwitch !=
widget.smartDoorModel.normalOpenSwitch) {
setState(() {
smartDoorModel = widget.smartDoorModel;
});
if (smartDoorModel.unlockRequest > 0) {
_animationController.forward(from: 0);
} else {
_animationController.reverse(from: 1);
}
}
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final progress = _calculateProgress();
final isEnabled = smartDoorModel.unlockRequest > 0;
return SizedBox( return SizedBox(
width: 255, width: 255,
height: 255, height: 255,
child: InkWell( child: InkWell(
onTap: () { onTap: isEnabled
_animationController.forward(from: 0); ? () {
BlocProvider.of<DoorLockBloc>(context) BlocProvider.of<DoorLockBloc>(context).add(
.add(UpdateLockEvent(value: !smartDoorModel.normalOpenSwitch)); UpdateLockEvent(value: !smartDoorModel.normalOpenSwitch),
}, );
}
: null,
child: Container( child: Container(
width: 255, width: 255,
height: 255, height: 255,
@ -115,15 +73,16 @@ class _DoorLockButtonState extends State<DoorLockButton>
), ),
), ),
), ),
SizedBox.expand( if (progress > 0)
child: CircularProgressIndicator( SizedBox.expand(
value: _animation.value, child: CircularProgressIndicator(
strokeWidth: 8, value: progress,
backgroundColor: Colors.transparent, strokeWidth: 8,
valueColor: const AlwaysStoppedAnimation<Color>( backgroundColor: Colors.transparent,
ColorsManager.primaryColor), valueColor: const AlwaysStoppedAnimation<Color>(
ColorsManager.primaryColor),
),
), ),
),
], ],
), ),
), ),

View File

@ -58,7 +58,9 @@ class CurtainHelper {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const DialogHeader('AC Functions'), DialogHeader(dialogType == 'THEN'
? 'Curtain Functions'
: 'Curtain Conditions'),
Expanded( Expanded(
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,

View File

@ -0,0 +1,6 @@
class SpaceConnectionModel {
final String from;
final String to;
const SpaceConnectionModel({required this.from, required this.to});
}

View File

@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:syncrow_web/pages/space_management_v2/main_module/models/space_connection_model.dart';
import 'package:syncrow_web/utils/color_manager.dart';
class SpacesConnectionsArrowPainter extends CustomPainter {
final List<SpaceConnectionModel> connections;
final Map<String, Offset> positions;
final double cardWidth = 150.0;
final double cardHeight = 90.0;
final String? selectedSpaceUuid;
SpacesConnectionsArrowPainter({
required this.connections,
required this.positions,
this.selectedSpaceUuid,
});
@override
void paint(Canvas canvas, Size size) {
for (final connection in connections) {
final isSelected = connection.to == selectedSpaceUuid;
final paint = Paint()
..color = isSelected
? ColorsManager.primaryColor
: ColorsManager.blackColor.withValues(alpha: 0.5)
..strokeWidth = 2.0
..style = PaintingStyle.stroke;
final from = positions[connection.from];
final to = positions[connection.to];
if (from != null && to != null) {
final startPoint =
Offset(from.dx + cardWidth / 2, from.dy + cardHeight - 10);
final endPoint = Offset(to.dx + cardWidth / 2, to.dy);
final path = Path()..moveTo(startPoint.dx, startPoint.dy);
final controlPoint1 = Offset(startPoint.dx, startPoint.dy + 60);
final controlPoint2 = Offset(endPoint.dx, endPoint.dy - 60);
path.cubicTo(controlPoint1.dx, controlPoint1.dy, controlPoint2.dx,
controlPoint2.dy, endPoint.dx, endPoint.dy);
canvas.drawPath(path, paint);
final circlePaint = Paint()
..color = isSelected
? ColorsManager.primaryColor
: ColorsManager.blackColor.withValues(alpha: 0.5)
..style = PaintingStyle.fill
..blendMode = BlendMode.srcIn;
canvas.drawCircle(endPoint, 4, circlePaint);
}
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

View File

@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/bloc/communities_bloc.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/communities_tree_selection_bloc/communities_tree_selection_bloc.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/create_community/presentation/create_community_dialog.dart';
abstract final class SpaceManagementCommunityDialogHelper {
static void showCreateDialog(BuildContext context) {
showDialog<void>(
context: context,
builder: (_) => CreateCommunityDialog(
title: const SelectableText('Community Name'),
onCreateCommunity: (community) {
context.read<CommunitiesBloc>().add(
InsertCommunity(community),
);
context.read<CommunitiesTreeSelectionBloc>().add(
SelectCommunityEvent(community: community),
);
},
),
);
}
}

View File

@ -0,0 +1,43 @@
import 'package:equatable/equatable.dart';
class PaginatedDataModel<T> extends Equatable {
const PaginatedDataModel({
required this.data,
required this.page,
required this.size,
required this.hasNext,
required this.totalItems,
required this.totalPages,
});
final List<T> data;
final int page;
final int size;
final bool hasNext;
final int totalItems;
final int totalPages;
factory PaginatedDataModel.fromJson(
Map<String, dynamic> json,
List<T> Function(List<dynamic>) fromJsonList,
) {
return PaginatedDataModel<T>(
data: fromJsonList(json['data'] as List<dynamic>),
page: json['page'] as int? ?? 1,
size: json['size'] as int? ?? 25,
hasNext: json['hasNext'] as bool? ?? false,
totalItems: json['totalItem'] as int? ?? 0,
totalPages: json['totalPage'] as int? ?? 0,
);
}
@override
List<Object?> get props => [
data,
page,
size,
hasNext,
totalItems,
totalPages,
];
}

View File

@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/device_managment/shared/navigate_home_grid_view.dart';
import 'package:syncrow_web/pages/space_management_v2/main_module/widgets/space_management_body.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/data/services/debounced_communities_service.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/data/services/remote_communities_service.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/params/load_communities_param.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/bloc/communities_bloc.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/communities_tree_selection_bloc/communities_tree_selection_bloc.dart';
import 'package:syncrow_web/services/api/http_service.dart';
import 'package:syncrow_web/utils/theme/responsive_text_theme.dart';
import 'package:syncrow_web/web_layout/web_scaffold.dart';
class SpaceManagementPage extends StatelessWidget {
const SpaceManagementPage({super.key});
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => CommunitiesBloc(
communitiesService: DebouncedCommunitiesService(
RemoteCommunitiesService(HTTPService()),
),
)..add(const LoadCommunities(LoadCommunitiesParam())),
),
BlocProvider(create: (context) => CommunitiesTreeSelectionBloc()),
],
child: WebScaffold(
appBarTitle: Text(
'Space Management',
style: ResponsiveTextTheme.of(context).deviceManagementTitle,
),
enableMenuSidebar: false,
centerBody: Text(
'Community Structure',
style: Theme.of(context).textTheme.bodyLarge!.copyWith(
fontWeight: FontWeight.bold,
),
),
rightBody: const NavigateHomeGridView(),
scaffoldBody: const SpaceManagementBody(),
),
);
}
}

View File

@ -0,0 +1,236 @@
import 'package:flutter/material.dart';
import 'package:syncrow_web/pages/space_management_v2/main_module/models/space_connection_model.dart';
import 'package:syncrow_web/pages/space_management_v2/main_module/painters/spaces_connections_arrow_painter.dart';
import 'package:syncrow_web/pages/space_management_v2/main_module/widgets/space_card_widget.dart';
import 'package:syncrow_web/pages/space_management_v2/main_module/widgets/space_cell.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/models/community_model.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/models/space_model.dart';
class CommunityStructureCanvas extends StatefulWidget {
const CommunityStructureCanvas({
required this.community,
super.key,
});
final CommunityModel community;
@override
State<CommunityStructureCanvas> createState() =>_CommunityStructureCanvasState();
}
class _CommunityStructureCanvasState extends State<CommunityStructureCanvas>
with SingleTickerProviderStateMixin {
final Map<String, Offset> _positions = {};
final double _cardWidth = 150.0;
final double _cardHeight = 90.0;
final double _horizontalSpacing = 150.0;
final double _verticalSpacing = 120.0;
String? _selectedSpaceUuid;
late TransformationController _transformationController;
late AnimationController _animationController;
@override
void initState() {
super.initState();
_transformationController = TransformationController();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 100),
);
}
@override
void dispose() {
_transformationController.dispose();
_animationController.dispose();
super.dispose();
}
void _runAnimation(Matrix4 target) {
final animation = Matrix4Tween(
begin: _transformationController.value,
end: target,
).animate(_animationController);
void listener() {
_transformationController.value = animation.value;
}
animation.addListener(listener);
_animationController.forward(from: 0).whenCompleteOrCancel(() {
animation.removeListener(listener);
});
}
void _onSpaceTapped(String spaceUuid) {
setState(() {
_selectedSpaceUuid = spaceUuid;
});
final position = _positions[spaceUuid];
if (position == null) return;
const scale = 2.0;
final viewSize = context.size;
if (viewSize == null) return;
final x = -position.dx * scale + (viewSize.width / 2) - (_cardWidth * scale / 2);
final y =
-position.dy * scale + (viewSize.height / 2) - (_cardHeight * scale / 2);
final matrix = Matrix4.identity()
..translate(x, y)
..scale(scale);
_runAnimation(matrix);
}
void _resetSelectionAndZoom() {
setState(() {
_selectedSpaceUuid = null;
});
_runAnimation(Matrix4.identity());
}
void _calculateLayout(
List<SpaceModel> spaces,
int depth,
Map<int, double> levelXOffset,
) {
for (final space in spaces) {
double childSubtreeWidth = 0;
if (space.children.isNotEmpty) {
_calculateLayout(space.children, depth + 1, levelXOffset);
final firstChildPos = _positions[space.children.first.uuid];
final lastChildPos = _positions[space.children.last.uuid];
if (firstChildPos != null && lastChildPos != null) {
childSubtreeWidth = (lastChildPos.dx + _cardWidth) - firstChildPos.dx;
}
}
final currentX = levelXOffset.putIfAbsent(depth, () => 0.0);
double? x;
if (space.children.isNotEmpty) {
final firstChildPos = _positions[space.children.first.uuid]!;
x = firstChildPos.dx + (childSubtreeWidth - _cardWidth) / 2;
} else {
x = currentX;
}
if (x < currentX) {
final shiftX = currentX - x;
_shiftSubtree(space, shiftX);
final keysToShift = levelXOffset.keys.where((d) => d > depth).toList();
for (final key in keysToShift) {
levelXOffset[key] = levelXOffset[key]! + shiftX;
}
x += shiftX;
}
final y = depth * (_verticalSpacing + _cardHeight);
_positions[space.uuid] = Offset(x, y);
levelXOffset[depth] = x + _cardWidth + _horizontalSpacing;
}
}
void _shiftSubtree(SpaceModel space, double shiftX) {
if (_positions.containsKey(space.uuid)) {
_positions[space.uuid] = _positions[space.uuid]!.translate(shiftX, 0);
}
for (final child in space.children) {
_shiftSubtree(child, shiftX);
}
}
List<Widget> _buildTreeWidgets() {
_positions.clear();
final community = widget.community;
_calculateLayout(community.spaces, 0, {});
final widgets = <Widget>[];
final connections = <SpaceConnectionModel>[];
_generateWidgets(community.spaces, widgets, connections);
return [
CustomPaint(
painter: SpacesConnectionsArrowPainter(
connections: connections,
positions: _positions,
selectedSpaceUuid: _selectedSpaceUuid,
),
child: Stack(alignment: AlignmentDirectional.center, children: widgets),
),
];
}
void _generateWidgets(
List<SpaceModel> spaces,
List<Widget> widgets,
List<SpaceConnectionModel> connections,
) {
for (final space in spaces) {
final position = _positions[space.uuid];
if (position == null) continue;
widgets.add(
Positioned(
left: position.dx,
top: position.dy,
width: _cardWidth,
height: _cardHeight,
child: SpaceCardWidget(
index: spaces.indexOf(space),
onPositionChanged: (newPosition) {},
buildSpaceContainer: (index) {
return Opacity(
opacity: 1.0,
child: SpaceCell(
index: index,
onTap: () => _onSpaceTapped(space.uuid),
icon: space.icon,
name: space.spaceName,
),
);
},
screenSize: MediaQuery.sizeOf(context),
position: position,
isHovered: false,
onHoverChanged: (int index, bool isHovered) {},
onButtonTap: (int index, Offset newPosition) {},
),
),
);
for (final child in space.children) {
connections.add(SpaceConnectionModel(from: space.uuid, to: child.uuid));
}
_generateWidgets(space.children, widgets, connections);
}
}
@override
Widget build(BuildContext context) {
final treeWidgets = _buildTreeWidgets();
return InteractiveViewer(
transformationController: _transformationController,
boundaryMargin: EdgeInsets.symmetric(
horizontal: MediaQuery.sizeOf(context).width * 0.3,
vertical: MediaQuery.sizeOf(context).height * 0.2,
),
minScale: 0.5,
maxScale: 3.0,
constrained: false,
child: GestureDetector(
onTap: _resetSelectionAndZoom,
child: SizedBox(
width: MediaQuery.sizeOf(context).width * 2,
height: MediaQuery.sizeOf(context).height * 2,
child: Stack(children: treeWidgets),
),
),
);
}
}

View File

@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart';
class CommunityTemplateCell extends StatelessWidget {
const CommunityTemplateCell({
super.key,
required this.onTap,
required this.title,
});
final void Function() onTap;
final Widget title;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Column(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: AspectRatio(
aspectRatio: 2.0,
child: Container(
decoration: ShapeDecoration(
shape: RoundedRectangleBorder(
side: const BorderSide(
width: 4,
strokeAlign: BorderSide.strokeAlignOutside,
color: ColorsManager.borderColor,
),
borderRadius: BorderRadius.circular(5),
),
),
),
),
),
DefaultTextStyle(
style: context.textTheme.bodyLarge!.copyWith(
color: ColorsManager.blackColor,
),
child: title,
),
],
),
);
}
}

View File

@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import 'package:syncrow_web/utils/color_manager.dart';
class CreateSpaceButton extends StatelessWidget {
const CreateSpaceButton({super.key});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {},
child: Container(
height: 60,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.grey.withValues(alpha: 0.5),
spreadRadius: 5,
blurRadius: 7,
offset: const Offset(0, 3),
),
],
),
child: Center(
child: Container(
width: 40,
height: 40,
decoration: const BoxDecoration(
color: ColorsManager.boxColor,
shape: BoxShape.circle,
),
child: const Icon(
Icons.add,
color: Colors.blue,
),
),
),
),
);
}
}

View File

@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import 'package:syncrow_web/utils/color_manager.dart';
class PlusButtonWidget extends StatelessWidget {
final int index;
final String direction;
final Offset offset;
final void Function(int index, Offset newPosition) onButtonTap;
const PlusButtonWidget({
super.key,
required this.index,
required this.direction,
required this.offset,
required this.onButtonTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
if (direction == 'down') {
onButtonTap(index, const Offset(0, 150));
} else {
onButtonTap(index, const Offset(150, 0));
}
},
child: Container(
width: 30,
height: 30,
decoration: const BoxDecoration(
color: ColorsManager.spaceColor,
shape: BoxShape.circle,
),
child: const Icon(
Icons.add,
color: ColorsManager.whiteColors,
size: 20,
),
),
);
}
}

View File

@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:syncrow_web/pages/space_management_v2/main_module/widgets/plus_button_widget.dart';
class SpaceCardWidget extends StatelessWidget {
final int index;
final Size screenSize;
final Offset position;
final bool isHovered;
final void Function(int index, bool isHovered) onHoverChanged;
final void Function(int index, Offset newPosition) onButtonTap;
final Widget Function(int index) buildSpaceContainer;
final ValueChanged<Offset> onPositionChanged;
const SpaceCardWidget({
super.key,
required this.index,
required this.onPositionChanged,
required this.screenSize,
required this.position,
required this.isHovered,
required this.onHoverChanged,
required this.onButtonTap,
required this.buildSpaceContainer,
});
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => onHoverChanged(index, true),
onExit: (_) => onHoverChanged(index, false),
child: SizedBox(
width: 150,
height: 90,
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
buildSpaceContainer(index),
if (isHovered)
Positioned(
bottom: 0,
child: PlusButtonWidget(
index: index,
direction: 'down',
offset: Offset.zero,
onButtonTap: onButtonTap,
),
),
if (isHovered)
Positioned(
right: -15,
child: PlusButtonWidget(
index: index,
direction: 'right',
offset: Offset.zero,
onButtonTap: onButtonTap,
),
),
],
),
),
);
}
}

View File

@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import 'package:syncrow_web/utils/color_manager.dart';
class SpaceCell extends StatelessWidget {
final int index;
final String icon;
final String name;
final VoidCallback? onDoubleTap;
final VoidCallback? onTap;
const SpaceCell({
super.key,
required this.index,
required this.icon,
required this.name,
this.onTap,
this.onDoubleTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return GestureDetector(
onDoubleTap: onDoubleTap,
onTap: onTap,
child: Container(
width: 150,
height: 70,
decoration: _containerDecoration(),
child: Row(
children: [
_buildIconContainer(),
const SizedBox(width: 10),
Expanded(
child: Text(
name,
style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold,
color: ColorsManager.blackColor,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
Widget _buildIconContainer() {
return Container(
width: 40,
height: double.infinity,
decoration: const BoxDecoration(
color: ColorsManager.spaceColor,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(15),
bottomLeft: Radius.circular(15),
),
),
child: Center(
child: SvgPicture.asset(
icon,
color: ColorsManager.whiteColors,
width: 24,
height: 24,
),
),
);
}
BoxDecoration _containerDecoration() {
return BoxDecoration(
color: ColorsManager.whiteColors,
borderRadius: BorderRadius.circular(15),
boxShadow: [
BoxShadow(
color: ColorsManager.lightGrayColor.withValues(alpha: 0.5),
spreadRadius: 2,
blurRadius: 5,
offset: const Offset(0, 3),
),
],
);
}
}

View File

@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/space_management_v2/main_module/widgets/space_management_community_structure.dart';
import 'package:syncrow_web/pages/space_management_v2/main_module/widgets/space_management_templates_view.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/communities_tree_selection_bloc/communities_tree_selection_bloc.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/widgets/space_management_communities_tree.dart';
class SpaceManagementBody extends StatelessWidget {
const SpaceManagementBody({super.key});
@override
Widget build(BuildContext context) {
return Row(
children: [
const SpaceManagementCommunitiesTree(),
Expanded(
child: BlocBuilder<CommunitiesTreeSelectionBloc,
CommunitiesTreeSelectionState>(
buildWhen: (previous, current) =>
previous.selectedCommunity != current.selectedCommunity,
builder: (context, state) => Visibility(
visible: state.selectedCommunity == null,
replacement: const SpaceManagementCommunityStructure(),
child: const SpaceManagementTemplatesView(),
),
),
),
],
);
}
}

View File

@ -0,0 +1,22 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/space_management_v2/main_module/widgets/community_structure_canvas.dart';
import 'package:syncrow_web/pages/space_management_v2/main_module/widgets/create_space_button.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/communities_tree_selection_bloc/communities_tree_selection_bloc.dart';
class SpaceManagementCommunityStructure extends StatelessWidget {
const SpaceManagementCommunityStructure({super.key});
@override
Widget build(BuildContext context) {
final selectedCommunity =
context.watch<CommunitiesTreeSelectionBloc>().state.selectedCommunity!;
const spacer = Spacer(flex: 10);
return Visibility(
visible: selectedCommunity.spaces.isNotEmpty,
replacement: const Row(
children: [spacer, Expanded(child: CreateSpaceButton()), spacer]),
child: CommunityStructureCanvas(community: selectedCommunity),
);
}
}

View File

@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:syncrow_web/pages/space_management_v2/main_module/shared/helpers/space_management_community_dialog_helper.dart';
import 'package:syncrow_web/pages/space_management_v2/main_module/widgets/community_template_cell.dart';
import 'package:syncrow_web/utils/color_manager.dart';
class SpaceManagementTemplatesView extends StatelessWidget {
const SpaceManagementTemplatesView({super.key});
@override
Widget build(BuildContext context) {
return Expanded(
child: ColoredBox(
color: ColorsManager.whiteColors,
child: GridView.builder(
padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 20),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 400,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childAspectRatio: 2.0,
),
itemCount: _gridItems(context).length,
itemBuilder: (context, index) {
final model = _gridItems(context)[index];
return CommunityTemplateCell(
onTap: model.onTap,
title: model.title,
);
},
),
),
);
}
List<_CommunityTemplateModel> _gridItems(BuildContext context) {
return [
_CommunityTemplateModel(
title: const Text('Blank'),
onTap: () => SpaceManagementCommunityDialogHelper.showCreateDialog(context),
),
];
}
}
class _CommunityTemplateModel {
final Widget title;
final void Function() onTap;
_CommunityTemplateModel({
required this.title,
required this.onTap,
});
}

View File

@ -0,0 +1,42 @@
import 'dart:async';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/params/load_communities_param.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/services/communities_service.dart';
final class DebouncedCommunitiesService implements CommunitiesService {
DebouncedCommunitiesService(
this._decoratee, {
this.debounceDuration = const Duration(milliseconds: 500),
});
final CommunitiesService _decoratee;
final Duration debounceDuration;
Timer? _debounceTimer;
late Completer<CommunitiesPaginationModel>? _completer;
@override
Future<CommunitiesPaginationModel> getCommunity(
LoadCommunitiesParam param,
) async {
_debounceTimer?.cancel();
_completer = Completer<CommunitiesPaginationModel>();
final currentCompleter = _completer!;
_debounceTimer = Timer(debounceDuration, () async {
try {
final result = await _decoratee.getCommunity(param);
if (!currentCompleter.isCompleted) {
currentCompleter.complete(result);
}
} catch (error) {
if (!currentCompleter.isCompleted) {
currentCompleter.completeError(error);
}
}
});
return currentCompleter.future;
}
}

View File

@ -1,9 +1,11 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:syncrow_web/pages/common/bloc/project_manager.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/models/community_model.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/models/community_model.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/params/load_communities_param.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/params/load_communities_param.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/services/communities_service.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/services/communities_service.dart';
import 'package:syncrow_web/services/api/api_exception.dart'; import 'package:syncrow_web/services/api/api_exception.dart';
import 'package:syncrow_web/services/api/http_service.dart'; import 'package:syncrow_web/services/api/http_service.dart';
import 'package:syncrow_web/utils/constants/api_const.dart';
class RemoteCommunitiesService implements CommunitiesService { class RemoteCommunitiesService implements CommunitiesService {
const RemoteCommunitiesService(this._httpService); const RemoteCommunitiesService(this._httpService);
@ -13,14 +15,26 @@ class RemoteCommunitiesService implements CommunitiesService {
static const _defaultErrorMessage = 'Failed to load communities'; static const _defaultErrorMessage = 'Failed to load communities';
@override @override
Future<List<CommunityModel>> getCommunity(LoadCommunitiesParam param) async { Future<CommunitiesPaginationModel> getCommunity(
LoadCommunitiesParam param,
) async {
try { try {
return _httpService.get( final response = await _httpService.get(
path: '/api/communities/', path: await _makeUrl(),
expectedResponseModel: (json) => (json as List<dynamic>) queryParameters: {
.map((e) => CommunityModel.fromJson(e as Map<String, dynamic>)) 'page': param.page,
.toList(), 'size': param.size,
'includeSpaces': param.includeSpaces,
if (param.search.isNotEmpty && param.search != 'null')
'search': param.search,
},
expectedResponseModel: (json) => CommunitiesPaginationModel.fromJson(
json as Map<String, dynamic>,
CommunityModel.fromJsonList,
),
); );
return response;
} on DioException catch (e) { } on DioException catch (e) {
final message = e.response?.data as Map<String, dynamic>?; final message = e.response?.data as Map<String, dynamic>?;
final error = message?['error'] as Map<String, dynamic>?; final error = message?['error'] as Map<String, dynamic>?;
@ -31,4 +45,13 @@ class RemoteCommunitiesService implements CommunitiesService {
throw APIException(formattedErrorMessage); throw APIException(formattedErrorMessage);
} }
} }
Future<String> _makeUrl() async {
final projectUuid = await ProjectManager.getProjectUUID();
if (projectUuid == null) throw APIException('Project UUID is required');
return ApiEndpoints.getCommunityListv2.replaceAll(
'{projectId}',
projectUuid,
);
}
} }

View File

@ -4,11 +4,19 @@ import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain
class CommunityModel extends Equatable { class CommunityModel extends Equatable {
final String uuid; final String uuid;
final String name; final String name;
final DateTime createdAt;
final DateTime updatedAt;
final String description;
final String externalId;
final List<SpaceModel> spaces; final List<SpaceModel> spaces;
const CommunityModel({ const CommunityModel({
required this.uuid, required this.uuid,
required this.name, required this.name,
required this.createdAt,
required this.updatedAt,
required this.description,
required this.externalId,
required this.spaces, required this.spaces,
}); });
@ -16,11 +24,20 @@ class CommunityModel extends Equatable {
return CommunityModel( return CommunityModel(
uuid: json['uuid'] as String, uuid: json['uuid'] as String,
name: json['name'] as String, name: json['name'] as String,
spaces: (json['spaces'] as List<dynamic>) createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: DateTime.parse(json['updatedAt'] as String),
description: json['description'] as String,
externalId: json['externalId']?.toString() ?? '',
spaces: (json['spaces'] as List<dynamic>? ?? <dynamic>[])
.map((e) => SpaceModel.fromJson(e as Map<String, dynamic>)) .map((e) => SpaceModel.fromJson(e as Map<String, dynamic>))
.toList(), .toList(),
); );
} }
static List<CommunityModel> fromJsonList(List<dynamic> json) {
return json
.map((e) => CommunityModel.fromJson(e as Map<String, dynamic>))
.toList();
}
@override @override
List<Object?> get props => [uuid, name, spaces]; List<Object?> get props => [uuid, name, spaces];

View File

@ -2,26 +2,37 @@ import 'package:equatable/equatable.dart';
class SpaceModel extends Equatable { class SpaceModel extends Equatable {
final String uuid; final String uuid;
final DateTime? createdAt;
final DateTime? updatedAt;
final String spaceName; final String spaceName;
final String icon; final String icon;
final List<SpaceModel> children; final List<SpaceModel> children;
final SpaceModel? parent;
const SpaceModel({ const SpaceModel({
required this.uuid, required this.uuid,
required this.createdAt,
required this.updatedAt,
required this.spaceName, required this.spaceName,
required this.icon, required this.icon,
required this.children, required this.children,
required this.parent,
}); });
factory SpaceModel.fromJson(Map<String, dynamic> json) { factory SpaceModel.fromJson(Map<String, dynamic> json) {
return SpaceModel( return SpaceModel(
uuid: json['uuid'] as String, uuid: json['uuid'] as String? ?? '',
spaceName: json['spaceName'] as String, createdAt: DateTime.tryParse(json['createdAt'] as String? ?? ''),
icon: json['icon'] as String, updatedAt: DateTime.tryParse(json['updatedAt'] as String? ?? ''),
spaceName: json['spaceName'] as String? ?? '',
icon: json['icon'] as String? ?? 'assets/icons/location_icon.svg',
children: (json['children'] as List<dynamic>?) children: (json['children'] as List<dynamic>?)
?.map((e) => SpaceModel.fromJson(e as Map<String, dynamic>)) ?.map((e) => SpaceModel.fromJson(e as Map<String, dynamic>))
.toList() ?? .toList() ??
[], [],
parent: json['parent'] != null
? SpaceModel.fromJson(json['parent'] as Map<String, dynamic>)
: null,
); );
} }

View File

@ -1,3 +1,32 @@
class LoadCommunitiesParam { import 'package:equatable/equatable.dart';
const LoadCommunitiesParam();
class LoadCommunitiesParam extends Equatable {
const LoadCommunitiesParam({
this.page = 1,
this.size = 25,
this.search = '',
this.includeSpaces = true,
});
final int page;
final int size;
final String search;
final bool includeSpaces;
LoadCommunitiesParam copyWith({
int? page,
int? size,
String? search,
bool? includeSpaces,
}) {
return LoadCommunitiesParam(
page: page ?? this.page,
size: size ?? this.size,
search: search ?? this.search,
includeSpaces: includeSpaces ?? this.includeSpaces,
);
}
@override
List<Object?> get props => [page, size, search, includeSpaces];
} }

View File

@ -1,6 +1,9 @@
import 'package:syncrow_web/pages/space_management_v2/main_module/shared/models/paginated_data_model.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/models/community_model.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/models/community_model.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/params/load_communities_param.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/params/load_communities_param.dart';
typedef CommunitiesPaginationModel = PaginatedDataModel<CommunityModel>;
abstract class CommunitiesService { abstract class CommunitiesService {
Future<List<CommunityModel>> getCommunity(LoadCommunitiesParam param); Future<CommunitiesPaginationModel> getCommunity(LoadCommunitiesParam param);
} }

View File

@ -14,6 +14,8 @@ class CommunitiesBloc extends Bloc<CommunitiesEvent, CommunitiesState> {
}) : _communitiesService = communitiesService, }) : _communitiesService = communitiesService,
super(const CommunitiesState()) { super(const CommunitiesState()) {
on<LoadCommunities>(_onLoadCommunities); on<LoadCommunities>(_onLoadCommunities);
on<LoadMoreCommunities>(_onLoadMoreCommunities);
on<InsertCommunity>(_onInsertCommunity);
} }
final CommunitiesService _communitiesService; final CommunitiesService _communitiesService;
@ -23,28 +25,93 @@ class CommunitiesBloc extends Bloc<CommunitiesEvent, CommunitiesState> {
Emitter<CommunitiesState> emit, Emitter<CommunitiesState> emit,
) async { ) async {
try { try {
emit(const CommunitiesState(status: CommunitiesStatus.loading)); emit(
final communities = await _communitiesService.getCommunity(event.param); state.copyWith(status: CommunitiesStatus.loading),
);
final paginationResponse = await _communitiesService.getCommunity(
event.param,
);
emit( emit(
CommunitiesState( CommunitiesState(
status: CommunitiesStatus.success, status: CommunitiesStatus.success,
communities: communities, communities: paginationResponse.data,
hasNext: paginationResponse.hasNext,
currentPage: paginationResponse.page,
searchQuery: event.param.search,
isLoadingMore: false,
), ),
); );
} on APIException catch (e) { } on APIException catch (e) {
emit( _onApiException(e, emit);
CommunitiesState(
status: CommunitiesStatus.failure,
errorMessage: e.message,
),
);
} catch (e) { } catch (e) {
emit( _onError(e, emit);
CommunitiesState(
status: CommunitiesStatus.failure,
errorMessage: e.toString(),
),
);
} }
} }
Future<void> _onLoadMoreCommunities(
LoadMoreCommunities event,
Emitter<CommunitiesState> emit,
) async {
if (!state.hasNext || state.isLoadingMore) return;
try {
emit(state.copyWith(isLoadingMore: true));
final param = LoadCommunitiesParam(
page: state.currentPage + 1,
search: state.searchQuery,
);
final paginationResponse = await _communitiesService.getCommunity(param);
final updatedCommunities = List<CommunityModel>.from(state.communities)
..addAll(paginationResponse.data);
emit(
state.copyWith(
status: CommunitiesStatus.success,
communities: updatedCommunities,
hasNext: paginationResponse.hasNext,
currentPage: paginationResponse.page,
isLoadingMore: false,
),
);
} on APIException catch (e) {
_onApiException(e, emit);
} catch (e) {
_onError(e, emit);
}
}
void _onApiException(
APIException e,
Emitter<CommunitiesState> emit,
) {
emit(
state.copyWith(
status: CommunitiesStatus.failure,
isLoadingMore: false,
errorMessage: e.message,
),
);
}
void _onError(Object e, Emitter<CommunitiesState> emit) {
emit(
state.copyWith(
status: CommunitiesStatus.failure,
isLoadingMore: false,
errorMessage: e.toString(),
),
);
}
void _onInsertCommunity(
InsertCommunity event,
Emitter<CommunitiesState> emit,
) {
emit(state.copyWith(communities: [event.community, ...state.communities]));
}
} }

View File

@ -15,3 +15,19 @@ class LoadCommunities extends CommunitiesEvent {
@override @override
List<Object?> get props => [param]; List<Object?> get props => [param];
} }
class LoadMoreCommunities extends CommunitiesEvent {
const LoadMoreCommunities();
@override
List<Object?> get props => [];
}
final class InsertCommunity extends CommunitiesEvent {
const InsertCommunity(this.community);
final CommunityModel community;
@override
List<Object?> get props => [community];
}

View File

@ -7,12 +7,48 @@ final class CommunitiesState extends Equatable {
this.status = CommunitiesStatus.initial, this.status = CommunitiesStatus.initial,
this.communities = const [], this.communities = const [],
this.errorMessage, this.errorMessage,
this.isLoadingMore = false,
this.hasNext = false,
this.currentPage = 1,
this.searchQuery = '',
}); });
final CommunitiesStatus status; final CommunitiesStatus status;
final List<CommunityModel> communities; final List<CommunityModel> communities;
final String? errorMessage; final String? errorMessage;
final bool isLoadingMore;
final bool hasNext;
final int currentPage;
final String searchQuery;
CommunitiesState copyWith({
CommunitiesStatus? status,
List<CommunityModel>? communities,
String? errorMessage,
bool? isLoadingMore,
bool? hasNext,
int? currentPage,
String? searchQuery,
}) {
return CommunitiesState(
status: status ?? this.status,
communities: communities ?? this.communities,
errorMessage: errorMessage ?? this.errorMessage,
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
hasNext: hasNext ?? this.hasNext,
currentPage: currentPage ?? this.currentPage,
searchQuery: searchQuery ?? this.searchQuery,
);
}
@override @override
List<Object?> get props => [status, communities, errorMessage]; List<Object?> get props => [
status,
communities,
errorMessage,
isLoadingMore,
hasNext,
currentPage,
searchQuery,
];
} }

View File

@ -0,0 +1,47 @@
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/models/community_model.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/models/space_model.dart';
part 'communities_tree_selection_event.dart';
part 'communities_tree_selection_state.dart';
class CommunitiesTreeSelectionBloc
extends Bloc<CommunitiesTreeSelectionEvent, CommunitiesTreeSelectionState> {
CommunitiesTreeSelectionBloc() : super(const CommunitiesTreeSelectionState()) {
on<SelectCommunityEvent>(_onSelectCommunity);
on<SelectSpaceEvent>(_onSelectSpace);
on<ClearCommunitiesTreeSelectionEvent>(_onClearSelection);
}
void _onSelectCommunity(
SelectCommunityEvent event,
Emitter<CommunitiesTreeSelectionState> emit,
) {
emit(
CommunitiesTreeSelectionState(
selectedCommunity: event.community,
selectedSpace: null,
),
);
}
void _onSelectSpace(
SelectSpaceEvent event,
Emitter<CommunitiesTreeSelectionState> emit,
) {
emit(
CommunitiesTreeSelectionState(
selectedCommunity: event.community,
selectedSpace: event.space,
),
);
}
void _onClearSelection(
ClearCommunitiesTreeSelectionEvent event,
Emitter<CommunitiesTreeSelectionState> emit,
) {
emit(const CommunitiesTreeSelectionState());
}
}

View File

@ -0,0 +1,31 @@
part of 'communities_tree_selection_bloc.dart';
sealed class CommunitiesTreeSelectionEvent extends Equatable {
const CommunitiesTreeSelectionEvent();
@override
List<Object?> get props => [];
}
final class SelectCommunityEvent extends CommunitiesTreeSelectionEvent {
final CommunityModel community;
const SelectCommunityEvent({required this.community});
@override
List<Object?> get props => [community];
}
final class SelectSpaceEvent extends CommunitiesTreeSelectionEvent {
final SpaceModel space;
final CommunityModel community;
const SelectSpaceEvent({required this.space, required this.community});
@override
List<Object?> get props => [space];
}
final class ClearCommunitiesTreeSelectionEvent
extends CommunitiesTreeSelectionEvent {
const ClearCommunitiesTreeSelectionEvent();
}

View File

@ -0,0 +1,29 @@
part of 'communities_tree_selection_bloc.dart';
final class CommunitiesTreeSelectionState extends Equatable {
const CommunitiesTreeSelectionState({
this.selectedCommunity,
this.selectedSpace,
});
final CommunityModel? selectedCommunity;
final SpaceModel? selectedSpace;
CommunitiesTreeSelectionState copyWith({
CommunityModel? selectedCommunity,
SpaceModel? selectedSpace,
List<CommunityModel>? expandedCommunities,
List<SpaceModel>? expandedSpaces,
}) {
return CommunitiesTreeSelectionState(
selectedCommunity: selectedCommunity ?? this.selectedCommunity,
selectedSpace: selectedSpace ?? this.selectedSpace,
);
}
@override
List<Object?> get props => [
selectedCommunity,
selectedSpace,
];
}

View File

@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/params/load_communities_param.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/bloc/communities_bloc.dart';
class CommunitiesTreeFailureWidget extends StatelessWidget {
const CommunitiesTreeFailureWidget({super.key, this.errorMessage});
final String? errorMessage;
@override
Widget build(BuildContext context) {
return Expanded(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
errorMessage ?? 'Something went wrong',
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => context.read<CommunitiesBloc>().add(
LoadCommunities(
LoadCommunitiesParam(
search: context.read<CommunitiesBloc>().state.searchQuery,
),
),
),
child: const Text('Retry'),
),
],
),
),
);
}
}

View File

@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:syncrow_web/common/widgets/custom_expansion_tile.dart';
class CommunityTile extends StatelessWidget {
final String title;
final List<Widget>? children;
final bool isExpanded;
final bool isSelected;
final void Function(String, bool isExpanded) onExpansionChanged;
final void Function() onItemSelected;
const CommunityTile({
super.key,
required this.title,
required this.isExpanded,
required this.onExpansionChanged,
required this.onItemSelected,
required this.isSelected,
this.children,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: CustomExpansionTile(
title: title,
initiallyExpanded: isExpanded,
isSelected: isSelected,
onExpansionChanged: (bool expanded) {
onExpansionChanged(title, expanded);
},
onItemSelected: onItemSelected,
children: children ?? [],
));
}
}

View File

@ -0,0 +1,22 @@
import 'package:flutter/material.dart';
class EmptyCommunitiesTreeSearchResultWidget extends StatelessWidget {
const EmptyCommunitiesTreeSearchResultWidget({
required this.searchQuery,
super.key,
});
final String searchQuery;
@override
Widget build(BuildContext context) {
return Center(
child: Text(
searchQuery.isEmpty
? 'No communities found'
: 'No communities found for "$searchQuery"',
textAlign: TextAlign.center,
),
);
}
}

View File

@ -0,0 +1,112 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/common/widgets/app_loading_indicator.dart';
import 'package:syncrow_web/common/widgets/search_bar.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/params/load_communities_param.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/bloc/communities_bloc.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/widgets/communities_tree_failure_widget.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/widgets/empty_communities_tree_search_result_widget.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/widgets/space_management_communities_tree_community_tile.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/widgets/space_management_sidebar_communities_list.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/widgets/space_management_sidebar_header.dart';
import 'package:syncrow_web/utils/style.dart';
class SpaceManagementCommunitiesTree extends StatefulWidget {
const SpaceManagementCommunitiesTree({super.key});
@override
State<SpaceManagementCommunitiesTree> createState() =>
_SpaceManagementCommunitiesTreeState();
}
class _SpaceManagementCommunitiesTreeState
extends State<SpaceManagementCommunitiesTree> {
@override
void initState() {
context.read<CommunitiesBloc>().add(
const LoadCommunities(LoadCommunitiesParam()),
);
super.initState();
}
void _onSearchChanged(String searchQuery) {
context
.read<CommunitiesBloc>()
.add(LoadCommunities(LoadCommunitiesParam(search: searchQuery.trim())));
}
void _onLoadMore() {
context.read<CommunitiesBloc>().add(const LoadMoreCommunities());
}
@override
Widget build(BuildContext context) {
return BlocBuilder<CommunitiesBloc, CommunitiesState>(
builder: (context, state) => Container(
width: 320,
decoration: subSectionContainerDecoration,
child: Column(
children: [
const SpaceManagementSidebarHeader(),
CustomSearchBar(
onSearchChanged: _onSearchChanged,
),
const SizedBox(height: 16),
switch (state.status) {
CommunitiesStatus.initial => const AppLoadingIndicator(),
CommunitiesStatus.loading => state.communities.isEmpty
? const AppLoadingIndicator()
: _buildCommunitiesTree(context, state),
CommunitiesStatus.success => _buildCommunitiesTree(context, state),
CommunitiesStatus.failure => CommunitiesTreeFailureWidget(
errorMessage: state.errorMessage,
),
},
Visibility(
visible: state.isLoadingMore,
child: const AppLoadingIndicator(),
),
],
),
),
);
}
Widget _buildCommunitiesTree(
BuildContext context,
CommunitiesState state,
) {
final communitiesIsEmpty = state.communities.isEmpty;
final statusIsSuccess = state.status == CommunitiesStatus.success;
return Expanded(
child: Visibility(
visible: statusIsSuccess && communitiesIsEmpty,
replacement: Stack(
children: [
SpaceManagementSidebarCommunitiesList(
communities: state.communities,
onLoadMore: state.hasNext ? _onLoadMore : null,
isLoadingMore: state.isLoadingMore,
hasNext: state.hasNext,
itemBuilder: (context, index) {
return SpaceManagementCommunitiesTreeCommunityTile(
community: state.communities[index],
);
},
),
if (state.status == CommunitiesStatus.loading &&
state.communities.isNotEmpty)
ColoredBox(
color: Colors.white.withValues(alpha: 0.7),
child: const AppLoadingIndicator(),
),
],
),
child: EmptyCommunitiesTreeSearchResultWidget(
searchQuery: state.searchQuery,
),
),
);
}
}

View File

@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/models/community_model.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/communities_tree_selection_bloc/communities_tree_selection_bloc.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/widgets/community_tile.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/widgets/space_management_communities_tree_space_tile.dart';
class SpaceManagementCommunitiesTreeCommunityTile extends StatelessWidget {
const SpaceManagementCommunitiesTreeCommunityTile({
required this.community,
super.key,
});
final CommunityModel community;
@override
Widget build(BuildContext context) {
final spaces = community.spaces
.map(
(space) => SpaceManagementCommunitiesTreeSpaceTile(
space: space,
community: community,
),
)
.toList();
return CommunityTile(
title: community.name,
key: ValueKey(community.uuid),
isSelected: context
.watch<CommunitiesTreeSelectionBloc>()
.state
.selectedCommunity
?.uuid ==
community.uuid,
isExpanded: false,
onItemSelected: () {
context.read<CommunitiesTreeSelectionBloc>().add(
SelectCommunityEvent(community: community),
);
},
onExpansionChanged: (title, expanded) {},
children: spaces,
);
}
}

View File

@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/models/community_model.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/models/space_model.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/communities_tree_selection_bloc/communities_tree_selection_bloc.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/widgets/space_tile.dart';
class SpaceManagementCommunitiesTreeSpaceTile extends StatelessWidget {
const SpaceManagementCommunitiesTreeSpaceTile({
required this.space,
required this.community,
super.key,
});
final SpaceModel space;
final CommunityModel community;
@override
Widget build(BuildContext context) {
final spaceIsExpanded = _isSpaceOrChildSelected(context, space);
final isSelected =
context.watch<CommunitiesTreeSelectionBloc>().state.selectedSpace?.uuid ==
space.uuid;
return Padding(
padding: const EdgeInsetsDirectional.only(start: 16.0),
child: SpaceTile(
title: space.spaceName,
key: ValueKey(space.uuid),
isSelected: isSelected,
initiallyExpanded: spaceIsExpanded,
onExpansionChanged: (expanded) {},
onItemSelected: () => context.read<CommunitiesTreeSelectionBloc>().add(
SelectSpaceEvent(community: community, space: space),
),
children: space.children
.map(
(childSpace) => SpaceManagementCommunitiesTreeSpaceTile(
space: childSpace,
community: community,
),
)
.toList(),
),
);
}
bool _isSpaceOrChildSelected(BuildContext context, SpaceModel space) {
final selectedSpace =
context.read<CommunitiesTreeSelectionBloc>().state.selectedSpace;
final isSpaceSelected = selectedSpace?.uuid == space.uuid;
final anySubSpaceIsSelected = space.children.any(
(child) => _isSpaceOrChildSelected(context, child),
);
return isSpaceSelected || anySubSpaceIsSelected;
}
}

View File

@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/utils/constants/assets.dart';
class SpaceManagementSidebarAddCommunityButton extends StatelessWidget {
const SpaceManagementSidebarAddCommunityButton({
required this.onTap,
super.key,
});
final void Function() onTap;
@override
Widget build(BuildContext context) {
return SizedBox.square(
dimension: 30,
child: IconButton(
style: IconButton.styleFrom(
iconSize: 20,
backgroundColor: ColorsManager.circleImageBackground,
shape: const CircleBorder(
side: BorderSide(
color: ColorsManager.lightGrayBorderColor,
width: 3,
),
),
),
onPressed: onTap,
icon: SvgPicture.asset(Assets.addIcon),
),
);
}
}

View File

@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/models/community_model.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart';
class SpaceManagementSidebarCommunitiesList extends StatefulWidget {
const SpaceManagementSidebarCommunitiesList({
required this.communities,
required this.itemBuilder,
this.onLoadMore,
this.isLoadingMore = false,
this.hasNext = false,
super.key,
});
final List<CommunityModel> communities;
final Widget Function(BuildContext context, int index) itemBuilder;
final VoidCallback? onLoadMore;
final bool isLoadingMore;
final bool hasNext;
@override
State<SpaceManagementSidebarCommunitiesList> createState() =>
_SpaceManagementSidebarCommunitiesListState();
}
class _SpaceManagementSidebarCommunitiesListState
extends State<SpaceManagementSidebarCommunitiesList> {
late final ScrollController _scrollController;
@override
void initState() {
super.initState();
_scrollController = ScrollController();
_scrollController.addListener(_onScroll);
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 100) {
if (widget.hasNext && !widget.isLoadingMore && widget.onLoadMore != null) {
widget.onLoadMore!();
}
}
}
bool _onNotification(ScrollEndNotification notification) {
final hasReachedEnd = notification.metrics.extentAfter == 0;
if (hasReachedEnd &&
widget.hasNext &&
!widget.isLoadingMore &&
widget.onLoadMore != null) {
widget.onLoadMore!();
return true;
}
return false;
}
@override
void dispose() {
_scrollController
..removeListener(_onScroll)
..dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final itemCount = widget.communities.length + (widget.isLoadingMore ? 1 : 0);
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SizedBox(
width: context.screenWidth * 0.5,
child: Scrollbar(
scrollbarOrientation: ScrollbarOrientation.left,
thumbVisibility: true,
controller: _scrollController,
child: NotificationListener<ScrollEndNotification>(
onNotification: _onNotification,
child: ListView.builder(
shrinkWrap: true,
padding: const EdgeInsetsDirectional.only(start: 16),
itemCount: itemCount,
controller: _scrollController,
itemBuilder: (context, index) {
if (index == widget.communities.length) {
return const Padding(
padding: EdgeInsets.all(16.0),
child: Center(
child: CircularProgressIndicator(),
),
);
}
return widget.itemBuilder(context, index);
},
),
),
),
),
);
}
}

View File

@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/space_management_v2/main_module/shared/helpers/space_management_community_dialog_helper.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/communities_tree_selection_bloc/communities_tree_selection_bloc.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/widgets/space_management_sidebar_add_community_button.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 SpaceManagementSidebarHeader extends StatelessWidget {
const SpaceManagementSidebarHeader({super.key});
@override
Widget build(BuildContext context) {
return Container(
decoration: subSectionContainerDecoration,
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Communities',
style: context.textTheme.titleMedium?.copyWith(
color: ColorsManager.blackColor,
),
),
SpaceManagementSidebarAddCommunityButton(
onTap: () => _onAddCommunity(context),
),
],
),
);
}
void _onAddCommunity(BuildContext context) {
final bloc = context.read<CommunitiesTreeSelectionBloc>();
final selectedCommunity = bloc.state.selectedCommunity;
final isSelected = selectedCommunity?.uuid.isNotEmpty ?? false;
if (isSelected) {
_clearSelection(context);
} else {
SpaceManagementCommunityDialogHelper.showCreateDialog(context);
}
}
void _clearSelection(BuildContext context) {
context.read<CommunitiesTreeSelectionBloc>().add(
const ClearCommunitiesTreeSelectionEvent(),
);
}
}

View File

@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:syncrow_web/common/widgets/custom_expansion_tile.dart';
class SpaceTile extends StatefulWidget {
final String title;
final bool isSelected;
final bool initiallyExpanded;
final ValueChanged<bool> onExpansionChanged;
final List<Widget>? children;
final void Function() onItemSelected;
const SpaceTile({
super.key,
required this.title,
required this.initiallyExpanded,
required this.onExpansionChanged,
required this.onItemSelected,
required this.isSelected,
this.children,
});
@override
State<SpaceTile> createState() => _SpaceTileState();
}
class _SpaceTileState extends State<SpaceTile> {
late bool _isExpanded;
@override
void initState() {
super.initState();
_isExpanded = widget.initiallyExpanded;
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(left: 8.0, right: 8.0, top: 8.0),
child: CustomExpansionTile(
isSelected: widget.isSelected,
title: widget.title,
initiallyExpanded: _isExpanded,
onItemSelected: widget.onItemSelected,
onExpansionChanged: (bool expanded) {
setState(() {
_isExpanded = expanded;
});
widget.onExpansionChanged(expanded);
},
children: widget.children ?? [],
),
);
}
}

View File

@ -1,4 +1,5 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:syncrow_web/pages/common/bloc/project_manager.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/models/community_model.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/models/community_model.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/create_community/domain/param/create_community_param.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/create_community/domain/param/create_community_param.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/create_community/domain/services/create_community_service.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/create_community/domain/services/create_community_service.dart';
@ -16,24 +17,51 @@ class RemoteCreateCommunityService implements CreateCommunityService {
Future<CommunityModel> createCommunity(CreateCommunityParam param) async { Future<CommunityModel> createCommunity(CreateCommunityParam param) async {
try { try {
final response = await _httpService.post( final response = await _httpService.post(
path: 'endpoint', path: await _makeUrl(),
expectedResponseModel: (data) => CommunityModel.fromJson( body: {
data as Map<String, dynamic>, 'name': param.name,
), 'description': param.description,
},
expectedResponseModel: (data) {
final json = data as Map<String, dynamic>;
if (json['success'] == true) {
return CommunityModel.fromJson(
json['data'] as Map<String, dynamic>,
);
}
return null;
},
); );
if (response == null) {
throw APIException(
_getErrorMessageFromBody(response as Map<String, dynamic>?),
);
}
return response; return response;
} on DioException catch (e) { } on DioException catch (e) {
final message = e.response?.data as Map<String, dynamic>?; final message = e.response?.data as Map<String, dynamic>?;
final error = message?['error'] as Map<String, dynamic>?; throw APIException(_getErrorMessageFromBody(message));
final errorMessage = error?['error'] as String? ?? '';
final formattedErrorMessage = [
_defaultErrorMessage,
errorMessage,
].join(': ');
throw APIException(formattedErrorMessage);
} catch (e) { } catch (e) {
final formattedErrorMessage = [_defaultErrorMessage, '$e'].join(': '); final formattedErrorMessage = [_defaultErrorMessage, '$e'].join(': ');
throw APIException(formattedErrorMessage); throw APIException(formattedErrorMessage);
} }
} }
String _getErrorMessageFromBody(Map<String, dynamic>? body) {
if (body == null) {
return _defaultErrorMessage;
}
final error = body['error'] as Map<String, dynamic>?;
final errorMessage = error?['message'] as String? ?? '';
return errorMessage;
}
Future<String> _makeUrl() async {
final projectUuid = await ProjectManager.getProjectUUID();
if (projectUuid == null) {
throw APIException('Project UUID is not set');
}
return '/projects/$projectUuid/communities';
}
} }

View File

@ -1,9 +1,13 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
class CreateCommunityParam extends Equatable { class CreateCommunityParam extends Equatable {
const CreateCommunityParam({required this.name}); const CreateCommunityParam({
required this.name,
this.description = '',
});
final String name; final String name;
final String description;
@override @override
List<Object> get props => [name]; List<Object> get props => [name];

View File

@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/models/community_model.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/create_community/data/services/remote_create_community_service.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/create_community/presentation/bloc/create_community_bloc.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/create_community/presentation/create_community_dialog_widget.dart';
import 'package:syncrow_web/services/api/http_service.dart';
class CreateCommunityDialog extends StatelessWidget {
final void Function(CommunityModel community) onCreateCommunity;
final String? initialName;
final Widget title;
const CreateCommunityDialog({
super.key,
required this.onCreateCommunity,
required this.title,
this.initialName,
});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => CreateCommunityBloc(RemoteCreateCommunityService(HTTPService())),
child: BlocListener<CreateCommunityBloc, CreateCommunityState>(
listener: (context, state) {
switch (state) {
case CreateCommunityLoading():
showDialog<void>(
context: context,
builder: (context) => const Center(
child: CircularProgressIndicator(),
),
);
break;
case CreateCommunitySuccess(:final community):
Navigator.of(context).pop();
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Community created successfully')),
);
onCreateCommunity.call(community);
break;
case CreateCommunityFailure():
Navigator.of(context).pop();
break;
default:
break;
}
},
child: CreateCommunityDialogWidget(
title: title,
initialName: initialName,
),
),
);
}
}

View File

@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/common/buttons/cancel_button.dart';
import 'package:syncrow_web/pages/common/buttons/default_button.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/create_community/domain/param/create_community_param.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/create_community/presentation/bloc/create_community_bloc.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/create_community/presentation/create_community_name_text_field.dart';
import 'package:syncrow_web/utils/color_manager.dart';
class CreateCommunityDialogWidget extends StatefulWidget {
final String? initialName;
final Widget title;
const CreateCommunityDialogWidget({
super.key,
required this.title,
this.initialName,
});
@override
State<CreateCommunityDialogWidget> createState() =>
_CreateCommunityDialogWidgetState();
}
class _CreateCommunityDialogWidgetState extends State<CreateCommunityDialogWidget> {
late final TextEditingController _nameController;
@override
void initState() {
_nameController = TextEditingController(text: widget.initialName ?? '');
super.initState();
}
@override
void dispose() {
_nameController.dispose();
super.dispose();
}
final _formKey = GlobalKey<FormState>();
@override
Widget build(BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
backgroundColor: ColorsManager.transparentColor,
child: Container(
width: MediaQuery.of(context).size.width * 0.3,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: ColorsManager.whiteColors,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: ColorsManager.blackColor.withValues(alpha: 0.25),
blurRadius: 20,
spreadRadius: 5,
offset: const Offset(0, 5),
),
],
),
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: BlocBuilder<CreateCommunityBloc, CreateCommunityState>(
builder: (context, state) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DefaultTextStyle(
style: Theme.of(context).textTheme.headlineMedium!,
child: widget.title,
),
const SizedBox(height: 18),
CreateCommunityNameTextField(
nameController: _nameController,
),
if (state case CreateCommunityFailure(:final message))
Padding(
padding: const EdgeInsets.only(top: 18),
child: SelectableText(
'* $message',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
),
const SizedBox(height: 24),
_buildActionButtons(context),
],
);
},
),
),
),
),
);
}
Widget _buildActionButtons(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: CancelButton(
label: 'Cancel',
onPressed: () => Navigator.of(context).pop(),
),
),
const SizedBox(width: 16),
_buildCreateCommunityButton(context),
],
);
}
Widget _buildCreateCommunityButton(BuildContext context) {
return Expanded(
child: DefaultButton(
onPressed: () {
if (_formKey.currentState?.validate() ?? false) {
_onSubmit(context);
}
},
borderRadius: 10,
foregroundColor: ColorsManager.whiteColors,
child: const Text('OK'),
),
);
}
void _onSubmit(BuildContext context) {
if (_formKey.currentState?.validate() ?? false) {
context.read<CreateCommunityBloc>().add(
CreateCommunity(
CreateCommunityParam(
name: _nameController.text.trim(),
),
),
);
}
}
}

View File

@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart';
class CreateCommunityNameTextField extends StatelessWidget {
const CreateCommunityNameTextField({
required this.nameController,
super.key,
});
final TextEditingController nameController;
@override
Widget build(BuildContext context) {
return TextFormField(
controller: nameController,
validator: _validator,
style: context.textTheme.bodyMedium,
decoration: InputDecoration(
hintText: 'Please enter the community name',
filled: true,
fillColor: ColorsManager.boxColor,
enabledBorder: _buildBorder(ColorsManager.boxColor),
focusedBorder: _buildBorder(),
focusedErrorBorder: _buildBorder(Theme.of(context).colorScheme.error),
errorBorder: _buildBorder(Theme.of(context).colorScheme.error),
),
);
}
String? _validator(String? value) {
if (value == null || value.isEmpty) {
return '*Name should not be empty.';
}
return null;
}
InputBorder _buildBorder([Color? color]) {
return OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(
color: color ?? ColorsManager.vividBlue.withValues(alpha: 0.5),
width: 1,
),
);
}
}

View File

@ -4,11 +4,10 @@ import 'package:syncrow_web/common/widgets/custom_expansion_tile.dart';
class SpaceTile extends StatefulWidget { class SpaceTile extends StatefulWidget {
final String title; final String title;
final bool isSelected; final bool isSelected;
final bool initiallyExpanded; final bool initiallyExpanded;
final ValueChanged<bool> onExpansionChanged; final ValueChanged<bool> onExpansionChanged;
final List<Widget>? children; final List<Widget>? children;
final Function() onItemSelected; final void Function() onItemSelected;
const SpaceTile({ const SpaceTile({
super.key, super.key,

View File

@ -46,6 +46,7 @@ abstract class ApiEndpoints {
// Community Module // Community Module
static const String createCommunity = '/projects/{projectId}/communities'; static const String createCommunity = '/projects/{projectId}/communities';
static const String getCommunityList = '/projects/{projectId}/communities'; static const String getCommunityList = '/projects/{projectId}/communities';
static const String getCommunityListv2 = '/projects/{projectId}/communities/v2';
static const String getCommunityById = static const String getCommunityById =
'/projects/{projectId}/communities/{communityId}'; '/projects/{projectId}/communities/{communityId}';
static const String updateCommunity = static const String updateCommunity =