feat(android): pull-to-refresh on Playlists / Discover / Requests

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 18:19:12 -04:00
parent e6e4f6dcf1
commit 4ca10e2afa
6 changed files with 68 additions and 55 deletions
@@ -45,6 +45,7 @@ import com.fabledsword.minstrel.models.LidarrRequestKind
import com.fabledsword.minstrel.models.LidarrSearchResultRef import com.fabledsword.minstrel.models.LidarrSearchResultRef
import com.fabledsword.minstrel.nav.Discover import com.fabledsword.minstrel.nav.Discover
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
// ─── Screen ────────────────────────────────────────────────────────── // ─── Screen ──────────────────────────────────────────────────────────
@@ -86,7 +87,10 @@ fun DiscoverScreen(
onSubmit = viewModel::runSearch, onSubmit = viewModel::runSearch,
) )
KindChips(kind = state.kind, onChange = viewModel::setKind) KindChips(kind = state.kind, onChange = viewModel::setKind)
Box(modifier = Modifier.fillMaxSize()) { PullToRefreshScaffold(
onRefresh = { viewModel.loadSuggestions().join() },
modifier = Modifier.fillMaxSize(),
) {
when (val r = state.results) { when (val r = state.results) {
ResultsState.Idle -> SuggestionsPane( ResultsState.Idle -> SuggestionsPane(
state = state.suggestions, state = state.suggestions,
@@ -8,6 +8,7 @@ import com.fabledsword.minstrel.models.ArtistSuggestionRef
import com.fabledsword.minstrel.models.LidarrRequestKind import com.fabledsword.minstrel.models.LidarrRequestKind
import com.fabledsword.minstrel.models.LidarrSearchResultRef import com.fabledsword.minstrel.models.LidarrSearchResultRef
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -66,8 +67,7 @@ class DiscoverViewModel @Inject constructor(
if (internal.value.query.isNotBlank()) runSearch() if (internal.value.query.isNotBlank()) runSearch()
} }
fun loadSuggestions() { fun loadSuggestions(): Job = viewModelScope.launch {
viewModelScope.launch {
internal.update { it.copy(suggestions = SuggestionState.Loading) } internal.update { it.copy(suggestions = SuggestionState.Loading) }
try { try {
val items = repository.listSuggestions() val items = repository.listSuggestions()
@@ -84,7 +84,6 @@ class DiscoverViewModel @Inject constructor(
} }
} }
} }
}
fun runSearch() { fun runSearch() {
val q = internal.value.query.trim() val q = internal.value.query.trim()
@@ -68,11 +68,13 @@ import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.playlists.data.PlaylistDetailRef import com.fabledsword.minstrel.playlists.data.PlaylistDetailRef
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
import com.fabledsword.minstrel.shared.widgets.LikeButton 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.TrackCoverThumb
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -163,8 +165,7 @@ class PlaylistDetailViewModel @Inject constructor(
} }
} }
fun refresh() { fun refresh(): Job = viewModelScope.launch {
viewModelScope.launch {
try { try {
internal.value = PlaylistDetailUiState.Loading internal.value = PlaylistDetailUiState.Loading
val detail = repository.refreshDetail(playlistId) val detail = repository.refreshDetail(playlistId)
@@ -177,7 +178,6 @@ class PlaylistDetailViewModel @Inject constructor(
) )
} }
} }
}
/** Play the available tracks starting at [startTrackId] (or first available). */ /** Play the available tracks starting at [startTrackId] (or first available). */
fun play(tracks: List<PlaylistTrackRef>, startTrackId: String?) { fun play(tracks: List<PlaylistTrackRef>, startTrackId: String?) {
@@ -222,7 +222,10 @@ fun PlaylistDetailScreen(
}, },
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
) { inner -> ) { inner ->
Box(modifier = Modifier.fillMaxSize().padding(inner)) { PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner),
) {
when (val s = state) { when (val s = state) {
PlaylistDetailUiState.Loading -> LoadingCentered() PlaylistDetailUiState.Loading -> LoadingCentered()
is PlaylistDetailUiState.Error -> ErrorBlock(s.message, viewModel::refresh) is PlaylistDetailUiState.Error -> ErrorBlock(s.message, viewModel::refresh)
@@ -34,7 +34,9 @@ import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
@@ -64,21 +66,20 @@ class PlaylistsListViewModel @Inject constructor(
) : ViewModel() { ) : ViewModel() {
init { init {
// Fire-and-forget initial pull; the screen renders cached rows refresh()
// immediately and refreshes as the server response lands.
viewModelScope.launch {
runCatching { repository.refreshList() }
}
// Live updates: a playlist created/updated/deleted from another // Live updates: a playlist created/updated/deleted from another
// client should reflect here without a manual refresh. // client should reflect here without a manual refresh.
viewModelScope.launch { viewModelScope.launch {
eventsStream.events eventsStream.events
.filter { it.kind.startsWith("playlist.") } .filter { it.kind.startsWith("playlist.") }
.collect { .collect { refresh() }
}
}
/** Re-pull the playlists list. Returns the Job for pull-to-refresh awaits. */
fun refresh(): Job = viewModelScope.launch {
runCatching { repository.refreshList() } runCatching { repository.refreshList() }
} }
}
}
val uiState: StateFlow<PlaylistsListUiState> = val uiState: StateFlow<PlaylistsListUiState> =
repository.observeAll() repository.observeAll()
@@ -122,7 +123,10 @@ fun PlaylistsListScreen(
}, },
) { inner -> ) { inner ->
val state by viewModel.uiState.collectAsStateWithLifecycle() 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) { when (val s = state) {
PlaylistsListUiState.Loading -> LoadingCentered() PlaylistsListUiState.Loading -> LoadingCentered()
PlaylistsListUiState.Empty -> EmptyState( PlaylistsListUiState.Empty -> EmptyState(
@@ -37,6 +37,7 @@ import com.fabledsword.minstrel.models.RequestStatus
import com.fabledsword.minstrel.nav.Requests import com.fabledsword.minstrel.nav.Requests
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -64,7 +65,10 @@ fun RequestsScreen(
) )
}, },
) { inner -> ) { inner ->
Box(modifier = Modifier.fillMaxSize().padding(inner)) { PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner),
) {
when (val s = state) { when (val s = state) {
RequestsUiState.Loading -> LoadingCentered() RequestsUiState.Loading -> LoadingCentered()
RequestsUiState.Empty -> EmptyState( RequestsUiState.Empty -> EmptyState(
@@ -6,6 +6,7 @@ import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.RequestRef import com.fabledsword.minstrel.models.RequestRef
import com.fabledsword.minstrel.requests.data.RequestsRepository import com.fabledsword.minstrel.requests.data.RequestsRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -41,8 +42,7 @@ class RequestsViewModel @Inject constructor(
} }
} }
fun refresh() { fun refresh(): Job = viewModelScope.launch {
viewModelScope.launch {
internal.value = RequestsUiState.Loading internal.value = RequestsUiState.Loading
try { try {
val rows = repository.listMine() val rows = repository.listMine()
@@ -57,7 +57,6 @@ class RequestsViewModel @Inject constructor(
internal.value = RequestsUiState.Error(e.message ?: "Requests load failed") internal.value = RequestsUiState.Error(e.message ?: "Requests load failed")
} }
} }
}
/** /**
* Optimistically removes [id] from the success list so the row * Optimistically removes [id] from the success list so the row