diff --git a/frontend/appflowy_flutter/integration_test/mobile/search/search_test.dart b/frontend/appflowy_flutter/integration_test/mobile/search/search_test.dart new file mode 100644 index 0000000000..3031d5eaa0 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/mobile/search/search_test.dart @@ -0,0 +1,59 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/search/mobile_search_ask_ai_entrance.dart'; +import 'package:appflowy/mobile/presentation/search/mobile_search_result.dart'; +import 'package:appflowy/mobile/presentation/search/mobile_search_textfield.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('Search test', () { + testWidgets('tap to search page', (tester) async { + await tester.launchInAnonymousMode(); + final searchButton = find.byFlowySvg(FlowySvgs.m_home_search_icon_m); + await tester.tapButton(searchButton); + + ///check for UI element + expect(find.byType(MobileSearchAskAiEntrance), findsNothing); + expect(find.byType(MobileSearchRecentList), findsOneWidget); + expect(find.byType(MobileSearchResultList), findsNothing); + + /// search for something + final searchTextField = find.descendant( + of: find.byType(MobileSearchTextfield), + matching: find.byType(TextFormField), + ); + final query = '$gettingStarted searching'; + await tester.enterText(searchTextField, query); + await tester.pumpAndSettle(); + + expect(find.byType(MobileSearchRecentList), findsNothing); + expect(find.byType(MobileSearchResultList), findsOneWidget); + expect( + find.text(LocaleKeys.search_noResultForSearching.tr(args: [query])), + findsOneWidget, + ); + + /// clear text + final clearButton = find.byFlowySvg(FlowySvgs.clear_s); + await tester.tapButton(clearButton); + expect(find.byType(MobileSearchRecentList), findsOneWidget); + expect(find.byType(MobileSearchResultList), findsNothing); + + /// tap cancel button + final cancelButton = find.text(LocaleKeys.button_cancel.tr()); + expect(cancelButton, findsNothing); + await tester.enterText(searchTextField, query); + await tester.pumpAndSettle(); + expect(cancelButton, findsOneWidget); + await tester.tapButton(cancelButton); + expect(cancelButton, findsNothing); + }); + }); +} diff --git a/frontend/appflowy_flutter/ios/Podfile.lock b/frontend/appflowy_flutter/ios/Podfile.lock index 4b7ed5d639..07f8217a78 100644 --- a/frontend/appflowy_flutter/ios/Podfile.lock +++ b/frontend/appflowy_flutter/ios/Podfile.lock @@ -184,7 +184,7 @@ SPEC CHECKSUMS: app_links: 3da4c36b46cac3bf24eb897f1a6ce80bda109874 appflowy_backend: 78f6a053f756e6bc29bcc5a2106cbe77b756e97a connectivity_plus: 481668c94744c30c53b8895afb39159d1e619bdf - device_info_plus: 71ffc6ab7634ade6267c7a93088ed7e4f74e5896 + device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 file_picker: 9b3292d7c8bc68c8a7bf8eb78f730e49c8efc517 diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page.dart index 345a4591d1..8e6a36e068 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page.dart @@ -2,10 +2,12 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/home/mobile_home_page_header.dart'; import 'package:appflowy/mobile/presentation/home/tab/mobile_space_tab.dart'; import 'package:appflowy/mobile/presentation/home/tab/space_order_bloc.dart'; +import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/shared/loading.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; import 'package:appflowy/workspace/application/recent/cached_recent_service.dart'; @@ -173,7 +175,14 @@ class _HomePageState extends State<_HomePage> { listener: (context, state) { getIt().reset(); mCurrentWorkspace.value = state.currentWorkspace; - + if (FeatureFlag.search.isOn) { + // Notify command palette that workspace has changed + context.read().add( + CommandPaletteEvent.workspaceChanged( + workspaceId: state.currentWorkspace?.workspaceId, + ), + ); + } Debounce.debounce( 'workspace_action_result', const Duration(milliseconds: 150), diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/ai_bubble_button.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/ai_bubble_button.dart index cc4176e0ef..8ae37e7c22 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/ai_bubble_button.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/ai_bubble_button.dart @@ -3,6 +3,7 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/animated_gesture.dart'; import 'package:appflowy/mobile/presentation/home/tab/mobile_space_tab.dart'; import 'package:appflowy/util/theme_extension.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -82,3 +83,35 @@ class FloatingAIEntry extends StatelessWidget { ); } } + +class FloatingAIEntryV2 extends StatelessWidget { + const FloatingAIEntryV2({super.key}); + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return GestureDetector( + onTap: () { + mobileCreateNewAIChatNotifier.value = + mobileCreateNewAIChatNotifier.value + 1; + }, + child: Container( + width: 56, + height: 56, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: theme.surfaceColorScheme.primary, + boxShadow: theme.shadow.small, + border: Border.all(color: theme.borderColorScheme.primary), + ), + child: Center( + child: FlowySvg( + FlowySvgs.m_home_ai_chat_icon_m, + blendMode: null, + size: Size(24, 24), + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/mobile_space_tab.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/mobile_space_tab.dart index 56f5f3e6ab..ad067162ab 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/mobile_space_tab.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/mobile_space_tab.dart @@ -163,16 +163,16 @@ class _MobileSpaceTabState extends State case MobileSpaceTabType.recent: return const MobileRecentSpace(); case MobileSpaceTabType.spaces: + final showAIFloatingButton = + widget.userProfile.workspaceAuthType == AuthTypePB.Server; return Stack( children: [ MobileHomeSpace(userProfile: widget.userProfile), - // only show ai chat button for cloud user - if (widget.userProfile.workspaceAuthType == AuthTypePB.Server) + if (showAIFloatingButton) Positioned( - bottom: MediaQuery.of(context).padding.bottom + 16, - left: 20, right: 20, - child: const FloatingAIEntry(), + bottom: MediaQuery.of(context).padding.bottom + 16, + child: FloatingAIEntryV2(), ), ], ); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/mobile_bottom_navigation_bar.dart b/frontend/appflowy_flutter/lib/mobile/presentation/mobile_bottom_navigation_bar.dart index 3c6adb8627..2f44a4c2e9 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/mobile_bottom_navigation_bar.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/mobile_bottom_navigation_bar.dart @@ -28,46 +28,52 @@ final PropertyValueNotifier mobileCreateNewPageNotifier = PropertyValueNotifier(null); final ValueNotifier bottomNavigationBarType = ValueNotifier(BottomNavigationBarActionType.home); +final ValueNotifier bottomNavigationBarItemType = + ValueNotifier(BottomNavigationBarItemType.home.label); enum BottomNavigationBarItemType { home, + search, add, notification; - String get label { - return switch (this) { - BottomNavigationBarItemType.home => 'home', - BottomNavigationBarItemType.add => 'add', - BottomNavigationBarItemType.notification => 'notification', - }; - } + String get label => name; ValueKey get valueKey { return ValueKey(label); } + + Widget get iconWidget { + return switch (this) { + home => const FlowySvg(FlowySvgs.m_home_unselected_m), + search => const FlowySvg(FlowySvgs.m_home_search_icon_m), + add => const FlowySvg(FlowySvgs.m_home_add_m), + notification => const _NotificationNavigationBarItemIcon(), + }; + } + + Widget? get activeIcon { + return switch (this) { + home => const FlowySvg(FlowySvgs.m_home_selected_m, blendMode: null), + search => + const FlowySvg(FlowySvgs.m_home_search_icon_active_m, blendMode: null), + add => null, + notification => const _NotificationNavigationBarItemIcon(isActive: true), + }; + } + + BottomNavigationBarItem get navigationItem { + return BottomNavigationBarItem( + key: valueKey, + label: label, + icon: iconWidget, + activeIcon: activeIcon, + ); + } } -final _items = [ - BottomNavigationBarItem( - key: BottomNavigationBarItemType.home.valueKey, - label: BottomNavigationBarItemType.home.label, - icon: const FlowySvg(FlowySvgs.m_home_unselected_m), - activeIcon: const FlowySvg(FlowySvgs.m_home_selected_m, blendMode: null), - ), - BottomNavigationBarItem( - key: BottomNavigationBarItemType.add.valueKey, - label: BottomNavigationBarItemType.add.label, - icon: const FlowySvg(FlowySvgs.m_home_add_m), - ), - BottomNavigationBarItem( - key: BottomNavigationBarItemType.notification.valueKey, - label: BottomNavigationBarItemType.notification.label, - icon: const _NotificationNavigationBarItemIcon(), - activeIcon: const _NotificationNavigationBarItemIcon( - isActive: true, - ), - ), -]; +final _items = + BottomNavigationBarItemType.values.map((e) => e.navigationItem).toList(); /// Builds the "shell" for the app by building a Scaffold with a /// BottomNavigationBar, where [child] is placed in the body of the Scaffold. @@ -259,6 +265,7 @@ class _HomePageNavigationBar extends StatelessWidget { } else if (label == BottomNavigationBarItemType.notification.label) { getIt().add(const ReminderEvent.refresh()); } + bottomNavigationBarItemType.value = label; // When navigating to a new branch, it's recommended to use the goBranch // method, as doing so makes sure the last navigation state of the // Navigator for the branch is restored. diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/search/mobile_search_ask_ai_entrance.dart b/frontend/appflowy_flutter/lib/mobile/presentation/search/mobile_search_ask_ai_entrance.dart new file mode 100644 index 0000000000..7a695da46c --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/search/mobile_search_ask_ai_entrance.dart @@ -0,0 +1,76 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/home/tab/mobile_space_tab.dart'; +import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class MobileSearchAskAiEntrance extends StatelessWidget { + const MobileSearchAskAiEntrance({super.key, this.query}); + final String? query; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return GestureDetector( + onTap: () { + context + .read() + ?.add(CommandPaletteEvent.gointToAskAI()); + mobileCreateNewAIChatNotifier.value = + mobileCreateNewAIChatNotifier.value + 1; + }, + behavior: HitTestBehavior.opaque, + child: Container( + height: 48, + margin: EdgeInsets.only(top: 16), + padding: EdgeInsets.fromLTRB(8, 12, 8, 12), + child: Row( + children: [ + FlowySvg( + FlowySvgs.m_home_ai_chat_icon_m, + size: Size.square(24), + blendMode: null, + ), + HSpace(12), + buildText(theme), + ], + ), + ), + ); + } + + Widget buildText(AppFlowyThemeData theme) { + final queryText = query ?? ''; + if (queryText.isEmpty) { + return Text( + LocaleKeys.search_askAIAnything.tr(), + style: theme.textStyle.heading4 + .standard(color: theme.textColorScheme.primary), + ); + } + return Flexible( + child: RichText( + maxLines: 1, + overflow: TextOverflow.ellipsis, + text: TextSpan( + children: [ + TextSpan( + text: LocaleKeys.search_askAIFor.tr(), + style: theme.textStyle.heading4 + .standard(color: theme.textColorScheme.primary), + ), + TextSpan( + text: ' "$queryText"', + style: theme.textStyle.heading4 + .enhanced(color: theme.textColorScheme.primary), + ), + ], + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/search/mobile_search_cell.dart b/frontend/appflowy_flutter/lib/mobile/presentation/search/mobile_search_cell.dart new file mode 100644 index 0000000000..318c02d638 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/search/mobile_search_cell.dart @@ -0,0 +1,187 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart'; +import 'package:appflowy/workspace/application/command_palette/search_result_ext.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'mobile_view_ancestors.dart'; + +class MobileSearchResultCell extends StatelessWidget { + const MobileSearchResultCell({super.key, required this.item, this.query}); + final SearchResultItem item; + final String? query; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context), + textColor = theme.textColorScheme.primary; + final commandPaletteState = context.read().state; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + buildIcon(), + HSpace(12), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RichText( + maxLines: 1, + overflow: TextOverflow.ellipsis, + text: buildHighLightSpan( + content: item.displayName, + normal: theme.textStyle.heading4.standard(color: textColor), + highlight: theme.textStyle.heading4 + .standard(color: textColor) + .copyWith( + backgroundColor: theme.fillColorScheme.themeSelect, + ), + ), + ), + buildPath(commandPaletteState, theme), + buildSummary(theme), + ], + ), + ), + ], + ), + ); + } + + Widget buildIcon() { + final icon = item.icon; + if (icon.ty == ResultIconTypePB.Emoji) { + return icon.getIcon(size: 16.0, lineHeight: 20 / 16) ?? SizedBox.shrink(); + } else { + return icon.getIcon(size: 20) ?? SizedBox.shrink(); + } + } + + Widget buildPath(CommandPaletteState state, AppFlowyThemeData theme) { + bool isInTrash = false; + for (final view in state.trash) { + if (view.id == item.id) { + isInTrash = true; + break; + } + } + if (isInTrash) { + return Row( + children: [ + const FlowySvg(FlowySvgs.trash_s, size: Size.square(20)), + const HSpace(4.0), + Text( + '${LocaleKeys.trash_text.tr()} / ${item.displayName}', + style: theme.textStyle.body + .standard(color: theme.textColorScheme.secondary), + ), + ], + ); + } else { + return BlocProvider( + create: (context) => ViewAncestorBloc(item.id), + child: BlocBuilder( + builder: (context, state) { + final ancestors = state.ancestor.ancestors; + List displayPath = ancestors.map((e) => e.name).toList(); + if (ancestors.length > 2) { + displayPath = [ancestors.first.name, '...', ancestors.last.name]; + } + return Text( + displayPath.join(' / '), + style: theme.textStyle.body + .standard(color: theme.textColorScheme.secondary), + ); + }, + ), + ); + } + } + + Widget buildSummary(AppFlowyThemeData theme) { + if (item.content.isEmpty) { + return const SizedBox.shrink(); + } + return RichText( + maxLines: 3, + overflow: TextOverflow.ellipsis, + text: buildHighLightSpan( + content: item.content, + normal: theme.textStyle.heading4 + .standard(color: theme.textColorScheme.secondary), + highlight: theme.textStyle.heading4 + .standard(color: theme.textColorScheme.primary) + .copyWith( + backgroundColor: theme.fillColorScheme.themeSelect, + ), + ), + ); + } + + TextSpan buildHighLightSpan({ + required String content, + required TextStyle normal, + required TextStyle highlight, + }) { + final queryText = (query ?? '').trim(); + if (queryText.isEmpty) { + return TextSpan(text: content, style: normal); + } + final contents = content.splitIncludeSeparator(queryText); + return TextSpan( + children: List.generate(contents.length, (index) { + final content = contents[index]; + final isHighlight = content.toLowerCase() == queryText.toLowerCase(); + return TextSpan( + text: content, + style: isHighlight ? highlight : normal, + ); + }), + ); + } +} + +extension ViewPBToSearchResultItem on ViewPB { + SearchResultItem toSearchResultItem() { + final hasIcon = icon.value.isNotEmpty; + return SearchResultItem( + id: id, + displayName: nameOrDefault, + icon: ResultIconPB( + ty: hasIcon + ? ResultIconTypePB.valueOf(icon.ty.value) + : ResultIconTypePB.Icon, + value: hasIcon ? icon.value : '${layout.value}', + ), + content: '', + ); + } +} + +extension StringSplitExtension on String { + List splitIncludeSeparator(String separator) { + final splits = + split(RegExp(RegExp.escape(separator), caseSensitive: false)); + final List contents = []; + int charIndex = 0; + final seperatorLength = separator.length; + for (int i = 0; i < splits.length; i++) { + contents.add(splits[i]); + charIndex += splits[i].length; + if (i != splits.length - 1) { + contents.add(substring(charIndex, charIndex + seperatorLength)); + charIndex += seperatorLength; + } + } + return contents; + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/search/mobile_search_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/search/mobile_search_page.dart new file mode 100644 index 0000000000..2b054f6a1f --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/search/mobile_search_page.dart @@ -0,0 +1,121 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/errors/workspace_failed_screen.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:provider/provider.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; + +import 'mobile_search_ask_ai_entrance.dart'; +import 'mobile_search_result.dart'; +import 'mobile_search_textfield.dart'; + +class MobileSearchScreen extends StatelessWidget { + const MobileSearchScreen({ + super.key, + }); + + static const routeName = '/search'; + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: Future.wait([ + FolderEventGetCurrentWorkspaceSetting().send(), + getIt().getUser(), + ]), + builder: (context, snapshots) { + if (!snapshots.hasData) { + return const Center(child: CircularProgressIndicator.adaptive()); + } + + final latest = snapshots.data?[0].fold( + (latest) { + return latest as WorkspaceLatestPB?; + }, + (error) => null, + ); + final userProfile = snapshots.data?[1].fold( + (userProfilePB) { + return userProfilePB as UserProfilePB?; + }, + (error) => null, + ); + + // In the unlikely case either of the above is null, eg. + // when a workspace is already open this can happen. + if (latest == null || userProfile == null) { + return const WorkspaceFailedScreen(); + } + + Sentry.configureScope( + (scope) => scope.setUser( + SentryUser( + id: userProfile.id.toString(), + ), + ), + ); + return Scaffold( + body: SafeArea( + bottom: false, + child: Provider.value( + value: userProfile, + child: MobileSearchPage( + userProfile: userProfile, + workspaceLatestPB: latest, + ), + ), + ), + ); + }, + ); + } +} + +class MobileSearchPage extends StatelessWidget { + const MobileSearchPage({ + super.key, + required this.userProfile, + required this.workspaceLatestPB, + }); + + final UserProfilePB userProfile; + final WorkspaceLatestPB workspaceLatestPB; + + bool get enableShowAISearch => + userProfile.workspaceAuthType == AuthTypePB.Server; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MobileSearchTextfield( + hintText: enableShowAISearch + ? LocaleKeys.search_searchOrAskAI.tr() + : LocaleKeys.search_label.tr(), + query: state.query ?? '', + onChanged: (value) => context + .read() + .add(CommandPaletteEvent.searchChanged(search: value)), + ), + if (enableShowAISearch) + MobileSearchAskAiEntrance(query: state.query), + Flexible(child: SafeArea(child: MobileSearchResult())), + ], + ), + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/search/mobile_search_result.dart b/frontend/appflowy_flutter/lib/mobile/presentation/search/mobile_search_result.dart new file mode 100644 index 0000000000..36f0fb2d14 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/search/mobile_search_result.dart @@ -0,0 +1,189 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/application/mobile_router.dart'; +import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; +import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart'; +import 'package:appflowy/workspace/application/command_palette/search_result_list_bloc.dart'; +import 'package:appflowy/workspace/application/recent/recent_views_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'mobile_search_cell.dart'; + +class MobileSearchResult extends StatelessWidget { + const MobileSearchResult({super.key}); + + @override + Widget build(BuildContext context) { + final state = context.read().state; + final query = (state.query ?? '').trim(); + if (query.isEmpty) { + return const MobileSearchRecentList(); + } + return MobileSearchResultList(); + } +} + +class MobileSearchRecentList extends StatelessWidget { + const MobileSearchRecentList({super.key}); + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return BlocProvider( + create: (context) => + RecentViewsBloc()..add(const RecentViewsEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + final List recentViews = + state.views.map((e) => e.item).toSet().toList(); + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const VSpace(16), + Text( + LocaleKeys.sideBar_recent.tr(), + style: theme.textStyle.body + .enhanced(color: theme.textColorScheme.secondary), + ), + const VSpace(4), + Column( + children: List.generate(recentViews.length, (index) { + final view = recentViews[index]; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _goToView(context, view), + child: MobileSearchResultCell( + item: view.toSearchResultItem(), + ), + ); + }), + ), + ], + ), + ); + }, + ), + ); + } +} + +class MobileSearchResultList extends StatefulWidget { + const MobileSearchResultList({super.key}); + + @override + State createState() => _MobileSearchResultListState(); +} + +class _MobileSearchResultListState extends State { + late final SearchResultListBloc bloc; + + @override + void initState() { + super.initState(); + bloc = SearchResultListBloc(); + } + + @override + void dispose() { + bloc.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final state = context.read().state, + theme = AppFlowyTheme.of(context); + final isSearching = state.searching, + items = state.combinedResponseItems.values.toList(), + hasData = items.isNotEmpty; + if (isSearching && !hasData) { + return Center(child: CircularProgressIndicator.adaptive()); + } else if (!hasData) { + return buildNoResult(state.query ?? ''); + } + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const VSpace(16), + Text( + LocaleKeys.search_bestMatch.tr(), + style: theme.textStyle.body + .enhanced(color: theme.textColorScheme.secondary), + ), + const VSpace(4), + Column( + children: List.generate(items.length, (index) { + final item = items[index]; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () async { + final view = await ViewBackendService.getView(item.id) + .fold((s) => s, (s) => null); + if (view != null && context.mounted) { + await _goToView(context, view); + } + }, + child: MobileSearchResultCell( + item: item, + query: state.query, + ), + ); + }), + ), + ], + ), + ); + } + + Widget buildNoResult(String query) { + final theme = AppFlowyTheme.of(context), + textColor = theme.textColorScheme.secondary; + return Align( + alignment: Alignment.topCenter, + child: SizedBox( + height: 140, + child: Column( + children: [ + const VSpace(48), + FlowySvg( + FlowySvgs.m_home_search_icon_m, + color: theme.iconColorScheme.secondary, + size: Size.square(24), + ), + const VSpace(12), + Text( + LocaleKeys.search_noResultForSearching.tr(args: [query]), + style: theme.textStyle.body.enhanced(color: textColor), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + LocaleKeys.search_noResultForSearchingHint.tr(), + style: theme.textStyle.caption.standard(color: textColor), + ), + ], + ), + ), + ); + } +} + +Future _goToView(BuildContext context, ViewPB view) async { + await context.pushView( + view, + tabs: [ + PickerTabType.emoji, + PickerTabType.icon, + PickerTabType.custom, + ].map((e) => e.name).toList(), + ); +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/search/mobile_search_textfield.dart b/frontend/appflowy_flutter/lib/mobile/presentation/search/mobile_search_textfield.dart new file mode 100644 index 0000000000..19a30e708e --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/search/mobile_search_textfield.dart @@ -0,0 +1,168 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/mobile_bottom_navigation_bar.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +class MobileSearchTextfield extends StatefulWidget { + const MobileSearchTextfield({ + super.key, + this.onChanged, + required this.hintText, + required this.query, + }); + + final String hintText; + final String query; + final ValueChanged? onChanged; + + @override + State createState() => _MobileSearchTextfieldState(); +} + +class _MobileSearchTextfieldState extends State { + late final TextEditingController controller; + late final FocusNode focusNode; + final ValueNotifier hasFocusValueNotifier = ValueNotifier(true); + + @override + void initState() { + super.initState(); + controller = TextEditingController(text: widget.query); + focusNode = FocusNode(); + focusNode.addListener(() { + if (!mounted) return; + hasFocusValueNotifier.value = focusNode.hasFocus; + }); + controller.addListener(() { + if (!mounted) return; + widget.onChanged?.call(controller.text); + }); + bottomNavigationBarItemType.addListener(onBackOrLeave); + focusNode.requestFocus(); + } + + @override + void dispose() { + controller.dispose(); + focusNode.dispose(); + hasFocusValueNotifier.dispose(); + bottomNavigationBarItemType.removeListener(onBackOrLeave); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return SizedBox( + height: 40, + child: ValueListenableBuilder( + valueListenable: controller, + builder: (context, _, __) { + final hasText = controller.text.isNotEmpty; + return Row( + children: [ + Expanded( + child: TextFormField( + autovalidateMode: AutovalidateMode.onUserInteraction, + autofocus: true, + focusNode: focusNode, + textAlign: TextAlign.left, + controller: controller, + style: TextStyle( + fontSize: 14, + height: 20 / 14, + fontWeight: FontWeight.w400, + color: theme.textColorScheme.primary, + ), + decoration: buildInputDecoration(context), + ), + ), + ValueListenableBuilder( + valueListenable: hasFocusValueNotifier, + builder: (context, hasFocus, __) { + if (!hasFocus || !hasText) return SizedBox.shrink(); + return GestureDetector( + onTap: () => focusNode.unfocus(), + child: Padding( + padding: EdgeInsets.only(left: 8), + child: Text( + LocaleKeys.button_cancel.tr(), + style: theme.textStyle.body + .standard(color: theme.textColorScheme.action), + ), + ), + ); + }, + ), + ], + ); + }, + ), + ); + } + + InputDecoration buildInputDecoration(BuildContext context) { + final theme = AppFlowyTheme.of(context); + final showCancelIcon = controller.text.isNotEmpty; + final border = OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(10.0)), + borderSide: BorderSide(color: theme.borderColorScheme.greyTertiary), + ); + final enableBorder = border.copyWith( + borderSide: BorderSide(color: theme.borderColorScheme.themeThick), + ); + final hintStyle = TextStyle( + fontSize: 14, + height: 20 / 14, + fontWeight: FontWeight.w400, + color: theme.textColorScheme.tertiary, + ); + return InputDecoration( + hintText: widget.hintText, + hintStyle: hintStyle, + contentPadding: const EdgeInsets.fromLTRB(8, 10, 8, 10), + isDense: true, + border: border, + enabledBorder: border, + focusedBorder: enableBorder, + prefixIconConstraints: BoxConstraints.loose(Size(34, 40)), + prefixIcon: Padding( + padding: const EdgeInsets.fromLTRB(8, 10, 4, 10), + child: FlowySvg( + FlowySvgs.m_home_search_icon_m, + color: theme.iconColorScheme.secondary, + size: Size.square(20), + ), + ), + suffixIconConstraints: + showCancelIcon ? BoxConstraints.loose(Size(34, 40)) : null, + suffixIcon: showCancelIcon + ? GestureDetector( + onTap: () { + controller.clear(); + }, + child: Padding( + padding: const EdgeInsets.fromLTRB(4, 10, 8, 10), + child: FlowySvg( + FlowySvgs.clear_s, + blendMode: null, + color: theme.iconColorScheme.tertiary, + size: Size.square(20), + ), + ), + ) + : null, + ); + } + + void onBackOrLeave() { + final label = bottomNavigationBarItemType.value; + if (label == BottomNavigationBarItemType.search.label) { + focusNode.requestFocus(); + } else { + controller.clear(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/search/mobile_view_ancestors.dart b/frontend/appflowy_flutter/lib/mobile/presentation/search/mobile_view_ancestors.dart new file mode 100644 index 0000000000..c40f728f3f --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/search/mobile_view_ancestors.dart @@ -0,0 +1,55 @@ +import 'package:appflowy/startup/startup.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'view_ancestor_cache.dart'; +part 'mobile_view_ancestors.freezed.dart'; + +class ViewAncestorBloc extends Bloc { + ViewAncestorBloc(String viewId) : super(ViewAncestorState.initial(viewId)) { + _cache = getIt(); + _dispatch(); + } + + late final ViewAncestorCache _cache; + + void _dispatch() { + on( + (event, emit) async { + await event.map( + initial: (e) async { + final ancester = await _cache.getAncestor( + state.viewId, + onRefresh: (ancestor) { + if (!emit.isDone) { + emit(state.copyWith(ancestor: ancestor, isLoading: false)); + } + }, + ); + emit(state.copyWith(ancestor: ancester, isLoading: false)); + }, + ); + }, + ); + add(const ViewAncestorEvent.initial()); + } +} + +@freezed +class ViewAncestorEvent with _$ViewAncestorEvent { + const factory ViewAncestorEvent.initial() = Initial; +} + +@freezed +class ViewAncestorState with _$ViewAncestorState { + const factory ViewAncestorState({ + required ViewAncestor ancestor, + required String viewId, + @Default(true) bool isLoading, + }) = _ViewAncestorState; + + factory ViewAncestorState.initial(String viewId) => ViewAncestorState( + viewId: viewId, + ancestor: ViewAncestor.empty(), + ); +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/search/view_ancestor_cache.dart b/frontend/appflowy_flutter/lib/mobile/presentation/search/view_ancestor_cache.dart new file mode 100644 index 0000000000..708f015901 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/search/view_ancestor_cache.dart @@ -0,0 +1,62 @@ +import 'dart:async'; + +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flutter/material.dart'; + +class ViewAncestorCache { + ViewAncestorCache(); + + final Map _ancestors = {}; + + Future getAncestor( + String viewId, { + ValueChanged? onRefresh, + }) async { + final cachedAncestor = _ancestors[viewId]; + if (cachedAncestor != null) { + unawaited(_getAncestor(viewId, onRefresh: onRefresh)); + return cachedAncestor; + } + return _getAncestor(viewId); + } + + Future _getAncestor( + String viewId, { + ValueChanged? onRefresh, + }) async { + final List ancestors = + await ViewBackendService.getViewAncestors(viewId).fold( + (s) => s.items + .where((e) => e.parentViewId.isNotEmpty && e.id != viewId) + .toList(), + (f) => [], + ); + final newAncestors = ViewAncestor( + ancestors: ancestors.map((e) => ViewParent.fromViewPB(e)).toList(), + ); + _ancestors[viewId] = newAncestors; + onRefresh?.call(newAncestors); + return newAncestors; + } +} + +class ViewAncestor { + const ViewAncestor({required this.ancestors}); + + const ViewAncestor.empty() : ancestors = const []; + + final List ancestors; +} + +class ViewParent { + ViewParent({required this.id, required this.name}); + + final String id; + final String name; + + static ViewParent fromViewPB(ViewPB view) => + ViewParent(id: view.id, name: view.nameOrDefault); +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/mobile_chat_input.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/mobile_chat_input.dart index 52904fbcaa..5d65b63151 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/mobile_chat_input.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/mobile_chat_input.dart @@ -1,11 +1,13 @@ import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_input_control_cubit.dart'; import 'package:appflowy/util/theme_extension.dart'; +import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:extended_text_field/extended_text_field.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:universal_platform/universal_platform.dart'; class MobileChatInput extends StatefulWidget { const MobileChatInput({ @@ -44,6 +46,7 @@ class _MobileChatInputState extends State { WidgetsBinding.instance.addPostFrameCallback((_) { focusNode.requestFocus(); + checkForAskingAI(); }); updateSendButtonState(); @@ -181,6 +184,10 @@ class _MobileChatInputState extends State { return; } + onSubmitText(trimmedText); + } + + void onSubmitText(String text) { // get the attached files and mentioned pages final metadata = context.read().consumeMetadata(); @@ -189,12 +196,24 @@ class _MobileChatInputState extends State { final predefinedFormat = bloc.state.predefinedFormat; widget.onSubmitted( - trimmedText, + text, showPredefinedFormats ? predefinedFormat : null, metadata, ); } + void checkForAskingAI() { + if (!UniversalPlatform.isMobile) return; + final paletteBloc = context.read(), + paletteState = paletteBloc?.state; + final isAskingAI = paletteState?.askAI ?? false; + if (!isAskingAI) return; + final query = paletteState?.query ?? ''; + if (query.isEmpty) return; + onSubmitText(query); + paletteBloc?.add(CommandPaletteEvent.askedAI()); + } + void handleTextControllerChanged() { if (textController.value.isComposingRangeValid) { return; diff --git a/frontend/appflowy_flutter/lib/startup/deps_resolver.dart b/frontend/appflowy_flutter/lib/startup/deps_resolver.dart index 5a8c0fa651..c769538b76 100644 --- a/frontend/appflowy_flutter/lib/startup/deps_resolver.dart +++ b/frontend/appflowy_flutter/lib/startup/deps_resolver.dart @@ -1,6 +1,7 @@ import 'package:appflowy/core/config/kv.dart'; import 'package:appflowy/core/network_monitor.dart'; import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/mobile/presentation/search/view_ancestor_cache.dart'; import 'package:appflowy/plugins/document/application/prelude.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/plugins/trash/application/prelude.dart'; @@ -127,6 +128,7 @@ void _resolveUserDeps(GetIt getIt, IntegrationMode mode) { getIt.registerFactory(() => SplashBloc()); getIt.registerLazySingleton(() => NetworkListener()); getIt.registerLazySingleton(() => CachedRecentService()); + getIt.registerLazySingleton(() => ViewAncestorCache()); getIt.registerLazySingleton( () => SubscriptionSuccessListenable(), ); diff --git a/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart b/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart index e64e0f98de..e3886eafbc 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart @@ -13,6 +13,7 @@ import 'package:appflowy/mobile/presentation/favorite/mobile_favorite_page.dart' import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_multiple_select_page.dart'; import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_screen.dart'; import 'package:appflowy/mobile/presentation/presentation.dart'; +import 'package:appflowy/mobile/presentation/search/mobile_search_page.dart'; import 'package:appflowy/mobile/presentation/setting/cloud/appflowy_cloud_page.dart'; import 'package:appflowy/mobile/presentation/setting/font/font_picker_screen.dart'; import 'package:appflowy/mobile/presentation/setting/language/language_picker_screen.dart'; @@ -148,6 +149,16 @@ StatefulShellRoute _mobileHomeScreenWithNavigationBarRoute() { ), ], ), + StatefulShellBranch( + routes: [ + GoRoute( + path: MobileSearchScreen.routeName, + builder: (BuildContext context, GoRouterState state) { + return const MobileSearchScreen(); + }, + ), + ], + ), StatefulShellBranch( routes: [ GoRoute( diff --git a/frontend/appflowy_flutter/lib/workspace/application/command_palette/command_palette_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/command_palette/command_palette_bloc.dart index 27b5db89a2..9cee2cd9e1 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/command_palette/command_palette_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/command_palette/command_palette_bloc.dart @@ -37,6 +37,8 @@ class CommandPaletteBloc on<_TrashChanged>(_onTrashChanged); on<_WorkspaceChanged>(_onWorkspaceChanged); on<_ClearSearch>(_onClearSearch); + on<_GoingToAskAI>(_onGoingToAskAI); + on<_AskedAI>(_onAskedAI); _initTrash(); } @@ -282,6 +284,20 @@ class CommandPaletteBloc emit(CommandPaletteState.initial().copyWith(trash: state.trash)); } + FutureOr _onGoingToAskAI( + _GoingToAskAI event, + Emitter emit, + ) { + emit(state.copyWith(askAI: true)); + } + + FutureOr _onAskedAI( + _AskedAI event, + Emitter emit, + ) { + emit(state.copyWith(askAI: false)); + } + bool _isActiveSearch(String searchId) => !isClosed && state.searchId == searchId; } @@ -311,6 +327,8 @@ class CommandPaletteEvent with _$CommandPaletteEvent { @Default(null) String? workspaceId, }) = _WorkspaceChanged; const factory CommandPaletteEvent.clearSearch() = _ClearSearch; + const factory CommandPaletteEvent.gointToAskAI() = _GoingToAskAI; + const factory CommandPaletteEvent.askedAI() = _AskedAI; } class SearchResultItem { @@ -341,6 +359,7 @@ class CommandPaletteState with _$CommandPaletteState { @Default(null) SearchResponseStream? searchResponseStream, required bool searching, required bool generatingAIOverview, + @Default(false) bool askAI, @Default([]) List trash, @Default(null) String? searchId, }) = _CommandPaletteState; diff --git a/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_ext.dart b/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_ext.dart index 6b6ea6d5c0..a248c4787a 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_ext.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_ext.dart @@ -2,23 +2,27 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; extension GetIcon on ResultIconPB { - Widget? getIcon() { + Widget? getIcon({double size = 18.0, double lineHeight = 1.0}) { final iconValue = value, iconType = ty; if (iconType == ResultIconTypePB.Emoji) { return iconValue.isNotEmpty - ? FlowyText.emoji(iconValue, fontSize: 18) + ? RawEmojiIconWidget( + emoji: EmojiIconData(iconType.toFlowyIconType(), iconValue), + emojiSize: size, + lineHeight: lineHeight, + ) : null; } else if (ty == ResultIconTypePB.Icon) { if (_resultIconValueTypes.contains(iconValue)) { - return FlowySvg(getViewSvg(), size: const Size.square(18)); + return FlowySvg(getViewSvg(), size: Size.square(size)); } return RawEmojiIconWidget( - emoji: EmojiIconData(iconType.toFlowyIconType(), value), - emojiSize: 18, + emoji: EmojiIconData(iconType.toFlowyIconType(), iconValue), + emojiSize: size, + lineHeight: lineHeight, ); } return null; diff --git a/frontend/appflowy_flutter/test/unit_test/search/split_search_test.dart b/frontend/appflowy_flutter/test/unit_test/search/split_search_test.dart new file mode 100644 index 0000000000..f5555d6fa4 --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/search/split_search_test.dart @@ -0,0 +1,72 @@ +import 'package:appflowy/mobile/presentation/search/mobile_search_cell.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Test for searching text with split query', () { + int checkLength(String query, List contents) { + int i = 0; + for (final content in contents) { + if (content.toLowerCase() == query.toLowerCase()) { + i++; + } + } + return i; + } + + test('split with space', () { + final content = 'Hello HELLO hello HeLLo'; + final query = 'Hello'; + final contents = content + .splitIncludeSeparator(query) + .where((e) => e.isNotEmpty) + .toList(); + assert(contents.join() == content); + + assert(checkLength(query, contents) == 4); + }); + + test('split without space', () { + final content = 'HelloHELLOhelloHeLLo'; + final query = 'Hello'; + final contents = content + .splitIncludeSeparator(query) + .where((e) => e.isNotEmpty) + .toList(); + assert(contents.join() == content); + assert(checkLength(query, contents) == 4); + }); + + test('split without space and with error content', () { + final content = 'HellHELLOhelloeLLo'; + final query = 'Hello'; + final contents = content + .splitIncludeSeparator(query) + .where((e) => e.isNotEmpty) + .toList(); + assert(contents.join() == content); + assert(checkLength(query, contents) == 2); + }); + + test('split with space and with error content', () { + final content = 'Hell HELLOhello eLLo'; + final query = 'Hello'; + final contents = content + .splitIncludeSeparator(query) + .where((e) => e.isNotEmpty) + .toList(); + assert(contents.join() == content); + assert(checkLength(query, contents) == 2); + }); + + test('split without longer query', () { + final content = 'Hello'; + final query = 'HelloHelloHelloHello'; + final contents = content + .splitIncludeSeparator(query) + .where((e) => e.isNotEmpty) + .toList(); + assert(contents.join() == content); + assert(checkLength(query, contents) == 0); + }); + }); +} diff --git a/frontend/resources/flowy_icons/24x/m_home_ai_chat_icon.svg b/frontend/resources/flowy_icons/24x/m_home_ai_chat_icon.svg new file mode 100644 index 0000000000..62db92995f --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_home_ai_chat_icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/24x/m_home_search_icon.svg b/frontend/resources/flowy_icons/24x/m_home_search_icon.svg new file mode 100644 index 0000000000..f24fd5e3be --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_home_search_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/24x/m_home_search_icon_active.svg b/frontend/resources/flowy_icons/24x/m_home_search_icon_active.svg new file mode 100644 index 0000000000..2ffa5e99e2 --- /dev/null +++ b/frontend/resources/flowy_icons/24x/m_home_search_icon_active.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 35b595c5ee..ecf27f2f65 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -2321,6 +2321,12 @@ "search": { "label": "Search", "sidebarSearchIcon": "Search and quickly jump to a page", + "searchOrAskAI": "Search or ask AI", + "askAIAnything": "Ask AI anything", + "askAIFor": "Ask AI", + "noResultForSearching": "No result for \"{}\"", + "noResultForSearchingHint": "Some results may be in your deleted pages", + "bestMatch": "Best match", "placeholder": { "actions": "Search actions..." }