show tabmenu for quick tabs

This commit is contained in:
Fabian Freund
2025-12-29 09:56:11 +01:00
parent ee50ba9599
commit 03b73edd66
2 changed files with 363 additions and 290 deletions
@@ -331,22 +331,24 @@ class BrowserTabBar extends HookConsumerWidget {
TabMenu( TabMenu(
controller: trippleDotMenuController, controller: trippleDotMenuController,
selectedTabId: selectedTabId, selectedTabId: selectedTabId,
child: InkWell( builder: (context, controller, child) {
onTap: () { return InkWell(
if (trippleDotMenuController.isOpen) { onTap: () {
trippleDotMenuController.close(); if (controller.isOpen) {
} else { controller.close();
trippleDotMenuController.open(); } else {
} controller.open();
}, }
child: const Padding( },
padding: EdgeInsets.symmetric( child: const Padding(
horizontal: 8.0, padding: EdgeInsets.symmetric(
vertical: 15.0, horizontal: 8.0,
vertical: 15.0,
),
child: Icon(MdiIcons.dotsVertical),
), ),
child: Icon(MdiIcons.dotsVertical), );
), },
),
), ),
if (showMainToolbarTabsCount) if (showMainToolbarTabsCount)
TabsCountButton( TabsCountButton(
@@ -500,6 +502,27 @@ class QuickTabSwitcher extends HookConsumerWidget {
} }
await animation; await animation;
}, },
itemWrap: (child, item) {
return TabMenu(
selectedTabId: item.id,
enableFindInPage: false,
enableFetchFeeds: false,
enableDesktopMode: false,
enableReaderMode: false,
builder: (context, controller, _) {
return InkWell(
onLongPress: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
child: child,
);
},
);
},
availableItems: tabStates.value availableItems: tabStates.value
.map( .map(
(state) => ( (state) => (
@@ -40,206 +40,220 @@ import 'package:weblibre/features/geckoview/features/tabs/data/models/container_
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
import 'package:weblibre/presentation/hooks/menu_controller.dart';
import 'package:weblibre/presentation/widgets/website_feed_menu_button.dart'; import 'package:weblibre/presentation/widgets/website_feed_menu_button.dart';
import 'package:weblibre/utils/ui_helper.dart' as ui_helper; import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
class TabMenu extends HookConsumerWidget { class TabMenu extends HookConsumerWidget {
final Widget child; final MenuAnchorChildBuilder builder;
final MenuController controller; final MenuController? controller;
final String selectedTabId; final String selectedTabId;
final bool enableFindInPage;
final bool enableReaderMode;
final bool enableDesktopMode;
final bool enableFetchFeeds;
final bool enableAddBookmark;
final bool enableCloneTab;
final bool enableContainer;
final bool enableShare;
final bool enableExport;
final bool enableCloseTab;
const TabMenu({ const TabMenu({
super.key, super.key,
required this.child, required this.builder,
required this.controller,
required this.selectedTabId, required this.selectedTabId,
this.controller,
this.enableFindInPage = true,
this.enableReaderMode = true,
this.enableDesktopMode = true,
this.enableFetchFeeds = true,
this.enableAddBookmark = true,
this.enableCloneTab = true,
this.enableContainer = true,
this.enableShare = true,
this.enableExport = true,
this.enableCloseTab = true,
}); });
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final showFeeds = useState(false); final showFeeds = useState(false);
final controller = this.controller ?? useMenuController();
return MenuAnchor( return MenuAnchor(
controller: controller, controller: controller,
onClose: () { onClose: () {
showFeeds.value = false; showFeeds.value = false;
}, },
builder: (context, controller, child) { builder: builder,
return child!;
},
menuChildren: [ menuChildren: [
MenuItemButton( if (enableFindInPage)
onPressed: () { MenuItemButton(
ref.read(bottomSheetControllerProvider.notifier).requestDismiss(); onPressed: () {
ref.read(bottomSheetControllerProvider.notifier).requestDismiss();
ref ref
.read(findInPageControllerProvider(selectedTabId).notifier) .read(findInPageControllerProvider(selectedTabId).notifier)
.show(); .show();
}, },
leadingIcon: const Icon(Icons.search), leadingIcon: const Icon(Icons.search),
child: const Text('Find in page'), child: const Text('Find in page'),
), ),
ReaderButton( if (enableReaderMode)
buttonBuilder: (isLoading, readerActive, icon) => MenuItemButton( ReaderButton(
onPressed: isLoading buttonBuilder: (isLoading, readerActive, icon) => MenuItemButton(
? null onPressed: isLoading
: () async { ? null
: () async {
await ref
.read(readerableScreenControllerProvider.notifier)
.toggleReaderView(!readerActive);
},
leadingIcon: icon,
trailingIcon: Checkbox(
value: readerActive,
onChanged: (value) async {
if (value != null && !isLoading) {
await ref await ref
.read(readerableScreenControllerProvider.notifier) .read(readerableScreenControllerProvider.notifier)
.toggleReaderView(!readerActive); .toggleReaderView(!readerActive);
},
leadingIcon: icon,
trailingIcon: Checkbox(
value: readerActive,
onChanged: (value) async {
if (value != null && !isLoading) {
await ref
.read(readerableScreenControllerProvider.notifier)
.toggleReaderView(!readerActive);
controller.close();
}
},
),
child: const Text('Reader Mode'),
),
),
Consumer(
builder: (context, childRef, child) {
final enabled = childRef.watch(desktopModeProvider(selectedTabId));
return MenuItemButton(
onPressed: () {
ref.read(desktopModeProvider(selectedTabId).notifier).toggle();
},
leadingIcon: const Icon(MdiIcons.monitor),
trailingIcon: Checkbox(
value: enabled,
onChanged: (value) {
if (value != null) {
ref
.read(desktopModeProvider(selectedTabId).notifier)
.enabled(value);
controller.close(); controller.close();
} }
}, },
), ),
child: const Text('Desktop Mode'), child: const Text('Reader Mode'),
); ),
}, ),
), if (enableDesktopMode)
const Divider(), Consumer(
Visibility( builder: (context, childRef, child) {
visible: showFeeds.value, final enabled = childRef.watch(
replacement: MenuItemButton( desktopModeProvider(selectedTabId),
closeOnActivate: false, );
leadingIcon: const Icon(Icons.rss_feed),
child: const Text('Fetch Feeds'), return MenuItemButton(
onPressed: () { onPressed: () {
showFeeds.value = true; ref
.read(desktopModeProvider(selectedTabId).notifier)
.toggle();
},
leadingIcon: const Icon(MdiIcons.monitor),
trailingIcon: Checkbox(
value: enabled,
onChanged: (value) {
if (value != null) {
ref
.read(desktopModeProvider(selectedTabId).notifier)
.enabled(value);
controller.close();
}
},
),
child: const Text('Desktop Mode'),
);
}, },
), ),
child: WebsiteFeedMenuButton(selectedTabId), if (enableFindInPage || enableReaderMode || enableDesktopMode)
), const Divider(),
MenuItemButton( if (enableFetchFeeds)
leadingIcon: const Icon(MdiIcons.bookmarkPlus), Visibility(
child: const Text('Add Bookmark'), visible: showFeeds.value,
onPressed: () async { replacement: MenuItemButton(
final tabState = ref.read(tabStateProvider(selectedTabId))!; closeOnActivate: false,
leadingIcon: const Icon(Icons.rss_feed),
child: const Text('Fetch Feeds'),
onPressed: () {
showFeeds.value = true;
},
),
child: WebsiteFeedMenuButton(selectedTabId),
),
if (enableAddBookmark)
MenuItemButton(
leadingIcon: const Icon(MdiIcons.bookmarkPlus),
child: const Text('Add Bookmark'),
onPressed: () async {
final tabState = ref.read(tabStateProvider(selectedTabId))!;
await BookmarkEntryAddRoute( await BookmarkEntryAddRoute(
bookmarkInfo: jsonEncode( bookmarkInfo: jsonEncode(
BookmarkInfo( BookmarkInfo(
title: tabState.titleOrAuthority, title: tabState.titleOrAuthority,
url: tabState.url.toString(), url: tabState.url.toString(),
).encode(), ).encode(),
), ),
).push(context); ).push(context);
}, },
), ),
SubmenuButton( if (enableCloneTab)
menuChildren: [ SubmenuButton(
MenuItemButton( menuChildren: [
leadingIcon: const Icon(MdiIcons.tabPlus), MenuItemButton(
child: const Text('Regular'), leadingIcon: const Icon(MdiIcons.tabPlus),
onPressed: () async { child: const Text('Regular'),
final tabState = ref.read(tabStateProvider(selectedTabId))!; onPressed: () async {
final tabState = ref.read(tabStateProvider(selectedTabId))!;
final tabId = await ref final tabId = await ref
.read(tabRepositoryProvider.notifier) .read(tabRepositoryProvider.notifier)
.addTab( .addTab(
url: tabState.url, url: tabState.url,
private: false, private: false,
selectTab: false, selectTab: false,
);
if (context.mounted) {
//save reference before pop `ref` gets disposed
final repo = ref.read(tabRepositoryProvider.notifier);
ui_helper.showTabSwitchMessage(
context,
onSwitch: () async {
await repo.selectTab(tabId);
},
); );
if (context.mounted) {
//save reference before pop `ref` gets disposed
final repo = ref.read(tabRepositoryProvider.notifier);
ui_helper.showTabSwitchMessage(
context,
onSwitch: () async {
await repo.selectTab(tabId);
},
);
}
},
),
MenuItemButton(
leadingIcon: const Icon(MdiIcons.tabUnselected),
child: const Text('Private'),
onPressed: () async {
final tabState = ref.read(tabStateProvider(selectedTabId))!;
final tabId = await ref
.read(tabRepositoryProvider.notifier)
.addTab(url: tabState.url, private: true, selectTab: false);
if (context.mounted) {
//save reference before pop `ref` gets disposed
final repo = ref.read(tabRepositoryProvider.notifier);
ui_helper.showTabSwitchMessage(
context,
onSwitch: () async {
await repo.selectTab(tabId);
},
);
}
},
),
],
leadingIcon: const Icon(MdiIcons.tabPlus),
child: const Text('Clone Tab'),
),
SubmenuButton(
menuChildren: [
MenuItemButton(
leadingIcon: const Icon(MdiIcons.folderArrowUpDownOutline),
child: const Text('Assign Container'),
onPressed: () async {
final targetContainerId = await const ContainerSelectionRoute()
.push<String?>(context);
if (targetContainerId != null) {
final containerData = await ref
.read(containerRepositoryProvider.notifier)
.getContainerData(targetContainerId);
if (containerData != null) {
final tabState = ref.read(tabStateProvider(selectedTabId))!;
await ref
.read(tabDataRepositoryProvider.notifier)
.assignContainer(tabState.id, containerData);
} }
} },
}, ),
), MenuItemButton(
Consumer( leadingIcon: const Icon(MdiIcons.tabUnselected),
child: MenuItemButton( child: const Text('Private'),
leadingIcon: const Icon(MdiIcons.webPlus), onPressed: () async {
child: const Text('URL relation'), final tabState = ref.read(tabStateProvider(selectedTabId))!;
final tabId = await ref
.read(tabRepositoryProvider.notifier)
.addTab(
url: tabState.url,
private: true,
selectTab: false,
);
if (context.mounted) {
//save reference before pop `ref` gets disposed
final repo = ref.read(tabRepositoryProvider.notifier);
ui_helper.showTabSwitchMessage(
context,
onSwitch: () async {
await repo.selectTab(tabId);
},
);
}
},
),
],
leadingIcon: const Icon(MdiIcons.tabPlus),
child: const Text('Clone Tab'),
),
if (enableContainer)
SubmenuButton(
menuChildren: [
MenuItemButton(
leadingIcon: const Icon(MdiIcons.folderArrowUpDownOutline),
child: const Text('Assign Container'),
onPressed: () async { onPressed: () async {
final targetContainerId = final targetContainerId =
await const ContainerSelectionRoute().push<String?>( await const ContainerSelectionRoute().push<String?>(
@@ -254,129 +268,165 @@ class TabMenu extends HookConsumerWidget {
if (containerData != null) { if (containerData != null) {
final tabState = ref.read( final tabState = ref.read(
tabStateProvider(selectedTabId), tabStateProvider(selectedTabId),
); )!;
final origin = tabState?.url.origin.mapNotNull(Uri.parse);
if (origin != null) { await ref
await ref .read(tabDataRepositoryProvider.notifier)
.read(containerRepositoryProvider.notifier) .assignContainer(tabState.id, containerData);
.replaceContainer(
containerData.copyWith.metadata(
containerData.metadata.copyWith.assignedSites([
...?containerData.metadata.assignedSites,
origin,
]),
),
);
}
} }
} }
}, },
), ),
builder: (context, ref, child) { Consumer(
final isSiteAssigned = ref.watch( child: MenuItemButton(
watchIsCurrentSiteAssignedToContainerProvider, leadingIcon: const Icon(MdiIcons.webPlus),
); child: const Text('URL relation'),
onPressed: () async {
final targetContainerId =
await const ContainerSelectionRoute().push<String?>(
context,
);
return Visibility( if (targetContainerId != null) {
visible: final containerData = await ref
isSiteAssigned.hasValue && !isSiteAssigned.requireValue, .read(containerRepositoryProvider.notifier)
child: child!, .getContainerData(targetContainerId);
);
},
),
Consumer(
child: MenuItemButton(
leadingIcon: const Icon(MdiIcons.folderCancelOutline),
child: const Text('Unassign Container'),
onPressed: () async {
final tabState = ref.read(tabStateProvider(selectedTabId))!;
await ref if (containerData != null) {
.read(tabDataRepositoryProvider.notifier) final tabState = ref.read(
.unassignContainer(tabState.id); tabStateProvider(selectedTabId),
);
final origin = tabState?.url.origin.mapNotNull(
Uri.parse,
);
if (origin != null) {
await ref
.read(containerRepositoryProvider.notifier)
.replaceContainer(
containerData.copyWith.metadata(
containerData.metadata.copyWith.assignedSites(
[
...?containerData.metadata.assignedSites,
origin,
],
),
),
);
}
}
}
},
),
builder: (context, ref, child) {
final isSiteAssigned = ref.watch(
watchIsCurrentSiteAssignedToContainerProvider,
);
return Visibility(
visible:
isSiteAssigned.hasValue && !isSiteAssigned.requireValue,
child: child!,
);
}, },
), ),
builder: (context, ref, child) { Consumer(
final containerId = ref.watch( child: MenuItemButton(
watchContainerTabIdProvider( leadingIcon: const Icon(MdiIcons.folderCancelOutline),
selectedTabId, child: const Text('Unassign Container'),
).select((value) => value.value), onPressed: () async {
); final tabState = ref.read(tabStateProvider(selectedTabId))!;
return Visibility(visible: containerId != null, child: child!); await ref
}, .read(tabDataRepositoryProvider.notifier)
), .unassignContainer(tabState.id);
], },
leadingIcon: const Icon(MdiIcons.folder), ),
child: const Text('Container'), builder: (context, ref, child) {
), final containerId = ref.watch(
SubmenuButton( watchContainerTabIdProvider(
menuChildren: [ selectedTabId,
CopyAddressMenuItemButton(selectedTabId: selectedTabId), ).select((value) => value.value),
LaunchExternalMenuItemButton(selectedTabId: selectedTabId),
ShareScreenshotMenuItemButton(selectedTabId: selectedTabId),
ShareMenuItemButton(selectedTabId: selectedTabId),
ShowQrCodeMenuItemButton(selectedTabId: selectedTabId),
],
leadingIcon: const Icon(Icons.share),
child: const Text('Share'),
),
SubmenuButton(
menuChildren: [
ShareMarkdownActionMenuItemButton(
selectedTabId: selectedTabId,
title: const Text('Copy as Markdown'),
// ignore: deprecated_member_use
icon: const Icon(MdiIcons.languageMarkdownOutline),
shareMarkdownAction: (content, fileName) async {
await Clipboard.setData(ClipboardData(text: content));
if (context.mounted) {
ui_helper.showInfoMessage(
context,
'Markdown copied to clipboard',
); );
}
},
),
ShareMarkdownActionMenuItemButton(
selectedTabId: selectedTabId,
title: const Text('Export as Markdown'),
// ignore: deprecated_member_use
icon: const Icon(MdiIcons.languageMarkdown),
shareMarkdownAction: (content, fileName) async {
await FilePicker.platform.saveFile(
fileName: fileName,
type: FileType.custom,
allowedExtensions: ['md'],
bytes: utf8.encode(content),
);
},
),
SaveToPdfMenuItemButton(selectedTabId: selectedTabId),
],
leadingIcon: const Icon(MdiIcons.fileExport),
child: const Text('Export'),
),
MenuItemButton(
onPressed: () async {
await ref
.read(tabRepositoryProvider.notifier)
.closeTab(selectedTabId);
if (context.mounted) { return Visibility(
ui_helper.showTabUndoClose( visible: containerId != null,
context, child: child!,
ref.read(tabRepositoryProvider.notifier).undoClose, );
); },
} ),
}, ],
leadingIcon: const Icon(Icons.close), leadingIcon: const Icon(MdiIcons.folder),
child: const Text('Close Tab'), child: const Text('Container'),
), ),
if (enableShare)
SubmenuButton(
menuChildren: [
CopyAddressMenuItemButton(selectedTabId: selectedTabId),
LaunchExternalMenuItemButton(selectedTabId: selectedTabId),
ShareScreenshotMenuItemButton(selectedTabId: selectedTabId),
ShareMenuItemButton(selectedTabId: selectedTabId),
ShowQrCodeMenuItemButton(selectedTabId: selectedTabId),
],
leadingIcon: const Icon(Icons.share),
child: const Text('Share'),
),
if (enableExport)
SubmenuButton(
menuChildren: [
ShareMarkdownActionMenuItemButton(
selectedTabId: selectedTabId,
title: const Text('Copy as Markdown'),
// ignore: deprecated_member_use
icon: const Icon(MdiIcons.languageMarkdownOutline),
shareMarkdownAction: (content, fileName) async {
await Clipboard.setData(ClipboardData(text: content));
if (context.mounted) {
ui_helper.showInfoMessage(
context,
'Markdown copied to clipboard',
);
}
},
),
ShareMarkdownActionMenuItemButton(
selectedTabId: selectedTabId,
title: const Text('Export as Markdown'),
// ignore: deprecated_member_use
icon: const Icon(MdiIcons.languageMarkdown),
shareMarkdownAction: (content, fileName) async {
await FilePicker.platform.saveFile(
fileName: fileName,
type: FileType.custom,
allowedExtensions: ['md'],
bytes: utf8.encode(content),
);
},
),
SaveToPdfMenuItemButton(selectedTabId: selectedTabId),
],
leadingIcon: const Icon(MdiIcons.fileExport),
child: const Text('Export'),
),
if (enableCloseTab)
MenuItemButton(
onPressed: () async {
await ref
.read(tabRepositoryProvider.notifier)
.closeTab(selectedTabId);
if (context.mounted) {
ui_helper.showTabUndoClose(
context,
ref.read(tabRepositoryProvider.notifier).undoClose,
);
}
},
leadingIcon: const Icon(Icons.close),
child: const Text('Close Tab'),
),
], ],
child: child,
); );
} }
} }