diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatBackgroundConnectionMenuItemTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatBackgroundConnectionMenuItemTest.kt new file mode 100644 index 00000000..7d0dffb4 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatBackgroundConnectionMenuItemTest.kt @@ -0,0 +1,47 @@ +package com.m57.hermescontrol.ui.chat + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsOff +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ChatBackgroundConnectionMenuItemTest { + @get:Rule + val compose = createComposeRule() + + @Test + fun menuReflectsStateAndTogglesInBothDirections() { + var enabled by mutableStateOf(false) + compose.setContent { + ChatBackgroundConnectionMenuItem( + checked = enabled, + onToggle = { enabled = it }, + ) + } + + compose.onNodeWithTag("chat_menu_keep_connected").assertIsDisplayed() + compose.onNodeWithTag("chat_menu_keep_connected_checkbox", useUnmergedTree = true).assertIsOff() + compose.onNodeWithTag("chat_menu_keep_connected").performClick() + compose.onNodeWithTag("chat_menu_keep_connected_checkbox", useUnmergedTree = true).assertIsOn() + compose.runOnIdle { assertEquals(true, enabled) } + + // The next opening reads the externally changed value rather than a stale menu snapshot. + compose.runOnIdle { enabled = false } + compose.onNodeWithTag("chat_menu_keep_connected_checkbox", useUnmergedTree = true).assertIsOff() + compose.runOnIdle { enabled = true } + compose.onNodeWithTag("chat_menu_keep_connected_checkbox", useUnmergedTree = true).performClick() + compose.onNodeWithTag("chat_menu_keep_connected_checkbox", useUnmergedTree = true).assertIsOff() + compose.runOnIdle { assertEquals(false, enabled) } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index ed645214..65e30861 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Search import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -117,6 +118,7 @@ import com.m57.hermescontrol.ui.common.CredentialWarningBanner import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.NavIcon import com.m57.hermescontrol.ui.model.components.ModelPickerDialog +import com.m57.hermescontrol.ui.settings.SettingsViewModel import com.m57.hermescontrol.util.ConnectorUrlValidator import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -160,6 +162,8 @@ fun ChatScreen( val connectorsState by connectorsViewModel.uiState.collectAsStateWithLifecycle() val actionProgressState by viewModel.actionProgress.state.collectAsStateWithLifecycle() val connectionOperationState by viewModel.connectionOperationState.collectAsStateWithLifecycle() + val settingsViewModel: SettingsViewModel = viewModel() + val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle() // Snapshot-backed search state — read directly so only the scopes that // read its fields recompose on search changes (bar, matched bubbles). val searchState = viewModel.searchState @@ -539,7 +543,10 @@ fun ChatScreen( var showSessionMenu by remember { mutableStateOf(false) } Box { IconButton( - onClick = { showSessionMenu = true }, + onClick = { + settingsViewModel.refreshKeepConnectedInBackground() + showSessionMenu = true + }, modifier = Modifier.testTag("chat_session_menu_button"), ) { Icon( @@ -609,6 +616,10 @@ fun ChatScreen( }, modifier = Modifier.testTag("chat_menu_session_integrations"), ) + ChatBackgroundConnectionMenuItem( + checked = settingsState.keepConnectedInBackground, + onToggle = settingsViewModel::onKeepConnectedInBackgroundChange, + ) } } }, @@ -1029,3 +1040,22 @@ fun ChatScreen( ) } } + +@Composable +internal fun ChatBackgroundConnectionMenuItem( + checked: Boolean, + onToggle: (Boolean) -> Unit, +) { + DropdownMenuItem( + text = { Text(stringResource(R.string.settings_keep_connected_in_background_title)) }, + trailingIcon = { + Checkbox( + checked = checked, + onCheckedChange = onToggle, + modifier = Modifier.testTag("chat_menu_keep_connected_checkbox"), + ) + }, + onClick = { onToggle(!checked) }, + modifier = Modifier.testTag("chat_menu_keep_connected"), + ) +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt index 624efb1d..c1e38f2f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt @@ -376,6 +376,12 @@ class SettingsViewModel( AuthManager.setKeepConnectedInBackground(enabled) } + /** Reconcile the chat shortcut with changes made through Settings or another screen. */ + fun refreshKeepConnectedInBackground() { + val enabled = AuthManager.isKeepConnectedInBackground() + _uiState.update { it.copy(keepConnectedInBackground = enabled) } + } + /** Clear all auth credentials — logs out and returns to landing screen. */ fun logout() { AuthManager.setToken(null) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt index ce4b388e..9df61ed2 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt @@ -383,6 +383,34 @@ class SettingsViewModelTest { verify { AuthManager.setKeepConnectedInBackground(false) } } + @Test + fun testKeepConnectedInBackground_refreshesBothDirectionsFromPersistedSetting() = + runTest { + var persisted = false + every { AuthManager.isKeepConnectedInBackground() } answers { persisted } + every { AuthManager.setKeepConnectedInBackground(any()) } answers { + persisted = firstArg() + } + val viewModel = SettingsViewModel(ioDispatcher = testDispatcher) + testDispatcher.scheduler.advanceUntilIdle() + + // An edit from Settings becomes visible when chat opens its menu. + persisted = true + viewModel.refreshKeepConnectedInBackground() + assertEquals(true, viewModel.uiState.value.keepConnectedInBackground) + + // Both menu directions use the existing Settings action and persistence path. + viewModel.onKeepConnectedInBackgroundChange(false) + assertEquals(false, persisted) + assertEquals(false, viewModel.uiState.value.keepConnectedInBackground) + viewModel.onKeepConnectedInBackgroundChange(true) + assertEquals(true, persisted) + + persisted = false + viewModel.refreshKeepConnectedInBackground() + assertEquals(false, viewModel.uiState.value.keepConnectedInBackground) + } + @Test fun testSave_persistsKeepConnectedInBackground() = runTest {