gesture feature initial
This commit is contained in:
@@ -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._();
|
||||
|
||||
|
||||
+1
@@ -43,5 +43,6 @@ enum ToolbarButtonId {
|
||||
pageDown,
|
||||
font,
|
||||
extensionShortcut,
|
||||
toggleGestures,
|
||||
quit,
|
||||
}
|
||||
|
||||
+6
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
+30
@@ -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',
|
||||
|
||||
+42
@@ -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 {
|
||||
|
||||
+16
@@ -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,
|
||||
|
||||
+2
-2
@@ -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';
|
||||
+181
@@ -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.'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+214
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
+71
@@ -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),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+158
@@ -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.'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+179
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
+181
@@ -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,
|
||||
|
||||
+3
-3
@@ -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,
|
||||
|
||||
+34
@@ -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
|
||||
|
||||
|
||||
+10
@@ -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
|
||||
|
||||
+22
@@ -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
|
||||
}
|
||||
}
|
||||
+2
@@ -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) {
|
||||
|
||||
+111
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+234
@@ -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
|
||||
}
|
||||
}
|
||||
+205
@@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+103
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user