Merge branch 'feature/board-detail-view'

This commit is contained in:
2026-03-16 03:18:48 -04:00
27 changed files with 4685 additions and 44 deletions

View File

@@ -72,7 +72,7 @@ Kanbn4Droid is an unofficial app to connect to and manipulate data stored in sel
- Long-pressing an existing board shows a modal dialog asking the user if they want to delete the board, with buttons for "Cancel" and "Delete".
- Pressing "Delete" in the modal dialog MUST show a second confirmation modal asking if the user is sure, with buttons for "Cancel" and "I'm sure".
- Only on pressing "I'm sure" in the second confirmation modal dialog should a board delete request be sent to the API.
- Current status: implemented in `BoardsActivity` using XML Views, `RecyclerView`, `SwipeRefreshLayout`, and a `BoardsViewModel`/`BoardsRepository` flow. The screen includes auto-refresh on entry, pull-to-refresh, create board dialog with optional template selector, and two-step board delete confirmation. Board taps navigate to `BoardDetailPlaceholderActivity` while full board detail is still pending. API calls are workspace-scoped; when no workspace is stored the app resolves workspaces through the API, stores the first workspace id as default, and uses it for board listing, template listing (`type=template`), and board creation.
- Current status: implemented in `BoardsActivity` using XML Views, `RecyclerView`, `SwipeRefreshLayout`, and a `BoardsViewModel`/`BoardsRepository` flow. The screen includes auto-refresh on entry, pull-to-refresh, create board dialog with optional template selector, and two-step board delete confirmation. Board taps navigate to `BoardDetailActivity`. API calls are workspace-scoped; when no workspace is stored the app resolves workspaces through the API, stores the first workspace id as default, and uses it for board listing, template listing (`type=template`), and board creation.
**Board detail view**
@@ -98,6 +98,7 @@ Kanbn4Droid is an unofficial app to connect to and manipulate data stored in sel
- Pressing "Delete" in the modal dialog MUST show a second confirmation modal asking if the user is sure, with buttons for "Cancel" and "I'm sure".
- Only on pressing "I'm sure" in the second confirmation modal dialog should a board delete request be sent to the API.
- Long-pressing any of the buttons must show a tooltip with the button name.
- Current status: implemented in `BoardDetailActivity` with `ViewPager2` (one list per page), inline list-title edit, card rendering (title/tags/due date locale formatting and expiry color), cross-page card selection, page-scoped select-all, move dialog with list selector, two-step delete confirmation, mutation guards while in progress, and API-backed reload/reconciliation behavior through `BoardDetailViewModel` and `BoardDetailRepository`. Selection action icons use local vector drawables (`ic_select_all_grid_24`, `ic_move_cards_horizontal_24`, `ic_delete_24`). Startup blocking dialogs are shown for missing board id and missing session.
**Card detail view**
- The view shows the card's title in bold letters. Tapping on the card's title allows editing it.
@@ -111,6 +112,7 @@ Kanbn4Droid is an unofficial app to connect to and manipulate data stored in sel
- The modal dialog has "Add comment" as a title.
- The modal dialog has an editable markdown-enabled text field for the comment.
- The modal dialog has two buttons at the bottom on the right side for "Cancel" and "Add" respectively.
- Current status: full card detail is still pending. Tapping a board-detail card currently navigates to `CardDetailPlaceholderActivity` with card id/title extras.
**Settings view**
- The view shows a list of settings that can be changed by the user. The following settings are available:

View File

@@ -0,0 +1,915 @@
package space.hackenslacker.kanbn4droid.app
import android.content.Intent
import android.graphics.Color
import android.view.inputmethod.EditorInfo
import android.view.View
import android.widget.TextView
import androidx.appcompat.content.res.AppCompatResources
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.longClick
import androidx.test.espresso.action.ViewActions.replaceText
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtra
import androidx.test.espresso.matcher.RootMatchers.isDialog
import androidx.test.espresso.matcher.ViewMatchers.withContentDescription
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.isEnabled
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.color.MaterialColors
import java.text.DateFormat
import java.util.ArrayDeque
import java.util.Date
import java.util.Locale
import kotlinx.coroutines.CompletableDeferred
import org.hamcrest.Matchers.not
import org.hamcrest.Description
import org.hamcrest.Matcher
import org.hamcrest.TypeSafeMatcher
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardCardSummary
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetail
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailActivity
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailDataSource
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardListDetail
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardTagSummary
import space.hackenslacker.kanbn4droid.app.boarddetail.CardBatchMutationResult
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
@RunWith(AndroidJUnit4::class)
class BoardDetailFlowTest {
private lateinit var defaultDataSource: FakeBoardDetailDataSource
private var originalLocale: Locale? = null
private val observedStates = mutableListOf<space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailUiState>()
@Before
fun setUp() {
MainActivity.dependencies.clear()
MainActivity.dependencies.sessionStoreFactory = { InMemorySessionStore("https://kan.bn/") }
MainActivity.dependencies.apiKeyStoreFactory = { InMemoryApiKeyStore("api") }
originalLocale = Locale.getDefault()
Intents.init()
defaultDataSource = FakeBoardDetailDataSource(initialDetail = detailOneList())
BoardDetailActivity.testDataSourceFactory = { defaultDataSource }
BoardDetailActivity.testUiStateObserver = { state -> observedStates += state }
}
@After
fun tearDown() {
Intents.release()
BoardDetailActivity.testDataSourceFactory = null
BoardDetailActivity.testUiStateObserver = null
MainActivity.dependencies.clear()
originalLocale?.let { Locale.setDefault(it) }
}
@Test
fun boardDetailShowsListTitleAndCards() {
launchBoardDetail()
onView(withText("To Do")).check(matches(isDisplayed()))
onView(withText("Card 1")).check(matches(isDisplayed()))
}
@Test
fun initialLoadShowsProgressThenContent() {
val gate = CompletableDeferred<Unit>()
defaultDataSource.loadGate = gate
val scenario = launchBoardDetail()
onView(withId(R.id.boardDetailInitialProgress)).check(matches(isDisplayed()))
gate.complete(Unit)
scenario.onActivity { }
onView(withText("Card 1")).check(matches(isDisplayed()))
}
@Test
fun emptyBoardShowsNoListsYetMessage() {
defaultDataSource.currentDetail = BoardDetail(id = "board-1", title = "Board", lists = emptyList())
launchBoardDetail()
onView(withText(R.string.board_detail_empty_board)).check(matches(isDisplayed()))
}
@Test
fun initialLoadFailureShowsRetryAndRetryReloads() {
defaultDataSource.loadResults.add(BoardsApiResult.Failure("Load failed"))
defaultDataSource.loadResults.add(BoardsApiResult.Success(detailOneList()))
launchBoardDetail()
onView(withText("Load failed")).check(matches(isDisplayed()))
onView(withId(R.id.boardDetailRetryButton)).perform(click())
onView(withText("Card 1")).check(matches(isDisplayed()))
}
@Test
fun expiredDueDateUsesErrorColor() {
defaultDataSource.currentDetail = detailWithDueDate(System.currentTimeMillis() - 3_600_000)
val scenario = launchBoardDetail()
var expectedColor: Int? = null
scenario.onActivity { activity ->
expectedColor = MaterialColors.getColor(activity.findViewById(android.R.id.content), com.google.android.material.R.attr.colorError)
}
onView(withId(R.id.cardDueDateText)).check(matches(withCurrentTextColor(expectedColor ?: Color.RED)))
}
@Test
fun validDueDateUsesOnSurfaceColor() {
defaultDataSource.currentDetail = detailWithDueDate(System.currentTimeMillis() + 86_400_000)
val scenario = launchBoardDetail()
var expectedColor: Int? = null
scenario.onActivity { activity ->
expectedColor = MaterialColors.getColor(activity.findViewById(android.R.id.content), com.google.android.material.R.attr.colorOnSurface)
}
onView(withId(R.id.cardDueDateText)).check(matches(withCurrentTextColor(expectedColor ?: Color.BLACK)))
}
@Test
fun dueDateUsesSystemLocaleFormatting() {
Locale.setDefault(Locale.FRANCE)
val due = 1_735_776_000_000L
defaultDataSource.currentDetail = detailWithDueDate(due)
launchBoardDetail()
val expected = DateFormat.getDateInstance(DateFormat.MEDIUM, Locale.getDefault()).format(Date(due))
onView(withText(expected)).check(matches(isDisplayed()))
}
@Test
fun inlineListTitleEdit_savesOnImeDone() {
defaultDataSource.currentDetail = detailOneList()
val scenario = launchBoardDetail()
onView(withId(R.id.listTitleText)).perform(click())
scenario.onActivity { activity ->
val input = activity.findViewById<TextView>(R.id.listTitleEditInput)
input.setText("Renamed")
input.onEditorAction(EditorInfo.IME_ACTION_DONE)
}
assertEquals(1, defaultDataSource.renameCalls)
assertEquals("Renamed", defaultDataSource.lastRenameTitle)
}
@Test
fun inlineListTitleEdit_savesOnFocusLoss() {
launchBoardDetail()
onView(withId(R.id.listTitleText)).perform(click())
onView(withId(R.id.listTitleEditInput)).perform(replaceText("Renamed again"))
onView(withId(R.id.listCardsRecycler)).perform(click())
assertEquals(1, defaultDataSource.renameCalls)
assertEquals("Renamed again", defaultDataSource.lastRenameTitle)
}
@Test
fun inlineListTitleEdit_trimmedNoOpSkipsRenameCall() {
val scenario = launchBoardDetail()
onView(withId(R.id.listTitleText)).perform(click())
scenario.onActivity { activity ->
val input = activity.findViewById<TextView>(R.id.listTitleEditInput)
input.setText(" To Do ")
input.onEditorAction(EditorInfo.IME_ACTION_DONE)
}
assertEquals(0, defaultDataSource.renameCalls)
}
@Test
fun inlineListTitleEdit_rejectsBlankTitle() {
val scenario = launchBoardDetail()
onView(withId(R.id.listTitleText)).perform(click())
scenario.onActivity { activity ->
val input = activity.findViewById<TextView>(R.id.listTitleEditInput)
input.setText(" ")
input.onEditorAction(EditorInfo.IME_ACTION_DONE)
}
onView(withText(R.string.list_title_required)).check(matches(isDisplayed()))
assertEquals(0, defaultDataSource.renameCalls)
}
@Test
fun inlineListTitleEdit_renameFailure_keepsEditModeAndShowsError() {
defaultDataSource.renameResult = BoardsApiResult.Failure("Rename rejected")
val scenario = launchBoardDetail()
onView(withId(R.id.listTitleText)).perform(click())
scenario.onActivity { activity ->
val input = activity.findViewById<TextView>(R.id.listTitleEditInput)
input.setText("X")
input.onEditorAction(EditorInfo.IME_ACTION_DONE)
}
onView(withId(R.id.listTitleEditInput)).check(matches(isDisplayed()))
onView(withText("Rename rejected")).check(matches(isDisplayed()))
}
@Test
fun inlineListTitleEdit_saveDisabledWhileMutating() {
defaultDataSource.renameGate = CompletableDeferred()
val scenario = launchBoardDetail()
onView(withId(R.id.listTitleText)).perform(click())
scenario.onActivity { activity ->
val input = activity.findViewById<TextView>(R.id.listTitleEditInput)
input.setText("New")
input.onEditorAction(EditorInfo.IME_ACTION_DONE)
}
onView(withId(R.id.listTitleEditInput)).check(matches(not(isEnabled())))
defaultDataSource.renameGate?.complete(Unit)
}
@Test
fun selectionModeActionsShowTooltipsOnLongPress() {
val scenario = launchBoardDetail()
onView(withText("Card 1")).perform(longClick())
scenario.onActivity { activity ->
val menu = activity.findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.boardDetailToolbar).menu
assertEquals(activity.getString(R.string.select_all), menu.findItem(R.id.actionSelectAll)?.tooltipText?.toString())
assertEquals(activity.getString(R.string.move_cards), menu.findItem(R.id.actionMoveCards)?.tooltipText?.toString())
assertEquals(activity.getString(R.string.delete_cards), menu.findItem(R.id.actionDeleteCards)?.tooltipText?.toString())
}
}
@Test
fun selectionActionIconsMatchExpectedResources() {
val scenario = launchBoardDetail()
onView(withText("Card 1")).perform(longClick())
scenario.onActivity { activity ->
val menu = activity.findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.boardDetailToolbar).menu
assertNotNull(menu.findItem(R.id.actionSelectAll)?.icon)
assertNotNull(menu.findItem(R.id.actionMoveCards)?.icon)
assertNotNull(menu.findItem(R.id.actionDeleteCards)?.icon)
assertEquals(
AppCompatResources.getDrawable(activity, R.drawable.ic_select_all_grid_24)?.constantState,
menu.findItem(R.id.actionSelectAll)?.icon?.constantState,
)
assertEquals(
AppCompatResources.getDrawable(activity, R.drawable.ic_move_cards_horizontal_24)?.constantState,
menu.findItem(R.id.actionMoveCards)?.icon?.constantState,
)
assertEquals(
AppCompatResources.getDrawable(activity, R.drawable.ic_delete_24)?.constantState,
menu.findItem(R.id.actionDeleteCards)?.icon?.constantState,
)
}
}
@Test
fun missingBoardIdShowsBlockingDialogAndFinishes() {
val scenario = launchBoardDetail(boardId = null)
onView(withText(R.string.board_detail_unable_to_open_board)).inRoot(isDialog()).check(matches(isDisplayed()))
onView(withText(R.string.ok)).inRoot(isDialog()).perform(click())
scenario.onActivity { activity ->
assertTrue(activity.isFinishing)
}
}
@Test
fun missingSessionShowsBlockingDialogAndFinishes() {
BoardDetailActivity.testDataSourceFactory = null
MainActivity.dependencies.sessionStoreFactory = { InMemorySessionStore(null) }
MainActivity.dependencies.apiKeyStoreFactory = { InMemoryApiKeyStore(null) }
val scenario = launchBoardDetail()
onView(withText(R.string.board_detail_session_expired)).inRoot(isDialog()).check(matches(isDisplayed()))
onView(withText(R.string.ok)).inRoot(isDialog()).perform(click())
scenario.onActivity { activity ->
assertTrue(activity.isFinishing)
}
}
@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
}
onView(withText("Some cards could not be moved. Please try again.")).check(matches(isDisplayed()))
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
}
onView(withText("Move failed")).check(matches(isDisplayed()))
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
}
onView(withText("Some cards could not be deleted. Please try again.")).check(matches(isDisplayed()))
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
}
onView(withText("Delete failed")).check(matches(isDisplayed()))
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
fun cardTapNavigatesToCardPlaceholderWithExtras() {
launchBoardDetail()
onView(withText("Card 1")).perform(click())
Intents.intended(hasComponent(CardDetailPlaceholderActivity::class.java.name))
Intents.intended(hasExtra(CardDetailPlaceholderActivity.EXTRA_CARD_ID, "card-1"))
Intents.intended(hasExtra(CardDetailPlaceholderActivity.EXTRA_CARD_TITLE, "Card 1"))
}
@Test
fun cardTapBlankTitle_usesCardFallbackInPlaceholderExtra() {
defaultDataSource.currentDetail = detailWithCardTitle(" ")
launchBoardDetail()
onView(withId(R.id.cardItemRoot)).perform(click())
val expectedFallback = ApplicationProvider.getApplicationContext<android.content.Context>()
.getString(R.string.card_detail_placeholder_fallback_title)
Intents.intended(hasComponent(CardDetailPlaceholderActivity::class.java.name))
Intents.intended(hasExtra(CardDetailPlaceholderActivity.EXTRA_CARD_ID, "card-1"))
Intents.intended(hasExtra(CardDetailPlaceholderActivity.EXTRA_CARD_TITLE, expectedFallback))
}
private fun launchBoardDetail(boardId: String? = "board-1"): ActivityScenario<BoardDetailActivity> {
val intent = Intent(
androidx.test.core.app.ApplicationProvider.getApplicationContext(),
BoardDetailActivity::class.java,
)
if (boardId != null) {
intent.putExtra(BoardDetailActivity.EXTRA_BOARD_ID, boardId)
}
intent.putExtra(BoardDetailActivity.EXTRA_BOARD_TITLE, "Board")
return ActivityScenario.launch(intent)
}
private fun withCurrentTextColor(expectedColor: Int): Matcher<View> {
return object : TypeSafeMatcher<View>() {
override fun describeTo(description: Description) {
description.appendText("with text color: $expectedColor")
}
override fun matchesSafely(item: View): Boolean {
if (item !is TextView) {
return false
}
return item.currentTextColor == expectedColor
}
}
}
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(
initialDetail: BoardDetail,
) : BoardDetailDataSource {
var currentDetail: BoardDetail = initialDetail
val loadResults: ArrayDeque<BoardsApiResult<BoardDetail>> = ArrayDeque()
var loadGate: CompletableDeferred<Unit>? = null
var renameResult: BoardsApiResult<Unit> = BoardsApiResult.Success(Unit)
var renameGate: CompletableDeferred<Unit>? = null
var renameCalls: Int = 0
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> {
loadGate?.await()
return if (loadResults.isNotEmpty()) {
loadResults.removeFirst()
} else {
BoardsApiResult.Success(currentDetail)
}
}
override suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult {
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 {
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> {
renameCalls += 1
lastRenameTitle = newTitle
renameGate?.await()
val result = renameResult
if (result is BoardsApiResult.Success) {
currentDetail = currentDetail.copy(
lists = currentDetail.lists.map { list ->
if (list.id == listId) list.copy(title = newTitle) else list
},
)
}
return result
}
}
private class InMemorySessionStore(
private var baseUrl: String?,
) : SessionStore {
override fun getBaseUrl(): String? = baseUrl
override fun saveBaseUrl(url: String) {
baseUrl = url
}
override fun clearBaseUrl() {
baseUrl = null
}
override fun getWorkspaceId(): String? = "ws-1"
override fun saveWorkspaceId(workspaceId: String) {
}
override fun clearWorkspaceId() {
}
}
private class InMemoryApiKeyStore(
private var key: String?,
) : ApiKeyStore {
override suspend fun saveApiKey(baseUrl: String, apiKey: String): Result<Unit> {
key = apiKey
return Result.success(Unit)
}
override suspend fun getApiKey(baseUrl: String): Result<String?> = Result.success(key)
override suspend fun invalidateApiKey(baseUrl: String): Result<Unit> {
key = null
return Result.success(Unit)
}
}
private companion object {
fun detailOneList(): 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 = listOf(BoardTagSummary("tag-1", "Backend", "#008080")),
dueAtEpochMillis = null,
),
),
),
),
)
}
fun detailWithDueDate(dueAtEpochMillis: Long): BoardDetail {
return detailOneList().copy(
lists = listOf(
BoardListDetail(
id = "list-1",
title = "To Do",
cards = listOf(
BoardCardSummary(
id = "card-1",
title = "Card 1",
tags = emptyList(),
dueAtEpochMillis = dueAtEpochMillis,
),
),
),
),
)
}
fun detailWithCardTitle(title: String): BoardDetail {
return detailOneList().copy(
lists = listOf(
BoardListDetail(
id = "list-1",
title = "To Do",
cards = listOf(
BoardCardSummary(
id = "card-1",
title = title,
tags = emptyList(),
dueAtEpochMillis = null,
),
),
),
),
)
}
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,
),
),
),
),
)
}
}
}

