feat: support mobile search (#7804)

* feat: support anon local ai chat/writer

* feat: add mobile search page

* feat: complete mobile search page

* chore: add some tests for mobile search

* fix: some UI issues
This commit is contained in:
Morn 2025-04-23 13:39:16 +08:00 committed by GitHub
parent 4067eea89f
commit aa4f904767
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 1161 additions and 42 deletions

View File

@ -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);
});
});
}

View File

@ -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

View File

@ -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<CachedRecentService>().reset();
mCurrentWorkspace.value = state.currentWorkspace;
if (FeatureFlag.search.isOn) {
// Notify command palette that workspace has changed
context.read<CommandPaletteBloc>().add(
CommandPaletteEvent.workspaceChanged(
workspaceId: state.currentWorkspace?.workspaceId,
),
);
}
Debounce.debounce(
'workspace_action_result',
const Duration(milliseconds: 150),

View File

@ -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),
),
),
),
);
}
}

View File

@ -163,16 +163,16 @@ class _MobileSpaceTabState extends State<MobileSpaceTab>
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(),
),
],
);

View File

@ -28,46 +28,52 @@ final PropertyValueNotifier<ViewLayoutPB?> mobileCreateNewPageNotifier =
PropertyValueNotifier(null);
final ValueNotifier<BottomNavigationBarActionType> bottomNavigationBarType =
ValueNotifier(BottomNavigationBarActionType.home);
final ValueNotifier<String?> 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>[
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<ReminderBloc>().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.

View File

@ -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<CommandPaletteBloc?>()
?.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),
),
],
),
),
);
}
}

View File

@ -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<CommandPaletteBloc>().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<ViewAncestorBloc, ViewAncestorState>(
builder: (context, state) {
final ancestors = state.ancestor.ancestors;
List<String> 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<String> splitIncludeSeparator(String separator) {
final splits =
split(RegExp(RegExp.escape(separator), caseSensitive: false));
final List<String> 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;
}
}

View File

@ -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<AuthService>().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<CommandPaletteBloc, CommandPaletteState>(
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<CommandPaletteBloc>()
.add(CommandPaletteEvent.searchChanged(search: value)),
),
if (enableShowAISearch)
MobileSearchAskAiEntrance(query: state.query),
Flexible(child: SafeArea(child: MobileSearchResult())),
],
),
);
},
);
}
}

View File

@ -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<CommandPaletteBloc>().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<RecentViewsBloc, RecentViewsState>(
builder: (context, state) {
final List<ViewPB> 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<MobileSearchResultList> createState() => _MobileSearchResultListState();
}
class _MobileSearchResultListState extends State<MobileSearchResultList> {
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<CommandPaletteBloc>().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<void> _goToView(BuildContext context, ViewPB view) async {
await context.pushView(
view,
tabs: [
PickerTabType.emoji,
PickerTabType.icon,
PickerTabType.custom,
].map((e) => e.name).toList(),
);
}

View File

@ -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<String>? onChanged;
@override
State<MobileSearchTextfield> createState() => _MobileSearchTextfieldState();
}
class _MobileSearchTextfieldState extends State<MobileSearchTextfield> {
late final TextEditingController controller;
late final FocusNode focusNode;
final ValueNotifier<bool> 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();
}
}
}

View File

@ -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<ViewAncestorEvent, ViewAncestorState> {
ViewAncestorBloc(String viewId) : super(ViewAncestorState.initial(viewId)) {
_cache = getIt<ViewAncestorCache>();
_dispatch();
}
late final ViewAncestorCache _cache;
void _dispatch() {
on<ViewAncestorEvent>(
(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(),
);
}

View File

@ -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<String, ViewAncestor> _ancestors = {};
Future<ViewAncestor> getAncestor(
String viewId, {
ValueChanged<ViewAncestor>? onRefresh,
}) async {
final cachedAncestor = _ancestors[viewId];
if (cachedAncestor != null) {
unawaited(_getAncestor(viewId, onRefresh: onRefresh));
return cachedAncestor;
}
return _getAncestor(viewId);
}
Future<ViewAncestor> _getAncestor(
String viewId, {
ValueChanged<ViewAncestor>? onRefresh,
}) async {
final List<ViewPB> 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<ViewParent> 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);
}

View File

@ -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<MobileChatInput> {
WidgetsBinding.instance.addPostFrameCallback((_) {
focusNode.requestFocus();
checkForAskingAI();
});
updateSendButtonState();
@ -181,6 +184,10 @@ class _MobileChatInputState extends State<MobileChatInput> {
return;
}
onSubmitText(trimmedText);
}
void onSubmitText(String text) {
// get the attached files and mentioned pages
final metadata = context.read<AIPromptInputBloc>().consumeMetadata();
@ -189,12 +196,24 @@ class _MobileChatInputState extends State<MobileChatInput> {
final predefinedFormat = bloc.state.predefinedFormat;
widget.onSubmitted(
trimmedText,
text,
showPredefinedFormats ? predefinedFormat : null,
metadata,
);
}
void checkForAskingAI() {
if (!UniversalPlatform.isMobile) return;
final paletteBloc = context.read<CommandPaletteBloc?>(),
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;

View File

@ -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>(() => SplashBloc());
getIt.registerLazySingleton<NetworkListener>(() => NetworkListener());
getIt.registerLazySingleton<CachedRecentService>(() => CachedRecentService());
getIt.registerLazySingleton<ViewAncestorCache>(() => ViewAncestorCache());
getIt.registerLazySingleton<SubscriptionSuccessListenable>(
() => SubscriptionSuccessListenable(),
);

View File

@ -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: <RouteBase>[
GoRoute(
path: MobileSearchScreen.routeName,
builder: (BuildContext context, GoRouterState state) {
return const MobileSearchScreen();
},
),
],
),
StatefulShellBranch(
routes: <RouteBase>[
GoRoute(

View File

@ -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<void> _onGoingToAskAI(
_GoingToAskAI event,
Emitter<CommandPaletteState> emit,
) {
emit(state.copyWith(askAI: true));
}
FutureOr<void> _onAskedAI(
_AskedAI event,
Emitter<CommandPaletteState> 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<TrashPB> trash,
@Default(null) String? searchId,
}) = _CommandPaletteState;

View File

@ -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;

View File

@ -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<String> 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);
});
});
}

