diff --git a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardsFlowTest.kt b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardsFlowTest.kt index 304d717..e762d88 100644 --- a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardsFlowTest.kt +++ b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardsFlowTest.kt @@ -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(R.id.boardsDrawerLayout) + assertFalse(drawer.isDrawerOpen(GravityCompat.START)) + } + } + + @Test + fun settingsDialogDisablesControlsWhileApplyInProgress() { + val blocked = CompletableDeferred() + 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>> = ArrayDeque() + var listBoardsCalls: Int = 0 + var getCurrentUserCalls: Int = 0 + var listWorkspacesCalls: Int = 0 + + fun enqueueBoards(result: BoardsApiResult>) { + boardsResponses.addLast(result) + } + + override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success + + override suspend fun getCurrentUser(baseUrl: String, apiKey: String): BoardsApiResult { + getCurrentUserCalls += 1 + return BoardsApiResult.Success(DrawerProfile("Alice", "alice@example.com")) + } + + override suspend fun listWorkspaces( + baseUrl: String, + apiKey: String, + ): BoardsApiResult> { + listWorkspacesCalls += 1 + return BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main"))) + } + + override suspend fun listBoards( + baseUrl: String, + apiKey: String, + workspaceId: String, + ): BoardsApiResult> { + listBoardsCalls += 1 + return if (boardsResponses.isEmpty()) { + BoardsApiResult.Success(emptyList()) + } else { + boardsResponses.removeFirst() + } + } + + override suspend fun listBoardTemplates( + baseUrl: String, + apiKey: String, + workspaceId: String, + ): BoardsApiResult> { + return BoardsApiResult.Success(emptyList()) + } + + override suspend fun createBoard( + baseUrl: String, + apiKey: String, + workspaceId: String, + name: String, + templateId: String?, + ): BoardsApiResult { + return BoardsApiResult.Success(BoardSummary("new", name)) + } + + override suspend fun deleteBoard(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult { + return BoardsApiResult.Success(Unit) + } + + override suspend fun getLabelByPublicId( + baseUrl: String, + apiKey: String, + labelId: String, + ): BoardsApiResult { + return BoardsApiResult.Failure("Not needed in boards flow tests") + } + } } diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/BoardsActivity.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/BoardsActivity.kt index 8f1d0db..7754a5c 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/BoardsActivity.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/BoardsActivity.kt @@ -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 { - drawerLayout.closeDrawer(GravityCompat.START) + 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) diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/MainActivity.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/MainActivity.kt index 26a0c84..c70a4ad 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/MainActivity.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/MainActivity.kt @@ -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 } } diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/SettingsApplyCoordinator.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/SettingsApplyCoordinator.kt index 5c31c78..0ed2348 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/SettingsApplyCoordinator.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/SettingsApplyCoordinator.kt @@ -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) { diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModel.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModel.kt index d8a82ae..7b2100f 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModel.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModel.kt @@ -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) { diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/settings/SettingsDialogFragment.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/settings/SettingsDialogFragment.kt new file mode 100644 index 0000000..0aa76f5 --- /dev/null +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/settings/SettingsDialogFragment.kt @@ -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, + ) + } + } +} diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/settings/SettingsPreferencesFragment.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/settings/SettingsPreferencesFragment.kt new file mode 100644 index 0000000..c1b2611 --- /dev/null +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/settings/SettingsPreferencesFragment.kt @@ -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(KEY_BASE_URL)?.performClick() + FIELD_API_KEY -> findPreference(KEY_API_KEY)?.performClick() + } + } + + private fun bindThemePreference() { + val pref = findPreference(KEY_THEME) ?: return + pref.value = sessionStore.getDraftThemeMode().ifBlank { "system" } + pref.setOnPreferenceChangeListener { _, newValue -> + sessionStore.saveDraftThemeMode(newValue?.toString().orEmpty()) + true + } + } + + private fun bindBaseUrlPreference() { + val pref = findPreference(KEY_BASE_URL) ?: return + pref.text = sessionStore.getDraftBaseUrl().orEmpty() + pref.setOnPreferenceChangeListener { _, newValue -> + sessionStore.saveDraftBaseUrl(newValue?.toString().orEmpty()) + true + } + } + + private fun bindApiKeyPreference() { + val pref = findPreference(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 { 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" + } +} diff --git a/app/src/main/res/layout/dialog_settings.xml b/app/src/main/res/layout/dialog_settings.xml index 2b35f8c..ecd4cd9 100644 --- a/app/src/main/res/layout/dialog_settings.xml +++ b/app/src/main/res/layout/dialog_settings.xml @@ -29,6 +29,14 @@ android:layout_marginTop="12dp" android:visibility="gone" /> + + Base URL API key Not configured + Configured Save and close diff --git a/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModelTest.kt b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModelTest.kt index fbdd9f6..9513d14 100644 --- a/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModelTest.kt +++ b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModelTest.kt @@ -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"),