diff --git a/frontend/pweb/lib/controllers/auth/password_form.dart b/frontend/pweb/lib/controllers/auth/password_form.dart index d456c741..867f586d 100644 --- a/frontend/pweb/lib/controllers/auth/password_form.dart +++ b/frontend/pweb/lib/controllers/auth/password_form.dart @@ -3,8 +3,8 @@ import 'package:flutter/material.dart'; import 'package:pshared/provider/account.dart'; import 'package:pshared/api/responses/error/server.dart'; -import 'package:pweb/models/state/edit_state.dart'; -import 'package:pweb/models/auth/password_field_type.dart'; +import 'package:pweb/models/state/controller_lifecycle.dart'; +import 'package:pweb/models/state/control_state.dart'; import 'package:pweb/models/state/visibility.dart'; @@ -14,32 +14,17 @@ class PasswordFormController extends ChangeNotifier { final newPasswordController = TextEditingController(); final confirmPasswordController = TextEditingController(); - final Map _visibility = { - PasswordFieldType.old: VisibilityState.hidden, - PasswordFieldType.newPassword: VisibilityState.hidden, - PasswordFieldType.confirmPassword: VisibilityState.hidden, - }; - EditState _state = EditState.view; + ControlState _formState = ControlState.enabled; String _errorText = ''; - bool _disposed = false; + VisibilityState _oldPasswordVisibility = VisibilityState.hidden; + ControllerLifecycleState _lifecycleState = ControllerLifecycleState.active; - bool get isExpanded => _state != EditState.view; - bool get isSaving => _state == EditState.saving; + bool get isSaving => _formState == ControlState.loading; String get errorText => _errorText; - EditState get state => _state; - bool isPasswordVisible(PasswordFieldType type) => - _visibility[type] == VisibilityState.visible; + VisibilityState get oldPasswordVisibility => _oldPasswordVisibility; - void toggleExpanded() { - if (_state == EditState.saving) return; - _setState(_state == EditState.view ? EditState.edit : EditState.view); - _setError(''); - } - - void togglePasswordVisibility(PasswordFieldType type) { - final current = _visibility[type]; - if (current == null) return; - _visibility[type] = current == VisibilityState.hidden + void toggleOldPasswordVisibility() { + _oldPasswordVisibility = _oldPasswordVisibility == VisibilityState.hidden ? VisibilityState.visible : VisibilityState.hidden; notifyListeners(); @@ -52,7 +37,7 @@ class PasswordFormController extends ChangeNotifier { final currentForm = formKey.currentState; if (currentForm == null || !currentForm.validate()) return false; - _setState(EditState.saving); + _setState(ControlState.loading); _setError(''); try { @@ -64,11 +49,11 @@ class PasswordFormController extends ChangeNotifier { oldPasswordController.clear(); newPasswordController.clear(); confirmPasswordController.clear(); - _setState(EditState.view); + _setState(ControlState.enabled); return true; } catch (e) { _setError(_errorMessageForException(e, errorText)); - _setState(EditState.edit); + _setState(ControlState.enabled); rethrow; } } @@ -80,21 +65,23 @@ class PasswordFormController extends ChangeNotifier { return fallback; } - void _setState(EditState value) { - if (_state == value || _disposed) return; - _state = value; + void _setState(ControlState value) { + if (_formState == value || _isDisposed) return; + _formState = value; notifyListeners(); } void _setError(String value) { - if (_disposed) return; + if (_isDisposed) return; _errorText = value; notifyListeners(); } + bool get _isDisposed => _lifecycleState == ControllerLifecycleState.disposed; + @override void dispose() { - _disposed = true; + _lifecycleState = ControllerLifecycleState.disposed; oldPasswordController.dispose(); newPasswordController.dispose(); confirmPasswordController.dispose(); diff --git a/frontend/pweb/lib/controllers/settings/profile_actions.dart b/frontend/pweb/lib/controllers/settings/profile_actions.dart new file mode 100644 index 00000000..e728034b --- /dev/null +++ b/frontend/pweb/lib/controllers/settings/profile_actions.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/controllers/auth/account_name.dart'; +import 'package:pweb/models/settings/profile_action_section.dart'; + + +class ProfileActionsController extends ChangeNotifier { + AccountNameController? _accountNameController; + ProfileActionSection? _expandedSection; + + ProfileActionSection? get expandedSection => _expandedSection; + bool get isEditingName => _accountNameController?.isEditing ?? false; + + bool isExpanded(ProfileActionSection section) => _expandedSection == section; + + void updateAccountNameController(AccountNameController controller) { + if (identical(_accountNameController, controller)) { + return; + } + _accountNameController?.removeListener(_handleAccountNameChanged); + _accountNameController = controller; + _accountNameController?.addListener(_handleAccountNameChanged); + } + + void toggle(ProfileActionSection section) { + final isOpeningSection = !isExpanded(section); + if (isOpeningSection) { + if (_accountNameController?.isBusy ?? false) { + return; + } + _accountNameController?.cancelEditing(); + } + _expandedSection = isExpanded(section) ? null : section; + notifyListeners(); + } + + void toggleNameEditing() { + final accountNameController = _accountNameController; + if (accountNameController == null || accountNameController.isBusy) { + return; + } + + if (accountNameController.isEditing) { + accountNameController.cancelEditing(); + return; + } + + _expandedSection = null; + accountNameController.startEditing(); + notifyListeners(); + } + + void _handleAccountNameChanged() { + notifyListeners(); + } + + @override + void dispose() { + _accountNameController?.removeListener(_handleAccountNameChanged); + super.dispose(); + } +} diff --git a/frontend/pweb/lib/l10n/en.arb b/frontend/pweb/lib/l10n/en.arb index 1537022d..537a0aaf 100644 --- a/frontend/pweb/lib/l10n/en.arb +++ b/frontend/pweb/lib/l10n/en.arb @@ -225,6 +225,7 @@ "settingsSuccessfullyUpdated": "Settings successfully updated", "language": "Language", "failedToUpdateLanguage": "Failed to update language", + "editName": "Edit name", "settingsImageUpdateError": "Couldn't update the image", "settingsImageTitle": "Image", "settingsImageHint": "Tap to change the image", diff --git a/frontend/pweb/lib/l10n/ru.arb b/frontend/pweb/lib/l10n/ru.arb index 3c21f96c..df774034 100644 --- a/frontend/pweb/lib/l10n/ru.arb +++ b/frontend/pweb/lib/l10n/ru.arb @@ -225,6 +225,7 @@ "settingsSuccessfullyUpdated": "Настройки успешно обновлены", "language": "Язык", "failedToUpdateLanguage": "Не удалось обновить язык", + "editName": "Изменить имя", "settingsImageUpdateError": "Не удалось обновить изображение", "settingsImageTitle": "Изображение", "settingsImageHint": "Нажмите, чтобы изменить изображение", diff --git a/frontend/pweb/lib/models/auth/password_field_type.dart b/frontend/pweb/lib/models/auth/password_field_type.dart deleted file mode 100644 index 31146473..00000000 --- a/frontend/pweb/lib/models/auth/password_field_type.dart +++ /dev/null @@ -1 +0,0 @@ -enum PasswordFieldType { old, newPassword, confirmPassword } \ No newline at end of file diff --git a/frontend/pweb/lib/models/settings/profile_action_section.dart b/frontend/pweb/lib/models/settings/profile_action_section.dart new file mode 100644 index 00000000..2f764c32 --- /dev/null +++ b/frontend/pweb/lib/models/settings/profile_action_section.dart @@ -0,0 +1 @@ +enum ProfileActionSection { language, password } diff --git a/frontend/pweb/lib/models/state/controller_lifecycle.dart b/frontend/pweb/lib/models/state/controller_lifecycle.dart new file mode 100644 index 00000000..036c4996 --- /dev/null +++ b/frontend/pweb/lib/models/state/controller_lifecycle.dart @@ -0,0 +1 @@ +enum ControllerLifecycleState { active, disposed } diff --git a/frontend/pweb/lib/pages/loaders/account.dart b/frontend/pweb/lib/pages/loaders/account.dart index 7c752763..de0c3925 100644 --- a/frontend/pweb/lib/pages/loaders/account.dart +++ b/frontend/pweb/lib/pages/loaders/account.dart @@ -14,6 +14,7 @@ import 'package:pweb/models/account/account_loader.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; + class AccountLoader extends StatefulWidget { final Widget child; const AccountLoader({super.key, required this.child}); diff --git a/frontend/pweb/lib/pages/roles/page.dart b/frontend/pweb/lib/pages/roles/page.dart new file mode 100644 index 00000000..54f587b0 --- /dev/null +++ b/frontend/pweb/lib/pages/roles/page.dart @@ -0,0 +1,162 @@ +import 'package:flutter/material.dart'; + +import 'package:go_router/go_router.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/models/permissions/data/policy.dart'; +import 'package:pshared/models/permissions/descriptions/role.dart'; +import 'package:pshared/models/resources.dart'; +import 'package:pshared/provider/permissions.dart'; + +import 'package:pweb/app/router/payout_routes.dart'; +import 'package:pweb/app/router/page_params.dart'; +import 'package:pweb/app/router/pages.dart'; +import 'package:pweb/pages/loader.dart'; +import 'package:pweb/pages/roles/widgets/actions.dart'; +import 'package:pweb/pages/roles/widgets/header.dart'; +import 'package:pweb/pages/roles/widgets/list.dart'; +import 'package:pweb/models/state/visibility.dart'; +import 'package:pweb/utils/roles/is_owner_role.dart'; +import 'package:pweb/widgets/sidebar/destinations.dart'; +import 'package:pweb/widgets/dialogs/confirmation_dialog.dart'; +import 'package:pweb/utils/error/snackbar.dart'; +import 'package:pweb/widgets/roles/create_role_dialog.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class RolesSettingsPage extends StatelessWidget { + const RolesSettingsPage({super.key}); + + Future _createRole(BuildContext context) async { + final loc = AppLocalizations.of(context)!; + final draft = await showCreateRoleDialog( + context, + // title: loc.rolesCreateTitle, + // confirmLabel: loc.rolesCreateAction, + ); + if (draft == null) return; + + final permissions = context.read(); + await executeActionWithNotification( + context: context, + action: () => permissions.createRoleDescription( + name: draft.name, + description: draft.description.isEmpty ? null : draft.description, + ), + successMessage: loc.rolesCreateSuccess, + errorMessage: loc.rolesCreateFailed, + ); + } + + Future _copyRole(BuildContext context, RoleDescription role) async { + final loc = AppLocalizations.of(context)!; + final permissions = context.read(); + final sourcePolicies = permissions.getRolePolicies(role.id); + final draft = await showCreateRoleDialog( + context, + // initialDraft: RoleDraft( + // name: copyName, + // description: role.description ?? '', + // ), + // title: loc.rolesCopyTitle, + // confirmLabel: loc.rolesCopyAction, + ); + if (draft == null) return; + + await executeActionWithNotification( + context: context, + action: () async { + final createdRole = await permissions.createRoleDescription( + name: draft.name, + description: draft.description.isEmpty ? null : draft.description, + ); + if (createdRole == null || sourcePolicies.isEmpty) return createdRole; + final copiedPolicies = sourcePolicies.map((policy) => Policy( + roleDescriptionRef: createdRole.id, + organizationRef: policy.organizationRef, + descriptionRef: policy.descriptionRef, + objectRef: policy.objectRef, + effect: policy.effect, + )).toList(); + await permissions.createPermissions(copiedPolicies); + return createdRole; + }, + successMessage: loc.rolesCopySuccess, + errorMessage: loc.rolesCopyFailed, + ); + } + + Future _deleteRole(BuildContext context, RoleDescription role) async { + final loc = AppLocalizations.of(context)!; + final confirmed = await showConfirmationDialog( + context: context, + title: loc.rolesDeleteConfirmTitle, + message: loc.rolesDeleteConfirmMessage(role.name), + confirmLabel: loc.delete, + ); + if (!confirmed) return; + + final permissions = context.read(); + await executeActionWithNotification( + context: context, + action: () => permissions.deleteRoleDescription(role.id), + successMessage: loc.rolesDeleteSuccess, + errorMessage: loc.rolesDeleteFailed, + ); + } + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + final permissions = context.watch(); + final canCreate = permissions.canCreate(ResourceType.roles); + final canDelete = permissions.canDelete(ResourceType.roles); + final roles = permissions.roleDescriptions; + bool isPermanentRole(RoleDescription role) => isOwnerRole(role, loc); + VisibilityState hiddenIf(bool isHidden) => + isHidden ? VisibilityState.hidden : VisibilityState.visible; + + return PageViewLoader( + child: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RolesHeader( + title: loc.rolesPageTitle, + subtitle: loc.rolesPageSubtitle, + onBack: () => context.goToPayout(PayoutDestination.invitations), + ), + const SizedBox(height: 16), + RolesActions( + canCreate: canCreate, + onCreateRole: () => _createRole(context), + createLabel: loc.rolesCreateAction, + ), + const SizedBox(height: 16), + RolesList( + roles: roles, + canCopy: (role) => hiddenIf(!canCreate || isPermanentRole(role)), + canDelete: (role) => hiddenIf(!canDelete || isPermanentRole(role)), + canManagePolicies: (role) => hiddenIf(isPermanentRole(role)), + emptyLabel: loc.rolesEmpty, + policiesCount: (role) => permissions.getRolePolicies(role.id).length, + onCopy: (role) => _copyRole(context, role), + onDelete: (role) => _deleteRole(context, role), + onManagePolicies: (role) => context.pushNamed( + Pages.permissions.name, + pathParameters: { + PageParams.roleRef.name: role.id, + }, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/roles/widgets/actions.dart b/frontend/pweb/lib/pages/roles/widgets/actions.dart new file mode 100644 index 00000000..e7ab053b --- /dev/null +++ b/frontend/pweb/lib/pages/roles/widgets/actions.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; + + +class RolesActions extends StatelessWidget { + final bool canCreate; + final VoidCallback onCreateRole; + final String createLabel; + + const RolesActions({ + super.key, + required this.canCreate, + required this.onCreateRole, + required this.createLabel, + }); + + @override + Widget build(BuildContext context) { + if (!canCreate) return const SizedBox.shrink(); + return Align( + alignment: Alignment.centerLeft, + child: ElevatedButton.icon( + onPressed: onCreateRole, + icon: const Icon(Icons.add), + label: Text(createLabel), + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/roles/widgets/empty_state.dart b/frontend/pweb/lib/pages/roles/widgets/empty_state.dart new file mode 100644 index 00000000..0588029b --- /dev/null +++ b/frontend/pweb/lib/pages/roles/widgets/empty_state.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; + + +class RolesEmptyState extends StatelessWidget { + final String label; + + const RolesEmptyState({ + super.key, + required this.label, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 32), + child: Text( + label, + style: theme.textTheme.bodyMedium, + ), + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/roles/widgets/header.dart b/frontend/pweb/lib/pages/roles/widgets/header.dart new file mode 100644 index 00000000..4b158e25 --- /dev/null +++ b/frontend/pweb/lib/pages/roles/widgets/header.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class RolesHeader extends StatelessWidget { + final String title; + final String subtitle; + final VoidCallback? onBack; + + const RolesHeader({ + super.key, + required this.title, + required this.subtitle, + this.onBack, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final loc = AppLocalizations.of(context)!; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (onBack != null) ...[ + IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: onBack, + tooltip: loc.back, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 8), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 6), + Text( + subtitle, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/roles/widgets/list.dart b/frontend/pweb/lib/pages/roles/widgets/list.dart new file mode 100644 index 00000000..dbecd006 --- /dev/null +++ b/frontend/pweb/lib/pages/roles/widgets/list.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/permissions/descriptions/role.dart'; + +import 'package:pweb/models/state/visibility.dart'; +import 'package:pweb/pages/roles/widgets/empty_state.dart'; +import 'package:pweb/pages/roles/widgets/role_card.dart'; + + +class RolesList extends StatelessWidget { + final List roles; + final VisibilityState Function(RoleDescription role) canCopy; + final VisibilityState Function(RoleDescription role) canDelete; + final VisibilityState Function(RoleDescription role) canManagePolicies; + final String emptyLabel; + final int Function(RoleDescription role) policiesCount; + final ValueChanged onCopy; + final ValueChanged onDelete; + final ValueChanged onManagePolicies; + + const RolesList({ + super.key, + required this.roles, + required this.canCopy, + required this.canDelete, + required this.canManagePolicies, + required this.emptyLabel, + required this.policiesCount, + required this.onCopy, + required this.onDelete, + required this.onManagePolicies, + }); + + @override + Widget build(BuildContext context) { + if (roles.isEmpty) { + return RolesEmptyState(label: emptyLabel); + } + + return Column( + children: roles.map((role) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: RoleCard( + role: role, + policyCount: policiesCount(role), + canManagePolicies: canManagePolicies(role), + canCopy: canCopy(role), + canDelete: canDelete(role), + onCopy: () => onCopy(role), + onDelete: () => onDelete(role), + onManagePolicies: () => onManagePolicies(role), + ), + ); + }).toList(), + ); + } +} diff --git a/frontend/pweb/lib/pages/roles/widgets/role_card.dart b/frontend/pweb/lib/pages/roles/widgets/role_card.dart new file mode 100644 index 00000000..ecfdeb2b --- /dev/null +++ b/frontend/pweb/lib/pages/roles/widgets/role_card.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/permissions/descriptions/role.dart'; + +import 'package:pweb/models/state/visibility.dart'; +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class RoleCard extends StatelessWidget { + final RoleDescription role; + final int policyCount; + final VisibilityState canManagePolicies; + final VisibilityState canCopy; + final VisibilityState canDelete; + final VoidCallback onCopy; + final VoidCallback onDelete; + final VoidCallback onManagePolicies; + + const RoleCard({ + super.key, + required this.role, + required this.policyCount, + required this.canManagePolicies, + required this.canCopy, + required this.canDelete, + required this.onCopy, + required this.onDelete, + required this.onManagePolicies, + }); + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + final theme = Theme.of(context); + + return Card( + elevation: 0, + color: theme.colorScheme.surfaceContainerHighest.withAlpha(40), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.security_outlined), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + role.name, + style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), + ), + if ((role.description ?? '').isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + role.description!, + style: theme.textTheme.bodyMedium, + ), + ], + const SizedBox(height: 6), + Text( + loc.rolesPoliciesCount(policyCount), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 12, + children: [ + if (canManagePolicies == VisibilityState.visible) + TextButton.icon( + onPressed: onManagePolicies, + icon: const Icon(Icons.tune, size: 18), + label: Text(loc.rolesPoliciesAction), + ), + if (canCopy == VisibilityState.visible) + TextButton.icon( + onPressed: onCopy, + icon: const Icon(Icons.copy_outlined, size: 18), + label: Text(loc.rolesCopyAction), + ), + if (canDelete == VisibilityState.visible) + TextButton.icon( + onPressed: onDelete, + icon: Icon(Icons.delete_outline, size: 18, color: theme.colorScheme.error), + label: Text(loc.delete), + style: TextButton.styleFrom( + foregroundColor: theme.colorScheme.error, + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/settings/profile/account/locale.dart b/frontend/pweb/lib/pages/settings/profile/account/locale.dart index 93618dcf..9babb874 100644 --- a/frontend/pweb/lib/pages/settings/profile/account/locale.dart +++ b/frontend/pweb/lib/pages/settings/profile/account/locale.dart @@ -8,21 +8,12 @@ import 'package:pweb/generated/i18n/app_localizations.dart'; class LocalePicker extends StatelessWidget { - final String title; - - const LocalePicker({ - super.key, - required this.title, - }); + const LocalePicker({super.key}); static const double _pickerWidth = 300; - static const double _iconSize = 20; - static const double _gapMedium = 6; - static const double _gapLarge = 8; @override Widget build(BuildContext context) { - final theme = Theme.of(context); final loc = AppLocalizations.of(context)!; return Consumer( @@ -32,37 +23,22 @@ class LocalePicker extends StatelessWidget { return SizedBox( width: _pickerWidth, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Icons.language_outlined, color: theme.colorScheme.primary, size: _iconSize), - const SizedBox(width: _gapMedium), - Text(title, style: theme.textTheme.bodyMedium), - ], - ), - const SizedBox(height: _gapLarge), - DropdownButtonFormField( - initialValue: currentLocale, - items: options - .map( - (locale) => DropdownMenuItem( - value: locale, - child: Text(_localizedLocaleName(locale, loc)), - ), - ) - .toList(), - onChanged: (locale) { - if (locale != null) { - localeProvider.setLocale(locale); - } - }, - decoration: const InputDecoration( - border: OutlineInputBorder(), - ), - ), - ], + child: DropdownButtonFormField( + initialValue: currentLocale, + items: options + .map( + (locale) => DropdownMenuItem( + value: locale, + child: Text(_localizedLocaleName(locale, loc)), + ), + ) + .toList(), + onChanged: (locale) { + if (locale != null) { + localeProvider.setLocale(locale); + } + }, + decoration: const InputDecoration(border: OutlineInputBorder()), ), ); }, diff --git a/frontend/pweb/lib/pages/settings/profile/account/name/actions.dart b/frontend/pweb/lib/pages/settings/profile/account/name/actions.dart index c36b275e..9cb01cee 100644 --- a/frontend/pweb/lib/pages/settings/profile/account/name/actions.dart +++ b/frontend/pweb/lib/pages/settings/profile/account/name/actions.dart @@ -13,33 +13,28 @@ class AccountNameActions extends StatelessWidget { final state = context.watch(); final theme = Theme.of(context); - if (state.isEditing) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: Icon(Icons.check, color: theme.colorScheme.primary), - onPressed: state.isBusy - ? null - : () async { - final wasSaved = await state.save(); - if (!context.mounted || wasSaved || state.errorText.isEmpty) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(state.errorText)), - ); - }, - ), - IconButton( - icon: Icon(Icons.close, color: theme.colorScheme.error), - onPressed: state.isBusy ? null : state.cancelEditing, - ), - ], - ); - } - - return IconButton( - icon: Icon(Icons.edit, color: theme.colorScheme.primary), - onPressed: state.isBusy ? null : state.startEditing, + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: Icon(Icons.check, color: theme.colorScheme.primary), + onPressed: state.isBusy + ? null + : () async { + final wasSaved = await state.save(); + if (!context.mounted || wasSaved || state.errorText.isEmpty) { + return; + } + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(state.errorText))); + }, + ), + IconButton( + icon: Icon(Icons.close, color: theme.colorScheme.error), + onPressed: state.isBusy ? null : state.cancelEditing, + ), + ], ); } } diff --git a/frontend/pweb/lib/pages/settings/profile/account/name/name.dart b/frontend/pweb/lib/pages/settings/profile/account/name/name.dart index b8eb404a..d205ee3d 100644 --- a/frontend/pweb/lib/pages/settings/profile/account/name/name.dart +++ b/frontend/pweb/lib/pages/settings/profile/account/name/name.dart @@ -2,8 +2,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:pshared/provider/account.dart'; - import 'package:pweb/controllers/auth/account_name.dart'; import 'package:pweb/pages/settings/profile/account/name/actions.dart'; import 'package:pweb/pages/settings/profile/account/name/text.dart'; @@ -11,52 +9,31 @@ import 'package:pweb/pages/settings/profile/account/name/text.dart'; class _AccountNameConstants { static const inputWidth = 200.0; - static const spacing = 8.0; + static const actionsSpacing = 8.0; + static const actionsWidth = kMinInteractiveDimension * 2; + static const actionsSlotWidth = actionsWidth + actionsSpacing; static const errorSpacing = 4.0; static const borderWidth = 2.0; } class AccountName extends StatelessWidget { - final String firstName; - final String lastName; - final String title; final String hintText; final String lastNameHint; - final String errorText; const AccountName({ super.key, - required this.firstName, - required this.lastName, - required this.title, required this.hintText, required this.lastNameHint, - required this.errorText, }); @override Widget build(BuildContext context) { - return ChangeNotifierProxyProvider( - create: (_) => AccountNameController( - initialFirstName: firstName, - initialLastName: lastName, - errorMessage: errorText, - ), - update: (_, accountProvider, controller) => - controller!..update(accountProvider), - child: _AccountNameBody( - hintText: hintText, - lastNameHint: lastNameHint, - ), - ); + return _AccountNameBody(hintText: hintText, lastNameHint: lastNameHint); } } class _AccountNameBody extends StatelessWidget { - const _AccountNameBody({ - required this.hintText, - required this.lastNameHint, - }); + const _AccountNameBody({required this.hintText, required this.lastNameHint}); final String hintText; final String lastNameHint; @@ -70,16 +47,27 @@ class _AccountNameBody extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Row( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, children: [ + const SizedBox(width: _AccountNameConstants.actionsSlotWidth), AccountNameText( hintText: hintText, lastNameHint: lastNameHint, inputWidth: _AccountNameConstants.inputWidth, borderWidth: _AccountNameConstants.borderWidth, ), - const SizedBox(width: _AccountNameConstants.spacing), - const AccountNameActions(), + SizedBox( + width: _AccountNameConstants.actionsSlotWidth, + child: state.isEditing + ? const Padding( + padding: EdgeInsets.only( + left: _AccountNameConstants.actionsSpacing, + ), + child: AccountNameActions(), + ) + : null, + ), ], ), const SizedBox(height: _AccountNameConstants.errorSpacing), diff --git a/frontend/pweb/lib/pages/settings/profile/account/name/text_line.dart b/frontend/pweb/lib/pages/settings/profile/account/name/text_line.dart index 6de2ad64..ad202d13 100644 --- a/frontend/pweb/lib/pages/settings/profile/account/name/text_line.dart +++ b/frontend/pweb/lib/pages/settings/profile/account/name/text_line.dart @@ -6,10 +6,12 @@ class AccountNameSingleLineText extends StatelessWidget { super.key, required this.text, required this.style, + this.textAlign = TextAlign.start, }); final String text; final TextStyle? style; + final TextAlign textAlign; @override Widget build(BuildContext context) { @@ -18,6 +20,7 @@ class AccountNameSingleLineText extends StatelessWidget { maxLines: 1, softWrap: false, overflow: TextOverflow.ellipsis, + textAlign: textAlign, style: style, ); } diff --git a/frontend/pweb/lib/pages/settings/profile/account/name/text_view.dart b/frontend/pweb/lib/pages/settings/profile/account/name/text_view.dart index e9a17dc2..1258eb1e 100644 --- a/frontend/pweb/lib/pages/settings/profile/account/name/text_view.dart +++ b/frontend/pweb/lib/pages/settings/profile/account/name/text_view.dart @@ -38,6 +38,7 @@ class AccountNameViewText extends StatelessWidget { child: AccountNameSingleLineText( text: hintText, style: firstLineStyle, + textAlign: TextAlign.center, ), ); } @@ -49,6 +50,7 @@ class AccountNameViewText extends StatelessWidget { child: AccountNameSingleLineText( text: singleLineName, style: firstLineStyle, + textAlign: TextAlign.center, ), ); } @@ -56,15 +58,17 @@ class AccountNameViewText extends StatelessWidget { return SizedBox( width: inputWidth, child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ AccountNameSingleLineText( text: trimmedFirstName, style: firstLineStyle, + textAlign: TextAlign.center, ), AccountNameSingleLineText( text: trimmedLastName, style: secondLineStyle, + textAlign: TextAlign.center, ), ], ), diff --git a/frontend/pweb/lib/pages/settings/profile/account/password/content.dart b/frontend/pweb/lib/pages/settings/profile/account/password/content.dart index 72d16f0e..b7d4ab96 100644 --- a/frontend/pweb/lib/pages/settings/profile/account/password/content.dart +++ b/frontend/pweb/lib/pages/settings/profile/account/password/content.dart @@ -12,7 +12,6 @@ import 'package:pweb/generated/i18n/app_localizations.dart'; class AccountPasswordContent extends StatelessWidget { const AccountPasswordContent({ - required this.title, required this.successText, required this.errorText, required this.oldPasswordLabel, @@ -22,7 +21,6 @@ class AccountPasswordContent extends StatelessWidget { required this.loc, }); - final String title; final String successText; final String errorText; final String oldPasswordLabel; @@ -33,34 +31,19 @@ class AccountPasswordContent extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - return Consumer2( builder: (context, accountProvider, formProvider, _) { - final isBusy = accountProvider.isLoading || formProvider.isSaving; - - return Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - TextButton.icon( - onPressed: isBusy ? null : formProvider.toggleExpanded, - icon: Icon(Icons.lock_outline, color: theme.colorScheme.primary), - label: Text(title, style: theme.textTheme.bodyMedium), - ), - if (formProvider.isExpanded) - PasswordForm( - formProvider: formProvider, - accountProvider: accountProvider, - isBusy: accountProvider.isLoading, - oldPasswordLabel: oldPasswordLabel, - newPasswordLabel: newPasswordLabel, - confirmPasswordLabel: confirmPasswordLabel, - savePassword: savePassword, - successText: successText, - errorText: errorText, - loc: loc, - ), - ], + return PasswordForm( + formProvider: formProvider, + accountProvider: accountProvider, + isBusy: accountProvider.isLoading, + oldPasswordLabel: oldPasswordLabel, + newPasswordLabel: newPasswordLabel, + confirmPasswordLabel: confirmPasswordLabel, + savePassword: savePassword, + successText: successText, + errorText: errorText, + loc: loc, ); }, ); diff --git a/frontend/pweb/lib/pages/settings/profile/account/password/form/form.dart b/frontend/pweb/lib/pages/settings/profile/account/password/form/form.dart index 46311d24..fe01736c 100644 --- a/frontend/pweb/lib/pages/settings/profile/account/password/form/form.dart +++ b/frontend/pweb/lib/pages/settings/profile/account/password/form/form.dart @@ -1,14 +1,17 @@ import 'package:flutter/material.dart'; import 'package:pshared/provider/account.dart'; -import 'package:pshared/widgets/password/fields.dart'; +import 'package:pshared/widgets/password/field.dart'; import 'package:pshared/utils/snackbar.dart'; -import 'package:pweb/models/auth/password_field_type.dart'; import 'package:pweb/controllers/auth/password_form.dart'; +import 'package:pweb/models/state/control_state.dart'; +import 'package:pweb/models/state/visibility.dart'; import 'package:pweb/pages/settings/profile/account/password/form/error_text.dart'; import 'package:pweb/pages/settings/profile/account/password/form/submit_button.dart'; import 'package:pweb/utils/error/snackbar.dart'; +import 'package:pweb/widgets/password/ui_controller.dart'; +import 'package:pweb/widgets/password/verify.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -46,6 +49,9 @@ class PasswordForm extends StatelessWidget { @override Widget build(BuildContext context) { final isFormBusy = isBusy || formProvider.isSaving; + final controlState = isFormBusy + ? ControlState.disabled + : ControlState.enabled; return Column( children: [ @@ -54,30 +60,38 @@ class PasswordForm extends StatelessWidget { key: formProvider.formKey, child: Column( children: [ - PasswordFields( - oldPasswordController: formProvider.oldPasswordController, - newPasswordController: formProvider.newPasswordController, - confirmPasswordController: formProvider.confirmPasswordController, - oldPasswordLabel: oldPasswordLabel, - newPasswordLabel: newPasswordLabel, - confirmPasswordLabel: confirmPasswordLabel, - missingPasswordError: loc.errorPasswordMissing, - passwordsDoNotMatchError: loc.passwordsDoNotMatch, + PasswordField( + controller: formProvider.oldPasswordController, + labelText: oldPasswordLabel, fieldWidth: _fieldWidth, - gapSmall: _gapSmall, - isEnabled: !isFormBusy, - showOldPassword: - formProvider.isPasswordVisible(PasswordFieldType.old), - showNewPassword: - formProvider.isPasswordVisible(PasswordFieldType.newPassword), - showConfirmPassword: formProvider - .isPasswordVisible(PasswordFieldType.confirmPassword), - onToggleOldPassword: () => - formProvider.togglePasswordVisibility(PasswordFieldType.old), - onToggleNewPassword: () => formProvider - .togglePasswordVisibility(PasswordFieldType.newPassword), - onToggleConfirmPassword: () => formProvider - .togglePasswordVisibility(PasswordFieldType.confirmPassword), + isEnabled: controlState == ControlState.enabled, + obscureText: + formProvider.oldPasswordVisibility != + VisibilityState.visible, + onToggleVisibility: formProvider.toggleOldPasswordVisibility, + validator: (value) => (value == null || value.isEmpty) + ? loc.errorPasswordMissing + : null, + ), + const SizedBox(height: _gapSmall), + SizedBox( + width: _fieldWidth, + child: PasswordUiController( + controller: formProvider.newPasswordController, + labelText: newPasswordLabel, + state: controlState, + ), + ), + const SizedBox(height: _gapSmall), + SizedBox( + width: _fieldWidth, + child: VerifyPasswordField( + controller: formProvider.confirmPasswordController, + externalPasswordController: + formProvider.newPasswordController, + labelText: confirmPasswordLabel, + state: controlState, + ), ), const SizedBox(height: _gapMedium), PasswordSubmitButton( diff --git a/frontend/pweb/lib/pages/settings/profile/account/password/password.dart b/frontend/pweb/lib/pages/settings/profile/account/password/password.dart index 5b66adb2..6aab2fc8 100644 --- a/frontend/pweb/lib/pages/settings/profile/account/password/password.dart +++ b/frontend/pweb/lib/pages/settings/profile/account/password/password.dart @@ -9,7 +9,6 @@ import 'package:pweb/generated/i18n/app_localizations.dart'; class AccountPassword extends StatelessWidget { - final String title; final String successText; final String errorText; final String oldPasswordLabel; @@ -19,7 +18,6 @@ class AccountPassword extends StatelessWidget { const AccountPassword({ super.key, - required this.title, required this.successText, required this.errorText, required this.oldPasswordLabel, @@ -35,7 +33,6 @@ class AccountPassword extends StatelessWidget { return ChangeNotifierProvider( create: (_) => PasswordFormController(), child: AccountPasswordContent( - title: title, successText: successText, errorText: errorText, oldPasswordLabel: oldPasswordLabel, diff --git a/frontend/pweb/lib/pages/settings/profile/actions/body.dart b/frontend/pweb/lib/pages/settings/profile/actions/body.dart new file mode 100644 index 00000000..69695fc0 --- /dev/null +++ b/frontend/pweb/lib/pages/settings/profile/actions/body.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pweb/controllers/settings/profile_actions.dart'; +import 'package:pweb/pages/settings/profile/actions/buttons.dart'; +import 'package:pweb/pages/settings/profile/actions/constants.dart'; +import 'package:pweb/pages/settings/profile/actions/content.dart'; + + +class ProfileActionsSectionBody extends StatelessWidget { + const ProfileActionsSectionBody({ + super.key, + required this.nameLabel, + required this.languageLabel, + required this.passwordLabel, + required this.passwordSuccessText, + required this.passwordErrorText, + required this.oldPasswordLabel, + required this.newPasswordLabel, + required this.confirmPasswordLabel, + required this.savePasswordLabel, + }); + + final String nameLabel; + final String languageLabel; + final String passwordLabel; + final String passwordSuccessText; + final String passwordErrorText; + final String oldPasswordLabel; + final String newPasswordLabel; + final String confirmPasswordLabel; + final String savePasswordLabel; + + @override + Widget build(BuildContext context) { + final controller = context.watch(); + final expandedSection = controller.expandedSection; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ProfileActionButtons( + nameLabel: nameLabel, + languageLabel: languageLabel, + passwordLabel: passwordLabel, + ), + if (expandedSection != null) ...[ + const SizedBox(height: ProfileActionsLayoutConstants.contentGap), + ProfileActionsContent( + section: expandedSection, + passwordSuccessText: passwordSuccessText, + passwordErrorText: passwordErrorText, + oldPasswordLabel: oldPasswordLabel, + newPasswordLabel: newPasswordLabel, + confirmPasswordLabel: confirmPasswordLabel, + savePasswordLabel: savePasswordLabel, + ), + ], + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/settings/profile/actions/button.dart b/frontend/pweb/lib/pages/settings/profile/actions/button.dart new file mode 100644 index 00000000..0ecf15e9 --- /dev/null +++ b/frontend/pweb/lib/pages/settings/profile/actions/button.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; + + +class ProfileActionButton extends StatelessWidget { + const ProfileActionButton({ + super.key, + required this.icon, + required this.label, + required this.isSelected, + required this.onPressed, + }); + + final IconData icon; + final String label; + final bool isSelected; + final VoidCallback onPressed; + + static const _buttonPadding = EdgeInsets.symmetric( + horizontal: 28, + vertical: 24, + ); + static const _iconSize = 28.0; + static const _contentGap = 12.0; + static const _borderRadius = 16.0; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final backgroundColor = colorScheme.onSecondary; + final borderColor = isSelected + ? colorScheme.primary + : colorScheme.onPrimary; + final textColor = colorScheme.primary; + + return TextButton( + onPressed: onPressed, + style: TextButton.styleFrom( + padding: _buttonPadding, + backgroundColor: backgroundColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(_borderRadius), + side: BorderSide(color: borderColor), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: textColor, size: _iconSize), + const SizedBox(height: _contentGap), + Text( + label, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: textColor, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/settings/profile/actions/buttons.dart b/frontend/pweb/lib/pages/settings/profile/actions/buttons.dart new file mode 100644 index 00000000..b7501612 --- /dev/null +++ b/frontend/pweb/lib/pages/settings/profile/actions/buttons.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/pages/settings/profile/actions/buttons_layout.dart'; +import 'package:pweb/pages/settings/profile/actions/language_button.dart'; +import 'package:pweb/pages/settings/profile/actions/name_button.dart'; +import 'package:pweb/pages/settings/profile/actions/password_button.dart'; + + +class ProfileActionButtons extends StatelessWidget { + const ProfileActionButtons({ + super.key, + required this.nameLabel, + required this.languageLabel, + required this.passwordLabel, + }); + + final String nameLabel; + final String languageLabel; + final String passwordLabel; + + @override + Widget build(BuildContext context) { + return ProfileActionButtonsLayout( + children: [ + ProfileNameActionButton(label: nameLabel), + ProfileLanguageActionButton(label: languageLabel), + ProfilePasswordActionButton(label: passwordLabel), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/settings/profile/actions/buttons_layout.dart b/frontend/pweb/lib/pages/settings/profile/actions/buttons_layout.dart new file mode 100644 index 00000000..ec2eb1d8 --- /dev/null +++ b/frontend/pweb/lib/pages/settings/profile/actions/buttons_layout.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/pages/settings/profile/actions/constants.dart'; + + +class ProfileActionButtonsLayout extends StatelessWidget { + const ProfileActionButtonsLayout({super.key, required this.children}); + + final List children; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final isCompact = + constraints.maxWidth < + ProfileActionsLayoutConstants.compactBreakpoint; + + if (isCompact) { + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: ProfileActionsLayoutConstants.buttonWidth, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: _buildChildren(isCompact: true), + ), + ), + ); + } + + return Row( + mainAxisSize: MainAxisSize.min, + children: _buildChildren(isCompact: false), + ); + }, + ); + } + + List _buildChildren({required bool isCompact}) { + return [ + for (var index = 0; index < children.length; index++) ...[ + SizedBox( + width: isCompact + ? double.infinity + : ProfileActionsLayoutConstants.buttonWidth, + child: children[index], + ), + if (index != children.length - 1) + SizedBox( + width: isCompact ? 0 : ProfileActionsLayoutConstants.buttonGap, + height: isCompact ? ProfileActionsLayoutConstants.buttonGap : 0, + ), + ], + ]; + } +} diff --git a/frontend/pweb/lib/pages/settings/profile/actions/constants.dart b/frontend/pweb/lib/pages/settings/profile/actions/constants.dart new file mode 100644 index 00000000..e915869d --- /dev/null +++ b/frontend/pweb/lib/pages/settings/profile/actions/constants.dart @@ -0,0 +1,6 @@ +class ProfileActionsLayoutConstants { + static const buttonGap = 12.0; + static const contentGap = 16.0; + static const buttonWidth = 180.0; + static const compactBreakpoint = buttonWidth * 3 + buttonGap * 2; +} diff --git a/frontend/pweb/lib/pages/settings/profile/actions/content.dart b/frontend/pweb/lib/pages/settings/profile/actions/content.dart new file mode 100644 index 00000000..87355a8e --- /dev/null +++ b/frontend/pweb/lib/pages/settings/profile/actions/content.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/models/settings/profile_action_section.dart'; +import 'package:pweb/pages/settings/profile/account/locale.dart'; +import 'package:pweb/pages/settings/profile/account/password/password.dart'; + + +class ProfileActionsContent extends StatelessWidget { + const ProfileActionsContent({ + super.key, + required this.section, + required this.passwordSuccessText, + required this.passwordErrorText, + required this.oldPasswordLabel, + required this.newPasswordLabel, + required this.confirmPasswordLabel, + required this.savePasswordLabel, + }); + + final ProfileActionSection section; + final String passwordSuccessText; + final String passwordErrorText; + final String oldPasswordLabel; + final String newPasswordLabel; + final String confirmPasswordLabel; + final String savePasswordLabel; + + @override + Widget build(BuildContext context) { + switch (section) { + case ProfileActionSection.language: + return const LocalePicker(); + case ProfileActionSection.password: + return AccountPassword( + successText: passwordSuccessText, + errorText: passwordErrorText, + oldPasswordLabel: oldPasswordLabel, + newPasswordLabel: newPasswordLabel, + confirmPasswordLabel: confirmPasswordLabel, + savePassword: savePasswordLabel, + ); + } + } +} diff --git a/frontend/pweb/lib/pages/settings/profile/actions/language_button.dart b/frontend/pweb/lib/pages/settings/profile/actions/language_button.dart new file mode 100644 index 00000000..de18b13d --- /dev/null +++ b/frontend/pweb/lib/pages/settings/profile/actions/language_button.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pweb/controllers/settings/profile_actions.dart'; +import 'package:pweb/models/settings/profile_action_section.dart'; +import 'package:pweb/pages/settings/profile/actions/button.dart'; + + +class ProfileLanguageActionButton extends StatelessWidget { + const ProfileLanguageActionButton({super.key, required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final controller = context.watch(); + + return ProfileActionButton( + icon: Icons.language_outlined, + label: label, + isSelected: controller.isExpanded(ProfileActionSection.language), + onPressed: () => controller.toggle(ProfileActionSection.language), + ); + } +} diff --git a/frontend/pweb/lib/pages/settings/profile/actions/name_button.dart b/frontend/pweb/lib/pages/settings/profile/actions/name_button.dart new file mode 100644 index 00000000..79c749d0 --- /dev/null +++ b/frontend/pweb/lib/pages/settings/profile/actions/name_button.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pweb/controllers/settings/profile_actions.dart'; +import 'package:pweb/pages/settings/profile/actions/button.dart'; + + +class ProfileNameActionButton extends StatelessWidget { + const ProfileNameActionButton({super.key, required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final controller = context.watch(); + + return ProfileActionButton( + icon: Icons.edit_outlined, + label: label, + isSelected: controller.isEditingName, + onPressed: controller.toggleNameEditing, + ); + } +} diff --git a/frontend/pweb/lib/pages/settings/profile/actions/password_button.dart b/frontend/pweb/lib/pages/settings/profile/actions/password_button.dart new file mode 100644 index 00000000..5d827a8a --- /dev/null +++ b/frontend/pweb/lib/pages/settings/profile/actions/password_button.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pweb/controllers/settings/profile_actions.dart'; +import 'package:pweb/models/settings/profile_action_section.dart'; +import 'package:pweb/pages/settings/profile/actions/button.dart'; + + +class ProfilePasswordActionButton extends StatelessWidget { + const ProfilePasswordActionButton({super.key, required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final controller = context.watch(); + + return ProfileActionButton( + icon: Icons.lock_outline, + label: label, + isSelected: controller.isExpanded(ProfileActionSection.password), + onPressed: () => controller.toggle(ProfileActionSection.password), + ); + } +} diff --git a/frontend/pweb/lib/pages/settings/profile/actions/section.dart b/frontend/pweb/lib/pages/settings/profile/actions/section.dart new file mode 100644 index 00000000..b065c49e --- /dev/null +++ b/frontend/pweb/lib/pages/settings/profile/actions/section.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pweb/controllers/auth/account_name.dart'; +import 'package:pweb/controllers/settings/profile_actions.dart'; +import 'package:pweb/pages/settings/profile/actions/body.dart'; + + +class ProfileActionsSection extends StatelessWidget { + const ProfileActionsSection({ + super.key, + required this.nameLabel, + required this.languageLabel, + required this.passwordLabel, + required this.passwordSuccessText, + required this.passwordErrorText, + required this.oldPasswordLabel, + required this.newPasswordLabel, + required this.confirmPasswordLabel, + required this.savePasswordLabel, + }); + + final String nameLabel; + final String languageLabel; + final String passwordLabel; + final String passwordSuccessText; + final String passwordErrorText; + final String oldPasswordLabel; + final String newPasswordLabel; + final String confirmPasswordLabel; + final String savePasswordLabel; + + @override + Widget build(BuildContext context) { + return ChangeNotifierProxyProvider< + AccountNameController, + ProfileActionsController + >( + create: (_) => ProfileActionsController(), + update: (_, accountNameController, controller) => + controller!..updateAccountNameController(accountNameController), + child: ProfileActionsSectionBody( + nameLabel: nameLabel, + languageLabel: languageLabel, + passwordLabel: passwordLabel, + passwordSuccessText: passwordSuccessText, + passwordErrorText: passwordErrorText, + oldPasswordLabel: oldPasswordLabel, + newPasswordLabel: newPasswordLabel, + confirmPasswordLabel: confirmPasswordLabel, + savePasswordLabel: savePasswordLabel, + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/settings/profile/page.dart b/frontend/pweb/lib/pages/settings/profile/page.dart index 3329d691..aaf08975 100644 --- a/frontend/pweb/lib/pages/settings/profile/page.dart +++ b/frontend/pweb/lib/pages/settings/profile/page.dart @@ -4,10 +4,10 @@ import 'package:provider/provider.dart'; import 'package:pshared/provider/account.dart'; +import 'package:pweb/controllers/auth/account_name.dart'; import 'package:pweb/pages/settings/profile/account/avatar.dart'; -import 'package:pweb/pages/settings/profile/account/locale.dart'; import 'package:pweb/pages/settings/profile/account/name/name.dart'; -import 'package:pweb/pages/settings/profile/account/password/password.dart'; +import 'package:pweb/pages/settings/profile/actions/section.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -15,7 +15,10 @@ import 'package:pweb/generated/i18n/app_localizations.dart'; class ProfileSettingsPage extends StatelessWidget { const ProfileSettingsPage({super.key}); - static const _cardPadding = EdgeInsets.symmetric(vertical: 32, horizontal: 16); + static const _cardPadding = EdgeInsets.symmetric( + vertical: 32, + horizontal: 16, + ); static const _cardRadius = 16.0; static const _itemSpacing = 12.0; @@ -33,45 +36,50 @@ class ProfileSettingsPage extends StatelessWidget { (provider) => provider.account?.avatarUrl, ); - return Align( - alignment: Alignment.topCenter, - child: Material( - elevation: 4, - borderRadius: BorderRadius.circular(_cardRadius), - color: theme.colorScheme.onSecondary, - child: Padding( - padding: _cardPadding, - child: Column( - mainAxisSize: MainAxisSize.min, - spacing: _itemSpacing, - children: [ - AvatarTile( - avatarUrl: accountAvatarUrl, - title: loc.avatar, - description: loc.avatarHint, - errorText: loc.avatarUpdateError, - ), - AccountName( - firstName: accountFirstName ?? '', - lastName: accountLastName ?? '', - title: loc.accountName, - hintText: loc.accountNameHint, - lastNameHint: loc.lastName, - errorText: loc.accountNameUpdateError, - ), - AccountPassword( - title: loc.changePassword, - successText: loc.changePasswordSuccess, - errorText: loc.changePasswordError, - oldPasswordLabel: loc.oldPassword, - newPasswordLabel: loc.newPassword, - confirmPasswordLabel: loc.confirmPassword, - savePassword: loc.savePassword, - ), - LocalePicker( - title: loc.language, - ), - ], + return ChangeNotifierProxyProvider( + create: (_) => AccountNameController( + initialFirstName: accountFirstName ?? '', + initialLastName: accountLastName ?? '', + errorMessage: loc.accountNameUpdateError, + ), + update: (_, accountProvider, controller) => + controller!..update(accountProvider), + child: Align( + alignment: Alignment.topCenter, + child: Material( + elevation: 4, + borderRadius: BorderRadius.circular(_cardRadius), + color: theme.colorScheme.onSecondary, + child: Padding( + padding: _cardPadding, + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: _itemSpacing, + children: [ + AvatarTile( + avatarUrl: accountAvatarUrl, + title: loc.avatar, + description: loc.avatarHint, + errorText: loc.avatarUpdateError, + ), + AccountName( + hintText: loc.accountNameHint, + lastNameHint: loc.lastName, + ), + SizedBox(height: _itemSpacing), + ProfileActionsSection( + nameLabel: loc.editName, + languageLabel: loc.language, + passwordLabel: loc.changePassword, + passwordSuccessText: loc.changePasswordSuccess, + passwordErrorText: loc.changePasswordError, + oldPasswordLabel: loc.oldPassword, + newPasswordLabel: loc.newPassword, + confirmPasswordLabel: loc.confirmPassword, + savePasswordLabel: loc.savePassword, + ), + ], + ), ), ), ), diff --git a/frontend/pweb/lib/pages/signup/form/feilds.dart b/frontend/pweb/lib/pages/signup/form/feilds.dart index b1d4be67..23092433 100644 --- a/frontend/pweb/lib/pages/signup/form/feilds.dart +++ b/frontend/pweb/lib/pages/signup/form/feilds.dart @@ -2,11 +2,11 @@ import 'package:flutter/material.dart'; import 'package:pweb/pages/signup/form/controllers.dart'; import 'package:pweb/pages/signup/form/description.dart'; -import 'package:pweb/widgets/username.dart'; -import 'package:pweb/pages/signup/form/password_ui_controller.dart'; import 'package:pweb/pages/signup/header.dart'; +import 'package:pweb/widgets/password/ui_controller.dart'; import 'package:pweb/widgets/password/verify.dart'; import 'package:pweb/widgets/text_field.dart'; +import 'package:pweb/widgets/username.dart'; import 'package:pweb/widgets/vspacer.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -47,7 +47,7 @@ class SignUpFormFields extends StatelessWidget { const VSpacer(), UsernameField(controller: controllers.email), const VSpacer(), - SignUpPasswordUiController(controller: controllers.password), + PasswordUiController(controller: controllers.password), const VSpacer(multiplier: 2.0), VerifyPasswordField( controller: controllers.passwordConfirm, diff --git a/frontend/pweb/lib/pages/signup/form/password_ui_controller.dart b/frontend/pweb/lib/widgets/password/ui_controller.dart similarity index 72% rename from frontend/pweb/lib/pages/signup/form/password_ui_controller.dart rename to frontend/pweb/lib/widgets/password/ui_controller.dart index ad2bbcc4..f4ca49ff 100644 --- a/frontend/pweb/lib/pages/signup/form/password_ui_controller.dart +++ b/frontend/pweb/lib/widgets/password/ui_controller.dart @@ -3,23 +3,29 @@ import 'package:flutter/material.dart'; import 'package:fancy_password_field/fancy_password_field.dart'; import 'package:pweb/config/constants.dart'; +import 'package:pweb/models/state/control_state.dart'; import 'package:pweb/widgets/password/password.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; -class SignUpPasswordUiController extends StatefulWidget { +class PasswordUiController extends StatefulWidget { final TextEditingController controller; + final String? labelText; + final ControlState state; - const SignUpPasswordUiController({required this.controller, super.key}); + const PasswordUiController({ + required this.controller, + this.labelText, + this.state = ControlState.enabled, + super.key, + }); @override - State createState() => - _SignUpPasswordUiControllerState(); + State createState() => _PasswordUiControllerState(); } -class _SignUpPasswordUiControllerState - extends State { +class _PasswordUiControllerState extends State { @override void initState() { super.initState(); @@ -36,9 +42,11 @@ class _SignUpPasswordUiControllerState @override Widget build(BuildContext context) { - final loc = AppLocalizations.of(context)!; + final isEnabled = widget.state == ControlState.enabled; final specialRule = _SpecialCharacterValidationRule( - customText: loc.passwordValidationRuleSpecialCharacter, + customText: AppLocalizations.of( + context, + )!.passwordValidationRuleSpecialCharacter, ); final value = widget.controller.text; final missing = _allRules(context, specialRule) @@ -50,13 +58,22 @@ class _SignUpPasswordUiControllerState return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Theme( - data: hasMissingRules ? _invalidTheme(context) : Theme.of(context), - child: defaulRulesPasswordField( - context, - controller: widget.controller, - additionalRules: {specialRule}, - validationRuleBuilder: (_, _) => const SizedBox.shrink(), + IgnorePointer( + ignoring: !isEnabled, + child: Opacity( + opacity: isEnabled ? 1 : 0.6, + child: Theme( + data: hasMissingRules + ? _invalidTheme(context) + : Theme.of(context), + child: defaulRulesPasswordField( + context, + controller: widget.controller, + labelText: widget.labelText, + additionalRules: {specialRule}, + validationRuleBuilder: (_, _) => const SizedBox.shrink(), + ), + ), ), ), if (hasMissingRules) ...[ diff --git a/frontend/pweb/lib/widgets/password/verify.dart b/frontend/pweb/lib/widgets/password/verify.dart index 85078211..9a858e83 100644 --- a/frontend/pweb/lib/widgets/password/verify.dart +++ b/frontend/pweb/lib/widgets/password/verify.dart @@ -2,6 +2,7 @@ import 'package:flutter/widgets.dart'; import 'package:fancy_password_field/fancy_password_field.dart'; +import 'package:pweb/models/state/control_state.dart'; import 'package:pweb/widgets/password/hint/error.dart'; import 'package:pweb/widgets/password/hint/short.dart'; import 'package:pweb/widgets/password/password.dart'; @@ -12,7 +13,7 @@ import 'package:pweb/generated/i18n/app_localizations.dart'; class PasswordVeirificationRule extends ValidationRule { final String ruleName; final TextEditingController externalPasswordController; - + PasswordVeirificationRule({ required this.ruleName, required this.externalPasswordController, @@ -32,12 +33,16 @@ class VerifyPasswordField extends StatefulWidget { final ValueChanged? onValid; final TextEditingController controller; final TextEditingController externalPasswordController; + final String? labelText; + final ControlState state; const VerifyPasswordField({ - super.key, + super.key, this.onValid, required this.controller, required this.externalPasswordController, + this.labelText, + this.state = ControlState.enabled, }); @override @@ -55,7 +60,8 @@ class _VerifyPasswordFieldState extends State { } void _validatePassword() { - final isValid = widget.controller.text == widget.externalPasswordController.text; + final isValid = + widget.controller.text == widget.externalPasswordController.text; // Only call onValid if the validity state has changed to prevent infinite loops if (isValid != _isCurrentlyValid) { @@ -68,21 +74,31 @@ class _VerifyPasswordFieldState extends State { @override Widget build(BuildContext context) { + final isEnabled = widget.state == ControlState.enabled; final rule = PasswordVeirificationRule( ruleName: AppLocalizations.of(context)!.passwordsDoNotMatch, externalPasswordController: widget.externalPasswordController, ); - return defaulRulesPasswordField( - context, - controller: widget.controller, - key: widget.key, - labelText: AppLocalizations.of(context)!.confirmPassword, - additionalRules: { rule }, - validationRuleBuilder: (rules, value) => rule.validate(value) - ? shortValidation(context, rules, value) - : PasswordValidationErrorLabel(labelText: AppLocalizations.of(context)!.passwordsDoNotMatch), - onValid: widget.onValid, + return IgnorePointer( + ignoring: !isEnabled, + child: Opacity( + opacity: isEnabled ? 1 : 0.6, + child: defaulRulesPasswordField( + context, + controller: widget.controller, + key: widget.key, + labelText: + widget.labelText ?? AppLocalizations.of(context)!.confirmPassword, + additionalRules: {rule}, + validationRuleBuilder: (rules, value) => rule.validate(value) + ? shortValidation(context, rules, value) + : PasswordValidationErrorLabel( + labelText: AppLocalizations.of(context)!.passwordsDoNotMatch, + ), + onValid: widget.onValid, + ), + ), ); }