diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 0c625e74..05e8d861 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -2550,5 +2550,14 @@ "editTodo": "Edit todo", "pleaseAddATitle": "Please add a title", "todoListChangedError": "Oops... The todo list has been changed while you edited it.", - "todosUnencrypted": "Please notice that todos are visible by everyone in the chat and are not end to end encrypted." + "todosUnencrypted": "Please notice that todos are visible by everyone in the chat and are not end to end encrypted.", + "yourGlobalUserIdIs": "Your global user-ID is: ", + "noUsersFoundWithQuery": "Unfortunately no user could be found with \"{query}\". Please check whether you made a typo.", + "@noUsersFoundWithQuery": { + "type": "text", + "placeholders": { + "query": {} + } + }, + "searchChatsRooms": "Search for #chats, @users..." } diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart index 75fa52a1..6cac22ed 100644 --- a/lib/config/app_config.dart +++ b/lib/config/app_config.dart @@ -25,6 +25,8 @@ abstract class AppConfig { 'https://github.com/krille-chan/fluffychat/wiki/Push-Notifications-without-Google-Services'; static const String encryptionTutorial = 'https://github.com/krille-chan/fluffychat/wiki/How-to-use-end-to-end-encryption-in-FluffyChat'; + static const String startChatTutorial = + 'https://github.com/krille-chan/fluffychat/wiki/How-to-Find-Users-in-FluffyChat'; static const String appId = 'im.fluffychat.FluffyChat'; static const String appOpenUrlScheme = 'im.fluffychat'; static String _webBaseUrl = 'https://fluffychat.im/web'; diff --git a/lib/pages/chat_list/chat_list_header.dart b/lib/pages/chat_list/chat_list_header.dart index 1329c8a8..177aec5f 100644 --- a/lib/pages/chat_list/chat_list_header.dart +++ b/lib/pages/chat_list/chat_list_header.dart @@ -54,7 +54,7 @@ class ChatListHeader extends StatelessWidget implements PreferredSizeWidget { borderSide: BorderSide.none, borderRadius: BorderRadius.circular(99), ), - hintText: L10n.of(context)!.search, + hintText: L10n.of(context)!.searchChatsRooms, floatingLabelBehavior: FloatingLabelBehavior.never, prefixIcon: controller.isSearchMode ? IconButton( diff --git a/lib/pages/new_private_chat/new_private_chat.dart b/lib/pages/new_private_chat/new_private_chat.dart index 70bf7517..0331a0c4 100644 --- a/lib/pages/new_private_chat/new_private_chat.dart +++ b/lib/pages/new_private_chat/new_private_chat.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -7,6 +9,7 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/pages/new_private_chat/new_private_chat_view.dart'; import 'package:fluffychat/pages/new_private_chat/qr_scanner_modal.dart'; +import 'package:fluffychat/pages/user_bottom_sheet/user_bottom_sheet.dart'; import 'package:fluffychat/utils/adaptive_bottom_sheet.dart'; import 'package:fluffychat/utils/fluffy_share.dart'; import 'package:fluffychat/utils/platform_infos.dart'; @@ -23,38 +26,30 @@ class NewPrivateChat extends StatefulWidget { class NewPrivateChatController extends State { final TextEditingController controller = TextEditingController(); final FocusNode textFieldFocus = FocusNode(); - final formKey = GlobalKey(); - bool loading = false; - // remove leading matrix.to from text field in order to simplify pasting - final List removeMatrixToFormatters = [ - FilteringTextInputFormatter.deny(NewPrivateChatController.prefix), - FilteringTextInputFormatter.deny(NewPrivateChatController.prefixNoProtocol), - ]; + Future? searchResponse; - static const Set supportedSigils = {'@', '!', '#'}; + Timer? _searchCoolDown; - static const String prefix = 'https://matrix.to/#/'; - static const String prefixNoProtocol = 'matrix.to/#/'; + static const Duration _coolDown = Duration(milliseconds: 500); - void submitAction([_]) async { - controller.text = controller.text.trim(); - if (!formKey.currentState!.validate()) return; - UrlLauncher(context, '$prefix${controller.text}').openMatrixToUrl(); - } - - String? validateForm(String? value) { - if (value!.isEmpty) { - return L10n.of(context)!.pleaseEnterAMatrixIdentifier; + void searchUsers([String? input]) async { + final searchTerm = input ?? controller.text; + if (searchTerm.isEmpty) { + _searchCoolDown?.cancel(); + setState(() { + searchResponse = _searchCoolDown = null; + }); + return; } - if (!controller.text.isValidMatrixId || - !supportedSigils.contains(controller.text.sigil)) { - return L10n.of(context)!.makeSureTheIdentifierIsValid; - } - if (controller.text == Matrix.of(context).client.userID) { - return L10n.of(context)!.youCannotInviteYourself; - } - return null; + + _searchCoolDown?.cancel(); + _searchCoolDown = Timer(_coolDown, () { + setState(() { + searchResponse = + Matrix.of(context).client.searchUserDirectory(searchTerm); + }); + }); } void inviteAction() => FluffyShare.shareInviteLink(context); @@ -81,6 +76,23 @@ class NewPrivateChatController extends State { ); } + void copyUserId() async { + await Clipboard.setData( + ClipboardData(text: Matrix.of(context).client.userID!), + ); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(L10n.of(context)!.copiedToClipboard)), + ); + } + + void openUserModal(Profile profile) => showAdaptiveBottomSheet( + context: context, + builder: (c) => UserBottomSheet( + profile: profile, + outerContext: context, + ), + ); + @override Widget build(BuildContext context) => NewPrivateChatView(this); } diff --git a/lib/pages/new_private_chat/new_private_chat_view.dart b/lib/pages/new_private_chat/new_private_chat_view.dart index f6aecbde..619145ee 100644 --- a/lib/pages/new_private_chat/new_private_chat_view.dart +++ b/lib/pages/new_private_chat/new_private_chat_view.dart @@ -4,10 +4,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:go_router/go_router.dart'; +import 'package:matrix/matrix.dart'; import 'package:qr_flutter/qr_flutter.dart'; +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pages/new_private_chat/new_private_chat.dart'; -import 'package:fluffychat/utils/platform_infos.dart'; +import 'package:fluffychat/utils/localized_exception_extension.dart'; +import 'package:fluffychat/utils/url_launcher.dart'; +import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/layouts/max_width_body.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -16,10 +21,9 @@ class NewPrivateChatView extends StatelessWidget { const NewPrivateChatView(this.controller, {super.key}); - static const double _qrCodePadding = 8; - @override Widget build(BuildContext context) { + final searchResponse = controller.searchResponse; final qrCodeSize = min(MediaQuery.of(context).size.width - 16, 256).toDouble(); return Scaffold( @@ -28,106 +32,221 @@ class NewPrivateChatView extends StatelessWidget { title: Text(L10n.of(context)!.newChat), backgroundColor: Theme.of(context).scaffoldBackgroundColor, actions: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: TextButton( - onPressed: () => context.go('/rooms/newgroup'), - child: Text( - L10n.of(context)!.createGroup, - style: - TextStyle(color: Theme.of(context).colorScheme.secondary), - ), - ), + IconButton( + onPressed: + UrlLauncher(context, AppConfig.startChatTutorial).launchUrl, + icon: const Icon(Icons.info_outlined), ), ], ), - body: Column( - children: [ - Expanded( - child: MaxWidthBody( - withFrame: false, - child: Container( - margin: const EdgeInsets.all(_qrCodePadding), - alignment: Alignment.center, - padding: const EdgeInsets.all(_qrCodePadding * 2), - child: Material( - borderRadius: BorderRadius.circular(12), - elevation: 10, - color: Colors.white, - shadowColor: Theme.of(context).appBarTheme.shadowColor, - clipBehavior: Clip.hardEdge, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - QrImageView( - data: - 'https://matrix.to/#/${Matrix.of(context).client.userID}', - version: QrVersions.auto, - size: qrCodeSize, - ), - TextButton.icon( - style: TextButton.styleFrom( - fixedSize: - Size.fromWidth(qrCodeSize - (2 * _qrCodePadding)), - foregroundColor: Colors.black, + body: MaxWidthBody( + withScrolling: false, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: TextField( + controller: controller.controller, + onChanged: controller.searchUsers, + decoration: InputDecoration( + hintText: 'Search for @users...', + prefixIcon: searchResponse == null + ? const Icon(Icons.search_outlined) + : FutureBuilder( + future: searchResponse, + builder: (context, snapshot) { + if (snapshot.connectionState != + ConnectionState.done) { + return const Padding( + padding: EdgeInsets.all(10.0), + child: SizedBox.square( + dimension: 24, + child: CircularProgressIndicator.adaptive( + strokeWidth: 1, + ), + ), + ); + } + return const Icon(Icons.search_outlined); + }, + ), + suffixIcon: controller.controller.text.isEmpty + ? null + : IconButton( + icon: const Icon(Icons.clear_outlined), + onPressed: () { + controller.controller.clear(); + controller.searchUsers(); + }, + ), + ), + ), + ), + Expanded( + child: AnimatedCrossFade( + duration: FluffyThemes.animationDuration, + crossFadeState: searchResponse == null + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + firstChild: ListView( + children: [ + ListTile( + title: SelectableText.rich( + TextSpan( + children: [ + TextSpan( + text: L10n.of(context)!.yourGlobalUserIdIs, + ), + TextSpan( + text: Matrix.of(context).client.userID, + style: const TextStyle( + fontWeight: FontWeight.w600, + ), + ), + ], + ), + style: TextStyle( + color: + Theme.of(context).colorScheme.onPrimaryContainer, + fontSize: 14, ), - icon: Icon(Icons.adaptive.share_outlined), - label: Text(L10n.of(context)!.shareInviteLink), - onPressed: controller.inviteAction, ), - const SizedBox(height: 8), - if (PlatformInfos.isMobile) ...[ - OutlinedButton.icon( - style: OutlinedButton.styleFrom( - backgroundColor: - Theme.of(context).colorScheme.primaryContainer, - fixedSize: Size.fromWidth( - qrCodeSize - (2 * _qrCodePadding), + trailing: IconButton( + icon: Icon( + Icons.copy_outlined, + size: 16, + color: + Theme.of(context).colorScheme.onPrimaryContainer, + ), + onPressed: controller.copyUserId, + ), + ), + //if (PlatformInfos.isMobile) + ListTile( + leading: CircleAvatar( + backgroundColor: + Theme.of(context).colorScheme.primaryContainer, + foregroundColor: + Theme.of(context).colorScheme.onPrimaryContainer, + child: const Icon(Icons.qr_code_scanner_outlined), + ), + title: Text(L10n.of(context)!.scanQrCode), + onTap: controller.openScannerAction, + ), + ListTile( + leading: CircleAvatar( + backgroundColor: + Theme.of(context).colorScheme.secondaryContainer, + foregroundColor: + Theme.of(context).colorScheme.onSecondaryContainer, + child: Icon(Icons.adaptive.share_outlined), + ), + title: Text(L10n.of(context)!.shareInviteLink), + onTap: controller.inviteAction, + ), + ListTile( + leading: CircleAvatar( + backgroundColor: + Theme.of(context).colorScheme.tertiaryContainer, + foregroundColor: + Theme.of(context).colorScheme.onTertiaryContainer, + child: const Icon(Icons.group_add_outlined), + ), + title: Text(L10n.of(context)!.createGroup), + onTap: () => context.go('/rooms/newgroup'), + ), + const SizedBox(height: 24), + Center( + child: Material( + borderRadius: BorderRadius.circular(12), + elevation: 10, + color: Colors.white, + shadowColor: Theme.of(context).appBarTheme.shadowColor, + clipBehavior: Clip.hardEdge, + child: QrImageView( + data: + 'https://matrix.to/#/${Matrix.of(context).client.userID}', + version: QrVersions.auto, + size: qrCodeSize, + ), + ), + ), + ], + ), + secondChild: FutureBuilder( + future: searchResponse, + builder: (context, snapshot) { + final result = snapshot.data; + final error = snapshot.error; + if (error != null) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + error.toLocalizedString(context), + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).colorScheme.error, ), ), - icon: const Icon(Icons.qr_code_scanner_outlined), - label: Text(L10n.of(context)!.scanQrCode), - onPressed: controller.openScannerAction, - ), - const SizedBox(height: 8), - ], - ], - ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: controller.searchUsers, + icon: const Icon(Icons.refresh_outlined), + label: Text(L10n.of(context)!.tryAgain), + ), + ], + ); + } + if (result == null) { + return const Center( + child: CircularProgressIndicator.adaptive(), + ); + } + if (result.results.isEmpty) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.search_outlined, size: 86), + Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + L10n.of(context)!.noUsersFoundWithQuery( + controller.controller.text, + ), + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + ), + textAlign: TextAlign.center, + ), + ), + ], + ); + } + return ListView.builder( + itemCount: result.results.length, + itemBuilder: (context, i) { + final contact = result.results[i]; + final displayname = contact.displayName ?? + contact.userId.localpart ?? + contact.userId; + return ListTile( + leading: Avatar( + name: displayname, + mxContent: contact.avatarUrl, + ), + title: Text(displayname), + subtitle: Text(contact.userId), + onTap: () => controller.openUserModal(contact), + ); + }, + ); + }, ), ), ), - ), - MaxWidthBody( - child: Padding( - padding: const EdgeInsets.all(12.0), - child: Form( - key: controller.formKey, - child: TextFormField( - controller: controller.controller, - autocorrect: false, - textInputAction: TextInputAction.go, - focusNode: controller.textFieldFocus, - onFieldSubmitted: controller.submitAction, - validator: controller.validateForm, - inputFormatters: controller.removeMatrixToFormatters, - decoration: InputDecoration( - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - labelText: L10n.of(context)!.enterInviteLinkOrMatrixId, - hintText: '@username', - prefixText: NewPrivateChatController.prefixNoProtocol, - suffixIcon: IconButton( - icon: const Icon(Icons.send_outlined), - onPressed: controller.submitAction, - ), - ), - ), - ), - ), - ), - ], + ], + ), ), ); }