diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcConnectionRow.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcConnectionRow.kt new file mode 100644 index 0000000..e7886c8 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcConnectionRow.kt @@ -0,0 +1,147 @@ +package com.bitcointxoko.gudariwallet.ui.nwc + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Cable +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.bitcointxoko.gudariwallet.api.NwcGetResponse +import com.bitcointxoko.gudariwallet.ui.theme.semanticColors +import java.time.Instant + +@Composable +internal fun NwcConnectionRow( + connection : NwcGetResponse, + isDeleting : Boolean, + onDeleteConfirm : () -> Unit, + onConnectionClick: () -> Unit, + modifier : Modifier = Modifier +) { + val key = connection.data + val expired = key.expires_at > 0 && key.expires_at < Instant.now().epochSecond + val semantic = semanticColors() + + // ── Swipe-to-delete state ──────────────────────────────────────────────── + val dismissState = rememberSwipeToDismissBoxState( + confirmValueChange = { + // Always reject — we never want the row to stay dismissed. + // The dialog is triggered via LaunchedEffect on targetValue below. + false + }, + positionalThreshold = { totalDistance -> totalDistance * 0.4f } + ) + + // targetValue changes to EndToStart as soon as the drag crosses the + // threshold, even though confirmValueChange returns false and currentValue + // stays at Settled. Use a flag so we only fire once per swipe gesture. + var dialogTriggered by remember { mutableStateOf(false) } + + LaunchedEffect(dismissState.targetValue) { + if (dismissState.targetValue == SwipeToDismissBoxValue.EndToStart + && !isDeleting + && !dialogTriggered + ) { + dialogTriggered = true + onDeleteConfirm() + // Snap the row back to its resting position. + dismissState.reset() + } + // Reset the flag once the swipe returns to Settled so the + // gesture can be triggered again if the user cancels and re-swipes. + if (dismissState.targetValue == SwipeToDismissBoxValue.Settled) { + dialogTriggered = false + } + } + + SwipeToDismissBox( + state = dismissState, + modifier = modifier, + enableDismissFromStartToEnd = false, + enableDismissFromEndToStart = !isDeleting, + backgroundContent = { + val fraction = dismissState.progress + val bgAlpha = (fraction / 0.4f).coerceIn(0f, 1f) + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.error.copy(alpha = bgAlpha)) + .padding(end = 24.dp), + contentAlignment = Alignment.CenterEnd + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Delete connection", + tint = MaterialTheme.colorScheme.onError.copy(alpha = bgAlpha) + ) + } + } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .clickable(onClick = onConnectionClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Cable, + contentDescription = null, + tint = if (expired) + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + else + MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = key.description.ifBlank { "Unnamed connection" }, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (expired) + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + else + MaterialTheme.colorScheme.onSurface + ) + Spacer(Modifier.height(2.dp)) + Text( + text = if (key.last_used <= 0L) "Never used" + else "Last used ${formatEpoch(key.last_used)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + if (expired) { + Spacer(Modifier.height(4.dp)) + Surface( + shape = MaterialTheme.shapes.extraSmall, + color = semantic.errorContainer, + contentColor = semantic.onErrorContainer + ) { + Text( + text = "Expired", + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + } + } + Spacer(Modifier.width(8.dp)) + Icon( + imageVector = Icons.AutoMirrored.Default.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.size(16.dp) + ) + } + } +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcScreen.kt index 349fc9c..d59968b 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcScreen.kt @@ -5,8 +5,6 @@ import android.content.ActivityNotFoundException import android.content.Intent import android.view.WindowManager import android.widget.Toast -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -15,12 +13,9 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Cable import androidx.compose.material.icons.filled.CalendarToday import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Schedule import androidx.compose.material3.* import androidx.compose.material3.pulltorefresh.PullToRefreshBox @@ -29,15 +24,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.bitcointxoko.gudariwallet.api.NwcGetResponse import com.bitcointxoko.gudariwallet.api.NwcNewBudget import com.bitcointxoko.gudariwallet.ui.receive.QrDisplayCard -import com.bitcointxoko.gudariwallet.ui.theme.semanticColors import java.text.SimpleDateFormat -import java.time.Instant import java.util.Calendar import java.util.Locale import androidx.core.net.toUri @@ -609,135 +600,4 @@ private fun NwcPairingUrlDialog( TextButton(onClick = onDismiss) { Text("Done") } } ) -} - - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun NwcConnectionRow( - connection : NwcGetResponse, - isDeleting : Boolean, - onDeleteConfirm : () -> Unit, - onConnectionClick: () -> Unit, - modifier : Modifier = Modifier -) { - val key = connection.data - val expired = key.expires_at > 0 && key.expires_at < Instant.now().epochSecond - val semantic = semanticColors() - - // ── Swipe-to-delete state ──────────────────────────────────────────────── - val dismissState = rememberSwipeToDismissBoxState( - confirmValueChange = { - // Always reject — we never want the row to stay dismissed. - // The dialog is triggered via LaunchedEffect on targetValue below. - false - }, - positionalThreshold = { totalDistance -> totalDistance * 0.4f } - ) - - // targetValue changes to EndToStart as soon as the drag crosses the - // threshold, even though confirmValueChange returns false and currentValue - // stays at Settled. Use a flag so we only fire once per swipe gesture. - var dialogTriggered by remember { mutableStateOf(false) } - - LaunchedEffect(dismissState.targetValue) { - if (dismissState.targetValue == SwipeToDismissBoxValue.EndToStart - && !isDeleting - && !dialogTriggered - ) { - dialogTriggered = true - onDeleteConfirm() - // Snap the row back to its resting position. - dismissState.reset() - } - // Reset the flag once the swipe returns to Settled so the - // gesture can be triggered again if the user cancels and re-swipes. - if (dismissState.targetValue == SwipeToDismissBoxValue.Settled) { - dialogTriggered = false - } - } - - SwipeToDismissBox( - state = dismissState, - modifier = modifier, - enableDismissFromStartToEnd = false, - enableDismissFromEndToStart = !isDeleting, - backgroundContent = { - val fraction = dismissState.progress - val bgAlpha = (fraction / 0.4f).coerceIn(0f, 1f) - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.error.copy(alpha = bgAlpha)) - .padding(end = 24.dp), - contentAlignment = Alignment.CenterEnd - ) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = "Delete connection", - tint = MaterialTheme.colorScheme.onError.copy(alpha = bgAlpha) - ) - } - } - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surface) - .clickable(onClick = onConnectionClick) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.Cable, - contentDescription = null, - tint = if (expired) - MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) - else - MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp) - ) - Spacer(Modifier.width(12.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = key.description.ifBlank { "Unnamed connection" }, - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = if (expired) - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) - else - MaterialTheme.colorScheme.onSurface - ) - Spacer(Modifier.height(2.dp)) - Text( - text = if (key.last_used <= 0L) "Never used" - else "Last used ${formatEpoch(key.last_used)}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - if (expired) { - Spacer(Modifier.height(4.dp)) - Surface( - shape = MaterialTheme.shapes.extraSmall, - color = semantic.errorContainer, - contentColor = semantic.onErrorContainer - ) { - Text( - text = "Expired", - style = MaterialTheme.typography.labelSmall, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) - ) - } - } - } - Spacer(Modifier.width(8.dp)) - Icon( - imageVector = Icons.AutoMirrored.Default.KeyboardArrowRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), - modifier = Modifier.size(16.dp) - ) - } - } } \ No newline at end of file