Playback: the flight screen's playback button opens the album over the live view, so a video tapped there plays on the aircraft into the feed with a transport bar. Long press selects files to download or delete (delete asks first); one file opens in a viewer with the camera's preview. Leaving the album puts the camera back in its capture mode. Flight records: every flight is recorded from take-off to landing (1 Hz track, max height / distance / speed, distance flown, battery used) and kept on the phone. When it lands while signed in, it is uploaded to the PilotVault logbook through the existing /api/drones/auto and /api/flights endpoints as an automatic-path entry with the track as GeoJSON. Simulator flights are never uploaded; flights recorded while signed out retry on sign-in. Flight logs lists them with their upload state and a track map, and the profile stats come from real flights. Find My Drone: the aircraft's last fix is kept across lost links and restarts, and shown against the phone's own GPS with distance, direction and a hand-off to the maps app. Live streaming: a new LiveStreamBridge streams the main camera to an RTMP address set in Settings > General, with a LIVE pill on the flight screen that turns amber when the uplink falls behind and stops on tap. Pre-flight checklist (manual checks plus the aircraft's live status) and an in-app Academy with six short guides that work offline. None of this has run on hardware yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
237 lines
8.9 KiB
Dart
237 lines
8.9 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
|
|
import '../dji_service.dart';
|
|
import '../flight_model.dart';
|
|
import '../fly/find_my_drone.dart';
|
|
import '../fly/flight_log.dart';
|
|
import '../fly/flight_record.dart';
|
|
import '../pb_auth.dart';
|
|
import '../theme.dart';
|
|
import 'find_my_drone_page.dart';
|
|
import 'flight_logs_page.dart';
|
|
import 'pv_icons.dart';
|
|
import 'routes_page.dart';
|
|
|
|
/// Profile — mirrors the v2 "Profile" mockup. Shows the PilotVault identity and
|
|
/// (optionally) the linked DJI account, headline stats, and library shortcuts.
|
|
class ProfilePage extends StatefulWidget {
|
|
const ProfilePage({
|
|
super.key,
|
|
required this.model,
|
|
required this.dji,
|
|
required this.onAppSettings,
|
|
required this.onSignIn,
|
|
required this.log,
|
|
required this.locator,
|
|
});
|
|
|
|
final FlightModel model;
|
|
final DjiService dji;
|
|
final FlightLog log;
|
|
final AircraftLocator locator;
|
|
final VoidCallback onAppSettings;
|
|
final VoidCallback onSignIn;
|
|
|
|
@override
|
|
State<ProfilePage> createState() => _ProfilePageState();
|
|
}
|
|
|
|
class _ProfilePageState extends State<ProfilePage> {
|
|
StreamSubscription<AuthStatus>? _authSub;
|
|
|
|
DjiService get _dji => widget.dji;
|
|
FlightModel get _m => widget.model;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_authSub = auth.status.listen((_) {
|
|
if (mounted) setState(() {});
|
|
});
|
|
widget.dji.refreshDjiAccountState().catchError((_) {});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_authSub?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
void _snack(String msg) {
|
|
ScaffoldMessenger.of(context)
|
|
..clearSnackBars()
|
|
..showSnackBar(SnackBar(content: Text(msg)));
|
|
}
|
|
|
|
Future<void> _djiLogin() async {
|
|
try {
|
|
await _dji.djiLogin();
|
|
_snack('DJI account linked');
|
|
} catch (e) {
|
|
_snack('DJI login: ${e is PlatformException ? (e.message ?? e.code) : e}');
|
|
}
|
|
}
|
|
|
|
Future<void> _djiLogout() async {
|
|
try {
|
|
await _dji.djiLogout();
|
|
} catch (_) {}
|
|
}
|
|
|
|
bool get _djiLinked => _m.djiAccountState == 'AUTHORIZED';
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final PVScheme s = PVScheme.of(context);
|
|
final bool signedIn = auth.isAuthed;
|
|
return Scaffold(
|
|
backgroundColor: s.bgApp,
|
|
body: SafeArea(
|
|
child: AnimatedBuilder(
|
|
animation: Listenable.merge(<Listenable>[_m, widget.log]),
|
|
builder: (BuildContext context, _) => ListView(
|
|
children: <Widget>[
|
|
_headerBar(s),
|
|
_identity(s, signedIn),
|
|
_stats(s),
|
|
_djiCard(s),
|
|
const SizedBox(height: 6),
|
|
..._rows(s),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _headerBar(PVScheme s) => Padding(
|
|
padding: const EdgeInsets.fromLTRB(12, 6, 12, 0),
|
|
child: Row(children: <Widget>[
|
|
IconButton(onPressed: () => Navigator.of(context).maybePop(), icon: PVIcon('chevronLeft', size: 24, color: s.textSecondary)),
|
|
const Spacer(),
|
|
IconButton(onPressed: widget.onAppSettings, icon: PVIcon('settings', size: 20, color: s.textSecondary)),
|
|
]),
|
|
);
|
|
|
|
Widget _identity(PVScheme s, bool signedIn) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 6, 20, 16),
|
|
child: Row(children: <Widget>[
|
|
Container(
|
|
width: 60, height: 60,
|
|
decoration: BoxDecoration(color: s.accentSoft, shape: BoxShape.circle),
|
|
alignment: Alignment.center,
|
|
child: PVIcon('user', size: 30, color: s.accent),
|
|
),
|
|
const SizedBox(width: 14),
|
|
Expanded(
|
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
|
|
Text(signedIn ? (auth.userEmail.isEmpty ? 'PilotVault pilot' : auth.userEmail) : 'Guest pilot',
|
|
maxLines: 1, overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(fontFamily: PV.fontSans, fontSize: 18, fontWeight: FontWeight.w700, letterSpacing: -0.2, color: s.textPrimary)),
|
|
const SizedBox(height: 2),
|
|
Text(signedIn ? 'Verified pilot' : 'Not signed in',
|
|
style: TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: s.textSecondary)),
|
|
]),
|
|
),
|
|
if (!signedIn)
|
|
FilledButton(onPressed: widget.onSignIn, child: const Text('Sign in')),
|
|
]),
|
|
);
|
|
}
|
|
|
|
Widget _stats(PVScheme s) {
|
|
// Real flights only: simulator hours are not pilot experience.
|
|
final List<FlightRecord> real = <FlightRecord>[for (final FlightRecord r in widget.log.records) if (!r.simulated) r];
|
|
final double metres = real.fold(0, (double t, FlightRecord r) => t + r.distanceFlown);
|
|
final int minutes = real.fold(0, (int t, FlightRecord r) => t + r.duration.inSeconds) ~/ 60;
|
|
final List<(String, String)> stats = <(String, String)>[
|
|
('Flights', '${real.length}'),
|
|
('Distance', real.isEmpty ? '—' : distanceLabel(metres)),
|
|
('Flight time', real.isEmpty ? '—' : (minutes >= 60 ? '${minutes ~/ 60} h ${minutes % 60} m' : '$minutes min')),
|
|
];
|
|
return Container(
|
|
margin: const EdgeInsets.fromLTRB(20, 0, 20, 18),
|
|
decoration: BoxDecoration(color: s.surface, borderRadius: BorderRadius.circular(16), border: Border.all(color: s.border), boxShadow: s.shadowXs),
|
|
child: Row(children: <Widget>[
|
|
for (int i = 0; i < stats.length; i++)
|
|
Expanded(
|
|
child: Container(
|
|
decoration: BoxDecoration(border: Border(left: i == 0 ? BorderSide.none : BorderSide(color: s.border))),
|
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
|
child: Column(children: <Widget>[
|
|
Text(stats[i].$2, style: TextStyle(fontFamily: PV.fontMono, fontSize: 19, fontWeight: FontWeight.w700, color: s.textPrimary)),
|
|
const SizedBox(height: 2),
|
|
Text(stats[i].$1, style: TextStyle(fontFamily: PV.fontSans, fontSize: 11.5, color: s.textSecondary)),
|
|
]),
|
|
),
|
|
),
|
|
]),
|
|
);
|
|
}
|
|
|
|
Widget _djiCard(PVScheme s) {
|
|
return Container(
|
|
margin: const EdgeInsets.fromLTRB(20, 0, 20, 8),
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
|
decoration: BoxDecoration(color: s.surface, borderRadius: BorderRadius.circular(14), border: Border.all(color: s.border)),
|
|
child: Row(children: <Widget>[
|
|
Container(
|
|
width: 34, height: 34,
|
|
decoration: BoxDecoration(color: s.surfaceInset, borderRadius: BorderRadius.circular(9)),
|
|
alignment: Alignment.center,
|
|
child: PVIcon('drone', size: 18, color: s.textSecondary),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
|
|
Text('DJI account', style: TextStyle(fontFamily: PV.fontSans, fontSize: 14, fontWeight: FontWeight.w600, color: s.textPrimary)),
|
|
Text(_djiLinked ? (_m.djiAccountUser ?? 'Linked') : 'Optional · unlocks NFZ & sync',
|
|
style: TextStyle(fontFamily: PV.fontMono, fontSize: 11, color: s.textSecondary)),
|
|
])),
|
|
_djiLinked
|
|
? TextButton(onPressed: _djiLogout, child: const Text('Unlink'))
|
|
: FilledButton(onPressed: _djiLogin, child: const Text('Link')),
|
|
]),
|
|
);
|
|
}
|
|
|
|
List<Widget> _rows(PVScheme s) {
|
|
final List<(String, String, VoidCallback)> rows = <(String, String, VoidCallback)>[
|
|
('gauge', 'Flight records', () => _push(FlightLogsPage(log: widget.log, locator: widget.locator, model: _m))),
|
|
('route', 'My routes', () => _push(const RoutesPage())),
|
|
('download', 'Downloads', () => _snack('Downloaded media is saved to the app files folder')),
|
|
('pin', 'Find my drone', () => _push(FindMyDronePage(locator: widget.locator, log: widget.log, model: _m))),
|
|
('settings', 'App settings', widget.onAppSettings),
|
|
];
|
|
return <Widget>[
|
|
for (int i = 0; i < rows.length; i++)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
|
child: InkWell(
|
|
onTap: rows[i].$3,
|
|
child: Container(
|
|
height: 52,
|
|
decoration: BoxDecoration(border: Border(bottom: i < rows.length - 1 ? BorderSide(color: s.border) : BorderSide.none)),
|
|
child: Row(children: <Widget>[
|
|
Container(
|
|
width: 34, height: 34,
|
|
decoration: BoxDecoration(color: s.surfaceInset, borderRadius: BorderRadius.circular(9)),
|
|
alignment: Alignment.center,
|
|
child: PVIcon(rows[i].$1, size: 17, color: s.textSecondary),
|
|
),
|
|
const SizedBox(width: 14),
|
|
Expanded(child: Text(rows[i].$2, style: TextStyle(fontFamily: PV.fontSans, fontSize: 14.5, fontWeight: FontWeight.w500, color: s.textPrimary))),
|
|
PVIcon('chevronRight', size: 18, color: s.textTertiary),
|
|
]),
|
|
),
|
|
),
|
|
),
|
|
];
|
|
}
|
|
|
|
void _push(Widget page) => Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => page));
|
|
}
|