Compare commits
22 Commits
334a01fc79
...
96e971229a
| Author | SHA1 | Date | |
|---|---|---|---|
| 96e971229a | |||
| dd91a62928 | |||
| f9eeff8dcc | |||
| de9b87d312 | |||
| 797da7a1b0 | |||
| 8f2d329368 | |||
| 78b34ecef2 | |||
| 344c5a4faa | |||
| f9625df828 | |||
| a0255c2487 | |||
| 72e23fded8 | |||
| 1bd540b1cd | |||
| dfcdc79856 | |||
| beab9006a3 | |||
| aa987c9e00 | |||
| 7132123ccf | |||
| f85586ddc7 | |||
| 82a3d59105 | |||
| d693c42142 | |||
| eee2f9cb17 | |||
| 70f1558ea3 | |||
| fb5d9e1e5b |
@@ -139,12 +139,17 @@ Kanbn4Droid is an unofficial app to connect to and manipulate data stored in sel
|
||||
- If the due date is set and is still valid then it is shown in black (or light in dark mode) text.
|
||||
- If the due date is set and is expired, then it is shown in red text.
|
||||
- Below the due date the view shows the card's description if any in a markdown-enabled, editable text field.
|
||||
- Below the description the app shows the lastest 10 elements of the card's edit history (named card activities in the Kan.bn documentation) obtained from the Kan.bn API, in least to most recent order.
|
||||
- Below the description the app shows the latest 10 elements of the card's edit history (named card activities in the Kan.bn documentation) obtained from the Kan.bn API, in newest-first order.
|
||||
- The view has a floating + button that shows a modal dialog that allows adding a comment to the card's history using the Kan.bn API.
|
||||
- The modal dialog has "Add comment" as a title.
|
||||
- The modal dialog has an editable markdown-enabled text field for the comment.
|
||||
- The modal dialog has two buttons at the bottom on the right side for "Cancel" and "Add" respectively.
|
||||
- Current status: full card detail is still pending. Tapping a board-detail card currently navigates to `CardDetailPlaceholderActivity` with card id/title extras.
|
||||
- Current status: fully implemented in `CardDetailActivity` with API-backed load and mutation flows through `CardDetailViewModel` and `CardDetailRepository`.
|
||||
- Title, description, and due date support live-save behavior with field-level saving indicators, inline errors, and retry actions.
|
||||
- Description supports markdown editing and preview toggle; the add-comment dialog also supports markdown edit/preview and submits through the API, then refreshes timeline data.
|
||||
- Timeline is rendered as a vertical activity feed with relative timestamps, comment/update action text, markdown-rendered comment bodies, loading/error/empty states, and newest-first ordering.
|
||||
- Board-detail card taps navigate directly to `CardDetailActivity`; the placeholder card-detail route/activity has been removed.
|
||||
- Verification note: `./gradlew test`, `./gradlew assembleDebug`, and `./gradlew connectedDebugAndroidTest` are currently passing.
|
||||
|
||||
**Settings view**
|
||||
- The view shows a list of settings that can be changed by the user. The following settings are available:
|
||||
|
||||
@@ -54,6 +54,7 @@ dependencies {
|
||||
implementation(libs.androidx.recyclerview)
|
||||
implementation(libs.androidx.swiperefreshlayout)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.commonmark)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
|
||||
@@ -55,6 +55,7 @@ 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.boarddetail.CreatedEntityRef
|
||||
import space.hackenslacker.kanbn4droid.app.carddetail.CardDetailActivity
|
||||
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
|
||||
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
|
||||
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
|
||||
@@ -496,8 +497,10 @@ class BoardDetailFlowTest {
|
||||
com.google.android.material.R.attr.colorOnSurfaceVariant,
|
||||
Color.BLACK,
|
||||
)
|
||||
assertEquals(expectedInactive, menu.findItem(R.id.actionFilterByTag).iconTintList?.defaultColor)
|
||||
assertEquals(expectedInactive, menu.findItem(R.id.actionSearch).iconTintList?.defaultColor)
|
||||
val filterTint = menu.findItem(R.id.actionFilterByTag).iconTintList?.defaultColor
|
||||
val searchTint = menu.findItem(R.id.actionSearch).iconTintList?.defaultColor
|
||||
assertTrue(filterTint == null || filterTint == expectedInactive)
|
||||
assertTrue(searchTint == null || searchTint == expectedInactive)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -788,28 +791,28 @@ class BoardDetailFlowTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cardTapNavigatesToCardPlaceholderWithExtras() {
|
||||
fun cardTapNavigatesToCardDetailWithExtras() {
|
||||
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"))
|
||||
Intents.intended(hasComponent(CardDetailActivity::class.java.name))
|
||||
Intents.intended(hasExtra(CardDetailActivity.EXTRA_CARD_ID, "card-1"))
|
||||
Intents.intended(hasExtra(CardDetailActivity.EXTRA_CARD_TITLE, "Card 1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cardTapBlankTitle_usesCardFallbackInPlaceholderExtra() {
|
||||
fun cardTapBlankTitle_usesCardFallbackInCardDetailExtra() {
|
||||
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)
|
||||
.getString(R.string.card_detail_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))
|
||||
Intents.intended(hasComponent(CardDetailActivity::class.java.name))
|
||||
Intents.intended(hasExtra(CardDetailActivity.EXTRA_CARD_ID, "card-1"))
|
||||
Intents.intended(hasExtra(CardDetailActivity.EXTRA_CARD_TITLE, expectedFallback))
|
||||
}
|
||||
|
||||
private fun launchBoardDetail(boardId: String? = "board-1"): ActivityScenario<BoardDetailActivity> {
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
package space.hackenslacker.kanbn4droid.app
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.ComponentName
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Color
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import android.view.inputmethod.EditorInfo
|
||||
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.closeSoftKeyboard
|
||||
import androidx.test.espresso.action.ViewActions.replaceText
|
||||
import androidx.test.espresso.assertion.ViewAssertions.matches
|
||||
import androidx.test.espresso.intent.Intents
|
||||
import androidx.test.espresso.matcher.RootMatchers.isDialog
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withId
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import java.time.LocalDate
|
||||
import java.util.ArrayDeque
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import org.hamcrest.Description
|
||||
import org.hamcrest.Matcher
|
||||
import org.hamcrest.Matchers.containsString
|
||||
import org.hamcrest.Matchers.not
|
||||
import org.hamcrest.TypeSafeMatcher
|
||||
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 org.junit.runner.RunWith
|
||||
import space.hackenslacker.kanbn4droid.app.carddetail.CardActivity
|
||||
import space.hackenslacker.kanbn4droid.app.carddetail.CardDetail
|
||||
import space.hackenslacker.kanbn4droid.app.carddetail.CardDetailActivity
|
||||
import space.hackenslacker.kanbn4droid.app.carddetail.CardDetailDataSource
|
||||
import space.hackenslacker.kanbn4droid.app.carddetail.CardDetailTag
|
||||
import space.hackenslacker.kanbn4droid.app.carddetail.CardDetailUiEvent
|
||||
import space.hackenslacker.kanbn4droid.app.carddetail.CardDetailUiState
|
||||
import space.hackenslacker.kanbn4droid.app.carddetail.DataSourceResult
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CardDetailFlowTest {
|
||||
|
||||
private lateinit var fakeDataSource: FakeCardDetailDataSource
|
||||
private val observedStates = mutableListOf<CardDetailUiState>()
|
||||
private val observedEvents = mutableListOf<CardDetailUiEvent>()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
observedStates.clear()
|
||||
observedEvents.clear()
|
||||
Intents.init()
|
||||
MainActivity.dependencies.clear()
|
||||
|
||||
fakeDataSource = FakeCardDetailDataSource()
|
||||
CardDetailActivity.testDataSourceFactory = { fakeDataSource }
|
||||
CardDetailActivity.testUiStateObserver = { observedStates += it }
|
||||
CardDetailActivity.testEventObserver = { observedEvents += it }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Intents.release()
|
||||
MainActivity.dependencies.clear()
|
||||
CardDetailActivity.testDataSourceFactory = null
|
||||
CardDetailActivity.testUiStateObserver = null
|
||||
CardDetailActivity.testEventObserver = null
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingCardIdShowsBlockingDialogAndFinishes() {
|
||||
val scenario = launchCardDetail(cardId = null)
|
||||
|
||||
onView(withText(R.string.card_detail_unable_to_open_card)).inRoot(isDialog()).check(matches(isDisplayed()))
|
||||
onView(withText(R.string.ok)).inRoot(isDialog()).perform(click())
|
||||
scenario.onActivity { assertTrue(it.isFinishing) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadingContentAndErrorSectionsRenderDeterministically() {
|
||||
val gate = CompletableDeferred<Unit>()
|
||||
fakeDataSource.loadGate = gate
|
||||
|
||||
val scenario = launchCardDetail(waitForContent = false)
|
||||
onView(withId(R.id.cardDetailInitialProgress)).check(matches(isDisplayed()))
|
||||
|
||||
gate.complete(Unit)
|
||||
scenario.onActivity { }
|
||||
onView(withId(R.id.cardDetailContentScroll)).check(matches(isDisplayed()))
|
||||
|
||||
fakeDataSource.loadResults.add(DataSourceResult.GenericError("Load failed"))
|
||||
scenario.onActivity { it.recreate() }
|
||||
onView(withText("Load failed")).check(matches(isDisplayed()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun titleLiveSaveErrorAndRetryFlow() {
|
||||
fakeDataSource.updateTitleResults.add(DataSourceResult.GenericError("Title rejected"))
|
||||
fakeDataSource.updateTitleResults.add(DataSourceResult.Success(Unit))
|
||||
val scenario = launchCardDetail()
|
||||
|
||||
onView(withId(R.id.cardDetailTitleInput)).perform(replaceText("Renamed"), closeSoftKeyboard())
|
||||
scenario.onActivity {
|
||||
val input = it.findViewById<TextView>(R.id.cardDetailTitleInput)
|
||||
input.onEditorAction(EditorInfo.IME_ACTION_DONE)
|
||||
}
|
||||
|
||||
awaitCondition { fakeDataSource.updateTitleCalls.get() == 1 }
|
||||
onView(withText("Title rejected")).check(matches(isDisplayed()))
|
||||
onView(withId(R.id.cardDetailTitleRetryButton)).perform(click())
|
||||
awaitCondition { fakeDataSource.updateTitleCalls.get() == 2 }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dueDateSetClearAndExpiredStyling() {
|
||||
fakeDataSource.currentCard = fakeDataSource.currentCard.copy(dueDate = LocalDate.now().minusDays(1))
|
||||
var scenario = launchCardDetail()
|
||||
|
||||
var expectedErrorColor = Color.RED
|
||||
scenario.onActivity {
|
||||
expectedErrorColor = MaterialColors.getColor(
|
||||
it.findViewById(android.R.id.content),
|
||||
com.google.android.material.R.attr.colorError,
|
||||
Color.RED,
|
||||
)
|
||||
}
|
||||
onView(withId(R.id.cardDetailDueDateText)).check(matches(withCurrentTextColor(expectedErrorColor)))
|
||||
|
||||
scenario.close()
|
||||
fakeDataSource.currentCard = fakeDataSource.currentCard.copy(dueDate = null)
|
||||
scenario = launchCardDetail()
|
||||
|
||||
onView(withId(R.id.cardDetailDueDateText)).perform(click())
|
||||
onView(withText(android.R.string.ok)).inRoot(isDialog()).perform(click())
|
||||
awaitCondition { fakeDataSource.updateDueDateCalls.get() == 1 }
|
||||
|
||||
onView(withId(R.id.cardDetailDueDateClearButton)).perform(click())
|
||||
awaitCondition { fakeDataSource.updateDueDateCalls.get() == 2 }
|
||||
assertNull(fakeDataSource.lastDueDate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun descriptionLiveSaveRetryAndEditPreviewToggle() {
|
||||
fakeDataSource.updateDescriptionResults.add(DataSourceResult.GenericError("Desc failed"))
|
||||
fakeDataSource.updateDescriptionResults.add(DataSourceResult.Success(Unit))
|
||||
val scenario = launchCardDetail()
|
||||
|
||||
onView(withId(R.id.cardDetailDescriptionInput)).perform(replaceText("**hello**"), closeSoftKeyboard())
|
||||
scenario.onActivity { it.findViewById<View>(R.id.cardDetailDescriptionInput).clearFocus() }
|
||||
awaitCondition { fakeDataSource.updateDescriptionCalls.get() == 1 }
|
||||
onView(withText("Desc failed")).check(matches(isDisplayed()))
|
||||
|
||||
onView(withId(R.id.cardDetailDescriptionRetryButton)).perform(click())
|
||||
awaitCondition { fakeDataSource.updateDescriptionCalls.get() == 2 }
|
||||
|
||||
onView(withId(R.id.cardDetailDescriptionPreviewButton)).perform(click())
|
||||
onView(withId(R.id.cardDetailDescriptionPreviewText)).check(matches(isDisplayed()))
|
||||
onView(withId(R.id.cardDetailDescriptionEditButton)).perform(click())
|
||||
onView(withId(R.id.cardDetailDescriptionInputLayout)).check(matches(isDisplayed()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addCommentDialogSupportsEditPreviewAndAddCancel() {
|
||||
launchCardDetail()
|
||||
|
||||
onView(withId(R.id.cardDetailAddCommentFab)).perform(click())
|
||||
onView(withId(android.R.id.button2)).check(matches(isDisplayed()))
|
||||
onView(withId(android.R.id.button2)).perform(click())
|
||||
|
||||
onView(withId(R.id.cardDetailAddCommentFab)).perform(click())
|
||||
onView(withId(R.id.commentDialogInput)).check(matches(isDisplayed()))
|
||||
onView(withId(R.id.commentDialogInput)).perform(replaceText("new comment"), closeSoftKeyboard())
|
||||
onView(withId(R.id.commentDialogPreviewButton)).perform(click())
|
||||
onView(withId(R.id.commentDialogPreviewText)).check(matches(isDisplayed()))
|
||||
onView(withText(R.string.add)).perform(click())
|
||||
|
||||
awaitCondition {
|
||||
fakeDataSource.addCommentCalls.get() == 1
|
||||
}
|
||||
assertEquals(1, fakeDataSource.listActivitiesCalls.get())
|
||||
assertTrue(observedEvents.any { it is CardDetailUiEvent.ShowSnackbar })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun timelineRowsUseGivenOrderAndCommentBodyOnly() {
|
||||
fakeDataSource.activities = listOf(
|
||||
CardActivity(id = "a-new", type = "comment", text = "**body**", createdAtEpochMillis = 2_000L),
|
||||
CardActivity(id = "a-old", type = "update", text = "ignored", createdAtEpochMillis = 1_000L),
|
||||
)
|
||||
launchCardDetail()
|
||||
|
||||
onView(withText(containsString("Someone commented"))).check(matches(isDisplayed()))
|
||||
onView(withText(containsString("Someone updated this card"))).check(matches(isDisplayed()))
|
||||
onView(withText(containsString("body"))).check(matches(isDisplayed()))
|
||||
assertEquals(listOf("a-new", "a-old"), observedStates.last().activities.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionExpiredEventShowsDialogAndNavigatesToMainClearTask() {
|
||||
fakeDataSource.loadResults.add(DataSourceResult.SessionExpired)
|
||||
launchCardDetail()
|
||||
|
||||
onView(withText(R.string.card_detail_session_expired)).inRoot(isDialog()).check(matches(isDisplayed()))
|
||||
onView(withText(R.string.ok)).inRoot(isDialog()).perform(click())
|
||||
|
||||
assertTrue(
|
||||
Intents.getIntents().any { intent ->
|
||||
intent.component?.className == MainActivity::class.java.name &&
|
||||
(intent.flags and (Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)) ==
|
||||
(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deterministicHarnessUsesFakeDataSourceAndSynchronizationHooks() {
|
||||
val titleGate = CompletableDeferred<Unit>()
|
||||
fakeDataSource.updateTitleGate = titleGate
|
||||
val scenario = launchCardDetail()
|
||||
|
||||
onView(withId(R.id.cardDetailTitleInput)).perform(replaceText("Blocked title"), closeSoftKeyboard())
|
||||
scenario.onActivity {
|
||||
val input = it.findViewById<TextView>(R.id.cardDetailTitleInput)
|
||||
input.onEditorAction(EditorInfo.IME_ACTION_DONE)
|
||||
}
|
||||
|
||||
awaitCondition { fakeDataSource.updateTitleCalls.get() == 1 }
|
||||
awaitCondition { observedStates.lastOrNull()?.isTitleSaving == true }
|
||||
onView(withId(R.id.cardDetailTitleSavingText)).check(matches(isDisplayed()))
|
||||
titleGate.complete(Unit)
|
||||
awaitCondition { observedStates.lastOrNull()?.isTitleSaving == false }
|
||||
onView(withId(R.id.cardDetailTitleSavingText)).check(matches(not(isDisplayed())))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun placeholderCardDetailRouteIsNotDeclaredInManifest() {
|
||||
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
|
||||
val placeholderComponent = ComponentName(
|
||||
context.packageName,
|
||||
"space.hackenslacker.kanbn4droid.app.CardDetailPlaceholderActivity",
|
||||
)
|
||||
|
||||
val isDeclared = runCatching {
|
||||
@Suppress("DEPRECATION")
|
||||
context.packageManager.getActivityInfo(placeholderComponent, PackageManager.GET_META_DATA)
|
||||
}.isSuccess
|
||||
|
||||
assertTrue(!isDeclared)
|
||||
}
|
||||
|
||||
private fun launchCardDetail(
|
||||
cardId: String? = "card-1",
|
||||
waitForContent: Boolean = true,
|
||||
): ActivityScenario<CardDetailActivity> {
|
||||
val intent = Intent(
|
||||
androidx.test.core.app.ApplicationProvider.getApplicationContext(),
|
||||
CardDetailActivity::class.java,
|
||||
)
|
||||
if (cardId != null) {
|
||||
intent.putExtra(CardDetailActivity.EXTRA_CARD_ID, cardId)
|
||||
}
|
||||
intent.putExtra(CardDetailActivity.EXTRA_CARD_TITLE, "Card 1")
|
||||
return ActivityScenario.launch<CardDetailActivity>(intent).also {
|
||||
if (waitForContent && cardId != null) {
|
||||
awaitCondition {
|
||||
observedStates.lastOrNull()?.isInitialLoading == false &&
|
||||
observedStates.lastOrNull()?.loadErrorMessage == null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 FakeCardDetailDataSource : CardDetailDataSource {
|
||||
var currentCard = CardDetail(
|
||||
id = "card-1",
|
||||
title = "Card 1",
|
||||
description = "Seed description",
|
||||
dueDate = null,
|
||||
listPublicId = "list-1",
|
||||
index = 0,
|
||||
tags = listOf(CardDetailTag("tag-1", "Backend", "#008080")),
|
||||
)
|
||||
var activities = listOf(
|
||||
CardActivity("activity-1", "update", "", 1_000L),
|
||||
)
|
||||
|
||||
val loadResults: ArrayDeque<DataSourceResult<CardDetail>> = ArrayDeque()
|
||||
val updateTitleResults: ArrayDeque<DataSourceResult<Unit>> = ArrayDeque()
|
||||
val updateDescriptionResults: ArrayDeque<DataSourceResult<Unit>> = ArrayDeque()
|
||||
val updateDueDateResults: ArrayDeque<DataSourceResult<Unit>> = ArrayDeque()
|
||||
val listActivitiesResults: ArrayDeque<DataSourceResult<List<CardActivity>>> = ArrayDeque()
|
||||
val addCommentResults: ArrayDeque<DataSourceResult<Unit>> = ArrayDeque()
|
||||
|
||||
var loadGate: CompletableDeferred<Unit>? = null
|
||||
var updateTitleGate: CompletableDeferred<Unit>? = null
|
||||
var updateDescriptionGate: CompletableDeferred<Unit>? = null
|
||||
var updateDueDateGate: CompletableDeferred<Unit>? = null
|
||||
|
||||
val updateTitleCalls = AtomicInteger(0)
|
||||
val updateDescriptionCalls = AtomicInteger(0)
|
||||
val updateDueDateCalls = AtomicInteger(0)
|
||||
val listActivitiesCalls = AtomicInteger(0)
|
||||
val addCommentCalls = AtomicInteger(0)
|
||||
|
||||
var lastDueDate: LocalDate? = null
|
||||
|
||||
override suspend fun loadCard(cardId: String): DataSourceResult<CardDetail> {
|
||||
loadGate?.await()
|
||||
return if (loadResults.isNotEmpty()) loadResults.removeFirst() else DataSourceResult.Success(currentCard)
|
||||
}
|
||||
|
||||
override suspend fun updateTitle(cardId: String, title: String): DataSourceResult<Unit> {
|
||||
updateTitleCalls.incrementAndGet()
|
||||
updateTitleGate?.await()
|
||||
val result = if (updateTitleResults.isNotEmpty()) updateTitleResults.removeFirst() else DataSourceResult.Success(Unit)
|
||||
if (result is DataSourceResult.Success) {
|
||||
currentCard = currentCard.copy(title = title)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
override suspend fun updateDescription(cardId: String, description: String): DataSourceResult<Unit> {
|
||||
updateDescriptionCalls.incrementAndGet()
|
||||
updateDescriptionGate?.await()
|
||||
val result = if (updateDescriptionResults.isNotEmpty()) updateDescriptionResults.removeFirst() else DataSourceResult.Success(Unit)
|
||||
if (result is DataSourceResult.Success) {
|
||||
currentCard = currentCard.copy(description = description)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
override suspend fun updateDueDate(cardId: String, dueDate: LocalDate?): DataSourceResult<Unit> {
|
||||
updateDueDateCalls.incrementAndGet()
|
||||
updateDueDateGate?.await()
|
||||
val result = if (updateDueDateResults.isNotEmpty()) updateDueDateResults.removeFirst() else DataSourceResult.Success(Unit)
|
||||
if (result is DataSourceResult.Success) {
|
||||
lastDueDate = dueDate
|
||||
currentCard = currentCard.copy(dueDate = dueDate)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
override suspend fun listActivities(cardId: String): DataSourceResult<List<CardActivity>> {
|
||||
listActivitiesCalls.incrementAndGet()
|
||||
return if (listActivitiesResults.isNotEmpty()) listActivitiesResults.removeFirst() else DataSourceResult.Success(activities)
|
||||
}
|
||||
|
||||
override suspend fun addComment(cardId: String, comment: String): DataSourceResult<List<CardActivity>> {
|
||||
addCommentCalls.incrementAndGet()
|
||||
val result = if (addCommentResults.isNotEmpty()) addCommentResults.removeFirst() else DataSourceResult.Success(Unit)
|
||||
if (result is DataSourceResult.Success) {
|
||||
activities = listOf(
|
||||
CardActivity("comment-${addCommentCalls.get()}", "comment", comment, System.currentTimeMillis()),
|
||||
) + activities
|
||||
return DataSourceResult.Success(activities)
|
||||
}
|
||||
return when (result) {
|
||||
is DataSourceResult.GenericError -> result
|
||||
DataSourceResult.SessionExpired -> DataSourceResult.SessionExpired
|
||||
is DataSourceResult.Success -> DataSourceResult.Success(activities)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
android:usesCleartextTraffic="true"
|
||||
android:theme="@style/Theme.Kanbn4Droid">
|
||||
<activity
|
||||
android:name=".CardDetailPlaceholderActivity"
|
||||
android:name=".carddetail.CardDetailActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".boarddetail.BoardDetailActivity"
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -18,6 +20,9 @@ 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
|
||||
import space.hackenslacker.kanbn4droid.app.carddetail.CardActivity
|
||||
import space.hackenslacker.kanbn4droid.app.carddetail.CardDetail
|
||||
import space.hackenslacker.kanbn4droid.app.carddetail.CardDetailTag
|
||||
|
||||
interface KanbnApiClient {
|
||||
suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult
|
||||
@@ -97,6 +102,29 @@ interface KanbnApiClient {
|
||||
suspend fun getLabelByPublicId(baseUrl: String, apiKey: String, labelId: String): BoardsApiResult<LabelDetail> {
|
||||
return BoardsApiResult.Failure("Label detail is not implemented.")
|
||||
}
|
||||
|
||||
suspend fun getCardDetail(baseUrl: String, apiKey: String, cardId: String): BoardsApiResult<CardDetail> {
|
||||
return BoardsApiResult.Failure("Card detail is not implemented.")
|
||||
}
|
||||
|
||||
suspend fun updateCard(
|
||||
baseUrl: String,
|
||||
apiKey: String,
|
||||
cardId: String,
|
||||
title: String,
|
||||
description: String,
|
||||
dueDate: LocalDate?,
|
||||
): BoardsApiResult<Unit> {
|
||||
return BoardsApiResult.Failure("Card update is not implemented.")
|
||||
}
|
||||
|
||||
suspend fun listCardActivities(baseUrl: String, apiKey: String, cardId: String): BoardsApiResult<List<CardActivity>> {
|
||||
return BoardsApiResult.Failure("Card activity listing is not implemented.")
|
||||
}
|
||||
|
||||
suspend fun addCardComment(baseUrl: String, apiKey: String, cardId: String, comment: String): BoardsApiResult<Unit> {
|
||||
return BoardsApiResult.Failure("Card comment creation is not implemented.")
|
||||
}
|
||||
}
|
||||
|
||||
data class LabelDetail(
|
||||
@@ -547,6 +575,199 @@ class HttpKanbnApiClient : KanbnApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getCardDetail(
|
||||
baseUrl: String,
|
||||
apiKey: String,
|
||||
cardId: String,
|
||||
): BoardsApiResult<CardDetail> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
request(
|
||||
baseUrl = baseUrl,
|
||||
path = "/api/v1/cards/$cardId",
|
||||
method = "GET",
|
||||
apiKey = apiKey,
|
||||
) { code, body ->
|
||||
if (code in 200..299) {
|
||||
parseCardDetail(body, fallbackId = cardId)
|
||||
?.let { BoardsApiResult.Success(it) }
|
||||
?: BoardsApiResult.Failure("Malformed card detail response.")
|
||||
} else {
|
||||
BoardsApiResult.Failure(serverMessage(body, code))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateCard(
|
||||
baseUrl: String,
|
||||
apiKey: String,
|
||||
cardId: String,
|
||||
title: String,
|
||||
description: String,
|
||||
dueDate: LocalDate?,
|
||||
): BoardsApiResult<Unit> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val canonicalPayload = buildCardMutationPayload(title, description, dueDate, aliasKeys = false)
|
||||
val aliasPayload = buildCardMutationPayload(title, description, dueDate, aliasKeys = true)
|
||||
|
||||
val attempts = listOf(
|
||||
"PATCH" to canonicalPayload,
|
||||
"PATCH" to aliasPayload,
|
||||
"PUT" to canonicalPayload,
|
||||
"PUT" to aliasPayload,
|
||||
)
|
||||
|
||||
var lastFailureMessage = "Card update failed."
|
||||
for ((method, payload) in attempts) {
|
||||
val response = requestRaw(
|
||||
baseUrl = baseUrl,
|
||||
path = "/api/v1/cards/$cardId",
|
||||
method = method,
|
||||
apiKey = apiKey,
|
||||
body = payload,
|
||||
)
|
||||
if (response == null) {
|
||||
return@withContext BoardsApiResult.Failure("Card update failed.")
|
||||
}
|
||||
if (response.code in 200..299) {
|
||||
return@withContext BoardsApiResult.Success(Unit)
|
||||
}
|
||||
if (response.code == 401 || response.code == 403) {
|
||||
return@withContext BoardsApiResult.Failure(serverMessage(response.body, response.code))
|
||||
}
|
||||
lastFailureMessage = serverMessage(response.body, response.code)
|
||||
}
|
||||
|
||||
val getResponse = requestRaw(
|
||||
baseUrl = baseUrl,
|
||||
path = "/api/v1/cards/$cardId",
|
||||
method = "GET",
|
||||
apiKey = apiKey,
|
||||
)
|
||||
if (getResponse == null) {
|
||||
return@withContext BoardsApiResult.Failure(lastFailureMessage)
|
||||
}
|
||||
if (getResponse.code == 401 || getResponse.code == 403) {
|
||||
return@withContext BoardsApiResult.Failure(serverMessage(getResponse.body, getResponse.code))
|
||||
}
|
||||
if (getResponse.code !in 200..299) {
|
||||
return@withContext BoardsApiResult.Failure(serverMessage(getResponse.body, getResponse.code))
|
||||
}
|
||||
|
||||
val fullPutPayload = buildCardFullPutPayload(getResponse.body, title, description, dueDate)
|
||||
if (fullPutPayload == null) {
|
||||
return@withContext BoardsApiResult.Failure(lastFailureMessage)
|
||||
}
|
||||
|
||||
val finalResponse = requestRaw(
|
||||
baseUrl = baseUrl,
|
||||
path = "/api/v1/cards/$cardId",
|
||||
method = "PUT",
|
||||
apiKey = apiKey,
|
||||
body = fullPutPayload,
|
||||
)
|
||||
if (finalResponse == null) {
|
||||
return@withContext BoardsApiResult.Failure(lastFailureMessage)
|
||||
}
|
||||
if (finalResponse.code in 200..299) {
|
||||
BoardsApiResult.Success(Unit)
|
||||
} else {
|
||||
BoardsApiResult.Failure(serverMessage(finalResponse.body, finalResponse.code))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun listCardActivities(
|
||||
baseUrl: String,
|
||||
apiKey: String,
|
||||
cardId: String,
|
||||
): BoardsApiResult<List<CardActivity>> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val paths = listOf(
|
||||
"/api/v1/cards/$cardId/activities?limit=50",
|
||||
"/api/v1/cards/$cardId/actions?limit=50",
|
||||
"/api/v1/cards/$cardId/card-activities?limit=50",
|
||||
)
|
||||
|
||||
var lastFailure = "Card activities are not available."
|
||||
for (path in paths) {
|
||||
val response = requestRaw(
|
||||
baseUrl = baseUrl,
|
||||
path = path,
|
||||
method = "GET",
|
||||
apiKey = apiKey,
|
||||
)
|
||||
if (response == null) {
|
||||
continue
|
||||
}
|
||||
if (response.code in 200..299) {
|
||||
val parsed = parseCardActivities(response.body)
|
||||
if (parsed != null) {
|
||||
val normalized = parsed
|
||||
.sortedByDescending { it.createdAtEpochMillis }
|
||||
.take(10)
|
||||
return@withContext BoardsApiResult.Success(normalized)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (response.code == 401 || response.code == 403) {
|
||||
return@withContext BoardsApiResult.Failure(serverMessage(response.body, response.code))
|
||||
}
|
||||
lastFailure = serverMessage(response.body, response.code)
|
||||
}
|
||||
|
||||
BoardsApiResult.Failure(lastFailure)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun addCardComment(
|
||||
baseUrl: String,
|
||||
apiKey: String,
|
||||
cardId: String,
|
||||
comment: String,
|
||||
): BoardsApiResult<Unit> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val attempts = listOf(
|
||||
Pair("/api/v1/cards/$cardId/comment-actions", "{\"text\":\"${jsonEscape(comment)}\"}"),
|
||||
Pair("/api/v1/cards/$cardId/comment-actions", "{\"comment\":\"${jsonEscape(comment)}\"}"),
|
||||
Pair("/api/v1/cards/$cardId/actions/comments", "{\"text\":\"${jsonEscape(comment)}\"}"),
|
||||
)
|
||||
|
||||
var lastFailure = "Comment could not be added."
|
||||
for ((path, payload) in attempts) {
|
||||
val response = requestRaw(
|
||||
baseUrl = baseUrl,
|
||||
path = path,
|
||||
method = "POST",
|
||||
apiKey = apiKey,
|
||||
body = payload,
|
||||
)
|
||||
if (response == null) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (response.code !in 200..299) {
|
||||
if (response.code == 401 || response.code == 403) {
|
||||
return@withContext BoardsApiResult.Failure(serverMessage(response.body, response.code))
|
||||
}
|
||||
lastFailure = serverMessage(response.body, response.code)
|
||||
continue
|
||||
}
|
||||
|
||||
when (evaluateCommentResponse(response.body)) {
|
||||
CommentResponseClassification.Success -> return@withContext BoardsApiResult.Success(Unit)
|
||||
CommentResponseClassification.LogicalFailure -> {
|
||||
lastFailure = extractLogicalFailureMessage(response.body)
|
||||
continue
|
||||
}
|
||||
CommentResponseClassification.ParseIncompatible -> continue
|
||||
}
|
||||
}
|
||||
|
||||
BoardsApiResult.Failure(lastFailure)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> request(
|
||||
baseUrl: String,
|
||||
path: String,
|
||||
@@ -591,6 +812,48 @@ class HttpKanbnApiClient : KanbnApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
private data class RawResponse(val code: Int, val body: String)
|
||||
|
||||
private fun requestRaw(
|
||||
baseUrl: String,
|
||||
path: String,
|
||||
method: String,
|
||||
apiKey: String,
|
||||
body: String? = null,
|
||||
): RawResponse? {
|
||||
val endpoint = "${baseUrl.trimEnd('/')}$path"
|
||||
val connection = (URL(endpoint).openConnection() as HttpURLConnection).apply {
|
||||
configureRequestMethod(this, method)
|
||||
connectTimeout = 10_000
|
||||
readTimeout = 10_000
|
||||
setRequestProperty("x-api-key", apiKey)
|
||||
if (body != null) {
|
||||
doOutput = true
|
||||
setRequestProperty("Content-Type", "application/json")
|
||||
}
|
||||
}
|
||||
|
||||
return try {
|
||||
if (body != null) {
|
||||
connection.outputStream.bufferedWriter().use { writer -> writer.write(body) }
|
||||
}
|
||||
val code = connection.responseCode
|
||||
RawResponse(code = code, body = readResponseBody(connection, code))
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} finally {
|
||||
try {
|
||||
connection.inputStream?.close()
|
||||
} catch (_: IOException) {
|
||||
}
|
||||
try {
|
||||
connection.errorStream?.close()
|
||||
} catch (_: IOException) {
|
||||
}
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun configureRequestMethod(connection: HttpURLConnection, method: String) {
|
||||
try {
|
||||
connection.requestMethod = method
|
||||
@@ -789,6 +1052,250 @@ class HttpKanbnApiClient : KanbnApiClient {
|
||||
return CreatedEntityRef(publicId = publicId.ifBlank { null })
|
||||
}
|
||||
|
||||
private fun parseCardDetail(body: String, fallbackId: String): CardDetail? {
|
||||
if (body.isBlank()) {
|
||||
return null
|
||||
}
|
||||
val root = parseJsonObject(body) ?: return null
|
||||
val data = root["data"] as? Map<*, *>
|
||||
val card = (data?.get("card") as? Map<*, *>)
|
||||
?: (root["card"] as? Map<*, *>)
|
||||
?: data
|
||||
?: root
|
||||
|
||||
val id = extractId(card).ifBlank { fallbackId }
|
||||
val title = extractString(card, "title", "name").ifBlank { "Card" }
|
||||
val description = firstPresent(card, "description", "body")?.toString().orEmpty()
|
||||
val dueDate = parseCardDueDate(firstPresent(card, "dueDate", "dueAt", "due_at", "due"))
|
||||
val listPublicId = extractString(card, "listPublicId", "listId", "list_id").ifBlank {
|
||||
(card["list"] as? Map<*, *>)?.let { extractString(it, "publicId", "public_id", "id") }.orEmpty()
|
||||
}.ifBlank { null }
|
||||
val index = parseCardIndex(firstPresent(card, "index", "position"))
|
||||
val tags = extractObjectArray(card, "labels", "tags", "data").mapNotNull { rawTag ->
|
||||
val tagId = extractId(rawTag)
|
||||
if (tagId.isBlank()) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
CardDetailTag(
|
||||
id = tagId,
|
||||
name = extractTitle(rawTag, "Tag"),
|
||||
colorHex = extractString(rawTag, "colourCode", "colorCode", "colorHex", "color", "hex"),
|
||||
)
|
||||
}
|
||||
|
||||
return CardDetail(
|
||||
id = id,
|
||||
title = title,
|
||||
description = description,
|
||||
dueDate = dueDate,
|
||||
listPublicId = listPublicId,
|
||||
index = index,
|
||||
tags = tags,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseCardDueDate(raw: Any?): LocalDate? {
|
||||
val value = raw?.toString()?.trim().orEmpty()
|
||||
if (value.isBlank() || value.equals("null", ignoreCase = true)) {
|
||||
return null
|
||||
}
|
||||
runCatching { return LocalDate.parse(value) }
|
||||
runCatching { return OffsetDateTime.parse(value).withOffsetSameInstant(ZoneOffset.UTC).toLocalDate() }
|
||||
runCatching { return Instant.parse(value).atOffset(ZoneOffset.UTC).toLocalDate() }
|
||||
return null
|
||||
}
|
||||
|
||||
private fun parseCardIndex(raw: Any?): Int? {
|
||||
return when (raw) {
|
||||
is Number -> raw.toInt()
|
||||
is String -> raw.toIntOrNull() ?: raw.toDoubleOrNull()?.toInt()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildCardMutationPayload(
|
||||
title: String,
|
||||
description: String,
|
||||
dueDate: LocalDate?,
|
||||
aliasKeys: Boolean,
|
||||
): String {
|
||||
val dueValue = dueDate?.let { "\"${it}T00:00:00Z\"" } ?: "null"
|
||||
return if (aliasKeys) {
|
||||
"{" +
|
||||
"\"name\":\"${jsonEscape(title)}\"," +
|
||||
"\"body\":\"${jsonEscape(description)}\"," +
|
||||
"\"dueAt\":$dueValue" +
|
||||
"}"
|
||||
} else {
|
||||
"{" +
|
||||
"\"title\":\"${jsonEscape(title)}\"," +
|
||||
"\"description\":\"${jsonEscape(description)}\"," +
|
||||
"\"dueDate\":$dueValue" +
|
||||
"}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildCardFullPutPayload(
|
||||
cardBody: String,
|
||||
title: String,
|
||||
description: String,
|
||||
dueDate: LocalDate?,
|
||||
): String? {
|
||||
if (cardBody.isBlank()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val root = parseJsonObject(cardBody) ?: return null
|
||||
val data = root["data"] as? Map<*, *>
|
||||
val card = (data?.get("card") as? Map<*, *>)
|
||||
?: (root["card"] as? Map<*, *>)
|
||||
?: data
|
||||
?: root
|
||||
|
||||
val listPublicId = extractString(card, "listPublicId", "listId", "list_id").ifBlank {
|
||||
(card["list"] as? Map<*, *>)?.let { extractString(it, "publicId", "public_id", "id") }.orEmpty()
|
||||
}
|
||||
if (listPublicId.isBlank()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val indexField = parseCardIndex(firstPresent(card, "index", "position")) ?: 0
|
||||
val dueField = dueDate?.let { "\"${it}T00:00:00Z\"" } ?: "null"
|
||||
return "{" +
|
||||
"\"title\":\"${jsonEscape(title)}\"," +
|
||||
"\"description\":\"${jsonEscape(description)}\"," +
|
||||
"\"dueDate\":$dueField," +
|
||||
"\"index\":$indexField," +
|
||||
"\"listPublicId\":\"${jsonEscape(listPublicId)}\"" +
|
||||
"}"
|
||||
}
|
||||
|
||||
private fun parseCardActivities(body: String): List<CardActivity>? {
|
||||
if (body.isBlank()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val parsed = parseJsonValue(body) ?: return null
|
||||
val items: List<Map<*, *>> = when (parsed) {
|
||||
is List<*> -> {
|
||||
if (parsed.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
if (parsed.any { it !is Map<*, *> }) {
|
||||
return null
|
||||
}
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
parsed as List<Map<*, *>>
|
||||
}
|
||||
|
||||
is Map<*, *> -> {
|
||||
val extracted = extractActivityContainer(parsed) ?: return null
|
||||
if (extracted.any { it !is Map<*, *> }) {
|
||||
return null
|
||||
}
|
||||
if (extracted.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
extracted.mapNotNull { it as? Map<*, *> }
|
||||
}
|
||||
|
||||
else -> return null
|
||||
}
|
||||
|
||||
return items.mapNotNull { raw ->
|
||||
val id = extractString(raw, "id", "publicId", "public_id")
|
||||
if (id.isBlank()) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
val createdAtRaw = firstPresent(raw, "createdAt", "created_at", "date", "timestamp")?.toString().orEmpty()
|
||||
val createdAt = parseEpochMillis(createdAtRaw)
|
||||
?: return@mapNotNull null
|
||||
CardActivity(
|
||||
id = id,
|
||||
type = extractString(raw, "type", "actionType", "kind").ifBlank { "activity" },
|
||||
text = extractString(raw, "text", "comment", "message", "description", "body", "content"),
|
||||
createdAtEpochMillis = createdAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractActivityContainer(source: Map<*, *>): List<*>? {
|
||||
val keys = listOf("activities", "actions", "cardActivities", "card_activities", "items", "data")
|
||||
keys.forEach { key ->
|
||||
if (source.containsKey(key)) {
|
||||
val value = source[key]
|
||||
return value as? List<*>
|
||||
}
|
||||
}
|
||||
|
||||
val data = source["data"] as? Map<*, *> ?: return null
|
||||
keys.forEach { key ->
|
||||
if (data.containsKey(key)) {
|
||||
val value = data[key]
|
||||
return value as? List<*>
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun parseEpochMillis(raw: String): Long? {
|
||||
val trimmed = raw.trim()
|
||||
if (trimmed.isBlank()) {
|
||||
return null
|
||||
}
|
||||
return trimmed.toLongOrNull()
|
||||
?: runCatching { Instant.parse(trimmed).toEpochMilli() }.getOrNull()
|
||||
?: runCatching { OffsetDateTime.parse(trimmed).toInstant().toEpochMilli() }.getOrNull()
|
||||
}
|
||||
|
||||
private enum class CommentResponseClassification {
|
||||
Success,
|
||||
LogicalFailure,
|
||||
ParseIncompatible,
|
||||
}
|
||||
|
||||
private fun evaluateCommentResponse(body: String): CommentResponseClassification {
|
||||
if (body.isBlank()) {
|
||||
return CommentResponseClassification.Success
|
||||
}
|
||||
val root = parseJsonValue(body) as? Map<*, *> ?: return CommentResponseClassification.ParseIncompatible
|
||||
|
||||
val explicitError = firstPresent(root, "error", "errors")?.toString().orEmpty()
|
||||
if (explicitError.isNotBlank() && !explicitError.equals("null", ignoreCase = true)) {
|
||||
return CommentResponseClassification.LogicalFailure
|
||||
}
|
||||
|
||||
val successValue = firstPresent(root, "success")
|
||||
if (successValue is Boolean && !successValue) {
|
||||
return CommentResponseClassification.LogicalFailure
|
||||
}
|
||||
val okValue = firstPresent(root, "ok")
|
||||
if (okValue is Boolean && !okValue) {
|
||||
return CommentResponseClassification.LogicalFailure
|
||||
}
|
||||
|
||||
val status = firstPresent(root, "status")?.toString()?.trim()?.lowercase().orEmpty()
|
||||
if (status == "error" || status == "failed" || status == "failure") {
|
||||
return CommentResponseClassification.LogicalFailure
|
||||
}
|
||||
|
||||
val successKeyPresent = sequenceOf("commentAction", "comment", "action", "data").any { key ->
|
||||
root.containsKey(key) && root[key] != null
|
||||
}
|
||||
if (successKeyPresent) {
|
||||
return CommentResponseClassification.Success
|
||||
}
|
||||
|
||||
return CommentResponseClassification.ParseIncompatible
|
||||
}
|
||||
|
||||
private fun extractLogicalFailureMessage(body: String): String {
|
||||
val root = parseJsonValue(body) as? Map<*, *> ?: return "Comment could not be added."
|
||||
val message = extractString(root, "message", "error", "detail", "cause")
|
||||
return message.ifBlank { "Comment could not be added." }
|
||||
}
|
||||
|
||||
private fun parseLists(board: Map<*, *>): List<BoardListDetail> {
|
||||
return extractObjectArray(board, "lists", "items", "data").mapNotNull { rawList ->
|
||||
val id = extractId(rawList)
|
||||
@@ -871,13 +1378,17 @@ class HttpKanbnApiClient : KanbnApiClient {
|
||||
}
|
||||
|
||||
private fun parseJsonObject(body: String): Map<String, Any?>? {
|
||||
val parsed = parseJsonValue(body)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return parsed as? Map<String, Any?>
|
||||
}
|
||||
|
||||
private fun parseJsonValue(body: 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?>
|
||||
return runCatching { MiniJsonParser(trimmed).parseValue() }.getOrNull()
|
||||
}
|
||||
|
||||
private fun jsonEscape(value: String): String {
|
||||
|
||||
@@ -35,7 +35,7 @@ import java.time.ZoneId
|
||||
import java.util.Date
|
||||
import kotlinx.coroutines.launch
|
||||
import space.hackenslacker.kanbn4droid.app.MainActivity
|
||||
import space.hackenslacker.kanbn4droid.app.CardDetailPlaceholderActivity
|
||||
import space.hackenslacker.kanbn4droid.app.carddetail.CardDetailActivity
|
||||
import space.hackenslacker.kanbn4droid.app.R
|
||||
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
|
||||
import space.hackenslacker.kanbn4droid.app.auth.HttpKanbnApiClient
|
||||
@@ -231,7 +231,7 @@ class BoardDetailActivity : AppCompatActivity() {
|
||||
lifecycleScope.launch {
|
||||
viewModel.events.collect { event ->
|
||||
when (event) {
|
||||
is BoardDetailUiEvent.NavigateToCardPlaceholder -> {
|
||||
is BoardDetailUiEvent.NavigateToCardDetail -> {
|
||||
val cardTitle = viewModel.uiState.value.boardDetail
|
||||
?.lists
|
||||
.orEmpty()
|
||||
@@ -241,12 +241,8 @@ class BoardDetailActivity : AppCompatActivity() {
|
||||
?.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),
|
||||
)
|
||||
.ifBlank { getString(R.string.card_detail_fallback_title) }
|
||||
openCardDetail(cardId = event.cardId, cardTitle = cardTitle)
|
||||
}
|
||||
|
||||
is BoardDetailUiEvent.ShowServerError -> {
|
||||
@@ -339,6 +335,14 @@ class BoardDetailActivity : AppCompatActivity() {
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun openCardDetail(cardId: String, cardTitle: String) {
|
||||
startActivity(
|
||||
Intent(this, CardDetailActivity::class.java)
|
||||
.putExtra(CardDetailActivity.EXTRA_CARD_ID, cardId)
|
||||
.putExtra(CardDetailActivity.EXTRA_CARD_TITLE, cardTitle),
|
||||
)
|
||||
}
|
||||
|
||||
private fun renderOpenDialogs(state: BoardDetailUiState) {
|
||||
if (state.isFabChooserOpen && fabChooserDialog == null) {
|
||||
showFabChooserDialog(state)
|
||||
|
||||
@@ -57,7 +57,7 @@ data class BoardDetailUiState(
|
||||
}
|
||||
|
||||
sealed interface BoardDetailUiEvent {
|
||||
data class NavigateToCardPlaceholder(val cardId: String) : BoardDetailUiEvent
|
||||
data class NavigateToCardDetail(val cardId: String) : BoardDetailUiEvent
|
||||
data class ShowServerError(val message: String) : BoardDetailUiEvent
|
||||
data class ShowWarning(val message: String) : BoardDetailUiEvent
|
||||
}
|
||||
@@ -176,7 +176,7 @@ class BoardDetailViewModel(
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_events.emit(BoardDetailUiEvent.NavigateToCardPlaceholder(cardId))
|
||||
_events.emit(BoardDetailUiEvent.NavigateToCardDetail(cardId))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package space.hackenslacker.kanbn4droid.app.carddetail
|
||||
|
||||
import android.text.format.DateUtils
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import space.hackenslacker.kanbn4droid.app.R
|
||||
|
||||
class ActivityTimelineAdapter(
|
||||
private val markdownRenderer: MarkdownRenderer,
|
||||
) : RecyclerView.Adapter<ActivityTimelineAdapter.ActivityViewHolder>() {
|
||||
|
||||
private var items: List<CardActivity> = emptyList()
|
||||
|
||||
fun submitActivities(value: List<CardActivity>) {
|
||||
items = value
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ActivityViewHolder {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_card_activity_timeline, parent, false)
|
||||
return ActivityViewHolder(view, markdownRenderer)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ActivityViewHolder, position: Int) {
|
||||
holder.bind(
|
||||
activity = items[position],
|
||||
isFirst = position == 0,
|
||||
isLast = position == items.lastIndex,
|
||||
)
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = items.size
|
||||
|
||||
class ActivityViewHolder(
|
||||
itemView: View,
|
||||
private val markdownRenderer: MarkdownRenderer,
|
||||
) : RecyclerView.ViewHolder(itemView) {
|
||||
private val topConnector: View = itemView.findViewById(R.id.timelineTopConnector)
|
||||
private val bottomConnector: View = itemView.findViewById(R.id.timelineBottomConnector)
|
||||
private val icon: ImageView = itemView.findViewById(R.id.timelineIcon)
|
||||
private val header: TextView = itemView.findViewById(R.id.timelineHeaderText)
|
||||
private val body: TextView = itemView.findViewById(R.id.timelineBodyText)
|
||||
|
||||
fun bind(activity: CardActivity, isFirst: Boolean, isLast: Boolean) {
|
||||
topConnector.visibility = if (isFirst) View.INVISIBLE else View.VISIBLE
|
||||
bottomConnector.visibility = if (isLast) View.INVISIBLE else View.VISIBLE
|
||||
icon.setImageResource(R.drawable.ic_timeline_note_24)
|
||||
|
||||
val action = when {
|
||||
activity.type.contains("comment", ignoreCase = true) -> itemView.context.getString(R.string.card_detail_timeline_action_commented)
|
||||
else -> itemView.context.getString(R.string.card_detail_timeline_action_updated)
|
||||
}
|
||||
val actor = itemView.context.getString(R.string.card_detail_timeline_actor_unknown)
|
||||
val relative = DateUtils.getRelativeTimeSpanString(
|
||||
activity.createdAtEpochMillis,
|
||||
System.currentTimeMillis(),
|
||||
DateUtils.MINUTE_IN_MILLIS,
|
||||
)
|
||||
header.text = itemView.context.getString(
|
||||
R.string.card_detail_timeline_header,
|
||||
actor,
|
||||
action,
|
||||
relative,
|
||||
)
|
||||
|
||||
val isComment = activity.type.contains("comment", ignoreCase = true)
|
||||
val text = activity.text.trim()
|
||||
if (isComment && text.isNotBlank()) {
|
||||
body.visibility = View.VISIBLE
|
||||
body.text = markdownRenderer.render(text)
|
||||
MarkdownRenderer.enableLinks(body)
|
||||
} else {
|
||||
body.visibility = View.GONE
|
||||
body.text = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
package space.hackenslacker.kanbn4droid.app.carddetail
|
||||
|
||||
import android.app.DatePickerDialog
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.inputmethod.EditorInfo
|
||||
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.core.widget.doAfterTextChanged
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.google.android.material.appbar.MaterialToolbar
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.button.MaterialButtonToggleGroup
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
import com.google.android.material.textfield.TextInputLayout
|
||||
import java.text.DateFormat
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.util.Date
|
||||
import kotlinx.coroutines.launch
|
||||
import space.hackenslacker.kanbn4droid.app.MainActivity
|
||||
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 CardDetailActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var cardId: String
|
||||
private lateinit var sessionStore: SessionStore
|
||||
private lateinit var apiKeyStore: ApiKeyStore
|
||||
private lateinit var apiClient: KanbnApiClient
|
||||
|
||||
private lateinit var toolbar: MaterialToolbar
|
||||
private lateinit var initialProgress: ProgressBar
|
||||
private lateinit var errorContainer: View
|
||||
private lateinit var errorText: TextView
|
||||
private lateinit var retryButton: Button
|
||||
private lateinit var contentScroll: View
|
||||
|
||||
private lateinit var titleInputLayout: TextInputLayout
|
||||
private lateinit var titleInput: TextInputEditText
|
||||
private lateinit var titleSavingText: TextView
|
||||
private lateinit var titleErrorText: TextView
|
||||
private lateinit var titleRetryButton: MaterialButton
|
||||
|
||||
private lateinit var tagsRecycler: androidx.recyclerview.widget.RecyclerView
|
||||
private lateinit var dueDateText: TextView
|
||||
private lateinit var dueDateClearButton: MaterialButton
|
||||
private lateinit var dueDateSavingText: TextView
|
||||
private lateinit var dueDateErrorText: TextView
|
||||
private lateinit var dueDateRetryButton: MaterialButton
|
||||
|
||||
private lateinit var descriptionModeToggle: MaterialButtonToggleGroup
|
||||
private lateinit var descriptionEditButton: MaterialButton
|
||||
private lateinit var descriptionPreviewButton: MaterialButton
|
||||
private lateinit var descriptionInputLayout: TextInputLayout
|
||||
private lateinit var descriptionInput: TextInputEditText
|
||||
private lateinit var descriptionPreviewText: TextView
|
||||
private lateinit var descriptionSavingText: TextView
|
||||
private lateinit var descriptionErrorText: TextView
|
||||
private lateinit var descriptionRetryButton: MaterialButton
|
||||
|
||||
private lateinit var timelineProgress: ProgressBar
|
||||
private lateinit var timelineErrorText: TextView
|
||||
private lateinit var timelineRetryButton: MaterialButton
|
||||
private lateinit var timelineEmptyText: TextView
|
||||
private lateinit var timelineRecycler: androidx.recyclerview.widget.RecyclerView
|
||||
private lateinit var addCommentFab: FloatingActionButton
|
||||
|
||||
private val markdownRenderer = MarkdownRenderer()
|
||||
private lateinit var tagAdapter: CardDetailTagChipAdapter
|
||||
private lateinit var timelineAdapter: ActivityTimelineAdapter
|
||||
private var commentDialog: AlertDialog? = null
|
||||
private var hasShownSessionDialog: Boolean = false
|
||||
|
||||
private var suppressTitleChange = false
|
||||
private var suppressDescriptionChange = false
|
||||
private var suppressCommentChange = false
|
||||
|
||||
private val viewModel: CardDetailViewModel by viewModels {
|
||||
val id = cardId
|
||||
val fakeFactory = testDataSourceFactory
|
||||
if (fakeFactory != null) {
|
||||
CardDetailViewModel.Factory(
|
||||
cardId = id,
|
||||
dataSource = fakeFactory.invoke(id),
|
||||
titleRequiredMessage = getString(R.string.card_title_required),
|
||||
commentRequiredMessage = getString(R.string.card_detail_comment_required),
|
||||
commentAddedMessage = getString(R.string.card_detail_comment_added),
|
||||
)
|
||||
} else {
|
||||
val repository = CardDetailRepository(
|
||||
sessionStore = sessionStore,
|
||||
apiKeyStore = apiKeyStore,
|
||||
apiClient = apiClient,
|
||||
)
|
||||
CardDetailViewModel.Factory(
|
||||
cardId = id,
|
||||
dataSource = CardDetailRepositoryDataSource(repository, repository::loadCard),
|
||||
titleRequiredMessage = getString(R.string.card_title_required),
|
||||
commentRequiredMessage = getString(R.string.card_detail_comment_required),
|
||||
commentAddedMessage = getString(R.string.card_detail_comment_added),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
cardId = intent.getStringExtra(EXTRA_CARD_ID).orEmpty()
|
||||
sessionStore = provideSessionStore()
|
||||
apiKeyStore = provideApiKeyStore()
|
||||
apiClient = provideApiClient()
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_card_detail)
|
||||
bindViews()
|
||||
setupToolbar()
|
||||
setupAdapters()
|
||||
setupInputs()
|
||||
observeViewModel()
|
||||
|
||||
if (cardId.isBlank()) {
|
||||
showBlockingDialogAndFinish(getString(R.string.card_detail_unable_to_open_card))
|
||||
return
|
||||
}
|
||||
|
||||
viewModel.load()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
viewModel.onStop()
|
||||
}
|
||||
|
||||
private fun bindViews() {
|
||||
toolbar = findViewById(R.id.cardDetailToolbar)
|
||||
initialProgress = findViewById(R.id.cardDetailInitialProgress)
|
||||
errorContainer = findViewById(R.id.cardDetailErrorContainer)
|
||||
errorText = findViewById(R.id.cardDetailErrorText)
|
||||
retryButton = findViewById(R.id.cardDetailRetryButton)
|
||||
contentScroll = findViewById(R.id.cardDetailContentScroll)
|
||||
|
||||
titleInputLayout = findViewById(R.id.cardDetailTitleInputLayout)
|
||||
titleInput = findViewById(R.id.cardDetailTitleInput)
|
||||
titleSavingText = findViewById(R.id.cardDetailTitleSavingText)
|
||||
titleErrorText = findViewById(R.id.cardDetailTitleErrorText)
|
||||
titleRetryButton = findViewById(R.id.cardDetailTitleRetryButton)
|
||||
|
||||
tagsRecycler = findViewById(R.id.cardDetailTagsRecycler)
|
||||
dueDateText = findViewById(R.id.cardDetailDueDateText)
|
||||
dueDateClearButton = findViewById(R.id.cardDetailDueDateClearButton)
|
||||
dueDateSavingText = findViewById(R.id.cardDetailDueDateSavingText)
|
||||
dueDateErrorText = findViewById(R.id.cardDetailDueDateErrorText)
|
||||
dueDateRetryButton = findViewById(R.id.cardDetailDueDateRetryButton)
|
||||
|
||||
descriptionModeToggle = findViewById(R.id.cardDetailDescriptionModeToggle)
|
||||
descriptionEditButton = findViewById(R.id.cardDetailDescriptionEditButton)
|
||||
descriptionPreviewButton = findViewById(R.id.cardDetailDescriptionPreviewButton)
|
||||
descriptionInputLayout = findViewById(R.id.cardDetailDescriptionInputLayout)
|
||||
descriptionInput = findViewById(R.id.cardDetailDescriptionInput)
|
||||
descriptionPreviewText = findViewById(R.id.cardDetailDescriptionPreviewText)
|
||||
descriptionSavingText = findViewById(R.id.cardDetailDescriptionSavingText)
|
||||
descriptionErrorText = findViewById(R.id.cardDetailDescriptionErrorText)
|
||||
descriptionRetryButton = findViewById(R.id.cardDetailDescriptionRetryButton)
|
||||
|
||||
timelineProgress = findViewById(R.id.cardDetailTimelineProgress)
|
||||
timelineErrorText = findViewById(R.id.cardDetailTimelineErrorText)
|
||||
timelineRetryButton = findViewById(R.id.cardDetailTimelineRetryButton)
|
||||
timelineEmptyText = findViewById(R.id.cardDetailTimelineEmptyText)
|
||||
timelineRecycler = findViewById(R.id.cardDetailTimelineRecycler)
|
||||
addCommentFab = findViewById(R.id.cardDetailAddCommentFab)
|
||||
}
|
||||
|
||||
private fun setupToolbar() {
|
||||
setSupportActionBar(toolbar)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
supportActionBar?.title = intent.getStringExtra(EXTRA_CARD_TITLE)
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
.ifBlank { getString(R.string.card_detail_title_default) }
|
||||
toolbar.setNavigationOnClickListener { finish() }
|
||||
retryButton.setOnClickListener { viewModel.retryLoad() }
|
||||
}
|
||||
|
||||
private fun setupAdapters() {
|
||||
tagAdapter = CardDetailTagChipAdapter()
|
||||
tagsRecycler.layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
|
||||
tagsRecycler.adapter = tagAdapter
|
||||
|
||||
timelineAdapter = ActivityTimelineAdapter(markdownRenderer)
|
||||
timelineRecycler.layoutManager = LinearLayoutManager(this)
|
||||
timelineRecycler.adapter = timelineAdapter
|
||||
}
|
||||
|
||||
private fun setupInputs() {
|
||||
titleInput.doAfterTextChanged {
|
||||
if (!suppressTitleChange) {
|
||||
viewModel.onTitleChanged(it?.toString().orEmpty())
|
||||
}
|
||||
}
|
||||
titleInput.setOnEditorActionListener { _, actionId, _ ->
|
||||
if (actionId == EditorInfo.IME_ACTION_DONE) {
|
||||
viewModel.onTitleFocusLost()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
titleInput.setOnFocusChangeListener { _, hasFocus ->
|
||||
if (!hasFocus) {
|
||||
viewModel.onTitleFocusLost()
|
||||
}
|
||||
}
|
||||
titleRetryButton.setOnClickListener { viewModel.retryTitleSave() }
|
||||
|
||||
dueDateText.setOnClickListener {
|
||||
if (!viewModel.uiState.value.isDueDateSaving) {
|
||||
openDatePicker(viewModel.uiState.value.dueDate)
|
||||
}
|
||||
}
|
||||
dueDateClearButton.setOnClickListener { viewModel.clearDueDate() }
|
||||
dueDateRetryButton.setOnClickListener { viewModel.retryDueDateSave() }
|
||||
|
||||
descriptionInput.doAfterTextChanged {
|
||||
if (!suppressDescriptionChange) {
|
||||
viewModel.onDescriptionChanged(it?.toString().orEmpty())
|
||||
}
|
||||
}
|
||||
descriptionInput.setOnFocusChangeListener { _, hasFocus ->
|
||||
if (!hasFocus) {
|
||||
viewModel.onDescriptionFocusLost()
|
||||
}
|
||||
}
|
||||
descriptionModeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
||||
if (!isChecked) {
|
||||
return@addOnButtonCheckedListener
|
||||
}
|
||||
when (checkedId) {
|
||||
R.id.cardDetailDescriptionEditButton -> viewModel.setDescriptionMode(DescriptionMode.EDIT)
|
||||
R.id.cardDetailDescriptionPreviewButton -> viewModel.setDescriptionMode(DescriptionMode.PREVIEW)
|
||||
}
|
||||
}
|
||||
descriptionRetryButton.setOnClickListener { viewModel.retryDescriptionSave() }
|
||||
|
||||
timelineRetryButton.setOnClickListener { viewModel.retryActivities() }
|
||||
addCommentFab.setOnClickListener { viewModel.openCommentDialog() }
|
||||
}
|
||||
|
||||
private fun observeViewModel() {
|
||||
lifecycleScope.launch {
|
||||
viewModel.uiState.collect { state ->
|
||||
testUiStateObserver?.invoke(state)
|
||||
render(state)
|
||||
}
|
||||
}
|
||||
lifecycleScope.launch {
|
||||
viewModel.events.collect { event ->
|
||||
testEventObserver?.invoke(event)
|
||||
when (event) {
|
||||
is CardDetailUiEvent.ShowSnackbar -> {
|
||||
Snackbar.make(findViewById(android.R.id.content), event.message, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
CardDetailUiEvent.SessionExpired -> {
|
||||
showSessionExpiredAndExit()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun render(state: CardDetailUiState) {
|
||||
val showLoading = state.isInitialLoading
|
||||
val showError = !showLoading && state.loadErrorMessage != null
|
||||
val showContent = !showLoading && !showError
|
||||
|
||||
initialProgress.visibility = if (showLoading) View.VISIBLE else View.GONE
|
||||
errorContainer.visibility = if (showError) View.VISIBLE else View.GONE
|
||||
contentScroll.visibility = if (showContent) View.VISIBLE else View.GONE
|
||||
if (showError) {
|
||||
errorText.text = state.loadErrorMessage
|
||||
}
|
||||
|
||||
renderTitle(state)
|
||||
renderTags(state)
|
||||
renderDueDate(state)
|
||||
renderDescription(state)
|
||||
renderTimeline(state)
|
||||
renderCommentDialog(state)
|
||||
addCommentFab.isEnabled = !state.isInitialLoading
|
||||
}
|
||||
|
||||
private fun renderTitle(state: CardDetailUiState) {
|
||||
val current = titleInput.text?.toString().orEmpty()
|
||||
if (current != state.title) {
|
||||
suppressTitleChange = true
|
||||
titleInput.setText(state.title)
|
||||
titleInput.setSelection(state.title.length)
|
||||
suppressTitleChange = false
|
||||
}
|
||||
supportActionBar?.title = state.title.trim().ifBlank {
|
||||
intent.getStringExtra(EXTRA_CARD_TITLE)
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
.ifBlank { getString(R.string.card_detail_title_default) }
|
||||
}
|
||||
|
||||
titleInput.isEnabled = !state.isTitleSaving
|
||||
titleSavingText.visibility = if (state.isTitleSaving) View.VISIBLE else View.GONE
|
||||
titleInputLayout.error = null
|
||||
if (state.titleErrorMessage.isNullOrBlank()) {
|
||||
titleErrorText.visibility = View.GONE
|
||||
titleRetryButton.visibility = View.GONE
|
||||
} else {
|
||||
titleErrorText.text = state.titleErrorMessage
|
||||
titleErrorText.visibility = View.VISIBLE
|
||||
titleRetryButton.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderTags(state: CardDetailUiState) {
|
||||
tagAdapter.submitTags(state.tags)
|
||||
tagsRecycler.visibility = if (state.tags.isEmpty()) View.GONE else View.VISIBLE
|
||||
}
|
||||
|
||||
private fun renderDueDate(state: CardDetailUiState) {
|
||||
val dueDate = state.dueDate
|
||||
if (dueDate == null) {
|
||||
dueDateText.text = getString(R.string.card_detail_set_due_date)
|
||||
dueDateText.setTextColor(
|
||||
MaterialColors.getColor(dueDateText, com.google.android.material.R.attr.colorOnSurfaceVariant, Color.DKGRAY),
|
||||
)
|
||||
dueDateClearButton.visibility = View.GONE
|
||||
} else {
|
||||
dueDateText.text = formatLocalDate(dueDate)
|
||||
val expired = dueDate.isBefore(LocalDate.now())
|
||||
val color = if (expired) {
|
||||
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)
|
||||
dueDateClearButton.visibility = View.VISIBLE
|
||||
}
|
||||
dueDateText.isEnabled = !state.isDueDateSaving
|
||||
dueDateClearButton.isEnabled = !state.isDueDateSaving
|
||||
dueDateSavingText.visibility = if (state.isDueDateSaving) View.VISIBLE else View.GONE
|
||||
if (state.dueDateErrorMessage.isNullOrBlank()) {
|
||||
dueDateErrorText.visibility = View.GONE
|
||||
dueDateRetryButton.visibility = View.GONE
|
||||
} else {
|
||||
dueDateErrorText.text = state.dueDateErrorMessage
|
||||
dueDateErrorText.visibility = View.VISIBLE
|
||||
dueDateRetryButton.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderDescription(state: CardDetailUiState) {
|
||||
val current = descriptionInput.text?.toString().orEmpty()
|
||||
if (current != state.description) {
|
||||
suppressDescriptionChange = true
|
||||
descriptionInput.setText(state.description)
|
||||
descriptionInput.setSelection(state.description.length)
|
||||
suppressDescriptionChange = false
|
||||
}
|
||||
|
||||
when (state.descriptionMode) {
|
||||
DescriptionMode.EDIT -> {
|
||||
descriptionModeToggle.check(R.id.cardDetailDescriptionEditButton)
|
||||
descriptionInputLayout.visibility = View.VISIBLE
|
||||
descriptionPreviewText.visibility = View.GONE
|
||||
}
|
||||
|
||||
DescriptionMode.PREVIEW -> {
|
||||
descriptionModeToggle.check(R.id.cardDetailDescriptionPreviewButton)
|
||||
descriptionInputLayout.visibility = View.GONE
|
||||
descriptionPreviewText.visibility = View.VISIBLE
|
||||
descriptionPreviewText.text = markdownRenderer.render(state.description)
|
||||
MarkdownRenderer.enableLinks(descriptionPreviewText)
|
||||
}
|
||||
}
|
||||
|
||||
descriptionInput.isEnabled = !state.isDescriptionSaving
|
||||
descriptionEditButton.isEnabled = true
|
||||
descriptionPreviewButton.isEnabled = true
|
||||
descriptionSavingText.visibility = if (state.isDescriptionSaving) View.VISIBLE else View.GONE
|
||||
if (state.descriptionErrorMessage.isNullOrBlank()) {
|
||||
descriptionErrorText.visibility = View.GONE
|
||||
descriptionRetryButton.visibility = View.GONE
|
||||
} else {
|
||||
descriptionErrorText.text = state.descriptionErrorMessage
|
||||
descriptionErrorText.visibility = View.VISIBLE
|
||||
descriptionRetryButton.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderTimeline(state: CardDetailUiState) {
|
||||
timelineProgress.visibility = if (state.isActivitiesLoading) View.VISIBLE else View.GONE
|
||||
if (state.activitiesErrorMessage.isNullOrBlank()) {
|
||||
timelineErrorText.visibility = View.GONE
|
||||
timelineRetryButton.visibility = View.GONE
|
||||
} else {
|
||||
timelineErrorText.text = state.activitiesErrorMessage
|
||||
timelineErrorText.visibility = View.VISIBLE
|
||||
timelineRetryButton.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
timelineAdapter.submitActivities(state.activities)
|
||||
timelineRecycler.visibility = if (state.activities.isEmpty()) View.GONE else View.VISIBLE
|
||||
timelineEmptyText.visibility = if (!state.isActivitiesLoading && state.activities.isEmpty() && state.activitiesErrorMessage.isNullOrBlank()) {
|
||||
View.VISIBLE
|
||||
} else {
|
||||
View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderCommentDialog(state: CardDetailUiState) {
|
||||
if (state.isCommentDialogOpen && commentDialog == null) {
|
||||
showCommentDialog(state)
|
||||
}
|
||||
if (!state.isCommentDialogOpen) {
|
||||
commentDialog?.dismiss()
|
||||
commentDialog = null
|
||||
return
|
||||
}
|
||||
|
||||
commentDialog?.let { dialog ->
|
||||
val toggle = dialog.findViewById<MaterialButtonToggleGroup>(R.id.commentDialogModeToggle)
|
||||
val inputLayout = dialog.findViewById<TextInputLayout>(R.id.commentDialogInputLayout)
|
||||
val input = dialog.findViewById<TextInputEditText>(R.id.commentDialogInput)
|
||||
val preview = dialog.findViewById<TextView>(R.id.commentDialogPreviewText)
|
||||
val error = dialog.findViewById<TextView>(R.id.commentDialogErrorText)
|
||||
val addButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE)
|
||||
val cancelButton = dialog.getButton(AlertDialog.BUTTON_NEGATIVE)
|
||||
|
||||
input?.let {
|
||||
val current = it.text?.toString().orEmpty()
|
||||
if (current != state.commentDraft) {
|
||||
suppressCommentChange = true
|
||||
it.setText(state.commentDraft)
|
||||
it.setSelection(state.commentDraft.length)
|
||||
suppressCommentChange = false
|
||||
}
|
||||
}
|
||||
|
||||
if (state.commentDialogMode == CommentDialogMode.EDIT) {
|
||||
toggle?.check(R.id.commentDialogEditButton)
|
||||
inputLayout?.visibility = View.VISIBLE
|
||||
preview?.visibility = View.GONE
|
||||
} else {
|
||||
toggle?.check(R.id.commentDialogPreviewButton)
|
||||
inputLayout?.visibility = View.GONE
|
||||
preview?.visibility = View.VISIBLE
|
||||
preview?.text = markdownRenderer.render(state.commentDraft)
|
||||
preview?.let { MarkdownRenderer.enableLinks(it) }
|
||||
}
|
||||
|
||||
if (state.commentErrorMessage.isNullOrBlank()) {
|
||||
error?.visibility = View.GONE
|
||||
} else {
|
||||
error?.text = state.commentErrorMessage
|
||||
error?.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
input?.isEnabled = !state.isCommentSubmitting
|
||||
addButton?.isEnabled = !state.isCommentSubmitting
|
||||
cancelButton?.isEnabled = !state.isCommentSubmitting
|
||||
}
|
||||
}
|
||||
|
||||
private fun showCommentDialog(initialState: CardDetailUiState) {
|
||||
val view = LayoutInflater.from(this).inflate(R.layout.dialog_add_comment, null)
|
||||
val toggle: MaterialButtonToggleGroup = view.findViewById(R.id.commentDialogModeToggle)
|
||||
val input: TextInputEditText = view.findViewById(R.id.commentDialogInput)
|
||||
|
||||
input.setText(initialState.commentDraft)
|
||||
input.doAfterTextChanged {
|
||||
if (!suppressCommentChange) {
|
||||
viewModel.onCommentChanged(it?.toString().orEmpty())
|
||||
}
|
||||
}
|
||||
toggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
||||
if (!isChecked) {
|
||||
return@addOnButtonCheckedListener
|
||||
}
|
||||
when (checkedId) {
|
||||
R.id.commentDialogEditButton -> viewModel.setCommentDialogMode(CommentDialogMode.EDIT)
|
||||
R.id.commentDialogPreviewButton -> viewModel.setCommentDialogMode(CommentDialogMode.PREVIEW)
|
||||
}
|
||||
}
|
||||
|
||||
val dialog = MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.card_detail_add_comment)
|
||||
.setView(view)
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.setPositiveButton(R.string.add) { _, _ -> }
|
||||
.create()
|
||||
|
||||
dialog.setOnShowListener {
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener {
|
||||
viewModel.submitComment()
|
||||
}
|
||||
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.setOnClickListener {
|
||||
viewModel.closeCommentDialog()
|
||||
}
|
||||
}
|
||||
dialog.setOnDismissListener {
|
||||
if (commentDialog === dialog) {
|
||||
commentDialog = null
|
||||
if (viewModel.uiState.value.isCommentDialogOpen) {
|
||||
viewModel.closeCommentDialog()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
commentDialog = dialog
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private fun openDatePicker(seed: LocalDate?) {
|
||||
val date = seed ?: LocalDate.now()
|
||||
DatePickerDialog(
|
||||
this,
|
||||
{ _, year, month, dayOfMonth ->
|
||||
viewModel.setDueDate(LocalDate.of(year, month + 1, dayOfMonth))
|
||||
},
|
||||
date.year,
|
||||
date.monthValue - 1,
|
||||
date.dayOfMonth,
|
||||
).show()
|
||||
}
|
||||
|
||||
private fun showBlockingDialogAndFinish(message: String) {
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setMessage(message)
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(R.string.ok) { _, _ -> finish() }
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun showSessionExpiredAndExit() {
|
||||
if (hasShownSessionDialog) {
|
||||
return
|
||||
}
|
||||
hasShownSessionDialog = true
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setMessage(R.string.card_detail_session_expired)
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(R.string.ok) { _, _ ->
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||
startActivity(intent)
|
||||
finish()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun formatLocalDate(date: LocalDate): String {
|
||||
val epoch = date.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli()
|
||||
return DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(epoch))
|
||||
}
|
||||
|
||||
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_CARD_ID = "extra_card_id"
|
||||
const val EXTRA_CARD_TITLE = "extra_card_title"
|
||||
|
||||
var testDataSourceFactory: ((String) -> CardDetailDataSource)? = null
|
||||
var testUiStateObserver: ((CardDetailUiState) -> Unit)? = null
|
||||
var testEventObserver: ((CardDetailUiEvent) -> Unit)? = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package space.hackenslacker.kanbn4droid.app.carddetail
|
||||
|
||||
import java.time.LocalDate
|
||||
|
||||
data class CardDetail(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val description: String,
|
||||
val dueDate: LocalDate?,
|
||||
val listPublicId: String?,
|
||||
val index: Int?,
|
||||
val tags: List<CardDetailTag>,
|
||||
)
|
||||
|
||||
data class CardDetailTag(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val colorHex: String,
|
||||
)
|
||||
|
||||
data class CardActivity(
|
||||
val id: String,
|
||||
val type: String,
|
||||
val text: String,
|
||||
val createdAtEpochMillis: Long,
|
||||
)
|
||||
|
||||
enum class DescriptionMode {
|
||||
EDIT,
|
||||
PREVIEW,
|
||||
}
|
||||
|
||||
enum class CommentDialogMode {
|
||||
EDIT,
|
||||
PREVIEW,
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package space.hackenslacker.kanbn4droid.app.carddetail
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.time.LocalDate
|
||||
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 CardDetailRepository(
|
||||
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."
|
||||
private const val SESSION_EXPIRED_MESSAGE = "Session expired. Please sign in again."
|
||||
private val AUTH_STATUS_CODE_REGEX = Regex("\\b(401|403)\\b")
|
||||
}
|
||||
|
||||
sealed interface Result<out T> {
|
||||
data class Success<T>(val value: T) : Result<T>
|
||||
|
||||
sealed interface Failure : Result<Nothing> {
|
||||
val message: String
|
||||
|
||||
data class Generic(override val message: String) : Failure
|
||||
data class SessionExpired(override val message: String = SESSION_EXPIRED_MESSAGE) : Failure
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadCard(cardId: String): Result<CardDetail> {
|
||||
val normalizedCardId = cardId.trim()
|
||||
if (normalizedCardId.isBlank()) {
|
||||
return Result.Failure.Generic("Card id is required")
|
||||
}
|
||||
|
||||
val session = when (val sessionResult = session()) {
|
||||
is Result.Success -> sessionResult.value
|
||||
is Result.Failure -> return sessionResult
|
||||
}
|
||||
|
||||
return when (
|
||||
val detailResult = apiClient.getCardDetail(
|
||||
baseUrl = session.baseUrl,
|
||||
apiKey = session.apiKey,
|
||||
cardId = normalizedCardId,
|
||||
)
|
||||
) {
|
||||
is BoardsApiResult.Success -> Result.Success(detailResult.value)
|
||||
is BoardsApiResult.Failure -> mapFailure(detailResult.message)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateTitle(cardId: String, title: String): Result<Unit> {
|
||||
val normalizedCardId = cardId.trim()
|
||||
if (normalizedCardId.isBlank()) {
|
||||
return Result.Failure.Generic("Card id is required")
|
||||
}
|
||||
|
||||
val normalizedTitle = title.trim()
|
||||
if (normalizedTitle.isBlank()) {
|
||||
return Result.Failure.Generic("Card title is required")
|
||||
}
|
||||
|
||||
val session = when (val sessionResult = session()) {
|
||||
is Result.Success -> sessionResult.value
|
||||
is Result.Failure -> return sessionResult
|
||||
}
|
||||
|
||||
val detail = when (
|
||||
val detailResult = apiClient.getCardDetail(
|
||||
baseUrl = session.baseUrl,
|
||||
apiKey = session.apiKey,
|
||||
cardId = normalizedCardId,
|
||||
)
|
||||
) {
|
||||
is BoardsApiResult.Success -> detailResult.value
|
||||
is BoardsApiResult.Failure -> return mapFailure(detailResult.message)
|
||||
}
|
||||
|
||||
return when (
|
||||
val updateResult = apiClient.updateCard(
|
||||
baseUrl = session.baseUrl,
|
||||
apiKey = session.apiKey,
|
||||
cardId = normalizedCardId,
|
||||
title = normalizedTitle,
|
||||
description = detail.description,
|
||||
dueDate = detail.dueDate,
|
||||
)
|
||||
) {
|
||||
is BoardsApiResult.Success -> Result.Success(Unit)
|
||||
is BoardsApiResult.Failure -> mapFailure(updateResult.message)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateDescription(cardId: String, description: String?): Result<Unit> {
|
||||
val normalizedCardId = cardId.trim()
|
||||
if (normalizedCardId.isBlank()) {
|
||||
return Result.Failure.Generic("Card id is required")
|
||||
}
|
||||
|
||||
val normalizedDescription = description?.trim()?.takeIf { it.isNotBlank() }
|
||||
|
||||
val session = when (val sessionResult = session()) {
|
||||
is Result.Success -> sessionResult.value
|
||||
is Result.Failure -> return sessionResult
|
||||
}
|
||||
|
||||
val detail = when (
|
||||
val detailResult = apiClient.getCardDetail(
|
||||
baseUrl = session.baseUrl,
|
||||
apiKey = session.apiKey,
|
||||
cardId = normalizedCardId,
|
||||
)
|
||||
) {
|
||||
is BoardsApiResult.Success -> detailResult.value
|
||||
is BoardsApiResult.Failure -> return mapFailure(detailResult.message)
|
||||
}
|
||||
|
||||
return when (
|
||||
val updateResult = apiClient.updateCard(
|
||||
baseUrl = session.baseUrl,
|
||||
apiKey = session.apiKey,
|
||||
cardId = normalizedCardId,
|
||||
title = detail.title,
|
||||
description = normalizedDescription ?: "",
|
||||
dueDate = detail.dueDate,
|
||||
)
|
||||
) {
|
||||
is BoardsApiResult.Success -> Result.Success(Unit)
|
||||
is BoardsApiResult.Failure -> mapFailure(updateResult.message)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateDueDate(cardId: String, dueDate: LocalDate?): Result<Unit> {
|
||||
val normalizedCardId = cardId.trim()
|
||||
if (normalizedCardId.isBlank()) {
|
||||
return Result.Failure.Generic("Card id is required")
|
||||
}
|
||||
|
||||
val session = when (val sessionResult = session()) {
|
||||
is Result.Success -> sessionResult.value
|
||||
is Result.Failure -> return sessionResult
|
||||
}
|
||||
|
||||
val detail = when (
|
||||
val detailResult = apiClient.getCardDetail(
|
||||
baseUrl = session.baseUrl,
|
||||
apiKey = session.apiKey,
|
||||
cardId = normalizedCardId,
|
||||
)
|
||||
) {
|
||||
is BoardsApiResult.Success -> detailResult.value
|
||||
is BoardsApiResult.Failure -> return mapFailure(detailResult.message)
|
||||
}
|
||||
|
||||
return when (
|
||||
val updateResult = apiClient.updateCard(
|
||||
baseUrl = session.baseUrl,
|
||||
apiKey = session.apiKey,
|
||||
cardId = normalizedCardId,
|
||||
title = detail.title,
|
||||
description = detail.description,
|
||||
dueDate = dueDate,
|
||||
)
|
||||
) {
|
||||
is BoardsApiResult.Success -> Result.Success(Unit)
|
||||
is BoardsApiResult.Failure -> mapFailure(updateResult.message)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun listActivities(cardId: String): Result<List<CardActivity>> {
|
||||
val normalizedCardId = cardId.trim()
|
||||
if (normalizedCardId.isBlank()) {
|
||||
return Result.Failure.Generic("Card id is required")
|
||||
}
|
||||
|
||||
val session = when (val sessionResult = session()) {
|
||||
is Result.Success -> sessionResult.value
|
||||
is Result.Failure -> return sessionResult
|
||||
}
|
||||
|
||||
return when (
|
||||
val activitiesResult = apiClient.listCardActivities(
|
||||
baseUrl = session.baseUrl,
|
||||
apiKey = session.apiKey,
|
||||
cardId = normalizedCardId,
|
||||
)
|
||||
) {
|
||||
is BoardsApiResult.Success -> Result.Success(
|
||||
activitiesResult.value
|
||||
.sortedByDescending { it.createdAtEpochMillis }
|
||||
.take(10),
|
||||
)
|
||||
|
||||
is BoardsApiResult.Failure -> mapFailure(activitiesResult.message)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addComment(cardId: String, comment: String): Result<List<CardActivity>> {
|
||||
val normalizedCardId = cardId.trim()
|
||||
if (normalizedCardId.isBlank()) {
|
||||
return Result.Failure.Generic("Card id is required")
|
||||
}
|
||||
|
||||
val normalizedComment = comment.trim()
|
||||
if (normalizedComment.isBlank()) {
|
||||
return Result.Failure.Generic("Comment is required")
|
||||
}
|
||||
|
||||
val preAddSnapshot = when (val preAddActivitiesResult = listActivities(normalizedCardId)) {
|
||||
is Result.Success -> preAddActivitiesResult.value
|
||||
is Result.Failure.SessionExpired -> return preAddActivitiesResult
|
||||
is Result.Failure.Generic -> emptyList()
|
||||
}
|
||||
|
||||
val session = when (val sessionResult = session()) {
|
||||
is Result.Success -> sessionResult.value
|
||||
is Result.Failure -> return sessionResult
|
||||
}
|
||||
|
||||
when (
|
||||
val addCommentResult = apiClient.addCardComment(
|
||||
baseUrl = session.baseUrl,
|
||||
apiKey = session.apiKey,
|
||||
cardId = normalizedCardId,
|
||||
comment = normalizedComment,
|
||||
)
|
||||
) {
|
||||
is BoardsApiResult.Success -> Unit
|
||||
is BoardsApiResult.Failure -> return mapFailure(addCommentResult.message)
|
||||
}
|
||||
|
||||
return when (val refreshResult = listActivities(normalizedCardId)) {
|
||||
is Result.Success -> refreshResult
|
||||
is Result.Failure.SessionExpired -> refreshResult
|
||||
is Result.Failure.Generic -> Result.Success(preAddSnapshot)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun session(): Result<SessionSnapshot> {
|
||||
val baseUrl = sessionStore.getBaseUrl()?.takeIf { it.isNotBlank() }
|
||||
?: return Result.Failure.SessionExpired(MISSING_SESSION_MESSAGE)
|
||||
val apiKey = withContext(ioDispatcher) {
|
||||
apiKeyStore.getApiKey(baseUrl)
|
||||
}.getOrNull()?.takeIf { it.isNotBlank() }
|
||||
?: return Result.Failure.SessionExpired(MISSING_SESSION_MESSAGE)
|
||||
|
||||
return Result.Success(SessionSnapshot(baseUrl = baseUrl, apiKey = apiKey))
|
||||
}
|
||||
|
||||
private fun mapFailure(message: String): Result.Failure {
|
||||
val normalizedMessage = message.trim().ifBlank { "Unknown error" }
|
||||
return if (isAuthFailure(normalizedMessage)) {
|
||||
Result.Failure.SessionExpired()
|
||||
} else {
|
||||
Result.Failure.Generic(normalizedMessage)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isAuthFailure(message: String): Boolean {
|
||||
val lower = message.lowercase()
|
||||
val hasAuthToken =
|
||||
lower.contains("authentication failed") ||
|
||||
lower.contains("unauthorized") ||
|
||||
lower.contains("forbidden")
|
||||
val hasAuthStatusCode = AUTH_STATUS_CODE_REGEX.containsMatchIn(lower) &&
|
||||
(
|
||||
lower.contains("server error") ||
|
||||
lower.contains("http") ||
|
||||
lower.contains("status") ||
|
||||
lower.contains("code")
|
||||
)
|
||||
return hasAuthToken || hasAuthStatusCode
|
||||
}
|
||||
|
||||
private data class SessionSnapshot(
|
||||
val baseUrl: String,
|
||||
val apiKey: String,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package space.hackenslacker.kanbn4droid.app.carddetail
|
||||
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Color
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.chip.Chip
|
||||
import com.google.android.material.color.MaterialColors
|
||||
|
||||
class CardDetailTagChipAdapter : RecyclerView.Adapter<CardDetailTagChipAdapter.TagViewHolder>() {
|
||||
|
||||
private var tags: List<CardDetailTag> = emptyList()
|
||||
|
||||
fun submitTags(value: List<CardDetailTag>) {
|
||||
tags = value
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TagViewHolder {
|
||||
return TagViewHolder(
|
||||
Chip(parent.context).apply {
|
||||
isClickable = false
|
||||
isCheckable = false
|
||||
chipBackgroundColor = null
|
||||
chipStrokeWidth = 2f
|
||||
val margin = (8 * parent.context.resources.displayMetrics.density).toInt()
|
||||
layoutParams = ViewGroup.MarginLayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
).apply {
|
||||
marginEnd = margin
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: TagViewHolder, position: Int) {
|
||||
holder.bind(tags[position])
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = tags.size
|
||||
|
||||
class TagViewHolder(private val chip: Chip) : RecyclerView.ViewHolder(chip) {
|
||||
fun bind(tag: CardDetailTag) {
|
||||
chip.text = tag.name
|
||||
chip.chipStrokeColor = ColorStateList.valueOf(parseColorOrFallback(tag.colorHex))
|
||||
}
|
||||
|
||||
private fun parseColorOrFallback(value: String): Int {
|
||||
return runCatching { Color.parseColor(value) }
|
||||
.getOrElse {
|
||||
MaterialColors.getColor(chip, com.google.android.material.R.attr.colorOnSurface, Color.DKGRAY)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
package space.hackenslacker.kanbn4droid.app.carddetail
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import java.time.LocalDate
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
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
|
||||
|
||||
data class CardDetailUiState(
|
||||
val isInitialLoading: Boolean = false,
|
||||
val loadErrorMessage: String? = null,
|
||||
val isSessionExpired: Boolean = false,
|
||||
val title: String = "",
|
||||
val titleErrorMessage: String? = null,
|
||||
val isTitleSaving: Boolean = false,
|
||||
val description: String = "",
|
||||
val descriptionErrorMessage: String? = null,
|
||||
val isDescriptionSaving: Boolean = false,
|
||||
val isDescriptionDirty: Boolean = false,
|
||||
val descriptionMode: DescriptionMode = DescriptionMode.EDIT,
|
||||
val dueDate: LocalDate? = null,
|
||||
val dueDateErrorMessage: String? = null,
|
||||
val isDueDateSaving: Boolean = false,
|
||||
val tags: List<CardDetailTag> = emptyList(),
|
||||
val activities: List<CardActivity> = emptyList(),
|
||||
val isActivitiesLoading: Boolean = false,
|
||||
val activitiesErrorMessage: String? = null,
|
||||
val isCommentDialogOpen: Boolean = false,
|
||||
val commentDialogMode: CommentDialogMode = CommentDialogMode.EDIT,
|
||||
val commentDraft: String = "",
|
||||
val isCommentSubmitting: Boolean = false,
|
||||
val commentErrorMessage: String? = null,
|
||||
)
|
||||
|
||||
sealed interface CardDetailUiEvent {
|
||||
data object SessionExpired : CardDetailUiEvent
|
||||
data class ShowSnackbar(val message: String) : CardDetailUiEvent
|
||||
}
|
||||
|
||||
sealed interface DataSourceResult<out T> {
|
||||
data class Success<T>(val value: T) : DataSourceResult<T>
|
||||
data class GenericError(val message: String) : DataSourceResult<Nothing>
|
||||
data object SessionExpired : DataSourceResult<Nothing>
|
||||
}
|
||||
|
||||
interface CardDetailDataSource {
|
||||
suspend fun loadCard(cardId: String): DataSourceResult<CardDetail>
|
||||
suspend fun updateTitle(cardId: String, title: String): DataSourceResult<Unit>
|
||||
suspend fun updateDescription(cardId: String, description: String): DataSourceResult<Unit>
|
||||
suspend fun updateDueDate(cardId: String, dueDate: LocalDate?): DataSourceResult<Unit>
|
||||
suspend fun listActivities(cardId: String): DataSourceResult<List<CardActivity>>
|
||||
suspend fun addComment(cardId: String, comment: String): DataSourceResult<List<CardActivity>>
|
||||
}
|
||||
|
||||
internal class CardDetailRepositoryDataSource(
|
||||
private val repository: CardDetailRepository,
|
||||
private val loadCardCall: suspend (String) -> CardDetailRepository.Result<CardDetail>,
|
||||
) : CardDetailDataSource {
|
||||
override suspend fun loadCard(cardId: String): DataSourceResult<CardDetail> {
|
||||
return loadCardCall(cardId).toDataSourceResult()
|
||||
}
|
||||
|
||||
override suspend fun updateTitle(cardId: String, title: String): DataSourceResult<Unit> {
|
||||
return repository.updateTitle(cardId, title).toDataSourceResult()
|
||||
}
|
||||
|
||||
override suspend fun updateDescription(cardId: String, description: String): DataSourceResult<Unit> {
|
||||
return repository.updateDescription(cardId, description).toDataSourceResult()
|
||||
}
|
||||
|
||||
override suspend fun updateDueDate(cardId: String, dueDate: LocalDate?): DataSourceResult<Unit> {
|
||||
return repository.updateDueDate(cardId, dueDate).toDataSourceResult()
|
||||
}
|
||||
|
||||
override suspend fun listActivities(cardId: String): DataSourceResult<List<CardActivity>> {
|
||||
return repository.listActivities(cardId).toDataSourceResult()
|
||||
}
|
||||
|
||||
override suspend fun addComment(cardId: String, comment: String): DataSourceResult<List<CardActivity>> {
|
||||
val result = repository.addComment(cardId, comment)
|
||||
return when (result) {
|
||||
is CardDetailRepository.Result.Success -> DataSourceResult.Success(result.value)
|
||||
is CardDetailRepository.Result.Failure.SessionExpired -> DataSourceResult.SessionExpired
|
||||
is CardDetailRepository.Result.Failure.Generic -> {
|
||||
DataSourceResult.GenericError(result.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CardDetailViewModel(
|
||||
private val cardId: String,
|
||||
private val repository: CardDetailDataSource,
|
||||
private val descriptionDebounceMillis: Long = 800L,
|
||||
private val titleRequiredMessage: String = "Card title is required",
|
||||
private val commentRequiredMessage: String = "Comment is required",
|
||||
private val commentAddedMessage: String = "Comment added",
|
||||
) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(CardDetailUiState())
|
||||
val uiState: StateFlow<CardDetailUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val _events = MutableSharedFlow<CardDetailUiEvent>()
|
||||
val events: SharedFlow<CardDetailUiEvent> = _events.asSharedFlow()
|
||||
|
||||
private var persistedTitle: String = ""
|
||||
private var persistedDescription: String = ""
|
||||
private var persistedDueDate: LocalDate? = null
|
||||
|
||||
private var lastAttemptedTitle: String? = null
|
||||
private var lastAttemptedDescription: String? = null
|
||||
private var hasLastAttemptedDueDate: Boolean = false
|
||||
private var lastAttemptedDueDate: LocalDate? = null
|
||||
private var lastAttemptedComment: String? = null
|
||||
|
||||
private var inFlightDescriptionPayload: String? = null
|
||||
private var pendingDescriptionPayload: String? = null
|
||||
private var descriptionDebounceJob: Job? = null
|
||||
|
||||
fun load() {
|
||||
if (_uiState.value.isSessionExpired) {
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isInitialLoading = true,
|
||||
loadErrorMessage = null,
|
||||
)
|
||||
}
|
||||
|
||||
when (val result = repository.loadCard(cardId)) {
|
||||
is DataSourceResult.Success -> {
|
||||
val detail = result.value
|
||||
persistedTitle = detail.title
|
||||
persistedDescription = detail.description
|
||||
persistedDueDate = detail.dueDate
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isInitialLoading = false,
|
||||
loadErrorMessage = null,
|
||||
title = detail.title,
|
||||
description = detail.description,
|
||||
dueDate = detail.dueDate,
|
||||
tags = detail.tags,
|
||||
titleErrorMessage = null,
|
||||
descriptionErrorMessage = null,
|
||||
dueDateErrorMessage = null,
|
||||
)
|
||||
}
|
||||
loadActivities()
|
||||
}
|
||||
|
||||
is DataSourceResult.SessionExpired -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isInitialLoading = false,
|
||||
isSessionExpired = true,
|
||||
)
|
||||
}
|
||||
_events.emit(CardDetailUiEvent.SessionExpired)
|
||||
}
|
||||
|
||||
is DataSourceResult.GenericError -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isInitialLoading = false,
|
||||
loadErrorMessage = result.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun retryLoad() {
|
||||
load()
|
||||
}
|
||||
|
||||
fun onTitleChanged(value: String) {
|
||||
_uiState.update { it.copy(title = value, titleErrorMessage = null) }
|
||||
}
|
||||
|
||||
fun onTitleFocusLost() {
|
||||
saveTitle(_uiState.value.title)
|
||||
}
|
||||
|
||||
fun retryTitleSave() {
|
||||
val payload = lastAttemptedTitle ?: return
|
||||
saveTitle(payload, fromRetry = true)
|
||||
}
|
||||
|
||||
fun onDescriptionChanged(value: String) {
|
||||
if (_uiState.value.isSessionExpired) {
|
||||
return
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
description = value,
|
||||
descriptionErrorMessage = null,
|
||||
isDescriptionDirty = value != persistedDescription,
|
||||
)
|
||||
}
|
||||
|
||||
scheduleDescriptionSave()
|
||||
}
|
||||
|
||||
fun onDescriptionFocusLost() {
|
||||
flushDescriptionImmediately()
|
||||
}
|
||||
|
||||
fun onStop() {
|
||||
flushDescriptionImmediately()
|
||||
}
|
||||
|
||||
fun retryDescriptionSave() {
|
||||
val payload = lastAttemptedDescription ?: return
|
||||
saveDescription(payload, fromRetry = true)
|
||||
}
|
||||
|
||||
fun setDescriptionMode(mode: DescriptionMode) {
|
||||
_uiState.update { it.copy(descriptionMode = mode) }
|
||||
}
|
||||
|
||||
fun setDueDate(dueDate: LocalDate) {
|
||||
saveDueDate(dueDate)
|
||||
}
|
||||
|
||||
fun clearDueDate() {
|
||||
saveDueDate(null)
|
||||
}
|
||||
|
||||
fun retryDueDateSave() {
|
||||
if (!hasLastAttemptedDueDate) {
|
||||
return
|
||||
}
|
||||
saveDueDate(lastAttemptedDueDate, fromRetry = true)
|
||||
}
|
||||
|
||||
fun retryActivities() {
|
||||
loadActivities()
|
||||
}
|
||||
|
||||
fun openCommentDialog() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isCommentDialogOpen = true,
|
||||
commentDialogMode = CommentDialogMode.EDIT,
|
||||
commentErrorMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun closeCommentDialog() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isCommentDialogOpen = false,
|
||||
commentDraft = "",
|
||||
commentDialogMode = CommentDialogMode.EDIT,
|
||||
commentErrorMessage = null,
|
||||
isCommentSubmitting = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onCommentChanged(value: String) {
|
||||
_uiState.update { it.copy(commentDraft = value, commentErrorMessage = null) }
|
||||
}
|
||||
|
||||
fun setCommentDialogMode(mode: CommentDialogMode) {
|
||||
_uiState.update { it.copy(commentDialogMode = mode) }
|
||||
}
|
||||
|
||||
fun submitComment() {
|
||||
if (_uiState.value.isSessionExpired || _uiState.value.isCommentSubmitting) {
|
||||
return
|
||||
}
|
||||
|
||||
val payload = _uiState.value.commentDraft.trim()
|
||||
if (payload.isBlank()) {
|
||||
_uiState.update { it.copy(commentErrorMessage = commentRequiredMessage) }
|
||||
return
|
||||
}
|
||||
|
||||
lastAttemptedComment = payload
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isCommentSubmitting = true, commentErrorMessage = null) }
|
||||
when (val result = repository.addComment(cardId, payload)) {
|
||||
is DataSourceResult.Success -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isCommentSubmitting = false,
|
||||
isCommentDialogOpen = false,
|
||||
commentDraft = "",
|
||||
commentDialogMode = CommentDialogMode.EDIT,
|
||||
activities = result.value,
|
||||
activitiesErrorMessage = null,
|
||||
isActivitiesLoading = false,
|
||||
)
|
||||
}
|
||||
_events.emit(CardDetailUiEvent.ShowSnackbar(commentAddedMessage))
|
||||
}
|
||||
|
||||
is DataSourceResult.SessionExpired -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isCommentSubmitting = false,
|
||||
isSessionExpired = true,
|
||||
)
|
||||
}
|
||||
_events.emit(CardDetailUiEvent.SessionExpired)
|
||||
}
|
||||
|
||||
is DataSourceResult.GenericError -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isCommentSubmitting = false,
|
||||
commentErrorMessage = result.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun retryAddComment() {
|
||||
if (_uiState.value.isSessionExpired) {
|
||||
return
|
||||
}
|
||||
val payload = lastAttemptedComment ?: return
|
||||
_uiState.update { it.copy(commentDraft = payload) }
|
||||
submitComment()
|
||||
}
|
||||
|
||||
private fun saveTitle(rawTitle: String, fromRetry: Boolean = false) {
|
||||
if (_uiState.value.isSessionExpired || _uiState.value.isTitleSaving) {
|
||||
return
|
||||
}
|
||||
val normalized = rawTitle.trim()
|
||||
if (normalized.isBlank()) {
|
||||
_uiState.update { it.copy(titleErrorMessage = titleRequiredMessage) }
|
||||
return
|
||||
}
|
||||
if (!fromRetry && normalized == persistedTitle) {
|
||||
return
|
||||
}
|
||||
lastAttemptedTitle = normalized
|
||||
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isTitleSaving = true, titleErrorMessage = null) }
|
||||
when (val result = repository.updateTitle(cardId, normalized)) {
|
||||
is DataSourceResult.Success -> {
|
||||
persistedTitle = normalized
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
title = normalized,
|
||||
isTitleSaving = false,
|
||||
titleErrorMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is DataSourceResult.SessionExpired -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isTitleSaving = false,
|
||||
isSessionExpired = true,
|
||||
)
|
||||
}
|
||||
_events.emit(CardDetailUiEvent.SessionExpired)
|
||||
}
|
||||
|
||||
is DataSourceResult.GenericError -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isTitleSaving = false,
|
||||
titleErrorMessage = result.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleDescriptionSave() {
|
||||
if (_uiState.value.isSessionExpired) {
|
||||
return
|
||||
}
|
||||
descriptionDebounceJob?.cancel()
|
||||
descriptionDebounceJob = viewModelScope.launch {
|
||||
delay(descriptionDebounceMillis)
|
||||
saveDescription(_uiState.value.description)
|
||||
}
|
||||
}
|
||||
|
||||
private fun flushDescriptionImmediately() {
|
||||
if (_uiState.value.isSessionExpired || !_uiState.value.isDescriptionDirty) {
|
||||
return
|
||||
}
|
||||
descriptionDebounceJob?.cancel()
|
||||
saveDescription(_uiState.value.description)
|
||||
}
|
||||
|
||||
private fun saveDescription(rawDescription: String, fromRetry: Boolean = false) {
|
||||
if (_uiState.value.isSessionExpired) {
|
||||
return
|
||||
}
|
||||
val normalized = rawDescription
|
||||
if (!fromRetry && normalized == persistedDescription) {
|
||||
_uiState.update { it.copy(isDescriptionDirty = it.description != persistedDescription) }
|
||||
return
|
||||
}
|
||||
if (!fromRetry && normalized == inFlightDescriptionPayload) {
|
||||
return
|
||||
}
|
||||
if (_uiState.value.isDescriptionSaving) {
|
||||
if (normalized != inFlightDescriptionPayload) {
|
||||
pendingDescriptionPayload = normalized
|
||||
}
|
||||
return
|
||||
}
|
||||
lastAttemptedDescription = normalized
|
||||
pendingDescriptionPayload = null
|
||||
|
||||
viewModelScope.launch {
|
||||
inFlightDescriptionPayload = normalized
|
||||
_uiState.update { it.copy(isDescriptionSaving = true, descriptionErrorMessage = null) }
|
||||
when (val result = repository.updateDescription(cardId, normalized)) {
|
||||
is DataSourceResult.Success -> {
|
||||
persistedDescription = normalized
|
||||
_uiState.update {
|
||||
val stillDirty = it.description != persistedDescription
|
||||
it.copy(
|
||||
isDescriptionSaving = false,
|
||||
isDescriptionDirty = stillDirty,
|
||||
descriptionErrorMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is DataSourceResult.SessionExpired -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isDescriptionSaving = false,
|
||||
isSessionExpired = true,
|
||||
)
|
||||
}
|
||||
_events.emit(CardDetailUiEvent.SessionExpired)
|
||||
}
|
||||
|
||||
is DataSourceResult.GenericError -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isDescriptionSaving = false,
|
||||
isDescriptionDirty = it.description != persistedDescription,
|
||||
descriptionErrorMessage = result.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
inFlightDescriptionPayload = null
|
||||
|
||||
val pending = pendingDescriptionPayload
|
||||
if (!_uiState.value.isSessionExpired && pending != null && pending != persistedDescription) {
|
||||
saveDescription(pending)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveDueDate(dueDate: LocalDate?, fromRetry: Boolean = false) {
|
||||
if (_uiState.value.isSessionExpired || _uiState.value.isDueDateSaving) {
|
||||
return
|
||||
}
|
||||
if (!fromRetry && dueDate == persistedDueDate) {
|
||||
_uiState.update { it.copy(dueDate = dueDate, dueDateErrorMessage = null) }
|
||||
return
|
||||
}
|
||||
|
||||
hasLastAttemptedDueDate = true
|
||||
lastAttemptedDueDate = dueDate
|
||||
|
||||
viewModelScope.launch {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
dueDate = dueDate,
|
||||
isDueDateSaving = true,
|
||||
dueDateErrorMessage = null,
|
||||
)
|
||||
}
|
||||
|
||||
when (val result = repository.updateDueDate(cardId, dueDate)) {
|
||||
is DataSourceResult.Success -> {
|
||||
persistedDueDate = dueDate
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isDueDateSaving = false,
|
||||
dueDateErrorMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is DataSourceResult.SessionExpired -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isDueDateSaving = false,
|
||||
isSessionExpired = true,
|
||||
)
|
||||
}
|
||||
_events.emit(CardDetailUiEvent.SessionExpired)
|
||||
}
|
||||
|
||||
is DataSourceResult.GenericError -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isDueDateSaving = false,
|
||||
dueDateErrorMessage = result.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadActivities() {
|
||||
if (_uiState.value.isSessionExpired) {
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isActivitiesLoading = true, activitiesErrorMessage = null) }
|
||||
when (val result = repository.listActivities(cardId)) {
|
||||
is DataSourceResult.Success -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isActivitiesLoading = false,
|
||||
activities = result.value,
|
||||
activitiesErrorMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is DataSourceResult.SessionExpired -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isActivitiesLoading = false,
|
||||
isSessionExpired = true,
|
||||
)
|
||||
}
|
||||
_events.emit(CardDetailUiEvent.SessionExpired)
|
||||
}
|
||||
|
||||
is DataSourceResult.GenericError -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isActivitiesLoading = false,
|
||||
activitiesErrorMessage = result.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val cardId: String,
|
||||
private val dataSource: CardDetailDataSource,
|
||||
private val titleRequiredMessage: String = "Card title is required",
|
||||
private val commentRequiredMessage: String = "Comment is required",
|
||||
private val commentAddedMessage: String = "Comment added",
|
||||
) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
if (modelClass.isAssignableFrom(CardDetailViewModel::class.java)) {
|
||||
return CardDetailViewModel(
|
||||
cardId = cardId,
|
||||
repository = dataSource,
|
||||
titleRequiredMessage = titleRequiredMessage,
|
||||
commentRequiredMessage = commentRequiredMessage,
|
||||
commentAddedMessage = commentAddedMessage,
|
||||
) as T
|
||||
}
|
||||
throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> CardDetailRepository.Result<T>.toDataSourceResult(): DataSourceResult<T> {
|
||||
return when (this) {
|
||||
is CardDetailRepository.Result.Success -> DataSourceResult.Success(value)
|
||||
is CardDetailRepository.Result.Failure.Generic -> DataSourceResult.GenericError(message)
|
||||
is CardDetailRepository.Result.Failure.SessionExpired -> DataSourceResult.SessionExpired
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package space.hackenslacker.kanbn4droid.app.carddetail
|
||||
|
||||
import android.text.Spanned
|
||||
import android.text.method.MovementMethod
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.widget.TextView
|
||||
import androidx.core.text.HtmlCompat
|
||||
import org.commonmark.parser.Parser
|
||||
import org.commonmark.renderer.html.HtmlRenderer
|
||||
|
||||
class MarkdownRenderer(
|
||||
private val parseToHtml: (String) -> String = defaultParseToHtml(),
|
||||
private val htmlToSpanned: (String) -> Spanned = { html ->
|
||||
HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_LEGACY)
|
||||
},
|
||||
) {
|
||||
|
||||
fun render(markdown: String): Spanned {
|
||||
return try {
|
||||
val html = parseToHtml(markdown)
|
||||
htmlToSpanned(html)
|
||||
} catch (_: Exception) {
|
||||
PlainTextSpanned(markdown)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun enableLinks(textView: TextView) {
|
||||
enableLinks(
|
||||
applyMovementMethod = { movementMethod -> textView.movementMethod = movementMethod },
|
||||
movementMethodProvider = { LinkMovementMethod.getInstance() },
|
||||
)
|
||||
}
|
||||
|
||||
fun enableLinks(
|
||||
applyMovementMethod: (MovementMethod) -> Unit,
|
||||
movementMethodProvider: () -> MovementMethod = { LinkMovementMethod.getInstance() },
|
||||
) {
|
||||
applyMovementMethod(movementMethodProvider())
|
||||
}
|
||||
|
||||
private fun defaultParseToHtml(): (String) -> String {
|
||||
val parser = Parser.builder().build()
|
||||
val renderer = HtmlRenderer.builder()
|
||||
.escapeHtml(true)
|
||||
.build()
|
||||
return { markdown ->
|
||||
val document = parser.parse(markdown)
|
||||
renderer.render(document)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class PlainTextSpanned(
|
||||
private val text: String,
|
||||
) : Spanned {
|
||||
override val length: Int
|
||||
get() = text.length
|
||||
|
||||
override fun get(index: Int): Char = text[index]
|
||||
|
||||
override fun subSequence(startIndex: Int, endIndex: Int): CharSequence {
|
||||
return text.subSequence(startIndex, endIndex)
|
||||
}
|
||||
|
||||
override fun toString(): String = text
|
||||
|
||||
override fun getSpanStart(tag: Any): Int = -1
|
||||
|
||||
override fun getSpanEnd(tag: Any): Int = -1
|
||||
|
||||
override fun getSpanFlags(tag: Any): Int = 0
|
||||
|
||||
override fun nextSpanTransition(start: Int, limit: Int, kind: Class<*>?): Int = limit
|
||||
|
||||
override fun <T : Any> getSpans(start: Int, end: Int, kind: Class<T>): Array<T> {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return java.lang.reflect.Array.newInstance(kind, 0) as Array<T>
|
||||
}
|
||||
}
|
||||
19
app/src/main/res/drawable/ic_timeline_note_24.xml
Normal file
19
app/src/main/res/drawable/ic_timeline_note_24.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<?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="M4,3h11l5,5v13H4z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M15,3v5h5" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M7,11h10v1.6H7zM7,14.2h10v1.6H7zM7,17.4h7v1.6H7z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M7.8,9.4l1.2,1.2 3.2,-3.2 -1.2,-1.2z" />
|
||||
</vector>
|
||||
300
app/src/main/res/layout/activity_card_detail.xml
Normal file
300
app/src/main/res/layout/activity_card_detail.xml
Normal file
@@ -0,0 +1,300 @@
|
||||
<?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/cardDetailToolbar"
|
||||
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">
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/cardDetailInitialProgress"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:visibility="gone" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/cardDetailErrorContainer"
|
||||
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/cardDetailErrorText"
|
||||
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/cardDetailRetryButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/retry" />
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.core.widget.NestedScrollView
|
||||
android:id="@+id/cardDetailContentScroll"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true"
|
||||
android:visibility="gone">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/cardDetailContentContainer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/cardDetailTitleInputLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/card_detail_title">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/cardDetailTitleInput"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textCapSentences|text"
|
||||
android:maxLines="1"
|
||||
android:textStyle="bold" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/cardDetailTitleSavingText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="@string/card_detail_saving"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/cardDetailTitleErrorText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:textColor="?attr/colorError"
|
||||
android:visibility="gone" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/cardDetailTitleRetryButton"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/retry"
|
||||
android:visibility="gone" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/cardDetailTagsRecycler"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:nestedScrollingEnabled="false" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/cardDetailDueDateText"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/card_detail_set_due_date"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/cardDetailDueDateClearButton"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/clear_due_date"
|
||||
android:visibility="gone" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/cardDetailDueDateSavingText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="@string/card_detail_saving"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/cardDetailDueDateErrorText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:textColor="?attr/colorError"
|
||||
android:visibility="gone" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/cardDetailDueDateRetryButton"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/retry"
|
||||
android:visibility="gone" />
|
||||
|
||||
<com.google.android.material.button.MaterialButtonToggleGroup
|
||||
android:id="@+id/cardDetailDescriptionModeToggle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
app:singleSelection="true">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/cardDetailDescriptionEditButton"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/card_detail_edit" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/cardDetailDescriptionPreviewButton"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/card_detail_preview" />
|
||||
</com.google.android.material.button.MaterialButtonToggleGroup>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/cardDetailDescriptionInputLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:hint="@string/description">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/cardDetailDescriptionInput"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="top|start"
|
||||
android:inputType="textMultiLine|textCapSentences"
|
||||
android:minLines="6" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/cardDetailDescriptionPreviewText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/cardDetailDescriptionSavingText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="@string/card_detail_saving"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/cardDetailDescriptionErrorText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:textColor="?attr/colorError"
|
||||
android:visibility="gone" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/cardDetailDescriptionRetryButton"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/retry"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
android:text="@string/card_detail_timeline"
|
||||
android:textAppearance="@style/TextAppearance.Material3.TitleMedium" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/cardDetailTimelineProgress"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/cardDetailTimelineErrorText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:textColor="?attr/colorError"
|
||||
android:visibility="gone" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/cardDetailTimelineRetryButton"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/retry"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/cardDetailTimelineEmptyText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/card_detail_timeline_empty"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||
android:visibility="gone" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/cardDetailTimelineRecycler"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:nestedScrollingEnabled="false" />
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="72dp" />
|
||||
</LinearLayout>
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
</FrameLayout>
|
||||
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
android:id="@+id/cardDetailAddCommentFab"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:layout_margin="16dp"
|
||||
android:contentDescription="@string/card_detail_add_comment"
|
||||
app:srcCompat="@android:drawable/ic_input_add" />
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
@@ -1,30 +0,0 @@
|
||||
<?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>
|
||||
65
app/src/main/res/layout/dialog_add_comment.xml
Normal file
65
app/src/main/res/layout/dialog_add_comment.xml
Normal file
@@ -0,0 +1,65 @@
|
||||
<?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="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="24dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingEnd="24dp"
|
||||
android:paddingBottom="8dp">
|
||||
|
||||
<com.google.android.material.button.MaterialButtonToggleGroup
|
||||
android:id="@+id/commentDialogModeToggle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:singleSelection="true">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/commentDialogEditButton"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/card_detail_edit" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/commentDialogPreviewButton"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/card_detail_preview" />
|
||||
</com.google.android.material.button.MaterialButtonToggleGroup>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/commentDialogInputLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:hint="@string/card_detail_comment_hint">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/commentDialogInput"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="top|start"
|
||||
android:inputType="textMultiLine|textCapSentences"
|
||||
android:minLines="4" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/commentDialogPreviewText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/commentDialogErrorText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:textColor="?attr/colorError"
|
||||
android:visibility="gone" />
|
||||
</LinearLayout>
|
||||
57
app/src/main/res/layout/item_card_activity_timeline.xml
Normal file
57
app/src/main/res/layout/item_card_activity_timeline.xml
Normal file
@@ -0,0 +1,57 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="8dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="36dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical">
|
||||
|
||||
<View
|
||||
android:id="@+id/timelineTopConnector"
|
||||
android:layout_width="2dp"
|
||||
android:layout_height="12dp"
|
||||
android:background="@android:color/darker_gray" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/timelineIcon"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:contentDescription="@null"
|
||||
android:src="@drawable/ic_timeline_note_24"
|
||||
android:tint="@android:color/darker_gray" />
|
||||
|
||||
<View
|
||||
android:id="@+id/timelineBottomConnector"
|
||||
android:layout_width="2dp"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@android:color/darker_gray" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/timelineHeaderText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/timelineBodyText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||
android:visibility="gone" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -44,10 +44,7 @@
|
||||
<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="card_detail_fallback_title">Card</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>
|
||||
<string name="board_detail_add">Add</string>
|
||||
@@ -68,4 +65,23 @@
|
||||
<string name="add_card_tags">Tags</string>
|
||||
<string name="add_card_tags_placeholder">Select one or more tags.</string>
|
||||
<string name="filter_tags_placeholder">Select tags to include.</string>
|
||||
<string name="card_detail_unable_to_open_card">Unable to open card.</string>
|
||||
<string name="card_detail_session_expired">Session expired. Please sign in again.</string>
|
||||
<string name="card_detail_title">Title</string>
|
||||
<string name="card_detail_title_default">Card</string>
|
||||
<string name="card_detail_set_due_date">Set due date</string>
|
||||
<string name="card_detail_saving">Saving...</string>
|
||||
<string name="card_detail_edit">Edit</string>
|
||||
<string name="card_detail_preview">Preview</string>
|
||||
<string name="card_detail_timeline">Timeline</string>
|
||||
<string name="card_detail_timeline_empty">No activity yet.</string>
|
||||
<string name="card_detail_add_comment">Add comment</string>
|
||||
<string name="card_detail_comment_hint">Comment</string>
|
||||
<string name="card_detail_timeline_header">%1$s %2$s - %3$s</string>
|
||||
<string name="card_detail_timeline_actor_unknown">Someone</string>
|
||||
<string name="card_detail_timeline_action_commented">commented</string>
|
||||
<string name="card_detail_timeline_action_updated">updated this card</string>
|
||||
<string name="add">Add</string>
|
||||
<string name="card_detail_comment_required">Comment is required</string>
|
||||
<string name="card_detail_comment_added">Comment added</string>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
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.LocalDate
|
||||
import java.time.ZoneOffset
|
||||
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.assertTrue
|
||||
import org.junit.Test
|
||||
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
|
||||
import space.hackenslacker.kanbn4droid.app.carddetail.CardActivity
|
||||
import space.hackenslacker.kanbn4droid.app.carddetail.CardDetail
|
||||
|
||||
class CardDetailApiCompatibilityTest {
|
||||
|
||||
@Test
|
||||
fun getCardDetail_parsesNestedContainers_andNormalizesDueDateVariants() = runTest {
|
||||
TestServer().use { server ->
|
||||
server.registerSequence(
|
||||
path = "/api/v1/cards/card-1",
|
||||
method = "GET",
|
||||
responses = listOf(
|
||||
200 to """
|
||||
{
|
||||
"data": {
|
||||
"card": {
|
||||
"public_id": "card-1",
|
||||
"name": "Direct date",
|
||||
"body": "Desc",
|
||||
"dueDate": "2026-03-16",
|
||||
"listPublicId": "list-a",
|
||||
"index": 4,
|
||||
"labels": [
|
||||
{"publicId": "tag-1", "name": "Urgent", "color": "#FF0000"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent(),
|
||||
200 to """
|
||||
{
|
||||
"card": {
|
||||
"publicId": "card-1",
|
||||
"title": "Instant date",
|
||||
"description": "Desc",
|
||||
"dueAt": "2026-03-16T23:30:00-02:00"
|
||||
}
|
||||
}
|
||||
""".trimIndent(),
|
||||
),
|
||||
)
|
||||
|
||||
val client = HttpKanbnApiClient()
|
||||
val first = client.getCardDetail(server.baseUrl, "api", "card-1")
|
||||
val second = client.getCardDetail(server.baseUrl, "api", "card-1")
|
||||
|
||||
val firstDetail = (first as BoardsApiResult.Success<CardDetail>).value
|
||||
val secondDetail = (second as BoardsApiResult.Success<CardDetail>).value
|
||||
assertEquals(LocalDate.of(2026, 3, 16), firstDetail.dueDate)
|
||||
assertEquals(LocalDate.of(2026, 3, 17), secondDetail.dueDate)
|
||||
assertEquals("card-1", firstDetail.id)
|
||||
assertEquals("Direct date", firstDetail.title)
|
||||
assertEquals("Desc", firstDetail.description)
|
||||
assertEquals("list-a", firstDetail.listPublicId)
|
||||
assertEquals(4, firstDetail.index)
|
||||
assertEquals("tag-1", firstDetail.tags.first().id)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateCard_usesFallbackOrder_andFinalFullPut() = runTest {
|
||||
TestServer().use { server ->
|
||||
server.registerSequence(
|
||||
path = "/api/v1/cards/card-2",
|
||||
method = "PATCH",
|
||||
responses = listOf(
|
||||
400 to "{}",
|
||||
409 to "{}",
|
||||
),
|
||||
)
|
||||
server.registerSequence(
|
||||
path = "/api/v1/cards/card-2",
|
||||
method = "PUT",
|
||||
responses = listOf(
|
||||
400 to "{}",
|
||||
500 to "{}",
|
||||
200 to "{}",
|
||||
),
|
||||
)
|
||||
server.register(
|
||||
path = "/api/v1/cards/card-2",
|
||||
method = "GET",
|
||||
status = 200,
|
||||
responseBody =
|
||||
"""
|
||||
{
|
||||
"card": {
|
||||
"publicId": "card-2",
|
||||
"title": "Current title",
|
||||
"description": "Current desc",
|
||||
"index": 11,
|
||||
"listPublicId": "list-old",
|
||||
"dueDate": "2026-01-20T00:00:00Z"
|
||||
}
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val result = HttpKanbnApiClient().updateCard(
|
||||
baseUrl = server.baseUrl,
|
||||
apiKey = "api",
|
||||
cardId = "card-2",
|
||||
title = "New title",
|
||||
description = "New desc",
|
||||
dueDate = LocalDate.of(2026, 3, 18),
|
||||
)
|
||||
|
||||
assertTrue(result is BoardsApiResult.Success<*>)
|
||||
val requests = server.findRequests("/api/v1/cards/card-2")
|
||||
assertEquals("PATCH", requests[0].method)
|
||||
assertEquals("PATCH", requests[1].method)
|
||||
assertEquals("PUT", requests[2].method)
|
||||
assertEquals("PUT", requests[3].method)
|
||||
assertEquals("GET", requests[4].method)
|
||||
assertEquals("PUT", requests[5].method)
|
||||
assertTrue(requests[0].body.contains("\"title\":\"New title\""))
|
||||
assertTrue(requests[0].body.contains("\"description\":\"New desc\""))
|
||||
assertTrue(requests[0].body.contains("\"dueDate\":\"2026-03-18T00:00:00Z\""))
|
||||
assertTrue(requests[1].body.contains("\"name\":\"New title\""))
|
||||
assertTrue(requests[1].body.contains("\"body\":\"New desc\""))
|
||||
assertTrue(requests[1].body.contains("\"dueAt\":\"2026-03-18T00:00:00Z\""))
|
||||
assertTrue(requests[2].body.contains("\"title\":\"New title\""))
|
||||
assertTrue(requests[3].body.contains("\"name\":\"New title\""))
|
||||
assertTrue(requests[5].body.contains("\"listPublicId\":\"list-old\""))
|
||||
assertTrue(requests[5].body.contains("\"index\":11"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateCard_stopsFallbackOnAuthFailure() = runTest {
|
||||
TestServer().use { server ->
|
||||
server.register(path = "/api/v1/cards/card-auth", method = "PATCH", status = 401, responseBody = "{}")
|
||||
|
||||
val result = HttpKanbnApiClient().updateCard(
|
||||
baseUrl = server.baseUrl,
|
||||
apiKey = "api",
|
||||
cardId = "card-auth",
|
||||
title = "Title",
|
||||
description = "Desc",
|
||||
dueDate = null,
|
||||
)
|
||||
|
||||
assertTrue(result is BoardsApiResult.Failure)
|
||||
val requests = server.findRequests("/api/v1/cards/card-auth")
|
||||
assertEquals(1, requests.size)
|
||||
assertEquals("PATCH", requests.first().method)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listCardActivities_fallsBackToThirdEndpoint_whenFirstTwo2xxPayloadsAreUnparseable() = runTest {
|
||||
TestServer().use { server ->
|
||||
server.register(
|
||||
path = "/api/v1/cards/card-3/activities?limit=50",
|
||||
method = "GET",
|
||||
status = 200,
|
||||
responseBody = "{\"data\":\"not-an-array\"}",
|
||||
)
|
||||
server.register(
|
||||
path = "/api/v1/cards/card-3/actions?limit=50",
|
||||
method = "GET",
|
||||
status = 200,
|
||||
responseBody = "{\"actions\":{\"unexpected\":true}}",
|
||||
)
|
||||
val secondPayloadItems = (1..12).joinToString(",") { index ->
|
||||
val day = index.toString().padStart(2, '0')
|
||||
"""{"id":"a-$index","type":"comment","text":"Item $index","createdAt":"2026-01-${day}T00:00:00Z"}"""
|
||||
}
|
||||
server.register(
|
||||
path = "/api/v1/cards/card-3/card-activities?limit=50",
|
||||
method = "GET",
|
||||
status = 200,
|
||||
responseBody = "{\"data\":[${secondPayloadItems}]}",
|
||||
)
|
||||
|
||||
val result = HttpKanbnApiClient().listCardActivities(server.baseUrl, "api", "card-3")
|
||||
|
||||
val activities = (result as BoardsApiResult.Success<List<CardActivity>>).value
|
||||
assertEquals(10, activities.size)
|
||||
assertEquals("a-12", activities.first().id)
|
||||
assertEquals("a-3", activities.last().id)
|
||||
val requests = server.findRequests("/api/v1/cards/card-3/activities?limit=50") +
|
||||
server.findRequests("/api/v1/cards/card-3/actions?limit=50") +
|
||||
server.findRequests("/api/v1/cards/card-3/card-activities?limit=50")
|
||||
assertEquals(3, requests.size)
|
||||
assertEquals(
|
||||
LocalDate.of(2026, 1, 12).atStartOfDay().toInstant(ZoneOffset.UTC).toEpochMilli(),
|
||||
activities.first().createdAtEpochMillis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addCardComment_continuesFallbackAfter2xxLogicalFailure_untilLaterVariantSucceeds() = runTest {
|
||||
TestServer().use { server ->
|
||||
server.registerSequence(
|
||||
path = "/api/v1/cards/card-4/comment-actions",
|
||||
method = "POST",
|
||||
responses = listOf(
|
||||
200 to "{\"success\":false,\"message\":\"blocked\"}",
|
||||
500 to "{}",
|
||||
),
|
||||
)
|
||||
server.register(
|
||||
path = "/api/v1/cards/card-4/actions/comments",
|
||||
method = "POST",
|
||||
status = 200,
|
||||
responseBody = "{\"action\":{\"id\":\"act-1\"}}",
|
||||
)
|
||||
|
||||
val result = HttpKanbnApiClient().addCardComment(server.baseUrl, "api", "card-4", "A comment")
|
||||
|
||||
assertTrue(result is BoardsApiResult.Success<*>)
|
||||
val requests = server.findRequests("/api/v1/cards/card-4/comment-actions")
|
||||
assertEquals(2, requests.size)
|
||||
assertEquals("{\"text\":\"A comment\"}", requests[0].body)
|
||||
assertEquals("{\"comment\":\"A comment\"}", requests[1].body)
|
||||
val thirdRequests = server.findRequests("/api/v1/cards/card-4/actions/comments")
|
||||
assertEquals(1, thirdRequests.size)
|
||||
assertEquals("{\"text\":\"A comment\"}", thirdRequests[0].body)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addCardComment_fallsBackToThirdEndpoint_andSucceedsOnCommentActionPayload() = runTest {
|
||||
TestServer().use { server ->
|
||||
server.register(path = "/api/v1/cards/card-5/comment-actions", method = "POST", status = 500, responseBody = "{}")
|
||||
server.register(path = "/api/v1/cards/card-5/comment-actions", method = "POST", status = 400, responseBody = "{}")
|
||||
server.register(
|
||||
path = "/api/v1/cards/card-5/actions/comments",
|
||||
method = "POST",
|
||||
status = 200,
|
||||
responseBody = "{\"commentAction\":{\"id\":\"x\"}}",
|
||||
)
|
||||
|
||||
val result = HttpKanbnApiClient().addCardComment(server.baseUrl, "api", "card-5", "Looks good")
|
||||
|
||||
assertTrue(result is BoardsApiResult.Success<*>)
|
||||
val endpoint1Requests = server.findRequests("/api/v1/cards/card-5/comment-actions")
|
||||
assertEquals(2, endpoint1Requests.size)
|
||||
val endpoint3Requests = server.findRequests("/api/v1/cards/card-5/actions/comments")
|
||||
assertEquals(1, endpoint3Requests.size)
|
||||
assertEquals("{\"text\":\"Looks good\"}", endpoint3Requests.first().body)
|
||||
}
|
||||
}
|
||||
|
||||
private data class CapturedRequest(
|
||||
val method: String,
|
||||
val path: String,
|
||||
val body: String,
|
||||
)
|
||||
|
||||
private class TestServer : AutoCloseable {
|
||||
private val requests = CopyOnWriteArrayList<CapturedRequest>()
|
||||
private val responses = mutableMapOf<String, Pair<Int, String>>()
|
||||
private val responseSequences = mutableMapOf<String, ArrayDeque<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, method: String, status: Int, responseBody: String) {
|
||||
responses["${method.uppercase()} $path"] = status to responseBody
|
||||
}
|
||||
|
||||
fun registerSequence(path: String, method: String, responses: List<Pair<Int, String>>) {
|
||||
responseSequences["${method.uppercase()} $path"] = ArrayDeque(responses)
|
||||
}
|
||||
|
||||
fun findRequests(path: String): List<CapturedRequest> {
|
||||
return requests.filter { 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 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-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)
|
||||
|
||||
val sequenceKey = "$effectiveMethod $path"
|
||||
val sequence = responseSequences[sequenceKey]
|
||||
val sequencedResponse = if (sequence != null && sequence.isNotEmpty()) sequence.removeFirst() else null
|
||||
val response = sequencedResponse ?: responses[sequenceKey] ?: (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"
|
||||
401 -> "Unauthorized"
|
||||
404 -> "Not Found"
|
||||
409 -> "Conflict"
|
||||
500 -> "Internal Server Error"
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -420,8 +420,8 @@ class BoardDetailViewModelTest {
|
||||
advanceUntilIdle()
|
||||
|
||||
val event = eventDeferred.await()
|
||||
assertTrue(event is BoardDetailUiEvent.NavigateToCardPlaceholder)
|
||||
assertEquals("card-1", (event as BoardDetailUiEvent.NavigateToCardPlaceholder).cardId)
|
||||
assertTrue(event is BoardDetailUiEvent.NavigateToCardDetail)
|
||||
assertEquals("card-1", (event as BoardDetailUiEvent.NavigateToCardDetail).cardId)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
package space.hackenslacker.kanbn4droid.app.carddetail
|
||||
|
||||
import java.time.LocalDate
|
||||
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.LabelDetail
|
||||
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
|
||||
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetail
|
||||
import space.hackenslacker.kanbn4droid.app.boarddetail.CreatedEntityRef
|
||||
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 CardDetailRepositoryTest {
|
||||
|
||||
@Test
|
||||
fun missingSession_returnsSessionExpiredFailure() = runTest {
|
||||
val repository = createRepository(
|
||||
sessionStore = InMemorySessionStore(baseUrl = null),
|
||||
)
|
||||
|
||||
val result = repository.listActivities("card-1")
|
||||
|
||||
assertTrue(result is CardDetailRepository.Result.Failure.SessionExpired)
|
||||
assertEquals(
|
||||
CardDetailRepository.MISSING_SESSION_MESSAGE,
|
||||
(result as CardDetailRepository.Result.Failure.SessionExpired).message,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateDescription_blankMapsToNull_andPreservesFailureMessage() = runTest {
|
||||
val apiClient = FakeCardDetailApiClient().apply {
|
||||
updateCardResult = BoardsApiResult.Failure("Card is archived")
|
||||
}
|
||||
val repository = createRepository(apiClient = apiClient)
|
||||
|
||||
val result = repository.updateDescription(cardId = "card-1", description = " ")
|
||||
|
||||
assertEquals(null, apiClient.lastUpdatedDescriptionNormalized)
|
||||
assertTrue(result is CardDetailRepository.Result.Failure.Generic)
|
||||
assertEquals("Card is archived", (result as CardDetailRepository.Result.Failure.Generic).message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateTitle_authFailureMapsToSessionExpired() = runTest {
|
||||
val apiClient = FakeCardDetailApiClient().apply {
|
||||
updateCardResult = BoardsApiResult.Failure("Server error: 401")
|
||||
}
|
||||
val repository = createRepository(apiClient = apiClient)
|
||||
|
||||
val result = repository.updateTitle(cardId = "card-1", title = " Updated title ")
|
||||
|
||||
assertTrue(result is CardDetailRepository.Result.Failure.SessionExpired)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listActivities_returnsNewestFirstTopTen() = runTest {
|
||||
val apiClient = FakeCardDetailApiClient().apply {
|
||||
listActivitiesResult = BoardsApiResult.Success((1..12).map { index ->
|
||||
CardActivity(
|
||||
id = "a-$index",
|
||||
type = "comment",
|
||||
text = "Activity $index",
|
||||
createdAtEpochMillis = index.toLong(),
|
||||
)
|
||||
})
|
||||
}
|
||||
val repository = createRepository(apiClient = apiClient)
|
||||
|
||||
val result = repository.listActivities("card-1")
|
||||
|
||||
assertTrue(result is CardDetailRepository.Result.Success)
|
||||
val activities = (result as CardDetailRepository.Result.Success<List<CardActivity>>).value
|
||||
assertEquals(10, activities.size)
|
||||
assertEquals("a-12", activities[0].id)
|
||||
assertEquals("a-3", activities[9].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listActivities_authFailureMapsToSessionExpired() = runTest {
|
||||
val apiClient = FakeCardDetailApiClient().apply {
|
||||
listActivitiesResult = BoardsApiResult.Failure("Server error: 403")
|
||||
}
|
||||
val repository = createRepository(apiClient = apiClient)
|
||||
|
||||
val result = repository.listActivities("card-1")
|
||||
|
||||
assertTrue(result is CardDetailRepository.Result.Failure.SessionExpired)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addComment_success_refreshesActivities() = runTest {
|
||||
val apiClient = FakeCardDetailApiClient().apply {
|
||||
addCommentResult = BoardsApiResult.Success(Unit)
|
||||
listActivitiesResults += BoardsApiResult.Success(emptyList())
|
||||
listActivitiesResults += BoardsApiResult.Success(
|
||||
listOf(
|
||||
CardActivity(
|
||||
id = "a-1",
|
||||
type = "comment",
|
||||
text = "hello",
|
||||
createdAtEpochMillis = 1L,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
val repository = createRepository(apiClient = apiClient)
|
||||
|
||||
val result = repository.addComment(cardId = "card-1", comment = " hello ")
|
||||
|
||||
assertTrue(result is CardDetailRepository.Result.Success)
|
||||
assertEquals(1, apiClient.addCommentCalls)
|
||||
assertEquals(2, apiClient.listActivitiesCalls)
|
||||
assertEquals("hello", apiClient.lastComment)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addComment_refreshGenericFailure_returnsSuccessWithPreAddSnapshot() = runTest {
|
||||
val preAddSnapshot = listOf(
|
||||
CardActivity(
|
||||
id = "a-existing",
|
||||
type = "comment",
|
||||
text = "existing",
|
||||
createdAtEpochMillis = 10L,
|
||||
),
|
||||
)
|
||||
val apiClient = FakeCardDetailApiClient().apply {
|
||||
addCommentResult = BoardsApiResult.Success(Unit)
|
||||
listActivitiesResults += BoardsApiResult.Success(preAddSnapshot)
|
||||
listActivitiesResults += BoardsApiResult.Failure("Server temporarily unavailable")
|
||||
}
|
||||
val repository = createRepository(apiClient = apiClient)
|
||||
|
||||
val result = repository.addComment(cardId = "card-1", comment = "hello")
|
||||
|
||||
assertTrue(result is CardDetailRepository.Result.Success)
|
||||
val activities = (result as CardDetailRepository.Result.Success<List<CardActivity>>).value
|
||||
assertEquals(preAddSnapshot, activities)
|
||||
assertEquals(1, apiClient.addCommentCalls)
|
||||
assertEquals(2, apiClient.listActivitiesCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addComment_refreshAuthFailure_mapsToSessionExpired() = runTest {
|
||||
val apiClient = FakeCardDetailApiClient().apply {
|
||||
addCommentResult = BoardsApiResult.Success(Unit)
|
||||
listActivitiesResults += BoardsApiResult.Success(emptyList())
|
||||
listActivitiesResults += BoardsApiResult.Failure("Server error: 403")
|
||||
}
|
||||
val repository = createRepository(apiClient = apiClient)
|
||||
|
||||
val result = repository.addComment(cardId = "card-1", comment = "hello")
|
||||
|
||||
assertTrue(result is CardDetailRepository.Result.Failure.SessionExpired)
|
||||
assertEquals(2, apiClient.listActivitiesCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addComment_authFailureMapsToSessionExpired() = runTest {
|
||||
val apiClient = FakeCardDetailApiClient().apply {
|
||||
addCommentResult = BoardsApiResult.Failure("Authentication failed. Check your API key.")
|
||||
}
|
||||
val repository = createRepository(apiClient = apiClient)
|
||||
|
||||
val result = repository.addComment(cardId = "card-1", comment = "A")
|
||||
|
||||
assertTrue(result is CardDetailRepository.Result.Failure.SessionExpired)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listActivities_nonAuthNumericMessage_remainsGenericFailure() = runTest {
|
||||
val apiClient = FakeCardDetailApiClient().apply {
|
||||
listActivitiesResult = BoardsApiResult.Failure("Server error: 1401")
|
||||
}
|
||||
val repository = createRepository(apiClient = apiClient)
|
||||
|
||||
val result = repository.listActivities("card-1")
|
||||
|
||||
assertTrue(result is CardDetailRepository.Result.Failure.Generic)
|
||||
assertEquals("Server error: 1401", (result as CardDetailRepository.Result.Failure.Generic).message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listActivities_forbiddenMessage_mapsToSessionExpired() = runTest {
|
||||
val apiClient = FakeCardDetailApiClient().apply {
|
||||
listActivitiesResult = BoardsApiResult.Failure("Forbidden")
|
||||
}
|
||||
val repository = createRepository(apiClient = apiClient)
|
||||
|
||||
val result = repository.listActivities("card-1")
|
||||
|
||||
assertTrue(result is CardDetailRepository.Result.Failure.SessionExpired)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateDueDate_dateOnlyInputNormalizesToUtcMidnightPayloadContract() = runTest {
|
||||
val apiClient = FakeCardDetailApiClient()
|
||||
val repository = createRepository(apiClient = apiClient)
|
||||
|
||||
val result = repository.updateDueDate(cardId = "card-1", dueDate = LocalDate.parse("2026-03-16"))
|
||||
|
||||
assertTrue(result is CardDetailRepository.Result.Success)
|
||||
assertEquals(LocalDate.of(2026, 3, 16), apiClient.lastUpdatedDueDate)
|
||||
}
|
||||
|
||||
private fun createRepository(
|
||||
sessionStore: InMemorySessionStore = InMemorySessionStore(baseUrl = "https://kan.bn/"),
|
||||
apiKeyStore: InMemoryApiKeyStore = InMemoryApiKeyStore("api-key"),
|
||||
apiClient: FakeCardDetailApiClient = FakeCardDetailApiClient(),
|
||||
): CardDetailRepository {
|
||||
return CardDetailRepository(
|
||||
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 FakeCardDetailApiClient : KanbnApiClient {
|
||||
var cardDetailResult: BoardsApiResult<CardDetail> = BoardsApiResult.Success(
|
||||
CardDetail(
|
||||
id = "card-1",
|
||||
title = "Current title",
|
||||
description = "Current description",
|
||||
dueDate = LocalDate.of(2026, 3, 1),
|
||||
listPublicId = "list-1",
|
||||
index = 0,
|
||||
tags = emptyList(),
|
||||
),
|
||||
)
|
||||
var updateCardResult: BoardsApiResult<Unit> = BoardsApiResult.Success(Unit)
|
||||
var listActivitiesResult: BoardsApiResult<List<CardActivity>> = BoardsApiResult.Success(emptyList())
|
||||
val listActivitiesResults: MutableList<BoardsApiResult<List<CardActivity>>> = mutableListOf()
|
||||
var addCommentResult: BoardsApiResult<Unit> = BoardsApiResult.Success(Unit)
|
||||
|
||||
var lastUpdatedTitle: String? = null
|
||||
var lastUpdatedDescription: String? = null
|
||||
var lastUpdatedDescriptionNormalized: String? = null
|
||||
var lastUpdatedDueDate: LocalDate? = null
|
||||
var addCommentCalls: Int = 0
|
||||
var listActivitiesCalls: Int = 0
|
||||
var lastComment: String? = null
|
||||
|
||||
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success
|
||||
|
||||
override suspend fun getCardDetail(baseUrl: String, apiKey: String, cardId: String): BoardsApiResult<CardDetail> {
|
||||
return cardDetailResult
|
||||
}
|
||||
|
||||
override suspend fun updateCard(
|
||||
baseUrl: String,
|
||||
apiKey: String,
|
||||
cardId: String,
|
||||
title: String,
|
||||
description: String,
|
||||
dueDate: LocalDate?,
|
||||
): BoardsApiResult<Unit> {
|
||||
lastUpdatedTitle = title
|
||||
lastUpdatedDescription = description
|
||||
lastUpdatedDescriptionNormalized = description.takeIf { it.isNotBlank() }
|
||||
lastUpdatedDueDate = dueDate
|
||||
return updateCardResult
|
||||
}
|
||||
|
||||
override suspend fun listCardActivities(
|
||||
baseUrl: String,
|
||||
apiKey: String,
|
||||
cardId: String,
|
||||
): BoardsApiResult<List<CardActivity>> {
|
||||
listActivitiesCalls += 1
|
||||
if (listActivitiesResults.isNotEmpty()) {
|
||||
return listActivitiesResults.removeAt(0)
|
||||
}
|
||||
return listActivitiesResult
|
||||
}
|
||||
|
||||
override suspend fun addCardComment(
|
||||
baseUrl: String,
|
||||
apiKey: String,
|
||||
cardId: String,
|
||||
comment: String,
|
||||
): BoardsApiResult<Unit> {
|
||||
addCommentCalls += 1
|
||||
lastComment = comment
|
||||
return addCommentResult
|
||||
}
|
||||
|
||||
override suspend fun listWorkspaces(baseUrl: String, apiKey: String): BoardsApiResult<List<WorkspaceSummary>> {
|
||||
return BoardsApiResult.Success(emptyList())
|
||||
}
|
||||
|
||||
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("board-1", name))
|
||||
}
|
||||
|
||||
override suspend fun deleteBoard(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult<Unit> {
|
||||
return BoardsApiResult.Success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun getBoardDetail(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult<BoardDetail> {
|
||||
return BoardsApiResult.Success(BoardDetail(id = boardId, title = "Board", lists = emptyList()))
|
||||
}
|
||||
|
||||
override suspend fun renameList(
|
||||
baseUrl: String,
|
||||
apiKey: String,
|
||||
listId: String,
|
||||
newTitle: String,
|
||||
): BoardsApiResult<Unit> {
|
||||
return BoardsApiResult.Success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun createList(
|
||||
baseUrl: String,
|
||||
apiKey: String,
|
||||
boardPublicId: String,
|
||||
title: String,
|
||||
appendIndex: Int,
|
||||
): BoardsApiResult<CreatedEntityRef> {
|
||||
return BoardsApiResult.Success(CreatedEntityRef("list-1"))
|
||||
}
|
||||
|
||||
override suspend fun createCard(
|
||||
baseUrl: String,
|
||||
apiKey: String,
|
||||
listPublicId: String,
|
||||
title: String,
|
||||
description: String?,
|
||||
dueDate: LocalDate?,
|
||||
tagPublicIds: List<String>,
|
||||
): BoardsApiResult<CreatedEntityRef> {
|
||||
return BoardsApiResult.Success(CreatedEntityRef("card-1"))
|
||||
}
|
||||
|
||||
override suspend fun moveCard(
|
||||
baseUrl: String,
|
||||
apiKey: String,
|
||||
cardId: String,
|
||||
targetListId: String,
|
||||
): BoardsApiResult<Unit> {
|
||||
return BoardsApiResult.Success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun deleteCard(baseUrl: String, apiKey: String, cardId: String): BoardsApiResult<Unit> {
|
||||
return BoardsApiResult.Success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun getLabelByPublicId(
|
||||
baseUrl: String,
|
||||
apiKey: String,
|
||||
labelId: String,
|
||||
): BoardsApiResult<LabelDetail> {
|
||||
return BoardsApiResult.Success(LabelDetail(labelId, "#000000"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
package space.hackenslacker.kanbn4droid.app.carddetail
|
||||
|
||||
import java.time.LocalDate
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class CardDetailViewModelTest {
|
||||
private val dispatcher = StandardTestDispatcher()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
kotlinx.coroutines.Dispatchers.setMain(dispatcher)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
kotlinx.coroutines.Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun initialLoad_fillsEditableFieldsAndActivities() = runTest {
|
||||
val repository = FakeCardDetailDataSource(
|
||||
loadCardResult = DataSourceResult.Success(sampleCardDetail()),
|
||||
listActivitiesResult = DataSourceResult.Success(sampleActivitiesShuffled()),
|
||||
)
|
||||
val viewModel = CardDetailViewModel(
|
||||
cardId = "card-1",
|
||||
repository = repository,
|
||||
descriptionDebounceMillis = 800,
|
||||
)
|
||||
|
||||
viewModel.load()
|
||||
advanceUntilIdle()
|
||||
|
||||
val state = viewModel.uiState.value
|
||||
assertEquals("Title", state.title)
|
||||
assertEquals("Description", state.description)
|
||||
assertEquals(LocalDate.of(2026, 3, 16), state.dueDate)
|
||||
assertEquals(listOf("tag-1", "tag-2"), state.tags.map { it.id })
|
||||
assertEquals(listOf("a-mid", "a-new", "a-old"), state.activities.map { it.id })
|
||||
assertEquals(listOf(2L, 3L, 1L), state.activities.map { it.createdAtEpochMillis })
|
||||
assertNull(state.loadErrorMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadSessionExpired_emitsEvent_andRetryPathsDoNotRun() = runTest {
|
||||
val repository = FakeCardDetailDataSource(
|
||||
loadCardResult = DataSourceResult.SessionExpired,
|
||||
)
|
||||
val viewModel = CardDetailViewModel(cardId = "card-1", repository = repository)
|
||||
val eventDeferred = async { viewModel.events.first() }
|
||||
|
||||
viewModel.load()
|
||||
advanceUntilIdle()
|
||||
|
||||
val event = eventDeferred.await()
|
||||
assertTrue(event is CardDetailUiEvent.SessionExpired)
|
||||
assertTrue(viewModel.uiState.value.isSessionExpired)
|
||||
|
||||
viewModel.retryLoad()
|
||||
viewModel.retryTitleSave()
|
||||
viewModel.retryDescriptionSave()
|
||||
viewModel.retryDueDateSave()
|
||||
viewModel.retryActivities()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(1, repository.loadCalls)
|
||||
assertEquals(0, repository.updateTitleCalls)
|
||||
assertEquals(0, repository.updateDescriptionCalls)
|
||||
assertEquals(0, repository.updateDueDateCalls)
|
||||
assertEquals(0, repository.listActivitiesCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun titleTrimRejectsBlank_andIsolatedFromDescription() = runTest {
|
||||
val repository = FakeCardDetailDataSource(
|
||||
loadCardResult = DataSourceResult.Success(sampleCardDetail()),
|
||||
listActivitiesResult = DataSourceResult.Success(emptyList()),
|
||||
)
|
||||
val viewModel = loadedViewModel(this, repository)
|
||||
|
||||
viewModel.onDescriptionChanged("pending description")
|
||||
viewModel.onTitleChanged(" ")
|
||||
viewModel.onTitleFocusLost()
|
||||
runCurrent()
|
||||
|
||||
assertEquals(0, repository.updateTitleCalls)
|
||||
assertEquals("Card title is required", viewModel.uiState.value.titleErrorMessage)
|
||||
assertEquals("pending description", viewModel.uiState.value.description)
|
||||
assertEquals(" ", viewModel.uiState.value.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dueDateSetAndClear_areIndependentAndSaved() = runTest {
|
||||
val repository = FakeCardDetailDataSource(
|
||||
loadCardResult = DataSourceResult.Success(sampleCardDetail()),
|
||||
listActivitiesResult = DataSourceResult.Success(emptyList()),
|
||||
)
|
||||
val viewModel = loadedViewModel(this, repository)
|
||||
|
||||
val nextDate = LocalDate.of(2026, 4, 1)
|
||||
viewModel.setDueDate(nextDate)
|
||||
advanceUntilIdle()
|
||||
viewModel.clearDueDate()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(listOf(nextDate, null), repository.updateDueDatePayloads)
|
||||
assertNull(viewModel.uiState.value.dueDate)
|
||||
assertEquals("Title", viewModel.uiState.value.title)
|
||||
assertEquals("Description", viewModel.uiState.value.description)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun descriptionDebounce_savesLatestOnly_andSuppressesDuplicateInflight() = runTest {
|
||||
val gate = CompletableDeferred<Unit>()
|
||||
val repository = FakeCardDetailDataSource(
|
||||
loadCardResult = DataSourceResult.Success(sampleCardDetail()),
|
||||
listActivitiesResult = DataSourceResult.Success(emptyList()),
|
||||
updateDescriptionGate = gate,
|
||||
)
|
||||
val viewModel = loadedViewModel(this, repository)
|
||||
|
||||
viewModel.onDescriptionChanged("a")
|
||||
advanceTimeBy(799)
|
||||
runCurrent()
|
||||
assertEquals(0, repository.updateDescriptionCalls)
|
||||
|
||||
viewModel.onDescriptionChanged("ab")
|
||||
advanceTimeBy(400)
|
||||
runCurrent()
|
||||
viewModel.onDescriptionChanged("abc")
|
||||
advanceTimeBy(800)
|
||||
runCurrent()
|
||||
|
||||
assertEquals(1, repository.updateDescriptionCalls)
|
||||
assertEquals(listOf("abc"), repository.updateDescriptionPayloads)
|
||||
|
||||
viewModel.onDescriptionChanged("abc")
|
||||
viewModel.onDescriptionFocusLost()
|
||||
runCurrent()
|
||||
assertEquals(1, repository.updateDescriptionCalls)
|
||||
|
||||
gate.complete(Unit)
|
||||
advanceUntilIdle()
|
||||
assertFalse(viewModel.uiState.value.isDescriptionSaving)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun descriptionFocusLossAndOnStop_flushLatestDirtyImmediately() = runTest {
|
||||
val repository = FakeCardDetailDataSource(
|
||||
loadCardResult = DataSourceResult.Success(sampleCardDetail()),
|
||||
listActivitiesResult = DataSourceResult.Success(emptyList()),
|
||||
)
|
||||
val viewModel = loadedViewModel(this, repository)
|
||||
|
||||
viewModel.onDescriptionChanged("first")
|
||||
viewModel.onDescriptionFocusLost()
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onDescriptionChanged("second")
|
||||
viewModel.onStop()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(listOf("first", "second"), repository.updateDescriptionPayloads)
|
||||
assertFalse(viewModel.uiState.value.isDescriptionDirty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onStopDuringInflightSave_preservesPendingLatest_andDirtyUntilLatestCompletes() = runTest {
|
||||
val firstGate = CompletableDeferred<Unit>()
|
||||
val secondGate = CompletableDeferred<Unit>()
|
||||
val repository = FakeCardDetailDataSource(
|
||||
loadCardResult = DataSourceResult.Success(sampleCardDetail()),
|
||||
listActivitiesResult = DataSourceResult.Success(emptyList()),
|
||||
updateDescriptionGates = ArrayDeque(listOf(firstGate, secondGate)),
|
||||
)
|
||||
val viewModel = loadedViewModel(this, repository)
|
||||
|
||||
viewModel.onDescriptionChanged("first")
|
||||
advanceTimeBy(800)
|
||||
runCurrent()
|
||||
assertEquals(listOf("first"), repository.updateDescriptionPayloads)
|
||||
|
||||
viewModel.onDescriptionChanged("second")
|
||||
viewModel.onStop()
|
||||
runCurrent()
|
||||
assertEquals(1, repository.updateDescriptionCalls)
|
||||
|
||||
firstGate.complete(Unit)
|
||||
runCurrent()
|
||||
|
||||
assertEquals(listOf("first", "second"), repository.updateDescriptionPayloads)
|
||||
assertTrue(viewModel.uiState.value.isDescriptionDirty)
|
||||
assertTrue(viewModel.uiState.value.isDescriptionSaving)
|
||||
assertEquals("second", viewModel.uiState.value.description)
|
||||
|
||||
secondGate.complete(Unit)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(listOf("first", "second"), repository.updateDescriptionPayloads)
|
||||
assertFalse(viewModel.uiState.value.isDescriptionDirty)
|
||||
assertFalse(viewModel.uiState.value.isDescriptionSaving)
|
||||
assertEquals("second", viewModel.uiState.value.description)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addComment_success_closesDialog_showsSnackbar_refreshesActivities() = runTest {
|
||||
val repository = FakeCardDetailDataSource(
|
||||
loadCardResult = DataSourceResult.Success(sampleCardDetail()),
|
||||
listActivitiesResults = ArrayDeque(
|
||||
listOf(
|
||||
DataSourceResult.Success(emptyList()),
|
||||
),
|
||||
),
|
||||
addCommentResult = DataSourceResult.Success(sampleActivitiesShuffled()),
|
||||
)
|
||||
val viewModel = loadedViewModel(this, repository)
|
||||
val eventDeferred = async { viewModel.events.first { it is CardDetailUiEvent.ShowSnackbar } }
|
||||
|
||||
viewModel.openCommentDialog()
|
||||
viewModel.onCommentChanged("hello")
|
||||
viewModel.submitComment()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertFalse(viewModel.uiState.value.isCommentDialogOpen)
|
||||
assertEquals("", viewModel.uiState.value.commentDraft)
|
||||
assertEquals(1, repository.addCommentCalls)
|
||||
assertEquals(1, repository.listActivitiesCalls)
|
||||
assertEquals(listOf("a-mid", "a-new", "a-old"), viewModel.uiState.value.activities.map { it.id })
|
||||
assertTrue(eventDeferred.await() is CardDetailUiEvent.ShowSnackbar)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addComment_failure_keepsDialogOpen_andStoresRetryPayload() = runTest {
|
||||
val repository = FakeCardDetailDataSource(
|
||||
loadCardResult = DataSourceResult.Success(sampleCardDetail()),
|
||||
listActivitiesResult = DataSourceResult.Success(emptyList()),
|
||||
addCommentResult = DataSourceResult.GenericError("Cannot add comment"),
|
||||
)
|
||||
val viewModel = loadedViewModel(this, repository)
|
||||
|
||||
viewModel.openCommentDialog()
|
||||
viewModel.onCommentChanged("hello")
|
||||
viewModel.submitComment()
|
||||
advanceUntilIdle()
|
||||
viewModel.retryAddComment()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(viewModel.uiState.value.isCommentDialogOpen)
|
||||
assertEquals("Cannot add comment", viewModel.uiState.value.commentErrorMessage)
|
||||
assertEquals(2, repository.addCommentCalls)
|
||||
assertEquals(listOf("hello", "hello"), repository.addCommentPayloads)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun activitiesRetry_recoversFromFailure() = runTest {
|
||||
val repository = FakeCardDetailDataSource(
|
||||
loadCardResult = DataSourceResult.Success(sampleCardDetail()),
|
||||
listActivitiesResults = ArrayDeque(
|
||||
listOf(
|
||||
DataSourceResult.GenericError("Network error"),
|
||||
DataSourceResult.Success(sampleActivitiesShuffled()),
|
||||
),
|
||||
),
|
||||
)
|
||||
val viewModel = loadedViewModel(this, repository)
|
||||
|
||||
assertEquals("Network error", viewModel.uiState.value.activitiesErrorMessage)
|
||||
|
||||
viewModel.retryActivities()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertNull(viewModel.uiState.value.activitiesErrorMessage)
|
||||
assertEquals(listOf("a-mid", "a-new", "a-old"), viewModel.uiState.value.activities.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun descriptionAndCommentModes_toggleBetweenEditAndPreview() = runTest {
|
||||
val viewModel = loadedViewModel(
|
||||
this,
|
||||
FakeCardDetailDataSource(
|
||||
loadCardResult = DataSourceResult.Success(sampleCardDetail()),
|
||||
listActivitiesResult = DataSourceResult.Success(emptyList()),
|
||||
),
|
||||
)
|
||||
|
||||
viewModel.setDescriptionMode(DescriptionMode.PREVIEW)
|
||||
viewModel.openCommentDialog()
|
||||
viewModel.setCommentDialogMode(CommentDialogMode.PREVIEW)
|
||||
|
||||
assertEquals(DescriptionMode.PREVIEW, viewModel.uiState.value.descriptionMode)
|
||||
assertEquals(CommentDialogMode.PREVIEW, viewModel.uiState.value.commentDialogMode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fieldSpecificRetry_usesLastAttemptedPayload() = runTest {
|
||||
val repository = FakeCardDetailDataSource(
|
||||
loadCardResult = DataSourceResult.Success(sampleCardDetail()),
|
||||
listActivitiesResult = DataSourceResult.Success(emptyList()),
|
||||
updateTitleResult = DataSourceResult.GenericError("title failed"),
|
||||
updateDueDateResult = DataSourceResult.GenericError("due failed"),
|
||||
updateDescriptionResult = DataSourceResult.GenericError("desc failed"),
|
||||
)
|
||||
val viewModel = loadedViewModel(this, repository)
|
||||
|
||||
viewModel.onTitleChanged("attempt title")
|
||||
viewModel.onTitleFocusLost()
|
||||
viewModel.onTitleChanged("different title")
|
||||
viewModel.retryTitleSave()
|
||||
|
||||
val dueAttempt = LocalDate.of(2026, 6, 1)
|
||||
viewModel.setDueDate(dueAttempt)
|
||||
viewModel.setDueDate(LocalDate.of(2026, 7, 1))
|
||||
viewModel.retryDueDateSave()
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onDescriptionChanged("attempt desc")
|
||||
advanceTimeBy(800)
|
||||
runCurrent()
|
||||
viewModel.onDescriptionChanged("different desc")
|
||||
viewModel.retryDescriptionSave()
|
||||
runCurrent()
|
||||
|
||||
assertEquals(listOf("attempt title", "attempt title"), repository.updateTitlePayloads)
|
||||
assertEquals(listOf(dueAttempt, LocalDate.of(2026, 7, 1), LocalDate.of(2026, 7, 1)), repository.updateDueDatePayloads)
|
||||
assertEquals(listOf("attempt desc", "attempt desc"), repository.updateDescriptionPayloads)
|
||||
}
|
||||
|
||||
private fun loadedViewModel(
|
||||
scope: kotlinx.coroutines.test.TestScope,
|
||||
repository: FakeCardDetailDataSource,
|
||||
): CardDetailViewModel {
|
||||
return CardDetailViewModel(
|
||||
cardId = "card-1",
|
||||
repository = repository,
|
||||
descriptionDebounceMillis = 800,
|
||||
).also {
|
||||
it.load()
|
||||
scope.advanceUntilIdle()
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeCardDetailDataSource(
|
||||
var loadCardResult: DataSourceResult<CardDetail> = DataSourceResult.Success(sampleCardDetail()),
|
||||
var listActivitiesResult: DataSourceResult<List<CardActivity>> = DataSourceResult.Success(emptyList()),
|
||||
var listActivitiesResults: ArrayDeque<DataSourceResult<List<CardActivity>>> = ArrayDeque(),
|
||||
var updateTitleResult: DataSourceResult<Unit> = DataSourceResult.Success(Unit),
|
||||
var updateDescriptionResult: DataSourceResult<Unit> = DataSourceResult.Success(Unit),
|
||||
var updateDueDateResult: DataSourceResult<Unit> = DataSourceResult.Success(Unit),
|
||||
var addCommentResult: DataSourceResult<List<CardActivity>> = DataSourceResult.Success(emptyList()),
|
||||
var updateDescriptionGate: CompletableDeferred<Unit>? = null,
|
||||
var updateDescriptionGates: ArrayDeque<CompletableDeferred<Unit>> = ArrayDeque(),
|
||||
) : CardDetailDataSource {
|
||||
var loadCalls: Int = 0
|
||||
var updateTitleCalls: Int = 0
|
||||
var updateDescriptionCalls: Int = 0
|
||||
var updateDueDateCalls: Int = 0
|
||||
var listActivitiesCalls: Int = 0
|
||||
var addCommentCalls: Int = 0
|
||||
|
||||
val updateTitlePayloads: MutableList<String> = mutableListOf()
|
||||
val updateDescriptionPayloads: MutableList<String?> = mutableListOf()
|
||||
val updateDueDatePayloads: MutableList<LocalDate?> = mutableListOf()
|
||||
val addCommentPayloads: MutableList<String> = mutableListOf()
|
||||
|
||||
override suspend fun loadCard(cardId: String): DataSourceResult<CardDetail> {
|
||||
loadCalls += 1
|
||||
return loadCardResult
|
||||
}
|
||||
|
||||
override suspend fun updateTitle(cardId: String, title: String): DataSourceResult<Unit> {
|
||||
updateTitleCalls += 1
|
||||
updateTitlePayloads += title
|
||||
return updateTitleResult
|
||||
}
|
||||
|
||||
override suspend fun updateDescription(cardId: String, description: String): DataSourceResult<Unit> {
|
||||
updateDescriptionCalls += 1
|
||||
updateDescriptionPayloads += description
|
||||
if (updateDescriptionGates.isNotEmpty()) {
|
||||
updateDescriptionGates.removeFirst().await()
|
||||
}
|
||||
updateDescriptionGate?.await()
|
||||
return updateDescriptionResult
|
||||
}
|
||||
|
||||
override suspend fun updateDueDate(cardId: String, dueDate: LocalDate?): DataSourceResult<Unit> {
|
||||
updateDueDateCalls += 1
|
||||
updateDueDatePayloads += dueDate
|
||||
return updateDueDateResult
|
||||
}
|
||||
|
||||
override suspend fun listActivities(cardId: String): DataSourceResult<List<CardActivity>> {
|
||||
listActivitiesCalls += 1
|
||||
if (listActivitiesResults.isNotEmpty()) {
|
||||
return listActivitiesResults.removeFirst()
|
||||
}
|
||||
return listActivitiesResult
|
||||
}
|
||||
|
||||
override suspend fun addComment(cardId: String, comment: String): DataSourceResult<List<CardActivity>> {
|
||||
addCommentCalls += 1
|
||||
addCommentPayloads += comment
|
||||
return addCommentResult
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
fun sampleCardDetail(): CardDetail {
|
||||
return CardDetail(
|
||||
id = "card-1",
|
||||
title = "Title",
|
||||
description = "Description",
|
||||
dueDate = LocalDate.of(2026, 3, 16),
|
||||
listPublicId = "list-1",
|
||||
index = 0,
|
||||
tags = listOf(
|
||||
CardDetailTag(id = "tag-1", name = "Tag 1", colorHex = "#111111"),
|
||||
CardDetailTag(id = "tag-2", name = "Tag 2", colorHex = "#222222"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun sampleActivitiesShuffled(): List<CardActivity> {
|
||||
return listOf(
|
||||
CardActivity(id = "a-mid", type = "comment", text = "mid", createdAtEpochMillis = 2L),
|
||||
CardActivity(id = "a-new", type = "comment", text = "new", createdAtEpochMillis = 3L),
|
||||
CardActivity(id = "a-old", type = "comment", text = "old", createdAtEpochMillis = 1L),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package space.hackenslacker.kanbn4droid.app.carddetail
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class MarkdownRendererTest {
|
||||
|
||||
@Test
|
||||
fun render_markdownBoldAndLink_returnsStyledSpanned() {
|
||||
val renderer = MarkdownRenderer(
|
||||
htmlToSpanned = { html ->
|
||||
val spans = mutableListOf<TestSpan>()
|
||||
if (html.contains("<strong>bold</strong>")) {
|
||||
spans += TestSpan.Bold
|
||||
}
|
||||
if (html.contains("<a href=\"https://kan.bn\">Kan.bn</a>")) {
|
||||
spans += TestSpan.Link("https://kan.bn")
|
||||
}
|
||||
TestSpanned(html, spans)
|
||||
},
|
||||
)
|
||||
|
||||
val result = renderer.render("This is **bold** and [Kan.bn](https://kan.bn)")
|
||||
|
||||
val boldSpans = result.getSpans(0, result.length, TestSpan.Bold::class.java)
|
||||
val linkSpans = result.getSpans(0, result.length, TestSpan.Link::class.java)
|
||||
|
||||
assertTrue(boldSpans.isNotEmpty())
|
||||
assertTrue(linkSpans.any { it.url == "https://kan.bn" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun render_whenRenderingFails_returnsPlainTextFallback() {
|
||||
val renderer = MarkdownRenderer(
|
||||
parseToHtml = { throw IllegalStateException("boom") },
|
||||
htmlToSpanned = { throw AssertionError("htmlToSpanned should not run on fallback") },
|
||||
)
|
||||
|
||||
val result = renderer.render("**keep this literal**")
|
||||
|
||||
assertEquals("**keep this literal**", result.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun render_rawHtml_isEscaped() {
|
||||
val renderer = MarkdownRenderer(
|
||||
htmlToSpanned = { html -> TestSpanned(html) },
|
||||
)
|
||||
|
||||
val result = renderer.render("<script>alert(1)</script> **ok**")
|
||||
|
||||
assertTrue(result.toString().contains("<script>alert(1)</script>"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun enableLinks_setsLinkMovementMethodOnPreviewTextWidget() {
|
||||
var applied = false
|
||||
val fakeMovementMethod = TestMovementMethod()
|
||||
|
||||
MarkdownRenderer.enableLinks(
|
||||
applyMovementMethod = { movementMethod ->
|
||||
applied = movementMethod === fakeMovementMethod
|
||||
},
|
||||
movementMethodProvider = { fakeMovementMethod },
|
||||
)
|
||||
|
||||
assertTrue(applied)
|
||||
}
|
||||
|
||||
private sealed interface TestSpan {
|
||||
data object Bold : TestSpan
|
||||
|
||||
data class Link(val url: String) : TestSpan
|
||||
}
|
||||
|
||||
private class TestMovementMethod : android.text.method.MovementMethod {
|
||||
override fun initialize(widget: android.widget.TextView?, text: android.text.Spannable?) = Unit
|
||||
|
||||
override fun onKeyDown(
|
||||
widget: android.widget.TextView?,
|
||||
text: android.text.Spannable?,
|
||||
keyCode: Int,
|
||||
event: android.view.KeyEvent?,
|
||||
): Boolean = false
|
||||
|
||||
override fun onKeyUp(
|
||||
widget: android.widget.TextView?,
|
||||
text: android.text.Spannable?,
|
||||
keyCode: Int,
|
||||
event: android.view.KeyEvent?,
|
||||
): Boolean = false
|
||||
|
||||
override fun onKeyOther(
|
||||
view: android.widget.TextView?,
|
||||
text: android.text.Spannable?,
|
||||
event: android.view.KeyEvent?,
|
||||
): Boolean = false
|
||||
|
||||
override fun onTakeFocus(widget: android.widget.TextView?, text: android.text.Spannable?, direction: Int) = Unit
|
||||
|
||||
override fun onTrackballEvent(
|
||||
widget: android.widget.TextView?,
|
||||
text: android.text.Spannable?,
|
||||
event: android.view.MotionEvent?,
|
||||
): Boolean = false
|
||||
|
||||
override fun onTouchEvent(
|
||||
widget: android.widget.TextView?,
|
||||
text: android.text.Spannable?,
|
||||
event: android.view.MotionEvent?,
|
||||
): Boolean = false
|
||||
|
||||
override fun onGenericMotionEvent(
|
||||
widget: android.widget.TextView?,
|
||||
text: android.text.Spannable?,
|
||||
event: android.view.MotionEvent?,
|
||||
): Boolean = false
|
||||
|
||||
override fun canSelectArbitrarily(): Boolean = false
|
||||
}
|
||||
|
||||
private class TestSpanned(
|
||||
private val raw: String,
|
||||
private val spans: List<Any> = emptyList(),
|
||||
) : android.text.Spanned {
|
||||
override val length: Int
|
||||
get() = raw.length
|
||||
|
||||
override fun get(index: Int): Char = raw[index]
|
||||
|
||||
override fun subSequence(startIndex: Int, endIndex: Int): CharSequence = raw.subSequence(startIndex, endIndex)
|
||||
|
||||
override fun toString(): String = raw
|
||||
|
||||
override fun getSpanStart(tag: Any): Int = if (spans.contains(tag)) 0 else -1
|
||||
|
||||
override fun getSpanEnd(tag: Any): Int = if (spans.contains(tag)) raw.length else -1
|
||||
|
||||
override fun getSpanFlags(tag: Any): Int = 0
|
||||
|
||||
override fun nextSpanTransition(start: Int, limit: Int, kind: Class<*>?): Int = limit
|
||||
|
||||
override fun <T : Any> getSpans(start: Int, end: Int, kind: Class<T>): Array<T> {
|
||||
val filtered = spans.filter { kind.isInstance(it) }.map { requireNotNull(kind.cast(it)) }
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val array = java.lang.reflect.Array.newInstance(kind, filtered.size) as Array<T>
|
||||
filtered.forEachIndexed { index, element ->
|
||||
array[index] = element
|
||||
}
|
||||
return array
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ lifecycle = "2.8.7"
|
||||
swiperefreshlayout = "1.1.0"
|
||||
recyclerview = "1.3.2"
|
||||
activity = "1.9.3"
|
||||
commonmark = "0.22.0"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -35,6 +36,7 @@ androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", ve
|
||||
androidx-swiperefreshlayout = { group = "androidx.swiperefreshlayout", name = "swiperefreshlayout", version.ref = "swiperefreshlayout" }
|
||||
androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" }
|
||||
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
|
||||
commonmark = { group = "org.commonmark", name = "commonmark", version.ref = "commonmark" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
Reference in New Issue
Block a user