From eeffb3de49a8f5b1f5bcbe1effbc50645d0df7cc Mon Sep 17 00:00:00 2001 From: Wally Hackenslacker Date: Wed, 18 Mar 2026 09:23:23 -0400 Subject: [PATCH] feat: integrate boards drawer interactions and workspace switching --- .../kanbn4droid/app/BoardsFlowTest.kt | 261 +++++++++++++++++- .../kanbn4droid/app/BoardsActivity.kt | 142 ++++++++++ .../app/boards/BoardsDrawerAdapter.kt | 57 ++++ .../kanbn4droid/app/boards/BoardsViewModel.kt | 18 ++ .../main/res/layout/view_boards_drawer.xml | 28 ++ .../app/boards/BoardsViewModelTest.kt | 37 ++- 6 files changed, 540 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsDrawerAdapter.kt diff --git a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardsFlowTest.kt b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardsFlowTest.kt index 820871f..304d717 100644 --- a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardsFlowTest.kt +++ b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardsFlowTest.kt @@ -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(R.id.drawerWorkspacesRecyclerView) + val hasActivatedPlatform = (0 until recycler.childCount) + .map { recycler.getChildAt(it) } + .any { row -> + val title = row.findViewById(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(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(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(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, private val templates: List, + private val profile: DrawerProfile = DrawerProfile("Alice", "alice@example.com"), + private val workspaces: List = 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 { + return BoardsApiResult.Success(profile) + } + override suspend fun listWorkspaces( baseUrl: String, apiKey: String, ): BoardsApiResult> { - 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>, + private val usersMeResponses: ArrayDeque> = ArrayDeque( + listOf(BoardsApiResult.Success(DrawerProfile("Alice", "alice@example.com"))), + ), + private val workspaceResponses: ArrayDeque>> = ArrayDeque( + listOf( + BoardsApiResult.Success( + listOf( + WorkspaceSummary("ws-1", "Main"), + WorkspaceSummary("ws-2", "Platform"), + ), + ), + ), + ), + ) : KanbnApiClient { + var listWorkspacesCalls: Int = 0 + val listBoardsWorkspaceCalls = mutableListOf() + + override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success + + override suspend fun getCurrentUser(baseUrl: String, apiKey: String): BoardsApiResult { + return if (usersMeResponses.isEmpty()) { + BoardsApiResult.Success(DrawerProfile("Alice", "alice@example.com")) + } else { + usersMeResponses.removeFirst() + } + } + + override suspend fun listWorkspaces( + baseUrl: String, + apiKey: String, + ): BoardsApiResult> { + 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> { + listBoardsWorkspaceCalls += workspaceId + return BoardsApiResult.Success(boardsByWorkspace[workspaceId].orEmpty()) + } + + override suspend fun listBoardTemplates( + baseUrl: String, + apiKey: String, + workspaceId: String, + ): BoardsApiResult> { + return BoardsApiResult.Success(emptyList()) + } + + override suspend fun createBoard( + baseUrl: String, + apiKey: String, + workspaceId: String, + name: String, + templateId: String?, + ): BoardsApiResult { + return BoardsApiResult.Success(BoardSummary("new", name)) + } + + override suspend fun deleteBoard(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult { + return BoardsApiResult.Success(Unit) + } + + override suspend fun getLabelByPublicId( + baseUrl: String, + apiKey: String, + labelId: String, + ): BoardsApiResult { + return BoardsApiResult.Failure("Not needed in boards flow tests") + } + } } diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/BoardsActivity.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/BoardsActivity.kt index 5db4337..3e9eed7 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/BoardsActivity.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/BoardsActivity.kt @@ -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() { diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsDrawerAdapter.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsDrawerAdapter.kt new file mode 100644 index 0000000..01976e5 --- /dev/null +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsDrawerAdapter.kt @@ -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() { + private val workspaces = mutableListOf() + private var activeWorkspaceId: String? = null + + fun submitItems(workspaces: List, 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) + } + } + } +} diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModel.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModel.kt index b1dfaf8..8e6d9c4 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModel.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModel.kt @@ -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 = _uiState.asStateFlow() @@ -37,6 +38,8 @@ class BoardsViewModel( private val _events = MutableSharedFlow() val events: SharedFlow = _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 + } } diff --git a/app/src/main/res/layout/view_boards_drawer.xml b/app/src/main/res/layout/view_boards_drawer.xml index 20dc677..28d9730 100644 --- a/app/src/main/res/layout/view_boards_drawer.xml +++ b/app/src/main/res/layout/view_boards_drawer.xml @@ -1,5 +1,6 @@ + + + + +