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>
215 lines
7.4 KiB
Dart
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}');
|
|
}
|
|
}
|
|
}
|