feat: implement card detail activity UI and timeline
This commit is contained in:
@@ -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
|
||||||
@@ -788,14 +789,14 @@ 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
|
||||||
@@ -807,9 +808,9 @@ class BoardDetailFlowTest {
|
|||||||
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_placeholder_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> {
|
||||||
|
|||||||
@@ -0,0 +1,374 @@
|
|||||||
|
package space.hackenslacker.kanbn4droid.app
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
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.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 && fakeDataSource.listActivitiesCalls.get() >= 2
|
||||||
|
}
|
||||||
|
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()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@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())))
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Unit> {
|
||||||
|
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 result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,9 @@
|
|||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
android:theme="@style/Theme.Kanbn4Droid">
|
android:theme="@style/Theme.Kanbn4Droid">
|
||||||
|
<activity
|
||||||
|
android:name=".carddetail.CardDetailActivity"
|
||||||
|
android:exported="false" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".CardDetailPlaceholderActivity"
|
android:name=".CardDetailPlaceholderActivity"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -243,9 +243,9 @@ class BoardDetailActivity : AppCompatActivity() {
|
|||||||
.trim()
|
.trim()
|
||||||
.ifBlank { getString(R.string.card_detail_placeholder_fallback_title) }
|
.ifBlank { getString(R.string.card_detail_placeholder_fallback_title) }
|
||||||
startActivity(
|
startActivity(
|
||||||
Intent(this@BoardDetailActivity, CardDetailPlaceholderActivity::class.java)
|
Intent(this@BoardDetailActivity, CardDetailActivity::class.java)
|
||||||
.putExtra(CardDetailPlaceholderActivity.EXTRA_CARD_ID, event.cardId)
|
.putExtra(CardDetailActivity.EXTRA_CARD_ID, event.cardId)
|
||||||
.putExtra(CardDetailPlaceholderActivity.EXTRA_CARD_TITLE, cardTitle),
|
.putExtra(CardDetailActivity.EXTRA_CARD_TITLE, cardTitle),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package space.hackenslacker.kanbn4droid.app.carddetail
|
||||||
|
|
||||||
|
import android.text.format.DateUtils
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import space.hackenslacker.kanbn4droid.app.R
|
||||||
|
|
||||||
|
class ActivityTimelineAdapter(
|
||||||
|
private val markdownRenderer: MarkdownRenderer,
|
||||||
|
) : RecyclerView.Adapter<ActivityTimelineAdapter.ActivityViewHolder>() {
|
||||||
|
|
||||||
|
private var items: List<CardActivity> = emptyList()
|
||||||
|
|
||||||
|
fun submitActivities(value: List<CardActivity>) {
|
||||||
|
items = value
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ActivityViewHolder {
|
||||||
|
val view = LayoutInflater.from(parent.context)
|
||||||
|
.inflate(R.layout.item_card_activity_timeline, parent, false)
|
||||||
|
return ActivityViewHolder(view, markdownRenderer)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: ActivityViewHolder, position: Int) {
|
||||||
|
holder.bind(
|
||||||
|
activity = items[position],
|
||||||
|
isFirst = position == 0,
|
||||||
|
isLast = position == items.lastIndex,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemCount(): Int = items.size
|
||||||
|
|
||||||
|
class ActivityViewHolder(
|
||||||
|
itemView: View,
|
||||||
|
private val markdownRenderer: MarkdownRenderer,
|
||||||
|
) : RecyclerView.ViewHolder(itemView) {
|
||||||
|
private val topConnector: View = itemView.findViewById(R.id.timelineTopConnector)
|
||||||
|
private val bottomConnector: View = itemView.findViewById(R.id.timelineBottomConnector)
|
||||||
|
private val icon: ImageView = itemView.findViewById(R.id.timelineIcon)
|
||||||
|
private val header: TextView = itemView.findViewById(R.id.timelineHeaderText)
|
||||||
|
private val body: TextView = itemView.findViewById(R.id.timelineBodyText)
|
||||||
|
|
||||||
|
fun bind(activity: CardActivity, isFirst: Boolean, isLast: Boolean) {
|
||||||
|
topConnector.visibility = if (isFirst) View.INVISIBLE else View.VISIBLE
|
||||||
|
bottomConnector.visibility = if (isLast) View.INVISIBLE else View.VISIBLE
|
||||||
|
icon.setImageResource(R.drawable.ic_timeline_note_24)
|
||||||
|
|
||||||
|
val action = when {
|
||||||
|
activity.type.contains("comment", ignoreCase = true) -> itemView.context.getString(R.string.card_detail_timeline_action_commented)
|
||||||
|
else -> itemView.context.getString(R.string.card_detail_timeline_action_updated)
|
||||||
|
}
|
||||||
|
val actor = itemView.context.getString(R.string.card_detail_timeline_actor_unknown)
|
||||||
|
val relative = DateUtils.getRelativeTimeSpanString(
|
||||||
|
activity.createdAtEpochMillis,
|
||||||
|
System.currentTimeMillis(),
|
||||||
|
DateUtils.MINUTE_IN_MILLIS,
|
||||||
|
)
|
||||||
|
header.text = itemView.context.getString(
|
||||||
|
R.string.card_detail_timeline_header,
|
||||||
|
actor,
|
||||||
|
action,
|
||||||
|
relative,
|
||||||
|
)
|
||||||
|
|
||||||
|
val isComment = activity.type.contains("comment", ignoreCase = true)
|
||||||
|
val text = activity.text.trim()
|
||||||
|
if (isComment && text.isNotBlank()) {
|
||||||
|
body.visibility = View.VISIBLE
|
||||||
|
body.text = markdownRenderer.render(text)
|
||||||
|
MarkdownRenderer.enableLinks(body)
|
||||||
|
} else {
|
||||||
|
body.visibility = View.GONE
|
||||||
|
body.text = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,590 @@
|
|||||||
|
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(id, fakeFactory.invoke(id))
|
||||||
|
} else {
|
||||||
|
val repository = CardDetailRepository(
|
||||||
|
sessionStore = sessionStore,
|
||||||
|
apiKeyStore = apiKeyStore,
|
||||||
|
apiClient = apiClient,
|
||||||
|
)
|
||||||
|
CardDetailViewModel.Factory(
|
||||||
|
cardId = id,
|
||||||
|
dataSource = CardDetailRepositoryDataSource(repository, repository::loadCard),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,29 @@ class CardDetailRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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> {
|
suspend fun updateTitle(cardId: String, title: String): Result<Unit> {
|
||||||
val normalizedCardId = cardId.trim()
|
val normalizedCardId = cardId.trim()
|
||||||
if (normalizedCardId.isBlank()) {
|
if (normalizedCardId.isBlank()) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/src/main/res/drawable/ic_timeline_note_24.xml
Normal file
19
app/src/main/res/drawable/ic_timeline_note_24.xml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M4,3h11l5,5v13H4z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M15,3v5h5" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M7,11h10v1.6H7zM7,14.2h10v1.6H7zM7,17.4h7v1.6H7z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M7.8,9.4l1.2,1.2 3.2,-3.2 -1.2,-1.2z" />
|
||||||
|
</vector>
|
||||||
300
app/src/main/res/layout/activity_card_detail.xml
Normal file
300
app/src/main/res/layout/activity_card_detail.xml
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<com.google.android.material.appbar.AppBarLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content">
|
||||||
|
|
||||||
|
<com.google.android.material.appbar.MaterialToolbar
|
||||||
|
android:id="@+id/cardDetailToolbar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:titleTextAppearance="@style/TextAppearance.Material3.TitleLarge" />
|
||||||
|
</com.google.android.material.appbar.AppBarLayout>
|
||||||
|
|
||||||
|
<FrameLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
app:layout_behavior="@string/appbar_scrolling_view_behavior">
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/cardDetailInitialProgress"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/cardDetailErrorContainer"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:gravity="center"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="24dp"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/cardDetailErrorText"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/cardDetailRetryButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:text="@string/retry" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<androidx.core.widget.NestedScrollView
|
||||||
|
android:id="@+id/cardDetailContentScroll"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:fillViewport="true"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/cardDetailContentContainer"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/cardDetailTitleInputLayout"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="@string/card_detail_title">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/cardDetailTitleInput"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="textCapSentences|text"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/cardDetailTitleSavingText"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:text="@string/card_detail_saving"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/cardDetailTitleErrorText"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||||
|
android:textColor="?attr/colorError"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/cardDetailTitleRetryButton"
|
||||||
|
style="@style/Widget.Material3.Button.TextButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/retry"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/cardDetailTagsRecycler"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:nestedScrollingEnabled="false" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/cardDetailDueDateText"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/card_detail_set_due_date"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/cardDetailDueDateClearButton"
|
||||||
|
style="@style/Widget.Material3.Button.TextButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/clear_due_date"
|
||||||
|
android:visibility="gone" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/cardDetailDueDateSavingText"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:text="@string/card_detail_saving"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/cardDetailDueDateErrorText"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||||
|
android:textColor="?attr/colorError"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/cardDetailDueDateRetryButton"
|
||||||
|
style="@style/Widget.Material3.Button.TextButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/retry"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButtonToggleGroup
|
||||||
|
android:id="@+id/cardDetailDescriptionModeToggle"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
app:singleSelection="true">
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/cardDetailDescriptionEditButton"
|
||||||
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/card_detail_edit" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/cardDetailDescriptionPreviewButton"
|
||||||
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/card_detail_preview" />
|
||||||
|
</com.google.android.material.button.MaterialButtonToggleGroup>
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/cardDetailDescriptionInputLayout"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:hint="@string/description">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/cardDetailDescriptionInput"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="top|start"
|
||||||
|
android:inputType="textMultiLine|textCapSentences"
|
||||||
|
android:minLines="6" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/cardDetailDescriptionPreviewText"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/cardDetailDescriptionSavingText"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:text="@string/card_detail_saving"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/cardDetailDescriptionErrorText"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||||
|
android:textColor="?attr/colorError"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/cardDetailDescriptionRetryButton"
|
||||||
|
style="@style/Widget.Material3.Button.TextButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/retry"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="20dp"
|
||||||
|
android:text="@string/card_detail_timeline"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.TitleMedium" />
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/cardDetailTimelineProgress"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/cardDetailTimelineErrorText"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||||
|
android:textColor="?attr/colorError"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/cardDetailTimelineRetryButton"
|
||||||
|
style="@style/Widget.Material3.Button.TextButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/retry"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/cardDetailTimelineEmptyText"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="@string/card_detail_timeline_empty"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/cardDetailTimelineRecycler"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:nestedScrollingEnabled="false" />
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="72dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
</androidx.core.widget.NestedScrollView>
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||||
|
android:id="@+id/cardDetailAddCommentFab"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="bottom|end"
|
||||||
|
android:layout_margin="16dp"
|
||||||
|
android:contentDescription="@string/card_detail_add_comment"
|
||||||
|
app:srcCompat="@android:drawable/ic_input_add" />
|
||||||
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||||
65
app/src/main/res/layout/dialog_add_comment.xml
Normal file
65
app/src/main/res/layout/dialog_add_comment.xml
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingStart="24dp"
|
||||||
|
android:paddingTop="8dp"
|
||||||
|
android:paddingEnd="24dp"
|
||||||
|
android:paddingBottom="8dp">
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButtonToggleGroup
|
||||||
|
android:id="@+id/commentDialogModeToggle"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:singleSelection="true">
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/commentDialogEditButton"
|
||||||
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/card_detail_edit" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/commentDialogPreviewButton"
|
||||||
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/card_detail_preview" />
|
||||||
|
</com.google.android.material.button.MaterialButtonToggleGroup>
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/commentDialogInputLayout"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:hint="@string/card_detail_comment_hint">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/commentDialogInput"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="top|start"
|
||||||
|
android:inputType="textMultiLine|textCapSentences"
|
||||||
|
android:minLines="4" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/commentDialogPreviewText"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/commentDialogErrorText"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||||
|
android:textColor="?attr/colorError"
|
||||||
|
android:visibility="gone" />
|
||||||
|
</LinearLayout>
|
||||||
57
app/src/main/res/layout/item_card_activity_timeline.xml
Normal file
57
app/src/main/res/layout/item_card_activity_timeline.xml
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingTop="8dp"
|
||||||
|
android:paddingBottom="8dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="36dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:id="@+id/timelineTopConnector"
|
||||||
|
android:layout_width="2dp"
|
||||||
|
android:layout_height="12dp"
|
||||||
|
android:background="@android:color/darker_gray" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/timelineIcon"
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:contentDescription="@null"
|
||||||
|
android:src="@drawable/ic_timeline_note_24"
|
||||||
|
android:tint="@android:color/darker_gray" />
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:id="@+id/timelineBottomConnector"
|
||||||
|
android:layout_width="2dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@android:color/darker_gray" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/timelineHeaderText"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/timelineBodyText"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||||
|
android:visibility="gone" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
@@ -68,4 +68,21 @@
|
|||||||
<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>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
Reference in New Issue
Block a user