gesture feature initial
This commit is contained in:
+181
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/features/gestures/data/models/gesture_settings.dart';
|
||||
import 'package:weblibre/features/gestures/domain/repositories/gesture_settings.dart';
|
||||
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
|
||||
|
||||
const List<SettingsSectionDefinition> _behaviorSections = [
|
||||
SettingsSectionDefinition(
|
||||
title: 'Strokes',
|
||||
entries: [
|
||||
SettingsEntryDefinition(
|
||||
title: 'Stroke sensitivity',
|
||||
subtitle: 'Minimum swipe length recognised as a direction',
|
||||
keywords: ['size', 'length'],
|
||||
child: _StrokeSensitivitySection(),
|
||||
),
|
||||
],
|
||||
),
|
||||
SettingsSectionDefinition(
|
||||
title: 'Timing',
|
||||
entries: [
|
||||
SettingsEntryDefinition(
|
||||
title: 'Timeout',
|
||||
subtitle: 'Drop a stroke if no new direction is drawn',
|
||||
keywords: ['delay'],
|
||||
child: _TimeoutSection(),
|
||||
),
|
||||
SettingsEntryDefinition(
|
||||
title: 'Cooldown',
|
||||
subtitle: 'Minimum delay between two gestures firing',
|
||||
keywords: ['interval'],
|
||||
child: _CooldownSection(),
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
/// Tuning for the gesture recognizer: stroke size, idle timeout and cooldown.
|
||||
class GestureBehaviorScreen extends StatelessWidget {
|
||||
const GestureBehaviorScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const SettingsDetailScaffold(
|
||||
title: 'Behavior & timing',
|
||||
subtitle: 'Stroke sensitivity, timeout, and cooldown.',
|
||||
icon: Icons.tune,
|
||||
sections: _behaviorSections,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StrokeSensitivitySection extends HookConsumerWidget {
|
||||
const _StrokeSensitivitySection();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(gestureSettingsWithDefaultsProvider);
|
||||
|
||||
return ListTile(
|
||||
leading: const Icon(MdiIcons.gestureTap),
|
||||
title: const Text('Stroke sensitivity'),
|
||||
subtitle: Slider.adaptive(
|
||||
min: minGestureStrokeSize.toDouble(),
|
||||
max: maxGestureStrokeSize.toDouble(),
|
||||
divisions: maxGestureStrokeSize - minGestureStrokeSize,
|
||||
value: settings.strokeSize
|
||||
.clamp(minGestureStrokeSize, maxGestureStrokeSize)
|
||||
.toDouble(),
|
||||
label: '${settings.strokeSize}',
|
||||
onChanged: (value) async {
|
||||
await ref
|
||||
.read(gestureSettingsRepositoryProvider.notifier)
|
||||
.updateSettings(
|
||||
(current) => current.copyWith.strokeSize(value.round()),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TimeoutSection extends HookConsumerWidget {
|
||||
const _TimeoutSection();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(gestureSettingsWithDefaultsProvider);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.hourglass_empty),
|
||||
title: const Text('Timeout'),
|
||||
subtitle: Slider.adaptive(
|
||||
min: minGestureTimeoutMs.toDouble(),
|
||||
max: maxGestureTimeoutMs.toDouble(),
|
||||
divisions: (maxGestureTimeoutMs - minGestureTimeoutMs) ~/ 100,
|
||||
value: settings.timeoutMs
|
||||
.clamp(minGestureTimeoutMs, maxGestureTimeoutMs)
|
||||
.toDouble(),
|
||||
label: '${settings.timeoutMs} ms',
|
||||
onChanged: (value) async {
|
||||
await ref
|
||||
.read(gestureSettingsRepositoryProvider.notifier)
|
||||
.updateSettings(
|
||||
(current) => current.copyWith.timeoutMs(value.round()),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(72, 0, 16, 8),
|
||||
child: Text(
|
||||
'A stroke is dropped if no new direction is drawn within this time.',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CooldownSection extends HookConsumerWidget {
|
||||
const _CooldownSection();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(gestureSettingsWithDefaultsProvider);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.timer_outlined),
|
||||
title: const Text('Cooldown'),
|
||||
subtitle: Slider.adaptive(
|
||||
min: minGestureIntervalMs.toDouble(),
|
||||
max: maxGestureIntervalMs.toDouble(),
|
||||
divisions: (maxGestureIntervalMs - minGestureIntervalMs) ~/ 100,
|
||||
value: settings.intervalMs
|
||||
.clamp(minGestureIntervalMs, maxGestureIntervalMs)
|
||||
.toDouble(),
|
||||
label: settings.intervalMs == 0 ? 'Off' : '${settings.intervalMs} ms',
|
||||
onChanged: (value) async {
|
||||
await ref
|
||||
.read(gestureSettingsRepositoryProvider.notifier)
|
||||
.updateSettings(
|
||||
(current) => current.copyWith.intervalMs(value.round()),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(72, 0, 16, 8),
|
||||
child: Text('Minimum delay between two gestures firing.'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/features/gestures/data/models/gesture_action.dart';
|
||||
import 'package:weblibre/features/gestures/data/models/gesture_settings.dart';
|
||||
import 'package:weblibre/features/gestures/data/models/gesture_stroke.dart';
|
||||
import 'package:weblibre/features/gestures/domain/repositories/gesture_settings.dart';
|
||||
import 'package:weblibre/features/gestures/presentation/widgets/gesture_binding_editor.dart';
|
||||
import 'package:weblibre/features/gestures/presentation/widgets/gesture_stroke_view.dart';
|
||||
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
|
||||
|
||||
/// Lists the configured gesture → action bindings, grouped by action category,
|
||||
/// and lets the user add, edit and remove them.
|
||||
class GestureBindingsScreen extends HookConsumerWidget {
|
||||
const GestureBindingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(gestureSettingsWithDefaultsProvider);
|
||||
|
||||
Future<void> upsertBinding(
|
||||
({GestureStroke stroke, GestureAction action}) result, {
|
||||
String? replacedKey,
|
||||
}) async {
|
||||
await ref.read(gestureSettingsRepositoryProvider.notifier).updateSettings(
|
||||
(current) {
|
||||
final bindings = Map<String, GestureAction>.from(current.bindings);
|
||||
if (replacedKey != null) {
|
||||
bindings.remove(replacedKey);
|
||||
}
|
||||
bindings[result.stroke.key] = result.action;
|
||||
return current.copyWith.bindings(bindings);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> removeBinding(String key) async {
|
||||
await ref.read(gestureSettingsRepositoryProvider.notifier).updateSettings(
|
||||
(current) {
|
||||
final bindings = Map<String, GestureAction>.from(current.bindings)
|
||||
..remove(key);
|
||||
return current.copyWith.bindings(bindings);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Group bindings by their action's category, preserving category order.
|
||||
final byCategory =
|
||||
<GestureActionCategory, List<MapEntry<String, GestureAction>>>{};
|
||||
for (final entry in settings.bindings.entries) {
|
||||
byCategory.putIfAbsent(entry.value.category, () => []).add(entry);
|
||||
}
|
||||
for (final entries in byCategory.values) {
|
||||
entries.sort((a, b) => a.value.title.compareTo(b.value.title));
|
||||
}
|
||||
|
||||
return SettingsCustomScrollScaffold(
|
||||
title: 'Gesture bindings',
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add gesture'),
|
||||
onPressed: () async {
|
||||
final result = await showGestureBindingEditor(
|
||||
context,
|
||||
existingBindings: settings.bindings,
|
||||
maxFingers: settings.maxFingers,
|
||||
);
|
||||
if (result != null) {
|
||||
await upsertBinding(result);
|
||||
}
|
||||
},
|
||||
),
|
||||
slivers: [
|
||||
if (settings.bindings.isEmpty)
|
||||
const SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(child: Text('No gestures assigned yet.')),
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 88),
|
||||
sliver: SliverList.list(
|
||||
children: [
|
||||
for (final category in GestureActionCategory.values)
|
||||
if (byCategory[category] case final entries?
|
||||
when entries.isNotEmpty)
|
||||
_GestureBindingGroup(
|
||||
title: category.label,
|
||||
children: [
|
||||
for (final binding in entries)
|
||||
_GestureBindingTile(
|
||||
gestureKey: binding.key,
|
||||
action: binding.value,
|
||||
onEdit: () async {
|
||||
final result = await showGestureBindingEditor(
|
||||
context,
|
||||
initialStroke: GestureStroke.fromKey(
|
||||
binding.key,
|
||||
),
|
||||
initialAction: binding.value,
|
||||
existingBindings: settings.bindings,
|
||||
maxFingers: settings.maxFingers,
|
||||
);
|
||||
if (result != null) {
|
||||
await upsertBinding(
|
||||
result,
|
||||
replacedKey: binding.key,
|
||||
);
|
||||
}
|
||||
},
|
||||
onRemove: () => removeBinding(binding.key),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A category heading followed by a filled card grouping its binding tiles,
|
||||
/// matching the visual language of the other settings screens.
|
||||
class _GestureBindingGroup extends StatelessWidget {
|
||||
final String title;
|
||||
final List<Widget> children;
|
||||
|
||||
const _GestureBindingGroup({required this.title, required this.children});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Card.filled(
|
||||
margin: EdgeInsets.zero,
|
||||
color: theme.colorScheme.surfaceContainer,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 0; i < children.length; i++) ...[
|
||||
if (i > 0) const Divider(height: 1),
|
||||
children[i],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GestureBindingTile extends StatelessWidget {
|
||||
final String gestureKey;
|
||||
final GestureAction action;
|
||||
final Future<void> Function() onEdit;
|
||||
final Future<void> Function() onRemove;
|
||||
|
||||
const _GestureBindingTile({
|
||||
required this.gestureKey,
|
||||
required this.action,
|
||||
required this.onEdit,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final stroke = GestureStroke.fromKey(gestureKey);
|
||||
|
||||
return ListTile(
|
||||
leading: Icon(action.icon),
|
||||
title: Text(action.title),
|
||||
subtitle: GestureStrokeView(stroke: stroke),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
tooltip: 'Remove',
|
||||
onPressed: () => onRemove(),
|
||||
),
|
||||
onTap: () => onEdit(),
|
||||
);
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/features/gestures/data/models/gesture_settings.dart';
|
||||
import 'package:weblibre/features/gestures/domain/repositories/gesture_settings.dart';
|
||||
import 'package:weblibre/features/gestures/presentation/widgets/string_list_editor.dart';
|
||||
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
|
||||
|
||||
/// Manages the list of sites on which gestures are disabled.
|
||||
class GestureExcludedSitesScreen extends HookConsumerWidget {
|
||||
const GestureExcludedSitesScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final excludedSites = ref.watch(
|
||||
gestureSettingsWithDefaultsProvider.select((s) => s.excludedSites),
|
||||
);
|
||||
|
||||
return SettingsCustomScrollScaffold(
|
||||
title: 'Excluded sites',
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: Text(
|
||||
'Gestures are disabled on these sites. Subdomains are included '
|
||||
'(e.g. "example.com" also covers "m.example.com").',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: StringListEditor(
|
||||
values: excludedSites,
|
||||
hintText: 'example.com',
|
||||
itemIcon: Icons.public_off,
|
||||
emptyLabel: 'No sites excluded.',
|
||||
normalize: normalizeGestureSiteHost,
|
||||
onChanged: (next) async {
|
||||
await ref
|
||||
.read(gestureSettingsRepositoryProvider.notifier)
|
||||
.updateSettings(
|
||||
(current) => current.copyWith.excludedSites(next),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/features/gestures/data/models/gesture_settings.dart';
|
||||
import 'package:weblibre/features/gestures/domain/repositories/gesture_settings.dart';
|
||||
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
|
||||
|
||||
const List<SettingsSectionDefinition> _feedbackSections = [
|
||||
SettingsSectionDefinition(
|
||||
title: 'Overlay',
|
||||
entries: [
|
||||
SettingsEntryDefinition(
|
||||
title: 'Live feedback',
|
||||
subtitle: 'Show the stroke and its action while you draw',
|
||||
child: _LiveFeedbackTile(),
|
||||
),
|
||||
SettingsEntryDefinition(
|
||||
title: 'Suggest next',
|
||||
subtitle: 'Also show the other gestures you can complete',
|
||||
child: _SuggestNextTile(),
|
||||
),
|
||||
SettingsEntryDefinition(
|
||||
title: 'Suggest after',
|
||||
subtitle: 'Number of strokes to draw before suggestions appear',
|
||||
child: _SuggestAfterSection(),
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
/// Controls the live feedback overlay shown while drawing a gesture.
|
||||
class GestureFeedbackScreen extends StatelessWidget {
|
||||
const GestureFeedbackScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const SettingsDetailScaffold(
|
||||
title: 'Feedback',
|
||||
subtitle: 'Live overlay and gesture suggestions.',
|
||||
icon: Icons.bolt_outlined,
|
||||
sections: _feedbackSections,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LiveFeedbackTile extends HookConsumerWidget {
|
||||
const _LiveFeedbackTile();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final showFeedback = ref.watch(
|
||||
gestureSettingsWithDefaultsProvider.select((s) => s.showFeedback),
|
||||
);
|
||||
|
||||
return SwitchListTile.adaptive(
|
||||
secondary: const Icon(Icons.bolt_outlined),
|
||||
title: const Text('Live feedback'),
|
||||
subtitle: const Text('Show the stroke and its action while you draw'),
|
||||
value: showFeedback,
|
||||
onChanged: (value) async {
|
||||
await ref
|
||||
.read(gestureSettingsRepositoryProvider.notifier)
|
||||
.updateSettings((current) => current.copyWith.showFeedback(value));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SuggestNextTile extends HookConsumerWidget {
|
||||
const _SuggestNextTile();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(gestureSettingsWithDefaultsProvider);
|
||||
|
||||
return SwitchListTile.adaptive(
|
||||
secondary: const Icon(Icons.lightbulb_outline),
|
||||
title: const Text('Suggest next'),
|
||||
subtitle: const Text('Also show the other gestures you can complete'),
|
||||
value: settings.suggestNext,
|
||||
onChanged: settings.showFeedback
|
||||
? (value) async {
|
||||
await ref
|
||||
.read(gestureSettingsRepositoryProvider.notifier)
|
||||
.updateSettings(
|
||||
(current) => current.copyWith.suggestNext(value),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SuggestAfterSection extends HookConsumerWidget {
|
||||
const _SuggestAfterSection();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(gestureSettingsWithDefaultsProvider);
|
||||
final enabled = settings.showFeedback && settings.suggestNext;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
enabled: enabled,
|
||||
leading: const Icon(Icons.straighten),
|
||||
title: const Text('Suggest after'),
|
||||
subtitle: Slider.adaptive(
|
||||
min: minGestureMinSuggestionStroke.toDouble(),
|
||||
max: maxGestureMinSuggestionStroke.toDouble(),
|
||||
divisions:
|
||||
maxGestureMinSuggestionStroke - minGestureMinSuggestionStroke,
|
||||
value: settings.minSuggestionStroke
|
||||
.clamp(
|
||||
minGestureMinSuggestionStroke,
|
||||
maxGestureMinSuggestionStroke,
|
||||
)
|
||||
.toDouble(),
|
||||
label: '${settings.minSuggestionStroke} strokes',
|
||||
onChanged: enabled
|
||||
? (value) async {
|
||||
await ref
|
||||
.read(gestureSettingsRepositoryProvider.notifier)
|
||||
.updateSettings(
|
||||
(current) =>
|
||||
current.copyWith.minSuggestionStroke(value.round()),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(72, 0, 16, 8),
|
||||
child: Text('Number of strokes to draw before suggestions appear.'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/features/gestures/data/models/gesture_settings.dart';
|
||||
import 'package:weblibre/features/gestures/domain/repositories/gesture_settings.dart';
|
||||
import 'package:weblibre/features/gestures/presentation/screens/gesture_behavior_screen.dart';
|
||||
import 'package:weblibre/features/gestures/presentation/screens/gesture_bindings_screen.dart';
|
||||
import 'package:weblibre/features/gestures/presentation/screens/gesture_excluded_sites_screen.dart';
|
||||
import 'package:weblibre/features/gestures/presentation/screens/gesture_feedback_screen.dart';
|
||||
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
|
||||
|
||||
/// Overview screen for gesture configuration: a master switch plus entries that
|
||||
/// open the dedicated bindings / behavior / excluded-sites / feedback subpages.
|
||||
class GestureSettingsScreen extends HookConsumerWidget {
|
||||
const GestureSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(gestureSettingsWithDefaultsProvider);
|
||||
final repository = ref.read(gestureSettingsRepositoryProvider.notifier);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
void open(Widget screen) {
|
||||
unawaited(
|
||||
Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute<void>(builder: (_) => screen)),
|
||||
);
|
||||
}
|
||||
|
||||
return SettingsCustomScrollScaffold(
|
||||
title: 'Gestures',
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Card.filled(
|
||||
margin: EdgeInsets.zero,
|
||||
color: colorScheme.primaryContainer,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: SwitchListTile.adaptive(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 8,
|
||||
),
|
||||
secondary: Icon(
|
||||
MdiIcons.gestureSwipe,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
title: Text(
|
||||
'Enable Gestures',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
'Draw stroke gestures on web pages to trigger actions',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onPrimaryContainer.withValues(
|
||||
alpha: 0.8,
|
||||
),
|
||||
),
|
||||
),
|
||||
value: settings.enabled,
|
||||
onChanged: (value) async {
|
||||
await repository.updateSettings(
|
||||
(current) => current.copyWith.enabled(value),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (settings.enabled)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 24, 16, 20),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: buildSettingsSectionWidgets(context, [
|
||||
SettingsSectionDefinition(
|
||||
title: 'Configuration',
|
||||
entries: [
|
||||
SettingsEntryDefinition(
|
||||
title: 'Gesture bindings',
|
||||
child: ListTile(
|
||||
leading: const Icon(MdiIcons.gestureDoubleTap),
|
||||
title: const Text('Gesture bindings'),
|
||||
subtitle: const Text('Strokes mapped to actions'),
|
||||
trailing: _CountChevron(
|
||||
count: settings.bindings.length,
|
||||
),
|
||||
onTap: () => open(const GestureBindingsScreen()),
|
||||
),
|
||||
),
|
||||
SettingsEntryDefinition(
|
||||
title: 'Behavior & timing',
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.tune),
|
||||
title: const Text('Behavior & timing'),
|
||||
subtitle: const Text(
|
||||
'Sensitivity, timeout, cooldown',
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => open(const GestureBehaviorScreen()),
|
||||
),
|
||||
),
|
||||
SettingsEntryDefinition(
|
||||
title: 'Excluded sites',
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.public_off),
|
||||
title: const Text('Excluded sites'),
|
||||
subtitle: const Text('Disable gestures per site'),
|
||||
trailing: _CountChevron(
|
||||
count: settings.excludedSites.length,
|
||||
),
|
||||
onTap: () => open(const GestureExcludedSitesScreen()),
|
||||
),
|
||||
),
|
||||
SettingsEntryDefinition(
|
||||
title: 'Feedback',
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.bolt_outlined),
|
||||
title: const Text('Feedback'),
|
||||
subtitle: const Text('Live overlay and suggestions'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => open(const GestureFeedbackScreen()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CountChevron extends StatelessWidget {
|
||||
final int count;
|
||||
|
||||
const _CountChevron({required this.count});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (count > 0)
|
||||
Text('$count', style: Theme.of(context).textTheme.labelLarge),
|
||||
const Icon(Icons.chevron_right),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:weblibre/features/gestures/data/models/gesture_action.dart';
|
||||
|
||||
/// Shows a modal bottom sheet listing every [GestureAction] grouped by category,
|
||||
/// each with its icon, title and description, and returns the chosen action (or
|
||||
/// null if dismissed). Mirrors the icon + subtitle selection sheets used
|
||||
/// elsewhere in the app (e.g. the contextual toolbar pickers).
|
||||
Future<GestureAction?> showGestureActionPicker(
|
||||
BuildContext context, {
|
||||
required GestureAction selected,
|
||||
}) {
|
||||
return showModalBottomSheet<GestureAction>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
showDragHandle: true,
|
||||
builder: (context) => _GestureActionPicker(selected: selected),
|
||||
);
|
||||
}
|
||||
|
||||
class _GestureActionPicker extends StatelessWidget {
|
||||
final GestureAction selected;
|
||||
|
||||
const _GestureActionPicker({required this.selected});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
final byCategory = <GestureActionCategory, List<GestureAction>>{};
|
||||
for (final action in GestureAction.values) {
|
||||
byCategory.putIfAbsent(action.category, () => []).add(action);
|
||||
}
|
||||
|
||||
// Cap the sheet height so the long action list scrolls inside a sheet that
|
||||
// never covers the whole screen.
|
||||
final maxHeight = MediaQuery.sizeOf(context).height * 0.8;
|
||||
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: maxHeight),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
|
||||
child: Text(
|
||||
'Choose action',
|
||||
style: theme.textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
children: [
|
||||
for (final category in GestureActionCategory.values)
|
||||
if (byCategory[category] case final actions?
|
||||
when actions.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 12, 24, 4),
|
||||
child: Text(
|
||||
category.label,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final action in actions)
|
||||
ListTile(
|
||||
leading: Icon(action.icon),
|
||||
title: Text(action.title),
|
||||
subtitle: Text(action.description),
|
||||
selected: action == selected,
|
||||
trailing: action == selected
|
||||
? Icon(Icons.check, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () => Navigator.of(context).pop(action),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:weblibre/features/gestures/data/models/gesture_action.dart';
|
||||
import 'package:weblibre/features/gestures/data/models/gesture_stroke.dart';
|
||||
import 'package:weblibre/features/gestures/presentation/widgets/gesture_action_picker.dart';
|
||||
import 'package:weblibre/features/gestures/presentation/widgets/gesture_stroke_view.dart';
|
||||
|
||||
typedef GestureBindingResult = ({GestureStroke stroke, GestureAction action});
|
||||
|
||||
/// Opens the gesture binding editor as a full-screen page.
|
||||
///
|
||||
/// Returns the configured stroke + action, or null if dismissed. Editing an
|
||||
/// existing binding is supported by passing [initialStroke]/[initialAction].
|
||||
///
|
||||
/// [existingBindings] is the current stroke-key → action map, used to warn
|
||||
/// before a new stroke would overwrite an already-assigned one.
|
||||
Future<GestureBindingResult?> showGestureBindingEditor(
|
||||
BuildContext context, {
|
||||
GestureStroke? initialStroke,
|
||||
GestureAction? initialAction,
|
||||
Map<String, GestureAction> existingBindings = const {},
|
||||
int maxFingers = 1,
|
||||
}) {
|
||||
return Navigator.of(context).push<GestureBindingResult>(
|
||||
MaterialPageRoute<GestureBindingResult>(
|
||||
builder: (context) => _GestureBindingEditor(
|
||||
initialStroke: initialStroke,
|
||||
initialAction: initialAction,
|
||||
existingBindings: existingBindings,
|
||||
maxFingers: maxFingers,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _GestureBindingEditor extends HookWidget {
|
||||
final GestureStroke? initialStroke;
|
||||
final GestureAction? initialAction;
|
||||
final Map<String, GestureAction> existingBindings;
|
||||
final int maxFingers;
|
||||
|
||||
const _GestureBindingEditor({
|
||||
required this.initialStroke,
|
||||
required this.initialAction,
|
||||
required this.existingBindings,
|
||||
required this.maxFingers,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final action = useState(initialAction ?? GestureAction.values.first);
|
||||
final startPosition = useState(
|
||||
initialStroke?.startPosition ?? GestureStartPosition.anywhere,
|
||||
);
|
||||
final fingers = useState(initialStroke?.fingers ?? 1);
|
||||
final arrows = useState<List<GestureArrow>>(
|
||||
initialStroke?.arrows ?? const [],
|
||||
);
|
||||
|
||||
// Allow at least 3 fingers in the picker so multi-finger gestures can be
|
||||
// configured; the native recognizer is told to match whatever is bound.
|
||||
final fingerLimit = maxFingers < 3 ? 3 : maxFingers;
|
||||
|
||||
final stroke = GestureStroke(
|
||||
startPosition: startPosition.value,
|
||||
fingers: fingers.value,
|
||||
arrows: arrows.value,
|
||||
);
|
||||
|
||||
// A collision is an existing binding under the same stroke key that is not
|
||||
// the one currently being edited (editing a binding in place is fine).
|
||||
final collisionAction = stroke.isValid && stroke.key != initialStroke?.key
|
||||
? existingBindings[stroke.key]
|
||||
: null;
|
||||
|
||||
Future<void> save() async {
|
||||
if (!stroke.isValid) return;
|
||||
|
||||
if (collisionAction != null) {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
icon: const Icon(Icons.warning_amber),
|
||||
title: const Text('Replace existing gesture?'),
|
||||
content: Text(
|
||||
'This stroke is already assigned to "${collisionAction.title}". '
|
||||
'Saving will replace that binding.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Replace'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop((stroke: stroke, action: action.value));
|
||||
}
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(initialStroke == null ? 'Create gesture' : 'Edit gesture'),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Target action — opens the grouped action picker sheet.
|
||||
Card.filled(
|
||||
margin: EdgeInsets.zero,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ListTile(
|
||||
leading: Icon(action.value.icon),
|
||||
title: const Text('Target action'),
|
||||
subtitle: Text(action.value.title),
|
||||
trailing: const Icon(Icons.unfold_more),
|
||||
onTap: () async {
|
||||
final picked = await showGestureActionPicker(
|
||||
context,
|
||||
selected: action.value,
|
||||
);
|
||||
if (picked != null) action.value = picked;
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
'Start position',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final value in GestureStartPosition.values)
|
||||
ChoiceChip(
|
||||
avatar: Icon(value.icon),
|
||||
showCheckmark: false,
|
||||
label: Text(value.label),
|
||||
selected: startPosition.value == value,
|
||||
onSelected: (selected) {
|
||||
if (selected) startPosition.value = value;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Fingers'),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.remove),
|
||||
onPressed: fingers.value > 1
|
||||
? () => fingers.value = fingers.value - 1
|
||||
: null,
|
||||
),
|
||||
Text('${fingers.value}'),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: fingers.value < fingerLimit
|
||||
? () => fingers.value = fingers.value + 1
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
'Stroke pattern',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Visualizer canvas mirroring the live stroke as it is built.
|
||||
Container(
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: arrows.value.isEmpty
|
||||
? Text(
|
||||
'Draw a stroke pattern below',
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: GestureStrokeView(stroke: stroke),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Direction pad laid out like a compass (← ↑ ↓ →).
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
for (final arrow in const [
|
||||
GestureArrow.left,
|
||||
GestureArrow.up,
|
||||
GestureArrow.down,
|
||||
GestureArrow.right,
|
||||
])
|
||||
_DirectionButton(
|
||||
arrow: arrow,
|
||||
// The recognizer collapses consecutive identical directions
|
||||
// into a single stroke, so a duplicate of the last arrow
|
||||
// could never be matched. Disable it to keep the builder in
|
||||
// sync with what native recognition can produce.
|
||||
onPressed: arrows.value.lastOrNull == arrow
|
||||
? null
|
||||
: () => arrows.value = [...arrows.value, arrow],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton.icon(
|
||||
onPressed: arrows.value.isEmpty
|
||||
? null
|
||||
: () => arrows.value = arrows.value.sublist(
|
||||
0,
|
||||
arrows.value.length - 1,
|
||||
),
|
||||
icon: const Icon(Icons.backspace_outlined),
|
||||
label: const Text('Undo last'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (collisionAction != null) ...[
|
||||
_CollisionWarning(action: collisionAction),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(56),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
onPressed: stroke.isValid ? save : null,
|
||||
child: Text(
|
||||
collisionAction != null
|
||||
? 'Replace gesture'
|
||||
: 'Save gesture',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Inline warning shown in the editor's save bar when the current stroke would
|
||||
/// overwrite another binding.
|
||||
class _CollisionWarning extends StatelessWidget {
|
||||
final GestureAction action;
|
||||
|
||||
const _CollisionWarning({required this.action});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.warning_amber,
|
||||
size: 20,
|
||||
color: colorScheme.onErrorContainer,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Already assigned to "${action.title}". Saving replaces it.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A circular tonal button for one swipe direction in the editor's D-pad.
|
||||
class _DirectionButton extends StatelessWidget {
|
||||
final GestureArrow arrow;
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
const _DirectionButton({required this.arrow, required this.onPressed});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FilledButton.tonal(
|
||||
style: FilledButton.styleFrom(
|
||||
shape: const CircleBorder(),
|
||||
padding: const EdgeInsets.all(20),
|
||||
),
|
||||
onPressed: onPressed,
|
||||
child: Icon(arrow.icon),
|
||||
);
|
||||
}
|
||||
}
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/features/gestures/data/models/gesture_action.dart';
|
||||
import 'package:weblibre/features/gestures/data/models/gesture_settings.dart';
|
||||
import 'package:weblibre/features/gestures/data/models/gesture_stroke.dart';
|
||||
import 'package:weblibre/features/gestures/domain/repositories/gesture_settings.dart';
|
||||
import 'package:weblibre/features/gestures/domain/services/gesture_control.dart';
|
||||
import 'package:weblibre/features/gestures/presentation/widgets/gesture_stroke_view.dart';
|
||||
|
||||
/// One candidate completion shown in the overlay.
|
||||
typedef _Suggestion = ({
|
||||
GestureStroke stroke,
|
||||
GestureAction action,
|
||||
bool exact,
|
||||
});
|
||||
|
||||
/// Translucent overlay shown near the top of the page while a gesture stroke is
|
||||
/// being drawn. Mirrors the reference add-on's live toast: it renders the
|
||||
/// in-progress stroke and the action it currently matches, and (once enough
|
||||
/// strokes are drawn) the other gestures the user could complete from here.
|
||||
///
|
||||
/// Purely informational — it never intercepts touch input.
|
||||
class GestureFeedbackOverlay extends HookConsumerWidget {
|
||||
const GestureFeedbackOverlay({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(gestureSettingsWithDefaultsProvider);
|
||||
final partialKey = settings.showFeedback
|
||||
? ref.watch(gestureProgressProvider).asData?.value
|
||||
: null;
|
||||
|
||||
final suggestions = partialKey == null
|
||||
? const <_Suggestion>[]
|
||||
: _resolveSuggestions(settings, GestureStroke.fromKey(partialKey));
|
||||
|
||||
return IgnorePointer(
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
child: suggestions.isEmpty
|
||||
? const SizedBox.shrink()
|
||||
: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 24),
|
||||
child: _SuggestionCard(suggestions: suggestions),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Bindings reachable from the in-progress [current] stroke.
|
||||
///
|
||||
/// A binding is reachable when it uses the same finger count, a compatible
|
||||
/// start position (its own, or anywhere), and its arrow sequence begins with
|
||||
/// what has been drawn so far. Until [GestureSettings.minSuggestionStroke]
|
||||
/// strokes are drawn (or suggestions are disabled), only the exact current
|
||||
/// match is shown.
|
||||
List<_Suggestion> _resolveSuggestions(
|
||||
GestureSettings settings,
|
||||
GestureStroke current,
|
||||
) {
|
||||
if (current.arrows.isEmpty) return const [];
|
||||
|
||||
final suggestAll =
|
||||
settings.suggestNext &&
|
||||
current.arrows.length >= settings.minSuggestionStroke;
|
||||
|
||||
final result = <_Suggestion>[];
|
||||
for (final MapEntry(key: key, value: action) in settings.bindings.entries) {
|
||||
final stroke = GestureStroke.fromKey(key);
|
||||
if (stroke.fingers != current.fingers) continue;
|
||||
if (stroke.startPosition != GestureStartPosition.anywhere &&
|
||||
stroke.startPosition != current.startPosition) {
|
||||
continue;
|
||||
}
|
||||
if (!_arrowsStartWith(stroke.arrows, current.arrows)) continue;
|
||||
|
||||
final exact = stroke.arrows.length == current.arrows.length;
|
||||
if (!suggestAll && !exact) continue;
|
||||
|
||||
result.add((stroke: stroke, action: action, exact: exact));
|
||||
}
|
||||
|
||||
result.sort((a, b) {
|
||||
if (a.exact != b.exact) return a.exact ? -1 : 1;
|
||||
return a.stroke.arrows.length.compareTo(b.stroke.arrows.length);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
bool _arrowsStartWith(List<GestureArrow> full, List<GestureArrow> prefix) {
|
||||
if (prefix.length > full.length) return false;
|
||||
for (var i = 0; i < prefix.length; i++) {
|
||||
if (full[i] != prefix[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class _SuggestionCard extends StatelessWidget {
|
||||
final List<_Suggestion> suggestions;
|
||||
|
||||
const _SuggestionCard({required this.suggestions});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Material(
|
||||
color: theme.colorScheme.inverseSurface.withValues(alpha: 0.92),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
elevation: 6,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 320),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final suggestion in suggestions)
|
||||
Opacity(
|
||||
opacity: suggestion.exact ? 1 : 0.6,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
suggestion.action.icon,
|
||||
size: 18,
|
||||
color: theme.colorScheme.onInverseSurface,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
suggestion.action.title,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onInverseSurface,
|
||||
fontWeight: suggestion.exact
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureStrokeView(
|
||||
stroke: suggestion.stroke,
|
||||
color: theme.colorScheme.onInverseSurface,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:weblibre/features/gestures/data/models/gesture_stroke.dart';
|
||||
|
||||
extension GestureArrowIcon on GestureArrow {
|
||||
IconData get icon => switch (this) {
|
||||
GestureArrow.up => Icons.arrow_upward,
|
||||
GestureArrow.down => Icons.arrow_downward,
|
||||
GestureArrow.left => Icons.arrow_back,
|
||||
GestureArrow.right => Icons.arrow_forward,
|
||||
};
|
||||
}
|
||||
|
||||
/// Compact visual rendering of a [GestureStroke]: the direction arrows in
|
||||
/// sequence, prefixed by start-position and finger-count qualifiers when set.
|
||||
///
|
||||
/// All glyphs share a single foreground [color] so the view blends with
|
||||
/// whatever surface it sits on; it defaults to the primary color, but callers
|
||||
/// rendering on a contrasting surface (e.g. the live overlay) should pass the
|
||||
/// matching `on…` color.
|
||||
class GestureStrokeView extends StatelessWidget {
|
||||
final GestureStroke stroke;
|
||||
final Color? color;
|
||||
|
||||
const GestureStrokeView({required this.stroke, this.color, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final foreground = color ?? theme.colorScheme.primary;
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (stroke.startPosition != GestureStartPosition.anywhere)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 6),
|
||||
child: Icon(
|
||||
stroke.startPosition.icon,
|
||||
size: 18,
|
||||
color: foreground,
|
||||
),
|
||||
),
|
||||
if (stroke.fingers >= 2)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: Text(
|
||||
'${stroke.fingers}×',
|
||||
style: theme.textTheme.labelLarge?.copyWith(color: foreground),
|
||||
),
|
||||
),
|
||||
for (final arrow in stroke.arrows)
|
||||
Icon(arrow.icon, size: 18, color: foreground),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
|
||||
/// Reusable editor for a list of unique string entries: a labelled input field
|
||||
/// with an add button, followed by the current entries each with a delete
|
||||
/// action.
|
||||
///
|
||||
/// Entries are passed through [normalize] before being added; returning null
|
||||
/// rejects the input (e.g. an unparseable value), and duplicates are ignored.
|
||||
class StringListEditor extends HookWidget {
|
||||
final List<String> values;
|
||||
final ValueChanged<List<String>> onChanged;
|
||||
|
||||
/// Hint shown in the input field.
|
||||
final String hintText;
|
||||
|
||||
/// Leading icon for each entry row.
|
||||
final IconData itemIcon;
|
||||
|
||||
/// Message shown when the list is empty.
|
||||
final String emptyLabel;
|
||||
|
||||
/// Canonicalises raw input before adding. Returns null to reject the value.
|
||||
final String? Function(String input) normalize;
|
||||
|
||||
const StringListEditor({
|
||||
required this.values,
|
||||
required this.onChanged,
|
||||
required this.hintText,
|
||||
required this.normalize,
|
||||
this.itemIcon = Icons.link,
|
||||
this.emptyLabel = 'Nothing added yet.',
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = useTextEditingController();
|
||||
// Rebuild the add button's enabled state as the field changes.
|
||||
useListenable(controller);
|
||||
|
||||
void add() {
|
||||
final normalized = normalize(controller.text);
|
||||
if (normalized == null) return;
|
||||
if (!values.contains(normalized)) {
|
||||
onChanged([...values, normalized]);
|
||||
}
|
||||
controller.clear();
|
||||
}
|
||||
|
||||
void remove(String value) {
|
||||
onChanged(values.where((v) => v != value).toList());
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
keyboardType: TextInputType.url,
|
||||
autocorrect: false,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => add(),
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton.filled(
|
||||
icon: const Icon(Icons.add),
|
||||
tooltip: 'Add',
|
||||
onPressed: controller.text.trim().isEmpty ? null : add,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (values.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
emptyLabel,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
)
|
||||
else
|
||||
for (final value in values)
|
||||
ListTile(
|
||||
leading: Icon(itemIcon),
|
||||
title: Text(value),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
tooltip: 'Remove',
|
||||
onPressed: () => remove(value),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user