feat: integrate boards drawer interactions and workspace switching

This commit is contained in:
2026-03-18 09:23:23 -04:00
parent 149663662b
commit eeffb3de49
6 changed files with 540 additions and 3 deletions

View File

@@ -2,7 +2,12 @@ package space.hackenslacker.kanbn4droid.app
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.ViewAction
import androidx.test.espresso.contrib.DrawerActions
import androidx.test.espresso.action.CoordinatesProvider
import androidx.test.espresso.action.GeneralSwipeAction
import androidx.test.espresso.action.Press
import androidx.test.espresso.action.Swipe
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.longClick
import androidx.test.espresso.action.ViewActions.replaceText
@@ -17,7 +22,13 @@ import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.core.view.GravityCompat
import androidx.drawerlayout.widget.DrawerLayout
import androidx.recyclerview.widget.RecyclerView
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@@ -29,7 +40,11 @@ import space.hackenslacker.kanbn4droid.app.auth.SessionStore
import space.hackenslacker.kanbn4droid.app.boards.BoardSummary
import space.hackenslacker.kanbn4droid.app.boards.BoardTemplate
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
import space.hackenslacker.kanbn4droid.app.boards.DrawerProfile
import space.hackenslacker.kanbn4droid.app.boards.WorkspaceSummary
import java.util.ArrayDeque
import android.view.View
import android.widget.TextView
@RunWith(AndroidJUnit4::class)
class BoardsFlowTest {
@@ -137,8 +152,153 @@ class BoardsFlowTest {
onView(withId(R.id.drawerLogoutButton)).check(matches(isDisplayed()))
}
@Test
fun workspaceSelectionHighlightsAndRefreshesBoards() {
val fake = MultiWorkspaceFakeBoardsApiClient(
boardsByWorkspace = mapOf(
"ws-1" to listOf(BoardSummary("1", "Alpha")),
"ws-2" to listOf(BoardSummary("2", "Beta")),
),
)
MainActivity.dependencies.apiClientFactory = { fake }
MainActivity.dependencies.sessionStoreFactory = { InMemorySessionStore("https://kan.bn/", workspaceId = "ws-1") }
val scenario = ActivityScenario.launch(BoardsActivity::class.java)
onView(withId(R.id.boardsDrawerLayout)).perform(DrawerActions.open())
onView(withText("Platform")).perform(click())
onView(withText("Beta")).check(matches(isDisplayed()))
onView(withText("Alpha")).check(doesNotExist())
scenario.onActivity { activity ->
val recycler = activity.findViewById<RecyclerView>(R.id.drawerWorkspacesRecyclerView)
val hasActivatedPlatform = (0 until recycler.childCount)
.map { recycler.getChildAt(it) }
.any { row ->
val title = row.findViewById<TextView>(R.id.workspaceTitleText).text.toString()
title == "Platform" && row.isActivated
}
assertTrue(hasActivatedPlatform)
}
assertTrue(fake.listBoardsWorkspaceCalls.contains("ws-2"))
}
@Test
fun drawerOpensFromLeftEdgeGesture() {
MainActivity.dependencies.apiClientFactory = {
FakeBoardsApiClient(
boards = mutableListOf(BoardSummary("1", "Alpha")),
templates = emptyList(),
)
}
val scenario = ActivityScenario.launch(BoardsActivity::class.java)
onView(withId(R.id.boardsDrawerLayout)).perform(swipeFromLeftEdge())
scenario.onActivity { activity ->
val drawer = activity.findViewById<DrawerLayout>(R.id.boardsDrawerLayout)
assertTrue(drawer.isDrawerOpen(GravityCompat.START))
}
}
@Test
fun drawerRetryButtonReloadsAfterRecoverableFailure() {
val fake = MultiWorkspaceFakeBoardsApiClient(
boardsByWorkspace = mapOf("ws-1" to listOf(BoardSummary("1", "Alpha"))),
usersMeResponses = ArrayDeque(
listOf(
BoardsApiResult.Failure("Cannot reach server. Check your connection and URL."),
BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = "alice@example.com")),
),
),
workspaceResponses = ArrayDeque(
listOf(
BoardsApiResult.Failure("Cannot reach server. Check your connection and URL."),
BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main"), WorkspaceSummary("ws-2", "Platform"))),
),
),
)
MainActivity.dependencies.apiClientFactory = { fake }
ActivityScenario.launch(BoardsActivity::class.java)
onView(withId(R.id.boardsDrawerLayout)).perform(DrawerActions.open())
onView(withId(R.id.drawerRetryButton)).check(matches(isDisplayed())).perform(click())
onView(withText("Main")).check(matches(isDisplayed()))
assertTrue(fake.listWorkspacesCalls >= 2)
}
@Test
fun drawerUnauthorizedForcesSignOutToLogin() {
val sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-1")
val fake = MultiWorkspaceFakeBoardsApiClient(
boardsByWorkspace = mapOf("ws-1" to listOf(BoardSummary("1", "Alpha"))),
usersMeResponses = ArrayDeque(listOf(BoardsApiResult.Failure("Server error: 401"))),
workspaceResponses = ArrayDeque(listOf(BoardsApiResult.Failure("Server error: 401"))),
)
MainActivity.dependencies.sessionStoreFactory = { sessionStore }
MainActivity.dependencies.apiClientFactory = { fake }
ActivityScenario.launch(BoardsActivity::class.java)
Intents.intended(hasComponent(MainActivity::class.java.name))
assertEquals(null, sessionStore.getWorkspaceId())
}
@Test
fun workspaceSelectionClosesDrawerAfterSuccess() {
val fake = MultiWorkspaceFakeBoardsApiClient(
boardsByWorkspace = mapOf(
"ws-1" to listOf(BoardSummary("1", "Alpha")),
"ws-2" to listOf(BoardSummary("2", "Beta")),
),
)
MainActivity.dependencies.apiClientFactory = { fake }
MainActivity.dependencies.sessionStoreFactory = { InMemorySessionStore("https://kan.bn/", workspaceId = "ws-1") }
val scenario = ActivityScenario.launch(BoardsActivity::class.java)
onView(withId(R.id.boardsDrawerLayout)).perform(DrawerActions.open())
onView(withText("Platform")).perform(click())
onView(withText("Beta")).check(matches(isDisplayed()))
scenario.onActivity { activity ->
val drawer = activity.findViewById<DrawerLayout>(R.id.boardsDrawerLayout)
assertFalse(drawer.isDrawerOpen(GravityCompat.START))
}
}
@Test
fun drawerWidthNeverExceedsOneThirdOfScreen() {
MainActivity.dependencies.apiClientFactory = {
FakeBoardsApiClient(
boards = mutableListOf(BoardSummary("1", "Alpha")),
templates = emptyList(),
)
}
val scenario = ActivityScenario.launch(BoardsActivity::class.java)
scenario.onActivity { activity ->
val drawerContent = activity.findViewById<View>(R.id.boardsDrawerContent)
val displayWidthPx = activity.resources.displayMetrics.widthPixels
assertTrue(drawerContent.layoutParams.width <= displayWidthPx / 3)
}
}
private fun swipeFromLeftEdge(): ViewAction {
val start = CoordinatesProvider { view -> floatArrayOf(5f, view.height * 0.5f) }
val end = CoordinatesProvider { view -> floatArrayOf(view.width * 0.6f, view.height * 0.5f) }
return GeneralSwipeAction(Swipe.FAST, start, end, Press.FINGER)
}
private class InMemorySessionStore(
private var baseUrl: String? = null,
private var workspaceId: String? = "ws-1",
) : SessionStore {
override fun getBaseUrl(): String? = baseUrl
@@ -150,12 +310,14 @@ class BoardsFlowTest {
baseUrl = null
}
override fun getWorkspaceId(): String? = "ws-1"
override fun getWorkspaceId(): String? = workspaceId
override fun saveWorkspaceId(workspaceId: String) {
this.workspaceId = workspaceId
}
override fun clearWorkspaceId() {
workspaceId = null
}
}
@@ -178,14 +340,23 @@ class BoardsFlowTest {
private class FakeBoardsApiClient(
private val boards: MutableList<BoardSummary>,
private val templates: List<BoardTemplate>,
private val profile: DrawerProfile = DrawerProfile("Alice", "alice@example.com"),
private val workspaces: List<WorkspaceSummary> = listOf(
WorkspaceSummary("ws-1", "Main"),
WorkspaceSummary("ws-2", "Platform"),
),
) : KanbnApiClient {
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success
override suspend fun getCurrentUser(baseUrl: String, apiKey: String): BoardsApiResult<DrawerProfile> {
return BoardsApiResult.Success(profile)
}
override suspend fun listWorkspaces(
baseUrl: String,
apiKey: String,
): BoardsApiResult<List<WorkspaceSummary>> {
return BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
return BoardsApiResult.Success(workspaces)
}
override suspend fun listBoards(
@@ -229,4 +400,90 @@ class BoardsFlowTest {
return BoardsApiResult.Failure("Not needed in boards flow tests")
}
}
private class MultiWorkspaceFakeBoardsApiClient(
private val boardsByWorkspace: Map<String, List<BoardSummary>>,
private val usersMeResponses: ArrayDeque<BoardsApiResult<DrawerProfile>> = ArrayDeque(
listOf(BoardsApiResult.Success(DrawerProfile("Alice", "alice@example.com"))),
),
private val workspaceResponses: ArrayDeque<BoardsApiResult<List<WorkspaceSummary>>> = ArrayDeque(
listOf(
BoardsApiResult.Success(
listOf(
WorkspaceSummary("ws-1", "Main"),
WorkspaceSummary("ws-2", "Platform"),
),
),
),
),
) : KanbnApiClient {
var listWorkspacesCalls: Int = 0
val listBoardsWorkspaceCalls = mutableListOf<String>()
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success
override suspend fun getCurrentUser(baseUrl: String, apiKey: String): BoardsApiResult<DrawerProfile> {
return if (usersMeResponses.isEmpty()) {
BoardsApiResult.Success(DrawerProfile("Alice", "alice@example.com"))
} else {
usersMeResponses.removeFirst()
}
}
override suspend fun listWorkspaces(
baseUrl: String,
apiKey: String,
): BoardsApiResult<List<WorkspaceSummary>> {
listWorkspacesCalls += 1
return if (workspaceResponses.isEmpty()) {
BoardsApiResult.Success(
listOf(
WorkspaceSummary("ws-1", "Main"),
WorkspaceSummary("ws-2", "Platform"),
),
)
} else {
workspaceResponses.removeFirst()
}
}
override suspend fun listBoards(
baseUrl: String,
apiKey: String,
workspaceId: String,
): BoardsApiResult<List<BoardSummary>> {
listBoardsWorkspaceCalls += workspaceId
return BoardsApiResult.Success(boardsByWorkspace[workspaceId].orEmpty())
}
override suspend fun listBoardTemplates(
baseUrl: String,
apiKey: String,
workspaceId: String,
): BoardsApiResult<List<BoardTemplate>> {
return BoardsApiResult.Success(emptyList())
}
override suspend fun createBoard(
baseUrl: String,
apiKey: String,
workspaceId: String,
name: String,
templateId: String?,
): BoardsApiResult<BoardSummary> {
return BoardsApiResult.Success(BoardSummary("new", name))
}
override suspend fun deleteBoard(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult<Unit> {
return BoardsApiResult.Success(Unit)
}
override suspend fun getLabelByPublicId(
baseUrl: String,
apiKey: String,
labelId: String,
): BoardsApiResult<LabelDetail> {
return BoardsApiResult.Failure("Not needed in boards flow tests")
}
}
}

View File

@@ -2,6 +2,7 @@ package space.hackenslacker.kanbn4droid.app
import android.content.Intent
import android.os.Bundle
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.widget.ArrayAdapter
@@ -12,15 +13,19 @@ import android.widget.TextView
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.GravityCompat
import androidx.drawerlayout.widget.DrawerLayout
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.chip.Chip
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
@@ -31,10 +36,12 @@ import space.hackenslacker.kanbn4droid.app.auth.SessionPreferences
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
import space.hackenslacker.kanbn4droid.app.boards.BoardSummary
import space.hackenslacker.kanbn4droid.app.boards.BoardsAdapter
import space.hackenslacker.kanbn4droid.app.boards.BoardsDrawerAdapter
import space.hackenslacker.kanbn4droid.app.boards.BoardsRepository
import space.hackenslacker.kanbn4droid.app.boards.BoardsUiEvent
import space.hackenslacker.kanbn4droid.app.boards.BoardsUiState
import space.hackenslacker.kanbn4droid.app.boards.BoardsViewModel
import space.hackenslacker.kanbn4droid.app.boards.DrawerDataErrorCode
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailActivity
class BoardsActivity : AppCompatActivity() {
@@ -47,8 +54,24 @@ class BoardsActivity : AppCompatActivity() {
private lateinit var emptyStateText: TextView
private lateinit var initialProgress: ProgressBar
private lateinit var createFab: FloatingActionButton
private lateinit var toolbar: MaterialToolbar
private lateinit var drawerLayout: DrawerLayout
private lateinit var drawerContent: View
private lateinit var drawerUsernameText: TextView
private lateinit var drawerEmailText: TextView
private lateinit var drawerLoadingIndicator: ProgressBar
private lateinit var drawerErrorText: TextView
private lateinit var drawerRetryButton: Button
private lateinit var drawerSettingsButton: Button
private lateinit var drawerLogoutButton: Button
private lateinit var drawerWorkspacesRecyclerView: RecyclerView
private lateinit var boardsAdapter: BoardsAdapter
private lateinit var drawerAdapter: BoardsDrawerAdapter
private var hasRequestedInitialDrawerLoad = false
private var lastDrawerSwitchInFlight = false
private var pendingWorkspaceSelectionId: String? = null
private val viewModel: BoardsViewModel by viewModels {
BoardsViewModel.Factory(
@@ -82,11 +105,24 @@ class BoardsActivity : AppCompatActivity() {
}
private fun bindViews() {
drawerLayout = findViewById(R.id.boardsDrawerLayout)
drawerContent = findViewById(R.id.boardsDrawerContent)
toolbar = findViewById(R.id.boardsToolbar)
swipeRefresh = findViewById(R.id.boardsSwipeRefresh)
recyclerView = findViewById(R.id.boardsRecyclerView)
emptyStateText = findViewById(R.id.boardsEmptyStateText)
initialProgress = findViewById(R.id.boardsInitialProgress)
createFab = findViewById(R.id.createBoardFab)
drawerUsernameText = findViewById(R.id.drawerUsernameText)
drawerEmailText = findViewById(R.id.drawerEmailText)
drawerLoadingIndicator = findViewById(R.id.drawerLoadingIndicator)
drawerErrorText = findViewById(R.id.drawerErrorText)
drawerRetryButton = findViewById(R.id.drawerRetryButton)
drawerSettingsButton = findViewById(R.id.drawerSettingsButton)
drawerLogoutButton = findViewById(R.id.drawerLogoutButton)
drawerWorkspacesRecyclerView = findViewById(R.id.drawerWorkspacesRecyclerView)
applyDrawerWidth()
}
private fun setupRecycler() {
@@ -96,9 +132,48 @@ class BoardsActivity : AppCompatActivity() {
)
recyclerView.layoutManager = LinearLayoutManager(this)
recyclerView.adapter = boardsAdapter
drawerAdapter = BoardsDrawerAdapter(
onWorkspaceClick = { workspace ->
if (viewModel.uiState.value.drawer.isWorkspaceInteractionEnabled) {
pendingWorkspaceSelectionId = workspace.id
viewModel.onWorkspaceSelected(workspace.id)
}
},
)
drawerWorkspacesRecyclerView.layoutManager = LinearLayoutManager(this)
drawerWorkspacesRecyclerView.adapter = drawerAdapter
}
private fun setupInteractions() {
toolbar.setNavigationIcon(android.R.drawable.ic_menu_sort_by_size)
toolbar.setNavigationContentDescription(R.string.drawer_workspaces)
toolbar.setNavigationOnClickListener {
drawerLayout.openDrawer(GravityCompat.START)
}
drawerRetryButton.setOnClickListener {
viewModel.retryDrawerData()
}
drawerSettingsButton.setOnClickListener {
drawerLayout.closeDrawer(GravityCompat.START)
}
drawerLogoutButton.setOnClickListener {
forceSignOutToLogin()
}
drawerLayout.addDrawerListener(
object : DrawerLayout.SimpleDrawerListener() {
override fun onDrawerOpened(drawerView: View) {
if (drawerView.id == R.id.boardsDrawerContent) {
viewModel.loadDrawerDataIfStale()
}
}
},
)
swipeRefresh.setOnRefreshListener {
viewModel.refreshBoards()
}
@@ -126,17 +201,84 @@ class BoardsActivity : AppCompatActivity() {
.setPositiveButton(R.string.ok, null)
.show()
}
BoardsUiEvent.ForceSignOut -> {
forceSignOutToLogin()
}
}
}
}
}
private fun render(state: BoardsUiState) {
if (!hasRequestedInitialDrawerLoad) {
hasRequestedInitialDrawerLoad = true
viewModel.loadDrawerData()
}
boardsAdapter.submitBoards(state.boards)
swipeRefresh.isRefreshing = state.isRefreshing
initialProgress.visibility = if (state.isInitialLoading) View.VISIBLE else View.GONE
emptyStateText.visibility = if (!state.isInitialLoading && state.boards.isEmpty()) View.VISIBLE else View.GONE
createFab.isEnabled = !state.isMutating
val profile = state.drawer.profile
drawerUsernameText.text = profile?.displayName?.ifBlank { getString(R.string.drawer_profile_unavailable) }
?: getString(R.string.drawer_profile_unavailable)
drawerEmailText.text = profile?.email?.ifBlank { getString(R.string.drawer_profile_unavailable) }
?: getString(R.string.drawer_profile_unavailable)
drawerAdapter.submitItems(
workspaces = state.drawer.workspaces,
activeWorkspaceId = state.drawer.activeWorkspaceId,
)
drawerLoadingIndicator.visibility = if (state.drawer.isLoading) View.VISIBLE else View.GONE
val hasError = state.drawer.profileError != null || state.drawer.workspacesError != null
drawerErrorText.visibility = if (hasError) View.VISIBLE else View.GONE
drawerErrorText.text = state.drawer.workspacesError
?: state.drawer.profileError
?: getString(R.string.drawer_workspaces_unavailable)
drawerRetryButton.visibility = if (state.drawer.isRetryable) View.VISIBLE else View.GONE
val isSwitchInFlight = state.drawer.isWorkspaceSwitchInFlight
if (lastDrawerSwitchInFlight && !isSwitchInFlight) {
val selectedWorkspace = pendingWorkspaceSelectionId
if (
selectedWorkspace != null &&
selectedWorkspace == state.drawer.activeWorkspaceId &&
state.drawer.errorCode == DrawerDataErrorCode.NONE
) {
drawerLayout.closeDrawer(Gravity.START)
}
pendingWorkspaceSelectionId = null
}
lastDrawerSwitchInFlight = isSwitchInFlight
}
private fun applyDrawerWidth() {
val maxWidthPx = resources.getDimensionPixelSize(R.dimen.boards_drawer_max_width)
val displayWidthPx = resources.displayMetrics.widthPixels
val computedWidth = minOf(displayWidthPx / 3, maxWidthPx)
drawerContent.layoutParams = drawerContent.layoutParams.apply {
width = computedWidth
}
}
private fun forceSignOutToLogin() {
lifecycleScope.launch {
val baseUrl = sessionStore.getBaseUrl().orEmpty()
if (baseUrl.isNotBlank()) {
kotlinx.coroutines.withContext(Dispatchers.IO) {
apiKeyStore.invalidateApiKey(baseUrl)
}
}
sessionStore.clearWorkspaceId()
val intent = Intent(this@BoardsActivity, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
startActivity(intent)
finish()
}
}
private fun showCreateBoardDialog() {

View File

@@ -0,0 +1,57 @@
package space.hackenslacker.kanbn4droid.app.boards
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import space.hackenslacker.kanbn4droid.app.R
class BoardsDrawerAdapter(
private val onWorkspaceClick: (WorkspaceSummary) -> Unit,
) : RecyclerView.Adapter<BoardsDrawerAdapter.WorkspaceViewHolder>() {
private val workspaces = mutableListOf<WorkspaceSummary>()
private var activeWorkspaceId: String? = null
fun submitItems(workspaces: List<WorkspaceSummary>, activeWorkspaceId: String?) {
this.workspaces.clear()
this.workspaces.addAll(workspaces)
this.activeWorkspaceId = activeWorkspaceId
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WorkspaceViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_workspace_drawer, parent, false)
return WorkspaceViewHolder(view)
}
override fun onBindViewHolder(holder: WorkspaceViewHolder, position: Int) {
val workspace = workspaces[position]
holder.bind(
workspace = workspace,
isSelected = workspace.id == activeWorkspaceId,
onWorkspaceClick = onWorkspaceClick,
)
}
override fun getItemCount(): Int = workspaces.size
class WorkspaceViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val workspaceTitleText: TextView = itemView.findViewById(R.id.workspaceTitleText)
fun bind(
workspace: WorkspaceSummary,
isSelected: Boolean,
onWorkspaceClick: (WorkspaceSummary) -> Unit,
) {
workspaceTitleText.text = workspace.name
itemView.isSelected = isSelected
itemView.isActivated = isSelected
workspaceTitleText.isSelected = isSelected
workspaceTitleText.isActivated = isSelected
itemView.setOnClickListener {
onWorkspaceClick(workspace)
}
}
}
}

View File

@@ -30,6 +30,7 @@ interface BoardsUiEvent {
class BoardsViewModel(
private val repository: BoardsRepository,
private val nowProvider: () -> Long = { System.currentTimeMillis() },
) : ViewModel() {
private val _uiState = MutableStateFlow(BoardsUiState())
val uiState: StateFlow<BoardsUiState> = _uiState.asStateFlow()
@@ -37,6 +38,8 @@ class BoardsViewModel(
private val _events = MutableSharedFlow<BoardsUiEvent>()
val events: SharedFlow<BoardsUiEvent> = _events.asSharedFlow()
private var lastDrawerLoadAtMillis: Long? = null
fun loadBoards() {
fetchBoards(initial = true)
}
@@ -45,6 +48,15 @@ class BoardsViewModel(
fetchDrawerData()
}
fun loadDrawerDataIfStale() {
val now = nowProvider()
val isStale = lastDrawerLoadAtMillis?.let { now - it >= DRAWER_STALE_MS } ?: true
if (!isStale) {
return
}
fetchDrawerData()
}
fun retryDrawerData() {
fetchDrawerData()
}
@@ -246,6 +258,8 @@ class BoardsViewModel(
)
}
lastDrawerLoadAtMillis = nowProvider()
if (result.errorCode == DrawerDataErrorCode.UNAUTHORIZED) {
_events.emit(BoardsUiEvent.ForceSignOut)
return@launch
@@ -288,4 +302,8 @@ class BoardsViewModel(
throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
}
}
private companion object {
private const val DRAWER_STALE_MS = 2 * 60 * 1000L
}
}

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/boardsDrawerContent"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface"
@@ -50,6 +51,33 @@
android:clipToPadding="false"
android:paddingBottom="8dp" />
<ProgressBar
android:id="@+id/drawerLoadingIndicator"
style="@style/Widget.AppCompat.ProgressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginBottom="8dp"
android:visibility="gone" />
<TextView
android:id="@+id/drawerErrorText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:text="@string/drawer_workspaces_unavailable"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
android:visibility="gone" />
<Button
android:id="@+id/drawerRetryButton"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:text="@string/drawer_retry"
android:visibility="gone" />
<Button
android:id="@+id/drawerLogoutButton"
style="?attr/materialButtonOutlinedStyle"

View File

@@ -272,6 +272,36 @@ class BoardsViewModelTest {
assertEquals("Alice", drawer.profile?.displayName)
}
@Test
fun loadDrawerDataIfStaleSkipsFreshDataAndReloadsWhenStale() = runTest {
var now = 1_000L
val api = FakeBoardsApiClient().apply {
usersMeResult = BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = null))
workspacesResult = BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
}
val viewModel = newViewModel(apiClient = api, nowProvider = { now })
viewModel.loadDrawerData()
advanceUntilIdle()
assertEquals(1, api.listWorkspacesCalls)
assertEquals(1, api.getCurrentUserCalls)
now += 10_000L
viewModel.loadDrawerDataIfStale()
advanceUntilIdle()
assertEquals(1, api.listWorkspacesCalls)
assertEquals(1, api.getCurrentUserCalls)
now += 180_000L
viewModel.loadDrawerDataIfStale()
advanceUntilIdle()
assertEquals(2, api.listWorkspacesCalls)
assertEquals(2, api.getCurrentUserCalls)
}
@Test
fun loadDrawerDataFallbackWorkspacePersistsFirstAndTriggersBoardsRefresh() = runTest {
val api = FakeBoardsApiClient().apply {
@@ -372,6 +402,7 @@ class BoardsViewModelTest {
private fun newViewModel(
apiClient: FakeBoardsApiClient,
sessionStore: InMemorySessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-1"),
nowProvider: () -> Long = { System.currentTimeMillis() },
): BoardsViewModel {
val repository = BoardsRepository(
sessionStore = sessionStore,
@@ -379,7 +410,7 @@ class BoardsViewModelTest {
apiClient = apiClient,
ioDispatcher = UnconfinedTestDispatcher(),
)
return BoardsViewModel(repository)
return BoardsViewModel(repository, nowProvider = nowProvider)
}
private class InMemorySessionStore(
@@ -434,9 +465,12 @@ class BoardsViewModelTest {
var lastDeletedId: String? = null
var listBoardsCalls: Int = 0
var listWorkspacesCalls: Int = 0
var getCurrentUserCalls: Int = 0
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success
override suspend fun getCurrentUser(baseUrl: String, apiKey: String): BoardsApiResult<DrawerProfile> {
getCurrentUserCalls += 1
return usersMeResults.removeFirstOrNull() ?: usersMeResult
}
@@ -444,6 +478,7 @@ class BoardsViewModelTest {
baseUrl: String,
apiKey: String,
): BoardsApiResult<List<WorkspaceSummary>> {
listWorkspacesCalls += 1
return workspacesResults.removeFirstOrNull() ?: workspacesResult
}