2 Commits

Author SHA1 Message Date
Arseni
ed8596a81e deleted unnecessaryfiles 2026-03-13 23:06:23 +03:00
Arseni
d601f245d4 redesign for settings page 2026-03-13 23:01:57 +03:00
30 changed files with 715 additions and 262 deletions

View File

@@ -3,8 +3,8 @@ import 'package:flutter/material.dart';
import 'package:pshared/provider/account.dart'; import 'package:pshared/provider/account.dart';
import 'package:pshared/api/responses/error/server.dart'; import 'package:pshared/api/responses/error/server.dart';
import 'package:pweb/models/state/edit_state.dart'; import 'package:pweb/models/state/controller_lifecycle.dart';
import 'package:pweb/models/auth/password_field_type.dart'; import 'package:pweb/models/state/control_state.dart';
import 'package:pweb/models/state/visibility.dart'; import 'package:pweb/models/state/visibility.dart';
@@ -14,32 +14,17 @@ class PasswordFormController extends ChangeNotifier {
final newPasswordController = TextEditingController(); final newPasswordController = TextEditingController();
final confirmPasswordController = TextEditingController(); final confirmPasswordController = TextEditingController();
final Map<PasswordFieldType, VisibilityState> _visibility = { ControlState _formState = ControlState.enabled;
PasswordFieldType.old: VisibilityState.hidden,
PasswordFieldType.newPassword: VisibilityState.hidden,
PasswordFieldType.confirmPassword: VisibilityState.hidden,
};
EditState _state = EditState.view;
String _errorText = ''; String _errorText = '';
bool _disposed = false; VisibilityState _oldPasswordVisibility = VisibilityState.hidden;
ControllerLifecycleState _lifecycleState = ControllerLifecycleState.active;
bool get isExpanded => _state != EditState.view; bool get isSaving => _formState == ControlState.loading;
bool get isSaving => _state == EditState.saving;
String get errorText => _errorText; String get errorText => _errorText;
EditState get state => _state; VisibilityState get oldPasswordVisibility => _oldPasswordVisibility;
bool isPasswordVisible(PasswordFieldType type) =>
_visibility[type] == VisibilityState.visible;
void toggleExpanded() { void toggleOldPasswordVisibility() {
if (_state == EditState.saving) return; _oldPasswordVisibility = _oldPasswordVisibility == VisibilityState.hidden
_setState(_state == EditState.view ? EditState.edit : EditState.view);
_setError('');
}
void togglePasswordVisibility(PasswordFieldType type) {
final current = _visibility[type];
if (current == null) return;
_visibility[type] = current == VisibilityState.hidden
? VisibilityState.visible ? VisibilityState.visible
: VisibilityState.hidden; : VisibilityState.hidden;
notifyListeners(); notifyListeners();
@@ -52,7 +37,7 @@ class PasswordFormController extends ChangeNotifier {
final currentForm = formKey.currentState; final currentForm = formKey.currentState;
if (currentForm == null || !currentForm.validate()) return false; if (currentForm == null || !currentForm.validate()) return false;
_setState(EditState.saving); _setState(ControlState.loading);
_setError(''); _setError('');
try { try {
@@ -64,11 +49,11 @@ class PasswordFormController extends ChangeNotifier {
oldPasswordController.clear(); oldPasswordController.clear();
newPasswordController.clear(); newPasswordController.clear();
confirmPasswordController.clear(); confirmPasswordController.clear();
_setState(EditState.view); _setState(ControlState.enabled);
return true; return true;
} catch (e) { } catch (e) {
_setError(_errorMessageForException(e, errorText)); _setError(_errorMessageForException(e, errorText));
_setState(EditState.edit); _setState(ControlState.enabled);
rethrow; rethrow;
} }
} }
@@ -80,21 +65,23 @@ class PasswordFormController extends ChangeNotifier {
return fallback; return fallback;
} }
void _setState(EditState value) { void _setState(ControlState value) {
if (_state == value || _disposed) return; if (_formState == value || _isDisposed) return;
_state = value; _formState = value;
notifyListeners(); notifyListeners();
} }
void _setError(String value) { void _setError(String value) {
if (_disposed) return; if (_isDisposed) return;
_errorText = value; _errorText = value;
notifyListeners(); notifyListeners();
} }
bool get _isDisposed => _lifecycleState == ControllerLifecycleState.disposed;
@override @override
void dispose() { void dispose() {
_disposed = true; _lifecycleState = ControllerLifecycleState.disposed;
oldPasswordController.dispose(); oldPasswordController.dispose();
newPasswordController.dispose(); newPasswordController.dispose();
confirmPasswordController.dispose(); confirmPasswordController.dispose();

View File

@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:pweb/controllers/auth/account_name.dart';
import 'package:pweb/models/settings/profile_action_section.dart';
class ProfileActionsController extends ChangeNotifier {
AccountNameController? _accountNameController;
ProfileActionSection? _expandedSection;
ProfileActionSection? get expandedSection => _expandedSection;
bool get isEditingName => _accountNameController?.isEditing ?? false;
bool isExpanded(ProfileActionSection section) => _expandedSection == section;
void updateAccountNameController(AccountNameController controller) {
if (identical(_accountNameController, controller)) {
return;
}
_accountNameController?.removeListener(_handleAccountNameChanged);
_accountNameController = controller;
_accountNameController?.addListener(_handleAccountNameChanged);
}
void toggle(ProfileActionSection section) {
final isOpeningSection = !isExpanded(section);
if (isOpeningSection) {
if (_accountNameController?.isBusy ?? false) {
return;
}
_accountNameController?.cancelEditing();
}
_expandedSection = isExpanded(section) ? null : section;
notifyListeners();
}
void toggleNameEditing() {
final accountNameController = _accountNameController;
if (accountNameController == null || accountNameController.isBusy) {
return;
}
if (accountNameController.isEditing) {
accountNameController.cancelEditing();
return;
}
_expandedSection = null;
accountNameController.startEditing();
notifyListeners();
}
void _handleAccountNameChanged() {
notifyListeners();
}
@override
void dispose() {
_accountNameController?.removeListener(_handleAccountNameChanged);
super.dispose();
}
}

View File

@@ -225,6 +225,7 @@
"settingsSuccessfullyUpdated": "Settings successfully updated", "settingsSuccessfullyUpdated": "Settings successfully updated",
"language": "Language", "language": "Language",
"failedToUpdateLanguage": "Failed to update language", "failedToUpdateLanguage": "Failed to update language",
"editName": "Edit name",
"settingsImageUpdateError": "Couldn't update the image", "settingsImageUpdateError": "Couldn't update the image",
"settingsImageTitle": "Image", "settingsImageTitle": "Image",
"settingsImageHint": "Tap to change the image", "settingsImageHint": "Tap to change the image",

View File

@@ -225,6 +225,7 @@
"settingsSuccessfullyUpdated": "Настройки успешно обновлены", "settingsSuccessfullyUpdated": "Настройки успешно обновлены",
"language": "Язык", "language": "Язык",
"failedToUpdateLanguage": "Не удалось обновить язык", "failedToUpdateLanguage": "Не удалось обновить язык",
"editName": "Изменить имя",
"settingsImageUpdateError": "Не удалось обновить изображение", "settingsImageUpdateError": "Не удалось обновить изображение",
"settingsImageTitle": "Изображение", "settingsImageTitle": "Изображение",
"settingsImageHint": "Нажмите, чтобы изменить изображение", "settingsImageHint": "Нажмите, чтобы изменить изображение",

View File

@@ -1 +0,0 @@
enum PasswordFieldType { old, newPassword, confirmPassword }

View File

@@ -0,0 +1 @@
enum ProfileActionSection { language, password }

View File

@@ -0,0 +1 @@
enum ControllerLifecycleState { active, disposed }

View File

@@ -14,6 +14,7 @@ import 'package:pweb/models/account/account_loader.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';
class AccountLoader extends StatefulWidget { class AccountLoader extends StatefulWidget {
final Widget child; final Widget child;
const AccountLoader({super.key, required this.child}); const AccountLoader({super.key, required this.child});

View File

@@ -8,21 +8,12 @@ import 'package:pweb/generated/i18n/app_localizations.dart';
class LocalePicker extends StatelessWidget { class LocalePicker extends StatelessWidget {
final String title; const LocalePicker({super.key});
const LocalePicker({
super.key,
required this.title,
});
static const double _pickerWidth = 300; static const double _pickerWidth = 300;
static const double _iconSize = 20;
static const double _gapMedium = 6;
static const double _gapLarge = 8;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
final loc = AppLocalizations.of(context)!; final loc = AppLocalizations.of(context)!;
return Consumer<LocaleProvider>( return Consumer<LocaleProvider>(
@@ -32,18 +23,7 @@ class LocalePicker extends StatelessWidget {
return SizedBox( return SizedBox(
width: _pickerWidth, width: _pickerWidth,
child: Column( child: DropdownButtonFormField<Locale>(
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, initialValue: currentLocale,
items: options items: options
.map( .map(
@@ -58,11 +38,7 @@ class LocalePicker extends StatelessWidget {
localeProvider.setLocale(locale); localeProvider.setLocale(locale);
} }
}, },
decoration: const InputDecoration( decoration: const InputDecoration(border: OutlineInputBorder()),
border: OutlineInputBorder(),
),
),
],
), ),
); );
}, },

View File

@@ -13,7 +13,6 @@ class AccountNameActions extends StatelessWidget {
final state = context.watch<AccountNameController>(); final state = context.watch<AccountNameController>();
final theme = Theme.of(context); final theme = Theme.of(context);
if (state.isEditing) {
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -23,10 +22,12 @@ class AccountNameActions extends StatelessWidget {
? null ? null
: () async { : () async {
final wasSaved = await state.save(); final wasSaved = await state.save();
if (!context.mounted || wasSaved || state.errorText.isEmpty) return; if (!context.mounted || wasSaved || state.errorText.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( return;
SnackBar(content: Text(state.errorText)), }
); ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(state.errorText)));
}, },
), ),
IconButton( IconButton(
@@ -36,10 +37,4 @@ class AccountNameActions extends StatelessWidget {
], ],
); );
} }
return IconButton(
icon: Icon(Icons.edit, color: theme.colorScheme.primary),
onPressed: state.isBusy ? null : state.startEditing,
);
}
} }

