Compare commits

...

28 Commits

Author SHA1 Message Date
2b8d987c69 Add SpaceReorderDataModel and integrate drag-and-drop functionality in CommunityStructureCanvas for improved space management. 2025-07-08 16:00:57 +03:00
707cb4791f Added CreateSpaceButton for improved user interaction and updated layout calculations to utilize context extensions for better responsiveness. 2025-07-08 13:08:43 +03:00
03c45ed8d0 Refactor SpaceCardWidget: Simplified widget structure by removing unnecessary SizedBox. 2025-07-08 13:07:55 +03:00
9e0ea4ad6f Adjust spacer flex in SpaceManagementCommunityStructure widget for improved layout consistency. 2025-07-08 13:07:39 +03:00
9e6b14737f Refactor CreateSpaceButton: Changed from StatelessWidget to StatefulWidget to manage hover state and added tooltip for improved user experience. Enhanced button styling and interaction feedback for better visual cues during space creation. 2025-07-08 13:07:26 +03:00
7c2aed2d58 Refactor RemoteUpdateSpaceService: Improved error handling in updateSpace method by checking API response success before returning the updated space. This enhances robustness and ensures proper error propagation for failed updates. 2025-07-08 12:20:10 +03:00
bcf62027bc Validate UUIDs in RemoteUpdateSpaceService: Added checks for empty space and community UUIDs before constructing the update URL, improving error handling and robustness in the update space process. 2025-07-08 11:12:12 +03:00
b001713ce4 Enhance Community Structure Widgets: Updated SpaceDetailsDialogHelper to accept community UUID for space creation and editing. Refactored CreateSpaceButton and CommunityStructureHeader to pass community UUID, improving data handling and consistency across the community structure features. 2025-07-08 11:10:22 +03:00
bab3226c73 Merge branch 'dev' of https://github.com/SyncrowIOT/web into SP-1717-FE-Draw-Create-Edit-Space-Dialog 2025-07-08 10:02:12 +03:00
fa1eaa570c Refactor Space Update Logic: Introduced UpdateSpaceParam for better parameter handling in update operations. Enhanced SpaceDetailsDialogHelper to manage loading and error states during space updates. Updated RemoteUpdateSpaceService to construct dynamic URLs for space updates based on community UUID. Improved CommunitiesTreeFailureWidget UI with SelectableText and added spacing for better layout. 2025-07-08 10:01:43 +03:00
4cfb984d2c Sp 1720 fe draw assign tags to space dialog (#341)
<!--
  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-1720](https://syncrow.atlassian.net/browse/SP-1720)

## Description

Implemented products and assign tags functionality.

## 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-1720]:
https://syncrow.atlassian.net/browse/SP-1720?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
2025-07-08 10:01:10 +03:00
4c06479469 Replaced Column with ListView in SpaceDetailsForm to enhance scrolling behavior and accommodate dynamic content. 2025-07-07 14:54:25 +03:00
3101960201 Enhance Space Management Features with Tag Assignment Improvements:
- Introduced UUID for ProductAllocation to ensure unique identification.
- Refactored AssignTagsDialog to manage tag assignments and validation more effectively, including error handling for empty tags and duplicate tag usage.
- Updated AssignTagsTable to support dynamic product allocation management and improved UI interactions.
- Enhanced AddDeviceTypeWidget to maintain selected products and handle increment/decrement actions, improving user experience during device type selection.
- Added AssignTagsErrorMessages widget for better error visibility in tag assignment process.
2025-07-07 14:26:59 +03:00
ddfd4ee153 removed print statement. 2025-07-07 14:26:39 +03:00
7f0484eec6 Add UpdateSpaceDetails Event. 2025-07-07 14:26:18 +03:00
dc7064d142 Add Factory Method for Empty Tag Instance in Tag Model. 2025-07-07 14:25:37 +03:00
e523a83912 Refactor Tags Service and Bloc for Improved Data Handling:
- Updated RemoteTagsService to remove LoadTagsParam and fetch project UUID internally, enhancing encapsulation and reducing parameter dependency.
- Modified TagsService interface to reflect the new loading method signature.
- Adjusted TagsBloc to align with the updated service method, simplifying the loading process.
- Enhanced AssignTagsTable and AddDeviceTypeWidget to utilize the new data flow, improving maintainability and user experience.
2025-07-07 10:50:03 +03:00
e917225c3d Refactor Widgets for Improved UI Consistency and Usability:
- Replaced Text with SelectableText in AddDeviceTypeWidget and AssignTagsTable for better text selection and accessibility.
- Simplified onCancel action in AssignTagsDialog for improved readability.
- Enhanced ProductsGrid layout by removing unnecessary Column widget, streamlining the widget structure for better performance and maintainability.
2025-07-07 10:36:42 +03:00
66ed30b50c Merge branch 'dev' of https://github.com/SyncrowIOT/web into SP-1720-FE-Draw-AssignTagsToSpace-Dialog 2025-07-07 10:21:14 +03:00
47bd6ff89e Refactor AddDeviceTypeWidget for Improved Error Handling and Loading States:
- Extracted loading and error handling logic into separate methods for better readability and maintainability.
- Updated UI to utilize centralized loading and failure widgets, enhancing user experience during data fetching.
2025-07-07 10:20:51 +03:00
138390496c Sp 1716 fe implement edit community service and b lo c and ensure data consistency (#340)
<!--
  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-1716](https://syncrow.atlassian.net/browse/SP-1716)

## Description

Implemented Edit Community Feature, and ensured data integrity by
reflecting changes anywhere needed immediately when changing a community
name.
Made subspaces unique and removed duplication when calling data from
API.

## 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-1716]:
https://syncrow.atlassian.net/browse/SP-1716?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
2025-07-07 10:20:45 +03:00
df87e41d61 Refactor AddDeviceTypeWidget and ProductTypeCard Components:
- Replaced ProductTypeCard with ProductsGrid in AddDeviceTypeWidget for improved layout and maintainability.
- Converted ProductTypeCardCounter from StatefulWidget to StatelessWidget, simplifying its implementation.
- Updated ProductTypeCard to accept count and increment/decrement callbacks, enhancing its reusability and interaction.
- Introduced ProductsGrid to manage product display in a grid format, improving UI organization and responsiveness.
2025-07-07 10:15:33 +03:00
f0bfe085a4 Enhance Product Model with Icon Mapping:
- Added icon mapping functionality to the Product model, allowing dynamic icon retrieval based on product type.
- Updated ProductTypeCard to utilize the new icon property, improving UI representation and maintainability.
2025-07-07 09:34:11 +03:00
bb846f797f Implement Tag Assignment and Device Addition Features:
- Introduced AssignTagsDialog for assigning tags to devices, enhancing user interaction and organization.
- Added AddDeviceTypeWidget for adding new device types, improving the flexibility of device management.
- Created ProductTypeCard and ProductTypeCardCounter for better representation and interaction with device types.
- Enhanced AssignTagsTable for displaying and managing product allocations, improving maintainability and user experience.
2025-07-06 16:54:15 +03:00
e234c9f3b2 Enhance SpaceDetailsActionButtons: Introduced customizable button labels for save and cancel actions, improving flexibility and user experience. Updated button implementations to utilize these new labels, enhancing maintainability and adherence to Clean Architecture principles. 2025-07-06 16:44:40 +03:00
bcd0ae4a2a Refactor Products Module:
- Introduced ProductsBloc and updated ProductsService to remove LoadProductsParam, simplifying the product loading process.
- Updated RemoteProductsService to utilize a new API endpoint for fetching products.
- Adjusted ProductsEvent and ProductsState to reflect changes in the loading mechanism, enhancing maintainability and clarity in the products management flow.
2025-07-06 16:44:26 +03:00
cebce2ce7f Update SpaceDetailsModel: Change default icon from villa to location for improved representation of space details. 2025-07-06 14:50:24 +03:00
97e3fb68bf Enhance Product Model and SpaceDetailsDevicesBox:
- Added 'productType' field to Product model for improved data representation.
- Updated JSON parsing in Product model to handle 'prodType'.
- Refactored SpaceDetailsDevicesBox to utilize productType for dynamic device icon rendering, enhancing UI clarity and maintainability.
2025-07-06 14:49:10 +03:00
42 changed files with 1639 additions and 252 deletions

View File

@ -0,0 +1,14 @@
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 SpaceReorderDataModel {
const SpaceReorderDataModel({
required this.space,
this.parent,
this.community,
});
final SpaceModel space;
final SpaceModel? parent;
final CommunityModel? community;
}

View File

@ -7,6 +7,8 @@ import 'package:syncrow_web/pages/space_management_v2/modules/communities/data/s
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/presentation/bloc/communities_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/communities/presentation/communities_tree_selection_bloc/communities_tree_selection_bloc.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/products/data/services/remote_products_service.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/products/presentation/bloc/products_bloc.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/space_details/data/services/remote_space_details_service.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/space_details/data/services/remote_space_details_service.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/space_details/data/services/unique_subspaces_decorator.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/space_details/data/services/unique_subspaces_decorator.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/space_details/presentation/bloc/space_details_bloc.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/space_details/presentation/bloc/space_details_bloc.dart';
@ -36,6 +38,11 @@ class SpaceManagementPage extends StatelessWidget {
), ),
), ),
), ),
BlocProvider(
create: (context) => ProductsBloc(
RemoteProductsService(HTTPService()),
),
),
], ],
child: WebScaffold( child: WebScaffold(
appBarTitle: Text( appBarTitle: Text(

View File

@ -1,13 +1,17 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.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/models/space_connection_model.dart';
import 'package:syncrow_web/pages/space_management_v2/main_module/models/space_reorder_data_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/painters/spaces_connections_arrow_painter.dart';
import 'package:syncrow_web/pages/space_management_v2/main_module/widgets/create_space_button.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_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/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/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/domain/models/space_model.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/communities/presentation/communities_tree_selection_bloc/communities_tree_selection_bloc.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/space_details/presentation/helpers/space_details_dialog_helper.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/space_details/presentation/helpers/space_details_dialog_helper.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart';
class CommunityStructureCanvas extends StatefulWidget { class CommunityStructureCanvas extends StatefulWidget {
const CommunityStructureCanvas({ const CommunityStructureCanvas({
@ -31,8 +35,9 @@ class _CommunityStructureCanvasState extends State<CommunityStructureCanvas>
final double _horizontalSpacing = 150.0; final double _horizontalSpacing = 150.0;
final double _verticalSpacing = 120.0; final double _verticalSpacing = 120.0;
late TransformationController _transformationController; late final TransformationController _transformationController;
late AnimationController _animationController; late final AnimationController _animationController;
SpaceReorderDataModel? _draggedData;
@override @override
void initState() { void initState() {
@ -97,7 +102,7 @@ class _CommunityStructureCanvasState extends State<CommunityStructureCanvas>
final position = _positions[space.uuid]; final position = _positions[space.uuid];
if (position == null) return; if (position == null) return;
const scale = 1.5; const scale = 1;
final viewSize = context.size; final viewSize = context.size;
if (viewSize == null) return; if (viewSize == null) return;
@ -112,16 +117,33 @@ class _CommunityStructureCanvasState extends State<CommunityStructureCanvas>
_runAnimation(matrix); _runAnimation(matrix);
} }
void _onReorder(SpaceReorderDataModel data, int newIndex) {
final newCommunity = widget.community.copyWith();
final children = data.parent?.children ?? newCommunity.spaces;
final oldIndex = children.indexWhere((s) => s.uuid == data.space.uuid);
if (oldIndex != -1) {
final item = children.removeAt(oldIndex);
if (newIndex > oldIndex) {
children.insert(newIndex - 1, item);
} else {
children.insert(newIndex, item);
}
}
context.read<CommunitiesBloc>().add(
CommunitiesUpdateCommunity(newCommunity),
);
}
void _onSpaceTapped(SpaceModel? space) { void _onSpaceTapped(SpaceModel? space) {
context.read<CommunitiesTreeSelectionBloc>().add( context.read<CommunitiesTreeSelectionBloc>().add(
SelectSpaceEvent(community: widget.community, space: space), SelectSpaceEvent(community: widget.community, space: space),
); );
} }
void _resetSelectionAndZoom() { void _resetSelectionAndZoom([CommunityModel? community]) {
context.read<CommunitiesTreeSelectionBloc>().add( context.read<CommunitiesTreeSelectionBloc>().add(
SelectSpaceEvent( SelectSpaceEvent(
community: widget.community, community: community ?? widget.community,
space: null, space: null,
), ),
); );
@ -182,7 +204,8 @@ class _CommunityStructureCanvasState extends State<CommunityStructureCanvas>
_positions.clear(); _positions.clear();
final community = widget.community; final community = widget.community;
_calculateLayout(community.spaces, 0, {}); final levelXOffset = <int, double>{};
_calculateLayout(community.spaces, 0, levelXOffset);
final selectedSpace = widget.selectedSpace; final selectedSpace = widget.selectedSpace;
final highlightedUuids = <String>{}; final highlightedUuids = <String>{};
@ -193,7 +216,24 @@ class _CommunityStructureCanvasState extends State<CommunityStructureCanvas>
final widgets = <Widget>[]; final widgets = <Widget>[];
final connections = <SpaceConnectionModel>[]; final connections = <SpaceConnectionModel>[];
_generateWidgets(community.spaces, widgets, connections, highlightedUuids); _generateWidgets(
widget.community.spaces,
widgets,
connections,
highlightedUuids,
community: widget.community,
);
final createButtonX = levelXOffset[0] ?? 0.0;
const createButtonY = 0.0;
widgets.add(
Positioned(
left: createButtonX,
top: createButtonY,
child: CreateSpaceButton(communityUuid: widget.community.uuid),
),
);
return [ return [
CustomPaint( CustomPaint(
@ -211,58 +251,178 @@ class _CommunityStructureCanvasState extends State<CommunityStructureCanvas>
List<SpaceModel> spaces, List<SpaceModel> spaces,
List<Widget> widgets, List<Widget> widgets,
List<SpaceConnectionModel> connections, List<SpaceConnectionModel> connections,
Set<String> highlightedUuids, Set<String> highlightedUuids, {
) { CommunityModel? community,
for (final space in spaces) { SpaceModel? parent,
}) {
if (spaces.isNotEmpty) {
final firstChildPos = _positions[spaces.first.uuid]!;
final targetPos = Offset(
firstChildPos.dx - (_horizontalSpacing / 4),
firstChildPos.dy,
);
widgets.add(_buildDropTarget(parent, community, 0, targetPos));
}
for (var i = 0; i < spaces.length; i++) {
final space = spaces[i];
final position = _positions[space.uuid]; final position = _positions[space.uuid];
if (position == null) continue; if (position == null) {
continue;
}
final isHighlighted = highlightedUuids.contains(space.uuid); final isHighlighted = highlightedUuids.contains(space.uuid);
final hasNoSelectedSpace = widget.selectedSpace == null; final hasNoSelectedSpace = widget.selectedSpace == null;
final spaceCard = SpaceCardWidget(
buildSpaceContainer: () {
return Opacity(
opacity: hasNoSelectedSpace || isHighlighted ? 1.0 : 0.5,
child: Tooltip(
message: space.spaceName,
preferBelow: false,
child: SpaceCell(
onTap: () => _onSpaceTapped(space),
icon: space.icon,
name: space.spaceName,
),
),
);
},
onTap: () => SpaceDetailsDialogHelper.showCreate(
context,
communityUuid: widget.community.uuid,
),
);
final reorderData = SpaceReorderDataModel(
space: space,
parent: parent,
community: community,
);
widgets.add( widgets.add(
Positioned( Positioned(
left: position.dx, left: position.dx,
top: position.dy, top: position.dy,
width: _cardWidth, width: _cardWidth,
height: _cardHeight, height: _cardHeight,
child: SpaceCardWidget( child: Draggable<SpaceReorderDataModel>(
buildSpaceContainer: () { data: reorderData,
return Opacity( feedback: Material(
opacity: hasNoSelectedSpace || isHighlighted ? 1.0 : 0.5, color: Colors.transparent,
child: Tooltip( child: Opacity(
message: space.spaceName, opacity: 0.2,
preferBelow: false, child: SizedBox(
child: SpaceCell( width: _cardWidth,
onTap: () => _onSpaceTapped(space), height: _cardHeight,
icon: space.icon, child: spaceCard,
name: space.spaceName,
),
), ),
); ),
}, ),
onTap: () => SpaceDetailsDialogHelper.showCreate(context), onDragStarted: () => setState(() => _draggedData = reorderData),
onDragEnd: (_) => setState(() => _draggedData = null),
onDraggableCanceled: (_, __) => setState(() => _draggedData = null),
childWhenDragging: Opacity(opacity: 0.4, child: spaceCard),
child: spaceCard,
), ),
), ),
); );
final targetPos = Offset(
position.dx + _cardWidth + (_horizontalSpacing / 4) - 20,
position.dy,
);
widgets.add(_buildDropTarget(parent, community, i + 1, targetPos));
for (final child in space.children) { for (final child in space.children) {
connections.add( connections.add(SpaceConnectionModel(from: space.uuid, to: child.uuid));
SpaceConnectionModel(from: space.uuid, to: child.uuid), }
if (space.children.isNotEmpty) {
_generateWidgets(
space.children,
widgets,
connections,
highlightedUuids,
parent: space,
); );
} }
_generateWidgets(space.children, widgets, connections, highlightedUuids);
} }
} }
Widget _buildDropTarget(
SpaceModel? parent,
CommunityModel? community,
int index,
Offset position,
) {
return Positioned(
left: position.dx,
top: position.dy,
width: 40,
height: _cardHeight,
child: DragTarget<SpaceReorderDataModel>(
builder: (context, candidateData, rejectedData) {
if (_draggedData == null) {
return const SizedBox();
}
final isTargetForDragged = (_draggedData?.parent?.uuid == parent?.uuid &&
_draggedData?.community == null) ||
(_draggedData?.community?.uuid == community?.uuid &&
_draggedData?.parent == null);
if (!isTargetForDragged) {
return const SizedBox();
}
return Container(
width: 40,
height: _cardHeight,
decoration: BoxDecoration(
color: context.theme.colorScheme.primary.withValues(
alpha: candidateData.isNotEmpty ? 0.7 : 0.3,
),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.add,
color: context.theme.colorScheme.onPrimary,
),
);
},
onWillAcceptWithDetails: (data) {
final children = parent?.children ?? community?.spaces ?? [];
final isSameParent = (data.data.parent?.uuid == parent?.uuid &&
data.data.community == null) ||
(data.data.community?.uuid == community?.uuid &&
data.data.parent == null);
if (!isSameParent) {
return false;
}
final oldIndex =
children.indexWhere((s) => s.uuid == data.data.space.uuid);
if (oldIndex == index || oldIndex == index - 1) {
return false;
}
return true;
},
onAcceptWithDetails: (data) => _onReorder(data.data, index),
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final treeWidgets = _buildTreeWidgets(); final treeWidgets = _buildTreeWidgets();
return InteractiveViewer( return InteractiveViewer(
transformationController: _transformationController, transformationController: _transformationController,
boundaryMargin: EdgeInsets.symmetric( boundaryMargin: EdgeInsets.symmetric(
horizontal: MediaQuery.sizeOf(context).width * 0.3, horizontal: context.screenWidth * 0.3,
vertical: MediaQuery.sizeOf(context).height * 0.3, vertical: context.screenHeight * 0.3,
), ),
minScale: 0.5, minScale: 0.5,
maxScale: 3.0, maxScale: 3.0,
@ -270,8 +430,8 @@ class _CommunityStructureCanvasState extends State<CommunityStructureCanvas>
child: GestureDetector( child: GestureDetector(
onTap: _resetSelectionAndZoom, onTap: _resetSelectionAndZoom,
child: SizedBox( child: SizedBox(
width: MediaQuery.sizeOf(context).width * 5, width: context.screenWidth * 5,
height: MediaQuery.sizeOf(context).height * 5, height: context.screenHeight * 5,
child: Stack(children: treeWidgets), child: Stack(children: treeWidgets),
), ),
), ),

View File

@ -55,8 +55,9 @@ class CommunityStructureHeader extends StatelessWidget {
children: [ children: [
Text( Text(
'Community Structure', 'Community Structure',
style: theme.textTheme.headlineLarge style: theme.textTheme.headlineLarge?.copyWith(
?.copyWith(color: ColorsManager.blackColor), color: ColorsManager.blackColor,
),
), ),
if (selectedCommunity != null) if (selectedCommunity != null)
Row( Row(
@ -67,8 +68,9 @@ class CommunityStructureHeader extends StatelessWidget {
Flexible( Flexible(
child: SelectableText( child: SelectableText(
selectedCommunity.name, selectedCommunity.name,
style: theme.textTheme.bodyLarge style: theme.textTheme.bodyLarge?.copyWith(
?.copyWith(color: ColorsManager.blackColor), color: ColorsManager.blackColor,
),
maxLines: 1, maxLines: 1,
), ),
), ),
@ -93,12 +95,11 @@ class CommunityStructureHeader extends StatelessWidget {
CommunityStructureHeaderActionButtons( CommunityStructureHeaderActionButtons(
onDelete: (space) {}, onDelete: (space) {},
onDuplicate: (space) {}, onDuplicate: (space) {},
onEdit: (space) { onEdit: (space) => SpaceDetailsDialogHelper.showEdit(
SpaceDetailsDialogHelper.showEdit( context,
context, spaceModel: selectedSpace!,
spaceModel: selectedSpace!, communityUuid: selectedCommunity.uuid,
); ),
},
selectedSpace: selectedSpace, selectedSpace: selectedSpace,
), ),
], ],

View File

@ -2,38 +2,66 @@ import 'package:flutter/material.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/space_details/presentation/helpers/space_details_dialog_helper.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/space_details/presentation/helpers/space_details_dialog_helper.dart';
import 'package:syncrow_web/utils/color_manager.dart'; import 'package:syncrow_web/utils/color_manager.dart';
class CreateSpaceButton extends StatelessWidget { class CreateSpaceButton extends StatefulWidget {
const CreateSpaceButton({super.key}); const CreateSpaceButton({
required this.communityUuid,
super.key,
});
final String communityUuid;
@override
State<CreateSpaceButton> createState() => _CreateSpaceButtonState();
}
class _CreateSpaceButtonState extends State<CreateSpaceButton> {
bool _isHovered = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return Tooltip(
onTap: () => SpaceDetailsDialogHelper.showCreate(context), margin: const EdgeInsets.symmetric(vertical: 24),
child: Container( message: 'Create a new space',
height: 60, child: GestureDetector(
decoration: BoxDecoration( onTap: () => SpaceDetailsDialogHelper.showCreate(
color: Colors.white, context,
borderRadius: BorderRadius.circular(20), communityUuid: widget.communityUuid,
boxShadow: [
BoxShadow(
color: Colors.grey.withValues(alpha: 0.5),
spreadRadius: 5,
blurRadius: 7,
offset: const Offset(0, 3),
),
],
), ),
child: Center( child: MouseRegion(
child: Container( onEnter: (_) => setState(() => _isHovered = true),
width: 40, onExit: (_) => setState(() => _isHovered = false),
height: 40, child: AnimatedOpacity(
decoration: const BoxDecoration( duration: const Duration(milliseconds: 100),
color: ColorsManager.boxColor, opacity: _isHovered ? 1.0 : 0.45,
shape: BoxShape.circle, child: Container(
), width: 150,
child: const Icon( height: 90,
Icons.add, decoration: BoxDecoration(
color: Colors.blue, color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.grey.withValues(alpha: 0.2),
spreadRadius: 3,
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: Container(
margin: const EdgeInsets.symmetric(vertical: 20),
decoration: BoxDecoration(
border: Border.all(color: ColorsManager.borderColor, width: 2),
color: ColorsManager.boxColor,
shape: BoxShape.circle,
),
child: const Center(
child: Icon(
Icons.add,
color: Colors.blue,
),
),
),
), ),
), ),
), ),

View File

@ -22,22 +22,20 @@ class _SpaceCardWidgetState extends State<SpaceCardWidget> {
return MouseRegion( return MouseRegion(
onEnter: (_) => setState(() => isHovered = true), onEnter: (_) => setState(() => isHovered = true),
onExit: (_) => setState(() => isHovered = false), onExit: (_) => setState(() => isHovered = false),
child: SizedBox( child: Stack(
child: Stack( clipBehavior: Clip.none,
clipBehavior: Clip.none, alignment: Alignment.center,
alignment: Alignment.center, children: [
children: [ widget.buildSpaceContainer(),
widget.buildSpaceContainer(), if (isHovered)
if (isHovered) Positioned(
Positioned( bottom: 0,
bottom: 0, child: PlusButtonWidget(
child: PlusButtonWidget( offset: Offset.zero,
offset: Offset.zero, onButtonTap: widget.onTap,
onButtonTap: widget.onTap,
),
), ),
], ),
), ],
), ),
); );
} }

View File

@ -13,11 +13,17 @@ class SpaceManagementCommunityStructure extends StatelessWidget {
final selectionBloc = context.watch<CommunitiesTreeSelectionBloc>().state; final selectionBloc = context.watch<CommunitiesTreeSelectionBloc>().state;
final selectedCommunity = selectionBloc.selectedCommunity; final selectedCommunity = selectionBloc.selectedCommunity;
final selectedSpace = selectionBloc.selectedSpace; final selectedSpace = selectionBloc.selectedSpace;
const spacer = Spacer(flex: 10); const spacer = Spacer(flex: 6);
return Visibility( return Visibility(
visible: selectedCommunity!.spaces.isNotEmpty, visible: selectedCommunity!.spaces.isNotEmpty,
replacement: const Row( replacement: Row(
children: [spacer, Expanded(child: CreateSpaceButton()), spacer], children: [
spacer,
Expanded(
child: CreateSpaceButton(communityUuid: selectedCommunity.uuid),
),
spacer
],
), ),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,

View File

@ -13,14 +13,14 @@ class CommunitiesTreeFailureWidget extends StatelessWidget {
return Expanded( return Expanded(
child: Center( child: Center(
child: Column( child: Column(
spacing: 16,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Text( SelectableText(
errorMessage ?? 'Something went wrong', errorMessage ?? 'Something went wrong',
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 16), FilledButton(
ElevatedButton(
onPressed: () => context.read<CommunitiesBloc>().add( onPressed: () => context.read<CommunitiesBloc>().add(
LoadCommunities( LoadCommunities(
LoadCommunitiesParam( LoadCommunitiesParam(

View File

@ -1,9 +1,9 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/models/product.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/models/product.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/params/load_products_param.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/services/products_service.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/services/products_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 RemoteProductsService implements ProductsService { class RemoteProductsService implements ProductsService {
const RemoteProductsService(this._httpService); const RemoteProductsService(this._httpService);
@ -13,17 +13,14 @@ class RemoteProductsService implements ProductsService {
static const _defaultErrorMessage = 'Failed to load devices'; static const _defaultErrorMessage = 'Failed to load devices';
@override @override
Future<List<Product>> getProducts(LoadProductsParam param) async { Future<List<Product>> getProducts() async {
try { try {
final response = await _httpService.get( final response = await _httpService.get(
path: 'devices', path: ApiEndpoints.listProducts,
queryParameters: {
'spaceUuid': param.spaceUuid,
if (param.type != null) 'type': param.type,
if (param.status != null) 'status': param.status,
},
expectedResponseModel: (data) { expectedResponseModel: (data) {
return (data as List) final json = data as Map<String, dynamic>;
final products = json['data'] as List<dynamic>;
return products
.map((e) => Product.fromJson(e as Map<String, dynamic>)) .map((e) => Product.fromJson(e as Map<String, dynamic>))
.toList(); .toList();
}, },

View File

@ -1,18 +1,24 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:syncrow_web/utils/constants/assets.dart';
class Product extends Equatable { class Product extends Equatable {
final String uuid;
final String name;
const Product({ const Product({
required this.uuid, required this.uuid,
required this.name, required this.name,
required this.productType,
}); });
final String uuid;
final String name;
final String productType;
String get icon => _mapIconToProduct(productType);
factory Product.fromJson(Map<String, dynamic> json) { factory Product.fromJson(Map<String, dynamic> json) {
return Product( return Product(
uuid: json['uuid'] as String, uuid: json['uuid'] as String? ?? '',
name: json['name'] as String, name: json['name'] as String? ?? '',
productType: json['prodType'] as String? ?? '',
); );
} }
@ -20,9 +26,37 @@ class Product extends Equatable {
return { return {
'uuid': uuid, 'uuid': uuid,
'name': name, 'name': name,
'productType': productType,
}; };
} }
static String _mapIconToProduct(String prodType) {
const iconMapping = {
'1G': Assets.Gang1SwitchIcon,
'1GT': Assets.oneTouchSwitch,
'2G': Assets.Gang2SwitchIcon,
'2GT': Assets.twoTouchSwitch,
'3G': Assets.Gang3SwitchIcon,
'3GT': Assets.threeTouchSwitch,
'CUR': Assets.curtain,
'CUR_2': Assets.curtain,
'GD': Assets.garageDoor,
'GW': Assets.SmartGatewayIcon,
'DL': Assets.DoorLockIcon,
'WL': Assets.waterLeakSensor,
'WH': Assets.waterHeater,
'WM': Assets.waterLeakSensor,
'SOS': Assets.sos,
'AC': Assets.ac,
'CPS': Assets.presenceSensor,
'PC': Assets.powerClamp,
'WPS': Assets.presenceSensor,
'DS': Assets.doorSensor
};
return iconMapping[prodType] ?? Assets.presenceSensor;
}
@override @override
List<Object?> get props => [uuid, name]; List<Object?> get props => [uuid, name, productType];
} }

View File

@ -1,11 +0,0 @@
class LoadProductsParam {
final String spaceUuid;
final String? type;
final String? status;
const LoadProductsParam({
required this.spaceUuid,
this.type,
this.status,
});
}

View File

@ -1,6 +1,5 @@
import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/models/product.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/models/product.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/params/load_products_param.dart';
abstract class ProductsService { abstract class ProductsService {
Future<List<Product>> getProducts(LoadProductsParam param); Future<List<Product>> getProducts();
} }

View File

@ -1,7 +1,6 @@
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/models/product.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/models/product.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/params/load_products_param.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/services/products_service.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/services/products_service.dart';
import 'package:syncrow_web/services/api/api_exception.dart'; import 'package:syncrow_web/services/api/api_exception.dart';
@ -9,20 +8,20 @@ part 'products_event.dart';
part 'products_state.dart'; part 'products_state.dart';
class ProductsBloc extends Bloc<ProductsEvent, ProductsState> { class ProductsBloc extends Bloc<ProductsEvent, ProductsState> {
final ProductsService _deviceService; ProductsBloc(this._productsService) : super(ProductsInitial()) {
ProductsBloc(this._deviceService) : super(ProductsInitial()) {
on<LoadProducts>(_onLoadProducts); on<LoadProducts>(_onLoadProducts);
} }
final ProductsService _productsService;
Future<void> _onLoadProducts( Future<void> _onLoadProducts(
LoadProducts event, LoadProducts event,
Emitter<ProductsState> emit, Emitter<ProductsState> emit,
) async { ) async {
emit(ProductsLoading()); emit(ProductsLoading());
try { try {
final devices = await _deviceService.getProducts(event.param); final products = await _productsService.getProducts();
emit(ProductsLoaded(devices)); emit(ProductsLoaded(products));
} on APIException catch (e) { } on APIException catch (e) {
emit(ProductsFailure(e.message)); emit(ProductsFailure(e.message));
} catch (e) { } catch (e) {

View File

@ -8,10 +8,5 @@ sealed class ProductsEvent extends Equatable {
} }
final class LoadProducts extends ProductsEvent { final class LoadProducts extends ProductsEvent {
const LoadProducts(this.param); const LoadProducts();
final LoadProductsParam param;
@override
List<Object> get props => [param];
} }

View File

@ -21,10 +21,10 @@ final class ProductsLoaded extends ProductsState {
} }
final class ProductsFailure extends ProductsState { final class ProductsFailure extends ProductsState {
final String message; final String errorMessage;
const ProductsFailure(this.message); const ProductsFailure(this.errorMessage);
@override @override
List<Object> get props => [message]; List<Object> get props => [errorMessage];
} }

View File

@ -2,6 +2,7 @@ import 'package:equatable/equatable.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/models/product.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/models/product.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/models/tag.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/models/tag.dart';
import 'package:syncrow_web/utils/constants/assets.dart'; import 'package:syncrow_web/utils/constants/assets.dart';
import 'package:uuid/uuid.dart';
class SpaceDetailsModel extends Equatable { class SpaceDetailsModel extends Equatable {
final String uuid; final String uuid;
@ -21,7 +22,7 @@ class SpaceDetailsModel extends Equatable {
factory SpaceDetailsModel.empty() => const SpaceDetailsModel( factory SpaceDetailsModel.empty() => const SpaceDetailsModel(
uuid: '', uuid: '',
spaceName: '', spaceName: '',
icon: Assets.villa, icon: Assets.location,
productAllocations: [], productAllocations: [],
subspaces: [], subspaces: [],
); );
@ -31,8 +32,8 @@ class SpaceDetailsModel extends Equatable {
spaceName: json['spaceName'] as String, spaceName: json['spaceName'] as String,
icon: json['icon'] as String, icon: json['icon'] as String,
productAllocations: (json['productAllocations'] as List) productAllocations: (json['productAllocations'] as List)
.map((e) => ProductAllocation.fromJson(e as Map<String, dynamic>)) .map((e) => ProductAllocation.fromJson(e as Map<String, dynamic>))
.toList(), .toList(),
subspaces: (json['subspaces'] as List) subspaces: (json['subspaces'] as List)
.map((e) => Subspace.fromJson(e as Map<String, dynamic>)) .map((e) => Subspace.fromJson(e as Map<String, dynamic>))
.toList(), .toList(),
@ -70,16 +71,19 @@ class SpaceDetailsModel extends Equatable {
} }
class ProductAllocation extends Equatable { class ProductAllocation extends Equatable {
final String uuid;
final Product product; final Product product;
final Tag tag; final Tag tag;
const ProductAllocation({ const ProductAllocation({
required this.uuid,
required this.product, required this.product,
required this.tag, required this.tag,
}); });
factory ProductAllocation.fromJson(Map<String, dynamic> json) { factory ProductAllocation.fromJson(Map<String, dynamic> json) {
return ProductAllocation( return ProductAllocation(
uuid: json['uuid'] as String? ?? const Uuid().v4(),
product: Product.fromJson(json['product'] as Map<String, dynamic>), product: Product.fromJson(json['product'] as Map<String, dynamic>),
tag: Tag.fromJson(json['tag'] as Map<String, dynamic>), tag: Tag.fromJson(json['tag'] as Map<String, dynamic>),
); );
@ -87,23 +91,26 @@ class ProductAllocation extends Equatable {
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
'uuid': uuid,
'product': product.toJson(), 'product': product.toJson(),
'tag': tag.toJson(), 'tag': tag.toJson(),
}; };
} }
ProductAllocation copyWith({ ProductAllocation copyWith({
String? uuid,
Product? product, Product? product,
Tag? tag, Tag? tag,
}) { }) {
return ProductAllocation( return ProductAllocation(
uuid: uuid ?? this.uuid,
product: product ?? this.product, product: product ?? this.product,
tag: tag ?? this.tag, tag: tag ?? this.tag,
); );
} }
@override @override
List<Object?> get props => [product, tag]; List<Object?> get props => [uuid, product, tag];
} }
class Subspace extends Equatable { class Subspace extends Equatable {

View File

@ -2,23 +2,42 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.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/domain/models/space_model.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/space_details/data/services/remote_space_details_service.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/space_details/data/services/remote_space_details_service.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/space_details/domain/models/space_details_model.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/space_details/presentation/bloc/space_details_bloc.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/space_details/presentation/bloc/space_details_bloc.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/space_details/presentation/widgets/space_details_dialog.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/space_details/presentation/widgets/space_details_dialog.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/update_space/data/services/remote_update_space_service.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/update_space/domain/params/update_space_param.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/update_space/presentation/bloc/update_space_bloc.dart';
import 'package:syncrow_web/services/api/http_service.dart'; import 'package:syncrow_web/services/api/http_service.dart';
abstract final class SpaceDetailsDialogHelper { abstract final class SpaceDetailsDialogHelper {
static void showCreate(BuildContext context) { static void showCreate(
BuildContext context, {
required String communityUuid,
}) {
showDialog<void>( showDialog<void>(
context: context, context: context,
builder: (_) => BlocProvider( builder: (_) => MultiBlocProvider(
create: (context) => SpaceDetailsBloc( providers: [
RemoteSpaceDetailsService(httpService: HTTPService()), BlocProvider(
), create: (context) => SpaceDetailsBloc(
child: SpaceDetailsDialog( RemoteSpaceDetailsService(httpService: HTTPService()),
context: context, ),
title: const SelectableText('Create Space'), ),
spaceModel: SpaceModel.empty(), BlocProvider(
onSave: print, create: (context) => UpdateSpaceBloc(
RemoteUpdateSpaceService(HTTPService()),
),
),
],
child: Builder(
builder: (context) => SpaceDetailsDialog(
context: context,
title: const SelectableText('Create Space'),
spaceModel: SpaceModel.empty(),
onSave: (space) {},
communityUuid: communityUuid,
),
), ),
), ),
); );
@ -27,20 +46,82 @@ abstract final class SpaceDetailsDialogHelper {
static void showEdit( static void showEdit(
BuildContext context, { BuildContext context, {
required SpaceModel spaceModel, required SpaceModel spaceModel,
required String communityUuid,
}) { }) {
showDialog<void>( showDialog<void>(
context: context, context: context,
builder: (_) => BlocProvider( builder: (_) => MultiBlocProvider(
create: (context) => SpaceDetailsBloc( providers: [
RemoteSpaceDetailsService(httpService: HTTPService()), BlocProvider(
), create: (context) => SpaceDetailsBloc(
child: SpaceDetailsDialog( RemoteSpaceDetailsService(httpService: HTTPService()),
context: context, ),
title: const SelectableText('Edit Space'), ),
spaceModel: spaceModel, BlocProvider(
onSave: (space) {}, create: (context) => UpdateSpaceBloc(
RemoteUpdateSpaceService(HTTPService()),
),
),
],
child: Builder(
builder: (context) => BlocListener<UpdateSpaceBloc, UpdateSpaceState>(
listener: _updateListener,
child: SpaceDetailsDialog(
context: context,
title: const SelectableText('Edit Space'),
spaceModel: spaceModel,
onSave: (space) => context.read<UpdateSpaceBloc>().add(
UpdateSpace(
UpdateSpaceParam(
communityUuid: communityUuid,
space: space,
),
),
),
communityUuid: communityUuid,
),
),
), ),
), ),
); );
} }
static void _updateListener(BuildContext context, UpdateSpaceState state) {
return switch (state) {
UpdateSpaceInitial() => null,
UpdateSpaceLoading() => _onLoading(context),
UpdateSpaceSuccess(:final space) => _onUpdateSuccess(context, space),
UpdateSpaceFailure(:final errorMessage) => _onError(context, errorMessage),
};
}
static void _onUpdateSuccess(BuildContext context, SpaceDetailsModel space) {
Navigator.of(context).pop();
}
static void _onLoading(BuildContext context) {
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => const Center(child: CircularProgressIndicator()),
);
}
static void _onError(BuildContext context, String errorMessage) {
Navigator.of(context).pop();
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => AlertDialog(
title: const Text('Error'),
content: Text(errorMessage),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: const Text('OK'),
),
],
),
);
}
} }

View File

@ -8,10 +8,14 @@ class SpaceDetailsActionButtons extends StatelessWidget {
super.key, super.key,
required this.onSave, required this.onSave,
required this.onCancel, required this.onCancel,
this.saveButtonLabel = 'OK',
this.cancelButtonLabel = 'Cancel',
}); });
final VoidCallback onCancel; final VoidCallback onCancel;
final VoidCallback? onSave; final VoidCallback? onSave;
final String saveButtonLabel;
final String cancelButtonLabel;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -27,10 +31,7 @@ class SpaceDetailsActionButtons extends StatelessWidget {
} }
Widget _buildCancelButton(BuildContext context) { Widget _buildCancelButton(BuildContext context) {
return CancelButton( return CancelButton(onPressed: onCancel, label: cancelButtonLabel);
onPressed: onCancel,
label: 'Cancel',
);
} }
Widget _buildSaveButton() { Widget _buildSaveButton() {
@ -39,7 +40,7 @@ class SpaceDetailsActionButtons extends StatelessWidget {
borderRadius: 10, borderRadius: 10,
backgroundColor: ColorsManager.secondaryColor, backgroundColor: ColorsManager.secondaryColor,
foregroundColor: ColorsManager.whiteColors, foregroundColor: ColorsManager.whiteColors,
child: const Text('OK'), child: Text(saveButtonLabel),
); );
} }
} }

View File

@ -1,9 +1,15 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_svg/svg.dart';
import 'package:syncrow_web/common/edit_chip.dart'; import 'package:syncrow_web/common/edit_chip.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/space_details/domain/models/space_details_model.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/space_details/domain/models/space_details_model.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/space_details/presentation/widgets/button_content_widget.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/space_details/presentation/widgets/button_content_widget.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/tags/presentation/widgets/assign_tags_dialog.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/update_space/presentation/bloc/space_details_model_bloc/space_details_model_bloc.dart';
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';
import 'package:syncrow_web/utils/enum/device_types.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart';
class SpaceDetailsDevicesBox extends StatelessWidget { class SpaceDetailsDevicesBox extends StatelessWidget {
const SpaceDetailsDevicesBox({ const SpaceDetailsDevicesBox({
@ -15,11 +21,18 @@ class SpaceDetailsDevicesBox extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final productAllocations = space.productAllocations; final allAllocations = [
final subspaces = space.subspaces; ...space.productAllocations,
final isAnySubspaceHasProductAllocations = ...space.subspaces.expand((s) => s.productAllocations),
subspaces.any((subspace) => subspace.productAllocations.isNotEmpty); ];
if (productAllocations.isNotEmpty || isAnySubspaceHasProductAllocations) {
if (allAllocations.isNotEmpty) {
final productCounts = <String, int>{};
for (final allocation in allAllocations) {
final productType = allocation.product.productType;
productCounts[productType] = (productCounts[productType] ?? 0) + 1;
}
return Container( return Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
@ -35,46 +48,40 @@ class SpaceDetailsDevicesBox extends StatelessWidget {
spacing: 8.0, spacing: 8.0,
runSpacing: 8.0, runSpacing: 8.0,
children: [ children: [
// Combine tags from spaceModel and subspaces ...productCounts.entries.map((entry) {
// ...TagHelper.groupTags([ final productType = entry.key;
// ...?tags, final count = entry.value;
// ...?subspaces?.expand((subspace) => subspace.tags ?? []) return Chip(
// ]).entries.map( avatar: SizedBox(
// (entry) => Chip( width: 24,
// avatar: SizedBox( height: 24,
// width: 24, child: SvgPicture.asset(
// height: 24, _getDeviceIcon(productType),
// child: SvgPicture.asset( fit: BoxFit.contain,
// entry.key.icon ?? 'assets/icons/gateway.svg', ),
// fit: BoxFit.contain, ),
// ), label: Text(
// ), 'x$count',
// label: Text( style: context.textTheme.bodySmall?.copyWith(
// 'x${entry.value}', color: ColorsManager.spaceColor,
// style: Theme.of(context) ),
// .textTheme ),
// .bodySmall backgroundColor: ColorsManager.whiteColors,
// ?.copyWith(color: ColorsManager.spaceColor), shape: RoundedRectangleBorder(
// ), borderRadius: BorderRadius.circular(16),
// backgroundColor: ColorsManager.whiteColors, side: const BorderSide(
// shape: RoundedRectangleBorder( color: ColorsManager.spaceColor,
// borderRadius: BorderRadius.circular(16), ),
// side: const BorderSide( ),
// color: ColorsManager.spaceColor, );
// ), }),
// ), EditChip(onTap: () => _showAssignTagsDialog(context)),
// ),
// ),
EditChip(
onTap: () {},
),
], ],
), ),
); );
} else { } else {
return TextButton( return TextButton(
onPressed: () {}, onPressed: () => _showAssignTagsDialog(context),
style: TextButton.styleFrom( style: TextButton.styleFrom(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
), ),
@ -83,10 +90,50 @@ class SpaceDetailsDevicesBox extends StatelessWidget {
child: ButtonContentWidget( child: ButtonContentWidget(
svgAssets: Assets.addIcon, svgAssets: Assets.addIcon,
label: 'Add Devices', label: 'Add Devices',
// disabled: isTagsAndSubspaceModelDisabled,
), ),
), ),
); );
} }
} }
void _showAssignTagsDialog(BuildContext context) {
showDialog<SpaceDetailsModel>(
context: context,
builder: (context) => AssignTagsDialog(space: space),
).then((resultSpace) {
if (resultSpace != null) {
if (context.mounted) {
context.read<SpaceDetailsModelBloc>().add(UpdateSpaceDetails(resultSpace));
}
}
});
}
String _getDeviceIcon(String productType) =>
switch (devicesTypesMap[productType]) {
DeviceType.LightBulb => Assets.lightBulb,
DeviceType.CeilingSensor => Assets.sensors,
DeviceType.AC => Assets.ac,
DeviceType.DoorLock => Assets.doorLock,
DeviceType.Curtain => Assets.curtain,
DeviceType.ThreeGang => Assets.gangSwitch,
DeviceType.Gateway => Assets.gateway,
DeviceType.OneGang => Assets.oneGang,
DeviceType.TwoGang => Assets.twoGang,
DeviceType.WH => Assets.waterHeater,
DeviceType.DoorSensor => Assets.openCloseDoor,
DeviceType.GarageDoor => Assets.openedDoor,
DeviceType.WaterLeak => Assets.waterLeakNormal,
DeviceType.Curtain2 => Assets.curtainIcon,
DeviceType.Blind => Assets.curtainIcon,
DeviceType.WallSensor => Assets.sensors,
DeviceType.DS => Assets.openCloseDoor,
DeviceType.OneTouch => Assets.gangSwitch,
DeviceType.TowTouch => Assets.gangSwitch,
DeviceType.ThreeTouch => Assets.gangSwitch,
DeviceType.NCPS => Assets.sensors,
DeviceType.PC => Assets.powerClamp,
DeviceType.Other => Assets.blackLogo,
null => Assets.blackLogo,
};
} }

View File

@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.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/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/space_details/domain/models/space_details_model.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/space_details/domain/models/space_details_model.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/space_details/domain/params/load_space_details_param.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/space_details/domain/params/load_space_details_param.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/space_details/presentation/bloc/space_details_bloc.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/space_details/presentation/bloc/space_details_bloc.dart';
@ -15,6 +14,7 @@ class SpaceDetailsDialog extends StatefulWidget {
required this.spaceModel, required this.spaceModel,
required this.onSave, required this.onSave,
required this.context, required this.context,
required this.communityUuid,
super.key, super.key,
}); });
@ -22,6 +22,7 @@ class SpaceDetailsDialog extends StatefulWidget {
final SpaceModel spaceModel; final SpaceModel spaceModel;
final void Function(SpaceDetailsModel space) onSave; final void Function(SpaceDetailsModel space) onSave;
final BuildContext context; final BuildContext context;
final String communityUuid;
@override @override
State<SpaceDetailsDialog> createState() => _SpaceDetailsDialogState(); State<SpaceDetailsDialog> createState() => _SpaceDetailsDialogState();
@ -35,11 +36,7 @@ class _SpaceDetailsDialogState extends State<SpaceDetailsDialog> {
if (!isCreateMode) { if (!isCreateMode) {
final param = LoadSpaceDetailsParam( final param = LoadSpaceDetailsParam(
spaceUuid: widget.spaceModel.uuid, spaceUuid: widget.spaceModel.uuid,
communityUuid: widget.context communityUuid: widget.communityUuid,
.read<CommunitiesTreeSelectionBloc>()
.state
.selectedCommunity!
.uuid,
); );
widget.context.read<SpaceDetailsBloc>().add(LoadSpaceDetails(param)); widget.context.read<SpaceDetailsBloc>().add(LoadSpaceDetails(param));
} }

View File

@ -42,9 +42,8 @@ class SpaceDetailsForm extends StatelessWidget {
Expanded(child: SpaceIconPicker(iconPath: space.icon)), Expanded(child: SpaceIconPicker(iconPath: space.icon)),
Expanded( Expanded(
flex: 2, flex: 2,
child: Column( child: ListView(
mainAxisSize: MainAxisSize.min, shrinkWrap: true,
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
SpaceNameTextField( SpaceNameTextField(
initialValue: space.spaceName, initialValue: space.spaceName,
@ -52,7 +51,7 @@ class SpaceDetailsForm extends StatelessWidget {
(subspace) => subspace.name == value, (subspace) => subspace.name == value,
), ),
), ),
const Spacer(), const SizedBox(height: 32),
SpaceSubSpacesBox( SpaceSubSpacesBox(
subspaces: space.subspaces, subspaces: space.subspaces,
), ),

View File

@ -1,10 +1,9 @@
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/tags/domain/models/tag.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/models/tag.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/params/load_tags_param.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/services/tags_service.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/services/tags_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';
final class RemoteTagsService implements TagsService { final class RemoteTagsService implements TagsService {
const RemoteTagsService(this._httpService); const RemoteTagsService(this._httpService);
@ -14,17 +13,10 @@ final class RemoteTagsService implements TagsService {
static const _defaultErrorMessage = 'Failed to load tags'; static const _defaultErrorMessage = 'Failed to load tags';
@override @override
Future<List<Tag>> loadTags(LoadTagsParam param) async { Future<List<Tag>> loadTags() async {
if (param.projectUuid == null) {
throw Exception('Project UUID is required');
}
try { try {
final response = await _httpService.get( final response = await _httpService.get(
path: ApiEndpoints.listTags.replaceAll( path: await _makeUrl(),
'{projectUuid}',
param.projectUuid!,
),
expectedResponseModel: (json) { expectedResponseModel: (json) {
final result = json as Map<String, dynamic>; final result = json as Map<String, dynamic>;
final data = result['data'] as List<dynamic>; final data = result['data'] as List<dynamic>;
@ -46,4 +38,12 @@ final class RemoteTagsService implements TagsService {
throw APIException(formattedErrorMessage); throw APIException(formattedErrorMessage);
} }
} }
Future<String> _makeUrl() async {
final projectUuid = await ProjectManager.getProjectUUID();
if (projectUuid == null || projectUuid.isEmpty) {
throw APIException('Project UUID is required');
}
return '/projects/$projectUuid/tags';
}
} }

View File

@ -13,6 +13,13 @@ class Tag extends Equatable {
required this.updatedAt, required this.updatedAt,
}); });
factory Tag.empty() => const Tag(
uuid: '',
name: '',
createdAt: '',
updatedAt: '',
);
factory Tag.fromJson(Map<String, dynamic> json) { factory Tag.fromJson(Map<String, dynamic> json) {
return Tag( return Tag(
uuid: json['uuid'] as String, uuid: json['uuid'] as String,

View File

@ -1,6 +1,5 @@
import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/models/tag.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/models/tag.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/params/load_tags_param.dart';
abstract interface class TagsService { abstract interface class TagsService {
Future<List<Tag>> loadTags(LoadTagsParam param); Future<List<Tag>> loadTags();
} }

View File

@ -1,7 +1,6 @@
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/models/tag.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/models/tag.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/params/load_tags_param.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/services/tags_service.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/services/tags_service.dart';
import 'package:syncrow_web/services/api/api_exception.dart'; import 'package:syncrow_web/services/api/api_exception.dart';
@ -21,7 +20,7 @@ class TagsBloc extends Bloc<TagsEvent, TagsState> {
) async { ) async {
emit(TagsLoading()); emit(TagsLoading());
try { try {
final tags = await _tagsService.loadTags(event.param); final tags = await _tagsService.loadTags();
emit(TagsLoaded(tags)); emit(TagsLoaded(tags));
} on APIException catch (e) { } on APIException catch (e) {
emit(TagsFailure(e.message)); emit(TagsFailure(e.message));

View File

@ -8,10 +8,5 @@ abstract class TagsEvent extends Equatable {
} }
class LoadTags extends TagsEvent { class LoadTags extends TagsEvent {
final LoadTagsParam param; const LoadTags();
const LoadTags(this.param);
@override
List<Object?> get props => [param];
} }

View File

@ -0,0 +1,100 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/products/data/services/remote_products_service.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/models/product.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/products/presentation/bloc/products_bloc.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/space_details/presentation/widgets/space_details_action_buttons.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/tags/presentation/widgets/products_grid.dart';
import 'package:syncrow_web/services/api/http_service.dart';
import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart';
class AddDeviceTypeWidget extends StatefulWidget {
const AddDeviceTypeWidget({super.key});
@override
State<AddDeviceTypeWidget> createState() => _AddDeviceTypeWidgetState();
}
class _AddDeviceTypeWidgetState extends State<AddDeviceTypeWidget> {
final Map<Product, int> _selectedProducts = {};
void _onIncrement(Product product) {
setState(() {
_selectedProducts[product] = (_selectedProducts[product] ?? 0) + 1;
});
}
void _onDecrement(Product product) {
setState(() {
if ((_selectedProducts[product] ?? 0) > 0) {
_selectedProducts[product] = _selectedProducts[product]! - 1;
if (_selectedProducts[product] == 0) {
_selectedProducts.remove(product);
}
}
});
}
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => ProductsBloc(RemoteProductsService(HTTPService()))
..add(const LoadProducts()),
child: Builder(
builder: (context) => AlertDialog(
title: const SelectableText('Add Devices'),
backgroundColor: ColorsManager.whiteColors,
content: BlocBuilder<ProductsBloc, ProductsState>(
builder: (context, state) => switch (state) {
ProductsInitial() || ProductsLoading() => _buildLoading(context),
ProductsLoaded(:final products) => ProductsGrid(
products: products,
selectedProducts: _selectedProducts,
onIncrement: _onIncrement,
onDecrement: _onDecrement,
),
ProductsFailure(:final errorMessage) => _buildFailure(
context,
errorMessage,
),
},
),
actions: [
SpaceDetailsActionButtons(
onSave: () {
final result = _selectedProducts.entries
.expand((entry) => List.generate(entry.value, (_) => entry.key))
.toList();
Navigator.of(context).pop(result);
},
onCancel: Navigator.of(context).pop,
saveButtonLabel: 'Next',
),
],
),
),
);
}
Widget _buildLoading(BuildContext context) => SizedBox(
width: context.screenWidth * 0.9,
height: context.screenHeight * 0.65,
child: const Center(child: CircularProgressIndicator()),
);
Widget _buildFailure(BuildContext context, String errorMessage) {
return SizedBox(
width: context.screenWidth * 0.9,
height: context.screenHeight * 0.65,
child: Center(
child: SelectableText(
errorMessage,
style: context.textTheme.bodyMedium?.copyWith(
color: context.theme.colorScheme.error,
),
),
),
);
}
}

View File

@ -0,0 +1,231 @@
import 'package:flutter/material.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/models/product.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/space_details/domain/models/space_details_model.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/space_details/presentation/widgets/space_details_action_buttons.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/models/tag.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/tags/presentation/widgets/add_device_type_widget.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/tags/presentation/widgets/assign_tags_error_messages.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/tags/presentation/widgets/assign_tags_table.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart';
import 'package:uuid/uuid.dart';
class AssignTagsDialog extends StatefulWidget {
const AssignTagsDialog({required this.space, super.key});
final SpaceDetailsModel space;
@override
State<AssignTagsDialog> createState() => _AssignTagsDialogState();
}
class _AssignTagsDialogState extends State<AssignTagsDialog> {
late SpaceDetailsModel _space;
final Map<String, String> _validationErrors = {};
@override
void initState() {
super.initState();
_space = widget.space.copyWith(
productAllocations:
widget.space.productAllocations.map((e) => e.copyWith()).toList(),
subspaces: widget.space.subspaces
.map(
(s) => s.copyWith(
productAllocations:
s.productAllocations.map((e) => e.copyWith()).toList(),
),
)
.toList(),
);
_validateAllTags();
}
void _validateAllTags() {
final newErrors = <String, String>{};
final allAllocations = [
..._space.productAllocations,
..._space.subspaces.expand((s) => s.productAllocations),
];
final allocationsByProductType = <String, List<ProductAllocation>>{};
for (final allocation in allAllocations) {
(allocationsByProductType[allocation.product.productType] ??= [])
.add(allocation);
}
for (final productType in allocationsByProductType.keys) {
final allocations = allocationsByProductType[productType]!;
final tagCounts = <String, int>{};
for (final allocation in allocations) {
final tagName = allocation.tag.name.trim().toLowerCase();
if (tagName.isEmpty) {
newErrors[allocation.uuid] =
'Tag for ${allocation.product.name} cannot be empty.';
} else {
tagCounts[tagName] = (tagCounts[tagName] ?? 0) + 1;
}
}
for (final allocation in allocations) {
final tagName = allocation.tag.name.trim().toLowerCase();
if (tagName.isNotEmpty && (tagCounts[tagName] ?? 0) > 1) {
newErrors[allocation.uuid] =
'Tag "${allocation.tag.name}" is used by multiple $productType devices.';
}
}
}
setState(() {
_validationErrors
..clear()
..addAll(newErrors);
});
}
void _handleTagChange(String allocationUuid, Tag newTag) {
setState(() {
var index =
_space.productAllocations.indexWhere((pa) => pa.uuid == allocationUuid);
if (index != -1) {
final allocation = _space.productAllocations[index];
_space.productAllocations[index] = allocation.copyWith(tag: newTag);
} else {
for (final subspace in _space.subspaces) {
index = subspace.productAllocations
.indexWhere((pa) => pa.uuid == allocationUuid);
if (index != -1) {
final allocation = subspace.productAllocations[index];
subspace.productAllocations[index] = allocation.copyWith(tag: newTag);
break;
}
}
}
});
_validateAllTags();
}
void _handleLocationChange(String allocationUuid, String? newSubspaceUuid) {
setState(() {
ProductAllocation? allocationToMove;
var index =
_space.productAllocations.indexWhere((pa) => pa.uuid == allocationUuid);
if (index != -1) {
allocationToMove = _space.productAllocations.removeAt(index);
} else {
for (final subspace in _space.subspaces) {
index = subspace.productAllocations
.indexWhere((pa) => pa.uuid == allocationUuid);
if (index != -1) {
allocationToMove = subspace.productAllocations.removeAt(index);
break;
}
}
}
if (allocationToMove == null) return;
if (newSubspaceUuid == null) {
_space.productAllocations.add(allocationToMove);
} else {
_space.subspaces
.firstWhere((s) => s.uuid == newSubspaceUuid)
.productAllocations
.add(allocationToMove);
}
});
}
void _handleProductDelete(String allocationUuid) {
setState(() {
_space.productAllocations.removeWhere((pa) => pa.uuid == allocationUuid);
for (final subspace in _space.subspaces) {
subspace.productAllocations.removeWhere(
(pa) => pa.uuid == allocationUuid,
);
}
});
_validateAllTags();
}
@override
Widget build(BuildContext context) {
final allProductAllocations = [
..._space.productAllocations,
..._space.subspaces.expand((s) => s.productAllocations),
];
final productLocations = <String, String?>{};
for (final pa in _space.productAllocations) {
productLocations[pa.uuid] = null;
}
for (final subspace in _space.subspaces) {
for (final pa in subspace.productAllocations) {
productLocations[pa.uuid] = subspace.uuid;
}
}
final hasErrors = _validationErrors.isNotEmpty;
return AlertDialog(
title: const SelectableText('Assign Tags'),
content: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: context.screenWidth * 0.6,
minWidth: context.screenWidth * 0.6,
maxHeight: context.screenHeight * 0.8,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: double.infinity,
child: AssignTagsTable(
productAllocations: allProductAllocations,
subspaces: _space.subspaces,
productLocations: productLocations,
onTagSelected: _handleTagChange,
onLocationSelected: _handleLocationChange,
onProductDeleted: _handleProductDelete,
),
),
if (hasErrors)
AssignTagsErrorMessages(
errorMessages: _validationErrors.values.toSet().toList(),
),
],
),
),
actions: [
SpaceDetailsActionButtons(
onSave: hasErrors ? null : () => Navigator.of(context).pop(_space),
onCancel: () async {
final newProducts = await showDialog<List<Product>>(
context: context,
builder: (context) => const AddDeviceTypeWidget(),
);
if (newProducts == null || newProducts.isEmpty) return;
setState(() {
for (final product in newProducts) {
_space.productAllocations.add(
ProductAllocation(
uuid: const Uuid().v4(),
product: product,
tag: Tag.empty(),
),
);
}
});
_validateAllTags();
},
cancelButtonLabel: 'Add New Device',
)
],
);
}
}

View File

@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart';
class AssignTagsErrorMessages extends StatelessWidget {
const AssignTagsErrorMessages({super.key, required this.errorMessages});
final List<String> errorMessages;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: errorMessages
.map(
(error) => Text(
'- $error',
style: context.textTheme.bodyMedium?.copyWith(
color: context.theme.colorScheme.error,
),
),
)
.toList(),
),
);
}
}

View File

@ -0,0 +1,204 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/common/dialog_dropdown.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/space_details/domain/models/space_details_model.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/tags/data/services/remote_tags_service.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/models/tag.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/tags/presentation/bloc/tags_bloc.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/tags/presentation/widgets/product_tag_field.dart';
import 'package:syncrow_web/services/api/http_service.dart';
import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart';
class AssignTagsTable extends StatelessWidget {
const AssignTagsTable({
required this.productAllocations,
required this.subspaces,
required this.productLocations,
required this.onTagSelected,
required this.onLocationSelected,
required this.onProductDeleted,
super.key,
});
final List<ProductAllocation> productAllocations;
final List<Subspace> subspaces;
final Map<String, String?> productLocations;
final void Function(String, Tag) onTagSelected;
final void Function(String, String?) onLocationSelected;
final void Function(String) onProductDeleted;
DataColumn _buildDataColumn(BuildContext context, String label) {
return DataColumn(
label: SelectableText(label, style: context.textTheme.bodyMedium),
);
}
@override
Widget build(BuildContext context) {
return BlocProvider<TagsBloc>(
create: (BuildContext context) => TagsBloc(
RemoteTagsService(HTTPService()),
)..add(const LoadTags()),
child: BlocBuilder<TagsBloc, TagsState>(
builder: (context, state) {
return switch (state) {
TagsLoading() || TagsInitial() => const Center(
child: CircularProgressIndicator(),
),
TagsFailure(:final message) => Center(
child: Text(message),
),
TagsLoaded(:final tags) => ClipRRect(
borderRadius: BorderRadius.circular(20),
child: DataTable(
headingRowColor: WidgetStateProperty.all(
ColorsManager.dataHeaderGrey,
),
key: ValueKey(productAllocations.length),
border: TableBorder.all(
color: ColorsManager.dataHeaderGrey,
width: 1,
borderRadius: BorderRadius.circular(20),
),
columns: [
_buildDataColumn(context, '#'),
_buildDataColumn(context, 'Device'),
_buildDataColumn(context, 'Tag'),
_buildDataColumn(context, 'Location'),
],
rows: productAllocations.isEmpty
? [
DataRow(
cells: [
DataCell(
Center(
child: SelectableText(
'No Devices Available',
style: context.textTheme.bodyMedium?.copyWith(
color: ColorsManager.lightGrayColor,
),
),
),
),
DataCell.empty,
DataCell.empty,
DataCell.empty,
],
),
]
: List.generate(productAllocations.length, (index) {
final productAllocation = productAllocations[index];
final allocationUuid = productAllocation.uuid;
final availableTags = tags
.where(
(tag) =>
!productAllocations
.where((p) =>
p.product.productType ==
productAllocation.product.productType)
.map((p) => p.tag.name.toLowerCase())
.contains(tag.name.toLowerCase()) ||
tag.uuid == productAllocation.tag.uuid,
)
.toList();
final currentLocationUuid =
productLocations[allocationUuid];
final currentLocationName = currentLocationUuid == null
? 'Main Space'
: subspaces
.firstWhere((s) => s.uuid == currentLocationUuid)
.name;
return DataRow(
key: ValueKey(allocationUuid),
cells: [
DataCell(Text((index + 1).toString())),
DataCell(
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
productAllocation.product.name,
overflow: TextOverflow.ellipsis,
)),
const SizedBox(width: 10),
Container(
width: 20,
height: 20,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: ColorsManager.lightGrayColor,
width: 1,
),
),
child: IconButton(
icon: const Icon(
Icons.close,
color: ColorsManager.lightGreyColor,
size: 16,
),
onPressed: () {
onProductDeleted(allocationUuid);
},
tooltip: 'Delete Tag',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
),
],
),
),
DataCell(
Container(
alignment: Alignment.centerLeft,
width: double.infinity,
child: ProductTagField(
key: ValueKey('dropdown_$allocationUuid'),
productName: productAllocation.product.uuid,
initialValue: productAllocation.tag,
onSelected: (newTag) {
onTagSelected(allocationUuid, newTag);
},
items: availableTags,
),
),
),
DataCell(
SizedBox(
width: double.infinity,
child: DialogDropdown(
items: [
'Main Space',
...subspaces.map((s) => s.name)
],
selectedValue: currentLocationName,
onSelected: (newLocationName) {
final newSubspaceUuid = newLocationName ==
'Main Space'
? null
: subspaces
.firstWhere(
(s) => s.name == newLocationName)
.uuid;
onLocationSelected(
allocationUuid, newSubspaceUuid);
},
)),
),
],
);
}),
),
),
_ => const SizedBox.shrink(),
};
},
),
);
}
}

View File

@ -0,0 +1,186 @@
import 'package:flutter/material.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/models/tag.dart';
import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart';
class ProductTagField extends StatefulWidget {
final List<Tag> items;
final ValueChanged<Tag> onSelected;
final Tag? initialValue;
final String productName;
const ProductTagField({
super.key,
required this.items,
required this.onSelected,
this.initialValue,
required this.productName,
});
@override
State<ProductTagField> createState() => _ProductTagFieldState();
}
class _ProductTagFieldState extends State<ProductTagField> {
bool _isOpen = false;
OverlayEntry? _overlayEntry;
final TextEditingController _controller = TextEditingController();
final FocusNode _focusNode = FocusNode();
@override
void initState() {
super.initState();
_controller.text = widget.initialValue?.name ?? '';
_focusNode.addListener(_handleFocusChange);
}
@override
void dispose() {
_focusNode.removeListener(_handleFocusChange);
_controller.dispose();
_focusNode.dispose();
_overlayEntry?.remove();
_overlayEntry = null;
super.dispose();
}
void _handleFocusChange() {
if (!_focusNode.hasFocus) {
_submit(_controller.text);
}
}
void _submit(String value) {
final lowerCaseValue = value.toLowerCase();
final selectedTag = widget.items.firstWhere(
(tag) => tag.name.toLowerCase() == lowerCaseValue,
orElse: () => Tag(
name: value,
uuid: '',
createdAt: '',
updatedAt: '',
),
);
widget.onSelected(selectedTag);
_closeDropdown();
}
void _toggleDropdown() {
if (_isOpen) {
_closeDropdown();
} else {
_openDropdown();
}
}
void _openDropdown() {
_overlayEntry = _createOverlayEntry();
Overlay.of(context).insert(_overlayEntry!);
setState(() => _isOpen = true);
}
void _closeDropdown() {
if (_isOpen) {
_overlayEntry?.remove();
_overlayEntry = null;
setState(() => _isOpen = false);
}
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
behavior: HitTestBehavior.opaque,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
decoration: BoxDecoration(
border: Border.all(color: ColorsManager.transparentColor),
borderRadius: BorderRadius.circular(8.0),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: TextFormField(
controller: _controller,
focusNode: _focusNode,
onFieldSubmitted: _submit,
style: context.textTheme.bodyMedium,
decoration: const InputDecoration(
hintText: 'Enter or Select a tag',
border: InputBorder.none,
),
),
),
GestureDetector(
onTap: _toggleDropdown,
child: const Icon(Icons.arrow_drop_down),
),
],
),
),
);
}
OverlayEntry _createOverlayEntry() {
final renderBox = context.findRenderObject()! as RenderBox;
final size = renderBox.size;
final offset = renderBox.localToGlobal(Offset.zero);
return OverlayEntry(
builder: (context) {
return GestureDetector(
onTap: _closeDropdown,
behavior: HitTestBehavior.translucent,
child: Stack(
children: [
Positioned(
left: offset.dx,
top: offset.dy + size.height,
width: size.width,
child: Material(
elevation: 4.0,
child: Container(
color: ColorsManager.whiteColors,
constraints: const BoxConstraints(maxHeight: 200.0),
child: ListView.builder(
shrinkWrap: true,
itemCount: widget.items.length,
itemBuilder: (context, index) {
final tag = widget.items[index];
return Container(
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(
color: ColorsManager.lightGrayBorderColor,
width: 1.0,
),
),
),
child: ListTile(
title: Text(
tag.name,
style: context.textTheme.bodyMedium?.copyWith(
color: ColorsManager.textPrimaryColor,
),
),
onTap: () {
_controller.text = tag.name;
_submit(tag.name);
_closeDropdown();
},
),
);
},
),
),
),
),
],
),
);
},
);
}
}

View File

@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/models/product.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/tags/presentation/widgets/product_type_card_counter.dart';
import 'package:syncrow_web/pages/spaces_management/tag_model/widgets/device_icon_widget.dart';
import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart';
class ProductTypeCard extends StatelessWidget {
const ProductTypeCard({
required this.product,
required this.count,
required this.onIncrement,
required this.onDecrement,
super.key,
});
final Product product;
final int count;
final void Function() onIncrement;
final void Function() onDecrement;
@override
Widget build(BuildContext context) {
return Card(
elevation: 2,
color: ColorsManager.whiteColors,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.all(4),
child: Column(
spacing: 8,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(child: DeviceIconWidget(icon: product.icon)),
_buildName(context, product.name),
ProductTypeCardCounter(
onIncrement: onIncrement,
onDecrement: onDecrement,
count: count,
),
const SizedBox(height: 4),
],
),
),
);
}
Widget _buildName(BuildContext context, String name) {
return Expanded(
child: SizedBox(
height: 35,
child: Text(
name,
style: context.textTheme.bodySmall?.copyWith(
color: ColorsManager.blackColor,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
);
}
}

View File

@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart';
class ProductTypeCardCounter extends StatelessWidget {
const ProductTypeCardCounter({
super.key,
required this.onIncrement,
required this.onDecrement,
required this.count,
});
final int count;
final void Function() onIncrement;
final void Function() onDecrement;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: ColorsManager.counterBackgroundColor,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
_buildCounterButton(
Icons.remove,
onDecrement,
),
Text(
count.toString(),
style: context.textTheme.bodyLarge?.copyWith(
color: ColorsManager.spaceColor,
),
),
_buildCounterButton(Icons.add, onIncrement),
],
),
);
}
Widget _buildCounterButton(
IconData icon,
VoidCallback onPressed,
) {
return GestureDetector(
onTap: onPressed,
child: Icon(
icon,
color: ColorsManager.spaceColor.withValues(alpha: 0.3),
size: 18,
),
);
}
}

View File

@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/models/product.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/tags/presentation/widgets/product_type_card.dart';
import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart';
class ProductsGrid extends StatelessWidget {
const ProductsGrid({
required this.products,
required this.selectedProducts,
required this.onIncrement,
required this.onDecrement,
super.key,
});
final List<Product> products;
final Map<Product, int> selectedProducts;
final void Function(Product) onIncrement;
final void Function(Product) onDecrement;
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
final crossAxisCount = switch (context.screenWidth) {
> 1200 => 8,
> 800 => 5,
_ => 3,
};
return SingleChildScrollView(
child: Container(
width: size.width * 0.9,
height: size.height * 0.65,
decoration: BoxDecoration(
color: ColorsManager.textFieldGreyColor,
borderRadius: BorderRadius.circular(8),
),
child: GridView.builder(
padding: const EdgeInsets.symmetric(
horizontal: 20,
),
shrinkWrap: true,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
mainAxisSpacing: 6,
crossAxisSpacing: 4,
childAspectRatio: 0.8,
),
itemCount: products.length,
itemBuilder: (context, index) {
final product = products[index];
return ProductTypeCard(
product: product,
count: selectedProducts[product] ?? 0,
onIncrement: () => onIncrement(product),
onDecrement: () => onDecrement(product),
);
},
),
),
);
}
}

View File

@ -1,5 +1,7 @@
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/space_details/domain/models/space_details_model.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/space_details/domain/models/space_details_model.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/update_space/domain/params/update_space_param.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/update_space/domain/services/update_space_service.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/update_space/domain/services/update_space_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';
@ -12,17 +14,23 @@ class RemoteUpdateSpaceService implements UpdateSpaceService {
static const _defaultErrorMessage = 'Failed to update space'; static const _defaultErrorMessage = 'Failed to update space';
@override @override
Future<SpaceDetailsModel> updateSpace(SpaceDetailsModel space) async { Future<SpaceDetailsModel> updateSpace(UpdateSpaceParam param) async {
try { try {
final response = await _httpService.put( final path = await _makeUrl(param);
path: 'endpoint', await _httpService.put(
body: space.toJson(), path: path,
expectedResponseModel: (data) => SpaceDetailsModel.fromJson( body: param.space.toJson(),
data as Map<String, dynamic>, expectedResponseModel: (data) {
), final response = data as Map<String, dynamic>;
final isSuccess = response['success'] as bool;
if (!isSuccess) {
throw APIException(response['error'] as String);
}
return isSuccess;
},
); );
return response; return param.space;
} 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>?;
@ -37,4 +45,23 @@ class RemoteUpdateSpaceService implements UpdateSpaceService {
throw APIException(formattedErrorMessage); throw APIException(formattedErrorMessage);
} }
} }
Future<String> _makeUrl(UpdateSpaceParam param) async {
final projectUuid = await ProjectManager.getProjectUUID();
if (projectUuid == null || projectUuid.isEmpty) {
throw APIException('Project UUID is not set');
}
final spaceUuid = param.space.uuid;
if (spaceUuid.isEmpty) {
throw APIException('Space UUID is not set');
}
final communityUuid = param.communityUuid;
if (communityUuid.isEmpty) {
throw APIException('Community UUID is not set');
}
return '/projects/$projectUuid/communities/$communityUuid/spaces/$spaceUuid';
}
} }

View File

@ -0,0 +1,45 @@
import 'package:syncrow_web/pages/space_management_v2/modules/space_details/domain/models/space_details_model.dart';
class UpdateSpaceParam {
UpdateSpaceParam({
required this.space,
required this.communityUuid,
});
final SpaceDetailsModel space;
final String communityUuid;
Map<String, dynamic> toJson() {
return {
'spaceName': space.spaceName,
'icon': space.icon,
'subspaces': space.subspaces
.map(
(e) => {
'subspaceName': e.name,
'productAllocations': e.productAllocations
.map(
(e) => {
'name': e.tag.name,
'productUuid': e.product.uuid,
'uuid': e.uuid,
},
)
.toList(),
'uuid': e.uuid,
},
)
.toList(),
'productAllocations': space.productAllocations
.map(
(e) => {
'tagName': e.tag.name,
'tagUuid': e.tag.uuid,
'productUuid': e.product.uuid,
},
)
.toList(),
'spaceModelUuid': space.uuid,
};
}
}

View File

@ -1,5 +1,6 @@
import 'package:syncrow_web/pages/space_management_v2/modules/space_details/domain/models/space_details_model.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/space_details/domain/models/space_details_model.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/update_space/domain/params/update_space_param.dart';
abstract class UpdateSpaceService { abstract interface class UpdateSpaceService {
Future<SpaceDetailsModel> updateSpace(SpaceDetailsModel space); Future<SpaceDetailsModel> updateSpace(UpdateSpaceParam param);
} }

View File

@ -13,6 +13,7 @@ class SpaceDetailsModelBloc extends Bloc<SpaceDetailsModelEvent, SpaceDetailsMod
on<UpdateSpaceDetailsSubspaces>(_onUpdateSpaceDetailsSubspaces); on<UpdateSpaceDetailsSubspaces>(_onUpdateSpaceDetailsSubspaces);
on<UpdateSpaceDetailsProductAllocations>( on<UpdateSpaceDetailsProductAllocations>(
_onUpdateSpaceDetailsProductAllocations); _onUpdateSpaceDetailsProductAllocations);
on<UpdateSpaceDetails>(_onUpdateSpaceDetails);
} }
void _onUpdateSpaceDetailsIcon( void _onUpdateSpaceDetailsIcon(
@ -42,4 +43,11 @@ class SpaceDetailsModelBloc extends Bloc<SpaceDetailsModelEvent, SpaceDetailsMod
) { ) {
emit(state.copyWith(productAllocations: event.productAllocations)); emit(state.copyWith(productAllocations: event.productAllocations));
} }
void _onUpdateSpaceDetails(
UpdateSpaceDetails event,
Emitter<SpaceDetailsModel> emit,
) {
emit(event.space);
}
} }

View File

@ -42,3 +42,12 @@ final class UpdateSpaceDetailsProductAllocations extends SpaceDetailsModelEvent
@override @override
List<Object> get props => [productAllocations]; List<Object> get props => [productAllocations];
} }
final class UpdateSpaceDetails extends SpaceDetailsModelEvent {
const UpdateSpaceDetails(this.space);
final SpaceDetailsModel space;
@override
List<Object> get props => [space];
}

View File

@ -1,6 +1,7 @@
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/space_details/domain/models/space_details_model.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/space_details/domain/models/space_details_model.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/update_space/domain/params/update_space_param.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/update_space/domain/services/update_space_service.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/update_space/domain/services/update_space_service.dart';
import 'package:syncrow_web/services/api/api_exception.dart'; import 'package:syncrow_web/services/api/api_exception.dart';
@ -20,7 +21,7 @@ class UpdateSpaceBloc extends Bloc<UpdateSpaceEvent, UpdateSpaceState> {
) async { ) async {
emit(UpdateSpaceLoading()); emit(UpdateSpaceLoading());
try { try {
final updatedSpace = await _updateSpaceService.updateSpace(event.space); final updatedSpace = await _updateSpaceService.updateSpace(event.param);
emit(UpdateSpaceSuccess(updatedSpace)); emit(UpdateSpaceSuccess(updatedSpace));
} on APIException catch (e) { } on APIException catch (e) {
emit(UpdateSpaceFailure(e.message)); emit(UpdateSpaceFailure(e.message));

View File

@ -8,10 +8,10 @@ sealed class UpdateSpaceEvent extends Equatable {
} }
final class UpdateSpace extends UpdateSpaceEvent { final class UpdateSpace extends UpdateSpaceEvent {
const UpdateSpace(this.space); const UpdateSpace(this.param);
final SpaceDetailsModel space; final UpdateSpaceParam param;
@override @override
List<Object> get props => [space]; List<Object> get props => [param];
} }

View File

@ -21,10 +21,10 @@ final class UpdateSpaceSuccess extends UpdateSpaceState {
} }
final class UpdateSpaceFailure extends UpdateSpaceState { final class UpdateSpaceFailure extends UpdateSpaceState {
final String message; final String errorMessage;
const UpdateSpaceFailure(this.message); const UpdateSpaceFailure(this.errorMessage);
@override @override
List<Object> get props => [message]; List<Object> get props => [errorMessage];
} }