diff --git a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardDetailFlowTest.kt b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardDetailFlowTest.kt index 8c4d1b1..8b06a6b 100644 --- a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardDetailFlowTest.kt +++ b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardDetailFlowTest.kt @@ -5,6 +5,7 @@ import android.graphics.Color import android.view.inputmethod.EditorInfo import android.view.View import android.widget.TextView +import androidx.appcompat.content.res.AppCompatResources import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ApplicationProvider import androidx.test.espresso.Espresso.onView @@ -17,7 +18,6 @@ import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtra import androidx.test.espresso.matcher.RootMatchers.isDialog import androidx.test.espresso.matcher.ViewMatchers.withContentDescription -import androidx.test.espresso.matcher.ViewMatchers.hasDescendant import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.isEnabled import androidx.test.espresso.matcher.ViewMatchers.withId @@ -38,6 +38,7 @@ import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -48,6 +49,8 @@ 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.auth.ApiKeyStore +import space.hackenslacker.kanbn4droid.app.auth.SessionStore import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult @RunWith(AndroidJUnit4::class) @@ -59,6 +62,9 @@ class BoardDetailFlowTest { @Before fun setUp() { + MainActivity.dependencies.clear() + MainActivity.dependencies.sessionStoreFactory = { InMemorySessionStore("https://kan.bn/") } + MainActivity.dependencies.apiKeyStoreFactory = { InMemoryApiKeyStore("api") } originalLocale = Locale.getDefault() Intents.init() defaultDataSource = FakeBoardDetailDataSource(initialDetail = detailOneList()) @@ -71,6 +77,7 @@ class BoardDetailFlowTest { Intents.release() BoardDetailActivity.testDataSourceFactory = null BoardDetailActivity.testUiStateObserver = null + MainActivity.dependencies.clear() originalLocale?.let { Locale.setDefault(it) } } @@ -256,6 +263,59 @@ class BoardDetailFlowTest { } } + @Test + fun selectionActionIconsMatchExpectedResources() { + val scenario = launchBoardDetail() + + onView(withText("Card 1")).perform(longClick()) + + scenario.onActivity { activity -> + val menu = activity.findViewById(R.id.boardDetailToolbar).menu + assertNotNull(menu.findItem(R.id.actionSelectAll)?.icon) + assertNotNull(menu.findItem(R.id.actionMoveCards)?.icon) + assertNotNull(menu.findItem(R.id.actionDeleteCards)?.icon) + + assertEquals( + AppCompatResources.getDrawable(activity, R.drawable.ic_select_all_grid_24)?.constantState, + menu.findItem(R.id.actionSelectAll)?.icon?.constantState, + ) + assertEquals( + AppCompatResources.getDrawable(activity, R.drawable.ic_move_cards_horizontal_24)?.constantState, + menu.findItem(R.id.actionMoveCards)?.icon?.constantState, + ) + assertEquals( + AppCompatResources.getDrawable(activity, R.drawable.ic_delete_24)?.constantState, + menu.findItem(R.id.actionDeleteCards)?.icon?.constantState, + ) + } + } + + @Test + fun missingBoardIdShowsBlockingDialogAndFinishes() { + val scenario = launchBoardDetail(boardId = null) + + onView(withText(R.string.board_detail_unable_to_open_board)).inRoot(isDialog()).check(matches(isDisplayed())) + onView(withText(R.string.ok)).inRoot(isDialog()).perform(click()) + scenario.onActivity { activity -> + assertTrue(activity.isFinishing) + } + } + + @Test + fun missingSessionShowsBlockingDialogAndFinishes() { + BoardDetailActivity.testDataSourceFactory = null + MainActivity.dependencies.sessionStoreFactory = { InMemorySessionStore(null) } + MainActivity.dependencies.apiKeyStoreFactory = { InMemoryApiKeyStore(null) } + + val scenario = launchBoardDetail() + + onView(withText(R.string.board_detail_session_expired)).inRoot(isDialog()).check(matches(isDisplayed())) + onView(withText(R.string.ok)).inRoot(isDialog()).perform(click()) + scenario.onActivity { activity -> + assertTrue(activity.isFinishing) + } + } + @Test fun moveDialogShowsListSelector() { defaultDataSource.currentDetail = detailTwoLists() @@ -341,6 +401,7 @@ class BoardDetailFlowTest { defaultDataSource.moveCalls == 1 && observedStates.lastOrNull()?.isMutating == false } + onView(withText("Some cards could not be moved. Please try again.")).check(matches(isDisplayed())) val last = observedStates.last() assertEquals(setOf("card-2"), last.selectedCardIds) } @@ -360,6 +421,7 @@ class BoardDetailFlowTest { defaultDataSource.moveCalls == 1 && observedStates.lastOrNull()?.isMutating == false } + onView(withText("Move failed")).check(matches(isDisplayed())) val last = observedStates.last() assertEquals(setOf("card-1"), last.selectedCardIds) } @@ -412,6 +474,7 @@ class BoardDetailFlowTest { defaultDataSource.deleteCalls == 1 && observedStates.lastOrNull()?.isMutating == false } + onView(withText("Some cards could not be deleted. Please try again.")).check(matches(isDisplayed())) val last = observedStates.last() assertEquals(setOf("card-2"), last.selectedCardIds) } @@ -430,6 +493,7 @@ class BoardDetailFlowTest { defaultDataSource.deleteCalls == 1 && observedStates.lastOrNull()?.isMutating == false } + onView(withText("Delete failed")).check(matches(isDisplayed())) val last = observedStates.last() assertEquals(setOf("card-1"), last.selectedCardIds) } @@ -480,12 +544,15 @@ class BoardDetailFlowTest { Intents.intended(hasExtra(CardDetailPlaceholderActivity.EXTRA_CARD_TITLE, expectedFallback)) } - private fun launchBoardDetail(): ActivityScenario { + private fun launchBoardDetail(boardId: String? = "board-1"): 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") + ) + if (boardId != null) { + intent.putExtra(BoardDetailActivity.EXTRA_BOARD_ID, boardId) + } + intent.putExtra(BoardDetailActivity.EXTRA_BOARD_TITLE, "Board") return ActivityScenario.launch(intent) } @@ -611,6 +678,44 @@ class BoardDetailFlowTest { } } + private class InMemorySessionStore( + private var baseUrl: String?, + ) : SessionStore { + override fun getBaseUrl(): String? = baseUrl + + override fun saveBaseUrl(url: String) { + baseUrl = url + } + + override fun clearBaseUrl() { + baseUrl = null + } + + override fun getWorkspaceId(): String? = "ws-1" + + override fun saveWorkspaceId(workspaceId: String) { + } + + override fun clearWorkspaceId() { + } + } + + private class InMemoryApiKeyStore( + private var key: String?, + ) : ApiKeyStore { + override suspend fun saveApiKey(baseUrl: String, apiKey: String): Result { + key = apiKey + return Result.success(Unit) + } + + override suspend fun getApiKey(baseUrl: String): Result = Result.success(key) + + override suspend fun invalidateApiKey(baseUrl: String): Result { + key = null + return Result.success(Unit) + } + } + private companion object { fun detailOneList(): BoardDetail { return BoardDetail( 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 index 305315c..cb29cb3 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailActivity.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailActivity.kt @@ -48,6 +48,7 @@ class BoardDetailActivity : AppCompatActivity() { private var deleteSecondConfirmationDialog: AlertDialog? = null private var dismissMoveDialogWhenMutationEnds: Boolean = false private var dismissDeleteDialogWhenMutationEnds: Boolean = false + private var hasShownBlockingStartupError: Boolean = false private lateinit var pagerAdapter: BoardListsPagerAdapter @@ -90,6 +91,11 @@ class BoardDetailActivity : AppCompatActivity() { setupPager() observeViewModel() + if (boardId.isBlank()) { + showBlockingStartupErrorAndFinish(getString(R.string.board_detail_unable_to_open_board)) + return + } + viewModel.loadBoardDetail() } @@ -205,6 +211,15 @@ class BoardDetailActivity : AppCompatActivity() { testUiStateObserver?.invoke(state) supportActionBar?.title = state.boardDetail?.title ?: intent.getStringExtra(EXTRA_BOARD_TITLE).orEmpty() + if ( + !hasShownBlockingStartupError && + state.boardDetail == null && + state.fullScreenErrorMessage == BoardDetailRepository.MISSING_SESSION_MESSAGE + ) { + showBlockingStartupErrorAndFinish(getString(R.string.board_detail_session_expired)) + return + } + fullScreenErrorContainer.visibility = if (state.fullScreenErrorMessage != null && state.boardDetail == null) { fullScreenErrorText.text = state.fullScreenErrorMessage View.VISIBLE @@ -248,6 +263,17 @@ class BoardDetailActivity : AppCompatActivity() { renderOpenDialogs(state) } + private fun showBlockingStartupErrorAndFinish(message: String) { + hasShownBlockingStartupError = true + MaterialAlertDialogBuilder(this) + .setMessage(message) + .setCancelable(false) + .setPositiveButton(R.string.ok) { _, _ -> + finish() + } + .show() + } + private fun renderOpenDialogs(state: BoardDetailUiState) { val activeMoveDialog = moveDialog if (activeMoveDialog != null) { @@ -350,7 +376,8 @@ class BoardDetailActivity : AppCompatActivity() { .create() dialog.setOnShowListener { dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener { - val targetId = lists[selectedIndex].id + val currentLists = viewModel.uiState.value.boardDetail?.lists.orEmpty() + val targetId = currentLists.getOrNull(selectedIndex)?.id ?: return@setOnClickListener dismissMoveDialogWhenMutationEnds = true viewModel.moveSelectedCards(targetId) } diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailRepository.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailRepository.kt index 0e9dc6e..21ee759 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailRepository.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailRepository.kt @@ -14,6 +14,10 @@ class BoardDetailRepository( private val apiClient: KanbnApiClient, private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, ) { + companion object { + const val MISSING_SESSION_MESSAGE = "Missing session. Please sign in again." + } + suspend fun getBoardDetail(boardId: String): BoardsApiResult { val normalizedBoardId = boardId.trim() if (normalizedBoardId.isBlank()) { @@ -154,11 +158,11 @@ class BoardDetailRepository( private suspend fun session(): BoardsApiResult { val baseUrl = sessionStore.getBaseUrl()?.takeIf { it.isNotBlank() } - ?: return BoardsApiResult.Failure("Missing session. Please sign in again.") + ?: return BoardsApiResult.Failure(MISSING_SESSION_MESSAGE) val apiKey = withContext(ioDispatcher) { apiKeyStore.getApiKey(baseUrl) }.getOrNull()?.takeIf { it.isNotBlank() } - ?: return BoardsApiResult.Failure("Missing session. Please sign in again.") + ?: return BoardsApiResult.Failure(MISSING_SESSION_MESSAGE) val workspaceId = when (val workspaceResult = resolveWorkspaceId(baseUrl = baseUrl, apiKey = apiKey)) { is BoardsApiResult.Success -> workspaceResult.value diff --git a/app/src/main/res/drawable/ic_delete_24.xml b/app/src/main/res/drawable/ic_delete_24.xml new file mode 100644 index 0000000..0118475 --- /dev/null +++ b/app/src/main/res/drawable/ic_delete_24.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_move_cards_horizontal_24.xml b/app/src/main/res/drawable/ic_move_cards_horizontal_24.xml new file mode 100644 index 0000000..5cddffd --- /dev/null +++ b/app/src/main/res/drawable/ic_move_cards_horizontal_24.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_select_all_grid_24.xml b/app/src/main/res/drawable/ic_select_all_grid_24.xml new file mode 100644 index 0000000..debd459 --- /dev/null +++ b/app/src/main/res/drawable/ic_select_all_grid_24.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/app/src/main/res/menu/menu_board_detail_selection.xml b/app/src/main/res/menu/menu_board_detail_selection.xml index 09f4048..be75a73 100644 --- a/app/src/main/res/menu/menu_board_detail_selection.xml +++ b/app/src/main/res/menu/menu_board_detail_selection.xml @@ -4,19 +4,19 @@ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4451843..2b74c26 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -48,4 +48,6 @@ %1$s\n(id: %2$s) Card Card detail view is coming soon. + Unable to open board. + Session expired. Please sign in again.