Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9bf3b8a2f2 | |||
| d5c8d316c5 | |||
| 9f6ceb1731 | |||
| afcaf56b9c | |||
| 7d207e56f5 | |||
| 3e905db906 | |||
| bd1bcab12b | |||
| 8bb192fe54 | |||
| 408eed1f3d | |||
| 6b5d12f3a1 | |||
| 372e7eca86 | |||
| 79d00ba001 | |||
| 2b595c40cd | |||
| dfc08650e7 | |||
| fc0350cb96 | |||
| 5546787f78 | |||
| 6564d37a2a | |||
| 492460cf4a | |||
| c5dc3bd256 | |||
| 3f2822dfc6 | |||
| dfbeed01a6 | |||
| 1ad67dbe59 | |||
| da1cb7b590 | |||
| 66545bf8c3 | |||
| 5541171e94 | |||
| 147d6e280e | |||
| 1b3f2e254b |
@@ -21,7 +21,7 @@ A self-hosted music server that thinks for you. Smart shuffle, contextual likes,
|
||||
# compose.yaml
|
||||
services:
|
||||
minstrel:
|
||||
image: git.fabledsword.com/bvandeusen/minstrel:v1.0.0
|
||||
image: git.fabledsword.com/bvandeusen/minstrel:latest
|
||||
ports: ['4533:4533']
|
||||
volumes:
|
||||
- ./music:/music:ro
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../shared/widgets/main_app_bar_actions.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'admin_providers.dart';
|
||||
import 'widgets/admin_section_card.dart';
|
||||
|
||||
class AdminLandingScreen extends ConsumerWidget {
|
||||
const AdminLandingScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final counts = ref.watch(adminCountsProvider);
|
||||
return Scaffold(
|
||||
backgroundColor: fs.obsidian,
|
||||
appBar: AppBar(
|
||||
backgroundColor: fs.obsidian,
|
||||
elevation: 0,
|
||||
title: Text('Admin', style: TextStyle(color: fs.parchment)),
|
||||
actions: const [MainAppBarActions(currentRoute: '/admin')],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: counts.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(
|
||||
child: Text('$e', style: TextStyle(color: fs.error)),
|
||||
),
|
||||
data: (c) => RefreshIndicator(
|
||||
onRefresh: () async => ref.refresh(adminCountsProvider.future),
|
||||
child: ListView(
|
||||
children: [
|
||||
AdminSectionCard(
|
||||
key: const Key('admin_card_requests'),
|
||||
icon: Icons.inbox,
|
||||
title: 'Requests',
|
||||
subtitle: 'Approve or reject Lidarr requests',
|
||||
count: c.requests,
|
||||
onTap: () => context.push('/admin/requests'),
|
||||
),
|
||||
AdminSectionCard(
|
||||
key: const Key('admin_card_quarantine'),
|
||||
icon: Icons.warning_amber,
|
||||
title: 'Quarantine',
|
||||
subtitle: 'Resolve scan failures',
|
||||
count: c.quarantine,
|
||||
onTap: () => context.push('/admin/quarantine'),
|
||||
),
|
||||
AdminSectionCard(
|
||||
key: const Key('admin_card_users'),
|
||||
icon: Icons.people,
|
||||
title: 'Users',
|
||||
subtitle: 'Manage accounts and invites',
|
||||
count: c.users,
|
||||
onTap: () => context.push('/admin/users'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../api/endpoints/admin_invites.dart';
|
||||
import '../api/endpoints/admin_quarantine.dart';
|
||||
import '../api/endpoints/admin_requests.dart';
|
||||
import '../api/endpoints/admin_users.dart';
|
||||
import '../library/library_providers.dart' show dioProvider;
|
||||
import '../models/admin_quarantine_item.dart';
|
||||
import '../models/admin_request.dart';
|
||||
import '../models/admin_user.dart';
|
||||
import '../models/invite.dart';
|
||||
|
||||
final adminRequestsApiProvider = FutureProvider<AdminRequestsApi>((ref) async {
|
||||
return AdminRequestsApi(await ref.watch(dioProvider.future));
|
||||
});
|
||||
|
||||
final adminQuarantineApiProvider =
|
||||
FutureProvider<AdminQuarantineApi>((ref) async {
|
||||
return AdminQuarantineApi(await ref.watch(dioProvider.future));
|
||||
});
|
||||
|
||||
final adminUsersApiProvider = FutureProvider<AdminUsersApi>((ref) async {
|
||||
return AdminUsersApi(await ref.watch(dioProvider.future));
|
||||
});
|
||||
|
||||
final adminInvitesApiProvider = FutureProvider<AdminInvitesApi>((ref) async {
|
||||
return AdminInvitesApi(await ref.watch(dioProvider.future));
|
||||
});
|
||||
|
||||
class AdminCounts {
|
||||
const AdminCounts({
|
||||
required this.requests,
|
||||
required this.quarantine,
|
||||
required this.users,
|
||||
});
|
||||
final int requests;
|
||||
final int quarantine;
|
||||
final int users;
|
||||
}
|
||||
|
||||
/// Fans out to the three list endpoints in parallel and rolls them up
|
||||
/// into `{requests, quarantine, users}` for the landing screen badges.
|
||||
final adminCountsProvider = FutureProvider<AdminCounts>((ref) async {
|
||||
final requestsApi = await ref.watch(adminRequestsApiProvider.future);
|
||||
final quarantineApi = await ref.watch(adminQuarantineApiProvider.future);
|
||||
final usersApi = await ref.watch(adminUsersApiProvider.future);
|
||||
final results = await Future.wait([
|
||||
requestsApi.list(),
|
||||
quarantineApi.list(),
|
||||
usersApi.list(),
|
||||
]);
|
||||
return AdminCounts(
|
||||
requests: (results[0] as List).length,
|
||||
quarantine: (results[1] as List).length,
|
||||
users: (results[2] as List).length,
|
||||
);
|
||||
});
|
||||
|
||||
class AdminRequestsController extends AsyncNotifier<List<AdminRequest>> {
|
||||
@override
|
||||
Future<List<AdminRequest>> build() async {
|
||||
final api = await ref.watch(adminRequestsApiProvider.future);
|
||||
return api.list();
|
||||
}
|
||||
|
||||
Future<void> approve(String id) async {
|
||||
await _decide(id, (api) => api.approve(id));
|
||||
}
|
||||
|
||||
Future<void> reject(String id) async {
|
||||
await _decide(id, (api) => api.reject(id));
|
||||
}
|
||||
|
||||
Future<void> _decide(
|
||||
String id,
|
||||
Future<void> Function(AdminRequestsApi api) action,
|
||||
) async {
|
||||
final api = await ref.read(adminRequestsApiProvider.future);
|
||||
final current = state.value ?? const <AdminRequest>[];
|
||||
state = AsyncData(current.where((r) => r.id != id).toList());
|
||||
try {
|
||||
await action(api);
|
||||
// Refresh landing badge.
|
||||
ref.invalidate(adminCountsProvider);
|
||||
} catch (e, st) {
|
||||
state = AsyncData(current);
|
||||
Error.throwWithStackTrace(e, st);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final adminRequestsProvider =
|
||||
AsyncNotifierProvider<AdminRequestsController, List<AdminRequest>>(
|
||||
AdminRequestsController.new);
|
||||
|
||||
/// Read side originally defined for the Requests screen's
|
||||
/// requester-UUID → username join. Mutation methods (setAdmin,
|
||||
/// setAutoApprove, resetPassword, delete) added alongside the Users
|
||||
/// screen in Slice 3. resetPassword is admin-supplies; the server
|
||||
/// has no auto-generation mode.
|
||||
class AdminUsersController extends AsyncNotifier<List<AdminUser>> {
|
||||
@override
|
||||
Future<List<AdminUser>> build() async {
|
||||
final api = await ref.watch(adminUsersApiProvider.future);
|
||||
return api.list();
|
||||
}
|
||||
|
||||
/// Optimistic flip of `is_admin` on the user row; rolls back on
|
||||
/// server error (including the last-admin guard 4xx).
|
||||
Future<void> setAdmin(String id, bool isAdmin) =>
|
||||
_patch(id, (u) => _copy(u, isAdmin: isAdmin),
|
||||
(api) => api.setAdmin(id, isAdmin));
|
||||
|
||||
Future<void> setAutoApprove(String id, bool autoApprove) =>
|
||||
_patch(id, (u) => _copy(u, autoApproveRequests: autoApprove),
|
||||
(api) => api.setAutoApprove(id, autoApprove));
|
||||
|
||||
Future<void> delete(String id) async {
|
||||
final api = await ref.read(adminUsersApiProvider.future);
|
||||
final current = state.value ?? const <AdminUser>[];
|
||||
state = AsyncData(current.where((u) => u.id != id).toList());
|
||||
try {
|
||||
await api.delete(id);
|
||||
ref.invalidate(adminCountsProvider);
|
||||
} catch (e, st) {
|
||||
state = AsyncData(current);
|
||||
Error.throwWithStackTrace(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> resetPassword(String id, String newPassword) async {
|
||||
final api = await ref.read(adminUsersApiProvider.future);
|
||||
await api.resetPassword(id, newPassword);
|
||||
}
|
||||
|
||||
Future<void> _patch(
|
||||
String id,
|
||||
AdminUser Function(AdminUser) mutate,
|
||||
Future<void> Function(AdminUsersApi api) action,
|
||||
) async {
|
||||
final api = await ref.read(adminUsersApiProvider.future);
|
||||
final current = state.value ?? const <AdminUser>[];
|
||||
state = AsyncData([
|
||||
for (final u in current) u.id == id ? mutate(u) : u,
|
||||
]);
|
||||
try {
|
||||
await action(api);
|
||||
} catch (e, st) {
|
||||
state = AsyncData(current);
|
||||
Error.throwWithStackTrace(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
AdminUser _copy(AdminUser u, {bool? isAdmin, bool? autoApproveRequests}) =>
|
||||
AdminUser(
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
displayName: u.displayName,
|
||||
isAdmin: isAdmin ?? u.isAdmin,
|
||||
autoApproveRequests: autoApproveRequests ?? u.autoApproveRequests,
|
||||
createdAt: u.createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
final adminUsersProvider =
|
||||
AsyncNotifierProvider<AdminUsersController, List<AdminUser>>(
|
||||
AdminUsersController.new);
|
||||
|
||||
class AdminQuarantineController
|
||||
extends AsyncNotifier<List<AdminQuarantineItem>> {
|
||||
@override
|
||||
Future<List<AdminQuarantineItem>> build() async {
|
||||
final api = await ref.watch(adminQuarantineApiProvider.future);
|
||||
return api.list();
|
||||
}
|
||||
|
||||
Future<void> resolve(String trackId) =>
|
||||
_act(trackId, (api) => api.resolve(trackId));
|
||||
Future<void> deleteFile(String trackId) =>
|
||||
_act(trackId, (api) => api.deleteFile(trackId));
|
||||
Future<void> deleteViaLidarr(String trackId) =>
|
||||
_act(trackId, (api) => api.deleteViaLidarr(trackId));
|
||||
|
||||
Future<void> _act(
|
||||
String trackId,
|
||||
Future<void> Function(AdminQuarantineApi api) action,
|
||||
) async {
|
||||
final api = await ref.read(adminQuarantineApiProvider.future);
|
||||
final current = state.value ?? const <AdminQuarantineItem>[];
|
||||
state = AsyncData(current.where((q) => q.trackId != trackId).toList());
|
||||
try {
|
||||
await action(api);
|
||||
ref.invalidate(adminCountsProvider);
|
||||
} catch (e, st) {
|
||||
state = AsyncData(current);
|
||||
Error.throwWithStackTrace(e, st);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final adminQuarantineProvider = AsyncNotifierProvider<AdminQuarantineController,
|
||||
List<AdminQuarantineItem>>(AdminQuarantineController.new);
|
||||
|
||||
class AdminInvitesController extends AsyncNotifier<List<Invite>> {
|
||||
@override
|
||||
Future<List<Invite>> build() async {
|
||||
final api = await ref.watch(adminInvitesApiProvider.future);
|
||||
return api.list();
|
||||
}
|
||||
|
||||
/// Returns the freshly-minted invite so the screen can show the
|
||||
/// token in a copy-once dialog. The new invite is also prepended
|
||||
/// to the local list so it shows up without a refresh.
|
||||
Future<Invite> create({String? note}) async {
|
||||
final api = await ref.read(adminInvitesApiProvider.future);
|
||||
final invite = await api.create(note: note);
|
||||
final current = state.value ?? const <Invite>[];
|
||||
state = AsyncData([invite, ...current]);
|
||||
return invite;
|
||||
}
|
||||
|
||||
Future<void> revoke(String token) async {
|
||||
final api = await ref.read(adminInvitesApiProvider.future);
|
||||
final current = state.value ?? const <Invite>[];
|
||||
state = AsyncData(current.where((i) => i.token != token).toList());
|
||||
try {
|
||||
await api.revoke(token);
|
||||
} catch (e, st) {
|
||||
state = AsyncData(current);
|
||||
Error.throwWithStackTrace(e, st);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final adminInvitesProvider =
|
||||
AsyncNotifierProvider<AdminInvitesController, List<Invite>>(
|
||||
AdminInvitesController.new);
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../shared/widgets/main_app_bar_actions.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'admin_providers.dart';
|
||||
import 'widgets/admin_quarantine_row.dart';
|
||||
|
||||
class AdminQuarantineScreen extends ConsumerWidget {
|
||||
const AdminQuarantineScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final items = ref.watch(adminQuarantineProvider);
|
||||
return Scaffold(
|
||||
backgroundColor: fs.obsidian,
|
||||
appBar: AppBar(
|
||||
backgroundColor: fs.obsidian,
|
||||
elevation: 0,
|
||||
title: Text('Quarantine', style: TextStyle(color: fs.parchment)),
|
||||
actions: const [MainAppBarActions(currentRoute: '/admin/quarantine')],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: items.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) =>
|
||||
Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||
data: (rows) {
|
||||
if (rows.isEmpty) {
|
||||
return Center(
|
||||
child: Text('No quarantined tracks.',
|
||||
style: TextStyle(color: fs.ash)),
|
||||
);
|
||||
}
|
||||
final notifier = ref.read(adminQuarantineProvider.notifier);
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async =>
|
||||
ref.refresh(adminQuarantineProvider.future),
|
||||
child: ListView.builder(
|
||||
itemCount: rows.length,
|
||||
itemBuilder: (_, i) => AdminQuarantineRow(
|
||||
key: Key('admin_quarantine_row_${rows[i].trackId}'),
|
||||
item: rows[i],
|
||||
onResolve: () => notifier.resolve(rows[i].trackId),
|
||||
onDeleteFile: () => notifier.deleteFile(rows[i].trackId),
|
||||
onDeleteViaLidarr: () =>
|
||||
notifier.deleteViaLidarr(rows[i].trackId),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../models/admin_user.dart';
|
||||
import '../shared/widgets/main_app_bar_actions.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'admin_providers.dart';
|
||||
import 'widgets/admin_request_row.dart';
|
||||
|
||||
class AdminRequestsScreen extends ConsumerWidget {
|
||||
const AdminRequestsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final requests = ref.watch(adminRequestsProvider);
|
||||
// Best-effort lookup for requester usernames. If the users provider
|
||||
// hasn't loaded yet, valueOrNull is null and rows fall back to the
|
||||
// UUID prefix; no blocking spinner.
|
||||
final users = ref.watch(adminUsersProvider).value ?? const <AdminUser>[];
|
||||
final usersById = {for (final u in users) u.id: u};
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: fs.obsidian,
|
||||
appBar: AppBar(
|
||||
backgroundColor: fs.obsidian,
|
||||
elevation: 0,
|
||||
title: Text('Requests', style: TextStyle(color: fs.parchment)),
|
||||
actions: const [MainAppBarActions(currentRoute: '/admin/requests')],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: requests.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) =>
|
||||
Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||
data: (rows) {
|
||||
if (rows.isEmpty) {
|
||||
return Center(
|
||||
child: Text('No pending requests.',
|
||||
style: TextStyle(color: fs.ash)),
|
||||
);
|
||||
}
|
||||
final notifier = ref.read(adminRequestsProvider.notifier);
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async =>
|
||||
ref.refresh(adminRequestsProvider.future),
|
||||
child: ListView.builder(
|
||||
itemCount: rows.length,
|
||||
itemBuilder: (_, i) {
|
||||
final req = rows[i];
|
||||
final user = usersById[req.userId];
|
||||
final display = user?.username ??
|
||||
(req.userId.length >= 8
|
||||
? req.userId.substring(0, 8)
|
||||
: req.userId);
|
||||
return AdminRequestRow(
|
||||
key: Key('admin_request_row_${req.id}'),
|
||||
request: req,
|
||||
requesterDisplay: display,
|
||||
onApprove: () => notifier.approve(req.id),
|
||||
onReject: () => notifier.reject(req.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../shared/widgets/main_app_bar_actions.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'admin_providers.dart';
|
||||
import 'widgets/admin_user_edit_sheet.dart';
|
||||
import 'widgets/admin_user_row.dart';
|
||||
import 'widgets/invite_row.dart';
|
||||
|
||||
class AdminUsersScreen extends ConsumerWidget {
|
||||
const AdminUsersScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final users = ref.watch(adminUsersProvider);
|
||||
final invites = ref.watch(adminInvitesProvider);
|
||||
return Scaffold(
|
||||
backgroundColor: fs.obsidian,
|
||||
appBar: AppBar(
|
||||
backgroundColor: fs.obsidian,
|
||||
elevation: 0,
|
||||
title: Text('Users', style: TextStyle(color: fs.parchment)),
|
||||
actions: const [MainAppBarActions(currentRoute: '/admin/users')],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
ref.invalidate(adminUsersProvider);
|
||||
ref.invalidate(adminInvitesProvider);
|
||||
},
|
||||
child: ListView(
|
||||
children: [
|
||||
const _SectionHeader(text: 'Users'),
|
||||
users.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: (rows) => Column(
|
||||
children: rows
|
||||
.map((u) => AdminUserRow(
|
||||
key: Key('admin_user_row_${u.id}'),
|
||||
user: u,
|
||||
onTap: () => AdminUserEditSheet.show(context, u),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
_SectionHeader(
|
||||
text: 'Invites',
|
||||
trailing: TextButton.icon(
|
||||
key: const Key('invite_generate_button'),
|
||||
onPressed: () => _showGenerateInvite(context, ref),
|
||||
icon: Icon(Icons.add, color: fs.parchment),
|
||||
label: Text('Generate',
|
||||
style: TextStyle(color: fs.parchment)),
|
||||
),
|
||||
),
|
||||
invites.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: (rows) => Column(
|
||||
children: rows
|
||||
.map((i) => InviteRow(
|
||||
key: Key('invite_row_${i.token}'),
|
||||
invite: i,
|
||||
onRevoke: () => ref
|
||||
.read(adminInvitesProvider.notifier)
|
||||
.revoke(i.token),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showGenerateInvite(BuildContext context, WidgetRef ref) async {
|
||||
final controller = TextEditingController();
|
||||
final note = await showDialog<String?>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Generate invite'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Token expires in 24 hours.'),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: controller,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Note (optional)',
|
||||
helperText: 'e.g. "for alice"',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, controller.text),
|
||||
child: const Text('Generate'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (note == null || !context.mounted) return;
|
||||
try {
|
||||
final invite = await ref
|
||||
.read(adminInvitesProvider.notifier)
|
||||
.create(note: note.isEmpty ? null : note);
|
||||
if (!context.mounted) return;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Invite created'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Share this token with the new user:'),
|
||||
const SizedBox(height: 8),
|
||||
SelectableText(
|
||||
invite.token,
|
||||
style: const TextStyle(fontFamily: 'JetBrainsMono'),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Clipboard.setData(ClipboardData(text: invite.token));
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text('Copy'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text('$e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader({required this.text, this.trailing});
|
||||
final String text;
|
||||
final Widget? trailing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: fs.parchment,
|
||||
fontFamily: 'Fraunces',
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (trailing != null) trailing!,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/admin_quarantine_item.dart';
|
||||
import '../../theme/theme_extension.dart';
|
||||
import 'typed_confirm_sheet.dart';
|
||||
|
||||
class AdminQuarantineRow extends StatelessWidget {
|
||||
const AdminQuarantineRow({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.onResolve,
|
||||
required this.onDeleteFile,
|
||||
required this.onDeleteViaLidarr,
|
||||
});
|
||||
|
||||
final AdminQuarantineItem item;
|
||||
final VoidCallback onResolve;
|
||||
final VoidCallback onDeleteFile;
|
||||
final VoidCallback onDeleteViaLidarr;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return ExpansionTile(
|
||||
key: Key('admin_quarantine_tile_${item.trackId}'),
|
||||
title: Text(
|
||||
item.trackTitle,
|
||||
style: TextStyle(
|
||||
color: fs.parchment,
|
||||
fontFamily: 'Fraunces',
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('${item.artistName} · ${item.albumTitle}',
|
||||
style: TextStyle(color: fs.ash)),
|
||||
Text(
|
||||
'${item.reportCount} '
|
||||
'${item.reportCount == 1 ? "report" : "reports"} · ${item.topReasonSummary}',
|
||||
style: TextStyle(color: fs.error, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: PopupMenuButton<String>(
|
||||
key: Key('admin_quarantine_menu_${item.trackId}'),
|
||||
icon: Icon(Icons.more_vert, color: fs.parchment),
|
||||
onSelected: (action) async {
|
||||
switch (action) {
|
||||
case 'resolve':
|
||||
onResolve();
|
||||
break;
|
||||
case 'delete_file':
|
||||
if (await TypedConfirmSheet.show(
|
||||
context,
|
||||
title: 'Delete file?',
|
||||
message:
|
||||
'Permanently delete the local file for "${item.trackTitle}". '
|
||||
'Cannot be undone.',
|
||||
)) {
|
||||
onDeleteFile();
|
||||
}
|
||||
break;
|
||||
case 'delete_lidarr':
|
||||
if (await TypedConfirmSheet.show(
|
||||
context,
|
||||
title: 'Delete via Lidarr?',
|
||||
message:
|
||||
'Ask Lidarr to delete the file for "${item.trackTitle}".',
|
||||
)) {
|
||||
onDeleteViaLidarr();
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (_) => const [
|
||||
PopupMenuItem(value: 'resolve', child: Text('Resolve')),
|
||||
PopupMenuItem(value: 'delete_file', child: Text('Delete file')),
|
||||
PopupMenuItem(
|
||||
value: 'delete_lidarr', child: Text('Delete via Lidarr')),
|
||||
],
|
||||
),
|
||||
childrenPadding: const EdgeInsets.only(left: 16, right: 16, bottom: 8),
|
||||
children: [
|
||||
for (final report in item.reports)
|
||||
ListTile(
|
||||
dense: true,
|
||||
title: Text(
|
||||
'${report.username} — ${report.reason}',
|
||||
style: TextStyle(color: fs.parchment, fontSize: 13),
|
||||
),
|
||||
subtitle: report.notes != null
|
||||
? Text(report.notes!,
|
||||
style: TextStyle(color: fs.ash, fontSize: 12))
|
||||
: null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/admin_request.dart';
|
||||
import '../../theme/theme_extension.dart';
|
||||
|
||||
class AdminRequestRow extends StatelessWidget {
|
||||
const AdminRequestRow({
|
||||
super.key,
|
||||
required this.request,
|
||||
required this.requesterDisplay,
|
||||
required this.onApprove,
|
||||
required this.onReject,
|
||||
});
|
||||
|
||||
final AdminRequest request;
|
||||
|
||||
/// Pre-resolved username (or UUID-prefix fallback) for the requester.
|
||||
final String requesterDisplay;
|
||||
|
||||
final VoidCallback onApprove;
|
||||
final VoidCallback onReject;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final subtitle =
|
||||
'${request.kind} · ${request.artistName} · requested by $requesterDisplay';
|
||||
return ListTile(
|
||||
title: Text(
|
||||
request.displayName,
|
||||
style: TextStyle(
|
||||
color: fs.parchment,
|
||||
fontFamily: 'Fraunces',
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
subtitle: Text(subtitle, style: TextStyle(color: fs.ash)),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: onApprove,
|
||||
style: TextButton.styleFrom(foregroundColor: fs.moss),
|
||||
child: const Text('Approve'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Reject request?'),
|
||||
content: Text('Reject "${request.displayName}"?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: TextButton.styleFrom(foregroundColor: fs.oxblood),
|
||||
child: const Text('Reject'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) onReject();
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: fs.oxblood),
|
||||
child: const Text('Reject'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../theme/theme_extension.dart';
|
||||
|
||||
class AdminSectionCard extends StatelessWidget {
|
||||
const AdminSectionCard({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.count,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final int count;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return Card(
|
||||
color: fs.iron,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
child: ListTile(
|
||||
leading: Icon(icon, color: fs.parchment),
|
||||
title: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: fs.parchment,
|
||||
fontFamily: 'Fraunces',
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
subtitle: Text(subtitle, style: TextStyle(color: fs.ash)),
|
||||
trailing: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: fs.bronze,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'$count',
|
||||
style: TextStyle(color: fs.obsidian, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
onTap: onTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../models/admin_user.dart';
|
||||
import '../../theme/theme_extension.dart';
|
||||
import '../admin_providers.dart';
|
||||
import 'typed_confirm_sheet.dart';
|
||||
|
||||
class AdminUserEditSheet extends ConsumerWidget {
|
||||
const AdminUserEditSheet({super.key, required this.user});
|
||||
|
||||
final AdminUser user;
|
||||
|
||||
static Future<void> show(BuildContext context, AdminUser user) =>
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => AdminUserEditSheet(user: user),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final users = ref.read(adminUsersProvider.notifier);
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: Container(
|
||||
color: fs.iron,
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
user.username,
|
||||
style: TextStyle(
|
||||
color: fs.parchment,
|
||||
fontFamily: 'Fraunces',
|
||||
fontSize: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SwitchListTile(
|
||||
key: const Key('user_edit_is_admin'),
|
||||
title: Text('Admin', style: TextStyle(color: fs.parchment)),
|
||||
value: user.isAdmin,
|
||||
onChanged: (v) async {
|
||||
try {
|
||||
await users.setAdmin(user.id, v);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text('$e')));
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
SwitchListTile(
|
||||
key: const Key('user_edit_auto_approve'),
|
||||
title: Text('Auto-approve requests',
|
||||
style: TextStyle(color: fs.parchment)),
|
||||
value: user.autoApproveRequests,
|
||||
onChanged: (v) async {
|
||||
try {
|
||||
await users.setAutoApprove(user.id, v);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text('$e')));
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
TextButton(
|
||||
key: const Key('user_edit_reset_password'),
|
||||
onPressed: () => _resetPassword(context, users),
|
||||
child: const Text('Reset password'),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
key: const Key('user_edit_delete'),
|
||||
style: TextButton.styleFrom(foregroundColor: fs.oxblood),
|
||||
onPressed: () => _deleteUser(context, users),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _resetPassword(
|
||||
BuildContext context, AdminUsersController users) async {
|
||||
final controller = TextEditingController();
|
||||
final newPw = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Reset password'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'New password',
|
||||
helperText: 'Minimum 8 characters',
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
if (controller.text.length >= 8) {
|
||||
Navigator.pop(ctx, controller.text);
|
||||
}
|
||||
},
|
||||
child: const Text('Reset'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (newPw == null || !context.mounted) return;
|
||||
try {
|
||||
await users.resetPassword(user.id, newPw);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Password reset.')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text('$e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteUser(
|
||||
BuildContext context, AdminUsersController users) async {
|
||||
if (!await TypedConfirmSheet.show(
|
||||
context,
|
||||
title: 'Delete user?',
|
||||
message:
|
||||
'Permanently delete user "${user.username}" and all their data. '
|
||||
'Cannot be undone.',
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await users.delete(user.id);
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text('$e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/admin_user.dart';
|
||||
import '../../theme/theme_extension.dart';
|
||||
|
||||
class AdminUserRow extends StatelessWidget {
|
||||
const AdminUserRow({super.key, required this.user, required this.onTap});
|
||||
|
||||
final AdminUser user;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return ListTile(
|
||||
title: Text(
|
||||
user.username,
|
||||
style: TextStyle(
|
||||
color: fs.parchment,
|
||||
fontFamily: 'Fraunces',
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
subtitle: Wrap(
|
||||
spacing: 6,
|
||||
children: [
|
||||
if (user.isAdmin) _Badge(label: 'admin', color: fs.bronze),
|
||||
if (user.autoApproveRequests)
|
||||
_Badge(label: 'auto-approve', color: fs.moss),
|
||||
],
|
||||
),
|
||||
trailing: Icon(Icons.chevron_right, color: fs.ash),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Badge extends StatelessWidget {
|
||||
const _Badge({required this.label, required this.color});
|
||||
final String label;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(color: fs.obsidian, fontSize: 11),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../models/invite.dart';
|
||||
import '../../theme/theme_extension.dart';
|
||||
|
||||
class InviteRow extends StatelessWidget {
|
||||
const InviteRow({
|
||||
super.key,
|
||||
required this.invite,
|
||||
required this.onRevoke,
|
||||
});
|
||||
|
||||
final Invite invite;
|
||||
final VoidCallback onRevoke;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return ListTile(
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SelectableText(
|
||||
invite.token,
|
||||
style: TextStyle(
|
||||
color: fs.parchment,
|
||||
fontFamily: 'JetBrainsMono',
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.copy, color: fs.ash, size: 18),
|
||||
tooltip: 'Copy',
|
||||
onPressed: () =>
|
||||
Clipboard.setData(ClipboardData(text: invite.token)),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Wrap(
|
||||
spacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Text('expires ${invite.expiresAt}',
|
||||
style: TextStyle(color: fs.ash, fontSize: 12)),
|
||||
if (invite.note != null)
|
||||
Text('· ${invite.note}',
|
||||
style: TextStyle(color: fs.ash, fontSize: 12)),
|
||||
if (invite.isRedeemed)
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: fs.ash,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
'redeemed',
|
||||
style: TextStyle(color: fs.obsidian, fontSize: 10),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: Icon(Icons.delete_outline, color: fs.oxblood),
|
||||
tooltip: 'Revoke',
|
||||
onPressed: onRevoke,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../theme/theme_extension.dart';
|
||||
|
||||
/// Modal bottom sheet that requires the user to type [confirmWord]
|
||||
/// (default "DELETE") before the confirm button enables. Used for
|
||||
/// destructive actions like quarantine delete-file / delete-via-Lidarr
|
||||
/// and user delete.
|
||||
///
|
||||
/// Returns true if the user confirmed, false (or null → false) otherwise.
|
||||
class TypedConfirmSheet extends StatefulWidget {
|
||||
const TypedConfirmSheet({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.message,
|
||||
this.confirmWord = 'DELETE',
|
||||
this.confirmLabel = 'Delete',
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String message;
|
||||
final String confirmWord;
|
||||
final String confirmLabel;
|
||||
|
||||
static Future<bool> show(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required String message,
|
||||
String confirmWord = 'DELETE',
|
||||
String confirmLabel = 'Delete',
|
||||
}) async {
|
||||
final ok = await showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => TypedConfirmSheet(
|
||||
title: title,
|
||||
message: message,
|
||||
confirmWord: confirmWord,
|
||||
confirmLabel: confirmLabel,
|
||||
),
|
||||
);
|
||||
return ok ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
State<TypedConfirmSheet> createState() => _TypedConfirmSheetState();
|
||||
}
|
||||
|
||||
class _TypedConfirmSheetState extends State<TypedConfirmSheet> {
|
||||
final _controller = TextEditingController();
|
||||
bool _enabled = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: Container(
|
||||
color: fs.iron,
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.title,
|
||||
style: TextStyle(
|
||||
color: fs.parchment,
|
||||
fontFamily: 'Fraunces',
|
||||
fontSize: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(widget.message, style: TextStyle(color: fs.ash)),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Type ${widget.confirmWord} to confirm:',
|
||||
style: TextStyle(color: fs.ash),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
key: const Key('typed_confirm_input'),
|
||||
controller: _controller,
|
||||
autofocus: true,
|
||||
style: TextStyle(color: fs.parchment, fontFamily: 'JetBrainsMono'),
|
||||
decoration: const InputDecoration(border: OutlineInputBorder()),
|
||||
onChanged: (v) =>
|
||||
setState(() => _enabled = v == widget.confirmWord),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
key: const Key('typed_confirm_button'),
|
||||
onPressed:
|
||||
_enabled ? () => Navigator.pop(context, true) : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: fs.oxblood,
|
||||
foregroundColor: fs.parchment,
|
||||
),
|
||||
child: Text(widget.confirmLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../models/invite.dart';
|
||||
|
||||
class AdminInvitesApi {
|
||||
AdminInvitesApi(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
/// GET /api/admin/invites → `{"invites": [...]}`. Envelope unwrapped.
|
||||
Future<List<Invite>> list() async {
|
||||
final r = await _dio.get<Map<String, dynamic>>('/api/admin/invites');
|
||||
final raw = (r.data?['invites'] as List?) ?? const [];
|
||||
return raw
|
||||
.map((e) => Invite.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
/// POST /api/admin/invites — body is optional `{"note": "..."}`.
|
||||
/// Server hardcodes the 24h TTL; admins cannot configure expiry.
|
||||
/// Returns the bare invite (not enveloped).
|
||||
Future<Invite> create({String? note}) async {
|
||||
final r = await _dio.post<Map<String, dynamic>>(
|
||||
'/api/admin/invites',
|
||||
data: {if (note != null && note.isNotEmpty) 'note': note},
|
||||
);
|
||||
return Invite.fromJson(r.data ?? const {});
|
||||
}
|
||||
|
||||
Future<void> revoke(String token) async {
|
||||
await _dio.delete<void>('/api/admin/invites/$token');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../models/admin_quarantine_item.dart';
|
||||
|
||||
class AdminQuarantineApi {
|
||||
AdminQuarantineApi(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
/// GET /api/admin/quarantine — flat list (no envelope) of aggregated
|
||||
/// quarantine rows.
|
||||
Future<List<AdminQuarantineItem>> list() async {
|
||||
final r = await _dio.get<List<dynamic>>('/api/admin/quarantine');
|
||||
final raw = r.data ?? const [];
|
||||
return raw
|
||||
.map((e) =>
|
||||
AdminQuarantineItem.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<void> resolve(String trackId) async {
|
||||
await _dio.post<void>('/api/admin/quarantine/$trackId/resolve');
|
||||
}
|
||||
|
||||
Future<void> deleteFile(String trackId) async {
|
||||
await _dio.post<void>('/api/admin/quarantine/$trackId/delete-file');
|
||||
}
|
||||
|
||||
Future<void> deleteViaLidarr(String trackId) async {
|
||||
await _dio.post<void>('/api/admin/quarantine/$trackId/delete-via-lidarr');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../models/admin_request.dart';
|
||||
|
||||
class AdminRequestsApi {
|
||||
AdminRequestsApi(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
/// GET /api/admin/requests — flat list of pending requests.
|
||||
/// Server defaults to ?status=pending; we don't override.
|
||||
Future<List<AdminRequest>> list() async {
|
||||
final r = await _dio.get<List<dynamic>>('/api/admin/requests');
|
||||
final raw = r.data ?? const [];
|
||||
return raw
|
||||
.map((e) => AdminRequest.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<void> approve(String id) async {
|
||||
await _dio.post<void>('/api/admin/requests/$id/approve');
|
||||
}
|
||||
|
||||
Future<void> reject(String id) async {
|
||||
await _dio.post<void>('/api/admin/requests/$id/reject');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../models/admin_user.dart';
|
||||
|
||||
class AdminUsersApi {
|
||||
AdminUsersApi(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
/// GET /api/admin/users → `{"users": [...]}`. Envelope unwrapped here.
|
||||
Future<List<AdminUser>> list() async {
|
||||
final r = await _dio.get<Map<String, dynamic>>('/api/admin/users');
|
||||
final raw = (r.data?['users'] as List?) ?? const [];
|
||||
return raw
|
||||
.map((e) => AdminUser.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<void> setAdmin(String id, bool isAdmin) async {
|
||||
await _dio.put<void>(
|
||||
'/api/admin/users/$id/admin',
|
||||
data: {'is_admin': isAdmin},
|
||||
);
|
||||
}
|
||||
|
||||
/// Server's body field is `auto_approve` (NOT `auto_approve_requests`)
|
||||
/// — different from the response field name. Verified against
|
||||
/// internal/api/admin_users.go `adminAutoApproveReq` (May 2026).
|
||||
Future<void> setAutoApprove(String id, bool autoApprove) async {
|
||||
await _dio.put<void>(
|
||||
'/api/admin/users/$id/auto-approve',
|
||||
data: {'auto_approve': autoApprove},
|
||||
);
|
||||
}
|
||||
|
||||
/// Admin supplies the new password; server returns 204. There is no
|
||||
/// server-generated-password mode.
|
||||
Future<void> resetPassword(String id, String newPassword) async {
|
||||
await _dio.post<void>(
|
||||
'/api/admin/users/$id/reset-password',
|
||||
data: {'password': newPassword},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> delete(String id) async {
|
||||
await _dio.delete<void>('/api/admin/users/$id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../models/lidarr.dart';
|
||||
|
||||
class DiscoverApi {
|
||||
DiscoverApi(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
/// GET /api/lidarr/search?q=...&kind=artist|album|track. Server has a
|
||||
/// 60s LRU cache for repeat queries so re-typing the same string in
|
||||
/// quick succession is cheap.
|
||||
Future<List<LidarrSearchResult>> search(
|
||||
String query,
|
||||
LidarrRequestKind kind,
|
||||
) async {
|
||||
final r = await _dio.get<List<dynamic>>(
|
||||
'/api/lidarr/search',
|
||||
queryParameters: {'q': query, 'kind': kind.wire},
|
||||
);
|
||||
final raw = r.data ?? const [];
|
||||
return raw
|
||||
.map((e) =>
|
||||
LidarrSearchResult.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
/// POST /api/requests. Returns the newly-created LidarrRequest body
|
||||
/// — we don't expose a typed wrapper for that yet on mobile (admin
|
||||
/// reviews requests; user just kicks them off), so we discard the
|
||||
/// response and surface success/failure to the caller.
|
||||
Future<void> createRequest({
|
||||
required LidarrRequestKind kind,
|
||||
required String artistMbid,
|
||||
required String artistName,
|
||||
String? albumMbid,
|
||||
String? albumTitle,
|
||||
String? trackMbid,
|
||||
String? trackTitle,
|
||||
}) async {
|
||||
final body = <String, dynamic>{
|
||||
'kind': kind.wire,
|
||||
'lidarr_artist_mbid': artistMbid,
|
||||
'artist_name': artistName,
|
||||
};
|
||||
if (albumMbid != null && albumMbid.isNotEmpty) {
|
||||
body['lidarr_album_mbid'] = albumMbid;
|
||||
}
|
||||
if (trackMbid != null && trackMbid.isNotEmpty) {
|
||||
body['lidarr_track_mbid'] = trackMbid;
|
||||
}
|
||||
if (albumTitle != null && albumTitle.isNotEmpty) {
|
||||
body['album_title'] = albumTitle;
|
||||
}
|
||||
if (trackTitle != null && trackTitle.isNotEmpty) {
|
||||
body['track_title'] = trackTitle;
|
||||
}
|
||||
await _dio.post<Map<String, dynamic>>('/api/requests', data: body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../models/album.dart';
|
||||
import '../../models/artist.dart';
|
||||
import '../../models/page.dart';
|
||||
|
||||
/// Paged library browse endpoints. Distinct from LibraryApi (which
|
||||
/// fetches Home + entity detail) so screens that just need a flat
|
||||
/// list don't drag in detail-page providers.
|
||||
class LibraryListsApi {
|
||||
LibraryListsApi(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
Future<Paged<ArtistRef>> listArtists({int limit = 50, int offset = 0}) async {
|
||||
final r = await _dio.get<Map<String, dynamic>>(
|
||||
'/api/library/artists',
|
||||
queryParameters: {'limit': limit, 'offset': offset},
|
||||
);
|
||||
return Paged.fromJson(r.data ?? const {}, ArtistRef.fromJson);
|
||||
}
|
||||
|
||||
Future<Paged<AlbumRef>> listAlbums({int limit = 50, int offset = 0}) async {
|
||||
final r = await _dio.get<Map<String, dynamic>>(
|
||||
'/api/library/albums',
|
||||
queryParameters: {'limit': limit, 'offset': offset},
|
||||
);
|
||||
return Paged.fromJson(r.data ?? const {}, AlbumRef.fromJson);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../models/album.dart';
|
||||
import '../../models/artist.dart';
|
||||
import '../../models/page.dart';
|
||||
import '../../models/track.dart';
|
||||
|
||||
enum LikeKind { artist, album, track }
|
||||
|
||||
extension LikeKindPath on LikeKind {
|
||||
@@ -22,6 +27,31 @@ class LikesApi {
|
||||
await _dio.delete<void>('/api/likes/${kind.path}/$id');
|
||||
}
|
||||
|
||||
/// GET /api/likes/tracks?limit=N&offset=N. Paged.
|
||||
Future<Paged<TrackRef>> listTracks({int limit = 50, int offset = 0}) async {
|
||||
final r = await _dio.get<Map<String, dynamic>>(
|
||||
'/api/likes/tracks',
|
||||
queryParameters: {'limit': limit, 'offset': offset},
|
||||
);
|
||||
return Paged.fromJson(r.data ?? const {}, TrackRef.fromJson);
|
||||
}
|
||||
|
||||
Future<Paged<AlbumRef>> listAlbums({int limit = 50, int offset = 0}) async {
|
||||
final r = await _dio.get<Map<String, dynamic>>(
|
||||
'/api/likes/albums',
|
||||
queryParameters: {'limit': limit, 'offset': offset},
|
||||
);
|
||||
return Paged.fromJson(r.data ?? const {}, AlbumRef.fromJson);
|
||||
}
|
||||
|
||||
Future<Paged<ArtistRef>> listArtists({int limit = 50, int offset = 0}) async {
|
||||
final r = await _dio.get<Map<String, dynamic>>(
|
||||
'/api/likes/artists',
|
||||
queryParameters: {'limit': limit, 'offset': offset},
|
||||
);
|
||||
return Paged.fromJson(r.data ?? const {}, ArtistRef.fromJson);
|
||||
}
|
||||
|
||||
/// Returns sets of {artists, albums, tracks} the user has liked.
|
||||
/// Server response keys (verified in internal/api/likes.go
|
||||
/// `likedIDsResponse`): `artist_ids`, `album_ids`, `track_ids`.
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../models/history_event.dart';
|
||||
import '../../models/quarantine_mine.dart';
|
||||
|
||||
/// /api/me/* endpoints — caller-scoped data (history, profile, etc.).
|
||||
class MeApi {
|
||||
MeApi(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
/// GET /api/me/history. Paginated via offset/limit; server emits
|
||||
/// `{events, has_more}` rather than the standard `Page<T>` envelope.
|
||||
Future<HistoryPage> history({int limit = 50, int offset = 0}) async {
|
||||
final r = await _dio.get<Map<String, dynamic>>(
|
||||
'/api/me/history',
|
||||
queryParameters: {'limit': limit, 'offset': offset},
|
||||
);
|
||||
return HistoryPage.fromJson(r.data ?? const {});
|
||||
}
|
||||
|
||||
/// GET /api/quarantine/mine — flat list (no envelope).
|
||||
Future<List<QuarantineMineRow>> quarantineMine() async {
|
||||
final r = await _dio.get<List<dynamic>>('/api/quarantine/mine');
|
||||
final raw = r.data ?? const [];
|
||||
return raw
|
||||
.map((e) =>
|
||||
QuarantineMineRow.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(growable: false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../models/playlist.dart';
|
||||
|
||||
class PlaylistsApi {
|
||||
PlaylistsApi(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
/// GET /api/playlists?kind=user|system|all. The server's default kind
|
||||
/// is "user" — passing "all" returns user playlists + system mixes.
|
||||
/// "system" returns just for-you / discover. The mobile UI defaults
|
||||
/// to "all" so users see their generated mixes alongside their own.
|
||||
Future<List<Playlist>> list({String kind = 'all'}) async {
|
||||
final r = await _dio.get<List<dynamic>>(
|
||||
'/api/playlists',
|
||||
queryParameters: {'kind': kind},
|
||||
);
|
||||
final raw = r.data ?? const [];
|
||||
return raw
|
||||
.map((e) => Playlist.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<PlaylistDetail> get(String id) async {
|
||||
final r = await _dio.get<Map<String, dynamic>>('/api/playlists/$id');
|
||||
return PlaylistDetail.fromJson(r.data ?? const {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../models/search_response.dart';
|
||||
|
||||
/// SearchApi wraps GET /api/search. The server runs three facets (artists,
|
||||
/// albums, tracks) sharing one limit/offset pair. Each facet returns its
|
||||
/// own total reflecting the full match count.
|
||||
class SearchApi {
|
||||
SearchApi(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
/// Empty / whitespace-only query is the caller's responsibility to
|
||||
/// guard against — the server returns 400 bad_request for it.
|
||||
Future<SearchResponse> search(
|
||||
String query, {
|
||||
int limit = 20,
|
||||
int offset = 0,
|
||||
}) async {
|
||||
final r = await _dio.get<Map<String, dynamic>>(
|
||||
'/api/search',
|
||||
queryParameters: {
|
||||
'q': query,
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
},
|
||||
);
|
||||
return SearchResponse.fromJson(r.data ?? const {});
|
||||
}
|
||||
}
|
||||
@@ -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 {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
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/discover.dart';
|
||||
import '../api/errors.dart';
|
||||
import '../library/library_providers.dart' show dioProvider;
|
||||
import '../models/lidarr.dart';
|
||||
import '../shared/widgets/main_app_bar_actions.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
|
||||
final _discoverApiProvider = FutureProvider<DiscoverApi>((ref) async {
|
||||
return DiscoverApi(await ref.watch(dioProvider.future));
|
||||
});
|
||||
|
||||
class DiscoverScreen extends ConsumerStatefulWidget {
|
||||
const DiscoverScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DiscoverScreen> createState() => _DiscoverScreenState();
|
||||
}
|
||||
|
||||
class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
||||
final _ctrl = TextEditingController();
|
||||
LidarrRequestKind _kind = LidarrRequestKind.artist;
|
||||
Future<List<LidarrSearchResult>>? _resultsFuture;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _runSearch() {
|
||||
final q = _ctrl.text.trim();
|
||||
if (q.isEmpty) {
|
||||
setState(() => _resultsFuture = null);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_resultsFuture = ref
|
||||
.read(_discoverApiProvider.future)
|
||||
.then((api) => api.search(q, _kind));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _request(LidarrSearchResult row) async {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
try {
|
||||
final api = await ref.read(_discoverApiProvider.future);
|
||||
await api.createRequest(
|
||||
kind: _kind,
|
||||
artistMbid: _kind == LidarrRequestKind.artist ? row.mbid : row.artistMbid,
|
||||
artistName: _kind == LidarrRequestKind.artist ? row.name : row.secondaryText,
|
||||
albumMbid: _kind == LidarrRequestKind.album ? row.mbid : null,
|
||||
albumTitle: _kind == LidarrRequestKind.album ? row.name : null,
|
||||
);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('Requested: ${row.name}'),
|
||||
backgroundColor: fs.iron,
|
||||
));
|
||||
// Re-run search to refresh the `requested` flag on the row.
|
||||
_runSearch();
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
if (mounted) {
|
||||
final code = ApiError.fromDio(e).code;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('Request failed: $code'),
|
||||
backgroundColor: fs.error,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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('Discover', style: TextStyle(color: fs.parchment)),
|
||||
actions: const [MainAppBarActions(currentRoute: '/discover')],
|
||||
),
|
||||
body: Column(children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _ctrl,
|
||||
style: TextStyle(color: fs.parchment),
|
||||
cursorColor: fs.accent,
|
||||
onSubmitted: (_) => _runSearch(),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search Lidarr for new music',
|
||||
hintStyle: TextStyle(color: fs.ash),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(color: fs.iron),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(color: fs.accent),
|
||||
),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(Icons.search, color: fs.ash),
|
||||
onPressed: _runSearch,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: SegmentedButton<LidarrRequestKind>(
|
||||
segments: const [
|
||||
ButtonSegment(value: LidarrRequestKind.artist, label: Text('Artists')),
|
||||
ButtonSegment(value: LidarrRequestKind.album, label: Text('Albums')),
|
||||
],
|
||||
selected: {_kind},
|
||||
onSelectionChanged: (s) {
|
||||
setState(() {
|
||||
_kind = s.first;
|
||||
if (_ctrl.text.trim().isNotEmpty) _runSearch();
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _resultsFuture == null
|
||||
? Center(
|
||||
child: Text(
|
||||
'Type to search, then tap Request to send to Lidarr.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: fs.ash),
|
||||
),
|
||||
)
|
||||
: FutureBuilder<List<LidarrSearchResult>>(
|
||||
future: _resultsFuture,
|
||||
builder: (ctx, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snap.hasError) {
|
||||
final err = snap.error;
|
||||
final code = err is DioException
|
||||
? ApiError.fromDio(err).code
|
||||
: 'unknown';
|
||||
return Center(
|
||||
child: Text('Search failed: $code',
|
||||
style: TextStyle(color: fs.error)),
|
||||
);
|
||||
}
|
||||
final rows = snap.data ?? const [];
|
||||
if (rows.isEmpty) {
|
||||
return Center(
|
||||
child: Text('No matches.',
|
||||
style: TextStyle(color: fs.ash)),
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
itemCount: rows.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
Divider(height: 1, color: fs.iron),
|
||||
itemBuilder: (ctx, i) => _ResultTile(
|
||||
row: rows[i],
|
||||
onRequest: () => _request(rows[i]),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ResultTile extends StatelessWidget {
|
||||
const _ResultTile({required this.row, required this.onRequest});
|
||||
final LidarrSearchResult row;
|
||||
final VoidCallback onRequest;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final disabled = row.inLibrary || row.requested;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
color: fs.slate,
|
||||
child: row.imageUrl.isEmpty
|
||||
? Icon(Icons.album, color: fs.ash)
|
||||
: Image.network(row.imageUrl, fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
Icon(Icons.album, color: fs.ash)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(row.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 14)),
|
||||
if (row.secondaryText.isNotEmpty)
|
||||
Text(row.secondaryText,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.ash, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (row.inLibrary)
|
||||
_Pill(label: 'In library', color: fs.ash)
|
||||
else if (row.requested)
|
||||
_Pill(label: 'Requested', color: fs.ash)
|
||||
else
|
||||
FilledButton(
|
||||
onPressed: disabled ? null : onRequest,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: fs.accent,
|
||||
foregroundColor: fs.parchment,
|
||||
),
|
||||
child: const Text('Request'),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Pill extends StatelessWidget {
|
||||
const _Pill({required this.label, required this.color});
|
||||
final String label;
|
||||
final Color color;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: fs.iron,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(label, style: TextStyle(color: color, fontSize: 11)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import '../models/artist.dart';
|
||||
import '../models/track.dart';
|
||||
import '../player/player_provider.dart';
|
||||
import '../shared/widgets/connection_error_banner.dart';
|
||||
import '../shared/widgets/main_app_bar_actions.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'library_providers.dart';
|
||||
import 'widgets/album_card.dart';
|
||||
@@ -24,6 +25,12 @@ class HomeScreen extends ConsumerWidget {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return Scaffold(
|
||||
backgroundColor: fs.obsidian,
|
||||
appBar: AppBar(
|
||||
backgroundColor: fs.obsidian,
|
||||
elevation: 0,
|
||||
title: Text('Minstrel', style: TextStyle(color: fs.parchment)),
|
||||
actions: const [MainAppBarActions(currentRoute: '/home')],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: ref.watch(homeProvider).when(
|
||||
error: (e, _) {
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../api/endpoints/library_lists.dart';
|
||||
import '../api/endpoints/likes.dart';
|
||||
import '../api/endpoints/me.dart';
|
||||
import '../library/library_providers.dart' show dioProvider;
|
||||
import '../models/album.dart';
|
||||
import '../models/artist.dart';
|
||||
import '../models/history_event.dart';
|
||||
// Aliased: Flutter's Material exports a `Page` class (Navigator 2.0
|
||||
// route descriptor) that conflicts with our wire-format Page<T>.
|
||||
import '../models/page.dart' as wire;
|
||||
import '../models/quarantine_mine.dart';
|
||||
import '../models/track.dart';
|
||||
import '../player/player_provider.dart';
|
||||
import '../shared/widgets/main_app_bar_actions.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'widgets/album_card.dart';
|
||||
import 'widgets/artist_card.dart';
|
||||
import 'widgets/track_row.dart';
|
||||
|
||||
// Providers scoped to this screen. Each tab gets its own first page.
|
||||
// Infinite scroll is a future enhancement; for v1 phone testing the
|
||||
// first 50 rows per tab is enough.
|
||||
|
||||
final _libraryListsApiProvider = FutureProvider<LibraryListsApi>((ref) async {
|
||||
return LibraryListsApi(await ref.watch(dioProvider.future));
|
||||
});
|
||||
|
||||
final _likesApiProvider = FutureProvider<LikesApi>((ref) async {
|
||||
return LikesApi(await ref.watch(dioProvider.future));
|
||||
});
|
||||
|
||||
final _meApiProvider = FutureProvider<MeApi>((ref) async {
|
||||
return MeApi(await ref.watch(dioProvider.future));
|
||||
});
|
||||
|
||||
final _libraryArtistsProvider = FutureProvider<wire.Paged<ArtistRef>>((ref) async {
|
||||
return (await ref.watch(_libraryListsApiProvider.future)).listArtists();
|
||||
});
|
||||
|
||||
final _libraryAlbumsProvider = FutureProvider<wire.Paged<AlbumRef>>((ref) async {
|
||||
return (await ref.watch(_libraryListsApiProvider.future)).listAlbums();
|
||||
});
|
||||
|
||||
final _historyProvider = FutureProvider<HistoryPage>((ref) async {
|
||||
return (await ref.watch(_meApiProvider.future)).history();
|
||||
});
|
||||
|
||||
final _likedTracksProvider = FutureProvider<wire.Paged<TrackRef>>((ref) async {
|
||||
return (await ref.watch(_likesApiProvider.future)).listTracks();
|
||||
});
|
||||
|
||||
final _likedAlbumsProvider = FutureProvider<wire.Paged<AlbumRef>>((ref) async {
|
||||
return (await ref.watch(_likesApiProvider.future)).listAlbums();
|
||||
});
|
||||
|
||||
final _likedArtistsProvider = FutureProvider<wire.Paged<ArtistRef>>((ref) async {
|
||||
return (await ref.watch(_likesApiProvider.future)).listArtists();
|
||||
});
|
||||
|
||||
final _quarantineProvider = FutureProvider<List<QuarantineMineRow>>((ref) async {
|
||||
return (await ref.watch(_meApiProvider.future)).quarantineMine();
|
||||
});
|
||||
|
||||
class LibraryScreen extends ConsumerStatefulWidget {
|
||||
const LibraryScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LibraryScreen> createState() => _LibraryScreenState();
|
||||
}
|
||||
|
||||
class _LibraryScreenState extends ConsumerState<LibraryScreen>
|
||||
with TickerProviderStateMixin {
|
||||
late final TabController _ctrl = TabController(length: 5, vsync: this);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return Scaffold(
|
||||
backgroundColor: fs.obsidian,
|
||||
appBar: AppBar(
|
||||
backgroundColor: fs.obsidian,
|
||||
elevation: 0,
|
||||
title: Text('Library', style: TextStyle(color: fs.parchment)),
|
||||
actions: const [MainAppBarActions(currentRoute: '/library')],
|
||||
bottom: TabBar(
|
||||
controller: _ctrl,
|
||||
isScrollable: true,
|
||||
tabAlignment: TabAlignment.start,
|
||||
labelColor: fs.parchment,
|
||||
unselectedLabelColor: fs.ash,
|
||||
indicatorColor: fs.accent,
|
||||
tabs: const [
|
||||
Tab(text: 'Artists'),
|
||||
Tab(text: 'Albums'),
|
||||
Tab(text: 'History'),
|
||||
Tab(text: 'Liked'),
|
||||
Tab(text: 'Hidden'),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: TabBarView(
|
||||
controller: _ctrl,
|
||||
children: const [
|
||||
_ArtistsTab(),
|
||||
_AlbumsTab(),
|
||||
_HistoryTab(),
|
||||
_LikedTab(),
|
||||
_HiddenTab(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ArtistsTab extends ConsumerWidget {
|
||||
const _ArtistsTab();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return ref.watch(_libraryArtistsProvider).when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||
data: (page) => page.items.isEmpty
|
||||
? Center(child: Text('No artists.', style: TextStyle(color: fs.ash)))
|
||||
: RefreshIndicator(
|
||||
onRefresh: () async => ref.refresh(_libraryArtistsProvider.future),
|
||||
child: GridView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
childAspectRatio: 0.78,
|
||||
),
|
||||
itemCount: page.items.length,
|
||||
itemBuilder: (ctx, i) => ArtistCard(
|
||||
artist: page.items[i],
|
||||
onTap: () => ctx.push('/artists/${page.items[i].id}'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AlbumsTab extends ConsumerWidget {
|
||||
const _AlbumsTab();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return ref.watch(_libraryAlbumsProvider).when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||
data: (page) => page.items.isEmpty
|
||||
? Center(child: Text('No albums.', style: TextStyle(color: fs.ash)))
|
||||
: RefreshIndicator(
|
||||
onRefresh: () async => ref.refresh(_libraryAlbumsProvider.future),
|
||||
child: GridView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
childAspectRatio: 0.78,
|
||||
),
|
||||
itemCount: page.items.length,
|
||||
itemBuilder: (ctx, i) => AlbumCard(
|
||||
album: page.items[i],
|
||||
onTap: () => ctx.push('/albums/${page.items[i].id}'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HistoryTab extends ConsumerWidget {
|
||||
const _HistoryTab();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return ref.watch(_historyProvider).when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||
data: (page) => page.events.isEmpty
|
||||
? Center(child: Text('No listening history yet.', style: TextStyle(color: fs.ash)))
|
||||
: RefreshIndicator(
|
||||
onRefresh: () async => ref.refresh(_historyProvider.future),
|
||||
child: ListView.separated(
|
||||
itemCount: page.events.length,
|
||||
separatorBuilder: (_, __) => Divider(height: 1, color: fs.iron),
|
||||
itemBuilder: (ctx, i) {
|
||||
final event = page.events[i];
|
||||
return TrackRow(
|
||||
track: event.track,
|
||||
onTap: () => ref
|
||||
.read(playerActionsProvider)
|
||||
.playTracks([event.track]),
|
||||
trailing: Text(
|
||||
_relativeTime(event.playedAt),
|
||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LikedTab extends ConsumerWidget {
|
||||
const _LikedTab();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final tracksA = ref.watch(_likedTracksProvider);
|
||||
final albumsA = ref.watch(_likedAlbumsProvider);
|
||||
final artistsA = ref.watch(_likedArtistsProvider);
|
||||
|
||||
if (tracksA.isLoading || albumsA.isLoading || artistsA.isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final t = tracksA.value;
|
||||
final al = albumsA.value;
|
||||
final ar = artistsA.value;
|
||||
if (t == null || al == null || ar == null) {
|
||||
return Center(child: Text("Couldn't load liked items.", style: TextStyle(color: fs.error)));
|
||||
}
|
||||
if (t.items.isEmpty && al.items.isEmpty && ar.items.isEmpty) {
|
||||
return Center(child: Text('Nothing liked yet.', style: TextStyle(color: fs.ash)));
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
await Future.wait([
|
||||
ref.refresh(_likedTracksProvider.future),
|
||||
ref.refresh(_likedAlbumsProvider.future),
|
||||
ref.refresh(_likedArtistsProvider.future),
|
||||
]);
|
||||
},
|
||||
child: ListView(children: [
|
||||
if (ar.items.isNotEmpty) ...[
|
||||
_SectionHeader(label: 'Artists', count: ar.total),
|
||||
SizedBox(
|
||||
height: 168,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
itemCount: ar.items.length,
|
||||
itemBuilder: (ctx, i) => ArtistCard(
|
||||
artist: ar.items[i],
|
||||
onTap: () => ctx.push('/artists/${ar.items[i].id}'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (al.items.isNotEmpty) ...[
|
||||
_SectionHeader(label: 'Albums', count: al.total),
|
||||
SizedBox(
|
||||
height: 200,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
itemCount: al.items.length,
|
||||
itemBuilder: (ctx, i) => AlbumCard(
|
||||
album: al.items[i],
|
||||
onTap: () => ctx.push('/albums/${al.items[i].id}'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (t.items.isNotEmpty) ...[
|
||||
_SectionHeader(label: 'Tracks', count: t.total),
|
||||
...t.items.asMap().entries.map((e) {
|
||||
return TrackRow(
|
||||
track: e.value,
|
||||
onTap: () => ref
|
||||
.read(playerActionsProvider)
|
||||
.playTracks(t.items, initialIndex: e.key),
|
||||
);
|
||||
}),
|
||||
],
|
||||
const SizedBox(height: 96),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HiddenTab extends ConsumerWidget {
|
||||
const _HiddenTab();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return ref.watch(_quarantineProvider).when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||
data: (rows) => rows.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'No hidden tracks.\nUse a track\'s menu to flag bad rips or wrong tags.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: fs.ash),
|
||||
),
|
||||
)
|
||||
: RefreshIndicator(
|
||||
onRefresh: () async => ref.refresh(_quarantineProvider.future),
|
||||
child: ListView.separated(
|
||||
itemCount: rows.length,
|
||||
separatorBuilder: (_, __) => Divider(height: 1, color: fs.iron),
|
||||
itemBuilder: (ctx, i) => _QuarantineTile(row: rows[i]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QuarantineTile extends StatelessWidget {
|
||||
const _QuarantineTile({required this.row});
|
||||
final QuarantineMineRow row;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final reasonLabel = switch (row.reason) {
|
||||
'bad_rip' => 'Bad rip',
|
||||
'wrong_file' => 'Wrong file',
|
||||
'wrong_tags' => 'Wrong tags',
|
||||
'duplicate' => 'Duplicate',
|
||||
_ => 'Other',
|
||||
};
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(
|
||||
row.trackTitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 14),
|
||||
),
|
||||
Text(
|
||||
'${row.artistName} · ${row.albumTitle}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: fs.iron,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(reasonLabel, style: TextStyle(color: fs.ash, fontSize: 11)),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader({required this.label, required this.count});
|
||||
final String label;
|
||||
final int count;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Row(children: [
|
||||
Text(label,
|
||||
style: TextStyle(
|
||||
color: fs.parchment, fontSize: 18, fontWeight: FontWeight.w500)),
|
||||
const SizedBox(width: 8),
|
||||
Text('$count', style: TextStyle(color: fs.ash, fontSize: 13)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Lightweight relative-time formatter: <1h "23m ago" / <24h "3h ago"
|
||||
/// / <7d "Tue 14:32" / older "May 1" / older diff-year "May 1, 2025".
|
||||
/// Mirrors web/src/lib/components/HistoryRow.svelte's relativeTime.
|
||||
String _relativeTime(String iso) {
|
||||
final t = DateTime.tryParse(iso);
|
||||
if (t == null) return '';
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(t);
|
||||
if (diff.inMinutes < 60) {
|
||||
final m = diff.inMinutes < 1 ? 1 : diff.inMinutes;
|
||||
return '${m}m ago';
|
||||
}
|
||||
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
||||
if (diff.inDays < 7) {
|
||||
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
final hh = t.hour.toString().padLeft(2, '0');
|
||||
final mm = t.minute.toString().padLeft(2, '0');
|
||||
return '${days[t.weekday - 1]} $hh:$mm';
|
||||
}
|
||||
const months = [
|
||||
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
|
||||
];
|
||||
if (t.year == now.year) return '${months[t.month - 1]} ${t.day}';
|
||||
return '${months[t.month - 1]} ${t.day}, ${t.year}';
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/// One user's report under an aggregated quarantine row.
|
||||
class AdminQuarantineReport {
|
||||
const AdminQuarantineReport({
|
||||
required this.userId,
|
||||
required this.username,
|
||||
required this.reason,
|
||||
this.notes,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
final String userId;
|
||||
final String username;
|
||||
final String reason;
|
||||
final String? notes;
|
||||
final String createdAt;
|
||||
|
||||
factory AdminQuarantineReport.fromJson(Map<String, dynamic> j) =>
|
||||
AdminQuarantineReport(
|
||||
userId: j['user_id'] as String? ?? '',
|
||||
username: j['username'] as String? ?? '',
|
||||
reason: j['reason'] as String? ?? '',
|
||||
notes: j['notes'] as String?,
|
||||
createdAt: j['created_at'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/// Mirrors `adminQueueRowView` from internal/api/admin_quarantine.go.
|
||||
/// One row aggregates all reports against a single track, with
|
||||
/// per-reason counts and the underlying per-user reports nested.
|
||||
class AdminQuarantineItem {
|
||||
const AdminQuarantineItem({
|
||||
required this.trackId,
|
||||
required this.trackTitle,
|
||||
required this.artistName,
|
||||
required this.albumTitle,
|
||||
required this.albumId,
|
||||
this.lidarrAlbumMbid,
|
||||
required this.reportCount,
|
||||
required this.latestAt,
|
||||
required this.reasonCounts,
|
||||
required this.reports,
|
||||
});
|
||||
|
||||
final String trackId;
|
||||
final String trackTitle;
|
||||
final String artistName;
|
||||
final String albumTitle;
|
||||
final String albumId;
|
||||
final String? lidarrAlbumMbid;
|
||||
final int reportCount;
|
||||
final String latestAt;
|
||||
|
||||
/// Map of reason → number of reports citing that reason.
|
||||
final Map<String, int> reasonCounts;
|
||||
|
||||
final List<AdminQuarantineReport> reports;
|
||||
|
||||
/// One-line summary of the most-cited reason. Returns just the top
|
||||
/// reason when only one reason exists, or `top (+N more)` when there
|
||||
/// are additional distinct reasons.
|
||||
String get topReasonSummary {
|
||||
if (reasonCounts.isEmpty) return '';
|
||||
final entries = reasonCounts.entries.toList()
|
||||
..sort((a, b) => b.value.compareTo(a.value));
|
||||
final top = entries.first.key;
|
||||
return entries.length > 1
|
||||
? '$top (+${entries.length - 1} more)'
|
||||
: top;
|
||||
}
|
||||
|
||||
factory AdminQuarantineItem.fromJson(Map<String, dynamic> j) {
|
||||
final reasonCountsRaw =
|
||||
(j['reason_counts'] as Map?)?.cast<String, dynamic>() ?? const {};
|
||||
final reportsRaw = (j['reports'] as List?) ?? const [];
|
||||
return AdminQuarantineItem(
|
||||
trackId: j['track_id'] as String? ?? '',
|
||||
trackTitle: j['track_title'] as String? ?? '',
|
||||
artistName: j['artist_name'] as String? ?? '',
|
||||
albumTitle: j['album_title'] as String? ?? '',
|
||||
albumId: j['album_id'] as String? ?? '',
|
||||
lidarrAlbumMbid: j['lidarr_album_mbid'] as String?,
|
||||
reportCount: (j['report_count'] as int?) ?? 0,
|
||||
latestAt: j['latest_at'] as String? ?? '',
|
||||
reasonCounts:
|
||||
reasonCountsRaw.map((k, v) => MapEntry(k, (v as int?) ?? 0)),
|
||||
reports: reportsRaw
|
||||
.map((e) =>
|
||||
AdminQuarantineReport.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(growable: false),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/// Mirrors `requestView` from internal/api/requests.go. Server returns
|
||||
/// these as a flat list (no envelope) from GET /api/admin/requests.
|
||||
///
|
||||
/// Note the requester is exposed only as a UUID (`user_id`) — the
|
||||
/// response does not include the username. The Flutter Requests screen
|
||||
/// joins client-side against the AdminUser list for display.
|
||||
class AdminRequest {
|
||||
const AdminRequest({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
required this.status,
|
||||
required this.kind,
|
||||
required this.artistName,
|
||||
this.albumTitle,
|
||||
this.trackTitle,
|
||||
required this.requestedAt,
|
||||
this.decidedAt,
|
||||
this.notes,
|
||||
required this.importedAlbumCount,
|
||||
required this.importedTrackCount,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String userId;
|
||||
|
||||
/// One of: pending, approved, rejected, completed, failed.
|
||||
final String status;
|
||||
|
||||
/// One of: artist, album, track.
|
||||
final String kind;
|
||||
|
||||
final String artistName;
|
||||
final String? albumTitle;
|
||||
final String? trackTitle;
|
||||
final String requestedAt;
|
||||
final String? decidedAt;
|
||||
final String? notes;
|
||||
final int importedAlbumCount;
|
||||
final int importedTrackCount;
|
||||
|
||||
/// Display label depending on the kind of request — what the user
|
||||
/// asked for. For an album request it's the album title; for an
|
||||
/// artist request it's the artist name; etc.
|
||||
String get displayName => switch (kind) {
|
||||
'album' => albumTitle ?? artistName,
|
||||
'track' => trackTitle ?? artistName,
|
||||
_ => artistName,
|
||||
};
|
||||
|
||||
factory AdminRequest.fromJson(Map<String, dynamic> j) => AdminRequest(
|
||||
id: j['id'] as String? ?? '',
|
||||
userId: j['user_id'] as String? ?? '',
|
||||
status: j['status'] as String? ?? 'pending',
|
||||
kind: j['kind'] as String? ?? 'artist',
|
||||
artistName: j['artist_name'] as String? ?? '',
|
||||
albumTitle: j['album_title'] as String?,
|
||||
trackTitle: j['track_title'] as String?,
|
||||
requestedAt: j['requested_at'] as String? ?? '',
|
||||
decidedAt: j['decided_at'] as String?,
|
||||
notes: j['notes'] as String?,
|
||||
importedAlbumCount: (j['imported_album_count'] as int?) ?? 0,
|
||||
importedTrackCount: (j['imported_track_count'] as int?) ?? 0,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/// Mirrors `adminUserView` from internal/api/admin_users.go. Distinct
|
||||
/// from MyProfile because admin endpoints expose `auto_approve_requests`
|
||||
/// and the canonical `created_at` that the user-scoped /me response
|
||||
/// intentionally omits.
|
||||
///
|
||||
/// The list endpoint wraps these in `{"users": [...]}`; the parsing of
|
||||
/// the envelope is done in `AdminUsersApi`, not here.
|
||||
class AdminUser {
|
||||
const AdminUser({
|
||||
required this.id,
|
||||
required this.username,
|
||||
this.displayName,
|
||||
required this.isAdmin,
|
||||
required this.autoApproveRequests,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String username;
|
||||
final String? displayName;
|
||||
final bool isAdmin;
|
||||
final bool autoApproveRequests;
|
||||
final String createdAt;
|
||||
|
||||
factory AdminUser.fromJson(Map<String, dynamic> j) => AdminUser(
|
||||
id: j['id'] as String? ?? '',
|
||||
username: j['username'] as String? ?? '',
|
||||
displayName: j['display_name'] as String?,
|
||||
isAdmin: j['is_admin'] as bool? ?? false,
|
||||
autoApproveRequests: j['auto_approve_requests'] as bool? ?? false,
|
||||
createdAt: j['created_at'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'track.dart';
|
||||
|
||||
/// Mirrors web/src/lib/api/history.ts HistoryEvent. Server emits
|
||||
/// `{events, has_more}` rather than the standard `Page<T>` envelope —
|
||||
/// history is timestamp-keyed so total isn't meaningful, only "more
|
||||
/// pages exist below."
|
||||
class HistoryEvent {
|
||||
const HistoryEvent({
|
||||
required this.id,
|
||||
required this.playedAt,
|
||||
required this.track,
|
||||
});
|
||||
|
||||
final String id;
|
||||
/// RFC3339 timestamp; UI formats relative ("3h ago" / "Tue 14:32" /
|
||||
/// "May 1, 2025") via formatRelative helper.
|
||||
final String playedAt;
|
||||
final TrackRef track;
|
||||
|
||||
factory HistoryEvent.fromJson(Map<String, dynamic> j) => HistoryEvent(
|
||||
id: j['id'] as String? ?? '',
|
||||
playedAt: j['played_at'] as String? ?? '',
|
||||
track: TrackRef.fromJson(
|
||||
(j['track'] as Map?)?.cast<String, dynamic>() ?? const {},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class HistoryPage {
|
||||
const HistoryPage({required this.events, required this.hasMore});
|
||||
|
||||
final List<HistoryEvent> events;
|
||||
final bool hasMore;
|
||||
|
||||
factory HistoryPage.fromJson(Map<String, dynamic> j) {
|
||||
final raw = (j['events'] as List?) ?? const [];
|
||||
return HistoryPage(
|
||||
events: raw
|
||||
.map((e) => HistoryEvent.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(growable: false),
|
||||
hasMore: j['has_more'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/// Mirrors `inviteResp` from internal/api/admin_invites.go. The list
|
||||
/// endpoint wraps these in `{"invites": [...]}`; the create endpoint
|
||||
/// returns a single bare invite. Envelope parsing is done in
|
||||
/// `AdminInvitesApi`, not here.
|
||||
///
|
||||
/// `invitedBy` is the UUID of the inviting admin (not their username);
|
||||
/// the server doesn't currently denormalize it. Likewise `redeemedBy`.
|
||||
/// TTL is hardcoded server-side at 24h, so admins can't customise the
|
||||
/// expiry — only the optional `note`.
|
||||
class Invite {
|
||||
const Invite({
|
||||
required this.token,
|
||||
required this.invitedBy,
|
||||
this.note,
|
||||
required this.createdAt,
|
||||
required this.expiresAt,
|
||||
this.redeemedAt,
|
||||
this.redeemedBy,
|
||||
});
|
||||
|
||||
final String token;
|
||||
final String invitedBy;
|
||||
final String? note;
|
||||
final String createdAt;
|
||||
final String expiresAt;
|
||||
final String? redeemedAt;
|
||||
final String? redeemedBy;
|
||||
|
||||
bool get isRedeemed => redeemedAt != null;
|
||||
|
||||
factory Invite.fromJson(Map<String, dynamic> j) => Invite(
|
||||
token: j['token'] as String? ?? '',
|
||||
invitedBy: j['invited_by'] as String? ?? '',
|
||||
note: j['note'] as String?,
|
||||
createdAt: j['created_at'] as String? ?? '',
|
||||
expiresAt: j['expires_at'] as String? ?? '',
|
||||
redeemedAt: j['redeemed_at'] as String?,
|
||||
redeemedBy: j['redeemed_by'] as String?,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/// Mirrors web/src/lib/api/types.ts LidarrSearchResult — one row in
|
||||
/// `/api/lidarr/search` results. `in_library` and `requested` let the
|
||||
/// UI gray out rows the user can't act on (already imported / already
|
||||
/// awaiting review).
|
||||
class LidarrSearchResult {
|
||||
const LidarrSearchResult({
|
||||
required this.mbid,
|
||||
required this.name,
|
||||
required this.secondaryText,
|
||||
required this.imageUrl,
|
||||
required this.artistMbid,
|
||||
required this.albumMbid,
|
||||
required this.inLibrary,
|
||||
required this.requested,
|
||||
});
|
||||
|
||||
final String mbid;
|
||||
final String name;
|
||||
final String secondaryText;
|
||||
final String imageUrl;
|
||||
final String artistMbid;
|
||||
final String albumMbid;
|
||||
final bool inLibrary;
|
||||
final bool requested;
|
||||
|
||||
factory LidarrSearchResult.fromJson(Map<String, dynamic> j) =>
|
||||
LidarrSearchResult(
|
||||
mbid: j['mbid'] as String? ?? '',
|
||||
name: j['name'] as String? ?? '',
|
||||
secondaryText: j['secondary_text'] as String? ?? '',
|
||||
imageUrl: j['image_url'] as String? ?? '',
|
||||
artistMbid: j['artist_mbid'] as String? ?? '',
|
||||
albumMbid: j['album_mbid'] as String? ?? '',
|
||||
inLibrary: j['in_library'] as bool? ?? false,
|
||||
requested: j['requested'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
enum LidarrRequestKind { artist, album, track }
|
||||
|
||||
extension LidarrRequestKindStr on LidarrRequestKind {
|
||||
String get wire => switch (this) {
|
||||
LidarrRequestKind.artist => 'artist',
|
||||
LidarrRequestKind.album => 'album',
|
||||
LidarrRequestKind.track => 'track',
|
||||
};
|
||||
}
|
||||
@@ -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,44 @@
|
||||
// Mirrors the server's Page[T] envelope used by paged endpoints
|
||||
// (/api/search, /api/library/artists, /api/library/albums,
|
||||
// /api/likes/*). Class is named `Paged<T>` rather than `Page<T>`
|
||||
// to avoid ambiguous imports with Flutter Material's Navigator-2.0
|
||||
// `Page` class.
|
||||
//
|
||||
// Wire shape from internal/api/types.go Page[T]:
|
||||
// { items: T[], total: int, limit: int, offset: int }
|
||||
//
|
||||
// Dart generics can't auto-infer T from JSON, so callers pass an
|
||||
// itemFromJson function alongside the raw map. Parse logic stays in
|
||||
// each entity's own fromJson; this class only handles the envelope.
|
||||
class Paged<T> {
|
||||
const Paged({
|
||||
required this.items,
|
||||
required this.total,
|
||||
required this.limit,
|
||||
required this.offset,
|
||||
});
|
||||
|
||||
final List<T> items;
|
||||
final int total;
|
||||
final int limit;
|
||||
final int offset;
|
||||
|
||||
factory Paged.fromJson(
|
||||
Map<String, dynamic> j,
|
||||
T Function(Map<String, dynamic>) itemFromJson,
|
||||
) {
|
||||
final raw = (j['items'] as List?) ?? const [];
|
||||
return Paged<T>(
|
||||
items: raw
|
||||
.map((e) => itemFromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(growable: false),
|
||||
total: (j['total'] as num?)?.toInt() ?? 0,
|
||||
limit: (j['limit'] as num?)?.toInt() ?? 0,
|
||||
offset: (j['offset'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
/// Convenience for endpoints that always return the first page or
|
||||
/// when the caller just wants the items.
|
||||
bool get hasMore => offset + items.length < total;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// Mirrors internal/api/playlists.go Playlist + PlaylistDetail wire shapes.
|
||||
//
|
||||
// Server distinguishes user playlists (created by the caller) from
|
||||
// system playlists (server-generated mixes like for_you / discover).
|
||||
// system_variant is null for user playlists; "for_you" / "discover"
|
||||
// otherwise. Read-only operations work the same; PATCH/POST/DELETE
|
||||
// are gated on user-owned playlists.
|
||||
|
||||
class Playlist {
|
||||
const Playlist({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.isPublic,
|
||||
required this.systemVariant,
|
||||
required this.trackCount,
|
||||
required this.coverUrl,
|
||||
required this.ownerUsername,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String userId;
|
||||
final String name;
|
||||
final String description;
|
||||
final bool isPublic;
|
||||
/// "for_you" / "discover" / null
|
||||
final String? systemVariant;
|
||||
final int trackCount;
|
||||
/// Server-emitted URL to the cover; empty when no tracks yet.
|
||||
final String coverUrl;
|
||||
/// Display name of the owner — for showing other users' public playlists.
|
||||
final String ownerUsername;
|
||||
final String createdAt;
|
||||
final String updatedAt;
|
||||
|
||||
bool get isSystem => systemVariant != null;
|
||||
|
||||
factory Playlist.fromJson(Map<String, dynamic> j) => Playlist(
|
||||
id: j['id'] as String,
|
||||
userId: j['user_id'] as String? ?? '',
|
||||
name: j['name'] as String? ?? '',
|
||||
description: j['description'] as String? ?? '',
|
||||
isPublic: j['is_public'] as bool? ?? false,
|
||||
systemVariant: j['system_variant'] as String?,
|
||||
trackCount: (j['track_count'] as num?)?.toInt() ?? 0,
|
||||
coverUrl: j['cover_url'] as String? ?? '',
|
||||
ownerUsername: j['owner_username'] as String? ?? '',
|
||||
createdAt: j['created_at'] as String? ?? '',
|
||||
updatedAt: j['updated_at'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/// Playlist row as returned in PlaylistDetail.tracks. Each row carries
|
||||
/// the track's display fields directly so the client doesn't need a
|
||||
/// separate /api/tracks/{id} fetch per row.
|
||||
///
|
||||
/// track_id is nullable: when the upstream track has been removed from
|
||||
/// the library, the row remains in the playlist with its title/artist
|
||||
/// preserved but track_id=null and stream_url=null. UI should render
|
||||
/// these greyed-out and unplayable.
|
||||
class PlaylistTrack {
|
||||
const PlaylistTrack({
|
||||
required this.position,
|
||||
required this.trackId,
|
||||
required this.title,
|
||||
required this.albumId,
|
||||
required this.albumTitle,
|
||||
required this.artistId,
|
||||
required this.artistName,
|
||||
required this.durationSec,
|
||||
required this.streamUrl,
|
||||
});
|
||||
|
||||
final int position;
|
||||
final String? trackId;
|
||||
final String title;
|
||||
final String? albumId;
|
||||
final String albumTitle;
|
||||
final String? artistId;
|
||||
final String artistName;
|
||||
final int durationSec;
|
||||
final String? streamUrl;
|
||||
|
||||
bool get isAvailable => trackId != null;
|
||||
|
||||
factory PlaylistTrack.fromJson(Map<String, dynamic> j) => PlaylistTrack(
|
||||
position: (j['position'] as num?)?.toInt() ?? 0,
|
||||
trackId: j['track_id'] as String?,
|
||||
title: j['title'] as String? ?? '',
|
||||
albumId: j['album_id'] as String?,
|
||||
albumTitle: j['album_title'] as String? ?? '',
|
||||
artistId: j['artist_id'] as String?,
|
||||
artistName: j['artist_name'] as String? ?? '',
|
||||
durationSec: (j['duration_sec'] as num?)?.toInt() ?? 0,
|
||||
streamUrl: j['stream_url'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
class PlaylistDetail {
|
||||
const PlaylistDetail({required this.playlist, required this.tracks});
|
||||
|
||||
final Playlist playlist;
|
||||
final List<PlaylistTrack> tracks;
|
||||
|
||||
factory PlaylistDetail.fromJson(Map<String, dynamic> j) {
|
||||
final tracksRaw = (j['tracks'] as List?) ?? const [];
|
||||
return PlaylistDetail(
|
||||
playlist: Playlist.fromJson(j),
|
||||
tracks: tracksRaw
|
||||
.map((e) => PlaylistTrack.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(growable: false),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/// Mirrors web/src/lib/api/types.ts LidarrQuarantineMineRow. One row
|
||||
/// per quarantined track owned by the caller. Server returns a flat
|
||||
/// list (no Page envelope) at /api/quarantine/mine.
|
||||
class QuarantineMineRow {
|
||||
const QuarantineMineRow({
|
||||
required this.trackId,
|
||||
required this.reason,
|
||||
this.notes,
|
||||
required this.createdAt,
|
||||
required this.trackTitle,
|
||||
required this.trackDurationMs,
|
||||
required this.albumId,
|
||||
required this.albumTitle,
|
||||
this.albumCoverArtPath,
|
||||
required this.artistId,
|
||||
required this.artistName,
|
||||
});
|
||||
|
||||
final String trackId;
|
||||
/// One of: bad_rip / wrong_file / wrong_tags / duplicate / other.
|
||||
final String reason;
|
||||
final String? notes;
|
||||
final String createdAt;
|
||||
final String trackTitle;
|
||||
final int trackDurationMs;
|
||||
final String albumId;
|
||||
final String albumTitle;
|
||||
final String? albumCoverArtPath;
|
||||
final String artistId;
|
||||
final String artistName;
|
||||
|
||||
factory QuarantineMineRow.fromJson(Map<String, dynamic> j) =>
|
||||
QuarantineMineRow(
|
||||
trackId: j['track_id'] as String? ?? '',
|
||||
reason: j['reason'] as String? ?? 'other',
|
||||
notes: j['notes'] as String?,
|
||||
createdAt: j['created_at'] as String? ?? '',
|
||||
trackTitle: j['track_title'] as String? ?? '',
|
||||
trackDurationMs: (j['track_duration_ms'] as num?)?.toInt() ?? 0,
|
||||
albumId: j['album_id'] as String? ?? '',
|
||||
albumTitle: j['album_title'] as String? ?? '',
|
||||
albumCoverArtPath: j['album_cover_art_path'] as String?,
|
||||
artistId: j['artist_id'] as String? ?? '',
|
||||
artistName: j['artist_name'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'album.dart';
|
||||
import 'artist.dart';
|
||||
import 'page.dart';
|
||||
import 'track.dart';
|
||||
|
||||
/// Mirrors internal/api/search.go SearchResponse — three pages keyed by
|
||||
/// facet, each independently paged. The mobile UI usually only walks
|
||||
/// the first page of each facet (limit 20-50); infinite scroll within a
|
||||
/// facet is a future enhancement, not v1.
|
||||
class SearchResponse {
|
||||
const SearchResponse({
|
||||
required this.artists,
|
||||
required this.albums,
|
||||
required this.tracks,
|
||||
});
|
||||
|
||||
final Paged<ArtistRef> artists;
|
||||
final Paged<AlbumRef> albums;
|
||||
final Paged<TrackRef> tracks;
|
||||
|
||||
factory SearchResponse.fromJson(Map<String, dynamic> j) => SearchResponse(
|
||||
artists: Paged.fromJson(
|
||||
(j['artists'] as Map?)?.cast<String, dynamic>() ?? const {},
|
||||
ArtistRef.fromJson,
|
||||
),
|
||||
albums: Paged.fromJson(
|
||||
(j['albums'] as Map?)?.cast<String, dynamic>() ?? const {},
|
||||
AlbumRef.fromJson,
|
||||
),
|
||||
tracks: Paged.fromJson(
|
||||
(j['tracks'] as Map?)?.cast<String, dynamic>() ?? const {},
|
||||
TrackRef.fromJson,
|
||||
),
|
||||
);
|
||||
|
||||
bool get isEmpty =>
|
||||
artists.items.isEmpty && albums.items.isEmpty && tracks.items.isEmpty;
|
||||
}
|
||||
@@ -34,8 +34,8 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
return AudioSource.uri(Uri.parse(url), headers: headers);
|
||||
}).toList();
|
||||
|
||||
await _player.setAudioSource(
|
||||
ConcatenatingAudioSource(children: sources),
|
||||
await _player.setAudioSources(
|
||||
sources,
|
||||
initialIndex: initialIndex,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'player_provider.dart';
|
||||
@@ -10,8 +11,8 @@ class NowPlayingScreen extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final media = ref.watch(mediaItemProvider).valueOrNull;
|
||||
final playback = ref.watch(playbackStateProvider).valueOrNull;
|
||||
final media = ref.watch(mediaItemProvider).value;
|
||||
final playback = ref.watch(playbackStateProvider).value;
|
||||
|
||||
if (media == null) {
|
||||
return const Scaffold(body: Center(child: Text('Nothing playing.')));
|
||||
@@ -28,6 +29,13 @@ class NowPlayingScreen extends ConsumerWidget {
|
||||
icon: Icon(Icons.expand_more, color: fs.parchment),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.queue_music, color: fs.parchment),
|
||||
tooltip: 'Queue',
|
||||
onPressed: () => GoRouter.of(context).push('/queue'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
|
||||
@@ -11,8 +11,8 @@ class PlayerBar extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final mediaItem = ref.watch(mediaItemProvider).valueOrNull;
|
||||
final playback = ref.watch(playbackStateProvider).valueOrNull;
|
||||
final mediaItem = ref.watch(mediaItemProvider).value;
|
||||
final playback = ref.watch(playbackStateProvider).value;
|
||||
if (mediaItem == null) return const SizedBox.shrink();
|
||||
|
||||
return Material(
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'player_provider.dart';
|
||||
|
||||
class QueueScreen extends ConsumerWidget {
|
||||
const QueueScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final queue = ref.watch(queueProvider).value ?? const [];
|
||||
final current = ref.watch(mediaItemProvider).value;
|
||||
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('Queue', style: TextStyle(color: fs.parchment)),
|
||||
),
|
||||
body: queue.isEmpty
|
||||
? Center(
|
||||
child: Text('Queue is empty.', style: TextStyle(color: fs.ash)),
|
||||
)
|
||||
: ListView.separated(
|
||||
itemCount: queue.length,
|
||||
separatorBuilder: (_, __) => Divider(height: 1, color: fs.iron),
|
||||
itemBuilder: (ctx, i) => _QueueRow(
|
||||
item: queue[i],
|
||||
index: i,
|
||||
isCurrent: current?.id == queue[i].id,
|
||||
onTap: () async {
|
||||
final h = ref.read(audioHandlerProvider);
|
||||
await h.skipToQueueItem(i);
|
||||
await h.play();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QueueRow extends StatelessWidget {
|
||||
const _QueueRow({
|
||||
required this.item,
|
||||
required this.index,
|
||||
required this.isCurrent,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final MediaItem item;
|
||||
final int index;
|
||||
final bool isCurrent;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final dur = item.duration;
|
||||
String? durLabel;
|
||||
if (dur != null) {
|
||||
final m = (dur.inSeconds ~/ 60).toString().padLeft(2, '0');
|
||||
final s = (dur.inSeconds % 60).toString().padLeft(2, '0');
|
||||
durLabel = '$m:$s';
|
||||
}
|
||||
return InkWell(
|
||||
onTap: isCurrent ? null : onTap,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: isCurrent
|
||||
? Border(left: BorderSide(color: fs.accent, width: 2))
|
||||
: null,
|
||||
color: isCurrent ? fs.iron : null,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(children: [
|
||||
if (isCurrent)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Icon(Icons.graphic_eq, color: fs.accent, size: 16),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: isCurrent ? fs.accent : fs.parchment,
|
||||
fontSize: 14,
|
||||
fontWeight: isCurrent ? FontWeight.w500 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${item.artist ?? ''} · ${item.album ?? ''}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (durLabel != null)
|
||||
Text(durLabel, style: TextStyle(color: fs.ash, fontSize: 12)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../models/playlist.dart';
|
||||
import '../models/track.dart';
|
||||
import '../player/player_provider.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'playlists_provider.dart';
|
||||
|
||||
class PlaylistDetailScreen extends ConsumerWidget {
|
||||
const PlaylistDetailScreen({required this.id, super.key});
|
||||
final String id;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final detail = ref.watch(playlistDetailProvider(id));
|
||||
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: detail.maybeWhen(
|
||||
data: (d) => Text(
|
||||
d.playlist.name,
|
||||
style: TextStyle(color: fs.parchment),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
orElse: () => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
body: detail.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) =>
|
||||
Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||
data: (d) => _Body(detail: d),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Body extends ConsumerWidget {
|
||||
const _Body({required this.detail});
|
||||
final PlaylistDetail detail;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tracks = detail.tracks;
|
||||
final playable = tracks.where((t) => t.isAvailable).toList();
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async =>
|
||||
ref.refresh(playlistDetailProvider(detail.playlist.id).future),
|
||||
child: ListView.builder(
|
||||
itemCount: tracks.length + 1, // header + rows
|
||||
itemBuilder: (ctx, i) {
|
||||
if (i == 0) return _Header(detail: detail, playable: playable);
|
||||
final t = tracks[i - 1];
|
||||
return _PlaylistTrackRow(
|
||||
row: t,
|
||||
onTap: t.isAvailable
|
||||
? () {
|
||||
final ref = ProviderScope.containerOf(ctx);
|
||||
final liveTrack = _toTrackRef(t);
|
||||
final playableRefs =
|
||||
playable.map(_toTrackRef).toList(growable: false);
|
||||
final startIdx = playable.indexWhere((p) => p.trackId == t.trackId);
|
||||
ref.read(playerActionsProvider).playTracks(
|
||||
playableRefs,
|
||||
initialIndex: startIdx >= 0 ? startIdx : 0,
|
||||
);
|
||||
// Keep liveTrack referenced to avoid an unused-variable
|
||||
// warning while we leave hooks for menu wiring later.
|
||||
assert(liveTrack.id == t.trackId);
|
||||
}
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TrackRef _toTrackRef(PlaylistTrack t) => TrackRef(
|
||||
id: t.trackId ?? '',
|
||||
title: t.title,
|
||||
albumId: t.albumId ?? '',
|
||||
albumTitle: t.albumTitle,
|
||||
artistId: t.artistId ?? '',
|
||||
artistName: t.artistName,
|
||||
durationSec: t.durationSec,
|
||||
streamUrl: t.streamUrl ?? '',
|
||||
);
|
||||
|
||||
class _Header extends ConsumerWidget {
|
||||
const _Header({required this.detail, required this.playable});
|
||||
final PlaylistDetail detail;
|
||||
final List<PlaylistTrack> playable;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final p = detail.playlist;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
if (p.description.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(
|
||||
p.description,
|
||||
style: TextStyle(color: fs.ash, fontSize: 13),
|
||||
),
|
||||
),
|
||||
Row(children: [
|
||||
Text(
|
||||
'${p.trackCount} ${p.trackCount == 1 ? "track" : "tracks"}',
|
||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||
),
|
||||
const Spacer(),
|
||||
if (playable.isNotEmpty)
|
||||
FilledButton.icon(
|
||||
onPressed: () {
|
||||
final refs = playable.map(_toTrackRef).toList(growable: false);
|
||||
ref.read(playerActionsProvider).playTracks(refs);
|
||||
},
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
label: const Text('Play'),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: fs.accent,
|
||||
foregroundColor: fs.parchment,
|
||||
),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlaylistTrackRow extends StatelessWidget {
|
||||
const _PlaylistTrackRow({required this.row, required this.onTap});
|
||||
final PlaylistTrack row;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final mins = (row.durationSec ~/ 60).toString().padLeft(2, '0');
|
||||
final secs = (row.durationSec % 60).toString().padLeft(2, '0');
|
||||
final color = row.isAvailable ? fs.parchment : fs.ash;
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
row.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 14,
|
||||
decoration: row.isAvailable
|
||||
? null
|
||||
: TextDecoration.lineThrough,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${row.artistName} · ${row.albumTitle}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text('$mins:$secs', style: TextStyle(color: fs.ash, fontSize: 12)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../models/playlist.dart';
|
||||
import '../shared/widgets/main_app_bar_actions.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'playlists_provider.dart';
|
||||
|
||||
class PlaylistsListScreen extends ConsumerWidget {
|
||||
const PlaylistsListScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
// "all" returns user-created + system mixes. Most useful default
|
||||
// on mobile where the user wants to see for-you/discover alongside
|
||||
// their own playlists in one tap.
|
||||
final list = ref.watch(playlistsListProvider('all'));
|
||||
return Scaffold(
|
||||
backgroundColor: fs.obsidian,
|
||||
appBar: AppBar(
|
||||
backgroundColor: fs.obsidian,
|
||||
elevation: 0,
|
||||
title: Text('Playlists', style: TextStyle(color: fs.parchment)),
|
||||
actions: const [MainAppBarActions(currentRoute: '/playlists')],
|
||||
),
|
||||
body: list.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(
|
||||
child: Text('$e', style: TextStyle(color: fs.error)),
|
||||
),
|
||||
data: (items) {
|
||||
if (items.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'No playlists yet.',
|
||||
style: TextStyle(color: fs.ash),
|
||||
),
|
||||
);
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async => ref.refresh(playlistsListProvider('all').future),
|
||||
child: ListView.separated(
|
||||
itemCount: items.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
Divider(height: 1, color: fs.iron),
|
||||
itemBuilder: (ctx, i) {
|
||||
final p = items[i];
|
||||
return _PlaylistTile(
|
||||
playlist: p,
|
||||
onTap: () => ctx.push('/playlists/${p.id}'),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlaylistTile extends StatelessWidget {
|
||||
const _PlaylistTile({required this.playlist, required this.onTap});
|
||||
final Playlist playlist;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
color: fs.slate,
|
||||
child: playlist.coverUrl.isEmpty
|
||||
? Icon(Icons.queue_music, color: fs.ash)
|
||||
: Image.network(playlist.coverUrl, fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
playlist.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 15),
|
||||
),
|
||||
),
|
||||
if (playlist.isSystem) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: fs.iron,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
playlist.systemVariant!.replaceAll('_', ' '),
|
||||
style: TextStyle(color: fs.accent, fontSize: 11),
|
||||
),
|
||||
),
|
||||
],
|
||||
]),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${playlist.trackCount} ${playlist.trackCount == 1 ? "track" : "tracks"}',
|
||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(Icons.chevron_right, color: fs.ash),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../api/endpoints/playlists.dart';
|
||||
import '../library/library_providers.dart' show dioProvider;
|
||||
import '../models/playlist.dart';
|
||||
|
||||
final playlistsApiProvider = FutureProvider<PlaylistsApi>((ref) async {
|
||||
return PlaylistsApi(await ref.watch(dioProvider.future));
|
||||
});
|
||||
|
||||
final playlistsListProvider =
|
||||
FutureProvider.family<List<Playlist>, String>((ref, kind) async {
|
||||
return (await ref.watch(playlistsApiProvider.future)).list(kind: kind);
|
||||
});
|
||||
|
||||
final playlistDetailProvider =
|
||||
FutureProvider.family<PlaylistDetail, String>((ref, id) async {
|
||||
return (await ref.watch(playlistsApiProvider.future)).get(id);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../api/endpoints/search.dart';
|
||||
import '../library/library_providers.dart' show dioProvider;
|
||||
import '../models/search_response.dart';
|
||||
|
||||
final searchApiProvider = FutureProvider<SearchApi>((ref) async {
|
||||
return SearchApi(await ref.watch(dioProvider.future));
|
||||
});
|
||||
|
||||
/// Current search query text. The screen's TextField writes here on
|
||||
/// every keystroke; the resultsProvider below debounces.
|
||||
class SearchQueryNotifier extends Notifier<String> {
|
||||
@override
|
||||
String build() => '';
|
||||
void set(String q) => state = q;
|
||||
}
|
||||
|
||||
final searchQueryProvider =
|
||||
NotifierProvider<SearchQueryNotifier, String>(SearchQueryNotifier.new);
|
||||
|
||||
/// Debounced search results. Returns null for empty queries so the
|
||||
/// screen can show a neutral empty state instead of "0 results."
|
||||
///
|
||||
/// Debounce: 250ms. After the wait, re-reads the live query — if the
|
||||
/// user has typed more in that window, this attempt is silently
|
||||
/// abandoned (returns null) and a newer attempt fires on the next
|
||||
/// rebuild. No request hits the server while the user is mid-typing.
|
||||
final searchResultsProvider = FutureProvider<SearchResponse?>((ref) async {
|
||||
final q = ref.watch(searchQueryProvider).trim();
|
||||
if (q.isEmpty) return null;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 250));
|
||||
// Re-read the (possibly newer) query; if the user typed more, bail.
|
||||
if (ref.read(searchQueryProvider).trim() != q) return null;
|
||||
final api = await ref.watch(searchApiProvider.future);
|
||||
return api.search(q);
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../library/widgets/album_card.dart';
|
||||
import '../library/widgets/artist_card.dart';
|
||||
import '../library/widgets/track_row.dart';
|
||||
import '../models/search_response.dart';
|
||||
import '../player/player_provider.dart';
|
||||
import '../shared/widgets/main_app_bar_actions.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'search_provider.dart';
|
||||
|
||||
class SearchScreen extends ConsumerStatefulWidget {
|
||||
const SearchScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SearchScreen> createState() => _SearchScreenState();
|
||||
}
|
||||
|
||||
class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
final _controller = TextEditingController();
|
||||
final _focus = FocusNode();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Autofocus the field on screen entry; keyboard pops up so the
|
||||
// user can start typing immediately.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _focus.requestFocus());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_focus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final results = ref.watch(searchResultsProvider);
|
||||
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: TextField(
|
||||
controller: _controller,
|
||||
focusNode: _focus,
|
||||
autofocus: true,
|
||||
style: TextStyle(color: fs.parchment),
|
||||
cursorColor: fs.accent,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search artists, albums, tracks',
|
||||
hintStyle: TextStyle(color: fs.ash),
|
||||
border: InputBorder.none,
|
||||
),
|
||||
onChanged: (v) => ref.read(searchQueryProvider.notifier).set(v),
|
||||
textInputAction: TextInputAction.search,
|
||||
),
|
||||
actions: [
|
||||
if (_controller.text.isNotEmpty)
|
||||
IconButton(
|
||||
icon: Icon(Icons.clear, color: fs.ash),
|
||||
onPressed: () {
|
||||
_controller.clear();
|
||||
ref.read(searchQueryProvider.notifier).set('');
|
||||
_focus.requestFocus();
|
||||
},
|
||||
),
|
||||
const MainAppBarActions(currentRoute: '/search'),
|
||||
],
|
||||
),
|
||||
body: results.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text('$e', style: TextStyle(color: fs.error)),
|
||||
),
|
||||
),
|
||||
data: (r) => r == null
|
||||
? const _Hint(message: 'Type to search your library.')
|
||||
: r.isEmpty
|
||||
? const _Hint(message: 'No matches.')
|
||||
: _Results(results: r),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Hint extends StatelessWidget {
|
||||
const _Hint({required this.message});
|
||||
final String message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return Center(
|
||||
child: Text(message, style: TextStyle(color: fs.ash)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Results extends ConsumerWidget {
|
||||
const _Results({required this.results});
|
||||
final SearchResponse results;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return ListView(
|
||||
children: [
|
||||
if (results.artists.items.isNotEmpty) ...[
|
||||
_Header(label: 'Artists', count: results.artists.total),
|
||||
SizedBox(
|
||||
height: 168,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
itemCount: results.artists.items.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final a = results.artists.items[i];
|
||||
return ArtistCard(
|
||||
artist: a,
|
||||
onTap: () => ctx.push('/artists/${a.id}'),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
if (results.albums.items.isNotEmpty) ...[
|
||||
_Header(label: 'Albums', count: results.albums.total),
|
||||
SizedBox(
|
||||
height: 200,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
itemCount: results.albums.items.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final a = results.albums.items[i];
|
||||
return AlbumCard(
|
||||
album: a,
|
||||
onTap: () => ctx.push('/albums/${a.id}'),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
if (results.tracks.items.isNotEmpty) ...[
|
||||
_Header(label: 'Tracks', count: results.tracks.total),
|
||||
...results.tracks.items.asMap().entries.map((e) {
|
||||
final i = e.key;
|
||||
final t = e.value;
|
||||
return TrackRow(
|
||||
track: t,
|
||||
onTap: () => ref
|
||||
.read(playerActionsProvider)
|
||||
.playTracks(results.tracks.items, initialIndex: i),
|
||||
);
|
||||
}),
|
||||
],
|
||||
const SizedBox(height: 96),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Header extends StatelessWidget {
|
||||
const _Header({required this.label, required this.count});
|
||||
final String label;
|
||||
final int count;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: fs.parchment,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text('$count', style: TextStyle(color: fs.ash, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -7,9 +7,20 @@ import '../auth/login_screen.dart';
|
||||
import '../auth/server_url_screen.dart';
|
||||
import '../library/album_detail_screen.dart';
|
||||
import '../library/artist_detail_screen.dart';
|
||||
import '../discover/discover_screen.dart';
|
||||
import '../library/home_screen.dart';
|
||||
import '../library/library_screen.dart';
|
||||
import '../player/now_playing_screen.dart';
|
||||
import '../player/player_bar.dart';
|
||||
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 '../admin/admin_landing_screen.dart';
|
||||
import '../admin/admin_requests_screen.dart';
|
||||
import '../admin/admin_quarantine_screen.dart';
|
||||
import '../admin/admin_users_screen.dart';
|
||||
import 'widgets/version_gate.dart';
|
||||
|
||||
/// Exposed as a Provider so its single argument is a real `Ref` (the
|
||||
@@ -32,6 +43,9 @@ GoRouter buildRouter(Ref ref) {
|
||||
if (user != null && (loc == '/login' || loc == '/server-url')) {
|
||||
return '/home';
|
||||
}
|
||||
if (loc.startsWith('/admin') && !user!.isAdmin) {
|
||||
return '/home';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
routes: [
|
||||
@@ -51,6 +65,20 @@ GoRouter buildRouter(Ref ref) {
|
||||
builder: (_, s) => AlbumDetailScreen(id: s.pathParameters['id']!),
|
||||
),
|
||||
GoRoute(path: '/now-playing', builder: (_, __) => const NowPlayingScreen()),
|
||||
GoRoute(path: '/queue', builder: (_, __) => const QueueScreen()),
|
||||
GoRoute(path: '/search', builder: (_, __) => const SearchScreen()),
|
||||
GoRoute(path: '/library', builder: (_, __) => const LibraryScreen()),
|
||||
GoRoute(path: '/discover', builder: (_, __) => const DiscoverScreen()),
|
||||
GoRoute(path: '/settings', builder: (_, __) => const SettingsScreen()),
|
||||
GoRoute(path: '/playlists', builder: (_, __) => const PlaylistsListScreen()),
|
||||
GoRoute(
|
||||
path: '/playlists/:id',
|
||||
builder: (_, s) => PlaylistDetailScreen(id: s.pathParameters['id']!),
|
||||
),
|
||||
GoRoute(path: '/admin', builder: (_, __) => const AdminLandingScreen()),
|
||||
GoRoute(path: '/admin/requests', builder: (_, __) => const AdminRequestsScreen()),
|
||||
GoRoute(path: '/admin/quarantine', builder: (_, __) => const AdminQuarantineScreen()),
|
||||
GoRoute(path: '/admin/users', builder: (_, __) => const AdminUsersScreen()),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../auth/auth_provider.dart';
|
||||
import '../../theme/theme_extension.dart';
|
||||
|
||||
/// Shared AppBar `actions` for top-level screens. Renders Home / Library /
|
||||
/// Search as primary icons (suppressing the icon for [currentRoute]) plus
|
||||
/// a kebab overflow with Playlists / Discover / Settings and (for admins)
|
||||
/// Admin.
|
||||
class MainAppBarActions extends ConsumerWidget {
|
||||
const MainAppBarActions({super.key, required this.currentRoute});
|
||||
|
||||
final String currentRoute;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final user = ref.watch(authControllerProvider).value;
|
||||
final isAdmin = user?.isAdmin ?? false;
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (currentRoute != '/home')
|
||||
IconButton(
|
||||
key: const Key('app_bar_home'),
|
||||
icon: Icon(Icons.home, color: fs.parchment),
|
||||
tooltip: 'Home',
|
||||
onPressed: () => context.go('/home'),
|
||||
),
|
||||
if (currentRoute != '/library')
|
||||
IconButton(
|
||||
key: const Key('app_bar_library'),
|
||||
icon: Icon(Icons.library_music, color: fs.parchment),
|
||||
tooltip: 'Library',
|
||||
onPressed: () => context.push('/library'),
|
||||
),
|
||||
if (currentRoute != '/search')
|
||||
IconButton(
|
||||
key: const Key('app_bar_search'),
|
||||
icon: Icon(Icons.search, color: fs.parchment),
|
||||
tooltip: 'Search',
|
||||
onPressed: () => context.push('/search'),
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
key: const Key('app_bar_overflow'),
|
||||
icon: Icon(Icons.more_vert, color: fs.parchment),
|
||||
tooltip: 'More',
|
||||
onSelected: (route) => context.push(route),
|
||||
itemBuilder: (_) => [
|
||||
const PopupMenuItem(value: '/playlists', child: Text('Playlists')),
|
||||
const PopupMenuItem(value: '/discover', child: Text('Discover')),
|
||||
const PopupMenuItem(value: '/settings', child: Text('Settings')),
|
||||
if (isAdmin)
|
||||
const PopupMenuItem(value: '/admin', child: Text('Admin')),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+208
-32
@@ -1,6 +1,22 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
_fe_analyzer_shared:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _fe_analyzer_shared
|
||||
sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "93.0.0"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer
|
||||
sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.0.1"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -65,6 +81,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
cli_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cli_config
|
||||
sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.0"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -89,6 +113,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
convert:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: convert
|
||||
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
coverage:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: coverage
|
||||
sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.15.0"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -170,66 +210,66 @@ packages:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c"
|
||||
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
version: "6.0.0"
|
||||
flutter_riverpod:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_riverpod
|
||||
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
|
||||
sha256: "4e166be88e1dbbaa34a280bdb744aeae73b7ef25fdf8db7a3bb776760a3648e2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
version: "3.3.1"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_secure_storage
|
||||
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
|
||||
sha256: "8b302d17096ba88f911b7eb317c71d5e691da60a259549f42b38c658d1776d87"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.2.4"
|
||||
version: "10.1.0"
|
||||
flutter_secure_storage_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_darwin
|
||||
sha256: "3af15a3cb2bf5b8b776832bd01776f8018766aece55623176e28b406481fb320"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.0"
|
||||
flutter_secure_storage_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_linux
|
||||
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
|
||||
sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.3"
|
||||
flutter_secure_storage_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_macos
|
||||
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
version: "3.0.0"
|
||||
flutter_secure_storage_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_platform_interface
|
||||
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
|
||||
sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
version: "2.0.1"
|
||||
flutter_secure_storage_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_web
|
||||
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
|
||||
sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
version: "2.1.1"
|
||||
flutter_secure_storage_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_windows
|
||||
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
|
||||
sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
version: "4.1.0"
|
||||
flutter_svg:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -248,6 +288,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
frontend_server_client:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: frontend_server_client
|
||||
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -260,18 +308,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: go_router
|
||||
sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3
|
||||
sha256: "92d8cee7c57dff0a6c409c05597b460002434eccf7424a712283225b3962d03f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "14.8.1"
|
||||
version: "17.2.3"
|
||||
google_fonts:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: google_fonts
|
||||
sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055
|
||||
sha256: "4e9391085e524954a51e3625b7c9c7e9851dc3f376603208bb45c24b9a66255d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.3"
|
||||
version: "8.1.0"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -288,6 +336,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_multi_server:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_multi_server
|
||||
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -296,6 +352,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
io:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: io
|
||||
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
jni:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -324,10 +388,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: just_audio
|
||||
sha256: f978d5b4ccea08f267dae0232ec5405c1b05d3f3cd63f82097ea46c015d5c09e
|
||||
sha256: "9694e4734f515f2a052493d1d7e0d6de219ee0427c7c29492e246ff32a219908"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.46"
|
||||
version: "0.10.5"
|
||||
just_audio_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -372,10 +436,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lints
|
||||
sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235"
|
||||
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
version: "6.1.0"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -432,6 +496,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.17.6"
|
||||
node_preamble:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: node_preamble
|
||||
sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
objective_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -552,6 +624,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
pool:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pool
|
||||
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.2"
|
||||
pub_semver:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -572,10 +652,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: riverpod
|
||||
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
|
||||
sha256: "8c22216be8ad3ef2b44af3a329693558c98eca7b8bd4ef495c92db0bba279f83"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
version: "3.2.1"
|
||||
rxdart:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -584,11 +664,59 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.28.0"
|
||||
shelf:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf
|
||||
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.2"
|
||||
shelf_packages_handler:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf_packages_handler
|
||||
sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
shelf_static:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf_static
|
||||
sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.3"
|
||||
shelf_web_socket:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf_web_socket
|
||||
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
source_map_stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_map_stack_trace
|
||||
sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
source_maps:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_maps
|
||||
sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.13"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -685,6 +813,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
test:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test
|
||||
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.30.0"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -693,6 +829,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.10"
|
||||
test_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_core
|
||||
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.16"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -749,6 +893,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.2.0"
|
||||
watcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: watcher
|
||||
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -757,6 +909,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
web_socket:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web_socket
|
||||
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
web_socket_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web_socket_channel
|
||||
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
webkit_inspection_protocol:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webkit_inspection_protocol
|
||||
sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -10,22 +10,24 @@ environment:
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_riverpod: ^2.5.1
|
||||
flutter_riverpod: ^3.3.1
|
||||
dio: ^5.7.0
|
||||
just_audio: ^0.9.40
|
||||
just_audio: ^0.10.5
|
||||
audio_service: ^0.18.15
|
||||
flutter_secure_storage: ^9.2.2
|
||||
go_router: ^14.6.1
|
||||
flutter_secure_storage: ^10.1.0
|
||||
go_router: ^17.2.3
|
||||
flutter_svg: ^2.0.16
|
||||
google_fonts: ^6.2.1
|
||||
package_info_plus: ^8.0.0
|
||||
google_fonts: ^8.1.0
|
||||
# 10.x conflicts with flutter_secure_storage 10.x on win32. Hold at 8.3.1
|
||||
# until either lib bumps win32 to 6.x.
|
||||
package_info_plus: ^8.3.1
|
||||
pub_semver: ^2.1.4
|
||||
cupertino_icons: ^1.0.8
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^4.0.0
|
||||
flutter_lints: ^6.0.0
|
||||
mocktail: ^1.0.4
|
||||
|
||||
flutter:
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:minstrel/admin/admin_landing_screen.dart';
|
||||
import 'package:minstrel/admin/admin_providers.dart';
|
||||
import 'package:minstrel/auth/auth_provider.dart';
|
||||
import 'package:minstrel/models/user.dart';
|
||||
import 'package:minstrel/theme/theme_data.dart';
|
||||
|
||||
class _StubAuth extends AuthController {
|
||||
@override
|
||||
Future<User?> build() async =>
|
||||
const User(id: 'u1', username: 'admin', isAdmin: true);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('renders three section cards with counts from provider',
|
||||
(t) async {
|
||||
await t.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
authControllerProvider.overrideWith(() => _StubAuth()),
|
||||
adminCountsProvider.overrideWith(
|
||||
(_) async => const AdminCounts(
|
||||
requests: 3,
|
||||
quarantine: 1,
|
||||
users: 5,
|
||||
),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: buildThemeData(),
|
||||
home: const AdminLandingScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await t.pumpAndSettle();
|
||||
expect(find.byKey(const Key('admin_card_requests')), findsOneWidget);
|
||||
expect(find.byKey(const Key('admin_card_quarantine')), findsOneWidget);
|
||||
expect(find.byKey(const Key('admin_card_users')), findsOneWidget);
|
||||
expect(find.text('3'), findsOneWidget);
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
expect(find.text('5'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:minstrel/admin/admin_providers.dart';
|
||||
import 'package:minstrel/admin/admin_quarantine_screen.dart';
|
||||
import 'package:minstrel/auth/auth_provider.dart';
|
||||
import 'package:minstrel/models/admin_quarantine_item.dart';
|
||||
import 'package:minstrel/models/user.dart';
|
||||
import 'package:minstrel/theme/theme_data.dart';
|
||||
|
||||
class _StubAuth extends AuthController {
|
||||
@override
|
||||
Future<User?> build() async =>
|
||||
const User(id: 'u1', username: 'admin', isAdmin: true);
|
||||
}
|
||||
|
||||
class _StubQuarantine extends AdminQuarantineController {
|
||||
_StubQuarantine(this._initial);
|
||||
final List<AdminQuarantineItem> _initial;
|
||||
@override
|
||||
Future<List<AdminQuarantineItem>> build() async => _initial;
|
||||
}
|
||||
|
||||
const _item = AdminQuarantineItem(
|
||||
trackId: 't1',
|
||||
trackTitle: 'Bad Track',
|
||||
artistName: 'Some Artist',
|
||||
albumTitle: 'Some Album',
|
||||
albumId: 'al1',
|
||||
lidarrAlbumMbid: null,
|
||||
reportCount: 2,
|
||||
latestAt: '2026-05-08T00:00:00Z',
|
||||
reasonCounts: {'wrong_tags': 1, 'bad_rip': 1},
|
||||
reports: [
|
||||
AdminQuarantineReport(
|
||||
userId: 'u2',
|
||||
username: 'alice',
|
||||
reason: 'wrong_tags',
|
||||
notes: null,
|
||||
createdAt: '2026-05-08T00:00:00Z',
|
||||
),
|
||||
AdminQuarantineReport(
|
||||
userId: 'u3',
|
||||
username: 'bob',
|
||||
reason: 'bad_rip',
|
||||
notes: 'cracks at 2:13',
|
||||
createdAt: '2026-05-08T00:01:00Z',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _harness(List<AdminQuarantineItem> rows) => ProviderScope(
|
||||
overrides: [
|
||||
authControllerProvider.overrideWith(() => _StubAuth()),
|
||||
adminQuarantineProvider.overrideWith(() => _StubQuarantine(rows)),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: buildThemeData(),
|
||||
home: const AdminQuarantineScreen(),
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets('empty state', (t) async {
|
||||
await t.pumpWidget(_harness(const []));
|
||||
await t.pumpAndSettle();
|
||||
expect(find.text('No quarantined tracks.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('row renders aggregate summary + 3-dot menu has three actions',
|
||||
(t) async {
|
||||
await t.pumpWidget(_harness(const [_item]));
|
||||
await t.pumpAndSettle();
|
||||
expect(find.byKey(const Key('admin_quarantine_tile_t1')), findsOneWidget);
|
||||
expect(find.text('Bad Track'), findsOneWidget);
|
||||
expect(find.textContaining('2 reports'), findsOneWidget);
|
||||
await t.tap(find.byKey(const Key('admin_quarantine_menu_t1')));
|
||||
await t.pumpAndSettle();
|
||||
expect(find.text('Resolve'), findsOneWidget);
|
||||
expect(find.text('Delete file'), findsOneWidget);
|
||||
expect(find.text('Delete via Lidarr'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('expanding tile reveals per-user reports', (t) async {
|
||||
await t.pumpWidget(_harness(const [_item]));
|
||||
await t.pumpAndSettle();
|
||||
await t.tap(find.byKey(const Key('admin_quarantine_tile_t1')));
|
||||
await t.pumpAndSettle();
|
||||
expect(find.textContaining('alice — wrong_tags'), findsOneWidget);
|
||||
expect(find.textContaining('bob — bad_rip'), findsOneWidget);
|
||||
expect(find.text('cracks at 2:13'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:minstrel/admin/admin_providers.dart';
|
||||
import 'package:minstrel/admin/admin_requests_screen.dart';
|
||||
import 'package:minstrel/auth/auth_provider.dart';
|
||||
import 'package:minstrel/models/admin_request.dart';
|
||||
import 'package:minstrel/models/admin_user.dart';
|
||||
import 'package:minstrel/models/user.dart';
|
||||
import 'package:minstrel/theme/theme_data.dart';
|
||||
|
||||
class _StubAuth extends AuthController {
|
||||
@override
|
||||
Future<User?> build() async =>
|
||||
const User(id: 'u1', username: 'admin', isAdmin: true);
|
||||
}
|
||||
|
||||
class _StubRequests extends AdminRequestsController {
|
||||
_StubRequests(this._initial);
|
||||
final List<AdminRequest> _initial;
|
||||
@override
|
||||
Future<List<AdminRequest>> build() async => _initial;
|
||||
}
|
||||
|
||||
class _StubUsers extends AdminUsersController {
|
||||
_StubUsers(this._initial);
|
||||
final List<AdminUser> _initial;
|
||||
@override
|
||||
Future<List<AdminUser>> build() async => _initial;
|
||||
}
|
||||
|
||||
const _row = AdminRequest(
|
||||
id: 'r1',
|
||||
userId: 'user-uuid-1',
|
||||
status: 'pending',
|
||||
kind: 'album',
|
||||
artistName: 'Some Artist',
|
||||
albumTitle: 'Test Album',
|
||||
trackTitle: null,
|
||||
requestedAt: '2026-05-08T00:00:00Z',
|
||||
decidedAt: null,
|
||||
notes: null,
|
||||
importedAlbumCount: 0,
|
||||
importedTrackCount: 0,
|
||||
);
|
||||
|
||||
const _alice = AdminUser(
|
||||
id: 'user-uuid-1',
|
||||
username: 'alice',
|
||||
displayName: null,
|
||||
isAdmin: false,
|
||||
autoApproveRequests: false,
|
||||
createdAt: '2026-05-01T00:00:00Z',
|
||||
);
|
||||
|
||||
Widget _harness({
|
||||
required List<AdminRequest> requests,
|
||||
required List<AdminUser> users,
|
||||
}) =>
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
authControllerProvider.overrideWith(() => _StubAuth()),
|
||||
adminRequestsProvider.overrideWith(() => _StubRequests(requests)),
|
||||
adminUsersProvider.overrideWith(() => _StubUsers(users)),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: buildThemeData(),
|
||||
home: const AdminRequestsScreen(),
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets('renders empty state when no requests', (t) async {
|
||||
await t.pumpWidget(_harness(requests: const [], users: const []));
|
||||
await t.pumpAndSettle();
|
||||
expect(find.text('No pending requests.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders row with display name + joined requester username',
|
||||
(t) async {
|
||||
await t.pumpWidget(_harness(requests: const [_row], users: const [_alice]));
|
||||
await t.pumpAndSettle();
|
||||
expect(find.byKey(const Key('admin_request_row_r1')), findsOneWidget);
|
||||
expect(find.text('Test Album'), findsOneWidget);
|
||||
expect(find.textContaining('requested by alice'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('falls back to uuid prefix when username unknown', (t) async {
|
||||
await t.pumpWidget(_harness(requests: const [_row], users: const []));
|
||||
await t.pumpAndSettle();
|
||||
expect(find.textContaining('requested by user-uui'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:minstrel/admin/admin_providers.dart';
|
||||
import 'package:minstrel/admin/admin_users_screen.dart';
|
||||
import 'package:minstrel/auth/auth_provider.dart';
|
||||
import 'package:minstrel/models/admin_user.dart';
|
||||
import 'package:minstrel/models/invite.dart';
|
||||
import 'package:minstrel/models/user.dart';
|
||||
import 'package:minstrel/theme/theme_data.dart';
|
||||
|
||||
class _StubAuth extends AuthController {
|
||||
@override
|
||||
Future<User?> build() async =>
|
||||
const User(id: 'u1', username: 'admin', isAdmin: true);
|
||||
}
|
||||
|
||||
class _StubUsers extends AdminUsersController {
|
||||
_StubUsers(this._initial);
|
||||
final List<AdminUser> _initial;
|
||||
@override
|
||||
Future<List<AdminUser>> build() async => _initial;
|
||||
}
|
||||
|
||||
class _StubInvites extends AdminInvitesController {
|
||||
_StubInvites(this._initial);
|
||||
final List<Invite> _initial;
|
||||
@override
|
||||
Future<List<Invite>> build() async => _initial;
|
||||
}
|
||||
|
||||
const _userRow = AdminUser(
|
||||
id: 'u2',
|
||||
username: 'alice',
|
||||
displayName: null,
|
||||
isAdmin: false,
|
||||
autoApproveRequests: true,
|
||||
createdAt: '2026-05-01T00:00:00Z',
|
||||
);
|
||||
|
||||
const _inviteRow = Invite(
|
||||
token: 'INV-ABC123',
|
||||
invitedBy: 'u1',
|
||||
note: 'for alice',
|
||||
createdAt: '2026-05-08T00:00:00Z',
|
||||
expiresAt: '2026-05-09T00:00:00Z',
|
||||
);
|
||||
|
||||
Widget _harness({
|
||||
required List<AdminUser> users,
|
||||
required List<Invite> invites,
|
||||
}) =>
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
authControllerProvider.overrideWith(() => _StubAuth()),
|
||||
adminUsersProvider.overrideWith(() => _StubUsers(users)),
|
||||
adminInvitesProvider.overrideWith(() => _StubInvites(invites)),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: buildThemeData(),
|
||||
home: const AdminUsersScreen(),
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets('renders user rows with badges', (t) async {
|
||||
await t.pumpWidget(_harness(users: const [_userRow], invites: const []));
|
||||
await t.pumpAndSettle();
|
||||
expect(find.byKey(const Key('admin_user_row_u2')), findsOneWidget);
|
||||
expect(find.text('alice'), findsOneWidget);
|
||||
expect(find.text('auto-approve'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders invite rows + Generate button', (t) async {
|
||||
await t.pumpWidget(_harness(users: const [], invites: const [_inviteRow]));
|
||||
await t.pumpAndSettle();
|
||||
expect(find.byKey(const Key('invite_row_INV-ABC123')), findsOneWidget);
|
||||
expect(find.byKey(const Key('invite_generate_button')), findsOneWidget);
|
||||
expect(find.textContaining('for alice'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -5,6 +5,10 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:minstrel/api/endpoints/likes.dart';
|
||||
import 'package:minstrel/likes/like_button.dart';
|
||||
import 'package:minstrel/likes/likes_provider.dart';
|
||||
import 'package:minstrel/models/album.dart';
|
||||
import 'package:minstrel/models/artist.dart';
|
||||
import 'package:minstrel/models/page.dart';
|
||||
import 'package:minstrel/models/track.dart';
|
||||
import 'package:minstrel/theme/theme_data.dart';
|
||||
|
||||
class _ThrowingLikesApi implements LikesApi {
|
||||
@@ -24,6 +28,19 @@ class _ThrowingLikesApi implements LikesApi {
|
||||
Future<({Set<String> artists, Set<String> albums, Set<String> tracks})>
|
||||
ids() async =>
|
||||
(artists: <String>{}, albums: <String>{}, tracks: <String>{});
|
||||
|
||||
// The list-* methods aren't exercised by this test; return empty pages.
|
||||
@override
|
||||
Future<Paged<TrackRef>> listTracks({int limit = 50, int offset = 0}) async =>
|
||||
const Paged<TrackRef>(items: [], total: 0, limit: 50, offset: 0);
|
||||
|
||||
@override
|
||||
Future<Paged<AlbumRef>> listAlbums({int limit = 50, int offset = 0}) async =>
|
||||
const Paged<AlbumRef>(items: [], total: 0, limit: 50, offset: 0);
|
||||
|
||||
@override
|
||||
Future<Paged<ArtistRef>> listArtists({int limit = 50, int offset = 0}) async =>
|
||||
const Paged<ArtistRef>(items: [], total: 0, limit: 50, offset: 0);
|
||||
}
|
||||
|
||||
void main() {
|
||||
|
||||
@@ -14,7 +14,19 @@ void main() {
|
||||
test('audioHandlerProvider must be overridden — bare read throws', () {
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
expect(() => container.read(audioHandlerProvider), throwsA(isA<UnimplementedError>()));
|
||||
// Riverpod 3 wraps the underlying error in a ProviderException. The
|
||||
// intent of the test is "bare read fails," so accept the wrapper as
|
||||
// long as the inner error is the UnimplementedError we threw.
|
||||
expect(
|
||||
() => container.read(audioHandlerProvider),
|
||||
throwsA(predicate((e) {
|
||||
if (e is UnimplementedError) return true;
|
||||
// ProviderException is private to riverpod; match by name + by
|
||||
// walking its toString for the inner UnimplementedError text.
|
||||
return e.runtimeType.toString().contains('ProviderException') &&
|
||||
e.toString().contains('UnimplementedError');
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
test('overridden audioHandlerProvider exposes playbackState stream', () {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:minstrel/auth/auth_provider.dart';
|
||||
import 'package:minstrel/models/user.dart';
|
||||
import 'package:minstrel/shared/widgets/main_app_bar_actions.dart';
|
||||
import 'package:minstrel/theme/theme_data.dart';
|
||||
|
||||
class _StubAuth extends AuthController {
|
||||
_StubAuth(this._user);
|
||||
final User? _user;
|
||||
@override
|
||||
Future<User?> build() async => _user;
|
||||
}
|
||||
|
||||
Widget _harness({required User? user, required String currentRoute}) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
authControllerProvider.overrideWith(() => _StubAuth(user)),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: buildThemeData(),
|
||||
home: Scaffold(
|
||||
appBar: AppBar(
|
||||
actions: [MainAppBarActions(currentRoute: currentRoute)],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
const adminUser = User(id: 'u1', username: 'admin', isAdmin: true);
|
||||
const regularUser = User(id: 'u2', username: 'alice', isAdmin: false);
|
||||
|
||||
testWidgets('renders Library + Search primary icons + kebab on /home',
|
||||
(t) async {
|
||||
await t.pumpWidget(_harness(user: regularUser, currentRoute: '/home'));
|
||||
await t.pumpAndSettle();
|
||||
expect(find.byKey(const Key('app_bar_library')), findsOneWidget);
|
||||
expect(find.byKey(const Key('app_bar_search')), findsOneWidget);
|
||||
expect(find.byKey(const Key('app_bar_home')), findsNothing);
|
||||
expect(find.byKey(const Key('app_bar_overflow')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('suppresses Library icon when currentRoute is /library',
|
||||
(t) async {
|
||||
await t.pumpWidget(_harness(user: regularUser, currentRoute: '/library'));
|
||||
await t.pumpAndSettle();
|
||||
expect(find.byKey(const Key('app_bar_library')), findsNothing);
|
||||
expect(find.byKey(const Key('app_bar_home')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('overflow includes Admin only for admin users', (t) async {
|
||||
await t.pumpWidget(_harness(user: adminUser, currentRoute: '/home'));
|
||||
await t.pumpAndSettle();
|
||||
await t.tap(find.byKey(const Key('app_bar_overflow')));
|
||||
await t.pumpAndSettle();
|
||||
expect(find.text('Admin'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('overflow omits Admin for non-admin users', (t) async {
|
||||
await t.pumpWidget(_harness(user: regularUser, currentRoute: '/home'));
|
||||
await t.pumpAndSettle();
|
||||
await t.tap(find.byKey(const Key('app_bar_overflow')));
|
||||
await t.pumpAndSettle();
|
||||
expect(find.text('Admin'), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { isPublicRoute } from './publicRoutes';
|
||||
|
||||
describe('isPublicRoute', () => {
|
||||
test('/login is public — login form must render to unauthenticated visitors', () => {
|
||||
expect(isPublicRoute('/login')).toBe(true);
|
||||
});
|
||||
|
||||
// Regression guard for #376: /register was previously bounced to /login
|
||||
// by the layout guard, breaking bootstrap-admin self-registration on
|
||||
// fresh deployments. Do NOT remove /register from the public set unless
|
||||
// you have a deliberate replacement (e.g. invite-only landing page).
|
||||
test('/register is public — bootstrap admin must reach the form', () => {
|
||||
expect(isPublicRoute('/register')).toBe(true);
|
||||
});
|
||||
|
||||
test('/ is gated', () => {
|
||||
expect(isPublicRoute('/')).toBe(false);
|
||||
});
|
||||
|
||||
test('/admin/users is gated', () => {
|
||||
expect(isPublicRoute('/admin/users')).toBe(false);
|
||||
});
|
||||
|
||||
test('/settings is gated', () => {
|
||||
expect(isPublicRoute('/settings')).toBe(false);
|
||||
});
|
||||
|
||||
test('paths that merely start with /login are not public', () => {
|
||||
expect(isPublicRoute('/login/extra')).toBe(false);
|
||||
expect(isPublicRoute('/loginx')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
// Routes that the +layout.svelte auth guard must NOT redirect away from
|
||||
// when the visitor is unauthenticated. Bootstrap-admin self-registration
|
||||
// (#376) requires /register to be reachable without a session — without
|
||||
// /register here, the login page's "Register" link bounces back to /login.
|
||||
const PUBLIC_ROUTES = new Set(['/login', '/register']);
|
||||
|
||||
export function isPublicRoute(pathname: string): boolean {
|
||||
return PUBLIC_ROUTES.has(pathname);
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
import { QueryClientProvider } from '@tanstack/svelte-query';
|
||||
import { queryClient } from '$lib/query/client';
|
||||
import { user } from '$lib/auth/store.svelte';
|
||||
import { isPublicRoute } from '$lib/auth/publicRoutes';
|
||||
import Shell from '$lib/components/Shell.svelte';
|
||||
import QueueDrawer from '$lib/components/QueueDrawer.svelte';
|
||||
import ToastHost from '$lib/components/ToastHost.svelte';
|
||||
@@ -27,11 +28,11 @@
|
||||
// Reactive guard: runs every time page.url or user.value changes.
|
||||
$effect(() => {
|
||||
const path = page.url.pathname;
|
||||
const isLogin = path === '/login';
|
||||
if (user.value === null && !isLogin) {
|
||||
const isPublic = isPublicRoute(path);
|
||||
if (user.value === null && !isPublic) {
|
||||
const returnTo = path + page.url.search;
|
||||
goto('/login?returnTo=' + encodeURIComponent(returnTo), { replaceState: true });
|
||||
} else if (user.value !== null && isLogin) {
|
||||
} else if (user.value !== null && isPublic) {
|
||||
goto('/', { replaceState: true });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user