From 1e4eadaab258475671702fffccc910eac9dea0e3 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Thu, 8 Jan 2026 14:28:34 +0100 Subject: [PATCH 1/4] replace scaffold --- .../browser/presentation/screens/browser.dart | 251 +++++++++++------- packages/flutter_tor/lib/flutter_tor.dart | 1 - packages/flutter_tor/lib/src/flutter_tor.dart | 5 - 3 files changed, 149 insertions(+), 108 deletions(-) diff --git a/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart b/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart index f9d776be..fe9738e2 100644 --- a/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart +++ b/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart @@ -56,12 +56,65 @@ import 'package:weblibre/features/user/data/models/general_settings.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/utils/ui_helper.dart' as ui_helper; +/// Animated toolbar that slides in/out without changing layout constraints. +/// Uses SlideTransition to animate visual transform while maintaining +/// constant intrinsic size for layout purposes. +class _AnimatedToolbar extends HookWidget { + final bool visible; + final TabBarPosition position; + final Widget child; + + static const _kAnimationDuration = Duration(milliseconds: 250); + + const _AnimatedToolbar({ + required this.visible, + required this.position, + required this.child, + }); + + @override + Widget build(BuildContext context) { + final controller = useAnimationController( + duration: _kAnimationDuration, + initialValue: visible ? 1.0 : 0.0, + ); + + useEffect(() { + if (visible) { + unawaited(controller.forward()); + } else { + unawaited(controller.reverse()); + } + return null; + }, [visible]); + + final slideAnimation = useMemoized( + () { + final begin = position == TabBarPosition.top + ? const Offset(0, -1) + : const Offset(0, 1); + + return Tween( + begin: begin, + end: Offset.zero, + ).animate( + CurvedAnimation(parent: controller, curve: Curves.easeInOutQuart), + ); + }, + [position], + ); + + return SlideTransition(position: slideAnimation, child: child); + } +} + +/// Manages scroll-based auto-hide logic and returns the toolbar widget. +/// Animation is handled by the parent _AnimatedToolbar wrapper. class _TabBar extends HookConsumerWidget { final bool showMainToolbar; final bool showContextualToolbar; final bool showQuickTabSwitcherBar; final ValueNotifier displayAppBar; - final ValueNotifier sheetController; final Stream? pointerMoveEvents; final TabBarPosition tabBarPosition; @@ -70,7 +123,6 @@ class _TabBar extends HookConsumerWidget { required this.showContextualToolbar, required this.showQuickTabSwitcherBar, required this.displayAppBar, - required this.sheetController, required this.tabBarPosition, required this.pointerMoveEvents, }); @@ -80,10 +132,6 @@ class _TabBar extends HookConsumerWidget { final tabId = ref.watch(selectedTabProvider); final displayedSheet = ref.watch(bottomSheetControllerProvider); - final tabInFullScreen = ref.watch( - selectedTabStateProvider.select((value) => value?.isFullScreen ?? false), - ); - final autoHideTabBar = switch (tabBarPosition) { TabBarPosition.top => false, TabBarPosition.bottom => ref.watch( @@ -93,48 +141,26 @@ class _TabBar extends HookConsumerWidget { ), }; - final appBarVisible = tabBarPosition == TabBarPosition.top - ? !ref.watch(tabBarDismissableControllerProvider) - : useValueListenable(displayAppBar); - - if (!autoHideTabBar) { - return Visibility( - visible: !tabInFullScreen && appBarVisible, - child: switch (tabBarPosition) { - TabBarPosition.top => BrowserTopAppBar( - showMainToolbar: showMainToolbar, - showContextualToolbar: showContextualToolbar, - showQuickTabSwitcherBar: showQuickTabSwitcherBar, - ), - TabBarPosition.bottom => BrowserBottomAppBar( - displayedSheet: displayedSheet, - showMainToolbar: showMainToolbar, - showContextualToolbar: showContextualToolbar, - showQuickTabSwitcherBar: showQuickTabSwitcherBar, - ), - }, - ); - } - + // Auto-hide scroll detection hooks (run unconditionally per hook rules) final diffAcc = useRef(0.0); void resetHiddenState() { if (!ref.read(tabBarDismissableControllerProvider)) { displayAppBar.value = true; } - diffAcc.value = 0.0; } useEffect(() { + if (!autoHideTabBar) return null; WidgetsBinding.instance.addPostFrameCallback((_) { resetHiddenState(); }); - return null; - }, [tabId]); + }, [tabId, autoHideTabBar]); useOnAppLifecycleStateChange((previous, current) { + if (!autoHideTabBar) return; if (current == AppLifecycleState.resumed) { resetHiddenState(); } @@ -144,6 +170,7 @@ class _TabBar extends HookConsumerWidget { previous, next, ) { + if (!autoHideTabBar) return; if (next == true) { resetHiddenState(); } @@ -153,6 +180,7 @@ class _TabBar extends HookConsumerWidget { previous, next, ) { + if (!autoHideTabBar) return; if (next != null && previous != null) { if (previous != next) { resetHiddenState(); @@ -163,12 +191,12 @@ class _TabBar extends HookConsumerWidget { useOnStreamChange( pointerMoveEvents, onData: (event) { + if (!autoHideTabBar) return; final diff = event.dy; if (diff < 0) { if (diffAcc.value > 0) { diffAcc.value = 0.0; } - diffAcc.value += diff; if (diffAcc.value.abs() > kToolbarHeight * 1.5) { displayAppBar.value = false; @@ -177,7 +205,6 @@ class _TabBar extends HookConsumerWidget { if (diffAcc.value < 0) { diffAcc.value = 0.0; } - diffAcc.value += diff; if (diffAcc.value.abs() > kToolbarHeight) { resetHiddenState(); @@ -186,29 +213,20 @@ class _TabBar extends HookConsumerWidget { }, ); - return AnimatedSize( - duration: const Duration(milliseconds: 250), - curve: Curves.easeInOutQuart, - child: Visibility( - visible: - sheetController.value != null || - (!tabInFullScreen && appBarVisible), - maintainState: true, - child: switch (tabBarPosition) { - TabBarPosition.top => BrowserTopAppBar( - showMainToolbar: showMainToolbar, - showContextualToolbar: showContextualToolbar, - showQuickTabSwitcherBar: showQuickTabSwitcherBar, - ), - TabBarPosition.bottom => BrowserBottomAppBar( - showMainToolbar: showMainToolbar, - displayedSheet: displayedSheet, - showContextualToolbar: showContextualToolbar, - showQuickTabSwitcherBar: showQuickTabSwitcherBar, - ), - }, + // Return the toolbar widget - parent handles animation + return switch (tabBarPosition) { + TabBarPosition.top => BrowserTopAppBar( + showMainToolbar: showMainToolbar, + showContextualToolbar: showContextualToolbar, + showQuickTabSwitcherBar: showQuickTabSwitcherBar, ), - ); + TabBarPosition.bottom => BrowserBottomAppBar( + displayedSheet: displayedSheet, + showMainToolbar: showMainToolbar, + showContextualToolbar: showContextualToolbar, + showQuickTabSwitcherBar: showQuickTabSwitcherBar, + ), + }; } } @@ -244,13 +262,9 @@ class BrowserScreen extends HookConsumerWidget { ); final displayAppBar = useValueNotifier(true); - final removeTopAppBar = useState(false); ref.listen(tabBarDismissableControllerProvider, (previous, next) { displayAppBar.value = !next; - if (tabBarPosition == TabBarPosition.bottom) { - removeTopAppBar.value = next; - } }); ref.listen(overlayControllerProvider, (previous, next) { @@ -290,6 +304,24 @@ class BrowserScreen extends HookConsumerWidget { final pointerMoveEventsController = useStreamController(); + // Compute visibility states for toolbars + final appBarVisible = useValueListenable(displayAppBar); + final topDismissed = ref.watch(tabBarDismissableControllerProvider); + + // Toolbar is visible when: sheet is shown OR (not fullscreen AND app bar visible) + final topToolbarVisible = + sheetController.value != null || (!tabInFullScreen && !topDismissed); + final bottomToolbarVisible = + sheetController.value != null || (!tabInFullScreen && appBarVisible); + + // Calculate bottom toolbar size for FAB positioning + final bottomAppBarSize = BrowserBottomAppBar( + showMainToolbar: tabBarPosition == TabBarPosition.bottom, + showContextualToolbar: showContextualToolbar, + showQuickTabSwitcherBar: showQuickTabSwitcherBar, + displayedSheet: null, + ).preferredSize; + return PopScope( //We need this for BackButtonListener to work downstream //No direct pop result will be handled here @@ -297,57 +329,72 @@ class BrowserScreen extends HookConsumerWidget { child: Theme( data: themeData, child: Scaffold( - extendBodyBehindAppBar: tabInFullScreen, + // Minimal scaffold - only for Material overlay support (SnackBars, BottomSheets) bottomSheetScrimBuilder: (_, _) { - //This causes issues with a non dismissable barrier pushed, we ahve our own barrier and this does seem to have issues when dismissing, so disable it completely + // Custom barrier handling - disable scaffold's scrim return null; }, - appBar: (tabBarPosition == TabBarPosition.top) - ? PreferredSize( - preferredSize: BrowserTopAppBar( - showMainToolbar: true, - showContextualToolbar: showContextualToolbar, - showQuickTabSwitcherBar: showQuickTabSwitcherBar, - ).preferredSize, + body: Stack( + children: [ + // Layer 0: Browser content (fills entire Stack - constant dimensions) + Positioned.fill( + child: _Browser( + overlayController: overlayController, + sheetController: sheetController, + displayAppBar: displayAppBar, + tabInFullScreen: tabInFullScreen, + pointerMoveEventSink: pointerMoveEventsController.sink, + bottomAppBarSize: bottomAppBarSize, + ), + ), + + // Layer 1: Bottom Toolbar (overlay, slides in/out) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: _AnimatedToolbar( + position: TabBarPosition.bottom, + visible: bottomToolbarVisible, child: _TabBar( - tabBarPosition: TabBarPosition.top, - showMainToolbar: true, + tabBarPosition: TabBarPosition.bottom, displayAppBar: displayAppBar, - sheetController: sheetController, + showMainToolbar: tabBarPosition == TabBarPosition.bottom, showContextualToolbar: showContextualToolbar, showQuickTabSwitcherBar: showQuickTabSwitcherBar, - pointerMoveEvents: null, + pointerMoveEvents: pointerMoveEventsController.stream, ), - ) - : removeTopAppBar.value - ? const PreferredSize( - preferredSize: Size.zero, - child: SizedBox.shrink(), - ) - : null, - bottomNavigationBar: _TabBar( - tabBarPosition: TabBarPosition.bottom, - displayAppBar: displayAppBar, - sheetController: sheetController, - showMainToolbar: tabBarPosition == TabBarPosition.bottom, - showContextualToolbar: showContextualToolbar, - showQuickTabSwitcherBar: showQuickTabSwitcherBar, - pointerMoveEvents: pointerMoveEventsController.stream, + ), + ), + + // Layer 2: Top Toolbar (overlay, slides in/out) - only when position is top + if (tabBarPosition == TabBarPosition.top) + Positioned( + left: 0, + right: 0, + top: 0, + child: _AnimatedToolbar( + position: TabBarPosition.top, + visible: topToolbarVisible, + child: _TabBar( + tabBarPosition: TabBarPosition.top, + showMainToolbar: true, + displayAppBar: displayAppBar, + showContextualToolbar: showContextualToolbar, + showQuickTabSwitcherBar: showQuickTabSwitcherBar, + pointerMoveEvents: null, + ), + ), + ), + + // Layer 3: FAB (positioned above bottom toolbar) + Positioned( + right: 16, + bottom: bottomAppBarSize.height + 16, + child: const BrowserFab(), + ), + ], ), - body: _Browser( - overlayController: overlayController, - sheetController: sheetController, - displayAppBar: displayAppBar, - tabInFullScreen: tabInFullScreen, - pointerMoveEventSink: pointerMoveEventsController.sink, - bottomAppBarSize: BrowserBottomAppBar( - showMainToolbar: false, - showContextualToolbar: showContextualToolbar, - showQuickTabSwitcherBar: showQuickTabSwitcherBar, - displayedSheet: null, - ).preferredSize, - ), - floatingActionButton: const BrowserFab(), ), ), ); diff --git a/packages/flutter_tor/lib/flutter_tor.dart b/packages/flutter_tor/lib/flutter_tor.dart index a11456a1..e94976d4 100644 --- a/packages/flutter_tor/lib/flutter_tor.dart +++ b/packages/flutter_tor/lib/flutter_tor.dart @@ -4,7 +4,6 @@ export 'src/tor_api.g.dart' show TransportType, TorConfiguration, - TorStartResult, TorStatus, TorLogMessage, IPtProxyController; diff --git a/packages/flutter_tor/lib/src/flutter_tor.dart b/packages/flutter_tor/lib/src/flutter_tor.dart index 5a27c445..ef3993d1 100644 --- a/packages/flutter_tor/lib/src/flutter_tor.dart +++ b/packages/flutter_tor/lib/src/flutter_tor.dart @@ -84,9 +84,4 @@ class _TorLogApiImpl extends TorLogApi { void onStatusChanged(TorStatus status) { onStatus(status); } - - @override - void onBootstrapProgress(int progress) { - onBootstrap(progress); - } } From ba057015f44885bdf35e88b9ad9970f1d4812807 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Thu, 8 Jan 2026 14:29:00 +0100 Subject: [PATCH 2/4] update ui logic to replace scaffold --- .../browser/presentation/screens/browser.dart | 197 +++++++++--------- .../widgets/tab_view/tab_view_header.dart | 25 +-- .../utils/profile_switch_handler.dart | 5 +- app/lib/utils/ui_helper.dart | 47 +++-- 4 files changed, 139 insertions(+), 135 deletions(-) diff --git a/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart b/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart index fe9738e2..db52b55d 100644 --- a/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart +++ b/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart @@ -300,19 +300,24 @@ class BrowserScreen extends HookConsumerWidget { [], ); - final sheetController = useState(null); - final pointerMoveEventsController = useStreamController(); + // Watch sheet state for rendering in Stack + final displayedSheet = ref.watch(bottomSheetControllerProvider); + final sheetDisplayed = displayedSheet != null; + // Compute visibility states for toolbars final appBarVisible = useValueListenable(displayAppBar); final topDismissed = ref.watch(tabBarDismissableControllerProvider); // Toolbar is visible when: sheet is shown OR (not fullscreen AND app bar visible) final topToolbarVisible = - sheetController.value != null || (!tabInFullScreen && !topDismissed); + sheetDisplayed || (!tabInFullScreen && !topDismissed); final bottomToolbarVisible = - sheetController.value != null || (!tabInFullScreen && appBarVisible); + sheetDisplayed || (!tabInFullScreen && appBarVisible); + + // Calculate relative safe area for sheet max size + final relativeSafeArea = MediaQuery.of(context).relativeSafeArea(); // Calculate bottom toolbar size for FAB positioning final bottomAppBarSize = BrowserBottomAppBar( @@ -340,15 +345,28 @@ class BrowserScreen extends HookConsumerWidget { Positioned.fill( child: _Browser( overlayController: overlayController, - sheetController: sheetController, displayAppBar: displayAppBar, tabInFullScreen: tabInFullScreen, pointerMoveEventSink: pointerMoveEventsController.sink, - bottomAppBarSize: bottomAppBarSize, + sheetDisplayed: sheetDisplayed, ), ), - // Layer 1: Bottom Toolbar (overlay, slides in/out) + // Layer 1: Sheet (when displayed) - positioned above toolbar + if (sheetDisplayed) + Positioned( + left: 0, + right: 0, + top: 0, + bottom: bottomAppBarSize.height, + child: _SheetContainer( + displayedSheet: displayedSheet, + relativeSafeArea: relativeSafeArea, + bottomAppBarSize: bottomAppBarSize, + ), + ), + + // Layer 2: Bottom Toolbar (overlay, slides in/out) - above sheet Positioned( left: 0, right: 0, @@ -367,7 +385,7 @@ class BrowserScreen extends HookConsumerWidget { ), ), - // Layer 2: Top Toolbar (overlay, slides in/out) - only when position is top + // Layer 3: Top Toolbar (overlay, slides in/out) - only when position is top if (tabBarPosition == TabBarPosition.top) Positioned( left: 0, @@ -387,10 +405,14 @@ class BrowserScreen extends HookConsumerWidget { ), ), - // Layer 3: FAB (positioned above bottom toolbar) - Positioned( + // Layer 4: FAB (animates position with toolbar visibility) + AnimatedPositioned( + duration: _AnimatedToolbar._kAnimationDuration, + curve: Curves.easeInOutQuart, right: 16, - bottom: bottomAppBarSize.height + 16, + bottom: bottomToolbarVisible + ? bottomAppBarSize.height + 16 + : 16 + MediaQuery.of(context).padding.bottom, child: const BrowserFab(), ), ], @@ -401,24 +423,80 @@ class BrowserScreen extends HookConsumerWidget { } } +/// Container widget for bottom sheets - renders in Stack for proper layering. +class _SheetContainer extends HookConsumerWidget { + final Sheet displayedSheet; + final double relativeSafeArea; + final Size bottomAppBarSize; + + const _SheetContainer({ + required this.displayedSheet, + required this.relativeSafeArea, + required this.bottomAppBarSize, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + bool dismissOnThreshold(DraggableScrollableNotification notification) { + if (notification.extent <= 0.1) { + logger.i('Dismissing sheet, reached min extend'); + ref.read(bottomSheetControllerProvider.notifier).requestDismiss(); + return true; + } + return false; + } + + return GestureDetector( + onTap: () { + // Dismiss sheet when tapping outside + ref.read(bottomSheetControllerProvider.notifier).requestDismiss(); + }, + child: Container( + color: Colors.black54, // Scrim + child: GestureDetector( + onTap: () {}, // Prevent tap from propagating to parent + child: Align( + alignment: Alignment.bottomCenter, + child: switch (displayedSheet) { + ViewTabsSheet() => + NotificationListener( + key: UniqueKey(), + onNotification: dismissOnThreshold, + child: _ViewTabsSheet(maxChildSize: relativeSafeArea), + ), + final EditUrlSheet parameter => + NotificationListener( + key: UniqueKey(), + onNotification: dismissOnThreshold, + child: _ViewUrlSheet( + initialTabState: parameter.tabState, + maxChildSize: relativeSafeArea, + bottomAppBarSize: bottomAppBarSize, + ), + ), + }, + ), + ), + ), + ); + } +} + class _Browser extends HookConsumerWidget { Duration get _backButtonPressTimeout => const Duration(seconds: 2); final OverlayPortalController overlayController; - final ValueNotifier sheetController; final ValueNotifier displayAppBar; final StreamSink pointerMoveEventSink; - final Size bottomAppBarSize; - final bool tabInFullScreen; + final bool sheetDisplayed; const _Browser({ required this.overlayController, - required this.sheetController, required this.displayAppBar, required this.tabInFullScreen, required this.pointerMoveEventSink, - required this.bottomAppBarSize, + required this.sheetDisplayed, }); @override @@ -427,88 +505,6 @@ class _Browser extends HookConsumerWidget { final overlayBuilder = ref.watch(overlayControllerProvider); - ref.listen(bottomSheetControllerProvider, (previous, next) { - if (!context.mounted) { - logger.e('Cannot show sheet, context not mounted'); - return; - } - - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!context.mounted) { - logger.e('Cannot show sheet, context not mounted (post frame)'); - return; - } - - // Close existing sheet - if (sheetController.value != null) { - try { - final existingController = sheetController.value!; - sheetController.value = null; - existingController.close(); - } catch (e) { - logger.e('Error closing existing sheet', error: e); - } - } - - // Show new sheet - if (next != null) { - try { - final relativeSafeArea = MediaQuery.of(context).relativeSafeArea(); - - final controller = Scaffold.of(context).showBottomSheet((context) { - logger.i( - 'Building bottom sheet, relativeSafeArea: $relativeSafeArea, mounted: ${context.mounted}', - ); - - bool dismissOnThreshold( - DraggableScrollableNotification notification, - ) { - if (notification.extent <= 0.1) { - logger.i('Dismissing sheet, reached min extend'); - ref - .read(bottomSheetControllerProvider.notifier) - .requestDismiss(); - return true; - } - return false; - } - - final sheet = switch (next) { - ViewTabsSheet() => - NotificationListener( - key: UniqueKey(), - onNotification: dismissOnThreshold, - child: _ViewTabsSheet(maxChildSize: relativeSafeArea), - ), - final EditUrlSheet parameter => - NotificationListener( - key: UniqueKey(), - onNotification: dismissOnThreshold, - child: _ViewUrlSheet( - initialTabState: parameter.tabState, - maxChildSize: relativeSafeArea, - bottomAppBarSize: bottomAppBarSize, - ), - ), - }; - - return sheet; - }); - - unawaited( - controller.closed.whenComplete(() { - ref.read(bottomSheetControllerProvider.notifier).closed(next); - }), - ); - - sheetController.value = controller; - } catch (e) { - debugPrint('Failed to show bottom sheet: $e'); - } - } - }); - }); - return DragTarget( onMove: (details) { ref @@ -538,7 +534,7 @@ class _Browser extends HookConsumerWidget { return overlayBuilder!.call(context); }, child: Listener( - onPointerDown: sheetController.value != null + onPointerDown: sheetDisplayed ? (_) { ref .read(bottomSheetControllerProvider.notifier) @@ -683,7 +679,8 @@ class _BrowserView extends StatelessWidget { SafeArea( top: !isFullscreen, right: !isFullscreen, - bottom: !isFullscreen, + // Bottom SafeArea is handled by the overlay toolbar (BottomAppBar) + bottom: false, left: !isFullscreen, child: Stack( children: [ diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_header.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_header.dart index f994891d..e37a7ea8 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_header.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_header.dart @@ -443,31 +443,18 @@ class TabViewHeader extends HookConsumerWidget { } if (context.mounted) { - ScaffoldMessenger.of( + ui_helper.showInfoMessage( context, - ).showSnackBar( - SnackBar( - content: Text( - shouldReopenTabs - ? 'Container data cleared successfully' - : 'Container data cleared. ${tabs.length} tab(s) closed.', - ), - ), + shouldReopenTabs + ? 'Container data cleared successfully' + : 'Container data cleared. ${tabs.length} tab(s) closed.', ); } } catch (e) { if (context.mounted) { - ScaffoldMessenger.of( + ui_helper.showErrorMessage( context, - ).showSnackBar( - SnackBar( - content: Text( - 'Error clearing data: $e', - ), - backgroundColor: Theme.of( - context, - ).colorScheme.error, - ), + 'Error clearing data: $e', ); } } diff --git a/app/lib/features/user/domain/presentation/utils/profile_switch_handler.dart b/app/lib/features/user/domain/presentation/utils/profile_switch_handler.dart index 8e92ffcd..9237a667 100644 --- a/app/lib/features/user/domain/presentation/utils/profile_switch_handler.dart +++ b/app/lib/features/user/domain/presentation/utils/profile_switch_handler.dart @@ -25,6 +25,7 @@ import 'package:weblibre/core/filesystem.dart'; import 'package:weblibre/domain/entities/profile.dart'; import 'package:weblibre/features/user/domain/repositories/profile.dart'; import 'package:weblibre/utils/exit_app.dart'; +import 'package:weblibre/utils/ui_helper.dart' as ui_helper; /// Handles the profile switching flow with confirmation dialog and cache clearing options. /// @@ -43,9 +44,7 @@ Future handleSwitchProfile( // Don't allow switching to the already active profile if (isSelected) { if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('This profile is already active')), - ); + ui_helper.showInfoMessage(context, 'This profile is already active'); } return; } diff --git a/app/lib/utils/ui_helper.dart b/app/lib/utils/ui_helper.dart index 255acfe8..ac4ba991 100644 --- a/app/lib/utils/ui_helper.dart +++ b/app/lib/utils/ui_helper.dart @@ -22,21 +22,47 @@ import 'package:nullability/nullability.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:weblibre/utils/clipboard.dart'; +/// Default bottom margin for floating snackbars to position above toolbar. +/// This accounts for the typical browser toolbar height. +const _kSnackBarBottomMargin = 72.0; + +/// Creates a floating snackbar with proper margin for overlay toolbar layout. +SnackBar _createFloatingSnackBar({ + required Widget content, + Color? backgroundColor, + SnackBarAction? action, + Duration duration = const Duration(seconds: 4), + bool persist = false, +}) { + return SnackBar( + content: content, + backgroundColor: backgroundColor, + action: action, + duration: duration, + persist: persist, + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.only( + left: 16, + right: 16, + bottom: _kSnackBarBottomMargin, + ), + ); +} + void showErrorMessage(BuildContext context, String message) { - final snackBar = SnackBar( + final snackBar = _createFloatingSnackBar( content: Text( message, style: TextStyle(color: Theme.of(context).colorScheme.error), ), backgroundColor: Theme.of(context).colorScheme.onError, - persist: false, ); ScaffoldMessenger.of(context).showSnackBar(snackBar); } void showInfoMessage(BuildContext context, String message) { - final snackBar = SnackBar(content: Text(message), persist: false); + final snackBar = _createFloatingSnackBar(content: Text(message)); ScaffoldMessenger.of(context).showSnackBar(snackBar); } @@ -46,12 +72,11 @@ void showTabBackButtonMessage( int tabCount, Duration duration, ) { - final snackbar = SnackBar( + final snackbar = _createFloatingSnackBar( content: (tabCount > 1) ? const Text('Navigate BACK again to close current tab') : const Text('Navigate BACK again to exit app'), duration: duration, - persist: false, ); ScaffoldMessenger.of(context) @@ -70,13 +95,12 @@ void showTabOpenedMessage( null => 'New tab opened in background', }; - final snackBar = SnackBar( + final snackBar = _createFloatingSnackBar( content: Text(message), action: onShow.mapNotNull( (onPressed) => SnackBarAction(label: 'Show', onPressed: onPressed), ), duration: duration, - persist: false, ); ScaffoldMessenger.of(context).showSnackBar(snackBar); @@ -90,7 +114,7 @@ Future showSuggestNewTabMessage( final clipboardUrl = await tryGetUriFromClipboard(); if (clipboardUrl != null) { - final snackBar = SnackBar( + final snackBar = _createFloatingSnackBar( content: const Text('Want to open link from clipboard?'), action: SnackBarAction( label: 'Open', @@ -99,7 +123,6 @@ Future showSuggestNewTabMessage( }, ), duration: duration, - persist: false, ); if (context.mounted) { @@ -121,13 +144,12 @@ void showTabSwitchMessage( null => 'New tab opened', }; - final snackBar = SnackBar( + final snackBar = _createFloatingSnackBar( content: Text(message), action: onSwitch.mapNotNull( (onPressed) => SnackBarAction(label: 'Switch', onPressed: onPressed), ), duration: duration, - persist: false, ); ScaffoldMessenger.of(context).showSnackBar(snackBar); @@ -165,13 +187,12 @@ void showTabUndoClose( }) { ScaffoldMessenger.of(context).clearSnackBars(); - final snackBar = SnackBar( + final snackBar = _createFloatingSnackBar( content: (count > 1) ? Text('$count Tabs closed') : const Text('Tab closed'), action: SnackBarAction(label: 'Undo', onPressed: onUndo), duration: duration, - persist: false, ); ScaffoldMessenger.of(context).showSnackBar(snackBar); From d4222a0dc4feb0285389fcfa164aa3c3abcbd5b7 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Thu, 8 Jan 2026 20:57:33 +0100 Subject: [PATCH 3/4] keyboard bottom sheet working --- .../browser/presentation/screens/browser.dart | 185 +++++++++--------- .../browser_modules/bottom_app_bar.dart | 19 +- 2 files changed, 104 insertions(+), 100 deletions(-) diff --git a/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart b/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart index db52b55d..0477fa1f 100644 --- a/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart +++ b/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart @@ -318,14 +318,19 @@ class BrowserScreen extends HookConsumerWidget { // Calculate relative safe area for sheet max size final relativeSafeArea = MediaQuery.of(context).relativeSafeArea(); + final bottomSafeArea = MediaQuery.of(context).padding.bottom; - // Calculate bottom toolbar size for FAB positioning - final bottomAppBarSize = BrowserBottomAppBar( + // Calculate bottom toolbar size for FAB and sheet positioning + // Pass actual displayedSheet to get correct height when ViewTabsSheet hides main toolbar + final bottomAppBarContentSize = BrowserBottomAppBar( showMainToolbar: tabBarPosition == TabBarPosition.bottom, showContextualToolbar: showContextualToolbar, showQuickTabSwitcherBar: showQuickTabSwitcherBar, - displayedSheet: null, + displayedSheet: displayedSheet, ).preferredSize; + // Total height includes safe area padding + final bottomAppBarTotalHeight = + bottomAppBarContentSize.height + bottomSafeArea; return PopScope( //We need this for BackButtonListener to work downstream @@ -334,11 +339,7 @@ class BrowserScreen extends HookConsumerWidget { child: Theme( data: themeData, child: Scaffold( - // Minimal scaffold - only for Material overlay support (SnackBars, BottomSheets) - bottomSheetScrimBuilder: (_, _) { - // Custom barrier handling - disable scaffold's scrim - return null; - }, + // Minimal scaffold - only for Material overlay support (SnackBars) body: Stack( children: [ // Layer 0: Browser content (fills entire Stack - constant dimensions) @@ -358,15 +359,15 @@ class BrowserScreen extends HookConsumerWidget { left: 0, right: 0, top: 0, - bottom: bottomAppBarSize.height, + bottom: bottomAppBarTotalHeight, child: _SheetContainer( displayedSheet: displayedSheet, relativeSafeArea: relativeSafeArea, - bottomAppBarSize: bottomAppBarSize, + bottomAppBarHeight: bottomAppBarTotalHeight, ), ), - // Layer 2: Bottom Toolbar (overlay, slides in/out) - above sheet + // Layer 2: Bottom Toolbar (overlay, slides in/out) Positioned( left: 0, right: 0, @@ -411,8 +412,8 @@ class BrowserScreen extends HookConsumerWidget { curve: Curves.easeInOutQuart, right: 16, bottom: bottomToolbarVisible - ? bottomAppBarSize.height + 16 - : 16 + MediaQuery.of(context).padding.bottom, + ? bottomAppBarTotalHeight + 16 + : 16 + bottomSafeArea, child: const BrowserFab(), ), ], @@ -427,16 +428,19 @@ class BrowserScreen extends HookConsumerWidget { class _SheetContainer extends HookConsumerWidget { final Sheet displayedSheet; final double relativeSafeArea; - final Size bottomAppBarSize; + final double bottomAppBarHeight; const _SheetContainer({ required this.displayedSheet, required this.relativeSafeArea, - required this.bottomAppBarSize, + required this.bottomAppBarHeight, }); @override Widget build(BuildContext context, WidgetRef ref) { + // Memoize maxChildSize to prevent keyboard from affecting sheet size + final stableMaxChildSize = useMemoized(() => relativeSafeArea, []); + bool dismissOnThreshold(DraggableScrollableNotification notification) { if (notification.extent <= 0.1) { logger.i('Dismissing sheet, reached min extend'); @@ -451,7 +455,7 @@ class _SheetContainer extends HookConsumerWidget { // Dismiss sheet when tapping outside ref.read(bottomSheetControllerProvider.notifier).requestDismiss(); }, - child: Container( + child: ColoredBox( color: Colors.black54, // Scrim child: GestureDetector( onTap: () {}, // Prevent tap from propagating to parent @@ -460,18 +464,16 @@ class _SheetContainer extends HookConsumerWidget { child: switch (displayedSheet) { ViewTabsSheet() => NotificationListener( - key: UniqueKey(), onNotification: dismissOnThreshold, - child: _ViewTabsSheet(maxChildSize: relativeSafeArea), + child: _ViewTabsSheet(maxChildSize: stableMaxChildSize), ), final EditUrlSheet parameter => NotificationListener( - key: UniqueKey(), onNotification: dismissOnThreshold, child: _ViewUrlSheet( initialTabState: parameter.tabState, - maxChildSize: relativeSafeArea, - bottomAppBarSize: bottomAppBarSize, + maxChildSize: stableMaxChildSize, + bottomAppBarHeight: bottomAppBarHeight, ), ), }, @@ -649,7 +651,6 @@ class _Browser extends HookConsumerWidget { } }, child: _BrowserView( - sheetDisplayed: sheetController.value != null, isFullscreen: tabInFullScreen, pointerMoveEventSink: pointerMoveEventSink, ), @@ -662,89 +663,79 @@ class _Browser extends HookConsumerWidget { } class _BrowserView extends StatelessWidget { - final bool sheetDisplayed; final bool isFullscreen; final StreamSink? pointerMoveEventSink; const _BrowserView({ - required this.sheetDisplayed, required this.isFullscreen, this.pointerMoveEventSink, }); @override Widget build(BuildContext context) { - return Stack( - children: [ - SafeArea( - top: !isFullscreen, - right: !isFullscreen, - // Bottom SafeArea is handled by the overlay toolbar (BottomAppBar) - bottom: false, - left: !isFullscreen, - child: Stack( - children: [ - BrowserView(pointerMoveEventSink: pointerMoveEventSink), - Positioned( - bottom: 0, - left: 0, - right: 0, - child: Consumer( - builder: (context, ref, child) { - final value = ref.watch( - selectedTabStateProvider.select((state) { - if (state?.isLoading == true) { - return state?.progress ?? 100; - } - - //When not loading we assumed finished - return 100; - }), - ); - - return Visibility( - visible: value < 100, - child: LinearProgressIndicator(value: value / 100), - ); - }, - ), - ), - Positioned( - bottom: 0, - left: 0, - right: 0, - child: Consumer( - builder: (context, ref, child) { - final value = ref.watch( - selectedTabStateProvider.select( - (state) => EdgeInsets.only( - bottom: - (state?.isLoading == true && - state?.progress != null && - state!.progress < 100) - ? 4.0 - : 0.0, - ), - ), - ); - - final tabId = ref.watch(selectedTabProvider); - if (tabId == null) { - return const SizedBox.shrink(); + return SafeArea( + top: !isFullscreen, + right: !isFullscreen, + // Bottom SafeArea is handled by the overlay toolbar (BottomAppBar) + bottom: false, + left: !isFullscreen, + child: Stack( + children: [ + BrowserView(pointerMoveEventSink: pointerMoveEventSink), + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Consumer( + builder: (context, ref, child) { + final value = ref.watch( + selectedTabStateProvider.select((state) { + if (state?.isLoading == true) { + return state?.progress ?? 100; } - return FindInPageWidget(tabId: tabId, padding: value); - }, - ), - ), - ], + //When not loading we assumed finished + return 100; + }), + ); + + return Visibility( + visible: value < 100, + child: LinearProgressIndicator(value: value / 100), + ); + }, + ), ), - ), - if (sheetDisplayed) - ModalBarrier( - color: Theme.of(context).dialogTheme.barrierColor ?? Colors.black54, + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Consumer( + builder: (context, ref, child) { + final value = ref.watch( + selectedTabStateProvider.select( + (state) => EdgeInsets.only( + bottom: + (state?.isLoading == true && + state?.progress != null && + state!.progress < 100) + ? 4.0 + : 0.0, + ), + ), + ); + + final tabId = ref.watch(selectedTabProvider); + if (tabId == null) { + return const SizedBox.shrink(); + } + + return FindInPageWidget(tabId: tabId, padding: value); + }, + ), ), - ], + ], + ), ); } } @@ -752,11 +743,11 @@ class _BrowserView extends StatelessWidget { class _ViewUrlSheet extends HookConsumerWidget { final double maxChildSize; final TabState initialTabState; - final Size bottomAppBarSize; + final double bottomAppBarHeight; const _ViewUrlSheet({ required this.initialTabState, - required this.bottomAppBarSize, + required this.bottomAppBarHeight, this.maxChildSize = 1.0, }); @@ -773,11 +764,12 @@ class _ViewUrlSheet extends HookConsumerWidget { minChildSize: 0.1, maxChildSize: maxChildSize, builder: (context, scrollController) { - return ClipRRect( + return Material( borderRadius: const BorderRadius.only( topLeft: Radius.circular(28), topRight: Radius.circular(28), ), + clipBehavior: Clip.antiAlias, child: ViewTabSheetWidget( initialTabState: initialTabState, sheetScrollController: scrollController, @@ -796,7 +788,7 @@ class _ViewUrlSheet extends HookConsumerWidget { } }, initialHeight: initialHeight, - bottomAppBarHeight: bottomAppBarSize.height, + bottomAppBarHeight: bottomAppBarHeight, ), ); }, @@ -825,11 +817,12 @@ class _ViewTabsSheet extends HookConsumerWidget { minChildSize: 0.1, maxChildSize: maxChildSize, builder: (context, scrollController) { - return ClipRRect( + return Material( borderRadius: const BorderRadius.only( topLeft: Radius.circular(28), topRight: Radius.circular(28), ), + clipBehavior: Clip.antiAlias, child: switch (tabsViewMode) { TabsViewMode.list => ViewTabListWidget( scrollController: scrollController, diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart index 0dcf340f..a3cb674c 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart @@ -120,10 +120,19 @@ class BrowserBottomAppBar extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - return BottomAppBar( - height: _size.height, - padding: EdgeInsets.zero, - child: _tabBar, + final bottomPadding = MediaQuery.of(context).padding.bottom; + + return Material( + elevation: 3.0, + surfaceTintColor: Theme.of(context).colorScheme.surfaceTint, + color: Theme.of(context).colorScheme.surfaceContainer, + child: Padding( + padding: EdgeInsets.only(bottom: bottomPadding), + child: SizedBox( + height: _size.height, + child: _tabBar, + ), + ), ); } @@ -271,8 +280,10 @@ class BrowserTabBar extends HookConsumerWidget { visible: displayAppBar, maintainState: true, child: AppBar( + primary: false, automaticallyImplyLeading: false, titleSpacing: 8.0, + toolbarHeight: kToolbarHeight, backgroundColor: (containerColor != null && displayedSheet is! ViewTabsSheet) ? ContainerColors.forAppBar(containerColor) From 3ee389e74caba9c1ebdbc70b1f869a9c89b032d8 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Fri, 9 Jan 2026 10:59:47 +0100 Subject: [PATCH 4/4] hide list/grid viw add tab button on scroll; add snackbar margins for appbar; --- .../browser/presentation/screens/browser.dart | 66 ++++++++-------- .../presentation/widgets/sheets/view_tab.dart | 1 + .../widgets/tab_view/tab_grid_view.dart | 75 ++++++++++++++++++- .../widgets/tab_view/tab_list_view.dart | 75 ++++++++++++++++++- app/lib/utils/ui_helper.dart | 13 +--- 5 files changed, 180 insertions(+), 50 deletions(-) diff --git a/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart b/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart index 0477fa1f..6c713aed 100644 --- a/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart +++ b/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart @@ -88,21 +88,15 @@ class _AnimatedToolbar extends HookWidget { return null; }, [visible]); - final slideAnimation = useMemoized( - () { - final begin = position == TabBarPosition.top - ? const Offset(0, -1) - : const Offset(0, 1); + final slideAnimation = useMemoized(() { + final begin = position == TabBarPosition.top + ? const Offset(0, -1) + : const Offset(0, 1); - return Tween( - begin: begin, - end: Offset.zero, - ).animate( - CurvedAnimation(parent: controller, curve: Curves.easeInOutQuart), - ); - }, - [position], - ); + return Tween(begin: begin, end: Offset.zero).animate( + CurvedAnimation(parent: controller, curve: Curves.easeInOutQuart), + ); + }, [position]); return SlideTransition(position: slideAnimation, child: child); } @@ -284,22 +278,6 @@ class BrowserScreen extends HookConsumerWidget { }, ); - final themeData = useMemoized( - () => Theme.of(context).copyWith( - bottomSheetTheme: BottomSheetThemeData( - constraints: BoxConstraints( - maxWidth: - MediaQuery.of(context).size.width - - math.max( - MediaQuery.of(context).padding.left * 2, - MediaQuery.of(context).padding.right * 2, - ), - ), - ), - ), - [], - ); - final pointerMoveEventsController = useStreamController(); // Watch sheet state for rendering in Stack @@ -332,6 +310,28 @@ class BrowserScreen extends HookConsumerWidget { final bottomAppBarTotalHeight = bottomAppBarContentSize.height + bottomSafeArea; + // Theme with dynamic snackbar margin to position above bottom toolbar + final themeData = Theme.of(context).copyWith( + bottomSheetTheme: BottomSheetThemeData( + constraints: BoxConstraints( + maxWidth: + MediaQuery.of(context).size.width - + math.max( + MediaQuery.of(context).padding.left * 2, + MediaQuery.of(context).padding.right * 2, + ), + ), + ), + snackBarTheme: SnackBarThemeData( + behavior: SnackBarBehavior.floating, + insetPadding: EdgeInsets.only( + left: 16, + right: 16, + bottom: bottomToolbarVisible ? bottomAppBarTotalHeight + 8 : 16, + ), + ), + ); + return PopScope( //We need this for BackButtonListener to work downstream //No direct pop result will be handled here @@ -340,6 +340,7 @@ class BrowserScreen extends HookConsumerWidget { data: themeData, child: Scaffold( // Minimal scaffold - only for Material overlay support (SnackBars) + resizeToAvoidBottomInset: false, body: Stack( children: [ // Layer 0: Browser content (fills entire Stack - constant dimensions) @@ -666,10 +667,7 @@ class _BrowserView extends StatelessWidget { final bool isFullscreen; final StreamSink? pointerMoveEventSink; - const _BrowserView({ - required this.isFullscreen, - this.pointerMoveEventSink, - }); + const _BrowserView({required this.isFullscreen, this.pointerMoveEventSink}); @override Widget build(BuildContext context) { diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tab.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tab.dart index 83eb34ff..5c4909b8 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tab.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tab.dart @@ -192,6 +192,7 @@ class ViewTabSheetWidget extends HookConsumerWidget { controller: sheetScrollController, builder: (context, controller) { return ListView( + padding: EdgeInsets.zero, controller: controller, physics: const ClampingScrollPhysicsWithoutImplicit(), children: [ diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_grid_view.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_grid_view.dart index 36e27527..aa53ad00 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_grid_view.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_grid_view.dart @@ -27,7 +27,6 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/co import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab_search.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; -import 'package:weblibre/presentation/hooks/scroll_visibility.dart'; class _TabDraggable extends HookConsumerWidget { final TabEntity entity; @@ -346,6 +345,7 @@ class _TabGrid extends StatelessWidget { Widget build(BuildContext context) { return GridView.builder( controller: scrollController, + padding: EdgeInsets.zero, gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( //Sync values for itemHeight calculation _calculateItemHeight childAspectRatio: 0.75, @@ -390,6 +390,9 @@ class ViewTabGridWidget extends HookConsumerWidget { final bool tabsReorderable; final VoidCallback onClose; + static const _hideThreshold = 0.02; // 2% of sheet size change + static const _showThreshold = 0.02; + const ViewTabGridWidget({ required this.onClose, required this.scrollController, @@ -401,8 +404,74 @@ class ViewTabGridWidget extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - // Track FAB visibility based on scroll direction - final isFabVisible = useScrollVisibility(scrollController); + final isFabVisible = useState(true); + final lastSheetSize = useRef(0.0); + final isInitialized = useRef(false); + + // Delay initialization to ignore initial animations + useEffect(() { + final timer = Timer(const Duration(milliseconds: 500), () { + isInitialized.value = true; + // Initialize lastSheetSize with current size + if (draggableScrollableController?.isAttached == true) { + lastSheetSize.value = draggableScrollableController!.size; + } + }); + return timer.cancel; + }, []); + + // Listen to DraggableScrollableController for sheet size changes + useEffect(() { + final controller = draggableScrollableController; + if (controller == null) return null; + + void listener() { + if (!isInitialized.value) return; + if (!controller.isAttached) return; + + final currentSize = controller.size; + final difference = currentSize - lastSheetSize.value; + + // Hide when sheet expands (dragging up / scrolling down) + if (difference > _hideThreshold && isFabVisible.value) { + isFabVisible.value = false; + } + // Show when sheet collapses (dragging down / scrolling up) + else if (difference < -_showThreshold && !isFabVisible.value) { + isFabVisible.value = true; + } + + lastSheetSize.value = currentSize; + } + + controller.addListener(listener); + return () => controller.removeListener(listener); + }, [draggableScrollableController]); + + // Fallback: Also listen to scroll controller for fullscreen mode (no draggable sheet) + useEffect(() { + if (draggableScrollableController != null) return null; + + var lastOffset = 0.0; + void listener() { + if (!isInitialized.value) return; + + final currentOffset = scrollController.offset; + final difference = currentOffset - lastOffset; + + if (difference > 10.0 && isFabVisible.value) { + isFabVisible.value = false; + } else if ((difference < -10.0 || currentOffset <= 0) && + !isFabVisible.value) { + isFabVisible.value = true; + } + + lastOffset = currentOffset; + } + + scrollController.addListener(listener); + return () => scrollController.removeListener(listener); + }, [scrollController, draggableScrollableController]); return Stack( alignment: Alignment.bottomRight, diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart index 27311efe..f1e045d4 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart @@ -25,7 +25,6 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/co import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab_search.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; -import 'package:weblibre/presentation/hooks/scroll_visibility.dart'; class _TabDraggable extends HookConsumerWidget { final TabEntity entity; @@ -194,6 +193,7 @@ class _TabListView extends HookConsumerWidget { builder: (context, controller) { return !tabsReorderable ? ListView.builder( + padding: EdgeInsets.zero, controller: scrollController, itemCount: itemCount, itemExtent: _itemHeight, @@ -359,6 +359,9 @@ class ViewTabListWidget extends HookConsumerWidget { final bool tabsReorderable; final VoidCallback onClose; + static const _hideThreshold = 0.02; // 2% of sheet size change + static const _showThreshold = 0.02; + const ViewTabListWidget({ required this.onClose, required this.scrollController, @@ -370,8 +373,74 @@ class ViewTabListWidget extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - // Track FAB visibility based on scroll direction - final isFabVisible = useScrollVisibility(scrollController); + final isFabVisible = useState(true); + final lastSheetSize = useRef(0.0); + final isInitialized = useRef(false); + + // Delay initialization to ignore initial animations + useEffect(() { + final timer = Timer(const Duration(milliseconds: 500), () { + isInitialized.value = true; + // Initialize lastSheetSize with current size + if (draggableScrollableController?.isAttached == true) { + lastSheetSize.value = draggableScrollableController!.size; + } + }); + return timer.cancel; + }, []); + + // Listen to DraggableScrollableController for sheet size changes + useEffect(() { + final controller = draggableScrollableController; + if (controller == null) return null; + + void listener() { + if (!isInitialized.value) return; + if (!controller.isAttached) return; + + final currentSize = controller.size; + final difference = currentSize - lastSheetSize.value; + + // Hide when sheet expands (dragging up / scrolling down) + if (difference > _hideThreshold && isFabVisible.value) { + isFabVisible.value = false; + } + // Show when sheet collapses (dragging down / scrolling up) + else if (difference < -_showThreshold && !isFabVisible.value) { + isFabVisible.value = true; + } + + lastSheetSize.value = currentSize; + } + + controller.addListener(listener); + return () => controller.removeListener(listener); + }, [draggableScrollableController]); + + // Fallback: Also listen to scroll controller for fullscreen mode (no draggable sheet) + useEffect(() { + if (draggableScrollableController != null) return null; + + var lastOffset = 0.0; + void listener() { + if (!isInitialized.value) return; + + final currentOffset = scrollController.offset; + final difference = currentOffset - lastOffset; + + if (difference > 10.0 && isFabVisible.value) { + isFabVisible.value = false; + } else if ((difference < -10.0 || currentOffset <= 0) && + !isFabVisible.value) { + isFabVisible.value = true; + } + + lastOffset = currentOffset; + } + + scrollController.addListener(listener); + return () => scrollController.removeListener(listener); + }, [scrollController, draggableScrollableController]); return Stack( alignment: Alignment.bottomRight, diff --git a/app/lib/utils/ui_helper.dart b/app/lib/utils/ui_helper.dart index ac4ba991..960cd9f5 100644 --- a/app/lib/utils/ui_helper.dart +++ b/app/lib/utils/ui_helper.dart @@ -22,11 +22,9 @@ import 'package:nullability/nullability.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:weblibre/utils/clipboard.dart'; -/// Default bottom margin for floating snackbars to position above toolbar. -/// This accounts for the typical browser toolbar height. -const _kSnackBarBottomMargin = 72.0; - -/// Creates a floating snackbar with proper margin for overlay toolbar layout. +/// Creates a floating snackbar. +/// The margin is controlled by the scaffold's snackBarTheme for proper +/// positioning above bottom app bars of varying heights. SnackBar _createFloatingSnackBar({ required Widget content, Color? backgroundColor, @@ -41,11 +39,6 @@ SnackBar _createFloatingSnackBar({ duration: duration, persist: persist, behavior: SnackBarBehavior.floating, - margin: const EdgeInsets.only( - left: 16, - right: 16, - bottom: _kSnackBarBottomMargin, - ), ); }