veilidchat/lib/account_manager/views/edit_profile_form.dart
Brandon Vandegrift a3aa7569ab edit_account_form visual improvements
This changes the icon for the "Waiting for network" button to an hourglass instead of a checkmark, and adds more vertical spacing between the field so that the labels and validation messages don't collide.
2025-04-03 14:52:09 -04:00

368 lines
14 KiB
Dart

import 'package:async_tools/async_tools.dart';
import 'package:awesome_extensions/awesome_extensions.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_translate/flutter_translate.dart';
import 'package:form_builder_validators/form_builder_validators.dart';
import '../../contacts/contacts.dart';
import '../../proto/proto.dart' as proto;
import '../../theme/theme.dart';
import '../../veilid_processor/veilid_processor.dart';
import '../models/models.dart';
const _kDoSubmitEditProfile = 'doSubmitEditProfile';
class EditProfileForm extends StatefulWidget {
const EditProfileForm({
required this.header,
required this.instructions,
required this.submitText,
required this.submitDisabledText,
required this.initialValue,
required this.onSubmit,
this.onModifiedState,
super.key,
});
@override
State createState() => _EditProfileFormState();
final String header;
final String instructions;
final Future<bool> Function(AccountSpec) onSubmit;
final void Function(bool)? onModifiedState;
final String submitText;
final String submitDisabledText;
final AccountSpec initialValue;
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
..add(StringProperty('header', header))
..add(StringProperty('instructions', instructions))
..add(StringProperty('submitText', submitText))
..add(StringProperty('submitDisabledText', submitDisabledText))
..add(ObjectFlagProperty<Future<bool> Function(AccountSpec)>.has(
'onSubmit', onSubmit))
..add(ObjectFlagProperty<void Function(bool p1)?>.has(
'onModifiedState', onModifiedState))
..add(DiagnosticsProperty<AccountSpec>('initialValue', initialValue));
}
static const String formFieldName = 'name';
static const String formFieldPronouns = 'pronouns';
static const String formFieldAbout = 'about';
static const String formFieldAvailability = 'availability';
static const String formFieldFreeMessage = 'free_message';
static const String formFieldAwayMessage = 'away_message';
static const String formFieldBusyMessage = 'busy_message';
static const String formFieldAvatar = 'avatar';
static const String formFieldAutoAway = 'auto_away';
static const String formFieldAutoAwayTimeout = 'auto_away_timeout';
}
class _EditProfileFormState extends State<EditProfileForm> {
final _formKey = GlobalKey<FormBuilderState>();
@override
void initState() {
_savedValue = widget.initialValue;
_currentValueName = widget.initialValue.name;
_currentValueAutoAway = widget.initialValue.autoAway;
super.initState();
}
FormBuilderDropdown<proto.Availability> _availabilityDropDown(
BuildContext context) {
final theme = Theme.of(context);
final scale = theme.extension<ScaleScheme>()!;
final initialValue =
_savedValue.availability == proto.Availability.AVAILABILITY_UNSPECIFIED
? proto.Availability.AVAILABILITY_FREE
: _savedValue.availability;
final availabilities = [
proto.Availability.AVAILABILITY_FREE,
proto.Availability.AVAILABILITY_AWAY,
proto.Availability.AVAILABILITY_BUSY,
proto.Availability.AVAILABILITY_OFFLINE,
];
return FormBuilderDropdown<proto.Availability>(
name: EditProfileForm.formFieldAvailability,
initialValue: initialValue,
decoration: InputDecoration(
floatingLabelBehavior: FloatingLabelBehavior.always,
labelText: translate('account.form_availability'),
hintText: translate('account.empty_busy_message')),
items: availabilities
.map((x) => DropdownMenuItem<proto.Availability>(
value: x,
child: Row(mainAxisSize: MainAxisSize.min, children: [
AvailabilityWidget.availabilityIcon(
x, scale.primaryScale.appText),
Text(x == proto.Availability.AVAILABILITY_OFFLINE
? translate('availability.always_show_offline')
: AvailabilityWidget.availabilityName(x))
.paddingLTRB(8, 0, 0, 0),
])))
.toList(),
);
}
AccountSpec _makeAccountSpec() {
final name = _formKey
.currentState!.fields[EditProfileForm.formFieldName]!.value as String;
final pronouns = _formKey.currentState!
.fields[EditProfileForm.formFieldPronouns]!.value as String;
final about = _formKey
.currentState!.fields[EditProfileForm.formFieldAbout]!.value as String;
final availability = _formKey
.currentState!
.fields[EditProfileForm.formFieldAvailability]!
.value as proto.Availability;
final invisible = availability == proto.Availability.AVAILABILITY_OFFLINE;
final freeMessage = _formKey.currentState!
.fields[EditProfileForm.formFieldFreeMessage]!.value as String;
final awayMessage = _formKey.currentState!
.fields[EditProfileForm.formFieldAwayMessage]!.value as String;
final busyMessage = _formKey.currentState!
.fields[EditProfileForm.formFieldBusyMessage]!.value as String;
const proto.DataReference? avatar = null;
// final avatar = _formKey.currentState!
// .fields[EditProfileForm.formFieldAvatar]!.value
//as proto.DataReference?;
final autoAway = _formKey
.currentState!.fields[EditProfileForm.formFieldAutoAway]!.value as bool;
final autoAwayTimeoutString = _formKey.currentState!
.fields[EditProfileForm.formFieldAutoAwayTimeout]!.value as String;
final autoAwayTimeout = int.parse(autoAwayTimeoutString);
return AccountSpec(
name: name,
pronouns: pronouns,
about: about,
availability: availability,
invisible: invisible,
freeMessage: freeMessage,
awayMessage: awayMessage,
busyMessage: busyMessage,
avatar: avatar,
autoAway: autoAway,
autoAwayTimeout: autoAwayTimeout);
}
// Check if everything is the same and update state
void _onChanged() {
final currentValue = _makeAccountSpec();
_isModified = currentValue != _savedValue;
final onModifiedState = widget.onModifiedState;
if (onModifiedState != null) {
onModifiedState(_isModified);
}
}
Widget _editProfileForm(
BuildContext context,
) {
final theme = Theme.of(context);
final scale = theme.extension<ScaleScheme>()!;
final scaleConfig = theme.extension<ScaleConfig>()!;
final textTheme = theme.textTheme;
late final Color border;
if (scaleConfig.useVisualIndicators && !scaleConfig.preferBorders) {
border = scale.primaryScale.elementBackground;
} else {
border = scale.primaryScale.border;
}
return FormBuilder(
key: _formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
onChanged: _onChanged,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
spacing: 8,
children: [
Row(children: [
const Spacer(),
AvatarWidget(
name: _currentValueName,
size: 128,
borderColor: border,
foregroundColor: scale.primaryScale.primaryText,
backgroundColor: scale.primaryScale.primary,
scaleConfig: scaleConfig,
textStyle: theme.textTheme.titleLarge!.copyWith(fontSize: 64),
).paddingLTRB(0, 0, 0, 16),
const Spacer()
]),
FormBuilderTextField(
autofocus: true,
name: EditProfileForm.formFieldName,
initialValue: _savedValue.name,
onChanged: (x) {
setState(() {
_currentValueName = x ?? '';
});
},
decoration: InputDecoration(
floatingLabelBehavior: FloatingLabelBehavior.always,
labelText: translate('account.form_name'),
hintText: translate('account.empty_name')),
maxLength: 64,
// The validator receives the text that the user has entered.
validator: FormBuilderValidators.compose([
FormBuilderValidators.required(),
]),
textInputAction: TextInputAction.next,
),
FormBuilderTextField(
name: EditProfileForm.formFieldPronouns,
initialValue: _savedValue.pronouns,
maxLength: 64,
decoration: InputDecoration(
floatingLabelBehavior: FloatingLabelBehavior.always,
labelText: translate('account.form_pronouns'),
hintText: translate('account.empty_pronouns')),
textInputAction: TextInputAction.next,
),
FormBuilderTextField(
name: EditProfileForm.formFieldAbout,
initialValue: _savedValue.about,
maxLength: 1024,
maxLines: 8,
minLines: 1,
decoration: InputDecoration(
floatingLabelBehavior: FloatingLabelBehavior.always,
labelText: translate('account.form_about'),
hintText: translate('account.empty_about')),
textInputAction: TextInputAction.newline,
),
_availabilityDropDown(context).paddingLTRB(0, 0, 0, 16),
FormBuilderTextField(
name: EditProfileForm.formFieldFreeMessage,
initialValue: _savedValue.freeMessage,
maxLength: 128,
decoration: InputDecoration(
floatingLabelBehavior: FloatingLabelBehavior.always,
labelText: translate('account.form_free_message'),
hintText: translate('account.empty_free_message')),
textInputAction: TextInputAction.next,
),
FormBuilderTextField(
name: EditProfileForm.formFieldAwayMessage,
initialValue: _savedValue.awayMessage,
maxLength: 128,
decoration: InputDecoration(
floatingLabelBehavior: FloatingLabelBehavior.always,
labelText: translate('account.form_away_message'),
hintText: translate('account.empty_away_message')),
textInputAction: TextInputAction.next,
),
FormBuilderTextField(
name: EditProfileForm.formFieldBusyMessage,
initialValue: _savedValue.busyMessage,
maxLength: 128,
decoration: InputDecoration(
floatingLabelBehavior: FloatingLabelBehavior.always,
labelText: translate('account.form_busy_message'),
hintText: translate('account.empty_busy_message')),
textInputAction: TextInputAction.next,
),
FormBuilderCheckbox(
name: EditProfileForm.formFieldAutoAway,
initialValue: _savedValue.autoAway,
title: Text(translate('account.form_auto_away'),
style: textTheme.labelMedium),
onChanged: (v) {
setState(() {
_currentValueAutoAway = v ?? false;
});
},
).paddingLTRB(0, 0, 0, 16),
FormBuilderTextField(
name: EditProfileForm.formFieldAutoAwayTimeout,
enabled: _currentValueAutoAway,
initialValue: _savedValue.autoAwayTimeout.toString(),
decoration: InputDecoration(
labelText: translate('account.form_auto_away_timeout'),
),
validator: FormBuilderValidators.positiveNumber(),
textInputAction: TextInputAction.next,
),
Row(children: [
const Spacer(),
Text(widget.instructions).toCenter().flexible(flex: 6),
const Spacer(),
]).paddingSymmetric(vertical: 16),
Row(children: [
const Spacer(),
Builder(builder: (context) {
final networkReady = context
.watch<ConnectionStateCubit>()
.state
.asData
?.value
.isPublicInternetReady ??
false;
return ElevatedButton(
onPressed: (networkReady && _isModified) ? _doSubmit : null,
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(networkReady ? Icons.check : Icons.hourglass_empty,
size: 16)
.paddingLTRB(0, 0, 4, 0),
Text(networkReady
? widget.submitText
: widget.submitDisabledText)
.paddingLTRB(0, 0, 4, 0)
]),
);
}),
const Spacer()
])
],
),
);
}
void _doSubmit() {
final onSubmit = widget.onSubmit;
if (_formKey.currentState?.saveAndValidate() ?? false) {
singleFuture((this, _kDoSubmitEditProfile), () async {
final updatedAccountSpec = _makeAccountSpec();
final saved = await onSubmit(updatedAccountSpec);
if (saved) {
setState(() {
_savedValue = updatedAccountSpec;
});
_onChanged();
}
});
}
}
@override
Widget build(BuildContext context) => _editProfileForm(
context,
);
///////////////////////////////////////////////////////////////////////////
late AccountSpec _savedValue;
late bool _currentValueAutoAway;
late String _currentValueName;
bool _isModified = false;
}