Rebuild Fly App to design v2 with full DJI SDK integration
Rebuild the Flutter/DJI-MSDK-V4 Fly App to the v2 UI kit and wire the full SDK surface behind it. Native (Kotlin): split DjiSdkBridge into a method/event router delegating to per-subsystem SubBridge helpers sharing a BridgeCtx — FlightController (takeoff/land/RTH + rich telemetry), Camera (mode/record/photo/exposure), Gimbal, Mission (Waypoint + ActiveTrack; QuickShots via ActiveTrack QUICK_SHOT), Media (MediaManager list/thumbnail/download), and optional DJI account login. Manifest gains scoped media permissions. Flutter: ten screens under lib/ui/ (Flight HUD, capture modes, camera settings, settings menu, map+waypoints, home, album, academy, profile, routes/flight logs), driven by an expanded FlightModel. New PVIcon renders the kit's SVG paths via flutter_svg; map uses flutter_map + latlong2. Pin transitive androidx.core/browser down to SDK-35-compatible versions so the newer plugins don't force AGP 8.9.1 onto the DJI toolchain. Verified with `flutter build apk --debug` (compiles Dart + all Kotlin); runtime behaviour is untested here — it needs a physical DJI-connected device. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
3903428964
commit
a4a2709456
+125
-13
@@ -2,38 +2,150 @@ import 'package:flutter/services.dart';
|
||||
|
||||
/// Thin Dart wrapper over the native DJI Mobile SDK bridge.
|
||||
///
|
||||
/// Mirrors the channels defined in `DjiSdkBridge.kt`:
|
||||
/// Mirrors the channels defined in `DjiSdkBridge.kt` (+ its per-subsystem
|
||||
/// helpers):
|
||||
/// * method channel `dji_msdk/methods` for imperative calls
|
||||
/// * event channel `dji_msdk/events` for the SDK's async updates
|
||||
///
|
||||
/// Every method returns cleanly whether or not a product is connected; the
|
||||
/// native side answers with a typed error the UI can surface. The event stream
|
||||
/// carries maps keyed by `type`: `registration`, `connection`, `telemetry`,
|
||||
/// `battery`, `camera`, `exposure`, `gimbal`, `mission`, `mediaList`,
|
||||
/// `mediaDownload`, `djiAccount`, `database`, `init`.
|
||||
class DjiService {
|
||||
static const MethodChannel _methods = MethodChannel('dji_msdk/methods');
|
||||
static const EventChannel _events = EventChannel('dji_msdk/events');
|
||||
|
||||
/// Broadcast stream of SDK events. Each event is a map with a `type` key:
|
||||
/// `registration`, `connection`, `telemetry`, `battery`, `database`, `init`.
|
||||
Stream<Map<String, dynamic>> events() {
|
||||
return _events
|
||||
.receiveBroadcastStream()
|
||||
.map((dynamic e) => Map<String, dynamic>.from(e as Map));
|
||||
}
|
||||
|
||||
Future<String> getSdkVersion() async {
|
||||
return await _methods.invokeMethod<String>('getSdkVersion') ?? 'unknown';
|
||||
}
|
||||
// ── SDK / registration / connection ─────────────────────────────────────────
|
||||
Future<String> getSdkVersion() async =>
|
||||
await _methods.invokeMethod<String>('getSdkVersion') ?? 'unknown';
|
||||
|
||||
/// Kicks off DJI app registration (requires a valid App Key + internet).
|
||||
Future<void> registerApp() => _methods.invokeMethod<void>('registerApp');
|
||||
|
||||
/// Starts scanning for a connected product (USB remote controller / Wi-Fi).
|
||||
Future<bool> startConnection() async {
|
||||
return await _methods.invokeMethod<bool>('startConnection') ?? false;
|
||||
}
|
||||
Future<bool> startConnection() async =>
|
||||
await _methods.invokeMethod<bool>('startConnection') ?? false;
|
||||
|
||||
Future<void> stopConnection() =>
|
||||
_methods.invokeMethod<void>('stopConnection');
|
||||
Future<void> stopConnection() => _methods.invokeMethod<void>('stopConnection');
|
||||
|
||||
Future<Map<String, dynamic>> getProductInfo() async {
|
||||
final dynamic info = await _methods.invokeMethod('getProductInfo');
|
||||
return Map<String, dynamic>.from(info as Map);
|
||||
}
|
||||
|
||||
// ── Flight controller commands ──────────────────────────────────────────────
|
||||
Future<void> takeOff() => _methods.invokeMethod<void>('takeOff');
|
||||
Future<void> land() => _methods.invokeMethod<void>('land');
|
||||
Future<void> confirmLanding() => _methods.invokeMethod<void>('confirmLanding');
|
||||
Future<void> cancelLanding() => _methods.invokeMethod<void>('cancelLanding');
|
||||
Future<void> startGoHome() => _methods.invokeMethod<void>('startGoHome');
|
||||
Future<void> cancelGoHome() => _methods.invokeMethod<void>('cancelGoHome');
|
||||
Future<void> setHomeToCurrent() => _methods.invokeMethod<void>('setHomeToCurrent');
|
||||
|
||||
// ── Flight settings (Safety / Control) ──────────────────────────────────────
|
||||
Future<void> setMaxFlightHeight(int m) =>
|
||||
_methods.invokeMethod<void>('setMaxFlightHeight', <String, dynamic>{'value': m});
|
||||
Future<void> setMaxFlightRadius(int m) =>
|
||||
_methods.invokeMethod<void>('setMaxFlightRadius', <String, dynamic>{'value': m});
|
||||
Future<void> setMaxRadiusEnabled(bool on) =>
|
||||
_methods.invokeMethod<void>('setMaxRadiusEnabled', <String, dynamic>{'value': on});
|
||||
Future<void> setGoHomeHeight(int m) =>
|
||||
_methods.invokeMethod<void>('setGoHomeHeight', <String, dynamic>{'value': m});
|
||||
Future<void> setNoviceMode(bool on) =>
|
||||
_methods.invokeMethod<void>('setNoviceMode', <String, dynamic>{'value': on});
|
||||
Future<void> setObstacleAvoidance(bool on) =>
|
||||
_methods.invokeMethod<void>('setObstacleAvoidance', <String, dynamic>{'value': on});
|
||||
|
||||
// ── Camera ──────────────────────────────────────────────────────────────────
|
||||
/// mode: `photo` | `video` | `mediaDownload` | `playback`
|
||||
Future<void> setCameraMode(String mode) =>
|
||||
_methods.invokeMethod<void>('setCameraMode', <String, dynamic>{'mode': mode});
|
||||
Future<void> startShootPhoto() => _methods.invokeMethod<void>('startShootPhoto');
|
||||
Future<void> stopShootPhoto() => _methods.invokeMethod<void>('stopShootPhoto');
|
||||
Future<void> startRecordVideo() => _methods.invokeMethod<void>('startRecordVideo');
|
||||
Future<void> stopRecordVideo() => _methods.invokeMethod<void>('stopRecordVideo');
|
||||
|
||||
/// mode: SINGLE | HDR | BURST | AEB | INTERVAL | PANORAMA
|
||||
Future<void> setShootPhotoMode(String mode) =>
|
||||
_methods.invokeMethod<void>('setShootPhotoMode', <String, dynamic>{'mode': mode});
|
||||
|
||||
/// program: `auto` (PROGRAM) | `pro` (MANUAL)
|
||||
Future<void> setExposureProgram(String program) =>
|
||||
_methods.invokeMethod<void>('setExposureProgram', <String, dynamic>{'program': program});
|
||||
Future<void> setISO(String value) =>
|
||||
_methods.invokeMethod<void>('setISO', <String, dynamic>{'value': value});
|
||||
Future<void> setShutterSpeed(String value) =>
|
||||
_methods.invokeMethod<void>('setShutterSpeed', <String, dynamic>{'value': value});
|
||||
Future<void> setAperture(String value) =>
|
||||
_methods.invokeMethod<void>('setAperture', <String, dynamic>{'value': value});
|
||||
Future<void> setEV(String value) =>
|
||||
_methods.invokeMethod<void>('setEV', <String, dynamic>{'value': value});
|
||||
Future<void> setWhiteBalance(String value) =>
|
||||
_methods.invokeMethod<void>('setWhiteBalance', <String, dynamic>{'value': value});
|
||||
Future<void> setHistogramEnabled(bool on) =>
|
||||
_methods.invokeMethod<void>('setHistogramEnabled', <String, dynamic>{'value': on});
|
||||
|
||||
// ── Gimbal ──────────────────────────────────────────────────────────────────
|
||||
Future<void> rotateGimbalPitch(double deg) =>
|
||||
_methods.invokeMethod<void>('rotateGimbalPitch', <String, dynamic>{'pitch': deg});
|
||||
Future<void> resetGimbal() => _methods.invokeMethod<void>('resetGimbal');
|
||||
|
||||
// ── Missions ────────────────────────────────────────────────────────────────
|
||||
/// points: [{lat, lon, altitude}], finishAction: NO_ACTION|GO_HOME|AUTO_LAND|GO_FIRST_WAYPOINT
|
||||
Future<void> uploadWaypointMission(
|
||||
List<Map<String, dynamic>> points, {
|
||||
double speed = 8,
|
||||
String finishAction = 'NO_ACTION',
|
||||
}) =>
|
||||
_methods.invokeMethod<void>('uploadWaypointMission', <String, dynamic>{
|
||||
'points': points,
|
||||
'speed': speed,
|
||||
'finishAction': finishAction,
|
||||
});
|
||||
Future<void> startWaypointMission() => _methods.invokeMethod<void>('startWaypointMission');
|
||||
Future<void> stopWaypointMission() => _methods.invokeMethod<void>('stopWaypointMission');
|
||||
Future<void> pauseWaypointMission() => _methods.invokeMethod<void>('pauseWaypointMission');
|
||||
Future<void> resumeWaypointMission() => _methods.invokeMethod<void>('resumeWaypointMission');
|
||||
|
||||
/// Rect is normalized 0..1 in the live view. mode: TRACE|PROFILE|SPOTLIGHT|QUICK_SHOT
|
||||
Future<void> startActiveTrack(
|
||||
double x,
|
||||
double y,
|
||||
double w,
|
||||
double h, {
|
||||
String mode = 'TRACE',
|
||||
}) =>
|
||||
_methods.invokeMethod<void>('startActiveTrack', <String, dynamic>{
|
||||
'x': x,
|
||||
'y': y,
|
||||
'w': w,
|
||||
'h': h,
|
||||
'mode': mode,
|
||||
});
|
||||
Future<void> stopActiveTrack() => _methods.invokeMethod<void>('stopActiveTrack');
|
||||
|
||||
/// A DJI-Fly QuickShot by name (Dronie/Rocket/Circle/Helix/Boomerang/Asteroid).
|
||||
/// The native side maps each to the closest real operator or returns a typed
|
||||
/// "unsupported on this aircraft" error.
|
||||
Future<void> startQuickShot(String name) =>
|
||||
_methods.invokeMethod<void>('startQuickShot', <String, dynamic>{'name': name});
|
||||
|
||||
// ── Media (MediaManager) ────────────────────────────────────────────────────
|
||||
Future<void> refreshMediaList() => _methods.invokeMethod<void>('refreshMediaList');
|
||||
Future<String?> fetchThumbnail(int index) =>
|
||||
_methods.invokeMethod<String>('fetchThumbnail', <String, dynamic>{'index': index});
|
||||
Future<String?> downloadMedia(int index) =>
|
||||
_methods.invokeMethod<String>('downloadMedia', <String, dynamic>{'index': index});
|
||||
Future<void> deleteMedia(int index) =>
|
||||
_methods.invokeMethod<void>('deleteMedia', <String, dynamic>{'index': index});
|
||||
|
||||
// ── DJI account (optional) ──────────────────────────────────────────────────
|
||||
Future<void> djiLogin() => _methods.invokeMethod<void>('djiLogin');
|
||||
Future<void> djiLogout() => _methods.invokeMethod<void>('djiLogout');
|
||||
Future<void> refreshDjiAccountState() => _methods.invokeMethod<void>('getDjiAccountState');
|
||||
}
|
||||
|
||||
@@ -4,29 +4,144 @@ import 'uploader.dart';
|
||||
|
||||
enum RegistrationState { idle, registering, success, failed }
|
||||
|
||||
/// Live aircraft/session state, shared by the Go Fly launch screen and the
|
||||
/// Flight Control overlay. [_HomePageState] owns the DJI/uploader plumbing and
|
||||
/// pushes updates here; the screens observe it via [AnimatedBuilder].
|
||||
/// Camera capture family, mirrors the right-rail Photo/Video/Pano switch.
|
||||
enum CaptureMode { photo, video, pano }
|
||||
|
||||
/// Exposure program — Auto (PROGRAM) vs Pro (MANUAL) tab on Camera settings.
|
||||
enum ExposureProgram { auto, pro }
|
||||
|
||||
/// A media file listed off the aircraft's SD card by the native MediaManager.
|
||||
class MediaItem {
|
||||
MediaItem({
|
||||
required this.index,
|
||||
required this.fileName,
|
||||
required this.isVideo,
|
||||
this.durationSeconds,
|
||||
this.sizeBytes,
|
||||
this.createdMs,
|
||||
this.thumbPath,
|
||||
this.localPath,
|
||||
});
|
||||
|
||||
final int index;
|
||||
final String fileName;
|
||||
final bool isVideo;
|
||||
final int? durationSeconds;
|
||||
final int? sizeBytes;
|
||||
final int? createdMs;
|
||||
|
||||
/// On-disk path of the fetched thumbnail (cache dir), once available.
|
||||
String? thumbPath;
|
||||
|
||||
/// On-disk path of the fully downloaded original, once available.
|
||||
String? localPath;
|
||||
|
||||
String? get durationLabel {
|
||||
if (durationSeconds == null) return null;
|
||||
final int s = durationSeconds!;
|
||||
final String mm = (s ~/ 60).toString();
|
||||
final String ss = (s % 60).toString().padLeft(2, '0');
|
||||
return '$mm:$ss';
|
||||
}
|
||||
|
||||
factory MediaItem.fromMap(Map<String, dynamic> m) => MediaItem(
|
||||
index: (m['index'] as num).toInt(),
|
||||
fileName: (m['fileName'] as String?) ?? 'file',
|
||||
isVideo: (m['type'] as String?) == 'video',
|
||||
durationSeconds: (m['durationSeconds'] as num?)?.toInt(),
|
||||
sizeBytes: (m['sizeBytes'] as num?)?.toInt(),
|
||||
createdMs: (m['createdMs'] as num?)?.toInt(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Live aircraft/session state, shared across every screen. `_HomePageState`
|
||||
/// owns the DJI/uploader plumbing and pushes updates here; the screens observe
|
||||
/// it via [AnimatedBuilder]. Call [bump] after a batch of field writes.
|
||||
class FlightModel extends ChangeNotifier {
|
||||
// ── SDK / registration / connection ────────────────────────────────────────
|
||||
String sdkVersion = '…';
|
||||
RegistrationState registration = RegistrationState.idle;
|
||||
String? registrationError;
|
||||
|
||||
bool connected = false;
|
||||
String? model;
|
||||
String? firmwareVersion;
|
||||
|
||||
// ── Flight controller telemetry ────────────────────────────────────────────
|
||||
int? satellites;
|
||||
int? gpsSignalLevel; // 0..5
|
||||
bool? isFlying;
|
||||
bool? motorsOn;
|
||||
String? flightMode;
|
||||
double? altitude;
|
||||
double? altitude; // m, relative to home
|
||||
double? latitude;
|
||||
double? longitude;
|
||||
// Phone's own GPS (independent of the drone's fix above); streamed to the
|
||||
// server as a location fallback for the Web App's automatic bounding box.
|
||||
double? homeLatitude;
|
||||
double? homeLongitude;
|
||||
double? homeDistance; // m, aircraft ↔ home
|
||||
double? horizontalSpeed; // m/s
|
||||
double? verticalSpeed; // m/s (up positive)
|
||||
double? heading; // deg
|
||||
int? flightTimeSeconds;
|
||||
double? goHomeHeight; // m
|
||||
|
||||
// Phone's own GPS (independent of the drone's fix); streamed to the server as
|
||||
// a location fallback for the Web App's automatic bounding box.
|
||||
double? phoneLatitude;
|
||||
double? phoneLongitude;
|
||||
int? batteryPercent;
|
||||
|
||||
// ── Battery ─────────────────────────────────────────────────────────────────
|
||||
int? batteryPercent;
|
||||
double? batteryVoltage; // V
|
||||
double? batteryTemperature; // °C
|
||||
|
||||
// ── Camera ──────────────────────────────────────────────────────────────────
|
||||
CaptureMode captureMode = CaptureMode.video;
|
||||
String? shootPhotoMode; // SINGLE / HDR / BURST / AEB / INTERVAL / PANORAMA
|
||||
bool isRecording = false;
|
||||
int recordSeconds = 0;
|
||||
bool sdInserted = false;
|
||||
int? sdRemainingMB;
|
||||
int? sdPhotoCount;
|
||||
int? sdVideoCount;
|
||||
|
||||
// ── Exposure ────────────────────────────────────────────────────────────────
|
||||
ExposureProgram exposureProgram = ExposureProgram.auto;
|
||||
String? iso; // "100", "AUTO", …
|
||||
String? shutter; // "1/240"
|
||||
String? aperture; // "f/2.8"
|
||||
String? ev; // "-0.3"
|
||||
String? whiteBalance; // "5200K" / "AUTO"
|
||||
List<double>? histogram; // 0..1 normalized bins
|
||||
|
||||
// ── Gimbal ──────────────────────────────────────────────────────────────────
|
||||
double? gimbalPitch; // deg (−90..30-ish)
|
||||
double? gimbalRoll;
|
||||
double? gimbalYaw;
|
||||
|
||||
// ── Flight settings (Safety / Control) ──────────────────────────────────────
|
||||
int? maxHeight; // m
|
||||
int? maxRadius; // m
|
||||
bool? maxRadiusEnabled;
|
||||
int? rthHeight; // m
|
||||
String? obstacleAvoidance; // "On" / "Bypass" / "Off"
|
||||
bool? noviceMode;
|
||||
bool arHomePoint = true; // client-side toggle (AR overlay)
|
||||
|
||||
// ── Missions ────────────────────────────────────────────────────────────────
|
||||
String missionState = 'idle'; // idle / ready / uploading / executing / …
|
||||
bool missionRunning = false;
|
||||
bool tracking = false; // ActiveTrack engaged
|
||||
String? missionError;
|
||||
|
||||
// ── On-drone media ──────────────────────────────────────────────────────────
|
||||
List<MediaItem> media = <MediaItem>[];
|
||||
bool mediaLoading = false;
|
||||
|
||||
// ── DJI account (optional, in addition to PilotVault) ───────────────────────
|
||||
String djiAccountState = 'unknown'; // notLoggedIn / tokenOutOfDate / authorized / …
|
||||
String? djiAccountUser;
|
||||
|
||||
// ── Telemetry upload channel ────────────────────────────────────────────────
|
||||
UploadStatus upload = UploadStatus.disabled;
|
||||
|
||||
bool get registered => registration == RegistrationState.success;
|
||||
|
||||
+139
-7
@@ -9,9 +9,13 @@ import 'flight_model.dart';
|
||||
import 'login_page.dart';
|
||||
import 'pb_auth.dart';
|
||||
import 'theme.dart';
|
||||
import 'ui/academy_page.dart';
|
||||
import 'ui/album_page.dart';
|
||||
import 'ui/flight_control_page.dart';
|
||||
import 'ui/go_fly_page.dart';
|
||||
import 'ui/flight_logs_page.dart';
|
||||
import 'ui/home_page.dart';
|
||||
import 'ui/profile_page.dart';
|
||||
import 'ui/routes_page.dart';
|
||||
import 'uploader.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
@@ -148,6 +152,7 @@ class _HomePageState extends State<HomePage> {
|
||||
case 'connection':
|
||||
_model.connected = event['connected'] as bool? ?? false;
|
||||
_model.model = event['model'] as String?;
|
||||
_model.firmwareVersion = event['firmware'] as String?;
|
||||
if (!_model.connected) _clearTelemetry();
|
||||
_model.bump();
|
||||
break;
|
||||
@@ -156,11 +161,20 @@ class _HomePageState extends State<HomePage> {
|
||||
// repopulates values _clearTelemetry() just wiped, leaving stale readings.
|
||||
if (!_model.connected) break;
|
||||
_model.satellites = event['satelliteCount'] as int?;
|
||||
_model.gpsSignalLevel = event['gpsSignalLevel'] as int?;
|
||||
_model.isFlying = event['isFlying'] as bool?;
|
||||
_model.motorsOn = event['areMotorsOn'] as bool?;
|
||||
_model.flightMode = event['flightMode'] as String?;
|
||||
_model.altitude = (event['altitude'] as num?)?.toDouble();
|
||||
_model.latitude = (event['latitude'] as num?)?.toDouble();
|
||||
_model.longitude = (event['longitude'] as num?)?.toDouble();
|
||||
_model.homeLatitude = (event['homeLatitude'] as num?)?.toDouble();
|
||||
_model.homeLongitude = (event['homeLongitude'] as num?)?.toDouble();
|
||||
_model.homeDistance = (event['homeDistance'] as num?)?.toDouble();
|
||||
_model.horizontalSpeed = (event['horizontalSpeed'] as num?)?.toDouble();
|
||||
_model.verticalSpeed = (event['verticalSpeed'] as num?)?.toDouble();
|
||||
_model.heading = (event['heading'] as num?)?.toDouble();
|
||||
_model.goHomeHeight = (event['goHomeHeight'] as num?)?.toDouble();
|
||||
_model.bump();
|
||||
break;
|
||||
case 'battery':
|
||||
@@ -168,19 +182,82 @@ class _HomePageState extends State<HomePage> {
|
||||
// last percentage (the reported "disconnected but still 42%" bug).
|
||||
if (!_model.connected) break;
|
||||
_model.batteryPercent = event['percent'] as int?;
|
||||
_model.batteryVoltage = (event['voltage'] as num?)?.toDouble();
|
||||
_model.batteryTemperature = (event['temperature'] as num?)?.toDouble();
|
||||
_model.bump();
|
||||
break;
|
||||
case 'camera':
|
||||
if (!_model.connected) break;
|
||||
_model.isRecording = event['isRecording'] as bool? ?? false;
|
||||
_model.recordSeconds = (event['recordingTimeSeconds'] as num?)?.toInt() ?? _model.recordSeconds;
|
||||
_model.bump();
|
||||
break;
|
||||
case 'exposure':
|
||||
if (!_model.connected) break;
|
||||
_model.iso = event['iso'] as String?;
|
||||
_model.shutter = event['shutter'] as String?;
|
||||
_model.aperture = event['aperture'] as String?;
|
||||
_model.ev = event['ev'] as String?;
|
||||
_model.bump();
|
||||
break;
|
||||
case 'gimbal':
|
||||
if (!_model.connected) break;
|
||||
_model.gimbalPitch = (event['pitch'] as num?)?.toDouble();
|
||||
_model.gimbalRoll = (event['roll'] as num?)?.toDouble();
|
||||
_model.gimbalYaw = (event['yaw'] as num?)?.toDouble();
|
||||
_model.bump();
|
||||
break;
|
||||
case 'djiAccount':
|
||||
_model.djiAccountState = event['state'] as String? ?? 'unknown';
|
||||
_model.djiAccountUser = event['user'] as String?;
|
||||
_model.bump();
|
||||
break;
|
||||
case 'mission':
|
||||
_model.missionState = event['state'] as String? ?? 'idle';
|
||||
_model.tracking = _model.missionState == 'tracking' || _model.missionState == 'quickshot';
|
||||
_model.missionRunning = _model.missionState != 'idle';
|
||||
_model.bump();
|
||||
break;
|
||||
case 'mediaList':
|
||||
final List<dynamic> files = (event['files'] as List<dynamic>?) ?? const <dynamic>[];
|
||||
_model.media = files
|
||||
.map((dynamic e) => MediaItem.fromMap(Map<String, dynamic>.from(e as Map)))
|
||||
.toList();
|
||||
_model.mediaLoading = false;
|
||||
_model.bump();
|
||||
break;
|
||||
case 'mediaDownload':
|
||||
final int idx = (event['index'] as num?)?.toInt() ?? -1;
|
||||
final String? path = event['path'] as String?;
|
||||
if (idx >= 0 && idx < _model.media.length && path != null) {
|
||||
_model.media[idx].localPath = path;
|
||||
_model.bump();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _clearTelemetry() {
|
||||
_model.satellites = null;
|
||||
_model.gpsSignalLevel = null;
|
||||
_model.isFlying = null;
|
||||
_model.motorsOn = null;
|
||||
_model.flightMode = null;
|
||||
_model.altitude = null;
|
||||
_model.latitude = null;
|
||||
_model.longitude = null;
|
||||
_model.homeLatitude = null;
|
||||
_model.homeLongitude = null;
|
||||
_model.homeDistance = null;
|
||||
_model.horizontalSpeed = null;
|
||||
_model.verticalSpeed = null;
|
||||
_model.heading = null;
|
||||
_model.batteryPercent = null;
|
||||
_model.batteryVoltage = null;
|
||||
_model.batteryTemperature = null;
|
||||
_model.isRecording = false;
|
||||
_model.recordSeconds = 0;
|
||||
_model.firmwareVersion = null;
|
||||
}
|
||||
|
||||
Future<void> _register() async {
|
||||
@@ -229,11 +306,19 @@ class _HomePageState extends State<HomePage> {
|
||||
}
|
||||
final Map<String, dynamic> tel = <String, dynamic>{'type': 'telemetry'};
|
||||
if (_model.satellites != null) tel['satelliteCount'] = _model.satellites;
|
||||
if (_model.gpsSignalLevel != null) tel['gpsSignalLevel'] = _model.gpsSignalLevel;
|
||||
if (_model.isFlying != null) tel['isFlying'] = _model.isFlying;
|
||||
if (_model.motorsOn != null) tel['areMotorsOn'] = _model.motorsOn;
|
||||
if (_model.flightMode != null) tel['flightMode'] = _model.flightMode;
|
||||
if (_model.altitude != null) tel['altitude'] = _model.altitude;
|
||||
if (_model.latitude != null) tel['latitude'] = _model.latitude;
|
||||
if (_model.longitude != null) tel['longitude'] = _model.longitude;
|
||||
if (_model.homeLatitude != null) tel['homeLatitude'] = _model.homeLatitude;
|
||||
if (_model.homeLongitude != null) tel['homeLongitude'] = _model.homeLongitude;
|
||||
if (_model.homeDistance != null) tel['homeDistance'] = _model.homeDistance;
|
||||
if (_model.horizontalSpeed != null) tel['horizontalSpeed'] = _model.horizontalSpeed;
|
||||
if (_model.verticalSpeed != null) tel['verticalSpeed'] = _model.verticalSpeed;
|
||||
if (_model.heading != null) tel['heading'] = _model.heading;
|
||||
if (_model.phoneLatitude != null) tel['phoneLatitude'] = _model.phoneLatitude;
|
||||
if (_model.phoneLongitude != null) tel['phoneLongitude'] = _model.phoneLongitude;
|
||||
if (tel.length > 1) events.add(tel);
|
||||
@@ -297,25 +382,72 @@ class _HomePageState extends State<HomePage> {
|
||||
|
||||
void _goFly() {
|
||||
Navigator.of(context).push(MaterialPageRoute<void>(
|
||||
builder: (_) => FlightControlPage(model: _model),
|
||||
builder: (_) => FlightControlPage(model: _model, dji: _dji),
|
||||
));
|
||||
}
|
||||
|
||||
void _openAlbum() {
|
||||
Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => const AlbumPage()));
|
||||
Navigator.of(context).push(MaterialPageRoute<void>(
|
||||
builder: (_) => AlbumPage(model: _model, dji: _dji),
|
||||
));
|
||||
}
|
||||
|
||||
void _onTile(String tile) => _snack('$tile — coming soon');
|
||||
/// The disconnected-home "Connect aircraft" button: register first if needed
|
||||
/// (registration auto-starts a product scan on success), else just scan.
|
||||
Future<void> _connectFlow() async {
|
||||
if (!auth.isAuthed) {
|
||||
_snack('Sign in to connect an aircraft');
|
||||
await _openLogin();
|
||||
return;
|
||||
}
|
||||
if (!_model.registered) {
|
||||
await _register();
|
||||
} else {
|
||||
await _connect();
|
||||
}
|
||||
}
|
||||
|
||||
void _onTile(String tile) {
|
||||
switch (tile) {
|
||||
case 'Album':
|
||||
_openAlbum();
|
||||
break;
|
||||
case 'Academy':
|
||||
Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => const AcademyPage()));
|
||||
break;
|
||||
case 'Routes':
|
||||
Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => const RoutesPage()));
|
||||
break;
|
||||
case 'Flight logs':
|
||||
Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => const FlightLogsPage()));
|
||||
break;
|
||||
default:
|
||||
_snack('$tile — coming soon');
|
||||
}
|
||||
}
|
||||
|
||||
/// The avatar opens the pilot profile (PilotVault + optional DJI account,
|
||||
/// stats, library shortcuts). Technical controls live behind its App settings.
|
||||
void _openProfile() {
|
||||
Navigator.of(context).push(MaterialPageRoute<void>(
|
||||
builder: (_) => ProfilePage(
|
||||
model: _model,
|
||||
dji: _dji,
|
||||
onAppSettings: _openSettings,
|
||||
onSignIn: _openLogin,
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
// ── UI ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GoFlyPage(
|
||||
return HomeScreen(
|
||||
model: _model,
|
||||
onGoFly: _goFly,
|
||||
onOpenAlbum: _openAlbum,
|
||||
onSettings: _openSettings,
|
||||
onConnect: _connectFlow,
|
||||
onSettings: _openProfile,
|
||||
onTile: _onTile,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../theme.dart';
|
||||
import 'pv_icons.dart';
|
||||
|
||||
/// Academy — mirrors the v2 "Academy" mockup. A featured card plus a list of
|
||||
/// tutorial / manual / tips entries that open reference material in the browser.
|
||||
class AcademyPage extends StatelessWidget {
|
||||
const AcademyPage({super.key});
|
||||
|
||||
static const List<(String, String, String, String)> _cards = <(String, String, String, String)>[
|
||||
('academy', 'Flight tutorials', '6 lessons', 'https://www.dji.com/dk/dji-fly'),
|
||||
('shield', 'Product manuals', 'Air 3 · RC 2', 'https://www.pix-pro.com/blog/dji-fly-guide-part1'),
|
||||
('sun', 'Flight tips', 'Wind & weather', 'https://www.skyzr.com/en/dji/dji-fly-app/the-ultimate-dji-fly-app-guide/'),
|
||||
];
|
||||
|
||||
Future<void> _open(BuildContext context, String url) async {
|
||||
final Uri uri = Uri.parse(url);
|
||||
if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Could not open $url')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final PVScheme s = PVScheme.of(context);
|
||||
return Scaffold(
|
||||
backgroundColor: s.bgApp,
|
||||
body: SafeArea(
|
||||
child: ListView(
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 6, 20, 14),
|
||||
child: Row(children: <Widget>[
|
||||
IconButton(onPressed: () => Navigator.of(context).maybePop(), icon: PVIcon('chevronLeft', size: 24, color: s.textSecondary)),
|
||||
Text('Academy', style: TextStyle(fontFamily: PV.fontSans, fontSize: 20, fontWeight: FontWeight.w700, letterSpacing: -0.4, color: s.textPrimary)),
|
||||
]),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => _open(context, 'https://www.dji.com/dk/dji-fly'),
|
||||
child: Container(
|
||||
height: 132,
|
||||
margin: const EdgeInsets.fromLTRB(20, 0, 20, 16),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: s.border),
|
||||
gradient: const LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: <Color>[Color(0xFF26406E), Color(0xFF0F1E3D)]),
|
||||
),
|
||||
child: Stack(children: <Widget>[
|
||||
const Positioned(
|
||||
left: 16, bottom: 16, right: 64,
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||
Text('GETTING STARTED', style: TextStyle(fontFamily: PV.fontMono, fontSize: 10, letterSpacing: 1.4, color: Color(0xB3EAF0FA))),
|
||||
SizedBox(height: 3),
|
||||
Text('Your first flight in 5 minutes', style: TextStyle(fontFamily: PV.fontSans, fontSize: 17, fontWeight: FontWeight.w700, color: Color(0xFFEAF0FA))),
|
||||
]),
|
||||
),
|
||||
Positioned(
|
||||
top: 16, right: 16,
|
||||
child: Container(
|
||||
width: 40, height: 40,
|
||||
decoration: const BoxDecoration(color: Color(0x29FFFFFF), shape: BoxShape.circle),
|
||||
alignment: Alignment.center,
|
||||
child: const PVIcon('play', size: 18, color: Colors.white, fill: true),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
for (final (String ic, String t, String sub, String url) in _cards)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 10),
|
||||
child: GestureDetector(
|
||||
onTap: () => _open(context, url),
|
||||
child: Container(
|
||||
height: 62,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
decoration: BoxDecoration(color: s.surface, borderRadius: BorderRadius.circular(14), border: Border.all(color: s.border), boxShadow: s.shadowXs),
|
||||
child: Row(children: <Widget>[
|
||||
Container(
|
||||
width: 38, height: 38,
|
||||
decoration: BoxDecoration(color: s.accentSoft, borderRadius: BorderRadius.circular(10)),
|
||||
alignment: Alignment.center,
|
||||
child: PVIcon(ic, size: 19, color: s.accentSoftFg),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[
|
||||
Text(t, style: TextStyle(fontFamily: PV.fontSans, fontSize: 14.5, fontWeight: FontWeight.w600, color: s.textPrimary)),
|
||||
Text(sub, style: TextStyle(fontFamily: PV.fontMono, fontSize: 11, color: s.textSecondary)),
|
||||
])),
|
||||
PVIcon('chevronRight', size: 18, color: s.textTertiary),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+155
-86
@@ -1,12 +1,22 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../dji_service.dart';
|
||||
import '../flight_model.dart';
|
||||
import '../theme.dart';
|
||||
import 'pv_icons.dart';
|
||||
|
||||
/// Portrait media grid — mirrors the ui_kit/fly "Album" mockup. Media is
|
||||
/// placeholder content (the app has no on-device gallery source yet); the
|
||||
/// layout, filters and badges match the design.
|
||||
/// Portrait media grid — mirrors the v2 "Album" mockup. Backed by the aircraft's
|
||||
/// SD card through the SDK MediaManager: refreshes the list on open, lazily
|
||||
/// fetches thumbnails, and downloads originals on tap. Falls back to an empty
|
||||
/// state when no aircraft/media is present.
|
||||
class AlbumPage extends StatefulWidget {
|
||||
const AlbumPage({super.key});
|
||||
const AlbumPage({super.key, required this.model, required this.dji});
|
||||
|
||||
final FlightModel model;
|
||||
final DjiService dji;
|
||||
|
||||
@override
|
||||
State<AlbumPage> createState() => _AlbumPageState();
|
||||
@@ -15,14 +25,57 @@ class AlbumPage extends StatefulWidget {
|
||||
class _AlbumPageState extends State<AlbumPage> {
|
||||
static const List<String> _filters = <String>['All', 'Photos', 'Videos', 'Pano'];
|
||||
int _active = 0;
|
||||
final Set<int> _thumbRequested = <int>{};
|
||||
|
||||
// (isVideo, duration) — placeholder set from the mockup.
|
||||
static const List<(bool, String?)> _media = <(bool, String?)>[
|
||||
(true, '0:24'), (false, null), (false, null),
|
||||
(true, '1:12'), (false, null), (false, null),
|
||||
(false, null), (true, '0:08'), (false, null),
|
||||
(false, null), (true, '0:31'), (false, null),
|
||||
];
|
||||
DjiService get _dji => widget.dji;
|
||||
FlightModel get _m => widget.model;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_refresh();
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
if (!_m.connected) return;
|
||||
setState(() => _m.mediaLoading = true);
|
||||
try {
|
||||
await _dji.refreshMediaList();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _m.mediaLoading = false);
|
||||
_snack('Media: ${e is PlatformException ? (e.message ?? e.code) : e}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _snack(String msg) {
|
||||
ScaffoldMessenger.of(context)
|
||||
..clearSnackBars()
|
||||
..showSnackBar(SnackBar(content: Text(msg)));
|
||||
}
|
||||
|
||||
Future<void> _ensureThumb(MediaItem item) async {
|
||||
if (item.thumbPath != null || _thumbRequested.contains(item.index)) return;
|
||||
_thumbRequested.add(item.index);
|
||||
try {
|
||||
final String? path = await _dji.fetchThumbnail(item.index);
|
||||
if (path != null && mounted) setState(() => item.thumbPath = path);
|
||||
} catch (_) {
|
||||
// leave placeholder icon
|
||||
}
|
||||
}
|
||||
|
||||
List<MediaItem> get _visible {
|
||||
switch (_active) {
|
||||
case 1:
|
||||
return _m.media.where((MediaItem m) => !m.isVideo).toList();
|
||||
case 2:
|
||||
return _m.media.where((MediaItem m) => m.isVideo).toList();
|
||||
default:
|
||||
return _m.media;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -30,14 +83,17 @@ class _AlbumPageState extends State<AlbumPage> {
|
||||
return Scaffold(
|
||||
backgroundColor: s.bgApp,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
_header(s),
|
||||
_filterBar(s),
|
||||
const SizedBox(height: 14),
|
||||
Expanded(child: _grid(s)),
|
||||
],
|
||||
child: AnimatedBuilder(
|
||||
animation: _m,
|
||||
builder: (BuildContext context, _) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
_header(s),
|
||||
_filterBar(s),
|
||||
const SizedBox(height: 14),
|
||||
Expanded(child: _body(s)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -46,20 +102,16 @@ class _AlbumPageState extends State<AlbumPage> {
|
||||
Widget _header(PVScheme s) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 6, 20, 12),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
icon: Icon(Icons.chevron_left, size: 26, color: s.textSecondary),
|
||||
),
|
||||
Text(
|
||||
'Album',
|
||||
style: TextStyle(fontFamily: PV.fontSans, fontSize: 20, fontWeight: FontWeight.w700, letterSpacing: -0.4, color: s.textPrimary),
|
||||
),
|
||||
const Spacer(),
|
||||
Text('${_media.length} items', style: TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: s.textTertiary)),
|
||||
],
|
||||
),
|
||||
child: Row(children: <Widget>[
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
icon: PVIcon('chevronLeft', size: 24, color: s.textSecondary),
|
||||
),
|
||||
Text('Album', style: TextStyle(fontFamily: PV.fontSans, fontSize: 20, fontWeight: FontWeight.w700, letterSpacing: -0.4, color: s.textPrimary)),
|
||||
const Spacer(),
|
||||
IconButton(onPressed: _refresh, icon: PVIcon('search', size: 19, color: s.textSecondary)),
|
||||
Text('${_m.media.length} items', style: TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: s.textTertiary)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,19 +130,9 @@ class _AlbumPageState extends State<AlbumPage> {
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: active ? s.accent : s.surfaceInset,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
_filters[i],
|
||||
style: TextStyle(
|
||||
fontFamily: PV.fontSans,
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: active ? Colors.white : s.textSecondary,
|
||||
),
|
||||
),
|
||||
decoration: BoxDecoration(color: active ? s.accent : s.surfaceInset, borderRadius: BorderRadius.circular(999)),
|
||||
child: Text(_filters[i],
|
||||
style: TextStyle(fontFamily: PV.fontSans, fontSize: 12.5, fontWeight: FontWeight.w600, color: active ? Colors.white : s.textSecondary)),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -98,48 +140,75 @@ class _AlbumPageState extends State<AlbumPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _grid(PVScheme s) {
|
||||
Widget _body(PVScheme s) {
|
||||
if (_m.mediaLoading) {
|
||||
return Center(child: CircularProgressIndicator(color: s.accent));
|
||||
}
|
||||
final List<MediaItem> items = _visible;
|
||||
if (items.isEmpty) {
|
||||
return Center(
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||
PVIcon('album', size: 40, stroke: 1.4, color: s.textTertiary),
|
||||
const SizedBox(height: 12),
|
||||
Text(_m.connected ? 'No media on the SD card' : 'Connect an aircraft to browse its media',
|
||||
style: TextStyle(fontFamily: PV.fontSans, fontSize: 14, color: s.textSecondary)),
|
||||
const SizedBox(height: 12),
|
||||
if (_m.connected) OutlinedButton(onPressed: _refresh, child: const Text('Refresh')),
|
||||
]),
|
||||
);
|
||||
}
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
crossAxisSpacing: 6,
|
||||
mainAxisSpacing: 6,
|
||||
),
|
||||
itemCount: _media.length,
|
||||
itemBuilder: (BuildContext context, int i) {
|
||||
final (bool isVideo, String? dur) = _media[i];
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: <Color>[s.surface2, s.surfaceInset],
|
||||
transform: GradientRotation((140 + i * 14) * 3.1415926 / 180),
|
||||
),
|
||||
border: Border.all(color: s.border),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Stack(
|
||||
children: <Widget>[
|
||||
Center(child: Icon(isVideo ? Icons.videocam_outlined : Icons.image_outlined, size: 20, color: s.textTertiary)),
|
||||
if (dur != null)
|
||||
Positioned(
|
||||
right: 6,
|
||||
bottom: 5,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
|
||||
decoration: BoxDecoration(color: const Color(0xB30B1730), borderRadius: BorderRadius.circular(5)),
|
||||
child: Text(dur, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 9.5, color: Colors.white)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3, crossAxisSpacing: 6, mainAxisSpacing: 6),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (BuildContext context, int i) => _cell(s, items[i], i),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _cell(PVScheme s, MediaItem item, int i) {
|
||||
_ensureThumb(item);
|
||||
final bool hasThumb = item.thumbPath != null && File(item.thumbPath!).existsSync();
|
||||
return GestureDetector(
|
||||
onTap: () => _open(item),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft, end: Alignment.bottomRight,
|
||||
colors: <Color>[s.surface2, s.surfaceInset],
|
||||
transform: GradientRotation((140 + i * 14) * 3.1415926 / 180),
|
||||
),
|
||||
border: Border.all(color: s.border),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Stack(fit: StackFit.expand, children: <Widget>[
|
||||
if (hasThumb)
|
||||
Image.file(File(item.thumbPath!), fit: BoxFit.cover)
|
||||
else
|
||||
Center(child: PVIcon(item.isVideo ? 'video' : 'image', size: 20, stroke: 1.6, color: s.textTertiary)),
|
||||
if (item.durationLabel != null)
|
||||
Positioned(
|
||||
right: 6, bottom: 5,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
|
||||
decoration: BoxDecoration(color: const Color(0xB30B1730), borderRadius: BorderRadius.circular(5)),
|
||||
child: Text(item.durationLabel!, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 9.5, color: Colors.white)),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _open(MediaItem item) async {
|
||||
_snack('Downloading ${item.fileName}…');
|
||||
try {
|
||||
final String? path = await _dji.downloadMedia(item.index);
|
||||
if (mounted && path != null) _snack('Saved to $path');
|
||||
} catch (e) {
|
||||
if (mounted) _snack('Download: ${e is PlatformException ? (e.message ?? e.code) : e}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../dji_service.dart';
|
||||
import '../flight_model.dart';
|
||||
import '../theme.dart';
|
||||
import 'flight_overlay_scaffold.dart';
|
||||
import 'pv_icons.dart';
|
||||
|
||||
/// Pro exposure controls — mirrors the v2 "Camera settings" mockup. Auto/Pro
|
||||
/// tabs switch exposure program; the five dials (ISO / shutter / aperture / EV /
|
||||
/// WB) each drive the matching SDK setter, and the value scrubber picks a value.
|
||||
class CameraSettingsPage extends StatefulWidget {
|
||||
const CameraSettingsPage({super.key, required this.model, required this.dji});
|
||||
|
||||
final FlightModel model;
|
||||
final DjiService dji;
|
||||
|
||||
@override
|
||||
State<CameraSettingsPage> createState() => _CameraSettingsPageState();
|
||||
}
|
||||
|
||||
class _CameraSettingsPageState extends State<CameraSettingsPage> {
|
||||
DjiService get _dji => widget.dji;
|
||||
FlightModel get _m => widget.model;
|
||||
|
||||
// (key, icon, values). Values mirror the kit scrubber; the SDK rejects any it
|
||||
// doesn't support (surfaced as a snackbar), so a superset is safe.
|
||||
static const List<(String, String, List<String>)> _dials = <(String, String, List<String>)>[
|
||||
('ISO', 'iso', <String>['AUTO', '100', '200', '400', '800', '1600', '3200', '6400']),
|
||||
('Shutter', 'shutter', <String>['1/2000', '1/1000', '1/500', '1/240', '1/120', '1/60', '1/30', '1/15', '1/8']),
|
||||
('Aperture', 'aperture', <String>['f/2.8', 'f/4', 'f/5.6', 'f/8', 'f/11']),
|
||||
('EV', 'ev', <String>['-2.0', '-1.3', '-0.7', '-0.3', '0.0', '+0.3', '+0.7', '+1.3', '+2.0']),
|
||||
('WB', 'wb', <String>['AUTO', '2700K', '4000K', '5200K', '5800K', '6500K']),
|
||||
];
|
||||
|
||||
int _active = 0; // selected dial
|
||||
|
||||
bool get _pro => _m.exposureProgram == ExposureProgram.pro;
|
||||
|
||||
void _snack(String msg) {
|
||||
ScaffoldMessenger.of(context)
|
||||
..clearSnackBars()
|
||||
..showSnackBar(SnackBar(content: Text(msg)));
|
||||
}
|
||||
|
||||
Future<void> _run(String label, Future<void> Function() action) async {
|
||||
try {
|
||||
await action();
|
||||
} catch (e) {
|
||||
_snack('$label: ${e is PlatformException ? (e.message ?? e.code) : e}');
|
||||
}
|
||||
}
|
||||
|
||||
String? _valueFor(String key) => switch (key) {
|
||||
'ISO' => _m.iso,
|
||||
'Shutter' => _m.shutter,
|
||||
'Aperture' => _m.aperture,
|
||||
'EV' => _m.ev,
|
||||
'WB' => _m.whiteBalance,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
Future<void> _setValue(String key, String value) async {
|
||||
setState(() {
|
||||
switch (key) {
|
||||
case 'ISO':
|
||||
_m.iso = value;
|
||||
case 'Shutter':
|
||||
_m.shutter = value;
|
||||
case 'Aperture':
|
||||
_m.aperture = value;
|
||||
case 'EV':
|
||||
_m.ev = value;
|
||||
case 'WB':
|
||||
_m.whiteBalance = value;
|
||||
}
|
||||
});
|
||||
switch (key) {
|
||||
case 'ISO':
|
||||
await _run('ISO', () => _dji.setISO(value));
|
||||
case 'Shutter':
|
||||
await _run('Shutter', () => _dji.setShutterSpeed(value));
|
||||
case 'Aperture':
|
||||
await _run('Aperture', () => _dji.setAperture(value));
|
||||
case 'EV':
|
||||
await _run('EV', () => _dji.setEV(value));
|
||||
case 'WB':
|
||||
await _run('White balance', () => _dji.setWhiteBalance(value));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _setProgram(bool pro) async {
|
||||
setState(() => _m.exposureProgram = pro ? ExposureProgram.pro : ExposureProgram.auto);
|
||||
await _run('Exposure', () => _dji.setExposureProgram(pro ? 'pro' : 'auto'));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FlightOverlayScaffold(
|
||||
title: 'Camera settings',
|
||||
trailing: _autoProTabs(),
|
||||
body: Column(
|
||||
children: <Widget>[
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Container(
|
||||
width: 132, height: 48,
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(10)),
|
||||
child: const CustomPaint(size: Size.infinite, painter: _HistogramPainter()),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
_dialRail(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _autoProTabs() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(3),
|
||||
decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(10)),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||
_tab('Auto', !_pro, () => _setProgram(false)),
|
||||
_tab('Pro', _pro, () => _setProgram(true)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tab(String label, bool active, VoidCallback onTap) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
height: 28,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(color: active ? Glass.accent : Colors.transparent, borderRadius: BorderRadius.circular(7)),
|
||||
alignment: Alignment.center,
|
||||
child: Text(label, style: const TextStyle(fontFamily: PV.fontSans, fontSize: 12, fontWeight: FontWeight.w700, color: Glass.ink)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _dialRail() {
|
||||
final (String key, _, List<String> values) = _dials[_active];
|
||||
final String? current = _valueFor(key);
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(8, 12, 8, 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Glass.pill,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color(0x1AFFFFFF)),
|
||||
),
|
||||
child: Column(children: <Widget>[
|
||||
Row(mainAxisAlignment: MainAxisAlignment.spaceAround, children: <Widget>[
|
||||
for (int i = 0; i < _dials.length; i++) _dial(i),
|
||||
]),
|
||||
const SizedBox(height: 12),
|
||||
Opacity(
|
||||
opacity: _pro ? 1 : 0.4,
|
||||
child: IgnorePointer(
|
||||
ignoring: !_pro,
|
||||
child: SizedBox(
|
||||
height: 30,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: <Widget>[
|
||||
for (final String v in values) _scrubValue(key, v, v == current),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _dial(int i) {
|
||||
final (String key, String icon, _) = _dials[i];
|
||||
final bool sel = i == _active;
|
||||
final String value = _valueFor(key) ?? '—';
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _active = i),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||
PVIcon(icon, size: 17, color: sel ? const Color(0xFF8FB4F6) : const Color(0xFF8FA0BE)),
|
||||
const SizedBox(height: 4),
|
||||
Text(key, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 9.5, letterSpacing: 0.8, color: Color(0x99EAF0FA))),
|
||||
Text(value, style: TextStyle(fontFamily: PV.fontMono, fontSize: 15, fontWeight: FontWeight.w700, color: sel ? const Color(0xFF8FB4F6) : Glass.ink)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _scrubValue(String key, String v, bool sel) {
|
||||
return GestureDetector(
|
||||
onTap: () => _setValue(key, v),
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text(v,
|
||||
style: TextStyle(
|
||||
fontFamily: PV.fontMono,
|
||||
fontSize: sel ? 14 : 12,
|
||||
fontWeight: sel ? FontWeight.w700 : FontWeight.w400,
|
||||
color: sel ? const Color(0xFF5B93F5) : const Color(0x99EAF0FA),
|
||||
)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HistogramPainter extends CustomPainter {
|
||||
const _HistogramPainter();
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final Path p = Path()
|
||||
..moveTo(0, size.height)
|
||||
..quadraticBezierTo(size.width * 0.2, size.height * 0.2, size.width * 0.4, size.height * 0.6)
|
||||
..quadraticBezierTo(size.width * 0.6, size.height * 0.05, size.width * 0.8, size.height * 0.5)
|
||||
..quadraticBezierTo(size.width * 0.9, size.height * 0.8, size.width, size.height * 0.9);
|
||||
canvas.drawPath(
|
||||
Path.from(p)
|
||||
..lineTo(size.width, size.height)
|
||||
..lineTo(0, size.height)
|
||||
..close(),
|
||||
Paint()..color = const Color(0x407FE0B0),
|
||||
);
|
||||
canvas.drawPath(p, Paint()
|
||||
..color = const Color(0xFF7FE0B0)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.4);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _HistogramPainter oldDelegate) => false;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../dji_service.dart';
|
||||
import '../flight_model.dart';
|
||||
import '../theme.dart';
|
||||
import 'flight_overlay_scaffold.dart';
|
||||
import 'pv_icons.dart';
|
||||
|
||||
/// Capture-mode selector — mirrors the v2 "Capture modes" mockup. Photo/Video
|
||||
/// modes drive the camera directly; QuickShots + Intelligent modes start the
|
||||
/// matching SDK mission (Phase 5 native — until then they report "unavailable").
|
||||
class CaptureModesPage extends StatefulWidget {
|
||||
const CaptureModesPage({super.key, required this.model, required this.dji});
|
||||
|
||||
final FlightModel model;
|
||||
final DjiService dji;
|
||||
|
||||
@override
|
||||
State<CaptureModesPage> createState() => _CaptureModesPageState();
|
||||
}
|
||||
|
||||
class _CaptureModesPageState extends State<CaptureModesPage> {
|
||||
DjiService get _dji => widget.dji;
|
||||
FlightModel get _m => widget.model;
|
||||
|
||||
// (icon, label, wire) — wire drives the SDK call per group.
|
||||
static const List<(String, String, List<(String, String, String)>)> _groups =
|
||||
<(String, String, List<(String, String, String)>)>[
|
||||
('Photo', 'photo', <(String, String, String)>[
|
||||
('image', 'Single', 'SINGLE'),
|
||||
('aperture', 'AEB', 'AEB'),
|
||||
('burst', 'Burst', 'BURST'),
|
||||
('timer', 'Timed', 'INTERVAL'),
|
||||
('hdr', 'HDR', 'HDR'),
|
||||
]),
|
||||
('Video', 'video', <(String, String, String)>[
|
||||
('video', 'Normal', 'NORMAL'),
|
||||
('slowmo', 'Slow-Mo', 'SLOW_MOTION'),
|
||||
('hyperlapse', 'Hyperlapse', 'HYPERLAPSE'),
|
||||
]),
|
||||
('QuickShots', 'quick', <(String, String, String)>[
|
||||
('dronie', 'Dronie', 'Dronie'),
|
||||
('rocket', 'Rocket', 'Rocket'),
|
||||
('circle', 'Circle', 'Circle'),
|
||||
('helix', 'Helix', 'Helix'),
|
||||
('boomerang', 'Boomerang', 'Boomerang'),
|
||||
('asteroid', 'Asteroid', 'Asteroid'),
|
||||
]),
|
||||
('Intelligent', 'smart', <(String, String, String)>[
|
||||
('master', 'MasterShot', 'MasterShot'),
|
||||
('pano', 'Pano', 'PANORAMA'),
|
||||
('crosshair', 'Track', 'TRACK'),
|
||||
]),
|
||||
];
|
||||
|
||||
String? _selected;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selected = _m.shootPhotoMode ?? 'SINGLE';
|
||||
}
|
||||
|
||||
void _snack(String msg) {
|
||||
ScaffoldMessenger.of(context)
|
||||
..clearSnackBars()
|
||||
..showSnackBar(SnackBar(content: Text(msg)));
|
||||
}
|
||||
|
||||
Future<void> _run(String label, Future<void> Function() action) async {
|
||||
try {
|
||||
await action();
|
||||
} catch (e) {
|
||||
_snack('$label: ${e is PlatformException ? (e.message ?? e.code) : e}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pick(String group, String label, String wire) async {
|
||||
setState(() => _selected = wire);
|
||||
switch (group) {
|
||||
case 'photo':
|
||||
_m.captureMode = CaptureMode.photo;
|
||||
_m.shootPhotoMode = wire;
|
||||
await _run('Photo mode', () => _dji.setCameraMode('photo'));
|
||||
await _run('Photo mode', () => _dji.setShootPhotoMode(wire));
|
||||
break;
|
||||
case 'video':
|
||||
_m.captureMode = CaptureMode.video;
|
||||
await _run('Video mode', () => _dji.setCameraMode('video'));
|
||||
if (wire != 'NORMAL') _snack('$label is applied on the aircraft camera');
|
||||
break;
|
||||
case 'quick':
|
||||
await _run('QuickShot', () => _dji.startQuickShot(wire));
|
||||
break;
|
||||
case 'smart':
|
||||
if (wire == 'PANORAMA') {
|
||||
_m.captureMode = CaptureMode.photo;
|
||||
await _run('Pano', () => _dji.setCameraMode('photo'));
|
||||
await _run('Pano', () => _dji.setShootPhotoMode('PANORAMA'));
|
||||
} else if (wire == 'MasterShot') {
|
||||
await _run('MasterShot', () => _dji.startQuickShot('MasterShot'));
|
||||
} else {
|
||||
_snack('Track: draw a box around a subject in the flight view');
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (mounted) _m.bump();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FlightOverlayScaffold(
|
||||
title: 'Capture modes',
|
||||
body: ListView(
|
||||
children: <Widget>[
|
||||
for (final (String label, String group, List<(String, String, String)> items) in _groups) ...<Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4, bottom: 8),
|
||||
child: Text(label.toUpperCase(),
|
||||
style: const TextStyle(fontFamily: PV.fontMono, fontSize: 10, letterSpacing: 1.4, color: Color(0x8CEAF0FA))),
|
||||
),
|
||||
Wrap(spacing: 10, runSpacing: 10, children: <Widget>[
|
||||
for (final (String ic, String l, String wire) in items) _cell(group, ic, l, wire),
|
||||
]),
|
||||
const SizedBox(height: 18),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _cell(String group, String icon, String label, String wire) {
|
||||
final bool sel = _selected == wire;
|
||||
return GestureDetector(
|
||||
onTap: () => _pick(group, label, wire),
|
||||
child: SizedBox(
|
||||
width: 66,
|
||||
child: Column(children: <Widget>[
|
||||
Container(
|
||||
width: 46, height: 46,
|
||||
decoration: BoxDecoration(
|
||||
color: sel ? const Color(0x403D7BF0) : const Color(0x0DFFFFFF),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: sel ? const Color(0xFF5B93F5) : Glass.hairline, width: sel ? 2 : 1),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: PVIcon(icon, size: 20, stroke: 1.7, color: sel ? const Color(0xFF8FB4F6) : Glass.ink),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(label,
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontFamily: PV.fontMono, fontSize: 9.5, color: Color(0xCCEAF0FA))),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,18 +3,25 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../dji_service.dart';
|
||||
import '../flight_model.dart';
|
||||
import '../theme.dart';
|
||||
import 'camera_settings_page.dart';
|
||||
import 'capture_modes_page.dart';
|
||||
import 'dji_video_view.dart';
|
||||
import 'map_page.dart';
|
||||
import 'pv_icons.dart';
|
||||
import 'settings_menu_page.dart';
|
||||
|
||||
/// Landscape live-flight overlay — mirrors the ui_kit/fly "Flight control ·
|
||||
/// landscape" mockup. Locks to landscape while shown and restores portrait on
|
||||
/// exit. Camera-feed HUD is composited over a painted placeholder feed; real
|
||||
/// telemetry (satellites, battery, altitude, mode) is bound where available.
|
||||
/// Landscape live-flight overlay — mirrors the v2 "Main flight view" mockup.
|
||||
/// Live camera feed behind a glass HUD; real telemetry bound throughout, and
|
||||
/// the shutter / mode switch / gimbal slider / RTH / take-off controls issue
|
||||
/// real SDK commands via [DjiService].
|
||||
class FlightControlPage extends StatefulWidget {
|
||||
const FlightControlPage({super.key, required this.model});
|
||||
const FlightControlPage({super.key, required this.model, required this.dji});
|
||||
|
||||
final FlightModel model;
|
||||
final DjiService dji;
|
||||
|
||||
@override
|
||||
State<FlightControlPage> createState() => _FlightControlPageState();
|
||||
@@ -23,7 +30,12 @@ class FlightControlPage extends StatefulWidget {
|
||||
class _FlightControlPageState extends State<FlightControlPage> {
|
||||
Timer? _recTimer;
|
||||
int _recSeconds = 0;
|
||||
String _mode = 'Video';
|
||||
// Gimbal pitch slider: fraction 0 (top, +30°) … 1 (bottom, −90°).
|
||||
double _gimbalFrac = 0.24;
|
||||
bool _grid = false;
|
||||
|
||||
DjiService get _dji => widget.dji;
|
||||
FlightModel get _m => widget.model;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -33,9 +45,6 @@ class _FlightControlPageState extends State<FlightControlPage> {
|
||||
DeviceOrientation.landscapeRight,
|
||||
]);
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||||
_recTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (mounted) setState(() => _recSeconds++);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -46,88 +55,144 @@ class _FlightControlPageState extends State<FlightControlPage> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String get _recLabel {
|
||||
final String mm = (_recSeconds ~/ 60).toString().padLeft(2, '0');
|
||||
final String ss = (_recSeconds % 60).toString().padLeft(2, '0');
|
||||
return '$mm:$ss';
|
||||
String _fmt(int s) => '${(s ~/ 60).toString().padLeft(2, '0')}:${(s % 60).toString().padLeft(2, '0')}';
|
||||
|
||||
void _snack(String msg) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
..clearSnackBars()
|
||||
..showSnackBar(SnackBar(content: Text(msg)));
|
||||
}
|
||||
|
||||
Future<void> _run(String label, Future<void> Function() action) async {
|
||||
try {
|
||||
await action();
|
||||
} catch (e) {
|
||||
_snack('$label failed: ${_reason(e)}');
|
||||
}
|
||||
}
|
||||
|
||||
String _reason(Object e) => e is PlatformException ? (e.message ?? e.code) : e.toString();
|
||||
|
||||
// ── Commands ───────────────────────────────────────────────────────────────
|
||||
Future<void> _toggleShutter() async {
|
||||
if (_m.captureMode == CaptureMode.video) {
|
||||
if (_m.isRecording) {
|
||||
_stopRecTimer();
|
||||
await _run('Stop recording', _dji.stopRecordVideo);
|
||||
} else {
|
||||
_startRecTimer();
|
||||
await _run('Start recording', _dji.startRecordVideo);
|
||||
}
|
||||
} else {
|
||||
await _run('Shoot photo', _dji.startShootPhoto);
|
||||
}
|
||||
}
|
||||
|
||||
void _startRecTimer() {
|
||||
_recSeconds = 0;
|
||||
_recTimer?.cancel();
|
||||
_recTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (mounted) setState(() => _recSeconds++);
|
||||
});
|
||||
}
|
||||
|
||||
void _stopRecTimer() {
|
||||
_recTimer?.cancel();
|
||||
_recTimer = null;
|
||||
}
|
||||
|
||||
Future<void> _setCaptureMode(CaptureMode mode) async {
|
||||
setState(() => _m.captureMode = mode);
|
||||
final String wire = mode == CaptureMode.video ? 'video' : 'photo';
|
||||
await _run('Set camera mode', () => _dji.setCameraMode(wire));
|
||||
}
|
||||
|
||||
Future<void> _rth() async {
|
||||
final bool ok = await _confirm('Return to Home', 'The aircraft will fly back to its recorded home point and land.');
|
||||
if (ok) await _run('Return to Home', _dji.startGoHome);
|
||||
}
|
||||
|
||||
Future<void> _toggleTakeoff() async {
|
||||
if (_m.isFlying == true) {
|
||||
final bool ok = await _confirm('Land now', 'The aircraft will descend and land at its current position.');
|
||||
if (ok) await _run('Land', _dji.land);
|
||||
} else {
|
||||
final bool ok = await _confirm('Take off', 'The aircraft will take off and hover at ~1.2 m.');
|
||||
if (ok) await _run('Take off', _dji.takeOff);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _confirm(String title, String body) async {
|
||||
final bool? r = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) => AlertDialog(
|
||||
backgroundColor: const Color(0xFF10203F),
|
||||
title: Text(title, style: const TextStyle(color: Glass.ink, fontFamily: PV.fontSans, fontWeight: FontWeight.w700)),
|
||||
content: Text(body, style: const TextStyle(color: Color(0xFF8FA0BE), fontFamily: PV.fontSans)),
|
||||
actions: <Widget>[
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(title)),
|
||||
],
|
||||
),
|
||||
);
|
||||
return r ?? false;
|
||||
}
|
||||
|
||||
void _onGimbalDrag(double frac) {
|
||||
setState(() => _gimbalFrac = frac.clamp(0.0, 1.0));
|
||||
final double pitch = 30 - _gimbalFrac * 120; // +30 (top) … −90 (bottom)
|
||||
_run('Gimbal', () => _dji.rotateGimbalPitch(pitch));
|
||||
}
|
||||
|
||||
void _openCaptureModes() {
|
||||
Navigator.of(context).push(MaterialPageRoute<void>(
|
||||
builder: (_) => CaptureModesPage(model: _m, dji: _dji),
|
||||
));
|
||||
}
|
||||
|
||||
void _openCameraSettings() {
|
||||
Navigator.of(context).push(MaterialPageRoute<void>(
|
||||
builder: (_) => CameraSettingsPage(model: _m, dji: _dji),
|
||||
));
|
||||
}
|
||||
|
||||
void _openSettingsMenu() {
|
||||
Navigator.of(context).push(MaterialPageRoute<void>(
|
||||
builder: (_) => SettingsMenuPage(model: _m, dji: _dji),
|
||||
));
|
||||
}
|
||||
|
||||
void _openMap() {
|
||||
Navigator.of(context).push(MaterialPageRoute<void>(
|
||||
builder: (_) => MapPage(model: _m, dji: _dji),
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final FlightModel m = widget.model;
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF0A1120),
|
||||
body: AnimatedBuilder(
|
||||
animation: m,
|
||||
animation: _m,
|
||||
builder: (BuildContext context, _) {
|
||||
return Stack(
|
||||
children: <Widget>[
|
||||
// Background: live DJI camera feed when a product is connected,
|
||||
// painted placeholder otherwise. The HUD layers below paint over
|
||||
// it since they come later in the stack.
|
||||
Positioned.fill(
|
||||
child: m.connected
|
||||
child: _m.connected
|
||||
? const DjiVideoView()
|
||||
: const CustomPaint(painter: _FeedPainter()),
|
||||
),
|
||||
|
||||
// Center reticle
|
||||
const Center(child: Icon(Icons.add, size: 30, color: Color(0xB3FFFFFF))),
|
||||
|
||||
// Top bar
|
||||
Positioned(
|
||||
top: 12,
|
||||
left: 14,
|
||||
right: 14,
|
||||
child: _topBar(m),
|
||||
),
|
||||
|
||||
// Left rail
|
||||
Positioned(
|
||||
left: 14,
|
||||
top: 58,
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
_sideBtn(Icons.control_camera, active: true),
|
||||
const SizedBox(height: 10),
|
||||
_sideBtn(Icons.wb_sunny_outlined),
|
||||
const SizedBox(height: 10),
|
||||
_sideBtn(Icons.camera_outlined),
|
||||
const SizedBox(height: 10),
|
||||
_sideBtn(Icons.grid_on),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Gimbal pitch slider
|
||||
Positioned(
|
||||
left: 70,
|
||||
top: 58,
|
||||
bottom: 96,
|
||||
child: _gimbalSlider(),
|
||||
),
|
||||
|
||||
// Right camera controls
|
||||
Positioned(
|
||||
right: 16,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: Center(child: _cameraControls()),
|
||||
),
|
||||
|
||||
// Bottom-left minimap
|
||||
if (_grid) const Positioned.fill(child: IgnorePointer(child: CustomPaint(painter: _GridPainter()))),
|
||||
const Center(child: PVIcon('crosshair', size: 30, stroke: 1.1, color: Color(0xB3FFFFFF))),
|
||||
Positioned(top: 12, left: 14, right: 14, child: _topBar()),
|
||||
Positioned(left: 14, top: 58, child: _leftRail()),
|
||||
Positioned(left: 70, top: 58, bottom: 96, child: _gimbalSlider()),
|
||||
Positioned(right: 16, top: 0, bottom: 0, child: Center(child: _cameraControls())),
|
||||
Positioned(left: 14, bottom: 12, child: _minimap()),
|
||||
|
||||
// Bottom-center telemetry
|
||||
Positioned(
|
||||
bottom: 14,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Center(child: _telemetry(m)),
|
||||
),
|
||||
|
||||
// RTH button
|
||||
Positioned(bottom: 14, left: 0, right: 0, child: Center(child: _telemetry())),
|
||||
Positioned(right: 92, bottom: 20, child: _rthButton()),
|
||||
Positioned(right: 92, bottom: 72, child: _takeoffButton()),
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -136,54 +201,64 @@ class _FlightControlPageState extends State<FlightControlPage> {
|
||||
}
|
||||
|
||||
// ── Top bar ──────────────────────────────────────────────────────────────
|
||||
Widget _topBar(FlightModel m) {
|
||||
return Row(
|
||||
children: <Widget>[
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.of(context).maybePop(),
|
||||
child: _pill(child: const Icon(Icons.chevron_left, size: 16, color: Glass.ink)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
height: 26,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 11),
|
||||
decoration: BoxDecoration(color: Glass.accent, borderRadius: BorderRadius.circular(8)),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
(m.flightMode != null && m.flightMode!.isNotEmpty) ? m.flightMode! : 'N',
|
||||
style: const TextStyle(fontFamily: PV.fontSans, fontSize: 12, fontWeight: FontWeight.w700, letterSpacing: 0.4, color: Colors.white),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_pill(child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||
const Icon(Icons.satellite_alt, size: 14, color: Glass.sat),
|
||||
const SizedBox(width: 4),
|
||||
_mono(m.satellites?.toString() ?? '0'),
|
||||
])),
|
||||
const SizedBox(width: 8),
|
||||
_pill(child: Row(mainAxisSize: MainAxisSize.min, children: const <Widget>[
|
||||
Icon(Icons.sensors, size: 14, color: Glass.ink),
|
||||
SizedBox(width: 4),
|
||||
Text('HD', style: TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: Glass.ink)),
|
||||
])),
|
||||
const Spacer(),
|
||||
_pill(child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||
_mono('REC'),
|
||||
const SizedBox(width: 6),
|
||||
Container(width: 7, height: 7, decoration: const BoxDecoration(color: Glass.rec, shape: BoxShape.circle)),
|
||||
const SizedBox(width: 6),
|
||||
_mono(_recLabel),
|
||||
])),
|
||||
const SizedBox(width: 8),
|
||||
_pill(child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||
const Icon(Icons.battery_full, size: 16, color: Glass.sat),
|
||||
const SizedBox(width: 4),
|
||||
_mono(m.batteryPercent == null ? '—' : '${m.batteryPercent}%'),
|
||||
])),
|
||||
const SizedBox(width: 8),
|
||||
_pill(child: const Icon(Icons.settings, size: 16, color: Glass.ink)),
|
||||
],
|
||||
);
|
||||
Widget _topBar() {
|
||||
final String mode = (_m.flightMode != null && _m.flightMode!.isNotEmpty) ? _m.flightMode! : 'N';
|
||||
return Row(children: <Widget>[
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.of(context).maybePop(),
|
||||
child: _pill(child: const PVIcon('chevronLeft', size: 16, color: Glass.ink)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
height: 26,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 11),
|
||||
decoration: BoxDecoration(color: Glass.accent, borderRadius: BorderRadius.circular(8)),
|
||||
alignment: Alignment.center,
|
||||
child: Text(mode, style: const TextStyle(fontFamily: PV.fontSans, fontSize: 12, fontWeight: FontWeight.w700, letterSpacing: 0.4, color: Colors.white)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_pill(child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||
const PVIcon('satellite', size: 14, color: Glass.sat),
|
||||
const SizedBox(width: 4),
|
||||
_mono(_m.satellites?.toString() ?? '0'),
|
||||
])),
|
||||
const SizedBox(width: 8),
|
||||
_pill(child: const PVIcon('obstacle', size: 13, color: Glass.sat)),
|
||||
const SizedBox(width: 8),
|
||||
_pill(child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||
const PVIcon('radio', size: 14, color: Glass.ink),
|
||||
const SizedBox(width: 4),
|
||||
_mono('HD'),
|
||||
])),
|
||||
const Spacer(),
|
||||
_pill(child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||
_mono('REC'),
|
||||
const SizedBox(width: 6),
|
||||
Container(width: 7, height: 7, decoration: BoxDecoration(color: _m.isRecording ? Glass.rec : const Color(0x66D64545), shape: BoxShape.circle)),
|
||||
const SizedBox(width: 6),
|
||||
_mono(_fmt(_m.isRecording ? _recSeconds : 0)),
|
||||
])),
|
||||
const SizedBox(width: 8),
|
||||
_pill(child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||
const PVIcon('battery', size: 16, color: Glass.sat),
|
||||
const SizedBox(width: 4),
|
||||
_mono(_m.batteryPercent == null ? '—' : '${_m.batteryPercent}%'),
|
||||
])),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(onTap: _openSettingsMenu, child: _pill(child: const PVIcon('more', size: 16, color: Glass.ink))),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget _leftRail() {
|
||||
return Column(children: <Widget>[
|
||||
_sideBtn('gimbal', active: true, onTap: _openCameraSettings),
|
||||
const SizedBox(height: 10),
|
||||
_sideBtn('sun', onTap: _openCameraSettings),
|
||||
const SizedBox(height: 10),
|
||||
_sideBtn('aperture', onTap: _openCaptureModes),
|
||||
const SizedBox(height: 10),
|
||||
_sideBtn('grid', active: _grid, onTap: () => setState(() => _grid = !_grid)),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Reusable glass pieces ────────────────────────────────────────────────
|
||||
@@ -199,167 +274,168 @@ class _FlightControlPageState extends State<FlightControlPage> {
|
||||
|
||||
Widget _mono(String t) => Text(t, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: Glass.ink));
|
||||
|
||||
Widget _sideBtn(IconData icon, {bool active = false}) {
|
||||
return Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: active ? Glass.accent : Glass.pill,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Glass.hairline),
|
||||
Widget _sideBtn(String icon, {bool active = false, VoidCallback? onTap}) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 42, height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: active ? Glass.accent : Glass.pill,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Glass.hairline),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: PVIcon(icon, size: 20, stroke: 1.8, color: Glass.ink),
|
||||
),
|
||||
child: Icon(icon, size: 20, color: Glass.ink),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _gimbalSlider() {
|
||||
return SizedBox(
|
||||
width: 16,
|
||||
child: Stack(
|
||||
alignment: Alignment.topCenter,
|
||||
children: <Widget>[
|
||||
Container(width: 6, decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(6))),
|
||||
const Align(
|
||||
alignment: Alignment(0, -0.24),
|
||||
child: _Thumb(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return LayoutBuilder(builder: (BuildContext context, BoxConstraints c) {
|
||||
final double h = c.maxHeight;
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onVerticalDragUpdate: (DragUpdateDetails d) => _onGimbalDrag(d.localPosition.dy / h),
|
||||
onTapDown: (TapDownDetails d) => _onGimbalDrag(d.localPosition.dy / h),
|
||||
child: SizedBox(
|
||||
width: 16,
|
||||
child: Stack(children: <Widget>[
|
||||
Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: Container(width: 6, height: h, decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(6))),
|
||||
),
|
||||
Positioned(
|
||||
top: (_gimbalFrac * h - 8).clamp(0.0, h - 16),
|
||||
left: 0,
|
||||
child: const _Thumb(),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _cameraControls() {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: 46,
|
||||
height: 46,
|
||||
return Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||
GestureDetector(
|
||||
onTap: _openCameraSettings,
|
||||
child: Container(
|
||||
width: 46, height: 46,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: const Color(0x99FFFFFF), width: 2),
|
||||
gradient: const LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: <Color>[Color(0xFF2A4E86), Color(0xFF12201A)]),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
// Shutter
|
||||
Container(
|
||||
width: 62,
|
||||
height: 62,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: const Color(0xD9FFFFFF), width: 4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
GestureDetector(
|
||||
onTap: _toggleShutter,
|
||||
child: Container(
|
||||
width: 62, height: 62,
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, border: Border.all(color: const Color(0xD9FFFFFF), width: 4)),
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 26,
|
||||
height: 26,
|
||||
decoration: BoxDecoration(color: Glass.rec, borderRadius: BorderRadius.circular(7)),
|
||||
),
|
||||
child: _m.captureMode == CaptureMode.video && _m.isRecording
|
||||
? Container(width: 24, height: 24, decoration: BoxDecoration(color: Glass.rec, borderRadius: BorderRadius.circular(5)))
|
||||
: Container(width: 46, height: 46, decoration: BoxDecoration(color: _m.captureMode == CaptureMode.video ? Glass.rec : Colors.white, shape: BoxShape.circle)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
// Mode switch
|
||||
Container(
|
||||
padding: const EdgeInsets.all(3),
|
||||
decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(10)),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
_modeBtn(Icons.photo_outlined, 'Photo'),
|
||||
_modeBtn(Icons.videocam_outlined, 'Video'),
|
||||
_modeBtn(Icons.panorama_outlined, 'Pano'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(3),
|
||||
decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(10)),
|
||||
child: Column(children: <Widget>[
|
||||
_modeBtn('image', CaptureMode.photo),
|
||||
_modeBtn('video', CaptureMode.video),
|
||||
_modeBtn('film', CaptureMode.pano),
|
||||
]),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget _modeBtn(IconData icon, String mode) {
|
||||
final bool active = _mode == mode;
|
||||
Widget _modeBtn(String icon, CaptureMode mode) {
|
||||
final bool active = _m.captureMode == mode;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _mode = mode),
|
||||
onTap: () => _setCaptureMode(mode),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 30,
|
||||
width: 40, height: 30,
|
||||
margin: const EdgeInsets.symmetric(vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? Glass.accent : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: Icon(icon, size: 17, color: Glass.ink),
|
||||
decoration: BoxDecoration(color: active ? Glass.accent : Colors.transparent, borderRadius: BorderRadius.circular(7)),
|
||||
alignment: Alignment.center,
|
||||
child: PVIcon(icon, size: 17, stroke: 1.8, color: Glass.ink),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _minimap() {
|
||||
return Container(
|
||||
width: 148,
|
||||
height: 78,
|
||||
decoration: BoxDecoration(
|
||||
color: Glass.pillStrong,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Glass.hairline),
|
||||
),
|
||||
child: Stack(
|
||||
children: <Widget>[
|
||||
final String rth = _m.homeDistance == null ? 'RTH —' : 'RTH ${_m.homeDistance!.toStringAsFixed(0)}m';
|
||||
return GestureDetector(
|
||||
onTap: _openMap,
|
||||
child: Container(
|
||||
width: 148, height: 78,
|
||||
decoration: BoxDecoration(color: Glass.pillStrong, borderRadius: BorderRadius.circular(12), border: Border.all(color: Glass.hairline)),
|
||||
child: Stack(children: <Widget>[
|
||||
const Positioned.fill(child: CustomPaint(painter: _MinimapPainter())),
|
||||
const Positioned(
|
||||
top: 6,
|
||||
left: 8,
|
||||
child: Row(children: <Widget>[
|
||||
Icon(Icons.home_outlined, size: 12, color: Glass.ink),
|
||||
SizedBox(width: 5),
|
||||
Text('RTH 340m', style: TextStyle(fontFamily: PV.fontMono, fontSize: 10, color: Glass.ink)),
|
||||
]),
|
||||
),
|
||||
],
|
||||
Positioned(top: 6, left: 8, child: Row(children: <Widget>[
|
||||
const PVIcon('home', size: 12, color: Glass.ink),
|
||||
const SizedBox(width: 5),
|
||||
Text(rth, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 10, color: Glass.ink)),
|
||||
])),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _telemetry(FlightModel m) {
|
||||
Widget _telemetry() {
|
||||
String f(double? v, {int d = 1}) => v == null ? '—' : v.toStringAsFixed(d);
|
||||
final List<(String, String, String)> fields = <(String, String, String)>[
|
||||
('H', m.altitude == null ? '—' : m.altitude!.toStringAsFixed(1), 'm'),
|
||||
('D', '—', 'm'),
|
||||
('H.S', '—', 'm/s'),
|
||||
('V.S', '—', 'm/s'),
|
||||
('H', f(_m.altitude), 'm'),
|
||||
('D', f(_m.homeDistance, d: 0), 'm'),
|
||||
('H.S', f(_m.horizontalSpeed), 'm/s'),
|
||||
('V.S', f(_m.verticalSpeed), 'm/s'),
|
||||
];
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8),
|
||||
decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(12)),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
for (int i = 0; i < fields.length; i++) ...<Widget>[
|
||||
if (i > 0) const SizedBox(width: 20),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Text(fields[i].$1, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 10, letterSpacing: 0.8, color: Color(0x99EAF0FA))),
|
||||
const SizedBox(height: 2),
|
||||
Text.rich(TextSpan(children: <TextSpan>[
|
||||
TextSpan(text: fields[i].$2, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 15, fontWeight: FontWeight.w700, color: Glass.ink)),
|
||||
TextSpan(text: ' ${fields[i].$3}', style: const TextStyle(fontFamily: PV.fontMono, fontSize: 10, color: Color(0x99EAF0FA))),
|
||||
])),
|
||||
],
|
||||
),
|
||||
],
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||
for (int i = 0; i < fields.length; i++) ...<Widget>[
|
||||
if (i > 0) const SizedBox(width: 20),
|
||||
Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||
Text(fields[i].$1, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 10, letterSpacing: 0.8, color: Color(0x99EAF0FA))),
|
||||
const SizedBox(height: 2),
|
||||
Text.rich(TextSpan(children: <TextSpan>[
|
||||
TextSpan(text: fields[i].$2, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 15, fontWeight: FontWeight.w700, color: Glass.ink)),
|
||||
TextSpan(text: ' ${fields[i].$3}', style: const TextStyle(fontFamily: PV.fontMono, fontSize: 10, color: Color(0x99EAF0FA))),
|
||||
])),
|
||||
]),
|
||||
],
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _rthButton() {
|
||||
return Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: Glass.pillStrong,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: const Color(0x2EFFFFFF)),
|
||||
return GestureDetector(
|
||||
onTap: _rth,
|
||||
child: Container(
|
||||
width: 44, height: 44,
|
||||
decoration: BoxDecoration(color: Glass.pillStrong, shape: BoxShape.circle, border: Border.all(color: const Color(0x2EFFFFFF))),
|
||||
alignment: Alignment.center,
|
||||
child: const PVIcon('rth', size: 20, color: Glass.ink),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _takeoffButton() {
|
||||
final bool flying = _m.isFlying == true;
|
||||
return GestureDetector(
|
||||
onTap: _toggleTakeoff,
|
||||
child: Container(
|
||||
width: 44, height: 44,
|
||||
decoration: BoxDecoration(color: flying ? const Color(0xE6D64545) : Glass.accent, shape: BoxShape.circle, border: Border.all(color: const Color(0x2EFFFFFF))),
|
||||
alignment: Alignment.center,
|
||||
child: PVIcon(flying ? 'home' : 'takeoff', size: 20, color: Colors.white),
|
||||
),
|
||||
child: const Icon(Icons.home_outlined, size: 20, color: Glass.ink),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -369,17 +445,34 @@ class _Thumb extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
width: 16, height: 16,
|
||||
decoration: const BoxDecoration(
|
||||
color: Glass.ink,
|
||||
shape: BoxShape.circle,
|
||||
color: Glass.ink, shape: BoxShape.circle,
|
||||
boxShadow: <BoxShadow>[BoxShadow(color: Color(0x80000000), blurRadius: 3, offset: Offset(0, 1))],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rule-of-thirds grid overlay toggled from the left rail.
|
||||
class _GridPainter extends CustomPainter {
|
||||
const _GridPainter();
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final Paint p = Paint()
|
||||
..color = const Color(0x33FFFFFF)
|
||||
..strokeWidth = 1;
|
||||
for (int i = 1; i < 3; i++) {
|
||||
final double x = size.width * i / 3, y = size.height * i / 3;
|
||||
canvas.drawLine(Offset(x, 0), Offset(x, size.height), p);
|
||||
canvas.drawLine(Offset(0, y), Offset(size.width, y), p);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _GridPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
/// Painted placeholder camera feed: graded sky→ground, perspective grid, haze,
|
||||
/// distant buildings, and a yellow tracked-subject bracket.
|
||||
class _FeedPainter extends CustomPainter {
|
||||
@@ -390,7 +483,6 @@ class _FeedPainter extends CustomPainter {
|
||||
final double w = size.width, h = size.height;
|
||||
final double horizon = h * 0.52;
|
||||
|
||||
// Sky → ground gradient.
|
||||
final Rect full = Offset.zero & size;
|
||||
final Paint sky = Paint()
|
||||
..shader = const LinearGradient(
|
||||
@@ -401,7 +493,6 @@ class _FeedPainter extends CustomPainter {
|
||||
).createShader(full);
|
||||
canvas.drawRect(full, sky);
|
||||
|
||||
// Horizon haze.
|
||||
final Paint haze = Paint()
|
||||
..shader = LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
@@ -410,7 +501,6 @@ class _FeedPainter extends CustomPainter {
|
||||
).createShader(Rect.fromLTWH(0, horizon - 30, w, 60));
|
||||
canvas.drawRect(Rect.fromLTWH(0, horizon - 30, w, 60), haze);
|
||||
|
||||
// Distant buildings just under the horizon.
|
||||
final Paint bld = Paint()..color = const Color(0xE612201A);
|
||||
void building(double x, double y, double bw, double bh) => canvas.drawRect(Rect.fromLTWH(x * w, horizon + y, bw, bh), bld);
|
||||
building(0.10, -40, 46, 40);
|
||||
@@ -418,7 +508,6 @@ class _FeedPainter extends CustomPainter {
|
||||
building(0.74, -46, 54, 46);
|
||||
building(0.83, -34, 34, 34);
|
||||
|
||||
// Perspective ground grid.
|
||||
final Paint grid = Paint()
|
||||
..color = const Color(0x297FE0B0)
|
||||
..strokeWidth = 1;
|
||||
@@ -433,15 +522,13 @@ class _FeedPainter extends CustomPainter {
|
||||
canvas.drawLine(Offset(vx + k * 10, horizon), Offset(bx, h), grid);
|
||||
}
|
||||
|
||||
// Tracked-subject bracket, centered.
|
||||
final Paint subj = Paint()
|
||||
..color = Glass.subject
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2;
|
||||
final double bxw = 118, bxh = 82;
|
||||
const double bxw = 118, bxh = 82;
|
||||
final Rect box = Rect.fromCenter(center: Offset(vx, horizon + 8), width: bxw, height: bxh);
|
||||
const double c = 14;
|
||||
// Four corner brackets.
|
||||
canvas.drawPath(Path()..moveTo(box.left + c, box.top)..lineTo(box.left, box.top)..lineTo(box.left, box.top + c), subj);
|
||||
canvas.drawPath(Path()..moveTo(box.right - c, box.top)..lineTo(box.right, box.top)..lineTo(box.right, box.top + c), subj);
|
||||
canvas.drawPath(Path()..moveTo(box.left + c, box.bottom)..lineTo(box.left, box.bottom)..lineTo(box.left, box.bottom - c), subj);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'simple_list_page.dart';
|
||||
|
||||
/// Flight-log history. Per-session logging/persistence is a later add, so this
|
||||
/// shows the empty state for now.
|
||||
class FlightLogsPage extends StatelessWidget {
|
||||
const FlightLogsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const SimpleListPage(
|
||||
title: 'Flight logs',
|
||||
emptyIcon: 'gauge',
|
||||
emptyText: 'No flights recorded yet.\nYour flight history will appear here.',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../theme.dart';
|
||||
import 'pv_icons.dart';
|
||||
|
||||
/// Shared chrome for the landscape flight overlays (Capture modes, Camera
|
||||
/// settings, Settings menu). Locks landscape/immersive like the HUD, paints the
|
||||
/// dark app ground, and provides a title row + close button.
|
||||
class FlightOverlayScaffold extends StatefulWidget {
|
||||
const FlightOverlayScaffold({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.body,
|
||||
this.leading,
|
||||
this.trailing,
|
||||
this.padded = true,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final Widget body;
|
||||
final Widget? leading;
|
||||
final Widget? trailing;
|
||||
final bool padded;
|
||||
|
||||
@override
|
||||
State<FlightOverlayScaffold> createState() => _FlightOverlayScaffoldState();
|
||||
}
|
||||
|
||||
class _FlightOverlayScaffoldState extends State<FlightOverlayScaffold> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
SystemChrome.setPreferredOrientations(<DeviceOrientation>[
|
||||
DeviceOrientation.landscapeLeft,
|
||||
DeviceOrientation.landscapeRight,
|
||||
]);
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF0A1120),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: widget.padded ? const EdgeInsets.fromLTRB(16, 12, 16, 12) : EdgeInsets.zero,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: widget.padded ? EdgeInsets.zero : const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: Row(children: <Widget>[
|
||||
if (widget.leading != null) ...<Widget>[widget.leading!, const SizedBox(width: 10)],
|
||||
Text(widget.title,
|
||||
style: const TextStyle(fontFamily: PV.fontSans, fontSize: 16, fontWeight: FontWeight.w700, color: Glass.ink)),
|
||||
const Spacer(),
|
||||
if (widget.trailing != null) ...<Widget>[widget.trailing!, const SizedBox(width: 8)],
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.of(context).maybePop(),
|
||||
child: Container(
|
||||
width: 30, height: 30,
|
||||
decoration: BoxDecoration(color: const Color(0x1FFFFFFF), borderRadius: BorderRadius.circular(9)),
|
||||
alignment: Alignment.center,
|
||||
child: const PVIcon('close', size: 17, color: Glass.ink),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Expanded(child: widget.body),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,292 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../flight_model.dart';
|
||||
import '../theme.dart';
|
||||
import '../uploader.dart';
|
||||
|
||||
/// Portrait launch screen — mirrors the ui_kit/fly "Go Fly · launch" mockup:
|
||||
/// brand header, aircraft connection card, big GO FLY, and a 2×2 tile grid.
|
||||
class GoFlyPage extends StatelessWidget {
|
||||
const GoFlyPage({
|
||||
super.key,
|
||||
required this.model,
|
||||
required this.onGoFly,
|
||||
required this.onOpenAlbum,
|
||||
required this.onSettings,
|
||||
required this.onTile,
|
||||
});
|
||||
|
||||
final FlightModel model;
|
||||
final VoidCallback onGoFly;
|
||||
final VoidCallback onOpenAlbum;
|
||||
final VoidCallback onSettings;
|
||||
final void Function(String tile) onTile;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final PVScheme s = PVScheme.of(context);
|
||||
return Scaffold(
|
||||
backgroundColor: s.bgApp,
|
||||
body: SafeArea(
|
||||
child: AnimatedBuilder(
|
||||
animation: model,
|
||||
builder: (BuildContext context, _) {
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
_header(s),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: _connectionCard(s),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
// No bottom gap here: the design pins GO FLY directly above the
|
||||
// tiles' 18px top pad, which centers the card 4px lower to match.
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 0),
|
||||
child: _goFlyButton(s),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 28),
|
||||
child: _tiles(s),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _header(PVScheme s) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 10, 20, 0),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
const PvBrandMark(size: 22),
|
||||
const SizedBox(width: 8),
|
||||
Text.rich(
|
||||
TextSpan(children: <TextSpan>[
|
||||
TextSpan(
|
||||
text: 'Pilot',
|
||||
style: TextStyle(fontFamily: PV.fontSans, fontSize: 19, fontWeight: FontWeight.w500, letterSpacing: -0.38, color: s.textSecondary),
|
||||
),
|
||||
TextSpan(
|
||||
text: 'Vault',
|
||||
style: TextStyle(fontFamily: PV.fontSans, fontSize: 19, fontWeight: FontWeight.w700, letterSpacing: -0.38, color: s.textPrimary),
|
||||
),
|
||||
TextSpan(
|
||||
text: ' Fly',
|
||||
style: TextStyle(fontFamily: PV.fontSans, fontSize: 19, fontWeight: FontWeight.w500, letterSpacing: -0.38, color: s.accent),
|
||||
),
|
||||
]),
|
||||
),
|
||||
const Spacer(),
|
||||
GestureDetector(
|
||||
onTap: onSettings,
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(color: s.surfaceInset, shape: BoxShape.circle),
|
||||
child: Icon(Icons.person_outline, size: 17, color: s.textSecondary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _connectionCard(PVScheme s) {
|
||||
final bool connected = model.connected;
|
||||
final Color dot = connected ? s.success : s.textTertiary;
|
||||
final Color statusFg = connected ? s.successFg : s.textTertiary;
|
||||
final String statusText = connected ? 'CONNECTED' : 'DISCONNECTED';
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: s.surface,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(color: s.border),
|
||||
boxShadow: s.shadowSm,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min, // size to content; Center handles vertical placement
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Container(width: 7, height: 7, decoration: BoxDecoration(color: dot, shape: BoxShape.circle)),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
statusText,
|
||||
style: TextStyle(fontFamily: PV.fontMono, fontSize: 11, letterSpacing: 1.1, fontWeight: FontWeight.w700, color: statusFg),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(color: s.accentSoft, borderRadius: BorderRadius.circular(14)),
|
||||
child: Icon(Icons.flight, size: 28, color: s.accent),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
connected ? (model.model ?? 'Aircraft') : 'No aircraft',
|
||||
style: TextStyle(fontFamily: PV.fontSans, fontSize: 18, fontWeight: FontWeight.w700, color: s.textPrimary),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'MSDK · ${model.sdkVersion}',
|
||||
style: TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: s.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
_chip(s, Icons.battery_full, model.batteryPercent == null ? '—' : '${model.batteryPercent}%'),
|
||||
const SizedBox(width: 8),
|
||||
_chip(s, Icons.satellite_alt, model.satellites == null ? '— sats' : '${model.satellites} sats'),
|
||||
const SizedBox(width: 8),
|
||||
_chip(s, Icons.link, _linkLabel(model.upload, connected)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _linkLabel(UploadStatus u, bool connected) {
|
||||
switch (u) {
|
||||
case UploadStatus.connected:
|
||||
return 'Streaming';
|
||||
case UploadStatus.connecting:
|
||||
return 'Linking…';
|
||||
case UploadStatus.error:
|
||||
return 'Retrying';
|
||||
case UploadStatus.disabled:
|
||||
return connected ? 'Linked' : 'Off';
|
||||
}
|
||||
}
|
||||
|
||||
Widget _chip(PVScheme s, IconData icon, String value) {
|
||||
return Expanded(
|
||||
child: Container(
|
||||
height: 34,
|
||||
decoration: BoxDecoration(color: s.surfaceInset, borderRadius: BorderRadius.circular(9)),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Icon(icon, size: 14, color: s.textTertiary),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
value,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontFamily: PV.fontMono, fontSize: 11.5, color: s.textSecondary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _goFlyButton(PVScheme s) {
|
||||
return SizedBox(
|
||||
height: 58,
|
||||
child: FilledButton(
|
||||
onPressed: onGoFly,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: s.accent,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: const <Widget>[
|
||||
Icon(Icons.play_arrow_rounded, size: 22),
|
||||
SizedBox(width: 10),
|
||||
Text('GO FLY', style: TextStyle(fontFamily: PV.fontSans, fontSize: 18, fontWeight: FontWeight.w700, letterSpacing: 0.4)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tiles(PVScheme s) {
|
||||
const List<(IconData, String)> tiles = <(IconData, String)>[
|
||||
(Icons.photo_library_outlined, 'Album'),
|
||||
(Icons.school_outlined, 'Academy'),
|
||||
(Icons.route_outlined, 'Routes'),
|
||||
(Icons.speed, 'Flight logs'),
|
||||
];
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
Row(children: <Widget>[
|
||||
Expanded(child: _tile(s, tiles[0])),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: _tile(s, tiles[1])),
|
||||
]),
|
||||
const SizedBox(height: 12),
|
||||
Row(children: <Widget>[
|
||||
Expanded(child: _tile(s, tiles[2])),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: _tile(s, tiles[3])),
|
||||
]),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tile(PVScheme s, (IconData, String) t) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
onTap: () => t.$2 == 'Album' ? onOpenAlbum() : onTile(t.$2),
|
||||
child: Container(
|
||||
height: 56,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: s.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: s.border),
|
||||
boxShadow: s.shadowXs,
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(color: s.accentSoft, borderRadius: BorderRadius.circular(9)),
|
||||
child: Icon(t.$1, size: 17, color: s.accentSoftFg),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Flexible(
|
||||
child: Text(
|
||||
t.$2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontFamily: PV.fontSans, fontSize: 14, fontWeight: FontWeight.w600, color: s.textPrimary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../flight_model.dart';
|
||||
import '../theme.dart';
|
||||
import '../uploader.dart';
|
||||
import 'pv_icons.dart';
|
||||
|
||||
/// Portrait launch screen — mirrors the v2 "Home" mockups (connected /
|
||||
/// disconnected). Brand header + avatar, aircraft card, GO FLY, and a 2×2 tile
|
||||
/// grid (Album / Academy / Routes / Flight logs).
|
||||
class HomeScreen extends StatelessWidget {
|
||||
const HomeScreen({
|
||||
super.key,
|
||||
required this.model,
|
||||
required this.onGoFly,
|
||||
required this.onConnect,
|
||||
required this.onSettings,
|
||||
required this.onTile,
|
||||
});
|
||||
|
||||
final FlightModel model;
|
||||
final VoidCallback onGoFly;
|
||||
final VoidCallback onConnect;
|
||||
final VoidCallback onSettings;
|
||||
final void Function(String tile) onTile;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final PVScheme s = PVScheme.of(context);
|
||||
return Scaffold(
|
||||
backgroundColor: s.bgApp,
|
||||
body: SafeArea(
|
||||
child: AnimatedBuilder(
|
||||
animation: model,
|
||||
builder: (BuildContext context, _) {
|
||||
final bool connected = model.connected;
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
_brandRow(s),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: connected ? _connectedCard(s) : _disconnectedCard(s),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (connected)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 0),
|
||||
child: _goFly(s),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 28),
|
||||
child: _tiles(s),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _brandRow(PVScheme s) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 10, 20, 0),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
const PvBrandMark(size: 22),
|
||||
const SizedBox(width: 8),
|
||||
Text.rich(TextSpan(children: <TextSpan>[
|
||||
TextSpan(text: 'Pilot', style: _brand(s.textSecondary, FontWeight.w500)),
|
||||
TextSpan(text: 'Vault', style: _brand(s.textPrimary, FontWeight.w700)),
|
||||
TextSpan(text: ' Fly', style: _brand(s.accent, FontWeight.w500)),
|
||||
])),
|
||||
const Spacer(),
|
||||
GestureDetector(
|
||||
onTap: onSettings,
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(color: s.surfaceInset, shape: BoxShape.circle),
|
||||
alignment: Alignment.center,
|
||||
child: PVIcon('user', size: 17, color: s.textSecondary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TextStyle _brand(Color c, FontWeight w) => TextStyle(
|
||||
fontFamily: PV.fontSans, fontSize: 19, fontWeight: w, letterSpacing: -0.38, color: c);
|
||||
|
||||
Widget _connectedCard(PVScheme s) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: s.surface,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(color: s.border),
|
||||
boxShadow: s.shadowSm,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(children: <Widget>[
|
||||
Container(width: 7, height: 7, decoration: BoxDecoration(color: s.success, shape: BoxShape.circle)),
|
||||
const SizedBox(width: 6),
|
||||
Text('CONNECTED',
|
||||
style: TextStyle(fontFamily: PV.fontMono, fontSize: 11, letterSpacing: 1.1, fontWeight: FontWeight.w700, color: s.successFg)),
|
||||
]),
|
||||
const SizedBox(height: 14),
|
||||
Row(children: <Widget>[
|
||||
Container(
|
||||
width: 56, height: 56,
|
||||
decoration: BoxDecoration(color: s.accentSoft, borderRadius: BorderRadius.circular(14)),
|
||||
alignment: Alignment.center,
|
||||
child: PVIcon('drone', size: 30, stroke: 1.6, color: s.accent),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(model.model ?? 'Aircraft',
|
||||
style: TextStyle(fontFamily: PV.fontSans, fontSize: 18, fontWeight: FontWeight.w700, color: s.textPrimary)),
|
||||
const SizedBox(height: 2),
|
||||
Text(model.firmwareVersion == null ? 'MSDK · ${model.sdkVersion}' : 'FW ${model.firmwareVersion}',
|
||||
style: TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: s.textSecondary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 14),
|
||||
Row(children: <Widget>[
|
||||
_chip(s, 'battery', model.batteryPercent == null ? '—' : '${model.batteryPercent}%'),
|
||||
const SizedBox(width: 8),
|
||||
_chip(s, 'sdcard', model.sdRemainingMB == null ? '— GB' : '${(model.sdRemainingMB! / 1024).toStringAsFixed(0)} GB'),
|
||||
const SizedBox(width: 8),
|
||||
_chip(s, 'link', _linkLabel(model.upload)),
|
||||
]),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _disconnectedCard(PVScheme s) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: s.surface,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(color: s.borderStrong, style: BorderStyle.solid),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: 64, height: 64,
|
||||
decoration: BoxDecoration(color: s.surfaceInset, borderRadius: BorderRadius.circular(18)),
|
||||
alignment: Alignment.center,
|
||||
child: PVIcon('drone', size: 34, stroke: 1.5, color: s.textTertiary),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text('No aircraft connected',
|
||||
style: TextStyle(fontFamily: PV.fontSans, fontSize: 17, fontWeight: FontWeight.w700, color: s.textPrimary)),
|
||||
const SizedBox(height: 4),
|
||||
Text('Power on your aircraft and remote controller, then connect to begin.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontFamily: PV.fontSans, fontSize: 13, height: 1.5, color: s.textSecondary)),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
child: FilledButton(
|
||||
onPressed: onConnect,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: s.accent, foregroundColor: Colors.white, elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
),
|
||||
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[
|
||||
const PVIcon('link', size: 18, color: Colors.white),
|
||||
const SizedBox(width: 8),
|
||||
const Text('Connect aircraft', style: TextStyle(fontFamily: PV.fontSans, fontSize: 15, fontWeight: FontWeight.w700)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _linkLabel(UploadStatus u) {
|
||||
switch (u) {
|
||||
case UploadStatus.connected:
|
||||
return 'Streaming';
|
||||
case UploadStatus.connecting:
|
||||
return 'Linking…';
|
||||
case UploadStatus.error:
|
||||
return 'Retrying';
|
||||
case UploadStatus.disabled:
|
||||
return 'RC linked';
|
||||
}
|
||||
}
|
||||
|
||||
Widget _chip(PVScheme s, String icon, String value) {
|
||||
return Expanded(
|
||||
child: Container(
|
||||
height: 34,
|
||||
decoration: BoxDecoration(color: s.surfaceInset, borderRadius: BorderRadius.circular(9)),
|
||||
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[
|
||||
PVIcon(icon, size: 14, color: s.textTertiary),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(child: Text(value, overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontFamily: PV.fontMono, fontSize: 11.5, color: s.textSecondary))),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _goFly(PVScheme s) {
|
||||
return SizedBox(
|
||||
height: 58,
|
||||
child: FilledButton(
|
||||
onPressed: onGoFly,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: s.accent, foregroundColor: Colors.white, elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
),
|
||||
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: const <Widget>[
|
||||
PVIcon('play2', size: 20, color: Colors.white, fill: true),
|
||||
SizedBox(width: 10),
|
||||
Text('GO FLY', style: TextStyle(fontFamily: PV.fontSans, fontSize: 18, fontWeight: FontWeight.w700, letterSpacing: 0.4)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tiles(PVScheme s) {
|
||||
const List<(String, String)> tiles = <(String, String)>[
|
||||
('album', 'Album'), ('academy', 'Academy'), ('route', 'Routes'), ('gauge', 'Flight logs'),
|
||||
];
|
||||
return Column(children: <Widget>[
|
||||
Row(children: <Widget>[
|
||||
Expanded(child: _tile(s, tiles[0])), const SizedBox(width: 12), Expanded(child: _tile(s, tiles[1])),
|
||||
]),
|
||||
const SizedBox(height: 12),
|
||||
Row(children: <Widget>[
|
||||
Expanded(child: _tile(s, tiles[2])), const SizedBox(width: 12), Expanded(child: _tile(s, tiles[3])),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget _tile(PVScheme s, (String, String) t) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
onTap: () => onTile(t.$2),
|
||||
child: Container(
|
||||
height: 56,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: s.surface, borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: s.border), boxShadow: s.shadowXs,
|
||||
),
|
||||
child: Row(children: <Widget>[
|
||||
Container(
|
||||
width: 32, height: 32,
|
||||
decoration: BoxDecoration(color: s.accentSoft, borderRadius: BorderRadius.circular(9)),
|
||||
alignment: Alignment.center,
|
||||
child: PVIcon(t.$1, size: 17, color: s.accentSoftFg),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Flexible(child: Text(t.$2, overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontFamily: PV.fontSans, fontSize: 14, fontWeight: FontWeight.w600, color: s.textPrimary))),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../dji_service.dart';
|
||||
import '../flight_model.dart';
|
||||
import '../theme.dart';
|
||||
import 'pv_icons.dart';
|
||||
|
||||
/// Map & waypoints — mirrors the v2 "Map & waypoints" mockup. Real OpenStreetMap
|
||||
/// tiles (no API key); tapping the map in Pin mode drops real-GPS waypoints, and
|
||||
/// "Run route" uploads + starts a Waypoint mission via [DjiService].
|
||||
class MapPage extends StatefulWidget {
|
||||
const MapPage({super.key, required this.model, required this.dji});
|
||||
|
||||
final FlightModel model;
|
||||
final DjiService dji;
|
||||
|
||||
@override
|
||||
State<MapPage> createState() => _MapPageState();
|
||||
}
|
||||
|
||||
class _MapPageState extends State<MapPage> {
|
||||
final MapController _map = MapController();
|
||||
final List<LatLng> _waypoints = <LatLng>[];
|
||||
bool _pinMode = true;
|
||||
double _altitude = 50;
|
||||
double _speed = 8;
|
||||
|
||||
DjiService get _dji => widget.dji;
|
||||
FlightModel get _m => widget.model;
|
||||
|
||||
LatLng get _fallback => const LatLng(37.7749, -122.4194);
|
||||
LatLng? get _drone => (_m.latitude != null && _m.longitude != null) ? LatLng(_m.latitude!, _m.longitude!) : null;
|
||||
LatLng? get _home => (_m.homeLatitude != null && _m.homeLongitude != null) ? LatLng(_m.homeLatitude!, _m.homeLongitude!) : null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
SystemChrome.setPreferredOrientations(<DeviceOrientation>[DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]);
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||||
}
|
||||
|
||||
void _snack(String msg) {
|
||||
ScaffoldMessenger.of(context)
|
||||
..clearSnackBars()
|
||||
..showSnackBar(SnackBar(content: Text(msg)));
|
||||
}
|
||||
|
||||
void _onTap(TapPosition pos, LatLng latlng) {
|
||||
if (!_pinMode) return;
|
||||
setState(() => _waypoints.add(latlng));
|
||||
}
|
||||
|
||||
Future<void> _runRoute() async {
|
||||
if (_waypoints.length < 2) {
|
||||
_snack('Drop at least 2 waypoints first');
|
||||
return;
|
||||
}
|
||||
final List<Map<String, dynamic>> points = _waypoints
|
||||
.map((LatLng p) => <String, dynamic>{'lat': p.latitude, 'lon': p.longitude, 'altitude': _altitude})
|
||||
.toList();
|
||||
try {
|
||||
await _dji.uploadWaypointMission(points, speed: _speed, finishAction: 'GO_HOME');
|
||||
await _dji.startWaypointMission();
|
||||
if (mounted) _snack('Route running — ${points.length} waypoints');
|
||||
} catch (e) {
|
||||
if (mounted) _snack('Route: ${e is PlatformException ? (e.message ?? e.code) : e}');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final LatLng center = _drone ?? _home ?? _fallback;
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF0E1726),
|
||||
body: Stack(children: <Widget>[
|
||||
FlutterMap(
|
||||
mapController: _map,
|
||||
options: MapOptions(initialCenter: center, initialZoom: 16, onTap: _onTap),
|
||||
children: <Widget>[
|
||||
TileLayer(
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: 'com.dji.flutter.dji_msdk_sample',
|
||||
),
|
||||
if (_waypoints.length >= 2)
|
||||
PolylineLayer<Object>(polylines: <Polyline<Object>>[
|
||||
Polyline<Object>(points: _waypoints, strokeWidth: 3, color: const Color(0xFF5B93F5)),
|
||||
]),
|
||||
MarkerLayer(markers: _markers()),
|
||||
],
|
||||
),
|
||||
SafeArea(
|
||||
child: Stack(children: <Widget>[
|
||||
Positioned(top: 12, left: 14, child: _back()),
|
||||
Positioned(left: 14, top: 58, child: _toolRail()),
|
||||
Positioned(right: 16, top: 58, child: _routePanel()),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
List<Marker> _markers() {
|
||||
final List<Marker> m = <Marker>[];
|
||||
for (int i = 0; i < _waypoints.length; i++) {
|
||||
m.add(Marker(
|
||||
point: _waypoints[i],
|
||||
width: 26, height: 26,
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(color: Color(0xFF3D7BF0), shape: BoxShape.circle),
|
||||
alignment: Alignment.center,
|
||||
child: Text('${i + 1}', style: const TextStyle(fontFamily: PV.fontMono, fontSize: 12, fontWeight: FontWeight.w700, color: Colors.white)),
|
||||
),
|
||||
));
|
||||
}
|
||||
if (_home != null) {
|
||||
m.add(Marker(point: _home!, width: 24, height: 24, child: const _Dot(Color(0xFF7FE0B0), 'home')));
|
||||
}
|
||||
if (_drone != null) {
|
||||
m.add(Marker(point: _drone!, width: 24, height: 24, child: const _Dot(Color(0xFFF4C542), 'drone')));
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
Widget _back() {
|
||||
return GestureDetector(
|
||||
onTap: () => Navigator.of(context).maybePop(),
|
||||
child: Container(
|
||||
height: 30, width: 30,
|
||||
decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(8)),
|
||||
alignment: Alignment.center,
|
||||
child: const PVIcon('chevronLeft', size: 18, color: Glass.ink),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _toolRail() {
|
||||
return Column(children: <Widget>[
|
||||
_tool('pin', _pinMode, () => setState(() => _pinMode = true)),
|
||||
const SizedBox(height: 10),
|
||||
_tool('route', false, () => setState(_waypoints.clear)),
|
||||
const SizedBox(height: 10),
|
||||
_tool('home', false, () { if (_home != null) _map.move(_home!, 16); }),
|
||||
const SizedBox(height: 10),
|
||||
_tool('crosshair', false, () { if (_drone != null) _map.move(_drone!, 16); }),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget _tool(String icon, bool active, VoidCallback onTap) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 42, height: 42,
|
||||
decoration: BoxDecoration(color: active ? Glass.accent : Glass.pill, borderRadius: BorderRadius.circular(12), border: Border.all(color: Glass.hairline)),
|
||||
alignment: Alignment.center,
|
||||
child: PVIcon(icon, size: 20, stroke: 1.8, color: Glass.ink),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _routePanel() {
|
||||
return Container(
|
||||
width: 200,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(color: Glass.pillStrong, borderRadius: BorderRadius.circular(14), border: Border.all(color: Glass.hairline)),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||
const Text('Waypoint route', style: TextStyle(fontFamily: PV.fontSans, fontSize: 13, fontWeight: FontWeight.w700, color: Glass.ink)),
|
||||
const SizedBox(height: 8),
|
||||
if (_waypoints.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text('Tap the map to drop waypoints', style: TextStyle(fontFamily: PV.fontMono, fontSize: 10.5, color: Color(0x99EAF0FA))),
|
||||
)
|
||||
else
|
||||
...List<Widget>.generate(_waypoints.length, (int i) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 5),
|
||||
child: Row(children: <Widget>[
|
||||
Container(
|
||||
width: 22, height: 22,
|
||||
decoration: const BoxDecoration(color: Color(0xE63D7BF0), shape: BoxShape.circle),
|
||||
alignment: Alignment.center,
|
||||
child: Text('${i + 1}', style: const TextStyle(fontFamily: PV.fontMono, fontSize: 11, fontWeight: FontWeight.w700, color: Colors.white)),
|
||||
),
|
||||
const SizedBox(width: 9),
|
||||
Text('Alt ${_altitude.toInt()}m · ${_speed.toInt()} m/s', style: const TextStyle(fontFamily: PV.fontMono, fontSize: 10.5, color: Color(0xCCEAF0FA))),
|
||||
]),
|
||||
)),
|
||||
const SizedBox(height: 8),
|
||||
_slider('Alt', _altitude, 20, 120, (double v) => setState(() => _altitude = v)),
|
||||
_slider('Speed', _speed, 2, 15, (double v) => setState(() => _speed = v)),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 36,
|
||||
child: FilledButton(
|
||||
onPressed: _runRoute,
|
||||
style: FilledButton.styleFrom(backgroundColor: const Color(0xFF3D7BF0), foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10))),
|
||||
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: const <Widget>[
|
||||
PVIcon('play2', size: 15, color: Colors.white, fill: true),
|
||||
SizedBox(width: 6),
|
||||
Text('Run route', style: TextStyle(fontFamily: PV.fontSans, fontSize: 13, fontWeight: FontWeight.w700)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _slider(String label, double value, double min, double max, ValueChanged<double> onChanged) {
|
||||
return Row(children: <Widget>[
|
||||
SizedBox(width: 38, child: Text(label, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 10, color: Color(0x99EAF0FA)))),
|
||||
Expanded(
|
||||
child: SliderTheme(
|
||||
data: SliderThemeData(trackHeight: 2, thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6)),
|
||||
child: Slider(value: value, min: min, max: max, activeColor: const Color(0xFF5B93F5), inactiveColor: const Color(0x33FFFFFF), onChanged: onChanged),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 26, child: Text('${value.toInt()}', textAlign: TextAlign.right, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 10, color: Glass.ink))),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
class _Dot extends StatelessWidget {
|
||||
const _Dot(this.color, this.icon);
|
||||
final Color color;
|
||||
final String icon;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2)),
|
||||
alignment: Alignment.center,
|
||||
child: PVIcon(icon, size: 12, color: const Color(0xFF05060A)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../dji_service.dart';
|
||||
import '../flight_model.dart';
|
||||
import '../pb_auth.dart';
|
||||
import '../theme.dart';
|
||||
import 'flight_logs_page.dart';
|
||||
import 'pv_icons.dart';
|
||||
import 'routes_page.dart';
|
||||
|
||||
/// Profile — mirrors the v2 "Profile" mockup. Shows the PilotVault identity and
|
||||
/// (optionally) the linked DJI account, headline stats, and library shortcuts.
|
||||
class ProfilePage extends StatefulWidget {
|
||||
const ProfilePage({
|
||||
super.key,
|
||||
required this.model,
|
||||
required this.dji,
|
||||
required this.onAppSettings,
|
||||
required this.onSignIn,
|
||||
});
|
||||
|
||||
final FlightModel model;
|
||||
final DjiService dji;
|
||||
final VoidCallback onAppSettings;
|
||||
final VoidCallback onSignIn;
|
||||
|
||||
@override
|
||||
State<ProfilePage> createState() => _ProfilePageState();
|
||||
}
|
||||
|
||||
class _ProfilePageState extends State<ProfilePage> {
|
||||
StreamSubscription<AuthStatus>? _authSub;
|
||||
|
||||
DjiService get _dji => widget.dji;
|
||||
FlightModel get _m => widget.model;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_authSub = auth.status.listen((_) {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
widget.dji.refreshDjiAccountState().catchError((_) {});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_authSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _snack(String msg) {
|
||||
ScaffoldMessenger.of(context)
|
||||
..clearSnackBars()
|
||||
..showSnackBar(SnackBar(content: Text(msg)));
|
||||
}
|
||||
|
||||
Future<void> _djiLogin() async {
|
||||
try {
|
||||
await _dji.djiLogin();
|
||||
_snack('DJI account linked');
|
||||
} catch (e) {
|
||||
_snack('DJI login: ${e is PlatformException ? (e.message ?? e.code) : e}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _djiLogout() async {
|
||||
try {
|
||||
await _dji.djiLogout();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
bool get _djiLinked => _m.djiAccountState == 'AUTHORIZED';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final PVScheme s = PVScheme.of(context);
|
||||
final bool signedIn = auth.isAuthed;
|
||||
return Scaffold(
|
||||
backgroundColor: s.bgApp,
|
||||
body: SafeArea(
|
||||
child: AnimatedBuilder(
|
||||
animation: _m,
|
||||
builder: (BuildContext context, _) => ListView(
|
||||
children: <Widget>[
|
||||
_headerBar(s),
|
||||
_identity(s, signedIn),
|
||||
_stats(s),
|
||||
_djiCard(s),
|
||||
const SizedBox(height: 6),
|
||||
..._rows(s),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _headerBar(PVScheme s) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 6, 12, 0),
|
||||
child: Row(children: <Widget>[
|
||||
IconButton(onPressed: () => Navigator.of(context).maybePop(), icon: PVIcon('chevronLeft', size: 24, color: s.textSecondary)),
|
||||
const Spacer(),
|
||||
IconButton(onPressed: widget.onAppSettings, icon: PVIcon('settings', size: 20, color: s.textSecondary)),
|
||||
]),
|
||||
);
|
||||
|
||||
Widget _identity(PVScheme s, bool signedIn) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 6, 20, 16),
|
||||
child: Row(children: <Widget>[
|
||||
Container(
|
||||
width: 60, height: 60,
|
||||
decoration: BoxDecoration(color: s.accentSoft, shape: BoxShape.circle),
|
||||
alignment: Alignment.center,
|
||||
child: PVIcon('user', size: 30, color: s.accent),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
|
||||
Text(signedIn ? (auth.userEmail.isEmpty ? 'PilotVault pilot' : auth.userEmail) : 'Guest pilot',
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontFamily: PV.fontSans, fontSize: 18, fontWeight: FontWeight.w700, letterSpacing: -0.2, color: s.textPrimary)),
|
||||
const SizedBox(height: 2),
|
||||
Text(signedIn ? 'Verified pilot' : 'Not signed in',
|
||||
style: TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: s.textSecondary)),
|
||||
]),
|
||||
),
|
||||
if (!signedIn)
|
||||
FilledButton(onPressed: widget.onSignIn, child: const Text('Sign in')),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _stats(PVScheme s) {
|
||||
const List<(String, String)> stats = <(String, String)>[('Flights', '—'), ('Distance', '—'), ('Flight time', '—')];
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(20, 0, 20, 18),
|
||||
decoration: BoxDecoration(color: s.surface, borderRadius: BorderRadius.circular(16), border: Border.all(color: s.border), boxShadow: s.shadowXs),
|
||||
child: Row(children: <Widget>[
|
||||
for (int i = 0; i < stats.length; i++)
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(border: Border(left: i == 0 ? BorderSide.none : BorderSide(color: s.border))),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
child: Column(children: <Widget>[
|
||||
Text(stats[i].$2, style: TextStyle(fontFamily: PV.fontMono, fontSize: 19, fontWeight: FontWeight.w700, color: s.textPrimary)),
|
||||
const SizedBox(height: 2),
|
||||
Text(stats[i].$1, style: TextStyle(fontFamily: PV.fontSans, fontSize: 11.5, color: s.textSecondary)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _djiCard(PVScheme s) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(20, 0, 20, 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(color: s.surface, borderRadius: BorderRadius.circular(14), border: Border.all(color: s.border)),
|
||||
child: Row(children: <Widget>[
|
||||
Container(
|
||||
width: 34, height: 34,
|
||||
decoration: BoxDecoration(color: s.surfaceInset, borderRadius: BorderRadius.circular(9)),
|
||||
alignment: Alignment.center,
|
||||
child: PVIcon('drone', size: 18, color: s.textSecondary),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
|
||||
Text('DJI account', style: TextStyle(fontFamily: PV.fontSans, fontSize: 14, fontWeight: FontWeight.w600, color: s.textPrimary)),
|
||||
Text(_djiLinked ? (_m.djiAccountUser ?? 'Linked') : 'Optional · unlocks NFZ & sync',
|
||||
style: TextStyle(fontFamily: PV.fontMono, fontSize: 11, color: s.textSecondary)),
|
||||
])),
|
||||
_djiLinked
|
||||
? TextButton(onPressed: _djiLogout, child: const Text('Unlink'))
|
||||
: FilledButton(onPressed: _djiLogin, child: const Text('Link')),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _rows(PVScheme s) {
|
||||
final List<(String, String, VoidCallback)> rows = <(String, String, VoidCallback)>[
|
||||
('gauge', 'Flight records', () => _push(const FlightLogsPage())),
|
||||
('route', 'My routes', () => _push(const RoutesPage())),
|
||||
('download', 'Downloads', () => _snack('Downloaded media is saved to the app files folder')),
|
||||
('shield', 'Find my drone', _findDrone),
|
||||
('settings', 'App settings', widget.onAppSettings),
|
||||
];
|
||||
return <Widget>[
|
||||
for (int i = 0; i < rows.length; i++)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: InkWell(
|
||||
onTap: rows[i].$3,
|
||||
child: Container(
|
||||
height: 52,
|
||||
decoration: BoxDecoration(border: Border(bottom: i < rows.length - 1 ? BorderSide(color: s.border) : BorderSide.none)),
|
||||
child: Row(children: <Widget>[
|
||||
Container(
|
||||
width: 34, height: 34,
|
||||
decoration: BoxDecoration(color: s.surfaceInset, borderRadius: BorderRadius.circular(9)),
|
||||
alignment: Alignment.center,
|
||||
child: PVIcon(rows[i].$1, size: 17, color: s.textSecondary),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(child: Text(rows[i].$2, style: TextStyle(fontFamily: PV.fontSans, fontSize: 14.5, fontWeight: FontWeight.w500, color: s.textPrimary))),
|
||||
PVIcon('chevronRight', size: 18, color: s.textTertiary),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
void _push(Widget page) => Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => page));
|
||||
|
||||
void _findDrone() {
|
||||
if (_m.latitude != null && _m.longitude != null) {
|
||||
_snack('Aircraft at ${_m.latitude!.toStringAsFixed(5)}, ${_m.longitude!.toStringAsFixed(5)}');
|
||||
} else {
|
||||
_snack('No aircraft GPS fix available');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
|
||||
/// The v2 UI-kit line-icon library.
|
||||
///
|
||||
/// Ported verbatim from the `P = {…}` SVG-path map in
|
||||
/// `Design/PilotVault Project logo/ui_kits/fly/index-v2.html`. Every icon is a
|
||||
/// single `d` attribute drawn on a 24×24 grid with round caps/joins, matching
|
||||
/// the kit's `<Icon>` component. Rendered through [PVIcon].
|
||||
class PvPaths {
|
||||
PvPaths._();
|
||||
|
||||
static const Map<String, String> d = <String, String>{
|
||||
// nav / system
|
||||
'chevronLeft': 'M15 6l-6 6 6 6',
|
||||
'chevronRight': 'M9 6l6 6-6 6',
|
||||
'chevronDown': 'M6 9l6 6 6-6',
|
||||
'close': 'M6 6l12 12M18 6L6 18',
|
||||
'more': 'M5 12h.01M12 12h.01M19 12h.01',
|
||||
'home': 'M3 10.5 12 3l9 7.5M5 9.5V21h14V9.5',
|
||||
'settings':
|
||||
'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z',
|
||||
// flight status
|
||||
'satellite':
|
||||
'M13 7 9 3 5 7l4 4M17 11l4 4-4 4-4-4M8.5 8.5 3 14l7 7 5.5-5.5M16 16l1-1M18 3a3 3 0 0 1 3 3',
|
||||
'battery': 'M3 8h14a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1zM22 11v2',
|
||||
'radio':
|
||||
'M4.9 19.1a10 10 0 0 1 0-14.2M7.8 16.2a6 6 0 0 1 0-8.4M16.2 7.8a6 6 0 0 1 0 8.4M19.1 4.9a10 10 0 0 1 0 14.2M12 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2z',
|
||||
'rc': 'M7 8h10a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-6a3 3 0 0 1 3-3zM8 4v4M16 4v4M9 14h2M8 13v2M15 13h.01M17 15h.01',
|
||||
'obstacle': 'M12 3 2 20h20L12 3zM12 10v4M12 17h.01',
|
||||
'compass': 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM15.5 8.5l-2 5-5 2 2-5 5-2z',
|
||||
'gimbal': 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM12 8v8M8 12h8',
|
||||
'crosshair': 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM22 12h-4M6 12H2M12 6V2M12 22v-4',
|
||||
'drone':
|
||||
'M6 6l3.5 3.5M18 6l-3.5 3.5M6 18l3.5-3.5M18 18l-3.5-3.5M6 6a2.5 2.5 0 1 0-.01-.01M18 6a2.5 2.5 0 1 0-.01 0M6 18a2.5 2.5 0 1 0-.01 0M18 18a2.5 2.5 0 1 0-.01 0M9.5 9.5h5v5h-5z',
|
||||
'takeoff': 'M12 20V8m0 0-4 4m4-4 4 4M4 4h16',
|
||||
'rth': 'M12 3a9 9 0 1 0 9 9M12 3v6l4 2M20 4l-3 1 1-3',
|
||||
'map': 'M9 4 3 6v14l6-2 6 2 6-2V4l-6 2-6-2zM9 4v14M15 6v14',
|
||||
'pin': 'M12 21s7-6.4 7-12A7 7 0 0 0 5 9c0 5.6 7 12 7 12zM12 11a2 2 0 1 0 0-4 2 2 0 0 0 0 4z',
|
||||
'route': 'M6 19a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM18 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM6 13V9a4 4 0 0 1 4-4h4',
|
||||
// camera
|
||||
'video': 'M23 7l-7 5 7 5V7zM3 5h11a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2z',
|
||||
'image': 'M3 3h18v18H3zM8.5 10a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zM21 15l-5-5L5 21',
|
||||
'aperture':
|
||||
'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM14.3 8 20 8M12.6 4.5 9.8 9.3M7 6 10 11.2M9.7 16 4 16M11.4 19.5l2.8-4.8M17 18l-3-5.2',
|
||||
'iso': 'M4 7v10M8 7c-2 0-2 5 0 5s2 5 0 5M14 7a3 5 0 0 1 0 10 3 5 0 0 1 0-10M19 7v10',
|
||||
'shutter': 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 12l6-3M12 12l-3 6M12 12l-3-6M12 12l6 3',
|
||||
'ev': 'M4 6h10M4 12h7M4 18h10M17 9v6M20 12h-6',
|
||||
'wb': 'M12 17a5 5 0 1 0 0-10 5 5 0 0 0 0 10zM12 2v3M12 19v3M5 12H2M22 12h-3',
|
||||
'timer': 'M12 22a8 8 0 1 0 0-16 8 8 0 0 0 0 16zM12 10v4l2 2M9 2h6',
|
||||
'burst': 'M7 8h10v10H7zM5 6h10M5 10v6',
|
||||
'film': 'M3 3h18v18H3zM7 3v18M17 3v18M3 8h4M3 16h4M17 8h4M17 16h4',
|
||||
'pano': 'M2 7l20-3v16L2 17V7zM2 7v10',
|
||||
'grid': 'M3 3h18v18H3zM3 9h18M3 15h18M9 3v18M15 3v18',
|
||||
'histogram': 'M4 20V10M9 20V4M14 20v-8M19 20v-6',
|
||||
'focus':
|
||||
'M3 8V5a2 2 0 0 1 2-2h3M16 3h3a2 2 0 0 1 2 2v3M21 16v3a2 2 0 0 1-2 2h-3M8 21H5a2 2 0 0 1-2-2v-3M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z',
|
||||
'slowmo': 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM10 9v6l5-3z',
|
||||
'hyperlapse': 'M12 8v4l3 2M4 12a8 8 0 1 1 3 6M4 20v-4h4',
|
||||
'master': 'M12 3l2.6 5.3 5.9.9-4.3 4.1 1 5.8-5.2-2.7-5.2 2.7 1-5.8L3.5 9.2l5.9-.9L12 3z',
|
||||
'hdr': 'M4 8v8M4 12h4M8 8v8M12 8v8h2a3 3 0 0 0 0-6M12 12h2M18 8v8M18 12h2l1 4',
|
||||
// quickshots
|
||||
'dronie': 'M12 4a3 3 0 1 0 0 6 3 3 0 0 0 0-6zM4 20l8-8 8 8',
|
||||
'rocket': 'M12 3c4 2 5 7 5 10l-2 3H9l-2-3c0-3 1-8 5-10zM12 9v.01M8 17l-3 4M16 17l3 4',
|
||||
'circle': 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z',
|
||||
'helix': 'M6 3c12 3-12 15 0 18M6 6c9 2.2-9 11.6 0 13.5',
|
||||
'boomerang': 'M4 20C4 10 10 4 20 4c0 10-6 16-16 16zM4 20l6-6',
|
||||
'asteroid': 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM3 12h18M8 4c-2 2.5-2 13.5 0 16M16 4c2 2.5 2 13.5 0 16',
|
||||
// home / library / misc
|
||||
'academy': 'M22 10 12 5 2 10l10 5 10-5zM6 12v5c0 1 2.7 2.5 6 2.5s6-1.5 6-2.5v-5',
|
||||
'album': 'M3 3h18v18H3zM8.5 10a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zM21 15l-5-5L5 21',
|
||||
'gauge': 'M12 15a3 3 0 1 0 0-6M3.5 18a9 9 0 1 1 17 0',
|
||||
'user': 'M20 21a8 8 0 1 0-16 0M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z',
|
||||
'sdcard': 'M18 2H8L4 6v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2zM10 6v2M13 6v2M16 6v2',
|
||||
'link': 'M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1',
|
||||
'play': 'M8 5v14l11-7z',
|
||||
'pause': 'M8 5v14M16 5v14',
|
||||
'sun': 'M12 17a5 5 0 1 0 0-10 5 5 0 0 0 0 10zM12 1v2M12 21v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M1 12h2M21 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4',
|
||||
'bell': 'M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9M13.73 21a2 2 0 0 1-3.46 0',
|
||||
'share': 'M4 12v8a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-8M16 6l-4-4-4 4M12 2v13',
|
||||
'search': 'M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16zM21 21l-4.3-4.3',
|
||||
'check': 'M20 6 9 17l-5-5',
|
||||
'shield': 'M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6l8-3z',
|
||||
'download': 'M12 3v12m0 0-4-4m4 4 4-4M4 21h16',
|
||||
'play2': 'M8 5v14l11-7z',
|
||||
};
|
||||
}
|
||||
|
||||
/// A single v2 line icon. Strokes one of [PvPaths.d] on a 24×24 grid, round
|
||||
/// caps/joins, matching the design's `<Icon>` component.
|
||||
class PVIcon extends StatelessWidget {
|
||||
const PVIcon(
|
||||
this.name, {
|
||||
super.key,
|
||||
this.size = 20,
|
||||
this.color = Colors.white,
|
||||
this.stroke = 2,
|
||||
this.fill = false,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final double size;
|
||||
final Color color;
|
||||
final double stroke;
|
||||
|
||||
/// When true (e.g. play triangles), the path is filled with [color] and drawn
|
||||
/// with no stroke — matches the kit's `fill="#fff" stroke="#fff"` usage.
|
||||
final bool fill;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final String d = PvPaths.d[name] ?? '';
|
||||
final String hex = _hex(color);
|
||||
final String fillAttr = fill ? hex : 'none';
|
||||
final String strokeAttr = fill ? 'none' : hex;
|
||||
final String svg =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="$size" height="$size" '
|
||||
'viewBox="0 0 24 24" fill="$fillAttr" stroke="$strokeAttr" '
|
||||
'stroke-width="$stroke" stroke-linecap="round" stroke-linejoin="round">'
|
||||
'<path d="$d"/></svg>';
|
||||
return SvgPicture.string(svg, width: size, height: size);
|
||||
}
|
||||
|
||||
static String _hex(Color c) {
|
||||
final int r = (c.r * 255).round();
|
||||
final int g = (c.g * 255).round();
|
||||
final int b = (c.b * 255).round();
|
||||
String two(int v) => v.toRadixString(16).padLeft(2, '0');
|
||||
return '#${two(r)}${two(g)}${two(b)}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'simple_list_page.dart';
|
||||
|
||||
/// Saved waypoint routes. Routes are built live on the Map screen; a persisted
|
||||
/// route library is a later add, so this shows the empty state for now.
|
||||
class RoutesPage extends StatelessWidget {
|
||||
const RoutesPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const SimpleListPage(
|
||||
title: 'My routes',
|
||||
emptyIcon: 'route',
|
||||
emptyText: 'No saved routes yet.\nCreate one on the map during flight.',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../dji_service.dart';
|
||||
import '../flight_model.dart';
|
||||
import '../theme.dart';
|
||||
import 'pv_icons.dart';
|
||||
|
||||
/// Aircraft settings — mirrors the v2 "Settings" mockup. Left tab column
|
||||
/// (Safety / Control / Camera / Transmission / About); Safety & Control rows
|
||||
/// drive real flight-controller setters. Values are held on the model and
|
||||
/// updated as the user changes them (sensible defaults until read back).
|
||||
class SettingsMenuPage extends StatefulWidget {
|
||||
const SettingsMenuPage({super.key, required this.model, required this.dji});
|
||||
|
||||
final FlightModel model;
|
||||
final DjiService dji;
|
||||
|
||||
@override
|
||||
State<SettingsMenuPage> createState() => _SettingsMenuPageState();
|
||||
}
|
||||
|
||||
class _SettingsMenuPageState extends State<SettingsMenuPage> {
|
||||
DjiService get _dji => widget.dji;
|
||||
FlightModel get _m => widget.model;
|
||||
|
||||
int _tab = 0;
|
||||
static const List<(String, String)> _tabs = <(String, String)>[
|
||||
('shield', 'Safety'),
|
||||
('rc', 'Control'),
|
||||
('aperture', 'Camera'),
|
||||
('radio', 'Transmission'),
|
||||
('drone', 'About'),
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Seed defaults where nothing has been read back yet.
|
||||
_m.maxHeight ??= 120;
|
||||
_m.maxRadius ??= 500;
|
||||
_m.maxRadiusEnabled ??= false;
|
||||
_m.rthHeight ??= 100;
|
||||
_m.obstacleAvoidance ??= 'On';
|
||||
_m.noviceMode ??= false;
|
||||
}
|
||||
|
||||
void _snack(String msg) {
|
||||
ScaffoldMessenger.of(context)
|
||||
..clearSnackBars()
|
||||
..showSnackBar(SnackBar(content: Text(msg)));
|
||||
}
|
||||
|
||||
Future<void> _run(String label, Future<void> Function() action) async {
|
||||
try {
|
||||
await action();
|
||||
} catch (e) {
|
||||
_snack('$label: ${e is PlatformException ? (e.message ?? e.code) : e}');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF0A1120),
|
||||
body: SafeArea(
|
||||
child: AnimatedBuilder(
|
||||
animation: _m,
|
||||
builder: (BuildContext context, _) => Stack(children: <Widget>[
|
||||
Row(children: <Widget>[
|
||||
_tabColumn(),
|
||||
Expanded(child: _rightPanel()),
|
||||
]),
|
||||
Positioned(
|
||||
top: 6, right: 8,
|
||||
child: GestureDetector(
|
||||
onTap: () => Navigator.of(context).maybePop(),
|
||||
child: Container(
|
||||
width: 30, height: 30,
|
||||
decoration: BoxDecoration(color: const Color(0x1FFFFFFF), borderRadius: BorderRadius.circular(9)),
|
||||
alignment: Alignment.center,
|
||||
child: const PVIcon('close', size: 17, color: Glass.ink),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tabColumn() {
|
||||
return Container(
|
||||
width: 160,
|
||||
padding: const EdgeInsets.fromLTRB(12, 18, 12, 12),
|
||||
color: const Color(0x8C081020),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 6, bottom: 10),
|
||||
child: Row(children: const <Widget>[
|
||||
PVIcon('settings', size: 18, color: Glass.ink),
|
||||
SizedBox(width: 8),
|
||||
Text('Settings', style: TextStyle(fontFamily: PV.fontSans, fontSize: 15, fontWeight: FontWeight.w700, color: Glass.ink)),
|
||||
]),
|
||||
),
|
||||
for (int i = 0; i < _tabs.length; i++) _tabBtn(i),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tabBtn(int i) {
|
||||
final bool active = i == _tab;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _tab = i),
|
||||
child: Container(
|
||||
height: 38,
|
||||
margin: const EdgeInsets.only(bottom: 6),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(color: active ? Glass.accent : Colors.transparent, borderRadius: BorderRadius.circular(10)),
|
||||
child: Row(children: <Widget>[
|
||||
PVIcon(_tabs[i].$1, size: 17, stroke: 1.7, color: Glass.ink),
|
||||
const SizedBox(width: 10),
|
||||
Text(_tabs[i].$2, style: const TextStyle(fontFamily: PV.fontSans, fontSize: 13, fontWeight: FontWeight.w600, color: Glass.ink)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _rightPanel() {
|
||||
final (String eyebrow, List<Widget> rows) = switch (_tab) {
|
||||
0 => ('Flight Safety', _safetyRows()),
|
||||
1 => ('Flight Control', _controlRows()),
|
||||
2 => ('Camera', _cameraRows()),
|
||||
3 => ('Transmission', _infoRows(<(String, String)>[('Channel Mode', 'Auto'), ('Frequency', '2.4 / 5.8 GHz'), ('Signal', 'HD 1080p')])),
|
||||
_ => ('About', _infoRows(<(String, String)>[('Model', _m.model ?? '—'), ('Firmware', _m.firmwareVersion ?? '—'), ('MSDK', _m.sdkVersion)])),
|
||||
};
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(22, 20, 22, 20),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(eyebrow.toUpperCase(),
|
||||
style: const TextStyle(fontFamily: PV.fontMono, fontSize: 10, letterSpacing: 1.4, color: Color(0x8CEAF0FA))),
|
||||
),
|
||||
Expanded(child: ListView(children: rows)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Rows ────────────────────────────────────────────────────────────────
|
||||
List<Widget> _safetyRows() => <Widget>[
|
||||
_stepperRow('Max Altitude', '${_m.maxHeight} m', () => _editNumber('Max Altitude', _m.maxHeight ?? 120, 20, 500, 10, (int v) {
|
||||
setState(() => _m.maxHeight = v);
|
||||
_run('Max altitude', () => _dji.setMaxFlightHeight(v));
|
||||
})),
|
||||
_toggleRow('Max Distance', _m.maxRadiusEnabled ?? false, (bool on) {
|
||||
setState(() => _m.maxRadiusEnabled = on);
|
||||
_run('Max distance', () => _dji.setMaxRadiusEnabled(on));
|
||||
}),
|
||||
_stepperRow('Return-to-Home Alt.', '${_m.rthHeight} m', () => _editNumber('RTH Altitude', _m.rthHeight ?? 100, 20, 500, 10, (int v) {
|
||||
setState(() => _m.rthHeight = v);
|
||||
_run('RTH altitude', () => _dji.setGoHomeHeight(v));
|
||||
})),
|
||||
_cycleRow('Obstacle Avoidance', _m.obstacleAvoidance ?? 'On', <String>['On', 'Off'], (String v) {
|
||||
setState(() => _m.obstacleAvoidance = v);
|
||||
_run('Obstacle avoidance', () => _dji.setObstacleAvoidance(v == 'On'));
|
||||
}),
|
||||
_toggleRow('Beginner Mode', _m.noviceMode ?? false, (bool on) {
|
||||
setState(() => _m.noviceMode = on);
|
||||
_run('Beginner mode', () => _dji.setNoviceMode(on));
|
||||
}),
|
||||
_toggleRow('AR Home Point', _m.arHomePoint, (bool on) => setState(() => _m.arHomePoint = on)),
|
||||
];
|
||||
|
||||
List<Widget> _controlRows() => <Widget>[
|
||||
_stepperRow('Max Distance', '${_m.maxRadius} m', () => _editNumber('Max Distance', _m.maxRadius ?? 500, 50, 5000, 50, (int v) {
|
||||
setState(() => _m.maxRadius = v);
|
||||
_run('Max distance', () => _dji.setMaxFlightRadius(v));
|
||||
})),
|
||||
_infoRow('Set Home to Current', 'Tap', onTap: () => _run('Set home', _dji.setHomeToCurrent)),
|
||||
_infoRow('Cancel Return-to-Home', 'Tap', onTap: () => _run('Cancel RTH', _dji.cancelGoHome)),
|
||||
];
|
||||
|
||||
List<Widget> _cameraRows() => <Widget>[
|
||||
_infoRow('Exposure', _m.exposureProgram == ExposureProgram.pro ? 'Pro' : 'Auto'),
|
||||
_infoRow('ISO', _m.iso ?? '—'),
|
||||
_infoRow('Shutter', _m.shutter ?? '—'),
|
||||
_infoRow('White Balance', _m.whiteBalance ?? 'Auto'),
|
||||
];
|
||||
|
||||
List<Widget> _infoRows(List<(String, String)> items) =>
|
||||
<Widget>[for (final (String l, String v) in items) _infoRow(l, v)];
|
||||
|
||||
Widget _rowShell({required Widget child}) => Container(
|
||||
height: 44,
|
||||
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Color(0x12FFFFFF)))),
|
||||
child: child,
|
||||
);
|
||||
|
||||
Widget _label(String l) => Text(l, style: const TextStyle(fontFamily: PV.fontSans, fontSize: 13.5, color: Glass.ink));
|
||||
|
||||
Widget _stepperRow(String label, String value, VoidCallback onTap) => _rowShell(
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Row(children: <Widget>[
|
||||
Expanded(child: _label(label)),
|
||||
Text(value, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 13, fontWeight: FontWeight.w700, color: Color(0xFF8FB4F6))),
|
||||
const SizedBox(width: 4),
|
||||
const PVIcon('chevronRight', size: 15, color: Color(0xFF8FB4F6)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _infoRow(String label, String value, {VoidCallback? onTap}) => _rowShell(
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Row(children: <Widget>[
|
||||
Expanded(child: _label(label)),
|
||||
Text(value, style: TextStyle(fontFamily: PV.fontMono, fontSize: 13, color: onTap == null ? const Color(0x99EAF0FA) : const Color(0xFF8FB4F6))),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _cycleRow(String label, String value, List<String> options, ValueChanged<String> onChanged) => _rowShell(
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
final int i = (options.indexOf(value) + 1) % options.length;
|
||||
onChanged(options[i]);
|
||||
},
|
||||
child: Row(children: <Widget>[
|
||||
Expanded(child: _label(label)),
|
||||
Text(value, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 13, fontWeight: FontWeight.w700, color: Color(0xFF8FB4F6))),
|
||||
const SizedBox(width: 4),
|
||||
const PVIcon('chevronRight', size: 15, color: Color(0xFF8FB4F6)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _toggleRow(String label, bool value, ValueChanged<bool> onChanged) => _rowShell(
|
||||
child: Row(children: <Widget>[
|
||||
Expanded(child: _label(label)),
|
||||
GestureDetector(
|
||||
onTap: () => onChanged(!value),
|
||||
child: Container(
|
||||
width: 40, height: 22,
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(color: value ? const Color(0xFF3D7BF0) : const Color(0x26FFFFFF), borderRadius: BorderRadius.circular(999)),
|
||||
alignment: value ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: Container(width: 18, height: 18, decoration: const BoxDecoration(color: Colors.white, shape: BoxShape.circle)),
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
Future<void> _editNumber(String title, int initial, int min, int max, int step, ValueChanged<int> onSet) async {
|
||||
int value = initial;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) => StatefulBuilder(
|
||||
builder: (BuildContext ctx, StateSetter set) => AlertDialog(
|
||||
backgroundColor: const Color(0xFF10203F),
|
||||
title: Text(title, style: const TextStyle(color: Glass.ink, fontFamily: PV.fontSans, fontWeight: FontWeight.w700)),
|
||||
content: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: <Widget>[
|
||||
IconButton(onPressed: () => set(() => value = (value - step).clamp(min, max)), icon: const Icon(Icons.remove, color: Glass.ink)),
|
||||
Text('$value m', style: const TextStyle(fontFamily: PV.fontMono, fontSize: 22, fontWeight: FontWeight.w700, color: Glass.ink)),
|
||||
IconButton(onPressed: () => set(() => value = (value + step).clamp(min, max)), icon: const Icon(Icons.add, color: Glass.ink)),
|
||||
]),
|
||||
actions: <Widget>[
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancel')),
|
||||
FilledButton(onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
onSet(value);
|
||||
}, child: const Text('Set')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme.dart';
|
||||
import 'pv_icons.dart';
|
||||
|
||||
/// A plain portrait scaffold with a back title and a centered empty state —
|
||||
/// shared chrome for the Routes and Flight-logs library screens (which have no
|
||||
/// persisted content yet).
|
||||
class SimpleListPage extends StatelessWidget {
|
||||
const SimpleListPage({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.emptyIcon,
|
||||
required this.emptyText,
|
||||
this.child,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String emptyIcon;
|
||||
final String emptyText;
|
||||
final Widget? child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final PVScheme s = PVScheme.of(context);
|
||||
return Scaffold(
|
||||
backgroundColor: s.bgApp,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 6, 20, 12),
|
||||
child: Row(children: <Widget>[
|
||||
IconButton(onPressed: () => Navigator.of(context).maybePop(), icon: PVIcon('chevronLeft', size: 24, color: s.textSecondary)),
|
||||
Text(title, style: TextStyle(fontFamily: PV.fontSans, fontSize: 20, fontWeight: FontWeight.w700, letterSpacing: -0.4, color: s.textPrimary)),
|
||||
]),
|
||||
),
|
||||
Expanded(
|
||||
child: child ??
|
||||
Center(
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||
PVIcon(emptyIcon, size: 40, stroke: 1.4, color: s.textTertiary),
|
||||
const SizedBox(height: 12),
|
||||
Text(emptyText, textAlign: TextAlign.center, style: TextStyle(fontFamily: PV.fontSans, fontSize: 14, color: s.textSecondary)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user