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

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