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

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.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)

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

View File

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

View File

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

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

View File

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

View File

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