redesign for settings page
This commit is contained in:
@@ -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});
|
||||
|
||||
162
frontend/pweb/lib/pages/roles/page.dart
Normal file
162
frontend/pweb/lib/pages/roles/page.dart
Normal 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,
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
28
frontend/pweb/lib/pages/roles/widgets/actions.dart
Normal file
28
frontend/pweb/lib/pages/roles/widgets/actions.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
25
frontend/pweb/lib/pages/roles/widgets/empty_state.dart
Normal file
25
frontend/pweb/lib/pages/roles/widgets/empty_state.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
55
frontend/pweb/lib/pages/roles/widgets/header.dart
Normal file
55
frontend/pweb/lib/pages/roles/widgets/header.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
58
frontend/pweb/lib/pages/roles/widgets/list.dart
Normal file
58
frontend/pweb/lib/pages/roles/widgets/list.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
108
frontend/pweb/lib/pages/roles/widgets/role_card.dart
Normal file
108
frontend/pweb/lib/pages/roles/widgets/role_card.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
64
frontend/pweb/lib/pages/settings/profile/actions/body.dart
Normal file
64
frontend/pweb/lib/pages/settings/profile/actions/body.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
63
frontend/pweb/lib/pages/settings/profile/actions/button.dart
Normal file
63
frontend/pweb/lib/pages/settings/profile/actions/button.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user