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 5198a7556..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 @@ -24,6 +24,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 @@ -35,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) @@ -49,12 +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 instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.runOnMainSync { StateManagerService.init(context) } + instrumentation.runOnMainSync { + StateManagerService.permissionManager = + MockPermissionManager(currentResult = PermissionResult.Granted) + StateManagerService.dangerModeService = + DangerModeService(permissionManager = StateManagerService.permissionManager) + } } private fun createTestViewModel(repository: MockActivityRepository = mockActivityRepository) = @@ -242,28 +245,26 @@ 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() - // Enable CALL capability deterministically via ViewModel - viewModel.onCapabilityToggled(DangerModeCapability.CALL) - - // Wait for capabilities to be applied and advanced section to appear + val callCapabilityNode = + composeTestRule.onNodeWithTag( + DangerModeTestTags.capabilityTag(DangerModeCapability.CALL), useUnmergedTree = true) + callCapabilityNode.performClick() composeTestRule.waitUntilWithTimeout { viewModel.capabilitiesInternal.value.contains(DangerModeCapability.CALL) } + + composeTestRule + .onNodeWithTag(DangerModeTestTags.EXPAND_ARROW, useUnmergedTree = true) + .performClick() 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) @@ -276,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) @@ -318,11 +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) callNode.performClick() + composeTestRule.waitUntilWithTimeout { + viewModel.capabilitiesInternal.value.contains(DangerModeCapability.CALL) + } + + composeTestRule + .onNodeWithTag(DangerModeTestTags.EXPAND_ARROW, useUnmergedTree = true) + .performClick() val confirmTouchSwitch = composeTestRule.onNodeWithTag( @@ -331,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/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 da31b1f35..74f61133f 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 @@ -213,4 +213,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/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/permissions/AppPermissions.kt b/app/src/main/java/com/github/warnastrophy/core/permissions/AppPermissions.kt index c77a335aa..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 : @@ -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/DangerModeCard.kt b/app/src/main/java/com/github/warnastrophy/core/ui/features/dashboard/DangerModeCard.kt index aa88dab47..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 @@ -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 @@ -46,6 +51,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 @@ -70,6 +76,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 @@ -108,17 +115,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) @@ -134,19 +150,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 }) } } } @@ -162,7 +186,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( @@ -190,11 +214,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() @@ -219,19 +246,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 d9c9b3813..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 @@ -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 @@ -11,7 +12,7 @@ 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 +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 @@ -36,33 +37,20 @@ 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. They are - * typically used for: - * 1. Navigation. - * 2. Displaying Toast messages or Dialogs. - * 3. Starting/Stopping services. - * 4. Requesting system permissions. - */ +/** One-time effects emitted to the UI. */ sealed interface Effect { - object RequestLocationPermission : 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 @@ -72,13 +60,7 @@ sealed interface 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 = @@ -86,12 +68,22 @@ class DangerModeCardViewModel( private val dispatcher: CoroutineDispatcher = Dispatchers.IO, private val userPreferencesRepository: UserPreferencesRepository = UserPreferencesRepositoryProvider.repository, - private val dangerModeService: DangerModeService = StateManagerService.dangerModeService + private val dangerModeService: DangerModeService = StateManagerService.dangerModeService, ) : ViewModel() { + val alertModePermission = AppPermissions.AlertModePermission - private val _activities = MutableStateFlow>(emptyList()) - val activities: StateFlow> = _activities.asStateFlow() + // Sequence of permissions required to enable danger/alert mode. + private val permissionSequence = + listOf( + AppPermissions.AlertModePermission, + AppPermissions.MicrophonePermission, + AppPermissions.SendEmergencySms, + AppPermissions.MakeEmergencyCall) + + private val _activities = MutableStateFlow>(emptyList()) + val activities: StateFlow> = _activities.asStateFlow() + private val _effects = MutableSharedFlow() val effects = _effects.asSharedFlow() @@ -102,29 +94,26 @@ 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. It is kept in sync with the service-backed - // `capabilities` and will be reverted if the service rejects the change. private val _capabilitiesInternal = MutableStateFlow>(emptySet()) - val capabilitiesInternal: StateFlow> = - _capabilitiesInternal.asStateFlow() + val capabilities: StateFlow> = _capabilitiesInternal.asStateFlow() + // Alias for backward compatibility + val capabilitiesInternal: StateFlow> = capabilities 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() + private var enableRequested = false + init { refreshActivities() + + // Listen for capability-related events from the service viewModelScope.launch(dispatcher) { dangerModeService.events.collect { event -> when (event) { @@ -132,30 +121,33 @@ class DangerModeCardViewModel( emitEffect(Effect.RequestCapabilityPermission(AppPermissions.SendEmergencySms)) DangerModeService.DangerModeEvent.MissingCallPermission -> emitEffect(Effect.RequestCapabilityPermission(AppPermissions.MakeEmergencyCall)) - null -> Unit + else -> Unit } } } - // Collect user preferences from the repository. Run collection on the provided dispatcher - // but update UI state on the Main thread. Guard the collector against unexpected exceptions - // so a single bad read won't crash the ViewModel. + + // Collect user preferences viewModelScope.launch(dispatcher) { try { userPreferencesRepository.getUserPreferences.collect { prefs -> - try { - // Ensure updates happen on main thread - viewModelScope.launch(Dispatchers.Main.immediate) { applyPreferencesInternal(prefs) } - } catch (e: Throwable) { - Log.e("DangerModeCardViewModel", "Failed to schedule applyPreferences", e) - } + viewModelScope.launch(Dispatchers.Main.immediate) { applyPreferencesInternal(prefs) } } } catch (e: Throwable) { Log.e("DangerModeCardViewModel", "Error collecting user preferences", e) } } + + // Initial sync: seed capabilities from service state + 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. */ fun refreshActivities() { viewModelScope.launch(dispatcher) { val result = repository.getAllActivities(userId) @@ -183,19 +175,6 @@ class DangerModeCardViewModel( .map { it.dangerLevel } .stateIn(viewModelScope, SharingStarted.Lazily, DangerLevel.LOW) - /** - * Public view of capabilities consumed by UI/tests. This returns the optimistic internal state so - * updates are immediate and deterministic. The service-backed `capabilities` is still updated by - * `onCapabilitiesChanged` which calls into DangerModeService. - */ - val capabilities: StateFlow> = _capabilitiesInternal.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) { dangerModeService.manualActivate() @@ -206,22 +185,11 @@ class DangerModeCardViewModel( } } - /** - * Sets the selected Activity - * - * @param activity The selected Activity. - */ - fun onActivitySelected(activity: 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) { - // Optimistically update the UI-facing capabilities so the UI reacts immediately. val previous = _capabilitiesInternal.value _capabilitiesInternal.value = newCapabilities @@ -233,25 +201,17 @@ class DangerModeCardViewModel( } if (result.isFailure) { - // revert optimistic update and log _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) { - // Use the UI-facing internal capabilities as the source of truth for immediate toggling val current = _capabilitiesInternal.value val enabling = !current.contains(capability) - val newCaps: Set = if (enabling) setOf(capability) else emptySet() - // If enabling a capability, enable auto-actions so the advanced section becomes visible. + _capabilitiesInternal.value = newCaps if (enabling) { _autoActionsEnabled.value = true viewModelScope.launch(dispatcher) { userPreferencesRepository.setAutoActionsEnabled(true) } @@ -273,11 +233,6 @@ class DangerModeCardViewModel( } } - /** - * 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) } @@ -305,67 +260,80 @@ class DangerModeCardViewModel( } } - /** - * 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 by update UIState. */ + fun onPermissionsRequestStart(permissionType: AppPermissions? = null) { + _alertModeUiState.update { + it.copy(waitingForUserResponse = true, pendingPermissionType = permissionType) + } + } + + /** Legacy overload without permission type. */ fun onPermissionsRequestStart() { - _alertModeUiState.update { it.copy(waitingForUserResponse = true) } + onPermissionsRequestStart(null) } - /** - * Updates the permission results in the UI state after the user has responded to a system - * permission dialog. - * - * @param activity The current `Activity`, required to check the latest permission statuses. - */ - fun onPermissionResult(activity: android.app.Activity) { - val newAlertModeResult = permissionManager.getPermissionResult(alertModePermission, activity) - _alertModeUiState.update { it.copy(alertModePermissionResult = newAlertModeResult) } + fun onPermissionResult(activity: Activity) { + onPermissionResult(activity, alertModePermission) + } + + fun onPermissionResult(activity: Activity, permissionType: AppPermissions) { + val newResult = permissionManager.getPermissionResult(permissionType, activity) + + 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, pendingPermissionType = null) + } + + if (newResult is PermissionResult.Granted && enableRequested) { + requestNextRequiredPermission(activity) + } else if (newResult !is PermissionResult.Granted) { + enableRequested = false } - _alertModeUiState.update { it.copy(waitingForUserResponse = false) } } - /** - * Handles the logic when user toggle the button, checking permissions and dispatching actions. - * - * @param isChecked The new state of the preference toggle. - * @param permissionResult The current permission status for this feature. - */ - fun handleToggle(isChecked: Boolean, permissionResult: PermissionResult) { - if (isChecked) { - when (permissionResult) { - PermissionResult.Granted -> { - onDangerModeToggled(true) + /** New API: starts the toggle flow with Activity for permission queries. */ + fun handleToggle(isChecked: Boolean, activity: Activity) { + if (!isChecked) { + onDangerModeToggled(false) + return + } + enableRequested = true + requestNextRequiredPermission(activity) + } + + private fun requestNextRequiredPermission(activity: Activity) { + 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) { + enableRequested = false + onDangerModeToggled(true) + return + } + + val (permissionType, result) = missing + when (result) { + is PermissionResult.PermanentlyDenied -> { + enableRequested = false + emitEffect(Effect.ShowOpenAppSettings) } - } else { - onDangerModeToggled(false) + else -> emitEffect(Effect.RequestPermissions(permissionType)) } } - // Internal suspend function that performs the actual state updates. Must be called on the - // Main dispatcher to safely update StateFlow-backed UI state. private fun applyPreferencesInternal(prefs: UserPreferences) { runCatching { _autoActionsEnabled.value = prefs.dangerModePreferences.autoActionsEnabled @@ -376,29 +344,8 @@ class DangerModeCardViewModel( if (prefs.dangerModePreferences.automaticCalls) caps.add(DangerModeCapability.CALL) if (prefs.dangerModePreferences.automaticSms) caps.add(DangerModeCapability.SMS) - try { - // Apply persisted capabilities to the UI-facing state directly without calling - // `onCapabilitiesChanged` to avoid triggering a service call which can overwrite - // optimistic UI updates during tests or startup. - _capabilitiesInternal.value = caps - } catch (e: Throwable) { - Log.e("DangerModeCardViewModel", "Failed to apply capabilities from prefs", e) - } + _capabilitiesInternal.value = caps } .onFailure { e -> Log.e("DangerModeCardViewModel", "Failed to apply user preferences", e) } } - - // Initial sync: read the current service state once and seed the UI-facing capabilities. - // We intentionally do not continuously overwrite the UI-facing optimistic state to avoid - // reverting user actions during transient service updates. - init { - 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}") - } - } - } } 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 b8e2a537a..51426b2b0 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( @@ -110,7 +111,8 @@ fun LatestNewsCard( currentIndex = currentIndex, onIndexChange = { newIndex -> currentIndex = newIndex }, extendedColors = extendedColors, - openWebPage = openWebPage) + openWebPage = openWebPage, + displayHazardCount = hazards.size) } } @@ -146,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 = @@ -157,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( @@ -176,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)) } 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 72a8e0e36..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 @@ -325,7 +327,7 @@ fun HazardsGoogleMap( GoogleMap( modifier = Modifier.fillMaxSize().testTag(MapScreenTestTags.GOOGLE_MAP_SCREEN), cameraPositionState = cameraPositionState, - onMapClick = {}, + onMapClick = { selectedMarkerId = null }, uiSettings = MapUiSettings( myLocationButtonEnabled = false, 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/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) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2d8152a9b..f0ae9f9a8 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/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 e553fcbc8..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 @@ -9,28 +9,28 @@ 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.resetMain 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 @@ -70,10 +70,7 @@ class DangerModeCardViewModelTest { @After fun tearDown() { - // No Main dispatcher was set in setup; resetMain is a no-op but keep for safety. - try { - kotlinx.coroutines.Dispatchers.resetMain() - } catch (_: Throwable) {} + // Cleanup mocks unmockkAll() } @@ -180,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) @@ -217,7 +226,7 @@ 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) 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... +} +