349 lines
14 KiB
Dart
349 lines
14 KiB
Dart
/*
|
|
* 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 'dart:async';
|
|
import 'dart:math';
|
|
|
|
import 'package:flutter/widgets.dart';
|
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|
import 'package:weblibre/core/logger.dart';
|
|
import 'package:weblibre/core/providers/router.dart';
|
|
import 'package:weblibre/core/routing/routes.dart';
|
|
import 'package:weblibre/features/geckoview/domain/providers.dart';
|
|
import 'package:weblibre/features/geckoview/domain/providers/desktop_mode.dart';
|
|
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
|
|
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/bookmarks/domain/repositories/bookmarks.dart';
|
|
import 'package:weblibre/features/geckoview/features/bookmarks/domain/utils/bookmark_tree_utils.dart';
|
|
import 'package:weblibre/features/geckoview/features/browser/domain/entities/font_size_constants.dart';
|
|
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/translation_bottom_sheet.dart';
|
|
import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart';
|
|
import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart';
|
|
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
|
|
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
|
|
import 'package:weblibre/features/gestures/data/models/gesture_action.dart';
|
|
import 'package:weblibre/features/gestures/data/models/gesture_settings.dart';
|
|
import 'package:weblibre/features/gestures/data/models/gesture_stroke.dart';
|
|
import 'package:weblibre/features/gestures/domain/repositories/gesture_settings.dart';
|
|
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
|
|
import 'package:weblibre/features/user/data/models/engine_settings.dart';
|
|
import 'package:weblibre/features/user/domain/presentation/dialogs/quit_browser_dialog.dart';
|
|
import 'package:weblibre/features/user/domain/repositories/engine_settings.dart';
|
|
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
|
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
|
|
import 'package:weblibre/utils/exit_app.dart';
|
|
import 'package:weblibre/utils/move_to_background.dart';
|
|
|
|
part 'gesture_control.g.dart';
|
|
|
|
/// Bridges gesture settings and recognized-gesture events to app actions.
|
|
///
|
|
/// On build it (1) keeps the native recognizer's [GestureConfig] in sync with
|
|
/// the user's [GestureSettings], and (2) subscribes to recognized gestures and
|
|
/// dispatches the bound [GestureAction] against the currently selected tab.
|
|
///
|
|
/// Must be kept alive (eagerly listened to from the browser view) for the
|
|
/// lifetime of the browser so the subscription stays active.
|
|
@Riverpod(keepAlive: true)
|
|
class GestureControlService extends _$GestureControlService {
|
|
/// Timestamp (ms since epoch) of the last fired gesture, for cooldown.
|
|
int _lastDispatchMs = 0;
|
|
|
|
@override
|
|
void build() {
|
|
final service = ref.read(gestureServiceProvider);
|
|
|
|
// Keep the native recognizer in sync with the effective configuration
|
|
// (settings folded together with the current site's exclusion state).
|
|
ref.listen(
|
|
fireImmediately: true,
|
|
gestureNativeConfigProvider,
|
|
(previous, next) async {
|
|
await service.setGestureConfig(next);
|
|
},
|
|
onError: (error, stackTrace) {
|
|
logger.e(
|
|
'Error syncing gesture configuration',
|
|
error: error,
|
|
stackTrace: stackTrace,
|
|
);
|
|
},
|
|
);
|
|
|
|
final subscription = service.recognizedGestures.listen(
|
|
(gestureKey) {
|
|
unawaited(_dispatch(gestureKey));
|
|
},
|
|
onError: (Object error, StackTrace stackTrace) {
|
|
logger.e(
|
|
'Error handling recognized gesture',
|
|
error: error,
|
|
stackTrace: stackTrace,
|
|
);
|
|
},
|
|
);
|
|
ref.onDispose(subscription.cancel);
|
|
}
|
|
|
|
Future<void> _dispatch(String gestureKey) async {
|
|
final settings = ref.read(gestureSettingsWithDefaultsProvider);
|
|
final action = settings.bindings[gestureKey];
|
|
if (action == null) return;
|
|
|
|
// Cooldown: ignore gestures fired within intervalMs of the previous one.
|
|
final now = DateTime.now().millisecondsSinceEpoch;
|
|
if (settings.intervalMs > 0 &&
|
|
now - _lastDispatchMs < settings.intervalMs) {
|
|
return;
|
|
}
|
|
_lastDispatchMs = now;
|
|
|
|
final tabId = ref.read(selectedTabProvider);
|
|
if (tabId == null) return;
|
|
|
|
try {
|
|
// Feedback is handled live by the gesture overlay while the stroke is
|
|
// drawn (driven by the native progress events), so nothing to show here.
|
|
await _execute(action, tabId);
|
|
} catch (error, stackTrace) {
|
|
logger.e(
|
|
'Error executing gesture action ${action.name}',
|
|
error: error,
|
|
stackTrace: stackTrace,
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _execute(GestureAction action, String tabId) async {
|
|
final tabRepository = ref.read(tabRepositoryProvider.notifier);
|
|
|
|
switch (action) {
|
|
case GestureAction.back:
|
|
await ref.read(tabSessionProvider(tabId: tabId).notifier).goBack();
|
|
case GestureAction.forward:
|
|
await ref.read(tabSessionProvider(tabId: tabId).notifier).goForward();
|
|
case GestureAction.reload:
|
|
await ref.read(tabSessionProvider(tabId: tabId).notifier).reload();
|
|
case GestureAction.scrollTop:
|
|
await ref.read(tabSessionProvider(tabId: tabId).notifier).scrollToTop();
|
|
case GestureAction.scrollBottom:
|
|
await ref
|
|
.read(tabSessionProvider(tabId: tabId).notifier)
|
|
.scrollToBottom();
|
|
case GestureAction.pageUp:
|
|
await ref.read(tabSessionProvider(tabId: tabId).notifier).pageUp();
|
|
case GestureAction.pageDown:
|
|
await ref.read(tabSessionProvider(tabId: tabId).notifier).pageDown();
|
|
case GestureAction.newTab:
|
|
await _openNewTab(tabId);
|
|
case GestureAction.closeTab:
|
|
await tabRepository.closeTab(tabId);
|
|
case GestureAction.duplicateTab:
|
|
final containerData = await ref
|
|
.read(tabDataRepositoryProvider.notifier)
|
|
.getTabContainerData(tabId);
|
|
await tabRepository.duplicateTab(
|
|
selectTabId: tabId,
|
|
containerData: containerData,
|
|
selectTab: true,
|
|
);
|
|
case GestureAction.nextTab:
|
|
await tabRepository.selectNextTab(tabId);
|
|
case GestureAction.previousTab:
|
|
await tabRepository.selectPreviousTab(tabId);
|
|
case GestureAction.lastUsedTab:
|
|
await tabRepository.selectPreviouslyOpenedTab(tabId);
|
|
case GestureAction.togglePinTab:
|
|
final pinned =
|
|
ref.read(watchPinnedTabIdsProvider).value?.contains(tabId) ?? false;
|
|
await ref
|
|
.read(tabDataRepositoryProvider.notifier)
|
|
.setPinned(tabId, pinned: !pinned);
|
|
case GestureAction.toggleReaderMode:
|
|
final readerActive =
|
|
ref.read(tabStateProvider(tabId))?.readerableState.active ?? false;
|
|
await ref
|
|
.read(readerableScreenControllerProvider.notifier)
|
|
.toggleReaderView(!readerActive);
|
|
case GestureAction.toggleDesktopMode:
|
|
ref.read(desktopModeProvider(tabId).notifier).toggle();
|
|
case GestureAction.findInPage:
|
|
ref.read(findInPageControllerProvider(tabId).notifier).show();
|
|
case GestureAction.increaseFontSize:
|
|
await _adjustFontSize(increase: true);
|
|
case GestureAction.decreaseFontSize:
|
|
await _adjustFontSize(increase: false);
|
|
case GestureAction.showHistory:
|
|
await _pushLocation(const HistoryRoute().location);
|
|
case GestureAction.showBookmarks:
|
|
await _pushLocation(
|
|
BookmarkListRoute(entryGuid: BookmarkRoot.root.id).location,
|
|
);
|
|
case GestureAction.toggleBookmark:
|
|
await _toggleBookmark(tabId);
|
|
case GestureAction.translatePage:
|
|
final context = await _navigatorContext();
|
|
if (context != null && context.mounted) {
|
|
await showTranslationBottomSheet(context, selectedTabId: tabId);
|
|
}
|
|
case GestureAction.moveToBackground:
|
|
await moveToBackground();
|
|
case GestureAction.quitBrowser:
|
|
final context = await _navigatorContext();
|
|
if (context != null && context.mounted) {
|
|
final confirmed = await showQuitBrowserDialog(context);
|
|
if (confirmed == true && context.mounted) {
|
|
await exitApp(ProviderScope.containerOf(context, listen: false));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Adds the current page to bookmarks, or removes it if already bookmarked,
|
|
/// mirroring the contextual toolbar's bookmark toggle button.
|
|
Future<void> _toggleBookmark(String tabId) async {
|
|
final tabState = ref.read(tabStateProvider(tabId));
|
|
if (tabState == null) return;
|
|
|
|
final bookmarkUrl =
|
|
ref.read(sandboxSourceUriForTabProvider(tabId: tabId)) ?? tabState.url;
|
|
|
|
final bookmarks = ref.read(bookmarksRepositoryProvider).value;
|
|
final existingGuids = bookmarkGuidsForUrl(bookmarks, bookmarkUrl);
|
|
|
|
final repository = ref.read(bookmarksRepositoryProvider.notifier);
|
|
if (existingGuids.isNotEmpty) {
|
|
for (final guid in existingGuids) {
|
|
await repository.delete(guid);
|
|
}
|
|
} else {
|
|
await repository.addBookmark(
|
|
parentGuid: BookmarkRoot.mobile.id,
|
|
url: bookmarkUrl,
|
|
title: tabState.titleOrAuthority,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Resolves the root navigator's [BuildContext] for actions that open a
|
|
/// dialog/sheet. Null when the navigator is not currently mounted.
|
|
Future<BuildContext?> _navigatorContext() async {
|
|
final router = await ref.read(routerProvider.future);
|
|
if (!ref.mounted) return null;
|
|
return router.routerDelegate.navigatorKey.currentContext;
|
|
}
|
|
|
|
Future<void> _pushLocation(String location) async {
|
|
final router = await ref.read(routerProvider.future);
|
|
if (!ref.mounted) return;
|
|
await router.push(location);
|
|
}
|
|
|
|
Future<void> _adjustFontSize({required bool increase}) async {
|
|
final settings = ref.read(engineSettingsWithDefaultsProvider);
|
|
|
|
// Manual adjustment is a no-op while automatic font sizing is enabled.
|
|
if (settings.automaticFontSizeAdjustment) return;
|
|
|
|
final current = settings.fontSizeFactor;
|
|
final newValue = increase
|
|
? (current + fontSizeStep).clamp(fontSizeMin, fontSizeMax)
|
|
: (current - fontSizeStep).clamp(fontSizeMin, fontSizeMax);
|
|
final rounded = (newValue * 10).round() / 10;
|
|
if (rounded == current) return;
|
|
|
|
await ref
|
|
.read(saveEngineSettingsControllerProvider.notifier)
|
|
.save(
|
|
(currentSettings) => currentSettings.copyWith.fontSizeFactor(rounded),
|
|
);
|
|
}
|
|
|
|
Future<void> _openNewTab(String tabId) async {
|
|
final router = await ref.read(routerProvider.future);
|
|
if (!ref.mounted) return;
|
|
|
|
final settings = ref.read(generalSettingsWithDefaultsProvider);
|
|
final selectedTabType = ref
|
|
.read(tabStatesProvider)[tabId]
|
|
?.tabMode
|
|
.toTabType();
|
|
|
|
final route = SearchRoute(
|
|
tabType: selectedTabType ?? settings.effectiveDefaultCreateTabType,
|
|
);
|
|
await router.push(route.location);
|
|
}
|
|
}
|
|
|
|
/// Whether gestures are currently disabled because the selected tab's site is
|
|
/// on the user's exclusion list.
|
|
@Riverpod(keepAlive: true)
|
|
bool gestureSiteExcluded(Ref ref) {
|
|
final excludedSites = ref.watch(
|
|
gestureSettingsWithDefaultsProvider.select((s) => s.excludedSites),
|
|
);
|
|
if (excludedSites.isEmpty) return false;
|
|
|
|
final tabId = ref.watch(selectedTabProvider);
|
|
if (tabId == null) return false;
|
|
|
|
final url = ref.watch(tabStateProvider(tabId).select((state) => state?.url));
|
|
if (url == null) return false;
|
|
|
|
return isGestureSiteExcluded(url, excludedSites);
|
|
}
|
|
|
|
/// The effective native recognizer configuration: the user's settings with the
|
|
/// recognizer disabled while the current site is excluded.
|
|
@Riverpod(keepAlive: true)
|
|
GestureConfig gestureNativeConfig(Ref ref) {
|
|
final settings = ref.watch(gestureSettingsWithDefaultsProvider);
|
|
final excluded = ref.watch(gestureSiteExcludedProvider);
|
|
|
|
// Recognize as many fingers as any bound gesture requires, so multi-finger
|
|
// bindings work without a separate finger setting.
|
|
final requiredFingers = settings.bindings.keys
|
|
.map((key) => GestureStroke.fromKey(key).fingers)
|
|
.fold(settings.maxFingers, max);
|
|
|
|
return GestureConfig(
|
|
enabled: settings.effectiveEnabled && !excluded,
|
|
strokeSize: settings.strokeSize,
|
|
timeoutMs: settings.timeoutMs,
|
|
maxFingers: requiredFingers,
|
|
minStrokeIntervalMs: settings.minStrokeIntervalMs,
|
|
activeGestureKeys: settings.bindings.keys.toList(),
|
|
);
|
|
}
|
|
|
|
/// The in-progress stroke shown by the live feedback overlay.
|
|
///
|
|
/// Emits the current partial canonical key (e.g. `R:D`) while a stroke is being
|
|
/// drawn, and null when nothing should be shown — driven by the native gesture
|
|
/// progress/reset events.
|
|
@riverpod
|
|
Stream<String?> gestureProgress(Ref ref) {
|
|
return ref.watch(gestureServiceProvider).gestureProgress;
|
|
}
|