Fixes for Settings Page

This commit is contained in:
Arseni
2025-12-22 21:09:58 +03:00
parent 41abf723e6
commit 47ada0691c
25 changed files with 1126 additions and 270 deletions

View File

@@ -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<String, dynamic> json) => _$ResetUserNameRequestFromJson(json);
Map<String, dynamic> toJson() => _$ResetUserNameRequestToJson(this);
static ResetUserNameRequest build({
required String userName,
}) => ResetUserNameRequest(userName: userName);
}

View File

@@ -228,6 +228,13 @@ class AccountProvider extends ChangeNotifier {
} }
} }
Future<Account?> resetUsername(String userName) async {
if (account == null) throw ErrorUnauthorized();
return update(
describable: account!.describable.copyWith(name: userName),
);
}
Future<void> forgotPassword(String email) async { Future<void> forgotPassword(String email) async {
_setResource(_resource.copyWith(isLoading: true, error: null)); _setResource(_resource.copyWith(isLoading: true, error: null));
try { try {

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,13 @@
"usernameErrorInvalid": "Provide a valid email address", "usernameErrorInvalid": "Provide a valid email address",
"usernameUnknownTLD": "Domain .{domain} is not known, please, check it", "usernameUnknownTLD": "Domain .{domain} is not known, please, check it",
"password": "Password", "password": "Password",
"oldPassword": "Current password",
"newPassword": "New password",
"confirmPassword": "Confirm password", "confirmPassword": "Confirm password",
"changePassword": "Change password",
"savePassword": "Save changed password",
"changePasswordSuccess": "Password updated",
"changePasswordError": "Could not update password",
"passwordValidationRuleDigit": "has digit", "passwordValidationRuleDigit": "has digit",
"passwordValidationRuleUpperCase": "has uppercase letter", "passwordValidationRuleUpperCase": "has uppercase letter",
"passwordValidationRuleLowerCase": "has lowercase letter", "passwordValidationRuleLowerCase": "has lowercase letter",

View File

@@ -9,7 +9,13 @@
"usernameErrorInvalid": "Укажите действительный адрес электронной почты", "usernameErrorInvalid": "Укажите действительный адрес электронной почты",
"usernameUnknownTLD": "Домен .{domain} неизвестен, пожалуйста, проверьте его", "usernameUnknownTLD": "Домен .{domain} неизвестен, пожалуйста, проверьте его",
"password": "Пароль", "password": "Пароль",
"oldPassword": "Текущий пароль",
"newPassword": "Новый пароль",
"confirmPassword": "Подтвердите пароль", "confirmPassword": "Подтвердите пароль",
"changePassword": "Изменить пароль",
"savePassword": "Сохранить пароль",
"changePasswordSuccess": "Пароль обновлен",
"changePasswordError": "Не удалось обновить пароль",
"passwordValidationRuleDigit": "содержит цифру", "passwordValidationRuleDigit": "содержит цифру",
"passwordValidationRuleUpperCase": "содержит заглавную букву", "passwordValidationRuleUpperCase": "содержит заглавную букву",
"passwordValidationRuleLowerCase": "содержит строчную букву", "passwordValidationRuleLowerCase": "содержит строчную букву",

View File

@@ -0,0 +1 @@
enum EditState { view, edit, saving }

View File

@@ -1,9 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
//import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:image_picker/image_picker.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 { class AvatarTile extends StatefulWidget {
@@ -28,80 +31,106 @@ class _AvatarTileState extends State<AvatarTile> {
static const double _avatarSize = 96.0; static const double _avatarSize = 96.0;
static const double _iconSize = 32.0; static const double _iconSize = 32.0;
static const double _titleSpacing = 4.0; static const double _titleSpacing = 4.0;
static const String _placeholderAsset = 'assets/images/avatar_placeholder.png';
bool _isHovering = false; bool _isHovering = false;
bool _isUploading = false;
String _errorText = '';
Future<void> _pickImage(AccountProvider provider) async {
if (_isUploading) return;
Future<void> _pickImage() async {
final picker = ImagePicker(); final picker = ImagePicker();
final file = await picker.pickImage(source: ImageSource.gallery); final file = await picker.pickImage(source: ImageSource.gallery);
if (file != null) { if (file == null) return;
debugPrint('Selected new avatar: ${file.path}');
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!; return Consumer<AccountProvider>(
final safeUrl = builder: (context, provider, _) {
widget.avatarUrl?.trim().isNotEmpty == true ? widget.avatarUrl : null; final theme = Theme.of(context);
final theme = Theme.of(context); final isBusy = _isUploading || provider.isLoading;
return Column( return Column(
children: [ children: [
MouseRegion( MouseRegion(
onEnter: (_) => setState(() => _isHovering = true), onEnter: (_) => setState(() => _isHovering = true),
onExit: (_) => setState(() => _isHovering = false), onExit: (_) => setState(() => _isHovering = false),
child: GestureDetector( child: GestureDetector(
onTap: _pickImage, onTap: isBusy ? null : () => _pickImage(provider),
child: Stack( child: Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
ClipOval( AccountAvatar(
child: safeUrl != null size: _avatarSize,
? Image.network( showHeader: false,
safeUrl, provider: provider,
fallbackUrl: widget.avatarUrl,
),
if (_isHovering || _isUploading)
ClipOval(
child: Container(
width: _avatarSize, width: _avatarSize,
height: _avatarSize, height: _avatarSize,
fit: BoxFit.cover, color: theme.colorScheme.primary.withAlpha(90),
errorBuilder: (_, _, _) => _buildPlaceholder(), child: _isUploading
) ? SizedBox(
: _buildPlaceholder(), width: _iconSize,
), height: _iconSize,
if (_isHovering) child: CircularProgressIndicator(
ClipOval( strokeWidth: 3,
child: Container( valueColor: AlwaysStoppedAnimation(theme.colorScheme.onSecondary),
width: _avatarSize, ),
height: _avatarSize, )
color: theme.colorScheme.primary.withAlpha(90), : Icon(
child: Icon( Icons.camera_alt,
Icons.camera_alt, color: theme.colorScheme.onSecondary,
color: theme.colorScheme.onSecondary, size: _iconSize,
size: _iconSize, ),
),
), ),
), ],
), ),
], ),
), ),
), SizedBox(height: _titleSpacing),
), Text(
SizedBox(height: _titleSpacing), widget.description,
Text( style: theme.textTheme.bodySmall?.copyWith(
loc.avatarHint, color: theme.colorScheme.onSecondary,
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,
Widget _buildPlaceholder() { ),
return Image.asset( ),
_placeholderAsset, ],
width: _avatarSize, ],
height: _avatarSize, );
fit: BoxFit.cover, },
); );
} }
} }

View File

@@ -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<AccountName> createState() => _AccountNameState();
}
class _AccountNameState extends State<AccountName> {
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,
),
),
],
);
}
}

