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 af6b1308..106b9a3a 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/products/data/services/remote_products_service.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/products/presentation/bloc/products_bloc.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/space_details/data/services/remote_space_details_service.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/space_details/data/services/unique_subspaces_decorator.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/space_details/presentation/bloc/space_details_bloc.dart'; @@ -36,6 +38,11 @@ class SpaceManagementPage extends StatelessWidget { ), ), ), + BlocProvider( + create: (context) => ProductsBloc( + RemoteProductsService(HTTPService()), + ), + ), ], child: WebScaffold( appBarTitle: Text( diff --git a/lib/pages/space_management_v2/modules/products/data/services/remote_products_service.dart b/lib/pages/space_management_v2/modules/products/data/services/remote_products_service.dart index 6e501b44..a01419fe 100644 --- a/lib/pages/space_management_v2/modules/products/data/services/remote_products_service.dart +++ b/lib/pages/space_management_v2/modules/products/data/services/remote_products_service.dart @@ -1,9 +1,9 @@ import 'package:dio/dio.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/models/product.dart'; -import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/params/load_products_param.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/services/products_service.dart'; import 'package:syncrow_web/services/api/api_exception.dart'; import 'package:syncrow_web/services/api/http_service.dart'; +import 'package:syncrow_web/utils/constants/api_const.dart'; class RemoteProductsService implements ProductsService { const RemoteProductsService(this._httpService); @@ -13,17 +13,14 @@ class RemoteProductsService implements ProductsService { static const _defaultErrorMessage = 'Failed to load devices'; @override - Future> getProducts(LoadProductsParam param) async { + Future> getProducts() async { try { final response = await _httpService.get( - path: 'devices', - queryParameters: { - 'spaceUuid': param.spaceUuid, - if (param.type != null) 'type': param.type, - if (param.status != null) 'status': param.status, - }, + path: ApiEndpoints.listProducts, expectedResponseModel: (data) { - return (data as List) + final json = data as Map; + final products = json['data'] as List; + return products .map((e) => Product.fromJson(e as Map)) .toList(); }, diff --git a/lib/pages/space_management_v2/modules/products/domain/models/product.dart b/lib/pages/space_management_v2/modules/products/domain/models/product.dart index cd837121..1a505bc5 100644 --- a/lib/pages/space_management_v2/modules/products/domain/models/product.dart +++ b/lib/pages/space_management_v2/modules/products/domain/models/product.dart @@ -1,18 +1,24 @@ import 'package:equatable/equatable.dart'; +import 'package:syncrow_web/utils/constants/assets.dart'; class Product extends Equatable { - final String uuid; - final String name; - const Product({ required this.uuid, required this.name, + required this.productType, }); + final String uuid; + final String name; + final String productType; + + String get icon => _mapIconToProduct(productType); + factory Product.fromJson(Map json) { return Product( - uuid: json['uuid'] as String, - name: json['name'] as String, + uuid: json['uuid'] as String? ?? '', + name: json['name'] as String? ?? '', + productType: json['prodType'] as String? ?? '', ); } @@ -20,9 +26,37 @@ class Product extends Equatable { return { 'uuid': uuid, 'name': name, + 'productType': productType, }; } + static String _mapIconToProduct(String prodType) { + const iconMapping = { + '1G': Assets.Gang1SwitchIcon, + '1GT': Assets.oneTouchSwitch, + '2G': Assets.Gang2SwitchIcon, + '2GT': Assets.twoTouchSwitch, + '3G': Assets.Gang3SwitchIcon, + '3GT': Assets.threeTouchSwitch, + 'CUR': Assets.curtain, + 'CUR_2': Assets.curtain, + 'GD': Assets.garageDoor, + 'GW': Assets.SmartGatewayIcon, + 'DL': Assets.DoorLockIcon, + 'WL': Assets.waterLeakSensor, + 'WH': Assets.waterHeater, + 'WM': Assets.waterLeakSensor, + 'SOS': Assets.sos, + 'AC': Assets.ac, + 'CPS': Assets.presenceSensor, + 'PC': Assets.powerClamp, + 'WPS': Assets.presenceSensor, + 'DS': Assets.doorSensor + }; + + return iconMapping[prodType] ?? Assets.presenceSensor; + } + @override - List get props => [uuid, name]; + List get props => [uuid, name, productType]; } diff --git a/lib/pages/space_management_v2/modules/products/domain/params/load_products_param.dart b/lib/pages/space_management_v2/modules/products/domain/params/load_products_param.dart deleted file mode 100644 index 87194ae7..00000000 --- a/lib/pages/space_management_v2/modules/products/domain/params/load_products_param.dart +++ /dev/null @@ -1,11 +0,0 @@ -class LoadProductsParam { - final String spaceUuid; - final String? type; - final String? status; - - const LoadProductsParam({ - required this.spaceUuid, - this.type, - this.status, - }); -} diff --git a/lib/pages/space_management_v2/modules/products/domain/services/products_service.dart b/lib/pages/space_management_v2/modules/products/domain/services/products_service.dart index 18554382..f6d41d0c 100644 --- a/lib/pages/space_management_v2/modules/products/domain/services/products_service.dart +++ b/lib/pages/space_management_v2/modules/products/domain/services/products_service.dart @@ -1,6 +1,5 @@ import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/models/product.dart'; -import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/params/load_products_param.dart'; abstract class ProductsService { - Future> getProducts(LoadProductsParam param); + Future> getProducts(); } diff --git a/lib/pages/space_management_v2/modules/products/presentation/bloc/products_bloc.dart b/lib/pages/space_management_v2/modules/products/presentation/bloc/products_bloc.dart index 1ce6ae89..0e85f1c7 100644 --- a/lib/pages/space_management_v2/modules/products/presentation/bloc/products_bloc.dart +++ b/lib/pages/space_management_v2/modules/products/presentation/bloc/products_bloc.dart @@ -1,7 +1,6 @@ import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/models/product.dart'; -import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/params/load_products_param.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/services/products_service.dart'; import 'package:syncrow_web/services/api/api_exception.dart'; @@ -9,20 +8,20 @@ part 'products_event.dart'; part 'products_state.dart'; class ProductsBloc extends Bloc { - final ProductsService _deviceService; - - ProductsBloc(this._deviceService) : super(ProductsInitial()) { + ProductsBloc(this._productsService) : super(ProductsInitial()) { on(_onLoadProducts); } + final ProductsService _productsService; + Future _onLoadProducts( LoadProducts event, Emitter emit, ) async { emit(ProductsLoading()); try { - final devices = await _deviceService.getProducts(event.param); - emit(ProductsLoaded(devices)); + final products = await _productsService.getProducts(); + emit(ProductsLoaded(products)); } on APIException catch (e) { emit(ProductsFailure(e.message)); } catch (e) { diff --git a/lib/pages/space_management_v2/modules/products/presentation/bloc/products_event.dart b/lib/pages/space_management_v2/modules/products/presentation/bloc/products_event.dart index 971b6d27..7bc14795 100644 --- a/lib/pages/space_management_v2/modules/products/presentation/bloc/products_event.dart +++ b/lib/pages/space_management_v2/modules/products/presentation/bloc/products_event.dart @@ -8,10 +8,5 @@ sealed class ProductsEvent extends Equatable { } final class LoadProducts extends ProductsEvent { - const LoadProducts(this.param); - - final LoadProductsParam param; - - @override - List get props => [param]; + const LoadProducts(); } diff --git a/lib/pages/space_management_v2/modules/products/presentation/bloc/products_state.dart b/lib/pages/space_management_v2/modules/products/presentation/bloc/products_state.dart index d5622cd3..78cee7ab 100644 --- a/lib/pages/space_management_v2/modules/products/presentation/bloc/products_state.dart +++ b/lib/pages/space_management_v2/modules/products/presentation/bloc/products_state.dart @@ -21,10 +21,10 @@ final class ProductsLoaded extends ProductsState { } final class ProductsFailure extends ProductsState { - final String message; + final String errorMessage; - const ProductsFailure(this.message); + const ProductsFailure(this.errorMessage); @override - List get props => [message]; + List get props => [errorMessage]; } 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 737d5060..b3e436b1 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 @@ -2,6 +2,7 @@ import 'package:equatable/equatable.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/models/product.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/models/tag.dart'; import 'package:syncrow_web/utils/constants/assets.dart'; +import 'package:uuid/uuid.dart'; class SpaceDetailsModel extends Equatable { final String uuid; @@ -21,7 +22,7 @@ class SpaceDetailsModel extends Equatable { factory SpaceDetailsModel.empty() => const SpaceDetailsModel( uuid: '', spaceName: '', - icon: Assets.villa, + icon: Assets.location, productAllocations: [], subspaces: [], ); @@ -31,8 +32,8 @@ class SpaceDetailsModel extends Equatable { 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(), @@ -70,16 +71,19 @@ class SpaceDetailsModel extends Equatable { } class ProductAllocation extends Equatable { + final String uuid; final Product product; final Tag tag; const ProductAllocation({ + required this.uuid, required this.product, required this.tag, }); factory ProductAllocation.fromJson(Map json) { return ProductAllocation( + uuid: json['uuid'] as String? ?? const Uuid().v4(), product: Product.fromJson(json['product'] as Map), tag: Tag.fromJson(json['tag'] as Map), ); @@ -87,23 +91,26 @@ class ProductAllocation extends Equatable { Map toJson() { return { + 'uuid': uuid, 'product': product.toJson(), 'tag': tag.toJson(), }; } ProductAllocation copyWith({ + String? uuid, Product? product, Tag? tag, }) { return ProductAllocation( + uuid: uuid ?? this.uuid, product: product ?? this.product, tag: tag ?? this.tag, ); } @override - List get props => [product, tag]; + List get props => [uuid, product, tag]; } class Subspace extends Equatable { 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 6b95556a..de2c40f0 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 @@ -18,7 +18,7 @@ abstract final class SpaceDetailsDialogHelper { context: context, title: const SelectableText('Create Space'), spaceModel: SpaceModel.empty(), - onSave: print, + onSave: (space) {}, ), ), ); 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 index 8518227f..8d7d2e29 100644 --- 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 @@ -8,10 +8,14 @@ class SpaceDetailsActionButtons extends StatelessWidget { super.key, required this.onSave, required this.onCancel, + this.saveButtonLabel = 'OK', + this.cancelButtonLabel = 'Cancel', }); final VoidCallback onCancel; final VoidCallback? onSave; + final String saveButtonLabel; + final String cancelButtonLabel; @override Widget build(BuildContext context) { @@ -27,10 +31,7 @@ class SpaceDetailsActionButtons extends StatelessWidget { } Widget _buildCancelButton(BuildContext context) { - return CancelButton( - onPressed: onCancel, - label: 'Cancel', - ); + return CancelButton(onPressed: onCancel, label: cancelButtonLabel); } Widget _buildSaveButton() { @@ -39,7 +40,7 @@ class SpaceDetailsActionButtons extends StatelessWidget { borderRadius: 10, backgroundColor: ColorsManager.secondaryColor, foregroundColor: ColorsManager.whiteColors, - child: const Text('OK'), + child: Text(saveButtonLabel), ); } } 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 index 5b21bd61..cf65dbb6 100644 --- 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 @@ -1,9 +1,15 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_svg/svg.dart'; import 'package:syncrow_web/common/edit_chip.dart'; import 'package:syncrow_web/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/tags/presentation/widgets/assign_tags_dialog.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/update_space/presentation/bloc/space_details_model_bloc/space_details_model_bloc.dart'; import 'package:syncrow_web/utils/color_manager.dart'; import 'package:syncrow_web/utils/constants/assets.dart'; +import 'package:syncrow_web/utils/enum/device_types.dart'; +import 'package:syncrow_web/utils/extension/build_context_x.dart'; class SpaceDetailsDevicesBox extends StatelessWidget { const SpaceDetailsDevicesBox({ @@ -15,11 +21,18 @@ class SpaceDetailsDevicesBox extends StatelessWidget { @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) { + final allAllocations = [ + ...space.productAllocations, + ...space.subspaces.expand((s) => s.productAllocations), + ]; + + if (allAllocations.isNotEmpty) { + final productCounts = {}; + for (final allocation in allAllocations) { + final productType = allocation.product.productType; + productCounts[productType] = (productCounts[productType] ?? 0) + 1; + } + return Container( width: double.infinity, padding: const EdgeInsets.all(8), @@ -35,46 +48,40 @@ class SpaceDetailsDevicesBox extends StatelessWidget { 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: () {}, - ), + ...productCounts.entries.map((entry) { + final productType = entry.key; + final count = entry.value; + return Chip( + avatar: SizedBox( + width: 24, + height: 24, + child: SvgPicture.asset( + _getDeviceIcon(productType), + fit: BoxFit.contain, + ), + ), + label: Text( + 'x$count', + style: context.textTheme.bodySmall?.copyWith( + color: ColorsManager.spaceColor, + ), + ), + backgroundColor: ColorsManager.whiteColors, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: const BorderSide( + color: ColorsManager.spaceColor, + ), + ), + ); + }), + EditChip(onTap: () => _showAssignTagsDialog(context)), ], ), ); } else { return TextButton( - onPressed: () {}, + onPressed: () => _showAssignTagsDialog(context), style: TextButton.styleFrom( padding: EdgeInsets.zero, ), @@ -83,10 +90,50 @@ class SpaceDetailsDevicesBox extends StatelessWidget { child: ButtonContentWidget( svgAssets: Assets.addIcon, label: 'Add Devices', - // disabled: isTagsAndSubspaceModelDisabled, ), ), ); } } + + void _showAssignTagsDialog(BuildContext context) { + showDialog( + context: context, + builder: (context) => AssignTagsDialog(space: space), + ).then((resultSpace) { + if (resultSpace != null) { + if (context.mounted) { + context.read().add(UpdateSpaceDetails(resultSpace)); + } + } + }); + } + + String _getDeviceIcon(String productType) => + switch (devicesTypesMap[productType]) { + DeviceType.LightBulb => Assets.lightBulb, + DeviceType.CeilingSensor => Assets.sensors, + DeviceType.AC => Assets.ac, + DeviceType.DoorLock => Assets.doorLock, + DeviceType.Curtain => Assets.curtain, + DeviceType.ThreeGang => Assets.gangSwitch, + DeviceType.Gateway => Assets.gateway, + DeviceType.OneGang => Assets.oneGang, + DeviceType.TwoGang => Assets.twoGang, + DeviceType.WH => Assets.waterHeater, + DeviceType.DoorSensor => Assets.openCloseDoor, + DeviceType.GarageDoor => Assets.openedDoor, + DeviceType.WaterLeak => Assets.waterLeakNormal, + DeviceType.Curtain2 => Assets.curtainIcon, + DeviceType.Blind => Assets.curtainIcon, + DeviceType.WallSensor => Assets.sensors, + DeviceType.DS => Assets.openCloseDoor, + DeviceType.OneTouch => Assets.gangSwitch, + DeviceType.TowTouch => Assets.gangSwitch, + DeviceType.ThreeTouch => Assets.gangSwitch, + DeviceType.NCPS => Assets.sensors, + DeviceType.PC => Assets.powerClamp, + DeviceType.Other => Assets.blackLogo, + null => Assets.blackLogo, + }; } 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 index d0495dd3..e4007511 100644 --- 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 @@ -42,9 +42,8 @@ class SpaceDetailsForm extends StatelessWidget { Expanded(child: SpaceIconPicker(iconPath: space.icon)), Expanded( flex: 2, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + child: ListView( + shrinkWrap: true, children: [ SpaceNameTextField( initialValue: space.spaceName, @@ -52,7 +51,7 @@ class SpaceDetailsForm extends StatelessWidget { (subspace) => subspace.name == value, ), ), - const Spacer(), + const SizedBox(height: 32), SpaceSubSpacesBox( subspaces: space.subspaces, ), diff --git a/lib/pages/space_management_v2/modules/tags/data/services/remote_tags_service.dart b/lib/pages/space_management_v2/modules/tags/data/services/remote_tags_service.dart index b5545bd3..76ceec71 100644 --- a/lib/pages/space_management_v2/modules/tags/data/services/remote_tags_service.dart +++ b/lib/pages/space_management_v2/modules/tags/data/services/remote_tags_service.dart @@ -1,10 +1,9 @@ import 'package:dio/dio.dart'; +import 'package:syncrow_web/pages/common/bloc/project_manager.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/models/tag.dart'; -import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/params/load_tags_param.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/services/tags_service.dart'; import 'package:syncrow_web/services/api/api_exception.dart'; import 'package:syncrow_web/services/api/http_service.dart'; -import 'package:syncrow_web/utils/constants/api_const.dart'; final class RemoteTagsService implements TagsService { const RemoteTagsService(this._httpService); @@ -14,17 +13,10 @@ final class RemoteTagsService implements TagsService { static const _defaultErrorMessage = 'Failed to load tags'; @override - Future> loadTags(LoadTagsParam param) async { - if (param.projectUuid == null) { - throw Exception('Project UUID is required'); - } - + Future> loadTags() async { try { final response = await _httpService.get( - path: ApiEndpoints.listTags.replaceAll( - '{projectUuid}', - param.projectUuid!, - ), + path: await _makeUrl(), expectedResponseModel: (json) { final result = json as Map; final data = result['data'] as List; @@ -46,4 +38,12 @@ final class RemoteTagsService implements TagsService { throw APIException(formattedErrorMessage); } } + + Future _makeUrl() async { + final projectUuid = await ProjectManager.getProjectUUID(); + if (projectUuid == null || projectUuid.isEmpty) { + throw APIException('Project UUID is required'); + } + return '/projects/$projectUuid/tags'; + } } diff --git a/lib/pages/space_management_v2/modules/tags/domain/models/tag.dart b/lib/pages/space_management_v2/modules/tags/domain/models/tag.dart index 1044d888..370bdf47 100644 --- a/lib/pages/space_management_v2/modules/tags/domain/models/tag.dart +++ b/lib/pages/space_management_v2/modules/tags/domain/models/tag.dart @@ -13,6 +13,13 @@ class Tag extends Equatable { required this.updatedAt, }); + factory Tag.empty() => const Tag( + uuid: '', + name: '', + createdAt: '', + updatedAt: '', + ); + factory Tag.fromJson(Map json) { return Tag( uuid: json['uuid'] as String, diff --git a/lib/pages/space_management_v2/modules/tags/domain/services/tags_service.dart b/lib/pages/space_management_v2/modules/tags/domain/services/tags_service.dart index ae097020..cf36527a 100644 --- a/lib/pages/space_management_v2/modules/tags/domain/services/tags_service.dart +++ b/lib/pages/space_management_v2/modules/tags/domain/services/tags_service.dart @@ -1,6 +1,5 @@ import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/models/tag.dart'; -import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/params/load_tags_param.dart'; abstract interface class TagsService { - Future> loadTags(LoadTagsParam param); + Future> loadTags(); } diff --git a/lib/pages/space_management_v2/modules/tags/presentation/bloc/tags_bloc.dart b/lib/pages/space_management_v2/modules/tags/presentation/bloc/tags_bloc.dart index e51884cb..b81fcb76 100644 --- a/lib/pages/space_management_v2/modules/tags/presentation/bloc/tags_bloc.dart +++ b/lib/pages/space_management_v2/modules/tags/presentation/bloc/tags_bloc.dart @@ -1,7 +1,6 @@ import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/models/tag.dart'; -import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/params/load_tags_param.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/services/tags_service.dart'; import 'package:syncrow_web/services/api/api_exception.dart'; @@ -21,7 +20,7 @@ class TagsBloc extends Bloc { ) async { emit(TagsLoading()); try { - final tags = await _tagsService.loadTags(event.param); + final tags = await _tagsService.loadTags(); emit(TagsLoaded(tags)); } on APIException catch (e) { emit(TagsFailure(e.message)); diff --git a/lib/pages/space_management_v2/modules/tags/presentation/bloc/tags_event.dart b/lib/pages/space_management_v2/modules/tags/presentation/bloc/tags_event.dart index 99134cab..8965b7b0 100644 --- a/lib/pages/space_management_v2/modules/tags/presentation/bloc/tags_event.dart +++ b/lib/pages/space_management_v2/modules/tags/presentation/bloc/tags_event.dart @@ -8,10 +8,5 @@ abstract class TagsEvent extends Equatable { } class LoadTags extends TagsEvent { - final LoadTagsParam param; - - const LoadTags(this.param); - - @override - List get props => [param]; + const LoadTags(); } diff --git a/lib/pages/space_management_v2/modules/tags/presentation/widgets/add_device_type_widget.dart b/lib/pages/space_management_v2/modules/tags/presentation/widgets/add_device_type_widget.dart new file mode 100644 index 00000000..4c9990ae --- /dev/null +++ b/lib/pages/space_management_v2/modules/tags/presentation/widgets/add_device_type_widget.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/products/data/services/remote_products_service.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/models/product.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/products/presentation/bloc/products_bloc.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/space_details/presentation/widgets/space_details_action_buttons.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/tags/presentation/widgets/products_grid.dart'; +import 'package:syncrow_web/services/api/http_service.dart'; +import 'package:syncrow_web/utils/color_manager.dart'; +import 'package:syncrow_web/utils/extension/build_context_x.dart'; + +class AddDeviceTypeWidget extends StatefulWidget { + const AddDeviceTypeWidget({super.key}); + + @override + State createState() => _AddDeviceTypeWidgetState(); +} + +class _AddDeviceTypeWidgetState extends State { + final Map _selectedProducts = {}; + + void _onIncrement(Product product) { + setState(() { + _selectedProducts[product] = (_selectedProducts[product] ?? 0) + 1; + }); + } + + void _onDecrement(Product product) { + setState(() { + if ((_selectedProducts[product] ?? 0) > 0) { + _selectedProducts[product] = _selectedProducts[product]! - 1; + if (_selectedProducts[product] == 0) { + _selectedProducts.remove(product); + } + } + }); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => ProductsBloc(RemoteProductsService(HTTPService())) + ..add(const LoadProducts()), + child: Builder( + builder: (context) => AlertDialog( + title: const SelectableText('Add Devices'), + backgroundColor: ColorsManager.whiteColors, + content: BlocBuilder( + builder: (context, state) => switch (state) { + ProductsInitial() || ProductsLoading() => _buildLoading(context), + ProductsLoaded(:final products) => ProductsGrid( + products: products, + selectedProducts: _selectedProducts, + onIncrement: _onIncrement, + onDecrement: _onDecrement, + ), + ProductsFailure(:final errorMessage) => _buildFailure( + context, + errorMessage, + ), + }, + ), + actions: [ + SpaceDetailsActionButtons( + onSave: () { + final result = _selectedProducts.entries + .expand((entry) => List.generate(entry.value, (_) => entry.key)) + .toList(); + Navigator.of(context).pop(result); + }, + onCancel: Navigator.of(context).pop, + saveButtonLabel: 'Next', + ), + ], + ), + ), + ); + } + + Widget _buildLoading(BuildContext context) => SizedBox( + width: context.screenWidth * 0.9, + height: context.screenHeight * 0.65, + child: const Center(child: CircularProgressIndicator()), + ); + + Widget _buildFailure(BuildContext context, String errorMessage) { + return SizedBox( + width: context.screenWidth * 0.9, + height: context.screenHeight * 0.65, + child: Center( + child: SelectableText( + errorMessage, + style: context.textTheme.bodyMedium?.copyWith( + color: context.theme.colorScheme.error, + ), + ), + ), + ); + } +} diff --git a/lib/pages/space_management_v2/modules/tags/presentation/widgets/assign_tags_dialog.dart b/lib/pages/space_management_v2/modules/tags/presentation/widgets/assign_tags_dialog.dart new file mode 100644 index 00000000..3cab4abe --- /dev/null +++ b/lib/pages/space_management_v2/modules/tags/presentation/widgets/assign_tags_dialog.dart @@ -0,0 +1,231 @@ +import 'package:flutter/material.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/models/product.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/space_details/domain/models/space_details_model.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/space_details/presentation/widgets/space_details_action_buttons.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/models/tag.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/tags/presentation/widgets/add_device_type_widget.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/tags/presentation/widgets/assign_tags_error_messages.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/tags/presentation/widgets/assign_tags_table.dart'; +import 'package:syncrow_web/utils/extension/build_context_x.dart'; +import 'package:uuid/uuid.dart'; + +class AssignTagsDialog extends StatefulWidget { + const AssignTagsDialog({required this.space, super.key}); + + final SpaceDetailsModel space; + + @override + State createState() => _AssignTagsDialogState(); +} + +class _AssignTagsDialogState extends State { + late SpaceDetailsModel _space; + final Map _validationErrors = {}; + + @override + void initState() { + super.initState(); + _space = widget.space.copyWith( + productAllocations: + widget.space.productAllocations.map((e) => e.copyWith()).toList(), + subspaces: widget.space.subspaces + .map( + (s) => s.copyWith( + productAllocations: + s.productAllocations.map((e) => e.copyWith()).toList(), + ), + ) + .toList(), + ); + _validateAllTags(); + } + + void _validateAllTags() { + final newErrors = {}; + final allAllocations = [ + ..._space.productAllocations, + ..._space.subspaces.expand((s) => s.productAllocations), + ]; + + final allocationsByProductType = >{}; + for (final allocation in allAllocations) { + (allocationsByProductType[allocation.product.productType] ??= []) + .add(allocation); + } + + for (final productType in allocationsByProductType.keys) { + final allocations = allocationsByProductType[productType]!; + final tagCounts = {}; + + for (final allocation in allocations) { + final tagName = allocation.tag.name.trim().toLowerCase(); + if (tagName.isEmpty) { + newErrors[allocation.uuid] = + 'Tag for ${allocation.product.name} cannot be empty.'; + } else { + tagCounts[tagName] = (tagCounts[tagName] ?? 0) + 1; + } + } + + for (final allocation in allocations) { + final tagName = allocation.tag.name.trim().toLowerCase(); + if (tagName.isNotEmpty && (tagCounts[tagName] ?? 0) > 1) { + newErrors[allocation.uuid] = + 'Tag "${allocation.tag.name}" is used by multiple $productType devices.'; + } + } + } + + setState(() { + _validationErrors + ..clear() + ..addAll(newErrors); + }); + } + + void _handleTagChange(String allocationUuid, Tag newTag) { + setState(() { + var index = + _space.productAllocations.indexWhere((pa) => pa.uuid == allocationUuid); + if (index != -1) { + final allocation = _space.productAllocations[index]; + _space.productAllocations[index] = allocation.copyWith(tag: newTag); + } else { + for (final subspace in _space.subspaces) { + index = subspace.productAllocations + .indexWhere((pa) => pa.uuid == allocationUuid); + if (index != -1) { + final allocation = subspace.productAllocations[index]; + subspace.productAllocations[index] = allocation.copyWith(tag: newTag); + break; + } + } + } + }); + _validateAllTags(); + } + + void _handleLocationChange(String allocationUuid, String? newSubspaceUuid) { + setState(() { + ProductAllocation? allocationToMove; + + var index = + _space.productAllocations.indexWhere((pa) => pa.uuid == allocationUuid); + if (index != -1) { + allocationToMove = _space.productAllocations.removeAt(index); + } else { + for (final subspace in _space.subspaces) { + index = subspace.productAllocations + .indexWhere((pa) => pa.uuid == allocationUuid); + if (index != -1) { + allocationToMove = subspace.productAllocations.removeAt(index); + break; + } + } + } + + if (allocationToMove == null) return; + + if (newSubspaceUuid == null) { + _space.productAllocations.add(allocationToMove); + } else { + _space.subspaces + .firstWhere((s) => s.uuid == newSubspaceUuid) + .productAllocations + .add(allocationToMove); + } + }); + } + + void _handleProductDelete(String allocationUuid) { + setState(() { + _space.productAllocations.removeWhere((pa) => pa.uuid == allocationUuid); + + for (final subspace in _space.subspaces) { + subspace.productAllocations.removeWhere( + (pa) => pa.uuid == allocationUuid, + ); + } + }); + _validateAllTags(); + } + + @override + Widget build(BuildContext context) { + final allProductAllocations = [ + ..._space.productAllocations, + ..._space.subspaces.expand((s) => s.productAllocations), + ]; + + final productLocations = {}; + for (final pa in _space.productAllocations) { + productLocations[pa.uuid] = null; + } + for (final subspace in _space.subspaces) { + for (final pa in subspace.productAllocations) { + productLocations[pa.uuid] = subspace.uuid; + } + } + + final hasErrors = _validationErrors.isNotEmpty; + + return AlertDialog( + title: const SelectableText('Assign Tags'), + content: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: context.screenWidth * 0.6, + minWidth: context.screenWidth * 0.6, + maxHeight: context.screenHeight * 0.8, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: double.infinity, + child: AssignTagsTable( + productAllocations: allProductAllocations, + subspaces: _space.subspaces, + productLocations: productLocations, + onTagSelected: _handleTagChange, + onLocationSelected: _handleLocationChange, + onProductDeleted: _handleProductDelete, + ), + ), + if (hasErrors) + AssignTagsErrorMessages( + errorMessages: _validationErrors.values.toSet().toList(), + ), + ], + ), + ), + actions: [ + SpaceDetailsActionButtons( + onSave: hasErrors ? null : () => Navigator.of(context).pop(_space), + onCancel: () async { + final newProducts = await showDialog>( + context: context, + builder: (context) => const AddDeviceTypeWidget(), + ); + + if (newProducts == null || newProducts.isEmpty) return; + + setState(() { + for (final product in newProducts) { + _space.productAllocations.add( + ProductAllocation( + uuid: const Uuid().v4(), + product: product, + tag: Tag.empty(), + ), + ); + } + }); + _validateAllTags(); + }, + cancelButtonLabel: 'Add New Device', + ) + ], + ); + } +} diff --git a/lib/pages/space_management_v2/modules/tags/presentation/widgets/assign_tags_error_messages.dart b/lib/pages/space_management_v2/modules/tags/presentation/widgets/assign_tags_error_messages.dart new file mode 100644 index 00000000..9b0fd478 --- /dev/null +++ b/lib/pages/space_management_v2/modules/tags/presentation/widgets/assign_tags_error_messages.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:syncrow_web/utils/extension/build_context_x.dart'; + +class AssignTagsErrorMessages extends StatelessWidget { + const AssignTagsErrorMessages({super.key, required this.errorMessages}); + + final List errorMessages; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: errorMessages + .map( + (error) => Text( + '- $error', + style: context.textTheme.bodyMedium?.copyWith( + color: context.theme.colorScheme.error, + ), + ), + ) + .toList(), + ), + ); + } +} diff --git a/lib/pages/space_management_v2/modules/tags/presentation/widgets/assign_tags_table.dart b/lib/pages/space_management_v2/modules/tags/presentation/widgets/assign_tags_table.dart new file mode 100644 index 00000000..6e7e2097 --- /dev/null +++ b/lib/pages/space_management_v2/modules/tags/presentation/widgets/assign_tags_table.dart @@ -0,0 +1,204 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:syncrow_web/common/dialog_dropdown.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/space_details/domain/models/space_details_model.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/tags/data/services/remote_tags_service.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/models/tag.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/tags/presentation/bloc/tags_bloc.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/tags/presentation/widgets/product_tag_field.dart'; +import 'package:syncrow_web/services/api/http_service.dart'; +import 'package:syncrow_web/utils/color_manager.dart'; +import 'package:syncrow_web/utils/extension/build_context_x.dart'; + +class AssignTagsTable extends StatelessWidget { + const AssignTagsTable({ + required this.productAllocations, + required this.subspaces, + required this.productLocations, + required this.onTagSelected, + required this.onLocationSelected, + required this.onProductDeleted, + super.key, + }); + + final List productAllocations; + final List subspaces; + final Map productLocations; + final void Function(String, Tag) onTagSelected; + final void Function(String, String?) onLocationSelected; + final void Function(String) onProductDeleted; + + DataColumn _buildDataColumn(BuildContext context, String label) { + return DataColumn( + label: SelectableText(label, style: context.textTheme.bodyMedium), + ); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (BuildContext context) => TagsBloc( + RemoteTagsService(HTTPService()), + )..add(const LoadTags()), + child: BlocBuilder( + builder: (context, state) { + return switch (state) { + TagsLoading() || TagsInitial() => const Center( + child: CircularProgressIndicator(), + ), + TagsFailure(:final message) => Center( + child: Text(message), + ), + TagsLoaded(:final tags) => ClipRRect( + borderRadius: BorderRadius.circular(20), + child: DataTable( + headingRowColor: WidgetStateProperty.all( + ColorsManager.dataHeaderGrey, + ), + key: ValueKey(productAllocations.length), + border: TableBorder.all( + color: ColorsManager.dataHeaderGrey, + width: 1, + borderRadius: BorderRadius.circular(20), + ), + columns: [ + _buildDataColumn(context, '#'), + _buildDataColumn(context, 'Device'), + _buildDataColumn(context, 'Tag'), + _buildDataColumn(context, 'Location'), + ], + rows: productAllocations.isEmpty + ? [ + DataRow( + cells: [ + DataCell( + Center( + child: SelectableText( + 'No Devices Available', + style: context.textTheme.bodyMedium?.copyWith( + color: ColorsManager.lightGrayColor, + ), + ), + ), + ), + DataCell.empty, + DataCell.empty, + DataCell.empty, + ], + ), + ] + : List.generate(productAllocations.length, (index) { + final productAllocation = productAllocations[index]; + final allocationUuid = productAllocation.uuid; + + final availableTags = tags + .where( + (tag) => + !productAllocations + .where((p) => + p.product.productType == + productAllocation.product.productType) + .map((p) => p.tag.name.toLowerCase()) + .contains(tag.name.toLowerCase()) || + tag.uuid == productAllocation.tag.uuid, + ) + .toList(); + + final currentLocationUuid = + productLocations[allocationUuid]; + final currentLocationName = currentLocationUuid == null + ? 'Main Space' + : subspaces + .firstWhere((s) => s.uuid == currentLocationUuid) + .name; + + return DataRow( + key: ValueKey(allocationUuid), + cells: [ + DataCell(Text((index + 1).toString())), + DataCell( + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + productAllocation.product.name, + overflow: TextOverflow.ellipsis, + )), + const SizedBox(width: 10), + Container( + width: 20, + height: 20, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: ColorsManager.lightGrayColor, + width: 1, + ), + ), + child: IconButton( + icon: const Icon( + Icons.close, + color: ColorsManager.lightGreyColor, + size: 16, + ), + onPressed: () { + onProductDeleted(allocationUuid); + }, + tooltip: 'Delete Tag', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ), + ], + ), + ), + DataCell( + Container( + alignment: Alignment.centerLeft, + width: double.infinity, + child: ProductTagField( + key: ValueKey('dropdown_$allocationUuid'), + productName: productAllocation.product.uuid, + initialValue: productAllocation.tag, + onSelected: (newTag) { + onTagSelected(allocationUuid, newTag); + }, + items: availableTags, + ), + ), + ), + DataCell( + SizedBox( + width: double.infinity, + child: DialogDropdown( + items: [ + 'Main Space', + ...subspaces.map((s) => s.name) + ], + selectedValue: currentLocationName, + onSelected: (newLocationName) { + final newSubspaceUuid = newLocationName == + 'Main Space' + ? null + : subspaces + .firstWhere( + (s) => s.name == newLocationName) + .uuid; + onLocationSelected( + allocationUuid, newSubspaceUuid); + }, + )), + ), + ], + ); + }), + ), + ), + _ => const SizedBox.shrink(), + }; + }, + ), + ); + } +} diff --git a/lib/pages/space_management_v2/modules/tags/presentation/widgets/product_tag_field.dart b/lib/pages/space_management_v2/modules/tags/presentation/widgets/product_tag_field.dart new file mode 100644 index 00000000..8bbf379d --- /dev/null +++ b/lib/pages/space_management_v2/modules/tags/presentation/widgets/product_tag_field.dart @@ -0,0 +1,186 @@ +import 'package:flutter/material.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/tags/domain/models/tag.dart'; +import 'package:syncrow_web/utils/color_manager.dart'; +import 'package:syncrow_web/utils/extension/build_context_x.dart'; + +class ProductTagField extends StatefulWidget { + final List items; + final ValueChanged onSelected; + final Tag? initialValue; + final String productName; + + const ProductTagField({ + super.key, + required this.items, + required this.onSelected, + this.initialValue, + required this.productName, + }); + + @override + State createState() => _ProductTagFieldState(); +} + +class _ProductTagFieldState extends State { + bool _isOpen = false; + OverlayEntry? _overlayEntry; + final TextEditingController _controller = TextEditingController(); + final FocusNode _focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + _controller.text = widget.initialValue?.name ?? ''; + _focusNode.addListener(_handleFocusChange); + } + + @override + void dispose() { + _focusNode.removeListener(_handleFocusChange); + _controller.dispose(); + _focusNode.dispose(); + _overlayEntry?.remove(); + _overlayEntry = null; + super.dispose(); + } + + void _handleFocusChange() { + if (!_focusNode.hasFocus) { + _submit(_controller.text); + } + } + + void _submit(String value) { + final lowerCaseValue = value.toLowerCase(); + final selectedTag = widget.items.firstWhere( + (tag) => tag.name.toLowerCase() == lowerCaseValue, + orElse: () => Tag( + name: value, + uuid: '', + createdAt: '', + updatedAt: '', + ), + ); + widget.onSelected(selectedTag); + _closeDropdown(); + } + + void _toggleDropdown() { + if (_isOpen) { + _closeDropdown(); + } else { + _openDropdown(); + } + } + + void _openDropdown() { + _overlayEntry = _createOverlayEntry(); + Overlay.of(context).insert(_overlayEntry!); + setState(() => _isOpen = true); + } + + void _closeDropdown() { + if (_isOpen) { + _overlayEntry?.remove(); + _overlayEntry = null; + setState(() => _isOpen = false); + } + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + behavior: HitTestBehavior.opaque, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), + decoration: BoxDecoration( + border: Border.all(color: ColorsManager.transparentColor), + borderRadius: BorderRadius.circular(8.0), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: TextFormField( + controller: _controller, + focusNode: _focusNode, + onFieldSubmitted: _submit, + style: context.textTheme.bodyMedium, + decoration: const InputDecoration( + hintText: 'Enter or Select a tag', + border: InputBorder.none, + ), + ), + ), + GestureDetector( + onTap: _toggleDropdown, + child: const Icon(Icons.arrow_drop_down), + ), + ], + ), + ), + ); + } + + OverlayEntry _createOverlayEntry() { + final renderBox = context.findRenderObject()! as RenderBox; + final size = renderBox.size; + final offset = renderBox.localToGlobal(Offset.zero); + + return OverlayEntry( + builder: (context) { + return GestureDetector( + onTap: _closeDropdown, + behavior: HitTestBehavior.translucent, + child: Stack( + children: [ + Positioned( + left: offset.dx, + top: offset.dy + size.height, + width: size.width, + child: Material( + elevation: 4.0, + child: Container( + color: ColorsManager.whiteColors, + constraints: const BoxConstraints(maxHeight: 200.0), + child: ListView.builder( + shrinkWrap: true, + itemCount: widget.items.length, + itemBuilder: (context, index) { + final tag = widget.items[index]; + return Container( + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide( + color: ColorsManager.lightGrayBorderColor, + width: 1.0, + ), + ), + ), + child: ListTile( + title: Text( + tag.name, + style: context.textTheme.bodyMedium?.copyWith( + color: ColorsManager.textPrimaryColor, + ), + ), + onTap: () { + _controller.text = tag.name; + _submit(tag.name); + _closeDropdown(); + }, + ), + ); + }, + ), + ), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/pages/space_management_v2/modules/tags/presentation/widgets/product_type_card.dart b/lib/pages/space_management_v2/modules/tags/presentation/widgets/product_type_card.dart new file mode 100644 index 00000000..c3910aca --- /dev/null +++ b/lib/pages/space_management_v2/modules/tags/presentation/widgets/product_type_card.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/models/product.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/tags/presentation/widgets/product_type_card_counter.dart'; +import 'package:syncrow_web/pages/spaces_management/tag_model/widgets/device_icon_widget.dart'; +import 'package:syncrow_web/utils/color_manager.dart'; +import 'package:syncrow_web/utils/extension/build_context_x.dart'; + +class ProductTypeCard extends StatelessWidget { + const ProductTypeCard({ + required this.product, + required this.count, + required this.onIncrement, + required this.onDecrement, + super.key, + }); + + final Product product; + final int count; + final void Function() onIncrement; + final void Function() onDecrement; + + @override + Widget build(BuildContext context) { + return Card( + elevation: 2, + color: ColorsManager.whiteColors, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.all(4), + child: Column( + spacing: 8, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded(child: DeviceIconWidget(icon: product.icon)), + _buildName(context, product.name), + ProductTypeCardCounter( + onIncrement: onIncrement, + onDecrement: onDecrement, + count: count, + ), + const SizedBox(height: 4), + ], + ), + ), + ); + } + + Widget _buildName(BuildContext context, String name) { + return Expanded( + child: SizedBox( + height: 35, + child: Text( + name, + style: context.textTheme.bodySmall?.copyWith( + color: ColorsManager.blackColor, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ); + } +} diff --git a/lib/pages/space_management_v2/modules/tags/presentation/widgets/product_type_card_counter.dart b/lib/pages/space_management_v2/modules/tags/presentation/widgets/product_type_card_counter.dart new file mode 100644 index 00000000..605fde2f --- /dev/null +++ b/lib/pages/space_management_v2/modules/tags/presentation/widgets/product_type_card_counter.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:syncrow_web/utils/color_manager.dart'; +import 'package:syncrow_web/utils/extension/build_context_x.dart'; + +class ProductTypeCardCounter extends StatelessWidget { + const ProductTypeCardCounter({ + super.key, + required this.onIncrement, + required this.onDecrement, + required this.count, + }); + + final int count; + final void Function() onIncrement; + final void Function() onDecrement; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: ColorsManager.counterBackgroundColor, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + _buildCounterButton( + Icons.remove, + onDecrement, + ), + Text( + count.toString(), + style: context.textTheme.bodyLarge?.copyWith( + color: ColorsManager.spaceColor, + ), + ), + _buildCounterButton(Icons.add, onIncrement), + ], + ), + ); + } + + Widget _buildCounterButton( + IconData icon, + VoidCallback onPressed, + ) { + return GestureDetector( + onTap: onPressed, + child: Icon( + icon, + color: ColorsManager.spaceColor.withValues(alpha: 0.3), + size: 18, + ), + ); + } +} diff --git a/lib/pages/space_management_v2/modules/tags/presentation/widgets/products_grid.dart b/lib/pages/space_management_v2/modules/tags/presentation/widgets/products_grid.dart new file mode 100644 index 00000000..53e59bde --- /dev/null +++ b/lib/pages/space_management_v2/modules/tags/presentation/widgets/products_grid.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/models/product.dart'; +import 'package:syncrow_web/pages/space_management_v2/modules/tags/presentation/widgets/product_type_card.dart'; +import 'package:syncrow_web/utils/color_manager.dart'; +import 'package:syncrow_web/utils/extension/build_context_x.dart'; + +class ProductsGrid extends StatelessWidget { + const ProductsGrid({ + required this.products, + required this.selectedProducts, + required this.onIncrement, + required this.onDecrement, + super.key, + }); + + final List products; + final Map selectedProducts; + final void Function(Product) onIncrement; + final void Function(Product) onDecrement; + + @override + Widget build(BuildContext context) { + final size = MediaQuery.of(context).size; + final crossAxisCount = switch (context.screenWidth) { + > 1200 => 8, + > 800 => 5, + _ => 3, + }; + return SingleChildScrollView( + child: Container( + width: size.width * 0.9, + height: size.height * 0.65, + decoration: BoxDecoration( + color: ColorsManager.textFieldGreyColor, + borderRadius: BorderRadius.circular(8), + ), + child: GridView.builder( + padding: const EdgeInsets.symmetric( + horizontal: 20, + ), + shrinkWrap: true, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + mainAxisSpacing: 6, + crossAxisSpacing: 4, + childAspectRatio: 0.8, + ), + itemCount: products.length, + itemBuilder: (context, index) { + final product = products[index]; + return ProductTypeCard( + product: product, + count: selectedProducts[product] ?? 0, + onIncrement: () => onIncrement(product), + onDecrement: () => onDecrement(product), + ); + }, + ), + ), + ); + } +} 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 index 21a72557..15a22fda 100644 --- 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 @@ -13,6 +13,7 @@ class SpaceDetailsModelBloc extends Bloc(_onUpdateSpaceDetailsSubspaces); on( _onUpdateSpaceDetailsProductAllocations); + on(_onUpdateSpaceDetails); } void _onUpdateSpaceDetailsIcon( @@ -42,4 +43,11 @@ class SpaceDetailsModelBloc extends Bloc emit, + ) { + emit(event.space); + } } 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 index d3e04bb9..abf9cd98 100644 --- 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 @@ -42,3 +42,12 @@ final class UpdateSpaceDetailsProductAllocations extends SpaceDetailsModelEvent @override List get props => [productAllocations]; } + +final class UpdateSpaceDetails extends SpaceDetailsModelEvent { + const UpdateSpaceDetails(this.space); + + final SpaceDetailsModel space; + + @override + List get props => [space]; +}