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>
294 lines
12 KiB
Dart
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)),
|
|
),
|
|
]),
|
|
]),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|