Files
PilotVault/Fly App/lib/ui/map_page.dart
T
tajniak81andClaude Opus 4.8 58655531f0 Give the Fly App its own identity instead of the DJI sample's
The app still shipped under the identity of the DJI MSDK Flutter sample it
was started from, so every install, log line and crash report named a DJI
sample rather than PilotVault. Rename both namespaces:

  Android  com.dji.flutter.dji_msdk_sample -> com.pilotvault.flyapp
  Dart     dji_msdk_sample                 -> pilotvault_fly

The Kotlin sources move under com/pilotvault/flyapp to match. The manifest's
meta-data name stays com.dji.sdk.API_KEY — that string is fixed by the SDK's
own lookup and is not ours to rename.

A DJI App Key is bound to the application id, so the old key died with the
old id and a new one was registered against com.pilotvault.flyapp. That
forced the key to be touched anyway, so stop committing it: the Gradle
property becomes PILOTVAULT_FLY_API_KEY and now lives in the developer's
~/.gradle/gradle.properties, which Gradle merges into every build.
android/gradle.properties keeps the build flags — gitignoring it wholesale
would have taken useAndroidX and the Flutter migrator flags out of version
control with it — and documents where the key belongs.

A missing key prints a banner rather than silently baking the placeholder
into an APK that cannot register, which otherwise only surfaces as a
registration failure once the tablet is out at the aircraft. That warning
uses println because `flutter build` filters Gradle's warn-level output.

The rename means Android treats this as a new app: uninstall
com.dji.flutter.dji_msdk_sample before installing, or both will sit on the
tablet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 21:11:17 +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.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)),
);
}
}