View File

@ -0,0 +1,14 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20.1818 12.3973C20.1818 17.418 16.1116 21.4882 11.0909 21.4882C9.63663 21.4882 8.26215 21.1467 7.04321 20.5396C6.71928 20.3783 6.34902 20.3246 5.99944 20.4181L3.97577 20.9596C3.09728 21.1946 2.29359 20.3909 2.52865 19.5125L3.07011 17.4888C3.16364 17.1392 3.10994 16.7689 2.9486 16.445C2.34147 15.226 2 13.8516 2 12.3973C2 7.37653 6.07013 3.3064 11.0909 3.3064" stroke="url(#paint0_linear_12410_5325)" stroke-width="1.5" stroke-linecap="round"/>
<path d="M16.5672 2.95381C16.8117 2.36433 17.6468 2.36447 17.891 2.95403L18.2097 3.72334C18.6944 4.89331 19.6239 5.82291 20.7938 6.30777L21.5579 6.6244C22.1475 6.86878 22.1474 7.70411 21.5576 7.94829L20.7884 8.26678C19.6173 8.75166 18.6869 9.68215 18.2021 10.8533L17.8877 11.6132C17.6434 12.2032 16.8075 12.2031 16.5635 11.6129L16.2468 10.8471C15.7622 9.67497 14.8312 8.74372 13.6592 8.25872L12.8971 7.94335C12.3072 7.69923 12.307 6.86369 12.8969 6.61937L13.6663 6.30064C14.8361 5.8161 15.7656 4.88696 16.2506 3.71741L16.5672 2.95381Z" fill="url(#paint1_linear_12410_5325)"/>
<defs>
<linearGradient id="paint0_linear_12410_5325" x1="6.94038" y1="17.7702" x2="19.8274" y2="14.106" gradientUnits="userSpaceOnUse">
<stop stop-color="#8032FF"/>
<stop offset="1" stop-color="#FB3DFF"/>
</linearGradient>
<linearGradient id="paint1_linear_12410_5325" x1="10.4259" y1="15.3832" x2="20.8041" y2="11.1784" gradientUnits="userSpaceOnUse">
<stop stop-color="#8032FF"/>
<stop offset="1" stop-color="#FB3DFF"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.4697 22.5304C21.7627 22.8233 22.2375 22.8232 22.5304 22.5303C22.8233 22.2373 22.8232 21.7625 22.5303 21.4696L21.4697 22.5304ZM19.1582 10.9517C19.1582 15.4812 15.4853 19.1535 10.9541 19.1535V20.6535C16.3134 20.6535 20.6582 16.31 20.6582 10.9517L19.1582 10.9517ZM10.9541 19.1535C6.42292 19.1535 2.75 15.4812 2.75 10.9517L1.25 10.9517C1.25 16.31 5.59487 20.6535 10.9541 20.6535V19.1535ZM2.75 10.9517C2.75 6.42223 6.42292 2.75 10.9541 2.75V1.25C5.59487 1.25 1.25 5.59342 1.25 10.9517L2.75 10.9517ZM10.9541 2.75C15.4853 2.75 19.1582 6.42223 19.1582 10.9517L20.6582 10.9517C20.6582 5.59342 16.3134 1.25 10.9541 1.25V2.75ZM16.3531 17.4151L21.4697 22.5304L22.5303 21.4696L17.4136 16.3543L16.3531 17.4151Z" fill="#21232A"/>
</svg>

After

Width:  |  Height:  |  Size: 831 B

View File

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20.9395 23.0608C21.5253 23.6465 22.4751 23.6464 23.0608 23.0605C23.6465 22.4747 23.6464 21.5249 23.0605 20.9392L20.9395 23.0608ZM18.4082 10.9517C18.4082 15.0668 15.0713 18.4035 10.9541 18.4035V21.4035C16.7274 21.4035 21.4082 16.7244 21.4082 10.9517L18.4082 10.9517ZM10.9541 18.4035C6.83695 18.4035 3.5 15.0668 3.5 10.9517L0.5 10.9517C0.5 16.7244 5.18085 21.4035 10.9541 21.4035V18.4035ZM3.5 10.9517C3.5 6.83663 6.83695 3.5 10.9541 3.5V0.5C5.18085 0.5 0.5 5.17902 0.5 10.9517L3.5 10.9517ZM10.9541 3.5C15.0713 3.5 18.4082 6.83663 18.4082 10.9517L21.4082 10.9517C21.4082 5.17902 16.7274 0.5 10.9541 0.5V3.5ZM15.8228 17.9455L20.9395 23.0608L23.0605 20.9392L17.9439 15.8239L15.8228 17.9455Z" fill="#00B5FF"/>
</svg>

After

Width:  |  Height:  |  Size: 817 B

View File

@ -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..."
}