diff --git a/lib/pages/home/bloc/home_bloc.dart b/lib/pages/home/bloc/home_bloc.dart index c1bcba6a..aa642e25 100644 --- a/lib/pages/home/bloc/home_bloc.dart +++ b/lib/pages/home/bloc/home_bloc.dart @@ -105,7 +105,7 @@ class HomeBloc extends Bloc { color: const Color(0xFF0026A2), ), HomeItemModel( - title: 'Devices Management', + title: 'Device Management', icon: Assets.devicesIcon, active: true, onPress: (context) { diff --git a/lib/pages/space_management_v2/main_module/views/space_management_page.dart b/lib/pages/space_management_v2/main_module/views/space_management_page.dart index 957be65a..05768035 100644 --- a/lib/pages/space_management_v2/main_module/views/space_management_page.dart +++ b/lib/pages/space_management_v2/main_module/views/space_management_page.dart @@ -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/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/space_details/data/services/remote_space_details_service.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/space_details/presentation/bloc/space_details_bloc.dart'; import 'package:syncrow_web/services/api/http_service.dart'; import 'package:syncrow_web/utils/theme/responsive_text_theme.dart'; import 'package:syncrow_web/web_layout/web_scaffold.dart'; @@ -26,6 +28,11 @@ class SpaceManagementPage extends StatelessWidget { )..add(const LoadCommunities(LoadCommunitiesParam())), ), BlocProvider(create: (context) => CommunitiesTreeSelectionBloc()), + BlocProvider( + create: (context) => SpaceDetailsBloc( + RemoteSpaceDetailsService(httpService: HTTPService()), + ), + ), ], child: WebScaffold( appBarTitle: Text( diff --git a/lib/pages/space_management_v2/main_module/widgets/community_structure_header.dart b/lib/pages/space_management_v2/main_module/widgets/community_structure_header.dart new file mode 100644 index 00000000..b457c413 --- /dev/null +++ b/lib/pages/space_management_v2/main_module/widgets/community_structure_header.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:syncrow_web/pages/space_management_v2/main_module/widgets/community_structure_header_action_buttons.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/communities_tree_selection_bloc/communities_tree_selection_bloc.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/create_community/presentation/create_community_dialog.dart'; +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/constants/assets.dart'; + +class CommunityStructureHeader extends StatelessWidget { + const CommunityStructureHeader({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final screenWidth = MediaQuery.of(context).size.width; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 16.0), + decoration: BoxDecoration( + color: ColorsManager.whiteColors, + boxShadow: [ + BoxShadow( + color: ColorsManager.shadowBlackColor, + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _buildCommunityInfo(context, theme, screenWidth), + ), + const SizedBox(width: 16), + ], + ), + ], + ), + ); + } + + void _showCreateCommunityDialog(BuildContext context) { + showDialog( + context: context, + builder: (context) => CreateCommunityDialog( + title: const Text('Edit Community'), + onCreateCommunity: (community) { + // TODO(FarisArmoush): Implement + }, + ), + ); + } + + Widget _buildCommunityInfo( + BuildContext context, ThemeData theme, double screenWidth) { + final selectedCommunity = + context.watch().state.selectedCommunity; + final selectedSpace = + context.watch().state.selectedSpace; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Community Structure', + style: theme.textTheme.headlineLarge + ?.copyWith(color: ColorsManager.blackColor), + ), + if (selectedCommunity != null) + Row( + children: [ + Expanded( + child: Row( + children: [ + Flexible( + child: SelectableText( + selectedCommunity.name, + style: theme.textTheme.bodyLarge + ?.copyWith(color: ColorsManager.blackColor), + maxLines: 1, + ), + ), + const SizedBox(width: 2), + GestureDetector( + onTap: () => _showCreateCommunityDialog(context), + child: SvgPicture.asset( + Assets.iconEdit, + width: 16, + height: 16, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + CommunityStructureHeaderActionButtons( + onDelete: (space) {}, + onDuplicate: (space) {}, + onEdit: (space) { + SpaceDetailsDialogHelper.showEdit( + context, + spaceModel: selectedSpace!, + ); + }, + selectedSpace: selectedSpace, + ), + ], + ), + ], + ); + } +} diff --git a/lib/pages/space_management_v2/main_module/widgets/community_structure_header_action_buttons.dart b/lib/pages/space_management_v2/main_module/widgets/community_structure_header_action_buttons.dart new file mode 100644 index 00000000..a965c866 --- /dev/null +++ b/lib/pages/space_management_v2/main_module/widgets/community_structure_header_action_buttons.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:syncrow_web/pages/space_management_v2/main_module/widgets/community_structure_header_button.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/models/space_model.dart'; +import 'package:syncrow_web/utils/constants/assets.dart'; + +class CommunityStructureHeaderActionButtons extends StatelessWidget { + const CommunityStructureHeaderActionButtons({ + super.key, + required this.onDelete, + required this.selectedSpace, + required this.onDuplicate, + required this.onEdit, + }); + + final void Function(SpaceModel space) onDelete; + final void Function(SpaceModel space) onDuplicate; + final void Function(SpaceModel space) onEdit; + final SpaceModel? selectedSpace; + + @override + Widget build(BuildContext context) { + return Wrap( + alignment: WrapAlignment.end, + spacing: 10, + children: [ + if (selectedSpace != null) ...[ + CommunityStructureHeaderButton( + label: 'Edit', + svgAsset: Assets.editSpace, + onPressed: () => onEdit(selectedSpace!), + ), + CommunityStructureHeaderButton( + label: 'Duplicate', + svgAsset: Assets.duplicate, + onPressed: () => onDuplicate(selectedSpace!), + ), + CommunityStructureHeaderButton( + label: 'Delete', + svgAsset: Assets.spaceDelete, + onPressed: () => onDelete(selectedSpace!), + ), + ], + ], + ); + } +} diff --git a/lib/pages/space_management_v2/main_module/widgets/community_structure_header_button.dart b/lib/pages/space_management_v2/main_module/widgets/community_structure_header_button.dart new file mode 100644 index 00000000..4c0285e3 --- /dev/null +++ b/lib/pages/space_management_v2/main_module/widgets/community_structure_header_button.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:syncrow_web/pages/common/buttons/default_button.dart'; +import 'package:syncrow_web/utils/color_manager.dart'; +import 'package:syncrow_web/utils/extension/build_context_x.dart'; + +class CommunityStructureHeaderButton extends StatelessWidget { + const CommunityStructureHeaderButton({ + super.key, + required this.label, + required this.onPressed, + this.svgAsset, + }); + + final String label; + final VoidCallback onPressed; + final String? svgAsset; + + @override + Widget build(BuildContext context) { + const double buttonHeight = 40; + return ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 130, + minHeight: buttonHeight, + ), + child: DefaultButton( + onPressed: onPressed, + borderWidth: 2, + backgroundColor: ColorsManager.textFieldGreyColor, + foregroundColor: ColorsManager.blackColor, + borderRadius: 12.0, + padding: 2.0, + height: buttonHeight, + elevation: 0, + borderColor: ColorsManager.lightGrayColor, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (svgAsset != null) + SvgPicture.asset( + svgAsset!, + width: 20, + height: 20, + ), + const SizedBox(width: 10), + Flexible( + child: Text( + label, + style: context.textTheme.bodySmall + ?.copyWith(color: ColorsManager.blackColor, fontSize: 14), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + ], + ), + ), + ); + } +} 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 index 99d0668a..e1f1fc00 100644 --- 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 @@ -1,6 +1,7 @@ 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/community_structure_header.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'; @@ -18,9 +19,17 @@ class SpaceManagementCommunityStructure extends StatelessWidget { replacement: const Row( children: [spacer, Expanded(child: CreateSpaceButton()), spacer], ), - child: CommunityStructureCanvas( - community: selectedCommunity, - selectedSpace: selectedSpace, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CommunityStructureHeader(), + Expanded( + child: CommunityStructureCanvas( + community: selectedCommunity, + selectedSpace: selectedSpace, + ), + ), + ], ), ); } diff --git a/lib/pages/space_management_v2/modules/communities/domain/models/space_model.dart b/lib/pages/space_management_v2/modules/communities/domain/models/space_model.dart index 36943adb..ddcc6a86 100644 --- a/lib/pages/space_management_v2/modules/communities/domain/models/space_model.dart +++ b/lib/pages/space_management_v2/modules/communities/domain/models/space_model.dart @@ -19,6 +19,16 @@ class SpaceModel extends Equatable { required this.parent, }); + factory SpaceModel.empty() => const SpaceModel( + uuid: '', + createdAt: null, + updatedAt: null, + spaceName: '', + icon: '', + children: [], + parent: null, + ); + factory SpaceModel.fromJson(Map json) { return SpaceModel( uuid: json['uuid'] as String? ?? '', diff --git a/lib/pages/space_management_v2/modules/space_details/data/services/remote_space_details_service.dart b/lib/pages/space_management_v2/modules/space_details/data/services/remote_space_details_service.dart index 2e999361..8b40cbfb 100644 --- a/lib/pages/space_management_v2/modules/space_details/data/services/remote_space_details_service.dart +++ b/lib/pages/space_management_v2/modules/space_details/data/services/remote_space_details_service.dart @@ -1,6 +1,7 @@ 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/params/load_spaces_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/domain/services/space_details_service.dart'; import 'package:syncrow_web/services/api/api_exception.dart'; import 'package:syncrow_web/services/api/http_service.dart'; @@ -15,12 +16,15 @@ class RemoteSpaceDetailsService implements SpaceDetailsService { static const _defaultErrorMessage = 'Failed to load space details'; @override - Future getSpaceDetails(LoadSpacesParam param) async { + Future getSpaceDetails(LoadSpaceDetailsParam param) async { try { final response = await _httpService.get( - path: 'endpoint', + path: await _makeEndpoint(param), expectedResponseModel: (data) { - return SpaceDetailsModel.fromJson(data as Map); + final response = data as Map; + return SpaceDetailsModel.fromJson( + response['data'] as Map, + ); }, ); return response; @@ -37,4 +41,13 @@ class RemoteSpaceDetailsService implements SpaceDetailsService { throw APIException(formattedErrorMessage); } } + + Future _makeEndpoint(LoadSpaceDetailsParam param) async { + final projectUuid = await ProjectManager.getProjectUUID(); + if (projectUuid == null || projectUuid.isEmpty) { + throw APIException('Project UUID is not set'); + } + + return '/projects/$projectUuid/communities/${param.communityUuid}/spaces/${param.spaceUuid}'; + } } diff --git a/lib/pages/space_management_v2/modules/space_details/domain/models/space_details_model.dart b/lib/pages/space_management_v2/modules/space_details/domain/models/space_details_model.dart index 891e7eb2..737d5060 100644 --- a/lib/pages/space_management_v2/modules/space_details/domain/models/space_details_model.dart +++ b/lib/pages/space_management_v2/modules/space_details/domain/models/space_details_model.dart @@ -1,6 +1,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/tags/domain/models/tag.dart'; +import 'package:syncrow_web/utils/constants/assets.dart'; class SpaceDetailsModel extends Equatable { final String uuid; @@ -17,14 +18,21 @@ class SpaceDetailsModel extends Equatable { required this.subspaces, }); + factory SpaceDetailsModel.empty() => const SpaceDetailsModel( + uuid: '', + spaceName: '', + icon: Assets.villa, + productAllocations: [], + subspaces: [], + ); factory SpaceDetailsModel.fromJson(Map json) { return SpaceDetailsModel( uuid: json['uuid'] as String, spaceName: json['spaceName'] as String, icon: json['icon'] as String, productAllocations: (json['productAllocations'] as List) - .map((e) => ProductAllocation.fromJson(e as Map)) - .toList(), + .map((e) => ProductAllocation.fromJson(e as Map)) + .toList(), subspaces: (json['subspaces'] as List) .map((e) => Subspace.fromJson(e as Map)) .toList(), @@ -41,6 +49,22 @@ class SpaceDetailsModel extends Equatable { }; } + SpaceDetailsModel copyWith({ + String? uuid, + String? spaceName, + String? icon, + List? productAllocations, + List? subspaces, + }) { + return SpaceDetailsModel( + uuid: uuid ?? this.uuid, + spaceName: spaceName ?? this.spaceName, + icon: icon ?? this.icon, + productAllocations: productAllocations ?? this.productAllocations, + subspaces: subspaces ?? this.subspaces, + ); + } + @override List get props => [uuid, spaceName, icon, productAllocations, subspaces]; } @@ -48,12 +72,10 @@ class SpaceDetailsModel extends Equatable { class ProductAllocation extends Equatable { final Product product; final Tag tag; - final String? location; const ProductAllocation({ required this.product, required this.tag, - this.location, }); factory ProductAllocation.fromJson(Map json) { @@ -70,6 +92,16 @@ class ProductAllocation extends Equatable { }; } + ProductAllocation copyWith({ + Product? product, + Tag? tag, + }) { + return ProductAllocation( + product: product ?? this.product, + tag: tag ?? this.tag, + ); + } + @override List get props => [product, tag]; } @@ -88,7 +120,7 @@ class Subspace extends Equatable { factory Subspace.fromJson(Map json) { return Subspace( uuid: json['uuid'] as String, - name: json['name'] as String, + name: json['subspaceName'] as String, productAllocations: (json['productAllocations'] as List) .map((e) => ProductAllocation.fromJson(e as Map)) .toList(), @@ -103,6 +135,18 @@ class Subspace extends Equatable { }; } + Subspace copyWith({ + String? uuid, + String? name, + List? productAllocations, + }) { + return Subspace( + uuid: uuid ?? this.uuid, + name: name ?? this.name, + productAllocations: productAllocations ?? this.productAllocations, + ); + } + @override List get props => [uuid, name, productAllocations]; } diff --git a/lib/pages/space_management_v2/modules/space_details/domain/params/load_space_details_param.dart b/lib/pages/space_management_v2/modules/space_details/domain/params/load_space_details_param.dart new file mode 100644 index 00000000..7242e62e --- /dev/null +++ b/lib/pages/space_management_v2/modules/space_details/domain/params/load_space_details_param.dart @@ -0,0 +1,9 @@ +class LoadSpaceDetailsParam { + const LoadSpaceDetailsParam({ + required this.spaceUuid, + required this.communityUuid, + }); + + final String spaceUuid; + final String communityUuid; +} diff --git a/lib/pages/space_management_v2/modules/space_details/domain/params/load_spaces_param.dart b/lib/pages/space_management_v2/modules/space_details/domain/params/load_spaces_param.dart deleted file mode 100644 index 5324ed98..00000000 --- a/lib/pages/space_management_v2/modules/space_details/domain/params/load_spaces_param.dart +++ /dev/null @@ -1,3 +0,0 @@ -class LoadSpacesParam { - const LoadSpacesParam(); -} diff --git a/lib/pages/space_management_v2/modules/space_details/domain/services/space_details_service.dart b/lib/pages/space_management_v2/modules/space_details/domain/services/space_details_service.dart index b032560b..16b09ff1 100644 --- a/lib/pages/space_management_v2/modules/space_details/domain/services/space_details_service.dart +++ b/lib/pages/space_management_v2/modules/space_details/domain/services/space_details_service.dart @@ -1,6 +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/params/load_spaces_param.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/space_details/domain/params/load_space_details_param.dart'; abstract class SpaceDetailsService { - Future getSpaceDetails(LoadSpacesParam param); + Future getSpaceDetails(LoadSpaceDetailsParam param); } diff --git a/lib/pages/space_management_v2/modules/space_details/presentation/bloc/space_details_bloc.dart b/lib/pages/space_management_v2/modules/space_details/presentation/bloc/space_details_bloc.dart index 59c1a06d..d397daf9 100644 --- a/lib/pages/space_management_v2/modules/space_details/presentation/bloc/space_details_bloc.dart +++ b/lib/pages/space_management_v2/modules/space_details/presentation/bloc/space_details_bloc.dart @@ -1,7 +1,7 @@ import 'package:bloc/bloc.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/params/load_spaces_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/domain/services/space_details_service.dart'; import 'package:syncrow_web/services/api/api_exception.dart'; @@ -9,12 +9,13 @@ part 'space_details_event.dart'; part 'space_details_state.dart'; class SpaceDetailsBloc extends Bloc { - final SpaceDetailsService _spaceDetailsService; - SpaceDetailsBloc(this._spaceDetailsService) : super(SpaceDetailsInitial()) { on(_onLoadSpaceDetails); + on(_onClearSpaceDetails); } + final SpaceDetailsService _spaceDetailsService; + Future _onLoadSpaceDetails( LoadSpaceDetails event, Emitter emit, @@ -31,4 +32,11 @@ class SpaceDetailsBloc extends Bloc { emit(SpaceDetailsFailure(e.toString())); } } + + void _onClearSpaceDetails( + ClearSpaceDetails event, + Emitter emit, + ) { + emit(SpaceDetailsInitial()); + } } diff --git a/lib/pages/space_management_v2/modules/space_details/presentation/bloc/space_details_event.dart b/lib/pages/space_management_v2/modules/space_details/presentation/bloc/space_details_event.dart index fe559e26..9dd40fba 100644 --- a/lib/pages/space_management_v2/modules/space_details/presentation/bloc/space_details_event.dart +++ b/lib/pages/space_management_v2/modules/space_details/presentation/bloc/space_details_event.dart @@ -7,11 +7,18 @@ sealed class SpaceDetailsEvent extends Equatable { List get props => []; } -class LoadSpaceDetails extends SpaceDetailsEvent { +final class LoadSpaceDetails extends SpaceDetailsEvent { const LoadSpaceDetails(this.param); - final LoadSpacesParam param; + final LoadSpaceDetailsParam param; @override List get props => [param]; } + +final class ClearSpaceDetails extends SpaceDetailsEvent { + const ClearSpaceDetails(); + + @override + List get props => []; +} \ No newline at end of file diff --git a/lib/pages/space_management_v2/modules/space_details/presentation/bloc/space_details_state.dart b/lib/pages/space_management_v2/modules/space_details/presentation/bloc/space_details_state.dart index c7378f89..53c4cf77 100644 --- a/lib/pages/space_management_v2/modules/space_details/presentation/bloc/space_details_state.dart +++ b/lib/pages/space_management_v2/modules/space_details/presentation/bloc/space_details_state.dart @@ -21,10 +21,10 @@ final class SpaceDetailsLoaded extends SpaceDetailsState { } final class SpaceDetailsFailure extends SpaceDetailsState { - final String message; + final String errorMessage; - const SpaceDetailsFailure(this.message); + const SpaceDetailsFailure(this.errorMessage); @override - List get props => [message]; + List get props => [errorMessage]; } diff --git a/lib/pages/space_management_v2/modules/space_details/presentation/helpers/space_details_dialog_helper.dart b/lib/pages/space_management_v2/modules/space_details/presentation/helpers/space_details_dialog_helper.dart index e871f4d0..c8835716 100644 --- a/lib/pages/space_management_v2/modules/space_details/presentation/helpers/space_details_dialog_helper.dart +++ b/lib/pages/space_management_v2/modules/space_details/presentation/helpers/space_details_dialog_helper.dart @@ -1,11 +1,46 @@ import 'package:flutter/material.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/space_details/data/services/remote_space_details_service.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/services/api/http_service.dart'; abstract final class SpaceDetailsDialogHelper { static void showCreate(BuildContext context) { showDialog( context: context, - builder: (context) => const SpaceDetailsDialog(), + builder: (_) => BlocProvider( + create: (context) => SpaceDetailsBloc( + RemoteSpaceDetailsService(httpService: HTTPService()), + ), + child: SpaceDetailsDialog( + context: context, + title: const Text('Create Space'), + spaceModel: SpaceModel.empty(), + onSave: print, + ), + ), + ); + } + + static void showEdit( + BuildContext context, { + required SpaceModel spaceModel, + }) { + showDialog( + context: context, + builder: (_) => BlocProvider( + create: (context) => SpaceDetailsBloc( + RemoteSpaceDetailsService(httpService: HTTPService()), + ), + child: SpaceDetailsDialog( + context: context, + title: const Text('Edit Space'), + spaceModel: spaceModel, + onSave: (space) {}, + ), + ), ); } } diff --git a/lib/pages/space_management_v2/modules/space_details/presentation/widgets/button_content_widget.dart b/lib/pages/space_management_v2/modules/space_details/presentation/widgets/button_content_widget.dart new file mode 100644 index 00000000..4c95634e --- /dev/null +++ b/lib/pages/space_management_v2/modules/space_details/presentation/widgets/button_content_widget.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:syncrow_web/utils/color_manager.dart'; + +class ButtonContentWidget extends StatelessWidget { + final String label; + final String? svgAssets; + final bool disabled; + + const ButtonContentWidget({ + required this.label, + this.svgAssets, + this.disabled = false, + super.key, + }); + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + + return Opacity( + opacity: disabled ? 0.5 : 1.0, + child: Container( + width: screenWidth * 0.25, + decoration: BoxDecoration( + color: ColorsManager.textFieldGreyColor, + border: Border.all( + color: ColorsManager.neutralGray, + width: 3.0, + ), + borderRadius: BorderRadius.circular(20), + ), + padding: const EdgeInsets.symmetric(vertical: 10.0, horizontal: 16.0), + child: Row( + children: [ + if (svgAssets != null) + Padding( + padding: const EdgeInsets.only(left: 6.0), + child: SvgPicture.asset( + svgAssets!, + width: screenWidth * 0.015, + height: screenWidth * 0.015, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + label, + style: const TextStyle( + color: ColorsManager.blackColor, + fontSize: 16, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/space_management_v2/modules/space_details/presentation/widgets/space_details_action_buttons.dart b/lib/pages/space_management_v2/modules/space_details/presentation/widgets/space_details_action_buttons.dart new file mode 100644 index 00000000..8518227f --- /dev/null +++ b/lib/pages/space_management_v2/modules/space_details/presentation/widgets/space_details_action_buttons.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:syncrow_web/pages/common/buttons/cancel_button.dart'; +import 'package:syncrow_web/pages/common/buttons/default_button.dart'; +import 'package:syncrow_web/utils/color_manager.dart'; + +class SpaceDetailsActionButtons extends StatelessWidget { + const SpaceDetailsActionButtons({ + super.key, + required this.onSave, + required this.onCancel, + }); + + final VoidCallback onCancel; + final VoidCallback? onSave; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + spacing: 10, + children: [ + Expanded(child: _buildCancelButton(context)), + Expanded(child: _buildSaveButton()), + ], + ); + } + + Widget _buildCancelButton(BuildContext context) { + return CancelButton( + onPressed: onCancel, + label: 'Cancel', + ); + } + + Widget _buildSaveButton() { + return DefaultButton( + onPressed: onSave, + borderRadius: 10, + backgroundColor: ColorsManager.secondaryColor, + foregroundColor: ColorsManager.whiteColors, + child: const Text('OK'), + ); + } +} diff --git a/lib/pages/space_management_v2/modules/space_details/presentation/widgets/space_details_devices_box.dart b/lib/pages/space_management_v2/modules/space_details/presentation/widgets/space_details_devices_box.dart new file mode 100644 index 00000000..5b21bd61 --- /dev/null +++ b/lib/pages/space_management_v2/modules/space_details/presentation/widgets/space_details_devices_box.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.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/presentation/widgets/button_content_widget.dart'; +import 'package:syncrow_web/utils/color_manager.dart'; +import 'package:syncrow_web/utils/constants/assets.dart'; + +class SpaceDetailsDevicesBox extends StatelessWidget { + const SpaceDetailsDevicesBox({ + required this.space, + super.key, + }); + + final SpaceDetailsModel space; + + @override + Widget build(BuildContext context) { + final productAllocations = space.productAllocations; + final subspaces = space.subspaces; + final isAnySubspaceHasProductAllocations = + subspaces.any((subspace) => subspace.productAllocations.isNotEmpty); + if (productAllocations.isNotEmpty || isAnySubspaceHasProductAllocations) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: ColorsManager.textFieldGreyColor, + borderRadius: BorderRadius.circular(15), + border: Border.all( + color: ColorsManager.textFieldGreyColor, + width: 3.0, + ), + ), + child: Wrap( + spacing: 8.0, + runSpacing: 8.0, + children: [ + // Combine tags from spaceModel and subspaces + // ...TagHelper.groupTags([ + // ...?tags, + // ...?subspaces?.expand((subspace) => subspace.tags ?? []) + // ]).entries.map( + // (entry) => Chip( + // avatar: SizedBox( + // width: 24, + // height: 24, + // child: SvgPicture.asset( + // entry.key.icon ?? 'assets/icons/gateway.svg', + // fit: BoxFit.contain, + // ), + // ), + // label: Text( + // 'x${entry.value}', + // style: Theme.of(context) + // .textTheme + // .bodySmall + // ?.copyWith(color: ColorsManager.spaceColor), + // ), + // backgroundColor: ColorsManager.whiteColors, + // shape: RoundedRectangleBorder( + // borderRadius: BorderRadius.circular(16), + // side: const BorderSide( + // color: ColorsManager.spaceColor, + // ), + // ), + // ), + // ), + + EditChip( + onTap: () {}, + ), + ], + ), + ); + } else { + return TextButton( + onPressed: () {}, + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + ), + child: const SizedBox( + width: double.infinity, + child: ButtonContentWidget( + svgAssets: Assets.addIcon, + label: 'Add Devices', + // disabled: isTagsAndSubspaceModelDisabled, + ), + ), + ); + } + } +} diff --git a/lib/pages/space_management_v2/modules/space_details/presentation/widgets/space_details_dialog.dart b/lib/pages/space_management_v2/modules/space_details/presentation/widgets/space_details_dialog.dart index 7213c99e..2461b3c6 100644 --- a/lib/pages/space_management_v2/modules/space_details/presentation/widgets/space_details_dialog.dart +++ b/lib/pages/space_management_v2/modules/space_details/presentation/widgets/space_details_dialog.dart @@ -1,12 +1,101 @@ import 'package:flutter/material.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/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/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/widgets/space_details_form.dart'; +import 'package:syncrow_web/utils/color_manager.dart'; +import 'package:syncrow_web/utils/extension/build_context_x.dart'; -class SpaceDetailsDialog extends StatelessWidget { - const SpaceDetailsDialog({super.key}); +class SpaceDetailsDialog extends StatefulWidget { + const SpaceDetailsDialog({ + required this.title, + required this.spaceModel, + required this.onSave, + required this.context, + super.key, + }); + + final Widget title; + final SpaceModel spaceModel; + final void Function(SpaceDetailsModel space) onSave; + final BuildContext context; + + @override + State createState() => _SpaceDetailsDialogState(); +} + +class _SpaceDetailsDialogState extends State { + @override + void initState() { + final isCreateMode = widget.spaceModel.uuid.isEmpty; + + if (!isCreateMode) { + final param = LoadSpaceDetailsParam( + spaceUuid: widget.spaceModel.uuid, + communityUuid: widget.context + .read() + .state + .selectedCommunity! + .uuid, + ); + widget.context.read().add(LoadSpaceDetails(param)); + } + super.initState(); + } @override Widget build(BuildContext context) { - return const Dialog( - child: Text('Create Space'), + final isCreateMode = widget.spaceModel.uuid.isEmpty; + if (isCreateMode) { + return SpaceDetailsForm( + title: widget.title, + space: SpaceDetailsModel.empty(), + onSave: widget.onSave, + ); + } + + return BlocBuilder( + bloc: widget.context.read(), + builder: (context, state) => switch (state) { + SpaceDetailsInitial() => _buildLoadingDialog(), + SpaceDetailsLoading() => _buildLoadingDialog(), + SpaceDetailsLoaded(:final spaceDetails) => SpaceDetailsForm( + title: widget.title, + space: spaceDetails, + onSave: widget.onSave, + ), + SpaceDetailsFailure(:final errorMessage) => _buildErrorDialog( + errorMessage, + ), + }, + ); + } + + Widget _buildLoadingDialog() { + return AlertDialog( + title: widget.title, + backgroundColor: ColorsManager.whiteColors, + content: const Center(child: CircularProgressIndicator()), + ); + } + + Widget _buildErrorDialog(String errorMessage) { + return AlertDialog( + title: widget.title, + backgroundColor: ColorsManager.whiteColors, + content: Center( + child: SelectableText( + errorMessage, + style: context.textTheme.bodyLarge?.copyWith( + color: ColorsManager.red, + fontWeight: FontWeight.w500, + fontSize: 18, + ), + ), + ), ); } } diff --git a/lib/pages/space_management_v2/modules/space_details/presentation/widgets/space_details_form.dart b/lib/pages/space_management_v2/modules/space_details/presentation/widgets/space_details_form.dart new file mode 100644 index 00000000..f9a5ad64 --- /dev/null +++ b/lib/pages/space_management_v2/modules/space_details/presentation/widgets/space_details_form.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_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/presentation/widgets/space_details_action_buttons.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/space_details/presentation/widgets/space_details_devices_box.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/space_details/presentation/widgets/space_icon_picker.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/space_details/presentation/widgets/space_name_text_field.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/space_details/presentation/widgets/space_sub_spaces_box.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/extension/build_context_x.dart'; + +class SpaceDetailsForm extends StatelessWidget { + const SpaceDetailsForm({ + required this.title, + required this.space, + required this.onSave, + super.key, + }); + + final Widget title; + final SpaceDetailsModel space; + final void Function(SpaceDetailsModel space) onSave; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => SpaceDetailsModelBloc(initialState: space), + child: BlocBuilder( + buildWhen: (previous, current) => previous != current, + builder: (context, state) { + return AlertDialog( + title: title, + backgroundColor: ColorsManager.whiteColors, + content: SizedBox( + height: context.screenHeight * 0.3, + width: context.screenWidth * 0.5, + child: Row( + spacing: 20, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: SpaceIconPicker(iconPath: state.icon)), + Expanded( + flex: 2, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SpaceNameTextField( + initialValue: state.spaceName, + isNameFieldExist: (value) => state.subspaces.any( + (subspace) => subspace.name == value, + ), + ), + const Spacer(), + SpaceSubSpacesBox( + subspaces: state.subspaces, + ), + const SizedBox(height: 16), + SpaceDetailsDevicesBox(space: state), + ], + ), + ), + ], + ), + ), + actions: [ + SpaceDetailsActionButtons( + onSave: () => onSave(state), + onCancel: Navigator.of(context).pop, + ), + ], + ); + }), + ); + } +} diff --git a/lib/pages/space_management_v2/modules/space_details/presentation/widgets/space_icon_picker.dart b/lib/pages/space_management_v2/modules/space_details/presentation/widgets/space_icon_picker.dart new file mode 100644 index 00000000..68bfdefc --- /dev/null +++ b/lib/pages/space_management_v2/modules/space_details/presentation/widgets/space_icon_picker.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/space_details/presentation/widgets/space_icon_selection_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/constants/assets.dart'; +import 'package:syncrow_web/utils/extension/build_context_x.dart'; + +class SpaceIconPicker extends StatelessWidget { + const SpaceIconPicker({ + required this.iconPath, + super.key, + }); + + final String iconPath; + + @override + Widget build(BuildContext context) { + return Center( + child: Stack( + + alignment: Alignment.center, + children: [ + Container( + width: context.screenWidth * 0.175, + height: context.screenHeight * 0.175, + decoration: const BoxDecoration( + color: ColorsManager.boxColor, + shape: BoxShape.circle, + ), + padding: const EdgeInsets.all(24), + child: SvgPicture.asset( + iconPath, + width: context.screenWidth * 0.08, + height: context.screenHeight * 0.08, + ), + ), + Positioned.directional( + top: 12, + start: context.screenHeight * 0.06, + textDirection: Directionality.of(context), + child: InkWell( + onTap: () { + showDialog( + context: context, + builder: (context) => SpaceIconSelectionDialog( + selectedIcon: iconPath, + ), + ).then((value) { + if (value != null) { + if (context.mounted) { + context.read().add( + UpdateSpaceDetailsIcon(value), + ); + } + } + }); + }, + child: Container( + decoration: const BoxDecoration( + shape: BoxShape.circle, + ), + child: SvgPicture.asset( + Assets.iconEdit, + width: 16, + height: 16, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/space_management_v2/modules/space_details/presentation/widgets/space_icon_selection_dialog.dart b/lib/pages/space_management_v2/modules/space_details/presentation/widgets/space_icon_selection_dialog.dart new file mode 100644 index 00000000..5fe5b463 --- /dev/null +++ b/lib/pages/space_management_v2/modules/space_details/presentation/widgets/space_icon_selection_dialog.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:syncrow_web/utils/color_manager.dart'; +import 'package:syncrow_web/utils/constants/assets.dart'; +import 'package:syncrow_web/utils/extension/build_context_x.dart'; + +class SpaceIconSelectionDialog extends StatelessWidget { + const SpaceIconSelectionDialog({super.key, required this.selectedIcon}); + final String selectedIcon; + + static const List _icons = [ + Assets.location, + Assets.villa, + Assets.gym, + Assets.sauna, + Assets.bbq, + Assets.building, + Assets.desk, + Assets.door, + Assets.parking, + Assets.pool, + Assets.stair, + Assets.steamRoom, + Assets.street, + Assets.unit, + ]; + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: SelectableText( + 'Space Icon', + style: context.textTheme.headlineMedium, + ), + backgroundColor: ColorsManager.whiteColors, + content: Container( + width: context.screenWidth * 0.45, + height: context.screenHeight * 0.275, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: ColorsManager.boxColor, + borderRadius: BorderRadius.circular(12), + ), + child: GridView.builder( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 7, + crossAxisSpacing: 8, + mainAxisSpacing: 16, + ), + itemCount: _icons.length, + itemBuilder: (context, index) { + final isSelected = selectedIcon == _icons[index]; + return Container( + padding: const EdgeInsetsDirectional.all(2), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: isSelected + ? Border.all(color: ColorsManager.vividBlue, width: 2) + : null, + ), + child: IconButton( + onPressed: () => Navigator.of(context).pop(_icons[index]), + icon: SvgPicture.asset( + _icons[index], + width: context.screenWidth * 0.03, + height: context.screenHeight * 0.08, + ), + ), + ); + }, + ), + ), + ); + } +} diff --git a/lib/pages/space_management_v2/modules/space_details/presentation/widgets/space_name_text_field.dart b/lib/pages/space_management_v2/modules/space_details/presentation/widgets/space_name_text_field.dart new file mode 100644 index 00000000..0c62490f --- /dev/null +++ b/lib/pages/space_management_v2/modules/space_details/presentation/widgets/space_name_text_field.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.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/extension/build_context_x.dart'; + +class SpaceNameTextField extends StatefulWidget { + const SpaceNameTextField({ + required this.initialValue, + required this.isNameFieldExist, + super.key, + }); + + final String? initialValue; + final bool Function(String value) isNameFieldExist; + + @override + State createState() => _SpaceNameTextFieldState(); +} + +class _SpaceNameTextFieldState extends State { + late final TextEditingController _controller; + + @override + void initState() { + _controller = TextEditingController(text: widget.initialValue); + super.initState(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + final _formKey = GlobalKey(); + + String? _validateName(String? value) { + if (value == null || value.isEmpty) { + return '*Space name should not be empty.'; + } + if (widget.isNameFieldExist(value)) { + return '*Name already exists'; + } + return null; + } + + @override + Widget build(BuildContext context) { + return Form( + key: _formKey, + autovalidateMode: AutovalidateMode.onUserInteraction, + child: TextFormField( + controller: _controller, + onChanged: (value) => context.read().add( + UpdateSpaceDetailsName(value), + ), + validator: _validateName, + style: context.textTheme.bodyMedium, + decoration: InputDecoration( + hintText: 'Please enter the name', + hintStyle: context.textTheme.bodyMedium!.copyWith( + color: ColorsManager.lightGrayColor, + ), + filled: true, + fillColor: ColorsManager.boxColor, + enabledBorder: _buildBorder(context, ColorsManager.vividBlue), + focusedBorder: _buildBorder(context, ColorsManager.primaryColor), + errorBorder: _buildBorder(context, context.theme.colorScheme.error), + focusedErrorBorder: _buildBorder(context, context.theme.colorScheme.error), + errorStyle: context.textTheme.bodySmall?.copyWith( + color: context.theme.colorScheme.error, + ), + ), + ), + ); + } + + OutlineInputBorder _buildBorder(BuildContext context, [Color? color]) { + return OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(width: 1, color: color ?? ColorsManager.boxColor), + ); + } +} diff --git a/lib/pages/space_management_v2/modules/space_details/presentation/widgets/space_sub_spaces_box.dart b/lib/pages/space_management_v2/modules/space_details/presentation/widgets/space_sub_spaces_box.dart new file mode 100644 index 00000000..68bf68bd --- /dev/null +++ b/lib/pages/space_management_v2/modules/space_details/presentation/widgets/space_sub_spaces_box.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.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/presentation/widgets/button_content_widget.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/space_details/presentation/widgets/space_sub_spaces_dialog.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/space_details/presentation/widgets/subspace_name_display_widget.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/constants/assets.dart'; + +class SpaceSubSpacesBox extends StatelessWidget { + const SpaceSubSpacesBox({super.key, required this.subspaces}); + + final List subspaces; + + @override + Widget build(BuildContext context) { + if (subspaces.isEmpty) { + return TextButton( + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + overlayColor: ColorsManager.transparentColor, + ), + onPressed: () => _showSubSpacesDialog(context), + child: const SizedBox( + width: double.infinity, + child: ButtonContentWidget( + svgAssets: Assets.addIcon, + label: 'Create Sub Spaces', + ), + ), + ); + } else { + return Container( + padding: const EdgeInsets.all(8.0), + width: double.infinity, + decoration: BoxDecoration( + color: ColorsManager.textFieldGreyColor, + borderRadius: BorderRadius.circular(15), + border: Border.all( + color: ColorsManager.textFieldGreyColor, + width: 3.0, + ), + ), + child: Wrap( + spacing: 8.0, + runSpacing: 8.0, + children: [ + ...subspaces.map((e) => SubspaceNameDisplayWidget(subSpace: e)), + EditChip( + onTap: () => _showSubSpacesDialog(context), + ), + ], + ), + ); + } + } + + void _showSubSpacesDialog(BuildContext context) { + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => SpaceSubSpacesDialog( + subspaces: subspaces, + onSave: (subspaces) { + context.read().add( + UpdateSpaceDetailsSubspaces(subspaces), + ); + }, + ), + ); + } +} diff --git a/lib/pages/space_management_v2/modules/space_details/presentation/widgets/space_sub_spaces_dialog.dart b/lib/pages/space_management_v2/modules/space_details/presentation/widgets/space_sub_spaces_dialog.dart new file mode 100644 index 00000000..4c537a8a --- /dev/null +++ b/lib/pages/space_management_v2/modules/space_details/presentation/widgets/space_sub_spaces_dialog.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.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/space_details/presentation/widgets/sub_spaces_input.dart'; +import 'package:uuid/uuid.dart'; + +class SpaceSubSpacesDialog extends StatefulWidget { + const SpaceSubSpacesDialog({ + required this.subspaces, + required this.onSave, + super.key, + }); + + final List subspaces; + final void Function(List subspaces) onSave; + + @override + State createState() => _SpaceSubSpacesDialogState(); +} + +class _SpaceSubSpacesDialogState extends State { + late List _subspaces; + + bool get _hasDuplicateNames => + _subspaces.map((subspace) => subspace.name.toLowerCase()).toSet().length != + _subspaces.length; + + @override + void initState() { + super.initState(); + _subspaces = List.from(widget.subspaces); + } + + void _handleSubspaceAdded(String name) { + setState(() { + _subspaces = [ + ..._subspaces, + Subspace( + name: name, + uuid: const Uuid().v4(), + productAllocations: const [], + ), + ]; + }); + } + + void _handleSubspaceDeleted(String uuid) => setState( + () => _subspaces = _subspaces.where((s) => s.uuid != uuid).toList(), + ); + + void _handleSave() { + widget.onSave(_subspaces); + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Create Sub Spaces'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SubSpacesInput( + subSpaces: _subspaces, + onSubspaceAdded: _handleSubspaceAdded, + onSubspaceDeleted: _handleSubspaceDeleted, + ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 100), + child: Visibility( + key: ValueKey(_hasDuplicateNames), + visible: _hasDuplicateNames, + child: const Text( + 'Error: Duplicate subspace names are not allowed.', + style: TextStyle(color: Colors.red), + ), + ), + ), + ], + ), + actions: [ + SpaceDetailsActionButtons( + onSave: _hasDuplicateNames ? null : _handleSave, + onCancel: Navigator.of(context).pop, + ) + ], + ); + } +} diff --git a/lib/pages/space_management_v2/modules/space_details/presentation/widgets/sub_spaces_input.dart b/lib/pages/space_management_v2/modules/space_details/presentation/widgets/sub_spaces_input.dart new file mode 100644 index 00000000..854b79bc --- /dev/null +++ b/lib/pages/space_management_v2/modules/space_details/presentation/widgets/sub_spaces_input.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.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/subspace_chip.dart'; +import 'package:syncrow_web/utils/color_manager.dart'; +import 'package:syncrow_web/utils/extension/build_context_x.dart'; + +class SubSpacesInput extends StatefulWidget { + const SubSpacesInput({ + super.key, + required this.subSpaces, + required this.onSubspaceAdded, + required this.onSubspaceDeleted, + }); + + final List subSpaces; + final void Function(String name) onSubspaceAdded; + final void Function(String uuid) onSubspaceDeleted; + + @override + State createState() => _SubSpacesInputState(); +} + +class _SubSpacesInputState extends State { + late final TextEditingController _subspaceNameController; + late final FocusNode _focusNode; + @override + void initState() { + super.initState(); + _subspaceNameController = TextEditingController(); + _focusNode = FocusNode(); + } + + @override + void dispose() { + _subspaceNameController.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + width: context.screenWidth * 0.35, + padding: const EdgeInsets.symmetric( + vertical: 10, + horizontal: 16, + ), + decoration: BoxDecoration( + color: ColorsManager.boxColor, + borderRadius: BorderRadius.circular(10), + ), + child: Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.start, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + ...widget.subSpaces.asMap().entries.map( + (entry) { + final index = entry.key; + final subSpace = entry.value; + + final lowerName = subSpace.name.toLowerCase(); + + final duplicateIndices = widget.subSpaces + .asMap() + .entries + .where((e) => e.value.name.toLowerCase() == lowerName) + .map((e) => e.key) + .toList(); + final isDuplicate = duplicateIndices.length > 1 && + duplicateIndices.indexOf(index) != 0; + return SubspaceChip( + subSpace: subSpace, + isDuplicate: isDuplicate, + onDeleted: () => widget.onSubspaceDeleted(subSpace.uuid), + ); + }, + ), + SizedBox( + width: 200, + child: TextField( + focusNode: _focusNode, + controller: _subspaceNameController, + decoration: InputDecoration( + border: InputBorder.none, + hintText: widget.subSpaces.isEmpty ? 'Please enter the name' : null, + hintStyle: context.textTheme.bodySmall?.copyWith( + color: ColorsManager.lightGrayColor, + ), + ), + onSubmitted: (value) { + final trimmedValue = value.trim(); + if (trimmedValue.isNotEmpty) { + widget.onSubspaceAdded(trimmedValue); + _subspaceNameController.clear(); + _focusNode.requestFocus(); + } + }, + style: context.textTheme.bodyMedium, + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/space_management_v2/modules/space_details/presentation/widgets/subspace_chip.dart b/lib/pages/space_management_v2/modules/space_details/presentation/widgets/subspace_chip.dart new file mode 100644 index 00000000..a80ddd15 --- /dev/null +++ b/lib/pages/space_management_v2/modules/space_details/presentation/widgets/subspace_chip.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/space_details/domain/models/space_details_model.dart'; +import 'package:syncrow_web/utils/color_manager.dart'; +import 'package:syncrow_web/utils/extension/build_context_x.dart'; + +class SubspaceChip extends StatelessWidget { + const SubspaceChip({ + required this.subSpace, + required this.isDuplicate, + required this.onDeleted, + super.key, + }); + + final Subspace subSpace; + final bool isDuplicate; + final void Function() onDeleted; + + @override + Widget build(BuildContext context) { + return Chip( + label: Text( + subSpace.name, + style: context.textTheme.bodySmall?.copyWith( + color: isDuplicate ? ColorsManager.red : ColorsManager.spaceColor, + ), + ), + backgroundColor: ColorsManager.whiteColors, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: BorderSide( + color: isDuplicate ? ColorsManager.red : ColorsManager.transparentColor, + width: 0, + ), + ), + deleteIcon: Container( + padding: const EdgeInsetsDirectional.all(1), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: ColorsManager.lightGrayColor, + width: 1.5, + ), + ), + child: const FittedBox( + fit: BoxFit.scaleDown, + child: Icon( + Icons.close, + color: ColorsManager.lightGrayColor, + ), + ), + ), + onDeleted: onDeleted, + ); + } +} diff --git a/lib/pages/space_management_v2/modules/space_details/presentation/widgets/subspace_name_display_widget.dart b/lib/pages/space_management_v2/modules/space_details/presentation/widgets/subspace_name_display_widget.dart new file mode 100644 index 00000000..bf13ffd3 --- /dev/null +++ b/lib/pages/space_management_v2/modules/space_details/presentation/widgets/subspace_name_display_widget.dart @@ -0,0 +1,171 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_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/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/extension/build_context_x.dart'; + +class SubspaceNameDisplayWidget extends StatefulWidget { + const SubspaceNameDisplayWidget({super.key, required this.subSpace}); + + final Subspace subSpace; + + @override + State createState() => + _SubspaceNameDisplayWidgetState(); +} + +class _SubspaceNameDisplayWidgetState extends State { + late final TextEditingController _controller; + late final FocusNode _focusNode; + bool _isEditing = false; + bool _hasDuplicateName = false; + + @override + void initState() { + _controller = TextEditingController(text: widget.subSpace.name); + _focusNode = FocusNode(); + super.initState(); + } + + @override + void dispose() { + _controller.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + bool _checkForDuplicateName(String name) { + final bloc = context.read(); + return bloc.state.subspaces + .where((s) => s.uuid != widget.subSpace.uuid) + .any((s) => s.name.toLowerCase() == name.toLowerCase()); + } + + void _handleNameChange(String value) { + setState(() { + _hasDuplicateName = _checkForDuplicateName(value); + }); + } + + void _tryToFinishEditing() { + if (!_hasDuplicateName) { + _onFinishEditing(); + } + } + + void _tryToSubmit(String value) { + if (_hasDuplicateName) return; + + final bloc = context.read(); + bloc.add( + UpdateSpaceDetailsSubspaces( + bloc.state.subspaces + .map( + (e) => e.uuid == widget.subSpace.uuid ? e.copyWith(name: value) : e, + ) + .toList(), + ), + ); + _onFinishEditing(); + } + + @override + Widget build(BuildContext context) { + final textStyle = context.textTheme.bodySmall?.copyWith( + color: ColorsManager.spaceColor, + ); + return InkWell( + onTap: () { + setState(() => _isEditing = true); + _focusNode.requestFocus(); + }, + child: Chip( + backgroundColor: ColorsManager.whiteColors, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + side: const BorderSide(color: ColorsManager.transparentColor), + ), + onDeleted: () { + final bloc = context.read(); + bloc.add( + UpdateSpaceDetailsSubspaces( + bloc.state.subspaces + .where((s) => s.uuid != widget.subSpace.uuid) + .toList(), + ), + ); + }, + deleteIcon: Container( + padding: const EdgeInsetsDirectional.all(1), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: ColorsManager.lightGrayColor, + width: 1.5, + ), + ), + child: const FittedBox( + child: Icon( + Icons.close, + color: ColorsManager.lightGrayColor, + ), + ), + ), + label: Visibility( + visible: _isEditing, + replacement: Text( + widget.subSpace.name, + style: textStyle, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: context.screenWidth * 0.065, + height: context.screenHeight * 0.025, + child: TextField( + focusNode: _focusNode, + controller: _controller, + style: textStyle?.copyWith( + color: _hasDuplicateName ? Colors.red : null, + ), + decoration: const InputDecoration.collapsed( + hintText: '', + ), + onChanged: _handleNameChange, + onTapOutside: (_) => _tryToFinishEditing(), + onSubmitted: _tryToSubmit, + ), + ), + if (_hasDuplicateName) + AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + child: Visibility( + key: ValueKey(_hasDuplicateName), + visible: _hasDuplicateName, + child: Text( + 'Name already exists', + style: textStyle?.copyWith( + color: Colors.red, + fontSize: 8, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + void _onFinishEditing() { + setState(() { + _isEditing = false; + _hasDuplicateName = false; + }); + _focusNode.unfocus(); + } +} diff --git a/lib/pages/space_management_v2/modules/update_space/presentation/bloc/space_details_model_bloc/space_details_model_bloc.dart b/lib/pages/space_management_v2/modules/update_space/presentation/bloc/space_details_model_bloc/space_details_model_bloc.dart new file mode 100644 index 00000000..21a72557 --- /dev/null +++ b/lib/pages/space_management_v2/modules/update_space/presentation/bloc/space_details_model_bloc/space_details_model_bloc.dart @@ -0,0 +1,45 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/space_details/domain/models/space_details_model.dart'; + +part 'space_details_model_event.dart'; + +class SpaceDetailsModelBloc extends Bloc { + SpaceDetailsModelBloc({ + required SpaceDetailsModel initialState, + }) : super(initialState) { + on(_onUpdateSpaceDetailsIcon); + on(_onUpdateSpaceDetailsName); + on(_onUpdateSpaceDetailsSubspaces); + on( + _onUpdateSpaceDetailsProductAllocations); + } + + void _onUpdateSpaceDetailsIcon( + UpdateSpaceDetailsIcon event, + Emitter emit, + ) { + emit(state.copyWith(icon: event.icon)); + } + + void _onUpdateSpaceDetailsName( + UpdateSpaceDetailsName event, + Emitter emit, + ) { + emit(state.copyWith(spaceName: event.name)); + } + + void _onUpdateSpaceDetailsSubspaces( + UpdateSpaceDetailsSubspaces event, + Emitter emit, + ) { + emit(state.copyWith(subspaces: event.subspaces)); + } + + void _onUpdateSpaceDetailsProductAllocations( + UpdateSpaceDetailsProductAllocations event, + Emitter emit, + ) { + emit(state.copyWith(productAllocations: event.productAllocations)); + } +} diff --git a/lib/pages/space_management_v2/modules/update_space/presentation/bloc/space_details_model_bloc/space_details_model_event.dart b/lib/pages/space_management_v2/modules/update_space/presentation/bloc/space_details_model_bloc/space_details_model_event.dart new file mode 100644 index 00000000..d3e04bb9 --- /dev/null +++ b/lib/pages/space_management_v2/modules/update_space/presentation/bloc/space_details_model_bloc/space_details_model_event.dart @@ -0,0 +1,44 @@ +part of 'space_details_model_bloc.dart'; + +sealed class SpaceDetailsModelEvent extends Equatable { + const SpaceDetailsModelEvent(); + + @override + List get props => []; +} + +final class UpdateSpaceDetailsIcon extends SpaceDetailsModelEvent { + const UpdateSpaceDetailsIcon(this.icon); + + final String icon; + + @override + List get props => [icon]; +} + +final class UpdateSpaceDetailsName extends SpaceDetailsModelEvent { + const UpdateSpaceDetailsName(this.name); + + final String name; + + @override + List get props => [name]; +} + +final class UpdateSpaceDetailsSubspaces extends SpaceDetailsModelEvent { + const UpdateSpaceDetailsSubspaces(this.subspaces); + + final List subspaces; + + @override + List get props => [subspaces]; +} + +final class UpdateSpaceDetailsProductAllocations extends SpaceDetailsModelEvent { + const UpdateSpaceDetailsProductAllocations(this.productAllocations); + + final List productAllocations; + + @override + List get props => [productAllocations]; +} diff --git a/lib/utils/theme/theme.dart b/lib/utils/theme/theme.dart index 5ac61afa..5c036762 100644 --- a/lib/utils/theme/theme.dart +++ b/lib/utils/theme/theme.dart @@ -52,4 +52,7 @@ final myTheme = ThemeData( borderRadius: BorderRadius.circular(4), ), ), + dialogTheme: const DialogThemeData( + backgroundColor: ColorsManager.whiteColors, + ), );