Fly mode previously showed just a black placeholder. It now renders the same MissionMap (route + waypoint markers) and footer (warnings banner, altitude profile, BottomStatsBar) as the Plan screen, minus the reticle/wheels since flying observes the route rather than replanning it. Waypoints can still be edited via the details list for spontaneous in-flight changes. Live drone position comes from a new telemetryProvider/ flightControllerLinkProvider pair (autoDispose), the first place FlightControllerLink/TelemetryFrame get wired into the UI - currently backed by MockFlightControllerLink until a real MSP transport exists (Doku 4.19). A drone marker renders on the map once a telemetry frame arrives. The Fly-mode header keeps the fit-to-area button but replaces "zoom to home" with "zoom to drone" (FlyMapControls) - the first waypoint isn't a meaningful reference point anymore once airborne. Extracted computeMissionStats and MissionFooterBar out of PlanScreen so Plan/Fly share the exact same stats/footer logic instead of duplicating it, and extracted NavIconButton out of MapSearchControls so both Plan's and Fly's map controls use the same button widget. Widget tests that now mount FlyScreen switch from the ProviderScope-based _wrap() helper to a manual ProviderContainer with an explicit FlightControllerLink.disconnect() call before test end - the mock's Timer.periodic doesn't get cancelled by container disposal alone, and flutter_test's pending-timer check runs before addTearDown callbacks. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
571 lines
21 KiB
Dart
571 lines
21 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_stats.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/halo_menu.dart';
|
|
import '../../widgets/mission_footer_bar.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));
|
|
}
|
|
},
|
|
),
|
|
));
|
|
}
|
|
|
|
@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 = computeMissionStats(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: MissionFooterBar(
|
|
waypoints: waypoints,
|
|
legClimbBad: routeGeometry.legClimbBad,
|
|
vertexBad: routeGeometry.vertexBad,
|
|
activeIndex: _mode == _ReticleMode.idle ? -1 : _targetIndex,
|
|
totalDistanceM: missionStats.distanceM,
|
|
totalDurationSec: missionStats.durationSec,
|
|
warnings: warnings,
|
|
onDetailsTap: _openWaypointList,
|
|
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;
|
|
}
|
|
}
|