Compare commits

..

22 Commits

Author SHA1 Message Date
96e971229a docs: update card detail verification status 2026-03-17 00:12:50 -04:00
dd91a62928 test: stabilize board detail intent assertions 2026-03-17 00:02:47 -04:00
f9eeff8dcc docs: refine card detail verification note wording 2026-03-16 23:41:03 -04:00
de9b87d312 docs: record card detail verification outcomes 2026-03-16 23:39:45 -04:00
797da7a1b0 docs: finalize card detail status and newest-first activity behavior 2026-03-16 23:37:42 -04:00
8f2d329368 refactor: remove placeholder card-detail route artifacts 2026-03-16 23:16:45 -04:00
78b34ecef2 feat: route board cards to full card detail screen 2026-03-16 22:51:55 -04:00
344c5a4faa fix: avoid redundant card activity reload and assert ordering 2026-03-16 22:01:09 -04:00
f9625df828 feat: implement card detail activity UI and timeline 2026-03-16 21:48:35 -04:00
a0255c2487 fix: preserve literal markdown on renderer fallback 2026-03-16 21:15:44 -04:00
72e23fded8 feat: add markdown renderer with CommonMark support 2026-03-16 21:12:08 -04:00
1bd540b1cd test: enforce card detail activities order ownership in repository 2026-03-16 21:04:00 -04:00
dfcdc79856 refactor: decouple card detail viewmodel datasource contracts 2026-03-16 21:00:51 -04:00
beab9006a3 fix: preserve pending description flush during in-flight save 2026-03-16 20:55:43 -04:00
aa987c9e00 feat: add card detail viewmodel live-save and debounce state 2026-03-16 20:52:04 -04:00
7132123ccf fix: preserve activity snapshot when comment refresh fails 2026-03-16 20:43:44 -04:00
f85586ddc7 fix: make card comment refresh failures non-fatal 2026-03-16 20:41:07 -04:00
82a3d59105 chore: revert out-of-scope AGENTS update in task 2 2026-03-16 20:36:13 -04:00
d693c42142 feat: add card detail repository with session-aware operations 2026-03-16 20:34:55 -04:00
eee2f9cb17 fix: continue card-detail fallbacks on 2xx incompatibility 2026-03-16 20:29:12 -04:00
70f1558ea3 chore: revert out-of-scope AGENTS update in task 1 2026-03-16 20:25:03 -04:00
fb5d9e1e5b feat: add card detail API contracts and compatibility parsing 2026-03-16 20:22:36 -04:00
28 changed files with 4597 additions and 87 deletions

View File

@@ -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 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. - 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 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 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 "Add comment" as a title.
- The modal dialog has an editable markdown-enabled text field for the comment. - The modal dialog has an editable markdown-enabled text field for the comment.
- The modal dialog has two buttons at the bottom on the right side for "Cancel" and "Add" respectively. - The modal dialog has two buttons at the bottom on the right side for "Cancel" and "Add" respectively.
- Current status: full card detail is still pending. Tapping a board-detail card currently navigates to `CardDetailPlaceholderActivity` with card id/title extras. - 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** **Settings view**
- The view shows a list of settings that can be changed by the user. The following settings are available: - The view shows a list of settings that can be changed by the user. The following settings are available:

View File

@@ -54,6 +54,7 @@ dependencies {
implementation(libs.androidx.recyclerview) implementation(libs.androidx.recyclerview)
implementation(libs.androidx.swiperefreshlayout) implementation(libs.androidx.swiperefreshlayout)
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
implementation(libs.commonmark)
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.kotlinx.coroutines.test)

View File