View File

@@ -46,7 +46,7 @@ class BoardsFlowTest {
}
@Test
fun boardTapNavigatesToDetailPlaceholderWithExtras() {
fun boardTapNavigatesToBoardDetailActivity() {
MainActivity.dependencies.apiClientFactory = {
FakeBoardsApiClient(
boards = mutableListOf(BoardSummary("1", "Alpha")),
@@ -58,9 +58,9 @@ class BoardsFlowTest {
onView(withText("Alpha")).perform(click())
Intents.intended(hasComponent(BoardDetailPlaceholderActivity::class.java.name))
Intents.intended(hasExtra(BoardDetailPlaceholderActivity.EXTRA_BOARD_ID, "1"))
Intents.intended(hasExtra(BoardDetailPlaceholderActivity.EXTRA_BOARD_TITLE, "Alpha"))
Intents.intended(hasComponent(space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailActivity::class.java.name))
Intents.intended(hasExtra(space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailActivity.EXTRA_BOARD_ID, "1"))
Intents.intended(hasExtra(space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailActivity.EXTRA_BOARD_TITLE, "Alpha"))
}
@Test
@@ -79,8 +79,8 @@ class BoardsFlowTest {
onView(withId(R.id.useTemplateChip)).perform(click())
onView(withId(android.R.id.button1)).inRoot(isDialog()).perform(click())
Intents.intended(hasComponent(BoardDetailPlaceholderActivity::class.java.name))
Intents.intended(hasExtra(BoardDetailPlaceholderActivity.EXTRA_BOARD_TITLE, "Roadmap"))
Intents.intended(hasComponent(space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailActivity::class.java.name))
Intents.intended(hasExtra(space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailActivity.EXTRA_BOARD_TITLE, "Roadmap"))
}
@Test

View File

@@ -12,7 +12,10 @@
android:usesCleartextTraffic="true"
android:theme="@style/Theme.Kanbn4Droid">
<activity
android:name=".BoardDetailPlaceholderActivity"
android:name=".CardDetailPlaceholderActivity"
android:exported="false" />
<activity
android:name=".boarddetail.BoardDetailActivity"
android:exported="false" />
<activity
android:name=".BoardsActivity"

View File

@@ -1,24 +0,0 @@
package space.hackenslacker.kanbn4droid.app
import android.os.Bundle
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
class BoardDetailPlaceholderActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_board_detail_placeholder)
val boardTitle = intent.getStringExtra(EXTRA_BOARD_TITLE).orEmpty()
val boardId = intent.getStringExtra(EXTRA_BOARD_ID).orEmpty()
val titleView: TextView = findViewById(R.id.boardDetailPlaceholderTitle)
titleView.text = getString(R.string.board_detail_placeholder_title, boardTitle, boardId)
}
companion object {
const val EXTRA_BOARD_ID = "extra_board_id"
const val EXTRA_BOARD_TITLE = "extra_board_title"
}
}

View File

@@ -35,6 +35,7 @@ import space.hackenslacker.kanbn4droid.app.boards.BoardsRepository
import space.hackenslacker.kanbn4droid.app.boards.BoardsUiEvent
import space.hackenslacker.kanbn4droid.app.boards.BoardsUiState
import space.hackenslacker.kanbn4droid.app.boards.BoardsViewModel
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailActivity
class BoardsActivity : AppCompatActivity() {
private lateinit var sessionStore: SessionStore
@@ -245,9 +246,9 @@ class BoardsActivity : AppCompatActivity() {
private fun navigateToBoard(board: BoardSummary) {
startActivity(
Intent(this, BoardDetailPlaceholderActivity::class.java)
.putExtra(BoardDetailPlaceholderActivity.EXTRA_BOARD_ID, board.id)
.putExtra(BoardDetailPlaceholderActivity.EXTRA_BOARD_TITLE, board.title),
Intent(this, BoardDetailActivity::class.java)
.putExtra(BoardDetailActivity.EXTRA_BOARD_ID, board.id)
.putExtra(BoardDetailActivity.EXTRA_BOARD_TITLE, board.title),
)
}

View File

@@ -0,0 +1,24 @@
package space.hackenslacker.kanbn4droid.app
import android.os.Bundle
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
class CardDetailPlaceholderActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_card_detail_placeholder)
val cardId = intent.getStringExtra(EXTRA_CARD_ID).orEmpty()
val cardTitle = intent.getStringExtra(EXTRA_CARD_TITLE).orEmpty()
val titleView: TextView = findViewById(R.id.cardDetailPlaceholderTitle)
titleView.text = getString(R.string.card_detail_placeholder_title, cardTitle, cardId)
}
companion object {
const val EXTRA_CARD_ID = "extra_card_id"
const val EXTRA_CARD_TITLE = "extra_card_title"
}
}

View File

