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>
612 lines
23 KiB
Dart
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)),
|
|
],
|
|
);
|
|
}
|
|
}
|