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/waypoint/flat_waypoint_list.dart'; import '../../providers/current_mission_provider.dart'; import '../../widgets/halo_menu.dart'; import '../../widgets/mission_map.dart'; import '../../widgets/reticle_button.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; // TODO: aus dem aktiven Drohnen-Profil (Doku 3.5) uebernehmen, sobald die // Profilverwaltung existiert. Werte entsprechen dem Default-Profil // T1 Ranger im HTML-Demonstrator (curAlt=60, cruiseSpeed=15, catchRadius=60). static const _defaultAltitudeM = 60.0; static const _defaultSpeedMs = 15.0; static const _defaultCatchRadiusM = 60.0; // 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: _defaultAltitudeM, speedMs: _defaultSpeedMs, catchRadiusM: _defaultCatchRadiusM, ), ); setState(() { _targetIndex = waypoints.length; _mode = _ReticleMode.snapped; }); case _ReticleMode.snapped: setState(() => _mode = _ReticleMode.editing); 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; }); } } @override Widget build(BuildContext context) { final waypoints = ref.watch(currentMissionProvider); final routePoints = [ for (final wp in waypoints) LatLng(wp.lat, wp.lon), ]; 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; return Stack( children: [ MissionMap( mapController: _mapController, onMapReady: () => setState(() => _mapReady = true), routeLine: routePoints, rubberBandLine: rubberBand, waypointMarkers: [ for (var i = 0; i < waypoints.length; i++) _markerFor(waypoints[i], isTarget: i == _targetIndex), ], ), 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), ), ], ); } IconData get _reticleIcon => switch (_mode) { _ReticleMode.idle => Icons.add, _ReticleMode.snapped => Icons.edit, _ReticleMode.editing => Icons.check, }; CircleMarker _markerFor(Waypoint wp, {required bool isTarget}) { final highlighted = isTarget && _mode != _ReticleMode.idle; return CircleMarker( point: LatLng(wp.lat, wp.lon), radius: highlighted ? 9 : 6, color: const Color(0xFF2B2B28), borderStrokeWidth: highlighted ? 3 : 1.5, borderColor: Colors.white, ); } }