@@ -3,10 +3,15 @@ package space.hackenslacker.kanbn4droid.app.auth
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import java.time.Instant
import org.json.JSONArray
import org.json.JSONObject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardCardSummary
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetail
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardListDetail
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardTagSummary
import space.hackenslacker.kanbn4droid.app.boards.BoardSummary
import space.hackenslacker.kanbn4droid.app.boards.BoardTemplate
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
@@ -48,6 +53,22 @@ interface KanbnApiClient {
suspend fun deleteBoard(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult<Unit> {
return BoardsApiResult.Failure("Board deletion is not implemented.")
}
suspend fun getBoardDetail(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult<BoardDetail> {
return BoardsApiResult.Failure("Board detail is not implemented.")
}
suspend fun renameList(baseUrl: String, apiKey: String, listId: String, newTitle: String): BoardsApiResult<Unit> {
return BoardsApiResult.Failure("List rename is not implemented.")
}
suspend fun moveCard(baseUrl: String, apiKey: String, cardId: String, targetListId: String): BoardsApiResult<Unit> {
return BoardsApiResult.Failure("Card move is not implemented.")
}
suspend fun deleteCard(baseUrl: String, apiKey: String, cardId: String): BoardsApiResult<Unit> {
return BoardsApiResult.Failure("Card deletion is not implemented.")
}
}
class HttpKanbnApiClient : KanbnApiClient {
@@ -193,6 +214,96 @@ class HttpKanbnApiClient : KanbnApiClient {
}
}
override suspend fun getBoardDetail(
baseUrl: String,
apiKey: String,
boardId: String,
): BoardsApiResult<BoardDetail> {
return withContext(Dispatchers.IO) {
request(
baseUrl = baseUrl,
path = "/api/v1/boards/$boardId",
method = "GET",
apiKey = apiKey,
) { code, body ->
if (code in 200..299) {
parseBoardDetail(body, boardId)
?.let { BoardsApiResult.Success(it) }
?: BoardsApiResult.Failure("Malformed board detail response.")
} else {
BoardsApiResult.Failure(serverMessage(body, code))
}
}
}
}
override suspend fun renameList(
baseUrl: String,
apiKey: String,
listId: String,
newTitle: String,
): BoardsApiResult<Unit> {
return withContext(Dispatchers.IO) {
request(
baseUrl = baseUrl,
path = "/api/v1/lists/$listId",
method = "PATCH",
apiKey = apiKey,
body = "{\"name\":\"${jsonEscape(newTitle.trim())}\"}",
) { code, body ->
if (code in 200..299) {
BoardsApiResult.Success(Unit)
} else {
BoardsApiResult.Failure(serverMessage(body, code))
}
}
}
}
override suspend fun moveCard(
baseUrl: String,
apiKey: String,
cardId: String,
targetListId: String,
): BoardsApiResult<Unit> {
return withContext(Dispatchers.IO) {
request(
baseUrl = baseUrl,
path = "/api/v1/cards/$cardId",
method = "PATCH",
apiKey = apiKey,
body = "{\"listId\":\"${jsonEscape(targetListId)}\"}",
) { code, body ->
if (code in 200..299) {
BoardsApiResult.Success(Unit)
} else {
BoardsApiResult.Failure(serverMessage(body, code))
}
}
}
}
override suspend fun deleteCard(
baseUrl: String,
apiKey: String,
cardId: String,
): BoardsApiResult<Unit> {
return withContext(Dispatchers.IO) {
request(
baseUrl = baseUrl,
path = "/api/v1/cards/$cardId",
method = "DELETE",
apiKey = apiKey,
) { code, body ->
if (code in 200..299) {
BoardsApiResult.Success(Unit)
} else {
BoardsApiResult.Failure(serverMessage(body, code))
}
}
}
}
private fun <T> request(
baseUrl: String,
path: String,
@@ -203,7 +314,7 @@ class HttpKanbnApiClient : KanbnApiClient {
): BoardsApiResult<T> {
val endpoint = "${baseUrl.trimEnd('/')}$path"
val connection = (URL(endpoint).openConnection() as HttpURLConnection).apply {
requestMethod = method
configureRequestMethod(this, method)
connectTimeout = 10_000
readTimeout = 10_000
setRequestProperty("x-api-key", apiKey)
@@ -237,6 +348,18 @@ class HttpKanbnApiClient : KanbnApiClient {
}
}
private fun configureRequestMethod(connection: HttpURLConnection, method: String) {
try {
connection.requestMethod = method
} catch (throwable: Throwable) {
if (method != "PATCH") {
throw throwable
}
connection.requestMethod = "POST"
connection.setRequestProperty("X-HTTP-Method-Override", "PATCH")
}
}
private fun readResponseBody(connection: HttpURLConnection, code: Int): String {
val stream = if (code in 200..299) connection.inputStream else connection.errorStream
return stream?.bufferedReader()?.use { it.readText() }.orEmpty()
@@ -361,18 +484,306 @@ class HttpKanbnApiClient : KanbnApiClient {
return workspaces
}
private fun parseBoardDetail(body: String, fallbackId: String): BoardDetail? {
if (body.isBlank()) {
return BoardDetail(id = fallbackId, title = "Board", lists = emptyList())
}
val root = parseJsonObject(body)
?: return null
val data = root["data"] as? Map<*, *>
val board = (data?.get("board") as? Map<*, *>)
?: data
?: (root["board"] as? Map<*, *>)
?: root
val boardId = extractId(board).ifBlank { fallbackId }
val boardTitle = extractTitle(board, "Board")
val lists = parseLists(board)
return BoardDetail(id = boardId, title = boardTitle, lists = lists)
}
private fun parseLists(board: Map<*, *>): List<BoardListDetail> {
return extractObjectArray(board, "lists", "items", "data").mapNotNull { rawList ->
val id = extractId(rawList)
if (id.isBlank()) {
return@mapNotNull null
}
BoardListDetail(
id = id,
title = extractTitle(rawList, "List"),
cards = parseCards(rawList),
)
}
}
private fun parseCards(list: Map<*, *>): List<BoardCardSummary> {
return extractObjectArray(list, "cards", "items", "data").mapNotNull { rawCard ->
val id = extractId(rawCard)
if (id.isBlank()) {
return@mapNotNull null
}
BoardCardSummary(
id = id,
title = extractTitle(rawCard, "Card"),
tags = parseTags(rawCard),
dueAtEpochMillis = parseDueDate(rawCard),
)
}
}
private fun parseTags(card: Map<*, *>): List<BoardTagSummary> {
return extractObjectArray(card, "labels", "tags", "data").mapNotNull { rawTag ->
val id = extractId(rawTag)
if (id.isBlank()) {
return@mapNotNull null
}
BoardTagSummary(
id = id,
name = extractTitle(rawTag, "Tag"),
colorHex = extractString(rawTag, "colorHex", "color", "hex"),
)
}
}
private fun parseDueDate(card: Map<*, *>): Long? {
val dueValue = firstPresent(card, "dueDate", "dueAt", "due_at", "due") ?: return null
return when (dueValue) {
is Number -> dueValue.toLong()
is String -> {
val trimmed = dueValue.trim()
if (trimmed.isBlank()) null else trimmed.toLongOrNull() ?: runCatching { Instant.parse(trimmed).toEpochMilli() }.getOrNull()
}
else -> null
}
}
private fun extractObjectArray(source: Map<*, *>, vararg keys: String): List<Map<*, *>> {
val array = keys.firstNotNullOfOrNull { key -> source[key] as? List<*> } ?: return emptyList()
return array.mapNotNull { it as? Map<*, *> }
}
private fun extractId(source: Map<*, *>): String {
val directId = source["id"]?.toString().orEmpty()
if (directId.isNotBlank()) {
return directId
}
return extractString(source, "publicId", "public_id")
}
private fun extractTitle(source: Map<*, *>, fallback: String): String {
return extractString(source, "title", "name").ifBlank { fallback }
}
private fun extractString(source: Map<*, *>, vararg keys: String): String {
return keys.firstNotNullOfOrNull { key -> source[key]?.toString()?.takeIf { it.isNotBlank() } }.orEmpty()
}
private fun firstPresent(source: Map<*, *>, vararg keys: String): Any? {
return keys.firstNotNullOfOrNull { key -> source[key] }
}
private fun parseJsonObject(body: String): Map<String, Any?>? {
val trimmed = body.trim()
if (trimmed.isBlank()) {
return null
}
val parsed = runCatching { MiniJsonParser(trimmed).parseValue() }.getOrNull()
@Suppress("UNCHECKED_CAST")
return parsed as? Map<String, Any?>
}
private fun jsonEscape(value: String): String {
val builder = StringBuilder()
value.forEach { ch ->
if (ch.code in 0x00..0x1F) {
when (ch) {
'\b' -> builder.append("\\b")
'\u000C' -> builder.append("\\f")
'\n' -> builder.append("\\n")
'\r' -> builder.append("\\r")
'\t' -> builder.append("\\t")
else -> builder.append("\\u%04x".format(ch.code))
}
} else {
when (ch) {
'\\' -> builder.append("\\\\")
'"' -> builder.append("\\\"")
else -> builder.append(ch)
}
}
}
return builder.toString()
}
private class MiniJsonParser(private val input: String) {
private var index = 0
fun parseValue(): Any? {
skipWhitespace()
if (index >= input.length) {
return null
}
return when (val ch = input[index]) {
'{' -> parseObject()
'[' -> parseArray()
'"' -> parseString()
't' -> parseLiteral("true", true)
'f' -> parseLiteral("false", false)
'n' -> parseLiteral("null", null)
'-', in '0'..'9' -> parseNumber()
else -> throw IllegalArgumentException("Unexpected token $ch at index $index")
}
}
private fun parseObject(): Map<String, Any?> {
expect('{')
skipWhitespace()
val result = linkedMapOf<String, Any?>()
if (peek() == '}') {
index += 1
return result
}
while (index < input.length) {
val key = parseString()
skipWhitespace()
expect(':')
val value = parseValue()
result[key] = value
skipWhitespace()
when (peek()) {
',' -> index += 1
'}' -> {
index += 1
return result
}
else -> throw IllegalArgumentException("Expected , or } at index $index")
}
skipWhitespace()
}
throw IllegalArgumentException("Unclosed object")
}
private fun parseArray(): List<Any?> {
expect('[')
skipWhitespace()
val result = mutableListOf<Any?>()
if (peek() == ']') {
index += 1
return result
}
while (index < input.length) {
result += parseValue()
skipWhitespace()
when (peek()) {
',' -> index += 1
']' -> {
index += 1
return result
}
else -> throw IllegalArgumentException("Expected , or ] at index $index")
}
skipWhitespace()
}
throw IllegalArgumentException("Unclosed array")
}
private fun parseString(): String {
expect('"')
val result = StringBuilder()
while (index < input.length) {
val ch = input[index++]
when (ch) {
'"' -> return result.toString()
'\\' -> {
val escaped = input.getOrNull(index++) ?: throw IllegalArgumentException("Invalid escape")
when (escaped) {
'"' -> result.append('"')
'\\' -> result.append('\\')
'/' -> result.append('/')
'b' -> result.append('\b')
'f' -> result.append('\u000C')
'n' -> result.append('\n')
'r' -> result.append('\r')
't' -> result.append('\t')
'u' -> {
val hex = input.substring(index, index + 4)
index += 4
result.append(hex.toInt(16).toChar())
}
else -> throw IllegalArgumentException("Invalid escape token")
}
}
else -> result.append(ch)
}
}
throw IllegalArgumentException("Unclosed string")
}
private fun parseNumber(): Any {
val start = index
if (peek() == '-') {
index += 1
}
while (peek()?.isDigit() == true) {
index += 1
}
var isFloating = false
if (peek() == '.') {
isFloating = true
index += 1
while (peek()?.isDigit() == true) {
index += 1
}
}
if (peek() == 'e' || peek() == 'E') {
isFloating = true
index += 1
if (peek() == '+' || peek() == '-') {
index += 1
}
while (peek()?.isDigit() == true) {
index += 1
}
}
val token = input.substring(start, index)
return if (isFloating) token.toDouble() else token.toLong()
}
private fun parseLiteral(token: String, value: Any?): Any? {
if (!input.startsWith(token, index)) {
throw IllegalArgumentException("Expected $token at index $index")
}
index += token.length
return value
}
private fun expect(expected: Char) {
skipWhitespace()
if (peek() != expected) {
throw IllegalArgumentException("Expected $expected at index $index")
}
index += 1
}
private fun peek(): Char? = input.getOrNull(index)
private fun skipWhitespace() {
while (peek()?.isWhitespace() == true) {
index += 1
}
}
}
private fun serverMessage(body: String, code: Int): String {
if (body.isBlank()) {
return "Server error: $code"
}
return runCatching {
val root = JSONObject(body)
listOf("message", "error", "cause", "detail")
.firstNotNullOfOrNull { key -> root.optString(key).takeIf { it.isNotBlank() } }
?: "Server error: $code"
}.getOrElse {
"Server error: $code"
}
val root = parseJsonObject(body)
val message = root?.let { extractString(it, "message", "error", "cause", "detail") }.orEmpty()
return message.ifBlank { "Server error: $code" }
}
}

View File

@@ -0,0 +1,446 @@
package space.hackenslacker.kanbn4droid.app.boarddetail
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.Button
import android.widget.ProgressBar
import android.widget.TextView
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.launch
import space.hackenslacker.kanbn4droid.app.MainActivity
import space.hackenslacker.kanbn4droid.app.CardDetailPlaceholderActivity
import space.hackenslacker.kanbn4droid.app.R
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
import space.hackenslacker.kanbn4droid.app.auth.HttpKanbnApiClient
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
import space.hackenslacker.kanbn4droid.app.auth.PreferencesApiKeyStore
import space.hackenslacker.kanbn4droid.app.auth.SessionPreferences
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
class BoardDetailActivity : AppCompatActivity() {
private lateinit var boardId: String
private lateinit var sessionStore: SessionStore
private lateinit var apiKeyStore: ApiKeyStore
private lateinit var apiClient: KanbnApiClient
private lateinit var toolbar: MaterialToolbar
private lateinit var pager: ViewPager2
private lateinit var emptyBoardText: TextView
private lateinit var initialProgress: ProgressBar
private lateinit var fullScreenErrorContainer: View
private lateinit var fullScreenErrorText: TextView
private lateinit var retryButton: Button
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 var hasShownBlockingStartupError: Boolean = false
private lateinit var pagerAdapter: BoardListsPagerAdapter
private val viewModel: BoardDetailViewModel by viewModels {
val id = boardId
val fakeFactory = testDataSourceFactory
if (fakeFactory != null) {
object : androidx.lifecycle.ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : androidx.lifecycle.ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(BoardDetailViewModel::class.java)) {
return BoardDetailViewModel(id, fakeFactory.invoke(id)) as T
}
throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
}
}
} else {
BoardDetailViewModel.Factory(
boardId = id,
repository = BoardDetailRepository(
sessionStore = sessionStore,
apiKeyStore = apiKeyStore,
apiClient = apiClient,
),
)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
boardId = intent.getStringExtra(EXTRA_BOARD_ID).orEmpty()
sessionStore = provideSessionStore()
apiKeyStore = provideApiKeyStore()
apiClient = provideApiClient()
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_board_detail)
bindViews()
setupToolbar()
setupPager()
observeViewModel()
if (boardId.isBlank()) {
showBlockingStartupErrorAndFinish(getString(R.string.board_detail_unable_to_open_board))
return
}
viewModel.loadBoardDetail()
}
override fun onBackPressed() {
if (!viewModel.onBackPressed()) {
super.onBackPressed()
}
}
private fun bindViews() {
toolbar = findViewById(R.id.boardDetailToolbar)
pager = findViewById(R.id.boardDetailPager)
emptyBoardText = findViewById(R.id.boardDetailEmptyBoardText)
initialProgress = findViewById(R.id.boardDetailInitialProgress)
fullScreenErrorContainer = findViewById(R.id.boardDetailFullScreenErrorContainer)
fullScreenErrorText = findViewById(R.id.boardDetailFullScreenErrorText)
retryButton = findViewById(R.id.boardDetailRetryButton)
}
private fun setupToolbar() {
setSupportActionBar(toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.title = intent.getStringExtra(EXTRA_BOARD_TITLE).orEmpty()
toolbar.setNavigationOnClickListener {
onBackPressedDispatcher.onBackPressed()
}
retryButton.setOnClickListener {
viewModel.retryLoad()
}
}
private fun setupPager() {
pagerAdapter = BoardListsPagerAdapter(
onListTitleClicked = { listId ->
inlineTitleErrorMessage = null
viewModel.startEditingList(listId)
},
onEditingTitleChanged = { title ->
inlineTitleErrorMessage = null
viewModel.updateEditingTitle(title)
},
onSubmitEditingTitle = { submitted ->
val trimmed = submitted.trim()
if (trimmed.isBlank()) {
inlineTitleErrorMessage = getString(R.string.list_title_required)
viewModel.updateEditingTitle(submitted)
render(viewModel.uiState.value)
} else {
inlineTitleErrorMessage = null
viewModel.updateEditingTitle(submitted)
viewModel.submitRenameList()
}
},
onCardClick = { card -> viewModel.onCardTapped(card.id) },
onCardLongClick = { card -> viewModel.onCardLongPressed(card.id) },
)
pager.adapter = pagerAdapter
pager.registerOnPageChangeCallback(
object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
viewModel.setCurrentPage(position)
}
},
)
}
private fun observeViewModel() {
lifecycleScope.launch {
viewModel.uiState.collect { render(it) }
}
lifecycleScope.launch {
viewModel.events.collect { event ->
when (event) {
is BoardDetailUiEvent.NavigateToCardPlaceholder -> {
val cardTitle = viewModel.uiState.value.boardDetail
?.lists
.orEmpty()
.asSequence()
.flatMap { list -> list.cards.asSequence() }
.firstOrNull { card -> card.id == event.cardId }
?.title
.orEmpty()
.trim()
.ifBlank { getString(R.string.card_detail_placeholder_fallback_title) }
startActivity(
Intent(this@BoardDetailActivity, CardDetailPlaceholderActivity::class.java)
.putExtra(CardDetailPlaceholderActivity.EXTRA_CARD_ID, event.cardId)
.putExtra(CardDetailPlaceholderActivity.EXTRA_CARD_TITLE, cardTitle),
)
}
is BoardDetailUiEvent.ShowServerError -> {
if (viewModel.uiState.value.editingListId != null) {
inlineTitleErrorMessage = event.message
render(viewModel.uiState.value)
} else {
MaterialAlertDialogBuilder(this@BoardDetailActivity)
.setMessage(event.message)
.setPositiveButton(R.string.ok, null)
.show()
}
}
is BoardDetailUiEvent.ShowWarning -> {
Snackbar.make(pager, event.message, Snackbar.LENGTH_LONG).show()
}
}
}
}
}
private fun render(state: BoardDetailUiState) {
testUiStateObserver?.invoke(state)
supportActionBar?.title = state.boardDetail?.title ?: intent.getStringExtra(EXTRA_BOARD_TITLE).orEmpty()
if (
!hasShownBlockingStartupError &&
state.boardDetail == null &&
state.fullScreenErrorMessage == BoardDetailRepository.MISSING_SESSION_MESSAGE
) {
showBlockingStartupErrorAndFinish(getString(R.string.board_detail_session_expired))
return
}
fullScreenErrorContainer.visibility = if (state.fullScreenErrorMessage != null && state.boardDetail == null) {
fullScreenErrorText.text = state.fullScreenErrorMessage
View.VISIBLE
} else {
View.GONE
}
initialProgress.visibility = if (state.isInitialLoading && state.boardDetail == null) View.VISIBLE else View.GONE
val boardLists = state.boardDetail?.lists.orEmpty()
val applyPagerState = {
pagerAdapter.submit(
lists = boardLists,
selectedCardIds = state.selectedCardIds,
editingListId = state.editingListId,
editingListTitle = state.editingListTitle,
isMutating = state.isMutating,
inlineEditErrorMessage = inlineTitleErrorMessage,
)
pager.visibility = if (boardLists.isNotEmpty()) View.VISIBLE else View.GONE
emptyBoardText.visibility = if (!state.isInitialLoading && state.fullScreenErrorMessage == null && boardLists.isEmpty()) {
View.VISIBLE
} else {
View.GONE
}
if (boardLists.isNotEmpty() && pager.currentItem != state.currentPageIndex) {
pager.setCurrentItem(state.currentPageIndex, false)
}
}
val pagerRecycler = pager.getChildAt(0) as? RecyclerView
if (pagerRecycler?.isComputingLayout == true) {
pager.post {
if (!isFinishing && !isDestroyed) {
applyPagerState()
}
}
} else {
applyPagerState()
}
renderSelectionActions(state)
renderOpenDialogs(state)
}
private fun showBlockingStartupErrorAndFinish(message: String) {
hasShownBlockingStartupError = true
MaterialAlertDialogBuilder(this)
.setMessage(message)
.setCancelable(false)
.setPositiveButton(R.string.ok) { _, _ ->
finish()
}
.show()
}
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) {
val inSelection = state.selectedCardIds.isNotEmpty()
toolbar.menu.clear()
if (!inSelection) {
return
}
toolbar.inflateMenu(R.menu.menu_board_detail_selection)
toolbar.menu.findItem(R.id.actionSelectAll)?.tooltipText = getString(R.string.select_all)
toolbar.menu.findItem(R.id.actionMoveCards)?.tooltipText = getString(R.string.move_cards)
toolbar.menu.findItem(R.id.actionDeleteCards)?.tooltipText = getString(R.string.delete_cards)
toolbar.setOnMenuItemClickListener { item ->
handleSelectionAction(item)
}
}
private fun handleSelectionAction(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.actionSelectAll -> {
viewModel.selectAllOnCurrentPage()
true
}
R.id.actionMoveCards -> {
showMoveCardsDialog()
true
}
R.id.actionDeleteCards -> {
showDeleteCardsDialog()
true
}
else -> false
}
}
private fun showMoveCardsDialog() {
val lists = viewModel.uiState.value.boardDetail?.lists.orEmpty()
if (lists.isEmpty()) {
return
}
val listNames = lists.map { it.title }.toTypedArray()
var selectedIndex = viewModel.uiState.value.currentPageIndex.coerceIn(0, lists.lastIndex)
val dialog = MaterialAlertDialogBuilder(this)
.setTitle(R.string.move_cards_to_list)
.setSingleChoiceItems(listNames, selectedIndex) { _, which ->
selectedIndex = which
renderOpenDialogs(viewModel.uiState.value)
}
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.move_cards, null)
.create()
dialog.setOnShowListener {
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener {
val currentLists = viewModel.uiState.value.boardDetail?.lists.orEmpty()
val targetId = currentLists.getOrNull(selectedIndex)?.id ?: return@setOnClickListener
dismissMoveDialogWhenMutationEnds = true
viewModel.moveSelectedCards(targetId)
}
renderOpenDialogs(viewModel.uiState.value)
}
dialog.setOnDismissListener {
if (moveDialog === dialog) {
moveDialog = null
}
dismissMoveDialogWhenMutationEnds = false
}
moveDialog = dialog
dialog.show()
}
private fun showDeleteCardsDialog() {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.delete_cards_title)
.setMessage(R.string.delete_cards_confirmation)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.delete) { _, _ ->
val secondDialog = MaterialAlertDialogBuilder(this)
.setMessage(R.string.delete_cards_second_confirmation)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.im_sure, null)
.create()
secondDialog.setOnShowListener {
secondDialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener {
dismissDeleteDialogWhenMutationEnds = true
viewModel.deleteSelectedCards()
}
renderOpenDialogs(viewModel.uiState.value)
}
secondDialog.setOnDismissListener {
if (deleteSecondConfirmationDialog === secondDialog) {
deleteSecondConfirmationDialog = null
}
dismissDeleteDialogWhenMutationEnds = false
}
deleteSecondConfirmationDialog = secondDialog
secondDialog.show()
}
.show()
}
protected fun provideSessionStore(): SessionStore {
return MainActivity.dependencies.sessionStoreFactory?.invoke(this) ?: SessionPreferences(applicationContext)
}
protected fun provideApiKeyStore(): ApiKeyStore {
return MainActivity.dependencies.apiKeyStoreFactory?.invoke(this)
?: PreferencesApiKeyStore(this)
}
protected fun provideApiClient(): KanbnApiClient {
return MainActivity.dependencies.apiClientFactory?.invoke() ?: HttpKanbnApiClient()
}
companion object {
const val EXTRA_BOARD_ID = "extra_board_id"
const val EXTRA_BOARD_TITLE = "extra_board_title"
var testDataSourceFactory: ((String) -> BoardDetailDataSource)? = null
var testUiStateObserver: ((BoardDetailUiState) -> Unit)? = null
}
}

View File

@@ -0,0 +1,38 @@
package space.hackenslacker.kanbn4droid.app.boarddetail
data class BoardDetail(
val id: String,
val title: String,
val lists: List<BoardListDetail>,
)
data class BoardListDetail(
val id: String,
val title: String,
val cards: List<BoardCardSummary>,
)
data class BoardCardSummary(
val id: String,
val title: String,
val tags: List<BoardTagSummary>,
val dueAtEpochMillis: Long?,
)
data class BoardTagSummary(
val id: String,
val name: String,
val colorHex: String,
)
sealed interface CardBatchMutationResult {
data object Success : CardBatchMutationResult
data class PartialSuccess(
val failedCardIds: Set<String>,
val message: String,
) : CardBatchMutationResult
data class Failure(
val message: String,
) : CardBatchMutationResult
}

