diff --git a/frontend/appflowy_flutter/ios/Podfile.lock b/frontend/appflowy_flutter/ios/Podfile.lock index 86cefebb34..c54ae23ed6 100644 --- a/frontend/appflowy_flutter/ios/Podfile.lock +++ b/frontend/appflowy_flutter/ios/Podfile.lock @@ -175,7 +175,7 @@ SPEC CHECKSUMS: file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655 flowy_infra_ui: 0455e1fa8c51885aa1437848e361e99419f34ebc Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - fluttertoast: 31b00dabfa7fb7bacd9e7dbee580d7a2ff4bf265 + fluttertoast: e9a18c7be5413da53898f660530c56f35edfba9c image_gallery_saver: cb43cc43141711190510e92c460eb1655cd343cb image_picker_ios: 99dfe1854b4fa34d0364e74a78448a0151025425 integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4 diff --git a/frontend/appflowy_flutter/lib/mobile/application/page_style/document_page_style_bloc.dart b/frontend/appflowy_flutter/lib/mobile/application/page_style/document_page_style_bloc.dart index 52552fce3b..20ab2326ca 100644 --- a/frontend/appflowy_flutter/lib/mobile/application/page_style/document_page_style_bloc.dart +++ b/frontend/appflowy_flutter/lib/mobile/application/page_style/document_page_style_bloc.dart @@ -23,6 +23,9 @@ class DocumentPageStyleBloc await event.when( initial: () async { try { + if (view.id.isEmpty) { + return; + } final layoutObject = await ViewBackendService.getView(view.id).fold( (s) => jsonDecode(s.extra), diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/gesture.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/gesture.dart new file mode 100644 index 0000000000..ba4ab3b1db --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/gesture.dart @@ -0,0 +1,54 @@ +import 'package:appflowy/shared/feedback_gesture_detector.dart'; +import 'package:flutter/material.dart'; + +class AnimatedGestureDetector extends StatefulWidget { + const AnimatedGestureDetector({ + super.key, + this.scaleFactor = 0.98, + this.feedback = true, + this.duration = const Duration(milliseconds: 100), + this.alignment = Alignment.center, + this.behavior = HitTestBehavior.opaque, + required this.onTapUp, + required this.child, + }); + + final Widget child; + final double scaleFactor; + final Duration duration; + final Alignment alignment; + final bool feedback; + final HitTestBehavior behavior; + final VoidCallback onTapUp; + + @override + State createState() => + _AnimatedGestureDetectorState(); +} + +class _AnimatedGestureDetectorState extends State { + double scale = 1.0; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: widget.behavior, + onTapUp: (details) { + setState(() => scale = 1.0); + + HapticFeedbackType.vibrate.call(); + + widget.onTapUp(); + }, + onTapDown: (details) { + setState(() => scale = widget.scaleFactor); + }, + child: AnimatedScale( + scale: scale, + alignment: widget.alignment, + duration: widget.duration, + child: widget.child, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_add_new_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_add_new_page.dart index 1a8ff64f2b..3316b7049b 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_add_new_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_add_new_page.dart @@ -24,9 +24,10 @@ class AddNewPageWidgetBottomSheet extends StatelessWidget { height: 52.0, leftIcon: const FlowySvg( FlowySvgs.icon_document_s, - size: Size.square(18), + size: Size.square(20), ), showTopBorder: false, + showBottomBorder: false, onTap: () => onAction(ViewLayoutPB.Document), ), FlowyOptionTile.text( @@ -34,9 +35,10 @@ class AddNewPageWidgetBottomSheet extends StatelessWidget { height: 52.0, leftIcon: const FlowySvg( FlowySvgs.icon_grid_s, - size: Size.square(18), + size: Size.square(20), ), showTopBorder: false, + showBottomBorder: false, onTap: () => onAction(ViewLayoutPB.Grid), ), FlowyOptionTile.text( @@ -44,9 +46,10 @@ class AddNewPageWidgetBottomSheet extends StatelessWidget { height: 52.0, leftIcon: const FlowySvg( FlowySvgs.icon_board_s, - size: Size.square(18), + size: Size.square(20), ), showTopBorder: false, + showBottomBorder: false, onTap: () => onAction(ViewLayoutPB.Board), ), FlowyOptionTile.text( @@ -54,9 +57,10 @@ class AddNewPageWidgetBottomSheet extends StatelessWidget { height: 52.0, leftIcon: const FlowySvg( FlowySvgs.icon_calendar_s, - size: Size.square(18), + size: Size.square(20), ), showTopBorder: false, + showBottomBorder: false, onTap: () => onAction(ViewLayoutPB.Calendar), ), FlowyOptionTile.text( @@ -64,9 +68,10 @@ class AddNewPageWidgetBottomSheet extends StatelessWidget { height: 52.0, leftIcon: const FlowySvg( FlowySvgs.chat_ai_page_s, - size: Size.square(18), + size: Size.square(20), ), showTopBorder: false, + showBottomBorder: false, onTap: () => onAction(ViewLayoutPB.Chat), ), ], diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item.dart index 75b0151a3a..b76dc63b1d 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item.dart @@ -1,4 +1,3 @@ -import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/widgets/show_flowy_mobile_confirm_dialog.dart'; @@ -6,6 +5,7 @@ import 'package:appflowy/startup/tasks/app_widget.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/recent/recent_views_bloc.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; @@ -109,16 +109,6 @@ class _MobileViewItemBottomSheetState extends State { await _showConfirmDialog( onDelete: () { recentViewsBloc.add(RecentViewsEvent.removeRecentViews([viewId])); - - fToast.showToast( - child: const _RemoveToast(), - positionedToastBuilder: (context, child) { - return Positioned.fill( - top: 450, - child: child, - ); - }, - ); }, ); } @@ -136,38 +126,14 @@ class _MobileViewItemBottomSheetState extends State { ), onRightButtonPressed: (context) { onDelete(); + Navigator.pop(context); + + showToastNotification( + context, + message: LocaleKeys.sideBar_removeSuccess.tr(), + ); }, ); } } - -class _RemoveToast extends StatelessWidget { - const _RemoveToast(); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 13.0), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12.0), - color: const Color(0xE5171717), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const FlowySvg( - FlowySvgs.success_s, - blendMode: null, - ), - const HSpace(8.0), - FlowyText.regular( - LocaleKeys.sideBar_removeSuccess.tr(), - fontSize: 16.0, - color: Colors.white, - ), - ], - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/default_mobile_action_pane.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/default_mobile_action_pane.dart index 1583e9e2e0..e0b10d153b 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/default_mobile_action_pane.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/default_mobile_action_pane.dart @@ -7,6 +7,7 @@ import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/recent/recent_views_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; @@ -40,18 +41,32 @@ enum MobilePaneActionType { backgroundColor: const Color(0xFFFA217F), svg: FlowySvgs.favorite_section_remove_from_favorite_s, size: 24.0, - onPressed: (context) => context - .read() - .add(FavoriteEvent.toggle(context.read().view)), + onPressed: (context) { + showToastNotification( + context, + message: LocaleKeys.button_unfavoriteSuccessfully.tr(), + ); + + context + .read() + .add(FavoriteEvent.toggle(context.read().view)); + }, ); case MobilePaneActionType.addToFavorites: return MobileSlideActionButton( backgroundColor: const Color(0xFF00C8FF), svg: FlowySvgs.favorite_s, size: 24.0, - onPressed: (context) => context - .read() - .add(FavoriteEvent.toggle(context.read().view)), + onPressed: (context) { + showToastNotification( + context, + message: LocaleKeys.button_favoriteSuccessfully.tr(), + ); + + context + .read() + .add(FavoriteEvent.toggle(context.read().view)); + }, ); case MobilePaneActionType.add: return MobileSlideActionButton( @@ -69,6 +84,7 @@ enum MobilePaneActionType { showDragHandle: true, showCloseButton: true, useRootNavigator: true, + showDivider: false, backgroundColor: Theme.of(context).colorScheme.surface, builder: (sheetContext) { return AddNewPageWidgetBottomSheet( @@ -145,8 +161,6 @@ enum MobilePaneActionType { ? MobileViewItemBottomSheetBodyAction.removeFromFavorites : MobileViewItemBottomSheetBodyAction.addToFavorites, MobileViewItemBottomSheetBodyAction.divider, - if (view.layout != ViewLayoutPB.Chat) - MobileViewItemBottomSheetBodyAction.duplicate, MobileViewItemBottomSheetBodyAction.divider, MobileViewItemBottomSheetBodyAction.removeFromRecent, ]; @@ -156,7 +170,6 @@ enum MobilePaneActionType { ? MobileViewItemBottomSheetBodyAction.removeFromFavorites : MobileViewItemBottomSheetBodyAction.addToFavorites, MobileViewItemBottomSheetBodyAction.divider, - MobileViewItemBottomSheetBodyAction.duplicate, ]; } } @@ -181,12 +194,13 @@ ActionPane buildEndActionPane( bool needSpace = true, MobilePageCardType? cardType, FolderSpaceType? spaceType, + required double spaceRatio, }) { return ActionPane( motion: const ScrollMotion(), - extentRatio: actions.length / 5, + extentRatio: actions.length / spaceRatio, children: [ - if (needSpace) const HSpace(20), + if (needSpace) const HSpace(60), ...actions.map( (action) => action.actionButton( context, diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart index be815b6550..9af49e98c8 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart @@ -70,6 +70,7 @@ Future showMobileBottomSheet( backgroundColor ??= Theme.of(context).brightness == Brightness.light ? const Color(0xFFF7F8FB) : const Color(0xFF23262B); + barrierColor ??= Colors.black.withOpacity(0.3); return showModalBottomSheet( context: context, @@ -226,10 +227,14 @@ class BottomSheetHeader extends StatelessWidget { ), ), Align( - child: FlowyText( - title, - fontSize: 16.0, - fontWeight: FontWeight.w500, + child: Container( + constraints: const BoxConstraints(maxWidth: 250), + child: FlowyText( + title, + fontSize: 17.0, + fontWeight: FontWeight.w500, + overflow: TextOverflow.ellipsis, + ), ), ), if (showDoneButton) diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/favorite_folder/favorite_space.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/favorite_folder/favorite_space.dart index ded486983e..36e6c57e85 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/favorite_folder/favorite_space.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/favorite_folder/favorite_space.dart @@ -100,8 +100,6 @@ class _FavoriteViews extends StatelessWidget { child: ListView.separated( key: const PageStorageKey('favorite_views_page_storage_key'), padding: EdgeInsets.only( - left: HomeSpaceViewSizes.mHorizontalPadding, - right: HomeSpaceViewSizes.mHorizontalPadding, bottom: HomeSpaceViewSizes.mVerticalPadding + MediaQuery.of(context).padding.bottom, ), diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/favorite_folder/mobile_home_favorite_folder.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/favorite_folder/mobile_home_favorite_folder.dart index 57ac43f255..1efee460eb 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/favorite_folder/mobile_home_favorite_folder.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/favorite_folder/mobile_home_favorite_folder.dart @@ -72,6 +72,7 @@ class MobileFavoriteFolder extends StatelessWidget { MobilePaneActionType.more, ], spaceType: FolderSpaceType.favorite, + spaceRatio: 5, ), ), ), diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/home_space/home_space.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/home_space/home_space.dart index 965c396d42..02e5fce9ab 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/home_space/home_space.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/home_space/home_space.dart @@ -29,8 +29,6 @@ class _MobileHomeSpaceState extends State child: SingleChildScrollView( child: Padding( padding: EdgeInsets.only( - left: HomeSpaceViewSizes.mHorizontalPadding, - right: HomeSpaceViewSizes.mHorizontalPadding, top: HomeSpaceViewSizes.mVerticalPadding, bottom: HomeSpaceViewSizes.mVerticalPadding + MediaQuery.of(context).padding.bottom, diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_folders.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_folders.dart index 64ad7e4cd1..c9588981ce 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_folders.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_folders.dart @@ -9,6 +9,7 @@ import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; @@ -91,7 +92,12 @@ class MobileFolders extends StatelessWidget { children: [ ..._buildSpaceOrSection(context, state), const VSpace(4.0), - const _TrashButton(), + const Padding( + padding: EdgeInsets.symmetric( + horizontal: HomeSpaceViewSizes.mHorizontalPadding, + ), + child: _TrashButton(), + ), ], ), ); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_header.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_header.dart index 28d0915aef..01f43ca87b 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_header.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_header.dart @@ -1,5 +1,6 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/gesture.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/home/mobile_home_setting_page.dart'; import 'package:appflowy/mobile/presentation/home/workspaces/workspace_menu_bottom_sheet.dart'; @@ -113,8 +114,9 @@ class _MobileWorkspace extends StatelessWidget { if (currentWorkspace == null) { return const SizedBox.shrink(); } - return GestureDetector( - onTap: () { + return AnimatedGestureDetector( + alignment: Alignment.centerLeft, + onTapUp: () { context.read().add( const UserWorkspaceEvent.fetchWorkspaces(), ); @@ -143,7 +145,7 @@ class _MobileWorkspace extends StatelessWidget { : const HSpace(8), FlowyText.semibold( currentWorkspace.name, - fontSize: 16.0, + fontSize: 20.0, overflow: TextOverflow.ellipsis, ), ], @@ -162,9 +164,10 @@ class _MobileWorkspace extends StatelessWidget { showHeader: true, showDragHandle: true, showCloseButton: true, + useRootNavigator: true, title: LocaleKeys.workspace_menuTitle.tr(), backgroundColor: Theme.of(context).colorScheme.surface, - builder: (_) { + builder: (sheetContext) { return BlocProvider.value( value: context.read(), child: BlocBuilder( @@ -179,7 +182,7 @@ class _MobileWorkspace extends StatelessWidget { currentWorkspace: currentWorkspace, workspaces: workspaces, onWorkspaceSelected: (workspace) { - context.pop(); + Navigator.of(sheetContext).pop(); if (workspace == currentWorkspace) { return; diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/recent_space.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/recent_space.dart index f3e807ec9c..e06506936c 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/recent_space.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/recent_space.dart @@ -72,8 +72,6 @@ class _RecentViews extends StatelessWidget { child: ListView.separated( key: const PageStorageKey('recent_views_page_storage_key'), padding: EdgeInsets.only( - left: HomeSpaceViewSizes.mHorizontalPadding, - right: HomeSpaceViewSizes.mHorizontalPadding, bottom: HomeSpaceViewSizes.mVerticalPadding + MediaQuery.of(context).padding.bottom, ), diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/section_folder/mobile_home_section_folder.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/section_folder/mobile_home_section_folder.dart index 4cd212f5a9..f0f121df6b 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/section_folder/mobile_home_section_folder.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/section_folder/mobile_home_section_folder.dart @@ -82,6 +82,7 @@ class MobileSectionFolder extends StatelessWidget { ], spaceType: spaceType, needSpace: false, + spaceRatio: 5, ); }, ), diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/shared/mobile_page_card.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/shared/mobile_page_card.dart index 71be20453d..85cdc98c4a 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/shared/mobile_page_card.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/shared/mobile_page_card.dart @@ -5,6 +5,7 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/mobile_router.dart'; import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/mobile/application/recent/recent_view_bloc.dart'; +import 'package:appflowy/mobile/presentation/base/gesture.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/shared/appflowy_network_image.dart'; @@ -15,6 +16,7 @@ import 'package:appflowy/workspace/application/settings/appearance/appearance_cu import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart'; import 'package:appflowy/workspace/application/settings/date_time/time_format_ext.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; @@ -76,13 +78,14 @@ class MobileViewPage extends StatelessWidget { : MobilePaneActionType.addToFavorites, ], cardType: type, + spaceRatio: 4, ), - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTapUp: (_) => context.pushView(view), + child: AnimatedGestureDetector( + onTapUp: () => context.pushView(view), child: Row( mainAxisSize: MainAxisSize.min, children: [ + const HSpace(HomeSpaceViewSizes.mHorizontalPadding), Expanded(child: _buildDescription(context, state)), const HSpace(20.0), SizedBox( @@ -90,6 +93,7 @@ class MobileViewPage extends StatelessWidget { height: 60, child: _buildCover(context, state), ), + const HSpace(HomeSpaceViewSizes.mHorizontalPadding), ], ), ), @@ -211,7 +215,7 @@ class MobileViewPage extends StatelessWidget { Widget _buildLastViewed(BuildContext context) { final textColor = Theme.of(context).isLightMode - ? const Color(0xFF171717) + ? const Color(0x7F171717) : Colors.white.withOpacity(0.45); if (timestamp == null) { return const SizedBox.shrink(); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space.dart index 48ef17e86c..296d305a81 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space.dart @@ -50,23 +50,17 @@ class _MobileSpaceState extends State { MobileSpaceHeader( isExpanded: state.isExpanded, space: currentSpace, - onAdded: () { - context.read().add( - SpaceEvent.createPage( - name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(), - layout: ViewLayoutPB.Document, - index: 0, - ), - ); - context.read().add( - SpaceEvent.expand(currentSpace, true), - ); - }, + onAdded: () => _showCreatePageMenu(currentSpace), onPressed: () => _showSpaceMenu(context), ), - _Pages( - key: ValueKey(currentSpace.id), - space: currentSpace, + Padding( + padding: const EdgeInsets.only( + left: HomeSpaceViewSizes.mHorizontalPadding, + ), + child: _Pages( + key: ValueKey(currentSpace.id), + space: currentSpace, + ), ), ], ); @@ -82,6 +76,7 @@ class _MobileSpaceState extends State { showDragHandle: true, showCloseButton: true, showDoneButton: true, + useRootNavigator: true, title: LocaleKeys.space_title.tr(), backgroundColor: Theme.of(context).colorScheme.surface, builder: (_) { @@ -104,6 +99,38 @@ class _MobileSpaceState extends State { ), ); } + + void _showCreatePageMenu(ViewPB space) { + final title = space.name; + showMobileBottomSheet( + context, + showHeader: true, + title: title, + showDragHandle: true, + showCloseButton: true, + useRootNavigator: true, + showDivider: false, + backgroundColor: Theme.of(context).colorScheme.surface, + builder: (sheetContext) { + return AddNewPageWidgetBottomSheet( + view: space, + onAction: (layout) { + Navigator.of(sheetContext).pop(); + context.read().add( + SpaceEvent.createPage( + name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(), + layout: layout, + index: 0, + ), + ); + context.read().add( + SpaceEvent.expand(space, true), + ); + }, + ); + }, + ); + } } class _Pages extends StatelessWidget { @@ -148,7 +175,7 @@ class _Pages extends StatelessWidget { MobilePaneActionType.add, ], spaceType: spaceType, - needSpace: false, + spaceRatio: 4, ); }, ), diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_header.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_header.dart index ffc1691404..004b527b78 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_header.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_header.dart @@ -1,4 +1,5 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; @@ -30,6 +31,7 @@ class MobileSpaceHeader extends StatelessWidget { height: 48, child: Row( children: [ + const HSpace(HomeSpaceViewSizes.mHorizontalPadding), SpaceIcon( dimension: 24, space: space, @@ -49,8 +51,15 @@ class MobileSpaceHeader extends StatelessWidget { GestureDetector( behavior: HitTestBehavior.translucent, onTap: onAdded, - child: const FlowySvg( - FlowySvgs.m_space_add_s, + child: Container( + // expand the touch area + margin: const EdgeInsets.symmetric( + horizontal: HomeSpaceViewSizes.mHorizontalPadding, + vertical: 8.0, + ), + child: const FlowySvg( + FlowySvgs.m_space_add_s, + ), ), ), ], diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_menu.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_menu.dart index 6788e1396d..e45270f634 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_menu.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_menu.dart @@ -59,6 +59,7 @@ class _SidebarSpaceMenuItem extends StatelessWidget { children: [ FlowyText.medium( space.name, + fontSize: 16.0, ), const HSpace(6.0), if (space.spacePermission == SpacePermission.private) @@ -68,16 +69,17 @@ class _SidebarSpaceMenuItem extends StatelessWidget { ), ], ), + margin: const EdgeInsets.symmetric(horizontal: 12.0), iconPadding: 10, leftIcon: SpaceIcon( dimension: 24, space: space, cornerRadius: 6.0, ), - leftIconSize: const Size.square(20), + leftIconSize: const Size.square(24), rightIcon: isSelected ? const FlowySvg( - FlowySvgs.workspace_selected_s, + FlowySvgs.m_blue_check_s, blendMode: null, ) : null, diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/workspaces/workspace_menu_bottom_sheet.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/workspaces/workspace_menu_bottom_sheet.dart index 17962bcc3c..9c79f5b20b 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/workspaces/workspace_menu_bottom_sheet.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/workspaces/workspace_menu_bottom_sheet.dart @@ -138,17 +138,20 @@ class _WorkspaceMenuItem extends StatelessWidget { height: 60, showTopBorder: showTopBorder, showBottomBorder: false, - leftIcon: WorkspaceIcon( - enableEdit: false, - iconSize: 26, - fontSize: 16.0, - workspace: workspace, - onSelected: (result) => context.read().add( - UserWorkspaceEvent.updateWorkspaceIcon( - workspace.workspaceId, - result.emoji, + leftIcon: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: WorkspaceIcon( + enableEdit: false, + iconSize: 26, + fontSize: 16.0, + workspace: workspace, + onSelected: (result) => context.read().add( + UserWorkspaceEvent.updateWorkspaceIcon( + workspace.workspaceId, + result.emoji, + ), ), - ), + ), ), trailing: workspace.workspaceId == currentWorkspace.workspaceId ? const FlowySvg( diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/mobile_bottom_navigation_bar.dart b/frontend/appflowy_flutter/lib/mobile/presentation/mobile_bottom_navigation_bar.dart index 5d33dfa500..f2c22e00dc 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/mobile_bottom_navigation_bar.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/mobile_bottom_navigation_bar.dart @@ -50,6 +50,9 @@ class MobileBottomNavigationBar extends StatelessWidget { final backgroundColor = isLightMode ? Colors.white.withOpacity(0.95) : const Color(0xFF23262B).withOpacity(0.95); + final borderColor = isLightMode + ? const Color(0x141F2329) + : const Color(0xFF23262B).withOpacity(0.5); return Scaffold( body: navigationShell, extendBody: true, @@ -62,9 +65,7 @@ class MobileBottomNavigationBar extends StatelessWidget { child: DecoratedBox( decoration: BoxDecoration( border: isLightMode - ? Border( - top: BorderSide(color: Theme.of(context).dividerColor), - ) + ? Border(top: BorderSide(color: borderColor)) : null, color: backgroundColor, ), diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/page_item/mobile_view_item.dart b/frontend/appflowy_flutter/lib/mobile/presentation/page_item/mobile_view_item.dart index 6ef969bb0b..443e2cbbc3 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/page_item/mobile_view_item.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/page_item/mobile_view_item.dart @@ -300,7 +300,7 @@ class _SingleMobileInnerViewItemState extends State { ) : Opacity( opacity: 0.7, - child: widget.view.defaultIcon(), + child: widget.view.defaultIcon(size: const Size.square(18)), ); return SizedBox(width: 18.0, child: icon); } diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_input_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_input_bloc.dart index 4b229db0ef..8a6bdf55be 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_input_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_input_bloc.dart @@ -13,9 +13,9 @@ class ChatInputBloc extends Bloc { : listener = LocalLLMListener(), super(const ChatInputState(aiType: _AppFlowyAI())) { listener.start( - stateCallback: (pluginState) { + chatStateCallback: (aiState) { if (!isClosed) { - add(ChatInputEvent.updateState(pluginState)); + add(ChatInputEvent.updateState(aiState)); } }, ); @@ -37,18 +37,26 @@ class ChatInputBloc extends Bloc { ) async { await event.when( started: () async { - final result = await ChatEventGetLocalAIPluginState().send(); + final result = await ChatEventGetLocalAIChatState().send(); result.fold( - (pluginState) { + (aiState) { if (!isClosed) { - add(ChatInputEvent.updateState(pluginState)); + add( + ChatInputEvent.updateState(aiState), + ); } }, - (err) => Log.error(err.toString()), + (err) { + Log.error(err.toString()); + }, ); }, - updateState: (LocalAIPluginStatePB aiPluginState) { - emit(const ChatInputState(aiType: _AppFlowyAI())); + updateState: (aiState) { + if (aiState.enabled) { + emit(const ChatInputState(aiType: _LocalAI())); + } else { + emit(const ChatInputState(aiType: _AppFlowyAI())); + } }, ); } @@ -57,8 +65,8 @@ class ChatInputBloc extends Bloc { @freezed class ChatInputEvent with _$ChatInputEvent { const factory ChatInputEvent.started() = _Started; - const factory ChatInputEvent.updateState(LocalAIPluginStatePB aiPluginState) = - _UpdatePluginState; + const factory ChatInputEvent.updateState(LocalAIChatPB aiState) = + _UpdateAIState; } @freezed diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart index 1fe467cd70..acb78faba5 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart @@ -23,7 +23,7 @@ import 'package:flutter_chat_types/flutter_chat_types.dart'; import 'package:flutter_chat_types/flutter_chat_types.dart' as types; import 'package:flutter_chat_ui/flutter_chat_ui.dart' show Chat; -import 'presentation/chat_input.dart'; +import 'presentation/chat_input/chat_input.dart'; import 'presentation/chat_popmenu.dart'; import 'presentation/chat_theme.dart'; import 'presentation/chat_user_invalid_message.dart'; @@ -82,7 +82,9 @@ class AIChatPage extends StatelessWidget { userProfile: userProfile, )..add(const ChatEvent.initialLoad()), ), - BlocProvider(create: (_) => ChatInputBloc()), + BlocProvider( + create: (_) => ChatInputBloc()..add(const ChatInputEvent.started()), + ), ], child: BlocListener( listenWhen: (previous, current) => @@ -391,36 +393,39 @@ class _ChatContentPageState extends State<_ChatContentPage> { padding: AIChatUILayout.safeAreaInsets(context), child: BlocBuilder( builder: (context, state) { - return state.aiType.when( - appflowyAI: () => Column( - children: [ - BlocSelector( - selector: (state) => state.streamingStatus, - builder: (context, state) { - return ChatInput( - chatId: widget.view.id, - onSendPressed: (message) => - onSendPressed(context, message.text), - isStreaming: state != const LoadingState.finish(), - onStopStreaming: () { - context - .read() - .add(const ChatEvent.stopStream()); - }, - ); - }, + final hintText = state.aiType.when( + appflowyAI: () => LocaleKeys.chat_inputMessageHint.tr(), + localAI: () => LocaleKeys.chat_inputLocalAIMessageHint.tr(), + ); + + return Column( + children: [ + BlocSelector( + selector: (state) => state.streamingStatus, + builder: (context, state) { + return ChatInput( + chatId: widget.view.id, + onSendPressed: (message) => + onSendPressed(context, message.text), + isStreaming: state != const LoadingState.finish(), + onStopStreaming: () { + context + .read() + .add(const ChatEvent.stopStream()); + }, + hintText: hintText, + ); + }, + ), + const VSpace(6), + Opacity( + opacity: 0.6, + child: FlowyText( + LocaleKeys.chat_aiMistakePrompt.tr(), + fontSize: 12, ), - const VSpace(6), - Opacity( - opacity: 0.6, - child: FlowyText( - LocaleKeys.chat_aiMistakePrompt.tr(), - fontSize: 12, - ), - ), - ], - ), - localAI: () => const SizedBox.shrink(), + ), + ], ); }, ), diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/ai_message_bubble.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/ai_message_bubble.dart index afea319d10..5ce43f8182 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/ai_message_bubble.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/ai_message_bubble.dart @@ -4,7 +4,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart'; import 'package:appflowy/plugins/ai_chat/presentation/chat_avatar.dart'; -import 'package:appflowy/plugins/ai_chat/presentation/chat_input.dart'; +import 'package:appflowy/plugins/ai_chat/presentation/chat_input/chat_input.dart'; import 'package:appflowy/plugins/ai_chat/presentation/chat_popmenu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/shared/markdown_to_document.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_inline_action_menu.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_inline_action_menu.dart new file mode 100644 index 0000000000..d6d85b3b35 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_inline_action_menu.dart @@ -0,0 +1,237 @@ +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +abstract class ChatActionMenuItem { + String get title; +} + +abstract class ChatActionHandler { + List get items; + void onEnter(); + void onSelected(ChatActionMenuItem item); + void onExit(); +} + +abstract class ChatAnchor { + GlobalKey get anchorKey; + LayerLink get layerLink; +} + +const int _itemHeight = 34; +const int _itemVerticalPadding = 4; + +class ChatActionsMenu { + ChatActionsMenu({ + required this.anchor, + required this.context, + required this.handler, + required this.style, + }); + + final BuildContext context; + final ChatAnchor anchor; + final ChatActionsMenuStyle style; + final ChatActionHandler handler; + + OverlayEntry? _overlayEntry; + + void dismiss() { + _overlayEntry?.remove(); + _overlayEntry = null; + handler.onExit(); + } + + void show() { + WidgetsBinding.instance.addPostFrameCallback((_) => _show()); + } + + void _show() { + if (_overlayEntry != null) { + dismiss(); + } + + if (anchor.anchorKey.currentContext == null) { + return; + } + + handler.onEnter(); + + final height = handler.items.length * (_itemHeight + _itemVerticalPadding); + _overlayEntry = OverlayEntry( + builder: (context) => Stack( + children: [ + CompositedTransformFollower( + link: anchor.layerLink, + showWhenUnlinked: false, + offset: Offset(0, -height - 4), + child: Material( + elevation: 4.0, + child: ConstrainedBox( + constraints: const BoxConstraints( + minWidth: 200, + maxWidth: 200, + maxHeight: 200, + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: + Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6.0), + ), + child: ActionList( + handler: handler, + onDismiss: () => dismiss(), + ), + ), + ), + ), + ), + ], + ), + ); + + Overlay.of(context).insert(_overlayEntry!); + } +} + +class _ActionItem extends StatelessWidget { + const _ActionItem({ + required this.item, + required this.onTap, + required this.isSelected, + }); + + final ChatActionMenuItem item; + final VoidCallback? onTap; + final bool isSelected; + + @override + Widget build(BuildContext context) { + return Container( + height: _itemHeight.toDouble(), + padding: const EdgeInsets.symmetric(vertical: _itemVerticalPadding / 2.0), + decoration: BoxDecoration( + color: isSelected + ? Theme.of(context).colorScheme.primary.withOpacity(0.1) + : Colors.transparent, + borderRadius: BorderRadius.circular(4.0), + ), + child: FlowyButton( + margin: const EdgeInsets.symmetric(horizontal: 6), + iconPadding: 10.0, + text: FlowyText.regular( + item.title, + ), + onTap: onTap, + ), + ); + } +} + +class ActionList extends StatefulWidget { + const ActionList({super.key, required this.handler, required this.onDismiss}); + + final ChatActionHandler handler; + final VoidCallback? onDismiss; + + @override + State createState() => _ActionListState(); +} + +class _ActionListState extends State { + final FocusScopeNode _focusNode = + FocusScopeNode(debugLabel: 'ChatActionsMenu'); + int _selectedIndex = 0; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusNode.requestFocus(); + }); + } + + @override + void dispose() { + _focusNode.dispose(); + super.dispose(); + } + + void _handleKeyPress(event) { + setState(() { + // ignore: deprecated_member_use + if (event is KeyDownEvent || event is RawKeyDownEvent) { + if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + _selectedIndex = (_selectedIndex + 1) % widget.handler.items.length; + } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + _selectedIndex = (_selectedIndex - 1 + widget.handler.items.length) % + widget.handler.items.length; + } else if (event.logicalKey == LogicalKeyboardKey.enter) { + widget.handler.onSelected(widget.handler.items[_selectedIndex]); + widget.onDismiss?.call(); + } else if (event.logicalKey == LogicalKeyboardKey.escape) { + widget.onDismiss?.call(); + } + } + }); + } + + @override + Widget build(BuildContext context) { + return FocusScope( + node: _focusNode, + onKey: (node, event) { + _handleKeyPress(event); + return KeyEventResult.handled; + }, + child: ListView( + shrinkWrap: true, + padding: const EdgeInsets.all(8), + children: widget.handler.items.asMap().entries.map((entry) { + final index = entry.key; + final ChatActionMenuItem item = entry.value; + return _ActionItem( + item: item, + onTap: () { + widget.handler.onSelected(item); + widget.onDismiss?.call(); + }, + isSelected: _selectedIndex == index, + ); + }).toList(), + ), + ); + } +} + +class ChatActionsMenuStyle { + ChatActionsMenuStyle({ + required this.backgroundColor, + required this.groupTextColor, + required this.menuItemTextColor, + required this.menuItemSelectedColor, + required this.menuItemSelectedTextColor, + }); + + const ChatActionsMenuStyle.light() + : backgroundColor = Colors.white, + groupTextColor = const Color(0xFF555555), + menuItemTextColor = const Color(0xFF333333), + menuItemSelectedColor = const Color(0xFFE0F8FF), + menuItemSelectedTextColor = const Color.fromARGB(255, 56, 91, 247); + + const ChatActionsMenuStyle.dark() + : backgroundColor = const Color(0xFF282E3A), + groupTextColor = const Color(0xFFBBC3CD), + menuItemTextColor = const Color(0xFFBBC3CD), + menuItemSelectedColor = const Color(0xFF00BCF0), + menuItemSelectedTextColor = const Color(0xFF131720); + + final Color backgroundColor; + final Color groupTextColor; + final Color menuItemTextColor; + final Color menuItemSelectedColor; + final Color menuItemSelectedTextColor; +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_accessory_button.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_accessory_button.dart new file mode 100644 index 0000000000..ae4d980da6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_accessory_button.dart @@ -0,0 +1,48 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/icon_button.dart'; +import 'package:flutter/material.dart'; + +class ChatInputAccessoryButton extends StatelessWidget { + const ChatInputAccessoryButton({ + required this.onSendPressed, + required this.onStopStreaming, + required this.isStreaming, + super.key, + }); + + final void Function() onSendPressed; + final void Function() onStopStreaming; + final bool isStreaming; + + @override + Widget build(BuildContext context) { + if (isStreaming) { + return FlowyIconButton( + width: 36, + icon: FlowySvg( + FlowySvgs.ai_stream_stop_s, + size: const Size.square(28), + color: Theme.of(context).colorScheme.primary, + ), + onPressed: onStopStreaming, + radius: BorderRadius.circular(18), + fillColor: AFThemeExtension.of(context).lightGreyHover, + hoverColor: AFThemeExtension.of(context).lightGreyHover, + ); + } else { + return FlowyIconButton( + width: 36, + fillColor: AFThemeExtension.of(context).lightGreyHover, + hoverColor: AFThemeExtension.of(context).lightGreyHover, + radius: BorderRadius.circular(18), + icon: FlowySvg( + FlowySvgs.send_s, + size: const Size.square(24), + color: Theme.of(context).colorScheme.primary, + ), + onPressed: onSendPressed, + ); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_command.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_command.dart new file mode 100644 index 0000000000..06e2361c57 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_command.dart @@ -0,0 +1,76 @@ +import 'package:appflowy/plugins/ai_chat/presentation/chat_inline_action_menu.dart'; +import 'package:flutter/material.dart'; + +class ChatTextFieldInterceptor { + String previosText = ""; + + ChatActionHandler? onTextChanged( + String text, + TextEditingController textController, + FocusNode textFieldFocusNode, + ) { + if (previosText == "/" && text == "/ ") { + final handler = IndexActionHandler( + textController: textController, + textFieldFocusNode: textFieldFocusNode, + ) as ChatActionHandler; + return handler; + } + previosText = text; + return null; + } +} + +class FixGrammarMenuItem extends ChatActionMenuItem { + @override + String get title => "Fix Grammar"; +} + +class ImproveWritingMenuItem extends ChatActionMenuItem { + @override + String get title => "Improve Writing"; +} + +class ChatWithFileMenuItem extends ChatActionMenuItem { + @override + String get title => "Chat With PDF"; +} + +class IndexActionHandler extends ChatActionHandler { + IndexActionHandler({ + required this.textController, + required this.textFieldFocusNode, + }); + + final TextEditingController textController; + final FocusNode textFieldFocusNode; + + @override + List get items => [ + ChatWithFileMenuItem(), + FixGrammarMenuItem(), + ImproveWritingMenuItem(), + ]; + + @override + void onSelected(ChatActionMenuItem item) { + textController.clear(); + WidgetsBinding.instance.addPostFrameCallback( + (_) => textFieldFocusNode.requestFocus(), + ); + } + + @override + void onExit() { + if (!textFieldFocusNode.hasFocus) { + textFieldFocusNode.requestFocus(); + } + } + + @override + void onEnter() { + if (textFieldFocusNode.hasFocus) { + textFieldFocusNode.unfocus(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_input.dart similarity index 66% rename from frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input.dart rename to frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_input.dart index 3f6846b8b5..f230c423ca 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_input.dart @@ -1,14 +1,13 @@ -import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:easy_localization/easy_localization.dart'; +import 'package:appflowy/plugins/ai_chat/presentation/chat_inline_action_menu.dart'; import 'package:flowy_infra/theme_extension.dart'; -import 'package:flowy_infra_ui/style_widget/icon_button.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_chat_types/flutter_chat_types.dart' as types; import 'package:flutter_chat_ui/flutter_chat_ui.dart'; +import 'chat_accessory_button.dart'; + class ChatInput extends StatefulWidget { /// Creates [ChatInput] widget. const ChatInput({ @@ -20,6 +19,7 @@ class ChatInput extends StatefulWidget { this.options = const InputOptions(), required this.isStreaming, required this.onStopStreaming, + required this.hintText, }); final bool? isAttachmentUploading; @@ -29,6 +29,7 @@ class ChatInput extends StatefulWidget { final InputOptions options; final String chatId; final bool isStreaming; + final String hintText; @override State createState() => _ChatInputState(); @@ -36,6 +37,11 @@ class ChatInput extends StatefulWidget { /// [ChatInput] widget state. class _ChatInputState extends State { + final GlobalKey _textFieldKey = GlobalKey(); + final LayerLink _layerLink = LayerLink(); + // final ChatTextFieldInterceptor _textFieldInterceptor = + // ChatTextFieldInterceptor(); + late final _inputFocusNode = FocusNode( onKeyEvent: (node, event) { if (event.physicalKey == PhysicalKeyboardKey.enter && @@ -59,9 +65,9 @@ class _ChatInputState extends State { } }, ); + late TextEditingController _textController; bool _sendButtonVisible = false; - late TextEditingController _textController; @override void initState() { @@ -71,33 +77,15 @@ class _ChatInputState extends State { _handleSendButtonVisibilityModeChange(); } - void _handleSendButtonVisibilityModeChange() { - _textController.removeListener(_handleTextControllerChange); - _sendButtonVisible = - _textController.text.trim() != '' || widget.isStreaming; - _textController.addListener(_handleTextControllerChange); + @override + void dispose() { + _inputFocusNode.dispose(); + _textController.dispose(); + super.dispose(); } - void _handleSendPressed() { - final trimmedText = _textController.text.trim(); - if (trimmedText != '') { - final partialText = types.PartialText(text: trimmedText); - widget.onSendPressed(partialText); - - _textController.clear(); - } - } - - void _handleTextControllerChange() { - if (_textController.value.isComposingRangeValid) { - return; - } - setState(() { - _sendButtonVisible = _textController.text.trim() != ''; - }); - } - - Widget _inputBuilder() { + @override + Widget build(BuildContext context) { const textPadding = EdgeInsets.symmetric(horizontal: 16, vertical: 6); const buttonPadding = EdgeInsets.symmetric(horizontal: 16, vertical: 6); const inputPadding = EdgeInsets.all(6); @@ -128,27 +116,78 @@ class _ChatInputState extends State { ); } - Padding _inputTextField(EdgeInsets textPadding) { - return Padding( - padding: textPadding, - child: TextField( - controller: _textController, - focusNode: _inputFocusNode, - decoration: InputDecoration( - border: InputBorder.none, - hintText: LocaleKeys.chat_inputMessageHint.tr(), - hintStyle: TextStyle( - color: AFThemeExtension.of(context).textColor.withOpacity(0.5), + void _handleSendButtonVisibilityModeChange() { + _textController.removeListener(_handleTextControllerChange); + _sendButtonVisible = + _textController.text.trim() != '' || widget.isStreaming; + _textController.addListener(_handleTextControllerChange); + } + + void _handleSendPressed() { + final trimmedText = _textController.text.trim(); + if (trimmedText != '') { + final partialText = types.PartialText(text: trimmedText); + widget.onSendPressed(partialText); + + _textController.clear(); + } + } + + void _handleTextControllerChange() { + if (_textController.value.isComposingRangeValid) { + return; + } + setState(() { + _sendButtonVisible = _textController.text.trim() != ''; + }); + } + + Widget _inputTextField(EdgeInsets textPadding) { + return CompositedTransformTarget( + link: _layerLink, + child: Padding( + padding: textPadding, + child: TextField( + key: _textFieldKey, + controller: _textController, + focusNode: _inputFocusNode, + decoration: InputDecoration( + border: InputBorder.none, + hintText: widget.hintText, + hintStyle: TextStyle( + color: AFThemeExtension.of(context).textColor.withOpacity(0.5), + ), ), + style: TextStyle( + color: AFThemeExtension.of(context).textColor, + ), + keyboardType: TextInputType.multiline, + textCapitalization: TextCapitalization.sentences, + maxLines: 10, + minLines: 1, + // onChanged: (text) { + // final handler = _textFieldInterceptor.onTextChanged( + // text, + // _textController, + // _inputFocusNode, + // ); + // // If the handler is not null, it means that the text has been + // // recognized as a command. + // if (handler != null) { + // ChatActionsMenu( + // anchor: ChatInputAnchor( + // anchorKey: _textFieldKey, + // layerLink: _layerLink, + // ), + // handler: handler, + // context: context, + // style: Theme.of(context).brightness == Brightness.dark + // ? const ChatActionsMenuStyle.dark() + // : const ChatActionsMenuStyle.light(), + // ).show(); + // } + // }, ), - style: TextStyle( - color: AFThemeExtension.of(context).textColor, - ), - keyboardType: TextInputType.multiline, - textCapitalization: TextCapitalization.sentences, - maxLines: 10, - minLines: 1, - onChanged: (_) {}, ), ); } @@ -162,16 +201,14 @@ class _ChatInputState extends State { visible: _sendButtonVisible, child: Padding( padding: buttonPadding, - child: AccessoryButton( + child: ChatInputAccessoryButton( onSendPressed: () { if (!widget.isStreaming) { widget.onStopStreaming(); _handleSendPressed(); } }, - onStopStreaming: () { - widget.onStopStreaming(); - }, + onStopStreaming: () => widget.onStopStreaming(), isStreaming: widget.isStreaming, ), ), @@ -184,64 +221,20 @@ class _ChatInputState extends State { super.didUpdateWidget(oldWidget); _handleSendButtonVisibilityModeChange(); } - - @override - void dispose() { - _inputFocusNode.dispose(); - _textController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) => GestureDetector( - onTap: () => _inputFocusNode.requestFocus(), - child: _inputBuilder(), - ); } final isMobile = defaultTargetPlatform == TargetPlatform.android || defaultTargetPlatform == TargetPlatform.iOS; -class AccessoryButton extends StatelessWidget { - const AccessoryButton({ - required this.onSendPressed, - required this.onStopStreaming, - required this.isStreaming, - super.key, +class ChatInputAnchor extends ChatAnchor { + ChatInputAnchor({ + required this.anchorKey, + required this.layerLink, }); - final void Function() onSendPressed; - final void Function() onStopStreaming; - final bool isStreaming; + @override + final GlobalKey> anchorKey; @override - Widget build(BuildContext context) { - if (isStreaming) { - return FlowyIconButton( - width: 36, - icon: FlowySvg( - FlowySvgs.ai_stream_stop_s, - size: const Size.square(28), - color: Theme.of(context).colorScheme.primary, - ), - onPressed: onStopStreaming, - radius: BorderRadius.circular(18), - fillColor: AFThemeExtension.of(context).lightGreyHover, - hoverColor: AFThemeExtension.of(context).lightGreyHover, - ); - } else { - return FlowyIconButton( - width: 36, - fillColor: AFThemeExtension.of(context).lightGreyHover, - hoverColor: AFThemeExtension.of(context).lightGreyHover, - radius: BorderRadius.circular(18), - icon: FlowySvg( - FlowySvgs.send_s, - size: const Size.square(24), - color: Theme.of(context).colorScheme.primary, - ), - onPressed: onSendPressed, - ); - } - } + final LayerLink layerLink; } diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_welcome_page.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_welcome_page.dart index 6a819e8d53..6576d13446 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_welcome_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_welcome_page.dart @@ -5,7 +5,7 @@ import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; -import 'chat_input.dart'; +import 'chat_input/chat_input.dart'; class ChatWelcomePage extends StatelessWidget { ChatWelcomePage({required this.onSelectedQuestion, super.key}); diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_markdown_text.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_markdown_text.dart index 38fe4a8807..ff7cb6bafe 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_markdown_text.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_markdown_text.dart @@ -1,11 +1,14 @@ +import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_configuration.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/shared/markdown_to_document.dart'; import 'package:appflowy/util/theme_extension.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:markdown_widget/markdown_widget.dart'; import 'selectable_highlight.dart'; @@ -30,7 +33,11 @@ class AIMarkdownText extends StatelessWidget { Widget build(BuildContext context) { switch (type) { case AIMarkdownType.appflowyEditor: - return _AppFlowyEditorMarkdown(markdown: markdown); + return BlocProvider( + create: (context) => DocumentPageStyleBloc(view: ViewPB()) + ..add(const DocumentPageStyleEvent.initial()), + child: _AppFlowyEditorMarkdown(markdown: markdown), + ); case AIMarkdownType.markdownWidget: return _ThirdPartyMarkdown(markdown: markdown); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart index 43539cea96..d1b550eded 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart @@ -90,19 +90,22 @@ class _DocumentImmersiveCoverState extends State { ); } - return Stack( - children: [ - _buildCover(context, state), - Positioned( - left: 0, - right: 0, - bottom: 0, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 24.0), - child: iconAndTitle, + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Stack( + children: [ + _buildCover(context, state), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 24.0), + child: iconAndTitle, + ), ), - ), - ], + ], + ), ); }, ), diff --git a/frontend/appflowy_flutter/lib/shared/feature_flags.dart b/frontend/appflowy_flutter/lib/shared/feature_flags.dart index 9cb1db4f00..bc87836659 100644 --- a/frontend/appflowy_flutter/lib/shared/feature_flags.dart +++ b/frontend/appflowy_flutter/lib/shared/feature_flags.dart @@ -4,7 +4,6 @@ import 'package:appflowy/core/config/kv.dart'; import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:collection/collection.dart'; -import 'package:flutter/foundation.dart'; typedef FeatureFlagMap = Map; diff --git a/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart b/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart index 31d610e278..e8b52ee4be 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart @@ -1,8 +1,5 @@ import 'dart:io'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - import 'package:appflowy/mobile/application/mobile_router.dart'; import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/shared/feature_flags.dart'; @@ -24,8 +21,11 @@ import 'package:appflowy_editor/appflowy_editor.dart' hide Log; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:toastification/toastification.dart'; import 'prelude.dart'; @@ -197,31 +197,33 @@ class _ApplicationWidgetState extends State { child: BlocBuilder( builder: (context, state) { _setSystemOverlayStyle(state); - return MaterialApp.router( - builder: (context, child) => MediaQuery( - // use the 1.0 as the textScaleFactor to avoid the text size - // affected by the system setting. - data: MediaQuery.of(context).copyWith( - textScaler: TextScaler.linear(state.textScaleFactor), - ), - child: overlayManagerBuilder( - context, - !PlatformExtension.isMobile && FeatureFlag.search.isOn - ? CommandPalette( - notifier: _commandPaletteNotifier, - child: child, - ) - : child, + return ToastificationWrapper( + child: MaterialApp.router( + builder: (context, child) => MediaQuery( + // use the 1.0 as the textScaleFactor to avoid the text size + // affected by the system setting. + data: MediaQuery.of(context).copyWith( + textScaler: TextScaler.linear(state.textScaleFactor), + ), + child: overlayManagerBuilder( + context, + !PlatformExtension.isMobile && FeatureFlag.search.isOn + ? CommandPalette( + notifier: _commandPaletteNotifier, + child: child, + ) + : child, + ), ), + debugShowCheckedModeBanner: false, + theme: state.lightTheme, + darkTheme: state.darkTheme, + themeMode: state.themeMode, + localizationsDelegates: context.localizationDelegates, + supportedLocales: context.supportedLocales, + locale: state.locale, + routerConfig: routerConfig, ), - debugShowCheckedModeBanner: false, - theme: state.lightTheme, - darkTheme: state.darkTheme, - themeMode: state.themeMode, - localizationsDelegates: context.localizationDelegates, - supportedLocales: context.supportedLocales, - locale: state.locale, - routerConfig: routerConfig, ); }, ), diff --git a/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart b/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart index 3dc87b0d78..0d6c8f7826 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart @@ -48,7 +48,7 @@ class ViewExtKeys { } extension ViewExtension on ViewPB { - Widget defaultIcon() => FlowySvg( + Widget defaultIcon({Size? size}) => FlowySvg( switch (layout) { ViewLayoutPB.Board => FlowySvgs.icon_board_s, ViewLayoutPB.Calendar => FlowySvgs.icon_calendar_s, @@ -57,6 +57,7 @@ extension ViewExtension on ViewPB { ViewLayoutPB.Chat => FlowySvgs.chat_ai_page_s, _ => FlowySvgs.document_s, }, + size: size, ); PluginType get pluginType => switch (layout) { diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart index 188303c3f7..8a99783c11 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart @@ -1,8 +1,8 @@ -import 'package:flutter/material.dart'; - +import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/tasks/app_widget.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; @@ -11,6 +11,7 @@ import 'package:flowy_infra_ui/widget/buttons/primary_button.dart'; import 'package:flowy_infra_ui/widget/buttons/secondary_button.dart'; import 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; import 'package:toastification/toastification.dart'; export 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart'; @@ -303,6 +304,18 @@ void showToastNotification( String? description, ToastificationType type = ToastificationType.success, }) { + if (PlatformExtension.isMobile) { + toastification.showCustom( + alignment: Alignment.bottomCenter, + autoCloseDuration: const Duration(milliseconds: 3000), + builder: (_, __) => _MToast( + message: message, + type: type, + ), + ); + return; + } + toastification.show( context: context, type: type, @@ -329,6 +342,50 @@ void showToastNotification( ); } +class _MToast extends StatelessWidget { + const _MToast({ + required this.message, + this.type = ToastificationType.success, + }); + + final String message; + final ToastificationType type; + + @override + Widget build(BuildContext context) { + // only support success type + assert(type == ToastificationType.success); + + return Container( + alignment: Alignment.bottomCenter, + padding: const EdgeInsets.only(bottom: 100), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 13.0), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12.0), + color: const Color(0xE5171717), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const FlowySvg( + FlowySvgs.success_s, + blendMode: null, + ), + const HSpace(8.0), + FlowyText.regular( + message, + fontSize: 16.0, + figmaLineHeight: 18.0, + color: Colors.white, + ), + ], + ), + ), + ); + } +} + Future showConfirmDeletionDialog({ required BuildContext context, required String name, diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index 8011ba4a3c..9cf8451f3f 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -53,8 +53,8 @@ packages: dependency: "direct main" description: path: "." - ref: "268aae9" - resolved-ref: "268aae905b18efc8a3a9c88dc75ebd19b314bd43" + ref: aac7729 + resolved-ref: aac77292a1a175fd7450eef30167032d3cec7fea url: "https://github.com/AppFlowy-IO/appflowy-editor.git" source: git version: "3.1.0" @@ -1670,10 +1670,10 @@ packages: dependency: transitive description: name: qr - sha256: "64957a3930367bf97cc211a5af99551d630f2f4625e38af10edd6b19131b64b3" + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" realtime_client: dependency: transitive description: @@ -2443,5 +2443,5 @@ packages: source: hosted version: "2.0.0" sdks: - dart: ">=3.3.0 <4.0.0" + dart: ">=3.4.0 <4.0.0" flutter: ">=3.22.0" diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index 07f3dd331e..4d526e7461 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -194,7 +194,7 @@ dependency_overrides: appflowy_editor: git: url: https://github.com/AppFlowy-IO/appflowy-editor.git - ref: "268aae9" + ref: "aac7729" appflowy_editor_plugins: git: diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 5c91a45b47..2cdddd9c03 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -159,6 +159,7 @@ "chat": { "newChat": "AI Chat", "inputMessageHint": "Message @:appName AI", + "inputLocalAIMessageHint": "Message @:appName Local AI", "unsupportedCloudPrompt": "This feature is only available when using @:appName Cloud", "relatedQuestion": "Related", "serverUnavailable": "Service Temporarily Unavailable. Please try again later.", @@ -276,7 +277,7 @@ "justNow": "just now", "minutesAgo": "{count} minutes ago", "lastViewed": "Last viewed", - "favoriteAt": "Favorited at", + "favoriteAt": "Favorited", "emptyRecent": "No Recent Documents", "emptyRecentDescription": "As you view documents, they will appear here for easy retrieval", "emptyFavorite": "No Favorite Documents", @@ -337,6 +338,8 @@ "removeFromFavorites": "Remove from favorites", "removeFromRecent": "Remove from recent", "addToFavorites": "Add to favorites", + "favoriteSuccessfully": "Favorited success", + "unfavoriteSuccessfully": "Unfavorited success", "rename": "Rename", "helpCenter": "Help Center", "add": "Add",