mobile-music/lib/screens/settings_screen.dart
2026-07-29 22:41:38 -04:00

468 lines
16 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../settings/settings_store.dart';
import '../state/providers.dart';
import '../subsonic/credentials.dart';
import '../theme/tokens.dart';
import '../widgets/hairline_panel.dart';
import 'connect_sheet.dart';
/// Audio-quality settings: independent streaming and download knobs, both
/// driven by Subsonic's `stream` transcode params (see `settings/settings_store.dart`).
class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(settingsProvider);
final controller = ref.read(settingsProvider.notifier);
final conn = ref.watch(connectionProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Settings',
style: TextStyle(fontWeight: FontWeight.w700)),
),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.all(TimbreSpacing.lg),
children: [
HairlinePanel(
title: 'Streaming',
active: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _Caption(
'Quality used when playing tracks that are not downloaded.'),
const SizedBox(height: TimbreSpacing.md),
_ChoiceChips<int>(
label: 'Max bitrate',
values: AppSettings.bitrateChoices,
selected: settings.streamMaxBitRate,
labelFor: AppSettings.bitrateLabel,
onSelect: controller.setStreamMaxBitRate,
),
],
),
),
const SizedBox(height: TimbreSpacing.xl),
HairlinePanel(
title: 'Downloads',
active: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _Caption(
'Quality used when saving tracks for offline playback. '
'"Original" keeps the source file (best quality, largest).'),
const SizedBox(height: TimbreSpacing.md),
_ChoiceChips<int>(
label: 'Max bitrate',
values: AppSettings.bitrateChoices,
selected: settings.downloadMaxBitRate,
labelFor: AppSettings.bitrateLabel,
onSelect: controller.setDownloadMaxBitRate,
),
const SizedBox(height: TimbreSpacing.lg),
_ChoiceChips<String?>(
label: 'Format',
values: AppSettings.formatChoices,
selected: settings.downloadFormat,
labelFor: AppSettings.formatLabel,
onSelect: controller.setDownloadFormat,
),
],
),
),
const SizedBox(height: TimbreSpacing.xl),
HairlinePanel(
title: 'Appearance',
active: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _Caption(
'By default the accent color is drawn from the playing '
'album art. Turn on a static accent to pin one color.'),
const SizedBox(height: TimbreSpacing.md),
_ChoiceChips<bool>(
label: 'Static accent',
values: const [false, true],
selected: settings.useStaticAccent,
labelFor: (v) => v ? 'On' : 'Off',
onSelect: controller.setUseStaticAccent,
),
if (settings.useStaticAccent) ...[
const SizedBox(height: TimbreSpacing.lg),
_ColorChips(
label: 'Accent color',
values: AppSettings.accentChoices,
selected: settings.staticAccentColor,
onSelect: controller.setStaticAccentColor,
),
],
const SizedBox(height: TimbreSpacing.lg),
const _Caption(
'Show Now Playing as an animated cassette — cover art on '
'the label, reels that spin and wind with the track.'),
const SizedBox(height: TimbreSpacing.md),
_ChoiceChips<bool>(
label: 'Now Playing art',
values: const [false, true],
selected: settings.nowPlayingCassette,
labelFor: (v) => v ? 'Cassette' : 'Album cover',
onSelect: controller.setNowPlayingCassette,
),
],
),
),
const SizedBox(height: TimbreSpacing.xl),
HairlinePanel(
title: 'Browse',
active: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _Caption('Which view the Browse tab opens on.'),
const SizedBox(height: TimbreSpacing.md),
_ChoiceChips<BrowseMode>(
label: 'Default view',
values: BrowseMode.values,
selected: settings.defaultBrowseMode,
labelFor: AppSettings.browseModeLabel,
onSelect: controller.setDefaultBrowseMode,
),
],
),
),
const SizedBox(height: TimbreSpacing.xl),
HairlinePanel(
title: 'Search',
active: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _Caption(
'"Standard" matches only names/titles that contain your '
'query. "Discovery" uses the server\'s broader matching.'),
const SizedBox(height: TimbreSpacing.md),
_ChoiceChips<SearchMode>(
label: 'Search mode',
values: const [SearchMode.standard, SearchMode.discovery],
selected: settings.searchMode,
labelFor: AppSettings.searchModeLabel,
onSelect: controller.setSearchMode,
),
],
),
),
const SizedBox(height: TimbreSpacing.xl),
HairlinePanel(
title: 'Servers',
active: true,
trailing:
conn.servers.isNotEmpty ? '(${conn.servers.length})' : null,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _Caption(
'Save multiple servers and switch the active one. Only '
'one is connected at a time.'),
const SizedBox(height: TimbreSpacing.sm),
if (conn.servers.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: TimbreSpacing.sm),
child: Text('No servers saved yet.',
style: TextStyle(color: TimbreColors.dimmed)),
)
else
for (final s in conn.servers)
_ServerManageRow(
creds: s,
active: s.id == conn.activeId,
onActivate: () => ref
.read(connectionProvider.notifier)
.switchTo(s.id),
onEdit: () =>
showConnectSheet(context, initial: s),
onDelete: () => ref
.read(connectionProvider.notifier)
.removeServer(s.id),
),
const SizedBox(height: TimbreSpacing.sm),
InkWell(
onTap: () => showConnectSheet(context),
child: const Padding(
padding:
EdgeInsets.symmetric(vertical: TimbreSpacing.md),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.add,
size: 16, color: TimbreColors.foreground),
SizedBox(width: TimbreSpacing.sm),
Text('Add server',
style:
TextStyle(color: TimbreColors.foreground)),
],
),
),
),
],
),
),
],
),
),
);
}
}
/// A saved-server row in Settings: activate (tap the label), edit, or delete.
class _ServerManageRow extends StatelessWidget {
const _ServerManageRow({
required this.creds,
required this.active,
required this.onActivate,
required this.onEdit,
required this.onDelete,
});
final SubsonicCredentials creds;
final bool active;
final VoidCallback onActivate;
final VoidCallback onEdit;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) {
final accent = Theme.of(context).colorScheme.primary;
return Row(
children: [
Expanded(
child: InkWell(
onTap: onActivate,
child: Container(
constraints: const BoxConstraints(
minHeight: TimbreSpacing.minTouchTarget),
alignment: Alignment.centerLeft,
child: Row(
children: [
Text(active ? '● ' : '○ ',
style: TextStyle(
color: active ? accent : TimbreColors.dimmed)),
Expanded(
child: Text(
creds.display,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: TimbreColors.foreground,
fontWeight:
active ? FontWeight.w700 : FontWeight.w400,
),
),
),
],
),
),
),
),
IconButton(
tooltip: 'Edit',
onPressed: onEdit,
icon: const Icon(Icons.edit, size: 16, color: TimbreColors.dimmed),
),
IconButton(
tooltip: 'Delete',
onPressed: onDelete,
icon:
const Icon(Icons.delete_outline, size: 18, color: TimbreColors.dimmed),
),
],
);
}
}
class _Caption extends StatelessWidget {
const _Caption(this.text);
final String text;
@override
Widget build(BuildContext context) => Text(
text,
style: const TextStyle(color: TimbreColors.dimmed, fontSize: 12),
);
}
/// A labelled row of selectable value chips — the app's underline-accent
/// language applied to compact bordered chips.
class _ChoiceChips<T> extends StatelessWidget {
const _ChoiceChips({
required this.label,
required this.values,
required this.selected,
required this.labelFor,
required this.onSelect,
});
final String label;
final List<T> values;
final T selected;
final String Function(T) labelFor;
final ValueChanged<T> onSelect;
@override
Widget build(BuildContext context) {
final accent = Theme.of(context).colorScheme.primary;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(color: TimbreColors.foreground)),
const SizedBox(height: TimbreSpacing.sm),
Wrap(
spacing: TimbreSpacing.sm,
runSpacing: TimbreSpacing.sm,
children: [
for (final v in values)
_Chip(
text: labelFor(v),
active: v == selected,
accent: accent,
onTap: () => onSelect(v),
),
],
),
],
);
}
}
class _Chip extends StatelessWidget {
const _Chip({
required this.text,
required this.active,
required this.accent,
required this.onTap,
});
final String text;
final bool active;
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.lg,
vertical: TimbreSpacing.md,
),
decoration: BoxDecoration(
border: Border.all(
color: active ? accent : TimbreColors.border,
),
color: active ? accent.withValues(alpha: 0.12) : TimbreColors.surface,
),
child: Text(
text,
style: TextStyle(
color: active ? accent : TimbreColors.foreground,
fontWeight: active ? FontWeight.w700 : FontWeight.w400,
),
),
),
);
}
}
/// A row of accent-color chips — like [_ChoiceChips] but each chip shows a
/// color swatch and the color's own hue drives the active border/fill.
class _ColorChips extends StatelessWidget {
const _ColorChips({
required this.label,
required this.values,
required this.selected,
required this.onSelect,
});
final String label;
final List<AccentChoice> values;
final Color selected;
final ValueChanged<Color> onSelect;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(color: TimbreColors.foreground)),
const SizedBox(height: TimbreSpacing.sm),
Wrap(
spacing: TimbreSpacing.sm,
runSpacing: TimbreSpacing.sm,
children: [
for (final c in values)
_ColorChip(
choice: c,
active: c.color == selected,
onTap: () => onSelect(c.color),
),
],
),
],
);
}
}
class _ColorChip extends StatelessWidget {
const _ColorChip({
required this.choice,
required this.active,
required this.onTap,
});
final AccentChoice choice;
final bool active;
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.lg,
vertical: TimbreSpacing.md,
),
decoration: BoxDecoration(
border: Border.all(
color: active ? choice.color : TimbreColors.border,
),
color: active
? choice.color.withValues(alpha: 0.12)
: TimbreColors.surface,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(width: 12, height: 12, color: choice.color),
const SizedBox(width: TimbreSpacing.sm),
Text(
choice.name,
style: TextStyle(
color: active ? choice.color : TimbreColors.foreground,
fontWeight: active ? FontWeight.w700 : FontWeight.w400,
),
),
],
),
),
);
}
}