Files
dmc/app/lib/ui/screens/plan/plan_screen.dart
T
Constantin Leue eca1c647b3 Auto-fetch wind, drop manual fetch/validate buttons, fix it in fly mode
Redesigns the waypoint list panel to match the drone-status/missions
panels: no title bar, close button floats over the tab row, tabs get
more vertical padding.

Removes the "Fetch wind" and "Validate" header buttons along with the
now-fully-orphaned WindValidatePanel screen and its WindService
support code (fetchValidation, WindValidationResult and friends) -
nothing else referenced them.

Wind is now fetched automatically instead: HeaderWindNotifier gains
ensurePerWaypointWind(), called when the waypoint list panel opens and
when the header pill's per-waypoint toggle switches on. A lat/lon
signature of the waypoint list tracks whether the fetched data is
still current, so a ref.listen(currentMissionProvider) in the same
notifier refetches only when the list itself changes (position added/
removed/moved) - not on every altitude/speed edit. Waypoints that
already carry wind data (e.g. loaded from a saved mission) short-
circuit the check instead of re-fetching pointlessly.

Also wires per-waypoint wind markers into the fly-mode map, which
never had them (plan_screen.dart built windMarkers; fly_screen.dart
just never did) - the reported "doesn't work in fly mode" bug.
windMarkerWidth() moved from a plan_screen.dart-private method to
wind_marker_pill.dart so both screens can share it.

Test containers in widget_test.dart now override windServiceProvider
with a MockClient so the new auto-fetch trigger can't reach the real
network during tests.
2026-08-09 22:26:23 +02:00

560 lines
20 KiB
Dart

