Retire the classic flight screen and stop the start-up location grant being dropped
MainActivity now makes the app's only start-up permission request and tells Dart when it finishes; the phone-location stream waits for that instead of asking in parallel, which made Android drop the location grant. The P3 simulator run passed, so the pre-P2 FlightControlPage, the pages only it used and its Settings toggle are gone. The app smoke test checks the home screen again instead of the login gate removed in July. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
63acacced7
commit
2db557a3b4
11 files changed
+50
-1088
No files matched your search
@@ -198,6 +198,15 @@ class DjiSdkBridge(
|
||||
mainHandler.post { eventSink?.success(event) }
|
||||
}
|
||||
|
||||
/**
|
||||
* MainActivity's start-up permission request finished. Dart must not ask for
|
||||
* location itself while that dialog is up (Android allows one request at a
|
||||
* time and drops the other's grant), so it waits for this event instead.
|
||||
*/
|
||||
fun onPermissionsResult(locationGranted: Boolean) {
|
||||
emit(mapOf("type" to "permissions", "location" to locationGranted))
|
||||
}
|
||||
|
||||
// ── DJI SDK registration & product lifecycle ─────────────────────────────────
|
||||
|
||||
private fun registerApp() {
|
||||
|
||||
@@ -31,7 +31,9 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
/**
|
||||
* The DJI SDK needs these dangerous permissions granted before it can connect
|
||||
* to / communicate with a product. We request them up front from the host
|
||||
* Activity so the Flutter UI can stay focused on the SDK flow.
|
||||
* Activity so the Flutter UI can stay focused on the SDK flow. This is the
|
||||
* app's only start-up permission request: Dart waits for the result (see
|
||||
* [DjiSdkBridge.onPermissionsResult]) rather than asking in parallel.
|
||||
*/
|
||||
private fun requestDjiPermissions() {
|
||||
val missing = REQUIRED_PERMISSIONS.filter {
|
||||
@@ -42,6 +44,17 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
if (requestCode != PERMISSION_REQUEST_CODE) return
|
||||
val location = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED ||
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
bridge?.onPermissionsResult(location)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PERMISSION_REQUEST_CODE = 12321
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ enum LongPressAction {
|
||||
|
||||
/// Device-local switches for the flight screen, persisted across launches.
|
||||
abstract final class FlyPrefs {
|
||||
static const String _classicKey = 'fly.classicHud';
|
||||
static const String _perfKey = 'fly.perfOverlay';
|
||||
static const String _gridKey = 'fly.grid';
|
||||
static const String _centerKey = 'fly.centerPoint';
|
||||
@@ -52,10 +51,6 @@ abstract final class FlyPrefs {
|
||||
static const String _lensCalibratedKey = 'fly.lensCalibrated';
|
||||
static const String _tutorialsKey = 'fly.modeTutorials';
|
||||
|
||||
/// Open the pre-P2 `FlightControlPage` instead of the HUD v2 — kept
|
||||
/// reachable until P3's safety flows pass their simulator run (plan P2/P3).
|
||||
static final ValueNotifier<bool> classicHud = ValueNotifier<bool>(false);
|
||||
|
||||
/// Flutter's performance overlay on the flight screen, for the P2 "no jank at
|
||||
/// 10 Hz" check.
|
||||
static final ValueNotifier<bool> perfOverlay = ValueNotifier<bool>(false);
|
||||
@@ -114,7 +109,6 @@ abstract final class FlyPrefs {
|
||||
static Future<void> load() async {
|
||||
try {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
classicHud.value = _prefs!.getBool(_classicKey) ?? false;
|
||||
perfOverlay.value = _prefs!.getBool(_perfKey) ?? false;
|
||||
grid.value = _byName(GridStyle.values, _prefs!.getString(_gridKey)) ?? GridStyle.off;
|
||||
centerPoint.value = _prefs!.getBool(_centerKey) ?? true;
|
||||
@@ -139,11 +133,6 @@ abstract final class FlyPrefs {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> setClassicHud(bool on) async {
|
||||
classicHud.value = on;
|
||||
await _prefs?.setBool(_classicKey, on);
|
||||
}
|
||||
|
||||
static Future<void> setPerfOverlay(bool on) async {
|
||||
perfOverlay.value = on;
|
||||
await _prefs?.setBool(_perfKey, on);
|
||||
|
||||
+17
-14
@@ -19,7 +19,6 @@ 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/flight_logs_page.dart';
|
||||
import 'ui/fly/fly_screen.dart';
|
||||
import 'ui/home_page.dart';
|
||||
@@ -108,16 +107,19 @@ class _HomePageState extends State<HomePage> {
|
||||
/// server as a telemetry field — a location fallback for the Web App's auto
|
||||
/// bounding box when the drone has no fix. Best-effort: silently gives up if
|
||||
/// location services or permission are unavailable.
|
||||
///
|
||||
/// It never asks for permission: MainActivity asks for every start-up
|
||||
/// permission in one request, and a second request in parallel makes Android
|
||||
/// drop the location grant. A `permissions` event retries once that is done.
|
||||
Future<void> _startPhoneLocation() async {
|
||||
if (_phoneLocSub != null) return;
|
||||
try {
|
||||
if (!await Geolocator.isLocationServiceEnabled()) return;
|
||||
LocationPermission perm = await Geolocator.checkPermission();
|
||||
if (perm == LocationPermission.denied) {
|
||||
perm = await Geolocator.requestPermission();
|
||||
}
|
||||
final LocationPermission perm = await Geolocator.checkPermission();
|
||||
if (perm == LocationPermission.denied || perm == LocationPermission.deniedForever) {
|
||||
return;
|
||||
}
|
||||
if (_phoneLocSub != null) return;
|
||||
_phoneLocSub = Geolocator.getPositionStream(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.low, // country-level is all the bbox needs
|
||||
@@ -161,6 +163,9 @@ class _HomePageState extends State<HomePage> {
|
||||
// Telemetry, camera, diagnostics and mission state go to the sub-models.
|
||||
if (_telemetry.apply(event)) return;
|
||||
switch (event['type'] as String?) {
|
||||
case 'permissions':
|
||||
if (event['location'] == true) _startPhoneLocation();
|
||||
return;
|
||||
case 'registration':
|
||||
switch (event['state'] as String?) {
|
||||
case 'registering':
|
||||
@@ -380,15 +385,13 @@ class _HomePageState extends State<HomePage> {
|
||||
|
||||
void _goFly() {
|
||||
Navigator.of(context).push(MaterialPageRoute<void>(
|
||||
builder: (_) => FlyPrefs.classicHud.value
|
||||
? FlightControlPage(model: _model, dji: _dji)
|
||||
: FlyScreen(
|
||||
model: _model,
|
||||
dji: _dji,
|
||||
onOpenFlightLogs: () => Navigator.of(context).push(MaterialPageRoute<void>(
|
||||
builder: (_) => FlightLogsPage(log: _flightLog, locator: _locator, model: _model),
|
||||
)),
|
||||
),
|
||||
builder: (_) => FlyScreen(
|
||||
model: _model,
|
||||
dji: _dji,
|
||||
onOpenFlightLogs: () => Navigator.of(context).push(MaterialPageRoute<void>(
|
||||
builder: (_) => FlightLogsPage(log: _flightLog, locator: _locator, model: _model),
|
||||
)),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,573 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../dji_service.dart';
|
||||
import '../flight_model.dart';
|
||||
import '../fly/hud_features.dart';
|
||||
import '../fly/product_profile.dart';
|
||||
import '../theme.dart';
|
||||
import 'camera_settings_page.dart';
|
||||
import 'capture_modes_page.dart';
|
||||
import 'dji_video_view.dart';
|
||||
import 'fly/panels/settings/settings_sheet.dart';
|
||||
import 'map_page.dart';
|
||||
import 'pv_icons.dart';
|
||||
|
||||
/// 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].
|
||||
///
|
||||
/// Retired by the HUD v2 (`fly/fly_screen.dart`, plan P2); still reachable via
|
||||
/// Settings → About → Classic flight screen until the P3 safety flows land.
|
||||
class FlightControlPage extends StatefulWidget {
|
||||
const FlightControlPage({super.key, required this.model, required this.dji});
|
||||
|
||||
final FlightModel model;
|
||||
final DjiService dji;
|
||||
|
||||
@override
|
||||
State<FlightControlPage> createState() => _FlightControlPageState();
|
||||
}
|
||||
|
||||
class _FlightControlPageState extends State<FlightControlPage> {
|
||||
Timer? _recTimer;
|
||||
int _recSeconds = 0;
|
||||
// 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() {
|
||||
super.initState();
|
||||
SystemChrome.setPreferredOrientations(<DeviceOrientation>[
|
||||
DeviceOrientation.landscapeLeft,
|
||||
DeviceOrientation.landscapeRight,
|
||||
]);
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_recTimer?.cancel();
|
||||
SystemChrome.setPreferredOrientations(<DeviceOrientation>[DeviceOrientation.portraitUp]);
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/// Fallback pitch range (+30° … −90°) until the profile reports the gimbal's.
|
||||
static const AxisRange _defaultPitch = AxisRange(-90, 30);
|
||||
|
||||
void _onGimbalDrag(double frac) {
|
||||
setState(() => _gimbalFrac = frac.clamp(0.0, 1.0));
|
||||
final AxisRange r = _m.profile.gimbal?.pitch ?? _defaultPitch;
|
||||
final double pitch = r.max - _gimbalFrac * (r.max - r.min); // max (top) … min (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() {
|
||||
showFlySettings(context, model: _m, dji: _dji, onError: (String label, Object e) => _snack('$label failed: $e'));
|
||||
}
|
||||
|
||||
void _openMap() {
|
||||
Navigator.of(context).push(MaterialPageRoute<void>(
|
||||
builder: (_) => MapPage(model: _m, dji: _dji),
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF0A1120),
|
||||
body: AnimatedBuilder(
|
||||
animation: _m,
|
||||
builder: (BuildContext context, _) {
|
||||
return ProductProfileScope(
|
||||
profile: _m.profile,
|
||||
child: Stack(
|
||||
children: <Widget>[
|
||||
Positioned.fill(
|
||||
child: _m.connected
|
||||
? const DjiVideoView()
|
||||
: const CustomPaint(painter: _FeedPainter()),
|
||||
),
|
||||
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: Requires(HudFeatures.gimbalPitch, child: _gimbalSlider())),
|
||||
Positioned(right: 16, top: 0, bottom: 0, child: Center(child: Requires(HudFeatures.cameraControls, child: _cameraControls()))),
|
||||
Positioned(left: 14, bottom: 12, child: _minimap()),
|
||||
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()),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Top bar ──────────────────────────────────────────────────────────────
|
||||
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),
|
||||
Requires(HudFeatures.visionIndicator, child: Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: _pill(child: const PVIcon('obstacle', size: 13, color: Glass.sat)),
|
||||
)),
|
||||
_pill(child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||
const PVIcon('radio', size: 14, color: Glass.ink),
|
||||
const SizedBox(width: 4),
|
||||
Requires(HudFeatures.wifiSignal, whenDisconnected: false, fallback: _mono('HD'), child: _mono('Wi-Fi')),
|
||||
])),
|
||||
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 ────────────────────────────────────────────────
|
||||
Widget _pill({required Widget child}) {
|
||||
return Container(
|
||||
height: 26,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(8)),
|
||||
alignment: Alignment.center,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _mono(String t) => Text(t, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: Glass.ink));
|
||||
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _gimbalSlider() {
|
||||
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>[
|
||||
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),
|
||||
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: _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),
|
||||
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(String icon, CaptureMode mode) {
|
||||
final bool active = _m.captureMode == mode;
|
||||
return GestureDetector(
|
||||
onTap: () => _setCaptureMode(mode),
|
||||
child: Container(
|
||||
width: 40, height: 30,
|
||||
margin: const EdgeInsets.symmetric(vertical: 1),
|
||||
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() {
|
||||
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())),
|
||||
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() {
|
||||
String f(double? v, {int d = 1}) => v == null ? '—' : v.toStringAsFixed(d);
|
||||
final List<(String, String, String)> fields = <(String, String, String)>[
|
||||
('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))),
|
||||
])),
|
||||
]),
|
||||
],
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _rthButton() {
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Thumb extends StatelessWidget {
|
||||
const _Thumb();
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 16, height: 16,
|
||||
decoration: const BoxDecoration(
|
||||
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 {
|
||||
const _FeedPainter();
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final double w = size.width, h = size.height;
|
||||
final double horizon = h * 0.52;
|
||||
|
||||
final Rect full = Offset.zero & size;
|
||||
final Paint sky = Paint()
|
||||
..shader = const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: <Color>[Color(0xFF2A4E86), Color(0xFF3E6199), Color(0xFF1C2A1E), Color(0xFF0E1710)],
|
||||
stops: <double>[0.0, 0.51, 0.54, 1.0],
|
||||
).createShader(full);
|
||||
canvas.drawRect(full, sky);
|
||||
|
||||
final Paint haze = Paint()
|
||||
..shader = LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: <Color>[const Color(0x808FB4D8), const Color(0x008FB4D8)],
|
||||
).createShader(Rect.fromLTWH(0, horizon - 30, w, 60));
|
||||
canvas.drawRect(Rect.fromLTWH(0, horizon - 30, w, 60), haze);
|
||||
|
||||
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);
|
||||
building(0.17, -52, 30, 52);
|
||||
building(0.74, -46, 54, 46);
|
||||
building(0.83, -34, 34, 34);
|
||||
|
||||
final Paint grid = Paint()
|
||||
..color = const Color(0x297FE0B0)
|
||||
..strokeWidth = 1;
|
||||
for (int i = 1; i <= 5; i++) {
|
||||
final double t = i / 5.0;
|
||||
final double y = horizon + (h - horizon) * t * t;
|
||||
canvas.drawLine(Offset(0, y), Offset(w, y), grid);
|
||||
}
|
||||
final double vx = w / 2;
|
||||
for (int k = -6; k <= 6; k += 2) {
|
||||
final double bx = vx + k * (w * 0.16);
|
||||
canvas.drawLine(Offset(vx + k * 10, horizon), Offset(bx, h), grid);
|
||||
}
|
||||
|
||||
final Paint subj = Paint()
|
||||
..color = Glass.subject
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2;
|
||||
const double bxw = 118, bxh = 82;
|
||||
final Rect box = Rect.fromCenter(center: Offset(vx, horizon + 8), width: bxw, height: bxh);
|
||||
const double c = 14;
|
||||
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);
|
||||
canvas.drawPath(Path()..moveTo(box.right - c, box.bottom)..lineTo(box.right, box.bottom)..lineTo(box.right, box.bottom - c), subj);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _FeedPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
class _MinimapPainter extends CustomPainter {
|
||||
const _MinimapPainter();
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final Path route = Path()
|
||||
..moveTo(20, 60)
|
||||
..cubicTo(50, 40, 70, 30, 120, 24);
|
||||
final Paint line = Paint()
|
||||
..color = const Color(0xFF5B93F5)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2;
|
||||
canvas.drawPath(route, line);
|
||||
canvas.drawCircle(const Offset(20, 60), 4, Paint()..color = Glass.sat);
|
||||
canvas.drawCircle(const Offset(120, 24), 4, Paint()..color = const Color(0xFF5B93F5));
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _MinimapPainter oldDelegate) => false;
|
||||
}
|
||||
@@ -34,7 +34,7 @@ class MinimapController extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// The map layer: OpenStreetMap tiles (like `map_page.dart`), the flown trail,
|
||||
/// The map layer: OpenStreetMap tiles, the flown trail,
|
||||
/// home, pilot and the aircraft. Used both as the small picture-in-picture and,
|
||||
/// after the swap, full-bleed behind the HUD — where it is also interactive.
|
||||
///
|
||||
|
||||
@@ -69,7 +69,7 @@ import 'top/camera_info_strip.dart';
|
||||
import 'top/smart_battery_bar.dart';
|
||||
import 'top/status_bar.dart';
|
||||
|
||||
/// The flight HUD v2 (plan P2), replacing [FlightControlPage].
|
||||
/// The flight HUD v2 (plan P2).
|
||||
///
|
||||
/// A stack of layers in `fpv_litchi`'s order — video → visual overlays → map →
|
||||
/// camera column → gimbal → top bar → bottom attitude → left rail → minimap —
|
||||
|
||||
@@ -515,15 +515,6 @@ class _FlySettingsSheetState extends State<FlySettingsSheet> {
|
||||
value: 'View',
|
||||
onTap: () => _push(TelemetryInspectorPage(model: _m, dji: widget.dji)),
|
||||
),
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: FlyPrefs.classicHud,
|
||||
builder: (BuildContext context, bool on, _) => SwitchTile(
|
||||
label: 'Classic flight screen',
|
||||
hint: 'Takes effect the next time the flight screen opens',
|
||||
value: on,
|
||||
onChanged: FlyPrefs.setClassicHud,
|
||||
),
|
||||
),
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: FlyPrefs.perfOverlay,
|
||||
builder: (BuildContext context, bool on, _) =>
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
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 '../fly/modes/waypoint_mission.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 WaypointMissionSpec mission = WaypointMissionSpec(
|
||||
points: <WaypointSpec>[for (final LatLng p in _waypoints) WaypointSpec(at: p, altitude: _altitude)],
|
||||
cruiseSpeed: _speed,
|
||||
);
|
||||
try {
|
||||
await _dji.uploadWaypointMission(mission.toBridge());
|
||||
await _dji.startWaypointMission();
|
||||
if (mounted) _snack('Route running — ${_waypoints.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.pilotvault.flyapp',
|
||||
),
|
||||
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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:pilotvault_fly/main.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Shows the login screen when signed out',
|
||||
(WidgetTester tester) async {
|
||||
testWidgets('Opens on the home screen with no aircraft connected', (WidgetTester tester) async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
await tester.pumpWidget(const DjiSampleApp());
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Sign in'), findsWidgets);
|
||||
expect(find.text('Sign in to continue'), findsOneWidget);
|
||||
expect(find.text('No aircraft connected'), findsOneWidget);
|
||||
expect(find.text('Connect aircraft'), findsOneWidget);
|
||||
expect(find.byType(MaterialApp), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user