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:
tajniak81andClaude Opus 5.5 committed 2026-10-21 20:36:17 +02:00
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
-11
View File
@@ -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
View File
@@ -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),
)),
),
));
}
-236
View File
@@ -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;
}
-573
View File
@@ -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;
}
+1 -1
View File
@@ -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.
///
+1 -1
View File
@@ -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, _) =>
-238
View File
@@ -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)),
);
}
}
+8 -4
View File
@@ -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);
});
}