Files
PilotVault/Fly App/lib/ui/settings_menu_page.dart
T
tajniak81andClaude Opus 4.8 ec4b1bcdd3 Show drone serial number and firmware in the Fly App
Firmware was already half-wired: the bridge read
BaseProduct.getFirmwarePackageVersion() once, at connect. The SDK
returns null there until it has finished handshaking with the
aircraft, so the About panel almost always showed "-" instead. The
serial was never fetched at all.

Resolve both asynchronously after connect. fetchIdentity() reads the
serial from FlightController.getSerialNumber() and the firmware from
the product package version, falling back to the component-level
getFirmwareVersion() for aircraft that only report the latter. It
re-checks every 2s (max 6 attempts) and emits each value on a new
`identity` event as it lands, so the serial still appears when
firmware never resolves. Values are cached to answer getProductInfo
without re-fetching, and cleared on disconnect.

The SDK delivers lifecycle callbacks on arbitrary threads, so all
identity mutation hops onto the main thread, guarded by a generation
counter that strands retries queued for a product that has since
changed or dropped - otherwise a reconnect could race a stale chain
and report the previous aircraft.

Verified via flutter analyze and an APK build (which type-checks the
new MSDK calls). Runtime timing and the reported values still need a
physical aircraft.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 13:49:57 +02:00

285 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 '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 _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', _infoRows(<(String, String)>[
('Model', _m.model ?? '—'),
('Serial Number', _m.serialNumber ?? '—'),
('Firmware', _m.firmwareVersion ?? '—'),
('MSDK', _m.sdkVersion),
])),
};
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')),
],
),
),
);
}
}