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:
@@ -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)) },
|
||||
|
||||
+11
-17
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+6
-12
@@ -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) },
|
||||
)
|
||||
|
||||
+13
-19
@@ -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;
|
||||
|
||||
+5
-4
@@ -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) })
|
||||
}
|
||||
}
|
||||
|
||||
+6
-11
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+9
-8
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user