From 4ca10e2afa19ecc6f5b244d57ce8ece406ce9194 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 26 May 2026 18:19:12 -0400 Subject: [PATCH] feat(android): pull-to-refresh on Playlists / Discover / Requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second wave of audit #8. PlaylistsListScreen, PlaylistDetailScreen, DiscoverScreen, RequestsScreen all wrap their body in PullToRefreshScaffold. VM refresh methods updated to return Job for the wrapper's await. PlaylistsListViewModel gains a public refresh() (was init-only fire-and-forget). DiscoverScreen's swipe re-fetches suggestions (the most-useful refresh target on that screen — Lidarr search results refresh on next query). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../minstrel/discover/ui/DiscoverScreen.kt | 6 +++- .../minstrel/discover/ui/DiscoverViewModel.kt | 31 +++++++++---------- .../playlists/ui/PlaylistDetailScreen.kt | 31 ++++++++++--------- .../playlists/ui/PlaylistsListScreen.kt | 22 +++++++------ .../minstrel/requests/ui/RequestsScreen.kt | 6 +++- .../minstrel/requests/ui/RequestsViewModel.kt | 27 ++++++++-------- 6 files changed, 68 insertions(+), 55 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverScreen.kt index dd83a11b..f6a1eb56 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverScreen.kt @@ -45,6 +45,7 @@ import com.fabledsword.minstrel.models.LidarrRequestKind import com.fabledsword.minstrel.models.LidarrSearchResultRef import com.fabledsword.minstrel.nav.Discover import com.fabledsword.minstrel.shared.widgets.MainAppBarActions +import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold import kotlinx.coroutines.launch // ─── Screen ────────────────────────────────────────────────────────── @@ -86,7 +87,10 @@ fun DiscoverScreen( onSubmit = viewModel::runSearch, ) KindChips(kind = state.kind, onChange = viewModel::setKind) - Box(modifier = Modifier.fillMaxSize()) { + PullToRefreshScaffold( + onRefresh = { viewModel.loadSuggestions().join() }, + modifier = Modifier.fillMaxSize(), + ) { when (val r = state.results) { ResultsState.Idle -> SuggestionsPane( state = state.suggestions, diff --git a/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverViewModel.kt index 912d0941..d84a6637 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverViewModel.kt @@ -8,6 +8,7 @@ import com.fabledsword.minstrel.models.ArtistSuggestionRef import com.fabledsword.minstrel.models.LidarrRequestKind import com.fabledsword.minstrel.models.LidarrSearchResultRef import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -66,22 +67,20 @@ class DiscoverViewModel @Inject constructor( if (internal.value.query.isNotBlank()) runSearch() } - fun loadSuggestions() { - viewModelScope.launch { - internal.update { it.copy(suggestions = SuggestionState.Loading) } - try { - val items = repository.listSuggestions() - internal.update { it.copy(suggestions = SuggestionState.Loaded(items)) } - } catch ( - @Suppress("TooGenericExceptionCaught") e: Throwable, - ) { - internal.update { - it.copy( - suggestions = SuggestionState.Error( - e.message ?: "Suggestions failed", - ), - ) - } + fun loadSuggestions(): Job = viewModelScope.launch { + internal.update { it.copy(suggestions = SuggestionState.Loading) } + try { + val items = repository.listSuggestions() + internal.update { it.copy(suggestions = SuggestionState.Loaded(items)) } + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + internal.update { + it.copy( + suggestions = SuggestionState.Error( + e.message ?: "Suggestions failed", + ), + ) } } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt index 01426acb..34e7c3e8 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt @@ -68,11 +68,13 @@ import com.fabledsword.minstrel.likes.data.LikesRepository import com.fabledsword.minstrel.playlists.data.PlaylistDetailRef import com.fabledsword.minstrel.playlists.data.PlaylistsRepository import com.fabledsword.minstrel.shared.widgets.LikeButton +import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel import com.fabledsword.minstrel.theme.FabledSwordFlatTokens import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -163,19 +165,17 @@ class PlaylistDetailViewModel @Inject constructor( } } - fun refresh() { - viewModelScope.launch { - try { - internal.value = PlaylistDetailUiState.Loading - val detail = repository.refreshDetail(playlistId) - internal.value = PlaylistDetailUiState.Success(detail) - } catch ( - @Suppress("TooGenericExceptionCaught") e: Throwable, - ) { - internal.value = PlaylistDetailUiState.Error( - e.message ?: "Couldn't load playlist", - ) - } + fun refresh(): Job = viewModelScope.launch { + try { + internal.value = PlaylistDetailUiState.Loading + val detail = repository.refreshDetail(playlistId) + internal.value = PlaylistDetailUiState.Success(detail) + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + internal.value = PlaylistDetailUiState.Error( + e.message ?: "Couldn't load playlist", + ) } } @@ -222,7 +222,10 @@ fun PlaylistDetailScreen( }, snackbarHost = { SnackbarHost(snackbarHostState) }, ) { inner -> - Box(modifier = Modifier.fillMaxSize().padding(inner)) { + PullToRefreshScaffold( + onRefresh = { viewModel.refresh().join() }, + modifier = Modifier.fillMaxSize().padding(inner), + ) { when (val s = state) { PlaylistDetailUiState.Loading -> LoadingCentered() is PlaylistDetailUiState.Error -> ErrorBlock(s.message, viewModel::refresh) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt index 39252838..4223e53b 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt @@ -34,7 +34,9 @@ import com.fabledsword.minstrel.playlists.data.PlaylistsRepository import com.fabledsword.minstrel.playlists.widgets.PlaylistCard import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.MainAppBarActions +import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch @@ -64,22 +66,21 @@ class PlaylistsListViewModel @Inject constructor( ) : ViewModel() { init { - // Fire-and-forget initial pull; the screen renders cached rows - // immediately and refreshes as the server response lands. - viewModelScope.launch { - runCatching { repository.refreshList() } - } + refresh() // Live updates: a playlist created/updated/deleted from another // client should reflect here without a manual refresh. viewModelScope.launch { eventsStream.events .filter { it.kind.startsWith("playlist.") } - .collect { - runCatching { repository.refreshList() } - } + .collect { refresh() } } } + /** Re-pull the playlists list. Returns the Job for pull-to-refresh awaits. */ + fun refresh(): Job = viewModelScope.launch { + runCatching { repository.refreshList() } + } + val uiState: StateFlow = repository.observeAll() .map { list -> @@ -122,7 +123,10 @@ fun PlaylistsListScreen( }, ) { inner -> val state by viewModel.uiState.collectAsStateWithLifecycle() - Box(modifier = Modifier.fillMaxSize().padding(inner)) { + PullToRefreshScaffold( + onRefresh = { viewModel.refresh().join() }, + modifier = Modifier.fillMaxSize().padding(inner), + ) { when (val s = state) { PlaylistsListUiState.Loading -> LoadingCentered() PlaylistsListUiState.Empty -> EmptyState( diff --git a/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsScreen.kt index 8246fdfa..37e64400 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsScreen.kt @@ -37,6 +37,7 @@ import com.fabledsword.minstrel.models.RequestStatus import com.fabledsword.minstrel.nav.Requests import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.MainAppBarActions +import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -64,7 +65,10 @@ fun RequestsScreen( ) }, ) { inner -> - Box(modifier = Modifier.fillMaxSize().padding(inner)) { + PullToRefreshScaffold( + onRefresh = { viewModel.refresh().join() }, + modifier = Modifier.fillMaxSize().padding(inner), + ) { when (val s = state) { RequestsUiState.Loading -> LoadingCentered() RequestsUiState.Empty -> EmptyState( diff --git a/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsViewModel.kt index 1f21b558..bf9945b8 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsViewModel.kt @@ -6,6 +6,7 @@ import com.fabledsword.minstrel.events.EventsStream import com.fabledsword.minstrel.models.RequestRef import com.fabledsword.minstrel.requests.data.RequestsRepository import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -41,21 +42,19 @@ class RequestsViewModel @Inject constructor( } } - fun refresh() { - viewModelScope.launch { - internal.value = RequestsUiState.Loading - try { - val rows = repository.listMine() - internal.value = if (rows.isEmpty()) { - RequestsUiState.Empty - } else { - RequestsUiState.Success(rows) - } - } catch ( - @Suppress("TooGenericExceptionCaught") e: Throwable, - ) { - internal.value = RequestsUiState.Error(e.message ?: "Requests load failed") + fun refresh(): Job = viewModelScope.launch { + internal.value = RequestsUiState.Loading + try { + val rows = repository.listMine() + internal.value = if (rows.isEmpty()) { + RequestsUiState.Empty + } else { + RequestsUiState.Success(rows) } + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + internal.value = RequestsUiState.Error(e.message ?: "Requests load failed") } }