feat: add in-place settings dialog with immediate apply
This commit is contained in:
@@ -2,6 +2,7 @@ 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.Espresso.pressBack
|
||||||
import androidx.test.espresso.ViewAction
|
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.CoordinatesProvider
|
||||||
@@ -18,13 +19,17 @@ import androidx.test.espresso.intent.Intents
|
|||||||
import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
|
import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
|
||||||
import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtra
|
import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtra
|
||||||
import androidx.test.espresso.matcher.RootMatchers.isDialog
|
import androidx.test.espresso.matcher.RootMatchers.isDialog
|
||||||
|
import androidx.test.espresso.matcher.ViewMatchers.isEnabled
|
||||||
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
|
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
|
||||||
|
import androidx.test.espresso.matcher.ViewMatchers.isRoot
|
||||||
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.test.platform.app.InstrumentationRegistry
|
||||||
import androidx.core.view.GravityCompat
|
import androidx.core.view.GravityCompat
|
||||||
import androidx.drawerlayout.widget.DrawerLayout
|
import androidx.drawerlayout.widget.DrawerLayout
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
@@ -36,12 +41,15 @@ import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
|
|||||||
import space.hackenslacker.kanbn4droid.app.auth.AuthResult
|
import space.hackenslacker.kanbn4droid.app.auth.AuthResult
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
|
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.LabelDetail
|
import space.hackenslacker.kanbn4droid.app.auth.LabelDetail
|
||||||
|
import space.hackenslacker.kanbn4droid.app.auth.SettingsApplyCoordinator
|
||||||
|
import space.hackenslacker.kanbn4droid.app.auth.SettingsApplyResult
|
||||||
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.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.DrawerProfile
|
||||||
import space.hackenslacker.kanbn4droid.app.boards.WorkspaceSummary
|
import space.hackenslacker.kanbn4droid.app.boards.WorkspaceSummary
|
||||||
|
import space.hackenslacker.kanbn4droid.app.settings.SettingsDialogFragment
|
||||||
import java.util.ArrayDeque
|
import java.util.ArrayDeque
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
@@ -290,6 +298,216 @@ class BoardsFlowTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun settingsDialogOpensFromDrawerAndShowsPreferences() {
|
||||||
|
MainActivity.dependencies.apiClientFactory = {
|
||||||
|
FakeBoardsApiClient(
|
||||||
|
boards = mutableListOf(BoardSummary("1", "Alpha")),
|
||||||
|
templates = emptyList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ActivityScenario.launch(BoardsActivity::class.java)
|
||||||
|
|
||||||
|
openSettingsFromDrawer()
|
||||||
|
|
||||||
|
onView(withText(R.string.settings_theme_title)).check(matches(isDisplayed()))
|
||||||
|
onView(withText(R.string.settings_base_url_title)).check(matches(isDisplayed()))
|
||||||
|
onView(withText(R.string.settings_api_key_title)).check(matches(isDisplayed()))
|
||||||
|
onView(withId(R.id.settingsSaveAndCloseButton)).check(matches(isDisplayed()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun settingsDialogBlocksBackAndOutsideDismiss() {
|
||||||
|
MainActivity.dependencies.apiClientFactory = {
|
||||||
|
FakeBoardsApiClient(
|
||||||
|
boards = mutableListOf(BoardSummary("1", "Alpha")),
|
||||||
|
templates = emptyList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ActivityScenario.launch(BoardsActivity::class.java)
|
||||||
|
|
||||||
|
openSettingsFromDrawer()
|
||||||
|
|
||||||
|
pressBack()
|
||||||
|
onView(withId(R.id.settingsFragmentContainer)).check(matches(isDisplayed()))
|
||||||
|
|
||||||
|
onView(isRoot()).perform(clickTopLeftCorner())
|
||||||
|
onView(withId(R.id.settingsFragmentContainer)).check(matches(isDisplayed()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun settingsButtonClosesDrawerBeforeOpeningDialog() {
|
||||||
|
MainActivity.dependencies.apiClientFactory = {
|
||||||
|
FakeBoardsApiClient(
|
||||||
|
boards = mutableListOf(BoardSummary("1", "Alpha")),
|
||||||
|
templates = emptyList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val scenario = ActivityScenario.launch(BoardsActivity::class.java)
|
||||||
|
|
||||||
|
openSettingsFromDrawer()
|
||||||
|
|
||||||
|
scenario.onActivity { activity ->
|
||||||
|
val drawer = activity.findViewById<DrawerLayout>(R.id.boardsDrawerLayout)
|
||||||
|
assertFalse(drawer.isDrawerOpen(GravityCompat.START))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun settingsDialogDisablesControlsWhileApplyInProgress() {
|
||||||
|
val blocked = CompletableDeferred<Unit>()
|
||||||
|
MainActivity.dependencies.apiClientFactory = {
|
||||||
|
FakeBoardsApiClient(
|
||||||
|
boards = mutableListOf(BoardSummary("1", "Alpha")),
|
||||||
|
templates = emptyList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
MainActivity.dependencies.settingsApplyCoordinatorFactory = { _, sessionStore, apiClient, apiKeyStore ->
|
||||||
|
object : SettingsApplyCoordinator(sessionStore, apiClient, apiKeyStore) {
|
||||||
|
override suspend fun apply(): SettingsApplyResult {
|
||||||
|
blocked.await()
|
||||||
|
return SettingsApplyResult.SuccessNoCredentialChange(themeChanged = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ActivityScenario.launch(BoardsActivity::class.java)
|
||||||
|
openSettingsFromDrawer()
|
||||||
|
|
||||||
|
onView(withId(R.id.settingsSaveAndCloseButton)).perform(click())
|
||||||
|
|
||||||
|
onView(withId(R.id.settingsApplyProgress)).check(matches(isDisplayed()))
|
||||||
|
onView(withId(R.id.settingsSaveAndCloseButton)).check(matches(org.hamcrest.Matchers.not(isEnabled())))
|
||||||
|
|
||||||
|
blocked.complete(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun settingsDialogClosesOnlyOnSuccessfulApply() {
|
||||||
|
MainActivity.dependencies.apiClientFactory = {
|
||||||
|
FakeBoardsApiClient(
|
||||||
|
boards = mutableListOf(BoardSummary("1", "Alpha")),
|
||||||
|
templates = emptyList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
MainActivity.dependencies.settingsApplyCoordinatorFactory = { _, sessionStore, apiClient, apiKeyStore ->
|
||||||
|
object : SettingsApplyCoordinator(sessionStore, apiClient, apiKeyStore) {
|
||||||
|
override suspend fun apply(): SettingsApplyResult {
|
||||||
|
return SettingsApplyResult.ValidationError(
|
||||||
|
field = "apiKey",
|
||||||
|
message = "API key is required",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ActivityScenario.launch(BoardsActivity::class.java)
|
||||||
|
openSettingsFromDrawer()
|
||||||
|
onView(withId(R.id.settingsSaveAndCloseButton)).perform(click())
|
||||||
|
|
||||||
|
onView(withId(R.id.settingsFragmentContainer)).check(matches(isDisplayed()))
|
||||||
|
onView(withId(R.id.settingsErrorText)).check(matches(withText("API key is required")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun settingsSaveSuccessReauthsAndRefreshesBoards() {
|
||||||
|
val fake = QueueBoardsApiClient()
|
||||||
|
fake.enqueueBoards(BoardsApiResult.Success(listOf(BoardSummary("1", "Alpha"))))
|
||||||
|
fake.enqueueBoards(BoardsApiResult.Success(listOf(BoardSummary("1", "Alpha"))))
|
||||||
|
fake.enqueueBoards(BoardsApiResult.Success(listOf(BoardSummary("2", "Beta"))))
|
||||||
|
MainActivity.dependencies.apiClientFactory = { fake }
|
||||||
|
MainActivity.dependencies.settingsApplyCoordinatorFactory = { _, sessionStore, apiClient, apiKeyStore ->
|
||||||
|
object : SettingsApplyCoordinator(sessionStore, apiClient, apiKeyStore) {
|
||||||
|
override suspend fun apply(): SettingsApplyResult = SettingsApplyResult.SuccessCredentialChange
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val scenario = ActivityScenario.launch(BoardsActivity::class.java)
|
||||||
|
waitForIdle()
|
||||||
|
val baselineBoardsCalls = fake.listBoardsCalls
|
||||||
|
val baselineMeCalls = fake.getCurrentUserCalls
|
||||||
|
val baselineWorkspacesCalls = fake.listWorkspacesCalls
|
||||||
|
|
||||||
|
openSettingsFromDrawer()
|
||||||
|
onView(withId(R.id.settingsSaveAndCloseButton)).perform(click())
|
||||||
|
onView(withText("Beta")).check(matches(isDisplayed()))
|
||||||
|
|
||||||
|
assertTrue(fake.listBoardsCalls > baselineBoardsCalls)
|
||||||
|
assertTrue(fake.getCurrentUserCalls > baselineMeCalls)
|
||||||
|
assertTrue(fake.listWorkspacesCalls > baselineWorkspacesCalls)
|
||||||
|
|
||||||
|
scenario.onActivity {
|
||||||
|
assertTrue(it.supportFragmentManager.findFragmentByTag(SettingsDialogFragment.TAG) == null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun settingsSaveFailureRollsBackAndStaysOnBoards() {
|
||||||
|
MainActivity.dependencies.apiClientFactory = {
|
||||||
|
FakeBoardsApiClient(
|
||||||
|
boards = mutableListOf(BoardSummary("1", "Alpha")),
|
||||||
|
templates = emptyList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
MainActivity.dependencies.settingsApplyCoordinatorFactory = { _, sessionStore, apiClient, apiKeyStore ->
|
||||||
|
object : SettingsApplyCoordinator(sessionStore, apiClient, apiKeyStore) {
|
||||||
|
override suspend fun apply(): SettingsApplyResult {
|
||||||
|
return SettingsApplyResult.AuthError("Authentication failed. Check your API key.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val scenario = ActivityScenario.launch(BoardsActivity::class.java)
|
||||||
|
openSettingsFromDrawer()
|
||||||
|
onView(withId(R.id.settingsSaveAndCloseButton)).perform(click())
|
||||||
|
|
||||||
|
onView(withText("Alpha")).check(matches(isDisplayed()))
|
||||||
|
onView(withId(R.id.settingsFragmentContainer)).check(matches(isDisplayed()))
|
||||||
|
scenario.onActivity { activity ->
|
||||||
|
assertFalse(activity.isFinishing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun settingsApplySuccessThenRefreshFailureShowsRetryableBoardsError() {
|
||||||
|
val fake = QueueBoardsApiClient()
|
||||||
|
fake.enqueueBoards(BoardsApiResult.Success(listOf(BoardSummary("1", "Alpha"))))
|
||||||
|
fake.enqueueBoards(BoardsApiResult.Success(listOf(BoardSummary("1", "Alpha"))))
|
||||||
|
fake.enqueueBoards(BoardsApiResult.Failure("Cannot reach server. Check your connection and URL."))
|
||||||
|
MainActivity.dependencies.apiClientFactory = { fake }
|
||||||
|
MainActivity.dependencies.settingsApplyCoordinatorFactory = { _, sessionStore, apiClient, apiKeyStore ->
|
||||||
|
object : SettingsApplyCoordinator(sessionStore, apiClient, apiKeyStore) {
|
||||||
|
override suspend fun apply(): SettingsApplyResult = SettingsApplyResult.SuccessCredentialChange
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ActivityScenario.launch(BoardsActivity::class.java)
|
||||||
|
openSettingsFromDrawer()
|
||||||
|
onView(withId(R.id.settingsSaveAndCloseButton)).perform(click())
|
||||||
|
|
||||||
|
onView(withText("Cannot reach server. Check your connection and URL."))
|
||||||
|
.inRoot(isDialog())
|
||||||
|
.check(matches(isDisplayed()))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openSettingsFromDrawer() {
|
||||||
|
onView(withId(R.id.boardsDrawerLayout)).perform(DrawerActions.open())
|
||||||
|
onView(withId(R.id.drawerSettingsButton)).perform(click())
|
||||||
|
onView(withId(R.id.settingsFragmentContainer)).check(matches(isDisplayed()))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clickTopLeftCorner(): ViewAction {
|
||||||
|
val start = CoordinatesProvider { floatArrayOf(2f, 2f) }
|
||||||
|
val end = CoordinatesProvider { floatArrayOf(2f, 2f) }
|
||||||
|
return GeneralSwipeAction(Swipe.FAST, start, end, Press.FINGER)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun waitForIdle() {
|
||||||
|
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
|
||||||
|
}
|
||||||
|
|
||||||
private fun swipeFromLeftEdge(): ViewAction {
|
private fun swipeFromLeftEdge(): ViewAction {
|
||||||
val start = CoordinatesProvider { view -> floatArrayOf(5f, view.height * 0.5f) }
|
val start = CoordinatesProvider { view -> floatArrayOf(5f, view.height * 0.5f) }
|
||||||
val end = CoordinatesProvider { view -> floatArrayOf(view.width * 0.6f, view.height * 0.5f) }
|
val end = CoordinatesProvider { view -> floatArrayOf(view.width * 0.6f, view.height * 0.5f) }
|
||||||
@@ -486,4 +704,73 @@ class BoardsFlowTest {
|
|||||||
return BoardsApiResult.Failure("Not needed in boards flow tests")
|
return BoardsApiResult.Failure("Not needed in boards flow tests")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private class QueueBoardsApiClient : KanbnApiClient {
|
||||||
|
private val boardsResponses: ArrayDeque<BoardsApiResult<List<BoardSummary>>> = ArrayDeque()
|
||||||
|
var listBoardsCalls: Int = 0
|
||||||
|
var getCurrentUserCalls: Int = 0
|
||||||
|
var listWorkspacesCalls: Int = 0
|
||||||
|
|
||||||
|
fun enqueueBoards(result: BoardsApiResult<List<BoardSummary>>) {
|
||||||
|
boardsResponses.addLast(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success
|
||||||
|
|
||||||
|
override suspend fun getCurrentUser(baseUrl: String, apiKey: String): BoardsApiResult<DrawerProfile> {
|
||||||
|
getCurrentUserCalls += 1
|
||||||
|
return BoardsApiResult.Success(DrawerProfile("Alice", "alice@example.com"))
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun listWorkspaces(
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
): BoardsApiResult<List<WorkspaceSummary>> {
|
||||||
|
listWorkspacesCalls += 1
|
||||||
|
return BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun listBoards(
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
workspaceId: String,
|
||||||
|
): BoardsApiResult<List<BoardSummary>> {
|
||||||
|
listBoardsCalls += 1
|
||||||
|
return if (boardsResponses.isEmpty()) {
|
||||||
|
BoardsApiResult.Success(emptyList())
|
||||||
|
} else {
|
||||||
|
boardsResponses.removeFirst()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ 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.boards.DrawerDataErrorCode
|
||||||
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailActivity
|
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailActivity
|
||||||
|
import space.hackenslacker.kanbn4droid.app.settings.SettingsDialogFragment
|
||||||
|
|
||||||
class BoardsActivity : AppCompatActivity() {
|
class BoardsActivity : AppCompatActivity() {
|
||||||
private lateinit var sessionStore: SessionStore
|
private lateinit var sessionStore: SessionStore
|
||||||
@@ -71,6 +72,7 @@ class BoardsActivity : AppCompatActivity() {
|
|||||||
private var hasRequestedInitialDrawerLoad = false
|
private var hasRequestedInitialDrawerLoad = false
|
||||||
private var lastDrawerSwitchInFlight = false
|
private var lastDrawerSwitchInFlight = false
|
||||||
private var pendingWorkspaceSelectionId: String? = null
|
private var pendingWorkspaceSelectionId: String? = null
|
||||||
|
private var pendingOpenSettingsAfterDrawerClose = false
|
||||||
|
|
||||||
private val viewModel: BoardsViewModel by viewModels {
|
private val viewModel: BoardsViewModel by viewModels {
|
||||||
BoardsViewModel.Factory(
|
BoardsViewModel.Factory(
|
||||||
@@ -94,6 +96,7 @@ class BoardsActivity : AppCompatActivity() {
|
|||||||
setupRecycler()
|
setupRecycler()
|
||||||
setupInteractions()
|
setupInteractions()
|
||||||
observeViewModel()
|
observeViewModel()
|
||||||
|
observeSettingsResult()
|
||||||
|
|
||||||
viewModel.loadBoards()
|
viewModel.loadBoards()
|
||||||
}
|
}
|
||||||
@@ -156,7 +159,13 @@ class BoardsActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
drawerSettingsButton.setOnClickListener {
|
drawerSettingsButton.setOnClickListener {
|
||||||
|
sessionStore.initializeDraftsFromCommitted()
|
||||||
|
if (drawerLayout.isDrawerOpen(GravityCompat.START)) {
|
||||||
|
pendingOpenSettingsAfterDrawerClose = true
|
||||||
drawerLayout.closeDrawer(GravityCompat.START)
|
drawerLayout.closeDrawer(GravityCompat.START)
|
||||||
|
} else {
|
||||||
|
showSettingsDialog()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
drawerLogoutButton.setOnClickListener {
|
drawerLogoutButton.setOnClickListener {
|
||||||
@@ -170,6 +179,16 @@ class BoardsActivity : AppCompatActivity() {
|
|||||||
viewModel.loadDrawerDataIfStale()
|
viewModel.loadDrawerDataIfStale()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onDrawerClosed(drawerView: View) {
|
||||||
|
if (drawerView.id != R.id.boardsDrawerContent) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (pendingOpenSettingsAfterDrawerClose) {
|
||||||
|
pendingOpenSettingsAfterDrawerClose = false
|
||||||
|
showSettingsDialog()
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -385,6 +404,33 @@ class BoardsActivity : AppCompatActivity() {
|
|||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun observeSettingsResult() {
|
||||||
|
supportFragmentManager.setFragmentResultListener(
|
||||||
|
SettingsDialogFragment.REQUEST_KEY_SETTINGS_APPLIED,
|
||||||
|
this,
|
||||||
|
) { _, bundle ->
|
||||||
|
val credentialsChanged = bundle.getBoolean(SettingsDialogFragment.RESULT_KEY_CREDENTIALS_CHANGED)
|
||||||
|
val themeChanged = bundle.getBoolean(SettingsDialogFragment.RESULT_KEY_THEME_CHANGED)
|
||||||
|
viewModel.onSettingsApplied(
|
||||||
|
credentialsChanged = credentialsChanged,
|
||||||
|
themeChanged = themeChanged,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showSettingsDialog() {
|
||||||
|
if (supportFragmentManager.findFragmentByTag(SettingsDialogFragment.TAG) != null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
SettingsDialogFragment
|
||||||
|
.newInstance(
|
||||||
|
sessionStore = sessionStore,
|
||||||
|
apiClient = apiClient,
|
||||||
|
apiKeyStore = apiKeyStore,
|
||||||
|
)
|
||||||
|
.show(supportFragmentManager, SettingsDialogFragment.TAG)
|
||||||
|
}
|
||||||
|
|
||||||
private fun navigateToBoard(board: BoardSummary) {
|
private fun navigateToBoard(board: BoardSummary) {
|
||||||
startActivity(
|
startActivity(
|
||||||
Intent(this, BoardDetailActivity::class.java)
|
Intent(this, BoardDetailActivity::class.java)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import space.hackenslacker.kanbn4droid.app.auth.AuthResult
|
|||||||
import space.hackenslacker.kanbn4droid.app.auth.HttpKanbnApiClient
|
import space.hackenslacker.kanbn4droid.app.auth.HttpKanbnApiClient
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
|
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.PreferencesApiKeyStore
|
import space.hackenslacker.kanbn4droid.app.auth.PreferencesApiKeyStore
|
||||||
|
import space.hackenslacker.kanbn4droid.app.auth.SettingsApplyCoordinator
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.SessionPreferences
|
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.auth.UrlNormalizer
|
import space.hackenslacker.kanbn4droid.app.auth.UrlNormalizer
|
||||||
@@ -225,10 +226,12 @@ class TestDependencies {
|
|||||||
var sessionStoreFactory: ((AppCompatActivity) -> SessionStore)? = null
|
var sessionStoreFactory: ((AppCompatActivity) -> SessionStore)? = null
|
||||||
var apiKeyStoreFactory: ((AppCompatActivity) -> ApiKeyStore)? = null
|
var apiKeyStoreFactory: ((AppCompatActivity) -> ApiKeyStore)? = null
|
||||||
var apiClientFactory: (() -> KanbnApiClient)? = null
|
var apiClientFactory: (() -> KanbnApiClient)? = null
|
||||||
|
var settingsApplyCoordinatorFactory: ((AppCompatActivity, SessionStore, KanbnApiClient, ApiKeyStore) -> SettingsApplyCoordinator)? = null
|
||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
sessionStoreFactory = null
|
sessionStoreFactory = null
|
||||||
apiKeyStoreFactory = null
|
apiKeyStoreFactory = null
|
||||||
apiClientFactory = null
|
apiClientFactory = null
|
||||||
|
settingsApplyCoordinatorFactory = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ sealed interface SettingsApplyResult {
|
|||||||
data class NetworkError(val message: String) : SettingsApplyResult
|
data class NetworkError(val message: String) : SettingsApplyResult
|
||||||
}
|
}
|
||||||
|
|
||||||
class SettingsApplyCoordinator(
|
open class SettingsApplyCoordinator(
|
||||||
private val sessionStore: SessionStore,
|
private val sessionStore: SessionStore,
|
||||||
private val apiClient: KanbnApiClient,
|
private val apiClient: KanbnApiClient,
|
||||||
private val apiKeyStore: ApiKeyStore,
|
private val apiKeyStore: ApiKeyStore,
|
||||||
) {
|
) {
|
||||||
suspend fun apply(): SettingsApplyResult {
|
open suspend fun apply(): SettingsApplyResult {
|
||||||
val snapshot = try {
|
val snapshot = try {
|
||||||
LastKnownGoodSnapshot.capture(sessionStore, apiKeyStore)
|
LastKnownGoodSnapshot.capture(sessionStore, apiKeyStore)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
|
|||||||
@@ -129,6 +129,14 @@ class BoardsViewModel(
|
|||||||
fetchBoards(initial = false, refresh = true)
|
fetchBoards(initial = false, refresh = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onSettingsApplied(credentialsChanged: Boolean, themeChanged: Boolean) {
|
||||||
|
if (!credentialsChanged) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fetchDrawerData()
|
||||||
|
fetchBoards(initial = false, refresh = true)
|
||||||
|
}
|
||||||
|
|
||||||
fun loadTemplatesIfNeeded() {
|
fun loadTemplatesIfNeeded() {
|
||||||
val current = _uiState.value
|
val current = _uiState.value
|
||||||
if (current.templates.isNotEmpty() || current.isTemplatesLoading) {
|
if (current.templates.isNotEmpty() || current.isTemplatesLoading) {
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package space.hackenslacker.kanbn4droid.app.settings
|
||||||
|
|
||||||
|
import android.app.Dialog
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.widget.Button
|
||||||
|
import android.widget.ProgressBar
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
|
import androidx.core.os.bundleOf
|
||||||
|
import androidx.fragment.app.DialogFragment
|
||||||
|
import androidx.fragment.app.commitNow
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import space.hackenslacker.kanbn4droid.app.MainActivity
|
||||||
|
import space.hackenslacker.kanbn4droid.app.R
|
||||||
|
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
|
||||||
|
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
|
||||||
|
import space.hackenslacker.kanbn4droid.app.auth.SettingsApplyCoordinator
|
||||||
|
import space.hackenslacker.kanbn4droid.app.auth.SettingsApplyResult
|
||||||
|
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
|
||||||
|
|
||||||
|
class SettingsDialogFragment(
|
||||||
|
internal var sessionStore: SessionStore? = null,
|
||||||
|
internal var apiClient: KanbnApiClient? = null,
|
||||||
|
internal var apiKeyStore: ApiKeyStore? = null,
|
||||||
|
internal var coordinator: SettingsApplyCoordinator? = null,
|
||||||
|
) : DialogFragment() {
|
||||||
|
private var progress: ProgressBar? = null
|
||||||
|
private var saveButton: Button? = null
|
||||||
|
private var errorText: TextView? = null
|
||||||
|
private var controlsEnabled: Boolean = true
|
||||||
|
|
||||||
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
|
val resolvedSessionStore = requireNotNull(sessionStore) { "SessionStore is required" }
|
||||||
|
val resolvedApiClient = requireNotNull(apiClient) { "KanbnApiClient is required" }
|
||||||
|
val resolvedApiKeyStore = requireNotNull(apiKeyStore) { "ApiKeyStore is required" }
|
||||||
|
val dialogView = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_settings, null)
|
||||||
|
|
||||||
|
childFragmentManager.commitNow {
|
||||||
|
replace(
|
||||||
|
R.id.settingsFragmentContainer,
|
||||||
|
SettingsPreferencesFragment(),
|
||||||
|
SETTINGS_PREFS_TAG,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
progress = dialogView.findViewById(R.id.settingsApplyProgress)
|
||||||
|
errorText = dialogView.findViewById(R.id.settingsErrorText)
|
||||||
|
saveButton = dialogView.findViewById(R.id.settingsSaveAndCloseButton)
|
||||||
|
|
||||||
|
val dialog = MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setTitle(R.string.drawer_settings)
|
||||||
|
.setView(dialogView)
|
||||||
|
.setCancelable(false)
|
||||||
|
.create()
|
||||||
|
|
||||||
|
isCancelable = false
|
||||||
|
dialog.setCanceledOnTouchOutside(false)
|
||||||
|
|
||||||
|
dialog.setOnShowListener {
|
||||||
|
saveButton?.setOnClickListener {
|
||||||
|
onSaveClicked(resolvedSessionStore, resolvedApiClient, resolvedApiKeyStore)
|
||||||
|
}
|
||||||
|
setApplyInProgress(false)
|
||||||
|
}
|
||||||
|
return dialog
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onSaveClicked(
|
||||||
|
sessionStore: SessionStore,
|
||||||
|
apiClient: KanbnApiClient,
|
||||||
|
apiKeyStore: ApiKeyStore,
|
||||||
|
) {
|
||||||
|
if (!controlsEnabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val resolvedCoordinator = coordinator
|
||||||
|
?: MainActivity.dependencies.settingsApplyCoordinatorFactory?.invoke(
|
||||||
|
requireActivity() as androidx.appcompat.app.AppCompatActivity,
|
||||||
|
sessionStore,
|
||||||
|
apiClient,
|
||||||
|
apiKeyStore,
|
||||||
|
)
|
||||||
|
?: SettingsApplyCoordinator(sessionStore, apiClient, apiKeyStore)
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
setApplyInProgress(true)
|
||||||
|
val previousTheme = sessionStore.getThemeMode()
|
||||||
|
when (val result = resolvedCoordinator.apply()) {
|
||||||
|
is SettingsApplyResult.SuccessCredentialChange -> {
|
||||||
|
val themeChanged = sessionStore.getThemeMode() != previousTheme
|
||||||
|
if (themeChanged) {
|
||||||
|
applyThemeFrom(sessionStore.getThemeMode())
|
||||||
|
}
|
||||||
|
parentFragmentManager.setFragmentResult(
|
||||||
|
REQUEST_KEY_SETTINGS_APPLIED,
|
||||||
|
bundleOf(
|
||||||
|
RESULT_KEY_CREDENTIALS_CHANGED to true,
|
||||||
|
RESULT_KEY_THEME_CHANGED to themeChanged,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
dismissAllowingStateLoss()
|
||||||
|
}
|
||||||
|
|
||||||
|
is SettingsApplyResult.SuccessNoCredentialChange -> {
|
||||||
|
if (result.themeChanged) {
|
||||||
|
applyThemeFrom(sessionStore.getThemeMode())
|
||||||
|
}
|
||||||
|
parentFragmentManager.setFragmentResult(
|
||||||
|
REQUEST_KEY_SETTINGS_APPLIED,
|
||||||
|
bundleOf(
|
||||||
|
RESULT_KEY_CREDENTIALS_CHANGED to false,
|
||||||
|
RESULT_KEY_THEME_CHANGED to result.themeChanged,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
dismissAllowingStateLoss()
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsApplyResult.NoChanges -> {
|
||||||
|
parentFragmentManager.setFragmentResult(
|
||||||
|
REQUEST_KEY_SETTINGS_APPLIED,
|
||||||
|
bundleOf(
|
||||||
|
RESULT_KEY_CREDENTIALS_CHANGED to false,
|
||||||
|
RESULT_KEY_THEME_CHANGED to false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
dismissAllowingStateLoss()
|
||||||
|
}
|
||||||
|
|
||||||
|
is SettingsApplyResult.ValidationError -> {
|
||||||
|
setApplyInProgress(false)
|
||||||
|
showError(result.message)
|
||||||
|
findPreferencesFragment()?.focusField(result.field)
|
||||||
|
}
|
||||||
|
|
||||||
|
is SettingsApplyResult.AuthError -> {
|
||||||
|
setApplyInProgress(false)
|
||||||
|
showError(result.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
is SettingsApplyResult.NetworkError -> {
|
||||||
|
setApplyInProgress(false)
|
||||||
|
showError(result.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setApplyInProgress(inProgress: Boolean) {
|
||||||
|
controlsEnabled = !inProgress
|
||||||
|
progress?.visibility = if (inProgress) android.view.View.VISIBLE else android.view.View.GONE
|
||||||
|
saveButton?.isEnabled = !inProgress
|
||||||
|
findPreferencesFragment()?.preferenceScreen?.isEnabled = !inProgress
|
||||||
|
if (inProgress) {
|
||||||
|
errorText?.visibility = android.view.View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showError(message: String) {
|
||||||
|
errorText?.text = message
|
||||||
|
errorText?.visibility = android.view.View.VISIBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findPreferencesFragment(): SettingsPreferencesFragment? {
|
||||||
|
return childFragmentManager.findFragmentByTag(SETTINGS_PREFS_TAG) as? SettingsPreferencesFragment
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyThemeFrom(themeMode: String) {
|
||||||
|
val mode = when (themeMode.lowercase()) {
|
||||||
|
"light" -> AppCompatDelegate.MODE_NIGHT_NO
|
||||||
|
"dark" -> AppCompatDelegate.MODE_NIGHT_YES
|
||||||
|
else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
||||||
|
}
|
||||||
|
AppCompatDelegate.setDefaultNightMode(mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TAG: String = "settings_dialog"
|
||||||
|
const val REQUEST_KEY_SETTINGS_APPLIED: String = "settings_applied_result"
|
||||||
|
const val RESULT_KEY_CREDENTIALS_CHANGED: String = "credentials_changed"
|
||||||
|
const val RESULT_KEY_THEME_CHANGED: String = "theme_changed"
|
||||||
|
|
||||||
|
private const val SETTINGS_PREFS_TAG: String = "settings_preferences_fragment"
|
||||||
|
|
||||||
|
fun newInstance(
|
||||||
|
sessionStore: SessionStore,
|
||||||
|
apiClient: KanbnApiClient,
|
||||||
|
apiKeyStore: ApiKeyStore,
|
||||||
|
coordinator: SettingsApplyCoordinator? = null,
|
||||||
|
): SettingsDialogFragment {
|
||||||
|
return SettingsDialogFragment(
|
||||||
|
sessionStore = sessionStore,
|
||||||
|
apiClient = apiClient,
|
||||||
|
apiKeyStore = apiKeyStore,
|
||||||
|
coordinator = coordinator,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package space.hackenslacker.kanbn4droid.app.settings
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.text.InputType
|
||||||
|
import androidx.preference.EditTextPreference
|
||||||
|
import androidx.preference.ListPreference
|
||||||
|
import androidx.preference.Preference
|
||||||
|
import androidx.preference.PreferenceFragmentCompat
|
||||||
|
import space.hackenslacker.kanbn4droid.app.R
|
||||||
|
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
|
||||||
|
|
||||||
|
class SettingsPreferencesFragment : PreferenceFragmentCompat() {
|
||||||
|
private lateinit var sessionStore: SessionStore
|
||||||
|
|
||||||
|
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||||
|
sessionStore = requireNotNull((requireParentFragment() as SettingsDialogFragment).sessionStore)
|
||||||
|
setPreferencesFromResource(R.xml.settings_preferences, rootKey)
|
||||||
|
bindThemePreference()
|
||||||
|
bindBaseUrlPreference()
|
||||||
|
bindApiKeyPreference()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun focusField(field: String) {
|
||||||
|
when (field) {
|
||||||
|
FIELD_BASE_URL -> findPreference<Preference>(KEY_BASE_URL)?.performClick()
|
||||||
|
FIELD_API_KEY -> findPreference<Preference>(KEY_API_KEY)?.performClick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindThemePreference() {
|
||||||
|
val pref = findPreference<ListPreference>(KEY_THEME) ?: return
|
||||||
|
pref.value = sessionStore.getDraftThemeMode().ifBlank { "system" }
|
||||||
|
pref.setOnPreferenceChangeListener { _, newValue ->
|
||||||
|
sessionStore.saveDraftThemeMode(newValue?.toString().orEmpty())
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindBaseUrlPreference() {
|
||||||
|
val pref = findPreference<EditTextPreference>(KEY_BASE_URL) ?: return
|
||||||
|
pref.text = sessionStore.getDraftBaseUrl().orEmpty()
|
||||||
|
pref.setOnPreferenceChangeListener { _, newValue ->
|
||||||
|
sessionStore.saveDraftBaseUrl(newValue?.toString().orEmpty())
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindApiKeyPreference() {
|
||||||
|
val pref = findPreference<EditTextPreference>(KEY_API_KEY) ?: return
|
||||||
|
pref.setOnBindEditTextListener { editText ->
|
||||||
|
editText.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||||
|
}
|
||||||
|
pref.text = sessionStore.getDraftApiKey().orEmpty()
|
||||||
|
pref.summaryProvider = Preference.SummaryProvider<EditTextPreference> { preference ->
|
||||||
|
if (preference.text.isNullOrBlank()) {
|
||||||
|
getString(R.string.settings_api_key_summary_not_configured)
|
||||||
|
} else {
|
||||||
|
getString(R.string.settings_api_key_summary_configured)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pref.setOnPreferenceChangeListener { _, newValue ->
|
||||||
|
sessionStore.saveDraftApiKey(newValue?.toString().orEmpty())
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val KEY_THEME: String = "pref_theme_draft"
|
||||||
|
const val KEY_BASE_URL: String = "pref_base_url_draft"
|
||||||
|
const val KEY_API_KEY: String = "pref_api_key_draft"
|
||||||
|
|
||||||
|
const val FIELD_BASE_URL: String = "baseUrl"
|
||||||
|
const val FIELD_API_KEY: String = "apiKey"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,14 @@
|
|||||||
android:layout_marginTop="12dp"
|
android:layout_marginTop="12dp"
|
||||||
android:visibility="gone" />
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/settingsErrorText"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
<com.google.android.material.button.MaterialButton
|
||||||
android:id="@+id/settingsSaveAndCloseButton"
|
android:id="@+id/settingsSaveAndCloseButton"
|
||||||
style="@style/Widget.Material3.Button"
|
style="@style/Widget.Material3.Button"
|
||||||
|
|||||||
@@ -95,6 +95,7 @@
|
|||||||
<string name="settings_base_url_title">Base URL</string>
|
<string name="settings_base_url_title">Base URL</string>
|
||||||
<string name="settings_api_key_title">API key</string>
|
<string name="settings_api_key_title">API key</string>
|
||||||
<string name="settings_api_key_summary_not_configured">Not configured</string>
|
<string name="settings_api_key_summary_not_configured">Not configured</string>
|
||||||
|
<string name="settings_api_key_summary_configured">Configured</string>
|
||||||
<string name="settings_save_and_close">Save and close</string>
|
<string name="settings_save_and_close">Save and close</string>
|
||||||
|
|
||||||
<string-array name="settings_theme_entries">
|
<string-array name="settings_theme_entries">
|
||||||
|
|||||||
@@ -423,6 +423,61 @@ class BoardsViewModelTest {
|
|||||||
assertEquals(1, api.listBoardsCalls)
|
assertEquals(1, api.listBoardsCalls)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun onSettingsAppliedWithCredentialsChangedRefreshesBoardsAndDrawer() = runTest {
|
||||||
|
val api = FakeBoardsApiClient().apply {
|
||||||
|
usersMeResult = BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = "alice@example.com"))
|
||||||
|
workspacesResult = BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
|
||||||
|
listBoardsResult = BoardsApiResult.Success(listOf(BoardSummary("1", "Alpha")))
|
||||||
|
}
|
||||||
|
val viewModel = newViewModel(api)
|
||||||
|
|
||||||
|
viewModel.onSettingsApplied(credentialsChanged = true, themeChanged = false)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertTrue(api.listBoardsCalls >= 1)
|
||||||
|
assertEquals(1, api.getCurrentUserCalls)
|
||||||
|
assertEquals(1, api.listWorkspacesCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun onSettingsAppliedWithThemeOnlySkipsNetworkRefresh() = runTest {
|
||||||
|
val api = FakeBoardsApiClient().apply {
|
||||||
|
usersMeResult = BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = "alice@example.com"))
|
||||||
|
workspacesResult = BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
|
||||||
|
listBoardsResult = BoardsApiResult.Success(listOf(BoardSummary("1", "Alpha")))
|
||||||
|
}
|
||||||
|
val viewModel = newViewModel(api)
|
||||||
|
|
||||||
|
viewModel.onSettingsApplied(credentialsChanged = false, themeChanged = true)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(0, api.listBoardsCalls)
|
||||||
|
assertEquals(0, api.getCurrentUserCalls)
|
||||||
|
assertEquals(0, api.listWorkspacesCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun onSettingsAppliedRefreshFailureKeepsCredentialsAndEmitsRetryableError() = runTest {
|
||||||
|
val api = FakeBoardsApiClient().apply {
|
||||||
|
usersMeResult = BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = "alice@example.com"))
|
||||||
|
workspacesResult = BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
|
||||||
|
listBoardsResult = BoardsApiResult.Failure("Cannot reach server. Check your connection and URL.")
|
||||||
|
}
|
||||||
|
val sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-1")
|
||||||
|
val viewModel = newViewModel(api, sessionStore = sessionStore)
|
||||||
|
|
||||||
|
val eventDeferred = async { viewModel.events.first() }
|
||||||
|
viewModel.onSettingsApplied(credentialsChanged = true, themeChanged = false)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val event = eventDeferred.await()
|
||||||
|
assertTrue(event is BoardsUiEvent.ShowServerError)
|
||||||
|
assertEquals("https://kan.bn/", sessionStore.getBaseUrl())
|
||||||
|
assertEquals("ws-1", sessionStore.getWorkspaceId())
|
||||||
|
assertTrue(api.listBoardsCalls >= 1)
|
||||||
|
}
|
||||||
|
|
||||||
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"),
|
||||||
|
|||||||
Reference in New Issue
Block a user