profile edit happens without requiring save button

This commit is contained in:
Christien Rioux 2024-08-01 14:30:06 -05:00
parent b6a812af87
commit 030f9d9651
19 changed files with 499 additions and 266 deletions

View File

@ -25,7 +25,7 @@
"empty_free_message": "Status when availability is 'Free'", "empty_free_message": "Status when availability is 'Free'",
"form_away_message": "Away Message", "form_away_message": "Away Message",
"empty_away_message": "Status when availability is 'Away'", "empty_away_message": "Status when availability is 'Away'",
"form_busy_message": "Free Message", "form_busy_message": "Busy Message",
"empty_busy_message": "Status when availability is 'Busy'", "empty_busy_message": "Status when availability is 'Busy'",
"form_availability": "Availability", "form_availability": "Availability",
"form_avatar": "Avatar", "form_avatar": "Avatar",
@ -42,6 +42,7 @@
"create": "Create", "create": "Create",
"instructions": "This information will be shared with the people you invite to connect with you on VeilidChat.", "instructions": "This information will be shared with the people you invite to connect with you on VeilidChat.",
"error": "Account creation error", "error": "Account creation error",
"network_is_offline": "Network is offline, try again when you're connected",
"name": "Name", "name": "Name",
"pronouns": "Pronouns" "pronouns": "Pronouns"
}, },
@ -149,9 +150,10 @@
}, },
"add_contact_sheet": { "add_contact_sheet": {
"new_contact": "New Contact", "new_contact": "New Contact",
"create_invite": "Create Invitation", "create_invite": "Create\nInvitation",
"scan_invite": "Scan Invitation", "receive_invite": "Receive\nInvitation",
"paste_invite": "Paste Invitation" "scan_invite": "Scan\nInvitation",
"paste_invite": "Paste\nInvitation"
}, },
"add_chat_sheet": { "add_chat_sheet": {
"new_chat": "New Chat" "new_chat": "New Chat"

BIN
assets/images/handshake.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

BIN
assets/images/toilet.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:async_tools/async_tools.dart';
import 'package:protobuf/protobuf.dart'; import 'package:protobuf/protobuf.dart';
import 'package:veilid_support/veilid_support.dart'; import 'package:veilid_support/veilid_support.dart';
@ -7,6 +8,10 @@ import '../../proto/proto.dart' as proto;
import '../account_manager.dart'; import '../account_manager.dart';
typedef AccountRecordState = proto.Account; typedef AccountRecordState = proto.Account;
typedef _sspUpdateState = (
AccountSpec accountSpec,
Future<void> Function() onSuccess
);
/// The saved state of a VeilidChat Account on the DHT /// The saved state of a VeilidChat Account on the DHT
/// Used to synchronize status, profile, and options for a specific account /// Used to synchronize status, profile, and options for a specific account
@ -34,16 +39,25 @@ class AccountRecordCubit extends DefaultDHTRecordCubit<AccountRecordState> {
@override @override
Future<void> close() async { Future<void> close() async {
await _sspUpdate.close();
await super.close(); await super.close();
} }
//////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////
// Public Interface // Public Interface
Future<void> updateAccount( void updateAccount(
AccountSpec accountSpec, AccountSpec accountSpec, Future<void> Function() onSuccess) {
) async { _sspUpdate.updateState((accountSpec, onSuccess), (state) async {
await _updateAccountAsync(state.$1, state.$2);
});
}
Future<void> _updateAccountAsync(
AccountSpec accountSpec, Future<void> Function() onSuccess) async {
var changed = false;
await record.eventualUpdateProtobuf(proto.Account.fromBuffer, (old) async { await record.eventualUpdateProtobuf(proto.Account.fromBuffer, (old) async {
changed = false;
if (old == null) { if (old == null) {
return null; return null;
} }
@ -63,7 +77,6 @@ class AccountRecordCubit extends DefaultDHTRecordCubit<AccountRecordState> {
..awayMessage = accountSpec.awayMessage ..awayMessage = accountSpec.awayMessage
..busyMessage = accountSpec.busyMessage; ..busyMessage = accountSpec.busyMessage;
var changed = false;
if (newAccount.profile != old.profile || if (newAccount.profile != old.profile ||
newAccount.invisible != old.invisible || newAccount.invisible != old.invisible ||
newAccount.autodetectAway != old.autodetectAway || newAccount.autodetectAway != old.autodetectAway ||
@ -78,5 +91,10 @@ class AccountRecordCubit extends DefaultDHTRecordCubit<AccountRecordState> {
} }
return null; return null;
}); });
if (changed) {
await onSuccess();
}
} }
final _sspUpdate = SingleStateProcessor<_sspUpdateState>();
} }

View File

@ -50,13 +50,13 @@ class _EditAccountPageState extends WindowSetupState<EditAccountPage> {
orientationCapability: OrientationCapability.portraitOnly); orientationCapability: OrientationCapability.portraitOnly);
Widget _editAccountForm(BuildContext context, Widget _editAccountForm(BuildContext context,
{required Future<void> Function(AccountSpec) onSubmit}) => {required Future<void> Function(AccountSpec) onUpdate}) =>
EditProfileForm( EditProfileForm(
header: translate('edit_account_page.header'), header: translate('edit_account_page.header'),
instructions: translate('edit_account_page.instructions'), instructions: translate('edit_account_page.instructions'),
submitText: translate('edit_account_page.update'), submitText: translate('edit_account_page.update'),
submitDisabledText: translate('button.waiting_for_network'), submitDisabledText: translate('button.waiting_for_network'),
onSubmit: onSubmit, onUpdate: onUpdate,
initialValueCallback: (key) => switch (key) { initialValueCallback: (key) => switch (key) {
EditProfileForm.formFieldName => widget.existingAccount.profile.name, EditProfileForm.formFieldName => widget.existingAccount.profile.name,
EditProfileForm.formFieldPronouns => EditProfileForm.formFieldPronouns =>
@ -76,7 +76,7 @@ class _EditAccountPageState extends WindowSetupState<EditAccountPage> {
EditProfileForm.formFieldAutoAway => EditProfileForm.formFieldAutoAway =>
widget.existingAccount.autodetectAway, widget.existingAccount.autodetectAway,
EditProfileForm.formFieldAutoAwayTimeout => EditProfileForm.formFieldAutoAwayTimeout =>
widget.existingAccount.autoAwayTimeoutMin, widget.existingAccount.autoAwayTimeoutMin.toString(),
String() => throw UnimplementedError(), String() => throw UnimplementedError(),
}, },
); );
@ -214,51 +214,24 @@ class _EditAccountPageState extends WindowSetupState<EditAccountPage> {
} }
} }
Future<void> _onSubmit(AccountSpec accountSpec) async { Future<void> _onUpdate(AccountSpec accountSpec) async {
// dismiss the keyboard by unfocusing the textfield // Look up account cubit for this specific account
FocusScope.of(context).unfocus(); final perAccountCollectionBlocMapCubit =
context.read<PerAccountCollectionBlocMapCubit>();
try { final accountRecordCubit = await perAccountCollectionBlocMapCubit.operate(
setState(() { widget.superIdentityRecordKey,
_isInAsyncCall = true; closure: (c) async => c.accountRecordCubit);
}); if (accountRecordCubit == null) {
try { return;
// Look up account cubit for this specific account
final perAccountCollectionBlocMapCubit =
context.read<PerAccountCollectionBlocMapCubit>();
final accountRecordCubit = await perAccountCollectionBlocMapCubit
.operate(widget.superIdentityRecordKey,
closure: (c) async => c.accountRecordCubit);
if (accountRecordCubit == null) {
return;
}
// Update account profile DHT record
// This triggers ConversationCubits to update
await accountRecordCubit.updateAccount(accountSpec);
// Update local account profile
await AccountRepository.instance
.updateLocalAccount(widget.superIdentityRecordKey, accountSpec);
if (mounted) {
Navigator.canPop(context)
? GoRouterHelper(context).pop()
: GoRouterHelper(context).go('/');
}
} finally {
if (mounted) {
setState(() {
_isInAsyncCall = false;
});
}
}
} on Exception catch (e) {
if (mounted) {
await showErrorModal(
context, translate('edit_account_page.error'), 'Exception: $e');
}
} }
// Update account profile DHT record
// This triggers ConversationCubits to update
accountRecordCubit.updateAccount(accountSpec, () async {
// Update local account profile
await AccountRepository.instance
.updateLocalAccount(widget.superIdentityRecordKey, accountSpec);
});
} }
@override @override
@ -290,7 +263,7 @@ class _EditAccountPageState extends WindowSetupState<EditAccountPage> {
child: Column(children: [ child: Column(children: [
_editAccountForm( _editAccountForm(
context, context,
onSubmit: _onSubmit, onUpdate: _onUpdate,
).paddingLTRB(0, 0, 0, 32), ).paddingLTRB(0, 0, 0, 32),
OptionBox( OptionBox(
instructions: instructions:

View File

@ -1,3 +1,4 @@
import 'package:async_tools/async_tools.dart';
import 'package:awesome_extensions/awesome_extensions.dart'; import 'package:awesome_extensions/awesome_extensions.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -10,15 +11,18 @@ import '../../proto/proto.dart' as proto;
import '../../theme/theme.dart'; import '../../theme/theme.dart';
import '../models/models.dart'; import '../models/models.dart';
const _kDoUpdateSubmit = 'doUpdateSubmit';
class EditProfileForm extends StatefulWidget { class EditProfileForm extends StatefulWidget {
const EditProfileForm({ const EditProfileForm({
required this.header, required this.header,
required this.instructions, required this.instructions,
required this.submitText, required this.submitText,
required this.submitDisabledText, required this.submitDisabledText,
super.key, required this.initialValueCallback,
this.onUpdate,
this.onSubmit, this.onSubmit,
this.initialValueCallback, super.key,
}); });
@override @override
@ -26,10 +30,11 @@ class EditProfileForm extends StatefulWidget {
final String header; final String header;
final String instructions; final String instructions;
final Future<void> Function(AccountSpec)? onUpdate;
final Future<void> Function(AccountSpec)? onSubmit; final Future<void> Function(AccountSpec)? onSubmit;
final String submitText; final String submitText;
final String submitDisabledText; final String submitDisabledText;
final Object? Function(String key)? initialValueCallback; final Object Function(String key) initialValueCallback;
@override @override
void debugFillProperties(DiagnosticPropertiesBuilder properties) { void debugFillProperties(DiagnosticPropertiesBuilder properties) {
@ -38,11 +43,13 @@ class EditProfileForm extends StatefulWidget {
..add(StringProperty('header', header)) ..add(StringProperty('header', header))
..add(StringProperty('instructions', instructions)) ..add(StringProperty('instructions', instructions))
..add(ObjectFlagProperty<Future<void> Function(AccountSpec)?>.has( ..add(ObjectFlagProperty<Future<void> Function(AccountSpec)?>.has(
'onSubmit', onSubmit)) 'onUpdate', onUpdate))
..add(StringProperty('submitText', submitText)) ..add(StringProperty('submitText', submitText))
..add(StringProperty('submitDisabledText', submitDisabledText)) ..add(StringProperty('submitDisabledText', submitDisabledText))
..add(ObjectFlagProperty<Object? Function(String key)?>.has( ..add(ObjectFlagProperty<Object Function(String key)?>.has(
'initialValueCallback', initialValueCallback)); 'initialValueCallback', initialValueCallback))
..add(ObjectFlagProperty<Future<void> Function(AccountSpec)?>.has(
'onSubmit', onSubmit));
} }
static const String formFieldName = 'name'; static const String formFieldName = 'name';
@ -62,15 +69,17 @@ class _EditProfileFormState extends State<EditProfileForm> {
@override @override
void initState() { void initState() {
_autoAwayEnabled =
widget.initialValueCallback(EditProfileForm.formFieldAutoAway) as bool;
super.initState(); super.initState();
} }
FormBuilderDropdown<proto.Availability> _availabilityDropDown( FormBuilderDropdown<proto.Availability> _availabilityDropDown(
BuildContext context) { BuildContext context) {
final initialValueX = final initialValueX =
widget.initialValueCallback?.call(EditProfileForm.formFieldAvailability) widget.initialValueCallback(EditProfileForm.formFieldAvailability)
as proto.Availability? ?? as proto.Availability;
proto.Availability.AVAILABILITY_FREE;
final initialValue = final initialValue =
initialValueX == proto.Availability.AVAILABILITY_UNSPECIFIED initialValueX == proto.Availability.AVAILABILITY_UNSPECIFIED
? proto.Availability.AVAILABILITY_FREE ? proto.Availability.AVAILABILITY_FREE
@ -86,14 +95,19 @@ class _EditProfileFormState extends State<EditProfileForm> {
return FormBuilderDropdown<proto.Availability>( return FormBuilderDropdown<proto.Availability>(
name: EditProfileForm.formFieldAvailability, name: EditProfileForm.formFieldAvailability,
initialValue: initialValue, initialValue: initialValue,
decoration: InputDecoration(
floatingLabelBehavior: FloatingLabelBehavior.always,
labelText: translate('account.form_availability'),
hintText: translate('account.empty_busy_message')),
items: availabilities items: availabilities
.map((x) => DropdownMenuItem<proto.Availability>( .map((x) => DropdownMenuItem<proto.Availability>(
value: x, value: x,
child: Row(mainAxisSize: MainAxisSize.min, children: [ child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(AvailabilityWidget.availabilityIcon(x)), AvailabilityWidget.availabilityIcon(x),
Text(x == proto.Availability.AVAILABILITY_OFFLINE Text(x == proto.Availability.AVAILABILITY_OFFLINE
? translate('availability.always_show_offline') ? translate('availability.always_show_offline')
: AvailabilityWidget.availabilityName(x)), : AvailabilityWidget.availabilityName(x))
.paddingLTRB(8, 0, 0, 0),
]))) ])))
.toList(), .toList(),
); );
@ -103,34 +117,26 @@ class _EditProfileFormState extends State<EditProfileForm> {
final name = _formKey final name = _formKey
.currentState!.fields[EditProfileForm.formFieldName]!.value as String; .currentState!.fields[EditProfileForm.formFieldName]!.value as String;
final pronouns = _formKey.currentState! final pronouns = _formKey.currentState!
.fields[EditProfileForm.formFieldPronouns]!.value as String? ?? .fields[EditProfileForm.formFieldPronouns]!.value as String;
''; final about = _formKey
final about = _formKey.currentState!.fields[EditProfileForm.formFieldAbout]! .currentState!.fields[EditProfileForm.formFieldAbout]!.value as String;
.value as String? ??
'';
final availability = _formKey final availability = _formKey
.currentState! .currentState!
.fields[EditProfileForm.formFieldAvailability]! .fields[EditProfileForm.formFieldAvailability]!
.value as proto.Availability? ?? .value as proto.Availability;
proto.Availability.AVAILABILITY_FREE;
final invisible = availability == proto.Availability.AVAILABILITY_OFFLINE; final invisible = availability == proto.Availability.AVAILABILITY_OFFLINE;
final freeMessage = _formKey.currentState! final freeMessage = _formKey.currentState!
.fields[EditProfileForm.formFieldFreeMessage]!.value as String? ?? .fields[EditProfileForm.formFieldFreeMessage]!.value as String;
'';
final awayMessage = _formKey.currentState! final awayMessage = _formKey.currentState!
.fields[EditProfileForm.formFieldAwayMessage]!.value as String? ?? .fields[EditProfileForm.formFieldAwayMessage]!.value as String;
'';
final busyMessage = _formKey.currentState! final busyMessage = _formKey.currentState!
.fields[EditProfileForm.formFieldBusyMessage]!.value as String? ?? .fields[EditProfileForm.formFieldBusyMessage]!.value as String;
''; final autoAway = _formKey
final autoAway = _formKey.currentState! .currentState!.fields[EditProfileForm.formFieldAutoAway]!.value as bool;
.fields[EditProfileForm.formFieldAutoAway]!.value as bool? ?? final autoAwayTimeoutString = _formKey.currentState!
false; .fields[EditProfileForm.formFieldAutoAwayTimeout]!.value as String;
final autoAwayTimeout = _formKey.currentState! final autoAwayTimeout = int.parse(autoAwayTimeoutString);
.fields[EditProfileForm.formFieldAutoAwayTimeout]!.value as int? ??
30;
return AccountSpec( return AccountSpec(
name: name, name: name,
@ -163,6 +169,7 @@ class _EditProfileFormState extends State<EditProfileForm> {
return FormBuilder( return FormBuilder(
key: _formKey, key: _formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: Column( child: Column(
children: [ children: [
AvatarWidget( AvatarWidget(
@ -179,9 +186,10 @@ class _EditProfileFormState extends State<EditProfileForm> {
FormBuilderTextField( FormBuilderTextField(
autofocus: true, autofocus: true,
name: EditProfileForm.formFieldName, name: EditProfileForm.formFieldName,
initialValue: widget.initialValueCallback initialValue: widget
?.call(EditProfileForm.formFieldName) as String?, .initialValueCallback(EditProfileForm.formFieldName) as String,
decoration: InputDecoration( decoration: InputDecoration(
floatingLabelBehavior: FloatingLabelBehavior.always,
labelText: translate('account.form_name'), labelText: translate('account.form_name'),
hintText: translate('account.empty_name')), hintText: translate('account.empty_name')),
maxLength: 64, maxLength: 64,
@ -190,113 +198,149 @@ class _EditProfileFormState extends State<EditProfileForm> {
FormBuilderValidators.required(), FormBuilderValidators.required(),
]), ]),
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
), ).onFocusChange(_onFocusChange),
FormBuilderTextField( FormBuilderTextField(
name: EditProfileForm.formFieldPronouns, name: EditProfileForm.formFieldPronouns,
initialValue: widget.initialValueCallback initialValue:
?.call(EditProfileForm.formFieldPronouns) as String?, widget.initialValueCallback(EditProfileForm.formFieldPronouns)
as String,
maxLength: 64, maxLength: 64,
decoration: InputDecoration( decoration: InputDecoration(
floatingLabelBehavior: FloatingLabelBehavior.always,
labelText: translate('account.form_pronouns'), labelText: translate('account.form_pronouns'),
hintText: translate('account.empty_pronouns')), hintText: translate('account.empty_pronouns')),
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
), ).onFocusChange(_onFocusChange),
FormBuilderTextField( FormBuilderTextField(
name: EditProfileForm.formFieldAbout, name: EditProfileForm.formFieldAbout,
initialValue: widget.initialValueCallback initialValue: widget
?.call(EditProfileForm.formFieldAbout) as String?, .initialValueCallback(EditProfileForm.formFieldAbout) as String,
maxLength: 1024, maxLength: 1024,
maxLines: 8, maxLines: 8,
minLines: 1, minLines: 1,
decoration: InputDecoration( decoration: InputDecoration(
floatingLabelBehavior: FloatingLabelBehavior.always,
labelText: translate('account.form_about'), labelText: translate('account.form_about'),
hintText: translate('account.empty_about')), hintText: translate('account.empty_about')),
textInputAction: TextInputAction.newline, textInputAction: TextInputAction.newline,
), ).onFocusChange(_onFocusChange),
_availabilityDropDown(context), _availabilityDropDown(context)
.paddingLTRB(0, 0, 0, 16)
.onFocusChange(_onFocusChange),
FormBuilderTextField( FormBuilderTextField(
name: EditProfileForm.formFieldFreeMessage, name: EditProfileForm.formFieldFreeMessage,
initialValue: widget.initialValueCallback initialValue: widget.initialValueCallback(
?.call(EditProfileForm.formFieldFreeMessage) as String?, EditProfileForm.formFieldFreeMessage) as String,
maxLength: 128, maxLength: 128,
decoration: InputDecoration( decoration: InputDecoration(
floatingLabelBehavior: FloatingLabelBehavior.always,
labelText: translate('account.form_free_message'), labelText: translate('account.form_free_message'),
hintText: translate('account.empty_free_message')), hintText: translate('account.empty_free_message')),
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
), ).onFocusChange(_onFocusChange),
FormBuilderTextField( FormBuilderTextField(
name: EditProfileForm.formFieldAwayMessage, name: EditProfileForm.formFieldAwayMessage,
initialValue: widget.initialValueCallback initialValue: widget.initialValueCallback(
?.call(EditProfileForm.formFieldAwayMessage) as String?, EditProfileForm.formFieldAwayMessage) as String,
maxLength: 128, maxLength: 128,
decoration: InputDecoration( decoration: InputDecoration(
floatingLabelBehavior: FloatingLabelBehavior.always,
labelText: translate('account.form_away_message'), labelText: translate('account.form_away_message'),
hintText: translate('account.empty_away_message')), hintText: translate('account.empty_away_message')),
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
), ).onFocusChange(_onFocusChange),
FormBuilderTextField( FormBuilderTextField(
name: EditProfileForm.formFieldBusyMessage, name: EditProfileForm.formFieldBusyMessage,
initialValue: widget.initialValueCallback initialValue: widget.initialValueCallback(
?.call(EditProfileForm.formFieldBusyMessage) as String?, EditProfileForm.formFieldBusyMessage) as String,
maxLength: 128, maxLength: 128,
decoration: InputDecoration( decoration: InputDecoration(
floatingLabelBehavior: FloatingLabelBehavior.always,
labelText: translate('account.form_busy_message'), labelText: translate('account.form_busy_message'),
hintText: translate('account.empty_busy_message')), hintText: translate('account.empty_busy_message')),
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
), ).onFocusChange(_onFocusChange),
FormBuilderCheckbox( FormBuilderCheckbox(
name: EditProfileForm.formFieldAutoAway, name: EditProfileForm.formFieldAutoAway,
initialValue: widget.initialValueCallback initialValue:
?.call(EditProfileForm.formFieldAutoAway) as bool? ?? widget.initialValueCallback(EditProfileForm.formFieldAutoAway)
false, as bool,
side: BorderSide(color: scale.primaryScale.border, width: 2), side: BorderSide(color: scale.primaryScale.border, width: 2),
title: Text(translate('account.form_auto_away'), title: Text(translate('account.form_auto_away'),
style: textTheme.labelMedium), style: textTheme.labelMedium),
), onChanged: (v) {
setState(() {
_autoAwayEnabled = v ?? false;
});
},
).onFocusChange(_onFocusChange),
FormBuilderTextField( FormBuilderTextField(
name: EditProfileForm.formFieldAutoAwayTimeout, name: EditProfileForm.formFieldAutoAwayTimeout,
enabled: _formKey.currentState enabled: _autoAwayEnabled,
?.value[EditProfileForm.formFieldAutoAway] as bool? ?? initialValue: widget.initialValueCallback(
false, EditProfileForm.formFieldAutoAwayTimeout) as String,
initialValue: widget.initialValueCallback
?.call(EditProfileForm.formFieldAutoAwayTimeout)
as String? ??
'15',
decoration: InputDecoration( decoration: InputDecoration(
labelText: translate('account.form_auto_away_timeout'), labelText: translate('account.form_auto_away_timeout'),
), ),
validator: FormBuilderValidators.positiveNumber(), validator: FormBuilderValidators.positiveNumber(),
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
), ).onFocusChange(_onFocusChange),
Row(children: [ Row(children: [
const Spacer(), const Spacer(),
Text(widget.instructions).toCenter().flexible(flex: 6), Text(widget.instructions).toCenter().flexible(flex: 6),
const Spacer(), const Spacer(),
]).paddingSymmetric(vertical: 4), ]).paddingSymmetric(vertical: 4),
ElevatedButton( if (widget.onSubmit != null)
onPressed: widget.onSubmit == null ElevatedButton(
? null onPressed: widget.onSubmit == null ? null : _doSubmit,
: () async { child: Row(mainAxisSize: MainAxisSize.min, children: [
if (_formKey.currentState?.saveAndValidate() ?? false) { const Icon(Icons.check, size: 16).paddingLTRB(0, 0, 4, 0),
final aus = _makeAccountSpec(); Text((widget.onSubmit == null)
await widget.onSubmit!(aus); ? widget.submitDisabledText
} : widget.submitText)
}, .paddingLTRB(0, 0, 4, 0)
child: Row(mainAxisSize: MainAxisSize.min, children: [ ]),
const Icon(Icons.check, size: 16).paddingLTRB(0, 0, 4, 0), )
Text((widget.onSubmit == null)
? widget.submitDisabledText
: widget.submitText)
.paddingLTRB(0, 0, 4, 0)
]),
).paddingSymmetric(vertical: 4).alignAtCenterRight(),
], ],
), ),
); );
} }
void _onFocusChange(bool focused) {
if (!focused) {
_doUpdate();
}
}
void _doUpdate() {
final onUpdate = widget.onUpdate;
if (onUpdate != null) {
singleFuture((this, _kDoUpdateSubmit), () async {
if (_formKey.currentState?.saveAndValidate() ?? false) {
final aus = _makeAccountSpec();
await onUpdate(aus);
}
});
}
}
void _doSubmit() {
final onSubmit = widget.onSubmit;
if (onSubmit != null) {
singleFuture((this, _kDoUpdateSubmit), () async {
if (_formKey.currentState?.saveAndValidate() ?? false) {
final aus = _makeAccountSpec();
await onSubmit(aus);
}
});
}
}
@override @override
Widget build(BuildContext context) => _editProfileForm( Widget build(BuildContext context) => _editProfileForm(
context, context,
); );
///////////////////////////////////////////////////////////////////////////
late bool _autoAwayEnabled;
} }

View File

@ -7,6 +7,8 @@ import 'package:flutter_translate/flutter_translate.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../layout/default_app_bar.dart'; import '../../layout/default_app_bar.dart';
import '../../notifications/cubits/notifications_cubit.dart';
import '../../proto/proto.dart' as proto;
import '../../theme/theme.dart'; import '../../theme/theme.dart';
import '../../tools/tools.dart'; import '../../tools/tools.dart';
import '../../veilid_processor/veilid_processor.dart'; import '../../veilid_processor/veilid_processor.dart';
@ -26,25 +28,44 @@ class _NewAccountPageState extends WindowSetupState<NewAccountPage> {
titleBarStyle: TitleBarStyle.normal, titleBarStyle: TitleBarStyle.normal,
orientationCapability: OrientationCapability.portraitOnly); orientationCapability: OrientationCapability.portraitOnly);
Widget _newAccountForm(BuildContext context, Object _defaultAccountValues(String key) {
{required Future<void> Function(AccountSpec) onSubmit}) { switch (key) {
final networkReady = context case EditProfileForm.formFieldName:
.watch<ConnectionStateCubit>() return '';
.state case EditProfileForm.formFieldPronouns:
.asData return '';
?.value case EditProfileForm.formFieldAbout:
.isPublicInternetReady ?? return '';
false; case EditProfileForm.formFieldAvailability:
final canSubmit = networkReady; return proto.Availability.AVAILABILITY_FREE;
case EditProfileForm.formFieldFreeMessage:
return EditProfileForm( return '';
header: translate('new_account_page.header'), case EditProfileForm.formFieldAwayMessage:
instructions: translate('new_account_page.instructions'), return '';
submitText: translate('new_account_page.create'), case EditProfileForm.formFieldBusyMessage:
submitDisabledText: translate('button.waiting_for_network'), return '';
onSubmit: !canSubmit ? null : onSubmit); // case EditProfileForm.formFieldAvatar:
// return null;
case EditProfileForm.formFieldAutoAway:
return false;
case EditProfileForm.formFieldAutoAwayTimeout:
return '15';
default:
throw StateError('missing form element');
}
} }
Widget _newAccountForm(
BuildContext context,
) =>
EditProfileForm(
header: translate('new_account_page.header'),
instructions: translate('new_account_page.instructions'),
submitText: translate('new_account_page.create'),
submitDisabledText: translate('button.waiting_for_network'),
initialValueCallback: _defaultAccountValues,
onSubmit: _onSubmit);
Future<void> _onSubmit(AccountSpec accountSpec) async { Future<void> _onSubmit(AccountSpec accountSpec) async {
// dismiss the keyboard by unfocusing the textfield // dismiss the keyboard by unfocusing the textfield
FocusScope.of(context).unfocus(); FocusScope.of(context).unfocus();
@ -54,6 +75,22 @@ class _NewAccountPageState extends WindowSetupState<NewAccountPage> {
_isInAsyncCall = true; _isInAsyncCall = true;
}); });
try { try {
final networkReady = context
.read<ConnectionStateCubit>()
.state
.asData
?.value
.isPublicInternetReady ??
false;
final canSubmit = networkReady;
if (!canSubmit) {
context.read<NotificationsCubit>().error(
text: translate('new_account_page.network_is_offline'),
title: translate('new_account_page.error'));
return;
}
final writableSuperIdentity = await AccountRepository.instance final writableSuperIdentity = await AccountRepository.instance
.createWithNewSuperIdentity(accountSpec); .createWithNewSuperIdentity(accountSpec);
GoRouterHelper(context).pushReplacement('/new_account/recovery_key', GoRouterHelper(context).pushReplacement('/new_account/recovery_key',
@ -100,7 +137,6 @@ class _NewAccountPageState extends WindowSetupState<NewAccountPage> {
body: SingleChildScrollView( body: SingleChildScrollView(
child: _newAccountForm( child: _newAccountForm(
context, context,
onSubmit: _onSubmit,
)).paddingSymmetric(horizontal: 24, vertical: 8), )).paddingSymmetric(horizontal: 24, vertical: 8),
).withModalHUD(context, displayModalHUD); ).withModalHUD(context, displayModalHUD);
} }

View File

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_translate/flutter_translate.dart'; import 'package:flutter_translate/flutter_translate.dart';
import '../../chat/cubits/active_chat_cubit.dart'; import '../../chat/cubits/active_chat_cubit.dart';
import '../../contacts/contacts.dart';
import '../../proto/proto.dart' as proto; import '../../proto/proto.dart' as proto;
import '../../theme/theme.dart'; import '../../theme/theme.dart';
import '../chat_list.dart'; import '../chat_list.dart';
@ -23,28 +24,33 @@ class ChatSingleContactItemWidget extends StatelessWidget {
Widget build( Widget build(
BuildContext context, BuildContext context,
) { ) {
final theme = Theme.of(context);
final scale = theme.extension<ScaleScheme>()!;
final scaleConfig = theme.extension<ScaleConfig>()!;
final activeChatCubit = context.watch<ActiveChatCubit>(); final activeChatCubit = context.watch<ActiveChatCubit>();
final localConversationRecordKey = final localConversationRecordKey =
_contact.localConversationRecordKey.toVeilid(); _contact.localConversationRecordKey.toVeilid();
final selected = activeChatCubit.state == localConversationRecordKey; final selected = activeChatCubit.state == localConversationRecordKey;
late final String title; final name = _contact.nameOrNickname;
late final String subtitle; final title = _contact.displayName;
if (_contact.nickname.isNotEmpty) { final subtitle = _contact.profile.status;
title = _contact.nickname;
if (_contact.profile.pronouns.isNotEmpty) { final avatar = AvatarWidget(
subtitle = '${_contact.profile.name} (${_contact.profile.pronouns})'; name: name,
} else { size: 34,
subtitle = _contact.profile.name; borderColor: _disabled
} ? scale.grayScale.primaryText
} else { : scale.secondaryScale.primaryText,
title = _contact.profile.name; foregroundColor: _disabled
if (_contact.profile.pronouns.isNotEmpty) { ? scale.grayScale.primaryText
subtitle = '(${_contact.profile.pronouns})'; : scale.secondaryScale.primaryText,
} else { backgroundColor:
subtitle = ''; _disabled ? scale.grayScale.primary : scale.secondaryScale.primary,
} scaleConfig: scaleConfig,
} textStyle: theme.textTheme.titleLarge!,
);
return SliderTile( return SliderTile(
key: ObjectKey(_contact), key: ObjectKey(_contact),
@ -53,7 +59,8 @@ class ChatSingleContactItemWidget extends StatelessWidget {
tileScale: ScaleKind.secondary, tileScale: ScaleKind.secondary,
title: title, title: title,
subtitle: subtitle, subtitle: subtitle,
icon: Icons.chat, leading: avatar,
trailing: AvailabilityWidget(availability: _contact.profile.availability),
onTap: () { onTap: () {
singleFuture(activeChatCubit, () async { singleFuture(activeChatCubit, () async {
activeChatCubit.setActiveChat(localConversationRecordKey); activeChatCubit.setActiveChat(localConversationRecordKey);

View File

@ -45,7 +45,7 @@ class ContactInvitationItemWidget extends StatelessWidget {
title: contactInvitationRecord.message.isEmpty title: contactInvitationRecord.message.isEmpty
? translate('contact_list.invitation') ? translate('contact_list.invitation')
: contactInvitationRecord.message, : contactInvitationRecord.message,
icon: Icons.person_add, leading: const Icon(Icons.person_add),
onTap: () async { onTap: () async {
if (!context.mounted) { if (!context.mounted) {
return; return;

View File

@ -203,34 +203,37 @@ class CreateInvitationDialogState extends State<CreateInvitationDialog> {
Text(translate('create_invitation_dialog.protect_this_invitation'), Text(translate('create_invitation_dialog.protect_this_invitation'),
style: textTheme.labelLarge) style: textTheme.labelLarge)
.paddingAll(8), .paddingAll(8),
Wrap(spacing: 5, children: [ Wrap(
ChoiceChip( alignment: WrapAlignment.center,
label: Text(translate('create_invitation_dialog.unlocked')), runAlignment: WrapAlignment.center,
selected: _encryptionKeyType == EncryptionKeyType.none, runSpacing: 8,
onSelected: _onNoneEncryptionSelected, spacing: 8,
), children: [
ChoiceChip( ChoiceChip(
label: Text(translate('create_invitation_dialog.pin')), label: Text(translate('create_invitation_dialog.unlocked')),
selected: _encryptionKeyType == EncryptionKeyType.pin, selected: _encryptionKeyType == EncryptionKeyType.none,
onSelected: _onPinEncryptionSelected, onSelected: _onNoneEncryptionSelected,
), ),
ChoiceChip( ChoiceChip(
label: Text(translate('create_invitation_dialog.password')), label: Text(translate('create_invitation_dialog.pin')),
selected: _encryptionKeyType == EncryptionKeyType.password, selected: _encryptionKeyType == EncryptionKeyType.pin,
onSelected: _onPasswordEncryptionSelected, onSelected: _onPinEncryptionSelected,
) ),
]).paddingAll(8), ChoiceChip(
label: Text(translate('create_invitation_dialog.password')),
selected: _encryptionKeyType == EncryptionKeyType.password,
onSelected: _onPasswordEncryptionSelected,
)
]).paddingAll(8).toCenter(),
Container( Container(
width: double.infinity,
height: 60,
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: ElevatedButton( child: ElevatedButton(
onPressed: _onGenerateButtonPressed, onPressed: _onGenerateButtonPressed,
child: Text( child: Text(
translate('create_invitation_dialog.generate'), translate('create_invitation_dialog.generate'),
), ).paddingAll(16),
), ),
), ).toCenter(),
Text(translate('create_invitation_dialog.note')).paddingAll(8), Text(translate('create_invitation_dialog.note')).paddingAll(8),
Text( Text(
translate('create_invitation_dialog.note_text'), translate('create_invitation_dialog.note_text'),

View File

@ -1,3 +1,4 @@
import 'package:awesome_extensions/awesome_extensions.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_translate/flutter_translate.dart'; import 'package:flutter_translate/flutter_translate.dart';
@ -5,21 +6,27 @@ import 'package:flutter_translate/flutter_translate.dart';
import '../../proto/proto.dart' as proto; import '../../proto/proto.dart' as proto;
class AvailabilityWidget extends StatelessWidget { class AvailabilityWidget extends StatelessWidget {
const AvailabilityWidget({required this.availability, super.key}); const AvailabilityWidget(
{required this.availability,
this.vertical = true,
this.iconSize = 32,
super.key});
static IconData availabilityIcon(proto.Availability availability) { static Widget availabilityIcon(proto.Availability availability,
late final IconData iconData; {double size = 32}) {
late final Widget iconData;
switch (availability) { switch (availability) {
case proto.Availability.AVAILABILITY_AWAY: case proto.Availability.AVAILABILITY_AWAY:
iconData = Icons.hot_tub; iconData =
ImageIcon(const AssetImage('assets/images/toilet.png'), size: size);
case proto.Availability.AVAILABILITY_BUSY: case proto.Availability.AVAILABILITY_BUSY:
iconData = Icons.event_busy; iconData = Icon(Icons.event_busy, size: size);
case proto.Availability.AVAILABILITY_FREE: case proto.Availability.AVAILABILITY_FREE:
iconData = Icons.event_available; iconData = Icon(Icons.event_available, size: size);
case proto.Availability.AVAILABILITY_OFFLINE: case proto.Availability.AVAILABILITY_OFFLINE:
iconData = Icons.cloud_off; iconData = Icon(Icons.cloud_off, size: size);
case proto.Availability.AVAILABILITY_UNSPECIFIED: case proto.Availability.AVAILABILITY_UNSPECIFIED:
iconData = Icons.question_mark; iconData = Icon(Icons.question_mark, size: size);
} }
return iconData; return iconData;
} }
@ -49,20 +56,35 @@ class AvailabilityWidget extends StatelessWidget {
// final scaleConfig = theme.extension<ScaleConfig>()!; // final scaleConfig = theme.extension<ScaleConfig>()!;
final name = availabilityName(availability); final name = availabilityName(availability);
final iconData = availabilityIcon(availability); final icon = availabilityIcon(availability, size: iconSize);
return Row(mainAxisSize: MainAxisSize.min, children: [ return vertical
Icon(iconData, size: 32), ? Column(
Text(name, style: textTheme.labelSmall) mainAxisSize: MainAxisSize.min,
]); //mainAxisAlignment: MainAxisAlignment.center,
children: [
icon,
Text(name, style: textTheme.labelSmall).paddingLTRB(0, 0, 0, 0)
])
: Row(mainAxisSize: MainAxisSize.min, children: [
icon,
Text(name, style: textTheme.labelSmall).paddingLTRB(8, 0, 0, 0)
]);
} }
////////////////////////////////////////////////////////////////////////////
final proto.Availability availability; final proto.Availability availability;
final bool vertical;
final double iconSize;
@override @override
void debugFillProperties(DiagnosticPropertiesBuilder properties) { void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties); super.debugFillProperties(properties);
properties.add( properties
DiagnosticsProperty<proto.Availability>('availability', availability)); ..add(
DiagnosticsProperty<proto.Availability>('availability', availability))
..add(DiagnosticsProperty<bool>('vertical', vertical))
..add(DoubleProperty('iconSize', iconSize));
} }
} }

View File

@ -28,24 +28,28 @@ class ContactItemWidget extends StatelessWidget {
Widget build( Widget build(
BuildContext context, BuildContext context,
) { ) {
late final String title; final theme = Theme.of(context);
late final String subtitle; final scale = theme.extension<ScaleScheme>()!;
final scaleConfig = theme.extension<ScaleConfig>()!;
if (_contact.nickname.isNotEmpty) { final name = _contact.nameOrNickname;
title = _contact.nickname; final title = _contact.displayName;
if (_contact.profile.pronouns.isNotEmpty) { final subtitle = _contact.profile.status;
subtitle = '${_contact.profile.name} (${_contact.profile.pronouns})';
} else { final avatar = AvatarWidget(
subtitle = _contact.profile.name; name: name,
} size: 34,
} else { borderColor: _disabled
title = _contact.profile.name; ? scale.grayScale.primaryText
if (_contact.profile.pronouns.isNotEmpty) { : scale.primaryScale.primaryText,
subtitle = '(${_contact.profile.pronouns})'; foregroundColor: _disabled
} else { ? scale.grayScale.primaryText
subtitle = ''; : scale.primaryScale.primaryText,
} backgroundColor:
} _disabled ? scale.grayScale.primary : scale.primaryScale.primary,
scaleConfig: scaleConfig,
textStyle: theme.textTheme.titleLarge!,
);
return SliderTile( return SliderTile(
key: ObjectKey(_contact), key: ObjectKey(_contact),
@ -54,7 +58,7 @@ class ContactItemWidget extends StatelessWidget {
tileScale: ScaleKind.primary, tileScale: ScaleKind.primary,
title: title, title: title,
subtitle: subtitle, subtitle: subtitle,
icon: Icons.person, leading: avatar,
onDoubleTap: _onDoubleTap == null onDoubleTap: _onDoubleTap == null
? null ? null
: () => singleFuture<void>((this, _kOnTap), () async { : () => singleFuture<void>((this, _kOnTap), () async {

View File

@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_translate/flutter_translate.dart'; import 'package:flutter_translate/flutter_translate.dart';
import 'package:searchable_listview/searchable_listview.dart'; import 'package:searchable_listview/searchable_listview.dart';
import 'package:star_menu/star_menu.dart';
import 'package:veilid_support/veilid_support.dart'; import 'package:veilid_support/veilid_support.dart';
import '../../chat_list/chat_list.dart'; import '../../chat_list/chat_list.dart';
@ -71,6 +72,75 @@ class _ContactsBrowserState extends State<ContactsBrowser>
final scale = theme.extension<ScaleScheme>()!; final scale = theme.extension<ScaleScheme>()!;
final scaleConfig = theme.extension<ScaleConfig>()!; final scaleConfig = theme.extension<ScaleConfig>()!;
final menuIconColor = scaleConfig.preferBorders
? scale.primaryScale.hoverBorder
: scale.primaryScale.borderText;
final menuBackgroundColor = scaleConfig.preferBorders
? scale.primaryScale.elementBackground
: scale.primaryScale.border;
// final menuHoverColor = scaleConfig.preferBorders
// ? scale.primaryScale.hoverElementBackground
// : scale.primaryScale.hoverBorder;
final menuBorderColor = scale.primaryScale.hoverBorder;
final menuParams = StarMenuParameters(
shape: MenuShape.grid,
checkItemsScreenBoundaries: true,
centerOffset: const Offset(0, 64),
backgroundParams:
BackgroundParams(backgroundColor: theme.shadowColor.withAlpha(128)),
boundaryBackground: BoundaryBackground(
color: menuBackgroundColor,
decoration: ShapeDecoration(
color: menuBackgroundColor,
shape: RoundedRectangleBorder(
side: scaleConfig.useVisualIndicators
? BorderSide(
width: 2, color: menuBorderColor, strokeAlign: 0)
: BorderSide.none,
borderRadius: BorderRadius.circular(
8 * scaleConfig.borderRadiusScale)))));
final receiveInviteMenuItems = [
Column(mainAxisSize: MainAxisSize.min, children: [
IconButton(
onPressed: () async {
_receiveInviteMenuController.closeMenu!();
await ScanInvitationDialog.show(context);
},
iconSize: 32,
icon: Icon(
Icons.qr_code_scanner,
size: 32,
color: menuIconColor,
),
),
Text(translate('add_contact_sheet.scan_invite'),
maxLines: 2,
textAlign: TextAlign.center,
style: textTheme.labelSmall!.copyWith(color: menuIconColor))
]).paddingAll(4),
Column(mainAxisSize: MainAxisSize.min, children: [
IconButton(
onPressed: () async {
_receiveInviteMenuController.closeMenu!();
await PasteInvitationDialog.show(context);
},
iconSize: 32,
icon: Icon(
Icons.paste,
size: 32,
color: menuIconColor,
),
),
Text(translate('add_contact_sheet.paste_invite'),
maxLines: 2,
textAlign: TextAlign.center,
style: textTheme.labelSmall!.copyWith(color: menuIconColor))
]).paddingAll(4)
];
return Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ return Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [
Column(mainAxisSize: MainAxisSize.min, children: [ Column(mainAxisSize: MainAxisSize.min, children: [
IconButton( IconButton(
@ -80,30 +150,36 @@ class _ContactsBrowserState extends State<ContactsBrowser>
iconSize: 32, iconSize: 32,
icon: const Icon(Icons.contact_page), icon: const Icon(Icons.contact_page),
color: scale.primaryScale.hoverBorder, color: scale.primaryScale.hoverBorder,
tooltip: translate('add_contact_sheet.create_invite'),
)
]),
Column(mainAxisSize: MainAxisSize.min, children: [
IconButton(
onPressed: () async {
await ScanInvitationDialog.show(context);
},
iconSize: 32,
icon: const Icon(Icons.qr_code_scanner),
color: scale.primaryScale.hoverBorder,
tooltip: translate('add_contact_sheet.scan_invite')),
]),
Column(mainAxisSize: MainAxisSize.min, children: [
IconButton(
onPressed: () async {
await PasteInvitationDialog.show(context);
},
iconSize: 32,
icon: const Icon(Icons.paste),
color: scale.primaryScale.hoverBorder,
tooltip: translate('add_contact_sheet.paste_invite'),
), ),
]) Text(translate('add_contact_sheet.create_invite'),
maxLines: 2,
textAlign: TextAlign.center,
style: textTheme.labelSmall!
.copyWith(color: scale.primaryScale.hoverBorder))
]),
StarMenu(
items: receiveInviteMenuItems,
onItemTapped: (_index, controller) {
controller.closeMenu!();
},
controller: _receiveInviteMenuController,
params: menuParams,
child: Column(mainAxisSize: MainAxisSize.min, children: [
IconButton(
onPressed: () {},
iconSize: 32,
icon: ImageIcon(
const AssetImage('assets/images/handshake.png'),
size: 32,
color: scale.primaryScale.hoverBorder,
)),
Text(translate('add_contact_sheet.receive_invite'),
maxLines: 2,
textAlign: TextAlign.center,
style: textTheme.labelSmall!
.copyWith(color: scale.primaryScale.hoverBorder))
]),
),
]).paddingAll(16); ]).paddingAll(16);
} }
@ -112,7 +188,7 @@ class _ContactsBrowserState extends State<ContactsBrowser>
final theme = Theme.of(context); final theme = Theme.of(context);
final textTheme = theme.textTheme; final textTheme = theme.textTheme;
final scale = theme.extension<ScaleScheme>()!; final scale = theme.extension<ScaleScheme>()!;
final scaleConfig = theme.extension<ScaleConfig>()!; //final scaleConfig = theme.extension<ScaleConfig>()!;
final cilState = context.watch<ContactInvitationListCubit>().state; final cilState = context.watch<ContactInvitationListCubit>().state;
final cilBusy = cilState.busy; final cilBusy = cilState.busy;
@ -244,4 +320,7 @@ class _ContactsBrowserState extends State<ContactsBrowser>
await chatListCubit.deleteChat( await chatListCubit.deleteChat(
localConversationRecordKey: localConversationRecordKey); localConversationRecordKey: localConversationRecordKey);
} }
////////////////////////////////////////////////////////////////////////////
final _receiveInviteMenuController = StarMenuController();
} }

View File

@ -1,18 +1,14 @@
import 'package:awesome_extensions/awesome_extensions.dart'; import 'package:awesome_extensions/awesome_extensions.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_translate/flutter_translate.dart'; import 'package:flutter_translate/flutter_translate.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../chat/chat.dart'; import '../../chat/chat.dart';
import '../../chat_list/chat_list.dart'; import '../../chat_list/chat_list.dart';
import '../../proto/proto.dart' as proto;
import '../../contact_invitation/contact_invitation.dart';
import '../../layout/layout.dart'; import '../../layout/layout.dart';
import '../../proto/proto.dart' as proto;
import '../../theme/theme.dart'; import '../../theme/theme.dart';
import '../../veilid_processor/veilid_processor.dart';
import '../contacts.dart'; import '../contacts.dart';
class ContactsDialog extends StatefulWidget { class ContactsDialog extends StatefulWidget {
@ -48,9 +44,9 @@ class _ContactsDialogState extends State<ContactsDialog> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final textTheme = theme.textTheme; // final textTheme = theme.textTheme;
final scale = theme.extension<ScaleScheme>()!; final scale = theme.extension<ScaleScheme>()!;
final scaleConfig = theme.extension<ScaleConfig>()!; // final scaleConfig = theme.extension<ScaleConfig>()!;
final enableSplit = !isMobileWidth(context); final enableSplit = !isMobileWidth(context);
final enableLeft = enableSplit || _selectedContact == null; final enableLeft = enableSplit || _selectedContact == null;
@ -105,7 +101,7 @@ class _ContactsDialogState extends State<ContactsDialog> {
.toVeilid(), .toVeilid(),
onContactSelected: onContactSelected, onContactSelected: onContactSelected,
onChatStarted: onChatStarted, onChatStarted: onChatStarted,
).paddingAll(8)))), ).paddingLTRB(8, 0, 8, 8)))),
if (enableRight) if (enableRight)
if (_selectedContact == null) if (_selectedContact == null)
const NoContactWidget().expanded() const NoContactWidget().expanded()

View File

@ -31,6 +31,7 @@ extension MessageExt on proto.Message {
} }
extension ContactExt on proto.Contact { extension ContactExt on proto.Contact {
String get nameOrNickname => nickname.isNotEmpty ? nickname : profile.name;
String get displayName => String get displayName =>
nickname.isNotEmpty ? '$nickname (${profile.name})' : profile.name; nickname.isNotEmpty ? '$nickname (${profile.name})' : profile.name;
} }

View File

@ -31,7 +31,8 @@ class SliderTile extends StatelessWidget {
this.startActions = const [], this.startActions = const [],
this.onTap, this.onTap,
this.onDoubleTap, this.onDoubleTap,
this.icon, this.leading,
this.trailing,
super.key}); super.key});
final bool disabled; final bool disabled;
@ -41,7 +42,8 @@ class SliderTile extends StatelessWidget {
final List<SliderTileAction> startActions; final List<SliderTileAction> startActions;
final GestureTapCallback? onTap; final GestureTapCallback? onTap;
final GestureTapCallback? onDoubleTap; final GestureTapCallback? onDoubleTap;
final IconData? icon; final Widget? leading;
final Widget? trailing;
final String title; final String title;
final String subtitle; final String subtitle;
@ -55,11 +57,12 @@ class SliderTile extends StatelessWidget {
..add(IterableProperty<SliderTileAction>('endActions', endActions)) ..add(IterableProperty<SliderTileAction>('endActions', endActions))
..add(IterableProperty<SliderTileAction>('startActions', startActions)) ..add(IterableProperty<SliderTileAction>('startActions', startActions))
..add(ObjectFlagProperty<GestureTapCallback?>.has('onTap', onTap)) ..add(ObjectFlagProperty<GestureTapCallback?>.has('onTap', onTap))
..add(DiagnosticsProperty<IconData?>('icon', icon)) ..add(DiagnosticsProperty<Widget?>('leading', leading))
..add(StringProperty('title', title)) ..add(StringProperty('title', title))
..add(StringProperty('subtitle', subtitle)) ..add(StringProperty('subtitle', subtitle))
..add(ObjectFlagProperty<GestureTapCallback?>.has( ..add(ObjectFlagProperty<GestureTapCallback?>.has(
'onDoubleTap', onDoubleTap)); 'onDoubleTap', onDoubleTap))
..add(DiagnosticsProperty<Widget?>('trailing', trailing));
} }
@override @override
@ -156,6 +159,7 @@ class SliderTile extends StatelessWidget {
subtitle: subtitle.isNotEmpty ? Text(subtitle) : null, subtitle: subtitle.isNotEmpty ? Text(subtitle) : null,
iconColor: textColor, iconColor: textColor,
textColor: textColor, textColor: textColor,
leading: icon == null ? null : Icon(icon)))))); leading: FittedBox(child: leading),
trailing: FittedBox(child: trailing))))));
} }
} }

View File

@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_spinkit/flutter_spinkit.dart'; import 'package:flutter_spinkit/flutter_spinkit.dart';
import 'package:flutter_sticky_header/flutter_sticky_header.dart'; import 'package:flutter_sticky_header/flutter_sticky_header.dart';
import 'package:meta/meta.dart';
import 'package:quickalert/quickalert.dart'; import 'package:quickalert/quickalert.dart';
import 'package:sliver_expandable/sliver_expandable.dart'; import 'package:sliver_expandable/sliver_expandable.dart';
@ -27,6 +28,38 @@ extension SizeToFixExt on Widget {
); );
} }
extension FocusExt<T> on Widget {
Focus focus(
{Key? key,
FocusNode? focusNode,
FocusNode? parentNode,
bool autofocus = false,
ValueChanged<bool>? onFocusChange,
FocusOnKeyEventCallback? onKeyEvent,
bool? canRequestFocus,
bool? skipTraversal,
bool? descendantsAreFocusable,
bool? descendantsAreTraversable,
bool includeSemantics = true,
String? debugLabel}) =>
Focus(
key: key,
focusNode: focusNode,
parentNode: parentNode,
autofocus: autofocus,
onFocusChange: onFocusChange,
onKeyEvent: onKeyEvent,
canRequestFocus: canRequestFocus,
skipTraversal: skipTraversal,
descendantsAreFocusable: descendantsAreFocusable,
descendantsAreTraversable: descendantsAreTraversable,
includeSemantics: includeSemantics,
debugLabel: debugLabel,
child: this);
Focus onFocusChange(void Function(bool) onFocusChange) =>
Focus(onFocusChange: onFocusChange, child: this);
}
extension ModalProgressExt on Widget { extension ModalProgressExt on Widget {
BlurryModalProgressHUD withModalHUD(BuildContext context, bool isLoading) { BlurryModalProgressHUD withModalHUD(BuildContext context, bool isLoading) {
final theme = Theme.of(context); final theme = Theme.of(context);

View File

@ -1468,6 +1468,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.11.1" version: "1.11.1"
star_menu:
dependency: "direct main"
description:
name: star_menu
sha256: f29c7d255677c49ec2412ec2d17220d967f54b72b9e6afc5688fe122ea4d1d78
url: "https://pub.dev"
source: hosted
version: "4.0.1"
stream_channel: stream_channel:
dependency: transitive dependency: transitive
description: description:

View File

@ -97,6 +97,7 @@ dependencies:
ref: main ref: main
split_view: ^3.2.1 split_view: ^3.2.1
stack_trace: ^1.11.1 stack_trace: ^1.11.1
star_menu: ^4.0.1
stream_transform: ^2.1.0 stream_transform: ^2.1.0
transitioned_indexed_stack: ^1.0.2 transitioned_indexed_stack: ^1.0.2
url_launcher: ^6.3.0 url_launcher: ^6.3.0
@ -163,6 +164,8 @@ flutter:
- assets/images/title.svg - assets/images/title.svg
- assets/images/vlogo.svg - assets/images/vlogo.svg
- assets/images/ellet.png - assets/images/ellet.png
- assets/images/toilet.png
- assets/images/handshake.png
# Printing # Printing
- assets/js/pdf/3.2.146/pdf.min.js - assets/js/pdf/3.2.146/pdf.min.js
# Sounds # Sounds