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 '../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 createState() => _MapPageState(); } class _MapPageState extends State { final MapController _map = MapController(); final List _waypoints = []; 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.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 _runRoute() async { if (_waypoints.length < 2) { _snack('Drop at least 2 waypoints first'); return; } final List> points = _waypoints .map((LatLng p) => {'lat': p.latitude, 'lon': p.longitude, 'altitude': _altitude}) .toList(); try { await _dji.uploadWaypointMission(points, speed: _speed, finishAction: 'GO_HOME'); await _dji.startWaypointMission(); if (mounted) _snack('Route running — ${points.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: [ FlutterMap( mapController: _map, options: MapOptions(initialCenter: center, initialZoom: 16, onTap: _onTap), children: [ TileLayer( urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', userAgentPackageName: 'com.pilotvault.flyapp', ), if (_waypoints.length >= 2) PolylineLayer(polylines: >[ Polyline(points: _waypoints, strokeWidth: 3, color: const Color(0xFF5B93F5)), ]), MarkerLayer(markers: _markers()), ], ), SafeArea( child: Stack(children: [ Positioned(top: 12, left: 14, child: _back()), Positioned(left: 14, top: 58, child: _toolRail()), Positioned(right: 16, top: 58, child: _routePanel()), ]), ), ]), ); } List _markers() { final List m = []; 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: [ _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: [ 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.generate(_waypoints.length, (int i) => Padding( padding: const EdgeInsets.symmetric(vertical: 5), child: Row(children: [ 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 [ 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 onChanged) { return Row(children: [ 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)), ); } }