diff --git a/app/src/androidTest/java/com/github/warnastrophy/core/ui/components/ConfirmationPopupTest.kt b/app/src/androidTest/java/com/github/warnastrophy/core/ui/components/ConfirmationPopupTest.kt index e6f4a49f9..1fe3fbb24 100644 --- a/app/src/androidTest/java/com/github/warnastrophy/core/ui/components/ConfirmationPopupTest.kt +++ b/app/src/androidTest/java/com/github/warnastrophy/core/ui/components/ConfirmationPopupTest.kt @@ -1,16 +1,35 @@ package com.github.warnastrophy.core.ui.components +import android.Manifest +import android.content.Context import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick +import androidx.test.core.app.ApplicationProvider +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import com.github.warnastrophy.WarnastrophyComposable +import com.github.warnastrophy.core.data.provider.ContactRepositoryProvider +import com.github.warnastrophy.core.data.provider.UserPreferencesRepositoryProvider +import com.github.warnastrophy.core.data.service.StateManagerService import com.github.warnastrophy.core.ui.features.dashboard.ConfirmationPopup import com.github.warnastrophy.core.ui.features.dashboard.ConfirmationPopupTestTags import com.github.warnastrophy.core.util.BaseAndroidComposeTest +import com.github.warnastrophy.userPrefsDataStore import org.junit.Assert.assertEquals +import org.junit.Rule import org.junit.Test class ConfirmationPopupTest : BaseAndroidComposeTest() { + @get:Rule + val permissionRule: GrantPermissionRule = + GrantPermissionRule.grant( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION, + Manifest.permission.POST_NOTIFICATIONS) + private fun setPopupContent(onConfirm: () -> Unit = {}, onCancel: () -> Unit = {}) { composeTestRule.setContent { ConfirmationPopup( @@ -53,4 +72,48 @@ class ConfirmationPopupTest : BaseAndroidComposeTest() { assertEquals(1, cancelCount) } + + @Test + fun confirmationPopup_shows_and_confirm_calls_orchestrator() { + val ctx = ApplicationProvider.getApplicationContext() + UserPreferencesRepositoryProvider.initLocal(ctx.userPrefsDataStore) + ContactRepositoryProvider.initLocal(ctx) + InstrumentationRegistry.getInstrumentation().runOnMainSync { StateManagerService.init(ctx) } + // Launch the app composable + composeTestRule.setContent { WarnastrophyComposable() } + + // Trigger the touch confirmation via the orchestrator debug helper + val orchestrator = StateManagerService.dangerModeOrchestrator + + // Ensure touch confirmation is required + orchestrator.setTouchConfirmationRequired(true) + + // Trigger touch confirmation + composeTestRule.runOnUiThread { orchestrator.debugTriggerTouchConfirmation() } + + // Wait for popup and assert it's visible + composeTestRule.waitUntilWithTimeout { + try { + composeTestRule.onNodeWithTag(ConfirmationPopupTestTags.DIALOG).assertIsDisplayed() + true + } catch (_: Throwable) { + false + } + } + + composeTestRule.onNodeWithTag(ConfirmationPopupTestTags.DIALOG).assertIsDisplayed() + + // Click confirm button + composeTestRule.onNodeWithTag(ConfirmationPopupTestTags.CONFIRM_BUTTON).performClick() + + // After confirm, popup should no longer be displayed + composeTestRule.waitUntilWithTimeout { + try { + composeTestRule.onNodeWithTag(ConfirmationPopupTestTags.DIALOG).assertIsNotDisplayed() + true + } catch (_: Throwable) { + false + } + } + } } diff --git a/app/src/main/java/com/github/warnastrophy/WarnastrophyComposable.kt b/app/src/main/java/com/github/warnastrophy/WarnastrophyComposable.kt index c07423cc9..c5926bddb 100644 --- a/app/src/main/java/com/github/warnastrophy/WarnastrophyComposable.kt +++ b/app/src/main/java/com/github/warnastrophy/WarnastrophyComposable.kt @@ -17,6 +17,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState @@ -31,6 +32,7 @@ import com.github.warnastrophy.core.ui.features.contact.ContactListScreen import com.github.warnastrophy.core.ui.features.contact.ContactListViewModel import com.github.warnastrophy.core.ui.features.contact.EditContactScreen import com.github.warnastrophy.core.ui.features.contact.EditContactViewModel +import com.github.warnastrophy.core.ui.features.dashboard.ConfirmationPopup import com.github.warnastrophy.core.ui.features.dashboard.DashboardScreen import com.github.warnastrophy.core.ui.features.dashboard.activity.ActivityListScreen import com.github.warnastrophy.core.ui.features.dashboard.activity.ActivityListViewModel @@ -151,6 +153,9 @@ fun WarnastrophyComposable( // Voice confirmation for danger mode val dangerModeOrchestrator = remember { StateManagerService.dangerModeOrchestrator } val showVoiceConfirmation by dangerModeOrchestrator.showVoiceConfirmationScreen.collectAsState() + val showTouchConfirmation by + dangerModeOrchestrator.showTouchConfirmationScreen.collectAsState(initial = false) + val orchestratorState by dangerModeOrchestrator.state.collectAsState() // Observe confirmation state from the communication view model val communicationUiState by communicationViewModel.uiState.collectAsState() @@ -182,6 +187,12 @@ fun WarnastrophyComposable( return // Don't render the rest of the UI while voice confirmation is showing } + // Show touch confirmation using the generic ConfirmationPopup when requested + if (showTouchConfirmation) { + TouchConfirmationPopup(dangerModeOrchestrator, orchestratorState.pendingAction) + return // Don't render the rest of the UI while touch confirmation is showing + } + Scaffold( modifier = Modifier.testTag(WarnastrophyAppTestTags.MAIN_SCREEN), bottomBar = { BottomNavigationBar(currentScreen, navController) }, @@ -292,3 +303,35 @@ fun WarnastrophyComposable( } } } + +/** + * Shows a touch confirmation popup for the given pending emergency action. Extracted to keep the + * main composable concise. + * + * @param orchestrator The DangerModeOrchestrator managing danger mode state. + * @param pendingAction The pending emergency action requiring confirmation. + */ +@Composable +private fun TouchConfirmationPopup( + orchestrator: com.github.warnastrophy.core.data.service.DangerModeOrchestrator, + pendingAction: com.github.warnastrophy.core.data.service.PendingEmergencyAction? +) { + val message = + when (pendingAction) { + is com.github.warnastrophy.core.data.service.PendingEmergencyAction.MakeCall -> + stringResource(id = R.string.touch_confirmation_call_message) + is com.github.warnastrophy.core.data.service.PendingEmergencyAction.SendSms -> + stringResource(id = R.string.touch_confirmation_sms_message) + is com.github.warnastrophy.core.data.service.PendingEmergencyAction.SendSmsAndCall -> + stringResource(id = R.string.touch_confirmation_both_message) + else -> stringResource(id = R.string.touch_confirmation_generic_message) + } + + ConfirmationPopup( + title = stringResource(id = R.string.touch_confirmation_title), + message = message, + confirmLabel = stringResource(id = R.string.confirmation_confirm_label), + cancelLabel = stringResource(id = R.string.confirmation_cancel_label), + onConfirm = { orchestrator.onTouchConfirmation() }, + onCancel = { orchestrator.onCancellation() }) +} diff --git a/app/src/main/java/com/github/warnastrophy/core/data/service/DangerModeOrchestrator.kt b/app/src/main/java/com/github/warnastrophy/core/data/service/DangerModeOrchestrator.kt index 5eb02b24b..90ea0fd4b 100644 --- a/app/src/main/java/com/github/warnastrophy/core/data/service/DangerModeOrchestrator.kt +++ b/app/src/main/java/com/github/warnastrophy/core/data/service/DangerModeOrchestrator.kt @@ -112,13 +112,22 @@ class DangerModeOrchestrator( private val _showVoiceConfirmationScreen = MutableStateFlow(false) val showVoiceConfirmationScreen: StateFlow = _showVoiceConfirmationScreen.asStateFlow() + private val _showTouchConfirmationScreen = MutableStateFlow(false) + val showTouchConfirmationScreen: StateFlow = _showTouchConfirmationScreen.asStateFlow() + private var _voiceConfirmationEnabled = false + private var _touchConfirmationRequired = false /** Sets whether voice confirmation is required before executing emergency actions. */ fun setVoiceConfirmationEnabled(enabled: Boolean) { _voiceConfirmationEnabled = enabled } + /** Sets whether touch confirmation is required before executing emergency actions. */ + fun setTouchConfirmationRequired(required: Boolean) { + _touchConfirmationRequired = required + } + private var smsSenderInstance: SmsSender? = smsSender private var callSenderInstance: CallSender? = callSender @@ -137,6 +146,19 @@ class DangerModeOrchestrator( } } } + + // Observe persisted user preferences and update confirmation requirement flags. + scope.launch { + try { + userPreferencesRepository.getUserPreferences.collectLatest { prefs -> + // Keep in-memory flags in sync with repository so orchestrator reacts immediately + _voiceConfirmationEnabled = prefs.dangerModePreferences.voiceConfirmationEnabled + _touchConfirmationRequired = prefs.dangerModePreferences.touchConfirmationRequired + } + } catch (_: Exception) { + // Ignore failures to read preferences at startup; defaults remain + } + } } /** @@ -302,12 +324,15 @@ class DangerModeOrchestrator( pendingAction = pendingAction, confirmationTimeoutSeconds = CONFIRMATION_TIMEOUT_SECONDS) - // Check if voice confirmation is enabled + // Check confirmation mode: prefer voice, otherwise touch if required by prefs or runtime flag if (_voiceConfirmationEnabled) { // Show voice confirmation screen _showVoiceConfirmationScreen.value = true + } else if (_touchConfirmationRequired || preferences.touchConfirmationRequired) { + // Show touch confirmation screen + _showTouchConfirmationScreen.value = true } else { - // Execute action directly without voice confirmation + // Execute action directly without confirmation scope.launch { executeEmergencyAction(pendingAction) } } } @@ -321,6 +346,15 @@ class DangerModeOrchestrator( scope.launch { executeEmergencyAction(pendingAction) } } + /** Called when the user confirms the emergency action via touch confirmation UI. */ + fun onTouchConfirmation() { + val pendingAction = _state.value.pendingAction ?: return + + _showTouchConfirmationScreen.value = false + + scope.launch { executeEmergencyAction(pendingAction) } + } + /** * Called when the user cancels the emergency action via voice ("no") or timeout. * @@ -328,6 +362,7 @@ class DangerModeOrchestrator( */ fun onCancellation(reportAsError: Boolean = false) { _showVoiceConfirmationScreen.value = false + _showTouchConfirmationScreen.value = false _state.value = OrchestratorState(lastActionTaken = EmergencyActionResult.Cancelled) if (reportAsError) { @@ -410,6 +445,7 @@ class DangerModeOrchestrator( private fun resetState() { _state.value = OrchestratorState() _showVoiceConfirmationScreen.value = false + _showTouchConfirmationScreen.value = false _permissionRequests.value = null } @@ -433,4 +469,19 @@ class DangerModeOrchestrator( confirmationTimeoutSeconds = CONFIRMATION_TIMEOUT_SECONDS) _showVoiceConfirmationScreen.value = true } + + /** DEBUG ONLY: Manually trigger the touch confirmation screen for testing purposes. */ + fun debugTriggerTouchConfirmation() { + val currentLocation = gpsService.positionState.value.position + val emergencyMessage = + EmergencyMessage(location = Location(currentLocation.latitude, currentLocation.longitude)) + + _state.value = + OrchestratorState( + isWaitingForConfirmation = true, + pendingAction = + PendingEmergencyAction.SendSmsAndCall(emergencyPhoneNumber, emergencyMessage), + confirmationTimeoutSeconds = CONFIRMATION_TIMEOUT_SECONDS) + _showTouchConfirmationScreen.value = true + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 71cf232f5..2d8152a9b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -209,4 +209,11 @@ Add an emergency contact in your profile to enable automatic SMS and call features. Confirm Emergency Action + Confirm emergency action + A phone call will be placed to your emergency contact. Do you want to proceed? + A text message containing your current location will be sent to your emergency contact. Do you want to proceed? + A text message will be sent and then a phone call will be placed to your emergency contact. Do you want to proceed? + An emergency action will be performed (call/SMS) to your emergency contact. Do you want to proceed? + Confirm + Cancel \ No newline at end of file