Files
dmc/app/lib/ui/widgets/mission_map.dart
T
Constantin LeueandClaude Sonnet 5 310d92f187 Show mission map and footer in Fly mode, add area/drone zoom buttons
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>
2026-07-30 07:45:36 +02:00

132 lines
4.5 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../domain/mission/route_geometry.dart';
/// Vollbild-OSM-Karte, entspricht dem Leaflet-`#map` im HTML-Demonstrator:
/// randlos, ohne Zoom-Buttons (Pinch-to-Zoom/Pan reicht), Standard-OSM-Tiles.
class MissionMap extends StatelessWidget {
const MissionMap({
super.key,
this.mapController,
this.waypointMarkers = const [],
this.windMarkers = const [],
this.routeSegments = const [],
this.rubberBandLine,
this.droneMarker,
this.onMapReady,
});
final MapController? mapController;
/// Kreis-Marker fuer die Wegpunkte der aktuellen Mission (Doku 3.7).
final List<CircleMarker> waypointMarkers;
/// Wind-Anzeigen je Wegpunkt (Doku 3.8), nur befuellt wenn die
/// Kopfleisten-Pille aktiv ist und fuer den jeweiligen Wegpunkt Wind
/// geladen wurde (HTML-Demonstrator: showPerWaypointWind).
final List<Marker> windMarkers;
/// Live-Position der Drohne im Fly-Modus (Doku 3.10), null solange noch
/// keine Telemetrie eingetroffen ist.
final Marker? droneMarker;
/// Geraden + Kreisbogen-Segmente des Flugpfads (Doku 3.7/4.6); rot
/// markierte Segmente ueberschreiten Steig-/Sinkrate oder Kurvenradius.
final List<RouteSegment> routeSegments;
/// Gestrichelte Vorschaulinie vom letzten Wegpunkt zur aktuellen
/// Kartenmitte, waehrend noch kein neuer Punkt bestaetigt wurde.
final List<LatLng>? rubberBandLine;
final VoidCallback? onMapReady;
// Default-Zentrum/Zoom uebernommen aus dem HTML-Demonstrator
// (Drone Mission Control.html: L.map(...).setView([52.3676, 4.9041], 16)).
static const _initialCenter = LatLng(52.3676, 4.9041);
static const _initialZoom = 16.0;
@override
Widget build(BuildContext context) {
return FlutterMap(
mapController: mapController,
options: MapOptions(
initialCenter: _initialCenter,
initialZoom: _initialZoom,
minZoom: 3,
maxZoom: 19,
// Kartendrehung (Zwei-Finger-Twist) bewusst deaktiviert - die Drohne
// fliegt nordorientiert, eine gedrehte Karte wuerde die Peilung der
// Wegpunkte/des Reticles gegenueber Kompass/HTML-Demonstrator (der
// gar keine Drehung kennt) nur verwirren.
interactionOptions: const InteractionOptions(
flags: InteractiveFlag.all & ~InteractiveFlag.rotate,
),
onMapReady: onMapReady,
),
children: [
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'de.dmc.dmc_app',
maxZoom: 19,
),
PolylineLayer(
polylines: [
for (final segment in routeSegments)
Polyline(
points: segment.points,
strokeWidth: 3,
color: segment.bad ? const Color(0xFFE2574A) : Colors.white,
),
if (rubberBandLine != null)
Polyline(
points: rubberBandLine!,
strokeWidth: 2,
color: Colors.white.withValues(alpha: 0.55),
pattern: StrokePattern.dashed(segments: const [6.0, 6.0]),
),
],
),
CircleLayer(circles: waypointMarkers),
MarkerLayer(markers: [
...windMarkers,
?droneMarker,
]),
const _CompactAttribution(),
],
);
}
}
/// Schlanke Pflicht-Attribution fuer die OSM-Kacheln (Tile Usage Policy) -
/// bewusst ohne den "flutter_map |"-Praefix von SimpleAttributionWidget und
/// dezenter gestaltet, damit sie nicht wie ein zweiter Balken ueber der
/// Bottom-Bar wirkt. Bleibt sichtbar und antippbar, da die Policy sichtbare
/// Namensnennung verlangt.
class _CompactAttribution extends StatelessWidget {
const _CompactAttribution();
@override
Widget build(BuildContext context) {
return SafeArea(
child: Align(
alignment: Alignment.bottomLeft,
child: GestureDetector(
onTap: () =>
launchUrl(Uri.parse('https://www.openstreetmap.org/copyright')),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
color: Colors.white.withValues(alpha: 0.5),
child: const Text(
'© OpenStreetMap contributors',
style: TextStyle(fontSize: 9, color: Colors.black87),
),
),
),
),
);
}
}