Merge pull request 'Fixes for Settings Page' (#123) from SEND011 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful

Reviewed-on: #123
Reviewed-by: tech <tech.sendico@proton.me>
This commit was merged in pull request #123.
This commit is contained in:
2025-12-22 19:26:44 +00:00
25 changed files with 1122 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 {
_setResource(_resource.copyWith(isLoading: true, error: null));
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",
"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",

View File

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

View File

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

View File

@@ -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<AvatarTile> {
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<void> _pickImage(AccountProvider provider) async {
if (_isUploading) return;
Future<void> _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<AccountProvider>(
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,
),
),
],
],
);
},
);
}
}

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,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<AccountNameState>();
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,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<AccountProvider>(),
),
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<AccountNameState>();
final provider = context.watch<AccountProvider>();
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,
),
),
],
);
}
}

View File

@@ -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<AccountNameState>();
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: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<AccountProvider, String?>(
(provider) => provider.account?.describable.name,
);
final accountAvatarUrl = context.select<AccountProvider, String?>(
(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,
),
],
),
),
),
);

View File

@@ -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<T> extends AbstractSettingsTile {
const BaseEditTile({
super.key,
@@ -16,6 +17,7 @@ abstract class BaseEditTile<T> extends AbstractSettingsTile {
required this.valueGetter,
required this.valueSetter,
required this.errorSituation,
this.editStateNotifier,
});
final IconData icon;
@@ -23,12 +25,10 @@ abstract class BaseEditTile<T> extends AbstractSettingsTile {
final ValueGetter<T?> valueGetter;
final Future<void> Function(T) valueSetter;
final String errorSituation;
final ValueNotifier<EditState>? 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<T> extends AbstractSettingsTile {
bool isSaving,
);
/// true → показывать редактор в диалоге, false → inline под заголовком.
bool get useDialogEditor => false;
@override
@@ -52,16 +51,35 @@ class _BaseEditTileBody<T> extends StatefulWidget {
}
class _BaseEditTileBodyState<T> extends State<_BaseEditTileBody<T>> {
_EditState _state = _EditState.view;
bool get _isSaving => _state == _EditState.saving;
late final ValueNotifier<EditState> _stateNotifier;
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 {
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<T> extends State<_BaseEditTileBody<T>> {
exception: e,
);
} 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
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<EditState>(
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<EditState>(
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;
},
);
},
);
}

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 {
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<AccountProvider>(
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,
);
}
}

View File

@@ -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<PayoutDestination> onSelected;
final VoidCallback? onLogout;
final Future<void> 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<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 ??
<PayoutDestination>[
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,