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>
237 lines
9.4 KiB
Dart
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)),
|
|
);
|
|
}
|
|
}
|