From 7a26002a637df31683c8b9a3c9a9af9280ffadef Mon Sep 17 00:00:00 2001 From: Constantin Leue <77660791+Consti3D@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:33:25 +0200 Subject: [PATCH] Auto-scroll waypoint list to active row; fix speed chip step size Wegpunktliste: der aktive Wegpunkt (per Halo-Menue "Edit" ausgewaehlt) war bei laengeren Missionen ausserhalb des sichtbaren Bereichs, da die Liste beim Oeffnen immer oben startete. WaypointListPanel bekommt jetzt einen ScrollController mit fester Zeilenhoehe (itemExtent, damit die Zielposition ohne Layout-Messung berechnet werden kann) und scrollt per post-frame-Callback zur aktiven Zeile - sowohl beim ersten Oeffnen als auch beim Zurueckwechseln vom Altitude-/Speed-Tab auf List. WaypointChip (der Swipe-Wertechip in den Listenzeilen) hatte eine fest einprogrammierte Schrittgroesse von 10 fuer alle Felder (Alt/Speed/ Catch) - fuer Speed bei einer Spanne von nur 13-25 m/s viel zu grob, ein einzelner Drag-Schritt sprang schon fast durch den gesamten gueltigen Bereich. Schrittgroesse ist jetzt ein expliziter Parameter; Speed nutzt DefaultDroneProfile.speedStep (1), Altitude weiterhin DefaultDroneProfile.altStep (10), Catch-Radius weiterhin 10 (keine Beschwerde hierzu, Wert unveraendert). Der Alt-/Speed-Drehrad und die Vollbild-Charts nutzten diese Profile bereits korrekt - der Row-Chip war die einzige Stelle mit hartcodiertem Wert. Verifiziert: flutter analyze (0 issues), flutter test (24/24, zwei neue Tests fuer Auto-Scroll und Speed-Schrittgroesse), manuell auf Pixel_10a-Emulator mit 7 Wegpunkten - Liste oeffnet direkt bei Wegpunkt 7, Speed-Chip-Drag aendert 15 m/s in kleinen Schritten (18) statt in Zehnerspruengen. Co-Authored-By: Claude Sonnet 5 --- app/lib/ui/widgets/waypoint_chip.dart | 7 +- app/lib/ui/widgets/waypoint_list_panel.dart | 47 ++++++++++++- app/test/widget_test.dart | 73 +++++++++++++++++++++ 3 files changed, 123 insertions(+), 4 deletions(-) diff --git a/app/lib/ui/widgets/waypoint_chip.dart b/app/lib/ui/widgets/waypoint_chip.dart index 3358f4e..db90dc8 100644 --- a/app/lib/ui/widgets/waypoint_chip.dart +++ b/app/lib/ui/widgets/waypoint_chip.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; /// Swipe-adjustierbarer Wertechip in der Wegpunktliste (HTML-Demonstrator: /// .value-chip / chipFieldConfig). Vertikal ziehen aendert den Wert in -/// festen Schritten von 10 pro 14px - nach oben erhoeht. +/// festen Schritten von [step] pro 14px - nach oben erhoeht. class WaypointChip extends StatefulWidget { const WaypointChip({ super.key, @@ -10,6 +10,7 @@ class WaypointChip extends StatefulWidget { required this.unit, required this.min, required this.max, + required this.step, required this.onChanged, }); @@ -17,9 +18,9 @@ class WaypointChip extends StatefulWidget { final String unit; final double min; final double max; + final double step; final ValueChanged onChanged; - static const _stepSize = 10.0; static const _pxPerStep = 14.0; @override @@ -41,7 +42,7 @@ class _WaypointChipState extends State { if (startY == null) return; final deltaY = startY - details.globalPosition.dy; final steps = (deltaY / WaypointChip._pxPerStep).round(); - var val = _dragStartValue + steps * WaypointChip._stepSize; + var val = _dragStartValue + steps * widget.step; val = val.clamp(widget.min, widget.max); if (val != widget.value) { widget.onChanged(val); diff --git a/app/lib/ui/widgets/waypoint_list_panel.dart b/app/lib/ui/widgets/waypoint_list_panel.dart index 11e0df8..d8f76de 100644 --- a/app/lib/ui/widgets/waypoint_list_panel.dart +++ b/app/lib/ui/widgets/waypoint_list_panel.dart @@ -51,6 +51,7 @@ class WaypointListPanel extends ConsumerStatefulWidget { class _WaypointListPanelState extends ConsumerState { int _rowEditIndex = -1; _PanelTab _tab = _PanelTab.list; + final _listScrollController = ScrollController(); static const _headerStyle = TextStyle( color: Colors.white60, @@ -59,6 +60,40 @@ class _WaypointListPanelState extends ConsumerState { letterSpacing: 0.5, ); + // Feste Zeilenhoehe, damit die Scrollposition des aktiven Wegpunkts ohne + // Layout-Messung berechnet werden kann (siehe _scrollToActive). + static const _rowExtent = 60.0; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToActive()); + } + + @override + void dispose() { + _listScrollController.dispose(); + super.dispose(); + } + + /// Zentriert die Liste auf den aktuell aktiven Wegpunkt (Doku 3.6: das + /// Panel oeffnet sich beim Editieren eines Wegpunkts ueber das + /// Halo-Menue - ohne Auto-Scroll waere er bei laengeren Missionen nicht + /// sichtbar). + void _scrollToActive() { + if (!mounted || widget.activeIndex < 0) return; + if (!_listScrollController.hasClients) return; + final position = _listScrollController.position; + final target = widget.activeIndex * _rowExtent - + position.viewportDimension / 2 + + _rowExtent / 2; + _listScrollController.animateTo( + target.clamp(0.0, position.maxScrollExtent), + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + @override Widget build(BuildContext context) { final waypoints = ref.watch(currentMissionProvider); @@ -140,7 +175,12 @@ class _WaypointListPanelState extends ConsumerState { final active = _tab == tab; return Expanded( child: InkWell( - onTap: () => setState(() => _tab = tab), + onTap: () { + setState(() => _tab = tab); + if (tab == _PanelTab.list) { + WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToActive()); + } + }, child: Container( padding: const EdgeInsets.symmetric(vertical: 10), decoration: BoxDecoration( @@ -220,7 +260,9 @@ class _WaypointListPanelState extends ConsumerState { ), ) : ListView.builder( + controller: _listScrollController, padding: const EdgeInsets.symmetric(horizontal: 16), + itemExtent: _rowExtent, itemCount: waypoints.length, itemBuilder: (context, index) => _row(waypoints[index], index, routeGeometry), @@ -273,6 +315,7 @@ class _WaypointListPanelState extends ConsumerState { unit: ' m', min: DefaultDroneProfile.altMin, max: DefaultDroneProfile.altMax, + step: DefaultDroneProfile.altStep, onChanged: (v) => widget.onAltitudeChanged(index, v), ) : Text('${wp.altitudeM.round()} m', @@ -285,6 +328,7 @@ class _WaypointListPanelState extends ConsumerState { unit: ' m/s', min: DefaultDroneProfile.speedMin, max: DefaultDroneProfile.speedMax, + step: DefaultDroneProfile.speedStep, onChanged: (v) => widget.onSpeedChanged(index, v), ) : Text('${wp.speedMs.round()} m/s', @@ -325,6 +369,7 @@ class _WaypointListPanelState extends ConsumerState { unit: ' m', min: 5, max: 500, + step: 10, onChanged: (v) => widget.onCatchRadiusChanged(index, v), ) : Text('${wp.catchRadiusM.round()} m', diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart index 1538b5b..d312d32 100644 --- a/app/test/widget_test.dart +++ b/app/test/widget_test.dart @@ -267,6 +267,37 @@ void main() { expect(waypoints.single.altitudeM, greaterThan(60)); }); + testWidgets( + 'Speed-Chip in der Liste aendert die Geschwindigkeit nur in Schritten von 1', + (tester) async { + final container = await _pumpWithContainer(tester); + + await tester.tap(find.byType(ReticleButton)); + await tester.pump(); + await tester.tap(find.text('Details:')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + await tester.tap(find.descendant( + of: find.byType(WaypointListPanel), + matching: find.byIcon(Icons.edit), + )); + await tester.pump(); + + // Speed-Chip ist der zweite der drei Chips (Alt/Speed/Catch). Ein Drag + // von nur 3 gemeinten Schritten (42px, abzueglich Touch-Slop real + // weniger) darf bei Schrittgroesse 1 hoechstens um wenige m/s aendern - + // mit der alten Schrittgroesse 10 waere schon ein einziger erkannter + // Schritt (+10) ausserhalb dieser Spanne. + final startSpeed = container.read(currentMissionProvider).single.speedMs; + await tester.drag(find.byType(WaypointChip).at(1), const Offset(0, -42)); + await tester.pump(const Duration(milliseconds: 200)); + + final speed = container.read(currentMissionProvider).single.speedMs; + expect(speed, greaterThan(startSpeed)); + expect(speed, lessThan(startSpeed + 4)); + }); + testWidgets( 'Altitude-Tab aendert die Hoehe per Drag auf dem Chart-Punkt', (tester) async { @@ -397,4 +428,46 @@ void main() { expect(camera.center.longitude, closeTo(movedTo.longitude, 0.0001)); expect(camera.zoom, 14); }); + + testWidgets( + 'Wegpunktliste scrollt beim Oeffnen automatisch zum aktiven Wegpunkt', + (tester) async { + final container = ProviderContainer(); + addTearDown(container.dispose); + + final notifier = container.read(currentMissionProvider.notifier); + for (var i = 0; i < 30; i++) { + notifier.addWaypoint(Waypoint( + lat: 48.2 + i * 0.001, + lon: 16.3, + altitudeM: 60, + speedMs: 15, + catchRadiusM: 60, + )); + } + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + home: WaypointListPanel( + activeIndex: 29, + onClose: () {}, + onCenterOnWaypoint: (_) {}, + onDelete: (_) {}, + onAltitudeChanged: (_, _) {}, + onSpeedChanged: (_, _) {}, + onCatchRadiusChanged: (_, _) {}, + onActionChanged: (_, _) {}, + ), + ), + ), + ); + await tester.pump(); + // Auto-Scroll laeuft als post-frame-Callback mit 300ms Animation. + await tester.pump(const Duration(milliseconds: 350)); + + final listView = tester.widget(find.byType(ListView)); + expect(listView.controller!.offset, greaterThan(0)); + }); }