diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailViewModel.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailViewModel.kt index f52de9f..d181fab 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailViewModel.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailViewModel.kt @@ -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 { + data class Success(val value: T) : DataSourceResult + data class GenericError(val message: String) : DataSourceResult + data object SessionExpired : DataSourceResult +} + interface CardDetailDataSource { - suspend fun loadCard(cardId: String): CardDetailRepository.Result - suspend fun updateTitle(cardId: String, title: String): CardDetailRepository.Result - suspend fun updateDescription(cardId: String, description: String): CardDetailRepository.Result - suspend fun updateDueDate(cardId: String, dueDate: LocalDate?): CardDetailRepository.Result - suspend fun listActivities(cardId: String): CardDetailRepository.Result> - suspend fun addComment(cardId: String, comment: String): CardDetailRepository.Result + suspend fun loadCard(cardId: String): DataSourceResult + suspend fun updateTitle(cardId: String, title: String): DataSourceResult + suspend fun updateDescription(cardId: String, description: String): DataSourceResult + suspend fun updateDueDate(cardId: String, dueDate: LocalDate?): DataSourceResult + suspend fun listActivities(cardId: String): DataSourceResult> + suspend fun addComment(cardId: String, comment: String): DataSourceResult +} + +internal class CardDetailRepositoryDataSource( + private val repository: CardDetailRepository, + private val loadCardCall: suspend (String) -> CardDetailRepository.Result, +) : CardDetailDataSource { + override suspend fun loadCard(cardId: String): DataSourceResult { + return loadCardCall(cardId).toDataSourceResult() + } + + override suspend fun updateTitle(cardId: String, title: String): DataSourceResult { + return repository.updateTitle(cardId, title).toDataSourceResult() + } + + override suspend fun updateDescription(cardId: String, description: String): DataSourceResult { + return repository.updateDescription(cardId, description).toDataSourceResult() + } + + override suspend fun updateDueDate(cardId: String, dueDate: LocalDate?): DataSourceResult { + return repository.updateDueDate(cardId, dueDate).toDataSourceResult() + } + + override suspend fun listActivities(cardId: String): DataSourceResult> { + return repository.listActivities(cardId).toDataSourceResult() + } + + override suspend fun addComment(cardId: String, comment: String): DataSourceResult { + 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 create(modelClass: Class): T { + if (modelClass.isAssignableFrom(CardDetailViewModel::class.java)) { + return CardDetailViewModel(cardId = cardId, repository = dataSource) as T + } + throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}") + } + } +} + +private fun CardDetailRepository.Result.toDataSourceResult(): DataSourceResult { + 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 + } } diff --git a/app/src/test/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailViewModelTest.kt b/app/src/test/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailViewModelTest.kt index f3ff836..db0e8fb 100644 --- a/app/src/test/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailViewModelTest.kt +++ b/app/src/test/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailViewModelTest.kt @@ -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() 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() val secondGate = CompletableDeferred() 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 = CardDetailRepository.Result.Success(sampleCardDetail()), - var listActivitiesResult: CardDetailRepository.Result> = CardDetailRepository.Result.Success(emptyList()), - var listActivitiesResults: ArrayDeque>> = ArrayDeque(), - var updateTitleResult: CardDetailRepository.Result = CardDetailRepository.Result.Success(Unit), - var updateDescriptionResult: CardDetailRepository.Result = CardDetailRepository.Result.Success(Unit), - var updateDueDateResult: CardDetailRepository.Result = CardDetailRepository.Result.Success(Unit), - var addCommentResult: CardDetailRepository.Result = CardDetailRepository.Result.Success(Unit), + var loadCardResult: DataSourceResult = DataSourceResult.Success(sampleCardDetail()), + var listActivitiesResult: DataSourceResult> = DataSourceResult.Success(emptyList()), + var listActivitiesResults: ArrayDeque>> = ArrayDeque(), + var updateTitleResult: DataSourceResult = DataSourceResult.Success(Unit), + var updateDescriptionResult: DataSourceResult = DataSourceResult.Success(Unit), + var updateDueDateResult: DataSourceResult = DataSourceResult.Success(Unit), + var addCommentResult: DataSourceResult = DataSourceResult.Success(Unit), var updateDescriptionGate: CompletableDeferred? = null, var updateDescriptionGates: ArrayDeque> = ArrayDeque(), ) : CardDetailDataSource { @@ -380,18 +380,18 @@ class CardDetailViewModelTest { val updateDueDatePayloads: MutableList = mutableListOf() val addCommentPayloads: MutableList = mutableListOf() - override suspend fun loadCard(cardId: String): CardDetailRepository.Result { + override suspend fun loadCard(cardId: String): DataSourceResult { loadCalls += 1 return loadCardResult } - override suspend fun updateTitle(cardId: String, title: String): CardDetailRepository.Result { + override suspend fun updateTitle(cardId: String, title: String): DataSourceResult { updateTitleCalls += 1 updateTitlePayloads += title return updateTitleResult } - override suspend fun updateDescription(cardId: String, description: String): CardDetailRepository.Result { + override suspend fun updateDescription(cardId: String, description: String): DataSourceResult { 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 { + override suspend fun updateDueDate(cardId: String, dueDate: LocalDate?): DataSourceResult { updateDueDateCalls += 1 updateDueDatePayloads += dueDate return updateDueDateResult } - override suspend fun listActivities(cardId: String): CardDetailRepository.Result> { + override suspend fun listActivities(cardId: String): DataSourceResult> { 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 { + override suspend fun addComment(cardId: String, comment: String): DataSourceResult { addCommentCalls += 1 addCommentPayloads += comment return addCommentResult @@ -440,9 +440,9 @@ class CardDetailViewModelTest { fun sampleActivitiesShuffled(): List { 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), ) } }