Compare commits

...

6 Commits

9 changed files with 254 additions and 111 deletions

View File

@ -68,4 +68,20 @@ abstract final class SpacesRecursiveHelper {
return space; return space;
}).toList(); }).toList();
} }
static SpaceModel? findParent(
List<SpaceModel> spaces,
String targetUuid,
) {
for (final space in spaces) {
if (space.children.any((child) => child.uuid == targetUuid)) {
return space;
}
final parent = findParent(space.children, targetUuid);
if (parent != null) {
return parent;
}
}
return null;
}
} }

View File

@ -49,8 +49,8 @@ class CommunityStructureHeaderActionButtonsComposer extends StatelessWidget {
context: context, context: context,
builder: (_) => DuplicateSpaceDialog( builder: (_) => DuplicateSpaceDialog(
initialName: space.spaceName, initialName: space.spaceName,
selectedSpaceUuid: space.uuid, selectedSpace: space,
selectedCommunityUuid: selectedCommunity.uuid, selectedCommunity: selectedCommunity,
onSuccess: (spaces) { onSuccess: (spaces) {
final updatedCommunity = selectedCommunity.copyWith( final updatedCommunity = selectedCommunity.copyWith(
spaces: spaces, spaces: spaces,

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/common/widgets/app_loading_indicator.dart'; import 'package:syncrow_web/common/widgets/app_loading_indicator.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/models/community_model.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/models/space_model.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/models/space_model.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/duplicate_space/data/services/remote_duplicate_space_service.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/duplicate_space/data/services/remote_duplicate_space_service.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/duplicate_space/presentation/bloc/duplicate_space_bloc.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/duplicate_space/presentation/bloc/duplicate_space_bloc.dart';
@ -12,15 +13,15 @@ class DuplicateSpaceDialog extends StatelessWidget {
const DuplicateSpaceDialog({ const DuplicateSpaceDialog({
required this.initialName, required this.initialName,
required this.onSuccess, required this.onSuccess,
required this.selectedSpaceUuid, required this.selectedSpace,
required this.selectedCommunityUuid, required this.selectedCommunity,
super.key, super.key,
}); });
final String initialName; final String initialName;
final void Function(List<SpaceModel> spaces) onSuccess; final void Function(List<SpaceModel> spaces) onSuccess;
final String selectedSpaceUuid; final SpaceModel selectedSpace;
final String selectedCommunityUuid; final CommunityModel selectedCommunity;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -31,9 +32,10 @@ class DuplicateSpaceDialog extends StatelessWidget {
child: BlocListener<DuplicateSpaceBloc, DuplicateSpaceState>( child: BlocListener<DuplicateSpaceBloc, DuplicateSpaceState>(
listener: _listener, listener: _listener,
child: DuplicateSpaceDialogForm( child: DuplicateSpaceDialogForm(
initialName: initialName, initialName: _getInitialName(),
selectedSpaceUuid: selectedSpaceUuid, selectedSpaceUuid: selectedSpace.uuid,
selectedCommunityUuid: selectedCommunityUuid, selectedCommunityUuid: selectedCommunity.uuid,
siblingNames: _getSiblingNames(),
), ),
), ),
); );
@ -66,4 +68,69 @@ class DuplicateSpaceDialog extends StatelessWidget {
break; break;
} }
} }
List<SpaceModel> _findSiblings(
List<SpaceModel> spaces,
String targetUuid,
) {
final parent = _findParent(spaces, targetUuid);
if (parent != null) {
return parent.children.where((s) => s.uuid != targetUuid).toList();
} else {
if (spaces.any((s) => s.uuid == targetUuid)) {
return spaces.where((s) => s.uuid != targetUuid).toList();
}
}
return [];
}
SpaceModel? _findParent(List<SpaceModel> allSpaces, String childUuid) {
for (final space in allSpaces) {
if (space.children.any((child) => child.uuid == childUuid)) {
return space;
}
final parent = _findParent(space.children, childUuid);
if (parent != null) {
return parent;
}
}
return null;
}
List<String> _getSiblingNames() {
final siblings = _findSiblings(selectedCommunity.spaces, selectedSpace.uuid);
final names = siblings.map((s) => s.spaceName).toList();
names.add(initialName);
return names;
}
String _getInitialName() {
final allRelevantNames = _getSiblingNames();
final nameRegex = RegExp(r'^(.*?) ?\((\d+)\)$');
final baseNameMatch = nameRegex.firstMatch(initialName);
final baseName = baseNameMatch?.group(1)?.trim() ?? initialName;
var maxSuffix = 0;
for (final name in allRelevantNames) {
if (name == baseName) {
continue;
}
final match = nameRegex.firstMatch(name);
if (match != null) {
final currentBaseName = match.group(1)!.trim();
if (currentBaseName == baseName) {
final suffix = int.parse(match.group(2)!);
if (suffix > maxSuffix) {
maxSuffix = suffix;
}
}
}
}
return '$baseName(${maxSuffix + 1})';
}
} }

View File

@ -3,18 +3,22 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/duplicate_space/domain/params/duplicate_space_param.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/duplicate_space/domain/params/duplicate_space_param.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/duplicate_space/presentation/bloc/duplicate_space_bloc.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/duplicate_space/presentation/bloc/duplicate_space_bloc.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/duplicate_space/presentation/widgets/duplicate_space_text_field.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/duplicate_space/presentation/widgets/duplicate_space_text_field.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/space_details/presentation/widgets/space_details_action_buttons.dart';
import 'package:syncrow_web/utils/color_manager.dart';
class DuplicateSpaceDialogForm extends StatefulWidget { class DuplicateSpaceDialogForm extends StatefulWidget {
const DuplicateSpaceDialogForm({ const DuplicateSpaceDialogForm({
required this.initialName, required this.initialName,
required this.selectedSpaceUuid, required this.selectedSpaceUuid,
required this.selectedCommunityUuid, required this.selectedCommunityUuid,
required this.siblingNames,
super.key, super.key,
}); });
final String initialName; final String initialName;
final String selectedSpaceUuid; final String selectedSpaceUuid;
final String selectedCommunityUuid; final String selectedCommunityUuid;
final List<String> siblingNames;
@override @override
State<DuplicateSpaceDialogForm> createState() => _DuplicateSpaceDialogFormState(); State<DuplicateSpaceDialogForm> createState() => _DuplicateSpaceDialogFormState();
@ -22,18 +26,33 @@ class DuplicateSpaceDialogForm extends StatefulWidget {
class _DuplicateSpaceDialogFormState extends State<DuplicateSpaceDialogForm> { class _DuplicateSpaceDialogFormState extends State<DuplicateSpaceDialogForm> {
late final TextEditingController _nameController; late final TextEditingController _nameController;
bool _isNameValid = true; String? _errorText;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_nameController = TextEditingController(text: '${widget.initialName}(1)'); _nameController = TextEditingController(
text: widget.initialName,
);
_nameController.addListener(_validateName); _nameController.addListener(_validateName);
} }
void _validateName() => setState( void _validateName() {
() => _isNameValid = _nameController.text.trim() != widget.initialName, final name = _nameController.text.trim();
); if (name.isEmpty) {
setState(() {
_errorText = 'Name cannot be empty';
});
} else if (widget.siblingNames.contains(name)) {
setState(() {
_errorText = 'Name must be unique';
});
} else {
setState(() {
_errorText = null;
});
}
}
@override @override
void dispose() { void dispose() {
@ -44,27 +63,32 @@ class _DuplicateSpaceDialogFormState extends State<DuplicateSpaceDialogForm> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AlertDialog( return AlertDialog(
title: const SelectableText('Duplicate Space'), title: const SelectableText(
'Duplicate Space',
style: TextStyle(
fontSize: 30,
fontWeight: FontWeight.w400,
color: ColorsManager.blackColor,
),
),
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 16, spacing: 16,
children: [ children: [
const SelectableText('Enter a new name for the duplicated space:'), const SelectableText('Enter a new name for the duplicated space:'),
DuplicateSpaceTextField( DuplicateSpaceTextField(
nameController: _nameController, nameController: _nameController,
isNameValid: _isNameValid, errorText: _errorText,
initialName: widget.initialName,
), ),
], ],
), ),
actions: [ actions: [
TextButton( SpaceDetailsActionButtons(
onPressed: Navigator.of(context).pop, spacerFlex: 2,
child: const Text('Cancel'), onSave: _errorText == null ? () => _submit(context) : null,
), onCancel: Navigator.of(context).pop,
TextButton( saveButtonLabel: 'Duplicate',
onPressed: _isNameValid ? () => _submit(context) : null,
child: const Text('Duplicate'),
), ),
], ],
); );

View File

@ -5,16 +5,12 @@ import 'package:syncrow_web/utils/extension/build_context_x.dart';
class DuplicateSpaceTextField extends StatelessWidget { class DuplicateSpaceTextField extends StatelessWidget {
const DuplicateSpaceTextField({ const DuplicateSpaceTextField({
required this.nameController, required this.nameController,
required this.isNameValid, required this.errorText,
required this.initialName,
super.key, super.key,
}); });
final TextEditingController nameController; final TextEditingController nameController;
final bool isNameValid; final String? errorText;
final String initialName;
String get _errorText => 'Name must be different from "$initialName"';
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -24,9 +20,10 @@ class DuplicateSpaceTextField extends StatelessWidget {
color: ColorsManager.blackColor, color: ColorsManager.blackColor,
), ),
decoration: InputDecoration( decoration: InputDecoration(
label: const Text('Space Name'), filled: true,
border: _border(), fillColor: ColorsManager.boxColor,
enabledBorder: _border(), border: _border(ColorsManager.transparentColor),
enabledBorder: _border(ColorsManager.transparentColor),
focusedBorder: _border(ColorsManager.primaryColor), focusedBorder: _border(ColorsManager.primaryColor),
errorBorder: _border(context.theme.colorScheme.error), errorBorder: _border(context.theme.colorScheme.error),
focusedErrorBorder: _border(context.theme.colorScheme.error), focusedErrorBorder: _border(context.theme.colorScheme.error),
@ -34,14 +31,14 @@ class DuplicateSpaceTextField extends StatelessWidget {
color: context.theme.colorScheme.error, color: context.theme.colorScheme.error,
fontSize: 8, fontSize: 8,
), ),
errorText: isNameValid ? null : _errorText, errorText: errorText,
), ),
); );
} }
OutlineInputBorder _border([Color? color]) { OutlineInputBorder _border([Color? color]) {
return OutlineInputBorder( return OutlineInputBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(8),
borderSide: BorderSide( borderSide: BorderSide(
color: color ?? ColorsManager.blackColor, color: color ?? ColorsManager.blackColor,
width: 0.5, width: 0.5,

View File

@ -10,7 +10,7 @@ import 'package:syncrow_web/pages/space_management_v2/modules/update_space/prese
import 'package:syncrow_web/utils/color_manager.dart'; import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart'; import 'package:syncrow_web/utils/extension/build_context_x.dart';
class SpaceDetailsForm extends StatelessWidget { class SpaceDetailsForm extends StatefulWidget {
const SpaceDetailsForm({ const SpaceDetailsForm({
required this.title, required this.title,
required this.space, required this.space,
@ -22,24 +22,52 @@ class SpaceDetailsForm extends StatelessWidget {
final SpaceDetailsModel space; final SpaceDetailsModel space;
final void Function(SpaceDetailsModel space) onSave; final void Function(SpaceDetailsModel space) onSave;
@override
State<SpaceDetailsForm> createState() => _SpaceDetailsFormState();
}
class _SpaceDetailsFormState extends State<SpaceDetailsForm> {
final _formKey = GlobalKey<FormState>();
bool _isFormValid = false;
@override
void initState() {
super.initState();
final initialName = widget.space.spaceName;
_isFormValid = initialName.isNotEmpty &&
initialName.length <= 50 &&
!widget.space.subspaces.any((subspace) => subspace.name == initialName);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider( return BlocProvider(
create: (context) => SpaceDetailsModelBloc(initialState: space), create: (context) => SpaceDetailsModelBloc(initialState: widget.space),
child: BlocBuilder<SpaceDetailsModelBloc, SpaceDetailsModel>( child: BlocBuilder<SpaceDetailsModelBloc, SpaceDetailsModel>(
buildWhen: (previous, current) => previous != current, buildWhen: (previous, current) => previous != current,
builder: (context, space) { builder: (context, space) {
return AlertDialog( return AlertDialog(
title: DefaultTextStyle( title: DefaultTextStyle(
style: context.textTheme.titleLarge!.copyWith( style: context.textTheme.titleLarge!.copyWith(
fontSize: 30, fontSize: 30,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
color: ColorsManager.blackColor, color: ColorsManager.blackColor,
),
child: title,
), ),
backgroundColor: ColorsManager.white, child: widget.title,
content: SizedBox( ),
backgroundColor: ColorsManager.white,
content: Form(
key: _formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
onChanged: () {
final isValid = _formKey.currentState?.validate() ?? false;
if (_isFormValid != isValid) {
setState(() {
_isFormValid = isValid;
});
}
},
child: SizedBox(
height: context.screenHeight * 0.3, height: context.screenHeight * 0.3,
width: context.screenWidth * 0.4, width: context.screenWidth * 0.4,
child: Row( child: Row(
@ -70,14 +98,16 @@ class SpaceDetailsForm extends StatelessWidget {
], ],
), ),
), ),
actions: [ ),
SpaceDetailsActionButtons( actions: [
onSave: () => onSave(space), SpaceDetailsActionButtons(
onCancel: Navigator.of(context).pop, onSave: _isFormValid ? () => widget.onSave(space) : null,
), onCancel: Navigator.of(context).pop,
], ),
); ],
}), );
},
),
); );
} }
} }

View File

@ -33,12 +33,13 @@ class _SpaceNameTextFieldState extends State<SpaceNameTextField> {
super.dispose(); super.dispose();
} }
final _formKey = GlobalKey<FormState>();
String? _validateName(String? value) { String? _validateName(String? value) {
if (value == null || value.isEmpty) { if (value == null || value.isEmpty) {
return '*Space name should not be empty.'; return '*Space name should not be empty.';
} }
if (value.length > 50) {
return '*Space name cannot be longer than 50 characters.';
}
if (widget.isNameFieldExist(value)) { if (widget.isNameFieldExist(value)) {
return '*Name already exists'; return '*Name already exists';
} }
@ -47,30 +48,26 @@ class _SpaceNameTextFieldState extends State<SpaceNameTextField> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Form( return TextFormField(
key: _formKey, controller: _controller,
autovalidateMode: AutovalidateMode.onUserInteraction, onChanged: (value) => context.read<SpaceDetailsModelBloc>().add(
child: TextFormField( UpdateSpaceDetailsName(value),
controller: _controller,
onChanged: (value) => context.read<SpaceDetailsModelBloc>().add(
UpdateSpaceDetailsName(value),
),
validator: _validateName,
style: context.textTheme.bodyMedium,
decoration: InputDecoration(
hintText: 'Please enter the name',
hintStyle: context.textTheme.bodyMedium!.copyWith(
color: ColorsManager.lightGrayColor,
),
filled: true,
fillColor: ColorsManager.boxColor,
enabledBorder: _buildBorder(context, ColorsManager.vividBlue),
focusedBorder: _buildBorder(context, ColorsManager.primaryColor),
errorBorder: _buildBorder(context, context.theme.colorScheme.error),
focusedErrorBorder: _buildBorder(context, context.theme.colorScheme.error),
errorStyle: context.textTheme.bodySmall?.copyWith(
color: context.theme.colorScheme.error,
), ),
validator: _validateName,
style: context.textTheme.bodyMedium,
decoration: InputDecoration(
hintText: 'Please enter the name',
hintStyle: context.textTheme.bodyMedium!.copyWith(
color: ColorsManager.lightGrayColor,
),
filled: true,
fillColor: ColorsManager.boxColor,
enabledBorder: _buildBorder(context, ColorsManager.vividBlue),
focusedBorder: _buildBorder(context, ColorsManager.primaryColor),
errorBorder: _buildBorder(context, context.theme.colorScheme.error),
focusedErrorBorder: _buildBorder(context, context.theme.colorScheme.error),
errorStyle: context.textTheme.bodySmall?.copyWith(
color: context.theme.colorScheme.error,
), ),
), ),
); );

View File

@ -89,28 +89,35 @@ class _SpaceSubSpacesDialogState extends State<SpaceSubSpacesDialog> {
), ),
child: const SelectableText('Create Sub-Space'), child: const SelectableText('Create Sub-Space'),
), ),
content: Column( content: ConstrainedBox(
spacing: 12, constraints: BoxConstraints(
mainAxisSize: MainAxisSize.min, maxHeight: context.screenHeight * 0.4,
children: [ ),
SubSpacesInput( child: SingleChildScrollView(
subSpaces: _subspaces, child: Column(
onSubspaceAdded: _handleSubspaceAdded, spacing: 12,
onSubspaceDeleted: _handleSubspaceDeleted, mainAxisSize: MainAxisSize.min,
controller: _subspaceNameController, children: [
), SubSpacesInput(
AnimatedSwitcher( subSpaces: _subspaces,
duration: const Duration(milliseconds: 100), onSubspaceAdded: _handleSubspaceAdded,
child: Visibility( onSubspaceDeleted: _handleSubspaceDeleted,
key: ValueKey(_hasDuplicateNames), controller: _subspaceNameController,
visible: _hasDuplicateNames,
child: const SelectableText(
'Error: Duplicate subspace names are not allowed.',
style: TextStyle(color: Colors.red),
), ),
), AnimatedSwitcher(
duration: const Duration(milliseconds: 100),
child: Visibility(
key: ValueKey(_hasDuplicateNames),
visible: _hasDuplicateNames,
child: const SelectableText(
'Error: Duplicate subspace names are not allowed.',
style: TextStyle(color: Colors.red),
),
),
),
],
), ),
], ),
), ),
actions: [ actions: [
SpaceDetailsActionButtons( SpaceDetailsActionButtons(

View File

@ -182,15 +182,20 @@ class _AssignTagsDialogState extends State<AssignTagsDialog> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
SizedBox( ConstrainedBox(
width: double.infinity, constraints: BoxConstraints(
child: AssignTagsTable( minWidth: double.infinity,
productAllocations: allProductAllocations, maxHeight: context.screenHeight * 0.6,
subspaces: _space.subspaces, ),
productLocations: productLocations, child: SingleChildScrollView(
onTagSelected: _handleTagChange, child: AssignTagsTable(
onLocationSelected: _handleLocationChange, productAllocations: allProductAllocations,
onProductDeleted: _handleProductDelete, subspaces: _space.subspaces,
productLocations: productLocations,
onTagSelected: _handleTagChange,
onLocationSelected: _handleLocationChange,
onProductDeleted: _handleProductDelete,
),
), ),
), ),
if (hasErrors) if (hasErrors)