import 'package:dio/dio.dart'; import 'package:flutter/material.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 '../theme/theme_extension.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(Icons.arrow_back, color: fs.parchment), onPressed: () => context.pop(), ), title: Text('Settings', style: TextStyle(color: fs.parchment)), ), body: ListView( padding: const EdgeInsets.symmetric(vertical: 8), children: const [ _ProfileSection(), _Divider(), _PasswordSection(), _Divider(), _ListenBrainzSection(), 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 _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), ), ); }