View File

@@ -2,8 +2,6 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:pshared/provider/account.dart';
import 'package:pweb/controllers/auth/account_name.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/actions.dart';
import 'package:pweb/pages/settings/profile/account/name/text.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 { class _AccountNameConstants {
static const inputWidth = 200.0; 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 errorSpacing = 4.0;
static const borderWidth = 2.0; static const borderWidth = 2.0;
} }
class AccountName extends StatelessWidget { class AccountName extends StatelessWidget {
final String firstName;
final String lastName;
final String title;
final String hintText; final String hintText;
final String lastNameHint; final String lastNameHint;
final String errorText;
const AccountName({ const AccountName({
super.key, super.key,
required this.firstName,
required this.lastName,
required this.title,
required this.hintText, required this.hintText,
required this.lastNameHint, required this.lastNameHint,
required this.errorText,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ChangeNotifierProxyProvider<AccountProvider, AccountNameController>( return _AccountNameBody(hintText: hintText, lastNameHint: lastNameHint);
create: (_) => AccountNameController(
initialFirstName: firstName,
initialLastName: lastName,
errorMessage: errorText,
),
update: (_, accountProvider, controller) =>
controller!..update(accountProvider),
child: _AccountNameBody(
hintText: hintText,
lastNameHint: lastNameHint,
),
);
} }
} }
class _AccountNameBody extends StatelessWidget { class _AccountNameBody extends StatelessWidget {
const _AccountNameBody({ const _AccountNameBody({required this.hintText, required this.lastNameHint});
required this.hintText,
required this.lastNameHint,
});
final String hintText; final String hintText;
final String lastNameHint; final String lastNameHint;
@@ -70,16 +47,27 @@ class _AccountNameBody extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Row( Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
const SizedBox(width: _AccountNameConstants.actionsSlotWidth),
AccountNameText( AccountNameText(
hintText: hintText, hintText: hintText,
lastNameHint: lastNameHint, lastNameHint: lastNameHint,
inputWidth: _AccountNameConstants.inputWidth, inputWidth: _AccountNameConstants.inputWidth,
borderWidth: _AccountNameConstants.borderWidth, borderWidth: _AccountNameConstants.borderWidth,
), ),
const SizedBox(width: _AccountNameConstants.spacing), SizedBox(
const AccountNameActions(), width: _AccountNameConstants.actionsSlotWidth,
child: state.isEditing
? const Padding(
padding: EdgeInsets.only(
left: _AccountNameConstants.actionsSpacing,
),
child: AccountNameActions(),
)
: null,
),
], ],
), ),
const SizedBox(height: _AccountNameConstants.errorSpacing), const SizedBox(height: _AccountNameConstants.errorSpacing),

View File

@@ -6,10 +6,12 @@ class AccountNameSingleLineText extends StatelessWidget {
super.key, super.key,
required this.text, required this.text,
required this.style, required this.style,
this.textAlign = TextAlign.start,
}); });
final String text; final String text;
final TextStyle? style; final TextStyle? style;
final TextAlign textAlign;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -18,6 +20,7 @@ class AccountNameSingleLineText extends StatelessWidget {
maxLines: 1, maxLines: 1,
softWrap: false, softWrap: false,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
textAlign: textAlign,
style: style, style: style,
); );
} }

