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),
],
);
}
}
@@ -0,0 +1,111 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:weblibre/features/gestures/data/models/gesture_action.dart';
/// Shows a modal bottom sheet listing every [GestureAction] grouped by category,
/// each with its icon, title and description, and returns the chosen action (or
/// null if dismissed). Mirrors the icon + subtitle selection sheets used
/// elsewhere in the app (e.g. the contextual toolbar pickers).
Future<GestureAction?> showGestureActionPicker(
BuildContext context, {
required GestureAction selected,
}) {
return showModalBottomSheet<GestureAction>(
context: context,
isScrollControlled: true,
useSafeArea: true,
showDragHandle: true,
builder: (context) => _GestureActionPicker(selected: selected),
);
}
class _GestureActionPicker extends StatelessWidget {
final GestureAction selected;
const _GestureActionPicker({required this.selected});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final byCategory = <GestureActionCategory, List<GestureAction>>{};
for (final action in GestureAction.values) {
byCategory.putIfAbsent(action.category, () => []).add(action);
}
// Cap the sheet height so the long action list scrolls inside a sheet that
// never covers the whole screen.
final maxHeight = MediaQuery.sizeOf(context).height * 0.8;
return ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxHeight),
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
child: Text(
'Choose action',
style: theme.textTheme.titleLarge,
),
),
Flexible(
child: ListView(
padding: const EdgeInsets.only(bottom: 8),
children: [
for (final category in GestureActionCategory.values)
if (byCategory[category] case final actions?
when actions.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 4),
child: Text(
category.label,
style: theme.textTheme.titleSmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.w700,
),
),
),
for (final action in actions)
ListTile(
leading: Icon(action.icon),
title: Text(action.title),
subtitle: Text(action.description),
selected: action == selected,
trailing: action == selected
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () => Navigator.of(context).pop(action),
),
],
],
),
),
],
),
),
);
}
}
@@ -0,0 +1,384 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:weblibre/features/gestures/data/models/gesture_action.dart';
import 'package:weblibre/features/gestures/data/models/gesture_stroke.dart';
import 'package:weblibre/features/gestures/presentation/widgets/gesture_action_picker.dart';
import 'package:weblibre/features/gestures/presentation/widgets/gesture_stroke_view.dart';
typedef GestureBindingResult = ({GestureStroke stroke, GestureAction action});
/// Opens the gesture binding editor as a full-screen page.
///
/// Returns the configured stroke + action, or null if dismissed. Editing an
/// existing binding is supported by passing [initialStroke]/[initialAction].
///
/// [existingBindings] is the current stroke-key → action map, used to warn
/// before a new stroke would overwrite an already-assigned one.
Future<GestureBindingResult?> showGestureBindingEditor(
BuildContext context, {
GestureStroke? initialStroke,
GestureAction? initialAction,
Map<String, GestureAction> existingBindings = const {},
int maxFingers = 1,
}) {
return Navigator.of(context).push<GestureBindingResult>(
MaterialPageRoute<GestureBindingResult>(
builder: (context) => _GestureBindingEditor(
initialStroke: initialStroke,
initialAction: initialAction,
existingBindings: existingBindings,
maxFingers: maxFingers,
),
),
);
}
class _GestureBindingEditor extends HookWidget {
final GestureStroke? initialStroke;
final GestureAction? initialAction;
final Map<String, GestureAction> existingBindings;
final int maxFingers;
const _GestureBindingEditor({
required this.initialStroke,
required this.initialAction,
required this.existingBindings,
required this.maxFingers,
});
@override
Widget build(BuildContext context) {
final action = useState(initialAction ?? GestureAction.values.first);
final startPosition = useState(
initialStroke?.startPosition ?? GestureStartPosition.anywhere,
);
final fingers = useState(initialStroke?.fingers ?? 1);
final arrows = useState<List<GestureArrow>>(
initialStroke?.arrows ?? const [],
);
// Allow at least 3 fingers in the picker so multi-finger gestures can be
// configured; the native recognizer is told to match whatever is bound.
final fingerLimit = maxFingers < 3 ? 3 : maxFingers;
final stroke = GestureStroke(
startPosition: startPosition.value,
fingers: fingers.value,
arrows: arrows.value,
);
// A collision is an existing binding under the same stroke key that is not
// the one currently being edited (editing a binding in place is fine).
final collisionAction = stroke.isValid && stroke.key != initialStroke?.key
? existingBindings[stroke.key]
: null;
Future<void> save() async {
if (!stroke.isValid) return;
if (collisionAction != null) {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
icon: const Icon(Icons.warning_amber),
title: const Text('Replace existing gesture?'),
content: Text(
'This stroke is already assigned to "${collisionAction.title}". '
'Saving will replace that binding.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Replace'),
),
],
),
);
if (confirmed != true) return;
}
if (context.mounted) {
Navigator.of(context).pop((stroke: stroke, action: action.value));
}
}
return Scaffold(
appBar: AppBar(
title: Text(initialStroke == null ? 'Create gesture' : 'Edit gesture'),
),
body: Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Target action — opens the grouped action picker sheet.
Card.filled(
margin: EdgeInsets.zero,
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
clipBehavior: Clip.antiAlias,
child: ListTile(
leading: Icon(action.value.icon),
title: const Text('Target action'),
subtitle: Text(action.value.title),
trailing: const Icon(Icons.unfold_more),
onTap: () async {
final picked = await showGestureActionPicker(
context,
selected: action.value,
);
if (picked != null) action.value = picked;
},
),
),
const SizedBox(height: 24),
Align(
alignment: Alignment.centerLeft,
child: Text(
'Start position',
style: Theme.of(context).textTheme.titleMedium,
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final value in GestureStartPosition.values)
ChoiceChip(
avatar: Icon(value.icon),
showCheckmark: false,
label: Text(value.label),
selected: startPosition.value == value,
onSelected: (selected) {
if (selected) startPosition.value = value;
},
),
],
),
const SizedBox(height: 16),
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Fingers'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.remove),
onPressed: fingers.value > 1
? () => fingers.value = fingers.value - 1
: null,
),
Text('${fingers.value}'),
IconButton(
icon: const Icon(Icons.add),
onPressed: fingers.value < fingerLimit
? () => fingers.value = fingers.value + 1
: null,
),
],
),
),
const SizedBox(height: 24),
Align(
alignment: Alignment.centerLeft,
child: Text(
'Stroke pattern',
style: Theme.of(context).textTheme.titleMedium,
),
),
const SizedBox(height: 12),
// Visualizer canvas mirroring the live stroke as it is built.
Container(
height: 80,
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
),
),
alignment: Alignment.center,
child: arrows.value.isEmpty
? Text(
'Draw a stroke pattern below',
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
)
: GestureStrokeView(stroke: stroke),
),
const SizedBox(height: 16),
// Direction pad laid out like a compass (← ↑ ↓ →).
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
for (final arrow in const [
GestureArrow.left,
GestureArrow.up,
GestureArrow.down,
GestureArrow.right,
])
_DirectionButton(
arrow: arrow,
// The recognizer collapses consecutive identical directions
// into a single stroke, so a duplicate of the last arrow
// could never be matched. Disable it to keep the builder in
// sync with what native recognition can produce.
onPressed: arrows.value.lastOrNull == arrow
? null
: () => arrows.value = [...arrows.value, arrow],
),
],
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: arrows.value.isEmpty
? null
: () => arrows.value = arrows.value.sublist(
0,
arrows.value.length - 1,
),
icon: const Icon(Icons.backspace_outlined),
label: const Text('Undo last'),
),
),
],
),
),
),
const Divider(height: 1),
SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (collisionAction != null) ...[
_CollisionWarning(action: collisionAction),
const SizedBox(height: 12),
],
FilledButton(
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(56),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
onPressed: stroke.isValid ? save : null,
child: Text(
collisionAction != null
? 'Replace gesture'
: 'Save gesture',
),
),
],
),
),
),
],
),
);
}
}
/// Inline warning shown in the editor's save bar when the current stroke would
/// overwrite another binding.
class _CollisionWarning extends StatelessWidget {
final GestureAction action;
const _CollisionWarning({required this.action});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
Icons.warning_amber,
size: 20,
color: colorScheme.onErrorContainer,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Already assigned to "${action.title}". Saving replaces it.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onErrorContainer,
),
),
),
],
),
);
}
}
/// A circular tonal button for one swipe direction in the editor's D-pad.
class _DirectionButton extends StatelessWidget {
final GestureArrow arrow;
final VoidCallback? onPressed;
const _DirectionButton({required this.arrow, required this.onPressed});
@override
Widget build(BuildContext context) {
return FilledButton.tonal(
style: FilledButton.styleFrom(
shape: const CircleBorder(),
padding: const EdgeInsets.all(20),
),
onPressed: onPressed,
child: Icon(arrow.icon),
);
}
}
@@ -0,0 +1,181 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/gestures/data/models/gesture_action.dart';
import 'package:weblibre/features/gestures/data/models/gesture_settings.dart';
import 'package:weblibre/features/gestures/data/models/gesture_stroke.dart';
import 'package:weblibre/features/gestures/domain/repositories/gesture_settings.dart';
import 'package:weblibre/features/gestures/domain/services/gesture_control.dart';
import 'package:weblibre/features/gestures/presentation/widgets/gesture_stroke_view.dart';
/// One candidate completion shown in the overlay.
typedef _Suggestion = ({
GestureStroke stroke,
GestureAction action,
bool exact,
});
/// Translucent overlay shown near the top of the page while a gesture stroke is
/// being drawn. Mirrors the reference add-on's live toast: it renders the
/// in-progress stroke and the action it currently matches, and (once enough
/// strokes are drawn) the other gestures the user could complete from here.
///
/// Purely informational — it never intercepts touch input.
class GestureFeedbackOverlay extends HookConsumerWidget {
const GestureFeedbackOverlay({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(gestureSettingsWithDefaultsProvider);
final partialKey = settings.showFeedback
? ref.watch(gestureProgressProvider).asData?.value
: null;
final suggestions = partialKey == null
? const <_Suggestion>[]
: _resolveSuggestions(settings, GestureStroke.fromKey(partialKey));
return IgnorePointer(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
child: suggestions.isEmpty
? const SizedBox.shrink()
: Align(
alignment: Alignment.topCenter,
child: Padding(
padding: const EdgeInsets.only(top: 24),
child: _SuggestionCard(suggestions: suggestions),
),
),
),
);
}
/// Bindings reachable from the in-progress [current] stroke.
///
/// A binding is reachable when it uses the same finger count, a compatible
/// start position (its own, or anywhere), and its arrow sequence begins with
/// what has been drawn so far. Until [GestureSettings.minSuggestionStroke]
/// strokes are drawn (or suggestions are disabled), only the exact current
/// match is shown.
List<_Suggestion> _resolveSuggestions(
GestureSettings settings,
GestureStroke current,
) {
if (current.arrows.isEmpty) return const [];
final suggestAll =
settings.suggestNext &&
current.arrows.length >= settings.minSuggestionStroke;
final result = <_Suggestion>[];
for (final MapEntry(key: key, value: action) in settings.bindings.entries) {
final stroke = GestureStroke.fromKey(key);
if (stroke.fingers != current.fingers) continue;
if (stroke.startPosition != GestureStartPosition.anywhere &&
stroke.startPosition != current.startPosition) {
continue;
}
if (!_arrowsStartWith(stroke.arrows, current.arrows)) continue;
final exact = stroke.arrows.length == current.arrows.length;
if (!suggestAll && !exact) continue;
result.add((stroke: stroke, action: action, exact: exact));
}
result.sort((a, b) {
if (a.exact != b.exact) return a.exact ? -1 : 1;
return a.stroke.arrows.length.compareTo(b.stroke.arrows.length);
});
return result;
}
bool _arrowsStartWith(List<GestureArrow> full, List<GestureArrow> prefix) {
if (prefix.length > full.length) return false;
for (var i = 0; i < prefix.length; i++) {
if (full[i] != prefix[i]) return false;
}
return true;
}
}
class _SuggestionCard extends StatelessWidget {
final List<_Suggestion> suggestions;
const _SuggestionCard({required this.suggestions});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Material(
color: theme.colorScheme.inverseSurface.withValues(alpha: 0.92),
borderRadius: BorderRadius.circular(16),
elevation: 6,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 320),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final suggestion in suggestions)
Opacity(
opacity: suggestion.exact ? 1 : 0.6,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
Icon(
suggestion.action.icon,
size: 18,
color: theme.colorScheme.onInverseSurface,
),
const SizedBox(width: 8),
Expanded(
child: Text(
suggestion.action.title,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onInverseSurface,
fontWeight: suggestion.exact
? FontWeight.w600
: FontWeight.normal,
),
),
),
const SizedBox(width: 8),
GestureStrokeView(
stroke: suggestion.stroke,
color: theme.colorScheme.onInverseSurface,
),
],
),
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,75 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:weblibre/features/gestures/data/models/gesture_stroke.dart';
extension GestureArrowIcon on GestureArrow {
IconData get icon => switch (this) {
GestureArrow.up => Icons.arrow_upward,
GestureArrow.down => Icons.arrow_downward,
GestureArrow.left => Icons.arrow_back,
GestureArrow.right => Icons.arrow_forward,
};
}
/// Compact visual rendering of a [GestureStroke]: the direction arrows in
/// sequence, prefixed by start-position and finger-count qualifiers when set.
///
/// All glyphs share a single foreground [color] so the view blends with
/// whatever surface it sits on; it defaults to the primary color, but callers
/// rendering on a contrasting surface (e.g. the live overlay) should pass the
/// matching `on…` color.
class GestureStrokeView extends StatelessWidget {
final GestureStroke stroke;
final Color? color;
const GestureStrokeView({required this.stroke, this.color, super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final foreground = color ?? theme.colorScheme.primary;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (stroke.startPosition != GestureStartPosition.anywhere)
Padding(
padding: const EdgeInsets.only(right: 6),
child: Icon(
stroke.startPosition.icon,
size: 18,
color: foreground,
),
),
if (stroke.fingers >= 2)
Padding(
padding: const EdgeInsets.only(right: 4),
child: Text(
'${stroke.fingers}×',
style: theme.textTheme.labelLarge?.copyWith(color: foreground),
),
),
for (final arrow in stroke.arrows)
Icon(arrow.icon, size: 18, color: foreground),
],
);
}
}
@@ -0,0 +1,126 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
/// Reusable editor for a list of unique string entries: a labelled input field
/// with an add button, followed by the current entries each with a delete
/// action.
///
/// Entries are passed through [normalize] before being added; returning null
/// rejects the input (e.g. an unparseable value), and duplicates are ignored.
class StringListEditor extends HookWidget {
final List<String> values;
final ValueChanged<List<String>> onChanged;
/// Hint shown in the input field.
final String hintText;
/// Leading icon for each entry row.
final IconData itemIcon;
/// Message shown when the list is empty.
final String emptyLabel;
/// Canonicalises raw input before adding. Returns null to reject the value.
final String? Function(String input) normalize;
const StringListEditor({
required this.values,
required this.onChanged,
required this.hintText,
required this.normalize,
this.itemIcon = Icons.link,
this.emptyLabel = 'Nothing added yet.',
super.key,
});
@override
Widget build(BuildContext context) {
final controller = useTextEditingController();
// Rebuild the add button's enabled state as the field changes.
useListenable(controller);
void add() {
final normalized = normalize(controller.text);
if (normalized == null) return;
if (!values.contains(normalized)) {
onChanged([...values, normalized]);
}
controller.clear();
}
void remove(String value) {
onChanged(values.where((v) => v != value).toList());
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Row(
children: [
Expanded(
child: TextField(
controller: controller,
keyboardType: TextInputType.url,
autocorrect: false,
textInputAction: TextInputAction.done,
onSubmitted: (_) => add(),
decoration: InputDecoration(
hintText: hintText,
border: const OutlineInputBorder(),
isDense: true,
),
),
),
const SizedBox(width: 8),
IconButton.filled(
icon: const Icon(Icons.add),
tooltip: 'Add',
onPressed: controller.text.trim().isEmpty ? null : add,
),
],
),
),
if (values.isEmpty)
Padding(
padding: const EdgeInsets.all(16),
child: Text(
emptyLabel,
style: Theme.of(context).textTheme.bodyMedium,
),
)
else
for (final value in values)
ListTile(
leading: Icon(itemIcon),
title: Text(value),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: 'Remove',
onPressed: () => remove(value),
),
),
],
);
}
}