feat: add board detail create and local filter viewmodel state

This commit is contained in:
2026-03-16 14:04:10 -04:00
parent 8022647047
commit 5e0eff37a6
2 changed files with 934 additions and 11 deletions

View File

@@ -3,6 +3,7 @@ package space.hackenslacker.kanbn4droid.app.boarddetail
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import java.time.LocalDate
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
@@ -23,7 +24,33 @@ data class BoardDetailUiState(
val selectedCardIds: Set<String> = emptySet(), val selectedCardIds: Set<String> = emptySet(),
val editingListId: String? = null, val editingListId: String? = null,
val editingListTitle: String = "", val editingListTitle: String = "",
) val pendingTagFilterIds: Set<String> = emptySet(),
val pendingTitleQuery: String = "",
val activeTagFilterIds: Set<String> = emptySet(),
val activeTitleQuery: String = "",
val isFabChooserOpen: Boolean = false,
val isAddListDialogOpen: Boolean = false,
val isAddCardDialogOpen: Boolean = false,
val isFilterDialogOpen: Boolean = false,
val isSearchDialogOpen: Boolean = false,
val addListTitleDraft: String = "",
val addCardTitleDraft: String = "",
val addCardDescriptionDraft: String = "",
val addCardDueDate: LocalDate? = null,
val addCardSelectedTagIds: Set<String> = emptySet(),
) {
val canAddCard: Boolean
get() = boardDetail?.lists?.isNotEmpty() == true
val addCardDisabledMessage: String?
get() = if (canAddCard) null else "Add a list before creating cards."
val filteredBoardDetail: BoardDetail?
get() = boardDetail?.withFilteredCards(
tagFilterIds = activeTagFilterIds,
titleQuery = activeTitleQuery,
)
}
sealed interface BoardDetailUiEvent { sealed interface BoardDetailUiEvent {
data class NavigateToCardPlaceholder(val cardId: String) : BoardDetailUiEvent data class NavigateToCardPlaceholder(val cardId: String) : BoardDetailUiEvent
@@ -33,6 +60,15 @@ sealed interface BoardDetailUiEvent {
interface BoardDetailDataSource { interface BoardDetailDataSource {
suspend fun getBoardDetail(boardId: String): BoardsApiResult<BoardDetail> suspend fun getBoardDetail(boardId: String): BoardsApiResult<BoardDetail>
suspend fun createList(boardPublicId: String, title: String): BoardsApiResult<CreatedEntityRef>
suspend fun createCard(
listPublicId: String,
title: String,
description: String?,
dueDate: LocalDate?,
tagPublicIds: Collection<String>,
): BoardsApiResult<CreatedEntityRef>
suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult
suspend fun deleteCards(cardIds: Collection<String>): CardBatchMutationResult suspend fun deleteCards(cardIds: Collection<String>): CardBatchMutationResult
suspend fun renameList(listId: String, newTitle: String): BoardsApiResult<Unit> suspend fun renameList(listId: String, newTitle: String): BoardsApiResult<Unit>
@@ -45,6 +81,26 @@ internal class BoardDetailRepositoryDataSource(
return repository.getBoardDetail(boardId) return repository.getBoardDetail(boardId)
} }
override suspend fun createList(boardPublicId: String, title: String): BoardsApiResult<CreatedEntityRef> {
return repository.createList(boardPublicId, title)
}
override suspend fun createCard(
listPublicId: String,
title: String,
description: String?,
dueDate: LocalDate?,
tagPublicIds: Collection<String>,
): BoardsApiResult<CreatedEntityRef> {
return repository.createCard(
listPublicId = listPublicId,
title = title,
description = description,
dueDate = dueDate,
tagPublicIds = tagPublicIds,
)
}
override suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult { override suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult {
return repository.moveCards(cardIds, targetListId) return repository.moveCards(cardIds, targetListId)
} }
@@ -129,6 +185,304 @@ class BoardDetailViewModel(
return true return true
} }
fun openFabChooser() {
_uiState.update { it.copy(isFabChooserOpen = true) }
}
fun closeFabChooser() {
_uiState.update { it.copy(isFabChooserOpen = false) }
}
fun openAddListDialog() {
_uiState.update {
it.copy(
isFabChooserOpen = false,
isAddListDialogOpen = true,
)
}
}
fun updateAddListTitle(title: String) {
_uiState.update { it.copy(addListTitleDraft = title) }
}
fun cancelAddListDialog() {
_uiState.update {
it.copy(
isAddListDialogOpen = false,
addListTitleDraft = "",
)
}
}
fun createList() {
val snapshot = _uiState.value
if (snapshot.isMutating) {
return
}
val detail = snapshot.boardDetail ?: return
val title = snapshot.addListTitleDraft.trim()
if (title.isBlank()) {
viewModelScope.launch {
_events.emit(BoardDetailUiEvent.ShowServerError("List title is required"))
}
return
}
val expectedIndex = detail.lists.size
viewModelScope.launch {
_uiState.update { it.copy(isMutating = true) }
when (val result = repository.createList(detail.id, title)) {
is BoardsApiResult.Success -> {
_uiState.update {
it.copy(
isAddListDialogOpen = false,
addListTitleDraft = "",
)
}
when (val reload = reloadDetailAndReconcile()) {
is BoardsApiResult.Success -> {
_uiState.update { it.copy(isMutating = false) }
val warning = verifyCreatedList(result.value.publicId, expectedIndex, reload.value)
if (warning != null) {
_events.emit(BoardDetailUiEvent.ShowWarning(warning))
}
}
is BoardsApiResult.Failure -> {
_uiState.update { it.copy(isMutating = false) }
_events.emit(
BoardDetailUiEvent.ShowWarning(
"Changes applied, but refresh failed. Pull to refresh.",
),
)
}
}
}
is BoardsApiResult.Failure -> {
_uiState.update { it.copy(isMutating = false) }
_events.emit(BoardDetailUiEvent.ShowServerError(result.message))
}
}
}
}
fun openAddCardDialog() {
val snapshot = _uiState.value
if (!snapshot.canAddCard) {
_uiState.update { it.copy(isFabChooserOpen = false, isAddCardDialogOpen = false) }
return
}
_uiState.update {
it.copy(
isFabChooserOpen = false,
isAddCardDialogOpen = true,
)
}
}
fun updateAddCardTitle(title: String) {
_uiState.update { it.copy(addCardTitleDraft = title) }
}
fun updateAddCardDescription(description: String) {
_uiState.update { it.copy(addCardDescriptionDraft = description) }
}
fun setDueDate(dueDate: LocalDate) {
_uiState.update { it.copy(addCardDueDate = dueDate) }
}
fun clearDueDate() {
_uiState.update { it.copy(addCardDueDate = null) }
}
fun toggleAddCardTag(tagId: String) {
val normalizedTagId = tagId.trim()
if (normalizedTagId.isBlank()) {
return
}
_uiState.update {
val next = it.addCardSelectedTagIds.toMutableSet()
if (!next.add(normalizedTagId)) {
next.remove(normalizedTagId)
}
it.copy(addCardSelectedTagIds = next)
}
}
fun cancelAddCardDialog() {
_uiState.update { it.resetAddCardDrafts().copy(isAddCardDialogOpen = false) }
}
fun createCard() {
val snapshot = _uiState.value
if (snapshot.isMutating) {
return
}
val detail = snapshot.boardDetail ?: return
val currentList = detail.lists.getOrNull(snapshot.currentPageIndex) ?: return
val title = snapshot.addCardTitleDraft.trim()
if (title.isBlank()) {
viewModelScope.launch {
_events.emit(BoardDetailUiEvent.ShowServerError("Card title is required"))
}
return
}
viewModelScope.launch {
_uiState.update { it.copy(isMutating = true) }
when (
val result = repository.createCard(
listPublicId = currentList.id,
title = title,
description = snapshot.addCardDescriptionDraft,
dueDate = snapshot.addCardDueDate,
tagPublicIds = snapshot.addCardSelectedTagIds,
)
) {
is BoardsApiResult.Success -> {
_uiState.update {
it.resetAddCardDrafts().copy(isAddCardDialogOpen = false)
}
when (val reload = reloadDetailAndReconcile()) {
is BoardsApiResult.Success -> {
_uiState.update { it.copy(isMutating = false) }
val warning = verifyCreatedCard(
createdCardId = result.value.publicId,
targetListId = currentList.id,
detail = reload.value,
)
if (warning != null) {
_events.emit(BoardDetailUiEvent.ShowWarning(warning))
}
}
is BoardsApiResult.Failure -> {
_uiState.update { it.copy(isMutating = false) }
_events.emit(
BoardDetailUiEvent.ShowWarning(
"Changes applied, but refresh failed. Pull to refresh.",
),
)
}
}
}
is BoardsApiResult.Failure -> {
_uiState.update { it.copy(isMutating = false) }
_events.emit(BoardDetailUiEvent.ShowServerError(result.message))
}
}
}
}
fun openFilterDialog() {
_uiState.update {
it.copy(
isFilterDialogOpen = true,
pendingTagFilterIds = it.activeTagFilterIds,
)
}
}
fun updatePendingTagFilterIds(tagIds: Set<String>) {
_uiState.update {
it.copy(
pendingTagFilterIds = tagIds
.map { id -> id.trim() }
.filter { id -> id.isNotBlank() }
.toSet(),
)
}
}
fun applyFilterDialog() {
_uiState.update {
it.copy(
activeTagFilterIds = it.pendingTagFilterIds,
isFilterDialogOpen = false,
)
}
}
fun cancelFilterDialog() {
_uiState.update {
it.copy(
pendingTagFilterIds = it.activeTagFilterIds,
isFilterDialogOpen = false,
)
}
}
fun onTagFilterIconTapped() {
val snapshot = _uiState.value
if (snapshot.activeTagFilterIds.isNotEmpty()) {
_uiState.update {
it.copy(
activeTagFilterIds = emptySet(),
pendingTagFilterIds = emptySet(),
isFilterDialogOpen = false,
)
}
return
}
openFilterDialog()
}
fun openSearchDialog() {
_uiState.update {
it.copy(
isSearchDialogOpen = true,
pendingTitleQuery = it.activeTitleQuery,
)
}
}
fun updatePendingTitleQuery(query: String) {
_uiState.update { it.copy(pendingTitleQuery = query) }
}
fun applySearchDialog() {
_uiState.update {
val trimmed = it.pendingTitleQuery.trim()
it.copy(
activeTitleQuery = trimmed,
pendingTitleQuery = trimmed,
isSearchDialogOpen = false,
)
}
}
fun cancelSearchDialog() {
_uiState.update {
it.copy(
pendingTitleQuery = it.activeTitleQuery,
isSearchDialogOpen = false,
)
}
}
fun onSearchIconTapped() {
val snapshot = _uiState.value
if (snapshot.activeTitleQuery.isNotBlank()) {
_uiState.update {
it.copy(
activeTitleQuery = "",
pendingTitleQuery = "",
isSearchDialogOpen = false,
)
}
return
}
openSearchDialog()
}
fun moveSelectedCards(targetListId: String) { fun moveSelectedCards(targetListId: String) {
val snapshot = _uiState.value val snapshot = _uiState.value
if (snapshot.isMutating) { if (snapshot.isMutating) {
@@ -191,9 +545,9 @@ class BoardDetailViewModel(
when (val result = repository.renameList(editingListId, trimmedTitle)) { when (val result = repository.renameList(editingListId, trimmedTitle)) {
is BoardsApiResult.Success -> { is BoardsApiResult.Success -> {
_uiState.update { it.copy(editingListId = null, editingListTitle = "") } _uiState.update { it.copy(editingListId = null, editingListTitle = "") }
val reloadFailureMessage = tryReloadDetailAndReconcile() val reloadResult = reloadDetailAndReconcile()
_uiState.update { it.copy(isMutating = false) } _uiState.update { it.copy(isMutating = false) }
if (reloadFailureMessage != null) { if (reloadResult is BoardsApiResult.Failure) {
_events.emit( _events.emit(
BoardDetailUiEvent.ShowWarning( BoardDetailUiEvent.ShowWarning(
"Changes applied, but refresh failed. Pull to refresh.", "Changes applied, but refresh failed. Pull to refresh.",
@@ -276,9 +630,9 @@ class BoardDetailViewModel(
when (val result = mutation(selectedIds)) { when (val result = mutation(selectedIds)) {
is CardBatchMutationResult.Success -> { is CardBatchMutationResult.Success -> {
_uiState.update { it.copy(selectedCardIds = emptySet()) } _uiState.update { it.copy(selectedCardIds = emptySet()) }
val reloadFailureMessage = tryReloadDetailAndReconcile() val reloadResult = reloadDetailAndReconcile()
_uiState.update { it.copy(isMutating = false) } _uiState.update { it.copy(isMutating = false) }
if (reloadFailureMessage != null) { if (reloadResult is BoardsApiResult.Failure) {
_events.emit( _events.emit(
BoardDetailUiEvent.ShowWarning( BoardDetailUiEvent.ShowWarning(
"Changes applied, but refresh failed. Pull to refresh.", "Changes applied, but refresh failed. Pull to refresh.",
@@ -288,8 +642,8 @@ class BoardDetailViewModel(
} }
is CardBatchMutationResult.PartialSuccess -> { is CardBatchMutationResult.PartialSuccess -> {
val reloadFailureMessage = tryReloadDetailAndReconcile() val reloadResult = reloadDetailAndReconcile()
if (reloadFailureMessage == null) { if (reloadResult is BoardsApiResult.Success) {
val visibleIds = allVisibleCardIds(_uiState.value.boardDetail) val visibleIds = allVisibleCardIds(_uiState.value.boardDetail)
_uiState.update { _uiState.update {
it.copy(selectedCardIds = result.failedCardIds.intersect(visibleIds)) it.copy(selectedCardIds = result.failedCardIds.intersect(visibleIds))
@@ -325,14 +679,14 @@ class BoardDetailViewModel(
} }
} }
private suspend fun tryReloadDetailAndReconcile(): String? { private suspend fun reloadDetailAndReconcile(): BoardsApiResult<BoardDetail> {
return when (val result = repository.getBoardDetail(boardId)) { return when (val result = repository.getBoardDetail(boardId)) {
is BoardsApiResult.Success -> { is BoardsApiResult.Success -> {
_uiState.update { reconcileWithNewDetail(it, result.value) } _uiState.update { reconcileWithNewDetail(it, result.value) }
null result
} }
is BoardsApiResult.Failure -> result.message is BoardsApiResult.Failure -> result
} }
} }
@@ -358,6 +712,8 @@ class BoardDetailViewModel(
selectedCardIds = prunedSelection, selectedCardIds = prunedSelection,
editingListId = if (hasEditedList) current.editingListId else null, editingListId = if (hasEditedList) current.editingListId else null,
editingListTitle = if (hasEditedList) current.editingListTitle else "", editingListTitle = if (hasEditedList) current.editingListTitle else "",
pendingTagFilterIds = current.pendingTagFilterIds.intersect(availableTagIds(detail)),
activeTagFilterIds = current.activeTagFilterIds.intersect(availableTagIds(detail)),
) )
} }
@@ -377,6 +733,50 @@ class BoardDetailViewModel(
.toSet() .toSet()
} }
private fun availableTagIds(detail: BoardDetail): Set<String> {
return detail.lists
.asSequence()
.flatMap { list -> list.cards.asSequence() }
.flatMap { card -> card.tags.asSequence() }
.map { tag -> tag.id }
.toSet()
}
private fun verifyCreatedList(createdListId: String?, expectedIndex: Int, detail: BoardDetail): String? {
val normalizedId = createdListId?.trim().orEmpty()
if (normalizedId.isBlank()) {
return CREATE_NON_DETERMINISTIC_WARNING
}
val actualIndex = detail.lists.indexOfFirst { list -> list.id == normalizedId }
if (actualIndex < 0) {
return CREATE_NON_DETERMINISTIC_WARNING
}
return if (actualIndex != expectedIndex) CREATE_LIST_PLACEMENT_WARNING else null
}
private fun verifyCreatedCard(createdCardId: String?, targetListId: String, detail: BoardDetail): String? {
val normalizedId = createdCardId?.trim().orEmpty()
if (normalizedId.isBlank()) {
return CREATE_NON_DETERMINISTIC_WARNING
}
val list = detail.lists.firstOrNull { it.id == targetListId }
?: return CREATE_NON_DETERMINISTIC_WARNING
val actualIndex = list.cards.indexOfFirst { card -> card.id == normalizedId }
if (actualIndex < 0) {
return CREATE_NON_DETERMINISTIC_WARNING
}
return if (actualIndex != 0) CREATE_CARD_PLACEMENT_WARNING else null
}
private companion object {
const val CREATE_LIST_PLACEMENT_WARNING =
"List created, but server ordering differed. Pull to refresh if needed."
const val CREATE_CARD_PLACEMENT_WARNING =
"Card created, but server ordering differed. Pull to refresh if needed."
const val CREATE_NON_DETERMINISTIC_WARNING =
"Created item could not be deterministically verified. Pull to refresh if needed."
}
class Factory( class Factory(
private val boardId: String, private val boardId: String,
private val repository: BoardDetailRepository, private val repository: BoardDetailRepository,
@@ -393,3 +793,39 @@ class BoardDetailViewModel(
} }
} }
} }
private fun BoardDetailUiState.resetAddCardDrafts(): BoardDetailUiState {
return copy(
addCardTitleDraft = "",
addCardDescriptionDraft = "",
addCardDueDate = null,
addCardSelectedTagIds = emptySet(),
)
}
private fun BoardDetail.withFilteredCards(tagFilterIds: Set<String>, titleQuery: String): BoardDetail {
val normalizedTitleQuery = titleQuery.trim()
if (tagFilterIds.isEmpty() && normalizedTitleQuery.isEmpty()) {
return this
}
return copy(
lists = lists.map { list ->
list.copy(
cards = list.cards.filter { card ->
val matchesTags = if (tagFilterIds.isEmpty()) {
true
} else {
card.tags.any { tag -> tag.id in tagFilterIds }
}
val matchesTitle = if (normalizedTitleQuery.isEmpty()) {
true
} else {
card.title.contains(normalizedTitleQuery, ignoreCase = true)
}
matchesTags && matchesTitle
},
)
},
)
}

View File

@@ -10,8 +10,10 @@ import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain import kotlinx.coroutines.test.setMain
import java.time.LocalDate
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
@@ -49,7 +51,7 @@ class BoardDetailViewModelTest {
} }
@Test @Test
fun backPressClearsSelectionWhenSelectionActive() = runTest { fun backPressWithSelection_clearsSelectionAndReturnsTrue() = runTest {
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithTwoLists()) val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithTwoLists())
viewModel.onCardLongPressed("card-1") viewModel.onCardLongPressed("card-1")
@@ -58,6 +60,338 @@ class BoardDetailViewModelTest {
assertTrue(viewModel.uiState.value.selectedCardIds.isEmpty()) assertTrue(viewModel.uiState.value.selectedCardIds.isEmpty())
} }
@Test
fun openAddCardWhenNoLists_setsCanAddCardFalse_andMessage() = runTest {
val viewModel = newLoadedViewModel(
this,
FakeBoardDetailDataSource(),
detailWithoutLists(),
)
viewModel.openAddCardDialog()
assertFalse(viewModel.uiState.value.canAddCard)
assertEquals("Add a list before creating cards.", viewModel.uiState.value.addCardDisabledMessage)
assertFalse(viewModel.uiState.value.isAddCardDialogOpen)
}
@Test
fun applyFilters_tagAnyAndQueryAnd_keepsAllLists_filtersCardsOnly() = runTest {
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithFilterFixture())
viewModel.openFilterDialog()
viewModel.updatePendingTagFilterIds(setOf("tag-b"))
viewModel.applyFilterDialog()
viewModel.openSearchDialog()
viewModel.updatePendingTitleQuery("duke")
viewModel.applySearchDialog()
val filtered = viewModel.uiState.value.filteredBoardDetail
assertEquals(2, filtered?.lists?.size)
assertEquals(listOf("card-2"), filtered?.lists?.get(0)?.cards?.map { it.id })
assertTrue(filtered?.lists?.get(1)?.cards?.isEmpty() == true)
}
@Test
fun tapActiveTagFilterIcon_clearsTagFilter_withoutOpeningDialog() = runTest {
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithFilterFixture())
viewModel.openFilterDialog()
viewModel.updatePendingTagFilterIds(setOf("tag-a"))
viewModel.applyFilterDialog()
viewModel.onTagFilterIconTapped()
assertTrue(viewModel.uiState.value.activeTagFilterIds.isEmpty())
assertTrue(viewModel.uiState.value.pendingTagFilterIds.isEmpty())
assertFalse(viewModel.uiState.value.isFilterDialogOpen)
}
@Test
fun cancelFilterDialog_restoresPendingFromActive() = runTest {
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithFilterFixture())
viewModel.openFilterDialog()
viewModel.updatePendingTagFilterIds(setOf("tag-a"))
viewModel.applyFilterDialog()
viewModel.openFilterDialog()
viewModel.updatePendingTagFilterIds(setOf("tag-b"))
viewModel.cancelFilterDialog()
assertEquals(setOf("tag-a"), viewModel.uiState.value.activeTagFilterIds)
assertEquals(setOf("tag-a"), viewModel.uiState.value.pendingTagFilterIds)
assertFalse(viewModel.uiState.value.isFilterDialogOpen)
}
@Test
fun cancelAndSuccess_resetAddDrafts() = runTest {
val repository = FakeBoardDetailDataSource(
boardDetailResults = ArrayDeque(
listOf(
BoardsApiResult.Success(detailWithSingleList()),
BoardsApiResult.Success(detailWithSingleListAndAppendedList()),
BoardsApiResult.Success(detailWithSingleListAndNewTopCard()),
),
),
createListResult = BoardsApiResult.Success(CreatedEntityRef("list-new")),
createCardResult = BoardsApiResult.Success(CreatedEntityRef("card-new")),
)
val viewModel = newLoadedViewModel(this, repository)
viewModel.openAddListDialog()
viewModel.updateAddListTitle("Temp list")
viewModel.cancelAddListDialog()
assertEquals("", viewModel.uiState.value.addListTitleDraft)
assertFalse(viewModel.uiState.value.isAddListDialogOpen)
viewModel.openAddCardDialog()
viewModel.updateAddCardTitle("Temp card")
viewModel.updateAddCardDescription("temp")
viewModel.toggleAddCardTag("tag-a")
viewModel.setDueDate(LocalDate.of(2026, 1, 2))
viewModel.cancelAddCardDialog()
assertEquals("", viewModel.uiState.value.addCardTitleDraft)
assertEquals("", viewModel.uiState.value.addCardDescriptionDraft)
assertTrue(viewModel.uiState.value.addCardSelectedTagIds.isEmpty())
assertNull(viewModel.uiState.value.addCardDueDate)
assertFalse(viewModel.uiState.value.isAddCardDialogOpen)
viewModel.openAddListDialog()
viewModel.updateAddListTitle("New list")
viewModel.createList()
advanceUntilIdle()
assertEquals("", viewModel.uiState.value.addListTitleDraft)
assertFalse(viewModel.uiState.value.isAddListDialogOpen)
viewModel.openAddCardDialog()
viewModel.updateAddCardTitle("New card")
viewModel.updateAddCardDescription("desc")
viewModel.toggleAddCardTag("tag-a")
viewModel.setDueDate(LocalDate.of(2026, 2, 3))
viewModel.createCard()
advanceUntilIdle()
assertEquals("", viewModel.uiState.value.addCardTitleDraft)
assertEquals("", viewModel.uiState.value.addCardDescriptionDraft)
assertTrue(viewModel.uiState.value.addCardSelectedTagIds.isEmpty())
assertNull(viewModel.uiState.value.addCardDueDate)
assertFalse(viewModel.uiState.value.isAddCardDialogOpen)
}
@Test
fun createFailure_keepsAddDialogsOpenWithDrafts() = runTest {
val repository = FakeBoardDetailDataSource(
boardDetailResults = ArrayDeque(listOf(BoardsApiResult.Success(detailWithSingleList()))),
createListResult = BoardsApiResult.Failure("List create failed"),
createCardResult = BoardsApiResult.Failure("Card create failed"),
)
val viewModel = newLoadedViewModel(this, repository)
viewModel.openAddListDialog()
viewModel.updateAddListTitle("My list")
viewModel.createList()
advanceUntilIdle()
assertTrue(viewModel.uiState.value.isAddListDialogOpen)
assertEquals("My list", viewModel.uiState.value.addListTitleDraft)
viewModel.openAddCardDialog()
viewModel.updateAddCardTitle("My card")
viewModel.updateAddCardDescription("desc")
viewModel.toggleAddCardTag("tag-a")
viewModel.setDueDate(LocalDate.of(2026, 3, 4))
viewModel.createCard()
advanceUntilIdle()
assertTrue(viewModel.uiState.value.isAddCardDialogOpen)
assertEquals("My card", viewModel.uiState.value.addCardTitleDraft)
assertEquals("desc", viewModel.uiState.value.addCardDescriptionDraft)
assertEquals(setOf("tag-a"), viewModel.uiState.value.addCardSelectedTagIds)
assertEquals(LocalDate.of(2026, 3, 4), viewModel.uiState.value.addCardDueDate)
}
@Test
fun createList_blankTitle_rejectsBeforeRepositoryCall() = runTest {
val repository = FakeBoardDetailDataSource(
boardDetailResults = ArrayDeque(listOf(BoardsApiResult.Success(detailWithSingleList()))),
)
val viewModel = newLoadedViewModel(this, repository)
val eventDeferred = async { viewModel.events.first() }
viewModel.openAddListDialog()
viewModel.updateAddListTitle(" ")
viewModel.createList()
advanceUntilIdle()
assertEquals(0, repository.createListCalls)
val event = eventDeferred.await()
assertTrue(event is BoardDetailUiEvent.ShowServerError)
assertEquals("List title is required", (event as BoardDetailUiEvent.ShowServerError).message)
}
@Test
fun createCard_blankTitle_rejectsBeforeRepositoryCall() = runTest {
val repository = FakeBoardDetailDataSource(
boardDetailResults = ArrayDeque(listOf(BoardsApiResult.Success(detailWithSingleList()))),
)
val viewModel = newLoadedViewModel(this, repository)
val eventDeferred = async { viewModel.events.first() }
viewModel.openAddCardDialog()
viewModel.updateAddCardTitle(" ")
viewModel.createCard()
advanceUntilIdle()
assertEquals(0, repository.createCardCalls)
val event = eventDeferred.await()
assertTrue(event is BoardDetailUiEvent.ShowServerError)
assertEquals("Card title is required", (event as BoardDetailUiEvent.ShowServerError).message)
}
@Test
fun setDueDate_updatesCardDraftDueDate() = runTest {
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithSingleList())
viewModel.openAddCardDialog()
viewModel.setDueDate(LocalDate.of(2026, 4, 5))
assertEquals(LocalDate.of(2026, 4, 5), viewModel.uiState.value.addCardDueDate)
}
@Test
fun clearDueDate_resetsCardDraftDueDate() = runTest {
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithSingleList())
viewModel.openAddCardDialog()
viewModel.setDueDate(LocalDate.of(2026, 4, 5))
viewModel.clearDueDate()
assertNull(viewModel.uiState.value.addCardDueDate)
}
@Test
fun createListPlacementMismatch_emitsExactWarning() = runTest {
val repository = FakeBoardDetailDataSource(
boardDetailResults = ArrayDeque(
listOf(
BoardsApiResult.Success(detailWithSingleList()),
BoardsApiResult.Success(detailWithListPlacementMismatch()),
),
),
createListResult = BoardsApiResult.Success(CreatedEntityRef("list-new")),
)
val viewModel = newLoadedViewModel(this, repository)
val eventDeferred = async { viewModel.events.first() }
viewModel.openAddListDialog()
viewModel.updateAddListTitle("New list")
viewModel.createList()
advanceUntilIdle()
val event = eventDeferred.await()
assertTrue(event is BoardDetailUiEvent.ShowWarning)
assertEquals(
"List created, but server ordering differed. Pull to refresh if needed.",
(event as BoardDetailUiEvent.ShowWarning).message,
)
}
@Test
fun createCardPlacementMismatch_emitsExactWarning() = runTest {
val repository = FakeBoardDetailDataSource(
boardDetailResults = ArrayDeque(
listOf(
BoardsApiResult.Success(detailWithSingleList()),
BoardsApiResult.Success(detailWithCardPlacementMismatch()),
),
),
createCardResult = BoardsApiResult.Success(CreatedEntityRef("card-new")),
)
val viewModel = newLoadedViewModel(this, repository)
val eventDeferred = async { viewModel.events.first() }
viewModel.openAddCardDialog()
viewModel.updateAddCardTitle("New card")
viewModel.createCard()
advanceUntilIdle()
val event = eventDeferred.await()
assertTrue(event is BoardDetailUiEvent.ShowWarning)
assertEquals(
"Card created, but server ordering differed. Pull to refresh if needed.",
(event as BoardDetailUiEvent.ShowWarning).message,
)
}
@Test
fun createFallbackNonDeterministic_emitsExactWarning() = runTest {
val repository = FakeBoardDetailDataSource(
boardDetailResults = ArrayDeque(
listOf(
BoardsApiResult.Success(detailWithSingleList()),
BoardsApiResult.Success(detailWithSingleListAndAppendedList()),
),
),
createListResult = BoardsApiResult.Success(CreatedEntityRef(null)),
)
val viewModel = newLoadedViewModel(this, repository)
val eventDeferred = async { viewModel.events.first() }
viewModel.openAddListDialog()
viewModel.updateAddListTitle("New list")
viewModel.createList()
advanceUntilIdle()
val event = eventDeferred.await()
assertTrue(event is BoardDetailUiEvent.ShowWarning)
assertEquals(
"Created item could not be deterministically verified. Pull to refresh if needed.",
(event as BoardDetailUiEvent.ShowWarning).message,
)
}
@Test
fun createSuccess_refreshFailure_emitsExactWarning() = runTest {
val repository = FakeBoardDetailDataSource(
boardDetailResults = ArrayDeque(
listOf(
BoardsApiResult.Success(detailWithSingleList()),
BoardsApiResult.Failure("refresh failed"),
),
),
createListResult = BoardsApiResult.Success(CreatedEntityRef("list-new")),
)
val viewModel = newLoadedViewModel(this, repository)
val eventDeferred = async { viewModel.events.first() }
viewModel.openAddListDialog()
viewModel.updateAddListTitle("New list")
viewModel.createList()
advanceUntilIdle()
val event = eventDeferred.await()
assertTrue(event is BoardDetailUiEvent.ShowWarning)
assertEquals(
"Changes applied, but refresh failed. Pull to refresh.",
(event as BoardDetailUiEvent.ShowWarning).message,
)
}
@Test
fun applySearch_withWhitespaceOnlyQuery_trimsToEmptyAndClearsActiveState() = runTest {
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithFilterFixture())
viewModel.openSearchDialog()
viewModel.updatePendingTitleQuery("Duke")
viewModel.applySearchDialog()
viewModel.openSearchDialog()
viewModel.updatePendingTitleQuery(" ")
viewModel.applySearchDialog()
assertEquals("", viewModel.uiState.value.activeTitleQuery)
assertEquals("", viewModel.uiState.value.pendingTitleQuery)
val filtered = viewModel.uiState.value.filteredBoardDetail
assertEquals(2, filtered?.lists?.size)
assertEquals(2, filtered?.lists?.get(0)?.cards?.size)
assertEquals(1, filtered?.lists?.get(1)?.cards?.size)
}
@Test @Test
fun cardTapWithoutSelectionEmitsNavigateEvent() = runTest { fun cardTapWithoutSelectionEmitsNavigateEvent() = runTest {
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithTwoLists()) val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithTwoLists())
@@ -506,15 +840,26 @@ class BoardDetailViewModelTest {
private class FakeBoardDetailDataSource( private class FakeBoardDetailDataSource(
val boardDetailResults: ArrayDeque<BoardsApiResult<BoardDetail>> = ArrayDeque(), val boardDetailResults: ArrayDeque<BoardsApiResult<BoardDetail>> = ArrayDeque(),
var createListResult: BoardsApiResult<CreatedEntityRef> = BoardsApiResult.Success(CreatedEntityRef("list-created")),
var createCardResult: BoardsApiResult<CreatedEntityRef> = BoardsApiResult.Success(CreatedEntityRef("card-created")),
var moveCardsResult: CardBatchMutationResult = CardBatchMutationResult.Success, var moveCardsResult: CardBatchMutationResult = CardBatchMutationResult.Success,
var deleteCardsResult: CardBatchMutationResult = CardBatchMutationResult.Success, var deleteCardsResult: CardBatchMutationResult = CardBatchMutationResult.Success,
var renameListResult: BoardsApiResult<Unit> = BoardsApiResult.Success(Unit), var renameListResult: BoardsApiResult<Unit> = BoardsApiResult.Success(Unit),
var moveGate: CompletableDeferred<Unit>? = null, var moveGate: CompletableDeferred<Unit>? = null,
var renameGate: CompletableDeferred<Unit>? = null, var renameGate: CompletableDeferred<Unit>? = null,
) : BoardDetailDataSource { ) : BoardDetailDataSource {
var createListCalls: Int = 0
var createCardCalls: Int = 0
var moveCalls: Int = 0 var moveCalls: Int = 0
var deleteCalls: Int = 0 var deleteCalls: Int = 0
var renameCalls: Int = 0 var renameCalls: Int = 0
var lastCreateListBoardId: String? = null
var lastCreateListTitle: String? = null
var lastCreateCardListId: String? = null
var lastCreateCardTitle: String? = null
var lastCreateCardDescription: String? = null
var lastCreateCardDueDate: LocalDate? = null
var lastCreateCardTagIds: List<String> = emptyList()
var lastRenameListId: String? = null var lastRenameListId: String? = null
var lastRenameTitle: String? = null var lastRenameTitle: String? = null
@@ -526,6 +871,29 @@ class BoardDetailViewModelTest {
} }
} }
override suspend fun createList(boardPublicId: String, title: String): BoardsApiResult<CreatedEntityRef> {
createListCalls += 1
lastCreateListBoardId = boardPublicId
lastCreateListTitle = title
return createListResult
}
override suspend fun createCard(
listPublicId: String,
title: String,
description: String?,
dueDate: LocalDate?,
tagPublicIds: Collection<String>,
): BoardsApiResult<CreatedEntityRef> {
createCardCalls += 1
lastCreateCardListId = listPublicId
lastCreateCardTitle = title
lastCreateCardDescription = description
lastCreateCardDueDate = dueDate
lastCreateCardTagIds = tagPublicIds.toList()
return createCardResult
}
override suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult { override suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult {
moveCalls += 1 moveCalls += 1
moveGate?.await() moveGate?.await()
@@ -561,6 +929,14 @@ class BoardDetailViewModelTest {
) )
} }
fun detailWithoutLists(): BoardDetail {
return BoardDetail(
id = "board-1",
title = "Board",
lists = emptyList(),
)
}
fun detailWithTwoLists(): BoardDetail { fun detailWithTwoLists(): BoardDetail {
return BoardDetail( return BoardDetail(
id = "board-1", id = "board-1",
@@ -583,6 +959,117 @@ class BoardDetailViewModelTest {
) )
} }
fun detailWithFilterFixture(): BoardDetail {
return BoardDetail(
id = "board-1",
title = "Board",
lists = listOf(
BoardListDetail(
id = "list-1",
title = "To Do",
cards = listOf(
BoardCardSummary(
id = "card-1",
title = "Alpha",
tags = listOf(BoardTagSummary("tag-a", "A", "#111111")),
dueAtEpochMillis = null,
),
BoardCardSummary(
id = "card-2",
title = "Duke Nukem",
tags = listOf(BoardTagSummary("tag-b", "B", "#222222")),
dueAtEpochMillis = null,
),
),
),
BoardListDetail(
id = "list-2",
title = "Doing",
cards = listOf(
BoardCardSummary(
id = "card-3",
title = "Bravo",
tags = listOf(BoardTagSummary("tag-b", "B", "#222222")),
dueAtEpochMillis = null,
),
),
),
),
)
}
fun detailWithSingleListAndAppendedList(): BoardDetail {
return BoardDetail(
id = "board-1",
title = "Board",
lists = listOf(
BoardListDetail(
id = "list-1",
title = "To Do",
cards = listOf(BoardCardSummary("card-1", "Card 1", emptyList(), null)),
),
BoardListDetail(
id = "list-new",
title = "New list",
cards = emptyList(),
),
),
)
}
fun detailWithSingleListAndNewTopCard(): BoardDetail {
return BoardDetail(
id = "board-1",
title = "Board",
lists = listOf(
BoardListDetail(
id = "list-1",
title = "To Do",
cards = listOf(
BoardCardSummary("card-new", "New card", emptyList(), null),
BoardCardSummary("card-1", "Card 1", emptyList(), null),
),
),
),
)
}
fun detailWithListPlacementMismatch(): BoardDetail {
return BoardDetail(
id = "board-1",
title = "Board",
lists = listOf(
BoardListDetail(
id = "list-new",
title = "New list",
cards = emptyList(),
),
BoardListDetail(
id = "list-1",
title = "To Do",
cards = listOf(BoardCardSummary("card-1", "Card 1", emptyList(), null)),
),
),
)
}
fun detailWithCardPlacementMismatch(): BoardDetail {
return BoardDetail(
id = "board-1",
title = "Board",
lists = listOf(
BoardListDetail(
id = "list-1",
title = "To Do",
cards = listOf(
BoardCardSummary("card-1", "Card 1", emptyList(), null),
BoardCardSummary("card-new", "New card", emptyList(), null),
),
),
),
)
}
fun detailWithThreeLists(): BoardDetail { fun detailWithThreeLists(): BoardDetail {
return BoardDetail( return BoardDetail(
id = "board-1", id = "board-1",