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

625 lines
23 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart' show LatLng;
import '../hud_geometry.dart';
/// A Waypoints mission as the pilot edits it (plan P7, GO 4's Waypoints 2.0
/// editor), and its translation into what MSDK V4's `WaypointMissionOperator`
/// takes.
///
/// The editor works in GO 4's terms — per-point heading Route / Custom / POI,
/// "aim the gimbal at the POI", a camera action, a hover — and [toBridge]
/// resolves them into the V1 mission: a heading per waypoint and the turn
/// direction to it, a gimbal pitch on every waypoint once any is set, and the
/// STAY / photo / record actions. MSDK V1 has one mission-wide point of
/// interest, so a per-point POI becomes that point's computed heading.
///
/// Pure and immutable, so the whole translation and [validate] are unit-tested.
/// What the camera does on arrival at a waypoint.
enum WaypointCameraAction {
none('None'),
photo('Photo'),
startRecord('Start recording'),
stopRecord('Stop recording');
const WaypointCameraAction(this.label);
final String label;
}
/// Where a waypoint points the aircraft, when the route heading is [RouteHeadingMode.perPoint].
enum WaypointHeading {
/// Along the route, towards the next waypoint.
route('Route'),
custom('Custom'),
/// At the waypoint's linked POI.
poi('POI');
const WaypointHeading(this.label);
final String label;
}
/// The mission-wide heading ("All Waypoint Direction").
enum RouteHeadingMode {
auto('Route', 'AUTO'),
initial('Initial direction', 'USING_INITIAL_DIRECTION'),
rc('Manual (RC)', 'CONTROL_BY_REMOTE_CONTROLLER'),
perPoint('Per waypoint', 'USING_WAYPOINT_HEADING'),
poi('Towards POI', 'TOWARD_POINT_OF_INTEREST');
const RouteHeadingMode(this.label, this.sdk);
final String label;
final String sdk;
}
/// "Task Finished".
enum RouteFinishAction {
hover('Hover', 'NO_ACTION'),
goHome('Return to Home', 'GO_HOME'),
land('Land', 'AUTO_LAND'),
firstWaypoint('Back to first waypoint', 'GO_FIRST_WAYPOINT');
const RouteFinishAction(this.label, this.sdk);
final String label;
final String sdk;
}
/// "Route type": straight legs that stop at each waypoint, or curves through them.
enum RoutePathMode {
straight('Straight', 'NORMAL'),
curved('Curved', 'CURVED');
const RoutePathMode(this.label, this.sdk);
final String label;
final String sdk;
}
/// How the aircraft gets to the first waypoint.
enum RouteGotoFirst {
/// Climbs to the waypoint's altitude first if it is higher.
safely('Climb first', 'SAFELY'),
pointToPoint('Straight line', 'POINT_TO_POINT');
const RouteGotoFirst(this.label, this.sdk);
final String label;
final String sdk;
}
/// A point of interest waypoints can face, and aim the gimbal at.
@immutable
class MissionPoi {
const MissionPoi({required this.id, required this.at, this.height = 0});
final String id;
final LatLng at;
/// Height of the subject above the take-off point, for aiming the gimbal.
final double height;
MissionPoi copyWith({LatLng? at, double? height}) => MissionPoi(id: id, at: at ?? this.at, height: height ?? this.height);
Map<String, Object?> toJson() => <String, Object?>{'id': id, 'lat': at.latitude, 'lon': at.longitude, 'height': height};
static MissionPoi fromJson(Map<String, dynamic> j) => MissionPoi(
id: j['id'] as String,
at: LatLng((j['lat'] as num).toDouble(), (j['lon'] as num).toDouble()),
height: (j['height'] as num?)?.toDouble() ?? 0,
);
@override
bool operator ==(Object other) => other is MissionPoi && other.id == id && other.at == at && other.height == height;
@override
int get hashCode => Object.hash(id, at, height);
}
/// One waypoint.
@immutable
class WaypointSpec {
const WaypointSpec({
required this.at,
this.altitude = 50,
this.speed,
this.heading = WaypointHeading.route,
this.customHeading = 0,
this.gimbalPitch,
this.gimbalAimPoi = false,
this.action = WaypointCameraAction.none,
this.hoverSeconds = 0,
this.poiId,
this.turnRadius = 0.2,
});
final LatLng at;
/// Metres above the take-off point.
final double altitude;
/// m/s from here to the next waypoint; null flies the route's cruise speed.
final double? speed;
final WaypointHeading heading;
/// −180…180°, for [WaypointHeading.custom].
final int customHeading;
/// Degrees; null keeps whatever the previous waypoint set.
final double? gimbalPitch;
/// Aim the gimbal at the linked POI instead of [gimbalPitch].
final bool gimbalAimPoi;
final WaypointCameraAction action;
/// Seconds to hover on arrival, before the camera action.
final int hoverSeconds;
/// The linked [MissionPoi.id].
final String? poiId;
/// Metres; only curved routes use it.
final double turnRadius;
static const Object _keep = Object();
/// Nullable fields take [_keep] to stay unchanged, so `null` can clear them.
WaypointSpec copyWith({
LatLng? at,
double? altitude,
Object? speed = _keep,
WaypointHeading? heading,
int? customHeading,
Object? gimbalPitch = _keep,
bool? gimbalAimPoi,
WaypointCameraAction? action,
int? hoverSeconds,
Object? poiId = _keep,
double? turnRadius,
}) =>
WaypointSpec(
at: at ?? this.at,
altitude: altitude ?? this.altitude,
// Any num: an int literal (`?? -30`) arrives here as an int.
speed: identical(speed, _keep) ? this.speed : (speed as num?)?.toDouble(),
heading: heading ?? this.heading,
customHeading: customHeading ?? this.customHeading,
gimbalPitch: identical(gimbalPitch, _keep) ? this.gimbalPitch : (gimbalPitch as num?)?.toDouble(),
gimbalAimPoi: gimbalAimPoi ?? this.gimbalAimPoi,
action: action ?? this.action,
hoverSeconds: hoverSeconds ?? this.hoverSeconds,
poiId: identical(poiId, _keep) ? this.poiId : poiId as String?,
turnRadius: turnRadius ?? this.turnRadius,
);
Map<String, Object?> toJson() => <String, Object?>{
'lat': at.latitude,
'lon': at.longitude,
'altitude': altitude,
if (speed != null) 'speed': speed,
'heading': heading.name,
'customHeading': customHeading,
if (gimbalPitch != null) 'gimbalPitch': gimbalPitch,
if (gimbalAimPoi) 'gimbalAimPoi': true,
'action': action.name,
if (hoverSeconds > 0) 'hoverSeconds': hoverSeconds,
if (poiId != null) 'poi': poiId,
'turnRadius': turnRadius,
};
static WaypointSpec fromJson(Map<String, dynamic> j) => WaypointSpec(
at: LatLng((j['lat'] as num).toDouble(), (j['lon'] as num).toDouble()),
altitude: (j['altitude'] as num?)?.toDouble() ?? 50,
speed: (j['speed'] as num?)?.toDouble(),
heading: _byName(WaypointHeading.values, j['heading']) ?? WaypointHeading.route,
customHeading: (j['customHeading'] as num?)?.toInt() ?? 0,
gimbalPitch: (j['gimbalPitch'] as num?)?.toDouble(),
gimbalAimPoi: j['gimbalAimPoi'] == true,
action: _byName(WaypointCameraAction.values, j['action']) ?? WaypointCameraAction.none,
hoverSeconds: (j['hoverSeconds'] as num?)?.toInt() ?? 0,
poiId: j['poi'] as String?,
turnRadius: (j['turnRadius'] as num?)?.toDouble() ?? 0.2,
);
@override
bool operator ==(Object other) => other is WaypointSpec && mapEquals(other.toJson(), toJson());
@override
int get hashCode => Object.hashAll(toJson().values);
}
enum IssueLevel { warning, error }
/// Something wrong with a mission, optionally pinned to a waypoint (0-based).
@immutable
class MissionIssue {
const MissionIssue.error(this.message, {this.point}) : level = IssueLevel.error;
const MissionIssue.warning(this.message, {this.point}) : level = IssueLevel.warning;
final IssueLevel level;
final String message;
final int? point;
bool get blocks => level == IssueLevel.error;
@override
bool operator ==(Object other) => other is MissionIssue && other.level == level && other.message == message && other.point == point;
@override
int get hashCode => Object.hash(level, message, point);
@override
String toString() => '${level.name}: $message';
}
/// What [WaypointMissionSpec.validate] checks the route against: the
/// aircraft's own limits, when known.
@immutable
class MissionLimits {
const MissionLimits({this.maxAltitude, this.maxRadius, this.home, this.gimbalMin = -90, this.gimbalMax = 30});
/// The flight controller's altitude limit, m.
final double? maxAltitude;
/// The distance limit, m from home, when enabled.
final double? maxRadius;
final LatLng? home;
final double gimbalMin;
final double gimbalMax;
}
class WaypointMissionSpec {
const WaypointMissionSpec({
this.id = '',
this.name = 'New route',
this.points = const <WaypointSpec>[],
this.pois = const <MissionPoi>[],
this.cruiseSpeed = 8,
this.headingMode = RouteHeadingMode.auto,
this.finishAction = RouteFinishAction.goHome,
this.pathMode = RoutePathMode.straight,
this.exitOnSignalLost = true,
this.gotoFirst = RouteGotoFirst.safely,
this.repeatTimes = 1,
this.updatedAt,
});
// ── MSDK 4.18 limits (`Waypoint` / `WaypointMission` constants) ────────────
static const int maxPoints = 99;
static const int minPoints = 2;
static const double minAltitude = -200;
/// The flight controller's ceiling, whatever the SDK constant says (it is FLT_MAX).
static const double maxAltitudeSdk = 500;
static const double minSpeed = 1;
static const double maxSpeed = 15;
static const double minTurnRadius = 0.2;
static const double maxTurnRadius = 1000;
/// Adjacent waypoints closer than this are refused.
static const double minLeg = 0.5;
/// …and further apart than this.
static const double maxLeg = 2000;
/// `STAY`'s parameter is milliseconds up to 32767.
static const int maxHoverSeconds = 32;
/// Below this the pre-start check warns about trees and buildings.
static const double lowAltitude = 10;
final String id;
final String name;
final List<WaypointSpec> points;
final List<MissionPoi> pois;
final double cruiseSpeed;
final RouteHeadingMode headingMode;
final RouteFinishAction finishAction;
final RoutePathMode pathMode;
/// True: on RC signal loss the mission ends and the aircraft does its
/// signal-lost action. False: it finishes the route.
final bool exitOnSignalLost;
final RouteGotoFirst gotoFirst;
final int repeatTimes;
final DateTime? updatedAt;
bool get isEmpty => points.isEmpty;
MissionPoi? poi(String? id) {
if (id == null) return null;
for (final MissionPoi p in pois) {
if (p.id == id) return p;
}
return null;
}
WaypointMissionSpec copyWith({
String? id,
String? name,
List<WaypointSpec>? points,
List<MissionPoi>? pois,
double? cruiseSpeed,
RouteHeadingMode? headingMode,
RouteFinishAction? finishAction,
RoutePathMode? pathMode,
bool? exitOnSignalLost,
RouteGotoFirst? gotoFirst,
int? repeatTimes,
DateTime? updatedAt,
}) =>
WaypointMissionSpec(
id: id ?? this.id,
name: name ?? this.name,
points: points ?? this.points,
pois: pois ?? this.pois,
cruiseSpeed: cruiseSpeed ?? this.cruiseSpeed,
headingMode: headingMode ?? this.headingMode,
finishAction: finishAction ?? this.finishAction,
pathMode: pathMode ?? this.pathMode,
exitOnSignalLost: exitOnSignalLost ?? this.exitOnSignalLost,
gotoFirst: gotoFirst ?? this.gotoFirst,
repeatTimes: repeatTimes ?? this.repeatTimes,
updatedAt: updatedAt ?? this.updatedAt,
);
// ── Geometry and estimates ────────────────────────────────────────────────
/// 3-D length of each leg, m: leg i runs from point i to point i+1.
List<double> get legs => <double>[
for (int i = 0; i + 1 < points.length; i++) _dist3(points[i], points[i + 1]),
];
static double _dist2(LatLng a, LatLng b) => HudGeometry.distance(a.latitude, a.longitude, b.latitude, b.longitude);
static double _dist3(WaypointSpec a, WaypointSpec b) {
final double h = _dist2(a.at, b.at), v = b.altitude - a.altitude;
return math.sqrt(h * h + v * v);
}
/// The route's length, m, once through.
double get length => legs.fold(0.0, (double s, double l) => s + l);
double speedOf(int i) => (points[i].speed ?? cruiseSpeed).clamp(minSpeed, maxSpeed);
/// A rough flight time for the route, s: each leg at its speed, the hovers,
/// ~2 s per photo, and ~3 s to brake and accelerate at every straight-route stop.
int get estimatedSeconds {
if (points.length < 2) return 0;
double t = 0;
final List<double> l = legs;
for (int i = 0; i < l.length; i++) {
t += l[i] / speedOf(i);
}
for (final WaypointSpec p in points) {
t += p.hoverSeconds;
if (p.action == WaypointCameraAction.photo) t += 2;
}
if (pathMode == RoutePathMode.straight) t += 3.0 * (points.length - 2).clamp(0, maxPoints);
return (t * repeatTimes.clamp(1, 255)).round();
}
// ── Resolution into the V1 mission ────────────────────────────────────────
static int _signed(double bearing) {
final int b = bearing.round() % 360;
return b > 180 ? b - 360 : b;
}
static double _bearing(LatLng a, LatLng b) => HudGeometry.bearing(a.latitude, a.longitude, b.latitude, b.longitude);
/// The heading each waypoint flies at, −180…180, when headings are per waypoint.
List<int> resolvedHeadings() {
final List<int> out = <int>[];
for (int i = 0; i < points.length; i++) {
final WaypointSpec p = points[i];
final MissionPoi? poiAt = poi(p.poiId);
switch (p.heading) {
case WaypointHeading.custom:
out.add(p.customHeading.clamp(-180, 180));
case WaypointHeading.poi when poiAt != null:
out.add(_signed(_bearing(p.at, poiAt.at)));
case WaypointHeading.poi:
case WaypointHeading.route:
if (points.length < 2) {
out.add(0);
} else if (i + 1 < points.length) {
out.add(_signed(_bearing(p.at, points[i + 1].at)));
} else {
out.add(_signed(_bearing(points[i - 1].at, p.at)));
}
}
}
return out;
}
/// The gimbal pitch at every waypoint, or null when no waypoint sets one
/// (the pilot keeps the gimbal). A waypoint without its own pitch keeps the
/// previous one's; leading ones take the first set value.
List<double>? resolvedGimbal({double min = -90, double max = 30}) {
final List<double?> own = <double?>[
for (final WaypointSpec p in points) _ownPitch(p),
];
final double? first = own.firstWhere((double? v) => v != null, orElse: () => null);
if (first == null) return null;
double last = first;
return <double>[
for (final double? v in own) (last = v ?? last).clamp(min, max).toDouble(),
];
}
double? _ownPitch(WaypointSpec p) {
final MissionPoi? target = poi(p.poiId);
if (p.gimbalAimPoi && target != null) {
final double d = _dist2(p.at, target.at);
final double drop = p.altitude - target.height;
return d < 0.5 ? -90 : -math.atan2(drop, d) * 180 / math.pi;
}
return p.gimbalPitch;
}
/// The `uploadWaypointMission` payload for `MissionBridge.kt`: SDK enum
/// names and resolved per-waypoint values.
Map<String, Object?> toBridge({double gimbalMin = -90, double gimbalMax = 30}) {
final bool perPoint = headingMode == RouteHeadingMode.perPoint;
final List<int> headings = resolvedHeadings();
final List<double>? pitch = resolvedGimbal(min: gimbalMin, max: gimbalMax);
final bool curved = pathMode == RoutePathMode.curved;
final List<Map<String, Object?>> wps = <Map<String, Object?>>[];
int? previous;
for (int i = 0; i < points.length; i++) {
final WaypointSpec p = points[i];
final int h = headings[i];
// Turn the short way round from the previous waypoint's heading.
String turn = 'CLOCKWISE';
if (perPoint && previous != null) {
final int delta = ((h - previous) % 360 + 360) % 360;
if (delta > 180) turn = 'COUNTER_CLOCKWISE';
}
previous = h;
wps.add(<String, Object?>{
'lat': p.at.latitude,
'lon': p.at.longitude,
'altitude': p.altitude,
if (perPoint) 'heading': h,
'turnMode': turn,
// 0 = the mission's auto flight speed.
'speed': p.speed == null ? 0.0 : p.speed!.clamp(minSpeed, maxSpeed),
'cornerRadius': curved ? p.turnRadius.clamp(minTurnRadius, maxTurnRadius) : minTurnRadius,
if (pitch != null) 'gimbalPitch': pitch[i],
'actions': <Map<String, Object?>>[
if (p.hoverSeconds > 0) <String, Object?>{'type': 'STAY', 'param': (p.hoverSeconds.clamp(0, maxHoverSeconds) * 1000).clamp(0, 32767)},
if (p.action == WaypointCameraAction.photo) const <String, Object?>{'type': 'START_TAKE_PHOTO', 'param': 0},
if (p.action == WaypointCameraAction.startRecord) const <String, Object?>{'type': 'START_RECORD', 'param': 0},
if (p.action == WaypointCameraAction.stopRecord) const <String, Object?>{'type': 'STOP_RECORD', 'param': 0},
],
});
}
final MissionPoi? missionPoi = pois.isEmpty ? null : pois.first;
return <String, Object?>{
'waypoints': wps,
'autoFlightSpeed': cruiseSpeed.clamp(minSpeed, maxSpeed),
'maxFlightSpeed': maxSpeed,
'finishedAction': finishAction.sdk,
'headingMode': headingMode.sdk,
'flightPathMode': pathMode.sdk,
'gotoFirstWaypointMode': gotoFirst.sdk,
'exitMissionOnRCSignalLost': exitOnSignalLost,
'gimbalPitchRotation': pitch != null,
'repeatTimes': repeatTimes.clamp(1, 255),
if (headingMode == RouteHeadingMode.poi && missionPoi != null)
'pointOfInterest': <String, Object?>{'lat': missionPoi.at.latitude, 'lon': missionPoi.at.longitude},
};
}
// ── Validation ────────────────────────────────────────────────────────────
/// Everything that would make the SDK refuse the mission (errors) or that
/// the pilot should know before flying it (warnings).
List<MissionIssue> validate([MissionLimits limits = const MissionLimits()]) {
final List<MissionIssue> out = <MissionIssue>[];
if (points.length < minPoints) out.add(const MissionIssue.error('Add at least 2 waypoints.'));
if (points.length > maxPoints) out.add(const MissionIssue.error('A route can have at most $maxPoints waypoints.'));
final double ceiling = math.min(maxAltitudeSdk, limits.maxAltitude ?? maxAltitudeSdk);
for (int i = 0; i < points.length; i++) {
final WaypointSpec p = points[i];
final String n = 'Waypoint ${i + 1}';
if (p.altitude > ceiling) {
out.add(MissionIssue.error('$n is at ${p.altitude.round()} m, above the ${ceiling.round()} m altitude limit.', point: i));
} else if (p.altitude < minAltitude) {
out.add(MissionIssue.error('$n is too far below the take-off point.', point: i));
} else if (p.altitude < lowAltitude) {
out.add(MissionIssue.warning('$n is only ${p.altitude.round()} m above the take-off point.', point: i));
}
if (limits.maxRadius != null && limits.home != null && _dist2(limits.home!, p.at) > limits.maxRadius!) {
out.add(MissionIssue.error('$n is beyond the ${limits.maxRadius!.round()} m distance limit.', point: i));
}
if (p.heading == WaypointHeading.poi && poi(p.poiId) == null && headingMode == RouteHeadingMode.perPoint) {
out.add(MissionIssue.warning('$n faces a POI but none is linked: it flies along the route.', point: i));
}
}
final List<double> l = legs;
for (int i = 0; i < l.length; i++) {
final double flat = _dist2(points[i].at, points[i + 1].at);
if (flat < minLeg && (points[i].altitude - points[i + 1].altitude).abs() < minLeg) {
out.add(MissionIssue.error('Waypoints ${i + 1} and ${i + 2} are on top of each other.', point: i + 1));
} else if (flat > maxLeg) {
out.add(MissionIssue.error('Waypoints ${i + 1} and ${i + 2} are more than 2 km apart.', point: i + 1));
}
}
if (pathMode == RoutePathMode.curved) {
for (int i = 0; i < l.length; i++) {
final double sum = points[i].turnRadius + points[i + 1].turnRadius;
if (sum >= _dist2(points[i].at, points[i + 1].at)) {
out.add(MissionIssue.error('The turn radii of waypoints ${i + 1} and ${i + 2} overlap: make them smaller.', point: i + 1));
}
}
if (points.any((WaypointSpec p) => p.action != WaypointCameraAction.none || p.hoverSeconds > 0)) {
out.add(const MissionIssue.warning('A curved route flies through its waypoints without stopping: their actions and hovers are skipped.'));
}
}
if (headingMode == RouteHeadingMode.poi && pois.isEmpty) {
out.add(const MissionIssue.error('"Towards POI" needs a point of interest: add one on the map.'));
}
if (cruiseSpeed < minSpeed || cruiseSpeed > maxSpeed) {
out.add(const MissionIssue.error('Cruise speed must be between 1 and 15 m/s.'));
}
return out;
}
// ── Storage (mission library, later the API Server) ───────────────────────
static const int formatVersion = 1;
Map<String, Object?> toJson() => <String, Object?>{
'version': formatVersion,
'id': id,
'name': name,
'points': <Map<String, Object?>>[for (final WaypointSpec p in points) p.toJson()],
'pois': <Map<String, Object?>>[for (final MissionPoi p in pois) p.toJson()],
'cruiseSpeed': cruiseSpeed,
'headingMode': headingMode.name,
'finishAction': finishAction.name,
'pathMode': pathMode.name,
'exitOnSignalLost': exitOnSignalLost,
'gotoFirst': gotoFirst.name,
'repeatTimes': repeatTimes,
if (updatedAt != null) 'updatedAt': updatedAt!.toUtc().toIso8601String(),
};
static WaypointMissionSpec fromJson(Map<String, dynamic> j) => WaypointMissionSpec(
id: j['id'] as String? ?? '',
name: j['name'] as String? ?? 'Route',
points: <WaypointSpec>[
for (final Object? p in (j['points'] as List<Object?>? ?? const <Object?>[]))
if (p is Map) WaypointSpec.fromJson(Map<String, dynamic>.from(p)),
],
pois: <MissionPoi>[
for (final Object? p in (j['pois'] as List<Object?>? ?? const <Object?>[]))
if (p is Map) MissionPoi.fromJson(Map<String, dynamic>.from(p)),
],
cruiseSpeed: (j['cruiseSpeed'] as num?)?.toDouble() ?? 8,
headingMode: _byName(RouteHeadingMode.values, j['headingMode']) ?? RouteHeadingMode.auto,
finishAction: _byName(RouteFinishAction.values, j['finishAction']) ?? RouteFinishAction.goHome,
pathMode: _byName(RoutePathMode.values, j['pathMode']) ?? RoutePathMode.straight,
exitOnSignalLost: j['exitOnSignalLost'] as bool? ?? true,
gotoFirst: _byName(RouteGotoFirst.values, j['gotoFirst']) ?? RouteGotoFirst.safely,
repeatTimes: (j['repeatTimes'] as num?)?.toInt() ?? 1,
updatedAt: DateTime.tryParse(j['updatedAt'] as String? ?? ''),
);
}
T? _byName<T extends Enum>(List<T> values, Object? name) {
for (final T v in values) {
if (v.name == name) return v;
}
return null;
}