refactor: decouple card detail viewmodel datasource contracts

This commit is contained in:
2026-03-16 21:00:51 -04:00
parent beab9006a3
commit dfcdc79856
2 changed files with 133 additions and 69 deletions

View File

@@ -1,6 +1,7 @@
package space.hackenslacker.kanbn4droid.app.carddetail
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import java.time.LocalDate
import kotlinx.coroutines.Job
@@ -45,13 +46,55 @@ sealed interface CardDetailUiEvent {
data class ShowSnackbar(val message: String) : CardDetailUiEvent
}
sealed interface DataSourceResult<out T> {
data class Success<T>(val value: T) : DataSourceResult<T>
data class GenericError(val message: String) : DataSourceResult<Nothing>
data object SessionExpired : DataSourceResult<Nothing>
}
interface CardDetailDataSource {
suspend fun loadCard(cardId: String): CardDetailRepository.Result<CardDetail>
suspend fun updateTitle(cardId: String, title: String): CardDetailRepository.Result<Unit>
suspend fun updateDescription(cardId: String, description: String): CardDetailRepository.Result<Unit>
suspend fun updateDueDate(cardId: String, dueDate: LocalDate?): CardDetailRepository.Result<Unit>
suspend fun listActivities(cardId: String): CardDetailRepository.Result<List<CardActivity>>
suspend fun addComment(cardId: String, comment: String): CardDetailRepository.Result<Unit>
suspend fun loadCard(cardId: String): DataSourceResult<CardDetail>
suspend fun updateTitle(cardId: String, title: String): DataSourceResult<Unit>
suspend fun updateDescription(cardId: String, description: String): DataSourceResult<Unit>
suspend fun updateDueDate(cardId: String, dueDate: LocalDate?): DataSourceResult<Unit>
suspend fun listActivities(cardId: String): DataSourceResult<List<CardActivity>>
suspend fun addComment(cardId: String, comment: String): DataSourceResult<Unit>
}
internal class CardDetailRepositoryDataSource(
private val repository: CardDetailRepository,
private val loadCardCall: suspend (String) -> CardDetailRepository.Result<CardDetail>,
) : CardDetailDataSource {
override suspend fun loadCard(cardId: String): DataSourceResult<CardDetail> {
return loadCardCall(cardId).toDataSourceResult()
}
override suspend fun updateTitle(cardId: String, title: String): DataSourceResult<Unit> {
return repository.updateTitle(cardId, title).toDataSourceResult()
}
override suspend fun updateDescription(cardId: String, description: String): DataSourceResult<Unit> {
return repository.updateDescription(cardId, description).toDataSourceResult()
}
override suspend fun updateDueDate(cardId: String, dueDate: LocalDate?): DataSourceResult<Unit> {
return repository.updateDueDate(cardId, dueDate).toDataSourceResult()
}
override suspend fun listActivities(cardId: String): DataSourceResult<List<CardActivity>> {
return repository.listActivities(cardId).toDataSourceResult()
}
override suspend fun addComment(cardId: String, comment: String): DataSourceResult<Unit> {
val result = repository.addComment(cardId, comment)
return when (result) {
is CardDetailRepository.Result.Success -> DataSourceResult.Success(Unit)
is CardDetailRepository.Result.Failure.SessionExpired -> DataSourceResult.SessionExpired
is CardDetailRepository.Result.Failure.Generic -> {
DataSourceResult.GenericError(result.message)
}
}
}
}
class CardDetailViewModel(
@@ -93,7 +136,7 @@ class CardDetailViewModel(
}
when (val result = repository.loadCard(cardId)) {
is CardDetailRepository.Result.Success -> {
is DataSourceResult.Success -> {
val detail = result.value
persistedTitle = detail.title
persistedDescription = detail.description
@@ -114,7 +157,7 @@ class CardDetailViewModel(
loadActivities()
}
is CardDetailRepository.Result.Failure.SessionExpired -> {
is DataSourceResult.SessionExpired -> {
_uiState.update {
it.copy(
isInitialLoading = false,
@@ -124,7 +167,7 @@ class CardDetailViewModel(
_events.emit(CardDetailUiEvent.SessionExpired)
}
is CardDetailRepository.Result.Failure.Generic -> {
is DataSourceResult.GenericError -> {
_uiState.update {
it.copy(
isInitialLoading = false,
@@ -249,7 +292,7 @@ class CardDetailViewModel(
viewModelScope.launch {
_uiState.update { it.copy(isCommentSubmitting = true, commentErrorMessage = null) }
when (val result = repository.addComment(cardId, payload)) {
is CardDetailRepository.Result.Success -> {
is DataSourceResult.Success -> {
_uiState.update {
it.copy(
isCommentSubmitting = false,
@@ -262,7 +305,7 @@ class CardDetailViewModel(
loadActivities()
}
is CardDetailRepository.Result.Failure.SessionExpired -> {
is DataSourceResult.SessionExpired -> {
_uiState.update {
it.copy(
isCommentSubmitting = false,
@@ -272,7 +315,7 @@ class CardDetailViewModel(
_events.emit(CardDetailUiEvent.SessionExpired)
}
is CardDetailRepository.Result.Failure.Generic -> {
is DataSourceResult.GenericError -> {
_uiState.update {
it.copy(
isCommentSubmitting = false,
@@ -310,7 +353,7 @@ class CardDetailViewModel(
viewModelScope.launch {
_uiState.update { it.copy(isTitleSaving = true, titleErrorMessage = null) }
when (val result = repository.updateTitle(cardId, normalized)) {
is CardDetailRepository.Result.Success -> {
is DataSourceResult.Success -> {
persistedTitle = normalized
_uiState.update {
it.copy(
@@ -321,7 +364,7 @@ class CardDetailViewModel(
}
}
is CardDetailRepository.Result.Failure.SessionExpired -> {
is DataSourceResult.SessionExpired -> {
_uiState.update {
it.copy(
isTitleSaving = false,
@@ -331,7 +374,7 @@ class CardDetailViewModel(
_events.emit(CardDetailUiEvent.SessionExpired)
}
is CardDetailRepository.Result.Failure.Generic -> {
is DataSourceResult.GenericError -> {
_uiState.update {
it.copy(
isTitleSaving = false,
@@ -387,7 +430,7 @@ class CardDetailViewModel(
inFlightDescriptionPayload = normalized
_uiState.update { it.copy(isDescriptionSaving = true, descriptionErrorMessage = null) }
when (val result = repository.updateDescription(cardId, normalized)) {
is CardDetailRepository.Result.Success -> {
is DataSourceResult.Success -> {
persistedDescription = normalized
_uiState.update {
val stillDirty = it.description != persistedDescription
@@ -399,7 +442,7 @@ class CardDetailViewModel(
}
}
is CardDetailRepository.Result.Failure.SessionExpired -> {
is DataSourceResult.SessionExpired -> {
_uiState.update {
it.copy(
isDescriptionSaving = false,
@@ -409,7 +452,7 @@ class CardDetailViewModel(
_events.emit(CardDetailUiEvent.SessionExpired)
}
is CardDetailRepository.Result.Failure.Generic -> {
is DataSourceResult.GenericError -> {
_uiState.update {
it.copy(
isDescriptionSaving = false,
@@ -450,7 +493,7 @@ class CardDetailViewModel(
}
when (val result = repository.updateDueDate(cardId, dueDate)) {
is CardDetailRepository.Result.Success -> {
is DataSourceResult.Success -> {
persistedDueDate = dueDate
_uiState.update {
it.copy(
@@ -460,7 +503,7 @@ class CardDetailViewModel(
}
}
is CardDetailRepository.Result.Failure.SessionExpired -> {
is DataSourceResult.SessionExpired -> {
_uiState.update {
it.copy(
isDueDateSaving = false,
@@ -470,7 +513,7 @@ class CardDetailViewModel(
_events.emit(CardDetailUiEvent.SessionExpired)
}
is CardDetailRepository.Result.Failure.Generic -> {
is DataSourceResult.GenericError -> {
_uiState.update {
it.copy(
isDueDateSaving = false,
@@ -489,17 +532,17 @@ class CardDetailViewModel(
viewModelScope.launch {
_uiState.update { it.copy(isActivitiesLoading = true, activitiesErrorMessage = null) }
when (val result = repository.listActivities(cardId)) {
is CardDetailRepository.Result.Success -> {
is DataSourceResult.Success -> {
_uiState.update {
it.copy(
isActivitiesLoading = false,
activities = result.value.sortedByDescending { activity -> activity.createdAtEpochMillis },
activities = result.value,
activitiesErrorMessage = null,
)
}
}
is CardDetailRepository.Result.Failure.SessionExpired -> {
is DataSourceResult.SessionExpired -> {
_uiState.update {
it.copy(
isActivitiesLoading = false,
@@ -509,7 +552,7 @@ class CardDetailViewModel(
_events.emit(CardDetailUiEvent.SessionExpired)
}
is CardDetailRepository.Result.Failure.Generic -> {
is DataSourceResult.GenericError -> {
_uiState.update {
it.copy(
isActivitiesLoading = false,
@@ -520,4 +563,25 @@ class CardDetailViewModel(
}
}
}
class Factory(
private val cardId: String,
private val dataSource: CardDetailDataSource,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(CardDetailViewModel::class.java)) {
return CardDetailViewModel(cardId = cardId, repository = dataSource) as T
}
throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
}
}
}
private fun <T> CardDetailRepository.Result<T>.toDataSourceResult(): DataSourceResult<T> {
return when (this) {
is CardDetailRepository.Result.Success -> DataSourceResult.Success(value)
is CardDetailRepository.Result.Failure.Generic -> DataSourceResult.GenericError(message)
is CardDetailRepository.Result.Failure.SessionExpired -> DataSourceResult.SessionExpired
}
}

View File

@@ -37,8 +37,8 @@ class CardDetailViewModelTest {
@Test
fun initialLoad_fillsEditableFieldsAndActivities() = runTest {
val repository = FakeCardDetailDataSource(
loadCardResult = CardDetailRepository.Result.Success(sampleCardDetail()),
listActivitiesResult = CardDetailRepository.Result.Success(sampleActivitiesShuffled()),
loadCardResult = DataSourceResult.Success(sampleCardDetail()),
listActivitiesResult = DataSourceResult.Success(sampleActivitiesShuffled()),
)
val viewModel = CardDetailViewModel(
cardId = "card-1",
@@ -61,7 +61,7 @@ class CardDetailViewModelTest {
@Test
fun loadSessionExpired_emitsEvent_andRetryPathsDoNotRun() = runTest {
val repository = FakeCardDetailDataSource(
loadCardResult = CardDetailRepository.Result.Failure.SessionExpired(),
loadCardResult = DataSourceResult.SessionExpired,
)
val viewModel = CardDetailViewModel(cardId = "card-1", repository = repository)
val eventDeferred = async { viewModel.events.first() }
@@ -90,8 +90,8 @@ class CardDetailViewModelTest {
@Test
fun titleTrimRejectsBlank_andIsolatedFromDescription() = runTest {
val repository = FakeCardDetailDataSource(
loadCardResult = CardDetailRepository.Result.Success(sampleCardDetail()),
listActivitiesResult = CardDetailRepository.Result.Success(emptyList()),
loadCardResult = DataSourceResult.Success(sampleCardDetail()),
listActivitiesResult = DataSourceResult.Success(emptyList()),
)
val viewModel = loadedViewModel(this, repository)
@@ -109,8 +109,8 @@ class CardDetailViewModelTest {
@Test
fun dueDateSetAndClear_areIndependentAndSaved() = runTest {
val repository = FakeCardDetailDataSource(
loadCardResult = CardDetailRepository.Result.Success(sampleCardDetail()),
listActivitiesResult = CardDetailRepository.Result.Success(emptyList()),
loadCardResult = DataSourceResult.Success(sampleCardDetail()),
listActivitiesResult = DataSourceResult.Success(emptyList()),
)
val viewModel = loadedViewModel(this, repository)
@@ -130,8 +130,8 @@ class CardDetailViewModelTest {
fun descriptionDebounce_savesLatestOnly_andSuppressesDuplicateInflight() = runTest {
val gate = CompletableDeferred<Unit>()
val repository = FakeCardDetailDataSource(
loadCardResult = CardDetailRepository.Result.Success(sampleCardDetail()),
listActivitiesResult = CardDetailRepository.Result.Success(emptyList()),
loadCardResult = DataSourceResult.Success(sampleCardDetail()),
listActivitiesResult = DataSourceResult.Success(emptyList()),
updateDescriptionGate = gate,
)
val viewModel = loadedViewModel(this, repository)
@@ -164,8 +164,8 @@ class CardDetailViewModelTest {
@Test
fun descriptionFocusLossAndOnStop_flushLatestDirtyImmediately() = runTest {
val repository = FakeCardDetailDataSource(
loadCardResult = CardDetailRepository.Result.Success(sampleCardDetail()),
listActivitiesResult = CardDetailRepository.Result.Success(emptyList()),
loadCardResult = DataSourceResult.Success(sampleCardDetail()),
listActivitiesResult = DataSourceResult.Success(emptyList()),
)
val viewModel = loadedViewModel(this, repository)
@@ -186,8 +186,8 @@ class CardDetailViewModelTest {
val firstGate = CompletableDeferred<Unit>()
val secondGate = CompletableDeferred<Unit>()
val repository = FakeCardDetailDataSource(
loadCardResult = CardDetailRepository.Result.Success(sampleCardDetail()),
listActivitiesResult = CardDetailRepository.Result.Success(emptyList()),
loadCardResult = DataSourceResult.Success(sampleCardDetail()),
listActivitiesResult = DataSourceResult.Success(emptyList()),
updateDescriptionGates = ArrayDeque(listOf(firstGate, secondGate)),
)
val viewModel = loadedViewModel(this, repository)
@@ -222,14 +222,14 @@ class CardDetailViewModelTest {
@Test
fun addComment_success_closesDialog_showsSnackbar_refreshesActivities() = runTest {
val repository = FakeCardDetailDataSource(
loadCardResult = CardDetailRepository.Result.Success(sampleCardDetail()),
loadCardResult = DataSourceResult.Success(sampleCardDetail()),
listActivitiesResults = ArrayDeque(
listOf(
CardDetailRepository.Result.Success(emptyList()),
CardDetailRepository.Result.Success(sampleActivitiesShuffled()),
DataSourceResult.Success(emptyList()),
DataSourceResult.Success(sampleActivitiesShuffled()),
),
),
addCommentResult = CardDetailRepository.Result.Success(Unit),
addCommentResult = DataSourceResult.Success(Unit),
)
val viewModel = loadedViewModel(this, repository)
val eventDeferred = async { viewModel.events.first { it is CardDetailUiEvent.ShowSnackbar } }
@@ -250,9 +250,9 @@ class CardDetailViewModelTest {
@Test
fun addComment_failure_keepsDialogOpen_andStoresRetryPayload() = runTest {
val repository = FakeCardDetailDataSource(
loadCardResult = CardDetailRepository.Result.Success(sampleCardDetail()),
listActivitiesResult = CardDetailRepository.Result.Success(emptyList()),
addCommentResult = CardDetailRepository.Result.Failure.Generic("Cannot add comment"),
loadCardResult = DataSourceResult.Success(sampleCardDetail()),
listActivitiesResult = DataSourceResult.Success(emptyList()),
addCommentResult = DataSourceResult.GenericError("Cannot add comment"),
)
val viewModel = loadedViewModel(this, repository)
@@ -272,11 +272,11 @@ class CardDetailViewModelTest {
@Test
fun activitiesRetry_recoversFromFailure() = runTest {
val repository = FakeCardDetailDataSource(
loadCardResult = CardDetailRepository.Result.Success(sampleCardDetail()),
loadCardResult = DataSourceResult.Success(sampleCardDetail()),
listActivitiesResults = ArrayDeque(
listOf(
CardDetailRepository.Result.Failure.Generic("Network error"),
CardDetailRepository.Result.Success(sampleActivitiesShuffled()),
DataSourceResult.GenericError("Network error"),
DataSourceResult.Success(sampleActivitiesShuffled()),
),
),
)
@@ -296,8 +296,8 @@ class CardDetailViewModelTest {
val viewModel = loadedViewModel(
this,
FakeCardDetailDataSource(
loadCardResult = CardDetailRepository.Result.Success(sampleCardDetail()),
listActivitiesResult = CardDetailRepository.Result.Success(emptyList()),
loadCardResult = DataSourceResult.Success(sampleCardDetail()),
listActivitiesResult = DataSourceResult.Success(emptyList()),
),
)
@@ -312,11 +312,11 @@ class CardDetailViewModelTest {
@Test
fun fieldSpecificRetry_usesLastAttemptedPayload() = runTest {
val repository = FakeCardDetailDataSource(
loadCardResult = CardDetailRepository.Result.Success(sampleCardDetail()),
listActivitiesResult = CardDetailRepository.Result.Success(emptyList()),
updateTitleResult = CardDetailRepository.Result.Failure.Generic("title failed"),
updateDueDateResult = CardDetailRepository.Result.Failure.Generic("due failed"),
updateDescriptionResult = CardDetailRepository.Result.Failure.Generic("desc failed"),
loadCardResult = DataSourceResult.Success(sampleCardDetail()),
listActivitiesResult = DataSourceResult.Success(emptyList()),
updateTitleResult = DataSourceResult.GenericError("title failed"),
updateDueDateResult = DataSourceResult.GenericError("due failed"),
updateDescriptionResult = DataSourceResult.GenericError("desc failed"),
)
val viewModel = loadedViewModel(this, repository)
@@ -358,13 +358,13 @@ class CardDetailViewModelTest {
}
private class FakeCardDetailDataSource(
var loadCardResult: CardDetailRepository.Result<CardDetail> = CardDetailRepository.Result.Success(sampleCardDetail()),
var listActivitiesResult: CardDetailRepository.Result<List<CardActivity>> = CardDetailRepository.Result.Success(emptyList()),
var listActivitiesResults: ArrayDeque<CardDetailRepository.Result<List<CardActivity>>> = ArrayDeque(),
var updateTitleResult: CardDetailRepository.Result<Unit> = CardDetailRepository.Result.Success(Unit),
var updateDescriptionResult: CardDetailRepository.Result<Unit> = CardDetailRepository.Result.Success(Unit),
var updateDueDateResult: CardDetailRepository.Result<Unit> = CardDetailRepository.Result.Success(Unit),
var addCommentResult: CardDetailRepository.Result<Unit> = CardDetailRepository.Result.Success(Unit),
var loadCardResult: DataSourceResult<CardDetail> = DataSourceResult.Success(sampleCardDetail()),
var listActivitiesResult: DataSourceResult<List<CardActivity>> = DataSourceResult.Success(emptyList()),
var listActivitiesResults: ArrayDeque<DataSourceResult<List<CardActivity>>> = ArrayDeque(),
var updateTitleResult: DataSourceResult<Unit> = DataSourceResult.Success(Unit),
var updateDescriptionResult: DataSourceResult<Unit> = DataSourceResult.Success(Unit),
var updateDueDateResult: DataSourceResult<Unit> = DataSourceResult.Success(Unit),
var addCommentResult: DataSourceResult<Unit> = DataSourceResult.Success(Unit),
var updateDescriptionGate: CompletableDeferred<Unit>? = null,
var updateDescriptionGates: ArrayDeque<CompletableDeferred<Unit>> = ArrayDeque(),
) : CardDetailDataSource {
@@ -380,18 +380,18 @@ class CardDetailViewModelTest {
val updateDueDatePayloads: MutableList<LocalDate?> = mutableListOf()
val addCommentPayloads: MutableList<String> = mutableListOf()
override suspend fun loadCard(cardId: String): CardDetailRepository.Result<CardDetail> {
override suspend fun loadCard(cardId: String): DataSourceResult<CardDetail> {
loadCalls += 1
return loadCardResult
}
override suspend fun updateTitle(cardId: String, title: String): CardDetailRepository.Result<Unit> {
override suspend fun updateTitle(cardId: String, title: String): DataSourceResult<Unit> {
updateTitleCalls += 1
updateTitlePayloads += title
return updateTitleResult
}
override suspend fun updateDescription(cardId: String, description: String): CardDetailRepository.Result<Unit> {
override suspend fun updateDescription(cardId: String, description: String): DataSourceResult<Unit> {
updateDescriptionCalls += 1
updateDescriptionPayloads += description
if (updateDescriptionGates.isNotEmpty()) {
@@ -401,13 +401,13 @@ class CardDetailViewModelTest {
return updateDescriptionResult
}
override suspend fun updateDueDate(cardId: String, dueDate: LocalDate?): CardDetailRepository.Result<Unit> {
override suspend fun updateDueDate(cardId: String, dueDate: LocalDate?): DataSourceResult<Unit> {
updateDueDateCalls += 1
updateDueDatePayloads += dueDate
return updateDueDateResult
}
override suspend fun listActivities(cardId: String): CardDetailRepository.Result<List<CardActivity>> {
override suspend fun listActivities(cardId: String): DataSourceResult<List<CardActivity>> {
listActivitiesCalls += 1
if (listActivitiesResults.isNotEmpty()) {
return listActivitiesResults.removeFirst()
@@ -415,7 +415,7 @@ class CardDetailViewModelTest {
return listActivitiesResult
}
override suspend fun addComment(cardId: String, comment: String): CardDetailRepository.Result<Unit> {
override suspend fun addComment(cardId: String, comment: String): DataSourceResult<Unit> {
addCommentCalls += 1
addCommentPayloads += comment
return addCommentResult
@@ -440,9 +440,9 @@ class CardDetailViewModelTest {
fun sampleActivitiesShuffled(): List<CardActivity> {
return listOf(
CardActivity(id = "a-new", type = "comment", text = "new", createdAtEpochMillis = 3L),
CardActivity(id = "a-mid", type = "comment", text = "mid", createdAtEpochMillis = 2L),
CardActivity(id = "a-old", type = "comment", text = "old", createdAtEpochMillis = 1L),
CardActivity(id = "a-new", type = "comment", text = "new", createdAtEpochMillis = 3L),
)
}
}