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:
2026-05-22 22:38:59 -04:00
parent 9eaaf93f23
commit 0ea0fbf8be
4 changed files with 191 additions and 0 deletions
@@ -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,
)
}