feat: add card detail repository with session-aware operations

This commit is contained in:
2026-03-16 20:34:55 -04:00
parent eee2f9cb17
commit d693c42142
3 changed files with 599 additions and 1 deletions

View File

@@ -144,7 +144,8 @@ Kanbn4Droid is an unofficial app to connect to and manipulate data stored in sel
- The modal dialog has "Add comment" as a title.
- The modal dialog has an editable markdown-enabled text field for the comment.
- The modal dialog has two buttons at the bottom on the right side for "Cancel" and "Add" respectively.
- Current status: full card detail is still pending. Tapping a board-detail card currently navigates to `CardDetailPlaceholderActivity` with card id/title extras.
- Current status: full card detail UI is still pending. Tapping a board-detail card currently navigates to `CardDetailPlaceholderActivity` with card id/title extras.
- Card detail data operations now include a dedicated `CardDetailRepository` with session-aware failure mapping (`Missing session. Please sign in again.` and typed session-expired failures for auth errors), field normalization for title/description/due date updates, activity listing capped to newest 10, and add-comment refresh behavior.
**Settings view**
- The view shows a list of settings that can be changed by the user. The following settings are available:

View File

@@ -0,0 +1,245 @@
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."
}
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 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 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 listActivities(normalizedCardId)
}
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()
return lower.contains("authentication failed") ||
lower.contains("server error: 401") ||
lower.contains("server error: 403") ||
lower.contains(" 401") ||
lower.contains(" 403")
}
private data class SessionSnapshot(
val baseUrl: String,
val apiKey: String,
)
}

View File

@@ -0,0 +1,352 @@
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)
listActivitiesResult = 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(1, apiClient.listActivitiesCalls)
assertEquals("hello", apiClient.lastComment)
}
@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 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())
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
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"))
}
}
}