This commit is contained in:
Forrest 2026-08-04 16:08:51 -04:00
parent d558aba246
commit 3bd713d667
17 changed files with 1566 additions and 132 deletions

View file

@ -0,0 +1,370 @@
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<AlbumSort>(
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<String?>(
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<int?>(
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<TrackSort>(
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<String?>(
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<int?>(
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);
}
},
),
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<Widget> 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: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.close, size: 14, color: TimbreColors.dimmed),
SizedBox(width: TimbreSpacing.xs),
Text('Clear', style: TextStyle(color: TimbreColors.dimmed)),
],
),
),
);
}
}
/// One option in a picker sheet.
class _Opt<T> {
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<T> {
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<T>?> _showPicker<T>(
BuildContext context, {
required String title,
required List<_Opt<T>> options,
required T current,
}) {
final accent = Theme.of(context).colorScheme.primary;
return showModalBottomSheet<_Picked<T>>(
context: context,
backgroundColor: TimbreColors.background,
isScrollControlled: true,
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<T>(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),
],
),
),
);
}
}