231 lines
8.7 KiB
Dart
231 lines
8.7 KiB
Dart
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/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/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 ConsumerWidget {
|
|
const FlyScreen({super.key});
|
|
|
|
void _onCenterOnWaypoint(WidgetRef ref, int index) {
|
|
final waypoints = ref.read(currentMissionProvider);
|
|
if (index < 0 || index >= waypoints.length) return;
|
|
final wp = waypoints[index];
|
|
final controller = ref.read(mapControllerProvider);
|
|
controller.move(LatLng(wp.lat, wp.lon), controller.camera.zoom);
|
|
}
|
|
|
|
void _openWaypointList(
|
|
BuildContext context,
|
|
WidgetRef ref, {
|
|
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(ref, 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(
|
|
BuildContext context,
|
|
WidgetRef ref,
|
|
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(context, ref, initialTab: PanelTab.altitude);
|
|
case WarningAction.speed:
|
|
_openWaypointList(context, ref, initialTab: PanelTab.speed);
|
|
case WarningAction.map:
|
|
fitMapToWaypoints(ref, ref.read(currentMissionProvider));
|
|
}
|
|
},
|
|
),
|
|
));
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final waypoints = ref.watch(currentMissionProvider);
|
|
final droneProfile = ref.watch(activeDroneProfileProvider);
|
|
final telemetry = ref.watch(telemetryProvider).value;
|
|
|
|
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,
|
|
);
|
|
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: 28,
|
|
height: 28,
|
|
alignment: Alignment.center,
|
|
child: DroneMarkerIcon(headingDeg: telemetry.headingDeg),
|
|
)
|
|
: waypoints.isEmpty
|
|
? null
|
|
: Marker(
|
|
point: LatLng(waypoints.first.lat, waypoints.first.lon),
|
|
width: 28,
|
|
height: 28,
|
|
alignment: Alignment.center,
|
|
child: const DroneMarkerIcon(),
|
|
);
|
|
|
|
return Stack(
|
|
children: [
|
|
MissionMap(
|
|
mapController: ref.watch(mapControllerProvider),
|
|
routeSegments: routeGeometry.segments,
|
|
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,
|
|
left: 14 + safeEdgeInset,
|
|
child: _liveReadout('Alt', telemetry?.altitudeM, 'm'),
|
|
),
|
|
Positioned(
|
|
top: 56,
|
|
right: 14 + safeEdgeInset,
|
|
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,
|
|
warnings: warnings,
|
|
onDetailsTap: () => _openWaypointList(context, ref),
|
|
onWarningsTap: () => _openWarningsPanel(context, ref, 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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|