feat(android): LibraryViewModel + UiState + MainDispatcherExtension (M8 phase 5.3)
First Hilt-injected ViewModel + sealed UiState pattern.
LibraryUiState (sealed interface): Loading / Empty / Success / Error.
The cases are exhaustive so Compose `when` blocks the compiler checks.
LibraryViewModel:
- combine(observeArtists, observeAlbums) → Success/Empty decision
- .catch translates upstream Flow exceptions to UiState.Error
- .stateIn(viewModelScope, WhileSubscribed(5_000), Loading) — the
standard Compose-friendly pattern; subscriptions tear down 5s after
the last collector to ride out config changes without hanging the
DAO Flow forever.
MainDispatcherExtension — JUnit 5 equivalent of the JUnit 4
MainDispatcherRule pattern (audit-deferred item; trigger met). Swaps
Dispatchers.Main for UnconfinedTestDispatcher in beforeEach +
resetMain in afterEach. Apply with `@ExtendWith`.
LibraryViewModelTest covers all four UiState cases — initial Loading,
empty cache (Empty), populated cache (Success), and an upstream Flow
exception (Error). MockK for the repo, Turbine for the Flow assertions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
package com.fabledsword.minstrel.library.ui
|
||||
|
||||
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.
|
||||
*/
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.fabledsword.minstrel.library.ui
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.library.data.LibraryRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||
|
||||
@HiltViewModel
|
||||
class LibraryViewModel @Inject constructor(
|
||||
private val repository: LibraryRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
val uiState: StateFlow<LibraryUiState> =
|
||||
combine(
|
||||
repository.observeArtists(),
|
||||
repository.observeAlbums(),
|
||||
) { artists, albums ->
|
||||
if (artists.isEmpty() && albums.isEmpty()) {
|
||||
LibraryUiState.Empty
|
||||
} else {
|
||||
LibraryUiState.Success(artists, albums)
|
||||
}
|
||||
}
|
||||
.catch { e ->
|
||||
emit(LibraryUiState.Error(e.message ?: "Library load failed"))
|
||||
}
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||
initialValue = LibraryUiState.Loading,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user