Files
PilotVault/Fly App/lib/main.dart
T
tajniak81andClaude Opus 5.5 636da1ac2c Give the Fly App every remaining GO 4 flight mode and the missing settings
Intelligent modes: Point of Interest (orbit on the map, live radius and
speed), Follow Me (follows the phone), QuickShots on a boxed subject,
Course Lock / Home Lock, Tripod and Cinematic, a Free hyperlapse, and
Waypoints 2.0 for V2-only aircraft. ActiveTrack gains Spotlight Pro and
Circle. Every mode has a first-use tutorial and stops from the rail.
Waypoints can resume an interrupted mission, change cruise speed in
flight and check terrain clearance (Open-Meteo, on tap).

Settings: units, C1/C2 actions, dual-operator master/slave, HDMI/SDI
output, a link spectrum chart, Wi-Fi name and password, aircraft name,
video cache, battery warning history and pairing, non-smart cell
voltages. Calibration: compass interference meter, per-IMU calibration,
lens infinity focus and a first-manual-focus prompt. Also an RTK chip,
gimbal movement presets and motor tuning, Precision Takeoff gated on the
flight controller, and the precise GEO database's server version.

Built and unit/widget-tested only; nothing has flown yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:06:16 +02:00

612 lines
23 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 'fly/find_my_drone.dart';
import 'fly/flight_log.dart';
import 'fly/flight_record.dart';
import 'fly/flight_record_sync.dart';
import 'fly/fly_prefs.dart';
import 'fly/preflight_checklist.dart';
import 'fly/product_profile.dart';
import 'fly/telemetry_reducer.dart';
import 'login_page.dart';
import 'pb_auth.dart';
import 'theme.dart';
import 'ui/academy_page.dart';
import 'ui/album_page.dart';
import 'ui/flight_control_page.dart';
import 'ui/flight_logs_page.dart';
import 'ui/fly/fly_screen.dart';
import 'ui/home_page.dart';
import 'ui/profile_page.dart';
import 'ui/routes_page.dart';
import 'uploader.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await SystemChrome.setPreferredOrientations(<DeviceOrientation>[DeviceOrientation.portraitUp]);
await auth.init();
await FlyPrefs.load();
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();
late final TelemetryReducer _telemetry = TelemetryReducer(_model);
// Flight records and the last known position (plan P9), app-wide: a flight
// is recorded whichever screen is showing.
final FlightLog _flightLog = FlightLog(store: FileFlightRecordStore(), remote: ApiLogbookRemote());
late final FlightRecorder _recorder;
late final AircraftLocator _locator = AircraftLocator(model: _model);
final PreflightChecklist _checklist = PreflightChecklist();
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;
// a sign-in also sends the flights recorded while signed out.
_authSub = auth.status.listen((_) {
if (mounted) setState(() {});
if (auth.isAuthed && FlyPrefs.autoUploadFlights.value) _flightLog.uploadPending();
});
_recorder = FlightRecorder(model: _model, onFinished: _onFlightFinished);
_locator.load();
_checklist.load();
_flightLog.load().then((_) {
if (auth.isAuthed && FlyPrefs.autoUploadFlights.value) _flightLog.uploadPending();
});
_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);
// Telemetry, camera, diagnostics and mission state go to the sub-models.
if (_telemetry.apply(event)) return;
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?;
_model.firmwareVersion = event['firmware'] as String?;
_model.controllerFirmwareVersion = event['controllerFirmware'] as String?;
_model.flightControllerSerial = event['flightControllerSerial'] as String?;
if (!_model.connected) _clearTelemetry();
// A product swap reports connected=true with a new model; drop the old
// aircraft's profile until the new one arrives.
if (_model.profile.displayName != _model.model) _model.profile = ProductProfile.disconnected;
_model.bump();
break;
case 'productProfile':
// Rebuilt natively on every component change; a profile trailing a
// disconnect must not resurrect the old aircraft's capabilities.
if (!_model.connected) break;
_model.profile = ProductProfile.fromBridge(event);
_model.bump();
break;
case 'identity':
// Serial and firmware resolve asynchronously after connect, each on its
// own schedule, so a null here means "not resolved yet" — never a reason
// to drop a value the previous identity event already delivered.
if (!_model.connected) break;
_model.flightControllerSerial =
event['flightControllerSerial'] as String? ?? _model.flightControllerSerial;
_model.firmwareVersion = event['firmware'] as String? ?? _model.firmwareVersion;
_model.controllerFirmwareVersion =
event['controllerFirmware'] as String? ?? _model.controllerFirmwareVersion;
_model.bump();
break;
case 'djiAccount':
_model.djiAccountState = event['state'] as String? ?? 'unknown';
_model.djiAccountUser = event['user'] as String?;
_model.bump();
break;
case 'mediaList':
final List<dynamic> files = (event['files'] as List<dynamic>?) ?? const <dynamic>[];
_model.media = files
.map((dynamic e) => MediaItem.fromMap(Map<String, dynamic>.from(e as Map)))
.toList();
_model.mediaLoading = false;
_model.mediaVideoPlayback = event['videoPlayback'] as bool? ?? false;
_model.bump();
break;
case 'mediaDownload':
final int idx = (event['index'] as num?)?.toInt() ?? -1;
final String? path = event['path'] as String?;
if (idx >= 0 && idx < _model.media.length && path != null) {
_model.media[idx].localPath = path;
_model.bump();
}
break;
}
}
Future<void> _onFlightFinished(FlightRecord r) async {
await _flightLog.add(r);
if (r.uploaded) {
_snack('Flight logged: ${r.durationLabel}');
} else if (r.uploadError != null && !r.simulated && auth.isAuthed) {
_snack('Flight saved on the phone; logbook upload failed: ${r.uploadError}');
}
}
void _clearTelemetry() {
_model.clearTelemetry();
_telemetry.reset();
_model.firmwareVersion = null;
_model.controllerFirmwareVersion = null;
_model.flightControllerSerial = null;
_model.profile = ProductProfile.disconnected;
}
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.gpsSignalLevel != null) tel['gpsSignalLevel'] = _model.gpsSignalLevel;
if (_model.isFlying != null) tel['isFlying'] = _model.isFlying;
if (_model.motorsOn != null) tel['areMotorsOn'] = _model.motorsOn;
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.homeLatitude != null) tel['homeLatitude'] = _model.homeLatitude;
if (_model.homeLongitude != null) tel['homeLongitude'] = _model.homeLongitude;
if (_model.homeDistance != null) tel['homeDistance'] = _model.homeDistance;
if (_model.horizontalSpeed != null) tel['horizontalSpeed'] = _model.horizontalSpeed;
if (_model.verticalSpeed != null) tel['verticalSpeed'] = _model.verticalSpeed;
if (_model.heading != null) tel['heading'] = _model.heading;
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();
_recorder.dispose();
_locator.dispose();
_flightLog.dispose();
_checklist.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: (_) => FlyPrefs.classicHud.value
? FlightControlPage(model: _model, dji: _dji)
: FlyScreen(
model: _model,
dji: _dji,
onOpenFlightLogs: () => Navigator.of(context).push(MaterialPageRoute<void>(
builder: (_) => FlightLogsPage(log: _flightLog, locator: _locator, model: _model),
)),
),
));
}
void _openAlbum() {
Navigator.of(context).push(MaterialPageRoute<void>(
builder: (_) => AlbumPage(model: _model, dji: _dji),
));
}
/// The disconnected-home "Connect aircraft" button: register first if needed
/// (registration auto-starts a product scan on success), else just scan.
Future<void> _connectFlow() async {
if (!auth.isAuthed) {
_snack('Sign in to connect an aircraft');
await _openLogin();
return;
}
if (!_model.registered) {
await _register();
} else {
await _connect();
}
}
void _onTile(String tile) {
switch (tile) {
case 'Album':
_openAlbum();
break;
case 'Academy':
Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => AcademyPage(model: _model, checklist: _checklist)));
break;
case 'Routes':
Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => const RoutesPage()));
break;
case 'Flight logs':
Navigator.of(context).push(MaterialPageRoute<void>(
builder: (_) => FlightLogsPage(log: _flightLog, locator: _locator, model: _model),
));
break;
default:
_snack('$tile — coming soon');
}
}
/// The avatar opens the pilot profile (PilotVault + optional DJI account,
/// stats, library shortcuts). Technical controls live behind its App settings.
void _openProfile() {
Navigator.of(context).push(MaterialPageRoute<void>(
builder: (_) => ProfilePage(
model: _model,
dji: _dji,
onAppSettings: _openSettings,
onSignIn: _openLogin,
log: _flightLog,
locator: _locator,
),
));
}
// ── UI ───────────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
return HomeScreen(
model: _model,
onGoFly: _goFly,
onConnect: _connectFlow,
onSettings: _openProfile,
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)),
],
);
}
}