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>
293 lines
9.7 KiB
Dart
293 lines
9.7 KiB
Dart
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),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|