Kartendrehung (Doku 4.6): flutter_map's Zwei-Finger-Twist-Geste bewusst
deaktiviert (InteractionOptions mit InteractiveFlag.all & ~rotate in
mission_map.dart) - die Drohne fliegt nordorientiert, eine gedrehte Karte
wuerde die Peilung von Wegpunkten/Reticle nur verwirren, zumal der HTML-
Demonstrator gar keine Drehung kennt.
Warnungsliste (Doku 3.10), analog dem #warningsBtn/#warningsPanel-Flow im
HTML-Demonstrator:
- domain/mission/mission_warnings.dart: computeMissionWarnings() sammelt
alle aktuell zutreffenden Probleme (Route-Geometrie ausserhalb der
Drohnenfaehigkeiten, zu starker Wind, zu geringer Bodenabstand,
Reichweite/Ausdauer ueberschritten) zu einer Liste aus {text, action}-
Eintraegen - reine Funktion aus bereits vorhandenen Zwischenergebnissen,
unabhaengig von Riverpod/Widgets testbar.
- domain/wind/wind_math.dart: computeLegWindBad() ergaenzt (Bodenge-
schwindigkeit <= 1 m/s oder Windgeschwindigkeit > Drohnen-Maximum) -
bislang gab es nur die Windkomponente/Bodengeschwindigkeit selbst, aber
keine "zu stark"-Markierung.
- ui/widgets/warnings_panel.dart: Vollbild-Liste, jede Zeile verweist per
Aktionslabel auf den Loesungs-Screen ("Open altitude view"/"Open speed
view"/"Show on map").
- ui/widgets/bottom_stats_bar.dart: roter Warnungs-Button neben den
Mission-/Drohnen-Chips, nur sichtbar solange Warnungen aktiv sind.
- ui/widgets/waypoint_list_panel.dart: `_PanelTab` zu `PanelTab` public
gemacht plus neuer `initialTab`-Parameter, damit das Panel direkt auf
dem Altitude- oder Speed-Tab geoeffnet werden kann statt immer auf der
Liste zu starten.
- ui/providers/map_controller_provider.dart: fitMapToWaypoints()-Hilfs-
funktion extrahiert und in MapSearchControls' Fit-Button (vorher eigene
Kopie der Logik) sowie fuer die "Show on map"-Warnungsaktion
wiederverwendet.
- ui/screens/plan/plan_screen.dart: berechnet die Warnungsliste, ersetzt
den bisherigen Einzel-Banner (nur "Route exceeds...") durch alle
aktuellen Warnungstexte (durch " · " getrennt, wie im HTML-Demonstrator)
und verdrahtet Warnungs-Button/-Liste mit der passenden Navigation.
Getestet: 4 neue Unit-Tests fuer computeLegWindBad, 7 fuer
computeMissionWarnings (inkl. Grenzfaelle: veraltetes Gelaendeprofil,
kein Limit bei maxRangeM/maxEnduranceMin <= 0), 2 neue Widget-Tests
(Warnungs-Button erscheint bei Reichweiten-Ueberschreitung und oeffnet
die Liste; Antippen einer Wind-Warnung oeffnet den Speed-Tab direkt).
Alle 110 Tests sowie flutter analyze bestehen. Manuell auf dem
Pixel_10a-Emulator verifiziert: Steigraten-Ueberschreitung (Altitude auf
220m bei kurzer Distanz) macht Route/Banner/Warnungs-Button rot,
Warnungsliste zeigt den Eintrag mit "Show on map", Antippen schliesst
die Liste und zoomt die Karte korrekt auf die Bounding-Box beider
Wegpunkte.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
618 lines
22 KiB
Dart
618 lines
22 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/default_drone_profile.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/bottom_stats_bar.dart';
|
|
import '../../widgets/halo_menu.dart';
|
|
import '../../widgets/mini_altitude_profile.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<PlanScreen> createState() => _PlanScreenState();
|
|
}
|
|
|
|
class _PlanScreenState extends ConsumerState<PlanScreen> {
|
|
// 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<MapEvent>? _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<void>(
|
|
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<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));
|
|
}
|
|
},
|
|
),
|
|
));
|
|
}
|
|
|
|
({double distanceM, double durationSec}) _missionStats(
|
|
List<Waypoint> 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 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 = _missionStats(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<LatLng>? 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: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (warnings.isNotEmpty)
|
|
Container(
|
|
width: double.infinity,
|
|
color: const Color(0xFF7C2D12),
|
|
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12),
|
|
child: Text(
|
|
warnings.map((w) => w.text).join(' · '),
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(color: Color(0xFFFDE3D0), fontSize: 12),
|
|
),
|
|
),
|
|
Container(
|
|
width: double.infinity,
|
|
color: const Color(0xFF0F0F0F).withValues(alpha: 0.72),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(8, 6, 8, 0),
|
|
child: MiniAltitudeProfile(
|
|
waypoints: waypoints,
|
|
legClimbBad: routeGeometry.legClimbBad,
|
|
vertexBad: routeGeometry.vertexBad,
|
|
activeIndex:
|
|
_mode == _ReticleMode.idle ? -1 : _targetIndex,
|
|
),
|
|
),
|
|
BottomStatsBar(
|
|
waypointCount: waypoints.length,
|
|
totalDistanceM: missionStats.distanceM,
|
|
totalDurationSec: missionStats.durationSec,
|
|
onTap: _openWaypointList,
|
|
warnings: warnings,
|
|
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;
|
|
}
|
|
}
|