improve gesture ui and matching
This commit is contained in:
@@ -39,6 +39,10 @@ const defaultGestureIntervalMs = 0;
|
||||
const minGestureIntervalMs = 0;
|
||||
const maxGestureIntervalMs = 2000;
|
||||
|
||||
const defaultGestureStrokeIntervalMs = 0;
|
||||
const minGestureStrokeIntervalMs = 0;
|
||||
const maxGestureStrokeIntervalMs = 500;
|
||||
|
||||
/// Minimum number of strokes drawn before the live overlay starts suggesting
|
||||
/// the other possible completions (mirrors the reference add-on's
|
||||
/// `toastMinStroke`).
|
||||
@@ -82,6 +86,11 @@ class GestureSettings with FastEquatable {
|
||||
/// gestures are ignored. 0 disables the cooldown.
|
||||
final int intervalMs;
|
||||
|
||||
/// Minimum milliseconds required between two consecutive direction changes
|
||||
/// within a single gesture. A faster direction change aborts the in-progress
|
||||
/// gesture, guarding against accidental fast scribbles. 0 disables the check.
|
||||
final int minStrokeIntervalMs;
|
||||
|
||||
/// Whether to show the live feedback overlay while a stroke is being drawn
|
||||
/// (the in-progress arrows plus the matching/possible actions).
|
||||
final bool showFeedback;
|
||||
@@ -108,6 +117,7 @@ class GestureSettings with FastEquatable {
|
||||
required this.timeoutMs,
|
||||
required this.maxFingers,
|
||||
required this.intervalMs,
|
||||
required this.minStrokeIntervalMs,
|
||||
required this.showFeedback,
|
||||
required this.suggestNext,
|
||||
required this.minSuggestionStroke,
|
||||
@@ -122,6 +132,7 @@ class GestureSettings with FastEquatable {
|
||||
int? timeoutMs,
|
||||
int? maxFingers,
|
||||
int? intervalMs,
|
||||
int? minStrokeIntervalMs,
|
||||
bool? showFeedback,
|
||||
bool? suggestNext,
|
||||
int? minSuggestionStroke,
|
||||
@@ -133,6 +144,8 @@ class GestureSettings with FastEquatable {
|
||||
timeoutMs = timeoutMs ?? defaultGestureTimeoutMs,
|
||||
maxFingers = maxFingers ?? defaultGestureMaxFingers,
|
||||
intervalMs = intervalMs ?? defaultGestureIntervalMs,
|
||||
minStrokeIntervalMs =
|
||||
minStrokeIntervalMs ?? defaultGestureStrokeIntervalMs,
|
||||
showFeedback = showFeedback ?? true,
|
||||
suggestNext = suggestNext ?? true,
|
||||
minSuggestionStroke =
|
||||
@@ -156,6 +169,7 @@ class GestureSettings with FastEquatable {
|
||||
timeoutMs,
|
||||
maxFingers,
|
||||
intervalMs,
|
||||
minStrokeIntervalMs,
|
||||
showFeedback,
|
||||
suggestNext,
|
||||
minSuggestionStroke,
|
||||
|
||||
@@ -19,6 +19,8 @@ abstract class _$GestureSettingsCWProxy {
|
||||
|
||||
GestureSettings intervalMs(int intervalMs);
|
||||
|
||||
GestureSettings minStrokeIntervalMs(int minStrokeIntervalMs);
|
||||
|
||||
GestureSettings showFeedback(bool showFeedback);
|
||||
|
||||
GestureSettings suggestNext(bool suggestNext);
|
||||
@@ -43,6 +45,7 @@ abstract class _$GestureSettingsCWProxy {
|
||||
int timeoutMs,
|
||||
int maxFingers,
|
||||
int intervalMs,
|
||||
int minStrokeIntervalMs,
|
||||
bool showFeedback,
|
||||
bool suggestNext,
|
||||
int minSuggestionStroke,
|
||||
@@ -76,6 +79,10 @@ class _$GestureSettingsCWProxyImpl implements _$GestureSettingsCWProxy {
|
||||
@override
|
||||
GestureSettings intervalMs(int intervalMs) => call(intervalMs: intervalMs);
|
||||
|
||||
@override
|
||||
GestureSettings minStrokeIntervalMs(int minStrokeIntervalMs) =>
|
||||
call(minStrokeIntervalMs: minStrokeIntervalMs);
|
||||
|
||||
@override
|
||||
GestureSettings showFeedback(bool showFeedback) =>
|
||||
call(showFeedback: showFeedback);
|
||||
@@ -111,6 +118,7 @@ class _$GestureSettingsCWProxyImpl implements _$GestureSettingsCWProxy {
|
||||
Object? timeoutMs = const $CopyWithPlaceholder(),
|
||||
Object? maxFingers = const $CopyWithPlaceholder(),
|
||||
Object? intervalMs = const $CopyWithPlaceholder(),
|
||||
Object? minStrokeIntervalMs = const $CopyWithPlaceholder(),
|
||||
Object? showFeedback = const $CopyWithPlaceholder(),
|
||||
Object? suggestNext = const $CopyWithPlaceholder(),
|
||||
Object? minSuggestionStroke = const $CopyWithPlaceholder(),
|
||||
@@ -145,6 +153,12 @@ class _$GestureSettingsCWProxyImpl implements _$GestureSettingsCWProxy {
|
||||
? _value.intervalMs
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: intervalMs as int,
|
||||
minStrokeIntervalMs:
|
||||
minStrokeIntervalMs == const $CopyWithPlaceholder() ||
|
||||
minStrokeIntervalMs == null
|
||||
? _value.minStrokeIntervalMs
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: minStrokeIntervalMs as int,
|
||||
showFeedback:
|
||||
showFeedback == const $CopyWithPlaceholder() || showFeedback == null
|
||||
? _value.showFeedback
|
||||
@@ -193,6 +207,7 @@ GestureSettings _$GestureSettingsFromJson(Map<String, dynamic> json) =>
|
||||
timeoutMs: (json['timeoutMs'] as num?)?.toInt(),
|
||||
maxFingers: (json['maxFingers'] as num?)?.toInt(),
|
||||
intervalMs: (json['intervalMs'] as num?)?.toInt(),
|
||||
minStrokeIntervalMs: (json['minStrokeIntervalMs'] as num?)?.toInt(),
|
||||
showFeedback: json['showFeedback'] as bool?,
|
||||
suggestNext: json['suggestNext'] as bool?,
|
||||
minSuggestionStroke: (json['minSuggestionStroke'] as num?)?.toInt(),
|
||||
@@ -212,6 +227,7 @@ Map<String, dynamic> _$GestureSettingsToJson(GestureSettings instance) =>
|
||||
'timeoutMs': instance.timeoutMs,
|
||||
'maxFingers': instance.maxFingers,
|
||||
'intervalMs': instance.intervalMs,
|
||||
'minStrokeIntervalMs': instance.minStrokeIntervalMs,
|
||||
'showFeedback': instance.showFeedback,
|
||||
'suggestNext': instance.suggestNext,
|
||||
'minSuggestionStroke': instance.minSuggestionStroke,
|
||||
|
||||
@@ -62,6 +62,10 @@ class GestureSettingsRepository extends _$GestureSettingsRepository {
|
||||
DriftSqlType.int,
|
||||
db.typeMapping,
|
||||
),
|
||||
'minStrokeIntervalMs': settings['minStrokeIntervalMs']?.readAs(
|
||||
DriftSqlType.int,
|
||||
db.typeMapping,
|
||||
),
|
||||
'showFeedback': settings['showFeedback']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
|
||||
@@ -332,6 +332,7 @@ GestureConfig gestureNativeConfig(Ref ref) {
|
||||
strokeSize: settings.strokeSize,
|
||||
timeoutMs: settings.timeoutMs,
|
||||
maxFingers: requiredFingers,
|
||||
minStrokeIntervalMs: settings.minStrokeIntervalMs,
|
||||
activeGestureKeys: settings.bindings.keys.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ final class GestureNativeConfigProvider
|
||||
}
|
||||
|
||||
String _$gestureNativeConfigHash() =>
|
||||
r'57aada8631dfc3d8355f9498f6b7c6954b0cdcf1';
|
||||
r'd7caf199a60089109cbf264ca3c4d59ccf97044e';
|
||||
|
||||
/// The in-progress stroke shown by the live feedback overlay.
|
||||
///
|
||||
|
||||
+113
-7
@@ -29,10 +29,10 @@ const List<SettingsSectionDefinition> _behaviorSections = [
|
||||
title: 'Strokes',
|
||||
entries: [
|
||||
SettingsEntryDefinition(
|
||||
title: 'Stroke sensitivity',
|
||||
title: 'Minimum stroke length',
|
||||
subtitle: 'Minimum swipe length recognised as a direction',
|
||||
keywords: ['size', 'length'],
|
||||
child: _StrokeSensitivitySection(),
|
||||
keywords: ['size', 'length', 'sensitivity'],
|
||||
child: _StrokeLengthSection(),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -51,6 +51,12 @@ const List<SettingsSectionDefinition> _behaviorSections = [
|
||||
keywords: ['interval'],
|
||||
child: _CooldownSection(),
|
||||
),
|
||||
SettingsEntryDefinition(
|
||||
title: 'Stroke interval',
|
||||
subtitle: 'Reject a gesture when direction changes come too fast',
|
||||
keywords: ['debounce', 'jitter', 'accidental'],
|
||||
child: _StrokeIntervalSection(),
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
@@ -63,15 +69,68 @@ class GestureBehaviorScreen extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return const SettingsDetailScaffold(
|
||||
title: 'Behavior & timing',
|
||||
subtitle: 'Stroke sensitivity, timeout, and cooldown.',
|
||||
subtitle: 'Stroke length, timeout, and cooldown.',
|
||||
icon: Icons.tune,
|
||||
sections: _behaviorSections,
|
||||
actions: [_ResetBehaviorButton()],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StrokeSensitivitySection extends HookConsumerWidget {
|
||||
const _StrokeSensitivitySection();
|
||||
/// App-bar action that restores the behavior & timing fields (stroke length,
|
||||
/// timeout, cooldown, stroke interval) to their defaults, leaving bindings,
|
||||
/// excluded sites and feedback untouched.
|
||||
class _ResetBehaviorButton extends ConsumerWidget {
|
||||
const _ResetBehaviorButton();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.settings_backup_restore),
|
||||
tooltip: 'Reset to defaults',
|
||||
onPressed: () async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
icon: const Icon(Icons.settings_backup_restore),
|
||||
title: const Text('Reset behavior & timing?'),
|
||||
content: const Text(
|
||||
'Stroke length, timeout, cooldown and stroke interval will be '
|
||||
'restored to their defaults. Your gesture bindings and other '
|
||||
'settings are kept.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Reset'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
await ref
|
||||
.read(gestureSettingsRepositoryProvider.notifier)
|
||||
.updateSettings(
|
||||
(current) => current.copyWith(
|
||||
strokeSize: defaultGestureStrokeSize,
|
||||
timeoutMs: defaultGestureTimeoutMs,
|
||||
intervalMs: defaultGestureIntervalMs,
|
||||
minStrokeIntervalMs: defaultGestureStrokeIntervalMs,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StrokeLengthSection extends HookConsumerWidget {
|
||||
const _StrokeLengthSection();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -79,7 +138,7 @@ class _StrokeSensitivitySection extends HookConsumerWidget {
|
||||
|
||||
return ListTile(
|
||||
leading: const Icon(MdiIcons.gestureTap),
|
||||
title: const Text('Stroke sensitivity'),
|
||||
title: const Text('Minimum stroke length'),
|
||||
subtitle: Slider.adaptive(
|
||||
min: minGestureStrokeSize.toDouble(),
|
||||
max: maxGestureStrokeSize.toDouble(),
|
||||
@@ -179,3 +238,50 @@ class _CooldownSection extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StrokeIntervalSection extends HookConsumerWidget {
|
||||
const _StrokeIntervalSection();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(gestureSettingsWithDefaultsProvider);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.gesture),
|
||||
title: const Text('Stroke interval'),
|
||||
subtitle: Slider.adaptive(
|
||||
min: minGestureStrokeIntervalMs.toDouble(),
|
||||
max: maxGestureStrokeIntervalMs.toDouble(),
|
||||
divisions:
|
||||
(maxGestureStrokeIntervalMs - minGestureStrokeIntervalMs) ~/ 25,
|
||||
value: settings.minStrokeIntervalMs
|
||||
.clamp(minGestureStrokeIntervalMs, maxGestureStrokeIntervalMs)
|
||||
.toDouble(),
|
||||
label: settings.minStrokeIntervalMs == 0
|
||||
? 'Off'
|
||||
: '${settings.minStrokeIntervalMs} ms',
|
||||
onChanged: (value) async {
|
||||
await ref
|
||||
.read(gestureSettingsRepositoryProvider.notifier)
|
||||
.updateSettings(
|
||||
(current) =>
|
||||
current.copyWith.minStrokeIntervalMs(value.round()),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(72, 0, 16, 8),
|
||||
child: Text(
|
||||
'Minimum time between direction changes within one gesture. '
|
||||
'Faster changes abort the gesture, guarding against accidental '
|
||||
'triggers.',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -121,7 +121,7 @@ class GestureSettingsScreen extends HookConsumerWidget {
|
||||
leading: const Icon(Icons.tune),
|
||||
title: const Text('Behavior & timing'),
|
||||
subtitle: const Text(
|
||||
'Sensitivity, timeout, cooldown',
|
||||
'Stroke length, timeout, cooldown',
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => open(const GestureBehaviorScreen()),
|
||||
|
||||
+34
-20
@@ -32,7 +32,9 @@ Future<GestureAction?> showGestureActionPicker(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
showDragHandle: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (context) => _GestureActionPicker(selected: selected),
|
||||
);
|
||||
}
|
||||
@@ -52,27 +54,39 @@ class _GestureActionPicker extends StatelessWidget {
|
||||
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,
|
||||
// A draggable sheet (matching the main browser menu) so the whole surface —
|
||||
// not just a small handle — can be swiped down to dismiss.
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.7,
|
||||
minChildSize: 0.4,
|
||||
maxChildSize: 0.95,
|
||||
expand: false,
|
||||
builder: (context, scrollController) {
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
|
||||
child: Text(
|
||||
'Choose action',
|
||||
style: theme.textTheme.titleLarge,
|
||||
// Drag handle.
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 12, bottom: 8),
|
||||
height: 4,
|
||||
width: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
'Choose action',
|
||||
style: theme.textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
children: [
|
||||
for (final category in GestureActionCategory.values)
|
||||
@@ -104,8 +118,8 @@ class _GestureActionPicker extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -243,7 +243,10 @@ class _GestureBindingEditor extends HookWidget {
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: GestureStrokeView(stroke: stroke),
|
||||
: GestureStrokeView(
|
||||
stroke: stroke,
|
||||
showQualifiers: false,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
* 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:weblibre/features/gestures/data/models/gesture_stroke.dart';
|
||||
import 'package:weblibre/presentation/widgets/inline_count_badge.dart';
|
||||
|
||||
extension GestureArrowIcon on GestureArrow {
|
||||
IconData get icon => switch (this) {
|
||||
@@ -30,17 +32,27 @@ extension GestureArrowIcon on GestureArrow {
|
||||
}
|
||||
|
||||
/// Compact visual rendering of a [GestureStroke]: the direction arrows in
|
||||
/// sequence, prefixed by start-position and finger-count qualifiers when set.
|
||||
/// sequence, prefixed by start-position and finger-count qualifiers.
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
/// Set [showQualifiers] to false to render only the direction arrows, omitting
|
||||
/// the start-position and finger qualifiers (e.g. the live pattern preview in
|
||||
/// the binding editor, where those are configured separately).
|
||||
class GestureStrokeView extends StatelessWidget {
|
||||
final GestureStroke stroke;
|
||||
final Color? color;
|
||||
final bool showQualifiers;
|
||||
|
||||
const GestureStrokeView({required this.stroke, this.color, super.key});
|
||||
const GestureStrokeView({
|
||||
required this.stroke,
|
||||
this.color,
|
||||
this.showQualifiers = true,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -50,7 +62,7 @@ class GestureStrokeView extends StatelessWidget {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (stroke.startPosition != GestureStartPosition.anywhere)
|
||||
if (showQualifiers) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 6),
|
||||
child: Icon(
|
||||
@@ -59,14 +71,16 @@ class GestureStrokeView extends StatelessWidget {
|
||||
color: foreground,
|
||||
),
|
||||
),
|
||||
if (stroke.fingers >= 2)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: Text(
|
||||
'${stroke.fingers}×',
|
||||
style: theme.textTheme.labelLarge?.copyWith(color: foreground),
|
||||
padding: const EdgeInsets.only(right: 6),
|
||||
child: InlineCountBadge(
|
||||
count: stroke.fingers,
|
||||
backgroundColor: foreground,
|
||||
foregroundColor: theme.colorScheme.surface,
|
||||
icon: MdiIcons.buttonPointer,
|
||||
),
|
||||
),
|
||||
],
|
||||
for (final arrow in stroke.arrows)
|
||||
Icon(arrow.icon, size: 18, color: foreground),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user