From 6ea4d0c26363fb64531c46f0f0c24ba8ebd47016 Mon Sep 17 00:00:00 2001 From: krille-chan Date: Wed, 17 Apr 2024 09:26:22 +0200 Subject: [PATCH] feat: Search feature --- assets/l10n/intl_en.arb | 9 + lib/config/routes.dart | 14 ++ lib/pages/chat/chat.dart | 23 +-- lib/pages/chat/events/image_bubble.dart | 1 + .../chat_search/chat_search_files_tab.dart | 179 ++++++++++++++++ .../chat_search/chat_search_images_tab.dart | 167 +++++++++++++++ .../chat_search/chat_search_message_tab.dart | 191 ++++++++++++++++++ lib/pages/chat_search/chat_search_page.dart | 95 +++++++++ lib/pages/chat_search/chat_search_view.dart | 105 ++++++++++ lib/widgets/chat_settings_popup_menu.dart | 124 ++++++------ 10 files changed, 836 insertions(+), 72 deletions(-) create mode 100644 lib/pages/chat_search/chat_search_files_tab.dart create mode 100644 lib/pages/chat_search/chat_search_images_tab.dart create mode 100644 lib/pages/chat_search/chat_search_message_tab.dart create mode 100644 lib/pages/chat_search/chat_search_page.dart create mode 100644 lib/pages/chat_search/chat_search_view.dart diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index f93b7004..09d28fe2 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -2533,6 +2533,15 @@ "level": {} } }, + "searchIn": "Search in {chat}...", + "@searchIn": { + "type": "text", + "placeholders": { + "chat": {} + } + }, + "photos": "Photos", + "files": "Files", "databaseBuildErrorBody": "Unable to build the SQlite database. The app tries to use the legacy database for now. Please report this error to the developers at {url}. The error message is: {error}", "@databaseBuildErrorBody": { "type": "text", diff --git a/lib/config/routes.dart b/lib/config/routes.dart index e7efe9d2..3bfbcf91 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -13,6 +13,7 @@ import 'package:fluffychat/pages/chat_encryption_settings/chat_encryption_settin import 'package:fluffychat/pages/chat_list/chat_list.dart'; import 'package:fluffychat/pages/chat_members/chat_members.dart'; import 'package:fluffychat/pages/chat_permissions_settings/chat_permissions_settings.dart'; +import 'package:fluffychat/pages/chat_search/chat_search_page.dart'; import 'package:fluffychat/pages/device_settings/device_settings.dart'; import 'package:fluffychat/pages/homeserver_picker/homeserver_picker.dart'; import 'package:fluffychat/pages/invitation_selection/invitation_selection.dart'; @@ -131,6 +132,7 @@ abstract class AppRoutes { state, ChatPage( roomId: state.pathParameters['roomid']!, + eventId: state.uri.queryParameters['event'], ), ), redirect: loggedOutRedirect, @@ -311,10 +313,22 @@ abstract class AppRoutes { ChatPage( roomId: state.pathParameters['roomid']!, shareText: state.uri.queryParameters['body'], + eventId: state.uri.queryParameters['event'], ), ), redirect: loggedOutRedirect, routes: [ + GoRoute( + path: 'search', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + ChatSearchPage( + roomId: state.pathParameters['roomid']!, + ), + ), + redirect: loggedOutRedirect, + ), GoRoute( path: 'encryption', pageBuilder: (context, state) => defaultPageBuilder( diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 64f6a2e2..1f0724ce 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -3,7 +3,6 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:adaptive_dialog/adaptive_dialog.dart'; @@ -43,10 +42,12 @@ import 'send_location_dialog.dart'; class ChatPage extends StatelessWidget { final String roomId; final String? shareText; + final String? eventId; const ChatPage({ super.key, required this.roomId, + this.eventId, this.shareText, }); @@ -70,6 +71,7 @@ class ChatPage extends StatelessWidget { key: Key('chat_page_$roomId'), room: room, shareText: shareText, + eventId: eventId, ); } } @@ -77,11 +79,13 @@ class ChatPage extends StatelessWidget { class ChatPageWithRoom extends StatefulWidget { final Room room; final String? shareText; + final String? eventId; const ChatPageWithRoom({ super.key, required this.room, this.shareText, + this.eventId, }); @override @@ -257,12 +261,14 @@ class ChatController extends State void initState() { scrollController.addListener(_updateScrollController); inputFocus.addListener(_inputFocusListener); + _loadDraft(); super.initState(); _displayChatDetailsColumn = ValueNotifier( Matrix.of(context).store.getBool(SettingKeys.displayChatDetailsColumn) ?? false, ); + sendingClient = Matrix.of(context).client; WidgetsBinding.instance.addObserver(this); _tryLoadTimeline(); @@ -272,7 +278,8 @@ class ChatController extends State } void _tryLoadTimeline() async { - loadTimelineFuture = _getTimeline(); + readMarkerEventId = widget.eventId; + loadTimelineFuture = _getTimeline(eventContextId: readMarkerEventId); try { await loadTimelineFuture; final fullyRead = room.fullyRead; @@ -352,18 +359,6 @@ class ChatController extends State timeline!.requestKeys(onlineKeyBackupOnly: false); if (room.markedUnread) room.markUnread(false); - // when the scroll controller is attached we want to scroll to an event id, if specified - // and update the scroll controller...which will trigger a request history, if the - // "load more" button is visible on the screen - SchedulerBinding.instance.addPostFrameCallback((_) async { - if (mounted) { - final event = GoRouterState.of(context).uri.queryParameters['event']; - if (event != null) { - scrollToEventId(event); - } - } - }); - return; } diff --git a/lib/pages/chat/events/image_bubble.dart b/lib/pages/chat/events/image_bubble.dart index 83d902d2..f5219b05 100644 --- a/lib/pages/chat/events/image_bubble.dart +++ b/lib/pages/chat/events/image_bubble.dart @@ -71,6 +71,7 @@ class ImageBubble extends StatelessWidget { this.borderRadius ?? BorderRadius.circular(AppConfig.borderRadius); return Material( color: Colors.transparent, + clipBehavior: Clip.hardEdge, shape: RoundedRectangleBorder( borderRadius: borderRadius, side: BorderSide( diff --git a/lib/pages/chat_search/chat_search_files_tab.dart b/lib/pages/chat_search/chat_search_files_tab.dart new file mode 100644 index 00000000..37030ba0 --- /dev/null +++ b/lib/pages/chat_search/chat_search_files_tab.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/utils/date_time_extension.dart'; +import 'package:fluffychat/utils/matrix_sdk_extensions/event_extension.dart'; +import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; + +class ChatSearchFilesTab extends StatelessWidget { + final Room room; + final Stream<(List, String?)>? searchStream; + final void Function({ + String? prevBatch, + List? previousSearchResult, + }) startSearch; + + const ChatSearchFilesTab({ + required this.room, + required this.startSearch, + required this.searchStream, + super.key, + }); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: searchStream, + builder: (context, snapshot) { + if (searchStream == null) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.search_outlined, size: 64), + const SizedBox(height: 8), + Text( + L10n.of(context)!.searchIn( + room.getLocalizedDisplayname( + MatrixLocals(L10n.of(context)!), + ), + ), + ), + ], + ); + } + final events = snapshot.data?.$1 + .where((event) => event.messageType == MessageTypes.File) + .toList() ?? + []; + + if (events.isEmpty) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.file_present_outlined, size: 64), + const SizedBox(height: 8), + Text(L10n.of(context)!.nothingFound), + ], + ); + } + + return SelectionArea( + child: ListView.builder( + padding: const EdgeInsets.all(8.0), + itemCount: events.length + 1, + itemBuilder: (context, i) { + if (i == events.length) { + if (snapshot.connectionState != ConnectionState.done) { + return const Padding( + padding: EdgeInsets.all(16.0), + child: Center( + child: CircularProgressIndicator.adaptive( + strokeWidth: 2, + ), + ), + ); + } + final nextBatch = snapshot.data?.$2; + if (nextBatch == null) { + return const SizedBox.shrink(); + } + return Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: TextButton.icon( + style: TextButton.styleFrom( + backgroundColor: + Theme.of(context).colorScheme.secondaryContainer, + foregroundColor: + Theme.of(context).colorScheme.onSecondaryContainer, + ), + onPressed: () => startSearch( + prevBatch: nextBatch, + previousSearchResult: events, + ), + icon: const Icon( + Icons.arrow_downward_outlined, + ), + label: const Text('Search more...'), + ), + ), + ); + } + final event = events[i]; + final filename = event.content.tryGet('filename') ?? + event.content.tryGet('body') ?? + L10n.of(context)!.unknownEvent('File'); + final filetype = (filename.contains('.') + ? filename.split('.').last.toUpperCase() + : event.content + .tryGetMap('info') + ?.tryGet('mimetype') + ?.toUpperCase() ?? + 'UNKNOWN'); + final sizeString = event.sizeString; + final prevEvent = i > 0 ? events[i - 1] : null; + final sameEnvironment = prevEvent == null + ? false + : prevEvent.originServerTs + .sameEnvironment(event.originServerTs); + return Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!sameEnvironment) ...[ + Row( + children: [ + Expanded( + child: Container( + height: 1, + color: Theme.of(context).dividerColor, + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + event.originServerTs.localizedTime(context), + style: Theme.of(context).textTheme.labelSmall, + textAlign: TextAlign.center, + ), + ), + Expanded( + child: Container( + height: 1, + color: Theme.of(context).dividerColor, + ), + ), + ], + ), + const SizedBox(height: 4), + ], + Material( + borderRadius: + BorderRadius.circular(AppConfig.borderRadius), + color: Theme.of(context).colorScheme.onInverseSurface, + clipBehavior: Clip.hardEdge, + child: ListTile( + leading: const Icon(Icons.file_present_outlined), + title: Text( + filename, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text('$sizeString | $filetype'), + onTap: () => event.saveFile(context), + ), + ), + ], + ), + ); + }, + ), + ); + }, + ); + } +} diff --git a/lib/pages/chat_search/chat_search_images_tab.dart b/lib/pages/chat_search/chat_search_images_tab.dart new file mode 100644 index 00000000..809f0f2e --- /dev/null +++ b/lib/pages/chat_search/chat_search_images_tab.dart @@ -0,0 +1,167 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:intl/intl.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/pages/chat/events/image_bubble.dart'; +import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; + +class ChatSearchImagesTab extends StatelessWidget { + final Room room; + final Stream<(List, String?)>? searchStream; + final void Function({ + String? prevBatch, + List? previousSearchResult, + }) startSearch; + + const ChatSearchImagesTab({ + required this.room, + required this.startSearch, + required this.searchStream, + super.key, + }); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: searchStream, + builder: (context, snapshot) { + if (searchStream == null) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.search_outlined, size: 64), + const SizedBox(height: 8), + Text( + L10n.of(context)!.searchIn( + room.getLocalizedDisplayname( + MatrixLocals(L10n.of(context)!), + ), + ), + ), + ], + ); + } + final events = snapshot.data?.$1 + .where((event) => event.messageType == MessageTypes.Image) + .toList() ?? + []; + if (events.isEmpty) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.photo_outlined, size: 64), + const SizedBox(height: 8), + Text(L10n.of(context)!.nothingFound), + ], + ); + } + final eventsByMonth = >{}; + for (final event in events) { + final month = DateTime( + event.originServerTs.year, + event.originServerTs.month, + ); + eventsByMonth[month] ??= []; + eventsByMonth[month]!.add(event); + } + final eventsByMonthList = eventsByMonth.entries.toList(); + + const padding = 8.0; + + return ListView.builder( + itemCount: eventsByMonth.length + 1, + itemBuilder: (context, i) { + if (i == eventsByMonth.length) { + if (snapshot.connectionState != ConnectionState.done) { + return const Padding( + padding: EdgeInsets.all(16.0), + child: Center( + child: CircularProgressIndicator.adaptive( + strokeWidth: 2, + ), + ), + ); + } + final nextBatch = snapshot.data?.$2; + if (nextBatch == null) { + return const SizedBox.shrink(); + } + return Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: TextButton.icon( + style: TextButton.styleFrom( + backgroundColor: + Theme.of(context).colorScheme.secondaryContainer, + foregroundColor: + Theme.of(context).colorScheme.onSecondaryContainer, + ), + onPressed: () => startSearch( + prevBatch: nextBatch, + previousSearchResult: events, + ), + icon: const Icon( + Icons.arrow_downward_outlined, + ), + label: const Text('Search more...'), + ), + ), + ); + } + + final monthEvents = eventsByMonthList[i].value; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: Container( + height: 1, + color: Theme.of(context).dividerColor, + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + DateFormat.yMMMM( + Localizations.localeOf(context).languageCode, + ).format(eventsByMonthList[i].key), + style: Theme.of(context).textTheme.labelSmall, + textAlign: TextAlign.center, + ), + ), + Expanded( + child: Container( + height: 1, + color: Theme.of(context).dividerColor, + ), + ), + ], + ), + GridView.count( + shrinkWrap: true, + mainAxisSpacing: padding, + crossAxisSpacing: padding, + padding: const EdgeInsets.all(padding), + crossAxisCount: 3, + children: monthEvents + .map( + (event) => ImageBubble( + event, + fit: BoxFit.cover, + ), + ) + .toList(), + ), + ], + ); + }, + ); + }, + ); + } +} diff --git a/lib/pages/chat_search/chat_search_message_tab.dart b/lib/pages/chat_search/chat_search_message_tab.dart new file mode 100644 index 00000000..a904ed34 --- /dev/null +++ b/lib/pages/chat_search/chat_search_message_tab.dart @@ -0,0 +1,191 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:flutter_linkify/flutter_linkify.dart'; +import 'package:go_router/go_router.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/utils/date_time_extension.dart'; +import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; +import 'package:fluffychat/utils/url_launcher.dart'; +import 'package:fluffychat/widgets/avatar.dart'; + +class ChatSearchMessageTab extends StatelessWidget { + final String searchQuery; + final Room room; + final Stream<(List, String?)>? searchStream; + final void Function({ + String? prevBatch, + List? previousSearchResult, + }) startSearch; + + const ChatSearchMessageTab({ + required this.searchQuery, + required this.room, + required this.searchStream, + required this.startSearch, + super.key, + }); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + key: ValueKey(searchQuery), + stream: searchStream, + builder: (context, snapshot) { + if (searchStream == null) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.search_outlined, size: 64), + const SizedBox(height: 8), + Text( + L10n.of(context)!.searchIn( + room.getLocalizedDisplayname( + MatrixLocals(L10n.of(context)!), + ), + ), + ), + ], + ); + } + final events = snapshot.data?.$1 + .where( + (event) => { + MessageTypes.Text, + MessageTypes.Notice, + }.contains(event.messageType), + ) + .toList() ?? + []; + + return SelectionArea( + child: ListView.separated( + itemCount: events.length + 1, + separatorBuilder: (context, _) => Divider( + color: Theme.of(context).dividerColor, + height: 1, + ), + itemBuilder: (context, i) { + if (i == events.length) { + if (snapshot.connectionState != ConnectionState.done) { + return const Padding( + padding: EdgeInsets.all(16.0), + child: Center( + child: CircularProgressIndicator.adaptive( + strokeWidth: 2, + ), + ), + ); + } + final nextBatch = snapshot.data?.$2; + if (nextBatch == null) { + return const SizedBox.shrink(); + } + return Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: TextButton.icon( + style: TextButton.styleFrom( + backgroundColor: + Theme.of(context).colorScheme.secondaryContainer, + foregroundColor: + Theme.of(context).colorScheme.onSecondaryContainer, + ), + onPressed: () => startSearch( + prevBatch: nextBatch, + previousSearchResult: events, + ), + icon: const Icon( + Icons.arrow_downward_outlined, + ), + label: const Text('Search more...'), + ), + ), + ); + } + final event = events[i]; + final sender = event.senderFromMemoryOrFallback; + final displayname = sender.calcDisplayname( + i18n: MatrixLocals(L10n.of(context)!), + ); + return _MessageSearchResultListTile( + sender: sender, + displayname: displayname, + event: event, + room: room, + ); + }, + ), + ); + }, + ); + } +} + +class _MessageSearchResultListTile extends StatelessWidget { + const _MessageSearchResultListTile({ + required this.sender, + required this.displayname, + required this.event, + required this.room, + }); + + final User sender; + final String displayname; + final Event event; + final Room room; + + @override + Widget build(BuildContext context) { + return ListTile( + title: Row( + children: [ + Avatar( + mxContent: sender.avatarUrl, + name: displayname, + size: 16, + ), + const SizedBox(width: 8), + Text( + displayname, + ), + Expanded( + child: Text( + ' | ${event.originServerTs.localizedTimeShort(context)}', + style: const TextStyle(fontSize: 12), + ), + ), + ], + ), + subtitle: Linkify( + options: const LinkifyOptions(humanize: false), + linkStyle: TextStyle( + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + decorationColor: Theme.of(context).colorScheme.primary, + ), + onOpen: (url) => UrlLauncher(context, url.url).launchUrl(), + text: event.calcLocalizedBodyFallback( + plaintextBody: true, + removeMarkdown: true, + MatrixLocals( + L10n.of(context)!, + ), + ), + maxLines: 4, + ), + trailing: IconButton( + icon: const Icon( + Icons.chevron_right_outlined, + ), + onPressed: () => context.go( + '/${Uri( + pathSegments: ['rooms', room.id], + queryParameters: {'event': event.eventId}, + )}', + ), + ), + ); + } +} diff --git a/lib/pages/chat_search/chat_search_page.dart b/lib/pages/chat_search/chat_search_page.dart new file mode 100644 index 00000000..a07bc4e9 --- /dev/null +++ b/lib/pages/chat_search/chat_search_page.dart @@ -0,0 +1,95 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/pages/chat_search/chat_search_view.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class ChatSearchPage extends StatefulWidget { + final String roomId; + const ChatSearchPage({required this.roomId, super.key}); + + @override + ChatSearchController createState() => ChatSearchController(); +} + +class ChatSearchController extends State + with SingleTickerProviderStateMixin { + Room? get room => Matrix.of(context).client.getRoomById(widget.roomId); + + final TextEditingController searchController = TextEditingController(); + late final TabController tabController; + + Timeline? timeline; + + Stream<(List, String?)>? searchStream; + + void restartSearch() { + if (tabController.index == 0 && searchController.text.isEmpty) { + setState(() { + searchStream = null; + }); + return; + } + setState(() { + searchStream = const Stream.empty(); + }); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + startSearch(); + }); + } + + void startSearch({ + String? prevBatch, + List? previousSearchResult, + }) async { + final timeline = this.timeline ??= await room!.getTimeline(); + + if (tabController.index == 0 && searchController.text.isEmpty) { + return; + } + + setState(() { + searchStream = timeline + .startSearch( + searchTerm: tabController.index == 0 ? searchController.text : null, + searchFunc: switch (tabController.index) { + 1 => (event) => event.messageType == MessageTypes.Image, + 2 => (event) => event.messageType == MessageTypes.File, + int() => null, + }, + prevBatch: prevBatch, + requestHistoryCount: 1000, + limit: 32, + ) + .map( + (result) => ( + [ + if (previousSearchResult != null) ...previousSearchResult, + ...result.$1, + ], + result.$2, + ), + ) + .asBroadcastStream(); + }); + } + + @override + void initState() { + super.initState(); + tabController = TabController(initialIndex: 0, length: 3, vsync: this); + tabController.addListener(restartSearch); + } + + @override + void dispose() { + tabController.removeListener(restartSearch); + super.dispose(); + } + + @override + Widget build(BuildContext context) => ChatSearchView(this); +} diff --git a/lib/pages/chat_search/chat_search_view.dart b/lib/pages/chat_search/chat_search_view.dart new file mode 100644 index 00000000..48c7cb60 --- /dev/null +++ b/lib/pages/chat_search/chat_search_view.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_gen/gen_l10n/l10n.dart'; + +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/pages/chat_search/chat_search_files_tab.dart'; +import 'package:fluffychat/pages/chat_search/chat_search_images_tab.dart'; +import 'package:fluffychat/pages/chat_search/chat_search_message_tab.dart'; +import 'package:fluffychat/pages/chat_search/chat_search_page.dart'; +import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; +import 'package:fluffychat/widgets/layouts/max_width_body.dart'; + +class ChatSearchView extends StatelessWidget { + final ChatSearchController controller; + + const ChatSearchView(this.controller, {super.key}); + + @override + Widget build(BuildContext context) { + final room = controller.room; + if (room == null) { + return Scaffold( + appBar: AppBar(title: Text(L10n.of(context)!.oopsSomethingWentWrong)), + body: Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: + Text(L10n.of(context)!.youAreNoLongerParticipatingInThisChat), + ), + ), + ); + } + + return Scaffold( + appBar: AppBar( + leading: const Center(child: BackButton()), + titleSpacing: 0, + title: Text( + L10n.of(context)!.searchIn( + room.getLocalizedDisplayname(MatrixLocals(L10n.of(context)!)), + ), + ), + ), + body: MaxWidthBody( + withScrolling: false, + child: Column( + children: [ + if (FluffyThemes.isThreeColumnMode(context)) + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + child: TextField( + controller: controller.searchController, + onSubmitted: (_) => controller.restartSearch(), + autofocus: true, + enabled: controller.tabController.index == 0, + decoration: InputDecoration( + hintText: L10n.of(context)!.searchIn( + room.getLocalizedDisplayname( + MatrixLocals(L10n.of(context)!), + ), + ), + suffixIcon: const Icon(Icons.search_outlined), + ), + ), + ), + TabBar( + controller: controller.tabController, + tabs: [ + Tab(child: Text(L10n.of(context)!.messages)), + Tab(child: Text(L10n.of(context)!.photos)), + Tab(child: Text(L10n.of(context)!.files)), + ], + ), + Expanded( + child: TabBarView( + controller: controller.tabController, + children: [ + ChatSearchMessageTab( + searchQuery: controller.searchController.text, + room: room, + startSearch: controller.startSearch, + searchStream: controller.searchStream, + ), + ChatSearchImagesTab( + room: room, + startSearch: controller.startSearch, + searchStream: controller.searchStream, + ), + ChatSearchFilesTab( + room: room, + startSearch: controller.startSearch, + searchStream: controller.searchStream, + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/chat_settings_popup_menu.dart b/lib/widgets/chat_settings_popup_menu.dart index 277e7170..23732aeb 100644 --- a/lib/widgets/chat_settings_popup_menu.dart +++ b/lib/widgets/chat_settings_popup_menu.dart @@ -12,6 +12,8 @@ import 'package:matrix/matrix.dart'; import 'matrix.dart'; +enum ChatPopupMenuActions { details, mute, unmute, leave, search } + class ChatSettingsPopupMenu extends StatefulWidget { final Room room; final bool displayChatDetails; @@ -41,54 +43,6 @@ class ChatSettingsPopupMenuState extends State { .listen( (u) => setState(() {}), ); - final items = >[ - widget.room.pushRuleState == PushRuleState.notify - ? PopupMenuItem( - value: 'mute', - child: Row( - children: [ - const Icon(Icons.notifications_off_outlined), - const SizedBox(width: 12), - Text(L10n.of(context)!.muteChat), - ], - ), - ) - : PopupMenuItem( - value: 'unmute', - child: Row( - children: [ - const Icon(Icons.notifications_on_outlined), - const SizedBox(width: 12), - Text(L10n.of(context)!.unmuteChat), - ], - ), - ), - PopupMenuItem( - value: 'leave', - child: Row( - children: [ - const Icon(Icons.delete_outlined), - const SizedBox(width: 12), - Text(L10n.of(context)!.leave), - ], - ), - ), - ]; - if (widget.displayChatDetails) { - items.insert( - 0, - PopupMenuItem( - value: 'details', - child: Row( - children: [ - const Icon(Icons.info_outline_rounded), - const SizedBox(width: 12), - Text(L10n.of(context)!.chatDetails), - ], - ), - ), - ); - } return Stack( alignment: Alignment.center, children: [ @@ -101,10 +55,10 @@ class ChatSettingsPopupMenuState extends State { onKeysPressed: _showChatDetails, child: const SizedBox.shrink(), ), - PopupMenuButton( - onSelected: (String choice) async { + PopupMenuButton( + onSelected: (choice) async { switch (choice) { - case 'leave': + case ChatPopupMenuActions.leave: final confirmed = await showOkCancelAlertDialog( useRootNavigator: false, context: context, @@ -123,29 +77,83 @@ class ChatSettingsPopupMenuState extends State { } } break; - case 'mute': + case ChatPopupMenuActions.mute: await showFutureLoadingDialog( context: context, future: () => widget.room.setPushRuleState(PushRuleState.mentionsOnly), ); break; - case 'unmute': + case ChatPopupMenuActions.unmute: await showFutureLoadingDialog( context: context, future: () => widget.room.setPushRuleState(PushRuleState.notify), ); break; - case 'todos': - context.go('/rooms/${widget.room.id}/tasks'); - break; - case 'details': + case ChatPopupMenuActions.details: _showChatDetails(); break; + case ChatPopupMenuActions.search: + context.go('/rooms/${widget.room.id}/search'); + break; } }, - itemBuilder: (BuildContext context) => items, + itemBuilder: (BuildContext context) => [ + if (widget.displayChatDetails) + PopupMenuItem( + value: ChatPopupMenuActions.details, + child: Row( + children: [ + const Icon(Icons.info_outline_rounded), + const SizedBox(width: 12), + Text(L10n.of(context)!.chatDetails), + ], + ), + ), + if (widget.room.pushRuleState == PushRuleState.notify) + PopupMenuItem( + value: ChatPopupMenuActions.mute, + child: Row( + children: [ + const Icon(Icons.notifications_off_outlined), + const SizedBox(width: 12), + Text(L10n.of(context)!.muteChat), + ], + ), + ) + else + PopupMenuItem( + value: ChatPopupMenuActions.unmute, + child: Row( + children: [ + const Icon(Icons.notifications_on_outlined), + const SizedBox(width: 12), + Text(L10n.of(context)!.unmuteChat), + ], + ), + ), + PopupMenuItem( + value: ChatPopupMenuActions.search, + child: Row( + children: [ + const Icon(Icons.search_outlined), + const SizedBox(width: 12), + Text(L10n.of(context)!.search), + ], + ), + ), + PopupMenuItem( + value: ChatPopupMenuActions.leave, + child: Row( + children: [ + const Icon(Icons.delete_outlined), + const SizedBox(width: 12), + Text(L10n.of(context)!.leave), + ], + ), + ), + ], ), ], );