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])
|
* Collect a gateway change event ([com.m57.hermescontrol.data.ws.ChangeEvents])
|
||||||
* from [ChangeEventHub] and run a **silent** refresh: no loading spinner, no
|
* 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