View File

@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:pweb/pages/settings/profile/account/name/state.dart';
class AccountNameActions extends StatelessWidget {
const AccountNameActions({
super.key,
required this.state,
});
final AccountNameState state;
@override
Widget build(BuildContext context) {
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,
);
}
}

View File

@@ -0,0 +1,93 @@
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/pages/settings/profile/account/name/state.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<AccountProvider>(),
),
child: _AccountNameBody(
hintText: hintText,
),
);
}
}
class _AccountNameBody extends StatelessWidget {
const _AccountNameBody({
required this.hintText,
});
final String hintText;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Consumer2<AccountNameState, AccountProvider>(
builder: (context, state, provider, _) {
final currentName = provider.account?.name ?? state.initialName;
state.syncName(currentName);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AccountNameText(
state: state,
hintText: hintText,
inputWidth: _AccountNameConstants.inputWidth,
borderWidth: _AccountNameConstants.borderWidth,
),
const SizedBox(width: _AccountNameConstants.spacing),
AccountNameActions(state: state),
],
),
const SizedBox(height: _AccountNameConstants.errorSpacing),
if (state.errorText.isNotEmpty)
Text(
state.errorText,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
],
);
},
);
}
}

View File

@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:pweb/pages/settings/profile/account/name/state.dart';
class AccountNameText extends StatelessWidget {
const AccountNameText({
super.key,
required this.state,
required this.hintText,
required this.inputWidth,
required this.borderWidth,
});
final AccountNameState state;
final String hintText;
final double inputWidth;
final double borderWidth;
@override
Widget build(BuildContext context) {
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,
),
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,13 @@
import 'package:flutter/material.dart'; 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/avatar.dart';
import 'package:pweb/pages/settings/profile/account/locale.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'; import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -18,34 +23,51 @@ class ProfileSettingsPage extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!; final loc = AppLocalizations.of(context)!;
final theme = Theme.of(context); final theme = Theme.of(context);
final accountName = context.select<AccountProvider, String?>(
(provider) => provider.account?.describable.name,
);
final accountAvatarUrl = context.select<AccountProvider, String?>(
(provider) => provider.account?.avatarUrl,
);
return Material( return Align(
elevation: 4, alignment: Alignment.topCenter,
borderRadius: BorderRadius.circular(_cardRadius), child: Material(
clipBehavior: Clip.antiAlias, elevation: 4,
color: theme.colorScheme.onSecondary, borderRadius: BorderRadius.circular(_cardRadius),
child: Padding( color: theme.colorScheme.onSecondary,
padding: _cardPadding, child: Padding(
child: Column( padding: _cardPadding,
mainAxisSize: MainAxisSize.min, child: Column(
spacing: _itemSpacing, mainAxisSize: MainAxisSize.min,
children: [ spacing: _itemSpacing,
AvatarTile( children: [
avatarUrl: 'https://avatars.githubusercontent.com/u/65651201', AvatarTile(
title: loc.avatar, avatarUrl: accountAvatarUrl,
description: loc.avatarHint, title: loc.avatar,
errorText: loc.avatarUpdateError, description: loc.avatarHint,
), errorText: loc.avatarUpdateError,
AccountName( ),
name: loc.userNamePlaceholder, AccountName(
title: loc.accountName, name: accountName ?? loc.userNamePlaceholder,
hintText: loc.accountNameHint, title: loc.accountName,
errorText: loc.accountNameUpdateError, hintText: loc.accountNameHint,
), errorText: loc.accountNameUpdateError,
LocalePicker( ),
title: loc.language, 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,
),
],
),
), ),
), ),
); );

View File

@@ -1,13 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_settings_ui/flutter_settings_ui.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'; 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<T> extends AbstractSettingsTile { abstract class BaseEditTile<T> extends AbstractSettingsTile {
const BaseEditTile({ const BaseEditTile({
super.key, super.key,
@@ -16,6 +17,7 @@ abstract class BaseEditTile<T> extends AbstractSettingsTile {
required this.valueGetter, required this.valueGetter,
required this.valueSetter, required this.valueSetter,
required this.errorSituation, required this.errorSituation,
this.editStateNotifier,
}); });
final IconData icon; final IconData icon;
@@ -23,12 +25,10 @@ abstract class BaseEditTile<T> extends AbstractSettingsTile {
final ValueGetter<T?> valueGetter; final ValueGetter<T?> valueGetter;
final Future<void> Function(T) valueSetter; final Future<void> Function(T) valueSetter;
final String errorSituation; final String errorSituation;
final ValueNotifier<EditState>? editStateNotifier;
/// Рисует в режиме просмотра (read-only).
Widget buildView(BuildContext context, T? value); Widget buildView(BuildContext context, T? value);
/// Рисует UI редактора.
/// Если [useDialogEditor]==true, его обернут в диалог.
Widget buildEditor( Widget buildEditor(
BuildContext context, BuildContext context,
T? initial, T? initial,
@@ -37,7 +37,6 @@ abstract class BaseEditTile<T> extends AbstractSettingsTile {
bool isSaving, bool isSaving,
); );
/// true → показывать редактор в диалоге, false → inline под заголовком.
bool get useDialogEditor => false; bool get useDialogEditor => false;
@override @override
@@ -52,16 +51,35 @@ class _BaseEditTileBody<T> extends StatefulWidget {
} }
class _BaseEditTileBodyState<T> extends State<_BaseEditTileBody<T>> { class _BaseEditTileBodyState<T> extends State<_BaseEditTileBody<T>> {
_EditState _state = _EditState.view; late final ValueNotifier<EditState> _stateNotifier;
bool get _isSaving => _state == _EditState.saving; late final bool _ownsNotifier;
bool get _isSaving => _stateNotifier.value == EditState.saving;
@override
void initState() {
super.initState();
final providedNotifier = widget.delegate.editStateNotifier ??
Provider.of<ValueNotifier<EditState>?>(context, listen: false);
_ownsNotifier = providedNotifier == null;
_stateNotifier = providedNotifier ?? ValueNotifier(EditState.view);
}
@override
void dispose() {
if (_ownsNotifier) {
_stateNotifier.dispose();
}
super.dispose();
}
Future<void> _performSave(T newValue) async { Future<void> _performSave(T newValue) async {
final current = widget.delegate.valueGetter(); final current = widget.delegate.valueGetter();
if (newValue == current) { if (newValue == current) {
setState(() => _state = _EditState.view); _stateNotifier.value = EditState.view;
return; return;
} }
setState(() => _state = _EditState.saving); _stateNotifier.value = EditState.saving;
final sms = ScaffoldMessenger.of(context); final sms = ScaffoldMessenger.of(context);
final locs = AppLocalizations.of(context)!; final locs = AppLocalizations.of(context)!;
try { try {
@@ -78,7 +96,7 @@ class _BaseEditTileBodyState<T> extends State<_BaseEditTileBody<T>> {
exception: e, exception: e,
); );
} finally { } finally {
if (mounted) setState(() => _state = _EditState.view); if (mounted) _stateNotifier.value = EditState.view;
} }
} }
@@ -110,33 +128,45 @@ class _BaseEditTileBodyState<T> extends State<_BaseEditTileBody<T>> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final delegate = widget.delegate; final delegate = widget.delegate;
final current = delegate.valueGetter();
// Диалоговый режим
if (delegate.useDialogEditor) { if (delegate.useDialogEditor) {
return SettingsTile.navigation( return ValueListenableBuilder<EditState>(
leading: Icon(delegate.icon), valueListenable: _stateNotifier,
title: Text(delegate.title), builder: (context, state, _) {
value: delegate.buildView(context, current), final current = delegate.valueGetter();
onPressed: (_) => _openDialogEditor(), 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 ValueListenableBuilder<EditState>(
return SettingsTile.navigation( valueListenable: _stateNotifier,
leading: Icon(delegate.icon), builder: (context, state, _) {
title: Text(delegate.title), final current = delegate.valueGetter();
value: _state == _EditState.view final isView = state == EditState.view;
? delegate.buildView(context, current) final isSaving = state == EditState.saving;
: delegate.buildEditor(
context, return SettingsTile.navigation(
current, leading: Icon(delegate.icon),
_performSave, title: Text(delegate.title),
() => setState(() => _state = _EditState.view), value: isView
_isSaving, ? delegate.buildView(context, current)
), : delegate.buildEditor(
onPressed: (_) { context,
if (_state == _EditState.view) setState(() => _state = _EditState.edit); current,
_performSave,
() => _stateNotifier.value = EditState.view,
isSaving,
),
onPressed: (_) {
if (isView) _stateNotifier.value = EditState.edit;
},
);
}, },
); );
} }

View File

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

View File

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

View File

@@ -10,24 +10,49 @@ import 'package:pweb/generated/i18n/app_localizations.dart';
class AccountAvatar extends StatelessWidget { 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!; if (provider != null) {
return _buildAvatar(context, provider!);
}
return Consumer<AccountProvider>( return Consumer<AccountProvider>(
builder: (context, provider, _) => UserAccountsDrawerHeader( builder: (context, provider, _) => _buildAvatar(context, provider),
accountName: Text(provider.account?.name ?? loc.userNamePlaceholder), );
accountEmail: Text(provider.account?.login ?? loc.usernameHint), }
currentAccountPicture: CircleAvatar(
backgroundImage: (provider.account?.avatarUrl?.isNotEmpty ?? false) Widget _buildAvatar(BuildContext context, AccountProvider provider) {
? CachedNetworkImageProvider(provider.account!.avatarUrl!) final avatarUrl = (provider.account?.avatarUrl ?? fallbackUrl)?.trim();
: null, final hasAvatar = avatarUrl?.isNotEmpty == true;
child: (provider.account?.avatarUrl?.isNotEmpty ?? false) final radius = size != null ? size! / 2 : null;
? null final double placeholderIconSize = size != null ? size! * 0.55 : 50;
: const Icon(Icons.account_circle, size: 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,
); );
} }
} }

View File

@@ -1,5 +1,9 @@
import 'package:flutter/material.dart'; 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/destinations.dart';
import 'package:pweb/widgets/sidebar/side_menu.dart'; import 'package:pweb/widgets/sidebar/side_menu.dart';
import 'package:pweb/widgets/sidebar/user.dart'; import 'package:pweb/widgets/sidebar/user.dart';
@@ -18,7 +22,7 @@ class PayoutSidebar extends StatelessWidget {
final PayoutDestination selected; final PayoutDestination selected;
final ValueChanged<PayoutDestination> onSelected; final ValueChanged<PayoutDestination> onSelected;
final VoidCallback? onLogout; final Future<void> Function()? onLogout;
final String? userName; final String? userName;
final String? avatarUrl; final String? avatarUrl;
@@ -27,6 +31,15 @@ class PayoutSidebar extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final accountName = context.select<AccountProvider, String?>(
(provider) => provider.account?.describable.name,
);
final accountAvatar = context.select<AccountProvider, String?>(
(provider) => provider.account?.avatarUrl,
);
final resolvedUserName = userName ?? accountName;
final resolvedAvatarUrl = avatarUrl ?? accountAvatar;
final menuItems = items ?? final menuItems = items ??
<PayoutDestination>[ <PayoutDestination>[
PayoutDestination.dashboard, PayoutDestination.dashboard,
@@ -42,16 +55,16 @@ class PayoutSidebar extends StatelessWidget {
children: [ children: [
UserProfileCard( UserProfileCard(
theme: theme, theme: theme,
avatarUrl: avatarUrl, avatarUrl: resolvedAvatarUrl,
userName: userName, userName: resolvedUserName,
selected: selected, selected: selected,
onSelected: onSelected onSelected: onSelected
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
SideMenuColumn( SideMenuColumn(
theme: theme, theme: theme,
avatarUrl: avatarUrl, avatarUrl: resolvedAvatarUrl,
userName: userName, userName: resolvedUserName,
items: menuItems, items: menuItems,
selected: selected, selected: selected,
onSelected: onSelected, onSelected: onSelected,