diff --git a/flutter_client/lib/api/endpoints/settings.dart b/flutter_client/lib/api/endpoints/settings.dart new file mode 100644 index 00000000..70cd1e57 --- /dev/null +++ b/flutter_client/lib/api/endpoints/settings.dart @@ -0,0 +1,57 @@ +import 'package:dio/dio.dart'; + +import '../../models/my_profile.dart'; + +class SettingsApi { + SettingsApi(this._dio); + final Dio _dio; + + Future getProfile() async { + final r = await _dio.get>('/api/me'); + return MyProfile.fromJson(r.data ?? const {}); + } + + /// Pass only the fields you want to change; server merges. Empty + /// strings clear; null leaves the existing value alone. + Future updateProfile({String? displayName, String? email}) async { + final body = {}; + if (displayName != null) body['display_name'] = displayName; + if (email != null) body['email'] = email; + final r = await _dio.put>( + '/api/me/profile', + data: body, + ); + return MyProfile.fromJson(r.data ?? const {}); + } + + Future changePassword({ + required String current, + required String next, + }) async { + await _dio.put('/api/me/password', data: { + 'current_password': current, + 'new_password': next, + }); + } + + Future getListenBrainz() async { + final r = await _dio.get>('/api/me/listenbrainz'); + return ListenBrainzStatus.fromJson(r.data ?? const {}); + } + + Future setListenBrainzToken(String token) async { + final r = await _dio.put>( + '/api/me/listenbrainz', + data: {'token': token}, + ); + return ListenBrainzStatus.fromJson(r.data ?? const {}); + } + + Future setListenBrainzEnabled(bool enabled) async { + final r = await _dio.put>( + '/api/me/listenbrainz', + data: {'enabled': enabled}, + ); + return ListenBrainzStatus.fromJson(r.data ?? const {}); + } +} diff --git a/flutter_client/lib/library/home_screen.dart b/flutter_client/lib/library/home_screen.dart index cb6977cd..cc058f0f 100644 --- a/flutter_client/lib/library/home_screen.dart +++ b/flutter_client/lib/library/home_screen.dart @@ -29,6 +29,11 @@ class HomeScreen extends ConsumerWidget { elevation: 0, title: Text('Minstrel', style: TextStyle(color: fs.parchment)), actions: [ + IconButton( + icon: Icon(Icons.library_music, color: fs.parchment), + tooltip: 'Library', + onPressed: () => context.push('/library'), + ), IconButton( icon: Icon(Icons.queue_music, color: fs.parchment), tooltip: 'Playlists', @@ -39,6 +44,11 @@ class HomeScreen extends ConsumerWidget { tooltip: 'Search', onPressed: () => context.push('/search'), ), + IconButton( + icon: Icon(Icons.settings, color: fs.parchment), + tooltip: 'Settings', + onPressed: () => context.push('/settings'), + ), ], ), body: SafeArea( diff --git a/flutter_client/lib/models/my_profile.dart b/flutter_client/lib/models/my_profile.dart new file mode 100644 index 00000000..0c6ceb5c --- /dev/null +++ b/flutter_client/lib/models/my_profile.dart @@ -0,0 +1,49 @@ +/// Mirrors web/src/lib/api/me.ts MyProfile. The `display_name` and +/// `email` are nullable — server returns null when the user hasn't +/// set them yet (registration only requires a username). +class MyProfile { + const MyProfile({ + required this.id, + required this.username, + this.displayName, + this.email, + required this.isAdmin, + }); + + final String id; + final String username; + final String? displayName; + final String? email; + final bool isAdmin; + + factory MyProfile.fromJson(Map j) => MyProfile( + id: j['id'] as String? ?? '', + username: j['username'] as String? ?? '', + displayName: j['display_name'] as String?, + email: j['email'] as String?, + isAdmin: j['is_admin'] as bool? ?? false, + ); +} + +/// Mirrors web/src/lib/api/listenbrainz.ts LBStatus. +class ListenBrainzStatus { + const ListenBrainzStatus({ + required this.enabled, + required this.tokenSet, + this.lastScrobbledAt, + }); + + final bool enabled; + /// True when the user has stored a token (token itself is never read + /// back from the server). UI uses this to show "token saved" vs + /// "no token" without exposing the value. + final bool tokenSet; + final String? lastScrobbledAt; + + factory ListenBrainzStatus.fromJson(Map j) => + ListenBrainzStatus( + enabled: j['enabled'] as bool? ?? false, + tokenSet: j['token_set'] as bool? ?? false, + lastScrobbledAt: j['last_scrobbled_at'] as String?, + ); +} diff --git a/flutter_client/lib/settings/settings_screen.dart b/flutter_client/lib/settings/settings_screen.dart new file mode 100644 index 00000000..ab27f0bb --- /dev/null +++ b/flutter_client/lib/settings/settings_screen.dart @@ -0,0 +1,453 @@ +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), + ), + ); +} diff --git a/flutter_client/lib/shared/routing.dart b/flutter_client/lib/shared/routing.dart index 79972043..8becfdb7 100644 --- a/flutter_client/lib/shared/routing.dart +++ b/flutter_client/lib/shared/routing.dart @@ -15,6 +15,7 @@ import '../player/queue_screen.dart'; import '../playlists/playlist_detail_screen.dart'; import '../playlists/playlists_list_screen.dart'; import '../search/search_screen.dart'; +import '../settings/settings_screen.dart'; import 'widgets/version_gate.dart'; /// Exposed as a Provider so its single argument is a real `Ref` (the @@ -59,6 +60,7 @@ GoRouter buildRouter(Ref ref) { GoRoute(path: '/queue', builder: (_, __) => const QueueScreen()), GoRoute(path: '/search', builder: (_, __) => const SearchScreen()), GoRoute(path: '/library', builder: (_, __) => const LibraryScreen()), + GoRoute(path: '/settings', builder: (_, __) => const SettingsScreen()), GoRoute(path: '/playlists', builder: (_, __) => const PlaylistsListScreen()), GoRoute( path: '/playlists/:id',