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>
285 lines
12 KiB
Dart
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')),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|