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