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

@@ -14,6 +14,9 @@
<activity
android:name=".BoardDetailPlaceholderActivity"
android:exported="false" />
<activity
android:name=".boarddetail.BoardDetailActivity"
android:exported="false" />
<activity
android:name=".BoardsActivity"
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="auth_failed">Authentication failed. Check your API key.</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>