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>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-20 23:06:16 +02:00
1 parent 91ca150a35
commit 636da1ac2c
57 files changed
+5363 -87

No files matched your search

+697
View File
@@ -0,0 +1,697 @@
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,
),
),
]),
],
);
},
);
}
}
+81 -2
View File
@@ -24,8 +24,87 @@ Future<IntelligentMode?> showModePicker(BuildContext context, {required ProductP
),
);
if (picked == null || !context.mounted) return null;
final bool ok = await ensureModeTerms(context, ModeCatalog.of(picked));
return ok ? picked : null;
final ModeInfo info = ModeCatalog.of(picked);
final bool ok = await ensureModeTerms(context, info);
if (!ok || !context.mounted) return null;
await ensureModeTutorial(context, info);
return picked;
}
/// GO 4's first-use tutorial, once per mode (and on demand with [force]).
Future<void> ensureModeTutorial(BuildContext context, ModeInfo info, {bool force = false}) async {
if (info.tutorial.isEmpty) return;
if (!force && FlyPrefs.seenTutorials.value.contains(info.mode.name)) return;
await showDialog<void>(
context: context,
barrierColor: const Color(0x99050A14),
builder: (BuildContext context) => ModeTutorialDialog(info: info),
);
await FlyPrefs.markTutorialSeen(info.mode.name);
}
/// The tutorial: one step per page, Back / Next, Done on the last.
class ModeTutorialDialog extends StatefulWidget {
const ModeTutorialDialog({super.key, required this.info});
final ModeInfo info;
@override
State<ModeTutorialDialog> createState() => _ModeTutorialDialogState();
}
class _ModeTutorialDialogState extends State<ModeTutorialDialog> {
int _page = 0;
@override
Widget build(BuildContext context) {
final List<String> steps = widget.info.tutorial;
final bool last = _page >= steps.length - 1;
return Dialog(
backgroundColor: const Color(0xF20B1426),
insetPadding: const EdgeInsets.symmetric(horizontal: 40, vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 12),
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
Row(children: <Widget>[
PVIcon(widget.info.icon, size: 18, color: Glass.ink),
const SizedBox(width: 8),
Expanded(child: Text('How to fly ${widget.info.label}', style: HudText.sans.copyWith(fontSize: 14, fontWeight: FontWeight.w700))),
Text('${_page + 1}/${steps.length}', style: HudText.label),
]),
const SizedBox(height: 14),
Text(steps[_page], key: const ValueKey<String>('tutorial-step'), style: HudText.sans.copyWith(fontSize: 13, fontWeight: FontWeight.w400, height: 1.4)),
const SizedBox(height: 14),
Row(children: <Widget>[
for (int i = 0; i < steps.length; i++)
Container(
width: 6,
height: 6,
margin: const EdgeInsets.only(right: 4),
decoration: BoxDecoration(color: i == _page ? Glass.accent : HudText.dim, shape: BoxShape.circle),
),
const Spacer(),
if (_page > 0)
TextButton(
onPressed: () => setState(() => _page--),
child: Text('Back', style: HudText.sans.copyWith(color: HudText.dim)),
),
const SizedBox(width: 6),
FilledButton(
key: const ValueKey<String>('tutorial-next'),
style: FilledButton.styleFrom(backgroundColor: PV.accent),
onPressed: last ? () => Navigator.of(context).pop() : () => setState(() => _page++),
child: Text(last ? 'Done' : 'Next', style: HudText.sans.copyWith(color: Colors.white)),
),
]),
]),
),
),
);
}
}
/// Shows the mode's terms the first time it is used; true once accepted.
@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import '../../../../flight_model.dart';
import '../../../../fly/modes/orbit_modes.dart' show QuickShot;
import '../../../../fly/modes/vision_modes.dart';
import '../../../../fly/product_profile.dart' show VisionSensors;
import '../../../../fly/safety_gate.dart';
import '../../../../fly/state/mission_state.dart';
import '../../../../theme.dart';
@@ -50,10 +52,14 @@ class VisionModePanel extends StatelessWidget {
required this.flow,
required this.onExit,
required this.onError,
this.quickShots = false,
});
/// TapFly's panel, or ActiveTrack's.
final bool tapFly;
/// QuickShots' panel: ActiveTrack's operator, the shot instead of the follow style.
final bool quickShots;
final FlightModel model;
final VisionSession session;
final SafetyGate gate;
@@ -76,11 +82,23 @@ class VisionModePanel extends StatelessWidget {
builder: (BuildContext context, _) {
final MissionState ms = model.mission;
final bool active = tapFly ? ms.tapFly.active : ms.activeTrack.active;
final String title = tapFly ? 'TAPFLY' : 'ACTIVETRACK';
final String title = tapFly ? 'TAPFLY' : (quickShots ? 'QUICKSHOTS' : 'ACTIVETRACK');
final String status;
final Color statusColor;
String? note;
if (tapFly) {
if (quickShots) {
final ActiveTrackProgress t = ms.activeTrack;
final bool filming = t.state == 'PERFORMING_QUICK_SHOT';
status = filming
? 'FILMING${t.percent == null ? '' : ' ${t.percent}%'}'
: t.active
? t.stateLabel.toUpperCase()
: session.pendingBox != null
? 'TAP GO'
: 'DRAW A BOX ROUND THE SUBJECT';
statusColor = t.cannotConfirm ? HudColors.danger : (filming ? HudColors.ready : (t.active ? HudColors.caution : HudText.dim));
note = t.cannotConfirm ? t.reasonLabel : (active ? null : session.quickShot.blurb);
} else if (tapFly) {
final TapFlyProgress f = ms.tapFly;
status = f.active ? (f.paused ? 'PAUSED' : 'FLYING') : (session.pendingPoint != null ? 'TAP GO' : 'TAP WHERE TO FLY');
statusColor = f.active ? (f.paused ? HudColors.caution : HudColors.ready) : HudText.dim;
@@ -98,7 +116,21 @@ class VisionModePanel extends StatelessWidget {
note = t.cannotConfirm ? t.reasonLabel : null;
}
final List<Widget> chips = tapFly
final List<Widget> chips = quickShots
? <Widget>[
for (final QuickShot s in QuickShot.offered(
omni: model.profile.vision == VisionSensors.omni || !model.profile.connected,
zoom: model.profile.hasZoom || !model.profile.connected,
))
HudChip(
key: ValueKey<String>('quickshot-${s.name}'),
label: s.label,
selected: session.quickShot == s,
// A shot cannot change once it flies.
onTap: active ? null : () => session.setQuickShot(s),
),
]
: tapFly
? <Widget>[
for (final TapFlyDirection d in gate.tapFlyDirections())
HudChip(
@@ -110,7 +142,7 @@ class VisionModePanel extends StatelessWidget {
),
]
: <Widget>[
for (final TrackMode m in TrackMode.values)
for (final TrackMode m in TrackMode.offered(omni: model.profile.vision == VisionSensors.omni || !model.profile.connected))
HudChip(
key: ValueKey<String>('track-${m.name}'),
label: m.label,
@@ -134,6 +166,19 @@ class VisionModePanel extends StatelessWidget {
});
},
),
// Circle: the orbit speed, live while circling (negative = anticlockwise).
if (session.trackMode == TrackMode.circle)
_SpeedStepper(
speed: session.circleSpeed,
min: -10,
max: 10,
keyPrefix: 'track-circle',
onStep: (int d) {
final double v = (session.circleSpeed + d).clamp(-10.0, 10.0);
session.setCircleSpeed(v);
_guard('Circle speed', () => gate.setTrackCircleSpeed(v));
},
),
// The aircraft finds subjects itself; tap one instead of drawing.
if (ms.activeTrack.autoSensingSupported && !active)
HudChip(
@@ -145,13 +190,13 @@ class VisionModePanel extends StatelessWidget {
];
return Container(
key: ValueKey<String>(tapFly ? 'tapfly-panel' : 'track-panel'),
key: ValueKey<String>(tapFly ? 'tapfly-panel' : (quickShots ? 'quickshot-panel' : 'track-panel')),
constraints: const BoxConstraints(maxWidth: 390),
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(tapFly ? 'chevronsRight' : 'crosshair', size: 13, color: Glass.ink),
PVIcon(tapFly ? 'chevronsRight' : (quickShots ? 'dronie' : 'crosshair'), 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),
@@ -202,17 +247,26 @@ class VisionModePanel extends StatelessWidget {
}
class _SpeedStepper extends StatelessWidget {
const _SpeedStepper({required this.speed, required this.onStep});
const _SpeedStepper({
required this.speed,
required this.onStep,
this.min = SafetyGate.tapFlyMinSpeed,
this.max = SafetyGate.tapFlyMaxSpeed,
this.keyPrefix = 'tapfly-speed',
});
final double speed;
final ValueChanged<int> onStep;
final double min;
final double max;
final String keyPrefix;
@override
Widget build(BuildContext context) {
Widget b(String l, int d, bool on) => Opacity(
opacity: on ? 1 : 0.35,
child: GestureDetector(
key: ValueKey<String>('tapfly-speed-${d > 0 ? 'plus' : 'minus'}'),
key: ValueKey<String>('$keyPrefix-${d > 0 ? 'plus' : 'minus'}'),
behavior: HitTestBehavior.opaque,
onTap: on ? () => onStep(d) : null,
child: Container(
@@ -225,9 +279,9 @@ class _SpeedStepper extends StatelessWidget {
),
);
return Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
b('−', -1, speed > SafetyGate.tapFlyMinSpeed),
b('−', -1, speed > min),
SizedBox(width: 54, child: Text('${speed.toStringAsFixed(0)} m/s', textAlign: TextAlign.center, style: HudText.mono)),
b('+', 1, speed < SafetyGate.tapFlyMaxSpeed),
b('+', 1, speed < max),
]);
}
}
@@ -15,7 +15,7 @@ import '../../hud_kit.dart';
/// The route summary across the top of the map while editing (GO 4's top
/// board): name, waypoints, length, time, cruise speed, and the issue count.
class WaypointBoard extends StatelessWidget {
const WaypointBoard({super.key, required this.editor, required this.limits, required this.onTap});
const WaypointBoard({super.key, required this.editor, required this.limits, required this.onTap, this.resumeFrom, this.onResume});
final WaypointEditor editor;
final MissionLimits limits;
@@ -23,6 +23,11 @@ class WaypointBoard extends StatelessWidget {
/// Opens the route settings (where the issues are listed).
final VoidCallback onTap;
/// The waypoint (0-based) this route was interrupted before, when it was:
/// a RESUME chip flies the rest of it.
final int? resumeFrom;
final VoidCallback? onResume;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
@@ -67,6 +72,19 @@ class WaypointBoard extends StatelessWidget {
_sep(),
Text(hint, style: HudText.label.copyWith(color: Glass.ink)),
],
if (resumeFrom != null && onResume != null && resumeFrom! < m.points.length) ...<Widget>[
const SizedBox(width: 8),
GestureDetector(
key: const ValueKey<String>('wp-resume'),
behavior: HitTestBehavior.opaque,
onTap: onResume,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(color: Glass.accent, borderRadius: BorderRadius.circular(6)),
child: Text('RESUME FROM ${resumeFrom! + 1}', style: HudText.label.copyWith(color: Colors.white, fontWeight: FontWeight.w700)),
),
),
],
]),
);
},
@@ -254,6 +272,8 @@ class WaypointRunPanel extends StatelessWidget {
const SizedBox(width: 8),
Text(left >= 1000 ? '${(left / 1000).toStringAsFixed(2)} km left' : '${left.round()} m left', style: HudText.label),
],
const SizedBox(width: 8),
_CruiseSpeed(initial: editor.mission.cruiseSpeed, gate: gate, onError: onError),
const SizedBox(width: 10),
_RunButton(
id: run.paused ? 'resume' : 'pause',
@@ -290,6 +310,59 @@ class WaypointRunPanel extends StatelessWidget {
}
}
/// The mission's cruise speed, changed in flight (`setAutoFlightSpeed`): − / +
/// by 1 m/s. Starts at the route's speed; the operator does not report it back.
class _CruiseSpeed extends StatefulWidget {
const _CruiseSpeed({required this.initial, required this.gate, required this.onError});
final double initial;
final SafetyGate gate;
final void Function(String label, Object error) onError;
@override
State<_CruiseSpeed> createState() => _CruiseSpeedState();
}
class _CruiseSpeedState extends State<_CruiseSpeed> {
late double _speed = widget.initial;
Future<void> _step(int d) async {
final double before = _speed;
final double next = (_speed + d).clamp(WaypointMissionSpec.minSpeed, WaypointMissionSpec.maxSpeed);
setState(() => _speed = next);
try {
await widget.gate.setWaypointCruiseSpeed(next);
} catch (e) {
if (mounted) setState(() => _speed = before);
widget.onError('Cruise speed', e);
}
}
@override
Widget build(BuildContext context) {
Widget b(String l, int d, bool on) => Opacity(
opacity: on ? 1 : 0.35,
child: GestureDetector(
key: ValueKey<String>('wp-speed-${d > 0 ? 'plus' : 'minus'}'),
behavior: HitTestBehavior.opaque,
onTap: on ? () => _step(d) : null,
child: Container(
width: 22,
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>[
b('−', -1, _speed > WaypointMissionSpec.minSpeed),
SizedBox(width: 50, child: Text('${_speed.toStringAsFixed(0)} m/s', key: const ValueKey<String>('wp-speed'), textAlign: TextAlign.center, style: HudText.mono)),
b('+', 1, _speed < WaypointMissionSpec.maxSpeed),
]);
}
}
class _RunButton extends StatelessWidget {
const _RunButton({required this.id, required this.icon, required this.label, required this.onTap, this.color = Glass.accent});
@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart' show LatLng;
import '../../../../fly/hud_geometry.dart';
import '../../../../fly/modes/mission_library.dart';
import '../../../../fly/modes/terrain.dart';
import '../../../../fly/modes/waypoint_editor.dart';
import '../../../../fly/modes/waypoint_mission.dart';
import '../../../../theme.dart';
@@ -139,6 +141,7 @@ class RouteSettingsList extends StatelessWidget {
onStep: (int d) => _set((WaypointMissionSpec m) => m.copyWith(repeatTimes: (m.repeatTimes + d).clamp(1, 10))),
),
ValueTile(label: 'Length', value: '${m.length.round()} m · ≈${formatDuration(m.estimatedSeconds)}'),
TerrainCheckRow(editor: editor, home: limits.home),
if (issues.isNotEmpty) ...<Widget>[
Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 4),
@@ -166,6 +169,76 @@ class RouteSettingsList extends StatelessWidget {
}
}
/// "Check terrain": samples the ground along the route online and lists where
/// it comes within 15 m. Runs only on tap — it sends the route's coordinates
/// to the elevation service — and from the home point, or waypoint 1 when the
/// aircraft has none yet (then altitudes are taken as above waypoint 1's ground).
class TerrainCheckRow extends StatefulWidget {
const TerrainCheckRow({super.key, required this.editor, this.home, this.source = const OpenMeteoElevation()});
final WaypointEditor editor;
final LatLng? home;
final ElevationSource source;
@override
State<TerrainCheckRow> createState() => _TerrainCheckRowState();
}
class _TerrainCheckRowState extends State<TerrainCheckRow> {
bool _running = false;
List<TerrainIssue>? _found;
String? _error;
/// The route the result belongs to; editing it makes the result stale.
WaypointMissionSpec? _checked;
Future<void> _run() async {
final WaypointMissionSpec m = widget.editor.mission;
setState(() {
_running = true;
_error = null;
});
try {
final List<TerrainIssue> found = await TerrainCheck(widget.source).run(m, widget.home ?? m.points.first.at);
if (mounted) {
setState(() {
_found = found;
_checked = m;
});
}
} catch (_) {
if (mounted) setState(() => _error = 'Could not reach the elevation service');
} finally {
if (mounted) setState(() => _running = false);
}
}
@override
Widget build(BuildContext context) {
final WaypointMissionSpec m = widget.editor.mission;
final bool stale = _checked != null && !identical(_checked, m);
final List<TerrainIssue>? f = stale ? null : _found;
final String hint = _running
? 'Checking…'
: _error ??
(f == null
? 'Sends the route to open-meteo.com for ground heights${widget.home == null ? ' (no home point yet: measured from waypoint 1)' : ''}'
: f.isEmpty
? 'At least 15 m over the ground all the way'
: '${f.length} leg${f.length == 1 ? '' : 's'} too close to the ground');
return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
ButtonTile(
id: 'terrain-check',
label: 'Check terrain',
hint: hint,
button: 'Check',
onTap: _running || m.points.length < 2 ? null : _run,
),
for (final TerrainIssue t in f ?? const <TerrainIssue>[]) IssueRow(issue: t.toIssue(), onTap: () => widget.editor.select(t.leg + 1)),
]);
}
}
class IssueRow extends StatelessWidget {
const IssueRow({super.key, required this.issue, this.onTap});