From ee09aa3050f426eaa9e040afd6005619a4ec3f95 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Sun, 11 Jan 2026 06:14:36 +0100 Subject: [PATCH] make fab moveable --- .../browser/presentation/screens/browser.dart | 25 ++-- .../browser_modules/draggable_fab.dart | 127 ++++++++++++++++++ 2 files changed, 138 insertions(+), 14 deletions(-) create mode 100644 app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/draggable_fab.dart 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, + ), + ); + } +}