nkbmusic/admin/lib/cover_editor.dart
metatroncubeswdev d994fc733f Cover photos for albums and songs: upload in NK Admin, shown in the app
Songs without their own cover use their album's. Covers live in a public
'covers' bucket that only admins can write to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 14:23:43 -04:00

97 lines
3.4 KiB
Dart

import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'api.dart';
import 'theme.dart';
import 'widgets.dart';
/// Cover photo for an album or song. Uploads and removes straight away (not on Save),
/// so the photo is live in the app as soon as it shows here.
class CoverEditor extends StatefulWidget {
const CoverEditor({super.key, required this.table, required this.id, required this.coverUrl, this.hint});
/// 'albums' or 'tracks'.
final String table;
final String id;
final String? coverUrl;
final String? hint;
@override
State<CoverEditor> createState() => _CoverEditorState();
}
class _CoverEditorState extends State<CoverEditor> {
late String? _url = widget.coverUrl;
bool _busy = false;
Future<void> _pick() async {
// Resized in the browser: covers show at most ~400 px wide in the app.
final file = await ImagePicker().pickImage(source: ImageSource.gallery, maxWidth: 1200, maxHeight: 1200, imageQuality: 85);
if (file == null) return;
await _run(() async {
final bytes = await file.readAsBytes();
if (bytes.length > 5 << 20) throw 'That photo is too large. Please choose one under 5 MB.';
final url = await AdminApi.setCover(widget.table, widget.id, bytes, file.mimeType ?? 'image/jpeg');
setState(() => _url = url);
}, 'Cover photo saved');
}
Future<void> _remove() => _run(() async {
await AdminApi.removeCover(widget.table, widget.id);
setState(() => _url = null);
}, 'Cover photo removed');
Future<void> _run(Future<void> Function() job, String done) async {
setState(() => _busy = true);
try {
await job();
if (mounted) showMessage(context, done);
} catch (e) {
if (mounted) showError(context, e);
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) => Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: SizedBox.square(
dimension: 88,
child: _busy
? const Center(child: CircularProgressIndicator())
: _url == null
? const ColoredBox(color: NkColors.surface, child: Icon(Icons.image_outlined, color: NkColors.muted, size: 36))
: Image.network(_url!, fit: BoxFit.cover),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Cover photo', style: Theme.of(context).textTheme.titleMedium),
Text(widget.hint ?? 'Square photos look best. JPG, PNG or WebP.',
style: const TextStyle(color: NkColors.muted)),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
OutlinedButton.icon(
onPressed: _busy ? null : _pick,
icon: const Icon(Icons.upload_outlined),
label: Text(_url == null ? 'Upload photo' : 'Change photo'),
),
if (_url != null)
TextButton(onPressed: _busy ? null : _remove, child: const Text('Remove')),
],
),
],
),
),
],
);
}