diff --git a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardDetailFlowTest.kt b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardDetailFlowTest.kt index 0a6cb37..b336f09 100644 --- a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardDetailFlowTest.kt +++ b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardDetailFlowTest.kt @@ -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() .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 { diff --git a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/CardDetailFlowTest.kt b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/CardDetailFlowTest.kt new file mode 100644 index 0000000..025d091 --- /dev/null +++ b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/CardDetailFlowTest.kt @@ -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() + private val observedEvents = mutableListOf() + + @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() + 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(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(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() + fakeDataSource.updateTitleGate = titleGate + val scenario = launchCardDetail() + + onView(withId(R.id.cardDetailTitleInput)).perform(replaceText("Blocked title"), closeSoftKeyboard()) + scenario.onActivity { + val input = it.findViewById(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 { + 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(intent).also { + if (waitForContent && cardId != null) { + awaitCondition { + observedStates.lastOrNull()?.isInitialLoading == false && + observedStates.lastOrNull()?.loadErrorMessage == null + } + } + } + } + + private fun withCurrentTextColor(expectedColor: Int): Matcher { + return object : TypeSafeMatcher() { + 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> = ArrayDeque() + val updateTitleResults: ArrayDeque> = ArrayDeque() + val updateDescriptionResults: ArrayDeque> = ArrayDeque() + val updateDueDateResults: ArrayDeque> = ArrayDeque() + val listActivitiesResults: ArrayDeque>> = ArrayDeque() + val addCommentResults: ArrayDeque> = ArrayDeque() + + var loadGate: CompletableDeferred? = null + var updateTitleGate: CompletableDeferred? = null + var updateDescriptionGate: CompletableDeferred? = null + var updateDueDateGate: CompletableDeferred? = 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 { + loadGate?.await() + return if (loadResults.isNotEmpty()) loadResults.removeFirst() else DataSourceResult.Success(currentCard) + } + + override suspend fun updateTitle(cardId: String, title: String): DataSourceResult { + 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 { + 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 { + 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> { + listActivitiesCalls.incrementAndGet() + return if (listActivitiesResults.isNotEmpty()) listActivitiesResults.removeFirst() else DataSourceResult.Success(activities) + } + + override suspend fun addComment(cardId: String, comment: String): DataSourceResult { + 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 + } + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 69dfb94..94ef9ab 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,6 +11,9 @@ android:supportsRtl="true" android:usesCleartextTraffic="true" android:theme="@style/Theme.Kanbn4Droid"> + diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailActivity.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailActivity.kt index ff3578f..91e69ef 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailActivity.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailActivity.kt @@ -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), ) } diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/carddetail/ActivityTimelineAdapter.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/carddetail/ActivityTimelineAdapter.kt new file mode 100644 index 0000000..325bccf --- /dev/null +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/carddetail/ActivityTimelineAdapter.kt @@ -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() { + + private var items: List = emptyList() + + fun submitActivities(value: List) { + 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 = "" + } + } + } +} diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailActivity.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailActivity.kt new file mode 100644 index 0000000..9f9e6a3 --- /dev/null +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailActivity.kt @@ -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(R.id.commentDialogModeToggle) + val inputLayout = dialog.findViewById(R.id.commentDialogInputLayout) + val input = dialog.findViewById(R.id.commentDialogInput) + val preview = dialog.findViewById(R.id.commentDialogPreviewText) + val error = dialog.findViewById(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 + } +} diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailRepository.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailRepository.kt index 0961b64..ed39ee9 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailRepository.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailRepository.kt @@ -33,6 +33,29 @@ class CardDetailRepository( } } + suspend fun loadCard(cardId: String): Result { + 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 { val normalizedCardId = cardId.trim() if (normalizedCardId.isBlank()) { diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailTagChipAdapter.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailTagChipAdapter.kt new file mode 100644 index 0000000..900c99a --- /dev/null +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailTagChipAdapter.kt @@ -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() { + + private var tags: List = emptyList() + + fun submitTags(value: List) { + 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) + } + } + } +} diff --git a/app/src/main/res/drawable/ic_timeline_note_24.xml b/app/src/main/res/drawable/ic_timeline_note_24.xml new file mode 100644 index 0000000..15e42cb --- /dev/null +++ b/app/src/main/res/drawable/ic_timeline_note_24.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/app/src/main/res/layout/activity_card_detail.xml b/app/src/main/res/layout/activity_card_detail.xml new file mode 100644 index 0000000..77f7207 --- /dev/null +++ b/app/src/main/res/layout/activity_card_detail.xml @@ -0,0 +1,300 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_add_comment.xml b/app/src/main/res/layout/dialog_add_comment.xml new file mode 100644 index 0000000..98b8103 --- /dev/null +++ b/app/src/main/res/layout/dialog_add_comment.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_card_activity_timeline.xml b/app/src/main/res/layout/item_card_activity_timeline.xml new file mode 100644 index 0000000..c36dc61 --- /dev/null +++ b/app/src/main/res/layout/item_card_activity_timeline.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6146bf1..6a1a551 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -68,4 +68,21 @@ Tags Select one or more tags. Select tags to include. + Unable to open card. + Session expired. Please sign in again. + Title + Card + Set due date + Saving... + Edit + Preview + Timeline + No activity yet. + Add comment + Comment + %1$s %2$s - %3$s + Someone + commented + updated this card + Add