Update bonded device item, handle confirm before delete bonded device item

This commit is contained in:
2026-06-06 10:48:21 +07:00
parent 9dcdf68c63
commit c462373762
2 changed files with 193 additions and 87 deletions
@@ -1,124 +1,225 @@
package com.digitoolsolutions.app.torquevaultkmp.screens.bonded.view
import androidx.compose.animation.core.spring
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.AnchoredDraggableState
import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.anchoredDraggable
import androidx.compose.foundation.gestures.animateTo
import androidx.compose.foundation.gestures.snapTo
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxHeight
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.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxDefaults
import androidx.compose.material3.SwipeToDismissBoxState
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import com.digitoolsolutions.app.torquevaultkmp.components.CircularIcon
import com.digitoolsolutions.app.torquevaultkmp.data.storage.db.Device
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
enum class DragValue {
Settled,
Revealed
}
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun BondedDeviceItem(device: Device, onDelete: (Device) -> Unit, modifier: Modifier = Modifier) {
val swipeToDismissBoxState = rememberSwipeToDismissBoxState(
SwipeToDismissBoxValue.Settled,
SwipeToDismissBoxDefaults.positionalThreshold
)
var showMenu by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
if (swipeToDismissBoxState.currentValue == SwipeToDismissBoxValue.EndToStart) {
LaunchedEffect(Unit) {
onDelete(device)
swipeToDismissBoxState.snapTo(SwipeToDismissBoxValue.Settled)
}
}
val density = LocalDensity.current
val actionSize = 80.dp
val actionSizePx = with(density) { actionSize.toPx() }
SwipeToDismissBox(
state = swipeToDismissBoxState,
modifier = modifier.fillMaxSize().padding(vertical = 4.dp),
enableDismissFromStartToEnd = false,
backgroundContent = {
when (swipeToDismissBoxState.dismissDirection) {
SwipeToDismissBoxValue.StartToEnd -> {
SwipeToDismissBackgroundItem(label = "Delete", contentAlignment = Alignment.CenterStart, state = swipeToDismissBoxState)
}
SwipeToDismissBoxValue.EndToStart -> {
SwipeToDismissBackgroundItem(label = "Delete", state = swipeToDismissBoxState)
}
SwipeToDismissBoxValue.Settled -> {}
}
}
) {
OutlinedCard(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Row (
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
CircularIcon( imageVector = Icons.Default.Bluetooth, backgroundColor = Color(0xFF0082FC))
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(
text = device.name,
style = MaterialTheme.typography.titleMedium
)
Text(
text = device.id,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
@Composable
internal fun SwipeToDismissBackgroundItem(
label: String,
contentAlignment: Alignment = Alignment.CenterEnd,
icon: @Composable () -> Unit = {
Icon(
imageVector = Icons.Default.DeleteSweep,
contentDescription = "Delete",
tint = Color.White
val state = remember {
AnchoredDraggableState(
initialValue = DragValue.Settled
)
},
state: SwipeToDismissBoxState
) {
}
SideEffect {
state.updateAnchors(
DraggableAnchors {
DragValue.Settled at 0f
DragValue.Revealed at -actionSizePx
}
)
}
if (showDeleteDialog) {
AlertDialog(
onDismissRequest = {
showDeleteDialog = false
scope.launch { state.animateTo(DragValue.Settled) }
},
title = { Text("Delete Device") },
text = { Text("Are you sure want to delete bonded device ${device.name}?") },
confirmButton = {
TextButton(
onClick = {
onDelete(device)
showDeleteDialog = false
scope.launch { state.snapTo(DragValue.Settled) }
}
) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = {
showDeleteDialog = false
scope.launch { state.animateTo(DragValue.Settled) }
}) {
Text("Cancel")
}
}
)
}
Box(
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(12.dp))
.background(lerp(Color.LightGray, Color.Red, state.progress))
.padding(end = 20.dp),
contentAlignment = contentAlignment
) {
Row {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
modifier = modifier
.fillMaxWidth()
.height(IntrinsicSize.Min)
.padding(vertical = 4.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surface)
.border(
BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
RoundedCornerShape(8.dp)
)
Spacer(modifier = Modifier.width(8.dp))
icon()
) {
// Action Layer (Background)
Box(
modifier = Modifier
.align(Alignment.CenterEnd)
.fillMaxHeight()
.width(actionSize)
.background(MaterialTheme.colorScheme.errorContainer)
.clickable { showDeleteDialog = true },
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = Icons.Default.DeleteSweep,
contentDescription = "Delete",
tint = MaterialTheme.colorScheme.error
)
Text(
text = "Delete",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error
)
}
}
// Content Layer (Foreground)
Box(
modifier = Modifier
.fillMaxWidth()
.offset {
IntOffset(
x = state.offset.roundToInt(),
y = 0
)
}
.anchoredDraggable(state, Orientation.Horizontal)
) {
OutlinedCard(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = { /* Action on click if needed */ },
onLongClick = { showMenu = true }
),
shape = RectangleShape,
colors = CardDefaults.outlinedCardColors(
containerColor = if (showMenu) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.surface
),
border = CardDefaults.outlinedCardBorder(enabled = true),
elevation = CardDefaults.cardElevation(defaultElevation = if (showMenu) 8.dp else 4.dp)
) {
Row (
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
CircularIcon( imageVector = Icons.Default.Bluetooth, backgroundColor = Color(0xFF0082FC))
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(
text = device.name,
style = MaterialTheme.typography.titleMedium
)
Text(
text = device.id,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
DropdownMenuItem(
text = { Text("Delete") },
onClick = {
showMenu = false
showDeleteDialog = true
},
leadingIcon = {
Icon(
imageVector = Icons.Default.DeleteSweep,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
}
)
}
}
}
}
@@ -50,7 +50,7 @@ class ScannerViewModel(
) : ViewModel() {
companion object {
private val SCAN_TIMEOUT = 1.seconds
private val CONNECTION_TIMEOUT = 15.seconds
private val CONNECTION_TIMEOUT = 5.seconds
}
private val _devices = MutableStateFlow<List<Advertisement>>(emptyList())
val devices: StateFlow<List<Advertisement>> = _devices.asStateFlow()
@@ -272,6 +272,7 @@ class ScannerViewModel(
sendWorkOrdersToDevice(peripheral,orders)
} catch (e: Exception) {
Napier.e("Failed to load work orders", e)
logRepository.addLog(title = "HTTP", content = "${e.message}".substringBefore("[url="))
}
}
}
@@ -312,6 +313,7 @@ class ScannerViewModel(
} catch (e: Exception) {
Napier.e(">>>>> Failed to handle message: $msg", e)
sendCommand(peripheral, Helper.CMD_NOT_AVAILABLE_WO)
logRepository.addLog(title = "HTTP", content = "${e.message}".substringBefore("[url="))
}
}
else -> {
@@ -332,6 +334,7 @@ class ScannerViewModel(
scannerRepository.sendMeasurementResult(woId, dto)
} catch (e: Exception) {
Napier.e("An error occurred while sending the measurement", e, tag = "ScannerViewModel")
logRepository.addLog(title = "HTTP", content = "${e.message}".substringBefore("[url="))
} finally {
// Currently, always send UPLOADED_WO regardless of success or failure
sendCommand(peripheral, Helper.CMD_UPLOADED_WO)
@@ -342,6 +345,7 @@ class ScannerViewModel(
scannerRepository.sendMeasurementResult(woId, dto, true)
} catch (e: Exception) {
Napier.e("An error occurred while sending the re-measurement", e, tag = "ScannerViewModel")
logRepository.addLog(title = "HTTP", content = "${e.message}".substringBefore("[url="))
} finally {
// Currently, always send UPLOADED_WO regardless of success or failure
sendCommand(peripheral, Helper.CMD_UPLOADED_WO)
@@ -352,6 +356,7 @@ class ScannerViewModel(
scannerRepository.sendCancelWorkOrder(woId, dto)
} catch (e: Exception) {
Napier.e("An error occurred while cancel work order", e, tag = "ScannerViewModel")
logRepository.addLog(title = "HTTP", content = "${e.message}".substringBefore("[url="))
} finally {
// Currently, always send UPLOADED_WO regardless of success or failure
sendCommand(peripheral, Helper.CMD_UPLOADED_WO)