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:
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user