nkbmusic/admin/lib/screens/song_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

322 lines
12 KiB
Dart

import 'package:flutter/material.dart';
import '../api.dart';
import '../cover_editor.dart';
import '../pickers.dart';
import '../theme.dart';
import '../widgets.dart';
typedef _Lookups = (List<AdminArtist>, List<AdminAlbum>);
Future<_Lookups> _lookups() async => (await AdminApi.artists(), await AdminApi.albums());
/// Edits one song. Returns true when something was saved.
Future<bool> editSong(BuildContext context, AdminTrack track) async =>
await showDialog<bool>(context: context, builder: (_) => _LookupsDialog(builder: (l) => _SongForm(track, l))) ??
false;
/// Changes chosen fields on many songs at once. Returns true when something was saved.
Future<bool> bulkEditSongs(BuildContext context, List<String> ids) async =>
await showDialog<bool>(context: context, builder: (_) => _LookupsDialog(builder: (l) => _BulkForm(ids, l))) ??
false;
class _LookupsDialog extends StatefulWidget {
const _LookupsDialog({required this.builder});
final Widget Function(_Lookups lookups) builder;
@override
State<_LookupsDialog> createState() => _LookupsDialogState();
}
class _LookupsDialogState extends State<_LookupsDialog> {
late Future<_Lookups> _data = _lookups();
@override
Widget build(BuildContext context) => FutureBuilder(
future: _data,
builder: (context, snap) {
if (snap.hasData) return widget.builder(snap.data!);
return AlertDialog(
content: SizedBox(
width: 320,
height: 120,
child: snap.hasError
? Center(child: Text(errorText(snap.error!)))
: const Center(child: CircularProgressIndicator()),
),
actions: [
if (snap.hasError) TextButton(onPressed: () => setState(() => _data = _lookups()), child: const Text('Retry')),
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Close')),
],
);
},
);
}
Widget _occasionField(String? value, ValueChanged<String?> onChanged) => DropdownButtonFormField<String>(
initialValue: value,
decoration: const InputDecoration(labelText: 'Occasion'),
items: [for (final e in occasions.entries) DropdownMenuItem(value: e.key, child: Text(e.value))],
onChanged: onChanged,
);
Widget _statusField(SongStatus? value, ValueChanged<SongStatus?> onChanged) => DropdownButtonFormField<SongStatus>(
initialValue: value,
decoration: const InputDecoration(labelText: 'Status'),
items: [for (final s in SongStatus.values) DropdownMenuItem(value: s, child: Text(s.label))],
onChanged: onChanged,
);
class _SongForm extends StatefulWidget {
const _SongForm(this.track, this.lookups);
final AdminTrack track;
final _Lookups lookups;
@override
State<_SongForm> createState() => _SongFormState();
}
class _SongFormState extends State<_SongForm> {
late final t = widget.track;
late final _title = TextEditingController(text: t.title);
late final _tamil = TextEditingController(text: t.titleTamil ?? '');
late final _meaning = TextEditingController(text: t.meaning ?? '');
late final _year = TextEditingController(text: t.year?.toString() ?? '');
late String? _albumId = t.albumId;
late String _occasion = t.occasion;
late List<String> _singers = [...t.singerIds];
late SongStatus _status = t.status;
bool _saving = false;
@override
void dispose() {
for (final c in [_title, _tamil, _meaning, _year]) {
c.dispose();
}
super.dispose();
}
Future<void> _save() async {
if (_title.text.trim().isEmpty) {
showMessage(context, 'The song needs a title.');
return;
}
final year = int.tryParse(_year.text.trim());
if (_year.text.trim().isNotEmpty && year == null) {
showMessage(context, 'The year should be a number, like 1998.');
return;
}
setState(() => _saving = true);
try {
await AdminApi.saveSong(
t,
title: _title.text.trim(),
titleTamil: trimmedOrNull(_tamil.text),
meaning: trimmedOrNull(_meaning.text),
year: year,
albumId: _albumId,
occasion: _occasion,
status: _status,
singerIds: _singers,
);
if (mounted) Navigator.pop(context, true);
} catch (e) {
if (mounted) showError(context, e);
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final (artists, albums) = widget.lookups;
return AlertDialog(
title: const Text('Edit song'),
content: DialogBody(
width: 560,
children: [
TextField(controller: _title, decoration: const InputDecoration(labelText: 'Title (English letters)')),
TextField(controller: _tamil, decoration: const InputDecoration(labelText: 'Title in Tamil script')),
TextField(controller: _meaning, decoration: const InputDecoration(labelText: 'Meaning in English')),
Row(
children: [
Expanded(child: _occasionField(_occasion, (v) => setState(() => _occasion = v ?? _occasion))),
const SizedBox(width: 12),
SizedBox(
width: 120,
child: TextField(
controller: _year,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Year'),
),
),
],
),
AlbumPicker(albums: albums, albumId: _albumId, onChanged: (v) => setState(() => _albumId = v)),
Text('Singers', style: Theme.of(context).textTheme.titleMedium),
SingerPicker(artists: artists, selected: _singers, onChanged: (v) => setState(() => _singers = v)),
_statusField(_status, (v) => setState(() => _status = v ?? _status)),
if (t.status == SongStatus.pending && _status == SongStatus.pending)
const Text('Tip: set the status to "Live" to publish this song in the app.',
style: TextStyle(color: NkColors.muted)),
CoverEditor(
table: 'tracks',
id: t.id,
coverUrl: t.coverUrl,
hint: 'Optional. Without one, the app shows the album cover.',
),
_Facts(t),
],
),
actions: [
TextButton(onPressed: _saving ? null : () => Navigator.pop(context, false), child: const Text('Cancel')),
FilledButton(onPressed: _saving ? null : _save, child: Text(_saving ? 'Saving…' : 'Save')),
],
);
}
}
class _Facts extends StatelessWidget {
const _Facts(this.t);
final AdminTrack t;
@override
Widget build(BuildContext context) {
final style = Theme.of(context).textTheme.bodySmall;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Divider(),
const SizedBox(height: 8),
Text('Added ${formatDate(t.createdAt)} · ${formatDuration(t.duration)}', style: style),
if (t.sourceNote != null) Text('Source: ${t.sourceNote}', style: style),
if (t.audioKey != null) SelectableText('File: ${t.audioKey}', style: style),
if (t.reviewNote != null) Text('Review note: ${t.reviewNote}', style: style),
],
);
}
}
class _BulkForm extends StatefulWidget {
const _BulkForm(this.ids, this.lookups);
final List<String> ids;
final _Lookups lookups;
@override
State<_BulkForm> createState() => _BulkFormState();
}
class _BulkFormState extends State<_BulkForm> {
bool _setAlbum = false, _setOccasion = false, _setSingers = false, _setStatus = false;
String? _albumId;
String? _occasion;
List<String> _singers = [];
bool _replaceSingers = false;
SongStatus? _status;
bool _saving = false;
Future<void> _save() async {
if (_setOccasion && _occasion == null || _setStatus && _status == null) {
showMessage(context, 'Choose a value for each ticked field.');
return;
}
if (_setSingers && _singers.isEmpty && !_replaceSingers) {
showMessage(context, 'Choose at least one singer to add.');
return;
}
if (!(_setAlbum || _setOccasion || _setSingers || _setStatus)) {
showMessage(context, 'Tick at least one field to change.');
return;
}
setState(() => _saving = true);
try {
await AdminApi.updateSongs(
widget.ids,
SongChanges(
albumId: _setAlbum ? _albumId : null,
clearAlbum: _setAlbum && _albumId == null,
occasion: _setOccasion ? _occasion : null,
status: _setStatus ? _status : null,
singerIds: _setSingers ? _singers : null,
replaceSingers: _replaceSingers,
),
);
if (mounted) Navigator.pop(context, true);
} catch (e) {
if (mounted) showError(context, e);
} finally {
if (mounted) setState(() => _saving = false);
}
}
Widget _toggle(String label, bool value, ValueChanged<bool> onChanged, Widget child) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
CheckboxListTile(
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
title: Text(label, style: const TextStyle(fontWeight: FontWeight.w700)),
value: value,
onChanged: (v) => setState(() => onChanged(v ?? false)),
),
if (value) Padding(padding: const EdgeInsets.only(left: 40), child: child),
],
);
@override
Widget build(BuildContext context) {
final (artists, albums) = widget.lookups;
return AlertDialog(
title: Text('Edit ${widget.ids.length} songs'),
content: DialogBody(
width: 560,
children: [
const Text('Tick what to change. Everything else stays as it is.', style: TextStyle(color: NkColors.muted)),
_toggle('Album', _setAlbum, (v) => _setAlbum = v, Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AlbumPicker(albums: albums, albumId: _albumId, onChanged: (v) => setState(() => _albumId = v)),
if (_albumId == null)
const Padding(
padding: EdgeInsets.only(top: 6),
child: Text('No album chosen: the songs will be removed from their album.',
style: TextStyle(color: NkColors.muted)),
),
],
)),
_toggle('Occasion', _setOccasion, (v) => _setOccasion = v,
_occasionField(_occasion, (v) => setState(() => _occasion = v))),
_toggle('Singers', _setSingers, (v) => _setSingers = v, Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SegmentedButton<bool>(
segments: const [
ButtonSegment(value: false, label: Text('Add to existing')),
ButtonSegment(value: true, label: Text('Replace all')),
],
selected: {_replaceSingers},
onSelectionChanged: (s) => setState(() => _replaceSingers = s.first),
),
const SizedBox(height: 10),
SingerPicker(artists: artists, selected: _singers, onChanged: (v) => setState(() => _singers = v)),
],
)),
_toggle('Status', _setStatus, (v) => _setStatus = v,
_statusField(_status, (v) => setState(() => _status = v))),
],
),
actions: [
TextButton(onPressed: _saving ? null : () => Navigator.pop(context, false), child: const Text('Cancel')),
FilledButton(
onPressed: _saving ? null : _save,
child: Text(_saving ? 'Saving…' : 'Apply to ${widget.ids.length} songs'),
),
],
);
}
}