fix: finalize board detail action icons and startup guards

This commit is contained in:
2026-03-16 03:15:31 -04:00
parent e72e584fd4
commit 81cd654611
8 changed files with 184 additions and 10 deletions

View File

@@ -5,6 +5,7 @@ import android.graphics.Color
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import android.view.View import android.view.View
import android.widget.TextView import android.widget.TextView
import androidx.appcompat.content.res.AppCompatResources
import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView 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.intent.matcher.IntentMatchers.hasExtra
import androidx.test.espresso.matcher.RootMatchers.isDialog import androidx.test.espresso.matcher.RootMatchers.isDialog
import androidx.test.espresso.matcher.ViewMatchers.withContentDescription 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.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.isEnabled import androidx.test.espresso.matcher.ViewMatchers.isEnabled
import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withId
@@ -38,6 +38,7 @@ import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith 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.BoardListDetail
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardTagSummary import space.hackenslacker.kanbn4droid.app.boarddetail.BoardTagSummary
import space.hackenslacker.kanbn4droid.app.boarddetail.CardBatchMutationResult 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 import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@@ -59,6 +62,9 @@ class BoardDetailFlowTest {
@Before @Before
fun setUp() { fun setUp() {
MainActivity.dependencies.clear()
MainActivity.dependencies.sessionStoreFactory = { InMemorySessionStore("https://kan.bn/") }
MainActivity.dependencies.apiKeyStoreFactory = { InMemoryApiKeyStore("api") }
originalLocale = Locale.getDefault() originalLocale = Locale.getDefault()
Intents.init() Intents.init()
defaultDataSource = FakeBoardDetailDataSource(initialDetail = detailOneList()) defaultDataSource = FakeBoardDetailDataSource(initialDetail = detailOneList())
@@ -71,6 +77,7 @@ class BoardDetailFlowTest {
Intents.release() Intents.release()
BoardDetailActivity.testDataSourceFactory = null BoardDetailActivity.testDataSourceFactory = null
BoardDetailActivity.testUiStateObserver = null BoardDetailActivity.testUiStateObserver = null
MainActivity.dependencies.clear()
originalLocale?.let { Locale.setDefault(it) } 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<com.google.android.material.appbar.MaterialToolbar>(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 @Test
fun moveDialogShowsListSelector() { fun moveDialogShowsListSelector() {
defaultDataSource.currentDetail = detailTwoLists() defaultDataSource.currentDetail = detailTwoLists()
@@ -341,6 +401,7 @@ class BoardDetailFlowTest {
defaultDataSource.moveCalls == 1 && observedStates.lastOrNull()?.isMutating == false 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() val last = observedStates.last()
assertEquals(setOf("card-2"), last.selectedCardIds) assertEquals(setOf("card-2"), last.selectedCardIds)
} }
@@ -360,6 +421,7 @@ class BoardDetailFlowTest {
defaultDataSource.moveCalls == 1 && observedStates.lastOrNull()?.isMutating == false defaultDataSource.moveCalls == 1 && observedStates.lastOrNull()?.isMutating == false
} }
onView(withText("Move failed")).check(matches(isDisplayed()))
val last = observedStates.last() val last = observedStates.last()
assertEquals(setOf("card-1"), last.selectedCardIds) assertEquals(setOf("card-1"), last.selectedCardIds)
} }
@@ -412,6 +474,7 @@ class BoardDetailFlowTest {
defaultDataSource.deleteCalls == 1 && observedStates.lastOrNull()?.isMutating == false 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() val last = observedStates.last()
assertEquals(setOf("card-2"), last.selectedCardIds) assertEquals(setOf("card-2"), last.selectedCardIds)
} }
@@ -430,6 +493,7 @@ class BoardDetailFlowTest {
defaultDataSource.deleteCalls == 1 && observedStates.lastOrNull()?.isMutating == false defaultDataSource.deleteCalls == 1 && observedStates.lastOrNull()?.isMutating == false
} }
onView(withText("Delete failed")).check(matches(isDisplayed()))
val last = observedStates.last() val last = observedStates.last()
assertEquals(setOf("card-1"), last.selectedCardIds) assertEquals(setOf("card-1"), last.selectedCardIds)
} }
@@ -480,12 +544,15 @@ class BoardDetailFlowTest {
Intents.intended(hasExtra(CardDetailPlaceholderActivity.EXTRA_CARD_TITLE, expectedFallback)) Intents.intended(hasExtra(CardDetailPlaceholderActivity.EXTRA_CARD_TITLE, expectedFallback))
} }
private fun launchBoardDetail(): ActivityScenario<BoardDetailActivity> { private fun launchBoardDetail(boardId: String? = "board-1"): ActivityScenario<BoardDetailActivity> {
val intent = Intent( val intent = Intent(
androidx.test.core.app.ApplicationProvider.getApplicationContext(), androidx.test.core.app.ApplicationProvider.getApplicationContext(),
BoardDetailActivity::class.java, 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) 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<Unit> {
key = apiKey
return Result.success(Unit)
}
override suspend fun getApiKey(baseUrl: String): Result<String?> = Result.success(key)
override suspend fun invalidateApiKey(baseUrl: String): Result<Unit> {
key = null
return Result.success(Unit)
}
}
private companion object { private companion object {
fun detailOneList(): BoardDetail { fun detailOneList(): BoardDetail {
return BoardDetail( return BoardDetail(

View File

@@ -48,6 +48,7 @@ class BoardDetailActivity : AppCompatActivity() {
private var deleteSecondConfirmationDialog: AlertDialog? = null private var deleteSecondConfirmationDialog: AlertDialog? = null
private var dismissMoveDialogWhenMutationEnds: Boolean = false private var dismissMoveDialogWhenMutationEnds: Boolean = false
private var dismissDeleteDialogWhenMutationEnds: Boolean = false private var dismissDeleteDialogWhenMutationEnds: Boolean = false
private var hasShownBlockingStartupError: Boolean = false
private lateinit var pagerAdapter: BoardListsPagerAdapter private lateinit var pagerAdapter: BoardListsPagerAdapter
@@ -90,6 +91,11 @@ class BoardDetailActivity : AppCompatActivity() {
setupPager() setupPager()
observeViewModel() observeViewModel()
if (boardId.isBlank()) {
showBlockingStartupErrorAndFinish(getString(R.string.board_detail_unable_to_open_board))
return
}
viewModel.loadBoardDetail() viewModel.loadBoardDetail()
} }
@@ -205,6 +211,15 @@ class BoardDetailActivity : AppCompatActivity() {
testUiStateObserver?.invoke(state) testUiStateObserver?.invoke(state)
supportActionBar?.title = state.boardDetail?.title ?: intent.getStringExtra(EXTRA_BOARD_TITLE).orEmpty() 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) { fullScreenErrorContainer.visibility = if (state.fullScreenErrorMessage != null && state.boardDetail == null) {
fullScreenErrorText.text = state.fullScreenErrorMessage fullScreenErrorText.text = state.fullScreenErrorMessage
View.VISIBLE View.VISIBLE
@@ -248,6 +263,17 @@ class BoardDetailActivity : AppCompatActivity() {
renderOpenDialogs(state) 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) { private fun renderOpenDialogs(state: BoardDetailUiState) {
val activeMoveDialog = moveDialog val activeMoveDialog = moveDialog
if (activeMoveDialog != null) { if (activeMoveDialog != null) {
@@ -350,7 +376,8 @@ class BoardDetailActivity : AppCompatActivity() {
.create() .create()
dialog.setOnShowListener { dialog.setOnShowListener {
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener { 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 dismissMoveDialogWhenMutationEnds = true
viewModel.moveSelectedCards(targetId) viewModel.moveSelectedCards(targetId)
} }

View File

@@ -14,6 +14,10 @@ class BoardDetailRepository(
private val apiClient: KanbnApiClient, private val apiClient: KanbnApiClient,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, 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<BoardDetail> { suspend fun getBoardDetail(boardId: String): BoardsApiResult<BoardDetail> {
val normalizedBoardId = boardId.trim() val normalizedBoardId = boardId.trim()
if (normalizedBoardId.isBlank()) { if (normalizedBoardId.isBlank()) {
@@ -154,11 +158,11 @@ class BoardDetailRepository(
private suspend fun session(): BoardsApiResult<SessionSnapshot> { private suspend fun session(): BoardsApiResult<SessionSnapshot> {
val baseUrl = sessionStore.getBaseUrl()?.takeIf { it.isNotBlank() } 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) { val apiKey = withContext(ioDispatcher) {
apiKeyStore.getApiKey(baseUrl) apiKeyStore.getApiKey(baseUrl)
}.getOrNull()?.takeIf { it.isNotBlank() } }.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)) { val workspaceId = when (val workspaceResult = resolveWorkspaceId(baseUrl = baseUrl, apiKey = apiKey)) {
is BoardsApiResult.Success -> workspaceResult.value is BoardsApiResult.Success -> workspaceResult.value

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M9,3h6l1,1h4v2h-16v-2h4zM6,8h12l-1,12h-10zM9,10v8h2v-8zM13,10v8h2v-8z" />
</vector>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M7.5,6l-4.5,6l4.5,6v-4h9v4l4.5,-6l-4.5,-6v4h-9z" />
</vector>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M3,3h4v4h-4zM8.5,3h4v4h-4zM14,3h4v4h-4zM19.5,3h1.5v4h-1.5zM3,8.5h4v4h-4zM8.5,8.5h4v4h-4zM14,8.5h4v4h-4zM19.5,8.5h1.5v4h-1.5zM3,14h4v4h-4zM8.5,14h4v4h-4zM14,14h4v4h-4zM19.5,14h1.5v4h-1.5zM3,19.5h4v1.5h-4zM8.5,19.5h4v1.5h-4zM14,19.5h4v1.5h-4zM19.5,19.5h1.5v1.5h-1.5z" />
</vector>

View File

@@ -4,19 +4,19 @@
<item <item
android:id="@+id/actionSelectAll" android:id="@+id/actionSelectAll"
android:icon="@android:drawable/ic_menu_agenda" android:icon="@drawable/ic_select_all_grid_24"
android:title="@string/select_all" android:title="@string/select_all"
app:showAsAction="always" /> app:showAsAction="always" />
<item <item
android:id="@+id/actionMoveCards" android:id="@+id/actionMoveCards"
android:icon="@android:drawable/ic_menu_directions" android:icon="@drawable/ic_move_cards_horizontal_24"
android:title="@string/move_cards" android:title="@string/move_cards"
app:showAsAction="always" /> app:showAsAction="always" />
<item <item
android:id="@+id/actionDeleteCards" android:id="@+id/actionDeleteCards"
android:icon="@android:drawable/ic_menu_delete" android:icon="@drawable/ic_delete_24"
android:title="@string/delete_cards" android:title="@string/delete_cards"
app:showAsAction="always" /> app:showAsAction="always" />
</menu> </menu>

View File

@@ -48,4 +48,6 @@
<string name="card_detail_placeholder_title">%1$s\n(id: %2$s)</string> <string name="card_detail_placeholder_title">%1$s\n(id: %2$s)</string>
<string name="card_detail_placeholder_fallback_title">Card</string> <string name="card_detail_placeholder_fallback_title">Card</string>
<string name="card_detail_placeholder_subtitle">Card detail view is coming soon.</string> <string name="card_detail_placeholder_subtitle">Card detail view is coming soon.</string>
<string name="board_detail_unable_to_open_board">Unable to open board.</string>
<string name="board_detail_session_expired">Session expired. Please sign in again.</string>
</resources> </resources>