Files
PilotVault/Fly App/lib/ui/fly/modes/mode_panels.dart
T
tajniak81andClaude Opus 5.5 636da1ac2c Give the Fly App every remaining GO 4 flight mode and the missing settings
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>
2026-10-20 23:06:16 +02:00

698 lines
29 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
),
),
]),
],
);
},
);
}
}