Merge pull request 'Added Last Name display and made it editable' (#145) from SEND015 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful

Reviewed-on: #145
This commit was merged in pull request #145.
This commit is contained in:
2025-12-24 16:08:40 +00:00
8 changed files with 118 additions and 43 deletions

View File

@@ -20,6 +20,14 @@ class AccountBase implements StorableDescribable {
DateTime get updatedAt => storable.updatedAt; DateTime get updatedAt => storable.updatedAt;
@override @override
String get name => describable.name; String get name => describable.name;
String get fullName {
final first = describable.name.trim();
final last = lastName.trim();
if (last.isEmpty) return first;
if (first.isEmpty) return last;
return '$first $last';
}
@override @override
String? get description => describable.description; String? get description => describable.description;
@@ -32,7 +40,7 @@ class AccountBase implements StorableDescribable {
required this.lastName, required this.lastName,
}); });
String get nameInitials => getNameInitials(describable.name); String get nameInitials => getNameInitials(fullName);
AccountBase copyWith({ AccountBase copyWith({
Describable? describable, Describable? describable,

View File

@@ -203,6 +203,7 @@ class AccountProvider extends ChangeNotifier {
Future<Account?> update({ Future<Account?> update({
Describable? describable, Describable? describable,
String? lastName,
String? locale, String? locale,
String? avatarUrl, String? avatarUrl,
String? notificationFrequency, String? notificationFrequency,
@@ -213,6 +214,7 @@ class AccountProvider extends ChangeNotifier {
final updated = await AccountService.update( final updated = await AccountService.update(
account!.copyWith( account!.copyWith(
describable: describable, describable: describable,
lastName: lastName,
avatarUrl: () => avatarUrl ?? account!.avatarUrl, avatarUrl: () => avatarUrl ?? account!.avatarUrl,
locale: locale ?? account!.locale, locale: locale ?? account!.locale,
), ),
@@ -250,10 +252,11 @@ class AccountProvider extends ChangeNotifier {
} }
} }
Future<Account?> resetUsername(String userName) async { Future<Account?> resetUsername(String userName, {String? lastName}) async {
if (account == null) throw ErrorUnauthorized(); if (account == null) throw ErrorUnauthorized();
return update( return update(
describable: account!.describable.copyWith(name: userName), describable: account!.describable.copyWith(name: userName),
lastName: lastName ?? account!.lastName,
); );
} }

View File

@@ -17,16 +17,20 @@ class _AccountNameConstants {
} }
class AccountName extends StatelessWidget { class AccountName extends StatelessWidget {
final String name; final String firstName;
final String lastName;
final String title; final String title;
final String hintText; final String hintText;
final String lastNameHint;
final String errorText; final String errorText;
const AccountName({ const AccountName({
super.key, super.key,
required this.name, required this.firstName,
required this.lastName,
required this.title, required this.title,
required this.hintText, required this.hintText,
required this.lastNameHint,
required this.errorText, required this.errorText,
}); });
@@ -34,12 +38,14 @@ class AccountName extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ChangeNotifierProvider( return ChangeNotifierProvider(
create: (ctx) => AccountNameState( create: (ctx) => AccountNameState(
initialName: name, initialFirstName: firstName,
initialLastName: lastName,
errorMessage: errorText, errorMessage: errorText,
accountProvider: ctx.read<AccountProvider>(), accountProvider: ctx.read<AccountProvider>(),
), ),
child: _AccountNameBody( child: _AccountNameBody(
hintText: hintText, hintText: hintText,
lastNameHint: lastNameHint,
), ),
); );
} }
@@ -48,9 +54,11 @@ class AccountName extends StatelessWidget {
class _AccountNameBody extends StatelessWidget { class _AccountNameBody extends StatelessWidget {
const _AccountNameBody({ const _AccountNameBody({
required this.hintText, required this.hintText,
required this.lastNameHint,
}); });
final String hintText; final String hintText;
final String lastNameHint;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -58,8 +66,9 @@ class _AccountNameBody extends StatelessWidget {
final provider = context.watch<AccountProvider>(); final provider = context.watch<AccountProvider>();
final theme = Theme.of(context); final theme = Theme.of(context);
final currentName = provider.account?.name ?? state.initialName; final currentFirstName = provider.account?.name ?? state.initialFirstName;
state.syncName(currentName); final currentLastName = provider.account?.lastName ?? state.initialLastName;
state.syncNames(currentFirstName, currentLastName);
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -69,6 +78,7 @@ class _AccountNameBody extends StatelessWidget {
children: [ children: [
AccountNameText( AccountNameText(
hintText: hintText, hintText: hintText,
lastNameHint: lastNameHint,
inputWidth: _AccountNameConstants.inputWidth, inputWidth: _AccountNameConstants.inputWidth,
borderWidth: _AccountNameConstants.borderWidth, borderWidth: _AccountNameConstants.borderWidth,
), ),

View File

@@ -9,11 +9,13 @@ class AccountNameText extends StatelessWidget {
const AccountNameText({ const AccountNameText({
super.key, super.key,
required this.hintText, required this.hintText,
required this.lastNameHint,
required this.inputWidth, required this.inputWidth,
required this.borderWidth, required this.borderWidth,
}); });
final String hintText; final String hintText;
final String lastNameHint;
final double inputWidth; final double inputWidth;
final double borderWidth; final double borderWidth;
@@ -25,29 +27,55 @@ class AccountNameText extends StatelessWidget {
if (state.isEditing) { if (state.isEditing) {
return SizedBox( return SizedBox(
width: inputWidth, width: inputWidth,
child: TextFormField( child: Column(
controller: state.controller, crossAxisAlignment: CrossAxisAlignment.start,
style: theme.textTheme.headlineMedium?.copyWith( children: [
fontWeight: FontWeight.bold, TextFormField(
), controller: state.firstNameController,
autofocus: true, style: theme.textTheme.headlineMedium?.copyWith(
enabled: !state.isBusy, fontWeight: FontWeight.bold,
decoration: InputDecoration( ),
hintText: hintText, autofocus: true,
isDense: true, enabled: !state.isBusy,
border: UnderlineInputBorder( decoration: InputDecoration(
borderSide: BorderSide( hintText: hintText,
color: theme.colorScheme.primary, labelText: hintText,
width: borderWidth, isDense: true,
border: UnderlineInputBorder(
borderSide: BorderSide(
color: theme.colorScheme.primary,
width: borderWidth,
),
),
), ),
), ),
), const SizedBox(height: 8),
TextFormField(
controller: state.lastNameController,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
),
enabled: !state.isBusy,
decoration: InputDecoration(
hintText: lastNameHint,
labelText: lastNameHint,
isDense: true,
border: UnderlineInputBorder(
borderSide: BorderSide(
color: theme.colorScheme.primary,
width: borderWidth,
),
),
),
),
],
), ),
); );
} }
final displayName = state.currentFullName.isNotEmpty ? state.currentFullName : hintText;
return Text( return Text(
state.currentName, displayName,
style: theme.textTheme.headlineMedium?.copyWith( style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),

View File

@@ -23,8 +23,11 @@ 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?>( final accountFirstName = context.select<AccountProvider, String?>(
(provider) => provider.account?.describable.name, (provider) => provider.account?.name,
);
final accountLastName = context.select<AccountProvider, String?>(
(provider) => provider.account?.lastName,
); );
final accountAvatarUrl = context.select<AccountProvider, String?>( final accountAvatarUrl = context.select<AccountProvider, String?>(
(provider) => provider.account?.avatarUrl, (provider) => provider.account?.avatarUrl,
@@ -49,9 +52,11 @@ class ProfileSettingsPage extends StatelessWidget {
errorText: loc.avatarUpdateError, errorText: loc.avatarUpdateError,
), ),
AccountName( AccountName(
name: accountName ?? loc.userNamePlaceholder, firstName: accountFirstName ?? '',
lastName: accountLastName ?? '',
title: loc.accountName, title: loc.accountName,
hintText: loc.accountNameHint, hintText: loc.accountNameHint,
lastNameHint: loc.lastName,
errorText: loc.accountNameUpdateError, errorText: loc.accountNameUpdateError,
), ),
AccountPassword( AccountPassword(

View File

@@ -7,50 +7,70 @@ import 'package:pweb/models/edit_state.dart';
class AccountNameState extends ChangeNotifier { class AccountNameState extends ChangeNotifier {
AccountNameState({ AccountNameState({
required this.initialName, required this.initialFirstName,
required this.initialLastName,
required this.errorMessage, required this.errorMessage,
required AccountProvider accountProvider, required AccountProvider accountProvider,
}) : _accountProvider = accountProvider { }) : _accountProvider = accountProvider {
_controller = TextEditingController(text: initialName); _firstNameController = TextEditingController(text: initialFirstName);
_lastNameController = TextEditingController(text: initialLastName);
} }
final AccountProvider _accountProvider; final AccountProvider _accountProvider;
final String initialName; final String initialFirstName;
final String initialLastName;
final String errorMessage; final String errorMessage;
late final TextEditingController _controller; late final TextEditingController _firstNameController;
late final TextEditingController _lastNameController;
EditState _editState = EditState.view; EditState _editState = EditState.view;
String _errorText = ''; String _errorText = '';
bool _disposed = false; bool _disposed = false;
TextEditingController get controller => _controller; TextEditingController get firstNameController => _firstNameController;
TextEditingController get lastNameController => _lastNameController;
EditState get editState => _editState; EditState get editState => _editState;
String get errorText => _errorText; String get errorText => _errorText;
bool get isEditing => _editState != EditState.view; bool get isEditing => _editState != EditState.view;
bool get isSaving => _editState == EditState.saving; bool get isSaving => _editState == EditState.saving;
bool get isBusy => _accountProvider.isLoading || isSaving; bool get isBusy => _accountProvider.isLoading || isSaving;
String get currentName => _accountProvider.account?.name ?? initialName; String get currentFirstName => _accountProvider.account?.name ?? initialFirstName;
String get currentLastName => _accountProvider.account?.lastName ?? initialLastName;
String get currentFullName {
final first = currentFirstName.trim();
final last = currentLastName.trim();
if (first.isEmpty && last.isEmpty) return '';
if (first.isEmpty) return last;
if (last.isEmpty) return first;
return '$first $last';
}
void startEditing() => _setState(EditState.edit); void startEditing() => _setState(EditState.edit);
void cancelEditing() { void cancelEditing() {
_controller.text = currentName; _firstNameController.text = currentFirstName;
_lastNameController.text = currentLastName;
_setError(''); _setError('');
_setState(EditState.view); _setState(EditState.view);
} }
void syncName(String latestName) { void syncNames(String latestFirstName, String latestLastName) {
if (isEditing) return; if (isEditing) return;
if (_controller.text != latestName) { if (_firstNameController.text != latestFirstName) {
_controller.text = latestName; _firstNameController.text = latestFirstName;
}
if (_lastNameController.text != latestLastName) {
_lastNameController.text = latestLastName;
} }
} }
Future<bool> save() async { Future<bool> save() async {
final newName = _controller.text.trim(); final newFirstName = _firstNameController.text.trim();
final current = currentName; final newLastName = _lastNameController.text.trim();
final currentFirst = currentFirstName;
final currentLast = currentLastName;
if (newName.isEmpty || newName == current) { if (newFirstName.isEmpty || (newFirstName == currentFirst && newLastName == currentLast)) {
cancelEditing(); cancelEditing();
return false; return false;
} }
@@ -59,7 +79,7 @@ class AccountNameState extends ChangeNotifier {
_setState(EditState.saving); _setState(EditState.saving);
try { try {
await _accountProvider.resetUsername(newName); await _accountProvider.resetUsername(newFirstName, lastName: newLastName);
_setState(EditState.view); _setState(EditState.view);
return true; return true;
} catch (_) { } catch (_) {
@@ -88,7 +108,8 @@ class AccountNameState extends ChangeNotifier {
@override @override
void dispose() { void dispose() {
_disposed = true; _disposed = true;
_controller.dispose(); _firstNameController.dispose();
_lastNameController.dispose();
super.dispose(); super.dispose();
} }
} }

View File

@@ -50,7 +50,7 @@ class AccountAvatar extends StatelessWidget {
final loc = AppLocalizations.of(context)!; final loc = AppLocalizations.of(context)!;
return UserAccountsDrawerHeader( return UserAccountsDrawerHeader(
accountName: Text(provider.account?.describable.name ?? loc.userNamePlaceholder), accountName: Text(provider.account?.fullName ?? loc.userNamePlaceholder),
accountEmail: Text(provider.account?.login ?? loc.usernameHint), accountEmail: Text(provider.account?.login ?? loc.usernameHint),
currentAccountPicture: avatar, currentAccountPicture: avatar,
); );

View File

@@ -32,7 +32,7 @@ class PayoutSidebar extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final accountName = context.select<AccountProvider, String?>( final accountName = context.select<AccountProvider, String?>(
(provider) => provider.account?.describable.name, (provider) => provider.account?.fullName,
); );
final accountAvatar = context.select<AccountProvider, String?>( final accountAvatar = context.select<AccountProvider, String?>(
(provider) => provider.account?.avatarUrl, (provider) => provider.account?.avatarUrl,