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.espresso.Espresso.onView
|
||||
import androidx.test.espresso.Espresso.pressBack
|
||||
import androidx.test.espresso.ViewAction
|
||||
import androidx.test.espresso.contrib.DrawerActions
|
||||
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.hasExtra
|
||||
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.isRoot
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withId
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.core.view.GravityCompat
|
||||
import androidx.drawerlayout.widget.DrawerLayout
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
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.KanbnApiClient
|
||||
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.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 space.hackenslacker.kanbn4droid.app.settings.SettingsDialogFragment
|
||||
import java.util.ArrayDeque
|
||||
import android.view.View
|
||||
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 {
|
||||
val start = CoordinatesProvider { view -> floatArrayOf(5f, 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")
|
||||
}
|
||||
}
|
||||
|
||||
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.DrawerDataErrorCode
|
||||
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailActivity
|
||||
import space.hackenslacker.kanbn4droid.app.settings.SettingsDialogFragment
|
||||
|
||||
class BoardsActivity : AppCompatActivity() {
|
||||
private lateinit var sessionStore: SessionStore
|
||||
@@ -71,6 +72,7 @@ class BoardsActivity : AppCompatActivity() {
|
||||
private var hasRequestedInitialDrawerLoad = false
|
||||
private var lastDrawerSwitchInFlight = false
|
||||
private var pendingWorkspaceSelectionId: String? = null
|
||||
private var pendingOpenSettingsAfterDrawerClose = false
|
||||
|
||||
private val viewModel: BoardsViewModel by viewModels {
|
||||
BoardsViewModel.Factory(
|
||||
@@ -94,6 +96,7 @@ class BoardsActivity : AppCompatActivity() {
|
||||
setupRecycler()
|
||||
setupInteractions()
|
||||
observeViewModel()
|
||||
observeSettingsResult()
|
||||
|
||||
viewModel.loadBoards()
|
||||
}
|
||||
@@ -156,7 +159,13 @@ class BoardsActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
drawerSettingsButton.setOnClickListener {
|
||||
sessionStore.initializeDraftsFromCommitted()
|
||||
if (drawerLayout.isDrawerOpen(GravityCompat.START)) {
|
||||
pendingOpenSettingsAfterDrawerClose = true
|
||||
drawerLayout.closeDrawer(GravityCompat.START)
|
||||
} else {
|
||||
showSettingsDialog()
|
||||
}
|
||||
}
|
||||
|
||||
drawerLogoutButton.setOnClickListener {
|
||||
@@ -170,6 +179,16 @@ class BoardsActivity : AppCompatActivity() {
|
||||
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()
|
||||
}
|
||||
|
||||
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) {
|
||||
startActivity(
|
||||
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.KanbnApiClient
|
||||
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.SessionStore
|
||||
import space.hackenslacker.kanbn4droid.app.auth.UrlNormalizer
|
||||
@@ -225,10 +226,12 @@ class TestDependencies {
|
||||
var sessionStoreFactory: ((AppCompatActivity) -> SessionStore)? = null
|
||||
var apiKeyStoreFactory: ((AppCompatActivity) -> ApiKeyStore)? = null
|
||||
var apiClientFactory: (() -> KanbnApiClient)? = null
|
||||
var settingsApplyCoordinatorFactory: ((AppCompatActivity, SessionStore, KanbnApiClient, ApiKeyStore) -> SettingsApplyCoordinator)? = null
|
||||
|
||||
fun clear() {
|
||||
sessionStoreFactory = null
|
||||
apiKeyStoreFactory = null
|
||||
apiClientFactory = null
|
||||
settingsApplyCoordinatorFactory = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,12 @@ sealed interface SettingsApplyResult {
|
||||
data class NetworkError(val message: String) : SettingsApplyResult
|
||||
}
|
||||
|
||||
class SettingsApplyCoordinator(
|
||||
open class SettingsApplyCoordinator(
|
||||
private val sessionStore: SessionStore,
|
||||
private val apiClient: KanbnApiClient,
|
||||
private val apiKeyStore: ApiKeyStore,
|
||||
) {
|
||||
suspend fun apply(): SettingsApplyResult {
|
||||
open suspend fun apply(): SettingsApplyResult {
|
||||
val snapshot = try {
|
||||
LastKnownGoodSnapshot.capture(sessionStore, apiKeyStore)
|
||||
} catch (_: Exception) {
|
||||
|
||||
@@ -129,6 +129,14 @@ class BoardsViewModel(
|
||||
fetchBoards(initial = false, refresh = true)
|
||||
}
|
||||
|
||||
fun onSettingsApplied(credentialsChanged: Boolean, themeChanged: Boolean) {
|
||||
if (!credentialsChanged) {
|
||||
return
|
||||
}
|
||||
fetchDrawerData()
|
||||
fetchBoards(initial = false, refresh = true)
|
||||
}
|
||||
|
||||
fun loadTemplatesIfNeeded() {
|
||||
val current = _uiState.value
|
||||
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: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
|
||||
android:id="@+id/settingsSaveAndCloseButton"
|
||||
style="@style/Widget.Material3.Button"
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
<string name="settings_base_url_title">Base URL</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_configured">Configured</string>
|
||||
<string name="settings_save_and_close">Save and close</string>
|
||||
|
||||
<string-array name="settings_theme_entries">
|
||||
|
||||
@@ -423,6 +423,61 @@ class BoardsViewModelTest {
|
||||
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(
|
||||
apiClient: FakeBoardsApiClient,
|
||||
sessionStore: InMemorySessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-1"),
|
||||
|
||||
Reference in New Issue
Block a user