import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:latlong2/latlong.dart';
import '../../../domain/mission/default_drone_profile.dart';
import '../../../domain/mission/mission_stats.dart';
import '../../../domain/mission/mission_warnings.dart';
import '../../../domain/mission/route_geometry.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/map_controller_provider.dart';
import '../../providers/mission_meta_provider.dart';
import '../../providers/terrain_provider.dart';
import '../../providers/wind_provider.dart';
import '../settings/settings_screen.dart';
import '../../widgets/halo_menu.dart';
import '../../widgets/mission_footer_bar.dart';
import '../../widgets/mission_map.dart';
import '../../widgets/reticle_button.dart';
import '../../widgets/value_wheel.dart';
import '../../widgets/waypoint_list_panel.dart';
import '../../widgets/warnings_panel.dart';
import '../../widgets/wind_marker_pill.dart';
/// Reticle-Zustand (HTML-Demonstrator: Variable `mode`).
/// idle: kein Wegpunkt in der Naehe, Tap legt einen neuen an.
/// snapped: Kartenmitte ist auf einen bestehenden Wegpunkt eingerastet.
/// editing: Wegpunkt wird bearbeitet - Kartenverschieben repositioniert ihn
/// live, das Halo-Menue (Loiter/Landing/Entfernen) ist sichtbar.
enum _ReticleMode { idle, snapped, editing }
/// Missionsplanung: Karte + Wegpunktliste (Architektur-Doku 3.6/3.7).
/// Kein eigenes Scaffold - die Kopfleiste (Plan/Fly-Umschalter) und der
/// gemeinsame Scaffold-Rahmen liegen im AppShell darueber.
class PlanScreen extends ConsumerStatefulWidget {
const PlanScreen({super.key});
@override
ConsumerState<PlanScreen> createState() => _PlanScreenState();
}
class _PlanScreenState extends ConsumerState<PlanScreen> {
// Geteilter, app-lebenslanger Controller statt einer PlanScreen-eigenen
// Instanz (siehe mapControllerProvider) - PlanScreen besitzt ihn nicht
// mehr und darf ihn daher auch nicht disposen.
late final MapController _mapController;
StreamSubscription<MapEvent>? _mapEventSub;
_ReticleMode _mode = _ReticleMode.idle;
int _targetIndex = -1;
bool _mapReady = false;
// Rein UI-seitige Darstellungsdetails der Drehraeder; die eigentlichen
// Wertebereiche kommen aus DefaultDroneProfile (Doku 3.5-Platzhalter).
static const _altPxPerStep = 14.0;
static const _altMajorEvery = 5;
static const _speedPxPerStep = 14.0;
static const _speedMajorEvery = 5;
double _curAlt = DefaultDroneProfile.altitudeM;
double _curSpeed = DefaultDroneProfile.speedMs;
// Radius des Reticle-Buttons (siehe ReticleButton: 55px Durchmesser).
static const _reticleRadiusPx = 55 / 2;
static const _distance = Distance(roundResult: false);
@override
void initState() {
super.initState();
_mapController = ref.read(mapControllerProvider);
_mapEventSub = _mapController.mapEventStream.listen(_onMapEvent);
// Erstbefuellung der Kopfleisten-Wind-Pille (Doku 3.8) - erst nach dem
// ersten Frame, da die Kartenkamera (Fallback-Referenzort ohne
// Wegpunkte) vor dem initialen Layout noch nicht bereit ist.
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!mounted) return;
ref.read(headerWindProvider.notifier).refresh();
// Bootstrap der Persistenz (Doku 4.15/7.5): mindestens ein
// Drohnenprofil sicherstellen/laden, danach ggf. den zuletzt
// bearbeiteten Autosave-Draft wiederherstellen.
await ref.read(activeDroneProfileProvider.notifier).initialize();
await ref.read(currentMissionMetaProvider.notifier).restoreLastSession();
if (mounted) {
setState(() {
_curSpeed = ref.read(activeDroneProfileProvider).cruiseSpeed;
});
}
});
// Wechselt der Nutzer waehrend der Bearbeitung das aktive Drohnenprofil,
// muss die aktuell eingestellte Soll-Geschwindigkeit in dessen
// gueltigen Bereich geklemmt werden (sonst zeigt das Drehrad einen
// Wert ausserhalb seiner eigenen Skala).
ref.listenManual(activeDroneProfileProvider, (previous, next) {
final clamped = _curSpeed.clamp(next.minSpeed, next.maxSpeed);
if (clamped != _curSpeed) setState(() => _curSpeed = clamped);
});
}
@override
void dispose() {
_mapEventSub?.cancel();
super.dispose();
}
void _onMapEvent(MapEvent event) {
if (event is MapEventMoveEnd) {
_handleMoveEnd();
} else if (event is MapEventMove) {
if (event.source == MapEventSource.mapController) {
// Programmatischer Sprung (Suche/Home/Fit in der Kopfleiste,
// Zeile-antippen in der Wegpunktliste) - im Gegensatz zu einer
// Gesten-Verschiebung ist die Position hier sofort final, es
// kommt aber nie ein MoveEnd fuer diesen Fall. Direkt reagieren
// statt darauf zu warten.
if (_mode != _ReticleMode.editing) _handleMoveEnd();
} else if (_mode == _ReticleMode.editing && _targetIndex >= 0) {
final waypoints = ref.read(currentMissionProvider);
if (_targetIndex < waypoints.length) {
final center = _mapController.camera.center;
ref.read(currentMissionProvider.notifier).moveWaypoint(
_targetIndex,
center.latitude,
center.longitude,
);
}
} else if (_mode == _ReticleMode.idle) {
// Nur fuer die gestrichelte Vorschaulinie neu zeichnen.
setState(() {});
}
}
}
/// Metergenauer Fangradius des Reticle bei aktuellem Zoom/Breitengrad
/// (HTML-Demonstrator: currentSnapMeters()).
double _currentSnapMeters() {
final camera = _mapController.camera;
final metersPerPixel = 156543.03392 *
math.cos(camera.center.latitude * math.pi / 180) /
math.pow(2, camera.zoom);
return _reticleRadiusPx * metersPerPixel;
}
void _handleMoveEnd() {
if (_mode == _ReticleMode.editing) return;
final waypoints = ref.read(currentMissionProvider);
final center = _mapController.camera.center;
final snapMeters = _currentSnapMeters();
var bestIndex = -1;
var bestDist = snapMeters;
for (var i = 0; i < waypoints.length; i++) {
final d = _distance(center, LatLng(waypoints[i].lat, waypoints[i].lon));
if (d < bestDist) {
bestDist = d;
bestIndex = i;
}
}
setState(() {
if (bestIndex >= 0) {
_mode = _ReticleMode.snapped;
_targetIndex = bestIndex;
final target = waypoints[bestIndex];
final targetLatLng = LatLng(target.lat, target.lon);
if (_distance(center, targetLatLng) > 0.5) {
// flutter_map's MapController.move() emits MapEventMove, nicht
// MoveEnd - anders als Leaflets panTo() droht hier also keine
// Rekursion in _handleMoveEnd, ein Guard ist nicht noetig.
_mapController.move(targetLatLng, _mapController.camera.zoom);
}
} else {
_mode = _ReticleMode.idle;
_targetIndex = -1;
}
});
}
void _onReticleTap() {
switch (_mode) {
case _ReticleMode.idle:
final center = _mapController.camera.center;
final waypoints = ref.read(currentMissionProvider);
ref.read(currentMissionProvider.notifier).addWaypoint(
Waypoint(
lat: center.latitude,
lon: center.longitude,
altitudeM: _curAlt,
speedMs: _curSpeed,
catchRadiusM: ref.read(activeDroneProfileProvider).catchRadius,
),
);
if (waypoints.isEmpty) {
// Referenzort fuer die Wind-Pille wechselt vom Kartenzentrum auf
// den ersten Wegpunkt (Doku 3.8) - neu abfragen.
ref.read(headerWindProvider.notifier).refresh();
}
setState(() {
_targetIndex = waypoints.length;
_mode = _ReticleMode.snapped;
});
case _ReticleMode.snapped:
final waypoints = ref.read(currentMissionProvider);
if (_targetIndex >= 0 && _targetIndex < waypoints.length) {
final target = waypoints[_targetIndex];
setState(() {
_mode = _ReticleMode.editing;
_curAlt = target.altitudeM;
_curSpeed = target.speedMs;
});
}
case _ReticleMode.editing:
setState(() => _mode = _ReticleMode.snapped);
}
}
void _onHaloAction(HaloAction action) {
if (_mode != _ReticleMode.editing || _targetIndex < 0) return;
final notifier = ref.read(currentMissionProvider.notifier);
switch (action) {
case HaloAction.loiter:
notifier.toggleAction(_targetIndex, WaypointAction.loiter);
setState(() => _mode = _ReticleMode.snapped);
case HaloAction.landing:
notifier.toggleAction(_targetIndex, WaypointAction.landing);
setState(() => _mode = _ReticleMode.snapped);
case HaloAction.remove:
notifier.removeAt(_targetIndex);
setState(() {
_mode = _ReticleMode.idle;
_targetIndex = -1;
});
}
}
void _onAltChanged(double value) {
setState(() => _curAlt = value);
if (_mode == _ReticleMode.editing && _targetIndex >= 0) {
ref.read(currentMissionProvider.notifier).setAltitude(_targetIndex, value);
}
}
void _onSpeedChanged(double value) {
setState(() => _curSpeed = value);
if (_mode == _ReticleMode.editing && _targetIndex >= 0) {
ref.read(currentMissionProvider.notifier).setSpeed(_targetIndex, value);
}
}
// Wegpunktliste (Doku 3.6): Aenderungen wirken auf einen explizit
// angegebenen Index, nicht zwingend den aktuellen Reticle-Zielpunkt.
void _onRowAltitudeChanged(int index, double value) {
ref.read(currentMissionProvider.notifier).setAltitude(index, value);
if (_mode == _ReticleMode.editing && index == _targetIndex) {
setState(() => _curAlt = value);
}
}
void _onRowSpeedChanged(int index, double value) {
ref.read(currentMissionProvider.notifier).setSpeed(index, value);
if (_mode == _ReticleMode.editing && index == _targetIndex) {
setState(() => _curSpeed = value);
}
}
void _onRowCatchRadiusChanged(int index, double value) {
ref.read(currentMissionProvider.notifier).setCatchRadius(index, value);
}
void _onRowActionChanged(int index, WaypointAction action) {
ref.read(currentMissionProvider.notifier).setAction(index, action);
}
void _onRowDelete(int index) {
ref.read(currentMissionProvider.notifier).removeAt(index);
setState(() {
if (_targetIndex == index) {
_mode = _ReticleMode.idle;
_targetIndex = -1;
} else if (_targetIndex > index) {
_targetIndex -= 1;
}
});
}
/// Zeile antippen zentriert die Karte auf diesen Wegpunkt (Doku 3.6). Die
/// Snap-Erkennung reagiert automatisch ueber _onMapEvent (source ==
/// mapController), ein manueller Folgeaufruf ist nicht noetig.
void _onCenterOnWaypoint(int index) {
final waypoints = ref.read(currentMissionProvider);
if (index < 0 || index >= waypoints.length) return;
final wp = waypoints[index];
_mapController.move(LatLng(wp.lat, wp.lon), _mapController.camera.zoom);
}
/// Oeffnet die Wegpunktliste als eigene Vollbild-Route statt als
/// internen Stack-Overlay, damit sie wirklich alles ueberdeckt -
/// inklusive der Kopfleiste im AppShell darueber. [initialTab] erlaubt
/// gezieltes Oeffnen auf Altitude/Speed statt immer auf der Liste (Doku
/// 3.10: Antippen einer Warnung).
void _openWaypointList({PanelTab initialTab = PanelTab.list}) {
Navigator.of(context).push(
PageRouteBuilder<void>(
opaque: true,
transitionDuration: const Duration(milliseconds: 150),
reverseTransitionDuration: const Duration(milliseconds: 150),
pageBuilder: (routeContext, animation, secondaryAnimation) {
return FadeTransition(
opacity: animation,
child: WaypointListPanel(
activeIndex: _mode == _ReticleMode.idle ? -1 : _targetIndex,
initialTab: initialTab,
onClose: () => Navigator.of(routeContext).pop(),
onCenterOnWaypoint: (index) {
_onCenterOnWaypoint(index);
Navigator.of(routeContext).pop();
},
onDelete: _onRowDelete,
onAltitudeChanged: _onRowAltitudeChanged,
onSpeedChanged: _onRowSpeedChanged,
onCatchRadiusChanged: _onRowCatchRadiusChanged,
onActionChanged: _onRowActionChanged,
),
);
},
),
);
}
/// Oeffnet die Warnungsliste (Doku 3.10, HTML-Demonstrator:
/// #warningsBtn/#warningsPanel). Antippen einer Warnung schliesst die
/// Liste und navigiert zum passenden Screen: Altitude-/Speed-Tab der
/// Wegpunktliste, oder ein Kartensprung auf die gesamte Route.
void _openWarningsPanel(List<MissionWarning> warnings) {
Navigator.of(context).push(MaterialPageRoute<void>(
builder: (routeContext) => WarningsPanel(
warnings: warnings,
onClose: () => Navigator.of(routeContext).pop(),
onWarningTap: (warning) {
Navigator.of(routeContext).pop();
switch (warning.action) {
case WarningAction.altitude:
_openWaypointList(initialTab: PanelTab.altitude);
case WarningAction.speed:
_openWaypointList(initialTab: PanelTab.speed);
case WarningAction.map:
fitMapToWaypoints(ref, ref.read(currentMissionProvider));
}
},
),
));
}
@override
Widget build(BuildContext context) {
final waypoints = ref.watch(currentMissionProvider);
final showPerWaypointWind = ref.watch(headerWindProvider).showPerWaypoint;
final droneProfile = ref.watch(activeDroneProfileProvider);
final routeGeometry = buildRouteGeometry(
waypoints,
minTurnRadius: droneProfile.minTurnRadius,
maxClimbRate: droneProfile.maxClimbRate,
maxDescentRate: droneProfile.maxDescentRate,
);
final missionStats = computeMissionStats(waypoints);
final terrainProfile = ref.watch(terrainProvider).profile;
final warnings = computeMissionWarnings(
waypoints: waypoints,
routeGeometry: routeGeometry,
legWindBad: computeLegWindBad(waypoints, droneProfile.maxSpeed),
droneProfile: droneProfile,
totalDistanceM: missionStats.distanceM,
totalDurationSec: missionStats.durationSec,
terrain: terrainProfile,
);
List<LatLng>? rubberBand;
if (_mapReady && _mode == _ReticleMode.idle && waypoints.isNotEmpty) {
final last = waypoints.last;
rubberBand = [
LatLng(last.lat, last.lon),
_mapController.camera.center,
];
}
final targetWaypoint =
(_targetIndex >= 0 && _targetIndex < waypoints.length)
? waypoints[_targetIndex]
: null;
final wheelBottomInset = math.max(
MediaQuery.of(context).size.height * 0.06,
124.0,
);
// Symmetrischer Randabstand auf beiden Seiten, damit z.B. eine
// Frontkamera-Aussparung im Querformat die Raeder nicht verdeckt
// (HTML-Demonstrator: max(env(safe-area-inset-left), env(safe-area-inset-right))).
final safeEdgeInset = math.max(
MediaQuery.of(context).padding.left,
MediaQuery.of(context).padding.right,
);
return Stack(
children: [
MissionMap(
mapController: _mapController,
onMapReady: () => setState(() => _mapReady = true),
routeSegments: routeGeometry.segments,
rubberBandLine: rubberBand,
waypointMarkers: [
for (var i = 0; i < waypoints.length; i++)
_markerFor(
waypoints[i],
isTarget: i == _targetIndex,
isBad: i < routeGeometry.vertexBad.length &&
routeGeometry.vertexBad[i],
),
],
windMarkers: [
if (showPerWaypointWind)
for (final wp in waypoints)
if (wp.windSpeedMs != null && wp.windDirFromDeg != null)
Marker(
point: LatLng(wp.lat, wp.lon),
width: windMarkerWidth(wp.windSpeedMs!),
height: 22,
alignment: const Alignment(1.1, -1.5),
child: WindMarkerPill(
speedMs: wp.windSpeedMs!,
dirFromDeg: wp.windDirFromDeg!,
),
),
],
),
if (_mode == _ReticleMode.editing && targetWaypoint != null)
Align(
alignment: Alignment.center,
child: HaloMenu(
loiterActive: targetWaypoint.action == WaypointAction.loiter,
landingActive: targetWaypoint.action == WaypointAction.landing,
onAction: _onHaloAction,
),
),
Align(
alignment: Alignment.center,
child: ReticleButton(icon: _reticleIcon, onTap: _onReticleTap),
),
Positioned(
top: 56,
bottom: wheelBottomInset,
left: 14 + safeEdgeInset,
child: ValueWheel(
value: _curAlt,
min: DefaultDroneProfile.altMin,
max: droneProfile.maxAltitudeM,
step: DefaultDroneProfile.altStep,
pxPerStep: _altPxPerStep,
majorEvery: _altMajorEvery,
onChanged: _onAltChanged,
),
),
Positioned(
top: 56,
bottom: wheelBottomInset,
left: 70 + safeEdgeInset,
child: Center(child: _readout('Alt', _curAlt, 'm')),
),
Positioned(
top: 56,
bottom: wheelBottomInset,
right: 14 + safeEdgeInset,
child: ValueWheel(
value: _curSpeed,
min: droneProfile.minSpeed,
max: droneProfile.maxSpeed,
step: DefaultDroneProfile.speedStep,
pxPerStep: _speedPxPerStep,
majorEvery: _speedMajorEvery,
onChanged: _onSpeedChanged,
),
),
Positioned(
top: 56,
bottom: wheelBottomInset,
right: 70 + safeEdgeInset,
child: Center(child: _readout('Speed', _curSpeed, 'm/s')),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: MissionFooterBar(
waypoints: waypoints,
legClimbBad: routeGeometry.legClimbBad,
vertexBad: routeGeometry.vertexBad,
activeIndex: _mode == _ReticleMode.idle ? -1 : _targetIndex,
totalDistanceM: missionStats.distanceM,
totalDurationSec: missionStats.durationSec,
warnings: warnings,
onDetailsTap: _openWaypointList,
onWarningsTap: () => _openWarningsPanel(warnings),
onSettingsTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => const SettingsScreen(),
)),
),
),
],
);
}
Widget _readout(String label, double value, String unit) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'$label: ${value.round()} $unit',
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
);
}
IconData get _reticleIcon => switch (_mode) {
_ReticleMode.idle => Icons.add,
_ReticleMode.snapped => Icons.edit,
_ReticleMode.editing => Icons.check,
};
CircleMarker _markerFor(
Waypoint wp, {
required bool isTarget,
required bool isBad,
}) {
final highlighted = isTarget && _mode != _ReticleMode.idle;
return CircleMarker(
point: LatLng(wp.lat, wp.lon),
radius: highlighted ? 9 : 6,
color: isBad ? const Color(0xFFE2574A) : const Color(0xFF2B2B28),
borderStrokeWidth: highlighted ? 3 : 1.5,
borderColor: Colors.white,
);
}
}