feat: implement card detail activity UI and timeline

This commit is contained in:
2026-03-16 21:48:35 -04:00
parent a0255c2487
commit f9625df828
13 changed files with 1599 additions and 11 deletions

View File

@@ -55,6 +55,7 @@ import space.hackenslacker.kanbn4droid.app.boarddetail.BoardListDetail
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardTagSummary
import space.hackenslacker.kanbn4droid.app.boarddetail.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> {

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,83 @@
package space.hackenslacker.kanbn4droid.app.carddetail
import android.text.format.DateUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import space.hackenslacker.kanbn4droid.app.R
class ActivityTimelineAdapter(
private val markdownRenderer: MarkdownRenderer,
) : RecyclerView.Adapter<ActivityTimelineAdapter.ActivityViewHolder>() {
private var items: List<CardActivity> = emptyList()
fun submitActivities(value: List<CardActivity>) {
items = value
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ActivityViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_card_activity_timeline, parent, false)
return ActivityViewHolder(view, markdownRenderer)
}
override fun onBindViewHolder(holder: ActivityViewHolder, position: Int) {
holder.bind(
activity = items[position],
isFirst = position == 0,
isLast = position == items.lastIndex,
)
}
override fun getItemCount(): Int = items.size
class ActivityViewHolder(
itemView: View,
private val markdownRenderer: MarkdownRenderer,
) : RecyclerView.ViewHolder(itemView) {
private val topConnector: View = itemView.findViewById(R.id.timelineTopConnector)
private val bottomConnector: View = itemView.findViewById(R.id.timelineBottomConnector)
private val icon: ImageView = itemView.findViewById(R.id.timelineIcon)
private val header: TextView = itemView.findViewById(R.id.timelineHeaderText)
private val body: TextView = itemView.findViewById(R.id.timelineBodyText)
fun bind(activity: CardActivity, isFirst: Boolean, isLast: Boolean) {
topConnector.visibility = if (isFirst) View.INVISIBLE else View.VISIBLE
bottomConnector.visibility = if (isLast) View.INVISIBLE else View.VISIBLE
icon.setImageResource(R.drawable.ic_timeline_note_24)
val action = when {
activity.type.contains("comment", ignoreCase = true) -> itemView.context.getString(R.string.card_detail_timeline_action_commented)
else -> itemView.context.getString(R.string.card_detail_timeline_action_updated)
}
val actor = itemView.context.getString(R.string.card_detail_timeline_actor_unknown)
val relative = DateUtils.getRelativeTimeSpanString(
activity.createdAtEpochMillis,
System.currentTimeMillis(),
DateUtils.MINUTE_IN_MILLIS,
)
header.text = itemView.context.getString(
R.string.card_detail_timeline_header,
actor,
action,
relative,
)
val isComment = activity.type.contains("comment", ignoreCase = true)
val text = activity.text.trim()
if (isComment && text.isNotBlank()) {
body.visibility = View.VISIBLE
body.text = markdownRenderer.render(text)
MarkdownRenderer.enableLinks(body)
} else {
body.visibility = View.GONE
body.text = ""
}
}
}
}

View File

@@ -0,0 +1,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
}
}

View File

