nkbmusic/admin/lib/screens/applications_screen.dart
metatroncubeswdev cf645071aa Add NK Admin dashboard (Flutter web) for admin.nkbmusic.com
Email-code sign-in for admins only; Overview with a needs-attention list;
Songs with search, playback, editing, approve/hide and bulk edit; Albums
and Singers with merge; creator applications queue. Adds the admin views,
functions and creator_applications table, a deploy-admin script and a
how-to guide. The admin nginx config no longer caches unhashed JS and
sends its security headers again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 11:35:44 -04:00

228 lines
8.7 KiB
Dart

import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../api.dart';
import '../pickers.dart';
import '../theme.dart';
import '../widgets.dart';
const _tabs = {
'pending': 'Waiting',
'needs_info': 'Asked for more',
'approved': 'Approved',
'rejected': 'Rejected',
};
/// Singers and creators asking to be verified (the "I'm a singer" form in the app).
class ApplicationsScreen extends StatefulWidget {
const ApplicationsScreen({super.key});
@override
State<ApplicationsScreen> createState() => _ApplicationsScreenState();
}
class _ApplicationsScreenState extends State<ApplicationsScreen> {
String _status = 'pending';
late Future<List<CreatorApplication>> _data = AdminApi.applications(_status);
void _reload() => setState(() => _data = AdminApi.applications(_status));
Future<void> _approve(CreatorApplication app) async {
final artists = await AdminApi.artists();
if (!mounted) return;
var link = <String>[if (app.artistId != null) app.artistId!];
final note = TextEditingController();
final ok = await showDialog<bool>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setLocal) => AlertDialog(
title: Text('Approve ${app.stageName}'),
content: DialogBody(children: [
const Text('If this singer is already in the catalog, choose them so their songs are linked to this '
'account. Otherwise a new verified singer is created.'),
SingerPicker(
artists: artists,
selected: link,
onChanged: (v) => setLocal(() => link = v.isEmpty ? v : [v.last]),
),
TextField(controller: note, decoration: const InputDecoration(labelText: 'Note (optional)')),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Approve')),
],
),
),
);
if (ok == true) {
await _review(app, 'approved', trimmedOrNull(note.text), link.isEmpty ? null : link.single);
}
}
Future<void> _decline(CreatorApplication app, String decision) async {
final note = TextEditingController();
final ask = decision == 'needs_info';
final ok = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(ask ? 'Ask ${app.stageName} for more' : 'Reject ${app.stageName}'),
content: DialogBody(children: [
TextField(
controller: note,
autofocus: true,
maxLines: 3,
decoration: InputDecoration(labelText: ask ? 'What do you need from them?' : 'Reason'),
),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
FilledButton(
style: ask ? null : FilledButton.styleFrom(backgroundColor: NkColors.roofRed, foregroundColor: NkColors.cream),
onPressed: () => Navigator.pop(context, true),
child: Text(ask ? 'Send back' : 'Reject'),
),
],
),
);
if (ok == true) await _review(app, decision, trimmedOrNull(note.text), null);
}
Future<void> _review(CreatorApplication app, String decision, String? note, String? artistId) async {
try {
await AdminApi.reviewApplication(app.id, decision, note: note, artistId: artistId);
if (mounted) showMessage(context, '${app.stageName}: ${_tabs[decision]!.toLowerCase()}');
_reload();
} catch (e) {
if (mounted) showError(context, e);
}
}
Future<void> _openDocument(String path) async {
try {
await launchUrl(Uri.parse(await AdminApi.idDocumentUrl(path)));
} catch (e) {
if (mounted) showError(context, e);
}
}
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
PageHeader('Creator applications', subtitle: 'Verify singers and creators before they can upload', actions: [
SegmentedButton<String>(
segments: [for (final e in _tabs.entries) ButtonSegment(value: e.key, label: Text(e.value))],
selected: {_status},
onSelectionChanged: (s) {
_status = s.first;
_reload();
},
),
]),
Expanded(
child: Loader(
future: _data,
onRetry: _reload,
builder: (context, apps) => apps.isEmpty
? const EmptyState('No applications here.', icon: Icons.how_to_reg_outlined)
: ListView.separated(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
itemCount: apps.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (context, i) => _card(apps[i]),
),
),
),
],
);
Widget _card(CreatorApplication a) {
final small = Theme.of(context).textTheme.bodySmall;
final open = a.status == 'pending' || a.status == 'needs_info';
Widget fact(String label, String? value, {String? link}) => value == null || value.isEmpty
? const SizedBox.shrink()
: Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: 120, child: Text(label, style: small)),
Expanded(
child: link == null
? SelectableText(value)
: InkWell(
onTap: () => launchUrl(Uri.parse(link)),
child: Text(value, style: const TextStyle(color: NkColors.sun, decoration: TextDecoration.underline)),
),
),
],
),
);
String? web(String? s) => s == null || s.isEmpty ? null : (s.startsWith('http') ? s : 'https://$s');
String? instagram(String? s) =>
s == null || s.isEmpty ? null : (s.startsWith('http') ? s : 'https://instagram.com/${s.replaceAll('@', '')}');
String? whatsapp(String? s) =>
s == null || s.isEmpty ? null : 'https://wa.me/${s.replaceAll(RegExp(r'[^0-9]'), '')}';
return Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(a.stageName, style: Theme.of(context).textTheme.titleLarge),
Text('Applied ${formatDate(a.createdAt)}', style: small),
],
),
),
if (a.idDocumentPath != null)
OutlinedButton.icon(
onPressed: () => _openDocument(a.idDocumentPath!),
icon: const Icon(Icons.badge_outlined, size: 18),
label: const Text('View ID'),
)
else
const Tag('No ID uploaded', color: NkColors.roofRed),
],
),
const SizedBox(height: 14),
fact('Real name', a.realName),
fact('Account', [?a.displayName, ?a.email].join(' · ')),
fact('Phone', a.phone),
fact('Village / hatti', a.hattiName),
fact('Instagram', a.instagram, link: instagram(a.instagram)),
fact('WhatsApp', a.whatsapp, link: whatsapp(a.whatsapp)),
for (final l in a.workLinks) fact('Work', l, link: web(l)),
fact('Their songs', a.claimedSongs),
fact('Message', a.message),
fact('Review note', a.reviewNote),
if (open) ...[
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilledButton.icon(
onPressed: () => _approve(a),
icon: const Icon(Icons.verified_outlined, size: 18),
label: const Text('Approve'),
),
if (a.status == 'pending')
OutlinedButton(onPressed: () => _decline(a, 'needs_info'), child: const Text('Ask for more')),
OutlinedButton(onPressed: () => _decline(a, 'rejected'), child: const Text('Reject')),
],
),
],
],
),
),
);
}
}