feat: add drawer logout confirmation and session clear flow

This commit is contained in:
2026-03-18 13:38:19 -04:00
parent 769b893959
commit 542ec5c181
6 changed files with 135 additions and 1 deletions

View File

@@ -298,6 +298,64 @@ class BoardsFlowTest {
}
}
@Test
fun logoutConfirmationClearsSessionAndReturnsToLogin() {
val sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-1")
val apiKeyStore = InMemoryApiKeyStore("api")
MainActivity.dependencies.sessionStoreFactory = { sessionStore }
MainActivity.dependencies.apiKeyStoreFactory = { apiKeyStore }
MainActivity.dependencies.apiClientFactory = {
FakeBoardsApiClient(
boards = mutableListOf(BoardSummary("1", "Alpha")),
templates = emptyList(),
)
}
val scenario = ActivityScenario.launch(BoardsActivity::class.java)
onView(withId(R.id.boardsDrawerLayout)).perform(DrawerActions.open())
onView(withId(R.id.drawerLogoutButton)).perform(click())
onView(withText(R.string.drawer_logout)).inRoot(isDialog()).perform(click())
Intents.intended(hasComponent(MainActivity::class.java.name))
assertEquals(null, sessionStore.getBaseUrl())
assertEquals(null, sessionStore.getWorkspaceId())
assertEquals(null, sessionStore.getDraftBaseUrl())
assertEquals(null, sessionStore.getDraftApiKey())
assertEquals(listOf("https://kan.bn/"), apiKeyStore.invalidatedBaseUrls)
scenario.onActivity { activity ->
assertTrue(activity.isFinishing)
}
}
@Test
fun logoutConfirmationCancelKeepsUserOnBoards() {
val sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-1")
val apiKeyStore = InMemoryApiKeyStore("api")
MainActivity.dependencies.sessionStoreFactory = { sessionStore }
MainActivity.dependencies.apiKeyStoreFactory = { apiKeyStore }
MainActivity.dependencies.apiClientFactory = {
FakeBoardsApiClient(
boards = mutableListOf(BoardSummary("1", "Alpha")),
templates = emptyList(),
)
}
val scenario = ActivityScenario.launch(BoardsActivity::class.java)
onView(withId(R.id.boardsDrawerLayout)).perform(DrawerActions.open())
onView(withId(R.id.drawerLogoutButton)).perform(click())
onView(withText(R.string.cancel)).inRoot(isDialog()).perform(click())
onView(withText("Alpha")).check(matches(isDisplayed()))
assertEquals("https://kan.bn/", sessionStore.getBaseUrl())
assertEquals("ws-1", sessionStore.getWorkspaceId())
assertTrue(apiKeyStore.invalidatedBaseUrls.isEmpty())
scenario.onActivity { activity ->
assertFalse(activity.isFinishing)
}
}
@Test
fun settingsDialogOpensFromDrawerAndShowsPreferences() {
MainActivity.dependencies.apiClientFactory = {
@@ -536,6 +594,9 @@ class BoardsFlowTest {
private var baseUrl: String? = null,
private var workspaceId: String? = "ws-1",
) : SessionStore {
private var draftBaseUrl: String? = baseUrl
private var draftApiKey: String? = null
override fun getBaseUrl(): String? = baseUrl
override fun saveBaseUrl(url: String) {
@@ -555,11 +616,29 @@ class BoardsFlowTest {
override fun clearWorkspaceId() {
workspaceId = null
}
override fun initializeDraftsFromCommitted() {
draftBaseUrl = baseUrl
}
override fun getDraftBaseUrl(): String? = draftBaseUrl
override fun saveDraftBaseUrl(url: String) {
draftBaseUrl = url
}
override fun getDraftApiKey(): String? = draftApiKey
override fun saveDraftApiKey(apiKey: String) {
draftApiKey = apiKey
}
}
private class InMemoryApiKeyStore(
private var key: String?,
) : ApiKeyStore {
val invalidatedBaseUrls = mutableListOf<String>()
override suspend fun saveApiKey(baseUrl: String, apiKey: String): Result<Unit> {
key = apiKey
return Result.success(Unit)
@@ -568,6 +647,7 @@ class BoardsFlowTest {
override suspend fun getApiKey(baseUrl: String): Result<String?> = Result.success(key)
override suspend fun invalidateApiKey(baseUrl: String): Result<Unit> {
invalidatedBaseUrls += baseUrl
key = null
return Result.success(Unit)
}

View File

@@ -178,7 +178,7 @@ class BoardsActivity : AppCompatActivity() {
}
drawerLogoutButton.setOnClickListener {
forceSignOutToLogin()
showLogoutConfirmation()
}
drawerLayout.addDrawerListener(
@@ -300,7 +300,12 @@ class BoardsActivity : AppCompatActivity() {
apiKeyStore.invalidateApiKey(baseUrl)
}
}
sessionStore.clearBaseUrl()
sessionStore.clearApiKey()
sessionStore.clearWorkspaceId()
sessionStore.saveDraftBaseUrl("")
sessionStore.saveDraftApiKey("")
viewModel.clearSessionUiState()
val intent = Intent(this@BoardsActivity, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
startActivity(intent)
@@ -308,6 +313,16 @@ class BoardsActivity : AppCompatActivity() {
}
}
private fun showLogoutConfirmation() {
MaterialAlertDialogBuilder(this)
.setMessage(getString(R.string.drawer_logout_confirmation_message))
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.drawer_logout) { _, _ ->
forceSignOutToLogin()
}
.show()
}
private fun showCreateBoardDialog() {
val dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_create_board, null)
val nameLayout: TextInputLayout = dialogView.findViewById(R.id.createBoardNameLayout)

View File

@@ -13,6 +13,10 @@ class BoardsRepository(
private val apiClient: KanbnApiClient,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
fun clearSessionCaches() {
// No-op: this repository currently keeps no in-memory session cache.
}
suspend fun loadDrawerData(): DrawerDataResult {
val session = when (val sessionResult = authSession()) {
is BoardsApiResult.Success -> sessionResult.value

View File

@@ -129,6 +129,13 @@ class BoardsViewModel(
fetchBoards(initial = false, refresh = true)
}
fun clearSessionUiState() {
repository.clearSessionCaches()
lastDrawerLoadAtMillis = null
hadDrawerLoadFailureSinceLastSuccess = false
_uiState.value = BoardsUiState()
}
fun onSettingsApplied(credentialsChanged: Boolean) {
if (!credentialsChanged) {
return

View File

@@ -87,6 +87,7 @@
<string name="drawer_settings">Settings</string>
<string name="drawer_workspaces">Workspaces</string>
<string name="drawer_logout">Log out</string>
<string name="drawer_logout_confirmation_message">Log out and clear this device session?</string>
<string name="drawer_retry">Retry</string>
<string name="drawer_profile_unavailable">Profile unavailable</string>
<string name="drawer_workspaces_unavailable">Workspaces unavailable</string>

View File

@@ -478,6 +478,33 @@ class BoardsViewModelTest {
assertTrue(api.listBoardsCalls >= 1)
}
@Test
fun clearSessionUiStateResetsBoardsStateToDefaults() = runTest {
val api = FakeBoardsApiClient().apply {
listBoardsResult = BoardsApiResult.Success(listOf(BoardSummary("1", "Alpha")))
usersMeResult = BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = "alice@example.com"))
workspacesResult = BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
}
val viewModel = newViewModel(api)
viewModel.loadBoards()
viewModel.loadDrawerData()
advanceUntilIdle()
assertEquals(1, viewModel.uiState.value.boards.size)
assertEquals("Alice", viewModel.uiState.value.drawer.profile?.displayName)
viewModel.clearSessionUiState()
val state = viewModel.uiState.value
assertTrue(state.isInitialLoading)
assertFalse(state.isRefreshing)
assertFalse(state.isMutating)
assertTrue(state.boards.isEmpty())
assertTrue(state.templates.isEmpty())
assertFalse(state.isTemplatesLoading)
assertEquals(BoardsDrawerState(), state.drawer)
}
private fun newViewModel(
apiClient: FakeBoardsApiClient,
sessionStore: InMemorySessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-1"),