Files
dmc/app/lib/ui/widgets/waypoint_list_panel.dart
T
Constantin LeueandClaude Sonnet 5 c5a8c68a8e Lock map rotation, add mission warnings list with per-issue navigation
Kartendrehung (Doku 4.6): flutter_map's Zwei-Finger-Twist-Geste bewusst
deaktiviert (InteractionOptions mit InteractiveFlag.all & ~rotate in
mission_map.dart) - die Drohne fliegt nordorientiert, eine gedrehte Karte
wuerde die Peilung von Wegpunkten/Reticle nur verwirren, zumal der HTML-
Demonstrator gar keine Drehung kennt.

Warnungsliste (Doku 3.10), analog dem #warningsBtn/#warningsPanel-Flow im
HTML-Demonstrator:

- domain/mission/mission_warnings.dart: computeMissionWarnings() sammelt
  alle aktuell zutreffenden Probleme (Route-Geometrie ausserhalb der
  Drohnenfaehigkeiten, zu starker Wind, zu geringer Bodenabstand,
  Reichweite/Ausdauer ueberschritten) zu einer Liste aus {text, action}-
  Eintraegen - reine Funktion aus bereits vorhandenen Zwischenergebnissen,
  unabhaengig von Riverpod/Widgets testbar.
- domain/wind/wind_math.dart: computeLegWindBad() ergaenzt (Bodenge-
  schwindigkeit <= 1 m/s oder Windgeschwindigkeit > Drohnen-Maximum) -
  bislang gab es nur die Windkomponente/Bodengeschwindigkeit selbst, aber
  keine "zu stark"-Markierung.
- ui/widgets/warnings_panel.dart: Vollbild-Liste, jede Zeile verweist per
  Aktionslabel auf den Loesungs-Screen ("Open altitude view"/"Open speed
  view"/"Show on map").
- ui/widgets/bottom_stats_bar.dart: roter Warnungs-Button neben den
  Mission-/Drohnen-Chips, nur sichtbar solange Warnungen aktiv sind.
- ui/widgets/waypoint_list_panel.dart: `_PanelTab` zu `PanelTab` public
  gemacht plus neuer `initialTab`-Parameter, damit das Panel direkt auf
  dem Altitude- oder Speed-Tab geoeffnet werden kann statt immer auf der
  Liste zu starten.
- ui/providers/map_controller_provider.dart: fitMapToWaypoints()-Hilfs-
  funktion extrahiert und in MapSearchControls' Fit-Button (vorher eigene
  Kopie der Logik) sowie fuer die "Show on map"-Warnungsaktion
  wiederverwendet.
- ui/screens/plan/plan_screen.dart: berechnet die Warnungsliste, ersetzt
  den bisherigen Einzel-Banner (nur "Route exceeds...") durch alle
  aktuellen Warnungstexte (durch " · " getrennt, wie im HTML-Demonstrator)
  und verdrahtet Warnungs-Button/-Liste mit der passenden Navigation.

Getestet: 4 neue Unit-Tests fuer computeLegWindBad, 7 fuer
computeMissionWarnings (inkl. Grenzfaelle: veraltetes Gelaendeprofil,
kein Limit bei maxRangeM/maxEnduranceMin <= 0), 2 neue Widget-Tests
(Warnungs-Button erscheint bei Reichweiten-Ueberschreitung und oeffnet
die Liste; Antippen einer Wind-Warnung oeffnet den Speed-Tab direkt).
Alle 110 Tests sowie flutter analyze bestehen. Manuell auf dem
Pixel_10a-Emulator verifiziert: Steigraten-Ueberschreitung (Altitude auf
220m bei kurzer Distanz) macht Route/Banner/Warnungs-Button rot,
Warnungsliste zeigt den Eintrag mit "Show on map", Antippen schliesst
die Liste und zoomt die Karte korrekt auf die Bounding-Box beider
Wegpunkte.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 19:51:42 +02:00

