fix: make settings dialog recreation-safe

This commit is contained in:
2026-03-18 13:32:20 -04:00
parent f3349f5dee
commit 769b893959
5 changed files with 82 additions and 40 deletions

View File

@@ -492,6 +492,24 @@ class BoardsFlowTest {
.check(matches(isDisplayed())) .check(matches(isDisplayed()))
} }
@Test
fun settingsDialogSurvivesActivityRecreate() {
MainActivity.dependencies.apiClientFactory = {
FakeBoardsApiClient(
boards = mutableListOf(BoardSummary("1", "Alpha")),
templates = emptyList(),
)
}
val scenario = ActivityScenario.launch(BoardsActivity::class.java)
openSettingsFromDrawer()
scenario.recreate()
onView(withId(R.id.settingsFragmentContainer)).check(matches(isDisplayed()))
onView(withId(R.id.settingsSaveAndCloseButton)).check(matches(isDisplayed()))
}
private fun openSettingsFromDrawer() { private fun openSettingsFromDrawer() {
onView(withId(R.id.boardsDrawerLayout)).perform(DrawerActions.open()) onView(withId(R.id.boardsDrawerLayout)).perform(DrawerActions.open())
onView(withId(R.id.drawerSettingsButton)).perform(click()) onView(withId(R.id.drawerSettingsButton)).perform(click())

View File

@@ -49,6 +49,15 @@ class BoardsActivity : AppCompatActivity() {
private lateinit var apiKeyStore: ApiKeyStore private lateinit var apiKeyStore: ApiKeyStore
private lateinit var apiClient: KanbnApiClient private lateinit var apiClient: KanbnApiClient
internal val sessionStoreForSettingsDialog: SessionStore
get() = sessionStore
internal val apiKeyStoreForSettingsDialog: ApiKeyStore
get() = apiKeyStore
internal val apiClientForSettingsDialog: KanbnApiClient
get() = apiClient
private lateinit var swipeRefresh: SwipeRefreshLayout private lateinit var swipeRefresh: SwipeRefreshLayout
private lateinit var recyclerView: RecyclerView private lateinit var recyclerView: RecyclerView
private lateinit var emptyStateText: TextView private lateinit var emptyStateText: TextView
@@ -413,7 +422,6 @@ class BoardsActivity : AppCompatActivity() {
val themeChanged = bundle.getBoolean(SettingsDialogFragment.RESULT_KEY_THEME_CHANGED) val themeChanged = bundle.getBoolean(SettingsDialogFragment.RESULT_KEY_THEME_CHANGED)
viewModel.onSettingsApplied( viewModel.onSettingsApplied(
credentialsChanged = credentialsChanged, credentialsChanged = credentialsChanged,
themeChanged = themeChanged,
) )
} }
} }
@@ -423,11 +431,7 @@ class BoardsActivity : AppCompatActivity() {
return return
} }
SettingsDialogFragment SettingsDialogFragment
.newInstance( .newInstance()
sessionStore = sessionStore,
apiClient = apiClient,
apiKeyStore = apiKeyStore,
)
.show(supportFragmentManager, SettingsDialogFragment.TAG) .show(supportFragmentManager, SettingsDialogFragment.TAG)
} }

View File

