feat(common): add ConfirmDialog and safeLaunchAction helper

Add reusable ConfirmDialog with destructive action styling.
Add safeLaunchAction to ViewModelExt for mutating calls with unit tests.
This commit is contained in:
Hy4ri committed 2026-09-11 18:24:00 +03:00
1 parent 70f331b931
commit e6e79be10d
3 files changed
+202

No files matched your search

@@ -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)
}
},
)
}
@@ -34,6 +34,33 @@ inline fun <T> 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 <T> ViewModel.safeLaunchAction(
currentJob: Job? = null,
crossinline onStart: () -> Unit = {},
crossinline apiCall: suspend () -> NetworkResult<T>,
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
@@ -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)
}
}