Files
PilotVault/Fly App/lib/ui/settings_menu_page.dart
T
tajniak81andClaude Opus 5.5 f6bbb1cfee Batch the Fly App's telemetry and split it per subsystem
The GO 4-style HUD needs far more live data than the bridge sent, and
pushing every SDK callback straight to Dart would rebuild the screen
dozens of times a second. This is the telemetry foundation (plan P1).

On the Kotlin side, every source now writes into a TelemetryHub that
emits one telemetry event per tick: flight-controller state, gimbal and
vision at up to 10 Hz, battery, links, RC, storage and camera settings
folded in at up to 2 Hz. Each event carries the full latest snapshot, so
a consumer that drops packets never loses a slow group. New bridges
cover batteries (every pack, cell voltages, warning thresholds), link
quality and RC battery, diagnostics, and obstacle sensing per sensor.
KeyBridge adds generic getKey/setKey/listenKey over KeyManager for the
settings rows to come. The flight controller also gains smart-RTH
confirmation and simulator start/stop.

On the Dart side, FlightModel now owns one ChangeNotifier per subsystem
(aircraft, battery, links, camera, gimbal, vision, alerts, mission), so
a HUD widget can listen to just the one it draws. TelemetryReducer
routes events into them, only notifies groups that changed, and gives
FlightModel one notification per tick. Flat getters keep the existing
screens working. Diagnostics become graded, de-duplicated alerts.
Settings > About > Telemetry inspector shows every field live and can
start the simulator.

Battery no longer arrives as its own event, so the API Server reads the
percentage from the telemetry snapshot (the old event is still accepted).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:34:56 +02:00

