Compare commits

...

5 Commits

9 changed files with 232 additions and 103 deletions

View File

@ -68,4 +68,20 @@ abstract final class SpacesRecursiveHelper {
return space;
}).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,
builder: (_) => DuplicateSpaceDialog(
initialName: space.spaceName,
selectedSpaceUuid: space.uuid,
selectedCommunityUuid: selectedCommunity.uuid,
selectedSpace: space,
selectedCommunity: selectedCommunity,
onSuccess: (spaces) {
final updatedCommunity = selectedCommunity.copyWith(
spaces: spaces,

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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/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';
@ -12,15 +13,15 @@ class DuplicateSpaceDialog extends StatelessWidget {
const DuplicateSpaceDialog({
required this.initialName,
required this.onSuccess,
required this.selectedSpaceUuid,
required this.selectedCommunityUuid,
required this.selectedSpace,
required this.selectedCommunity,
super.key,
});
final String initialName;
final void Function(List<SpaceModel> spaces) onSuccess;
final String selectedSpaceUuid;
final String selectedCommunityUuid;
final SpaceModel selectedSpace;
final CommunityModel selectedCommunity;
@override
Widget build(BuildContext context) {
@ -31,10 +32,10 @@ class DuplicateSpaceDialog extends StatelessWidget {
child: BlocListener<DuplicateSpaceBloc, DuplicateSpaceState>(
listener: _listener,
child: DuplicateSpaceDialogForm(
initialNameSuffix: '(1)',
initialName: initialName,
selectedSpaceUuid: selectedSpaceUuid,
selectedCommunityUuid: selectedCommunityUuid,
initialName: _getInitialName(),
selectedSpaceUuid: selectedSpace.uuid,
selectedCommunityUuid: selectedCommunity.uuid,
siblingNames: _getSiblingNames(),
),
),
);
@ -67,4 +68,69 @@ class DuplicateSpaceDialog extends StatelessWidget {
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

@ -11,14 +11,14 @@ class DuplicateSpaceDialogForm extends StatefulWidget {
required this.initialName,
required this.selectedSpaceUuid,
required this.selectedCommunityUuid,
required this.initialNameSuffix,
required this.siblingNames,
super.key,
});
final String initialName;
final String selectedSpaceUuid;
final String selectedCommunityUuid;
final String initialNameSuffix;
final List<String> siblingNames;
@override
State<DuplicateSpaceDialogForm> createState() => _DuplicateSpaceDialogFormState();
@ -26,20 +26,33 @@ class DuplicateSpaceDialogForm extends StatefulWidget {
class _DuplicateSpaceDialogFormState extends State<DuplicateSpaceDialogForm> {
late final TextEditingController _nameController;
bool _isNameValid = true;
String? _errorText;
@override
void initState() {
super.initState();
_nameController = TextEditingController(
text: '${widget.initialName}${widget.initialNameSuffix}',
text: widget.initialName,
);
_nameController.addListener(_validateName);
}
void _validateName() => setState(
() => _isNameValid = _nameController.text.trim() != widget.initialName,
);
void _validateName() {
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
void dispose() {
@ -66,15 +79,14 @@ class _DuplicateSpaceDialogFormState extends State<DuplicateSpaceDialogForm> {
const SelectableText('Enter a new name for the duplicated space:'),
DuplicateSpaceTextField(
nameController: _nameController,
isNameValid: _isNameValid,
initialName: widget.initialName,
errorText: _errorText,
),
],
),
actions: [
SpaceDetailsActionButtons(
spacerFlex: 2,
onSave: _isNameValid ? () => _submit(context) : null,
onSave: _errorText == null ? () => _submit(context) : null,
onCancel: Navigator.of(context).pop,
saveButtonLabel: 'Duplicate',
),

View File

@ -5,16 +5,12 @@ import 'package:syncrow_web/utils/extension/build_context_x.dart';
class DuplicateSpaceTextField extends StatelessWidget {
const DuplicateSpaceTextField({
required this.nameController,
required this.isNameValid,
required this.initialName,
required this.errorText,
super.key,
});
final TextEditingController nameController;
final bool isNameValid;
final String initialName;
String get _errorText => 'Name must be different from "$initialName"';
final String? errorText;
@override
Widget build(BuildContext context) {
@ -35,7 +31,7 @@ class DuplicateSpaceTextField extends StatelessWidget {
color: context.theme.colorScheme.error,
fontSize: 8,
),
errorText: isNameValid ? null : _errorText,
errorText: errorText,
),
);
}

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/extension/build_context_x.dart';
class SpaceDetailsForm extends StatelessWidget {
class SpaceDetailsForm extends StatefulWidget {
const SpaceDetailsForm({
required this.title,
required this.space,
@ -22,24 +22,52 @@ class SpaceDetailsForm extends StatelessWidget {
final SpaceDetailsModel space;
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
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => SpaceDetailsModelBloc(initialState: space),
create: (context) => SpaceDetailsModelBloc(initialState: widget.space),
child: BlocBuilder<SpaceDetailsModelBloc, SpaceDetailsModel>(
buildWhen: (previous, current) => previous != current,
builder: (context, space) {
return AlertDialog(
title: DefaultTextStyle(
style: context.textTheme.titleLarge!.copyWith(
fontSize: 30,
fontWeight: FontWeight.w400,
color: ColorsManager.blackColor,
),
child: title,
buildWhen: (previous, current) => previous != current,
builder: (context, space) {
return AlertDialog(
title: DefaultTextStyle(
style: context.textTheme.titleLarge!.copyWith(
fontSize: 30,
fontWeight: FontWeight.w400,
color: ColorsManager.blackColor,
),
backgroundColor: ColorsManager.white,
content: SizedBox(
child: widget.title,
),
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,
width: context.screenWidth * 0.4,
child: Row(
@ -70,14 +98,16 @@ class SpaceDetailsForm extends StatelessWidget {
],
),
),
actions: [
SpaceDetailsActionButtons(
onSave: () => onSave(space),
onCancel: Navigator.of(context).pop,
),
],
);
}),
),
actions: [
SpaceDetailsActionButtons(
onSave: _isFormValid ? () => widget.onSave(space) : null,
onCancel: Navigator.of(context).pop,
),
],
);
},
),
);
}
}

View File

@ -33,12 +33,13 @@ class _SpaceNameTextFieldState extends State<SpaceNameTextField> {
super.dispose();
}
final _formKey = GlobalKey<FormState>();
String? _validateName(String? value) {
if (value == null || value.isEmpty) {
return '*Space name should not be empty.';
}
if (value.length > 50) {
return '*Space name cannot be longer than 50 characters.';
}
if (widget.isNameFieldExist(value)) {
return '*Name already exists';
}
@ -47,30 +48,26 @@ class _SpaceNameTextFieldState extends State<SpaceNameTextField> {
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: TextFormField(
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,
return TextFormField(
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,
),
),
);

View File

@ -89,28 +89,35 @@ class _SpaceSubSpacesDialogState extends State<SpaceSubSpacesDialog> {
),
child: const SelectableText('Create Sub-Space'),
),
content: Column(
spacing: 12,
mainAxisSize: MainAxisSize.min,
children: [
SubSpacesInput(
subSpaces: _subspaces,
onSubspaceAdded: _handleSubspaceAdded,
onSubspaceDeleted: _handleSubspaceDeleted,
controller: _subspaceNameController,
),
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),
content: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: context.screenHeight * 0.4,
),
child: SingleChildScrollView(
child: Column(
spacing: 12,
mainAxisSize: MainAxisSize.min,
children: [
SubSpacesInput(
subSpaces: _subspaces,
onSubspaceAdded: _handleSubspaceAdded,
onSubspaceDeleted: _handleSubspaceDeleted,
controller: _subspaceNameController,
),
),
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: [
SpaceDetailsActionButtons(

View File

@ -182,15 +182,20 @@ class _AssignTagsDialogState extends State<AssignTagsDialog> {
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,
ConstrainedBox(
constraints: BoxConstraints(
minWidth: double.infinity,
maxHeight: context.screenHeight * 0.6,
),
child: SingleChildScrollView(
child: AssignTagsTable(
productAllocations: allProductAllocations,
subspaces: _space.subspaces,
productLocations: productLocations,
onTagSelected: _handleTagChange,
onLocationSelected: _handleLocationChange,
onProductDeleted: _handleProductDelete,
),
),
),
if (hasErrors)