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)); + }); }