View File

@@ -0,0 +1,201 @@
package space.hackenslacker.kanbn4droid.app.boarddetail
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
class BoardDetailRepository(
private val sessionStore: SessionStore,
private val apiKeyStore: ApiKeyStore,
private val apiClient: KanbnApiClient,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
companion object {
const val MISSING_SESSION_MESSAGE = "Missing session. Please sign in again."
}
suspend fun getBoardDetail(boardId: String): BoardsApiResult<BoardDetail> {
val normalizedBoardId = boardId.trim()
if (normalizedBoardId.isBlank()) {
return BoardsApiResult.Failure("Board id is required")
}
val session = when (val sessionResult = session()) {
is BoardsApiResult.Success -> sessionResult.value
is BoardsApiResult.Failure -> return sessionResult
}
return apiClient.getBoardDetail(
baseUrl = session.baseUrl,
apiKey = session.apiKey,
boardId = normalizedBoardId,
)
}
suspend fun renameList(listId: String, newTitle: String): BoardsApiResult<Unit> {
val normalizedListId = listId.trim()
if (normalizedListId.isBlank()) {
return BoardsApiResult.Failure("List id is required")
}
val normalizedTitle = newTitle.trim()
if (normalizedTitle.isBlank()) {
return BoardsApiResult.Failure("List title is required")
}
val session = when (val sessionResult = session()) {
is BoardsApiResult.Success -> sessionResult.value
is BoardsApiResult.Failure -> return sessionResult
}
return apiClient.renameList(
baseUrl = session.baseUrl,
apiKey = session.apiKey,
listId = normalizedListId,
newTitle = normalizedTitle,
)
}
suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult {
val normalizedTargetListId = targetListId.trim()
if (normalizedTargetListId.isBlank()) {
return CardBatchMutationResult.Failure("Target list id is required")
}
val normalizedCardIds = normalizeCardIds(cardIds)
if (normalizedCardIds.isEmpty()) {
return CardBatchMutationResult.Failure("At least one card id is required")
}
val session = when (val sessionResult = session()) {
is BoardsApiResult.Success -> sessionResult.value
is BoardsApiResult.Failure -> return CardBatchMutationResult.Failure(sessionResult.message)
}
val failuresByCardId = linkedMapOf<String, String>()
normalizedCardIds.forEach { cardId ->
when (
val result = apiClient.moveCard(
baseUrl = session.baseUrl,
apiKey = session.apiKey,
cardId = cardId,
targetListId = normalizedTargetListId,
)
) {
is BoardsApiResult.Success -> Unit
is BoardsApiResult.Failure -> failuresByCardId[cardId] = result.message
}
}
return aggregateBatchMutationResult(
normalizedCardIds = normalizedCardIds,
failuresByCardId = failuresByCardId,
partialMessage = "Some cards could not be moved. Please try again.",
)
}
suspend fun deleteCards(cardIds: Collection<String>): CardBatchMutationResult {
val normalizedCardIds = normalizeCardIds(cardIds)
if (normalizedCardIds.isEmpty()) {
return CardBatchMutationResult.Failure("At least one card id is required")
}
val session = when (val sessionResult = session()) {
is BoardsApiResult.Success -> sessionResult.value
is BoardsApiResult.Failure -> return CardBatchMutationResult.Failure(sessionResult.message)
}
val failuresByCardId = linkedMapOf<String, String>()
normalizedCardIds.forEach { cardId ->
when (val result = apiClient.deleteCard(session.baseUrl, session.apiKey, cardId)) {
is BoardsApiResult.Success -> Unit
is BoardsApiResult.Failure -> failuresByCardId[cardId] = result.message
}
}
return aggregateBatchMutationResult(
normalizedCardIds = normalizedCardIds,
failuresByCardId = failuresByCardId,
partialMessage = "Some cards could not be deleted. Please try again.",
)
}
private fun normalizeCardIds(cardIds: Collection<String>): List<String> {
return cardIds.map { it.trim() }
.filter { it.isNotBlank() }
.distinct()
}
private fun aggregateBatchMutationResult(
normalizedCardIds: List<String>,
failuresByCardId: Map<String, String>,
partialMessage: String,
): CardBatchMutationResult {
if (failuresByCardId.isEmpty()) {
return CardBatchMutationResult.Success
}
if (failuresByCardId.size == normalizedCardIds.size) {
val firstFailureMessage = normalizedCardIds
.asSequence()
.mapNotNull { failuresByCardId[it] }
.firstOrNull()
?.trim()
.orEmpty()
.ifBlank { "Unknown error" }
return CardBatchMutationResult.Failure(firstFailureMessage)
}
return CardBatchMutationResult.PartialSuccess(
failedCardIds = failuresByCardId.keys.toSet(),
message = partialMessage,
)
}
private suspend fun session(): BoardsApiResult<SessionSnapshot> {
val baseUrl = sessionStore.getBaseUrl()?.takeIf { it.isNotBlank() }
?: return BoardsApiResult.Failure(MISSING_SESSION_MESSAGE)
val apiKey = withContext(ioDispatcher) {
apiKeyStore.getApiKey(baseUrl)
}.getOrNull()?.takeIf { it.isNotBlank() }
?: return BoardsApiResult.Failure(MISSING_SESSION_MESSAGE)
val workspaceId = when (val workspaceResult = resolveWorkspaceId(baseUrl = baseUrl, apiKey = apiKey)) {
is BoardsApiResult.Success -> workspaceResult.value
is BoardsApiResult.Failure -> return workspaceResult
}
return BoardsApiResult.Success(
SessionSnapshot(baseUrl = baseUrl, apiKey = apiKey, workspaceId = workspaceId),
)
}
private suspend fun resolveWorkspaceId(baseUrl: String, apiKey: String): BoardsApiResult<String> {
val storedWorkspaceId = sessionStore.getWorkspaceId()?.takeIf { it.isNotBlank() }
if (storedWorkspaceId != null) {
return BoardsApiResult.Success(storedWorkspaceId)
}
return when (val workspacesResult = apiClient.listWorkspaces(baseUrl, apiKey)) {
is BoardsApiResult.Success -> {
val first = workspacesResult.value.firstOrNull()?.id
?: return BoardsApiResult.Failure("No workspaces available for this account.")
sessionStore.saveWorkspaceId(first)
BoardsApiResult.Success(first)
}
is BoardsApiResult.Failure -> workspacesResult
}
}
private data class SessionSnapshot(
val baseUrl: String,
val apiKey: String,
@Suppress("unused")
val workspaceId: String,
)
}

View File

@@ -0,0 +1,395 @@
package space.hackenslacker.kanbn4droid.app.boarddetail
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
data class BoardDetailUiState(
val isInitialLoading: Boolean = false,
val isRefreshing: Boolean = false,
val isMutating: Boolean = false,
val boardDetail: BoardDetail? = null,
val fullScreenErrorMessage: String? = null,
val currentPageIndex: Int = 0,
val selectedCardIds: Set<String> = emptySet(),
val editingListId: String? = null,
val editingListTitle: String = "",
)
sealed interface BoardDetailUiEvent {
data class NavigateToCardPlaceholder(val cardId: String) : BoardDetailUiEvent
data class ShowServerError(val message: String) : BoardDetailUiEvent
data class ShowWarning(val message: String) : BoardDetailUiEvent
}
interface BoardDetailDataSource {
suspend fun getBoardDetail(boardId: String): BoardsApiResult<BoardDetail>
suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult
suspend fun deleteCards(cardIds: Collection<String>): CardBatchMutationResult
suspend fun renameList(listId: String, newTitle: String): BoardsApiResult<Unit>
}
internal class BoardDetailRepositoryDataSource(
private val repository: BoardDetailRepository,
) : BoardDetailDataSource {
override suspend fun getBoardDetail(boardId: String): BoardsApiResult<BoardDetail> {
return repository.getBoardDetail(boardId)
}
override suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult {
return repository.moveCards(cardIds, targetListId)
}
override suspend fun deleteCards(cardIds: Collection<String>): CardBatchMutationResult {
return repository.deleteCards(cardIds)
}
override suspend fun renameList(listId: String, newTitle: String): BoardsApiResult<Unit> {
return repository.renameList(listId, newTitle)
}
}
class BoardDetailViewModel(
private val boardId: String,
private val repository: BoardDetailDataSource,
) : ViewModel() {
private val _uiState = MutableStateFlow(BoardDetailUiState())
val uiState: StateFlow<BoardDetailUiState> = _uiState.asStateFlow()
private val _events = MutableSharedFlow<BoardDetailUiEvent>()
val events: SharedFlow<BoardDetailUiEvent> = _events.asSharedFlow()
fun loadBoardDetail() {
fetchBoardDetail(initial = true)
}
fun retryLoad() {
fetchBoardDetail(initial = true)
}
fun refreshBoardDetail() {
fetchBoardDetail(initial = false, refresh = true)
}
fun setCurrentPage(pageIndex: Int) {
_uiState.update {
it.copy(currentPageIndex = clampPageIndex(detail = it.boardDetail, pageIndex = pageIndex))
}
}
fun selectAllOnCurrentPage() {
val current = _uiState.value
val pageCards = current.boardDetail
?.lists
?.getOrNull(current.currentPageIndex)
?.cards
.orEmpty()
.map { it.id }
.toSet()
if (pageCards.isEmpty()) {
return
}
_uiState.update {
it.copy(selectedCardIds = it.selectedCardIds + pageCards)
}
}
fun onCardLongPressed(cardId: String) {
toggleCardSelection(cardId)
}
fun onCardTapped(cardId: String) {
val hasSelection = _uiState.value.selectedCardIds.isNotEmpty()
if (hasSelection) {
toggleCardSelection(cardId)
return
}
viewModelScope.launch {
_events.emit(BoardDetailUiEvent.NavigateToCardPlaceholder(cardId))
}
}
fun onBackPressed(): Boolean {
if (_uiState.value.selectedCardIds.isEmpty()) {
return false
}
_uiState.update { it.copy(selectedCardIds = emptySet()) }
return true
}
fun moveSelectedCards(targetListId: String) {
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() {
runMutation(selectedIds = _uiState.value.selectedCardIds, mutation = repository::deleteCards)
}
fun startEditingList(listId: String) {
val list = _uiState.value.boardDetail?.lists?.firstOrNull { it.id == listId } ?: return
_uiState.update {
it.copy(
editingListId = list.id,
editingListTitle = list.title,
)
}
}
fun updateEditingTitle(title: String) {
_uiState.update { it.copy(editingListTitle = title) }
}
fun submitRenameList() {
val snapshot = _uiState.value
if (snapshot.isMutating) {
return
}
val editingListId = snapshot.editingListId ?: return
val currentList = snapshot.boardDetail?.lists?.firstOrNull { it.id == editingListId } ?: return
val trimmedTitle = snapshot.editingListTitle.trim()
if (trimmedTitle == currentList.title.trim()) {
_uiState.update { it.copy(editingListId = null, editingListTitle = "") }
return
}
viewModelScope.launch {
_uiState.update { it.copy(isMutating = true) }
when (val result = repository.renameList(editingListId, trimmedTitle)) {
is BoardsApiResult.Success -> {
_uiState.update { it.copy(editingListId = null, editingListTitle = "") }
val reloadFailureMessage = tryReloadDetailAndReconcile()
_uiState.update { it.copy(isMutating = false) }
if (reloadFailureMessage != null) {
_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))
}
}
}
}
private fun fetchBoardDetail(initial: Boolean, refresh: Boolean = false) {
if (_uiState.value.isMutating) {
return
}
viewModelScope.launch {
_uiState.update {
it.copy(
isInitialLoading = initial && it.boardDetail == null,
isRefreshing = refresh,
fullScreenErrorMessage = if (initial && it.boardDetail == null) null else it.fullScreenErrorMessage,
)
}
when (val result = repository.getBoardDetail(boardId)) {
is BoardsApiResult.Success -> {
_uiState.update {
reconcileWithNewDetail(it, result.value).copy(
isInitialLoading = false,
isRefreshing = false,
fullScreenErrorMessage = null,
)
}
}
is BoardsApiResult.Failure -> {
_uiState.update {
if (it.boardDetail == null) {
it.copy(
isInitialLoading = false,
isRefreshing = false,
fullScreenErrorMessage = result.message,
)
} else {
it.copy(
isInitialLoading = false,
isRefreshing = false,
)
}
}
if (_uiState.value.boardDetail != null) {
_events.emit(BoardDetailUiEvent.ShowServerError(result.message))
}
}
}
}
}
private fun runMutation(
selectedIds: Set<String>,
mutation: suspend (Set<String>) -> CardBatchMutationResult,
) {
val preMutation = _uiState.value
if (preMutation.isMutating) {
return
}
if (preMutation.selectedCardIds.isEmpty() || selectedIds.isEmpty()) {
return
}
viewModelScope.launch {
_uiState.update { it.copy(isMutating = true) }
when (val result = mutation(selectedIds)) {
is CardBatchMutationResult.Success -> {
_uiState.update { it.copy(selectedCardIds = emptySet()) }
val reloadFailureMessage = tryReloadDetailAndReconcile()
_uiState.update { it.copy(isMutating = false) }
if (reloadFailureMessage != null) {
_events.emit(
BoardDetailUiEvent.ShowWarning(
"Changes applied, but refresh failed. Pull to refresh.",
),
)
}
}
is CardBatchMutationResult.PartialSuccess -> {
val reloadFailureMessage = tryReloadDetailAndReconcile()
if (reloadFailureMessage == null) {
val visibleIds = allVisibleCardIds(_uiState.value.boardDetail)
_uiState.update {
it.copy(selectedCardIds = result.failedCardIds.intersect(visibleIds))
}
_events.emit(BoardDetailUiEvent.ShowWarning(result.message))
} else {
_uiState.update {
it.copy(
selectedCardIds = preMutation.selectedCardIds,
currentPageIndex = preMutation.currentPageIndex,
)
}
_events.emit(
BoardDetailUiEvent.ShowWarning(
"Some changes were applied, but refresh failed. Pull to refresh.",
),
)
}
_uiState.update { it.copy(isMutating = false) }
}
is CardBatchMutationResult.Failure -> {
_uiState.update {
it.copy(
isMutating = false,
selectedCardIds = preMutation.selectedCardIds,
currentPageIndex = preMutation.currentPageIndex,
)
}
_events.emit(BoardDetailUiEvent.ShowServerError(result.message))
}
}
}
}
private suspend fun tryReloadDetailAndReconcile(): String? {
return when (val result = repository.getBoardDetail(boardId)) {
is BoardsApiResult.Success -> {
_uiState.update { reconcileWithNewDetail(it, result.value) }
null
}
is BoardsApiResult.Failure -> result.message
}
}
private fun toggleCardSelection(cardId: String) {
_uiState.update {
val next = it.selectedCardIds.toMutableSet()
if (!next.add(cardId)) {
next.remove(cardId)
}
it.copy(selectedCardIds = next)
}
}
private fun reconcileWithNewDetail(current: BoardDetailUiState, detail: BoardDetail): BoardDetailUiState {
val clampedPage = clampPageIndex(detail, current.currentPageIndex)
val visibleIds = allVisibleCardIds(detail)
val prunedSelection = current.selectedCardIds.intersect(visibleIds)
val hasEditedList = current.editingListId?.let { id -> detail.lists.any { it.id == id } } ?: false
return current.copy(
boardDetail = detail,
currentPageIndex = clampedPage,
selectedCardIds = prunedSelection,
editingListId = if (hasEditedList) current.editingListId else null,
editingListTitle = if (hasEditedList) current.editingListTitle else "",
)
}
private fun clampPageIndex(detail: BoardDetail?, pageIndex: Int): Int {
val lastIndex = detail?.lists?.lastIndex ?: -1
if (lastIndex < 0) {
return 0
}
return pageIndex.coerceIn(0, lastIndex)
}
private fun allVisibleCardIds(detail: BoardDetail?): Set<String> {
return detail?.lists
.orEmpty()
.flatMap { list -> list.cards }
.map { card -> card.id }
.toSet()
}
class Factory(
private val boardId: String,
private val repository: BoardDetailRepository,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(BoardDetailViewModel::class.java)) {
return BoardDetailViewModel(
boardId = boardId,
repository = BoardDetailRepositoryDataSource(repository),
) as T
}
throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
}
}
}

