Rebuild the Flutter/DJI-MSDK-V4 Fly App to the v2 UI kit and wire the full SDK surface behind it. Native (Kotlin): split DjiSdkBridge into a method/event router delegating to per-subsystem SubBridge helpers sharing a BridgeCtx — FlightController (takeoff/land/RTH + rich telemetry), Camera (mode/record/photo/exposure), Gimbal, Mission (Waypoint + ActiveTrack; QuickShots via ActiveTrack QUICK_SHOT), Media (MediaManager list/thumbnail/download), and optional DJI account login. Manifest gains scoped media permissions. Flutter: ten screens under lib/ui/ (Flight HUD, capture modes, camera settings, settings menu, map+waypoints, home, album, academy, profile, routes/flight logs), driven by an expanded FlightModel. New PVIcon renders the kit's SVG paths via flutter_svg; map uses flutter_map + latlong2. Pin transitive androidx.core/browser down to SDK-35-compatible versions so the newer plugins don't force AGP 8.9.1 onto the DJI toolchain. Verified with `flutter build apk --debug` (compiles Dart + all Kotlin); runtime behaviour is untested here — it needs a physical DJI-connected device. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
605 lines
23 KiB
Dart
605 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 '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/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();
|
|
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?;
|
|
_model.firmwareVersion = event['firmware'] 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.gpsSignalLevel = event['gpsSignalLevel'] as int?;
|
|
_model.isFlying = event['isFlying'] as bool?;
|
|
_model.motorsOn = event['areMotorsOn'] 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.homeLatitude = (event['homeLatitude'] as num?)?.toDouble();
|
|
_model.homeLongitude = (event['homeLongitude'] as num?)?.toDouble();
|
|
_model.homeDistance = (event['homeDistance'] as num?)?.toDouble();
|
|
_model.horizontalSpeed = (event['horizontalSpeed'] as num?)?.toDouble();
|
|
_model.verticalSpeed = (event['verticalSpeed'] as num?)?.toDouble();
|
|
_model.heading = (event['heading'] as num?)?.toDouble();
|
|
_model.goHomeHeight = (event['goHomeHeight'] 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.batteryVoltage = (event['voltage'] as num?)?.toDouble();
|
|
_model.batteryTemperature = (event['temperature'] as num?)?.toDouble();
|
|
_model.bump();
|
|
break;
|
|
case 'camera':
|
|
if (!_model.connected) break;
|
|
_model.isRecording = event['isRecording'] as bool? ?? false;
|
|
_model.recordSeconds = (event['recordingTimeSeconds'] as num?)?.toInt() ?? _model.recordSeconds;
|
|
_model.bump();
|
|
break;
|
|
case 'exposure':
|
|
if (!_model.connected) break;
|
|
_model.iso = event['iso'] as String?;
|
|
_model.shutter = event['shutter'] as String?;
|
|
_model.aperture = event['aperture'] as String?;
|
|
_model.ev = event['ev'] as String?;
|
|
_model.bump();
|
|
break;
|
|
case 'gimbal':
|
|
if (!_model.connected) break;
|
|
_model.gimbalPitch = (event['pitch'] as num?)?.toDouble();
|
|
_model.gimbalRoll = (event['roll'] as num?)?.toDouble();
|
|
_model.gimbalYaw = (event['yaw'] as num?)?.toDouble();
|
|
_model.bump();
|
|
break;
|
|
case 'djiAccount':
|
|
_model.djiAccountState = event['state'] as String? ?? 'unknown';
|
|
_model.djiAccountUser = event['user'] as String?;
|
|
_model.bump();
|
|
break;
|
|
case 'mission':
|
|
_model.missionState = event['state'] as String? ?? 'idle';
|
|
_model.tracking = _model.missionState == 'tracking' || _model.missionState == 'quickshot';
|
|
_model.missionRunning = _model.missionState != 'idle';
|
|
_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.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;
|
|
}
|
|
}
|
|
|
|
void _clearTelemetry() {
|
|
_model.satellites = null;
|
|
_model.gpsSignalLevel = null;
|
|
_model.isFlying = null;
|
|
_model.motorsOn = null;
|
|
_model.flightMode = null;
|
|
_model.altitude = null;
|
|
_model.latitude = null;
|
|
_model.longitude = null;
|
|
_model.homeLatitude = null;
|
|
_model.homeLongitude = null;
|
|
_model.homeDistance = null;
|
|
_model.horizontalSpeed = null;
|
|
_model.verticalSpeed = null;
|
|
_model.heading = null;
|
|
_model.batteryPercent = null;
|
|
_model.batteryVoltage = null;
|
|
_model.batteryTemperature = null;
|
|
_model.isRecording = false;
|
|
_model.recordSeconds = 0;
|
|
_model.firmwareVersion = 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.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();
|
|
_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, dji: _dji),
|
|
));
|
|
}
|
|
|
|
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: (_) => const AcademyPage()));
|
|
break;
|
|
case 'Routes':
|
|
Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => const RoutesPage()));
|
|
break;
|
|
case 'Flight logs':
|
|
Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => const FlightLogsPage()));
|
|
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,
|
|
),
|
|
));
|
|
}
|
|
|
|
// ── 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)),
|
|
],
|
|
);
|
|
}
|
|
}
|