gesture feature initial

This commit is contained in:
Fabian Freund
2026-05-31 11:40:57 +02:00
parent 4aecb8966c
commit 4d4c8a8786
45 changed files with 4729 additions and 5 deletions
@@ -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';
@@ -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<T?> push<T>(BuildContext context) => context.push<T>(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();
@@ -31,6 +31,10 @@ part of 'routes.dart';
name: 'BrowsingSettingsRoute',
path: 'browsing',
),
TypedGoRoute<GestureSettingsRoute>(
name: 'GestureSettingsRoute',
path: 'gestures',
),
TypedGoRoute<PrivacySecuritySettingsRoute>(
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
@@ -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<bool> waitUntilReady({
@@ -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<GeckoGestureService> {
GestureServiceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'gestureServiceProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$gestureServiceHash();
@$internal
@override
$ProviderElement<GeckoGestureService> $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<GeckoGestureService>(value),
);
}
}
String _$gestureServiceHash() => r'81e90a1adb64f596190b52aba0e2ab9dcab3c5e7';
@ProviderFor(EngineReadyState)
final engineReadyStateProvider = EngineReadyStateProvider._();
@@ -43,5 +43,6 @@ enum ToolbarButtonId {
pageDown,
font,
extensionShortcut,
toggleGestures,
quit,
}
@@ -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,
];
@@ -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<ToolbarButtonDefinition> 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',
@@ -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 {
@@ -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<BrowserView>
),
),
if (showHome) const Positioned.fill(child: BrowserHome()),
const Positioned.fill(child: GestureFeedbackOverlay()),
],
),
);
@@ -610,6 +613,19 @@ class _BrowserViewState extends ConsumerState<BrowserView>
},
);
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,
@@ -42,7 +42,7 @@ final class GeckoInferenceRepositoryProvider
}
String _$geckoInferenceRepositoryHash() =>
r'c4f52d2edfb8c5763577a17697202016645177da';
r'cd6d47ccb5aa8d64aba81ac1d982b1df8cf64d14';
abstract class _$GeckoInferenceRepository extends $Notifier<void> {
void build();
@@ -389,7 +389,7 @@ final class ContainerTabSuggestionsProvider
}
String _$containerTabSuggestionsHash() =>
r'958c7432aa5c2a2828fcccd0127e0110d0a008cf';
r'a29a43773000b415f3e8a23db281078c30c3b10d';
final class ContainerTabSuggestionsFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<List<String>?>, String?> {
@@ -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 <http://www.gnu.org/licenses/>.
*/
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);
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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 = <String, GestureAction>{
'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<String> excludedSites;
/// Canonical gesture key → action. Keys follow the grammar documented on
/// [GestureStroke].
final Map<String, GestureAction> 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<String>? excludedSites,
Map<String, GestureAction>? 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<String, dynamic> json) =>
_$GestureSettingsFromJson(json);
Map<String, dynamic> toJson() => _$GestureSettingsToJson(this);
@override
List<Object?> 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<String> 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;
}
@@ -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<String> excludedSites);
GestureSettings bindings(Map<String, GestureAction> 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<String> excludedSites,
Map<String, GestureAction> 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<String> excludedSites) =>
call(excludedSites: excludedSites);
@override
GestureSettings bindings(Map<String, GestureAction> 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<String>,
bindings: bindings == const $CopyWithPlaceholder() || bindings == null
? _value.bindings
// ignore: cast_nullable_to_non_nullable
: bindings as Map<String, GestureAction>,
);
}
}
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<String, dynamic> 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<dynamic>?)
?.map((e) => e as String)
.toList(),
bindings: (json['bindings'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, $enumDecode(_$GestureActionEnumMap, e)),
),
);
Map<String, dynamic> _$GestureSettingsToJson(GestureSettings instance) =>
<String, dynamic>{
'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',
};
@@ -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 <http://www.gnu.org/licenses/>.
*/
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: `<start-prefix><finger-prefix><arrows joined by '-'>`, 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<GestureArrow> 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
? <GestureArrow>[]
: 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 = <String>[
if (startPosition != GestureStartPosition.anywhere) startPosition.label,
if (fingers >= 2) '$fingers fingers',
arrows.map((arrow) => arrow.symbol).join(),
];
return parts.join(' · ');
}
@override
List<Object?> get hashParameters => [startPosition, fingers, arrows];
}
@@ -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<GestureArrow> 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<GestureArrow> 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<GestureArrow> 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<GestureArrow>,
);
}
}
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);
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<MapEntry<String, DriftAny?>> 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<GestureSettings> fetchSettings() {
return ref
.read(userDatabaseProvider)
.settingDao
.getAllSettingsOfPartitionKey(_partitionKey)
.get()
.then(_deserializeSettings);
}
Future<void> 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<GestureSettings> 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(),
),
);
}
@@ -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<GestureSettingsRepository, GestureSettings> {
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<GestureSettings> {
Stream<GestureSettings> build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<AsyncValue<GestureSettings>, GestureSettings>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<GestureSettings>, GestureSettings>,
AsyncValue<GestureSettings>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@ProviderFor(gestureSettingsWithDefaults)
final gestureSettingsWithDefaultsProvider =
GestureSettingsWithDefaultsProvider._();
final class GestureSettingsWithDefaultsProvider
extends
$FunctionalProvider<GestureSettings, GestureSettings, GestureSettings>
with $Provider<GestureSettings> {
GestureSettingsWithDefaultsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'gestureSettingsWithDefaultsProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$gestureSettingsWithDefaultsHash();
@$internal
@override
$ProviderElement<GestureSettings> $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<GestureSettings>(value),
);
}
}
String _$gestureSettingsWithDefaultsHash() =>
r'f9f242b81ef9d4d0594ae1700fa11db503583672';
@@ -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 <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'dart:math';
import 'package:flutter/widgets.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/core/providers/router.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/geckoview/domain/providers.dart';
import 'package:weblibre/features/geckoview/domain/providers/desktop_mode.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/utils/bookmark_tree_utils.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/entities/font_size_constants.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/translation_bottom_sheet.dart';
import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart';
import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
import 'package:weblibre/features/gestures/data/models/gesture_action.dart';
import 'package:weblibre/features/gestures/data/models/gesture_settings.dart';
import 'package:weblibre/features/gestures/data/models/gesture_stroke.dart';
import 'package:weblibre/features/gestures/domain/repositories/gesture_settings.dart';
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
import 'package:weblibre/features/user/data/models/engine_settings.dart';
import 'package:weblibre/features/user/domain/presentation/dialogs/quit_browser_dialog.dart';
import 'package:weblibre/features/user/domain/repositories/engine_settings.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
import 'package:weblibre/utils/exit_app.dart';
import 'package:weblibre/utils/move_to_background.dart';
part 'gesture_control.g.dart';
/// Bridges gesture settings and recognized-gesture events to app actions.
///
/// On build it (1) keeps the native recognizer's [GestureConfig] in sync with
/// the user's [GestureSettings], and (2) subscribes to recognized gestures and
/// dispatches the bound [GestureAction] against the currently selected tab.
///
/// Must be kept alive (eagerly listened to from the browser view) for the
/// lifetime of the browser so the subscription stays active.
@Riverpod(keepAlive: true)
class GestureControlService extends _$GestureControlService {
/// Timestamp (ms since epoch) of the last fired gesture, for cooldown.
int _lastDispatchMs = 0;
@override
void build() {
final service = ref.read(gestureServiceProvider);
// Keep the native recognizer in sync with the effective configuration
// (settings folded together with the current site's exclusion state).
ref.listen(
fireImmediately: true,
gestureNativeConfigProvider,
(previous, next) async {
await service.setGestureConfig(next);
},
onError: (error, stackTrace) {
logger.e(
'Error syncing gesture configuration',
error: error,
stackTrace: stackTrace,
);
},
);
final subscription = service.recognizedGestures.listen(
(gestureKey) {
unawaited(_dispatch(gestureKey));
},
onError: (Object error, StackTrace stackTrace) {
logger.e(
'Error handling recognized gesture',
error: error,
stackTrace: stackTrace,
);
},
);
ref.onDispose(subscription.cancel);
}
Future<void> _dispatch(String gestureKey) async {
final settings = ref.read(gestureSettingsWithDefaultsProvider);
final action = settings.bindings[gestureKey];
if (action == null) return;
// Cooldown: ignore gestures fired within intervalMs of the previous one.
final now = DateTime.now().millisecondsSinceEpoch;
if (settings.intervalMs > 0 &&
now - _lastDispatchMs < settings.intervalMs) {
return;
}
_lastDispatchMs = now;
final tabId = ref.read(selectedTabProvider);
if (tabId == null) return;
try {
// Feedback is handled live by the gesture overlay while the stroke is
// drawn (driven by the native progress events), so nothing to show here.
await _execute(action, tabId);
} catch (error, stackTrace) {
logger.e(
'Error executing gesture action ${action.name}',
error: error,
stackTrace: stackTrace,
);
}
}
Future<void> _execute(GestureAction action, String tabId) async {
final tabRepository = ref.read(tabRepositoryProvider.notifier);
switch (action) {
case GestureAction.back:
await ref.read(tabSessionProvider(tabId: tabId).notifier).goBack();
case GestureAction.forward:
await ref.read(tabSessionProvider(tabId: tabId).notifier).goForward();
case GestureAction.reload:
await ref.read(tabSessionProvider(tabId: tabId).notifier).reload();
case GestureAction.scrollTop:
await ref.read(tabSessionProvider(tabId: tabId).notifier).scrollToTop();
case GestureAction.scrollBottom:
await ref
.read(tabSessionProvider(tabId: tabId).notifier)
.scrollToBottom();
case GestureAction.pageUp:
await ref.read(tabSessionProvider(tabId: tabId).notifier).pageUp();
case GestureAction.pageDown:
await ref.read(tabSessionProvider(tabId: tabId).notifier).pageDown();
case GestureAction.newTab:
await _openNewTab(tabId);
case GestureAction.closeTab:
await tabRepository.closeTab(tabId);
case GestureAction.duplicateTab:
final containerData = await ref
.read(tabDataRepositoryProvider.notifier)
.getTabContainerData(tabId);
await tabRepository.duplicateTab(
selectTabId: tabId,
containerData: containerData,
selectTab: true,
);
case GestureAction.nextTab:
await tabRepository.selectNextTab(tabId);
case GestureAction.previousTab:
await tabRepository.selectPreviousTab(tabId);
case GestureAction.lastUsedTab:
await tabRepository.selectPreviouslyOpenedTab(tabId);
case GestureAction.togglePinTab:
final pinned =
ref.read(watchPinnedTabIdsProvider).value?.contains(tabId) ?? false;
await ref
.read(tabDataRepositoryProvider.notifier)
.setPinned(tabId, pinned: !pinned);
case GestureAction.toggleReaderMode:
final readerActive =
ref.read(tabStateProvider(tabId))?.readerableState.active ?? false;
await ref
.read(readerableScreenControllerProvider.notifier)
.toggleReaderView(!readerActive);
case GestureAction.toggleDesktopMode:
ref.read(desktopModeProvider(tabId).notifier).toggle();
case GestureAction.findInPage:
ref.read(findInPageControllerProvider(tabId).notifier).show();
case GestureAction.increaseFontSize:
await _adjustFontSize(increase: true);
case GestureAction.decreaseFontSize:
await _adjustFontSize(increase: false);
case GestureAction.showHistory:
await _pushLocation(const HistoryRoute().location);
case GestureAction.showBookmarks:
await _pushLocation(
BookmarkListRoute(entryGuid: BookmarkRoot.root.id).location,
);
case GestureAction.toggleBookmark:
await _toggleBookmark(tabId);
case GestureAction.translatePage:
final context = await _navigatorContext();
if (context != null && context.mounted) {
await showTranslationBottomSheet(context, selectedTabId: tabId);
}
case GestureAction.moveToBackground:
await moveToBackground();
case GestureAction.quitBrowser:
final context = await _navigatorContext();
if (context != null && context.mounted) {
final confirmed = await showQuitBrowserDialog(context);
if (confirmed == true && context.mounted) {
await exitApp(ProviderScope.containerOf(context, listen: false));
}
}
}
}
/// Adds the current page to bookmarks, or removes it if already bookmarked,
/// mirroring the contextual toolbar's bookmark toggle button.
Future<void> _toggleBookmark(String tabId) async {
final tabState = ref.read(tabStateProvider(tabId));
if (tabState == null) return;
final bookmarkUrl =
ref.read(sandboxSourceUriForTabProvider(tabId: tabId)) ?? tabState.url;
final bookmarks = ref.read(bookmarksRepositoryProvider).value;
final existingGuids = bookmarkGuidsForUrl(bookmarks, bookmarkUrl);
final repository = ref.read(bookmarksRepositoryProvider.notifier);
if (existingGuids.isNotEmpty) {
for (final guid in existingGuids) {
await repository.delete(guid);
}
} else {
await repository.addBookmark(
parentGuid: BookmarkRoot.mobile.id,
url: bookmarkUrl,
title: tabState.titleOrAuthority,
);
}
}
/// Resolves the root navigator's [BuildContext] for actions that open a
/// dialog/sheet. Null when the navigator is not currently mounted.
Future<BuildContext?> _navigatorContext() async {
final router = await ref.read(routerProvider.future);
if (!ref.mounted) return null;
return router.routerDelegate.navigatorKey.currentContext;
}
Future<void> _pushLocation(String location) async {
final router = await ref.read(routerProvider.future);
if (!ref.mounted) return;
await router.push(location);
}
Future<void> _adjustFontSize({required bool increase}) async {
final settings = ref.read(engineSettingsWithDefaultsProvider);
// Manual adjustment is a no-op while automatic font sizing is enabled.
if (settings.automaticFontSizeAdjustment) return;
final current = settings.fontSizeFactor;
final newValue = increase
? (current + fontSizeStep).clamp(fontSizeMin, fontSizeMax)
: (current - fontSizeStep).clamp(fontSizeMin, fontSizeMax);
final rounded = (newValue * 10).round() / 10;
if (rounded == current) return;
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save(
(currentSettings) => currentSettings.copyWith.fontSizeFactor(rounded),
);
}
Future<void> _openNewTab(String tabId) async {
final router = await ref.read(routerProvider.future);
if (!ref.mounted) return;
final settings = ref.read(generalSettingsWithDefaultsProvider);
final selectedTabType = ref
.read(tabStatesProvider)[tabId]
?.tabMode
.toTabType();
final route = SearchRoute(
tabType: selectedTabType ?? settings.effectiveDefaultCreateTabType,
);
await router.push(route.location);
}
}
/// Whether gestures are currently disabled because the selected tab's site is
/// on the user's exclusion list.
@Riverpod(keepAlive: true)
bool gestureSiteExcluded(Ref ref) {
final excludedSites = ref.watch(
gestureSettingsWithDefaultsProvider.select((s) => s.excludedSites),
);
if (excludedSites.isEmpty) return false;
final tabId = ref.watch(selectedTabProvider);
if (tabId == null) return false;
final url = ref.watch(tabStateProvider(tabId).select((state) => state?.url));
if (url == null) return false;
return isGestureSiteExcluded(url, excludedSites);
}
/// The effective native recognizer configuration: the user's settings with the
/// recognizer disabled while the current site is excluded.
@Riverpod(keepAlive: true)
GestureConfig gestureNativeConfig(Ref ref) {
final settings = ref.watch(gestureSettingsWithDefaultsProvider);
final excluded = ref.watch(gestureSiteExcludedProvider);
// Recognize as many fingers as any bound gesture requires, so multi-finger
// bindings work without a separate finger setting.
final requiredFingers = settings.bindings.keys
.map((key) => GestureStroke.fromKey(key).fingers)
.fold(settings.maxFingers, max);
return GestureConfig(
enabled: settings.effectiveEnabled && !excluded,
strokeSize: settings.strokeSize,
timeoutMs: settings.timeoutMs,
maxFingers: requiredFingers,
activeGestureKeys: settings.bindings.keys.toList(),
);
}
/// The in-progress stroke shown by the live feedback overlay.
///
/// Emits the current partial canonical key (e.g. `R:D`) while a stroke is being
/// drawn, and null when nothing should be shown — driven by the native gesture
/// progress/reset events.
@riverpod
Stream<String?> gestureProgress(Ref ref) {
return ref.watch(gestureServiceProvider).gestureProgress;
}
@@ -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<GestureControlService, void> {
/// 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<void>(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> {
void build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<void, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<void, void>,
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<bool, bool, bool>
with $Provider<bool> {
/// 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<bool> $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<bool>(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<GestureConfig, GestureConfig, GestureConfig>
with $Provider<GestureConfig> {
/// 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<GestureConfig> $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<GestureConfig>(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<AsyncValue<String?>, String?, Stream<String?>>
with $FutureModifier<String?>, $StreamProvider<String?> {
/// 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<String?> $createElement($ProviderPointer pointer) =>
$StreamProviderElement(pointer);
@override
Stream<String?> create(Ref ref) {
return gestureProgress(ref);
}
}
String _$gestureProgressHash() => r'2f3ed4a8db41d317869db2e5aa71e737afb5364b';
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<SettingsSectionDefinition> _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.'),
),
],
);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<void> upsertBinding(
({GestureStroke stroke, GestureAction action}) result, {
String? replacedKey,
}) async {
await ref.read(gestureSettingsRepositoryProvider.notifier).updateSettings(
(current) {
final bindings = Map<String, GestureAction>.from(current.bindings);
if (replacedKey != null) {
bindings.remove(replacedKey);
}
bindings[result.stroke.key] = result.action;
return current.copyWith.bindings(bindings);
},
);
}
Future<void> removeBinding(String key) async {
await ref.read(gestureSettingsRepositoryProvider.notifier).updateSettings(
(current) {
final bindings = Map<String, GestureAction>.from(current.bindings)
..remove(key);
return current.copyWith.bindings(bindings);
},
);
}
// Group bindings by their action's category, preserving category order.
final byCategory =
<GestureActionCategory, List<MapEntry<String, GestureAction>>>{};
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<Widget> 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<void> Function() onEdit;
final Future<void> 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(),
);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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),
);
},
),
),
],
);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<SettingsSectionDefinition> _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.'),
),
],
);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<void>(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),
],
);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<GestureAction?> showGestureActionPicker(
BuildContext context, {
required GestureAction selected,
}) {
return showModalBottomSheet<GestureAction>(
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 = <GestureActionCategory, List<GestureAction>>{};
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),
),
],
],
),
),
],
),
),
);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<GestureBindingResult?> showGestureBindingEditor(
BuildContext context, {
GestureStroke? initialStroke,
GestureAction? initialAction,
Map<String, GestureAction> existingBindings = const {},
int maxFingers = 1,
}) {
return Navigator.of(context).push<GestureBindingResult>(
MaterialPageRoute<GestureBindingResult>(
builder: (context) => _GestureBindingEditor(
initialStroke: initialStroke,
initialAction: initialAction,
existingBindings: existingBindings,
maxFingers: maxFingers,
),
),
);
}
class _GestureBindingEditor extends HookWidget {
final GestureStroke? initialStroke;
final GestureAction? initialAction;
final Map<String, GestureAction> 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<List<GestureArrow>>(
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<void> save() async {
if (!stroke.isValid) return;
if (collisionAction != null) {
final confirmed = await showDialog<bool>(
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),
);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<GestureArrow> full, List<GestureArrow> 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,
),
],
),
),
),
],
),
),
),
);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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),
],
);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<String> values;
final ValueChanged<List<String>> 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),
),
),
],
);
}
}
@@ -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',
@@ -167,6 +167,7 @@ class SettingsCustomScrollScaffold extends StatelessWidget {
final TextEditingController? searchController;
final String searchHintText;
final List<Widget> 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,
@@ -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<PromptFeature>()
private val webExtensionPromptFeature = ViewBoundFeatureWrapper<WebExtensionPromptFeature>()
private val sitePermissionsFeature = ViewBoundFeatureWrapper<SitePermissionsFeature>()
private val swipeRefreshFeature = ViewBoundFeatureWrapper<SwipeRefreshFeature>()
private val swipeRefreshFeature = ViewBoundFeatureWrapper<GestureAwareSwipeRefreshFeature>()
private val secureWindowFeature = ViewBoundFeatureWrapper<SecureWindowFeature>()
private val fullScreenFeature = ViewBoundFeatureWrapper<FullScreenFeature>()
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,
@@ -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
@@ -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
@@ -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
}
}
@@ -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) {
@@ -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)
}
}
}
@@ -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<Char>(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
}
}
@@ -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<String>
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): 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<String>
return GestureConfig(enabled, strokeSize, timeoutMs, maxFingers, activeGestureKeys)
}
}
fun toList(): List<Any?> {
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<Any?>)?.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<Any?> 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<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoGestureApi.setGestureConfig$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val configArg = args[0] as GestureConfig
val wrapped: List<Any?> = 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<Any?> 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>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoGestureEvents.onGestureRecognized$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(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>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoGestureEvents.onGestureProgress$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(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>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoGestureEvents.onGestureReset$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(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)))
}
}
}
}
@@ -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
@@ -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,
@@ -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<String>();
final _gestureProgressSubject = BehaviorSubject<String?>.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<String> 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<String?> 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<void> 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<void> dispose() async {
await _recognizedGestureSubject.close();
await _gestureProgressSubject.close();
}
}
@@ -6098,6 +6098,87 @@ class SandboxCaptureEntry {
int get hashCode => _deepHash(<Object?>[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<String> activeGestureKeys;
List<Object?> _toList() {
return <Object?>[
enabled,
strokeSize,
timeoutMs,
maxFingers,
activeGestureKeys,
];
}
Object encode() {
return _toList();
}
static GestureConfig decode(Object result) {
result as List<Object?>;
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<Object?>).cast<String>(),
);
}
@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(<Object?>[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<Object?> pigeonChannelCodec = _PigeonCodec();
final String pigeonVar_messageChannelSuffix;
Future<void> setGestureConfig(GestureConfig config) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.flutter_mozilla_components.GeckoGestureApi.setGestureConfig$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
<Object?>[config],
);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
_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<Object?> 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<Object?>(
'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<Object?> args = message! as List<Object?>;
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<Object?>(
'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<Object?> args = message! as List<Object?>;
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<Object?>(
'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<Object?> args = message! as List<Object?>;
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()),
);
}
});
}
}
}
}
@@ -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<String> 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);
}