diff --git a/assets/i18n/en.json b/assets/i18n/en.json index 61bb802..9192851 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -158,9 +158,8 @@ "away": "Away" }, "add_contact_sheet": { - "new_contact": "New Contact", + "add_contact": "Add Contact", "create_invite": "Create\nInvitation", - "receive_invite": "Receive\nInvitation", "scan_invite": "Scan\nInvitation", "paste_invite": "Paste\nInvitation" }, diff --git a/lib/account_manager/cubits/active_local_account_cubit.dart b/lib/account_manager/cubits/active_local_account_cubit.dart index 58a9cb8..8856848 100644 --- a/lib/account_manager/cubits/active_local_account_cubit.dart +++ b/lib/account_manager/cubits/active_local_account_cubit.dart @@ -14,7 +14,6 @@ class ActiveLocalAccountCubit extends Cubit { switch (change) { case AccountRepositoryChange.activeLocalAccount: emit(_accountRepository.getActiveLocalAccount()); - break; // Ignore these case AccountRepositoryChange.localAccounts: case AccountRepositoryChange.userLogins: diff --git a/lib/account_manager/models/per_account_collection_state/per_account_collection_state.dart b/lib/account_manager/models/per_account_collection_state/per_account_collection_state.dart index 7fc8f0d..9e0a6f0 100644 --- a/lib/account_manager/models/per_account_collection_state/per_account_collection_state.dart +++ b/lib/account_manager/models/per_account_collection_state/per_account_collection_state.dart @@ -32,6 +32,7 @@ class PerAccountCollectionState with _$PerAccountCollectionState { } extension PerAccountCollectionStateExt on PerAccountCollectionState { + // Returns if the account is ready and logged in bool get isReady => avAccountRecordState != null && avAccountRecordState!.isData && @@ -45,7 +46,11 @@ extension PerAccountCollectionStateExt on PerAccountCollectionState { activeConversationsBlocMapCubit != null && activeSingleContactChatBlocMapCubit != null; - Widget provide({required Widget child}) => MultiBlocProvider(providers: [ + /// If we have a selected account and it is ready and not locked, + /// this will provide the unlocked account's cubits to the context + Widget provideReady({required Widget child}) { + if (isReady) { + return MultiBlocProvider(providers: [ BlocProvider.value(value: accountInfoCubit!), BlocProvider.value(value: accountRecordCubit!), BlocProvider.value(value: contactInvitationListCubit!), @@ -56,4 +61,9 @@ extension PerAccountCollectionStateExt on PerAccountCollectionState { BlocProvider.value(value: activeConversationsBlocMapCubit!), BlocProvider.value(value: activeSingleContactChatBlocMapCubit!), ], child: child); + } else { + // Otherwise we just provide the child + return child; + } + } } diff --git a/lib/chat/views/chat_component_widget.dart b/lib/chat/views/chat_component_widget.dart index 8e43299..f41ba47 100644 --- a/lib/chat/views/chat_component_widget.dart +++ b/lib/chat/views/chat_component_widget.dart @@ -33,11 +33,6 @@ class ChatComponentWidget extends StatelessWidget { @override Widget build(BuildContext context) { - // final theme = Theme.of(context); - // final scale = theme.extension()!; - // final scaleConfig = theme.extension()!; - // final textTheme = theme.textTheme; - // Get the account info final accountInfo = context.watch().state; @@ -125,7 +120,7 @@ class ChatComponentWidget extends StatelessWidget { return Column( children: [ Container( - height: 48, + height: 40, decoration: BoxDecoration( color: scale.border, ), @@ -141,9 +136,10 @@ class ChatComponentWidget extends StatelessWidget { )), const Spacer(), IconButton( + iconSize: 24, icon: Icon(Icons.close, color: scale.borderText), onPressed: _onClose) - .paddingLTRB(16, 0, 16, 0) + .paddingLTRB(0, 0, 8, 0) ]), ), DecoratedBox( diff --git a/lib/chat/views/new_chat_bottom_sheet.dart b/lib/chat/views/new_chat_bottom_sheet.dart deleted file mode 100644 index 646a3ec..0000000 --- a/lib/chat/views/new_chat_bottom_sheet.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_translate/flutter_translate.dart'; - -import '../../theme/theme.dart'; - -Widget newChatBottomSheetBuilder( - BuildContext sheetContext, BuildContext context) { - //final theme = Theme.of(sheetContext); - //final scale = theme.extension()!; - - return KeyboardListener( - focusNode: FocusNode(), - onKeyEvent: (ke) { - if (ke.logicalKey == LogicalKeyboardKey.escape) { - Navigator.pop(sheetContext); - } - }, - child: styledBottomSheet( - context: context, - title: translate('add_chat_sheet.new_chat'), - child: SizedBox( - height: 160, - child: const Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Text( - 'Group and custom chat functionality is not available yet') - ]).paddingAll(16)))); -} diff --git a/lib/chat/views/no_conversation_widget.dart b/lib/chat/views/no_conversation_widget.dart index 13c4cc6..3269bea 100644 --- a/lib/chat/views/no_conversation_widget.dart +++ b/lib/chat/views/no_conversation_widget.dart @@ -1,8 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_translate/flutter_translate.dart'; -import '../../theme/models/scale_theme/scale_scheme.dart'; -import '../../theme/views/views.dart'; +import '../../theme/theme.dart'; class NoConversationWidget extends StatelessWidget { const NoConversationWidget({super.key}); diff --git a/lib/chat/views/views.dart b/lib/chat/views/views.dart index 7e8adce..9703643 100644 --- a/lib/chat/views/views.dart +++ b/lib/chat/views/views.dart @@ -1,4 +1,3 @@ export 'chat_component_widget.dart'; export 'empty_chat_widget.dart'; -export 'new_chat_bottom_sheet.dart'; export 'no_conversation_widget.dart'; diff --git a/lib/contact_invitation/views/contact_invitation_item_widget.dart b/lib/contact_invitation/views/contact_invitation_item_widget.dart index 779f962..544e5db 100644 --- a/lib/contact_invitation/views/contact_invitation_item_widget.dart +++ b/lib/contact_invitation/views/contact_invitation_item_widget.dart @@ -48,7 +48,7 @@ class ContactInvitationItemWidget extends StatelessWidget { key: ObjectKey(contactInvitationRecord), disabled: tileDisabled, selected: selected, - tileScale: ScaleKind.primary, + tileScale: ScaleKind.secondary, title: title, leading: const Icon(Icons.person_add), onTap: () async { diff --git a/lib/contacts/views/contacts_browser.dart b/lib/contacts/views/contacts_browser.dart index c5a6b22..74cd0b5 100644 --- a/lib/contacts/views/contacts_browser.dart +++ b/lib/contacts/views/contacts_browser.dart @@ -17,19 +17,25 @@ import 'contact_item_widget.dart'; import 'empty_contact_list_widget.dart'; enum ContactsBrowserElementKind { - invitation, contact, + invitation, } 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; + ContactsBrowserElement.invitation(proto.ContactInvitationRecord i) + : kind = ContactsBrowserElementKind.invitation, + contact = null, + invitation = i; + + String get sortKey => switch (kind) { + ContactsBrowserElementKind.contact => contact!.displayName, + ContactsBrowserElementKind.invitation => + invitation!.recipient + invitation!.message + }; final ContactsBrowserElementKind kind; final proto.ContactInvitationRecord? invitation; @@ -66,27 +72,25 @@ class ContactsBrowser extends StatefulWidget { class _ContactsBrowserState extends State with SingleTickerProviderStateMixin { - Widget buildInvitationBar(BuildContext context) { + Widget buildInvitationButton(BuildContext context) { final theme = Theme.of(context); - final textTheme = theme.textTheme; - final scale = theme.extension()!; + final scaleScheme = theme.extension()!; final scaleConfig = theme.extension()!; final menuIconColor = scaleConfig.preferBorders - ? scale.primaryScale.hoverBorder - : scale.primaryScale.hoverBorder; + ? scaleScheme.primaryScale.hoverBorder + : scaleScheme.primaryScale.hoverBorder; final menuBackgroundColor = scaleConfig.preferBorders - ? scale.primaryScale.elementBackground - : scale.primaryScale.elementBackground; + ? scaleScheme.primaryScale.activeElementBackground + : scaleScheme.primaryScale.activeElementBackground; - final menuBorderColor = scale.primaryScale.hoverBorder; + final menuBorderColor = scaleScheme.primaryScale.hoverBorder; final menuParams = StarMenuParameters( - shape: MenuShape.grid, - checkItemsScreenBoundaries: true, + shape: MenuShape.linear, centerOffset: const Offset(0, 64), - backgroundParams: - BackgroundParams(backgroundColor: theme.shadowColor.withAlpha(128)), + // backgroundParams: + // BackgroundParams(backgroundColor: theme.shadowColor.withAlpha(128)), boundaryBackground: BoundaryBackground( color: menuBackgroundColor, decoration: ShapeDecoration( @@ -99,89 +103,64 @@ class _ContactsBrowserState extends State 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( - onPressed: () async { - await CreateInvitationDialog.show(context); - }, - iconSize: 32, - icon: const Icon(Icons.contact_page), - color: menuIconColor, - ), - Text(translate('add_contact_sheet.create_invite'), - maxLines: 2, - textAlign: TextAlign.center, - style: textTheme.labelSmall!.copyWith(color: menuIconColor)) - ]), - 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: menuIconColor, - )), - Text(translate('add_contact_sheet.receive_invite'), + ElevatedButton makeMenuButton( + {required IconData iconData, + required String text, + required void Function()? onPressed}) => + ElevatedButton.icon( + onPressed: onPressed, + icon: Icon( + iconData, + size: 32, + ).paddingSTEB(0, 8, 0, 8), + label: Text( + text, maxLines: 2, textAlign: TextAlign.center, - style: textTheme.labelSmall!.copyWith(color: menuIconColor)) - ]), - ), - ]).paddingAll(16); + ).paddingSTEB(0, 8, 0, 8)); + + final inviteMenuItems = [ + makeMenuButton( + iconData: Icons.paste, + text: translate('add_contact_sheet.paste_invite'), + onPressed: () async { + _invitationMenuController.closeMenu!(); + await PasteInvitationDialog.show(context); + }), + makeMenuButton( + iconData: Icons.qr_code_scanner, + text: translate('add_contact_sheet.scan_invite'), + onPressed: () async { + _invitationMenuController.closeMenu!(); + await ScanInvitationDialog.show(context); + }).paddingLTRB(0, 0, 0, 8), + makeMenuButton( + iconData: Icons.contact_page, + text: translate('add_contact_sheet.create_invite'), + onPressed: () async { + _invitationMenuController.closeMenu!(); + await CreateInvitationDialog.show(context); + }).paddingLTRB(0, 0, 0, 8), + ]; + + return StarMenu( + items: inviteMenuItems, + onItemTapped: (_index, controller) { + controller.closeMenu!(); + }, + controller: _invitationMenuController, + params: menuParams, + child: IconButton( + onPressed: () {}, + iconSize: 24, + icon: Icon(Icons.person_add, color: menuIconColor), + tooltip: translate('add_contact_sheet.add_contact')), + ); } @override Widget build(BuildContext context) { final theme = Theme.of(context); - final textTheme = theme.textTheme; final scale = theme.extension()!; //final scaleConfig = theme.extension()!; @@ -196,100 +175,89 @@ class _ContactsBrowserState extends State 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(); - } + final initialList = []; if (contactList != null) { - expansionListData[ContactsBrowserElementKind.contact] = - contactList.toList().map(ContactsBrowserElement.contact).toList(); + initialList + .addAll(contactList.toList().map(ContactsBrowserElement.contact)); } + if (contactInvitationRecordList.isNotEmpty) { + initialList.addAll(contactInvitationRecordList + .toList() + .map(ContactsBrowserElement.invitation)); + } + + initialList.sort((a, b) => a.sortKey.compareTo(b.sortKey)); 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: false, - onDoubleTap: _onStartChat, - onTap: _onSelectContact, - onDelete: _onDeleteContact) - .paddingLTRB(0, 4, 0, 0); - case ContactsBrowserElementKind.invitation: - final invitation = element.invitation!; - return ContactInvitationItemWidget( - contactInvitationRecord: invitation, disabled: false) - .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) { + SearchableList( + initialList: initialList, + itemBuilder: (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); + return ContactItemWidget( + contact: contact, + selected: widget.selectedContactRecordKey == + contact.localConversationRecordKey.toVeilid(), + disabled: false, + onDoubleTap: _onStartChat, + onTap: _onSelectContact, + onDelete: _onDeleteContact) + .paddingLTRB(0, 4, 0, 0); case ContactsBrowserElementKind.invitation: final invitation = element.invitation!; - return invitation.message - .toLowerCase() - .contains(lowerValue) || - invitation.recipient.toLowerCase().contains(lowerValue); + return ContactInvitationItemWidget( + contactInvitationRecord: invitation, + disabled: false) + .paddingLTRB(0, 4, 0, 0); } - }).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() + }, + filter: (value) { + final lowerValue = value.toLowerCase(); + + final filtered = []; + for (final element in initialList) { + switch (element.kind) { + case ContactsBrowserElementKind.contact: + final contact = element.contact!; + if (contact.nickname.toLowerCase().contains(lowerValue) || + contact.profile.name + .toLowerCase() + .contains(lowerValue) || + contact.profile.pronouns + .toLowerCase() + .contains(lowerValue)) { + filtered.add(element); + } + case ContactsBrowserElementKind.invitation: + final invitation = element.invitation!; + if (invitation.message + .toLowerCase() + .contains(lowerValue) || + invitation.recipient + .toLowerCase() + .contains(lowerValue)) { + filtered.add(element); + } + } + } + return filtered; + }, + searchFieldHeight: 40, + listViewPadding: const EdgeInsets.fromLTRB(4, 0, 4, 4), + searchFieldPadding: const EdgeInsets.fromLTRB(4, 8, 4, 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')), + secondaryWidget: + buildInvitationButton(context).paddingLTRB(4, 0, 0, 0)) + .expanded() ]); } @@ -318,5 +286,5 @@ class _ContactsBrowserState extends State } //////////////////////////////////////////////////////////////////////////// - final _receiveInviteMenuController = StarMenuController(); + final _invitationMenuController = StarMenuController(); } diff --git a/lib/contacts/views/contacts_dialog.dart b/lib/contacts/views/contacts_dialog.dart deleted file mode 100644 index 721043e..0000000 --- a/lib/contacts/views/contacts_dialog.dart +++ /dev/null @@ -1,196 +0,0 @@ -import 'package:async_tools/async_tools.dart'; -import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_translate/flutter_translate.dart'; -import 'package:provider/provider.dart'; - -import '../../chat/chat.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 _kDoBackArrow = 'doBackArrow'; - -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 scale = theme.extension()!; - final appBarIconColor = scale.primaryScale.borderText; - - 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: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () { - singleFuture((this, _kDoBackArrow), () async { - final confirmed = await _onContactSelected(null); - if (!enableSplit && enableRight) { - } else { - if (confirmed) { - if (context.mounted) { - Navigator.pop(context); - } - } - } - }); - }, - ), - actions: [ - if (_selectedContact != null) - FittedBox( - fit: BoxFit.scaleDown, - child: - Column(mainAxisSize: MainAxisSize.min, children: [ - IconButton( - icon: const Icon(Icons.chat_bubble), - color: appBarIconColor, - tooltip: translate('contacts_dialog.new_chat'), - onPressed: () async { - await _onChatStarted(_selectedContact!); - }), - Text(translate('contacts_dialog.new_chat'), - style: theme.textTheme.labelSmall! - .copyWith(color: appBarIconColor)), - ])).paddingLTRB(8, 0, 8, 0), - if (enableSplit && _selectedContact != null) - FittedBox( - fit: BoxFit.scaleDown, - child: - Column(mainAxisSize: MainAxisSize.min, children: [ - IconButton( - icon: const Icon(Icons.close), - color: appBarIconColor, - tooltip: - translate('contacts_dialog.close_contact'), - onPressed: () async { - await _onContactSelected(null); - }), - Text(translate('contacts_dialog.close_contact'), - style: theme.textTheme.labelSmall! - .copyWith(color: appBarIconColor)), - ])).paddingLTRB(8, 0, 8, 0), - ]), - body: LayoutBuilder(builder: (context, constraint) { - final maxWidth = constraint.maxWidth; - - return ColoredBox( - color: scale.primaryScale.appBackground, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Offstage( - offstage: !enableLeft, - child: SizedBox( - width: enableLeft && !enableRight - ? maxWidth - : (maxWidth / 3).clamp(200, 500), - child: DecoratedBox( - decoration: BoxDecoration( - color: scale - .primaryScale.subtleBackground), - child: ContactsBrowser( - selectedContactRecordKey: _selectedContact - ?.localConversationRecordKey - .toVeilid(), - onContactSelected: _onContactSelected, - onStartChat: _onChatStarted, - ).paddingLTRB(8, 0, 8, 8)))), - if (enableRight && enableLeft) - Container( - constraints: const BoxConstraints( - minWidth: 1, maxWidth: 1), - color: scale.primaryScale.subtleBorder), - if (enableRight) - if (_selectedContact == null) - const NoContactWidget().expanded() - else - ContactDetailsWidget( - contact: _selectedContact!, - onModifiedState: _onModifiedState) - .paddingLTRB(16, 16, 16, 16) - .expanded(), - ])); - }))); - } - - void _onModifiedState(bool isModified) { - setState(() { - _isModified = isModified; - }); - } - - Future _onContactSelected(proto.Contact? contact) async { - if (contact != _selectedContact && _isModified) { - final ok = await showConfirmModal( - context: context, - title: translate('confirmation.discard_changes'), - text: translate('confirmation.are_you_sure_discard')); - if (!ok) { - return false; - } - } - setState(() { - _selectedContact = contact; - _isModified = false; - }); - return true; - } - - Future _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; - bool _isModified = false; -} diff --git a/lib/contacts/views/contacts_page.dart b/lib/contacts/views/contacts_page.dart new file mode 100644 index 0000000..28217e6 --- /dev/null +++ b/lib/contacts/views/contacts_page.dart @@ -0,0 +1,171 @@ +import 'package:async_tools/async_tools.dart'; +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:provider/provider.dart'; + +import '../../chat/chat.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 _kDoBackArrow = 'doBackArrow'; + +class ContactsPage extends StatefulWidget { + const ContactsPage({super.key}); + + @override + State createState() => _ContactsPageState(); +} + +class _ContactsPageState extends State { + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final appBarIconColor = scale.primaryScale.borderText; + + final enableSplit = !isMobileSize(context); + final enableLeft = enableSplit || _selectedContact == null; + final enableRight = enableSplit || _selectedContact != null; + + return StyledScaffold( + appBar: DefaultAppBar( + title: Text(!enableSplit && enableRight + ? translate('contacts_dialog.edit_contact') + : translate('contacts_dialog.contacts')), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + singleFuture((this, _kDoBackArrow), () async { + final confirmed = await _onContactSelected(null); + if (!enableSplit && enableRight) { + } else { + if (confirmed) { + if (context.mounted) { + Navigator.pop(context); + } + } + } + }); + }, + ), + actions: [ + if (_selectedContact != null) + FittedBox( + fit: BoxFit.scaleDown, + child: Column(mainAxisSize: MainAxisSize.min, children: [ + IconButton( + icon: const Icon(Icons.chat_bubble), + color: appBarIconColor, + tooltip: translate('contacts_dialog.new_chat'), + onPressed: () async { + await _onChatStarted(_selectedContact!); + }), + Text(translate('contacts_dialog.new_chat'), + style: theme.textTheme.labelSmall! + .copyWith(color: appBarIconColor)), + ])).paddingLTRB(8, 0, 8, 0), + if (enableSplit && _selectedContact != null) + FittedBox( + fit: BoxFit.scaleDown, + child: Column(mainAxisSize: MainAxisSize.min, children: [ + IconButton( + icon: const Icon(Icons.close), + color: appBarIconColor, + tooltip: translate('contacts_dialog.close_contact'), + onPressed: () async { + await _onContactSelected(null); + }), + Text(translate('contacts_dialog.close_contact'), + style: theme.textTheme.labelSmall! + .copyWith(color: appBarIconColor)), + ])).paddingLTRB(8, 0, 8, 0), + ]), + body: LayoutBuilder(builder: (context, constraint) { + final maxWidth = constraint.maxWidth; + + return ColoredBox( + color: scale.primaryScale.appBackground, + child: + Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Offstage( + offstage: !enableLeft, + child: SizedBox( + width: enableLeft && !enableRight + ? maxWidth + : (maxWidth / 3).clamp(200, 500), + child: DecoratedBox( + decoration: BoxDecoration( + color: scale.primaryScale.subtleBackground), + child: ContactsBrowser( + selectedContactRecordKey: _selectedContact + ?.localConversationRecordKey + .toVeilid(), + onContactSelected: _onContactSelected, + onStartChat: _onChatStarted, + ).paddingLTRB(8, 0, 8, 8)))), + if (enableRight && enableLeft) + Container( + constraints: + const BoxConstraints(minWidth: 1, maxWidth: 1), + color: scale.primaryScale.subtleBorder), + if (enableRight) + if (_selectedContact == null) + const NoContactWidget().expanded() + else + ContactDetailsWidget( + contact: _selectedContact!, + onModifiedState: _onModifiedState) + .paddingLTRB(16, 16, 16, 16) + .expanded(), + ])); + })); + } + + void _onModifiedState(bool isModified) { + setState(() { + _isModified = isModified; + }); + } + + Future _onContactSelected(proto.Contact? contact) async { + if (contact != _selectedContact && _isModified) { + final ok = await showConfirmModal( + context: context, + title: translate('confirmation.discard_changes'), + text: translate('confirmation.are_you_sure_discard')); + if (!ok) { + return false; + } + } + setState(() { + _selectedContact = contact; + _isModified = false; + }); + return true; + } + + Future _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; + bool _isModified = false; +} diff --git a/lib/contacts/views/views.dart b/lib/contacts/views/views.dart index 55b96d0..b74aff3 100644 --- a/lib/contacts/views/views.dart +++ b/lib/contacts/views/views.dart @@ -2,7 +2,7 @@ export 'availability_widget.dart'; export 'contact_details_widget.dart'; export 'contact_item_widget.dart'; export 'contacts_browser.dart'; -export 'contacts_dialog.dart'; +export 'contacts_page.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 6aa817f..779609c 100644 --- a/lib/layout/home/drawer_menu/drawer_menu.dart +++ b/lib/layout/home/drawer_menu/drawer_menu.dart @@ -84,8 +84,9 @@ class _DrawerMenuState extends State { hoverBorder = border; activeBorder = border; } else { - background = - selected ? scale.activeElementBackground : scale.elementBackground; + background = selected + ? scale.elementBackground + : scale.elementBackground.withAlpha(128); hoverBackground = scale.hoverElementBackground; activeBackground = scale.activeElementBackground; border = loggedIn ? scale.border : scale.subtleBorder; @@ -132,9 +133,16 @@ class _DrawerMenuState extends State { callback: callback, footerButtonIcon: loggedIn ? Icons.edit_outlined : null, footerCallback: footerCallback, - footerButtonIconColor: border, - footerButtonIconHoverColor: hoverBackground, - footerButtonIconFocusColor: activeBackground, + footerButtonIconColor: + scaleConfig.preferBorders ? scale.border : scale.borderText, + footerButtonIconHoverColor: + (scaleConfig.preferBorders || scaleConfig.useVisualIndicators) + ? null + : hoverBorder, + footerButtonIconFocusColor: + (scaleConfig.preferBorders || scaleConfig.useVisualIndicators) + ? null + : activeBorder, minHeight: 48, )); } diff --git a/lib/layout/home/drawer_menu/menu_item_widget.dart b/lib/layout/home/drawer_menu/menu_item_widget.dart index a786010..1255458 100644 --- a/lib/layout/home/drawer_menu/menu_item_widget.dart +++ b/lib/layout/home/drawer_menu/menu_item_widget.dart @@ -39,6 +39,8 @@ class MenuItemWidget extends StatelessWidget { } return backgroundColor; }), + overlayColor: + WidgetStateProperty.resolveWith((states) => backgroundHoverColor), side: WidgetStateBorderSide.resolveWith((states) { if (states.contains(WidgetState.hovered)) { return borderColor != null diff --git a/lib/layout/home/home_account_ready.dart b/lib/layout/home/home_account_ready.dart index 9109b78..5f90c9f 100644 --- a/lib/layout/home/home_account_ready.dart +++ b/lib/layout/home/home_account_ready.dart @@ -89,7 +89,11 @@ class _HomeAccountReadyState extends State { )), tooltip: translate('menu.contacts_tooltip'), onPressed: () async { - await ContactsDialog.show(context); + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const ContactsPage(), + ), + ); }); }); @@ -139,10 +143,7 @@ class _HomeAccountReadyState extends State { @override Widget build(BuildContext context) { - final isLarge = responsiveVisibility( - context: context, - phone: false, - ); + final isSmallScreen = isMobileSize(context); final theme = Theme.of(context); final scaleScheme = theme.extension()!; @@ -159,14 +160,7 @@ class _HomeAccountReadyState extends State { late final bool visibleRight; late final double leftWidth; late final double rightWidth; - if (isLarge) { - visibleLeft = true; - visibleRight = true; - leftWidth = leftColumnSize; - rightWidth = constraints.maxWidth - - leftColumnSize - - (scaleConfig.useVisualIndicators ? 2 : 0); - } else { + if (isSmallScreen) { if (hasActiveChat) { visibleLeft = false; visibleRight = true; @@ -178,6 +172,13 @@ class _HomeAccountReadyState extends State { leftWidth = constraints.maxWidth; rightWidth = 400; // whatever } + } else { + visibleLeft = true; + visibleRight = true; + leftWidth = leftColumnSize; + rightWidth = constraints.maxWidth - + leftColumnSize - + (scaleConfig.useVisualIndicators ? 2 : 0); } return Row(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ diff --git a/lib/layout/home/home_screen.dart b/lib/layout/home/home_screen.dart index 63e2f19..a534415 100644 --- a/lib/layout/home/home_screen.dart +++ b/lib/layout/home/home_screen.dart @@ -136,15 +136,11 @@ class HomeScreenState extends State } // Re-export all ready blocs to the account display subtree - return perAccountCollectionState.provide( - child: Navigator( - onPopPage: (route, result) { - if (!route.didPop(result)) { - return false; - } - return true; - }, - pages: const [MaterialPage(child: HomeAccountReady())])); + final pages = >[ + const MaterialPage(child: HomeAccountReady()) + ]; + return perAccountCollectionState.provideReady( + child: Navigator(onDidRemovePage: pages.remove, pages: pages)); } } diff --git a/lib/router/cubits/router_cubit.dart b/lib/router/cubits/router_cubit.dart index 48bf95f..6684c45 100644 --- a/lib/router/cubits/router_cubit.dart +++ b/lib/router/cubits/router_cubit.dart @@ -65,29 +65,49 @@ class RouterCubit extends Cubit { ), GoRoute( path: '/edit_account', + redirect: (_, state) { + final extra = state.extra; + if (extra == null || + extra is! List || + extra[0] is! TypedKey) { + return '/'; + } + return null; + }, builder: (context, state) { - final extra = state.extra! as List; + final extra = state.extra! as List; return EditAccountPage( - superIdentityRecordKey: extra[0]! as TypedKey, - initialValue: extra[1]! as AccountSpec, - accountRecord: extra[2]! as OwnedDHTRecordPointer, + superIdentityRecordKey: extra[0] as TypedKey, + initialValue: extra[1] as AccountSpec, + accountRecord: extra[2] as OwnedDHTRecordPointer, ); }, ), GoRoute( - path: '/new_account', - builder: (context, state) => const NewAccountPage(), - ), - GoRoute( - path: '/new_account/recovery_key', - builder: (context, state) { - final extra = state.extra! as List; + path: '/new_account', + builder: (context, state) => const NewAccountPage(), + routes: [ + GoRoute( + path: 'recovery_key', + redirect: (_, state) { + final extra = state.extra; + if (extra == null || + extra is! List || + extra[0] is! WritableSuperIdentity || + extra[1] is! String) { + return '/'; + } + return null; + }, + builder: (context, state) { + final extra = state.extra! as List; - return ShowRecoveryKeyPage( - writableSuperIdentity: - extra[0]! as WritableSuperIdentity, - name: extra[1]! as String); - }), + return ShowRecoveryKeyPage( + writableSuperIdentity: + extra[0] as WritableSuperIdentity, + name: extra[1] as String); + }), + ]), GoRoute( path: '/settings', builder: (context, state) => const SettingsPage(), diff --git a/lib/theme/models/chat_theme.dart b/lib/theme/models/chat_theme.dart index 5552979..b650e17 100644 --- a/lib/theme/models/chat_theme.dart +++ b/lib/theme/models/chat_theme.dart @@ -16,7 +16,7 @@ ChatTheme makeChatTheme( : scale.secondaryScale.calloutBackground, backgroundColor: scale.grayScale.appBackground.withAlpha(scaleConfig.wallpaperAlpha), - messageBorderRadius: scaleConfig.borderRadiusScale * 16, + messageBorderRadius: scaleConfig.borderRadiusScale * 12, bubbleBorderSide: scaleConfig.preferBorders ? BorderSide( color: scale.primaryScale.calloutBackground, @@ -37,7 +37,7 @@ ChatTheme makeChatTheme( filled: !scaleConfig.preferBorders, fillColor: scale.primaryScale.subtleBackground, isDense: true, - contentPadding: const EdgeInsets.fromLTRB(8, 12, 8, 12), + contentPadding: const EdgeInsets.fromLTRB(8, 8, 8, 8), disabledBorder: OutlineInputBorder( borderSide: scaleConfig.preferBorders ? BorderSide(color: scale.grayScale.border, width: 2) @@ -65,10 +65,12 @@ ChatTheme makeChatTheme( color: scaleConfig.preferBorders ? scale.primaryScale.elementBackground : scale.primaryScale.border), - inputPadding: const EdgeInsets.all(12), + inputPadding: const EdgeInsets.all(6), inputTextColor: !scaleConfig.preferBorders ? scale.primaryScale.appText : scale.primaryScale.border, + messageInsetsHorizontal: 12, + messageInsetsVertical: 8, attachmentButtonIcon: const Icon(Icons.attach_file), sentMessageBodyTextStyle: textTheme.bodyLarge!.copyWith( color: scaleConfig.preferBorders diff --git a/lib/theme/models/contrast_generator.dart b/lib/theme/models/contrast_generator.dart index 861b052..314e28a 100644 --- a/lib/theme/models/contrast_generator.dart +++ b/lib/theme/models/contrast_generator.dart @@ -263,6 +263,36 @@ ThemeData contrastGenerator({ final baseThemeData = scaleTheme.toThemeData(brightness); + WidgetStateProperty elementBorderWidgetStateProperty() => + WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return BorderSide(color: scheme.grayScale.border.withAlpha(0x7F)); + } else if (states.contains(WidgetState.pressed)) { + return BorderSide( + color: scheme.primaryScale.border, + strokeAlign: BorderSide.strokeAlignOutside); + } else if (states.contains(WidgetState.hovered)) { + return BorderSide(color: scheme.primaryScale.hoverBorder, width: 2); + } else if (states.contains(WidgetState.focused)) { + return BorderSide(color: scheme.primaryScale.hoverBorder, width: 2); + } + return BorderSide(color: scheme.primaryScale.border); + }); + + WidgetStateProperty elementBackgroundWidgetStateProperty() => + WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return scheme.grayScale.elementBackground; + } else if (states.contains(WidgetState.pressed)) { + return scheme.primaryScale.activeElementBackground; + } else if (states.contains(WidgetState.hovered)) { + return scheme.primaryScale.hoverElementBackground; + } else if (states.contains(WidgetState.focused)) { + return scheme.primaryScale.activeElementBackground; + } + return scheme.primaryScale.elementBackground; + }); + final elevatedButtonTheme = ElevatedButtonThemeData( style: ElevatedButton.styleFrom( backgroundColor: scheme.primaryScale.elementBackground, @@ -274,20 +304,9 @@ ThemeData contrastGenerator({ side: BorderSide(color: scheme.primaryScale.border), borderRadius: BorderRadius.circular(8 * scaleConfig.borderRadiusScale))) - .copyWith(side: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) { - return BorderSide(color: scheme.grayScale.border.withAlpha(0x7F)); - } else if (states.contains(WidgetState.pressed)) { - return BorderSide( - color: scheme.primaryScale.border, - strokeAlign: BorderSide.strokeAlignOutside); - } else if (states.contains(WidgetState.hovered)) { - return BorderSide(color: scheme.primaryScale.hoverBorder); - } else if (states.contains(WidgetState.focused)) { - return BorderSide(color: scheme.primaryScale.hoverBorder, width: 2); - } - return BorderSide(color: scheme.primaryScale.border); - }))); + .copyWith( + side: elementBorderWidgetStateProperty(), + backgroundColor: elementBackgroundWidgetStateProperty())); final themeData = baseThemeData.copyWith( // chipTheme: baseThemeData.chipTheme.copyWith( diff --git a/lib/theme/models/scale_theme/scale_scheme.dart b/lib/theme/models/scale_theme/scale_scheme.dart index 6bdae25..dd88b4f 100644 --- a/lib/theme/models/scale_theme/scale_scheme.dart +++ b/lib/theme/models/scale_theme/scale_scheme.dart @@ -97,7 +97,7 @@ class ScaleScheme extends ThemeExtension { onSurfaceVariant: secondaryScale.appText, outline: primaryScale.border, outlineVariant: secondaryScale.border, - shadow: primaryScale.appBackground.darken(60), + shadow: primaryScale.primary.darken(60), //scrim: primaryScale.background, // inverseSurface: primaryScale.subtleText, // onInverseSurface: primaryScale.subtleBackground, diff --git a/lib/theme/models/scale_theme/scale_theme.dart b/lib/theme/models/scale_theme/scale_theme.dart index 4bfc438..e787c0e 100644 --- a/lib/theme/models/scale_theme/scale_theme.dart +++ b/lib/theme/models/scale_theme/scale_theme.dart @@ -43,6 +43,50 @@ class ScaleTheme extends ThemeExtension { config: config.lerp(other.config, t)); } + WidgetStateProperty elementBorderWidgetStateProperty() => + WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return BorderSide( + color: scheme.grayScale.border.withAlpha(0x7F), + strokeAlign: BorderSide.strokeAlignOutside); + } else if (states.contains(WidgetState.pressed)) { + return BorderSide( + color: scheme.primaryScale.border, + ); + } else if (states.contains(WidgetState.hovered)) { + return BorderSide( + color: scheme.primaryScale.hoverBorder, + strokeAlign: BorderSide.strokeAlignOutside); + } else if (states.contains(WidgetState.focused)) { + return BorderSide( + color: scheme.primaryScale.hoverBorder, + width: 2, + strokeAlign: BorderSide.strokeAlignOutside); + } + return BorderSide( + color: scheme.primaryScale.border, + strokeAlign: BorderSide.strokeAlignOutside); + }); + + WidgetStateProperty elementColorWidgetStateProperty() => + WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return scheme.grayScale.primary.withAlpha(0x7F); + } else if (states.contains(WidgetState.pressed)) { + return scheme.primaryScale.borderText; + } else if (states.contains(WidgetState.hovered)) { + return scheme.primaryScale.borderText; + } else if (states.contains(WidgetState.focused)) { + return scheme.primaryScale.borderText; + } + return Color.lerp( + scheme.primaryScale.borderText, scheme.primaryScale.primary, 0.25); + }); + + // WidgetStateProperty elementBackgroundWidgetStateProperty() { + // return null; + // } + ThemeData toThemeData(Brightness brightness) { final colorScheme = scheme.toColorScheme(brightness); @@ -51,8 +95,9 @@ class ScaleTheme extends ThemeExtension { final elevatedButtonTheme = ElevatedButtonThemeData( style: ElevatedButton.styleFrom( + elevation: 0, + textStyle: textTheme.labelSmall, backgroundColor: scheme.primaryScale.elementBackground, - foregroundColor: scheme.primaryScale.appText, disabledBackgroundColor: scheme.grayScale.elementBackground.withAlpha(0x7F), disabledForegroundColor: @@ -61,20 +106,11 @@ class ScaleTheme extends ThemeExtension { side: BorderSide(color: scheme.primaryScale.border), borderRadius: BorderRadius.circular(8 * config.borderRadiusScale))) - .copyWith(side: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) { - return BorderSide(color: scheme.grayScale.border.withAlpha(0x7F)); - } else if (states.contains(WidgetState.pressed)) { - return BorderSide( - color: scheme.primaryScale.border, - strokeAlign: BorderSide.strokeAlignOutside); - } else if (states.contains(WidgetState.hovered)) { - return BorderSide(color: scheme.primaryScale.hoverBorder); - } else if (states.contains(WidgetState.focused)) { - return BorderSide(color: scheme.primaryScale.hoverBorder, width: 2); - } - return BorderSide(color: scheme.primaryScale.border); - }))); + .copyWith( + foregroundColor: elementColorWidgetStateProperty(), + side: elementBorderWidgetStateProperty(), + iconColor: elementColorWidgetStateProperty(), + )); final themeData = baseThemeData.copyWith( scrollbarTheme: baseThemeData.scrollbarTheme.copyWith( diff --git a/lib/theme/views/pop_control.dart b/lib/theme/views/pop_control.dart index 29dc562..deaf785 100644 --- a/lib/theme/views/pop_control.dart +++ b/lib/theme/views/pop_control.dart @@ -29,11 +29,12 @@ class PopControl extends StatelessWidget { return PopScope( canPop: false, - onPopInvoked: (didPop) { + onPopInvokedWithResult: (didPop, _) { if (didPop) { return; } _doDismiss(navigator); + return; }, child: child); } diff --git a/lib/theme/views/styled_scaffold.dart b/lib/theme/views/styled_scaffold.dart index 9d02fab..4fc803f 100644 --- a/lib/theme/views/styled_scaffold.dart +++ b/lib/theme/views/styled_scaffold.dart @@ -1,4 +1,3 @@ -import 'package:awesome_extensions/awesome_extensions.dart'; import 'package:flutter/material.dart'; import '../theme.dart'; @@ -13,7 +12,7 @@ class StyledScaffold extends StatelessWidget { final scaleConfig = theme.extension()!; final scale = scaleScheme.scale(ScaleKind.primary); - final enableBorder = !isMobileSize(context); + const enableBorder = false; //!isMobileSize(context); var scaffold = clipBorder( clipEnabled: enableBorder, @@ -28,7 +27,7 @@ class StyledScaffold extends StatelessWidget { return GestureDetector( onTap: () => FocusManager.instance.primaryFocus?.unfocus(), - child: scaffold.paddingAll(enableBorder ? 32 : 0)); + child: scaffold /*.paddingAll(enableBorder ? 32 : 0) */); } //////////////////////////////////////////////////////////////////////////// diff --git a/lib/tools/window_control.dart b/lib/tools/window_control.dart index 2e9a21f..32c05ff 100644 --- a/lib/tools/window_control.dart +++ b/lib/tools/window_control.dart @@ -7,7 +7,6 @@ import 'package:flutter/services.dart'; import 'package:window_manager/window_manager.dart'; import '../theme/views/responsive.dart'; -import 'tools.dart'; export 'package:window_manager/window_manager.dart' show TitleBarStyle; diff --git a/pubspec.lock b/pubspec.lock index 529ae6c..fa60f63 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1310,10 +1310,10 @@ packages: description: path: "." ref: main - resolved-ref: db0f7b6f1baec0250ecba82f3d161bac1cf23d7d + resolved-ref: f367c2f713dcc0c965a4f7af5952d94b2f699998 url: "https://gitlab.com/veilid/Searchable-Listview.git" source: git - version: "2.14.1" + version: "2.16.0" share_plus: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 5617c1c..5206f5d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -111,13 +111,13 @@ dependencies: xterm: ^4.0.0 zxing2: ^0.2.3 -#dependency_overrides: +# dependency_overrides: # async_tools: # path: ../dart_async_tools # bloc_advanced_tools: # path: ../bloc_advanced_tools -# searchable_listview: -# path: ../Searchable-Listview +# searchable_listview: +# path: ../Searchable-Listview # flutter_chat_ui: # path: ../flutter_chat_ui