From 41fa89ed2b82390f92eb8f39a4e6184e063f91f1 Mon Sep 17 00:00:00 2001 From: ssidimoh Date: Thu, 18 Dec 2025 00:05:58 +0100 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 7/8] 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 8/8] 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)