Add swipe gestures to drone profile editor (vertical=big, horizontal=small step)
Ersetzt die einfachen Zahlen-TextFormFields im Drohnenprofil-Editor durch tastaturlose Swipe-Chips (Doku 3.5, HTML-Demonstrator: SwipeValueInput) - vertikales Ziehen aendert den Wert in grossen Schritten (stepBig), horizontales Ziehen in kleinen Schritten (stepSmall). Die Zugrichtung wird beim ersten Ueberschreiten einer 8px-Schwelle einmalig festgelegt und bleibt fuer den Rest der Geste gesperrt, damit eine leicht schraege Geste nicht zwischen den beiden Schrittweiten hin- und herspringt. Min/Max/ Schrittweiten je Feld 1:1 aus DRONE_EDITOR_GROUPS im HTML-Demonstrator uebernommen. Zusaetzlich ein "modified"-Punkt neben dem Label, sobald ein Wert vom Ausgangswert beim Oeffnen des Editors abweicht (HTML: .modified- dot/.chip-modified). lib/ui/widgets/swipe_value_input.dart: neuer, wiederverwendbarer Chip. Gesten-Konflikt mit der scrollbaren Formular-Liste (wichtigster Teil dieser Aenderung): Die Chips sitzen in einer vertikal scrollbaren ListView (elf Felder in Gruppen passen nicht auf einen Bildschirm). Ein GestureDetector.onPan* auf dem Chip verlor dabei auf einem echten Geraet durchgehend gegen die eigene Scroll-Geste der ListView - onPanStart/ onPanUpdate feuerten nie, weder fuer vertikale noch horizontale Zuege (mit Debug-Prints auf dem Pixel_10a-Emulator verifiziert). Behoben durch: - SwipeValueInput nutzt jetzt rohe Pointer-Events (Listener statt GestureDetector.onPan*) - diese werden unabhaengig vom Gesture-Arena- Ausgang immer zugestellt. - Ein neuer onDragActiveChanged-Callback informiert das Elternwidget, solange eine Geste aktiv ist; DroneProfileEditor sperrt darueber die ListView (NeverScrollableScrollPhysics) fuer die Dauer des Ziehens, so dass die Liste waehrenddessen nicht mitscrollt. Getestet: 7 Widget-Tests fuer SwipeValueInput (grosse/kleine Schritte, Richtungswechsel, Klemmen auf min/max, Rundung auf Nachkommastellen, "modified"-Punkt, sowie ein Regressionstest fuer exakt dieses Szenario - Chip innerhalb einer scrollbaren ListView). Alle 117 Tests sowie flutter analyze bestehen. Manuell auf dem Pixel_10a-Emulator verifiziert: vertikaler Zug auf "Min (stall)" aendert den Wert in 10er-Schritten ohne die Liste zu scrollen, horizontaler Zug auf "Cruise" in 1er-Schritten - beide mit korrekt aufleuchtendem "modified"-Indikator. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
c5a8c68a8e
commit
f25c312697
@@ -4,11 +4,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../domain/mission/drone_profile.dart';
|
||||
import '../providers/active_drone_profile_provider.dart';
|
||||
import '../providers/database_provider.dart';
|
||||
import 'swipe_value_input.dart';
|
||||
|
||||
/// Formular zum Anlegen/Bearbeiten eines Drohnenprofils (Doku 3.5/7.3).
|
||||
/// Vereinfacht gegenueber dem HTML-Demonstrator: normale Zahlenfelder statt
|
||||
/// Swipe-Gesten-Chips (SwipeValueInput) - bei elf Feldern in Gruppen ist ein
|
||||
/// Formular auf einem Mobilgeraet die zugaenglichere Eingabemethode.
|
||||
/// Numerische Felder als Swipe-Chips (HTML-Demonstrator: SwipeValueInput) -
|
||||
/// vertikales Ziehen aendert den Wert in grossen Schritten, horizontales
|
||||
/// Ziehen in kleinen Schritten; nur der Name bleibt ein normales Textfeld.
|
||||
class DroneProfileEditor extends ConsumerStatefulWidget {
|
||||
const DroneProfileEditor({super.key, this.existing});
|
||||
|
||||
@@ -22,38 +23,46 @@ class DroneProfileEditor extends ConsumerStatefulWidget {
|
||||
class _DroneProfileEditorState extends ConsumerState<DroneProfileEditor> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late final TextEditingController _nameController;
|
||||
late final Map<String, TextEditingController> _fieldControllers;
|
||||
late final Map<String, double> _initialValues;
|
||||
late Map<String, double> _fieldValues;
|
||||
|
||||
// Waehrend eine Swipe-Geste auf einem Chip aktiv ist, wird das Formular
|
||||
// nicht scrollbar (Doku 3.5) - sonst gewinnt die ListView-eigene
|
||||
// Scroll-Geste das Gesture-Arena gegen den Chip, siehe SwipeValueInput.
|
||||
bool _scrolling = true;
|
||||
|
||||
// Min/Max/Schrittweiten 1:1 aus DRONE_EDITOR_GROUPS im HTML-Demonstrator
|
||||
// uebernommen.
|
||||
static const _groups = [
|
||||
(
|
||||
title: 'Speed (m/s)',
|
||||
fields: [
|
||||
('minSpeed', 'Min (stall)'),
|
||||
('cruiseSpeed', 'Cruise'),
|
||||
('maxSpeed', 'Max'),
|
||||
(key: 'minSpeed', label: 'Min (stall)', min: 3.0, max: 40.0, stepBig: 10.0, stepSmall: 1.0, decimals: 0, unit: 'm/s'),
|
||||
(key: 'cruiseSpeed', label: 'Cruise', min: 3.0, max: 50.0, stepBig: 10.0, stepSmall: 1.0, decimals: 0, unit: 'm/s'),
|
||||
(key: 'maxSpeed', label: 'Max', min: 5.0, max: 80.0, stepBig: 10.0, stepSmall: 1.0, decimals: 0, unit: 'm/s'),
|
||||
],
|
||||
),
|
||||
(
|
||||
title: 'Radii (m)',
|
||||
fields: [
|
||||
('minTurnRadius', 'Min turn'),
|
||||
('loiterRadius', 'Loiter'),
|
||||
('catchRadius', 'Waypoint catch'),
|
||||
(key: 'minTurnRadius', label: 'Min turn', min: 5.0, max: 500.0, stepBig: 10.0, stepSmall: 1.0, decimals: 0, unit: 'm'),
|
||||
(key: 'loiterRadius', label: 'Loiter', min: 20.0, max: 1000.0, stepBig: 10.0, stepSmall: 5.0, decimals: 0, unit: 'm'),
|
||||
(key: 'catchRadius', label: 'Waypoint catch', min: 5.0, max: 500.0, stepBig: 10.0, stepSmall: 1.0, decimals: 0, unit: 'm'),
|
||||
],
|
||||
),
|
||||
(
|
||||
title: 'Climb / Descent (m/s)',
|
||||
fields: [
|
||||
('maxClimbRate', 'Max climb'),
|
||||
('maxDescentRate', 'Max descent'),
|
||||
(key: 'maxClimbRate', label: 'Max climb', min: 0.5, max: 20.0, stepBig: 1.0, stepSmall: 0.5, decimals: 1, unit: 'm/s'),
|
||||
(key: 'maxDescentRate', label: 'Max descent', min: 0.5, max: 20.0, stepBig: 1.0, stepSmall: 0.5, decimals: 1, unit: 'm/s'),
|
||||
],
|
||||
),
|
||||
(
|
||||
title: 'Limits',
|
||||
fields: [
|
||||
('maxRangeM', 'Range (m)'),
|
||||
('maxEnduranceMin', 'Endurance (min)'),
|
||||
('maxAltitudeM', 'Ceiling (m)'),
|
||||
(key: 'maxRangeM', label: 'Range', min: 500.0, max: 200000.0, stepBig: 1000.0, stepSmall: 100.0, decimals: 0, unit: 'm'),
|
||||
(key: 'maxEnduranceMin', label: 'Endurance', min: 5.0, max: 600.0, stepBig: 10.0, stepSmall: 1.0, decimals: 0, unit: 'min'),
|
||||
(key: 'maxAltitudeM', label: 'Ceiling', min: 50.0, max: 10000.0, stepBig: 100.0, stepSmall: 10.0, decimals: 0, unit: 'm'),
|
||||
],
|
||||
),
|
||||
];
|
||||
@@ -80,25 +89,22 @@ class _DroneProfileEditorState extends ConsumerState<DroneProfileEditor> {
|
||||
_nameController = TextEditingController(
|
||||
text: widget.existing != null ? base.name : 'New profile',
|
||||
);
|
||||
_fieldControllers = {
|
||||
_initialValues = {
|
||||
for (final group in _groups)
|
||||
for (final (key, _) in group.fields)
|
||||
key: TextEditingController(text: _fieldValue(base, key).toString()),
|
||||
for (final field in group.fields) field.key: _fieldValue(base, field.key),
|
||||
};
|
||||
_fieldValues = Map.of(_initialValues);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
for (final c in _fieldControllers.values) {
|
||||
c.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
double v(String key) => double.parse(_fieldControllers[key]!.text);
|
||||
double v(String key) => _fieldValues[key]!;
|
||||
|
||||
final profile = DroneProfile(
|
||||
id: widget.existing?.id,
|
||||
@@ -160,6 +166,9 @@ class _DroneProfileEditorState extends ConsumerState<DroneProfileEditor> {
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
physics: _scrolling
|
||||
? const ClampingScrollPhysics()
|
||||
: const NeverScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
TextFormField(
|
||||
@@ -181,20 +190,26 @@ class _DroneProfileEditorState extends ConsumerState<DroneProfileEditor> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final (key, label) in group.fields)
|
||||
for (final field in group.fields)
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: TextFormField(
|
||||
controller: _fieldControllers[key],
|
||||
style: const TextStyle(color: Colors.white),
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
decoration: InputDecoration(labelText: label),
|
||||
validator: (v) =>
|
||||
double.tryParse(v ?? '') == null ? 'Number' : null,
|
||||
child: SwipeValueInput(
|
||||
label: field.label,
|
||||
value: _fieldValues[field.key]!,
|
||||
initialValue: _initialValues[field.key]!,
|
||||
min: field.min,
|
||||
max: field.max,
|
||||
stepBig: field.stepBig,
|
||||
stepSmall: field.stepSmall,
|
||||
decimals: field.decimals,
|
||||
unit: field.unit,
|
||||
onChanged: (v) =>
|
||||
setState(() => _fieldValues[field.key] = v),
|
||||
onDragActiveChanged: (active) =>
|
||||
setState(() => _scrolling = !active),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user