Files
PilotVault/Fly App/lib/ui/fly/modes/mode_picker.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

294 lines
12 KiB
Dart

import 'package:flutter/material.dart';
import '../../../fly/fly_prefs.dart';
import '../../../fly/modes/intelligent_modes.dart';
import '../../../fly/product_profile.dart';
import '../../../theme.dart';
import '../../pv_icons.dart';
import '../hud_kit.dart';
/// The Intelligent Flight Modes picker (plan P7): a grid of the connected
/// product's modes, grouped as GO 4 does. Resolves to the chosen mode once
/// its first-use terms are accepted, or null.
Future<IntelligentMode?> showModePicker(BuildContext context, {required ProductProfile profile}) async {
final IntelligentMode? picked = await showGeneralDialog<IntelligentMode>(
context: context,
barrierDismissible: true,
barrierLabel: 'Intelligent Flight Modes',
barrierColor: const Color(0x88050A14),
transitionDuration: const Duration(milliseconds: 180),
pageBuilder: (BuildContext context, _, __) => Center(child: ModePickerPanel(profile: profile)),
transitionBuilder: (BuildContext context, Animation<double> a, _, Widget child) => FadeTransition(
opacity: a,
child: ScaleTransition(scale: Tween<double>(begin: 0.96, end: 1).animate(a), child: child),
),
);
if (picked == null || !context.mounted) return 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.
Future<bool> ensureModeTerms(BuildContext context, ModeInfo info) async {
if (FlyPrefs.acceptedModeTerms.value.contains(info.mode.name)) return true;
final bool? agreed = await showDialog<bool>(
context: context,
barrierColor: const Color(0x99050A14),
builder: (BuildContext context) => ModeTermsDialog(info: info),
);
if (agreed != true) return false;
await FlyPrefs.acceptModeTerms(info.mode.name);
return true;
}
class ModePickerPanel extends StatelessWidget {
const ModePickerPanel({super.key, required this.profile});
final ProductProfile profile;
@override
Widget build(BuildContext context) {
final List<ModeInfo> modes = ModeCatalog.forProfile(profile);
final Size screen = MediaQuery.sizeOf(context);
return Material(
color: const Color(0xF20B1426),
borderRadius: BorderRadius.circular(16),
clipBehavior: Clip.antiAlias,
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: 620, maxHeight: screen.height - 32),
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
Padding(
padding: const EdgeInsets.fromLTRB(18, 10, 6, 6),
child: Row(children: <Widget>[
const PVIcon('master', size: 16, color: Glass.ink),
const SizedBox(width: 8),
Expanded(child: Text('Intelligent Flight Modes', style: HudText.sans.copyWith(fontSize: 14, fontWeight: FontWeight.w700))),
IconButton(
key: const ValueKey<String>('mode-picker-close'),
onPressed: () => Navigator.of(context).pop(),
icon: const PVIcon('close', size: 18, color: HudText.dim),
),
]),
),
const Divider(height: 1, color: Glass.hairline),
Flexible(
child: modes.isEmpty
? Padding(
padding: const EdgeInsets.all(24),
child: Text('This aircraft offers no intelligent flight modes through the SDK.',
style: HudText.sans.copyWith(color: HudText.dim, fontWeight: FontWeight.w400)),
)
: ListView(
shrinkWrap: true,
padding: const EdgeInsets.fromLTRB(16, 10, 16, 16),
children: <Widget>[
for (final String g in ModeCatalog.groups)
if (modes.any((ModeInfo m) => m.group == g)) ...<Widget>[
Padding(
padding: const EdgeInsets.only(top: 6, bottom: 8),
child: Text(g.toUpperCase(), style: HudText.label),
),
Wrap(spacing: 10, runSpacing: 10, children: <Widget>[
for (final ModeInfo m in modes)
if (m.group == g) _ModeTile(info: m),
]),
],
],
),
),
]),
),
);
}
}
class _ModeTile extends StatelessWidget {
const _ModeTile({required this.info});
final ModeInfo info;
@override
Widget build(BuildContext context) {
final bool on = info.built;
return GestureDetector(
key: ValueKey<String>('mode-${info.mode.name}'),
behavior: HitTestBehavior.opaque,
onTap: on ? () => Navigator.of(context).pop(info.mode) : null,
child: Opacity(
opacity: on ? 1 : 0.5,
child: Container(
width: 136,
height: 92,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: on ? const Color(0x263D7BF0) : const Color(0x14FFFFFF),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: on ? const Color(0x663D7BF0) : Glass.hairline),
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
Row(children: <Widget>[
PVIcon(info.icon, size: 18, color: Glass.ink),
const Spacer(),
if (!on) Text('SOON', style: HudText.label.copyWith(fontSize: 7.5)),
]),
const SizedBox(height: 6),
Text(info.label, style: HudText.sans.copyWith(fontSize: 12.5, fontWeight: FontWeight.w700)),
const SizedBox(height: 3),
Expanded(
child: Text(info.blurb,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: HudText.sans.copyWith(fontSize: 10, fontWeight: FontWeight.w400, color: HudText.dim)),
),
]),
),
),
);
}
}
/// GO 4's "Intelligent Flight Mode Terms": Agree / Disagree, once per mode.
class ModeTermsDialog extends StatelessWidget {
const ModeTermsDialog({super.key, required this.info});
final ModeInfo info;
@override
Widget build(BuildContext context) {
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: 480),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 12),
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
Row(children: <Widget>[
PVIcon(info.icon, size: 18, color: Glass.ink),
const SizedBox(width: 8),
Expanded(
child: Text('${info.label}: before you fly', style: HudText.sans.copyWith(fontSize: 14, fontWeight: FontWeight.w700))),
]),
const SizedBox(height: 10),
Flexible(
child: SingleChildScrollView(
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
for (final String t in info.terms)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
Container(
width: 5,
height: 5,
margin: const EdgeInsets.only(top: 5),
decoration: const BoxDecoration(color: HudText.dim, shape: BoxShape.circle),
),
const SizedBox(width: 8),
Expanded(child: Text(t, style: HudText.sans.copyWith(fontSize: 11.5, fontWeight: FontWeight.w400, height: 1.35))),
]),
),
]),
),
),
const SizedBox(height: 6),
Row(mainAxisAlignment: MainAxisAlignment.end, children: <Widget>[
TextButton(
key: const ValueKey<String>('terms-disagree'),
onPressed: () => Navigator.of(context).pop(false),
child: Text('Disagree', style: HudText.sans.copyWith(color: HudText.dim)),
),
const SizedBox(width: 8),
FilledButton(
key: const ValueKey<String>('terms-agree'),
style: FilledButton.styleFrom(backgroundColor: PV.accent),
onPressed: () => Navigator.of(context).pop(true),
child: Text('Agree', style: HudText.sans.copyWith(color: Colors.white)),
),
]),
]),
),
),
);
}
}