gesture feature initial

This commit is contained in:
Fabian Freund
2026-05-31 11:40:57 +02:00
parent 4aecb8966c
commit 4d4c8a8786
45 changed files with 4729 additions and 5 deletions
@@ -0,0 +1,181 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/gestures/data/models/gesture_settings.dart';
import 'package:weblibre/features/gestures/domain/repositories/gesture_settings.dart';
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
const List<SettingsSectionDefinition> _behaviorSections = [
SettingsSectionDefinition(
title: 'Strokes',
entries: [
SettingsEntryDefinition(
title: 'Stroke sensitivity',
subtitle: 'Minimum swipe length recognised as a direction',
keywords: ['size', 'length'],
child: _StrokeSensitivitySection(),
),
],
),
SettingsSectionDefinition(
title: 'Timing',
entries: [
SettingsEntryDefinition(
title: 'Timeout',
subtitle: 'Drop a stroke if no new direction is drawn',
keywords: ['delay'],
child: _TimeoutSection(),
),
SettingsEntryDefinition(
title: 'Cooldown',
subtitle: 'Minimum delay between two gestures firing',
keywords: ['interval'],
child: _CooldownSection(),
),
],
),
];
/// Tuning for the gesture recognizer: stroke size, idle timeout and cooldown.
class GestureBehaviorScreen extends StatelessWidget {
const GestureBehaviorScreen({super.key});
@override
Widget build(BuildContext context) {
return const SettingsDetailScaffold(
title: 'Behavior & timing',
subtitle: 'Stroke sensitivity, timeout, and cooldown.',
icon: Icons.tune,
sections: _behaviorSections,
);
}
}
class _StrokeSensitivitySection extends HookConsumerWidget {
const _StrokeSensitivitySection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(gestureSettingsWithDefaultsProvider);
return ListTile(
leading: const Icon(MdiIcons.gestureTap),
title: const Text('Stroke sensitivity'),
subtitle: Slider.adaptive(
min: minGestureStrokeSize.toDouble(),
max: maxGestureStrokeSize.toDouble(),
divisions: maxGestureStrokeSize - minGestureStrokeSize,
value: settings.strokeSize
.clamp(minGestureStrokeSize, maxGestureStrokeSize)
.toDouble(),
label: '${settings.strokeSize}',
onChanged: (value) async {
await ref
.read(gestureSettingsRepositoryProvider.notifier)
.updateSettings(
(current) => current.copyWith.strokeSize(value.round()),
);
},
),
);
}
}
class _TimeoutSection extends HookConsumerWidget {
const _TimeoutSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(gestureSettingsWithDefaultsProvider);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.hourglass_empty),
title: const Text('Timeout'),
subtitle: Slider.adaptive(
min: minGestureTimeoutMs.toDouble(),
max: maxGestureTimeoutMs.toDouble(),
divisions: (maxGestureTimeoutMs - minGestureTimeoutMs) ~/ 100,
value: settings.timeoutMs
.clamp(minGestureTimeoutMs, maxGestureTimeoutMs)
.toDouble(),
label: '${settings.timeoutMs} ms',
onChanged: (value) async {
await ref
.read(gestureSettingsRepositoryProvider.notifier)
.updateSettings(
(current) => current.copyWith.timeoutMs(value.round()),
);
},
),
),
const Padding(
padding: EdgeInsets.fromLTRB(72, 0, 16, 8),
child: Text(
'A stroke is dropped if no new direction is drawn within this time.',
),
),
],
);
}
}
class _CooldownSection extends HookConsumerWidget {
const _CooldownSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(gestureSettingsWithDefaultsProvider);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.timer_outlined),
title: const Text('Cooldown'),
subtitle: Slider.adaptive(
min: minGestureIntervalMs.toDouble(),
max: maxGestureIntervalMs.toDouble(),
divisions: (maxGestureIntervalMs - minGestureIntervalMs) ~/ 100,
value: settings.intervalMs
.clamp(minGestureIntervalMs, maxGestureIntervalMs)
.toDouble(),
label: settings.intervalMs == 0 ? 'Off' : '${settings.intervalMs} ms',
onChanged: (value) async {
await ref
.read(gestureSettingsRepositoryProvider.notifier)
.updateSettings(
(current) => current.copyWith.intervalMs(value.round()),
);
},
),
),
const Padding(
padding: EdgeInsets.fromLTRB(72, 0, 16, 8),
child: Text('Minimum delay between two gestures firing.'),
),
],
);
}
}
@@ -0,0 +1,214 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/gestures/data/models/gesture_action.dart';
import 'package:weblibre/features/gestures/data/models/gesture_settings.dart';
import 'package:weblibre/features/gestures/data/models/gesture_stroke.dart';
import 'package:weblibre/features/gestures/domain/repositories/gesture_settings.dart';
import 'package:weblibre/features/gestures/presentation/widgets/gesture_binding_editor.dart';
import 'package:weblibre/features/gestures/presentation/widgets/gesture_stroke_view.dart';
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
/// Lists the configured gesture → action bindings, grouped by action category,
/// and lets the user add, edit and remove them.
class GestureBindingsScreen extends HookConsumerWidget {
const GestureBindingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(gestureSettingsWithDefaultsProvider);
Future<void> upsertBinding(
({GestureStroke stroke, GestureAction action}) result, {
String? replacedKey,
}) async {
await ref.read(gestureSettingsRepositoryProvider.notifier).updateSettings(
(current) {
final bindings = Map<String, GestureAction>.from(current.bindings);
if (replacedKey != null) {
bindings.remove(replacedKey);
}
bindings[result.stroke.key] = result.action;
return current.copyWith.bindings(bindings);
},
);
}
Future<void> removeBinding(String key) async {
await ref.read(gestureSettingsRepositoryProvider.notifier).updateSettings(
(current) {
final bindings = Map<String, GestureAction>.from(current.bindings)
..remove(key);
return current.copyWith.bindings(bindings);
},
);
}
// Group bindings by their action's category, preserving category order.
final byCategory =
<GestureActionCategory, List<MapEntry<String, GestureAction>>>{};
for (final entry in settings.bindings.entries) {
byCategory.putIfAbsent(entry.value.category, () => []).add(entry);
}
for (final entries in byCategory.values) {
entries.sort((a, b) => a.value.title.compareTo(b.value.title));
}
return SettingsCustomScrollScaffold(
title: 'Gesture bindings',
floatingActionButton: FloatingActionButton.extended(
icon: const Icon(Icons.add),
label: const Text('Add gesture'),
onPressed: () async {
final result = await showGestureBindingEditor(
context,
existingBindings: settings.bindings,
maxFingers: settings.maxFingers,
);
if (result != null) {
await upsertBinding(result);
}
},
),
slivers: [
if (settings.bindings.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: Text('No gestures assigned yet.')),
)
else
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 88),
sliver: SliverList.list(
children: [
for (final category in GestureActionCategory.values)
if (byCategory[category] case final entries?
when entries.isNotEmpty)
_GestureBindingGroup(
title: category.label,
children: [
for (final binding in entries)
_GestureBindingTile(
gestureKey: binding.key,
action: binding.value,
onEdit: () async {
final result = await showGestureBindingEditor(
context,
initialStroke: GestureStroke.fromKey(
binding.key,
),
initialAction: binding.value,
existingBindings: settings.bindings,
maxFingers: settings.maxFingers,
);
if (result != null) {
await upsertBinding(
result,
replacedKey: binding.key,
);
}
},
onRemove: () => removeBinding(binding.key),
),
],
),
],
),
),
],
);
}
}
/// A category heading followed by a filled card grouping its binding tiles,
/// matching the visual language of the other settings screens.
class _GestureBindingGroup extends StatelessWidget {
final String title;
final List<Widget> children;
const _GestureBindingGroup({required this.title, required this.children});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(bottom: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 12),
Card.filled(
margin: EdgeInsets.zero,
color: theme.colorScheme.surfaceContainer,
clipBehavior: Clip.antiAlias,
child: Column(
children: [
for (var i = 0; i < children.length; i++) ...[
if (i > 0) const Divider(height: 1),
children[i],
],
],
),
),
],
),
);
}
}
class _GestureBindingTile extends StatelessWidget {
final String gestureKey;
final GestureAction action;
final Future<void> Function() onEdit;
final Future<void> Function() onRemove;
const _GestureBindingTile({
required this.gestureKey,
required this.action,
required this.onEdit,
required this.onRemove,
});
@override
Widget build(BuildContext context) {
final stroke = GestureStroke.fromKey(gestureKey);
return ListTile(
leading: Icon(action.icon),
title: Text(action.title),
subtitle: GestureStrokeView(stroke: stroke),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: 'Remove',
onPressed: () => onRemove(),
),
onTap: () => onEdit(),
);
}
}
@@ -0,0 +1,71 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/gestures/data/models/gesture_settings.dart';
import 'package:weblibre/features/gestures/domain/repositories/gesture_settings.dart';
import 'package:weblibre/features/gestures/presentation/widgets/string_list_editor.dart';
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
/// Manages the list of sites on which gestures are disabled.
class GestureExcludedSitesScreen extends HookConsumerWidget {
const GestureExcludedSitesScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final excludedSites = ref.watch(
gestureSettingsWithDefaultsProvider.select((s) => s.excludedSites),
);
return SettingsCustomScrollScaffold(
title: 'Excluded sites',
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Text(
'Gestures are disabled on these sites. Subdomains are included '
'(e.g. "example.com" also covers "m.example.com").',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
),
SliverToBoxAdapter(
child: StringListEditor(
values: excludedSites,
hintText: 'example.com',
itemIcon: Icons.public_off,
emptyLabel: 'No sites excluded.',
normalize: normalizeGestureSiteHost,
onChanged: (next) async {
await ref
.read(gestureSettingsRepositoryProvider.notifier)
.updateSettings(
(current) => current.copyWith.excludedSites(next),
);
},
),
),
],
);
}
}
@@ -0,0 +1,158 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/gestures/data/models/gesture_settings.dart';
import 'package:weblibre/features/gestures/domain/repositories/gesture_settings.dart';
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
const List<SettingsSectionDefinition> _feedbackSections = [
SettingsSectionDefinition(
title: 'Overlay',
entries: [
SettingsEntryDefinition(
title: 'Live feedback',
subtitle: 'Show the stroke and its action while you draw',
child: _LiveFeedbackTile(),
),
SettingsEntryDefinition(
title: 'Suggest next',
subtitle: 'Also show the other gestures you can complete',
child: _SuggestNextTile(),
),
SettingsEntryDefinition(
title: 'Suggest after',
subtitle: 'Number of strokes to draw before suggestions appear',
child: _SuggestAfterSection(),
),
],
),
];
/// Controls the live feedback overlay shown while drawing a gesture.
class GestureFeedbackScreen extends StatelessWidget {
const GestureFeedbackScreen({super.key});
@override
Widget build(BuildContext context) {
return const SettingsDetailScaffold(
title: 'Feedback',
subtitle: 'Live overlay and gesture suggestions.',
icon: Icons.bolt_outlined,
sections: _feedbackSections,
);
}
}
class _LiveFeedbackTile extends HookConsumerWidget {
const _LiveFeedbackTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final showFeedback = ref.watch(
gestureSettingsWithDefaultsProvider.select((s) => s.showFeedback),
);
return SwitchListTile.adaptive(
secondary: const Icon(Icons.bolt_outlined),
title: const Text('Live feedback'),
subtitle: const Text('Show the stroke and its action while you draw'),
value: showFeedback,
onChanged: (value) async {
await ref
.read(gestureSettingsRepositoryProvider.notifier)
.updateSettings((current) => current.copyWith.showFeedback(value));
},
);
}
}
class _SuggestNextTile extends HookConsumerWidget {
const _SuggestNextTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(gestureSettingsWithDefaultsProvider);
return SwitchListTile.adaptive(
secondary: const Icon(Icons.lightbulb_outline),
title: const Text('Suggest next'),
subtitle: const Text('Also show the other gestures you can complete'),
value: settings.suggestNext,
onChanged: settings.showFeedback
? (value) async {
await ref
.read(gestureSettingsRepositoryProvider.notifier)
.updateSettings(
(current) => current.copyWith.suggestNext(value),
);
}
: null,
);
}
}
class _SuggestAfterSection extends HookConsumerWidget {
const _SuggestAfterSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(gestureSettingsWithDefaultsProvider);
final enabled = settings.showFeedback && settings.suggestNext;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
enabled: enabled,
leading: const Icon(Icons.straighten),
title: const Text('Suggest after'),
subtitle: Slider.adaptive(
min: minGestureMinSuggestionStroke.toDouble(),
max: maxGestureMinSuggestionStroke.toDouble(),
divisions:
maxGestureMinSuggestionStroke - minGestureMinSuggestionStroke,
value: settings.minSuggestionStroke
.clamp(
minGestureMinSuggestionStroke,
maxGestureMinSuggestionStroke,
)
.toDouble(),
label: '${settings.minSuggestionStroke} strokes',
onChanged: enabled
? (value) async {
await ref
.read(gestureSettingsRepositoryProvider.notifier)
.updateSettings(
(current) =>
current.copyWith.minSuggestionStroke(value.round()),
);
}
: null,
),
),
const Padding(
padding: EdgeInsets.fromLTRB(72, 0, 16, 8),
child: Text('Number of strokes to draw before suggestions appear.'),
),
],
);
}
}
@@ -0,0 +1,179 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/gestures/data/models/gesture_settings.dart';
import 'package:weblibre/features/gestures/domain/repositories/gesture_settings.dart';
import 'package:weblibre/features/gestures/presentation/screens/gesture_behavior_screen.dart';
import 'package:weblibre/features/gestures/presentation/screens/gesture_bindings_screen.dart';
import 'package:weblibre/features/gestures/presentation/screens/gesture_excluded_sites_screen.dart';
import 'package:weblibre/features/gestures/presentation/screens/gesture_feedback_screen.dart';
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
/// Overview screen for gesture configuration: a master switch plus entries that
/// open the dedicated bindings / behavior / excluded-sites / feedback subpages.
class GestureSettingsScreen extends HookConsumerWidget {
const GestureSettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(gestureSettingsWithDefaultsProvider);
final repository = ref.read(gestureSettingsRepositoryProvider.notifier);
final colorScheme = Theme.of(context).colorScheme;
void open(Widget screen) {
unawaited(
Navigator.of(
context,
).push(MaterialPageRoute<void>(builder: (_) => screen)),
);
}
return SettingsCustomScrollScaffold(
title: 'Gestures',
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
sliver: SliverToBoxAdapter(
child: Card.filled(
margin: EdgeInsets.zero,
color: colorScheme.primaryContainer,
clipBehavior: Clip.antiAlias,
child: SwitchListTile.adaptive(
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 8,
),
secondary: Icon(
MdiIcons.gestureSwipe,
color: colorScheme.onPrimaryContainer,
),
title: Text(
'Enable Gestures',
style: TextStyle(
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
subtitle: Text(
'Draw stroke gestures on web pages to trigger actions',
style: TextStyle(
color: colorScheme.onPrimaryContainer.withValues(
alpha: 0.8,
),
),
),
value: settings.enabled,
onChanged: (value) async {
await repository.updateSettings(
(current) => current.copyWith.enabled(value),
);
},
),
),
),
),
if (settings.enabled)
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 20),
sliver: SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: buildSettingsSectionWidgets(context, [
SettingsSectionDefinition(
title: 'Configuration',
entries: [
SettingsEntryDefinition(
title: 'Gesture bindings',
child: ListTile(
leading: const Icon(MdiIcons.gestureDoubleTap),
title: const Text('Gesture bindings'),
subtitle: const Text('Strokes mapped to actions'),
trailing: _CountChevron(
count: settings.bindings.length,
),
onTap: () => open(const GestureBindingsScreen()),
),
),
SettingsEntryDefinition(
title: 'Behavior & timing',
child: ListTile(
leading: const Icon(Icons.tune),
title: const Text('Behavior & timing'),
subtitle: const Text(
'Sensitivity, timeout, cooldown',
),
trailing: const Icon(Icons.chevron_right),
onTap: () => open(const GestureBehaviorScreen()),
),
),
SettingsEntryDefinition(
title: 'Excluded sites',
child: ListTile(
leading: const Icon(Icons.public_off),
title: const Text('Excluded sites'),
subtitle: const Text('Disable gestures per site'),
trailing: _CountChevron(
count: settings.excludedSites.length,
),
onTap: () => open(const GestureExcludedSitesScreen()),
),
),
SettingsEntryDefinition(
title: 'Feedback',
child: ListTile(
leading: const Icon(Icons.bolt_outlined),
title: const Text('Feedback'),
subtitle: const Text('Live overlay and suggestions'),
trailing: const Icon(Icons.chevron_right),
onTap: () => open(const GestureFeedbackScreen()),
),
),
],
),
]),
),
),
),
],
);
}
}
class _CountChevron extends StatelessWidget {
final int count;
const _CountChevron({required this.count});
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (count > 0)
Text('$count', style: Theme.of(context).textTheme.labelLarge),
const Icon(Icons.chevron_right),
],
);
}
}