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>
275 lines
9.1 KiB
Dart
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)),
|
|
]),
|
|
),
|
|
);
|
|
}
|