From 5f5a273d7f1eeed0d7f0f6bb27e5adeaaf842db8 Mon Sep 17 00:00:00 2001 From: Wally Hackenslacker Date: Mon, 16 Mar 2026 01:19:15 -0400 Subject: [PATCH] feat: implement board detail pager UI and card rendering --- .../kanbn4droid/app/BoardDetailFlowTest.kt | 357 ++++++++++++++++++ app/src/main/AndroidManifest.xml | 3 + .../app/boarddetail/BoardDetailActivity.kt | 317 ++++++++++++++++ .../app/boarddetail/BoardListsPagerAdapter.kt | 161 ++++++++ .../app/boarddetail/CardsAdapter.kt | 116 ++++++ .../main/res/layout/activity_board_detail.xml | 71 ++++ .../res/layout/item_board_card_detail.xml | 39 ++ .../main/res/layout/item_board_list_page.xml | 57 +++ .../res/menu/menu_board_detail_selection.xml | 22 ++ app/src/main/res/values/strings.xml | 12 + 10 files changed, 1155 insertions(+) create mode 100644 app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardDetailFlowTest.kt create mode 100644 app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailActivity.kt create mode 100644 app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardListsPagerAdapter.kt create mode 100644 app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/CardsAdapter.kt create mode 100644 app/src/main/res/layout/activity_board_detail.xml create mode 100644 app/src/main/res/layout/item_board_card_detail.xml create mode 100644 app/src/main/res/layout/item_board_list_page.xml create mode 100644 app/src/main/res/menu/menu_board_detail_selection.xml diff --git a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardDetailFlowTest.kt b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardDetailFlowTest.kt new file mode 100644 index 0000000..ef032bb --- /dev/null +++ b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardDetailFlowTest.kt @@ -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() + 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(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(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(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(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(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(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 { + 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 { + return object : TypeSafeMatcher() { + override fun describeTo(description: Description) { + description.appendText("with text color: $expectedColor") + } + + override fun matchesSafely(item: View): Boolean { + if (item !is TextView) { + return false + } + return item.currentTextColor == expectedColor + } + } + } + + private class FakeBoardDetailDataSource( + initialDetail: BoardDetail, + ) : BoardDetailDataSource { + var currentDetail: BoardDetail = initialDetail + val loadResults: ArrayDeque> = ArrayDeque() + var loadGate: CompletableDeferred? = null + var renameResult: BoardsApiResult = BoardsApiResult.Success(Unit) + var renameGate: CompletableDeferred? = null + var renameCalls: Int = 0 + var lastRenameTitle: String? = null + + override suspend fun getBoardDetail(boardId: String): BoardsApiResult { + loadGate?.await() + return if (loadResults.isNotEmpty()) { + loadResults.removeFirst() + } else { + BoardsApiResult.Success(currentDetail) + } + } + + override suspend fun moveCards(cardIds: Collection, targetListId: String): CardBatchMutationResult { + return CardBatchMutationResult.Success + } + + override suspend fun deleteCards(cardIds: Collection): CardBatchMutationResult { + return CardBatchMutationResult.Success + } + + override suspend fun renameList(listId: String, newTitle: String): BoardsApiResult { + 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, + ), + ), + ), + ), + ) + } + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 110764f..14acd5f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -14,6 +14,9 @@ + diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailActivity.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailActivity.kt new file mode 100644 index 0000000..452e6a9 --- /dev/null +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailActivity.kt @@ -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 create(modelClass: Class): 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 + } +} diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardListsPagerAdapter.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardListsPagerAdapter.kt new file mode 100644 index 0000000..be4013b --- /dev/null +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardListsPagerAdapter.kt @@ -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() { + + private var lists: List = emptyList() + private var selectedCardIds: Set = emptySet() + private var editingListId: String? = null + private var editingListTitle: String = "" + private var isMutating: Boolean = false + private var inlineEditErrorMessage: String? = null + + fun submit( + lists: List, + selectedCardIds: Set, + 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, + 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 + } + } +} diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/CardsAdapter.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/CardsAdapter.kt new file mode 100644 index 0000000..18823c7 --- /dev/null +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/CardsAdapter.kt @@ -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() { + + private var cards: List = emptyList() + private var selectedCardIds: Set = emptySet() + + fun submitCards(cards: List, selectedCardIds: Set) { + 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) { + 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) + } + } + } +} diff --git a/app/src/main/res/layout/activity_board_detail.xml b/app/src/main/res/layout/activity_board_detail.xml new file mode 100644 index 0000000..66ed963 --- /dev/null +++ b/app/src/main/res/layout/activity_board_detail.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_board_card_detail.xml b/app/src/main/res/layout/item_board_card_detail.xml new file mode 100644 index 0000000..83eea6c --- /dev/null +++ b/app/src/main/res/layout/item_board_card_detail.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_board_list_page.xml b/app/src/main/res/layout/item_board_list_page.xml new file mode 100644 index 0000000..a51b697 --- /dev/null +++ b/app/src/main/res/layout/item_board_list_page.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/menu_board_detail_selection.xml b/app/src/main/res/menu/menu_board_detail_selection.xml new file mode 100644 index 0000000..09f4048 --- /dev/null +++ b/app/src/main/res/menu/menu_board_detail_selection.xml @@ -0,0 +1,22 @@ + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c9c8472..1c0679b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -32,4 +32,16 @@ Cannot reach server. Check your connection and URL. Authentication failed. Check your API key. Unexpected error. Please try again. + No lists yet. + No cards in this list. + Retry + List title + List title is required + Select all + Move cards + Delete cards + Move cards to list + Delete selected cards? + Are you sure you want to permanently delete the selected cards? + Card detail view is coming soon.