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.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,
@@ -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",
),
)
}
}
}
@@ -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)
@@ -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<PlaylistsListUiState> =
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(
@@ -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(
@@ -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")
}
}