Files
PilotVault/Fly App/lib/ui/profile_page.dart
T
tajniak81andClaude Opus 4.8 a4a2709456 Rebuild Fly App to design v2 with full DJI SDK integration
Rebuild the Flutter/DJI-MSDK-V4 Fly App to the v2 UI kit and wire the
full SDK surface behind it.

Native (Kotlin): split DjiSdkBridge into a method/event router delegating
to per-subsystem SubBridge helpers sharing a BridgeCtx — FlightController
(takeoff/land/RTH + rich telemetry), Camera (mode/record/photo/exposure),
Gimbal, Mission (Waypoint + ActiveTrack; QuickShots via ActiveTrack
QUICK_SHOT), Media (MediaManager list/thumbnail/download), and optional
DJI account login. Manifest gains scoped media permissions.

Flutter: ten screens under lib/ui/ (Flight HUD, capture modes, camera
settings, settings menu, map+waypoints, home, album, academy, profile,
routes/flight logs), driven by an expanded FlightModel. New PVIcon renders
the kit's SVG paths via flutter_svg; map uses flutter_map + latlong2.

Pin transitive androidx.core/browser down to SDK-35-compatible versions
so the newer plugins don't force AGP 8.9.1 onto the DJI toolchain.

Verified with `flutter build apk --debug` (compiles Dart + all Kotlin);
runtime behaviour is untested here — it needs a physical DJI-connected
device.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 16:51:08 +02:00

