getSerialNumber() is a BaseComponent method, so every component answers for
itself — and the bridge reads it off the flight controller. A Mavic Pro reports
08RDE1J00103H1 (what DJI Go labels "Flight Controller SN") where the airframe
sticker, and the registration, say 08QDE3H012032E. We were publishing the former
as the drone's serial, onto records that exist to satisfy BEK 1649 §5.
Same trap as 002e484, where a component's own firmware stood in for the
aircraft's, but with no correct source to switch to: MSDK v4 exposes no
aircraft-level serial at all — BaseProduct offers only the model and the
firmware package version — so the registered serial can only be typed by hand.
So split the two rather than pick one:
serial the airframe's, hand-entered, and the only one that
reaches the logbook and the CSV export
flight_controller_serial what the aircraft reports; auto-filled on connect,
and what POST /api/drones/auto now upserts on
Keying auto-add on the flight controller's serial keeps the fleet recognising a
connected drone without typing — it is stable per airframe — while leaving the
compliance record's serial to the pilot. A flight controller swapped in a repair
now costs a duplicate fleet entry to merge, where before it would have quietly
rewritten what the logbook claimed the drone was.
Note droneInput.payload() is a whole-record write, so any UI editing a drone must
round-trip flightControllerSerial; blanking it forks the drone into a duplicate
on its next connect. Drones.vue carries it through the edit form for that reason.
The migration copies existing serials into flight_controller_serial rather than
moving them: every current value came from auto-add and is therefore a flight
controller's, but a pilot may since have corrected one by hand and this cannot
tell them apart. Copying keeps auto-add matching the airframes it matched before.
Applied to the remote PocketBase, where drones held no records, so the backfill
was a no-op there.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
286 lines
12 KiB
Dart
286 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 ?? '—'),
|
|
('Flight Controller SN', _m.flightControllerSerial ?? '—'),
|
|
('Firmware', _m.firmwareVersion ?? '—'),
|
|
('Controller Firmware', _m.controllerFirmwareVersion ?? '—'),
|
|
('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')),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|