View File

@@ -0,0 +1,161 @@
package space.hackenslacker.kanbn4droid.app.boarddetail
import android.text.Editable
import android.text.TextWatcher
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.widget.EditText
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.textfield.TextInputLayout
import space.hackenslacker.kanbn4droid.app.R
class BoardListsPagerAdapter(
private val onListTitleClicked: (String) -> Unit,
private val onEditingTitleChanged: (String) -> Unit,
private val onSubmitEditingTitle: (String) -> Unit,
private val onCardClick: (BoardCardSummary) -> Unit,
private val onCardLongClick: (BoardCardSummary) -> Unit,
) : RecyclerView.Adapter<BoardListsPagerAdapter.ListPageViewHolder>() {
private var lists: List<BoardListDetail> = emptyList()
private var selectedCardIds: Set<String> = emptySet()
private var editingListId: String? = null
private var editingListTitle: String = ""
private var isMutating: Boolean = false
private var inlineEditErrorMessage: String? = null
fun submit(
lists: List<BoardListDetail>,
selectedCardIds: Set<String>,
editingListId: String?,
editingListTitle: String,
isMutating: Boolean,
inlineEditErrorMessage: String?,
) {
this.lists = lists
this.selectedCardIds = selectedCardIds
this.editingListId = editingListId
this.editingListTitle = editingListTitle
this.isMutating = isMutating
this.inlineEditErrorMessage = inlineEditErrorMessage
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListPageViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_board_list_page, parent, false)
return ListPageViewHolder(view, onListTitleClicked, onEditingTitleChanged, onSubmitEditingTitle, onCardClick, onCardLongClick)
}
override fun getItemCount(): Int = lists.size
override fun onBindViewHolder(holder: ListPageViewHolder, position: Int) {
val list = lists[position]
holder.bind(
list = list,
selectedCardIds = selectedCardIds,
isEditing = list.id == editingListId,
editingTitle = editingListTitle,
isMutating = isMutating,
inlineEditErrorMessage = inlineEditErrorMessage,
)
}
class ListPageViewHolder(
itemView: View,
private val onListTitleClicked: (String) -> Unit,
private val onEditingTitleChanged: (String) -> Unit,
private val onSubmitEditingTitle: (String) -> Unit,
onCardClick: (BoardCardSummary) -> Unit,
onCardLongClick: (BoardCardSummary) -> Unit,
) : RecyclerView.ViewHolder(itemView) {
private val listTitleText: TextView = itemView.findViewById(R.id.listTitleText)
private val listTitleInputLayout: TextInputLayout = itemView.findViewById(R.id.listTitleInputLayout)
private val listTitleEditInput: EditText = itemView.findViewById(R.id.listTitleEditInput)
private val cardsRecycler: RecyclerView = itemView.findViewById(R.id.listCardsRecycler)
private val emptyText: TextView = itemView.findViewById(R.id.listEmptyText)
private val cardsAdapter = CardsAdapter(onCardClick = onCardClick, onCardLongClick = onCardLongClick)
private var isBinding = false
private var attachedListId: String? = null
init {
cardsRecycler.adapter = cardsAdapter
listTitleEditInput.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
override fun afterTextChanged(editable: Editable?) {
if (isBinding) {
return
}
onEditingTitleChanged(editable?.toString().orEmpty())
}
})
listTitleEditInput.setOnEditorActionListener { _, actionId, event ->
val imeDone = actionId == EditorInfo.IME_ACTION_DONE
val enterKey = event?.keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_DOWN
if (imeDone || enterKey) {
onSubmitEditingTitle(listTitleEditInput.text?.toString().orEmpty())
true
} else {
false
}
}
listTitleEditInput.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus ->
if (isBinding || hasFocus) {
return@OnFocusChangeListener
}
if (listTitleInputLayout.visibility == View.VISIBLE) {
onSubmitEditingTitle(listTitleEditInput.text?.toString().orEmpty())
}
}
}
fun bind(
list: BoardListDetail,
selectedCardIds: Set<String>,
isEditing: Boolean,
editingTitle: String,
isMutating: Boolean,
inlineEditErrorMessage: String?,
) {
attachedListId = list.id
listTitleText.text = list.title
listTitleText.setOnClickListener { onListTitleClicked(list.id) }
cardsAdapter.submitCards(list.cards, selectedCardIds)
val hasCards = list.cards.isNotEmpty()
cardsRecycler.visibility = if (hasCards) View.VISIBLE else View.GONE
emptyText.visibility = if (hasCards) View.GONE else View.VISIBLE
isBinding = true
if (isEditing) {
listTitleText.visibility = View.GONE
listTitleInputLayout.visibility = View.VISIBLE
listTitleEditInput.isEnabled = !isMutating
if (listTitleEditInput.text?.toString() != editingTitle) {
listTitleEditInput.setText(editingTitle)
listTitleEditInput.setSelection(editingTitle.length)
}
listTitleInputLayout.error = inlineEditErrorMessage
if (!listTitleEditInput.hasFocus()) {
listTitleEditInput.requestFocus()
}
} else {
listTitleInputLayout.visibility = View.GONE
listTitleText.visibility = View.VISIBLE
listTitleInputLayout.error = null
}
isBinding = false
}
}
}

View File

@@ -0,0 +1,116 @@
package space.hackenslacker.kanbn4droid.app.boarddetail
import android.graphics.Color
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.card.MaterialCardView
import com.google.android.material.chip.Chip
import com.google.android.material.color.MaterialColors
import space.hackenslacker.kanbn4droid.app.R
import java.text.DateFormat as JavaDateFormat
import java.util.Date
class CardsAdapter(
private val onCardClick: (BoardCardSummary) -> Unit,
private val onCardLongClick: (BoardCardSummary) -> Unit,
) : RecyclerView.Adapter<CardsAdapter.CardViewHolder>() {
private var cards: List<BoardCardSummary> = emptyList()
private var selectedCardIds: Set<String> = emptySet()
fun submitCards(cards: List<BoardCardSummary>, selectedCardIds: Set<String>) {
this.cards = cards
this.selectedCardIds = selectedCardIds
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CardViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_board_card_detail, parent, false)
return CardViewHolder(view)
}
override fun onBindViewHolder(holder: CardViewHolder, position: Int) {
holder.bind(cards[position], selectedCardIds.contains(cards[position].id), onCardClick, onCardLongClick)
}
override fun getItemCount(): Int = cards.size
class CardViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val rootCard: MaterialCardView = itemView.findViewById(R.id.cardItemRoot)
private val titleText: TextView = itemView.findViewById(R.id.cardTitleText)
private val tagsContainer: LinearLayout = itemView.findViewById(R.id.cardTagsContainer)
private val dueDateText: TextView = itemView.findViewById(R.id.cardDueDateText)
fun bind(
card: BoardCardSummary,
isSelected: Boolean,
onCardClick: (BoardCardSummary) -> Unit,
onCardLongClick: (BoardCardSummary) -> Unit,
) {
titleText.text = card.title
bindTags(card.tags)
bindDueDate(card.dueAtEpochMillis)
rootCard.isChecked = isSelected
rootCard.strokeWidth = if (isSelected) 4 else 1
val strokeColor = if (isSelected) {
MaterialColors.getColor(rootCard, com.google.android.material.R.attr.colorPrimary, Color.BLUE)
} else {
MaterialColors.getColor(rootCard, com.google.android.material.R.attr.colorOnSurface, Color.DKGRAY)
}
rootCard.strokeColor = strokeColor
itemView.setOnClickListener { onCardClick(card) }
itemView.setOnLongClickListener {
onCardLongClick(card)
true
}
}
private fun bindTags(tags: List<BoardTagSummary>) {
tagsContainer.removeAllViews()
tagsContainer.visibility = if (tags.isEmpty()) View.GONE else View.VISIBLE
tags.forEach { tag ->
val chip = Chip(itemView.context)
chip.text = tag.name
chip.isClickable = false
chip.isCheckable = false
chip.chipBackgroundColor = null
chip.chipStrokeWidth = 2f
chip.chipStrokeColor = android.content.res.ColorStateList.valueOf(parseColorOrFallback(tag.colorHex))
tagsContainer.addView(chip)
}
}
private fun bindDueDate(dueAtEpochMillis: Long?) {
if (dueAtEpochMillis == null) {
dueDateText.visibility = View.GONE
dueDateText.text = ""
return
}
val isExpired = dueAtEpochMillis < System.currentTimeMillis()
val color = if (isExpired) {
MaterialColors.getColor(dueDateText, com.google.android.material.R.attr.colorError, Color.RED)
} else {
MaterialColors.getColor(dueDateText, com.google.android.material.R.attr.colorOnSurface, Color.BLACK)
}
dueDateText.setTextColor(color)
val formatted = JavaDateFormat.getDateInstance(JavaDateFormat.MEDIUM, java.util.Locale.getDefault())
.format(Date(dueAtEpochMillis))
dueDateText.text = formatted
dueDateText.visibility = View.VISIBLE
}
private fun parseColorOrFallback(colorHex: String): Int {
return runCatching { Color.parseColor(colorHex) }
.getOrElse {
MaterialColors.getColor(itemView, com.google.android.material.R.attr.colorOnSurface, Color.DKGRAY)
}
}
}
}

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M9,3h6l1,1h4v2h-16v-2h4zM6,8h12l-1,12h-10zM9,10v8h2v-8zM13,10v8h2v-8z" />
</vector>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M7.5,6l-4.5,6l4.5,6v-4h9v4l4.5,-6l-4.5,-6v4h-9z" />
</vector>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M3,3h4v4h-4zM8.5,3h4v4h-4zM14,3h4v4h-4zM19.5,3h1.5v4h-1.5zM3,8.5h4v4h-4zM8.5,8.5h4v4h-4zM14,8.5h4v4h-4zM19.5,8.5h1.5v4h-1.5zM3,14h4v4h-4zM8.5,14h4v4h-4zM14,14h4v4h-4zM19.5,14h1.5v4h-1.5zM3,19.5h4v1.5h-4zM8.5,19.5h4v1.5h-4zM14,19.5h4v1.5h-4zM19.5,19.5h1.5v1.5h-1.5z" />
</vector>

View File

@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/boardDetailToolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:titleTextAppearance="@style/TextAppearance.Material3.TitleLarge" />
</com.google.android.material.appbar.AppBarLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/boardDetailPager"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
<TextView
android:id="@+id/boardDetailEmptyBoardText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:padding="24dp"
android:text="@string/board_detail_empty_board"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
android:visibility="gone" />
<ProgressBar
android:id="@+id/boardDetailInitialProgress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
<LinearLayout
android:id="@+id/boardDetailFullScreenErrorContainer"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:padding="24dp"
android:visibility="gone">
<TextView
android:id="@+id/boardDetailFullScreenErrorText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
<com.google.android.material.button.MaterialButton
android:id="@+id/boardDetailRetryButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/retry" />
</LinearLayout>
</FrameLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="24dp">
<TextView
android:id="@+id/cardDetailPlaceholderTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline5"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/cardDetailPlaceholderSubtitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:gravity="center"
android:text="@string/card_detail_placeholder_subtitle"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/cardDetailPlaceholderTitle" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/cardItemRoot"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
app:cardCornerRadius="16dp"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="14dp">
<TextView
android:id="@+id/cardTitleText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
android:textStyle="bold" />
<LinearLayout
android:id="@+id/cardTagsContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal" />
<TextView
android:id="@+id/cardDueDateText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:visibility="gone" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View File

@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingHorizontal="16dp"
android:paddingTop="12dp"
android:paddingBottom="16dp">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/listTitleInputLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
app:hintEnabled="false">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/listTitleEditInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/list_title_hint"
android:imeOptions="actionDone"
android:inputType="textCapSentences|text"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:id="@+id/listTitleText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:paddingVertical="8dp"
android:textAppearance="@style/TextAppearance.Material3.TitleLarge"
android:textStyle="bold" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/listCardsRecycler"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:clipToPadding="false"
android:paddingBottom="8dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
<TextView
android:id="@+id/listEmptyText"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:gravity="center"
android:padding="12dp"
android:text="@string/board_detail_empty_list"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
android:visibility="gone" />
</LinearLayout>

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/actionSelectAll"
android:icon="@drawable/ic_select_all_grid_24"
android:title="@string/select_all"
app:showAsAction="always" />
<item
android:id="@+id/actionMoveCards"
android:icon="@drawable/ic_move_cards_horizontal_24"
android:title="@string/move_cards"
app:showAsAction="always" />
<item
android:id="@+id/actionDeleteCards"
android:icon="@drawable/ic_delete_24"
android:title="@string/delete_cards"
app:showAsAction="always" />
</menu>

View File

@@ -32,4 +32,22 @@
<string name="network_unreachable">Cannot reach server. Check your connection and URL.</string>
<string name="auth_failed">Authentication failed. Check your API key.</string>
<string name="unexpected_error">Unexpected error. Please try again.</string>
<string name="board_detail_empty_board">No lists yet.</string>
<string name="board_detail_empty_list">No cards in this list.</string>
<string name="retry">Retry</string>
<string name="list_title_hint">List title</string>
<string name="list_title_required">List title is required</string>
<string name="select_all">Select all</string>
<string name="move_cards">Move 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="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="board_detail_card_detail_coming_soon">Card detail view is coming soon.</string>
<string name="card_detail_placeholder_title">%1$s\n(id: %2$s)</string>
<string name="card_detail_placeholder_fallback_title">Card</string>
<string name="card_detail_placeholder_subtitle">Card detail view is coming soon.</string>
<string name="board_detail_unable_to_open_board">Unable to open board.</string>
<string name="board_detail_session_expired">Session expired. Please sign in again.</string>
</resources>

View File

