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, ), ), ], ), ), ), ], ); } }