refactor: extract connection row from nwc

This commit is contained in:
2026-06-10 16:39:28 +02:00
parent 28b366d81f
commit a22e5e4628
2 changed files with 147 additions and 140 deletions
@@ -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)
)
}
}
}
@@ -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
@@ -610,134 +601,3 @@ private fun NwcPairingUrlDialog(
}
)
}
@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)
)
}
}
}