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 '../../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 createState() => _PlanScreenState(); } class _PlanScreenState extends ConsumerState { // 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? _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( 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 warnings) { Navigator.of(context).push(MaterialPageRoute( 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? 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), ), ), ], ); } 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, ); } /// flutter_map's Marker verlangt eine feste Breite (kein intrinsisches /// Sizing wie bei normalen Widgets) - eine einzelne fuer alle Faelle /// grosszuegig bemessene Breite liesse bei kurzen Werten (z.B. "1 m/s") /// sichtbaren Leerraum rechts neben dem Text. Stattdessen je Ziffernzahl /// berechnet (per Widget-Messung ermittelt: +11px/Ziffer, siehe /// WindMarkerPill). double _windMarkerWidth(double speedMs) { final digits = speedMs.round().abs().toString().length; // +1px Puffer: MarkerLayer rendert ueber eine eigene Transform-/ // OverflowBox-Kette, die im echten Baum minimal (< 1px) anders rundet // als eine isolierte Messung - ohne Puffer fuehrte das zu einem // 0.25px-Overflow. return 81.0 + digits * 11.0; } }