diff --git a/app/lib/ui/widgets/drone_profile_editor.dart b/app/lib/ui/widgets/drone_profile_editor.dart index ea49a0f..8ffd2e6 100644 --- a/app/lib/ui/widgets/drone_profile_editor.dart +++ b/app/lib/ui/widgets/drone_profile_editor.dart @@ -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 { final _formKey = GlobalKey(); late final TextEditingController _nameController; - late final Map _fieldControllers; + late final Map _initialValues; + late Map _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 { _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 _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 { 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 { ), 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), ), ), ), diff --git a/app/lib/ui/widgets/swipe_value_input.dart b/app/lib/ui/widgets/swipe_value_input.dart new file mode 100644 index 0000000..1de2bf3 --- /dev/null +++ b/app/lib/ui/widgets/swipe_value_input.dart @@ -0,0 +1,204 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +enum _DragAxis { vertical, horizontal } + +/// Tastaturloser Zahlen-Eingabe-Chip mit richtungsabhaengiger Schrittweite +/// (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 von [_axisLockThreshold] einmalig festgelegt und bleibt +/// fuer den Rest der Geste gesperrt - eine leicht schraege Geste springt +/// so nicht zwischen grossem und kleinem Schritt hin und her. +class SwipeValueInput extends StatefulWidget { + const SwipeValueInput({ + super.key, + required this.label, + required this.value, + required this.initialValue, + required this.min, + required this.max, + required this.stepBig, + required this.stepSmall, + this.decimals = 0, + required this.unit, + required this.onChanged, + this.onDragActiveChanged, + }); + + final String label; + final double value; + + /// Ausgangswert beim Oeffnen des Editors - weicht [value] davon ab, + /// zeigt der Chip einen kleinen Punkt neben dem Label (HTML- + /// Demonstrator: .modified-dot), analog dem gelben "chip-modified"-Rand. + final double initialValue; + final double min; + final double max; + final double stepBig; + final double stepSmall; + final int decimals; + final String unit; + final ValueChanged onChanged; + + /// Wird true, solange eine Geste auf diesem Chip aktiv ist - der + /// Elternwidget nutzt dies, um eine umgebende scrollbare Liste waehrend + /// des Ziehens zu sperren (sonst gewinnt deren eigene Scroll-Geste das + /// Gesture-Arena gegen den Chip, siehe DroneProfileEditor). + final ValueChanged? onDragActiveChanged; + + static const _pxPerStep = 14.0; + static const _axisLockThreshold = 8.0; + + @override + State createState() => _SwipeValueInputState(); +} + +class _SwipeValueInputState extends State { + Offset? _dragStart; + double _dragStartValue = 0; + _DragAxis? _axis; + int? _activePointer; + bool _flash = false; + Timer? _flashTimer; + + @override + void dispose() { + _flashTimer?.cancel(); + super.dispose(); + } + + // Rohe Pointer-Events statt GestureDetector.onPan* (Doku 3.5): der Chip + // sitzt in einer vertikal scrollbaren Liste (Formular mit vielen + // Feldern), deren eigene Scroll-Geste im Gesture-Arena sonst gegen ein + // GestureDetector-Pan gewinnt - Listener-Events werden unabhaengig davon + // immer zugestellt, dafuer sperrt onDragActiveChanged() die Liste + // explizit waehrend des Ziehens (siehe DroneProfileEditor). + void _onPointerDown(PointerDownEvent event) { + _activePointer = event.pointer; + _dragStart = event.position; + _dragStartValue = widget.value; + _axis = null; + widget.onDragActiveChanged?.call(true); + } + + void _onPointerMove(PointerMoveEvent event) { + if (event.pointer != _activePointer) return; + final start = _dragStart; + if (start == null) return; + final dx = event.position.dx - start.dx; + final dy = event.position.dy - start.dy; + + if (_axis == null) { + if (dx.abs() < SwipeValueInput._axisLockThreshold && + dy.abs() < SwipeValueInput._axisLockThreshold) { + return; + } + _axis = dy.abs() >= dx.abs() ? _DragAxis.vertical : _DragAxis.horizontal; + } + + final stepSize = _axis == _DragAxis.vertical ? widget.stepBig : widget.stepSmall; + final travel = _axis == _DragAxis.vertical ? -dy : dx; + final steps = (travel / SwipeValueInput._pxPerStep).round(); + var val = _dragStartValue + steps * stepSize; + val = double.parse(val.toStringAsFixed(widget.decimals)); + val = val.clamp(widget.min, widget.max); + if (val != widget.value) { + widget.onChanged(val); + _flashTimer?.cancel(); + setState(() => _flash = true); + _flashTimer = Timer(const Duration(milliseconds: 120), () { + if (mounted) setState(() => _flash = false); + }); + } + } + + void _onPointerUp(PointerEvent event) { + if (event.pointer != _activePointer) return; + _endDrag(); + } + + void _endDrag() { + _dragStart = null; + _axis = null; + _activePointer = null; + widget.onDragActiveChanged?.call(false); + } + + @override + Widget build(BuildContext context) { + final modified = widget.value != widget.initialValue; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(widget.label, style: const TextStyle(color: Colors.white70, fontSize: 12)), + const SizedBox(width: 6), + AnimatedOpacity( + opacity: modified ? 1 : 0, + duration: const Duration(milliseconds: 250), + child: Container( + width: 6, + height: 6, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Color(0xFFF0C86E), + ), + ), + ), + ], + ), + const SizedBox(height: 6), + Listener( + onPointerDown: _onPointerDown, + onPointerMove: _onPointerMove, + onPointerUp: _onPointerUp, + onPointerCancel: _onPointerUp, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), + alignment: Alignment.center, + decoration: BoxDecoration( + color: _flash + ? const Color(0xFF4D9DFF).withValues(alpha: 0.4) + : modified + ? const Color(0xFFC8A04D).withValues(alpha: 0.16) + : Colors.black.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: _flash + ? const Color(0xFF8FD0FF) + : modified + ? const Color(0xFFF0C86E).withValues(alpha: 0.85) + : const Color(0xFF4D9DFF).withValues(alpha: 0.7), + width: 1.5, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '↕↔', + style: TextStyle(color: Colors.white.withValues(alpha: 0.55), fontSize: 10), + ), + const SizedBox(width: 5), + Text( + '${widget.value.toStringAsFixed(widget.decimals)} ${widget.unit}', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 15, + ), + ), + ], + ), + ), + ), + ], + ); + } +} diff --git a/app/test/widgets/swipe_value_input_test.dart b/app/test/widgets/swipe_value_input_test.dart new file mode 100644 index 0000000..7d28724 --- /dev/null +++ b/app/test/widgets/swipe_value_input_test.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:dmc_app/ui/widgets/swipe_value_input.dart'; + +void main() { + Widget wrap({ + required double value, + required double initialValue, + required void Function(double) onChanged, + double min = 0, + double max = 200, + double stepBig = 10, + double stepSmall = 1, + int decimals = 0, + }) { + return MaterialApp( + home: Scaffold( + body: Center( + child: SwipeValueInput( + label: 'Test', + value: value, + initialValue: initialValue, + min: min, + max: max, + stepBig: stepBig, + stepSmall: stepSmall, + decimals: decimals, + unit: 'm', + onChanged: onChanged, + ), + ), + ), + ); + } + + testWidgets('vertikales Ziehen aendert den Wert in grossen Schritten (stepBig)', + (tester) async { + final changes = []; + await tester.pumpWidget(wrap(value: 50, initialValue: 50, onChanged: changes.add)); + + // 3 Schritte a 14px nach oben -> +3*stepBig(10) = +30. + await tester.drag(find.byType(SwipeValueInput), const Offset(0, -42)); + await tester.pump(); + + expect(changes, isNotEmpty); + expect(changes.last, 80); + }); + + testWidgets('horizontales Ziehen aendert den Wert in kleinen Schritten (stepSmall)', + (tester) async { + final changes = []; + await tester.pumpWidget(wrap(value: 50, initialValue: 50, onChanged: changes.add)); + + // 3 Schritte a 14px nach rechts -> +3*stepSmall(1) = +3. + await tester.drag(find.byType(SwipeValueInput), const Offset(42, 0)); + await tester.pump(); + + expect(changes, isNotEmpty); + expect(changes.last, 53); + }); + + testWidgets('nach links/unten ziehen verringert den Wert', (tester) async { + final changes = []; + await tester.pumpWidget(wrap(value: 50, initialValue: 50, onChanged: changes.add)); + + await tester.drag(find.byType(SwipeValueInput), const Offset(0, 42)); + await tester.pump(); + + expect(changes.last, 20); + }); + + testWidgets('klemmt auf max, wenn die grosse Schrittweite darueber hinausschiesst', + (tester) async { + final changes = []; + await tester.pumpWidget(wrap(value: 190, initialValue: 190, onChanged: changes.add)); + + await tester.drag(find.byType(SwipeValueInput), const Offset(0, -140)); + await tester.pump(); + + expect(changes.last, 200); + }); + + testWidgets('rundet auf die konfigurierte Nachkommastellenzahl', (tester) async { + final changes = []; + await tester.pumpWidget(wrap( + value: 1.0, + initialValue: 1.0, + stepSmall: 0.5, + decimals: 1, + onChanged: changes.add, + )); + + await tester.drag(find.byType(SwipeValueInput), const Offset(14, 0)); + await tester.pump(); + + expect(changes.last, 1.5); + }); + + testWidgets( + 'aendert den Wert auch innerhalb einer scrollbaren Liste (Regression: ' + 'GestureDetector.onPan* verlor frueher gegen die Scroll-Geste der Liste)', + (tester) async { + final changes = []; + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: ListView( + children: [ + const SizedBox(height: 400), + SwipeValueInput( + label: 'Test', + value: 50, + initialValue: 50, + min: 0, + max: 200, + stepBig: 10, + stepSmall: 1, + unit: 'm', + onChanged: changes.add, + ), + const SizedBox(height: 400), + ], + ), + ), + )); + + await tester.drag(find.byType(SwipeValueInput), const Offset(0, -42)); + await tester.pump(); + + expect(changes, isNotEmpty); + expect(changes.last, 80); + }); + + testWidgets('zeigt den "modified"-Punkt nur, wenn der Wert vom Ausgangswert abweicht', + (tester) async { + await tester.pumpWidget(wrap(value: 50, initialValue: 50, onChanged: (_) {})); + var opacity = tester.widget(find.byType(AnimatedOpacity)); + expect(opacity.opacity, 0); + + await tester.pumpWidget(wrap(value: 60, initialValue: 50, onChanged: (_) {})); + opacity = tester.widget(find.byType(AnimatedOpacity)); + expect(opacity.opacity, 1); + }); +}