@@ -55,6 +55,7 @@ import space.hackenslacker.kanbn4droid.app.boarddetail.BoardListDetail
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardTagSummary import space.hackenslacker.kanbn4droid.app.boarddetail.BoardTagSummary
import space.hackenslacker.kanbn4droid.app.boarddetail.CardBatchMutationResult import space.hackenslacker.kanbn4droid.app.boarddetail.CardBatchMutationResult
import space.hackenslacker.kanbn4droid.app.boarddetail.CreatedEntityRef 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.ApiKeyStore
import space.hackenslacker.kanbn4droid.app.auth.SessionStore import space.hackenslacker.kanbn4droid.app.auth.SessionStore
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
@@ -496,8 +497,10 @@ class BoardDetailFlowTest {
com.google.android.material.R.attr.colorOnSurfaceVariant, com.google.android.material.R.attr.colorOnSurfaceVariant,
Color.BLACK, Color.BLACK,
) )
assertEquals(expectedInactive, menu.findItem(R.id.actionFilterByTag).iconTintList?.defaultColor) val filterTint = menu.findItem(R.id.actionFilterByTag).iconTintList?.defaultColor
assertEquals(expectedInactive, menu.findItem(R.id.actionSearch).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 @Test
fun cardTapNavigatesToCardPlaceholderWithExtras() { fun cardTapNavigatesToCardDetailWithExtras() {
launchBoardDetail() launchBoardDetail()
onView(withText("Card 1")).perform(click()) onView(withText("Card 1")).perform(click())
Intents.intended(hasComponent(CardDetailPlaceholderActivity::class.java.name)) Intents.intended(hasComponent(CardDetailActivity::class.java.name))
Intents.intended(hasExtra(CardDetailPlaceholderActivity.EXTRA_CARD_ID, "card-1")) Intents.intended(hasExtra(CardDetailActivity.EXTRA_CARD_ID, "card-1"))
Intents.intended(hasExtra(CardDetailPlaceholderActivity.EXTRA_CARD_TITLE, "Card 1")) Intents.intended(hasExtra(CardDetailActivity.EXTRA_CARD_TITLE, "Card 1"))
} }
@Test @Test
fun cardTapBlankTitle_usesCardFallbackInPlaceholderExtra() { fun cardTapBlankTitle_usesCardFallbackInCardDetailExtra() {
defaultDataSource.currentDetail = detailWithCardTitle(" ") defaultDataSource.currentDetail = detailWithCardTitle(" ")
launchBoardDetail() launchBoardDetail()
onView(withId(R.id.cardItemRoot)).perform(click()) onView(withId(R.id.cardItemRoot)).perform(click())
val expectedFallback = ApplicationProvider.getApplicationContext<android.content.Context>() 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(hasComponent(CardDetailActivity::class.java.name))
Intents.intended(hasExtra(CardDetailPlaceholderActivity.EXTRA_CARD_ID, "card-1")) Intents.intended(hasExtra(CardDetailActivity.EXTRA_CARD_ID, "card-1"))
Intents.intended(hasExtra(CardDetailPlaceholderActivity.EXTRA_CARD_TITLE, expectedFallback)) Intents.intended(hasExtra(CardDetailActivity.EXTRA_CARD_TITLE, expectedFallback))
} }
private fun launchBoardDetail(boardId: String? = "board-1"): ActivityScenario<BoardDetailActivity> { private fun launchBoardDetail(boardId: String? = "board-1"): ActivityScenario<BoardDetailActivity> {

View File

@@ -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)
}
}
}
}

View File

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

View File

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

View File

