feat: integrate boards drawer interactions and workspace switching
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user