581 lines
20 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/mission/default_drone_profile.dart';
import '../../domain/mission/drone_profile.dart';
import '../../domain/mission/route_geometry.dart';
import '../../domain/mission/terrain_math.dart';
import '../../domain/waypoint/flat_waypoint_list.dart';
import '../../domain/wind/wind_math.dart';
import '../providers/active_drone_profile_provider.dart';
import '../providers/current_mission_provider.dart';
import '../providers/terrain_provider.dart';
import '../providers/wind_provider.dart';
import 'full_value_chart.dart';
import 'waypoint_chip.dart';
import 'wind_arrow_icon.dart';
import 'wind_validate_panel.dart';
/// Welcher Tab beim Oeffnen des Panels aktiv ist (Doku 3.6/3.10) - public,
/// damit Aufrufer wie die Warnungsliste direkt auf Altitude/Speed
/// verweisen koennen, statt immer auf der Liste zu starten.
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,
this.initialTab = PanelTab.list,
required this.onClose,
required this.onCenterOnWaypoint,
required this.onDelete,
required this.onAltitudeChanged,
required this.onSpeedChanged,
required this.onCatchRadiusChanged,
required this.onActionChanged,
});
final int activeIndex;
/// Startet direkt auf einem bestimmten Tab statt immer auf der Liste -
/// genutzt, wenn eine Warnung (Doku 3.10) gezielt auf Altitude/Speed
/// verweist.
final PanelTab initialTab;
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;
late PanelTab _tab;
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();
_tab = widget.initialTab;
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToActive();
// Startet das Panel direkt auf dem Altitude-Tab (z.B. aus der
// Warnungsliste), muss der Gelaendeprofil-Abruf hier ausgeloest
// werden - sonst passiert das erst beim (dann gar nicht mehr
// stattfindenden) Antippen des Tab-Buttons.
if (_tab == PanelTab.altitude) {
ref.read(terrainProvider.notifier).ensureFor(ref.read(currentMissionProvider));
}
});
// Laedt/aktualisiert das Gelaendeprofil (Doku 3.9) nur, waehrend der
// Altitude-Tab tatsaechlich sichtbar ist - aendert sich die Route
// waehrenddessen (Editieren, Loeschen, neuer Wegpunkt), muss neu
// gesampelt werden. ensureFor() selbst filtert per routeKey, ob ein
// erneuter Kachel-Download noetig ist.
ref.listenManual(currentMissionProvider, (previous, next) {
if (_tab == PanelTab.altitude) {
ref.read(terrainProvider.notifier).ensureFor(next);
}
});
}
@override
void dispose() {
_listScrollController.dispose();
super.dispose();
}
/// Holt Wind fuer alle Wegpunkte gebuendelt (Doku 3.8, HTML-Demonstrator:
/// fetchWindForWaypoints()) und schreibt das Ergebnis komplett zurueck.
Future<void> _onFetchWind() async {
final waypoints = ref.read(currentMissionProvider);
final messenger = ScaffoldMessenger.of(context);
if (waypoints.isEmpty) {
messenger.showSnackBar(
const SnackBar(content: Text('No waypoints to fetch wind for')),
);
return;
}
messenger.showSnackBar(const SnackBar(content: Text('Fetching wind…')));
final updated =
await ref.read(windServiceProvider).fetchWindForWaypoints(waypoints);
ref.read(currentMissionProvider.notifier).replaceAll(updated);
messenger.showSnackBar(const SnackBar(content: Text('Wind data updated')));
}
/// Oeffnet das Wind-Validierungspanel fuer den aktuellen Referenzort
/// (erster Wegpunkt, sonst Kartenzentrum - Doku 3.8).
void _onValidateWind() {
final location = windReferenceLocation(ref);
if (location == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No location available yet')),
);
return;
}
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => WindValidatePanel(lat: location.$1, lon: location.$2),
));
}
/// 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 droneProfile = ref.watch(activeDroneProfileProvider);
final terrainState = ref.watch(terrainProvider);
final routeGeometry = buildRouteGeometry(
waypoints,
minTurnRadius: droneProfile.minTurnRadius,
maxClimbRate: droneProfile.maxClimbRate,
maxDescentRate: droneProfile.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,
),
),
),
_HeaderActionButton(
icon: Icons.air,
label: 'Fetch wind',
tint: const Color(0xFF4D9DFF),
onTap: _onFetchWind,
),
const SizedBox(width: 8),
_HeaderActionButton(
icon: Icons.rule,
label: 'Validate',
tint: const Color(0xFFC8A04D),
onTap: _onValidateWind,
),
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, droneProfile),
PanelTab.altitude => _chartTab(
waypoints,
routeGeometry,
values: [for (final w in waypoints) w.altitudeM],
min: DefaultDroneProfile.altMin,
max: droneProfile.maxAltitudeM,
step: DefaultDroneProfile.altStep,
pxPerUnit: 5,
unit: ' m',
onChanged: widget.onAltitudeChanged,
terrainPoints: terrainState.profile?.points,
terrainDangerRanges: terrainState.profile != null
? terrainDangerRanges(waypoints, terrainState.profile!)
: null,
terrainLoading: terrainState.loading,
),
PanelTab.speed => _chartTab(
waypoints,
routeGeometry,
values: [for (final w in waypoints) w.speedMs],
min: droneProfile.minSpeed,
max: droneProfile.maxSpeed,
step: DefaultDroneProfile.speedStep,
pxPerUnit: 22,
unit: ' m/s',
onChanged: widget.onSpeedChanged,
legWind: computeLegWind(waypoints),
groundSpeeds: computeGroundSpeeds(waypoints),
),
},
),
],
),
),
);
}
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());
} else if (tab == PanelTab.altitude) {
ref.read(terrainProvider.notifier).ensureFor(ref.read(currentMissionProvider));
}
},
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,
List<double?>? legWind,
List<double?>? groundSpeeds,
List<TerrainSamplePoint>? terrainPoints,
List<(double, double)>? terrainDangerRanges,
bool terrainLoading = false,
}) {
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,
legWind: legWind,
groundSpeeds: groundSpeeds,
terrainPoints: terrainPoints,
terrainDangerRanges: terrainDangerRanges,
terrainLoading: terrainLoading,
),
);
}
Widget _listTab(
List<Waypoint> waypoints,
RouteGeometry routeGeometry,
DroneProfile droneProfile,
) {
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)),
Expanded(child: Text('WIND', 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, droneProfile),
),
),
],
);
}
Widget _row(
Waypoint wp,
int index,
RouteGeometry routeGeometry,
DroneProfile droneProfile,
) {
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: droneProfile.maxAltitudeM,
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: droneProfile.minSpeed,
max: droneProfile.maxSpeed,
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)),
),
Expanded(
child: wp.windSpeedMs != null
? Row(
mainAxisSize: MainAxisSize.min,
children: [
WindArrowIcon(dirFromDeg: wp.windDirFromDeg!, size: 13),
const SizedBox(width: 4),
Text('${wp.windSpeedMs!.round()} m/s',
style: const TextStyle(color: Colors.white)),
],
)
: const Text('—', style: TextStyle(color: Colors.white38)),
),
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',
_ => '—',
};
}
/// Kleiner getoenter Aktions-Button im Panel-Header (HTML-Demonstrator:
/// #windFetchBtn/#windValidateBtn).
class _HeaderActionButton extends StatelessWidget {
const _HeaderActionButton({
required this.icon,
required this.label,
required this.tint,
required this.onTap,
});
final IconData icon;
final String label;
final Color tint;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: tint.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: tint.withValues(alpha: 0.5)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 15, color: Colors.white),
const SizedBox(width: 6),
Text(
label,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
],
),
),
);
}
}