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