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/route_geometry.dart'; import '../../../domain/waypoint/flat_waypoint_list.dart'; import '../../providers/current_mission_provider.dart'; import '../../widgets/bottom_stats_bar.dart'; import '../../widgets/halo_menu.dart'; import '../../widgets/mission_map.dart'; import '../../widgets/reticle_button.dart'; import '../../widgets/value_wheel.dart'; import '../../widgets/waypoint_list_panel.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 { final _mapController = MapController(); StreamSubscription? _mapEventSub; _ReticleMode _mode = _ReticleMode.idle; int _targetIndex = -1; bool _mapReady = false; bool _listOpen = false; // TODO: aus dem aktiven Drohnen-Profil (Doku 3.5) uebernehmen, sobald die // Profilverwaltung existiert. Werte entsprechen dem Default-Profil // T1 Ranger im HTML-Demonstrator (DEFAULT_DRONE). static const _altMin = -500.0; static const _altMax = 2000.0; static const _altStep = 10.0; static const _altPxPerStep = 14.0; static const _altMajorEvery = 5; static const _speedMin = 13.0; static const _speedMax = 25.0; static const _speedStep = 1.0; static const _speedPxPerStep = 14.0; static const _speedMajorEvery = 5; static const _defaultCatchRadiusM = 60.0; static const _minTurnRadius = 35.0; static const _maxClimbRate = 5.0; static const _maxDescentRate = 6.0; double _curAlt = 60; double _curSpeed = 15; // Radius des Reticle-Buttons (siehe ReticleButton: 55px Durchmesser). static const _reticleRadiusPx = 55 / 2; static const _distance = Distance(roundResult: false); @override void initState() { super.initState(); _mapEventSub = _mapController.mapEventStream.listen(_onMapEvent); } @override void dispose() { _mapEventSub?.cancel(); _mapController.dispose(); super.dispose(); } void _onMapEvent(MapEvent event) { if (event is MapEventMoveEnd) { _handleMoveEnd(); } else if (event is MapEventMove) { 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: _defaultCatchRadiusM, ), ); 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 und schliesst /// die Liste (Doku 3.6). Danach wird die Snap-Erkennung einmal manuell /// angestossen, da flutter_maps MapController.move() - anders als /// Leaflets panTo() - kein MoveEnd-Event feuert. void _onCenterOnWaypoint(int index) { final waypoints = ref.read(currentMissionProvider); if (index < 0 || index >= waypoints.length) return; final wp = waypoints[index]; setState(() => _listOpen = false); _mapController.move(LatLng(wp.lat, wp.lon), _mapController.camera.zoom); _handleMoveEnd(); } ({double distanceM, double durationSec}) _missionStats( List waypoints, ) { var totalDist = 0.0; var totalDur = 0.0; for (var i = 0; i < waypoints.length - 1; i++) { final a = LatLng(waypoints[i].lat, waypoints[i].lon); final b = LatLng(waypoints[i + 1].lat, waypoints[i + 1].lon); final d = _distance(a, b); totalDist += d; totalDur += d / waypoints[i + 1].speedMs; } return (distanceM: totalDist, durationSec: totalDur); } @override Widget build(BuildContext context) { final waypoints = ref.watch(currentMissionProvider); final routeGeometry = buildRouteGeometry( waypoints, minTurnRadius: _minTurnRadius, maxClimbRate: _maxClimbRate, maxDescentRate: _maxDescentRate, ); final missionStats = _missionStats(waypoints); 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, ); 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], ), ], ), 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, child: ValueWheel( value: _curAlt, min: _altMin, max: _altMax, step: _altStep, pxPerStep: _altPxPerStep, majorEvery: _altMajorEvery, onChanged: _onAltChanged, ), ), Positioned( top: 56, bottom: wheelBottomInset, left: 70, child: Center(child: _readout('Alt', _curAlt, 'm')), ), Positioned( top: 56, bottom: wheelBottomInset, right: 14, child: ValueWheel( value: _curSpeed, min: _speedMin, max: _speedMax, step: _speedStep, pxPerStep: _speedPxPerStep, majorEvery: _speedMajorEvery, onChanged: _onSpeedChanged, ), ), Positioned( top: 56, bottom: wheelBottomInset, right: 70, child: Center(child: _readout('Speed', _curSpeed, 'm/s')), ), Positioned( left: 0, right: 0, bottom: 0, child: Column( mainAxisSize: MainAxisSize.min, children: [ if (routeGeometry.vertexBad.any((bad) => bad) || routeGeometry.segments.any((s) => s.bad)) Container( width: double.infinity, color: const Color(0xFF7C2D12), padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12), child: const Text( 'Route exceeds drone capabilities at highlighted point', textAlign: TextAlign.center, style: TextStyle(color: Color(0xFFFDE3D0), fontSize: 12), ), ), BottomStatsBar( waypointCount: waypoints.length, totalDistanceM: missionStats.distanceM, totalDurationSec: missionStats.durationSec, onTap: () => setState(() => _listOpen = true), ), ], ), ), if (_listOpen) Positioned.fill( child: WaypointListPanel( activeIndex: _mode == _ReticleMode.idle ? -1 : _targetIndex, vertexBad: routeGeometry.vertexBad, altMin: _altMin, altMax: _altMax, speedMin: _speedMin, speedMax: _speedMax, onClose: () => setState(() => _listOpen = false), onCenterOnWaypoint: _onCenterOnWaypoint, onDelete: _onRowDelete, onAltitudeChanged: _onRowAltitudeChanged, onSpeedChanged: _onRowSpeedChanged, onCatchRadiusChanged: _onRowCatchRadiusChanged, onActionChanged: _onRowActionChanged, ), ), ], ); } 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, ); } }