gesture feature initial
This commit is contained in:
@@ -60,6 +60,7 @@ import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/c
|
||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_edit.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_list.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_selection.dart';
|
||||
import 'package:weblibre/features/gestures/presentation/screens/gesture_settings_screen.dart';
|
||||
import 'package:weblibre/features/onboarding/presentation/onboarding.dart';
|
||||
import 'package:weblibre/features/proxy/data/models/proxy_profile_seed.dart';
|
||||
import 'package:weblibre/features/proxy/presentation/screens/proxy_routing_settings.dart';
|
||||
|
||||
@@ -1552,6 +1552,11 @@ RouteBase get $settingsRoute => GoRouteData.$route(
|
||||
name: 'BrowsingSettingsRoute',
|
||||
factory: $BrowsingSettingsRoute._fromState,
|
||||
),
|
||||
GoRouteData.$route(
|
||||
path: 'gestures',
|
||||
name: 'GestureSettingsRoute',
|
||||
factory: $GestureSettingsRoute._fromState,
|
||||
),
|
||||
GoRouteData.$route(
|
||||
path: 'privacy_security',
|
||||
name: 'PrivacySecuritySettingsRoute',
|
||||
@@ -1766,6 +1771,27 @@ mixin $BrowsingSettingsRoute on GoRouteData {
|
||||
void replace(BuildContext context) => context.replace(location);
|
||||
}
|
||||
|
||||
mixin $GestureSettingsRoute on GoRouteData {
|
||||
static GestureSettingsRoute _fromState(GoRouterState state) =>
|
||||
GestureSettingsRoute();
|
||||
|
||||
@override
|
||||
String get location => GoRouteData.$location('/settings/gestures');
|
||||
|
||||
@override
|
||||
void go(BuildContext context) => context.go(location);
|
||||
|
||||
@override
|
||||
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
|
||||
|
||||
@override
|
||||
void pushReplacement(BuildContext context) =>
|
||||
context.pushReplacement(location);
|
||||
|
||||
@override
|
||||
void replace(BuildContext context) => context.replace(location);
|
||||
}
|
||||
|
||||
mixin $PrivacySecuritySettingsRoute on GoRouteData {
|
||||
static PrivacySecuritySettingsRoute _fromState(GoRouterState state) =>
|
||||
PrivacySecuritySettingsRoute();
|
||||
|
||||
@@ -31,6 +31,10 @@ part of 'routes.dart';
|
||||
name: 'BrowsingSettingsRoute',
|
||||
path: 'browsing',
|
||||
),
|
||||
TypedGoRoute<GestureSettingsRoute>(
|
||||
name: 'GestureSettingsRoute',
|
||||
path: 'gestures',
|
||||
),
|
||||
TypedGoRoute<PrivacySecuritySettingsRoute>(
|
||||
name: 'PrivacySecuritySettingsRoute',
|
||||
path: 'privacy_security',
|
||||
@@ -159,6 +163,13 @@ class BrowsingSettingsRoute extends GoRouteData with $BrowsingSettingsRoute {
|
||||
}
|
||||
}
|
||||
|
||||
class GestureSettingsRoute extends GoRouteData with $GestureSettingsRoute {
|
||||
@override
|
||||
Widget build(BuildContext context, GoRouterState state) {
|
||||
return const GestureSettingsScreen();
|
||||
}
|
||||
}
|
||||
|
||||
class PrivacySecuritySettingsRoute extends GoRouteData
|
||||
with $PrivacySecuritySettingsRoute {
|
||||
@override
|
||||
|
||||
@@ -223,6 +223,18 @@ GeckoViewportService viewportService(Ref ref) {
|
||||
return service;
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
GeckoGestureService gestureService(Ref ref) {
|
||||
final service = GeckoGestureService();
|
||||
service.setUp();
|
||||
|
||||
ref.onDispose(() async {
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
return service;
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class EngineReadyState extends _$EngineReadyState {
|
||||
Future<bool> waitUntilReady({
|
||||
|
||||
@@ -293,6 +293,53 @@ final class ViewportServiceProvider
|
||||
|
||||
String _$viewportServiceHash() => r'bab39db3180bb6a1cf8c055966b7f5910b41b424';
|
||||
|
||||
@ProviderFor(gestureService)
|
||||
final gestureServiceProvider = GestureServiceProvider._();
|
||||
|
||||
final class GestureServiceProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
GeckoGestureService,
|
||||
GeckoGestureService,
|
||||
GeckoGestureService
|
||||
>
|
||||
with $Provider<GeckoGestureService> {
|
||||
GestureServiceProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'gestureServiceProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$gestureServiceHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<GeckoGestureService> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
GeckoGestureService create(Ref ref) {
|
||||
return gestureService(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(GeckoGestureService value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<GeckoGestureService>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$gestureServiceHash() => r'81e90a1adb64f596190b52aba0e2ab9dcab3c5e7';
|
||||
|
||||
@ProviderFor(EngineReadyState)
|
||||
final engineReadyStateProvider = EngineReadyStateProvider._();
|
||||
|
||||
|
||||
+1
@@ -43,5 +43,6 @@ enum ToolbarButtonId {
|
||||
pageDown,
|
||||
font,
|
||||
extensionShortcut,
|
||||
toggleGestures,
|
||||
quit,
|
||||
}
|
||||
|
||||
+6
@@ -169,6 +169,11 @@ const quitToolbarButtonSpec = ToolbarButtonSpec(
|
||||
canBeFallbackTarget: false,
|
||||
);
|
||||
|
||||
const toggleGesturesToolbarButtonSpec = ToolbarButtonSpec(
|
||||
id: ToolbarButtonId.toggleGestures,
|
||||
defaultVisible: false,
|
||||
);
|
||||
|
||||
const toolbarButtonSpecs = [
|
||||
backToolbarButtonSpec,
|
||||
forwardToolbarButtonSpec,
|
||||
@@ -194,6 +199,7 @@ const toolbarButtonSpecs = [
|
||||
pageDownToolbarButtonSpec,
|
||||
fontToolbarButtonSpec,
|
||||
extensionShortcutToolbarButtonSpec,
|
||||
toggleGesturesToolbarButtonSpec,
|
||||
quitToolbarButtonSpec,
|
||||
];
|
||||
|
||||
|
||||
+30
@@ -47,6 +47,8 @@ import 'package:weblibre/features/geckoview/features/find_in_page/presentation/c
|
||||
import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
|
||||
import 'package:weblibre/features/gestures/data/models/gesture_settings.dart';
|
||||
import 'package:weblibre/features/gestures/domain/repositories/gesture_settings.dart';
|
||||
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
|
||||
import 'package:weblibre/features/user/data/models/engine_settings.dart';
|
||||
import 'package:weblibre/features/user/domain/presentation/dialogs/quit_browser_dialog.dart';
|
||||
@@ -399,6 +401,34 @@ final List<ToolbarButtonDefinition> toolbarButtonRegistry = [
|
||||
);
|
||||
},
|
||||
),
|
||||
ToolbarButtonDefinition(
|
||||
spec: toggleGesturesToolbarButtonSpec,
|
||||
label: 'Gestures',
|
||||
icon: MdiIcons.gestureSwipe,
|
||||
builder: (scope, context, ref) {
|
||||
final on = ref.watch(
|
||||
gestureSettingsWithDefaultsProvider.select((s) => s.effectiveEnabled),
|
||||
);
|
||||
return IconButton(
|
||||
isSelected: on,
|
||||
tooltip: on ? 'Disable gestures' : 'Enable gestures',
|
||||
onPressed: scope.isPreview
|
||||
? () {}
|
||||
: () async {
|
||||
final newActive = !on;
|
||||
await ref
|
||||
.read(gestureSettingsRepositoryProvider.notifier)
|
||||
.updateSettings(
|
||||
(s) => s.copyWith(
|
||||
enabled: s.enabled || newActive,
|
||||
active: newActive,
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: Icon(on ? MdiIcons.gestureSwipe : MdiIcons.gestureDoubleTap),
|
||||
);
|
||||
},
|
||||
),
|
||||
ToolbarButtonDefinition(
|
||||
spec: pageUpToolbarButtonSpec,
|
||||
label: 'Page Up',
|
||||
|
||||
+42
@@ -69,6 +69,8 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
|
||||
import 'package:weblibre/features/geckoview/features/top_sites/domain/repositories/top_site_repository.dart';
|
||||
import 'package:weblibre/features/gestures/data/models/gesture_settings.dart';
|
||||
import 'package:weblibre/features/gestures/domain/repositories/gesture_settings.dart';
|
||||
import 'package:weblibre/features/proxy/data/models/singbox_proxy_profile.dart';
|
||||
import 'package:weblibre/features/proxy/domain/providers/assigned_proxy_profiles.dart';
|
||||
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart';
|
||||
@@ -173,6 +175,16 @@ class _BrowserMenuSheet extends HookConsumerWidget {
|
||||
_ProfileCard(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Gestures quick toggle (only when the master switch is on)
|
||||
if (ref.watch(
|
||||
gestureSettingsWithDefaultsProvider.select(
|
||||
(s) => s.enabled,
|
||||
),
|
||||
)) ...[
|
||||
const _GestureToggleTile(),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// App
|
||||
const _SettingsCard(),
|
||||
const SizedBox(height: 24),
|
||||
@@ -930,6 +942,36 @@ class _FetchFeedsTile extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Gestures Quick Toggle ───
|
||||
|
||||
class _GestureToggleTile extends ConsumerWidget {
|
||||
const _GestureToggleTile();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final active = ref.watch(
|
||||
gestureSettingsWithDefaultsProvider.select((s) => s.active),
|
||||
);
|
||||
|
||||
return _buildMenuCard(
|
||||
context,
|
||||
children: [
|
||||
SwitchListTile.adaptive(
|
||||
secondary: const Icon(MdiIcons.gestureSwipe),
|
||||
title: const Text('Gestures'),
|
||||
subtitle: Text(active ? 'Active' : 'Suspended'),
|
||||
value: active,
|
||||
onChanged: (value) async {
|
||||
await ref
|
||||
.read(gestureSettingsRepositoryProvider.notifier)
|
||||
.updateSettings((s) => s.copyWith(active: value));
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tab Actions Card ───
|
||||
|
||||
class _TabActionsCard extends HookConsumerWidget {
|
||||
|
||||
+16
@@ -55,6 +55,8 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selec
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/services/local_index_pruner.dart';
|
||||
import 'package:weblibre/features/gestures/domain/services/gesture_control.dart';
|
||||
import 'package:weblibre/features/gestures/presentation/widgets/gesture_feedback_overlay.dart';
|
||||
import 'package:weblibre/features/intent_gatekeeper/domain/entities/intent_source_policy.dart';
|
||||
import 'package:weblibre/features/intent_gatekeeper/domain/entities/pending_intent_decision.dart';
|
||||
import 'package:weblibre/features/intent_gatekeeper/domain/services/intent_gatekeeper.dart';
|
||||
@@ -304,6 +306,7 @@ class _BrowserViewState extends ConsumerState<BrowserView>
|
||||
),
|
||||
),
|
||||
if (showHome) const Positioned.fill(child: BrowserHome()),
|
||||
const Positioned.fill(child: GestureFeedbackOverlay()),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -610,6 +613,19 @@ class _BrowserViewState extends ConsumerState<BrowserView>
|
||||
},
|
||||
);
|
||||
|
||||
ref.listenManual(
|
||||
fireImmediately: true,
|
||||
gestureControlServiceProvider,
|
||||
(previous, next) {},
|
||||
onError: (error, stackTrace) {
|
||||
logger.e(
|
||||
'Error listening to gestureControlServiceProvider',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ref.listenManual(
|
||||
fireImmediately: true,
|
||||
proxySettingsReplicationProvider,
|
||||
|
||||
+2
-2
@@ -42,7 +42,7 @@ final class GeckoInferenceRepositoryProvider
|
||||
}
|
||||
|
||||
String _$geckoInferenceRepositoryHash() =>
|
||||
r'c4f52d2edfb8c5763577a17697202016645177da';
|
||||
r'cd6d47ccb5aa8d64aba81ac1d982b1df8cf64d14';
|
||||
|
||||
abstract class _$GeckoInferenceRepository extends $Notifier<void> {
|
||||
void build();
|
||||
@@ -389,7 +389,7 @@ final class ContainerTabSuggestionsProvider
|
||||
}
|
||||
|
||||
String _$containerTabSuggestionsHash() =>
|
||||
r'958c7432aa5c2a2828fcccd0127e0110d0a008cf';
|
||||
r'a29a43773000b415f3e8a23db281078c30c3b10d';
|
||||
|
||||
final class ContainerTabSuggestionsFamily extends $Family
|
||||
with $FunctionalFamilyOverride<FutureOr<List<String>?>, String?> {
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||
|
||||
/// Actions that can be bound to a touch gesture.
|
||||
///
|
||||
/// Each value carries a human-readable [title]/[description] for the settings
|
||||
/// UI and an [icon] mirroring the action's representation elsewhere in the app
|
||||
/// (contextual toolbar, browser menu sheet). The dispatcher resolves each value
|
||||
/// against the currently selected tab.
|
||||
enum GestureAction {
|
||||
// Navigation
|
||||
back(
|
||||
'Back',
|
||||
'Go back in history',
|
||||
Icons.arrow_back,
|
||||
GestureActionCategory.navigation,
|
||||
),
|
||||
forward(
|
||||
'Forward',
|
||||
'Go forward in history',
|
||||
Icons.arrow_forward,
|
||||
GestureActionCategory.navigation,
|
||||
),
|
||||
reload(
|
||||
'Reload',
|
||||
'Reload the current page',
|
||||
Icons.refresh,
|
||||
GestureActionCategory.navigation,
|
||||
),
|
||||
|
||||
// Scrolling
|
||||
scrollTop(
|
||||
'Scroll to Top',
|
||||
'Jump to the top of the page',
|
||||
Icons.vertical_align_top,
|
||||
GestureActionCategory.scrolling,
|
||||
),
|
||||
scrollBottom(
|
||||
'Scroll to Bottom',
|
||||
'Jump to the bottom of the page',
|
||||
Icons.vertical_align_bottom,
|
||||
GestureActionCategory.scrolling,
|
||||
),
|
||||
pageUp(
|
||||
'Page Up',
|
||||
'Scroll up by one screen',
|
||||
MdiIcons.chevronDoubleUp,
|
||||
GestureActionCategory.scrolling,
|
||||
),
|
||||
pageDown(
|
||||
'Page Down',
|
||||
'Scroll down by one screen',
|
||||
MdiIcons.chevronDoubleDown,
|
||||
GestureActionCategory.scrolling,
|
||||
),
|
||||
|
||||
// Tabs
|
||||
newTab(
|
||||
'New Tab',
|
||||
'Open a new tab',
|
||||
MdiIcons.tabPlus,
|
||||
GestureActionCategory.tabs,
|
||||
),
|
||||
closeTab(
|
||||
'Close Tab',
|
||||
'Close the current tab',
|
||||
MdiIcons.tabMinus,
|
||||
GestureActionCategory.tabs,
|
||||
),
|
||||
duplicateTab(
|
||||
'Duplicate Tab',
|
||||
'Open a copy of the current tab',
|
||||
MdiIcons.contentDuplicate,
|
||||
GestureActionCategory.tabs,
|
||||
),
|
||||
nextTab(
|
||||
'Next Tab',
|
||||
'Switch to the next tab',
|
||||
Icons.skip_next,
|
||||
GestureActionCategory.tabs,
|
||||
),
|
||||
previousTab(
|
||||
'Previous Tab',
|
||||
'Switch to the previous tab',
|
||||
Icons.skip_previous,
|
||||
GestureActionCategory.tabs,
|
||||
),
|
||||
lastUsedTab(
|
||||
'Last Used Tab',
|
||||
'Switch to the previously used tab',
|
||||
Icons.swap_horiz,
|
||||
GestureActionCategory.tabs,
|
||||
),
|
||||
togglePinTab(
|
||||
'Pin / Unpin Tab',
|
||||
'Toggle the pinned state of the current tab',
|
||||
MdiIcons.pin,
|
||||
GestureActionCategory.tabs,
|
||||
),
|
||||
|
||||
// Page tools
|
||||
toggleReaderMode(
|
||||
'Reader Mode',
|
||||
'Toggle reader mode for the current page',
|
||||
MdiIcons.bookOpenOutline,
|
||||
GestureActionCategory.page,
|
||||
),
|
||||
toggleDesktopMode(
|
||||
'Desktop Site',
|
||||
'Toggle desktop site for the current page',
|
||||
Icons.desktop_windows,
|
||||
GestureActionCategory.page,
|
||||
),
|
||||
findInPage(
|
||||
'Find in Page',
|
||||
'Open find in page',
|
||||
Icons.find_in_page,
|
||||
GestureActionCategory.page,
|
||||
),
|
||||
increaseFontSize(
|
||||
'Increase Font',
|
||||
'Increase the page font size',
|
||||
MdiIcons.formatFontSizeIncrease,
|
||||
GestureActionCategory.page,
|
||||
),
|
||||
decreaseFontSize(
|
||||
'Decrease Font',
|
||||
'Decrease the page font size',
|
||||
MdiIcons.formatFontSizeDecrease,
|
||||
GestureActionCategory.page,
|
||||
),
|
||||
toggleBookmark(
|
||||
'Bookmark',
|
||||
'Bookmark or unbookmark the current page',
|
||||
Icons.bookmark_border,
|
||||
GestureActionCategory.page,
|
||||
),
|
||||
translatePage(
|
||||
'Translate',
|
||||
'Open the page translation sheet',
|
||||
Icons.translate,
|
||||
GestureActionCategory.page,
|
||||
),
|
||||
|
||||
// Open
|
||||
showHistory(
|
||||
'History',
|
||||
'Open browsing history',
|
||||
Icons.history,
|
||||
GestureActionCategory.open,
|
||||
),
|
||||
showBookmarks(
|
||||
'Bookmarks',
|
||||
'Open bookmarks',
|
||||
MdiIcons.bookmarkMultiple,
|
||||
GestureActionCategory.open,
|
||||
),
|
||||
|
||||
// App
|
||||
moveToBackground(
|
||||
'Minimize',
|
||||
'Send WebLibre to the background',
|
||||
MdiIcons.arrowCollapseDown,
|
||||
GestureActionCategory.app,
|
||||
),
|
||||
quitBrowser(
|
||||
'Quit',
|
||||
'Close all tabs and quit WebLibre',
|
||||
MdiIcons.power,
|
||||
GestureActionCategory.app,
|
||||
);
|
||||
|
||||
final String title;
|
||||
final String description;
|
||||
final IconData icon;
|
||||
|
||||
/// Grouping used to organise actions in the bindings list and picker.
|
||||
final GestureActionCategory category;
|
||||
|
||||
const GestureAction(this.title, this.description, this.icon, this.category);
|
||||
}
|
||||
|
||||
/// High-level grouping of [GestureAction]s for the settings UI.
|
||||
enum GestureActionCategory {
|
||||
navigation('Navigation'),
|
||||
scrolling('Scrolling'),
|
||||
tabs('Tabs'),
|
||||
page('Page'),
|
||||
open('Open'),
|
||||
app('App');
|
||||
|
||||
final String label;
|
||||
|
||||
const GestureActionCategory(this.label);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:copy_with_extension/copy_with_extension.dart';
|
||||
import 'package:fast_equatable/fast_equatable.dart';
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:weblibre/features/gestures/data/models/gesture_action.dart';
|
||||
import 'package:weblibre/utils/uri_input_parser.dart';
|
||||
|
||||
part 'gesture_settings.g.dart';
|
||||
|
||||
const defaultGestureStrokeSize = 50;
|
||||
const minGestureStrokeSize = 20;
|
||||
const maxGestureStrokeSize = 100;
|
||||
|
||||
const defaultGestureTimeoutMs = 1500;
|
||||
const minGestureTimeoutMs = 500;
|
||||
const maxGestureTimeoutMs = 3000;
|
||||
|
||||
const defaultGestureMaxFingers = 1;
|
||||
|
||||
const defaultGestureIntervalMs = 0;
|
||||
const minGestureIntervalMs = 0;
|
||||
const maxGestureIntervalMs = 2000;
|
||||
|
||||
/// Minimum number of strokes drawn before the live overlay starts suggesting
|
||||
/// the other possible completions (mirrors the reference add-on's
|
||||
/// `toastMinStroke`).
|
||||
const defaultGestureMinSuggestionStroke = 2;
|
||||
const minGestureMinSuggestionStroke = 1;
|
||||
const maxGestureMinSuggestionStroke = 5;
|
||||
|
||||
/// Default gesture-to-action bindings, aligned with the reference add-on's
|
||||
/// defaults for the actions WebLibre currently supports.
|
||||
const defaultGestureBindings = <String, GestureAction>{
|
||||
'D-L': GestureAction.forward,
|
||||
'D-R': GestureAction.back,
|
||||
'R-D': GestureAction.scrollTop,
|
||||
'R-U': GestureAction.scrollBottom,
|
||||
'D-R-U': GestureAction.reload,
|
||||
'L-D-R': GestureAction.closeTab,
|
||||
};
|
||||
|
||||
@CopyWith()
|
||||
@JsonSerializable(includeIfNull: true, constructor: 'withDefaults')
|
||||
class GestureSettings with FastEquatable {
|
||||
/// Master switch. When false, gesture recognition is fully disabled and the
|
||||
/// quick toggles ([active]) have no effect.
|
||||
final bool enabled;
|
||||
|
||||
/// Runtime toggle exposed via the quick toggles (menu sheet tile, contextual
|
||||
/// toolbar button). Lets the user suspend gestures without touching the
|
||||
/// master switch. The recognizer runs only when [enabled] && [active].
|
||||
final bool active;
|
||||
|
||||
/// Base stroke length in logical pixels (scaled to the screen by native).
|
||||
final int strokeSize;
|
||||
|
||||
/// Milliseconds of inactivity after which an in-progress gesture is dropped.
|
||||
final int timeoutMs;
|
||||
|
||||
/// Maximum simultaneous pointers a gesture may use.
|
||||
final int maxFingers;
|
||||
|
||||
/// Cooldown in milliseconds after a gesture fires, during which further
|
||||
/// gestures are ignored. 0 disables the cooldown.
|
||||
final int intervalMs;
|
||||
|
||||
/// Whether to show the live feedback overlay while a stroke is being drawn
|
||||
/// (the in-progress arrows plus the matching/possible actions).
|
||||
final bool showFeedback;
|
||||
|
||||
/// Within the live overlay, also suggest the other possible completions once
|
||||
/// at least [minSuggestionStroke] strokes have been drawn.
|
||||
final bool suggestNext;
|
||||
|
||||
/// Minimum strokes drawn before [suggestNext] kicks in.
|
||||
final int minSuggestionStroke;
|
||||
|
||||
/// Hosts on which gestures are disabled. A page is excluded when its host
|
||||
/// equals or is a subdomain of any entry (see `isGestureSiteExcluded`).
|
||||
final List<String> excludedSites;
|
||||
|
||||
/// Canonical gesture key → action. Keys follow the grammar documented on
|
||||
/// [GestureStroke].
|
||||
final Map<String, GestureAction> bindings;
|
||||
|
||||
GestureSettings({
|
||||
required this.enabled,
|
||||
required this.active,
|
||||
required this.strokeSize,
|
||||
required this.timeoutMs,
|
||||
required this.maxFingers,
|
||||
required this.intervalMs,
|
||||
required this.showFeedback,
|
||||
required this.suggestNext,
|
||||
required this.minSuggestionStroke,
|
||||
required this.excludedSites,
|
||||
required this.bindings,
|
||||
});
|
||||
|
||||
GestureSettings.withDefaults({
|
||||
bool? enabled,
|
||||
bool? active,
|
||||
int? strokeSize,
|
||||
int? timeoutMs,
|
||||
int? maxFingers,
|
||||
int? intervalMs,
|
||||
bool? showFeedback,
|
||||
bool? suggestNext,
|
||||
int? minSuggestionStroke,
|
||||
List<String>? excludedSites,
|
||||
Map<String, GestureAction>? bindings,
|
||||
}) : enabled = enabled ?? false,
|
||||
active = active ?? true,
|
||||
strokeSize = strokeSize ?? defaultGestureStrokeSize,
|
||||
timeoutMs = timeoutMs ?? defaultGestureTimeoutMs,
|
||||
maxFingers = maxFingers ?? defaultGestureMaxFingers,
|
||||
intervalMs = intervalMs ?? defaultGestureIntervalMs,
|
||||
showFeedback = showFeedback ?? true,
|
||||
suggestNext = suggestNext ?? true,
|
||||
minSuggestionStroke =
|
||||
minSuggestionStroke ?? defaultGestureMinSuggestionStroke,
|
||||
excludedSites = excludedSites ?? const [],
|
||||
bindings = bindings ?? defaultGestureBindings;
|
||||
|
||||
/// Whether the recognizer should actually run.
|
||||
bool get effectiveEnabled => enabled && active;
|
||||
|
||||
factory GestureSettings.fromJson(Map<String, dynamic> json) =>
|
||||
_$GestureSettingsFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$GestureSettingsToJson(this);
|
||||
|
||||
@override
|
||||
List<Object?> get hashParameters => [
|
||||
enabled,
|
||||
active,
|
||||
strokeSize,
|
||||
timeoutMs,
|
||||
maxFingers,
|
||||
intervalMs,
|
||||
showFeedback,
|
||||
suggestNext,
|
||||
minSuggestionStroke,
|
||||
excludedSites,
|
||||
bindings,
|
||||
];
|
||||
}
|
||||
|
||||
/// Normalises a user-entered site into a bare lowercase host, e.g.
|
||||
/// `https://News.example.com/foo` → `news.example.com`. Accepts either a full
|
||||
/// URL or a bare host, and validates the result with the same rules the address
|
||||
/// bar uses ([isValidHostCandidate]). Returns null for input without a valid
|
||||
/// host.
|
||||
String? normalizeGestureSiteHost(String input) {
|
||||
final trimmed = input.trim().toLowerCase();
|
||||
if (trimmed.isEmpty) return null;
|
||||
|
||||
final candidate = trimmed.contains('://') ? trimmed : 'https://$trimmed';
|
||||
final host = Uri.tryParse(candidate)?.host;
|
||||
if (host == null || host.isEmpty) return null;
|
||||
|
||||
return isValidHostCandidate(host) ? host : null;
|
||||
}
|
||||
|
||||
/// Whether [url] is covered by any entry in [excludedSites]. An entry matches
|
||||
/// the URL's host exactly or as a parent domain (so `example.com` also covers
|
||||
/// `m.example.com`).
|
||||
bool isGestureSiteExcluded(Uri url, List<String> excludedSites) {
|
||||
final host = url.host.toLowerCase();
|
||||
if (host.isEmpty) return false;
|
||||
|
||||
for (final entry in excludedSites) {
|
||||
final pattern = entry.toLowerCase();
|
||||
if (pattern.isEmpty) continue;
|
||||
if (host == pattern || host.endsWith('.$pattern')) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'gesture_settings.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// CopyWithGenerator
|
||||
// **************************************************************************
|
||||
|
||||
abstract class _$GestureSettingsCWProxy {
|
||||
GestureSettings enabled(bool enabled);
|
||||
|
||||
GestureSettings active(bool active);
|
||||
|
||||
GestureSettings strokeSize(int strokeSize);
|
||||
|
||||
GestureSettings timeoutMs(int timeoutMs);
|
||||
|
||||
GestureSettings maxFingers(int maxFingers);
|
||||
|
||||
GestureSettings intervalMs(int intervalMs);
|
||||
|
||||
GestureSettings showFeedback(bool showFeedback);
|
||||
|
||||
GestureSettings suggestNext(bool suggestNext);
|
||||
|
||||
GestureSettings minSuggestionStroke(int minSuggestionStroke);
|
||||
|
||||
GestureSettings excludedSites(List<String> excludedSites);
|
||||
|
||||
GestureSettings bindings(Map<String, GestureAction> bindings);
|
||||
|
||||
/// Creates a new instance with the provided field values.
|
||||
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GestureSettings(...).copyWith.fieldName(value)`.
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// GestureSettings(...).copyWith(id: 12, name: "My name")
|
||||
/// ```
|
||||
GestureSettings call({
|
||||
bool enabled,
|
||||
bool active,
|
||||
int strokeSize,
|
||||
int timeoutMs,
|
||||
int maxFingers,
|
||||
int intervalMs,
|
||||
bool showFeedback,
|
||||
bool suggestNext,
|
||||
int minSuggestionStroke,
|
||||
List<String> excludedSites,
|
||||
Map<String, GestureAction> bindings,
|
||||
});
|
||||
}
|
||||
|
||||
/// Callable proxy for `copyWith` functionality.
|
||||
/// Use as `instanceOfGestureSettings.copyWith(...)` or call `instanceOfGestureSettings.copyWith.fieldName(value)` for a single field.
|
||||
class _$GestureSettingsCWProxyImpl implements _$GestureSettingsCWProxy {
|
||||
const _$GestureSettingsCWProxyImpl(this._value);
|
||||
|
||||
final GestureSettings _value;
|
||||
|
||||
@override
|
||||
GestureSettings enabled(bool enabled) => call(enabled: enabled);
|
||||
|
||||
@override
|
||||
GestureSettings active(bool active) => call(active: active);
|
||||
|
||||
@override
|
||||
GestureSettings strokeSize(int strokeSize) => call(strokeSize: strokeSize);
|
||||
|
||||
@override
|
||||
GestureSettings timeoutMs(int timeoutMs) => call(timeoutMs: timeoutMs);
|
||||
|
||||
@override
|
||||
GestureSettings maxFingers(int maxFingers) => call(maxFingers: maxFingers);
|
||||
|
||||
@override
|
||||
GestureSettings intervalMs(int intervalMs) => call(intervalMs: intervalMs);
|
||||
|
||||
@override
|
||||
GestureSettings showFeedback(bool showFeedback) =>
|
||||
call(showFeedback: showFeedback);
|
||||
|
||||
@override
|
||||
GestureSettings suggestNext(bool suggestNext) =>
|
||||
call(suggestNext: suggestNext);
|
||||
|
||||
@override
|
||||
GestureSettings minSuggestionStroke(int minSuggestionStroke) =>
|
||||
call(minSuggestionStroke: minSuggestionStroke);
|
||||
|
||||
@override
|
||||
GestureSettings excludedSites(List<String> excludedSites) =>
|
||||
call(excludedSites: excludedSites);
|
||||
|
||||
@override
|
||||
GestureSettings bindings(Map<String, GestureAction> bindings) =>
|
||||
call(bindings: bindings);
|
||||
|
||||
@override
|
||||
/// Creates a new instance with the provided field values.
|
||||
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GestureSettings(...).copyWith.fieldName(value)`.
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// GestureSettings(...).copyWith(id: 12, name: "My name")
|
||||
/// ```
|
||||
GestureSettings call({
|
||||
Object? enabled = const $CopyWithPlaceholder(),
|
||||
Object? active = const $CopyWithPlaceholder(),
|
||||
Object? strokeSize = const $CopyWithPlaceholder(),
|
||||
Object? timeoutMs = const $CopyWithPlaceholder(),
|
||||
Object? maxFingers = const $CopyWithPlaceholder(),
|
||||
Object? intervalMs = const $CopyWithPlaceholder(),
|
||||
Object? showFeedback = const $CopyWithPlaceholder(),
|
||||
Object? suggestNext = const $CopyWithPlaceholder(),
|
||||
Object? minSuggestionStroke = const $CopyWithPlaceholder(),
|
||||
Object? excludedSites = const $CopyWithPlaceholder(),
|
||||
Object? bindings = const $CopyWithPlaceholder(),
|
||||
}) {
|
||||
return GestureSettings(
|
||||
enabled: enabled == const $CopyWithPlaceholder() || enabled == null
|
||||
? _value.enabled
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: enabled as bool,
|
||||
active: active == const $CopyWithPlaceholder() || active == null
|
||||
? _value.active
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: active as bool,
|
||||
strokeSize:
|
||||
strokeSize == const $CopyWithPlaceholder() || strokeSize == null
|
||||
? _value.strokeSize
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: strokeSize as int,
|
||||
timeoutMs: timeoutMs == const $CopyWithPlaceholder() || timeoutMs == null
|
||||
? _value.timeoutMs
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: timeoutMs as int,
|
||||
maxFingers:
|
||||
maxFingers == const $CopyWithPlaceholder() || maxFingers == null
|
||||
? _value.maxFingers
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: maxFingers as int,
|
||||
intervalMs:
|
||||
intervalMs == const $CopyWithPlaceholder() || intervalMs == null
|
||||
? _value.intervalMs
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: intervalMs as int,
|
||||
showFeedback:
|
||||
showFeedback == const $CopyWithPlaceholder() || showFeedback == null
|
||||
? _value.showFeedback
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: showFeedback as bool,
|
||||
suggestNext:
|
||||
suggestNext == const $CopyWithPlaceholder() || suggestNext == null
|
||||
? _value.suggestNext
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: suggestNext as bool,
|
||||
minSuggestionStroke:
|
||||
minSuggestionStroke == const $CopyWithPlaceholder() ||
|
||||
minSuggestionStroke == null
|
||||
? _value.minSuggestionStroke
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: minSuggestionStroke as int,
|
||||
excludedSites:
|
||||
excludedSites == const $CopyWithPlaceholder() || excludedSites == null
|
||||
? _value.excludedSites
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: excludedSites as List<String>,
|
||||
bindings: bindings == const $CopyWithPlaceholder() || bindings == null
|
||||
? _value.bindings
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: bindings as Map<String, GestureAction>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension $GestureSettingsCopyWith on GestureSettings {
|
||||
/// Returns a callable class used to build a new instance with modified fields.
|
||||
/// Example: `instanceOfGestureSettings.copyWith(...)` or `instanceOfGestureSettings.copyWith.fieldName(...)`.
|
||||
// ignore: library_private_types_in_public_api
|
||||
_$GestureSettingsCWProxy get copyWith => _$GestureSettingsCWProxyImpl(this);
|
||||
}
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
GestureSettings _$GestureSettingsFromJson(Map<String, dynamic> json) =>
|
||||
GestureSettings.withDefaults(
|
||||
enabled: json['enabled'] as bool?,
|
||||
active: json['active'] as bool?,
|
||||
strokeSize: (json['strokeSize'] as num?)?.toInt(),
|
||||
timeoutMs: (json['timeoutMs'] as num?)?.toInt(),
|
||||
maxFingers: (json['maxFingers'] as num?)?.toInt(),
|
||||
intervalMs: (json['intervalMs'] as num?)?.toInt(),
|
||||
showFeedback: json['showFeedback'] as bool?,
|
||||
suggestNext: json['suggestNext'] as bool?,
|
||||
minSuggestionStroke: (json['minSuggestionStroke'] as num?)?.toInt(),
|
||||
excludedSites: (json['excludedSites'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList(),
|
||||
bindings: (json['bindings'] as Map<String, dynamic>?)?.map(
|
||||
(k, e) => MapEntry(k, $enumDecode(_$GestureActionEnumMap, e)),
|
||||
),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$GestureSettingsToJson(GestureSettings instance) =>
|
||||
<String, dynamic>{
|
||||
'enabled': instance.enabled,
|
||||
'active': instance.active,
|
||||
'strokeSize': instance.strokeSize,
|
||||
'timeoutMs': instance.timeoutMs,
|
||||
'maxFingers': instance.maxFingers,
|
||||
'intervalMs': instance.intervalMs,
|
||||
'showFeedback': instance.showFeedback,
|
||||
'suggestNext': instance.suggestNext,
|
||||
'minSuggestionStroke': instance.minSuggestionStroke,
|
||||
'excludedSites': instance.excludedSites,
|
||||
'bindings': instance.bindings.map(
|
||||
(k, e) => MapEntry(k, _$GestureActionEnumMap[e]!),
|
||||
),
|
||||
};
|
||||
|
||||
const _$GestureActionEnumMap = {
|
||||
GestureAction.back: 'back',
|
||||
GestureAction.forward: 'forward',
|
||||
GestureAction.reload: 'reload',
|
||||
GestureAction.scrollTop: 'scrollTop',
|
||||
GestureAction.scrollBottom: 'scrollBottom',
|
||||
GestureAction.pageUp: 'pageUp',
|
||||
GestureAction.pageDown: 'pageDown',
|
||||
GestureAction.newTab: 'newTab',
|
||||
GestureAction.closeTab: 'closeTab',
|
||||
GestureAction.duplicateTab: 'duplicateTab',
|
||||
GestureAction.nextTab: 'nextTab',
|
||||
GestureAction.previousTab: 'previousTab',
|
||||
GestureAction.lastUsedTab: 'lastUsedTab',
|
||||
GestureAction.togglePinTab: 'togglePinTab',
|
||||
GestureAction.toggleReaderMode: 'toggleReaderMode',
|
||||
GestureAction.toggleDesktopMode: 'toggleDesktopMode',
|
||||
GestureAction.findInPage: 'findInPage',
|
||||
GestureAction.increaseFontSize: 'increaseFontSize',
|
||||
GestureAction.decreaseFontSize: 'decreaseFontSize',
|
||||
GestureAction.toggleBookmark: 'toggleBookmark',
|
||||
GestureAction.translatePage: 'translatePage',
|
||||
GestureAction.showHistory: 'showHistory',
|
||||
GestureAction.showBookmarks: 'showBookmarks',
|
||||
GestureAction.moveToBackground: 'moveToBackground',
|
||||
GestureAction.quitBrowser: 'quitBrowser',
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:copy_with_extension/copy_with_extension.dart';
|
||||
import 'package:fast_equatable/fast_equatable.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||
|
||||
part 'gesture_stroke.g.dart';
|
||||
|
||||
/// Where a gesture must begin. Mirrors the reference add-on's start-position
|
||||
/// tokens (the trailing colon is part of the canonical key prefix).
|
||||
enum GestureStartPosition {
|
||||
anywhere('', 'Anywhere', MdiIcons.borderNone),
|
||||
leftEdge('L:', 'Left edge', MdiIcons.borderLeft),
|
||||
rightEdge('R:', 'Right edge', MdiIcons.borderRight),
|
||||
topEdge('T:', 'Top edge', MdiIcons.borderTop),
|
||||
bottomEdge('B:', 'Bottom edge', MdiIcons.borderBottom),
|
||||
leftHalf('W:', 'Left half', MdiIcons.borderLeftVariant),
|
||||
rightHalf('E:', 'Right half', MdiIcons.borderRightVariant);
|
||||
|
||||
/// Canonical key prefix, e.g. `R:` (empty for [anywhere]).
|
||||
final String prefix;
|
||||
final String label;
|
||||
final IconData icon;
|
||||
|
||||
const GestureStartPosition(this.prefix, this.label, this.icon);
|
||||
|
||||
static GestureStartPosition fromPrefixLetter(String letter) {
|
||||
return GestureStartPosition.values.firstWhere(
|
||||
(position) => position.prefix == '$letter:',
|
||||
orElse: () => GestureStartPosition.anywhere,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A single dominant swipe direction within a gesture.
|
||||
enum GestureArrow {
|
||||
up('U', '↑'),
|
||||
down('D', '↓'),
|
||||
left('L', '←'),
|
||||
right('R', '→');
|
||||
|
||||
/// Canonical key token, e.g. `D`.
|
||||
final String token;
|
||||
|
||||
/// Compact glyph for rendering a stroke sequence.
|
||||
final String symbol;
|
||||
|
||||
const GestureArrow(this.token, this.symbol);
|
||||
|
||||
static GestureArrow fromToken(String token) {
|
||||
return GestureArrow.values.firstWhere((arrow) => arrow.token == token);
|
||||
}
|
||||
}
|
||||
|
||||
/// A configurable gesture: an ordered sequence of swipe directions, optionally
|
||||
/// constrained by where the touch begins and how many fingers are used.
|
||||
///
|
||||
/// The canonical [key] is the on-the-wire identifier shared with the native
|
||||
/// recognizer: `<start-prefix><finger-prefix><arrows joined by '-'>`, e.g.
|
||||
/// `R:2:D-L`. The finger prefix is omitted for a single finger and the start
|
||||
/// prefix is omitted for [GestureStartPosition.anywhere].
|
||||
@CopyWith()
|
||||
class GestureStroke with FastEquatable {
|
||||
final GestureStartPosition startPosition;
|
||||
final int fingers;
|
||||
final List<GestureArrow> arrows;
|
||||
|
||||
GestureStroke({
|
||||
this.startPosition = GestureStartPosition.anywhere,
|
||||
this.fingers = 1,
|
||||
this.arrows = const [],
|
||||
});
|
||||
|
||||
/// The canonical gesture key (see class docs).
|
||||
String get key {
|
||||
final fingerPrefix = fingers >= 2 ? '$fingers:' : '';
|
||||
final arrowPart = arrows.map((arrow) => arrow.token).join('-');
|
||||
return '${startPosition.prefix}$fingerPrefix$arrowPart';
|
||||
}
|
||||
|
||||
/// Parses a canonical [key] back into a stroke.
|
||||
///
|
||||
/// The arrow sequence is always the final colon-separated segment; preceding
|
||||
/// segments are either a single start-position letter or a finger count.
|
||||
factory GestureStroke.fromKey(String key) {
|
||||
final segments = key.split(':');
|
||||
final arrowPart = segments.removeLast();
|
||||
|
||||
var startPosition = GestureStartPosition.anywhere;
|
||||
var fingers = 1;
|
||||
for (final segment in segments) {
|
||||
final asFingers = int.tryParse(segment);
|
||||
if (asFingers != null) {
|
||||
fingers = asFingers;
|
||||
} else if (segment.isNotEmpty) {
|
||||
startPosition = GestureStartPosition.fromPrefixLetter(segment);
|
||||
}
|
||||
}
|
||||
|
||||
final arrows = arrowPart.isEmpty
|
||||
? <GestureArrow>[]
|
||||
: arrowPart.split('-').map(GestureArrow.fromToken).toList();
|
||||
|
||||
return GestureStroke(
|
||||
startPosition: startPosition,
|
||||
fingers: fingers,
|
||||
arrows: arrows,
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether this stroke is complete enough to be bound to an action.
|
||||
bool get isValid => arrows.isNotEmpty;
|
||||
|
||||
/// Human-readable rendering, e.g. `Right edge · ✌ · ↓→`.
|
||||
String get displayLabel {
|
||||
final parts = <String>[
|
||||
if (startPosition != GestureStartPosition.anywhere) startPosition.label,
|
||||
if (fingers >= 2) '$fingers fingers',
|
||||
arrows.map((arrow) => arrow.symbol).join(),
|
||||
];
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get hashParameters => [startPosition, fingers, arrows];
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'gesture_stroke.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// CopyWithGenerator
|
||||
// **************************************************************************
|
||||
|
||||
abstract class _$GestureStrokeCWProxy {
|
||||
GestureStroke startPosition(GestureStartPosition startPosition);
|
||||
|
||||
GestureStroke fingers(int fingers);
|
||||
|
||||
GestureStroke arrows(List<GestureArrow> arrows);
|
||||
|
||||
/// Creates a new instance with the provided field values.
|
||||
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GestureStroke(...).copyWith.fieldName(value)`.
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// GestureStroke(...).copyWith(id: 12, name: "My name")
|
||||
/// ```
|
||||
GestureStroke call({
|
||||
GestureStartPosition startPosition,
|
||||
int fingers,
|
||||
List<GestureArrow> arrows,
|
||||
});
|
||||
}
|
||||
|
||||
/// Callable proxy for `copyWith` functionality.
|
||||
/// Use as `instanceOfGestureStroke.copyWith(...)` or call `instanceOfGestureStroke.copyWith.fieldName(value)` for a single field.
|
||||
class _$GestureStrokeCWProxyImpl implements _$GestureStrokeCWProxy {
|
||||
const _$GestureStrokeCWProxyImpl(this._value);
|
||||
|
||||
final GestureStroke _value;
|
||||
|
||||
@override
|
||||
GestureStroke startPosition(GestureStartPosition startPosition) =>
|
||||
call(startPosition: startPosition);
|
||||
|
||||
@override
|
||||
GestureStroke fingers(int fingers) => call(fingers: fingers);
|
||||
|
||||
@override
|
||||
GestureStroke arrows(List<GestureArrow> arrows) => call(arrows: arrows);
|
||||
|
||||
@override
|
||||
/// Creates a new instance with the provided field values.
|
||||
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GestureStroke(...).copyWith.fieldName(value)`.
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// GestureStroke(...).copyWith(id: 12, name: "My name")
|
||||
/// ```
|
||||
GestureStroke call({
|
||||
Object? startPosition = const $CopyWithPlaceholder(),
|
||||
Object? fingers = const $CopyWithPlaceholder(),
|
||||
Object? arrows = const $CopyWithPlaceholder(),
|
||||
}) {
|
||||
return GestureStroke(
|
||||
startPosition:
|
||||
startPosition == const $CopyWithPlaceholder() || startPosition == null
|
||||
? _value.startPosition
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: startPosition as GestureStartPosition,
|
||||
fingers: fingers == const $CopyWithPlaceholder() || fingers == null
|
||||
? _value.fingers
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: fingers as int,
|
||||
arrows: arrows == const $CopyWithPlaceholder() || arrows == null
|
||||
? _value.arrows
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: arrows as List<GestureArrow>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension $GestureStrokeCopyWith on GestureStroke {
|
||||
/// Returns a callable class used to build a new instance with modified fields.
|
||||
/// Example: `instanceOfGestureStroke.copyWith(...)` or `instanceOfGestureStroke.copyWith.fieldName(...)`.
|
||||
// ignore: library_private_types_in_public_api
|
||||
_$GestureStrokeCWProxy get copyWith => _$GestureStrokeCWProxyImpl(this);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:nullability/nullability.dart';
|
||||
import 'package:riverpod/riverpod.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/features/gestures/data/models/gesture_settings.dart';
|
||||
import 'package:weblibre/features/user/data/providers.dart';
|
||||
|
||||
part 'gesture_settings.g.dart';
|
||||
|
||||
typedef UpdateGestureSettingsFunc =
|
||||
GestureSettings Function(GestureSettings currentSettings);
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class GestureSettingsRepository extends _$GestureSettingsRepository {
|
||||
final _partitionKey = 'gesture';
|
||||
|
||||
GestureSettings _deserializeSettings(
|
||||
List<MapEntry<String, DriftAny?>> entries,
|
||||
) {
|
||||
final settings = Map.fromEntries(entries);
|
||||
|
||||
final db = ref.read(userDatabaseProvider);
|
||||
|
||||
return GestureSettings.fromJson({
|
||||
'enabled': settings['enabled']?.readAs(DriftSqlType.bool, db.typeMapping),
|
||||
'active': settings['active']?.readAs(DriftSqlType.bool, db.typeMapping),
|
||||
'strokeSize': settings['strokeSize']?.readAs(
|
||||
DriftSqlType.int,
|
||||
db.typeMapping,
|
||||
),
|
||||
'timeoutMs': settings['timeoutMs']?.readAs(
|
||||
DriftSqlType.int,
|
||||
db.typeMapping,
|
||||
),
|
||||
'maxFingers': settings['maxFingers']?.readAs(
|
||||
DriftSqlType.int,
|
||||
db.typeMapping,
|
||||
),
|
||||
'intervalMs': settings['intervalMs']?.readAs(
|
||||
DriftSqlType.int,
|
||||
db.typeMapping,
|
||||
),
|
||||
'showFeedback': settings['showFeedback']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'suggestNext': settings['suggestNext']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'minSuggestionStroke': settings['minSuggestionStroke']?.readAs(
|
||||
DriftSqlType.int,
|
||||
db.typeMapping,
|
||||
),
|
||||
'excludedSites': settings['excludedSites']
|
||||
?.readAs(DriftSqlType.string, db.typeMapping)
|
||||
.mapNotNull(jsonDecode),
|
||||
'bindings': settings['bindings']
|
||||
?.readAs(DriftSqlType.string, db.typeMapping)
|
||||
.mapNotNull(jsonDecode),
|
||||
});
|
||||
}
|
||||
|
||||
//Eager fetch, when up to date settings are required
|
||||
Future<GestureSettings> fetchSettings() {
|
||||
return ref
|
||||
.read(userDatabaseProvider)
|
||||
.settingDao
|
||||
.getAllSettingsOfPartitionKey(_partitionKey)
|
||||
.get()
|
||||
.then(_deserializeSettings);
|
||||
}
|
||||
|
||||
Future<void> updateSettings(
|
||||
UpdateGestureSettingsFunc updateWithCurrent,
|
||||
) async {
|
||||
final db = ref.read(userDatabaseProvider);
|
||||
|
||||
final current = await fetchSettings();
|
||||
|
||||
final oldJson = current.toJson();
|
||||
final newJson = updateWithCurrent(current).toJson();
|
||||
|
||||
return db.transaction(() async {
|
||||
for (final MapEntry(:key, :value) in newJson.entries) {
|
||||
if (oldJson[key] != value) {
|
||||
await db.settingDao.updateSetting(key, _partitionKey, value);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<GestureSettings> build() {
|
||||
final db = ref.watch(userDatabaseProvider);
|
||||
|
||||
return db.settingDao
|
||||
.getAllSettingsOfPartitionKey(_partitionKey)
|
||||
.watch()
|
||||
.map((event) {
|
||||
return _deserializeSettings(event);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
GestureSettings gestureSettingsWithDefaults(Ref ref) {
|
||||
return ref.watch(
|
||||
gestureSettingsRepositoryProvider.select(
|
||||
(value) => value.value ?? GestureSettings.withDefaults(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'gesture_settings.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(GestureSettingsRepository)
|
||||
final gestureSettingsRepositoryProvider = GestureSettingsRepositoryProvider._();
|
||||
|
||||
final class GestureSettingsRepositoryProvider
|
||||
extends
|
||||
$StreamNotifierProvider<GestureSettingsRepository, GestureSettings> {
|
||||
GestureSettingsRepositoryProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'gestureSettingsRepositoryProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$gestureSettingsRepositoryHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
GestureSettingsRepository create() => GestureSettingsRepository();
|
||||
}
|
||||
|
||||
String _$gestureSettingsRepositoryHash() =>
|
||||
r'fc898b669a484fe45f89024d8456190f08d56085';
|
||||
|
||||
abstract class _$GestureSettingsRepository
|
||||
extends $StreamNotifier<GestureSettings> {
|
||||
Stream<GestureSettings> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<AsyncValue<GestureSettings>, GestureSettings>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<AsyncValue<GestureSettings>, GestureSettings>,
|
||||
AsyncValue<GestureSettings>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
|
||||
@ProviderFor(gestureSettingsWithDefaults)
|
||||
final gestureSettingsWithDefaultsProvider =
|
||||
GestureSettingsWithDefaultsProvider._();
|
||||
|
||||
final class GestureSettingsWithDefaultsProvider
|
||||
extends
|
||||
$FunctionalProvider<GestureSettings, GestureSettings, GestureSettings>
|
||||
with $Provider<GestureSettings> {
|
||||
GestureSettingsWithDefaultsProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'gestureSettingsWithDefaultsProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$gestureSettingsWithDefaultsHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<GestureSettings> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
GestureSettings create(Ref ref) {
|
||||
return gestureSettingsWithDefaults(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(GestureSettings value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<GestureSettings>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$gestureSettingsWithDefaultsHash() =>
|
||||
r'f9f242b81ef9d4d0594ae1700fa11db503583672';
|
||||
@@ -0,0 +1,347 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/core/logger.dart';
|
||||
import 'package:weblibre/core/providers/router.dart';
|
||||
import 'package:weblibre/core/routing/routes.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers/desktop_mode.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/utils/bookmark_tree_utils.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/domain/entities/font_size_constants.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/translation_bottom_sheet.dart';
|
||||
import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart';
|
||||
import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
|
||||
import 'package:weblibre/features/gestures/data/models/gesture_action.dart';
|
||||
import 'package:weblibre/features/gestures/data/models/gesture_settings.dart';
|
||||
import 'package:weblibre/features/gestures/data/models/gesture_stroke.dart';
|
||||
import 'package:weblibre/features/gestures/domain/repositories/gesture_settings.dart';
|
||||
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
|
||||
import 'package:weblibre/features/user/data/models/engine_settings.dart';
|
||||
import 'package:weblibre/features/user/domain/presentation/dialogs/quit_browser_dialog.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/engine_settings.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
|
||||
import 'package:weblibre/utils/exit_app.dart';
|
||||
import 'package:weblibre/utils/move_to_background.dart';
|
||||
|
||||
part 'gesture_control.g.dart';
|
||||
|
||||
/// Bridges gesture settings and recognized-gesture events to app actions.
|
||||
///
|
||||
/// On build it (1) keeps the native recognizer's [GestureConfig] in sync with
|
||||
/// the user's [GestureSettings], and (2) subscribes to recognized gestures and
|
||||
/// dispatches the bound [GestureAction] against the currently selected tab.
|
||||
///
|
||||
/// Must be kept alive (eagerly listened to from the browser view) for the
|
||||
/// lifetime of the browser so the subscription stays active.
|
||||
@Riverpod(keepAlive: true)
|
||||
class GestureControlService extends _$GestureControlService {
|
||||
/// Timestamp (ms since epoch) of the last fired gesture, for cooldown.
|
||||
int _lastDispatchMs = 0;
|
||||
|
||||
@override
|
||||
void build() {
|
||||
final service = ref.read(gestureServiceProvider);
|
||||
|
||||
// Keep the native recognizer in sync with the effective configuration
|
||||
// (settings folded together with the current site's exclusion state).
|
||||
ref.listen(
|
||||
fireImmediately: true,
|
||||
gestureNativeConfigProvider,
|
||||
(previous, next) async {
|
||||
await service.setGestureConfig(next);
|
||||
},
|
||||
onError: (error, stackTrace) {
|
||||
logger.e(
|
||||
'Error syncing gesture configuration',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
final subscription = service.recognizedGestures.listen(
|
||||
(gestureKey) {
|
||||
unawaited(_dispatch(gestureKey));
|
||||
},
|
||||
onError: (Object error, StackTrace stackTrace) {
|
||||
logger.e(
|
||||
'Error handling recognized gesture',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
},
|
||||
);
|
||||
ref.onDispose(subscription.cancel);
|
||||
}
|
||||
|
||||
Future<void> _dispatch(String gestureKey) async {
|
||||
final settings = ref.read(gestureSettingsWithDefaultsProvider);
|
||||
final action = settings.bindings[gestureKey];
|
||||
if (action == null) return;
|
||||
|
||||
// Cooldown: ignore gestures fired within intervalMs of the previous one.
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
if (settings.intervalMs > 0 &&
|
||||
now - _lastDispatchMs < settings.intervalMs) {
|
||||
return;
|
||||
}
|
||||
_lastDispatchMs = now;
|
||||
|
||||
final tabId = ref.read(selectedTabProvider);
|
||||
if (tabId == null) return;
|
||||
|
||||
try {
|
||||
// Feedback is handled live by the gesture overlay while the stroke is
|
||||
// drawn (driven by the native progress events), so nothing to show here.
|
||||
await _execute(action, tabId);
|
||||
} catch (error, stackTrace) {
|
||||
logger.e(
|
||||
'Error executing gesture action ${action.name}',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _execute(GestureAction action, String tabId) async {
|
||||
final tabRepository = ref.read(tabRepositoryProvider.notifier);
|
||||
|
||||
switch (action) {
|
||||
case GestureAction.back:
|
||||
await ref.read(tabSessionProvider(tabId: tabId).notifier).goBack();
|
||||
case GestureAction.forward:
|
||||
await ref.read(tabSessionProvider(tabId: tabId).notifier).goForward();
|
||||
case GestureAction.reload:
|
||||
await ref.read(tabSessionProvider(tabId: tabId).notifier).reload();
|
||||
case GestureAction.scrollTop:
|
||||
await ref.read(tabSessionProvider(tabId: tabId).notifier).scrollToTop();
|
||||
case GestureAction.scrollBottom:
|
||||
await ref
|
||||
.read(tabSessionProvider(tabId: tabId).notifier)
|
||||
.scrollToBottom();
|
||||
case GestureAction.pageUp:
|
||||
await ref.read(tabSessionProvider(tabId: tabId).notifier).pageUp();
|
||||
case GestureAction.pageDown:
|
||||
await ref.read(tabSessionProvider(tabId: tabId).notifier).pageDown();
|
||||
case GestureAction.newTab:
|
||||
await _openNewTab(tabId);
|
||||
case GestureAction.closeTab:
|
||||
await tabRepository.closeTab(tabId);
|
||||
case GestureAction.duplicateTab:
|
||||
final containerData = await ref
|
||||
.read(tabDataRepositoryProvider.notifier)
|
||||
.getTabContainerData(tabId);
|
||||
await tabRepository.duplicateTab(
|
||||
selectTabId: tabId,
|
||||
containerData: containerData,
|
||||
selectTab: true,
|
||||
);
|
||||
case GestureAction.nextTab:
|
||||
await tabRepository.selectNextTab(tabId);
|
||||
case GestureAction.previousTab:
|
||||
await tabRepository.selectPreviousTab(tabId);
|
||||
case GestureAction.lastUsedTab:
|
||||
await tabRepository.selectPreviouslyOpenedTab(tabId);
|
||||
case GestureAction.togglePinTab:
|
||||
final pinned =
|
||||
ref.read(watchPinnedTabIdsProvider).value?.contains(tabId) ?? false;
|
||||
await ref
|
||||
.read(tabDataRepositoryProvider.notifier)
|
||||
.setPinned(tabId, pinned: !pinned);
|
||||
case GestureAction.toggleReaderMode:
|
||||
final readerActive =
|
||||
ref.read(tabStateProvider(tabId))?.readerableState.active ?? false;
|
||||
await ref
|
||||
.read(readerableScreenControllerProvider.notifier)
|
||||
.toggleReaderView(!readerActive);
|
||||
case GestureAction.toggleDesktopMode:
|
||||
ref.read(desktopModeProvider(tabId).notifier).toggle();
|
||||
case GestureAction.findInPage:
|
||||
ref.read(findInPageControllerProvider(tabId).notifier).show();
|
||||
case GestureAction.increaseFontSize:
|
||||
await _adjustFontSize(increase: true);
|
||||
case GestureAction.decreaseFontSize:
|
||||
await _adjustFontSize(increase: false);
|
||||
case GestureAction.showHistory:
|
||||
await _pushLocation(const HistoryRoute().location);
|
||||
case GestureAction.showBookmarks:
|
||||
await _pushLocation(
|
||||
BookmarkListRoute(entryGuid: BookmarkRoot.root.id).location,
|
||||
);
|
||||
case GestureAction.toggleBookmark:
|
||||
await _toggleBookmark(tabId);
|
||||
case GestureAction.translatePage:
|
||||
final context = await _navigatorContext();
|
||||
if (context != null && context.mounted) {
|
||||
await showTranslationBottomSheet(context, selectedTabId: tabId);
|
||||
}
|
||||
case GestureAction.moveToBackground:
|
||||
await moveToBackground();
|
||||
case GestureAction.quitBrowser:
|
||||
final context = await _navigatorContext();
|
||||
if (context != null && context.mounted) {
|
||||
final confirmed = await showQuitBrowserDialog(context);
|
||||
if (confirmed == true && context.mounted) {
|
||||
await exitApp(ProviderScope.containerOf(context, listen: false));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds the current page to bookmarks, or removes it if already bookmarked,
|
||||
/// mirroring the contextual toolbar's bookmark toggle button.
|
||||
Future<void> _toggleBookmark(String tabId) async {
|
||||
final tabState = ref.read(tabStateProvider(tabId));
|
||||
if (tabState == null) return;
|
||||
|
||||
final bookmarkUrl =
|
||||
ref.read(sandboxSourceUriForTabProvider(tabId: tabId)) ?? tabState.url;
|
||||
|
||||
final bookmarks = ref.read(bookmarksRepositoryProvider).value;
|
||||
final existingGuids = bookmarkGuidsForUrl(bookmarks, bookmarkUrl);
|
||||
|
||||
final repository = ref.read(bookmarksRepositoryProvider.notifier);
|
||||
if (existingGuids.isNotEmpty) {
|
||||
for (final guid in existingGuids) {
|
||||
await repository.delete(guid);
|
||||
}
|
||||
} else {
|
||||
await repository.addBookmark(
|
||||
parentGuid: BookmarkRoot.mobile.id,
|
||||
url: bookmarkUrl,
|
||||
title: tabState.titleOrAuthority,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the root navigator's [BuildContext] for actions that open a
|
||||
/// dialog/sheet. Null when the navigator is not currently mounted.
|
||||
Future<BuildContext?> _navigatorContext() async {
|
||||
final router = await ref.read(routerProvider.future);
|
||||
if (!ref.mounted) return null;
|
||||
return router.routerDelegate.navigatorKey.currentContext;
|
||||
}
|
||||
|
||||
Future<void> _pushLocation(String location) async {
|
||||
final router = await ref.read(routerProvider.future);
|
||||
if (!ref.mounted) return;
|
||||
await router.push(location);
|
||||
}
|
||||
|
||||
Future<void> _adjustFontSize({required bool increase}) async {
|
||||
final settings = ref.read(engineSettingsWithDefaultsProvider);
|
||||
|
||||
// Manual adjustment is a no-op while automatic font sizing is enabled.
|
||||
if (settings.automaticFontSizeAdjustment) return;
|
||||
|
||||
final current = settings.fontSizeFactor;
|
||||
final newValue = increase
|
||||
? (current + fontSizeStep).clamp(fontSizeMin, fontSizeMax)
|
||||
: (current - fontSizeStep).clamp(fontSizeMin, fontSizeMax);
|
||||
final rounded = (newValue * 10).round() / 10;
|
||||
if (rounded == current) return;
|
||||
|
||||
await ref
|
||||
.read(saveEngineSettingsControllerProvider.notifier)
|
||||
.save(
|
||||
(currentSettings) => currentSettings.copyWith.fontSizeFactor(rounded),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openNewTab(String tabId) async {
|
||||
final router = await ref.read(routerProvider.future);
|
||||
if (!ref.mounted) return;
|
||||
|
||||
final settings = ref.read(generalSettingsWithDefaultsProvider);
|
||||
final selectedTabType = ref
|
||||
.read(tabStatesProvider)[tabId]
|
||||
?.tabMode
|
||||
.toTabType();
|
||||
|
||||
final route = SearchRoute(
|
||||
tabType: selectedTabType ?? settings.effectiveDefaultCreateTabType,
|
||||
);
|
||||
await router.push(route.location);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether gestures are currently disabled because the selected tab's site is
|
||||
/// on the user's exclusion list.
|
||||
@Riverpod(keepAlive: true)
|
||||
bool gestureSiteExcluded(Ref ref) {
|
||||
final excludedSites = ref.watch(
|
||||
gestureSettingsWithDefaultsProvider.select((s) => s.excludedSites),
|
||||
);
|
||||
if (excludedSites.isEmpty) return false;
|
||||
|
||||
final tabId = ref.watch(selectedTabProvider);
|
||||
if (tabId == null) return false;
|
||||
|
||||
final url = ref.watch(tabStateProvider(tabId).select((state) => state?.url));
|
||||
if (url == null) return false;
|
||||
|
||||
return isGestureSiteExcluded(url, excludedSites);
|
||||
}
|
||||
|
||||
/// The effective native recognizer configuration: the user's settings with the
|
||||
/// recognizer disabled while the current site is excluded.
|
||||
@Riverpod(keepAlive: true)
|
||||
GestureConfig gestureNativeConfig(Ref ref) {
|
||||
final settings = ref.watch(gestureSettingsWithDefaultsProvider);
|
||||
final excluded = ref.watch(gestureSiteExcludedProvider);
|
||||
|
||||
// Recognize as many fingers as any bound gesture requires, so multi-finger
|
||||
// bindings work without a separate finger setting.
|
||||
final requiredFingers = settings.bindings.keys
|
||||
.map((key) => GestureStroke.fromKey(key).fingers)
|
||||
.fold(settings.maxFingers, max);
|
||||
|
||||
return GestureConfig(
|
||||
enabled: settings.effectiveEnabled && !excluded,
|
||||
strokeSize: settings.strokeSize,
|
||||
timeoutMs: settings.timeoutMs,
|
||||
maxFingers: requiredFingers,
|
||||
activeGestureKeys: settings.bindings.keys.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// The in-progress stroke shown by the live feedback overlay.
|
||||
///
|
||||
/// Emits the current partial canonical key (e.g. `R:D`) while a stroke is being
|
||||
/// drawn, and null when nothing should be shown — driven by the native gesture
|
||||
/// progress/reset events.
|
||||
@riverpod
|
||||
Stream<String?> gestureProgress(Ref ref) {
|
||||
return ref.watch(gestureServiceProvider).gestureProgress;
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'gesture_control.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// Bridges gesture settings and recognized-gesture events to app actions.
|
||||
///
|
||||
/// On build it (1) keeps the native recognizer's [GestureConfig] in sync with
|
||||
/// the user's [GestureSettings], and (2) subscribes to recognized gestures and
|
||||
/// dispatches the bound [GestureAction] against the currently selected tab.
|
||||
///
|
||||
/// Must be kept alive (eagerly listened to from the browser view) for the
|
||||
/// lifetime of the browser so the subscription stays active.
|
||||
|
||||
@ProviderFor(GestureControlService)
|
||||
final gestureControlServiceProvider = GestureControlServiceProvider._();
|
||||
|
||||
/// Bridges gesture settings and recognized-gesture events to app actions.
|
||||
///
|
||||
/// On build it (1) keeps the native recognizer's [GestureConfig] in sync with
|
||||
/// the user's [GestureSettings], and (2) subscribes to recognized gestures and
|
||||
/// dispatches the bound [GestureAction] against the currently selected tab.
|
||||
///
|
||||
/// Must be kept alive (eagerly listened to from the browser view) for the
|
||||
/// lifetime of the browser so the subscription stays active.
|
||||
final class GestureControlServiceProvider
|
||||
extends $NotifierProvider<GestureControlService, void> {
|
||||
/// Bridges gesture settings and recognized-gesture events to app actions.
|
||||
///
|
||||
/// On build it (1) keeps the native recognizer's [GestureConfig] in sync with
|
||||
/// the user's [GestureSettings], and (2) subscribes to recognized gestures and
|
||||
/// dispatches the bound [GestureAction] against the currently selected tab.
|
||||
///
|
||||
/// Must be kept alive (eagerly listened to from the browser view) for the
|
||||
/// lifetime of the browser so the subscription stays active.
|
||||
GestureControlServiceProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'gestureControlServiceProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$gestureControlServiceHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
GestureControlService create() => GestureControlService();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(void value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<void>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$gestureControlServiceHash() =>
|
||||
r'12bd852a5b90b67bee4a94e7bd55fccc53c11bd4';
|
||||
|
||||
/// Bridges gesture settings and recognized-gesture events to app actions.
|
||||
///
|
||||
/// On build it (1) keeps the native recognizer's [GestureConfig] in sync with
|
||||
/// the user's [GestureSettings], and (2) subscribes to recognized gestures and
|
||||
/// dispatches the bound [GestureAction] against the currently selected tab.
|
||||
///
|
||||
/// Must be kept alive (eagerly listened to from the browser view) for the
|
||||
/// lifetime of the browser so the subscription stays active.
|
||||
|
||||
abstract class _$GestureControlService extends $Notifier<void> {
|
||||
void build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<void, void>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<void, void>,
|
||||
void,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether gestures are currently disabled because the selected tab's site is
|
||||
/// on the user's exclusion list.
|
||||
|
||||
@ProviderFor(gestureSiteExcluded)
|
||||
final gestureSiteExcludedProvider = GestureSiteExcludedProvider._();
|
||||
|
||||
/// Whether gestures are currently disabled because the selected tab's site is
|
||||
/// on the user's exclusion list.
|
||||
|
||||
final class GestureSiteExcludedProvider
|
||||
extends $FunctionalProvider<bool, bool, bool>
|
||||
with $Provider<bool> {
|
||||
/// Whether gestures are currently disabled because the selected tab's site is
|
||||
/// on the user's exclusion list.
|
||||
GestureSiteExcludedProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'gestureSiteExcludedProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$gestureSiteExcludedHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<bool> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
bool create(Ref ref) {
|
||||
return gestureSiteExcluded(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(bool value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<bool>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$gestureSiteExcludedHash() =>
|
||||
r'15dd371a176303a7c52c37df560c93bbb03c5039';
|
||||
|
||||
/// The effective native recognizer configuration: the user's settings with the
|
||||
/// recognizer disabled while the current site is excluded.
|
||||
|
||||
@ProviderFor(gestureNativeConfig)
|
||||
final gestureNativeConfigProvider = GestureNativeConfigProvider._();
|
||||
|
||||
/// The effective native recognizer configuration: the user's settings with the
|
||||
/// recognizer disabled while the current site is excluded.
|
||||
|
||||
final class GestureNativeConfigProvider
|
||||
extends $FunctionalProvider<GestureConfig, GestureConfig, GestureConfig>
|
||||
with $Provider<GestureConfig> {
|
||||
/// The effective native recognizer configuration: the user's settings with the
|
||||
/// recognizer disabled while the current site is excluded.
|
||||
GestureNativeConfigProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'gestureNativeConfigProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$gestureNativeConfigHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<GestureConfig> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
GestureConfig create(Ref ref) {
|
||||
return gestureNativeConfig(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(GestureConfig value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<GestureConfig>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$gestureNativeConfigHash() =>
|
||||
r'57aada8631dfc3d8355f9498f6b7c6954b0cdcf1';
|
||||
|
||||
/// The in-progress stroke shown by the live feedback overlay.
|
||||
///
|
||||
/// Emits the current partial canonical key (e.g. `R:D`) while a stroke is being
|
||||
/// drawn, and null when nothing should be shown — driven by the native gesture
|
||||
/// progress/reset events.
|
||||
|
||||
@ProviderFor(gestureProgress)
|
||||
final gestureProgressProvider = GestureProgressProvider._();
|
||||
|
||||
/// The in-progress stroke shown by the live feedback overlay.
|
||||
///
|
||||
/// Emits the current partial canonical key (e.g. `R:D`) while a stroke is being
|
||||
/// drawn, and null when nothing should be shown — driven by the native gesture
|
||||
/// progress/reset events.
|
||||
|
||||
final class GestureProgressProvider
|
||||
extends $FunctionalProvider<AsyncValue<String?>, String?, Stream<String?>>
|
||||
with $FutureModifier<String?>, $StreamProvider<String?> {
|
||||
/// The in-progress stroke shown by the live feedback overlay.
|
||||
///
|
||||
/// Emits the current partial canonical key (e.g. `R:D`) while a stroke is being
|
||||
/// drawn, and null when nothing should be shown — driven by the native gesture
|
||||
/// progress/reset events.
|
||||
GestureProgressProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'gestureProgressProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$gestureProgressHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$StreamProviderElement<String?> $createElement($ProviderPointer pointer) =>
|
||||
$StreamProviderElement(pointer);
|
||||
|
||||
@override
|
||||
Stream<String?> create(Ref ref) {
|
||||
return gestureProgress(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$gestureProgressHash() => r'2f3ed4a8db41d317869db2e5aa71e737afb5364b';
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/features/gestures/data/models/gesture_settings.dart';
|
||||
import 'package:weblibre/features/gestures/domain/repositories/gesture_settings.dart';
|
||||
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
|
||||
|
||||
const List<SettingsSectionDefinition> _behaviorSections = [
|
||||
SettingsSectionDefinition(
|
||||
title: 'Strokes',
|
||||
entries: [
|
||||
SettingsEntryDefinition(
|
||||
title: 'Stroke sensitivity',
|
||||
subtitle: 'Minimum swipe length recognised as a direction',
|
||||
keywords: ['size', 'length'],
|
||||
child: _StrokeSensitivitySection(),
|
||||
),
|
||||
],
|
||||
),
|
||||
SettingsSectionDefinition(
|
||||
title: 'Timing',
|
||||
entries: [
|
||||
SettingsEntryDefinition(
|
||||
title: 'Timeout',
|
||||
subtitle: 'Drop a stroke if no new direction is drawn',
|
||||
keywords: ['delay'],
|
||||
child: _TimeoutSection(),
|
||||
),
|
||||
SettingsEntryDefinition(
|
||||
title: 'Cooldown',
|
||||
subtitle: 'Minimum delay between two gestures firing',
|
||||
keywords: ['interval'],
|
||||
child: _CooldownSection(),
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
/// Tuning for the gesture recognizer: stroke size, idle timeout and cooldown.
|
||||
class GestureBehaviorScreen extends StatelessWidget {
|
||||
const GestureBehaviorScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const SettingsDetailScaffold(
|
||||
title: 'Behavior & timing',
|
||||
subtitle: 'Stroke sensitivity, timeout, and cooldown.',
|
||||
icon: Icons.tune,
|
||||
sections: _behaviorSections,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StrokeSensitivitySection extends HookConsumerWidget {
|
||||
const _StrokeSensitivitySection();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(gestureSettingsWithDefaultsProvider);
|
||||
|
||||
return ListTile(
|
||||
leading: const Icon(MdiIcons.gestureTap),
|
||||
title: const Text('Stroke sensitivity'),
|
||||
subtitle: Slider.adaptive(
|
||||
min: minGestureStrokeSize.toDouble(),
|
||||
max: maxGestureStrokeSize.toDouble(),
|
||||
divisions: maxGestureStrokeSize - minGestureStrokeSize,
|
||||
value: settings.strokeSize
|
||||
.clamp(minGestureStrokeSize, maxGestureStrokeSize)
|
||||
.toDouble(),
|
||||
label: '${settings.strokeSize}',
|
||||
onChanged: (value) async {
|
||||
await ref
|
||||
.read(gestureSettingsRepositoryProvider.notifier)
|
||||
.updateSettings(
|
||||
(current) => current.copyWith.strokeSize(value.round()),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TimeoutSection extends HookConsumerWidget {
|
||||
const _TimeoutSection();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(gestureSettingsWithDefaultsProvider);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.hourglass_empty),
|
||||
title: const Text('Timeout'),
|
||||
subtitle: Slider.adaptive(
|
||||
min: minGestureTimeoutMs.toDouble(),
|
||||
max: maxGestureTimeoutMs.toDouble(),
|
||||
divisions: (maxGestureTimeoutMs - minGestureTimeoutMs) ~/ 100,
|
||||
value: settings.timeoutMs
|
||||
.clamp(minGestureTimeoutMs, maxGestureTimeoutMs)
|
||||
.toDouble(),
|
||||
label: '${settings.timeoutMs} ms',
|
||||
onChanged: (value) async {
|
||||
await ref
|
||||
.read(gestureSettingsRepositoryProvider.notifier)
|
||||
.updateSettings(
|
||||
(current) => current.copyWith.timeoutMs(value.round()),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(72, 0, 16, 8),
|
||||
child: Text(
|
||||
'A stroke is dropped if no new direction is drawn within this time.',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CooldownSection extends HookConsumerWidget {
|
||||
const _CooldownSection();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(gestureSettingsWithDefaultsProvider);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.timer_outlined),
|
||||
title: const Text('Cooldown'),
|
||||
subtitle: Slider.adaptive(
|
||||
min: minGestureIntervalMs.toDouble(),
|
||||
max: maxGestureIntervalMs.toDouble(),
|
||||
divisions: (maxGestureIntervalMs - minGestureIntervalMs) ~/ 100,
|
||||
value: settings.intervalMs
|
||||
.clamp(minGestureIntervalMs, maxGestureIntervalMs)
|
||||
.toDouble(),
|
||||
label: settings.intervalMs == 0 ? 'Off' : '${settings.intervalMs} ms',
|
||||
onChanged: (value) async {
|
||||
await ref
|
||||
.read(gestureSettingsRepositoryProvider.notifier)
|
||||
.updateSettings(
|
||||
(current) => current.copyWith.intervalMs(value.round()),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(72, 0, 16, 8),
|
||||
child: Text('Minimum delay between two gestures firing.'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/features/gestures/data/models/gesture_action.dart';
|
||||
import 'package:weblibre/features/gestures/data/models/gesture_settings.dart';
|
||||
import 'package:weblibre/features/gestures/data/models/gesture_stroke.dart';
|
||||
import 'package:weblibre/features/gestures/domain/repositories/gesture_settings.dart';
|
||||
import 'package:weblibre/features/gestures/presentation/widgets/gesture_binding_editor.dart';
|
||||
import 'package:weblibre/features/gestures/presentation/widgets/gesture_stroke_view.dart';
|
||||
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
|
||||
|
||||
/// Lists the configured gesture → action bindings, grouped by action category,
|
||||
/// and lets the user add, edit and remove them.
|
||||
class GestureBindingsScreen extends HookConsumerWidget {
|
||||
const GestureBindingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(gestureSettingsWithDefaultsProvider);
|
||||
|
||||
Future<void> upsertBinding(
|
||||
({GestureStroke stroke, GestureAction action}) result, {
|
||||
String? replacedKey,
|
||||
}) async {
|
||||
await ref.read(gestureSettingsRepositoryProvider.notifier).updateSettings(
|
||||
(current) {
|
||||
final bindings = Map<String, GestureAction>.from(current.bindings);
|
||||
if (replacedKey != null) {
|
||||
bindings.remove(replacedKey);
|
||||
}
|
||||
bindings[result.stroke.key] = result.action;
|
||||
return current.copyWith.bindings(bindings);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> removeBinding(String key) async {
|
||||
await ref.read(gestureSettingsRepositoryProvider.notifier).updateSettings(
|
||||
(current) {
|
||||
final bindings = Map<String, GestureAction>.from(current.bindings)
|
||||
..remove(key);
|
||||
return current.copyWith.bindings(bindings);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Group bindings by their action's category, preserving category order.
|
||||
final byCategory =
|
||||
<GestureActionCategory, List<MapEntry<String, GestureAction>>>{};
|
||||
for (final entry in settings.bindings.entries) {
|
||||
byCategory.putIfAbsent(entry.value.category, () => []).add(entry);
|
||||
}
|
||||
for (final entries in byCategory.values) {
|
||||
entries.sort((a, b) => a.value.title.compareTo(b.value.title));
|
||||
}
|
||||
|
||||
return SettingsCustomScrollScaffold(
|
||||
title: 'Gesture bindings',
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add gesture'),
|
||||
onPressed: () async {
|
||||
final result = await showGestureBindingEditor(
|
||||
context,
|
||||
existingBindings: settings.bindings,
|
||||
maxFingers: settings.maxFingers,
|
||||
);
|
||||
if (result != null) {
|
||||
await upsertBinding(result);
|
||||
}
|
||||
},
|
||||
),
|
||||
slivers: [
|
||||
if (settings.bindings.isEmpty)
|
||||
const SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(child: Text('No gestures assigned yet.')),
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 88),
|
||||
sliver: SliverList.list(
|
||||
children: [
|
||||
for (final category in GestureActionCategory.values)
|
||||
if (byCategory[category] case final entries?
|
||||
when entries.isNotEmpty)
|
||||
_GestureBindingGroup(
|
||||
title: category.label,
|
||||
children: [
|
||||
for (final binding in entries)
|
||||
_GestureBindingTile(
|
||||
gestureKey: binding.key,
|
||||
action: binding.value,
|
||||
onEdit: () async {
|
||||
final result = await showGestureBindingEditor(
|
||||
context,
|
||||
initialStroke: GestureStroke.fromKey(
|
||||
binding.key,
|
||||
),
|
||||
initialAction: binding.value,
|
||||
existingBindings: settings.bindings,
|
||||
maxFingers: settings.maxFingers,
|
||||
);
|
||||
if (result != null) {
|
||||
await upsertBinding(
|
||||
result,
|
||||
replacedKey: binding.key,
|
||||
);
|
||||
}
|
||||
},
|
||||
onRemove: () => removeBinding(binding.key),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A category heading followed by a filled card grouping its binding tiles,
|
||||
/// matching the visual language of the other settings screens.
|
||||
class _GestureBindingGroup extends StatelessWidget {
|
||||
final String title;
|
||||
final List<Widget> children;
|
||||
|
||||
const _GestureBindingGroup({required this.title, required this.children});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Card.filled(
|
||||
margin: EdgeInsets.zero,
|
||||
color: theme.colorScheme.surfaceContainer,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 0; i < children.length; i++) ...[
|
||||
if (i > 0) const Divider(height: 1),
|
||||
children[i],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GestureBindingTile extends StatelessWidget {
|
||||
final String gestureKey;
|
||||
final GestureAction action;
|
||||
final Future<void> Function() onEdit;
|
||||
final Future<void> Function() onRemove;
|
||||
|
||||
const _GestureBindingTile({
|
||||
required this.gestureKey,
|
||||
required this.action,
|
||||
required this.onEdit,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final stroke = GestureStroke.fromKey(gestureKey);
|
||||
|
||||
return ListTile(
|
||||
leading: Icon(action.icon),
|
||||
title: Text(action.title),
|
||||
subtitle: GestureStrokeView(stroke: stroke),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
tooltip: 'Remove',
|
||||
onPressed: () => onRemove(),
|
||||
),
|
||||
onTap: () => onEdit(),
|
||||
);
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/features/gestures/data/models/gesture_settings.dart';
|
||||
import 'package:weblibre/features/gestures/domain/repositories/gesture_settings.dart';
|
||||
import 'package:weblibre/features/gestures/presentation/widgets/string_list_editor.dart';
|
||||
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
|
||||
|
||||
/// Manages the list of sites on which gestures are disabled.
|
||||
class GestureExcludedSitesScreen extends HookConsumerWidget {
|
||||
const GestureExcludedSitesScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final excludedSites = ref.watch(
|
||||
gestureSettingsWithDefaultsProvider.select((s) => s.excludedSites),
|
||||
);
|
||||
|
||||
return SettingsCustomScrollScaffold(
|
||||
title: 'Excluded sites',
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: Text(
|
||||
'Gestures are disabled on these sites. Subdomains are included '
|
||||
'(e.g. "example.com" also covers "m.example.com").',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: StringListEditor(
|
||||
values: excludedSites,
|
||||
hintText: 'example.com',
|
||||
itemIcon: Icons.public_off,
|
||||
emptyLabel: 'No sites excluded.',
|
||||
normalize: normalizeGestureSiteHost,
|
||||
onChanged: (next) async {
|
||||
await ref
|
||||
.read(gestureSettingsRepositoryProvider.notifier)
|
||||
.updateSettings(
|
||||
(current) => current.copyWith.excludedSites(next),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/features/gestures/data/models/gesture_settings.dart';
|
||||
import 'package:weblibre/features/gestures/domain/repositories/gesture_settings.dart';
|
||||
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
|
||||
|
||||
const List<SettingsSectionDefinition> _feedbackSections = [
|
||||
SettingsSectionDefinition(
|
||||
title: 'Overlay',
|
||||
entries: [
|
||||
SettingsEntryDefinition(
|
||||
title: 'Live feedback',
|
||||
subtitle: 'Show the stroke and its action while you draw',
|
||||
child: _LiveFeedbackTile(),
|
||||
),
|
||||
SettingsEntryDefinition(
|
||||
title: 'Suggest next',
|
||||
subtitle: 'Also show the other gestures you can complete',
|
||||
child: _SuggestNextTile(),
|
||||
),
|
||||
SettingsEntryDefinition(
|
||||
title: 'Suggest after',
|
||||
subtitle: 'Number of strokes to draw before suggestions appear',
|
||||
child: _SuggestAfterSection(),
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
/// Controls the live feedback overlay shown while drawing a gesture.
|
||||
class GestureFeedbackScreen extends StatelessWidget {
|
||||
const GestureFeedbackScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const SettingsDetailScaffold(
|
||||
title: 'Feedback',
|
||||
subtitle: 'Live overlay and gesture suggestions.',
|
||||
icon: Icons.bolt_outlined,
|
||||
sections: _feedbackSections,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LiveFeedbackTile extends HookConsumerWidget {
|
||||
const _LiveFeedbackTile();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final showFeedback = ref.watch(
|
||||
gestureSettingsWithDefaultsProvider.select((s) => s.showFeedback),
|
||||
);
|
||||
|
||||
return SwitchListTile.adaptive(
|
||||
secondary: const Icon(Icons.bolt_outlined),
|
||||
title: const Text('Live feedback'),
|
||||
subtitle: const Text('Show the stroke and its action while you draw'),
|
||||
value: showFeedback,
|
||||
onChanged: (value) async {
|
||||
await ref
|
||||
.read(gestureSettingsRepositoryProvider.notifier)
|
||||
.updateSettings((current) => current.copyWith.showFeedback(value));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SuggestNextTile extends HookConsumerWidget {
|
||||
const _SuggestNextTile();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(gestureSettingsWithDefaultsProvider);
|
||||
|
||||
return SwitchListTile.adaptive(
|
||||
secondary: const Icon(Icons.lightbulb_outline),
|
||||
title: const Text('Suggest next'),
|
||||
subtitle: const Text('Also show the other gestures you can complete'),
|
||||
value: settings.suggestNext,
|
||||
onChanged: settings.showFeedback
|
||||
? (value) async {
|
||||
await ref
|
||||
.read(gestureSettingsRepositoryProvider.notifier)
|
||||
.updateSettings(
|
||||
(current) => current.copyWith.suggestNext(value),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SuggestAfterSection extends HookConsumerWidget {
|
||||
const _SuggestAfterSection();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(gestureSettingsWithDefaultsProvider);
|
||||
final enabled = settings.showFeedback && settings.suggestNext;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
enabled: enabled,
|
||||
leading: const Icon(Icons.straighten),
|
||||
title: const Text('Suggest after'),
|
||||
subtitle: Slider.adaptive(
|
||||
min: minGestureMinSuggestionStroke.toDouble(),
|
||||
max: maxGestureMinSuggestionStroke.toDouble(),
|
||||
divisions:
|
||||
maxGestureMinSuggestionStroke - minGestureMinSuggestionStroke,
|
||||
value: settings.minSuggestionStroke
|
||||
.clamp(
|
||||
minGestureMinSuggestionStroke,
|
||||
maxGestureMinSuggestionStroke,
|
||||
)
|
||||
.toDouble(),
|
||||
label: '${settings.minSuggestionStroke} strokes',
|
||||
onChanged: enabled
|
||||
? (value) async {
|
||||
await ref
|
||||
.read(gestureSettingsRepositoryProvider.notifier)
|
||||
.updateSettings(
|
||||
(current) =>
|
||||
current.copyWith.minSuggestionStroke(value.round()),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(72, 0, 16, 8),
|
||||
child: Text('Number of strokes to draw before suggestions appear.'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/features/gestures/data/models/gesture_settings.dart';
|
||||
import 'package:weblibre/features/gestures/domain/repositories/gesture_settings.dart';
|
||||
import 'package:weblibre/features/gestures/presentation/screens/gesture_behavior_screen.dart';
|
||||
import 'package:weblibre/features/gestures/presentation/screens/gesture_bindings_screen.dart';
|
||||
import 'package:weblibre/features/gestures/presentation/screens/gesture_excluded_sites_screen.dart';
|
||||
import 'package:weblibre/features/gestures/presentation/screens/gesture_feedback_screen.dart';
|
||||
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
|
||||
|
||||
/// Overview screen for gesture configuration: a master switch plus entries that
|
||||
/// open the dedicated bindings / behavior / excluded-sites / feedback subpages.
|
||||
class GestureSettingsScreen extends HookConsumerWidget {
|
||||
const GestureSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(gestureSettingsWithDefaultsProvider);
|
||||
final repository = ref.read(gestureSettingsRepositoryProvider.notifier);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
void open(Widget screen) {
|
||||
unawaited(
|
||||
Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute<void>(builder: (_) => screen)),
|
||||
);
|
||||
}
|
||||
|
||||
return SettingsCustomScrollScaffold(
|
||||
title: 'Gestures',
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Card.filled(
|
||||
margin: EdgeInsets.zero,
|
||||
color: colorScheme.primaryContainer,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: SwitchListTile.adaptive(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 8,
|
||||
),
|
||||
secondary: Icon(
|
||||
MdiIcons.gestureSwipe,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
title: Text(
|
||||
'Enable Gestures',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
'Draw stroke gestures on web pages to trigger actions',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onPrimaryContainer.withValues(
|
||||
alpha: 0.8,
|
||||
),
|
||||
),
|
||||
),
|
||||
value: settings.enabled,
|
||||
onChanged: (value) async {
|
||||
await repository.updateSettings(
|
||||
(current) => current.copyWith.enabled(value),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (settings.enabled)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 24, 16, 20),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: buildSettingsSectionWidgets(context, [
|
||||
SettingsSectionDefinition(
|
||||
title: 'Configuration',
|
||||
entries: [
|
||||
SettingsEntryDefinition(
|
||||
title: 'Gesture bindings',
|
||||
child: ListTile(
|
||||
leading: const Icon(MdiIcons.gestureDoubleTap),
|
||||
title: const Text('Gesture bindings'),
|
||||
subtitle: const Text('Strokes mapped to actions'),
|
||||
trailing: _CountChevron(
|
||||
count: settings.bindings.length,
|
||||
),
|
||||
onTap: () => open(const GestureBindingsScreen()),
|
||||
),
|
||||
),
|
||||
SettingsEntryDefinition(
|
||||
title: 'Behavior & timing',
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.tune),
|
||||
title: const Text('Behavior & timing'),
|
||||
subtitle: const Text(
|
||||
'Sensitivity, timeout, cooldown',
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => open(const GestureBehaviorScreen()),
|
||||
),
|
||||
),
|
||||
SettingsEntryDefinition(
|
||||
title: 'Excluded sites',
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.public_off),
|
||||
title: const Text('Excluded sites'),
|
||||
subtitle: const Text('Disable gestures per site'),
|
||||
trailing: _CountChevron(
|
||||
count: settings.excludedSites.length,
|
||||
),
|
||||
onTap: () => open(const GestureExcludedSitesScreen()),
|
||||
),
|
||||
),
|
||||
SettingsEntryDefinition(
|
||||
title: 'Feedback',
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.bolt_outlined),
|
||||
title: const Text('Feedback'),
|
||||
subtitle: const Text('Live overlay and suggestions'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => open(const GestureFeedbackScreen()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CountChevron extends StatelessWidget {
|
||||
final int count;
|
||||
|
||||
const _CountChevron({required this.count});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (count > 0)
|
||||
Text('$count', style: Theme.of(context).textTheme.labelLarge),
|
||||
const Icon(Icons.chevron_right),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:weblibre/features/gestures/data/models/gesture_action.dart';
|
||||
|
||||
/// Shows a modal bottom sheet listing every [GestureAction] grouped by category,
|
||||
/// each with its icon, title and description, and returns the chosen action (or
|
||||
/// null if dismissed). Mirrors the icon + subtitle selection sheets used
|
||||
/// elsewhere in the app (e.g. the contextual toolbar pickers).
|
||||
Future<GestureAction?> showGestureActionPicker(
|
||||
BuildContext context, {
|
||||
required GestureAction selected,
|
||||
}) {
|
||||
return showModalBottomSheet<GestureAction>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
showDragHandle: true,
|
||||
builder: (context) => _GestureActionPicker(selected: selected),
|
||||
);
|
||||
}
|
||||
|
||||
class _GestureActionPicker extends StatelessWidget {
|
||||
final GestureAction selected;
|
||||
|
||||
const _GestureActionPicker({required this.selected});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
final byCategory = <GestureActionCategory, List<GestureAction>>{};
|
||||
for (final action in GestureAction.values) {
|
||||
byCategory.putIfAbsent(action.category, () => []).add(action);
|
||||
}
|
||||
|
||||
// Cap the sheet height so the long action list scrolls inside a sheet that
|
||||
// never covers the whole screen.
|
||||
final maxHeight = MediaQuery.sizeOf(context).height * 0.8;
|
||||
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: maxHeight),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
|
||||
child: Text(
|
||||
'Choose action',
|
||||
style: theme.textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
children: [
|
||||
for (final category in GestureActionCategory.values)
|
||||
if (byCategory[category] case final actions?
|
||||
when actions.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 12, 24, 4),
|
||||
child: Text(
|
||||
category.label,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final action in actions)
|
||||
ListTile(
|
||||
leading: Icon(action.icon),
|
||||
title: Text(action.title),
|
||||
subtitle: Text(action.description),
|
||||
selected: action == selected,
|
||||
trailing: action == selected
|
||||
? Icon(Icons.check, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () => Navigator.of(context).pop(action),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:weblibre/features/gestures/data/models/gesture_action.dart';
|
||||
import 'package:weblibre/features/gestures/data/models/gesture_stroke.dart';
|
||||
import 'package:weblibre/features/gestures/presentation/widgets/gesture_action_picker.dart';
|
||||
import 'package:weblibre/features/gestures/presentation/widgets/gesture_stroke_view.dart';
|
||||
|
||||
typedef GestureBindingResult = ({GestureStroke stroke, GestureAction action});
|
||||
|
||||
/// Opens the gesture binding editor as a full-screen page.
|
||||
///
|
||||
/// Returns the configured stroke + action, or null if dismissed. Editing an
|
||||
/// existing binding is supported by passing [initialStroke]/[initialAction].
|
||||
///
|
||||
/// [existingBindings] is the current stroke-key → action map, used to warn
|
||||
/// before a new stroke would overwrite an already-assigned one.
|
||||
Future<GestureBindingResult?> showGestureBindingEditor(
|
||||
BuildContext context, {
|
||||
GestureStroke? initialStroke,
|
||||
GestureAction? initialAction,
|
||||
Map<String, GestureAction> existingBindings = const {},
|
||||
int maxFingers = 1,
|
||||
}) {
|
||||
return Navigator.of(context).push<GestureBindingResult>(
|
||||
MaterialPageRoute<GestureBindingResult>(
|
||||
builder: (context) => _GestureBindingEditor(
|
||||
initialStroke: initialStroke,
|
||||
initialAction: initialAction,
|
||||
existingBindings: existingBindings,
|
||||
maxFingers: maxFingers,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _GestureBindingEditor extends HookWidget {
|
||||
final GestureStroke? initialStroke;
|
||||
final GestureAction? initialAction;
|
||||
final Map<String, GestureAction> existingBindings;
|
||||
final int maxFingers;
|
||||
|
||||
const _GestureBindingEditor({
|
||||
required this.initialStroke,
|
||||
required this.initialAction,
|
||||
required this.existingBindings,
|
||||
required this.maxFingers,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final action = useState(initialAction ?? GestureAction.values.first);
|
||||
final startPosition = useState(
|
||||
initialStroke?.startPosition ?? GestureStartPosition.anywhere,
|
||||
);
|
||||
final fingers = useState(initialStroke?.fingers ?? 1);
|
||||
final arrows = useState<List<GestureArrow>>(
|
||||
initialStroke?.arrows ?? const [],
|
||||
);
|
||||
|
||||
// Allow at least 3 fingers in the picker so multi-finger gestures can be
|
||||
// configured; the native recognizer is told to match whatever is bound.
|
||||
final fingerLimit = maxFingers < 3 ? 3 : maxFingers;
|
||||
|
||||
final stroke = GestureStroke(
|
||||
startPosition: startPosition.value,
|
||||
fingers: fingers.value,
|
||||
arrows: arrows.value,
|
||||
);
|
||||
|
||||
// A collision is an existing binding under the same stroke key that is not
|
||||
// the one currently being edited (editing a binding in place is fine).
|
||||
final collisionAction = stroke.isValid && stroke.key != initialStroke?.key
|
||||
? existingBindings[stroke.key]
|
||||
: null;
|
||||
|
||||
Future<void> save() async {
|
||||
if (!stroke.isValid) return;
|
||||
|
||||
if (collisionAction != null) {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
icon: const Icon(Icons.warning_amber),
|
||||
title: const Text('Replace existing gesture?'),
|
||||
content: Text(
|
||||
'This stroke is already assigned to "${collisionAction.title}". '
|
||||
'Saving will replace that binding.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Replace'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop((stroke: stroke, action: action.value));
|
||||
}
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(initialStroke == null ? 'Create gesture' : 'Edit gesture'),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Target action — opens the grouped action picker sheet.
|
||||
Card.filled(
|
||||
margin: EdgeInsets.zero,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ListTile(
|
||||
leading: Icon(action.value.icon),
|
||||
title: const Text('Target action'),
|
||||
subtitle: Text(action.value.title),
|
||||
trailing: const Icon(Icons.unfold_more),
|
||||
onTap: () async {
|
||||
final picked = await showGestureActionPicker(
|
||||
context,
|
||||
selected: action.value,
|
||||
);
|
||||
if (picked != null) action.value = picked;
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
'Start position',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final value in GestureStartPosition.values)
|
||||
ChoiceChip(
|
||||
avatar: Icon(value.icon),
|
||||
showCheckmark: false,
|
||||
label: Text(value.label),
|
||||
selected: startPosition.value == value,
|
||||
onSelected: (selected) {
|
||||
if (selected) startPosition.value = value;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Fingers'),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.remove),
|
||||
onPressed: fingers.value > 1
|
||||
? () => fingers.value = fingers.value - 1
|
||||
: null,
|
||||
),
|
||||
Text('${fingers.value}'),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: fingers.value < fingerLimit
|
||||
? () => fingers.value = fingers.value + 1
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
'Stroke pattern',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Visualizer canvas mirroring the live stroke as it is built.
|
||||
Container(
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: arrows.value.isEmpty
|
||||
? Text(
|
||||
'Draw a stroke pattern below',
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: GestureStrokeView(stroke: stroke),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Direction pad laid out like a compass (← ↑ ↓ →).
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
for (final arrow in const [
|
||||
GestureArrow.left,
|
||||
GestureArrow.up,
|
||||
GestureArrow.down,
|
||||
GestureArrow.right,
|
||||
])
|
||||
_DirectionButton(
|
||||
arrow: arrow,
|
||||
// The recognizer collapses consecutive identical directions
|
||||
// into a single stroke, so a duplicate of the last arrow
|
||||
// could never be matched. Disable it to keep the builder in
|
||||
// sync with what native recognition can produce.
|
||||
onPressed: arrows.value.lastOrNull == arrow
|
||||
? null
|
||||
: () => arrows.value = [...arrows.value, arrow],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton.icon(
|
||||
onPressed: arrows.value.isEmpty
|
||||
? null
|
||||
: () => arrows.value = arrows.value.sublist(
|
||||
0,
|
||||
arrows.value.length - 1,
|
||||
),
|
||||
icon: const Icon(Icons.backspace_outlined),
|
||||
label: const Text('Undo last'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (collisionAction != null) ...[
|
||||
_CollisionWarning(action: collisionAction),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(56),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
onPressed: stroke.isValid ? save : null,
|
||||
child: Text(
|
||||
collisionAction != null
|
||||
? 'Replace gesture'
|
||||
: 'Save gesture',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Inline warning shown in the editor's save bar when the current stroke would
|
||||
/// overwrite another binding.
|
||||
class _CollisionWarning extends StatelessWidget {
|
||||
final GestureAction action;
|
||||
|
||||
const _CollisionWarning({required this.action});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.warning_amber,
|
||||
size: 20,
|
||||
color: colorScheme.onErrorContainer,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Already assigned to "${action.title}". Saving replaces it.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A circular tonal button for one swipe direction in the editor's D-pad.
|
||||
class _DirectionButton extends StatelessWidget {
|
||||
final GestureArrow arrow;
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
const _DirectionButton({required this.arrow, required this.onPressed});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FilledButton.tonal(
|
||||
style: FilledButton.styleFrom(
|
||||
shape: const CircleBorder(),
|
||||
padding: const EdgeInsets.all(20),
|
||||
),
|
||||
onPressed: onPressed,
|
||||
child: Icon(arrow.icon),
|
||||
);
|
||||
}
|
||||
}
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/features/gestures/data/models/gesture_action.dart';
|
||||
import 'package:weblibre/features/gestures/data/models/gesture_settings.dart';
|
||||
import 'package:weblibre/features/gestures/data/models/gesture_stroke.dart';
|
||||
import 'package:weblibre/features/gestures/domain/repositories/gesture_settings.dart';
|
||||
import 'package:weblibre/features/gestures/domain/services/gesture_control.dart';
|
||||
import 'package:weblibre/features/gestures/presentation/widgets/gesture_stroke_view.dart';
|
||||
|
||||
/// One candidate completion shown in the overlay.
|
||||
typedef _Suggestion = ({
|
||||
GestureStroke stroke,
|
||||
GestureAction action,
|
||||
bool exact,
|
||||
});
|
||||
|
||||
/// Translucent overlay shown near the top of the page while a gesture stroke is
|
||||
/// being drawn. Mirrors the reference add-on's live toast: it renders the
|
||||
/// in-progress stroke and the action it currently matches, and (once enough
|
||||
/// strokes are drawn) the other gestures the user could complete from here.
|
||||
///
|
||||
/// Purely informational — it never intercepts touch input.
|
||||
class GestureFeedbackOverlay extends HookConsumerWidget {
|
||||
const GestureFeedbackOverlay({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(gestureSettingsWithDefaultsProvider);
|
||||
final partialKey = settings.showFeedback
|
||||
? ref.watch(gestureProgressProvider).asData?.value
|
||||
: null;
|
||||
|
||||
final suggestions = partialKey == null
|
||||
? const <_Suggestion>[]
|
||||
: _resolveSuggestions(settings, GestureStroke.fromKey(partialKey));
|
||||
|
||||
return IgnorePointer(
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
child: suggestions.isEmpty
|
||||
? const SizedBox.shrink()
|
||||
: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 24),
|
||||
child: _SuggestionCard(suggestions: suggestions),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Bindings reachable from the in-progress [current] stroke.
|
||||
///
|
||||
/// A binding is reachable when it uses the same finger count, a compatible
|
||||
/// start position (its own, or anywhere), and its arrow sequence begins with
|
||||
/// what has been drawn so far. Until [GestureSettings.minSuggestionStroke]
|
||||
/// strokes are drawn (or suggestions are disabled), only the exact current
|
||||
/// match is shown.
|
||||
List<_Suggestion> _resolveSuggestions(
|
||||
GestureSettings settings,
|
||||
GestureStroke current,
|
||||
) {
|
||||
if (current.arrows.isEmpty) return const [];
|
||||
|
||||
final suggestAll =
|
||||
settings.suggestNext &&
|
||||
current.arrows.length >= settings.minSuggestionStroke;
|
||||
|
||||
final result = <_Suggestion>[];
|
||||
for (final MapEntry(key: key, value: action) in settings.bindings.entries) {
|
||||
final stroke = GestureStroke.fromKey(key);
|
||||
if (stroke.fingers != current.fingers) continue;
|
||||
if (stroke.startPosition != GestureStartPosition.anywhere &&
|
||||
stroke.startPosition != current.startPosition) {
|
||||
continue;
|
||||
}
|
||||
if (!_arrowsStartWith(stroke.arrows, current.arrows)) continue;
|
||||
|
||||
final exact = stroke.arrows.length == current.arrows.length;
|
||||
if (!suggestAll && !exact) continue;
|
||||
|
||||
result.add((stroke: stroke, action: action, exact: exact));
|
||||
}
|
||||
|
||||
result.sort((a, b) {
|
||||
if (a.exact != b.exact) return a.exact ? -1 : 1;
|
||||
return a.stroke.arrows.length.compareTo(b.stroke.arrows.length);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
bool _arrowsStartWith(List<GestureArrow> full, List<GestureArrow> prefix) {
|
||||
if (prefix.length > full.length) return false;
|
||||
for (var i = 0; i < prefix.length; i++) {
|
||||
if (full[i] != prefix[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class _SuggestionCard extends StatelessWidget {
|
||||
final List<_Suggestion> suggestions;
|
||||
|
||||
const _SuggestionCard({required this.suggestions});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Material(
|
||||
color: theme.colorScheme.inverseSurface.withValues(alpha: 0.92),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
elevation: 6,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 320),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final suggestion in suggestions)
|
||||
Opacity(
|
||||
opacity: suggestion.exact ? 1 : 0.6,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
suggestion.action.icon,
|
||||
size: 18,
|
||||
color: theme.colorScheme.onInverseSurface,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
suggestion.action.title,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onInverseSurface,
|
||||
fontWeight: suggestion.exact
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureStrokeView(
|
||||
stroke: suggestion.stroke,
|
||||
color: theme.colorScheme.onInverseSurface,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:weblibre/features/gestures/data/models/gesture_stroke.dart';
|
||||
|
||||
extension GestureArrowIcon on GestureArrow {
|
||||
IconData get icon => switch (this) {
|
||||
GestureArrow.up => Icons.arrow_upward,
|
||||
GestureArrow.down => Icons.arrow_downward,
|
||||
GestureArrow.left => Icons.arrow_back,
|
||||
GestureArrow.right => Icons.arrow_forward,
|
||||
};
|
||||
}
|
||||
|
||||
/// Compact visual rendering of a [GestureStroke]: the direction arrows in
|
||||
/// sequence, prefixed by start-position and finger-count qualifiers when set.
|
||||
///
|
||||
/// All glyphs share a single foreground [color] so the view blends with
|
||||
/// whatever surface it sits on; it defaults to the primary color, but callers
|
||||
/// rendering on a contrasting surface (e.g. the live overlay) should pass the
|
||||
/// matching `on…` color.
|
||||
class GestureStrokeView extends StatelessWidget {
|
||||
final GestureStroke stroke;
|
||||
final Color? color;
|
||||
|
||||
const GestureStrokeView({required this.stroke, this.color, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final foreground = color ?? theme.colorScheme.primary;
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (stroke.startPosition != GestureStartPosition.anywhere)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 6),
|
||||
child: Icon(
|
||||
stroke.startPosition.icon,
|
||||
size: 18,
|
||||
color: foreground,
|
||||
),
|
||||
),
|
||||
if (stroke.fingers >= 2)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: Text(
|
||||
'${stroke.fingers}×',
|
||||
style: theme.textTheme.labelLarge?.copyWith(color: foreground),
|
||||
),
|
||||
),
|
||||
for (final arrow in stroke.arrows)
|
||||
Icon(arrow.icon, size: 18, color: foreground),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
|
||||
/// Reusable editor for a list of unique string entries: a labelled input field
|
||||
/// with an add button, followed by the current entries each with a delete
|
||||
/// action.
|
||||
///
|
||||
/// Entries are passed through [normalize] before being added; returning null
|
||||
/// rejects the input (e.g. an unparseable value), and duplicates are ignored.
|
||||
class StringListEditor extends HookWidget {
|
||||
final List<String> values;
|
||||
final ValueChanged<List<String>> onChanged;
|
||||
|
||||
/// Hint shown in the input field.
|
||||
final String hintText;
|
||||
|
||||
/// Leading icon for each entry row.
|
||||
final IconData itemIcon;
|
||||
|
||||
/// Message shown when the list is empty.
|
||||
final String emptyLabel;
|
||||
|
||||
/// Canonicalises raw input before adding. Returns null to reject the value.
|
||||
final String? Function(String input) normalize;
|
||||
|
||||
const StringListEditor({
|
||||
required this.values,
|
||||
required this.onChanged,
|
||||
required this.hintText,
|
||||
required this.normalize,
|
||||
this.itemIcon = Icons.link,
|
||||
this.emptyLabel = 'Nothing added yet.',
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = useTextEditingController();
|
||||
// Rebuild the add button's enabled state as the field changes.
|
||||
useListenable(controller);
|
||||
|
||||
void add() {
|
||||
final normalized = normalize(controller.text);
|
||||
if (normalized == null) return;
|
||||
if (!values.contains(normalized)) {
|
||||
onChanged([...values, normalized]);
|
||||
}
|
||||
controller.clear();
|
||||
}
|
||||
|
||||
void remove(String value) {
|
||||
onChanged(values.where((v) => v != value).toList());
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
keyboardType: TextInputType.url,
|
||||
autocorrect: false,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => add(),
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton.filled(
|
||||
icon: const Icon(Icons.add),
|
||||
tooltip: 'Add',
|
||||
onPressed: controller.text.trim().isEmpty ? null : add,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (values.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
emptyLabel,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
)
|
||||
else
|
||||
for (final value in values)
|
||||
ListTile(
|
||||
leading: Icon(itemIcon),
|
||||
title: Text(value),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
tooltip: 'Remove',
|
||||
onPressed: () => remove(value),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,13 @@ _CategoryGroups _buildCategories() {
|
||||
sections: browsingSettingsSections,
|
||||
onTap: (context) => BrowsingSettingsRoute().push(context),
|
||||
),
|
||||
_SettingsCategoryDefinition(
|
||||
title: 'Gestures',
|
||||
subtitle: 'Stroke gestures for browser actions',
|
||||
icon: MdiIcons.gestureSwipe,
|
||||
keywords: const ['gesture', 'swipe', 'stroke'],
|
||||
onTap: (context) => GestureSettingsRoute().push(context),
|
||||
),
|
||||
_SettingsCategoryDefinition(
|
||||
title: 'Toolbar & Layout',
|
||||
subtitle: 'Tab bar, toolbar, quick switcher, tab view',
|
||||
|
||||
@@ -167,6 +167,7 @@ class SettingsCustomScrollScaffold extends StatelessWidget {
|
||||
final TextEditingController? searchController;
|
||||
final String searchHintText;
|
||||
final List<Widget> slivers;
|
||||
final Widget? floatingActionButton;
|
||||
|
||||
const SettingsCustomScrollScaffold({
|
||||
super.key,
|
||||
@@ -175,11 +176,13 @@ class SettingsCustomScrollScaffold extends StatelessWidget {
|
||||
this.actions = const [],
|
||||
this.searchController,
|
||||
this.searchHintText = 'Search settings',
|
||||
this.floatingActionButton,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
floatingActionButton: floatingActionButton,
|
||||
body: SafeArea(
|
||||
child: FadingScroll(
|
||||
fadingSize: 25,
|
||||
|
||||
Reference in New Issue
Block a user