339 lines
13 KiB
Dart
339 lines
13 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/mission_stats.dart';
|
|
import '../../../domain/mission/mission_warnings.dart';
|
|
import '../../../domain/mission/route_geometry.dart';
|
|
import '../../../domain/telemetry/drone_status.dart';
|
|
import '../../../domain/wind/wind_math.dart';
|
|
import '../../../transport/flight_controller_link.dart';
|
|
import '../../providers/active_drone_profile_provider.dart';
|
|
import '../../providers/current_mission_provider.dart';
|
|
import '../../providers/follow_drone_provider.dart';
|
|
import '../../providers/map_controller_provider.dart';
|
|
import '../../providers/telemetry_provider.dart';
|
|
import '../../providers/terrain_provider.dart';
|
|
import '../settings/settings_screen.dart';
|
|
import '../../widgets/drone_marker_icon.dart';
|
|
import '../../widgets/mission_footer_bar.dart';
|
|
import '../../widgets/mission_map.dart';
|
|
import '../../widgets/waypoint_list_panel.dart';
|
|
import '../../widgets/warnings_panel.dart';
|
|
|
|
/// Fly-Modus (Architektur-Doku 3.10/3.11): zeigt dieselbe Karte + Fusszeile
|
|
/// wie der Plan-Modus, aber ohne Reticle/Draeder - im Flug wird die Route
|
|
/// beobachtet statt neu geplant. Der Kartencontroller ist derselbe
|
|
/// app-lebenslange Provider wie im Plan-Modus (Doku: mapControllerProvider),
|
|
/// die Kameraposition bleibt also beim Umschalten erhalten. Wegpunkte lassen
|
|
/// sich weiterhin ueber die Liste anpassen (z.B. spontane Hoehenaenderung),
|
|
/// nur das Karten-Reticle zum Anlegen/Verschieben entfaellt hier.
|
|
class FlyScreen extends ConsumerStatefulWidget {
|
|
const FlyScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<FlyScreen> createState() => _FlyScreenState();
|
|
}
|
|
|
|
class _FlyScreenState extends ConsumerState<FlyScreen> {
|
|
late final MapController _mapController;
|
|
StreamSubscription<MapEvent>? _mapEventSub;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_mapController = ref.read(mapControllerProvider);
|
|
_mapEventSub = _mapController.mapEventStream.listen(_onMapEvent);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_mapEventSub?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
/// Jede manuelle Kartengeste (Pan/Pinch/Fling, ...) bricht den ueber den
|
|
/// GPS-Knopf in der Kopfleiste (FlyMapControls) aktivierten Folgemodus ab
|
|
/// - nur Bewegungen mit source == mapController sind programmatisch
|
|
/// (durch die Telemetrie-Nachfuehrung unten selbst oder andere
|
|
/// Kartensprmuenge ausgeloest) und sollen ihn nicht beenden.
|
|
void _onMapEvent(MapEvent event) {
|
|
if (ref.read(followDroneProvider) &&
|
|
event.source != MapEventSource.mapController) {
|
|
ref.read(followDroneProvider.notifier).set(false);
|
|
}
|
|
}
|
|
|
|
void _onCenterOnWaypoint(int index) {
|
|
final waypoints = ref.read(currentMissionProvider);
|
|
if (index < 0 || index >= waypoints.length) return;
|
|
final wp = waypoints[index];
|
|
// Programmatischer Sprung (source == mapController) - _onMapEvent
|
|
// erkennt das nicht als manuelle Geste, der Folgemodus muss also
|
|
// explizit beendet werden (sonst zieht der naechste Telemetrie-Frame
|
|
// die Kamera sofort wieder vom antippten Wegpunkt weg).
|
|
ref.read(followDroneProvider.notifier).set(false);
|
|
_mapController.move(LatLng(wp.lat, wp.lon), _mapController.camera.zoom);
|
|
}
|
|
|
|
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: -1,
|
|
initialTab: initialTab,
|
|
onClose: () => Navigator.of(routeContext).pop(),
|
|
onCenterOnWaypoint: (index) {
|
|
_onCenterOnWaypoint(index);
|
|
Navigator.of(routeContext).pop();
|
|
},
|
|
onDelete: (index) =>
|
|
ref.read(currentMissionProvider.notifier).removeAt(index),
|
|
onAltitudeChanged: (index, value) => ref
|
|
.read(currentMissionProvider.notifier)
|
|
.setAltitude(index, value),
|
|
onSpeedChanged: (index, value) =>
|
|
ref.read(currentMissionProvider.notifier).setSpeed(index, value),
|
|
onCatchRadiusChanged: (index, value) => ref
|
|
.read(currentMissionProvider.notifier)
|
|
.setCatchRadius(index, value),
|
|
onActionChanged: (index, action) =>
|
|
ref.read(currentMissionProvider.notifier).setAction(index, action),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
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 droneProfile = ref.watch(activeDroneProfileProvider);
|
|
final telemetry = ref.watch(telemetryProvider).value;
|
|
final followDrone = ref.watch(followDroneProvider);
|
|
|
|
// Solange der ueber den GPS-Knopf in der Kopfleiste (FlyMapControls)
|
|
// aktivierte Folgemodus aktiv ist, jede neue Telemetrie-Position
|
|
// programmatisch nachziehen (source == mapController, siehe
|
|
// _onMapEvent - bricht den Modus dadurch nicht selbst wieder ab).
|
|
ref.listen<AsyncValue<TelemetryFrame>>(telemetryProvider, (previous, next) {
|
|
if (!followDrone) return;
|
|
final frame = next.value;
|
|
if (frame != null && frame.hasFix) {
|
|
_mapController.move(LatLng(frame.lat, frame.lon), _mapController.camera.zoom);
|
|
}
|
|
});
|
|
|
|
final routeGeometry = buildRouteGeometry(
|
|
waypoints,
|
|
minTurnRadius: droneProfile.minTurnRadius,
|
|
maxClimbRate: droneProfile.maxClimbRate,
|
|
maxDescentRate: droneProfile.maxDescentRate,
|
|
);
|
|
final missionStats = computeMissionStats(waypoints);
|
|
final safeEdgeInset = math.max(
|
|
MediaQuery.of(context).padding.left,
|
|
MediaQuery.of(context).padding.right,
|
|
);
|
|
// Gleiche Reservierung fuer die Fusszeile wie die Rad-Readouts im
|
|
// Plan-Modus (PlanScreen: wheelBottomInset), damit die Alt/Speed-
|
|
// Anzeige zwischen Kopf- und Fusszeile vertikal zentriert erscheint,
|
|
// statt am Kartenrand zu kleben.
|
|
final footerBottomInset = math.max(
|
|
MediaQuery.of(context).size.height * 0.06,
|
|
124.0,
|
|
);
|
|
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,
|
|
);
|
|
|
|
// Vor der ersten Telemetrie (Verbindungsaufbau laeuft noch, oder Mock
|
|
// hat noch nicht getickt) und ohne GPS-Fix (TelemetryFrame wird bewusst
|
|
// nicht auf einen Fix gegated, siehe dortige Doku - ohne Fix meldet der
|
|
// FC aber oft (0,0) oder eine veraltete Position) auf dem ersten
|
|
// Wegpunkt statt an einer erfundenen Stelle anzeigen.
|
|
final droneMarker = telemetry != null && telemetry.hasFix
|
|
? Marker(
|
|
point: LatLng(telemetry.lat, telemetry.lon),
|
|
width: 40,
|
|
height: 40,
|
|
alignment: Alignment.center,
|
|
child: DroneMarkerIcon(headingDeg: telemetry.headingDeg),
|
|
)
|
|
: waypoints.isEmpty
|
|
? null
|
|
: Marker(
|
|
point: LatLng(waypoints.first.lat, waypoints.first.lon),
|
|
width: 40,
|
|
height: 40,
|
|
alignment: Alignment.center,
|
|
child: const DroneMarkerIcon(),
|
|
);
|
|
|
|
// Gestrichelte Linie von der Drohne zum aktiven Wegpunkt (Doku 3.4:
|
|
// TelemetryFrame.activeWaypointIndex aus MSP_NAV_STATUS) - derselbe
|
|
// Darstellungsstil wie das Reticle-Gummiband im Plan-Modus (MissionMap.
|
|
// rubberBandLine), hier aber Drohne -> naechster Wegpunkt statt letzter
|
|
// Wegpunkt -> Kartenmitte.
|
|
List<LatLng>? rubberBand;
|
|
final activeWpIndex = telemetry?.activeWaypointIndex;
|
|
if (telemetry != null &&
|
|
telemetry.hasFix &&
|
|
activeWpIndex != null &&
|
|
activeWpIndex >= 0 &&
|
|
activeWpIndex < waypoints.length) {
|
|
final target = waypoints[activeWpIndex];
|
|
rubberBand = [
|
|
LatLng(telemetry.lat, telemetry.lon),
|
|
LatLng(target.lat, target.lon),
|
|
];
|
|
}
|
|
|
|
// Live-Missionsstatus fuer die Details-Pille (Doku: "aktiver Wegpunkt
|
|
// 3/23, Distanz und Zeit zum naechsten Wegpunkt" statt der statischen
|
|
// Plan-Werte) - siehe FlyMissionStatus-Klassendoku dazu, warum das aus
|
|
// Position+Wegpunktkoordinaten berechnet wird statt direkt von MSP
|
|
// abgefragt.
|
|
final flyMissionStatus = computeFlyMissionStatus(
|
|
waypoints: waypoints,
|
|
activeWaypointIndex: activeWpIndex,
|
|
currentLat: telemetry != null && telemetry.hasFix ? telemetry.lat : null,
|
|
currentLon: telemetry != null && telemetry.hasFix ? telemetry.lon : null,
|
|
currentSpeedMs: telemetry?.speedMs,
|
|
);
|
|
|
|
// Drohnen-Status fuer die Drohnen-Pille (Doku: "im fly modus zeigt die
|
|
// drone pille den zustand der drohne") - null solange keine Telemetrie
|
|
// vorliegt, dann zeigt BottomStatsBar weiterhin den Drohnennamen-Chip.
|
|
final droneStatus = telemetry != null
|
|
? computeDroneStatus(
|
|
armed: telemetry.armed,
|
|
hasFix: telemetry.hasFix,
|
|
numSat: telemetry.numSat,
|
|
hdop: telemetry.hdop,
|
|
linkQuality: telemetry.linkQuality,
|
|
snrDb: telemetry.snrDb,
|
|
navMode: telemetry.navMode,
|
|
)
|
|
: null;
|
|
|
|
return Stack(
|
|
children: [
|
|
MissionMap(
|
|
mapController: _mapController,
|
|
routeSegments: routeGeometry.segments,
|
|
rubberBandLine: rubberBand,
|
|
waypointMarkers: [
|
|
for (var i = 0; i < waypoints.length; i++)
|
|
CircleMarker(
|
|
point: LatLng(waypoints[i].lat, waypoints[i].lon),
|
|
radius: 6,
|
|
color: i < routeGeometry.vertexBad.length && routeGeometry.vertexBad[i]
|
|
? const Color(0xFFE2574A)
|
|
: const Color(0xFF2B2B28),
|
|
borderStrokeWidth: 1.5,
|
|
borderColor: Colors.white,
|
|
),
|
|
],
|
|
droneMarker: droneMarker,
|
|
),
|
|
// Live-Hoehe/-Geschwindigkeit aus der Telemetrie (HTML-Demonstrator:
|
|
// #altReadout/#spdReadout im Fly-Modus) - anders als dort keine
|
|
// SET-Zeile, weil es im Waypoint-Fly-Modus kein editierbares
|
|
// Rad-Widget (curAlt/curSpeed) gibt, dessen Wert sinnvoll waere.
|
|
Positioned(
|
|
top: 56,
|
|
bottom: footerBottomInset,
|
|
left: 14 + safeEdgeInset,
|
|
child: Center(child: _liveReadout('Alt', telemetry?.altitudeM, 'm')),
|
|
),
|
|
Positioned(
|
|
top: 56,
|
|
bottom: footerBottomInset,
|
|
right: 14 + safeEdgeInset,
|
|
child: Center(child: _liveReadout('Speed', telemetry?.speedMs, 'm/s')),
|
|
),
|
|
Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
child: MissionFooterBar(
|
|
waypoints: waypoints,
|
|
legClimbBad: routeGeometry.legClimbBad,
|
|
vertexBad: routeGeometry.vertexBad,
|
|
activeIndex: -1,
|
|
totalDistanceM: missionStats.distanceM,
|
|
totalDurationSec: missionStats.durationSec,
|
|
flyMissionStatus: flyMissionStatus,
|
|
droneStatus: droneStatus,
|
|
warnings: warnings,
|
|
showWarningsBanner: false,
|
|
batteryPercent: telemetry?.batteryPercent,
|
|
onDetailsTap: _openWaypointList,
|
|
onWarningsTap: () => _openWarningsPanel(warnings),
|
|
onSettingsTap: () => Navigator.of(context).push(MaterialPageRoute(
|
|
builder: (_) => const SettingsScreen(),
|
|
)),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _liveReadout(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 != null ? '${value.round()} $unit' : '—'}',
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|