diff --git a/lib/pages/space_management_v2/main_module/models/space_connection_model.dart b/lib/pages/space_management_v2/main_module/models/space_connection_model.dart new file mode 100644 index 00000000..538a922c --- /dev/null +++ b/lib/pages/space_management_v2/main_module/models/space_connection_model.dart @@ -0,0 +1,6 @@ +class SpaceConnectionModel { + final String from; + final String to; + + const SpaceConnectionModel({required this.from, required this.to}); +} diff --git a/lib/pages/space_management_v2/main_module/painters/spaces_connections_arrow_painter.dart b/lib/pages/space_management_v2/main_module/painters/spaces_connections_arrow_painter.dart new file mode 100644 index 00000000..fcf523bf --- /dev/null +++ b/lib/pages/space_management_v2/main_module/painters/spaces_connections_arrow_painter.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:syncrow_web/pages/space_management_v2/main_module/models/space_connection_model.dart'; +import 'package:syncrow_web/utils/color_manager.dart'; + +class SpacesConnectionsArrowPainter extends CustomPainter { + final List connections; + final Map positions; + final double cardWidth = 150.0; + final double cardHeight = 90.0; + final String? selectedSpaceUuid; + + SpacesConnectionsArrowPainter({ + required this.connections, + required this.positions, + this.selectedSpaceUuid, + }); + + @override + void paint(Canvas canvas, Size size) { + for (final connection in connections) { + final isSelected = connection.to == selectedSpaceUuid; + final paint = Paint() + ..color = isSelected + ? ColorsManager.primaryColor + : ColorsManager.blackColor.withValues(alpha: 0.5) + ..strokeWidth = 2.0 + ..style = PaintingStyle.stroke; + + final from = positions[connection.from]; + final to = positions[connection.to]; + + if (from != null && to != null) { + final startPoint = + Offset(from.dx + cardWidth / 2, from.dy + cardHeight - 10); + final endPoint = Offset(to.dx + cardWidth / 2, to.dy); + + final path = Path()..moveTo(startPoint.dx, startPoint.dy); + + final controlPoint1 = Offset(startPoint.dx, startPoint.dy + 60); + final controlPoint2 = Offset(endPoint.dx, endPoint.dy - 60); + + path.cubicTo(controlPoint1.dx, controlPoint1.dy, controlPoint2.dx, + controlPoint2.dy, endPoint.dx, endPoint.dy); + + canvas.drawPath(path, paint); + + final circlePaint = Paint() + ..color = isSelected + ? ColorsManager.primaryColor + : ColorsManager.blackColor.withValues(alpha: 0.5) + ..style = PaintingStyle.fill + ..blendMode = BlendMode.srcIn; + canvas.drawCircle(endPoint, 4, circlePaint); + } + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => true; +} diff --git a/lib/pages/space_management_v2/main_module/widgets/community_structure_canvas.dart b/lib/pages/space_management_v2/main_module/widgets/community_structure_canvas.dart new file mode 100644 index 00000000..92c5add6 --- /dev/null +++ b/lib/pages/space_management_v2/main_module/widgets/community_structure_canvas.dart @@ -0,0 +1,236 @@ +import 'package:flutter/material.dart'; +import 'package:syncrow_web/pages/space_management_v2/main_module/models/space_connection_model.dart'; +import 'package:syncrow_web/pages/space_management_v2/main_module/painters/spaces_connections_arrow_painter.dart'; +import 'package:syncrow_web/pages/space_management_v2/main_module/widgets/space_card_widget.dart'; +import 'package:syncrow_web/pages/space_management_v2/main_module/widgets/space_cell.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/models/community_model.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/models/space_model.dart'; + +class CommunityStructureCanvas extends StatefulWidget { + const CommunityStructureCanvas({ + required this.community, + super.key, + }); + + final CommunityModel community; + + @override + State createState() =>_CommunityStructureCanvasState(); +} + +class _CommunityStructureCanvasState extends State + with SingleTickerProviderStateMixin { + final Map _positions = {}; + final double _cardWidth = 150.0; + final double _cardHeight = 90.0; + final double _horizontalSpacing = 150.0; + final double _verticalSpacing = 120.0; + String? _selectedSpaceUuid; + + late TransformationController _transformationController; + late AnimationController _animationController; + + @override + void initState() { + super.initState(); + _transformationController = TransformationController(); + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 100), + ); + } + + @override + void dispose() { + _transformationController.dispose(); + _animationController.dispose(); + super.dispose(); + } + + void _runAnimation(Matrix4 target) { + final animation = Matrix4Tween( + begin: _transformationController.value, + end: target, + ).animate(_animationController); + + void listener() { + _transformationController.value = animation.value; + } + + animation.addListener(listener); + _animationController.forward(from: 0).whenCompleteOrCancel(() { + animation.removeListener(listener); + }); + } + + void _onSpaceTapped(String spaceUuid) { + setState(() { + _selectedSpaceUuid = spaceUuid; + }); + + final position = _positions[spaceUuid]; + if (position == null) return; + + const scale = 2.0; + final viewSize = context.size; + if (viewSize == null) return; + + final x = -position.dx * scale + (viewSize.width / 2) - (_cardWidth * scale / 2); + final y = + -position.dy * scale + (viewSize.height / 2) - (_cardHeight * scale / 2); + + final matrix = Matrix4.identity() + ..translate(x, y) + ..scale(scale); + + _runAnimation(matrix); + } + + void _resetSelectionAndZoom() { + setState(() { + _selectedSpaceUuid = null; + }); + _runAnimation(Matrix4.identity()); + } + + void _calculateLayout( + List spaces, + int depth, + Map levelXOffset, + ) { + for (final space in spaces) { + double childSubtreeWidth = 0; + if (space.children.isNotEmpty) { + _calculateLayout(space.children, depth + 1, levelXOffset); + final firstChildPos = _positions[space.children.first.uuid]; + final lastChildPos = _positions[space.children.last.uuid]; + if (firstChildPos != null && lastChildPos != null) { + childSubtreeWidth = (lastChildPos.dx + _cardWidth) - firstChildPos.dx; + } + } + + final currentX = levelXOffset.putIfAbsent(depth, () => 0.0); + double? x; + + if (space.children.isNotEmpty) { + final firstChildPos = _positions[space.children.first.uuid]!; + x = firstChildPos.dx + (childSubtreeWidth - _cardWidth) / 2; + } else { + x = currentX; + } + + if (x < currentX) { + final shiftX = currentX - x; + _shiftSubtree(space, shiftX); + final keysToShift = levelXOffset.keys.where((d) => d > depth).toList(); + for (final key in keysToShift) { + levelXOffset[key] = levelXOffset[key]! + shiftX; + } + x += shiftX; + } + + final y = depth * (_verticalSpacing + _cardHeight); + _positions[space.uuid] = Offset(x, y); + levelXOffset[depth] = x + _cardWidth + _horizontalSpacing; + } + } + + void _shiftSubtree(SpaceModel space, double shiftX) { + if (_positions.containsKey(space.uuid)) { + _positions[space.uuid] = _positions[space.uuid]!.translate(shiftX, 0); + } + for (final child in space.children) { + _shiftSubtree(child, shiftX); + } + } + + List _buildTreeWidgets() { + _positions.clear(); + final community = widget.community; + + _calculateLayout(community.spaces, 0, {}); + + final widgets = []; + final connections = []; + _generateWidgets(community.spaces, widgets, connections); + + return [ + CustomPaint( + painter: SpacesConnectionsArrowPainter( + connections: connections, + positions: _positions, + selectedSpaceUuid: _selectedSpaceUuid, + ), + child: Stack(alignment: AlignmentDirectional.center, children: widgets), + ), + ]; + } + + void _generateWidgets( + List spaces, + List widgets, + List connections, + ) { + for (final space in spaces) { + final position = _positions[space.uuid]; + if (position == null) continue; + + widgets.add( + Positioned( + left: position.dx, + top: position.dy, + width: _cardWidth, + height: _cardHeight, + child: SpaceCardWidget( + index: spaces.indexOf(space), + onPositionChanged: (newPosition) {}, + buildSpaceContainer: (index) { + return Opacity( + opacity: 1.0, + child: SpaceCell( + index: index, + onTap: () => _onSpaceTapped(space.uuid), + icon: space.icon, + name: space.spaceName, + ), + ); + }, + screenSize: MediaQuery.sizeOf(context), + position: position, + isHovered: false, + onHoverChanged: (int index, bool isHovered) {}, + onButtonTap: (int index, Offset newPosition) {}, + ), + ), + ); + + for (final child in space.children) { + connections.add(SpaceConnectionModel(from: space.uuid, to: child.uuid)); + } + _generateWidgets(space.children, widgets, connections); + } + } + + @override + Widget build(BuildContext context) { + final treeWidgets = _buildTreeWidgets(); + return InteractiveViewer( + transformationController: _transformationController, + boundaryMargin: EdgeInsets.symmetric( + horizontal: MediaQuery.sizeOf(context).width * 0.3, + vertical: MediaQuery.sizeOf(context).height * 0.2, + ), + minScale: 0.5, + maxScale: 3.0, + constrained: false, + child: GestureDetector( + onTap: _resetSelectionAndZoom, + child: SizedBox( + width: MediaQuery.sizeOf(context).width * 2, + height: MediaQuery.sizeOf(context).height * 2, + child: Stack(children: treeWidgets), + ), + ), + ); + } +} diff --git a/lib/pages/space_management_v2/main_module/widgets/create_space_button.dart b/lib/pages/space_management_v2/main_module/widgets/create_space_button.dart new file mode 100644 index 00000000..5caf6a81 --- /dev/null +++ b/lib/pages/space_management_v2/main_module/widgets/create_space_button.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:syncrow_web/utils/color_manager.dart'; + +class CreateSpaceButton extends StatelessWidget { + const CreateSpaceButton({super.key}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () {}, + child: Container( + height: 60, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.grey.withValues(alpha: 0.5), + spreadRadius: 5, + blurRadius: 7, + offset: const Offset(0, 3), + ), + ], + ), + child: Center( + child: Container( + width: 40, + height: 40, + decoration: const BoxDecoration( + color: ColorsManager.boxColor, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.add, + color: Colors.blue, + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/space_management_v2/main_module/widgets/plus_button_widget.dart b/lib/pages/space_management_v2/main_module/widgets/plus_button_widget.dart new file mode 100644 index 00000000..755a6ab9 --- /dev/null +++ b/lib/pages/space_management_v2/main_module/widgets/plus_button_widget.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:syncrow_web/utils/color_manager.dart'; + +class PlusButtonWidget extends StatelessWidget { + final int index; + final String direction; + final Offset offset; + final void Function(int index, Offset newPosition) onButtonTap; + + const PlusButtonWidget({ + super.key, + required this.index, + required this.direction, + required this.offset, + required this.onButtonTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + if (direction == 'down') { + onButtonTap(index, const Offset(0, 150)); + } else { + onButtonTap(index, const Offset(150, 0)); + } + }, + child: Container( + width: 30, + height: 30, + decoration: const BoxDecoration( + color: ColorsManager.spaceColor, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.add, + color: ColorsManager.whiteColors, + size: 20, + ), + ), + ); + } +} diff --git a/lib/pages/space_management_v2/main_module/widgets/space_card_widget.dart b/lib/pages/space_management_v2/main_module/widgets/space_card_widget.dart new file mode 100644 index 00000000..1ce28502 --- /dev/null +++ b/lib/pages/space_management_v2/main_module/widgets/space_card_widget.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:syncrow_web/pages/space_management_v2/main_module/widgets/plus_button_widget.dart'; + +class SpaceCardWidget extends StatelessWidget { + final int index; + final Size screenSize; + final Offset position; + final bool isHovered; + final void Function(int index, bool isHovered) onHoverChanged; + final void Function(int index, Offset newPosition) onButtonTap; + final Widget Function(int index) buildSpaceContainer; + final ValueChanged onPositionChanged; + + const SpaceCardWidget({ + super.key, + required this.index, + required this.onPositionChanged, + required this.screenSize, + required this.position, + required this.isHovered, + required this.onHoverChanged, + required this.onButtonTap, + required this.buildSpaceContainer, + }); + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => onHoverChanged(index, true), + onExit: (_) => onHoverChanged(index, false), + child: SizedBox( + width: 150, + height: 90, + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + buildSpaceContainer(index), + + if (isHovered) + Positioned( + bottom: 0, + child: PlusButtonWidget( + index: index, + direction: 'down', + offset: Offset.zero, + onButtonTap: onButtonTap, + ), + ), + if (isHovered) + Positioned( + right: -15, + child: PlusButtonWidget( + index: index, + direction: 'right', + offset: Offset.zero, + onButtonTap: onButtonTap, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/space_management_v2/main_module/widgets/space_cell.dart b/lib/pages/space_management_v2/main_module/widgets/space_cell.dart new file mode 100644 index 00000000..1b08835a --- /dev/null +++ b/lib/pages/space_management_v2/main_module/widgets/space_cell.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:syncrow_web/utils/color_manager.dart'; + +class SpaceCell extends StatelessWidget { + final int index; + final String icon; + final String name; + final VoidCallback? onDoubleTap; + final VoidCallback? onTap; + + const SpaceCell({ + super.key, + required this.index, + required this.icon, + required this.name, + this.onTap, + this.onDoubleTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return GestureDetector( + onDoubleTap: onDoubleTap, + onTap: onTap, + child: Container( + width: 150, + height: 70, + decoration: _containerDecoration(), + child: Row( + children: [ + _buildIconContainer(), + const SizedBox(width: 10), + Expanded( + child: Text( + name, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.bold, + color: ColorsManager.blackColor, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ); + } + + Widget _buildIconContainer() { + return Container( + width: 40, + height: double.infinity, + decoration: const BoxDecoration( + color: ColorsManager.spaceColor, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(15), + bottomLeft: Radius.circular(15), + ), + ), + child: Center( + child: SvgPicture.asset( + icon, + color: ColorsManager.whiteColors, + width: 24, + height: 24, + ), + ), + ); + } + + BoxDecoration _containerDecoration() { + return BoxDecoration( + color: ColorsManager.whiteColors, + borderRadius: BorderRadius.circular(15), + boxShadow: [ + BoxShadow( + color: ColorsManager.lightGrayColor.withValues(alpha: 0.5), + spreadRadius: 2, + blurRadius: 5, + offset: const Offset(0, 3), + ), + ], + ); + } +} diff --git a/lib/pages/space_management_v2/main_module/widgets/space_management_body.dart b/lib/pages/space_management_v2/main_module/widgets/space_management_body.dart index 5d28a533..5d81bffb 100644 --- a/lib/pages/space_management_v2/main_module/widgets/space_management_body.dart +++ b/lib/pages/space_management_v2/main_module/widgets/space_management_body.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:syncrow_web/pages/space_management_v2/main_module/widgets/space_management_community_structure.dart'; import 'package:syncrow_web/pages/space_management_v2/main_module/widgets/space_management_templates_view.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/communities_tree_selection_bloc/communities_tree_selection_bloc.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/widgets/space_management_communities_tree.dart'; @@ -19,7 +20,7 @@ class SpaceManagementBody extends StatelessWidget { previous.selectedCommunity != current.selectedCommunity, builder: (context, state) => Visibility( visible: state.selectedCommunity == null, - replacement: const Placeholder(), + replacement: const SpaceManagementCommunityStructure(), child: const SpaceManagementTemplatesView(), ), ), diff --git a/lib/pages/space_management_v2/main_module/widgets/space_management_community_structure.dart b/lib/pages/space_management_v2/main_module/widgets/space_management_community_structure.dart new file mode 100644 index 00000000..11ee5078 --- /dev/null +++ b/lib/pages/space_management_v2/main_module/widgets/space_management_community_structure.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:syncrow_web/pages/space_management_v2/main_module/widgets/community_structure_canvas.dart'; +import 'package:syncrow_web/pages/space_management_v2/main_module/widgets/create_space_button.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/communities_tree_selection_bloc/communities_tree_selection_bloc.dart'; + +class SpaceManagementCommunityStructure extends StatelessWidget { + const SpaceManagementCommunityStructure({super.key}); + + @override + Widget build(BuildContext context) { + final selectedCommunity = + context.watch().state.selectedCommunity!; + const spacer = Spacer(flex: 10); + return Visibility( + visible: selectedCommunity.spaces.isNotEmpty, + replacement: const Row( + children: [spacer, Expanded(child: CreateSpaceButton()), spacer]), + child: CommunityStructureCanvas(community: selectedCommunity), + ); + } +} diff --git a/lib/pages/space_management_v2/modules/communities/presentation/communities_tree_selection_bloc/communities_tree_selection_bloc.dart b/lib/pages/space_management_v2/modules/communities/presentation/communities_tree_selection_bloc/communities_tree_selection_bloc.dart index bfc02f11..bdda04ee 100644 --- a/lib/pages/space_management_v2/modules/communities/presentation/communities_tree_selection_bloc/communities_tree_selection_bloc.dart +++ b/lib/pages/space_management_v2/modules/communities/presentation/communities_tree_selection_bloc/communities_tree_selection_bloc.dart @@ -32,7 +32,7 @@ class CommunitiesTreeSelectionBloc ) { emit( CommunitiesTreeSelectionState( - selectedCommunity: null, + selectedCommunity: event.community, selectedSpace: event.space, ), ); diff --git a/lib/pages/space_management_v2/modules/communities/presentation/communities_tree_selection_bloc/communities_tree_selection_event.dart b/lib/pages/space_management_v2/modules/communities/presentation/communities_tree_selection_bloc/communities_tree_selection_event.dart index 95ffe173..40a41f74 100644 --- a/lib/pages/space_management_v2/modules/communities/presentation/communities_tree_selection_bloc/communities_tree_selection_event.dart +++ b/lib/pages/space_management_v2/modules/communities/presentation/communities_tree_selection_bloc/communities_tree_selection_event.dart @@ -8,7 +8,7 @@ sealed class CommunitiesTreeSelectionEvent extends Equatable { } final class SelectCommunityEvent extends CommunitiesTreeSelectionEvent { - final CommunityModel? community; + final CommunityModel community; const SelectCommunityEvent({required this.community}); @override @@ -16,9 +16,10 @@ final class SelectCommunityEvent extends CommunitiesTreeSelectionEvent { } final class SelectSpaceEvent extends CommunitiesTreeSelectionEvent { - final SpaceModel? space; + final SpaceModel space; + final CommunityModel community; - const SelectSpaceEvent({required this.space}); + const SelectSpaceEvent({required this.space, required this.community}); @override List get props => [space]; diff --git a/lib/pages/space_management_v2/modules/communities/presentation/widgets/space_management_communities_tree_space_tile.dart b/lib/pages/space_management_v2/modules/communities/presentation/widgets/space_management_communities_tree_space_tile.dart index dcd44ac8..795e2c3a 100644 --- a/lib/pages/space_management_v2/modules/communities/presentation/widgets/space_management_communities_tree_space_tile.dart +++ b/lib/pages/space_management_v2/modules/communities/presentation/widgets/space_management_communities_tree_space_tile.dart @@ -30,7 +30,7 @@ class SpaceManagementCommunitiesTreeSpaceTile extends StatelessWidget { initiallyExpanded: spaceIsExpanded, onExpansionChanged: (expanded) {}, onItemSelected: () => context.read().add( - SelectSpaceEvent(space: space), + SelectSpaceEvent(community: community, space: space), ), children: space.children .map( diff --git a/lib/utils/app_routes.dart b/lib/utils/app_routes.dart index 263bdbd6..7663a3f3 100644 --- a/lib/utils/app_routes.dart +++ b/lib/utils/app_routes.dart @@ -5,7 +5,7 @@ import 'package:syncrow_web/pages/auth/view/login_page.dart'; import 'package:syncrow_web/pages/device_managment/all_devices/view/device_managment_page.dart'; import 'package:syncrow_web/pages/home/view/home_page.dart'; import 'package:syncrow_web/pages/roles_and_permission/view/roles_and_permission_page.dart'; -import 'package:syncrow_web/pages/spaces_management/all_spaces/view/spaces_management_page.dart'; +import 'package:syncrow_web/pages/space_management_v2/main_module/views/space_management_page.dart'; import 'package:syncrow_web/pages/visitor_password/view/visitor_password_dialog.dart'; import 'package:syncrow_web/utils/constants/routes_const.dart';