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
@@ -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<double> 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<bool>? onDragActiveChanged;
|
||||
|
||||
static const _pxPerStep = 14.0;
|
||||
static const _axisLockThreshold = 8.0;
|
||||
|
||||
@override
|
||||
State<SwipeValueInput> createState() => _SwipeValueInputState();
|
||||
}
|
||||
|
||||
class _SwipeValueInputState extends State<SwipeValueInput> {
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user