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
|
||||
android:name=".BoardDetailPlaceholderActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".boarddetail.BoardDetailActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".BoardsActivity"
|
||||
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="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>
|
||||
|
||||
Reference in New Issue
Block a user