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 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.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.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bluetooth import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.DeleteSweep import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CardDefaults 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.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard 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.Text
import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.digitoolsolutions.app.torquevaultkmp.components.CircularIcon import com.digitoolsolutions.app.torquevaultkmp.components.CircularIcon
import com.digitoolsolutions.app.torquevaultkmp.data.storage.db.Device 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 @Composable
fun BondedDeviceItem(device: Device, onDelete: (Device) -> Unit, modifier: Modifier = Modifier) { fun BondedDeviceItem(device: Device, onDelete: (Device) -> Unit, modifier: Modifier = Modifier) {
val swipeToDismissBoxState = rememberSwipeToDismissBoxState( var showMenu by remember { mutableStateOf(false) }
SwipeToDismissBoxValue.Settled, var showDeleteDialog by remember { mutableStateOf(false) }
SwipeToDismissBoxDefaults.positionalThreshold val scope = rememberCoroutineScope()
)
if (swipeToDismissBoxState.currentValue == SwipeToDismissBoxValue.EndToStart) { val density = LocalDensity.current
LaunchedEffect(Unit) { val actionSize = 80.dp
onDelete(device) val actionSizePx = with(density) { actionSize.toPx() }
swipeToDismissBoxState.snapTo(SwipeToDismissBoxValue.Settled)
}
}
SwipeToDismissBox( val state = remember {
state = swipeToDismissBoxState, AnchoredDraggableState(
modifier = modifier.fillMaxSize().padding(vertical = 4.dp), initialValue = DragValue.Settled
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
) )
}, }
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( Box(
modifier = Modifier modifier = modifier
.fillMaxSize() .fillMaxWidth()
.clip(RoundedCornerShape(12.dp)) .height(IntrinsicSize.Min)
.background(lerp(Color.LightGray, Color.Red, state.progress)) .padding(vertical = 4.dp)
.padding(end = 20.dp), .clip(RoundedCornerShape(8.dp))
contentAlignment = contentAlignment .background(MaterialTheme.colorScheme.surface)
) { .border(
Row { BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
Text( RoundedCornerShape(8.dp)
text = label,
style = MaterialTheme.typography.bodyMedium,
) )
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() { ) : ViewModel() {
companion object { companion object {
private val SCAN_TIMEOUT = 1.seconds 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()) private val _devices = MutableStateFlow<List<Advertisement>>(emptyList())
val devices: StateFlow<List<Advertisement>> = _devices.asStateFlow() val devices: StateFlow<List<Advertisement>> = _devices.asStateFlow()
@@ -272,6 +272,7 @@ class ScannerViewModel(
sendWorkOrdersToDevice(peripheral,orders) sendWorkOrdersToDevice(peripheral,orders)
} catch (e: Exception) { } catch (e: Exception) {
Napier.e("Failed to load work orders", e) 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) { } catch (e: Exception) {
Napier.e(">>>>> Failed to handle message: $msg", e) Napier.e(">>>>> Failed to handle message: $msg", e)
sendCommand(peripheral, Helper.CMD_NOT_AVAILABLE_WO) sendCommand(peripheral, Helper.CMD_NOT_AVAILABLE_WO)
logRepository.addLog(title = "HTTP", content = "${e.message}".substringBefore("[url="))
} }
} }
else -> { else -> {
@@ -332,6 +334,7 @@ class ScannerViewModel(
scannerRepository.sendMeasurementResult(woId, dto) scannerRepository.sendMeasurementResult(woId, dto)
} catch (e: Exception) { } catch (e: Exception) {
Napier.e("An error occurred while sending the measurement", e, tag = "ScannerViewModel") Napier.e("An error occurred while sending the measurement", e, tag = "ScannerViewModel")
logRepository.addLog(title = "HTTP", content = "${e.message}".substringBefore("[url="))
} finally { } finally {
// Currently, always send UPLOADED_WO regardless of success or failure // Currently, always send UPLOADED_WO regardless of success or failure
sendCommand(peripheral, Helper.CMD_UPLOADED_WO) sendCommand(peripheral, Helper.CMD_UPLOADED_WO)
@@ -342,6 +345,7 @@ class ScannerViewModel(
scannerRepository.sendMeasurementResult(woId, dto, true) scannerRepository.sendMeasurementResult(woId, dto, true)
} catch (e: Exception) { } catch (e: Exception) {
Napier.e("An error occurred while sending the re-measurement", e, tag = "ScannerViewModel") Napier.e("An error occurred while sending the re-measurement", e, tag = "ScannerViewModel")
logRepository.addLog(title = "HTTP", content = "${e.message}".substringBefore("[url="))
} finally { } finally {
// Currently, always send UPLOADED_WO regardless of success or failure // Currently, always send UPLOADED_WO regardless of success or failure
sendCommand(peripheral, Helper.CMD_UPLOADED_WO) sendCommand(peripheral, Helper.CMD_UPLOADED_WO)
@@ -352,6 +356,7 @@ class ScannerViewModel(
scannerRepository.sendCancelWorkOrder(woId, dto) scannerRepository.sendCancelWorkOrder(woId, dto)
} catch (e: Exception) { } catch (e: Exception) {
Napier.e("An error occurred while cancel work order", e, tag = "ScannerViewModel") Napier.e("An error occurred while cancel work order", e, tag = "ScannerViewModel")
logRepository.addLog(title = "HTTP", content = "${e.message}".substringBefore("[url="))
} finally { } finally {
// Currently, always send UPLOADED_WO regardless of success or failure // Currently, always send UPLOADED_WO regardless of success or failure
sendCommand(peripheral, Helper.CMD_UPLOADED_WO) sendCommand(peripheral, Helper.CMD_UPLOADED_WO)