import 'package:flutter/material.dart'; import 'api.dart'; import 'theme.dart'; import 'widgets.dart'; /// Chooses singers in order: type to search, pick a match, or add a new singer by name. class SingerPicker extends StatefulWidget { const SingerPicker({super.key, required this.artists, required this.selected, required this.onChanged}); final List artists; final List selected; final ValueChanged> onChanged; @override State createState() => _SingerPickerState(); } class _SingerPickerState extends State { late final _names = {for (final a in widget.artists) a.id: a.name}; bool _adding = false; void _add(String id) { if (widget.selected.contains(id)) return; widget.onChanged([...widget.selected, id]); } Future _createSinger(String name) async { setState(() => _adding = true); try { final id = await AdminApi.createArtist(name: name); _names[id] = name; _add(id); if (mounted) showMessage(context, 'Added singer "$name"'); } catch (e) { if (mounted) showError(context, e); } finally { if (mounted) setState(() => _adding = false); } } @override Widget build(BuildContext context) => Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (widget.selected.isNotEmpty) ...[ ReorderableListView( shrinkWrap: true, buildDefaultDragHandles: false, onReorderItem: (from, to) { final list = [...widget.selected]; list.insert(to, list.removeAt(from)); widget.onChanged(list); }, children: [ for (final (i, id) in widget.selected.indexed) ListTile( key: ValueKey(id), dense: true, contentPadding: EdgeInsets.zero, leading: ReorderableDragStartListener( index: i, child: const Icon(Icons.drag_indicator, color: NkColors.muted), ), title: Text(_names[id] ?? 'Unknown singer'), trailing: IconButton( tooltip: 'Remove', icon: const Icon(Icons.close, size: 18), onPressed: () => widget.onChanged([...widget.selected]..remove(id)), ), ), ], ), const SizedBox(height: 6), ], _SearchAdd( label: _adding ? 'Adding…' : 'Add a singer', options: [ for (final a in widget.artists) if (!widget.selected.contains(a.id)) (a.id, a.name, a.hattiName), ], onPick: _add, createLabel: (q) => 'New singer "$q"', onCreate: _createSinger, ), ], ); } /// Chooses one album, or none; can create a new album. class AlbumPicker extends StatefulWidget { const AlbumPicker({super.key, required this.albums, required this.albumId, required this.onChanged}); final List albums; final String? albumId; final ValueChanged onChanged; @override State createState() => _AlbumPickerState(); } class _AlbumPickerState extends State { late final _titles = {for (final a in widget.albums) a.id: a.title}; Future _create(String title) async { try { final id = await AdminApi.createAlbum(title: title); _titles[id] = title; widget.onChanged(id); if (mounted) showMessage(context, 'Added album "$title"'); } catch (e) { if (mounted) showError(context, e); } } @override Widget build(BuildContext context) { final current = widget.albumId; if (current != null) { return InputDecorator( decoration: const InputDecoration(labelText: 'Album'), child: Row( children: [ Expanded(child: Text(_titles[current] ?? 'Unknown album')), TextButton(onPressed: () => widget.onChanged(null), child: const Text('Change')), ], ), ); } return _SearchAdd( label: 'Album (type to search)', options: [for (final a in widget.albums) (a.id, a.title, a.year?.toString())], onPick: widget.onChanged, createLabel: (q) => 'New album "$q"', onCreate: _create, ); } } typedef _Option = (String id, String name, String? detail); class _SearchAdd extends StatelessWidget { const _SearchAdd({ required this.label, required this.options, required this.onPick, required this.createLabel, required this.onCreate, }); final String label; final List<_Option> options; final ValueChanged onPick; final String Function(String query) createLabel; final ValueChanged onCreate; static const _createId = '\u0000create'; @override Widget build(BuildContext context) => LayoutBuilder( builder: (context, box) => Autocomplete<_Option>( displayStringForOption: (o) => '', optionsBuilder: (value) { final q = value.text.trim().toLowerCase(); if (q.isEmpty) return const []; final matches = options.where((o) => o.$2.toLowerCase().contains(q)).take(12).toList() ..sort((a, b) => (a.$2.toLowerCase().startsWith(q) ? 0 : 1) .compareTo(b.$2.toLowerCase().startsWith(q) ? 0 : 1)); final exact = options.any((o) => o.$2.toLowerCase() == q); return [...matches, if (!exact) (_createId, value.text.trim(), null)]; }, onSelected: (o) => o.$1 == _createId ? onCreate(o.$2) : onPick(o.$1), fieldViewBuilder: (context, text, focus, submit) => TextField( controller: text, focusNode: focus, decoration: InputDecoration(labelText: label, prefixIcon: const Icon(Icons.search, color: NkColors.muted)), ), optionsViewBuilder: (context, select, items) => Align( alignment: Alignment.topLeft, child: Material( color: NkColors.raised, elevation: 6, borderRadius: BorderRadius.circular(12), child: ConstrainedBox( constraints: BoxConstraints(maxHeight: 280, maxWidth: box.maxWidth), child: ListView( shrinkWrap: true, padding: const EdgeInsets.symmetric(vertical: 6), children: [ for (final o in items) ListTile( dense: true, leading: Icon(o.$1 == _createId ? Icons.add : Icons.check_circle_outline, color: o.$1 == _createId ? NkColors.sun : NkColors.muted, size: 20), title: Text(o.$1 == _createId ? createLabel(o.$2) : o.$2), subtitle: o.$3 == null ? null : Text(o.$3!), onTap: () => select(o), ), ], ), ), ), ), ), ); }