@@ -0,0 +1,493 @@
package space.hackenslacker.kanbn4droid.app.auth
import java.io.BufferedInputStream
import java.io.OutputStream
import java.net.InetSocketAddress
import java.net.ServerSocket
import java.net.Socket
import java.time.Instant
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetail
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
class HttpKanbnApiClientBoardDetailParsingTest {
@Test
fun getBoardDetailParsesWrappedPayloadWithDueDateVariants() = runTest {
TestServer().use { server ->
server.register(
path = "/api/v1/boards/board-1",
status = 200,
responseBody =
"""
{
"data": {
"board": {
"public_id": "board-1",
"name": "Roadmap",
"items": [
{
"id": "list-1",
"name": "Todo",
"cards": [
{
"publicId": "card-iso",
"name": "ISO",
"tags": [{"id": "tag-1", "name": "Urgent", "color": "#FF0000"}],
"dueAt": "2026-01-05T08:30:00Z"
},
{
"public_id": "card-epoch-num",
"title": "EpochNum",
"labels": [{"public_id": "tag-2", "title": "Backend", "colorHex": "#00FF00"}],
"due": 1735689600000
}
]
},
{
"publicId": "list-2",
"title": "Doing",
"data": [
{
"id": "card-epoch-string",
"title": "EpochString",
"data": [{"id": "tag-3", "name": "Ops", "hex": "#0000FF"}],
"due_at": "1735689600123"
},
{
"id": "card-invalid",
"name": "Invalid",
"labels": [],
"dueDate": "not-a-date"
}
]
}
]
}
}
}
""".trimIndent(),
)
val client = HttpKanbnApiClient()
val result = client.getBoardDetail(server.baseUrl, "api-key", "board-1")
assertTrue(result is BoardsApiResult.Success<*>)
val detail = (result as BoardsApiResult.Success<*>).value as BoardDetail
assertEquals("board-1", detail.id)
assertEquals("Roadmap", detail.title)
assertEquals(2, detail.lists.size)
assertEquals("list-1", detail.lists[0].id)
assertEquals("Todo", detail.lists[0].title)
assertEquals("card-iso", detail.lists[0].cards[0].id)
assertEquals(Instant.parse("2026-01-05T08:30:00Z").toEpochMilli(), detail.lists[0].cards[0].dueAtEpochMillis)
assertEquals(1735689600000L, detail.lists[0].cards[1].dueAtEpochMillis)
assertEquals("tag-1", detail.lists[0].cards[0].tags[0].id)
assertEquals("Urgent", detail.lists[0].cards[0].tags[0].name)
assertEquals("#FF0000", detail.lists[0].cards[0].tags[0].colorHex)
assertEquals(1735689600123L, detail.lists[1].cards[0].dueAtEpochMillis)
assertNull(detail.lists[1].cards[1].dueAtEpochMillis)
}
}
@Test
fun getBoardDetailParsesDirectRootObject() = runTest {
TestServer().use { server ->
server.register(
path = "/api/v1/boards/b-2",
status = 200,
responseBody =
"""
{
"id": "b-2",
"title": "Board Direct",
"lists": [
{
"id": "l-1",
"title": "List",
"cards": [
{
"id": "c-1",
"title": "Card",
"labels": [{"id": "t-1", "name": "Tag", "color": "#111111"}]
}
]
}
]
}
""".trimIndent(),
)
val result = HttpKanbnApiClient().getBoardDetail(server.baseUrl, "key", "b-2")
assertTrue(result is BoardsApiResult.Success<*>)
val detail = (result as BoardsApiResult.Success<*>).value as BoardDetail
assertEquals("b-2", detail.id)
assertEquals("Board Direct", detail.title)
assertEquals(1, detail.lists.size)
assertEquals("l-1", detail.lists[0].id)
assertEquals("c-1", detail.lists[0].cards[0].id)
assertEquals("t-1", detail.lists[0].cards[0].tags[0].id)
}
}
@Test
fun getBoardDetailParsesPlainDataWrapperWithoutBoardKey() = runTest {
TestServer().use { server ->
server.register(
path = "/api/v1/boards/data-only",
status = 200,
responseBody =
"""
{
"data": {
"id": "data-only",
"name": "Wrapped board",
"lists": [
{
"public_id": "list-9",
"name": "Queue",
"cards": [
{
"publicId": "card-99",
"name": "Card in data wrapper",
"labels": [
{"public_id": "tag-77", "title": "Infra", "color": "#ABCDEF"}
]
}
]
}
]
}
}
""".trimIndent(),
)
val result = HttpKanbnApiClient().getBoardDetail(server.baseUrl, "key", "data-only")
assertTrue(result is BoardsApiResult.Success<*>)
val detail = (result as BoardsApiResult.Success<*>).value as BoardDetail
assertEquals("data-only", detail.id)
assertEquals("Wrapped board", detail.title)
assertEquals(1, detail.lists.size)
assertEquals("list-9", detail.lists[0].id)
assertEquals("card-99", detail.lists[0].cards[0].id)
assertEquals("tag-77", detail.lists[0].cards[0].tags[0].id)
}
}
@Test
fun getBoardDetailFailsOnMalformedJsonPayload() = runTest {
TestServer().use { server ->
server.register(
path = "/api/v1/boards/malformed",
status = 200,
responseBody = "{\"data\": {\"id\": \"broken\"",
)
val result = HttpKanbnApiClient().getBoardDetail(server.baseUrl, "key", "malformed")
assertTrue(result is BoardsApiResult.Failure)
assertEquals(
"Malformed board detail response.",
(result as BoardsApiResult.Failure).message,
)
}
}
@Test
fun requestMappingMatchesContractForBoardDetailAndMutations() = runTest {
TestServer().use { server ->
server.register(
path = "/api/v1/boards/b-1",
status = 200,
responseBody = """{"id":"b-1","title":"Board","lists":[]}""",
)
server.register(path = "/api/v1/lists/l-1", status = 200, responseBody = "{}")
server.register(path = "/api/v1/cards/c-1", status = 200, responseBody = "{}")
server.register(path = "/api/v1/cards/c-2", status = 200, responseBody = "{}")
val client = HttpKanbnApiClient()
val boardResult = client.getBoardDetail(server.baseUrl, "api-123", "b-1")
val renameResult = client.renameList(server.baseUrl, "api-123", "l-1", " New title ")
val moveResult = client.moveCard(server.baseUrl, "api-123", "c-1", "l-9")
val deleteResult = client.deleteCard(server.baseUrl, "api-123", "c-2")
assertTrue(boardResult is BoardsApiResult.Success<*>)
assertTrue(renameResult is BoardsApiResult.Success<*>)
assertTrue(moveResult is BoardsApiResult.Success<*>)
assertTrue(deleteResult is BoardsApiResult.Success<*>)
val boardRequest = server.findRequest("GET", "/api/v1/boards/b-1")
assertNotNull(boardRequest)
assertEquals("api-123", boardRequest?.apiKey)
val renameRequest = server.findRequest("PATCH", "/api/v1/lists/l-1")
assertNotNull(renameRequest)
assertEquals("{\"name\":\"New title\"}", renameRequest?.body)
assertEquals("api-123", renameRequest?.apiKey)
val moveRequest = server.findRequest("PATCH", "/api/v1/cards/c-1")
assertNotNull(moveRequest)
assertEquals("{\"listId\":\"l-9\"}", moveRequest?.body)
assertEquals("api-123", moveRequest?.apiKey)
val deleteRequest = server.findRequest("DELETE", "/api/v1/cards/c-2")
assertNotNull(deleteRequest)
assertEquals("api-123", deleteRequest?.apiKey)
}
}
@Test
fun renameListEscapesControlCharactersInRequestBody() = runTest {
TestServer().use { server ->
server.register(path = "/api/v1/lists/l-esc", status = 200, responseBody = "{}")
val raw = "name\u0000line\u001f\n\tend"
val result = HttpKanbnApiClient().renameList(server.baseUrl, "api", "l-esc", raw)
assertTrue(result is BoardsApiResult.Success<*>)
val request = server.findRequest("PATCH", "/api/v1/lists/l-esc")
assertNotNull(request)
assertEquals(
"{\"name\":\"name\\u0000line\\u001f\\n\\tend\"}",
request?.body,
)
}
}
@Test
fun serverMessageIsPropagatedWithFallbackWhenMissing() = runTest {
TestServer().use { server ->
server.register(
path = "/api/v1/lists/l-error",
status = 400,
responseBody = """{"message":"List is locked"}""",
)
server.register(
path = "/api/v1/lists/l-fallback",
status = 503,
responseBody = "{}",
)
val client = HttpKanbnApiClient()
val messageResult = client.renameList(server.baseUrl, "api", "l-error", "Name")
val fallbackResult = client.renameList(server.baseUrl, "api", "l-fallback", "Name")
assertTrue(messageResult is BoardsApiResult.Failure)
assertEquals("List is locked", (messageResult as BoardsApiResult.Failure).message)
assertTrue(fallbackResult is BoardsApiResult.Failure)
assertEquals("Server error: 503", (fallbackResult as BoardsApiResult.Failure).message)
}
}
@Test
fun moveCardFailureUsesServerMessageAndFallback() = runTest {
TestServer().use { server ->
server.register(
path = "/api/v1/cards/c-msg",
status = 409,
responseBody = """{"error":"Card cannot be moved"}""",
)
server.register(
path = "/api/v1/cards/c-fallback",
status = 500,
responseBody = "{}",
)
val client = HttpKanbnApiClient()
val messageResult = client.moveCard(server.baseUrl, "api", "c-msg", "l-1")
val fallbackResult = client.moveCard(server.baseUrl, "api", "c-fallback", "l-1")
assertTrue(messageResult is BoardsApiResult.Failure)
assertEquals("Card cannot be moved", (messageResult as BoardsApiResult.Failure).message)
assertTrue(fallbackResult is BoardsApiResult.Failure)
assertEquals("Server error: 500", (fallbackResult as BoardsApiResult.Failure).message)
}
}
@Test
fun deleteCardFailureUsesServerMessageAndFallback() = runTest {
TestServer().use { server ->
server.register(
path = "/api/v1/cards/c-del-msg",
status = 403,
responseBody = """{"detail":"No permission to delete card"}""",
)
server.register(
path = "/api/v1/cards/c-del-fallback",
status = 502,
responseBody = "[]",
)
val client = HttpKanbnApiClient()
val messageResult = client.deleteCard(server.baseUrl, "api", "c-del-msg")
val fallbackResult = client.deleteCard(server.baseUrl, "api", "c-del-fallback")
assertTrue(messageResult is BoardsApiResult.Failure)
assertEquals("No permission to delete card", (messageResult as BoardsApiResult.Failure).message)
assertTrue(fallbackResult is BoardsApiResult.Failure)
assertEquals("Server error: 502", (fallbackResult as BoardsApiResult.Failure).message)
}
}
private data class CapturedRequest(
val method: String,
val path: String,
val body: String,
val apiKey: String?,
)
private class TestServer : AutoCloseable {
private val requests = CopyOnWriteArrayList<CapturedRequest>()
private val responses = mutableMapOf<String, Pair<Int, String>>()
private val running = AtomicBoolean(true)
private val serverSocket = ServerSocket().apply {
bind(InetSocketAddress("127.0.0.1", 0))
}
private val executor = Executors.newSingleThreadExecutor()
val baseUrl: String = "http://127.0.0.1:${serverSocket.localPort}"
init {
executor.execute {
while (running.get()) {
val socket = try {
serverSocket.accept()
} catch (_: Throwable) {
if (!running.get()) {
break
}
continue
}
handle(socket)
}
}
}
fun register(path: String, status: Int, responseBody: String) {
responses["GET $path"] = status to responseBody
responses["PATCH $path"] = status to responseBody
responses["DELETE $path"] = status to responseBody
responses["POST $path"] = status to responseBody
}
fun findRequest(method: String, path: String): CapturedRequest? {
return requests.firstOrNull { it.method == method && it.path == path }
}
private fun handle(socket: Socket) {
socket.use { s ->
s.soTimeout = 3_000
val input = BufferedInputStream(s.getInputStream())
val output = s.getOutputStream()
val requestLine = readHttpLine(input).orEmpty()
if (requestLine.isBlank()) {
return
}
val parts = requestLine.split(" ")
val method = parts.getOrNull(0).orEmpty()
val path = parts.getOrNull(1).orEmpty()
var apiKey: String? = null
var contentLength = 0
var methodOverride: String? = null
while (true) {
val line = readHttpLine(input).orEmpty()
if (line.isBlank()) {
break
}
val separatorIndex = line.indexOf(':')
if (separatorIndex <= 0) {
continue
}
val headerName = line.substring(0, separatorIndex).trim().lowercase()
val headerValue = line.substring(separatorIndex + 1).trim()
if (headerName == "x-api-key") {
apiKey = headerValue
} else if (headerName == "x-http-method-override") {
methodOverride = headerValue
} else if (headerName == "content-length") {
contentLength = headerValue.toIntOrNull() ?: 0
}
}
val bodyBytes = if (contentLength > 0) ByteArray(contentLength) else ByteArray(0)
if (contentLength > 0) {
var total = 0
while (total < contentLength) {
val read = input.read(bodyBytes, total, contentLength - total)
if (read <= 0) {
break
}
total += read
}
}
val body = String(bodyBytes)
val effectiveMethod = methodOverride ?: method
requests += CapturedRequest(method = effectiveMethod, path = path, body = body, apiKey = apiKey)
val response = responses["$effectiveMethod $path"] ?: responses["$method $path"] ?: (404 to "")
writeResponse(output, response.first, response.second)
}
}
private fun writeResponse(output: OutputStream, status: Int, body: String) {
val bytes = body.toByteArray()
val reason = when (status) {
200 -> "OK"
400 -> "Bad Request"
403 -> "Forbidden"
409 -> "Conflict"
404 -> "Not Found"
500 -> "Internal Server Error"
502 -> "Bad Gateway"
503 -> "Service Unavailable"
else -> "Error"
}
val responseHeaders =
"HTTP/1.1 $status $reason\r\n" +
"Content-Type: application/json\r\n" +
"Content-Length: ${bytes.size}\r\n" +
"Connection: close\r\n\r\n"
output.write(responseHeaders.toByteArray())
output.write(bytes)
output.flush()
}
private fun readHttpLine(input: BufferedInputStream): String? {
val builder = StringBuilder()
while (true) {
val next = input.read()
if (next == -1) {
return if (builder.isEmpty()) null else builder.toString()
}
if (next == '\n'.code) {
if (builder.isNotEmpty() && builder.last() == '\r') {
builder.deleteCharAt(builder.length - 1)
}
return builder.toString()
}
builder.append(next.toChar())
}
}
override fun close() {
running.set(false)
serverSocket.close()
executor.shutdownNow()
executor.awaitTermination(3, TimeUnit.SECONDS)
}
}
}

View File

@@ -0,0 +1,58 @@
package space.hackenslacker.kanbn4droid.app.boarddetail
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class BoardDetailModelsTest {
@Test
fun boardDetailModelsExposeRequiredFields() {
val tag = BoardTagSummary(
id = "tag-1",
name = "Urgent",
colorHex = "#FF0000",
)
val card = BoardCardSummary(
id = "card-1",
title = "Fix sync bug",
tags = listOf(tag),
dueAtEpochMillis = null,
)
val list = BoardListDetail(
id = "list-1",
title = "To Do",
cards = listOf(card),
)
val detail = BoardDetail(
id = "board-1",
title = "Sprint",
lists = listOf(list),
)
assertEquals("tag-1", tag.id)
assertEquals("Urgent", tag.name)
assertEquals("#FF0000", tag.colorHex)
assertEquals("card-1", card.id)
assertEquals("Fix sync bug", card.title)
assertEquals(listOf(tag), card.tags)
assertNull(card.dueAtEpochMillis)
assertEquals("list-1", list.id)
assertEquals("To Do", list.title)
assertEquals(listOf(card), list.cards)
assertEquals("board-1", detail.id)
assertEquals("Sprint", detail.title)
assertEquals(listOf(list), detail.lists)
}
@Test
fun partialSuccessCarriesFailedCardIds() {
val result = CardBatchMutationResult.PartialSuccess(
failedCardIds = setOf("card-2", "card-9"),
message = "Some cards could not be updated.",
)
assertEquals(setOf("card-2", "card-9"), result.failedCardIds)
assertEquals("Some cards could not be updated.", result.message)
}
}

View File

