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".
|
- 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".
|
- 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.
|
- 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**
|
**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".
|
- 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.
|
- 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.
|
- 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**
|
**Card detail view**
|
||||||
- The view shows the card's title in bold letters. Tapping on the card's title allows editing it.
|
- 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 "Add comment" as a title.
|
||||||
- The modal dialog has an editable markdown-enabled text field for the comment.
|
- 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.
|
- 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**
|
**Settings view**
|
||||||
- The view shows a list of settings that can be changed by the user. The following settings are available:
|
- 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
|
@Test
|
||||||
fun boardTapNavigatesToDetailPlaceholderWithExtras() {
|
fun boardTapNavigatesToBoardDetailActivity() {
|
||||||
MainActivity.dependencies.apiClientFactory = {
|
MainActivity.dependencies.apiClientFactory = {
|
||||||
FakeBoardsApiClient(
|
FakeBoardsApiClient(
|
||||||
boards = mutableListOf(BoardSummary("1", "Alpha")),
|
boards = mutableListOf(BoardSummary("1", "Alpha")),
|
||||||
@@ -58,9 +58,9 @@ class BoardsFlowTest {
|
|||||||
|
|
||||||
onView(withText("Alpha")).perform(click())
|
onView(withText("Alpha")).perform(click())
|
||||||
|
|
||||||
Intents.intended(hasComponent(BoardDetailPlaceholderActivity::class.java.name))
|
Intents.intended(hasComponent(space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailActivity::class.java.name))
|
||||||
Intents.intended(hasExtra(BoardDetailPlaceholderActivity.EXTRA_BOARD_ID, "1"))
|
Intents.intended(hasExtra(space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailActivity.EXTRA_BOARD_ID, "1"))
|
||||||
Intents.intended(hasExtra(BoardDetailPlaceholderActivity.EXTRA_BOARD_TITLE, "Alpha"))
|
Intents.intended(hasExtra(space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailActivity.EXTRA_BOARD_TITLE, "Alpha"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -79,8 +79,8 @@ class BoardsFlowTest {
|
|||||||
onView(withId(R.id.useTemplateChip)).perform(click())
|
onView(withId(R.id.useTemplateChip)).perform(click())
|
||||||
onView(withId(android.R.id.button1)).inRoot(isDialog()).perform(click())
|
onView(withId(android.R.id.button1)).inRoot(isDialog()).perform(click())
|
||||||
|
|
||||||
Intents.intended(hasComponent(BoardDetailPlaceholderActivity::class.java.name))
|
Intents.intended(hasComponent(space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailActivity::class.java.name))
|
||||||
Intents.intended(hasExtra(BoardDetailPlaceholderActivity.EXTRA_BOARD_TITLE, "Roadmap"))
|
Intents.intended(hasExtra(space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailActivity.EXTRA_BOARD_TITLE, "Roadmap"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -12,7 +12,10 @@
|
|||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
android:theme="@style/Theme.Kanbn4Droid">
|
android:theme="@style/Theme.Kanbn4Droid">
|
||||||
<activity
|
<activity
|
||||||
android:name=".BoardDetailPlaceholderActivity"
|
android:name=".CardDetailPlaceholderActivity"
|
||||||
|
android:exported="false" />
|
||||||
|
<activity
|
||||||
|
android:name=".boarddetail.BoardDetailActivity"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".BoardsActivity"
|
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.BoardsUiEvent
|
||||||
import space.hackenslacker.kanbn4droid.app.boards.BoardsUiState
|
import space.hackenslacker.kanbn4droid.app.boards.BoardsUiState
|
||||||
import space.hackenslacker.kanbn4droid.app.boards.BoardsViewModel
|
import space.hackenslacker.kanbn4droid.app.boards.BoardsViewModel
|
||||||
|
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailActivity
|
||||||
|
|
||||||
class BoardsActivity : AppCompatActivity() {
|
class BoardsActivity : AppCompatActivity() {
|
||||||
private lateinit var sessionStore: SessionStore
|
private lateinit var sessionStore: SessionStore
|
||||||
@@ -245,9 +246,9 @@ class BoardsActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
private fun navigateToBoard(board: BoardSummary) {
|
private fun navigateToBoard(board: BoardSummary) {
|
||||||
startActivity(
|
startActivity(
|
||||||
Intent(this, BoardDetailPlaceholderActivity::class.java)
|
Intent(this, BoardDetailActivity::class.java)
|
||||||
.putExtra(BoardDetailPlaceholderActivity.EXTRA_BOARD_ID, board.id)
|
.putExtra(BoardDetailActivity.EXTRA_BOARD_ID, board.id)
|
||||||
.putExtra(BoardDetailPlaceholderActivity.EXTRA_BOARD_TITLE, board.title),
|
.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.io.IOException
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
|
import java.time.Instant
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
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.BoardSummary
|
||||||
import space.hackenslacker.kanbn4droid.app.boards.BoardTemplate
|
import space.hackenslacker.kanbn4droid.app.boards.BoardTemplate
|
||||||
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
|
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
|
||||||
@@ -48,6 +53,22 @@ interface KanbnApiClient {
|
|||||||
suspend fun deleteBoard(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult<Unit> {
|
suspend fun deleteBoard(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult<Unit> {
|
||||||
return BoardsApiResult.Failure("Board deletion is not implemented.")
|
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 {
|
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(
|
private fun <T> request(
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
path: String,
|
path: String,
|
||||||
@@ -203,7 +314,7 @@ class HttpKanbnApiClient : KanbnApiClient {
|
|||||||
): BoardsApiResult<T> {
|
): BoardsApiResult<T> {
|
||||||
val endpoint = "${baseUrl.trimEnd('/')}$path"
|
val endpoint = "${baseUrl.trimEnd('/')}$path"
|
||||||
val connection = (URL(endpoint).openConnection() as HttpURLConnection).apply {
|
val connection = (URL(endpoint).openConnection() as HttpURLConnection).apply {
|
||||||
requestMethod = method
|
configureRequestMethod(this, method)
|
||||||
connectTimeout = 10_000
|
connectTimeout = 10_000
|
||||||
readTimeout = 10_000
|
readTimeout = 10_000
|
||||||
setRequestProperty("x-api-key", apiKey)
|
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 {
|
private fun readResponseBody(connection: HttpURLConnection, code: Int): String {
|
||||||
val stream = if (code in 200..299) connection.inputStream else connection.errorStream
|
val stream = if (code in 200..299) connection.inputStream else connection.errorStream
|
||||||
return stream?.bufferedReader()?.use { it.readText() }.orEmpty()
|
return stream?.bufferedReader()?.use { it.readText() }.orEmpty()
|
||||||
@@ -361,18 +484,306 @@ class HttpKanbnApiClient : KanbnApiClient {
|
|||||||
return workspaces
|
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 {
|
private fun serverMessage(body: String, code: Int): String {
|
||||||
if (body.isBlank()) {
|
if (body.isBlank()) {
|
||||||
return "Server error: $code"
|
return "Server error: $code"
|
||||||
}
|
}
|
||||||
|
|
||||||
return runCatching {
|
val root = parseJsonObject(body)
|
||||||
val root = JSONObject(body)
|
val message = root?.let { extractString(it, "message", "error", "cause", "detail") }.orEmpty()
|
||||||
listOf("message", "error", "cause", "detail")
|
return message.ifBlank { "Server error: $code" }
|
||||||
.firstNotNullOfOrNull { key -> root.optString(key).takeIf { it.isNotBlank() } }
|
|
||||||
?: "Server error: $code"
|
|
||||||
}.getOrElse {
|
|
||||||
"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="network_unreachable">Cannot reach server. Check your connection and URL.</string>
|
||||||
<string name="auth_failed">Authentication failed. Check your API key.</string>
|
<string name="auth_failed">Authentication failed. Check your API key.</string>
|
||||||
<string name="unexpected_error">Unexpected error. Please try again.</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>
|
</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