diff --git a/apps/weblibre/lib/core/routing/routes.dart b/apps/weblibre/lib/core/routing/routes.dart index 9afb1110..51c1e424 100644 --- a/apps/weblibre/lib/core/routing/routes.dart +++ b/apps/weblibre/lib/core/routing/routes.dart @@ -60,6 +60,7 @@ import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/c import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_edit.dart'; import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_list.dart'; import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_selection.dart'; +import 'package:weblibre/features/gestures/presentation/screens/gesture_settings_screen.dart'; import 'package:weblibre/features/onboarding/presentation/onboarding.dart'; import 'package:weblibre/features/proxy/data/models/proxy_profile_seed.dart'; import 'package:weblibre/features/proxy/presentation/screens/proxy_routing_settings.dart'; diff --git a/apps/weblibre/lib/core/routing/routes.g.dart b/apps/weblibre/lib/core/routing/routes.g.dart index dda85d12..2a6db346 100644 --- a/apps/weblibre/lib/core/routing/routes.g.dart +++ b/apps/weblibre/lib/core/routing/routes.g.dart @@ -1552,6 +1552,11 @@ RouteBase get $settingsRoute => GoRouteData.$route( name: 'BrowsingSettingsRoute', factory: $BrowsingSettingsRoute._fromState, ), + GoRouteData.$route( + path: 'gestures', + name: 'GestureSettingsRoute', + factory: $GestureSettingsRoute._fromState, + ), GoRouteData.$route( path: 'privacy_security', name: 'PrivacySecuritySettingsRoute', @@ -1766,6 +1771,27 @@ mixin $BrowsingSettingsRoute on GoRouteData { void replace(BuildContext context) => context.replace(location); } +mixin $GestureSettingsRoute on GoRouteData { + static GestureSettingsRoute _fromState(GoRouterState state) => + GestureSettingsRoute(); + + @override + String get location => GoRouteData.$location('/settings/gestures'); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + mixin $PrivacySecuritySettingsRoute on GoRouteData { static PrivacySecuritySettingsRoute _fromState(GoRouterState state) => PrivacySecuritySettingsRoute(); diff --git a/apps/weblibre/lib/core/routing/routes.settings.dart b/apps/weblibre/lib/core/routing/routes.settings.dart index feac1531..1d9f767f 100644 --- a/apps/weblibre/lib/core/routing/routes.settings.dart +++ b/apps/weblibre/lib/core/routing/routes.settings.dart @@ -31,6 +31,10 @@ part of 'routes.dart'; name: 'BrowsingSettingsRoute', path: 'browsing', ), + TypedGoRoute( + name: 'GestureSettingsRoute', + path: 'gestures', + ), TypedGoRoute( name: 'PrivacySecuritySettingsRoute', path: 'privacy_security', @@ -159,6 +163,13 @@ class BrowsingSettingsRoute extends GoRouteData with $BrowsingSettingsRoute { } } +class GestureSettingsRoute extends GoRouteData with $GestureSettingsRoute { + @override + Widget build(BuildContext context, GoRouterState state) { + return const GestureSettingsScreen(); + } +} + class PrivacySecuritySettingsRoute extends GoRouteData with $PrivacySecuritySettingsRoute { @override diff --git a/apps/weblibre/lib/features/geckoview/domain/providers.dart b/apps/weblibre/lib/features/geckoview/domain/providers.dart index e62c3a0b..3fac77da 100644 --- a/apps/weblibre/lib/features/geckoview/domain/providers.dart +++ b/apps/weblibre/lib/features/geckoview/domain/providers.dart @@ -223,6 +223,18 @@ GeckoViewportService viewportService(Ref ref) { return service; } +@Riverpod(keepAlive: true) +GeckoGestureService gestureService(Ref ref) { + final service = GeckoGestureService(); + service.setUp(); + + ref.onDispose(() async { + await service.dispose(); + }); + + return service; +} + @Riverpod(keepAlive: true) class EngineReadyState extends _$EngineReadyState { Future waitUntilReady({ diff --git a/apps/weblibre/lib/features/geckoview/domain/providers.g.dart b/apps/weblibre/lib/features/geckoview/domain/providers.g.dart index 2be6e07c..3dcb15b9 100644 --- a/apps/weblibre/lib/features/geckoview/domain/providers.g.dart +++ b/apps/weblibre/lib/features/geckoview/domain/providers.g.dart @@ -293,6 +293,53 @@ final class ViewportServiceProvider String _$viewportServiceHash() => r'bab39db3180bb6a1cf8c055966b7f5910b41b424'; +@ProviderFor(gestureService) +final gestureServiceProvider = GestureServiceProvider._(); + +final class GestureServiceProvider + extends + $FunctionalProvider< + GeckoGestureService, + GeckoGestureService, + GeckoGestureService + > + with $Provider { + GestureServiceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'gestureServiceProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$gestureServiceHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + GeckoGestureService create(Ref ref) { + return gestureService(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(GeckoGestureService value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$gestureServiceHash() => r'81e90a1adb64f596190b52aba0e2ab9dcab3c5e7'; + @ProviderFor(EngineReadyState) final engineReadyStateProvider = EngineReadyStateProvider._(); diff --git a/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_id.dart b/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_id.dart index 27341ed5..6c8e7627 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_id.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_id.dart @@ -43,5 +43,6 @@ enum ToolbarButtonId { pageDown, font, extensionShortcut, + toggleGestures, quit, } diff --git a/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_spec.dart b/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_spec.dart index 4535cb09..2595bddc 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_spec.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_spec.dart @@ -169,6 +169,11 @@ const quitToolbarButtonSpec = ToolbarButtonSpec( canBeFallbackTarget: false, ); +const toggleGesturesToolbarButtonSpec = ToolbarButtonSpec( + id: ToolbarButtonId.toggleGestures, + defaultVisible: false, +); + const toolbarButtonSpecs = [ backToolbarButtonSpec, forwardToolbarButtonSpec, @@ -194,6 +199,7 @@ const toolbarButtonSpecs = [ pageDownToolbarButtonSpec, fontToolbarButtonSpec, extensionShortcutToolbarButtonSpec, + toggleGesturesToolbarButtonSpec, quitToolbarButtonSpec, ]; diff --git a/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/toolbar_button_registry.dart b/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/toolbar_button_registry.dart index a1dddc18..3d90b574 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/toolbar_button_registry.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/presentation/toolbar_button_registry.dart @@ -47,6 +47,8 @@ import 'package:weblibre/features/geckoview/features/find_in_page/presentation/c import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; +import 'package:weblibre/features/gestures/data/models/gesture_settings.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'; @@ -399,6 +401,34 @@ final List toolbarButtonRegistry = [ ); }, ), + ToolbarButtonDefinition( + spec: toggleGesturesToolbarButtonSpec, + label: 'Gestures', + icon: MdiIcons.gestureSwipe, + builder: (scope, context, ref) { + final on = ref.watch( + gestureSettingsWithDefaultsProvider.select((s) => s.effectiveEnabled), + ); + return IconButton( + isSelected: on, + tooltip: on ? 'Disable gestures' : 'Enable gestures', + onPressed: scope.isPreview + ? () {} + : () async { + final newActive = !on; + await ref + .read(gestureSettingsRepositoryProvider.notifier) + .updateSettings( + (s) => s.copyWith( + enabled: s.enabled || newActive, + active: newActive, + ), + ); + }, + icon: Icon(on ? MdiIcons.gestureSwipe : MdiIcons.gestureDoubleTap), + ); + }, + ), ToolbarButtonDefinition( spec: pageUpToolbarButtonSpec, label: 'Page Up', diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart index 6015e0cc..ab74801e 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart @@ -69,6 +69,8 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart' import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/top_sites/domain/repositories/top_site_repository.dart'; +import 'package:weblibre/features/gestures/data/models/gesture_settings.dart'; +import 'package:weblibre/features/gestures/domain/repositories/gesture_settings.dart'; import 'package:weblibre/features/proxy/data/models/singbox_proxy_profile.dart'; import 'package:weblibre/features/proxy/domain/providers/assigned_proxy_profiles.dart'; import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart'; @@ -173,6 +175,16 @@ class _BrowserMenuSheet extends HookConsumerWidget { _ProfileCard(), const SizedBox(height: 16), + // Gestures quick toggle (only when the master switch is on) + if (ref.watch( + gestureSettingsWithDefaultsProvider.select( + (s) => s.enabled, + ), + )) ...[ + const _GestureToggleTile(), + const SizedBox(height: 16), + ], + // App const _SettingsCard(), const SizedBox(height: 24), @@ -930,6 +942,36 @@ class _FetchFeedsTile extends HookConsumerWidget { } } +// ─── Gestures Quick Toggle ─── + +class _GestureToggleTile extends ConsumerWidget { + const _GestureToggleTile(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final active = ref.watch( + gestureSettingsWithDefaultsProvider.select((s) => s.active), + ); + + return _buildMenuCard( + context, + children: [ + SwitchListTile.adaptive( + secondary: const Icon(MdiIcons.gestureSwipe), + title: const Text('Gestures'), + subtitle: Text(active ? 'Active' : 'Suspended'), + value: active, + onChanged: (value) async { + await ref + .read(gestureSettingsRepositoryProvider.notifier) + .updateSettings((s) => s.copyWith(active: value)); + }, + ), + ], + ); + } +} + // ─── Tab Actions Card ─── class _TabActionsCard extends HookConsumerWidget { diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart index 7b0f0593..68ea7291 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart @@ -55,6 +55,8 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selec import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/services/local_index_pruner.dart'; +import 'package:weblibre/features/gestures/domain/services/gesture_control.dart'; +import 'package:weblibre/features/gestures/presentation/widgets/gesture_feedback_overlay.dart'; import 'package:weblibre/features/intent_gatekeeper/domain/entities/intent_source_policy.dart'; import 'package:weblibre/features/intent_gatekeeper/domain/entities/pending_intent_decision.dart'; import 'package:weblibre/features/intent_gatekeeper/domain/services/intent_gatekeeper.dart'; @@ -304,6 +306,7 @@ class _BrowserViewState extends ConsumerState ), ), if (showHome) const Positioned.fill(child: BrowserHome()), + const Positioned.fill(child: GestureFeedbackOverlay()), ], ), ); @@ -610,6 +613,19 @@ class _BrowserViewState extends ConsumerState }, ); + ref.listenManual( + fireImmediately: true, + gestureControlServiceProvider, + (previous, next) {}, + onError: (error, stackTrace) { + logger.e( + 'Error listening to gestureControlServiceProvider', + error: error, + stackTrace: stackTrace, + ); + }, + ); + ref.listenManual( fireImmediately: true, proxySettingsReplicationProvider, diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/gecko_inference.g.dart b/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/gecko_inference.g.dart index 8c752c7a..c8a99285 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/gecko_inference.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/gecko_inference.g.dart @@ -42,7 +42,7 @@ final class GeckoInferenceRepositoryProvider } String _$geckoInferenceRepositoryHash() => - r'c4f52d2edfb8c5763577a17697202016645177da'; + r'cd6d47ccb5aa8d64aba81ac1d982b1df8cf64d14'; abstract class _$GeckoInferenceRepository extends $Notifier { void build(); @@ -389,7 +389,7 @@ final class ContainerTabSuggestionsProvider } String _$containerTabSuggestionsHash() => - r'958c7432aa5c2a2828fcccd0127e0110d0a008cf'; + r'a29a43773000b415f3e8a23db281078c30c3b10d'; final class ContainerTabSuggestionsFamily extends $Family with $FunctionalFamilyOverride?>, String?> { diff --git a/apps/weblibre/lib/features/gestures/data/models/gesture_action.dart b/apps/weblibre/lib/features/gestures/data/models/gesture_action.dart new file mode 100644 index 00000000..aa7ce5bb --- /dev/null +++ b/apps/weblibre/lib/features/gestures/data/models/gesture_action.dart @@ -0,0 +1,214 @@ +/* + * 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 . + */ +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; + +/// Actions that can be bound to a touch gesture. +/// +/// Each value carries a human-readable [title]/[description] for the settings +/// UI and an [icon] mirroring the action's representation elsewhere in the app +/// (contextual toolbar, browser menu sheet). The dispatcher resolves each value +/// against the currently selected tab. +enum GestureAction { + // Navigation + back( + 'Back', + 'Go back in history', + Icons.arrow_back, + GestureActionCategory.navigation, + ), + forward( + 'Forward', + 'Go forward in history', + Icons.arrow_forward, + GestureActionCategory.navigation, + ), + reload( + 'Reload', + 'Reload the current page', + Icons.refresh, + GestureActionCategory.navigation, + ), + + // Scrolling + scrollTop( + 'Scroll to Top', + 'Jump to the top of the page', + Icons.vertical_align_top, + GestureActionCategory.scrolling, + ), + scrollBottom( + 'Scroll to Bottom', + 'Jump to the bottom of the page', + Icons.vertical_align_bottom, + GestureActionCategory.scrolling, + ), + pageUp( + 'Page Up', + 'Scroll up by one screen', + MdiIcons.chevronDoubleUp, + GestureActionCategory.scrolling, + ), + pageDown( + 'Page Down', + 'Scroll down by one screen', + MdiIcons.chevronDoubleDown, + GestureActionCategory.scrolling, + ), + + // Tabs + newTab( + 'New Tab', + 'Open a new tab', + MdiIcons.tabPlus, + GestureActionCategory.tabs, + ), + closeTab( + 'Close Tab', + 'Close the current tab', + MdiIcons.tabMinus, + GestureActionCategory.tabs, + ), + duplicateTab( + 'Duplicate Tab', + 'Open a copy of the current tab', + MdiIcons.contentDuplicate, + GestureActionCategory.tabs, + ), + nextTab( + 'Next Tab', + 'Switch to the next tab', + Icons.skip_next, + GestureActionCategory.tabs, + ), + previousTab( + 'Previous Tab', + 'Switch to the previous tab', + Icons.skip_previous, + GestureActionCategory.tabs, + ), + lastUsedTab( + 'Last Used Tab', + 'Switch to the previously used tab', + Icons.swap_horiz, + GestureActionCategory.tabs, + ), + togglePinTab( + 'Pin / Unpin Tab', + 'Toggle the pinned state of the current tab', + MdiIcons.pin, + GestureActionCategory.tabs, + ), + + // Page tools + toggleReaderMode( + 'Reader Mode', + 'Toggle reader mode for the current page', + MdiIcons.bookOpenOutline, + GestureActionCategory.page, + ), + toggleDesktopMode( + 'Desktop Site', + 'Toggle desktop site for the current page', + Icons.desktop_windows, + GestureActionCategory.page, + ), + findInPage( + 'Find in Page', + 'Open find in page', + Icons.find_in_page, + GestureActionCategory.page, + ), + increaseFontSize( + 'Increase Font', + 'Increase the page font size', + MdiIcons.formatFontSizeIncrease, + GestureActionCategory.page, + ), + decreaseFontSize( + 'Decrease Font', + 'Decrease the page font size', + MdiIcons.formatFontSizeDecrease, + GestureActionCategory.page, + ), + toggleBookmark( + 'Bookmark', + 'Bookmark or unbookmark the current page', + Icons.bookmark_border, + GestureActionCategory.page, + ), + translatePage( + 'Translate', + 'Open the page translation sheet', + Icons.translate, + GestureActionCategory.page, + ), + + // Open + showHistory( + 'History', + 'Open browsing history', + Icons.history, + GestureActionCategory.open, + ), + showBookmarks( + 'Bookmarks', + 'Open bookmarks', + MdiIcons.bookmarkMultiple, + GestureActionCategory.open, + ), + + // App + moveToBackground( + 'Minimize', + 'Send WebLibre to the background', + MdiIcons.arrowCollapseDown, + GestureActionCategory.app, + ), + quitBrowser( + 'Quit', + 'Close all tabs and quit WebLibre', + MdiIcons.power, + GestureActionCategory.app, + ); + + final String title; + final String description; + final IconData icon; + + /// Grouping used to organise actions in the bindings list and picker. + final GestureActionCategory category; + + const GestureAction(this.title, this.description, this.icon, this.category); +} + +/// High-level grouping of [GestureAction]s for the settings UI. +enum GestureActionCategory { + navigation('Navigation'), + scrolling('Scrolling'), + tabs('Tabs'), + page('Page'), + open('Open'), + app('App'); + + final String label; + + const GestureActionCategory(this.label); +} diff --git a/apps/weblibre/lib/features/gestures/data/models/gesture_settings.dart b/apps/weblibre/lib/features/gestures/data/models/gesture_settings.dart new file mode 100644 index 00000000..832a0eb5 --- /dev/null +++ b/apps/weblibre/lib/features/gestures/data/models/gesture_settings.dart @@ -0,0 +1,196 @@ +/* + * 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 . + */ +import 'package:copy_with_extension/copy_with_extension.dart'; +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:weblibre/features/gestures/data/models/gesture_action.dart'; +import 'package:weblibre/utils/uri_input_parser.dart'; + +part 'gesture_settings.g.dart'; + +const defaultGestureStrokeSize = 50; +const minGestureStrokeSize = 20; +const maxGestureStrokeSize = 100; + +const defaultGestureTimeoutMs = 1500; +const minGestureTimeoutMs = 500; +const maxGestureTimeoutMs = 3000; + +const defaultGestureMaxFingers = 1; + +const defaultGestureIntervalMs = 0; +const minGestureIntervalMs = 0; +const maxGestureIntervalMs = 2000; + +/// Minimum number of strokes drawn before the live overlay starts suggesting +/// the other possible completions (mirrors the reference add-on's +/// `toastMinStroke`). +const defaultGestureMinSuggestionStroke = 2; +const minGestureMinSuggestionStroke = 1; +const maxGestureMinSuggestionStroke = 5; + +/// Default gesture-to-action bindings, aligned with the reference add-on's +/// defaults for the actions WebLibre currently supports. +const defaultGestureBindings = { + 'D-L': GestureAction.forward, + 'D-R': GestureAction.back, + 'R-D': GestureAction.scrollTop, + 'R-U': GestureAction.scrollBottom, + 'D-R-U': GestureAction.reload, + 'L-D-R': GestureAction.closeTab, +}; + +@CopyWith() +@JsonSerializable(includeIfNull: true, constructor: 'withDefaults') +class GestureSettings with FastEquatable { + /// Master switch. When false, gesture recognition is fully disabled and the + /// quick toggles ([active]) have no effect. + final bool enabled; + + /// Runtime toggle exposed via the quick toggles (menu sheet tile, contextual + /// toolbar button). Lets the user suspend gestures without touching the + /// master switch. The recognizer runs only when [enabled] && [active]. + final bool active; + + /// Base stroke length in logical pixels (scaled to the screen by native). + final int strokeSize; + + /// Milliseconds of inactivity after which an in-progress gesture is dropped. + final int timeoutMs; + + /// Maximum simultaneous pointers a gesture may use. + final int maxFingers; + + /// Cooldown in milliseconds after a gesture fires, during which further + /// gestures are ignored. 0 disables the cooldown. + final int intervalMs; + + /// Whether to show the live feedback overlay while a stroke is being drawn + /// (the in-progress arrows plus the matching/possible actions). + final bool showFeedback; + + /// Within the live overlay, also suggest the other possible completions once + /// at least [minSuggestionStroke] strokes have been drawn. + final bool suggestNext; + + /// Minimum strokes drawn before [suggestNext] kicks in. + final int minSuggestionStroke; + + /// Hosts on which gestures are disabled. A page is excluded when its host + /// equals or is a subdomain of any entry (see `isGestureSiteExcluded`). + final List excludedSites; + + /// Canonical gesture key → action. Keys follow the grammar documented on + /// [GestureStroke]. + final Map bindings; + + GestureSettings({ + required this.enabled, + required this.active, + required this.strokeSize, + required this.timeoutMs, + required this.maxFingers, + required this.intervalMs, + required this.showFeedback, + required this.suggestNext, + required this.minSuggestionStroke, + required this.excludedSites, + required this.bindings, + }); + + GestureSettings.withDefaults({ + bool? enabled, + bool? active, + int? strokeSize, + int? timeoutMs, + int? maxFingers, + int? intervalMs, + bool? showFeedback, + bool? suggestNext, + int? minSuggestionStroke, + List? excludedSites, + Map? bindings, + }) : enabled = enabled ?? false, + active = active ?? true, + strokeSize = strokeSize ?? defaultGestureStrokeSize, + timeoutMs = timeoutMs ?? defaultGestureTimeoutMs, + maxFingers = maxFingers ?? defaultGestureMaxFingers, + intervalMs = intervalMs ?? defaultGestureIntervalMs, + showFeedback = showFeedback ?? true, + suggestNext = suggestNext ?? true, + minSuggestionStroke = + minSuggestionStroke ?? defaultGestureMinSuggestionStroke, + excludedSites = excludedSites ?? const [], + bindings = bindings ?? defaultGestureBindings; + + /// Whether the recognizer should actually run. + bool get effectiveEnabled => enabled && active; + + factory GestureSettings.fromJson(Map json) => + _$GestureSettingsFromJson(json); + + Map toJson() => _$GestureSettingsToJson(this); + + @override + List get hashParameters => [ + enabled, + active, + strokeSize, + timeoutMs, + maxFingers, + intervalMs, + showFeedback, + suggestNext, + minSuggestionStroke, + excludedSites, + bindings, + ]; +} + +/// Normalises a user-entered site into a bare lowercase host, e.g. +/// `https://News.example.com/foo` → `news.example.com`. Accepts either a full +/// URL or a bare host, and validates the result with the same rules the address +/// bar uses ([isValidHostCandidate]). Returns null for input without a valid +/// host. +String? normalizeGestureSiteHost(String input) { + final trimmed = input.trim().toLowerCase(); + if (trimmed.isEmpty) return null; + + final candidate = trimmed.contains('://') ? trimmed : 'https://$trimmed'; + final host = Uri.tryParse(candidate)?.host; + if (host == null || host.isEmpty) return null; + + return isValidHostCandidate(host) ? host : null; +} + +/// Whether [url] is covered by any entry in [excludedSites]. An entry matches +/// the URL's host exactly or as a parent domain (so `example.com` also covers +/// `m.example.com`). +bool isGestureSiteExcluded(Uri url, List excludedSites) { + final host = url.host.toLowerCase(); + if (host.isEmpty) return false; + + for (final entry in excludedSites) { + final pattern = entry.toLowerCase(); + if (pattern.isEmpty) continue; + if (host == pattern || host.endsWith('.$pattern')) return true; + } + return false; +} diff --git a/apps/weblibre/lib/features/gestures/data/models/gesture_settings.g.dart b/apps/weblibre/lib/features/gestures/data/models/gesture_settings.g.dart new file mode 100644 index 00000000..d7168a6b --- /dev/null +++ b/apps/weblibre/lib/features/gestures/data/models/gesture_settings.g.dart @@ -0,0 +1,250 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'gesture_settings.dart'; + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class _$GestureSettingsCWProxy { + GestureSettings enabled(bool enabled); + + GestureSettings active(bool active); + + GestureSettings strokeSize(int strokeSize); + + GestureSettings timeoutMs(int timeoutMs); + + GestureSettings maxFingers(int maxFingers); + + GestureSettings intervalMs(int intervalMs); + + GestureSettings showFeedback(bool showFeedback); + + GestureSettings suggestNext(bool suggestNext); + + GestureSettings minSuggestionStroke(int minSuggestionStroke); + + GestureSettings excludedSites(List excludedSites); + + GestureSettings bindings(Map bindings); + + /// Creates a new instance with the provided field values. + /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GestureSettings(...).copyWith.fieldName(value)`. + /// + /// Example: + /// ```dart + /// GestureSettings(...).copyWith(id: 12, name: "My name") + /// ``` + GestureSettings call({ + bool enabled, + bool active, + int strokeSize, + int timeoutMs, + int maxFingers, + int intervalMs, + bool showFeedback, + bool suggestNext, + int minSuggestionStroke, + List excludedSites, + Map bindings, + }); +} + +/// Callable proxy for `copyWith` functionality. +/// Use as `instanceOfGestureSettings.copyWith(...)` or call `instanceOfGestureSettings.copyWith.fieldName(value)` for a single field. +class _$GestureSettingsCWProxyImpl implements _$GestureSettingsCWProxy { + const _$GestureSettingsCWProxyImpl(this._value); + + final GestureSettings _value; + + @override + GestureSettings enabled(bool enabled) => call(enabled: enabled); + + @override + GestureSettings active(bool active) => call(active: active); + + @override + GestureSettings strokeSize(int strokeSize) => call(strokeSize: strokeSize); + + @override + GestureSettings timeoutMs(int timeoutMs) => call(timeoutMs: timeoutMs); + + @override + GestureSettings maxFingers(int maxFingers) => call(maxFingers: maxFingers); + + @override + GestureSettings intervalMs(int intervalMs) => call(intervalMs: intervalMs); + + @override + GestureSettings showFeedback(bool showFeedback) => + call(showFeedback: showFeedback); + + @override + GestureSettings suggestNext(bool suggestNext) => + call(suggestNext: suggestNext); + + @override + GestureSettings minSuggestionStroke(int minSuggestionStroke) => + call(minSuggestionStroke: minSuggestionStroke); + + @override + GestureSettings excludedSites(List excludedSites) => + call(excludedSites: excludedSites); + + @override + GestureSettings bindings(Map bindings) => + call(bindings: bindings); + + @override + /// Creates a new instance with the provided field values. + /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GestureSettings(...).copyWith.fieldName(value)`. + /// + /// Example: + /// ```dart + /// GestureSettings(...).copyWith(id: 12, name: "My name") + /// ``` + GestureSettings call({ + Object? enabled = const $CopyWithPlaceholder(), + Object? active = const $CopyWithPlaceholder(), + Object? strokeSize = const $CopyWithPlaceholder(), + Object? timeoutMs = const $CopyWithPlaceholder(), + Object? maxFingers = const $CopyWithPlaceholder(), + Object? intervalMs = const $CopyWithPlaceholder(), + Object? showFeedback = const $CopyWithPlaceholder(), + Object? suggestNext = const $CopyWithPlaceholder(), + Object? minSuggestionStroke = const $CopyWithPlaceholder(), + Object? excludedSites = const $CopyWithPlaceholder(), + Object? bindings = const $CopyWithPlaceholder(), + }) { + return GestureSettings( + enabled: enabled == const $CopyWithPlaceholder() || enabled == null + ? _value.enabled + // ignore: cast_nullable_to_non_nullable + : enabled as bool, + active: active == const $CopyWithPlaceholder() || active == null + ? _value.active + // ignore: cast_nullable_to_non_nullable + : active as bool, + strokeSize: + strokeSize == const $CopyWithPlaceholder() || strokeSize == null + ? _value.strokeSize + // ignore: cast_nullable_to_non_nullable + : strokeSize as int, + timeoutMs: timeoutMs == const $CopyWithPlaceholder() || timeoutMs == null + ? _value.timeoutMs + // ignore: cast_nullable_to_non_nullable + : timeoutMs as int, + maxFingers: + maxFingers == const $CopyWithPlaceholder() || maxFingers == null + ? _value.maxFingers + // ignore: cast_nullable_to_non_nullable + : maxFingers as int, + intervalMs: + intervalMs == const $CopyWithPlaceholder() || intervalMs == null + ? _value.intervalMs + // ignore: cast_nullable_to_non_nullable + : intervalMs as int, + showFeedback: + showFeedback == const $CopyWithPlaceholder() || showFeedback == null + ? _value.showFeedback + // ignore: cast_nullable_to_non_nullable + : showFeedback as bool, + suggestNext: + suggestNext == const $CopyWithPlaceholder() || suggestNext == null + ? _value.suggestNext + // ignore: cast_nullable_to_non_nullable + : suggestNext as bool, + minSuggestionStroke: + minSuggestionStroke == const $CopyWithPlaceholder() || + minSuggestionStroke == null + ? _value.minSuggestionStroke + // ignore: cast_nullable_to_non_nullable + : minSuggestionStroke as int, + excludedSites: + excludedSites == const $CopyWithPlaceholder() || excludedSites == null + ? _value.excludedSites + // ignore: cast_nullable_to_non_nullable + : excludedSites as List, + bindings: bindings == const $CopyWithPlaceholder() || bindings == null + ? _value.bindings + // ignore: cast_nullable_to_non_nullable + : bindings as Map, + ); + } +} + +extension $GestureSettingsCopyWith on GestureSettings { + /// Returns a callable class used to build a new instance with modified fields. + /// Example: `instanceOfGestureSettings.copyWith(...)` or `instanceOfGestureSettings.copyWith.fieldName(...)`. + // ignore: library_private_types_in_public_api + _$GestureSettingsCWProxy get copyWith => _$GestureSettingsCWProxyImpl(this); +} + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +GestureSettings _$GestureSettingsFromJson(Map json) => + GestureSettings.withDefaults( + enabled: json['enabled'] as bool?, + active: json['active'] as bool?, + strokeSize: (json['strokeSize'] as num?)?.toInt(), + timeoutMs: (json['timeoutMs'] as num?)?.toInt(), + maxFingers: (json['maxFingers'] as num?)?.toInt(), + intervalMs: (json['intervalMs'] as num?)?.toInt(), + showFeedback: json['showFeedback'] as bool?, + suggestNext: json['suggestNext'] as bool?, + minSuggestionStroke: (json['minSuggestionStroke'] as num?)?.toInt(), + excludedSites: (json['excludedSites'] as List?) + ?.map((e) => e as String) + .toList(), + bindings: (json['bindings'] as Map?)?.map( + (k, e) => MapEntry(k, $enumDecode(_$GestureActionEnumMap, e)), + ), + ); + +Map _$GestureSettingsToJson(GestureSettings instance) => + { + 'enabled': instance.enabled, + 'active': instance.active, + 'strokeSize': instance.strokeSize, + 'timeoutMs': instance.timeoutMs, + 'maxFingers': instance.maxFingers, + 'intervalMs': instance.intervalMs, + 'showFeedback': instance.showFeedback, + 'suggestNext': instance.suggestNext, + 'minSuggestionStroke': instance.minSuggestionStroke, + 'excludedSites': instance.excludedSites, + 'bindings': instance.bindings.map( + (k, e) => MapEntry(k, _$GestureActionEnumMap[e]!), + ), + }; + +const _$GestureActionEnumMap = { + GestureAction.back: 'back', + GestureAction.forward: 'forward', + GestureAction.reload: 'reload', + GestureAction.scrollTop: 'scrollTop', + GestureAction.scrollBottom: 'scrollBottom', + GestureAction.pageUp: 'pageUp', + GestureAction.pageDown: 'pageDown', + GestureAction.newTab: 'newTab', + GestureAction.closeTab: 'closeTab', + GestureAction.duplicateTab: 'duplicateTab', + GestureAction.nextTab: 'nextTab', + GestureAction.previousTab: 'previousTab', + GestureAction.lastUsedTab: 'lastUsedTab', + GestureAction.togglePinTab: 'togglePinTab', + GestureAction.toggleReaderMode: 'toggleReaderMode', + GestureAction.toggleDesktopMode: 'toggleDesktopMode', + GestureAction.findInPage: 'findInPage', + GestureAction.increaseFontSize: 'increaseFontSize', + GestureAction.decreaseFontSize: 'decreaseFontSize', + GestureAction.toggleBookmark: 'toggleBookmark', + GestureAction.translatePage: 'translatePage', + GestureAction.showHistory: 'showHistory', + GestureAction.showBookmarks: 'showBookmarks', + GestureAction.moveToBackground: 'moveToBackground', + GestureAction.quitBrowser: 'quitBrowser', +}; diff --git a/apps/weblibre/lib/features/gestures/data/models/gesture_stroke.dart b/apps/weblibre/lib/features/gestures/data/models/gesture_stroke.dart new file mode 100644 index 00000000..1a459814 --- /dev/null +++ b/apps/weblibre/lib/features/gestures/data/models/gesture_stroke.dart @@ -0,0 +1,144 @@ +/* + * 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 . + */ +import 'package:copy_with_extension/copy_with_extension.dart'; +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; + +part 'gesture_stroke.g.dart'; + +/// Where a gesture must begin. Mirrors the reference add-on's start-position +/// tokens (the trailing colon is part of the canonical key prefix). +enum GestureStartPosition { + anywhere('', 'Anywhere', MdiIcons.borderNone), + leftEdge('L:', 'Left edge', MdiIcons.borderLeft), + rightEdge('R:', 'Right edge', MdiIcons.borderRight), + topEdge('T:', 'Top edge', MdiIcons.borderTop), + bottomEdge('B:', 'Bottom edge', MdiIcons.borderBottom), + leftHalf('W:', 'Left half', MdiIcons.borderLeftVariant), + rightHalf('E:', 'Right half', MdiIcons.borderRightVariant); + + /// Canonical key prefix, e.g. `R:` (empty for [anywhere]). + final String prefix; + final String label; + final IconData icon; + + const GestureStartPosition(this.prefix, this.label, this.icon); + + static GestureStartPosition fromPrefixLetter(String letter) { + return GestureStartPosition.values.firstWhere( + (position) => position.prefix == '$letter:', + orElse: () => GestureStartPosition.anywhere, + ); + } +} + +/// A single dominant swipe direction within a gesture. +enum GestureArrow { + up('U', '↑'), + down('D', '↓'), + left('L', '←'), + right('R', '→'); + + /// Canonical key token, e.g. `D`. + final String token; + + /// Compact glyph for rendering a stroke sequence. + final String symbol; + + const GestureArrow(this.token, this.symbol); + + static GestureArrow fromToken(String token) { + return GestureArrow.values.firstWhere((arrow) => arrow.token == token); + } +} + +/// A configurable gesture: an ordered sequence of swipe directions, optionally +/// constrained by where the touch begins and how many fingers are used. +/// +/// The canonical [key] is the on-the-wire identifier shared with the native +/// recognizer: ``, e.g. +/// `R:2:D-L`. The finger prefix is omitted for a single finger and the start +/// prefix is omitted for [GestureStartPosition.anywhere]. +@CopyWith() +class GestureStroke with FastEquatable { + final GestureStartPosition startPosition; + final int fingers; + final List arrows; + + GestureStroke({ + this.startPosition = GestureStartPosition.anywhere, + this.fingers = 1, + this.arrows = const [], + }); + + /// The canonical gesture key (see class docs). + String get key { + final fingerPrefix = fingers >= 2 ? '$fingers:' : ''; + final arrowPart = arrows.map((arrow) => arrow.token).join('-'); + return '${startPosition.prefix}$fingerPrefix$arrowPart'; + } + + /// Parses a canonical [key] back into a stroke. + /// + /// The arrow sequence is always the final colon-separated segment; preceding + /// segments are either a single start-position letter or a finger count. + factory GestureStroke.fromKey(String key) { + final segments = key.split(':'); + final arrowPart = segments.removeLast(); + + var startPosition = GestureStartPosition.anywhere; + var fingers = 1; + for (final segment in segments) { + final asFingers = int.tryParse(segment); + if (asFingers != null) { + fingers = asFingers; + } else if (segment.isNotEmpty) { + startPosition = GestureStartPosition.fromPrefixLetter(segment); + } + } + + final arrows = arrowPart.isEmpty + ? [] + : arrowPart.split('-').map(GestureArrow.fromToken).toList(); + + return GestureStroke( + startPosition: startPosition, + fingers: fingers, + arrows: arrows, + ); + } + + /// Whether this stroke is complete enough to be bound to an action. + bool get isValid => arrows.isNotEmpty; + + /// Human-readable rendering, e.g. `Right edge · ✌ · ↓→`. + String get displayLabel { + final parts = [ + if (startPosition != GestureStartPosition.anywhere) startPosition.label, + if (fingers >= 2) '$fingers fingers', + arrows.map((arrow) => arrow.symbol).join(), + ]; + return parts.join(' · '); + } + + @override + List get hashParameters => [startPosition, fingers, arrows]; +} diff --git a/apps/weblibre/lib/features/gestures/data/models/gesture_stroke.g.dart b/apps/weblibre/lib/features/gestures/data/models/gesture_stroke.g.dart new file mode 100644 index 00000000..b7f7f20f --- /dev/null +++ b/apps/weblibre/lib/features/gestures/data/models/gesture_stroke.g.dart @@ -0,0 +1,83 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'gesture_stroke.dart'; + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class _$GestureStrokeCWProxy { + GestureStroke startPosition(GestureStartPosition startPosition); + + GestureStroke fingers(int fingers); + + GestureStroke arrows(List arrows); + + /// Creates a new instance with the provided field values. + /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GestureStroke(...).copyWith.fieldName(value)`. + /// + /// Example: + /// ```dart + /// GestureStroke(...).copyWith(id: 12, name: "My name") + /// ``` + GestureStroke call({ + GestureStartPosition startPosition, + int fingers, + List arrows, + }); +} + +/// Callable proxy for `copyWith` functionality. +/// Use as `instanceOfGestureStroke.copyWith(...)` or call `instanceOfGestureStroke.copyWith.fieldName(value)` for a single field. +class _$GestureStrokeCWProxyImpl implements _$GestureStrokeCWProxy { + const _$GestureStrokeCWProxyImpl(this._value); + + final GestureStroke _value; + + @override + GestureStroke startPosition(GestureStartPosition startPosition) => + call(startPosition: startPosition); + + @override + GestureStroke fingers(int fingers) => call(fingers: fingers); + + @override + GestureStroke arrows(List arrows) => call(arrows: arrows); + + @override + /// Creates a new instance with the provided field values. + /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GestureStroke(...).copyWith.fieldName(value)`. + /// + /// Example: + /// ```dart + /// GestureStroke(...).copyWith(id: 12, name: "My name") + /// ``` + GestureStroke call({ + Object? startPosition = const $CopyWithPlaceholder(), + Object? fingers = const $CopyWithPlaceholder(), + Object? arrows = const $CopyWithPlaceholder(), + }) { + return GestureStroke( + startPosition: + startPosition == const $CopyWithPlaceholder() || startPosition == null + ? _value.startPosition + // ignore: cast_nullable_to_non_nullable + : startPosition as GestureStartPosition, + fingers: fingers == const $CopyWithPlaceholder() || fingers == null + ? _value.fingers + // ignore: cast_nullable_to_non_nullable + : fingers as int, + arrows: arrows == const $CopyWithPlaceholder() || arrows == null + ? _value.arrows + // ignore: cast_nullable_to_non_nullable + : arrows as List, + ); + } +} + +extension $GestureStrokeCopyWith on GestureStroke { + /// Returns a callable class used to build a new instance with modified fields. + /// Example: `instanceOfGestureStroke.copyWith(...)` or `instanceOfGestureStroke.copyWith.fieldName(...)`. + // ignore: library_private_types_in_public_api + _$GestureStrokeCWProxy get copyWith => _$GestureStrokeCWProxyImpl(this); +} diff --git a/apps/weblibre/lib/features/gestures/domain/repositories/gesture_settings.dart b/apps/weblibre/lib/features/gestures/domain/repositories/gesture_settings.dart new file mode 100644 index 00000000..e209ee59 --- /dev/null +++ b/apps/weblibre/lib/features/gestures/domain/repositories/gesture_settings.dart @@ -0,0 +1,135 @@ +/* + * 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 . + */ +import 'dart:async'; +import 'dart:convert'; + +import 'package:drift/drift.dart'; +import 'package:nullability/nullability.dart'; +import 'package:riverpod/riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/gestures/data/models/gesture_settings.dart'; +import 'package:weblibre/features/user/data/providers.dart'; + +part 'gesture_settings.g.dart'; + +typedef UpdateGestureSettingsFunc = + GestureSettings Function(GestureSettings currentSettings); + +@Riverpod(keepAlive: true) +class GestureSettingsRepository extends _$GestureSettingsRepository { + final _partitionKey = 'gesture'; + + GestureSettings _deserializeSettings( + List> entries, + ) { + final settings = Map.fromEntries(entries); + + final db = ref.read(userDatabaseProvider); + + return GestureSettings.fromJson({ + 'enabled': settings['enabled']?.readAs(DriftSqlType.bool, db.typeMapping), + 'active': settings['active']?.readAs(DriftSqlType.bool, db.typeMapping), + 'strokeSize': settings['strokeSize']?.readAs( + DriftSqlType.int, + db.typeMapping, + ), + 'timeoutMs': settings['timeoutMs']?.readAs( + DriftSqlType.int, + db.typeMapping, + ), + 'maxFingers': settings['maxFingers']?.readAs( + DriftSqlType.int, + db.typeMapping, + ), + 'intervalMs': settings['intervalMs']?.readAs( + DriftSqlType.int, + db.typeMapping, + ), + 'showFeedback': settings['showFeedback']?.readAs( + DriftSqlType.bool, + db.typeMapping, + ), + 'suggestNext': settings['suggestNext']?.readAs( + DriftSqlType.bool, + db.typeMapping, + ), + 'minSuggestionStroke': settings['minSuggestionStroke']?.readAs( + DriftSqlType.int, + db.typeMapping, + ), + 'excludedSites': settings['excludedSites'] + ?.readAs(DriftSqlType.string, db.typeMapping) + .mapNotNull(jsonDecode), + 'bindings': settings['bindings'] + ?.readAs(DriftSqlType.string, db.typeMapping) + .mapNotNull(jsonDecode), + }); + } + + //Eager fetch, when up to date settings are required + Future fetchSettings() { + return ref + .read(userDatabaseProvider) + .settingDao + .getAllSettingsOfPartitionKey(_partitionKey) + .get() + .then(_deserializeSettings); + } + + Future updateSettings( + UpdateGestureSettingsFunc updateWithCurrent, + ) async { + final db = ref.read(userDatabaseProvider); + + final current = await fetchSettings(); + + final oldJson = current.toJson(); + final newJson = updateWithCurrent(current).toJson(); + + return db.transaction(() async { + for (final MapEntry(:key, :value) in newJson.entries) { + if (oldJson[key] != value) { + await db.settingDao.updateSetting(key, _partitionKey, value); + } + } + }); + } + + @override + Stream build() { + final db = ref.watch(userDatabaseProvider); + + return db.settingDao + .getAllSettingsOfPartitionKey(_partitionKey) + .watch() + .map((event) { + return _deserializeSettings(event); + }); + } +} + +@Riverpod(keepAlive: true) +GestureSettings gestureSettingsWithDefaults(Ref ref) { + return ref.watch( + gestureSettingsRepositoryProvider.select( + (value) => value.value ?? GestureSettings.withDefaults(), + ), + ); +} diff --git a/apps/weblibre/lib/features/gestures/domain/repositories/gesture_settings.g.dart b/apps/weblibre/lib/features/gestures/domain/repositories/gesture_settings.g.dart new file mode 100644 index 00000000..61699cbe --- /dev/null +++ b/apps/weblibre/lib/features/gestures/domain/repositories/gesture_settings.g.dart @@ -0,0 +1,101 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'gesture_settings.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(GestureSettingsRepository) +final gestureSettingsRepositoryProvider = GestureSettingsRepositoryProvider._(); + +final class GestureSettingsRepositoryProvider + extends + $StreamNotifierProvider { + GestureSettingsRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'gestureSettingsRepositoryProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$gestureSettingsRepositoryHash(); + + @$internal + @override + GestureSettingsRepository create() => GestureSettingsRepository(); +} + +String _$gestureSettingsRepositoryHash() => + r'fc898b669a484fe45f89024d8456190f08d56085'; + +abstract class _$GestureSettingsRepository + extends $StreamNotifier { + Stream build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref, GestureSettings>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, GestureSettings>, + AsyncValue, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} + +@ProviderFor(gestureSettingsWithDefaults) +final gestureSettingsWithDefaultsProvider = + GestureSettingsWithDefaultsProvider._(); + +final class GestureSettingsWithDefaultsProvider + extends + $FunctionalProvider + with $Provider { + GestureSettingsWithDefaultsProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'gestureSettingsWithDefaultsProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$gestureSettingsWithDefaultsHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + GestureSettings create(Ref ref) { + return gestureSettingsWithDefaults(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(GestureSettings value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$gestureSettingsWithDefaultsHash() => + r'f9f242b81ef9d4d0594ae1700fa11db503583672'; diff --git a/apps/weblibre/lib/features/gestures/domain/services/gesture_control.dart b/apps/weblibre/lib/features/gestures/domain/services/gesture_control.dart new file mode 100644 index 00000000..29057346 --- /dev/null +++ b/apps/weblibre/lib/features/gestures/domain/services/gesture_control.dart @@ -0,0 +1,347 @@ +/* + * 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 . + */ +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 _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 _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 _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 _navigatorContext() async { + final router = await ref.read(routerProvider.future); + if (!ref.mounted) return null; + return router.routerDelegate.navigatorKey.currentContext; + } + + Future _pushLocation(String location) async { + final router = await ref.read(routerProvider.future); + if (!ref.mounted) return; + await router.push(location); + } + + Future _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 _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, + 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 gestureProgress(Ref ref) { + return ref.watch(gestureServiceProvider).gestureProgress; +} diff --git a/apps/weblibre/lib/features/gestures/domain/services/gesture_control.g.dart b/apps/weblibre/lib/features/gestures/domain/services/gesture_control.g.dart new file mode 100644 index 00000000..740281ec --- /dev/null +++ b/apps/weblibre/lib/features/gestures/domain/services/gesture_control.g.dart @@ -0,0 +1,246 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'gesture_control.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// 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. + +@ProviderFor(GestureControlService) +final gestureControlServiceProvider = GestureControlServiceProvider._(); + +/// 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. +final class GestureControlServiceProvider + extends $NotifierProvider { + /// 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. + GestureControlServiceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'gestureControlServiceProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$gestureControlServiceHash(); + + @$internal + @override + GestureControlService create() => GestureControlService(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(void value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$gestureControlServiceHash() => + r'12bd852a5b90b67bee4a94e7bd55fccc53c11bd4'; + +/// 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. + +abstract class _$GestureControlService extends $Notifier { + void build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + void, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} + +/// Whether gestures are currently disabled because the selected tab's site is +/// on the user's exclusion list. + +@ProviderFor(gestureSiteExcluded) +final gestureSiteExcludedProvider = GestureSiteExcludedProvider._(); + +/// Whether gestures are currently disabled because the selected tab's site is +/// on the user's exclusion list. + +final class GestureSiteExcludedProvider + extends $FunctionalProvider + with $Provider { + /// Whether gestures are currently disabled because the selected tab's site is + /// on the user's exclusion list. + GestureSiteExcludedProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'gestureSiteExcludedProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$gestureSiteExcludedHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + bool create(Ref ref) { + return gestureSiteExcluded(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(bool value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$gestureSiteExcludedHash() => + r'15dd371a176303a7c52c37df560c93bbb03c5039'; + +/// The effective native recognizer configuration: the user's settings with the +/// recognizer disabled while the current site is excluded. + +@ProviderFor(gestureNativeConfig) +final gestureNativeConfigProvider = GestureNativeConfigProvider._(); + +/// The effective native recognizer configuration: the user's settings with the +/// recognizer disabled while the current site is excluded. + +final class GestureNativeConfigProvider + extends $FunctionalProvider + with $Provider { + /// The effective native recognizer configuration: the user's settings with the + /// recognizer disabled while the current site is excluded. + GestureNativeConfigProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'gestureNativeConfigProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$gestureNativeConfigHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + GestureConfig create(Ref ref) { + return gestureNativeConfig(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(GestureConfig value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$gestureNativeConfigHash() => + r'57aada8631dfc3d8355f9498f6b7c6954b0cdcf1'; + +/// 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. + +@ProviderFor(gestureProgress) +final gestureProgressProvider = GestureProgressProvider._(); + +/// 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. + +final class GestureProgressProvider + extends $FunctionalProvider, String?, Stream> + with $FutureModifier, $StreamProvider { + /// 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. + GestureProgressProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'gestureProgressProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$gestureProgressHash(); + + @$internal + @override + $StreamProviderElement $createElement($ProviderPointer pointer) => + $StreamProviderElement(pointer); + + @override + Stream create(Ref ref) { + return gestureProgress(ref); + } +} + +String _$gestureProgressHash() => r'2f3ed4a8db41d317869db2e5aa71e737afb5364b'; diff --git a/apps/weblibre/lib/features/gestures/presentation/screens/gesture_behavior_screen.dart b/apps/weblibre/lib/features/gestures/presentation/screens/gesture_behavior_screen.dart new file mode 100644 index 00000000..14d5557c --- /dev/null +++ b/apps/weblibre/lib/features/gestures/presentation/screens/gesture_behavior_screen.dart @@ -0,0 +1,181 @@ +/* + * 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 . + */ +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/gestures/data/models/gesture_settings.dart'; +import 'package:weblibre/features/gestures/domain/repositories/gesture_settings.dart'; +import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart'; + +const List _behaviorSections = [ + SettingsSectionDefinition( + title: 'Strokes', + entries: [ + SettingsEntryDefinition( + title: 'Stroke sensitivity', + subtitle: 'Minimum swipe length recognised as a direction', + keywords: ['size', 'length'], + child: _StrokeSensitivitySection(), + ), + ], + ), + SettingsSectionDefinition( + title: 'Timing', + entries: [ + SettingsEntryDefinition( + title: 'Timeout', + subtitle: 'Drop a stroke if no new direction is drawn', + keywords: ['delay'], + child: _TimeoutSection(), + ), + SettingsEntryDefinition( + title: 'Cooldown', + subtitle: 'Minimum delay between two gestures firing', + keywords: ['interval'], + child: _CooldownSection(), + ), + ], + ), +]; + +/// Tuning for the gesture recognizer: stroke size, idle timeout and cooldown. +class GestureBehaviorScreen extends StatelessWidget { + const GestureBehaviorScreen({super.key}); + + @override + Widget build(BuildContext context) { + return const SettingsDetailScaffold( + title: 'Behavior & timing', + subtitle: 'Stroke sensitivity, timeout, and cooldown.', + icon: Icons.tune, + sections: _behaviorSections, + ); + } +} + +class _StrokeSensitivitySection extends HookConsumerWidget { + const _StrokeSensitivitySection(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch(gestureSettingsWithDefaultsProvider); + + return ListTile( + leading: const Icon(MdiIcons.gestureTap), + title: const Text('Stroke sensitivity'), + subtitle: Slider.adaptive( + min: minGestureStrokeSize.toDouble(), + max: maxGestureStrokeSize.toDouble(), + divisions: maxGestureStrokeSize - minGestureStrokeSize, + value: settings.strokeSize + .clamp(minGestureStrokeSize, maxGestureStrokeSize) + .toDouble(), + label: '${settings.strokeSize}', + onChanged: (value) async { + await ref + .read(gestureSettingsRepositoryProvider.notifier) + .updateSettings( + (current) => current.copyWith.strokeSize(value.round()), + ); + }, + ), + ); + } +} + +class _TimeoutSection extends HookConsumerWidget { + const _TimeoutSection(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch(gestureSettingsWithDefaultsProvider); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.hourglass_empty), + title: const Text('Timeout'), + subtitle: Slider.adaptive( + min: minGestureTimeoutMs.toDouble(), + max: maxGestureTimeoutMs.toDouble(), + divisions: (maxGestureTimeoutMs - minGestureTimeoutMs) ~/ 100, + value: settings.timeoutMs + .clamp(minGestureTimeoutMs, maxGestureTimeoutMs) + .toDouble(), + label: '${settings.timeoutMs} ms', + onChanged: (value) async { + await ref + .read(gestureSettingsRepositoryProvider.notifier) + .updateSettings( + (current) => current.copyWith.timeoutMs(value.round()), + ); + }, + ), + ), + const Padding( + padding: EdgeInsets.fromLTRB(72, 0, 16, 8), + child: Text( + 'A stroke is dropped if no new direction is drawn within this time.', + ), + ), + ], + ); + } +} + +class _CooldownSection extends HookConsumerWidget { + const _CooldownSection(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch(gestureSettingsWithDefaultsProvider); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.timer_outlined), + title: const Text('Cooldown'), + subtitle: Slider.adaptive( + min: minGestureIntervalMs.toDouble(), + max: maxGestureIntervalMs.toDouble(), + divisions: (maxGestureIntervalMs - minGestureIntervalMs) ~/ 100, + value: settings.intervalMs + .clamp(minGestureIntervalMs, maxGestureIntervalMs) + .toDouble(), + label: settings.intervalMs == 0 ? 'Off' : '${settings.intervalMs} ms', + onChanged: (value) async { + await ref + .read(gestureSettingsRepositoryProvider.notifier) + .updateSettings( + (current) => current.copyWith.intervalMs(value.round()), + ); + }, + ), + ), + const Padding( + padding: EdgeInsets.fromLTRB(72, 0, 16, 8), + child: Text('Minimum delay between two gestures firing.'), + ), + ], + ); + } +} diff --git a/apps/weblibre/lib/features/gestures/presentation/screens/gesture_bindings_screen.dart b/apps/weblibre/lib/features/gestures/presentation/screens/gesture_bindings_screen.dart new file mode 100644 index 00000000..5dd23025 --- /dev/null +++ b/apps/weblibre/lib/features/gestures/presentation/screens/gesture_bindings_screen.dart @@ -0,0 +1,214 @@ +/* + * 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 . + */ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.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/gestures/presentation/widgets/gesture_binding_editor.dart'; +import 'package:weblibre/features/gestures/presentation/widgets/gesture_stroke_view.dart'; +import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart'; + +/// Lists the configured gesture → action bindings, grouped by action category, +/// and lets the user add, edit and remove them. +class GestureBindingsScreen extends HookConsumerWidget { + const GestureBindingsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch(gestureSettingsWithDefaultsProvider); + + Future upsertBinding( + ({GestureStroke stroke, GestureAction action}) result, { + String? replacedKey, + }) async { + await ref.read(gestureSettingsRepositoryProvider.notifier).updateSettings( + (current) { + final bindings = Map.from(current.bindings); + if (replacedKey != null) { + bindings.remove(replacedKey); + } + bindings[result.stroke.key] = result.action; + return current.copyWith.bindings(bindings); + }, + ); + } + + Future removeBinding(String key) async { + await ref.read(gestureSettingsRepositoryProvider.notifier).updateSettings( + (current) { + final bindings = Map.from(current.bindings) + ..remove(key); + return current.copyWith.bindings(bindings); + }, + ); + } + + // Group bindings by their action's category, preserving category order. + final byCategory = + >>{}; + for (final entry in settings.bindings.entries) { + byCategory.putIfAbsent(entry.value.category, () => []).add(entry); + } + for (final entries in byCategory.values) { + entries.sort((a, b) => a.value.title.compareTo(b.value.title)); + } + + return SettingsCustomScrollScaffold( + title: 'Gesture bindings', + floatingActionButton: FloatingActionButton.extended( + icon: const Icon(Icons.add), + label: const Text('Add gesture'), + onPressed: () async { + final result = await showGestureBindingEditor( + context, + existingBindings: settings.bindings, + maxFingers: settings.maxFingers, + ); + if (result != null) { + await upsertBinding(result); + } + }, + ), + slivers: [ + if (settings.bindings.isEmpty) + const SliverFillRemaining( + hasScrollBody: false, + child: Center(child: Text('No gestures assigned yet.')), + ) + else + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 88), + sliver: SliverList.list( + children: [ + for (final category in GestureActionCategory.values) + if (byCategory[category] case final entries? + when entries.isNotEmpty) + _GestureBindingGroup( + title: category.label, + children: [ + for (final binding in entries) + _GestureBindingTile( + gestureKey: binding.key, + action: binding.value, + onEdit: () async { + final result = await showGestureBindingEditor( + context, + initialStroke: GestureStroke.fromKey( + binding.key, + ), + initialAction: binding.value, + existingBindings: settings.bindings, + maxFingers: settings.maxFingers, + ); + if (result != null) { + await upsertBinding( + result, + replacedKey: binding.key, + ); + } + }, + onRemove: () => removeBinding(binding.key), + ), + ], + ), + ], + ), + ), + ], + ); + } +} + +/// A category heading followed by a filled card grouping its binding tiles, +/// matching the visual language of the other settings screens. +class _GestureBindingGroup extends StatelessWidget { + final String title; + final List children; + + const _GestureBindingGroup({required this.title, required this.children}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Padding( + padding: const EdgeInsets.only(bottom: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 12), + Card.filled( + margin: EdgeInsets.zero, + color: theme.colorScheme.surfaceContainer, + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + for (var i = 0; i < children.length; i++) ...[ + if (i > 0) const Divider(height: 1), + children[i], + ], + ], + ), + ), + ], + ), + ); + } +} + +class _GestureBindingTile extends StatelessWidget { + final String gestureKey; + final GestureAction action; + final Future Function() onEdit; + final Future Function() onRemove; + + const _GestureBindingTile({ + required this.gestureKey, + required this.action, + required this.onEdit, + required this.onRemove, + }); + + @override + Widget build(BuildContext context) { + final stroke = GestureStroke.fromKey(gestureKey); + + return ListTile( + leading: Icon(action.icon), + title: Text(action.title), + subtitle: GestureStrokeView(stroke: stroke), + trailing: IconButton( + icon: const Icon(Icons.delete_outline), + tooltip: 'Remove', + onPressed: () => onRemove(), + ), + onTap: () => onEdit(), + ); + } +} diff --git a/apps/weblibre/lib/features/gestures/presentation/screens/gesture_excluded_sites_screen.dart b/apps/weblibre/lib/features/gestures/presentation/screens/gesture_excluded_sites_screen.dart new file mode 100644 index 00000000..dccbcb70 --- /dev/null +++ b/apps/weblibre/lib/features/gestures/presentation/screens/gesture_excluded_sites_screen.dart @@ -0,0 +1,71 @@ +/* + * 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 . + */ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/gestures/data/models/gesture_settings.dart'; +import 'package:weblibre/features/gestures/domain/repositories/gesture_settings.dart'; +import 'package:weblibre/features/gestures/presentation/widgets/string_list_editor.dart'; +import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart'; + +/// Manages the list of sites on which gestures are disabled. +class GestureExcludedSitesScreen extends HookConsumerWidget { + const GestureExcludedSitesScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final excludedSites = ref.watch( + gestureSettingsWithDefaultsProvider.select((s) => s.excludedSites), + ); + + return SettingsCustomScrollScaffold( + title: 'Excluded sites', + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Text( + 'Gestures are disabled on these sites. Subdomains are included ' + '(e.g. "example.com" also covers "m.example.com").', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ), + SliverToBoxAdapter( + child: StringListEditor( + values: excludedSites, + hintText: 'example.com', + itemIcon: Icons.public_off, + emptyLabel: 'No sites excluded.', + normalize: normalizeGestureSiteHost, + onChanged: (next) async { + await ref + .read(gestureSettingsRepositoryProvider.notifier) + .updateSettings( + (current) => current.copyWith.excludedSites(next), + ); + }, + ), + ), + ], + ); + } +} diff --git a/apps/weblibre/lib/features/gestures/presentation/screens/gesture_feedback_screen.dart b/apps/weblibre/lib/features/gestures/presentation/screens/gesture_feedback_screen.dart new file mode 100644 index 00000000..a73933f7 --- /dev/null +++ b/apps/weblibre/lib/features/gestures/presentation/screens/gesture_feedback_screen.dart @@ -0,0 +1,158 @@ +/* + * 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 . + */ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/gestures/data/models/gesture_settings.dart'; +import 'package:weblibre/features/gestures/domain/repositories/gesture_settings.dart'; +import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart'; + +const List _feedbackSections = [ + SettingsSectionDefinition( + title: 'Overlay', + entries: [ + SettingsEntryDefinition( + title: 'Live feedback', + subtitle: 'Show the stroke and its action while you draw', + child: _LiveFeedbackTile(), + ), + SettingsEntryDefinition( + title: 'Suggest next', + subtitle: 'Also show the other gestures you can complete', + child: _SuggestNextTile(), + ), + SettingsEntryDefinition( + title: 'Suggest after', + subtitle: 'Number of strokes to draw before suggestions appear', + child: _SuggestAfterSection(), + ), + ], + ), +]; + +/// Controls the live feedback overlay shown while drawing a gesture. +class GestureFeedbackScreen extends StatelessWidget { + const GestureFeedbackScreen({super.key}); + + @override + Widget build(BuildContext context) { + return const SettingsDetailScaffold( + title: 'Feedback', + subtitle: 'Live overlay and gesture suggestions.', + icon: Icons.bolt_outlined, + sections: _feedbackSections, + ); + } +} + +class _LiveFeedbackTile extends HookConsumerWidget { + const _LiveFeedbackTile(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final showFeedback = ref.watch( + gestureSettingsWithDefaultsProvider.select((s) => s.showFeedback), + ); + + return SwitchListTile.adaptive( + secondary: const Icon(Icons.bolt_outlined), + title: const Text('Live feedback'), + subtitle: const Text('Show the stroke and its action while you draw'), + value: showFeedback, + onChanged: (value) async { + await ref + .read(gestureSettingsRepositoryProvider.notifier) + .updateSettings((current) => current.copyWith.showFeedback(value)); + }, + ); + } +} + +class _SuggestNextTile extends HookConsumerWidget { + const _SuggestNextTile(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch(gestureSettingsWithDefaultsProvider); + + return SwitchListTile.adaptive( + secondary: const Icon(Icons.lightbulb_outline), + title: const Text('Suggest next'), + subtitle: const Text('Also show the other gestures you can complete'), + value: settings.suggestNext, + onChanged: settings.showFeedback + ? (value) async { + await ref + .read(gestureSettingsRepositoryProvider.notifier) + .updateSettings( + (current) => current.copyWith.suggestNext(value), + ); + } + : null, + ); + } +} + +class _SuggestAfterSection extends HookConsumerWidget { + const _SuggestAfterSection(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch(gestureSettingsWithDefaultsProvider); + final enabled = settings.showFeedback && settings.suggestNext; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + enabled: enabled, + leading: const Icon(Icons.straighten), + title: const Text('Suggest after'), + subtitle: Slider.adaptive( + min: minGestureMinSuggestionStroke.toDouble(), + max: maxGestureMinSuggestionStroke.toDouble(), + divisions: + maxGestureMinSuggestionStroke - minGestureMinSuggestionStroke, + value: settings.minSuggestionStroke + .clamp( + minGestureMinSuggestionStroke, + maxGestureMinSuggestionStroke, + ) + .toDouble(), + label: '${settings.minSuggestionStroke} strokes', + onChanged: enabled + ? (value) async { + await ref + .read(gestureSettingsRepositoryProvider.notifier) + .updateSettings( + (current) => + current.copyWith.minSuggestionStroke(value.round()), + ); + } + : null, + ), + ), + const Padding( + padding: EdgeInsets.fromLTRB(72, 0, 16, 8), + child: Text('Number of strokes to draw before suggestions appear.'), + ), + ], + ); + } +} diff --git a/apps/weblibre/lib/features/gestures/presentation/screens/gesture_settings_screen.dart b/apps/weblibre/lib/features/gestures/presentation/screens/gesture_settings_screen.dart new file mode 100644 index 00000000..f7287dc5 --- /dev/null +++ b/apps/weblibre/lib/features/gestures/presentation/screens/gesture_settings_screen.dart @@ -0,0 +1,179 @@ +/* + * 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 . + */ +import 'dart:async'; + +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/gestures/data/models/gesture_settings.dart'; +import 'package:weblibre/features/gestures/domain/repositories/gesture_settings.dart'; +import 'package:weblibre/features/gestures/presentation/screens/gesture_behavior_screen.dart'; +import 'package:weblibre/features/gestures/presentation/screens/gesture_bindings_screen.dart'; +import 'package:weblibre/features/gestures/presentation/screens/gesture_excluded_sites_screen.dart'; +import 'package:weblibre/features/gestures/presentation/screens/gesture_feedback_screen.dart'; +import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart'; + +/// Overview screen for gesture configuration: a master switch plus entries that +/// open the dedicated bindings / behavior / excluded-sites / feedback subpages. +class GestureSettingsScreen extends HookConsumerWidget { + const GestureSettingsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch(gestureSettingsWithDefaultsProvider); + final repository = ref.read(gestureSettingsRepositoryProvider.notifier); + final colorScheme = Theme.of(context).colorScheme; + + void open(Widget screen) { + unawaited( + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => screen)), + ); + } + + return SettingsCustomScrollScaffold( + title: 'Gestures', + slivers: [ + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + sliver: SliverToBoxAdapter( + child: Card.filled( + margin: EdgeInsets.zero, + color: colorScheme.primaryContainer, + clipBehavior: Clip.antiAlias, + child: SwitchListTile.adaptive( + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 8, + ), + secondary: Icon( + MdiIcons.gestureSwipe, + color: colorScheme.onPrimaryContainer, + ), + title: Text( + 'Enable Gestures', + style: TextStyle( + color: colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), + ), + subtitle: Text( + 'Draw stroke gestures on web pages to trigger actions', + style: TextStyle( + color: colorScheme.onPrimaryContainer.withValues( + alpha: 0.8, + ), + ), + ), + value: settings.enabled, + onChanged: (value) async { + await repository.updateSettings( + (current) => current.copyWith.enabled(value), + ); + }, + ), + ), + ), + ), + if (settings.enabled) + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 20), + sliver: SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: buildSettingsSectionWidgets(context, [ + SettingsSectionDefinition( + title: 'Configuration', + entries: [ + SettingsEntryDefinition( + title: 'Gesture bindings', + child: ListTile( + leading: const Icon(MdiIcons.gestureDoubleTap), + title: const Text('Gesture bindings'), + subtitle: const Text('Strokes mapped to actions'), + trailing: _CountChevron( + count: settings.bindings.length, + ), + onTap: () => open(const GestureBindingsScreen()), + ), + ), + SettingsEntryDefinition( + title: 'Behavior & timing', + child: ListTile( + leading: const Icon(Icons.tune), + title: const Text('Behavior & timing'), + subtitle: const Text( + 'Sensitivity, timeout, cooldown', + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => open(const GestureBehaviorScreen()), + ), + ), + SettingsEntryDefinition( + title: 'Excluded sites', + child: ListTile( + leading: const Icon(Icons.public_off), + title: const Text('Excluded sites'), + subtitle: const Text('Disable gestures per site'), + trailing: _CountChevron( + count: settings.excludedSites.length, + ), + onTap: () => open(const GestureExcludedSitesScreen()), + ), + ), + SettingsEntryDefinition( + title: 'Feedback', + child: ListTile( + leading: const Icon(Icons.bolt_outlined), + title: const Text('Feedback'), + subtitle: const Text('Live overlay and suggestions'), + trailing: const Icon(Icons.chevron_right), + onTap: () => open(const GestureFeedbackScreen()), + ), + ), + ], + ), + ]), + ), + ), + ), + ], + ); + } +} + +class _CountChevron extends StatelessWidget { + final int count; + + const _CountChevron({required this.count}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (count > 0) + Text('$count', style: Theme.of(context).textTheme.labelLarge), + const Icon(Icons.chevron_right), + ], + ); + } +} diff --git a/apps/weblibre/lib/features/gestures/presentation/widgets/gesture_action_picker.dart b/apps/weblibre/lib/features/gestures/presentation/widgets/gesture_action_picker.dart new file mode 100644 index 00000000..a8ad0043 --- /dev/null +++ b/apps/weblibre/lib/features/gestures/presentation/widgets/gesture_action_picker.dart @@ -0,0 +1,111 @@ +/* + * 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 . + */ +import 'package:flutter/material.dart'; +import 'package:weblibre/features/gestures/data/models/gesture_action.dart'; + +/// Shows a modal bottom sheet listing every [GestureAction] grouped by category, +/// each with its icon, title and description, and returns the chosen action (or +/// null if dismissed). Mirrors the icon + subtitle selection sheets used +/// elsewhere in the app (e.g. the contextual toolbar pickers). +Future showGestureActionPicker( + BuildContext context, { + required GestureAction selected, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + showDragHandle: true, + builder: (context) => _GestureActionPicker(selected: selected), + ); +} + +class _GestureActionPicker extends StatelessWidget { + final GestureAction selected; + + const _GestureActionPicker({required this.selected}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + final byCategory = >{}; + for (final action in GestureAction.values) { + byCategory.putIfAbsent(action.category, () => []).add(action); + } + + // Cap the sheet height so the long action list scrolls inside a sheet that + // never covers the whole screen. + final maxHeight = MediaQuery.sizeOf(context).height * 0.8; + + return ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxHeight), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 8), + child: Text( + 'Choose action', + style: theme.textTheme.titleLarge, + ), + ), + Flexible( + child: ListView( + padding: const EdgeInsets.only(bottom: 8), + children: [ + for (final category in GestureActionCategory.values) + if (byCategory[category] case final actions? + when actions.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 4), + child: Text( + category.label, + style: theme.textTheme.titleSmall?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w700, + ), + ), + ), + for (final action in actions) + ListTile( + leading: Icon(action.icon), + title: Text(action.title), + subtitle: Text(action.description), + selected: action == selected, + trailing: action == selected + ? Icon(Icons.check, color: colorScheme.primary) + : null, + onTap: () => Navigator.of(context).pop(action), + ), + ], + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/weblibre/lib/features/gestures/presentation/widgets/gesture_binding_editor.dart b/apps/weblibre/lib/features/gestures/presentation/widgets/gesture_binding_editor.dart new file mode 100644 index 00000000..371c1e60 --- /dev/null +++ b/apps/weblibre/lib/features/gestures/presentation/widgets/gesture_binding_editor.dart @@ -0,0 +1,384 @@ +/* + * 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 . + */ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:weblibre/features/gestures/data/models/gesture_action.dart'; +import 'package:weblibre/features/gestures/data/models/gesture_stroke.dart'; +import 'package:weblibre/features/gestures/presentation/widgets/gesture_action_picker.dart'; +import 'package:weblibre/features/gestures/presentation/widgets/gesture_stroke_view.dart'; + +typedef GestureBindingResult = ({GestureStroke stroke, GestureAction action}); + +/// Opens the gesture binding editor as a full-screen page. +/// +/// Returns the configured stroke + action, or null if dismissed. Editing an +/// existing binding is supported by passing [initialStroke]/[initialAction]. +/// +/// [existingBindings] is the current stroke-key → action map, used to warn +/// before a new stroke would overwrite an already-assigned one. +Future showGestureBindingEditor( + BuildContext context, { + GestureStroke? initialStroke, + GestureAction? initialAction, + Map existingBindings = const {}, + int maxFingers = 1, +}) { + return Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => _GestureBindingEditor( + initialStroke: initialStroke, + initialAction: initialAction, + existingBindings: existingBindings, + maxFingers: maxFingers, + ), + ), + ); +} + +class _GestureBindingEditor extends HookWidget { + final GestureStroke? initialStroke; + final GestureAction? initialAction; + final Map existingBindings; + final int maxFingers; + + const _GestureBindingEditor({ + required this.initialStroke, + required this.initialAction, + required this.existingBindings, + required this.maxFingers, + }); + + @override + Widget build(BuildContext context) { + final action = useState(initialAction ?? GestureAction.values.first); + final startPosition = useState( + initialStroke?.startPosition ?? GestureStartPosition.anywhere, + ); + final fingers = useState(initialStroke?.fingers ?? 1); + final arrows = useState>( + initialStroke?.arrows ?? const [], + ); + + // Allow at least 3 fingers in the picker so multi-finger gestures can be + // configured; the native recognizer is told to match whatever is bound. + final fingerLimit = maxFingers < 3 ? 3 : maxFingers; + + final stroke = GestureStroke( + startPosition: startPosition.value, + fingers: fingers.value, + arrows: arrows.value, + ); + + // A collision is an existing binding under the same stroke key that is not + // the one currently being edited (editing a binding in place is fine). + final collisionAction = stroke.isValid && stroke.key != initialStroke?.key + ? existingBindings[stroke.key] + : null; + + Future save() async { + if (!stroke.isValid) return; + + if (collisionAction != null) { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + icon: const Icon(Icons.warning_amber), + title: const Text('Replace existing gesture?'), + content: Text( + 'This stroke is already assigned to "${collisionAction.title}". ' + 'Saving will replace that binding.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Replace'), + ), + ], + ), + ); + if (confirmed != true) return; + } + + if (context.mounted) { + Navigator.of(context).pop((stroke: stroke, action: action.value)); + } + } + + return Scaffold( + appBar: AppBar( + title: Text(initialStroke == null ? 'Create gesture' : 'Edit gesture'), + ), + body: Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Target action — opens the grouped action picker sheet. + Card.filled( + margin: EdgeInsets.zero, + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + clipBehavior: Clip.antiAlias, + child: ListTile( + leading: Icon(action.value.icon), + title: const Text('Target action'), + subtitle: Text(action.value.title), + trailing: const Icon(Icons.unfold_more), + onTap: () async { + final picked = await showGestureActionPicker( + context, + selected: action.value, + ); + if (picked != null) action.value = picked; + }, + ), + ), + const SizedBox(height: 24), + + Align( + alignment: Alignment.centerLeft, + child: Text( + 'Start position', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final value in GestureStartPosition.values) + ChoiceChip( + avatar: Icon(value.icon), + showCheckmark: false, + label: Text(value.label), + selected: startPosition.value == value, + onSelected: (selected) { + if (selected) startPosition.value = value; + }, + ), + ], + ), + const SizedBox(height: 16), + + ListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Fingers'), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.remove), + onPressed: fingers.value > 1 + ? () => fingers.value = fingers.value - 1 + : null, + ), + Text('${fingers.value}'), + IconButton( + icon: const Icon(Icons.add), + onPressed: fingers.value < fingerLimit + ? () => fingers.value = fingers.value + 1 + : null, + ), + ], + ), + ), + const SizedBox(height: 24), + + Align( + alignment: Alignment.centerLeft, + child: Text( + 'Stroke pattern', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + const SizedBox(height: 12), + + // Visualizer canvas mirroring the live stroke as it is built. + Container( + height: 80, + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + alignment: Alignment.center, + child: arrows.value.isEmpty + ? Text( + 'Draw a stroke pattern below', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + ) + : GestureStrokeView(stroke: stroke), + ), + const SizedBox(height: 16), + + // Direction pad laid out like a compass (← ↑ ↓ →). + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + for (final arrow in const [ + GestureArrow.left, + GestureArrow.up, + GestureArrow.down, + GestureArrow.right, + ]) + _DirectionButton( + arrow: arrow, + // The recognizer collapses consecutive identical directions + // into a single stroke, so a duplicate of the last arrow + // could never be matched. Disable it to keep the builder in + // sync with what native recognition can produce. + onPressed: arrows.value.lastOrNull == arrow + ? null + : () => arrows.value = [...arrows.value, arrow], + ), + ], + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + onPressed: arrows.value.isEmpty + ? null + : () => arrows.value = arrows.value.sublist( + 0, + arrows.value.length - 1, + ), + icon: const Icon(Icons.backspace_outlined), + label: const Text('Undo last'), + ), + ), + ], + ), + ), + ), + const Divider(height: 1), + SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (collisionAction != null) ...[ + _CollisionWarning(action: collisionAction), + const SizedBox(height: 12), + ], + FilledButton( + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(56), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + onPressed: stroke.isValid ? save : null, + child: Text( + collisionAction != null + ? 'Replace gesture' + : 'Save gesture', + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +/// Inline warning shown in the editor's save bar when the current stroke would +/// overwrite another binding. +class _CollisionWarning extends StatelessWidget { + final GestureAction action; + + const _CollisionWarning({required this.action}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: colorScheme.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon( + Icons.warning_amber, + size: 20, + color: colorScheme.onErrorContainer, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Already assigned to "${action.title}". Saving replaces it.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onErrorContainer, + ), + ), + ), + ], + ), + ); + } +} + +/// A circular tonal button for one swipe direction in the editor's D-pad. +class _DirectionButton extends StatelessWidget { + final GestureArrow arrow; + final VoidCallback? onPressed; + + const _DirectionButton({required this.arrow, required this.onPressed}); + + @override + Widget build(BuildContext context) { + return FilledButton.tonal( + style: FilledButton.styleFrom( + shape: const CircleBorder(), + padding: const EdgeInsets.all(20), + ), + onPressed: onPressed, + child: Icon(arrow.icon), + ); + } +} diff --git a/apps/weblibre/lib/features/gestures/presentation/widgets/gesture_feedback_overlay.dart b/apps/weblibre/lib/features/gestures/presentation/widgets/gesture_feedback_overlay.dart new file mode 100644 index 00000000..abee204a --- /dev/null +++ b/apps/weblibre/lib/features/gestures/presentation/widgets/gesture_feedback_overlay.dart @@ -0,0 +1,181 @@ +/* + * 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 . + */ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.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/gestures/domain/services/gesture_control.dart'; +import 'package:weblibre/features/gestures/presentation/widgets/gesture_stroke_view.dart'; + +/// One candidate completion shown in the overlay. +typedef _Suggestion = ({ + GestureStroke stroke, + GestureAction action, + bool exact, +}); + +/// Translucent overlay shown near the top of the page while a gesture stroke is +/// being drawn. Mirrors the reference add-on's live toast: it renders the +/// in-progress stroke and the action it currently matches, and (once enough +/// strokes are drawn) the other gestures the user could complete from here. +/// +/// Purely informational — it never intercepts touch input. +class GestureFeedbackOverlay extends HookConsumerWidget { + const GestureFeedbackOverlay({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch(gestureSettingsWithDefaultsProvider); + final partialKey = settings.showFeedback + ? ref.watch(gestureProgressProvider).asData?.value + : null; + + final suggestions = partialKey == null + ? const <_Suggestion>[] + : _resolveSuggestions(settings, GestureStroke.fromKey(partialKey)); + + return IgnorePointer( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 150), + child: suggestions.isEmpty + ? const SizedBox.shrink() + : Align( + alignment: Alignment.topCenter, + child: Padding( + padding: const EdgeInsets.only(top: 24), + child: _SuggestionCard(suggestions: suggestions), + ), + ), + ), + ); + } + + /// Bindings reachable from the in-progress [current] stroke. + /// + /// A binding is reachable when it uses the same finger count, a compatible + /// start position (its own, or anywhere), and its arrow sequence begins with + /// what has been drawn so far. Until [GestureSettings.minSuggestionStroke] + /// strokes are drawn (or suggestions are disabled), only the exact current + /// match is shown. + List<_Suggestion> _resolveSuggestions( + GestureSettings settings, + GestureStroke current, + ) { + if (current.arrows.isEmpty) return const []; + + final suggestAll = + settings.suggestNext && + current.arrows.length >= settings.minSuggestionStroke; + + final result = <_Suggestion>[]; + for (final MapEntry(key: key, value: action) in settings.bindings.entries) { + final stroke = GestureStroke.fromKey(key); + if (stroke.fingers != current.fingers) continue; + if (stroke.startPosition != GestureStartPosition.anywhere && + stroke.startPosition != current.startPosition) { + continue; + } + if (!_arrowsStartWith(stroke.arrows, current.arrows)) continue; + + final exact = stroke.arrows.length == current.arrows.length; + if (!suggestAll && !exact) continue; + + result.add((stroke: stroke, action: action, exact: exact)); + } + + result.sort((a, b) { + if (a.exact != b.exact) return a.exact ? -1 : 1; + return a.stroke.arrows.length.compareTo(b.stroke.arrows.length); + }); + return result; + } + + bool _arrowsStartWith(List full, List prefix) { + if (prefix.length > full.length) return false; + for (var i = 0; i < prefix.length; i++) { + if (full[i] != prefix[i]) return false; + } + return true; + } +} + +class _SuggestionCard extends StatelessWidget { + final List<_Suggestion> suggestions; + + const _SuggestionCard({required this.suggestions}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Material( + color: theme.colorScheme.inverseSurface.withValues(alpha: 0.92), + borderRadius: BorderRadius.circular(16), + elevation: 6, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 320), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final suggestion in suggestions) + Opacity( + opacity: suggestion.exact ? 1 : 0.6, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + Icon( + suggestion.action.icon, + size: 18, + color: theme.colorScheme.onInverseSurface, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + suggestion.action.title, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onInverseSurface, + fontWeight: suggestion.exact + ? FontWeight.w600 + : FontWeight.normal, + ), + ), + ), + const SizedBox(width: 8), + GestureStrokeView( + stroke: suggestion.stroke, + color: theme.colorScheme.onInverseSurface, + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/weblibre/lib/features/gestures/presentation/widgets/gesture_stroke_view.dart b/apps/weblibre/lib/features/gestures/presentation/widgets/gesture_stroke_view.dart new file mode 100644 index 00000000..47dcaffd --- /dev/null +++ b/apps/weblibre/lib/features/gestures/presentation/widgets/gesture_stroke_view.dart @@ -0,0 +1,75 @@ +/* + * 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 . + */ +import 'package:flutter/material.dart'; +import 'package:weblibre/features/gestures/data/models/gesture_stroke.dart'; + +extension GestureArrowIcon on GestureArrow { + IconData get icon => switch (this) { + GestureArrow.up => Icons.arrow_upward, + GestureArrow.down => Icons.arrow_downward, + GestureArrow.left => Icons.arrow_back, + GestureArrow.right => Icons.arrow_forward, + }; +} + +/// Compact visual rendering of a [GestureStroke]: the direction arrows in +/// sequence, prefixed by start-position and finger-count qualifiers when set. +/// +/// All glyphs share a single foreground [color] so the view blends with +/// whatever surface it sits on; it defaults to the primary color, but callers +/// rendering on a contrasting surface (e.g. the live overlay) should pass the +/// matching `on…` color. +class GestureStrokeView extends StatelessWidget { + final GestureStroke stroke; + final Color? color; + + const GestureStrokeView({required this.stroke, this.color, super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final foreground = color ?? theme.colorScheme.primary; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (stroke.startPosition != GestureStartPosition.anywhere) + Padding( + padding: const EdgeInsets.only(right: 6), + child: Icon( + stroke.startPosition.icon, + size: 18, + color: foreground, + ), + ), + if (stroke.fingers >= 2) + Padding( + padding: const EdgeInsets.only(right: 4), + child: Text( + '${stroke.fingers}×', + style: theme.textTheme.labelLarge?.copyWith(color: foreground), + ), + ), + for (final arrow in stroke.arrows) + Icon(arrow.icon, size: 18, color: foreground), + ], + ); + } +} diff --git a/apps/weblibre/lib/features/gestures/presentation/widgets/string_list_editor.dart b/apps/weblibre/lib/features/gestures/presentation/widgets/string_list_editor.dart new file mode 100644 index 00000000..db2f512c --- /dev/null +++ b/apps/weblibre/lib/features/gestures/presentation/widgets/string_list_editor.dart @@ -0,0 +1,126 @@ +/* + * 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 . + */ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +/// Reusable editor for a list of unique string entries: a labelled input field +/// with an add button, followed by the current entries each with a delete +/// action. +/// +/// Entries are passed through [normalize] before being added; returning null +/// rejects the input (e.g. an unparseable value), and duplicates are ignored. +class StringListEditor extends HookWidget { + final List values; + final ValueChanged> onChanged; + + /// Hint shown in the input field. + final String hintText; + + /// Leading icon for each entry row. + final IconData itemIcon; + + /// Message shown when the list is empty. + final String emptyLabel; + + /// Canonicalises raw input before adding. Returns null to reject the value. + final String? Function(String input) normalize; + + const StringListEditor({ + required this.values, + required this.onChanged, + required this.hintText, + required this.normalize, + this.itemIcon = Icons.link, + this.emptyLabel = 'Nothing added yet.', + super.key, + }); + + @override + Widget build(BuildContext context) { + final controller = useTextEditingController(); + // Rebuild the add button's enabled state as the field changes. + useListenable(controller); + + void add() { + final normalized = normalize(controller.text); + if (normalized == null) return; + if (!values.contains(normalized)) { + onChanged([...values, normalized]); + } + controller.clear(); + } + + void remove(String value) { + onChanged(values.where((v) => v != value).toList()); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Row( + children: [ + Expanded( + child: TextField( + controller: controller, + keyboardType: TextInputType.url, + autocorrect: false, + textInputAction: TextInputAction.done, + onSubmitted: (_) => add(), + decoration: InputDecoration( + hintText: hintText, + border: const OutlineInputBorder(), + isDense: true, + ), + ), + ), + const SizedBox(width: 8), + IconButton.filled( + icon: const Icon(Icons.add), + tooltip: 'Add', + onPressed: controller.text.trim().isEmpty ? null : add, + ), + ], + ), + ), + if (values.isEmpty) + Padding( + padding: const EdgeInsets.all(16), + child: Text( + emptyLabel, + style: Theme.of(context).textTheme.bodyMedium, + ), + ) + else + for (final value in values) + ListTile( + leading: Icon(itemIcon), + title: Text(value), + trailing: IconButton( + icon: const Icon(Icons.delete_outline), + tooltip: 'Remove', + onPressed: () => remove(value), + ), + ), + ], + ); + } +} diff --git a/apps/weblibre/lib/features/settings/presentation/screens/settings.dart b/apps/weblibre/lib/features/settings/presentation/screens/settings.dart index d5e39259..02b9cdd7 100644 --- a/apps/weblibre/lib/features/settings/presentation/screens/settings.dart +++ b/apps/weblibre/lib/features/settings/presentation/screens/settings.dart @@ -112,6 +112,13 @@ _CategoryGroups _buildCategories() { sections: browsingSettingsSections, onTap: (context) => BrowsingSettingsRoute().push(context), ), + _SettingsCategoryDefinition( + title: 'Gestures', + subtitle: 'Stroke gestures for browser actions', + icon: MdiIcons.gestureSwipe, + keywords: const ['gesture', 'swipe', 'stroke'], + onTap: (context) => GestureSettingsRoute().push(context), + ), _SettingsCategoryDefinition( title: 'Toolbar & Layout', subtitle: 'Tab bar, toolbar, quick switcher, tab view', diff --git a/apps/weblibre/lib/features/settings/presentation/widgets/settings_detail.dart b/apps/weblibre/lib/features/settings/presentation/widgets/settings_detail.dart index 97340abf..6e5dec02 100644 --- a/apps/weblibre/lib/features/settings/presentation/widgets/settings_detail.dart +++ b/apps/weblibre/lib/features/settings/presentation/widgets/settings_detail.dart @@ -167,6 +167,7 @@ class SettingsCustomScrollScaffold extends StatelessWidget { final TextEditingController? searchController; final String searchHintText; final List slivers; + final Widget? floatingActionButton; const SettingsCustomScrollScaffold({ super.key, @@ -175,11 +176,13 @@ class SettingsCustomScrollScaffold extends StatelessWidget { this.actions = const [], this.searchController, this.searchHintText = 'Search settings', + this.floatingActionButton, }); @override Widget build(BuildContext context) { return Scaffold( + floatingActionButton: floatingActionButton, body: SafeArea( child: FadingScroll( fadingSize: 25, diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BaseBrowserFragment.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BaseBrowserFragment.kt index b34064b5..3a7dcb9f 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BaseBrowserFragment.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BaseBrowserFragment.kt @@ -55,7 +55,7 @@ import mozilla.components.feature.prompts.file.AndroidPhotoPicker import mozilla.components.feature.session.FullScreenFeature import mozilla.components.feature.session.PictureInPictureFeature import mozilla.components.feature.session.SessionFeature -import mozilla.components.feature.session.SwipeRefreshFeature +import eu.weblibre.flutter_mozilla_components.feature.GestureAwareSwipeRefreshFeature import mozilla.components.feature.sitepermissions.SitePermissionsFeature import mozilla.components.feature.sitepermissions.SitePermissionsRules import mozilla.components.feature.sitepermissions.SitePermissionsRules.AutoplayAction @@ -87,7 +87,7 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit private val promptFeature = ViewBoundFeatureWrapper() private val webExtensionPromptFeature = ViewBoundFeatureWrapper() private val sitePermissionsFeature = ViewBoundFeatureWrapper() - private val swipeRefreshFeature = ViewBoundFeatureWrapper() + private val swipeRefreshFeature = ViewBoundFeatureWrapper() private val secureWindowFeature = ViewBoundFeatureWrapper() private val fullScreenFeature = ViewBoundFeatureWrapper() private val mediaSessionFullscreenFeature = @@ -272,7 +272,7 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit ) swipeRefreshFeature.set( - feature = SwipeRefreshFeature( + feature = GestureAwareSwipeRefreshFeature( components.core.store, components.useCases.sessionUseCases.reload, binding.swipeToRefresh, diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt index af875459..4138c01b 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt @@ -14,12 +14,14 @@ import eu.weblibre.flutter_mozilla_components.pigeons.BounceTrackingProtectionMo import eu.weblibre.flutter_mozilla_components.pigeons.ContentBlocking import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoEngineSettings +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoGestureEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSuggestionEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSyncStateEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoTabContentEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoViewportEvents +import eu.weblibre.flutter_mozilla_components.pigeons.GestureConfig import eu.weblibre.flutter_mozilla_components.pigeons.QueryParameterStripping import eu.weblibre.flutter_mozilla_components.pigeons.ReaderViewController import eu.weblibre.flutter_mozilla_components.services.PrivateTabsNotificationService @@ -97,6 +99,38 @@ object GlobalComponents { // Engine settings API for managing engine-specific settings var engineSettingsApi: GeckoEngineSettingsApiImpl? = null + // Touch-gesture recognition: event sink (Kotlin → Dart) and the current + // configuration pushed from Dart. Read by the browser container's + // GestureRecognizer on the UI thread. + var gestureEvents: GeckoGestureEvents? = null + + @Volatile + var gestureConfig: GestureConfig? = null + + /** + * Set true when the in-flight touch sequence was recognized as a configured + * gesture, so pull-to-refresh ([GestureAwareSwipeRefreshFeature]) can + * suppress the otherwise-redundant reload for down-leading gestures started + * at the top of the page. Reset on each ACTION_DOWN by the gesture + * container. Read and written on the UI thread. + */ + @Volatile + var touchConsumedByGesture: Boolean = false + + // Current dynamic-toolbar viewport insets (physical px), tracked from the + // viewport API so gesture edge-detection can exclude the bottom toolbar + // area the engine view never receives touches in. + @Volatile + var dynamicToolbarMaxHeightPx: Int = 0 + + @Volatile + var verticalClippingPx: Int = 0 + + /** Currently visible bottom inset: full toolbar height when shown, 0 when + * auto-hidden (clipping cancels it out). */ + val bottomViewportInsetPx: Int + get() = (dynamicToolbarMaxHeightPx + verticalClippingPx).coerceAtLeast(0) + // External download manager setting var useExternalDownloadManager: Boolean = false diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt index fd804408..a983b15e 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt @@ -41,6 +41,8 @@ import eu.weblibre.flutter_mozilla_components.pigeons.GeckoHistoryApi import eu.weblibre.flutter_mozilla_components.pigeons.GeckoIconsApi import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPublicSuffixListApi import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSitePermissionsApi +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoGestureApi +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoGestureEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoTrackingProtectionApi import eu.weblibre.flutter_mozilla_components.pigeons.GeckoLogging import eu.weblibre.flutter_mozilla_components.pigeons.GeckoMlApi @@ -337,6 +339,14 @@ class GeckoBrowserApiImpl : GeckoBrowserApi { GlobalComponents.viewportEvents = viewportEvents GlobalComponents.viewportApi = viewportApi + // Touch-gesture recognition: event sink (Kotlin → Dart) + config API + GlobalComponents.gestureEvents = + GeckoGestureEvents(_flutterPluginBinding.binaryMessenger) + GeckoGestureApi.setUp( + _flutterPluginBinding.binaryMessenger, + GeckoGestureApiImpl() + ) + ReaderViewEvents.setUp( _flutterPluginBinding.binaryMessenger, components.events.readerViewEvents diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoGestureApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoGestureApiImpl.kt new file mode 100644 index 00000000..fa9b2ecc --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoGestureApiImpl.kt @@ -0,0 +1,22 @@ +/* + * 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.api + +import eu.weblibre.flutter_mozilla_components.GlobalComponents +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoGestureApi +import eu.weblibre.flutter_mozilla_components.pigeons.GestureConfig + +/** + * Stores the gesture-recognition configuration pushed from Dart. The browser + * container's [eu.weblibre.flutter_mozilla_components.feature.GestureRecognizer] + * reads it on the UI thread for every touch event. + */ +class GeckoGestureApiImpl : GeckoGestureApi { + override fun setGestureConfig(config: GestureConfig) { + GlobalComponents.gestureConfig = config + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoViewportApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoViewportApiImpl.kt index 584cb630..81cd1be1 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoViewportApiImpl.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoViewportApiImpl.kt @@ -36,6 +36,7 @@ class GeckoViewportApiImpl : GeckoViewportApi { */ override fun setDynamicToolbarMaxHeight(heightPx: Long) { val height = heightPx.toInt() + GlobalComponents.dynamicToolbarMaxHeightPx = height val engineView = components.mainBrowserEngineView if (engineView == null) { @@ -65,6 +66,7 @@ class GeckoViewportApiImpl : GeckoViewportApi { */ override fun setVerticalClipping(clippingPx: Long) { val clipping = clippingPx.toInt() + GlobalComponents.verticalClippingPx = clipping val engineView = components.mainBrowserEngineView if (engineView == null) { diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/GestureAwareSwipeRefreshFeature.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/GestureAwareSwipeRefreshFeature.kt new file mode 100644 index 00000000..f7e34e55 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/GestureAwareSwipeRefreshFeature.kt @@ -0,0 +1,111 @@ +/* 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 http://mozilla.org/MPL/2.0/. */ + +package eu.weblibre.flutter_mozilla_components.feature + +import android.os.Build +import android.view.HapticFeedbackConstants +import android.view.View +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import eu.weblibre.flutter_mozilla_components.GlobalComponents +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.map +import mozilla.components.browser.state.action.ContentAction.UpdateRefreshCanceledStateAction +import mozilla.components.browser.state.selector.findTabOrCustomTabOrSelectedTab +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.concept.engine.EngineView +import mozilla.components.feature.session.SessionUseCases +import mozilla.components.lib.state.ext.flowScoped +import mozilla.components.support.base.feature.LifecycleAwareFeature +import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifAnyChanged + +/** + * A gesture-aware variant of Mozilla's `SwipeRefreshFeature`. + * + * Behaves exactly like the upstream feature (coordinates a [SwipeRefreshLayout] + * with the session's loading state and reloads on a pull-down at the top of the + * page), with one addition: if the same touch sequence was recognized as a + * configured touch gesture, the reload is suppressed. + * + * Why: the gesture recognizer in `BackGestureFilterFrameLayout` is purely + * observational (it never consumes events), so a down-leading gesture — e.g. + * `D-R` (back) or `D-R-U` (reload) — that starts at the top of the page also + * drives the pull-to-refresh throbber and would otherwise fire a redundant + * reload on release. This mirrors the reference add-on's pull-to-refresh + * `continue()`/`end()` guards, which let pull-to-refresh act only as the + * fallback for a plain straight-down pull that matches no gesture. + * + * The recognizer flags [GlobalComponents.touchConsumedByGesture] on the + * terminating ACTION_UP (which dispatches to the ancestor container before this + * layout's own up-handling runs [onRefresh]), so the flag is reliably set by the + * time we consult it here. + * + * Derived from android-components `SwipeRefreshFeature` (MPL-2.0). + */ +class GestureAwareSwipeRefreshFeature( + private val store: BrowserStore, + private val reloadUrlUseCase: SessionUseCases.ReloadUrlUseCase, + private val swipeRefreshLayout: SwipeRefreshLayout, + private val tabId: String? = null, + private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main, +) : LifecycleAwareFeature, + SwipeRefreshLayout.OnChildScrollUpCallback, + SwipeRefreshLayout.OnRefreshListener { + private var scope: CoroutineScope? = null + + init { + swipeRefreshLayout.setOnRefreshListener(this) + swipeRefreshLayout.setOnChildScrollUpCallback(this) + } + + override fun start() { + scope = store.flowScoped(dispatcher = mainDispatcher) { flow -> + flow.map { state -> state.findTabOrCustomTabOrSelectedTab(tabId) } + .ifAnyChanged { + arrayOf(it?.content?.loading, it?.content?.refreshCanceled) + } + .collect { tab -> + tab?.let { + if (!tab.content.loading || tab.content.refreshCanceled) { + swipeRefreshLayout.isRefreshing = false + if (tab.content.refreshCanceled) { + store.dispatch(UpdateRefreshCanceledStateAction(tab.id, false)) + } + } + } + } + } + } + + override fun stop() { + scope?.cancel() + } + + @Suppress("Deprecation") + override fun canChildScrollUp(parent: SwipeRefreshLayout, child: View?) = + if (child is EngineView) { + !child.getInputResultDetail().canOverscrollTop() + } else { + true + } + + override fun onRefresh() { + // A configured touch gesture already handled this stroke; don't also + // reload. Retract the throbber the layout showed during the pull. + if (GlobalComponents.touchConsumedByGesture) { + swipeRefreshLayout.isRefreshing = false + return + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + swipeRefreshLayout.performHapticFeedback(HapticFeedbackConstants.CONFIRM) + } + store.state.findTabOrCustomTabOrSelectedTab(tabId)?.let { tab -> + reloadUrlUseCase(tab.id) + } + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/GestureRecognizer.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/GestureRecognizer.kt new file mode 100644 index 00000000..f7a55251 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/GestureRecognizer.kt @@ -0,0 +1,234 @@ +/* + * 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 eu.weblibre.flutter_mozilla_components.pigeons.GestureConfig +import kotlin.math.abs +import kotlin.math.min + +/** + * Pure, view-agnostic touch-gesture recognizer. + * + * Ported from the Simple Gesture add-on + * (https://github.com/utubo/firefox-simple_gesture, MPL-2.0, Copyright 2017 + * utubo): the same stroke grammar is used so user configuration carries over. + * A gesture is encoded as a canonical key built from three parts: + * + * - an optional **start-position** prefix describing where the touch began + * (`L:`/`R:`/`T:`/`B:` for the four edges, `W:`/`E:` for the left/right + * half otherwise), + * - an optional **finger-count** prefix (`2:`, `3:` …; omitted for a single + * finger), and + * - the dash-joined sequence of dominant **directions** (`U`/`D`/`L`/`R`), + * + * e.g. `R:2:D-L` (two fingers, started at the right edge, moved down then + * left) or simply `D-R`. + * + * Recognition is **observational**: callers feed every [MotionEvent] via [feed] + * without consuming it, and [feed] returns the matched key on the terminating + * `ACTION_UP` only when the assembled stroke matches an entry in + * [GestureConfig.activeGestureKeys]. Pages therefore scroll and tap normally; + * multi-stroke gestures simply fire their action on release. + */ +class GestureRecognizer { + /** + * Current configuration. Assigned by the owner (the browser container) + * before each event so updates pushed from Dart take effect immediately. + */ + var config: GestureConfig? = null + + /** + * Invoked on the UI thread whenever a new direction arrow is appended to the + * in-progress stroke, with the current partial canonical key (e.g. `R:D`). + * The owner uses this both to restart the idle-timeout (matching the + * reference add-on, which restarts only on a new arrow) and to drive the + * live gesture-feedback overlay. + */ + var onProgress: ((String) -> Unit)? = null + + private val arrows = ArrayList(MAX_ARROWS + 1) + private var startPosition = "" + private var fingers = "" + private var fingersNum = 1 + private var lastX = 0f + private var lastY = 0f + private var lastArrow = ' ' + private var strokeSize = 0f + private var edgeWidth = 0f + private var contentBottom = 0f + private var aborted = false + private var active = false + + /** + * Feeds one motion event. Returns the recognized gesture key on the + * terminating `ACTION_UP`, or null otherwise. Never consumes the event. + * + * [bottomInsetPx] is the height (physical px) of the dynamic bottom toolbar + * currently overlaying the engine view. It is subtracted from the view + * height so the bottom edge zone tracks the visible content edge rather than + * the toolbar area (which never receives touches). + */ + fun feed( + event: MotionEvent, + viewWidth: Int, + viewHeight: Int, + bottomInsetPx: Int, + ): String? { + val cfg = config + if (cfg == null || !cfg.enabled || cfg.activeGestureKeys.isEmpty()) { + active = false + return null + } + + return when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + onDown(event, cfg, viewWidth, viewHeight, bottomInsetPx) + null + } + + MotionEvent.ACTION_POINTER_DOWN, + MotionEvent.ACTION_MOVE -> { + onMove(event, cfg) + null + } + + MotionEvent.ACTION_UP -> onUp(cfg) + + MotionEvent.ACTION_CANCEL -> { + reset() + null + } + + else -> null + } + } + + /** Discards any in-progress gesture (e.g. on idle timeout or back-gesture + * interception). */ + fun cancel() = reset() + + private fun onDown( + event: MotionEvent, + cfg: GestureConfig, + w: Int, + h: Int, + bottomInsetPx: Int, + ) { + reset() + val minSide = min(w, h).toFloat() + if (minSide <= 0f) { + aborted = true + return + } + active = true + // Scale the configured base stroke length to the screen, matching the + // reference add-on's `strokeSize * min(w, h) / 320`. + strokeSize = cfg.strokeSize * minSide / REFERENCE_SHORT_SIDE + edgeWidth = minSide / EDGE_DIVISOR + // The visible content ends above the dynamic bottom toolbar. + contentBottom = (h - bottomInsetPx).toFloat() + lastX = event.getX(0) + lastY = event.getY(0) + startPosition = computeStartPosition(lastX, lastY, w) + } + + private fun onMove(event: MotionEvent, cfg: GestureConfig) { + if (!active || aborted) return + if (!setupFingers(event, cfg)) return + if (arrows.size > MAX_ARROWS) return + + val x = event.getX(0) + val y = event.getY(0) + val dx = x - lastX + val dy = y - lastY + val absX = abs(dx) + val absY = abs(dy) + if (absX < strokeSize && absY < strokeSize) return + + lastX = x + lastY = y + val arrow = if (absX < absY) { + if (dy < 0) 'U' else 'D' + } else { + if (dx < 0) 'L' else 'R' + } + // Collapse consecutive identical directions into a single stroke. + if (arrow == lastArrow) return + lastArrow = arrow + arrows.add(arrow) + onProgress?.invoke(currentKey()) + } + + private fun onUp(cfg: GestureConfig): String? { + val result = if (active && !aborted && arrows.isNotEmpty()) matchKey(cfg) else null + reset() + return result + } + + private fun setupFingers(event: MotionEvent, cfg: GestureConfig): Boolean { + val count = event.pointerCount + if (count > cfg.maxFingers) { + aborted = true + return false + } + if (fingersNum < count) { + fingersNum = count + fingers = "$count:" + } + return true + } + + /** + * The current in-progress canonical key including the start-position and + * finger prefixes, e.g. `R:2:D-L`. Used for live feedback, where the + * consumer matches it against configured bindings to suggest completions. + */ + private fun currentKey(): String = startPosition + fingers + arrows.joinToString("-") + + private fun matchKey(cfg: GestureConfig): String? { + val input = fingers + arrows.joinToString("-") + val withStart = startPosition + input + return when { + cfg.activeGestureKeys.contains(withStart) -> withStart + cfg.activeGestureKeys.contains(input) -> input + else -> null + } + } + + private fun computeStartPosition(x: Float, y: Float, w: Int): String { + return when { + x < edgeWidth -> "L:" + x > w - edgeWidth -> "R:" + y < edgeWidth -> "T:" + y > contentBottom - edgeWidth -> "B:" + x < w / 2f -> "W:" + else -> "E:" + } + } + + private fun reset() { + arrows.clear() + startPosition = "" + fingers = "" + fingersNum = 1 + lastArrow = ' ' + aborted = false + active = false + } + + companion object { + /** Maximum number of direction strokes per gesture. */ + private const val MAX_ARROWS = 9 + + /** Reference short-side length the base stroke size is calibrated for. */ + private const val REFERENCE_SHORT_SIDE = 320f + + /** Edge-zone width is `min(width, height) / EDGE_DIVISOR`. */ + private const val EDGE_DIVISOR = 10f + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt index 3dbecd6f..64ffe964 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt @@ -5572,6 +5572,81 @@ data class SandboxCaptureEntry ( return result } } + +/** + * Configuration for native touch-gesture recognition. + * + * Pushed from Dart whenever the user's gesture settings change. Native + * recognition is purely observational: it assembles a canonical stroke key + * (start-position prefix + finger-count prefix + dash-joined directions, e.g. + * `R:2:D-L`) and only emits when that key matches an entry in + * [activeGestureKeys]. Strokes that do not match are ignored, so normal + * scrolling, tapping and pinch-zoom are never affected. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class GestureConfig ( + val enabled: Boolean, + /** + * Base stroke length in logical pixels, scaled at runtime by + * `min(viewWidth, viewHeight) / 320` to match the reference gesture add-on. + */ + val strokeSize: Long, + /** + * Milliseconds of inactivity after which an in-progress gesture is + * discarded. + */ + val timeoutMs: Long, + /** Maximum number of simultaneous pointers a gesture may use. */ + val maxFingers: Long, + /** + * Canonical keys that currently have an action bound, e.g. `D-R`, + * `R:2:D-L`. Native only emits [GeckoGestureEvents.onGestureRecognized] + * when an assembled stroke matches one of these. + */ + val activeGestureKeys: List +) + { + companion object { + fun fromList(pigeonVar_list: List): GestureConfig { + val enabled = pigeonVar_list[0] as Boolean + val strokeSize = pigeonVar_list[1] as Long + val timeoutMs = pigeonVar_list[2] as Long + val maxFingers = pigeonVar_list[3] as Long + val activeGestureKeys = pigeonVar_list[4] as List + return GestureConfig(enabled, strokeSize, timeoutMs, maxFingers, activeGestureKeys) + } + } + fun toList(): List { + return listOf( + enabled, + strokeSize, + timeoutMs, + maxFingers, + activeGestureKeys, + ) + } + override fun equals(other: Any?): Boolean { + if (other == null || other.javaClass != javaClass) { + return false + } + if (this === other) { + return true + } + val other = other as GestureConfig + return GeckoPigeonUtils.deepEquals(this.enabled, other.enabled) && GeckoPigeonUtils.deepEquals(this.strokeSize, other.strokeSize) && GeckoPigeonUtils.deepEquals(this.timeoutMs, other.timeoutMs) && GeckoPigeonUtils.deepEquals(this.maxFingers, other.maxFingers) && GeckoPigeonUtils.deepEquals(this.activeGestureKeys, other.activeGestureKeys) + } + + override fun hashCode(): Int { + var result = javaClass.hashCode() + result = 31 * result + GeckoPigeonUtils.deepHash(this.enabled) + result = 31 * result + GeckoPigeonUtils.deepHash(this.strokeSize) + result = 31 * result + GeckoPigeonUtils.deepHash(this.timeoutMs) + result = 31 * result + GeckoPigeonUtils.deepHash(this.maxFingers) + result = 31 * result + GeckoPigeonUtils.deepHash(this.activeGestureKeys) + return result + } +} private open class GeckoPigeonCodec : StandardMessageCodec() { override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { return when (type) { @@ -6190,6 +6265,11 @@ private open class GeckoPigeonCodec : StandardMessageCodec() { SandboxCaptureEntry.fromList(it) } } + 252.toByte() -> { + return (readValue(buffer) as? List)?.let { + GestureConfig.fromList(it) + } + } else -> super.readValueOfType(type, buffer) } } @@ -6687,6 +6767,10 @@ private open class GeckoPigeonCodec : StandardMessageCodec() { stream.write(251) writeValue(stream, value.toList()) } + is GestureConfig -> { + stream.write(252) + writeValue(stream, value.toList()) + } else -> super.writeValue(stream, value) } } @@ -11829,3 +11913,124 @@ class SandboxCaptureHostEvents(private val binaryMessenger: BinaryMessenger, pri } } } +/** + * Dart → Kotlin. Pushes the current gesture-recognition configuration. + * + * Generated interface from Pigeon that represents a handler of messages from Flutter. + */ +interface GeckoGestureApi { + fun setGestureConfig(config: GestureConfig) + + companion object { + /** The codec used by GeckoGestureApi. */ + val codec: MessageCodec by lazy { + GeckoPigeonCodec() + } + /** Sets up an instance of `GeckoGestureApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: GeckoGestureApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoGestureApi.setGestureConfig$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val configArg = args[0] as GestureConfig + val wrapped: List = try { + api.setGestureConfig(configArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} +/** + * Kotlin → Dart. Emitted when an assembled touch stroke matches a configured + * gesture key. + * + * Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. + */ +class GeckoGestureEvents(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") { + companion object { + /** The codec used by GeckoGestureEvents. */ + val codec: MessageCodec by lazy { + GeckoPigeonCodec() + } + } + /** + * [sequence] Event sequence number for ordering. + * [gestureKey] Canonical key of the recognized gesture, e.g. `D-R`. + */ + fun onGestureRecognized(sequenceArg: Long, gestureKeyArg: String, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoGestureEvents.onGestureRecognized$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(sequenceArg, gestureKeyArg)) { + 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))) + } + } + } + /** + * Emitted while a stroke is being drawn, each time a new direction arrow is + * appended. Drives the live feedback overlay. + * + * [sequence] Event sequence number for ordering. + * [partialKey] Current partial canonical key including start/finger + * prefixes, e.g. `R:D`. + */ + fun onGestureProgress(sequenceArg: Long, partialKeyArg: String, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoGestureEvents.onGestureProgress$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(sequenceArg, partialKeyArg)) { + 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))) + } + } + } + /** + * Emitted when an in-progress stroke ends (release, cancel or idle timeout) + * so the live feedback overlay can be hidden. + * + * [sequence] Event sequence number for ordering. + */ + fun onGestureReset(sequenceArg: Long, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoGestureEvents.onGestureReset$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(sequenceArg)) { + 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))) + } + } + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/widget/BackGestureFilterFrameLayout.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/widget/BackGestureFilterFrameLayout.kt index 93f6c7c5..8900e2c1 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/widget/BackGestureFilterFrameLayout.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/widget/BackGestureFilterFrameLayout.kt @@ -12,6 +12,9 @@ import android.content.Context import android.view.MotionEvent import android.widget.FrameLayout import androidx.core.view.WindowInsetsCompat +import eu.weblibre.flutter_mozilla_components.GlobalComponents +import eu.weblibre.flutter_mozilla_components.ext.EventSequence +import eu.weblibre.flutter_mozilla_components.feature.GestureRecognizer import kotlin.math.abs /** @@ -34,6 +37,12 @@ import kotlin.math.abs * * Taps and vertical drags that originate in the inset still reach the engine * view, so links and vertical scrolling continue to work at the edges. + * + * This container is also the single observation point for configurable touch + * gestures: every event is fed to a [GestureRecognizer] before the back-gesture + * filter runs. Recognition is purely observational (events are never consumed + * for gestures), so pages scroll and tap normally; a recognized multi-stroke + * gesture is reported to Dart on touch-up via [GlobalComponents.gestureEvents]. */ class BackGestureFilterFrameLayout( context: Context, @@ -44,7 +53,35 @@ class BackGestureFilterFrameLayout( private var startedInEdgeZone = false private var hasIntercepted = false + private val gestureRecognizer = GestureRecognizer() + private val gestureTimeoutRunnable = Runnable { cancelGesture() } + + /** + * Whether the live feedback overlay is currently being shown for an + * in-progress stroke. Tracked so a reset is reported to Dart only after a + * progress event, keeping plain taps and scrolls off the platform channel. + */ + private var gestureFeedbackActive = false + + init { + // A new direction arrow was registered: restart the idle-timeout (the + // reference add-on restarts only on a new arrow, not on every move) and + // forward the partial stroke to the live feedback overlay. + gestureRecognizer.onProgress = { partialKey -> + val config = GlobalComponents.gestureConfig + if (config != null && config.enabled) { + removeCallbacks(gestureTimeoutRunnable) + postDelayed(gestureTimeoutRunnable, config.timeoutMs) + gestureFeedbackActive = true + GlobalComponents.gestureEvents + ?.onGestureProgress(EventSequence.next(), partialKey) { } + } + } + } + override fun dispatchTouchEvent(ev: MotionEvent): Boolean { + feedGestureRecognizer(ev) + when (ev.actionMasked) { MotionEvent.ACTION_DOWN -> { downX = ev.x @@ -68,6 +105,9 @@ class BackGestureFilterFrameLayout( // already scrolled the page. Cancel APZ now and swallow the rest. dx > dy + 1 -> { hasIntercepted = true + // The stream now belongs to the system back gesture; + // discard any partial touch gesture so it can't fire. + cancelGesture() // Log.d(TAG, "INTERCEPT dx=$dx dy=$dy") dispatchSyntheticCancel(ev) return true @@ -93,6 +133,69 @@ class BackGestureFilterFrameLayout( return super.dispatchTouchEvent(ev) } + /** + * Observes every touch event for configurable gestures. Reads the latest + * config pushed from Dart, drives the idle-timeout, and reports a matched + * gesture key on touch-up. Runs on the UI thread (dispatchTouchEvent), so + * the event sink can be invoked directly. + */ + private fun feedGestureRecognizer(ev: MotionEvent) { + val config = GlobalComponents.gestureConfig + gestureRecognizer.config = config + + val key = gestureRecognizer.feed( + ev, + width, + height, + GlobalComponents.bottomViewportInsetPx, + ) + + when (ev.actionMasked) { + // Start the idle window on touch-down; subsequent restarts happen + // only when the recognizer reports a new arrow (see onProgress). + MotionEvent.ACTION_DOWN -> { + // New touch: clear any gesture claim so pull-to-refresh is only + // suppressed when this stroke actually matches a gesture. + GlobalComponents.touchConsumedByGesture = false + if (config != null && config.enabled) { + removeCallbacks(gestureTimeoutRunnable) + postDelayed(gestureTimeoutRunnable, config.timeoutMs) + } + } + + MotionEvent.ACTION_UP, + MotionEvent.ACTION_CANCEL -> { + removeCallbacks(gestureTimeoutRunnable) + emitGestureReset() + } + } + + if (key != null) { + // Mark this touch as gesture-handled before the event propagates to + // the swipe-refresh layout's own ACTION_UP handling, so a redundant + // pull-to-refresh reload is suppressed (see + // GestureAwareSwipeRefreshFeature). + GlobalComponents.touchConsumedByGesture = true + GlobalComponents.gestureEvents + ?.onGestureRecognized(EventSequence.next(), key) { } + } + } + + /** Discards any in-progress gesture and clears its feedback overlay. */ + private fun cancelGesture() { + removeCallbacks(gestureTimeoutRunnable) + gestureRecognizer.cancel() + emitGestureReset() + } + + /** Tells Dart to hide the live feedback overlay, if one is showing. */ + private fun emitGestureReset() { + if (!gestureFeedbackActive) return + gestureFeedbackActive = false + GlobalComponents.gestureEvents + ?.onGestureReset(EventSequence.next()) { } + } + private fun dispatchSyntheticCancel(source: MotionEvent) { val cancel = MotionEvent.obtain(source).apply { action = MotionEvent.ACTION_CANCEL diff --git a/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart b/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart index 7b816e62..bdc7f500 100644 --- a/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart +++ b/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart @@ -20,6 +20,7 @@ export 'src/domain/services/gecko_engine_settings.dart'; export 'src/domain/services/gecko_event.dart'; export 'src/domain/services/gecko_fetch_service.dart'; export 'src/domain/services/gecko_find_in_page.dart'; +export 'src/domain/services/gecko_gesture.dart'; export 'src/domain/services/gecko_history.dart'; export 'src/domain/services/gecko_icon.dart'; export 'src/domain/services/gecko_logging.dart'; @@ -80,6 +81,7 @@ export 'src/pigeons/gecko.g.dart' GeckoSuggestionType, GeckoTrackingProtectionApi, GeoHitResult, + GestureConfig, HistoryHighlight, HistoryHighlightWeights, HistoryMetadata, diff --git a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_gesture.dart b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_gesture.dart new file mode 100644 index 00000000..3faf80a0 --- /dev/null +++ b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_gesture.dart @@ -0,0 +1,98 @@ +/* + * 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/. + */ + +import 'package:flutter/services.dart'; +import 'package:flutter_mozilla_components/src/extensions/subject.dart'; +import 'package:flutter_mozilla_components/src/pigeons/gecko.g.dart'; +import 'package:rxdart/rxdart.dart'; + +/// Service for native touch-gesture recognition. +/// +/// Pushes the user's gesture configuration to the native recognizer via +/// [setGestureConfig] and exposes a stream of recognized gesture keys. The +/// native side is purely observational (it never consumes touch input for +/// gestures), so recognized events fire on touch release for multi-stroke +/// gestures only. +/// +/// A gesture key is the canonical encoding shared with native: an optional +/// start-position prefix (`L:`/`R:`/`T:`/`B:`/`W:`/`E:`), an optional +/// finger-count prefix (`2:` …), and the dash-joined directions (`U`/`D`/`L`/ +/// `R`), e.g. `R:2:D-L` or `D-R`. +class GeckoGestureService extends GeckoGestureEvents { + final GeckoGestureApi _api; + + final _recognizedGestureSubject = PublishSubject(); + + final _gestureProgressSubject = BehaviorSubject.seeded(null); + + /// Stream of recognized gesture keys. + /// + /// Emits the canonical key (e.g. `D-R`) each time the native recognizer + /// matches a configured gesture. + Stream get recognizedGestures => _recognizedGestureSubject.stream; + + /// Stream of the in-progress stroke for the live feedback overlay. + /// + /// Emits the current partial canonical key (e.g. `R:D`) each time a new + /// arrow is drawn, and `null` when the stroke ends (release, cancel or idle + /// timeout). Consumers render the overlay while non-null. + Stream get gestureProgress => _gestureProgressSubject.stream; + + /// Creates a new gesture service. + /// + /// Call [setUp] to register the event handlers after construction. + GeckoGestureService({ + BinaryMessenger? binaryMessenger, + String messageChannelSuffix = '', + }) : _api = GeckoGestureApi( + binaryMessenger: binaryMessenger, + messageChannelSuffix: messageChannelSuffix, + ); + + /// Sets up the service to receive events from native. + /// + /// Must be called before events will be received. + void setUp({ + BinaryMessenger? binaryMessenger, + String messageChannelSuffix = '', + }) { + GeckoGestureEvents.setUp( + this, + binaryMessenger: binaryMessenger, + messageChannelSuffix: messageChannelSuffix, + ); + } + + /// Pushes the gesture-recognition configuration to native. + /// + /// Call whenever the user's gesture settings change. + Future setGestureConfig(GestureConfig config) async { + await _api.setGestureConfig(config); + } + + // GeckoGestureEvents implementation + + @override + void onGestureRecognized(int sequence, String gestureKey) { + _recognizedGestureSubject.addWhenMoreRecent(sequence, null, gestureKey); + } + + @override + void onGestureProgress(int sequence, String partialKey) { + _gestureProgressSubject.addWhenMoreRecent(sequence, null, partialKey); + } + + @override + void onGestureReset(int sequence) { + _gestureProgressSubject.addWhenMoreRecent(sequence, null, null); + } + + /// Disposes the service and closes all streams. + Future dispose() async { + await _recognizedGestureSubject.close(); + await _gestureProgressSubject.close(); + } +} diff --git a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart index 2cfd21e3..c609944b 100644 --- a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart +++ b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart @@ -6098,6 +6098,87 @@ class SandboxCaptureEntry { int get hashCode => _deepHash([runtimeType, ..._toList()]); } +/// Configuration for native touch-gesture recognition. +/// +/// Pushed from Dart whenever the user's gesture settings change. Native +/// recognition is purely observational: it assembles a canonical stroke key +/// (start-position prefix + finger-count prefix + dash-joined directions, e.g. +/// `R:2:D-L`) and only emits when that key matches an entry in +/// [activeGestureKeys]. Strokes that do not match are ignored, so normal +/// scrolling, tapping and pinch-zoom are never affected. +class GestureConfig { + GestureConfig({ + required this.enabled, + required this.strokeSize, + required this.timeoutMs, + required this.maxFingers, + required this.activeGestureKeys, + }); + + bool enabled; + + /// Base stroke length in logical pixels, scaled at runtime by + /// `min(viewWidth, viewHeight) / 320` to match the reference gesture add-on. + int strokeSize; + + /// Milliseconds of inactivity after which an in-progress gesture is + /// discarded. + int timeoutMs; + + /// Maximum number of simultaneous pointers a gesture may use. + int maxFingers; + + /// Canonical keys that currently have an action bound, e.g. `D-R`, + /// `R:2:D-L`. Native only emits [GeckoGestureEvents.onGestureRecognized] + /// when an assembled stroke matches one of these. + List activeGestureKeys; + + List _toList() { + return [ + enabled, + strokeSize, + timeoutMs, + maxFingers, + activeGestureKeys, + ]; + } + + Object encode() { + return _toList(); + } + + static GestureConfig decode(Object result) { + result as List; + return GestureConfig( + enabled: result[0]! as bool, + strokeSize: result[1]! as int, + timeoutMs: result[2]! as int, + maxFingers: result[3]! as int, + activeGestureKeys: (result[4]! as List).cast(), + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! GestureConfig || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(enabled, other.enabled) && + _deepEquals(strokeSize, other.strokeSize) && + _deepEquals(timeoutMs, other.timeoutMs) && + _deepEquals(maxFingers, other.maxFingers) && + _deepEquals(activeGestureKeys, other.activeGestureKeys); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); +} + class _PigeonCodec extends StandardMessageCodec { const _PigeonCodec(); @override @@ -6474,6 +6555,9 @@ class _PigeonCodec extends StandardMessageCodec { } else if (value is SandboxCaptureEntry) { buffer.putUint8(251); writeValue(buffer, value.encode()); + } else if (value is GestureConfig) { + buffer.putUint8(252); + writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); } @@ -6769,6 +6853,8 @@ class _PigeonCodec extends StandardMessageCodec { return PwaManifest.decode(readValue(buffer)!); case 251: return SandboxCaptureEntry.decode(readValue(buffer)!); + case 252: + return GestureConfig.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); } @@ -12745,3 +12831,153 @@ abstract class SandboxCaptureHostEvents { } } } + +/// Dart → Kotlin. Pushes the current gesture-recognition configuration. +class GeckoGestureApi { + /// Constructor for [GeckoGestureApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + GeckoGestureApi({ + BinaryMessenger? binaryMessenger, + String messageChannelSuffix = '', + }) : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty + ? '.$messageChannelSuffix' + : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + Future setGestureConfig(GestureConfig config) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.flutter_mozilla_components.GeckoGestureApi.setGestureConfig$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [config], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ); + } +} + +/// Kotlin → Dart. Emitted when an assembled touch stroke matches a configured +/// gesture key. +abstract class GeckoGestureEvents { + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + /// [sequence] Event sequence number for ordering. + /// [gestureKey] Canonical key of the recognized gesture, e.g. `D-R`. + void onGestureRecognized(int sequence, String gestureKey); + + /// Emitted while a stroke is being drawn, each time a new direction arrow is + /// appended. Drives the live feedback overlay. + /// + /// [sequence] Event sequence number for ordering. + /// [partialKey] Current partial canonical key including start/finger + /// prefixes, e.g. `R:D`. + void onGestureProgress(int sequence, String partialKey); + + /// Emitted when an in-progress stroke ends (release, cancel or idle timeout) + /// so the live feedback overlay can be hidden. + /// + /// [sequence] Event sequence number for ordering. + void onGestureReset(int sequence); + + static void setUp( + GeckoGestureEvents? api, { + BinaryMessenger? binaryMessenger, + String messageChannelSuffix = '', + }) { + messageChannelSuffix = messageChannelSuffix.isNotEmpty + ? '.$messageChannelSuffix' + : ''; + { + final pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.flutter_mozilla_components.GeckoGestureEvents.onGestureRecognized$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger, + ); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + final List args = message! as List; + final int arg_sequence = args[0]! as int; + final String arg_gestureKey = args[1]! as String; + try { + api.onGestureRecognized(arg_sequence, arg_gestureKey); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse( + error: PlatformException(code: 'error', message: e.toString()), + ); + } + }); + } + } + { + final pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.flutter_mozilla_components.GeckoGestureEvents.onGestureProgress$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger, + ); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + final List args = message! as List; + final int arg_sequence = args[0]! as int; + final String arg_partialKey = args[1]! as String; + try { + api.onGestureProgress(arg_sequence, arg_partialKey); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse( + error: PlatformException(code: 'error', message: e.toString()), + ); + } + }); + } + } + { + final pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.flutter_mozilla_components.GeckoGestureEvents.onGestureReset$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger, + ); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + final List args = message! as List; + final int arg_sequence = args[0]! as int; + try { + api.onGestureReset(arg_sequence); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse( + error: PlatformException(code: 'error', message: e.toString()), + ); + } + }); + } + } + } +} diff --git a/packages/flutter_mozilla_components/pigeons/gecko.dart b/packages/flutter_mozilla_components/pigeons/gecko.dart index db9274e5..53d50416 100644 --- a/packages/flutter_mozilla_components/pigeons/gecko.dart +++ b/packages/flutter_mozilla_components/pigeons/gecko.dart @@ -3004,3 +3004,72 @@ abstract class SandboxCaptureHostEvents { String targetUrl, ); } + +// ============================================================================= +// Touch Gestures API +// ============================================================================= + +/// Configuration for native touch-gesture recognition. +/// +/// Pushed from Dart whenever the user's gesture settings change. Native +/// recognition is purely observational: it assembles a canonical stroke key +/// (start-position prefix + finger-count prefix + dash-joined directions, e.g. +/// `R:2:D-L`) and only emits when that key matches an entry in +/// [activeGestureKeys]. Strokes that do not match are ignored, so normal +/// scrolling, tapping and pinch-zoom are never affected. +class GestureConfig { + final bool enabled; + + /// Base stroke length in logical pixels, scaled at runtime by + /// `min(viewWidth, viewHeight) / 320` to match the reference gesture add-on. + final int strokeSize; + + /// Milliseconds of inactivity after which an in-progress gesture is + /// discarded. + final int timeoutMs; + + /// Maximum number of simultaneous pointers a gesture may use. + final int maxFingers; + + /// Canonical keys that currently have an action bound, e.g. `D-R`, + /// `R:2:D-L`. Native only emits [GeckoGestureEvents.onGestureRecognized] + /// when an assembled stroke matches one of these. + final List activeGestureKeys; + + GestureConfig({ + this.enabled = false, + this.strokeSize = 50, + this.timeoutMs = 1500, + this.maxFingers = 1, + this.activeGestureKeys = const [], + }); +} + +/// Dart → Kotlin. Pushes the current gesture-recognition configuration. +@HostApi() +abstract class GeckoGestureApi { + void setGestureConfig(GestureConfig config); +} + +/// Kotlin → Dart. Emitted when an assembled touch stroke matches a configured +/// gesture key. +@FlutterApi() +abstract class GeckoGestureEvents { + /// [sequence] Event sequence number for ordering. + /// [gestureKey] Canonical key of the recognized gesture, e.g. `D-R`. + void onGestureRecognized(int sequence, String gestureKey); + + /// Emitted while a stroke is being drawn, each time a new direction arrow is + /// appended. Drives the live feedback overlay. + /// + /// [sequence] Event sequence number for ordering. + /// [partialKey] Current partial canonical key including start/finger + /// prefixes, e.g. `R:D`. + void onGestureProgress(int sequence, String partialKey); + + /// Emitted when an in-progress stroke ends (release, cancel or idle timeout) + /// so the live feedback overlay can be hidden. + /// + /// [sequence] Event sequence number for ordering. + void onGestureReset(int sequence); +}