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.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.ViewAction
import androidx.test.espresso.contrib.DrawerActions 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.click
import androidx.test.espresso.action.ViewActions.longClick import androidx.test.espresso.action.ViewActions.longClick
import androidx.test.espresso.action.ViewActions.replaceText 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.withId
import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4 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.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
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
@@ -29,7 +40,11 @@ import space.hackenslacker.kanbn4droid.app.auth.SessionStore
import space.hackenslacker.kanbn4droid.app.boards.BoardSummary import space.hackenslacker.kanbn4droid.app.boards.BoardSummary
import space.hackenslacker.kanbn4droid.app.boards.BoardTemplate import space.hackenslacker.kanbn4droid.app.boards.BoardTemplate
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
import space.hackenslacker.kanbn4droid.app.boards.DrawerProfile
import space.hackenslacker.kanbn4droid.app.boards.WorkspaceSummary import space.hackenslacker.kanbn4droid.app.boards.WorkspaceSummary
import java.util.ArrayDeque
import android.view.View
import android.widget.TextView
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class BoardsFlowTest { class BoardsFlowTest {
@@ -137,8 +152,153 @@ class BoardsFlowTest {
onView(withId(R.id.drawerLogoutButton)).check(matches(isDisplayed())) 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 class InMemorySessionStore(
private var baseUrl: String? = null, private var baseUrl: String? = null,
private var workspaceId: String? = "ws-1",
) : SessionStore { ) : SessionStore {
override fun getBaseUrl(): String? = baseUrl override fun getBaseUrl(): String? = baseUrl
@@ -150,12 +310,14 @@ class BoardsFlowTest {
baseUrl = null baseUrl = null
} }
override fun getWorkspaceId(): String? = "ws-1" override fun getWorkspaceId(): String? = workspaceId
override fun saveWorkspaceId(workspaceId: String) { override fun saveWorkspaceId(workspaceId: String) {
this.workspaceId = workspaceId
} }
override fun clearWorkspaceId() { override fun clearWorkspaceId() {
workspaceId = null
} }
} }
@@ -178,14 +340,23 @@ class BoardsFlowTest {
private class FakeBoardsApiClient( private class FakeBoardsApiClient(
private val boards: MutableList<BoardSummary>, private val boards: MutableList<BoardSummary>,
private val templates: List<BoardTemplate>, 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 { ) : KanbnApiClient {
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success 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( override suspend fun listWorkspaces(
baseUrl: String, baseUrl: String,
apiKey: String, apiKey: String,
): BoardsApiResult<List<WorkspaceSummary>> { ): BoardsApiResult<List<WorkspaceSummary>> {
return BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main"))) return BoardsApiResult.Success(workspaces)
} }
override suspend fun listBoards( override suspend fun listBoards(
@@ -229,4 +400,90 @@ class BoardsFlowTest {
return BoardsApiResult.Failure("Not needed in boards flow tests") 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.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.Gravity
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
@@ -12,15 +13,19 @@ import android.widget.TextView
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.GravityCompat
import androidx.drawerlayout.widget.DrawerLayout
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout import com.google.android.material.textfield.TextInputLayout
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore 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.auth.SessionStore
import space.hackenslacker.kanbn4droid.app.boards.BoardSummary import space.hackenslacker.kanbn4droid.app.boards.BoardSummary
import space.hackenslacker.kanbn4droid.app.boards.BoardsAdapter 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.BoardsRepository
import space.hackenslacker.kanbn4droid.app.boards.BoardsUiEvent import space.hackenslacker.kanbn4droid.app.boards.BoardsUiEvent
import space.hackenslacker.kanbn4droid.app.boards.BoardsUiState import space.hackenslacker.kanbn4droid.app.boards.BoardsUiState
import space.hackenslacker.kanbn4droid.app.boards.BoardsViewModel import space.hackenslacker.kanbn4droid.app.boards.BoardsViewModel
import space.hackenslacker.kanbn4droid.app.boards.DrawerDataErrorCode
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailActivity import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailActivity
class BoardsActivity : AppCompatActivity() { class BoardsActivity : AppCompatActivity() {
@@ -47,8 +54,24 @@ class BoardsActivity : AppCompatActivity() {
private lateinit var emptyStateText: TextView private lateinit var emptyStateText: TextView
private lateinit var initialProgress: ProgressBar private lateinit var initialProgress: ProgressBar
private lateinit var createFab: FloatingActionButton 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 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 { private val viewModel: BoardsViewModel by viewModels {
BoardsViewModel.Factory( BoardsViewModel.Factory(
@@ -82,11 +105,24 @@ class BoardsActivity : AppCompatActivity() {
} }
private fun bindViews() { private fun bindViews() {
drawerLayout = findViewById(R.id.boardsDrawerLayout)
drawerContent = findViewById(R.id.boardsDrawerContent)
toolbar = findViewById(R.id.boardsToolbar)
swipeRefresh = findViewById(R.id.boardsSwipeRefresh) swipeRefresh = findViewById(R.id.boardsSwipeRefresh)
recyclerView = findViewById(R.id.boardsRecyclerView) recyclerView = findViewById(R.id.boardsRecyclerView)
emptyStateText = findViewById(R.id.boardsEmptyStateText) emptyStateText = findViewById(R.id.boardsEmptyStateText)
initialProgress = findViewById(R.id.boardsInitialProgress) initialProgress = findViewById(R.id.boardsInitialProgress)
createFab = findViewById(R.id.createBoardFab) 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() { private fun setupRecycler() {
@@ -96,9 +132,48 @@ class BoardsActivity : AppCompatActivity() {
) )
recyclerView.layoutManager = LinearLayoutManager(this) recyclerView.layoutManager = LinearLayoutManager(this)
recyclerView.adapter = boardsAdapter 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() { 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 { swipeRefresh.setOnRefreshListener {
viewModel.refreshBoards() viewModel.refreshBoards()
} }
@@ -126,17 +201,84 @@ class BoardsActivity : AppCompatActivity() {
.setPositiveButton(R.string.ok, null) .setPositiveButton(R.string.ok, null)
.show() .show()
} }
BoardsUiEvent.ForceSignOut -> {
forceSignOutToLogin()
}
} }
} }
} }
} }
private fun render(state: BoardsUiState) { private fun render(state: BoardsUiState) {
if (!hasRequestedInitialDrawerLoad) {
hasRequestedInitialDrawerLoad = true
viewModel.loadDrawerData()
}
boardsAdapter.submitBoards(state.boards) boardsAdapter.submitBoards(state.boards)
swipeRefresh.isRefreshing = state.isRefreshing swipeRefresh.isRefreshing = state.isRefreshing
initialProgress.visibility = if (state.isInitialLoading) View.VISIBLE else View.GONE initialProgress.visibility = if (state.isInitialLoading) View.VISIBLE else View.GONE
emptyStateText.visibility = if (!state.isInitialLoading && state.boards.isEmpty()) View.VISIBLE else View.GONE emptyStateText.visibility = if (!state.isInitialLoading && state.boards.isEmpty()) View.VISIBLE else View.GONE
createFab.isEnabled = !state.isMutating 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() { 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( class BoardsViewModel(
private val repository: BoardsRepository, private val repository: BoardsRepository,
private val nowProvider: () -> Long = { System.currentTimeMillis() },
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(BoardsUiState()) private val _uiState = MutableStateFlow(BoardsUiState())
val uiState: StateFlow<BoardsUiState> = _uiState.asStateFlow() val uiState: StateFlow<BoardsUiState> = _uiState.asStateFlow()
@@ -37,6 +38,8 @@ class BoardsViewModel(
private val _events = MutableSharedFlow<BoardsUiEvent>() private val _events = MutableSharedFlow<BoardsUiEvent>()
val events: SharedFlow<BoardsUiEvent> = _events.asSharedFlow() val events: SharedFlow<BoardsUiEvent> = _events.asSharedFlow()
private var lastDrawerLoadAtMillis: Long? = null
fun loadBoards() { fun loadBoards() {
fetchBoards(initial = true) fetchBoards(initial = true)
} }
@@ -45,6 +48,15 @@ class BoardsViewModel(
fetchDrawerData() fetchDrawerData()
} }
fun loadDrawerDataIfStale() {
val now = nowProvider()
val isStale = lastDrawerLoadAtMillis?.let { now - it >= DRAWER_STALE_MS } ?: true
if (!isStale) {
return
}
fetchDrawerData()
}
fun retryDrawerData() { fun retryDrawerData() {
fetchDrawerData() fetchDrawerData()
} }
@@ -246,6 +258,8 @@ class BoardsViewModel(
) )
} }
lastDrawerLoadAtMillis = nowProvider()
if (result.errorCode == DrawerDataErrorCode.UNAUTHORIZED) { if (result.errorCode == DrawerDataErrorCode.UNAUTHORIZED) {
_events.emit(BoardsUiEvent.ForceSignOut) _events.emit(BoardsUiEvent.ForceSignOut)
return@launch return@launch
@@ -288,4 +302,8 @@ class BoardsViewModel(
throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}") 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"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/boardsDrawerContent"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="?attr/colorSurface" android:background="?attr/colorSurface"
@@ -50,6 +51,33 @@
android:clipToPadding="false" android:clipToPadding="false"
android:paddingBottom="8dp" /> 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 <Button
android:id="@+id/drawerLogoutButton" android:id="@+id/drawerLogoutButton"
style="?attr/materialButtonOutlinedStyle" style="?attr/materialButtonOutlinedStyle"

View File

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