@@ -0,0 +1,468 @@
package space.hackenslacker.kanbn4droid.app.boarddetail
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
import space.hackenslacker.kanbn4droid.app.auth.AuthResult
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
import space.hackenslacker.kanbn4droid.app.boards.BoardSummary
import space.hackenslacker.kanbn4droid.app.boards.BoardTemplate
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
import space.hackenslacker.kanbn4droid.app.boards.WorkspaceSummary
class BoardDetailRepositoryTest {
@Test
fun getBoardDetailUsesStoredWorkspaceWhenPresent() = runTest {
val apiClient = FakeBoardDetailApiClient().apply {
boardDetailResult = BoardsApiResult.Success(sampleBoardDetail())
}
val sessionStore = InMemorySessionStore(baseUrl = "https://kan.bn/", workspaceId = "ws-stored")
val repository = createRepository(sessionStore = sessionStore, apiClient = apiClient)
val result = repository.getBoardDetail("board-1")
assertTrue(result is BoardsApiResult.Success<*>)
assertEquals(0, apiClient.listWorkspacesCalls)
assertEquals("board-1", apiClient.lastBoardId)
}
@Test
fun getBoardDetailFetchesAndPersistsWorkspaceWhenMissing() = runTest {
val apiClient = FakeBoardDetailApiClient().apply {
workspacesResult = BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
boardDetailResult = BoardsApiResult.Success(sampleBoardDetail())
}
val sessionStore = InMemorySessionStore(baseUrl = "https://kan.bn/")
val repository = createRepository(sessionStore = sessionStore, apiClient = apiClient)
val result = repository.getBoardDetail("board-1")
assertTrue(result is BoardsApiResult.Success<*>)
assertEquals(1, apiClient.listWorkspacesCalls)
assertEquals("ws-1", sessionStore.getWorkspaceId())
assertEquals("board-1", apiClient.lastBoardId)
}
@Test
fun getBoardDetailReusesPersistedWorkspaceAfterFirstFetch() = runTest {
val apiClient = FakeBoardDetailApiClient().apply {
workspacesResult = BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
boardDetailResult = BoardsApiResult.Success(sampleBoardDetail())
}
val sessionStore = InMemorySessionStore(baseUrl = "https://kan.bn/")
val repository = createRepository(sessionStore = sessionStore, apiClient = apiClient)
val firstResult = repository.getBoardDetail("board-1")
apiClient.workspacesResult = BoardsApiResult.Failure("Should not be called")
val secondResult = repository.getBoardDetail("board-2")
assertTrue(firstResult is BoardsApiResult.Success<*>)
assertTrue(secondResult is BoardsApiResult.Success<*>)
assertEquals("ws-1", sessionStore.getWorkspaceId())
assertEquals(1, apiClient.listWorkspacesCalls)
assertEquals("board-2", apiClient.lastBoardId)
}
@Test
fun getBoardDetailFailsWhenNoWorkspacesAvailable() = runTest {
val apiClient = FakeBoardDetailApiClient().apply {
workspacesResult = BoardsApiResult.Success(emptyList())
}
val repository = createRepository(
sessionStore = InMemorySessionStore(baseUrl = "https://kan.bn/"),
apiClient = apiClient,
)
val result = repository.getBoardDetail("board-1")
assertTrue(result is BoardsApiResult.Failure)
assertEquals("No workspaces available for this account.", (result as BoardsApiResult.Failure).message)
}
@Test
fun getBoardDetailPropagatesApiFailureMessageUnchanged() = runTest {
val apiClient = FakeBoardDetailApiClient().apply {
boardDetailResult = BoardsApiResult.Failure("Server says no")
}
val repository = createRepository(
sessionStore = InMemorySessionStore(baseUrl = "https://kan.bn/", workspaceId = "ws-1"),
apiClient = apiClient,
)
val result = repository.getBoardDetail("board-1")
assertTrue(result is BoardsApiResult.Failure)
assertEquals("Server says no", (result as BoardsApiResult.Failure).message)
}
@Test
fun renameListPropagatesApiFailureMessageUnchanged() = runTest {
val apiClient = FakeBoardDetailApiClient().apply {
renameListResult = BoardsApiResult.Failure("List cannot be renamed")
}
val repository = createRepository(
sessionStore = InMemorySessionStore(baseUrl = "https://kan.bn/", workspaceId = "ws-1"),
apiClient = apiClient,
)
val result = repository.renameList("list-1", "New title")
assertTrue(result is BoardsApiResult.Failure)
assertEquals("List cannot be renamed", (result as BoardsApiResult.Failure).message)
assertEquals("list-1", apiClient.lastListId)
assertEquals("New title", apiClient.lastListTitle)
}
@Test
fun getBoardDetailValidatesBoardId() = runTest {
val repository = createRepository()
val result = repository.getBoardDetail(" ")
assertTrue(result is BoardsApiResult.Failure)
assertEquals("Board id is required", (result as BoardsApiResult.Failure).message)
}
@Test
fun renameListValidatesListId() = runTest {
val repository = createRepository()
val result = repository.renameList(" ", "Some title")
assertTrue(result is BoardsApiResult.Failure)
assertEquals("List id is required", (result as BoardsApiResult.Failure).message)
}
@Test
fun renameListValidatesTitle() = runTest {
val repository = createRepository()
val result = repository.renameList("list-1", " ")
assertTrue(result is BoardsApiResult.Failure)
assertEquals("List title is required", (result as BoardsApiResult.Failure).message)
}
@Test
fun moveCardsValidatesTargetListId() = runTest {
val repository = createRepository()
val result = repository.moveCards(cardIds = listOf("card-1"), targetListId = " ")
assertTrue(result is CardBatchMutationResult.Failure)
assertEquals("Target list id is required", (result as CardBatchMutationResult.Failure).message)
}
@Test
fun moveCardsValidatesCardIds() = runTest {
val repository = createRepository()
val result = repository.moveCards(cardIds = listOf(" ", ""), targetListId = "list-2")
assertTrue(result is CardBatchMutationResult.Failure)
assertEquals("At least one card id is required", (result as CardBatchMutationResult.Failure).message)
}
@Test
fun deleteCardsValidatesCardIds() = runTest {
val repository = createRepository()
val result = repository.deleteCards(cardIds = listOf(" ", ""))
assertTrue(result is CardBatchMutationResult.Failure)
assertEquals("At least one card id is required", (result as CardBatchMutationResult.Failure).message)
}
@Test
fun moveCardsReturnsSuccessWhenAllMutationsSucceed() = runTest {
val apiClient = FakeBoardDetailApiClient().apply {
moveOutcomes = mapOf(
"card-1" to BoardsApiResult.Success(Unit),
"card-2" to BoardsApiResult.Success(Unit),
)
}
val repository = createRepository(apiClient = apiClient)
val result = repository.moveCards(
cardIds = listOf(" card-1 ", "", "card-1", "card-2"),
targetListId = " list-target ",
)
assertEquals(CardBatchMutationResult.Success, result)
assertEquals(listOf("card-1", "card-2"), apiClient.movedCardIds)
assertEquals("list-target", apiClient.lastMoveTargetListId)
}
@Test
fun moveCardsReturnsPartialSuccessWhenSomeMutationsFail() = runTest {
val apiClient = FakeBoardDetailApiClient().apply {
moveOutcomes = mapOf(
"card-1" to BoardsApiResult.Success(Unit),
"card-2" to BoardsApiResult.Failure("Cannot move"),
"card-3" to BoardsApiResult.Success(Unit),
)
}
val repository = createRepository(apiClient = apiClient)
val result = repository.moveCards(
cardIds = listOf("card-1", " card-2 ", "card-3", "card-2"),
targetListId = "list-target",
)
assertTrue(result is CardBatchMutationResult.PartialSuccess)
assertEquals(setOf("card-2"), (result as CardBatchMutationResult.PartialSuccess).failedCardIds)
assertEquals("Some cards could not be moved. Please try again.", result.message)
assertEquals(listOf("card-1", "card-2", "card-3"), apiClient.movedCardIds)
}
@Test
fun moveCardsReturnsFailureWithFirstNormalizedMessageWhenAllFail() = runTest {
val apiClient = FakeBoardDetailApiClient().apply {
moveOutcomes = mapOf(
"card-2" to BoardsApiResult.Failure(" "),
"card-1" to BoardsApiResult.Failure("Second failure"),
)
}
val repository = createRepository(apiClient = apiClient)
val result = repository.moveCards(
cardIds = listOf(" card-2 ", "card-1", "card-2"),
targetListId = "list-target",
)
assertTrue(result is CardBatchMutationResult.Failure)
assertEquals("Unknown error", (result as CardBatchMutationResult.Failure).message)
assertEquals(listOf("card-2", "card-1"), apiClient.movedCardIds)
}
@Test
fun deleteCardsReturnsSuccessWhenAllMutationsSucceed() = runTest {
val apiClient = FakeBoardDetailApiClient().apply {
deleteOutcomes = mapOf(
"card-1" to BoardsApiResult.Success(Unit),
"card-2" to BoardsApiResult.Success(Unit),
)
}
val repository = createRepository(apiClient = apiClient)
val result = repository.deleteCards(cardIds = listOf("card-1", " card-2 ", "card-1"))
assertEquals(CardBatchMutationResult.Success, result)
assertEquals(listOf("card-1", "card-2"), apiClient.deletedCardIds)
}
@Test
fun deleteCardsReturnsPartialSuccessWhenSomeMutationsFail() = runTest {
val apiClient = FakeBoardDetailApiClient().apply {
deleteOutcomes = mapOf(
"card-1" to BoardsApiResult.Success(Unit),
"card-2" to BoardsApiResult.Failure("Cannot delete"),
"card-3" to BoardsApiResult.Success(Unit),
)
}
val repository = createRepository(apiClient = apiClient)
val result = repository.deleteCards(cardIds = listOf("card-1", " card-2 ", "card-3", "card-2"))
assertTrue(result is CardBatchMutationResult.PartialSuccess)
assertEquals(setOf("card-2"), (result as CardBatchMutationResult.PartialSuccess).failedCardIds)
assertEquals("Some cards could not be deleted. Please try again.", result.message)
assertEquals(listOf("card-1", "card-2", "card-3"), apiClient.deletedCardIds)
}
@Test
fun deleteCardsReturnsFailureWithFirstNormalizedMessageWhenAllFail() = runTest {
val apiClient = FakeBoardDetailApiClient().apply {
deleteOutcomes = mapOf(
"card-2" to BoardsApiResult.Failure("Delete failed first"),
"card-1" to BoardsApiResult.Failure("Delete failed second"),
)
}
val repository = createRepository(apiClient = apiClient)
val result = repository.deleteCards(cardIds = listOf(" card-2 ", "card-1", "card-2"))
assertTrue(result is CardBatchMutationResult.Failure)
assertEquals("Delete failed first", (result as CardBatchMutationResult.Failure).message)
assertEquals(listOf("card-2", "card-1"), apiClient.deletedCardIds)
}
@Test
fun deleteCardsReturnsUnknownErrorWhenAllFailAndFirstMessageIsBlank() = runTest {
val apiClient = FakeBoardDetailApiClient().apply {
deleteOutcomes = mapOf(
"card-2" to BoardsApiResult.Failure(" "),
"card-1" to BoardsApiResult.Failure("Delete failed second"),
)
}
val repository = createRepository(apiClient = apiClient)
val result = repository.deleteCards(cardIds = listOf(" card-2 ", "card-1", "card-2"))
assertTrue(result is CardBatchMutationResult.Failure)
assertEquals("Unknown error", (result as CardBatchMutationResult.Failure).message)
assertEquals(listOf("card-2", "card-1"), apiClient.deletedCardIds)
}
private fun createRepository(
sessionStore: InMemorySessionStore = InMemorySessionStore(baseUrl = "https://kan.bn/", workspaceId = "ws-1"),
apiClient: FakeBoardDetailApiClient = FakeBoardDetailApiClient(),
apiKeyStore: InMemoryApiKeyStore = InMemoryApiKeyStore("api-key"),
): BoardDetailRepository {
return BoardDetailRepository(
sessionStore = sessionStore,
apiKeyStore = apiKeyStore,
apiClient = apiClient,
)
}
private class InMemorySessionStore(
private var baseUrl: String?,
private var workspaceId: String? = null,
) : SessionStore {
override fun getBaseUrl(): String? = baseUrl
override fun saveBaseUrl(url: String) {
baseUrl = url
}
override fun getWorkspaceId(): String? = workspaceId
override fun saveWorkspaceId(workspaceId: String) {
this.workspaceId = workspaceId
}
override fun clearBaseUrl() {
baseUrl = null
}
override fun clearWorkspaceId() {
workspaceId = null
}
}
private class InMemoryApiKeyStore(private var apiKey: String?) : ApiKeyStore {
override suspend fun saveApiKey(baseUrl: String, apiKey: String): Result<Unit> {
this.apiKey = apiKey
return Result.success(Unit)
}
override suspend fun getApiKey(baseUrl: String): Result<String?> = Result.success(apiKey)
override suspend fun invalidateApiKey(baseUrl: String): Result<Unit> {
apiKey = null
return Result.success(Unit)
}
}
private class FakeBoardDetailApiClient : KanbnApiClient {
var workspacesResult: BoardsApiResult<List<WorkspaceSummary>> =
BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
var boardDetailResult: BoardsApiResult<BoardDetail> = BoardsApiResult.Success(sampleBoardDetail())
var renameListResult: BoardsApiResult<Unit> = BoardsApiResult.Success(Unit)
var moveOutcomes: Map<String, BoardsApiResult<Unit>> = emptyMap()
var deleteOutcomes: Map<String, BoardsApiResult<Unit>> = emptyMap()
var listWorkspacesCalls: Int = 0
var lastBoardId: String? = null
var lastListId: String? = null
var lastListTitle: String? = null
var movedCardIds: MutableList<String> = mutableListOf()
var deletedCardIds: MutableList<String> = mutableListOf()
var lastMoveTargetListId: String? = null
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success
override suspend fun listWorkspaces(
baseUrl: String,
apiKey: String,
): BoardsApiResult<List<WorkspaceSummary>> {
listWorkspacesCalls += 1
return workspacesResult
}
override suspend fun getBoardDetail(
baseUrl: String,
apiKey: String,
boardId: String,
): BoardsApiResult<BoardDetail> {
lastBoardId = boardId
return boardDetailResult
}
override suspend fun renameList(
baseUrl: String,
apiKey: String,
listId: String,
newTitle: String,
): BoardsApiResult<Unit> {
lastListId = listId
lastListTitle = newTitle
return renameListResult
}
override suspend fun moveCard(
baseUrl: String,
apiKey: String,
cardId: String,
targetListId: String,
): BoardsApiResult<Unit> {
movedCardIds += cardId
lastMoveTargetListId = targetListId
return moveOutcomes[cardId] ?: BoardsApiResult.Success(Unit)
}
override suspend fun deleteCard(
baseUrl: String,
apiKey: String,
cardId: String,
): BoardsApiResult<Unit> {
deletedCardIds += cardId
return deleteOutcomes[cardId] ?: BoardsApiResult.Success(Unit)
}
override suspend fun listBoards(
baseUrl: String,
apiKey: String,
workspaceId: String,
): BoardsApiResult<List<BoardSummary>> {
return BoardsApiResult.Success(emptyList())
}
override suspend fun listBoardTemplates(
baseUrl: String,
apiKey: String,
workspaceId: String,
): BoardsApiResult<List<BoardTemplate>> {
return BoardsApiResult.Success(emptyList())
}
override suspend fun createBoard(
baseUrl: String,
apiKey: String,
workspaceId: String,
name: String,
templateId: String?,
): BoardsApiResult<BoardSummary> {
return BoardsApiResult.Success(BoardSummary("new", name))
}
override suspend fun deleteBoard(
baseUrl: String,
apiKey: String,
boardId: String,
): BoardsApiResult<Unit> {
return BoardsApiResult.Success(Unit)
}
}
private companion object {
fun sampleBoardDetail(): BoardDetail {
return BoardDetail(id = "board-1", title = "Board", lists = emptyList())
}
}
}

View File

