decouple app bar related widgets from logic

This commit is contained in:
Fabian Freund
2026-03-05 07:54:45 +01:00
parent c3e39b3a1d
commit 1fc8cc5fab
4 changed files with 721 additions and 457 deletions
@@ -18,7 +18,6 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
@@ -27,6 +26,7 @@ import 'package:weblibre/core/design/app_colors.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/extensions/uri.dart';
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/entities/sheet.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/providers/site_settings_badge_provider.dart';
@@ -35,14 +35,11 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/widget
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
class CompactAppBarTitle extends HookConsumerWidget {
class CompactAppBarTitle extends ConsumerWidget {
const CompactAppBarTitle({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final appColors = AppColors.of(context);
final tabState = ref.watch(selectedTabStateProvider);
final isTabTuneledAsync = ref.watch(isTabTunneledProvider(tabState?.id));
final showSiteSettingsBadge = ref.watch(
@@ -53,42 +50,60 @@ class CompactAppBarTitle extends HookConsumerWidget {
return const SizedBox.shrink();
}
final icon = useMemoized(() {
if (tabState.url.isHttp) {
return Icon(
MdiIcons.lockOff,
color: Theme.of(context).colorScheme.error,
size: 16,
);
} else if (tabState.readerableState.active) {
return const Icon(MdiIcons.lockMinus, size: 16);
} else if (!tabState.securityInfoState.secure) {
return Icon(
MdiIcons.lockAlert,
color: Theme.of(context).colorScheme.errorContainer,
size: 16,
);
} else if (!tabState.isLoading) {
return const Icon(MdiIcons.lock, size: 16);
} else {
return const Icon(MdiIcons.timerSand, size: 16);
}
}, [tabState]);
return CompactAppBarTitleView(
tabState: tabState,
isTabTunneled:
isTabTuneledAsync.hasValue && isTabTuneledAsync.value == true,
showSiteSettingsBadge: showSiteSettingsBadge,
onSiteSettingsTap: () {
ref
.read(bottomSheetControllerProvider.notifier)
.show(SiteSettingsSheet(tabState: tabState));
},
onTitleTap: () async {
await SearchRoute(
tabId: tabState.id,
searchText: _searchTextForTab(tabState),
tabType: tabState.tabMode.toTabType(),
).push(context);
},
);
}
}
class CompactAppBarTitleView extends StatelessWidget {
const CompactAppBarTitleView({
super.key,
required this.tabState,
required this.isTabTunneled,
required this.showSiteSettingsBadge,
required this.onSiteSettingsTap,
required this.onTitleTap,
this.tabIcon,
});
final TabState tabState;
final bool isTabTunneled;
final bool showSiteSettingsBadge;
final VoidCallback onSiteSettingsTap;
final VoidCallback onTitleTap;
final Widget? tabIcon;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final appColors = AppColors.of(context);
return Row(
children: [
ToolbarButton(
onTap: () {
ref
.read(bottomSheetControllerProvider.notifier)
.show(SiteSettingsSheet(tabState: tabState));
},
onTap: onSiteSettingsTap,
child: Padding(
padding: const EdgeInsets.only(right: 4.0),
child: Stack(
clipBehavior: Clip.none,
children: [
TabIcon(tabState: tabState, iconSize: 24),
tabIcon ?? TabIcon(tabState: tabState, iconSize: 24),
Positioned(
top: -4,
right: -4,
@@ -106,19 +121,7 @@ class CompactAppBarTitle extends HookConsumerWidget {
),
Expanded(
child: GestureDetector(
onTap: () async {
final searchText = tabState.url.scheme == 'about'
? ''
: tabState.url.toString();
await SearchRoute(
tabId: tabState.id,
searchText: searchText.isEmpty
? SearchRoute.emptySearchText
: searchText,
tabType: tabState.tabMode.toTabType(),
).push(context);
},
onTap: onTitleTap,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
decoration: BoxDecoration(
@@ -144,12 +147,11 @@ class CompactAppBarTitle extends HookConsumerWidget {
),
const SizedBox(width: 4),
],
if (isTabTuneledAsync.hasValue &&
isTabTuneledAsync.value == true) ...[
if (isTabTunneled) ...[
const Icon(MdiIcons.tunnelOutline, size: 16),
const SizedBox(width: 4),
],
icon,
_SecurityStatusIcon(tabState: tabState, size: 16),
const SizedBox(width: 6),
Flexible(
child: UriBreadcrumb(
@@ -170,14 +172,11 @@ class CompactAppBarTitle extends HookConsumerWidget {
}
}
class AppBarTitle extends HookConsumerWidget {
class AppBarTitle extends ConsumerWidget {
const AppBarTitle({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final appColors = AppColors.of(context);
final tabState = ref.watch(selectedTabStateProvider);
final isTabTuneledAsync = ref.watch(isTabTunneledProvider(tabState?.id));
final showSiteSettingsBadge = ref.watch(
@@ -188,42 +187,60 @@ class AppBarTitle extends HookConsumerWidget {
return const SizedBox.shrink();
}
final icon = useMemoized(() {
if (tabState.url.isHttp) {
return Icon(
MdiIcons.lockOff,
color: Theme.of(context).colorScheme.error,
size: 14,
);
} else if (tabState.readerableState.active) {
return const Icon(MdiIcons.lockMinus, size: 14);
} else if (!tabState.securityInfoState.secure) {
return Icon(
MdiIcons.lockAlert,
color: Theme.of(context).colorScheme.errorContainer,
size: 14,
);
} else if (!tabState.isLoading) {
return const Icon(MdiIcons.lock, size: 14);
} else {
return const Icon(MdiIcons.timerSand, size: 14);
}
}, [tabState]);
return AppBarTitleView(
tabState: tabState,
isTabTunneled:
isTabTuneledAsync.hasValue && isTabTuneledAsync.value == true,
showSiteSettingsBadge: showSiteSettingsBadge,
onSiteSettingsTap: () {
ref
.read(bottomSheetControllerProvider.notifier)
.show(SiteSettingsSheet(tabState: tabState));
},
onTitleTap: () async {
await SearchRoute(
tabId: tabState.id,
searchText: _searchTextForTab(tabState),
tabType: tabState.tabMode.toTabType(),
).push(context);
},
);
}
}
class AppBarTitleView extends StatelessWidget {
const AppBarTitleView({
super.key,
required this.tabState,
required this.isTabTunneled,
required this.showSiteSettingsBadge,
required this.onSiteSettingsTap,
required this.onTitleTap,
this.tabIcon,
});
final TabState tabState;
final bool isTabTunneled;
final bool showSiteSettingsBadge;
final VoidCallback onSiteSettingsTap;
final VoidCallback onTitleTap;
final Widget? tabIcon;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final appColors = AppColors.of(context);
return Row(
children: [
ToolbarButton(
onTap: () {
ref
.read(bottomSheetControllerProvider.notifier)
.show(SiteSettingsSheet(tabState: tabState));
},
onTap: onSiteSettingsTap,
child: Padding(
padding: const EdgeInsets.only(right: 4.0),
child: Stack(
clipBehavior: Clip.none,
children: [
TabIcon(tabState: tabState, iconSize: 24),
tabIcon ?? TabIcon(tabState: tabState, iconSize: 24),
Positioned(
top: -4,
right: -4,
@@ -239,23 +256,9 @@ class AppBarTitle extends HookConsumerWidget {
),
),
),
// Title/URL tap → opens search screen
Expanded(
child: GestureDetector(
onTap: () async {
// Don't pre-fill for internal URLs
final searchText = tabState.url.scheme == 'about'
? ''
: tabState.url.toString();
await SearchRoute(
tabId: tabState.id,
searchText: searchText.isEmpty
? SearchRoute.emptySearchText
: searchText,
tabType: tabState.tabMode.toTabType(),
).push(context);
},
onTap: onTitleTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
@@ -273,7 +276,6 @@ class AppBarTitle extends HookConsumerWidget {
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurface,
),
// mode: TextScrollMode.bouncing,
velocity: const Velocity(pixelsPerSecond: Offset(75, 0)),
delayBefore: const Duration(milliseconds: 500),
pauseBetween: const Duration(milliseconds: 5000),
@@ -302,12 +304,11 @@ class AppBarTitle extends HookConsumerWidget {
),
const SizedBox(width: 4),
],
if (isTabTuneledAsync.hasValue &&
isTabTuneledAsync.value == true) ...[
if (isTabTunneled) ...[
const Icon(MdiIcons.tunnelOutline, size: 14),
const SizedBox(width: 4),
],
icon,
_SecurityStatusIcon(tabState: tabState, size: 14),
const SizedBox(width: 4),
Expanded(
child: UriBreadcrumb(
@@ -328,3 +329,41 @@ class AppBarTitle extends HookConsumerWidget {
);
}
}
class _SecurityStatusIcon extends StatelessWidget {
const _SecurityStatusIcon({required this.tabState, required this.size});
final TabState tabState;
final double size;
@override
Widget build(BuildContext context) {
if (tabState.url.isHttp) {
return Icon(
MdiIcons.lockOff,
color: Theme.of(context).colorScheme.error,
size: size,
);
} else if (tabState.readerableState.active) {
return Icon(MdiIcons.lockMinus, size: size);
} else if (!tabState.securityInfoState.secure) {
return Icon(
MdiIcons.lockAlert,
color: Theme.of(context).colorScheme.errorContainer,
size: size,
);
} else if (!tabState.isLoading) {
return Icon(MdiIcons.lock, size: size);
}
return Icon(MdiIcons.timerSand, size: size);
}
}
String _searchTextForTab(TabState tabState) {
final searchText = tabState.url.scheme == 'about'
? ''
: tabState.url.toString();
return searchText.isEmpty ? SearchRoute.emptySearchText : searchText;
}
@@ -27,12 +27,10 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.dart';
import 'package:weblibre/core/design/app_colors.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/readerable.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
@@ -60,7 +58,7 @@ import 'package:weblibre/presentation/hooks/menu_controller.dart';
import 'package:weblibre/presentation/widgets/selectable_chips.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart';
class BrowserTopAppBar extends HookConsumerWidget {
class BrowserTopAppBar extends StatelessWidget {
final bool showMainToolbar;
final bool showContextualToolbar;
final bool showQuickTabSwitcherBar;
@@ -85,7 +83,7 @@ class BrowserTopAppBar extends HookConsumerWidget {
}
@override
Widget build(BuildContext context, WidgetRef ref) {
Widget build(BuildContext context) {
return SafeArea(
child: SizedBox(height: preferredSize.height, child: _tabBar),
);
@@ -94,7 +92,7 @@ class BrowserTopAppBar extends HookConsumerWidget {
Size get preferredSize => _size;
}
class BrowserBottomAppBar extends HookConsumerWidget {
class BrowserBottomAppBar extends StatelessWidget {
final bool showMainToolbar;
final bool showContextualToolbar;
final bool showQuickTabSwitcherBar;
@@ -121,7 +119,7 @@ class BrowserBottomAppBar extends HookConsumerWidget {
}
@override
Widget build(BuildContext context, WidgetRef ref) {
Widget build(BuildContext context) {
final bottomPadding = MediaQuery.of(context).padding.bottom;
return Material(
@@ -205,8 +203,97 @@ class BrowserTabBar extends HookConsumerWidget {
final dragStartPosition = useRef(Offset.zero);
return GestureDetector(
// Tap handling moved to AppBarTitle for split icon/title behavior
final showTabTitle =
selectedTabId != null && displayedSheet is! ViewTabsSheet;
final backgroundColor =
(settings.showContainerUi &&
containerColor != null &&
displayedSheet is! ViewTabsSheet)
? ContainerColors.forAppBar(containerColor)
: null;
return BrowserTabBarView(
showMainToolbar: showMainToolbar,
showContextualToolbar: showContextualToolbar,
showQuickTabSwitcherBar: showQuickTabSwitcherBar,
displayAppBar: displayAppBar,
displayQuickTabSwitcher: displayQuickTabSwitcher,
backgroundColor: backgroundColor,
title: showTabTitle
? settings.tabBarLayout == TabBarLayout.compact
? const CompactAppBarTitle()
: const AppBarTitle()
: null,
actions: [
if (showTabTitle)
Consumer(
builder: (context, ref, child) {
final tabBarReaderView = ref.watch(
generalSettingsWithDefaultsProvider.select(
(value) => value.tabBarReaderView,
),
);
final readerabilityStateActive = ref.watch(
selectedTabStateProvider.select(
(state) =>
(state?.readerableState ?? ReaderableState.$default())
.active,
),
);
return Visibility(
visible: tabBarReaderView || readerabilityStateActive,
child: ReaderButton(
buttonBuilder: (isLoading, readerActive, icon) =>
ToolbarButton(
onTap: isLoading
? null
: () async {
await ref
.read(
readerableScreenControllerProvider
.notifier,
)
.toggleReaderView(!readerActive);
},
child: icon,
),
),
);
},
),
if (showExtensionShortcut)
ExtensionShortcutMenu(
controller: extensionMenuController,
child: ToolbarButton(
onTap: () {
if (extensionMenuController.isOpen) {
extensionMenuController.close();
} else {
extensionMenuController.open();
}
},
child: const Icon(MdiIcons.puzzle),
),
),
if (showMainToolbarTabsCount)
TabsCountButton(
selectedTabId: selectedTabId,
displayedSheet: displayedSheet,
showLongPressMenu: true,
),
if (showMainToolbarNavigationButton)
NavigationMenuButton(selectedTabId: selectedTabId),
],
quickTabSwitcher: QuickTabSwitcher(
quickTabSwitcherMode: quickTabSwitcherMode,
),
contextualToolbar: ContextualToolbar(
selectedTabId: selectedTabId,
displayedSheet: displayedSheet,
),
onHorizontalDragStart: (details) {
dragStartPosition.value = details.globalPosition;
},
@@ -262,6 +349,52 @@ class BrowserTabBar extends HookConsumerWidget {
.dismiss();
}
},
);
}
}
class BrowserTabBarView extends StatelessWidget {
const BrowserTabBarView({
super.key,
required this.showMainToolbar,
required this.showContextualToolbar,
required this.showQuickTabSwitcherBar,
required this.displayAppBar,
required this.displayQuickTabSwitcher,
required this.backgroundColor,
required this.title,
required this.actions,
required this.quickTabSwitcher,
required this.contextualToolbar,
this.onHorizontalDragStart,
this.onHorizontalDragEnd,
this.onVerticalDragStart,
this.onVerticalDragEnd,
});
final bool showMainToolbar;
final bool showContextualToolbar;
final bool showQuickTabSwitcherBar;
final bool displayAppBar;
final bool displayQuickTabSwitcher;
final Color? backgroundColor;
final Widget? title;
final List<Widget> actions;
final Widget quickTabSwitcher;
final Widget contextualToolbar;
final GestureDragStartCallback? onHorizontalDragStart;
final GestureDragEndCallback? onHorizontalDragEnd;
final GestureDragStartCallback? onVerticalDragStart;
final GestureDragEndCallback? onVerticalDragEnd;
@override
Widget build(BuildContext context) {
return GestureDetector(
// Tap handling moved to AppBarTitle for split icon/title behavior
onHorizontalDragStart: onHorizontalDragStart,
onHorizontalDragEnd: onHorizontalDragEnd,
onVerticalDragStart: onVerticalDragStart,
onVerticalDragEnd: onVerticalDragEnd,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -269,9 +402,7 @@ class BrowserTabBar extends HookConsumerWidget {
Visibility(
visible: displayQuickTabSwitcher,
maintainState: true,
child: QuickTabSwitcher(
quickTabSwitcherMode: quickTabSwitcherMode,
),
child: quickTabSwitcher,
),
if (showMainToolbar)
Visibility(
@@ -283,95 +414,19 @@ class BrowserTabBar extends HookConsumerWidget {
titleSpacing: 0.0,
leadingWidth: 40.0,
toolbarHeight: kToolbarHeight,
backgroundColor:
(settings.showContainerUi &&
containerColor != null &&
displayedSheet is! ViewTabsSheet)
? ContainerColors.forAppBar(containerColor)
: null,
title:
(selectedTabId != null && displayedSheet is! ViewTabsSheet)
? settings.tabBarLayout == TabBarLayout.compact
? const CompactAppBarTitle()
: const AppBarTitle()
: null,
actions: [
if (selectedTabId != null && displayedSheet is! ViewTabsSheet)
Consumer(
builder: (context, ref, child) {
final tabBarReaderView = ref.watch(
generalSettingsWithDefaultsProvider.select(
(value) => value.tabBarReaderView,
),
);
final readerabilityStateActive = ref.watch(
selectedTabStateProvider.select(
(state) =>
(state?.readerableState ??
ReaderableState.$default())
.active,
),
);
return Visibility(
visible: tabBarReaderView || readerabilityStateActive,
child: ReaderButton(
buttonBuilder: (isLoading, readerActive, icon) =>
ToolbarButton(
onTap: isLoading
? null
: () async {
await ref
.read(
readerableScreenControllerProvider
.notifier,
)
.toggleReaderView(!readerActive);
},
child: icon,
),
),
);
},
),
if (showExtensionShortcut)
ExtensionShortcutMenu(
controller: extensionMenuController,
child: ToolbarButton(
onTap: () {
if (extensionMenuController.isOpen) {
extensionMenuController.close();
} else {
extensionMenuController.open();
}
},
child: const Icon(MdiIcons.puzzle),
),
),
if (showMainToolbarTabsCount)
TabsCountButton(
selectedTabId: selectedTabId,
displayedSheet: displayedSheet,
showLongPressMenu: true,
),
if (showMainToolbarNavigationButton)
NavigationMenuButton(selectedTabId: selectedTabId),
],
backgroundColor: backgroundColor,
title: title,
actions: actions,
),
),
if (showContextualToolbar)
ContextualToolbar(
selectedTabId: selectedTabId,
displayedSheet: displayedSheet,
),
if (showContextualToolbar) contextualToolbar,
],
),
);
}
}
class _QuickTabItem with FastEquatable {
class QuickTabSwitcherItem with FastEquatable {
final Color? color;
final String id;
final TabMode tabMode;
@@ -379,9 +434,9 @@ class _QuickTabItem with FastEquatable {
final bool isPinned;
final String title;
final Uri url;
final TabState? tabState;
final Widget avatar;
_QuickTabItem({
QuickTabSwitcherItem({
required this.color,
required this.id,
required this.tabMode,
@@ -389,7 +444,7 @@ class _QuickTabItem with FastEquatable {
required this.isPinned,
required this.title,
required this.url,
required this.tabState,
required this.avatar,
});
@override
@@ -401,7 +456,7 @@ class _QuickTabItem with FastEquatable {
isPinned,
title,
url,
tabState,
avatar,
];
}
@@ -419,35 +474,71 @@ class ContextualToolbar extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final tabState = ref.watch(tabStateProvider(selectedTabId));
return ContextualToolbarView(
canGoBack:
tabState?.historyState.canGoBack == true ||
tabState?.isLoading == true,
canGoForward: tabState?.historyState.canGoForward == true,
onBookmarksTap: () async {
await BookmarkListRoute(entryGuid: BookmarkRoot.root.id).push(context);
},
backButton: NavigateBackButton(
selectedTabId: selectedTabId,
isLoading: tabState?.isLoading ?? false,
),
forwardButton: NavigateForwardButton(selectedTabId: selectedTabId),
shareButton: ShareMenuButton(selectedTabId: selectedTabId),
addTabButton: const AddTabButton(),
tabsCountButton: TabsCountButton(
selectedTabId: selectedTabId,
displayedSheet: displayedSheet,
showLongPressMenu: false,
),
navigationButton: NavigationMenuButton(selectedTabId: selectedTabId),
);
}
}
class ContextualToolbarView extends StatelessWidget {
const ContextualToolbarView({
super.key,
required this.canGoBack,
required this.canGoForward,
required this.onBookmarksTap,
required this.backButton,
required this.forwardButton,
required this.shareButton,
required this.addTabButton,
required this.tabsCountButton,
required this.navigationButton,
});
final bool canGoBack;
final bool canGoForward;
final VoidCallback onBookmarksTap;
final Widget backButton;
final Widget forwardButton;
final Widget shareButton;
final Widget addTabButton;
final Widget tabsCountButton;
final Widget navigationButton;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
if (tabState?.historyState.canGoBack == true ||
tabState?.isLoading == true)
NavigateBackButton(
selectedTabId: selectedTabId,
isLoading: tabState?.isLoading ?? false,
)
if (canGoBack)
backButton
else
IconButton(
onPressed: () async {
await BookmarkListRoute(
entryGuid: BookmarkRoot.root.id,
).push(context);
},
onPressed: onBookmarksTap,
icon: const Icon(MdiIcons.bookmarkMultiple),
),
if (tabState?.historyState.canGoForward == true)
NavigateForwardButton(selectedTabId: selectedTabId)
else
ShareMenuButton(selectedTabId: selectedTabId),
const AddTabButton(),
TabsCountButton(
selectedTabId: selectedTabId,
displayedSheet: displayedSheet,
showLongPressMenu: false,
),
NavigationMenuButton(selectedTabId: selectedTabId),
if (canGoForward) forwardButton else shareButton,
addTabButton,
tabsCountButton,
navigationButton,
],
);
}
@@ -460,7 +551,6 @@ class QuickTabSwitcher extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final appColors = AppColors.of(context);
final showIsolatedTabUi = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.showIsolatedTabUi),
);
@@ -484,8 +574,8 @@ class QuickTabSwitcher extends HookConsumerWidget {
.watch(quickTabSwitcherHistorySuggestionsProvider(quickTabSwitcherMode))
.value;
final availableItems = tabStates.value
.map<_QuickTabItem>(
(state) => _QuickTabItem(
.map<QuickTabSwitcherItem>(
(state) => QuickTabSwitcherItem(
id: state.$1.id,
title: state.$1.titleOrAuthority,
tabMode: state.$1.tabMode,
@@ -493,14 +583,14 @@ class QuickTabSwitcher extends HookConsumerWidget {
isPinned: pinnedTabIds?.contains(state.$1.id) ?? false,
url: state.$1.url,
color: state.$2?.color,
tabState: state.$1,
avatar: TabIcon(tabState: state.$1, iconSize: 20),
),
)
.followedBy(
(historySuggestions ?? []).map<_QuickTabItem>((state) {
(historySuggestions ?? []).map<QuickTabSwitcherItem>((state) {
final url = Uri.parse(state.url);
return _QuickTabItem(
return QuickTabSwitcherItem(
id: state.url,
title: state.title ?? url.authority,
tabMode: TabMode.regular,
@@ -508,136 +598,164 @@ class QuickTabSwitcher extends HookConsumerWidget {
isPinned: false,
url: url,
color: null,
tabState: null,
avatar: UrlIcon([url], iconSize: 20),
);
}),
)
.toList();
final chipScrollController = useScrollController();
return QuickTabSwitcherView(
availableItems: availableItems,
scrollController: chipScrollController,
showTitles: showTitles,
showIsolatedTabUi: showIsolatedTabUi,
onSelected: (item) async {
final animation = chipScrollController.animateTo(
0,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutBack,
);
if (item.isHistory) {
await ref
.read(tabRepositoryProvider.notifier)
.addTab(url: item.url, tabMode: TabMode.regular, selectTab: true);
} else {
await ref.read(tabRepositoryProvider.notifier).selectTab(item.id);
}
await animation;
},
itemWrapBuilder: (child, item) {
if (item.isHistory) {
return child;
}
return TabMenu(
selectedTabId: item.id,
enableFindInPage: false,
enableFetchFeeds: false,
enableDesktopMode: false,
enableReaderMode: false,
enableReloadButton: false,
enableNavigationButtons: false,
enableAddToHomeScreen: false,
enablePinTab: effectiveMode == QuickTabSwitcherMode.containerTabs,
builder: (context, controller, _) {
return InkWell(
onLongPress: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
child: child,
);
},
);
},
);
}
}
class QuickTabSwitcherView extends StatelessWidget {
const QuickTabSwitcherView({
super.key,
required this.availableItems,
required this.scrollController,
required this.showTitles,
required this.showIsolatedTabUi,
required this.onSelected,
required this.itemWrapBuilder,
});
final List<QuickTabSwitcherItem> availableItems;
final ScrollController scrollController;
final bool showTitles;
final bool showIsolatedTabUi;
final Future<void> Function(QuickTabSwitcherItem item) onSelected;
final Widget Function(Widget child, QuickTabSwitcherItem item)
itemWrapBuilder;
@override
Widget build(BuildContext context) {
final appColors = AppColors.of(context);
if (availableItems.isEmpty) {
return const SizedBox.shrink();
}
final chipScrollController = useScrollController();
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: SizedBox(
height: 48,
width: double.maxFinite,
child: SelectableChips<_QuickTabItem, _QuickTabItem, String>(
enableDelete: false,
scrollController: chipScrollController,
itemId: (item) => item.id,
labelPadding: (item) =>
(!showTitles &&
!item.isHistory &&
!item.isPinned &&
item.tabMode is! PrivateTabMode &&
item.tabMode is! IsolatedTabMode)
? EdgeInsets.zero
: null,
itemLabel: (item) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (item.isHistory || showTitles)
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 64),
child: Text(item.title),
),
if (showIsolatedTabUi && item.tabMode is IsolatedTabMode)
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Icon(
MdiIcons.snowflake,
color: appColors.isolatedTabTeal,
size: 20,
),
)
else if (item.tabMode is PrivateTabMode)
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Icon(
MdiIcons.dominoMask,
color: appColors.privateTabPurple,
size: 20,
),
),
if (item.isPinned)
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Icon(
MdiIcons.pin,
color: Theme.of(context).colorScheme.primary,
size: 20,
),
),
if (item.isHistory)
const Padding(
padding: EdgeInsets.only(left: 8.0),
child: Icon(MdiIcons.history, size: 20),
),
],
);
},
itemAvatar: (item) =>
item.tabState.mapNotNull(
(tabState) => TabIcon(tabState: tabState, iconSize: 20),
) ??
UrlIcon([item.url], iconSize: 20),
itemBackgroundColor: (item) =>
item.color != null ? ContainerColors.forChip(item.color!) : null,
onSelected: (item) async {
final animation = chipScrollController.animateTo(
0,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutBack,
);
if (item.isHistory) {
await ref
.read(tabRepositoryProvider.notifier)
.addTab(
url: item.url,
tabMode: TabMode.regular,
selectTab: true,
);
} else {
await ref.read(tabRepositoryProvider.notifier).selectTab(item.id);
}
await animation;
},
itemWrap: (child, item) {
if (item.isHistory) {
return child;
}
return TabMenu(
selectedTabId: item.id,
enableFindInPage: false,
enableFetchFeeds: false,
enableDesktopMode: false,
enableReaderMode: false,
enableReloadButton: false,
enableNavigationButtons: false,
enableAddToHomeScreen: false,
enablePinTab: effectiveMode == QuickTabSwitcherMode.containerTabs,
builder: (context, controller, _) {
return InkWell(
onLongPress: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
child: child,
child:
SelectableChips<QuickTabSwitcherItem, QuickTabSwitcherItem, String>(
enableDelete: false,
scrollController: scrollController,
itemId: (item) => item.id,
labelPadding: (item) =>
(!showTitles &&
!item.isHistory &&
!item.isPinned &&
item.tabMode is! PrivateTabMode &&
item.tabMode is! IsolatedTabMode)
? EdgeInsets.zero
: null,
itemLabel: (item) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (item.isHistory || showTitles)
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 64),
child: Text(item.title),
),
if (showIsolatedTabUi && item.tabMode is IsolatedTabMode)
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Icon(
MdiIcons.snowflake,
color: appColors.isolatedTabTeal,
size: 20,
),
)
else if (item.tabMode is PrivateTabMode)
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Icon(
MdiIcons.dominoMask,
color: appColors.privateTabPurple,
size: 20,
),
),
if (item.isPinned)
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Icon(
MdiIcons.pin,
color: Theme.of(context).colorScheme.primary,
size: 20,
),
),
if (item.isHistory)
const Padding(
padding: EdgeInsets.only(left: 8.0),
child: Icon(MdiIcons.history, size: 20),
),
],
);
},
);
},
availableItems: availableItems,
),
itemAvatar: (item) => item.avatar,
itemBackgroundColor: (item) => item.color != null
? ContainerColors.forChip(item.color!)
: null,
onSelected: onSelected,
itemWrap: itemWrapBuilder,
availableItems: availableItems,
),
),
);
}
@@ -650,18 +768,28 @@ class ShareMenuButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return IconButton(
return ShareMenuButtonView(
onPressed: () async {
final tabId = selectedTabId;
if (tabId != null) {
await showShareBottomSheet(context, selectedTabId: tabId);
}
},
icon: const Icon(Icons.share),
);
}
}
class ShareMenuButtonView extends StatelessWidget {
const ShareMenuButtonView({super.key, this.onPressed});
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
return IconButton(onPressed: onPressed, icon: const Icon(Icons.share));
}
}
class NavigationMenuButton extends StatelessWidget {
final String? selectedTabId;
@@ -669,15 +797,25 @@ class NavigationMenuButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ToolbarButton(
return NavigationMenuButtonView(
onTap: () async {
await showBrowserMenuSheet(context);
},
child: const Icon(Icons.more_vert),
);
}
}
class NavigationMenuButtonView extends StatelessWidget {
const NavigationMenuButtonView({super.key, this.onTap});
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return ToolbarButton(onTap: onTap, child: const Icon(Icons.more_vert));
}
}
class AddTabButton extends HookConsumerWidget {
const AddTabButton({super.key});
@@ -687,7 +825,7 @@ class AddTabButton extends HookConsumerWidget {
return TabCreationMenu(
controller: tabMenuController,
child: IconButton(
child: AddTabButtonView(
onPressed: () async {
final settings = ref.read(generalSettingsWithDefaultsProvider);
@@ -701,7 +839,6 @@ class AddTabButton extends HookConsumerWidget {
const BrowserRoute().go(context);
}
},
icon: const Icon(MdiIcons.tabPlus),
onLongPress: () {
if (tabMenuController.isOpen) {
tabMenuController.close();
@@ -714,6 +851,53 @@ class AddTabButton extends HookConsumerWidget {
}
}
class AddTabButtonView extends StatelessWidget {
const AddTabButtonView({super.key, this.onPressed, this.onLongPress});
final VoidCallback? onPressed;
final VoidCallback? onLongPress;
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: onPressed,
icon: const Icon(MdiIcons.tabPlus),
onLongPress: onLongPress,
);
}
}
class TabsCountButtonView extends StatelessWidget {
const TabsCountButtonView({
super.key,
required this.isActive,
required this.onTap,
this.onLongPress,
this.buttonBuilder,
});
final bool isActive;
final VoidCallback onTap;
final VoidCallback? onLongPress;
final Widget Function(
bool isActive,
VoidCallback onTap,
VoidCallback? onLongPress,
)?
buttonBuilder;
@override
Widget build(BuildContext context) {
return (buttonBuilder != null)
? buttonBuilder!(isActive, onTap, onLongPress)
: TabsActionButton(
isActive: isActive,
onTap: onTap,
onLongPress: onLongPress,
);
}
}
class TabsCountButton extends HookConsumerWidget {
const TabsCountButton({
super.key,
@@ -732,7 +916,7 @@ class TabsCountButton extends HookConsumerWidget {
return TabCreationMenu(
controller: tabMenuController,
child: TabsActionButton(
child: TabsCountButtonView(
isActive: displayedSheet is ViewTabsSheet,
onTap: () async {
final tabViewBottomSheet = ref
@@ -25,6 +25,52 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/widget
import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart';
import 'package:weblibre/presentation/hooks/menu_controller.dart';
class NavigateForwardButtonView extends StatelessWidget {
const NavigateForwardButtonView({
super.key,
required this.canGoForward,
this.onPressed,
this.onLongPress,
});
final bool canGoForward;
final VoidCallback? onPressed;
final VoidCallback? onLongPress;
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: canGoForward ? onPressed : null,
onLongPress: canGoForward ? onLongPress : null,
icon: const Icon(Icons.arrow_forward),
);
}
}
class NavigateBackButtonView extends StatelessWidget {
const NavigateBackButtonView({
super.key,
required this.canGoBack,
required this.isLoading,
this.onPressed,
this.onLongPress,
});
final bool canGoBack;
final bool isLoading;
final VoidCallback? onPressed;
final VoidCallback? onLongPress;
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: (canGoBack || isLoading) ? onPressed : null,
onLongPress: (canGoBack && !isLoading) ? onLongPress : null,
icon: isLoading ? const Icon(Icons.close) : const Icon(Icons.arrow_back),
);
}
}
class NavigateForwardButton extends HookConsumerWidget {
const NavigateForwardButton({
super.key,
@@ -45,25 +91,21 @@ class NavigateForwardButton extends HookConsumerWidget {
selectedTabId: selectedTabId,
controller: historyMenuController,
direction: HistoryMenuDirection.forward,
child: IconButton(
onPressed: canGoForward
? () async {
final controller = ref.read(
tabSessionProvider(tabId: selectedTabId).notifier,
);
child: NavigateForwardButtonView(
canGoForward: canGoForward,
onPressed: () async {
final controller = ref.read(
tabSessionProvider(tabId: selectedTabId).notifier,
);
await controller.goForward();
menuControllerToClose?.close();
}
: null,
onLongPress: canGoForward
? () {
if (!historyMenuController.isOpen) {
historyMenuController.open();
}
}
: null,
icon: const Icon(Icons.arrow_forward),
await controller.goForward();
menuControllerToClose?.close();
},
onLongPress: () {
if (!historyMenuController.isOpen) {
historyMenuController.open();
}
},
),
);
}
@@ -91,42 +133,37 @@ class NavigateBackButton extends HookConsumerWidget {
selectedTabId: selectedTabId,
controller: historyMenuController,
direction: HistoryMenuDirection.back,
child: IconButton(
onPressed: (canGoBack || isLoading)
? () async {
final controller = ref.read(
tabSessionProvider(tabId: selectedTabId).notifier,
);
child: NavigateBackButtonView(
canGoBack: canGoBack,
isLoading: isLoading,
onPressed: () async {
final controller = ref.read(
tabSessionProvider(tabId: selectedTabId).notifier,
);
final isReaderActive = ref.read(
selectedTabStateProvider.select(
(state) => state?.readerableState.active ?? false,
),
);
final isReaderActive = ref.read(
selectedTabStateProvider.select(
(state) => state?.readerableState.active ?? false,
),
);
if (isLoading) {
await controller.stopLoading();
} else if (isReaderActive) {
await ref
.read(readerableScreenControllerProvider.notifier)
.toggleReaderView(false);
} else {
await controller.goBack();
}
if (isLoading) {
await controller.stopLoading();
} else if (isReaderActive) {
await ref
.read(readerableScreenControllerProvider.notifier)
.toggleReaderView(false);
} else {
await controller.goBack();
}
menuControllerToClose?.close();
}
: null,
onLongPress: (canGoBack && !isLoading)
? () {
if (!historyMenuController.isOpen) {
historyMenuController.open();
}
}
: null,
icon: isLoading
? const Icon(Icons.close)
: const Icon(Icons.arrow_back),
menuControllerToClose?.close();
},
onLongPress: () {
if (!historyMenuController.isOpen) {
historyMenuController.open();
}
},
),
);
}
@@ -26,6 +26,56 @@ import 'package:weblibre/features/geckoview/features/tabs/data/entities/containe
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart';
class TabsActionButtonView extends StatelessWidget {
const TabsActionButtonView({
super.key,
required this.isActive,
required this.tabCountText,
this.showSkeleton = false,
this.onTap,
this.onDoubleTap,
this.onLongPress,
});
final bool isActive;
final String tabCountText;
final bool showSkeleton;
final VoidCallback? onTap;
final VoidCallback? onDoubleTap;
final VoidCallback? onLongPress;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final iconColor = isActive
? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant;
final text = Text(
tabCountText,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14.0,
color: iconColor,
),
);
return ToolbarButton(
onTap: onTap,
onDoubleTap: onDoubleTap,
onLongPress: onLongPress,
child: Container(
decoration: BoxDecoration(
border: Border.all(width: 2.0, color: iconColor),
borderRadius: BorderRadius.circular(5.0),
),
constraints: const BoxConstraints(minWidth: 25.0),
child: Center(child: showSkeleton ? Skeletonizer(child: text) : text),
),
);
}
}
class TabsActionButton extends HookConsumerWidget {
final bool isActive;
final VoidCallback? onTap;
@@ -42,78 +92,32 @@ class TabsActionButton extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final tabCount = isActive
// ignore: provider_parameters
? ref.watch(containerTabCountProvider(ContainerFilterDisabled()))
: ref.watch(selectedContainerTabCountProvider);
final iconColor = isActive
? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant;
final tabCountText = tabCount.when(
skipLoadingOnReload: true,
data: (count) => count.toString(),
loading: () => tabCount.hasValue ? tabCount.value.toString() : '0',
error: (error, stackTrace) {
logger.e(
'Could not determine tab count',
error: error,
stackTrace: stackTrace,
);
return '-1';
},
);
return ToolbarButton(
return TabsActionButtonView(
isActive: isActive,
tabCountText: tabCountText,
showSkeleton: tabCount.isLoading && !tabCount.hasValue,
onTap: onTap,
onDoubleTap: onDoubleTap,
onLongPress: onLongPress,
child: Container(
decoration: BoxDecoration(
border: Border.all(width: 2.0, color: iconColor),
borderRadius: BorderRadius.circular(5.0),
),
constraints: const BoxConstraints(minWidth: 25.0),
child: Center(
child: tabCount.when(
skipLoadingOnReload: true,
data: (count) {
return Text(
count.toString(),
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14.0,
color: iconColor,
),
);
},
loading: () => (tabCount.hasValue)
? Text(
tabCount.value.toString(),
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14.0,
color: iconColor,
),
)
: Skeletonizer(
child: Text(
'0',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14.0,
color: iconColor,
),
),
),
error: (error, stackTrace) {
logger.e(
'Could not determine tab count',
error: error,
stackTrace: stackTrace,
);
return Text(
'-1',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14.0,
color: iconColor,
),
);
},
),
),
),
);
}
}