diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/ConfirmDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/ConfirmDialog.kt new file mode 100644 index 00000000..96ed49a0 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/ConfirmDialog.kt @@ -0,0 +1,101 @@ +package com.m57.hermescontrol.ui.common + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R + +/** + * Standard confirmation dialog across the app. + * Supports destructive actions with error styling, optional title icons, and optional extra body content. + */ +@Composable +fun ConfirmDialog( + title: String, + message: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + confirmText: String = stringResource(R.string.action_confirm), + dismissText: String = stringResource(R.string.action_cancel), + isDestructive: Boolean = false, + icon: ImageVector? = null, + extraContent: (@Composable () -> Unit)? = null, +) { + AlertDialog( + onDismissRequest = onDismiss, + modifier = modifier, + title = { + if (icon != null) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = icon, + contentDescription = null, + tint = + if (isDestructive) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + }, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(title) + } + } else { + Text(title) + } + }, + text = { + if (extraContent != null) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(message) + extraContent() + } + } else { + Text(message) + } + }, + confirmButton = { + if (isDestructive) { + Button( + onClick = onConfirm, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text(confirmText) + } + } else { + Button(onClick = onConfirm) { + Text(confirmText) + } + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(dismissText) + } + }, + ) +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/ViewModelExt.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/ViewModelExt.kt index 0ceb4520..56d2473e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/common/ViewModelExt.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/ViewModelExt.kt @@ -34,6 +34,33 @@ inline fun ViewModel.safeLaunchLoad( } } +/** + * Executes a mutating API call inside viewModelScope with onStart, onSuccess, onError, and onComplete lifecycle callbacks. + * Like safeLaunchLoad, avoids Dispatchers.IO hop to protect tests from dispatcher poisoning. + */ +inline fun ViewModel.safeLaunchAction( + currentJob: Job? = null, + crossinline onStart: () -> Unit = {}, + crossinline apiCall: suspend () -> NetworkResult, + crossinline onSuccess: (T) -> Unit = {}, + crossinline onError: (String) -> Unit = {}, + crossinline onComplete: () -> Unit = {}, +): Job { + if (currentJob?.isActive == true) return currentJob + onStart() + return viewModelScope.launch { + try { + val result = apiCall() + when (result) { + is NetworkResult.Success -> onSuccess(result.data) + is NetworkResult.Failure -> onError(result.error.message) + } + } finally { + onComplete() + } + } +} + /** * Collect a gateway change event ([com.m57.hermescontrol.data.ws.ChangeEvents]) * from [ChangeEventHub] and run a **silent** refresh: no loading spinner, no diff --git a/app/src/test/java/com/m57/hermescontrol/ui/common/ViewModelExtTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/common/ViewModelExtTest.kt new file mode 100644 index 00000000..962e6b37 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/common/ViewModelExtTest.kt @@ -0,0 +1,74 @@ +package com.m57.hermescontrol.ui.common + +import androidx.lifecycle.ViewModel +import com.m57.hermescontrol.data.remote.NetworkError +import com.m57.hermescontrol.data.remote.NetworkResult +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ViewModelExtTest { + private val testDispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private class TestViewModel : ViewModel() + + @Test + fun `safeLaunchAction triggers onStart, onSuccess and onComplete on success`() = + runTest(testDispatcher) { + val vm = TestViewModel() + var started = false + var successResult: String? = null + var completed = false + + vm.safeLaunchAction( + onStart = { started = true }, + apiCall = { NetworkResult.Success("ok") }, + onSuccess = { successResult = it }, + onError = {}, + onComplete = { completed = true }, + ) + + assertTrue(started) + testDispatcher.scheduler.advanceUntilIdle() + assertEquals("ok", successResult) + assertTrue(completed) + } + + @Test + fun `safeLaunchAction triggers onError and onComplete on failure`() = + runTest(testDispatcher) { + val vm = TestViewModel() + var errorMessage: String? = null + var completed = false + + vm.safeLaunchAction( + apiCall = { NetworkResult.Failure(NetworkError.Unknown("network error", Exception("boom"))) }, + onSuccess = {}, + onError = { errorMessage = it }, + onComplete = { completed = true }, + ) + + testDispatcher.scheduler.advanceUntilIdle() + assertEquals("network error", errorMessage) + assertTrue(completed) + } +}