diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bonded/view/BondedDeviceItem.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bonded/view/BondedDeviceItem.kt index e621940..deb9675 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bonded/view/BondedDeviceItem.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bonded/view/BondedDeviceItem.kt @@ -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 + ) + } + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/scanner/ScannerViewModel.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/scanner/ScannerViewModel.kt index baf8128..62caabe 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/scanner/ScannerViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/scanner/ScannerViewModel.kt @@ -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>(emptyList()) val devices: StateFlow> = _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)