229 lines
8.2 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../dji_service.dart';
import '../flight_model.dart';
import '../pb_auth.dart';
import '../theme.dart';
import 'flight_logs_page.dart';
import 'pv_icons.dart';
import 'routes_page.dart';
/// Profile — mirrors the v2 "Profile" mockup. Shows the PilotVault identity and
/// (optionally) the linked DJI account, headline stats, and library shortcuts.
class ProfilePage extends StatefulWidget {
const ProfilePage({
super.key,
required this.model,
required this.dji,
required this.onAppSettings,
required this.onSignIn,
});
final FlightModel model;
final DjiService dji;
final VoidCallback onAppSettings;
final VoidCallback onSignIn;
@override
State<ProfilePage> createState() => _ProfilePageState();
}
class _ProfilePageState extends State<ProfilePage> {
StreamSubscription<AuthStatus>? _authSub;
DjiService get _dji => widget.dji;
FlightModel get _m => widget.model;
@override
void initState() {
super.initState();
_authSub = auth.status.listen((_) {
if (mounted) setState(() {});
});
widget.dji.refreshDjiAccountState().catchError((_) {});
}
@override
void dispose() {
_authSub?.cancel();
super.dispose();
}
void _snack(String msg) {
ScaffoldMessenger.of(context)
..clearSnackBars()
..showSnackBar(SnackBar(content: Text(msg)));
}
Future<void> _djiLogin() async {
try {
await _dji.djiLogin();
_snack('DJI account linked');
} catch (e) {
_snack('DJI login: ${e is PlatformException ? (e.message ?? e.code) : e}');
}
}
Future<void> _djiLogout() async {
try {
await _dji.djiLogout();
} catch (_) {}
}
bool get _djiLinked => _m.djiAccountState == 'AUTHORIZED';
@override
Widget build(BuildContext context) {
final PVScheme s = PVScheme.of(context);
final bool signedIn = auth.isAuthed;
return Scaffold(
backgroundColor: s.bgApp,
body: SafeArea(
child: AnimatedBuilder(
animation: _m,
builder: (BuildContext context, _) => ListView(
children: <Widget>[
_headerBar(s),
_identity(s, signedIn),
_stats(s),
_djiCard(s),
const SizedBox(height: 6),
..._rows(s),
],
),
),
),
);
}
Widget _headerBar(PVScheme s) => Padding(
padding: const EdgeInsets.fromLTRB(12, 6, 12, 0),
child: Row(children: <Widget>[
IconButton(onPressed: () => Navigator.of(context).maybePop(), icon: PVIcon('chevronLeft', size: 24, color: s.textSecondary)),
const Spacer(),
IconButton(onPressed: widget.onAppSettings, icon: PVIcon('settings', size: 20, color: s.textSecondary)),
]),
);
Widget _identity(PVScheme s, bool signedIn) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 6, 20, 16),
child: Row(children: <Widget>[
Container(
width: 60, height: 60,
decoration: BoxDecoration(color: s.accentSoft, shape: BoxShape.circle),
alignment: Alignment.center,
child: PVIcon('user', size: 30, color: s.accent),
),
const SizedBox(width: 14),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
Text(signedIn ? (auth.userEmail.isEmpty ? 'PilotVault pilot' : auth.userEmail) : 'Guest pilot',
maxLines: 1, overflow: TextOverflow.ellipsis,
style: TextStyle(fontFamily: PV.fontSans, fontSize: 18, fontWeight: FontWeight.w700, letterSpacing: -0.2, color: s.textPrimary)),
const SizedBox(height: 2),
Text(signedIn ? 'Verified pilot' : 'Not signed in',
style: TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: s.textSecondary)),
]),
),
if (!signedIn)
FilledButton(onPressed: widget.onSignIn, child: const Text('Sign in')),
]),
);
}
Widget _stats(PVScheme s) {
const List<(String, String)> stats = <(String, String)>[('Flights', '—'), ('Distance', '—'), ('Flight time', '—')];
return Container(
margin: const EdgeInsets.fromLTRB(20, 0, 20, 18),
decoration: BoxDecoration(color: s.surface, borderRadius: BorderRadius.circular(16), border: Border.all(color: s.border), boxShadow: s.shadowXs),
child: Row(children: <Widget>[
for (int i = 0; i < stats.length; i++)
Expanded(
child: Container(
decoration: BoxDecoration(border: Border(left: i == 0 ? BorderSide.none : BorderSide(color: s.border))),
padding: const EdgeInsets.symmetric(vertical: 14),
child: Column(children: <Widget>[
Text(stats[i].$2, style: TextStyle(fontFamily: PV.fontMono, fontSize: 19, fontWeight: FontWeight.w700, color: s.textPrimary)),
const SizedBox(height: 2),
Text(stats[i].$1, style: TextStyle(fontFamily: PV.fontSans, fontSize: 11.5, color: s.textSecondary)),
]),
),
),
]),
);
}
Widget _djiCard(PVScheme s) {
return Container(
margin: const EdgeInsets.fromLTRB(20, 0, 20, 8),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(color: s.surface, borderRadius: BorderRadius.circular(14), border: Border.all(color: s.border)),
child: Row(children: <Widget>[
Container(
width: 34, height: 34,
decoration: BoxDecoration(color: s.surfaceInset, borderRadius: BorderRadius.circular(9)),
alignment: Alignment.center,
child: PVIcon('drone', size: 18, color: s.textSecondary),
),
const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
Text('DJI account', style: TextStyle(fontFamily: PV.fontSans, fontSize: 14, fontWeight: FontWeight.w600, color: s.textPrimary)),
Text(_djiLinked ? (_m.djiAccountUser ?? 'Linked') : 'Optional · unlocks NFZ & sync',
style: TextStyle(fontFamily: PV.fontMono, fontSize: 11, color: s.textSecondary)),
])),
_djiLinked
? TextButton(onPressed: _djiLogout, child: const Text('Unlink'))
: FilledButton(onPressed: _djiLogin, child: const Text('Link')),
]),
);
}
List<Widget> _rows(PVScheme s) {
final List<(String, String, VoidCallback)> rows = <(String, String, VoidCallback)>[
('gauge', 'Flight records', () => _push(const FlightLogsPage())),
('route', 'My routes', () => _push(const RoutesPage())),
('download', 'Downloads', () => _snack('Downloaded media is saved to the app files folder')),
('shield', 'Find my drone', _findDrone),
('settings', 'App settings', widget.onAppSettings),
];
return <Widget>[
for (int i = 0; i < rows.length; i++)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: InkWell(
onTap: rows[i].$3,
child: Container(
height: 52,
decoration: BoxDecoration(border: Border(bottom: i < rows.length - 1 ? BorderSide(color: s.border) : BorderSide.none)),
child: Row(children: <Widget>[
Container(
width: 34, height: 34,
decoration: BoxDecoration(color: s.surfaceInset, borderRadius: BorderRadius.circular(9)),
alignment: Alignment.center,
child: PVIcon(rows[i].$1, size: 17, color: s.textSecondary),
),
const SizedBox(width: 14),
Expanded(child: Text(rows[i].$2, style: TextStyle(fontFamily: PV.fontSans, fontSize: 14.5, fontWeight: FontWeight.w500, color: s.textPrimary))),
PVIcon('chevronRight', size: 18, color: s.textTertiary),
]),
),
),
),
];
}
void _push(Widget page) => Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => page));
void _findDrone() {
if (_m.latitude != null && _m.longitude != null) {
_snack('Aircraft at ${_m.latitude!.toStringAsFixed(5)}, ${_m.longitude!.toStringAsFixed(5)}');
} else {
_snack('No aircraft GPS fix available');
}
}
}