Files
PilotVault/Fly App/lib/ui/map_page.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

239 lines
9.4 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import '../dji_service.dart';
import '../flight_model.dart';
import '../fly/modes/waypoint_mission.dart';
import '../theme.dart';
import 'pv_icons.dart';
/// Map & waypoints — mirrors the v2 "Map & waypoints" mockup. Real OpenStreetMap
/// tiles (no API key); tapping the map in Pin mode drops real-GPS waypoints, and
/// "Run route" uploads + starts a Waypoint mission via [DjiService].
class MapPage extends StatefulWidget {
const MapPage({super.key, required this.model, required this.dji});
final FlightModel model;
final DjiService dji;
@override
State<MapPage> createState() => _MapPageState();
}
class _MapPageState extends State<MapPage> {
final MapController _map = MapController();
final List<LatLng> _waypoints = <LatLng>[];
bool _pinMode = true;
double _altitude = 50;
double _speed = 8;
DjiService get _dji => widget.dji;
FlightModel get _m => widget.model;
LatLng get _fallback => const LatLng(37.7749, -122.4194);
LatLng? get _drone => (_m.latitude != null && _m.longitude != null) ? LatLng(_m.latitude!, _m.longitude!) : null;
LatLng? get _home => (_m.homeLatitude != null && _m.homeLongitude != null) ? LatLng(_m.homeLatitude!, _m.homeLongitude!) : null;
@override
void initState() {
super.initState();
SystemChrome.setPreferredOrientations(<DeviceOrientation>[DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
}
void _snack(String msg) {
ScaffoldMessenger.of(context)
..clearSnackBars()
..showSnackBar(SnackBar(content: Text(msg)));
}
void _onTap(TapPosition pos, LatLng latlng) {
if (!_pinMode) return;
setState(() => _waypoints.add(latlng));
}
Future<void> _runRoute() async {
if (_waypoints.length < 2) {
_snack('Drop at least 2 waypoints first');
return;
}
final WaypointMissionSpec mission = WaypointMissionSpec(
points: <WaypointSpec>[for (final LatLng p in _waypoints) WaypointSpec(at: p, altitude: _altitude)],
cruiseSpeed: _speed,
);
try {
await _dji.uploadWaypointMission(mission.toBridge());
await _dji.startWaypointMission();
if (mounted) _snack('Route running — ${_waypoints.length} waypoints');
} catch (e) {
if (mounted) _snack('Route: ${e is PlatformException ? (e.message ?? e.code) : e}');
}
}
@override
Widget build(BuildContext context) {
final LatLng center = _drone ?? _home ?? _fallback;
return Scaffold(
backgroundColor: const Color(0xFF0E1726),
body: Stack(children: <Widget>[
FlutterMap(
mapController: _map,
options: MapOptions(initialCenter: center, initialZoom: 16, onTap: _onTap),
children: <Widget>[
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.pilotvault.flyapp',
),
if (_waypoints.length >= 2)
PolylineLayer<Object>(polylines: <Polyline<Object>>[
Polyline<Object>(points: _waypoints, strokeWidth: 3, color: const Color(0xFF5B93F5)),
]),
MarkerLayer(markers: _markers()),
],
),
SafeArea(
child: Stack(children: <Widget>[
Positioned(top: 12, left: 14, child: _back()),
Positioned(left: 14, top: 58, child: _toolRail()),
Positioned(right: 16, top: 58, child: _routePanel()),
]),
),
]),
);
}
List<Marker> _markers() {
final List<Marker> m = <Marker>[];
for (int i = 0; i < _waypoints.length; i++) {
m.add(Marker(
point: _waypoints[i],
width: 26, height: 26,
child: Container(
decoration: const BoxDecoration(color: Color(0xFF3D7BF0), shape: BoxShape.circle),
alignment: Alignment.center,
child: Text('${i + 1}', style: const TextStyle(fontFamily: PV.fontMono, fontSize: 12, fontWeight: FontWeight.w700, color: Colors.white)),
),
));
}
if (_home != null) {
m.add(Marker(point: _home!, width: 24, height: 24, child: const _Dot(Color(0xFF7FE0B0), 'home')));
}
if (_drone != null) {
m.add(Marker(point: _drone!, width: 24, height: 24, child: const _Dot(Color(0xFFF4C542), 'drone')));
}
return m;
}
Widget _back() {
return GestureDetector(
onTap: () => Navigator.of(context).maybePop(),
child: Container(
height: 30, width: 30,
decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(8)),
alignment: Alignment.center,
child: const PVIcon('chevronLeft', size: 18, color: Glass.ink),
),
);
}
Widget _toolRail() {
return Column(children: <Widget>[
_tool('pin', _pinMode, () => setState(() => _pinMode = true)),
const SizedBox(height: 10),
_tool('route', false, () => setState(_waypoints.clear)),
const SizedBox(height: 10),
_tool('home', false, () { if (_home != null) _map.move(_home!, 16); }),
const SizedBox(height: 10),
_tool('crosshair', false, () { if (_drone != null) _map.move(_drone!, 16); }),
]);
}
Widget _tool(String icon, bool active, VoidCallback onTap) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 42, height: 42,
decoration: BoxDecoration(color: active ? Glass.accent : Glass.pill, borderRadius: BorderRadius.circular(12), border: Border.all(color: Glass.hairline)),
alignment: Alignment.center,
child: PVIcon(icon, size: 20, stroke: 1.8, color: Glass.ink),
),
);
}
Widget _routePanel() {
return Container(
width: 200,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: Glass.pillStrong, borderRadius: BorderRadius.circular(14), border: Border.all(color: Glass.hairline)),
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: <Widget>[
const Text('Waypoint route', style: TextStyle(fontFamily: PV.fontSans, fontSize: 13, fontWeight: FontWeight.w700, color: Glass.ink)),
const SizedBox(height: 8),
if (_waypoints.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Text('Tap the map to drop waypoints', style: TextStyle(fontFamily: PV.fontMono, fontSize: 10.5, color: Color(0x99EAF0FA))),
)
else
...List<Widget>.generate(_waypoints.length, (int i) => Padding(
padding: const EdgeInsets.symmetric(vertical: 5),
child: Row(children: <Widget>[
Container(
width: 22, height: 22,
decoration: const BoxDecoration(color: Color(0xE63D7BF0), shape: BoxShape.circle),
alignment: Alignment.center,
child: Text('${i + 1}', style: const TextStyle(fontFamily: PV.fontMono, fontSize: 11, fontWeight: FontWeight.w700, color: Colors.white)),
),
const SizedBox(width: 9),
Text('Alt ${_altitude.toInt()}m · ${_speed.toInt()} m/s', style: const TextStyle(fontFamily: PV.fontMono, fontSize: 10.5, color: Color(0xCCEAF0FA))),
]),
)),
const SizedBox(height: 8),
_slider('Alt', _altitude, 20, 120, (double v) => setState(() => _altitude = v)),
_slider('Speed', _speed, 2, 15, (double v) => setState(() => _speed = v)),
const SizedBox(height: 8),
SizedBox(
height: 36,
child: FilledButton(
onPressed: _runRoute,
style: FilledButton.styleFrom(backgroundColor: const Color(0xFF3D7BF0), foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10))),
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: const <Widget>[
PVIcon('play2', size: 15, color: Colors.white, fill: true),
SizedBox(width: 6),
Text('Run route', style: TextStyle(fontFamily: PV.fontSans, fontSize: 13, fontWeight: FontWeight.w700)),
]),
),
),
]),
);
}
Widget _slider(String label, double value, double min, double max, ValueChanged<double> onChanged) {
return Row(children: <Widget>[
SizedBox(width: 38, child: Text(label, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 10, color: Color(0x99EAF0FA)))),
Expanded(
child: SliderTheme(
data: SliderThemeData(trackHeight: 2, thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6)),
child: Slider(value: value, min: min, max: max, activeColor: const Color(0xFF5B93F5), inactiveColor: const Color(0x33FFFFFF), onChanged: onChanged),
),
),
SizedBox(width: 26, child: Text('${value.toInt()}', textAlign: TextAlign.right, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 10, color: Glass.ink))),
]);
}
}
class _Dot extends StatelessWidget {
const _Dot(this.color, this.icon);
final Color color;
final String icon;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(color: color, shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2)),
alignment: Alignment.center,
child: PVIcon(icon, size: 12, color: const Color(0xFF05060A)),
);
}
}