Merge branch 'feature/board-detail-view'
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12
app/src/main/res/drawable/ic_delete_24.xml
Normal file
12
app/src/main/res/drawable/ic_delete_24.xml
Normal 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>
|
||||
12
app/src/main/res/drawable/ic_move_cards_horizontal_24.xml
Normal file
12
app/src/main/res/drawable/ic_move_cards_horizontal_24.xml
Normal 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>
|
||||
12
app/src/main/res/drawable/ic_select_all_grid_24.xml
Normal file
12
app/src/main/res/drawable/ic_select_all_grid_24.xml
Normal 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>
|
||||
71
app/src/main/res/layout/activity_board_detail.xml
Normal file
71
app/src/main/res/layout/activity_board_detail.xml
Normal 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>
|
||||
30
app/src/main/res/layout/activity_card_detail_placeholder.xml
Normal file
30
app/src/main/res/layout/activity_card_detail_placeholder.xml
Normal 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>
|
||||
39
app/src/main/res/layout/item_board_card_detail.xml
Normal file
39
app/src/main/res/layout/item_board_card_detail.xml
Normal 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>
|
||||
57
app/src/main/res/layout/item_board_list_page.xml
Normal file
57
app/src/main/res/layout/item_board_list_page.xml
Normal 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>
|
||||
22
app/src/main/res/menu/menu_board_detail_selection.xml
Normal file
22
app/src/main/res/menu/menu_board_detail_selection.xml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user