feat: add in-place settings dialog with immediate apply

This commit is contained in:
2026-03-18 12:21:25 -04:00
parent 8d847ae4ea
commit f3349f5dee
10 changed files with 688 additions and 3 deletions

View File

@@ -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")
}
}
} }

View File

@@ -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 {
drawerLayout.closeDrawer(GravityCompat.START) sessionStore.initializeDraftsFromCommitted()
if (drawerLayout.isDrawerOpen(GravityCompat.START)) {
pendingOpenSettingsAfterDrawerClose = true
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)

View File

@@ -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
} }
} }

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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,
)
}
}
}

View File

@@ -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"
}
}

View File

@@ -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"

View File

@@ -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">

View File

@@ -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"),