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:
tajniak81
2026-07-15 16:51:08 +02:00
co-authored by Claude Opus 4.8
parent 3903428964
commit a4a2709456
29 changed files with 3766 additions and 696 deletions
+125 -13
View File
@@ -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');
}
+123 -8
View File
@@ -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
View File
@@ -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,
);
}
+104
View File
@@ -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
View File
@@ -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}');
}
}
}
+236
View File
@@ -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;
}
+158
View File
@@ -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))),
]),
),
);
}
}
+339 -252
View File
@@ -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);
+16
View File
@@ -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),
],
),
),
),
);
}
}
-292
View File
@@ -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),
),
),
],
),
),
),
);
}
}
+285
View File
@@ -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))),
]),
),
),
);
}
}
+236
View File
@@ -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)),
);
}
}
+228
View File
@@ -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');
}
}
}
+131
View File
@@ -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)}';
}
}
+16
View File
@@ -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.',
);
}
+279
View File
@@ -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')),
],
),
),
);
}
}
+54
View File
@@ -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)),
]),
),
),
],
),
),
);
}
}