Files
minstrel/flutter_client/lib/shared/routing.dart
T
bvandeusen 0db7054518 feat(flutter): user-facing requests view + cancel (#356)
Closes the cleanest remaining #356 gap — Discover submits Lidarr
requests but had no surface for users to view or cancel their own.
Admin had it; user didn't.

- RequestsApi at lib/api/endpoints/requests.dart — listMine() +
  cancel(id). Same /api/requests endpoint the web /requests page
  uses; identical AdminRequest wire shape so the existing model
  is reused (just augmented with matched_*_id fields for the
  "Listen" CTA on completed rows).
- MyRequestsController mirrors the web's auto-poll (#369 piece
  already shipped server-side / web-side): 12s refresh while any
  row is mid-ingest (status='approved'), stops on settle. Riverpod
  Timer in build() that's cancelled in onDispose.
- RequestsScreen with kind/status pills, ingest progress copy,
  Cancel (with confirm dialog) and Listen CTAs per row state.
- Route /requests under the shell. Entry point in settings between
  Profile and Appearance — "My requests".
- Widget tests cover empty / pending / completed / rejected states +
  the Cancel-confirm dialog.

Out of scope this slice: Discover-screen "View your requests" CTA
after submitting a new request. Reasonable follow-up but doesn't
block the management loop.

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

104 lines
4.2 KiB
Dart

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 '../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 '../requests/requests_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
/// constructor's redirect closures use `ref.read`). Widgets consume the
/// router via `ref.watch(routerProvider)` instead of constructing it
/// themselves with a `WidgetRef`, which can't satisfy the `Ref` type.
final routerProvider = Provider<GoRouter>((ref) => buildRouter(ref));
GoRouter buildRouter(Ref ref) {
return GoRouter(
initialLocation: '/',
redirect: (ctx, state) async {
final url = await ref.read(serverUrlProvider.future);
if (url == null) return '/server-url';
final user = await ref.read(authControllerProvider.future);
final loc = state.matchedLocation;
if (user == null && loc != '/login' && loc != '/server-url') {
return '/login';
}
if (user != null && (loc == '/login' || loc == '/server-url')) {
return '/home';
}
if (loc.startsWith('/admin') && !user!.isAdmin) {
return '/home';
}
return null;
},
routes: [
GoRoute(path: '/', redirect: (_, __) => '/home'),
GoRoute(path: '/server-url', builder: (_, __) => const ServerUrlScreen()),
GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
ShellRoute(
builder: (ctx, state, child) => VersionGate(child: _ShellWithPlayerBar(child: child)),
routes: [
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
GoRoute(
path: '/artists/:id',
builder: (_, s) => ArtistDetailScreen(id: s.pathParameters['id']!),
),
GoRoute(
path: '/albums/:id',
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: '/requests', builder: (_, __) => const RequestsScreen()),
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()),
],
),
],
);
}
class _ShellWithPlayerBar extends StatelessWidget {
const _ShellWithPlayerBar({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return Column(
children: [
Expanded(child: child),
const PlayerBar(),
],
);
}
}