d5c8d316c5
CI caught six issues against the new admin slice: - AsyncValue<T> in this Riverpod 3.3.1 codebase exposes `.value` (returns T?), not `.valueOrNull`. Switched the three admin readers to match the established convention (player_bar, queue_screen, now_playing_screen all use `.value`). - home_screen.dart still has ctx.push calls in _albumsRow / _artistsRow (carousel tap handlers) — restored the go_router import that Task 2 over-eagerly removed. - admin_user_edit_sheet.dart had a single-line `if (!await ...) return;` that violated curly_braces_in_flow_control_structures. Wrapped in braces. - admin_quarantine_item.dart doc comment had `<top>` placeholders that the analyzer flagged as unintended HTML. Rephrased without angle brackets. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
495 lines
15 KiB
Dart
495 lines
15 KiB
Dart
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 '../shared/widgets/main_app_bar_actions.dart';
|
|
import '../theme/theme_extension.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(Icons.arrow_back, 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(),
|
|
_PasswordSection(),
|
|
_Divider(),
|
|
_ListenBrainzSection(),
|
|
_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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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(Icons.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(Icons.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),
|
|
),
|
|
);
|
|
}
|