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 a6bc7d0a..f964fb96 100644
--- a/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart
+++ b/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart
@@ -44,6 +44,7 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/contro
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_fab.dart';
+import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/draggable_fab.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/view_tab.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_grid_view.dart';
@@ -182,7 +183,8 @@ class _TabBar extends HookConsumerWidget {
useOnStreamChange(
pointerMoveEvents,
onData: (event) {
- if (!ref.read(generalSettingsWithDefaultsProvider).autoHideTabBar) return;
+ if (!ref.read(generalSettingsWithDefaultsProvider).autoHideTabBar)
+ return;
final diff = event.dy;
if (diff < 0) {
if (diffAcc.value > 0) {
@@ -333,8 +335,8 @@ class BrowserScreen extends HookConsumerWidget {
// This ensures top-of-page content is always accessible
final browserTopOffset =
(tabBarPosition == TabBarPosition.top && topToolbarVisible)
- ? topAppBarTotalHeight
- : 0.0;
+ ? topAppBarTotalHeight
+ : 0.0;
// Theme with dynamic snackbar margin to position above bottom toolbar
final themeData = Theme.of(context).copyWith(
@@ -443,14 +445,11 @@ class BrowserScreen extends HookConsumerWidget {
),
),
- // Layer 4: FAB (animates position with toolbar visibility)
- AnimatedPositioned(
- duration: _AnimatedToolbar._kAnimationDuration,
- curve: Curves.easeInOutQuart,
- right: 16,
- bottom: bottomToolbarVisible
- ? bottomAppBarTotalHeight + 16
- : 16 + bottomSafeArea,
+ // Layer 4: FAB (draggable via long press)
+ DraggableFab(
+ bottomToolbarVisible: bottomToolbarVisible,
+ bottomAppBarHeight: bottomAppBarTotalHeight,
+ bottomSafeArea: bottomSafeArea,
child: const BrowserFab(),
),
@@ -460,9 +459,7 @@ class BrowserScreen extends HookConsumerWidget {
curve: Curves.easeInOutQuart,
left: 0,
right: 0,
- bottom: bottomToolbarVisible
- ? bottomAppBarTotalHeight
- : 0,
+ bottom: bottomToolbarVisible ? bottomAppBarTotalHeight : 0,
child: Consumer(
builder: (context, ref, child) {
final value = ref.watch(
diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/draggable_fab.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/draggable_fab.dart
new file mode 100644
index 00000000..a61c8408
--- /dev/null
+++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/draggable_fab.dart
@@ -0,0 +1,127 @@
+/*
+ * Copyright (c) 2024-2025 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+
+class DraggableFab extends HookConsumerWidget {
+ final Widget child;
+ final double fabSize;
+ final bool bottomToolbarVisible;
+ final double bottomAppBarHeight;
+ final double bottomSafeArea;
+
+ const DraggableFab({
+ super.key,
+ required this.child,
+ this.fabSize = 56.0,
+ required this.bottomToolbarVisible,
+ required this.bottomAppBarHeight,
+ required this.bottomSafeArea,
+ });
+
+ static const _edgePadding = 16.0;
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final mediaQuery = MediaQuery.of(context);
+ final screenSize = mediaQuery.size;
+ final padding = mediaQuery.padding;
+
+ final isDragging = useState(false);
+ final customOffset = useState(null);
+ final dragStartOffset = useRef(null);
+ final dragStartPosition = useRef(null);
+
+ // Calculate default position (bottom-right, respecting toolbar)
+ final defaultBottom = bottomToolbarVisible
+ ? bottomAppBarHeight + _edgePadding
+ : _edgePadding + bottomSafeArea;
+ const defaultRight = _edgePadding;
+ final defaultLeft = screenSize.width - fabSize - defaultRight;
+ final defaultTop = screenSize.height - fabSize - defaultBottom;
+
+ // Current position: custom if set, otherwise default
+ final currentLeft = customOffset.value?.dx ?? defaultLeft;
+ final currentTop = customOffset.value?.dy ?? defaultTop;
+
+ return Positioned(
+ left: currentLeft,
+ top: currentTop,
+ child: GestureDetector(
+ onLongPressStart: (details) {
+ isDragging.value = true;
+ unawaited(HapticFeedback.mediumImpact());
+ dragStartOffset.value = Offset(currentLeft, currentTop);
+ dragStartPosition.value = details.globalPosition;
+ },
+ onLongPressMoveUpdate: (details) {
+ if (!isDragging.value ||
+ dragStartOffset.value == null ||
+ dragStartPosition.value == null) {
+ return;
+ }
+
+ final delta = details.globalPosition - dragStartPosition.value!;
+ final newOffset = dragStartOffset.value! + delta;
+
+ // Clamp to screen bounds
+ customOffset.value = _clampToBounds(
+ newOffset,
+ screenSize: screenSize,
+ padding: padding,
+ );
+ },
+ onLongPressEnd: (details) {
+ isDragging.value = false;
+ dragStartOffset.value = null;
+ dragStartPosition.value = null;
+ },
+ child: AnimatedScale(
+ duration: const Duration(milliseconds: 150),
+ scale: isDragging.value ? 1.1 : 1.0,
+ child: child,
+ ),
+ ),
+ );
+ }
+
+ Offset _clampToBounds(
+ Offset offset, {
+ required Size screenSize,
+ required EdgeInsets padding,
+ }) {
+ const minEdgePadding = 8.0;
+
+ return Offset(
+ offset.dx.clamp(
+ padding.left + minEdgePadding,
+ screenSize.width - fabSize - padding.right - minEdgePadding,
+ ),
+ offset.dy.clamp(
+ padding.top + minEdgePadding,
+ screenSize.height - fabSize - padding.bottom - minEdgePadding,
+ ),
+ );
+ }
+}