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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user