Intelligent modes: Point of Interest (orbit on the map, live radius and speed), Follow Me (follows the phone), QuickShots on a boxed subject, Course Lock / Home Lock, Tripod and Cinematic, a Free hyperlapse, and Waypoints 2.0 for V2-only aircraft. ActiveTrack gains Spotlight Pro and Circle. Every mode has a first-use tutorial and stops from the rail. Waypoints can resume an interrupted mission, change cruise speed in flight and check terrain clearance (Open-Meteo, on tap). Settings: units, C1/C2 actions, dual-operator master/slave, HDMI/SDI output, a link spectrum chart, Wi-Fi name and password, aircraft name, video cache, battery warning history and pairing, non-smart cell voltages. Calibration: compass interference meter, per-IMU calibration, lens infinity focus and a first-manual-focus prompt. Also an RTK chip, gimbal movement presets and motor tuning, Precision Takeoff gated on the flight controller, and the precise GEO database's server version. Built and unit/widget-tested only; nothing has flown yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
698 lines
29 KiB
Dart
698 lines
29 KiB
Dart
import 'dart:math' as math;
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_map/flutter_map.dart';
|
||
import 'package:latlong2/latlong.dart' show LatLng;
|
||
|
||
import '../../../dji_service.dart';
|
||
import '../../../flight_model.dart';
|
||
import '../../../fly/hud_geometry.dart' show formatDuration;
|
||
import '../../../fly/modes/intelligent_modes.dart';
|
||
import '../../../fly/modes/orbit_modes.dart';
|
||
import '../../../fly/product_profile.dart' show IntelligentMode;
|
||
import '../../../fly/safety_gate.dart';
|
||
import '../../../fly/settings/settings_spec.dart';
|
||
import '../../../fly/state/key_state.dart';
|
||
import '../../../fly/state/mission_state.dart';
|
||
import '../../../fly/state/parse.dart';
|
||
import '../../../theme.dart';
|
||
import '../../pv_icons.dart';
|
||
import '../dialogs/slide_to_confirm.dart';
|
||
import '../flight_command_flow.dart';
|
||
import '../hud_kit.dart';
|
||
import '../panels/settings/setting_tiles.dart';
|
||
import 'mode_picker.dart' show ensureModeTutorial;
|
||
import 'waypoints/point_editor.dart' show HudChip;
|
||
|
||
/// Confirms a mission-type mode with its gate checks and starts it: always
|
||
/// slide-to-confirm, since the aircraft goes off on its own; blockers show
|
||
/// their reasons and no slider.
|
||
Future<void> confirmAndStart(
|
||
BuildContext context, {
|
||
required String title,
|
||
required String icon,
|
||
required String description,
|
||
required List<GateCheck> checks,
|
||
required Future<void> Function() start,
|
||
required void Function(String label, Object error) onError,
|
||
String slider = 'Slide to start',
|
||
}) async {
|
||
final SlideResult? r = await showSlideToConfirm(
|
||
context,
|
||
SlideConfirmSpec(icon: icon, title: title, description: description, sliderLabel: slider, checks: checks),
|
||
);
|
||
if (r == null) return;
|
||
try {
|
||
await start();
|
||
} catch (e) {
|
||
onError(title, e);
|
||
}
|
||
}
|
||
|
||
/// The frame every mode panel shares: icon, title, status, Stop while it
|
||
/// flies, a "?" for the tutorial, Exit — then the panel's own rows.
|
||
class ModePanelFrame extends StatelessWidget {
|
||
const ModePanelFrame({
|
||
super.key,
|
||
required this.mode,
|
||
required this.title,
|
||
required this.icon,
|
||
required this.status,
|
||
required this.statusColor,
|
||
required this.children,
|
||
required this.onExit,
|
||
this.onStop,
|
||
this.note,
|
||
});
|
||
|
||
final IntelligentMode mode;
|
||
final String title;
|
||
final String icon;
|
||
final String status;
|
||
final Color statusColor;
|
||
final List<Widget> children;
|
||
final VoidCallback onExit;
|
||
|
||
/// Shown while the mode flies the aircraft.
|
||
final VoidCallback? onStop;
|
||
final String? note;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
key: ValueKey<String>('mode-panel-${mode.name}'),
|
||
constraints: const BoxConstraints(maxWidth: 420),
|
||
padding: const EdgeInsets.fromLTRB(10, 6, 6, 6),
|
||
decoration: BoxDecoration(color: Glass.pillStrong, borderRadius: BorderRadius.circular(10), border: Border.all(color: Glass.hairline)),
|
||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
|
||
Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||
PVIcon(icon, size: 13, color: Glass.ink),
|
||
const SizedBox(width: 6),
|
||
Text(title, style: HudText.label.copyWith(color: Glass.ink, fontWeight: FontWeight.w700)),
|
||
const SizedBox(width: 8),
|
||
Flexible(child: Text(status, overflow: TextOverflow.ellipsis, style: HudText.label.copyWith(color: statusColor, fontWeight: FontWeight.w700))),
|
||
const SizedBox(width: 8),
|
||
if (onStop != null) ...<Widget>[
|
||
ModeButton(id: 'stop', icon: 'stop', label: 'STOP', color: HudColors.danger, onTap: onStop),
|
||
const SizedBox(width: 4),
|
||
],
|
||
ModeButton(
|
||
id: 'help',
|
||
icon: 'info',
|
||
label: '?',
|
||
color: Glass.pill,
|
||
onTap: () => ensureModeTutorial(context, ModeCatalog.of(mode), force: true),
|
||
),
|
||
const SizedBox(width: 4),
|
||
ModeButton(id: 'exit', icon: 'close', label: 'EXIT', color: Glass.pill, onTap: onExit),
|
||
]),
|
||
if (note != null) Padding(padding: const EdgeInsets.only(top: 3), child: Text(note!, style: HudText.label.copyWith(color: HudColors.caution))),
|
||
const SizedBox(height: 5),
|
||
...children,
|
||
]),
|
||
);
|
||
}
|
||
}
|
||
|
||
class ModeButton extends StatelessWidget {
|
||
const ModeButton({super.key, required this.id, required this.icon, required this.label, required this.color, required this.onTap});
|
||
|
||
final String id;
|
||
final String icon;
|
||
final String label;
|
||
final Color color;
|
||
final VoidCallback? onTap;
|
||
|
||
@override
|
||
Widget build(BuildContext context) => Opacity(
|
||
opacity: onTap == null ? 0.4 : 1,
|
||
child: GestureDetector(
|
||
key: ValueKey<String>('mode-$id'),
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: onTap,
|
||
child: Container(
|
||
height: 24,
|
||
padding: const EdgeInsets.symmetric(horizontal: 7),
|
||
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(7), border: Border.all(color: Glass.hairline)),
|
||
child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||
PVIcon(icon, size: 11, color: Colors.white),
|
||
const SizedBox(width: 3),
|
||
Text(label, style: HudText.label.copyWith(color: Colors.white, fontWeight: FontWeight.w700)),
|
||
]),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// A compact − value + for the mode panels.
|
||
class ModeStepper extends StatelessWidget {
|
||
const ModeStepper({super.key, required this.id, required this.label, required this.value, required this.onStep, this.canDown = true, this.canUp = true});
|
||
|
||
final String id;
|
||
final String label;
|
||
final String value;
|
||
final ValueChanged<int> onStep;
|
||
final bool canDown;
|
||
final bool canUp;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
Widget b(String l, int d, bool on) => Opacity(
|
||
opacity: on ? 1 : 0.35,
|
||
child: GestureDetector(
|
||
key: ValueKey<String>('$id-${d > 0 ? 'plus' : 'minus'}'),
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: on ? () => onStep(d) : null,
|
||
child: Container(
|
||
width: 24,
|
||
height: 22,
|
||
decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(6), border: Border.all(color: Glass.hairline)),
|
||
alignment: Alignment.center,
|
||
child: Text(l, style: HudText.mono.copyWith(fontWeight: FontWeight.w700)),
|
||
),
|
||
),
|
||
);
|
||
return Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||
Text(label, style: HudText.label),
|
||
const SizedBox(width: 4),
|
||
b('−', -1, canDown),
|
||
SizedBox(width: 58, child: Text(value, key: ValueKey<String>(id), textAlign: TextAlign.center, style: HudText.mono)),
|
||
b('+', 1, canUp),
|
||
]);
|
||
}
|
||
}
|
||
|
||
Future<void> _guard(String label, Future<void> Function() f, void Function(String, Object) onError) async {
|
||
try {
|
||
await f();
|
||
} catch (e) {
|
||
onError(label, e);
|
||
}
|
||
}
|
||
|
||
// ── Point of Interest ─────────────────────────────────────────────────────────
|
||
|
||
/// Point of Interest (plan P7): set the centre (tap the map, or the
|
||
/// aircraft's position), radius, altitude, speed, direction and heading; GO;
|
||
/// then radius and speed live, Pause / Resume and Stop.
|
||
class PoiPanel extends StatelessWidget {
|
||
const PoiPanel({super.key, required this.model, required this.setup, required this.gate, required this.flow, required this.dji, required this.onExit, required this.onError});
|
||
|
||
final FlightModel model;
|
||
final ModeSetup setup;
|
||
final SafetyGate gate;
|
||
final FlightCommandFlow flow;
|
||
final DjiService dji;
|
||
final VoidCallback onExit;
|
||
final void Function(String label, Object error) onError;
|
||
|
||
Future<void> _go(BuildContext context) async {
|
||
final PoiSpec p = setup.poi;
|
||
double? max = setup.poiMaxAngular;
|
||
if (max == null && model.connected) {
|
||
try {
|
||
max = await dji.getHotpointMaxAngularVelocity(p.radius);
|
||
setup.setPoiMaxAngular(max);
|
||
} catch (_) {}
|
||
}
|
||
if (!context.mounted) return;
|
||
await confirmAndStart(
|
||
context,
|
||
title: 'Point of Interest',
|
||
icon: 'circle',
|
||
description: '${p.clockwise ? 'Clockwise' : 'Anticlockwise'} at ${p.radius.round()} m, ${p.altitude.round()} m up, '
|
||
'${p.speed.toStringAsFixed(1)} m/s (a lap in about ${formatDuration(p.lapSeconds)}). Facing: ${p.heading.label.toLowerCase()}.',
|
||
checks: gate.checkPoi(p, maxAngular: max),
|
||
start: () => gate.startPoi(p, maxAngular: max),
|
||
onError: onError,
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return ListenableBuilder(
|
||
listenable: Listenable.merge(<Listenable>[model.mission, setup]),
|
||
builder: (BuildContext context, _) {
|
||
final HotpointProgress run = model.mission.hotpoint;
|
||
final PoiSpec p = setup.poi;
|
||
final bool active = run.active;
|
||
void set(PoiSpec n) => setup.setPoi(n);
|
||
void live({double? radius, double? speed}) => _guard(
|
||
'Point of Interest',
|
||
() => gate.adjustPoi(radius: radius, speed: speed, clockwise: p.clockwise, currentRadius: run.radius ?? p.radius),
|
||
onError,
|
||
);
|
||
return ModePanelFrame(
|
||
mode: IntelligentMode.pointOfInterest,
|
||
title: 'POINT OF INTEREST',
|
||
icon: 'circle',
|
||
status: active ? run.label.toUpperCase() : (p.center == null ? 'TAP THE MAP ON THE SUBJECT' : 'READY'),
|
||
statusColor: active ? (run.paused ? HudColors.caution : HudColors.ready) : HudText.dim,
|
||
onStop: active ? () => flow.request(context, FlightCommand.stopMode) : null,
|
||
onExit: onExit,
|
||
children: <Widget>[
|
||
Wrap(spacing: 6, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: <Widget>[
|
||
if (!active)
|
||
HudChip(
|
||
key: const ValueKey<String>('poi-aircraft'),
|
||
label: 'Centre: aircraft',
|
||
selected: false,
|
||
onTap: model.aircraft.latitude == null
|
||
? null
|
||
: () => set(p.copyWith(center: LatLng(model.aircraft.latitude!, model.aircraft.longitude!))),
|
||
),
|
||
ModeStepper(
|
||
id: 'poi-radius',
|
||
label: 'R',
|
||
value: '${(active ? (run.radius ?? p.radius) : p.radius).round()} m',
|
||
canDown: p.radius > PoiSpec.minRadius,
|
||
canUp: p.radius < PoiSpec.maxRadius,
|
||
onStep: (int d) {
|
||
final double r = (p.radius + d * 5).clamp(PoiSpec.minRadius, PoiSpec.maxRadius);
|
||
set(p.copyWith(radius: r));
|
||
if (active) live(radius: r);
|
||
},
|
||
),
|
||
if (!active)
|
||
ModeStepper(
|
||
id: 'poi-altitude',
|
||
label: 'H',
|
||
value: '${p.altitude.round()} m',
|
||
canDown: p.altitude > PoiSpec.minAltitude,
|
||
canUp: p.altitude < PoiSpec.maxAltitude,
|
||
onStep: (int d) => set(p.copyWith(altitude: (p.altitude + d * 5).clamp(PoiSpec.minAltitude, PoiSpec.maxAltitude))),
|
||
),
|
||
ModeStepper(
|
||
id: 'poi-speed',
|
||
label: 'V',
|
||
value: '${p.speed.toStringAsFixed(1)} m/s',
|
||
canDown: p.speed > PoiSpec.minSpeed,
|
||
canUp: p.speed < PoiSpec.maxSpeed,
|
||
onStep: (int d) {
|
||
final double v = (p.speed + d * 0.5).clamp(PoiSpec.minSpeed, PoiSpec.maxSpeed);
|
||
set(p.copyWith(speed: v));
|
||
if (active) live(speed: v);
|
||
},
|
||
),
|
||
]),
|
||
if (!active) ...<Widget>[
|
||
const SizedBox(height: 4),
|
||
Wrap(spacing: 4, runSpacing: 4, children: <Widget>[
|
||
HudChip(key: const ValueKey<String>('poi-cw'), label: 'Clockwise', selected: p.clockwise, onTap: () => set(p.copyWith(clockwise: true))),
|
||
HudChip(key: const ValueKey<String>('poi-ccw'), label: 'Anticlockwise', selected: !p.clockwise, onTap: () => set(p.copyWith(clockwise: false))),
|
||
for (final PoiHeading h in PoiHeading.values)
|
||
HudChip(key: ValueKey<String>('poi-heading-${h.name}'), label: h.label, selected: p.heading == h, onTap: () => set(p.copyWith(heading: h))),
|
||
]),
|
||
const SizedBox(height: 4),
|
||
Wrap(spacing: 4, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: <Widget>[
|
||
Text('JOIN', style: HudText.label),
|
||
for (final PoiStart s in PoiStart.values)
|
||
HudChip(key: ValueKey<String>('poi-start-${s.name}'), label: s.label, selected: p.start == s, onTap: () => set(p.copyWith(start: s))),
|
||
const SizedBox(width: 2),
|
||
ModeButton(id: 'poi-go', icon: 'play2', label: 'GO', color: Glass.accent, onTap: p.center == null ? null : () => _go(context)),
|
||
]),
|
||
] else ...<Widget>[
|
||
const SizedBox(height: 4),
|
||
Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||
ModeButton(
|
||
id: run.paused ? 'poi-resume' : 'poi-pause',
|
||
icon: run.paused ? 'play2' : 'pause',
|
||
label: run.paused ? 'RESUME' : 'PAUSE',
|
||
color: Glass.accent,
|
||
onTap: () => _guard(run.paused ? 'Resume' : 'Pause', run.paused ? gate.resumePoi : gate.pausePoi, onError),
|
||
),
|
||
const SizedBox(width: 6),
|
||
ModeButton(
|
||
id: 'poi-reset-heading',
|
||
icon: 'compass',
|
||
label: 'FACE POI',
|
||
color: Glass.pill,
|
||
onTap: () => _guard('Reset heading', dji.resetHotpointHeading, onError),
|
||
),
|
||
]),
|
||
],
|
||
],
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
/// The orbit on the map: the circle and its centre, editable before GO.
|
||
List<Widget> poiMapLayers(ModeSetup setup, MissionState mission) => <Widget>[
|
||
ListenableBuilder(
|
||
listenable: Listenable.merge(<Listenable>[setup, mission]),
|
||
builder: (BuildContext context, _) {
|
||
final LatLng? c = setup.poi.center;
|
||
if (c == null) return const SizedBox.shrink();
|
||
final double r = mission.hotpoint.active ? (mission.hotpoint.radius ?? setup.poi.radius) : setup.poi.radius;
|
||
return PolylineLayer<Object>(polylines: <Polyline<Object>>[
|
||
Polyline<Object>(points: _circle(c, r), color: const Color(0xCC8E6CFF), strokeWidth: 2.5),
|
||
]);
|
||
},
|
||
),
|
||
ListenableBuilder(
|
||
listenable: setup,
|
||
builder: (BuildContext context, _) {
|
||
final LatLng? c = setup.poi.center;
|
||
if (c == null) return const SizedBox.shrink();
|
||
return MarkerLayer(markers: <Marker>[
|
||
Marker(
|
||
point: c,
|
||
width: 22,
|
||
height: 22,
|
||
child: Container(
|
||
key: const ValueKey<String>('poi-center'),
|
||
decoration: BoxDecoration(color: const Color(0xFF8E6CFF), shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2)),
|
||
),
|
||
),
|
||
]);
|
||
},
|
||
),
|
||
];
|
||
|
||
/// The orbit as a 72-sided polygon; flat-earth offsets are exact enough at 500 m.
|
||
List<LatLng> _circle(LatLng c, double r) {
|
||
const double metresPerDegree = 111320;
|
||
final double cosLat = math.cos(c.latitude * math.pi / 180);
|
||
return <LatLng>[
|
||
for (int i = 0; i <= 72; i++)
|
||
LatLng(
|
||
c.latitude + r * math.cos(i * 5 * math.pi / 180) / metresPerDegree,
|
||
c.longitude + r * math.sin(i * 5 * math.pi / 180) / (metresPerDegree * cosLat),
|
||
),
|
||
];
|
||
}
|
||
|
||
// ── Follow Me ─────────────────────────────────────────────────────────────────
|
||
|
||
class FollowMePanel extends StatelessWidget {
|
||
const FollowMePanel({super.key, required this.model, required this.setup, required this.gate, required this.flow, required this.onExit, required this.onError});
|
||
|
||
final FlightModel model;
|
||
final ModeSetup setup;
|
||
final SafetyGate gate;
|
||
final FlightCommandFlow flow;
|
||
final VoidCallback onExit;
|
||
final void Function(String label, Object error) onError;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return ListenableBuilder(
|
||
listenable: Listenable.merge(<Listenable>[model.mission, setup]),
|
||
builder: (BuildContext context, _) {
|
||
final FollowMeProgress run = model.mission.followMe;
|
||
final FollowMeSpec f = setup.followMe;
|
||
final bool active = run.active;
|
||
return ModePanelFrame(
|
||
mode: IntelligentMode.followMe,
|
||
title: 'FOLLOW ME',
|
||
icon: 'user',
|
||
status: active
|
||
? (run.stuck ? 'STUCK — STEER WITH THE STICKS' : 'FOLLOWING${run.distance == null ? '' : ' · ${run.distance!.round()} m'}')
|
||
: (model.phoneLatitude == null ? 'WAITING FOR THE PHONE\'S GPS' : 'READY'),
|
||
statusColor: active ? (run.stuck ? HudColors.caution : HudColors.ready) : HudText.dim,
|
||
onStop: active ? () => flow.request(context, FlightCommand.stopMode) : null,
|
||
onExit: onExit,
|
||
children: <Widget>[
|
||
if (!active)
|
||
Wrap(spacing: 6, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: <Widget>[
|
||
ModeStepper(
|
||
id: 'follow-altitude',
|
||
label: 'H',
|
||
value: '${f.altitude.round()} m',
|
||
canDown: f.altitude > FollowMeSpec.minAltitude,
|
||
canUp: f.altitude < FollowMeSpec.maxAltitude,
|
||
onStep: (int d) => setup.setFollowMe(f.copyWith(altitude: (f.altitude + d * 5).clamp(FollowMeSpec.minAltitude, FollowMeSpec.maxAltitude))),
|
||
),
|
||
for (final FollowMeHeading h in FollowMeHeading.values)
|
||
HudChip(key: ValueKey<String>('follow-${h.name}'), label: h.label, selected: f.heading == h, onTap: () => setup.setFollowMe(f.copyWith(heading: h))),
|
||
ModeButton(
|
||
id: 'follow-go',
|
||
icon: 'play2',
|
||
label: 'GO',
|
||
color: Glass.accent,
|
||
onTap: () => confirmAndStart(
|
||
context,
|
||
title: 'Follow Me',
|
||
icon: 'user',
|
||
description: 'The aircraft follows this phone at ${f.altitude.round()} m. Keep the app open; STOP or the remote\'s pause button ends it.',
|
||
checks: gate.checkFollowMe(f),
|
||
start: () => gate.startFollowMe(f),
|
||
onError: onError,
|
||
),
|
||
),
|
||
]),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Course Lock / Home Lock ───────────────────────────────────────────────────
|
||
|
||
class OrientationPanel extends StatelessWidget {
|
||
const OrientationPanel({super.key, required this.mode, required this.model, required this.gate, required this.onExit, required this.onError});
|
||
|
||
/// The picker tile it was opened from (Course Lock or Home Lock).
|
||
final IntelligentMode mode;
|
||
final FlightModel model;
|
||
final SafetyGate gate;
|
||
final VoidCallback onExit;
|
||
final void Function(String label, Object error) onError;
|
||
|
||
Future<void> _set(BuildContext context, OrientationMode m) async {
|
||
final List<GateCheck> checks = gate.checkOrientation(m);
|
||
if (m == OrientationMode.normal) {
|
||
await _guard('Orientation', () => gate.setOrientation(m), onError);
|
||
return;
|
||
}
|
||
await confirmAndStart(
|
||
context,
|
||
title: m.label,
|
||
icon: m == OrientationMode.homeLock ? 'home' : 'compass',
|
||
description: m == OrientationMode.homeLock
|
||
? 'Back on the stick flies towards home; turn it off to fly normally again.'
|
||
: 'Forward on the stick is the current heading; turn it off to fly normally again.',
|
||
checks: checks,
|
||
start: () => gate.setOrientation(m),
|
||
onError: onError,
|
||
slider: 'Slide to turn on',
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return ListenableBuilder(
|
||
listenable: model.aircraft,
|
||
builder: (BuildContext context, _) {
|
||
final OrientationMode now = OrientationMode.fromSdk(model.aircraft.orientationMode);
|
||
return ModePanelFrame(
|
||
mode: mode,
|
||
title: 'ORIENTATION',
|
||
icon: mode == IntelligentMode.homeLock ? 'home' : 'compass',
|
||
status: now.label.toUpperCase(),
|
||
statusColor: now == OrientationMode.normal ? HudText.dim : HudColors.caution,
|
||
onExit: onExit,
|
||
children: <Widget>[
|
||
Wrap(spacing: 4, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: <Widget>[
|
||
for (final OrientationMode m in OrientationMode.values)
|
||
HudChip(key: ValueKey<String>('ioc-${m.name}'), label: m.label, selected: now == m, onTap: now == m ? null : () => _set(context, m)),
|
||
if (now == OrientationMode.courseLock)
|
||
ModeButton(
|
||
id: 'ioc-lock',
|
||
icon: 'compass',
|
||
label: 'LOCK HEADING',
|
||
color: Glass.accent,
|
||
onTap: () => _guard('Lock heading', gate.lockCourse, onError),
|
||
),
|
||
]),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Tripod / Cinematic ────────────────────────────────────────────────────────
|
||
|
||
/// Tripod and Cinematic (plan P7): switches plus Cinematic's brake and yaw
|
||
/// tuning, each row hidden where the flight controller says it has no such key.
|
||
class SlowModePanel extends StatefulWidget {
|
||
const SlowModePanel({super.key, required this.model, required this.dji, required this.gate, required this.onExit, required this.onError});
|
||
|
||
final FlightModel model;
|
||
final DjiService dji;
|
||
final SafetyGate gate;
|
||
final VoidCallback onExit;
|
||
final void Function(String label, Object error) onError;
|
||
|
||
static const DjiKey tripod = DjiKey.flightController('TRIPOD_MODE_ENABLED');
|
||
static const DjiKey cinematic = DjiKey.flightController('CINEMATIC_MODE_ENABLED');
|
||
static const DjiKey brake = DjiKey.flightController('CINEMATIC_BRAKE_SENSITIVITY');
|
||
static const DjiKey brakeRange = DjiKey.flightController('CINEMATIC_BRAKE_SENSITIVITY_RANGE');
|
||
static const DjiKey yaw = DjiKey.flightController('CINEMATIC_YAW_SPEED');
|
||
static const DjiKey yawRange = DjiKey.flightController('CINEMATIC_YAW_SPEED_RANGE');
|
||
|
||
static const List<SettingSpec> tuning = <SettingSpec>[
|
||
KeyNumberSpec(id: 'cinematicBrake', label: 'Brake sensitivity', key: brake, rangeKey: brakeRange, min: 10, max: 100, step: 10),
|
||
KeyNumberSpec(id: 'cinematicYaw', label: 'Yaw speed', key: yaw, rangeKey: yawRange, min: 10, max: 100, step: 5, unit: '°/s'),
|
||
];
|
||
|
||
@override
|
||
State<SlowModePanel> createState() => _SlowModePanelState();
|
||
}
|
||
|
||
class _SlowModePanelState extends State<SlowModePanel> {
|
||
late final KeySubscriptions _subs = KeySubscriptions(widget.dji, widget.model.keys);
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
if (widget.model.connected) {
|
||
_subs.watch(<DjiKey>[SlowModePanel.tripod, SlowModePanel.cinematic, SlowModePanel.brake, SlowModePanel.brakeRange, SlowModePanel.yaw, SlowModePanel.yawRange]);
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_subs.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _toggle(BuildContext context, {required bool cinematic, required bool on}) async {
|
||
final KeyState k = widget.model.keys;
|
||
final DjiKey key = cinematic ? SlowModePanel.cinematic : SlowModePanel.tripod;
|
||
Future<void> send() async {
|
||
k.setPending(<String, Object?>{key.id: on});
|
||
try {
|
||
await widget.gate.setSlowMode(cinematic: cinematic, on: on);
|
||
} finally {
|
||
k.dropPending(<String>[key.id]);
|
||
}
|
||
}
|
||
|
||
if (!on) return _guard(cinematic ? 'Cinematic' : 'Tripod', send, widget.onError);
|
||
await confirmAndStart(
|
||
context,
|
||
title: cinematic ? 'Cinematic mode' : 'Tripod mode',
|
||
icon: 'gauge',
|
||
description: cinematic ? 'Smooth, slow braking and turns for steady video.' : 'About 1 m/s and very gentle sticks, for precise framing.',
|
||
checks: widget.gate.checkSlowMode(cinematic: cinematic, on: true),
|
||
start: send,
|
||
onError: widget.onError,
|
||
slider: 'Slide to turn on',
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final FlightModel m = widget.model;
|
||
return ListenableBuilder(
|
||
listenable: m.keys,
|
||
builder: (BuildContext context, _) {
|
||
final KeyState k = m.keys;
|
||
final bool tripod = asBool(k.value(SlowModePanel.tripod)) == true;
|
||
final bool cinematic = asBool(k.value(SlowModePanel.cinematic)) == true;
|
||
final SettingsContext c = SettingsContext(profile: m.profile, keys: k, aircraft: m.aircraft);
|
||
return ModePanelFrame(
|
||
mode: IntelligentMode.tripod,
|
||
title: 'TRIPOD · CINEMATIC',
|
||
icon: 'gauge',
|
||
status: tripod ? 'TRIPOD ON' : (cinematic ? 'CINEMATIC ON' : 'OFF'),
|
||
statusColor: tripod || cinematic ? HudColors.caution : HudText.dim,
|
||
onExit: widget.onExit,
|
||
children: <Widget>[
|
||
Wrap(spacing: 4, runSpacing: 4, children: <Widget>[
|
||
if (k.supported(SlowModePanel.tripod) != false)
|
||
HudChip(
|
||
key: const ValueKey<String>('slow-tripod'),
|
||
label: tripod ? 'Tripod on' : 'Tripod off',
|
||
selected: tripod,
|
||
onTap: () => _toggle(context, cinematic: false, on: !tripod),
|
||
),
|
||
if (k.supported(SlowModePanel.cinematic) != false)
|
||
HudChip(
|
||
key: const ValueKey<String>('slow-cinematic'),
|
||
label: cinematic ? 'Cinematic on' : 'Cinematic off',
|
||
selected: cinematic,
|
||
onTap: () => _toggle(context, cinematic: true, on: !cinematic),
|
||
),
|
||
]),
|
||
if (cinematic)
|
||
SizedBox(
|
||
width: 380,
|
||
child: Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||
for (final SettingSpec s in SlowModePanel.tuning)
|
||
if (s.visible(c))
|
||
SettingRowTile(spec: s, settings: c, dji: widget.dji, onError: widget.onError, custom: (CustomSpec _) => const SizedBox.shrink()),
|
||
]),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Hyperlapse ────────────────────────────────────────────────────────────────
|
||
|
||
class HyperlapsePanel extends StatelessWidget {
|
||
const HyperlapsePanel({super.key, required this.model, required this.setup, required this.gate, required this.onExit, required this.onError});
|
||
|
||
final FlightModel model;
|
||
final ModeSetup setup;
|
||
final SafetyGate gate;
|
||
final VoidCallback onExit;
|
||
final void Function(String label, Object error) onError;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return ListenableBuilder(
|
||
listenable: Listenable.merge(<Listenable>[model.camera, setup]),
|
||
builder: (BuildContext context, _) {
|
||
final HyperlapseSpec h = setup.hyperlapse;
|
||
final bool shooting = model.camera.shootingInterval;
|
||
return ModePanelFrame(
|
||
mode: IntelligentMode.hyperlapse,
|
||
title: 'HYPERLAPSE · FREE',
|
||
icon: 'hyperlapse',
|
||
status: shooting ? 'SHOOTING — FLY SLOWLY' : '${h.frames} FRAMES · ${formatDuration(h.shootingSeconds)}',
|
||
statusColor: shooting ? HudColors.ready : HudText.dim,
|
||
onStop: shooting ? () => _guard('Hyperlapse', gate.stopHyperlapse, onError) : null,
|
||
onExit: onExit,
|
||
children: <Widget>[
|
||
if (!shooting)
|
||
Wrap(spacing: 4, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: <Widget>[
|
||
Text('EVERY', style: HudText.label),
|
||
for (final int s in HyperlapseSpec.intervals)
|
||
HudChip(key: ValueKey<String>('hl-interval-$s'), label: '$s s', selected: h.intervalSeconds == s, onTap: () => setup.setHyperlapse(h.copyWith(intervalSeconds: s))),
|
||
const SizedBox(width: 6),
|
||
Text('VIDEO', style: HudText.label),
|
||
for (final int s in HyperlapseSpec.durations)
|
||
HudChip(key: ValueKey<String>('hl-length-$s'), label: '$s s', selected: h.videoSeconds == s, onTap: () => setup.setHyperlapse(h.copyWith(videoSeconds: s))),
|
||
ModeButton(
|
||
id: 'hl-start',
|
||
icon: 'play2',
|
||
label: 'START',
|
||
color: Glass.accent,
|
||
onTap: () => confirmAndStart(
|
||
context,
|
||
title: 'Hyperlapse',
|
||
icon: 'hyperlapse',
|
||
description: '${h.frames} photos, one every ${h.intervalSeconds} s (${formatDuration(h.shootingSeconds)}), '
|
||
'for ${h.videoSeconds} s of video at ${h.fps} fps. You fly; keep it slow and steady.',
|
||
checks: gate.checkHyperlapse(h),
|
||
start: () => gate.startHyperlapse(h),
|
||
onError: onError,
|
||
),
|
||
),
|
||
]),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|