View File

@@ -38,6 +38,7 @@ class AccountNameViewText extends StatelessWidget {
child: AccountNameSingleLineText( child: AccountNameSingleLineText(
text: hintText, text: hintText,
style: firstLineStyle, style: firstLineStyle,
textAlign: TextAlign.center,
), ),
); );
} }
@@ -49,6 +50,7 @@ class AccountNameViewText extends StatelessWidget {
child: AccountNameSingleLineText( child: AccountNameSingleLineText(
text: singleLineName, text: singleLineName,
style: firstLineStyle, style: firstLineStyle,
textAlign: TextAlign.center,
), ),
); );
} }
@@ -56,15 +58,17 @@ class AccountNameViewText extends StatelessWidget {
return SizedBox( return SizedBox(
width: inputWidth, width: inputWidth,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
AccountNameSingleLineText( AccountNameSingleLineText(
text: trimmedFirstName, text: trimmedFirstName,
style: firstLineStyle, style: firstLineStyle,
textAlign: TextAlign.center,
), ),
AccountNameSingleLineText( AccountNameSingleLineText(
text: trimmedLastName, text: trimmedLastName,
style: secondLineStyle, style: secondLineStyle,
textAlign: TextAlign.center,
), ),
], ],
), ),

View File

@@ -12,7 +12,6 @@ import 'package:pweb/generated/i18n/app_localizations.dart';
class AccountPasswordContent extends StatelessWidget { class AccountPasswordContent extends StatelessWidget {
const AccountPasswordContent({ const AccountPasswordContent({
required this.title,
required this.successText, required this.successText,
required this.errorText, required this.errorText,
required this.oldPasswordLabel, required this.oldPasswordLabel,
@@ -22,7 +21,6 @@ class AccountPasswordContent extends StatelessWidget {
required this.loc, required this.loc,
}); });
final String title;
final String successText; final String successText;
final String errorText; final String errorText;
final String oldPasswordLabel; final String oldPasswordLabel;
@@ -33,22 +31,9 @@ class AccountPasswordContent extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
return Consumer2<AccountProvider, PasswordFormController>( return Consumer2<AccountProvider, PasswordFormController>(
builder: (context, accountProvider, formProvider, _) { builder: (context, accountProvider, formProvider, _) {
final isBusy = accountProvider.isLoading || formProvider.isSaving; return PasswordForm(
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, formProvider: formProvider,
accountProvider: accountProvider, accountProvider: accountProvider,
isBusy: accountProvider.isLoading, isBusy: accountProvider.isLoading,
@@ -59,8 +44,6 @@ class AccountPasswordContent extends StatelessWidget {
successText: successText, successText: successText,
errorText: errorText, errorText: errorText,
loc: loc, loc: loc,
),
],
); );
}, },
); );

View File

@@ -1,14 +1,17 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:pshared/provider/account.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:pshared/utils/snackbar.dart';
import 'package:pweb/models/auth/password_field_type.dart';
import 'package:pweb/controllers/auth/password_form.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/error_text.dart';
import 'package:pweb/pages/settings/profile/account/password/form/submit_button.dart'; import 'package:pweb/pages/settings/profile/account/password/form/submit_button.dart';
import 'package:pweb/utils/error/snackbar.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'; import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -46,6 +49,9 @@ class PasswordForm extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isFormBusy = isBusy || formProvider.isSaving; final isFormBusy = isBusy || formProvider.isSaving;
final controlState = isFormBusy
? ControlState.disabled
: ControlState.enabled;
return Column( return Column(
children: [ children: [
@@ -54,30 +60,38 @@ class PasswordForm extends StatelessWidget {
key: formProvider.formKey, key: formProvider.formKey,
child: Column( child: Column(
children: [ children: [
PasswordFields( PasswordField(
oldPasswordController: formProvider.oldPasswordController, controller: formProvider.oldPasswordController,
newPasswordController: formProvider.newPasswordController, labelText: oldPasswordLabel,
confirmPasswordController: formProvider.confirmPasswordController,
oldPasswordLabel: oldPasswordLabel,
newPasswordLabel: newPasswordLabel,
confirmPasswordLabel: confirmPasswordLabel,
missingPasswordError: loc.errorPasswordMissing,
passwordsDoNotMatchError: loc.passwordsDoNotMatch,
fieldWidth: _fieldWidth, fieldWidth: _fieldWidth,
gapSmall: _gapSmall, isEnabled: controlState == ControlState.enabled,
isEnabled: !isFormBusy, obscureText:
showOldPassword: formProvider.oldPasswordVisibility !=
formProvider.isPasswordVisible(PasswordFieldType.old), VisibilityState.visible,
showNewPassword: onToggleVisibility: formProvider.toggleOldPasswordVisibility,
formProvider.isPasswordVisible(PasswordFieldType.newPassword), validator: (value) => (value == null || value.isEmpty)
showConfirmPassword: formProvider ? loc.errorPasswordMissing
.isPasswordVisible(PasswordFieldType.confirmPassword), : null,
onToggleOldPassword: () => ),
formProvider.togglePasswordVisibility(PasswordFieldType.old), const SizedBox(height: _gapSmall),
onToggleNewPassword: () => formProvider SizedBox(
.togglePasswordVisibility(PasswordFieldType.newPassword), width: _fieldWidth,
onToggleConfirmPassword: () => formProvider child: PasswordUiController(
.togglePasswordVisibility(PasswordFieldType.confirmPassword), 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), const SizedBox(height: _gapMedium),
PasswordSubmitButton( PasswordSubmitButton(

View File

@@ -9,7 +9,6 @@ import 'package:pweb/generated/i18n/app_localizations.dart';
class AccountPassword extends StatelessWidget { class AccountPassword extends StatelessWidget {
final String title;
final String successText; final String successText;
final String errorText; final String errorText;
final String oldPasswordLabel; final String oldPasswordLabel;
@@ -19,7 +18,6 @@ class AccountPassword extends StatelessWidget {
const AccountPassword({ const AccountPassword({
super.key, super.key,
required this.title,
required this.successText, required this.successText,
required this.errorText, required this.errorText,
required this.oldPasswordLabel, required this.oldPasswordLabel,
@@ -35,7 +33,6 @@ class AccountPassword extends StatelessWidget {
return ChangeNotifierProvider( return ChangeNotifierProvider(
create: (_) => PasswordFormController(), create: (_) => PasswordFormController(),
child: AccountPasswordContent( child: AccountPasswordContent(
title: title,
successText: successText, successText: successText,
errorText: errorText, errorText: errorText,
oldPasswordLabel: oldPasswordLabel, 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: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/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/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'; import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -15,7 +15,10 @@ import 'package:pweb/generated/i18n/app_localizations.dart';
class ProfileSettingsPage extends StatelessWidget { class ProfileSettingsPage extends StatelessWidget {
const ProfileSettingsPage({super.key}); 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 _cardRadius = 16.0;
static const _itemSpacing = 12.0; static const _itemSpacing = 12.0;
@@ -33,7 +36,15 @@ class ProfileSettingsPage extends StatelessWidget {
(provider) => provider.account?.avatarUrl, (provider) => provider.account?.avatarUrl,
); );
return Align( return ChangeNotifierProxyProvider<AccountProvider, AccountNameController>(
create: (_) => AccountNameController(
initialFirstName: accountFirstName ?? '',
initialLastName: accountLastName ?? '',
errorMessage: loc.accountNameUpdateError,
),
update: (_, accountProvider, controller) =>
controller!..update(accountProvider),
child: Align(
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
child: Material( child: Material(
elevation: 4, elevation: 4,
@@ -52,29 +63,26 @@ class ProfileSettingsPage extends StatelessWidget {
errorText: loc.avatarUpdateError, errorText: loc.avatarUpdateError,
), ),
AccountName( AccountName(
firstName: accountFirstName ?? '',
lastName: accountLastName ?? '',
title: loc.accountName,
hintText: loc.accountNameHint, hintText: loc.accountNameHint,
lastNameHint: loc.lastName, lastNameHint: loc.lastName,
errorText: loc.accountNameUpdateError,
), ),
AccountPassword( SizedBox(height: _itemSpacing),
title: loc.changePassword, ProfileActionsSection(
successText: loc.changePasswordSuccess, nameLabel: loc.editName,
errorText: loc.changePasswordError, languageLabel: loc.language,
passwordLabel: loc.changePassword,
passwordSuccessText: loc.changePasswordSuccess,
passwordErrorText: loc.changePasswordError,
oldPasswordLabel: loc.oldPassword, oldPasswordLabel: loc.oldPassword,
newPasswordLabel: loc.newPassword, newPasswordLabel: loc.newPassword,
confirmPasswordLabel: loc.confirmPassword, confirmPasswordLabel: loc.confirmPassword,
savePassword: loc.savePassword, savePasswordLabel: loc.savePassword,
),
LocalePicker(
title: loc.language,
), ),
], ],
), ),
), ),
), ),
),
); );
} }
} }

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/controllers.dart';
import 'package:pweb/pages/signup/form/description.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/pages/signup/header.dart';
import 'package:pweb/widgets/password/ui_controller.dart';
import 'package:pweb/widgets/password/verify.dart'; import 'package:pweb/widgets/password/verify.dart';
import 'package:pweb/widgets/text_field.dart'; import 'package:pweb/widgets/text_field.dart';
import 'package:pweb/widgets/username.dart';
import 'package:pweb/widgets/vspacer.dart'; import 'package:pweb/widgets/vspacer.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -47,7 +47,7 @@ class SignUpFormFields extends StatelessWidget {
const VSpacer(), const VSpacer(),
UsernameField(controller: controllers.email), UsernameField(controller: controllers.email),
const VSpacer(), const VSpacer(),
SignUpPasswordUiController(controller: controllers.password), PasswordUiController(controller: controllers.password),
const VSpacer(multiplier: 2.0), const VSpacer(multiplier: 2.0),
VerifyPasswordField( VerifyPasswordField(
controller: controllers.passwordConfirm, controller: controllers.passwordConfirm,

View File

@@ -3,23 +3,29 @@ import 'package:flutter/material.dart';
import 'package:fancy_password_field/fancy_password_field.dart'; import 'package:fancy_password_field/fancy_password_field.dart';
import 'package:pweb/config/constants.dart'; import 'package:pweb/config/constants.dart';
import 'package:pweb/models/state/control_state.dart';
import 'package:pweb/widgets/password/password.dart'; import 'package:pweb/widgets/password/password.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';
class SignUpPasswordUiController extends StatefulWidget { class PasswordUiController extends StatefulWidget {
final TextEditingController controller; final TextEditingController controller;
final String? labelText;
final ControlState state;
const SignUpPasswordUiController({required this.controller, super.key}); const PasswordUiController({
required this.controller,
this.labelText,
this.state = ControlState.enabled,
super.key,
});
@override @override
State<SignUpPasswordUiController> createState() => State<PasswordUiController> createState() => _PasswordUiControllerState();
_SignUpPasswordUiControllerState();
} }
class _SignUpPasswordUiControllerState class _PasswordUiControllerState extends State<PasswordUiController> {
extends State<SignUpPasswordUiController> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -36,9 +42,11 @@ class _SignUpPasswordUiControllerState
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!; final isEnabled = widget.state == ControlState.enabled;
final specialRule = _SpecialCharacterValidationRule( final specialRule = _SpecialCharacterValidationRule(
customText: loc.passwordValidationRuleSpecialCharacter, customText: AppLocalizations.of(
context,
)!.passwordValidationRuleSpecialCharacter,
); );
final value = widget.controller.text; final value = widget.controller.text;
final missing = _allRules(context, specialRule) final missing = _allRules(context, specialRule)
@@ -50,15 +58,24 @@ class _SignUpPasswordUiControllerState
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Theme( IgnorePointer(
data: hasMissingRules ? _invalidTheme(context) : Theme.of(context), ignoring: !isEnabled,
child: Opacity(
opacity: isEnabled ? 1 : 0.6,
child: Theme(
data: hasMissingRules
? _invalidTheme(context)
: Theme.of(context),
child: defaulRulesPasswordField( child: defaulRulesPasswordField(
context, context,
controller: widget.controller, controller: widget.controller,
labelText: widget.labelText,
additionalRules: {specialRule}, additionalRules: {specialRule},
validationRuleBuilder: (_, _) => const SizedBox.shrink(), validationRuleBuilder: (_, _) => const SizedBox.shrink(),
), ),
), ),
),
),
if (hasMissingRules) ...[ if (hasMissingRules) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
...missing.map( ...missing.map(

View File

@@ -2,6 +2,7 @@ import 'package:flutter/widgets.dart';
import 'package:fancy_password_field/fancy_password_field.dart'; import 'package:fancy_password_field/fancy_password_field.dart';
import 'package:pweb/models/state/control_state.dart';
import 'package:pweb/widgets/password/hint/error.dart'; import 'package:pweb/widgets/password/hint/error.dart';
import 'package:pweb/widgets/password/hint/short.dart'; import 'package:pweb/widgets/password/hint/short.dart';
import 'package:pweb/widgets/password/password.dart'; import 'package:pweb/widgets/password/password.dart';
@@ -32,12 +33,16 @@ class VerifyPasswordField extends StatefulWidget {
final ValueChanged<bool>? onValid; final ValueChanged<bool>? onValid;
final TextEditingController controller; final TextEditingController controller;
final TextEditingController externalPasswordController; final TextEditingController externalPasswordController;
final String? labelText;
final ControlState state;
const VerifyPasswordField({ const VerifyPasswordField({
super.key, super.key,
this.onValid, this.onValid,
required this.controller, required this.controller,
required this.externalPasswordController, required this.externalPasswordController,
this.labelText,
this.state = ControlState.enabled,
}); });
@override @override
@@ -55,7 +60,8 @@ class _VerifyPasswordFieldState extends State<VerifyPasswordField> {
} }
void _validatePassword() { void _validatePassword() {
final isValid = widget.controller.text == widget.externalPasswordController.text; final isValid =
widget.controller.text == widget.externalPasswordController.text;
// Only call onValid if the validity state has changed to prevent infinite loops // Only call onValid if the validity state has changed to prevent infinite loops
if (isValid != _isCurrentlyValid) { if (isValid != _isCurrentlyValid) {
@@ -68,21 +74,31 @@ class _VerifyPasswordFieldState extends State<VerifyPasswordField> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isEnabled = widget.state == ControlState.enabled;
final rule = PasswordVeirificationRule( final rule = PasswordVeirificationRule(
ruleName: AppLocalizations.of(context)!.passwordsDoNotMatch, ruleName: AppLocalizations.of(context)!.passwordsDoNotMatch,
externalPasswordController: widget.externalPasswordController, externalPasswordController: widget.externalPasswordController,
); );
return defaulRulesPasswordField( return IgnorePointer(
ignoring: !isEnabled,
child: Opacity(
opacity: isEnabled ? 1 : 0.6,
child: defaulRulesPasswordField(
context, context,
controller: widget.controller, controller: widget.controller,
key: widget.key, key: widget.key,
labelText: AppLocalizations.of(context)!.confirmPassword, labelText:
additionalRules: { rule }, widget.labelText ?? AppLocalizations.of(context)!.confirmPassword,
additionalRules: {rule},
validationRuleBuilder: (rules, value) => rule.validate(value) validationRuleBuilder: (rules, value) => rule.validate(value)
? shortValidation(context, rules, value) ? shortValidation(context, rules, value)
: PasswordValidationErrorLabel(labelText: AppLocalizations.of(context)!.passwordsDoNotMatch), : PasswordValidationErrorLabel(
labelText: AppLocalizations.of(context)!.passwordsDoNotMatch,
),
onValid: widget.onValid, onValid: widget.onValid,
),
),
); );
} }