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.CardBatchMutationResult
|
||||
import space.hackenslacker.kanbn4droid.app.boarddetail.CreatedEntityRef
|
||||
import space.hackenslacker.kanbn4droid.app.carddetail.CardDetailActivity
|
||||
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
|
||||
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
|
||||
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
|
||||
@@ -788,14 +789,14 @@ class BoardDetailFlowTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cardTapNavigatesToCardPlaceholderWithExtras() {
|
||||
fun cardTapNavigatesToCardDetailWithExtras() {
|
||||
launchBoardDetail()
|
||||
|
||||
onView(withText("Card 1")).perform(click())
|
||||
|
||||
Intents.intended(hasComponent(CardDetailPlaceholderActivity::class.java.name))
|
||||
Intents.intended(hasExtra(CardDetailPlaceholderActivity.EXTRA_CARD_ID, "card-1"))
|
||||
Intents.intended(hasExtra(CardDetailPlaceholderActivity.EXTRA_CARD_TITLE, "Card 1"))
|
||||
Intents.intended(hasComponent(CardDetailActivity::class.java.name))
|
||||
Intents.intended(hasExtra(CardDetailActivity.EXTRA_CARD_ID, "card-1"))
|
||||
Intents.intended(hasExtra(CardDetailActivity.EXTRA_CARD_TITLE, "Card 1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -807,9 +808,9 @@ class BoardDetailFlowTest {
|
||||
val expectedFallback = ApplicationProvider.getApplicationContext<android.content.Context>()
|
||||
.getString(R.string.card_detail_placeholder_fallback_title)
|
||||
|
||||
Intents.intended(hasComponent(CardDetailPlaceholderActivity::class.java.name))
|
||||
Intents.intended(hasExtra(CardDetailPlaceholderActivity.EXTRA_CARD_ID, "card-1"))
|
||||
Intents.intended(hasExtra(CardDetailPlaceholderActivity.EXTRA_CARD_TITLE, expectedFallback))
|
||||
Intents.intended(hasComponent(CardDetailActivity::class.java.name))
|
||||
Intents.intended(hasExtra(CardDetailActivity.EXTRA_CARD_ID, "card-1"))
|
||||
Intents.intended(hasExtra(CardDetailActivity.EXTRA_CARD_TITLE, expectedFallback))
|
||||
}
|
||||
|
||||
private fun launchBoardDetail(boardId: String? = "board-1"): ActivityScenario<BoardDetailActivity> {
|
||||
|
||||
@@ -0,0 +1,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:usesCleartextTraffic="true"
|
||||
android:theme="@style/Theme.Kanbn4Droid">
|
||||
<activity
|
||||
android:name=".carddetail.CardDetailActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".CardDetailPlaceholderActivity"
|
||||
android:exported="false" />
|
||||
|
||||
@@ -35,7 +35,7 @@ import java.time.ZoneId
|
||||
import java.util.Date
|
||||
import kotlinx.coroutines.launch
|
||||
import space.hackenslacker.kanbn4droid.app.MainActivity
|
||||
import space.hackenslacker.kanbn4droid.app.CardDetailPlaceholderActivity
|
||||
import space.hackenslacker.kanbn4droid.app.carddetail.CardDetailActivity
|
||||
import space.hackenslacker.kanbn4droid.app.R
|
||||
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
|
||||
import space.hackenslacker.kanbn4droid.app.auth.HttpKanbnApiClient
|
||||
@@ -243,9 +243,9 @@ class BoardDetailActivity : AppCompatActivity() {
|
||||
.trim()
|
||||
.ifBlank { getString(R.string.card_detail_placeholder_fallback_title) }
|
||||
startActivity(
|
||||
Intent(this@BoardDetailActivity, CardDetailPlaceholderActivity::class.java)
|
||||
.putExtra(CardDetailPlaceholderActivity.EXTRA_CARD_ID, event.cardId)
|
||||
.putExtra(CardDetailPlaceholderActivity.EXTRA_CARD_TITLE, cardTitle),
|
||||
Intent(this@BoardDetailActivity, CardDetailActivity::class.java)
|
||||
.putExtra(CardDetailActivity.EXTRA_CARD_ID, event.cardId)
|
||||
.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> {
|
||||
val normalizedCardId = cardId.trim()
|
||||
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_placeholder">Select one or more tags.</string>
|
||||
<string name="filter_tags_placeholder">Select tags to include.</string>
|
||||
<string name="card_detail_unable_to_open_card">Unable to open card.</string>
|
||||
<string name="card_detail_session_expired">Session expired. Please sign in again.</string>
|
||||
<string name="card_detail_title">Title</string>
|
||||
<string name="card_detail_title_default">Card</string>
|
||||
<string name="card_detail_set_due_date">Set due date</string>
|
||||
<string name="card_detail_saving">Saving...</string>
|
||||
<string name="card_detail_edit">Edit</string>
|
||||
<string name="card_detail_preview">Preview</string>
|
||||
<string name="card_detail_timeline">Timeline</string>
|
||||
<string name="card_detail_timeline_empty">No activity yet.</string>
|
||||
<string name="card_detail_add_comment">Add comment</string>
|
||||
<string name="card_detail_comment_hint">Comment</string>
|
||||
<string name="card_detail_timeline_header">%1$s %2$s - %3$s</string>
|
||||
<string name="card_detail_timeline_actor_unknown">Someone</string>
|
||||
<string name="card_detail_timeline_action_commented">commented</string>
|
||||
<string name="card_detail_timeline_action_updated">updated this card</string>
|
||||
<string name="add">Add</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user