@@ -5,6 +5,8 @@ import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import java.time.Instant import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
import java.time.OffsetDateTime
import java.time.ZoneOffset
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import kotlinx.coroutines.Dispatchers 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.BoardTemplate
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
import space.hackenslacker.kanbn4droid.app.boards.WorkspaceSummary 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 { interface KanbnApiClient {
suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult 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> { suspend fun getLabelByPublicId(baseUrl: String, apiKey: String, labelId: String): BoardsApiResult<LabelDetail> {
return BoardsApiResult.Failure("Label detail is not implemented.") 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( 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( private fun <T> request(
baseUrl: String, baseUrl: String,
path: 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) { private fun configureRequestMethod(connection: HttpURLConnection, method: String) {
try { try {
connection.requestMethod = method connection.requestMethod = method
@@ -789,6 +1052,250 @@ class HttpKanbnApiClient : KanbnApiClient {
return CreatedEntityRef(publicId = publicId.ifBlank { null }) 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> { private fun parseLists(board: Map<*, *>): List<BoardListDetail> {
return extractObjectArray(board, "lists", "items", "data").mapNotNull { rawList -> return extractObjectArray(board, "lists", "items", "data").mapNotNull { rawList ->
val id = extractId(rawList) val id = extractId(rawList)
@@ -871,13 +1378,17 @@ class HttpKanbnApiClient : KanbnApiClient {
} }
private fun parseJsonObject(body: String): Map<String, Any?>? { 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() val trimmed = body.trim()
if (trimmed.isBlank()) { if (trimmed.isBlank()) {
return null return null
} }
val parsed = runCatching { MiniJsonParser(trimmed).parseValue() }.getOrNull() return runCatching { MiniJsonParser(trimmed).parseValue() }.getOrNull()
@Suppress("UNCHECKED_CAST")
return parsed as? Map<String, Any?>
} }
private fun jsonEscape(value: String): String { private fun jsonEscape(value: String): String {

View File

@@ -35,7 +35,7 @@ import java.time.ZoneId
import java.util.Date import java.util.Date
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import space.hackenslacker.kanbn4droid.app.MainActivity 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.R
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
import space.hackenslacker.kanbn4droid.app.auth.HttpKanbnApiClient import space.hackenslacker.kanbn4droid.app.auth.HttpKanbnApiClient
@@ -231,7 +231,7 @@ class BoardDetailActivity : AppCompatActivity() {
lifecycleScope.launch { lifecycleScope.launch {
viewModel.events.collect { event -> viewModel.events.collect { event ->
when (event) { when (event) {
is BoardDetailUiEvent.NavigateToCardPlaceholder -> { is BoardDetailUiEvent.NavigateToCardDetail -> {
val cardTitle = viewModel.uiState.value.boardDetail val cardTitle = viewModel.uiState.value.boardDetail
?.lists ?.lists
.orEmpty() .orEmpty()
@@ -241,12 +241,8 @@ class BoardDetailActivity : AppCompatActivity() {
?.title ?.title
.orEmpty() .orEmpty()
.trim() .trim()
.ifBlank { getString(R.string.card_detail_placeholder_fallback_title) } .ifBlank { getString(R.string.card_detail_fallback_title) }
startActivity( openCardDetail(cardId = event.cardId, cardTitle = cardTitle)
Intent(this@BoardDetailActivity, CardDetailPlaceholderActivity::class.java)
.putExtra(CardDetailPlaceholderActivity.EXTRA_CARD_ID, event.cardId)
.putExtra(CardDetailPlaceholderActivity.EXTRA_CARD_TITLE, cardTitle),
)
} }
is BoardDetailUiEvent.ShowServerError -> { is BoardDetailUiEvent.ShowServerError -> {
@@ -339,6 +335,14 @@ class BoardDetailActivity : AppCompatActivity() {
.show() .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) { private fun renderOpenDialogs(state: BoardDetailUiState) {
if (state.isFabChooserOpen && fabChooserDialog == null) { if (state.isFabChooserOpen && fabChooserDialog == null) {
showFabChooserDialog(state) showFabChooserDialog(state)

View File

@@ -57,7 +57,7 @@ data class BoardDetailUiState(
} }
sealed interface BoardDetailUiEvent { 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 ShowServerError(val message: String) : BoardDetailUiEvent
data class ShowWarning(val message: String) : BoardDetailUiEvent data class ShowWarning(val message: String) : BoardDetailUiEvent
} }
@@ -176,7 +176,7 @@ class BoardDetailViewModel(
} }
viewModelScope.launch { viewModelScope.launch {
_events.emit(BoardDetailUiEvent.NavigateToCardPlaceholder(cardId)) _events.emit(BoardDetailUiEvent.NavigateToCardDetail(cardId))
} }
} }

View File

@@ -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 = ""
}
}
}
}

View File

@@ -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
}
}

View File

@@ -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,
}

View File

@@ -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,
)
}

View File

@@ -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)
}
}
}
}

View File

@@ -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
}
}

View File

@@ -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>
}
}

View 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>

View 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>

View File

@@ -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>

View 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>

View 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>

View File

@@ -44,10 +44,7 @@
<string name="move_cards_to_list">Move cards to list</string> <string name="move_cards_to_list">Move cards to list</string>
<string name="delete_cards_confirmation">Delete selected cards?</string> <string name="delete_cards_confirmation">Delete selected cards?</string>
<string name="delete_cards_second_confirmation">Are you sure you want to permanently delete the selected cards?</string> <string name="delete_cards_second_confirmation">Are you sure you want to permanently delete the selected cards?</string>
<string name="board_detail_card_detail_coming_soon">Card detail view is coming soon.</string> <string name="card_detail_fallback_title">Card</string>
<string name="card_detail_placeholder_title">%1$s\n(id: %2$s)</string>
<string name="card_detail_placeholder_fallback_title">Card</string>
<string name="card_detail_placeholder_subtitle">Card detail view is coming soon.</string>
<string name="board_detail_unable_to_open_board">Unable to open board.</string> <string name="board_detail_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_session_expired">Session expired. Please sign in again.</string>
<string name="board_detail_add">Add</string> <string name="board_detail_add">Add</string>
@@ -68,4 +65,23 @@
<string name="add_card_tags">Tags</string> <string name="add_card_tags">Tags</string>
<string name="add_card_tags_placeholder">Select one or more 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="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> </resources>

View File

@@ -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)
}
}
}

View File

@@ -420,8 +420,8 @@ class BoardDetailViewModelTest {
advanceUntilIdle() advanceUntilIdle()
val event = eventDeferred.await() val event = eventDeferred.await()
assertTrue(event is BoardDetailUiEvent.NavigateToCardPlaceholder) assertTrue(event is BoardDetailUiEvent.NavigateToCardDetail)
assertEquals("card-1", (event as BoardDetailUiEvent.NavigateToCardPlaceholder).cardId) assertEquals("card-1", (event as BoardDetailUiEvent.NavigateToCardDetail).cardId)
} }
@Test @Test

View File

@@ -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"))
}
}
}

View File

@@ -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),
)
}
}
}

View File

@@ -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("&lt;script&gt;alert(1)&lt;/script&gt;"))
}
@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
}
}
}

View File

@@ -16,6 +16,7 @@ lifecycle = "2.8.7"
swiperefreshlayout = "1.1.0" swiperefreshlayout = "1.1.0"
recyclerview = "1.3.2" recyclerview = "1.3.2"
activity = "1.9.3" activity = "1.9.3"
commonmark = "0.22.0"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 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-swiperefreshlayout = { group = "androidx.swiperefreshlayout", name = "swiperefreshlayout", version.ref = "swiperefreshlayout" }
androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" } androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
commonmark = { group = "org.commonmark", name = "commonmark", version.ref = "commonmark" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }