ui cleanup

This commit is contained in:
Christien Rioux 2025-03-17 00:51:16 -04:00
parent d460a0388c
commit 77c68aa45f
57 changed files with 1158 additions and 914 deletions

View file

@ -1,2 +1,3 @@
export 'cubits/cubits.dart';
export 'models/models.dart';
export 'views/views.dart';

View file

@ -8,6 +8,7 @@ import 'package:veilid_support/veilid_support.dart';
import '../../account_manager/account_manager.dart';
import '../../proto/proto.dart' as proto;
import '../../tools/tools.dart';
import '../models/models.dart';
//////////////////////////////////////////////////
// Mutable state for per-account contacts
@ -81,9 +82,7 @@ class ContactListCubit extends DHTShortArrayCubit<proto.Contact> {
Future<void> updateContactFields({
required TypedKey localConversationRecordKey,
String? nickname,
String? notes,
bool? showAvailability,
required ContactSpec updatedContactSpec,
}) async {
// Update contact's locally-modifiable fields
await operateWriteEventual((writer) async {
@ -92,17 +91,7 @@ class ContactListCubit extends DHTShortArrayCubit<proto.Contact> {
if (c != null &&
c.localConversationRecordKey.toVeilid() ==
localConversationRecordKey) {
final newContact = c.deepCopy();
if (nickname != null) {
newContact.nickname = nickname;
}
if (notes != null) {
newContact.notes = notes;
}
if (showAvailability != null) {
newContact.showAvailability = showAvailability;
}
final newContact = await updatedContactSpec.updateProto(c);
final updated = await writer.tryWriteItemProtobuf(
proto.Contact.fromBuffer, pos, newContact);

View file

@ -0,0 +1,37 @@
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:protobuf/protobuf.dart';
import '../../proto/proto.dart' as proto;
@immutable
class ContactSpec extends Equatable {
const ContactSpec({
required this.nickname,
required this.notes,
required this.showAvailability,
});
ContactSpec.fromProto(proto.Contact p)
: nickname = p.nickname,
notes = p.notes,
showAvailability = p.showAvailability;
Future<proto.Contact> updateProto(proto.Contact old) async {
final newProto = old.deepCopy()
..nickname = nickname
..notes = notes
..showAvailability = showAvailability;
return newProto;
}
////////////////////////////////////////////////////////////////////////////
final String nickname;
final String notes;
final bool showAvailability;
@override
List<Object?> get props => [nickname, notes, showAvailability];
}

View file

@ -0,0 +1 @@
export 'contact_spec.dart';

View file

@ -10,11 +10,11 @@ class AvailabilityWidget extends StatelessWidget {
{required this.availability,
required this.color,
this.vertical = true,
this.iconSize = 32,
this.iconSize = 24,
super.key});
static Widget availabilityIcon(proto.Availability availability, Color color,
{double size = 32}) {
{double size = 24}) {
late final Widget iconData;
switch (availability) {
case proto.Availability.AVAILABILITY_AWAY:
@ -70,7 +70,7 @@ class AvailabilityWidget extends StatelessWidget {
])
: Row(mainAxisSize: MainAxisSize.min, children: [
icon,
Text(name, style: textTheme.labelSmall!.copyWith(color: color))
Text(name, style: textTheme.labelLarge!.copyWith(color: color))
.paddingLTRB(8, 0, 0, 0)
]);
}

View file

@ -1,13 +1,15 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_translate/flutter_translate.dart';
import '../../proto/proto.dart' as proto;
import '../../tools/tools.dart';
import '../contacts.dart';
class ContactDetailsWidget extends StatefulWidget {
const ContactDetailsWidget({required this.contact, super.key});
final proto.Contact contact;
const ContactDetailsWidget(
{required this.contact, this.onModifiedState, super.key});
@override
State<ContactDetailsWidget> createState() => _ContactDetailsWidgetState();
@ -15,8 +17,14 @@ class ContactDetailsWidget extends StatefulWidget {
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<proto.Contact>('contact', contact));
properties
..add(DiagnosticsProperty<proto.Contact>('contact', contact))
..add(ObjectFlagProperty<void Function(bool p1)?>.has(
'onModifiedState', onModifiedState));
}
final proto.Contact contact;
final void Function(bool)? onModifiedState;
}
class _ContactDetailsWidgetState extends State<ContactDetailsWidget>
@ -24,18 +32,21 @@ class _ContactDetailsWidgetState extends State<ContactDetailsWidget>
@override
Widget build(BuildContext context) => SingleChildScrollView(
child: EditContactForm(
formKey: GlobalKey(),
contact: widget.contact,
onSubmit: (fbs) async {
submitText: translate('button.update'),
submitDisabledText: translate('button.waiting_for_network'),
onModifiedState: widget.onModifiedState,
onSubmit: (updatedContactSpec) async {
final contactList = context.read<ContactListCubit>();
await contactList.updateContactFields(
localConversationRecordKey:
widget.contact.localConversationRecordKey.toVeilid(),
nickname: fbs.currentState
?.value[EditContactForm.formFieldNickname] as String,
notes: fbs.currentState?.value[EditContactForm.formFieldNotes]
as String,
showAvailability: fbs.currentState
?.value[EditContactForm.formFieldShowAvailability] as bool);
try {
await contactList.updateContactFields(
localConversationRecordKey:
widget.contact.localConversationRecordKey.toVeilid(),
updatedContactSpec: updatedContactSpec);
} on Exception catch (e) {
log.debug('error updating contact: $e', e);
return false;
}
return true;
}));
}

View file

@ -40,7 +40,7 @@ class ContactItemWidget extends StatelessWidget {
size: 34,
borderColor: _disabled
? scale.grayScale.primaryText
: scale.primaryScale.primaryText,
: scale.primaryScale.subtleBorder,
foregroundColor: _disabled
? scale.grayScale.primaryText
: scale.primaryScale.primaryText,
@ -71,7 +71,7 @@ class ContactItemWidget extends StatelessWidget {
endActions: [
if (_onDoubleTap != null)
SliderTileAction(
icon: Icons.edit,
//icon: Icons.edit,
label: translate('button.edit'),
actionScale: ScaleKind.secondary,
onPressed: (_context) =>
@ -81,7 +81,7 @@ class ContactItemWidget extends StatelessWidget {
),
if (_onDelete != null)
SliderTileAction(
icon: Icons.delete,
//icon: Icons.delete,
label: translate('button.delete'),
actionScale: ScaleKind.tertiary,
onPressed: (_context) =>

View file

@ -74,13 +74,10 @@ class _ContactsBrowserState extends State<ContactsBrowser>
final menuIconColor = scaleConfig.preferBorders
? scale.primaryScale.hoverBorder
: scale.primaryScale.borderText;
: scale.primaryScale.hoverBorder;
final menuBackgroundColor = scaleConfig.preferBorders
? scale.primaryScale.elementBackground
: scale.primaryScale.border;
// final menuHoverColor = scaleConfig.preferBorders
// ? scale.primaryScale.hoverElementBackground
// : scale.primaryScale.hoverBorder;
: scale.primaryScale.elementBackground;
final menuBorderColor = scale.primaryScale.hoverBorder;
@ -149,13 +146,12 @@ class _ContactsBrowserState extends State<ContactsBrowser>
},
iconSize: 32,
icon: const Icon(Icons.contact_page),
color: scale.primaryScale.hoverBorder,
color: menuIconColor,
),
Text(translate('add_contact_sheet.create_invite'),
maxLines: 2,
textAlign: TextAlign.center,
style: textTheme.labelSmall!
.copyWith(color: scale.primaryScale.hoverBorder))
style: textTheme.labelSmall!.copyWith(color: menuIconColor))
]),
StarMenu(
items: receiveInviteMenuItems,
@ -171,13 +167,12 @@ class _ContactsBrowserState extends State<ContactsBrowser>
icon: ImageIcon(
const AssetImage('assets/images/handshake.png'),
size: 32,
color: scale.primaryScale.hoverBorder,
color: menuIconColor,
)),
Text(translate('add_contact_sheet.receive_invite'),
maxLines: 2,
textAlign: TextAlign.center,
style: textTheme.labelSmall!
.copyWith(color: scale.primaryScale.hoverBorder))
style: textTheme.labelSmall!.copyWith(color: menuIconColor))
]),
),
]).paddingAll(16);
@ -274,8 +269,9 @@ class _ContactsBrowserState extends State<ContactsBrowser>
case ContactsBrowserElementKind.invitation:
final invitation = element.invitation!;
return invitation.message
.toLowerCase()
.contains(lowerValue);
.toLowerCase()
.contains(lowerValue) ||
invitation.recipient.toLowerCase().contains(lowerValue);
}
}).toList()
};

View file

@ -1,3 +1,4 @@
import 'package:async_tools/async_tools.dart';
import 'package:awesome_extensions/awesome_extensions.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@ -11,6 +12,8 @@ import '../../proto/proto.dart' as proto;
import '../../theme/theme.dart';
import '../contacts.dart';
const _kDoBackArrow = 'doBackArrow';
class ContactsDialog extends StatefulWidget {
const ContactsDialog._({required this.modalContext});
@ -44,13 +47,8 @@ class _ContactsDialogState extends State<ContactsDialog> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// final textTheme = theme.textTheme;
final scale = theme.extension<ScaleScheme>()!;
final scaleConfig = theme.extension<ScaleConfig>()!;
final appBarIconColor = scaleConfig.useVisualIndicators
? scale.secondaryScale.border
: scale.secondaryScale.borderText;
final appBarIconColor = scale.primaryScale.borderText;
final enableSplit = !isMobileWidth(context);
final enableLeft = enableSplit || _selectedContact == null;
@ -63,20 +61,22 @@ class _ContactsDialogState extends State<ContactsDialog> {
title: Text(!enableSplit && enableRight
? translate('contacts_dialog.edit_contact')
: translate('contacts_dialog.contacts')),
leading: Navigator.canPop(context)
? IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
if (!enableSplit && enableRight) {
setState(() {
_selectedContact = null;
});
} else {
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
singleFuture((this, _kDoBackArrow), () async {
final confirmed = await _onContactSelected(null);
if (!enableSplit && enableRight) {
} else {
if (confirmed) {
if (context.mounted) {
Navigator.pop(context);
}
},
)
: null,
}
}
});
},
),
actions: [
if (_selectedContact != null)
FittedBox(
@ -85,9 +85,10 @@ class _ContactsDialogState extends State<ContactsDialog> {
Column(mainAxisSize: MainAxisSize.min, children: [
IconButton(
icon: const Icon(Icons.chat_bubble),
color: appBarIconColor,
tooltip: translate('contacts_dialog.new_chat'),
onPressed: () async {
await onChatStarted(_selectedContact!);
await _onChatStarted(_selectedContact!);
}),
Text(translate('contacts_dialog.new_chat'),
style: theme.textTheme.labelSmall!
@ -100,10 +101,11 @@ class _ContactsDialogState extends State<ContactsDialog> {
Column(mainAxisSize: MainAxisSize.min, children: [
IconButton(
icon: const Icon(Icons.close),
color: appBarIconColor,
tooltip:
translate('contacts_dialog.close_contact'),
onPressed: () async {
await onContactSelected(null);
await _onContactSelected(null);
}),
Text(translate('contacts_dialog.close_contact'),
style: theme.textTheme.labelSmall!
@ -115,41 +117,68 @@ class _ContactsDialogState extends State<ContactsDialog> {
return ColoredBox(
color: scale.primaryScale.appBackground,
child: Row(children: [
Offstage(
offstage: !enableLeft,
child: SizedBox(
width: enableLeft && !enableRight
? maxWidth
: (maxWidth / 3).clamp(200, 500),
child: DecoratedBox(
decoration: BoxDecoration(
color: scale.primaryScale.subtleBackground),
child: ContactsBrowser(
selectedContactRecordKey: _selectedContact
?.localConversationRecordKey
.toVeilid(),
onContactSelected: onContactSelected,
onChatStarted: onChatStarted,
).paddingLTRB(8, 0, 8, 8)))),
if (enableRight)
if (_selectedContact == null)
const NoContactWidget().expanded()
else
ContactDetailsWidget(contact: _selectedContact!)
.paddingAll(8)
.expanded(),
]));
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Offstage(
offstage: !enableLeft,
child: SizedBox(
width: enableLeft && !enableRight
? maxWidth
: (maxWidth / 3).clamp(200, 500),
child: DecoratedBox(
decoration: BoxDecoration(
color: scale
.primaryScale.subtleBackground),
child: ContactsBrowser(
selectedContactRecordKey: _selectedContact
?.localConversationRecordKey
.toVeilid(),
onContactSelected: _onContactSelected,
onChatStarted: _onChatStarted,
).paddingLTRB(8, 0, 8, 8)))),
if (enableRight && enableLeft)
Container(
constraints: const BoxConstraints(
minWidth: 1, maxWidth: 1),
color: scale.primaryScale.subtleBorder),
if (enableRight)
if (_selectedContact == null)
const NoContactWidget().expanded()
else
ContactDetailsWidget(
contact: _selectedContact!,
onModifiedState: _onModifiedState)
.paddingLTRB(16, 16, 16, 16)
.expanded(),
]));
})));
}
Future<void> onContactSelected(proto.Contact? contact) async {
void _onModifiedState(bool isModified) {
setState(() {
_selectedContact = contact;
_isModified = isModified;
});
}
Future<void> onChatStarted(proto.Contact contact) async {
Future<bool> _onContactSelected(proto.Contact? contact) async {
if (contact != _selectedContact && _isModified) {
final ok = await showConfirmModal(
context: context,
title: translate('confirmation.discard_changes'),
text: translate('confirmation.are_you_sure_discard'));
if (!ok) {
return false;
}
}
setState(() {
_selectedContact = contact;
_isModified = false;
});
return true;
}
Future<void> _onChatStarted(proto.Contact contact) async {
final chatListCubit = context.read<ChatListCubit>();
await chatListCubit.getOrCreateChatSingleContact(contact: contact);
@ -163,4 +192,5 @@ class _ContactsDialogState extends State<ContactsDialog> {
}
proto.Contact? _selectedContact;
bool _isModified = false;
}

View file

@ -1,3 +1,4 @@
import 'package:async_tools/async_tools.dart';
import 'package:awesome_extensions/awesome_extensions.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@ -6,13 +7,18 @@ import 'package:flutter_translate/flutter_translate.dart';
import '../../proto/proto.dart' as proto;
import '../../theme/theme.dart';
import '../models/contact_spec.dart';
import 'availability_widget.dart';
const _kDoSubmitEditContact = 'doSubmitEditContact';
class EditContactForm extends StatefulWidget {
const EditContactForm({
required this.formKey,
required this.contact,
this.onSubmit,
required this.onSubmit,
required this.submitText,
required this.submitDisabledText,
this.onModifiedState,
super.key,
});
@ -20,19 +26,22 @@ class EditContactForm extends StatefulWidget {
State createState() => _EditContactFormState();
final proto.Contact contact;
final Future<void> Function(GlobalKey<FormBuilderState>)? onSubmit;
final GlobalKey<FormBuilderState> formKey;
final String submitText;
final String submitDisabledText;
final Future<bool> Function(ContactSpec) onSubmit;
final void Function(bool)? onModifiedState;
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
..add(ObjectFlagProperty<
Future<void> Function(
GlobalKey<FormBuilderState> p1)?>.has('onSubmit', onSubmit))
..add(ObjectFlagProperty<Future<bool> Function(ContactSpec p1)>.has(
'onSubmit', onSubmit))
..add(ObjectFlagProperty<void Function(bool p1)?>.has(
'onModifiedState', onModifiedState))
..add(DiagnosticsProperty<proto.Contact>('contact', contact))
..add(
DiagnosticsProperty<GlobalKey<FormBuilderState>>('formKey', formKey));
..add(StringProperty('submitText', submitText))
..add(StringProperty('submitDisabledText', submitDisabledText));
}
static const String formFieldNickname = 'nickname';
@ -41,16 +50,46 @@ class EditContactForm extends StatefulWidget {
}
class _EditContactFormState extends State<EditContactForm> {
final _formKey = GlobalKey<FormBuilderState>();
@override
void initState() {
_savedValue = ContactSpec.fromProto(widget.contact);
_currentValueNickname = _savedValue.nickname;
super.initState();
}
Widget _availabilityWidget(proto.Availability availability, Color color) =>
AvailabilityWidget(availability: availability, color: color);
ContactSpec _makeContactSpec() {
final nickname = _formKey.currentState!
.fields[EditContactForm.formFieldNickname]!.value as String;
final notes = _formKey
.currentState!.fields[EditContactForm.formFieldNotes]!.value as String;
final showAvailability = _formKey.currentState!
.fields[EditContactForm.formFieldShowAvailability]!.value as bool;
@override
Widget build(BuildContext context) {
return ContactSpec(
nickname: nickname, notes: notes, showAvailability: showAvailability);
}
// Check if everything is the same and update state
void _onChanged() {
final currentValue = _makeContactSpec();
_isModified = currentValue != _savedValue;
final onModifiedState = widget.onModifiedState;
if (onModifiedState != null) {
onModifiedState(_isModified);
}
}
Widget _availabilityWidget(proto.Availability availability, Color color) =>
AvailabilityWidget(
availability: availability,
color: color,
vertical: false,
);
Widget _editContactForm(BuildContext context) {
final theme = Theme.of(context);
final scale = theme.extension<ScaleScheme>()!;
final scaleConfig = theme.extension<ScaleConfig>()!;
@ -60,75 +99,94 @@ class _EditContactFormState extends State<EditContactForm> {
if (scaleConfig.useVisualIndicators && !scaleConfig.preferBorders) {
border = scale.primaryScale.elementBackground;
} else {
border = scale.primaryScale.border;
border = scale.primaryScale.subtleBorder;
}
return FormBuilder(
key: widget.formKey,
key: _formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
onChanged: _onChanged,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AvatarWidget(
name: widget.contact.profile.name,
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),
SelectableText(widget.contact.profile.name,
style: textTheme.headlineMedium)
.noEditDecoratorLabel(
context,
translate('contact_form.form_name'),
scale: scale.secondaryScale,
)
.paddingSymmetric(vertical: 4),
SelectableText(widget.contact.profile.pronouns,
style: textTheme.headlineSmall)
.noEditDecoratorLabel(
context,
translate('contact_form.form_pronouns'),
scale: scale.secondaryScale,
)
.paddingSymmetric(vertical: 4),
Row(mainAxisSize: MainAxisSize.min, children: [
_availabilityWidget(widget.contact.profile.availability,
scale.primaryScale.primaryText),
SelectableText(widget.contact.profile.status,
style: textTheme.bodyMedium)
.paddingSymmetric(horizontal: 8)
])
.noEditDecoratorLabel(
context,
translate('contact_form.form_status'),
scale: scale.secondaryScale,
)
.paddingSymmetric(vertical: 4),
SelectableText(widget.contact.profile.about,
minLines: 1, maxLines: 8, style: textTheme.bodyMedium)
.noEditDecoratorLabel(
context,
translate('contact_form.form_about'),
scale: scale.secondaryScale,
)
.paddingSymmetric(vertical: 4),
SelectableText(
widget.contact.identityPublicKey.value.toVeilid().toString(),
style: textTheme.labelMedium!
.copyWith(fontFamily: 'Source Code Pro'))
.noEditDecoratorLabel(
context,
translate('contact_form.form_fingerprint'),
scale: scale.secondaryScale,
)
.paddingSymmetric(vertical: 4),
Divider(color: border).paddingLTRB(8, 0, 8, 8),
styledCard(
context: context,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(children: [
const Spacer(),
AvatarWidget(
name: _currentValueNickname.isNotEmpty
? _currentValueNickname
: widget.contact.profile.name,
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()
]),
SelectableText(widget.contact.profile.name,
style: textTheme.bodyLarge)
.noEditDecoratorLabel(
context,
translate('contact_form.form_name'),
)
.paddingSymmetric(vertical: 4),
SelectableText(widget.contact.profile.pronouns,
style: textTheme.bodyLarge)
.noEditDecoratorLabel(
context,
translate('contact_form.form_pronouns'),
)
.paddingSymmetric(vertical: 4),
Row(mainAxisSize: MainAxisSize.min, children: [
_availabilityWidget(
widget.contact.profile.availability,
scale.primaryScale.appText),
SelectableText(widget.contact.profile.status,
style: textTheme.bodyMedium)
.paddingSymmetric(horizontal: 8)
])
.noEditDecoratorLabel(
context,
translate('contact_form.form_status'),
)
.paddingSymmetric(vertical: 4),
SelectableText(widget.contact.profile.about,
minLines: 1,
maxLines: 8,
style: textTheme.bodyMedium)
.noEditDecoratorLabel(
context,
translate('contact_form.form_about'),
)
.paddingSymmetric(vertical: 4),
SelectableText(
widget.contact.identityPublicKey.value
.toVeilid()
.toString(),
style: textTheme.bodyMedium!
.copyWith(fontFamily: 'Source Code Pro'))
.noEditDecoratorLabel(
context,
translate('contact_form.form_fingerprint'),
)
.paddingSymmetric(vertical: 4),
]).paddingAll(16))
.paddingLTRB(0, 0, 0, 16),
FormBuilderTextField(
//autofocus: true,
name: EditContactForm.formFieldNickname,
initialValue: widget.contact.nickname,
initialValue: _currentValueNickname,
onChanged: (x) {
setState(() {
_currentValueNickname = x ?? '';
});
},
decoration: InputDecoration(
labelText: translate('contact_form.form_nickname')),
maxLength: 64,
@ -136,14 +194,16 @@ class _EditContactFormState extends State<EditContactForm> {
),
FormBuilderCheckbox(
name: EditContactForm.formFieldShowAvailability,
initialValue: widget.contact.showAvailability,
initialValue: _savedValue.showAvailability,
side: BorderSide(color: scale.primaryScale.border, width: 2),
checkColor: scale.primaryScale.borderText,
activeColor: scale.primaryScale.border,
title: Text(translate('contact_form.form_show_availability'),
style: textTheme.labelMedium),
),
FormBuilderTextField(
name: EditContactForm.formFieldNotes,
initialValue: widget.contact.notes,
initialValue: _savedValue.notes,
minLines: 1,
maxLines: 8,
maxLength: 1024,
@ -152,24 +212,38 @@ class _EditContactFormState extends State<EditContactForm> {
textInputAction: TextInputAction.newline,
),
ElevatedButton(
onPressed: widget.onSubmit == null
? null
: () async {
if (widget.formKey.currentState?.saveAndValidate() ??
false) {
await widget.onSubmit!(widget.formKey);
}
},
onPressed: _isModified ? _doSubmit : null,
child: Row(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.check, size: 16).paddingLTRB(0, 0, 4, 0),
Text((widget.onSubmit == null)
? translate('contact_form.save')
: translate('contact_form.save'))
.paddingLTRB(0, 0, 4, 0)
Text(widget.submitText).paddingLTRB(0, 0, 4, 0)
]),
).paddingSymmetric(vertical: 4).alignAtCenterRight(),
).paddingSymmetric(vertical: 4).alignAtCenter(),
],
),
);
}
void _doSubmit() {
final onSubmit = widget.onSubmit;
if (_formKey.currentState?.saveAndValidate() ?? false) {
singleFuture((this, _kDoSubmitEditContact), () async {
final updatedContactSpec = _makeContactSpec();
final saved = await onSubmit(updatedContactSpec);
if (saved) {
setState(() {
_savedValue = updatedContactSpec;
});
_onChanged();
}
});
}
}
@override
Widget build(BuildContext context) => _editContactForm(context);
///////////////////////////////////////////////////////////////////////////
late ContactSpec _savedValue;
late String _currentValueNickname;
bool _isModified = false;
}