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>
1048 lines
42 KiB
Dart
1048 lines
42 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:latlong2/latlong.dart' show LatLng;
|
|
|
|
import '../../dji_service.dart';
|
|
import '../../flight_model.dart';
|
|
import '../../fly/calibration.dart' show CalibrationKind;
|
|
import '../../fly/flight_trail.dart';
|
|
import '../../fly/fly_zone_loader.dart';
|
|
import '../../fly/fly_prefs.dart';
|
|
import '../../fly/gimbal_control.dart' show GimbalActions;
|
|
import '../../fly/hud_features.dart';
|
|
import '../../fly/modes/mission_library.dart';
|
|
import '../../fly/modes/mission_sync.dart' show ApiMissionRemote;
|
|
import '../../fly/modes/orbit_modes.dart';
|
|
import '../../fly/modes/vision_modes.dart';
|
|
import '../../fly/state/link_extras.dart' show RcButtonPress;
|
|
import '../../fly/state/parse.dart' show asString;
|
|
import '../../fly/modes/waypoint_editor.dart';
|
|
import '../../fly/modes/waypoint_mission.dart' show MissionLimits;
|
|
import '../../fly/product_profile.dart';
|
|
import '../../fly/safety_gate.dart';
|
|
import '../../fly/state/mission_state.dart';
|
|
import '../../fly/virtual_sticks.dart';
|
|
import '../../theme.dart';
|
|
import '../album_page.dart';
|
|
import '../capture_modes_page.dart';
|
|
import '../pv_icons.dart';
|
|
import 'bottom/attitude_ball.dart';
|
|
import 'bottom/minimap.dart';
|
|
import 'bottom/telemetry_strip.dart';
|
|
import 'control/virtual_stick_pads.dart';
|
|
import 'flight_command_flow.dart';
|
|
import 'hud_kit.dart';
|
|
import 'layers/video_layer.dart';
|
|
import 'left/action_rail.dart';
|
|
import 'left/gimbal_slider.dart';
|
|
import 'modes/mode_panels.dart';
|
|
import 'modes/mode_picker.dart';
|
|
import 'modes/vision/vision_layers.dart';
|
|
import 'modes/vision/vision_panel.dart';
|
|
import 'modes/waypoints/point_editor.dart';
|
|
import 'modes/waypoints/waypoint_layers.dart';
|
|
import 'modes/waypoints/waypoint_panels.dart';
|
|
import 'modes/waypoints/waypoint_sheets.dart';
|
|
import 'modes/waypoints/waypoint_start.dart';
|
|
import 'overlays/airsense.dart';
|
|
import 'overlays/alert_stack.dart';
|
|
import 'overlays/camera_touch_layer.dart';
|
|
import 'overlays/fly_zone_licences.dart';
|
|
import 'overlays/fly_zones.dart';
|
|
import 'overlays/gimbal_drag_layer.dart';
|
|
import 'overlays/live_stream.dart';
|
|
import 'overlays/histogram_view.dart';
|
|
import 'overlays/safety_banners.dart';
|
|
import 'overlays/vision_radar.dart';
|
|
import 'panels/aircraft_status_sheet.dart';
|
|
import 'panels/camera_menu/camera_menu_sheet.dart';
|
|
import 'panels/camera_quick_exposure.dart';
|
|
import 'panels/camera_write.dart';
|
|
import 'panels/gimbal_sheet.dart';
|
|
import 'panels/settings/calibration/calibration_hub.dart' show openCalibration;
|
|
import 'panels/settings/settings_sheet.dart';
|
|
import 'right/camera_column.dart';
|
|
import 'right/zoom_column.dart';
|
|
import 'top/camera_info_strip.dart';
|
|
import 'top/smart_battery_bar.dart';
|
|
import 'top/status_bar.dart';
|
|
|
|
/// The flight HUD v2 (plan P2), replacing [FlightControlPage].
|
|
///
|
|
/// A stack of layers in `fpv_litchi`'s order — video → visual overlays → map →
|
|
/// camera column → gimbal → top bar → bottom attitude → left rail → minimap —
|
|
/// where every readout listens to its own telemetry sub-model, so a 10 Hz tick
|
|
/// rebuilds only what moved. The HUD is laid out once in design pixels and
|
|
/// scaled to phone or tablet by [HudCanvas]; capability-gated parts use
|
|
/// [Requires] against the connected [ProductProfile].
|
|
///
|
|
/// Tapping the minimap swaps it with the video: the map goes full-bleed (and
|
|
/// becomes interactive) while the feed shrinks into the corner, and back.
|
|
///
|
|
/// Safety (plan P3): every aircraft-moving command on the rail goes through
|
|
/// one [SafetyGate] via [FlightCommandFlow]; the error stack and low-battery
|
|
/// banners sit under the top bar; the status chip opens the Aircraft Status
|
|
/// sheet, which is also offered before each connection's first take-off.
|
|
///
|
|
/// Camera (plan P4): the camera column, the zoom column on zoom cameras, the
|
|
/// quick exposure panel (from the camera info strip), the tabbed camera menu,
|
|
/// tap-to-focus / tap-to-meter on the video and the histogram.
|
|
///
|
|
/// Gimbal (plan P5): the pitch slider with its value bubble, press-and-drag
|
|
/// on the video as a speed joystick, and the gimbal settings sheet from the
|
|
/// button under the slider.
|
|
///
|
|
/// Intelligent modes (plan P7): the rail's MODES button opens the picker.
|
|
/// Waypoints turns the map big and editable — tap to add, the point editor
|
|
/// along the bottom, the tool column on the right — and the run panel at the
|
|
/// head of the left column follows the upload and the flight, whatever the
|
|
/// screen shows. The route stays on the map after leaving the mode.
|
|
///
|
|
/// Radar, sticks, AirSense (plan P8): the vision radar around the picture's
|
|
/// centre; the rail's STICKS button hands the aircraft to two on-screen stick
|
|
/// pads through the SafetyGate (simulator first); AirSense warnings above the
|
|
/// error stack and the planes it hears on the map; DJI GEO fly zones on the
|
|
/// map with their banner, and the radar ring on the attitude ball.
|
|
///
|
|
/// Secondary screens (plan P9): the camera column's playback button opens the
|
|
/// album over the live view (videos play on the aircraft into the feed), and
|
|
/// a LIVE pill heads the left column while an RTMP stream runs.
|
|
class FlyScreen extends StatefulWidget {
|
|
const FlyScreen({super.key, required this.model, required this.dji, this.waypoints, this.onOpenFlightLogs});
|
|
|
|
final FlightModel model;
|
|
final DjiService dji;
|
|
|
|
/// The Waypoints editor to use; the screen makes (and disposes) its own when null.
|
|
final WaypointEditor? waypoints;
|
|
|
|
/// Opens the flight records (Settings → General → Flight logs).
|
|
final VoidCallback? onOpenFlightLogs;
|
|
|
|
@override
|
|
State<FlyScreen> createState() => _FlyScreenState();
|
|
}
|
|
|
|
class _FlyScreenState extends State<FlyScreen> {
|
|
late final FlightTrail _trail = FlightTrail(widget.model.aircraft);
|
|
final MinimapController _minimap = MinimapController();
|
|
late final SafetyGate _gate = SafetyGate(model: widget.model, dji: widget.dji);
|
|
late final FlightCommandFlow _flow = FlightCommandFlow(
|
|
gate: _gate,
|
|
onError: _error,
|
|
reviewBeforeTakeOff: (BuildContext context) => _openStatus(context, offerTakeOff: true),
|
|
);
|
|
bool _wasConnected = false;
|
|
|
|
bool _mapBig = false;
|
|
|
|
/// The intelligent mode whose editor is open, or null.
|
|
IntelligentMode? _mode;
|
|
late final WaypointEditor _waypoints = widget.waypoints ?? WaypointEditor();
|
|
final VisionSession _vision = VisionSession();
|
|
late final MissionLibrary _library = MissionLibrary(remote: ApiMissionRemote())..load();
|
|
MissionLimits _limits = const MissionLimits();
|
|
int _seenFinishes = 0;
|
|
|
|
/// The on-screen sticks (plan P8); [_sticksOpen] mirrors its `open` for layout.
|
|
late final VirtualStickController _sticks = VirtualStickController(send: _gate.sendSticks)..mode = FlyPrefs.stickMode.value;
|
|
bool _sticksOpen = false;
|
|
|
|
/// Keeps the GEO zones around the aircraft loaded (plan P8).
|
|
late final FlyZoneLoader _zones = FlyZoneLoader(model: widget.model, dji: widget.dji);
|
|
|
|
/// POI / Follow Me / Hyperlapse set-up (plan P7).
|
|
final ModeSetup _setup = ModeSetup();
|
|
|
|
/// Sends the phone's position to Follow Me while it runs.
|
|
Timer? _followTimer;
|
|
|
|
/// The waypoint the current route was interrupted before, if it was.
|
|
int? _resumeFrom;
|
|
int _seenModeFinishes = 0;
|
|
|
|
/// The live-feed cache is recording (plan P6): the aircraft flies and the pref is on.
|
|
bool _caching = false;
|
|
|
|
/// The last focus mode seen, for the first-manual-focus lens prompt.
|
|
String? _focusMode;
|
|
bool _lensPromptShown = false;
|
|
|
|
/// Waypoints is open on the big map and nothing is flying: taps edit.
|
|
bool get _editing {
|
|
final WaypointProgress run = _m.mission.waypoint;
|
|
return _mode == IntelligentMode.waypoint && _mapBig && !run.active && !run.uploading;
|
|
}
|
|
|
|
/// The picture-in-picture box, in HUD canvas pixels from the bottom-left corner.
|
|
static const Size _pipSize = Size(150, 90);
|
|
|
|
/// An on-screen stick pad's diameter, in HUD canvas pixels.
|
|
static const double _stickPad = 110;
|
|
|
|
FlightModel get _m => widget.model;
|
|
DjiService get _dji => widget.dji;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
SystemChrome.setPreferredOrientations(<DeviceOrientation>[
|
|
DeviceOrientation.landscapeLeft,
|
|
DeviceOrientation.landscapeRight,
|
|
]);
|
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
|
_wasConnected = _m.connected;
|
|
_m.addListener(_onModel);
|
|
_seenFinishes = _m.mission.waypointFinishes;
|
|
_seenModeFinishes = _m.mission.modeFinishes;
|
|
_m.mission.addListener(_onMission);
|
|
_m.aircraft.addListener(_onSticksAircraft);
|
|
_m.aircraft.addListener(_onCacheAircraft);
|
|
FlyPrefs.videoCache.addListener(_onCacheAircraft);
|
|
_m.rcButton.addListener(_onRcButton);
|
|
_focusMode = asString(_m.camera.setting('focusMode'));
|
|
_m.camera.addListener(_onCamera);
|
|
_sticks.addListener(_onSticks);
|
|
FlyPrefs.stickMode.addListener(_onStickMode);
|
|
_zones.start();
|
|
}
|
|
|
|
// ── Video cache (plan P6) ───────────────────────────────────────────────────
|
|
|
|
/// Records the live feed while the aircraft flies, when the pref is on.
|
|
void _onCacheAircraft() {
|
|
final bool want = FlyPrefs.videoCache.value && _m.connected && _m.aircraft.isFlying == true;
|
|
if (want == _caching) return;
|
|
_caching = want;
|
|
_dji.setVideoCacheRecording(want, limitMb: FlyPrefs.videoCacheLimitMb.value).catchError((Object _) {});
|
|
}
|
|
|
|
// ── C1 / C2 (plan P6) ───────────────────────────────────────────────────────
|
|
|
|
void _onRcButton() {
|
|
final RcButtonPress? p = _m.rcButton.value;
|
|
if (p == null || !mounted) return;
|
|
_runButtonAction(FlyPrefs.rcButton(p.button));
|
|
}
|
|
|
|
Future<void> _runButtonAction(RcButtonAction a) async {
|
|
try {
|
|
switch (a) {
|
|
case RcButtonAction.none:
|
|
break;
|
|
case RcButtonAction.gimbalRecenter:
|
|
await _dji.resetGimbal();
|
|
case RcButtonAction.gimbalToggleDown:
|
|
// Down when roughly level, level when pointing down.
|
|
final double pitch = _m.gimbal.pitch ?? 0;
|
|
await _dji.rotateGimbalPitch(pitch < -45 ? 0 : GimbalActions.faceDownPitch(_m.profile.gimbal?.pitch));
|
|
case RcButtonAction.mapSwap:
|
|
setState(() => _mapBig = !_mapBig);
|
|
case RcButtonAction.cameraSettings:
|
|
await showCameraMenu(context, model: _m, dji: _dji, onError: _error);
|
|
case RcButtonAction.aircraftStatus:
|
|
await _openStatus(context);
|
|
case RcButtonAction.aeLock:
|
|
final bool on = _m.camera.aeLock != true;
|
|
await writeCameraSetting(_m.camera, label: 'AE lock', pending: <String, Object?>{'aeLock': on}, send: () => _dji.setCameraValue('aeLock', on), onError: _error);
|
|
case RcButtonAction.radar:
|
|
await FlyPrefs.setShowRadar(!FlyPrefs.showRadar.value);
|
|
}
|
|
} catch (e) {
|
|
_error(a.label, e);
|
|
}
|
|
}
|
|
|
|
// ── First manual focus: offer the lens calibration (plan P6b) ──────────────
|
|
|
|
void _onCamera() {
|
|
final String? mode = asString(_m.camera.setting('focusMode'));
|
|
if (mode == _focusMode) return;
|
|
final bool toManual = mode == 'MANUAL' && _focusMode != null;
|
|
_focusMode = mode;
|
|
if (!toManual || _lensPromptShown || !mounted) return;
|
|
final String camera = _m.profile.camera?.displayName ?? _m.model ?? 'camera';
|
|
if (!HudFeatures.calibrationLens.enabledFor(_m.profile) || FlyPrefs.lensCalibrated.value.contains(camera)) return;
|
|
_lensPromptShown = true;
|
|
_offerLensCalibration(camera);
|
|
}
|
|
|
|
Future<void> _offerLensCalibration(String camera) async {
|
|
final bool? go = await showDialog<bool>(
|
|
context: context,
|
|
builder: (BuildContext ctx) => AlertDialog(
|
|
backgroundColor: const Color(0xFF10203F),
|
|
title: Text('Calibrate the lens?', style: HudText.sans.copyWith(fontSize: 15)),
|
|
content: Text(
|
|
'Manual focus is only accurate once the lens has been calibrated on this camera. It takes a minute, on the ground.',
|
|
style: HudText.sans.copyWith(fontWeight: FontWeight.w400),
|
|
),
|
|
actions: <Widget>[
|
|
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Not now')),
|
|
FilledButton(key: const ValueKey<String>('lens-prompt-go'), onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Calibrate')),
|
|
],
|
|
),
|
|
);
|
|
if (go != true || !mounted) return;
|
|
await openCalibration(context, CalibrationKind.lens, model: _m, dji: _dji, onError: _error);
|
|
if (_m.calibration.lensResult == 'COMPLETED') await FlyPrefs.markLensCalibrated(camera);
|
|
}
|
|
|
|
void _onStickMode() => _sticks.mode = FlyPrefs.stickMode.value;
|
|
|
|
/// The flight controller can end virtual-stick mode on its own (the remote's
|
|
/// mode switch, RTH, landing): the controller closes, and the loop is told to stop.
|
|
void _onSticksAircraft() {
|
|
final String mode = _m.aircraft.flightModeEnum ?? '';
|
|
_sticks.onAircraft(
|
|
enabled: _m.aircraft.virtualStickEnabled,
|
|
goingHome: _m.aircraft.goingHome == true || mode == 'GO_HOME',
|
|
landing: mode == 'AUTO_LANDING' || mode == 'CONFIRM_LANDING',
|
|
);
|
|
}
|
|
|
|
void _onSticks() {
|
|
if (_sticks.open == _sticksOpen) return;
|
|
final String? reason = _sticks.closedReason;
|
|
if (!_sticks.open && reason != null) {
|
|
_gate.disableVirtualSticks().catchError((Object _) {});
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context)
|
|
..clearSnackBars()
|
|
..showSnackBar(SnackBar(content: Text('On-screen sticks off: $reason')));
|
|
}
|
|
}
|
|
if (mounted) setState(() => _sticksOpen = _sticks.open);
|
|
}
|
|
|
|
Future<void> _toggleSticks(BuildContext context) async {
|
|
if (_sticks.open) {
|
|
_sticks.close();
|
|
try {
|
|
await _gate.disableVirtualSticks();
|
|
} catch (e) {
|
|
_error('On-screen sticks', e);
|
|
}
|
|
return;
|
|
}
|
|
await startVisionMode(
|
|
context,
|
|
title: 'On-screen sticks',
|
|
icon: 'rc',
|
|
description: '${FlyPrefs.stickMode.value.label}. Lift your thumbs and the aircraft hovers. Tap STICKS again to hand back to the remote.',
|
|
checks: _gate.checkVirtualSticks(),
|
|
start: () async {
|
|
await _gate.enableVirtualSticks();
|
|
_sticks.opened();
|
|
},
|
|
onError: _error,
|
|
);
|
|
}
|
|
|
|
/// Says how a waypoint mission ended, and leaves edit mode when one starts.
|
|
void _onMission() {
|
|
final MissionState ms = _m.mission;
|
|
if (ms.waypointFinishes != _seenFinishes) {
|
|
_seenFinishes = ms.waypointFinishes;
|
|
final String? r = ms.waypointResult;
|
|
if (mounted && r != null) {
|
|
ScaffoldMessenger.of(context)
|
|
..clearSnackBars()
|
|
..showSnackBar(SnackBar(content: Text(r.isEmpty ? 'Waypoint mission complete' : 'Waypoint mission ended: $r')));
|
|
}
|
|
// A mission cut short may be resumed from where it stopped.
|
|
_checkResume();
|
|
}
|
|
if (ms.modeFinishes != _seenModeFinishes) {
|
|
_seenModeFinishes = ms.modeFinishes;
|
|
final String? r = ms.modeResult;
|
|
if (mounted && r != null && r.isNotEmpty) {
|
|
ScaffoldMessenger.of(context)
|
|
..clearSnackBars()
|
|
..showSnackBar(SnackBar(content: Text('Mode ended: $r')));
|
|
}
|
|
}
|
|
// Follow Me needs the phone's position for as long as it follows.
|
|
final bool following = ms.followMe.active;
|
|
if (following && _followTimer == null) {
|
|
_followTimer = Timer.periodic(FollowMeSpec.updateEvery, (_) => _gate.updateFollowMe().catchError((Object _) {}));
|
|
} else if (!following && _followTimer != null) {
|
|
_followTimer!.cancel();
|
|
_followTimer = null;
|
|
}
|
|
// Execution events arrive at ~10 Hz; rebuild the screen only when editing turns on or off.
|
|
final bool editing = _editing;
|
|
if (mounted && editing != _wasEditing) setState(() => _wasEditing = editing);
|
|
}
|
|
|
|
bool _wasEditing = false;
|
|
|
|
Future<void> _openModes(BuildContext context) async {
|
|
final IntelligentMode? m = await showModePicker(context, profile: _m.profile);
|
|
if (m == null || !mounted) return;
|
|
switch (m) {
|
|
case IntelligentMode.waypoint:
|
|
case IntelligentMode.waypointV2:
|
|
setState(() {
|
|
_mode = IntelligentMode.waypoint;
|
|
_mapBig = true;
|
|
});
|
|
_minimap.setFollow(_waypoints.mission.points.isEmpty);
|
|
final MissionLimits limits = await _gate.missionLimits();
|
|
if (mounted) setState(() => _limits = limits);
|
|
_checkResume();
|
|
case IntelligentMode.activeTrack:
|
|
case IntelligentMode.tapFly:
|
|
case IntelligentMode.quickShots:
|
|
// Vision modes work on the live video.
|
|
_vision.clearPending();
|
|
setState(() {
|
|
_mode = m;
|
|
_mapBig = false;
|
|
});
|
|
if (m == IntelligentMode.activeTrack && _m.connected) {
|
|
try {
|
|
_vision.setRetreat(await _dji.getActiveTrackRetreat());
|
|
} catch (_) {
|
|
_vision.setRetreat(null); // not reported: the chip starts off
|
|
}
|
|
}
|
|
case IntelligentMode.pointOfInterest:
|
|
// The centre is picked on the map; it stays big until GO.
|
|
setState(() {
|
|
_mode = m;
|
|
_mapBig = !_m.mission.hotpoint.active;
|
|
});
|
|
if (_setup.poi.center == null && _m.aircraft.latitude != null && _m.aircraft.longitude != null) {
|
|
_minimap.setFollow(true);
|
|
}
|
|
case IntelligentMode.followMe:
|
|
case IntelligentMode.courseLock:
|
|
case IntelligentMode.homeLock:
|
|
case IntelligentMode.tripod:
|
|
case IntelligentMode.hyperlapse:
|
|
setState(() {
|
|
_mode = m;
|
|
_mapBig = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Asks whether the route on screen was cut short, to offer RESUME.
|
|
Future<void> _checkResume() async {
|
|
final int? i = await _gate.interruptionFor(_waypoints.mission);
|
|
if (mounted && i != _resumeFrom) setState(() => _resumeFrom = i);
|
|
}
|
|
|
|
void _resume(BuildContext context) {
|
|
final int? i = _resumeFrom;
|
|
if (i == null) return;
|
|
startWaypointFlow(context, gate: _gate, spec: _waypoints.mission.from(i), onError: _error);
|
|
}
|
|
|
|
void _exitMode() => setState(() {
|
|
_mode = null;
|
|
_mapBig = false;
|
|
_waypoints.select(null);
|
|
});
|
|
|
|
bool get _visionMode => _mode == IntelligentMode.activeTrack || _mode == IntelligentMode.tapFly || _mode == IntelligentMode.quickShots;
|
|
|
|
/// Leaving a vision mode while it flies stops it first (GO 4's Exit).
|
|
Future<void> _exitVision(BuildContext context) async {
|
|
final MissionState ms = _m.mission;
|
|
final bool running = _mode == IntelligentMode.tapFly ? ms.tapFly.active : ms.activeTrack.active;
|
|
if (running) await _flow.request(context, FlightCommand.stopMode);
|
|
_vision.clearPending();
|
|
if (mounted) setState(() => _mode = null);
|
|
}
|
|
|
|
/// Leaving POI or Follow Me while it flies stops it first, like the vision modes.
|
|
Future<void> _exitAuto(BuildContext context) async {
|
|
final MissionState ms = _m.mission;
|
|
final bool running = (_mode == IntelligentMode.pointOfInterest && ms.hotpoint.active) || (_mode == IntelligentMode.followMe && ms.followMe.active);
|
|
if (running) await _flow.request(context, FlightCommand.stopMode);
|
|
if (mounted) {
|
|
setState(() {
|
|
_mode = null;
|
|
_mapBig = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
void _goQuickShot(BuildContext context, Rect box) {
|
|
final QuickShot shot = _vision.quickShot;
|
|
startVisionMode(
|
|
context,
|
|
title: 'QuickShot · ${shot.label}',
|
|
icon: 'dronie',
|
|
description: '${shot.blurb}. The aircraft records, flies the shot and comes back. Stop, the sticks or the remote\'s pause button end it.',
|
|
// QuickShots always get the slider: the aircraft flies far on its own.
|
|
checks: <GateCheck>[..._gate.checkQuickShot(shot), if (_gate.checkQuickShot(shot).isEmpty) const GateCheck.warn('The aircraft flies the whole shot on its own.')],
|
|
start: () async {
|
|
await _gate.startQuickShot(box, shot);
|
|
_vision.setPendingBox(null);
|
|
},
|
|
onError: _error,
|
|
);
|
|
}
|
|
|
|
void _goTrack(BuildContext context, Rect box, {int? subject}) {
|
|
final TrackMode mode = _vision.trackMode;
|
|
startVisionMode(
|
|
context,
|
|
title: 'ActiveTrack · ${mode.label}',
|
|
icon: 'crosshair',
|
|
description: 'The aircraft follows the boxed subject. Stop, the remote\'s pause button, or the sticks take back control.',
|
|
checks: _gate.checkActiveTrack(mode),
|
|
start: () async {
|
|
// A subject the aircraft sensed itself is started by its index, not a drawn box.
|
|
await (subject != null ? _gate.startActiveTrackSubject(subject, mode) : _gate.startActiveTrack(box, mode));
|
|
_vision.setPendingBox(null);
|
|
},
|
|
onError: _error,
|
|
);
|
|
}
|
|
|
|
void _goTapFly(BuildContext context, Offset target) {
|
|
final TapFlyDirection d = _vision.direction;
|
|
startVisionMode(
|
|
context,
|
|
title: 'TapFly · ${d.label}',
|
|
icon: 'chevronsRight',
|
|
description: 'The aircraft flies towards the point at ${_vision.speed.toStringAsFixed(0)} m/s until you stop it.',
|
|
checks: _gate.checkTapFly(d),
|
|
start: () async {
|
|
await _gate.startTapFly(target, d, speed: _vision.speed);
|
|
_vision.setPendingPoint(null);
|
|
},
|
|
onError: _error,
|
|
);
|
|
}
|
|
|
|
Future<void> _confirmTrack(bool yes) async {
|
|
try {
|
|
await _gate.confirmActiveTrack(yes: yes);
|
|
} catch (e) {
|
|
_error('ActiveTrack', e);
|
|
}
|
|
}
|
|
|
|
/// A new connection gets its own pre-flight review.
|
|
void _onModel() {
|
|
if (_m.connected == _wasConnected) return;
|
|
_wasConnected = _m.connected;
|
|
if (!_m.connected) {
|
|
_flow.resetReview();
|
|
_sticks.close();
|
|
}
|
|
}
|
|
|
|
Future<bool> _openStatus(BuildContext context, {bool offerTakeOff = false}) {
|
|
// Looking at the sheet on purpose counts as the pre-flight review.
|
|
if (!offerTakeOff) _flow.markReviewed();
|
|
return showAircraftStatusSheet(context, model: _m, dji: _dji, onError: _error, offerTakeOff: offerTakeOff);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_m.removeListener(_onModel);
|
|
_m.mission.removeListener(_onMission);
|
|
_m.aircraft.removeListener(_onSticksAircraft);
|
|
_m.aircraft.removeListener(_onCacheAircraft);
|
|
FlyPrefs.videoCache.removeListener(_onCacheAircraft);
|
|
_m.rcButton.removeListener(_onRcButton);
|
|
_m.camera.removeListener(_onCamera);
|
|
FlyPrefs.stickMode.removeListener(_onStickMode);
|
|
_followTimer?.cancel();
|
|
// The cache records only while this screen flies.
|
|
if (_caching) _dji.setVideoCacheRecording(false).catchError((Object _) {});
|
|
_setup.dispose();
|
|
_zones.dispose();
|
|
// Leaving the screen hands the aircraft back to the remote.
|
|
if (_sticks.open) _gate.disableVirtualSticks().catchError((Object _) {});
|
|
_sticks.dispose();
|
|
if (widget.waypoints == null) _waypoints.dispose();
|
|
_vision.dispose();
|
|
_library.dispose();
|
|
_trail.dispose();
|
|
_minimap.dispose();
|
|
SystemChrome.setPreferredOrientations(<DeviceOrientation>[DeviceOrientation.portraitUp]);
|
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
|
super.dispose();
|
|
}
|
|
|
|
void _error(String label, Object e) {
|
|
if (!mounted) return;
|
|
final String reason = e is PlatformException ? (e.message ?? e.code) : e.toString();
|
|
ScaffoldMessenger.of(context)
|
|
..clearSnackBars()
|
|
..showSnackBar(SnackBar(content: Text('$label failed: $reason')));
|
|
}
|
|
|
|
void _push(Widget page) => Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => page));
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final MediaQueryData mq = MediaQuery.of(context);
|
|
final double s = HudScale.scaleFor(mq.size.shortestSide);
|
|
final Rect area = HudCanvas.screenRect(mq);
|
|
final double canvasHeight = area.height / s;
|
|
final Rect pip = HudCanvas.toScreen(mq, Rect.fromLTWH(0, canvasHeight - _pipSize.height, _pipSize.width, _pipSize.height));
|
|
final Rect full = Offset.zero & mq.size;
|
|
_wasEditing = _editing;
|
|
|
|
final Widget video = ListenableBuilder(
|
|
key: const ValueKey<String>('video'),
|
|
listenable: _m,
|
|
builder: (BuildContext context, _) => VideoLayer(connected: _m.connected),
|
|
);
|
|
final Widget map = FlyMap(
|
|
key: const ValueKey<String>('map'),
|
|
model: _m,
|
|
trail: _trail,
|
|
controller: _minimap,
|
|
interactive: _mapBig,
|
|
layers: <Widget>[
|
|
// Zones under the route, planes on top.
|
|
flyZoneMapLayer(_m.flyZones),
|
|
flyZoneLicenceLayer(_m.flyZones),
|
|
flyZoneHeightLayer(_m.flyZones),
|
|
...waypointMapLayers(editor: _waypoints, mission: _m.mission, editable: _editing),
|
|
if (_mode == IntelligentMode.pointOfInterest || _m.mission.hotpoint.active) ...poiMapLayers(_setup, _m.mission),
|
|
airSenseMapLayer(_m.airSense),
|
|
],
|
|
onTap: (LatLng at) {
|
|
if (_editing) {
|
|
_waypoints.tapMap(at);
|
|
} else if (_mode == IntelligentMode.pointOfInterest && !_m.mission.hotpoint.active) {
|
|
_setup.setPoi(_setup.poi.copyWith(center: at));
|
|
}
|
|
},
|
|
);
|
|
|
|
final Widget body = Stack(fit: StackFit.expand, children: <Widget>[
|
|
// The background view, then whatever sits in the corner box. Keyed, so
|
|
// the swap moves the native video view instead of recreating it.
|
|
if (!_mapBig) ...<Widget>[
|
|
Positioned.fromRect(key: const ValueKey<String>('video-slot'), rect: full, child: video),
|
|
Positioned.fill(child: VisualOverlays(video: _m.videoSize)),
|
|
// A vision mode owns every touch on the video: a box or a point.
|
|
if (_mode == IntelligentMode.activeTrack || _mode == IntelligentMode.quickShots)
|
|
Positioned.fill(
|
|
child: ActiveTrackLayer(
|
|
mission: _m.mission,
|
|
session: _vision,
|
|
video: _m.videoSize,
|
|
onGo: (Rect box, int? subject) =>
|
|
_mode == IntelligentMode.quickShots ? _goQuickShot(context, box) : _goTrack(context, box, subject: subject),
|
|
onConfirm: _confirmTrack,
|
|
),
|
|
)
|
|
else if (_mode == IntelligentMode.tapFly)
|
|
Positioned.fill(
|
|
child: TapFlyLayer(mission: _m.mission, session: _vision, video: _m.videoSize, onGo: (Offset p) => _goTapFly(context, p)),
|
|
)
|
|
else ...<Widget>[
|
|
// Under the camera touch layer, in the same gesture arena: drags aim
|
|
// the gimbal, taps and long presses still reach the camera.
|
|
Positioned.fill(
|
|
child: Requires(
|
|
HudFeatures.gimbalDrag,
|
|
child: ListenableBuilder(
|
|
listenable: _m,
|
|
builder: (BuildContext context, _) => GimbalDragLayer(
|
|
gimbal: _m.gimbal,
|
|
dji: _dji,
|
|
connected: _m.connected,
|
|
yaw: HudFeatures.gimbalYaw.enabledFor(ProductProfileScope.of(context)),
|
|
onError: _error,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Positioned.fill(
|
|
child: Requires(
|
|
HudFeatures.cameraControls,
|
|
child: ListenableBuilder(
|
|
listenable: _m,
|
|
builder: (BuildContext context, _) =>
|
|
CameraTouchLayer(camera: _m.camera, dji: _dji, connected: _m.connected, onError: _error, video: _m.videoSize),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
// Obstacles around the picture's centre; touch-transparent.
|
|
Positioned.fill(
|
|
child: Requires(
|
|
HudFeatures.visionRadar,
|
|
whenDisconnected: false,
|
|
child: VisionRadarOverlay(vision: _m.vision, aircraft: _m.aircraft, video: _m.videoSize),
|
|
),
|
|
),
|
|
Positioned.fromRect(key: const ValueKey<String>('map-slot'), rect: pip, child: map),
|
|
] else ...<Widget>[
|
|
Positioned.fromRect(key: const ValueKey<String>('map-slot'), rect: full, child: map),
|
|
Positioned.fromRect(key: const ValueKey<String>('video-slot'), rect: pip, child: video),
|
|
],
|
|
Positioned.fill(child: HudCanvas(builder: _hud)),
|
|
Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
child: IgnorePointer(
|
|
child: ValueListenableBuilder<bool>(
|
|
valueListenable: FlyPrefs.perfOverlay,
|
|
builder: (BuildContext context, bool on, _) => on ? PerformanceOverlay.allEnabled() : const SizedBox.shrink(),
|
|
),
|
|
),
|
|
),
|
|
]);
|
|
|
|
return Scaffold(
|
|
backgroundColor: const Color(0xFF0A1120),
|
|
body: ListenableBuilder(
|
|
listenable: _m,
|
|
// Only the scope is rebuilt per tick; it notifies [Requires] below it
|
|
// when the profile object itself changes.
|
|
builder: (BuildContext context, Widget? child) => ProductProfileScope(profile: _m.profile, child: child!),
|
|
child: body,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// The open mode's panel at the head of the left column, for the modes
|
|
/// that are neither Waypoints nor a vision mode.
|
|
Widget? _modePanel(BuildContext context) {
|
|
final MissionState ms = _m.mission;
|
|
// A POI or Follow Me flying keeps its panel even after the mode was left.
|
|
final IntelligentMode? m = _mode ??
|
|
(ms.hotpoint.active
|
|
? IntelligentMode.pointOfInterest
|
|
: ms.followMe.active
|
|
? IntelligentMode.followMe
|
|
: null);
|
|
return switch (m) {
|
|
IntelligentMode.pointOfInterest =>
|
|
PoiPanel(model: _m, setup: _setup, gate: _gate, flow: _flow, dji: _dji, onExit: () => _exitAuto(context), onError: _error),
|
|
IntelligentMode.followMe => FollowMePanel(model: _m, setup: _setup, gate: _gate, flow: _flow, onExit: () => _exitAuto(context), onError: _error),
|
|
IntelligentMode.courseLock || IntelligentMode.homeLock => OrientationPanel(mode: m!, model: _m, gate: _gate, onExit: _exitMode, onError: _error),
|
|
IntelligentMode.tripod => SlowModePanel(model: _m, dji: _dji, gate: _gate, onExit: _exitMode, onError: _error),
|
|
IntelligentMode.hyperlapse => HyperlapsePanel(model: _m, setup: _setup, gate: _gate, onExit: _exitMode, onError: _error),
|
|
_ => null,
|
|
};
|
|
}
|
|
|
|
Widget _hud(BuildContext context, Size canvas, double scale) {
|
|
return Stack(clipBehavior: Clip.none, children: <Widget>[
|
|
// ── Camera column (right) ─────────────────────────────────────────────
|
|
Positioned(
|
|
right: 0,
|
|
top: 76,
|
|
bottom: 0,
|
|
child: Center(
|
|
child: Requires(
|
|
HudFeatures.cameraControls,
|
|
child: CameraColumn(
|
|
model: _m,
|
|
dji: _dji,
|
|
onError: _error,
|
|
onCameraMenu: () => showCameraMenu(context, model: _m, dji: _dji, onError: _error),
|
|
onPlayback: () => Navigator.of(context).push(AlbumPage.route(model: _m, dji: _dji)),
|
|
onCaptureModes: () => _push(CaptureModesPage(model: _m, dji: _dji)),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
// ── Zoom column (zoom cameras only) ───────────────────────────────────
|
|
// The right stick pad takes its place while the sticks fly.
|
|
if (!_mapBig && !_sticksOpen)
|
|
Positioned(
|
|
right: 68,
|
|
top: 76,
|
|
bottom: 0,
|
|
child: Center(
|
|
child: Requires(
|
|
HudFeatures.zoomColumn,
|
|
whenDisconnected: false,
|
|
child: ZoomColumn(camera: _m.camera, dji: _dji, onError: _error),
|
|
),
|
|
),
|
|
),
|
|
if (!_editing)
|
|
Positioned(
|
|
right: 68,
|
|
top: StatusBar.height + SmartBatteryBar.height + 32,
|
|
child: Requires(HudFeatures.histogram, child: HistogramView(camera: _m.camera)),
|
|
),
|
|
// ── Gimbal ────────────────────────────────────────────────────────────
|
|
Positioned(
|
|
left: 54,
|
|
top: 64,
|
|
bottom: _pipSize.height + 24,
|
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
|
|
Expanded(
|
|
child: Requires(
|
|
HudFeatures.gimbalPitch,
|
|
child: Builder(
|
|
builder: (BuildContext context) => GimbalSlider(
|
|
gimbal: _m.gimbal,
|
|
dji: _dji,
|
|
range: ProductProfileScope.of(context).gimbal?.pitch,
|
|
onError: _error,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Requires(
|
|
HudFeatures.gimbalSettings,
|
|
child: GlassPill(
|
|
key: const ValueKey<String>('gimbal-settings-button'),
|
|
height: 28,
|
|
padding: const EdgeInsets.symmetric(horizontal: 6),
|
|
onTap: () => showGimbalSettings(context, model: _m, dji: _dji, onError: _error),
|
|
child: const PVIcon('gimbal', size: 16, color: Glass.ink),
|
|
),
|
|
),
|
|
]),
|
|
),
|
|
// ── Top bar, battery bar, camera info ─────────────────────────────────
|
|
Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
top: 0,
|
|
child: StatusBar(
|
|
model: _m,
|
|
onBack: () => Navigator.of(context).maybePop(),
|
|
onStatus: () => _openStatus(context),
|
|
onMore: () => showFlySettings(
|
|
context,
|
|
model: _m,
|
|
dji: _dji,
|
|
onError: _error,
|
|
onOpenStatus: () => _openStatus(context),
|
|
onClearRoute: _trail.clear,
|
|
onOpenFlightLogs: widget.onOpenFlightLogs,
|
|
),
|
|
),
|
|
),
|
|
Positioned(left: 0, right: 0, top: StatusBar.height + 3, child: SmartBatteryBar(model: _m)),
|
|
// Exposure means nothing while planning a route on the map.
|
|
if (!_editing)
|
|
Positioned(
|
|
right: 0,
|
|
top: StatusBar.height + SmartBatteryBar.height + 5,
|
|
child: CameraInfoStrip(
|
|
camera: _m.camera,
|
|
onTap: () => showQuickExposure(context, model: _m, dji: _dji, onError: _error),
|
|
),
|
|
),
|
|
// ── Bottom: attitude ball + telemetry ─────────────────────────────────
|
|
// Gives way to the point editor while a waypoint or POI is selected.
|
|
Positioned(
|
|
left: _pipSize.width + 10,
|
|
bottom: 0,
|
|
child: ListenableBuilder(
|
|
listenable: _waypoints,
|
|
builder: (BuildContext context, Widget? child) =>
|
|
_editing && (_waypoints.selected != null || _waypoints.selectedPoi != null) ? const SizedBox.shrink() : child!,
|
|
// The left stick pad takes the attitude ball's place while the sticks fly.
|
|
child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: <Widget>[
|
|
if (_sticksOpen) const SizedBox(width: _stickPad + 10) else AttitudeBall(model: _m, size: 84),
|
|
const SizedBox(width: 6),
|
|
TelemetryStrip(aircraft: _m.aircraft),
|
|
]),
|
|
),
|
|
),
|
|
// ── On-screen sticks (plan P8) ────────────────────────────────────────
|
|
if (_sticksOpen)
|
|
Positioned(
|
|
left: _pipSize.width + 10,
|
|
bottom: 0,
|
|
child: ListenableBuilder(
|
|
listenable: _sticks,
|
|
builder: (BuildContext context, _) => StickPad(
|
|
key: const ValueKey<String>('stick-left'),
|
|
size: _stickPad,
|
|
value: _sticks.left,
|
|
onChanged: _sticks.setLeft,
|
|
caption: stickCaptions(_sticks.mode).left,
|
|
),
|
|
),
|
|
),
|
|
if (_sticksOpen)
|
|
Positioned(
|
|
right: 70,
|
|
bottom: 0,
|
|
child: ListenableBuilder(
|
|
listenable: _sticks,
|
|
builder: (BuildContext context, _) => StickPad(
|
|
key: const ValueKey<String>('stick-right'),
|
|
size: _stickPad,
|
|
value: _sticks.right,
|
|
onChanged: _sticks.setRight,
|
|
caption: stickCaptions(_sticks.mode).right,
|
|
),
|
|
),
|
|
),
|
|
// ── Mission run panel / route board, error stack, low-battery banners ─
|
|
// Bounded above the attitude ball; the stack fits what is left under the banners.
|
|
Positioned(
|
|
left: 108,
|
|
top: StatusBar.height + SmartBatteryBar.height + 8,
|
|
bottom: _sticksOpen ? _stickPad + 18 : 104,
|
|
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
|
|
LiveBadge(live: _m.liveStream, dji: _dji, onError: _error),
|
|
WaypointRunPanel(model: _m, editor: _waypoints, gate: _gate, flow: _flow, onError: _error),
|
|
// Palm control (Spark) flies the aircraft from the pilot's hand: say so.
|
|
ListenableBuilder(
|
|
listenable: _m.vision,
|
|
builder: (BuildContext context, _) => !_m.vision.palmActive
|
|
? const SizedBox.shrink()
|
|
: Padding(
|
|
padding: const EdgeInsets.only(bottom: 6),
|
|
child: GlassPill(
|
|
key: const ValueKey<String>('palm-control'),
|
|
color: Glass.pillStrong,
|
|
child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
|
const PVIcon('user', size: 12, color: Glass.ink),
|
|
const SizedBox(width: 6),
|
|
Text('PALM CONTROL', style: HudText.label.copyWith(color: Glass.ink, fontWeight: FontWeight.w700)),
|
|
const SizedBox(width: 6),
|
|
Text(_m.vision.palmLabel.toUpperCase(), style: HudText.label.copyWith(color: HudColors.ready)),
|
|
]),
|
|
),
|
|
),
|
|
),
|
|
if (_visionMode)
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 6),
|
|
child: VisionModePanel(
|
|
tapFly: _mode == IntelligentMode.tapFly,
|
|
quickShots: _mode == IntelligentMode.quickShots,
|
|
model: _m,
|
|
session: _vision,
|
|
gate: _gate,
|
|
flow: _flow,
|
|
onExit: () => _exitVision(context),
|
|
onError: _error,
|
|
),
|
|
),
|
|
if (_modePanel(context) case final Widget panel) Padding(padding: const EdgeInsets.only(bottom: 6), child: panel),
|
|
if (_editing)
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 6),
|
|
child: WaypointBoard(
|
|
editor: _waypoints,
|
|
limits: _limits,
|
|
onTap: () => showRouteSettings(context, editor: _waypoints, limits: _limits),
|
|
resumeFrom: _resumeFrom,
|
|
onResume: () => _resume(context),
|
|
),
|
|
),
|
|
Requires(
|
|
HudFeatures.airSense,
|
|
child: AirSenseBanner(airSense: _m.airSense, onTap: () => showAirSenseSheet(context, airSense: _m.airSense)),
|
|
),
|
|
Requires(
|
|
HudFeatures.flyZones,
|
|
child: FlyZoneBanner(
|
|
zones: _m.flyZones,
|
|
onTap: () => showFlyZoneSheet(context, model: _m, gate: _gate, onError: _error),
|
|
),
|
|
),
|
|
SafetyBanners(gate: _gate, onError: _error),
|
|
Flexible(child: AlertStack(alerts: _m.alerts, onMore: () => _openStatus(context))),
|
|
]),
|
|
),
|
|
// ── Left rail ─────────────────────────────────────────────────────────
|
|
Positioned(
|
|
left: 0,
|
|
top: 60,
|
|
child: ActionRail(
|
|
model: _m,
|
|
flow: _flow,
|
|
modeActive: _mode != null,
|
|
onModes: () => _openModes(context),
|
|
trailing: Requires(
|
|
HudFeatures.virtualSticks,
|
|
whenDisconnected: false,
|
|
child: SticksButton(open: _sticksOpen, onTap: () => _toggleSticks(context)),
|
|
),
|
|
),
|
|
),
|
|
if (_mapBig) Positioned(right: _editing ? 122 : 70, top: 80, child: MinimapControls(controller: _minimap, trail: _trail)),
|
|
// ── Waypoints editor: tools + point editor ────────────────────────────
|
|
if (_editing) ...<Widget>[
|
|
Positioned(
|
|
right: 70,
|
|
top: 76,
|
|
child: WaypointTools(
|
|
editor: _waypoints,
|
|
onStart: () => startWaypointFlow(context, gate: _gate, spec: _waypoints.mission, onError: _error),
|
|
onSettings: () => showRouteSettings(context, editor: _waypoints, limits: _limits),
|
|
onLibrary: () => showMissionLibrary(context, editor: _waypoints, library: _library),
|
|
onExit: _exitMode,
|
|
),
|
|
),
|
|
Positioned(
|
|
left: _pipSize.width + 10,
|
|
right: 124,
|
|
bottom: 0,
|
|
child: Builder(
|
|
builder: (BuildContext context) {
|
|
final ProductProfile p = ProductProfileScope.of(context);
|
|
return PointEditorPanel(
|
|
editor: _waypoints,
|
|
gimbalMin: p.gimbal?.pitch?.min ?? -90,
|
|
gimbalMax: p.gimbal?.pitch?.max ?? 30,
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
// ── Minimap / video corner box ────────────────────────────────────────
|
|
Positioned(
|
|
left: 0,
|
|
bottom: 0,
|
|
width: _pipSize.width,
|
|
height: _pipSize.height,
|
|
child: _PipFrame(label: _mapBig ? 'VIDEO' : 'MAP', onTap: () => setState(() => _mapBig = !_mapBig)),
|
|
),
|
|
]);
|
|
}
|
|
}
|
|
|
|
/// The corner box's frame and tap target. The content underneath is the real
|
|
/// map or video layer, placed at the same spot by [FlyScreen].
|
|
class _PipFrame extends StatelessWidget {
|
|
const _PipFrame({required this.label, required this.onTap});
|
|
|
|
final String label;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: onTap,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(4),
|
|
border: Border.all(color: const Color(0x66EAF0FA)),
|
|
),
|
|
alignment: Alignment.topLeft,
|
|
padding: const EdgeInsets.all(4),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
|
decoration: BoxDecoration(color: Glass.pillStrong, borderRadius: BorderRadius.circular(3)),
|
|
child: Text(label, style: HudText.label.copyWith(color: Glass.ink)),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|