From 3e52ff7fa3a67cc4edd0c79636eb8b8d70ed0673 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 13 May 2026 14:29:57 -0400 Subject: [PATCH] feat(#402): wire screen-scoped providers to liveEventsProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #392's dispatcher only invalidates publicly-importable providers (myQuarantine + home). Screen-scoped providers (file-private in their feature folders) get their own ref.listen(liveEventsProvider, ...) so they go live without needing back-edge dependencies from /shared. Five screens wired: - library_screen.dart _LikedTab — invalidates _likedTracksProvider / _likedAlbumsProvider / _likedArtistsProvider on any of the six track/album/artist like/unlike kinds. - playlist_detail_screen.dart — invalidates playlistDetailProvider(id) on playlist.updated / playlist.tracks_changed matching the visible playlist_id. On playlist.deleted matching the visible id, pops back so the user isn't left staring at a gone playlist. - admin_requests_screen.dart — invalidates adminRequestsProvider on request.status_changed (covers user create/cancel + admin approve/reject + reconciler complete). - admin_quarantine_screen.dart — invalidates adminQuarantineProvider on any quarantine.* event (flag from a user / admin resolve / file delete / lidarr delete). - requests_screen.dart (own requests) — invalidates myRequestsProvider on request.status_changed. Server-side events are user-scoped via publishRequestStatusChanged's row.UserID, so admin actions on someone else's request route to the right stream. History tab is NOT wired (no server-side play.scrobbled event yet — documented in #402 body as deferred until that event ships). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/admin/admin_quarantine_screen.dart | 10 ++++++++++ .../lib/admin/admin_requests_screen.dart | 10 ++++++++++ .../lib/library/library_screen.dart | 20 +++++++++++++++++++ .../lib/playlists/playlist_detail_screen.dart | 18 +++++++++++++++++ .../lib/requests/requests_screen.dart | 11 ++++++++++ 5 files changed, 69 insertions(+) diff --git a/flutter_client/lib/admin/admin_quarantine_screen.dart b/flutter_client/lib/admin/admin_quarantine_screen.dart index d9c55a9d..be77580a 100644 --- a/flutter_client/lib/admin/admin_quarantine_screen.dart +++ b/flutter_client/lib/admin/admin_quarantine_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../shared/live_events_provider.dart'; import '../shared/widgets/main_app_bar_actions.dart'; import '../theme/theme_extension.dart'; import 'admin_providers.dart'; @@ -12,6 +13,15 @@ class AdminQuarantineScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; + // #402 wire-up: any quarantine event (flag from a user / admin + // resolve / file delete / lidarr delete) refreshes the admin queue. + ref.listen>(liveEventsProvider, (_, next) { + final e = next.asData?.value; + if (e == null) return; + if (e.kind.startsWith('quarantine.')) { + ref.invalidate(adminQuarantineProvider); + } + }); final items = ref.watch(adminQuarantineProvider); return Scaffold( backgroundColor: fs.obsidian, diff --git a/flutter_client/lib/admin/admin_requests_screen.dart b/flutter_client/lib/admin/admin_requests_screen.dart index b0faecf6..5063265e 100644 --- a/flutter_client/lib/admin/admin_requests_screen.dart +++ b/flutter_client/lib/admin/admin_requests_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../models/admin_user.dart'; +import '../shared/live_events_provider.dart'; import '../shared/widgets/main_app_bar_actions.dart'; import '../theme/theme_extension.dart'; import 'admin_providers.dart'; @@ -13,6 +14,15 @@ class AdminRequestsScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; + // #402 wire-up: invalidate the admin requests list when a user + // creates/cancels/admin approves/rejects/reconciler completes — + // request.status_changed covers all of them. + ref.listen>(liveEventsProvider, (_, next) { + final e = next.asData?.value; + if (e?.kind == 'request.status_changed') { + ref.invalidate(adminRequestsProvider); + } + }); 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 diff --git a/flutter_client/lib/library/library_screen.dart b/flutter_client/lib/library/library_screen.dart index ebe6ea27..159160f0 100644 --- a/flutter_client/lib/library/library_screen.dart +++ b/flutter_client/lib/library/library_screen.dart @@ -17,6 +17,7 @@ import '../models/quarantine_mine.dart'; import '../models/track.dart'; import '../player/player_provider.dart'; import '../quarantine/quarantine_provider.dart'; +import '../shared/live_events_provider.dart'; import '../shared/widgets/main_app_bar_actions.dart'; import '../shared/widgets/server_image.dart'; import '../theme/theme_extension.dart'; @@ -375,6 +376,25 @@ class _LikedTab extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; + // #402 wire-up: when any like/unlike event arrives via SSE, + // invalidate all three Liked sub-lists. Server-side events are + // already user-scoped (publishLikeEvent attaches user_id), so the + // dispatcher filters out other users' events upstream. + ref.listen>(liveEventsProvider, (_, next) { + final e = next.asData?.value; + if (e == null) return; + switch (e.kind) { + case 'track.liked': + case 'track.unliked': + case 'album.liked': + case 'album.unliked': + case 'artist.liked': + case 'artist.unliked': + ref.invalidate(_likedTracksProvider); + ref.invalidate(_likedAlbumsProvider); + ref.invalidate(_likedArtistsProvider); + } + }); final tracksA = ref.watch(_likedTracksProvider); final albumsA = ref.watch(_likedAlbumsProvider); final artistsA = ref.watch(_likedArtistsProvider); diff --git a/flutter_client/lib/playlists/playlist_detail_screen.dart b/flutter_client/lib/playlists/playlist_detail_screen.dart index 5e8c07e7..1e0b0fbb 100644 --- a/flutter_client/lib/playlists/playlist_detail_screen.dart +++ b/flutter_client/lib/playlists/playlist_detail_screen.dart @@ -7,6 +7,7 @@ import '../cache/db.dart'; import '../models/playlist.dart'; import '../models/track.dart'; import '../player/player_provider.dart'; +import '../shared/live_events_provider.dart'; import '../shared/widgets/track_actions/track_actions_button.dart'; import '../theme/theme_extension.dart'; import 'playlists_provider.dart'; @@ -24,6 +25,23 @@ class PlaylistDetailScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; + // #402 wire-up: invalidate the detail provider on playlist.updated / + // .tracks_changed events whose payload matches the visible id. On + // .deleted matching this id, navigate back so the user isn't left + // staring at a gone-from-server playlist. + ref.listen>(liveEventsProvider, (_, next) { + final e = next.asData?.value; + if (e == null) return; + final eventPlaylistId = e.data['playlist_id'] as String?; + if (eventPlaylistId != id) return; + switch (e.kind) { + case 'playlist.updated': + case 'playlist.tracks_changed': + ref.invalidate(playlistDetailProvider(id)); + case 'playlist.deleted': + if (context.mounted) context.pop(); + } + }); final detail = ref.watch(playlistDetailProvider(id)); // Resolve the best playlist info available for the AppBar title. diff --git a/flutter_client/lib/requests/requests_screen.dart b/flutter_client/lib/requests/requests_screen.dart index 0eb8f19a..2cf98fdd 100644 --- a/flutter_client/lib/requests/requests_screen.dart +++ b/flutter_client/lib/requests/requests_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../models/admin_request.dart'; +import '../shared/live_events_provider.dart'; import '../shared/widgets/main_app_bar_actions.dart'; import '../theme/theme_extension.dart'; import 'requests_provider.dart'; @@ -13,6 +14,16 @@ class RequestsScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; + // #402 wire-up: refresh the user's own requests list on + // request.status_changed. Server-side events are user-scoped + // (admin actions on someone else's request route to that other + // user's stream); the dispatcher's filter already drops mismatches. + ref.listen>(liveEventsProvider, (_, next) { + final e = next.asData?.value; + if (e?.kind == 'request.status_changed') { + ref.invalidate(myRequestsProvider); + } + }); final requests = ref.watch(myRequestsProvider); return Scaffold(