The OpenSky "Default bounding box" now follows where flying happens. A new "Automatic" picker mode (the default) resolves the live-map area from a location cascade — drone telemetry → phone GPS → browser geolocation → the user's Region country → Europe — instead of a fixed box. Manual presets and Custom coordinates still work. - Web App: new shared countries.js dataset (all countries + bbox, offline point→country); the bbox picker gains all European countries and an Automatic option (client pref prefs.autoBbox); the Region setting expands from 6 locale entries to all countries; the live map resolves the cascade each poll and sends it as ?bbox=. - API Server: the states endpoint accepts and validates a ?bbox= override (validBBox); the Web App BFF forwards the query; the hub relays new phoneLatitude/phoneLongitude telemetry to the Web App. - Fly App: reports the phone's own GPS (geolocator) alongside telemetry, used as the "your location" fallback. - API panel: the OpenSky bbox picker lists all European countries. Builds verified across web, panel, both Go modules and the Fly App APK. Region list, Automatic default and the cascade ?bbox= override verified in the browser. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
473 lines
18 KiB
Dart
473 lines
18 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:geolocator/geolocator.dart';
|
|
|
|
import 'dji_service.dart';
|
|
import 'flight_model.dart';
|
|
import 'login_page.dart';
|
|
import 'pb_auth.dart';
|
|
import 'theme.dart';
|
|
import 'ui/album_page.dart';
|
|
import 'ui/flight_control_page.dart';
|
|
import 'ui/go_fly_page.dart';
|
|
import 'uploader.dart';
|
|
|
|
Future<void> main() async {
|
|
WidgetsFlutterBinding.ensureInitialized();
|
|
await SystemChrome.setPreferredOrientations(<DeviceOrientation>[DeviceOrientation.portraitUp]);
|
|
await auth.init();
|
|
runApp(const DjiSampleApp());
|
|
}
|
|
|
|
class DjiSampleApp extends StatelessWidget {
|
|
const DjiSampleApp({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return MaterialApp(
|
|
title: 'PilotVault',
|
|
theme: PV.theme(),
|
|
// The app is usable without signing in; login lives behind the user panel.
|
|
home: const HomePage(),
|
|
);
|
|
}
|
|
}
|
|
|
|
class HomePage extends StatefulWidget {
|
|
const HomePage({super.key});
|
|
|
|
@override
|
|
State<HomePage> createState() => _HomePageState();
|
|
}
|
|
|
|
class _HomePageState extends State<HomePage> {
|
|
final DjiService _dji = DjiService();
|
|
final FlightModel _model = FlightModel();
|
|
StreamSubscription<Map<String, dynamic>>? _sub;
|
|
StreamSubscription<AuthStatus>? _authSub;
|
|
StreamSubscription<Position>? _phoneLocSub;
|
|
|
|
// Streams telemetry to the API Server and receives commands back.
|
|
late final ServerUploader _uploader;
|
|
StreamSubscription<UploadStatus>? _uploadSub;
|
|
final TextEditingController _serverHost =
|
|
TextEditingController(text: '10.2.1.101:8080');
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_uploader = ServerUploader(
|
|
onCommand: _handleServerCommand,
|
|
snapshotProvider: _buildStateSnapshot,
|
|
);
|
|
_uploadSub = _uploader.status.listen((UploadStatus s) {
|
|
_model.upload = s;
|
|
_model.bump();
|
|
});
|
|
// Rebuild when the session changes so the user panel reflects sign-in/out.
|
|
_authSub = auth.status.listen((_) {
|
|
if (mounted) setState(() {});
|
|
});
|
|
_init();
|
|
_startPhoneLocation();
|
|
}
|
|
|
|
/// Streams the phone's own GPS (coarse, low-frequency) and reports it to the
|
|
/// server as a telemetry field — a location fallback for the Web App's auto
|
|
/// bounding box when the drone has no fix. Best-effort: silently gives up if
|
|
/// location services or permission are unavailable.
|
|
Future<void> _startPhoneLocation() async {
|
|
try {
|
|
if (!await Geolocator.isLocationServiceEnabled()) return;
|
|
LocationPermission perm = await Geolocator.checkPermission();
|
|
if (perm == LocationPermission.denied) {
|
|
perm = await Geolocator.requestPermission();
|
|
}
|
|
if (perm == LocationPermission.denied || perm == LocationPermission.deniedForever) {
|
|
return;
|
|
}
|
|
_phoneLocSub = Geolocator.getPositionStream(
|
|
locationSettings: const LocationSettings(
|
|
accuracy: LocationAccuracy.low, // country-level is all the bbox needs
|
|
distanceFilter: 1000, // metres — infrequent updates
|
|
),
|
|
).listen((Position pos) {
|
|
_model.phoneLatitude = pos.latitude;
|
|
_model.phoneLongitude = pos.longitude;
|
|
_model.bump();
|
|
// Report to the server (rides the existing telemetry channel; only the
|
|
// phone fields are present, so it never disturbs drone telemetry).
|
|
_uploader.onEvent(<String, dynamic>{
|
|
'type': 'telemetry',
|
|
'phoneLatitude': pos.latitude,
|
|
'phoneLongitude': pos.longitude,
|
|
});
|
|
});
|
|
} catch (_) {
|
|
// Location plugin/permission unavailable — non-fatal.
|
|
}
|
|
}
|
|
|
|
Future<void> _init() async {
|
|
_sub = _dji.events().listen(_onEvent, onError: (Object e) {
|
|
_snack('Event channel error: $e');
|
|
});
|
|
try {
|
|
final String version = await _dji.getSdkVersion();
|
|
_model.sdkVersion = version;
|
|
_model.bump();
|
|
} catch (_) {
|
|
_model.sdkVersion = 'unavailable';
|
|
_model.bump();
|
|
}
|
|
}
|
|
|
|
void _onEvent(Map<String, dynamic> event) {
|
|
if (!mounted) return;
|
|
// Forward every event to the API server (throttled internally).
|
|
_uploader.onEvent(event);
|
|
switch (event['type'] as String?) {
|
|
case 'registration':
|
|
switch (event['state'] as String?) {
|
|
case 'registering':
|
|
_model.registration = RegistrationState.registering;
|
|
_model.registrationError = null;
|
|
break;
|
|
case 'success':
|
|
_model.registration = RegistrationState.success;
|
|
break;
|
|
case 'failed':
|
|
_model.registration = RegistrationState.failed;
|
|
_model.registrationError = event['error'] as String?;
|
|
break;
|
|
}
|
|
_model.bump();
|
|
break;
|
|
case 'connection':
|
|
_model.connected = event['connected'] as bool? ?? false;
|
|
_model.model = event['model'] as String?;
|
|
if (!_model.connected) _clearTelemetry();
|
|
_model.bump();
|
|
break;
|
|
case 'telemetry':
|
|
// Ignore stray telemetry that arrives after a disconnect — otherwise it
|
|
// repopulates values _clearTelemetry() just wiped, leaving stale readings.
|
|
if (!_model.connected) break;
|
|
_model.satellites = event['satelliteCount'] as int?;
|
|
_model.isFlying = event['isFlying'] as bool?;
|
|
_model.flightMode = event['flightMode'] as String?;
|
|
_model.altitude = (event['altitude'] as num?)?.toDouble();
|
|
_model.latitude = (event['latitude'] as num?)?.toDouble();
|
|
_model.longitude = (event['longitude'] as num?)?.toDouble();
|
|
_model.bump();
|
|
break;
|
|
case 'battery':
|
|
// Same guard: a battery packet trailing a disconnect must not revive the
|
|
// last percentage (the reported "disconnected but still 42%" bug).
|
|
if (!_model.connected) break;
|
|
_model.batteryPercent = event['percent'] as int?;
|
|
_model.bump();
|
|
break;
|
|
}
|
|
}
|
|
|
|
void _clearTelemetry() {
|
|
_model.satellites = null;
|
|
_model.isFlying = null;
|
|
_model.flightMode = null;
|
|
_model.altitude = null;
|
|
_model.latitude = null;
|
|
_model.longitude = null;
|
|
_model.batteryPercent = null;
|
|
}
|
|
|
|
Future<void> _register() async {
|
|
try {
|
|
await _dji.registerApp();
|
|
} catch (e) {
|
|
_snack('registerApp failed: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _connect() async {
|
|
try {
|
|
final bool started = await _dji.startConnection();
|
|
_snack(started ? 'Scanning for product…' : 'Could not start connection');
|
|
} catch (e) {
|
|
_snack('startConnection failed: $e');
|
|
}
|
|
}
|
|
|
|
void _snack(String msg) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context)
|
|
..clearSnackBars()
|
|
..showSnackBar(SnackBar(content: Text(msg)));
|
|
}
|
|
|
|
// ── API server upload ──────────────────────────────────────────────────────
|
|
|
|
Future<void> _toggleUpload() async {
|
|
if (_model.upload == UploadStatus.disabled) {
|
|
await _uploader.enable(_serverHost.text);
|
|
} else {
|
|
await _uploader.disable();
|
|
}
|
|
}
|
|
|
|
/// Builds events describing the app's CURRENT state, sent to the server right
|
|
/// after (re)connecting so it always reflects reality.
|
|
List<Map<String, dynamic>> _buildStateSnapshot() {
|
|
final List<Map<String, dynamic>> events = <Map<String, dynamic>>[
|
|
<String, dynamic>{'type': 'registration', 'state': _registrationWire()},
|
|
<String, dynamic>{'type': 'connection', 'connected': _model.connected, 'model': _model.model ?? ''},
|
|
];
|
|
if (_model.batteryPercent != null) {
|
|
events.add(<String, dynamic>{'type': 'battery', 'percent': _model.batteryPercent});
|
|
}
|
|
final Map<String, dynamic> tel = <String, dynamic>{'type': 'telemetry'};
|
|
if (_model.satellites != null) tel['satelliteCount'] = _model.satellites;
|
|
if (_model.isFlying != null) tel['isFlying'] = _model.isFlying;
|
|
if (_model.flightMode != null) tel['flightMode'] = _model.flightMode;
|
|
if (_model.altitude != null) tel['altitude'] = _model.altitude;
|
|
if (_model.latitude != null) tel['latitude'] = _model.latitude;
|
|
if (_model.longitude != null) tel['longitude'] = _model.longitude;
|
|
if (_model.phoneLatitude != null) tel['phoneLatitude'] = _model.phoneLatitude;
|
|
if (_model.phoneLongitude != null) tel['phoneLongitude'] = _model.phoneLongitude;
|
|
if (tel.length > 1) events.add(tel);
|
|
return events;
|
|
}
|
|
|
|
String _registrationWire() {
|
|
switch (_model.registration) {
|
|
case RegistrationState.success:
|
|
return 'success';
|
|
case RegistrationState.registering:
|
|
return 'registering';
|
|
case RegistrationState.failed:
|
|
return 'failed';
|
|
case RegistrationState.idle:
|
|
return 'not registered';
|
|
}
|
|
}
|
|
|
|
/// Handles commands pushed down from the server.
|
|
void _handleServerCommand(String command, Map<String, dynamic> payload) {
|
|
switch (command) {
|
|
case 'registerApp':
|
|
_register();
|
|
break;
|
|
case 'startConnection':
|
|
_connect();
|
|
break;
|
|
case 'stopConnection':
|
|
_dji.stopConnection();
|
|
break;
|
|
default:
|
|
_snack('Unknown server command: $command');
|
|
return;
|
|
}
|
|
_snack('Server command: $command');
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_sub?.cancel();
|
|
_authSub?.cancel();
|
|
_phoneLocSub?.cancel();
|
|
_uploadSub?.cancel();
|
|
_uploader.dispose();
|
|
_serverHost.dispose();
|
|
_model.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
/// Opens the login page as a modal popup (from the user panel). Returns to the
|
|
/// caller once dismissed; the auth listener refreshes the UI on success.
|
|
Future<void> _openLogin() async {
|
|
await Navigator.of(context).push(MaterialPageRoute<void>(
|
|
fullscreenDialog: true,
|
|
builder: (BuildContext ctx) => LoginPage(onSignedIn: () => Navigator.of(ctx).pop()),
|
|
));
|
|
}
|
|
|
|
// ── Navigation ───────────────────────────────────────────────────────────
|
|
|
|
void _goFly() {
|
|
Navigator.of(context).push(MaterialPageRoute<void>(
|
|
builder: (_) => FlightControlPage(model: _model),
|
|
));
|
|
}
|
|
|
|
void _openAlbum() {
|
|
Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => const AlbumPage()));
|
|
}
|
|
|
|
void _onTile(String tile) => _snack('$tile — coming soon');
|
|
|
|
// ── UI ───────────────────────────────────────────────────────────────────
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GoFlyPage(
|
|
model: _model,
|
|
onGoFly: _goFly,
|
|
onOpenAlbum: _openAlbum,
|
|
onSettings: _openSettings,
|
|
onTile: _onTile,
|
|
);
|
|
}
|
|
|
|
/// Aircraft/connection controls that don't appear on the design's launch
|
|
/// screen live here: MSDK registration, product connection, and telemetry
|
|
/// streaming to the API Server, plus account sign-out.
|
|
void _openSettings() {
|
|
final PVScheme s = PVScheme.of(context);
|
|
showModalBottomSheet<void>(
|
|
context: context,
|
|
backgroundColor: s.surface,
|
|
isScrollControlled: true,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
|
),
|
|
builder: (BuildContext context) {
|
|
return AnimatedBuilder(
|
|
animation: _model,
|
|
builder: (BuildContext context, _) {
|
|
final (Color, String) reg = switch (_model.registration) {
|
|
RegistrationState.idle => (s.textTertiary, 'Not registered'),
|
|
RegistrationState.registering => (s.warning, 'Registering…'),
|
|
RegistrationState.success => (s.success, 'Registered'),
|
|
RegistrationState.failed => (s.danger, 'Registration failed'),
|
|
};
|
|
final (Color, String) up = switch (_model.upload) {
|
|
UploadStatus.disabled => (s.textTertiary, 'Off'),
|
|
UploadStatus.connecting => (s.warning, 'Connecting…'),
|
|
UploadStatus.connected => (s.success, 'Streaming'),
|
|
UploadStatus.error => (s.danger, 'Retrying…'),
|
|
};
|
|
final bool streaming = _model.upload != UploadStatus.disabled;
|
|
|
|
return Padding(
|
|
padding: EdgeInsets.fromLTRB(20, 12, 20, 20 + MediaQuery.of(context).viewInsets.bottom),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: <Widget>[
|
|
Center(
|
|
child: Container(
|
|
width: 40,
|
|
height: 4,
|
|
decoration: BoxDecoration(color: s.borderStrong, borderRadius: BorderRadius.circular(999)),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
// Account — signed-in identity + sign-out, or a sign-in entry
|
|
// that opens the login page (biometric / face live there).
|
|
Row(
|
|
children: <Widget>[
|
|
Expanded(
|
|
child: Text(
|
|
auth.isAuthed
|
|
? (auth.userEmail.isEmpty ? 'Signed in' : auth.userEmail)
|
|
: 'Not signed in',
|
|
style: TextStyle(fontFamily: PV.fontSans, fontSize: 14, fontWeight: FontWeight.w600, color: s.textPrimary),
|
|
),
|
|
),
|
|
if (auth.isAuthed)
|
|
TextButton.icon(
|
|
onPressed: () {
|
|
Navigator.of(context).pop();
|
|
auth.signOut();
|
|
},
|
|
icon: Icon(Icons.logout, size: 16, color: s.textSecondary),
|
|
label: Text('Sign out', style: TextStyle(color: s.textSecondary)),
|
|
)
|
|
else
|
|
FilledButton.icon(
|
|
onPressed: () {
|
|
Navigator.of(context).pop();
|
|
_openLogin();
|
|
},
|
|
icon: const Icon(Icons.login, size: 16),
|
|
label: const Text('Sign in'),
|
|
),
|
|
],
|
|
),
|
|
// Registration, aircraft and telemetry controls require a
|
|
// session — they only appear once the user has signed in.
|
|
if (auth.isAuthed) ...<Widget>[
|
|
Divider(color: s.border, height: 24),
|
|
|
|
_settingRow(s, 'Registration', reg.$1, reg.$2, trailing: 'MSDK · ${_model.sdkVersion}'),
|
|
if (_model.registrationError != null) ...<Widget>[
|
|
const SizedBox(height: 6),
|
|
Text(_model.registrationError!, style: TextStyle(fontFamily: PV.fontSans, fontSize: 13, color: s.danger)),
|
|
],
|
|
const SizedBox(height: 10),
|
|
FilledButton(
|
|
onPressed: _model.registration == RegistrationState.registering ? null : _register,
|
|
child: const Text('Register app'),
|
|
),
|
|
const SizedBox(height: 20),
|
|
|
|
_settingRow(s, 'Aircraft', _model.connected ? s.success : s.textTertiary,
|
|
_model.connected ? 'Connected' : 'No product',
|
|
trailing: _model.model ?? '—'),
|
|
const SizedBox(height: 10),
|
|
OutlinedButton.icon(
|
|
onPressed: _model.registered ? _connect : null,
|
|
icon: const Icon(Icons.usb, size: 18),
|
|
label: const Text('Connect to product'),
|
|
),
|
|
const SizedBox(height: 20),
|
|
|
|
_settingRow(s, 'Telemetry stream', up.$1, up.$2),
|
|
const SizedBox(height: 10),
|
|
TextField(
|
|
controller: _serverHost,
|
|
enabled: !streaming,
|
|
style: PV.body,
|
|
decoration: const InputDecoration(labelText: 'API Server host', hintText: '10.2.1.101:8080'),
|
|
),
|
|
const SizedBox(height: 10),
|
|
FilledButton.icon(
|
|
style: FilledButton.styleFrom(backgroundColor: streaming ? s.danger : s.accent),
|
|
onPressed: _model.upload == UploadStatus.connecting ? null : _toggleUpload,
|
|
icon: Icon(streaming ? Icons.stop : Icons.play_arrow, size: 18),
|
|
label: Text(streaming ? 'Stop streaming' : 'Start streaming'),
|
|
),
|
|
] else ...<Widget>[
|
|
const SizedBox(height: 14),
|
|
Text(
|
|
'Sign in to manage registration, aircraft connection and telemetry streaming.',
|
|
style: TextStyle(fontFamily: PV.fontSans, fontSize: 13, color: s.textSecondary),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _settingRow(PVScheme s, String title, Color dot, String status, {String? trailing}) {
|
|
return Row(
|
|
children: <Widget>[
|
|
Container(width: 8, height: 8, decoration: BoxDecoration(color: dot, shape: BoxShape.circle)),
|
|
const SizedBox(width: 10),
|
|
Text(title, style: TextStyle(fontFamily: PV.fontSans, fontSize: 15, fontWeight: FontWeight.w600, color: s.textPrimary)),
|
|
const SizedBox(width: 8),
|
|
Text(status, style: TextStyle(fontFamily: PV.fontSans, fontSize: 13, color: s.textSecondary)),
|
|
const Spacer(),
|
|
if (trailing != null)
|
|
Text(trailing, style: TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: s.textTertiary)),
|
|
],
|
|
);
|
|
}
|
|
}
|