| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341 |
- 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<List<JellyfinGenreSelection>?> showJellyfinGenreDialog(
- BuildContext context, {
- required JellyfinLibraryService library,
- required List<JellyfinGenreSelection> selected,
- }) {
- return showDialog<List<JellyfinGenreSelection>>(
- 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<JellyfinGenreSelection> selected;
- @override
- State<JellyfinGenreDialog> createState() => _JellyfinGenreDialogState();
- }
- class _JellyfinGenreDialogState extends State<JellyfinGenreDialog> {
- static const _maxSelections = 5;
- final TextEditingController _search = TextEditingController();
- List<JellyfinGenreFamily>? _families;
- final Set<String> _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<void> _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 <JellyfinGenreFamily>[];
- 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,
- ),
- ),
- ],
- ),
- ),
- ],
- ),
- ),
- );
- }
- }
|