import 'package:flutter/material.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'api.dart'; import 'theme.dart'; String creditLine(AdminTrack t) => t.singerNames.isEmpty ? 'No singer yet' : t.singerNames; String formatDuration(Duration d) { final s = d.inSeconds; return '${s ~/ 60}:${(s % 60).toString().padLeft(2, '0')}'; } String formatDate(DateTime d) { const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; final l = d.toLocal(); return '${l.day} ${months[l.month - 1]} ${l.year}'; } String errorText(Object e) => switch (e) { PostgrestException(:final message) => message, AuthException(:final message) => message, StorageException(:final message) => message, _ => '$e', }; void showMessage(BuildContext context, String text) => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text))); void showError(BuildContext context, Object e) => ScaffoldMessenger.of(context) .showSnackBar(SnackBar(content: Text(errorText(e)), backgroundColor: NkColors.roofRed)); Future confirm(BuildContext context, String title, String body, {String action = 'OK', bool danger = false}) async { final ok = await showDialog( context: context, builder: (context) => AlertDialog( title: Text(title), content: ConstrainedBox(constraints: const BoxConstraints(maxWidth: 420), child: Text(body)), actions: [ TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')), FilledButton( style: danger ? FilledButton.styleFrom(backgroundColor: NkColors.roofRed, foregroundColor: NkColors.cream) : null, onPressed: () => Navigator.pop(context, true), child: Text(action), ), ], ), ); return ok ?? false; } /// Title row at the top of each page. class PageHeader extends StatelessWidget { const PageHeader(this.title, {super.key, this.subtitle, this.actions = const []}); final String title; final String? subtitle; final List actions; @override Widget build(BuildContext context) => Padding( padding: const EdgeInsets.fromLTRB(24, 24, 24, 12), child: Wrap( alignment: WrapAlignment.spaceBetween, crossAxisAlignment: WrapCrossAlignment.center, spacing: 16, runSpacing: 12, children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text(title, style: Theme.of(context).textTheme.headlineSmall), if (subtitle != null) ...[ const SizedBox(height: 4), Text(subtitle!, style: Theme.of(context).textTheme.bodySmall), ], ], ), Wrap(spacing: 8, runSpacing: 8, children: actions), ], ), ); } class StatusChip extends StatelessWidget { const StatusChip(this.status, {super.key}); final SongStatus status; @override Widget build(BuildContext context) { final color = switch (status) { SongStatus.approved => NkColors.teaLeaf, SongStatus.pending => NkColors.sun, SongStatus.needsChanges => NkColors.info, SongStatus.draft => NkColors.muted, SongStatus.rejected || SongStatus.takenDown => NkColors.roofRed, }; return Tag(status.label, color: color); } } class Tag extends StatelessWidget { const Tag(this.text, {super.key, this.color = NkColors.muted}); final String text; final Color color; @override Widget build(BuildContext context) => Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3), decoration: BoxDecoration( color: color.withValues(alpha: 0.16), borderRadius: BorderRadius.circular(99), border: Border.all(color: color.withValues(alpha: 0.5)), ), child: Text(text, style: TextStyle(color: color, fontSize: 12, fontWeight: FontWeight.w700)), ); } /// Shows a spinner, an error with a retry button, or the loaded data. class Loader extends StatelessWidget { const Loader({super.key, required this.future, required this.onRetry, required this.builder}); final Future? future; final VoidCallback onRetry; final Widget Function(BuildContext context, T data) builder; @override Widget build(BuildContext context) => FutureBuilder( future: future, builder: (context, snap) { if (snap.hasError) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.cloud_off, color: NkColors.muted, size: 40), const SizedBox(height: 12), Text(errorText(snap.error!), textAlign: TextAlign.center), const SizedBox(height: 12), OutlinedButton(onPressed: onRetry, child: const Text('Try again')), ], ), ); } if (!snap.hasData) return const Center(child: CircularProgressIndicator()); return builder(context, snap.data as T); }, ); } class EmptyState extends StatelessWidget { const EmptyState(this.text, {super.key, this.icon = Icons.inbox_outlined}); final String text; final IconData icon; @override Widget build(BuildContext context) => Center( child: Padding( padding: const EdgeInsets.all(32), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, color: NkColors.muted, size: 40), const SizedBox(height: 12), Text(text, textAlign: TextAlign.center, style: const TextStyle(color: NkColors.muted)), ], ), ), ); } /// Search box that reports its text after a short pause in typing. class SearchField extends StatefulWidget { const SearchField({super.key, required this.hint, required this.onChanged, this.initial = '', this.width = 320}); final String hint; final String initial; final double width; final ValueChanged onChanged; @override State createState() => _SearchFieldState(); } class _SearchFieldState extends State { late final _text = TextEditingController(text: widget.initial); int _typing = 0; @override void dispose() { _text.dispose(); super.dispose(); } void _changed(String v) { final n = ++_typing; Future.delayed(const Duration(milliseconds: 350), () { if (mounted && n == _typing) widget.onChanged(v.trim()); }); } @override Widget build(BuildContext context) => SizedBox( width: widget.width, child: TextField( controller: _text, onChanged: _changed, onSubmitted: (v) => widget.onChanged(v.trim()), decoration: InputDecoration( hintText: widget.hint, prefixIcon: const Icon(Icons.search, color: NkColors.muted), suffixIcon: ListenableBuilder( listenable: _text, builder: (context, _) => _text.text.isEmpty ? const SizedBox.shrink() : IconButton( tooltip: 'Clear', icon: const Icon(Icons.close, size: 18), onPressed: () { _text.clear(); widget.onChanged(''); }, ), ), ), ), ); } /// A dialog body with a fixed width that scrolls when tall. class DialogBody extends StatelessWidget { const DialogBody({super.key, required this.children, this.width = 520}); final List children; final double width; @override Widget build(BuildContext context) => SizedBox( width: width, child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (final (i, c) in children.indexed) ...[if (i > 0) const SizedBox(height: 14), c], ], ), ), ); } String? trimmedOrNull(String s) => s.trim().isEmpty ? null : s.trim();