Files
PilotVault/Fly App/lib/main.dart
T
tajniak81andClaude Opus 4.8 94f6876024 Add location-aware automatic default bounding box
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>
2026-07-14 00:07:12 +02:00

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)),
],
);
}
}