Files
PilotVault/Fly App/lib/ui/fly/modes/mode_picker.dart
T
tajniak81andClaude Opus 5.5 43a66df8df Give the Fly App GO 4-style intelligent modes and a Waypoints editor
P7a of the GO 4-parity plan. The rail's GRID button becomes MODES, which
opens a picker of the product's intelligent flight modes with first-use
terms; Waypoints is the first one built.

Waypoints opens on the big map: tap to add waypoints or POIs, long-press
to drag, "+" to insert on a leg, and a point editor for altitude, speed,
heading (route / custom / POI), gimbal (set or aim at POI), camera action
and hover, and turn radius, with a swipe-to-adjust readout. Route settings,
a local mission library and undo sit in a tool column. Start runs the
SafetyGate's pre-start check in the slide-to-confirm dialog, then uploads
and starts; a run panel shows upload and flight progress with Pause,
Resume and Stop.

MissionBridge now listens to the waypoint operator and builds the full V1
mission (per-waypoint headings, turn direction, gimbal pitch, actions).
Not yet run on a simulator or aircraft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:22:53 +02:00

215 lines
8.9 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 bool ok = await ensureModeTerms(context, ModeCatalog.of(picked));
return ok ? picked : null;
}
/// 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)),
),
]),
]),
),
),
);
}
}