From 20442ee758c7fdc0b141596856228ec15a5ff94b Mon Sep 17 00:00:00 2001 From: ssidimoh Date: Sun, 14 Dec 2025 18:23:01 +0100 Subject: [PATCH 01/15] feat: add microphone permission in danger mode preferences --- .../util/MockUserPreferencesRepository.kt | 10 ++++- .../HybridUserPreferencesRepository.kt | 3 ++ .../repository/UserPreferencesRepository.kt | 5 +++ .../UserPreferencesRepositoryLocal.kt | 14 +++++- .../UserPreferencesRepositoryRemote.kt | 10 ++++- .../core/permissions/AppPermissions.kt | 4 +- .../preferences/DangerModePreferences.kt | 44 +++++++++++-------- .../DangerModePreferencesViewModel.kt | 24 ++++++++-- app/src/main/res/values/strings.xml | 3 ++ .../service/DangerModeOrchestratorTest.kt | 10 ++++- 10 files changed, 99 insertions(+), 28 deletions(-) diff --git a/app/src/androidTest/java/com/github/warnastrophy/core/util/MockUserPreferencesRepository.kt b/app/src/androidTest/java/com/github/warnastrophy/core/util/MockUserPreferencesRepository.kt index 1fd4df968..dbbbc161f 100644 --- a/app/src/androidTest/java/com/github/warnastrophy/core/util/MockUserPreferencesRepository.kt +++ b/app/src/androidTest/java/com/github/warnastrophy/core/util/MockUserPreferencesRepository.kt @@ -18,7 +18,8 @@ class MockUserPreferencesRepository : UserPreferencesRepository { alertMode = false, inactivityDetection = false, automaticSms = false, - automaticCalls = false), + automaticCalls = false, + microphoneAccess = false), themePreferences = false)) override val getUserPreferences: StateFlow = _preferences.asStateFlow() @@ -52,5 +53,12 @@ class MockUserPreferencesRepository : UserPreferencesRepository { dangerModePreferences = current.dangerModePreferences.copy(automaticCalls = enabled)) } + override suspend fun setMicrophoneAccess(enabled: Boolean) { + val current = _preferences.value + _preferences.value = + current.copy( + dangerModePreferences = current.dangerModePreferences.copy(microphoneAccess = enabled)) + } + override suspend fun setDarkMode(isDark: Boolean) {} } diff --git a/app/src/main/java/com/github/warnastrophy/core/data/repository/HybridUserPreferencesRepository.kt b/app/src/main/java/com/github/warnastrophy/core/data/repository/HybridUserPreferencesRepository.kt index 9950b67bd..ebea61013 100644 --- a/app/src/main/java/com/github/warnastrophy/core/data/repository/HybridUserPreferencesRepository.kt +++ b/app/src/main/java/com/github/warnastrophy/core/data/repository/HybridUserPreferencesRepository.kt @@ -79,6 +79,9 @@ class HybridUserPreferencesRepository( updateBothRepositories { setAutomaticCalls(enabled) } } + override suspend fun setMicrophoneAccess(enabled: Boolean) { + updateBothRepositories { setMicrophoneAccess(enabled) } + } /** * Updates both local and remote repositories to set the dark mode preference. * diff --git a/app/src/main/java/com/github/warnastrophy/core/data/repository/UserPreferencesRepository.kt b/app/src/main/java/com/github/warnastrophy/core/data/repository/UserPreferencesRepository.kt index 6cd4ae21c..c79bdf5f1 100644 --- a/app/src/main/java/com/github/warnastrophy/core/data/repository/UserPreferencesRepository.kt +++ b/app/src/main/java/com/github/warnastrophy/core/data/repository/UserPreferencesRepository.kt @@ -41,6 +41,11 @@ interface UserPreferencesRepository { */ suspend fun setAutomaticCalls(enabled: Boolean) + /* + * Enables or disables microphone access for the app. + */ + suspend fun setMicrophoneAccess(enabled: Boolean) + /** * Enables or disables dark mode for the app's UI. * diff --git a/app/src/main/java/com/github/warnastrophy/core/data/repository/UserPreferencesRepositoryLocal.kt b/app/src/main/java/com/github/warnastrophy/core/data/repository/UserPreferencesRepositoryLocal.kt index 4a077f7ac..2290a232f 100644 --- a/app/src/main/java/com/github/warnastrophy/core/data/repository/UserPreferencesRepositoryLocal.kt +++ b/app/src/main/java/com/github/warnastrophy/core/data/repository/UserPreferencesRepositoryLocal.kt @@ -35,6 +35,7 @@ data class DangerModePreferences( val inactivityDetection: Boolean, val automaticSms: Boolean, val automaticCalls: Boolean, + val microphoneAccess: Boolean = false ) { companion object { fun default() = @@ -42,7 +43,8 @@ data class DangerModePreferences( alertMode = false, inactivityDetection = false, automaticSms = false, - automaticCalls = false) + automaticCalls = false, + microphoneAccess = false) } } @@ -60,6 +62,8 @@ class UserPreferencesRepositoryLocal(private val dataStore: DataStore preferences[AUTOMATIC_CALLS_KEY] = enabled } } + override suspend fun setMicrophoneAccess(enabled: Boolean) { + dataStore.edit { preferences -> preferences[MICROPHONE_KEY] = enabled } + } + override suspend fun setDarkMode(isDark: Boolean) { dataStore.edit { preferences -> preferences[DARK_MODE_KEY] = isDark } } @@ -107,10 +115,12 @@ class UserPreferencesRepositoryLocal(private val dataStore: DataStore viewModel.inactivityDetectionPermissions PendingAction.TOGGLE_AUTOMATIC_SMS -> viewModel.smsPermissions PendingAction.TOGGLE_AUTOMATIC_CALLS -> viewModel.callPermissions + PendingAction.TOGGLE_MICROPHONE -> viewModel.microphonePermissions } viewModel.onPermissionsRequestStart(action = action) @@ -174,6 +176,27 @@ fun DangerModePreferencesScreen(viewModel: DangerModePreferencesViewModel) { enabled = uiState.inactivityDetectionEnabled, isRequestInFlight = uiState.isOsRequestInFlight), switchTestTag = DangerModePreferencesScreenTestTags.AUTOMATIC_SMS_SWITCH) + PreferenceItem( + modifier = Modifier.testTag(DangerModePreferencesScreenTestTags.MICROPHONE_ACESS), + data = + PreferenceItemData( + title = stringResource(R.string.danger_mode_microphone_access_title), + description = + stringResource(R.string.danger_mode_microphone_access_description), + checked = uiState.microphoneAccessEnabled, + onCheckedChange = { isChecked -> + viewModel.handlePreferenceChange( + isChecked = isChecked, + permissionResult = uiState.microphonePermissionResult, + onToggle = { viewModel.onMicrophoneToggled(it) }, + onPermissionDenied = { + requestPermission(PendingAction.TOGGLE_MICROPHONE) + }, + onPermissionPermDenied = { openAppSettings(activity) }, + ) + }, + isRequestInFlight = uiState.isOsRequestInFlight), + switchTestTag = DangerModePreferencesScreenTestTags.MICROPHONE_ACESS_SWITCH) PreferenceItem( modifier = Modifier.testTag(DangerModePreferencesScreenTestTags.AUTOMATIC_CALLS_ITEM), @@ -202,23 +225,6 @@ fun DangerModePreferencesScreen(viewModel: DangerModePreferencesViewModel) { val orchestrator = remember { com.github.warnastrophy.core.data.service.StateManagerService.dangerModeOrchestrator } - - PreferenceItem( - modifier = - Modifier.testTag(DangerModePreferencesScreenTestTags.VOICE_CONFIRMATION_ITEM), - data = - PreferenceItemData( - title = stringResource(R.string.danger_mode_voice_confirmation_title), - description = - stringResource(R.string.danger_mode_voice_confirmation_description), - checked = voiceConfirmationEnabled, - onCheckedChange = { isChecked -> - voiceConfirmationEnabled = isChecked - orchestrator.setVoiceConfirmationEnabled(isChecked) - }, - enabled = uiState.inactivityDetectionEnabled, - isRequestInFlight = uiState.isOsRequestInFlight), - switchTestTag = DangerModePreferencesScreenTestTags.VOICE_CONFIRMATION_SWITCH) } } diff --git a/app/src/main/java/com/github/warnastrophy/core/ui/features/profile/preferences/DangerModePreferencesViewModel.kt b/app/src/main/java/com/github/warnastrophy/core/ui/features/profile/preferences/DangerModePreferencesViewModel.kt index e39d27614..c007100f8 100644 --- a/app/src/main/java/com/github/warnastrophy/core/ui/features/profile/preferences/DangerModePreferencesViewModel.kt +++ b/app/src/main/java/com/github/warnastrophy/core/ui/features/profile/preferences/DangerModePreferencesViewModel.kt @@ -26,7 +26,8 @@ enum class PendingAction { TOGGLE_ALERT_MODE, TOGGLE_INACTIVITY_DETECTION, TOGGLE_AUTOMATIC_SMS, - TOGGLE_AUTOMATIC_CALLS + TOGGLE_AUTOMATIC_CALLS, + TOGGLE_MICROPHONE } /** @@ -47,11 +48,13 @@ data class DangerModePreferencesUiState( val inactivityDetectionEnabled: Boolean = false, val automaticSmsEnabled: Boolean = false, val automaticCallsEnabled: Boolean = false, + val microphoneAccessEnabled: Boolean = false, val alertModePermissionResult: PermissionResult, val inactivityDetectionPermissionResult: PermissionResult, val smsPermissionResult: PermissionResult, val callPermissionResult: PermissionResult, - val pendingPermissionAction: PendingAction? = null + val pendingPermissionAction: PendingAction? = null, + val microphonePermissionResult: PermissionResult ) { /** A computed property that is true if a permission request is in flight. */ val isOsRequestInFlight: Boolean @@ -74,6 +77,8 @@ class DangerModePreferencesViewModel( val smsPermissions = AppPermissions.SendEmergencySms val callPermissions = AppPermissions.MakeEmergencyCall + val microphonePermissions = AppPermissions.Microphone + private val _uiState = MutableStateFlow( DangerModePreferencesUiState( @@ -82,7 +87,9 @@ class DangerModePreferencesViewModel( inactivityDetectionPermissionResult = permissionManager.getPermissionResult(inactivityDetectionPermissions), smsPermissionResult = permissionManager.getPermissionResult(smsPermissions), - callPermissionResult = permissionManager.getPermissionResult(callPermissions))) + callPermissionResult = permissionManager.getPermissionResult(callPermissions), + microphonePermissionResult = + permissionManager.getPermissionResult(microphonePermissions))) val uiState = _uiState.asStateFlow() @@ -151,6 +158,10 @@ class DangerModePreferencesViewModel( viewModelScope.launch { userPreferencesRepository.setAutomaticCalls(enabled) } } + fun onMicrophoneToggled(enabled: Boolean) { + TODO("Not yet implemented") + } + /** * Records that a permission request has been initiated for a specific action. * @@ -172,6 +183,7 @@ class DangerModePreferencesViewModel( permissionManager.getPermissionResult(inactivityDetectionPermissions, activity) val newSmsResult = permissionManager.getPermissionResult(smsPermissions, activity) val newCallResult = permissionManager.getPermissionResult(callPermissions, activity) + val newMicrophoneResult = permissionManager.getPermissionResult(microphonePermissions, activity) _uiState.update { it.copy( @@ -206,6 +218,12 @@ class DangerModePreferencesViewModel( onAutomaticCallsToggled(true) } } + PendingAction.TOGGLE_MICROPHONE -> { + permissionManager.markPermissionsAsAsked(microphonePermissions) + if (newMicrophoneResult is PermissionResult.Granted) { + onMicrophoneToggled(true) + } + } null -> {} } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b62a973d8..946c8d2e6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -189,5 +189,8 @@ The emergency action was cancelled. You can trigger it again if needed. Add an emergency contact in your profile to enable automatic SMS and call features. + + Microphone Access Required + To use voice confirmation for emergency actions, the app needs access to your device\'s microphone. Please grant microphone permission in your device settings. Confirm Emergency Action \ No newline at end of file diff --git a/app/src/test/java/com/github/warnastrophy/core/data/service/DangerModeOrchestratorTest.kt b/app/src/test/java/com/github/warnastrophy/core/data/service/DangerModeOrchestratorTest.kt index 95f26464a..ee5e7c692 100644 --- a/app/src/test/java/com/github/warnastrophy/core/data/service/DangerModeOrchestratorTest.kt +++ b/app/src/test/java/com/github/warnastrophy/core/data/service/DangerModeOrchestratorTest.kt @@ -268,7 +268,8 @@ class DangerModeOrchestratorTest { alertMode = true, inactivityDetection = true, automaticSms = true, - automaticCalls = false)) + automaticCalls = false, + microphoneAccess = false)) advanceUntilIdle() // Trigger via debug which creates SendSmsAndCall, then test direct SMS @@ -806,6 +807,13 @@ class MockUserPreferencesRepository( preferencesFlow.value.dangerModePreferences.copy(automaticCalls = enabled)) } + override suspend fun setMicrophoneAccess(enabled: Boolean) { + preferencesFlow.value = + preferencesFlow.value.copy( + dangerModePreferences = + preferencesFlow.value.dangerModePreferences.copy(microphoneAccess = enabled)) + } + override suspend fun setDarkMode(isDark: Boolean) {} } From c6634d21f7cdd2a9c5fa852f7bc5120ee3e6f7fc Mon Sep 17 00:00:00 2001 From: ssidimoh Date: Sun, 14 Dec 2025 18:37:09 +0100 Subject: [PATCH 02/15] feat: add microphone permission in repository and in local storage --- .../profile/preferences/DangerModePreferencesViewModel.kt | 5 +++-- app/src/main/res/values/strings.xml | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/github/warnastrophy/core/ui/features/profile/preferences/DangerModePreferencesViewModel.kt b/app/src/main/java/com/github/warnastrophy/core/ui/features/profile/preferences/DangerModePreferencesViewModel.kt index c007100f8..5930df8fd 100644 --- a/app/src/main/java/com/github/warnastrophy/core/ui/features/profile/preferences/DangerModePreferencesViewModel.kt +++ b/app/src/main/java/com/github/warnastrophy/core/ui/features/profile/preferences/DangerModePreferencesViewModel.kt @@ -101,7 +101,8 @@ class DangerModePreferencesViewModel( alertModeAutomaticEnabled = prefs.dangerModePreferences.alertMode, inactivityDetectionEnabled = prefs.dangerModePreferences.inactivityDetection, automaticSmsEnabled = prefs.dangerModePreferences.automaticSms, - automaticCallsEnabled = prefs.dangerModePreferences.automaticCalls) + automaticCallsEnabled = prefs.dangerModePreferences.automaticCalls, + microphoneAccessEnabled = prefs.dangerModePreferences.microphoneAccess) } } .launchIn(viewModelScope) @@ -159,7 +160,7 @@ class DangerModePreferencesViewModel( } fun onMicrophoneToggled(enabled: Boolean) { - TODO("Not yet implemented") + viewModelScope.launch { userPreferencesRepository.setMicrophoneAccess(enabled) } } /** diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 946c8d2e6..ad847c81e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -190,7 +190,7 @@ Add an emergency contact in your profile to enable automatic SMS and call features. - Microphone Access Required + Microphone Access To use voice confirmation for emergency actions, the app needs access to your device\'s microphone. Please grant microphone permission in your device settings. Confirm Emergency Action \ No newline at end of file From a98ebdaf721fc3cda834872fe8569602ee874b2d Mon Sep 17 00:00:00 2001 From: ssidimoh Date: Mon, 15 Dec 2025 18:03:36 +0100 Subject: [PATCH 03/15] refactor: adapt test given new permission adapt test for hybrid, local and remote repository. adapt test for preferenceScreen --- .../DangerModePreferencesScreenTest.kt | 59 +++++++++++++++++++ .../HybridUserPreferencesRepository.kt | 1 + .../core/data/service/StateManagerService.kt | 4 +- .../preferences/DangerModePreferences.kt | 14 ++--- .../HybridUserPreferencesRepositoryTest.kt | 16 ++++- .../UserPreferencesRepositoryLocalTest.kt | 9 +++ .../UserPreferencesRepositoryRemoteTest.kt | 35 ++++++++++- 7 files changed, 127 insertions(+), 11 deletions(-) diff --git a/app/src/androidTest/java/com/github/warnastrophy/core/ui/profile/preferences/DangerModePreferencesScreenTest.kt b/app/src/androidTest/java/com/github/warnastrophy/core/ui/profile/preferences/DangerModePreferencesScreenTest.kt index f7631b073..f76eb4efa 100644 --- a/app/src/androidTest/java/com/github/warnastrophy/core/ui/profile/preferences/DangerModePreferencesScreenTest.kt +++ b/app/src/androidTest/java/com/github/warnastrophy/core/ui/profile/preferences/DangerModePreferencesScreenTest.kt @@ -1,5 +1,6 @@ package com.github.warnastrophy.core.ui.profile.preferences +import android.content.Context import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.test.assertIsDisplayed @@ -15,7 +16,14 @@ import androidx.compose.ui.test.performScrollToNode import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.github.warnastrophy.core.data.repository.UserPreferencesRepository +import com.github.warnastrophy.core.data.service.DangerModeOrchestrator +import com.github.warnastrophy.core.data.service.DangerModeService +import com.github.warnastrophy.core.data.service.GpsService +import com.github.warnastrophy.core.data.service.HazardsDataService +import com.github.warnastrophy.core.data.service.MovementService +import com.github.warnastrophy.core.data.service.StateManagerService import com.github.warnastrophy.core.permissions.PermissionResult +import com.github.warnastrophy.core.ui.common.ErrorHandler import com.github.warnastrophy.core.ui.components.FALLBACK_ACTIVITY_ERROR import com.github.warnastrophy.core.ui.features.profile.preferences.DangerModePreferencesScreen import com.github.warnastrophy.core.ui.features.profile.preferences.DangerModePreferencesScreenTestTags @@ -24,6 +32,7 @@ import com.github.warnastrophy.core.ui.features.profile.preferences.PendingActio import com.github.warnastrophy.core.ui.map.MockPermissionManager import com.github.warnastrophy.core.ui.util.MockUserPreferencesRepository import com.github.warnastrophy.core.util.BaseAndroidComposeTest +import io.mockk.mockk import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertFalse import junit.framework.TestCase.assertNull @@ -41,9 +50,23 @@ class DangerModePreferencesScreenTest : BaseAndroidComposeTest() { @Before override fun setUp() { + val ctx: Context = mockk(relaxed = true) super.setUp() mockPermissionManager = MockPermissionManager() userPreferencesRepository = MockUserPreferencesRepository() + val dangerModeService = mockk(relaxed = true) + val mouvementServiceMock = mockk(relaxed = true) + val errorHandlerMock = mockk(relaxed = true) + val gpsServiceMock = mockk(relaxed = true) + val hazardServicemock = mockk(relaxed = true) + val dangerModeOrchestrator = mockk(relaxed = true) + StateManagerService.init( + gpsServiceMock, + hazardServicemock, + dangerModeService, + mouvementServiceMock, + errorHandlerMock, + dangerModeOrchestrator) } /** Helper function to set the content of the test rule with a configured ViewModel. */ @@ -67,6 +90,12 @@ class DangerModePreferencesScreenTest : BaseAndroidComposeTest() { .performScrollToNode(hasTestTag(DangerModePreferencesScreenTestTags.AUTOMATIC_CALLS_ITEM)) } + private fun scrollToMicrophoneAccess() { + composeTestRule + .onNodeWithTag(DangerModePreferencesScreenTestTags.SCROLL_CONTAINER) + .performScrollToNode(hasTestTag(DangerModePreferencesScreenTestTags.MICROPHONE_ACCESS)) + } + /** Applies a given [PermissionResult] to the view model. */ private fun applyPerm(permissionResult: PermissionResult) { mockPermissionManager.setPermissionResult(permissionResult) @@ -87,6 +116,7 @@ class DangerModePreferencesScreenTest : BaseAndroidComposeTest() { nodeWithTag(DangerModePreferencesScreenTestTags.AUTOMATIC_SMS_SWITCH).assertIsOff() scrollToAutomaticCalls() nodeWithTag(DangerModePreferencesScreenTestTags.AUTOMATIC_CALLS_SWITCH).assertIsOff() + nodeWithTag(DangerModePreferencesScreenTestTags.MICROPHONE_ACCESS_SWITCH).assertIsOff() } /** Verifies that a fallback error message is shown when the context is not an Activity. */ @@ -120,6 +150,7 @@ class DangerModePreferencesScreenTest : BaseAndroidComposeTest() { nodeWithTag(DangerModePreferencesScreenTestTags.AUTOMATIC_SMS_SWITCH).assertIsNotEnabled() scrollToAutomaticCalls() nodeWithTag(DangerModePreferencesScreenTestTags.AUTOMATIC_CALLS_SWITCH).assertIsNotEnabled() + nodeWithTag(DangerModePreferencesScreenTestTags.MICROPHONE_ACCESS_SWITCH).assertIsNotEnabled() } @Test @@ -135,6 +166,7 @@ class DangerModePreferencesScreenTest : BaseAndroidComposeTest() { nodeWithTag(DangerModePreferencesScreenTestTags.AUTOMATIC_SMS_SWITCH).assertIsNotEnabled() scrollToAutomaticCalls() nodeWithTag(DangerModePreferencesScreenTestTags.AUTOMATIC_CALLS_SWITCH).assertIsNotEnabled() + nodeWithTag(DangerModePreferencesScreenTestTags.MICROPHONE_ACCESS_SWITCH).assertIsNotEnabled() } /** @@ -286,5 +318,32 @@ class DangerModePreferencesScreenTest : BaseAndroidComposeTest() { } } + @Test + fun microphoneAccessToggle_requestsPermission_whenNotGranted() { + mockPermissionManager.setPermissionResult(PermissionResult.Granted) + setContent() + + toggleSwitch( + DangerModePreferencesScreenTestTags.ALERT_MODE_SWITCH, + { viewModel.uiState.value.alertModeAutomaticEnabled }) + toggleSwitch( + DangerModePreferencesScreenTestTags.INACTIVITY_DETECTION_SWITCH, + { viewModel.uiState.value.inactivityDetectionEnabled }) + + mockPermissionManager.setPermissionResult(PermissionResult.Denied(listOf("FAKE_PERMISSION"))) + + // Simulate toggle the switch + nodeWithTag(DangerModePreferencesScreenTestTags.MICROPHONE_ACCESS_SWITCH) + .assertIsEnabled() + .assertIsOff() + + applyPerm(PermissionResult.Granted) + + scrollToMicrophoneAccess() + toggleSwitch(DangerModePreferencesScreenTestTags.MICROPHONE_ACCESS_SWITCH) { + viewModel.uiState.value.microphoneAccessEnabled + } + } + private fun nodeWithTag(tag: String) = composeTestRule.onNodeWithTag(tag, useUnmergedTree = true) } diff --git a/app/src/main/java/com/github/warnastrophy/core/data/repository/HybridUserPreferencesRepository.kt b/app/src/main/java/com/github/warnastrophy/core/data/repository/HybridUserPreferencesRepository.kt index ebea61013..0e3115cec 100644 --- a/app/src/main/java/com/github/warnastrophy/core/data/repository/HybridUserPreferencesRepository.kt +++ b/app/src/main/java/com/github/warnastrophy/core/data/repository/HybridUserPreferencesRepository.kt @@ -133,6 +133,7 @@ class HybridUserPreferencesRepository( setInactivityDetection(remotePrefs.dangerModePreferences.inactivityDetection) setAutomaticSms(remotePrefs.dangerModePreferences.automaticSms) setDarkMode(remotePrefs.themePreferences) + setMicrophoneAccess(remotePrefs.dangerModePreferences.microphoneAccess) } } isRemoteAvailable = true diff --git a/app/src/main/java/com/github/warnastrophy/core/data/service/StateManagerService.kt b/app/src/main/java/com/github/warnastrophy/core/data/service/StateManagerService.kt index 68856e80d..59821af8d 100644 --- a/app/src/main/java/com/github/warnastrophy/core/data/service/StateManagerService.kt +++ b/app/src/main/java/com/github/warnastrophy/core/data/service/StateManagerService.kt @@ -135,12 +135,14 @@ object StateManagerService { hazardsService: HazardsDataService, dangerModeService: DangerModeService, movementService: MovementService? = null, - errorHandler: ErrorHandler = ErrorHandler() + errorHandler: ErrorHandler = ErrorHandler(), + dangerModeOrchestrator: DangerModeOrchestrator? = null ) { this.gpsService = gpsService this.hazardsService = hazardsService this.dangerModeService = dangerModeService this.errorHandler = errorHandler + if (dangerModeOrchestrator != null) this.dangerModeOrchestrator = dangerModeOrchestrator if (movementService != null) { this.movementService = movementService } diff --git a/app/src/main/java/com/github/warnastrophy/core/ui/features/profile/preferences/DangerModePreferences.kt b/app/src/main/java/com/github/warnastrophy/core/ui/features/profile/preferences/DangerModePreferences.kt index 51105bcea..00f66158b 100644 --- a/app/src/main/java/com/github/warnastrophy/core/ui/features/profile/preferences/DangerModePreferences.kt +++ b/app/src/main/java/com/github/warnastrophy/core/ui/features/profile/preferences/DangerModePreferences.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.github.warnastrophy.R +import com.github.warnastrophy.core.data.service.StateManagerService import com.github.warnastrophy.core.ui.components.ActivityFallback import com.github.warnastrophy.core.util.findActivity import com.github.warnastrophy.core.util.openAppSettings @@ -42,12 +43,12 @@ object DangerModePreferencesScreenTestTags { const val AUTOMATIC_SMS_ITEM = "automaticSmsItem" const val AUTOMATIC_CALLS_ITEM = "automaticCallsItem" - const val MICROPHONE_ACESS = "microphoneAccessItem" + const val MICROPHONE_ACCESS = "microphoneAccessItem" const val ALERT_MODE_SWITCH = "alertModeSwitch" const val INACTIVITY_DETECTION_SWITCH = "inactivitySwitch" const val AUTOMATIC_SMS_SWITCH = "automaticSmsSwitch" const val AUTOMATIC_CALLS_SWITCH = "automaticCallsSwitch" - const val MICROPHONE_ACESS_SWITCH = "microphoneAccessSwitch" + const val MICROPHONE_ACCESS_SWITCH = "microphoneAccessSwitch" } /** @@ -177,7 +178,7 @@ fun DangerModePreferencesScreen(viewModel: DangerModePreferencesViewModel) { isRequestInFlight = uiState.isOsRequestInFlight), switchTestTag = DangerModePreferencesScreenTestTags.AUTOMATIC_SMS_SWITCH) PreferenceItem( - modifier = Modifier.testTag(DangerModePreferencesScreenTestTags.MICROPHONE_ACESS), + modifier = Modifier.testTag(DangerModePreferencesScreenTestTags.MICROPHONE_ACCESS), data = PreferenceItemData( title = stringResource(R.string.danger_mode_microphone_access_title), @@ -195,8 +196,9 @@ fun DangerModePreferencesScreen(viewModel: DangerModePreferencesViewModel) { onPermissionPermDenied = { openAppSettings(activity) }, ) }, + enabled = uiState.inactivityDetectionEnabled, isRequestInFlight = uiState.isOsRequestInFlight), - switchTestTag = DangerModePreferencesScreenTestTags.MICROPHONE_ACESS_SWITCH) + switchTestTag = DangerModePreferencesScreenTestTags.MICROPHONE_ACCESS_SWITCH) PreferenceItem( modifier = Modifier.testTag(DangerModePreferencesScreenTestTags.AUTOMATIC_CALLS_ITEM), @@ -222,9 +224,7 @@ fun DangerModePreferencesScreen(viewModel: DangerModePreferencesViewModel) { // Voice confirmation - uses local state, not persisted var voiceConfirmationEnabled by remember { mutableStateOf(false) } - val orchestrator = remember { - com.github.warnastrophy.core.data.service.StateManagerService.dangerModeOrchestrator - } + val orchestrator = remember { StateManagerService.dangerModeOrchestrator } } } diff --git a/app/src/test/java/com/github/warnastrophy/core/data/repository/HybridUserPreferencesRepositoryTest.kt b/app/src/test/java/com/github/warnastrophy/core/data/repository/HybridUserPreferencesRepositoryTest.kt index 17a087713..47e79408d 100644 --- a/app/src/test/java/com/github/warnastrophy/core/data/repository/HybridUserPreferencesRepositoryTest.kt +++ b/app/src/test/java/com/github/warnastrophy/core/data/repository/HybridUserPreferencesRepositoryTest.kt @@ -37,7 +37,8 @@ class HybridUserPreferencesRepositoryTest { alertMode = true, inactivityDetection = true, automaticSms = true, - automaticCalls = false), + automaticCalls = false, + microphoneAccess = false), themePreferences = true) @Before @@ -52,11 +53,13 @@ class HybridUserPreferencesRepositoryTest { coEvery { mockLocal.setInactivityDetection(any()) } just Runs coEvery { mockLocal.setAutomaticSms(any()) } just Runs coEvery { mockLocal.setDarkMode(any()) } just Runs + coEvery { mockLocal.setMicrophoneAccess(any()) } just Runs coEvery { mockRemote.setAlertMode(any()) } just Runs coEvery { mockRemote.setInactivityDetection(any()) } just Runs coEvery { mockRemote.setAutomaticSms(any()) } just Runs coEvery { mockRemote.setDarkMode(any()) } just Runs + coEvery { mockRemote.setMicrophoneAccess(any()) } just Runs repository = HybridUserPreferencesRepository(mockLocal, mockRemote) } @@ -85,7 +88,8 @@ class HybridUserPreferencesRepositoryTest { alertMode = true, inactivityDetection = false, automaticSms = true, - automaticCalls = false), + automaticCalls = false, + microphoneAccess = false), themePreferences = false) every { mockLocal.getUserPreferences } returns flowOf(defaultPreferences) @@ -98,6 +102,7 @@ class HybridUserPreferencesRepositoryTest { mockLocal.setInactivityDetection(false) mockLocal.setAutomaticSms(true) mockLocal.setDarkMode(false) + mockLocal.setMicrophoneAccess(false) } } @@ -216,6 +221,13 @@ class HybridUserPreferencesRepositoryTest { coVerify { mockLocal.setDarkMode(false) } } + @Test + fun `setMicrophoneAccess updates both repositories when available`() = runTest { + repository.setMicrophoneAccess(true) + coVerify { mockLocal.setMicrophoneAccess(true) } + coVerify { mockRemote.setMicrophoneAccess(true) } + } + @Test fun `multiple updates work correctly when both repositories available`() = runTest { repository.setAlertMode(true) diff --git a/app/src/test/java/com/github/warnastrophy/core/data/repository/UserPreferencesRepositoryLocalTest.kt b/app/src/test/java/com/github/warnastrophy/core/data/repository/UserPreferencesRepositoryLocalTest.kt index 506416802..dd27a26bd 100644 --- a/app/src/test/java/com/github/warnastrophy/core/data/repository/UserPreferencesRepositoryLocalTest.kt +++ b/app/src/test/java/com/github/warnastrophy/core/data/repository/UserPreferencesRepositoryLocalTest.kt @@ -69,6 +69,7 @@ class UserPreferencesRepositoryLocalTest { assertEquals(alertModeValue, preferences.dangerModePreferences.alertMode) assertFalse(preferences.dangerModePreferences.inactivityDetection) assertFalse(preferences.dangerModePreferences.automaticSms) + assertFalse(preferences.dangerModePreferences.microphoneAccess) } } @@ -84,6 +85,7 @@ class UserPreferencesRepositoryLocalTest { assertFalse(preferences.dangerModePreferences.alertMode) assertFalse(preferences.dangerModePreferences.automaticSms) assertFalse(preferences.themePreferences) + assertFalse(preferences.dangerModePreferences.microphoneAccess) } } @@ -98,6 +100,7 @@ class UserPreferencesRepositoryLocalTest { assertFalse(preferences.dangerModePreferences.alertMode) assertFalse(preferences.dangerModePreferences.inactivityDetection) assertFalse(preferences.themePreferences) + assertFalse(preferences.dangerModePreferences.microphoneAccess) } } @@ -112,6 +115,7 @@ class UserPreferencesRepositoryLocalTest { assertFalse(preferences.dangerModePreferences.alertMode) assertFalse(preferences.dangerModePreferences.inactivityDetection) assertFalse(preferences.themePreferences) + assertFalse(preferences.dangerModePreferences.microphoneAccess) } } @@ -127,16 +131,19 @@ class UserPreferencesRepositoryLocalTest { assertTrue(intermediatePrefs.dangerModePreferences.inactivityDetection) assertFalse(intermediatePrefs.dangerModePreferences.automaticSms) assertTrue(intermediatePrefs.themePreferences) + assertFalse(intermediatePrefs.dangerModePreferences.microphoneAccess) repository.setAutomaticSms(true) repository.setAlertMode(false) repository.setDarkMode(false) + repository.setMicrophoneAccess(true) val finalPrefs = repository.getUserPreferences.first() assertFalse(finalPrefs.dangerModePreferences.alertMode) assertTrue(finalPrefs.dangerModePreferences.inactivityDetection) assertTrue(finalPrefs.dangerModePreferences.automaticSms) + assertTrue(finalPrefs.dangerModePreferences.microphoneAccess) assertFalse(finalPrefs.themePreferences) } @@ -154,6 +161,8 @@ class UserPreferencesRepositoryLocalTest { assertFalse(preferences.dangerModePreferences.inactivityDetection) assertFalse(preferences.dangerModePreferences.automaticSms) assertFalse(preferences.dangerModePreferences.automaticCalls) + assertFalse(preferences.dangerModePreferences.microphoneAccess) + assertFalse(preferences.themePreferences) } diff --git a/app/src/test/java/com/github/warnastrophy/core/data/repository/UserPreferencesRepositoryRemoteTest.kt b/app/src/test/java/com/github/warnastrophy/core/data/repository/UserPreferencesRepositoryRemoteTest.kt index a8a28f857..4b8a8922f 100644 --- a/app/src/test/java/com/github/warnastrophy/core/data/repository/UserPreferencesRepositoryRemoteTest.kt +++ b/app/src/test/java/com/github/warnastrophy/core/data/repository/UserPreferencesRepositoryRemoteTest.kt @@ -92,6 +92,8 @@ class UserPreferencesRepositoryRemoteTest { assertFalse(result.dangerModePreferences.alertMode) assertFalse(result.dangerModePreferences.inactivityDetection) assertFalse(result.dangerModePreferences.automaticSms) + assertFalse(result.dangerModePreferences.automaticCalls) + assertFalse(result.dangerModePreferences.microphoneAccess) assertFalse(result.themePreferences) } @@ -113,6 +115,8 @@ class UserPreferencesRepositoryRemoteTest { assertFalse(result.dangerModePreferences.alertMode) assertFalse(result.dangerModePreferences.inactivityDetection) assertFalse(result.dangerModePreferences.automaticSms) + assertFalse(result.dangerModePreferences.automaticCalls) + assertFalse(result.dangerModePreferences.microphoneAccess) assertFalse(result.themePreferences) } @@ -124,6 +128,7 @@ class UserPreferencesRepositoryRemoteTest { "inactivityDetection" to true, "automaticSms" to false, "automaticCalls" to false, + "microphoneAccess" to true, "darkMode" to true) val mockSnapshot = mockk() @@ -145,12 +150,13 @@ class UserPreferencesRepositoryRemoteTest { assertTrue(result.dangerModePreferences.inactivityDetection) assertFalse(result.dangerModePreferences.automaticSms) assertFalse(result.dangerModePreferences.automaticCalls) + assertTrue(result.dangerModePreferences.microphoneAccess) assertTrue(result.themePreferences) } @Test fun `getUserPreferences handles null values in document`() = runTest { - val data = mapOf("alertMode" to null, "inactivityDetection" to true) + val data = mapOf("alertMode" to null, "inactivityDetection" to true, "microphoneAccess" to null) val mockSnapshot = mockk() every { mockSnapshot.exists() } returns true @@ -169,6 +175,7 @@ class UserPreferencesRepositoryRemoteTest { assertFalse(result.dangerModePreferences.alertMode) assertTrue(result.dangerModePreferences.inactivityDetection) assertFalse(result.dangerModePreferences.automaticSms) + assertFalse(result.dangerModePreferences.microphoneAccess) assertFalse(result.themePreferences) } @@ -252,17 +259,35 @@ class UserPreferencesRepositoryRemoteTest { verify(exactly = 0) { mockDocument.update(any(), any()) } } + @Test + fun `setMicrophoneAccess updates field successfully when authenticated`() = runTest { + repository.setMicrophoneAccess(true) + + verify { mockDocument.update("microphoneAccess", true) } + } + + @Test + fun `setMicrophoneAccess does nothing when user is not authenticated`() = runTest { + every { mockAuth.currentUser } returns null + + repository.setMicrophoneAccess(true) + + verify(exactly = 0) { mockDocument.update(any(), any()) } + } + @Test fun `multiple field updates work correctly when authenticated`() = runTest { repository.setAlertMode(true) repository.setInactivityDetection(false) repository.setAutomaticSms(true) repository.setDarkMode(false) + repository.setMicrophoneAccess(true) verify { mockDocument.update("alertMode", true) } verify { mockDocument.update("inactivityDetection", false) } verify { mockDocument.update("automaticSms", true) } verify { mockDocument.update("darkMode", false) } + verify { mockDocument.update("microphoneAccess", true) } } @Test @@ -273,6 +298,7 @@ class UserPreferencesRepositoryRemoteTest { repository.setInactivityDetection(false) repository.setAutomaticSms(true) repository.setDarkMode(false) + repository.setMicrophoneAccess(true) verify(exactly = 0) { mockDocument.update(any(), any()) } verify(exactly = 0) { mockDocument.set(any>(), any()) } @@ -312,6 +338,8 @@ class UserPreferencesRepositoryRemoteTest { "alertMode" to true, "inactivityDetection" to true, "automaticSms" to true, + "automaticCalls" to true, + "microphoneAccess" to true, "darkMode" to true) val mockSnapshot = mockk() @@ -331,6 +359,8 @@ class UserPreferencesRepositoryRemoteTest { assertTrue(result.dangerModePreferences.alertMode) assertTrue(result.dangerModePreferences.inactivityDetection) assertTrue(result.dangerModePreferences.automaticSms) + assertTrue(result.dangerModePreferences.automaticCalls) + assertTrue(result.dangerModePreferences.microphoneAccess) assertTrue(result.themePreferences) } @@ -367,6 +397,9 @@ class UserPreferencesRepositoryRemoteTest { repository.setDarkMode(true) verify { mockDocument.update("darkMode", any()) } + + repository.setMicrophoneAccess(true) + verify { mockDocument.update("microphoneAccess", any()) } } @Test From f55f2f621fd87dd54b84abf523577c00fbec9783 Mon Sep 17 00:00:00 2001 From: ssidimoh Date: Mon, 15 Dec 2025 20:00:54 +0100 Subject: [PATCH 04/15] fix: fix end2end tests fix initialization error of userPreferenceRepository --- .../java/com/github/warnastrophy/e2e/EndToEndM1Test.kt | 4 ++++ .../java/com/github/warnastrophy/e2e/EndToEndM2Test.kt | 3 +++ 2 files changed, 7 insertions(+) diff --git a/app/src/androidTest/java/com/github/warnastrophy/e2e/EndToEndM1Test.kt b/app/src/androidTest/java/com/github/warnastrophy/e2e/EndToEndM1Test.kt index fc9878d49..e6295d66a 100644 --- a/app/src/androidTest/java/com/github/warnastrophy/e2e/EndToEndM1Test.kt +++ b/app/src/androidTest/java/com/github/warnastrophy/e2e/EndToEndM1Test.kt @@ -8,7 +8,9 @@ import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick 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.di.userPrefsDataStore import com.github.warnastrophy.core.ui.features.profile.ThemeViewModel import com.github.warnastrophy.core.ui.navigation.NavigationTestTags import io.mockk.every @@ -32,6 +34,8 @@ class EndToEndM1Test : EndToEndUtils() { every { themeViewModel.isDarkMode } returns mockk(relaxed = true) ContactRepositoryProvider.initLocal(context) + + UserPreferencesRepositoryProvider.initLocal(context.userPrefsDataStore) StateManagerService.init(context) contactRepository = ContactRepositoryProvider.repository activityRepository = StateManagerService.activityRepository diff --git a/app/src/androidTest/java/com/github/warnastrophy/e2e/EndToEndM2Test.kt b/app/src/androidTest/java/com/github/warnastrophy/e2e/EndToEndM2Test.kt index 2b90200a9..58d484360 100644 --- a/app/src/androidTest/java/com/github/warnastrophy/e2e/EndToEndM2Test.kt +++ b/app/src/androidTest/java/com/github/warnastrophy/e2e/EndToEndM2Test.kt @@ -4,7 +4,9 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.onNodeWithTag import com.github.warnastrophy.core.data.provider.ContactRepositoryProvider import com.github.warnastrophy.core.data.provider.HealthCardRepositoryProvider +import com.github.warnastrophy.core.data.provider.UserPreferencesRepositoryProvider import com.github.warnastrophy.core.data.service.StateManagerService +import com.github.warnastrophy.core.di.userPrefsDataStore import com.github.warnastrophy.core.ui.features.dashboard.DashboardScreenTestTags import com.github.warnastrophy.core.ui.navigation.NavigationTestTags import org.junit.After @@ -19,6 +21,7 @@ class EndToEndM2Test : EndToEndUtils() { val context = composeTestRule.activity.applicationContext ContactRepositoryProvider.initLocal(context) + UserPreferencesRepositoryProvider.initLocal(context.userPrefsDataStore) StateManagerService.init(context) contactRepository = ContactRepositoryProvider.repository HealthCardRepositoryProvider.useLocalEncrypted(context) From 41371e399e09911b74e37b698e544a991b60786c Mon Sep 17 00:00:00 2001 From: ssidimoh Date: Tue, 16 Dec 2025 21:37:25 +0100 Subject: [PATCH 05/15] fix: fix end2end tests --- .../java/com/github/warnastrophy/e2e/EndToEndM1Test.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/androidTest/java/com/github/warnastrophy/e2e/EndToEndM1Test.kt b/app/src/androidTest/java/com/github/warnastrophy/e2e/EndToEndM1Test.kt index acc769edb..a03c6ac14 100644 --- a/app/src/androidTest/java/com/github/warnastrophy/e2e/EndToEndM1Test.kt +++ b/app/src/androidTest/java/com/github/warnastrophy/e2e/EndToEndM1Test.kt @@ -36,7 +36,6 @@ class EndToEndM1Test : EndToEndUtils() { ContactRepositoryProvider.initLocal(context) UserPreferencesRepositoryProvider.initLocal(context.userPrefsDataStore) - StateManagerService.init(context) composeTestRule.runOnUiThread { StateManagerService.init(context) } contactRepository = ContactRepositoryProvider.repository activityRepository = StateManagerService.activityRepository From eec716b610e4215b714f4042599319c5ae9be427 Mon Sep 17 00:00:00 2001 From: ssidimoh Date: Tue, 16 Dec 2025 21:42:20 +0100 Subject: [PATCH 06/15] merge: merge main --- README.md | 185 ++++++++- app/build.gradle.kts | 14 +- .../warnastrophy/WarnastrophyAppTest.kt | 2 +- .../HealthCardStorageTest.kt | 8 +- .../HybridHealthCardRepositoryTest.kt | 2 +- .../LocalActivityRepositoryTest.kt | 2 +- .../LocalHealthCardRepositoryTest.kt | 2 +- .../ui/components/ConfirmationPopupTest.kt | 56 +++ .../warnastrophy/core/ui/features/UITest.kt | 2 +- .../activity/ActivityListScreenTest.kt | 2 +- .../features/dashboard/DangerModeCardTest.kt | 2 +- .../dashboard/DashboardHealthCardTest.kt | 6 +- .../HealthCardRepositoryProviderTest.kt | 2 +- .../core/ui/features/map/MapIconTest.kt | 365 +++++++++++++++++- .../DangerModePreferencesScreenTest.kt | 2 +- .../warnastrophy/core/util/HiltTestRunner.kt | 21 - .../util/MockUserPreferencesRepository.kt | 2 +- .../github/warnastrophy/e2e/EndToEndM1Test.kt | 2 +- .../github/warnastrophy/e2e/EndToEndM2Test.kt | 2 +- .../github/warnastrophy/e2e/EndToEndM3Test.kt | 187 +++++++++ app/src/debug/AndroidManifest.xml | 11 - .../github/warnastrophy/HiltTestActivity.kt | 6 - app/src/main/AndroidManifest.xml | 1 - .../com/github/warnastrophy/MainActivity.kt | 29 +- .../github/warnastrophy/WarnastrophyApp.kt | 6 - .../ActivityRepository.kt | 5 +- .../UserPreferencesRepository.kt | 8 +- .../ContactsStorage.kt | 2 +- .../HealthCardStorage.kt | 2 +- .../LocalActivityRepository.kt | 4 +- .../LocalHealthCardRepository.kt | 2 +- .../{localStorage => local}/StorageResults.kt | 2 +- .../data/localStorage/LocalUserIdStore.kt | 21 - .../provider/ActivityRepositoryProvider.kt | 4 +- .../provider/ContactRepositoryProvider.kt | 4 +- .../{service => provider}/DeviceIdProvider.kt | 2 +- .../provider/HealthCardRepositoryProvider.kt | 3 +- .../KeyStoreProvider.kt | 2 +- .../provider/OnboardingRepositoryProvider.kt | 23 ++ .../UserPreferencesRepositoryProvider.kt | 2 +- .../data/repository/ContactRespositoryImpl.kt | 2 +- .../HybridUserPreferencesRepository.kt | 1 + .../data/repository/MockActivityRepository.kt | 1 + .../OnboardingRepositoryProvider.kt | 17 - .../UserPreferencesRepositoryLocal.kt | 1 + .../UserPreferencesRepositoryRemote.kt | 7 +- .../data/service/DangerModeOrchestrator.kt | 2 +- .../core/data/service/StateManagerService.kt | 9 +- .../warnastrophy/core/di/ContactsModule.kt | 23 -- .../warnastrophy/core/di/DataStoreModule.kt | 24 -- .../github/warnastrophy/core/di/GPSModule.kt | 16 - .../warnastrophy/core/di/HazardsModule.kt | 28 -- .../warnastrophy/core/di/LocationModule.kt | 23 -- .../warnastrophy/core/di/PermissionModule.kt | 24 -- .../warnastrophy/core/model/Activity.kt | 2 +- .../features/dashboard/ConfirmationPopup.kt | 75 ++++ .../ui/features/dashboard/DangerModeCard.kt | 45 ++- .../dashboard/DangerModeCardViewModel.kt | 113 +++++- .../core/ui/features/dashboard/Dashboard.kt | 1 - ...boardEmergencyContactsStatefulViewModel.kt | 8 +- .../dashboard/DashboardHealthCardViewModel.kt | 4 +- .../dashboard}/SafeZoneTopBar.kt | 2 +- .../activity/ActivityListViewModel.kt | 2 +- .../activity/AddActivityViewModel.kt | 2 +- .../activity/EditActivityViewModel.kt | 2 +- .../ui/features/health/HealthCardViewModel.kt | 4 +- .../warnastrophy/core/ui/features/map/Map.kt | 12 +- .../core/ui/features/map/MapIcon.kt | 130 +++++-- .../onboard/AppStateManagerViewModel.kt | 4 +- .../onboard/OnboardingGraphUI.kt | 2 +- .../{ => features}/onboard/OnboardingModel.kt | 2 +- .../onboard/OnboardingScreen.kt | 2 +- .../ui/features/profile/ThemeViewModel.kt | 2 +- .../features/profile/ThemeViewModelFactory.kt | 2 +- .../DangerModePreferencesViewModel.kt | 2 +- .../warnastrophy/core/util/CryptoUtils.kt | 2 +- .../HealthCardStorageUnitTest.kt | 2 +- .../StorageExceptionTest.kt | 2 +- .../repository/ContactRepositoryImplTest.kt | 2 +- .../ContactsRepositoryLocalTests.kt | 6 +- .../UserPreferencesRepositoryRemoteTest.kt | 1 + .../service/DangerModeOrchestratorTest.kt | 2 +- .../data/service/ForegroundServiceTest.kt | 2 +- .../dashboard/DangerModeCardViewModelTest.kt | 92 ++++- .../DashboardHealthCardViewModelTest.kt | 6 +- .../feature/health/HealthCardViewModelTest.kt | 6 +- .../onboard/AppStateManagerViewModelTest.kt | 1 + .../DangerModePreferencesViewModelTest.kt | 2 +- .../profile/preferences/ThemeViewModelTest.kt | 2 +- .../warnastrophy/core/util/CryptoUtilsTest.kt | 2 +- 90 files changed, 1329 insertions(+), 402 deletions(-) rename app/src/androidTest/java/com/github/warnastrophy/core/data/{localStorage => local}/HealthCardStorageTest.kt (95%) rename app/src/androidTest/java/com/github/warnastrophy/core/data/{localStorage => local}/HybridHealthCardRepositoryTest.kt (97%) rename app/src/androidTest/java/com/github/warnastrophy/core/data/{localStorage => local}/LocalActivityRepositoryTest.kt (99%) rename app/src/androidTest/java/com/github/warnastrophy/core/data/{localStorage => local}/LocalHealthCardRepositoryTest.kt (98%) create mode 100644 app/src/androidTest/java/com/github/warnastrophy/core/ui/components/ConfirmationPopupTest.kt delete mode 100644 app/src/androidTest/java/com/github/warnastrophy/core/util/HiltTestRunner.kt create mode 100644 app/src/androidTest/java/com/github/warnastrophy/e2e/EndToEndM3Test.kt delete mode 100644 app/src/debug/AndroidManifest.xml delete mode 100644 app/src/debug/java/com/github/warnastrophy/HiltTestActivity.kt delete mode 100644 app/src/main/java/com/github/warnastrophy/WarnastrophyApp.kt rename app/src/main/java/com/github/warnastrophy/core/data/{repository => interfaces}/ActivityRepository.kt (95%) rename app/src/main/java/com/github/warnastrophy/core/data/{repository => interfaces}/UserPreferencesRepository.kt (85%) rename app/src/main/java/com/github/warnastrophy/core/data/{localStorage => local}/ContactsStorage.kt (98%) rename app/src/main/java/com/github/warnastrophy/core/data/{localStorage => local}/HealthCardStorage.kt (99%) rename app/src/main/java/com/github/warnastrophy/core/data/{localStorage => local}/LocalActivityRepository.kt (98%) rename app/src/main/java/com/github/warnastrophy/core/data/{localStorage => local}/LocalHealthCardRepository.kt (96%) rename app/src/main/java/com/github/warnastrophy/core/data/{localStorage => local}/StorageResults.kt (96%) delete mode 100644 app/src/main/java/com/github/warnastrophy/core/data/localStorage/LocalUserIdStore.kt rename app/src/main/java/com/github/warnastrophy/core/data/{service => provider}/DeviceIdProvider.kt (91%) rename app/src/main/java/com/github/warnastrophy/core/data/{localStorage => provider}/KeyStoreProvider.kt (97%) create mode 100644 app/src/main/java/com/github/warnastrophy/core/data/provider/OnboardingRepositoryProvider.kt delete mode 100644 app/src/main/java/com/github/warnastrophy/core/data/repository/OnboardingRepositoryProvider.kt delete mode 100644 app/src/main/java/com/github/warnastrophy/core/di/ContactsModule.kt delete mode 100644 app/src/main/java/com/github/warnastrophy/core/di/DataStoreModule.kt delete mode 100644 app/src/main/java/com/github/warnastrophy/core/di/GPSModule.kt delete mode 100644 app/src/main/java/com/github/warnastrophy/core/di/HazardsModule.kt delete mode 100644 app/src/main/java/com/github/warnastrophy/core/di/LocationModule.kt delete mode 100644 app/src/main/java/com/github/warnastrophy/core/di/PermissionModule.kt create mode 100644 app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/ConfirmationPopup.kt rename app/src/main/java/com/github/warnastrophy/core/ui/{layout => features/dashboard}/SafeZoneTopBar.kt (96%) rename app/src/main/java/com/github/warnastrophy/core/ui/{ => features}/onboard/AppStateManagerViewModel.kt (91%) rename app/src/main/java/com/github/warnastrophy/core/ui/{ => features}/onboard/OnboardingGraphUI.kt (97%) rename app/src/main/java/com/github/warnastrophy/core/ui/{ => features}/onboard/OnboardingModel.kt (94%) rename app/src/main/java/com/github/warnastrophy/core/ui/{ => features}/onboard/OnboardingScreen.kt (99%) rename app/src/test/java/com/github/warnastrophy/core/data/{localStorage => local}/HealthCardStorageUnitTest.kt (99%) rename app/src/test/java/com/github/warnastrophy/core/data/{localStorage => local}/StorageExceptionTest.kt (96%) diff --git a/README.md b/README.md index 0707b4ce4..24dbdc82c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,182 @@ -# warnastrophy -incident detection app -figma plan https://www.figma.com/team_invite/redeem/NSv7YeZKUNalLSsFQN0U6O +# Warnastrophy – Emergency and Safety Assistant (Android) + +Warnastrophy is an Android application designed to assist users in dangerous or emergency +situations. The application prioritizes hands-free interaction so that users can confirm or cancel emergency actions when it may not be safe or possible to interact with the screen. It combines voice-based confirmation, location awareness, and secure data management to support emergency workflows while protecting sensitive personal information. + +The project is built using Jetpack Compose and follows a modular service and repository +architecture. It supports both local and cloud-backed storage depending on the user’s +authentication state and is designed to be testable through clear interfaces and mockable +components. + +## Project purpose + +In emergency situations, users may be stressed, injured, moving, or unable to focus on +their device. Warnastrophy aims to reduce the number of manual interactions required to +take action. The app provides a structured emergency flow that can escalate to actions +such as phone calls or alerts, while still giving the user a clear opportunity to confirm +or cancel using voice commands. + +## Main features + +### Authentication and onboarding + +The application supports user authentication using Firebase Authentication. It integrates +Android Credential Manager to simplify sign-in flows on supported devices. Warnastrophy +also includes an onboarding process, and the app determines which screen to display at +launch based on the user’s authentication state and whether onboarding has been completed. + +### Hybrid and secure storage + +Warnastrophy supports both local and cloud-based data storage. When a user is not authenticated, +repositories operate entirely in local mode. When the user is authenticated, repositories switch +to a hybrid mode using Firebase Firestore for cloud synchronization while maintaining local access +when appropriate. This design ensures both availability and data security. + +### Danger mode and confirmation flow + +The app includes a danger mode orchestrated through shared services. When a confirmation is +required, a dedicated communication screen is displayed as an overlay. The application uses +text-to-speech to ask the user to confirm an action. Once speaking finishes, the app listens +for a voice response. The user can respond with short confirmations such as “yes” or “no”. +The app then verbally confirms whether the alert was sent or canceled. + +### Voice communication + +Voice interaction is implemented through two main services: +- Speech-to-text using Android SpeechRecognizer for recognition and confirmation parsing. +- Text-to-speech using Android TextToSpeech with an utterance progress listener to track +- speaking state. + +Both services expose their state through StateFlow, allowing the UI to react to listening +and speaking status, recognized text, errors, and audio levels. These services are defined +through interfaces, which enables deterministic and reliable testing using mock implementations. + +### Emergency actions (call and SMS) + +Emergency actions such as phone calls are implemented as explicit services. +Calling is permission-aware and uses the ACTION_CALL intent. SMS support can +be integrated through a similar abstraction. These services are designed to be +replaceable and testable, ensuring that emergency behavior can be validated +without triggering real actions during tests. + +### Location and mapping + +The application includes map screens and a map preview used on the dashboard. +Location access and GPS behavior are wrapped in service abstractions that can +be mocked during testing. Reverse geocoding is provided through a Nominatim service +and repository. + +### Contacts and health information + +Warnastrophy allows users to manage emergency contacts and store health card +information. These features are fully integrated into the application flow and work +with both local and hybrid storage modes. + +### End-to-end testing support + +The project includes a complete end-to-end testing setup using Jetpack Compose UI +tests. The test harness can render the full application composable and optionally +replace the map with a fake component to improve stability. Voice confirmation flows +can be tested using mock speech-to-text and text-to-speech services. + +## Architecture overview + +The application follows a modular and test-friendly architecture: +- The UI is built with Jetpack Compose and Navigation Compose. +- State is managed using ViewModels and Kotlin StateFlow. +- Core behavior is implemented through service abstractions, including speech, +- text-to-speech, GPS, sensors, and danger mode orchestration. +- Data access is handled via repositories with local and hybrid cloud implementations. +- Global initialization and shared services are coordinated through a central StateManagerService. + +This structure keeps UI code reactive and lightweight, isolates Android framework +dependencies inside services, and ensures that core logic remains easy to test. + +## Technologies and tools + +- Kotlin +- Jetpack Compose (Material 3) +- Navigation Compose +- Android ViewModel +- Kotlin Coroutines and StateFlow +- Android DataStore (Preferences) +- Firebase Authentication +- Firebase Firestore +- Android Credential Manager +- Android SpeechRecognizer (speech-to-text) +- Android TextToSpeech (text-to-speech) +- SensorManager (accelerometer and gyroscope) +- JUnit +- Jetpack Compose UI testing + +## Running the app + +### Prerequisites + +- Android Studio (recent stable version recommended) +- Android SDK 33 or higher (or the version specified by the project) +- An emulator or physical device with microphone support +- Google speech recognition services available on the device +- A Firebase project configured for the application + +### Installation and setup + +1. Clone the repository: + ```bash + git clone https://github.com/SWENTapp/warnastrophy.git +2. Open the project in Android Studio. +3. Add your Firebase configuration file: +4. Place google-services.json in the app/ directory. +5. Sync Gradle and build the project. +6. Run the application on an emulator or a physical device. + +## Using the main features + +### First-time usage + +1. Launch the application. +2. Sign in or create an account. +4. Complete the onboarding process. +5. Add emergency contacts. +6. Optionally fill in health card information. + +## Voice confirmation flow + +### When the app requires a voice confirmation: + +- The communication screen is displayed. +- The app speaks a confirmation request. +- After speaking ends, the app starts listening. +- The user responds with “yes” or “no”. +- The app verbally confirms the result and proceeds accordingly. + +## Building and testing +### Run unit tests +./gradlew test + +### Run instrumented and end-to-end tests +./gradlew connectedAndroidTest + +End-to-end tests use an isolated DataStore and can replace real components, +such as the map, with fake implementations to reduce flakiness. + +## Design and documentation links + +### Figma UI mockups and design system: +https://www.figma.com/team_invite/redeem/NSv7YeZKUNalLSsFQN0U6O + +### Figma architecture diagrams: +https://www.figma.com/team_invite/redeem/NSv7YeZKUNalLSsFQN0U6O + +### GitHub Wiki (technical documentation): +https://github.com/SWENTapp/warnastrophy/wiki + +The GitHub Wiki contains deeper technical documentation, including architecture +explanations, service contracts, and testing strategies. + +## Contributing + +Contributions are welcome. New features should follow the existing architectural +patterns. Services should be defined through interfaces to remain mockable, and tests +should be added or updated when modifying core logic or user flows. + diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 570f2296e..e3276ab3f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -8,9 +8,6 @@ plugins { alias(libs.plugins.sonar) id("com.google.gms.google-services") id("jacoco") - id("com.google.devtools.ksp") - id("kotlin-kapt") - id("com.google.dagger.hilt.android") } val localProps = Properties() @@ -43,7 +40,7 @@ android { targetSdk = 34 versionCode = 1 versionName = "1.0" - testInstrumentationRunner = "com.github.warnastrophy.core.util.HiltTestRunner" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { useSupportLibrary = true } @@ -310,15 +307,6 @@ dependencies { androidTestImplementation(libs.androidx.espresso.intents) testImplementation(kotlin("test")) - // Hilt - implementation(libs.dagger.hilt.android) - kapt(libs.dagger.hilt.android.compiler) - implementation(libs.androidx.hilt.navigation.compose) - androidTestImplementation(libs.hilt.android.testing) - kaptAndroidTest(libs.dagger.hilt.android.compiler) - testImplementation(libs.hilt.android.testing) - kaptTest(libs.dagger.hilt.android.compiler) - // in-app browser implementation(libs.androidx.browser) } diff --git a/app/src/androidTest/java/com/github/warnastrophy/WarnastrophyAppTest.kt b/app/src/androidTest/java/com/github/warnastrophy/WarnastrophyAppTest.kt index d7a55802a..384e8db5e 100644 --- a/app/src/androidTest/java/com/github/warnastrophy/WarnastrophyAppTest.kt +++ b/app/src/androidTest/java/com/github/warnastrophy/WarnastrophyAppTest.kt @@ -8,7 +8,7 @@ import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.github.warnastrophy.core.ui.onboard.OnboardingScreenTestTags +import com.github.warnastrophy.core.ui.features.onboard.OnboardingScreenTestTags import com.google.firebase.FirebaseApp import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseUser diff --git a/app/src/androidTest/java/com/github/warnastrophy/core/data/localStorage/HealthCardStorageTest.kt b/app/src/androidTest/java/com/github/warnastrophy/core/data/local/HealthCardStorageTest.kt similarity index 95% rename from app/src/androidTest/java/com/github/warnastrophy/core/data/localStorage/HealthCardStorageTest.kt rename to app/src/androidTest/java/com/github/warnastrophy/core/data/local/HealthCardStorageTest.kt index 81c945593..5d3518128 100644 --- a/app/src/androidTest/java/com/github/warnastrophy/core/data/localStorage/HealthCardStorageTest.kt +++ b/app/src/androidTest/java/com/github/warnastrophy/core/data/local/HealthCardStorageTest.kt @@ -4,10 +4,10 @@ import android.content.Context import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.test.core.app.ApplicationProvider -import com.github.warnastrophy.core.data.localStorage.HealthCardStorage -import com.github.warnastrophy.core.data.localStorage.StorageException -import com.github.warnastrophy.core.data.localStorage.StorageResult -import com.github.warnastrophy.core.data.localStorage.healthCardDataStore +import com.github.warnastrophy.core.data.local.HealthCardStorage +import com.github.warnastrophy.core.data.local.StorageException +import com.github.warnastrophy.core.data.local.StorageResult +import com.github.warnastrophy.core.data.local.healthCardDataStore import com.github.warnastrophy.core.model.HealthCard import com.github.warnastrophy.core.util.CryptoUtils import com.google.gson.Gson diff --git a/app/src/androidTest/java/com/github/warnastrophy/core/data/localStorage/HybridHealthCardRepositoryTest.kt b/app/src/androidTest/java/com/github/warnastrophy/core/data/local/HybridHealthCardRepositoryTest.kt similarity index 97% rename from app/src/androidTest/java/com/github/warnastrophy/core/data/localStorage/HybridHealthCardRepositoryTest.kt rename to app/src/androidTest/java/com/github/warnastrophy/core/data/local/HybridHealthCardRepositoryTest.kt index ee8377529..cdac5f5e3 100644 --- a/app/src/androidTest/java/com/github/warnastrophy/core/data/localStorage/HybridHealthCardRepositoryTest.kt +++ b/app/src/androidTest/java/com/github/warnastrophy/core/data/local/HybridHealthCardRepositoryTest.kt @@ -1,4 +1,4 @@ -package com.github.warnastrophy.core.data.localStorage +package com.github.warnastrophy.core.data.local import com.github.warnastrophy.core.data.repository.HealthCardRepository import com.github.warnastrophy.core.data.repository.HybridHealthCardRepository diff --git a/app/src/androidTest/java/com/github/warnastrophy/core/data/localStorage/LocalActivityRepositoryTest.kt b/app/src/androidTest/java/com/github/warnastrophy/core/data/local/LocalActivityRepositoryTest.kt similarity index 99% rename from app/src/androidTest/java/com/github/warnastrophy/core/data/localStorage/LocalActivityRepositoryTest.kt rename to app/src/androidTest/java/com/github/warnastrophy/core/data/local/LocalActivityRepositoryTest.kt index 969679246..e35ad885d 100644 --- a/app/src/androidTest/java/com/github/warnastrophy/core/data/localStorage/LocalActivityRepositoryTest.kt +++ b/app/src/androidTest/java/com/github/warnastrophy/core/data/local/LocalActivityRepositoryTest.kt @@ -1,4 +1,4 @@ -package com.github.warnastrophy.core.data.localStorage +package com.github.warnastrophy.core.data.local import android.content.Context import androidx.datastore.preferences.core.edit diff --git a/app/src/androidTest/java/com/github/warnastrophy/core/data/localStorage/LocalHealthCardRepositoryTest.kt b/app/src/androidTest/java/com/github/warnastrophy/core/data/local/LocalHealthCardRepositoryTest.kt similarity index 98% rename from app/src/androidTest/java/com/github/warnastrophy/core/data/localStorage/LocalHealthCardRepositoryTest.kt rename to app/src/androidTest/java/com/github/warnastrophy/core/data/local/LocalHealthCardRepositoryTest.kt index a6c6f517e..5515d80df 100644 --- a/app/src/androidTest/java/com/github/warnastrophy/core/data/localStorage/LocalHealthCardRepositoryTest.kt +++ b/app/src/androidTest/java/com/github/warnastrophy/core/data/local/LocalHealthCardRepositoryTest.kt @@ -1,4 +1,4 @@ -package com.github.warnastrophy.core.data.localStorage +package com.github.warnastrophy.core.data.local import android.content.Context import androidx.test.core.app.ApplicationProvider 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 new file mode 100644 index 000000000..e6f4a49f9 --- /dev/null +++ b/app/src/androidTest/java/com/github/warnastrophy/core/ui/components/ConfirmationPopupTest.kt @@ -0,0 +1,56 @@ +package com.github.warnastrophy.core.ui.components + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +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 org.junit.Assert.assertEquals +import org.junit.Test + +class ConfirmationPopupTest : BaseAndroidComposeTest() { + + private fun setPopupContent(onConfirm: () -> Unit = {}, onCancel: () -> Unit = {}) { + composeTestRule.setContent { + ConfirmationPopup( + title = "Test Title", + message = "Test description", + confirmLabel = "Confirm", + cancelLabel = "Cancel", + onConfirm = onConfirm, + onCancel = onCancel) + } + } + + @Test + fun confirmationPopup_displaysAllTexts() { + setPopupContent() + + composeTestRule.onNodeWithTag(ConfirmationPopupTestTags.DIALOG).assertIsDisplayed() + composeTestRule.onNodeWithTag(ConfirmationPopupTestTags.TITLE_TEXT).assertIsDisplayed() + composeTestRule.onNodeWithTag(ConfirmationPopupTestTags.DESCRIPTION_TEXT).assertIsDisplayed() + composeTestRule.onNodeWithTag(ConfirmationPopupTestTags.CONFIRM_BUTTON).assertIsDisplayed() + composeTestRule.onNodeWithTag(ConfirmationPopupTestTags.CANCEL_BUTTON).assertIsDisplayed() + } + + @Test + fun confirmationPopup_confirmInvokesCallback() { + var confirmCount = 0 + setPopupContent(onConfirm = { confirmCount++ }) + + composeTestRule.onNodeWithTag(ConfirmationPopupTestTags.CONFIRM_BUTTON).performClick() + + assertEquals(1, confirmCount) + } + + @Test + fun confirmationPopup_cancelInvokesCallback() { + var cancelCount = 0 + setPopupContent(onCancel = { cancelCount++ }) + + composeTestRule.onNodeWithTag(ConfirmationPopupTestTags.CANCEL_BUTTON).performClick() + + assertEquals(1, cancelCount) + } +} diff --git a/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/UITest.kt b/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/UITest.kt index 0b7b0d423..b9e9dd6f6 100644 --- a/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/UITest.kt +++ b/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/UITest.kt @@ -7,8 +7,8 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextClearance import androidx.compose.ui.test.performTextInput +import com.github.warnastrophy.core.data.interfaces.ActivityRepository import com.github.warnastrophy.core.data.interfaces.ContactsRepository -import com.github.warnastrophy.core.data.repository.ActivityRepository import com.github.warnastrophy.core.ui.features.contact.AddContactTestTags import com.github.warnastrophy.core.ui.features.contact.EditContactTestTags import com.github.warnastrophy.core.ui.features.dashboard.activity.AddActivityTestTags diff --git a/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/activity/ActivityListScreenTest.kt b/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/activity/ActivityListScreenTest.kt index 5be231e06..1621d3cf2 100644 --- a/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/activity/ActivityListScreenTest.kt +++ b/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/activity/ActivityListScreenTest.kt @@ -6,7 +6,7 @@ import androidx.compose.ui.test.hasAnyDescendant import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText import androidx.compose.ui.test.onNodeWithTag -import com.github.warnastrophy.core.data.repository.ActivityRepository +import com.github.warnastrophy.core.data.interfaces.ActivityRepository import com.github.warnastrophy.core.data.repository.MockActivityRepository import com.github.warnastrophy.core.model.Activity import com.github.warnastrophy.core.ui.features.dashboard.activity.ActivityListScreen diff --git a/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCardTest.kt b/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCardTest.kt index 0a582053e..342897725 100644 --- a/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCardTest.kt +++ b/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCardTest.kt @@ -19,12 +19,12 @@ import com.github.warnastrophy.core.data.repository.MockActivityRepository import com.github.warnastrophy.core.data.service.DangerLevel import com.github.warnastrophy.core.data.service.DangerModeService import com.github.warnastrophy.core.data.service.StateManagerService -import com.github.warnastrophy.core.di.userPrefsDataStore import com.github.warnastrophy.core.model.Activity import com.github.warnastrophy.core.permissions.PermissionResult import com.github.warnastrophy.core.ui.map.MockPermissionManager import com.github.warnastrophy.core.util.AppConfig import com.github.warnastrophy.core.util.BaseAndroidComposeTest +import com.github.warnastrophy.userPrefsDataStore import kotlinx.coroutines.runBlocking import org.junit.Before import org.junit.Test diff --git a/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/dashboard/DashboardHealthCardTest.kt b/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/dashboard/DashboardHealthCardTest.kt index 2f3001eed..358eee305 100644 --- a/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/dashboard/DashboardHealthCardTest.kt +++ b/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/dashboard/DashboardHealthCardTest.kt @@ -5,9 +5,9 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick -import com.github.warnastrophy.core.data.localStorage.HealthCardStorage -import com.github.warnastrophy.core.data.localStorage.StorageException -import com.github.warnastrophy.core.data.localStorage.StorageResult +import com.github.warnastrophy.core.data.local.HealthCardStorage +import com.github.warnastrophy.core.data.local.StorageException +import com.github.warnastrophy.core.data.local.StorageResult import com.github.warnastrophy.core.model.HealthCard import com.github.warnastrophy.core.ui.components.LoadingTestTags import com.github.warnastrophy.core.ui.features.dashboard.DashboardHealthCardStateful diff --git a/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/health/HealthCardRepositoryProviderTest.kt b/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/health/HealthCardRepositoryProviderTest.kt index b25bdb3b3..6b6264a51 100644 --- a/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/health/HealthCardRepositoryProviderTest.kt +++ b/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/health/HealthCardRepositoryProviderTest.kt @@ -3,7 +3,7 @@ package com.github.warnastrophy.core.ui.features.health import android.content.Context import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.github.warnastrophy.core.data.localStorage.LocalHealthCardRepository +import com.github.warnastrophy.core.data.local.LocalHealthCardRepository import com.github.warnastrophy.core.data.provider.HealthCardRepositoryProvider import com.github.warnastrophy.core.data.repository.HybridHealthCardRepository import com.google.firebase.auth.FirebaseAuth diff --git a/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/map/MapIconTest.kt b/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/map/MapIconTest.kt index fbe785e6e..c3180b74e 100644 --- a/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/map/MapIconTest.kt +++ b/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/map/MapIconTest.kt @@ -3,7 +3,10 @@ package com.github.warnastrophy.core.ui.features.map import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.getOrNull import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed @@ -14,6 +17,7 @@ import androidx.compose.ui.test.onNodeWithText import com.github.warnastrophy.core.model.Hazard import com.github.warnastrophy.core.util.BaseAndroidComposeTest import com.github.warnastrophy.core.util.formatDate +import com.google.android.gms.maps.model.LatLng import junit.framework.TestCase import org.junit.Test @@ -62,7 +66,12 @@ class MapIconTest : BaseAndroidComposeTest() { composeTestRule.setContent { hazard.value?.let { - HazardMarker(it, markerContent = { _, _, _, content -> Box { content() } }) + HazardMarker( + it, + markerIconProvider = { _, _, _, _ -> null }, + polygonContent = {}, + markerInfoWindowContent = { _, _, _, _, content -> Box { content() } }, + iconContent = { icon, tint -> icon(tint) }) } } @@ -196,13 +205,10 @@ class MapIconTest : BaseAndroidComposeTest() { composeTestRule.setContent { HazardMarker( hazard = hazard, - markerContent = { _, title, snippet, _ -> - HazardInfoWindowContent( - hazard = hazard, - title = title, - snippet = snippet, - ) - }) + markerIconProvider = { _, _, _, _ -> null }, + polygonContent = {}, + markerInfoWindowContent = { _, _, _, _, content -> Box { content() } }, + iconContent = { _, _ -> }) } // Title is visible @@ -212,7 +218,7 @@ class MapIconTest : BaseAndroidComposeTest() { } @Test - fun hazardMarker_passesLocationTitleSnippetAndTintToMarkerContent() { + fun hazardMarker_passesIconAndTintToIconContent() { val hazard = Hazard( id = 1, @@ -228,27 +234,31 @@ class MapIconTest : BaseAndroidComposeTest() { affectedZone = null, centroid = null) - // capture what markerContent receives - var receivedTitle: String? = null - var receivedSnippet: String? = null + // capture what iconContent receives + var receivedIcon: MapIcon? = null + var receivedTint: Color? = null composeTestRule.setContent { HazardMarker( hazard = hazard, - markerContent = { _, title, snippet, content -> - receivedTitle = title - receivedSnippet = snippet - Box { content() } + markerIconProvider = { _, _, _, _ -> null }, + polygonContent = {}, + markerInfoWindowContent = { _, _, _, _, content -> Box { content() } }, + iconContent = { icon, tint -> + receivedIcon = icon + receivedTint = tint + icon(tint) }) } composeTestRule.waitForIdle() - // 2) Title and snippet are passed - TestCase.assertEquals("Test hazard", receivedTitle) - TestCase.assertEquals("5 units", receivedSnippet) + // Icon is Unknown for type "XX" + TestCase.assertEquals(MapIcon.Unknown, receivedIcon) + // Tint color should be UNKNOWN (gray) since alertLevel is null + TestCase.assertEquals(SeverityColors.UNKNOWN, receivedTint) - // 3) Icon composable is rendered with a Tint semantics; we use the Unknown tag + // Icon composable is rendered with a Tint semantics; we use the Unknown tag val node = composeTestRule.onNodeWithTag(MapIcon.Unknown.tag) node.assertIsDisplayed() val tintColor = node.fetchSemanticsNode().config.getOrNull(Tint) @@ -297,4 +307,319 @@ class MapIconTest : BaseAndroidComposeTest() { .onNodeWithText("Tap this bubble to open the full news article") .assertIsDisplayed() } + + @Test + fun hazardMarker_polygonHiddenWhenClickingOutside() { + // Create a test polygon geometry + val polygonGeoJson = + """ + { + "type": "Polygon", + "coordinates": [[ + [10.0, 10.0], + [10.0, 11.0], + [11.0, 11.0], + [11.0, 10.0], + [10.0, 10.0] + ]] + } + """ + .trimIndent() + + val affectedZone = + com.github.warnastrophy.core.util.GeometryParser.convertRawGeoJsonGeometryToJTS( + polygonGeoJson) + + val hazard = + Hazard( + id = 456, + type = "EQ", + description = "Test earthquake", + country = null, + date = null, + severity = 6.5, + severityUnit = "M", + articleUrl = null, + alertLevel = 3.0, + bbox = null, + centroid = null, + affectedZone = affectedZone) + + // Track state with external control (simulating Map's selectedMarkerId) + var selectedMarkerId by mutableStateOf(null) + var polygonCoordsReceived: List? = null + + composeTestRule.setContent { + // Reset polygon coords on each recomposition + polygonCoordsReceived = null + HazardMarker( + hazard = hazard, + selectedMarkerId = selectedMarkerId, + onMarkerSelected = { selectedMarkerId = it }, + markerIconProvider = { _, _, _, _ -> null }, + polygonContent = { coords -> polygonCoordsReceived = coords }, + markerInfoWindowContent = { _, _, _, _, content -> Box { content() } }, + iconContent = { _, _ -> }) + } + + composeTestRule.waitForIdle() + + // Initially, polygon should not be shown (polygonContent not called) + TestCase.assertNull("Polygon should not be shown initially", polygonCoordsReceived) + + // Simulate marker selection + selectedMarkerId = hazard.id + + composeTestRule.waitForIdle() + + // After selection, polygon should be shown (polygonContent called with coords) + TestCase.assertNotNull( + "Polygon should be shown after marker is selected", polygonCoordsReceived) + TestCase.assertTrue("Polygon should have coordinates", polygonCoordsReceived!!.size > 1) + + // Simulate clicking outside (clearing selection) + selectedMarkerId = null + + composeTestRule.waitForIdle() + + // After clicking outside, polygon should be hidden again + TestCase.assertNull("Polygon should be hidden after clicking outside", polygonCoordsReceived) + } + + @Test + fun getSeverityColor_returnsLowForAlertLevel1() { + val hazard = hazardBasedOnType("FL").copy(alertLevel = 1.0) + TestCase.assertEquals(SeverityColors.LOW, getSeverityColor(hazard)) + } + + @Test + fun getSeverityColor_returnsMediumForAlertLevel2() { + val hazard = hazardBasedOnType("FL").copy(alertLevel = 2.0) + TestCase.assertEquals(SeverityColors.MEDIUM, getSeverityColor(hazard)) + } + + @Test + fun getSeverityColor_returnsHighForAlertLevel3() { + val hazard = hazardBasedOnType("FL").copy(alertLevel = 3.0) + TestCase.assertEquals(SeverityColors.HIGH, getSeverityColor(hazard)) + } + + @Test + fun getSeverityColor_returnsUnknownForNullAlertLevel() { + val hazard = hazardBasedOnType("FL").copy(alertLevel = null) + TestCase.assertEquals(SeverityColors.UNKNOWN, getSeverityColor(hazard)) + } + + @Test + fun getSeverityColor_returnsUnknownForOtherAlertLevels() { + val hazard = hazardBasedOnType("FL").copy(alertLevel = 4.0) + TestCase.assertEquals(SeverityColors.UNKNOWN, getSeverityColor(hazard)) + + val hazard2 = hazardBasedOnType("FL").copy(alertLevel = 0.5) + TestCase.assertEquals(SeverityColors.UNKNOWN, getSeverityColor(hazard2)) + } + + @Test + fun hazardNewsImage_displaysFallbackWhenUrlIsNull() { + val hazard = hazardBasedOnType("FL").copy(articleUrl = null, description = "Test flood") + + composeTestRule.setContent { HazardNewsImage(hazard = hazard) } + + composeTestRule.waitForIdle() + // The image should be displayed (fallback) + composeTestRule.onNodeWithContentDescription("Test flood").assertIsDisplayed() + } + + @Test + fun hazardNewsImage_displaysFallbackWhenUrlIsBlank() { + val hazard = hazardBasedOnType("FL").copy(articleUrl = " ", description = "Test flood blank") + + composeTestRule.setContent { HazardNewsImage(hazard = hazard) } + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithContentDescription("Test flood blank").assertIsDisplayed() + } + + @Test + fun hazardNewsImage_displaysFallbackWhenUrlIsNotImageFormat() { + val hazard = + hazardBasedOnType("FL") + .copy(articleUrl = "https://example.com/news/article", description = "Non-image URL") + + composeTestRule.setContent { HazardNewsImage(hazard = hazard) } + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithContentDescription("Non-image URL").assertIsDisplayed() + } + + @Test + fun hazardNewsImage_usesDefaultDescriptionWhenDescriptionIsNull() { + val hazard = hazardBasedOnType("FL").copy(articleUrl = null, description = null) + + composeTestRule.setContent { HazardNewsImage(hazard = hazard) } + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithContentDescription("Hazard image").assertIsDisplayed() + } + + @Test + fun mapIcon_unknownUsesDefaultWarningIcon() { + composeTestRule.setContent { MapIcon.Unknown.invoke(tint = Color.Red) } + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag(MapIcon.Unknown.tag).assertIsDisplayed() + + // Verify tint is set in semantics + val node = composeTestRule.onNodeWithTag(MapIcon.Unknown.tag) + val tintColor = node.fetchSemanticsNode().config.getOrNull(Tint) + TestCase.assertEquals(Color.Red, tintColor) + } + + @Test + fun hazardInfoWindowContent_hidesArticleHintWhenNoUrl() { + val hazard = + Hazard( + id = 100, + type = "FL", + description = "Flood without article", + country = null, + date = null, + severity = 50.0, + severityUnit = "km", + articleUrl = null, + alertLevel = 1.0, + bbox = null, + affectedZone = null, + centroid = null) + + composeTestRule.setContent { + HazardInfoWindowContent( + hazard = hazard, title = hazard.description, snippet = formatSeveritySnippet(hazard)) + } + + composeTestRule.onNodeWithText("Flood without article").assertIsDisplayed() + composeTestRule.onNodeWithText("50 km").assertIsDisplayed() + // Article hint should NOT be present + composeTestRule + .onAllNodesWithText("Tap this bubble to open the full news article") + .assertCountEquals(0) + } + + @Test + fun hazardInfoWindowContent_showsSeverityText() { + val hazard = + Hazard( + id = 101, + type = "EQ", + description = "Earthquake with severity text", + country = null, + date = null, + severity = 7.2, + severityUnit = "M", + severityText = "Magnitude 7.2 - Very strong", + articleUrl = "https://example.com", + alertLevel = 3.0, + bbox = null, + affectedZone = null, + centroid = null) + + composeTestRule.setContent { + HazardInfoWindowContent( + hazard = hazard, title = hazard.description, snippet = formatSeveritySnippet(hazard)) + } + + composeTestRule.onNodeWithText("Magnitude 7.2 - Very strong").assertIsDisplayed() + } + + @Test + fun hazardInfoWindowContent_usesDefaultTitleWhenDescriptionNull() { + val hazard = + Hazard( + id = 102, + type = "FL", + description = null, + country = null, + date = null, + severity = null, + severityUnit = null, + articleUrl = null, + alertLevel = null, + bbox = null, + affectedZone = null, + centroid = null) + + composeTestRule.setContent { + HazardInfoWindowContent(hazard = hazard, title = null, snippet = null) + } + + // Should show default "Hazard" title + composeTestRule.onNodeWithText("Hazard").assertIsDisplayed() + } + + @Test + fun hazardMarker_invokesOnInfoWindowClickCallback() { + var onInfoWindowClickCalled = false + val hazard = hazardBasedOnType("FL").copy(articleUrl = "https://example.com/article") + + setUpHazardMarker(hazard = hazard, onInfoWindowClick = { onInfoWindowClickCalled = true }) + + composeTestRule.waitForIdle() + TestCase.assertTrue("onInfoWindowClick should have been called", onInfoWindowClickCalled) + } + + @Test + fun hazardMarker_invokesOnMarkerClickCallback() { + var markerClickId: Int? = null + val hazard = hazardBasedOnType("FL").copy(id = 999) + + setUpHazardMarker(hazard = hazard, onMarkerSelected = { markerClickId = it }) + + composeTestRule.waitForIdle() + TestCase.assertEquals(999, markerClickId) + } + + @Test + fun formatSeveritySnippet_handlesWholeNumbers() { + val hazard = hazardBasedOnType("FL").copy(severity = 100.0, severityUnit = "km") + TestCase.assertEquals("100 km", formatSeveritySnippet(hazard)) + } + + @Test + fun formatSeveritySnippet_handlesDecimalNumbers() { + val hazard = hazardBasedOnType("FL").copy(severity = 5.67, severityUnit = "M") + TestCase.assertEquals("5.7 M", formatSeveritySnippet(hazard)) + } + + @Test + fun formatSeveritySnippet_handlesEmptyUnit() { + val hazard = hazardBasedOnType("FL").copy(severity = 42.0, severityUnit = "") + TestCase.assertEquals("42", formatSeveritySnippet(hazard)) + } + + @Test + fun formatSeveritySnippet_trimsUnitWhitespace() { + val hazard = hazardBasedOnType("FL").copy(severity = 10.0, severityUnit = " ha ") + TestCase.assertEquals("10 ha", formatSeveritySnippet(hazard)) + } + + private fun setUpHazardMarker( + hazard: Hazard, + onMarkerSelected: ((Int?) -> Unit)? = null, + onInfoWindowClick: (() -> Unit)? = null + ) { + composeTestRule.setContent { + HazardMarker( + hazard = hazard, + onMarkerSelected = onMarkerSelected ?: { _ -> }, + onInfoWindowClick = onInfoWindowClick ?: {}, + markerIconProvider = { _, _, _, _ -> null }, + polygonContent = {}, + markerInfoWindowContent = { _, _, onMarkerClick, onInfoWindowClickLambda, content -> + onMarkerClick() + onInfoWindowClickLambda() + Box { content() } + }, + iconContent = { _, _ -> }) + } + } } diff --git a/app/src/androidTest/java/com/github/warnastrophy/core/ui/profile/preferences/DangerModePreferencesScreenTest.kt b/app/src/androidTest/java/com/github/warnastrophy/core/ui/profile/preferences/DangerModePreferencesScreenTest.kt index f76eb4efa..4cf3b3e6f 100644 --- a/app/src/androidTest/java/com/github/warnastrophy/core/ui/profile/preferences/DangerModePreferencesScreenTest.kt +++ b/app/src/androidTest/java/com/github/warnastrophy/core/ui/profile/preferences/DangerModePreferencesScreenTest.kt @@ -15,7 +15,7 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollToNode import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry -import com.github.warnastrophy.core.data.repository.UserPreferencesRepository +import com.github.warnastrophy.core.data.interfaces.UserPreferencesRepository import com.github.warnastrophy.core.data.service.DangerModeOrchestrator import com.github.warnastrophy.core.data.service.DangerModeService import com.github.warnastrophy.core.data.service.GpsService diff --git a/app/src/androidTest/java/com/github/warnastrophy/core/util/HiltTestRunner.kt b/app/src/androidTest/java/com/github/warnastrophy/core/util/HiltTestRunner.kt deleted file mode 100644 index 42a1c516f..000000000 --- a/app/src/androidTest/java/com/github/warnastrophy/core/util/HiltTestRunner.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.github.warnastrophy.core.util - -import android.app.Application -import android.content.Context -import androidx.test.runner.AndroidJUnitRunner -import dagger.hilt.android.testing.HiltTestApplication - -/** - * Custom test runner required by Hilt to inject dependencies into instrumented tests. - * * It ensures that the HiltTestApplication is used instead of the production Application class, - * allowing the Hilt test graph to be built correctly. - */ -class HiltTestRunner : AndroidJUnitRunner() { - override fun newApplication( - cl: ClassLoader?, - className: String?, - context: Context? - ): Application? { - return super.newApplication(cl, HiltTestApplication::class.java.name, context) - } -} diff --git a/app/src/androidTest/java/com/github/warnastrophy/core/util/MockUserPreferencesRepository.kt b/app/src/androidTest/java/com/github/warnastrophy/core/util/MockUserPreferencesRepository.kt index dbbbc161f..ae861faed 100644 --- a/app/src/androidTest/java/com/github/warnastrophy/core/util/MockUserPreferencesRepository.kt +++ b/app/src/androidTest/java/com/github/warnastrophy/core/util/MockUserPreferencesRepository.kt @@ -1,8 +1,8 @@ package com.github.warnastrophy.core.ui.util +import com.github.warnastrophy.core.data.interfaces.UserPreferencesRepository import com.github.warnastrophy.core.data.repository.DangerModePreferences import com.github.warnastrophy.core.data.repository.UserPreferences -import com.github.warnastrophy.core.data.repository.UserPreferencesRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow diff --git a/app/src/androidTest/java/com/github/warnastrophy/e2e/EndToEndM1Test.kt b/app/src/androidTest/java/com/github/warnastrophy/e2e/EndToEndM1Test.kt index a03c6ac14..f570750af 100644 --- a/app/src/androidTest/java/com/github/warnastrophy/e2e/EndToEndM1Test.kt +++ b/app/src/androidTest/java/com/github/warnastrophy/e2e/EndToEndM1Test.kt @@ -10,9 +10,9 @@ import androidx.compose.ui.test.performClick 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.di.userPrefsDataStore import com.github.warnastrophy.core.ui.features.profile.ThemeViewModel import com.github.warnastrophy.core.ui.navigation.NavigationTestTags +import com.github.warnastrophy.userPrefsDataStore import io.mockk.every import io.mockk.mockk import org.junit.After diff --git a/app/src/androidTest/java/com/github/warnastrophy/e2e/EndToEndM2Test.kt b/app/src/androidTest/java/com/github/warnastrophy/e2e/EndToEndM2Test.kt index 9e95bf2e0..a52899400 100644 --- a/app/src/androidTest/java/com/github/warnastrophy/e2e/EndToEndM2Test.kt +++ b/app/src/androidTest/java/com/github/warnastrophy/e2e/EndToEndM2Test.kt @@ -6,9 +6,9 @@ import com.github.warnastrophy.core.data.provider.ContactRepositoryProvider import com.github.warnastrophy.core.data.provider.HealthCardRepositoryProvider import com.github.warnastrophy.core.data.provider.UserPreferencesRepositoryProvider import com.github.warnastrophy.core.data.service.StateManagerService -import com.github.warnastrophy.core.di.userPrefsDataStore import com.github.warnastrophy.core.ui.features.dashboard.DashboardScreenTestTags import com.github.warnastrophy.core.ui.navigation.NavigationTestTags +import com.github.warnastrophy.userPrefsDataStore import org.junit.After import org.junit.Before import org.junit.Test diff --git a/app/src/androidTest/java/com/github/warnastrophy/e2e/EndToEndM3Test.kt b/app/src/androidTest/java/com/github/warnastrophy/e2e/EndToEndM3Test.kt new file mode 100644 index 000000000..d6b48946c --- /dev/null +++ b/app/src/androidTest/java/com/github/warnastrophy/e2e/EndToEndM3Test.kt @@ -0,0 +1,187 @@ +package com.github.warnastrophy.e2e + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithTag +import com.github.warnastrophy.R +import com.github.warnastrophy.core.data.provider.ContactRepositoryProvider +import com.github.warnastrophy.core.data.provider.HealthCardRepositoryProvider +import com.github.warnastrophy.core.data.provider.UserPreferencesRepositoryProvider +import com.github.warnastrophy.core.data.service.SpeechRecognitionUiState +import com.github.warnastrophy.core.data.service.SpeechToTextServiceInterface +import com.github.warnastrophy.core.data.service.StateManagerService +import com.github.warnastrophy.core.data.service.TextToSpeechServiceInterface +import com.github.warnastrophy.core.data.service.TextToSpeechUiState +import com.github.warnastrophy.core.ui.components.CommunicationScreenTags +import com.github.warnastrophy.userPrefsDataStore +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.junit.After +import org.junit.Before +import org.junit.Test + +class EndToEndM3Test : EndToEndUtils() { + + private lateinit var fakeTts: FakeTextToSpeechService + private lateinit var fakeStt: FakeSpeechToTextService + + @Before + override fun setUp() { + super.setUp() + + val context = composeTestRule.activity.applicationContext + ContactRepositoryProvider.initLocal(context) + UserPreferencesRepositoryProvider.initLocal(context.userPrefsDataStore) + composeTestRule.runOnUiThread { StateManagerService.init(context) } + contactRepository = ContactRepositoryProvider.repository + HealthCardRepositoryProvider.useLocalEncrypted(context) + + // Swap real services with deterministic fakes for E2E. + fakeTts = FakeTextToSpeechService() + fakeStt = FakeSpeechToTextService(confirmationResult = true) + + composeTestRule.runOnUiThread { + setStateManagerServiceField("textToSpeechService", fakeTts) + setStateManagerServiceField("speechToTextService", fakeStt) + } + } + + @After + override fun tearDown() { + super.tearDown() + composeTestRule.runOnUiThread { StateManagerService.shutdown() } + } + + @Test + fun voice_confirmation_flow_tts_then_stt_then_tts_yes() { + val context = composeTestRule.activity.applicationContext + + // MUST use EndToEndUtils.setContent() (your harness). + setContent() + + // Force the overlay to appear (WarnastrophyComposable shows CommunicationScreen when true). + composeTestRule.runOnUiThread { forceShowVoiceConfirmationOverlay() } + + // Screen is visible. + composeTestRule.onNodeWithTag(CommunicationScreenTags.TITLE).assertIsDisplayed() + composeTestRule.onNodeWithTag(CommunicationScreenTags.STATUS_CARD).assertIsDisplayed() + + // VM should have spoken the confirmation request via TTS. + composeTestRule.waitUntil(3_000) { + fakeTts.spokenHistory.contains(context.getString(R.string.confirmation_request)) + } + + // End TTS speaking -> VM should start STT listening. + fakeTts.finishSpeaking() + + composeTestRule.waitUntil(3_000) { fakeStt.listenCalled } + + // STT returns "yes" -> VM should speak "alert sent". + composeTestRule.waitUntil(3_000) { + fakeTts.spokenHistory.contains(context.getString(R.string.alert_sent)) + } + } + + /** + * Tries to set a private field on StateManagerService (used to swap services in tests). This + * keeps the test E2E-ish while still making STT/TTS deterministic. + */ + private fun setStateManagerServiceField(fieldName: String, value: Any) { + runCatching { + val field = StateManagerService::class.java.getDeclaredField(fieldName) + field.isAccessible = true + field.set(StateManagerService, value) + } + .getOrElse { + throw IllegalStateException( + "Could not replace StateManagerService.$fieldName. " + + "Expose a test hook or keep the field name stable.", + it) + } + } + + /** + * Turns on the danger-mode voice confirmation overlay by mutating the orchestrator's + * showVoiceConfirmationScreen flow via reflection. + * + * Expected shape (typical): val showVoiceConfirmationScreen: StateFlow backed by a + * MutableStateFlow inside the orchestrator. + */ + private fun forceShowVoiceConfirmationOverlay() { + val orchestrator = StateManagerService.dangerModeOrchestrator + + // 1) Directly try a MutableStateFlow field called "_showVoiceConfirmationScreen" + val ok1 = + runCatching { + val f = orchestrator.javaClass.getDeclaredField("_showVoiceConfirmationScreen") + f.isAccessible = true + val v = f.get(orchestrator) + (v as? MutableStateFlow)?.value = true + true + } + .getOrDefault(false) + + if (ok1) return + + // 2) Try a MutableStateFlow field called "showVoiceConfirmationScreen" + val ok2 = + runCatching { + val f = orchestrator.javaClass.getDeclaredField("showVoiceConfirmationScreen") + f.isAccessible = true + val v = f.get(orchestrator) + (v as? MutableStateFlow)?.value = true + true + } + .getOrDefault(false) + + if (ok2) return + + throw IllegalStateException( + "Couldn't force showVoiceConfirmation overlay. " + + "Please add a test-only method on dangerModeOrchestrator like triggerVoiceConfirmation().") + } + + private class FakeTextToSpeechService : TextToSpeechServiceInterface { + private val _uiState = MutableStateFlow(TextToSpeechUiState()) + override val uiState: StateFlow = _uiState + + val spokenHistory = mutableListOf() + + override fun speak(text: String) { + if (text.isBlank()) return + spokenHistory += text + _uiState.value = _uiState.value.copy(isSpeaking = true, rms = 20f, spokenText = text) + } + + fun finishSpeaking() { + _uiState.value = _uiState.value.copy(isSpeaking = false, rms = 0f, spokenText = null) + } + + override fun destroy() { + _uiState.value = TextToSpeechUiState() + } + } + + private class FakeSpeechToTextService(private val confirmationResult: Boolean) : + SpeechToTextServiceInterface { + private val _uiState = MutableStateFlow(SpeechRecognitionUiState()) + override val uiState: StateFlow = _uiState + + @Volatile var listenCalled: Boolean = false + + override suspend fun listenForConfirmation(): Boolean { + listenCalled = true + _uiState.value = + SpeechRecognitionUiState( + isListening = false, + rmsLevel = 0f, + recognizedText = if (confirmationResult) "yes" else "no", + errorMessage = null, + isConfirmed = confirmationResult) + return confirmationResult + } + + override fun destroy() { + _uiState.value = SpeechRecognitionUiState() + } + } +} diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml deleted file mode 100644 index cac534e5a..000000000 --- a/app/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/debug/java/com/github/warnastrophy/HiltTestActivity.kt b/app/src/debug/java/com/github/warnastrophy/HiltTestActivity.kt deleted file mode 100644 index 98545b639..000000000 --- a/app/src/debug/java/com/github/warnastrophy/HiltTestActivity.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.github.warnastrophy - -import androidx.activity.ComponentActivity -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint class HiltTestActivity : ComponentActivity() diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ccef39d6f..fd05665ec 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,7 +7,6 @@ android:name="android.hardware.telephony" android:required="false" /> + * prefs[USER_THEME_KEY] = "dark" + * } + * ``` + * + * Note: This code was generated with the help of AI + */ +val Context.userPrefsDataStore by preferencesDataStore(name = AppConfig.PREF_FILE_NAME) + /** * `MainActivity` is the entry point of the application. It initializes Firebase, sets up the data * repositories, and provides the UI for the app. It also handles the theme configuration and passes diff --git a/app/src/main/java/com/github/warnastrophy/WarnastrophyApp.kt b/app/src/main/java/com/github/warnastrophy/WarnastrophyApp.kt deleted file mode 100644 index 89873f315..000000000 --- a/app/src/main/java/com/github/warnastrophy/WarnastrophyApp.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.github.warnastrophy - -import android.app.Application -import dagger.hilt.android.HiltAndroidApp - -@HiltAndroidApp class WarnastrophyApp : Application() {} diff --git a/app/src/main/java/com/github/warnastrophy/core/data/repository/ActivityRepository.kt b/app/src/main/java/com/github/warnastrophy/core/data/interfaces/ActivityRepository.kt similarity index 95% rename from app/src/main/java/com/github/warnastrophy/core/data/repository/ActivityRepository.kt rename to app/src/main/java/com/github/warnastrophy/core/data/interfaces/ActivityRepository.kt index aba4cb629..05cb8ae15 100644 --- a/app/src/main/java/com/github/warnastrophy/core/data/repository/ActivityRepository.kt +++ b/app/src/main/java/com/github/warnastrophy/core/data/interfaces/ActivityRepository.kt @@ -1,4 +1,4 @@ -package com.github.warnastrophy.core.data.repository +package com.github.warnastrophy.core.data.interfaces import com.github.warnastrophy.core.model.Activity import com.github.warnastrophy.core.util.AppConfig @@ -8,7 +8,8 @@ interface ActivityRepository { * Adds a new Activity item to the repository. * * @param activity The Activity item to add. - * @param userId user ID that is using the app. The default value is [AppConfig.defaultUserId] + * @param userId user ID that is using the app. The default value is + * [com.github.warnastrophy.core.util.AppConfig.defaultUserId] * @return A [Result] containing Unit on success or a failure with the exception on error. */ suspend fun addActivity( diff --git a/app/src/main/java/com/github/warnastrophy/core/data/repository/UserPreferencesRepository.kt b/app/src/main/java/com/github/warnastrophy/core/data/interfaces/UserPreferencesRepository.kt similarity index 85% rename from app/src/main/java/com/github/warnastrophy/core/data/repository/UserPreferencesRepository.kt rename to app/src/main/java/com/github/warnastrophy/core/data/interfaces/UserPreferencesRepository.kt index c79bdf5f1..8bb5eee9b 100644 --- a/app/src/main/java/com/github/warnastrophy/core/data/repository/UserPreferencesRepository.kt +++ b/app/src/main/java/com/github/warnastrophy/core/data/interfaces/UserPreferencesRepository.kt @@ -1,5 +1,6 @@ -package com.github.warnastrophy.core.data.repository +package com.github.warnastrophy.core.data.interfaces +import com.github.warnastrophy.core.data.repository.UserPreferences import kotlinx.coroutines.flow.Flow /** @@ -8,8 +9,9 @@ import kotlinx.coroutines.flow.Flow */ interface UserPreferencesRepository { /** - * Retrieves a cold flow of the user's preferences. This flow emits a new [UserPreferences] object - * whenever any preference value changes. + * Retrieves a cold flow of the user's preferences. This flow emits a new + * [com.github.warnastrophy.core.data.repository.UserPreferences] object whenever any preference + * value changes. */ val getUserPreferences: Flow diff --git a/app/src/main/java/com/github/warnastrophy/core/data/localStorage/ContactsStorage.kt b/app/src/main/java/com/github/warnastrophy/core/data/local/ContactsStorage.kt similarity index 98% rename from app/src/main/java/com/github/warnastrophy/core/data/localStorage/ContactsStorage.kt rename to app/src/main/java/com/github/warnastrophy/core/data/local/ContactsStorage.kt index 8508a7d4c..042c13e4f 100644 --- a/app/src/main/java/com/github/warnastrophy/core/data/localStorage/ContactsStorage.kt +++ b/app/src/main/java/com/github/warnastrophy/core/data/local/ContactsStorage.kt @@ -1,4 +1,4 @@ -package com.github.warnastrophy.core.data.localStorage +package com.github.warnastrophy.core.data.local import android.content.Context import android.util.Log diff --git a/app/src/main/java/com/github/warnastrophy/core/data/localStorage/HealthCardStorage.kt b/app/src/main/java/com/github/warnastrophy/core/data/local/HealthCardStorage.kt similarity index 99% rename from app/src/main/java/com/github/warnastrophy/core/data/localStorage/HealthCardStorage.kt rename to app/src/main/java/com/github/warnastrophy/core/data/local/HealthCardStorage.kt index cff64b405..5b087a2c4 100644 --- a/app/src/main/java/com/github/warnastrophy/core/data/localStorage/HealthCardStorage.kt +++ b/app/src/main/java/com/github/warnastrophy/core/data/local/HealthCardStorage.kt @@ -1,4 +1,4 @@ -package com.github.warnastrophy.core.data.localStorage +package com.github.warnastrophy.core.data.local import android.content.Context import android.util.Log diff --git a/app/src/main/java/com/github/warnastrophy/core/data/localStorage/LocalActivityRepository.kt b/app/src/main/java/com/github/warnastrophy/core/data/local/LocalActivityRepository.kt similarity index 98% rename from app/src/main/java/com/github/warnastrophy/core/data/localStorage/LocalActivityRepository.kt rename to app/src/main/java/com/github/warnastrophy/core/data/local/LocalActivityRepository.kt index 772b0fe89..723192498 100644 --- a/app/src/main/java/com/github/warnastrophy/core/data/localStorage/LocalActivityRepository.kt +++ b/app/src/main/java/com/github/warnastrophy/core/data/local/LocalActivityRepository.kt @@ -1,4 +1,4 @@ -package com.github.warnastrophy.core.data.localStorage +package com.github.warnastrophy.core.data.local import android.content.Context import android.util.Log @@ -8,7 +8,7 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore -import com.github.warnastrophy.core.data.repository.ActivityRepository +import com.github.warnastrophy.core.data.interfaces.ActivityRepository import com.github.warnastrophy.core.model.Activity import com.github.warnastrophy.core.ui.common.ErrorHandler import com.github.warnastrophy.core.ui.common.ErrorType diff --git a/app/src/main/java/com/github/warnastrophy/core/data/localStorage/LocalHealthCardRepository.kt b/app/src/main/java/com/github/warnastrophy/core/data/local/LocalHealthCardRepository.kt similarity index 96% rename from app/src/main/java/com/github/warnastrophy/core/data/localStorage/LocalHealthCardRepository.kt rename to app/src/main/java/com/github/warnastrophy/core/data/local/LocalHealthCardRepository.kt index 168119fc1..e0ae1c506 100644 --- a/app/src/main/java/com/github/warnastrophy/core/data/localStorage/LocalHealthCardRepository.kt +++ b/app/src/main/java/com/github/warnastrophy/core/data/local/LocalHealthCardRepository.kt @@ -1,4 +1,4 @@ -package com.github.warnastrophy.core.data.localStorage +package com.github.warnastrophy.core.data.local import android.content.Context import androidx.datastore.preferences.core.stringPreferencesKey diff --git a/app/src/main/java/com/github/warnastrophy/core/data/localStorage/StorageResults.kt b/app/src/main/java/com/github/warnastrophy/core/data/local/StorageResults.kt similarity index 96% rename from app/src/main/java/com/github/warnastrophy/core/data/localStorage/StorageResults.kt rename to app/src/main/java/com/github/warnastrophy/core/data/local/StorageResults.kt index db728e016..f660584cd 100644 --- a/app/src/main/java/com/github/warnastrophy/core/data/localStorage/StorageResults.kt +++ b/app/src/main/java/com/github/warnastrophy/core/data/local/StorageResults.kt @@ -1,4 +1,4 @@ -package com.github.warnastrophy.core.data.localStorage +package com.github.warnastrophy.core.data.local import com.github.warnastrophy.core.model.HealthCard diff --git a/app/src/main/java/com/github/warnastrophy/core/data/localStorage/LocalUserIdStore.kt b/app/src/main/java/com/github/warnastrophy/core/data/localStorage/LocalUserIdStore.kt deleted file mode 100644 index c6d62345a..000000000 --- a/app/src/main/java/com/github/warnastrophy/core/data/localStorage/LocalUserIdStore.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.github.warnastrophy.core.data.localStorage - -import android.content.Context -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.stringPreferencesKey -import java.util.UUID -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map - -object LocalUserIdStore { - private val KEY = stringPreferencesKey("local_user_id") - - suspend fun getOrCreate(context: Context): String { - val ds = context.healthCardDataStore - val existing = ds.data.map { it[KEY] }.first() - if (existing != null) return existing - val fresh = UUID.randomUUID().toString() - ds.edit { it[KEY] = fresh } - return fresh - } -} diff --git a/app/src/main/java/com/github/warnastrophy/core/data/provider/ActivityRepositoryProvider.kt b/app/src/main/java/com/github/warnastrophy/core/data/provider/ActivityRepositoryProvider.kt index 0ead5db11..7b9054417 100644 --- a/app/src/main/java/com/github/warnastrophy/core/data/provider/ActivityRepositoryProvider.kt +++ b/app/src/main/java/com/github/warnastrophy/core/data/provider/ActivityRepositoryProvider.kt @@ -1,8 +1,8 @@ package com.github.warnastrophy.core.data.provider import android.content.Context -import com.github.warnastrophy.core.data.localStorage.LocalActivityRepository -import com.github.warnastrophy.core.data.repository.ActivityRepository +import com.github.warnastrophy.core.data.interfaces.ActivityRepository +import com.github.warnastrophy.core.data.local.LocalActivityRepository import com.github.warnastrophy.core.data.repository.MockActivityRepository import com.github.warnastrophy.core.ui.common.ErrorHandler diff --git a/app/src/main/java/com/github/warnastrophy/core/data/provider/ContactRepositoryProvider.kt b/app/src/main/java/com/github/warnastrophy/core/data/provider/ContactRepositoryProvider.kt index b88c6ff88..d474b8d34 100644 --- a/app/src/main/java/com/github/warnastrophy/core/data/provider/ContactRepositoryProvider.kt +++ b/app/src/main/java/com/github/warnastrophy/core/data/provider/ContactRepositoryProvider.kt @@ -2,8 +2,8 @@ package com.github.warnastrophy.core.data.provider import android.content.Context import com.github.warnastrophy.core.data.interfaces.ContactsRepository -import com.github.warnastrophy.core.data.localStorage.ContactsStorage -import com.github.warnastrophy.core.data.localStorage.contactDataStore +import com.github.warnastrophy.core.data.local.ContactsStorage +import com.github.warnastrophy.core.data.local.contactDataStore import com.github.warnastrophy.core.data.repository.ContactRepositoryImpl import com.github.warnastrophy.core.data.repository.HybridContactRepository import com.google.firebase.firestore.FirebaseFirestore diff --git a/app/src/main/java/com/github/warnastrophy/core/data/service/DeviceIdProvider.kt b/app/src/main/java/com/github/warnastrophy/core/data/provider/DeviceIdProvider.kt similarity index 91% rename from app/src/main/java/com/github/warnastrophy/core/data/service/DeviceIdProvider.kt rename to app/src/main/java/com/github/warnastrophy/core/data/provider/DeviceIdProvider.kt index e4e2fbe32..b48760d22 100644 --- a/app/src/main/java/com/github/warnastrophy/core/data/service/DeviceIdProvider.kt +++ b/app/src/main/java/com/github/warnastrophy/core/data/provider/DeviceIdProvider.kt @@ -1,4 +1,4 @@ -package com.github.warnastrophy.core.data.service +package com.github.warnastrophy.core.data.provider import android.content.Context import java.util.UUID diff --git a/app/src/main/java/com/github/warnastrophy/core/data/provider/HealthCardRepositoryProvider.kt b/app/src/main/java/com/github/warnastrophy/core/data/provider/HealthCardRepositoryProvider.kt index 0662de43d..8ee77882b 100644 --- a/app/src/main/java/com/github/warnastrophy/core/data/provider/HealthCardRepositoryProvider.kt +++ b/app/src/main/java/com/github/warnastrophy/core/data/provider/HealthCardRepositoryProvider.kt @@ -1,11 +1,10 @@ package com.github.warnastrophy.core.data.provider import android.content.Context -import com.github.warnastrophy.core.data.localStorage.LocalHealthCardRepository +import com.github.warnastrophy.core.data.local.LocalHealthCardRepository import com.github.warnastrophy.core.data.repository.HealthCardRepository import com.github.warnastrophy.core.data.repository.HealthCardRepositoryImpl import com.github.warnastrophy.core.data.repository.HybridHealthCardRepository -import com.github.warnastrophy.core.data.service.DeviceIdProvider import com.google.firebase.auth.FirebaseAuth import com.google.firebase.firestore.FirebaseFirestore import kotlinx.coroutines.flow.MutableStateFlow diff --git a/app/src/main/java/com/github/warnastrophy/core/data/localStorage/KeyStoreProvider.kt b/app/src/main/java/com/github/warnastrophy/core/data/provider/KeyStoreProvider.kt similarity index 97% rename from app/src/main/java/com/github/warnastrophy/core/data/localStorage/KeyStoreProvider.kt rename to app/src/main/java/com/github/warnastrophy/core/data/provider/KeyStoreProvider.kt index 9ee61477f..dc091459e 100644 --- a/app/src/main/java/com/github/warnastrophy/core/data/localStorage/KeyStoreProvider.kt +++ b/app/src/main/java/com/github/warnastrophy/core/data/provider/KeyStoreProvider.kt @@ -1,4 +1,4 @@ -package com.github.warnastrophy.core.data.localStorage +package com.github.warnastrophy.core.data.provider import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties diff --git a/app/src/main/java/com/github/warnastrophy/core/data/provider/OnboardingRepositoryProvider.kt b/app/src/main/java/com/github/warnastrophy/core/data/provider/OnboardingRepositoryProvider.kt new file mode 100644 index 000000000..3779f3686 --- /dev/null +++ b/app/src/main/java/com/github/warnastrophy/core/data/provider/OnboardingRepositoryProvider.kt @@ -0,0 +1,23 @@ +package com.github.warnastrophy.core.data.provider + +import android.content.Context +import com.github.warnastrophy.core.data.repository.IntroductionRepository +import com.github.warnastrophy.core.data.repository.OnboardingRepository + +/** + * Provides access to the [com.github.warnastrophy.core.data.repository.IntroductionRepository] + * instance for managing onboarding state. + */ +object OnboardingRepositoryProvider { + lateinit var repository: IntroductionRepository + + /** + * Initializes the repository provider with the concrete + * [com.github.warnastrophy.core.data.repository.OnboardingRepository] implementation. + * + * @param context The application context required to instantiate the repository. + */ + fun init(context: Context) { + repository = OnboardingRepository(context) + } +} diff --git a/app/src/main/java/com/github/warnastrophy/core/data/provider/UserPreferencesRepositoryProvider.kt b/app/src/main/java/com/github/warnastrophy/core/data/provider/UserPreferencesRepositoryProvider.kt index 2824dae27..2a5d021dd 100644 --- a/app/src/main/java/com/github/warnastrophy/core/data/provider/UserPreferencesRepositoryProvider.kt +++ b/app/src/main/java/com/github/warnastrophy/core/data/provider/UserPreferencesRepositoryProvider.kt @@ -2,8 +2,8 @@ package com.github.warnastrophy.core.data.provider import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences +import com.github.warnastrophy.core.data.interfaces.UserPreferencesRepository import com.github.warnastrophy.core.data.repository.HybridUserPreferencesRepository -import com.github.warnastrophy.core.data.repository.UserPreferencesRepository import com.github.warnastrophy.core.data.repository.UserPreferencesRepositoryLocal import com.github.warnastrophy.core.data.repository.UserPreferencesRepositoryRemote import com.google.firebase.firestore.FirebaseFirestore diff --git a/app/src/main/java/com/github/warnastrophy/core/data/repository/ContactRespositoryImpl.kt b/app/src/main/java/com/github/warnastrophy/core/data/repository/ContactRespositoryImpl.kt index b1815a99a..fb4071701 100644 --- a/app/src/main/java/com/github/warnastrophy/core/data/repository/ContactRespositoryImpl.kt +++ b/app/src/main/java/com/github/warnastrophy/core/data/repository/ContactRespositoryImpl.kt @@ -2,7 +2,7 @@ package com.github.warnastrophy.core.data.repository import android.util.Log import com.github.warnastrophy.core.data.interfaces.ContactsRepository -import com.github.warnastrophy.core.data.localStorage.StorageException +import com.github.warnastrophy.core.data.local.StorageException import com.github.warnastrophy.core.model.Contact import com.github.warnastrophy.core.util.CryptoUtils import com.google.firebase.firestore.FirebaseFirestore diff --git a/app/src/main/java/com/github/warnastrophy/core/data/repository/HybridUserPreferencesRepository.kt b/app/src/main/java/com/github/warnastrophy/core/data/repository/HybridUserPreferencesRepository.kt index 0e3115cec..80fca97d7 100644 --- a/app/src/main/java/com/github/warnastrophy/core/data/repository/HybridUserPreferencesRepository.kt +++ b/app/src/main/java/com/github/warnastrophy/core/data/repository/HybridUserPreferencesRepository.kt @@ -1,6 +1,7 @@ package com.github.warnastrophy.core.data.repository import android.util.Log +import com.github.warnastrophy.core.data.interfaces.UserPreferencesRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.emitAll diff --git a/app/src/main/java/com/github/warnastrophy/core/data/repository/MockActivityRepository.kt b/app/src/main/java/com/github/warnastrophy/core/data/repository/MockActivityRepository.kt index aabd54080..d0069498d 100644 --- a/app/src/main/java/com/github/warnastrophy/core/data/repository/MockActivityRepository.kt +++ b/app/src/main/java/com/github/warnastrophy/core/data/repository/MockActivityRepository.kt @@ -1,5 +1,6 @@ package com.github.warnastrophy.core.data.repository +import com.github.warnastrophy.core.data.interfaces.ActivityRepository import com.github.warnastrophy.core.model.Activity import java.util.UUID diff --git a/app/src/main/java/com/github/warnastrophy/core/data/repository/OnboardingRepositoryProvider.kt b/app/src/main/java/com/github/warnastrophy/core/data/repository/OnboardingRepositoryProvider.kt deleted file mode 100644 index 72f698d03..000000000 --- a/app/src/main/java/com/github/warnastrophy/core/data/repository/OnboardingRepositoryProvider.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.github.warnastrophy.core.data.repository - -import android.content.Context - -/** Provides access to the [IntroductionRepository] instance for managing onboarding state. */ -object OnboardingRepositoryProvider { - lateinit var repository: IntroductionRepository - - /** - * Initializes the repository provider with the concrete [OnboardingRepository] implementation. - * - * @param context The application context required to instantiate the repository. - */ - fun init(context: Context) { - repository = OnboardingRepository(context) - } -} diff --git a/app/src/main/java/com/github/warnastrophy/core/data/repository/UserPreferencesRepositoryLocal.kt b/app/src/main/java/com/github/warnastrophy/core/data/repository/UserPreferencesRepositoryLocal.kt index 2290a232f..ba0133fbb 100644 --- a/app/src/main/java/com/github/warnastrophy/core/data/repository/UserPreferencesRepositoryLocal.kt +++ b/app/src/main/java/com/github/warnastrophy/core/data/repository/UserPreferencesRepositoryLocal.kt @@ -3,6 +3,7 @@ package com.github.warnastrophy.core.data.repository import androidx.datastore.core.DataStore import androidx.datastore.core.IOException import androidx.datastore.preferences.core.* +import com.github.warnastrophy.core.data.interfaces.UserPreferencesRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map diff --git a/app/src/main/java/com/github/warnastrophy/core/data/repository/UserPreferencesRepositoryRemote.kt b/app/src/main/java/com/github/warnastrophy/core/data/repository/UserPreferencesRepositoryRemote.kt index fbbaac05e..b737ee485 100644 --- a/app/src/main/java/com/github/warnastrophy/core/data/repository/UserPreferencesRepositoryRemote.kt +++ b/app/src/main/java/com/github/warnastrophy/core/data/repository/UserPreferencesRepositoryRemote.kt @@ -1,5 +1,6 @@ package com.github.warnastrophy.core.data.repository +import com.github.warnastrophy.core.data.interfaces.UserPreferencesRepository import com.google.firebase.auth.FirebaseAuth import com.google.firebase.firestore.DocumentReference import com.google.firebase.firestore.FirebaseFirestore @@ -12,8 +13,10 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.tasks.await /** - * A remote implementation of [UserPreferencesRepository] that interacts with Firestore to manage - * user preferences, such as alert mode, inactivity detection, SMS alerts, and dark mode. + * A remote implementation of + * [com.github.warnastrophy.core.data.interfaces.UserPreferencesRepository] that interacts with + * Firestore to manage user preferences, such as alert mode, inactivity detection, SMS alerts, and + * dark mode. * * This repository provides functionality for both retrieving and updating user preferences stored * in Firestore. It listens to changes in Firestore and emits updated preferences to subscribers, 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 964018679..3a798bcb6 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 @@ -3,9 +3,9 @@ package com.github.warnastrophy.core.data.service import android.content.Context import android.util.Log import com.github.warnastrophy.core.data.interfaces.ContactsRepository +import com.github.warnastrophy.core.data.interfaces.UserPreferencesRepository import com.github.warnastrophy.core.data.provider.ContactRepositoryProvider import com.github.warnastrophy.core.data.repository.DangerModePreferences -import com.github.warnastrophy.core.data.repository.UserPreferencesRepository import com.github.warnastrophy.core.domain.model.EmergencyMessage import com.github.warnastrophy.core.model.Location import com.github.warnastrophy.core.ui.common.ErrorHandler diff --git a/app/src/main/java/com/github/warnastrophy/core/data/service/StateManagerService.kt b/app/src/main/java/com/github/warnastrophy/core/data/service/StateManagerService.kt index d3eb5d965..0bb2cfb65 100644 --- a/app/src/main/java/com/github/warnastrophy/core/data/service/StateManagerService.kt +++ b/app/src/main/java/com/github/warnastrophy/core/data/service/StateManagerService.kt @@ -1,19 +1,17 @@ package com.github.warnastrophy.core.data.service import android.content.Context +import com.github.warnastrophy.core.data.interfaces.ActivityRepository +import com.github.warnastrophy.core.data.interfaces.UserPreferencesRepository import com.github.warnastrophy.core.data.provider.ActivityRepositoryProvider import com.github.warnastrophy.core.data.provider.HazardRepositoryProvider import com.github.warnastrophy.core.data.provider.UserPreferencesRepositoryProvider -import com.github.warnastrophy.core.data.repository.ActivityRepository import com.github.warnastrophy.core.data.repository.MovementSensorRepository -import com.github.warnastrophy.core.data.repository.UserPreferencesRepository import com.github.warnastrophy.core.domain.usecase.HazardCheckerService import com.github.warnastrophy.core.model.Hazard import com.github.warnastrophy.core.permissions.PermissionManager import com.github.warnastrophy.core.permissions.PermissionManagerInterface import com.github.warnastrophy.core.ui.common.ErrorHandler -import com.github.warnastrophy.core.util.startForegroundGpsService -import com.github.warnastrophy.core.util.stopForegroundGpsService import com.google.android.gms.location.LocationServices import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -39,6 +37,7 @@ import kotlinx.coroutines.launch * services when the user enters a dangerous zone. */ object StateManagerService { + private lateinit var appContext: Context private var initialized = false private val serviceScope = CoroutineScope(Dispatchers.IO) @@ -115,7 +114,6 @@ object StateManagerService { MovementService( MovementSensorRepository(context), dangerModeStateFlow = dangerModeService.state) movementService.startListening() - startForegroundGpsService(appContext) dangerModeOrchestrator = DangerModeOrchestrator( @@ -185,7 +183,6 @@ object StateManagerService { if (::textToSpeechService.isInitialized) { textToSpeechService.destroy() } - stopForegroundGpsService(appContext) } /** diff --git a/app/src/main/java/com/github/warnastrophy/core/di/ContactsModule.kt b/app/src/main/java/com/github/warnastrophy/core/di/ContactsModule.kt deleted file mode 100644 index ae6150282..000000000 --- a/app/src/main/java/com/github/warnastrophy/core/di/ContactsModule.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.github.warnastrophy.core.di - -import android.content.Context -import com.github.warnastrophy.core.data.interfaces.ContactsRepository -import com.github.warnastrophy.core.data.localStorage.ContactsStorage -import com.github.warnastrophy.core.data.localStorage.contactDataStore -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -/** Hilt module that provides the binding for the application's local contact data repository. */ -@Module -@InstallIn(SingletonComponent::class) -object ContactsModule { - @Singleton - @Provides - fun provideContactsRepository(@ApplicationContext context: Context): ContactsRepository { - return ContactsStorage(context.contactDataStore) - } -} diff --git a/app/src/main/java/com/github/warnastrophy/core/di/DataStoreModule.kt b/app/src/main/java/com/github/warnastrophy/core/di/DataStoreModule.kt deleted file mode 100644 index 6d6396d5c..000000000 --- a/app/src/main/java/com/github/warnastrophy/core/di/DataStoreModule.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.github.warnastrophy.core.di - -import android.content.Context -import androidx.datastore.preferences.preferencesDataStore -import com.github.warnastrophy.core.util.AppConfig - -/** - * Extension property for [Context] to provide a singleton instance of - * [androidx.datastore.core.DataStore] for user preferences. This DataStore is used to persist - * simple key-value pairs, such as user settings, using the Jetpack DataStore library. - * - * The `preferencesDataStore` delegate ensures that there's only one instance of DataStore with the - * name [AppConfig.PREF_FILE_NAME] per application process. - * - * Usage: - * ``` - * context.userPrefsDataStore.edit { prefs -> - * prefs[USER_THEME_KEY] = "dark" - * } - * ``` - * - * Note: This code was generated with the help of AI - */ -val Context.userPrefsDataStore by preferencesDataStore(name = AppConfig.PREF_FILE_NAME) diff --git a/app/src/main/java/com/github/warnastrophy/core/di/GPSModule.kt b/app/src/main/java/com/github/warnastrophy/core/di/GPSModule.kt deleted file mode 100644 index 25ba50370..000000000 --- a/app/src/main/java/com/github/warnastrophy/core/di/GPSModule.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.github.warnastrophy.core.di - -import com.github.warnastrophy.core.data.service.GpsService -import com.github.warnastrophy.core.data.service.PositionService -import dagger.Binds -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -/** Hilt module that provides the binding for the application's position tracking service. */ -@Module -@InstallIn(SingletonComponent::class) -abstract class GPSModule { - @Singleton @Binds abstract fun bindPositionService(gpsService: GpsService): PositionService -} diff --git a/app/src/main/java/com/github/warnastrophy/core/di/HazardsModule.kt b/app/src/main/java/com/github/warnastrophy/core/di/HazardsModule.kt deleted file mode 100644 index 76a1023e8..000000000 --- a/app/src/main/java/com/github/warnastrophy/core/di/HazardsModule.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.github.warnastrophy.core.di - -import com.github.warnastrophy.core.data.repository.HazardsDataSource -import com.github.warnastrophy.core.data.repository.HazardsRepository -import com.github.warnastrophy.core.data.service.HazardsDataService -import com.github.warnastrophy.core.data.service.HazardsService -import dagger.Binds -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -/** - * Hilt module that provides the bindings for the application's hazard data and service layers. This - * module ensures that components requiring hazard information receive Singleton instances of both - * the data source (HazardsDataSource) and the domain service (HazardsDataService). - */ -@Module -@InstallIn(SingletonComponent::class) -abstract class HazardsModule { - @Binds - @Singleton - abstract fun bindHazardsDataSource(repository: HazardsRepository): HazardsDataSource - - @Binds - @Singleton - abstract fun bindHazardDataService(hazardsService: HazardsService): HazardsDataService -} diff --git a/app/src/main/java/com/github/warnastrophy/core/di/LocationModule.kt b/app/src/main/java/com/github/warnastrophy/core/di/LocationModule.kt deleted file mode 100644 index 03833166f..000000000 --- a/app/src/main/java/com/github/warnastrophy/core/di/LocationModule.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.github.warnastrophy.core.di - -import android.content.Context -import com.google.android.gms.location.FusedLocationProviderClient -import com.google.android.gms.location.LocationServices -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent - -/** This Dagger Hilt module provides [FusedLocationProviderClient] dependencies for GPS service. */ -@Module -@InstallIn(SingletonComponent::class) -object LocationModule { - - @Provides - fun provideFusedLocationProviderClient( - @ApplicationContext context: Context - ): FusedLocationProviderClient { - return LocationServices.getFusedLocationProviderClient(context) - } -} diff --git a/app/src/main/java/com/github/warnastrophy/core/di/PermissionModule.kt b/app/src/main/java/com/github/warnastrophy/core/di/PermissionModule.kt deleted file mode 100644 index 4afb9f10a..000000000 --- a/app/src/main/java/com/github/warnastrophy/core/di/PermissionModule.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.github.warnastrophy.core.di - -import android.content.Context -import com.github.warnastrophy.core.permissions.PermissionManager -import com.github.warnastrophy.core.permissions.PermissionManagerInterface -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -/** Hilt module that provides the binding for the application's permission management system. */ -@Module -@InstallIn(SingletonComponent::class) -object PermissionModule { - @Provides - @Singleton - fun providePermissionManagerInterface( - @ApplicationContext context: Context - ): PermissionManagerInterface { - return PermissionManager(context) - } -} diff --git a/app/src/main/java/com/github/warnastrophy/core/model/Activity.kt b/app/src/main/java/com/github/warnastrophy/core/model/Activity.kt index f8e42424d..cc2c39566 100644 --- a/app/src/main/java/com/github/warnastrophy/core/model/Activity.kt +++ b/app/src/main/java/com/github/warnastrophy/core/model/Activity.kt @@ -6,7 +6,7 @@ import com.github.warnastrophy.core.data.service.MovementConfig * A data class representing an activity that user does when danger mode is on. * * @param id id of activity, created by - * [com.github.warnastrophy.core.data.repository.ActivityRepository] + * [com.github.warnastrophy.core.data.interfaces.ActivityRepository] * @param activityName title of activity * @param movementConfig configuration related to movement detection for this activity */ diff --git a/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/ConfirmationPopup.kt b/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/ConfirmationPopup.kt new file mode 100644 index 000000000..7fc7e9ca6 --- /dev/null +++ b/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/ConfirmationPopup.kt @@ -0,0 +1,75 @@ +package com.github.warnastrophy.core.ui.features.dashboard + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp + +object ConfirmationPopupTestTags { + const val DIALOG = "confirmationPopupDialog" + const val TITLE = "confirmationPopupTitle" + const val CONFIRM_BUTTON = "confirmationPopupConfirm" + const val CANCEL_BUTTON = "confirmationPopupCancel" + const val TITLE_TEXT = "confirmationPopupTitleText" + const val DESCRIPTION_TEXT = "confirmationPopupDescriptionText" +} + +@Composable +fun ConfirmationPopup( + title: String, + message: String, + confirmLabel: String, + cancelLabel: String, + onConfirm: () -> Unit, + onCancel: () -> Unit, + modifier: Modifier = Modifier +) { + AlertDialog( + modifier = modifier.testTag(ConfirmationPopupTestTags.DIALOG), + onDismissRequest = onCancel, + title = { + Text( + modifier = Modifier.testTag(ConfirmationPopupTestTags.TITLE_TEXT), + text = title, + style = MaterialTheme.typography.titleLarge) + }, + text = { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + modifier = Modifier.testTag(ConfirmationPopupTestTags.DESCRIPTION_TEXT), + text = message, + style = MaterialTheme.typography.bodyMedium) + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = + Modifier.fillMaxWidth().semantics { contentDescription = "confirmationActions" }, + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + TextButton( + modifier = Modifier.weight(1f).testTag(ConfirmationPopupTestTags.CANCEL_BUTTON), + onClick = onCancel) { + Text(text = cancelLabel) + } + TextButton( + modifier = + Modifier.weight(1f).testTag(ConfirmationPopupTestTags.CONFIRM_BUTTON), + onClick = onConfirm) { + Text(text = confirmLabel) + } + } + } + }, + confirmButton = {}, + dismissButton = {}) +} diff --git a/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCard.kt b/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCard.kt index 8bcdfcf13..e265e39e0 100644 --- a/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCard.kt +++ b/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCard.kt @@ -1,5 +1,7 @@ package com.github.warnastrophy.core.ui.features.dashboard +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -34,6 +36,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics @@ -42,10 +45,15 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import com.github.warnastrophy.core.data.service.DangerLevel +import com.github.warnastrophy.core.ui.components.ActivityFallback import com.github.warnastrophy.core.ui.components.StandardDashboardButton import com.github.warnastrophy.core.ui.components.StandardDashboardCard import com.github.warnastrophy.core.ui.navigation.NavigationTestTags import com.github.warnastrophy.core.ui.theme.extendedColors +import com.github.warnastrophy.core.util.findActivity +import com.github.warnastrophy.core.util.openAppSettings +import com.github.warnastrophy.core.util.startForegroundGpsService +import com.github.warnastrophy.core.util.stopForegroundGpsService object DangerModeTestTags { const val CARD = "dangerModeCard" @@ -105,6 +113,37 @@ fun DangerModeCard( val colorScheme = MaterialTheme.colorScheme val extendedColors = MaterialTheme.extendedColors + val context = LocalContext.current + val activity = LocalContext.current.findActivity() + if (activity == null) { + ActivityFallback() + return + } + val launcher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestMultiplePermissions(), + onResult = { viewModel.onPermissionResult(activity = activity) }) + + LaunchedEffect(Unit) { + viewModel.effects.collect { effect -> + when (effect) { + Effect.RequestLocationPermission -> { + viewModel.onPermissionsRequestStart() + launcher.launch(viewModel.alertModePermission.permissions) + } + Effect.StartForegroundService -> { + startForegroundGpsService(activity) + } + Effect.StopForegroundService -> { + stopForegroundGpsService(context) + } + Effect.ShowOpenAppSettings -> { + openAppSettings(context) + } + } + } + } + StandardDashboardCard( modifier = modifier.fillMaxWidth().testTag(DangerModeTestTags.CARD), backgroundColor = colorScheme.error, @@ -356,9 +395,13 @@ private fun DangerModeSwitch( viewModel: DangerModeCardViewModel, modifier: Modifier = Modifier ) { + val permissionResState by viewModel.permissionUiState.collectAsState() Switch( checked = checked, - onCheckedChange = { viewModel.onDangerModeToggled(it) }, + onCheckedChange = { isChecked -> + viewModel.handleToggle( + isChecked = isChecked, permissionResult = permissionResState.alertModePermissionResult) + }, modifier = modifier.testTag(DangerModeTestTags.SWITCH)) } diff --git a/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCardViewModel.kt b/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCardViewModel.kt index 4c1675bac..760d84f5b 100644 --- a/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCardViewModel.kt +++ b/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCardViewModel.kt @@ -3,20 +3,26 @@ package com.github.warnastrophy.core.ui.features.dashboard import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.github.warnastrophy.core.data.repository.ActivityRepository +import com.github.warnastrophy.core.data.interfaces.ActivityRepository import com.github.warnastrophy.core.data.service.DangerLevel import com.github.warnastrophy.core.data.service.StateManagerService +import com.github.warnastrophy.core.data.service.StateManagerService.permissionManager import com.github.warnastrophy.core.model.Activity +import com.github.warnastrophy.core.permissions.AppPermissions +import com.github.warnastrophy.core.permissions.PermissionResult import com.github.warnastrophy.core.util.AppConfig import com.google.firebase.auth.FirebaseAuth import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch /** Capabilities of the danger mode that can be enabled in Danger Mode with associated labels. */ @@ -25,6 +31,40 @@ enum class DangerModeCapability(val label: String) { SMS("SMS"), } +/** + * Represents the entire state of the Alert Mode feature in the UI at any given time. + * + * @property alertModeManualEnabled True if the user has manually toggled the feature ON/OFF. This + * controls the primary switch state on the screen. + * @property alertModePermissionResult The current permission status related to running the Alert + * Mode feature. + * @property waitingForUserResponse True if the ViewModel is waiting for an asynchronous response + * from the system or the user. + */ +data class AlertModeUiState( + val alertModeManualEnabled: Boolean = false, + val alertModePermissionResult: PermissionResult, + val waitingForUserResponse: Boolean = false +) + +/** + * Represents one-time, non-repeatable side effects that must be executed by the UI layer. They are + * typically used for: + * 1. Navigation. + * 2. Displaying Toast messages or Dialogs. + * 3. Starting/Stopping services. + * 4. Requesting system permissions. + */ +sealed interface Effect { + object RequestLocationPermission : Effect + + object StartForegroundService : Effect + + object StopForegroundService : Effect + + object ShowOpenAppSettings : Effect +} + /** * ViewModel for managing the state of the Danger Mode card in the dashboard UI. * @@ -38,10 +78,20 @@ class DangerModeCardViewModel( FirebaseAuth.getInstance().currentUser?.uid ?: AppConfig.defaultUserId, private val dispatcher: CoroutineDispatcher = Dispatchers.IO ) : ViewModel() { + val alertModePermission = AppPermissions.AlertModePermission private val dangerModeService = StateManagerService.dangerModeService private val _activities = MutableStateFlow>(emptyList()) val activities: StateFlow> = _activities.asStateFlow() + private val _effects = MutableSharedFlow() + val effects = _effects.asSharedFlow() + + private val _alertModeUiState = + MutableStateFlow( + AlertModeUiState( + alertModePermissionResult = + permissionManager.getPermissionResult(alertModePermission))) + val permissionUiState = _alertModeUiState.asStateFlow() init { refreshActivities() @@ -99,13 +149,14 @@ class DangerModeCardViewModel( * accordingly. * * @param enabled True to enable Danger Mode, false to disable it. - * @param context The context used to start or stop the GPS service. */ fun onDangerModeToggled(enabled: Boolean) { if (enabled) { dangerModeService.manualActivate() + emitEffect(Effect.StartForegroundService) } else { dangerModeService.manualDeactivate() + emitEffect(Effect.StopForegroundService) } } @@ -170,4 +221,62 @@ class DangerModeCardViewModel( _confirmVoiceRequired.value = enabled // TODO: Persist & enforce voice confirmation before actions. } + + /** + * Emits a one-time side effect to the UI. + * + * This function launches a coroutine on the specified [dispatcher] within the ViewModel's + * lifecycle scope. + * + * The effect is emitted via the [_effects] SharedFlow, allowing the UI layer to collect it once + * and then discard the event, preventing replays on configuration changes. + * + * @param effect The specific [Effect] to be emitted to the collector (typically the UI/Fragment). + */ + private fun emitEffect(effect: Effect) { + viewModelScope.launch(dispatcher) { _effects.emit(effect) } + } + + /** Records that a permission request has been initiated by update UIState. */ + fun onPermissionsRequestStart() { + _alertModeUiState.update { it.copy(waitingForUserResponse = true) } + } + + /** + * Updates the permission results in the UI state after the user has responded to a system + * permission dialog. + * + * @param activity The current `Activity`, required to check the latest permission statuses. + */ + fun onPermissionResult(activity: android.app.Activity) { + val newAlertModeResult = permissionManager.getPermissionResult(alertModePermission, activity) + _alertModeUiState.update { it.copy(alertModePermissionResult = newAlertModeResult) } + if (_alertModeUiState.value.waitingForUserResponse) { + permissionManager.markPermissionsAsAsked(alertModePermission) + if (newAlertModeResult is PermissionResult.Granted) { + onDangerModeToggled(true) + } + } + _alertModeUiState.update { it.copy(waitingForUserResponse = false) } + } + + /** + * Handles the logic when user toggle the button, checking permissions and dispatching actions. + * + * @param isChecked The new state of the preference toggle. + * @param permissionResult The current permission status for this feature. + */ + fun handleToggle(isChecked: Boolean, permissionResult: PermissionResult) { + if (isChecked) { + when (permissionResult) { + PermissionResult.Granted -> { + onDangerModeToggled(true) + } + is PermissionResult.Denied -> emitEffect(Effect.RequestLocationPermission) + is PermissionResult.PermanentlyDenied -> emitEffect(Effect.ShowOpenAppSettings) + } + } else { + onDangerModeToggled(false) + } + } } diff --git a/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/Dashboard.kt b/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/Dashboard.kt index f5c0fc2b6..f141dde7c 100644 --- a/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/Dashboard.kt +++ b/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/Dashboard.kt @@ -25,7 +25,6 @@ import com.github.warnastrophy.core.data.service.HazardsDataService import com.github.warnastrophy.core.model.Contact import com.github.warnastrophy.core.ui.features.contact.ContactPopUp import com.github.warnastrophy.core.ui.features.health.HealthCardPopUp -import com.github.warnastrophy.core.ui.layout.SafeZoneTopBar import com.github.warnastrophy.core.ui.theme.extendedColors object DashboardScreenTestTags { diff --git a/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/DashboardEmergencyContactsStatefulViewModel.kt b/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/DashboardEmergencyContactsStatefulViewModel.kt index 83bd7dd48..977f3b3ae 100644 --- a/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/DashboardEmergencyContactsStatefulViewModel.kt +++ b/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/DashboardEmergencyContactsStatefulViewModel.kt @@ -4,8 +4,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.warnastrophy.core.data.interfaces.ContactsRepository import com.github.warnastrophy.core.model.Contact -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -17,10 +15,8 @@ import kotlinx.coroutines.launch * * @property repository The [ContactsRepository] used to fetch contact data from the data layer. */ -@HiltViewModel -class DashboardEmergencyContactsStatefulViewModel -@Inject -constructor(private val repository: ContactsRepository) : ViewModel() { +class DashboardEmergencyContactsStatefulViewModel(private val repository: ContactsRepository) : + ViewModel() { private val _contactsState = MutableStateFlow(ContactCardState.Loading) val contactsState: StateFlow = _contactsState.asStateFlow() diff --git a/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/DashboardHealthCardViewModel.kt b/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/DashboardHealthCardViewModel.kt index 7a0821181..7335b1e9c 100644 --- a/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/DashboardHealthCardViewModel.kt +++ b/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/DashboardHealthCardViewModel.kt @@ -3,8 +3,8 @@ package com.github.warnastrophy.core.ui.features.dashboard import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.github.warnastrophy.core.data.localStorage.HealthCardStorage -import com.github.warnastrophy.core.data.localStorage.StorageResult +import com.github.warnastrophy.core.data.local.HealthCardStorage +import com.github.warnastrophy.core.data.local.StorageResult import com.github.warnastrophy.core.model.HealthCard import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers diff --git a/app/src/main/java/com/github/warnastrophy/core/ui/layout/SafeZoneTopBar.kt b/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/SafeZoneTopBar.kt similarity index 96% rename from app/src/main/java/com/github/warnastrophy/core/ui/layout/SafeZoneTopBar.kt rename to app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/SafeZoneTopBar.kt index ddfa20de5..6f6839a21 100644 --- a/app/src/main/java/com/github/warnastrophy/core/ui/layout/SafeZoneTopBar.kt +++ b/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/SafeZoneTopBar.kt @@ -1,4 +1,4 @@ -package com.github.warnastrophy.core.ui.layout +package com.github.warnastrophy.core.ui.features.dashboard import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box diff --git a/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/activity/ActivityListViewModel.kt b/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/activity/ActivityListViewModel.kt index 95ceea8fc..e3c8bffba 100644 --- a/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/activity/ActivityListViewModel.kt +++ b/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/activity/ActivityListViewModel.kt @@ -3,7 +3,7 @@ package com.github.warnastrophy.core.ui.features.dashboard.activity import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.github.warnastrophy.core.data.repository.ActivityRepository +import com.github.warnastrophy.core.data.interfaces.ActivityRepository import com.github.warnastrophy.core.data.service.StateManagerService import com.github.warnastrophy.core.model.Activity import kotlinx.coroutines.CoroutineDispatcher diff --git a/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/activity/AddActivityViewModel.kt b/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/activity/AddActivityViewModel.kt index f919ce877..907de9ded 100644 --- a/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/activity/AddActivityViewModel.kt +++ b/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/activity/AddActivityViewModel.kt @@ -3,7 +3,7 @@ package com.github.warnastrophy.core.ui.features.dashboard.activity import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.github.warnastrophy.core.data.repository.ActivityRepository +import com.github.warnastrophy.core.data.interfaces.ActivityRepository import com.github.warnastrophy.core.data.service.StateManagerService import com.github.warnastrophy.core.model.Activity import kotlinx.coroutines.CoroutineDispatcher diff --git a/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/activity/EditActivityViewModel.kt b/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/activity/EditActivityViewModel.kt index 75c5909f8..8b6298be3 100644 --- a/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/activity/EditActivityViewModel.kt +++ b/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/activity/EditActivityViewModel.kt @@ -2,7 +2,7 @@ package com.github.warnastrophy.core.ui.features.dashboard.activity import android.util.Log import androidx.lifecycle.viewModelScope -import com.github.warnastrophy.core.data.repository.ActivityRepository +import com.github.warnastrophy.core.data.interfaces.ActivityRepository import com.github.warnastrophy.core.data.service.StateManagerService import com.github.warnastrophy.core.model.Activity import kotlinx.coroutines.CoroutineDispatcher diff --git a/app/src/main/java/com/github/warnastrophy/core/ui/features/health/HealthCardViewModel.kt b/app/src/main/java/com/github/warnastrophy/core/ui/features/health/HealthCardViewModel.kt index f74bfeebb..9606d3a8e 100644 --- a/app/src/main/java/com/github/warnastrophy/core/ui/features/health/HealthCardViewModel.kt +++ b/app/src/main/java/com/github/warnastrophy/core/ui/features/health/HealthCardViewModel.kt @@ -3,8 +3,8 @@ package com.github.warnastrophy.core.ui.features.health import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.github.warnastrophy.core.data.localStorage.HealthCardStorage -import com.github.warnastrophy.core.data.localStorage.StorageResult +import com.github.warnastrophy.core.data.local.HealthCardStorage +import com.github.warnastrophy.core.data.local.StorageResult import com.github.warnastrophy.core.data.provider.HealthCardRepositoryProvider import com.github.warnastrophy.core.data.repository.HealthCardRepository import com.github.warnastrophy.core.model.HealthCard diff --git a/app/src/main/java/com/github/warnastrophy/core/ui/features/map/Map.kt b/app/src/main/java/com/github/warnastrophy/core/ui/features/map/Map.kt index 3d2d624f3..1b763a0d9 100644 --- a/app/src/main/java/com/github/warnastrophy/core/ui/features/map/Map.kt +++ b/app/src/main/java/com/github/warnastrophy/core/ui/features/map/Map.kt @@ -19,6 +19,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput @@ -211,17 +214,24 @@ fun HazardsGoogleMap( context: Context = LocalContext.current, ) { val hazards = uiState.hazardState.hazards + var selectedMarkerId by remember { mutableStateOf(null) } GoogleMap( modifier = Modifier.fillMaxSize().testTag(MapScreenTestTags.GOOGLE_MAP_SCREEN), cameraPositionState = cameraPositionState, + onMapClick = { selectedMarkerId = null }, uiSettings = MapUiSettings( myLocationButtonEnabled = false, zoomControlsEnabled = true, mapToolbarEnabled = false), properties = MapProperties(isMyLocationEnabled = uiState.isGranted)) { - hazards.forEach { hazard -> HazardMarker(hazard) } + hazards.forEach { hazard -> + HazardMarker( + hazard = hazard, + selectedMarkerId = selectedMarkerId, + onMarkerSelected = { selectedMarkerId = it }) + } uiState.selectedLocation?.let { loc -> val pos = toLatLng(loc) diff --git a/app/src/main/java/com/github/warnastrophy/core/ui/features/map/MapIcon.kt b/app/src/main/java/com/github/warnastrophy/core/ui/features/map/MapIcon.kt index 49884c047..22f5ca704 100644 --- a/app/src/main/java/com/github/warnastrophy/core/ui/features/map/MapIcon.kt +++ b/app/src/main/java/com/github/warnastrophy/core/ui/features/map/MapIcon.kt @@ -20,6 +20,8 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -139,70 +141,114 @@ fun getSeverityColor(hazard: Hazard): Color { * Composable function to display a hazard marker on the map. * * @param hazard The hazard data to be displayed. - * @param markerContent A composable function mainly to test the marker content, because - * MarkerComposable is not directly testable because it's rasterized on the map. + * @param selectedMarkerId The ID of the currently selected marker (if any), used to show/hide + * polygon. + * @param onMarkerSelected Callback invoked when the marker is selected. + * @param onInfoWindowClick Callback invoked when the info window is clicked. If null (default), + * opens the article URL. + * @param markerIconProvider Injectable function for creating the marker icon, allows testing + * without GoogleMap. If null (default), uses bitmapDescriptorFromVector. + * @param polygonContent Injectable composable for rendering the polygon, allows testing without + * GoogleMap. + * @param markerInfoWindowContent Injectable composable for the marker info window, allows testing + * without GoogleMap. + * @param iconContent Injectable composable for the icon content, allows testing without GoogleMap. */ @Composable fun HazardMarker( hazard: Hazard, - markerContent: + selectedMarkerId: Int? = null, + onMarkerSelected: (Int?) -> Unit = {}, + onInfoWindowClick: (() -> Unit)? = null, + markerIconProvider: ((Context, Int, Float, Color) -> BitmapDescriptor?)? = null, + polygonContent: @Composable (polygonCoords: List) -> Unit = { coords -> + PolygonWrapper(coords) + }, + markerInfoWindowContent: @Composable ( state: MarkerState, - title: String?, - snippet: String?, + icon: BitmapDescriptor?, + onMarkerClick: () -> Unit, + onInfoWindowClick: () -> Unit, content: @Composable () -> Unit) -> Unit = - { state, title, snippet, _ /* we ignore content here for default impl */ -> - val ctx = LocalContext.current - val severityTint = getSeverityColor(hazard) - val iconRes = hazardTypeToDrawableRes(hazard.type) - val markerIcon: BitmapDescriptor? = - iconRes?.let { - bitmapDescriptorFromVector( - context = ctx, vectorResId = it, sizeDp = 32f, tintColor = severityTint) - } - + { state, icon, onMarkerClick, infoWindowClick, content -> MarkerInfoWindow( state = state, - onClick = { false }, // keep default behaviour - icon = markerIcon, - onInfoWindowClick = { openWebPage(ctx, hazard.articleUrl) }) { - HazardInfoWindowContent(hazard = hazard, title = title, snippet = snippet) - } - } + onClick = { + onMarkerClick() + false // keep default behaviour + }, + icon = icon, + onInfoWindowClick = { infoWindowClick() }, + content = { content() }) + }, + iconContent: @Composable (icon: MapIcon, tint: Color) -> Unit = { icon, tint -> icon(tint) } ) { + val ctx = LocalContext.current + // The markerLocation is the centroid of the geometry. val markerLocation = hazard.centroid?.centroid?.let { point -> Location(point.y, point.x) } ?: Location(0.0, 0.0) - val affectedZone: List? = - hazard.affectedZone?.let { nonNullGeometry -> - // 'nonNullGeometry' inside the 'let' block is now guaranteed to be 'Geometry' (non-null) - GeometryParser.jtsGeometryToLatLngList(nonNullGeometry) + // Only create markerState when not in test mode (markerIconProvider null means production) + // In test mode, we'll create a fake MarkerState that won't be used + val markerState = + if (markerIconProvider == null) { + rememberMarkerState(position = Location.toLatLng(markerLocation)) + } else { + // Test mode: create a minimal MarkerState without Google Maps + remember { MarkerState(position = LatLng(0.0, 0.0)) } } + + // If selectedMarkerId is provided, use it to determine if this marker should show polygon + // Otherwise, maintain local state for backward compatibility + val showPolygon = + if (onMarkerSelected !== {}) { + selectedMarkerId == hazard.id + } else { + remember { mutableStateOf(false) }.value + } + val snippet: String? = formatSeveritySnippet(hazard) + val severityTint = getSeverityColor(hazard) + val iconRes = hazardTypeToDrawableRes(hazard.type) + + // Use the injectable markerIconProvider if provided, otherwise use the default + // bitmapDescriptorFromVector + val markerIcon: BitmapDescriptor? = + iconRes?.let { res -> + if (markerIconProvider != null) { + markerIconProvider(ctx, res, 32f, severityTint) + } else { + bitmapDescriptorFromVector( + context = ctx, vectorResId = res, sizeDp = 32f, tintColor = severityTint) + } + } - affectedZone?.let { locations -> - if (locations.size > 1) { - val polygonCoords = locations.map { location -> Location.toLatLng(location) } + // Display polygon only when marker is selected (info window showing) + if (showPolygon) { + val affectedZone: List? = + hazard.affectedZone?.let { nonNullGeometry -> + GeometryParser.jtsGeometryToLatLngList(nonNullGeometry) + } - PolygonWrapper(polygonCoords) - } else { - // Fallback for empty polygon. - // This branch is unlikely if hazard.affectedZone exists. - // Log or handle this case as an anomaly if necessary. + affectedZone?.let { locations -> + if (locations.size > 1) { + val polygonCoords = locations.map { location -> Location.toLatLng(location) } + polygonContent(polygonCoords) + } } } - val severityTint = getSeverityColor(hazard) - markerContent( - rememberMarkerState(position = Location.toLatLng(markerLocation)), - hazard.description, - snippet, - ) { - val icon: MapIcon = hazardTypeToMapIcon(hazard.type) - icon(tint = severityTint) - } + // Determine the callback for info window click: use custom if provided, otherwise open web page + val infoWindowClickCallback = onInfoWindowClick ?: { openWebPage(ctx, hazard.articleUrl) } + + markerInfoWindowContent( + markerState, markerIcon, { onMarkerSelected(hazard.id) }, infoWindowClickCallback) { + HazardInfoWindowContent(hazard = hazard, title = hazard.description, snippet = snippet) + iconContent(hazardTypeToMapIcon(hazard.type), severityTint) + } } /** diff --git a/app/src/main/java/com/github/warnastrophy/core/ui/onboard/AppStateManagerViewModel.kt b/app/src/main/java/com/github/warnastrophy/core/ui/features/onboard/AppStateManagerViewModel.kt similarity index 91% rename from app/src/main/java/com/github/warnastrophy/core/ui/onboard/AppStateManagerViewModel.kt rename to app/src/main/java/com/github/warnastrophy/core/ui/features/onboard/AppStateManagerViewModel.kt index b8bb0822f..08879e8e1 100644 --- a/app/src/main/java/com/github/warnastrophy/core/ui/onboard/AppStateManagerViewModel.kt +++ b/app/src/main/java/com/github/warnastrophy/core/ui/features/onboard/AppStateManagerViewModel.kt @@ -1,9 +1,9 @@ -package com.github.warnastrophy.core.ui.onboard +package com.github.warnastrophy.core.ui.features.onboard import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.github.warnastrophy.core.data.provider.OnboardingRepositoryProvider import com.github.warnastrophy.core.data.repository.IntroductionRepository -import com.github.warnastrophy.core.data.repository.OnboardingRepositoryProvider import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow diff --git a/app/src/main/java/com/github/warnastrophy/core/ui/onboard/OnboardingGraphUI.kt b/app/src/main/java/com/github/warnastrophy/core/ui/features/onboard/OnboardingGraphUI.kt similarity index 97% rename from app/src/main/java/com/github/warnastrophy/core/ui/onboard/OnboardingGraphUI.kt rename to app/src/main/java/com/github/warnastrophy/core/ui/features/onboard/OnboardingGraphUI.kt index 0ed6357e1..2e0a88de3 100644 --- a/app/src/main/java/com/github/warnastrophy/core/ui/onboard/OnboardingGraphUI.kt +++ b/app/src/main/java/com/github/warnastrophy/core/ui/features/onboard/OnboardingGraphUI.kt @@ -1,4 +1,4 @@ -package com.github.warnastrophy.core.ui.onboard +package com.github.warnastrophy.core.ui.features.onboard import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column diff --git a/app/src/main/java/com/github/warnastrophy/core/ui/onboard/OnboardingModel.kt b/app/src/main/java/com/github/warnastrophy/core/ui/features/onboard/OnboardingModel.kt similarity index 94% rename from app/src/main/java/com/github/warnastrophy/core/ui/onboard/OnboardingModel.kt rename to app/src/main/java/com/github/warnastrophy/core/ui/features/onboard/OnboardingModel.kt index 34dbda7eb..eb8b8a9be 100644 --- a/app/src/main/java/com/github/warnastrophy/core/ui/onboard/OnboardingModel.kt +++ b/app/src/main/java/com/github/warnastrophy/core/ui/features/onboard/OnboardingModel.kt @@ -1,4 +1,4 @@ -package com.github.warnastrophy.core.ui.onboard +package com.github.warnastrophy.core.ui.features.onboard import androidx.annotation.StringRes import com.github.warnastrophy.R diff --git a/app/src/main/java/com/github/warnastrophy/core/ui/onboard/OnboardingScreen.kt b/app/src/main/java/com/github/warnastrophy/core/ui/features/onboard/OnboardingScreen.kt similarity index 99% rename from app/src/main/java/com/github/warnastrophy/core/ui/onboard/OnboardingScreen.kt rename to app/src/main/java/com/github/warnastrophy/core/ui/features/onboard/OnboardingScreen.kt index c7cd6fe0a..c4eb8ba1e 100644 --- a/app/src/main/java/com/github/warnastrophy/core/ui/onboard/OnboardingScreen.kt +++ b/app/src/main/java/com/github/warnastrophy/core/ui/features/onboard/OnboardingScreen.kt @@ -1,4 +1,4 @@ -package com.github.warnastrophy.core.ui.onboard +package com.github.warnastrophy.core.ui.features.onboard import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background diff --git a/app/src/main/java/com/github/warnastrophy/core/ui/features/profile/ThemeViewModel.kt b/app/src/main/java/com/github/warnastrophy/core/ui/features/profile/ThemeViewModel.kt index 19fc44254..e15c3c2a7 100644 --- a/app/src/main/java/com/github/warnastrophy/core/ui/features/profile/ThemeViewModel.kt +++ b/app/src/main/java/com/github/warnastrophy/core/ui/features/profile/ThemeViewModel.kt @@ -2,7 +2,7 @@ package com.github.warnastrophy.core.ui.features.profile import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.github.warnastrophy.core.data.repository.UserPreferencesRepository +import com.github.warnastrophy.core.data.interfaces.UserPreferencesRepository import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted diff --git a/app/src/main/java/com/github/warnastrophy/core/ui/features/profile/ThemeViewModelFactory.kt b/app/src/main/java/com/github/warnastrophy/core/ui/features/profile/ThemeViewModelFactory.kt index 16d5ba5be..d1ef415a2 100644 --- a/app/src/main/java/com/github/warnastrophy/core/ui/features/profile/ThemeViewModelFactory.kt +++ b/app/src/main/java/com/github/warnastrophy/core/ui/features/profile/ThemeViewModelFactory.kt @@ -2,7 +2,7 @@ package com.github.warnastrophy.core.ui.features.profile import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider -import com.github.warnastrophy.core.data.repository.UserPreferencesRepository +import com.github.warnastrophy.core.data.interfaces.UserPreferencesRepository /** * Factory class responsible for creating instances of the [ThemeViewModel]. diff --git a/app/src/main/java/com/github/warnastrophy/core/ui/features/profile/preferences/DangerModePreferencesViewModel.kt b/app/src/main/java/com/github/warnastrophy/core/ui/features/profile/preferences/DangerModePreferencesViewModel.kt index 5930df8fd..8d643066a 100644 --- a/app/src/main/java/com/github/warnastrophy/core/ui/features/profile/preferences/DangerModePreferencesViewModel.kt +++ b/app/src/main/java/com/github/warnastrophy/core/ui/features/profile/preferences/DangerModePreferencesViewModel.kt @@ -3,7 +3,7 @@ package com.github.warnastrophy.core.ui.features.profile.preferences import android.app.Activity import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.github.warnastrophy.core.data.repository.UserPreferencesRepository +import com.github.warnastrophy.core.data.interfaces.UserPreferencesRepository import com.github.warnastrophy.core.permissions.AppPermissions import com.github.warnastrophy.core.permissions.PermissionManagerInterface import com.github.warnastrophy.core.permissions.PermissionResult diff --git a/app/src/main/java/com/github/warnastrophy/core/util/CryptoUtils.kt b/app/src/main/java/com/github/warnastrophy/core/util/CryptoUtils.kt index bf3bb2af6..b4df349f2 100644 --- a/app/src/main/java/com/github/warnastrophy/core/util/CryptoUtils.kt +++ b/app/src/main/java/com/github/warnastrophy/core/util/CryptoUtils.kt @@ -1,6 +1,6 @@ package com.github.warnastrophy.core.util -import com.github.warnastrophy.core.data.localStorage.KeyStoreProvider +import com.github.warnastrophy.core.data.provider.KeyStoreProvider import java.util.Base64 import javax.crypto.Cipher import javax.crypto.SecretKey diff --git a/app/src/test/java/com/github/warnastrophy/core/data/localStorage/HealthCardStorageUnitTest.kt b/app/src/test/java/com/github/warnastrophy/core/data/local/HealthCardStorageUnitTest.kt similarity index 99% rename from app/src/test/java/com/github/warnastrophy/core/data/localStorage/HealthCardStorageUnitTest.kt rename to app/src/test/java/com/github/warnastrophy/core/data/local/HealthCardStorageUnitTest.kt index f8b6cdcb5..5bced3116 100644 --- a/app/src/test/java/com/github/warnastrophy/core/data/localStorage/HealthCardStorageUnitTest.kt +++ b/app/src/test/java/com/github/warnastrophy/core/data/local/HealthCardStorageUnitTest.kt @@ -1,4 +1,4 @@ -package com.github.warnastrophy.core.data.localStorage +package com.github.warnastrophy.core.data.local import android.content.Context import androidx.datastore.preferences.core.edit diff --git a/app/src/test/java/com/github/warnastrophy/core/data/localStorage/StorageExceptionTest.kt b/app/src/test/java/com/github/warnastrophy/core/data/local/StorageExceptionTest.kt similarity index 96% rename from app/src/test/java/com/github/warnastrophy/core/data/localStorage/StorageExceptionTest.kt rename to app/src/test/java/com/github/warnastrophy/core/data/local/StorageExceptionTest.kt index 60e162ab8..875186855 100644 --- a/app/src/test/java/com/github/warnastrophy/core/data/localStorage/StorageExceptionTest.kt +++ b/app/src/test/java/com/github/warnastrophy/core/data/local/StorageExceptionTest.kt @@ -1,4 +1,4 @@ -package com.github.warnastrophy.core.data.localStorage +package com.github.warnastrophy.core.data.local import org.junit.Assert.assertEquals import org.junit.Assert.assertSame diff --git a/app/src/test/java/com/github/warnastrophy/core/data/repository/ContactRepositoryImplTest.kt b/app/src/test/java/com/github/warnastrophy/core/data/repository/ContactRepositoryImplTest.kt index 33512487d..12725b26c 100644 --- a/app/src/test/java/com/github/warnastrophy/core/data/repository/ContactRepositoryImplTest.kt +++ b/app/src/test/java/com/github/warnastrophy/core/data/repository/ContactRepositoryImplTest.kt @@ -1,6 +1,6 @@ package com.github.warnastrophy.core.data.repository -import com.github.warnastrophy.core.data.localStorage.StorageException +import com.github.warnastrophy.core.data.local.StorageException import com.github.warnastrophy.core.model.Contact import com.github.warnastrophy.core.util.CryptoUtils import com.google.android.gms.tasks.Tasks diff --git a/app/src/test/java/com/github/warnastrophy/core/data/repository/ContactsRepositoryLocalTests.kt b/app/src/test/java/com/github/warnastrophy/core/data/repository/ContactsRepositoryLocalTests.kt index dfe697b65..e22d98e42 100644 --- a/app/src/test/java/com/github/warnastrophy/core/data/repository/ContactsRepositoryLocalTests.kt +++ b/app/src/test/java/com/github/warnastrophy/core/data/repository/ContactsRepositoryLocalTests.kt @@ -5,9 +5,9 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.test.core.app.ApplicationProvider -import com.github.warnastrophy.core.data.localStorage.ContactsStorage -import com.github.warnastrophy.core.data.localStorage.StorageException -import com.github.warnastrophy.core.data.localStorage.contactDataStore +import com.github.warnastrophy.core.data.local.ContactsStorage +import com.github.warnastrophy.core.data.local.StorageException +import com.github.warnastrophy.core.data.local.contactDataStore import com.github.warnastrophy.core.model.Contact import com.github.warnastrophy.core.util.CryptoUtils import io.mockk.coEvery diff --git a/app/src/test/java/com/github/warnastrophy/core/data/repository/UserPreferencesRepositoryRemoteTest.kt b/app/src/test/java/com/github/warnastrophy/core/data/repository/UserPreferencesRepositoryRemoteTest.kt index 4b8a8922f..f3bfedf72 100644 --- a/app/src/test/java/com/github/warnastrophy/core/data/repository/UserPreferencesRepositoryRemoteTest.kt +++ b/app/src/test/java/com/github/warnastrophy/core/data/repository/UserPreferencesRepositoryRemoteTest.kt @@ -1,5 +1,6 @@ package com.github.warnastrophy.core.data.repository +import com.github.warnastrophy.core.data.interfaces.UserPreferencesRepository import com.google.android.gms.tasks.Tasks import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseUser diff --git a/app/src/test/java/com/github/warnastrophy/core/data/service/DangerModeOrchestratorTest.kt b/app/src/test/java/com/github/warnastrophy/core/data/service/DangerModeOrchestratorTest.kt index ee5e7c692..15d619dfb 100644 --- a/app/src/test/java/com/github/warnastrophy/core/data/service/DangerModeOrchestratorTest.kt +++ b/app/src/test/java/com/github/warnastrophy/core/data/service/DangerModeOrchestratorTest.kt @@ -1,9 +1,9 @@ package com.github.warnastrophy.core.data.service +import com.github.warnastrophy.core.data.interfaces.UserPreferencesRepository import com.github.warnastrophy.core.data.repository.MotionData import com.github.warnastrophy.core.data.repository.MovementSensorRepository import com.github.warnastrophy.core.data.repository.UserPreferences -import com.github.warnastrophy.core.data.repository.UserPreferencesRepository import com.github.warnastrophy.core.domain.model.EmergencyMessage import com.github.warnastrophy.core.model.Hazard import com.github.warnastrophy.core.permissions.AppPermissions diff --git a/app/src/test/java/com/github/warnastrophy/core/data/service/ForegroundServiceTest.kt b/app/src/test/java/com/github/warnastrophy/core/data/service/ForegroundServiceTest.kt index de8abbcd8..da2cfb48d 100644 --- a/app/src/test/java/com/github/warnastrophy/core/data/service/ForegroundServiceTest.kt +++ b/app/src/test/java/com/github/warnastrophy/core/data/service/ForegroundServiceTest.kt @@ -10,10 +10,10 @@ import android.os.Build import androidx.test.core.app.ApplicationProvider import com.github.warnastrophy.core.data.provider.ActivityRepositoryProvider import com.github.warnastrophy.core.data.provider.UserPreferencesRepositoryProvider -import com.github.warnastrophy.core.di.userPrefsDataStore import com.github.warnastrophy.core.ui.common.ErrorHandler import com.github.warnastrophy.core.util.startForegroundGpsService import com.github.warnastrophy.core.util.stopForegroundGpsService +import com.github.warnastrophy.userPrefsDataStore import com.google.android.gms.location.FusedLocationProviderClient import io.mockk.mockk import io.mockk.verify diff --git a/app/src/test/java/com/github/warnastrophy/core/ui/feature/dashboard/DangerModeCardViewModelTest.kt b/app/src/test/java/com/github/warnastrophy/core/ui/feature/dashboard/DangerModeCardViewModelTest.kt index 093532e37..4a011b437 100644 --- a/app/src/test/java/com/github/warnastrophy/core/ui/feature/dashboard/DangerModeCardViewModelTest.kt +++ b/app/src/test/java/com/github/warnastrophy/core/ui/feature/dashboard/DangerModeCardViewModelTest.kt @@ -1,18 +1,32 @@ -package com.github.warnastrophy.core.ui.dangermodecard +package com.github.warnastrophy.core.ui.feature.dashboard +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences import androidx.test.core.app.ApplicationProvider +import com.github.warnastrophy.core.data.provider.UserPreferencesRepositoryProvider import com.github.warnastrophy.core.data.repository.MockActivityRepository import com.github.warnastrophy.core.data.service.DangerModeService import com.github.warnastrophy.core.data.service.MockPermissionManager import com.github.warnastrophy.core.data.service.StateManagerService import com.github.warnastrophy.core.model.Activity +import com.github.warnastrophy.core.permissions.PermissionManagerInterface import com.github.warnastrophy.core.permissions.PermissionResult import com.github.warnastrophy.core.ui.features.dashboard.DangerModeCapability import com.github.warnastrophy.core.ui.features.dashboard.DangerModeCardViewModel +import com.github.warnastrophy.core.ui.features.dashboard.Effect import com.github.warnastrophy.core.util.AppConfig +import io.mockk.mockk +import io.mockk.spyk +import io.mockk.unmockkAll +import io.mockk.verify +import kotlin.test.assertFalse +import kotlin.test.assertTrue import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest @@ -21,6 +35,7 @@ import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner @ExperimentalCoroutinesApi @@ -29,24 +44,34 @@ class DangerModeCardViewModelTest { private lateinit var viewModel: DangerModeCardViewModel private lateinit var repository: MockActivityRepository private val testDispatcher = StandardTestDispatcher() + private lateinit var activity: android.app.Activity + + private lateinit var mockPermissionManager: PermissionManagerInterface + + private lateinit var mockDataStore: DataStore @Before fun setup() { + activity = Robolectric.buildActivity(android.app.Activity::class.java).get() + mockDataStore = mockk(relaxed = true) + UserPreferencesRepositoryProvider.initLocal(mockDataStore) StateManagerService.init(ApplicationProvider.getApplicationContext()) - StateManagerService.permissionManager = - MockPermissionManager(currentResult = PermissionResult.Granted) + val realMockPermissionManager = MockPermissionManager(currentResult = PermissionResult.Granted) + mockPermissionManager = spyk(realMockPermissionManager) + StateManagerService.permissionManager = mockPermissionManager StateManagerService.dangerModeService = DangerModeService(permissionManager = StateManagerService.permissionManager) // Initialize the mock repository for testing repository = MockActivityRepository() viewModel = DangerModeCardViewModel( - repository = repository, userId = AppConfig.defaultUserId, dispatcher = testDispatcher) + repository = repository, userId = AppConfig.defaultUserId, testDispatcher) } @After fun tearDown() { Dispatchers.resetMain() + unmockkAll() } @Test @@ -128,4 +153,63 @@ class DangerModeCardViewModelTest { val activities = viewModel.activities.first() assertEquals(2, activities.size) } + + @Test + fun onPermissionsResult_markPermissionAsAsked() = + runTest(testDispatcher) { + viewModel.onPermissionsRequestStart() + assertTrue(viewModel.permissionUiState.value.waitingForUserResponse) + viewModel.onPermissionResult(activity) + testDispatcher.scheduler.advanceUntilIdle() + verify(exactly = 1) { + mockPermissionManager.markPermissionsAsAsked(viewModel.alertModePermission) + } + assertFalse(viewModel.permissionUiState.value.waitingForUserResponse) + } + + @Test + fun handleToggle_ON_with_Granted_permission_activatesDangerMode_and_emitsStartServiceEffect() = + runTest(testDispatcher) { + val collectedEffects = mutableListOf() + launch { viewModel.effects.take(1).toList(collectedEffects) } + viewModel.handleToggle(isChecked = true, permissionResult = PermissionResult.Granted) + testDispatcher.scheduler.advanceUntilIdle() + assertEquals(1, collectedEffects.size) + assertEquals(collectedEffects[0], Effect.StartForegroundService) + } + + @Test + fun handleToggle_ON_with_Denied_permission_emits_RequestLocationPermissionEffect() = + runTest(testDispatcher) { + val collectedEffects = mutableListOf() + launch { viewModel.effects.take(1).toList(collectedEffects) } + viewModel.handleToggle( + isChecked = true, permissionResult = PermissionResult.Denied(emptyList())) + testDispatcher.scheduler.advanceUntilIdle() + assertEquals(1, collectedEffects.size) + assertEquals(collectedEffects[0], Effect.RequestLocationPermission) + } + + @Test + fun handleToggle_ON_with_Permanent_Denied_permission_emits_OpenAppSettingsEffect() = + runTest(testDispatcher) { + val collectedEffects = mutableListOf() + launch { viewModel.effects.take(1).toList(collectedEffects) } + viewModel.handleToggle( + isChecked = true, permissionResult = PermissionResult.PermanentlyDenied(emptyList())) + testDispatcher.scheduler.advanceUntilIdle() + assertEquals(1, collectedEffects.size) + assertEquals(collectedEffects[0], Effect.ShowOpenAppSettings) + } + + @Test + fun handleToggle_Off_emits_StopForegroundServiceEffect() = + runTest(testDispatcher) { + val collectedEffects = mutableListOf() + launch { viewModel.effects.take(1).toList(collectedEffects) } + viewModel.handleToggle(isChecked = false, permissionResult = PermissionResult.Granted) + testDispatcher.scheduler.advanceUntilIdle() + assertEquals(1, collectedEffects.size) + assertEquals(collectedEffects[0], Effect.StopForegroundService) + } } diff --git a/app/src/test/java/com/github/warnastrophy/core/ui/feature/dashboard/DashboardHealthCardViewModelTest.kt b/app/src/test/java/com/github/warnastrophy/core/ui/feature/dashboard/DashboardHealthCardViewModelTest.kt index e1a2471df..07ad47a35 100644 --- a/app/src/test/java/com/github/warnastrophy/core/ui/feature/dashboard/DashboardHealthCardViewModelTest.kt +++ b/app/src/test/java/com/github/warnastrophy/core/ui/feature/dashboard/DashboardHealthCardViewModelTest.kt @@ -1,9 +1,9 @@ package com.github.warnastrophy.core.ui.feature.dashboard import android.content.Context -import com.github.warnastrophy.core.data.localStorage.HealthCardStorage -import com.github.warnastrophy.core.data.localStorage.StorageException -import com.github.warnastrophy.core.data.localStorage.StorageResult +import com.github.warnastrophy.core.data.local.HealthCardStorage +import com.github.warnastrophy.core.data.local.StorageException +import com.github.warnastrophy.core.data.local.StorageResult import com.github.warnastrophy.core.model.HealthCard import com.github.warnastrophy.core.ui.features.dashboard.DashboardHealthCardUiState import com.github.warnastrophy.core.ui.features.dashboard.DashboardHealthCardViewModel diff --git a/app/src/test/java/com/github/warnastrophy/core/ui/feature/health/HealthCardViewModelTest.kt b/app/src/test/java/com/github/warnastrophy/core/ui/feature/health/HealthCardViewModelTest.kt index e3e8f103b..89bfa6e6b 100644 --- a/app/src/test/java/com/github/warnastrophy/core/ui/feature/health/HealthCardViewModelTest.kt +++ b/app/src/test/java/com/github/warnastrophy/core/ui/feature/health/HealthCardViewModelTest.kt @@ -2,9 +2,9 @@ package com.github.warnastrophy.core.ui.dashboard import android.content.Context import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import com.github.warnastrophy.core.data.localStorage.HealthCardStorage -import com.github.warnastrophy.core.data.localStorage.StorageException -import com.github.warnastrophy.core.data.localStorage.StorageResult +import com.github.warnastrophy.core.data.local.HealthCardStorage +import com.github.warnastrophy.core.data.local.StorageException +import com.github.warnastrophy.core.data.local.StorageResult import com.github.warnastrophy.core.data.repository.HealthCardRepository import com.github.warnastrophy.core.model.HealthCard import com.github.warnastrophy.core.ui.features.health.HealthCardUiState diff --git a/app/src/test/java/com/github/warnastrophy/core/ui/onboard/AppStateManagerViewModelTest.kt b/app/src/test/java/com/github/warnastrophy/core/ui/onboard/AppStateManagerViewModelTest.kt index d291507d5..8aa13a4d7 100644 --- a/app/src/test/java/com/github/warnastrophy/core/ui/onboard/AppStateManagerViewModelTest.kt +++ b/app/src/test/java/com/github/warnastrophy/core/ui/onboard/AppStateManagerViewModelTest.kt @@ -1,6 +1,7 @@ package com.github.warnastrophy.core.ui.onboard import com.github.warnastrophy.core.data.repository.IntroductionRepository +import com.github.warnastrophy.core.ui.features.onboard.AppStateManagerViewModel import io.mockk.Runs import io.mockk.coEvery import io.mockk.coVerify diff --git a/app/src/test/java/com/github/warnastrophy/core/ui/profile/preferences/DangerModePreferencesViewModelTest.kt b/app/src/test/java/com/github/warnastrophy/core/ui/profile/preferences/DangerModePreferencesViewModelTest.kt index 5597ab502..42bdacb4b 100644 --- a/app/src/test/java/com/github/warnastrophy/core/ui/profile/preferences/DangerModePreferencesViewModelTest.kt +++ b/app/src/test/java/com/github/warnastrophy/core/ui/profile/preferences/DangerModePreferencesViewModelTest.kt @@ -3,9 +3,9 @@ package com.github.warnastrophy.core.ui.profile.preferences import android.app.Activity import android.content.Context import android.content.SharedPreferences +import com.github.warnastrophy.core.data.interfaces.UserPreferencesRepository import com.github.warnastrophy.core.data.repository.DangerModePreferences import com.github.warnastrophy.core.data.repository.UserPreferences -import com.github.warnastrophy.core.data.repository.UserPreferencesRepository import com.github.warnastrophy.core.data.service.MockPermissionManager import com.github.warnastrophy.core.permissions.PermissionResult import com.github.warnastrophy.core.ui.features.profile.preferences.DangerModePreferencesViewModel diff --git a/app/src/test/java/com/github/warnastrophy/core/ui/profile/preferences/ThemeViewModelTest.kt b/app/src/test/java/com/github/warnastrophy/core/ui/profile/preferences/ThemeViewModelTest.kt index de4b8041c..6dafb5da2 100644 --- a/app/src/test/java/com/github/warnastrophy/core/ui/profile/preferences/ThemeViewModelTest.kt +++ b/app/src/test/java/com/github/warnastrophy/core/ui/profile/preferences/ThemeViewModelTest.kt @@ -1,8 +1,8 @@ package com.github.warnastrophy.core.ui.profile.preferences +import com.github.warnastrophy.core.data.interfaces.UserPreferencesRepository import com.github.warnastrophy.core.data.repository.DangerModePreferences import com.github.warnastrophy.core.data.repository.UserPreferences -import com.github.warnastrophy.core.data.repository.UserPreferencesRepository import com.github.warnastrophy.core.ui.features.profile.ThemeViewModel import io.mockk.Runs import io.mockk.coEvery diff --git a/app/src/test/java/com/github/warnastrophy/core/util/CryptoUtilsTest.kt b/app/src/test/java/com/github/warnastrophy/core/util/CryptoUtilsTest.kt index 9e9c5ff54..dc67e23c2 100644 --- a/app/src/test/java/com/github/warnastrophy/core/util/CryptoUtilsTest.kt +++ b/app/src/test/java/com/github/warnastrophy/core/util/CryptoUtilsTest.kt @@ -1,6 +1,6 @@ package com.github.warnastrophy.core.util -import com.github.warnastrophy.core.data.localStorage.KeyStoreProvider +import com.github.warnastrophy.core.data.provider.KeyStoreProvider import java.util.Base64 import junit.framework.TestCase import org.junit.Assert From f3e7776836ef70bd85452e52ef687a99a87280c4 Mon Sep 17 00:00:00 2001 From: ssidimoh Date: Wed, 17 Dec 2025 11:14:19 +0100 Subject: [PATCH 07/15] fix: fix end2end tests --- .../java/com/github/warnastrophy/e2e/EndToEndM1Test.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/androidTest/java/com/github/warnastrophy/e2e/EndToEndM1Test.kt b/app/src/androidTest/java/com/github/warnastrophy/e2e/EndToEndM1Test.kt index f570750af..001db7058 100644 --- a/app/src/androidTest/java/com/github/warnastrophy/e2e/EndToEndM1Test.kt +++ b/app/src/androidTest/java/com/github/warnastrophy/e2e/EndToEndM1Test.kt @@ -34,7 +34,6 @@ class EndToEndM1Test : EndToEndUtils() { every { themeViewModel.isDarkMode } returns mockk(relaxed = true) ContactRepositoryProvider.initLocal(context) - UserPreferencesRepositoryProvider.initLocal(context.userPrefsDataStore) composeTestRule.runOnUiThread { StateManagerService.init(context) } contactRepository = ContactRepositoryProvider.repository From 41fa89ed2b82390f92eb8f39a4e6184e063f91f1 Mon Sep 17 00:00:00 2001 From: ssidimoh Date: Thu, 18 Dec 2025 00:05:58 +0100 Subject: [PATCH 08/15] refactor: display only hazards with article url in latest news --- .../core/ui/features/dashboard/LatestNewsCardTest.kt | 9 +++++++++ .../warnastrophy/core/permissions/AppPermissions.kt | 3 +++ .../core/ui/features/dashboard/LatestNewsCard.kt | 5 +++-- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/dashboard/LatestNewsCardTest.kt b/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/dashboard/LatestNewsCardTest.kt index 1bbf51372..c7f1869ba 100644 --- a/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/dashboard/LatestNewsCardTest.kt +++ b/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/dashboard/LatestNewsCardTest.kt @@ -216,4 +216,13 @@ class LatestNewsCardTest : BaseAndroidComposeTest() { Assert.assertEquals(expectedUrl, capturedUrl.get()) } + + @Test + fun article_with_no_url_link_not_displayed() { + hazardService.setHazards(listOf(no_url_hazard)) + composeTestRule.setContent { MaterialTheme { LatestNewsCard(hazardService) } } + composeTestRule + .onNodeWithTag(LatestNewsTestTags.LINK, useUnmergedTree = true) + .assertIsNotDisplayed() + } } diff --git a/app/src/main/java/com/github/warnastrophy/core/permissions/AppPermissions.kt b/app/src/main/java/com/github/warnastrophy/core/permissions/AppPermissions.kt index c77a335aa..849835597 100644 --- a/app/src/main/java/com/github/warnastrophy/core/permissions/AppPermissions.kt +++ b/app/src/main/java/com/github/warnastrophy/core/permissions/AppPermissions.kt @@ -83,6 +83,9 @@ sealed class AppPermissions(val key: String, vararg permissionsWithNulls: String Manifest.permission.ACCESS_FINE_LOCATION, ) + object MicrophonePermission : + AppPermissions(key = "microphone_permission", Manifest.permission.RECORD_AUDIO) + companion object { /** A reusable check for SDK versions, annotated to help the compiler with smart casting. */ @ChecksSdkIntAtLeast(parameter = 0) diff --git a/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/LatestNewsCard.kt b/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/LatestNewsCard.kt index d6e7f3cd6..981098a5d 100644 --- a/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/LatestNewsCard.kt +++ b/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/LatestNewsCard.kt @@ -89,9 +89,10 @@ fun LatestNewsCard( ) { val fetcherState by hazardsService.fetcherState.collectAsState() var currentIndex by remember { mutableIntStateOf(0) } - currentIndex = currentIndex.coerceIn(0, (fetcherState.hazards.size - 1).coerceAtLeast(0)) + val hazards = fetcherState.hazards.filter { hazard -> hazard.articleUrl != null } + currentIndex = currentIndex.coerceIn(0, (hazards.size - 1).coerceAtLeast(0)) - val currentHazard = fetcherState.hazards.getOrNull(currentIndex) ?: Hazard() + val currentHazard = hazards.getOrNull(currentIndex) ?: Hazard() val extendedColors = MaterialTheme.extendedColors Column( From 735f593571b1cfe72d2da44cde7b4ac71daad483 Mon Sep 17 00:00:00 2001 From: ssidimoh Date: Thu, 18 Dec 2025 00:53:03 +0100 Subject: [PATCH 09/15] refactor: add downward arrow for additional danger mode card options --- .../features/dashboard/DangerModeCardTest.kt | 1 + .../core/permissions/AppPermissions.kt | 12 +- .../ui/features/dashboard/DangerModeCard.kt | 87 ++++++++--- .../dashboard/DangerModeCardViewModel.kt | 140 +++++++++++------- .../ui/features/dashboard/LatestNewsCard.kt | 19 ++- 5 files changed, 170 insertions(+), 89 deletions(-) diff --git a/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCardTest.kt b/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCardTest.kt index 342897725..da6feb365 100644 --- a/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCardTest.kt +++ b/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCardTest.kt @@ -25,6 +25,7 @@ import com.github.warnastrophy.core.ui.map.MockPermissionManager import com.github.warnastrophy.core.util.AppConfig import com.github.warnastrophy.core.util.BaseAndroidComposeTest import com.github.warnastrophy.userPrefsDataStore +import kotlin.collections.isNotEmpty import kotlinx.coroutines.runBlocking import org.junit.Before import org.junit.Test diff --git a/app/src/main/java/com/github/warnastrophy/core/permissions/AppPermissions.kt b/app/src/main/java/com/github/warnastrophy/core/permissions/AppPermissions.kt index 849835597..d48308e8b 100644 --- a/app/src/main/java/com/github/warnastrophy/core/permissions/AppPermissions.kt +++ b/app/src/main/java/com/github/warnastrophy/core/permissions/AppPermissions.kt @@ -57,12 +57,12 @@ sealed class AppPermissions(val key: String, vararg permissionsWithNulls: String object ForegroundServiceLocation : AppPermissions( key = "foreground_service_location", - if (isAtLeastSdkVersion(34)) { - Manifest.permission.FOREGROUND_SERVICE_LOCATION - Manifest.permission.POST_NOTIFICATIONS - } else { - null - }) + // Add FOREGROUND_SERVICE_LOCATION on API 34+, and POST_NOTIFICATIONS on TIRAMISU+ as + // needed. + if (isAtLeastSdkVersion(34)) Manifest.permission.FOREGROUND_SERVICE_LOCATION else null, + if (isAtLeastSdkVersion(Build.VERSION_CODES.TIRAMISU)) + Manifest.permission.POST_NOTIFICATIONS + else null) /** Permissions required to send emergency messages */ object SendEmergencySms : diff --git a/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCard.kt b/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCard.kt index ff6904472..b8a135efb 100644 --- a/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCard.kt +++ b/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCard.kt @@ -2,6 +2,9 @@ package com.github.warnastrophy.core.ui.features.dashboard import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -11,7 +14,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -19,9 +21,12 @@ import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Switch @@ -48,6 +53,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.github.warnastrophy.R import com.github.warnastrophy.core.data.service.DangerLevel import com.github.warnastrophy.core.model.Activity +import com.github.warnastrophy.core.permissions.AppPermissions import com.github.warnastrophy.core.ui.components.ActivityFallback import com.github.warnastrophy.core.ui.components.StandardDashboardButton import com.github.warnastrophy.core.ui.components.StandardDashboardCard @@ -72,6 +78,7 @@ object DangerModeTestTags { const val CONFIRM_TOUCH_SWITCH = "dangerModeConfirmTouchSwitch" const val CONFIRM_VOICE_SWITCH = "dangerModeConfirmVoiceSwitch" const val AUTO_CALL_SWITCH = "dangerModeAutoActionsSwitch" + const val EXPAND_ARROW = "dangerModeExpandArrow" fun capabilityTag(capability: DangerModeCapability) = CAPABILITY_PREFIX + capability.label @@ -110,17 +117,26 @@ fun DangerModeCard( return } + // Track which permission set is being requested + var pendingPermission by remember { mutableStateOf(null) } + val launcher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestMultiplePermissions(), - onResult = { viewModel.onPermissionResult(activity = activity) }) + onResult = { + pendingPermission?.let { permissionType -> + viewModel.onPermissionResult(activity = activity, permissionType = permissionType) + } + pendingPermission = null + }) LaunchedEffect(Unit) { viewModel.effects.collect { effect -> when (effect) { - Effect.RequestLocationPermission -> { - viewModel.onPermissionsRequestStart() - launcher.launch(viewModel.alertModePermission.permissions) + is Effect.RequestPermissions -> { + pendingPermission = effect.permissionType + viewModel.onPermissionsRequestStart(effect.permissionType) + launcher.launch(effect.permissionType.permissions) } Effect.StartForegroundService -> startForegroundGpsService(activity) Effect.StopForegroundService -> stopForegroundGpsService(context) @@ -131,19 +147,27 @@ fun DangerModeCard( val isDangerModeEnabled by viewModel.isDangerModeEnabled.collectAsState(false) + // State for expanding/collapsing advanced options + var isExpanded by remember { mutableStateOf(false) } + StandardDashboardCard( modifier = modifier.fillMaxWidth().testTag(DangerModeTestTags.CARD), backgroundColor = MaterialTheme.colorScheme.error, borderColor = MaterialTheme.colorScheme.error, ) { - Column(modifier = Modifier.padding(16.dp)) { + // reduced vertical padding to make the card shorter on screen + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 0.dp)) { DangerModeHeader( isDangerModeEnabled = isDangerModeEnabled, - onCheckedChange = { - viewModel.handleToggle(it, viewModel.permissionUiState.value.alertModePermissionResult) - }) + onCheckedChange = { viewModel.handleToggle(it, activity) }) + + DangerModeBody( + viewModel = viewModel, + onManageActivitiesClick = onManageActivitiesClick, + isExpanded = isExpanded) - DangerModeBody(viewModel, onManageActivitiesClick) + // Expand/Collapse arrow at the bottom + ExpandArrow(isExpanded = isExpanded, onToggle = { isExpanded = !isExpanded }) } } } @@ -159,7 +183,7 @@ fun DangerModeCard( @Composable private fun DangerModeHeader(isDangerModeEnabled: Boolean, onCheckedChange: (Boolean) -> Unit) { Row( - modifier = Modifier.fillMaxWidth().offset(y = (-10).dp), + modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { Text( @@ -187,11 +211,14 @@ private fun DangerModeHeader(isDangerModeEnabled: Boolean, onCheckedChange: (Boo * @param viewModel The ViewModel that provides state for the UI and handles user events. * @param onManageActivitiesClick A lambda function to be invoked when the user clicks the "Manage" * button, typically to navigate to a screen for managing activities. + * @param isExpanded A boolean state that controls whether the advanced options section is expanded + * or collapsed. */ @Composable private fun DangerModeBody( viewModel: DangerModeCardViewModel, - onManageActivitiesClick: () -> Unit + onManageActivitiesClick: () -> Unit, + isExpanded: Boolean ) { val currentActivity by viewModel.currentActivity.collectAsState(null) val activities by viewModel.activities.collectAsState() @@ -216,19 +243,33 @@ private fun DangerModeBody( CapabilitiesRow( capabilities = capabilities, onCapabilityToggled = { viewModel.onCapabilityToggled(it) }) - if (capabilities.contains(DangerModeCapability.CALL) || - capabilities.contains(DangerModeCapability.SMS)) { - Spacer(modifier = Modifier.height(12.dp)) - DangerModeAdvancedOptionsSection( - autoActionsEnabled = autoActionsEnabled, - confirmTouchRequired = confirmTouchRequired, - confirmVoiceRequired = confirmVoiceRequired, - onAutoActionsChanged = viewModel::onAutoActionsEnabled, - onConfirmTouchChanged = viewModel::onConfirmTouchChanged, - onConfirmVoiceChanged = viewModel::onConfirmVoiceChanged) + // Advanced section is shown when expanded + AnimatedVisibility(visible = isExpanded, enter = expandVertically(), exit = shrinkVertically()) { + Column { + // reduced spacer to shrink height around the expand arrow + DangerModeAdvancedOptionsSection( + autoActionsEnabled = autoActionsEnabled, + confirmTouchRequired = confirmTouchRequired, + confirmVoiceRequired = confirmVoiceRequired, + onAutoActionsChanged = viewModel::onAutoActionsEnabled, + onConfirmTouchChanged = viewModel::onConfirmTouchChanged, + onConfirmVoiceChanged = viewModel::onConfirmVoiceChanged) + } } +} - Spacer(modifier = Modifier.height(4.dp)) +/** A small arrow at the bottom of the card to expand/collapse advanced options. */ +@Composable +private fun ExpandArrow(isExpanded: Boolean, onToggle: () -> Unit, modifier: Modifier = Modifier) { + Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + IconButton(onClick = onToggle, modifier = Modifier.testTag(DangerModeTestTags.EXPAND_ARROW)) { + Icon( + imageVector = + if (isExpanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown, + contentDescription = if (isExpanded) "Collapse" else "Expand", + tint = MaterialTheme.colorScheme.onError) + } + } } /** diff --git a/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCardViewModel.kt b/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCardViewModel.kt index 760d84f5b..23edb50a1 100644 --- a/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCardViewModel.kt +++ b/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCardViewModel.kt @@ -1,5 +1,6 @@ package com.github.warnastrophy.core.ui.features.dashboard +import android.app.Activity import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -7,7 +8,7 @@ import com.github.warnastrophy.core.data.interfaces.ActivityRepository import com.github.warnastrophy.core.data.service.DangerLevel import com.github.warnastrophy.core.data.service.StateManagerService import com.github.warnastrophy.core.data.service.StateManagerService.permissionManager -import com.github.warnastrophy.core.model.Activity +import com.github.warnastrophy.core.model.Activity as DangerActivity import com.github.warnastrophy.core.permissions.AppPermissions import com.github.warnastrophy.core.permissions.PermissionResult import com.github.warnastrophy.core.util.AppConfig @@ -47,16 +48,10 @@ data class AlertModeUiState( val waitingForUserResponse: Boolean = false ) -/** - * Represents one-time, non-repeatable side effects that must be executed by the UI layer. They are - * typically used for: - * 1. Navigation. - * 2. Displaying Toast messages or Dialogs. - * 3. Starting/Stopping services. - * 4. Requesting system permissions. - */ +/** Represents one-time, non-repeatable side effects that must be executed by the UI layer. */ sealed interface Effect { - object RequestLocationPermission : Effect + /** Request a specific set of permissions. */ + data class RequestPermissions(val permissionType: AppPermissions) : Effect object StartForegroundService : Effect @@ -81,8 +76,8 @@ class DangerModeCardViewModel( val alertModePermission = AppPermissions.AlertModePermission private val dangerModeService = StateManagerService.dangerModeService - private val _activities = MutableStateFlow>(emptyList()) - val activities: StateFlow> = _activities.asStateFlow() + private val _activities = MutableStateFlow>(emptyList()) + val activities: StateFlow> = _activities.asStateFlow() private val _effects = MutableSharedFlow() val effects = _effects.asSharedFlow() @@ -93,6 +88,18 @@ class DangerModeCardViewModel( permissionManager.getPermissionResult(alertModePermission))) val permissionUiState = _alertModeUiState.asStateFlow() + // Sequence of permissions required to enable danger/alert mode. + private val permissionSequence = + listOf( + AppPermissions.AlertModePermission, // location + AppPermissions.MicrophonePermission, // microphone + AppPermissions.SendEmergencySms, // sms + AppPermissions.MakeEmergencyCall // call + ) + + // Tracks whether enabling was requested so we can continue chain after each grant. + private var enableRequested = false + init { refreshActivities() } @@ -132,16 +139,11 @@ class DangerModeCardViewModel( private val _autoActionsEnabled = MutableStateFlow(false) val autoActionsEnabled: StateFlow = _autoActionsEnabled.asStateFlow() - private val _confirmTouchRequired = - MutableStateFlow( - false) // This is tactile confirmation before the app takes actions like calling/emergency - // SMS + + private val _confirmTouchRequired = MutableStateFlow(false) val confirmTouchRequired: StateFlow = _confirmTouchRequired.asStateFlow() - private val _confirmVoiceRequired = - MutableStateFlow( - false) // This is audio confirmation before the app takes actions like calling/emergency - // SMS + private val _confirmVoiceRequired = MutableStateFlow(false) val confirmVoiceRequired: StateFlow = _confirmVoiceRequired.asStateFlow() /** @@ -165,7 +167,7 @@ class DangerModeCardViewModel( * * @param activity The selected Activity. */ - fun onActivitySelected(activity: Activity?) { + fun onActivitySelected(activity: DangerActivity?) { dangerModeService.setActivity(activity) } @@ -176,7 +178,6 @@ class DangerModeCardViewModel( */ fun onCapabilitiesChanged(newCapabilities: Set) { if (dangerModeService.setCapabilities(newCapabilities).isFailure) { - // TODO Log.e("DangerModeCardViewModel", "Failed to set capabilities: $newCapabilities") } } @@ -194,7 +195,6 @@ class DangerModeCardViewModel( } else { current + capability } - onCapabilitiesChanged(future) } @@ -209,17 +209,14 @@ class DangerModeCardViewModel( fun onConfirmTouchChanged(enabled: Boolean) { _confirmTouchRequired.value = enabled - // TODO: Persist & enforce tactile confirmation before actions. } fun onAutoActionsEnabled(enabled: Boolean) { _autoActionsEnabled.value = enabled - // TODO: Persist & enforce tactile confirmation before actions. } fun onConfirmVoiceChanged(enabled: Boolean) { _confirmVoiceRequired.value = enabled - // TODO: Persist & enforce voice confirmation before actions. } /** @@ -237,46 +234,85 @@ class DangerModeCardViewModel( viewModelScope.launch(dispatcher) { _effects.emit(effect) } } - /** Records that a permission request has been initiated by update UIState. */ - fun onPermissionsRequestStart() { + /** Records that a permission request has been initiated. */ + fun onPermissionsRequestStart(permissionType: AppPermissions? = null) { _alertModeUiState.update { it.copy(waitingForUserResponse = true) } } /** - * Updates the permission results in the UI state after the user has responded to a system - * permission dialog. - * - * @param activity The current `Activity`, required to check the latest permission statuses. + * Backward-compatible single-parameter permission result handler. Delegates to the full handler + * using the AlertModePermission. + */ + fun onPermissionResult(activity: Activity) { + onPermissionResult(activity, alertModePermission) + } + + /** + * Permission result handler which receives the Activity and the specific permission set. + * Continues the permission chain if allowed, or cancels enabling if denied. */ - fun onPermissionResult(activity: android.app.Activity) { - val newAlertModeResult = permissionManager.getPermissionResult(alertModePermission, activity) - _alertModeUiState.update { it.copy(alertModePermissionResult = newAlertModeResult) } + fun onPermissionResult(activity: Activity, permissionType: AppPermissions) { + val newResult = permissionManager.getPermissionResult(permissionType, activity) + + // Update UI state if this was the alert mode permission + if (permissionType == alertModePermission) { + _alertModeUiState.update { it.copy(alertModePermissionResult = newResult) } + } + if (_alertModeUiState.value.waitingForUserResponse) { - permissionManager.markPermissionsAsAsked(alertModePermission) - if (newAlertModeResult is PermissionResult.Granted) { - onDangerModeToggled(true) - } + permissionManager.markPermissionsAsAsked(permissionType) } + _alertModeUiState.update { it.copy(waitingForUserResponse = false) } + + // If granted and we were enabling, continue to next required permission + if (newResult is PermissionResult.Granted && enableRequested) { + requestNextRequiredPermission(activity) + } else if (newResult !is PermissionResult.Granted) { + // If any permission denied, cancel the enable flow + enableRequested = false + } + } + + /** Starts the toggle flow. The Activity is required to query current permission statuses. */ + fun handleToggle(isChecked: Boolean, activity: Activity) { + if (!isChecked) { + onDangerModeToggled(false) + return + } + // Start the enable flow + enableRequested = true + requestNextRequiredPermission(activity) } /** - * Handles the logic when user toggle the button, checking permissions and dispatching actions. - * - * @param isChecked The new state of the preference toggle. - * @param permissionResult The current permission status for this feature. + * Scan the permissionSequence and either enable the mode (if none missing) or emit an effect to + * request the next missing permission (or open app settings if permanently denied). */ - fun handleToggle(isChecked: Boolean, permissionResult: PermissionResult) { - if (isChecked) { - when (permissionResult) { - PermissionResult.Granted -> { - onDangerModeToggled(true) + private fun requestNextRequiredPermission(activity: Activity) { + // Find the first permission that is not granted + val missing = + permissionSequence.firstNotNullOfOrNull { permissionType -> + val result = permissionManager.getPermissionResult(permissionType, activity) + if (result is PermissionResult.Granted) null else permissionType to result } - is PermissionResult.Denied -> emitEffect(Effect.RequestLocationPermission) - is PermissionResult.PermanentlyDenied -> emitEffect(Effect.ShowOpenAppSettings) + + if (missing == null) { + // All required permissions are granted -> enable + enableRequested = false + onDangerModeToggled(true) + return + } + + val (permissionType, result) = missing + when (result) { + is PermissionResult.PermanentlyDenied -> { + enableRequested = false + emitEffect(Effect.ShowOpenAppSettings) + } + else -> { + emitEffect(Effect.RequestPermissions(permissionType)) } - } else { - onDangerModeToggled(false) } } } diff --git a/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/LatestNewsCard.kt b/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/LatestNewsCard.kt index 981098a5d..07868f6fd 100644 --- a/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/LatestNewsCard.kt +++ b/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/LatestNewsCard.kt @@ -111,7 +111,8 @@ fun LatestNewsCard( currentIndex = currentIndex, onIndexChange = { newIndex -> currentIndex = newIndex }, extendedColors = extendedColors, - openWebPage = openWebPage) + openWebPage = openWebPage, + displayHazardCount = hazards.size) } } @@ -147,7 +148,8 @@ private fun CardBody( currentIndex: Int, onIndexChange: (Int) -> Unit, extendedColors: ExtendedColorScheme, - openWebPage: (context: Context, url: String?) -> Unit + openWebPage: (context: Context, url: String?) -> Unit, + displayHazardCount: Int ) { Column( modifier = @@ -158,15 +160,16 @@ private fun CardBody( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { - val hasHazards = fetcherState.hazards.isNotEmpty() - val hazardCount = fetcherState.hazards.size + val hasDisplayHazards = displayHazardCount > 0 - if (hasHazards) { + if (hasDisplayHazards) { NavigationButton( testTag = LatestNewsTestTags.LEFT_BUTTON, content = "<", extendedColors = extendedColors, - onClick = { onIndexChange((currentIndex - 1 + hazardCount) % hazardCount) }) + onClick = { + onIndexChange((currentIndex - 1 + displayHazardCount) % displayHazardCount) + }) } HazardInfo( @@ -177,12 +180,12 @@ private fun CardBody( HazardImage(currentHazard.type) - if (hasHazards) { + if (hasDisplayHazards) { NavigationButton( testTag = LatestNewsTestTags.RIGHT_BUTTON, content = ">", extendedColors = extendedColors, - onClick = { onIndexChange((currentIndex + 1) % hazardCount) }) + onClick = { onIndexChange((currentIndex + 1) % displayHazardCount) }) } else { Spacer(modifier = Modifier.width(2.dp)) } From 336422019610747acc41f0791929a67780b49081 Mon Sep 17 00:00:00 2001 From: ssidimoh Date: Thu, 18 Dec 2025 01:00:08 +0100 Subject: [PATCH 10/15] refactor: adapt test with changes in danger card --- .../features/dashboard/DangerModeCardTest.kt | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCardTest.kt b/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCardTest.kt index da6feb365..e8c74970f 100644 --- a/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCardTest.kt +++ b/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCardTest.kt @@ -35,13 +35,16 @@ class DangerModeCardTest : BaseAndroidComposeTest() { @Before fun setup() { - UserPreferencesRepositoryProvider.initLocal( - composeTestRule.activity.applicationContext.userPrefsDataStore) - StateManagerService.init(composeTestRule.activity.applicationContext) - StateManagerService.permissionManager = - MockPermissionManager(currentResult = PermissionResult.Granted) - StateManagerService.dangerModeService = - DangerModeService(permissionManager = StateManagerService.permissionManager) + val appContext = composeTestRule.activity.applicationContext + val instrumentation = InstrumentationRegistry.getInstrumentation() + UserPreferencesRepositoryProvider.initLocal(appContext.userPrefsDataStore) + instrumentation.runOnMainSync { StateManagerService.init(appContext) } + instrumentation.runOnMainSync { + StateManagerService.permissionManager = + MockPermissionManager(currentResult = PermissionResult.Granted) + StateManagerService.dangerModeService = + DangerModeService(permissionManager = StateManagerService.permissionManager) + } // Initialize the ActivityRepositoryProvider with mock for testing mockActivityRepository = MockActivityRepository() ActivityRepositoryProvider.useMock() @@ -242,13 +245,14 @@ class DangerModeCardTest : BaseAndroidComposeTest() { .onNodeWithTag(DangerModeTestTags.ADVANCED_SECTION, useUnmergedTree = true) .assertDoesNotExist() - // Enable CALL capability val callCapabilityNode = composeTestRule.onNodeWithTag( DangerModeTestTags.capabilityTag(DangerModeCapability.CALL), useUnmergedTree = true) callCapabilityNode.performClick() + composeTestRule + .onNodeWithTag(DangerModeTestTags.EXPAND_ARROW, useUnmergedTree = true) + .performClick() - // Advanced section should now be visible composeTestRule .onNodeWithTag(DangerModeTestTags.ADVANCED_SECTION, useUnmergedTree = true) .assertIsDisplayed() @@ -281,6 +285,9 @@ class DangerModeCardTest : BaseAndroidComposeTest() { composeTestRule.onNodeWithTag( DangerModeTestTags.capabilityTag(DangerModeCapability.CALL), useUnmergedTree = true) callCapabilityNode.performClick() + composeTestRule + .onNodeWithTag(DangerModeTestTags.EXPAND_ARROW, useUnmergedTree = true) + .performClick() val autoActionsSwitch = composeTestRule.onNodeWithTag(DangerModeTestTags.AUTO_CALL_SWITCH, useUnmergedTree = true) From 1cb2b29eadf328994c8b94368870549bd1268da1 Mon Sep 17 00:00:00 2001 From: ssidimoh Date: Thu, 18 Dec 2025 01:09:52 +0100 Subject: [PATCH 11/15] fix: fix search bar bug where can't clear message --- .../core/ui/features/map/MapScreenTest.kt | 25 ++++ .../warnastrophy/core/ui/features/map/Map.kt | 2 + .../core/ui/features/map/SearchBar.kt | 117 +++++++++++++++--- app/src/main/res/values/strings.xml | 5 +- core/ui/features/map/MapScreenTestTags.kt | 7 ++ 5 files changed, 138 insertions(+), 18 deletions(-) create mode 100644 core/ui/features/map/MapScreenTestTags.kt diff --git a/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/map/MapScreenTest.kt b/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/map/MapScreenTest.kt index cbf4f3884..d2b4940a7 100644 --- a/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/map/MapScreenTest.kt +++ b/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/map/MapScreenTest.kt @@ -431,4 +431,29 @@ class MapScreenTest : BaseAndroidComposeTest() { // We don’t have a direct tag, but rendering without crash covers all lines composeTestRule.waitForIdle() } + + @Test + fun search_bar_cleared_when_clear_button_clicked() { + setContent() + applyPerm(PermissionResult.Granted) + waitForMapReadyAndAssertVisibility() + + val testInput = "Test Location" + val textField = composeTestRule.onNodeWithTag(MapScreenTestTags.SEARCH_BAR_TEXT_FIELD) + + textField.assertIsDisplayed() + textField.performClick() + textField.performTextInput(testInput) + textField.assert(hasText(testInput)) + + val clearBtn = composeTestRule.onNodeWithTag(MapScreenTestTags.SEARCH_BAR_CLEAR_BUTTON) + clearBtn.assertIsDisplayed() + clearBtn.performClick() + composeTestRule.waitForIdle() + + // Vérifications après clear + textField.assert(hasText("")) + composeTestRule.onNodeWithTag(MapScreenTestTags.SEARCH_BAR_CLEAR_BUTTON).assertIsNotDisplayed() + composeTestRule.onNodeWithTag(MapScreenTestTags.SEARCH_BAR_DROPDOWN).assertIsNotDisplayed() + } } diff --git a/app/src/main/java/com/github/warnastrophy/core/ui/features/map/Map.kt b/app/src/main/java/com/github/warnastrophy/core/ui/features/map/Map.kt index d398ac5d6..60db0b3f6 100644 --- a/app/src/main/java/com/github/warnastrophy/core/ui/features/map/Map.kt +++ b/app/src/main/java/com/github/warnastrophy/core/ui/features/map/Map.kt @@ -61,6 +61,8 @@ object MapScreenTestTags { const val SEARCH_BAR_DROPDOWN = "searchBarDropdown" const val SEARCH_BAR_DROPDOWN_ITEM = "searchBarDropdownItem" + + const val SEARCH_BAR_CLEAR_BUTTON = "searchBarClearButton" } @Composable diff --git a/app/src/main/java/com/github/warnastrophy/core/ui/features/map/SearchBar.kt b/app/src/main/java/com/github/warnastrophy/core/ui/features/map/SearchBar.kt index d9e9db53a..93eb76ec4 100644 --- a/app/src/main/java/com/github/warnastrophy/core/ui/features/map/SearchBar.kt +++ b/app/src/main/java/com/github/warnastrophy/core/ui/features/map/SearchBar.kt @@ -1,21 +1,29 @@ package com.github.warnastrophy.core.ui.features.map import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Clear import androidx.compose.material.icons.outlined.Search import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -30,10 +38,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import androidx.compose.ui.window.PopupProperties import com.github.warnastrophy.R @@ -43,10 +52,23 @@ import com.github.warnastrophy.core.ui.features.map.Colors.horizontalDividerColo import com.google.maps.android.compose.CameraPositionState import kotlinx.coroutines.launch +/** Color constants used in the map search bar UI components. */ object Colors { + /** Color for horizontal dividers between dropdown items. */ val horizontalDividerColor: Color = Color(0xFF3B3B3B) } +/** + * A search bar component for searching locations on the map. + * + * This composable provides a text input field with autocomplete suggestions from Nominatim. When a + * suggestion is selected, the map camera animates to that location. + * + * @param modifier Modifier to be applied to the search bar container. + * @param viewModel The [MapViewModel] that handles search logic and state. + * @param cameraPositionState The camera state used to animate the map to selected locations. + * @param focusManager The focus manager used to clear focus when a selection is made. + */ @Composable fun SearchBar( modifier: Modifier = Modifier, @@ -56,16 +78,20 @@ fun SearchBar( ) { val coroutineScope = rememberCoroutineScope() val focusRequester = remember { FocusRequester() } + val interactionSource = remember { MutableInteractionSource() } var text by remember { mutableStateOf("") } var expanded by remember { mutableStateOf(false) } - var isTextFocused by remember { mutableStateOf(false) } + + // Track focus state from interaction source for better device compatibility + val isFocused by interactionSource.collectIsFocusedAsState() val uiState by viewModel.uiState.collectAsState() val suggestions = uiState.nominatimState - LaunchedEffect(suggestions, text, isTextFocused) { - expanded = isTextFocused && text.isNotEmpty() && suggestions.isNotEmpty() + // Update dropdown visibility based on focus, text content and available suggestions + LaunchedEffect(suggestions, text, isFocused) { + expanded = isFocused && text.isNotEmpty() && suggestions.isNotEmpty() } Box(modifier = modifier.fillMaxWidth(0.75f).testTag(MapScreenTestTags.SEARCH_BAR)) { @@ -77,9 +103,14 @@ fun SearchBar( viewModel.searchLocations(newText) }, focusRequester = focusRequester, - onFocusChanged = { focused -> - isTextFocused = focused - if (!focused) expanded = false + interactionSource = interactionSource, + onClearClick = { + text = "" + viewModel.searchLocations("") + }, + onSearchAction = { + expanded = false + focusManager.clearFocus() }, modifier = Modifier.fillMaxWidth()) @@ -103,18 +134,40 @@ fun SearchBar( } } +/** + * Internal text field component for the search bar. + * + * Displays a search icon, text input field with placeholder, and a clear button when text is + * present. The entire row is clickable to request focus, which improves compatibility on physical + * Android devices. + * + * @param text Current text value of the search field. + * @param onTextChange Callback invoked when the text changes. + * @param focusRequester Focus requester used to programmatically request focus. + * @param interactionSource Interaction source to track focus state reliably. + * @param onClearClick Callback invoked when the clear button is clicked. + * @param onSearchAction Callback invoked when the search IME action is triggered. + * @param modifier Modifier to be applied to the text field container. + */ @Composable private fun SearchTextField( text: String, onTextChange: (String) -> Unit, focusRequester: FocusRequester, - onFocusChanged: (Boolean) -> Unit, + interactionSource: MutableInteractionSource, + onClearClick: () -> Unit, + onSearchAction: () -> Unit, modifier: Modifier = Modifier ) { Row( modifier = modifier .background(Color.White, RoundedCornerShape(16.dp)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, indication = null) { + // Request focus when the row is tapped to re-enable editing + focusRequester.requestFocus() + } .padding(horizontal = 8.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) { Icon( @@ -129,23 +182,55 @@ private fun SearchTextField( value = text, onValueChange = onTextChange, modifier = - Modifier.fillMaxWidth() + Modifier.weight(1f) .focusRequester(focusRequester) - .onFocusChanged { state -> onFocusChanged(state.isFocused) } .testTag(MapScreenTestTags.SEARCH_BAR_TEXT_FIELD), singleLine = true, textStyle = LocalTextStyle.current.copy(color = Color.Black), + cursorBrush = SolidColor(Color.Black), + interactionSource = interactionSource, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { onSearchAction() }), decorationBox = { innerTextField -> - if (text.isEmpty()) { - Text( - stringResource(R.string.search_bar_search), - color = Color.Black.copy(alpha = 0.6f)) + Box(contentAlignment = Alignment.CenterStart) { + if (text.isEmpty()) { + Text( + stringResource(R.string.search_bar_search), + color = Color.Black.copy(alpha = 0.6f)) + } + innerTextField() } - innerTextField() }) + + // Clear button shown only when there is text + if (text.isNotEmpty()) { + IconButton( + onClick = { + onClearClick() + focusRequester.requestFocus() + }, + modifier = Modifier.size(24.dp).testTag(MapScreenTestTags.SEARCH_BAR_CLEAR_BUTTON)) { + Icon( + imageVector = Icons.Outlined.Clear, + contentDescription = stringResource(R.string.search_bar_clear), + tint = Color.Black.copy(alpha = 0.6f)) + } + } } } +/** + * Dropdown menu displaying location suggestions. + * + * Shows a list of [Location] items returned from the search. Each item can be selected to navigate + * the map to that location. + * + * @param expanded Whether the dropdown is currently visible. + * @param suggestions List of location suggestions to display. + * @param onDismiss Callback invoked when the dropdown should be dismissed. + * @param onSelect Callback invoked when a location is selected. + * @param modifier Modifier to be applied to the dropdown menu. + */ @Composable private fun SuggestionsDropdown( expanded: Boolean, @@ -170,7 +255,7 @@ private fun SuggestionsDropdown( modifier = Modifier.testTag(MapScreenTestTags.SEARCH_BAR_DROPDOWN_ITEM), text = { Text(name, maxLines = 2) }, onClick = { onSelect(item) }) - if (index < suggestions.size - 1) { + if (index < suggestions.lastIndex) { HorizontalDivider(thickness = 1.dp, color = horizontalDividerColor) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 213b4e3e3..d296a8559 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -63,8 +63,9 @@ Danger mode is enabled - Search - Search Icon + Search location… + Search icon + Clear search Danger Mode Preferences diff --git a/core/ui/features/map/MapScreenTestTags.kt b/core/ui/features/map/MapScreenTestTags.kt new file mode 100644 index 000000000..0bd268a22 --- /dev/null +++ b/core/ui/features/map/MapScreenTestTags.kt @@ -0,0 +1,7 @@ +...existing code... +object MapScreenTestTags { + ...existing code... + const val SEARCH_BAR_CLEAR_BUTTON = "search_bar_clear_button" + ...existing code... +} + From d9808beb2ed32de647be7e7e7a52d1c7cc8aa60e Mon Sep 17 00:00:00 2001 From: ssidimoh Date: Thu, 18 Dec 2025 01:38:43 +0100 Subject: [PATCH 12/15] fix: fix user id resolution in orchestrator to fetch contacts --- .../core/data/service/DangerModeOrchestrator.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 3a798bcb6..814c05826 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 @@ -11,6 +11,8 @@ import com.github.warnastrophy.core.model.Location import com.github.warnastrophy.core.ui.common.ErrorHandler import com.github.warnastrophy.core.ui.common.ErrorType import com.github.warnastrophy.core.ui.navigation.Screen +import com.github.warnastrophy.core.util.AppConfig +import com.google.firebase.auth.FirebaseAuth import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -166,8 +168,8 @@ class DangerModeOrchestrator( return } } - - val contacts = contactsRepo?.getAllContacts()?.getOrNull() + val uid: String = FirebaseAuth.getInstance().currentUser?.uid ?: AppConfig.defaultUserId + val contacts = contactsRepo?.getAllContacts(uid)?.getOrNull() if (!contacts.isNullOrEmpty()) { emergencyPhoneNumber = contacts.first().phoneNumber } else { From 0de1a8ba44b6581d639320b79e78cea5ff584bae Mon Sep 17 00:00:00 2001 From: ssidimoh Date: Thu, 18 Dec 2025 03:06:13 +0100 Subject: [PATCH 13/15] merge: merge main --- .../features/dashboard/DangerModeCardTest.kt | 131 ++++++--- .../UserPreferencesRepositoryLocal.kt | 2 - .../data/service/DangerModeOrchestrator.kt | 20 +- .../core/data/service/DangerModeService.kt | 39 +-- .../ui/components/StandardDashboardButton.kt | 10 +- .../ui/features/dashboard/DangerModeCard.kt | 25 +- .../dashboard/DangerModeCardViewModel.kt | 254 ++++++++++-------- .../DangerModePreferencesViewModel.kt | 15 ++ .../data/service/DangerModeServiceTest.kt | 25 ++ .../dashboard/DangerModeCardViewModelTest.kt | 66 +++-- 10 files changed, 382 insertions(+), 205 deletions(-) diff --git a/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCardTest.kt b/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCardTest.kt index e8c74970f..c9572cece 100644 --- a/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCardTest.kt +++ b/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCardTest.kt @@ -7,7 +7,6 @@ import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsOff import androidx.compose.ui.test.assertIsOn -import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onNodeWithTag @@ -48,6 +47,28 @@ class DangerModeCardTest : BaseAndroidComposeTest() { // Initialize the ActivityRepositoryProvider with mock for testing mockActivityRepository = MockActivityRepository() ActivityRepositoryProvider.useMock() + val context = composeTestRule.activity.applicationContext + UserPreferencesRepositoryProvider.initLocal(context.userPrefsDataStore) + // Initialize the ActivityRepositoryProvider with mock for testing + mockActivityRepository = MockActivityRepository() + ActivityRepositoryProvider.useMock() + // Make sure user preferences are reset to defaults so tests are deterministic + runBlocking { + val repo = UserPreferencesRepositoryProvider.repository + repo.setAutoActionsEnabled(false) + repo.setAutomaticCalls(false) + repo.setAutomaticSms(false) + repo.setVoiceConfirmationEnabled(false) + repo.setTouchConfirmationRequired(false) + repo.setInactivityDetection(false) + repo.setAlertMode(false) + } + // Initialize services after prefs have been reset + InstrumentationRegistry.getInstrumentation().runOnMainSync { StateManagerService.init(context) } + StateManagerService.permissionManager = + MockPermissionManager(currentResult = PermissionResult.Granted) + StateManagerService.dangerModeService = + DangerModeService(permissionManager = StateManagerService.permissionManager) } private fun createTestViewModel(repository: MockActivityRepository = mockActivityRepository) = @@ -234,11 +255,12 @@ class DangerModeCardTest : BaseAndroidComposeTest() { @Test fun dangerModeCard_advancedSection_shownWhenCapabilitySelected() { - lateinit var viewModel: DangerModeCardViewModel - composeTestRule.setContent { - viewModel = testViewModel - MaterialTheme { DangerModeCard(viewModel = viewModel) } - } + val viewModel = createTestViewModel() + composeTestRule.setContent { MaterialTheme { DangerModeCard(viewModel = viewModel) } } + + // Ensure the ViewModel starts with no capabilities enabled to make the test deterministic + viewModel.onCapabilitiesChanged(emptySet()) + assert(viewModel.capabilitiesInternal.value.isEmpty()) // Advanced section should not be visible initially composeTestRule @@ -253,27 +275,67 @@ class DangerModeCardTest : BaseAndroidComposeTest() { .onNodeWithTag(DangerModeTestTags.EXPAND_ARROW, useUnmergedTree = true) .performClick() - composeTestRule - .onNodeWithTag(DangerModeTestTags.ADVANCED_SECTION, useUnmergedTree = true) - .assertIsDisplayed() + // Enable CALL capability deterministically via ViewModel + viewModel.onCapabilityToggled(DangerModeCapability.CALL) - // Auto actions switch should exist and be off by default + // Wait for capabilities to be applied and advanced section to appear + composeTestRule.waitUntilWithTimeout { + viewModel.capabilitiesInternal.value.contains(DangerModeCapability.CALL) + } composeTestRule - .onNodeWithTag(DangerModeTestTags.AUTO_CALL_SWITCH, useUnmergedTree = true) - .assertIsOff() + .onNodeWithTag(DangerModeTestTags.ADVANCED_SECTION, useUnmergedTree = true) + .assertExists() - // Confirmation switches should exist and be off by default - composeTestRule - .onNodeWithTag(DangerModeTestTags.CONFIRM_TOUCH_SWITCH, useUnmergedTree = true) - .assertIsOff() - composeTestRule - .onNodeWithTag(DangerModeTestTags.CONFIRM_VOICE_SWITCH, useUnmergedTree = true) - .assertIsOff() + // Wait for autoActions to be enabled by the ViewModel's logic and then assert confirmations are + // false + composeTestRule.waitUntilWithTimeout { viewModel.autoActionsEnabled.value } + assert(viewModel.autoActionsEnabled.value) + assert(!viewModel.confirmTouchRequired.value) + assert(!viewModel.confirmVoiceRequired.value) } /* Advanced switches update their respective state in the ViewModel independently */ @Test fun dangerModeCard_advancedSwitches_updateViewModelState_independently() { + val viewModel = createTestViewModel() + composeTestRule.setContent { MaterialTheme { DangerModeCard(viewModel = viewModel) } } + + // Enable CALL capability to show advanced section deterministically via ViewModel + viewModel.onCapabilityToggled(DangerModeCapability.CALL) + + // Wait until autoActions is enabled by the ViewModel + composeTestRule.waitUntilWithTimeout { viewModel.autoActionsEnabled.value } + assert(viewModel.autoActionsEnabled.value) + assert(!viewModel.confirmTouchRequired.value) + assert(!viewModel.confirmVoiceRequired.value) + + // Toggle auto actions off and verify state updates correctly + viewModel.onAutoActionsEnabled(false) + composeTestRule.waitUntilWithTimeout { !viewModel.autoActionsEnabled.value } + assert(!viewModel.autoActionsEnabled.value) + } + + /** Verify that toggling one capability disables the other (mutually exclusive) in the UI */ + @Test + fun dangerModeCard_capabilities_are_mutually_exclusive_ui() { + lateinit var viewModel: DangerModeCardViewModel + composeTestRule.setContent { + viewModel = testViewModel + MaterialTheme { DangerModeCard(viewModel = viewModel) } + } + + // Toggle CALL capability via the ViewModel (optimistic update is synchronous) + testViewModel.onCapabilityToggled(DangerModeCapability.CALL) + assert(testViewModel.capabilitiesInternal.value.contains(DangerModeCapability.CALL)) + + // Toggle SMS capability via the ViewModel - should replace CALL (mutually exclusive) + testViewModel.onCapabilityToggled(DangerModeCapability.SMS) + assert(testViewModel.capabilitiesInternal.value.contains(DangerModeCapability.SMS)) + assert(!testViewModel.capabilitiesInternal.value.contains(DangerModeCapability.CALL)) + } + + @Test + fun dangerModeCard_confirmSwitches_are_mutually_exclusive_ui() { lateinit var viewModel: DangerModeCardViewModel composeTestRule.setContent { viewModel = testViewModel @@ -281,16 +343,15 @@ class DangerModeCardTest : BaseAndroidComposeTest() { } // Enable CALL capability to show advanced section - val callCapabilityNode = + val callNode = composeTestRule.onNodeWithTag( DangerModeTestTags.capabilityTag(DangerModeCapability.CALL), useUnmergedTree = true) callCapabilityNode.performClick() composeTestRule .onNodeWithTag(DangerModeTestTags.EXPAND_ARROW, useUnmergedTree = true) .performClick() + callNode.performClick() - val autoActionsSwitch = - composeTestRule.onNodeWithTag(DangerModeTestTags.AUTO_CALL_SWITCH, useUnmergedTree = true) val confirmTouchSwitch = composeTestRule.onNodeWithTag( DangerModeTestTags.CONFIRM_TOUCH_SWITCH, useUnmergedTree = true) @@ -298,30 +359,14 @@ class DangerModeCardTest : BaseAndroidComposeTest() { composeTestRule.onNodeWithTag( DangerModeTestTags.CONFIRM_VOICE_SWITCH, useUnmergedTree = true) - // Initial state: all off - autoActionsSwitch.assertIsOff() + // Toggle voice -> voice on, touch off + confirmVoiceSwitch.performClick() + confirmVoiceSwitch.assertIsOn() confirmTouchSwitch.assertIsOff() - confirmVoiceSwitch.assertIsOff() - assert(!viewModel.autoActionsEnabled.value) - assert(!viewModel.confirmTouchRequired.value) - assert(!viewModel.confirmVoiceRequired.value) - - // Toggle auto actions - autoActionsSwitch.performClick() - autoActionsSwitch.assertIsOn() - assert(viewModel.autoActionsEnabled.value) - // Confirm others are still independent - assert(!viewModel.confirmTouchRequired.value) - assert(!viewModel.confirmVoiceRequired.value) - // Toggle touch confirmation + // Toggle touch -> touch on, voice off confirmTouchSwitch.performClick() confirmTouchSwitch.assertIsOn() - assert(viewModel.confirmTouchRequired.value) - - // Toggle voice confirmation - confirmVoiceSwitch.performClick() - confirmVoiceSwitch.assertIsOn() - assert(viewModel.confirmVoiceRequired.value) + confirmVoiceSwitch.assertIsOff() } } diff --git a/app/src/main/java/com/github/warnastrophy/core/data/repository/UserPreferencesRepositoryLocal.kt b/app/src/main/java/com/github/warnastrophy/core/data/repository/UserPreferencesRepositoryLocal.kt index 3f921763d..fac509d7c 100644 --- a/app/src/main/java/com/github/warnastrophy/core/data/repository/UserPreferencesRepositoryLocal.kt +++ b/app/src/main/java/com/github/warnastrophy/core/data/repository/UserPreferencesRepositoryLocal.kt @@ -72,8 +72,6 @@ class UserPreferencesRepositoryLocal(private val dataStore: DataStore = 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 814c05826..fbe3333f9 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 @@ -8,6 +8,7 @@ import com.github.warnastrophy.core.data.provider.ContactRepositoryProvider import com.github.warnastrophy.core.data.repository.DangerModePreferences import com.github.warnastrophy.core.domain.model.EmergencyMessage import com.github.warnastrophy.core.model.Location +import com.github.warnastrophy.core.permissions.AppPermissions import com.github.warnastrophy.core.ui.common.ErrorHandler import com.github.warnastrophy.core.ui.common.ErrorType import com.github.warnastrophy.core.ui.navigation.Screen @@ -123,6 +124,23 @@ class DangerModeOrchestrator( private var smsSenderInstance: SmsSender? = smsSender private var callSenderInstance: CallSender? = callSender + private val _permissionRequests = MutableStateFlow(null) + val permissionRequests: StateFlow = _permissionRequests.asStateFlow() + + init { + scope.launch { + dangerModeService.events.collectLatest { event -> + when (event) { + DangerModeService.DangerModeEvent.MissingSmsPermission -> + _permissionRequests.value = AppPermissions.SendEmergencySms + DangerModeService.DangerModeEvent.MissingCallPermission -> + _permissionRequests.value = AppPermissions.MakeEmergencyCall + null -> Unit + } + } + } + } + /** * Initializes the orchestrator with context-dependent services. Must be called before starting * monitoring if SMS/Call services weren't provided in constructor. @@ -224,7 +242,6 @@ class DangerModeOrchestrator( // Check if all conditions are met for triggering emergency actions val shouldTrigger = dangerState.isActive && - dangerState.activatingHazard != null && preferences.alertMode && preferences.inactivityDetection && movementState is MovementState.Danger @@ -395,6 +412,7 @@ class DangerModeOrchestrator( private fun resetState() { _state.value = OrchestratorState() _showVoiceConfirmationScreen.value = false + _permissionRequests.value = null } /** Gets the current emergency phone number. This could be fetched from contacts in the future. */ diff --git a/app/src/main/java/com/github/warnastrophy/core/data/service/DangerModeService.kt b/app/src/main/java/com/github/warnastrophy/core/data/service/DangerModeService.kt index caee57a68..88e0f35a5 100644 --- a/app/src/main/java/com/github/warnastrophy/core/data/service/DangerModeService.kt +++ b/app/src/main/java/com/github/warnastrophy/core/data/service/DangerModeService.kt @@ -71,6 +71,8 @@ class DangerModeService( sealed class DangerModeEvent { object MissingSmsPermission : DangerModeEvent() + + object MissingCallPermission : DangerModeEvent() } private val _state = MutableStateFlow(DangerModeState()) @@ -108,29 +110,30 @@ class DangerModeService( * @param capabilities The set of capabilities to enable. */ fun setCapabilities(capabilities: Set): Result { - val validated = mutableSetOf() - - for (cap in capabilities) { - - val requiredPermission = - when (cap) { - DangerModeCapability.SMS -> AppPermissions.SendEmergencySms - DangerModeCapability.CALL -> null - } + if (capabilities.size > 1) { + return Result.failure(IllegalStateException("Capabilities must be mutually exclusive")) + } - requiredPermission?.let { perm -> - val result = permissionManager.getPermissionResult(perm) - if (result != PermissionResult.Granted) { - return Result.failure( - IllegalStateException("Missing permission for ${cap.label}: ${perm.key}")) + val capability = capabilities.firstOrNull() + when (capability) { + DangerModeCapability.SMS -> { + if (permissionManager.getPermissionResult(AppPermissions.SendEmergencySms) != + PermissionResult.Granted) { + _events.value = DangerModeEvent.MissingSmsPermission + return Result.failure(IllegalStateException("Missing SMS permission")) } } - - validated.add(cap) + DangerModeCapability.CALL -> { + if (permissionManager.getPermissionResult(AppPermissions.MakeEmergencyCall) != + PermissionResult.Granted) { + _events.value = DangerModeEvent.MissingCallPermission + return Result.failure(IllegalStateException("Missing call permission")) + } + } + null -> Unit } - _state.value = _state.value.copy(capabilities = validated) - + _state.value = _state.value.copy(capabilities = capabilities) return Result.success(Unit) } diff --git a/app/src/main/java/com/github/warnastrophy/core/ui/components/StandardDashboardButton.kt b/app/src/main/java/com/github/warnastrophy/core/ui/components/StandardDashboardButton.kt index a5f5dc67c..d0d3d6b62 100644 --- a/app/src/main/java/com/github/warnastrophy/core/ui/components/StandardDashboardButton.kt +++ b/app/src/main/java/com/github/warnastrophy/core/ui/components/StandardDashboardButton.kt @@ -11,6 +11,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -36,13 +38,17 @@ fun StandardDashboardButton( onClick: () -> Unit = {}, textColor: Color = MaterialTheme.colorScheme.onSurface, borderColor: Color = MaterialTheme.colorScheme.surface, - icon: @Composable () -> Unit = {} + icon: @Composable () -> Unit = {}, + isSelected: Boolean? = null ) { + val effectiveModifier = + if (isSelected != null) modifier.semantics { selected = isSelected } else modifier + Surface( onClick = onClick, shape = RoundedCornerShape(20.dp), color = color, - modifier = modifier, + modifier = effectiveModifier, tonalElevation = 0.dp, border = BorderStroke(1.dp, borderColor)) { Row( diff --git a/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCard.kt b/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCard.kt index b8a135efb..8d0fbfa37 100644 --- a/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCard.kt +++ b/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCard.kt @@ -44,8 +44,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.selected -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -141,6 +139,11 @@ fun DangerModeCard( Effect.StartForegroundService -> startForegroundGpsService(activity) Effect.StopForegroundService -> stopForegroundGpsService(context) Effect.ShowOpenAppSettings -> openAppSettings(context) + is Effect.RequestCapabilityPermission -> { + // Reuse the same launcher to request the capability permissions + viewModel.onPermissionsRequestStart() + launcher.launch(effect.permissions.permissions) + } } } } @@ -447,19 +450,21 @@ private fun CapabilitiesRow( Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { DangerModeCapability.entries.forEach { capability -> val selected = capabilities.contains(capability) + val visuallyDisabled = capabilities.isNotEmpty() && !selected val (color, textColor) = - if (selected) { - Pair(colorScheme.secondaryContainer, colorScheme.onSecondaryContainer) - } else { - Pair(colorScheme.error, colorScheme.onError) + when { + selected -> Pair(colorScheme.secondaryContainer, colorScheme.onSecondaryContainer) + visuallyDisabled -> + Pair( + colorScheme.error.copy(alpha = 0.25f), + colorScheme.onError.copy(alpha = 0.25f)) + else -> Pair(colorScheme.error, colorScheme.onError) } StandardDashboardButton( label = capability.label, - modifier = - Modifier.testTag(DangerModeTestTags.capabilityTag(capability)).semantics { - this.selected = selected - }, + modifier = Modifier.testTag(DangerModeTestTags.capabilityTag(capability)), + isSelected = selected, color = color, borderColor = colorScheme.onError, onClick = { onCapabilityToggled(capability) }, diff --git a/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCardViewModel.kt b/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCardViewModel.kt index b19e4659a..54ce58048 100644 --- a/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCardViewModel.kt +++ b/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCardViewModel.kt @@ -5,7 +5,11 @@ import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.warnastrophy.core.data.interfaces.ActivityRepository +import com.github.warnastrophy.core.data.interfaces.UserPreferencesRepository +import com.github.warnastrophy.core.data.provider.UserPreferencesRepositoryProvider +import com.github.warnastrophy.core.data.repository.UserPreferences import com.github.warnastrophy.core.data.service.DangerLevel +import com.github.warnastrophy.core.data.service.DangerModeService import com.github.warnastrophy.core.data.service.StateManagerService import com.github.warnastrophy.core.data.service.StateManagerService.permissionManager import com.github.warnastrophy.core.model.Activity as DangerActivity @@ -21,6 +25,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update @@ -32,25 +37,16 @@ enum class DangerModeCapability(val label: String) { SMS("SMS"), } -/** - * Represents the entire state of the Alert Mode feature in the UI at any given time. - * - * @property alertModeManualEnabled True if the user has manually toggled the feature ON/OFF. This - * controls the primary switch state on the screen. - * @property alertModePermissionResult The current permission status related to running the Alert - * Mode feature. - * @property waitingForUserResponse True if the ViewModel is waiting for an asynchronous response - * from the system or the user. - */ +/** Represents the entire state of the Alert Mode feature in the UI at any given time. */ data class AlertModeUiState( val alertModeManualEnabled: Boolean = false, val alertModePermissionResult: PermissionResult, - val waitingForUserResponse: Boolean = false + val waitingForUserResponse: Boolean = false, + val pendingPermissionType: AppPermissions? = null, ) -/** Represents one-time, non-repeatable side effects that must be executed by the UI layer. */ +/** One-time effects emitted to the UI. */ sealed interface Effect { - /** Request a specific set of permissions. */ data class RequestPermissions(val permissionType: AppPermissions) : Effect object StartForegroundService : Effect @@ -58,26 +54,34 @@ sealed interface Effect { object StopForegroundService : Effect object ShowOpenAppSettings : Effect + + data class RequestCapabilityPermission(val permissions: AppPermissions) : Effect } -/** - * ViewModel for managing the state of the Danger Mode card in the dashboard UI. - * - * @param repository The ActivityRepository to use for fetching activities. - * @param userId The user ID for loading activities. Defaults to current Firebase user or fallback. - * @param dispatcher The coroutine dispatcher to use for async operations. - */ +/** ViewModel for managing the state of the Danger Mode card in the dashboard UI. */ class DangerModeCardViewModel( private val repository: ActivityRepository = StateManagerService.activityRepository, private val userId: String = FirebaseAuth.getInstance().currentUser?.uid ?: AppConfig.defaultUserId, - private val dispatcher: CoroutineDispatcher = Dispatchers.IO + private val dispatcher: CoroutineDispatcher = Dispatchers.IO, + private val userPreferencesRepository: UserPreferencesRepository = + UserPreferencesRepositoryProvider.repository, + private val dangerModeService: DangerModeService = StateManagerService.dangerModeService, ) : ViewModel() { val alertModePermission = AppPermissions.AlertModePermission - private val dangerModeService = StateManagerService.dangerModeService + + // Sequence of permissions required to enable danger/alert mode. + private val permissionSequence = + listOf( + AppPermissions.AlertModePermission, // location + AppPermissions.MicrophonePermission, // microphone + AppPermissions.SendEmergencySms, // sms + AppPermissions.MakeEmergencyCall // call + ) private val _activities = MutableStateFlow>(emptyList()) val activities: StateFlow> = _activities.asStateFlow() + private val _effects = MutableSharedFlow() val effects = _effects.asSharedFlow() @@ -88,20 +92,59 @@ class DangerModeCardViewModel( permissionManager.getPermissionResult(alertModePermission))) val permissionUiState = _alertModeUiState.asStateFlow() - // Sequence of permissions required to enable danger/alert mode. - private val permissionSequence = - listOf( - AppPermissions.AlertModePermission, // location - AppPermissions.MicrophonePermission, // microphone - AppPermissions.SendEmergencySms, // sms - AppPermissions.MakeEmergencyCall // call - ) + // UI-facing optimistic capabilities state. The UI should consume this for immediate + // feedback when toggling capabilities. + private val _capabilitiesInternal = MutableStateFlow>(emptySet()) + val capabilities: StateFlow> = _capabilitiesInternal.asStateFlow() + + private val _autoActionsEnabled = MutableStateFlow(false) + val autoActionsEnabled: StateFlow = _autoActionsEnabled.asStateFlow() + + private val _confirmTouchRequired = MutableStateFlow(false) + val confirmTouchRequired: StateFlow = _confirmTouchRequired.asStateFlow() + + private val _confirmVoiceRequired = MutableStateFlow(false) + val confirmVoiceRequired: StateFlow = _confirmVoiceRequired.asStateFlow() - // Tracks whether enabling was requested so we can continue chain after each grant. private var enableRequested = false init { + // Load activities refreshActivities() + + // Listen for capability-related events from the service and translate them to effects + viewModelScope.launch(dispatcher) { + dangerModeService.events.collect { event -> + when (event) { + DangerModeService.DangerModeEvent.MissingSmsPermission -> + emitEffect(Effect.RequestCapabilityPermission(AppPermissions.SendEmergencySms)) + DangerModeService.DangerModeEvent.MissingCallPermission -> + emitEffect(Effect.RequestCapabilityPermission(AppPermissions.MakeEmergencyCall)) + else -> Unit + } + } + } + + // Collect user preferences and apply to UI-state + viewModelScope.launch(dispatcher) { + try { + userPreferencesRepository.getUserPreferences.collect { prefs -> + viewModelScope.launch(Dispatchers.Main.immediate) { applyPreferencesInternal(prefs) } + } + } catch (e: Throwable) { + Log.e("DangerModeCardViewModel", "Error collecting user preferences", e) + } + } + + // Initial sync: seed optimistic capabilities from service state once + viewModelScope.launch(dispatcher) { + try { + val initial = dangerModeService.state.first() + _capabilitiesInternal.value = initial.capabilities + } catch (t: Throwable) { + Log.w("DangerModeCardViewModel", "Failed to initial-sync capabilities: ${t.message}") + } + } } /** Refreshes the activities list from the repository. */ @@ -132,25 +175,9 @@ class DangerModeCardViewModel( .map { it.dangerLevel } .stateIn(viewModelScope, SharingStarted.Lazily, DangerLevel.LOW) - val capabilities = - dangerModeService.state - .map { it.capabilities } - .stateIn(viewModelScope, SharingStarted.Lazily, emptySet()) - - private val _autoActionsEnabled = MutableStateFlow(false) - val autoActionsEnabled: StateFlow = _autoActionsEnabled.asStateFlow() - - private val _confirmTouchRequired = MutableStateFlow(false) - val confirmTouchRequired: StateFlow = _confirmTouchRequired.asStateFlow() - - private val _confirmVoiceRequired = MutableStateFlow(false) - val confirmVoiceRequired: StateFlow = _confirmVoiceRequired.asStateFlow() - /** * Handles the toggling of Danger Mode on or off and starts or stops the foreground GPS service * accordingly. - * - * @param enabled True to enable Danger Mode, false to disable it. */ fun onDangerModeToggled(enabled: Boolean) { if (enabled) { @@ -162,100 +189,99 @@ class DangerModeCardViewModel( } } - /** - * Sets the selected Activity - * - * @param activity The selected Activity. - */ fun onActivitySelected(activity: DangerActivity?) { dangerModeService.setActivity(activity) } - /** - * Updates the set of enabled capabilities for Danger Mode. - * - * @param newCapabilities The new set of enabled capabilities. - */ fun onCapabilitiesChanged(newCapabilities: Set) { - if (dangerModeService.setCapabilities(newCapabilities).isFailure) { + val previous = _capabilitiesInternal.value + _capabilitiesInternal.value = newCapabilities + val result = + try { + dangerModeService.setCapabilities(newCapabilities) + } catch (t: Throwable) { + Result.failure(t) + } + if (result.isFailure) { + _capabilitiesInternal.value = previous Log.e("DangerModeCardViewModel", "Failed to set capabilities: $newCapabilities") } } - /** - * Toggles a specific capability for Danger Mode. - * - * @param capability The capability to be toggled. - */ fun onCapabilityToggled(capability: DangerModeCapability) { - val current = dangerModeService.state.value.capabilities - val future = - if (current.contains(capability)) { - current - capability - } else { - current + capability + // derive desired set from optimistic internal state + val current = _capabilitiesInternal.value + val enabling = !current.contains(capability) + val newCaps: Set = if (enabling) setOf(capability) else emptySet() + + // optimistic update plus persistence + _capabilitiesInternal.value = newCaps + if (enabling) { + _autoActionsEnabled.value = true + viewModelScope.launch(dispatcher) { userPreferencesRepository.setAutoActionsEnabled(true) } + } + + viewModelScope.launch(dispatcher) { + when (capability) { + DangerModeCapability.CALL -> { + userPreferencesRepository.setAutomaticCalls(enabling) + if (enabling) userPreferencesRepository.setAutomaticSms(false) + } + DangerModeCapability.SMS -> { + userPreferencesRepository.setAutomaticSms(enabling) + if (enabling) userPreferencesRepository.setAutomaticCalls(false) } - onCapabilitiesChanged(future) + } + } + + // Apply to service (non-blocking) + viewModelScope.launch(dispatcher) { dangerModeService.setCapabilities(newCaps) } } - /** - * Sets the danger level, ensuring it stays within the valid range of 0 to 3. - * - * @param level The new danger level to be set. - */ fun onDangerLevelChanged(level: DangerLevel) { dangerModeService.setDangerLevel(level) } fun onConfirmTouchChanged(enabled: Boolean) { _confirmTouchRequired.value = enabled + if (enabled) _confirmVoiceRequired.value = false + viewModelScope.launch(dispatcher) { + userPreferencesRepository.setTouchConfirmationRequired(enabled) + if (enabled) userPreferencesRepository.setVoiceConfirmationEnabled(false) + } } fun onAutoActionsEnabled(enabled: Boolean) { _autoActionsEnabled.value = enabled + viewModelScope.launch(dispatcher) { userPreferencesRepository.setAutoActionsEnabled(enabled) } } fun onConfirmVoiceChanged(enabled: Boolean) { _confirmVoiceRequired.value = enabled + if (enabled) _confirmTouchRequired.value = false + viewModelScope.launch(dispatcher) { + userPreferencesRepository.setVoiceConfirmationEnabled(enabled) + if (enabled) userPreferencesRepository.setTouchConfirmationRequired(false) + } } - /** - * Emits a one-time side effect to the UI. - * - * This function launches a coroutine on the specified [dispatcher] within the ViewModel's - * lifecycle scope. - * - * The effect is emitted via the [_effects] SharedFlow, allowing the UI layer to collect it once - * and then discard the event, preventing replays on configuration changes. - * - * @param effect The specific [Effect] to be emitted to the collector (typically the UI/Fragment). - */ - @Suppress("RemoveRedundantDispatcherCall") private fun emitEffect(effect: Effect) { viewModelScope.launch(dispatcher) { _effects.emit(effect) } } - /** Records that a permission request has been initiated. */ fun onPermissionsRequestStart(permissionType: AppPermissions? = null) { - _alertModeUiState.update { it.copy(waitingForUserResponse = true) } + _alertModeUiState.update { + it.copy(waitingForUserResponse = true, pendingPermissionType = permissionType) + } } - /** - * Backward-compatible single-parameter permission result handler. Delegates to the full handler - * using the AlertModePermission. - */ fun onPermissionResult(activity: Activity) { onPermissionResult(activity, alertModePermission) } - /** - * Permission result handler which receives the Activity and the specific permission set. - * Continues the permission chain if allowed, or cancels enabling if denied. - */ fun onPermissionResult(activity: Activity, permissionType: AppPermissions) { val newResult = permissionManager.getPermissionResult(permissionType, activity) - // Update UI state if this was the alert mode permission if (permissionType == alertModePermission) { _alertModeUiState.update { it.copy(alertModePermissionResult = newResult) } } @@ -264,13 +290,13 @@ class DangerModeCardViewModel( permissionManager.markPermissionsAsAsked(permissionType) } - _alertModeUiState.update { it.copy(waitingForUserResponse = false) } + _alertModeUiState.update { + it.copy(waitingForUserResponse = false, pendingPermissionType = null) + } - // If granted and we were enabling, continue to next required permission if (newResult is PermissionResult.Granted && enableRequested) { requestNextRequiredPermission(activity) } else if (newResult !is PermissionResult.Granted) { - // If any permission denied, cancel the enable flow enableRequested = false } } @@ -281,17 +307,11 @@ class DangerModeCardViewModel( onDangerModeToggled(false) return } - // Start the enable flow enableRequested = true requestNextRequiredPermission(activity) } - /** - * Scan the permissionSequence and either enable the mode (if none missing) or emit an effect to - * request the next missing permission (or open app settings if permanently denied). - */ private fun requestNextRequiredPermission(activity: Activity) { - // Find the first permission that is not granted val missing = permissionSequence.firstNotNullOfOrNull { permissionType -> val result = permissionManager.getPermissionResult(permissionType, activity) @@ -299,7 +319,6 @@ class DangerModeCardViewModel( } if (missing == null) { - // All required permissions are granted -> enable enableRequested = false onDangerModeToggled(true) return @@ -311,9 +330,22 @@ class DangerModeCardViewModel( enableRequested = false emitEffect(Effect.ShowOpenAppSettings) } - else -> { - emitEffect(Effect.RequestPermissions(permissionType)) - } + else -> emitEffect(Effect.RequestPermissions(permissionType)) } } + + private fun applyPreferencesInternal(prefs: UserPreferences) { + runCatching { + _autoActionsEnabled.value = prefs.dangerModePreferences.autoActionsEnabled + _confirmTouchRequired.value = prefs.dangerModePreferences.touchConfirmationRequired + _confirmVoiceRequired.value = prefs.dangerModePreferences.voiceConfirmationEnabled + + val caps = mutableSetOf() + if (prefs.dangerModePreferences.automaticCalls) caps.add(DangerModeCapability.CALL) + if (prefs.dangerModePreferences.automaticSms) caps.add(DangerModeCapability.SMS) + + _capabilitiesInternal.value = caps + } + .onFailure { e -> Log.e("DangerModeCardViewModel", "Failed to apply user preferences", e) } + } } diff --git a/app/src/main/java/com/github/warnastrophy/core/ui/features/profile/preferences/DangerModePreferencesViewModel.kt b/app/src/main/java/com/github/warnastrophy/core/ui/features/profile/preferences/DangerModePreferencesViewModel.kt index 82bf6cc19..16ff3cfe7 100644 --- a/app/src/main/java/com/github/warnastrophy/core/ui/features/profile/preferences/DangerModePreferencesViewModel.kt +++ b/app/src/main/java/com/github/warnastrophy/core/ui/features/profile/preferences/DangerModePreferencesViewModel.kt @@ -241,4 +241,19 @@ class DangerModePreferencesViewModel( onToggle(false) } } + + /** + * Requests the necessary permissions for a given action. This function centralizes the logic for + * determining which permissions to request based on the action being performed. + * + * @param action The action for which permissions are being requested. + */ + fun requestPermissionFor(action: PendingAction) { + when (action) { + PendingAction.TOGGLE_AUTOMATIC_SMS -> onPermissionsRequestStart(action) + PendingAction.TOGGLE_AUTOMATIC_CALLS -> onPermissionsRequestStart(action) + PendingAction.TOGGLE_ALERT_MODE -> onPermissionsRequestStart(action) + PendingAction.TOGGLE_INACTIVITY_DETECTION -> onPermissionsRequestStart(action) + } + } } diff --git a/app/src/test/java/com/github/warnastrophy/core/data/service/DangerModeServiceTest.kt b/app/src/test/java/com/github/warnastrophy/core/data/service/DangerModeServiceTest.kt index 13a6430d1..6a17900df 100644 --- a/app/src/test/java/com/github/warnastrophy/core/data/service/DangerModeServiceTest.kt +++ b/app/src/test/java/com/github/warnastrophy/core/data/service/DangerModeServiceTest.kt @@ -340,4 +340,29 @@ class DangerModeServiceTest { assertTrue(result.isSuccess) assertEquals(setOf(DangerModeCapability.SMS), service.state.value.capabilities) } + + @Test + fun `capabilities must be mutually exclusive`() { + val result = service.setCapabilities(setOf(DangerModeCapability.CALL, DangerModeCapability.SMS)) + assertTrue(result.isFailure) + assertTrue(service.state.value.capabilities.isEmpty()) + } + + @Test + fun `missing sms permission emits event`() = runTest { + val pm = PermissionManagerMock(PermissionResult.Denied(emptyList())) + val (svc, _) = createService(permissionManager = pm) + val result = svc.setCapabilities(setOf(DangerModeCapability.SMS)) + assertTrue(result.isFailure) + assertEquals(DangerModeService.DangerModeEvent.MissingSmsPermission, svc.events.value) + } + + @Test + fun `missing call permission emits event`() = runTest { + val pm = PermissionManagerMock(PermissionResult.Denied(emptyList())) + val (svc, _) = createService(permissionManager = pm) + val result = svc.setCapabilities(setOf(DangerModeCapability.CALL)) + assertTrue(result.isFailure) + assertEquals(DangerModeService.DangerModeEvent.MissingCallPermission, svc.events.value) + } } diff --git a/app/src/test/java/com/github/warnastrophy/core/ui/feature/dashboard/DangerModeCardViewModelTest.kt b/app/src/test/java/com/github/warnastrophy/core/ui/feature/dashboard/DangerModeCardViewModelTest.kt index 4a011b437..e42a8a12e 100644 --- a/app/src/test/java/com/github/warnastrophy/core/ui/feature/dashboard/DangerModeCardViewModelTest.kt +++ b/app/src/test/java/com/github/warnastrophy/core/ui/feature/dashboard/DangerModeCardViewModelTest.kt @@ -9,26 +9,25 @@ import com.github.warnastrophy.core.data.service.DangerModeService import com.github.warnastrophy.core.data.service.MockPermissionManager import com.github.warnastrophy.core.data.service.StateManagerService import com.github.warnastrophy.core.model.Activity +import com.github.warnastrophy.core.permissions.AppPermissions import com.github.warnastrophy.core.permissions.PermissionManagerInterface import com.github.warnastrophy.core.permissions.PermissionResult import com.github.warnastrophy.core.ui.features.dashboard.DangerModeCapability import com.github.warnastrophy.core.ui.features.dashboard.DangerModeCardViewModel import com.github.warnastrophy.core.ui.features.dashboard.Effect import com.github.warnastrophy.core.util.AppConfig +import io.mockk.every import io.mockk.mockk import io.mockk.spyk import io.mockk.unmockkAll import io.mockk.verify import kotlin.test.assertFalse import kotlin.test.assertTrue -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.assertEquals @@ -43,7 +42,8 @@ import org.robolectric.RobolectricTestRunner class DangerModeCardViewModelTest { private lateinit var viewModel: DangerModeCardViewModel private lateinit var repository: MockActivityRepository - private val testDispatcher = StandardTestDispatcher() + // Use an unconfined dispatcher for deterministic immediate execution in unit tests + private val testDispatcher = kotlinx.coroutines.test.UnconfinedTestDispatcher() private lateinit var activity: android.app.Activity private lateinit var mockPermissionManager: PermissionManagerInterface @@ -65,12 +65,12 @@ class DangerModeCardViewModelTest { repository = MockActivityRepository() viewModel = DangerModeCardViewModel( - repository = repository, userId = AppConfig.defaultUserId, testDispatcher) + repository = repository, userId = AppConfig.defaultUserId, dispatcher = testDispatcher) } @After fun tearDown() { - Dispatchers.resetMain() + // Cleanup mocks unmockkAll() } @@ -122,11 +122,18 @@ class DangerModeCardViewModelTest { } @Test - fun `onCapabilityToggled removes capability when present`() = runTest { - viewModel.onCapabilitiesChanged(setOf(DangerModeCapability.SMS)) - assertEquals(setOf(DangerModeCapability.SMS), viewModel.capabilities.first()) + fun `capability exclusivity enforces single selection and enables autoActions`() = runTest { + // Enable SMS capability viewModel.onCapabilityToggled(DangerModeCapability.SMS) - assertEquals(emptySet(), viewModel.capabilities.first()) + assertEquals(setOf(DangerModeCapability.SMS), viewModel.capabilities.first()) + // autoActions should be enabled when capability turned on + assertEquals(true, viewModel.autoActionsEnabled.first()) + + // Now toggle CALL - should replace SMS with CALL + viewModel.onCapabilityToggled(DangerModeCapability.CALL) + assertEquals(setOf(DangerModeCapability.CALL), viewModel.capabilities.first()) + // SMS should be off + assertFalse(viewModel.capabilities.first().contains(DangerModeCapability.SMS)) } @Test @@ -170,33 +177,45 @@ class DangerModeCardViewModelTest { @Test fun handleToggle_ON_with_Granted_permission_activatesDangerMode_and_emitsStartServiceEffect() = runTest(testDispatcher) { + // stub permission manager to return Granted for all permissions + every { mockPermissionManager.getPermissionResult(any(), any()) } returns + PermissionResult.Granted + val collectedEffects = mutableListOf() launch { viewModel.effects.take(1).toList(collectedEffects) } - viewModel.handleToggle(isChecked = true, permissionResult = PermissionResult.Granted) + viewModel.handleToggle(isChecked = true, activity = activity) testDispatcher.scheduler.advanceUntilIdle() assertEquals(1, collectedEffects.size) assertEquals(collectedEffects[0], Effect.StartForegroundService) } @Test - fun handleToggle_ON_with_Denied_permission_emits_RequestLocationPermissionEffect() = + fun handleToggle_ON_with_Denied_permission_emits_RequestPermissionEffect() = runTest(testDispatcher) { + // return Denied for the AlertModePermission so the ViewModel requests it + every { + mockPermissionManager.getPermissionResult(AppPermissions.AlertModePermission, any()) + } returns PermissionResult.Denied(emptyList()) + val collectedEffects = mutableListOf() launch { viewModel.effects.take(1).toList(collectedEffects) } - viewModel.handleToggle( - isChecked = true, permissionResult = PermissionResult.Denied(emptyList())) + viewModel.handleToggle(isChecked = true, activity = activity) testDispatcher.scheduler.advanceUntilIdle() assertEquals(1, collectedEffects.size) - assertEquals(collectedEffects[0], Effect.RequestLocationPermission) + assertEquals( + Effect.RequestPermissions(AppPermissions.AlertModePermission), collectedEffects[0]) } @Test fun handleToggle_ON_with_Permanent_Denied_permission_emits_OpenAppSettingsEffect() = runTest(testDispatcher) { + every { + mockPermissionManager.getPermissionResult(AppPermissions.AlertModePermission, any()) + } returns PermissionResult.PermanentlyDenied(emptyList()) + val collectedEffects = mutableListOf() launch { viewModel.effects.take(1).toList(collectedEffects) } - viewModel.handleToggle( - isChecked = true, permissionResult = PermissionResult.PermanentlyDenied(emptyList())) + viewModel.handleToggle(isChecked = true, activity = activity) testDispatcher.scheduler.advanceUntilIdle() assertEquals(1, collectedEffects.size) assertEquals(collectedEffects[0], Effect.ShowOpenAppSettings) @@ -207,9 +226,20 @@ class DangerModeCardViewModelTest { runTest(testDispatcher) { val collectedEffects = mutableListOf() launch { viewModel.effects.take(1).toList(collectedEffects) } - viewModel.handleToggle(isChecked = false, permissionResult = PermissionResult.Granted) + viewModel.handleToggle(isChecked = false, activity = activity) testDispatcher.scheduler.advanceUntilIdle() assertEquals(1, collectedEffects.size) assertEquals(collectedEffects[0], Effect.StopForegroundService) } + + @Test + fun `confirm switches are mutually exclusive`() = runTest { + viewModel.onConfirmVoiceChanged(true) + assertTrue(viewModel.confirmVoiceRequired.first()) + assertFalse(viewModel.confirmTouchRequired.first()) + + viewModel.onConfirmTouchChanged(true) + assertTrue(viewModel.confirmTouchRequired.first()) + assertFalse(viewModel.confirmVoiceRequired.first()) + } } From b68b9cd8974d877020d03eb74448326a707c09d6 Mon Sep 17 00:00:00 2001 From: ssidimoh Date: Thu, 18 Dec 2025 03:52:47 +0100 Subject: [PATCH 14/15] fix: fix tests in dangercardviewmodel --- .../core/ui/feature/dashboard/DangerModeCardViewModelTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/test/java/com/github/warnastrophy/core/ui/feature/dashboard/DangerModeCardViewModelTest.kt b/app/src/test/java/com/github/warnastrophy/core/ui/feature/dashboard/DangerModeCardViewModelTest.kt index 657cbb817..77d31e62e 100644 --- a/app/src/test/java/com/github/warnastrophy/core/ui/feature/dashboard/DangerModeCardViewModelTest.kt +++ b/app/src/test/java/com/github/warnastrophy/core/ui/feature/dashboard/DangerModeCardViewModelTest.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith From f748965fde53fb548f8cefd714f402503624c2d9 Mon Sep 17 00:00:00 2001 From: ssidimoh Date: Thu, 18 Dec 2025 04:55:11 +0100 Subject: [PATCH 15/15] fix: fix onboarding screen --- .../features/dashboard/DangerModeCardTest.kt | 86 +++---------------- .../dashboard/DangerModeCardViewModel.kt | 36 ++++---- .../ui/features/onboard/OnboardingScreen.kt | 13 +-- 3 files changed, 38 insertions(+), 97 deletions(-) diff --git a/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCardTest.kt b/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCardTest.kt index ed62142bf..657d28ec8 100644 --- a/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCardTest.kt +++ b/app/src/androidTest/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCardTest.kt @@ -36,10 +36,9 @@ class DangerModeCardTest : BaseAndroidComposeTest() { fun setup() { val context = composeTestRule.activity.applicationContext UserPreferencesRepositoryProvider.initLocal(context.userPrefsDataStore) - // Initialize the ActivityRepositoryProvider with mock for testing mockActivityRepository = MockActivityRepository() ActivityRepositoryProvider.useMock() - // Make sure user preferences are reset to defaults so tests are deterministic + runBlocking { val repo = UserPreferencesRepositoryProvider.repository repo.setAutoActionsEnabled(false) @@ -50,47 +49,15 @@ class DangerModeCardTest : BaseAndroidComposeTest() { repo.setInactivityDetection(false) repo.setAlertMode(false) } - // Initialize services after prefs have been reset - InstrumentationRegistry.getInstrumentation().runOnMainSync { StateManagerService.init(context) } - StateManagerService.permissionManager = - MockPermissionManager(currentResult = PermissionResult.Granted) - StateManagerService.dangerModeService = - DangerModeService(permissionManager = StateManagerService.permissionManager) - val appContext = composeTestRule.activity.applicationContext + val instrumentation = InstrumentationRegistry.getInstrumentation() - UserPreferencesRepositoryProvider.initLocal(appContext.userPrefsDataStore) - instrumentation.runOnMainSync { StateManagerService.init(appContext) } + instrumentation.runOnMainSync { StateManagerService.init(context) } instrumentation.runOnMainSync { StateManagerService.permissionManager = MockPermissionManager(currentResult = PermissionResult.Granted) StateManagerService.dangerModeService = DangerModeService(permissionManager = StateManagerService.permissionManager) } - // Initialize the ActivityRepositoryProvider with mock for testing - mockActivityRepository = MockActivityRepository() - ActivityRepositoryProvider.useMock() - val context = composeTestRule.activity.applicationContext - UserPreferencesRepositoryProvider.initLocal(context.userPrefsDataStore) - // Initialize the ActivityRepositoryProvider with mock for testing - mockActivityRepository = MockActivityRepository() - ActivityRepositoryProvider.useMock() - // Make sure user preferences are reset to defaults so tests are deterministic - runBlocking { - val repo = UserPreferencesRepositoryProvider.repository - repo.setAutoActionsEnabled(false) - repo.setAutomaticCalls(false) - repo.setAutomaticSms(false) - repo.setVoiceConfirmationEnabled(false) - repo.setTouchConfirmationRequired(false) - repo.setInactivityDetection(false) - repo.setAlertMode(false) - } - // Initialize services after prefs have been reset - InstrumentationRegistry.getInstrumentation().runOnMainSync { StateManagerService.init(context) } - StateManagerService.permissionManager = - MockPermissionManager(currentResult = PermissionResult.Granted) - StateManagerService.dangerModeService = - DangerModeService(permissionManager = StateManagerService.permissionManager) } private fun createTestViewModel(repository: MockActivityRepository = mockActivityRepository) = @@ -278,11 +245,7 @@ class DangerModeCardTest : BaseAndroidComposeTest() { val viewModel = createTestViewModel() composeTestRule.setContent { MaterialTheme { DangerModeCard(viewModel = viewModel) } } - // Ensure the ViewModel starts with no capabilities enabled to make the test deterministic viewModel.onCapabilitiesChanged(emptySet()) - assert(viewModel.capabilitiesInternal.value.isEmpty()) - - // Advanced section should not be visible initially composeTestRule .onNodeWithTag(DangerModeTestTags.ADVANCED_SECTION, useUnmergedTree = true) .assertDoesNotExist() @@ -291,38 +254,17 @@ class DangerModeCardTest : BaseAndroidComposeTest() { composeTestRule.onNodeWithTag( DangerModeTestTags.capabilityTag(DangerModeCapability.CALL), useUnmergedTree = true) callCapabilityNode.performClick() - // Enable CALL capability deterministically via ViewModel - viewModel.onCapabilityToggled(DangerModeCapability.CALL) - - // Wait for capabilities to be applied and advanced section to appear composeTestRule.waitUntilWithTimeout { viewModel.capabilitiesInternal.value.contains(DangerModeCapability.CALL) } + composeTestRule .onNodeWithTag(DangerModeTestTags.EXPAND_ARROW, useUnmergedTree = true) .performClick() - .onNodeWithTag(DangerModeTestTags.ADVANCED_SECTION, useUnmergedTree = true) - .assertExists() - - // Enable CALL capability deterministically via ViewModel - viewModel.onCapabilityToggled(DangerModeCapability.CALL) - - // Wait for capabilities to be applied and advanced section to appear - composeTestRule.waitUntilWithTimeout { - viewModel.capabilitiesInternal.value.contains(DangerModeCapability.CALL) - } composeTestRule .onNodeWithTag(DangerModeTestTags.ADVANCED_SECTION, useUnmergedTree = true) .assertExists() - // Wait for autoActions to be enabled by the ViewModel's logic and then assert confirmations are - // false - composeTestRule.waitUntilWithTimeout { viewModel.autoActionsEnabled.value } - assert(viewModel.autoActionsEnabled.value) - assert(!viewModel.confirmTouchRequired.value) - assert(!viewModel.confirmVoiceRequired.value) - // Wait for autoActions to be enabled by the ViewModel's logic and then assert confirmations are - // false composeTestRule.waitUntilWithTimeout { viewModel.autoActionsEnabled.value } assert(viewModel.autoActionsEnabled.value) assert(!viewModel.confirmTouchRequired.value) @@ -335,16 +277,17 @@ class DangerModeCardTest : BaseAndroidComposeTest() { val viewModel = createTestViewModel() composeTestRule.setContent { MaterialTheme { DangerModeCard(viewModel = viewModel) } } - // Enable CALL capability to show advanced section deterministically via ViewModel viewModel.onCapabilityToggled(DangerModeCapability.CALL) - - // Wait until autoActions is enabled by the ViewModel composeTestRule.waitUntilWithTimeout { viewModel.autoActionsEnabled.value } + + composeTestRule + .onNodeWithTag(DangerModeTestTags.EXPAND_ARROW, useUnmergedTree = true) + .performClick() + assert(viewModel.autoActionsEnabled.value) assert(!viewModel.confirmTouchRequired.value) assert(!viewModel.confirmVoiceRequired.value) - // Toggle auto actions off and verify state updates correctly viewModel.onAutoActionsEnabled(false) composeTestRule.waitUntilWithTimeout { !viewModel.autoActionsEnabled.value } assert(!viewModel.autoActionsEnabled.value) @@ -377,16 +320,17 @@ class DangerModeCardTest : BaseAndroidComposeTest() { MaterialTheme { DangerModeCard(viewModel = viewModel) } } - // Enable CALL capability to show advanced section val callNode = composeTestRule.onNodeWithTag( DangerModeTestTags.capabilityTag(DangerModeCapability.CALL), useUnmergedTree = true) - callCapabilityNode.performClick() + callNode.performClick() + composeTestRule.waitUntilWithTimeout { + viewModel.capabilitiesInternal.value.contains(DangerModeCapability.CALL) + } + composeTestRule .onNodeWithTag(DangerModeTestTags.EXPAND_ARROW, useUnmergedTree = true) .performClick() - callNode.performClick() - callNode.performClick() val confirmTouchSwitch = composeTestRule.onNodeWithTag( @@ -395,12 +339,10 @@ class DangerModeCardTest : BaseAndroidComposeTest() { composeTestRule.onNodeWithTag( DangerModeTestTags.CONFIRM_VOICE_SWITCH, useUnmergedTree = true) - // Toggle voice -> voice on, touch off confirmVoiceSwitch.performClick() confirmVoiceSwitch.assertIsOn() confirmTouchSwitch.assertIsOff() - // Toggle touch -> touch on, voice off confirmTouchSwitch.performClick() confirmTouchSwitch.assertIsOn() confirmVoiceSwitch.assertIsOff() diff --git a/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCardViewModel.kt b/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCardViewModel.kt index 9f96c97f1..48e12b449 100644 --- a/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCardViewModel.kt +++ b/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCardViewModel.kt @@ -47,8 +47,10 @@ data class AlertModeUiState( /** One-time effects emitted to the UI. */ sealed interface Effect { + /** Request a specific permission type (new API). */ data class RequestPermissions(val permissionType: AppPermissions) : Effect + /** Legacy effect for location permission request. */ object StartForegroundService : Effect object StopForegroundService : Effect @@ -74,11 +76,10 @@ class DangerModeCardViewModel( // Sequence of permissions required to enable danger/alert mode. private val permissionSequence = listOf( - AppPermissions.AlertModePermission, // location - AppPermissions.MicrophonePermission, // microphone - AppPermissions.SendEmergencySms, // sms - AppPermissions.MakeEmergencyCall // call - ) + AppPermissions.AlertModePermission, + AppPermissions.MicrophonePermission, + AppPermissions.SendEmergencySms, + AppPermissions.MakeEmergencyCall) private val _activities = MutableStateFlow>(emptyList()) val activities: StateFlow> = _activities.asStateFlow() @@ -93,10 +94,10 @@ class DangerModeCardViewModel( permissionManager.getPermissionResult(alertModePermission))) val permissionUiState = _alertModeUiState.asStateFlow() - // UI-facing optimistic capabilities state. The UI should consume this for immediate - // feedback when toggling capabilities. private val _capabilitiesInternal = MutableStateFlow>(emptySet()) val capabilities: StateFlow> = _capabilitiesInternal.asStateFlow() + // Alias for backward compatibility + val capabilitiesInternal: StateFlow> = capabilities private val _autoActionsEnabled = MutableStateFlow(false) val autoActionsEnabled: StateFlow = _autoActionsEnabled.asStateFlow() @@ -110,10 +111,9 @@ class DangerModeCardViewModel( private var enableRequested = false init { - // Load activities refreshActivities() - // Listen for capability-related events from the service and translate them to effects + // Listen for capability-related events from the service viewModelScope.launch(dispatcher) { dangerModeService.events.collect { event -> when (event) { @@ -126,7 +126,7 @@ class DangerModeCardViewModel( } } - // Collect user preferences and apply to UI-state + // Collect user preferences viewModelScope.launch(dispatcher) { try { userPreferencesRepository.getUserPreferences.collect { prefs -> @@ -137,7 +137,7 @@ class DangerModeCardViewModel( } } - // Initial sync: seed optimistic capabilities from service state once + // Initial sync: seed capabilities from service state viewModelScope.launch(dispatcher) { try { val initial = dangerModeService.state.first() @@ -148,7 +148,6 @@ class DangerModeCardViewModel( } } - /** Refreshes the activities list from the repository. */ fun refreshActivities() { viewModelScope.launch(dispatcher) { val result = repository.getAllActivities(userId) @@ -176,10 +175,6 @@ class DangerModeCardViewModel( .map { it.dangerLevel } .stateIn(viewModelScope, SharingStarted.Lazily, DangerLevel.LOW) - /** - * Handles the toggling of Danger Mode on or off and starts or stops the foreground GPS service - * accordingly. - */ fun onDangerModeToggled(enabled: Boolean) { if (enabled) { dangerModeService.manualActivate() @@ -212,12 +207,10 @@ class DangerModeCardViewModel( } fun onCapabilityToggled(capability: DangerModeCapability) { - // derive desired set from optimistic internal state val current = _capabilitiesInternal.value val enabling = !current.contains(capability) val newCaps: Set = if (enabling) setOf(capability) else emptySet() - // optimistic update plus persistence _capabilitiesInternal.value = newCaps if (enabling) { _autoActionsEnabled.value = true @@ -277,6 +270,11 @@ class DangerModeCardViewModel( } } + /** Legacy overload without permission type. */ + fun onPermissionsRequestStart() { + onPermissionsRequestStart(null) + } + fun onPermissionResult(activity: Activity) { onPermissionResult(activity, alertModePermission) } @@ -303,7 +301,7 @@ class DangerModeCardViewModel( } } - /** Starts the toggle flow. The Activity is required to query current permission statuses. */ + /** New API: starts the toggle flow with Activity for permission queries. */ fun handleToggle(isChecked: Boolean, activity: Activity) { if (!isChecked) { onDangerModeToggled(false) diff --git a/app/src/main/java/com/github/warnastrophy/core/ui/features/onboard/OnboardingScreen.kt b/app/src/main/java/com/github/warnastrophy/core/ui/features/onboard/OnboardingScreen.kt index b591acaa0..17b92f305 100644 --- a/app/src/main/java/com/github/warnastrophy/core/ui/features/onboard/OnboardingScreen.kt +++ b/app/src/main/java/com/github/warnastrophy/core/ui/features/onboard/OnboardingScreen.kt @@ -23,7 +23,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -124,9 +123,11 @@ fun OnboardingScreen(onFinished: () -> Unit) { val screenState = rememberPagerState(initialPage = 0) { screens.size } - val buttons by remember { - derivedStateOf { getButtonState(screenState.currentPage, screens.size) } - } + val currentPage = screenState.currentPage + val buttons = + remember(currentPage, screens.size) { + getButtonState(screenIndex = currentPage, numberOfScreens = screens.size) + } val scope = rememberCoroutineScope() @@ -151,7 +152,7 @@ fun OnboardingScreen(onFinished: () -> Unit) { } } Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) { - IndicatorUI(pageSize = screens.size, currentPage = screenState.currentPage) + IndicatorUI(pageSize = screens.size, currentPage = currentPage) } Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { @@ -163,7 +164,7 @@ fun OnboardingScreen(onFinished: () -> Unit) { textColor = MaterialTheme.colorScheme.onPrimary) { scope.launch { handleNextClick( - page = screenState.currentPage, + page = currentPage, pageCount = screens.size, pagerState = screenState, onFinished = onFinished)