304 lines
12 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../dji_service.dart';
import '../flight_model.dart';
import '../theme.dart';
import 'fly/debug/product_profile_page.dart';
import 'fly/debug/telemetry_inspector_page.dart';
import 'pv_icons.dart';
/// Aircraft settings — mirrors the v2 "Settings" mockup. Left tab column
/// (Safety / Control / Camera / Transmission / About); Safety & Control rows
/// drive real flight-controller setters. Values are held on the model and
/// updated as the user changes them (sensible defaults until read back).
class SettingsMenuPage extends StatefulWidget {
const SettingsMenuPage({super.key, required this.model, required this.dji});
final FlightModel model;
final DjiService dji;
@override
State<SettingsMenuPage> createState() => _SettingsMenuPageState();
}
class _SettingsMenuPageState extends State<SettingsMenuPage> {
DjiService get _dji => widget.dji;
FlightModel get _m => widget.model;
int _tab = 0;
static const List<(String, String)> _tabs = <(String, String)>[
('shield', 'Safety'),
('rc', 'Control'),
('aperture', 'Camera'),
('radio', 'Transmission'),
('drone', 'About'),
];
@override
void initState() {
super.initState();
// Seed defaults where nothing has been read back yet.
_m.maxHeight ??= 120;
_m.maxRadius ??= 500;
_m.maxRadiusEnabled ??= false;
_m.rthHeight ??= 100;
_m.obstacleAvoidance ??= 'On';
_m.noviceMode ??= false;
}
void _openProfile() {
Navigator.of(context).push(MaterialPageRoute<void>(
builder: (_) => ProductProfilePage(model: _m, dji: _dji),
));
}
void _openTelemetry() {
Navigator.of(context).push(MaterialPageRoute<void>(
builder: (_) => TelemetryInspectorPage(model: _m, dji: _dji),
));
}
void _snack(String msg) {
ScaffoldMessenger.of(context)
..clearSnackBars()
..showSnackBar(SnackBar(content: Text(msg)));
}
Future<void> _run(String label, Future<void> Function() action) async {
try {
await action();
} catch (e) {
_snack('$label: ${e is PlatformException ? (e.message ?? e.code) : e}');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF0A1120),
body: SafeArea(
child: AnimatedBuilder(
animation: _m,
builder: (BuildContext context, _) => Stack(children: <Widget>[
Row(children: <Widget>[
_tabColumn(),
Expanded(child: _rightPanel()),
]),
Positioned(
top: 6, right: 8,
child: GestureDetector(
onTap: () => Navigator.of(context).maybePop(),
child: Container(
width: 30, height: 30,
decoration: BoxDecoration(color: const Color(0x1FFFFFFF), borderRadius: BorderRadius.circular(9)),
alignment: Alignment.center,
child: const PVIcon('close', size: 17, color: Glass.ink),
),
),
),
]),
),
),
);
}
Widget _tabColumn() {
return Container(
width: 160,
padding: const EdgeInsets.fromLTRB(12, 18, 12, 12),
color: const Color(0x8C081020),
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
Padding(
padding: const EdgeInsets.only(left: 6, bottom: 10),
child: Row(children: const <Widget>[
PVIcon('settings', size: 18, color: Glass.ink),
SizedBox(width: 8),
Text('Settings', style: TextStyle(fontFamily: PV.fontSans, fontSize: 15, fontWeight: FontWeight.w700, color: Glass.ink)),
]),
),
for (int i = 0; i < _tabs.length; i++) _tabBtn(i),
]),
);
}
Widget _tabBtn(int i) {
final bool active = i == _tab;
return GestureDetector(
onTap: () => setState(() => _tab = i),
child: Container(
height: 38,
margin: const EdgeInsets.only(bottom: 6),
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(color: active ? Glass.accent : Colors.transparent, borderRadius: BorderRadius.circular(10)),
child: Row(children: <Widget>[
PVIcon(_tabs[i].$1, size: 17, stroke: 1.7, color: Glass.ink),
const SizedBox(width: 10),
Text(_tabs[i].$2, style: const TextStyle(fontFamily: PV.fontSans, fontSize: 13, fontWeight: FontWeight.w600, color: Glass.ink)),
]),
),
);
}
Widget _rightPanel() {
final (String eyebrow, List<Widget> rows) = switch (_tab) {
0 => ('Flight Safety', _safetyRows()),
1 => ('Flight Control', _controlRows()),
2 => ('Camera', _cameraRows()),
3 => ('Transmission', _infoRows(<(String, String)>[('Channel Mode', 'Auto'), ('Frequency', '2.4 / 5.8 GHz'), ('Signal', 'HD 1080p')])),
_ => ('About', <Widget>[
..._infoRows(<(String, String)>[
('Model', _m.model ?? '—'),
('Flight Controller SN', _m.flightControllerSerial ?? '—'),
('Firmware', _m.firmwareVersion ?? '—'),
('Controller Firmware', _m.controllerFirmwareVersion ?? '—'),
('MSDK', _m.sdkVersion),
]),
_infoRow('Capability profile', _m.profile.connected ? 'View' : '—', onTap: _openProfile),
_infoRow('Telemetry inspector', 'View', onTap: _openTelemetry),
]),
};
return Padding(
padding: const EdgeInsets.fromLTRB(22, 20, 22, 20),
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(eyebrow.toUpperCase(),
style: const TextStyle(fontFamily: PV.fontMono, fontSize: 10, letterSpacing: 1.4, color: Color(0x8CEAF0FA))),
),
Expanded(child: ListView(children: rows)),
]),
);
}
// ── Rows ────────────────────────────────────────────────────────────────
List<Widget> _safetyRows() => <Widget>[
_stepperRow('Max Altitude', '${_m.maxHeight} m', () => _editNumber('Max Altitude', _m.maxHeight ?? 120, 20, 500, 10, (int v) {
setState(() => _m.maxHeight = v);
_run('Max altitude', () => _dji.setMaxFlightHeight(v));
})),
_toggleRow('Max Distance', _m.maxRadiusEnabled ?? false, (bool on) {
setState(() => _m.maxRadiusEnabled = on);
_run('Max distance', () => _dji.setMaxRadiusEnabled(on));
}),
_stepperRow('Return-to-Home Alt.', '${_m.rthHeight} m', () => _editNumber('RTH Altitude', _m.rthHeight ?? 100, 20, 500, 10, (int v) {
setState(() => _m.rthHeight = v);
_run('RTH altitude', () => _dji.setGoHomeHeight(v));
})),
_cycleRow('Obstacle Avoidance', _m.obstacleAvoidance ?? 'On', <String>['On', 'Off'], (String v) {
setState(() => _m.obstacleAvoidance = v);
_run('Obstacle avoidance', () => _dji.setObstacleAvoidance(v == 'On'));
}),
_toggleRow('Beginner Mode', _m.noviceMode ?? false, (bool on) {
setState(() => _m.noviceMode = on);
_run('Beginner mode', () => _dji.setNoviceMode(on));
}),
_toggleRow('AR Home Point', _m.arHomePoint, (bool on) => setState(() => _m.arHomePoint = on)),
];
List<Widget> _controlRows() => <Widget>[
_stepperRow('Max Distance', '${_m.maxRadius} m', () => _editNumber('Max Distance', _m.maxRadius ?? 500, 50, 5000, 50, (int v) {
setState(() => _m.maxRadius = v);
_run('Max distance', () => _dji.setMaxFlightRadius(v));
})),
_infoRow('Set Home to Current', 'Tap', onTap: () => _run('Set home', _dji.setHomeToCurrent)),
_infoRow('Cancel Return-to-Home', 'Tap', onTap: () => _run('Cancel RTH', _dji.cancelGoHome)),
];
List<Widget> _cameraRows() => <Widget>[
_infoRow('Exposure', _m.exposureProgram == ExposureProgram.pro ? 'Pro' : 'Auto'),
_infoRow('ISO', _m.iso ?? '—'),
_infoRow('Shutter', _m.shutter ?? '—'),
_infoRow('White Balance', _m.whiteBalance ?? 'Auto'),
];
List<Widget> _infoRows(List<(String, String)> items) =>
<Widget>[for (final (String l, String v) in items) _infoRow(l, v)];
Widget _rowShell({required Widget child}) => Container(
height: 44,
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Color(0x12FFFFFF)))),
child: child,
);
Widget _label(String l) => Text(l, style: const TextStyle(fontFamily: PV.fontSans, fontSize: 13.5, color: Glass.ink));
Widget _stepperRow(String label, String value, VoidCallback onTap) => _rowShell(
child: InkWell(
onTap: onTap,
child: Row(children: <Widget>[
Expanded(child: _label(label)),
Text(value, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 13, fontWeight: FontWeight.w700, color: Color(0xFF8FB4F6))),
const SizedBox(width: 4),
const PVIcon('chevronRight', size: 15, color: Color(0xFF8FB4F6)),
]),
),
);
Widget _infoRow(String label, String value, {VoidCallback? onTap}) => _rowShell(
child: InkWell(
onTap: onTap,
child: Row(children: <Widget>[
Expanded(child: _label(label)),
Text(value, style: TextStyle(fontFamily: PV.fontMono, fontSize: 13, color: onTap == null ? const Color(0x99EAF0FA) : const Color(0xFF8FB4F6))),
]),
),
);
Widget _cycleRow(String label, String value, List<String> options, ValueChanged<String> onChanged) => _rowShell(
child: InkWell(
onTap: () {
final int i = (options.indexOf(value) + 1) % options.length;
onChanged(options[i]);
},
child: Row(children: <Widget>[
Expanded(child: _label(label)),
Text(value, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 13, fontWeight: FontWeight.w700, color: Color(0xFF8FB4F6))),
const SizedBox(width: 4),
const PVIcon('chevronRight', size: 15, color: Color(0xFF8FB4F6)),
]),
),
);
Widget _toggleRow(String label, bool value, ValueChanged<bool> onChanged) => _rowShell(
child: Row(children: <Widget>[
Expanded(child: _label(label)),
GestureDetector(
onTap: () => onChanged(!value),
child: Container(
width: 40, height: 22,
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(color: value ? const Color(0xFF3D7BF0) : const Color(0x26FFFFFF), borderRadius: BorderRadius.circular(999)),
alignment: value ? Alignment.centerRight : Alignment.centerLeft,
child: Container(width: 18, height: 18, decoration: const BoxDecoration(color: Colors.white, shape: BoxShape.circle)),
),
),
]),
);
Future<void> _editNumber(String title, int initial, int min, int max, int step, ValueChanged<int> onSet) async {
int value = initial;
await showDialog<void>(
context: context,
builder: (BuildContext ctx) => StatefulBuilder(
builder: (BuildContext ctx, StateSetter set) => AlertDialog(
backgroundColor: const Color(0xFF10203F),
title: Text(title, style: const TextStyle(color: Glass.ink, fontFamily: PV.fontSans, fontWeight: FontWeight.w700)),
content: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: <Widget>[
IconButton(onPressed: () => set(() => value = (value - step).clamp(min, max)), icon: const Icon(Icons.remove, color: Glass.ink)),
Text('$value m', style: const TextStyle(fontFamily: PV.fontMono, fontSize: 22, fontWeight: FontWeight.w700, color: Glass.ink)),
IconButton(onPressed: () => set(() => value = (value + step).clamp(min, max)), icon: const Icon(Icons.add, color: Glass.ink)),
]),
actions: <Widget>[
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancel')),
FilledButton(onPressed: () {
Navigator.pop(ctx);
onSet(value);
}, child: const Text('Set')),
],
),
),
);
}
}