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
This commit is contained in:
Mathias Mogensen 2023-07-12 13:13:18 +02:00 committed by GitHub
parent d0747afbf2
commit 5b1afeb85d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 654 additions and 75 deletions

View File

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

View File

@ -276,6 +276,14 @@ extension CommonOperations on WidgetTester {
}
await pumpAndSettle();
}
Future<void> 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 {

View File

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

View File

@ -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(() {

View File

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

View File

@ -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<BuiltInPageWidget> {
case _ActionType.viewDatabase:
getIt<MenuSharedState>().latestOpenView = viewPB;
getIt<HomeStackManager>().setPlugin(viewPB.plugin());
getIt<TabsBloc>().add(
TabsEvent.openPlugin(
plugin: viewPB.plugin(),
),
);
break;
case _ActionType.delete:
final transaction = widget.editorState.transaction;

View File

@ -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<MenuSharedState>().latestOpenView = null;
getIt<HomeStackManager>()
.setPlugin(makePlugin(pluginType: PluginType.trash));
getIt<TabsBloc>().add(
TabsEvent.openPlugin(
plugin: makePlugin(pluginType: PluginType.trash),
),
);
},
child: _render(context),
),

View File

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

View File

@ -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>(() => HomeStackManager());
getIt.registerFactoryParam<WelcomeBloc, UserProfilePB, void>(
(user, _) => WelcomeBloc(
userService: UserBackendService(userId: user.id),
@ -122,6 +119,8 @@ void _resolveHomeDeps(GetIt getIt) {
getIt.registerFactoryParam<DocShareBloc, ViewPB, void>(
(view, _) => DocShareBloc(view: view),
);
getIt.registerLazySingleton<TabsBloc>(() => TabsBloc());
}
void _resolveFolderDeps(GetIt getIt) {

View File

@ -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<TabsEvent, TabsState> {
late final MenuSharedState menuSharedState;
TabsBloc() : super(TabsState()) {
menuSharedState = getIt<MenuSharedState>();
on<TabsEvent>((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;
}
}
}
}

View File

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

View File

@ -0,0 +1,93 @@
part of 'tabs_bloc.dart';
class TabsState {
final int currentIndex;
final List<PageManager> _pageManagers;
int get pages => _pageManagers.length;
PageManager get currentPageManager => _pageManagers[currentIndex];
List<PageManager> get pageManagers => _pageManagers;
TabsState({
this.currentIndex = 0,
List<PageManager>? 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<PageManager>? pageManagers,
}) =>
TabsState(
currentIndex: newIndex ?? currentIndex,
pageManagers: pageManagers ?? _pageManagers,
);
}

View File

@ -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<HomeScreen> {
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider<TabsBloc>.value(value: getIt<TabsBloc>()),
BlocProvider<HomeBloc>(
create: (context) {
return HomeBloc(widget.user, widget.workspaceSetting)
@ -74,14 +76,18 @@ class _HomeScreenState extends State<HomeScreen> {
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<HomeStackManager>().plugin.pluginType ==
final currentPageManager =
context.read<TabsBloc>().state.currentPageManager;
if (currentPageManager.plugin.pluginType ==
PluginType.blank) {
getIt<HomeStackManager>().setPlugin(
view.plugin(listenOnViewChanged: true),
getIt<TabsBloc>().add(
TabsEvent.openPlugin(
plugin: view.plugin(listenOnViewChanged: true),
),
);
getIt<MenuSharedState>().latestOpenView = view;
}
}
},
@ -275,18 +281,22 @@ class HomeScreenStackAdaptor extends HomeStackDelegate {
(parentView) {
final List<ViewPB> 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<MenuSharedState>().latestOpenView = lastView;
getIt<HomeStackManager>().setPlugin(
lastView.plugin(listenOnViewChanged: true),
getIt<TabsBloc>().add(
TabsEvent.openPlugin(
plugin: lastView.plugin(listenOnViewChanged: true),
),
);
} else {
getIt<MenuSharedState>().latestOpenView = null;
getIt<HomeStackManager>().setPlugin(BlankPagePlugin());
getIt<TabsBloc>().add(
TabsEvent.openPlugin(
plugin: BlankPagePlugin(),
),
);
}
},
(err) => Log.error(err),

View File

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

View File

@ -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<HomeStackManager>().stackTopBar(layout: layout),
Expanded(
child: Container(
color: Theme.of(context).colorScheme.surface,
child: FocusTraversalGroup(
child: getIt<HomeStackManager>().stackWidget(
onDeleted: (view, index) {
delegate.didDeleteStackWidget(view, index);
},
final pageController = PageController();
return BlocProvider<TabsBloc>.value(
value: getIt<TabsBloc>(),
child: BlocBuilder<TabsBloc, TabsState>(
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<PageStack> createState() => _PageStackState();
}
class _PageStackState extends State<PageStack>
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<Widget> children;
@ -104,18 +153,20 @@ class FadingIndexedStackState extends State<FadingIndexedStack> {
abstract mixin class NavigationItem {
Widget get leftBarItem;
Widget? get rightBarItem => null;
Widget tabBarItem(String pluginId);
NavigationCallback get action => (id) {
getIt<HomeStackManager>().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<HomeStackNotifier, Widget>(
child: Selector<PageNotifier, Widget>(
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<PluginSandbox>().indexOf(notifier.plugin.pluginType),
children: getIt<PluginSandbox>().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<HomeStackNotifier>(context, listen: false),
value: Provider.of<PageNotifier>(context, listen: false),
child: Consumer(
builder: (_, HomeStackNotifier notifier, __) =>
builder: (_, PageNotifier notifier, __) =>
notifier.plugin.widgetBuilder.rightBarItem ??
const SizedBox.shrink(),
),

View File

@ -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<ViewBloc>().add(const ViewEvent.duplicate());
break;
case ViewDisclosureAction.openInNewTab:
blocContext.read<TabsBloc>().add(
TabsEvent.openTab(
plugin: state.view.plugin(),
view: blocContext.read<ViewBloc>().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');
}
}
}

View File

@ -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<HomeStackManager>().setPlugin(
state.selectedView!.plugin(listenOnViewChanged: true),
getIt<TabsBloc>().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;
}
}

View File

@ -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<MenuBloc, MenuState>(
listenWhen: (p, c) => p.plugin.id != c.plugin.id,
listener: (context, state) {
getIt<HomeStackManager>().setPlugin(state.plugin);
getIt<TabsBloc>().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<MenuBloc>()
.add(MenuEvent.moveApp(oldIndex, index));

View File

@ -19,14 +19,9 @@ class NavigationNotifier with ChangeNotifier {
List<NavigationItem> 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<HomeStackNotifier, NavigationNotifier>(
return ChangeNotifierProxyProvider<PageNotifier, NavigationNotifier>(
create: (_) {
final notifier = Provider.of<HomeStackNotifier>(context, listen: false);
final notifier = Provider.of<PageNotifier>(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) {};
}

View File

@ -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<FlowyTab> createState() => _FlowyTabState();
}
class _FlowyTabState extends State<FlowyTab> {
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<PageNotifier>(
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<TabsBloc>()
.add(TabsEvent.closeTab(widget.pageManager.plugin.id));
}

View File

@ -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<TabsManager> createState() => _TabsManagerState();
}
class _TabsManagerState extends State<TabsManager>
with TickerProviderStateMixin {
late TabController _controller;
@override
void initState() {
super.initState();
_controller = TabController(vsync: this, length: 1);
}
@override
Widget build(BuildContext context) {
return BlocProvider<TabsBloc>.value(
value: BlocProvider.of<TabsBloc>(context),
child: BlocListener<TabsBloc, TabsState>(
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<TabsBloc, TabsState>(
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<TabsBloc>().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();
}
}

View File

@ -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<ViewTabBarItem> createState() => _ViewTabBarItemState();
}
class _ViewTabBarItemState extends State<ViewTabBarItem> {
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);
}
}

View File

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