feat: implement board detail pager UI and card rendering

This commit is contained in:
2026-03-16 01:19:15 -04:00
parent 4455f0ecd3
commit 5f5a273d7f
10 changed files with 1155 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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