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( 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( label: 'Max bitrate', values: AppSettings.bitrateChoices, selected: settings.downloadMaxBitRate, labelFor: AppSettings.bitrateLabel, onSelect: controller.setDownloadMaxBitRate, ), const SizedBox(height: TimbreSpacing.lg), _ChoiceChips( 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( 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( 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( 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( 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 extends StatelessWidget { const _ChoiceChips({ required this.label, required this.values, required this.selected, required this.labelFor, required this.onSelect, }); final String label; final List values; final T selected; final String Function(T) labelFor; final ValueChanged 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 values; final Color selected; final ValueChanged 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, ), ), ], ), ), ); } }