Files
dmc/app/lib/ui/widgets/waypoint_list_panel.dart
T
Constantin LeueandClaude Sonnet 5 7a26002a63 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 <noreply@anthropic.com>
2026-07-28 10:33:25 +02:00

408 lines
14 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/mission/default_drone_profile.dart';
import '../../domain/mission/route_geometry.dart';
import '../../domain/waypoint/flat_waypoint_list.dart';
import '../providers/current_mission_provider.dart';
import 'full_value_chart.dart';
import 'waypoint_chip.dart';
enum _PanelTab { list, altitude, speed }
/// Vollbild-Wegpunktliste (HTML-Demonstrator: #wpListPanel). Wird von
/// PlanScreen als eigene Vollbild-Route gepusht (Navigator.push), damit sie
/// wirklich alles ueberdeckt, inklusive der Kopfleiste - anders als ein
/// interner Stack-Overlay innerhalb des Plan-Screens.
///
/// Berechnet die Flugpfad-Geometrie (fuer bad-Markierungen) selbst aus dem
/// beobachteten currentMissionProvider, damit sie auch reaktiv bleibt,
/// wenn sich Wegpunkte aendern, waehrend das Panel offen ist.
///
/// Nur eine Zeile gleichzeitig editierbar (rowEditIdx); Antippen einer
/// Zeile ausserhalb des Edit-Modus zentriert die Karte auf diesen
/// Wegpunkt und schliesst die Liste (Doku 3.6).
class WaypointListPanel extends ConsumerStatefulWidget {
const WaypointListPanel({
super.key,
required this.activeIndex,
required this.onClose,
required this.onCenterOnWaypoint,
required this.onDelete,
required this.onAltitudeChanged,
required this.onSpeedChanged,
required this.onCatchRadiusChanged,
required this.onActionChanged,
});
final int activeIndex;
final VoidCallback onClose;
final void Function(int index) onCenterOnWaypoint;
final void Function(int index) onDelete;
final void Function(int index, double value) onAltitudeChanged;
final void Function(int index, double value) onSpeedChanged;
final void Function(int index, double value) onCatchRadiusChanged;
final void Function(int index, WaypointAction action) onActionChanged;
@override
ConsumerState<WaypointListPanel> createState() => _WaypointListPanelState();
}
class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
int _rowEditIndex = -1;
_PanelTab _tab = _PanelTab.list;
final _listScrollController = ScrollController();
static const _headerStyle = TextStyle(
color: Colors.white60,
fontSize: 12,
fontWeight: FontWeight.w700,
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);
final routeGeometry = buildRouteGeometry(
waypoints,
minTurnRadius: DefaultDroneProfile.minTurnRadius,
maxClimbRate: DefaultDroneProfile.maxClimbRate,
maxDescentRate: DefaultDroneProfile.maxDescentRate,
);
return Material(
color: const Color(0xEB080808),
child: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 12),
child: Row(
children: [
const Expanded(
child: Text(
'Waypoints',
style: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.w700,
),
),
),
IconButton(
onPressed: widget.onClose,
icon: const Icon(Icons.close, color: Colors.white),
),
],
),
),
Row(
children: [
_tabButton('List', _PanelTab.list),
_tabButton('Altitude', _PanelTab.altitude),
_tabButton('Speed', _PanelTab.speed),
],
),
const Divider(color: Colors.white24, height: 1),
Expanded(
child: switch (_tab) {
_PanelTab.list => _listTab(waypoints, routeGeometry),
_PanelTab.altitude => _chartTab(
waypoints,
routeGeometry,
values: [for (final w in waypoints) w.altitudeM],
min: DefaultDroneProfile.altMin,
max: DefaultDroneProfile.altMax,
step: DefaultDroneProfile.altStep,
pxPerUnit: 5,
unit: ' m',
onChanged: widget.onAltitudeChanged,
),
_PanelTab.speed => _chartTab(
waypoints,
routeGeometry,
values: [for (final w in waypoints) w.speedMs],
min: DefaultDroneProfile.speedMin,
max: DefaultDroneProfile.speedMax,
step: DefaultDroneProfile.speedStep,
pxPerUnit: 22,
unit: ' m/s',
onChanged: widget.onSpeedChanged,
),
},
),
],
),
),
);
}
Widget _tabButton(String label, _PanelTab tab) {
final active = _tab == tab;
return Expanded(
child: InkWell(
onTap: () {
setState(() => _tab = tab);
if (tab == _PanelTab.list) {
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToActive());
}
},
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: active ? const Color(0xFF4D9DFF) : Colors.transparent,
width: 2,
),
),
),
child: Text(
label,
textAlign: TextAlign.center,
style: TextStyle(
color: active ? Colors.white : Colors.white60,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
),
);
}
Widget _chartTab(
List<Waypoint> waypoints,
RouteGeometry routeGeometry, {
required List<double> values,
required double min,
required double max,
required double step,
required double pxPerUnit,
required String unit,
required void Function(int index, double value) onChanged,
}) {
return Padding(
padding: const EdgeInsets.fromLTRB(8, 16, 8, 8),
child: FullValueChart(
waypoints: waypoints,
values: values,
legBad: routeGeometry.legClimbBad,
vertexBad: routeGeometry.vertexBad,
activeIndex: widget.activeIndex,
min: min,
max: max,
step: step,
pxPerUnit: pxPerUnit,
unit: unit,
onChanged: onChanged,
),
);
}
Widget _listTab(List<Waypoint> waypoints, RouteGeometry routeGeometry) {
return Column(
children: [
if (waypoints.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: const [
SizedBox(width: 34),
Expanded(child: Text('ALTITUDE', style: _headerStyle)),
Expanded(child: Text('SPEED', style: _headerStyle)),
Expanded(child: Text('EXECUTE', style: _headerStyle)),
Expanded(child: Text('CATCH', style: _headerStyle)),
SizedBox(width: 76),
],
),
),
Expanded(
child: waypoints.isEmpty
? const Center(
child: Text(
'No waypoints yet',
style: TextStyle(color: Colors.white60, fontSize: 15),
),
)
: ListView.builder(
controller: _listScrollController,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemExtent: _rowExtent,
itemCount: waypoints.length,
itemBuilder: (context, index) =>
_row(waypoints[index], index, routeGeometry),
),
),
],
);
}
Widget _row(Waypoint wp, int index, RouteGeometry routeGeometry) {
final isEditing = _rowEditIndex == index;
final isActive = widget.activeIndex == index;
final isBad = index < routeGeometry.vertexBad.length &&
routeGeometry.vertexBad[index];
return GestureDetector(
onTap: () {
if (_rowEditIndex >= 0) return;
widget.onCenterOnWaypoint(index);
},
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
decoration: BoxDecoration(
color: isActive
? const Color(0xFF4D9DFF).withValues(alpha: 0.28)
: Colors.white.withValues(alpha: 0.04),
border: isActive
? Border.all(color: const Color(0xFF4D9DFF))
: null,
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
CircleAvatar(
radius: 13,
backgroundColor: isBad
? const Color(0xFFE2574A).withValues(alpha: 0.5)
: Colors.white24,
child: Text(
'${index + 1}',
style: const TextStyle(color: Colors.white, fontSize: 12),
),
),
const SizedBox(width: 8),
Expanded(
child: isEditing
? WaypointChip(
value: wp.altitudeM,
unit: ' m',
min: DefaultDroneProfile.altMin,
max: DefaultDroneProfile.altMax,
step: DefaultDroneProfile.altStep,
onChanged: (v) => widget.onAltitudeChanged(index, v),
)
: Text('${wp.altitudeM.round()} m',
style: const TextStyle(color: Colors.white)),
),
Expanded(
child: isEditing
? WaypointChip(
value: wp.speedMs,
unit: ' m/s',
min: DefaultDroneProfile.speedMin,
max: DefaultDroneProfile.speedMax,
step: DefaultDroneProfile.speedStep,
onChanged: (v) => widget.onSpeedChanged(index, v),
)
: Text('${wp.speedMs.round()} m/s',
style: const TextStyle(color: Colors.white)),
),
Expanded(
child: isEditing
? DropdownButton<WaypointAction>(
value: wp.action,
isDense: true,
dropdownColor: const Color(0xFF1A1A1A),
style: const TextStyle(color: Colors.white, fontSize: 13),
items: const [
DropdownMenuItem(
value: WaypointAction.none,
child: Text('—'),
),
DropdownMenuItem(
value: WaypointAction.loiter,
child: Text('Loiter'),
),
DropdownMenuItem(
value: WaypointAction.landing,
child: Text('Landing'),
),
],
onChanged: (action) {
if (action != null) widget.onActionChanged(index, action);
},
)
: Text(_actionLabel(wp.action),
style: const TextStyle(color: Colors.white)),
),
Expanded(
child: isEditing
? WaypointChip(
value: wp.catchRadiusM,
unit: ' m',
min: 5,
max: 500,
step: 10,
onChanged: (v) => widget.onCatchRadiusChanged(index, v),
)
: Text('${wp.catchRadiusM.round()} m',
style: const TextStyle(color: Colors.white)),
),
IconButton(
onPressed: () => setState(
() => _rowEditIndex = isEditing ? -1 : index,
),
icon: Icon(
isEditing ? Icons.check : Icons.edit,
color: Colors.white70,
size: 18,
),
),
IconButton(
onPressed: () {
widget.onDelete(index);
if (_rowEditIndex == index) setState(() => _rowEditIndex = -1);
},
icon: const Icon(Icons.delete_outline,
color: Color(0xFFE2574A), size: 18),
),
],
),
),
);
}
String _actionLabel(WaypointAction action) => switch (action) {
WaypointAction.loiter => 'Loiter',
WaypointAction.landing => 'Landing',
_ => '—',
};
}