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