refactor tab bar behaviour; respect scroll preferences of websites;

This commit is contained in:
Fabian Freund
2026-02-19 06:20:08 +01:00
parent 4d7c185460
commit 3025d6e42d
21 changed files with 602 additions and 195 deletions
@@ -64,6 +64,7 @@ class TabState extends WebPageInfo {
final bool isPrivate;
final bool isFullScreen;
final bool isLoading;
final bool showToolbarAsExpanded;
bool get isFinishedLoading => !isLoading && progress == 100;
@@ -84,6 +85,7 @@ class TabState extends WebPageInfo {
required this.isPrivate,
required this.isFullScreen,
required this.isLoading,
required this.showToolbarAsExpanded,
required this.securityInfoState,
required this.historyState,
required this.readerableState,
@@ -102,6 +104,7 @@ class TabState extends WebPageInfo {
isPrivate: false,
isFullScreen: false,
isLoading: false,
showToolbarAsExpanded: false,
securityInfoState: SecurityState.$default(),
historyState: HistoryState.$default(),
readerableState: ReaderableState.$default(),
@@ -120,6 +123,7 @@ class TabState extends WebPageInfo {
isPrivate,
isFullScreen,
isLoading,
showToolbarAsExpanded,
securityInfoState,
historyState,
readerableState,
@@ -27,6 +27,8 @@ abstract class _$TabStateCWProxy {
TabState isLoading(bool isLoading);
TabState showToolbarAsExpanded(bool showToolbarAsExpanded);
TabState securityInfoState(SecurityState securityInfoState);
TabState historyState(HistoryState historyState);
@@ -53,6 +55,7 @@ abstract class _$TabStateCWProxy {
bool isPrivate,
bool isFullScreen,
bool isLoading,
bool showToolbarAsExpanded,
SecurityState securityInfoState,
HistoryState historyState,
ReaderableState readerableState,
@@ -97,6 +100,10 @@ class _$TabStateCWProxyImpl implements _$TabStateCWProxy {
@override
TabState isLoading(bool isLoading) => call(isLoading: isLoading);
@override
TabState showToolbarAsExpanded(bool showToolbarAsExpanded) =>
call(showToolbarAsExpanded: showToolbarAsExpanded);
@override
TabState securityInfoState(SecurityState securityInfoState) =>
call(securityInfoState: securityInfoState);
@@ -132,6 +139,7 @@ class _$TabStateCWProxyImpl implements _$TabStateCWProxy {
Object? isPrivate = const $CopyWithPlaceholder(),
Object? isFullScreen = const $CopyWithPlaceholder(),
Object? isLoading = const $CopyWithPlaceholder(),
Object? showToolbarAsExpanded = const $CopyWithPlaceholder(),
Object? securityInfoState = const $CopyWithPlaceholder(),
Object? historyState = const $CopyWithPlaceholder(),
Object? readerableState = const $CopyWithPlaceholder(),
@@ -180,6 +188,12 @@ class _$TabStateCWProxyImpl implements _$TabStateCWProxy {
? _value.isLoading
// ignore: cast_nullable_to_non_nullable
: isLoading as bool,
showToolbarAsExpanded:
showToolbarAsExpanded == const $CopyWithPlaceholder() ||
showToolbarAsExpanded == null
? _value.showToolbarAsExpanded
// ignore: cast_nullable_to_non_nullable
: showToolbarAsExpanded as bool,
securityInfoState:
securityInfoState == const $CopyWithPlaceholder() ||
securityInfoState == null
@@ -87,6 +87,7 @@ class TabStates extends _$TabStates {
isPrivate: contentState.isPrivate,
isFullScreen: contentState.isFullScreen,
isLoading: contentState.isLoading,
showToolbarAsExpanded: contentState.showToolbarAsExpanded,
);
_updateState({...state}..[contentState.id] = newState);
@@ -41,7 +41,7 @@ final class TabStatesProvider
}
}
String _$tabStatesHash() => r'985d2eb87c59c3a113b8bbcf9d96267c42a2f543';
String _$tabStatesHash() => r'975de1242f42e2017754dc560125788b83ad8b72';
abstract class _$TabStates extends $Notifier<Map<String, TabState>> {
Map<String, TabState> build();
@@ -1,40 +0,0 @@
/*
* Copyright (c) 2024-2026 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 'package:riverpod_annotation/riverpod_annotation.dart';
part 'tab_bar_dismissable.g.dart';
@Riverpod(keepAlive: true)
class TabBarDismissableController extends _$TabBarDismissableController {
void show() {
state = false;
}
void dismiss() {
if (!state) {
state = true;
}
}
@override
bool build() {
return false;
}
}
@@ -1,64 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'tab_bar_dismissable.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(TabBarDismissableController)
final tabBarDismissableControllerProvider =
TabBarDismissableControllerProvider._();
final class TabBarDismissableControllerProvider
extends $NotifierProvider<TabBarDismissableController, bool> {
TabBarDismissableControllerProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'tabBarDismissableControllerProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$tabBarDismissableControllerHash();
@$internal
@override
TabBarDismissableController create() => TabBarDismissableController();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(bool value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<bool>(value),
);
}
}
String _$tabBarDismissableControllerHash() =>
r'e0c2f700ccebc35bc8029370cdebd63e98bf95e1';
abstract class _$TabBarDismissableController extends $Notifier<bool> {
bool build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<bool, bool>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<bool, bool>,
bool,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,108 @@
/*
* Copyright (c) 2024-2026 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 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/geckoview/domain/providers.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
part 'toolbar_visibility.g.dart';
enum ToolbarVisibility { visible, hidden, dismissed }
@Riverpod(keepAlive: true)
class ToolbarVisibilityController extends _$ToolbarVisibilityController {
@override
ToolbarVisibility build(String? tabId) {
// Show toolbar when loading starts
ref.listen(tabStatesProvider.select((tabs) => tabs[tabId]?.isLoading), (
previous,
next,
) {
if (!ref.read(generalSettingsWithDefaultsProvider).autoHideTabBar) {
return;
}
if (next == true) {
show();
}
});
// Show toolbar on navigation (history state change)
ref.listen(tabStatesProvider.select((tabs) => tabs[tabId]?.historyState), (
previous,
next,
) {
if (!ref.read(generalSettingsWithDefaultsProvider).autoHideTabBar) {
return;
}
if (next != null && previous != null && previous != next) {
show();
}
});
// Force-show when GeckoView requests toolbar expansion
// (e.g. touch on form input)
ref.listen<bool>(
tabStatesProvider.select(
(tabs) => tabs[tabId]?.showToolbarAsExpanded ?? false,
),
(previous, next) {
if (next && previous != next) {
forceShow();
}
},
);
return ToolbarVisibility.visible;
}
/// Hide toolbar via scroll. All guards checked internally.
void requestHide() {
final settings = ref.read(generalSettingsWithDefaultsProvider);
if (!settings.autoHideTabBar) return;
final isLoading = ref.read(tabStatesProvider)[tabId]?.isLoading ?? false;
if (isLoading) return;
final viewportService = ref.read(viewportServiceProvider);
if (!viewportService.isBrowserHandlingScrollEnabled) return;
if (state == ToolbarVisibility.dismissed) return;
state = ToolbarVisibility.hidden;
}
/// Show toolbar (scroll-up, tab change, loading start, etc).
/// Won't show if manually dismissed — use forceShow() for that.
void show() {
if (state == ToolbarVisibility.dismissed) return;
state = ToolbarVisibility.visible;
}
/// Force-show unconditionally + un-dismiss.
void forceShow() {
state = ToolbarVisibility.visible;
}
/// Dismiss toolbar (user swipe). Only affects this tab.
void dismiss() {
state = ToolbarVisibility.dismissed;
}
}
@@ -0,0 +1,111 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'toolbar_visibility.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(ToolbarVisibilityController)
final toolbarVisibilityControllerProvider =
ToolbarVisibilityControllerFamily._();
final class ToolbarVisibilityControllerProvider
extends $NotifierProvider<ToolbarVisibilityController, ToolbarVisibility> {
ToolbarVisibilityControllerProvider._({
required ToolbarVisibilityControllerFamily super.from,
required String? super.argument,
}) : super(
retry: null,
name: r'toolbarVisibilityControllerProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$toolbarVisibilityControllerHash();
@override
String toString() {
return r'toolbarVisibilityControllerProvider'
''
'($argument)';
}
@$internal
@override
ToolbarVisibilityController create() => ToolbarVisibilityController();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(ToolbarVisibility value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<ToolbarVisibility>(value),
);
}
@override
bool operator ==(Object other) {
return other is ToolbarVisibilityControllerProvider &&
other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$toolbarVisibilityControllerHash() =>
r'242449893afd6780d977544659e83d4641c8a0ad';
final class ToolbarVisibilityControllerFamily extends $Family
with
$ClassFamilyOverride<
ToolbarVisibilityController,
ToolbarVisibility,
ToolbarVisibility,
ToolbarVisibility,
String?
> {
ToolbarVisibilityControllerFamily._()
: super(
retry: null,
name: r'toolbarVisibilityControllerProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: false,
);
ToolbarVisibilityControllerProvider call(String? tabId) =>
ToolbarVisibilityControllerProvider._(argument: tabId, from: this);
@override
String toString() => r'toolbarVisibilityControllerProvider';
}
abstract class _$ToolbarVisibilityController
extends $Notifier<ToolbarVisibility> {
late final _$args = ref.$arg as String?;
String? get tabId => _$args;
ToolbarVisibility build(String? tabId);
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<ToolbarVisibility, ToolbarVisibility>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<ToolbarVisibility, ToolbarVisibility>,
ToolbarVisibility,
Object?,
Object?
>;
element.handleCreate(ref, () => build(_$args));
}
}
@@ -39,7 +39,7 @@ import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/entities/sheet.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tab_bar_dismissable.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/toolbar_visibility.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/keep_tab_dialog.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart';
@@ -136,7 +136,6 @@ class _TabBar extends HookConsumerWidget {
final bool showMainToolbar;
final bool showContextualToolbar;
final bool showQuickTabSwitcherBar;
final ValueNotifier<bool> displayAppBar;
final Stream<Offset>? pointerMoveEvents;
final TabBarPosition tabBarPosition;
@@ -144,7 +143,6 @@ class _TabBar extends HookConsumerWidget {
required this.showMainToolbar,
required this.showContextualToolbar,
required this.showQuickTabSwitcherBar,
required this.displayAppBar,
required this.tabBarPosition,
required this.pointerMoveEvents,
});
@@ -154,30 +152,34 @@ class _TabBar extends HookConsumerWidget {
final tabId = ref.watch(selectedTabProvider);
final displayedSheet = ref.watch(bottomSheetControllerProvider);
final autoHideTabBar = ref.watch(
generalSettingsWithDefaultsProvider.select(
(value) => value.autoHideTabBar,
),
);
// Auto-hide scroll detection hooks (run unconditionally per hook rules)
final diffAcc = useRef(0.0);
void resetHiddenState() {
if (!ref.read(tabBarDismissableControllerProvider)) {
displayAppBar.value = true;
// Reset scroll accumulator on tab switch
useEffect(() {
diffAcc.value = 0.0;
return null;
}, [tabId]);
// Reset scroll accumulator when controller shows toolbar
// (covers: loading start, navigation, showToolbarAsExpanded, forceShow)
ref.listen(toolbarVisibilityControllerProvider(tabId), (previous, next) {
if (next == ToolbarVisibility.visible && previous != next) {
diffAcc.value = 0.0;
}
});
void resetHiddenState() {
ref
.read(
toolbarVisibilityControllerProvider(
ref.read(selectedTabProvider),
).notifier,
)
.show();
diffAcc.value = 0.0;
}
useEffect(() {
if (!autoHideTabBar) return null;
WidgetsBinding.instance.addPostFrameCallback((_) {
resetHiddenState();
});
return null;
}, [tabId, autoHideTabBar]);
useOnAppLifecycleStateChange((previous, current) {
if (!ref.read(generalSettingsWithDefaultsProvider).autoHideTabBar) return;
if (current == AppLifecycleState.resumed) {
@@ -185,38 +187,13 @@ class _TabBar extends HookConsumerWidget {
}
});
ref.listen(tabStateProvider(tabId).select((value) => value?.isLoading), (
previous,
next,
) {
if (!ref.read(generalSettingsWithDefaultsProvider).autoHideTabBar) return;
if (next == true) {
resetHiddenState();
}
});
ref.listen(tabStateProvider(tabId).select((value) => value?.historyState), (
previous,
next,
) {
if (!ref.read(generalSettingsWithDefaultsProvider).autoHideTabBar) return;
if (next != null && previous != null) {
if (previous != next) {
resetHiddenState();
}
}
});
useOnStreamChange(
pointerMoveEvents,
onData: (event) {
if (!ref.read(generalSettingsWithDefaultsProvider).autoHideTabBar) {
return;
}
// Don't apply auto-hide when toolbar is manually dismissed
if (ref.read(tabBarDismissableControllerProvider)) {
return;
}
final autoHide = ref
.read(generalSettingsWithDefaultsProvider)
.autoHideTabBar;
if (!autoHide) return;
final diff = event.dy;
if (diff < 0) {
@@ -225,7 +202,13 @@ class _TabBar extends HookConsumerWidget {
}
diffAcc.value += diff;
if (diffAcc.value.abs() > kToolbarHeight * 1.5) {
displayAppBar.value = false;
ref
.read(
toolbarVisibilityControllerProvider(
ref.read(selectedTabProvider),
).notifier,
)
.requestHide();
}
} else if (diff > 0) {
if (diffAcc.value < 0) {
@@ -267,6 +250,9 @@ class BrowserScreen extends HookConsumerWidget {
final tabInFullScreen = ref.watch(
selectedTabStateProvider.select((value) => value?.isFullScreen ?? false),
);
final tabIsLoading = ref.watch(
selectedTabStateProvider.select((value) => value?.isLoading ?? false),
);
final overlayController = useOverlayPortalController();
@@ -300,12 +286,6 @@ class BrowserScreen extends HookConsumerWidget {
),
);
final displayAppBar = useValueNotifier(true);
ref.listen(tabBarDismissableControllerProvider, (previous, next) {
displayAppBar.value = !next;
});
ref.listen(overlayControllerProvider, (previous, next) {
if (next != null) {
overlayController.show();
@@ -330,14 +310,19 @@ class BrowserScreen extends HookConsumerWidget {
final sheetDisplayed = displayedSheet != null;
// Compute visibility states for toolbars
final appBarVisible = useValueListenable(displayAppBar);
final selectedTabId = ref.watch(selectedTabProvider);
final toolbarState = ref.watch(
toolbarVisibilityControllerProvider(selectedTabId),
);
// Toolbar is visible when: sheet is shown OR (not fullscreen AND app bar visible)
// appBarVisible reflects both scroll-based auto-hide and swipe dismiss state
// Toolbar is visible when: sheet is shown OR (not fullscreen AND controller says visible)
// The controller handles loading-start show internally via ref.listen on isLoading
final effectiveAppBarVisible =
toolbarState == ToolbarVisibility.visible;
final topToolbarVisible =
sheetDisplayed || (!tabInFullScreen && appBarVisible);
sheetDisplayed || (!tabInFullScreen && effectiveAppBarVisible);
final bottomToolbarVisible =
sheetDisplayed || (!tabInFullScreen && appBarVisible);
sheetDisplayed || (!tabInFullScreen && effectiveAppBarVisible);
// Calculate relative safe area for sheet max size
final relativeSafeArea = MediaQuery.of(context).relativeSafeArea();
@@ -382,7 +367,6 @@ class BrowserScreen extends HookConsumerWidget {
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
// Watch find-in-page visibility for the selected tab
final selectedTabId = ref.watch(selectedTabProvider);
final findInPageVisible =
selectedTabId != null &&
ref.watch(
@@ -446,7 +430,8 @@ class BrowserScreen extends HookConsumerWidget {
// Keep clipping state in sync with keyboard and auto-hide states.
// While keyboard is visible, do not apply toolbar clipping.
useEffect(() {
final targetClippingPx = autoHideTabBar && !keyboardVisible
final targetClippingPx =
autoHideTabBar && !keyboardVisible && !tabIsLoading
? desiredToolbarClippingPx.value
: 0;
@@ -456,7 +441,7 @@ class BrowserScreen extends HookConsumerWidget {
}
return null;
}, [autoHideTabBar, keyboardVisible]);
}, [autoHideTabBar, keyboardVisible, tabIsLoading]);
final animationProgressCallback = useCallback((
double progress,
@@ -467,13 +452,15 @@ class BrowserScreen extends HookConsumerWidget {
final clippingPx = -((1.0 - progress) * heightPx * pixelRatio).round();
desiredToolbarClippingPx.value = clippingPx;
final targetClippingPx = keyboardVisible ? 0 : clippingPx;
final targetClippingPx = (keyboardVisible || tabIsLoading)
? 0
: clippingPx;
if (targetClippingPx != lastClippingPx.value) {
lastClippingPx.value = targetClippingPx;
unawaited(viewportService.setVerticalClipping(targetClippingPx));
}
}, [keyboardVisible, pixelRatio]);
}, [keyboardVisible, pixelRatio, tabIsLoading]);
// Theme with dynamic snackbar margin to position above bottom toolbar
final themeData = Theme.of(context).copyWith(
@@ -522,7 +509,6 @@ class BrowserScreen extends HookConsumerWidget {
bottom: browserBottomOffset,
child: _Browser(
overlayController: overlayController,
displayAppBar: displayAppBar,
tabInFullScreen: tabInFullScreen,
pointerMoveEventSink: pointerMoveEventsController.sink,
sheetDisplayed: sheetDisplayed,
@@ -558,7 +544,6 @@ class BrowserScreen extends HookConsumerWidget {
: null,
child: _TabBar(
tabBarPosition: TabBarPosition.bottom,
displayAppBar: displayAppBar,
showMainToolbar: tabBarPosition == TabBarPosition.bottom,
showContextualToolbar: showContextualToolbar,
showQuickTabSwitcherBar: showQuickTabSwitcherBar,
@@ -583,7 +568,6 @@ class BrowserScreen extends HookConsumerWidget {
child: _TabBar(
tabBarPosition: TabBarPosition.top,
showMainToolbar: true,
displayAppBar: displayAppBar,
showContextualToolbar: showContextualToolbar,
showQuickTabSwitcherBar: showQuickTabSwitcherBar,
pointerMoveEvents: pointerMoveEventsController.stream,
@@ -729,7 +713,6 @@ class _Browser extends HookConsumerWidget {
Duration get _backButtonPressTimeout => const Duration(seconds: 2);
final OverlayPortalController overlayController;
final ValueNotifier<bool> displayAppBar;
final StreamSink<Offset> pointerMoveEventSink;
final bool tabInFullScreen;
final bool sheetDisplayed;
@@ -737,7 +720,6 @@ class _Browser extends HookConsumerWidget {
const _Browser({
required this.overlayController,
required this.displayAppBar,
required this.tabInFullScreen,
required this.pointerMoveEventSink,
required this.sheetDisplayed,
@@ -822,9 +804,10 @@ class _Browser extends HookConsumerWidget {
}
//Make sure app bar is visible
if (!ref.read(tabBarDismissableControllerProvider)) {
displayAppBar.value = true;
}
final tabId = ref.read(selectedTabProvider);
ref
.read(toolbarVisibilityControllerProvider(tabId).notifier)
.show();
if (tabState?.isLoading == true) {
lastBackButtonPress.value = null;
@@ -32,12 +32,13 @@ import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/readerable.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
import 'package:weblibre/features/geckoview/domain/providers.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/entities/sheet.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tab_bar_dismissable.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/toolbar_visibility.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/extension_shortcut_menu.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart';
@@ -59,6 +60,7 @@ import 'package:weblibre/presentation/icons/weblibre_icons.dart';
import 'package:weblibre/presentation/widgets/safe_raw_image.dart';
import 'package:weblibre/presentation/widgets/selectable_chips.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart';
import 'package:weblibre/utils/ui_helper.dart';
class BrowserTopAppBar extends HookConsumerWidget {
final bool showMainToolbar;
@@ -68,7 +70,8 @@ class BrowserTopAppBar extends HookConsumerWidget {
late final BrowserTabBar _tabBar;
late final _size = Size.fromHeight(_tabBar.getToolbarHeight());
BrowserTopAppBar({super.key,
BrowserTopAppBar({
super.key,
required this.showMainToolbar,
required this.showContextualToolbar,
required this.showQuickTabSwitcherBar,
@@ -103,7 +106,8 @@ class BrowserBottomAppBar extends HookConsumerWidget {
late final BrowserTabBar _tabBar;
late final _size = Size.fromHeight(_tabBar.getToolbarHeight());
BrowserBottomAppBar({super.key,
BrowserBottomAppBar({
super.key,
required this.showMainToolbar,
required this.displayedSheet,
required this.showContextualToolbar,
@@ -148,7 +152,8 @@ class BrowserTabBar extends HookConsumerWidget {
final bool showMainToolbarNavigationButton;
final bool showMainToolbarTabActionButton;
const BrowserTabBar({super.key,
const BrowserTabBar({
super.key,
required this.showMainToolbar,
required this.displayedSheet,
required this.showContextualToolbar,
@@ -270,8 +275,23 @@ class BrowserTabBar extends HookConsumerWidget {
!distance.dy.isNegative && distance.dy.abs() > dismissThreshold,
};
if (shouldDismiss && ref.read(bottomSheetControllerProvider) == null) {
unawaited(HapticFeedback.lightImpact());
ref.read(tabBarDismissableControllerProvider.notifier).dismiss();
final viewportService = ref.read(viewportServiceProvider);
if (viewportService.isBrowserHandlingScrollEnabled) {
unawaited(HapticFeedback.lightImpact());
ref
.read(
toolbarVisibilityControllerProvider(selectedTabId).notifier,
)
.dismiss();
} else if (context.mounted) {
showDismissOverrideMessage(context, () {
ref
.read(
toolbarVisibilityControllerProvider(selectedTabId).notifier,
)
.dismiss();
});
}
}
},
child: Column(
@@ -21,8 +21,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/readerable.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tab_bar_dismissable.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/toolbar_visibility.dart';
import 'package:weblibre/features/geckoview/features/readerview/domain/providers/readerable.dart';
class BrowserFab extends HookConsumerWidget {
@@ -30,7 +31,10 @@ class BrowserFab extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabBarDismissed = ref.watch(tabBarDismissableControllerProvider);
final selectedTabId = ref.watch(selectedTabProvider);
final toolbarState = ref.watch(
toolbarVisibilityControllerProvider(selectedTabId),
);
final appearanceButtonVisible = ref.watch(
appearanceButtonVisibilityProvider.select(
@@ -54,12 +58,16 @@ class BrowserFab extends HookConsumerWidget {
},
child: const Icon(MdiIcons.formatFont),
);
} else if (tabBarDismissed) {
} else if (toolbarState == ToolbarVisibility.dismissed) {
child = FloatingActionButton(
key: const ValueKey('dock_fab'),
heroTag: 'dock_fab',
onPressed: () {
ref.read(tabBarDismissableControllerProvider.notifier).show();
ref
.read(
toolbarVisibilityControllerProvider(selectedTabId).notifier,
)
.forceShow();
},
child: const Icon(MdiIcons.dockBottom),
);
@@ -33,7 +33,7 @@ final class ProfileRepositoryProvider
ProfileRepository create() => ProfileRepository();
}
String _$profileRepositoryHash() => r'c17702af1e59727ab3fec26e9ca659048e92c8bb';
String _$profileRepositoryHash() => r'b770e7406e1602f808cc8076c1eda67b4fce6b2d';
abstract class _$ProfileRepository extends $AsyncNotifier<List<Profile>> {
FutureOr<List<Profile>> build();
+16
View File
@@ -192,3 +192,19 @@ void showTabUndoClose(
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
void showDismissOverrideMessage(
BuildContext context,
VoidCallback onDismiss, {
Duration duration = const Duration(seconds: 4),
}) {
ScaffoldMessenger.of(context).clearSnackBars();
final snackBar = _createFloatingSnackBar(
content: const Text('Hiding disabled by site'),
action: SnackBarAction(label: 'Dismiss', onPressed: onDismiss),
duration: duration,
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
@@ -26,6 +26,7 @@ import eu.weblibre.flutter_mozilla_components.addons.WebExtensionActionPopupActi
import eu.weblibre.flutter_mozilla_components.addons.WebExtensionPromptFeature
import eu.weblibre.flutter_mozilla_components.databinding.FragmentBrowserBinding
import eu.weblibre.flutter_mozilla_components.ext.getPreferenceKey
import eu.weblibre.flutter_mozilla_components.feature.BrowserHandlingScrollFeature
import eu.weblibre.flutter_mozilla_components.feature.KeyboardVisibilityFeature
import eu.weblibre.flutter_mozilla_components.feature.ReadabilityExtractFeature
import eu.weblibre.flutter_mozilla_components.feature.WebExtensionToolbarFeature
@@ -97,6 +98,9 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
// Keyboard visibility detection feature
private var keyboardVisibilityFeature: KeyboardVisibilityFeature? = null
// Browser scroll-handling detection feature
private var browserHandlingScrollFeature: BrowserHandlingScrollFeature? = null
// Registers a photo picker activity launcher in single-select mode.
private val singleMediaPicker =
AndroidPhotoPicker.singleMediaPicker(
@@ -500,6 +504,10 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
keyboardVisibilityFeature = KeyboardVisibilityFeature(viewportEvents).also {
it.start(binding.root)
}
browserHandlingScrollFeature = BrowserHandlingScrollFeature(viewportEvents).also {
it.start()
}
}
onEngineSetupComplete()
@@ -614,6 +622,10 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
keyboardVisibilityFeature?.stop()
keyboardVisibilityFeature = null
// Stop browser scroll-handling detection
browserHandlingScrollFeature?.stop()
browserHandlingScrollFeature = null
GlobalComponents.onPullToRefreshEnabledChanged = null
val engineView = fragmentEngineView
engineView?.setActivityContext(null)
@@ -216,7 +216,8 @@ class GeckoTabsApiImpl : GeckoTabsApi {
progress = tab.content.progress.toLong(),
isPrivate = tab.content.private,
isFullScreen = tab.content.fullScreen,
isLoading = tab.content.loading
isLoading = tab.content.loading,
showToolbarAsExpanded = tab.content.showToolbarAsExpanded,
)
) { }
}
@@ -30,6 +30,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import mozilla.components.browser.state.action.BrowserAction
import mozilla.components.browser.state.action.ContentAction
import mozilla.components.browser.state.selector.selectedTab
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.feature.addons.logger
@@ -188,7 +189,8 @@ class Events(
it.content.private,
it.content.fullScreen,
it.content.progress,
it.content.loading
it.content.loading,
it.content.showToolbarAsExpanded,
)
}
.debounce(15)
@@ -204,9 +206,19 @@ class Events(
progress = tab.content.progress.toLong(),
isPrivate = tab.content.private,
isFullScreen = tab.content.fullScreen,
isLoading = tab.content.loading
isLoading = tab.content.loading,
showToolbarAsExpanded = tab.content.showToolbarAsExpanded,
)
) { _ -> }
// Reset showToolbarAsExpanded after forwarding to Flutter,
// mirroring Fenix ToolbarBehaviorController behavior.
// This ensures subsequent expand events trigger a new state change.
if (tab.content.showToolbarAsExpanded) {
stateFlow.dispatch(
ContentAction.UpdateExpandedToolbarStateAction(tab.id, false)
)
}
}
}
@@ -0,0 +1,124 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.feature
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import eu.weblibre.flutter_mozilla_components.GlobalComponents
import eu.weblibre.flutter_mozilla_components.ext.EventSequence
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoViewportEvents
import org.mozilla.gecko.util.ThreadUtils.runOnUiThread
/**
* Feature that reports whether GeckoView is currently handling scroll input.
*
* Checks InputResultDetail while active and only emits on state changes.
*/
class BrowserHandlingScrollFeature(
private val flutterEvents: GeckoViewportEvents,
) {
private var running = false
private var touchSessionActive = false
private var lastValue: Boolean? = null
private var touchListener: View.OnTouchListener? = null
private var touchTargetView: View? = null
fun start() {
if (running) return
running = true
touchSessionActive = false
lastValue = null
attachTouchListenerIfPossible()
}
fun stop() {
if (!running) return
running = false
touchSessionActive = false
detachTouchListenerIfPossible()
lastValue = null
}
private fun attachTouchListenerIfPossible() {
val engineViewRoot = GlobalComponents.components?.mainBrowserEngineView?.asView()
if (engineViewRoot == null) return
if (touchListener != null) return
val targetView = resolveTouchTargetView(engineViewRoot)
touchListener = View.OnTouchListener { _, event ->
if (!running) return@OnTouchListener false
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
touchSessionActive = true
emitIfChanged()
}
MotionEvent.ACTION_MOVE -> {
if (touchSessionActive) {
emitIfChanged()
}
}
MotionEvent.ACTION_UP,
MotionEvent.ACTION_CANCEL -> {
touchSessionActive = false
}
}
// Never consume touch; GeckoView must keep handling it.
false
}
targetView.setOnTouchListener(touchListener)
touchTargetView = targetView
}
private fun detachTouchListenerIfPossible() {
if (touchTargetView != null && touchListener != null) {
touchTargetView?.setOnTouchListener(null)
}
touchListener = null
touchTargetView = null
}
private fun resolveTouchTargetView(root: View): View {
if (root !is ViewGroup) return root
// GeckoEngineView wraps the actual NestedGeckoView as its first child.
if (root.childCount > 0) {
return root.getChildAt(0)
}
return root
}
private fun emitIfChanged() {
val isHandling = try {
val engineView = GlobalComponents.components?.mainBrowserEngineView
if (engineView == null) {
false
} else {
val detail = engineView.getInputResultDetail()
detail.canScrollToTop() || detail.canScrollToBottom()
}
} catch (_: Throwable) {
false
}
if (lastValue == isHandling) return
lastValue = isHandling
val sequence = EventSequence.next()
runOnUiThread {
flutterEvents.onBrowserHandlingScrollChanged(sequence, isHandling) { }
}
}
}
@@ -1597,7 +1597,8 @@ data class TabContentState (
val progress: Long,
val isPrivate: Boolean,
val isFullScreen: Boolean,
val isLoading: Boolean
val isLoading: Boolean,
val showToolbarAsExpanded: Boolean
)
{
companion object {
@@ -1611,7 +1612,8 @@ data class TabContentState (
val isPrivate = pigeonVar_list[6] as Boolean
val isFullScreen = pigeonVar_list[7] as Boolean
val isLoading = pigeonVar_list[8] as Boolean
return TabContentState(id, parentId, contextId, url, title, progress, isPrivate, isFullScreen, isLoading)
val showToolbarAsExpanded = pigeonVar_list[9] as Boolean
return TabContentState(id, parentId, contextId, url, title, progress, isPrivate, isFullScreen, isLoading, showToolbarAsExpanded)
}
}
fun toList(): List<Any?> {
@@ -1625,6 +1627,7 @@ data class TabContentState (
isPrivate,
isFullScreen,
isLoading,
showToolbarAsExpanded,
)
}
override fun equals(other: Any?): Boolean {
@@ -6919,6 +6922,31 @@ class GeckoViewportEvents(private val binaryMessenger: BinaryMessenger, private
}
}
}
/**
* Called when GeckoView scroll-handling eligibility changes.
*
* [sequence] Event sequence number for ordering.
* [isHandling] True when browser content can consume scrolling for
* dynamic toolbar behavior. False when content is not scrollable or
* the page consumed touch input.
*/
fun onBrowserHandlingScrollChanged(sequenceArg: Long, isHandlingArg: Boolean, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoViewportEvents.onBrowserHandlingScrollChanged$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(sequenceArg, isHandlingArg)) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
} else {
callback(Result.success(Unit))
}
} else {
callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName)))
}
}
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface GeckoBookmarksApi {
@@ -30,6 +30,7 @@ class GeckoViewportService extends GeckoViewportEvents {
isVisible: false,
isAnimating: false,
));
final _browserHandlingScrollSubject = BehaviorSubject<bool>.seeded(false);
/// Stream of keyboard visibility changes.
///
@@ -46,6 +47,17 @@ class GeckoViewportService extends GeckoViewportEvents {
/// Whether the keyboard is currently visible.
bool get isKeyboardVisible => _keyboardSubject.value.isVisible;
/// Stream of browser scroll-handling eligibility.
///
/// Emits true when GeckoView reports that browser content can handle
/// scrolling for dynamic toolbar behavior.
ValueStream<bool> get browserHandlingScrollEvents =>
_browserHandlingScrollSubject.stream;
/// Current browser scroll-handling eligibility.
bool get isBrowserHandlingScrollEnabled =>
_browserHandlingScrollSubject.value;
/// Creates a new viewport service.
///
/// Call [setUp] to register the event handlers after construction.
@@ -113,8 +125,14 @@ class GeckoViewportService extends GeckoViewportEvents {
));
}
@override
void onBrowserHandlingScrollChanged(int sequence, bool isHandling) {
_browserHandlingScrollSubject.addWhenMoreRecent(sequence, null, isHandling);
}
/// Disposes the service and closes all streams.
Future<void> dispose() async {
await _keyboardSubject.close();
await _browserHandlingScrollSubject.close();
}
}
@@ -1735,6 +1735,7 @@ class TabContentState {
required this.isPrivate,
required this.isFullScreen,
required this.isLoading,
required this.showToolbarAsExpanded,
});
String id;
@@ -1755,6 +1756,8 @@ class TabContentState {
bool isLoading;
bool showToolbarAsExpanded;
List<Object?> _toList() {
return <Object?>[
id,
@@ -1766,6 +1769,7 @@ class TabContentState {
isPrivate,
isFullScreen,
isLoading,
showToolbarAsExpanded,
];
}
@@ -1784,6 +1788,7 @@ class TabContentState {
isPrivate: result[6]! as bool,
isFullScreen: result[7]! as bool,
isLoading: result[8]! as bool,
showToolbarAsExpanded: result[9]! as bool,
);
}
@@ -8133,6 +8138,14 @@ abstract class GeckoViewportEvents {
/// [isAnimating] Whether the keyboard is currently animating.
void onKeyboardVisibilityChanged(int sequence, int heightPx, bool isVisible, bool isAnimating);
/// Called when GeckoView scroll-handling eligibility changes.
///
/// [sequence] Event sequence number for ordering.
/// [isHandling] True when browser content can consume scrolling for
/// dynamic toolbar behavior. False when content is not scrollable or
/// the page consumed touch input.
void onBrowserHandlingScrollChanged(int sequence, bool isHandling);
static void setUp(GeckoViewportEvents? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) {
messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
{
@@ -8169,6 +8182,34 @@ abstract class GeckoViewportEvents {
});
}
}
{
final pigeonVar_channel = BasicMessageChannel<Object?>(
'dev.flutter.pigeon.flutter_mozilla_components.GeckoViewportEvents.onBrowserHandlingScrollChanged$messageChannelSuffix', pigeonChannelCodec,
binaryMessenger: binaryMessenger);
if (api == null) {
pigeonVar_channel.setMessageHandler(null);
} else {
pigeonVar_channel.setMessageHandler((Object? message) async {
assert(message != null,
'Argument for dev.flutter.pigeon.flutter_mozilla_components.GeckoViewportEvents.onBrowserHandlingScrollChanged was null.');
final List<Object?> args = (message as List<Object?>?)!;
final int? arg_sequence = (args[0] as int?);
assert(arg_sequence != null,
'Argument for dev.flutter.pigeon.flutter_mozilla_components.GeckoViewportEvents.onBrowserHandlingScrollChanged was null, expected non-null int.');
final bool? arg_isHandling = (args[1] as bool?);
assert(arg_isHandling != null,
'Argument for dev.flutter.pigeon.flutter_mozilla_components.GeckoViewportEvents.onBrowserHandlingScrollChanged was null, expected non-null bool.');
try {
api.onBrowserHandlingScrollChanged(arg_sequence!, arg_isHandling!);
return wrapResponse(empty: true);
} on PlatformException catch (e) {
return wrapResponse(error: e);
} catch (e) {
return wrapResponse(error: PlatformException(code: 'error', message: e.toString()));
}
});
}
}
}
}
@@ -517,6 +517,7 @@ class TabContentState {
final bool isPrivate;
final bool isFullScreen;
final bool isLoading;
final bool showToolbarAsExpanded;
TabContentState(
this.id,
@@ -528,6 +529,7 @@ class TabContentState {
this.isPrivate,
this.isFullScreen,
this.isLoading,
this.showToolbarAsExpanded,
);
}
@@ -1746,6 +1748,14 @@ abstract class GeckoViewportEvents {
bool isVisible,
bool isAnimating,
);
/// Called when GeckoView scroll-handling eligibility changes.
///
/// [sequence] Event sequence number for ordering.
/// [isHandling] True when browser content can consume scrolling for
/// dynamic toolbar behavior. False when content is not scrollable or
/// the page consumed touch input.
void onBrowserHandlingScrollChanged(int sequence, bool isHandling);
}
// =============================================================================