The GO 4-parity HUD has to work on every aircraft MSDK V4 drives, so no screen may assume one model. This adds the capability layer the rest of the plan builds on (P0). ProductProfileBridge reads what the SDK can report at runtime (cameras and their ISO/shutter/aperture/EV ranges, gimbal axes, air link, FC and RC feature flags, batteries) and emits it as a productProfile event on connect and component change. Capability ranges stay empty until the camera handshakes, so it re-probes at 3, 10 and 30 s and emits only when something changed. On the Dart side, ProductProfile merges that with a static product_matrix keyed by the MSDK Model enum name, for facts the SDK cannot report: vision sensor layout, battery bay count, intelligent modes. SDK answers win and every fact records its source. HudFeatures lists each capability-gated element with its predicate, and Requires hides a widget the profile rules out. The flight page now gates its gimbal slider, camera column and vision pill, labels the link Wi-Fi or HD, and takes the gimbal pitch range from the profile. Settings > About > Capability profile shows the active profile, fact sources and which features are on. Matrix rows are seeded from published specs until runtime capture confirms them; nothing has run on a real aircraft yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
296 lines
12 KiB
Dart
296 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 '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 _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),
|
|
]),
|
|
};
|
|
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')),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|