Rebuild the Flutter/DJI-MSDK-V4 Fly App to the v2 UI kit and wire the full SDK surface behind it. Native (Kotlin): split DjiSdkBridge into a method/event router delegating to per-subsystem SubBridge helpers sharing a BridgeCtx — FlightController (takeoff/land/RTH + rich telemetry), Camera (mode/record/photo/exposure), Gimbal, Mission (Waypoint + ActiveTrack; QuickShots via ActiveTrack QUICK_SHOT), Media (MediaManager list/thumbnail/download), and optional DJI account login. Manifest gains scoped media permissions. Flutter: ten screens under lib/ui/ (Flight HUD, capture modes, camera settings, settings menu, map+waypoints, home, album, academy, profile, routes/flight logs), driven by an expanded FlightModel. New PVIcon renders the kit's SVG paths via flutter_svg; map uses flutter_map + latlong2. Pin transitive androidx.core/browser down to SDK-35-compatible versions so the newer plugins don't force AGP 8.9.1 onto the DJI toolchain. Verified with `flutter build apk --debug` (compiles Dart + all Kotlin); runtime behaviour is untested here — it needs a physical DJI-connected device. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
280 lines
12 KiB
Dart
280 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 ?? '—'), ('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')),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|