From 01c6490ec4ed5b8fc96e1e43b970c2621acaf943 Mon Sep 17 00:00:00 2001 From: Christien Rioux Date: Sat, 27 Jul 2024 18:36:06 -0400 Subject: [PATCH 1/5] ui improvements for invitations --- assets/i18n/en.json | 11 +++- .../cubits/contact_invitation_list_cubit.dart | 27 +++++--- .../views/contact_invitation_display.dart | 48 ++++++++++---- .../views/create_invitation_dialog.dart | 13 ++-- .../views/invitation_dialog.dart | 44 +++++++++++-- .../views/notifications_preferences.dart | 65 +++++++++++++++---- packages/veilid_support/pubspec.lock | 14 ++-- packages/veilid_support/pubspec.yaml | 10 +-- pubspec.lock | 14 ++-- pubspec.yaml | 10 +-- 10 files changed, 179 insertions(+), 77 deletions(-) diff --git a/assets/i18n/en.json b/assets/i18n/en.json index 0fad430..22feb44 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -122,7 +122,9 @@ }, "create_invitation_dialog": { "title": "Create Contact Invitation", - "connect_with_me": "Connect with me on VeilidChat!", + "me": "me", + "fingerprint": "Fingerprint:", + "connect_with_me": "Connect with {name} on VeilidChat!", "enter_message_hint": "Enter message for contact (optional)", "message_to_contact": "Message to send with invitation (not encrypted)", "generate": "Generate Invitation", @@ -148,6 +150,7 @@ "failed_to_reject": "Failed to reject contact invitation", "invalid_invitation": "Invalid invitation", "try_again_online": "Invitation could not be reached, try again when online", + "key_not_found": "Invitation could not be found, it may not be on the network yet", "protected_with_pin": "Contact invitation is protected with a PIN", "protected_with_password": "Contact invitation is protected with a password", "invalid_pin": "Invalid PIN", @@ -155,7 +158,7 @@ }, "waiting_invitation": { "accepted": "Contact invitation accepted from {name}", - "reject": "Contact invitation was rejected" + "rejected": "Contact invitation was rejected" }, "paste_invitation_dialog": { "title": "Paste Contact Invite", @@ -225,6 +228,10 @@ "in_app": "In-app", "push": "Push", "in_app_or_push": "In-app or Push", + "notifications": "Notifications", + "event": "Event", + "sound": "Sound", + "delivery": "Delivery", "enable_badge": "Enable icon 'badge' bubble", "enable_notifications": "Enable notifications", "message_notification_content": "Message notification content", diff --git a/lib/contact_invitation/cubits/contact_invitation_list_cubit.dart b/lib/contact_invitation/cubits/contact_invitation_list_cubit.dart index f3e9521..f5663af 100644 --- a/lib/contact_invitation/cubits/contact_invitation_list_cubit.dart +++ b/lib/contact_invitation/cubits/contact_invitation_list_cubit.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:async_tools/async_tools.dart'; import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:fixnum/fixnum.dart'; @@ -214,9 +215,11 @@ class ContactInvitationListCubit } } - Future validateInvitation( - {required Uint8List inviteData, - required GetEncryptionKeyCallback getEncryptionKeyCallback}) async { + Future validateInvitation({ + required Uint8List inviteData, + required GetEncryptionKeyCallback getEncryptionKeyCallback, + required CancelRequest cancelRequest, + }) async { final pool = DHTRecordPool.instance; // Get contact request inbox from invitation @@ -245,15 +248,18 @@ class ContactInvitationListCubit contactRequestInboxKey) != -1; - await (await pool.openRecordRead(contactRequestInboxKey, - debugName: 'ContactInvitationListCubit::validateInvitation::' - 'ContactRequestInbox', - parent: pool.getParentRecordKey(contactRequestInboxKey) ?? - _accountInfo.accountRecordKey)) + await (await pool + .openRecordRead(contactRequestInboxKey, + debugName: 'ContactInvitationListCubit::validateInvitation::' + 'ContactRequestInbox', + parent: pool.getParentRecordKey(contactRequestInboxKey) ?? + _accountInfo.accountRecordKey) + .withCancel(cancelRequest)) .maybeDeleteScope(!isSelf, (contactRequestInbox) async { // final contactRequest = await contactRequestInbox - .getProtobuf(proto.ContactRequest.fromBuffer); + .getProtobuf(proto.ContactRequest.fromBuffer) + .withCancel(cancelRequest); final cs = await pool.veilid.getCryptoSystem(contactRequestInboxKey.kind); @@ -281,7 +287,8 @@ class ContactInvitationListCubit // Fetch the account master final contactSuperIdentity = await SuperIdentity.open( - superRecordKey: contactSuperIdentityRecordKey); + superRecordKey: contactSuperIdentityRecordKey) + .withCancel(cancelRequest); // Verify final idcs = await contactSuperIdentity.currentInstance.cryptoSystem; diff --git a/lib/contact_invitation/views/contact_invitation_display.dart b/lib/contact_invitation/views/contact_invitation_display.dart index d816fc3..83b80d0 100644 --- a/lib/contact_invitation/views/contact_invitation_display.dart +++ b/lib/contact_invitation/views/contact_invitation_display.dart @@ -11,6 +11,7 @@ import 'package:provider/provider.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'package:veilid_support/veilid_support.dart'; +import '../../account_manager/account_manager.dart'; import '../../notifications/notifications.dart'; import '../../proto/proto.dart' as proto; import '../../theme/theme.dart'; @@ -20,17 +21,20 @@ class ContactInvitationDisplayDialog extends StatelessWidget { const ContactInvitationDisplayDialog._({ required this.locator, required this.message, + required this.fingerprint, }); final Locator locator; final String message; + final String fingerprint; @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties ..add(StringProperty('message', message)) - ..add(DiagnosticsProperty('locator', locator)); + ..add(DiagnosticsProperty('locator', locator)) + ..add(StringProperty('fingerprint', fingerprint)); } String makeTextInvite(String message, Uint8List data) { @@ -38,10 +42,12 @@ class ContactInvitationDisplayDialog extends StatelessWidget { base64UrlNoPadEncode(data), '\n', 40, repeat: true); final msg = message.isNotEmpty ? '$message\n' : ''; + return '$msg' '--- BEGIN VEILIDCHAT CONTACT INVITE ----\n' '$invite\n' - '---- END VEILIDCHAT CONTACT INVITE -----\n'; + '---- END VEILIDCHAT CONTACT INVITE -----\n' + 'Fingerprint:\n$fingerprint\n'; } @override @@ -97,18 +103,27 @@ class ContactInvitationDisplayDialog extends StatelessWidget { .copyWith(color: Colors.black))) .paddingAll(8), FittedBox( - child: QrImageView.withQr( - size: 300, - qr: QrCode.fromUint8List( - data: data.$1, - errorCorrectLevel: - QrErrorCorrectLevel.L))) - .expanded(), + child: QrImageView.withQr( + size: 300, + qr: QrCode.fromUint8List( + data: data.$1, + errorCorrectLevel: + QrErrorCorrectLevel.L)), + ).expanded(), Text(message, softWrap: true, style: textTheme.labelLarge! .copyWith(color: Colors.black)) .paddingAll(8), + Text( + '${translate('create_invitation_dialog.fingerprint')}\n' + '$fingerprint', + softWrap: true, + textAlign: TextAlign.center, + style: textTheme.labelSmall!.copyWith( + color: Colors.black, + fontFamily: 'Source Code Pro')) + .paddingAll(2), ElevatedButton.icon( icon: const Icon(Icons.copy), style: ElevatedButton.styleFrom( @@ -129,11 +144,15 @@ class ContactInvitationDisplayDialog extends StatelessWidget { error: errorPage))))); } - static Future show( - {required BuildContext context, - required Locator locator, - required InvitationGeneratorCubit Function(BuildContext) create, - required String message}) async { + static Future show({ + required BuildContext context, + required Locator locator, + required InvitationGeneratorCubit Function(BuildContext) create, + required String message, + }) async { + final fingerprint = + locator().state.identityPublicKey.toString(); + await showPopControlDialog( context: context, builder: (context) => BlocProvider( @@ -141,6 +160,7 @@ class ContactInvitationDisplayDialog extends StatelessWidget { child: ContactInvitationDisplayDialog._( locator: locator, message: message, + fingerprint: fingerprint, ))); } } diff --git a/lib/contact_invitation/views/create_invitation_dialog.dart b/lib/contact_invitation/views/create_invitation_dialog.dart index 9117d03..5711a32 100644 --- a/lib/contact_invitation/views/create_invitation_dialog.dart +++ b/lib/contact_invitation/views/create_invitation_dialog.dart @@ -37,8 +37,7 @@ class CreateInvitationDialog extends StatefulWidget { } class CreateInvitationDialogState extends State { - final _messageTextController = TextEditingController( - text: translate('create_invitation_dialog.connect_with_me')); + late final TextEditingController _messageTextController; EncryptionKeyType _encryptionKeyType = EncryptionKeyType.none; String _encryptionKey = ''; @@ -46,6 +45,12 @@ class CreateInvitationDialogState extends State { @override void initState() { + final accountInfo = widget.locator().state; + final name = accountInfo.asData?.value.profile.name ?? + translate('create_invitation_dialog.me'); + _messageTextController = TextEditingController( + text: translate('create_invitation_dialog.connect_with_me', + args: {'name': name})); super.initState(); } @@ -152,13 +157,13 @@ class CreateInvitationDialogState extends State { message: _messageTextController.text, expiration: _expiration); + navigator.pop(); + await ContactInvitationDisplayDialog.show( context: context, locator: widget.locator, message: _messageTextController.text, create: (context) => InvitationGeneratorCubit(generator)); - - navigator.pop(); } @override diff --git a/lib/contact_invitation/views/invitation_dialog.dart b/lib/contact_invitation/views/invitation_dialog.dart index 5e1ece6..385cbcb 100644 --- a/lib/contact_invitation/views/invitation_dialog.dart +++ b/lib/contact_invitation/views/invitation_dialog.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:async_tools/async_tools.dart'; import 'package:awesome_extensions/awesome_extensions.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -61,17 +62,19 @@ class InvitationDialog extends StatefulWidget { } class InvitationDialogState extends State { - ValidContactInvitation? _validInvitation; - bool _isValidating = false; - bool _isAccepting = false; - @override void initState() { super.initState(); } - bool get isValidating => _isValidating; - bool get isAccepting => _isAccepting; + Future _onCancel() async { + final navigator = Navigator.of(context); + _cancelRequest.cancel(); + setState(() { + _isAccepting = false; + }); + navigator.pop(); + } Future _onAccept() async { final navigator = Navigator.of(context); @@ -153,6 +156,7 @@ class InvitationDialogState extends State { final validatedContactInvitation = await contactInvitationListCubit.validateInvitation( inviteData: inviteData, + cancelRequest: _cancelRequest, getEncryptionKeyCallback: (cs, encryptionKeyType, encryptedSecret) async { String encryptionKey; @@ -234,6 +238,9 @@ class InvitationDialogState extends State { late final String errorText; if (e is VeilidAPIExceptionTryAgain) { errorText = translate('invitation_dialog.try_again_online'); + } + if (e is VeilidAPIExceptionKeyNotFound) { + errorText = translate('invitation_dialog.key_not_found'); } else { errorText = translate('invitation_dialog.invalid_invitation'); } @@ -245,6 +252,12 @@ class InvitationDialogState extends State { _validInvitation = null; widget.onValidationFailed(); }); + } on CancelException { + setState(() { + _isValidating = false; + _validInvitation = null; + widget.onValidationCancelled(); + }); } on Exception catch (e) { log.debug('exception: $e', e); setState(() { @@ -264,6 +277,11 @@ class InvitationDialogState extends State { Text(translate('invitation_dialog.validating')) .paddingLTRB(0, 0, 0, 16), buildProgressIndicator().paddingAll(16), + ElevatedButton.icon( + icon: const Icon(Icons.cancel), + label: Text(translate('button.cancel')), + onPressed: _onCancel, + ).paddingAll(16), ]).toCenter(), if (_validInvitation == null && !_isValidating && @@ -315,13 +333,25 @@ class InvitationDialogState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: _isAccepting - ? [buildProgressIndicator().paddingAll(16)] + ? [ + buildProgressIndicator().paddingAll(16), + ] : _buildPreAccept()), ), ); return PopControl(dismissible: dismissible, child: dialog); } + //////////////////////////////////////////////////////////////////////////// + + ValidContactInvitation? _validInvitation; + bool _isValidating = false; + bool _isAccepting = false; + final _cancelRequest = CancelRequest(); + + bool get isValidating => _isValidating; + bool get isAccepting => _isAccepting; + @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); diff --git a/lib/notifications/views/notifications_preferences.dart b/lib/notifications/views/notifications_preferences.dart index 12f4667..5967522 100644 --- a/lib/notifications/views/notifications_preferences.dart +++ b/lib/notifications/views/notifications_preferences.dart @@ -52,7 +52,11 @@ Widget buildSettingsPageNotificationPreferences( out.add(DropdownMenuItem( value: x.$1, enabled: x.$2, - child: Text(x.$3, style: textTheme.labelSmall))); + child: Text( + x.$3, + style: textTheme.labelSmall, + textAlign: TextAlign.center, + ))); } return out; } @@ -71,7 +75,11 @@ Widget buildSettingsPageNotificationPreferences( out.add(DropdownMenuItem( value: x.$1, enabled: x.$2, - child: Text(x.$3, style: textTheme.labelSmall))); + child: Text( + x.$3, + style: textTheme.labelSmall, + textAlign: TextAlign.center, + ))); } return out; } @@ -100,17 +108,23 @@ Widget buildSettingsPageNotificationPreferences( out.add(DropdownMenuItem( value: x.$1, enabled: x.$2, - child: Text(x.$3, style: textTheme.labelSmall))); + child: Text( + x.$3, + style: textTheme.labelSmall, + textAlign: TextAlign.center, + ))); } return out; } - return DecoratedBox( - decoration: ShapeDecoration( - shape: RoundedRectangleBorder( - side: BorderSide(width: 2, color: scale.primaryScale.border), - borderRadius: - BorderRadius.circular(8 * scaleConfig.borderRadiusScale))), + return InputDecorator( + decoration: InputDecoration( + labelText: translate('settings_page.notifications'), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8 * scaleConfig.borderRadiusScale), + borderSide: BorderSide(width: 2, color: scale.primaryScale.border), + ), + ), child: Column(mainAxisSize: MainAxisSize.min, children: [ // Display Beta Warning FormBuilderCheckbox( @@ -175,12 +189,35 @@ Widget buildSettingsPageNotificationPreferences( await updatePreferences(newNotificationsPreference); }, items: messageNotificationContentItems(), - ).paddingAll(8), + ).paddingLTRB(0, 4, 0, 4), // Notifications Table( defaultVerticalAlignment: TableCellVerticalAlignment.middle, children: [ + TableRow(children: [ + Text(translate('settings_page.event'), + textAlign: TextAlign.center, + style: textTheme.titleMedium!.copyWith( + color: scale.primaryScale.border, + decorationColor: scale.primaryScale.border, + decoration: TextDecoration.underline)) + .paddingAll(8), + Text(translate('settings_page.delivery'), + textAlign: TextAlign.center, + style: textTheme.titleMedium!.copyWith( + color: scale.primaryScale.border, + decorationColor: scale.primaryScale.border, + decoration: TextDecoration.underline)) + .paddingAll(8), + Text(translate('settings_page.sound'), + textAlign: TextAlign.center, + style: textTheme.titleMedium!.copyWith( + color: scale.primaryScale.border, + decorationColor: scale.primaryScale.border, + decoration: TextDecoration.underline)) + .paddingAll(8), + ]), TableRow(children: [ // Invitation accepted Text( @@ -216,7 +253,7 @@ Widget buildSettingsPageNotificationPreferences( await updatePreferences(newNotificationsPreference); }, items: soundEffectItems(), - ).paddingAll(4) + ).paddingLTRB(4, 4, 0, 4) ]), // Message received TableRow(children: [ @@ -253,7 +290,7 @@ Widget buildSettingsPageNotificationPreferences( await updatePreferences(newNotificationsPreference); }, items: soundEffectItems(), - ).paddingAll(4) + ).paddingLTRB(4, 4, 0, 4) ]), // Message sent @@ -277,9 +314,9 @@ Widget buildSettingsPageNotificationPreferences( await updatePreferences(newNotificationsPreference); }, items: soundEffectItems(), - ).paddingAll(4) + ).paddingLTRB(4, 4, 0, 4) ]), - ]).paddingAll(8) + ]) ]).paddingAll(8), ); } diff --git a/packages/veilid_support/pubspec.lock b/packages/veilid_support/pubspec.lock index 7f08359..e68b6db 100644 --- a/packages/veilid_support/pubspec.lock +++ b/packages/veilid_support/pubspec.lock @@ -36,10 +36,9 @@ packages: async_tools: dependency: "direct main" description: - name: async_tools - sha256: "375da1e5b51974a9e84469b7630f36d1361fb53d3fcc818a5a0121ab51fe0343" - url: "https://pub.dev" - source: hosted + path: "../../../dart_async_tools" + relative: true + source: path version: "0.1.4" bloc: dependency: "direct main" @@ -52,10 +51,9 @@ packages: bloc_advanced_tools: dependency: "direct main" description: - name: bloc_advanced_tools - sha256: "0599d860eb096c5b12457c60bdf7f66bfcb7171bc94ccf2c7c752b6a716a79f9" - url: "https://pub.dev" - source: hosted + path: "../../../bloc_advanced_tools" + relative: true + source: path version: "0.1.4" boolean_selector: dependency: transitive diff --git a/packages/veilid_support/pubspec.yaml b/packages/veilid_support/pubspec.yaml index 90d7ad8..51c3e60 100644 --- a/packages/veilid_support/pubspec.yaml +++ b/packages/veilid_support/pubspec.yaml @@ -26,11 +26,11 @@ dependencies: # veilid: ^0.0.1 path: ../../../veilid/veilid-flutter -# dependency_overrides: -# async_tools: -# path: ../../../dart_async_tools -# bloc_advanced_tools: -# path: ../../../bloc_advanced_tools +dependency_overrides: + async_tools: + path: ../../../dart_async_tools + bloc_advanced_tools: + path: ../../../bloc_advanced_tools dev_dependencies: build_runner: ^2.4.10 diff --git a/pubspec.lock b/pubspec.lock index 2b3149b..6d78407 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -84,10 +84,9 @@ packages: async_tools: dependency: "direct main" description: - name: async_tools - sha256: "375da1e5b51974a9e84469b7630f36d1361fb53d3fcc818a5a0121ab51fe0343" - url: "https://pub.dev" - source: hosted + path: "../dart_async_tools" + relative: true + source: path version: "0.1.4" awesome_extensions: dependency: "direct main" @@ -140,10 +139,9 @@ packages: bloc_advanced_tools: dependency: "direct main" description: - name: bloc_advanced_tools - sha256: "0599d860eb096c5b12457c60bdf7f66bfcb7171bc94ccf2c7c752b6a716a79f9" - url: "https://pub.dev" - source: hosted + path: "../bloc_advanced_tools" + relative: true + source: path version: "0.1.4" blurry_modal_progress_hud: dependency: "direct main" diff --git a/pubspec.yaml b/pubspec.yaml index fea8acb..2c0b8eb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -107,11 +107,11 @@ dependencies: xterm: ^4.0.0 zxing2: ^0.2.3 -# dependency_overrides: -# async_tools: -# path: ../dart_async_tools -# bloc_advanced_tools: -# path: ../bloc_advanced_tools +dependency_overrides: + async_tools: + path: ../dart_async_tools + bloc_advanced_tools: + path: ../bloc_advanced_tools # flutter_chat_ui: # path: ../flutter_chat_ui From 5e4f47d5a181643535e45b40d9d53f806847784a Mon Sep 17 00:00:00 2001 From: Christien Rioux Date: Wed, 31 Jul 2024 12:04:43 -0500 Subject: [PATCH 2/5] account management update --- assets/i18n/en.json | 60 +++- .../cubits/account_record_cubit.dart | 37 ++- lib/account_manager/models/account_spec.dart | 55 ++++ lib/account_manager/models/models.dart | 2 +- .../models/new_profile_spec.dart | 5 - .../repository/account_repository.dart | 21 +- .../views/edit_account_page.dart | 52 +-- .../views/edit_profile_form.dart | 302 ++++++++++++++++++ .../views/new_account_page.dart | 21 +- .../views/profile_edit_form.dart | 118 ------- .../views/contact_invitation_list_widget.dart | 2 +- lib/contacts/cubits/contact_list_cubit.dart | 38 ++- lib/contacts/views/availability_widget.dart | 68 ++++ .../views/contact_details_widget.dart | 41 +++ lib/contacts/views/contact_item_widget.dart | 82 +++-- lib/contacts/views/contact_list_widget.dart | 86 ----- lib/contacts/views/contacts_browser.dart | 247 ++++++++++++++ lib/contacts/views/contacts_dialog.dart | 140 ++++++++ lib/contacts/views/edit_contact_form.dart | 174 ++++++++++ lib/contacts/views/no_contact_widget.dart | 41 +++ lib/contacts/views/views.dart | 7 +- lib/layout/home/drawer_menu/drawer_menu.dart | 79 +++-- lib/layout/home/home_account_ready.dart | 110 ++++--- lib/layout/home/home_screen.dart | 9 +- .../bottom_sheet_action_button.dart | 68 ---- lib/layout/home/main_pager/chats_page.dart | 28 -- lib/layout/home/main_pager/contacts_page.dart | 58 ---- lib/layout/home/main_pager/main_pager.dart | 242 -------------- lib/layout/layout.dart | 1 - lib/proto/veilidchat.pb.dart | 50 ++- lib/proto/veilidchat.pbjson.dart | 15 +- lib/proto/veilidchat.proto | 21 +- lib/router/cubits/router_cubit.dart | 2 +- lib/settings/settings_page.dart | 4 +- lib/theme/models/slider_tile.dart | 34 +- lib/theme/views/avatar_widget.dart | 77 +++++ lib/theme/views/styled_dialog.dart | 1 + lib/theme/views/styled_scaffold.dart | 18 +- lib/theme/views/views.dart | 1 + lib/theme/views/widget_helpers.dart | 55 ++++ pubspec.lock | 13 +- pubspec.yaml | 9 +- 42 files changed, 1663 insertions(+), 831 deletions(-) create mode 100644 lib/account_manager/models/account_spec.dart delete mode 100644 lib/account_manager/models/new_profile_spec.dart create mode 100644 lib/account_manager/views/edit_profile_form.dart delete mode 100644 lib/account_manager/views/profile_edit_form.dart create mode 100644 lib/contacts/views/availability_widget.dart create mode 100644 lib/contacts/views/contact_details_widget.dart delete mode 100644 lib/contacts/views/contact_list_widget.dart create mode 100644 lib/contacts/views/contacts_browser.dart create mode 100644 lib/contacts/views/contacts_dialog.dart create mode 100644 lib/contacts/views/edit_contact_form.dart create mode 100644 lib/contacts/views/no_contact_widget.dart delete mode 100644 lib/layout/home/main_pager/bottom_sheet_action_button.dart delete mode 100644 lib/layout/home/main_pager/chats_page.dart delete mode 100644 lib/layout/home/main_pager/contacts_page.dart delete mode 100644 lib/layout/home/main_pager/main_pager.dart create mode 100644 lib/theme/views/avatar_widget.dart diff --git a/assets/i18n/en.json b/assets/i18n/en.json index 22feb44..ade2dcb 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -3,7 +3,9 @@ "title": "VeilidChat" }, "menu": { - "settings_tooltip": "Settings", + "accounts_menu_tooltip": "Accounts Menu", + "contacts_tooltip": "Contacts List", + "new_chat_tooltip": "Start New Chat", "add_account_tooltip": "Add Account", "accounts": "Accounts", "version": "Version" @@ -12,13 +14,23 @@ "beta_title": "VeilidChat is BETA SOFTWARE", "beta_text": "DO NOT USE THIS FOR ANYTHING IMPORTANT\n\nUntil 1.0 is released:\n\n• You should have no expectations of actual privacy, or guarantees of security.\n• You will likely lose accounts, contacts, and messages and need to recreate them.\n\nPlease read our BETA PARTICIPATION GUIDE located here:\n\n" }, - "pager": { - "chats": "Chats", - "contacts": "Contacts" - }, "account": { "form_name": "Name", - "form_pronouns": "Pronouns (optional)", + "empty_name": "Your name (required)", + "form_pronouns": "Pronouns", + "empty_pronouns": "(optional pronouns)", + "form_about": "About Me", + "empty_about": "Tell your contacts about yourself", + "form_free_message": "Free Message", + "empty_free_message": "Status when availability is 'Free'", + "form_away_message": "Away Message", + "empty_away_message": "Status when availability is 'Away'", + "form_busy_message": "Free Message", + "empty_busy_message": "Status when availability is 'Busy'", + "form_availability": "Availability", + "form_avatar": "Avatar", + "form_auto_away": "Automatic 'away' detection", + "form_auto_away_timeout": "Auto-away timeout (in minutes)", "form_lock_type": "Lock Type", "lock_type_none": "none", "lock_type_pin": "pin", @@ -101,11 +113,40 @@ "invalid_account_title": "Invalid Account", "invalid_account_text": "Account is invalid, removing from list" }, - "contacts_page": { + "contacts_dialog": { "contacts": "Contacts", + "edit_contact": "Edit Contact", "invitations": "Invitations", + "no_contact_selected": "No contact selected", + "new_chat": "New Chat" + }, + "contact_list": { + "contacts": "Contacts", + "invite_people": "Invite people to VeilidChat", + "search": "Search contacts", + "invitation": "Invitation", "loading_contacts": "Loading contacts..." }, + "contact_form": { + "form_name": "Name", + "form_pronouns": "Pronouns", + "form_about": "About", + "form_status": "Current Status", + "form_nickname": "Nickname", + "form_notes": "Notes", + "form_fingerprint": "Fingerprint", + "form_show_availability": "Show availability", + "save": "Save", + "save_disabled": "Save" + }, + "availability": { + "unspecified": "Unspecified", + "offline": "Offline", + "always_show_offline": "Always Show Offline", + "free": "Free", + "busy": "Busy", + "away": "Away" + }, "add_contact_sheet": { "new_contact": "New Contact", "create_invite": "Create Invitation", @@ -188,11 +229,6 @@ "reenter_password": "Re-Enter Password To Confirm", "password_does_not_match": "Password does not match" }, - "contact_list": { - "invite_people": "Invite people to VeilidChat", - "search": "Search contacts", - "invitation": "Invitation" - }, "chat_list": { "search": "Search chats", "start_a_conversation": "Start A Conversation", diff --git a/lib/account_manager/cubits/account_record_cubit.dart b/lib/account_manager/cubits/account_record_cubit.dart index 4028d65..0762926 100644 --- a/lib/account_manager/cubits/account_record_cubit.dart +++ b/lib/account_manager/cubits/account_record_cubit.dart @@ -40,12 +40,43 @@ class AccountRecordCubit extends DefaultDHTRecordCubit { //////////////////////////////////////////////////////////////////////////// // Public Interface - Future updateProfile(proto.Profile profile) async { + Future updateAccount( + AccountSpec accountSpec, + ) async { await record.eventualUpdateProtobuf(proto.Account.fromBuffer, (old) async { - if (old == null || old.profile == profile) { + if (old == null) { return null; } - return old.deepCopy()..profile = profile; + + final newAccount = old.deepCopy() + ..profile.name = accountSpec.name + ..profile.pronouns = accountSpec.pronouns + ..profile.about = accountSpec.about + ..profile.availability = accountSpec.availability + ..profile.status = accountSpec.status + //..profile.avatar = + ..profile.timestamp = Veilid.instance.now().toInt64() + ..invisible = accountSpec.invisible + ..autodetectAway = accountSpec.autoAway + ..autoAwayTimeoutMin = accountSpec.autoAwayTimeout + ..freeMessage = accountSpec.freeMessage + ..awayMessage = accountSpec.awayMessage + ..busyMessage = accountSpec.busyMessage; + + var changed = false; + if (newAccount.profile != old.profile || + newAccount.invisible != old.invisible || + newAccount.autodetectAway != old.autodetectAway || + newAccount.autoAwayTimeoutMin != old.autoAwayTimeoutMin || + newAccount.freeMessage != old.freeMessage || + newAccount.busyMessage != old.busyMessage || + newAccount.awayMessage != old.awayMessage) { + changed = true; + } + if (changed) { + return newAccount; + } + return null; }); } } diff --git a/lib/account_manager/models/account_spec.dart b/lib/account_manager/models/account_spec.dart new file mode 100644 index 0000000..539b8d0 --- /dev/null +++ b/lib/account_manager/models/account_spec.dart @@ -0,0 +1,55 @@ +import 'package:flutter/widgets.dart'; + +import '../../proto/proto.dart' as proto; + +/// Profile and Account configurable fields +/// Some are publicly visible via the proto.Profile +/// Some are privately held as proto.Account configurations +class AccountSpec { + AccountSpec( + {required this.name, + required this.pronouns, + required this.about, + required this.availability, + required this.invisible, + required this.freeMessage, + required this.awayMessage, + required this.busyMessage, + required this.avatar, + required this.autoAway, + required this.autoAwayTimeout}); + + String get status { + late final String status; + switch (availability) { + case proto.Availability.AVAILABILITY_AWAY: + status = awayMessage; + break; + case proto.Availability.AVAILABILITY_BUSY: + status = busyMessage; + break; + case proto.Availability.AVAILABILITY_FREE: + status = freeMessage; + break; + case proto.Availability.AVAILABILITY_UNSPECIFIED: + case proto.Availability.AVAILABILITY_OFFLINE: + status = ''; + break; + } + return status; + } + + //////////////////////////////////////////////////////////////////////////// + + String name; + String pronouns; + String about; + proto.Availability availability; + bool invisible; + String freeMessage; + String awayMessage; + String busyMessage; + ImageProvider? avatar; + bool autoAway; + int autoAwayTimeout; +} diff --git a/lib/account_manager/models/models.dart b/lib/account_manager/models/models.dart index 2860eec..8b785c6 100644 --- a/lib/account_manager/models/models.dart +++ b/lib/account_manager/models/models.dart @@ -1,6 +1,6 @@ export 'account_info.dart'; +export 'account_update_spec.dart'; export 'encryption_key_type.dart'; export 'local_account/local_account.dart'; -export 'new_profile_spec.dart'; export 'per_account_collection_state/per_account_collection_state.dart'; export 'user_login/user_login.dart'; diff --git a/lib/account_manager/models/new_profile_spec.dart b/lib/account_manager/models/new_profile_spec.dart deleted file mode 100644 index 173a382..0000000 --- a/lib/account_manager/models/new_profile_spec.dart +++ /dev/null @@ -1,5 +0,0 @@ -class NewProfileSpec { - NewProfileSpec({required this.name, required this.pronouns}); - String name; - String pronouns; -} diff --git a/lib/account_manager/repository/account_repository.dart b/lib/account_manager/repository/account_repository.dart index 9510997..13954ba 100644 --- a/lib/account_manager/repository/account_repository.dart +++ b/lib/account_manager/repository/account_repository.dart @@ -133,14 +133,14 @@ class AccountRepository { /// with the identity instance, stores the account in the identity key and /// then logs into that account with no password set at this time Future createWithNewSuperIdentity( - proto.Profile newProfile) async { + AccountSpec accountSpec) async { log.debug('Creating super identity'); final wsi = await WritableSuperIdentity.create(); try { final localAccount = await _newLocalAccount( superIdentity: wsi.superIdentity, identitySecret: wsi.identitySecret, - newProfile: newProfile); + accountSpec: accountSpec); // Log in the new account by default with no pin final ok = await login( @@ -154,15 +154,13 @@ class AccountRepository { } } - Future editAccountProfile( - TypedKey superIdentityRecordKey, proto.Profile newProfile) async { - log.debug('Editing profile for $superIdentityRecordKey'); - + Future updateLocalAccount( + TypedKey superIdentityRecordKey, AccountSpec accountSpec) async { final localAccounts = await _localAccounts.get(); final newLocalAccounts = localAccounts.replaceFirstWhere( (x) => x.superIdentity.recordKey == superIdentityRecordKey, - (localAccount) => localAccount!.copyWith(name: newProfile.name)); + (localAccount) => localAccount!.copyWith(name: accountSpec.name)); await _localAccounts.set(newLocalAccounts); _streamController.add(AccountRepositoryChange.localAccounts); @@ -248,7 +246,7 @@ class AccountRepository { Future _newLocalAccount( {required SuperIdentity superIdentity, required SecretKey identitySecret, - required proto.Profile newProfile, + required AccountSpec accountSpec, EncryptionKeyType encryptionKeyType = EncryptionKeyType.none, String encryptionKey = ''}) async { log.debug('Creating new local account'); @@ -285,7 +283,10 @@ class AccountRepository { // Make account object final account = proto.Account() - ..profile = newProfile + ..profile.name = accountSpec.name + ..profile.pronouns = accountSpec.pronouns + ..profile.about = accountSpec.about + ..profile.status = accountSpec.status ..contactList = contactList.toProto() ..contactInvitationRecords = contactInvitationRecords.toProto() ..chatList = chatRecords.toProto(); @@ -309,7 +310,7 @@ class AccountRepository { encryptionKeyType: encryptionKeyType, biometricsEnabled: false, hiddenAccount: false, - name: newProfile.name, + name: accountSpec.name, ); // Add local account object to internal store diff --git a/lib/account_manager/views/edit_account_page.dart b/lib/account_manager/views/edit_account_page.dart index 0554def..4f19429 100644 --- a/lib/account_manager/views/edit_account_page.dart +++ b/lib/account_manager/views/edit_account_page.dart @@ -4,10 +4,8 @@ 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:go_router/go_router.dart'; -import 'package:protobuf/protobuf.dart'; import 'package:veilid_support/veilid_support.dart'; import '../../layout/default_app_bar.dart'; @@ -17,12 +15,12 @@ import '../../theme/theme.dart'; import '../../tools/tools.dart'; import '../../veilid_processor/veilid_processor.dart'; import '../account_manager.dart'; -import 'profile_edit_form.dart'; +import 'edit_profile_form.dart'; class EditAccountPage extends StatefulWidget { const EditAccountPage( {required this.superIdentityRecordKey, - required this.existingProfile, + required this.existingAccount, required this.accountRecord, super.key}); @@ -30,7 +28,7 @@ class EditAccountPage extends StatefulWidget { State createState() => _EditAccountPageState(); final TypedKey superIdentityRecordKey; - final proto.Profile existingProfile; + final proto.Account existingAccount; final OwnedDHTRecordPointer accountRecord; @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { @@ -38,8 +36,8 @@ class EditAccountPage extends StatefulWidget { properties ..add(DiagnosticsProperty( 'superIdentityRecordKey', superIdentityRecordKey)) - ..add(DiagnosticsProperty( - 'existingProfile', existingProfile)) + ..add(DiagnosticsProperty( + 'existingAccount', existingAccount)) ..add(DiagnosticsProperty( 'accountRecord', accountRecord)); } @@ -52,8 +50,7 @@ class _EditAccountPageState extends WindowSetupState { orientationCapability: OrientationCapability.portraitOnly); Widget _editAccountForm(BuildContext context, - {required Future Function(GlobalKey) - onSubmit}) => + {required Future Function(AccountSpec) onSubmit}) => EditProfileForm( header: translate('edit_account_page.header'), instructions: translate('edit_account_page.instructions'), @@ -61,8 +58,25 @@ class _EditAccountPageState extends WindowSetupState { submitDisabledText: translate('button.waiting_for_network'), onSubmit: onSubmit, initialValueCallback: (key) => switch (key) { - EditProfileForm.formFieldName => widget.existingProfile.name, - EditProfileForm.formFieldPronouns => widget.existingProfile.pronouns, + EditProfileForm.formFieldName => widget.existingAccount.profile.name, + EditProfileForm.formFieldPronouns => + widget.existingAccount.profile.pronouns, + EditProfileForm.formFieldAbout => + widget.existingAccount.profile.about, + EditProfileForm.formFieldAvailability => + widget.existingAccount.profile.availability, + EditProfileForm.formFieldFreeMessage => + widget.existingAccount.freeMessage, + EditProfileForm.formFieldAwayMessage => + widget.existingAccount.awayMessage, + EditProfileForm.formFieldBusyMessage => + widget.existingAccount.busyMessage, + EditProfileForm.formFieldAvatar => + widget.existingAccount.profile.avatar, + EditProfileForm.formFieldAutoAway => + widget.existingAccount.autodetectAway, + EditProfileForm.formFieldAutoAwayTimeout => + widget.existingAccount.autoAwayTimeoutMin, String() => throw UnimplementedError(), }, ); @@ -200,21 +214,11 @@ class _EditAccountPageState extends WindowSetupState { } } - Future _onSubmit(GlobalKey formKey) async { + Future _onSubmit(AccountSpec accountSpec) async { // dismiss the keyboard by unfocusing the textfield FocusScope.of(context).unfocus(); try { - final name = formKey - .currentState!.fields[EditProfileForm.formFieldName]!.value as String; - final pronouns = formKey.currentState! - .fields[EditProfileForm.formFieldPronouns]!.value as String? ?? - ''; - final newProfile = widget.existingProfile.deepCopy() - ..name = name - ..pronouns = pronouns - ..timestamp = Veilid.instance.now().toInt64(); - setState(() { _isInAsyncCall = true; }); @@ -231,11 +235,11 @@ class _EditAccountPageState extends WindowSetupState { // Update account profile DHT record // This triggers ConversationCubits to update - await accountRecordCubit.updateProfile(newProfile); + await accountRecordCubit.updateAccount(accountSpec); // Update local account profile await AccountRepository.instance - .editAccountProfile(widget.superIdentityRecordKey, newProfile); + .updateLocalAccount(widget.superIdentityRecordKey, accountSpec); if (mounted) { Navigator.canPop(context) diff --git a/lib/account_manager/views/edit_profile_form.dart b/lib/account_manager/views/edit_profile_form.dart new file mode 100644 index 0000000..c9a328e --- /dev/null +++ b/lib/account_manager/views/edit_profile_form.dart @@ -0,0 +1,302 @@ +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.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 '../models/models.dart'; + +class EditProfileForm extends StatefulWidget { + const EditProfileForm({ + required this.header, + required this.instructions, + required this.submitText, + required this.submitDisabledText, + super.key, + this.onSubmit, + this.initialValueCallback, + }); + + @override + State createState() => _EditProfileFormState(); + + final String header; + final String instructions; + final Future Function(AccountSpec)? onSubmit; + final String submitText; + final String submitDisabledText; + final Object? Function(String key)? initialValueCallback; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(StringProperty('header', header)) + ..add(StringProperty('instructions', instructions)) + ..add(ObjectFlagProperty Function(AccountSpec)?>.has( + 'onSubmit', onSubmit)) + ..add(StringProperty('submitText', submitText)) + ..add(StringProperty('submitDisabledText', submitDisabledText)) + ..add(ObjectFlagProperty.has( + 'initialValueCallback', initialValueCallback)); + } + + 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 { + final _formKey = GlobalKey(); + + @override + void initState() { + super.initState(); + } + + FormBuilderDropdown _availabilityDropDown( + BuildContext context) { + final initialValueX = + widget.initialValueCallback?.call(EditProfileForm.formFieldAvailability) + as proto.Availability? ?? + proto.Availability.AVAILABILITY_FREE; + final initialValue = + initialValueX == proto.Availability.AVAILABILITY_UNSPECIFIED + ? proto.Availability.AVAILABILITY_FREE + : initialValueX; + + final availabilities = [ + proto.Availability.AVAILABILITY_FREE, + proto.Availability.AVAILABILITY_AWAY, + proto.Availability.AVAILABILITY_BUSY, + proto.Availability.AVAILABILITY_OFFLINE, + ]; + + return FormBuilderDropdown( + name: EditProfileForm.formFieldAvailability, + initialValue: initialValue, + items: availabilities + .map((x) => DropdownMenuItem( + value: x, + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(AvailabilityWidget.availabilityIcon(x)), + Text(x == proto.Availability.AVAILABILITY_OFFLINE + ? translate('availability.always_show_offline') + : AvailabilityWidget.availabilityName(x)), + ]))) + .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? ?? + proto.Availability.AVAILABILITY_FREE; + + 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? ?? + ''; + final autoAway = _formKey.currentState! + .fields[EditProfileForm.formFieldAutoAway]!.value as bool? ?? + false; + final autoAwayTimeout = _formKey.currentState! + .fields[EditProfileForm.formFieldAutoAwayTimeout]!.value as int? ?? + 30; + + return AccountSpec( + name: name, + pronouns: pronouns, + about: about, + availability: availability, + invisible: invisible, + freeMessage: freeMessage, + awayMessage: awayMessage, + busyMessage: busyMessage, + avatar: null, + autoAway: autoAway, + autoAwayTimeout: autoAwayTimeout); + } + + Widget _editProfileForm( + BuildContext context, + ) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + 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, + child: Column( + children: [ + AvatarWidget( + name: _formKey.currentState?.value[EditProfileForm.formFieldName] + as String? ?? + '?', + 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), + FormBuilderTextField( + autofocus: true, + name: EditProfileForm.formFieldName, + initialValue: widget.initialValueCallback + ?.call(EditProfileForm.formFieldName) as String?, + decoration: InputDecoration( + 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: widget.initialValueCallback + ?.call(EditProfileForm.formFieldPronouns) as String?, + maxLength: 64, + decoration: InputDecoration( + labelText: translate('account.form_pronouns'), + hintText: translate('account.empty_pronouns')), + textInputAction: TextInputAction.next, + ), + FormBuilderTextField( + name: EditProfileForm.formFieldAbout, + initialValue: widget.initialValueCallback + ?.call(EditProfileForm.formFieldAbout) as String?, + maxLength: 1024, + maxLines: 8, + minLines: 1, + decoration: InputDecoration( + labelText: translate('account.form_about'), + hintText: translate('account.empty_about')), + textInputAction: TextInputAction.newline, + ), + _availabilityDropDown(context), + FormBuilderTextField( + name: EditProfileForm.formFieldFreeMessage, + initialValue: widget.initialValueCallback + ?.call(EditProfileForm.formFieldFreeMessage) as String?, + maxLength: 128, + decoration: InputDecoration( + labelText: translate('account.form_free_message'), + hintText: translate('account.empty_free_message')), + textInputAction: TextInputAction.next, + ), + FormBuilderTextField( + name: EditProfileForm.formFieldAwayMessage, + initialValue: widget.initialValueCallback + ?.call(EditProfileForm.formFieldAwayMessage) as String?, + maxLength: 128, + decoration: InputDecoration( + labelText: translate('account.form_away_message'), + hintText: translate('account.empty_away_message')), + textInputAction: TextInputAction.next, + ), + FormBuilderTextField( + name: EditProfileForm.formFieldBusyMessage, + initialValue: widget.initialValueCallback + ?.call(EditProfileForm.formFieldBusyMessage) as String?, + maxLength: 128, + decoration: InputDecoration( + labelText: translate('account.form_busy_message'), + hintText: translate('account.empty_busy_message')), + textInputAction: TextInputAction.next, + ), + FormBuilderCheckbox( + name: EditProfileForm.formFieldAutoAway, + initialValue: widget.initialValueCallback + ?.call(EditProfileForm.formFieldAutoAway) as bool? ?? + false, + side: BorderSide(color: scale.primaryScale.border, width: 2), + title: Text(translate('account.form_auto_away'), + style: textTheme.labelMedium), + ), + FormBuilderTextField( + name: EditProfileForm.formFieldAutoAwayTimeout, + enabled: _formKey.currentState + ?.value[EditProfileForm.formFieldAutoAway] as bool? ?? + false, + initialValue: widget.initialValueCallback + ?.call(EditProfileForm.formFieldAutoAwayTimeout) + as String? ?? + '15', + 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: 4), + ElevatedButton( + onPressed: widget.onSubmit == null + ? null + : () async { + if (_formKey.currentState?.saveAndValidate() ?? false) { + final aus = _makeAccountSpec(); + await widget.onSubmit!(aus); + } + }, + 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(), + ], + ), + ); + } + + @override + Widget build(BuildContext context) => _editProfileForm( + context, + ); +} diff --git a/lib/account_manager/views/new_account_page.dart b/lib/account_manager/views/new_account_page.dart index 943e79e..b107a7b 100644 --- a/lib/account_manager/views/new_account_page.dart +++ b/lib/account_manager/views/new_account_page.dart @@ -3,17 +3,15 @@ import 'dart:async'; import 'package:awesome_extensions/awesome_extensions.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:go_router/go_router.dart'; import '../../layout/default_app_bar.dart'; -import '../../proto/proto.dart' as proto; import '../../theme/theme.dart'; import '../../tools/tools.dart'; import '../../veilid_processor/veilid_processor.dart'; import '../account_manager.dart'; -import 'profile_edit_form.dart'; +import 'edit_profile_form.dart'; class NewAccountPage extends StatefulWidget { const NewAccountPage({super.key}); @@ -29,7 +27,7 @@ class _NewAccountPageState extends WindowSetupState { orientationCapability: OrientationCapability.portraitOnly); Widget _newAccountForm(BuildContext context, - {required Future Function(GlobalKey) onSubmit}) { + {required Future Function(AccountSpec) onSubmit}) { final networkReady = context .watch() .state @@ -47,28 +45,19 @@ class _NewAccountPageState extends WindowSetupState { onSubmit: !canSubmit ? null : onSubmit); } - Future _onSubmit(GlobalKey formKey) async { + Future _onSubmit(AccountSpec accountSpec) async { // dismiss the keyboard by unfocusing the textfield FocusScope.of(context).unfocus(); try { - final name = formKey - .currentState!.fields[EditProfileForm.formFieldName]!.value as String; - final pronouns = formKey.currentState! - .fields[EditProfileForm.formFieldPronouns]!.value as String? ?? - ''; - final newProfile = proto.Profile() - ..name = name - ..pronouns = pronouns; - setState(() { _isInAsyncCall = true; }); try { final writableSuperIdentity = await AccountRepository.instance - .createWithNewSuperIdentity(newProfile); + .createWithNewSuperIdentity(accountSpec); GoRouterHelper(context).pushReplacement('/new_account/recovery_key', - extra: [writableSuperIdentity, newProfile.name]); + extra: [writableSuperIdentity, accountSpec.name]); } finally { if (mounted) { setState(() { diff --git a/lib/account_manager/views/profile_edit_form.dart b/lib/account_manager/views/profile_edit_form.dart deleted file mode 100644 index cc6e987..0000000 --- a/lib/account_manager/views/profile_edit_form.dart +++ /dev/null @@ -1,118 +0,0 @@ -import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.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'; - -class EditProfileForm extends StatefulWidget { - const EditProfileForm({ - required this.header, - required this.instructions, - required this.submitText, - required this.submitDisabledText, - super.key, - this.onSubmit, - this.initialValueCallback, - }); - - @override - State createState() => _EditProfileFormState(); - - final String header; - final String instructions; - final Future Function(GlobalKey)? onSubmit; - final String submitText; - final String submitDisabledText; - final Object? Function(String key)? initialValueCallback; - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(StringProperty('header', header)) - ..add(StringProperty('instructions', instructions)) - ..add(ObjectFlagProperty< - Future Function( - GlobalKey p1)?>.has('onSubmit', onSubmit)) - ..add(StringProperty('submitText', submitText)) - ..add(StringProperty('submitDisabledText', submitDisabledText)) - ..add(ObjectFlagProperty.has( - 'initialValueCallback', initialValueCallback)); - } - - static const String formFieldName = 'name'; - static const String formFieldPronouns = 'pronouns'; -} - -class _EditProfileFormState extends State { - final _formKey = GlobalKey(); - - @override - void initState() { - super.initState(); - } - - Widget _editProfileForm( - BuildContext context, - ) => - FormBuilder( - key: _formKey, - child: Column( - children: [ - Text(widget.header) - .textStyle(context.headlineSmall) - .paddingSymmetric(vertical: 16), - FormBuilderTextField( - autofocus: true, - name: EditProfileForm.formFieldName, - initialValue: widget.initialValueCallback - ?.call(EditProfileForm.formFieldName) as String?, - decoration: - InputDecoration(labelText: translate('account.form_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: widget.initialValueCallback - ?.call(EditProfileForm.formFieldPronouns) as String?, - maxLength: 64, - decoration: InputDecoration( - labelText: translate('account.form_pronouns')), - textInputAction: TextInputAction.next, - ), - Row(children: [ - const Spacer(), - Text(widget.instructions).toCenter().flexible(flex: 6), - const Spacer(), - ]).paddingSymmetric(vertical: 4), - ElevatedButton( - onPressed: widget.onSubmit == null - ? null - : () async { - if (_formKey.currentState?.saveAndValidate() ?? false) { - await widget.onSubmit!(_formKey); - } - }, - 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(), - ], - ), - ); - - @override - Widget build(BuildContext context) => _editProfileForm( - context, - ); -} diff --git a/lib/contact_invitation/views/contact_invitation_list_widget.dart b/lib/contact_invitation/views/contact_invitation_list_widget.dart index 56a6b9a..ff3bb8d 100644 --- a/lib/contact_invitation/views/contact_invitation_list_widget.dart +++ b/lib/contact_invitation/views/contact_invitation_list_widget.dart @@ -61,7 +61,7 @@ class ContactInvitationListWidgetState }); _controller.animateTo(_expanded ? 1 : 0); }, - title: translate('contacts_page.invitations'), + title: translate('contacts_dialog.invitations'), sliver: SliverList.builder( itemCount: widget.contactInvitationRecordList.length, itemBuilder: (context, index) { diff --git a/lib/contacts/cubits/contact_list_cubit.dart b/lib/contacts/cubits/contact_list_cubit.dart index 76079b3..d3c6483 100644 --- a/lib/contacts/cubits/contact_list_cubit.dart +++ b/lib/contacts/cubits/contact_list_cubit.dart @@ -71,7 +71,43 @@ class ContactListCubit extends DHTShortArrayCubit { final updated = await writer.tryWriteItemProtobuf( proto.Contact.fromBuffer, pos, newContact); if (!updated) { - throw DHTExceptionOutdated(); + throw const DHTExceptionOutdated(); + } + break; + } + } + }); + } + + Future updateContactFields({ + required TypedKey localConversationRecordKey, + String? nickname, + String? notes, + bool? showAvailability, + }) async { + // Update contact's locally-modifiable fields + await operateWriteEventual((writer) async { + for (var pos = 0; pos < writer.length; pos++) { + final c = await writer.getProtobuf(proto.Contact.fromBuffer, pos); + 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 updated = await writer.tryWriteItemProtobuf( + proto.Contact.fromBuffer, pos, newContact); + if (!updated) { + throw const DHTExceptionOutdated(); } break; } diff --git a/lib/contacts/views/availability_widget.dart b/lib/contacts/views/availability_widget.dart new file mode 100644 index 0000000..8dc66d8 --- /dev/null +++ b/lib/contacts/views/availability_widget.dart @@ -0,0 +1,68 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_translate/flutter_translate.dart'; + +import '../../proto/proto.dart' as proto; + +class AvailabilityWidget extends StatelessWidget { + const AvailabilityWidget({required this.availability, super.key}); + + static IconData availabilityIcon(proto.Availability availability) { + late final IconData iconData; + switch (availability) { + case proto.Availability.AVAILABILITY_AWAY: + iconData = Icons.hot_tub; + case proto.Availability.AVAILABILITY_BUSY: + iconData = Icons.event_busy; + case proto.Availability.AVAILABILITY_FREE: + iconData = Icons.event_available; + case proto.Availability.AVAILABILITY_OFFLINE: + iconData = Icons.cloud_off; + case proto.Availability.AVAILABILITY_UNSPECIFIED: + iconData = Icons.question_mark; + } + return iconData; + } + + static String availabilityName(proto.Availability availability) { + late final String name; + switch (availability) { + case proto.Availability.AVAILABILITY_AWAY: + name = translate('availability.away'); + case proto.Availability.AVAILABILITY_BUSY: + name = translate('availability.busy'); + case proto.Availability.AVAILABILITY_FREE: + name = translate('availability.free'); + case proto.Availability.AVAILABILITY_OFFLINE: + name = translate('availability.offline'); + case proto.Availability.AVAILABILITY_UNSPECIFIED: + name = translate('availability.unspecified'); + } + return name; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + // final scale = theme.extension()!; + // final scaleConfig = theme.extension()!; + + final name = availabilityName(availability); + final iconData = availabilityIcon(availability); + + return Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(iconData, size: 32), + Text(name, style: textTheme.labelSmall) + ]); + } + + final proto.Availability availability; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty('availability', availability)); + } +} diff --git a/lib/contacts/views/contact_details_widget.dart b/lib/contacts/views/contact_details_widget.dart new file mode 100644 index 0000000..bd4376f --- /dev/null +++ b/lib/contacts/views/contact_details_widget.dart @@ -0,0 +1,41 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../proto/proto.dart' as proto; +import '../contacts.dart'; + +class ContactDetailsWidget extends StatefulWidget { + const ContactDetailsWidget({required this.contact, super.key}); + final proto.Contact contact; + + @override + State createState() => _ContactDetailsWidgetState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('contact', contact)); + } +} + +class _ContactDetailsWidgetState extends State + with SingleTickerProviderStateMixin { + @override + Widget build(BuildContext context) => SingleChildScrollView( + child: EditContactForm( + formKey: GlobalKey(), + contact: widget.contact, + onSubmit: (fbs) async { + final contactList = context.read(); + 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); + })); +} diff --git a/lib/contacts/views/contact_item_widget.dart b/lib/contacts/views/contact_item_widget.dart index a7441e9..7bf2fa4 100644 --- a/lib/contacts/views/contact_item_widget.dart +++ b/lib/contacts/views/contact_item_widget.dart @@ -1,34 +1,36 @@ +import 'package:async_tools/async_tools.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_animate/flutter_animate.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_translate/flutter_translate.dart'; -import '../../chat_list/chat_list.dart'; -import '../../layout/layout.dart'; import '../../proto/proto.dart' as proto; import '../../theme/theme.dart'; -import '../contacts.dart'; + +const _kOnTap = 'onTap'; +const _kOnDelete = 'onDelete'; class ContactItemWidget extends StatelessWidget { const ContactItemWidget( - {required proto.Contact contact, required bool disabled, super.key}) + {required proto.Contact contact, + required bool disabled, + required bool selected, + Future Function(proto.Contact)? onTap, + Future Function(proto.Contact)? onDoubleTap, + Future Function(proto.Contact)? onDelete, + super.key}) : _disabled = disabled, - _contact = contact; + _selected = selected, + _contact = contact, + _onTap = onTap, + _onDoubleTap = onDoubleTap, + _onDelete = onDelete; @override // ignore: prefer_expression_function_bodies Widget build( BuildContext context, ) { - final localConversationRecordKey = - _contact.localConversationRecordKey.toVeilid(); - - const selected = false; // xxx: eventually when we have selectable contacts: - // activeContactCubit.state == localConversationRecordKey; - - final tileDisabled = _disabled || context.watch().isBusy; - late final String title; late final String subtitle; + if (_contact.nickname.isNotEmpty) { title = _contact.nickname; if (_contact.profile.pronouns.isNotEmpty) { @@ -47,41 +49,33 @@ class ContactItemWidget extends StatelessWidget { return SliderTile( key: ObjectKey(_contact), - disabled: tileDisabled, - selected: selected, + disabled: _disabled, + selected: _selected, tileScale: ScaleKind.primary, title: title, subtitle: subtitle, icon: Icons.person, - onTap: () async { - // Start a chat - final chatListCubit = context.read(); - - await chatListCubit.getOrCreateChatSingleContact(contact: _contact); - // Click over to chats - if (context.mounted) { - await MainPager.of(context) - ?.pageController - .animateToPage(1, duration: 250.ms, curve: Curves.easeInOut); - } - }, + onDoubleTap: _onDoubleTap == null + ? null + : () => singleFuture((this, _kOnTap), () async { + await _onDoubleTap(_contact); + }), + onTap: _onTap == null + ? null + : () => singleFuture((this, _kOnTap), () async { + await _onTap(_contact); + }), endActions: [ - SliderTileAction( + if (_onDelete != null) + SliderTileAction( icon: Icons.delete, label: translate('button.delete'), actionScale: ScaleKind.tertiary, - onPressed: (context) async { - final contactListCubit = context.read(); - final chatListCubit = context.read(); - - // Delete the contact itself - await contactListCubit.deleteContact( - localConversationRecordKey: localConversationRecordKey); - - // Remove any chats for this contact - await chatListCubit.deleteChat( - localConversationRecordKey: localConversationRecordKey); - }) + onPressed: (_context) => + singleFuture((this, _kOnDelete), () async { + await _onDelete(_contact); + }), + ), ], ); } @@ -90,4 +84,8 @@ class ContactItemWidget extends StatelessWidget { final proto.Contact _contact; final bool _disabled; + final bool _selected; + final Future Function(proto.Contact contact)? _onTap; + final Future Function(proto.Contact contact)? _onDoubleTap; + final Future Function(proto.Contact contact)? _onDelete; } diff --git a/lib/contacts/views/contact_list_widget.dart b/lib/contacts/views/contact_list_widget.dart deleted file mode 100644 index f818892..0000000 --- a/lib/contacts/views/contact_list_widget.dart +++ /dev/null @@ -1,86 +0,0 @@ -import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_translate/flutter_translate.dart'; -import 'package:searchable_listview/searchable_listview.dart'; - -import '../../proto/proto.dart' as proto; -import '../../theme/theme.dart'; -import 'contact_item_widget.dart'; -import 'empty_contact_list_widget.dart'; - -class ContactListWidget extends StatefulWidget { - const ContactListWidget( - {required this.contactList, required this.disabled, super.key}); - final IList? contactList; - final bool disabled; - - @override - State createState() => _ContactListWidgetState(); - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(IterableProperty('contactList', contactList)) - ..add(DiagnosticsProperty('disabled', disabled)); - } -} - -class _ContactListWidgetState extends State - with SingleTickerProviderStateMixin { - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - //final textTheme = theme.textTheme; - final scale = theme.extension()!; - final scaleConfig = theme.extension()!; - - return SliverLayoutBuilder( - builder: (context, constraints) => styledHeaderSliver( - context: context, - backgroundColor: scaleConfig.preferBorders - ? scale.primaryScale.subtleBackground - : scale.primaryScale.subtleBorder, - title: translate('contacts_page.contacts'), - sliver: SliverFillRemaining( - child: SearchableList.sliver( - initialList: widget.contactList == null - ? [] - : widget.contactList!.toList(), - itemBuilder: (c) => - ContactItemWidget(contact: c, disabled: widget.disabled) - .paddingLTRB(0, 4, 0, 0), - filter: (value) { - final lowerValue = value.toLowerCase(); - if (widget.contactList == null) { - return []; - } - return widget.contactList! - .where((element) => - element.nickname.toLowerCase().contains(lowerValue) || - element.profile.name - .toLowerCase() - .contains(lowerValue) || - element.profile.pronouns - .toLowerCase() - .contains(lowerValue)) - .toList(); - }, - searchFieldHeight: 40, - spaceBetweenSearchAndList: 4, - emptyWidget: widget.contactList == null - ? waitingPage( - text: translate('contacts_page.loading_contacts')) - : const EmptyContactListWidget(), - defaultSuffixIconColor: scale.primaryScale.border, - closeKeyboardWhenScrolling: true, - searchFieldEnabled: widget.contactList != null, - inputDecoration: InputDecoration( - labelText: translate('contact_list.search'), - ), - ), - ))); - } -} diff --git a/lib/contacts/views/contacts_browser.dart b/lib/contacts/views/contacts_browser.dart new file mode 100644 index 0000000..f3b03c9 --- /dev/null +++ b/lib/contacts/views/contacts_browser.dart @@ -0,0 +1,247 @@ +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +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 'package:searchable_listview/searchable_listview.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../chat_list/chat_list.dart'; +import '../../contact_invitation/contact_invitation.dart'; +import '../../proto/proto.dart' as proto; +import '../../theme/theme.dart'; +import '../cubits/cubits.dart'; +import 'contact_item_widget.dart'; +import 'empty_contact_list_widget.dart'; + +enum ContactsBrowserElementKind { + invitation, + contact, +} + +class ContactsBrowserElement { + ContactsBrowserElement.invitation(proto.ContactInvitationRecord i) + : kind = ContactsBrowserElementKind.invitation, + contact = null, + invitation = i; + ContactsBrowserElement.contact(proto.Contact c) + : kind = ContactsBrowserElementKind.contact, + invitation = null, + contact = c; + + final ContactsBrowserElementKind kind; + final proto.ContactInvitationRecord? invitation; + final proto.Contact? contact; +} + +class ContactsBrowser extends StatefulWidget { + const ContactsBrowser( + {required this.onContactSelected, + required this.onChatStarted, + this.selectedContactRecordKey, + super.key}); + @override + State createState() => _ContactsBrowserState(); + + final Future Function(proto.Contact? contact) onContactSelected; + final Future Function(proto.Contact contact) onChatStarted; + final TypedKey? selectedContactRecordKey; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty( + 'selectedContactRecordKey', selectedContactRecordKey)) + ..add( + ObjectFlagProperty Function(proto.Contact? contact)>.has( + 'onContactSelected', onContactSelected)) + ..add( + ObjectFlagProperty Function(proto.Contact contact)>.has( + 'onChatStarted', onChatStarted)); + } +} + +class _ContactsBrowserState extends State + with SingleTickerProviderStateMixin { + Widget buildInvitationBar(BuildContext context) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + + return Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ + Column(mainAxisSize: MainAxisSize.min, children: [ + IconButton( + onPressed: () async { + await CreateInvitationDialog.show(context); + }, + iconSize: 32, + icon: const Icon(Icons.contact_page), + 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'), + ), + ]) + ]).paddingAll(16); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + + final cilState = context.watch().state; + final cilBusy = cilState.busy; + final contactInvitationRecordList = + cilState.state.asData?.value.map((x) => x.value).toIList() ?? + const IListConst([]); + + final ciState = context.watch().state; + final ciBusy = ciState.busy; + final contactList = + ciState.state.asData?.value.map((x) => x.value).toIList(); + + final expansionListData = + >{}; + if (contactInvitationRecordList.isNotEmpty) { + expansionListData[ContactsBrowserElementKind.invitation] = + contactInvitationRecordList + .toList() + .map(ContactsBrowserElement.invitation) + .toList(); + } + if (contactList != null) { + expansionListData[ContactsBrowserElementKind.contact] = + contactList.toList().map(ContactsBrowserElement.contact).toList(); + } + + return Column(children: [ + buildInvitationBar(context), + SearchableList.expansion( + expansionListData: expansionListData, + expansionTitleBuilder: (k) { + final kind = k as ContactsBrowserElementKind; + late final String title; + switch (kind) { + case ContactsBrowserElementKind.contact: + title = translate('contacts_dialog.contacts'); + case ContactsBrowserElementKind.invitation: + title = translate('contacts_dialog.invitations'); + } + + return Center( + child: Text(title, style: textTheme.titleSmall), + ); + }, + expansionInitiallyExpanded: (k) => true, + expansionListBuilder: (_index, element) { + switch (element.kind) { + case ContactsBrowserElementKind.contact: + final contact = element.contact!; + return ContactItemWidget( + contact: contact, + selected: widget.selectedContactRecordKey == + contact.localConversationRecordKey.toVeilid(), + disabled: ciBusy, + onTap: _onTapContact, + onDoubleTap: _onStartChat, + onDelete: _onDeleteContact) + .paddingLTRB(0, 4, 0, 0); + case ContactsBrowserElementKind.invitation: + final invitation = element.invitation!; + return ContactInvitationItemWidget( + contactInvitationRecord: invitation, disabled: cilBusy) + .paddingLTRB(0, 4, 0, 0); + } + }, + filterExpansionData: (value) { + final lowerValue = value.toLowerCase(); + final filteredMap = { + for (final entry in expansionListData.entries) + entry.key: (expansionListData[entry.key] ?? []).where((element) { + switch (element.kind) { + case ContactsBrowserElementKind.contact: + final contact = element.contact!; + return contact.nickname + .toLowerCase() + .contains(lowerValue) || + contact.profile.name + .toLowerCase() + .contains(lowerValue) || + contact.profile.pronouns + .toLowerCase() + .contains(lowerValue); + case ContactsBrowserElementKind.invitation: + final invitation = element.invitation!; + return invitation.message + .toLowerCase() + .contains(lowerValue); + } + }).toList() + }; + return filteredMap; + }, + hideEmptyExpansionItems: true, + searchFieldHeight: 40, + listViewPadding: const EdgeInsets.all(4), + spaceBetweenSearchAndList: 4, + emptyWidget: contactList == null + ? waitingPage(text: translate('contact_list.loading_contacts')) + : const EmptyContactListWidget(), + defaultSuffixIconColor: scale.primaryScale.border, + closeKeyboardWhenScrolling: true, + searchFieldEnabled: contactList != null, + inputDecoration: + InputDecoration(labelText: translate('contact_list.search')), + ).expanded() + ]); + } + + Future _onTapContact(proto.Contact contact) async { + await widget.onContactSelected(contact); + } + + Future _onStartChat(proto.Contact contact) async { + await widget.onChatStarted(contact); + } + + Future _onDeleteContact(proto.Contact contact) async { + final localConversationRecordKey = + contact.localConversationRecordKey.toVeilid(); + + final contactListCubit = context.read(); + final chatListCubit = context.read(); + + // Delete the contact itself + await contactListCubit.deleteContact( + localConversationRecordKey: localConversationRecordKey); + + // Remove any chats for this contact + await chatListCubit.deleteChat( + localConversationRecordKey: localConversationRecordKey); + } +} diff --git a/lib/contacts/views/contacts_dialog.dart b/lib/contacts/views/contacts_dialog.dart new file mode 100644 index 0000000..d0c1c82 --- /dev/null +++ b/lib/contacts/views/contacts_dialog.dart @@ -0,0 +1,140 @@ +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; + +import '../../chat/chat.dart'; +import '../../chat_list/chat_list.dart'; +import '../../proto/proto.dart' as proto; +import '../../contact_invitation/contact_invitation.dart'; +import '../../layout/layout.dart'; +import '../../theme/theme.dart'; +import '../../veilid_processor/veilid_processor.dart'; +import '../contacts.dart'; + +class ContactsDialog extends StatefulWidget { + const ContactsDialog._({required this.modalContext}); + + @override + State createState() => _ContactsDialogState(); + + static Future show(BuildContext modalContext) async { + await showDialog( + context: modalContext, + barrierDismissible: false, + useRootNavigator: false, + builder: (context) => ContactsDialog._(modalContext: modalContext)); + } + + final BuildContext modalContext; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + .add(DiagnosticsProperty('modalContext', modalContext)); + } +} + +class _ContactsDialogState extends State { + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + + final enableSplit = !isMobileWidth(context); + final enableLeft = enableSplit || _selectedContact == null; + final enableRight = enableSplit || _selectedContact != null; + + return SizedBox( + width: MediaQuery.of(context).size.width, + child: StyledScaffold( + appBar: DefaultAppBar( + 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 { + Navigator.pop(context); + } + }, + ) + : null, + actions: [ + if (_selectedContact != null) + IconButton( + icon: const Icon(Icons.chat_bubble), + tooltip: translate('contacts_dialog.new_chat'), + onPressed: () async { + await onChatStarted(_selectedContact!); + }) + ]), + body: LayoutBuilder(builder: (context, constraint) { + final maxWidth = constraint.maxWidth; + + return 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, + ).paddingAll(8)))), + if (enableRight) + if (_selectedContact == null) + const NoContactWidget().expanded() + else + ContactDetailsWidget(contact: _selectedContact!) + .paddingAll(8) + .expanded(), + ]); + }))); + } + + Future onContactSelected(proto.Contact? contact) async { + setState(() { + _selectedContact = contact; + }); + } + + Future onChatStarted(proto.Contact contact) async { + final chatListCubit = context.read(); + await chatListCubit.getOrCreateChatSingleContact(contact: contact); + + if (mounted) { + context + .read() + .setActiveChat(contact.localConversationRecordKey.toVeilid()); + + Navigator.pop(context); + } + } + + proto.Contact? _selectedContact; +} diff --git a/lib/contacts/views/edit_contact_form.dart b/lib/contacts/views/edit_contact_form.dart new file mode 100644 index 0000000..0e8acc1 --- /dev/null +++ b/lib/contacts/views/edit_contact_form.dart @@ -0,0 +1,174 @@ +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_translate/flutter_translate.dart'; + +import '../../proto/proto.dart' as proto; +import '../../theme/theme.dart'; +import 'availability_widget.dart'; + +class EditContactForm extends StatefulWidget { + const EditContactForm({ + required this.formKey, + required this.contact, + this.onSubmit, + super.key, + }); + + @override + State createState() => _EditContactFormState(); + + final proto.Contact contact; + final Future Function(GlobalKey)? onSubmit; + final GlobalKey formKey; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(ObjectFlagProperty< + Future Function( + GlobalKey p1)?>.has('onSubmit', onSubmit)) + ..add(DiagnosticsProperty('contact', contact)) + ..add( + DiagnosticsProperty>('formKey', formKey)); + } + + static const String formFieldNickname = 'nickname'; + static const String formFieldNotes = 'notes'; + static const String formFieldShowAvailability = 'show_availability'; +} + +class _EditContactFormState extends State { + @override + void initState() { + super.initState(); + } + + Widget _availabilityWidget( + BuildContext context, proto.Availability availability) => + AvailabilityWidget(availability: availability); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + 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: widget.formKey, + child: Column( + 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) + .decoratorLabel( + context, + translate('contact_form.form_name'), + scale: scale.secondaryScale, + ) + .paddingSymmetric(vertical: 8), + SelectableText(widget.contact.profile.pronouns, + style: textTheme.headlineSmall) + .decoratorLabel( + context, + translate('contact_form.form_pronouns'), + scale: scale.secondaryScale, + ) + .paddingSymmetric(vertical: 8), + Row(children: [ + _availabilityWidget(context, widget.contact.profile.availability), + SelectableText(widget.contact.profile.status, + style: textTheme.bodyMedium) + .paddingSymmetric(horizontal: 8) + ]) + .decoratorLabel( + context, + translate('contact_form.form_status'), + scale: scale.secondaryScale, + ) + .paddingSymmetric(vertical: 8), + SelectableText(widget.contact.profile.about, + minLines: 1, maxLines: 8, style: textTheme.bodyMedium) + .decoratorLabel( + context, + translate('contact_form.form_about'), + scale: scale.secondaryScale, + ) + .paddingSymmetric(vertical: 8), + SelectableText( + widget.contact.identityPublicKey.value.toVeilid().toString(), + style: textTheme.labelMedium! + .copyWith(fontFamily: 'Source Code Pro')) + .decoratorLabel( + context, + translate('contact_form.form_fingerprint'), + scale: scale.secondaryScale, + ) + .paddingSymmetric(vertical: 8), + Divider(color: border).paddingLTRB(8, 0, 8, 8), + FormBuilderTextField( + autofocus: true, + name: EditContactForm.formFieldNickname, + initialValue: widget.contact.nickname, + decoration: InputDecoration( + labelText: translate('contact_form.form_nickname')), + maxLength: 64, + textInputAction: TextInputAction.next, + ), + FormBuilderCheckbox( + name: EditContactForm.formFieldShowAvailability, + initialValue: widget.contact.showAvailability, + side: BorderSide(color: scale.primaryScale.border, width: 2), + title: Text(translate('contact_form.form_show_availability'), + style: textTheme.labelMedium), + ), + FormBuilderTextField( + name: EditContactForm.formFieldNotes, + initialValue: widget.contact.notes, + minLines: 1, + maxLines: 8, + maxLength: 1024, + decoration: InputDecoration( + labelText: translate('contact_form.form_notes')), + textInputAction: TextInputAction.newline, + ), + ElevatedButton( + onPressed: widget.onSubmit == null + ? null + : () async { + if (widget.formKey.currentState?.saveAndValidate() ?? + false) { + await widget.onSubmit!(widget.formKey); + } + }, + 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) + ]), + ).paddingSymmetric(vertical: 4).alignAtCenterRight(), + ], + ), + ); + } +} diff --git a/lib/contacts/views/no_contact_widget.dart b/lib/contacts/views/no_contact_widget.dart new file mode 100644 index 0000000..c559d8b --- /dev/null +++ b/lib/contacts/views/no_contact_widget.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_translate/flutter_translate.dart'; + +import '../../theme/models/scale_scheme.dart'; + +class NoContactWidget extends StatelessWidget { + const NoContactWidget({super.key}); + + @override + // ignore: prefer_expression_function_bodies + Widget build( + BuildContext context, + ) { + final theme = Theme.of(context); + final scale = theme.extension()!; + + return DecoratedBox( + decoration: BoxDecoration( + color: scale.primaryScale.appBackground, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.person, + color: scale.primaryScale.subtleBorder, + size: 48, + ), + Text( + textAlign: TextAlign.center, + translate('contacts_dialog.no_contact_selected'), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: scale.primaryScale.subtleBorder, + ), + ), + ], + ), + ); + } +} diff --git a/lib/contacts/views/views.dart b/lib/contacts/views/views.dart index 8c98b0f..55b96d0 100644 --- a/lib/contacts/views/views.dart +++ b/lib/contacts/views/views.dart @@ -1,3 +1,8 @@ +export 'availability_widget.dart'; +export 'contact_details_widget.dart'; export 'contact_item_widget.dart'; -export 'contact_list_widget.dart'; +export 'contacts_browser.dart'; +export 'contacts_dialog.dart'; +export 'edit_contact_form.dart'; export 'empty_contact_list_widget.dart'; +export 'no_contact_widget.dart'; diff --git a/lib/layout/home/drawer_menu/drawer_menu.dart b/lib/layout/home/drawer_menu/drawer_menu.dart index 6d7209c..0821bbb 100644 --- a/lib/layout/home/drawer_menu/drawer_menu.dart +++ b/lib/layout/home/drawer_menu/drawer_menu.dart @@ -40,10 +40,10 @@ class _DrawerMenuState extends State { } void _doEditClick(TypedKey superIdentityRecordKey, - proto.Profile existingProfile, OwnedDHTRecordPointer accountRecord) { + proto.Account existingAccount, OwnedDHTRecordPointer accountRecord) { singleFuture(this, () async { await GoRouterHelper(context).push('/edit_account', - extra: [superIdentityRecordKey, existingProfile, accountRecord]); + extra: [superIdentityRecordKey, existingAccount, accountRecord]); }); } @@ -58,6 +58,45 @@ class _DrawerMenuState extends State { borderRadius: BorderRadius.circular(borderRadius))), child: child); + Widget _makeAvatarWidget({ + required String name, + required double size, + required Color borderColor, + required Color foregroundColor, + required Color backgroundColor, + required ScaleConfig scaleConfig, + required TextStyle textStyle, + ImageProvider? imageProvider, + }) { + final abbrev = name.split(' ').map((s) => s.isEmpty ? '' : s[0]).join(); + late final String shortname; + if (abbrev.length >= 3) { + shortname = abbrev[0] + abbrev[1] + abbrev[abbrev.length - 1]; + } else { + shortname = abbrev; + } + + return Container( + height: size, + width: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: scaleConfig.preferBorders + ? Border.all( + color: borderColor, + width: 2 * (size ~/ 32 + 1), + strokeAlign: BorderSide.strokeAlignOutside) + : null, + color: Colors.blue, + ), + child: AvatarImage( + //size: 32, + backgroundImage: imageProvider, + backgroundColor: backgroundColor, + foregroundColor: foregroundColor, + child: Text(shortname, style: textStyle))); + } + Widget _makeAccountWidget( {required String name, required bool selected, @@ -67,13 +106,6 @@ class _DrawerMenuState extends State { required void Function()? callback, required void Function()? footerCallback}) { final theme = Theme.of(context); - final abbrev = name.split(' ').map((s) => s.isEmpty ? '' : s[0]).join(); - late final String shortname; - if (abbrev.length >= 3) { - shortname = abbrev[0] + abbrev[1] + abbrev[abbrev.length - 1]; - } else { - shortname = abbrev; - } late final Color background; late final Color hoverBackground; @@ -99,24 +131,15 @@ class _DrawerMenuState extends State { activeBorder = scale.primary; } - final avatar = Container( - height: 34, - width: 34, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: scaleConfig.preferBorders - ? Border.all( - color: border, - width: 2, - strokeAlign: BorderSide.strokeAlignOutside) - : null, - color: Colors.blue, - ), - child: AvatarImage( - //size: 32, - backgroundColor: loggedIn ? scale.primary : scale.elementBackground, - foregroundColor: loggedIn ? scale.primaryText : scale.subtleText, - child: Text(shortname, style: theme.textTheme.titleLarge))); + final avatar = AvatarWidget( + name: name, + size: 34, + borderColor: border, + foregroundColor: loggedIn ? scale.primaryText : scale.subtleText, + backgroundColor: loggedIn ? scale.primary : scale.elementBackground, + scaleConfig: scaleConfig, + textStyle: theme.textTheme.titleLarge!, + ); return AnimatedPadding( padding: EdgeInsets.fromLTRB(selected ? 0 : 8, selected ? 0 : 2, @@ -190,7 +213,7 @@ class _DrawerMenuState extends State { footerCallback: () { _doEditClick( superIdentityRecordKey, - value.profile, + value, perAccountState.accountInfo.userLogin!.accountRecordInfo .accountRecord); }), diff --git a/lib/layout/home/home_account_ready.dart b/lib/layout/home/home_account_ready.dart index a5dba61..5a6ab2b 100644 --- a/lib/layout/home/home_account_ready.dart +++ b/lib/layout/home/home_account_ready.dart @@ -6,9 +6,10 @@ import 'package:flutter_zoom_drawer/flutter_zoom_drawer.dart'; import '../../account_manager/account_manager.dart'; import '../../chat/chat.dart'; +import '../../chat_list/chat_list.dart'; +import '../../contacts/contacts.dart'; import '../../proto/proto.dart' as proto; import '../../theme/theme.dart'; -import 'main_pager/main_pager.dart'; class HomeAccountReady extends StatefulWidget { const HomeAccountReady({super.key}); @@ -23,6 +24,75 @@ class _HomeAccountReadyState extends State { super.initState(); } + Widget buildMenuButton() => Builder(builder: (context) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + return IconButton( + icon: const Icon(Icons.menu), + color: scaleConfig.preferBorders + ? scale.primaryScale.border + : scale.primaryScale.borderText, + constraints: const BoxConstraints.expand(height: 48, width: 48), + style: ButtonStyle( + backgroundColor: WidgetStateProperty.all( + scaleConfig.preferBorders + ? scale.primaryScale.hoverElementBackground + : scale.primaryScale.hoverBorder), + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + side: !scaleConfig.useVisualIndicators + ? BorderSide.none + : BorderSide( + strokeAlign: BorderSide.strokeAlignCenter, + color: scaleConfig.preferBorders + ? scale.primaryScale.border + : scale.primaryScale.borderText, + width: 2), + borderRadius: BorderRadius.all( + Radius.circular(12 * scaleConfig.borderRadiusScale))), + )), + tooltip: translate('menu.accounts_menu_tooltip'), + onPressed: () async { + final ctrl = context.read(); + await ctrl.toggle?.call(); + }); + }); + + Widget buildContactsButton() => Builder(builder: (context) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + return IconButton( + icon: const Icon(Icons.contacts), + color: scaleConfig.preferBorders + ? scale.primaryScale.border + : scale.primaryScale.borderText, + constraints: const BoxConstraints.expand(height: 48, width: 48), + style: ButtonStyle( + backgroundColor: WidgetStateProperty.all( + scaleConfig.preferBorders + ? scale.primaryScale.hoverElementBackground + : scale.primaryScale.hoverBorder), + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + side: !scaleConfig.useVisualIndicators + ? BorderSide.none + : BorderSide( + strokeAlign: BorderSide.strokeAlignCenter, + color: scaleConfig.preferBorders + ? scale.primaryScale.border + : scale.primaryScale.borderText, + width: 2), + borderRadius: BorderRadius.all( + Radius.circular(12 * scaleConfig.borderRadiusScale))), + )), + tooltip: translate('menu.contacts_tooltip'), + onPressed: () async { + await ContactsDialog.show(context); + }); + }); + Widget buildUserPanel() => Builder(builder: (context) { final profile = context.select( (c) => c.state.asData!.value.profile); @@ -36,43 +106,14 @@ class _HomeAccountReadyState extends State { : scale.primaryScale.subtleBorder, child: Column(children: [ Row(children: [ - IconButton( - icon: const Icon(Icons.menu), - color: scaleConfig.preferBorders - ? scale.primaryScale.border - : scale.primaryScale.borderText, - constraints: - const BoxConstraints.expand(height: 48, width: 48), - style: ButtonStyle( - backgroundColor: WidgetStateProperty.all( - scaleConfig.preferBorders - ? scale.primaryScale.hoverElementBackground - : scale.primaryScale.hoverBorder), - shape: WidgetStateProperty.all( - RoundedRectangleBorder( - side: !scaleConfig.useVisualIndicators - ? BorderSide.none - : BorderSide( - strokeAlign: BorderSide.strokeAlignCenter, - color: scaleConfig.preferBorders - ? scale.primaryScale.border - : scale.primaryScale.borderText, - width: 2), - borderRadius: BorderRadius.all(Radius.circular( - 12 * scaleConfig.borderRadiusScale))), - )), - tooltip: translate('menu.settings_tooltip'), - onPressed: () async { - final ctrl = context.read(); - await ctrl.toggle?.call(); - //await GoRouterHelper(context).push('/settings'); - }).paddingLTRB(0, 0, 8, 0), + buildMenuButton().paddingLTRB(0, 0, 8, 0), ProfileWidget( profile: profile, showPronouns: false, ).expanded(), + buildContactsButton().paddingLTRB(8, 0, 0, 0), ]).paddingAll(8), - MainPager(key: _mainPagerKey).expanded() + const ChatListWidget().expanded() ])); }); @@ -156,7 +197,4 @@ class _HomeAccountReadyState extends State { ]); }); } - - //////////////////////////////////////////////////////////////////////////// - final _mainPagerKey = GlobalKey(debugLabel: '_mainPagerKey'); } diff --git a/lib/layout/home/home_screen.dart b/lib/layout/home/home_screen.dart index a941220..5fd463a 100644 --- a/lib/layout/home/home_screen.dart +++ b/lib/layout/home/home_screen.dart @@ -132,7 +132,14 @@ class HomeScreenState extends State // Re-export all ready blocs to the account display subtree return perAccountCollectionState.provide( - child: const HomeAccountReady()); + child: Navigator( + onPopPage: (route, result) { + if (!route.didPop(result)) { + return false; + } + return true; + }, + pages: const [MaterialPage(child: HomeAccountReady())])); } } diff --git a/lib/layout/home/main_pager/bottom_sheet_action_button.dart b/lib/layout/home/main_pager/bottom_sheet_action_button.dart deleted file mode 100644 index 494cdb4..0000000 --- a/lib/layout/home/main_pager/bottom_sheet_action_button.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -class BottomSheetActionButton extends StatefulWidget { - const BottomSheetActionButton( - {required this.bottomSheetBuilder, - required this.builder, - this.foregroundColor, - this.backgroundColor, - this.shape, - super.key}); - final Color? foregroundColor; - final Color? backgroundColor; - final ShapeBorder? shape; - final Widget Function(BuildContext) builder; - final Widget Function(BuildContext) bottomSheetBuilder; - - @override - BottomSheetActionButtonState createState() => BottomSheetActionButtonState(); - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(ObjectFlagProperty.has( - 'bottomSheetBuilder', bottomSheetBuilder)) - ..add(ColorProperty('foregroundColor', foregroundColor)) - ..add(ColorProperty('backgroundColor', backgroundColor)) - ..add(DiagnosticsProperty('shape', shape)) - ..add(ObjectFlagProperty.has( - 'builder', builder)); - } -} - -class BottomSheetActionButtonState extends State { - bool _showFab = true; - - @override - void initState() { - super.initState(); - } - - @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context) { - // - return _showFab - ? FloatingActionButton( - elevation: 0, - heroTag: this, - hoverElevation: 0, - shape: widget.shape, - foregroundColor: widget.foregroundColor, - backgroundColor: widget.backgroundColor, - child: widget.builder(context), - onPressed: () async { - await showModalBottomSheet( - context: context, builder: widget.bottomSheetBuilder); - }, - ) - : Container(); - } - - void showFloatingActionButton(bool value) { - setState(() { - _showFab = value; - }); - } -} diff --git a/lib/layout/home/main_pager/chats_page.dart b/lib/layout/home/main_pager/chats_page.dart deleted file mode 100644 index 2146b3d..0000000 --- a/lib/layout/home/main_pager/chats_page.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../../chat_list/chat_list.dart'; - -class ChatsPage extends StatefulWidget { - const ChatsPage({super.key}); - - @override - ChatsPageState createState() => ChatsPageState(); -} - -class ChatsPageState extends State { - @override - void initState() { - super.initState(); - } - - @override - void dispose() { - super.dispose(); - } - - @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context) { - return const ChatListWidget(); - } -} diff --git a/lib/layout/home/main_pager/contacts_page.dart b/lib/layout/home/main_pager/contacts_page.dart deleted file mode 100644 index c3699f2..0000000 --- a/lib/layout/home/main_pager/contacts_page.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -import '../../../contact_invitation/contact_invitation.dart'; -import '../../../contacts/contacts.dart'; - -class ContactsPage extends StatefulWidget { - const ContactsPage({ - super.key, - }); - - @override - ContactsPageState createState() => ContactsPageState(); -} - -class ContactsPageState extends State { - @override - void initState() { - super.initState(); - } - - @override - void dispose() { - super.dispose(); - } - - @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context) { - // final theme = Theme.of(context); - // final textTheme = theme.textTheme; - // final scale = theme.extension()!; - // final scaleConfig = theme.extension()!; - - final cilState = context.watch().state; - final cilBusy = cilState.busy; - final contactInvitationRecordList = - cilState.state.asData?.value.map((x) => x.value).toIList() ?? - const IListConst([]); - - final ciState = context.watch().state; - final ciBusy = ciState.busy; - final contactList = - ciState.state.asData?.value.map((x) => x.value).toIList(); - - return CustomScrollView(slivers: [ - if (contactInvitationRecordList.isNotEmpty) - SliverPadding( - padding: const EdgeInsets.only(bottom: 8), - sliver: ContactInvitationListWidget( - contactInvitationRecordList: contactInvitationRecordList, - disabled: cilBusy)), - ContactListWidget(contactList: contactList, disabled: ciBusy) - ]).paddingLTRB(8, 0, 8, 8); - } -} diff --git a/lib/layout/home/main_pager/main_pager.dart b/lib/layout/home/main_pager/main_pager.dart deleted file mode 100644 index 68ef39b..0000000 --- a/lib/layout/home/main_pager/main_pager.dart +++ /dev/null @@ -1,242 +0,0 @@ -import 'dart:async'; - -import 'package:animated_bottom_navigation_bar/' - 'animated_bottom_navigation_bar.dart'; -import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter_animate/flutter_animate.dart'; -import 'package:flutter_translate/flutter_translate.dart'; -import 'package:preload_page_view/preload_page_view.dart'; -import 'package:provider/provider.dart'; - -import '../../../chat/chat.dart'; -import '../../../contact_invitation/contact_invitation.dart'; -import '../../../theme/theme.dart'; -import 'bottom_sheet_action_button.dart'; -import 'chats_page.dart'; -import 'contacts_page.dart'; - -class MainPager extends StatefulWidget { - const MainPager({super.key}); - - @override - MainPagerState createState() => MainPagerState(); - - static MainPagerState? of(BuildContext context) => - context.findAncestorStateOfType(); -} - -class MainPagerState extends State with TickerProviderStateMixin { - ////////////////////////////////////////////////////////////////// - - @override - void initState() { - super.initState(); - } - - @override - void dispose() { - pageController.dispose(); - super.dispose(); - } - - Future scanContactInvitationDialog(BuildContext context) async { - await showDialog( - context: context, - // ignore: prefer_expression_function_bodies - builder: (context) { - return AlertDialog( - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(20)), - ), - contentPadding: const EdgeInsets.only( - top: 10, - ), - title: const Text( - 'Scan Contact Invite', - style: TextStyle(fontSize: 24), - ), - content: ScanInvitationDialog( - locator: context.read, - )); - }); - } - - Widget _buildBottomBarItem(int index, bool isActive) { - final theme = Theme.of(context); - final scale = theme.extension()!; - final scaleConfig = theme.extension()!; - - final color = scaleConfig.useVisualIndicators - ? (scaleConfig.preferBorders - ? scale.primaryScale.border - : scale.primaryScale.borderText) - : (isActive - ? (scaleConfig.preferBorders - ? scale.primaryScale.border - : scale.primaryScale.borderText) - : (scaleConfig.preferBorders - ? scale.primaryScale.subtleBorder - : scale.primaryScale.borderText.withAlpha(0x80))); - - final item = Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - _selectedIconList[index], - size: 24, - color: color, - ), - const SizedBox(height: 4), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Text( - _bottomLabelList[index], - style: theme.textTheme.labelMedium!.copyWith( - fontWeight: isActive ? FontWeight.bold : FontWeight.normal, - color: color), - ), - ) - ], - ); - - if (scaleConfig.useVisualIndicators && isActive) { - return DecoratedBox( - decoration: ShapeDecoration( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - 14 * scaleConfig.borderRadiusScale), - side: BorderSide( - width: 2, - color: scaleConfig.preferBorders - ? scale.primaryScale.border - : scale.primaryScale.borderText))), - child: item) - .paddingLTRB(8, 0, 8, 6); - } - - return item; - } - - Widget _bottomSheetBuilder(BuildContext sheetContext, BuildContext context) { - if (currentPage == 0) { - // New contact invitation - return newContactBottomSheetBuilder(sheetContext, context); - } else if (currentPage == 1) { - // New chat - return newChatBottomSheetBuilder(sheetContext, context); - } else { - // Unknown error - return debugPage('unknown page'); - } - } - - @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context) { - final theme = Theme.of(context); - final scale = theme.extension()!; - final scaleConfig = theme.extension()!; - - return Scaffold( - //extendBody: true, - backgroundColor: Colors.transparent, - body: PreloadPageView( - key: _pageViewKey, - controller: pageController, - preloadPagesCount: 2, - onPageChanged: (index) { - setState(() { - currentPage = index; - }); - }, - children: const [ - ContactsPage(), - ChatsPage(), - ]), - // appBar: AppBar( - // toolbarHeight: 24, - // title: Text( - // 'C', - // style: Theme.of(context).textTheme.headlineSmall, - // ), - // ), - bottomNavigationBar: AnimatedBottomNavigationBar.builder( - itemCount: 2, - height: 64, - tabBuilder: _buildBottomBarItem, - activeIndex: currentPage, - gapLocation: GapLocation.end, - gapWidth: 90, - notchSmoothness: NotchSmoothness.defaultEdge, - notchMargin: 4, - backgroundColor: scaleConfig.preferBorders - ? scale.primaryScale.hoverElementBackground - : scale.primaryScale.hoverBorder, - elevation: 0, - onTap: (index) async { - await pageController.animateToPage(index, - duration: 250.ms, curve: Curves.easeInOut); - }, - ), - floatingActionButton: BottomSheetActionButton( - shape: CircleBorder( - side: !scaleConfig.useVisualIndicators - ? BorderSide.none - : BorderSide( - strokeAlign: BorderSide.strokeAlignCenter, - color: scaleConfig.preferBorders - ? scale.secondaryScale.border - : scale.secondaryScale.borderText, - width: 2), - ), - foregroundColor: scaleConfig.preferBorders - ? scale.secondaryScale.border - : scale.secondaryScale.borderText, - backgroundColor: scaleConfig.preferBorders - ? scale.secondaryScale.hoverElementBackground - : scale.secondaryScale.hoverBorder, - builder: (context) => Icon( - _fabIconList[currentPage], - color: scaleConfig.preferBorders - ? scale.secondaryScale.border - : scale.secondaryScale.borderText, - ), - bottomSheetBuilder: (sheetContext) => - _bottomSheetBuilder(sheetContext, context)), - floatingActionButtonLocation: FloatingActionButtonLocation.endDocked, - ); - } - - ////////////////////////////////////////////////////////////////// - - final _selectedIconList = [Icons.person, Icons.chat]; - // final _unselectedIconList = [ - // Icons.chat_outlined, - // Icons.person_outlined - // ]; - final _fabIconList = [ - Icons.person_add_sharp, - Icons.chat, - ]; - final _bottomLabelList = [ - translate('pager.contacts'), - translate('pager.chats'), - ]; - final _pageViewKey = GlobalKey(debugLabel: '_pageViewKey'); - - // key-accessible controller - int currentPage = 0; - final pageController = PreloadPageController(); - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(IntProperty('currentPage', currentPage)) - ..add(DiagnosticsProperty( - 'pageController', pageController)); - } -} diff --git a/lib/layout/layout.dart b/lib/layout/layout.dart index 27975d5..a744264 100644 --- a/lib/layout/layout.dart +++ b/lib/layout/layout.dart @@ -1,4 +1,3 @@ export 'default_app_bar.dart'; export 'home/home.dart'; -export 'home/main_pager/main_pager.dart'; export 'splash.dart'; diff --git a/lib/proto/veilidchat.pb.dart b/lib/proto/veilidchat.pb.dart index 63bd910..5152594 100644 --- a/lib/proto/veilidchat.pb.dart +++ b/lib/proto/veilidchat.pb.dart @@ -1647,11 +1647,15 @@ class Account extends $pb.GeneratedMessage { static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'Account', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) ..aOM(1, _omitFieldNames ? '' : 'profile', subBuilder: Profile.create) ..aOB(2, _omitFieldNames ? '' : 'invisible') - ..a<$core.int>(3, _omitFieldNames ? '' : 'autoAwayTimeoutSec', $pb.PbFieldType.OU3) + ..a<$core.int>(3, _omitFieldNames ? '' : 'autoAwayTimeoutMin', $pb.PbFieldType.OU3) ..aOM<$1.OwnedDHTRecordPointer>(4, _omitFieldNames ? '' : 'contactList', subBuilder: $1.OwnedDHTRecordPointer.create) ..aOM<$1.OwnedDHTRecordPointer>(5, _omitFieldNames ? '' : 'contactInvitationRecords', subBuilder: $1.OwnedDHTRecordPointer.create) ..aOM<$1.OwnedDHTRecordPointer>(6, _omitFieldNames ? '' : 'chatList', subBuilder: $1.OwnedDHTRecordPointer.create) ..aOM<$1.OwnedDHTRecordPointer>(7, _omitFieldNames ? '' : 'groupChatList', subBuilder: $1.OwnedDHTRecordPointer.create) + ..aOS(8, _omitFieldNames ? '' : 'freeMessage') + ..aOS(9, _omitFieldNames ? '' : 'busyMessage') + ..aOS(10, _omitFieldNames ? '' : 'awayMessage') + ..aOB(11, _omitFieldNames ? '' : 'autodetectAway') ..hasRequiredFields = false ; @@ -1697,13 +1701,13 @@ class Account extends $pb.GeneratedMessage { void clearInvisible() => clearField(2); @$pb.TagNumber(3) - $core.int get autoAwayTimeoutSec => $_getIZ(2); + $core.int get autoAwayTimeoutMin => $_getIZ(2); @$pb.TagNumber(3) - set autoAwayTimeoutSec($core.int v) { $_setUnsignedInt32(2, v); } + set autoAwayTimeoutMin($core.int v) { $_setUnsignedInt32(2, v); } @$pb.TagNumber(3) - $core.bool hasAutoAwayTimeoutSec() => $_has(2); + $core.bool hasAutoAwayTimeoutMin() => $_has(2); @$pb.TagNumber(3) - void clearAutoAwayTimeoutSec() => clearField(3); + void clearAutoAwayTimeoutMin() => clearField(3); @$pb.TagNumber(4) $1.OwnedDHTRecordPointer get contactList => $_getN(3); @@ -1748,6 +1752,42 @@ class Account extends $pb.GeneratedMessage { void clearGroupChatList() => clearField(7); @$pb.TagNumber(7) $1.OwnedDHTRecordPointer ensureGroupChatList() => $_ensure(6); + + @$pb.TagNumber(8) + $core.String get freeMessage => $_getSZ(7); + @$pb.TagNumber(8) + set freeMessage($core.String v) { $_setString(7, v); } + @$pb.TagNumber(8) + $core.bool hasFreeMessage() => $_has(7); + @$pb.TagNumber(8) + void clearFreeMessage() => clearField(8); + + @$pb.TagNumber(9) + $core.String get busyMessage => $_getSZ(8); + @$pb.TagNumber(9) + set busyMessage($core.String v) { $_setString(8, v); } + @$pb.TagNumber(9) + $core.bool hasBusyMessage() => $_has(8); + @$pb.TagNumber(9) + void clearBusyMessage() => clearField(9); + + @$pb.TagNumber(10) + $core.String get awayMessage => $_getSZ(9); + @$pb.TagNumber(10) + set awayMessage($core.String v) { $_setString(9, v); } + @$pb.TagNumber(10) + $core.bool hasAwayMessage() => $_has(9); + @$pb.TagNumber(10) + void clearAwayMessage() => clearField(10); + + @$pb.TagNumber(11) + $core.bool get autodetectAway => $_getBF(10); + @$pb.TagNumber(11) + set autodetectAway($core.bool v) { $_setBool(10, v); } + @$pb.TagNumber(11) + $core.bool hasAutodetectAway() => $_has(10); + @$pb.TagNumber(11) + void clearAutodetectAway() => clearField(11); } class Contact extends $pb.GeneratedMessage { diff --git a/lib/proto/veilidchat.pbjson.dart b/lib/proto/veilidchat.pbjson.dart index fe6cac3..ec327f4 100644 --- a/lib/proto/veilidchat.pbjson.dart +++ b/lib/proto/veilidchat.pbjson.dart @@ -467,24 +467,31 @@ const Account$json = { '2': [ {'1': 'profile', '3': 1, '4': 1, '5': 11, '6': '.veilidchat.Profile', '10': 'profile'}, {'1': 'invisible', '3': 2, '4': 1, '5': 8, '10': 'invisible'}, - {'1': 'auto_away_timeout_sec', '3': 3, '4': 1, '5': 13, '10': 'autoAwayTimeoutSec'}, + {'1': 'auto_away_timeout_min', '3': 3, '4': 1, '5': 13, '10': 'autoAwayTimeoutMin'}, {'1': 'contact_list', '3': 4, '4': 1, '5': 11, '6': '.dht.OwnedDHTRecordPointer', '10': 'contactList'}, {'1': 'contact_invitation_records', '3': 5, '4': 1, '5': 11, '6': '.dht.OwnedDHTRecordPointer', '10': 'contactInvitationRecords'}, {'1': 'chat_list', '3': 6, '4': 1, '5': 11, '6': '.dht.OwnedDHTRecordPointer', '10': 'chatList'}, {'1': 'group_chat_list', '3': 7, '4': 1, '5': 11, '6': '.dht.OwnedDHTRecordPointer', '10': 'groupChatList'}, + {'1': 'free_message', '3': 8, '4': 1, '5': 9, '10': 'freeMessage'}, + {'1': 'busy_message', '3': 9, '4': 1, '5': 9, '10': 'busyMessage'}, + {'1': 'away_message', '3': 10, '4': 1, '5': 9, '10': 'awayMessage'}, + {'1': 'autodetect_away', '3': 11, '4': 1, '5': 8, '10': 'autodetectAway'}, ], }; /// Descriptor for `Account`. Decode as a `google.protobuf.DescriptorProto`. final $typed_data.Uint8List accountDescriptor = $convert.base64Decode( 'CgdBY2NvdW50Ei0KB3Byb2ZpbGUYASABKAsyEy52ZWlsaWRjaGF0LlByb2ZpbGVSB3Byb2ZpbG' - 'USHAoJaW52aXNpYmxlGAIgASgIUglpbnZpc2libGUSMQoVYXV0b19hd2F5X3RpbWVvdXRfc2Vj' - 'GAMgASgNUhJhdXRvQXdheVRpbWVvdXRTZWMSPQoMY29udGFjdF9saXN0GAQgASgLMhouZGh0Lk' + 'USHAoJaW52aXNpYmxlGAIgASgIUglpbnZpc2libGUSMQoVYXV0b19hd2F5X3RpbWVvdXRfbWlu' + 'GAMgASgNUhJhdXRvQXdheVRpbWVvdXRNaW4SPQoMY29udGFjdF9saXN0GAQgASgLMhouZGh0Lk' '93bmVkREhUUmVjb3JkUG9pbnRlclILY29udGFjdExpc3QSWAoaY29udGFjdF9pbnZpdGF0aW9u' 'X3JlY29yZHMYBSABKAsyGi5kaHQuT3duZWRESFRSZWNvcmRQb2ludGVyUhhjb250YWN0SW52aX' 'RhdGlvblJlY29yZHMSNwoJY2hhdF9saXN0GAYgASgLMhouZGh0Lk93bmVkREhUUmVjb3JkUG9p' 'bnRlclIIY2hhdExpc3QSQgoPZ3JvdXBfY2hhdF9saXN0GAcgASgLMhouZGh0Lk93bmVkREhUUm' - 'Vjb3JkUG9pbnRlclINZ3JvdXBDaGF0TGlzdA=='); + 'Vjb3JkUG9pbnRlclINZ3JvdXBDaGF0TGlzdBIhCgxmcmVlX21lc3NhZ2UYCCABKAlSC2ZyZWVN' + 'ZXNzYWdlEiEKDGJ1c3lfbWVzc2FnZRgJIAEoCVILYnVzeU1lc3NhZ2USIQoMYXdheV9tZXNzYW' + 'dlGAogASgJUgthd2F5TWVzc2FnZRInCg9hdXRvZGV0ZWN0X2F3YXkYCyABKAhSDmF1dG9kZXRl' + 'Y3RBd2F5'); @$core.Deprecated('Use contactDescriptor instead') const Contact$json = { diff --git a/lib/proto/veilidchat.proto b/lib/proto/veilidchat.proto index 794cef8..0d4ca0a 100644 --- a/lib/proto/veilidchat.proto +++ b/lib/proto/veilidchat.proto @@ -319,13 +319,13 @@ message Chat { // Pronouns - Pronouns of user // Icon - Little picture to represent user in contact list message Profile { - // Friendy name + // Friendy name (max length 64) string name = 1; - // Pronouns of user + // Pronouns of user (max length 64) string pronouns = 2; - // Description of the user + // Description of the user (max length 1024) string about = 3; - // Status/away message + // Status/away message (max length 128) string status = 4; // Availability Availability availability = 5; @@ -345,8 +345,8 @@ message Account { Profile profile = 1; // Invisibility makes you always look 'Offline' bool invisible = 2; - // Auto-away sets 'away' mode after an inactivity time - uint32 auto_away_timeout_sec = 3; + // Auto-away sets 'away' mode after an inactivity time (only if autodetect_away is set) + uint32 auto_away_timeout_min = 3; // The contacts DHTList for this account // DHT Private dht.OwnedDHTRecordPointer contact_list = 4; @@ -359,6 +359,15 @@ message Account { // The GroupChats DHTList for this account // DHT Private dht.OwnedDHTRecordPointer group_chat_list = 7; + // Free message (max length 128) + string free_message = 8; + // Busy message (max length 128) + string busy_message = 9; + // Away message (max length 128) + string away_message = 10; + // Auto-detect away + bool autodetect_away = 11; + } // A record of a contact that has accepted a contact invitation diff --git a/lib/router/cubits/router_cubit.dart b/lib/router/cubits/router_cubit.dart index 95f2bf7..d442485 100644 --- a/lib/router/cubits/router_cubit.dart +++ b/lib/router/cubits/router_cubit.dart @@ -72,7 +72,7 @@ class RouterCubit extends Cubit { final extra = state.extra! as List; return EditAccountPage( superIdentityRecordKey: extra[0]! as TypedKey, - existingProfile: extra[1]! as proto.Profile, + existingAccount: extra[1]! as proto.Account, accountRecord: extra[2]! as OwnedDHTRecordPointer, ); }, diff --git a/lib/settings/settings_page.dart b/lib/settings/settings_page.dart index ac21fc4..94606aa 100644 --- a/lib/settings/settings_page.dart +++ b/lib/settings/settings_page.dart @@ -47,7 +47,9 @@ class SettingsPageState extends State { child: ListView( children: [ buildSettingsPageColorPreferences( - context: context, onChanged: () => setState(() {})), + context: context, + onChanged: () => setState(() {})) + .paddingLTRB(0, 8, 0, 0), buildSettingsPageBrightnessPreferences( context: context, onChanged: () => setState(() {})), buildSettingsPageNotificationPreferences( diff --git a/lib/theme/models/slider_tile.dart b/lib/theme/models/slider_tile.dart index e6c4711..7631303 100644 --- a/lib/theme/models/slider_tile.dart +++ b/lib/theme/models/slider_tile.dart @@ -30,6 +30,7 @@ class SliderTile extends StatelessWidget { this.endActions = const [], this.startActions = const [], this.onTap, + this.onDoubleTap, this.icon, super.key}); @@ -39,6 +40,7 @@ class SliderTile extends StatelessWidget { final List endActions; final List startActions; final GestureTapCallback? onTap; + final GestureTapCallback? onDoubleTap; final IconData? icon; final String title; final String subtitle; @@ -55,7 +57,9 @@ class SliderTile extends StatelessWidget { ..add(ObjectFlagProperty.has('onTap', onTap)) ..add(DiagnosticsProperty('icon', icon)) ..add(StringProperty('title', title)) - ..add(StringProperty('subtitle', subtitle)); + ..add(StringProperty('subtitle', subtitle)) + ..add(ObjectFlagProperty.has( + 'onDoubleTap', onDoubleTap)); } @override @@ -138,18 +142,20 @@ class SliderTile extends StatelessWidget { padding: scaleConfig.useVisualIndicators ? EdgeInsets.zero : const EdgeInsets.fromLTRB(0, 2, 0, 2), - child: ListTile( - onTap: onTap, - dense: true, - visualDensity: const VisualDensity(vertical: -4), - title: Text( - title, - overflow: TextOverflow.fade, - softWrap: false, - ), - subtitle: subtitle.isNotEmpty ? Text(subtitle) : null, - iconColor: textColor, - textColor: textColor, - leading: icon == null ? null : Icon(icon))))); + child: GestureDetector( + onDoubleTap: onDoubleTap, + child: ListTile( + onTap: onTap, + dense: true, + visualDensity: const VisualDensity(vertical: -4), + title: Text( + title, + overflow: TextOverflow.fade, + softWrap: false, + ), + subtitle: subtitle.isNotEmpty ? Text(subtitle) : null, + iconColor: textColor, + textColor: textColor, + leading: icon == null ? null : Icon(icon)))))); } } diff --git a/lib/theme/views/avatar_widget.dart b/lib/theme/views/avatar_widget.dart new file mode 100644 index 0000000..43d351b --- /dev/null +++ b/lib/theme/views/avatar_widget.dart @@ -0,0 +1,77 @@ +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; + +import '../theme.dart'; + +class AvatarWidget extends StatelessWidget { + AvatarWidget({ + required String name, + required double size, + required Color borderColor, + required Color foregroundColor, + required Color backgroundColor, + required ScaleConfig scaleConfig, + required TextStyle textStyle, + super.key, + ImageProvider? imageProvider, + }) : _name = name, + _size = size, + _borderColor = borderColor, + _foregroundColor = foregroundColor, + _backgroundColor = backgroundColor, + _scaleConfig = scaleConfig, + _textStyle = textStyle, + _imageProvider = imageProvider; + + @override + Widget build(BuildContext context) { + final abbrev = _name.split(' ').map((s) => s.isEmpty ? '' : s[0]).join(); + late final String shortname; + if (abbrev.length >= 3) { + shortname = abbrev[0] + abbrev[1] + abbrev[abbrev.length - 1]; + } else { + shortname = abbrev; + } + + return Container( + height: _size, + width: _size, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: _scaleConfig.preferBorders + ? Border.all( + color: _borderColor, + width: 1 * (_size ~/ 32 + 1), + strokeAlign: BorderSide.strokeAlignOutside) + : null, + color: _borderColor, + ), + child: AvatarImage( + //size: 32, + backgroundImage: _imageProvider, + backgroundColor: + _scaleConfig.useVisualIndicators && !_scaleConfig.preferBorders + ? _foregroundColor + : _backgroundColor, + child: Text( + shortname, + style: _textStyle.copyWith( + color: _scaleConfig.useVisualIndicators && + !_scaleConfig.preferBorders + ? _backgroundColor + : _foregroundColor, + ), + ))); + } + + //////////////////////////////////////////////////////////////////////////// + final String _name; + final double _size; + final Color _borderColor; + final Color _foregroundColor; + final Color _backgroundColor; + final ScaleConfig _scaleConfig; + final TextStyle _textStyle; + final ImageProvider? _imageProvider; +} diff --git a/lib/theme/views/styled_dialog.dart b/lib/theme/views/styled_dialog.dart index b48a8fb..4e4bd50 100644 --- a/lib/theme/views/styled_dialog.dart +++ b/lib/theme/views/styled_dialog.dart @@ -50,6 +50,7 @@ class StyledDialog extends StatelessWidget { required Widget child}) async => showDialog( context: context, + useRootNavigator: false, builder: (context) => StyledDialog(title: title, child: child)); final String title; diff --git a/lib/theme/views/styled_scaffold.dart b/lib/theme/views/styled_scaffold.dart index af9e4eb..61e32aa 100644 --- a/lib/theme/views/styled_scaffold.dart +++ b/lib/theme/views/styled_scaffold.dart @@ -12,15 +12,15 @@ class StyledScaffold extends StatelessWidget { final scale = theme.extension()!; final scaleConfig = theme.extension()!; - final scaffold = isDesktop - ? clipBorder( - clipEnabled: true, - borderEnabled: scaleConfig.useVisualIndicators, - borderRadius: 16 * scaleConfig.borderRadiusScale, - borderColor: scale.primaryScale.border, - child: Scaffold(appBar: appBar, body: body, key: key)) - .paddingAll(32) - : Scaffold(appBar: appBar, body: body, key: key); + final enableBorder = !isMobileWidth(context); + + final scaffold = clipBorder( + clipEnabled: enableBorder, + borderEnabled: scaleConfig.useVisualIndicators, + borderRadius: 16 * scaleConfig.borderRadiusScale, + borderColor: scale.primaryScale.border, + child: Scaffold(appBar: appBar, body: body, key: key)) + .paddingAll(enableBorder ? 32 : 0); return GestureDetector( onTap: () => FocusManager.instance.primaryFocus?.unfocus(), diff --git a/lib/theme/views/views.dart b/lib/theme/views/views.dart index 642255e..b81f184 100644 --- a/lib/theme/views/views.dart +++ b/lib/theme/views/views.dart @@ -1,3 +1,4 @@ +export 'avatar_widget.dart'; export 'brightness_preferences.dart'; export 'color_preferences.dart'; export 'enter_password.dart'; diff --git a/lib/theme/views/widget_helpers.dart b/lib/theme/views/widget_helpers.dart index 4beb48b..158cc6c 100644 --- a/lib/theme/views/widget_helpers.dart +++ b/lib/theme/views/widget_helpers.dart @@ -41,6 +41,44 @@ extension ModalProgressExt on Widget { } } +extension LabelExt on Widget { + Widget decoratorLabel(BuildContext context, String label, + {ScaleColor? scale}) { + final theme = Theme.of(context); + final scaleScheme = theme.extension()!; + final scaleConfig = theme.extension()!; + scale = scale ?? scaleScheme.primaryScale; + + final border = scale.border; + final disabledBorder = scaleScheme.grayScale.border; + final hoverBorder = scale.hoverBorder; + final focusedErrorBorder = scaleScheme.errorScale.border; + final errorBorder = scaleScheme.errorScale.primary; + OutlineInputBorder makeBorder(Color color) => OutlineInputBorder( + borderRadius: + BorderRadius.circular(8 * scaleConfig.borderRadiusScale), + borderSide: BorderSide(color: color), + ); + OutlineInputBorder makeFocusedBorder(Color color) => OutlineInputBorder( + borderRadius: + BorderRadius.circular(8 * scaleConfig.borderRadiusScale), + borderSide: BorderSide(width: 2, color: color), + ); + return InputDecorator( + decoration: InputDecoration( + labelText: label, + floatingLabelStyle: TextStyle(color: hoverBorder), + border: makeBorder(border), + enabledBorder: makeBorder(border), + disabledBorder: makeBorder(disabledBorder), + focusedBorder: makeFocusedBorder(hoverBorder), + errorBorder: makeBorder(errorBorder), + focusedErrorBorder: makeFocusedBorder(focusedErrorBorder), + ), + child: this); + } +} + Widget buildProgressIndicator() => Builder(builder: (context) { final theme = Theme.of(context); final scale = theme.extension()!; @@ -292,6 +330,23 @@ Widget styledExpandingSliver( )); } +Widget styledHeader({required BuildContext context, required Widget child}) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + // final textTheme = theme.textTheme; + + return DecoratedBox( + decoration: ShapeDecoration( + color: scale.primaryScale.border, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(12 * scaleConfig.borderRadiusScale), + topRight: + Radius.circular(12 * scaleConfig.borderRadiusScale)))), + child: child); +} + Widget styledTitleContainer({ required BuildContext context, required String title, diff --git a/pubspec.lock b/pubspec.lock index 6d78407..a42b9b0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -675,10 +675,10 @@ packages: dependency: "direct main" description: name: form_builder_validators - sha256: "475853a177bfc832ec12551f752fd0001278358a6d42d2364681ff15f48f67cf" + sha256: c61ed7b1deecf0e1ebe49e2fa79e3283937c5a21c7e48e3ed9856a4a14e1191a url: "https://pub.dev" source: hosted - version: "10.0.1" + version: "11.0.0" freezed: dependency: "direct dev" description: @@ -1258,11 +1258,10 @@ packages: searchable_listview: dependency: "direct main" description: - name: searchable_listview - sha256: "5e547f2a3f88f99a798cfe64882cfce51496d8f3177bea4829dd7bcf3fa8910d" - url: "https://pub.dev" - source: hosted - version: "2.14.0" + path: "../Searchable-Listview" + relative: true + source: path + version: "2.14.1" share_plus: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 2c0b8eb..2226bc9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -52,7 +52,7 @@ dependencies: flutter_svg: ^2.0.10+1 flutter_translate: ^4.1.0 flutter_zoom_drawer: ^3.2.0 - form_builder_validators: ^10.0.1 + form_builder_validators: ^11.0.0 freezed_annotation: ^2.4.1 go_router: ^14.1.4 hydrated_bloc: ^9.1.5 @@ -81,7 +81,10 @@ dependencies: reorderable_grid: ^1.0.10 screenshot: ^3.0.0 scroll_to_index: ^3.0.1 - searchable_listview: ^2.14.0 + searchable_listview: + git: + url: https://gitlab.com/veilid/Searchable-Listview.git + ref: main share_plus: ^9.0.0 shared_preferences: ^2.2.3 signal_strength_indicator: ^0.4.1 @@ -112,6 +115,8 @@ dependency_overrides: path: ../dart_async_tools bloc_advanced_tools: path: ../bloc_advanced_tools + searchable_listview: + path: ../Searchable-Listview # flutter_chat_ui: # path: ../flutter_chat_ui From b6a812af87467cd9b79eca66e7ba5e53ab992747 Mon Sep 17 00:00:00 2001 From: Christien Rioux Date: Wed, 31 Jul 2024 12:14:06 -0500 Subject: [PATCH 3/5] oops --- lib/account_manager/models/models.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/account_manager/models/models.dart b/lib/account_manager/models/models.dart index 8b785c6..1a0c809 100644 --- a/lib/account_manager/models/models.dart +++ b/lib/account_manager/models/models.dart @@ -1,5 +1,5 @@ export 'account_info.dart'; -export 'account_update_spec.dart'; +export 'account_spec.dart'; export 'encryption_key_type.dart'; export 'local_account/local_account.dart'; export 'per_account_collection_state/per_account_collection_state.dart'; From 030f9d9651768a0f50664567d5984ad3a88d8fab Mon Sep 17 00:00:00 2001 From: Christien Rioux Date: Thu, 1 Aug 2024 14:30:06 -0500 Subject: [PATCH 4/5] profile edit happens without requiring save button --- assets/i18n/en.json | 10 +- assets/images/handshake.png | Bin 0 -> 43924 bytes assets/images/toilet.png | Bin 0 -> 28325 bytes .../cubits/account_record_cubit.dart | 26 ++- .../views/edit_account_page.dart | 69 ++---- .../views/edit_profile_form.dart | 208 +++++++++++------- .../views/new_account_page.dart | 72 ++++-- .../chat_single_contact_item_widget.dart | 43 ++-- .../views/contact_invitation_item_widget.dart | 2 +- .../views/create_invitation_dialog.dart | 45 ++-- lib/contacts/views/availability_widget.dart | 52 +++-- lib/contacts/views/contact_item_widget.dart | 40 ++-- lib/contacts/views/contacts_browser.dart | 127 +++++++++-- lib/contacts/views/contacts_dialog.dart | 12 +- lib/proto/extensions.dart | 1 + lib/theme/models/slider_tile.dart | 14 +- lib/theme/views/widget_helpers.dart | 33 +++ pubspec.lock | 8 + pubspec.yaml | 3 + 19 files changed, 499 insertions(+), 266 deletions(-) create mode 100644 assets/images/handshake.png create mode 100644 assets/images/toilet.png diff --git a/assets/i18n/en.json b/assets/i18n/en.json index ade2dcb..b2e6907 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -25,7 +25,7 @@ "empty_free_message": "Status when availability is 'Free'", "form_away_message": "Away Message", "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'", "form_availability": "Availability", "form_avatar": "Avatar", @@ -42,6 +42,7 @@ "create": "Create", "instructions": "This information will be shared with the people you invite to connect with you on VeilidChat.", "error": "Account creation error", + "network_is_offline": "Network is offline, try again when you're connected", "name": "Name", "pronouns": "Pronouns" }, @@ -149,9 +150,10 @@ }, "add_contact_sheet": { "new_contact": "New Contact", - "create_invite": "Create Invitation", - "scan_invite": "Scan Invitation", - "paste_invite": "Paste Invitation" + "create_invite": "Create\nInvitation", + "receive_invite": "Receive\nInvitation", + "scan_invite": "Scan\nInvitation", + "paste_invite": "Paste\nInvitation" }, "add_chat_sheet": { "new_chat": "New Chat" diff --git a/assets/images/handshake.png b/assets/images/handshake.png new file mode 100644 index 0000000000000000000000000000000000000000..d40fad1c889f016d4a61f59f17350a99b9ef0e31 GIT binary patch literal 43924 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 literal 0 HcmV?d00001 diff --git a/assets/images/toilet.png b/assets/images/toilet.png new file mode 100644 index 0000000000000000000000000000000000000000..6c0efa2a51a8399cf5af75529780da71f17a58d7 GIT binary patch literal 28325 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 literal 0 HcmV?d00001 diff --git a/lib/account_manager/cubits/account_record_cubit.dart b/lib/account_manager/cubits/account_record_cubit.dart index 0762926..2a3d9e2 100644 --- a/lib/account_manager/cubits/account_record_cubit.dart +++ b/lib/account_manager/cubits/account_record_cubit.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:async_tools/async_tools.dart'; import 'package:protobuf/protobuf.dart'; import 'package:veilid_support/veilid_support.dart'; @@ -7,6 +8,10 @@ import '../../proto/proto.dart' as proto; import '../account_manager.dart'; typedef AccountRecordState = proto.Account; +typedef _sspUpdateState = ( + AccountSpec accountSpec, + Future Function() onSuccess +); /// The saved state of a VeilidChat Account on the DHT /// Used to synchronize status, profile, and options for a specific account @@ -34,16 +39,25 @@ class AccountRecordCubit extends DefaultDHTRecordCubit { @override Future close() async { + await _sspUpdate.close(); await super.close(); } //////////////////////////////////////////////////////////////////////////// // Public Interface - Future updateAccount( - AccountSpec accountSpec, - ) async { + void updateAccount( + AccountSpec accountSpec, Future Function() onSuccess) { + _sspUpdate.updateState((accountSpec, onSuccess), (state) async { + await _updateAccountAsync(state.$1, state.$2); + }); + } + + Future _updateAccountAsync( + AccountSpec accountSpec, Future Function() onSuccess) async { + var changed = false; await record.eventualUpdateProtobuf(proto.Account.fromBuffer, (old) async { + changed = false; if (old == null) { return null; } @@ -63,7 +77,6 @@ class AccountRecordCubit extends DefaultDHTRecordCubit { ..awayMessage = accountSpec.awayMessage ..busyMessage = accountSpec.busyMessage; - var changed = false; if (newAccount.profile != old.profile || newAccount.invisible != old.invisible || newAccount.autodetectAway != old.autodetectAway || @@ -78,5 +91,10 @@ class AccountRecordCubit extends DefaultDHTRecordCubit { } return null; }); + if (changed) { + await onSuccess(); + } } + + final _sspUpdate = SingleStateProcessor<_sspUpdateState>(); } diff --git a/lib/account_manager/views/edit_account_page.dart b/lib/account_manager/views/edit_account_page.dart index 4f19429..e49b48a 100644 --- a/lib/account_manager/views/edit_account_page.dart +++ b/lib/account_manager/views/edit_account_page.dart @@ -50,13 +50,13 @@ class _EditAccountPageState extends WindowSetupState { orientationCapability: OrientationCapability.portraitOnly); Widget _editAccountForm(BuildContext context, - {required Future Function(AccountSpec) onSubmit}) => + {required Future Function(AccountSpec) onUpdate}) => EditProfileForm( header: translate('edit_account_page.header'), instructions: translate('edit_account_page.instructions'), submitText: translate('edit_account_page.update'), submitDisabledText: translate('button.waiting_for_network'), - onSubmit: onSubmit, + onUpdate: onUpdate, initialValueCallback: (key) => switch (key) { EditProfileForm.formFieldName => widget.existingAccount.profile.name, EditProfileForm.formFieldPronouns => @@ -76,7 +76,7 @@ class _EditAccountPageState extends WindowSetupState { EditProfileForm.formFieldAutoAway => widget.existingAccount.autodetectAway, EditProfileForm.formFieldAutoAwayTimeout => - widget.existingAccount.autoAwayTimeoutMin, + widget.existingAccount.autoAwayTimeoutMin.toString(), String() => throw UnimplementedError(), }, ); @@ -214,51 +214,24 @@ class _EditAccountPageState extends WindowSetupState { } } - Future _onSubmit(AccountSpec accountSpec) async { - // dismiss the keyboard by unfocusing the textfield - FocusScope.of(context).unfocus(); - - try { - setState(() { - _isInAsyncCall = true; - }); - try { - // Look up account cubit for this specific account - final perAccountCollectionBlocMapCubit = - context.read(); - 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'); - } + Future _onUpdate(AccountSpec accountSpec) async { + // Look up account cubit for this specific account + final perAccountCollectionBlocMapCubit = + context.read(); + 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 + accountRecordCubit.updateAccount(accountSpec, () async { + // Update local account profile + await AccountRepository.instance + .updateLocalAccount(widget.superIdentityRecordKey, accountSpec); + }); } @override @@ -290,7 +263,7 @@ class _EditAccountPageState extends WindowSetupState { child: Column(children: [ _editAccountForm( context, - onSubmit: _onSubmit, + onUpdate: _onUpdate, ).paddingLTRB(0, 0, 0, 32), OptionBox( instructions: diff --git a/lib/account_manager/views/edit_profile_form.dart b/lib/account_manager/views/edit_profile_form.dart index c9a328e..05e6ffe 100644 --- a/lib/account_manager/views/edit_profile_form.dart +++ b/lib/account_manager/views/edit_profile_form.dart @@ -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'; @@ -10,15 +11,18 @@ import '../../proto/proto.dart' as proto; import '../../theme/theme.dart'; import '../models/models.dart'; +const _kDoUpdateSubmit = 'doUpdateSubmit'; + class EditProfileForm extends StatefulWidget { const EditProfileForm({ required this.header, required this.instructions, required this.submitText, required this.submitDisabledText, - super.key, + required this.initialValueCallback, + this.onUpdate, this.onSubmit, - this.initialValueCallback, + super.key, }); @override @@ -26,10 +30,11 @@ class EditProfileForm extends StatefulWidget { final String header; final String instructions; + final Future Function(AccountSpec)? onUpdate; final Future Function(AccountSpec)? onSubmit; final String submitText; final String submitDisabledText; - final Object? Function(String key)? initialValueCallback; + final Object Function(String key) initialValueCallback; @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { @@ -38,11 +43,13 @@ class EditProfileForm extends StatefulWidget { ..add(StringProperty('header', header)) ..add(StringProperty('instructions', instructions)) ..add(ObjectFlagProperty Function(AccountSpec)?>.has( - 'onSubmit', onSubmit)) + 'onUpdate', onUpdate)) ..add(StringProperty('submitText', submitText)) ..add(StringProperty('submitDisabledText', submitDisabledText)) - ..add(ObjectFlagProperty.has( - 'initialValueCallback', initialValueCallback)); + ..add(ObjectFlagProperty.has( + 'initialValueCallback', initialValueCallback)) + ..add(ObjectFlagProperty Function(AccountSpec)?>.has( + 'onSubmit', onSubmit)); } static const String formFieldName = 'name'; @@ -62,15 +69,17 @@ class _EditProfileFormState extends State { @override void initState() { + _autoAwayEnabled = + widget.initialValueCallback(EditProfileForm.formFieldAutoAway) as bool; + super.initState(); } FormBuilderDropdown _availabilityDropDown( BuildContext context) { final initialValueX = - widget.initialValueCallback?.call(EditProfileForm.formFieldAvailability) - as proto.Availability? ?? - proto.Availability.AVAILABILITY_FREE; + widget.initialValueCallback(EditProfileForm.formFieldAvailability) + as proto.Availability; final initialValue = initialValueX == proto.Availability.AVAILABILITY_UNSPECIFIED ? proto.Availability.AVAILABILITY_FREE @@ -86,14 +95,19 @@ class _EditProfileFormState extends State { return FormBuilderDropdown( 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( value: x, child: Row(mainAxisSize: MainAxisSize.min, children: [ - Icon(AvailabilityWidget.availabilityIcon(x)), + AvailabilityWidget.availabilityIcon(x), Text(x == proto.Availability.AVAILABILITY_OFFLINE - ? translate('availability.always_show_offline') - : AvailabilityWidget.availabilityName(x)), + ? translate('availability.always_show_offline') + : AvailabilityWidget.availabilityName(x)) + .paddingLTRB(8, 0, 0, 0), ]))) .toList(), ); @@ -103,34 +117,26 @@ class _EditProfileFormState extends State { 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? ?? - ''; + .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? ?? - proto.Availability.AVAILABILITY_FREE; + .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? ?? - ''; + .fields[EditProfileForm.formFieldFreeMessage]!.value as String; final awayMessage = _formKey.currentState! - .fields[EditProfileForm.formFieldAwayMessage]!.value as String? ?? - ''; + .fields[EditProfileForm.formFieldAwayMessage]!.value as String; final busyMessage = _formKey.currentState! - .fields[EditProfileForm.formFieldBusyMessage]!.value as String? ?? - ''; - final autoAway = _formKey.currentState! - .fields[EditProfileForm.formFieldAutoAway]!.value as bool? ?? - false; - final autoAwayTimeout = _formKey.currentState! - .fields[EditProfileForm.formFieldAutoAwayTimeout]!.value as int? ?? - 30; + .fields[EditProfileForm.formFieldBusyMessage]!.value as String; + 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, @@ -163,6 +169,7 @@ class _EditProfileFormState extends State { return FormBuilder( key: _formKey, + autovalidateMode: AutovalidateMode.onUserInteraction, child: Column( children: [ AvatarWidget( @@ -179,9 +186,10 @@ class _EditProfileFormState extends State { FormBuilderTextField( autofocus: true, name: EditProfileForm.formFieldName, - initialValue: widget.initialValueCallback - ?.call(EditProfileForm.formFieldName) as String?, + initialValue: widget + .initialValueCallback(EditProfileForm.formFieldName) as String, decoration: InputDecoration( + floatingLabelBehavior: FloatingLabelBehavior.always, labelText: translate('account.form_name'), hintText: translate('account.empty_name')), maxLength: 64, @@ -190,113 +198,149 @@ class _EditProfileFormState extends State { FormBuilderValidators.required(), ]), textInputAction: TextInputAction.next, - ), + ).onFocusChange(_onFocusChange), FormBuilderTextField( name: EditProfileForm.formFieldPronouns, - initialValue: widget.initialValueCallback - ?.call(EditProfileForm.formFieldPronouns) as String?, + initialValue: + widget.initialValueCallback(EditProfileForm.formFieldPronouns) + as String, maxLength: 64, decoration: InputDecoration( + floatingLabelBehavior: FloatingLabelBehavior.always, labelText: translate('account.form_pronouns'), hintText: translate('account.empty_pronouns')), textInputAction: TextInputAction.next, - ), + ).onFocusChange(_onFocusChange), FormBuilderTextField( name: EditProfileForm.formFieldAbout, - initialValue: widget.initialValueCallback - ?.call(EditProfileForm.formFieldAbout) as String?, + initialValue: widget + .initialValueCallback(EditProfileForm.formFieldAbout) as String, 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), + ).onFocusChange(_onFocusChange), + _availabilityDropDown(context) + .paddingLTRB(0, 0, 0, 16) + .onFocusChange(_onFocusChange), FormBuilderTextField( name: EditProfileForm.formFieldFreeMessage, - initialValue: widget.initialValueCallback - ?.call(EditProfileForm.formFieldFreeMessage) as String?, + initialValue: widget.initialValueCallback( + EditProfileForm.formFieldFreeMessage) as String, maxLength: 128, decoration: InputDecoration( + floatingLabelBehavior: FloatingLabelBehavior.always, labelText: translate('account.form_free_message'), hintText: translate('account.empty_free_message')), textInputAction: TextInputAction.next, - ), + ).onFocusChange(_onFocusChange), FormBuilderTextField( name: EditProfileForm.formFieldAwayMessage, - initialValue: widget.initialValueCallback - ?.call(EditProfileForm.formFieldAwayMessage) as String?, + initialValue: widget.initialValueCallback( + EditProfileForm.formFieldAwayMessage) as String, maxLength: 128, decoration: InputDecoration( + floatingLabelBehavior: FloatingLabelBehavior.always, labelText: translate('account.form_away_message'), hintText: translate('account.empty_away_message')), textInputAction: TextInputAction.next, - ), + ).onFocusChange(_onFocusChange), FormBuilderTextField( name: EditProfileForm.formFieldBusyMessage, - initialValue: widget.initialValueCallback - ?.call(EditProfileForm.formFieldBusyMessage) as String?, + initialValue: widget.initialValueCallback( + EditProfileForm.formFieldBusyMessage) as String, maxLength: 128, decoration: InputDecoration( + floatingLabelBehavior: FloatingLabelBehavior.always, labelText: translate('account.form_busy_message'), hintText: translate('account.empty_busy_message')), textInputAction: TextInputAction.next, - ), + ).onFocusChange(_onFocusChange), FormBuilderCheckbox( name: EditProfileForm.formFieldAutoAway, - initialValue: widget.initialValueCallback - ?.call(EditProfileForm.formFieldAutoAway) as bool? ?? - false, + initialValue: + widget.initialValueCallback(EditProfileForm.formFieldAutoAway) + as bool, side: BorderSide(color: scale.primaryScale.border, width: 2), title: Text(translate('account.form_auto_away'), style: textTheme.labelMedium), - ), + onChanged: (v) { + setState(() { + _autoAwayEnabled = v ?? false; + }); + }, + ).onFocusChange(_onFocusChange), FormBuilderTextField( name: EditProfileForm.formFieldAutoAwayTimeout, - enabled: _formKey.currentState - ?.value[EditProfileForm.formFieldAutoAway] as bool? ?? - false, - initialValue: widget.initialValueCallback - ?.call(EditProfileForm.formFieldAutoAwayTimeout) - as String? ?? - '15', + enabled: _autoAwayEnabled, + initialValue: widget.initialValueCallback( + EditProfileForm.formFieldAutoAwayTimeout) as String, decoration: InputDecoration( labelText: translate('account.form_auto_away_timeout'), ), validator: FormBuilderValidators.positiveNumber(), textInputAction: TextInputAction.next, - ), + ).onFocusChange(_onFocusChange), Row(children: [ const Spacer(), Text(widget.instructions).toCenter().flexible(flex: 6), const Spacer(), ]).paddingSymmetric(vertical: 4), - ElevatedButton( - onPressed: widget.onSubmit == null - ? null - : () async { - if (_formKey.currentState?.saveAndValidate() ?? false) { - final aus = _makeAccountSpec(); - await widget.onSubmit!(aus); - } - }, - 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(), + if (widget.onSubmit != null) + ElevatedButton( + onPressed: widget.onSubmit == null ? null : _doSubmit, + 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) + ]), + ) ], ), ); } + 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 Widget build(BuildContext context) => _editProfileForm( context, ); + + /////////////////////////////////////////////////////////////////////////// + late bool _autoAwayEnabled; } diff --git a/lib/account_manager/views/new_account_page.dart b/lib/account_manager/views/new_account_page.dart index b107a7b..ee2f62c 100644 --- a/lib/account_manager/views/new_account_page.dart +++ b/lib/account_manager/views/new_account_page.dart @@ -7,6 +7,8 @@ import 'package:flutter_translate/flutter_translate.dart'; import 'package:go_router/go_router.dart'; import '../../layout/default_app_bar.dart'; +import '../../notifications/cubits/notifications_cubit.dart'; +import '../../proto/proto.dart' as proto; import '../../theme/theme.dart'; import '../../tools/tools.dart'; import '../../veilid_processor/veilid_processor.dart'; @@ -26,25 +28,44 @@ class _NewAccountPageState extends WindowSetupState { titleBarStyle: TitleBarStyle.normal, orientationCapability: OrientationCapability.portraitOnly); - Widget _newAccountForm(BuildContext context, - {required Future Function(AccountSpec) onSubmit}) { - final networkReady = context - .watch() - .state - .asData - ?.value - .isPublicInternetReady ?? - false; - final canSubmit = networkReady; - - return 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'), - onSubmit: !canSubmit ? null : onSubmit); + Object _defaultAccountValues(String key) { + switch (key) { + case EditProfileForm.formFieldName: + return ''; + case EditProfileForm.formFieldPronouns: + return ''; + case EditProfileForm.formFieldAbout: + return ''; + case EditProfileForm.formFieldAvailability: + return proto.Availability.AVAILABILITY_FREE; + case EditProfileForm.formFieldFreeMessage: + return ''; + case EditProfileForm.formFieldAwayMessage: + return ''; + case EditProfileForm.formFieldBusyMessage: + return ''; + // 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 _onSubmit(AccountSpec accountSpec) async { // dismiss the keyboard by unfocusing the textfield FocusScope.of(context).unfocus(); @@ -54,6 +75,22 @@ class _NewAccountPageState extends WindowSetupState { _isInAsyncCall = true; }); try { + final networkReady = context + .read() + .state + .asData + ?.value + .isPublicInternetReady ?? + false; + + final canSubmit = networkReady; + if (!canSubmit) { + context.read().error( + text: translate('new_account_page.network_is_offline'), + title: translate('new_account_page.error')); + return; + } + final writableSuperIdentity = await AccountRepository.instance .createWithNewSuperIdentity(accountSpec); GoRouterHelper(context).pushReplacement('/new_account/recovery_key', @@ -100,7 +137,6 @@ class _NewAccountPageState extends WindowSetupState { body: SingleChildScrollView( child: _newAccountForm( context, - onSubmit: _onSubmit, )).paddingSymmetric(horizontal: 24, vertical: 8), ).withModalHUD(context, displayModalHUD); } diff --git a/lib/chat_list/views/chat_single_contact_item_widget.dart b/lib/chat_list/views/chat_single_contact_item_widget.dart index 7fd38cd..b186290 100644 --- a/lib/chat_list/views/chat_single_contact_item_widget.dart +++ b/lib/chat_list/views/chat_single_contact_item_widget.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_translate/flutter_translate.dart'; import '../../chat/cubits/active_chat_cubit.dart'; +import '../../contacts/contacts.dart'; import '../../proto/proto.dart' as proto; import '../../theme/theme.dart'; import '../chat_list.dart'; @@ -23,28 +24,33 @@ class ChatSingleContactItemWidget extends StatelessWidget { Widget build( BuildContext context, ) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; + final activeChatCubit = context.watch(); final localConversationRecordKey = _contact.localConversationRecordKey.toVeilid(); final selected = activeChatCubit.state == localConversationRecordKey; - late final String title; - late final String subtitle; - if (_contact.nickname.isNotEmpty) { - title = _contact.nickname; - if (_contact.profile.pronouns.isNotEmpty) { - subtitle = '${_contact.profile.name} (${_contact.profile.pronouns})'; - } else { - subtitle = _contact.profile.name; - } - } else { - title = _contact.profile.name; - if (_contact.profile.pronouns.isNotEmpty) { - subtitle = '(${_contact.profile.pronouns})'; - } else { - subtitle = ''; - } - } + final name = _contact.nameOrNickname; + final title = _contact.displayName; + final subtitle = _contact.profile.status; + + final avatar = AvatarWidget( + name: name, + size: 34, + borderColor: _disabled + ? scale.grayScale.primaryText + : scale.secondaryScale.primaryText, + foregroundColor: _disabled + ? scale.grayScale.primaryText + : scale.secondaryScale.primaryText, + backgroundColor: + _disabled ? scale.grayScale.primary : scale.secondaryScale.primary, + scaleConfig: scaleConfig, + textStyle: theme.textTheme.titleLarge!, + ); return SliderTile( key: ObjectKey(_contact), @@ -53,7 +59,8 @@ class ChatSingleContactItemWidget extends StatelessWidget { tileScale: ScaleKind.secondary, title: title, subtitle: subtitle, - icon: Icons.chat, + leading: avatar, + trailing: AvailabilityWidget(availability: _contact.profile.availability), onTap: () { singleFuture(activeChatCubit, () async { activeChatCubit.setActiveChat(localConversationRecordKey); diff --git a/lib/contact_invitation/views/contact_invitation_item_widget.dart b/lib/contact_invitation/views/contact_invitation_item_widget.dart index 8fccc8a..6e6dfcf 100644 --- a/lib/contact_invitation/views/contact_invitation_item_widget.dart +++ b/lib/contact_invitation/views/contact_invitation_item_widget.dart @@ -45,7 +45,7 @@ class ContactInvitationItemWidget extends StatelessWidget { title: contactInvitationRecord.message.isEmpty ? translate('contact_list.invitation') : contactInvitationRecord.message, - icon: Icons.person_add, + leading: const Icon(Icons.person_add), onTap: () async { if (!context.mounted) { return; diff --git a/lib/contact_invitation/views/create_invitation_dialog.dart b/lib/contact_invitation/views/create_invitation_dialog.dart index 5711a32..f1115d7 100644 --- a/lib/contact_invitation/views/create_invitation_dialog.dart +++ b/lib/contact_invitation/views/create_invitation_dialog.dart @@ -203,34 +203,37 @@ class CreateInvitationDialogState extends State { Text(translate('create_invitation_dialog.protect_this_invitation'), style: textTheme.labelLarge) .paddingAll(8), - Wrap(spacing: 5, children: [ - ChoiceChip( - label: Text(translate('create_invitation_dialog.unlocked')), - selected: _encryptionKeyType == EncryptionKeyType.none, - onSelected: _onNoneEncryptionSelected, - ), - ChoiceChip( - label: Text(translate('create_invitation_dialog.pin')), - selected: _encryptionKeyType == EncryptionKeyType.pin, - onSelected: _onPinEncryptionSelected, - ), - ChoiceChip( - label: Text(translate('create_invitation_dialog.password')), - selected: _encryptionKeyType == EncryptionKeyType.password, - onSelected: _onPasswordEncryptionSelected, - ) - ]).paddingAll(8), + Wrap( + alignment: WrapAlignment.center, + runAlignment: WrapAlignment.center, + runSpacing: 8, + spacing: 8, + children: [ + ChoiceChip( + label: Text(translate('create_invitation_dialog.unlocked')), + selected: _encryptionKeyType == EncryptionKeyType.none, + onSelected: _onNoneEncryptionSelected, + ), + ChoiceChip( + label: Text(translate('create_invitation_dialog.pin')), + selected: _encryptionKeyType == EncryptionKeyType.pin, + onSelected: _onPinEncryptionSelected, + ), + ChoiceChip( + label: Text(translate('create_invitation_dialog.password')), + selected: _encryptionKeyType == EncryptionKeyType.password, + onSelected: _onPasswordEncryptionSelected, + ) + ]).paddingAll(8).toCenter(), Container( - width: double.infinity, - height: 60, padding: const EdgeInsets.all(8), child: ElevatedButton( onPressed: _onGenerateButtonPressed, child: Text( translate('create_invitation_dialog.generate'), - ), + ).paddingAll(16), ), - ), + ).toCenter(), Text(translate('create_invitation_dialog.note')).paddingAll(8), Text( translate('create_invitation_dialog.note_text'), diff --git a/lib/contacts/views/availability_widget.dart b/lib/contacts/views/availability_widget.dart index 8dc66d8..d50e323 100644 --- a/lib/contacts/views/availability_widget.dart +++ b/lib/contacts/views/availability_widget.dart @@ -1,3 +1,4 @@ +import 'package:awesome_extensions/awesome_extensions.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.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; 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) { - late final IconData iconData; + static Widget availabilityIcon(proto.Availability availability, + {double size = 32}) { + late final Widget iconData; switch (availability) { case proto.Availability.AVAILABILITY_AWAY: - iconData = Icons.hot_tub; + iconData = + ImageIcon(const AssetImage('assets/images/toilet.png'), size: size); case proto.Availability.AVAILABILITY_BUSY: - iconData = Icons.event_busy; + iconData = Icon(Icons.event_busy, size: size); case proto.Availability.AVAILABILITY_FREE: - iconData = Icons.event_available; + iconData = Icon(Icons.event_available, size: size); case proto.Availability.AVAILABILITY_OFFLINE: - iconData = Icons.cloud_off; + iconData = Icon(Icons.cloud_off, size: size); case proto.Availability.AVAILABILITY_UNSPECIFIED: - iconData = Icons.question_mark; + iconData = Icon(Icons.question_mark, size: size); } return iconData; } @@ -49,20 +56,35 @@ class AvailabilityWidget extends StatelessWidget { // final scaleConfig = theme.extension()!; final name = availabilityName(availability); - final iconData = availabilityIcon(availability); + final icon = availabilityIcon(availability, size: iconSize); - return Row(mainAxisSize: MainAxisSize.min, children: [ - Icon(iconData, size: 32), - Text(name, style: textTheme.labelSmall) - ]); + return vertical + ? Column( + 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 bool vertical; + final double iconSize; @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); - properties.add( - DiagnosticsProperty('availability', availability)); + properties + ..add( + DiagnosticsProperty('availability', availability)) + ..add(DiagnosticsProperty('vertical', vertical)) + ..add(DoubleProperty('iconSize', iconSize)); } } diff --git a/lib/contacts/views/contact_item_widget.dart b/lib/contacts/views/contact_item_widget.dart index 7bf2fa4..46b658a 100644 --- a/lib/contacts/views/contact_item_widget.dart +++ b/lib/contacts/views/contact_item_widget.dart @@ -28,24 +28,28 @@ class ContactItemWidget extends StatelessWidget { Widget build( BuildContext context, ) { - late final String title; - late final String subtitle; + final theme = Theme.of(context); + final scale = theme.extension()!; + final scaleConfig = theme.extension()!; - if (_contact.nickname.isNotEmpty) { - title = _contact.nickname; - if (_contact.profile.pronouns.isNotEmpty) { - subtitle = '${_contact.profile.name} (${_contact.profile.pronouns})'; - } else { - subtitle = _contact.profile.name; - } - } else { - title = _contact.profile.name; - if (_contact.profile.pronouns.isNotEmpty) { - subtitle = '(${_contact.profile.pronouns})'; - } else { - subtitle = ''; - } - } + final name = _contact.nameOrNickname; + final title = _contact.displayName; + final subtitle = _contact.profile.status; + + final avatar = AvatarWidget( + name: name, + size: 34, + borderColor: _disabled + ? scale.grayScale.primaryText + : scale.primaryScale.primaryText, + foregroundColor: _disabled + ? scale.grayScale.primaryText + : scale.primaryScale.primaryText, + backgroundColor: + _disabled ? scale.grayScale.primary : scale.primaryScale.primary, + scaleConfig: scaleConfig, + textStyle: theme.textTheme.titleLarge!, + ); return SliderTile( key: ObjectKey(_contact), @@ -54,7 +58,7 @@ class ContactItemWidget extends StatelessWidget { tileScale: ScaleKind.primary, title: title, subtitle: subtitle, - icon: Icons.person, + leading: avatar, onDoubleTap: _onDoubleTap == null ? null : () => singleFuture((this, _kOnTap), () async { diff --git a/lib/contacts/views/contacts_browser.dart b/lib/contacts/views/contacts_browser.dart index f3b03c9..db2a602 100644 --- a/lib/contacts/views/contacts_browser.dart +++ b/lib/contacts/views/contacts_browser.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_translate/flutter_translate.dart'; import 'package:searchable_listview/searchable_listview.dart'; +import 'package:star_menu/star_menu.dart'; import 'package:veilid_support/veilid_support.dart'; import '../../chat_list/chat_list.dart'; @@ -71,6 +72,75 @@ class _ContactsBrowserState extends State final scale = theme.extension()!; final scaleConfig = theme.extension()!; + 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: [ Column(mainAxisSize: MainAxisSize.min, children: [ IconButton( @@ -80,30 +150,36 @@ class _ContactsBrowserState extends State iconSize: 32, icon: const Icon(Icons.contact_page), 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); } @@ -112,7 +188,7 @@ class _ContactsBrowserState extends State final theme = Theme.of(context); final textTheme = theme.textTheme; final scale = theme.extension()!; - final scaleConfig = theme.extension()!; + //final scaleConfig = theme.extension()!; final cilState = context.watch().state; final cilBusy = cilState.busy; @@ -244,4 +320,7 @@ class _ContactsBrowserState extends State await chatListCubit.deleteChat( localConversationRecordKey: localConversationRecordKey); } + + //////////////////////////////////////////////////////////////////////////// + final _receiveInviteMenuController = StarMenuController(); } diff --git a/lib/contacts/views/contacts_dialog.dart b/lib/contacts/views/contacts_dialog.dart index d0c1c82..f994148 100644 --- a/lib/contacts/views/contacts_dialog.dart +++ b/lib/contacts/views/contacts_dialog.dart @@ -1,18 +1,14 @@ import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_translate/flutter_translate.dart'; -import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import '../../chat/chat.dart'; import '../../chat_list/chat_list.dart'; -import '../../proto/proto.dart' as proto; -import '../../contact_invitation/contact_invitation.dart'; import '../../layout/layout.dart'; +import '../../proto/proto.dart' as proto; import '../../theme/theme.dart'; -import '../../veilid_processor/veilid_processor.dart'; import '../contacts.dart'; class ContactsDialog extends StatefulWidget { @@ -48,9 +44,9 @@ class _ContactsDialogState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final textTheme = theme.textTheme; + // final textTheme = theme.textTheme; final scale = theme.extension()!; - final scaleConfig = theme.extension()!; + // final scaleConfig = theme.extension()!; final enableSplit = !isMobileWidth(context); final enableLeft = enableSplit || _selectedContact == null; @@ -105,7 +101,7 @@ class _ContactsDialogState extends State { .toVeilid(), onContactSelected: onContactSelected, onChatStarted: onChatStarted, - ).paddingAll(8)))), + ).paddingLTRB(8, 0, 8, 8)))), if (enableRight) if (_selectedContact == null) const NoContactWidget().expanded() diff --git a/lib/proto/extensions.dart b/lib/proto/extensions.dart index 4491f89..2f4ad68 100644 --- a/lib/proto/extensions.dart +++ b/lib/proto/extensions.dart @@ -31,6 +31,7 @@ extension MessageExt on proto.Message { } extension ContactExt on proto.Contact { + String get nameOrNickname => nickname.isNotEmpty ? nickname : profile.name; String get displayName => nickname.isNotEmpty ? '$nickname (${profile.name})' : profile.name; } diff --git a/lib/theme/models/slider_tile.dart b/lib/theme/models/slider_tile.dart index 7631303..e70c6bd 100644 --- a/lib/theme/models/slider_tile.dart +++ b/lib/theme/models/slider_tile.dart @@ -31,7 +31,8 @@ class SliderTile extends StatelessWidget { this.startActions = const [], this.onTap, this.onDoubleTap, - this.icon, + this.leading, + this.trailing, super.key}); final bool disabled; @@ -41,7 +42,8 @@ class SliderTile extends StatelessWidget { final List startActions; final GestureTapCallback? onTap; final GestureTapCallback? onDoubleTap; - final IconData? icon; + final Widget? leading; + final Widget? trailing; final String title; final String subtitle; @@ -55,11 +57,12 @@ class SliderTile extends StatelessWidget { ..add(IterableProperty('endActions', endActions)) ..add(IterableProperty('startActions', startActions)) ..add(ObjectFlagProperty.has('onTap', onTap)) - ..add(DiagnosticsProperty('icon', icon)) + ..add(DiagnosticsProperty('leading', leading)) ..add(StringProperty('title', title)) ..add(StringProperty('subtitle', subtitle)) ..add(ObjectFlagProperty.has( - 'onDoubleTap', onDoubleTap)); + 'onDoubleTap', onDoubleTap)) + ..add(DiagnosticsProperty('trailing', trailing)); } @override @@ -156,6 +159,7 @@ class SliderTile extends StatelessWidget { subtitle: subtitle.isNotEmpty ? Text(subtitle) : null, iconColor: textColor, textColor: textColor, - leading: icon == null ? null : Icon(icon)))))); + leading: FittedBox(child: leading), + trailing: FittedBox(child: trailing)))))); } } diff --git a/lib/theme/views/widget_helpers.dart b/lib/theme/views/widget_helpers.dart index 158cc6c..0a5af02 100644 --- a/lib/theme/views/widget_helpers.dart +++ b/lib/theme/views/widget_helpers.dart @@ -8,6 +8,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_spinkit/flutter_spinkit.dart'; import 'package:flutter_sticky_header/flutter_sticky_header.dart'; +import 'package:meta/meta.dart'; import 'package:quickalert/quickalert.dart'; import 'package:sliver_expandable/sliver_expandable.dart'; @@ -27,6 +28,38 @@ extension SizeToFixExt on Widget { ); } +extension FocusExt on Widget { + Focus focus( + {Key? key, + FocusNode? focusNode, + FocusNode? parentNode, + bool autofocus = false, + ValueChanged? 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 { BlurryModalProgressHUD withModalHUD(BuildContext context, bool isLoading) { final theme = Theme.of(context); diff --git a/pubspec.lock b/pubspec.lock index a42b9b0..77484ed 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1468,6 +1468,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 2226bc9..2f38215 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -97,6 +97,7 @@ dependencies: ref: main split_view: ^3.2.1 stack_trace: ^1.11.1 + star_menu: ^4.0.1 stream_transform: ^2.1.0 transitioned_indexed_stack: ^1.0.2 url_launcher: ^6.3.0 @@ -163,6 +164,8 @@ flutter: - assets/images/title.svg - assets/images/vlogo.svg - assets/images/ellet.png + - assets/images/toilet.png + - assets/images/handshake.png # Printing - assets/js/pdf/3.2.146/pdf.min.js # Sounds From aed76c30b096291f4bee8e22faea78bb09e8487e Mon Sep 17 00:00:00 2001 From: Christien Rioux Date: Thu, 1 Aug 2024 14:52:10 -0500 Subject: [PATCH 5/5] update dependencies --- ios/Podfile.lock | 2 +- lib/theme/views/widget_helpers.dart | 1 - packages/veilid_support/example/pubspec.lock | 8 +- packages/veilid_support/example/pubspec.yaml | 2 +- packages/veilid_support/pubspec.lock | 18 +- packages/veilid_support/pubspec.yaml | 14 +- pubspec.lock | 178 ++++++++++--------- pubspec.yaml | 18 +- 8 files changed, 123 insertions(+), 118 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 3a17844..536f7a4 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -158,7 +158,7 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/veilid/ios" SPEC CHECKSUMS: - camera_avfoundation: 759172d1a77ae7be0de08fc104cfb79738b8a59e + camera_avfoundation: dd002b0330f4981e1bbcb46ae9b62829237459a4 file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778 diff --git a/lib/theme/views/widget_helpers.dart b/lib/theme/views/widget_helpers.dart index 0a5af02..b079d2b 100644 --- a/lib/theme/views/widget_helpers.dart +++ b/lib/theme/views/widget_helpers.dart @@ -8,7 +8,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_spinkit/flutter_spinkit.dart'; import 'package:flutter_sticky_header/flutter_sticky_header.dart'; -import 'package:meta/meta.dart'; import 'package:quickalert/quickalert.dart'; import 'package:sliver_expandable/sliver_expandable.dart'; diff --git a/packages/veilid_support/example/pubspec.lock b/packages/veilid_support/example/pubspec.lock index 2c2b1ad..aa857d4 100644 --- a/packages/veilid_support/example/pubspec.lock +++ b/packages/veilid_support/example/pubspec.lock @@ -37,10 +37,10 @@ packages: dependency: "direct dev" description: name: async_tools - sha256: "375da1e5b51974a9e84469b7630f36d1361fb53d3fcc818a5a0121ab51fe0343" + sha256: "9166e8fe65fc65eb79202a6d540f4de768553d78141b885f5bd3f8d7d30eef5e" url: "https://pub.dev" source: hosted - version: "0.1.4" + version: "0.1.5" bloc: dependency: transitive description: @@ -53,10 +53,10 @@ packages: dependency: transitive description: name: bloc_advanced_tools - sha256: "0599d860eb096c5b12457c60bdf7f66bfcb7171bc94ccf2c7c752b6a716a79f9" + sha256: "2b2dd492a350e7192a933d09f15ea04d5d00e7bd3fe2a906fe629cd461ddbf94" url: "https://pub.dev" source: hosted - version: "0.1.4" + version: "0.1.5" boolean_selector: dependency: transitive description: diff --git a/packages/veilid_support/example/pubspec.yaml b/packages/veilid_support/example/pubspec.yaml index a885f94..eb1c45a 100644 --- a/packages/veilid_support/example/pubspec.yaml +++ b/packages/veilid_support/example/pubspec.yaml @@ -14,7 +14,7 @@ dependencies: path: ../ dev_dependencies: - async_tools: ^0.1.4 + async_tools: ^0.1.5 integration_test: sdk: flutter lint_hard: ^4.0.0 diff --git a/packages/veilid_support/pubspec.lock b/packages/veilid_support/pubspec.lock index e68b6db..5078198 100644 --- a/packages/veilid_support/pubspec.lock +++ b/packages/veilid_support/pubspec.lock @@ -36,10 +36,11 @@ packages: async_tools: dependency: "direct main" description: - path: "../../../dart_async_tools" - relative: true - source: path - version: "0.1.4" + name: async_tools + sha256: "9166e8fe65fc65eb79202a6d540f4de768553d78141b885f5bd3f8d7d30eef5e" + url: "https://pub.dev" + source: hosted + version: "0.1.5" bloc: dependency: "direct main" description: @@ -51,10 +52,11 @@ packages: bloc_advanced_tools: dependency: "direct main" description: - path: "../../../bloc_advanced_tools" - relative: true - source: path - version: "0.1.4" + name: bloc_advanced_tools + sha256: "2b2dd492a350e7192a933d09f15ea04d5d00e7bd3fe2a906fe629cd461ddbf94" + url: "https://pub.dev" + source: hosted + version: "0.1.5" boolean_selector: dependency: transitive description: diff --git a/packages/veilid_support/pubspec.yaml b/packages/veilid_support/pubspec.yaml index 51c3e60..cec41f7 100644 --- a/packages/veilid_support/pubspec.yaml +++ b/packages/veilid_support/pubspec.yaml @@ -7,9 +7,9 @@ environment: sdk: '>=3.2.0 <4.0.0' dependencies: - async_tools: ^0.1.4 + async_tools: ^0.1.5 bloc: ^8.1.4 - bloc_advanced_tools: ^0.1.4 + bloc_advanced_tools: ^0.1.5 charcode: ^1.3.1 collection: ^1.18.0 equatable: ^2.0.5 @@ -26,11 +26,11 @@ dependencies: # veilid: ^0.0.1 path: ../../../veilid/veilid-flutter -dependency_overrides: - async_tools: - path: ../../../dart_async_tools - bloc_advanced_tools: - path: ../../../bloc_advanced_tools +# dependency_overrides: +# async_tools: +# path: ../../../dart_async_tools +# bloc_advanced_tools: +# path: ../../../bloc_advanced_tools dev_dependencies: build_runner: ^2.4.10 diff --git a/pubspec.lock b/pubspec.lock index 77484ed..2e8947c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -84,10 +84,11 @@ packages: async_tools: dependency: "direct main" description: - path: "../dart_async_tools" - relative: true - source: path - version: "0.1.4" + name: async_tools + sha256: "9166e8fe65fc65eb79202a6d540f4de768553d78141b885f5bd3f8d7d30eef5e" + url: "https://pub.dev" + source: hosted + version: "0.1.5" awesome_extensions: dependency: "direct main" description: @@ -139,10 +140,11 @@ packages: bloc_advanced_tools: dependency: "direct main" description: - path: "../bloc_advanced_tools" - relative: true - source: path - version: "0.1.4" + name: bloc_advanced_tools + sha256: "2b2dd492a350e7192a933d09f15ea04d5d00e7bd3fe2a906fe629cd461ddbf94" + url: "https://pub.dev" + source: hosted + version: "0.1.5" blurry_modal_progress_hud: dependency: "direct main" description: @@ -227,26 +229,26 @@ packages: dependency: transitive description: name: cached_network_image - sha256: "28ea9690a8207179c319965c13cd8df184d5ee721ae2ce60f398ced1219cea1f" + sha256: "4a5d8d2c728b0f3d0245f69f921d7be90cae4c2fd5288f773088672c0893f819" url: "https://pub.dev" source: hosted - version: "3.3.1" + version: "3.4.0" cached_network_image_platform_interface: dependency: transitive description: name: cached_network_image_platform_interface - sha256: "9e90e78ae72caa874a323d78fa6301b3fb8fa7ea76a8f96dc5b5bf79f283bf2f" + sha256: ff0c949e323d2a1b52be73acce5b4a7b04063e61414c8ca542dbba47281630a7 url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.1.0" cached_network_image_web: dependency: transitive description: name: cached_network_image_web - sha256: "205d6a9f1862de34b93184f22b9d2d94586b2f05c581d546695e3d8f6a805cd7" + sha256: "6322dde7a5ad92202e64df659241104a43db20ed594c41ca18de1014598d7996" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" camera: dependency: transitive description: @@ -259,34 +261,34 @@ packages: dependency: transitive description: name: camera_android - sha256: "981654e0e56a4c735f7ecc7bd3921385eb5f7dd13deaf4a6431255d9731df01a" + sha256: "134b83167cc3c83199e8d75e5bcfde677fec843e7b2ca6b754a5b0b96d00d921" url: "https://pub.dev" source: hosted - version: "0.10.9+7" + version: "0.10.9+10" camera_avfoundation: dependency: transitive description: name: camera_avfoundation - sha256: "7d021e8cd30d9b71b8b92b4ad669e80af432d722d18d6aac338572754a786c15" + sha256: b5093a82537b64bb88d4244f8e00b5ba69e822a5994f47b31d11400e1db975e5 url: "https://pub.dev" source: hosted - version: "0.9.16" + version: "0.9.17+1" camera_platform_interface: dependency: transitive description: name: camera_platform_interface - sha256: a250314a48ea337b35909a4c9d5416a208d736dcb01d0b02c6af122be66660b0 + sha256: b3ede1f171532e0d83111fe0980b46d17f1aa9788a07a2fbed07366bbdbb9061 url: "https://pub.dev" source: hosted - version: "2.7.4" + version: "2.8.0" camera_web: dependency: transitive description: name: camera_web - sha256: "9e9aba2fbab77ce2472924196ff8ac4dd8f9126c4f9a3096171cd1d870d6b26c" + sha256: b9235ec0a2ce949daec546f1f3d86f05c3921ed31c7d9ab6b7c03214d152fc2d url: "https://pub.dev" source: hosted - version: "0.3.3" + version: "0.3.4" change_case: dependency: "direct main" description: @@ -459,10 +461,10 @@ packages: dependency: "direct main" description: name: expansion_tile_group - sha256: "6918433891481c7d98cbc604d7b4c93509986e8134d52940853301ad6fbff404" + sha256: "47615665d4e610dee0b6362de9e81003b56b150b5765ea5444a091762b5dc7d5" url: "https://pub.dev" source: hosted - version: "1.2.4" + version: "1.3.0" fast_immutable_collections: dependency: "direct main" description: @@ -528,10 +530,10 @@ packages: dependency: transitive description: name: flutter_cache_manager - sha256: "395d6b7831f21f3b989ebedbb785545932adb9afe2622c1ffacf7f4b53a7e544" + sha256: a77f77806a790eb9ba0118a5a3a936e81c4fea2b61533033b2b0c3d50bbde5ea url: "https://pub.dev" source: hosted - version: "3.3.2" + version: "3.4.0" flutter_chat_types: dependency: "direct main" description: @@ -606,10 +608,10 @@ packages: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: c6b0b4c05c458e1c01ad9bcc14041dd7b1f6783d487be4386f793f47a8a4d03e + sha256: "9d98bd47ef9d34e803d438f17fd32b116d31009f534a6fa5ce3a1167f189a6de" url: "https://pub.dev" source: hosted - version: "2.0.20" + version: "2.0.21" flutter_shaders: dependency: transitive description: @@ -622,10 +624,10 @@ packages: dependency: "direct main" description: name: flutter_slidable - sha256: "673403d2eeef1f9e8483bd6d8d92aae73b1d8bd71f382bc3930f699c731bc27c" + sha256: "2c5611c0b44e20d180e4342318e1bbc28b0a44ad2c442f5df16962606fd3e8e3" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.1.1" flutter_spinkit: dependency: "direct main" description: @@ -691,10 +693,10 @@ packages: dependency: "direct main" description: name: freezed_annotation - sha256: f54946fdb1fa7b01f780841937b1a80783a20b393485f3f6cdf336fd6f4705f2 + sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.4" frontend_server_client: dependency: transitive description: @@ -731,18 +733,18 @@ packages: dependency: "direct main" description: name: go_router - sha256: cdae1b9c8bd7efadcef6112e81c903662ef2ce105cbd220a04bbb7c3425b5554 + sha256: "39dd52168d6c59984454183148dc3a5776960c61083adfc708cc79a7b3ce1ba8" url: "https://pub.dev" source: hosted - version: "14.2.0" + version: "14.2.1" graphs: dependency: transitive description: name: graphs - sha256: aedc5a15e78fc65a6e23bcd927f24c64dd995062bcd1ca6eda65a3cff92a4d19 + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" hive: dependency: transitive description: @@ -763,10 +765,10 @@ packages: dependency: transitive description: name: http - sha256: "761a297c042deedc1ffbb156d6e2af13886bb305c2a343a4d972504cd67dd938" + sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" http_multi_server: dependency: transitive description: @@ -947,10 +949,10 @@ packages: dependency: transitive description: name: octo_image - sha256: "45b40f99622f11901238e18d48f5f12ea36426d8eced9f4cbf58479c7aa2430d" + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.0" package_config: dependency: transitive description: @@ -963,18 +965,18 @@ packages: dependency: "direct main" description: name: package_info_plus - sha256: b93d8b4d624b4ea19b0a5a208b2d6eff06004bc3ce74c06040b120eeadd00ce0 + sha256: "4de6c36df77ffbcef0a5aefe04669d33f2d18397fea228277b852a2d4e58e860" url: "https://pub.dev" source: hosted - version: "8.0.0" + version: "8.0.1" package_info_plus_platform_interface: dependency: transitive description: name: package_info_plus_platform_interface - sha256: f49918f3433a3146047372f9d4f1f847511f2acd5cd030e1f44fe5a50036b70e + sha256: ac1f4a4847f1ade8e6a87d1f39f5d7c67490738642e2542f559ec38c37489a66 url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "3.0.1" pasteboard: dependency: "direct main" description: @@ -1003,18 +1005,18 @@ packages: dependency: "direct main" description: name: path_provider - sha256: c9e7d3a4cd1410877472158bee69963a4579f78b68c65a2b7d40d1a7a88bb161 + sha256: fec0d61223fba3154d87759e3cc27fe2c8dc498f6386c6d6fc80d1afdd1bf378 url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.1.4" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: bca87b0165ffd7cdb9cad8edd22d18d2201e886d9a9f19b4fb3452ea7df3a72a + sha256: "490539678396d4c3c0b06efdaab75ae60675c3e0c66f72bc04c2e2c1e0e2abeb" url: "https://pub.dev" source: hosted - version: "2.2.6" + version: "2.2.9" path_provider_foundation: dependency: transitive description: @@ -1043,10 +1045,10 @@ packages: dependency: transitive description: name: path_provider_windows - sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170" + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.3.0" pdf: dependency: "direct main" description: @@ -1171,10 +1173,10 @@ packages: dependency: transitive description: name: qr - sha256: "64957a3930367bf97cc211a5af99551d630f2f4625e38af10edd6b19131b64b3" + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" qr_code_dart_scan: dependency: "direct main" description: @@ -1227,10 +1229,10 @@ packages: dependency: transitive description: name: rxdart - sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb" + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" url: "https://pub.dev" source: hosted - version: "0.27.7" + version: "0.28.0" screen_retriever: dependency: transitive description: @@ -1258,9 +1260,11 @@ packages: searchable_listview: dependency: "direct main" description: - path: "../Searchable-Listview" - relative: true - source: path + path: "." + ref: main + resolved-ref: db0f7b6f1baec0250ecba82f3d161bac1cf23d7d + url: "https://gitlab.com/veilid/Searchable-Listview.git" + source: git version: "2.14.1" share_plus: dependency: "direct main" @@ -1282,58 +1286,58 @@ packages: dependency: "direct main" description: name: shared_preferences - sha256: d3bbe5553a986e83980916ded2f0b435ef2e1893dfaa29d5a7a790d0eca12180 + sha256: c272f9cabca5a81adc9b0894381e9c1def363e980f960fa903c604c471b22f68 url: "https://pub.dev" source: hosted - version: "2.2.3" + version: "2.3.1" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "93d0ec9dd902d85f326068e6a899487d1f65ffcd5798721a95330b26c8131577" + sha256: "041be4d9d2dc6079cf342bc8b761b03787e3b71192d658220a56cac9c04a0294" url: "https://pub.dev" source: hosted - version: "2.2.3" + version: "2.3.0" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "0a8a893bf4fd1152f93fec03a415d11c27c74454d96e2318a7ac38dd18683ab7" + sha256: "671e7a931f55a08aa45be2a13fe7247f2a41237897df434b30d2012388191833" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.5.0" shared_preferences_linux: dependency: transitive description: name: shared_preferences_linux - sha256: "9f2cbcf46d4270ea8be39fa156d86379077c8a5228d9dfdb1164ae0bb93f1faa" + sha256: "2ba0510d3017f91655b7543e9ee46d48619de2a2af38e5c790423f7007c7ccc1" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.4.0" shared_preferences_platform_interface: dependency: transitive description: name: shared_preferences_platform_interface - sha256: "22e2ecac9419b4246d7c22bfbbda589e3acf5c0351137d87dd2939d984d37c3b" + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.4.1" shared_preferences_web: dependency: transitive description: name: shared_preferences_web - sha256: "9aee1089b36bd2aafe06582b7d7817fd317ef05fc30e6ba14bff247d0933042a" + sha256: "3a293170d4d9403c3254ee05b84e62e8a9b3c5808ebd17de6a33fe9ea6457936" url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.4.0" shared_preferences_windows: dependency: transitive description: name: shared_preferences_windows - sha256: "841ad54f3c8381c480d0c9b508b89a34036f512482c407e6df7a9c4aa2ef8f59" + sha256: "398084b47b7f92110683cac45c6dc4aae853db47e470e5ddcd52cab7f7196ab2" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.4.0" shelf: dependency: transitive description: @@ -1496,10 +1500,10 @@ packages: dependency: transitive description: name: string_scanner - sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" synchronized: dependency: transitive description: @@ -1592,18 +1596,18 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: ceb2625f0c24ade6ef6778d1de0b2e44f2db71fded235eb52295247feba8c5cf + sha256: "94d8ad05f44c6d4e2ffe5567ab4d741b82d62e3c8e288cc1fcea45965edf47c9" url: "https://pub.dev" source: hosted - version: "6.3.3" + version: "6.3.8" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "7068716403343f6ba4969b4173cbf3b84fc768042124bc2c011e5d782b24fe89" + sha256: e43b677296fadce447e987a2f519dcf5f6d1e527dc35d01ffab4fff5b8a7063e url: "https://pub.dev" source: hosted - version: "6.3.0" + version: "6.3.1" url_launcher_linux: dependency: transitive description: @@ -1640,18 +1644,18 @@ packages: dependency: transitive description: name: url_launcher_windows - sha256: ecf9725510600aa2bb6d7ddabe16357691b6d2805f66216a97d1b881e21beff7 + sha256: "49c10f879746271804767cb45551ec5592cdab00ee105c06dddde1a98f73b185" url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.1.2" uuid: dependency: "direct main" description: name: uuid - sha256: "814e9e88f21a176ae1359149021870e87f7cddaf633ab678a5d2b0bff7fd1ba8" + sha256: "83d37c7ad7aaf9aa8e275490669535c8080377cfa7a7004c24dfac53afffaa90" url: "https://pub.dev" source: hosted - version: "4.4.0" + version: "4.4.2" value_layout_builder: dependency: transitive description: @@ -1734,26 +1738,26 @@ packages: dependency: transitive description: name: web_socket - sha256: "24301d8c293ce6fe327ffe6f59d8fd8834735f0ec36e4fd383ec7ff8a64aa078" + sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" url: "https://pub.dev" source: hosted - version: "0.1.5" + version: "0.1.6" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: a2d56211ee4d35d9b344d9d4ce60f362e4f5d1aafb988302906bd732bc731276 + sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f" url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "3.0.1" win32: dependency: transitive description: name: win32 - sha256: a79dbe579cb51ecd6d30b17e0cae4e0ea15e2c0e66f69ad4198f22a6789e94f4 + sha256: "015002c060f1ae9f41a818f2d5640389cc05283e368be19dc8d77cecb43c40c9" url: "https://pub.dev" source: hosted - version: "5.5.1" + version: "5.5.3" window_manager: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 2f38215..9777813 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,12 +14,12 @@ dependencies: animated_theme_switcher: ^2.0.10 ansicolor: ^2.0.2 archive: ^3.6.1 - async_tools: ^0.1.4 + async_tools: ^0.1.5 awesome_extensions: ^2.0.16 badges: ^3.1.2 basic_utils: ^5.7.0 bloc: ^8.1.4 - bloc_advanced_tools: ^0.1.4 + bloc_advanced_tools: ^0.1.5 blurry_modal_progress_hud: ^1.1.1 change_case: ^2.1.0 charcode: ^1.3.1 @@ -111,13 +111,13 @@ dependencies: xterm: ^4.0.0 zxing2: ^0.2.3 -dependency_overrides: - async_tools: - path: ../dart_async_tools - bloc_advanced_tools: - path: ../bloc_advanced_tools - searchable_listview: - path: ../Searchable-Listview +# dependency_overrides: +# async_tools: +# path: ../dart_async_tools +# bloc_advanced_tools: +# path: ../bloc_advanced_tools +# searchable_listview: +# path: ../Searchable-Listview # flutter_chat_ui: # path: ../flutter_chat_ui