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,22 +67,20 @@ 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() internal.update { it.copy(suggestions = SuggestionState.Loaded(items)) }
internal.update { it.copy(suggestions = SuggestionState.Loaded(items)) } } catch (
} catch ( @Suppress("TooGenericExceptionCaught") e: Throwable,
@Suppress("TooGenericExceptionCaught") e: Throwable, ) {
) { internal.update {
internal.update { it.copy(
it.copy( suggestions = SuggestionState.Error(
suggestions = SuggestionState.Error( e.message ?: "Suggestions failed",
e.message ?: "Suggestions failed", ),
), )
)
}
} }
} }
} }
@@ -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,19 +165,17 @@ 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) internal.value = PlaylistDetailUiState.Success(detail)
internal.value = PlaylistDetailUiState.Success(detail) } catch (
} catch ( @Suppress("TooGenericExceptionCaught") e: Throwable,
@Suppress("TooGenericExceptionCaught") e: Throwable, ) {
) { internal.value = PlaylistDetailUiState.Error(
internal.value = PlaylistDetailUiState.Error( e.message ?: "Couldn't load playlist",
e.message ?: "Couldn't load playlist", )
)
}
} }
} }
@@ -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,22 +66,21 @@ 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() }
runCatching { repository.refreshList() }
}
} }
} }
/** Re-pull the playlists list. Returns the Job for pull-to-refresh awaits. */
fun refresh(): Job = viewModelScope.launch {
runCatching { repository.refreshList() }
}
val uiState: StateFlow<PlaylistsListUiState> = val uiState: StateFlow<PlaylistsListUiState> =
repository.observeAll() repository.observeAll()
.map { list -> .map { list ->
@@ -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,21 +42,19 @@ 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() internal.value = if (rows.isEmpty()) {
internal.value = if (rows.isEmpty()) { RequestsUiState.Empty
RequestsUiState.Empty } else {
} else { RequestsUiState.Success(rows)
RequestsUiState.Success(rows)
}
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.value = RequestsUiState.Error(e.message ?: "Requests load failed")
} }
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.value = RequestsUiState.Error(e.message ?: "Requests load failed")
} }
} }