feat(flutter): settings screen + 4-icon home AppBar
- models/my_profile.dart (MyProfile + ListenBrainzStatus) - api/endpoints/settings.dart (profile / password / listenbrainz CRUD) - settings/settings_screen.dart with three sections: - Profile: display_name + email, Save profile - Password: current + new + confirm, validates >=8 + match - ListenBrainz: token paste + scrobble-enabled toggle - /settings route + Settings icon on home AppBar - Home AppBar now: Library + Playlists + Search + Settings (was just Search)
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../models/my_profile.dart';
|
||||
|
||||
class SettingsApi {
|
||||
SettingsApi(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
Future<MyProfile> getProfile() async {
|
||||
final r = await _dio.get<Map<String, dynamic>>('/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<MyProfile> updateProfile({String? displayName, String? email}) async {
|
||||
final body = <String, dynamic>{};
|
||||
if (displayName != null) body['display_name'] = displayName;
|
||||
if (email != null) body['email'] = email;
|
||||
final r = await _dio.put<Map<String, dynamic>>(
|
||||
'/api/me/profile',
|
||||
data: body,
|
||||
);
|
||||
return MyProfile.fromJson(r.data ?? const {});
|
||||
}
|
||||
|
||||
Future<void> changePassword({
|
||||
required String current,
|
||||
required String next,
|
||||
}) async {
|
||||
await _dio.put<void>('/api/me/password', data: {
|
||||
'current_password': current,
|
||||
'new_password': next,
|
||||
});
|
||||
}
|
||||
|
||||
Future<ListenBrainzStatus> getListenBrainz() async {
|
||||
final r = await _dio.get<Map<String, dynamic>>('/api/me/listenbrainz');
|
||||
return ListenBrainzStatus.fromJson(r.data ?? const {});
|
||||
}
|
||||
|
||||
Future<ListenBrainzStatus> setListenBrainzToken(String token) async {
|
||||
final r = await _dio.put<Map<String, dynamic>>(
|
||||
'/api/me/listenbrainz',
|
||||
data: {'token': token},
|
||||
);
|
||||
return ListenBrainzStatus.fromJson(r.data ?? const {});
|
||||
}
|
||||
|
||||
Future<ListenBrainzStatus> setListenBrainzEnabled(bool enabled) async {
|
||||
final r = await _dio.put<Map<String, dynamic>>(
|
||||
'/api/me/listenbrainz',
|
||||
data: {'enabled': enabled},
|
||||
);
|
||||
return ListenBrainzStatus.fromJson(r.data ?? const {});
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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<String, dynamic> 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<String, dynamic> j) =>
|
||||
ListenBrainzStatus(
|
||||
enabled: j['enabled'] as bool? ?? false,
|
||||
tokenSet: j['token_set'] as bool? ?? false,
|
||||
lastScrobbledAt: j['last_scrobbled_at'] as String?,
|
||||
);
|
||||
}
|
||||
@@ -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<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)),
|
||||
),
|
||||
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<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 _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),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user