Files
PilotVault/Fly App/lib/ui/go_fly_page.dart
T
tajniak81andClaude Opus 4.8 afc6952eda Initial commit: PilotVault multi-service project
Add API Server (Go/PocketBase), Web App (Go BFF + Vue), Fly App
(Flutter/DJI MSDK), Adobe Plugin, and Docker/Docker AIO deployment
configs. Design assets and build artifacts are gitignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 11:43:33 +02:00

293 lines
9.7 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import '../flight_model.dart';
import '../theme.dart';
import '../uploader.dart';
/// Portrait launch screen — mirrors the ui_kit/fly "Go Fly · launch" mockup:
/// brand header, aircraft connection card, big GO FLY, and a 2×2 tile grid.
class GoFlyPage extends StatelessWidget {
const GoFlyPage({
super.key,
required this.model,
required this.onGoFly,
required this.onOpenAlbum,
required this.onSettings,
required this.onTile,
});
final FlightModel model;
final VoidCallback onGoFly;
final VoidCallback onOpenAlbum;
final VoidCallback onSettings;
final void Function(String tile) onTile;
@override
Widget build(BuildContext context) {
final PVScheme s = PVScheme.of(context);
return Scaffold(
backgroundColor: s.bgApp,
body: SafeArea(
child: AnimatedBuilder(
animation: model,
builder: (BuildContext context, _) {
return Column(
children: <Widget>[
_header(s),
Expanded(
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: _connectionCard(s),
),
),
),
Padding(
// No bottom gap here: the design pins GO FLY directly above the
// tiles' 18px top pad, which centers the card 4px lower to match.
padding: const EdgeInsets.fromLTRB(20, 0, 20, 0),
child: _goFlyButton(s),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 28),
child: _tiles(s),
),
],
);
},
),
),
);
}
Widget _header(PVScheme s) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 10, 20, 0),
child: Row(
children: <Widget>[
const PvBrandMark(size: 22),
const SizedBox(width: 8),
Text.rich(
TextSpan(children: <TextSpan>[
TextSpan(
text: 'Pilot',
style: TextStyle(fontFamily: PV.fontSans, fontSize: 19, fontWeight: FontWeight.w500, letterSpacing: -0.38, color: s.textSecondary),
),
TextSpan(
text: 'Vault',
style: TextStyle(fontFamily: PV.fontSans, fontSize: 19, fontWeight: FontWeight.w700, letterSpacing: -0.38, color: s.textPrimary),
),
TextSpan(
text: ' Fly',
style: TextStyle(fontFamily: PV.fontSans, fontSize: 19, fontWeight: FontWeight.w500, letterSpacing: -0.38, color: s.accent),
),
]),
),
const Spacer(),
GestureDetector(
onTap: onSettings,
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(color: s.surfaceInset, shape: BoxShape.circle),
child: Icon(Icons.person_outline, size: 17, color: s.textSecondary),
),
),
],
),
);
}
Widget _connectionCard(PVScheme s) {
final bool connected = model.connected;
final Color dot = connected ? s.success : s.textTertiary;
final Color statusFg = connected ? s.successFg : s.textTertiary;
final String statusText = connected ? 'CONNECTED' : 'DISCONNECTED';
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: s.surface,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: s.border),
boxShadow: s.shadowSm,
),
child: Column(
mainAxisSize: MainAxisSize.min, // size to content; Center handles vertical placement
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Container(width: 7, height: 7, decoration: BoxDecoration(color: dot, shape: BoxShape.circle)),
const SizedBox(width: 6),
Text(
statusText,
style: TextStyle(fontFamily: PV.fontMono, fontSize: 11, letterSpacing: 1.1, fontWeight: FontWeight.w700, color: statusFg),
),
],
),
const SizedBox(height: 14),
Row(
children: <Widget>[
Container(
width: 56,
height: 56,
decoration: BoxDecoration(color: s.accentSoft, borderRadius: BorderRadius.circular(14)),
child: Icon(Icons.flight, size: 28, color: s.accent),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
connected ? (model.model ?? 'Aircraft') : 'No aircraft',
style: TextStyle(fontFamily: PV.fontSans, fontSize: 18, fontWeight: FontWeight.w700, color: s.textPrimary),
),
const SizedBox(height: 2),
Text(
'MSDK · ${model.sdkVersion}',
style: TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: s.textSecondary),
),
],
),
),
],
),
const SizedBox(height: 14),
Row(
children: <Widget>[
_chip(s, Icons.battery_full, model.batteryPercent == null ? '—' : '${model.batteryPercent}%'),
const SizedBox(width: 8),
_chip(s, Icons.satellite_alt, model.satellites == null ? '— sats' : '${model.satellites} sats'),
const SizedBox(width: 8),
_chip(s, Icons.link, _linkLabel(model.upload, connected)),
],
),
],
),
);
}
String _linkLabel(UploadStatus u, bool connected) {
switch (u) {
case UploadStatus.connected:
return 'Streaming';
case UploadStatus.connecting:
return 'Linking…';
case UploadStatus.error:
return 'Retrying';
case UploadStatus.disabled:
return connected ? 'Linked' : 'Off';
}
}
Widget _chip(PVScheme s, IconData icon, String value) {
return Expanded(
child: Container(
height: 34,
decoration: BoxDecoration(color: s.surfaceInset, borderRadius: BorderRadius.circular(9)),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Icon(icon, size: 14, color: s.textTertiary),
const SizedBox(width: 6),
Flexible(
child: Text(
value,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontFamily: PV.fontMono, fontSize: 11.5, color: s.textSecondary),
),
),
],
),
),
);
}
Widget _goFlyButton(PVScheme s) {
return SizedBox(
height: 58,
child: FilledButton(
onPressed: onGoFly,
style: FilledButton.styleFrom(
backgroundColor: s.accent,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: const <Widget>[
Icon(Icons.play_arrow_rounded, size: 22),
SizedBox(width: 10),
Text('GO FLY', style: TextStyle(fontFamily: PV.fontSans, fontSize: 18, fontWeight: FontWeight.w700, letterSpacing: 0.4)),
],
),
),
);
}
Widget _tiles(PVScheme s) {
const List<(IconData, String)> tiles = <(IconData, String)>[
(Icons.photo_library_outlined, 'Album'),
(Icons.school_outlined, 'Academy'),
(Icons.route_outlined, 'Routes'),
(Icons.speed, 'Flight logs'),
];
return Column(
children: <Widget>[
Row(children: <Widget>[
Expanded(child: _tile(s, tiles[0])),
const SizedBox(width: 12),
Expanded(child: _tile(s, tiles[1])),
]),
const SizedBox(height: 12),
Row(children: <Widget>[
Expanded(child: _tile(s, tiles[2])),
const SizedBox(width: 12),
Expanded(child: _tile(s, tiles[3])),
]),
],
);
}
Widget _tile(PVScheme s, (IconData, String) t) {
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () => t.$2 == 'Album' ? onOpenAlbum() : onTile(t.$2),
child: Container(
height: 56,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: s.surface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: s.border),
boxShadow: s.shadowXs,
),
child: Row(
children: <Widget>[
Container(
width: 32,
height: 32,
decoration: BoxDecoration(color: s.accentSoft, borderRadius: BorderRadius.circular(9)),
child: Icon(t.$1, size: 17, color: s.accentSoftFg),
),
const SizedBox(width: 10),
Flexible(
child: Text(
t.$2,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontFamily: PV.fontSans, fontSize: 14, fontWeight: FontWeight.w600, color: s.textPrimary),
),
),
],
),
),
),
);
}
}