gesture feature initial

This commit is contained in:
Fabian Freund
2026-05-31 11:40:57 +02:00
parent 4aecb8966c
commit 4d4c8a8786
45 changed files with 4729 additions and 5 deletions
@@ -0,0 +1,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:flutter_material_design_icons/flutter_material_design_icons.dart';
/// Actions that can be bound to a touch gesture.
///
/// Each value carries a human-readable [title]/[description] for the settings
/// UI and an [icon] mirroring the action's representation elsewhere in the app
/// (contextual toolbar, browser menu sheet). The dispatcher resolves each value
/// against the currently selected tab.
enum GestureAction {
// Navigation
back(
'Back',
'Go back in history',
Icons.arrow_back,
GestureActionCategory.navigation,
),
forward(
'Forward',
'Go forward in history',
Icons.arrow_forward,
GestureActionCategory.navigation,
),
reload(
'Reload',
'Reload the current page',
Icons.refresh,
GestureActionCategory.navigation,
),
// Scrolling
scrollTop(
'Scroll to Top',
'Jump to the top of the page',
Icons.vertical_align_top,
GestureActionCategory.scrolling,
),
scrollBottom(
'Scroll to Bottom',
'Jump to the bottom of the page',
Icons.vertical_align_bottom,
GestureActionCategory.scrolling,
),
pageUp(
'Page Up',
'Scroll up by one screen',
MdiIcons.chevronDoubleUp,
GestureActionCategory.scrolling,
),
pageDown(
'Page Down',
'Scroll down by one screen',
MdiIcons.chevronDoubleDown,
GestureActionCategory.scrolling,
),
// Tabs
newTab(
'New Tab',
'Open a new tab',
MdiIcons.tabPlus,
GestureActionCategory.tabs,
),
closeTab(
'Close Tab',
'Close the current tab',
MdiIcons.tabMinus,
GestureActionCategory.tabs,
),
duplicateTab(
'Duplicate Tab',
'Open a copy of the current tab',
MdiIcons.contentDuplicate,
GestureActionCategory.tabs,
),
nextTab(
'Next Tab',
'Switch to the next tab',
Icons.skip_next,
GestureActionCategory.tabs,
),
previousTab(
'Previous Tab',
'Switch to the previous tab',
Icons.skip_previous,
GestureActionCategory.tabs,
),
lastUsedTab(
'Last Used Tab',
'Switch to the previously used tab',
Icons.swap_horiz,
GestureActionCategory.tabs,
),
togglePinTab(
'Pin / Unpin Tab',
'Toggle the pinned state of the current tab',
MdiIcons.pin,
GestureActionCategory.tabs,
),
// Page tools
toggleReaderMode(
'Reader Mode',
'Toggle reader mode for the current page',
MdiIcons.bookOpenOutline,
GestureActionCategory.page,
),
toggleDesktopMode(
'Desktop Site',
'Toggle desktop site for the current page',
Icons.desktop_windows,
GestureActionCategory.page,
),
findInPage(
'Find in Page',
'Open find in page',
Icons.find_in_page,
GestureActionCategory.page,
),
increaseFontSize(
'Increase Font',
'Increase the page font size',
MdiIcons.formatFontSizeIncrease,
GestureActionCategory.page,
),
decreaseFontSize(
'Decrease Font',
'Decrease the page font size',
MdiIcons.formatFontSizeDecrease,
GestureActionCategory.page,
),
toggleBookmark(
'Bookmark',
'Bookmark or unbookmark the current page',
Icons.bookmark_border,
GestureActionCategory.page,
),
translatePage(
'Translate',
'Open the page translation sheet',
Icons.translate,
GestureActionCategory.page,
),
// Open
showHistory(
'History',
'Open browsing history',
Icons.history,
GestureActionCategory.open,
),
showBookmarks(
'Bookmarks',
'Open bookmarks',
MdiIcons.bookmarkMultiple,
GestureActionCategory.open,
),
// App
moveToBackground(
'Minimize',
'Send WebLibre to the background',
MdiIcons.arrowCollapseDown,
GestureActionCategory.app,
),
quitBrowser(
'Quit',
'Close all tabs and quit WebLibre',
MdiIcons.power,
GestureActionCategory.app,
);
final String title;
final String description;
final IconData icon;
/// Grouping used to organise actions in the bindings list and picker.
final GestureActionCategory category;
const GestureAction(this.title, this.description, this.icon, this.category);
}
/// High-level grouping of [GestureAction]s for the settings UI.
enum GestureActionCategory {
navigation('Navigation'),
scrolling('Scrolling'),
tabs('Tabs'),
page('Page'),
open('Open'),
app('App');
final String label;
const GestureActionCategory(this.label);
}
@@ -0,0 +1,196 @@
/*
* 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:copy_with_extension/copy_with_extension.dart';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:weblibre/features/gestures/data/models/gesture_action.dart';
import 'package:weblibre/utils/uri_input_parser.dart';
part 'gesture_settings.g.dart';
const defaultGestureStrokeSize = 50;
const minGestureStrokeSize = 20;
const maxGestureStrokeSize = 100;
const defaultGestureTimeoutMs = 1500;
const minGestureTimeoutMs = 500;
const maxGestureTimeoutMs = 3000;
const defaultGestureMaxFingers = 1;
const defaultGestureIntervalMs = 0;
const minGestureIntervalMs = 0;
const maxGestureIntervalMs = 2000;
/// Minimum number of strokes drawn before the live overlay starts suggesting
/// the other possible completions (mirrors the reference add-on's
/// `toastMinStroke`).
const defaultGestureMinSuggestionStroke = 2;
const minGestureMinSuggestionStroke = 1;
const maxGestureMinSuggestionStroke = 5;
/// Default gesture-to-action bindings, aligned with the reference add-on's
/// defaults for the actions WebLibre currently supports.
const defaultGestureBindings = <String, GestureAction>{
'D-L': GestureAction.forward,
'D-R': GestureAction.back,
'R-D': GestureAction.scrollTop,
'R-U': GestureAction.scrollBottom,
'D-R-U': GestureAction.reload,
'L-D-R': GestureAction.closeTab,
};
@CopyWith()
@JsonSerializable(includeIfNull: true, constructor: 'withDefaults')
class GestureSettings with FastEquatable {
/// Master switch. When false, gesture recognition is fully disabled and the
/// quick toggles ([active]) have no effect.
final bool enabled;
/// Runtime toggle exposed via the quick toggles (menu sheet tile, contextual
/// toolbar button). Lets the user suspend gestures without touching the
/// master switch. The recognizer runs only when [enabled] && [active].
final bool active;
/// Base stroke length in logical pixels (scaled to the screen by native).
final int strokeSize;
/// Milliseconds of inactivity after which an in-progress gesture is dropped.
final int timeoutMs;
/// Maximum simultaneous pointers a gesture may use.
final int maxFingers;
/// Cooldown in milliseconds after a gesture fires, during which further
/// gestures are ignored. 0 disables the cooldown.
final int intervalMs;
/// 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;
/// Within the live overlay, also suggest the other possible completions once
/// at least [minSuggestionStroke] strokes have been drawn.
final bool suggestNext;
/// Minimum strokes drawn before [suggestNext] kicks in.
final int minSuggestionStroke;
/// Hosts on which gestures are disabled. A page is excluded when its host
/// equals or is a subdomain of any entry (see `isGestureSiteExcluded`).
final List<String> excludedSites;
/// Canonical gesture key → action. Keys follow the grammar documented on
/// [GestureStroke].
final Map<String, GestureAction> bindings;
GestureSettings({
required this.enabled,
required this.active,
required this.strokeSize,
required this.timeoutMs,
required this.maxFingers,
required this.intervalMs,
required this.showFeedback,
required this.suggestNext,
required this.minSuggestionStroke,
required this.excludedSites,
required this.bindings,
});
GestureSettings.withDefaults({
bool? enabled,
bool? active,
int? strokeSize,
int? timeoutMs,
int? maxFingers,
int? intervalMs,
bool? showFeedback,
bool? suggestNext,
int? minSuggestionStroke,
List<String>? excludedSites,
Map<String, GestureAction>? bindings,
}) : enabled = enabled ?? false,
active = active ?? true,
strokeSize = strokeSize ?? defaultGestureStrokeSize,
timeoutMs = timeoutMs ?? defaultGestureTimeoutMs,
maxFingers = maxFingers ?? defaultGestureMaxFingers,
intervalMs = intervalMs ?? defaultGestureIntervalMs,
showFeedback = showFeedback ?? true,
suggestNext = suggestNext ?? true,
minSuggestionStroke =
minSuggestionStroke ?? defaultGestureMinSuggestionStroke,
excludedSites = excludedSites ?? const [],
bindings = bindings ?? defaultGestureBindings;
/// Whether the recognizer should actually run.
bool get effectiveEnabled => enabled && active;
factory GestureSettings.fromJson(Map<String, dynamic> json) =>
_$GestureSettingsFromJson(json);
Map<String, dynamic> toJson() => _$GestureSettingsToJson(this);
@override
List<Object?> get hashParameters => [
enabled,
active,
strokeSize,
timeoutMs,
maxFingers,
intervalMs,
showFeedback,
suggestNext,
minSuggestionStroke,
excludedSites,
bindings,
];
}
/// Normalises a user-entered site into a bare lowercase host, e.g.
/// `https://News.example.com/foo` → `news.example.com`. Accepts either a full
/// URL or a bare host, and validates the result with the same rules the address
/// bar uses ([isValidHostCandidate]). Returns null for input without a valid
/// host.
String? normalizeGestureSiteHost(String input) {
final trimmed = input.trim().toLowerCase();
if (trimmed.isEmpty) return null;
final candidate = trimmed.contains('://') ? trimmed : 'https://$trimmed';
final host = Uri.tryParse(candidate)?.host;
if (host == null || host.isEmpty) return null;
return isValidHostCandidate(host) ? host : null;
}
/// Whether [url] is covered by any entry in [excludedSites]. An entry matches
/// the URL's host exactly or as a parent domain (so `example.com` also covers
/// `m.example.com`).
bool isGestureSiteExcluded(Uri url, List<String> excludedSites) {
final host = url.host.toLowerCase();
if (host.isEmpty) return false;
for (final entry in excludedSites) {
final pattern = entry.toLowerCase();
if (pattern.isEmpty) continue;
if (host == pattern || host.endsWith('.$pattern')) return true;
}
return false;
}
@@ -0,0 +1,250 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'gesture_settings.dart';
// **************************************************************************
// CopyWithGenerator
// **************************************************************************
abstract class _$GestureSettingsCWProxy {
GestureSettings enabled(bool enabled);
GestureSettings active(bool active);
GestureSettings strokeSize(int strokeSize);
GestureSettings timeoutMs(int timeoutMs);
GestureSettings maxFingers(int maxFingers);
GestureSettings intervalMs(int intervalMs);
GestureSettings showFeedback(bool showFeedback);
GestureSettings suggestNext(bool suggestNext);
GestureSettings minSuggestionStroke(int minSuggestionStroke);
GestureSettings excludedSites(List<String> excludedSites);
GestureSettings bindings(Map<String, GestureAction> bindings);
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GestureSettings(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// GestureSettings(...).copyWith(id: 12, name: "My name")
/// ```
GestureSettings call({
bool enabled,
bool active,
int strokeSize,
int timeoutMs,
int maxFingers,
int intervalMs,
bool showFeedback,
bool suggestNext,
int minSuggestionStroke,
List<String> excludedSites,
Map<String, GestureAction> bindings,
});
}
/// Callable proxy for `copyWith` functionality.
/// Use as `instanceOfGestureSettings.copyWith(...)` or call `instanceOfGestureSettings.copyWith.fieldName(value)` for a single field.
class _$GestureSettingsCWProxyImpl implements _$GestureSettingsCWProxy {
const _$GestureSettingsCWProxyImpl(this._value);
final GestureSettings _value;
@override
GestureSettings enabled(bool enabled) => call(enabled: enabled);
@override
GestureSettings active(bool active) => call(active: active);
@override
GestureSettings strokeSize(int strokeSize) => call(strokeSize: strokeSize);
@override
GestureSettings timeoutMs(int timeoutMs) => call(timeoutMs: timeoutMs);
@override
GestureSettings maxFingers(int maxFingers) => call(maxFingers: maxFingers);
@override
GestureSettings intervalMs(int intervalMs) => call(intervalMs: intervalMs);
@override
GestureSettings showFeedback(bool showFeedback) =>
call(showFeedback: showFeedback);
@override
GestureSettings suggestNext(bool suggestNext) =>
call(suggestNext: suggestNext);
@override
GestureSettings minSuggestionStroke(int minSuggestionStroke) =>
call(minSuggestionStroke: minSuggestionStroke);
@override
GestureSettings excludedSites(List<String> excludedSites) =>
call(excludedSites: excludedSites);
@override
GestureSettings bindings(Map<String, GestureAction> bindings) =>
call(bindings: bindings);
@override
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GestureSettings(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// GestureSettings(...).copyWith(id: 12, name: "My name")
/// ```
GestureSettings call({
Object? enabled = const $CopyWithPlaceholder(),
Object? active = const $CopyWithPlaceholder(),
Object? strokeSize = const $CopyWithPlaceholder(),
Object? timeoutMs = const $CopyWithPlaceholder(),
Object? maxFingers = const $CopyWithPlaceholder(),
Object? intervalMs = const $CopyWithPlaceholder(),
Object? showFeedback = const $CopyWithPlaceholder(),
Object? suggestNext = const $CopyWithPlaceholder(),
Object? minSuggestionStroke = const $CopyWithPlaceholder(),
Object? excludedSites = const $CopyWithPlaceholder(),
Object? bindings = const $CopyWithPlaceholder(),
}) {
return GestureSettings(
enabled: enabled == const $CopyWithPlaceholder() || enabled == null
? _value.enabled
// ignore: cast_nullable_to_non_nullable
: enabled as bool,
active: active == const $CopyWithPlaceholder() || active == null
? _value.active
// ignore: cast_nullable_to_non_nullable
: active as bool,
strokeSize:
strokeSize == const $CopyWithPlaceholder() || strokeSize == null
? _value.strokeSize
// ignore: cast_nullable_to_non_nullable
: strokeSize as int,
timeoutMs: timeoutMs == const $CopyWithPlaceholder() || timeoutMs == null
? _value.timeoutMs
// ignore: cast_nullable_to_non_nullable
: timeoutMs as int,
maxFingers:
maxFingers == const $CopyWithPlaceholder() || maxFingers == null
? _value.maxFingers
// ignore: cast_nullable_to_non_nullable
: maxFingers as int,
intervalMs:
intervalMs == const $CopyWithPlaceholder() || intervalMs == null
? _value.intervalMs
// ignore: cast_nullable_to_non_nullable
: intervalMs as int,
showFeedback:
showFeedback == const $CopyWithPlaceholder() || showFeedback == null
? _value.showFeedback
// ignore: cast_nullable_to_non_nullable
: showFeedback as bool,
suggestNext:
suggestNext == const $CopyWithPlaceholder() || suggestNext == null
? _value.suggestNext
// ignore: cast_nullable_to_non_nullable
: suggestNext as bool,
minSuggestionStroke:
minSuggestionStroke == const $CopyWithPlaceholder() ||
minSuggestionStroke == null
? _value.minSuggestionStroke
// ignore: cast_nullable_to_non_nullable
: minSuggestionStroke as int,
excludedSites:
excludedSites == const $CopyWithPlaceholder() || excludedSites == null
? _value.excludedSites
// ignore: cast_nullable_to_non_nullable
: excludedSites as List<String>,
bindings: bindings == const $CopyWithPlaceholder() || bindings == null
? _value.bindings
// ignore: cast_nullable_to_non_nullable
: bindings as Map<String, GestureAction>,
);
}
}
extension $GestureSettingsCopyWith on GestureSettings {
/// Returns a callable class used to build a new instance with modified fields.
/// Example: `instanceOfGestureSettings.copyWith(...)` or `instanceOfGestureSettings.copyWith.fieldName(...)`.
// ignore: library_private_types_in_public_api
_$GestureSettingsCWProxy get copyWith => _$GestureSettingsCWProxyImpl(this);
}
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
GestureSettings _$GestureSettingsFromJson(Map<String, dynamic> json) =>
GestureSettings.withDefaults(
enabled: json['enabled'] as bool?,
active: json['active'] as bool?,
strokeSize: (json['strokeSize'] as num?)?.toInt(),
timeoutMs: (json['timeoutMs'] as num?)?.toInt(),
maxFingers: (json['maxFingers'] as num?)?.toInt(),
intervalMs: (json['intervalMs'] as num?)?.toInt(),
showFeedback: json['showFeedback'] as bool?,
suggestNext: json['suggestNext'] as bool?,
minSuggestionStroke: (json['minSuggestionStroke'] as num?)?.toInt(),
excludedSites: (json['excludedSites'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
bindings: (json['bindings'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, $enumDecode(_$GestureActionEnumMap, e)),
),
);
Map<String, dynamic> _$GestureSettingsToJson(GestureSettings instance) =>
<String, dynamic>{
'enabled': instance.enabled,
'active': instance.active,
'strokeSize': instance.strokeSize,
'timeoutMs': instance.timeoutMs,
'maxFingers': instance.maxFingers,
'intervalMs': instance.intervalMs,
'showFeedback': instance.showFeedback,
'suggestNext': instance.suggestNext,
'minSuggestionStroke': instance.minSuggestionStroke,
'excludedSites': instance.excludedSites,
'bindings': instance.bindings.map(
(k, e) => MapEntry(k, _$GestureActionEnumMap[e]!),
),
};
const _$GestureActionEnumMap = {
GestureAction.back: 'back',
GestureAction.forward: 'forward',
GestureAction.reload: 'reload',
GestureAction.scrollTop: 'scrollTop',
GestureAction.scrollBottom: 'scrollBottom',
GestureAction.pageUp: 'pageUp',
GestureAction.pageDown: 'pageDown',
GestureAction.newTab: 'newTab',
GestureAction.closeTab: 'closeTab',
GestureAction.duplicateTab: 'duplicateTab',
GestureAction.nextTab: 'nextTab',
GestureAction.previousTab: 'previousTab',
GestureAction.lastUsedTab: 'lastUsedTab',
GestureAction.togglePinTab: 'togglePinTab',
GestureAction.toggleReaderMode: 'toggleReaderMode',
GestureAction.toggleDesktopMode: 'toggleDesktopMode',
GestureAction.findInPage: 'findInPage',
GestureAction.increaseFontSize: 'increaseFontSize',
GestureAction.decreaseFontSize: 'decreaseFontSize',
GestureAction.toggleBookmark: 'toggleBookmark',
GestureAction.translatePage: 'translatePage',
GestureAction.showHistory: 'showHistory',
GestureAction.showBookmarks: 'showBookmarks',
GestureAction.moveToBackground: 'moveToBackground',
GestureAction.quitBrowser: 'quitBrowser',
};
@@ -0,0 +1,144 @@
/*
* 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:copy_with_extension/copy_with_extension.dart';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
part 'gesture_stroke.g.dart';
/// Where a gesture must begin. Mirrors the reference add-on's start-position
/// tokens (the trailing colon is part of the canonical key prefix).
enum GestureStartPosition {
anywhere('', 'Anywhere', MdiIcons.borderNone),
leftEdge('L:', 'Left edge', MdiIcons.borderLeft),
rightEdge('R:', 'Right edge', MdiIcons.borderRight),
topEdge('T:', 'Top edge', MdiIcons.borderTop),
bottomEdge('B:', 'Bottom edge', MdiIcons.borderBottom),
leftHalf('W:', 'Left half', MdiIcons.borderLeftVariant),
rightHalf('E:', 'Right half', MdiIcons.borderRightVariant);
/// Canonical key prefix, e.g. `R:` (empty for [anywhere]).
final String prefix;
final String label;
final IconData icon;
const GestureStartPosition(this.prefix, this.label, this.icon);
static GestureStartPosition fromPrefixLetter(String letter) {
return GestureStartPosition.values.firstWhere(
(position) => position.prefix == '$letter:',
orElse: () => GestureStartPosition.anywhere,
);
}
}
/// A single dominant swipe direction within a gesture.
enum GestureArrow {
up('U', ''),
down('D', ''),
left('L', ''),
right('R', '');
/// Canonical key token, e.g. `D`.
final String token;
/// Compact glyph for rendering a stroke sequence.
final String symbol;
const GestureArrow(this.token, this.symbol);
static GestureArrow fromToken(String token) {
return GestureArrow.values.firstWhere((arrow) => arrow.token == token);
}
}
/// A configurable gesture: an ordered sequence of swipe directions, optionally
/// constrained by where the touch begins and how many fingers are used.
///
/// The canonical [key] is the on-the-wire identifier shared with the native
/// recognizer: `<start-prefix><finger-prefix><arrows joined by '-'>`, e.g.
/// `R:2:D-L`. The finger prefix is omitted for a single finger and the start
/// prefix is omitted for [GestureStartPosition.anywhere].
@CopyWith()
class GestureStroke with FastEquatable {
final GestureStartPosition startPosition;
final int fingers;
final List<GestureArrow> arrows;
GestureStroke({
this.startPosition = GestureStartPosition.anywhere,
this.fingers = 1,
this.arrows = const [],
});
/// The canonical gesture key (see class docs).
String get key {
final fingerPrefix = fingers >= 2 ? '$fingers:' : '';
final arrowPart = arrows.map((arrow) => arrow.token).join('-');
return '${startPosition.prefix}$fingerPrefix$arrowPart';
}
/// Parses a canonical [key] back into a stroke.
///
/// The arrow sequence is always the final colon-separated segment; preceding
/// segments are either a single start-position letter or a finger count.
factory GestureStroke.fromKey(String key) {
final segments = key.split(':');
final arrowPart = segments.removeLast();
var startPosition = GestureStartPosition.anywhere;
var fingers = 1;
for (final segment in segments) {
final asFingers = int.tryParse(segment);
if (asFingers != null) {
fingers = asFingers;
} else if (segment.isNotEmpty) {
startPosition = GestureStartPosition.fromPrefixLetter(segment);
}
}
final arrows = arrowPart.isEmpty
? <GestureArrow>[]
: arrowPart.split('-').map(GestureArrow.fromToken).toList();
return GestureStroke(
startPosition: startPosition,
fingers: fingers,
arrows: arrows,
);
}
/// Whether this stroke is complete enough to be bound to an action.
bool get isValid => arrows.isNotEmpty;
/// Human-readable rendering, e.g. `Right edge · ✌ · ↓→`.
String get displayLabel {
final parts = <String>[
if (startPosition != GestureStartPosition.anywhere) startPosition.label,
if (fingers >= 2) '$fingers fingers',
arrows.map((arrow) => arrow.symbol).join(),
];
return parts.join(' · ');
}
@override
List<Object?> get hashParameters => [startPosition, fingers, arrows];
}
@@ -0,0 +1,83 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'gesture_stroke.dart';
// **************************************************************************
// CopyWithGenerator
// **************************************************************************
abstract class _$GestureStrokeCWProxy {
GestureStroke startPosition(GestureStartPosition startPosition);
GestureStroke fingers(int fingers);
GestureStroke arrows(List<GestureArrow> arrows);
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GestureStroke(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// GestureStroke(...).copyWith(id: 12, name: "My name")
/// ```
GestureStroke call({
GestureStartPosition startPosition,
int fingers,
List<GestureArrow> arrows,
});
}
/// Callable proxy for `copyWith` functionality.
/// Use as `instanceOfGestureStroke.copyWith(...)` or call `instanceOfGestureStroke.copyWith.fieldName(value)` for a single field.
class _$GestureStrokeCWProxyImpl implements _$GestureStrokeCWProxy {
const _$GestureStrokeCWProxyImpl(this._value);
final GestureStroke _value;
@override
GestureStroke startPosition(GestureStartPosition startPosition) =>
call(startPosition: startPosition);
@override
GestureStroke fingers(int fingers) => call(fingers: fingers);
@override
GestureStroke arrows(List<GestureArrow> arrows) => call(arrows: arrows);
@override
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GestureStroke(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// GestureStroke(...).copyWith(id: 12, name: "My name")
/// ```
GestureStroke call({
Object? startPosition = const $CopyWithPlaceholder(),
Object? fingers = const $CopyWithPlaceholder(),
Object? arrows = const $CopyWithPlaceholder(),
}) {
return GestureStroke(
startPosition:
startPosition == const $CopyWithPlaceholder() || startPosition == null
? _value.startPosition
// ignore: cast_nullable_to_non_nullable
: startPosition as GestureStartPosition,
fingers: fingers == const $CopyWithPlaceholder() || fingers == null
? _value.fingers
// ignore: cast_nullable_to_non_nullable
: fingers as int,
arrows: arrows == const $CopyWithPlaceholder() || arrows == null
? _value.arrows
// ignore: cast_nullable_to_non_nullable
: arrows as List<GestureArrow>,
);
}
}
extension $GestureStrokeCopyWith on GestureStroke {
/// Returns a callable class used to build a new instance with modified fields.
/// Example: `instanceOfGestureStroke.copyWith(...)` or `instanceOfGestureStroke.copyWith.fieldName(...)`.
// ignore: library_private_types_in_public_api
_$GestureStrokeCWProxy get copyWith => _$GestureStrokeCWProxyImpl(this);
}