Files
PilotVault/Fly App/lib/ui/map_page.dart
T
tajniak81andClaude Opus 4.8 a4a2709456 Rebuild Fly App to design v2 with full DJI SDK integration
Rebuild the Flutter/DJI-MSDK-V4 Fly App to the v2 UI kit and wire the
full SDK surface behind it.

Native (Kotlin): split DjiSdkBridge into a method/event router delegating
to per-subsystem SubBridge helpers sharing a BridgeCtx — FlightController
(takeoff/land/RTH + rich telemetry), Camera (mode/record/photo/exposure),
Gimbal, Mission (Waypoint + ActiveTrack; QuickShots via ActiveTrack
QUICK_SHOT), Media (MediaManager list/thumbnail/download), and optional
DJI account login. Manifest gains scoped media permissions.

Flutter: ten screens under lib/ui/ (Flight HUD, capture modes, camera
settings, settings menu, map+waypoints, home, album, academy, profile,
routes/flight logs), driven by an expanded FlightModel. New PVIcon renders
the kit's SVG paths via flutter_svg; map uses flutter_map + latlong2.

Pin transitive androidx.core/browser down to SDK-35-compatible versions
so the newer plugins don't force AGP 8.9.1 onto the DJI toolchain.

Verified with `flutter build apk --debug` (compiles Dart + all Kotlin);
runtime behaviour is untested here — it needs a physical DJI-connected
device.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 16:51:08 +02:00

237 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 '../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 List<Map<String, dynamic>> points = _waypoints
.map((LatLng p) => <String, dynamic>{'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: <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.dji.flutter.dji_msdk_sample',
),
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)),
);
}
}