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,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),
),
),
],
);
}
}