Files
bvandeusen 835592f073 refactor(ui): Lucide migration unit 2 — Icons.* -> LucideIcons.* sweep (#60)
Mechanical sweep across 30 files: every Material Icons.* replaced with
the signed-off Lucide equivalent + a flutter_lucide import per file.
Zero Material Icons.* remain in lib/; no unused imports.

Judgment-call mappings: album->disc_3, library_music->library_big,
playlist_play->list_video, graphic_eq->audio_lines,
system_update->download, restore->archive_restore,
download_done->circle_check_big.

track_actions_sheet like menu row: collapsed `liked ? favorite :
favorite_border` to a single LucideIcons.heart (the row's Like/Unlike
text label conveys state). Icon-only LikeButton + the notification keep
the filled-vs-outline shape per the design decision.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:43:05 -04:00

576 lines
18 KiB
Dart

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<SettingsApi>((ref) async {
return SettingsApi(await ref.watch(dioProvider.future));
});
final _profileProvider = FutureProvider<MyProfile>((ref) async {
return (await ref.watch(_settingsApiProvider.future)).getProfile();
});
final _lbStatusProvider = FutureProvider<ListenBrainzStatus>((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<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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<void> _save() async {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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<void> _change() async {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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<void> _saveToken() async {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
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<void> _toggleEnabled(bool value) async {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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<FabledSwordTheme>()!;
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<AppThemeMode>(
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<AppThemeMode>(
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',
};
}