import 'dart:async'; import 'package:flutter/material.dart'; import '../../core/theme/app_colors.dart'; import '../../core/widgets/app_icon.dart'; import '../../data/models/jellyfin_library.dart'; import '../../data/models/jellyfin_models.dart'; import '../../data/services/jellyfin_library_service.dart'; Future?> showJellyfinGenreDialog( BuildContext context, { required JellyfinLibraryService library, required List selected, }) { return showDialog>( context: context, builder: (_) => JellyfinGenreDialog(library: library, selected: selected), ); } /// Grouped multi-select for the Random row. At most five families; the server /// genre list is fetched each time so new tags appear after a library scan. class JellyfinGenreDialog extends StatefulWidget { const JellyfinGenreDialog({ super.key, required this.library, required this.selected, }); final JellyfinLibraryService library; final List selected; @override State createState() => _JellyfinGenreDialogState(); } class _JellyfinGenreDialogState extends State { static const _maxSelections = 5; final TextEditingController _search = TextEditingController(); List? _families; final Set _selectedIds = {}; bool _loading = true; String? _error; String _query = ''; @override void initState() { super.initState(); _selectedIds.addAll(widget.selected.map((selection) => selection.id)); unawaited(_load()); } @override void dispose() { _search.dispose(); super.dispose(); } Future _load() async { setState(() { _loading = true; _error = null; }); try { final families = await widget.library.genreFamilies(force: true); if (!mounted) return; final ids = families.map((family) => family.id).toSet(); setState(() { _families = families; _selectedIds.removeWhere((id) => !ids.contains(id)); _loading = false; }); } on JellyfinException catch (exception) { if (!mounted) return; setState(() { _loading = false; _error = exception.message; }); } catch (_) { if (!mounted) return; setState(() { _loading = false; _error = 'Could not load the music library.'; }); } } void _toggle(JellyfinGenreFamily family) { final selected = _selectedIds.contains(family.id); if (!selected && _selectedIds.length >= _maxSelections) return; setState(() { if (selected) { _selectedIds.remove(family.id); } else { _selectedIds.add(family.id); } }); } void _save() { final families = _families; if (families == null) return; final selections = families .where((family) => _selectedIds.contains(family.id)) .map( (family) => JellyfinGenreSelection(id: family.id, label: family.label), ) .toList(growable: false); Navigator.of(context).pop(selections); } @override Widget build(BuildContext context) { final colors = context.colors; final theme = Theme.of(context); final screen = MediaQuery.sizeOf(context); final width = (screen.width - 120).clamp(420.0, 680.0); final height = (screen.height - 160).clamp(320.0, 500.0); return AlertDialog( title: Row( children: [ Text('Random genres', style: theme.textTheme.titleLarge), const Spacer(), Text( '${_selectedIds.length} / $_maxSelections', style: theme.textTheme.labelLarge?.copyWith( color: _selectedIds.length >= _maxSelections ? colors.accent : colors.inkMuted, ), ), ], ), content: SizedBox( width: width, height: height, child: _buildContent(context), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel'), ), FilledButton( onPressed: _loading || _error != null ? null : _save, child: const Text('Save'), ), ], ); } Widget _buildContent(BuildContext context) { final colors = context.colors; final theme = Theme.of(context); if (_loading) { return const Center(child: CircularProgressIndicator()); } final error = _error; if (error != null) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( error, textAlign: TextAlign.center, style: theme.textTheme.bodyMedium?.copyWith( color: colors.inkMuted, ), ), const SizedBox(height: 10), TextButton( onPressed: _load, style: TextButton.styleFrom(foregroundColor: colors.accent), child: const Text('Retry'), ), ], ), ); } final families = _families ?? const []; if (families.isEmpty) { return Center( child: Text( 'No genres found in the music library.', style: theme.textTheme.bodyMedium?.copyWith(color: colors.inkMuted), ), ); } final query = _query.trim().toLowerCase(); final visible = query.isEmpty ? families : families .where( (family) => family.label.toLowerCase().contains(query) || family.rawGenres.any( (raw) => raw.toLowerCase().contains(query), ), ) .toList(growable: false); final atLimit = _selectedIds.length >= _maxSelections; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (families.length > 12) Padding( padding: const EdgeInsets.only(bottom: 8), child: TextField( controller: _search, onChanged: (value) => setState(() => _query = value), style: theme.textTheme.bodyMedium, decoration: InputDecoration( hintText: 'Search genres', isDense: true, prefixIcon: Padding( padding: const EdgeInsets.only(left: 2), child: SizedBox( width: 18, height: 18, child: Center( child: AppIcon( 'assets/icons/search.svg', size: 15, color: colors.inkFaint, ), ), ), ), prefixIconConstraints: const BoxConstraints( minWidth: 34, minHeight: 34, ), ), ), ), if (atLimit) Padding( padding: const EdgeInsets.only(bottom: 4), child: Text( 'Deselect one to choose another.', style: theme.textTheme.bodySmall?.copyWith( color: colors.inkMuted, ), ), ), Expanded( child: visible.isEmpty ? Center( child: Text( 'No genres match that search.', style: theme.textTheme.bodySmall?.copyWith( color: colors.inkMuted, ), ), ) : ListView.builder( itemCount: visible.length, itemBuilder: (context, index) { final family = visible[index]; final selected = _selectedIds.contains(family.id); final enabled = selected || !atLimit; return _GenreFamilyRow( family: family, selected: selected, enabled: enabled, onTap: enabled ? () => _toggle(family) : null, ); }, ), ), ], ); } } class _GenreFamilyRow extends StatelessWidget { const _GenreFamilyRow({ required this.family, required this.selected, required this.enabled, required this.onTap, }); final JellyfinGenreFamily family; final bool selected; final bool enabled; final VoidCallback? onTap; @override Widget build(BuildContext context) { final colors = context.colors; final theme = Theme.of(context); final count = family.rawCount; return InkWell( borderRadius: BorderRadius.circular(12), onTap: onTap, child: Padding( padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 2), child: Row( children: [ Checkbox( value: selected, onChanged: enabled ? (_) => onTap?.call() : null, activeColor: colors.accent, checkColor: colors.accentInk, side: BorderSide(color: colors.border), ), const SizedBox(width: 4), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( family.label, style: theme.textTheme.bodyLarge?.copyWith( color: enabled ? colors.ink : colors.inkFaint, ), ), Text( '$count ${count == 1 ? 'genre' : 'genres'}', style: theme.textTheme.bodySmall?.copyWith( color: colors.inkMuted, ), ), ], ), ), ], ), ), ); } }