mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2026-01-06 04:11:53 +00:00
fix: tab menu and tabbar improvements (#6785)
* fix: tab menu and tabbar improvements * chore: update appflowy_editor * test: tab menu test * test: fix test after refactor
This commit is contained in:
parent
d9f2d14e99
commit
1952ef0853
2
.github/workflows/flutter_ci.yaml
vendored
2
.github/workflows/flutter_ci.yaml
vendored
@ -346,7 +346,7 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
test_number: [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
test_number: [1, 2, 3, 4, 5, 6, 7, 8, 9]
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
target: "x86_64-unknown-linux-gnu"
|
||||
|
||||
@ -1,17 +1,18 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
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-folder/view.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import '../../shared/base.dart';
|
||||
import '../../shared/common_operations.dart';
|
||||
import '../../shared/expectation.dart';
|
||||
import '../../shared/keyboard.dart';
|
||||
import '../../shared/util.dart';
|
||||
|
||||
const _documentName = 'First Doc';
|
||||
const _documentTwoName = 'Second Doc';
|
||||
@ -20,17 +21,12 @@ void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('Tabs', () {
|
||||
testWidgets('Open AppFlowy and open/navigate/close tabs', (tester) async {
|
||||
testWidgets('open/navigate/close tabs', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(TabsManager),
|
||||
matching: find.byType(TabBar),
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
// No tabs rendered yet
|
||||
expect(find.byType(FlowyTab), findsNothing);
|
||||
|
||||
await tester.createNewPageWithNameUnderParent(name: _documentName);
|
||||
|
||||
@ -44,7 +40,7 @@ void main() {
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(TabBar),
|
||||
of: find.byType(TabsManager),
|
||||
matching: find.byType(FlowyTab),
|
||||
),
|
||||
findsNWidgets(3),
|
||||
@ -71,11 +67,83 @@ void main() {
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(TabBar),
|
||||
of: find.byType(TabsManager),
|
||||
matching: find.byType(FlowyTab),
|
||||
),
|
||||
findsNWidgets(2),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('right click show tab menu, close others', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(TabsManager),
|
||||
matching: find.byType(TabBar),
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
|
||||
await tester.createNewPageWithNameUnderParent(name: _documentName);
|
||||
await tester.createNewPageWithNameUnderParent(name: _documentTwoName);
|
||||
|
||||
/// Open second menu item in a new tab
|
||||
await tester.openAppInNewTab(gettingStarted, ViewLayoutPB.Document);
|
||||
|
||||
/// Open third menu item in a new tab
|
||||
await tester.openAppInNewTab(_documentName, ViewLayoutPB.Document);
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(TabsManager),
|
||||
matching: find.byType(FlowyTab),
|
||||
),
|
||||
findsNWidgets(3),
|
||||
);
|
||||
|
||||
/// Right click on second tab
|
||||
await tester.tap(
|
||||
buttons: kSecondaryButton,
|
||||
find.descendant(
|
||||
of: find.byType(FlowyTab),
|
||||
matching: find.text(gettingStarted),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(TabMenu), findsOneWidget);
|
||||
|
||||
final firstTabFinder = find.descendant(
|
||||
of: find.byType(FlowyTab),
|
||||
matching: find.text(_documentTwoName),
|
||||
);
|
||||
final secondTabFinder = find.descendant(
|
||||
of: find.byType(FlowyTab),
|
||||
matching: find.text(gettingStarted),
|
||||
);
|
||||
final thirdTabFinder = find.descendant(
|
||||
of: find.byType(FlowyTab),
|
||||
matching: find.text(_documentName),
|
||||
);
|
||||
|
||||
expect(firstTabFinder, findsOneWidget);
|
||||
expect(secondTabFinder, findsOneWidget);
|
||||
expect(thirdTabFinder, findsOneWidget);
|
||||
|
||||
// Close other tabs than the second item
|
||||
await tester.tap(find.text(LocaleKeys.tabMenu_closeOthers.tr()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// We expect to not find any tabs
|
||||
expect(firstTabFinder, findsNothing);
|
||||
expect(secondTabFinder, findsNothing);
|
||||
expect(thirdTabFinder, findsNothing);
|
||||
|
||||
// Expect second tab to be current page (current page has breadcrumb, cover title,
|
||||
// and in this case view name in sidebar)
|
||||
expect(find.text(gettingStarted), findsNWidgets(3));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -4,7 +4,6 @@ import 'emoji_shortcut_test.dart' as emoji_shortcut_test;
|
||||
import 'hotkeys_test.dart' as hotkeys_test;
|
||||
import 'import_files_test.dart' as import_files_test;
|
||||
import 'share_markdown_test.dart' as share_markdown_test;
|
||||
import 'tabs_test.dart' as tabs_test;
|
||||
import 'zoom_in_out_test.dart' as zoom_in_out_test;
|
||||
|
||||
void main() {
|
||||
@ -17,7 +16,6 @@ void main() {
|
||||
emoji_shortcut_test.main();
|
||||
share_markdown_test.main();
|
||||
import_files_test.main();
|
||||
tabs_test.main();
|
||||
zoom_in_out_test.main();
|
||||
// DON'T add more tests here.
|
||||
}
|
||||
|
||||
@ -0,0 +1,16 @@
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import 'desktop/uncategorized/tabs_test.dart' as tabs_test;
|
||||
import 'desktop/first_test/first_test.dart' as first_test;
|
||||
|
||||
Future<void> main() async {
|
||||
await runIntegration9OnDesktop();
|
||||
}
|
||||
|
||||
Future<void> runIntegration9OnDesktop() async {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
first_test.main();
|
||||
|
||||
tabs_test.main();
|
||||
}
|
||||
@ -8,6 +8,7 @@ import 'desktop_runner_5.dart';
|
||||
import 'desktop_runner_6.dart';
|
||||
import 'desktop_runner_7.dart';
|
||||
import 'desktop_runner_8.dart';
|
||||
import 'desktop_runner_9.dart';
|
||||
import 'mobile_runner_1.dart';
|
||||
|
||||
/// The main task runner for all integration tests in AppFlowy.
|
||||
@ -27,6 +28,7 @@ Future<void> main() async {
|
||||
await runIntegration6OnDesktop();
|
||||
await runIntegration7OnDesktop();
|
||||
await runIntegration8OnDesktop();
|
||||
await runIntegration9OnDesktop();
|
||||
} else if (Platform.isIOS || Platform.isAndroid) {
|
||||
await runIntegration1OnMobile();
|
||||
} else {
|
||||
|
||||
@ -230,9 +230,7 @@ class DatabaseTabBarViewPlugin extends Plugin {
|
||||
const kDatabasePluginWidgetBuilderHorizontalPadding = 'horizontal_padding';
|
||||
|
||||
class DatabasePluginWidgetBuilderSize {
|
||||
const DatabasePluginWidgetBuilderSize({
|
||||
required this.horizontalPadding,
|
||||
});
|
||||
const DatabasePluginWidgetBuilderSize({required this.horizontalPadding});
|
||||
|
||||
final double horizontalPadding;
|
||||
}
|
||||
|
||||
@ -0,0 +1,79 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy/workspace/application/view/view_service.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'tab_menu_bloc.freezed.dart';
|
||||
|
||||
class TabMenuBloc extends Bloc<TabMenuEvent, TabMenuState> {
|
||||
TabMenuBloc({required this.viewId}) : super(const TabMenuState.isLoading()) {
|
||||
_fetchView();
|
||||
_dispatch();
|
||||
}
|
||||
|
||||
final String viewId;
|
||||
ViewPB? view;
|
||||
|
||||
void _dispatch() {
|
||||
on<TabMenuEvent>(
|
||||
(event, emit) async {
|
||||
await event.when(
|
||||
error: (error) async => emit(const TabMenuState.isError()),
|
||||
fetchedView: (view) async =>
|
||||
emit(TabMenuState.isReady(isFavorite: view.isFavorite)),
|
||||
toggleFavorite: () async {
|
||||
final didToggle = await ViewBackendService.favorite(viewId: viewId);
|
||||
if (didToggle.isSuccess) {
|
||||
final isFavorite = state.maybeMap(
|
||||
isReady: (s) => s.isFavorite,
|
||||
orElse: () => null,
|
||||
);
|
||||
if (isFavorite != null) {
|
||||
emit(TabMenuState.isReady(isFavorite: !isFavorite));
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _fetchView() async {
|
||||
final viewOrFailure = await ViewBackendService.getView(viewId);
|
||||
viewOrFailure.fold(
|
||||
(view) {
|
||||
this.view = view;
|
||||
add(TabMenuEvent.fetchedView(view));
|
||||
},
|
||||
(error) {
|
||||
Log.error(error);
|
||||
add(TabMenuEvent.error(error));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class TabMenuEvent with _$TabMenuEvent {
|
||||
const factory TabMenuEvent.error(FlowyError error) = _Error;
|
||||
const factory TabMenuEvent.fetchedView(ViewPB view) = _FetchedView;
|
||||
const factory TabMenuEvent.toggleFavorite() = _ToggleFavorite;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class TabMenuState with _$TabMenuState {
|
||||
const factory TabMenuState.isLoading() = _IsLoading;
|
||||
|
||||
/// This will only be the state in case fetching the view failed.
|
||||
///
|
||||
/// One such case can be from when a View is in the trash, as such we can disable
|
||||
/// certain options in the TabMenu such as the favorite option.
|
||||
///
|
||||
const factory TabMenuState.isError() = _IsError;
|
||||
|
||||
const factory TabMenuState.isReady({required bool isFavorite}) = _IsReady;
|
||||
}
|
||||
@ -58,6 +58,18 @@ class TabsBloc extends Bloc<TabsEvent, TabsState> {
|
||||
_setLatestOpenView(view);
|
||||
}
|
||||
},
|
||||
closeOtherTabs: (String pluginId) {
|
||||
final pagesToClose = [
|
||||
...state._pageManagers.where((pm) => pm.plugin.id != pluginId),
|
||||
];
|
||||
|
||||
final newstate = state;
|
||||
for (final pm in pagesToClose) {
|
||||
newstate.closeView(pm.plugin.id);
|
||||
}
|
||||
emit(newstate.copyWith(newIndex: 0));
|
||||
_setLatestOpenView();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@ -69,7 +81,8 @@ class TabsBloc extends Bloc<TabsEvent, TabsState> {
|
||||
} else {
|
||||
final pageManager = state.currentPageManager;
|
||||
final notifier = pageManager.plugin.notifier;
|
||||
if (notifier is ViewPluginNotifier) {
|
||||
if (notifier is ViewPluginNotifier &&
|
||||
menuSharedState.latestOpenView?.id != notifier.view.id) {
|
||||
menuSharedState.latestOpenView = notifier.view;
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ part of 'tabs_bloc.dart';
|
||||
class TabsEvent with _$TabsEvent {
|
||||
const factory TabsEvent.moveTab() = _MoveTab;
|
||||
const factory TabsEvent.closeTab(String pluginId) = _CloseTab;
|
||||
const factory TabsEvent.closeOtherTabs(String pluginId) = _CloseOtherTabs;
|
||||
const factory TabsEvent.closeCurrentTab() = _CloseCurrentTab;
|
||||
const factory TabsEvent.selectTab(int index) = _SelectTab;
|
||||
const factory TabsEvent.openTab({
|
||||
|
||||
@ -34,7 +34,7 @@ abstract class HomeStackDelegate {
|
||||
void didDeleteStackWidget(ViewPB view, int? index);
|
||||
}
|
||||
|
||||
class HomeStack extends StatelessWidget {
|
||||
class HomeStack extends StatefulWidget {
|
||||
const HomeStack({
|
||||
super.key,
|
||||
required this.delegate,
|
||||
@ -47,49 +47,67 @@ class HomeStack extends StatelessWidget {
|
||||
final UserProfilePB userProfile;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pageController = PageController();
|
||||
State<HomeStack> createState() => _HomeStackState();
|
||||
}
|
||||
|
||||
class _HomeStackState extends State<HomeStack> {
|
||||
int selectedIndex = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<TabsBloc>.value(
|
||||
value: getIt<TabsBloc>(),
|
||||
child: BlocBuilder<TabsBloc, TabsState>(
|
||||
builder: (context, state) {
|
||||
return Column(
|
||||
children: [
|
||||
if (Platform.isWindows)
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
WindowTitleBar(
|
||||
leftChildren: [
|
||||
_buildToggleMenuButton(context),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: layout.menuSpacing),
|
||||
child: TabsManager(pageController: pageController),
|
||||
buildWhen: (prev, curr) => prev.currentIndex != curr.currentIndex,
|
||||
builder: (context, state) => Column(
|
||||
children: [
|
||||
if (Platform.isWindows)
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
WindowTitleBar(
|
||||
leftChildren: [_buildToggleMenuButton(context)],
|
||||
),
|
||||
],
|
||||
),
|
||||
state.currentPageManager.stackTopBar(layout: layout),
|
||||
Expanded(
|
||||
child: PageView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
controller: pageController,
|
||||
children: state.pageManagers
|
||||
.map(
|
||||
(pm) => PageStack(
|
||||
pageManager: pm,
|
||||
delegate: delegate,
|
||||
userProfile: userProfile,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: widget.layout.menuSpacing),
|
||||
child: TabsManager(
|
||||
onIndexChanged: (index) {
|
||||
if (selectedIndex != index) {
|
||||
// Unfocus editor to hide selection toolbar
|
||||
FocusScope.of(context).unfocus();
|
||||
|
||||
context.read<TabsBloc>().add(TabsEvent.selectTab(index));
|
||||
setState(() => selectedIndex = index);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: FadingIndexedStack(
|
||||
index: selectedIndex,
|
||||
duration: const Duration(milliseconds: 350),
|
||||
children: state.pageManagers
|
||||
.map(
|
||||
(pm) => Column(
|
||||
children: [
|
||||
pm.stackTopBar(layout: widget.layout),
|
||||
Expanded(
|
||||
child: PageStack(
|
||||
pageManager: pm,
|
||||
delegate: widget.delegate,
|
||||
userProfile: widget.userProfile,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -145,7 +163,6 @@ class PageStack extends StatefulWidget {
|
||||
});
|
||||
|
||||
final PageManager pageManager;
|
||||
|
||||
final HomeStackDelegate delegate;
|
||||
final UserProfilePB userProfile;
|
||||
|
||||
@ -216,9 +233,7 @@ class FadingIndexedStackState extends State<FadingIndexedStack> {
|
||||
return TweenAnimationBuilder<double>(
|
||||
duration: _targetOpacity > 0 ? widget.duration : 0.milliseconds,
|
||||
tween: Tween(begin: 0, end: _targetOpacity),
|
||||
builder: (_, value, child) {
|
||||
return Opacity(opacity: value, child: child);
|
||||
},
|
||||
builder: (_, value, child) => Opacity(opacity: value, child: child),
|
||||
child: IndexedStack(index: widget.index, children: widget.children),
|
||||
);
|
||||
}
|
||||
@ -279,15 +294,9 @@ class PageManager {
|
||||
_notifier.setPlugin(newPlugin, setLatest);
|
||||
}
|
||||
|
||||
void setStackWithId(String id) {
|
||||
// Navigate to the page with id
|
||||
}
|
||||
|
||||
Widget stackTopBar({required HomeLayout layout}) {
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider.value(value: _notifier),
|
||||
],
|
||||
providers: [ChangeNotifierProvider.value(value: _notifier)],
|
||||
child: Selector<PageNotifier, Widget>(
|
||||
selector: (context, notifier) => notifier.titleWidget,
|
||||
builder: (_, __, child) => MoveWindowDetector(
|
||||
@ -319,7 +328,6 @@ class PageManager {
|
||||
shrinkWrap: false,
|
||||
);
|
||||
|
||||
// TODO(Xazin): Board should fill up full width
|
||||
return Padding(
|
||||
padding: builder.contentPadding,
|
||||
child: pluginWidget,
|
||||
@ -340,13 +348,21 @@ class PageManager {
|
||||
}
|
||||
}
|
||||
|
||||
class HomeTopBar extends StatelessWidget {
|
||||
class HomeTopBar extends StatefulWidget {
|
||||
const HomeTopBar({super.key, required this.layout});
|
||||
|
||||
final HomeLayout layout;
|
||||
|
||||
@override
|
||||
State<HomeTopBar> createState() => _HomeTopBarState();
|
||||
}
|
||||
|
||||
class _HomeTopBarState extends State<HomeTopBar>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
@ -359,7 +375,7 @@ class HomeTopBar extends StatelessWidget {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
HSpace(layout.menuSpacing),
|
||||
HSpace(widget.layout.menuSpacing),
|
||||
const FlowyNavigation(),
|
||||
const HSpace(16),
|
||||
ChangeNotifierProvider.value(
|
||||
@ -375,4 +391,7 @@ class HomeTopBar extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
}
|
||||
|
||||
@ -1,10 +1,16 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/workspace/application/tabs/tab_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:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/hover.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class FlowyTab extends StatefulWidget {
|
||||
@ -12,63 +18,187 @@ class FlowyTab extends StatefulWidget {
|
||||
super.key,
|
||||
required this.pageManager,
|
||||
required this.isCurrent,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final PageManager pageManager;
|
||||
final bool isCurrent;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
State<FlowyTab> createState() => _FlowyTabState();
|
||||
}
|
||||
|
||||
class _FlowyTabState extends State<FlowyTab> {
|
||||
final controller = PopoverController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FlowyHover(
|
||||
isSelected: () => widget.isCurrent,
|
||||
style: const HoverStyle(
|
||||
resetHoverOnRebuild: false,
|
||||
style: HoverStyle(
|
||||
borderRadius: BorderRadius.zero,
|
||||
backgroundColor: widget.isCurrent
|
||||
? Theme.of(context).colorScheme.surface
|
||||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
hoverColor:
|
||||
widget.isCurrent ? Theme.of(context).colorScheme.surface : null,
|
||||
),
|
||||
builder: (context, onHover) {
|
||||
return ChangeNotifierProvider.value(
|
||||
builder: (context, isHovering) => AppFlowyPopover(
|
||||
controller: controller,
|
||||
offset: const Offset(4, 4),
|
||||
triggerActions: PopoverTriggerFlags.secondaryClick,
|
||||
showAtCursor: true,
|
||||
popupBuilder: (_) => BlocProvider.value(
|
||||
value: context.read<TabsBloc>(),
|
||||
child: BlocProvider<TabMenuBloc>(
|
||||
create: (_) => TabMenuBloc(
|
||||
viewId: widget.pageManager.plugin.id,
|
||||
),
|
||||
child: TabMenu(pageId: widget.pageManager.plugin.id),
|
||||
),
|
||||
),
|
||||
child: ChangeNotifierProvider.value(
|
||||
value: widget.pageManager.notifier,
|
||||
child: Consumer<PageNotifier>(
|
||||
builder: (context, value, child) => Padding(
|
||||
builder: (context, value, _) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: SizedBox(
|
||||
width: HomeSizes.tabBarWidth,
|
||||
height: HomeSizes.tabBarHeight,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: widget.pageManager.notifier
|
||||
.tabBarWidget(widget.pageManager.plugin.id),
|
||||
// We use a Listener to avoid gesture detector onPanStart debounce
|
||||
child: Listener(
|
||||
onPointerDown: (event) {
|
||||
if (event.buttons == kPrimaryButton) {
|
||||
widget.onTap();
|
||||
}
|
||||
},
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
// Stop move window detector
|
||||
onPanStart: (_) {},
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: HomeSizes.tabBarWidth,
|
||||
minWidth: 100,
|
||||
),
|
||||
Visibility(
|
||||
visible: onHover,
|
||||
child: SizedBox(
|
||||
width: 26,
|
||||
height: 26,
|
||||
child: FlowyIconButton(
|
||||
onPressed: _closeTab,
|
||||
icon: const FlowySvg(
|
||||
FlowySvgs.close_s,
|
||||
size: Size.square(22),
|
||||
height: HomeSizes.tabBarHeight,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Flexible(
|
||||
child: widget.pageManager.notifier
|
||||
.tabBarWidget(widget.pageManager.plugin.id),
|
||||
),
|
||||
Visibility(
|
||||
visible: isHovering,
|
||||
child: SizedBox(
|
||||
width: 26,
|
||||
height: 26,
|
||||
child: FlowyIconButton(
|
||||
onPressed: () => _closeTab(context),
|
||||
icon: const FlowySvg(
|
||||
FlowySvgs.close_s,
|
||||
size: Size.square(22),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _closeTab(BuildContext context) => context
|
||||
.read<TabsBloc>()
|
||||
.add(TabsEvent.closeTab(widget.pageManager.plugin.id));
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
class TabMenu extends StatelessWidget {
|
||||
const TabMenu({super.key, required this.pageId});
|
||||
|
||||
final String pageId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<TabMenuBloc, TabMenuState>(
|
||||
builder: (context, state) {
|
||||
if (state.maybeMap(
|
||||
isLoading: (_) => true,
|
||||
orElse: () => false,
|
||||
)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final disableFavoriteOption = state.maybeWhen(
|
||||
isReady: (_) => false,
|
||||
orElse: () => true,
|
||||
);
|
||||
|
||||
return SeparatedColumn(
|
||||
separatorBuilder: () => const VSpace(4),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FlowyButton(
|
||||
text: FlowyText.regular(LocaleKeys.tabMenu_close.tr()),
|
||||
onTap: () => _closeTab(context),
|
||||
),
|
||||
FlowyButton(
|
||||
text: FlowyText.regular(
|
||||
LocaleKeys.tabMenu_closeOthers.tr(),
|
||||
),
|
||||
onTap: () => _closeOtherTabs(context),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
_favoriteDisabledTooltip(
|
||||
showTooltip: disableFavoriteOption,
|
||||
child: FlowyButton(
|
||||
disable: disableFavoriteOption,
|
||||
text: FlowyText.regular(
|
||||
state.maybeWhen(
|
||||
isReady: (isFavorite) => isFavorite
|
||||
? LocaleKeys.tabMenu_unfavorite.tr()
|
||||
: LocaleKeys.tabMenu_favorite.tr(),
|
||||
orElse: () => LocaleKeys.tabMenu_favorite.tr(),
|
||||
),
|
||||
color: disableFavoriteOption
|
||||
? Theme.of(context).hintColor
|
||||
: null,
|
||||
),
|
||||
onTap: () => _toggleFavorite(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _closeTab([TapUpDetails? details]) => context
|
||||
.read<TabsBloc>()
|
||||
.add(TabsEvent.closeTab(widget.pageManager.plugin.id));
|
||||
void _closeTab(BuildContext context) =>
|
||||
context.read<TabsBloc>().add(TabsEvent.closeTab(pageId));
|
||||
|
||||
void _closeOtherTabs(BuildContext context) =>
|
||||
context.read<TabsBloc>().add(TabsEvent.closeOtherTabs(pageId));
|
||||
|
||||
void _toggleFavorite(BuildContext context) =>
|
||||
context.read<TabMenuBloc>().add(const TabMenuEvent.toggleFavorite());
|
||||
|
||||
Widget _favoriteDisabledTooltip({
|
||||
required bool showTooltip,
|
||||
required Widget child,
|
||||
}) {
|
||||
if (showTooltip) {
|
||||
return FlowyTooltip(
|
||||
message: LocaleKeys.tabMenu_favoriteDisabledHint.tr(),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
return child;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,59 +1,35 @@
|
||||
import 'package:appflowy/core/frameless_window.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/af_focus_manager.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/tabs/flowy_tab.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class TabsManager extends StatefulWidget {
|
||||
const TabsManager({super.key, required this.pageController});
|
||||
const TabsManager({
|
||||
super.key,
|
||||
required this.onIndexChanged,
|
||||
});
|
||||
|
||||
final PageController pageController;
|
||||
final void Function(int) onIndexChanged;
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
class _TabsManagerState extends State<TabsManager> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<TabsBloc>.value(
|
||||
value: BlocProvider.of<TabsBloc>(context),
|
||||
value: context.read<TabsBloc>(),
|
||||
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,
|
||||
);
|
||||
}
|
||||
},
|
||||
listenWhen: (prev, curr) =>
|
||||
prev.currentIndex != curr.currentIndex || prev.pages != curr.pages,
|
||||
listener: (context, state) => widget.onIndexChanged(state.currentIndex),
|
||||
child: BlocBuilder<TabsBloc, TabsState>(
|
||||
builder: (context, state) {
|
||||
if (_controller.length == 1) {
|
||||
if (state.pages == 1) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
@ -63,31 +39,29 @@ class _TabsManagerState extends State<TabsManager>
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
|
||||
/// 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) {
|
||||
AFFocusManager.of(context).notifyLoseFocus();
|
||||
context.read<TabsBloc>().add(TabsEvent.selectTab(newIndex));
|
||||
},
|
||||
tabs: state.pageManagers
|
||||
.map(
|
||||
(pm) => FlowyTab(
|
||||
key: UniqueKey(),
|
||||
pageManager: pm,
|
||||
isCurrent: state.currentPageManager == pm,
|
||||
child: MoveWindowDetector(
|
||||
child: Row(
|
||||
children: state.pageManagers.map<Widget>((pm) {
|
||||
return Flexible(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: HomeSizes.tabBarWidth,
|
||||
),
|
||||
child: FlowyTab(
|
||||
key: ValueKey('tab-${pm.plugin.id}'),
|
||||
pageManager: pm,
|
||||
isCurrent: state.currentPageManager == pm,
|
||||
onTap: () {
|
||||
if (state.currentPageManager != pm) {
|
||||
final index = state.pageManagers.indexOf(pm);
|
||||
widget.onIndexChanged(index);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@ -95,10 +69,4 @@ class _TabsManagerState extends State<TabsManager>
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@ -61,8 +61,8 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: "76daa96"
|
||||
resolved-ref: "76daa96af51f0ad4e881c10426a91780977544e5"
|
||||
ref: ea81e3c
|
||||
resolved-ref: ea81e3c1647344aff45970c39556902ffad4373d
|
||||
url: "https://github.com/AppFlowy-IO/appflowy-editor.git"
|
||||
source: git
|
||||
version: "4.0.0"
|
||||
|
||||
@ -172,7 +172,7 @@ dependency_overrides:
|
||||
appflowy_editor:
|
||||
git:
|
||||
url: https://github.com/AppFlowy-IO/appflowy-editor.git
|
||||
ref: "76daa96"
|
||||
ref: "ea81e3c"
|
||||
|
||||
appflowy_editor_plugins:
|
||||
git:
|
||||
|
||||
@ -2845,5 +2845,12 @@
|
||||
"one": "1 member",
|
||||
"many": "{count} members",
|
||||
"other": "{count} members"
|
||||
},
|
||||
"tabMenu": {
|
||||
"close": "Close",
|
||||
"closeOthers": "Close other tabs",
|
||||
"favorite": "Favorite",
|
||||
"unfavorite": "Unfavorite",
|
||||
"favoriteDisabledHint": "Cannot favorite this view"
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user