Files
PilotVault/Fly App/lib/ui/album_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

215 lines
7.4 KiB
Dart

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../dji_service.dart';
import '../flight_model.dart';
import '../theme.dart';
import 'pv_icons.dart';
/// Portrait media grid — mirrors the v2 "Album" mockup. Backed by the aircraft's
/// SD card through the SDK MediaManager: refreshes the list on open, lazily
/// fetches thumbnails, and downloads originals on tap. Falls back to an empty
/// state when no aircraft/media is present.
class AlbumPage extends StatefulWidget {
const AlbumPage({super.key, required this.model, required this.dji});
final FlightModel model;
final DjiService dji;
@override
State<AlbumPage> createState() => _AlbumPageState();
}
class _AlbumPageState extends State<AlbumPage> {
static const List<String> _filters = <String>['All', 'Photos', 'Videos', 'Pano'];
int _active = 0;
final Set<int> _thumbRequested = <int>{};
DjiService get _dji => widget.dji;
FlightModel get _m => widget.model;
@override
void initState() {
super.initState();
_refresh();
}
Future<void> _refresh() async {
if (!_m.connected) return;
setState(() => _m.mediaLoading = true);
try {
await _dji.refreshMediaList();
} catch (e) {
if (mounted) {
setState(() => _m.mediaLoading = false);
_snack('Media: ${e is PlatformException ? (e.message ?? e.code) : e}');
}
}
}
void _snack(String msg) {
ScaffoldMessenger.of(context)
..clearSnackBars()
..showSnackBar(SnackBar(content: Text(msg)));
}
Future<void> _ensureThumb(MediaItem item) async {
if (item.thumbPath != null || _thumbRequested.contains(item.index)) return;
_thumbRequested.add(item.index);
try {
final String? path = await _dji.fetchThumbnail(item.index);
if (path != null && mounted) setState(() => item.thumbPath = path);
} catch (_) {
// leave placeholder icon
}
}
List<MediaItem> get _visible {
switch (_active) {
case 1:
return _m.media.where((MediaItem m) => !m.isVideo).toList();
case 2:
return _m.media.where((MediaItem m) => m.isVideo).toList();
default:
return _m.media;
}
}
@override
Widget build(BuildContext context) {
final PVScheme s = PVScheme.of(context);
return Scaffold(
backgroundColor: s.bgApp,
body: SafeArea(
child: AnimatedBuilder(
animation: _m,
builder: (BuildContext context, _) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_header(s),
_filterBar(s),
const SizedBox(height: 14),
Expanded(child: _body(s)),
],
),
),
),
);
}
Widget _header(PVScheme s) {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 6, 20, 12),
child: Row(children: <Widget>[
IconButton(
onPressed: () => Navigator.of(context).maybePop(),
icon: PVIcon('chevronLeft', size: 24, color: s.textSecondary),
),
Text('Album', style: TextStyle(fontFamily: PV.fontSans, fontSize: 20, fontWeight: FontWeight.w700, letterSpacing: -0.4, color: s.textPrimary)),
const Spacer(),
IconButton(onPressed: _refresh, icon: PVIcon('search', size: 19, color: s.textSecondary)),
Text('${_m.media.length} items', style: TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: s.textTertiary)),
]),
);
}
Widget _filterBar(PVScheme s) {
return SizedBox(
height: 28,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 20),
itemCount: _filters.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (BuildContext context, int i) {
final bool active = i == _active;
return GestureDetector(
onTap: () => setState(() => _active = i),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
alignment: Alignment.center,
decoration: BoxDecoration(color: active ? s.accent : s.surfaceInset, borderRadius: BorderRadius.circular(999)),
child: Text(_filters[i],
style: TextStyle(fontFamily: PV.fontSans, fontSize: 12.5, fontWeight: FontWeight.w600, color: active ? Colors.white : s.textSecondary)),
),
);
},
),
);
}
Widget _body(PVScheme s) {
if (_m.mediaLoading) {
return Center(child: CircularProgressIndicator(color: s.accent));
}
final List<MediaItem> items = _visible;
if (items.isEmpty) {
return Center(
child: Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
PVIcon('album', size: 40, stroke: 1.4, color: s.textTertiary),
const SizedBox(height: 12),
Text(_m.connected ? 'No media on the SD card' : 'Connect an aircraft to browse its media',
style: TextStyle(fontFamily: PV.fontSans, fontSize: 14, color: s.textSecondary)),
const SizedBox(height: 12),
if (_m.connected) OutlinedButton(onPressed: _refresh, child: const Text('Refresh')),
]),
);
}
return GridView.builder(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3, crossAxisSpacing: 6, mainAxisSpacing: 6),
itemCount: items.length,
itemBuilder: (BuildContext context, int i) => _cell(s, items[i], i),
);
}
Widget _cell(PVScheme s, MediaItem item, int i) {
_ensureThumb(item);
final bool hasThumb = item.thumbPath != null && File(item.thumbPath!).existsSync();
return GestureDetector(
onTap: () => _open(item),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft, end: Alignment.bottomRight,
colors: <Color>[s.surface2, s.surfaceInset],
transform: GradientRotation((140 + i * 14) * 3.1415926 / 180),
),
border: Border.all(color: s.border),
borderRadius: BorderRadius.circular(10),
),
child: Stack(fit: StackFit.expand, children: <Widget>[
if (hasThumb)
Image.file(File(item.thumbPath!), fit: BoxFit.cover)
else
Center(child: PVIcon(item.isVideo ? 'video' : 'image', size: 20, stroke: 1.6, color: s.textTertiary)),
if (item.durationLabel != null)
Positioned(
right: 6, bottom: 5,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(color: const Color(0xB30B1730), borderRadius: BorderRadius.circular(5)),
child: Text(item.durationLabel!, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 9.5, color: Colors.white)),
),
),
]),
),
),
);
}
Future<void> _open(MediaItem item) async {
_snack('Downloading ${item.fileName}…');
try {
final String? path = await _dji.downloadMedia(item.index);
if (mounted && path != null) _snack('Saved to $path');
} catch (e) {
if (mounted) _snack('Download: ${e is PlatformException ? (e.message ?? e.code) : e}');
}
}
}