import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../settings/settings_store.dart'; import '../state/providers.dart'; import '../theme/tokens.dart'; /// Filter/sort chip bar for the Albums browse grid. Sort is persisted; the /// genre/year filters are session-only (see `state/providers.dart`). class AlbumControlBar extends ConsumerWidget { const AlbumControlBar({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final sort = ref.watch(settingsProvider.select((s) => s.albumSort)); final filter = ref.watch(albumFilterProvider); final genres = ref.watch(albumGenresProvider); final years = ref.watch(albumYearsProvider); return _ControlBar( children: [ _ControlChip( label: 'Sort: ${AppSettings.albumSortLabel(sort)}', onTap: () async { final picked = await _showPicker( context, title: 'Sort albums', current: sort, options: [ for (final s in AlbumSort.values) _Opt(AppSettings.albumSortLabel(s), s), ], ); if (picked != null) { ref.read(settingsProvider.notifier).setAlbumSort(picked.value); } }, ), _ControlChip( label: filter.genre ?? 'Genre', active: filter.genre != null, onTap: genres.isEmpty ? null : () async { final picked = await _showPicker( context, title: 'Filter by genre', current: filter.genre, options: [ const _Opt('All genres', null), for (final g in genres) _Opt(g, g), ], ); if (picked != null) { ref.read(albumFilterProvider.notifier).state = filter.copyWith(genre: picked.value); } }, ), _ControlChip( label: filter.year?.toString() ?? 'Year', active: filter.year != null, onTap: years.isEmpty ? null : () async { final picked = await _showPicker( context, title: 'Filter by year', current: filter.year, options: [ const _Opt('All years', null), for (final y in years) _Opt('$y', y), ], ); if (picked != null) { ref.read(albumFilterProvider.notifier).state = filter.copyWith(year: picked.value); } }, ), if (filter.isActive) _ClearChip(onTap: () => ref.read(albumFilterProvider.notifier).state = const BrowseFilter()), ], ); } } /// Filter/sort chip bar for the Tracks browse list. class TrackControlBar extends ConsumerWidget { const TrackControlBar({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final sort = ref.watch(settingsProvider.select((s) => s.trackSort)); final filter = ref.watch(trackFilterProvider); final genres = ref.watch(trackGenresProvider); final years = ref.watch(trackYearsProvider); return _ControlBar( children: [ _ControlChip( label: 'Sort: ${AppSettings.trackSortLabel(sort)}', onTap: () async { final picked = await _showPicker( context, title: 'Sort tracks', current: sort, options: [ for (final s in TrackSort.values) _Opt(AppSettings.trackSortLabel(s), s), ], ); if (picked != null) { ref.read(settingsProvider.notifier).setTrackSort(picked.value); } }, ), _ControlChip( label: filter.genre ?? 'Genre', active: filter.genre != null, onTap: genres.isEmpty ? null : () async { final picked = await _showPicker( context, title: 'Filter by genre', current: filter.genre, options: [ const _Opt('All genres', null), for (final g in genres) _Opt(g, g), ], ); if (picked != null) { ref.read(trackFilterProvider.notifier).state = filter.copyWith(genre: picked.value); } }, ), _ControlChip( label: filter.year?.toString() ?? 'Year', active: filter.year != null, onTap: years.isEmpty ? null : () async { final picked = await _showPicker( context, title: 'Filter by year', current: filter.year, options: [ const _Opt('All years', null), for (final y in years) _Opt('$y', y), ], ); if (picked != null) { ref.read(trackFilterProvider.notifier).state = filter.copyWith(year: picked.value); } }, ), _ControlChip( label: filter.minRating != null ? '${filter.minRating}+★' : 'Rating', active: filter.minRating != null, onTap: () async { final picked = await _showPicker( context, title: 'Minimum rating', current: filter.minRating, options: const [ _Opt('Any rating', null), _Opt('1+', 1), _Opt('2+', 2), _Opt('3+', 3), _Opt('4+', 4), _Opt('5 stars', 5), ], ); if (picked != null) { ref.read(trackFilterProvider.notifier).state = filter.copyWith(minRating: picked.value); } }, ), if (filter.isActive) _ClearChip(onTap: () => ref.read(trackFilterProvider.notifier).state = const BrowseFilter()), ], ); } } /// Horizontally-scrolling row of control chips. class _ControlBar extends StatelessWidget { const _ControlBar({required this.children}); final List children; @override Widget build(BuildContext context) { return SingleChildScrollView( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.md), child: Row( children: [ for (var i = 0; i < children.length; i++) ...[ if (i > 0) const SizedBox(width: TimbreSpacing.sm), children[i], ], ], ), ); } } /// A bordered dropdown-style chip; tints toward accent when [active] (a filter /// is applied). A null [onTap] renders it disabled/dimmed. class _ControlChip extends StatelessWidget { const _ControlChip({ required this.label, required this.onTap, this.active = false, }); final String label; final VoidCallback? onTap; final bool active; @override Widget build(BuildContext context) { final accent = Theme.of(context).colorScheme.primary; final enabled = onTap != null; final fg = !enabled ? TimbreColors.dimmed : (active ? accent : TimbreColors.foreground); return InkWell( onTap: onTap, child: Container( constraints: const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget), padding: const EdgeInsets.symmetric( horizontal: TimbreSpacing.md, vertical: TimbreSpacing.sm, ), decoration: BoxDecoration( border: Border.all(color: active ? accent : TimbreColors.border), color: active ? accent.withValues(alpha: 0.12) : TimbreColors.surface, ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text(label, style: TextStyle( color: fg, fontWeight: active ? FontWeight.w700 : FontWeight.w400)), Icon(Icons.arrow_drop_down, size: 18, color: fg), ], ), ), ); } } class _ClearChip extends StatelessWidget { const _ClearChip({required this.onTap}); final VoidCallback onTap; @override Widget build(BuildContext context) { return InkWell( onTap: onTap, child: Container( constraints: const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget), padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.md), alignment: Alignment.center, child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.close, size: 14, color: TimbreColors.dimmed), const SizedBox(width: TimbreSpacing.xs), Text('Clear', style: TextStyle(color: TimbreColors.dimmed)), ], ), ), ); } } /// One option in a picker sheet. class _Opt { const _Opt(this.label, this.value); final String label; final T value; } /// Wraps the picked value so a chosen `null` ("All") is distinguishable from a /// dismissed sheet (which resolves to a bare `null`). class _Picked { const _Picked(this.value); final T value; } /// A titled bottom-sheet list picker. Returns the wrapped selection, or null if /// the sheet was dismissed without a choice. Future<_Picked?> _showPicker( BuildContext context, { required String title, required List<_Opt> options, required T current, }) { final accent = Theme.of(context).colorScheme.primary; return showModalBottomSheet<_Picked>( context: context, backgroundColor: TimbreColors.background, isScrollControlled: true, // isScrollControlled removes the default height cap; constrain to 75% of // the window so a long genre/year list never fills the entire screen. constraints: BoxConstraints( maxHeight: MediaQuery.of(context).size.height * 0.75, ), builder: (ctx) => SafeArea( child: Padding( padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.lg), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.xl), child: Text(title, style: TextStyle(color: accent, fontWeight: FontWeight.w700)), ), const SizedBox(height: TimbreSpacing.md), Flexible( child: ListView( shrinkWrap: true, children: [ for (final o in options) _PickerTile( label: o.label, selected: o.value == current, accent: accent, onTap: () => Navigator.pop(ctx, _Picked(o.value)), ), ], ), ), ], ), ), ), ); } class _PickerTile extends StatelessWidget { const _PickerTile({ required this.label, required this.selected, required this.accent, required this.onTap, }); final String label; final bool selected; final Color accent; final VoidCallback onTap; @override Widget build(BuildContext context) { return InkWell( onTap: onTap, child: Container( constraints: const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget), padding: const EdgeInsets.symmetric( horizontal: TimbreSpacing.xl, vertical: TimbreSpacing.md, ), child: Row( children: [ Expanded( child: Text( label, style: TextStyle( color: selected ? accent : TimbreColors.foreground, fontWeight: selected ? FontWeight.w700 : FontWeight.w400, ), ), ), if (selected) Icon(Icons.check, size: 16, color: accent), ], ), ), ); } }