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:
@@ -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,
|
||||
|
||||
+15
-16
@@ -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",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+17
-14
@@ -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)
|
||||
|
||||
+13
-9
@@ -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(
|
||||
|
||||
+13
-14
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user