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_edit.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_list.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/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/onboarding/presentation/onboarding.dart';
|
||||||
import 'package:weblibre/features/proxy/data/models/proxy_profile_seed.dart';
|
import 'package:weblibre/features/proxy/data/models/proxy_profile_seed.dart';
|
||||||
import 'package:weblibre/features/proxy/presentation/screens/proxy_routing_settings.dart';
|
import 'package:weblibre/features/proxy/presentation/screens/proxy_routing_settings.dart';
|
||||||
|
|||||||
@@ -1552,6 +1552,11 @@ RouteBase get $settingsRoute => GoRouteData.$route(
|
|||||||
name: 'BrowsingSettingsRoute',
|
name: 'BrowsingSettingsRoute',
|
||||||
factory: $BrowsingSettingsRoute._fromState,
|
factory: $BrowsingSettingsRoute._fromState,
|
||||||
),
|
),
|
||||||
|
GoRouteData.$route(
|
||||||
|
path: 'gestures',
|
||||||
|
name: 'GestureSettingsRoute',
|
||||||
|
factory: $GestureSettingsRoute._fromState,
|
||||||
|
),
|
||||||
GoRouteData.$route(
|
GoRouteData.$route(
|
||||||
path: 'privacy_security',
|
path: 'privacy_security',
|
||||||
name: 'PrivacySecuritySettingsRoute',
|
name: 'PrivacySecuritySettingsRoute',
|
||||||
@@ -1766,6 +1771,27 @@ mixin $BrowsingSettingsRoute on GoRouteData {
|
|||||||
void replace(BuildContext context) => context.replace(location);
|
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 {
|
mixin $PrivacySecuritySettingsRoute on GoRouteData {
|
||||||
static PrivacySecuritySettingsRoute _fromState(GoRouterState state) =>
|
static PrivacySecuritySettingsRoute _fromState(GoRouterState state) =>
|
||||||
PrivacySecuritySettingsRoute();
|
PrivacySecuritySettingsRoute();
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ part of 'routes.dart';
|
|||||||
name: 'BrowsingSettingsRoute',
|
name: 'BrowsingSettingsRoute',
|
||||||
path: 'browsing',
|
path: 'browsing',
|
||||||
),
|
),
|
||||||
|
TypedGoRoute<GestureSettingsRoute>(
|
||||||
|
name: 'GestureSettingsRoute',
|
||||||
|
path: 'gestures',
|
||||||
|
),
|
||||||
TypedGoRoute<PrivacySecuritySettingsRoute>(
|
TypedGoRoute<PrivacySecuritySettingsRoute>(
|
||||||
name: 'PrivacySecuritySettingsRoute',
|
name: 'PrivacySecuritySettingsRoute',
|
||||||
path: 'privacy_security',
|
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
|
class PrivacySecuritySettingsRoute extends GoRouteData
|
||||||
with $PrivacySecuritySettingsRoute {
|
with $PrivacySecuritySettingsRoute {
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -223,6 +223,18 @@ GeckoViewportService viewportService(Ref ref) {
|
|||||||
return service;
|
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)
|
@Riverpod(keepAlive: true)
|
||||||
class EngineReadyState extends _$EngineReadyState {
|
class EngineReadyState extends _$EngineReadyState {
|
||||||
Future<bool> waitUntilReady({
|
Future<bool> waitUntilReady({
|
||||||
|
|||||||
@@ -293,6 +293,53 @@ final class ViewportServiceProvider
|
|||||||
|
|
||||||
String _$viewportServiceHash() => r'bab39db3180bb6a1cf8c055966b7f5910b41b424';
|
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)
|
@ProviderFor(EngineReadyState)
|
||||||
final engineReadyStateProvider = EngineReadyStateProvider._();
|
final engineReadyStateProvider = EngineReadyStateProvider._();
|
||||||
|
|
||||||
|
|||||||
+1
@@ -43,5 +43,6 @@ enum ToolbarButtonId {
|
|||||||
pageDown,
|
pageDown,
|
||||||
font,
|
font,
|
||||||
extensionShortcut,
|
extensionShortcut,
|
||||||
|
toggleGestures,
|
||||||
quit,
|
quit,
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -169,6 +169,11 @@ const quitToolbarButtonSpec = ToolbarButtonSpec(
|
|||||||
canBeFallbackTarget: false,
|
canBeFallbackTarget: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const toggleGesturesToolbarButtonSpec = ToolbarButtonSpec(
|
||||||
|
id: ToolbarButtonId.toggleGestures,
|
||||||
|
defaultVisible: false,
|
||||||
|
);
|
||||||
|
|
||||||
const toolbarButtonSpecs = [
|
const toolbarButtonSpecs = [
|
||||||
backToolbarButtonSpec,
|
backToolbarButtonSpec,
|
||||||
forwardToolbarButtonSpec,
|
forwardToolbarButtonSpec,
|
||||||
@@ -194,6 +199,7 @@ const toolbarButtonSpecs = [
|
|||||||
pageDownToolbarButtonSpec,
|
pageDownToolbarButtonSpec,
|
||||||
fontToolbarButtonSpec,
|
fontToolbarButtonSpec,
|
||||||
extensionShortcutToolbarButtonSpec,
|
extensionShortcutToolbarButtonSpec,
|
||||||
|
toggleGesturesToolbarButtonSpec,
|
||||||
quitToolbarButtonSpec,
|
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/readerview/presentation/controllers/readerable.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.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/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/settings/presentation/controllers/save_settings.dart';
|
||||||
import 'package:weblibre/features/user/data/models/engine_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/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(
|
ToolbarButtonDefinition(
|
||||||
spec: pageUpToolbarButtonSpec,
|
spec: pageUpToolbarButtonSpec,
|
||||||
label: 'Page Up',
|
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/container.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.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/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/data/models/singbox_proxy_profile.dart';
|
||||||
import 'package:weblibre/features/proxy/domain/providers/assigned_proxy_profiles.dart';
|
import 'package:weblibre/features/proxy/domain/providers/assigned_proxy_profiles.dart';
|
||||||
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart';
|
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart';
|
||||||
@@ -173,6 +175,16 @@ class _BrowserMenuSheet extends HookConsumerWidget {
|
|||||||
_ProfileCard(),
|
_ProfileCard(),
|
||||||
const SizedBox(height: 16),
|
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
|
// App
|
||||||
const _SettingsCard(),
|
const _SettingsCard(),
|
||||||
const SizedBox(height: 24),
|
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 ───
|
// ─── Tab Actions Card ───
|
||||||
|
|
||||||
class _TabActionsCard extends HookConsumerWidget {
|
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/container.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.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/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/intent_source_policy.dart';
|
||||||
import 'package:weblibre/features/intent_gatekeeper/domain/entities/pending_intent_decision.dart';
|
import 'package:weblibre/features/intent_gatekeeper/domain/entities/pending_intent_decision.dart';
|
||||||
import 'package:weblibre/features/intent_gatekeeper/domain/services/intent_gatekeeper.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()),
|
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(
|
ref.listenManual(
|
||||||
fireImmediately: true,
|
fireImmediately: true,
|
||||||
proxySettingsReplicationProvider,
|
proxySettingsReplicationProvider,
|
||||||
|
|||||||
+2
-2
@@ -42,7 +42,7 @@ final class GeckoInferenceRepositoryProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$geckoInferenceRepositoryHash() =>
|
String _$geckoInferenceRepositoryHash() =>
|
||||||
r'c4f52d2edfb8c5763577a17697202016645177da';
|
r'cd6d47ccb5aa8d64aba81ac1d982b1df8cf64d14';
|
||||||
|
|
||||||
abstract class _$GeckoInferenceRepository extends $Notifier<void> {
|
abstract class _$GeckoInferenceRepository extends $Notifier<void> {
|
||||||
void build();
|
void build();
|
||||||
@@ -389,7 +389,7 @@ final class ContainerTabSuggestionsProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$containerTabSuggestionsHash() =>
|
String _$containerTabSuggestionsHash() =>
|
||||||
r'958c7432aa5c2a2828fcccd0127e0110d0a008cf';
|
r'a29a43773000b415f3e8a23db281078c30c3b10d';
|
||||||
|
|
||||||
final class ContainerTabSuggestionsFamily extends $Family
|
final class ContainerTabSuggestionsFamily extends $Family
|
||||||
with $FunctionalFamilyOverride<FutureOr<List<String>?>, String?> {
|
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,
|
sections: browsingSettingsSections,
|
||||||
onTap: (context) => BrowsingSettingsRoute().push(context),
|
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(
|
_SettingsCategoryDefinition(
|
||||||
title: 'Toolbar & Layout',
|
title: 'Toolbar & Layout',
|
||||||
subtitle: 'Tab bar, toolbar, quick switcher, tab view',
|
subtitle: 'Tab bar, toolbar, quick switcher, tab view',
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ class SettingsCustomScrollScaffold extends StatelessWidget {
|
|||||||
final TextEditingController? searchController;
|
final TextEditingController? searchController;
|
||||||
final String searchHintText;
|
final String searchHintText;
|
||||||
final List<Widget> slivers;
|
final List<Widget> slivers;
|
||||||
|
final Widget? floatingActionButton;
|
||||||
|
|
||||||
const SettingsCustomScrollScaffold({
|
const SettingsCustomScrollScaffold({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -175,11 +176,13 @@ class SettingsCustomScrollScaffold extends StatelessWidget {
|
|||||||
this.actions = const [],
|
this.actions = const [],
|
||||||
this.searchController,
|
this.searchController,
|
||||||
this.searchHintText = 'Search settings',
|
this.searchHintText = 'Search settings',
|
||||||
|
this.floatingActionButton,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
floatingActionButton: floatingActionButton,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: FadingScroll(
|
child: FadingScroll(
|
||||||
fadingSize: 25,
|
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.FullScreenFeature
|
||||||
import mozilla.components.feature.session.PictureInPictureFeature
|
import mozilla.components.feature.session.PictureInPictureFeature
|
||||||
import mozilla.components.feature.session.SessionFeature
|
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.SitePermissionsFeature
|
||||||
import mozilla.components.feature.sitepermissions.SitePermissionsRules
|
import mozilla.components.feature.sitepermissions.SitePermissionsRules
|
||||||
import mozilla.components.feature.sitepermissions.SitePermissionsRules.AutoplayAction
|
import mozilla.components.feature.sitepermissions.SitePermissionsRules.AutoplayAction
|
||||||
@@ -87,7 +87,7 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
|
|||||||
private val promptFeature = ViewBoundFeatureWrapper<PromptFeature>()
|
private val promptFeature = ViewBoundFeatureWrapper<PromptFeature>()
|
||||||
private val webExtensionPromptFeature = ViewBoundFeatureWrapper<WebExtensionPromptFeature>()
|
private val webExtensionPromptFeature = ViewBoundFeatureWrapper<WebExtensionPromptFeature>()
|
||||||
private val sitePermissionsFeature = ViewBoundFeatureWrapper<SitePermissionsFeature>()
|
private val sitePermissionsFeature = ViewBoundFeatureWrapper<SitePermissionsFeature>()
|
||||||
private val swipeRefreshFeature = ViewBoundFeatureWrapper<SwipeRefreshFeature>()
|
private val swipeRefreshFeature = ViewBoundFeatureWrapper<GestureAwareSwipeRefreshFeature>()
|
||||||
private val secureWindowFeature = ViewBoundFeatureWrapper<SecureWindowFeature>()
|
private val secureWindowFeature = ViewBoundFeatureWrapper<SecureWindowFeature>()
|
||||||
private val fullScreenFeature = ViewBoundFeatureWrapper<FullScreenFeature>()
|
private val fullScreenFeature = ViewBoundFeatureWrapper<FullScreenFeature>()
|
||||||
private val mediaSessionFullscreenFeature =
|
private val mediaSessionFullscreenFeature =
|
||||||
@@ -272,7 +272,7 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
|
|||||||
)
|
)
|
||||||
|
|
||||||
swipeRefreshFeature.set(
|
swipeRefreshFeature.set(
|
||||||
feature = SwipeRefreshFeature(
|
feature = GestureAwareSwipeRefreshFeature(
|
||||||
components.core.store,
|
components.core.store,
|
||||||
components.useCases.sessionUseCases.reload,
|
components.useCases.sessionUseCases.reload,
|
||||||
binding.swipeToRefresh,
|
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.ContentBlocking
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonEvents
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonEvents
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoEngineSettings
|
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.GeckoSelectionActionEvents
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSuggestionEvents
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSuggestionEvents
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSyncStateEvents
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSyncStateEvents
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoTabContentEvents
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoTabContentEvents
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoViewportEvents
|
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.QueryParameterStripping
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.ReaderViewController
|
import eu.weblibre.flutter_mozilla_components.pigeons.ReaderViewController
|
||||||
import eu.weblibre.flutter_mozilla_components.services.PrivateTabsNotificationService
|
import eu.weblibre.flutter_mozilla_components.services.PrivateTabsNotificationService
|
||||||
@@ -97,6 +99,38 @@ object GlobalComponents {
|
|||||||
// Engine settings API for managing engine-specific settings
|
// Engine settings API for managing engine-specific settings
|
||||||
var engineSettingsApi: GeckoEngineSettingsApiImpl? = null
|
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
|
// External download manager setting
|
||||||
var useExternalDownloadManager: Boolean = false
|
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.GeckoIconsApi
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPublicSuffixListApi
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPublicSuffixListApi
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSitePermissionsApi
|
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.GeckoTrackingProtectionApi
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoLogging
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoLogging
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoMlApi
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoMlApi
|
||||||
@@ -337,6 +339,14 @@ class GeckoBrowserApiImpl : GeckoBrowserApi {
|
|||||||
GlobalComponents.viewportEvents = viewportEvents
|
GlobalComponents.viewportEvents = viewportEvents
|
||||||
GlobalComponents.viewportApi = viewportApi
|
GlobalComponents.viewportApi = viewportApi
|
||||||
|
|
||||||
|
// Touch-gesture recognition: event sink (Kotlin → Dart) + config API
|
||||||
|
GlobalComponents.gestureEvents =
|
||||||
|
GeckoGestureEvents(_flutterPluginBinding.binaryMessenger)
|
||||||
|
GeckoGestureApi.setUp(
|
||||||
|
_flutterPluginBinding.binaryMessenger,
|
||||||
|
GeckoGestureApiImpl()
|
||||||
|
)
|
||||||
|
|
||||||
ReaderViewEvents.setUp(
|
ReaderViewEvents.setUp(
|
||||||
_flutterPluginBinding.binaryMessenger,
|
_flutterPluginBinding.binaryMessenger,
|
||||||
components.events.readerViewEvents
|
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) {
|
override fun setDynamicToolbarMaxHeight(heightPx: Long) {
|
||||||
val height = heightPx.toInt()
|
val height = heightPx.toInt()
|
||||||
|
GlobalComponents.dynamicToolbarMaxHeightPx = height
|
||||||
|
|
||||||
val engineView = components.mainBrowserEngineView
|
val engineView = components.mainBrowserEngineView
|
||||||
if (engineView == null) {
|
if (engineView == null) {
|
||||||
@@ -65,6 +66,7 @@ class GeckoViewportApiImpl : GeckoViewportApi {
|
|||||||
*/
|
*/
|
||||||
override fun setVerticalClipping(clippingPx: Long) {
|
override fun setVerticalClipping(clippingPx: Long) {
|
||||||
val clipping = clippingPx.toInt()
|
val clipping = clippingPx.toInt()
|
||||||
|
GlobalComponents.verticalClippingPx = clipping
|
||||||
|
|
||||||
val engineView = components.mainBrowserEngineView
|
val engineView = components.mainBrowserEngineView
|
||||||
if (engineView == null) {
|
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
|
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() {
|
private open class GeckoPigeonCodec : StandardMessageCodec() {
|
||||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||||
return when (type) {
|
return when (type) {
|
||||||
@@ -6190,6 +6265,11 @@ private open class GeckoPigeonCodec : StandardMessageCodec() {
|
|||||||
SandboxCaptureEntry.fromList(it)
|
SandboxCaptureEntry.fromList(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
252.toByte() -> {
|
||||||
|
return (readValue(buffer) as? List<Any?>)?.let {
|
||||||
|
GestureConfig.fromList(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
else -> super.readValueOfType(type, buffer)
|
else -> super.readValueOfType(type, buffer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6687,6 +6767,10 @@ private open class GeckoPigeonCodec : StandardMessageCodec() {
|
|||||||
stream.write(251)
|
stream.write(251)
|
||||||
writeValue(stream, value.toList())
|
writeValue(stream, value.toList())
|
||||||
}
|
}
|
||||||
|
is GestureConfig -> {
|
||||||
|
stream.write(252)
|
||||||
|
writeValue(stream, value.toList())
|
||||||
|
}
|
||||||
else -> super.writeValue(stream, value)
|
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.view.MotionEvent
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import androidx.core.view.WindowInsetsCompat
|
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
|
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
|
* 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.
|
* 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(
|
class BackGestureFilterFrameLayout(
|
||||||
context: Context,
|
context: Context,
|
||||||
@@ -44,7 +53,35 @@ class BackGestureFilterFrameLayout(
|
|||||||
private var startedInEdgeZone = false
|
private var startedInEdgeZone = false
|
||||||
private var hasIntercepted = 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 {
|
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
|
||||||
|
feedGestureRecognizer(ev)
|
||||||
|
|
||||||
when (ev.actionMasked) {
|
when (ev.actionMasked) {
|
||||||
MotionEvent.ACTION_DOWN -> {
|
MotionEvent.ACTION_DOWN -> {
|
||||||
downX = ev.x
|
downX = ev.x
|
||||||
@@ -68,6 +105,9 @@ class BackGestureFilterFrameLayout(
|
|||||||
// already scrolled the page. Cancel APZ now and swallow the rest.
|
// already scrolled the page. Cancel APZ now and swallow the rest.
|
||||||
dx > dy + 1 -> {
|
dx > dy + 1 -> {
|
||||||
hasIntercepted = true
|
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")
|
// Log.d(TAG, "INTERCEPT dx=$dx dy=$dy")
|
||||||
dispatchSyntheticCancel(ev)
|
dispatchSyntheticCancel(ev)
|
||||||
return true
|
return true
|
||||||
@@ -93,6 +133,69 @@ class BackGestureFilterFrameLayout(
|
|||||||
return super.dispatchTouchEvent(ev)
|
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) {
|
private fun dispatchSyntheticCancel(source: MotionEvent) {
|
||||||
val cancel = MotionEvent.obtain(source).apply {
|
val cancel = MotionEvent.obtain(source).apply {
|
||||||
action = MotionEvent.ACTION_CANCEL
|
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_event.dart';
|
||||||
export 'src/domain/services/gecko_fetch_service.dart';
|
export 'src/domain/services/gecko_fetch_service.dart';
|
||||||
export 'src/domain/services/gecko_find_in_page.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_history.dart';
|
||||||
export 'src/domain/services/gecko_icon.dart';
|
export 'src/domain/services/gecko_icon.dart';
|
||||||
export 'src/domain/services/gecko_logging.dart';
|
export 'src/domain/services/gecko_logging.dart';
|
||||||
@@ -80,6 +81,7 @@ export 'src/pigeons/gecko.g.dart'
|
|||||||
GeckoSuggestionType,
|
GeckoSuggestionType,
|
||||||
GeckoTrackingProtectionApi,
|
GeckoTrackingProtectionApi,
|
||||||
GeoHitResult,
|
GeoHitResult,
|
||||||
|
GestureConfig,
|
||||||
HistoryHighlight,
|
HistoryHighlight,
|
||||||
HistoryHighlightWeights,
|
HistoryHighlightWeights,
|
||||||
HistoryMetadata,
|
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()]);
|
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 {
|
class _PigeonCodec extends StandardMessageCodec {
|
||||||
const _PigeonCodec();
|
const _PigeonCodec();
|
||||||
@override
|
@override
|
||||||
@@ -6474,6 +6555,9 @@ class _PigeonCodec extends StandardMessageCodec {
|
|||||||
} else if (value is SandboxCaptureEntry) {
|
} else if (value is SandboxCaptureEntry) {
|
||||||
buffer.putUint8(251);
|
buffer.putUint8(251);
|
||||||
writeValue(buffer, value.encode());
|
writeValue(buffer, value.encode());
|
||||||
|
} else if (value is GestureConfig) {
|
||||||
|
buffer.putUint8(252);
|
||||||
|
writeValue(buffer, value.encode());
|
||||||
} else {
|
} else {
|
||||||
super.writeValue(buffer, value);
|
super.writeValue(buffer, value);
|
||||||
}
|
}
|
||||||
@@ -6769,6 +6853,8 @@ class _PigeonCodec extends StandardMessageCodec {
|
|||||||
return PwaManifest.decode(readValue(buffer)!);
|
return PwaManifest.decode(readValue(buffer)!);
|
||||||
case 251:
|
case 251:
|
||||||
return SandboxCaptureEntry.decode(readValue(buffer)!);
|
return SandboxCaptureEntry.decode(readValue(buffer)!);
|
||||||
|
case 252:
|
||||||
|
return GestureConfig.decode(readValue(buffer)!);
|
||||||
default:
|
default:
|
||||||
return super.readValueOfType(type, buffer);
|
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,
|
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