mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2025-12-26 14:46:19 +00:00
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:
parent
4067eea89f
commit
aa4f904767
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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())),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
);
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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(),
|
||||
);
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
14
frontend/resources/flowy_icons/24x/m_home_ai_chat_icon.svg
Normal file
14
frontend/resources/flowy_icons/24x/m_home_ai_chat_icon.svg
Normal 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 |
@ -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 |
@ -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 |
@ -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..."
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user