@@ -129,7 +129,7 @@ class BoardsViewModel(
fetchBoards(initial = false, refresh = true) fetchBoards(initial = false, refresh = true)
} }
fun onSettingsApplied(credentialsChanged: Boolean, themeChanged: Boolean) { fun onSettingsApplied(credentialsChanged: Boolean) {
if (!credentialsChanged) { if (!credentialsChanged) {
return return
} }

View File

@@ -1,11 +1,13 @@
package space.hackenslacker.kanbn4droid.app.settings package space.hackenslacker.kanbn4droid.app.settings
import android.app.Dialog import android.app.Dialog
import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.widget.Button import android.widget.Button
import android.widget.ProgressBar import android.widget.ProgressBar
import android.widget.TextView import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
@@ -13,29 +15,36 @@ import androidx.fragment.app.commitNow
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import space.hackenslacker.kanbn4droid.app.BoardsActivity
import space.hackenslacker.kanbn4droid.app.MainActivity import space.hackenslacker.kanbn4droid.app.MainActivity
import space.hackenslacker.kanbn4droid.app.R import space.hackenslacker.kanbn4droid.app.R
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
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.SettingsApplyCoordinator import space.hackenslacker.kanbn4droid.app.auth.SettingsApplyCoordinator
import space.hackenslacker.kanbn4droid.app.auth.SettingsApplyResult import space.hackenslacker.kanbn4droid.app.auth.SettingsApplyResult
import space.hackenslacker.kanbn4droid.app.auth.SessionPreferences
import space.hackenslacker.kanbn4droid.app.auth.SessionStore import space.hackenslacker.kanbn4droid.app.auth.SessionStore
class SettingsDialogFragment( class SettingsDialogFragment : DialogFragment() {
internal var sessionStore: SessionStore? = null, lateinit var sessionStore: SessionStore
internal var apiClient: KanbnApiClient? = null, private set
internal var apiKeyStore: ApiKeyStore? = null,
internal var coordinator: SettingsApplyCoordinator? = null, private lateinit var apiClient: KanbnApiClient
) : DialogFragment() { private lateinit var apiKeyStore: ApiKeyStore
private var progress: ProgressBar? = null private var progress: ProgressBar? = null
private var saveButton: Button? = null private var saveButton: Button? = null
private var errorText: TextView? = null private var errorText: TextView? = null
private var controlsEnabled: Boolean = true private var controlsEnabled: Boolean = true
override fun onAttach(context: Context) {
super.onAttach(context)
resolveDependencies(context)
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { 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) val dialogView = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_settings, null)
childFragmentManager.commitNow { childFragmentManager.commitNow {
@@ -61,25 +70,21 @@ class SettingsDialogFragment(
dialog.setOnShowListener { dialog.setOnShowListener {
saveButton?.setOnClickListener { saveButton?.setOnClickListener {
onSaveClicked(resolvedSessionStore, resolvedApiClient, resolvedApiKeyStore) onSaveClicked()
} }
setApplyInProgress(false) setApplyInProgress(false)
} }
return dialog return dialog
} }
private fun onSaveClicked( private fun onSaveClicked() {
sessionStore: SessionStore,
apiClient: KanbnApiClient,
apiKeyStore: ApiKeyStore,
) {
if (!controlsEnabled) { if (!controlsEnabled) {
return return
} }
val resolvedCoordinator = coordinator val hostActivity = requireActivity() as AppCompatActivity
?: MainActivity.dependencies.settingsApplyCoordinatorFactory?.invoke( val resolvedCoordinator = MainActivity.dependencies.settingsApplyCoordinatorFactory?.invoke(
requireActivity() as androidx.appcompat.app.AppCompatActivity, hostActivity,
sessionStore, sessionStore,
apiClient, apiClient,
apiKeyStore, apiKeyStore,
@@ -177,6 +182,31 @@ class SettingsDialogFragment(
AppCompatDelegate.setDefaultNightMode(mode) AppCompatDelegate.setDefaultNightMode(mode)
} }
private fun resolveDependencies(context: Context) {
val host = activity as? BoardsActivity
if (host != null) {
sessionStore = host.sessionStoreForSettingsDialog
apiClient = host.apiClientForSettingsDialog
apiKeyStore = host.apiKeyStoreForSettingsDialog
return
}
val appCompatActivity = activity as? AppCompatActivity
if (appCompatActivity != null) {
sessionStore = MainActivity.dependencies.sessionStoreFactory?.invoke(appCompatActivity)
?: SessionPreferences(appCompatActivity.applicationContext)
apiClient = MainActivity.dependencies.apiClientFactory?.invoke()
?: HttpKanbnApiClient()
apiKeyStore = MainActivity.dependencies.apiKeyStoreFactory?.invoke(appCompatActivity)
?: PreferencesApiKeyStore(appCompatActivity)
return
}
sessionStore = SessionPreferences(context.applicationContext)
apiClient = HttpKanbnApiClient()
apiKeyStore = PreferencesApiKeyStore(context)
}
companion object { companion object {
const val TAG: String = "settings_dialog" const val TAG: String = "settings_dialog"
const val REQUEST_KEY_SETTINGS_APPLIED: String = "settings_applied_result" const val REQUEST_KEY_SETTINGS_APPLIED: String = "settings_applied_result"
@@ -185,18 +215,8 @@ class SettingsDialogFragment(
private const val SETTINGS_PREFS_TAG: String = "settings_preferences_fragment" private const val SETTINGS_PREFS_TAG: String = "settings_preferences_fragment"
fun newInstance( fun newInstance(): SettingsDialogFragment {
sessionStore: SessionStore, return SettingsDialogFragment()
apiClient: KanbnApiClient,
apiKeyStore: ApiKeyStore,
coordinator: SettingsApplyCoordinator? = null,
): SettingsDialogFragment {
return SettingsDialogFragment(
sessionStore = sessionStore,
apiClient = apiClient,
apiKeyStore = apiKeyStore,
coordinator = coordinator,
)
} }
} }
} }

View File

@@ -432,7 +432,7 @@ class BoardsViewModelTest {
} }
val viewModel = newViewModel(api) val viewModel = newViewModel(api)
viewModel.onSettingsApplied(credentialsChanged = true, themeChanged = false) viewModel.onSettingsApplied(credentialsChanged = true)
advanceUntilIdle() advanceUntilIdle()
assertTrue(api.listBoardsCalls >= 1) assertTrue(api.listBoardsCalls >= 1)
@@ -449,7 +449,7 @@ class BoardsViewModelTest {
} }
val viewModel = newViewModel(api) val viewModel = newViewModel(api)
viewModel.onSettingsApplied(credentialsChanged = false, themeChanged = true) viewModel.onSettingsApplied(credentialsChanged = false)
advanceUntilIdle() advanceUntilIdle()
assertEquals(0, api.listBoardsCalls) assertEquals(0, api.listBoardsCalls)
@@ -468,7 +468,7 @@ class BoardsViewModelTest {
val viewModel = newViewModel(api, sessionStore = sessionStore) val viewModel = newViewModel(api, sessionStore = sessionStore)
val eventDeferred = async { viewModel.events.first() } val eventDeferred = async { viewModel.events.first() }
viewModel.onSettingsApplied(credentialsChanged = true, themeChanged = false) viewModel.onSettingsApplied(credentialsChanged = true)
advanceUntilIdle() advanceUntilIdle()
val event = eventDeferred.await() val event = eventDeferred.await()