@@ -0,0 +1,660 @@
package space.hackenslacker.kanbn4droid.app.boarddetail
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
@OptIn(ExperimentalCoroutinesApi::class)
class BoardDetailViewModelTest {
private val dispatcher = StandardTestDispatcher()
@Before
fun setUp() {
kotlinx.coroutines.Dispatchers.setMain(dispatcher)
}
@After
fun tearDown() {
kotlinx.coroutines.Dispatchers.resetMain()
}
@Test
fun selectAllIsAdditiveAcrossPages() = runTest {
val viewModel = newLoadedViewModel(
scope = this,
repository = FakeBoardDetailDataSource(
boardDetailResults = ArrayDeque(listOf(BoardsApiResult.Success(detailWithTwoLists()))),
),
)
viewModel.selectAllOnCurrentPage()
viewModel.setCurrentPage(1)
viewModel.selectAllOnCurrentPage()
assertEquals(setOf("card-1", "card-2", "card-3"), viewModel.uiState.value.selectedCardIds)
}
@Test
fun backPressClearsSelectionWhenSelectionActive() = runTest {
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithTwoLists())
viewModel.onCardLongPressed("card-1")
assertTrue(viewModel.onBackPressed())
assertTrue(viewModel.uiState.value.selectedCardIds.isEmpty())
}
@Test
fun cardTapWithoutSelectionEmitsNavigateEvent() = runTest {
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithTwoLists())
val eventDeferred = async { viewModel.events.first() }
viewModel.onCardTapped("card-1")
advanceUntilIdle()
val event = eventDeferred.await()
assertTrue(event is BoardDetailUiEvent.NavigateToCardPlaceholder)
assertEquals("card-1", (event as BoardDetailUiEvent.NavigateToCardPlaceholder).cardId)
}
@Test
fun reloadClampsPageIndex() = runTest {
val repository = FakeBoardDetailDataSource(
boardDetailResults = ArrayDeque(
listOf(
BoardsApiResult.Success(detailWithThreeLists()),
BoardsApiResult.Success(detailWithSingleList()),
),
),
)
val viewModel = newLoadedViewModel(this, repository)
viewModel.setCurrentPage(2)
viewModel.refreshBoardDetail()
advanceUntilIdle()
assertEquals(0, viewModel.uiState.value.currentPageIndex)
}
@Test
fun reloadPrunesSelectedIdsAgainstVisibleCards() = runTest {
val repository = FakeBoardDetailDataSource(
boardDetailResults = ArrayDeque(
listOf(
BoardsApiResult.Success(detailWithTwoLists()),
BoardsApiResult.Success(detailOnlyCardThree()),
),
),
)
val viewModel = newLoadedViewModel(this, repository)
viewModel.onCardLongPressed("card-1")
viewModel.onCardLongPressed("card-3")
viewModel.refreshBoardDetail()
advanceUntilIdle()
assertEquals(setOf("card-3"), viewModel.uiState.value.selectedCardIds)
}
@Test
fun reloadClearsEditStateWhenEditedListDisappears() = runTest {
val repository = FakeBoardDetailDataSource(
boardDetailResults = ArrayDeque(
listOf(
BoardsApiResult.Success(detailWithTwoLists()),
BoardsApiResult.Success(detailWithoutListOne()),
),
),
)
val viewModel = newLoadedViewModel(this, repository)
viewModel.startEditingList("list-1")
viewModel.refreshBoardDetail()
advanceUntilIdle()
assertNull(viewModel.uiState.value.editingListId)
assertEquals("", viewModel.uiState.value.editingListTitle)
}
@Test
fun initialLoadFailureShowsFullScreenErrorAndRetryRecovers() = runTest {
val repository = FakeBoardDetailDataSource(
boardDetailResults = ArrayDeque(
listOf(
BoardsApiResult.Failure("No network"),
BoardsApiResult.Success(detailWithSingleList()),
),
),
)
val viewModel = BoardDetailViewModel(boardId = "board-1", repository = repository)
viewModel.loadBoardDetail()
advanceUntilIdle()
assertNull(viewModel.uiState.value.boardDetail)
assertEquals("No network", viewModel.uiState.value.fullScreenErrorMessage)
viewModel.retryLoad()
advanceUntilIdle()
assertEquals("board-1", viewModel.uiState.value.boardDetail?.id)
assertNull(viewModel.uiState.value.fullScreenErrorMessage)
}
@Test
fun refreshFailureKeepsStaleContentAndEmitsServerError() = runTest {
val repository = FakeBoardDetailDataSource(
boardDetailResults = ArrayDeque(
listOf(
BoardsApiResult.Success(detailWithSingleList()),
BoardsApiResult.Failure("Refresh failed"),
),
),
)
val viewModel = newLoadedViewModel(this, repository)
val eventDeferred = async { viewModel.events.first() }
viewModel.refreshBoardDetail()
advanceUntilIdle()
assertEquals("board-1", viewModel.uiState.value.boardDetail?.id)
val event = eventDeferred.await()
assertTrue(event is BoardDetailUiEvent.ShowServerError)
assertEquals("Refresh failed", (event as BoardDetailUiEvent.ShowServerError).message)
}
@Test
fun mutationSuccessWithReloadSuccessClearsSelection() = runTest {
val repository = FakeBoardDetailDataSource(
boardDetailResults = ArrayDeque(
listOf(
BoardsApiResult.Success(detailWithTwoLists()),
BoardsApiResult.Success(detailWithTwoLists()),
),
),
moveCardsResult = CardBatchMutationResult.Success,
)
val viewModel = newLoadedViewModel(this, repository)
viewModel.onCardLongPressed("card-1")
viewModel.moveSelectedCards("list-2")
advanceUntilIdle()
assertTrue(viewModel.uiState.value.selectedCardIds.isEmpty())
}
@Test
fun mutationSuccessWithReloadFailureClearsSelectionAndWarns() = runTest {
val repository = FakeBoardDetailDataSource(
boardDetailResults = ArrayDeque(
listOf(
BoardsApiResult.Success(detailWithTwoLists()),
BoardsApiResult.Failure("Reload failed"),
),
),
moveCardsResult = CardBatchMutationResult.Success,
)
val viewModel = newLoadedViewModel(this, repository)
viewModel.onCardLongPressed("card-1")
val eventDeferred = async { viewModel.events.first() }
viewModel.moveSelectedCards("list-2")
advanceUntilIdle()
assertTrue(viewModel.uiState.value.selectedCardIds.isEmpty())
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 mutationPartialWithReloadSuccessReselectsFailedIdsStillVisibleAndWarns() = runTest {
val repository = FakeBoardDetailDataSource(
boardDetailResults = ArrayDeque(
listOf(
BoardsApiResult.Success(detailWithTwoLists()),
BoardsApiResult.Success(detailOnlyCardThree()),
),
),
moveCardsResult = CardBatchMutationResult.PartialSuccess(
failedCardIds = setOf("card-2", "card-3"),
message = "Some cards failed.",
),
)
val viewModel = newLoadedViewModel(this, repository)
viewModel.onCardLongPressed("card-1")
viewModel.onCardLongPressed("card-2")
viewModel.onCardLongPressed("card-3")
val eventDeferred = async { viewModel.events.first() }
viewModel.moveSelectedCards("list-2")
advanceUntilIdle()
assertEquals(setOf("card-3"), viewModel.uiState.value.selectedCardIds)
val event = eventDeferred.await()
assertTrue(event is BoardDetailUiEvent.ShowWarning)
assertEquals("Some cards failed.", (event as BoardDetailUiEvent.ShowWarning).message)
}
@Test
fun mutationPartialWithReloadFailurePreservesPreMutationSelectionAndWarns() = runTest {
val repository = FakeBoardDetailDataSource(
boardDetailResults = ArrayDeque(
listOf(
BoardsApiResult.Success(detailWithTwoLists()),
BoardsApiResult.Failure("Reload failed"),
),
),
moveCardsResult = CardBatchMutationResult.PartialSuccess(
failedCardIds = setOf("card-2"),
message = "Some cards failed.",
),
)
val viewModel = newLoadedViewModel(this, repository)
viewModel.onCardLongPressed("card-1")
viewModel.onCardLongPressed("card-2")
val eventDeferred = async { viewModel.events.first() }
viewModel.moveSelectedCards("list-2")
advanceUntilIdle()
assertEquals(setOf("card-1", "card-2"), viewModel.uiState.value.selectedCardIds)
val event = eventDeferred.await()
assertTrue(event is BoardDetailUiEvent.ShowWarning)
assertEquals(
"Some changes were applied, but refresh failed. Pull to refresh.",
(event as BoardDetailUiEvent.ShowWarning).message,
)
}
@Test
fun mutationFailurePreservesSelectionAndPageAndShowsServerError() = runTest {
val repository = FakeBoardDetailDataSource(
boardDetailResults = ArrayDeque(listOf(BoardsApiResult.Success(detailWithThreeLists()))),
moveCardsResult = CardBatchMutationResult.Failure("Cannot move"),
)
val viewModel = newLoadedViewModel(this, repository)
viewModel.setCurrentPage(2)
viewModel.onCardLongPressed("card-4")
val eventDeferred = async { viewModel.events.first() }
viewModel.moveSelectedCards("list-2")
advanceUntilIdle()
assertEquals(2, viewModel.uiState.value.currentPageIndex)
assertEquals(setOf("card-4"), viewModel.uiState.value.selectedCardIds)
val event = eventDeferred.await()
assertTrue(event is BoardDetailUiEvent.ShowServerError)
assertEquals("Cannot move", (event as BoardDetailUiEvent.ShowServerError).message)
}
@Test
fun deleteSuccessWithReloadSuccessClearsSelection() = runTest {
val repository = FakeBoardDetailDataSource(
boardDetailResults = ArrayDeque(
listOf(
BoardsApiResult.Success(detailWithTwoLists()),
BoardsApiResult.Success(detailWithTwoLists()),
),
),
deleteCardsResult = CardBatchMutationResult.Success,
)
val viewModel = newLoadedViewModel(this, repository)
viewModel.onCardLongPressed("card-1")
viewModel.deleteSelectedCards()
advanceUntilIdle()
assertTrue(viewModel.uiState.value.selectedCardIds.isEmpty())
}
@Test
fun deletePartialWithReloadSuccessReselectsFailedIdsStillVisibleAndWarns() = runTest {
val repository = FakeBoardDetailDataSource(
boardDetailResults = ArrayDeque(
listOf(
BoardsApiResult.Success(detailWithTwoLists()),
BoardsApiResult.Success(detailOnlyCardThree()),
),
),
deleteCardsResult = CardBatchMutationResult.PartialSuccess(
failedCardIds = setOf("card-2", "card-3"),
message = "Some cards could not be deleted.",
),
)
val viewModel = newLoadedViewModel(this, repository)
viewModel.onCardLongPressed("card-1")
viewModel.onCardLongPressed("card-2")
viewModel.onCardLongPressed("card-3")
val eventDeferred = async { viewModel.events.first() }
viewModel.deleteSelectedCards()
advanceUntilIdle()
assertEquals(setOf("card-3"), viewModel.uiState.value.selectedCardIds)
val event = eventDeferred.await()
assertTrue(event is BoardDetailUiEvent.ShowWarning)
assertEquals("Some cards could not be deleted.", (event as BoardDetailUiEvent.ShowWarning).message)
}
@Test
fun deleteFailurePreservesSelectionAndEmitsServerError() = runTest {
val repository = FakeBoardDetailDataSource(
boardDetailResults = ArrayDeque(listOf(BoardsApiResult.Success(detailWithTwoLists()))),
deleteCardsResult = CardBatchMutationResult.Failure("Delete blocked"),
)
val viewModel = newLoadedViewModel(this, repository)
viewModel.onCardLongPressed("card-1")
val eventDeferred = async { viewModel.events.first() }
viewModel.deleteSelectedCards()
advanceUntilIdle()
assertEquals(setOf("card-1"), viewModel.uiState.value.selectedCardIds)
val event = eventDeferred.await()
assertTrue(event is BoardDetailUiEvent.ShowServerError)
assertEquals("Delete blocked", (event as BoardDetailUiEvent.ShowServerError).message)
}
@Test
fun runMutationNoOpsWhenAlreadyMutating() = runTest {
val gate = CompletableDeferred<Unit>()
val repository = FakeBoardDetailDataSource(
boardDetailResults = ArrayDeque(
listOf(
BoardsApiResult.Success(detailWithTwoLists()),
BoardsApiResult.Success(detailWithTwoLists()),
),
),
moveCardsResult = CardBatchMutationResult.Success,
moveGate = gate,
)
val viewModel = newLoadedViewModel(this, repository)
viewModel.onCardLongPressed("card-1")
viewModel.moveSelectedCards("list-2")
advanceUntilIdle()
viewModel.moveSelectedCards("list-2")
gate.complete(Unit)
advanceUntilIdle()
assertEquals(1, repository.moveCalls)
}
@Test
fun renameNoOpSkipsApiCallAndExitsEditMode() = runTest {
val repository = FakeBoardDetailDataSource(
boardDetailResults = ArrayDeque(listOf(BoardsApiResult.Success(detailWithSingleList()))),
)
val viewModel = newLoadedViewModel(this, repository)
viewModel.startEditingList("list-1")
viewModel.updateEditingTitle(" To Do ")
viewModel.submitRenameList()
advanceUntilIdle()
assertEquals(0, repository.renameCalls)
assertNull(viewModel.uiState.value.editingListId)
}
@Test
fun renameSuccessReloadsExitsEditAndPreservesPageAndSelectionSemantics() = runTest {
val repository = FakeBoardDetailDataSource(
boardDetailResults = ArrayDeque(
listOf(
BoardsApiResult.Success(detailWithTwoLists()),
BoardsApiResult.Success(detailWithRenamedListOne()),
),
),
renameListResult = BoardsApiResult.Success(Unit),
)
val viewModel = newLoadedViewModel(this, repository)
viewModel.setCurrentPage(1)
viewModel.onCardLongPressed("card-3")
viewModel.startEditingList("list-1")
viewModel.updateEditingTitle("Renamed")
viewModel.submitRenameList()
advanceUntilIdle()
assertEquals(1, repository.renameCalls)
assertEquals("list-1", repository.lastRenameListId)
assertEquals("Renamed", repository.lastRenameTitle)
assertNull(viewModel.uiState.value.editingListId)
assertEquals(1, viewModel.uiState.value.currentPageIndex)
assertEquals(setOf("card-3"), viewModel.uiState.value.selectedCardIds)
assertEquals("Renamed", viewModel.uiState.value.boardDetail?.lists?.first()?.title)
}
@Test
fun renameFailureKeepsEditModeAndEmitsServerError() = runTest {
val repository = FakeBoardDetailDataSource(
boardDetailResults = ArrayDeque(listOf(BoardsApiResult.Success(detailWithSingleList()))),
renameListResult = BoardsApiResult.Failure("Rename rejected"),
)
val viewModel = newLoadedViewModel(this, repository)
val eventDeferred = async { viewModel.events.first() }
viewModel.startEditingList("list-1")
viewModel.updateEditingTitle("Renamed")
viewModel.submitRenameList()
advanceUntilIdle()
assertEquals("list-1", viewModel.uiState.value.editingListId)
val event = eventDeferred.await()
assertTrue(event is BoardDetailUiEvent.ShowServerError)
assertEquals("Rename rejected", (event as BoardDetailUiEvent.ShowServerError).message)
}
@Test
fun submitRenameNoOpsWhenAlreadyMutating() = runTest {
val gate = CompletableDeferred<Unit>()
val repository = FakeBoardDetailDataSource(
boardDetailResults = ArrayDeque(
listOf(
BoardsApiResult.Success(detailWithSingleList()),
BoardsApiResult.Success(detailWithSingleList()),
),
),
renameListResult = BoardsApiResult.Success(Unit),
renameGate = gate,
)
val viewModel = newLoadedViewModel(this, repository)
viewModel.startEditingList("list-1")
viewModel.updateEditingTitle("Renamed")
viewModel.submitRenameList()
advanceUntilIdle()
viewModel.submitRenameList()
gate.complete(Unit)
advanceUntilIdle()
assertEquals(1, repository.renameCalls)
}
private fun newLoadedViewModel(
scope: TestScope,
repository: FakeBoardDetailDataSource,
initialDetail: BoardDetail = detailWithTwoLists(),
): BoardDetailViewModel {
if (repository.boardDetailResults.isEmpty()) {
repository.boardDetailResults.add(BoardsApiResult.Success(initialDetail))
}
return BoardDetailViewModel(boardId = "board-1", repository = repository).also {
it.loadBoardDetail()
scope.advanceUntilIdle()
}
}
private class FakeBoardDetailDataSource(
val boardDetailResults: ArrayDeque<BoardsApiResult<BoardDetail>> = ArrayDeque(),
var moveCardsResult: CardBatchMutationResult = CardBatchMutationResult.Success,
var deleteCardsResult: CardBatchMutationResult = CardBatchMutationResult.Success,
var renameListResult: BoardsApiResult<Unit> = BoardsApiResult.Success(Unit),
var moveGate: CompletableDeferred<Unit>? = null,
var renameGate: CompletableDeferred<Unit>? = null,
) : BoardDetailDataSource {
var moveCalls: Int = 0
var deleteCalls: Int = 0
var renameCalls: Int = 0
var lastRenameListId: String? = null
var lastRenameTitle: String? = null
override suspend fun getBoardDetail(boardId: String): BoardsApiResult<BoardDetail> {
return if (boardDetailResults.isNotEmpty()) {
boardDetailResults.removeFirst()
} else {
BoardsApiResult.Success(detailWithSingleList())
}
}
override suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult {
moveCalls += 1
moveGate?.await()
return moveCardsResult
}
override suspend fun deleteCards(cardIds: Collection<String>): CardBatchMutationResult {
deleteCalls += 1
return deleteCardsResult
}
override suspend fun renameList(listId: String, newTitle: String): BoardsApiResult<Unit> {
renameCalls += 1
renameGate?.await()
lastRenameListId = listId
lastRenameTitle = newTitle
return renameListResult
}
}
private companion object {
fun detailWithSingleList(): 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)),
),
),
)
}
fun detailWithTwoLists(): 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-2", "Card 2", emptyList(), null),
),
),
BoardListDetail(
id = "list-2",
title = "Doing",
cards = listOf(BoardCardSummary("card-3", "Card 3", emptyList(), null)),
),
),
)
}
fun detailWithThreeLists(): 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-2",
title = "Doing",
cards = listOf(BoardCardSummary("card-3", "Card 3", emptyList(), null)),
),
BoardListDetail(
id = "list-3",
title = "Done",
cards = listOf(BoardCardSummary("card-4", "Card 4", emptyList(), null)),
),
),
)
}
fun detailOnlyCardThree(): BoardDetail {
return BoardDetail(
id = "board-1",
title = "Board",
lists = listOf(
BoardListDetail(
id = "list-2",
title = "Doing",
cards = listOf(BoardCardSummary("card-3", "Card 3", emptyList(), null)),
),
),
)
}
fun detailWithoutListOne(): BoardDetail {
return BoardDetail(
id = "board-1",
title = "Board",
lists = listOf(
BoardListDetail(
id = "list-2",
title = "Doing",
cards = listOf(BoardCardSummary("card-3", "Card 3", emptyList(), null)),
),
),
)
}
fun detailWithRenamedListOne(): BoardDetail {
return BoardDetail(
id = "board-1",
title = "Board",
lists = listOf(
BoardListDetail(
id = "list-1",
title = "Renamed",
cards = listOf(
BoardCardSummary("card-1", "Card 1", emptyList(), null),
BoardCardSummary("card-2", "Card 2", emptyList(), null),
),
),
BoardListDetail(
id = "list-2",
title = "Doing",
cards = listOf(BoardCardSummary("card-3", "Card 3", emptyList(), null)),
),
),
)
}
}
}