From 0ecd17d2dc68150b0e27145482964c62a09b972f Mon Sep 17 00:00:00 2001 From: Arseni Date: Thu, 18 Dec 2025 15:15:33 +0300 Subject: [PATCH] Updated Settings Page --- .../pshared/lib/api/requests/username.dart | 20 ++ frontend/pshared/lib/provider/account.dart | 13 ++ frontend/pshared/lib/service/account.dart | 10 +- frontend/pweb/lib/l10n/en.arb | 6 + frontend/pweb/lib/l10n/ru.arb | 6 + frontend/pweb/lib/models/edit_state.dart | 1 + .../pages/settings/profile/account/name.dart | 166 ++++++++++------ .../profile/account/password/form.dart | 129 ++++++++++++ .../profile/account/password/password.dart | 187 ++++++++++++++++++ .../account/password/toggle_button.dart | 36 ++++ .../pweb/lib/pages/settings/profile/page.dart | 73 ++++--- .../pweb/lib/pages/settings/widgets/base.dart | 30 ++- .../pweb/lib/providers/password_form.dart | 87 ++++++++ .../pweb/lib/widgets/sidebar/sidebar.dart | 23 ++- frontend/pweb/pubspec.yaml | 2 +- 15 files changed, 679 insertions(+), 110 deletions(-) create mode 100644 frontend/pshared/lib/api/requests/username.dart create mode 100644 frontend/pweb/lib/models/edit_state.dart create mode 100644 frontend/pweb/lib/pages/settings/profile/account/password/form.dart create mode 100644 frontend/pweb/lib/pages/settings/profile/account/password/password.dart create mode 100644 frontend/pweb/lib/pages/settings/profile/account/password/toggle_button.dart create mode 100644 frontend/pweb/lib/providers/password_form.dart diff --git a/frontend/pshared/lib/api/requests/username.dart b/frontend/pshared/lib/api/requests/username.dart new file mode 100644 index 0000000..d193985 --- /dev/null +++ b/frontend/pshared/lib/api/requests/username.dart @@ -0,0 +1,20 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'username.g.dart'; + + +@JsonSerializable() +class ResetUserNameRequest { + final String userName; + + const ResetUserNameRequest({ + required this.userName, + }); + + factory ResetUserNameRequest.fromJson(Map json) => _$ResetUserNameRequestFromJson(json); + Map toJson() => _$ResetUserNameRequestToJson(this); + + static ResetUserNameRequest build({ + required String userName, + }) => ResetUserNameRequest(userName: userName); +} \ No newline at end of file diff --git a/frontend/pshared/lib/provider/account.dart b/frontend/pshared/lib/provider/account.dart index 0bfd24e..c0ca4df 100644 --- a/frontend/pshared/lib/provider/account.dart +++ b/frontend/pshared/lib/provider/account.dart @@ -228,6 +228,19 @@ class AccountProvider extends ChangeNotifier { } } + Future resetUsername(String userName) async { + if (account == null) throw ErrorUnauthorized(); + _setResource(_resource.copyWith(isLoading: true, error: null)); + try { + final updated = await AccountService.resetUsername(account!, userName); + _setResource(Resource(data: updated, isLoading: false)); + return updated; + } catch (e) { + _setResource(_resource.copyWith(isLoading: false, error: toException(e))); + rethrow; + } + } + Future forgotPassword(String email) async { _setResource(_resource.copyWith(isLoading: true, error: null)); try { diff --git a/frontend/pshared/lib/service/account.dart b/frontend/pshared/lib/service/account.dart index b18db06..4d57408 100644 --- a/frontend/pshared/lib/service/account.dart +++ b/frontend/pshared/lib/service/account.dart @@ -1,5 +1,4 @@ import 'package:logging/logging.dart'; - import 'package:share_plus/share_plus.dart'; import 'package:pshared/api/requests/signup.dart'; @@ -10,6 +9,7 @@ import 'package:pshared/api/requests/password/forgot.dart'; import 'package:pshared/api/requests/password/reset.dart'; import 'package:pshared/data/mapper/account/account.dart'; import 'package:pshared/models/account/account.dart'; +import 'package:pshared/models/describable.dart'; import 'package:pshared/models/auth/login_outcome.dart'; import 'package:pshared/service/authorization/service.dart'; import 'package:pshared/service/files.dart'; @@ -61,6 +61,14 @@ class AccountService { await getPOSTResponse(_objectType, 'password/reset/$accountRef/$token', ResetPasswordRequest.build(password: newPassword).toJson()); } + static Future resetUsername(Account account, String userName) async { + _logger.fine('Updating username for account: ${account.id}'); + final updatedAccount = account.copyWith( + describable: account.describable.copyWith(name: userName), + ); + return update(updatedAccount); + } + static Future changePassword(String oldPassword, String newPassword) async { _logger.fine('Changing password'); return _getAccount(AuthorizationService.getPATCHResponse( diff --git a/frontend/pweb/lib/l10n/en.arb b/frontend/pweb/lib/l10n/en.arb index 6a8a051..27fbfb9 100644 --- a/frontend/pweb/lib/l10n/en.arb +++ b/frontend/pweb/lib/l10n/en.arb @@ -9,7 +9,13 @@ "usernameErrorInvalid": "Provide a valid email address", "usernameUnknownTLD": "Domain .{domain} is not known, please, check it", "password": "Password", + "oldPassword": "Current password", + "newPassword": "New password", "confirmPassword": "Confirm password", + "changePassword": "Change password", + "savePassword": "Save changed password", + "changePasswordSuccess": "Password updated", + "changePasswordError": "Could not update password", "passwordValidationRuleDigit": "has digit", "passwordValidationRuleUpperCase": "has uppercase letter", "passwordValidationRuleLowerCase": "has lowercase letter", diff --git a/frontend/pweb/lib/l10n/ru.arb b/frontend/pweb/lib/l10n/ru.arb index a0011ee..7aeb458 100644 --- a/frontend/pweb/lib/l10n/ru.arb +++ b/frontend/pweb/lib/l10n/ru.arb @@ -9,7 +9,13 @@ "usernameErrorInvalid": "Укажите действительный адрес электронной почты", "usernameUnknownTLD": "Домен .{domain} неизвестен, пожалуйста, проверьте его", "password": "Пароль", + "oldPassword": "Текущий пароль", + "newPassword": "Новый пароль", "confirmPassword": "Подтвердите пароль", + "changePassword": "Изменить пароль", + "savePassword": "Сохранить пароль", + "changePasswordSuccess": "Пароль обновлен", + "changePasswordError": "Не удалось обновить пароль", "passwordValidationRuleDigit": "содержит цифру", "passwordValidationRuleUpperCase": "содержит заглавную букву", "passwordValidationRuleLowerCase": "содержит строчную букву", diff --git a/frontend/pweb/lib/models/edit_state.dart b/frontend/pweb/lib/models/edit_state.dart new file mode 100644 index 0000000..b6267bb --- /dev/null +++ b/frontend/pweb/lib/models/edit_state.dart @@ -0,0 +1 @@ +enum EditState { view, edit, saving } diff --git a/frontend/pweb/lib/pages/settings/profile/account/name.dart b/frontend/pweb/lib/pages/settings/profile/account/name.dart index cf231e0..d79dc56 100644 --- a/frontend/pweb/lib/pages/settings/profile/account/name.dart +++ b/frontend/pweb/lib/pages/settings/profile/account/name.dart @@ -1,5 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'package:pweb/models/edit_state.dart'; +import 'package:pshared/provider/account.dart'; class AccountName extends StatefulWidget { final String name; @@ -26,8 +30,9 @@ class _AccountNameState extends State { static const double _borderWidth = 2; late final TextEditingController _controller; - bool _isEditing = false; + EditState _editState = EditState.view; late String _originalName; + String _errorText = ''; @override void initState() { @@ -42,86 +47,131 @@ class _AccountNameState extends State { super.dispose(); } - void _startEditing() => setState(() => _isEditing = true); + void _startEditing() => setState(() => _editState = EditState.edit); void _cancelEditing() { setState(() { _controller.text = _originalName; - _isEditing = false; + _editState = EditState.view; + _errorText = ''; }); } - void _saveEditing() { + Future _saveEditing(AccountProvider provider) async { + final newName = _controller.text.trim(); + if (newName.isEmpty || newName == _originalName) { + _cancelEditing(); + return; + } + setState(() { - _originalName = _controller.text; - _isEditing = false; + _editState = EditState.saving; + _errorText = ''; }); + + try { + await provider.resetUsername(newName); + if (!mounted) return; + setState(() { + _originalName = newName; + _editState = EditState.view; + }); + } catch (_) { + if (!mounted) return; + setState(() { + _errorText = widget.errorText; + _editState = EditState.edit; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(widget.errorText)), + ); + return; + } finally { + if (!mounted) return; + if (_editState == EditState.saving) { + setState(() => _editState = EditState.edit); + } + } } @override Widget build(BuildContext context) { final theme = Theme.of(context); - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, + return Consumer( + builder: (context, provider, _) { + final isEditing = _editState != EditState.view; + final currentName = provider.account?.name ?? _originalName; + final isBusy = provider.isLoading || _editState == EditState.saving; + + if (!isEditing && currentName != _originalName) { + _originalName = currentName; + _controller.text = currentName; + } + + return Column( + mainAxisSize: MainAxisSize.min, children: [ - if (_isEditing) - SizedBox( - width: _inputWidth, - child: TextFormField( - controller: _controller, - style: theme.textTheme.headlineMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - autofocus: true, - decoration: InputDecoration( - hintText: widget.hintText, - isDense: true, - border: UnderlineInputBorder( - borderSide: BorderSide( - color: theme.colorScheme.primary, - width: _borderWidth, + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (isEditing) + SizedBox( + width: _inputWidth, + child: TextFormField( + controller: _controller, + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + autofocus: true, + enabled: !isBusy, + decoration: InputDecoration( + hintText: widget.hintText, + isDense: true, + border: UnderlineInputBorder( + borderSide: BorderSide( + color: theme.colorScheme.primary, + width: _borderWidth, + ), + ), ), ), + ) + else + Text( + _originalName, + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), ), - ), - ) - else + const SizedBox(width: _spacing), + if (isEditing) ...[ + IconButton( + icon: Icon(Icons.check, color: theme.colorScheme.primary), + onPressed: isBusy ? null : () => _saveEditing(provider), + ), + IconButton( + icon: Icon(Icons.close, color: theme.colorScheme.error), + onPressed: isBusy ? null : _cancelEditing, + ), + ] else + IconButton( + icon: Icon(Icons.edit, color: theme.colorScheme.primary), + onPressed: isBusy ? null : _startEditing, + ), + ], + ), + const SizedBox(height: _errorSpacing), + if (_errorText.isNotEmpty) Text( - _originalName, - style: theme.textTheme.headlineMedium?.copyWith( - fontWeight: FontWeight.bold, + _errorText, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, ), ), - const SizedBox(width: _spacing), - if (_isEditing) ...[ - IconButton( - icon: Icon(Icons.check, color: theme.colorScheme.primary), - onPressed: _saveEditing, - ), - IconButton( - icon: Icon(Icons.close, color: theme.colorScheme.error), - onPressed: _cancelEditing, - ), - ] else - IconButton( - icon: Icon(Icons.edit, color: theme.colorScheme.primary), - onPressed: _startEditing, - ), ], - ), - const SizedBox(height: _errorSpacing), - if (widget.errorText.isEmpty) - Text( - widget.errorText, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.error, - ), - ), - ], + ); + }, ); } } diff --git a/frontend/pweb/lib/pages/settings/profile/account/password/form.dart b/frontend/pweb/lib/pages/settings/profile/account/password/form.dart new file mode 100644 index 0000000..782bb88 --- /dev/null +++ b/frontend/pweb/lib/pages/settings/profile/account/password/form.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/provider/account.dart'; + +import 'package:pweb/providers/password_form.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class PasswordForm extends StatelessWidget { + const PasswordForm({ + super.key, + required this.formProvider, + required this.accountProvider, + required this.isBusy, + required this.oldPasswordLabel, + required this.newPasswordLabel, + required this.confirmPasswordLabel, + required this.savePassword, + required this.successText, + required this.errorText, + required this.loc, + }); + + static const double _fieldWidth = 320; + static const double _gapMedium = 12; + static const double _gapSmall = 8; + + final PasswordFormProvider formProvider; + final AccountProvider accountProvider; + final bool isBusy; + final String oldPasswordLabel; + final String newPasswordLabel; + final String confirmPasswordLabel; + final String savePassword; + final String successText; + final String errorText; + final AppLocalizations loc; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isFormBusy = isBusy || formProvider.isSaving; + + return Column( + children: [ + const SizedBox(height: _gapMedium), + Form( + key: formProvider.formKey, + child: Column( + children: [ + SizedBox( + width: _fieldWidth, + child: TextFormField( + controller: formProvider.oldPasswordController, + obscureText: true, + enabled: !isFormBusy, + decoration: InputDecoration( + labelText: oldPasswordLabel, + border: const OutlineInputBorder(), + ), + validator: (value) => + (value == null || value.isEmpty) ? loc.errorPasswordMissing : null, + ), + ), + const SizedBox(height: _gapSmall), + SizedBox( + width: _fieldWidth, + child: TextFormField( + controller: formProvider.newPasswordController, + obscureText: true, + enabled: !isFormBusy, + decoration: InputDecoration( + labelText: newPasswordLabel, + border: const OutlineInputBorder(), + ), + validator: (value) => + (value == null || value.isEmpty) ? loc.errorPasswordMissing : null, + ), + ), + const SizedBox(height: _gapSmall), + SizedBox( + width: _fieldWidth, + child: TextFormField( + controller: formProvider.confirmPasswordController, + obscureText: true, + enabled: !isFormBusy, + decoration: InputDecoration( + labelText: confirmPasswordLabel, + border: const OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.isEmpty) return loc.errorPasswordMissing; + if (value != formProvider.newPasswordController.text) { + return loc.passwordsDoNotMatch; + } + return null; + }, + ), + ), + const SizedBox(height: _gapMedium), + ElevatedButton.icon( + onPressed: isFormBusy + ? null + : () => formProvider.submit( + context: context, + accountProvider: accountProvider, + successText: successText, + errorText: errorText, + ), + icon: const Icon(Icons.save_outlined), + label: Text(savePassword), + ), + if (formProvider.errorText.isNotEmpty) ...[ + const SizedBox(height: _gapSmall), + Text( + formProvider.errorText, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ], + ], + ), + ), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/settings/profile/account/password/password.dart b/frontend/pweb/lib/pages/settings/profile/account/password/password.dart new file mode 100644 index 0000000..9338071 --- /dev/null +++ b/frontend/pweb/lib/pages/settings/profile/account/password/password.dart @@ -0,0 +1,187 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/provider/account.dart'; +import 'package:pshared/utils/snackbar.dart'; + +import 'package:pweb/models/edit_state.dart'; +import 'package:pweb/utils/error/snackbar.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class AccountPassword extends StatefulWidget { + final String title; + final String successText; + final String errorText; + final String oldPasswordLabel; + final String newPasswordLabel; + final String confirmPasswordLabel; + final String savePassword; + + const AccountPassword({ + super.key, + required this.title, + required this.successText, + required this.errorText, + required this.oldPasswordLabel, + required this.newPasswordLabel, + required this.confirmPasswordLabel, + required this.savePassword, + }); + + @override + State createState() => _AccountPasswordState(); +} + +class _AccountPasswordState extends State { + static const double _fieldWidth = 320; + static const double _gapMedium = 12; + static const double _gapSmall = 8; + + final _formKey = GlobalKey(); + final _oldPasswordController = TextEditingController(); + final _newPasswordController = TextEditingController(); + final _confirmPasswordController = TextEditingController(); + + EditState _state = EditState.view; + String _errorText = ''; + + bool get _isSaving => _state == EditState.saving; + bool get _isExpanded => _state != EditState.view; + + @override + void dispose() { + _oldPasswordController.dispose(); + _newPasswordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _changePassword(AccountProvider provider) async { + if (!_formKey.currentState!.validate()) return; + + setState(() { + _state = EditState.saving; + _errorText = ''; + }); + + try { + await provider.changePassword(_oldPasswordController.text, _newPasswordController.text); + if (!mounted) return; + _oldPasswordController.clear(); + _newPasswordController.clear(); + _confirmPasswordController.clear(); + notifyUser(context, widget.successText); + } catch (e) { + if (!mounted) return; + setState(() => _errorText = widget.errorText); + await postNotifyUserOfErrorX( + context: context, + errorSituation: widget.errorText, + exception: e, + ); + } finally { + if (mounted) { + setState(() => _state = EditState.edit); + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final loc = AppLocalizations.of(context)!; + + return Consumer( + builder: (context, provider, _) { + final isBusy = provider.isLoading || _isSaving; + + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + TextButton.icon( + onPressed: isBusy + ? null + : () => setState(() { + _state = _isExpanded ? EditState.view : EditState.edit; + _errorText = ''; + }), + icon: Icon(Icons.lock_outline, color: theme.colorScheme.primary), + label: Text(widget.title, style: theme.textTheme.bodyMedium), + ), + if (_isExpanded) ...[ + const SizedBox(height: _gapMedium), + Form( + key: _formKey, + child: Column( + children: [ + SizedBox( + width: _fieldWidth, + child: TextFormField( + controller: _oldPasswordController, + obscureText: true, + enabled: !isBusy, + decoration: InputDecoration( + labelText: widget.oldPasswordLabel, + border: const OutlineInputBorder(), + ), + validator: (value) => (value == null || value.isEmpty) ? loc.errorPasswordMissing : null, + ), + ), + const SizedBox(height: _gapSmall), + SizedBox( + width: _fieldWidth, + child: TextFormField( + controller: _newPasswordController, + obscureText: true, + enabled: !isBusy, + decoration: InputDecoration( + labelText: widget.newPasswordLabel, + border: const OutlineInputBorder(), + ), + validator: (value) => (value == null || value.isEmpty) ? loc.errorPasswordMissing : null, + ), + ), + const SizedBox(height: _gapSmall), + SizedBox( + width: _fieldWidth, + child: TextFormField( + controller: _confirmPasswordController, + obscureText: true, + enabled: !isBusy, + decoration: InputDecoration( + labelText: widget.confirmPasswordLabel, + border: const OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.isEmpty) return loc.errorPasswordMissing; + if (value != _newPasswordController.text) return loc.passwordsDoNotMatch; + return null; + }, + ), + ), + const SizedBox(height: _gapMedium), + ElevatedButton.icon( + onPressed: isBusy ? null : () => _changePassword(provider), + icon: const Icon(Icons.save_outlined), + label: Text(widget.savePassword), + ), + if (_errorText.isNotEmpty) ...[ + const SizedBox(height: _gapSmall), + Text( + _errorText, + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error), + ), + ], + ], + ), + ), + ], + ], + ); + }, + ); + } +} diff --git a/frontend/pweb/lib/pages/settings/profile/account/password/toggle_button.dart b/frontend/pweb/lib/pages/settings/profile/account/password/toggle_button.dart new file mode 100644 index 0000000..b90dbb2 --- /dev/null +++ b/frontend/pweb/lib/pages/settings/profile/account/password/toggle_button.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + + +class PasswordToggleButton extends StatelessWidget { + const PasswordToggleButton({ + super.key, + required this.title, + required this.isExpanded, + required this.isBusy, + required this.onToggle, + }); + + final String title; + final bool isExpanded; + final bool isBusy; + final VoidCallback onToggle; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final iconColor = theme.colorScheme.primary; + + return TextButton.icon( + onPressed: isBusy + ? null + : () { + onToggle(); + }, + icon: Icon( + isExpanded ? Icons.lock_open : Icons.lock_outline, + color: iconColor, + ), + label: Text(title, style: theme.textTheme.bodyMedium), + ); + } +} diff --git a/frontend/pweb/lib/pages/settings/profile/page.dart b/frontend/pweb/lib/pages/settings/profile/page.dart index 861ab09..ed4d87f 100644 --- a/frontend/pweb/lib/pages/settings/profile/page.dart +++ b/frontend/pweb/lib/pages/settings/profile/page.dart @@ -1,8 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'package:pshared/provider/account.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.dart'; +import 'package:pweb/pages/settings/profile/account/password/password.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -18,34 +23,48 @@ class ProfileSettingsPage extends StatelessWidget { Widget build(BuildContext context) { final loc = AppLocalizations.of(context)!; final theme = Theme.of(context); + final accountName = context.select( + (provider) => provider.account?.describable.name, + ); - return Material( - elevation: 4, - borderRadius: BorderRadius.circular(_cardRadius), - clipBehavior: Clip.antiAlias, - color: theme.colorScheme.onSecondary, - child: Padding( - padding: _cardPadding, - child: Column( - mainAxisSize: MainAxisSize.min, - spacing: _itemSpacing, - children: [ - AvatarTile( - avatarUrl: 'https://avatars.githubusercontent.com/u/65651201', - title: loc.avatar, - description: loc.avatarHint, - errorText: loc.avatarUpdateError, - ), - AccountName( - name: loc.userNamePlaceholder, - title: loc.accountName, - hintText: loc.accountNameHint, - errorText: loc.accountNameUpdateError, - ), - LocalePicker( - title: loc.language, - ), - ], + 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: 'https://avatars.githubusercontent.com/u/65651201', + title: loc.avatar, + description: loc.avatarHint, + errorText: loc.avatarUpdateError, + ), + AccountName( + name: accountName ?? loc.userNamePlaceholder, + title: loc.accountName, + hintText: loc.accountNameHint, + 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, + ), + ], + ), ), ), ); diff --git a/frontend/pweb/lib/pages/settings/widgets/base.dart b/frontend/pweb/lib/pages/settings/widgets/base.dart index 31a6a96..a597331 100644 --- a/frontend/pweb/lib/pages/settings/widgets/base.dart +++ b/frontend/pweb/lib/pages/settings/widgets/base.dart @@ -1,13 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_settings_ui/flutter_settings_ui.dart'; -import 'package:pweb/generated/i18n/app_localizations.dart'; + +import 'package:pweb/models/edit_state.dart'; import 'package:pweb/utils/error/snackbar.dart'; -enum _EditState { view, edit, saving } +import 'package:pweb/generated/i18n/app_localizations.dart'; + -/// Базовый класс, управляющий состояниями (view/edit/saving), -/// показом snackbar ошибок и успешного сохранения. abstract class BaseEditTile extends AbstractSettingsTile { const BaseEditTile({ super.key, @@ -24,11 +24,8 @@ abstract class BaseEditTile extends AbstractSettingsTile { final Future Function(T) valueSetter; final String errorSituation; - /// Рисует в режиме просмотра (read-only). Widget buildView(BuildContext context, T? value); - /// Рисует UI редактора. - /// Если [useDialogEditor]==true, его обернут в диалог. Widget buildEditor( BuildContext context, T? initial, @@ -37,7 +34,6 @@ abstract class BaseEditTile extends AbstractSettingsTile { bool isSaving, ); - /// true → показывать редактор в диалоге, false → inline под заголовком. bool get useDialogEditor => false; @override @@ -52,16 +48,16 @@ class _BaseEditTileBody extends StatefulWidget { } class _BaseEditTileBodyState extends State<_BaseEditTileBody> { - _EditState _state = _EditState.view; - bool get _isSaving => _state == _EditState.saving; + EditState _state = EditState.view; + bool get _isSaving => _state == EditState.saving; Future _performSave(T newValue) async { final current = widget.delegate.valueGetter(); if (newValue == current) { - setState(() => _state = _EditState.view); + setState(() => _state = EditState.view); return; } - setState(() => _state = _EditState.saving); + setState(() => _state = EditState.saving); final sms = ScaffoldMessenger.of(context); final locs = AppLocalizations.of(context)!; try { @@ -78,7 +74,7 @@ class _BaseEditTileBodyState extends State<_BaseEditTileBody> { exception: e, ); } finally { - if (mounted) setState(() => _state = _EditState.view); + if (mounted) setState(() => _state = EditState.view); } } @@ -112,7 +108,6 @@ class _BaseEditTileBodyState extends State<_BaseEditTileBody> { final delegate = widget.delegate; final current = delegate.valueGetter(); - // Диалоговый режим if (delegate.useDialogEditor) { return SettingsTile.navigation( leading: Icon(delegate.icon), @@ -122,21 +117,20 @@ class _BaseEditTileBodyState extends State<_BaseEditTileBody> { ); } - // Inline-режим (под заголовком будет редактор прямо в tile) return SettingsTile.navigation( leading: Icon(delegate.icon), title: Text(delegate.title), - value: _state == _EditState.view + value: _state == EditState.view ? delegate.buildView(context, current) : delegate.buildEditor( context, current, _performSave, - () => setState(() => _state = _EditState.view), + () => setState(() => _state = EditState.view), _isSaving, ), onPressed: (_) { - if (_state == _EditState.view) setState(() => _state = _EditState.edit); + if (_state == EditState.view) setState(() => _state = EditState.edit); }, ); } diff --git a/frontend/pweb/lib/providers/password_form.dart b/frontend/pweb/lib/providers/password_form.dart new file mode 100644 index 0000000..670d21b --- /dev/null +++ b/frontend/pweb/lib/providers/password_form.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/provider/account.dart'; +import 'package:pshared/utils/snackbar.dart'; + +import 'package:pweb/models/edit_state.dart'; +import 'package:pweb/utils/error/snackbar.dart'; + + +class PasswordFormProvider extends ChangeNotifier { + final formKey = GlobalKey(); + final oldPasswordController = TextEditingController(); + final newPasswordController = TextEditingController(); + final confirmPasswordController = TextEditingController(); + + EditState _state = EditState.view; + String _errorText = ''; + bool _disposed = false; + + bool get isExpanded => _state != EditState.view; + bool get isSaving => _state == EditState.saving; + String get errorText => _errorText; + EditState get state => _state; + + void toggleExpanded() { + if (_state == EditState.saving) return; + _setState(_state == EditState.view ? EditState.edit : EditState.view); + _setError(''); + } + + Future submit({ + required BuildContext context, + required AccountProvider accountProvider, + required String successText, + required String errorText, + }) async { + final currentForm = formKey.currentState; + if (currentForm == null || !currentForm.validate()) return; + + _setState(EditState.saving); + _setError(''); + + try { + await accountProvider.changePassword( + oldPasswordController.text, + newPasswordController.text, + ); + + oldPasswordController.clear(); + newPasswordController.clear(); + confirmPasswordController.clear(); + if (!context.mounted) return; + notifyUser(context, successText); + } catch (e) { + _setError(errorText); + if (!context.mounted) return; + await postNotifyUserOfErrorX( + context: context, + errorSituation: errorText, + exception: e, + ); + } finally { + _setState(EditState.edit); + } + } + + void _setState(EditState value) { + if (_state == value || _disposed) return; + _state = value; + notifyListeners(); + } + + void _setError(String value) { + if (_disposed) return; + _errorText = value; + notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + oldPasswordController.dispose(); + newPasswordController.dispose(); + confirmPasswordController.dispose(); + super.dispose(); + } +} diff --git a/frontend/pweb/lib/widgets/sidebar/sidebar.dart b/frontend/pweb/lib/widgets/sidebar/sidebar.dart index b51e012..60cdefd 100644 --- a/frontend/pweb/lib/widgets/sidebar/sidebar.dart +++ b/frontend/pweb/lib/widgets/sidebar/sidebar.dart @@ -1,5 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'package:pshared/provider/account.dart'; + import 'package:pweb/widgets/sidebar/destinations.dart'; import 'package:pweb/widgets/sidebar/side_menu.dart'; import 'package:pweb/widgets/sidebar/user.dart'; @@ -18,7 +22,7 @@ class PayoutSidebar extends StatelessWidget { final PayoutDestination selected; final ValueChanged onSelected; - final VoidCallback? onLogout; + final Future Function()? onLogout; final String? userName; final String? avatarUrl; @@ -27,6 +31,15 @@ class PayoutSidebar extends StatelessWidget { @override Widget build(BuildContext context) { + final accountName = context.select( + (provider) => provider.account?.describable.name, + ); + final accountAvatar = context.select( + (provider) => provider.account?.avatarUrl, + ); + final resolvedUserName = userName ?? accountName; + final resolvedAvatarUrl = avatarUrl ?? accountAvatar; + final menuItems = items ?? [ PayoutDestination.dashboard, @@ -42,16 +55,16 @@ class PayoutSidebar extends StatelessWidget { children: [ UserProfileCard( theme: theme, - avatarUrl: avatarUrl, - userName: userName, + avatarUrl: resolvedAvatarUrl, + userName: resolvedUserName, selected: selected, onSelected: onSelected ), const SizedBox(height: 8), SideMenuColumn( theme: theme, - avatarUrl: avatarUrl, - userName: userName, + avatarUrl: resolvedAvatarUrl, + userName: resolvedUserName, items: menuItems, selected: selected, onSelected: onSelected, diff --git a/frontend/pweb/pubspec.yaml b/frontend/pweb/pubspec.yaml index 786005c..610e043 100644 --- a/frontend/pweb/pubspec.yaml +++ b/frontend/pweb/pubspec.yaml @@ -65,7 +65,7 @@ dependencies: flutter_settings_ui: ^3.0.1 pin_code_fields: ^8.0.1 fl_chart: ^1.0.0 - syncfusion_flutter_charts: ^32.1.19 + syncfusion_flutter_charts: ^31.2.10 flutter_multi_formatter: ^2.13.7 dotted_border: ^3.1.0 qr_flutter: ^4.1.0