feat: add board detail batch move and delete workflows
This commit is contained in:
@@ -17,11 +17,13 @@ import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
|
|||||||
import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtra
|
import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtra
|
||||||
import androidx.test.espresso.matcher.RootMatchers.isDialog
|
import androidx.test.espresso.matcher.RootMatchers.isDialog
|
||||||
import androidx.test.espresso.matcher.ViewMatchers.withContentDescription
|
import androidx.test.espresso.matcher.ViewMatchers.withContentDescription
|
||||||
|
import androidx.test.espresso.matcher.ViewMatchers.hasDescendant
|
||||||
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
|
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
|
||||||
import androidx.test.espresso.matcher.ViewMatchers.isEnabled
|
import androidx.test.espresso.matcher.ViewMatchers.isEnabled
|
||||||
import androidx.test.espresso.matcher.ViewMatchers.withId
|
import androidx.test.espresso.matcher.ViewMatchers.withId
|
||||||
import androidx.test.espresso.matcher.ViewMatchers.withText
|
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.viewpager2.widget.ViewPager2
|
||||||
import com.google.android.material.color.MaterialColors
|
import com.google.android.material.color.MaterialColors
|
||||||
import java.text.DateFormat
|
import java.text.DateFormat
|
||||||
import java.util.ArrayDeque
|
import java.util.ArrayDeque
|
||||||
@@ -53,6 +55,7 @@ class BoardDetailFlowTest {
|
|||||||
|
|
||||||
private lateinit var defaultDataSource: FakeBoardDetailDataSource
|
private lateinit var defaultDataSource: FakeBoardDetailDataSource
|
||||||
private var originalLocale: Locale? = null
|
private var originalLocale: Locale? = null
|
||||||
|
private val observedStates = mutableListOf<space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailUiState>()
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
@@ -60,12 +63,14 @@ class BoardDetailFlowTest {
|
|||||||
Intents.init()
|
Intents.init()
|
||||||
defaultDataSource = FakeBoardDetailDataSource(initialDetail = detailOneList())
|
defaultDataSource = FakeBoardDetailDataSource(initialDetail = detailOneList())
|
||||||
BoardDetailActivity.testDataSourceFactory = { defaultDataSource }
|
BoardDetailActivity.testDataSourceFactory = { defaultDataSource }
|
||||||
|
BoardDetailActivity.testUiStateObserver = { state -> observedStates += state }
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
fun tearDown() {
|
fun tearDown() {
|
||||||
Intents.release()
|
Intents.release()
|
||||||
BoardDetailActivity.testDataSourceFactory = null
|
BoardDetailActivity.testDataSourceFactory = null
|
||||||
|
BoardDetailActivity.testUiStateObserver = null
|
||||||
originalLocale?.let { Locale.setDefault(it) }
|
originalLocale?.let { Locale.setDefault(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,6 +256,205 @@ class BoardDetailFlowTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun moveDialogShowsListSelector() {
|
||||||
|
defaultDataSource.currentDetail = detailTwoLists()
|
||||||
|
launchBoardDetail()
|
||||||
|
|
||||||
|
onView(withText("Card 1")).perform(longClick())
|
||||||
|
onView(withContentDescription("Move cards")).perform(click())
|
||||||
|
|
||||||
|
onView(withText(R.string.move_cards_to_list)).inRoot(isDialog()).check(matches(isDisplayed()))
|
||||||
|
onView(withText("To Do")).inRoot(isDialog()).check(matches(isDisplayed()))
|
||||||
|
onView(withText("Doing")).inRoot(isDialog()).check(matches(isDisplayed()))
|
||||||
|
onView(withText(R.string.cancel)).inRoot(isDialog()).check(matches(isDisplayed()))
|
||||||
|
onView(withText(R.string.move_cards)).inRoot(isDialog()).check(matches(isDisplayed()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun moveDisablesWhenTargetAlreadyContainsAllSelected() {
|
||||||
|
defaultDataSource.currentDetail = detailTwoLists()
|
||||||
|
launchBoardDetail()
|
||||||
|
|
||||||
|
onView(withText("Card 1")).perform(longClick())
|
||||||
|
onView(withContentDescription("Move cards")).perform(click())
|
||||||
|
|
||||||
|
onView(withId(android.R.id.button1)).inRoot(isDialog()).check(matches(not(isEnabled())))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun moveExcludesNoOpCardsFromPayload() {
|
||||||
|
defaultDataSource.currentDetail = detailTwoListsWithCardsOnBothPages()
|
||||||
|
val scenario = launchBoardDetail()
|
||||||
|
|
||||||
|
onView(withText("Card 1")).perform(longClick())
|
||||||
|
scenario.onActivity { activity ->
|
||||||
|
activity.findViewById<ViewPager2>(R.id.boardDetailPager).setCurrentItem(1, false)
|
||||||
|
}
|
||||||
|
onView(withText("Card 2")).perform(click())
|
||||||
|
onView(withContentDescription("Move cards")).perform(click())
|
||||||
|
onView(withText("Doing")).inRoot(isDialog()).perform(click())
|
||||||
|
onView(withId(android.R.id.button1)).inRoot(isDialog()).perform(click())
|
||||||
|
|
||||||
|
awaitCondition {
|
||||||
|
defaultDataSource.moveCalls == 1 && observedStates.lastOrNull()?.isMutating == false
|
||||||
|
}
|
||||||
|
|
||||||
|
scenario.onActivity {
|
||||||
|
assertEquals(1, defaultDataSource.moveCalls)
|
||||||
|
assertEquals("list-2", defaultDataSource.lastMoveTargetListId)
|
||||||
|
assertEquals(setOf("card-1"), defaultDataSource.lastMoveCardIds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun moveConfirmDisabledWhileMutating() {
|
||||||
|
defaultDataSource.currentDetail = detailTwoLists()
|
||||||
|
defaultDataSource.moveGate = CompletableDeferred()
|
||||||
|
launchBoardDetail()
|
||||||
|
|
||||||
|
onView(withText("Card 1")).perform(longClick())
|
||||||
|
onView(withContentDescription("Move cards")).perform(click())
|
||||||
|
onView(withText("Doing")).inRoot(isDialog()).perform(click())
|
||||||
|
onView(withId(android.R.id.button1)).inRoot(isDialog()).perform(click())
|
||||||
|
|
||||||
|
onView(withId(android.R.id.button1)).inRoot(isDialog()).check(matches(not(isEnabled())))
|
||||||
|
defaultDataSource.moveGate?.complete(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun movePartialSuccessShowsWarningAndReselectsFailedCards() {
|
||||||
|
defaultDataSource.currentDetail = detailSingleListTwoCards()
|
||||||
|
defaultDataSource.moveResult = CardBatchMutationResult.PartialSuccess(
|
||||||
|
failedCardIds = setOf("card-2"),
|
||||||
|
message = "Some cards could not be moved. Please try again.",
|
||||||
|
)
|
||||||
|
launchBoardDetail()
|
||||||
|
|
||||||
|
onView(withText("Card 1")).perform(longClick())
|
||||||
|
onView(withText("Card 2")).perform(click())
|
||||||
|
onView(withContentDescription("Move cards")).perform(click())
|
||||||
|
onView(withText("Doing")).inRoot(isDialog()).perform(click())
|
||||||
|
onView(withId(android.R.id.button1)).inRoot(isDialog()).perform(click())
|
||||||
|
|
||||||
|
awaitCondition {
|
||||||
|
defaultDataSource.moveCalls == 1 && observedStates.lastOrNull()?.isMutating == false
|
||||||
|
}
|
||||||
|
|
||||||
|
val last = observedStates.last()
|
||||||
|
assertEquals(setOf("card-2"), last.selectedCardIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun moveFullFailurePreservesSelectionAndShowsError() {
|
||||||
|
defaultDataSource.currentDetail = detailTwoLists()
|
||||||
|
defaultDataSource.moveResult = CardBatchMutationResult.Failure("Move failed")
|
||||||
|
launchBoardDetail()
|
||||||
|
|
||||||
|
onView(withText("Card 1")).perform(longClick())
|
||||||
|
onView(withContentDescription("Move cards")).perform(click())
|
||||||
|
onView(withText("Doing")).inRoot(isDialog()).perform(click())
|
||||||
|
onView(withId(android.R.id.button1)).inRoot(isDialog()).perform(click())
|
||||||
|
|
||||||
|
awaitCondition {
|
||||||
|
defaultDataSource.moveCalls == 1 && observedStates.lastOrNull()?.isMutating == false
|
||||||
|
}
|
||||||
|
|
||||||
|
val last = observedStates.last()
|
||||||
|
assertEquals(setOf("card-1"), last.selectedCardIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deleteRequiresSecondConfirmation() {
|
||||||
|
val scenario = launchBoardDetail()
|
||||||
|
|
||||||
|
onView(withText("Card 1")).perform(longClick())
|
||||||
|
onView(withContentDescription("Delete cards")).perform(click())
|
||||||
|
onView(withText(R.string.delete)).inRoot(isDialog()).perform(click())
|
||||||
|
onView(withText(R.string.delete_cards_second_confirmation)).inRoot(isDialog()).check(matches(isDisplayed()))
|
||||||
|
onView(withText(R.string.im_sure)).inRoot(isDialog()).perform(click())
|
||||||
|
|
||||||
|
scenario.onActivity {
|
||||||
|
assertEquals(1, defaultDataSource.deleteCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deleteConfirmDisabledWhileMutating() {
|
||||||
|
defaultDataSource.deleteGate = CompletableDeferred()
|
||||||
|
launchBoardDetail()
|
||||||
|
|
||||||
|
onView(withText("Card 1")).perform(longClick())
|
||||||
|
onView(withContentDescription("Delete cards")).perform(click())
|
||||||
|
onView(withText(R.string.delete)).inRoot(isDialog()).perform(click())
|
||||||
|
onView(withText(R.string.im_sure)).inRoot(isDialog()).perform(click())
|
||||||
|
|
||||||
|
onView(withId(android.R.id.button1)).inRoot(isDialog()).check(matches(not(isEnabled())))
|
||||||
|
defaultDataSource.deleteGate?.complete(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deletePartialSuccessShowsWarningAndReselectsFailedCards() {
|
||||||
|
defaultDataSource.currentDetail = detailSingleListTwoCards()
|
||||||
|
defaultDataSource.deleteResult = CardBatchMutationResult.PartialSuccess(
|
||||||
|
failedCardIds = setOf("card-2"),
|
||||||
|
message = "Some cards could not be deleted. Please try again.",
|
||||||
|
)
|
||||||
|
launchBoardDetail()
|
||||||
|
|
||||||
|
onView(withText("Card 1")).perform(longClick())
|
||||||
|
onView(withText("Card 2")).perform(click())
|
||||||
|
onView(withContentDescription("Delete cards")).perform(click())
|
||||||
|
onView(withText(R.string.delete)).inRoot(isDialog()).perform(click())
|
||||||
|
onView(withText(R.string.im_sure)).inRoot(isDialog()).perform(click())
|
||||||
|
|
||||||
|
awaitCondition {
|
||||||
|
defaultDataSource.deleteCalls == 1 && observedStates.lastOrNull()?.isMutating == false
|
||||||
|
}
|
||||||
|
|
||||||
|
val last = observedStates.last()
|
||||||
|
assertEquals(setOf("card-2"), last.selectedCardIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deleteFullFailurePreservesSelectionAndShowsError() {
|
||||||
|
defaultDataSource.deleteResult = CardBatchMutationResult.Failure("Delete failed")
|
||||||
|
launchBoardDetail()
|
||||||
|
|
||||||
|
onView(withText("Card 1")).perform(longClick())
|
||||||
|
onView(withContentDescription("Delete cards")).perform(click())
|
||||||
|
onView(withText(R.string.delete)).inRoot(isDialog()).perform(click())
|
||||||
|
onView(withText(R.string.im_sure)).inRoot(isDialog()).perform(click())
|
||||||
|
|
||||||
|
awaitCondition {
|
||||||
|
defaultDataSource.deleteCalls == 1 && observedStates.lastOrNull()?.isMutating == false
|
||||||
|
}
|
||||||
|
|
||||||
|
val last = observedStates.last()
|
||||||
|
assertEquals(setOf("card-1"), last.selectedCardIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun selectionPersistsAcrossPagesAndSelectAllIsPageScoped() {
|
||||||
|
defaultDataSource.currentDetail = detailTwoListsTwoCardsEach()
|
||||||
|
val scenario = launchBoardDetail()
|
||||||
|
|
||||||
|
onView(withText("Todo A")).perform(longClick())
|
||||||
|
scenario.onActivity { activity ->
|
||||||
|
activity.findViewById<ViewPager2>(R.id.boardDetailPager).setCurrentItem(1, false)
|
||||||
|
}
|
||||||
|
onView(withContentDescription("Select all")).perform(click())
|
||||||
|
onView(withContentDescription("Move cards")).perform(click())
|
||||||
|
onView(withText("To Do")).inRoot(isDialog()).perform(click())
|
||||||
|
onView(withId(android.R.id.button1)).inRoot(isDialog()).perform(click())
|
||||||
|
|
||||||
|
scenario.onActivity {
|
||||||
|
assertEquals(1, defaultDataSource.moveCalls)
|
||||||
|
assertEquals("list-1", defaultDataSource.lastMoveTargetListId)
|
||||||
|
assertEquals(setOf("doing-a", "doing-b"), defaultDataSource.lastMoveCardIds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun cardTapNavigatesToCardPlaceholderWithExtras() {
|
fun cardTapNavigatesToCardPlaceholderWithExtras() {
|
||||||
launchBoardDetail()
|
launchBoardDetail()
|
||||||
@@ -300,6 +504,19 @@ class BoardDetailFlowTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun awaitCondition(timeoutMs: Long = 4_000L, condition: () -> Boolean) {
|
||||||
|
val instrumentation = androidx.test.platform.app.InstrumentationRegistry.getInstrumentation()
|
||||||
|
val start = System.currentTimeMillis()
|
||||||
|
while (System.currentTimeMillis() - start < timeoutMs) {
|
||||||
|
instrumentation.waitForIdleSync()
|
||||||
|
if (condition()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Thread.sleep(50)
|
||||||
|
}
|
||||||
|
throw AssertionError("Condition not met within ${timeoutMs}ms")
|
||||||
|
}
|
||||||
|
|
||||||
private class FakeBoardDetailDataSource(
|
private class FakeBoardDetailDataSource(
|
||||||
initialDetail: BoardDetail,
|
initialDetail: BoardDetail,
|
||||||
) : BoardDetailDataSource {
|
) : BoardDetailDataSource {
|
||||||
@@ -310,6 +527,15 @@ class BoardDetailFlowTest {
|
|||||||
var renameGate: CompletableDeferred<Unit>? = null
|
var renameGate: CompletableDeferred<Unit>? = null
|
||||||
var renameCalls: Int = 0
|
var renameCalls: Int = 0
|
||||||
var lastRenameTitle: String? = null
|
var lastRenameTitle: String? = null
|
||||||
|
var moveResult: CardBatchMutationResult = CardBatchMutationResult.Success
|
||||||
|
var deleteResult: CardBatchMutationResult = CardBatchMutationResult.Success
|
||||||
|
var moveGate: CompletableDeferred<Unit>? = null
|
||||||
|
var deleteGate: CompletableDeferred<Unit>? = null
|
||||||
|
var moveCalls: Int = 0
|
||||||
|
var deleteCalls: Int = 0
|
||||||
|
var lastMoveCardIds: Set<String> = emptySet()
|
||||||
|
var lastDeleteCardIds: Set<String> = emptySet()
|
||||||
|
var lastMoveTargetListId: String? = null
|
||||||
|
|
||||||
override suspend fun getBoardDetail(boardId: String): BoardsApiResult<BoardDetail> {
|
override suspend fun getBoardDetail(boardId: String): BoardsApiResult<BoardDetail> {
|
||||||
loadGate?.await()
|
loadGate?.await()
|
||||||
@@ -321,11 +547,52 @@ class BoardDetailFlowTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult {
|
override suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult {
|
||||||
return CardBatchMutationResult.Success
|
moveCalls += 1
|
||||||
|
lastMoveCardIds = cardIds.toSet()
|
||||||
|
lastMoveTargetListId = targetListId
|
||||||
|
moveGate?.await()
|
||||||
|
|
||||||
|
val result = moveResult
|
||||||
|
if (result is CardBatchMutationResult.Success || result is CardBatchMutationResult.PartialSuccess) {
|
||||||
|
val failedIds = if (result is CardBatchMutationResult.PartialSuccess) result.failedCardIds else emptySet()
|
||||||
|
val movedIds = cardIds.filterNot { failedIds.contains(it) }.toSet()
|
||||||
|
if (movedIds.isNotEmpty()) {
|
||||||
|
val targetList = currentDetail.lists.firstOrNull { it.id == targetListId }
|
||||||
|
if (targetList != null) {
|
||||||
|
val movedCards = currentDetail.lists.flatMap { it.cards }.filter { movedIds.contains(it.id) }
|
||||||
|
currentDetail = currentDetail.copy(
|
||||||
|
lists = currentDetail.lists.map { list ->
|
||||||
|
if (list.id == targetList.id) {
|
||||||
|
list.copy(cards = list.cards + movedCards.filter { moved -> list.cards.none { it.id == moved.id } })
|
||||||
|
} else {
|
||||||
|
list.copy(cards = list.cards.filterNot { movedIds.contains(it.id) })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteCards(cardIds: Collection<String>): CardBatchMutationResult {
|
override suspend fun deleteCards(cardIds: Collection<String>): CardBatchMutationResult {
|
||||||
return CardBatchMutationResult.Success
|
deleteCalls += 1
|
||||||
|
lastDeleteCardIds = cardIds.toSet()
|
||||||
|
deleteGate?.await()
|
||||||
|
|
||||||
|
val result = deleteResult
|
||||||
|
if (result is CardBatchMutationResult.Success || result is CardBatchMutationResult.PartialSuccess) {
|
||||||
|
val failedIds = if (result is CardBatchMutationResult.PartialSuccess) result.failedCardIds else emptySet()
|
||||||
|
val deletedIds = cardIds.filterNot { failedIds.contains(it) }.toSet()
|
||||||
|
if (deletedIds.isNotEmpty()) {
|
||||||
|
currentDetail = currentDetail.copy(
|
||||||
|
lists = currentDetail.lists.map { list ->
|
||||||
|
list.copy(cards = list.cards.filterNot { deletedIds.contains(it.id) })
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun renameList(listId: String, newTitle: String): BoardsApiResult<Unit> {
|
override suspend fun renameList(listId: String, newTitle: String): BoardsApiResult<Unit> {
|
||||||
@@ -403,5 +670,141 @@ class BoardDetailFlowTest {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun detailTwoLists(): BoardDetail {
|
||||||
|
return BoardDetail(
|
||||||
|
id = "board-1",
|
||||||
|
title = "Board",
|
||||||
|
lists = listOf(
|
||||||
|
BoardListDetail(
|
||||||
|
id = "list-1",
|
||||||
|
title = "To Do",
|
||||||
|
cards = listOf(
|
||||||
|
BoardCardSummary(
|
||||||
|
id = "card-1",
|
||||||
|
title = "Card 1",
|
||||||
|
tags = emptyList(),
|
||||||
|
dueAtEpochMillis = null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BoardListDetail(
|
||||||
|
id = "list-2",
|
||||||
|
title = "Doing",
|
||||||
|
cards = emptyList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun detailTwoListsWithCardsOnBothPages(): BoardDetail {
|
||||||
|
return BoardDetail(
|
||||||
|
id = "board-1",
|
||||||
|
title = "Board",
|
||||||
|
lists = listOf(
|
||||||
|
BoardListDetail(
|
||||||
|
id = "list-1",
|
||||||
|
title = "To Do",
|
||||||
|
cards = listOf(
|
||||||
|
BoardCardSummary(
|
||||||
|
id = "card-1",
|
||||||
|
title = "Card 1",
|
||||||
|
tags = emptyList(),
|
||||||
|
dueAtEpochMillis = null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BoardListDetail(
|
||||||
|
id = "list-2",
|
||||||
|
title = "Doing",
|
||||||
|
cards = listOf(
|
||||||
|
BoardCardSummary(
|
||||||
|
id = "card-2",
|
||||||
|
title = "Card 2",
|
||||||
|
tags = emptyList(),
|
||||||
|
dueAtEpochMillis = null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun detailSingleListTwoCards(): BoardDetail {
|
||||||
|
return BoardDetail(
|
||||||
|
id = "board-1",
|
||||||
|
title = "Board",
|
||||||
|
lists = listOf(
|
||||||
|
BoardListDetail(
|
||||||
|
id = "list-1",
|
||||||
|
title = "To Do",
|
||||||
|
cards = listOf(
|
||||||
|
BoardCardSummary(
|
||||||
|
id = "card-1",
|
||||||
|
title = "Card 1",
|
||||||
|
tags = emptyList(),
|
||||||
|
dueAtEpochMillis = null,
|
||||||
|
),
|
||||||
|
BoardCardSummary(
|
||||||
|
id = "card-2",
|
||||||
|
title = "Card 2",
|
||||||
|
tags = emptyList(),
|
||||||
|
dueAtEpochMillis = null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BoardListDetail(
|
||||||
|
id = "list-2",
|
||||||
|
title = "Doing",
|
||||||
|
cards = emptyList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun detailTwoListsTwoCardsEach(): BoardDetail {
|
||||||
|
return BoardDetail(
|
||||||
|
id = "board-1",
|
||||||
|
title = "Board",
|
||||||
|
lists = listOf(
|
||||||
|
BoardListDetail(
|
||||||
|
id = "list-1",
|
||||||
|
title = "To Do",
|
||||||
|
cards = listOf(
|
||||||
|
BoardCardSummary(
|
||||||
|
id = "todo-a",
|
||||||
|
title = "Todo A",
|
||||||
|
tags = emptyList(),
|
||||||
|
dueAtEpochMillis = null,
|
||||||
|
),
|
||||||
|
BoardCardSummary(
|
||||||
|
id = "todo-b",
|
||||||
|
title = "Todo B",
|
||||||
|
tags = emptyList(),
|
||||||
|
dueAtEpochMillis = null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BoardListDetail(
|
||||||
|
id = "list-2",
|
||||||
|
title = "Doing",
|
||||||
|
cards = listOf(
|
||||||
|
BoardCardSummary(
|
||||||
|
id = "doing-a",
|
||||||
|
title = "Doing A",
|
||||||
|
tags = emptyList(),
|
||||||
|
dueAtEpochMillis = null,
|
||||||
|
),
|
||||||
|
BoardCardSummary(
|
||||||
|
id = "doing-b",
|
||||||
|
title = "Doing B",
|
||||||
|
tags = emptyList(),
|
||||||
|
dueAtEpochMillis = null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import android.widget.Button
|
|||||||
import android.widget.ProgressBar
|
import android.widget.ProgressBar
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
@@ -43,6 +44,10 @@ class BoardDetailActivity : AppCompatActivity() {
|
|||||||
private lateinit var retryButton: Button
|
private lateinit var retryButton: Button
|
||||||
|
|
||||||
private var inlineTitleErrorMessage: String? = null
|
private var inlineTitleErrorMessage: String? = null
|
||||||
|
private var moveDialog: AlertDialog? = null
|
||||||
|
private var deleteSecondConfirmationDialog: AlertDialog? = null
|
||||||
|
private var dismissMoveDialogWhenMutationEnds: Boolean = false
|
||||||
|
private var dismissDeleteDialogWhenMutationEnds: Boolean = false
|
||||||
|
|
||||||
private lateinit var pagerAdapter: BoardListsPagerAdapter
|
private lateinit var pagerAdapter: BoardListsPagerAdapter
|
||||||
|
|
||||||
@@ -197,6 +202,7 @@ class BoardDetailActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun render(state: BoardDetailUiState) {
|
private fun render(state: BoardDetailUiState) {
|
||||||
|
testUiStateObserver?.invoke(state)
|
||||||
supportActionBar?.title = state.boardDetail?.title ?: intent.getStringExtra(EXTRA_BOARD_TITLE).orEmpty()
|
supportActionBar?.title = state.boardDetail?.title ?: intent.getStringExtra(EXTRA_BOARD_TITLE).orEmpty()
|
||||||
|
|
||||||
fullScreenErrorContainer.visibility = if (state.fullScreenErrorMessage != null && state.boardDetail == null) {
|
fullScreenErrorContainer.visibility = if (state.fullScreenErrorMessage != null && state.boardDetail == null) {
|
||||||
@@ -239,6 +245,54 @@ class BoardDetailActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderSelectionActions(state)
|
renderSelectionActions(state)
|
||||||
|
renderOpenDialogs(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun renderOpenDialogs(state: BoardDetailUiState) {
|
||||||
|
val activeMoveDialog = moveDialog
|
||||||
|
if (activeMoveDialog != null) {
|
||||||
|
val lists = state.boardDetail?.lists.orEmpty()
|
||||||
|
if (lists.isEmpty()) {
|
||||||
|
activeMoveDialog.dismiss()
|
||||||
|
moveDialog = null
|
||||||
|
} else {
|
||||||
|
val selectedIndex = activeMoveDialog.listView?.checkedItemPosition
|
||||||
|
?.takeIf { it in lists.indices }
|
||||||
|
?: state.currentPageIndex.coerceIn(0, lists.lastIndex)
|
||||||
|
val targetList = lists[selectedIndex]
|
||||||
|
val targetIds = targetList.cards.map { it.id }.toSet()
|
||||||
|
val canMove = !state.isMutating && (state.selectedCardIds - targetIds).isNotEmpty()
|
||||||
|
|
||||||
|
activeMoveDialog.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = canMove
|
||||||
|
activeMoveDialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.isEnabled = !state.isMutating
|
||||||
|
|
||||||
|
if (dismissMoveDialogWhenMutationEnds && !state.isMutating) {
|
||||||
|
activeMoveDialog.dismiss()
|
||||||
|
moveDialog = null
|
||||||
|
dismissMoveDialogWhenMutationEnds = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.isMutating && state.selectedCardIds.isEmpty()) {
|
||||||
|
activeMoveDialog.dismiss()
|
||||||
|
moveDialog = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val activeDeleteDialog = deleteSecondConfirmationDialog
|
||||||
|
if (activeDeleteDialog != null) {
|
||||||
|
activeDeleteDialog.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = !state.isMutating
|
||||||
|
activeDeleteDialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.isEnabled = !state.isMutating
|
||||||
|
if (dismissDeleteDialogWhenMutationEnds && !state.isMutating) {
|
||||||
|
activeDeleteDialog.dismiss()
|
||||||
|
deleteSecondConfirmationDialog = null
|
||||||
|
dismissDeleteDialogWhenMutationEnds = false
|
||||||
|
}
|
||||||
|
if (!state.isMutating && state.selectedCardIds.isEmpty()) {
|
||||||
|
activeDeleteDialog.dismiss()
|
||||||
|
deleteSecondConfirmationDialog = null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun renderSelectionActions(state: BoardDetailUiState) {
|
private fun renderSelectionActions(state: BoardDetailUiState) {
|
||||||
@@ -282,31 +336,62 @@ class BoardDetailActivity : AppCompatActivity() {
|
|||||||
if (lists.isEmpty()) {
|
if (lists.isEmpty()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val listNames = lists.map { it.title }.toTypedArray()
|
val listNames = lists.map { it.title }.toTypedArray()
|
||||||
var selectedIndex = viewModel.uiState.value.currentPageIndex.coerceIn(0, lists.lastIndex)
|
var selectedIndex = viewModel.uiState.value.currentPageIndex.coerceIn(0, lists.lastIndex)
|
||||||
MaterialAlertDialogBuilder(this)
|
val dialog = MaterialAlertDialogBuilder(this)
|
||||||
.setTitle(R.string.move_cards_to_list)
|
.setTitle(R.string.move_cards_to_list)
|
||||||
.setSingleChoiceItems(listNames, selectedIndex) { _, which -> selectedIndex = which }
|
.setSingleChoiceItems(listNames, selectedIndex) { _, which ->
|
||||||
|
selectedIndex = which
|
||||||
|
renderOpenDialogs(viewModel.uiState.value)
|
||||||
|
}
|
||||||
.setNegativeButton(R.string.cancel, null)
|
.setNegativeButton(R.string.cancel, null)
|
||||||
.setPositiveButton(R.string.move_cards) { _, _ ->
|
.setPositiveButton(R.string.move_cards, null)
|
||||||
|
.create()
|
||||||
|
dialog.setOnShowListener {
|
||||||
|
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener {
|
||||||
val targetId = lists[selectedIndex].id
|
val targetId = lists[selectedIndex].id
|
||||||
|
dismissMoveDialogWhenMutationEnds = true
|
||||||
viewModel.moveSelectedCards(targetId)
|
viewModel.moveSelectedCards(targetId)
|
||||||
}
|
}
|
||||||
.show()
|
renderOpenDialogs(viewModel.uiState.value)
|
||||||
|
}
|
||||||
|
dialog.setOnDismissListener {
|
||||||
|
if (moveDialog === dialog) {
|
||||||
|
moveDialog = null
|
||||||
|
}
|
||||||
|
dismissMoveDialogWhenMutationEnds = false
|
||||||
|
}
|
||||||
|
moveDialog = dialog
|
||||||
|
dialog.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showDeleteCardsDialog() {
|
private fun showDeleteCardsDialog() {
|
||||||
MaterialAlertDialogBuilder(this)
|
MaterialAlertDialogBuilder(this)
|
||||||
|
.setTitle(R.string.delete_cards_title)
|
||||||
.setMessage(R.string.delete_cards_confirmation)
|
.setMessage(R.string.delete_cards_confirmation)
|
||||||
.setNegativeButton(R.string.cancel, null)
|
.setNegativeButton(R.string.cancel, null)
|
||||||
.setPositiveButton(R.string.delete) { _, _ ->
|
.setPositiveButton(R.string.delete) { _, _ ->
|
||||||
MaterialAlertDialogBuilder(this)
|
val secondDialog = MaterialAlertDialogBuilder(this)
|
||||||
.setMessage(R.string.delete_cards_second_confirmation)
|
.setMessage(R.string.delete_cards_second_confirmation)
|
||||||
.setNegativeButton(R.string.cancel, null)
|
.setNegativeButton(R.string.cancel, null)
|
||||||
.setPositiveButton(R.string.im_sure) { _, _ ->
|
.setPositiveButton(R.string.im_sure, null)
|
||||||
|
.create()
|
||||||
|
secondDialog.setOnShowListener {
|
||||||
|
secondDialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener {
|
||||||
|
dismissDeleteDialogWhenMutationEnds = true
|
||||||
viewModel.deleteSelectedCards()
|
viewModel.deleteSelectedCards()
|
||||||
}
|
}
|
||||||
.show()
|
renderOpenDialogs(viewModel.uiState.value)
|
||||||
|
}
|
||||||
|
secondDialog.setOnDismissListener {
|
||||||
|
if (deleteSecondConfirmationDialog === secondDialog) {
|
||||||
|
deleteSecondConfirmationDialog = null
|
||||||
|
}
|
||||||
|
dismissDeleteDialogWhenMutationEnds = false
|
||||||
|
}
|
||||||
|
deleteSecondConfirmationDialog = secondDialog
|
||||||
|
secondDialog.show()
|
||||||
}
|
}
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
@@ -329,5 +414,6 @@ class BoardDetailActivity : AppCompatActivity() {
|
|||||||
const val EXTRA_BOARD_TITLE = "extra_board_title"
|
const val EXTRA_BOARD_TITLE = "extra_board_title"
|
||||||
|
|
||||||
var testDataSourceFactory: ((String) -> BoardDetailDataSource)? = null
|
var testDataSourceFactory: ((String) -> BoardDetailDataSource)? = null
|
||||||
|
var testUiStateObserver: ((BoardDetailUiState) -> Unit)? = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,11 +130,32 @@ class BoardDetailViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun moveSelectedCards(targetListId: String) {
|
fun moveSelectedCards(targetListId: String) {
|
||||||
runMutation { selectedIds -> repository.moveCards(selectedIds, targetListId) }
|
val snapshot = _uiState.value
|
||||||
|
if (snapshot.isMutating) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val selectedIds = snapshot.selectedCardIds
|
||||||
|
if (selectedIds.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val targetCardIds = snapshot.boardDetail
|
||||||
|
?.lists
|
||||||
|
?.firstOrNull { it.id == targetListId }
|
||||||
|
?.cards
|
||||||
|
.orEmpty()
|
||||||
|
.map { it.id }
|
||||||
|
.toSet()
|
||||||
|
val movableIds = selectedIds - targetCardIds
|
||||||
|
if (movableIds.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
runMutation(selectedIds = movableIds) { ids -> repository.moveCards(ids, targetListId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteSelectedCards() {
|
fun deleteSelectedCards() {
|
||||||
runMutation(repository::deleteCards)
|
runMutation(selectedIds = _uiState.value.selectedCardIds, mutation = repository::deleteCards)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startEditingList(listId: String) {
|
fun startEditingList(listId: String) {
|
||||||
@@ -238,14 +259,14 @@ class BoardDetailViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun runMutation(
|
private fun runMutation(
|
||||||
|
selectedIds: Set<String>,
|
||||||
mutation: suspend (Set<String>) -> CardBatchMutationResult,
|
mutation: suspend (Set<String>) -> CardBatchMutationResult,
|
||||||
) {
|
) {
|
||||||
val preMutation = _uiState.value
|
val preMutation = _uiState.value
|
||||||
if (preMutation.isMutating) {
|
if (preMutation.isMutating) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val selectedIds = preMutation.selectedCardIds
|
if (preMutation.selectedCardIds.isEmpty() || selectedIds.isEmpty()) {
|
||||||
if (selectedIds.isEmpty()) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
<string name="select_all">Select all</string>
|
<string name="select_all">Select all</string>
|
||||||
<string name="move_cards">Move cards</string>
|
<string name="move_cards">Move cards</string>
|
||||||
<string name="delete_cards">Delete cards</string>
|
<string name="delete_cards">Delete cards</string>
|
||||||
|
<string name="delete_cards_title">Delete selected cards</string>
|
||||||
<string name="move_cards_to_list">Move cards to list</string>
|
<string name="move_cards_to_list">Move cards to list</string>
|
||||||
<string name="delete_cards_confirmation">Delete selected cards?</string>
|
<string name="delete_cards_confirmation">Delete selected cards?</string>
|
||||||
<string name="delete_cards_second_confirmation">Are you sure you want to permanently delete the selected cards?</string>
|
<string name="delete_cards_second_confirmation">Are you sure you want to permanently delete the selected cards?</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user