From 1be52adcc8feea402b562a6888c1e62c3949d83d Mon Sep 17 00:00:00 2001 From: hannathkadher Date: Sun, 12 Jan 2025 10:43:47 +0400 Subject: [PATCH] added assign tag --- ...idget.dart => add_device_type_widget.dart} | 21 +- .../widgets/dialogs/create_space_dialog.dart | 106 +++++- .../assign_tag/bloc/assign_tag_bloc.dart | 153 ++++++++ .../assign_tag/bloc/assign_tag_event.dart | 55 +++ .../assign_tag/bloc/assign_tag_state.dart | 38 ++ .../assign_tag/views/assign_tag_dialog.dart | 340 ++++++++++++++++++ 6 files changed, 707 insertions(+), 6 deletions(-) rename lib/pages/spaces_management/add_device_type/views/{add_device_type_model_widget.dart => add_device_type_widget.dart} (82%) create mode 100644 lib/pages/spaces_management/assign_tag/bloc/assign_tag_bloc.dart create mode 100644 lib/pages/spaces_management/assign_tag/bloc/assign_tag_event.dart create mode 100644 lib/pages/spaces_management/assign_tag/bloc/assign_tag_state.dart create mode 100644 lib/pages/spaces_management/assign_tag/views/assign_tag_dialog.dart diff --git a/lib/pages/spaces_management/add_device_type/views/add_device_type_model_widget.dart b/lib/pages/spaces_management/add_device_type/views/add_device_type_widget.dart similarity index 82% rename from lib/pages/spaces_management/add_device_type/views/add_device_type_model_widget.dart rename to lib/pages/spaces_management/add_device_type/views/add_device_type_widget.dart index 38f5650b..677b9fb9 100644 --- a/lib/pages/spaces_management/add_device_type/views/add_device_type_model_widget.dart +++ b/lib/pages/spaces_management/add_device_type/views/add_device_type_widget.dart @@ -1,16 +1,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:syncrow_web/pages/common/buttons/cancel_button.dart'; +import 'package:syncrow_web/pages/spaces_management/add_device_type/bloc/add_device_model_bloc.dart'; import 'package:syncrow_web/pages/spaces_management/add_device_type/widgets/scrollable_grid_view_widget.dart'; import 'package:syncrow_web/pages/spaces_management/all_spaces/model/subspace_model.dart'; import 'package:syncrow_web/pages/spaces_management/all_spaces/model/tag.dart'; import 'package:syncrow_web/pages/spaces_management/all_spaces/model/product_model.dart'; import 'package:syncrow_web/pages/spaces_management/all_spaces/model/selected_product_model.dart'; -import 'package:syncrow_web/pages/spaces_management/tag_model/bloc/add_device_model_bloc.dart'; +import 'package:syncrow_web/pages/spaces_management/assign_tag/views/assign_tag_dialog.dart'; import 'package:syncrow_web/pages/spaces_management/tag_model/widgets/action_button_widget.dart'; import 'package:syncrow_web/utils/color_manager.dart'; -import '../bloc/add_device_model_bloc.dart'; class AddDeviceTypeWidget extends StatelessWidget { final List? products; @@ -20,6 +20,8 @@ class AddDeviceTypeWidget extends StatelessWidget { final List? spaceTags; final List? allTags; final String spaceName; + final Function(List,List?)? onSave; + const AddDeviceTypeWidget( {super.key, @@ -29,6 +31,7 @@ class AddDeviceTypeWidget extends StatelessWidget { this.subspaces, this.allTags, this.spaceTags, + this.onSave, required this.spaceName}); @override @@ -93,6 +96,20 @@ class AddDeviceTypeWidget extends StatelessWidget { final dialogTitle = initialTags.isNotEmpty ? 'Edit Device' : 'Assign Tags'; + await showDialog( + barrierDismissible: false, + context: context, + builder: (context) => AssignTagDialog( + products: products, + subspaces: subspaces, + addedProducts: currentState, + allTags: allTags, + spaceName: spaceName, + initialTags: initialTags, + title: dialogTitle, + onSave: onSave, + ), + ); } }, ), diff --git a/lib/pages/spaces_management/all_spaces/widgets/dialogs/create_space_dialog.dart b/lib/pages/spaces_management/all_spaces/widgets/dialogs/create_space_dialog.dart index e7e103e3..ed4a22b9 100644 --- a/lib/pages/spaces_management/all_spaces/widgets/dialogs/create_space_dialog.dart +++ b/lib/pages/spaces_management/all_spaces/widgets/dialogs/create_space_dialog.dart @@ -2,13 +2,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:syncrow_web/pages/common/buttons/cancel_button.dart'; import 'package:syncrow_web/pages/common/buttons/default_button.dart'; -import 'package:syncrow_web/pages/spaces_management/add_device_type/views/add_device_type_model_widget.dart'; +import 'package:syncrow_web/pages/spaces_management/add_device_type/views/add_device_type_widget.dart'; import 'package:syncrow_web/pages/spaces_management/all_spaces/model/product_model.dart'; import 'package:syncrow_web/pages/spaces_management/all_spaces/model/selected_product_model.dart'; import 'package:syncrow_web/pages/spaces_management/all_spaces/model/space_model.dart'; import 'package:syncrow_web/pages/spaces_management/all_spaces/model/subspace_model.dart'; import 'package:syncrow_web/pages/spaces_management/all_spaces/model/tag.dart'; -import 'package:syncrow_web/pages/spaces_management/all_spaces/widgets/add_device_type_widget.dart'; import 'package:syncrow_web/pages/spaces_management/all_spaces/widgets/dialogs/icon_selection_dialog.dart'; import 'package:syncrow_web/pages/spaces_management/create_subspace/views/create_subspace_model_dialog.dart'; import 'package:syncrow_web/pages/spaces_management/link_space_model/view/link_space_model_dialog.dart'; @@ -72,7 +71,6 @@ class CreateSpaceDialogState extends State { enteredName.isNotEmpty || nameController.text.isNotEmpty; } - @override @override Widget build(BuildContext context) { final screenWidth = MediaQuery.of(context).size.width; @@ -437,7 +435,80 @@ class CreateSpaceDialogState extends State { subspaces?.any((subspace) => subspace.tags?.isNotEmpty == true) == true) - ? Container() + ? SizedBox( + width: screenWidth * 0.25, + child: Container( + padding: const EdgeInsets.all(8.0), + decoration: BoxDecoration( + color: ColorsManager.textFieldGreyColor, + borderRadius: BorderRadius.circular(15), + border: Border.all( + color: ColorsManager.textFieldGreyColor, + width: 3.0, // Border width + ), + ), + child: Wrap( + spacing: 8.0, + runSpacing: 8.0, + children: [ + // Combine tags from spaceModel and subspaces + ..._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}', // Show count + style: const TextStyle( + color: ColorsManager.spaceColor, + ), + ), + backgroundColor: + ColorsManager.whiteColors, + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(16), + side: const BorderSide( + color: ColorsManager.spaceColor, + ), + ), + ), + ), + GestureDetector( + onTap: () async { + _showTagCreateDialog(context, enteredName, + tags, subspaces, widget.products); + // Edit action + }, + child: Chip( + label: const Text( + 'Edit', + style: TextStyle( + color: ColorsManager.spaceColor), + ), + backgroundColor: + ColorsManager.whiteColors, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: const BorderSide( + color: ColorsManager.spaceColor), + ), + ), + ), + ], + ), + ), + ) : DefaultButton( onPressed: () { _showTagCreateDialog(context, enteredName, tags, @@ -618,6 +689,23 @@ class CreateSpaceDialogState extends State { allTags: [], initialSelectedProducts: createInitialSelectedProducts(tags, subspaces), + onSave: (selectedSpaceTags, selectedSubspaces) { + setState(() { + if (spaceTags != null) selectedSpaceTags = spaceTags; + if (selectedSubspaces != null) { + if (subspaces != null) { + for (final subspace in subspaces) { + for (final selectedSubspace in selectedSubspaces) { + if (subspace.subspaceName == + selectedSubspace.subspaceName) { + subspace.tags = selectedSubspace.tags; + } + } + } + } + } + }); + }, ); }, ); @@ -657,4 +745,14 @@ class CreateSpaceDialogState extends State { )) .toList(); } + + Map _groupTags(List tags) { + final Map groupedTags = {}; + for (var tag in tags) { + if (tag.product != null) { + groupedTags[tag.product!] = (groupedTags[tag.product!] ?? 0) + 1; + } + } + return groupedTags; + } } diff --git a/lib/pages/spaces_management/assign_tag/bloc/assign_tag_bloc.dart b/lib/pages/spaces_management/assign_tag/bloc/assign_tag_bloc.dart new file mode 100644 index 00000000..6adcc6a7 --- /dev/null +++ b/lib/pages/spaces_management/assign_tag/bloc/assign_tag_bloc.dart @@ -0,0 +1,153 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:syncrow_web/pages/spaces_management/all_spaces/model/tag.dart'; +import 'package:syncrow_web/pages/spaces_management/assign_tag/bloc/assign_tag_event.dart'; +import 'package:syncrow_web/pages/spaces_management/assign_tag/bloc/assign_tag_state.dart'; + +class AssignTagBloc + extends Bloc { + AssignTagBloc() : super(AssignTagInitial()) { + on((event, emit) { + final initialTags = event.initialTags ?? []; + + final existingTagCounts = {}; + for (var tag in initialTags) { + if (tag.product != null) { + existingTagCounts[tag.product!.uuid] = + (existingTagCounts[tag.product!.uuid] ?? 0) + 1; + } + } + + final allTags = []; + + for (var selectedProduct in event.addedProducts) { + final existingCount = existingTagCounts[selectedProduct.productId] ?? 0; + + if (selectedProduct.count == 0 || + selectedProduct.count <= existingCount) { + allTags.addAll(initialTags + .where((tag) => tag.product?.uuid == selectedProduct.productId)); + continue; + } + + final missingCount = selectedProduct.count - existingCount; + + allTags.addAll(initialTags + .where((tag) => tag.product?.uuid == selectedProduct.productId)); + + if (missingCount > 0) { + allTags.addAll(List.generate( + missingCount, + (index) => Tag( + tag: '', + product: selectedProduct.product, + location: 'None', + ), + )); + } + } + + emit(AssignTagLoaded( + tags: allTags, + isSaveEnabled: _validateTags(allTags), + )); + }); + + on((event, emit) { + final currentState = state; + + if (currentState is AssignTagLoaded && + currentState.tags.isNotEmpty) { + final tags = List.from(currentState.tags); + tags[event.index].tag = event.tag; + emit(AssignTagLoaded( + tags: tags, + isSaveEnabled: _validateTags(tags), + errorMessage: _getValidationError(tags), + )); + } + }); + + on((event, emit) { + final currentState = state; + + if (currentState is AssignTagLoaded && + currentState.tags.isNotEmpty) { + final tags = List.from(currentState.tags); + + // Use copyWith for immutability + tags[event.index] = + tags[event.index].copyWith(location: event.location); + + emit(AssignTagLoaded( + tags: tags, + isSaveEnabled: _validateTags(tags), + )); + } + }); + + on((event, emit) { + final currentState = state; + + if (currentState is AssignTagLoaded && + currentState.tags.isNotEmpty) { + final tags = List.from(currentState.tags); + + emit(AssignTagLoaded( + tags: tags, + isSaveEnabled: _validateTags(tags), + errorMessage: _getValidationError(tags), + )); + } + }); + + on((event, emit) { + final currentState = state; + + if (currentState is AssignTagLoaded && + currentState.tags.isNotEmpty) { + final updatedTags = List.from(currentState.tags) + ..remove(event.tagToDelete); + + emit(AssignTagLoaded( + tags: updatedTags, + isSaveEnabled: _validateTags(updatedTags), + )); + } else { + emit(const AssignTagLoaded( + tags: [], + isSaveEnabled: false, + )); + } + }); + } + + bool _validateTags(List tags) { + if (tags.isEmpty) { + return false; + } + final uniqueTags = tags.map((tag) => tag.tag?.trim() ?? '').toSet(); + final hasEmptyTag = tags.any((tag) => (tag.tag?.trim() ?? '').isEmpty); + return uniqueTags.length == tags.length && !hasEmptyTag; + } + + String? _getValidationError(List tags) { + final hasEmptyTag = tags.any((tag) => (tag.tag?.trim() ?? '').isEmpty); + if (hasEmptyTag) return 'Tags cannot be empty.'; + final duplicateTags = tags + .map((tag) => tag.tag?.trim() ?? '') + .fold>({}, (map, tag) { + map[tag] = (map[tag] ?? 0) + 1; + return map; + }) + .entries + .where((entry) => entry.value > 1) + .map((entry) => entry.key) + .toList(); + + if (duplicateTags.isNotEmpty) { + return 'Duplicate tags found: ${duplicateTags.join(', ')}'; + } + + return null; + } +} diff --git a/lib/pages/spaces_management/assign_tag/bloc/assign_tag_event.dart b/lib/pages/spaces_management/assign_tag/bloc/assign_tag_event.dart new file mode 100644 index 00000000..9116b094 --- /dev/null +++ b/lib/pages/spaces_management/assign_tag/bloc/assign_tag_event.dart @@ -0,0 +1,55 @@ +import 'package:equatable/equatable.dart'; +import 'package:syncrow_web/pages/spaces_management/all_spaces/model/tag.dart'; +import 'package:syncrow_web/pages/spaces_management/all_spaces/model/selected_product_model.dart'; + +abstract class AssignTagEvent extends Equatable { + const AssignTagEvent(); + + @override + List get props => []; +} + +class InitializeTags extends AssignTagEvent { + final List? initialTags; + final List addedProducts; + + const InitializeTags({ + required this.initialTags, + required this.addedProducts, + }); + + @override + List get props => [initialTags ?? [], addedProducts]; +} + +class UpdateTagEvent extends AssignTagEvent { + final int index; + final String tag; + + const UpdateTagEvent({required this.index, required this.tag}); + + @override + List get props => [index, tag]; +} + +class UpdateLocation extends AssignTagEvent { + final int index; + final String location; + + const UpdateLocation({required this.index, required this.location}); + + @override + List get props => [index, location]; +} + +class ValidateTags extends AssignTagEvent {} + +class DeleteTag extends AssignTagEvent { + final Tag tagToDelete; + final List tags; + + const DeleteTag({required this.tagToDelete, required this.tags}); + + @override + List get props => [tagToDelete, tags]; +} diff --git a/lib/pages/spaces_management/assign_tag/bloc/assign_tag_state.dart b/lib/pages/spaces_management/assign_tag/bloc/assign_tag_state.dart new file mode 100644 index 00000000..19cf4435 --- /dev/null +++ b/lib/pages/spaces_management/assign_tag/bloc/assign_tag_state.dart @@ -0,0 +1,38 @@ +import 'package:equatable/equatable.dart'; +import 'package:syncrow_web/pages/spaces_management/all_spaces/model/tag.dart'; +import 'package:syncrow_web/pages/spaces_management/space_model/models/tag_model.dart'; + +abstract class AssignTagState extends Equatable { + const AssignTagState(); + + @override + List get props => []; +} + +class AssignTagInitial extends AssignTagState {} + +class AssignTagLoading extends AssignTagState {} + +class AssignTagLoaded extends AssignTagState { + final List tags; + final bool isSaveEnabled; + final String? errorMessage; + + const AssignTagLoaded({ + required this.tags, + required this.isSaveEnabled, + this.errorMessage, + }); + + @override + List get props => [tags, isSaveEnabled]; +} + +class AssignTagError extends AssignTagState { + final String errorMessage; + + const AssignTagError(this.errorMessage); + + @override + List get props => [errorMessage]; +} diff --git a/lib/pages/spaces_management/assign_tag/views/assign_tag_dialog.dart b/lib/pages/spaces_management/assign_tag/views/assign_tag_dialog.dart new file mode 100644 index 00000000..31f9bec1 --- /dev/null +++ b/lib/pages/spaces_management/assign_tag/views/assign_tag_dialog.dart @@ -0,0 +1,340 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:syncrow_web/pages/common/buttons/cancel_button.dart'; +import 'package:syncrow_web/pages/common/buttons/default_button.dart'; +import 'package:syncrow_web/pages/spaces_management/all_spaces/model/product_model.dart'; +import 'package:syncrow_web/pages/spaces_management/all_spaces/model/selected_product_model.dart'; +import 'package:syncrow_web/pages/spaces_management/all_spaces/model/subspace_model.dart'; +import 'package:syncrow_web/pages/spaces_management/all_spaces/model/tag.dart'; +import 'package:syncrow_web/pages/spaces_management/assign_tag/bloc/assign_tag_bloc.dart'; +import 'package:syncrow_web/pages/spaces_management/assign_tag/bloc/assign_tag_event.dart'; +import 'package:syncrow_web/pages/spaces_management/assign_tag/bloc/assign_tag_state.dart'; +import 'package:syncrow_web/utils/color_manager.dart'; + +class AssignTagDialog extends StatelessWidget { + final List? products; + final List? subspaces; + final List? initialTags; + final ValueChanged>? onTagsAssigned; + final List addedProducts; + final List? allTags; + final String spaceName; + final String title; + final Function(List, List?)? onSave; + + const AssignTagDialog( + {Key? key, + required this.products, + required this.subspaces, + required this.addedProducts, + this.initialTags, + this.onTagsAssigned, + this.allTags, + required this.spaceName, + required this.title, + this.onSave}) + : super(key: key); + + @override + Widget build(BuildContext context) { + final List locations = + (subspaces ?? []).map((subspace) => subspace.subspaceName).toList(); + return BlocProvider( + create: (_) => AssignTagBloc() + ..add(InitializeTags( + initialTags: initialTags, + addedProducts: addedProducts, + )), + child: BlocBuilder( + builder: (context, state) { + if (state is AssignTagLoaded) { + final controllers = List.generate( + state.tags.length, + (index) => TextEditingController(text: state.tags[index].tag), + ); + + return AlertDialog( + title: Text(title), + backgroundColor: ColorsManager.whiteColors, + content: SingleChildScrollView( + child: Column( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(20), + child: DataTable( + headingRowColor: WidgetStateProperty.all( + ColorsManager.dataHeaderGrey), + border: TableBorder.all( + color: ColorsManager.dataHeaderGrey, + width: 1, + borderRadius: BorderRadius.circular(20), + ), + columns: [ + DataColumn( + label: Text('#', + style: + Theme.of(context).textTheme.bodyMedium)), + DataColumn( + label: Text('Device', + style: + Theme.of(context).textTheme.bodyMedium)), + DataColumn( + label: Text('Tag', + style: + Theme.of(context).textTheme.bodyMedium)), + DataColumn( + label: Text('Location', + style: + Theme.of(context).textTheme.bodyMedium)), + ], + rows: state.tags.isEmpty + ? [ + const DataRow(cells: [ + DataCell( + Center( + child: Text( + 'No Data Available', + style: TextStyle( + fontSize: 14, + color: ColorsManager.lightGrayColor, + ), + ), + ), + ), + DataCell(SizedBox()), + DataCell(SizedBox()), + DataCell(SizedBox()), + ]) + ] + : List.generate(state.tags.length, (index) { + final tag = state.tags[index]; + final controller = controllers[index]; + + return DataRow( + cells: [ + DataCell(Text(index.toString())), + DataCell( + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + tag.product?.name ?? 'Unknown', + overflow: TextOverflow.ellipsis, + )), + IconButton( + icon: const Icon(Icons.close, + color: ColorsManager.warningRed, + size: 16), + onPressed: () { + context.read().add( + DeleteTag( + tagToDelete: tag, + tags: state.tags)); + }, + tooltip: 'Delete Tag', + ) + ], + ), + ), + DataCell( + Row( + children: [ + Expanded( + child: TextFormField( + controller: controller, + onChanged: (value) { + context + .read() + .add(UpdateTagEvent( + index: index, + tag: value.trim(), + )); + }, + decoration: const InputDecoration( + hintText: 'Enter Tag', + border: InputBorder.none, + ), + style: const TextStyle( + fontSize: 14, + color: ColorsManager.blackColor, + ), + ), + ), + SizedBox( + width: MediaQuery.of(context) + .size + .width * + 0.15, + child: PopupMenuButton( + color: ColorsManager.whiteColors, + icon: const Icon( + Icons.arrow_drop_down, + color: + ColorsManager.blackColor), + onSelected: (value) { + controller.text = value; + context + .read() + .add(UpdateTagEvent( + index: index, + tag: value, + )); + }, + itemBuilder: (context) { + return (allTags ?? []) + .where((tagValue) => !state + .tags + .map((e) => e.tag) + .contains(tagValue)) + .map((tagValue) { + return PopupMenuItem( + textStyle: const TextStyle( + color: ColorsManager + .textPrimaryColor), + value: tagValue, + child: ConstrainedBox( + constraints: + BoxConstraints( + minWidth: MediaQuery.of( + context) + .size + .width * + 0.15, + maxWidth: MediaQuery.of( + context) + .size + .width * + 0.15, + ), + child: Text( + tagValue, + overflow: TextOverflow + .ellipsis, + ), + )); + }).toList(); + }, + ), + ), + ], + ), + ), + DataCell( + DropdownButtonHideUnderline( + child: DropdownButton( + value: tag.location ?? 'None', + dropdownColor: ColorsManager + .whiteColors, // Dropdown background + style: const TextStyle( + color: Colors + .black), // Style for selected text + items: [ + const DropdownMenuItem( + value: 'None', + child: Text( + 'None', + style: TextStyle( + color: ColorsManager + .textPrimaryColor), + ), + ), + ...locations.map((location) { + return DropdownMenuItem( + value: location, + child: Text( + location, + style: const TextStyle( + color: ColorsManager + .textPrimaryColor), + ), + ); + }).toList(), + ], + onChanged: (value) { + if (value != null) { + context + .read() + .add(UpdateLocation( + index: index, + location: value, + )); + } + }, + ), + ), + ), + ], + ); + }), + ), + ), + if (state.errorMessage != null) + Text( + state.errorMessage!, + style: const TextStyle(color: ColorsManager.warningRed), + ), + ], + ), + ), + actions: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + const SizedBox(width: 10), + Expanded( + child: CancelButton( + label: 'Cancel', + onPressed: () async { + Navigator.of(context).pop(); + }, + ), + ), + const SizedBox(width: 10), + Expanded( + child: DefaultButton( + borderRadius: 10, + backgroundColor: state.isSaveEnabled + ? ColorsManager.secondaryColor + : ColorsManager.grayColor, + foregroundColor: ColorsManager.whiteColors, + onPressed: state.isSaveEnabled + ? () async { + Navigator.of(context).pop(); + final assignedTags = {}; + for (var tag in state.tags) { + if (tag.location == null || + subspaces == null) { + continue; + } + for (var subspace in subspaces!) { + if (tag.location == subspace.subspaceName) { + subspace.tags ??= []; + subspace.tags!.add(tag); + assignedTags.add(tag); + break; + } + } + } + onSave!(state.tags,subspaces); + } + : null, + child: const Text('Save'), + ), + ), + const SizedBox(width: 10), + ], + ), + ], + ); + } else if (state is AssignTagLoading) { + return const Center(child: CircularProgressIndicator()); + } else { + return const Center(child: Text('Something went wrong.')); + } + }, + ), + ); + } +}