From 3242460c495e8960f5b09be9f3614f5fe3987379 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Sun, 14 Sep 2025 11:40:01 +0200 Subject: [PATCH 1/4] improve bottom sheet logic --- .../browser/presentation/screens/browser.dart | 64 +++++----- .../widgets/draggable_scrollable_header.dart | 117 +++++++++++++++++- .../presentation/widgets/sheets/view_tab.dart | 28 +++-- 3 files changed, 159 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 b9d6da26..bb15a884 100644 --- a/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart +++ b/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart @@ -115,44 +115,44 @@ class BrowserScreen extends HookConsumerWidget { } if (next != null) { - bool dismissOnThreshold( - DraggableScrollableNotification notification, - ) { - if (!context.mounted) return false; + final relativeSafeArea = MediaQuery.of(context).relativeSafeArea(); - if (notification.extent <= 0.1) { - ref.read(bottomSheetControllerProvider.notifier).dismiss(); - return true; - } else { - ref - .read(bottomSheetExtendProvider.notifier) - .add(notification.extent); + sheetController.value = state.showBottomSheet((context) { + bool dismissOnThreshold( + DraggableScrollableNotification notification, + ) { + if (notification.extent <= 0.1) { + ref.read(bottomSheetControllerProvider.notifier).dismiss(); + return true; + } else { + ref + .read(bottomSheetExtendProvider.notifier) + .add(notification.extent); + } + + return false; } - return false; - } - - final sheet = switch (next) { - ViewTabsSheet() => - NotificationListener( - key: ValueKey(next), - onNotification: dismissOnThreshold, - child: _ViewTabsSheet( - maxChildSize: MediaQuery.of(context).relativeSafeArea(), + final sheet = switch (next) { + ViewTabsSheet() => + NotificationListener( + key: ValueKey(next), + onNotification: dismissOnThreshold, + child: _ViewTabsSheet(maxChildSize: relativeSafeArea), ), - ), - final EditUrlSheet parameter => - NotificationListener( - key: ValueKey(parameter), - onNotification: dismissOnThreshold, - child: _ViewUrlSheet( - initialTabState: parameter.tabState, - maxChildSize: MediaQuery.of(context).relativeSafeArea(), + final EditUrlSheet parameter => + NotificationListener( + key: ValueKey(parameter), + onNotification: dismissOnThreshold, + child: _ViewUrlSheet( + initialTabState: parameter.tabState, + maxChildSize: relativeSafeArea, + ), ), - ), - }; + }; - sheetController.value = state.showBottomSheet((context) => sheet); + return sheet; + }); } } }); diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/draggable_scrollable_header.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/draggable_scrollable_header.dart index 81ecc8ea..9e36e50e 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/draggable_scrollable_header.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/draggable_scrollable_header.dart @@ -21,26 +21,133 @@ import 'dart:math'; import 'package:flutter/material.dart'; -class DraggableScrollableHeader extends StatelessWidget { +class DraggableScrollableHeader extends StatefulWidget { final DraggableScrollableController controller; final Widget child; + // Velocity control parameters + final double velocitySensitivity; + final double maxVelocity; + final double minVelocity; + final Duration animationDuration; + final Curve animationCurve; + final double dragSensitivity; + const DraggableScrollableHeader({ super.key, required this.controller, required this.child, + this.velocitySensitivity = 0.35, // Higher = more sensitive to velocity + this.maxVelocity = 2500.0, // Maximum velocity to consider + this.minVelocity = 150.0, // Minimum velocity to trigger momentum + this.animationDuration = const Duration(milliseconds: 300), + this.animationCurve = Curves.decelerate, + this.dragSensitivity = 1.0, // Controls drag responsiveness }); + @override + State createState() => + _DraggableScrollableHeaderState(); +} + +class _DraggableScrollableHeaderState extends State + with TickerProviderStateMixin { + late AnimationController _animationController; + late Animation? _animation; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + vsync: this, + duration: widget.animationDuration, + ); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + Future _animateToPosition( + double targetSize, { + Duration? customDuration, + }) async { + if (customDuration != null) { + _animationController.duration = customDuration; + } else { + _animationController.duration = widget.animationDuration; + } + + _animation = Tween(begin: widget.controller.size, end: targetSize) + .animate( + CurvedAnimation( + parent: _animationController, + curve: widget.animationCurve, + ), + ); + + _animationController.reset(); + await _animationController.forward(); + + _animation!.addListener(() { + widget.controller.jumpTo(_animation!.value); + }); + } + @override Widget build(BuildContext context) { return GestureDetector( onVerticalDragUpdate: (details) { - // Use the DraggableScrollableSheet's controller - controller.jumpTo( - min(1, controller.pixelsToSize(controller.pixels - details.delta.dy)), + _animationController.stop(); + + // Apply drag sensitivity + final adjustedDelta = details.delta.dy * widget.dragSensitivity; + + widget.controller.jumpTo( + min( + 1, + widget.controller.pixelsToSize( + widget.controller.pixels - adjustedDelta, + ), + ), ); }, - child: child, + onVerticalDragEnd: (details) async { + final velocity = details.primaryVelocity ?? 0; + final currentSize = widget.controller.size; + + // Clamp velocity to defined range + final clampedVelocity = velocity.clamp( + -widget.maxVelocity, + widget.maxVelocity, + ); + + // Only apply momentum if velocity exceeds minimum threshold + if (clampedVelocity.abs() < widget.minVelocity) { + return; // No momentum, stay at current position + } + + // Calculate momentum-based target with sensitivity control + final velocityFactor = + clampedVelocity / 1000 * widget.velocitySensitivity; + double targetSize = currentSize - velocityFactor; + + // Clamp to valid range + targetSize = targetSize.clamp(0.0, 1.0); + + // Calculate animation duration based on velocity (faster velocity = longer animation) + final velocityRatio = clampedVelocity.abs() / widget.maxVelocity; + final dynamicDuration = Duration( + milliseconds: + (widget.animationDuration.inMilliseconds * (0.5 + velocityRatio)) + .round(), + ); + + await _animateToPosition(targetSize, customDuration: dynamicDuration); + }, + child: widget.child, ); } } 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 c8f25f29..9f0bb960 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 @@ -96,18 +96,17 @@ class ViewTabSheetWidget extends HookConsumerWidget { final totalHeight = headerBox.size.height + textBox.size.height + kToolbarHeight; - final relative = totalHeight / MediaQuery.of(context).size.height; + final relative = (totalHeight / MediaQuery.of(context).size.height) + .clamp(0.0, 1.0); - if (relative >= 0 && relative <= 1) { - if (draggableScrollableController.size < relative && - relative > scrolledTo.value) { - await draggableScrollableController.animateTo( - relative, - duration: const Duration(milliseconds: 150), - curve: Curves.easeInOut, - ); - scrolledTo.value = relative; - } + if (draggableScrollableController.size < relative && + relative > scrolledTo.value) { + await draggableScrollableController.animateTo( + relative, + duration: const Duration(milliseconds: 150), + curve: Curves.easeInOut, + ); + scrolledTo.value = relative; } } } @@ -125,10 +124,13 @@ class ViewTabSheetWidget extends HookConsumerWidget { MediaQuery.of(context).size.height) - bottomInsets.value; - draggableScrollableController.jumpTo( - draggableScrollableController.size + diff, + final jumpValue = (draggableScrollableController.size + diff).clamp( + 0.0, + 1.0, ); + draggableScrollableController.jumpTo(jumpValue); + bottomInsets.value += diff; }); From be348ca7d6b99c3dfafe6e27b910081a7891c191 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Sun, 14 Sep 2025 11:47:25 +0200 Subject: [PATCH 2/4] prepare new alpha --- app/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 17c5bd24..d0595074 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -2,7 +2,7 @@ name: weblibre description: "The Privacy-Focused & AI-Powered Research Browser" publish_to: 'none' resolution: workspace -version: 0.9.23-alpha-1+26 +version: 0.9.23-alpha-2+26 environment: sdk: '>=3.8.0 <4.0.0' From 31621f580f963ccf533cf9fb8ff3322d01df2a34 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Mon, 15 Sep 2025 06:03:08 +0200 Subject: [PATCH 3/4] adjust velocity values --- .../presentation/widgets/draggable_scrollable_header.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/draggable_scrollable_header.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/draggable_scrollable_header.dart index 9e36e50e..230d7674 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/draggable_scrollable_header.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/draggable_scrollable_header.dart @@ -37,9 +37,9 @@ class DraggableScrollableHeader extends StatefulWidget { super.key, required this.controller, required this.child, - this.velocitySensitivity = 0.35, // Higher = more sensitive to velocity + this.velocitySensitivity = 0.25, // Higher = more sensitive to velocity this.maxVelocity = 2500.0, // Maximum velocity to consider - this.minVelocity = 150.0, // Minimum velocity to trigger momentum + this.minVelocity = 50.0, // Minimum velocity to trigger momentum this.animationDuration = const Duration(milliseconds: 300), this.animationCurve = Curves.decelerate, this.dragSensitivity = 1.0, // Controls drag responsiveness From 2b667e3f110d99d30b04de774940639da552fd68 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Mon, 15 Sep 2025 06:04:45 +0200 Subject: [PATCH 4/4] fix key usage issue after long inactivity --- .../browser/presentation/screens/browser.dart | 387 +++++++++--------- 1 file changed, 197 insertions(+), 190 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 bb15a884..7104b67f 100644 --- a/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart +++ b/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart @@ -57,8 +57,6 @@ class BrowserScreen extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final scaffoldKey = useMemoized(() => GlobalKey()); - final eventService = ref.watch(eventServiceProvider); final sheetDisplayed = ref.watch( @@ -106,56 +104,6 @@ class BrowserScreen extends HookConsumerWidget { ); final sheetController = useRef(null); - ref.listen(bottomSheetControllerProvider, (previous, next) { - final state = scaffoldKey.currentState; - if (state != null) { - if (sheetController.value != null) { - sheetController.value!.close(); - sheetController.value = null; - } - - if (next != null) { - final relativeSafeArea = MediaQuery.of(context).relativeSafeArea(); - - sheetController.value = state.showBottomSheet((context) { - bool dismissOnThreshold( - DraggableScrollableNotification notification, - ) { - if (notification.extent <= 0.1) { - ref.read(bottomSheetControllerProvider.notifier).dismiss(); - return true; - } else { - ref - .read(bottomSheetExtendProvider.notifier) - .add(notification.extent); - } - - return false; - } - - final sheet = switch (next) { - ViewTabsSheet() => - NotificationListener( - key: ValueKey(next), - onNotification: dismissOnThreshold, - child: _ViewTabsSheet(maxChildSize: relativeSafeArea), - ), - final EditUrlSheet parameter => - NotificationListener( - key: ValueKey(parameter), - onNotification: dismissOnThreshold, - child: _ViewUrlSheet( - initialTabState: parameter.tabState, - maxChildSize: relativeSafeArea, - ), - ), - }; - - return sheet; - }); - } - } - }); return PopScope( //We need this for BackButtonListener to work downstream @@ -164,7 +112,6 @@ class BrowserScreen extends HookConsumerWidget { child: Theme( data: themeData, child: Scaffold( - key: scaffoldKey, extendBodyBehindAppBar: tabInFullScreen, bottomNavigationBar: HookConsumer( builder: (context, ref, child) { @@ -265,161 +212,221 @@ class BrowserScreen extends HookConsumerWidget { ); }, ), - body: DragTarget( - onMove: (details) { - ref - .read(willAcceptDropProvider.notifier) - .setData(DeleteDropData(details.data.tabId)); - }, - onLeave: (data) { - ref.read(willAcceptDropProvider.notifier).clear(); - }, - onAcceptWithDetails: (details) async { - ref.read(willAcceptDropProvider.notifier).clear(); - await ref - .read(tabRepositoryProvider.notifier) - .closeTab(details.data.tabId); + body: Consumer( + child: DragTarget( + onMove: (details) { + ref + .read(willAcceptDropProvider.notifier) + .setData(DeleteDropData(details.data.tabId)); + }, + onLeave: (data) { + ref.read(willAcceptDropProvider.notifier).clear(); + }, + onAcceptWithDetails: (details) async { + ref.read(willAcceptDropProvider.notifier).clear(); + await ref + .read(tabRepositoryProvider.notifier) + .closeTab(details.data.tabId); - if (context.mounted) { - ui_helper.showTabUndoClose( - context, - ref.read(tabRepositoryProvider.notifier).undoClose, - ); - } - }, - builder: (context, _, _) { - return OverlayPortal( - controller: overlayController, - overlayChildBuilder: (context) { - return overlayBuilder!.call(context); - }, - child: Listener( - onPointerDown: sheetDisplayed - ? (_) { + if (context.mounted) { + ui_helper.showTabUndoClose( + context, + ref.read(tabRepositoryProvider.notifier).undoClose, + ); + } + }, + builder: (context, _, _) { + return OverlayPortal( + controller: overlayController, + overlayChildBuilder: (context) { + return overlayBuilder!.call(context); + }, + child: Listener( + onPointerDown: sheetDisplayed + ? (_) { + ref + .read(bottomSheetControllerProvider.notifier) + .dismiss(); + } + : null, + child: BackButtonListener( + onBackButtonPressed: () async { + final tabState = ref.read(selectedTabStateProvider); + + final tabCount = ref.read( + tabListProvider.select((tabs) => tabs.value.length), + ); + + //Don't do anything if a child route is active + if (GoRouterState.of(context).topRoute?.name != + BrowserRoute.name) { + return false; + } + + if (sheetDisplayed) { ref .read(bottomSheetControllerProvider.notifier) .dismiss(); + return true; } - : null, - child: BackButtonListener( - onBackButtonPressed: () async { - final tabState = ref.read(selectedTabStateProvider); - final tabCount = ref.read( - tabListProvider.select((tabs) => tabs.value.length), - ); + if (overlayBuilder != null) { + ref + .read(overlayControllerProvider.notifier) + .dismiss(); + return true; + } - //Don't do anything if a child route is active - if (GoRouterState.of(context).topRoute?.name != - BrowserRoute.name) { - return false; - } - - if (sheetDisplayed) { - ref - .read(bottomSheetControllerProvider.notifier) - .dismiss(); - return true; - } - - if (overlayBuilder != null) { - ref.read(overlayControllerProvider.notifier).dismiss(); - return true; - } - - if (tabState?.isFullScreen == true) { - await ref - .read(selectedTabSessionNotifierProvider) - .exitFullscreen(); - return true; - } - - if (tabState?.isLoading == true) { - lastBackButtonPress.value = null; - - final controller = ref.read( - selectedTabSessionNotifierProvider, - ); - - await controller.stopLoading(); - return true; - } else if (tabState?.readerableState.active == true) { - lastBackButtonPress.value = null; - - await ref - .read(readerableScreenControllerProvider.notifier) - .toggleReaderView(false); - - return true; - } else if (tabState?.historyState.canGoBack == true) { - lastBackButtonPress.value = null; - - final controller = ref.read( - selectedTabSessionNotifierProvider, - ); - - await controller.goBack(); - return true; - } - - //Go router has routes to go back to - if (context.canPop()) { - return true; - } - - if (ref - .read(tabRepositoryProvider.notifier) - .hasLaunchedFromIntent(tabState?.id)) { - //Mark back as unhandled and navigator will pop - await SystemNavigator.pop(); - return false; - } - - if (lastBackButtonPress.value != null && - DateTime.now().difference( - lastBackButtonPress.value!, - ) < - _backButtonPressTimeout) { - lastBackButtonPress.value = null; - - if (tabState != null && tabCount > 1) { + if (tabState?.isFullScreen == true) { await ref - .read(tabRepositoryProvider.notifier) - .closeTab(tabState.id); + .read(selectedTabSessionNotifierProvider) + .exitFullscreen(); + return true; + } - if (context.mounted) { - ui_helper.showTabUndoClose( - context, - ref - .read(tabRepositoryProvider.notifier) - .undoClose, - ); - } + if (tabState?.isLoading == true) { + lastBackButtonPress.value = null; + + final controller = ref.read( + selectedTabSessionNotifierProvider, + ); + + await controller.stopLoading(); + return true; + } else if (tabState?.readerableState.active == true) { + lastBackButtonPress.value = null; + + await ref + .read(readerableScreenControllerProvider.notifier) + .toggleReaderView(false); return true; - } else { + } else if (tabState?.historyState.canGoBack == true) { + lastBackButtonPress.value = null; + + final controller = ref.read( + selectedTabSessionNotifierProvider, + ); + + await controller.goBack(); + return true; + } + + //Go router has routes to go back to + if (context.canPop()) { + return true; + } + + if (ref + .read(tabRepositoryProvider.notifier) + .hasLaunchedFromIntent(tabState?.id)) { //Mark back as unhandled and navigator will pop await SystemNavigator.pop(); return false; } - } else { - lastBackButtonPress.value = DateTime.now(); - ui_helper.showTabBackButtonMessage( - context, - tabCount, - _backButtonPressTimeout, - ); - return true; - } - }, - child: _BrowserView( - sheetDisplayed: sheetDisplayed, - isFullscreen: tabInFullScreen, + if (lastBackButtonPress.value != null && + DateTime.now().difference( + lastBackButtonPress.value!, + ) < + _backButtonPressTimeout) { + lastBackButtonPress.value = null; + + if (tabState != null && tabCount > 1) { + await ref + .read(tabRepositoryProvider.notifier) + .closeTab(tabState.id); + + if (context.mounted) { + ui_helper.showTabUndoClose( + context, + ref + .read(tabRepositoryProvider.notifier) + .undoClose, + ); + } + + return true; + } else { + //Mark back as unhandled and navigator will pop + await SystemNavigator.pop(); + return false; + } + } else { + lastBackButtonPress.value = DateTime.now(); + ui_helper.showTabBackButtonMessage( + context, + tabCount, + _backButtonPressTimeout, + ); + + return true; + } + }, + child: _BrowserView( + sheetDisplayed: sheetDisplayed, + isFullscreen: tabInFullScreen, + ), ), ), - ), - ); + ); + }, + ), + builder: (context, ref, child) { + ref.listen(bottomSheetControllerProvider, (previous, next) { + if (sheetController.value != null) { + sheetController.value!.close(); + sheetController.value = null; + } + + if (next != null) { + final relativeSafeArea = MediaQuery.of( + context, + ).relativeSafeArea(); + sheetController.value = Scaffold.of(context).showBottomSheet(( + context, + ) { + bool dismissOnThreshold( + DraggableScrollableNotification notification, + ) { + if (notification.extent <= 0.1) { + ref + .read(bottomSheetControllerProvider.notifier) + .dismiss(); + return true; + } else { + ref + .read(bottomSheetExtendProvider.notifier) + .add(notification.extent); + } + + return false; + } + + final sheet = switch (next) { + ViewTabsSheet() => + NotificationListener( + key: ValueKey(next), + onNotification: dismissOnThreshold, + child: _ViewTabsSheet(maxChildSize: relativeSafeArea), + ), + final EditUrlSheet parameter => + NotificationListener( + key: ValueKey(parameter), + onNotification: dismissOnThreshold, + child: _ViewUrlSheet( + initialTabState: parameter.tabState, + maxChildSize: relativeSafeArea, + ), + ), + }; + + return sheet; + }); + } + }); + + return child!; }, ), floatingActionButton: ReaderAppearanceButton(),