import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_lucide/flutter_lucide.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../api/endpoints/settings.dart'; import '../api/errors.dart'; import '../library/library_providers.dart' show dioProvider; import '../models/my_profile.dart'; import '../shared/widgets/main_app_bar_actions.dart'; import '../theme/theme_extension.dart'; import 'about_section.dart'; import 'storage_section.dart'; import '../theme/theme_mode_provider.dart'; final _settingsApiProvider = FutureProvider((ref) async { return SettingsApi(await ref.watch(dioProvider.future)); }); final _profileProvider = FutureProvider((ref) async { return (await ref.watch(_settingsApiProvider.future)).getProfile(); }); final _lbStatusProvider = FutureProvider((ref) async { return (await ref.watch(_settingsApiProvider.future)).getListenBrainz(); }); class SettingsScreen extends ConsumerWidget { const SettingsScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; return Scaffold( backgroundColor: fs.obsidian, appBar: AppBar( backgroundColor: fs.obsidian, elevation: 0, leading: IconButton( icon: Icon(LucideIcons.arrow_left, color: fs.parchment), onPressed: () => context.pop(), ), title: Text('Settings', style: TextStyle(color: fs.parchment)), actions: const [MainAppBarActions(currentRoute: '/settings')], ), body: ListView( padding: const EdgeInsets.symmetric(vertical: 8), children: const [ _ProfileSection(), _Divider(), _RequestsSection(), _Divider(), _AppearanceSection(), _Divider(), StorageSection(), _Divider(), _PasswordSection(), _Divider(), _ListenBrainzSection(), _Divider(), AboutSection(), _AdminSection(), SizedBox(height: 96), ], ), ); } } class _Divider extends StatelessWidget { const _Divider(); @override Widget build(BuildContext context) { final fs = Theme.of(context).extension()!; return Padding( padding: const EdgeInsets.symmetric(vertical: 16), child: Container(height: 1, color: fs.iron), ); } } class _SectionHeader extends StatelessWidget { const _SectionHeader(this.label); final String label; @override Widget build(BuildContext context) { final fs = Theme.of(context).extension()!; return Padding( padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), child: Text( label, style: TextStyle( color: fs.parchment, fontSize: 16, fontWeight: FontWeight.w500, ), ), ); } } class _RequestsSection extends StatelessWidget { const _RequestsSection(); @override Widget build(BuildContext context) { final fs = Theme.of(context).extension()!; return ListTile( key: const Key('settings_requests_card'), leading: Icon(LucideIcons.list_music, color: fs.parchment), title: Text( 'My requests', style: TextStyle( color: fs.parchment, fontFamily: 'Fraunces', fontSize: 18, ), ), subtitle: Text( "Track what you've asked Minstrel to add", style: TextStyle(color: fs.ash), ), trailing: Icon(LucideIcons.chevron_right, color: fs.ash), onTap: () => context.push('/requests'), ); } } /// Renders an "Admin" entry only when the current profile has /// `is_admin = true`. Returns an empty SizedBox otherwise so the /// const-children layout above stays valid. class _AdminSection extends ConsumerWidget { const _AdminSection(); @override Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; final profile = ref.watch(_profileProvider).value; if (profile == null || !profile.isAdmin) return const SizedBox.shrink(); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const _Divider(), ListTile( key: const Key('settings_admin_card'), leading: Icon(LucideIcons.shield, color: fs.parchment), title: Text( 'Admin', style: TextStyle( color: fs.parchment, fontFamily: 'Fraunces', fontSize: 18, ), ), subtitle: Text( 'Manage requests, quarantine, and users', style: TextStyle(color: fs.ash), ), trailing: Icon(LucideIcons.chevron_right, color: fs.ash), onTap: () => context.push('/admin'), ), ], ); } } class _ProfileSection extends ConsumerStatefulWidget { const _ProfileSection(); @override ConsumerState<_ProfileSection> createState() => _ProfileSectionState(); } class _ProfileSectionState extends ConsumerState<_ProfileSection> { final _displayName = TextEditingController(); final _email = TextEditingController(); bool _initialized = false; bool _saving = false; @override void dispose() { _displayName.dispose(); _email.dispose(); super.dispose(); } Future _save() async { final fs = Theme.of(context).extension()!; setState(() => _saving = true); try { final api = await ref.read(_settingsApiProvider.future); await api.updateProfile( displayName: _displayName.text.trim(), email: _email.text.trim(), ); ref.invalidate(_profileProvider); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: const Text('Profile saved.'), backgroundColor: fs.iron, ), ); } } on DioException catch (e) { if (mounted) { final msg = ApiError.fromDio(e).code; ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text('Save failed: $msg'), backgroundColor: fs.error, )); } } finally { if (mounted) setState(() => _saving = false); } } @override Widget build(BuildContext context) { final fs = Theme.of(context).extension()!; final profile = ref.watch(_profileProvider); return profile.when( loading: () => const Padding( padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator()), ), error: (e, _) => Padding( padding: const EdgeInsets.all(16), child: Text('$e', style: TextStyle(color: fs.error)), ), data: (p) { if (!_initialized) { _displayName.text = p.displayName ?? ''; _email.text = p.email ?? ''; _initialized = true; } return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ const _SectionHeader('Profile'), Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 4), child: Text( 'Signed in as ${p.username}${p.isAdmin ? " · admin" : ""}', style: TextStyle(color: fs.ash, fontSize: 12), ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: TextField( controller: _displayName, style: TextStyle(color: fs.parchment), decoration: _inputDecoration(fs, 'Display name'), ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: TextField( controller: _email, style: TextStyle(color: fs.parchment), keyboardType: TextInputType.emailAddress, decoration: _inputDecoration(fs, 'Email (for password reset)'), ), ), Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), child: FilledButton( onPressed: _saving ? null : _save, style: FilledButton.styleFrom( backgroundColor: fs.accent, foregroundColor: fs.parchment, ), child: Text(_saving ? 'Saving…' : 'Save profile'), ), ), ]); }, ); } } class _PasswordSection extends ConsumerStatefulWidget { const _PasswordSection(); @override ConsumerState<_PasswordSection> createState() => _PasswordSectionState(); } class _PasswordSectionState extends ConsumerState<_PasswordSection> { final _current = TextEditingController(); final _next = TextEditingController(); final _confirm = TextEditingController(); bool _saving = false; @override void dispose() { _current.dispose(); _next.dispose(); _confirm.dispose(); super.dispose(); } Future _change() async { final fs = Theme.of(context).extension()!; if (_next.text != _confirm.text) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: const Text('New passwords do not match.'), backgroundColor: fs.error, )); return; } if (_next.text.length < 8) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: const Text('Password must be at least 8 characters.'), backgroundColor: fs.error, )); return; } setState(() => _saving = true); try { final api = await ref.read(_settingsApiProvider.future); await api.changePassword(current: _current.text, next: _next.text); _current.clear(); _next.clear(); _confirm.clear(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: const Text('Password changed.'), backgroundColor: fs.iron, )); } } on DioException catch (e) { if (mounted) { final msg = ApiError.fromDio(e).code; ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text('Change failed: $msg'), backgroundColor: fs.error, )); } } finally { if (mounted) setState(() => _saving = false); } } @override Widget build(BuildContext context) { final fs = Theme.of(context).extension()!; return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ const _SectionHeader('Password'), Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: TextField( controller: _current, obscureText: true, style: TextStyle(color: fs.parchment), decoration: _inputDecoration(fs, 'Current password'), ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: TextField( controller: _next, obscureText: true, style: TextStyle(color: fs.parchment), decoration: _inputDecoration(fs, 'New password'), ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: TextField( controller: _confirm, obscureText: true, style: TextStyle(color: fs.parchment), decoration: _inputDecoration(fs, 'Confirm new password'), ), ), Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), child: FilledButton( onPressed: _saving ? null : _change, style: FilledButton.styleFrom( backgroundColor: fs.accent, foregroundColor: fs.parchment, ), child: Text(_saving ? 'Changing…' : 'Change password'), ), ), ]); } } class _ListenBrainzSection extends ConsumerStatefulWidget { const _ListenBrainzSection(); @override ConsumerState<_ListenBrainzSection> createState() => _ListenBrainzSectionState(); } class _ListenBrainzSectionState extends ConsumerState<_ListenBrainzSection> { final _token = TextEditingController(); bool _saving = false; @override void dispose() { _token.dispose(); super.dispose(); } Future _saveToken() async { final fs = Theme.of(context).extension()!; if (_token.text.trim().isEmpty) return; setState(() => _saving = true); try { final api = await ref.read(_settingsApiProvider.future); await api.setListenBrainzToken(_token.text.trim()); _token.clear(); ref.invalidate(_lbStatusProvider); if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: const Text('Token saved.'), backgroundColor: fs.iron, )); } } on DioException catch (e) { if (mounted) { final msg = ApiError.fromDio(e).code; ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text('Save failed: $msg'), backgroundColor: fs.error, )); } } finally { if (mounted) setState(() => _saving = false); } } Future _toggleEnabled(bool value) async { final fs = Theme.of(context).extension()!; try { final api = await ref.read(_settingsApiProvider.future); await api.setListenBrainzEnabled(value); ref.invalidate(_lbStatusProvider); } on DioException catch (e) { if (mounted) { final msg = ApiError.fromDio(e).code; ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text('Toggle failed: $msg'), backgroundColor: fs.error, )); } } } @override Widget build(BuildContext context) { final fs = Theme.of(context).extension()!; final status = ref.watch(_lbStatusProvider); return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ const _SectionHeader('ListenBrainz'), Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), child: Text( 'Get a token at listenbrainz.org/profile. Tokens are stored ' 'unencrypted on this server — treat as sensitive.', style: TextStyle(color: fs.ash, fontSize: 12), ), ), status.when( loading: () => const Padding( padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator()), ), error: (e, _) => Padding( padding: const EdgeInsets.all(16), child: Text('$e', style: TextStyle(color: fs.error)), ), data: (s) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: TextField( controller: _token, obscureText: true, style: TextStyle(color: fs.parchment), decoration: _inputDecoration( fs, s.tokenSet ? 'Update token (current is set)' : 'Paste token', ), ), ), Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), child: FilledButton( onPressed: _saving ? null : _saveToken, style: FilledButton.styleFrom( backgroundColor: fs.accent, foregroundColor: fs.parchment, ), child: Text(_saving ? 'Saving…' : 'Save token'), ), ), SwitchListTile( title: Text( 'Send my plays to ListenBrainz', style: TextStyle(color: fs.parchment), ), subtitle: s.lastScrobbledAt != null ? Text( 'Last scrobble: ${s.lastScrobbledAt}', style: TextStyle(color: fs.ash, fontSize: 11), ) : null, value: s.enabled, activeThumbColor: fs.accent, onChanged: s.tokenSet ? _toggleEnabled : null, ), ], ), ), ]); } } InputDecoration _inputDecoration(FabledSwordTheme fs, String label) { return InputDecoration( labelText: label, labelStyle: TextStyle(color: fs.ash), enabledBorder: OutlineInputBorder( borderSide: BorderSide(color: fs.iron), ), focusedBorder: OutlineInputBorder( borderSide: BorderSide(color: fs.accent), ), ); } class _AppearanceSection extends ConsumerWidget { const _AppearanceSection(); @override Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; final current = ref.watch(themeModeProvider).value ?? AppThemeMode.system; // RadioGroup is the post-Flutter-3.32 API: the ancestor owns // groupValue/onChanged so individual RadioListTiles don't have // to repeat them. Pre-3.32 RadioListTile.groupValue/onChanged // are deprecated. return RadioGroup( groupValue: current, onChanged: (m) { if (m != null) { ref.read(themeModeProvider.notifier).set(m); } }, child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ const _SectionHeader('Appearance'), for (final mode in AppThemeMode.values) RadioListTile( key: Key('appearance_${mode.name}'), title: Text(_label(mode), style: TextStyle(color: fs.parchment)), subtitle: mode == AppThemeMode.system ? Text('Match the device setting', style: TextStyle(color: fs.ash)) : null, value: mode, activeColor: fs.accent, ), ]), ); } String _label(AppThemeMode m) => switch (m) { AppThemeMode.system => 'System', AppThemeMode.light => 'Light', AppThemeMode.dark => 'Dark', }; }