Sp 1720 fe draw assign tags to space dialog (#341)

<!--
  Thanks for contributing!

Provide a description of your changes below and a general summary in the
title

Please look at the following checklist to ensure that your PR can be
accepted quickly:
-->

## Jira Ticket
[SP-1720](https://syncrow.atlassian.net/browse/SP-1720)

## Description

Implemented products and assign tags functionality.

## Type of Change

<!--- Put an `x` in all the boxes that apply: -->

- [x]  New feature (non-breaking change which adds functionality)
- [ ] 🛠️ Bug fix (non-breaking change which fixes an issue)
- [ ]  Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] 🧹 Code refactor
- [ ]  Build configuration change
- [ ] 📝 Documentation
- [ ] 🗑️ Chore 


[SP-1720]:
https://syncrow.atlassian.net/browse/SP-1720?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
This commit is contained in:
Faris Armoush
2025-07-08 10:01:10 +03:00
committed by GitHub
28 changed files with 1147 additions and 119 deletions

View File

@ -7,6 +7,8 @@ import 'package:syncrow_web/pages/space_management_v2/modules/communities/data/s
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/params/load_communities_param.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/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(

View File

@ -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<List<Product>> getProducts(LoadProductsParam param) async {
Future<List<Product>> 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<String, dynamic>;
final products = json['data'] as List<dynamic>;
return products
.map((e) => Product.fromJson(e as Map<String, dynamic>))
.toList();
},

View File

@ -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<String, dynamic> 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<Object?> get props => [uuid, name];
List<Object?> get props => [uuid, name, productType];
}

View File

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

View File

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

View File

@ -1,7 +1,6 @@
import 'package:bloc/bloc.dart';
import 'package: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<ProductsEvent, ProductsState> {
final ProductsService _deviceService;
ProductsBloc(this._deviceService) : super(ProductsInitial()) {
ProductsBloc(this._productsService) : super(ProductsInitial()) {
on<LoadProducts>(_onLoadProducts);
}
final ProductsService _productsService;
Future<void> _onLoadProducts(
LoadProducts event,
Emitter<ProductsState> 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) {

View File

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

View File

@ -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<Object> get props => [message];
List<Object> get props => [errorMessage];
}

View File

@ -2,6 +2,7 @@ import 'package:equatable/equatable.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/products/domain/models/product.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/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: [],
);
@ -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<String, dynamic> json) {
return ProductAllocation(
uuid: json['uuid'] as String? ?? const Uuid().v4(),
product: Product.fromJson(json['product'] as Map<String, dynamic>),
tag: Tag.fromJson(json['tag'] as Map<String, dynamic>),
);
@ -87,23 +91,26 @@ class ProductAllocation extends Equatable {
Map<String, dynamic> toJson() {
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<Object?> get props => [product, tag];
List<Object?> get props => [uuid, product, tag];
}
class Subspace extends Equatable {

View File

@ -18,7 +18,7 @@ abstract final class SpaceDetailsDialogHelper {
context: context,
title: const SelectableText('Create Space'),
spaceModel: SpaceModel.empty(),
onSave: print,
onSave: (space) {},
),
),
);

View File

@ -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),
);
}
}

View File

@ -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 = <String, int>{};
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<SpaceDetailsModel>(
context: context,
builder: (context) => AssignTagsDialog(space: space),
).then((resultSpace) {
if (resultSpace != null) {
if (context.mounted) {
context.read<SpaceDetailsModelBloc>().add(UpdateSpaceDetails(resultSpace));
}
}
});
}
String _getDeviceIcon(String productType) =>
switch (devicesTypesMap[productType]) {
DeviceType.LightBulb => Assets.lightBulb,
DeviceType.CeilingSensor => Assets.sensors,
DeviceType.AC => Assets.ac,
DeviceType.DoorLock => Assets.doorLock,
DeviceType.Curtain => Assets.curtain,
DeviceType.ThreeGang => Assets.gangSwitch,
DeviceType.Gateway => Assets.gateway,
DeviceType.OneGang => Assets.oneGang,
DeviceType.TwoGang => Assets.twoGang,
DeviceType.WH => Assets.waterHeater,
DeviceType.DoorSensor => Assets.openCloseDoor,
DeviceType.GarageDoor => Assets.openedDoor,
DeviceType.WaterLeak => Assets.waterLeakNormal,
DeviceType.Curtain2 => Assets.curtainIcon,
DeviceType.Blind => Assets.curtainIcon,
DeviceType.WallSensor => Assets.sensors,
DeviceType.DS => Assets.openCloseDoor,
DeviceType.OneTouch => Assets.gangSwitch,
DeviceType.TowTouch => Assets.gangSwitch,
DeviceType.ThreeTouch => Assets.gangSwitch,
DeviceType.NCPS => Assets.sensors,
DeviceType.PC => Assets.powerClamp,
DeviceType.Other => Assets.blackLogo,
null => Assets.blackLogo,
};
}

View File

@ -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,
),

View File

@ -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<List<Tag>> loadTags(LoadTagsParam param) async {
if (param.projectUuid == null) {
throw Exception('Project UUID is required');
}
Future<List<Tag>> 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<String, dynamic>;
final data = result['data'] as List<dynamic>;
@ -46,4 +38,12 @@ final class RemoteTagsService implements TagsService {
throw APIException(formattedErrorMessage);
}
}
Future<String> _makeUrl() async {
final projectUuid = await ProjectManager.getProjectUUID();
if (projectUuid == null || projectUuid.isEmpty) {
throw APIException('Project UUID is required');
}
return '/projects/$projectUuid/tags';
}
}

View File

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

View File

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

View File

@ -1,7 +1,6 @@
import 'package:bloc/bloc.dart';
import 'package: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<TagsEvent, TagsState> {
) 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));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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