redesign for settings page

This commit is contained in:
Arseni
2026-03-13 23:01:57 +03:00
parent 70bd7a6214
commit d601f245d4
36 changed files with 1151 additions and 262 deletions

View File

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

View File

@@ -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<void> _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<PermissionsProvider>();
await executeActionWithNotification(
context: context,
action: () => permissions.createRoleDescription(
name: draft.name,
description: draft.description.isEmpty ? null : draft.description,
),
successMessage: loc.rolesCreateSuccess,
errorMessage: loc.rolesCreateFailed,
);
}
Future<void> _copyRole(BuildContext context, RoleDescription role) async {
final loc = AppLocalizations.of(context)!;
final permissions = context.read<PermissionsProvider>();
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<void> _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<PermissionsProvider>();
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<PermissionsProvider>();
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,
},
),
),
],
),
),
),
);
}
}

View File

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

View File

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

View File

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

View File

@@ -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<RoleDescription> 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<RoleDescription> onCopy;
final ValueChanged<RoleDescription> onDelete;
final ValueChanged<RoleDescription> 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(),
);
}
}

View File

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

View File

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

View File

@@ -13,33 +13,28 @@ class AccountNameActions extends StatelessWidget {
final state = context.watch<AccountNameController>();
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,
),
],
);
}
}

View File

@@ -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<AccountProvider, AccountNameController>(
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),

View File

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

View File

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

View File

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

View File

@@ -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(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<ProfileActionsController>();
return ProfileActionButton(
icon: Icons.language_outlined,
label: label,
isSelected: controller.isExpanded(ProfileActionSection.language),
onPressed: () => controller.toggle(ProfileActionSection.language),
);
}
}

View File

@@ -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<ProfileActionsController>();
return ProfileActionButton(
icon: Icons.edit_outlined,
label: label,
isSelected: controller.isEditingName,
onPressed: controller.toggleNameEditing,
);
}
}

View File

@@ -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<ProfileActionsController>();
return ProfileActionButton(
icon: Icons.lock_outline,
label: label,
isSelected: controller.isExpanded(ProfileActionSection.password),
onPressed: () => controller.toggle(ProfileActionSection.password),
);
}
}

View File

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

View File

@@ -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<AccountProvider, AccountNameController>(
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,
),
],
),
),
),
),

View File

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

View File

@@ -1,135 +0,0 @@
import 'package:flutter/material.dart';
import 'package:fancy_password_field/fancy_password_field.dart';
import 'package:pweb/config/constants.dart';
import 'package:pweb/widgets/password/password.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class SignUpPasswordUiController extends StatefulWidget {
final TextEditingController controller;
const SignUpPasswordUiController({required this.controller, super.key});
@override
State<SignUpPasswordUiController> createState() =>
_SignUpPasswordUiControllerState();
}
class _SignUpPasswordUiControllerState
extends State<SignUpPasswordUiController> {
@override
void initState() {
super.initState();
widget.controller.addListener(_onPasswordChanged);
}
@override
void dispose() {
widget.controller.removeListener(_onPasswordChanged);
super.dispose();
}
void _onPasswordChanged() => setState(() {});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
final specialRule = _SpecialCharacterValidationRule(
customText: loc.passwordValidationRuleSpecialCharacter,
);
final value = widget.controller.text;
final missing = _allRules(context, specialRule)
.where((rule) => !rule.validate(value))
.map((rule) => rule.name)
.toList(growable: false);
final hasMissingRules = value.isNotEmpty && missing.isNotEmpty;
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(),
),
),
if (hasMissingRules) ...[
const SizedBox(height: 8),
...missing.map(
(ruleText) => Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(
'$ruleText',
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
),
],
],
);
}
List<ValidationRule> _allRules(
BuildContext context,
ValidationRule specialRule,
) {
final loc = AppLocalizations.of(context)!;
return [
DigitValidationRule(customText: loc.passwordValidationRuleDigit),
UppercaseValidationRule(customText: loc.passwordValidationRuleUpperCase),
LowercaseValidationRule(customText: loc.passwordValidationRuleLowerCase),
MinCharactersValidationRule(
Constants.minPasswordCharacters,
customText: loc.passwordValidationRuleMinCharacters(
Constants.minPasswordCharacters,
),
),
specialRule,
];
}
ThemeData _invalidTheme(BuildContext context) {
final theme = Theme.of(context);
final errorColor = theme.colorScheme.error;
final border = OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
borderSide: BorderSide(color: errorColor, width: 1.2),
);
return theme.copyWith(
inputDecorationTheme: theme.inputDecorationTheme.copyWith(
enabledBorder: border,
focusedBorder: border,
errorBorder: border,
focusedErrorBorder: border,
),
);
}
}
class _SpecialCharacterValidationRule extends ValidationRule {
final String customText;
_SpecialCharacterValidationRule({required this.customText});
@override
String get name => customText;
@override
bool get showName => true;
@override
bool validate(String value) => value.runes.any(_isAsciiSpecialCharacter);
bool _isAsciiSpecialCharacter(int code) =>
(code >= 33 && code <= 47) ||
(code >= 58 && code <= 64) ||
(code >= 91 && code <= 96) ||
(code >= 123 && code <= 126);
}