Files
dmc/app/lib/ui/widgets/drone_profile_editor.dart
T
Constantin LeueandClaude Sonnet 5 f25c312697 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>
2026-07-29 21:11:51 +02:00

234 lines
9.1 KiB
Dart

import 'package:flutter/material.dart';
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).
/// 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});
/// Null fuer ein neues Profil (vorbefuellt mit T1-Ranger-Standardwerten).
final DroneProfile? existing;
@override
ConsumerState<DroneProfileEditor> createState() => _DroneProfileEditorState();
}
class _DroneProfileEditorState extends ConsumerState<DroneProfileEditor> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nameController;
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: [
(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: [
(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: [
(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: [
(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'),
],
),
];
double _fieldValue(DroneProfile p, String key) => switch (key) {
'minSpeed' => p.minSpeed,
'cruiseSpeed' => p.cruiseSpeed,
'maxSpeed' => p.maxSpeed,
'minTurnRadius' => p.minTurnRadius,
'loiterRadius' => p.loiterRadius,
'catchRadius' => p.catchRadius,
'maxClimbRate' => p.maxClimbRate,
'maxDescentRate' => p.maxDescentRate,
'maxRangeM' => p.maxRangeM,
'maxEnduranceMin' => p.maxEnduranceMin,
'maxAltitudeM' => p.maxAltitudeM,
_ => throw ArgumentError(key),
};
@override
void initState() {
super.initState();
final base = widget.existing ?? DroneProfile.t1Ranger();
_nameController = TextEditingController(
text: widget.existing != null ? base.name : 'New profile',
);
_initialValues = {
for (final group in _groups)
for (final field in group.fields) field.key: _fieldValue(base, field.key),
};
_fieldValues = Map.of(_initialValues);
}
@override
void dispose() {
_nameController.dispose();
super.dispose();
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
double v(String key) => _fieldValues[key]!;
final profile = DroneProfile(
id: widget.existing?.id,
name: _nameController.text.trim(),
minSpeed: v('minSpeed'),
cruiseSpeed: v('cruiseSpeed'),
maxSpeed: v('maxSpeed'),
minTurnRadius: v('minTurnRadius'),
loiterRadius: v('loiterRadius'),
catchRadius: v('catchRadius'),
maxClimbRate: v('maxClimbRate'),
maxDescentRate: v('maxDescentRate'),
maxRangeM: v('maxRangeM'),
maxEnduranceMin: v('maxEnduranceMin'),
maxAltitudeM: v('maxAltitudeM'),
);
final repo = ref.read(droneProfileRepositoryProvider);
if (profile.id == null) {
final id = await repo.insert(profile);
await ref.read(activeDroneProfileProvider.notifier).select(profile.copyWith(id: id));
} else {
await repo.update(profile);
await ref.read(activeDroneProfileProvider.notifier).reloadIfActive(profile.id!);
}
if (mounted) Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
return Material(
color: const Color(0xEB080808),
child: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 12),
child: Row(
children: [
Expanded(
child: Text(
widget.existing == null ? 'New drone profile' : 'Edit drone profile',
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.w700,
),
),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close, color: Colors.white),
),
],
),
),
const Divider(color: Colors.white24, height: 1),
Expanded(
child: Form(
key: _formKey,
child: ListView(
physics: _scrolling
? const ClampingScrollPhysics()
: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
children: [
TextFormField(
controller: _nameController,
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(labelText: 'Name'),
validator: (v) =>
(v == null || v.trim().isEmpty) ? 'Required' : null,
),
for (final group in _groups) ...[
const SizedBox(height: 20),
Text(
group.title,
style: const TextStyle(
color: Colors.white70,
fontWeight: FontWeight.w700,
fontSize: 13,
),
),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final field in group.fields)
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 8),
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),
),
),
),
],
),
],
const SizedBox(height: 24),
FilledButton(
onPressed: _save,
child: const Text('Save'),
),
],
),
),
),
],
),
),
);
}
}