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