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..4e17dc7 100644 --- a/frontend/pshared/lib/provider/account.dart +++ b/frontend/pshared/lib/provider/account.dart @@ -228,6 +228,13 @@ class AccountProvider extends ChangeNotifier { } } + Future resetUsername(String userName) async { + if (account == null) throw ErrorUnauthorized(); + return update( + describable: account!.describable.copyWith(name: userName), + ); + } + Future forgotPassword(String email) async { _setResource(_resource.copyWith(isLoading: true, error: null)); try { diff --git a/frontend/pshared/lib/widgets/password/confirm_field.dart b/frontend/pshared/lib/widgets/password/confirm_field.dart new file mode 100644 index 0000000..acd00cc --- /dev/null +++ b/frontend/pshared/lib/widgets/password/confirm_field.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + + +class ConfirmPasswordField extends StatelessWidget { + const ConfirmPasswordField({ + required this.controller, + required this.fieldWidth, + required this.isEnabled, + required this.confirmPasswordLabel, + required this.newPasswordController, + required this.missingPasswordError, + required this.passwordsDoNotMatchError, + }); + + final TextEditingController controller; + final double fieldWidth; + final bool isEnabled; + final String confirmPasswordLabel; + final TextEditingController newPasswordController; + final String missingPasswordError; + final String passwordsDoNotMatchError; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: fieldWidth, + child: TextFormField( + controller: controller, + obscureText: true, + enabled: isEnabled, + decoration: InputDecoration( + labelText: confirmPasswordLabel, + border: const OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.isEmpty) return missingPasswordError; + if (value != newPasswordController.text) { + return passwordsDoNotMatchError; + } + return null; + }, + ), + ); + } +} diff --git a/frontend/pshared/lib/widgets/password/field.dart b/frontend/pshared/lib/widgets/password/field.dart new file mode 100644 index 0000000..5ff5e75 --- /dev/null +++ b/frontend/pshared/lib/widgets/password/field.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; + + +class PasswordField extends StatelessWidget { + const PasswordField({ + required this.controller, + required this.labelText, + required this.fieldWidth, + required this.isEnabled, + required this.validator, + }); + + final TextEditingController controller; + final String labelText; + final double fieldWidth; + final bool isEnabled; + final String? Function(String?) validator; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: fieldWidth, + child: TextFormField( + controller: controller, + obscureText: true, + enabled: isEnabled, + decoration: InputDecoration( + labelText: labelText, + border: const OutlineInputBorder(), + ), + validator: validator, + ), + ); + } +} \ No newline at end of file diff --git a/frontend/pshared/lib/widgets/password/fields.dart b/frontend/pshared/lib/widgets/password/fields.dart new file mode 100644 index 0000000..2966389 --- /dev/null +++ b/frontend/pshared/lib/widgets/password/fields.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/widgets/password/confirm_field.dart'; +import 'package:pshared/widgets/password/field.dart'; + + +class PasswordFields extends StatelessWidget { + const PasswordFields({ + super.key, + required this.oldPasswordController, + required this.newPasswordController, + required this.confirmPasswordController, + required this.oldPasswordLabel, + required this.newPasswordLabel, + required this.confirmPasswordLabel, + required this.missingPasswordError, + required this.passwordsDoNotMatchError, + required this.fieldWidth, + required this.gapSmall, + required this.isEnabled, + }); + + final TextEditingController oldPasswordController; + final TextEditingController newPasswordController; + final TextEditingController confirmPasswordController; + final String oldPasswordLabel; + final String newPasswordLabel; + final String confirmPasswordLabel; + final String missingPasswordError; + final String passwordsDoNotMatchError; + final double fieldWidth; + final double gapSmall; + final bool isEnabled; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + PasswordField( + controller: oldPasswordController, + labelText: oldPasswordLabel, + fieldWidth: fieldWidth, + isEnabled: isEnabled, + validator: (value) => + (value == null || value.isEmpty) ? missingPasswordError : null, + ), + SizedBox(height: gapSmall), + PasswordField( + controller: newPasswordController, + labelText: newPasswordLabel, + fieldWidth: fieldWidth, + isEnabled: isEnabled, + validator: (value) => + (value == null || value.isEmpty) ? missingPasswordError : null, + ), + SizedBox(height: gapSmall), + ConfirmPasswordField( + controller: confirmPasswordController, + fieldWidth: fieldWidth, + isEnabled: isEnabled, + confirmPasswordLabel: confirmPasswordLabel, + newPasswordController: newPasswordController, + missingPasswordError: missingPasswordError, + passwordsDoNotMatchError: passwordsDoNotMatchError, + ), + ], + ); + } +} \ No newline at end of file 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..4ce88f4 --- /dev/null +++ b/frontend/pweb/lib/models/edit_state.dart @@ -0,0 +1 @@ +enum EditState { view, edit, saving } \ No newline at end of file diff --git a/frontend/pweb/lib/pages/settings/profile/account/avatar.dart b/frontend/pweb/lib/pages/settings/profile/account/avatar.dart index 9d64c43..38480f4 100644 --- a/frontend/pweb/lib/pages/settings/profile/account/avatar.dart +++ b/frontend/pweb/lib/pages/settings/profile/account/avatar.dart @@ -1,9 +1,12 @@ import 'package:flutter/material.dart'; -//import 'package:provider/provider.dart'; +import 'package:provider/provider.dart'; import 'package:image_picker/image_picker.dart'; -import 'package:pweb/generated/i18n/app_localizations.dart'; + +import 'package:pshared/provider/account.dart'; + +import 'package:pweb/widgets/drawer/avatar.dart'; class AvatarTile extends StatefulWidget { @@ -28,80 +31,106 @@ class _AvatarTileState extends State { static const double _avatarSize = 96.0; static const double _iconSize = 32.0; static const double _titleSpacing = 4.0; - static const String _placeholderAsset = 'assets/images/avatar_placeholder.png'; bool _isHovering = false; + bool _isUploading = false; + String _errorText = ''; + + Future _pickImage(AccountProvider provider) async { + if (_isUploading) return; - Future _pickImage() async { final picker = ImagePicker(); final file = await picker.pickImage(source: ImageSource.gallery); - if (file != null) { - debugPrint('Selected new avatar: ${file.path}'); + if (file == null) return; + + setState(() { + _isUploading = true; + _errorText = ''; + }); + + try { + await provider.uploadAvatar(file); + } catch (_) { + if (!mounted) return; + setState(() => _errorText = widget.errorText); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(widget.errorText)), + ); + } finally { + if (mounted) { + setState(() => _isUploading = false); + } } } @override Widget build(BuildContext context) { - final loc = AppLocalizations.of(context)!; - final safeUrl = - widget.avatarUrl?.trim().isNotEmpty == true ? widget.avatarUrl : null; - final theme = Theme.of(context); + return Consumer( + builder: (context, provider, _) { + final theme = Theme.of(context); + final isBusy = _isUploading || provider.isLoading; - return Column( - children: [ - MouseRegion( - onEnter: (_) => setState(() => _isHovering = true), - onExit: (_) => setState(() => _isHovering = false), - child: GestureDetector( - onTap: _pickImage, - child: Stack( - alignment: Alignment.center, - children: [ - ClipOval( - child: safeUrl != null - ? Image.network( - safeUrl, + return Column( + children: [ + MouseRegion( + onEnter: (_) => setState(() => _isHovering = true), + onExit: (_) => setState(() => _isHovering = false), + child: GestureDetector( + onTap: isBusy ? null : () => _pickImage(provider), + child: Stack( + alignment: Alignment.center, + children: [ + AccountAvatar( + size: _avatarSize, + showHeader: false, + provider: provider, + fallbackUrl: widget.avatarUrl, + ), + if (_isHovering || _isUploading) + ClipOval( + child: Container( width: _avatarSize, height: _avatarSize, - fit: BoxFit.cover, - errorBuilder: (_, _, _) => _buildPlaceholder(), - ) - : _buildPlaceholder(), - ), - if (_isHovering) - ClipOval( - child: Container( - width: _avatarSize, - height: _avatarSize, - color: theme.colorScheme.primary.withAlpha(90), - child: Icon( - Icons.camera_alt, - color: theme.colorScheme.onSecondary, - size: _iconSize, + color: theme.colorScheme.primary.withAlpha(90), + child: _isUploading + ? SizedBox( + width: _iconSize, + height: _iconSize, + child: CircularProgressIndicator( + strokeWidth: 3, + valueColor: AlwaysStoppedAnimation(theme.colorScheme.onSecondary), + ), + ) + : Icon( + Icons.camera_alt, + color: theme.colorScheme.onSecondary, + size: _iconSize, + ), + ), ), - ), - ), - ], + ], + ), + ), ), - ), - ), - SizedBox(height: _titleSpacing), - Text( - loc.avatarHint, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSecondary, - ), - ), - ], - ); - } - - Widget _buildPlaceholder() { - return Image.asset( - _placeholderAsset, - width: _avatarSize, - height: _avatarSize, - fit: BoxFit.cover, + SizedBox(height: _titleSpacing), + Text( + widget.description, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSecondary, + ), + ), + if (_errorText.isNotEmpty) ...[ + SizedBox(height: _titleSpacing), + Text( + _errorText, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ], + ], + ); + }, ); } } diff --git a/frontend/pweb/lib/pages/settings/profile/account/name.dart b/frontend/pweb/lib/pages/settings/profile/account/name.dart deleted file mode 100644 index cf231e0..0000000 --- a/frontend/pweb/lib/pages/settings/profile/account/name.dart +++ /dev/null @@ -1,127 +0,0 @@ -import 'package:flutter/material.dart'; - - -class AccountName extends StatefulWidget { - final String name; - final String title; - final String hintText; - final String errorText; - - const AccountName({ - super.key, - required this.name, - required this.title, - required this.hintText, - required this.errorText, - }); - - @override - State createState() => _AccountNameState(); -} - -class _AccountNameState extends State { - static const double _inputWidth = 200; - static const double _spacing = 8; - static const double _errorSpacing = 4; - static const double _borderWidth = 2; - - late final TextEditingController _controller; - bool _isEditing = false; - late String _originalName; - - @override - void initState() { - super.initState(); - _controller = TextEditingController(text: widget.name); - _originalName = widget.name; - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - void _startEditing() => setState(() => _isEditing = true); - - void _cancelEditing() { - setState(() { - _controller.text = _originalName; - _isEditing = false; - }); - } - - void _saveEditing() { - setState(() { - _originalName = _controller.text; - _isEditing = false; - }); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - 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, - ), - ), - ), - ), - ) - else - Text( - _originalName, - style: theme.textTheme.headlineMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - 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/name/actions.dart b/frontend/pweb/lib/pages/settings/profile/account/name/actions.dart new file mode 100644 index 0000000..68e6dc5 --- /dev/null +++ b/frontend/pweb/lib/pages/settings/profile/account/name/actions.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pweb/providers/account_name.dart'; + + +class AccountNameActions extends StatelessWidget { + const AccountNameActions({super.key}); + + @override + Widget build(BuildContext context) { + final state = context.watch(); + final theme = Theme.of(context); + + if (state.isEditing) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: Icon(Icons.check, color: theme.colorScheme.primary), + onPressed: state.isBusy + ? null + : () async { + final wasSaved = await state.save(); + if (!context.mounted || wasSaved || state.errorText.isEmpty) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(state.errorText)), + ); + }, + ), + IconButton( + icon: Icon(Icons.close, color: theme.colorScheme.error), + onPressed: state.isBusy ? null : state.cancelEditing, + ), + ], + ); + } + + return IconButton( + icon: Icon(Icons.edit, color: theme.colorScheme.primary), + onPressed: state.isBusy ? null : state.startEditing, + ); + } +} diff --git a/frontend/pweb/lib/pages/settings/profile/account/name/name.dart b/frontend/pweb/lib/pages/settings/profile/account/name/name.dart new file mode 100644 index 0000000..4539e98 --- /dev/null +++ b/frontend/pweb/lib/pages/settings/profile/account/name/name.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/provider/account.dart'; + +import 'package:pweb/pages/settings/profile/account/name/actions.dart'; +import 'package:pweb/providers/account_name.dart'; +import 'package:pweb/pages/settings/profile/account/name/text.dart'; + + +class _AccountNameConstants { + static const inputWidth = 200.0; + static const spacing = 8.0; + static const errorSpacing = 4.0; + static const borderWidth = 2.0; +} + +class AccountName extends StatelessWidget { + final String name; + final String title; + final String hintText; + final String errorText; + + const AccountName({ + super.key, + required this.name, + required this.title, + required this.hintText, + required this.errorText, + }); + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider( + create: (ctx) => AccountNameState( + initialName: name, + errorMessage: errorText, + accountProvider: ctx.read(), + ), + child: _AccountNameBody( + hintText: hintText, + ), + ); + } +} + +class _AccountNameBody extends StatelessWidget { + const _AccountNameBody({ + required this.hintText, + }); + + final String hintText; + + @override + Widget build(BuildContext context) { + final state = context.watch(); + final provider = context.watch(); + final theme = Theme.of(context); + + final currentName = provider.account?.name ?? state.initialName; + state.syncName(currentName); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AccountNameText( + hintText: hintText, + inputWidth: _AccountNameConstants.inputWidth, + borderWidth: _AccountNameConstants.borderWidth, + ), + const SizedBox(width: _AccountNameConstants.spacing), + const AccountNameActions(), + ], + ), + const SizedBox(height: _AccountNameConstants.errorSpacing), + if (state.errorText.isNotEmpty) + Text( + state.errorText, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/settings/profile/account/name/text.dart b/frontend/pweb/lib/pages/settings/profile/account/name/text.dart new file mode 100644 index 0000000..d077d4d --- /dev/null +++ b/frontend/pweb/lib/pages/settings/profile/account/name/text.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pweb/providers/account_name.dart'; + + +class AccountNameText extends StatelessWidget { + const AccountNameText({ + super.key, + required this.hintText, + required this.inputWidth, + required this.borderWidth, + }); + + final String hintText; + final double inputWidth; + final double borderWidth; + + @override + Widget build(BuildContext context) { + final state = context.watch(); + final theme = Theme.of(context); + + if (state.isEditing) { + return SizedBox( + width: inputWidth, + child: TextFormField( + controller: state.controller, + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + autofocus: true, + enabled: !state.isBusy, + decoration: InputDecoration( + hintText: hintText, + isDense: true, + border: UnderlineInputBorder( + borderSide: BorderSide( + color: theme.colorScheme.primary, + width: borderWidth, + ), + ), + ), + ), + ); + } + + return Text( + state.currentName, + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/settings/profile/account/password/content.dart b/frontend/pweb/lib/pages/settings/profile/account/password/content.dart new file mode 100644 index 0000000..777bd43 --- /dev/null +++ b/frontend/pweb/lib/pages/settings/profile/account/password/content.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/provider/account.dart'; + +import 'package:pweb/pages/settings/profile/account/password/form/form.dart'; +import 'package:pweb/providers/password_form.dart'; + +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, + required this.newPasswordLabel, + required this.confirmPasswordLabel, + required this.savePassword, + required this.loc, + }); + + final String title; + final String successText; + final String errorText; + final String oldPasswordLabel; + final String newPasswordLabel; + final String confirmPasswordLabel; + final String savePassword; + final AppLocalizations loc; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Consumer2( + builder: (context, accountProvider, formProvider, _) { + final isBusy = accountProvider.isLoading || formProvider.isSaving; + + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + TextButton.icon( + onPressed: isBusy ? null : formProvider.toggleExpanded, + icon: Icon(Icons.lock_outline, color: theme.colorScheme.primary), + label: Text(title, style: theme.textTheme.bodyMedium), + ), + if (formProvider.isExpanded) + PasswordForm( + formProvider: formProvider, + accountProvider: accountProvider, + isBusy: accountProvider.isLoading, + oldPasswordLabel: oldPasswordLabel, + newPasswordLabel: newPasswordLabel, + confirmPasswordLabel: confirmPasswordLabel, + savePassword: savePassword, + successText: successText, + errorText: errorText, + loc: loc, + ), + ], + ); + }, + ); + } +} diff --git a/frontend/pweb/lib/pages/settings/profile/account/password/form/error_text.dart b/frontend/pweb/lib/pages/settings/profile/account/password/form/error_text.dart new file mode 100644 index 0000000..8d20f1b --- /dev/null +++ b/frontend/pweb/lib/pages/settings/profile/account/password/form/error_text.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + + +class PasswordErrorText extends StatelessWidget { + const PasswordErrorText({ + super.key, + required this.errorText, + required this.gapSmall, + }); + + final String errorText; + final double gapSmall; + + @override + Widget build(BuildContext context) { + if (errorText.isEmpty) return const SizedBox.shrink(); + + final theme = Theme.of(context); + + return Column( + children: [ + 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/form/form.dart b/frontend/pweb/lib/pages/settings/profile/account/password/form/form.dart new file mode 100644 index 0000000..a6ef7eb --- /dev/null +++ b/frontend/pweb/lib/pages/settings/profile/account/password/form/form.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/provider/account.dart'; +import 'package:pshared/widgets/password/fields.dart'; +import 'package:pshared/utils/snackbar.dart'; + +import 'package:pweb/providers/password_form.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/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 isFormBusy = isBusy || formProvider.isSaving; + + return Column( + children: [ + const SizedBox(height: _gapMedium), + Form( + 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, + fieldWidth: _fieldWidth, + gapSmall: _gapSmall, + isEnabled: !isFormBusy, + ), + const SizedBox(height: _gapMedium), + PasswordSubmitButton( + isBusy: isFormBusy, + label: savePassword, + onSubmit: () async { + try { + await formProvider.submit( + accountProvider: accountProvider, + errorText: errorText, + ); + if (!context.mounted) return; + notifyUser(context, successText); + } catch (e) { + if (!context.mounted) return; + await postNotifyUserOfErrorX( + context: context, + errorSituation: errorText, + exception: e, + ); + } + }, + ), + PasswordErrorText( + errorText: formProvider.errorText, + gapSmall: _gapSmall, + ), + ], + ), + ), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/settings/profile/account/password/form/submit_button.dart b/frontend/pweb/lib/pages/settings/profile/account/password/form/submit_button.dart new file mode 100644 index 0000000..5761e33 --- /dev/null +++ b/frontend/pweb/lib/pages/settings/profile/account/password/form/submit_button.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; + + +class PasswordSubmitButton extends StatelessWidget { + const PasswordSubmitButton({ + super.key, + required this.isBusy, + required this.onSubmit, + required this.label, + }); + + final bool isBusy; + final VoidCallback onSubmit; + final String label; + + @override + Widget build(BuildContext context) { + return ElevatedButton.icon( + onPressed: isBusy ? null : onSubmit, + icon: const Icon(Icons.save_outlined), + label: Text(label), + ); + } +} 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..a898a2d --- /dev/null +++ b/frontend/pweb/lib/pages/settings/profile/account/password/password.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pweb/pages/settings/profile/account/password/content.dart'; +import 'package:pweb/providers/password_form.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class AccountPassword extends StatelessWidget { + 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 + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + + return ChangeNotifierProvider( + create: (_) => PasswordFormProvider(), + child: AccountPasswordContent( + title: title, + successText: successText, + errorText: errorText, + oldPasswordLabel: oldPasswordLabel, + newPasswordLabel: newPasswordLabel, + confirmPasswordLabel: confirmPasswordLabel, + savePassword: savePassword, + loc: loc, + ), + ); + } +} \ No newline at end of file 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..f628ee9 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/name/name.dart'; +import 'package:pweb/pages/settings/profile/account/password/password.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -18,34 +23,51 @@ 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, + ); + final accountAvatarUrl = context.select( + (provider) => provider.account?.avatarUrl, + ); - 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: accountAvatarUrl, + 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..708d472 100644 --- a/frontend/pweb/lib/pages/settings/widgets/base.dart +++ b/frontend/pweb/lib/pages/settings/widgets/base.dart @@ -1,13 +1,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_settings_ui/flutter_settings_ui.dart'; -import 'package:pweb/generated/i18n/app_localizations.dart'; +import 'package:provider/provider.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, @@ -16,6 +17,7 @@ abstract class BaseEditTile extends AbstractSettingsTile { required this.valueGetter, required this.valueSetter, required this.errorSituation, + this.editStateNotifier, }); final IconData icon; @@ -23,12 +25,10 @@ abstract class BaseEditTile extends AbstractSettingsTile { final ValueGetter valueGetter; final Future Function(T) valueSetter; final String errorSituation; + final ValueNotifier? editStateNotifier; - /// Рисует в режиме просмотра (read-only). Widget buildView(BuildContext context, T? value); - /// Рисует UI редактора. - /// Если [useDialogEditor]==true, его обернут в диалог. Widget buildEditor( BuildContext context, T? initial, @@ -37,7 +37,6 @@ abstract class BaseEditTile extends AbstractSettingsTile { bool isSaving, ); - /// true → показывать редактор в диалоге, false → inline под заголовком. bool get useDialogEditor => false; @override @@ -52,16 +51,35 @@ class _BaseEditTileBody extends StatefulWidget { } class _BaseEditTileBodyState extends State<_BaseEditTileBody> { - _EditState _state = _EditState.view; - bool get _isSaving => _state == _EditState.saving; + late final ValueNotifier _stateNotifier; + late final bool _ownsNotifier; + + bool get _isSaving => _stateNotifier.value == EditState.saving; + + @override + void initState() { + super.initState(); + final providedNotifier = widget.delegate.editStateNotifier ?? + Provider.of?>(context, listen: false); + _ownsNotifier = providedNotifier == null; + _stateNotifier = providedNotifier ?? ValueNotifier(EditState.view); + } + + @override + void dispose() { + if (_ownsNotifier) { + _stateNotifier.dispose(); + } + super.dispose(); + } Future _performSave(T newValue) async { final current = widget.delegate.valueGetter(); if (newValue == current) { - setState(() => _state = _EditState.view); + _stateNotifier.value = EditState.view; return; } - setState(() => _state = _EditState.saving); + _stateNotifier.value = EditState.saving; final sms = ScaffoldMessenger.of(context); final locs = AppLocalizations.of(context)!; try { @@ -78,7 +96,7 @@ class _BaseEditTileBodyState extends State<_BaseEditTileBody> { exception: e, ); } finally { - if (mounted) setState(() => _state = _EditState.view); + if (mounted) _stateNotifier.value = EditState.view; } } @@ -110,33 +128,45 @@ class _BaseEditTileBodyState extends State<_BaseEditTileBody> { @override Widget build(BuildContext context) { final delegate = widget.delegate; - final current = delegate.valueGetter(); - // Диалоговый режим if (delegate.useDialogEditor) { - return SettingsTile.navigation( - leading: Icon(delegate.icon), - title: Text(delegate.title), - value: delegate.buildView(context, current), - onPressed: (_) => _openDialogEditor(), + return ValueListenableBuilder( + valueListenable: _stateNotifier, + builder: (context, state, _) { + final current = delegate.valueGetter(); + return SettingsTile.navigation( + leading: Icon(delegate.icon), + title: Text(delegate.title), + value: delegate.buildView(context, current), + onPressed: state == EditState.saving ? null : (_) => _openDialogEditor(), + ); + }, ); } - // Inline-режим (под заголовком будет редактор прямо в tile) - return SettingsTile.navigation( - leading: Icon(delegate.icon), - title: Text(delegate.title), - value: _state == _EditState.view - ? delegate.buildView(context, current) - : delegate.buildEditor( - context, - current, - _performSave, - () => setState(() => _state = _EditState.view), - _isSaving, - ), - onPressed: (_) { - if (_state == _EditState.view) setState(() => _state = _EditState.edit); + return ValueListenableBuilder( + valueListenable: _stateNotifier, + builder: (context, state, _) { + final current = delegate.valueGetter(); + final isView = state == EditState.view; + final isSaving = state == EditState.saving; + + return SettingsTile.navigation( + leading: Icon(delegate.icon), + title: Text(delegate.title), + value: isView + ? delegate.buildView(context, current) + : delegate.buildEditor( + context, + current, + _performSave, + () => _stateNotifier.value = EditState.view, + isSaving, + ), + onPressed: (_) { + if (isView) _stateNotifier.value = EditState.edit; + }, + ); }, ); } diff --git a/frontend/pweb/lib/providers/account_name.dart b/frontend/pweb/lib/providers/account_name.dart new file mode 100644 index 0000000..4084f6c --- /dev/null +++ b/frontend/pweb/lib/providers/account_name.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/provider/account.dart'; + +import 'package:pweb/models/edit_state.dart'; + + +class AccountNameState extends ChangeNotifier { + AccountNameState({ + required this.initialName, + required this.errorMessage, + required AccountProvider accountProvider, + }) : _accountProvider = accountProvider { + _controller = TextEditingController(text: initialName); + } + + final AccountProvider _accountProvider; + final String initialName; + final String errorMessage; + + late final TextEditingController _controller; + EditState _editState = EditState.view; + String _errorText = ''; + bool _disposed = false; + + TextEditingController get controller => _controller; + EditState get editState => _editState; + String get errorText => _errorText; + bool get isEditing => _editState != EditState.view; + bool get isSaving => _editState == EditState.saving; + bool get isBusy => _accountProvider.isLoading || isSaving; + String get currentName => _accountProvider.account?.name ?? initialName; + + void startEditing() => _setState(EditState.edit); + + void cancelEditing() { + _controller.text = currentName; + _setError(''); + _setState(EditState.view); + } + + void syncName(String latestName) { + if (isEditing) return; + if (_controller.text != latestName) { + _controller.text = latestName; + } + } + + Future save() async { + final newName = _controller.text.trim(); + final current = currentName; + + if (newName.isEmpty || newName == current) { + cancelEditing(); + return false; + } + + _setError(''); + _setState(EditState.saving); + + try { + await _accountProvider.resetUsername(newName); + _setState(EditState.view); + return true; + } catch (_) { + _setError(errorMessage); + _setState(EditState.edit); + return false; + } finally { + if (_editState == EditState.saving) { + _setState(EditState.edit); + } + } + } + + void _setState(EditState value) { + if (_disposed || _editState == value) return; + _editState = value; + notifyListeners(); + } + + void _setError(String value) { + if (_disposed) return; + _errorText = value; + notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + _controller.dispose(); + super.dispose(); + } +} diff --git a/frontend/pweb/lib/providers/password_form.dart b/frontend/pweb/lib/providers/password_form.dart new file mode 100644 index 0000000..30ab2c0 --- /dev/null +++ b/frontend/pweb/lib/providers/password_form.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/provider/account.dart'; + +import 'package:pweb/models/edit_state.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 AccountProvider accountProvider, + 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(); + } catch (e) { + _setError(errorText); + rethrow; + } 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/drawer/avatar.dart b/frontend/pweb/lib/widgets/drawer/avatar.dart index 632ea53..7cb65ef 100644 --- a/frontend/pweb/lib/widgets/drawer/avatar.dart +++ b/frontend/pweb/lib/widgets/drawer/avatar.dart @@ -10,24 +10,49 @@ import 'package:pweb/generated/i18n/app_localizations.dart'; class AccountAvatar extends StatelessWidget { - const AccountAvatar({super.key}); + final double? size; + final bool showHeader; + final String? fallbackUrl; + final AccountProvider? provider; + + const AccountAvatar({ + super.key, + this.size, + this.showHeader = true, + this.fallbackUrl, + this.provider, + }); @override Widget build(BuildContext context) { - final loc = AppLocalizations.of(context)!; + if (provider != null) { + return _buildAvatar(context, provider!); + } + return Consumer( - builder: (context, provider, _) => UserAccountsDrawerHeader( - accountName: Text(provider.account?.name ?? loc.userNamePlaceholder), - accountEmail: Text(provider.account?.login ?? loc.usernameHint), - currentAccountPicture: CircleAvatar( - backgroundImage: (provider.account?.avatarUrl?.isNotEmpty ?? false) - ? CachedNetworkImageProvider(provider.account!.avatarUrl!) - : null, - child: (provider.account?.avatarUrl?.isNotEmpty ?? false) - ? null - : const Icon(Icons.account_circle, size: 50), - ), - ), + builder: (context, provider, _) => _buildAvatar(context, provider), + ); + } + + Widget _buildAvatar(BuildContext context, AccountProvider provider) { + final avatarUrl = (provider.account?.avatarUrl ?? fallbackUrl)?.trim(); + final hasAvatar = avatarUrl?.isNotEmpty == true; + final radius = size != null ? size! / 2 : null; + final double placeholderIconSize = size != null ? size! * 0.55 : 50; + + final avatar = CircleAvatar( + radius: radius, + backgroundImage: hasAvatar ? CachedNetworkImageProvider(avatarUrl!) : null, + child: hasAvatar ? null : Icon(Icons.account_circle, size: placeholderIconSize), + ); + + if (!showHeader) return avatar; + + final loc = AppLocalizations.of(context)!; + return UserAccountsDrawerHeader( + accountName: Text(provider.account?.describable.name ?? loc.userNamePlaceholder), + accountEmail: Text(provider.account?.login ?? loc.usernameHint), + currentAccountPicture: avatar, ); } } 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,