feat: implement board detail pager UI and card rendering
This commit is contained in:
@@ -0,0 +1,357 @@
|
|||||||
|
package space.hackenslacker.kanbn4droid.app
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.view.inputmethod.EditorInfo
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.TextView
|
||||||
|
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.longClick
|
||||||
|
import androidx.test.espresso.action.ViewActions.replaceText
|
||||||
|
import androidx.test.espresso.assertion.ViewAssertions.matches
|
||||||
|
import androidx.test.espresso.matcher.RootMatchers.isDialog
|
||||||
|
import androidx.test.espresso.matcher.ViewMatchers.withContentDescription
|
||||||
|
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
|
||||||
|
import androidx.test.espresso.matcher.ViewMatchers.isEnabled
|
||||||
|
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.text.DateFormat
|
||||||
|
import java.util.ArrayDeque
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import org.hamcrest.Matchers.not
|
||||||
|
import org.hamcrest.Description
|
||||||
|
import org.hamcrest.Matcher
|
||||||
|
import org.hamcrest.TypeSafeMatcher
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardCardSummary
|
||||||
|
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetail
|
||||||
|
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailActivity
|
||||||
|
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailDataSource
|
||||||
|
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.boards.BoardsApiResult
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class BoardDetailFlowTest {
|
||||||
|
|
||||||
|
private lateinit var defaultDataSource: FakeBoardDetailDataSource
|
||||||
|
private var originalLocale: Locale? = null
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
originalLocale = Locale.getDefault()
|
||||||
|
defaultDataSource = FakeBoardDetailDataSource(initialDetail = detailOneList())
|
||||||
|
BoardDetailActivity.testDataSourceFactory = { defaultDataSource }
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
BoardDetailActivity.testDataSourceFactory = null
|
||||||
|
originalLocale?.let { Locale.setDefault(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun boardDetailShowsListTitleAndCards() {
|
||||||
|
launchBoardDetail()
|
||||||
|
|
||||||
|
onView(withText("To Do")).check(matches(isDisplayed()))
|
||||||
|
onView(withText("Card 1")).check(matches(isDisplayed()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun initialLoadShowsProgressThenContent() {
|
||||||
|
val gate = CompletableDeferred<Unit>()
|
||||||
|
defaultDataSource.loadGate = gate
|
||||||
|
|
||||||
|
val scenario = launchBoardDetail()
|
||||||
|
onView(withId(R.id.boardDetailInitialProgress)).check(matches(isDisplayed()))
|
||||||
|
|
||||||
|
gate.complete(Unit)
|
||||||
|
scenario.onActivity { }
|
||||||
|
|
||||||
|
onView(withText("Card 1")).check(matches(isDisplayed()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun emptyBoardShowsNoListsYetMessage() {
|
||||||
|
defaultDataSource.currentDetail = BoardDetail(id = "board-1", title = "Board", lists = emptyList())
|
||||||
|
|
||||||
|
launchBoardDetail()
|
||||||
|
|
||||||
|
onView(withText(R.string.board_detail_empty_board)).check(matches(isDisplayed()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun initialLoadFailureShowsRetryAndRetryReloads() {
|
||||||
|
defaultDataSource.loadResults.add(BoardsApiResult.Failure("Load failed"))
|
||||||
|
defaultDataSource.loadResults.add(BoardsApiResult.Success(detailOneList()))
|
||||||
|
|
||||||
|
launchBoardDetail()
|
||||||
|
|
||||||
|
onView(withText("Load failed")).check(matches(isDisplayed()))
|
||||||
|
onView(withId(R.id.boardDetailRetryButton)).perform(click())
|
||||||
|
onView(withText("Card 1")).check(matches(isDisplayed()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun expiredDueDateUsesErrorColor() {
|
||||||
|
defaultDataSource.currentDetail = detailWithDueDate(System.currentTimeMillis() - 3_600_000)
|
||||||
|
val scenario = launchBoardDetail()
|
||||||
|
|
||||||
|
var expectedColor: Int? = null
|
||||||
|
scenario.onActivity { activity ->
|
||||||
|
expectedColor = MaterialColors.getColor(activity.findViewById(android.R.id.content), com.google.android.material.R.attr.colorError)
|
||||||
|
}
|
||||||
|
onView(withId(R.id.cardDueDateText)).check(matches(withCurrentTextColor(expectedColor ?: Color.RED)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun validDueDateUsesOnSurfaceColor() {
|
||||||
|
defaultDataSource.currentDetail = detailWithDueDate(System.currentTimeMillis() + 86_400_000)
|
||||||
|
val scenario = launchBoardDetail()
|
||||||
|
|
||||||
|
var expectedColor: Int? = null
|
||||||
|
scenario.onActivity { activity ->
|
||||||
|
expectedColor = MaterialColors.getColor(activity.findViewById(android.R.id.content), com.google.android.material.R.attr.colorOnSurface)
|
||||||
|
}
|
||||||
|
onView(withId(R.id.cardDueDateText)).check(matches(withCurrentTextColor(expectedColor ?: Color.BLACK)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dueDateUsesSystemLocaleFormatting() {
|
||||||
|
Locale.setDefault(Locale.FRANCE)
|
||||||
|
val due = 1_735_776_000_000L
|
||||||
|
defaultDataSource.currentDetail = detailWithDueDate(due)
|
||||||
|
|
||||||
|
launchBoardDetail()
|
||||||
|
|
||||||
|
val expected = DateFormat.getDateInstance(DateFormat.MEDIUM, Locale.getDefault()).format(Date(due))
|
||||||
|
onView(withText(expected)).check(matches(isDisplayed()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun inlineListTitleEdit_savesOnImeDone() {
|
||||||
|
defaultDataSource.currentDetail = detailOneList()
|
||||||
|
val scenario = launchBoardDetail()
|
||||||
|
|
||||||
|
onView(withId(R.id.listTitleText)).perform(click())
|
||||||
|
scenario.onActivity { activity ->
|
||||||
|
val input = activity.findViewById<TextView>(R.id.listTitleEditInput)
|
||||||
|
input.setText("Renamed")
|
||||||
|
input.onEditorAction(EditorInfo.IME_ACTION_DONE)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(1, defaultDataSource.renameCalls)
|
||||||
|
assertEquals("Renamed", defaultDataSource.lastRenameTitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun inlineListTitleEdit_savesOnFocusLoss() {
|
||||||
|
launchBoardDetail()
|
||||||
|
|
||||||
|
onView(withId(R.id.listTitleText)).perform(click())
|
||||||
|
onView(withId(R.id.listTitleEditInput)).perform(replaceText("Renamed again"))
|
||||||
|
onView(withId(R.id.listCardsRecycler)).perform(click())
|
||||||
|
|
||||||
|
assertEquals(1, defaultDataSource.renameCalls)
|
||||||
|
assertEquals("Renamed again", defaultDataSource.lastRenameTitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun inlineListTitleEdit_trimmedNoOpSkipsRenameCall() {
|
||||||
|
val scenario = launchBoardDetail()
|
||||||
|
|
||||||
|
onView(withId(R.id.listTitleText)).perform(click())
|
||||||
|
scenario.onActivity { activity ->
|
||||||
|
val input = activity.findViewById<TextView>(R.id.listTitleEditInput)
|
||||||
|
input.setText(" To Do ")
|
||||||
|
input.onEditorAction(EditorInfo.IME_ACTION_DONE)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(0, defaultDataSource.renameCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun inlineListTitleEdit_rejectsBlankTitle() {
|
||||||
|
val scenario = launchBoardDetail()
|
||||||
|
|
||||||
|
onView(withId(R.id.listTitleText)).perform(click())
|
||||||
|
scenario.onActivity { activity ->
|
||||||
|
val input = activity.findViewById<TextView>(R.id.listTitleEditInput)
|
||||||
|
input.setText(" ")
|
||||||
|
input.onEditorAction(EditorInfo.IME_ACTION_DONE)
|
||||||
|
}
|
||||||
|
|
||||||
|
onView(withText(R.string.list_title_required)).check(matches(isDisplayed()))
|
||||||
|
assertEquals(0, defaultDataSource.renameCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun inlineListTitleEdit_renameFailure_keepsEditModeAndShowsError() {
|
||||||
|
defaultDataSource.renameResult = BoardsApiResult.Failure("Rename rejected")
|
||||||
|
val scenario = launchBoardDetail()
|
||||||
|
|
||||||
|
onView(withId(R.id.listTitleText)).perform(click())
|
||||||
|
scenario.onActivity { activity ->
|
||||||
|
val input = activity.findViewById<TextView>(R.id.listTitleEditInput)
|
||||||
|
input.setText("X")
|
||||||
|
input.onEditorAction(EditorInfo.IME_ACTION_DONE)
|
||||||
|
}
|
||||||
|
|
||||||
|
onView(withId(R.id.listTitleEditInput)).check(matches(isDisplayed()))
|
||||||
|
onView(withText("Rename rejected")).check(matches(isDisplayed()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun inlineListTitleEdit_saveDisabledWhileMutating() {
|
||||||
|
defaultDataSource.renameGate = CompletableDeferred()
|
||||||
|
val scenario = launchBoardDetail()
|
||||||
|
|
||||||
|
onView(withId(R.id.listTitleText)).perform(click())
|
||||||
|
scenario.onActivity { activity ->
|
||||||
|
val input = activity.findViewById<TextView>(R.id.listTitleEditInput)
|
||||||
|
input.setText("New")
|
||||||
|
input.onEditorAction(EditorInfo.IME_ACTION_DONE)
|
||||||
|
}
|
||||||
|
|
||||||
|
onView(withId(R.id.listTitleEditInput)).check(matches(not(isEnabled())))
|
||||||
|
defaultDataSource.renameGate?.complete(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun selectionModeActionsShowTooltipsOnLongPress() {
|
||||||
|
val scenario = launchBoardDetail()
|
||||||
|
|
||||||
|
onView(withText("Card 1")).perform(longClick())
|
||||||
|
|
||||||
|
scenario.onActivity { activity ->
|
||||||
|
val menu = activity.findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.boardDetailToolbar).menu
|
||||||
|
assertEquals(activity.getString(R.string.select_all), menu.findItem(R.id.actionSelectAll)?.tooltipText?.toString())
|
||||||
|
assertEquals(activity.getString(R.string.move_cards), menu.findItem(R.id.actionMoveCards)?.tooltipText?.toString())
|
||||||
|
assertEquals(activity.getString(R.string.delete_cards), menu.findItem(R.id.actionDeleteCards)?.tooltipText?.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun launchBoardDetail(): ActivityScenario<BoardDetailActivity> {
|
||||||
|
val intent = Intent(
|
||||||
|
androidx.test.core.app.ApplicationProvider.getApplicationContext(),
|
||||||
|
BoardDetailActivity::class.java,
|
||||||
|
).putExtra(BoardDetailActivity.EXTRA_BOARD_ID, "board-1")
|
||||||
|
.putExtra(BoardDetailActivity.EXTRA_BOARD_TITLE, "Board")
|
||||||
|
return ActivityScenario.launch(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 class FakeBoardDetailDataSource(
|
||||||
|
initialDetail: BoardDetail,
|
||||||
|
) : BoardDetailDataSource {
|
||||||
|
var currentDetail: BoardDetail = initialDetail
|
||||||
|
val loadResults: ArrayDeque<BoardsApiResult<BoardDetail>> = ArrayDeque()
|
||||||
|
var loadGate: CompletableDeferred<Unit>? = null
|
||||||
|
var renameResult: BoardsApiResult<Unit> = BoardsApiResult.Success(Unit)
|
||||||
|
var renameGate: CompletableDeferred<Unit>? = null
|
||||||
|
var renameCalls: Int = 0
|
||||||
|
var lastRenameTitle: String? = null
|
||||||
|
|
||||||
|
override suspend fun getBoardDetail(boardId: String): BoardsApiResult<BoardDetail> {
|
||||||
|
loadGate?.await()
|
||||||
|
return if (loadResults.isNotEmpty()) {
|
||||||
|
loadResults.removeFirst()
|
||||||
|
} else {
|
||||||
|
BoardsApiResult.Success(currentDetail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult {
|
||||||
|
return CardBatchMutationResult.Success
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteCards(cardIds: Collection<String>): CardBatchMutationResult {
|
||||||
|
return CardBatchMutationResult.Success
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun renameList(listId: String, newTitle: String): BoardsApiResult<Unit> {
|
||||||
|
renameCalls += 1
|
||||||
|
lastRenameTitle = newTitle
|
||||||
|
renameGate?.await()
|
||||||
|
val result = renameResult
|
||||||
|
if (result is BoardsApiResult.Success) {
|
||||||
|
currentDetail = currentDetail.copy(
|
||||||
|
lists = currentDetail.lists.map { list ->
|
||||||
|
if (list.id == listId) list.copy(title = newTitle) else list
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
fun detailOneList(): BoardDetail {
|
||||||
|
return BoardDetail(
|
||||||
|
id = "board-1",
|
||||||
|
title = "Board",
|
||||||
|
lists = listOf(
|
||||||
|
BoardListDetail(
|
||||||
|
id = "list-1",
|
||||||
|
title = "To Do",
|
||||||
|
cards = listOf(
|
||||||
|
BoardCardSummary(
|
||||||
|
id = "card-1",
|
||||||
|
title = "Card 1",
|
||||||
|
tags = listOf(BoardTagSummary("tag-1", "Backend", "#008080")),
|
||||||
|
dueAtEpochMillis = null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun detailWithDueDate(dueAtEpochMillis: Long): BoardDetail {
|
||||||
|
return detailOneList().copy(
|
||||||
|
lists = listOf(
|
||||||
|
BoardListDetail(
|
||||||
|
id = "list-1",
|
||||||
|
title = "To Do",
|
||||||
|
cards = listOf(
|
||||||
|
BoardCardSummary(
|
||||||
|
id = "card-1",
|
||||||
|
title = "Card 1",
|
||||||
|
tags = emptyList(),
|
||||||
|
dueAtEpochMillis = dueAtEpochMillis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,9 @@
|
|||||||
<activity
|
<activity
|
||||||
android:name=".BoardDetailPlaceholderActivity"
|
android:name=".BoardDetailPlaceholderActivity"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
<activity
|
||||||
|
android:name=".boarddetail.BoardDetailActivity"
|
||||||
|
android:exported="false" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".BoardsActivity"
|
android:name=".BoardsActivity"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|||||||
@@ -0,0 +1,317 @@
|
|||||||
|
package space.hackenslacker.kanbn4droid.app.boarddetail
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.Menu
|
||||||
|
import android.view.MenuItem
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.Button
|
||||||
|
import android.widget.ProgressBar
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import androidx.viewpager2.widget.ViewPager2
|
||||||
|
import com.google.android.material.appbar.MaterialToolbar
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import com.google.android.material.snackbar.Snackbar
|
||||||
|
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 BoardDetailActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
private lateinit var boardId: String
|
||||||
|
private lateinit var sessionStore: SessionStore
|
||||||
|
private lateinit var apiKeyStore: ApiKeyStore
|
||||||
|
private lateinit var apiClient: KanbnApiClient
|
||||||
|
|
||||||
|
private lateinit var toolbar: MaterialToolbar
|
||||||
|
private lateinit var pager: ViewPager2
|
||||||
|
private lateinit var emptyBoardText: TextView
|
||||||
|
private lateinit var initialProgress: ProgressBar
|
||||||
|
private lateinit var fullScreenErrorContainer: View
|
||||||
|
private lateinit var fullScreenErrorText: TextView
|
||||||
|
private lateinit var retryButton: Button
|
||||||
|
|
||||||
|
private var inlineTitleErrorMessage: String? = null
|
||||||
|
|
||||||
|
private lateinit var pagerAdapter: BoardListsPagerAdapter
|
||||||
|
|
||||||
|
private val viewModel: BoardDetailViewModel by viewModels {
|
||||||
|
val id = boardId
|
||||||
|
val fakeFactory = testDataSourceFactory
|
||||||
|
if (fakeFactory != null) {
|
||||||
|
object : androidx.lifecycle.ViewModelProvider.Factory {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : androidx.lifecycle.ViewModel> create(modelClass: Class<T>): T {
|
||||||
|
if (modelClass.isAssignableFrom(BoardDetailViewModel::class.java)) {
|
||||||
|
return BoardDetailViewModel(id, fakeFactory.invoke(id)) as T
|
||||||
|
}
|
||||||
|
throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
BoardDetailViewModel.Factory(
|
||||||
|
boardId = id,
|
||||||
|
repository = BoardDetailRepository(
|
||||||
|
sessionStore = sessionStore,
|
||||||
|
apiKeyStore = apiKeyStore,
|
||||||
|
apiClient = apiClient,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
boardId = intent.getStringExtra(EXTRA_BOARD_ID).orEmpty()
|
||||||
|
sessionStore = provideSessionStore()
|
||||||
|
apiKeyStore = provideApiKeyStore()
|
||||||
|
apiClient = provideApiClient()
|
||||||
|
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContentView(R.layout.activity_board_detail)
|
||||||
|
|
||||||
|
bindViews()
|
||||||
|
setupToolbar()
|
||||||
|
setupPager()
|
||||||
|
observeViewModel()
|
||||||
|
|
||||||
|
viewModel.loadBoardDetail()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBackPressed() {
|
||||||
|
if (!viewModel.onBackPressed()) {
|
||||||
|
super.onBackPressed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindViews() {
|
||||||
|
toolbar = findViewById(R.id.boardDetailToolbar)
|
||||||
|
pager = findViewById(R.id.boardDetailPager)
|
||||||
|
emptyBoardText = findViewById(R.id.boardDetailEmptyBoardText)
|
||||||
|
initialProgress = findViewById(R.id.boardDetailInitialProgress)
|
||||||
|
fullScreenErrorContainer = findViewById(R.id.boardDetailFullScreenErrorContainer)
|
||||||
|
fullScreenErrorText = findViewById(R.id.boardDetailFullScreenErrorText)
|
||||||
|
retryButton = findViewById(R.id.boardDetailRetryButton)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupToolbar() {
|
||||||
|
setSupportActionBar(toolbar)
|
||||||
|
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||||
|
supportActionBar?.title = intent.getStringExtra(EXTRA_BOARD_TITLE).orEmpty()
|
||||||
|
toolbar.setNavigationOnClickListener {
|
||||||
|
onBackPressedDispatcher.onBackPressed()
|
||||||
|
}
|
||||||
|
retryButton.setOnClickListener {
|
||||||
|
viewModel.retryLoad()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupPager() {
|
||||||
|
pagerAdapter = BoardListsPagerAdapter(
|
||||||
|
onListTitleClicked = { listId ->
|
||||||
|
inlineTitleErrorMessage = null
|
||||||
|
viewModel.startEditingList(listId)
|
||||||
|
},
|
||||||
|
onEditingTitleChanged = { title ->
|
||||||
|
inlineTitleErrorMessage = null
|
||||||
|
viewModel.updateEditingTitle(title)
|
||||||
|
},
|
||||||
|
onSubmitEditingTitle = { submitted ->
|
||||||
|
val trimmed = submitted.trim()
|
||||||
|
if (trimmed.isBlank()) {
|
||||||
|
inlineTitleErrorMessage = getString(R.string.list_title_required)
|
||||||
|
viewModel.updateEditingTitle(submitted)
|
||||||
|
render(viewModel.uiState.value)
|
||||||
|
} else {
|
||||||
|
inlineTitleErrorMessage = null
|
||||||
|
viewModel.updateEditingTitle(submitted)
|
||||||
|
viewModel.submitRenameList()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCardClick = { card -> viewModel.onCardTapped(card.id) },
|
||||||
|
onCardLongClick = { card -> viewModel.onCardLongPressed(card.id) },
|
||||||
|
)
|
||||||
|
pager.adapter = pagerAdapter
|
||||||
|
pager.registerOnPageChangeCallback(
|
||||||
|
object : ViewPager2.OnPageChangeCallback() {
|
||||||
|
override fun onPageSelected(position: Int) {
|
||||||
|
viewModel.setCurrentPage(position)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeViewModel() {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
viewModel.uiState.collect { render(it) }
|
||||||
|
}
|
||||||
|
lifecycleScope.launch {
|
||||||
|
viewModel.events.collect { event ->
|
||||||
|
when (event) {
|
||||||
|
is BoardDetailUiEvent.NavigateToCardPlaceholder -> {
|
||||||
|
Snackbar.make(pager, getString(R.string.board_detail_card_detail_coming_soon), Snackbar.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
is BoardDetailUiEvent.ShowServerError -> {
|
||||||
|
if (viewModel.uiState.value.editingListId != null) {
|
||||||
|
inlineTitleErrorMessage = event.message
|
||||||
|
render(viewModel.uiState.value)
|
||||||
|
} else {
|
||||||
|
MaterialAlertDialogBuilder(this@BoardDetailActivity)
|
||||||
|
.setMessage(event.message)
|
||||||
|
.setPositiveButton(R.string.ok, null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is BoardDetailUiEvent.ShowWarning -> {
|
||||||
|
Snackbar.make(pager, event.message, Snackbar.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun render(state: BoardDetailUiState) {
|
||||||
|
supportActionBar?.title = state.boardDetail?.title ?: intent.getStringExtra(EXTRA_BOARD_TITLE).orEmpty()
|
||||||
|
|
||||||
|
fullScreenErrorContainer.visibility = if (state.fullScreenErrorMessage != null && state.boardDetail == null) {
|
||||||
|
fullScreenErrorText.text = state.fullScreenErrorMessage
|
||||||
|
View.VISIBLE
|
||||||
|
} else {
|
||||||
|
View.GONE
|
||||||
|
}
|
||||||
|
initialProgress.visibility = if (state.isInitialLoading && state.boardDetail == null) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
|
val boardLists = state.boardDetail?.lists.orEmpty()
|
||||||
|
val applyPagerState = {
|
||||||
|
pagerAdapter.submit(
|
||||||
|
lists = boardLists,
|
||||||
|
selectedCardIds = state.selectedCardIds,
|
||||||
|
editingListId = state.editingListId,
|
||||||
|
editingListTitle = state.editingListTitle,
|
||||||
|
isMutating = state.isMutating,
|
||||||
|
inlineEditErrorMessage = inlineTitleErrorMessage,
|
||||||
|
)
|
||||||
|
pager.visibility = if (boardLists.isNotEmpty()) View.VISIBLE else View.GONE
|
||||||
|
emptyBoardText.visibility = if (!state.isInitialLoading && state.fullScreenErrorMessage == null && boardLists.isEmpty()) {
|
||||||
|
View.VISIBLE
|
||||||
|
} else {
|
||||||
|
View.GONE
|
||||||
|
}
|
||||||
|
if (boardLists.isNotEmpty() && pager.currentItem != state.currentPageIndex) {
|
||||||
|
pager.setCurrentItem(state.currentPageIndex, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val pagerRecycler = pager.getChildAt(0) as? RecyclerView
|
||||||
|
if (pagerRecycler?.isComputingLayout == true) {
|
||||||
|
pager.post {
|
||||||
|
if (!isFinishing && !isDestroyed) {
|
||||||
|
applyPagerState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
applyPagerState()
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSelectionActions(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun renderSelectionActions(state: BoardDetailUiState) {
|
||||||
|
val inSelection = state.selectedCardIds.isNotEmpty()
|
||||||
|
toolbar.menu.clear()
|
||||||
|
if (!inSelection) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toolbar.inflateMenu(R.menu.menu_board_detail_selection)
|
||||||
|
toolbar.menu.findItem(R.id.actionSelectAll)?.tooltipText = getString(R.string.select_all)
|
||||||
|
toolbar.menu.findItem(R.id.actionMoveCards)?.tooltipText = getString(R.string.move_cards)
|
||||||
|
toolbar.menu.findItem(R.id.actionDeleteCards)?.tooltipText = getString(R.string.delete_cards)
|
||||||
|
toolbar.setOnMenuItemClickListener { item ->
|
||||||
|
handleSelectionAction(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleSelectionAction(item: MenuItem): Boolean {
|
||||||
|
return when (item.itemId) {
|
||||||
|
R.id.actionSelectAll -> {
|
||||||
|
viewModel.selectAllOnCurrentPage()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
R.id.actionMoveCards -> {
|
||||||
|
showMoveCardsDialog()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
R.id.actionDeleteCards -> {
|
||||||
|
showDeleteCardsDialog()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showMoveCardsDialog() {
|
||||||
|
val lists = viewModel.uiState.value.boardDetail?.lists.orEmpty()
|
||||||
|
if (lists.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val listNames = lists.map { it.title }.toTypedArray()
|
||||||
|
var selectedIndex = viewModel.uiState.value.currentPageIndex.coerceIn(0, lists.lastIndex)
|
||||||
|
MaterialAlertDialogBuilder(this)
|
||||||
|
.setTitle(R.string.move_cards_to_list)
|
||||||
|
.setSingleChoiceItems(listNames, selectedIndex) { _, which -> selectedIndex = which }
|
||||||
|
.setNegativeButton(R.string.cancel, null)
|
||||||
|
.setPositiveButton(R.string.move_cards) { _, _ ->
|
||||||
|
val targetId = lists[selectedIndex].id
|
||||||
|
viewModel.moveSelectedCards(targetId)
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showDeleteCardsDialog() {
|
||||||
|
MaterialAlertDialogBuilder(this)
|
||||||
|
.setMessage(R.string.delete_cards_confirmation)
|
||||||
|
.setNegativeButton(R.string.cancel, null)
|
||||||
|
.setPositiveButton(R.string.delete) { _, _ ->
|
||||||
|
MaterialAlertDialogBuilder(this)
|
||||||
|
.setMessage(R.string.delete_cards_second_confirmation)
|
||||||
|
.setNegativeButton(R.string.cancel, null)
|
||||||
|
.setPositiveButton(R.string.im_sure) { _, _ ->
|
||||||
|
viewModel.deleteSelectedCards()
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
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_BOARD_ID = "extra_board_id"
|
||||||
|
const val EXTRA_BOARD_TITLE = "extra_board_title"
|
||||||
|
|
||||||
|
var testDataSourceFactory: ((String) -> BoardDetailDataSource)? = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package space.hackenslacker.kanbn4droid.app.boarddetail
|
||||||
|
|
||||||
|
import android.text.Editable
|
||||||
|
import android.text.TextWatcher
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.inputmethod.EditorInfo
|
||||||
|
import android.widget.EditText
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.google.android.material.textfield.TextInputLayout
|
||||||
|
import space.hackenslacker.kanbn4droid.app.R
|
||||||
|
|
||||||
|
class BoardListsPagerAdapter(
|
||||||
|
private val onListTitleClicked: (String) -> Unit,
|
||||||
|
private val onEditingTitleChanged: (String) -> Unit,
|
||||||
|
private val onSubmitEditingTitle: (String) -> Unit,
|
||||||
|
private val onCardClick: (BoardCardSummary) -> Unit,
|
||||||
|
private val onCardLongClick: (BoardCardSummary) -> Unit,
|
||||||
|
) : RecyclerView.Adapter<BoardListsPagerAdapter.ListPageViewHolder>() {
|
||||||
|
|
||||||
|
private var lists: List<BoardListDetail> = emptyList()
|
||||||
|
private var selectedCardIds: Set<String> = emptySet()
|
||||||
|
private var editingListId: String? = null
|
||||||
|
private var editingListTitle: String = ""
|
||||||
|
private var isMutating: Boolean = false
|
||||||
|
private var inlineEditErrorMessage: String? = null
|
||||||
|
|
||||||
|
fun submit(
|
||||||
|
lists: List<BoardListDetail>,
|
||||||
|
selectedCardIds: Set<String>,
|
||||||
|
editingListId: String?,
|
||||||
|
editingListTitle: String,
|
||||||
|
isMutating: Boolean,
|
||||||
|
inlineEditErrorMessage: String?,
|
||||||
|
) {
|
||||||
|
this.lists = lists
|
||||||
|
this.selectedCardIds = selectedCardIds
|
||||||
|
this.editingListId = editingListId
|
||||||
|
this.editingListTitle = editingListTitle
|
||||||
|
this.isMutating = isMutating
|
||||||
|
this.inlineEditErrorMessage = inlineEditErrorMessage
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListPageViewHolder {
|
||||||
|
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_board_list_page, parent, false)
|
||||||
|
return ListPageViewHolder(view, onListTitleClicked, onEditingTitleChanged, onSubmitEditingTitle, onCardClick, onCardLongClick)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemCount(): Int = lists.size
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: ListPageViewHolder, position: Int) {
|
||||||
|
val list = lists[position]
|
||||||
|
holder.bind(
|
||||||
|
list = list,
|
||||||
|
selectedCardIds = selectedCardIds,
|
||||||
|
isEditing = list.id == editingListId,
|
||||||
|
editingTitle = editingListTitle,
|
||||||
|
isMutating = isMutating,
|
||||||
|
inlineEditErrorMessage = inlineEditErrorMessage,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
class ListPageViewHolder(
|
||||||
|
itemView: View,
|
||||||
|
private val onListTitleClicked: (String) -> Unit,
|
||||||
|
private val onEditingTitleChanged: (String) -> Unit,
|
||||||
|
private val onSubmitEditingTitle: (String) -> Unit,
|
||||||
|
onCardClick: (BoardCardSummary) -> Unit,
|
||||||
|
onCardLongClick: (BoardCardSummary) -> Unit,
|
||||||
|
) : RecyclerView.ViewHolder(itemView) {
|
||||||
|
|
||||||
|
private val listTitleText: TextView = itemView.findViewById(R.id.listTitleText)
|
||||||
|
private val listTitleInputLayout: TextInputLayout = itemView.findViewById(R.id.listTitleInputLayout)
|
||||||
|
private val listTitleEditInput: EditText = itemView.findViewById(R.id.listTitleEditInput)
|
||||||
|
private val cardsRecycler: RecyclerView = itemView.findViewById(R.id.listCardsRecycler)
|
||||||
|
private val emptyText: TextView = itemView.findViewById(R.id.listEmptyText)
|
||||||
|
private val cardsAdapter = CardsAdapter(onCardClick = onCardClick, onCardLongClick = onCardLongClick)
|
||||||
|
|
||||||
|
private var isBinding = false
|
||||||
|
private var attachedListId: String? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
cardsRecycler.adapter = cardsAdapter
|
||||||
|
|
||||||
|
listTitleEditInput.addTextChangedListener(object : TextWatcher {
|
||||||
|
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
|
||||||
|
|
||||||
|
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
|
||||||
|
|
||||||
|
override fun afterTextChanged(editable: Editable?) {
|
||||||
|
if (isBinding) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onEditingTitleChanged(editable?.toString().orEmpty())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
listTitleEditInput.setOnEditorActionListener { _, actionId, event ->
|
||||||
|
val imeDone = actionId == EditorInfo.IME_ACTION_DONE
|
||||||
|
val enterKey = event?.keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_DOWN
|
||||||
|
if (imeDone || enterKey) {
|
||||||
|
onSubmitEditingTitle(listTitleEditInput.text?.toString().orEmpty())
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
listTitleEditInput.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus ->
|
||||||
|
if (isBinding || hasFocus) {
|
||||||
|
return@OnFocusChangeListener
|
||||||
|
}
|
||||||
|
if (listTitleInputLayout.visibility == View.VISIBLE) {
|
||||||
|
onSubmitEditingTitle(listTitleEditInput.text?.toString().orEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun bind(
|
||||||
|
list: BoardListDetail,
|
||||||
|
selectedCardIds: Set<String>,
|
||||||
|
isEditing: Boolean,
|
||||||
|
editingTitle: String,
|
||||||
|
isMutating: Boolean,
|
||||||
|
inlineEditErrorMessage: String?,
|
||||||
|
) {
|
||||||
|
attachedListId = list.id
|
||||||
|
listTitleText.text = list.title
|
||||||
|
listTitleText.setOnClickListener { onListTitleClicked(list.id) }
|
||||||
|
|
||||||
|
cardsAdapter.submitCards(list.cards, selectedCardIds)
|
||||||
|
val hasCards = list.cards.isNotEmpty()
|
||||||
|
cardsRecycler.visibility = if (hasCards) View.VISIBLE else View.GONE
|
||||||
|
emptyText.visibility = if (hasCards) View.GONE else View.VISIBLE
|
||||||
|
|
||||||
|
isBinding = true
|
||||||
|
if (isEditing) {
|
||||||
|
listTitleText.visibility = View.GONE
|
||||||
|
listTitleInputLayout.visibility = View.VISIBLE
|
||||||
|
listTitleEditInput.isEnabled = !isMutating
|
||||||
|
if (listTitleEditInput.text?.toString() != editingTitle) {
|
||||||
|
listTitleEditInput.setText(editingTitle)
|
||||||
|
listTitleEditInput.setSelection(editingTitle.length)
|
||||||
|
}
|
||||||
|
listTitleInputLayout.error = inlineEditErrorMessage
|
||||||
|
if (!listTitleEditInput.hasFocus()) {
|
||||||
|
listTitleEditInput.requestFocus()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
listTitleInputLayout.visibility = View.GONE
|
||||||
|
listTitleText.visibility = View.VISIBLE
|
||||||
|
listTitleInputLayout.error = null
|
||||||
|
}
|
||||||
|
isBinding = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package space.hackenslacker.kanbn4droid.app.boarddetail
|
||||||
|
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.google.android.material.card.MaterialCardView
|
||||||
|
import com.google.android.material.chip.Chip
|
||||||
|
import com.google.android.material.color.MaterialColors
|
||||||
|
import space.hackenslacker.kanbn4droid.app.R
|
||||||
|
import java.text.DateFormat as JavaDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
class CardsAdapter(
|
||||||
|
private val onCardClick: (BoardCardSummary) -> Unit,
|
||||||
|
private val onCardLongClick: (BoardCardSummary) -> Unit,
|
||||||
|
) : RecyclerView.Adapter<CardsAdapter.CardViewHolder>() {
|
||||||
|
|
||||||
|
private var cards: List<BoardCardSummary> = emptyList()
|
||||||
|
private var selectedCardIds: Set<String> = emptySet()
|
||||||
|
|
||||||
|
fun submitCards(cards: List<BoardCardSummary>, selectedCardIds: Set<String>) {
|
||||||
|
this.cards = cards
|
||||||
|
this.selectedCardIds = selectedCardIds
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CardViewHolder {
|
||||||
|
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_board_card_detail, parent, false)
|
||||||
|
return CardViewHolder(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: CardViewHolder, position: Int) {
|
||||||
|
holder.bind(cards[position], selectedCardIds.contains(cards[position].id), onCardClick, onCardLongClick)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemCount(): Int = cards.size
|
||||||
|
|
||||||
|
class CardViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||||
|
private val rootCard: MaterialCardView = itemView.findViewById(R.id.cardItemRoot)
|
||||||
|
private val titleText: TextView = itemView.findViewById(R.id.cardTitleText)
|
||||||
|
private val tagsContainer: LinearLayout = itemView.findViewById(R.id.cardTagsContainer)
|
||||||
|
private val dueDateText: TextView = itemView.findViewById(R.id.cardDueDateText)
|
||||||
|
|
||||||
|
fun bind(
|
||||||
|
card: BoardCardSummary,
|
||||||
|
isSelected: Boolean,
|
||||||
|
onCardClick: (BoardCardSummary) -> Unit,
|
||||||
|
onCardLongClick: (BoardCardSummary) -> Unit,
|
||||||
|
) {
|
||||||
|
titleText.text = card.title
|
||||||
|
bindTags(card.tags)
|
||||||
|
bindDueDate(card.dueAtEpochMillis)
|
||||||
|
|
||||||
|
rootCard.isChecked = isSelected
|
||||||
|
rootCard.strokeWidth = if (isSelected) 4 else 1
|
||||||
|
val strokeColor = if (isSelected) {
|
||||||
|
MaterialColors.getColor(rootCard, com.google.android.material.R.attr.colorPrimary, Color.BLUE)
|
||||||
|
} else {
|
||||||
|
MaterialColors.getColor(rootCard, com.google.android.material.R.attr.colorOnSurface, Color.DKGRAY)
|
||||||
|
}
|
||||||
|
rootCard.strokeColor = strokeColor
|
||||||
|
|
||||||
|
itemView.setOnClickListener { onCardClick(card) }
|
||||||
|
itemView.setOnLongClickListener {
|
||||||
|
onCardLongClick(card)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindTags(tags: List<BoardTagSummary>) {
|
||||||
|
tagsContainer.removeAllViews()
|
||||||
|
tagsContainer.visibility = if (tags.isEmpty()) View.GONE else View.VISIBLE
|
||||||
|
tags.forEach { tag ->
|
||||||
|
val chip = Chip(itemView.context)
|
||||||
|
chip.text = tag.name
|
||||||
|
chip.isClickable = false
|
||||||
|
chip.isCheckable = false
|
||||||
|
chip.chipBackgroundColor = null
|
||||||
|
chip.chipStrokeWidth = 2f
|
||||||
|
chip.chipStrokeColor = android.content.res.ColorStateList.valueOf(parseColorOrFallback(tag.colorHex))
|
||||||
|
tagsContainer.addView(chip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindDueDate(dueAtEpochMillis: Long?) {
|
||||||
|
if (dueAtEpochMillis == null) {
|
||||||
|
dueDateText.visibility = View.GONE
|
||||||
|
dueDateText.text = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val isExpired = dueAtEpochMillis < System.currentTimeMillis()
|
||||||
|
val color = if (isExpired) {
|
||||||
|
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)
|
||||||
|
val formatted = JavaDateFormat.getDateInstance(JavaDateFormat.MEDIUM, java.util.Locale.getDefault())
|
||||||
|
.format(Date(dueAtEpochMillis))
|
||||||
|
dueDateText.text = formatted
|
||||||
|
dueDateText.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseColorOrFallback(colorHex: String): Int {
|
||||||
|
return runCatching { Color.parseColor(colorHex) }
|
||||||
|
.getOrElse {
|
||||||
|
MaterialColors.getColor(itemView, com.google.android.material.R.attr.colorOnSurface, Color.DKGRAY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
71
app/src/main/res/layout/activity_board_detail.xml
Normal file
71
app/src/main/res/layout/activity_board_detail.xml
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<?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/boardDetailToolbar"
|
||||||
|
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">
|
||||||
|
|
||||||
|
<androidx.viewpager2.widget.ViewPager2
|
||||||
|
android:id="@+id/boardDetailPager"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/boardDetailEmptyBoardText"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:gravity="center"
|
||||||
|
android:padding="24dp"
|
||||||
|
android:text="@string/board_detail_empty_board"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/boardDetailInitialProgress"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/boardDetailFullScreenErrorContainer"
|
||||||
|
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/boardDetailFullScreenErrorText"
|
||||||
|
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/boardDetailRetryButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:text="@string/retry" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||||
39
app/src/main/res/layout/item_board_card_detail.xml
Normal file
39
app/src/main/res/layout/item_board_card_detail.xml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:id="@+id/cardItemRoot"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="12dp"
|
||||||
|
app:cardCornerRadius="16dp"
|
||||||
|
app:strokeWidth="1dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="14dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/cardTitleText"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/cardTagsContainer"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:orientation="horizontal" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/cardDueDateText"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||||
|
android:visibility="gone" />
|
||||||
|
</LinearLayout>
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
57
app/src/main/res/layout/item_board_list_page.xml
Normal file
57
app/src/main/res/layout/item_board_list_page.xml
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?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="match_parent"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingHorizontal="16dp"
|
||||||
|
android:paddingTop="12dp"
|
||||||
|
android:paddingBottom="16dp">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/listTitleInputLayout"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:hintEnabled="false">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/listTitleEditInput"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="@string/list_title_hint"
|
||||||
|
android:imeOptions="actionDone"
|
||||||
|
android:inputType="textCapSentences|text"
|
||||||
|
android:maxLines="1" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/listTitleText"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="2"
|
||||||
|
android:paddingVertical="8dp"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.TitleLarge"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/listCardsRecycler"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:clipToPadding="false"
|
||||||
|
android:paddingBottom="8dp"
|
||||||
|
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/listEmptyText"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:padding="12dp"
|
||||||
|
android:text="@string/board_detail_empty_list"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
|
||||||
|
android:visibility="gone" />
|
||||||
|
</LinearLayout>
|
||||||
22
app/src/main/res/menu/menu_board_detail_selection.xml
Normal file
22
app/src/main/res/menu/menu_board_detail_selection.xml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:id="@+id/actionSelectAll"
|
||||||
|
android:icon="@android:drawable/ic_menu_agenda"
|
||||||
|
android:title="@string/select_all"
|
||||||
|
app:showAsAction="always" />
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:id="@+id/actionMoveCards"
|
||||||
|
android:icon="@android:drawable/ic_menu_directions"
|
||||||
|
android:title="@string/move_cards"
|
||||||
|
app:showAsAction="always" />
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:id="@+id/actionDeleteCards"
|
||||||
|
android:icon="@android:drawable/ic_menu_delete"
|
||||||
|
android:title="@string/delete_cards"
|
||||||
|
app:showAsAction="always" />
|
||||||
|
</menu>
|
||||||
@@ -32,4 +32,16 @@
|
|||||||
<string name="network_unreachable">Cannot reach server. Check your connection and URL.</string>
|
<string name="network_unreachable">Cannot reach server. Check your connection and URL.</string>
|
||||||
<string name="auth_failed">Authentication failed. Check your API key.</string>
|
<string name="auth_failed">Authentication failed. Check your API key.</string>
|
||||||
<string name="unexpected_error">Unexpected error. Please try again.</string>
|
<string name="unexpected_error">Unexpected error. Please try again.</string>
|
||||||
|
<string name="board_detail_empty_board">No lists yet.</string>
|
||||||
|
<string name="board_detail_empty_list">No cards in this list.</string>
|
||||||
|
<string name="retry">Retry</string>
|
||||||
|
<string name="list_title_hint">List title</string>
|
||||||
|
<string name="list_title_required">List title is required</string>
|
||||||
|
<string name="select_all">Select all</string>
|
||||||
|
<string name="move_cards">Move cards</string>
|
||||||
|
<string name="delete_cards">Delete cards</string>
|
||||||
|
<string name="move_cards_to_list">Move cards to list</string>
|
||||||
|
<string name="delete_cards_confirmation">Delete selected cards?</string>
|
||||||
|
<string name="delete_cards_second_confirmation">Are you sure you want to permanently delete the selected cards?</string>
|
||||||
|
<string name="board_detail_card_detail_coming_soon">Card detail view is coming soon.</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
Reference in New Issue
Block a user