@@ -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()) {

View File

@@ -0,0 +1,56 @@
package space.hackenslacker.kanbn4droid.app.carddetail
import android.content.res.ColorStateList
import android.graphics.Color
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.chip.Chip
import com.google.android.material.color.MaterialColors
class CardDetailTagChipAdapter : RecyclerView.Adapter<CardDetailTagChipAdapter.TagViewHolder>() {
private var tags: List<CardDetailTag> = emptyList()
fun submitTags(value: List<CardDetailTag>) {
tags = value
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TagViewHolder {
return TagViewHolder(
Chip(parent.context).apply {
isClickable = false
isCheckable = false
chipBackgroundColor = null
chipStrokeWidth = 2f
val margin = (8 * parent.context.resources.displayMetrics.density).toInt()
layoutParams = ViewGroup.MarginLayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
).apply {
marginEnd = margin
}
},
)
}
override fun onBindViewHolder(holder: TagViewHolder, position: Int) {
holder.bind(tags[position])
}
override fun getItemCount(): Int = tags.size
class TagViewHolder(private val chip: Chip) : RecyclerView.ViewHolder(chip) {
fun bind(tag: CardDetailTag) {
chip.text = tag.name
chip.chipStrokeColor = ColorStateList.valueOf(parseColorOrFallback(tag.colorHex))
}
private fun parseColorOrFallback(value: String): Int {
return runCatching { Color.parseColor(value) }
.getOrElse {
MaterialColors.getColor(chip, com.google.android.material.R.attr.colorOnSurface, Color.DKGRAY)
}
}
}
}

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M4,3h11l5,5v13H4z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M15,3v5h5" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M7,11h10v1.6H7zM7,14.2h10v1.6H7zM7,17.4h7v1.6H7z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M7.8,9.4l1.2,1.2 3.2,-3.2 -1.2,-1.2z" />
</vector>

View File

@@ -0,0 +1,300 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/cardDetailToolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:titleTextAppearance="@style/TextAppearance.Material3.TitleLarge" />
</com.google.android.material.appbar.AppBarLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<ProgressBar
android:id="@+id/cardDetailInitialProgress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
<LinearLayout
android:id="@+id/cardDetailErrorContainer"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:padding="24dp"
android:visibility="gone">
<TextView
android:id="@+id/cardDetailErrorText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
<com.google.android.material.button.MaterialButton
android:id="@+id/cardDetailRetryButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/retry" />
</LinearLayout>
<androidx.core.widget.NestedScrollView
android:id="@+id/cardDetailContentScroll"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
android:visibility="gone">
<LinearLayout
android:id="@+id/cardDetailContentContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/cardDetailTitleInputLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/card_detail_title">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/cardDetailTitleInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textCapSentences|text"
android:maxLines="1"
android:textStyle="bold" />
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:id="@+id/cardDetailTitleSavingText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/card_detail_saving"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:visibility="gone" />
<TextView
android:id="@+id/cardDetailTitleErrorText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorError"
android:visibility="gone" />
<com.google.android.material.button.MaterialButton
android:id="@+id/cardDetailTitleRetryButton"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/retry"
android:visibility="gone" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/cardDetailTagsRecycler"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:nestedScrollingEnabled="false" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/cardDetailDueDateText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/card_detail_set_due_date"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
<com.google.android.material.button.MaterialButton
android:id="@+id/cardDetailDueDateClearButton"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/clear_due_date"
android:visibility="gone" />
</LinearLayout>
<TextView
android:id="@+id/cardDetailDueDateSavingText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/card_detail_saving"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:visibility="gone" />
<TextView
android:id="@+id/cardDetailDueDateErrorText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorError"
android:visibility="gone" />
<com.google.android.material.button.MaterialButton
android:id="@+id/cardDetailDueDateRetryButton"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/retry"
android:visibility="gone" />
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/cardDetailDescriptionModeToggle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:singleSelection="true">
<com.google.android.material.button.MaterialButton
android:id="@+id/cardDetailDescriptionEditButton"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/card_detail_edit" />
<com.google.android.material.button.MaterialButton
android:id="@+id/cardDetailDescriptionPreviewButton"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/card_detail_preview" />
</com.google.android.material.button.MaterialButtonToggleGroup>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/cardDetailDescriptionInputLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/description">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/cardDetailDescriptionInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="top|start"
android:inputType="textMultiLine|textCapSentences"
android:minLines="6" />
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:id="@+id/cardDetailDescriptionPreviewText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:visibility="gone" />
<TextView
android:id="@+id/cardDetailDescriptionSavingText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/card_detail_saving"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:visibility="gone" />
<TextView
android:id="@+id/cardDetailDescriptionErrorText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorError"
android:visibility="gone" />
<com.google.android.material.button.MaterialButton
android:id="@+id/cardDetailDescriptionRetryButton"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/retry"
android:visibility="gone" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="@string/card_detail_timeline"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium" />
<ProgressBar
android:id="@+id/cardDetailTimelineProgress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:visibility="gone" />
<TextView
android:id="@+id/cardDetailTimelineErrorText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorError"
android:visibility="gone" />
<com.google.android.material.button.MaterialButton
android:id="@+id/cardDetailTimelineRetryButton"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/retry"
android:visibility="gone" />
<TextView
android:id="@+id/cardDetailTimelineEmptyText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/card_detail_timeline_empty"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:visibility="gone" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/cardDetailTimelineRecycler"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:nestedScrollingEnabled="false" />
<View
android:layout_width="match_parent"
android:layout_height="72dp" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</FrameLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/cardDetailAddCommentFab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:contentDescription="@string/card_detail_add_comment"
app:srcCompat="@android:drawable/ic_input_add" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="24dp"
android:paddingTop="8dp"
android:paddingEnd="24dp"
android:paddingBottom="8dp">
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/commentDialogModeToggle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:singleSelection="true">
<com.google.android.material.button.MaterialButton
android:id="@+id/commentDialogEditButton"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/card_detail_edit" />
<com.google.android.material.button.MaterialButton
android:id="@+id/commentDialogPreviewButton"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/card_detail_preview" />
</com.google.android.material.button.MaterialButtonToggleGroup>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/commentDialogInputLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/card_detail_comment_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/commentDialogInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="top|start"
android:inputType="textMultiLine|textCapSentences"
android:minLines="4" />
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:id="@+id/commentDialogPreviewText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:visibility="gone" />
<TextView
android:id="@+id/commentDialogErrorText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorError"
android:visibility="gone" />
</LinearLayout>

View File

@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingTop="8dp"
android:paddingBottom="8dp">
<LinearLayout
android:layout_width="36dp"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:orientation="vertical">
<View
android:id="@+id/timelineTopConnector"
android:layout_width="2dp"
android:layout_height="12dp"
android:background="@android:color/darker_gray" />
<ImageView
android:id="@+id/timelineIcon"
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@null"
android:src="@drawable/ic_timeline_note_24"
android:tint="@android:color/darker_gray" />
<View
android:id="@+id/timelineBottomConnector"
android:layout_width="2dp"
android:layout_height="match_parent"
android:background="@android:color/darker_gray" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/timelineHeaderText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:textStyle="bold" />
<TextView
android:id="@+id/timelineBodyText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:visibility="gone" />
</LinearLayout>
</LinearLayout>

View File

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