From 5b1afeb85d96293e3a56dc8e9c9a6d80585d6da2 Mon Sep 17 00:00:00 2001 From: Mathias Mogensen <42929161+Xazin@users.noreply.github.com> Date: Wed, 12 Jul 2023 13:13:18 +0200 Subject: [PATCH] feat: open apps in tabs (#2962) * feat: open apps in tabs Closes: #2942 Relates: #2312 * fix: resolve comments * fix: unfocus editor to close toolbar on open/change tab * test: abstract open in a new tab helper --- .../integration_test/tabs_test.dart | 69 ++++++++++ .../util/common_operations.dart | 8 ++ .../lib/plugins/blank/blank.dart | 3 + .../database_view/tar_bar/tab_bar_view.dart | 4 + .../lib/plugins/document/document.dart | 4 + .../base/built_in_page_widget.dart | 8 +- .../lib/plugins/trash/menu.dart | 9 +- .../lib/plugins/trash/trash.dart | 3 + .../lib/startup/deps_resolver.dart | 7 +- .../workspace/application/tabs/tabs_bloc.dart | 57 +++++++++ .../application/tabs/tabs_event.dart | 14 +++ .../application/tabs/tabs_state.dart | 93 ++++++++++++++ .../presentation/home/home_screen.dart | 32 +++-- .../presentation/home/home_sizes.dart | 2 + .../presentation/home/home_stack.dart | 118 +++++++++++++----- .../home/menu/app/section/item.dart | 15 ++- .../home/menu/app/section/section.dart | 14 +-- .../presentation/home/menu/menu.dart | 10 +- .../presentation/home/navigation.dart | 15 +-- .../presentation/home/tabs/flowy_tab.dart | 92 ++++++++++++++ .../presentation/home/tabs/tabs_manager.dart | 103 +++++++++++++++ .../presentation/widgets/tab_bar_item.dart | 46 +++++++ frontend/resources/translations/en.json | 3 +- 23 files changed, 654 insertions(+), 75 deletions(-) create mode 100644 frontend/appflowy_flutter/integration_test/tabs_test.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_bloc.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_event.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_state.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/home/tabs/flowy_tab.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/home/tabs/tabs_manager.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/widgets/tab_bar_item.dart diff --git a/frontend/appflowy_flutter/integration_test/tabs_test.dart b/frontend/appflowy_flutter/integration_test/tabs_test.dart new file mode 100644 index 0000000000..6f8249c3de --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/tabs_test.dart @@ -0,0 +1,69 @@ +import 'package:appflowy/workspace/presentation/home/tabs/flowy_tab.dart'; +import 'package:appflowy/workspace/presentation/home/tabs/tabs_manager.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import 'util/base.dart'; +import 'util/common_operations.dart'; + +const _readmeName = 'Read me'; +const _documentName = 'Document'; +const _calendarName = 'Calendar'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('Tabs', () { + testWidgets('Open AppFlowy and open/navigate multiple tabs', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapGoButton(); + + expect( + find.descendant( + of: find.byType(TabsManager), + matching: find.byType(TabBar), + ), + findsNothing, + ); + + await tester.createNewPageWithName(ViewLayoutPB.Calendar, _calendarName); + await tester.createNewPageWithName(ViewLayoutPB.Document, _documentName); + + // Navigate current view to "Read me" document again + await tester.tapButtonWithName(_readmeName); + + /// Open second menu item in a new tab + await tester.openAppInNewTab(_calendarName); + + /// Open third menu item in a new tab + await tester.openAppInNewTab(_documentName); + + expect( + find.descendant( + of: find.byType(TabsManager), + matching: find.byType(TabBar), + ), + findsOneWidget, + ); + + expect( + find.descendant( + of: find.byType(TabBar), + matching: find.byType(FlowyTab), + ), + findsNWidgets(3), + ); + + /// Navigate to the first tab + await tester.tap( + find.descendant( + of: find.byType(FlowyTab), + matching: find.text(_readmeName), + ), + ); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/util/common_operations.dart b/frontend/appflowy_flutter/integration_test/util/common_operations.dart index 524f6cd61a..0ac505eb07 100644 --- a/frontend/appflowy_flutter/integration_test/util/common_operations.dart +++ b/frontend/appflowy_flutter/integration_test/util/common_operations.dart @@ -276,6 +276,14 @@ extension CommonOperations on WidgetTester { } await pumpAndSettle(); } + + Future openAppInNewTab(String name) async { + await hoverOnPageName(name); + await tap(find.byType(ViewDisclosureButton)); + await pumpAndSettle(); + await tap(find.text(LocaleKeys.disclosureAction_openNewTab.tr())); + await pumpAndSettle(); + } } extension ViewLayoutPBTest on ViewLayoutPB { diff --git a/frontend/appflowy_flutter/lib/plugins/blank/blank.dart b/frontend/appflowy_flutter/lib/plugins/blank/blank.dart index afd1ef5545..5c18eab7ac 100644 --- a/frontend/appflowy_flutter/lib/plugins/blank/blank.dart +++ b/frontend/appflowy_flutter/lib/plugins/blank/blank.dart @@ -42,6 +42,9 @@ class BlankPagePluginWidgetBuilder extends PluginWidgetBuilder @override Widget get leftBarItem => FlowyText.medium(LocaleKeys.blankPageTitle.tr()); + @override + Widget tabBarItem(String pluginId) => leftBarItem; + @override Widget buildWidget({PluginContext? context}) => const BlankPage(); diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/tar_bar/tab_bar_view.dart b/frontend/appflowy_flutter/lib/plugins/database_view/tar_bar/tab_bar_view.dart index 4a5e6a0cb2..f6841cda83 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/tar_bar/tab_bar_view.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/tar_bar/tab_bar_view.dart @@ -7,6 +7,7 @@ import 'package:appflowy/workspace/presentation/home/home_stack.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/left_bar_item.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy/workspace/presentation/widgets/tab_bar_item.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -210,6 +211,9 @@ class DatabasePluginWidgetBuilder extends PluginWidgetBuilder { @override Widget get leftBarItem => ViewLeftBarItem(view: notifier.view); + @override + Widget tabBarItem(String pluginId) => ViewTabBarItem(view: notifier.view); + @override Widget buildWidget({PluginContext? context}) { notifier.isDeleted.addListener(() { diff --git a/frontend/appflowy_flutter/lib/plugins/document/document.dart b/frontend/appflowy_flutter/lib/plugins/document/document.dart index 85b0de6602..01686c7874 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/document.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/document.dart @@ -9,6 +9,7 @@ import 'package:appflowy/plugins/util.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/workspace/presentation/home/home_stack.dart'; import 'package:appflowy/workspace/presentation/widgets/left_bar_item.dart'; +import 'package:appflowy/workspace/presentation/widgets/tab_bar_item.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; import 'package:flutter/material.dart'; @@ -104,6 +105,9 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder @override Widget get leftBarItem => ViewLeftBarItem(view: view); + @override + Widget tabBarItem(String pluginId) => ViewTabBarItem(view: notifier.view); + @override Widget? get rightBarItem { return Row( diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/built_in_page_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/built_in_page_widget.dart index 82a1b9016f..8305cdbee4 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/built_in_page_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/built_in_page_widget.dart @@ -1,5 +1,6 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pbserver.dart'; @@ -13,7 +14,6 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/image.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; -import 'package:appflowy/workspace/presentation/home/home_stack.dart'; import 'package:appflowy/workspace/presentation/home/menu/menu.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flowy_infra_ui/style_widget/icon_button.dart'; @@ -156,7 +156,11 @@ class _BuiltInPageWidgetState extends State { case _ActionType.viewDatabase: getIt().latestOpenView = viewPB; - getIt().setPlugin(viewPB.plugin()); + getIt().add( + TabsEvent.openPlugin( + plugin: viewPB.plugin(), + ), + ); break; case _ActionType.delete: final transaction = widget.editorState.transaction; diff --git a/frontend/appflowy_flutter/lib/plugins/trash/menu.dart b/frontend/appflowy_flutter/lib/plugins/trash/menu.dart index 8708aa7563..4f2400a5e8 100644 --- a/frontend/appflowy_flutter/lib/plugins/trash/menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/trash/menu.dart @@ -1,6 +1,6 @@ import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/startup/startup.dart'; -import 'package:appflowy/workspace/presentation/home/home_stack.dart'; +import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/presentation/home/menu/menu.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; @@ -31,8 +31,11 @@ class MenuTrash extends StatelessWidget { child: InkWell( onTap: () { getIt().latestOpenView = null; - getIt() - .setPlugin(makePlugin(pluginType: PluginType.trash)); + getIt().add( + TabsEvent.openPlugin( + plugin: makePlugin(pluginType: PluginType.trash), + ), + ); }, child: _render(context), ), diff --git a/frontend/appflowy_flutter/lib/plugins/trash/trash.dart b/frontend/appflowy_flutter/lib/plugins/trash/trash.dart index 768a660ed2..4bebbbbfaf 100644 --- a/frontend/appflowy_flutter/lib/plugins/trash/trash.dart +++ b/frontend/appflowy_flutter/lib/plugins/trash/trash.dart @@ -51,6 +51,9 @@ class TrashPluginDisplay extends PluginWidgetBuilder { @override Widget get leftBarItem => FlowyText.medium(LocaleKeys.trash_text.tr()); + @override + Widget tabBarItem(String pluginId) => leftBarItem; + @override Widget? get rightBarItem => null; diff --git a/frontend/appflowy_flutter/lib/startup/deps_resolver.dart b/frontend/appflowy_flutter/lib/startup/deps_resolver.dart index b99546022c..ede07fd44e 100644 --- a/frontend/appflowy_flutter/lib/startup/deps_resolver.dart +++ b/frontend/appflowy_flutter/lib/startup/deps_resolver.dart @@ -11,6 +11,7 @@ import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/user/application/auth/supabase_auth_service.dart'; import 'package:appflowy/user/application/user_listener.dart'; import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:flowy_infra/file_picker/file_picker_impl.dart'; import 'package:flowy_infra/file_picker/file_picker_service.dart'; import 'package:appflowy/plugins/document/application/prelude.dart'; @@ -23,7 +24,6 @@ import 'package:appflowy/workspace/application/settings/prelude.dart'; import 'package:appflowy/user/application/prelude.dart'; import 'package:appflowy/user/presentation/router.dart'; import 'package:appflowy/plugins/trash/application/prelude.dart'; -import 'package:appflowy/workspace/presentation/home/home_stack.dart'; import 'package:appflowy/workspace/presentation/home/menu/menu.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; @@ -108,9 +108,6 @@ void _resolveHomeDeps(GetIt getIt) { (user, _) => UserListener(userProfile: user), ); - // - getIt.registerLazySingleton(() => HomeStackManager()); - getIt.registerFactoryParam( (user, _) => WelcomeBloc( userService: UserBackendService(userId: user.id), @@ -122,6 +119,8 @@ void _resolveHomeDeps(GetIt getIt) { getIt.registerFactoryParam( (view, _) => DocShareBloc(view: view), ); + + getIt.registerLazySingleton(() => TabsBloc()); } void _resolveFolderDeps(GetIt getIt) { diff --git a/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_bloc.dart new file mode 100644 index 0000000000..51e2d63476 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_bloc.dart @@ -0,0 +1,57 @@ +import 'package:appflowy/plugins/util.dart'; +import 'package:appflowy/startup/plugin/plugin.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/presentation/home/home_stack.dart'; +import 'package:appflowy/workspace/presentation/home/menu/menu.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; +import 'package:bloc/bloc.dart'; +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'tabs_event.dart'; +part 'tabs_state.dart'; +part 'tabs_bloc.freezed.dart'; + +class TabsBloc extends Bloc { + late final MenuSharedState menuSharedState; + + TabsBloc() : super(TabsState()) { + menuSharedState = getIt(); + + on((event, emit) async { + event.when( + selectTab: (int index) { + if (index != state.currentIndex) { + emit(state.copyWith(newIndex: index)); + _setLatestOpenView(); + } + }, + moveTab: () {}, + closeTab: (String pluginId) { + emit(state.closeView(pluginId)); + _setLatestOpenView(); + }, + openTab: (Plugin plugin, ViewPB view) { + emit(state.openView(plugin, view)); + _setLatestOpenView(view); + }, + openPlugin: (Plugin plugin, ViewPB? view) { + emit(state.openPlugin(plugin: plugin)); + _setLatestOpenView(view); + }, + ); + }); + } + + void _setLatestOpenView([ViewPB? view]) { + if (view != null) { + menuSharedState.latestOpenView = view; + } else { + final pageManager = state.currentPageManager; + final notifier = pageManager.plugin.notifier; + if (notifier is ViewPluginNotifier) { + menuSharedState.latestOpenView = notifier.view; + } + } + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_event.dart b/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_event.dart new file mode 100644 index 0000000000..b3e6dcc39f --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_event.dart @@ -0,0 +1,14 @@ +part of 'tabs_bloc.dart'; + +@freezed +class TabsEvent with _$TabsEvent { + const factory TabsEvent.moveTab() = _MoveTab; + const factory TabsEvent.closeTab(String pluginId) = _CloseTab; + const factory TabsEvent.selectTab(int index) = _SelectTab; + const factory TabsEvent.openTab({ + required Plugin plugin, + required ViewPB view, + }) = _OpenTab; + const factory TabsEvent.openPlugin({required Plugin plugin, ViewPB? view}) = + _OpenPlugin; +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_state.dart b/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_state.dart new file mode 100644 index 0000000000..5b34b3bac0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_state.dart @@ -0,0 +1,93 @@ +part of 'tabs_bloc.dart'; + +class TabsState { + final int currentIndex; + + final List _pageManagers; + int get pages => _pageManagers.length; + PageManager get currentPageManager => _pageManagers[currentIndex]; + List get pageManagers => _pageManagers; + + TabsState({ + this.currentIndex = 0, + List? pageManagers, + }) : _pageManagers = pageManagers ?? [PageManager()]; + + /// This opens a new tab given a [Plugin] and a [View]. + /// + /// If the [Plugin.id] is already associated with an open tab, + /// then it selects that tab. + /// + TabsState openView(Plugin plugin, ViewPB view) { + final selectExistingPlugin = _selectPluginIfOpen(plugin.id); + + if (selectExistingPlugin == null) { + _pageManagers.add(PageManager()..setPlugin(plugin)); + + return copyWith(newIndex: pages - 1, pageManagers: [..._pageManagers]); + } + + return selectExistingPlugin; + } + + TabsState closeView(String pluginId) { + _pageManagers.removeWhere((pm) => pm.plugin.id == pluginId); + + /// If currentIndex is greater than the amount of allowed indices + /// And the current selected tab isn't the first (index 0) + /// as currentIndex cannot be -1 + /// Then decrease currentIndex by 1 + final newIndex = currentIndex > pages - 1 && currentIndex > 0 + ? currentIndex - 1 + : currentIndex; + + return copyWith( + newIndex: newIndex, + pageManagers: [..._pageManagers], + ); + } + + /// This opens a plugin in the current selected tab, + /// due to how Document currently works, only one tab + /// per plugin can currently be active. + /// + /// If the plugin is already open in a tab, then that tab + /// will become selected. + /// + TabsState openPlugin({required Plugin plugin}) { + final selectExistingPlugin = _selectPluginIfOpen(plugin.id); + + if (selectExistingPlugin == null) { + final pageManagers = [..._pageManagers]; + pageManagers[currentIndex].setPlugin(plugin); + + return copyWith(pageManagers: pageManagers); + } + + return selectExistingPlugin; + } + + /// Checks if a [Plugin.id] is already associated with an open tab. + /// Returns a [TabState] with new index if there is a match. + /// + /// If no match it returns null + /// + TabsState? _selectPluginIfOpen(String id) { + final index = _pageManagers.indexWhere((pm) => pm.plugin.id == id); + + if (index == -1) { + return null; + } + + return copyWith(newIndex: index); + } + + TabsState copyWith({ + int? newIndex, + List? pageManagers, + }) => + TabsState( + currentIndex: newIndex ?? currentIndex, + pageManagers: pageManagers ?? _pageManagers, + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/home_screen.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/home_screen.dart index 392cf22b7e..29266927d7 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/home_screen.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/home_screen.dart @@ -5,6 +5,7 @@ import 'package:appflowy/workspace/application/appearance.dart'; import 'package:appflowy/workspace/application/home/home_bloc.dart'; import 'package:appflowy/workspace/application/home/home_service.dart'; import 'package:appflowy/workspace/application/home/home_setting_bloc.dart'; +import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/home/hotkeys.dart'; import 'package:appflowy/workspace/presentation/widgets/edit_panel/panel_animation.dart'; @@ -39,6 +40,7 @@ class _HomeScreenState extends State { Widget build(BuildContext context) { return MultiBlocProvider( providers: [ + BlocProvider.value(value: getIt()), BlocProvider( create: (context) { return HomeBloc(widget.user, widget.workspaceSetting) @@ -74,14 +76,18 @@ class _HomeScreenState extends State { listener: (context, state) { final view = state.latestView; if (view != null) { - // Only open the last opened view if the [HomeStackManager] current opened plugin is blank and the last opened view is not null. + // Only open the last opened view if the [TabsState.currentPageManager] current opened plugin is blank and the last opened view is not null. // All opened widgets that display on the home screen are in the form of plugins. There is a list of built-in plugins defined in the [PluginType] enum, including board, grid and trash. - if (getIt().plugin.pluginType == + final currentPageManager = + context.read().state.currentPageManager; + + if (currentPageManager.plugin.pluginType == PluginType.blank) { - getIt().setPlugin( - view.plugin(listenOnViewChanged: true), + getIt().add( + TabsEvent.openPlugin( + plugin: view.plugin(listenOnViewChanged: true), + ), ); - getIt().latestOpenView = view; } } }, @@ -275,18 +281,22 @@ class HomeScreenStackAdaptor extends HomeStackDelegate { (parentView) { final List views = parentView.childViews; if (views.isNotEmpty) { - var lastView = views.last; + ViewPB lastView = views.last; if (index != null && index != 0 && views.length > index - 1) { lastView = views[index - 1]; } - getIt().latestOpenView = lastView; - getIt().setPlugin( - lastView.plugin(listenOnViewChanged: true), + getIt().add( + TabsEvent.openPlugin( + plugin: lastView.plugin(listenOnViewChanged: true), + ), ); } else { - getIt().latestOpenView = null; - getIt().setPlugin(BlankPagePlugin()); + getIt().add( + TabsEvent.openPlugin( + plugin: BlankPagePlugin(), + ), + ); } }, (err) => Log.error(err), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/home_sizes.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/home_sizes.dart index bc5b340b92..fd15cf8d23 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/home_sizes.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/home_sizes.dart @@ -3,6 +3,8 @@ class HomeSizes { static const double topBarHeight = 60; static const double editPanelTopBarHeight = 60; static const double editPanelWidth = 400; + static const double tabBarHeigth = 40; + static const double tabBarWidth = 200; } class HomeInsets { diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/home_stack.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/home_stack.dart index e47eb8820c..ad9a97aa0e 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/home_stack.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/home_stack.dart @@ -2,14 +2,17 @@ import 'package:appflowy/core/frameless_window.dart'; import 'package:appflowy/plugins/blank/blank.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:appflowy/workspace/presentation/home/navigation.dart'; +import 'package:appflowy/workspace/presentation/home/tabs/tabs_manager.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/extension.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; import 'package:time/time.dart'; @@ -32,27 +35,73 @@ class HomeStack extends StatelessWidget { @override Widget build(BuildContext context) { - return Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - getIt().stackTopBar(layout: layout), - Expanded( - child: Container( - color: Theme.of(context).colorScheme.surface, - child: FocusTraversalGroup( - child: getIt().stackWidget( - onDeleted: (view, index) { - delegate.didDeleteStackWidget(view, index); - }, + final pageController = PageController(); + + return BlocProvider.value( + value: getIt(), + child: BlocBuilder( + builder: (context, state) { + return Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + TabsManager(pageController: pageController), + state.currentPageManager.stackTopBar(layout: layout), + Expanded( + child: PageView( + physics: const NeverScrollableScrollPhysics(), + controller: pageController, + children: state.pageManagers + .map( + (pm) => PageStack(pageManager: pm, delegate: delegate), + ) + .toList(), + ), ), - ), - ), - ), - ], + ], + ); + }, + ), ); } } +class PageStack extends StatefulWidget { + const PageStack({ + super.key, + required this.pageManager, + required this.delegate, + }); + + final PageManager pageManager; + + final HomeStackDelegate delegate; + + @override + State createState() => _PageStackState(); +} + +class _PageStackState extends State + with AutomaticKeepAliveClientMixin { + @override + Widget build(BuildContext context) { + super.build(context); + + return Container( + color: Theme.of(context).colorScheme.surface, + child: FocusTraversalGroup( + child: widget.pageManager.stackWidget( + onDeleted: (view, index) { + widget.delegate.didDeleteStackWidget(view, index); + }, + ), + ), + ); + } + + @override + bool get wantKeepAlive => true; +} + class FadingIndexedStack extends StatefulWidget { final int index; final List children; @@ -104,18 +153,20 @@ class FadingIndexedStackState extends State { abstract mixin class NavigationItem { Widget get leftBarItem; Widget? get rightBarItem => null; + Widget tabBarItem(String pluginId); - NavigationCallback get action => (id) { - getIt().setStackWithId(id); - }; + NavigationCallback get action => (id) => throw UnimplementedError(); } -class HomeStackNotifier extends ChangeNotifier { +class PageNotifier extends ChangeNotifier { Plugin _plugin; Widget get titleWidget => _plugin.widgetBuilder.leftBarItem; - HomeStackNotifier({Plugin? plugin}) + Widget tabBarWidget(String pluginId) => + _plugin.widgetBuilder.tabBarItem(pluginId); + + PageNotifier({Plugin? plugin}) : _plugin = plugin ?? makePlugin(pluginType: PluginType.blank); /// This is the only place where the plugin is set. @@ -133,10 +184,13 @@ class HomeStackNotifier extends ChangeNotifier { Plugin get plugin => _plugin; } -// HomeStack is initialized as singleton to control the page stack. -class HomeStackManager { - final HomeStackNotifier _notifier = HomeStackNotifier(); - HomeStackManager(); +// PageManager manages the view for one Tab +class PageManager { + final PageNotifier _notifier = PageNotifier(); + + PageNotifier get notifier => _notifier; + + PageManager(); Widget title() { return _notifier.plugin.widgetBuilder.leftBarItem; @@ -157,7 +211,7 @@ class HomeStackManager { providers: [ ChangeNotifierProvider.value(value: _notifier), ], - child: Selector( + child: Selector( selector: (context, notifier) => notifier.titleWidget, builder: (context, widget, child) { return MoveWindowDetector(child: HomeTopBar(layout: layout)); @@ -170,7 +224,7 @@ class HomeStackManager { return MultiProvider( providers: [ChangeNotifierProvider.value(value: _notifier)], child: Consumer( - builder: (_, HomeStackNotifier notifier, __) { + builder: (_, PageNotifier notifier, __) { return FadingIndexedStack( index: getIt().indexOf(notifier.plugin.pluginType), children: getIt().supportPluginTypes.map( @@ -185,9 +239,9 @@ class HomeStackManager { padding: builder.contentPadding, child: pluginWidget, ); - } else { - return const BlankPage(); } + + return const BlankPage(); }, ).toList(), ); @@ -218,9 +272,9 @@ class HomeTopBar extends StatelessWidget { const FlowyNavigation(), const HSpace(16), ChangeNotifierProvider.value( - value: Provider.of(context, listen: false), + value: Provider.of(context, listen: false), child: Consumer( - builder: (_, HomeStackNotifier notifier, __) => + builder: (_, PageNotifier notifier, __) => notifier.plugin.widgetBuilder.rightBarItem ?? const SizedBox.shrink(), ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/app/section/item.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/app/section/item.dart index 6043d7a694..85da0cf414 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/app/section/item.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/app/section/item.dart @@ -1,4 +1,5 @@ import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/home/menu/menu.dart'; @@ -18,7 +19,6 @@ import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flowy_infra_ui/style_widget/icon_button.dart'; -// ignore: must_be_immutable class ViewSectionItem extends StatelessWidget { final bool isSelected; final ViewPB view; @@ -115,6 +115,14 @@ class ViewSectionItem extends StatelessWidget { case ViewDisclosureAction.duplicate: blocContext.read().add(const ViewEvent.duplicate()); break; + case ViewDisclosureAction.openInNewTab: + blocContext.read().add( + TabsEvent.openTab( + plugin: state.view.plugin(), + view: blocContext.read().state.view, + ), + ); + break; } }, ), @@ -135,6 +143,7 @@ enum ViewDisclosureAction { rename, delete, duplicate, + openInNewTab, } extension ViewDisclosureExtension on ViewDisclosureAction { @@ -146,6 +155,8 @@ extension ViewDisclosureExtension on ViewDisclosureAction { return LocaleKeys.disclosureAction_delete.tr(); case ViewDisclosureAction.duplicate: return LocaleKeys.disclosureAction_duplicate.tr(); + case ViewDisclosureAction.openInNewTab: + return LocaleKeys.disclosureAction_openNewTab.tr(); } } @@ -157,6 +168,8 @@ extension ViewDisclosureExtension on ViewDisclosureAction { return const FlowySvg(name: 'editor/delete'); case ViewDisclosureAction.duplicate: return const FlowySvg(name: 'editor/copy'); + case ViewDisclosureAction.openInNewTab: + return const FlowySvg(name: 'grid/expander'); } } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/app/section/section.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/app/section/section.dart index 780fd0d686..41cf877302 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/app/section/section.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/app/section/section.dart @@ -1,8 +1,8 @@ import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/app/app_bloc.dart'; import 'package:appflowy/workspace/application/menu/menu_view_section_bloc.dart'; +import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; -import 'package:appflowy/workspace/presentation/home/home_stack.dart'; import 'package:appflowy/workspace/presentation/home/menu/menu.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -27,8 +27,10 @@ class ViewSection extends StatelessWidget { listener: (context, state) { if (state.selectedView != null) { WidgetsBinding.instance.addPostFrameCallback((_) { - getIt().setPlugin( - state.selectedView!.plugin(listenOnViewChanged: true), + getIt().add( + TabsEvent.openPlugin( + plugin: state.selectedView!.plugin(listenOnViewChanged: true), + ), ); }); } @@ -73,10 +75,6 @@ class ViewSection extends StatelessWidget { } bool _isViewSelected(ViewSectionState state, String viewId) { - final view = state.selectedView; - if (view == null) { - return false; - } - return view.id == viewId; + return state.selectedView?.id == viewId; } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/menu.dart index ec1ba4502c..86ee7a575d 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/menu.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/menu.dart @@ -6,15 +6,14 @@ import 'package:appflowy/plugins/trash/menu.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/home/home_setting_bloc.dart'; import 'package:appflowy/workspace/application/menu/menu_bloc.dart'; +import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; -import 'package:appflowy/workspace/presentation/home/home_stack.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/workspace.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' show UserProfilePB; import 'package:easy_localization/easy_localization.dart'; import 'package:expandable/expandable.dart'; -// import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:flowy_infra/image.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/time/duration.dart'; @@ -63,7 +62,9 @@ class HomeMenu extends StatelessWidget { BlocListener( listenWhen: (p, c) => p.plugin.id != c.plugin.id, listener: (context, state) { - getIt().setPlugin(state.plugin); + getIt().add( + TabsEvent.openPlugin(plugin: state.plugin), + ); }, ), ], @@ -131,7 +132,8 @@ class HomeMenu extends StatelessWidget { // expect: oldIndex: 0, newIndex: 1 // receive: oldIndex: 0, newIndex: 2 // Workaround: if newIndex > oldIndex, we just minus one - final int index = newIndex > oldIndex ? newIndex - 1 : newIndex; + final int index = + newIndex > oldIndex ? newIndex - 1 : newIndex; context .read() .add(MenuEvent.moveApp(oldIndex, index)); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/navigation.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/navigation.dart index f5cacd3cfd..aa34984374 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/navigation.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/navigation.dart @@ -19,14 +19,9 @@ class NavigationNotifier with ChangeNotifier { List navigationItems; NavigationNotifier({required this.navigationItems}); - void update(HomeStackNotifier notifier) { - bool shouldNotify = false; + void update(PageNotifier notifier) { if (navigationItems != notifier.plugin.widgetBuilder.navigationItems) { navigationItems = notifier.plugin.widgetBuilder.navigationItems; - shouldNotify = true; - } - - if (shouldNotify) { notifyListeners(); } } @@ -37,9 +32,9 @@ class FlowyNavigation extends StatelessWidget { @override Widget build(BuildContext context) { - return ChangeNotifierProxyProvider( + return ChangeNotifierProxyProvider( create: (_) { - final notifier = Provider.of(context, listen: false); + final notifier = Provider.of(context, listen: false); return NavigationNotifier( navigationItems: notifier.plugin.widgetBuilder.navigationItems, ); @@ -54,7 +49,6 @@ class FlowyNavigation extends StatelessWidget { builder: (ctx, items, child) => Expanded( child: Row( children: _renderNavigationItems(items), - // crossAxisAlignment: WrapCrossAlignment.start, ), ), ), @@ -173,6 +167,9 @@ class EllipsisNaviItem extends NavigationItem { fontSize: FontSizes.s16, ); + @override + Widget tabBarItem(String pluginId) => leftBarItem; + @override NavigationCallback get action => (id) {}; } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/tabs/flowy_tab.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/tabs/flowy_tab.dart new file mode 100644 index 0000000000..0b97774c36 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/tabs/flowy_tab.dart @@ -0,0 +1,92 @@ +import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; +import 'package:appflowy/workspace/presentation/home/home_stack.dart'; +import 'package:flowy_infra/image.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/icon_button.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class FlowyTab extends StatefulWidget { + final PageManager pageManager; + final bool isCurrent; + + const FlowyTab({ + super.key, + required this.pageManager, + required this.isCurrent, + }); + + @override + State createState() => _FlowyTabState(); +} + +class _FlowyTabState extends State { + bool _isHovering = false; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTertiaryTapUp: _closeTab, + child: MouseRegion( + onEnter: (_) => _setHovering(true), + onExit: (_) => _setHovering(), + child: Container( + width: HomeSizes.tabBarWidth, + height: HomeSizes.tabBarHeigth, + decoration: BoxDecoration( + color: _getBackgroundColor(), + ), + child: ChangeNotifierProvider.value( + value: widget.pageManager.notifier, + child: Consumer( + builder: (context, value, child) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Row( + children: [ + Expanded( + child: widget.pageManager.notifier + .tabBarWidget(widget.pageManager.plugin.id), + ), + Visibility( + visible: _isHovering, + child: FlowyIconButton( + onPressed: _closeTab, + icon: const FlowySvg( + name: 'editor/close', + size: Size.fromWidth(16), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } + + void _setHovering([bool isHovering = false]) { + if (mounted) { + setState(() => _isHovering = isHovering); + } + } + + Color _getBackgroundColor() { + if (widget.isCurrent) { + return Theme.of(context).colorScheme.onSecondaryContainer; + } + + if (_isHovering) { + return AFThemeExtension.of(context).lightGreyHover; + } + + return Theme.of(context).colorScheme.surfaceVariant; + } + + void _closeTab([TapUpDetails? details]) => context + .read() + .add(TabsEvent.closeTab(widget.pageManager.plugin.id)); +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/tabs/tabs_manager.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/tabs/tabs_manager.dart new file mode 100644 index 0000000000..0f502a6c29 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/tabs/tabs_manager.dart @@ -0,0 +1,103 @@ +import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; +import 'package:appflowy/workspace/presentation/home/tabs/flowy_tab.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class TabsManager extends StatefulWidget { + final PageController pageController; + + const TabsManager({ + super.key, + required this.pageController, + }); + + @override + State createState() => _TabsManagerState(); +} + +class _TabsManagerState extends State + with TickerProviderStateMixin { + late TabController _controller; + + @override + void initState() { + super.initState(); + _controller = TabController(vsync: this, length: 1); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: BlocProvider.of(context), + child: BlocListener( + listener: (context, state) { + if (_controller.length != state.pages) { + _controller.dispose(); + _controller = TabController( + vsync: this, + initialIndex: state.currentIndex, + length: state.pages, + ); + } + + if (state.currentIndex != widget.pageController.page) { + // Unfocus editor to hide selection toolbar + FocusScope.of(context).unfocus(); + + widget.pageController.animateToPage( + state.currentIndex, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + }, + child: BlocBuilder( + builder: (context, state) { + if (_controller.length == 1) { + return const SizedBox.shrink(); + } + + return Container( + alignment: Alignment.bottomLeft, + height: HomeSizes.tabBarHeigth, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceVariant, + ), + + /// TODO(Xazin): Custom Reorderable TabBar + child: TabBar( + padding: EdgeInsets.zero, + labelPadding: EdgeInsets.zero, + indicator: BoxDecoration( + border: Border.all(width: 0, color: Colors.transparent), + ), + indicatorWeight: 0, + dividerColor: Colors.transparent, + isScrollable: true, + controller: _controller, + onTap: (newIndex) => + context.read().add(TabsEvent.selectTab(newIndex)), + tabs: state.pageManagers + .map( + (pm) => FlowyTab( + key: UniqueKey(), + pageManager: pm, + isCurrent: state.currentPageManager == pm, + ), + ) + .toList(), + ), + ); + }, + ), + ), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/tab_bar_item.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/tab_bar_item.dart new file mode 100644 index 0000000000..7a541c842e --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/tab_bar_item.dart @@ -0,0 +1,46 @@ +import 'package:appflowy/workspace/application/view/view_listener.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class ViewTabBarItem extends StatefulWidget { + final ViewPB view; + + const ViewTabBarItem({ + super.key, + required this.view, + }); + + @override + State createState() => _ViewTabBarItemState(); +} + +class _ViewTabBarItemState extends State { + late final ViewListener _viewListener; + late ViewPB view; + + @override + void initState() { + super.initState(); + view = widget.view; + _viewListener = ViewListener(viewId: widget.view.id); + _viewListener.start( + onViewUpdated: (updatedView) { + if (mounted) { + setState(() => view = updatedView); + } + }, + ); + } + + @override + void dispose() { + _viewListener.stop(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FlowyText.medium(view.name); + } +} diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 0d1cbc3b6e..00723a2e27 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -61,7 +61,8 @@ "disclosureAction": { "rename": "Rename", "delete": "Delete", - "duplicate": "Duplicate" + "duplicate": "Duplicate", + "openNewTab": "Open in a new tab" }, "blankPageTitle": "Blank page", "newPageText": "New page",