Files
PilotVault/Fly App/lib/ui/fly/bottom/minimap.dart
T
tajniak81andClaude Opus 5.5 43a66df8df Give the Fly App GO 4-style intelligent modes and a Waypoints editor
P7a of the GO 4-parity plan. The rail's GRID button becomes MODES, which
opens a picker of the product's intelligent flight modes with first-use
terms; Waypoints is the first one built.

Waypoints opens on the big map: tap to add waypoints or POIs, long-press
to drag, "+" to insert on a leg, and a point editor for altitude, speed,
heading (route / custom / POI), gimbal (set or aim at POI), camera action
and hover, and turn radius, with a swipe-to-adjust readout. Route settings,
a local mission library and undo sit in a tool column. Start runs the
SafetyGate's pre-start check in the slide-to-confirm dialog, then uploads
and starts; a run panel shows upload and flight progress with Pause,
Resume and Stop.

MissionBridge now listens to the waypoint operator and builds the full V1
mission (per-waypoint headings, turn direction, gimbal pitch, actions).
Not yet run on a simulator or aircraft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:22:53 +02:00

275 lines
9.1 KiB
Dart

import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart' show LatLng;
import '../../../flight_model.dart';
import '../../../fly/flight_trail.dart';
import '../../../fly/fly_prefs.dart';
import '../../../fly/state/aircraft_state.dart';
import '../../../theme.dart';
import '../../pv_icons.dart';
import '../hud_kit.dart';
/// What the minimap shows and how it follows the aircraft. Shared between the
/// map layer and its HUD controls, which live in different parts of the tree.
class MinimapController extends ChangeNotifier {
/// Keep the aircraft centred. A pan by hand turns it off; recentring turns it back on.
bool follow = true;
/// Rotate the map so the aircraft's heading points up, instead of north.
bool headingUp = false;
void setFollow(bool on) {
if (follow == on) return;
follow = on;
notifyListeners();
}
void toggleHeadingUp() {
headingUp = !headingUp;
follow = true;
notifyListeners();
}
}
/// The map layer: OpenStreetMap tiles (like `map_page.dart`), the flown trail,
/// home, pilot and the aircraft. Used both as the small picture-in-picture and,
/// after the swap, full-bleed behind the HUD — where it is also interactive.
///
/// An intelligent mode adds its own [layers] (plan P7: the waypoint route)
/// between the trail and the aircraft, and takes map taps through [onTap].
class FlyMap extends StatefulWidget {
const FlyMap({
super.key,
required this.model,
required this.trail,
required this.controller,
required this.interactive,
this.layers = const <Widget>[],
this.onTap,
});
final FlightModel model;
final FlightTrail trail;
final MinimapController controller;
final bool interactive;
final List<Widget> layers;
/// A tap on the map itself (not on a marker), while [interactive].
final ValueChanged<LatLng>? onTap;
@override
State<FlyMap> createState() => _FlyMapState();
}
class _FlyMapState extends State<FlyMap> {
final MapController _map = MapController();
bool _ready = false;
AircraftState get _a => widget.model.aircraft;
LatLng? get _aircraft => _point(_a.latitude, _a.longitude);
LatLng? get _home => _a.homeLocationSet == false ? null : _point(_a.homeLatitude, _a.homeLongitude);
LatLng? get _pilot => _point(widget.model.phoneLatitude, widget.model.phoneLongitude);
static LatLng? _point(double? lat, double? lon) =>
lat == null || lon == null || (lat == 0 && lon == 0) ? null : LatLng(lat, lon);
@override
void initState() {
super.initState();
_a.addListener(_follow);
widget.controller.addListener(_follow);
}
@override
void didUpdateWidget(FlyMap old) {
super.didUpdateWidget(old);
if (old.controller != widget.controller) {
old.controller.removeListener(_follow);
widget.controller.addListener(_follow);
}
}
@override
void dispose() {
_a.removeListener(_follow);
widget.controller.removeListener(_follow);
super.dispose();
}
/// Moves the camera with the aircraft — outside build, as a listener, so a
/// 10 Hz tick does not rebuild the tile layer.
void _follow() {
if (!_ready || !widget.controller.follow) return;
final LatLng? target = _aircraft ?? _pilot ?? _home;
if (target == null) return;
final double rotation = widget.controller.headingUp ? -(_a.heading ?? 0) : 0;
_map.moveAndRotate(target, _map.camera.zoom, rotation);
}
@override
Widget build(BuildContext context) {
final LatLng? start = _aircraft ?? _pilot ?? _home;
if (start == null) {
return Container(
color: const Color(0xFF0E1726),
alignment: Alignment.center,
child: const Text('No position', style: HudText.label),
);
}
return FlutterMap(
mapController: _map,
options: MapOptions(
initialCenter: start,
initialZoom: 17,
backgroundColor: const Color(0xFF0E1726),
interactionOptions: InteractionOptions(
flags: widget.interactive ? InteractiveFlag.all & ~InteractiveFlag.rotate : InteractiveFlag.none,
),
onMapReady: () {
_ready = true;
_follow();
},
onPositionChanged: (MapCamera _, bool hasGesture) {
if (hasGesture) widget.controller.setFollow(false);
},
onTap: widget.interactive && widget.onTap != null ? (TapPosition _, LatLng at) => widget.onTap!(at) : null,
),
children: <Widget>[
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.pilotvault.flyapp',
),
ListenableBuilder(
listenable: Listenable.merge(<Listenable>[widget.trail, FlyPrefs.showRoute]),
builder: (BuildContext context, _) {
final List<LatLng> pts = widget.trail.points;
if (pts.length < 2 || !FlyPrefs.showRoute.value) return const SizedBox.shrink();
return PolylineLayer<Object>(polylines: <Polyline<Object>>[
Polyline<Object>(points: pts, strokeWidth: 2.5, color: const Color(0xFFF4C542)),
]);
},
),
...widget.layers,
ListenableBuilder(
listenable: _a,
builder: (BuildContext context, _) => MarkerLayer(markers: <Marker>[
if (_home case final LatLng h) Marker(point: h, width: 18, height: 18, child: const _HomeMarker()),
if (_pilot case final LatLng p)
Marker(
point: p,
width: 12,
height: 12,
child: Container(
decoration: BoxDecoration(
color: const Color(0xFF5B93F5),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 1.5),
),
),
),
if (_aircraft case final LatLng ac)
Marker(
point: ac,
width: 22,
height: 22,
// Markers turn with the map (Marker.rotate is off), so the
// true heading is right in both north-up and heading-up.
child: Transform.rotate(
angle: (_a.heading ?? 0) * math.pi / 180,
child: const _AircraftMarker(),
),
),
]),
),
],
);
}
}
class _HomeMarker extends StatelessWidget {
const _HomeMarker();
@override
Widget build(BuildContext context) => Container(
decoration: const BoxDecoration(color: Glass.sat, shape: BoxShape.circle),
alignment: Alignment.center,
child: const Text('H', style: TextStyle(fontFamily: PV.fontMono, fontSize: 10, fontWeight: FontWeight.w700, color: Color(0xFF0A1120))),
);
}
class _AircraftMarker extends StatelessWidget {
const _AircraftMarker();
@override
Widget build(BuildContext context) => const CustomPaint(painter: _ArrowPainter());
}
class _ArrowPainter extends CustomPainter {
const _ArrowPainter();
@override
void paint(Canvas canvas, Size size) {
final double w = size.width, h = size.height;
final Path p = Path()
..moveTo(w / 2, 0)
..lineTo(w * 0.85, h * 0.9)
..lineTo(w / 2, h * 0.7)
..lineTo(w * 0.15, h * 0.9)
..close();
canvas.drawPath(p, Paint()..color = Colors.white);
canvas.drawPath(p, Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.2
..color = const Color(0xFF0A1120));
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
/// The minimap's buttons — north / heading lock, recentre, clear path — shown
/// in the HUD while the map is the big view.
class MinimapControls extends StatelessWidget {
const MinimapControls({super.key, required this.controller, required this.trail});
final MinimapController controller;
final FlightTrail trail;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: controller,
builder: (BuildContext context, _) => Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
_btn(controller.headingUp ? 'compass' : 'chevronDown', controller.headingUp ? 'HDG' : 'N', controller.toggleHeadingUp,
active: controller.headingUp),
const SizedBox(height: 8),
_btn('crosshair', 'CTR', () => controller.setFollow(true), active: controller.follow),
const SizedBox(height: 8),
_btn('route', 'CLR', trail.clear),
]),
);
}
Widget _btn(String icon, String label, VoidCallback onTap, {bool active = false}) => GestureDetector(
onTap: onTap,
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: active ? Glass.accent : Glass.pillStrong,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Glass.hairline),
),
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[
PVIcon(icon, size: 14, color: Glass.ink),
const SizedBox(height: 1),
Text(label, style: HudText.label.copyWith(color: Glass.ink, fontSize: 8)),
]),
),
);
}