make fab moveable

This commit is contained in:
Fabian Freund
2026-01-11 06:14:36 +01:00
parent 388091622a
commit ee09aa3050
2 changed files with 138 additions and 14 deletions
@@ -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(
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<Offset?>(null);
final dragStartOffset = useRef<Offset?>(null);
final dragStartPosition = useRef<Offset?>(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,
),
);
}
}