refactor: extract common ui components

This commit is contained in:
2026-06-10 19:30:44 +02:00
parent b90fdca83a
commit 3c9b480a9e
12 changed files with 279 additions and 313 deletions
@@ -0,0 +1,41 @@
package com.bitcointxoko.gudariwallet.ui.common
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
@Composable
internal fun AmountWithFiatColumn(
amountSat : Long,
isOutgoing : Boolean,
amountColor : Color,
fiatLine : String?,
style : TextStyle = MaterialTheme.typography.bodyMedium,
horizontalAlignment: Alignment.Horizontal = Alignment.End
) {
val prefix = if (isOutgoing) "" else "+"
Column(horizontalAlignment = horizontalAlignment) {
Text(
text = "$prefix$amountSat sats",
style = style.copy(fontFamily = FontFamily.Monospace),
color = amountColor
)
if (fiatLine != null) {
Spacer(Modifier.height(if (horizontalAlignment == Alignment.CenterHorizontally) 4.dp else 1.dp))
Text(
text = fiatLine,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f)
)
}
}
}
@@ -0,0 +1,48 @@
package com.bitcointxoko.gudariwallet.ui.common
import android.content.ClipData
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import androidx.compose.runtime.rememberCoroutineScope
/**
* A small icon button that copies [value] to the clipboard under [label].
*/
@Composable
fun CopyIconButton(
label : String,
value : String,
size : Dp = 32.dp,
iconSize : Dp = 16.dp,
tint : Color = Color.Unspecified
) {
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
IconButton(
onClick = {
scope.launch {
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(label, value)))
}
},
modifier = Modifier.size(size)
) {
Icon(
imageVector = Icons.Outlined.ContentCopy,
contentDescription = "Copy $label",
modifier = Modifier.size(iconSize),
tint = if (tint == Color.Unspecified)
MaterialTheme.colorScheme.onSurfaceVariant
else tint
)
}
}
@@ -0,0 +1,93 @@
package com.bitcointxoko.gudariwallet.ui.common
import androidx.compose.runtime.Composable
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@Composable
fun DetailRow(
label : String,
value : String,
subtitle : String? = null, // ← from components
monospace : Boolean = false, // ← from history
copyable : Boolean = false, // ← from history
onCopy : (() -> Unit)? = null, // ← from history
maxLines : Int = Int.MAX_VALUE,
valueColor : Color = Color.Unspecified,
textDecoration : TextDecoration? = null,
onValueClick : (() -> Unit)? = null,
trailingContent: (@Composable () -> Unit)? = null
) {
val baseStyle = MaterialTheme.typography.bodySmall
Row(
modifier = Modifier
.fillMaxWidth()
.then(if (copyable && onCopy != null) Modifier.clickable(onClick = onCopy) else Modifier)
.padding(vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
Text(
text = label,
style = baseStyle,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(0.35f)
)
Spacer(Modifier.width(8.dp))
Row(
modifier = Modifier.weight(0.65f),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f, fill = false)) {
Text(
text = value,
style = baseStyle.copy(
fontFamily = if (monospace) FontFamily.Monospace else FontFamily.Default,
textDecoration = textDecoration
),
color = valueColor,
maxLines = maxLines,
overflow = TextOverflow.Ellipsis,
modifier = if (onValueClick != null) Modifier.clickable(onClick = onValueClick)
else Modifier
)
if (subtitle != null) {
Text(
text = subtitle,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (copyable) {
Spacer(Modifier.width(4.dp))
Icon(
imageVector = Icons.Outlined.ContentCopy,
contentDescription = "Copy $label",
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
trailingContent?.invoke()
}
}
}
@@ -0,0 +1,32 @@
package com.bitcointxoko.gudariwallet.ui.common
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun DetailSection(
title : String,
content: @Composable ColumnScope.() -> Unit
) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.height(12.dp))
content()
}
}
}
@@ -0,0 +1,53 @@
package com.bitcointxoko.gudariwallet.ui.common
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
@Composable
internal fun StatusChip(status: String) {
val semantic = semanticColors()
val strings = LocalAppStrings.current
data class ChipStyle(
val containerColor: Color,
val contentColor: Color,
val label: String,
)
val style = when (status.lowercase()) {
"success", "complete", "paid" -> ChipStyle(
semantic.successContainer, semantic.onSuccessContainer, strings.statusSuccess
)
"pending", "in_flight", "inflight" -> ChipStyle(
semantic.warningContainer, semantic.onWarningContainer, strings.statusPending
)
"failed", "error", "expired" -> ChipStyle(
semantic.errorContainer, semantic.onErrorContainer, strings.statusFailed
)
else -> ChipStyle(
MaterialTheme.colorScheme.surfaceVariant,
MaterialTheme.colorScheme.onSurfaceVariant,
strings.statusUnknown,
)
}
Surface(
shape = MaterialTheme.shapes.small,
color = style.containerColor,
contentColor = style.contentColor,
) {
Text(
text = style.label,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
)
}
}
@@ -1,106 +0,0 @@
package com.bitcointxoko.gudariwallet.ui.components
import android.content.ClipData
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import androidx.compose.runtime.rememberCoroutineScope
/**
* A two-column label/value row used throughout the send flow.
*/
@Composable
fun DetailRow(
label : String,
value : String,
subtitle : String? = null,
valueColor : Color = Color.Unspecified,
textDecoration : TextDecoration? = null,
onValueClick : (() -> Unit)? = null,
trailingContent: (@Composable () -> Unit)? = null
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(0.35f)
)
Row(
modifier = Modifier
.weight(0.65f)
.padding(start = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f, fill = false)) {
Text(
text = value,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = valueColor,
style = LocalTextStyle.current.copy(textDecoration = textDecoration),
modifier = if (onValueClick != null) Modifier.clickable(onClick = onValueClick)
else Modifier
)
if (subtitle != null) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
trailingContent?.invoke()
}
}
}
/**
* A small icon button that copies [value] to the clipboard under [label].
*/
@Composable
fun CopyIconButton(
label : String,
value : String,
size : Dp = 32.dp,
iconSize : Dp = 16.dp,
tint : Color = Color.Unspecified
) {
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
IconButton(
onClick = {
scope.launch {
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(label, value)))
}
},
modifier = Modifier.size(size)
) {
Icon(
imageVector = Icons.Outlined.ContentCopy,
contentDescription = "Copy $label",
modifier = Modifier.size(iconSize),
tint = if (tint == Color.Unspecified)
MaterialTheme.colorScheme.onSurfaceVariant
else tint
)
}
}
@@ -1,185 +0,0 @@
package com.bitcointxoko.gudariwallet.ui.history
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
// ── Amount + fiat column ─────────────────────────────────────────────────────
@Composable
internal fun AmountWithFiatColumn(
amountSat : Long,
isOutgoing : Boolean,
amountColor : Color,
fiatLine : String?,
style : TextStyle = MaterialTheme.typography.bodyMedium,
horizontalAlignment: Alignment.Horizontal = Alignment.End
) {
val prefix = if (isOutgoing) "" else "+"
Column(horizontalAlignment = horizontalAlignment) {
Text(
text = "$prefix$amountSat sats",
style = style.copy(fontFamily = FontFamily.Monospace),
color = amountColor
)
if (fiatLine != null) {
Spacer(Modifier.height(if (horizontalAlignment == Alignment.CenterHorizontally) 4.dp else 1.dp))
Text(
text = fiatLine,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f)
)
}
}
}
// ── Status chip ──────────────────────────────────────────────────────────────
@Composable
internal fun StatusChip(status: String) {
val semantic = semanticColors()
val strings = LocalAppStrings.current // or however you access Lyricist strings
data class ChipStyle(
val containerColor: Color,
val contentColor: Color,
val label: String,
)
val style = when (status.lowercase()) {
"success", "complete", "paid" -> ChipStyle(
semantic.successContainer, semantic.onSuccessContainer, strings.statusSuccess
)
"pending", "in_flight", "inflight" -> ChipStyle(
semantic.warningContainer, semantic.onWarningContainer, strings.statusPending
)
"failed", "error", "expired" -> ChipStyle(
semantic.errorContainer, semantic.onErrorContainer, strings.statusFailed
)
else -> ChipStyle(
MaterialTheme.colorScheme.surfaceVariant,
MaterialTheme.colorScheme.onSurfaceVariant,
strings.statusUnknown,
)
}
Surface(
shape = MaterialTheme.shapes.small,
color = style.containerColor,
contentColor = style.contentColor,
) {
Text(
text = style.label,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
)
}
}
// ── Section card wrapper ─────────────────────────────────────────────────────
@Composable
internal fun DetailSection(
title : String,
content: @Composable ColumnScope.() -> Unit
) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.height(12.dp))
content()
}
}
}
// ── Label / value row ────────────────────────────────────────────────────────
@Composable
internal fun DetailRow(
label : String,
value : String,
monospace : Boolean = false,
copyable : Boolean = false,
onCopy : (() -> Unit)? = null,
maxLines : Int = Int.MAX_VALUE,
valueColor : Color = Color.Unspecified,
textDecoration : TextDecoration? = null,
onValueClick : (() -> Unit)? = null,
trailingContent: (@Composable () -> Unit)? = null
) {
Row(
modifier = Modifier
.fillMaxWidth()
.then(if (copyable && onCopy != null) Modifier.clickable(onClick = onCopy) else Modifier)
.padding(vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(0.35f)
)
Spacer(Modifier.width(8.dp))
Row(
modifier = Modifier.weight(0.65f),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = value,
style = if (monospace)
MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace, textDecoration = textDecoration)
else
MaterialTheme.typography.bodySmall.copy(textDecoration = textDecoration),
color = valueColor,
modifier = Modifier
.weight(1f, fill = false)
.then(if (onValueClick != null) Modifier.clickable(onClick = onValueClick) else Modifier),
maxLines = maxLines,
overflow = TextOverflow.Ellipsis
)
if (copyable) {
Spacer(Modifier.width(4.dp))
Icon(
imageVector = Icons.Default.ContentCopy,
contentDescription = "Copy $label",
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
trailingContent?.invoke()
}
}
}
@@ -45,6 +45,11 @@ import androidx.core.net.toUri
import com.bitcointxoko.gudariwallet.api.PaymentRecord import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.LocalAppStrings import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.ui.DetailState import com.bitcointxoko.gudariwallet.ui.DetailState
import com.bitcointxoko.gudariwallet.ui.common.AmountWithFiatColumn
import com.bitcointxoko.gudariwallet.ui.common.CopyIconButton
import com.bitcointxoko.gudariwallet.ui.common.DetailRow
import com.bitcointxoko.gudariwallet.ui.common.DetailSection
import com.bitcointxoko.gudariwallet.ui.common.StatusChip
import com.bitcointxoko.gudariwallet.util.feePpm import com.bitcointxoko.gudariwallet.util.feePpm
import com.bitcointxoko.gudariwallet.util.formatFiatForSats import com.bitcointxoko.gudariwallet.util.formatFiatForSats
import com.bitcointxoko.gudariwallet.util.formatTimestamp import com.bitcointxoko.gudariwallet.util.formatTimestamp
@@ -297,23 +302,7 @@ private fun PaymentDetailContent(
context.startActivity(Intent(Intent.ACTION_VIEW, ambossUrl.toUri())) context.startActivity(Intent(Intent.ACTION_VIEW, ambossUrl.toUri()))
}, },
trailingContent = { trailingContent = {
IconButton( CopyIconButton(label = "node_id", value = pubkey)
onClick = {
scope.launch {
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText("node_id", pubkey))
)
}
},
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Outlined.ContentCopy,
contentDescription = strings.detailCopyNodeId, // ← "Copy node ID"
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} }
) )
} }
@@ -24,6 +24,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.api.PaymentRecord import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.LocalAppStrings import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.ui.common.AmountWithFiatColumn
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
import com.bitcointxoko.gudariwallet.util.formatFiatForSats import com.bitcointxoko.gudariwallet.util.formatFiatForSats
import com.bitcointxoko.gudariwallet.util.formatTimestamp import com.bitcointxoko.gudariwallet.util.formatTimestamp
@@ -60,8 +60,8 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.LocalAppStrings import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.i18n.AppStrings import com.bitcointxoko.gudariwallet.i18n.AppStrings
import com.bitcointxoko.gudariwallet.ui.history.DetailRow import com.bitcointxoko.gudariwallet.ui.common.DetailRow
import com.bitcointxoko.gudariwallet.ui.history.DetailSection import com.bitcointxoko.gudariwallet.ui.common.DetailSection
import com.bitcointxoko.gudariwallet.util.formatEpoch import com.bitcointxoko.gudariwallet.util.formatEpoch
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -28,8 +28,8 @@ import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.ui.DecodedInvoice import com.bitcointxoko.gudariwallet.ui.DecodedInvoice
import com.bitcointxoko.gudariwallet.ui.SendState import com.bitcointxoko.gudariwallet.ui.SendState
import com.bitcointxoko.gudariwallet.ui.WalletViewModel import com.bitcointxoko.gudariwallet.ui.WalletViewModel
import com.bitcointxoko.gudariwallet.ui.components.CopyIconButton import com.bitcointxoko.gudariwallet.ui.common.CopyIconButton
import com.bitcointxoko.gudariwallet.ui.components.DetailRow import com.bitcointxoko.gudariwallet.ui.common.DetailRow
import com.bitcointxoko.gudariwallet.util.BiometricHelper import com.bitcointxoko.gudariwallet.util.BiometricHelper
import com.bitcointxoko.gudariwallet.util.fiatLabel import com.bitcointxoko.gudariwallet.util.fiatLabel
import com.bitcointxoko.gudariwallet.util.payingToLabel import com.bitcointxoko.gudariwallet.util.payingToLabel
@@ -36,7 +36,7 @@ import androidx.compose.ui.unit.dp
import androidx.core.net.toUri import androidx.core.net.toUri
import com.bitcointxoko.gudariwallet.api.RouteHintHop import com.bitcointxoko.gudariwallet.api.RouteHintHop
import com.bitcointxoko.gudariwallet.LocalAppStrings import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.ui.components.CopyIconButton import com.bitcointxoko.gudariwallet.ui.common.CopyIconButton
import com.bitcointxoko.gudariwallet.util.RiskLevel import com.bitcointxoko.gudariwallet.util.RiskLevel
import com.bitcointxoko.gudariwallet.util.RouteHintRisk import com.bitcointxoko.gudariwallet.util.RouteHintRisk
import com.bitcointxoko.gudariwallet.util.baseFeeLabel import com.bitcointxoko.gudariwallet.util.baseFeeLabel