refactor(android): migrate 8 per-screen UiStates to shared UiState<T>

History/Hidden/Requests/PlaylistsList/AddToPlaylist/Home/Liked/Library
each had its own sealed interface with Loading/Empty/Success(payload)/
Error variants. Collapsed to the generic shared/UiState<T> introduced
in the prior commit. Library carries two lists (artists + albums) so
its payload is wrapped in a new LibraryData record; the test was
updated to assert against UiState.Success<LibraryData>.

The 3 detail screens (Artist/Album/Playlist Detail) keep their own
sealed interfaces for now since they include a Loading(seed: ...)
variant that does not fit UiState<T>.
This commit is contained in:
2026-05-30 14:03:39 -04:00
parent 587baf4a79
commit 810e3126c2
14 changed files with 120 additions and 166 deletions
@@ -23,6 +23,7 @@ import com.fabledsword.minstrel.history.data.HistoryEntry
import com.fabledsword.minstrel.history.data.HistoryRepository
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.shared.widgets.TrackRow
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
@@ -49,13 +50,6 @@ import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
sealed interface HistoryUiState {
data object Loading : HistoryUiState
data object Empty : HistoryUiState
data class Success(val entries: List<HistoryEntry>) : HistoryUiState
data class Error(val message: String) : HistoryUiState
}
// ─── ViewModel ───────────────────────────────────────────────────────
@HiltViewModel
@@ -72,18 +66,18 @@ class HistoryTabViewModel @Inject constructor(
* only when there's nothing cached AND the refresh failed — a
* failed refresh over a populated cache stays silent.
*/
val uiState: StateFlow<HistoryUiState> =
val uiState: StateFlow<UiState<List<HistoryEntry>>> =
combine(repository.observeHistory(), refreshError) { page, err ->
when {
page != null && page.entries.isNotEmpty() -> HistoryUiState.Success(page.entries)
page != null -> HistoryUiState.Empty
err != null -> HistoryUiState.Error(err)
else -> HistoryUiState.Loading
page != null && page.entries.isNotEmpty() -> UiState.Success(page.entries)
page != null -> UiState.Empty
err != null -> UiState.Error(err)
else -> UiState.Loading
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = HistoryUiState.Loading,
initialValue = UiState.Loading,
)
init {
@@ -117,17 +111,17 @@ fun HistoryTab(
val playingTrackId = playerState.currentTrack?.id
PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
when (val s = state) {
HistoryUiState.Loading -> LoadingCentered()
HistoryUiState.Empty -> EmptyState(
UiState.Loading -> LoadingCentered()
UiState.Empty -> EmptyState(
title = "No listening history yet",
body = "Play something — your recent plays will show up here.",
)
is HistoryUiState.Error -> EmptyState(
is UiState.Error -> EmptyState(
title = "Couldn't load history",
body = s.message,
)
is HistoryUiState.Success -> HistoryList(
entries = s.entries,
is UiState.Success -> HistoryList(
entries = s.data,
playingTrackId = playingTrackId,
onPlay = viewModel::playTrack,
onNavigateToAlbum = onNavigateToAlbum,
@@ -67,6 +67,7 @@ import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
import com.fabledsword.minstrel.playlists.widgets.OfflinePoolCard
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard
import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
@@ -113,13 +114,6 @@ data class HomeSections(
lastPlayedArtists.isEmpty()
}
sealed interface HomeUiState {
data object Loading : HomeUiState
data object Empty : HomeUiState
data class Success(val sections: HomeSections) : HomeUiState
data class Error(val message: String) : HomeUiState
}
// ─── ViewModel ───────────────────────────────────────────────────────
@HiltViewModel
@@ -204,10 +198,10 @@ class HomeViewModel @Inject constructor(
status.join()
}
val uiState: StateFlow<HomeUiState> = combineHomeFlows().stateIn(
val uiState: StateFlow<UiState<HomeSections>> = combineHomeFlows().stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = HomeUiState.Loading,
initialValue = UiState.Loading,
)
/**
@@ -236,8 +230,8 @@ class HomeViewModel @Inject constructor(
private fun combineHomeFlows() =
observeHomeSections().combine(playlistsRepository.observeAll()) { sections, playlists ->
val merged = sections.copy(playlists = playlists)
if (merged.isAllEmpty) HomeUiState.Empty else HomeUiState.Success(merged)
}.catch { e -> emit(HomeUiState.Error(ErrorCopy.fromThrowable(e))) }
if (merged.isAllEmpty) UiState.Empty else UiState.Success(merged)
}.catch { e -> emit(UiState.Error(ErrorCopy.fromThrowable(e))) }
}
// ─── Screen ──────────────────────────────────────────────────────────
@@ -276,19 +270,19 @@ fun HomeScreen(
) {
Crossfade(targetState = state, label = "home-state") { s ->
when (s) {
HomeUiState.Loading -> HomeSkeletonContent()
HomeUiState.Empty -> EmptyState(
UiState.Loading -> HomeSkeletonContent()
UiState.Empty -> EmptyState(
title = "Welcome to Minstrel",
body = "Nothing to show yet — scan a folder in your server " +
"settings, then come back here for system playlists " +
"and recommendations.",
)
is HomeUiState.Error -> EmptyState(
is UiState.Error -> EmptyState(
title = "Couldn't load home",
body = s.message,
)
is HomeUiState.Success -> HomeSuccessContent(
sections = s.sections,
is UiState.Success -> HomeSuccessContent(
sections = s.data,
systemStatus = systemStatus,
offline = offline,
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
@@ -46,6 +46,7 @@ import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.nav.Library
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Shuffle
import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
@@ -151,18 +152,18 @@ private fun ArtistsTab(
PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
Crossfade(targetState = state, label = "library-artists") { s ->
when (s) {
LibraryUiState.Loading -> SkeletonArtistsGrid()
LibraryUiState.Empty -> EmptyState(
UiState.Loading -> SkeletonArtistsGrid()
UiState.Empty -> EmptyState(
title = "No artists yet",
body = "Scan a folder in your server settings to " +
"populate the library.",
)
is LibraryUiState.Error -> ErrorRetry(
is UiState.Error -> ErrorRetry(
message = s.message,
onRetry = { viewModel.refresh() },
)
is LibraryUiState.Success -> ArtistsGrid(
artists = s.artists,
is UiState.Success -> ArtistsGrid(
artists = s.data.artists,
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
)
}
@@ -179,18 +180,18 @@ private fun AlbumsTab(
PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
Crossfade(targetState = state, label = "library-albums") { s ->
when (s) {
LibraryUiState.Loading -> SkeletonAlbumsGrid()
LibraryUiState.Empty -> EmptyState(
UiState.Loading -> SkeletonAlbumsGrid()
UiState.Empty -> EmptyState(
title = "No albums yet",
body = "Scan a folder in your server settings to " +
"populate the library.",
)
is LibraryUiState.Error -> ErrorRetry(
is UiState.Error -> ErrorRetry(
message = s.message,
onRetry = { viewModel.refresh() },
)
is LibraryUiState.Success -> AlbumsGrid(
albums = s.albums,
is UiState.Success -> AlbumsGrid(
albums = s.data.albums,
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
)
}
@@ -4,23 +4,12 @@ import com.fabledsword.minstrel.models.AlbumRef
import com.fabledsword.minstrel.models.ArtistRef
/**
* UI state for the Library screen. Sealed interface — each composable
* branch handles exactly the cases it cares about; the compiler
* enforces exhaustiveness.
*
* - Loading: initial state before the first DAO emission.
* - Empty: DAO emitted but the cache holds no artists and no albums
* (fresh install, never synced).
* - Success: DAO emitted with content.
* - Error: an exception bubbled from the DAO/repository layer; the
* message is surfaced as-is in the empty-state copy.
* Payload carried by `UiState.Success` for the Library screen. The
* grid renders artists and albums side-by-side, so the state wraps
* both lists into a single value; the generic `shared/UiState<T>` then
* provides Loading / Empty / Error around it.
*/
sealed interface LibraryUiState {
data object Loading : LibraryUiState
data object Empty : LibraryUiState
data class Success(
val artists: List<ArtistRef>,
val albums: List<AlbumRef>,
) : LibraryUiState
data class Error(val message: String) : LibraryUiState
}
data class LibraryData(
val artists: List<ArtistRef>,
val albums: List<AlbumRef>,
)
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.cache.sync.SyncController
import com.fabledsword.minstrel.library.data.LibraryRepository
import com.fabledsword.minstrel.shared.UiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.SharingStarted
@@ -24,24 +25,24 @@ class LibraryViewModel @Inject constructor(
private val player: com.fabledsword.minstrel.player.PlayerController,
) : ViewModel() {
val uiState: StateFlow<LibraryUiState> =
val uiState: StateFlow<UiState<LibraryData>> =
combine(
repository.observeArtists(),
repository.observeAlbums(),
) { artists, albums ->
if (artists.isEmpty() && albums.isEmpty()) {
LibraryUiState.Empty
UiState.Empty
} else {
LibraryUiState.Success(artists, albums)
UiState.Success(LibraryData(artists, albums))
}
}
.catch { e ->
emit(LibraryUiState.Error(ErrorCopy.fromThrowable(e)))
emit(UiState.Error(ErrorCopy.fromThrowable(e)))
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = LibraryUiState.Loading,
initialValue = UiState.Loading,
)
/**
@@ -35,6 +35,7 @@ import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.shared.widgets.TrackRow
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
@@ -66,13 +67,6 @@ data class LikedSections(
get() = artists.isEmpty() && albums.isEmpty() && tracks.isEmpty()
}
sealed interface LikedTabUiState {
data object Loading : LikedTabUiState
data object Empty : LikedTabUiState
data class Success(val sections: LikedSections) : LikedTabUiState
data class Error(val message: String) : LikedTabUiState
}
// ─── ViewModel ───────────────────────────────────────────────────────
@HiltViewModel
@@ -99,19 +93,19 @@ class LikedTabViewModel @Inject constructor(
}
}
val uiState: StateFlow<LikedTabUiState> = combine(
val uiState: StateFlow<UiState<LikedSections>> = combine(
repository.observeLikedArtists(),
repository.observeLikedAlbums(),
repository.observeLikedTracks(),
) { artists, albums, tracks ->
val sections = LikedSections(artists, albums, tracks)
if (sections.isAllEmpty) LikedTabUiState.Empty else LikedTabUiState.Success(sections)
if (sections.isAllEmpty) UiState.Empty else UiState.Success(sections)
}
.catch { e -> emit(LikedTabUiState.Error(ErrorCopy.fromThrowable(e))) }
.catch { e -> emit(UiState.Error(ErrorCopy.fromThrowable(e))) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = LikedTabUiState.Loading,
initialValue = UiState.Loading,
)
/**
@@ -141,23 +135,23 @@ fun LikedTab(
val playingTrackId = playerState.currentTrack?.id
PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
when (val s = state) {
LikedTabUiState.Loading -> LoadingCentered()
LikedTabUiState.Empty -> EmptyState(
UiState.Loading -> LoadingCentered()
UiState.Empty -> EmptyState(
title = "No likes yet",
body = "Tap the heart on an artist, album, or track to start " +
"building your liked collection.",
)
is LikedTabUiState.Error -> EmptyState(
is UiState.Error -> EmptyState(
title = "Couldn't load likes",
body = s.message,
)
is LikedTabUiState.Success -> LikedContent(
sections = s.sections,
is UiState.Success -> LikedContent(
sections = s.data,
playingTrackId = playingTrackId,
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
onTrackClick = { index ->
viewModel.playTracks(s.sections.tracks, index)
viewModel.playTracks(s.data.tracks, index)
},
onUnlike = viewModel::unlikeTrack,
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
@@ -29,6 +29,7 @@ import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.nav.PlaylistDetail
import com.fabledsword.minstrel.nav.Playlists
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
@@ -49,13 +50,6 @@ private const val SHARE_STOP_TIMEOUT_MS = 5_000L
// ─── State ───────────────────────────────────────────────────────────
sealed interface PlaylistsListUiState {
data object Loading : PlaylistsListUiState
data object Empty : PlaylistsListUiState
data class Success(val playlists: List<PlaylistRef>) : PlaylistsListUiState
data class Error(val message: String) : PlaylistsListUiState
}
// ─── ViewModel ───────────────────────────────────────────────────────
@HiltViewModel
@@ -80,22 +74,22 @@ class PlaylistsListViewModel @Inject constructor(
runCatching { repository.refreshList() }
}
val uiState: StateFlow<PlaylistsListUiState> =
val uiState: StateFlow<UiState<List<PlaylistRef>>> =
repository.observeAll()
.map { list ->
if (list.isEmpty()) {
PlaylistsListUiState.Empty
UiState.Empty
} else {
PlaylistsListUiState.Success(list)
UiState.Success(list)
}
}
.catch { e ->
emit(PlaylistsListUiState.Error(ErrorCopy.fromThrowable(e)))
emit(UiState.Error(ErrorCopy.fromThrowable(e)))
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = PlaylistsListUiState.Loading,
initialValue = UiState.Loading,
)
}
@@ -127,19 +121,19 @@ fun PlaylistsListScreen(
modifier = Modifier.fillMaxSize().padding(inner),
) {
when (val s = state) {
PlaylistsListUiState.Loading -> LoadingCentered()
PlaylistsListUiState.Empty -> EmptyState(
UiState.Loading -> LoadingCentered()
UiState.Empty -> EmptyState(
title = "No playlists yet",
body = "System playlists (For You / Discover / Songs like…) " +
"appear once your library has enough plays. Create your " +
"own playlist from any album or track in the meantime.",
)
is PlaylistsListUiState.Error -> EmptyState(
is UiState.Error -> EmptyState(
title = "Couldn't load playlists",
body = s.message,
)
is PlaylistsListUiState.Success -> PlaylistsGrid(
playlists = s.playlists,
is UiState.Success -> PlaylistsGrid(
playlists = s.data,
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
)
}
@@ -25,6 +25,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composables.icons.lucide.ArchiveRestore
import com.composables.icons.lucide.Lucide
import com.fabledsword.minstrel.models.QuarantineRef
import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
@@ -35,19 +36,19 @@ fun HiddenTab(viewModel: HiddenTabViewModel = hiltViewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
when (val s = state) {
HiddenTabUiState.Loading -> LoadingCentered()
HiddenTabUiState.Empty -> EmptyState(
UiState.Loading -> LoadingCentered()
UiState.Empty -> EmptyState(
title = "No hidden tracks",
body = "Flag a track from its menu (bad rip, wrong tags, etc.) " +
"and it'll show up here. The track stays out of system " +
"playlists until you unhide it.",
)
is HiddenTabUiState.Error -> EmptyState(
is UiState.Error -> EmptyState(
title = "Couldn't load hidden tracks",
body = s.message,
)
is HiddenTabUiState.Success -> HiddenList(
rows = s.rows,
is UiState.Success -> HiddenList(
rows = s.data,
onUnflag = viewModel::unflag,
)
}
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.QuarantineRef
import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.quarantine.data.QuarantineRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
@@ -19,13 +20,6 @@ import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
sealed interface HiddenTabUiState {
data object Loading : HiddenTabUiState
data object Empty : HiddenTabUiState
data class Success(val rows: List<QuarantineRef>) : HiddenTabUiState
data class Error(val message: String) : HiddenTabUiState
}
@HiltViewModel
class HiddenTabViewModel @Inject constructor(
private val repository: QuarantineRepository,
@@ -41,17 +35,17 @@ class HiddenTabViewModel @Inject constructor(
* refresh failed. Optimistic flag/unflag write the cache, so the
* list updates without waiting on the server.
*/
val uiState: StateFlow<HiddenTabUiState> =
val uiState: StateFlow<UiState<List<QuarantineRef>>> =
combine(repository.observeMine(), refreshError) { rows, err ->
when {
rows.isNotEmpty() -> HiddenTabUiState.Success(rows)
err != null -> HiddenTabUiState.Error(err)
else -> HiddenTabUiState.Empty
rows.isNotEmpty() -> UiState.Success(rows)
err != null -> UiState.Error(err)
else -> UiState.Empty
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = HiddenTabUiState.Loading,
initialValue = UiState.Loading,
)
init {
@@ -46,6 +46,7 @@ import com.composables.icons.lucide.X
import com.fabledsword.minstrel.models.RequestRef
import com.fabledsword.minstrel.models.RequestStatus
import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.nav.Requests
import com.fabledsword.minstrel.shared.widgets.EmptyState
@@ -84,18 +85,18 @@ fun RequestsScreen(
modifier = Modifier.fillMaxSize().padding(inner),
) {
when (val s = state) {
RequestsUiState.Loading -> LoadingCentered()
RequestsUiState.Empty -> EmptyState(
UiState.Loading -> LoadingCentered()
UiState.Empty -> EmptyState(
title = "Nothing requested yet",
body = "Use Discover to ask Lidarr for new music; your " +
"requests show up here.",
)
is RequestsUiState.Error -> EmptyState(
is UiState.Error -> EmptyState(
title = "Couldn't load requests",
body = s.message,
)
is RequestsUiState.Success -> RequestList(
rows = s.requests,
is UiState.Success -> RequestList(
rows = s.data,
onCancel = viewModel::cancel,
onListen = { id -> navController.navigate(id) },
)
@@ -6,6 +6,7 @@ import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.RequestRef
import com.fabledsword.minstrel.requests.data.RequestsRepository
import com.fabledsword.minstrel.shared.UiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
@@ -18,21 +19,14 @@ import javax.inject.Inject
private val RELEVANT_EVENT_KINDS = setOf("request.status_changed")
sealed interface RequestsUiState {
data object Loading : RequestsUiState
data object Empty : RequestsUiState
data class Success(val requests: List<RequestRef>) : RequestsUiState
data class Error(val message: String) : RequestsUiState
}
@HiltViewModel
class RequestsViewModel @Inject constructor(
private val repository: RequestsRepository,
private val eventsStream: EventsStream,
) : ViewModel() {
private val internal = MutableStateFlow<RequestsUiState>(RequestsUiState.Loading)
val uiState: StateFlow<RequestsUiState> = internal.asStateFlow()
private val internal = MutableStateFlow<UiState<List<RequestRef>>>(UiState.Loading)
val uiState: StateFlow<UiState<List<RequestRef>>> = internal.asStateFlow()
init {
refresh()
@@ -44,18 +38,18 @@ class RequestsViewModel @Inject constructor(
}
fun refresh(): Job = viewModelScope.launch {
internal.value = RequestsUiState.Loading
internal.value = UiState.Loading
try {
val rows = repository.listMine()
internal.value = if (rows.isEmpty()) {
RequestsUiState.Empty
UiState.Empty
} else {
RequestsUiState.Success(rows)
UiState.Success(rows)
}
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.value = RequestsUiState.Error(ErrorCopy.fromThrowable(e))
internal.value = UiState.Error(ErrorCopy.fromThrowable(e))
}
}
@@ -67,12 +61,12 @@ class RequestsViewModel @Inject constructor(
*/
fun cancel(id: String) {
val before = internal.value
if (before is RequestsUiState.Success) {
val filtered = before.requests.filterNot { it.id == id }
if (before is UiState.Success) {
val filtered = before.data.filterNot { it.id == id }
internal.value = if (filtered.isEmpty()) {
RequestsUiState.Empty
UiState.Empty
} else {
RequestsUiState.Success(filtered)
UiState.Success(filtered)
}
}
viewModelScope.launch {
@@ -80,11 +74,11 @@ class RequestsViewModel @Inject constructor(
val (_, updated) = repository.cancel(id)
if (updated != null) {
internal.update { state ->
if (state !is RequestsUiState.Success) {
if (state !is UiState.Success) {
// Mid-refresh — let the refresh result win, but
// splice the cancelled row in so it shows the new
// status if the user re-pulls.
RequestsUiState.Success(listOf(updated))
UiState.Success(listOf(updated))
} else {
// Re-insert the cancelled row in its old position
// would require remembering the prior ordering;
@@ -27,6 +27,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composables.icons.lucide.ListMusic
import com.composables.icons.lucide.Lucide
import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.shared.UiState
/**
* ModalBottomSheet that lists the caller's user-owned playlists and
@@ -55,16 +56,16 @@ fun AddToPlaylistSheet(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
)
when (val s = state) {
AddToPlaylistUiState.Loading -> Box(
UiState.Loading -> Box(
modifier = Modifier.fillMaxWidth().padding(24.dp),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) }
is AddToPlaylistUiState.Error -> Text(
is UiState.Error -> Text(
text = s.message,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
)
is AddToPlaylistUiState.Success -> if (s.playlists.isEmpty()) {
is UiState.Success -> if (s.data.isEmpty()) {
Text(
text = "You haven't created any playlists yet.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -72,7 +73,7 @@ fun AddToPlaylistSheet(
)
} else {
LazyColumn(modifier = Modifier.fillMaxWidth()) {
items(items = s.playlists, key = { it.id }) { p ->
items(items = s.data, key = { it.id }) { p ->
PlaylistPickRow(playlist = p, onClick = { onPick(p.id) })
}
}
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
import com.fabledsword.minstrel.shared.UiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -17,12 +18,6 @@ import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
sealed interface AddToPlaylistUiState {
data object Loading : AddToPlaylistUiState
data class Success(val playlists: List<PlaylistRef>) : AddToPlaylistUiState
data class Error(val message: String) : AddToPlaylistUiState
}
/**
* Backs [AddToPlaylistSheet] with the caller's user-owned playlists.
* System playlists (For You / Discover / etc.) are filtered out at
@@ -33,9 +28,9 @@ class AddToPlaylistViewModel @Inject constructor(
repository: PlaylistsRepository,
) : ViewModel() {
val uiState: StateFlow<AddToPlaylistUiState> = repository
val uiState: StateFlow<UiState<List<PlaylistRef>>> = repository
.observeUserPlaylists()
.map { list -> AddToPlaylistUiState.Success(list) as AddToPlaylistUiState }
.map { list -> UiState.Success(list) as UiState<List<PlaylistRef>> }
.onStart {
// Best-effort refresh so the sheet reflects newly-created
// playlists from another device. Cached list shows first;
@@ -43,12 +38,12 @@ class AddToPlaylistViewModel @Inject constructor(
viewModelScope.launch {
runCatching { repository.refreshList() }
}
emit(AddToPlaylistUiState.Loading)
emit(UiState.Loading)
}
.catch { e -> emit(AddToPlaylistUiState.Error(ErrorCopy.fromThrowable(e))) }
.catch { e -> emit(UiState.Error(ErrorCopy.fromThrowable(e))) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = AddToPlaylistUiState.Loading,
initialValue = UiState.Loading,
)
}
@@ -6,6 +6,7 @@ import com.fabledsword.minstrel.library.data.LibraryRepository
import com.fabledsword.minstrel.models.AlbumRef
import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.testutil.MainDispatcherExtension
import io.mockk.every
import io.mockk.mockk
@@ -32,7 +33,7 @@ class LibraryViewModelTest {
mockk<PlayerController>(relaxed = true),
)
assertEquals(LibraryUiState.Loading, vm.uiState.value)
assertEquals(UiState.Loading, vm.uiState.value)
}
// Note: tests below collect the resolved state directly. With
@@ -55,7 +56,7 @@ class LibraryViewModelTest {
)
vm.uiState.test {
assertEquals(LibraryUiState.Empty, awaitItem())
assertEquals(UiState.Empty, awaitItem())
cancelAndConsumeRemainingEvents()
}
}
@@ -75,11 +76,11 @@ class LibraryViewModelTest {
)
vm.uiState.test {
val success = awaitItem() as LibraryUiState.Success
assertEquals(1, success.artists.size)
assertEquals(1, success.albums.size)
assertEquals("a1", success.artists[0].id)
assertEquals("al1", success.albums[0].id)
val success = awaitItem() as UiState.Success<LibraryData>
assertEquals(1, success.data.artists.size)
assertEquals(1, success.data.albums.size)
assertEquals("a1", success.data.artists[0].id)
assertEquals("al1", success.data.albums[0].id)
cancelAndConsumeRemainingEvents()
}
}
@@ -98,7 +99,7 @@ class LibraryViewModelTest {
)
vm.uiState.test {
val error = awaitItem() as LibraryUiState.Error
val error = awaitItem() as UiState.Error
// ErrorCopy maps a non-HTTP/non-IO throwable to the generic
// fallback rather than leaking the raw exception text.
assertEquals("Something went wrong.", error.message)