refactor: extract HistoryScreen into separate components
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -22,8 +22,6 @@ import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// ── Filter model ──────────────────────────────────────────────────────────────
|
||||
|
||||
data class PaymentFilter(
|
||||
val searchQuery : String = "",
|
||||
val direction : DirectionFilter = DirectionFilter.ALL,
|
||||
@@ -37,8 +35,7 @@ data class PaymentFilter(
|
||||
)
|
||||
|
||||
val PaymentFilter.isActive: Boolean
|
||||
get() = searchQuery.isNotBlank()
|
||||
|| direction != DirectionFilter.ALL
|
||||
get() = direction != DirectionFilter.ALL
|
||||
|| statuses.isNotEmpty()
|
||||
|| types.isNotEmpty()
|
||||
|| minAmountSat != null
|
||||
@@ -50,8 +47,6 @@ enum class DirectionFilter { ALL, OUTGOING, INCOMING }
|
||||
enum class StatusFilter { COMPLETED, PENDING, FAILED }
|
||||
enum class DatePreset { THIS_WEEK, THIS_MONTH, THIS_YEAR }
|
||||
|
||||
|
||||
// ── ViewModel ─────────────────────────────────────────────────────────────────
|
||||
@OptIn(FlowPreview::class)
|
||||
class HistoryViewModel(
|
||||
private val repo : WalletRepository,
|
||||
@@ -65,7 +60,6 @@ class HistoryViewModel(
|
||||
val state: StateFlow<HistoryState> = _state
|
||||
|
||||
// ── Filter state (search is just another filter field) ────────────────────
|
||||
|
||||
private val _filter = MutableStateFlow(PaymentFilter())
|
||||
val filter: StateFlow<PaymentFilter> = _filter
|
||||
|
||||
@@ -90,9 +84,6 @@ class HistoryViewModel(
|
||||
fun setAmountFilter(min: Long?, max: Long?) {
|
||||
_filter.update { it.copy(minAmountSat = min, maxAmountSat = max) }
|
||||
}
|
||||
fun clearStatusFilter() { _filter.update { it.copy(statuses = emptySet()) } }
|
||||
fun clearTypeFilter() { _filter.update { it.copy(types = emptySet()) } }
|
||||
|
||||
fun setDateFilter(min: Long?, max: Long?, preset: DatePreset? = null) {
|
||||
_filter.update { it.copy(minCreatedAt = min, maxCreatedAt = max, datePreset = preset) }
|
||||
}
|
||||
@@ -193,56 +184,33 @@ class HistoryViewModel(
|
||||
}
|
||||
|
||||
// ── filteredState ─────────────────────────────────────────────────────────
|
||||
val filteredState: StateFlow<HistoryState> = combine(
|
||||
_state, _filter, _roomResults
|
||||
) { s, f, roomResults ->
|
||||
|
||||
val filteredState = combine(_state, _filter, _roomResults) { s, f, roomResults ->
|
||||
if (s !is HistoryState.Success) return@combine s
|
||||
|
||||
// ── Path A: pure in-memory ────────────────────────────────────────────
|
||||
if (roomResults == null) {
|
||||
Log.d(TAG, "FILTER [IN-MEMORY] filteredState: base=${s.payments.size} payments")
|
||||
val filtered = s.payments
|
||||
.distinctBy { it.paymentHash }
|
||||
.let { list ->
|
||||
when (f.direction) {
|
||||
DirectionFilter.ALL -> list
|
||||
DirectionFilter.OUTGOING -> list.filter { it.isOutgoing }
|
||||
DirectionFilter.INCOMING -> list.filter { !it.isOutgoing }
|
||||
s.copy(payments = s.payments.applyInMemoryFilters(f))
|
||||
} else {
|
||||
s.copy(payments = roomResults.applyInMemoryFilters(f), canLoadMore = false)
|
||||
}
|
||||
}
|
||||
.let { list ->
|
||||
if (f.types.isEmpty()) list
|
||||
else list.filter { it.extra?.tag in f.types }
|
||||
}
|
||||
Log.d(TAG, "FILTER [IN-MEMORY] filteredState: after filters → ${filtered.size} shown")
|
||||
return@combine s.copy(payments = filtered)
|
||||
}
|
||||
|
||||
// ── Path B: Room results — apply direction + type in-memory ───────────
|
||||
Log.d(TAG, "FILTER [ROOM ] filteredState: base=${roomResults.size} from Room")
|
||||
val filtered = roomResults
|
||||
.distinctBy { it.paymentHash }
|
||||
.let { list ->
|
||||
when (f.direction) {
|
||||
DirectionFilter.ALL -> list
|
||||
DirectionFilter.OUTGOING -> list.filter { it.isOutgoing }
|
||||
DirectionFilter.INCOMING -> list.filter { !it.isOutgoing }
|
||||
}
|
||||
}
|
||||
.let { list ->
|
||||
if (f.types.isEmpty()) list
|
||||
else list.filter { it.extra?.tag in f.types }
|
||||
}
|
||||
|
||||
Log.d(TAG, "FILTER [ROOM ] filteredState: after dir+type → ${filtered.size} shown")
|
||||
s.copy(payments = filtered, canLoadMore = false)
|
||||
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, HistoryState.Loading)
|
||||
|
||||
private var currentOffset = 0
|
||||
private var loadJob: Job? = null
|
||||
|
||||
private fun List<PaymentRecord>.applyInMemoryFilters(f: PaymentFilter) =
|
||||
distinctBy { it.paymentHash }
|
||||
.let { list ->
|
||||
when (f.direction) {
|
||||
DirectionFilter.ALL -> list
|
||||
DirectionFilter.OUTGOING -> list.filter { it.isOutgoing }
|
||||
DirectionFilter.INCOMING -> list.filter { !it.isOutgoing }
|
||||
}
|
||||
}
|
||||
.let { list ->
|
||||
if (f.types.isEmpty()) list
|
||||
else list.filter { it.extra?.tag in f.types }
|
||||
}
|
||||
|
||||
|
||||
private suspend fun syncNewPayments(maxPayments: Int = WalletConstants.PAYMENTS_SYNC_CHECKPOINT) {
|
||||
var syncOffset = 0
|
||||
var totalNew = 0
|
||||
@@ -445,14 +413,8 @@ class HistoryViewModel(
|
||||
|
||||
fun clearDetail() { _detailState.value = DetailState.Idle }
|
||||
|
||||
// ── Destination resolution ────────────────────────────────────────────────
|
||||
|
||||
companion object {
|
||||
private const val TAG = "HistoryViewModel"
|
||||
|
||||
fun isCompletedStatus(s: String) = s.lowercase() in setOf("success", "complete", "paid")
|
||||
fun isPendingStatus(s: String) = s.lowercase() in setOf("pending", "in_flight", "inflight")
|
||||
fun isFailedStatus(s: String) = s.lowercase() in setOf("failed", "error", "expired")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,8 @@ import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.bitcointxoko.gudariwallet.MainActivity
|
||||
import com.bitcointxoko.gudariwallet.ui.history.HistoryScreen
|
||||
import com.bitcointxoko.gudariwallet.ui.history.PaymentDetailScreen
|
||||
import com.bitcointxoko.gudariwallet.ui.receive.ReceiveScreen
|
||||
import com.bitcointxoko.gudariwallet.ui.send.SendScreen
|
||||
import com.bitcointxoko.gudariwallet.util.SendInputType
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
package com.bitcointxoko.gudariwallet.ui.history
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
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.text.KeyboardOptions
|
||||
import androidx.compose.material3.DatePicker
|
||||
import androidx.compose.material3.DatePickerDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberDatePickerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bitcointxoko.gudariwallet.ui.DatePreset
|
||||
import com.bitcointxoko.gudariwallet.ui.DirectionFilter
|
||||
import com.bitcointxoko.gudariwallet.ui.PaymentFilter
|
||||
import com.bitcointxoko.gudariwallet.ui.StatusFilter
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun FilterBottomSheet(
|
||||
currentFilter : PaymentFilter,
|
||||
availableTypes : List<String>,
|
||||
sheetState : SheetState,
|
||||
onDismiss : () -> Unit,
|
||||
onDirectionSelected : (DirectionFilter) -> Unit,
|
||||
onStatusToggled : (StatusFilter) -> Unit,
|
||||
onTypeToggled : (String) -> Unit,
|
||||
onAmountFilterSet : (min: Long?, max: Long?) -> Unit,
|
||||
onDatePresetSelected: (DatePreset) -> Unit,
|
||||
onDateFilterSet : (min: Long?, max: Long?) -> Unit,
|
||||
onDateClearRequested: () -> Unit
|
||||
) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 24.dp, end = 24.dp, bottom = 32.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Filter payments",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
|
||||
// ── Direction ────────────────────────────────────────────────────
|
||||
Text(
|
||||
text = "Direction",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
DirectionFilter.entries.forEach { option ->
|
||||
val label = when (option) {
|
||||
DirectionFilter.ALL -> "All"
|
||||
DirectionFilter.OUTGOING -> "Outgoing"
|
||||
DirectionFilter.INCOMING -> "Incoming"
|
||||
}
|
||||
FilterChip(
|
||||
selected = currentFilter.direction == option,
|
||||
onClick = { onDirectionSelected(option) },
|
||||
label = { Text(label) }
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
|
||||
// ── Status ───────────────────────────────────────────────────────
|
||||
Text(
|
||||
text = "Status",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
StatusFilter.entries.forEach { option ->
|
||||
FilterChip(
|
||||
selected = option in currentFilter.statuses,
|
||||
onClick = { onStatusToggled(option) },
|
||||
label = {
|
||||
Text(
|
||||
when (option) {
|
||||
StatusFilter.COMPLETED -> "Completed"
|
||||
StatusFilter.PENDING -> "Pending"
|
||||
StatusFilter.FAILED -> "Failed"
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Type (dynamic, only shown when tags exist) ───────────────────
|
||||
if (availableTypes.isNotEmpty()) {
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Text(
|
||||
text = "Type",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
availableTypes.forEach { type ->
|
||||
FilterChip(
|
||||
selected = type in currentFilter.types,
|
||||
onClick = { onTypeToggled(type) },
|
||||
label = { Text(type.uppercase()) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
|
||||
// ── Amount (sats) ────────────────────────────────────────────────
|
||||
Text(
|
||||
text = "Amount (sats)",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
var minText by rememberSaveable { mutableStateOf(currentFilter.minAmountSat?.toString() ?: "") }
|
||||
var maxText by rememberSaveable { mutableStateOf(currentFilter.maxAmountSat?.toString() ?: "") }
|
||||
|
||||
val commitAmount = {
|
||||
val min = minText.trim().toLongOrNull()
|
||||
val max = maxText.trim().toLongOrNull()
|
||||
onAmountFilterSet(min, max)
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = minText,
|
||||
onValueChange = { minText = it.filter(Char::isDigit); commitAmount() },
|
||||
label = { Text("Min") },
|
||||
suffix = { Text("sats") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text("–", style = MaterialTheme.typography.bodyLarge)
|
||||
OutlinedTextField(
|
||||
value = maxText,
|
||||
onValueChange = { maxText = it.filter(Char::isDigit); commitAmount() },
|
||||
label = { Text("Max") },
|
||||
suffix = { Text("sats") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
|
||||
// ── Date ─────────────────────────────────────────────────────────
|
||||
Text(
|
||||
text = "Date",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// Preset chips
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
DatePreset.entries.forEach { preset ->
|
||||
val label = when (preset) {
|
||||
DatePreset.THIS_WEEK -> "This week"
|
||||
DatePreset.THIS_MONTH -> "This month"
|
||||
DatePreset.THIS_YEAR -> "This year"
|
||||
}
|
||||
FilterChip(
|
||||
selected = currentFilter.datePreset == preset,
|
||||
onClick = {
|
||||
if (currentFilter.datePreset == preset) onDateClearRequested()
|
||||
else onDatePresetSelected(preset)
|
||||
},
|
||||
label = { Text(label) }
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// Manual From / To date pickers
|
||||
var showFromPicker by remember { mutableStateOf(false) }
|
||||
var showToPicker by remember { mutableStateOf(false) }
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = { showFromPicker = true },
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(currentFilter.minCreatedAt?.let { formatEpochShort(it) } ?: "From")
|
||||
}
|
||||
Text("–", style = MaterialTheme.typography.bodyLarge)
|
||||
OutlinedButton(
|
||||
onClick = { showToPicker = true },
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(currentFilter.maxCreatedAt?.let { formatEpochShort(it) } ?: "To")
|
||||
}
|
||||
}
|
||||
|
||||
if (showFromPicker) {
|
||||
val pickerState = rememberDatePickerState(
|
||||
initialSelectedDateMillis = currentFilter.minCreatedAt?.let { it * 1000 }
|
||||
)
|
||||
DatePickerDialog(
|
||||
onDismissRequest = { showFromPicker = false },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
val epochSec = pickerState.selectedDateMillis?.div(1000)
|
||||
onDateFilterSet(epochSec, currentFilter.maxCreatedAt)
|
||||
showFromPicker = false
|
||||
}) { Text("OK") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showFromPicker = false }) { Text("Cancel") }
|
||||
}
|
||||
) { DatePicker(state = pickerState) }
|
||||
}
|
||||
|
||||
if (showToPicker) {
|
||||
val pickerState = rememberDatePickerState(
|
||||
initialSelectedDateMillis = currentFilter.maxCreatedAt?.let { it * 1000 }
|
||||
)
|
||||
DatePickerDialog(
|
||||
onDismissRequest = { showToPicker = false },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
// +86399 to make "To" date inclusive (end of day)
|
||||
val epochSec = pickerState.selectedDateMillis?.div(1000)?.let { it + 86399L }
|
||||
onDateFilterSet(currentFilter.minCreatedAt, epochSec)
|
||||
showToPicker = false
|
||||
}) { Text("OK") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showToPicker = false }) { Text("Cancel") }
|
||||
}
|
||||
) { DatePicker(state = pickerState) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
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.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 (containerColor, contentColor) = when (status.lowercase()) {
|
||||
"success", "complete", "paid" ->
|
||||
semantic.successContainer to semantic.onSuccessContainer
|
||||
"pending", "in_flight", "inflight" ->
|
||||
semantic.warningContainer to semantic.onWarningContainer
|
||||
"failed", "error", "expired" ->
|
||||
semantic.errorContainer to semantic.onErrorContainer
|
||||
else ->
|
||||
MaterialTheme.colorScheme.surfaceVariant to MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = containerColor,
|
||||
contentColor = contentColor
|
||||
) {
|
||||
Text(
|
||||
text = status.replaceFirstChar { it.uppercase() },
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
package com.bitcointxoko.gudariwallet.ui.history
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ContentPaste
|
||||
import androidx.compose.material.icons.filled.FilterList
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.BadgedBox
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.InputChip
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||
import com.bitcointxoko.gudariwallet.ui.DatePreset
|
||||
import com.bitcointxoko.gudariwallet.ui.DirectionFilter
|
||||
import com.bitcointxoko.gudariwallet.ui.HistoryState
|
||||
import com.bitcointxoko.gudariwallet.ui.HistoryViewModel
|
||||
import com.bitcointxoko.gudariwallet.ui.PaymentFilter
|
||||
import com.bitcointxoko.gudariwallet.ui.StatusFilter
|
||||
import com.bitcointxoko.gudariwallet.ui.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// ── List screen ──────────────────────────────────────────────────────────────
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HistoryScreen(
|
||||
vm : HistoryViewModel,
|
||||
onClose : () -> Unit,
|
||||
onPaymentClick : (PaymentRecord) -> Unit
|
||||
) {
|
||||
val state by vm.filteredState.collectAsState()
|
||||
val filter by vm.filter.collectAsState()
|
||||
val fiatCurrency by vm.fiatCurrency.collectAsState()
|
||||
val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState()
|
||||
val availableTypes by vm.availableTypes.collectAsState()
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
var filterSheetVisible by rememberSaveable { mutableStateOf(false) }
|
||||
val dismissFilterSheet = { filterSheetVisible = false }
|
||||
var searchVisible by rememberSaveable { mutableStateOf(false) }
|
||||
var searchInfoSheetVisible by remember { mutableStateOf(false) }
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
val isFiltered = filter.isActive
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("History") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Close"
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
BadgedBox(
|
||||
badge = { if (filter.searchQuery.isNotBlank()) Badge() }
|
||||
) {
|
||||
IconButton(onClick = {
|
||||
searchVisible = !searchVisible
|
||||
if (!searchVisible) vm.clearSearch()
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = "Search payments"
|
||||
)
|
||||
}
|
||||
}
|
||||
BadgedBox(
|
||||
badge = { if (isFiltered) Badge() }
|
||||
) {
|
||||
IconButton(onClick = { filterSheetVisible = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.FilterList,
|
||||
contentDescription = "Filter payments"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val clipboard = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(searchVisible) {
|
||||
if (searchVisible) focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
// ── Search bar ───────────────────────────────────────────────────
|
||||
if (searchVisible) {
|
||||
SearchBar(
|
||||
query = filter.searchQuery,
|
||||
focusRequester = focusRequester,
|
||||
clipboard = clipboard,
|
||||
scope = scope,
|
||||
onQueryChange = { vm.setSearchQuery(it) },
|
||||
onClear = { vm.clearSearch() },
|
||||
onInfoClick = { searchInfoSheetVisible = true }
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
// ── Active filter chips ──────────────────────────────────────────
|
||||
val searchActive = filter.searchQuery.isNotBlank()
|
||||
if (isFiltered || searchActive) {
|
||||
ActiveFilterChips(
|
||||
filter = filter,
|
||||
searchActive = searchActive,
|
||||
onClearDirection = { vm.setDirectionFilter(DirectionFilter.ALL) },
|
||||
onClearStatus = { vm.toggleStatusFilter(it) },
|
||||
onClearType = { vm.toggleTypeFilter(it) },
|
||||
onClearAmount = { vm.setAmountFilter(null, null) },
|
||||
onClearDate = { vm.clearDateFilter() },
|
||||
onClearSearch = {
|
||||
vm.clearSearch()
|
||||
searchVisible = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// ── Content ──────────────────────────────────────────────────────
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
when (val s = state) {
|
||||
|
||||
is HistoryState.Loading -> {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
}
|
||||
|
||||
is HistoryState.Error -> {
|
||||
Column(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = s.message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Button(onClick = { vm.refresh() }) { Text("Retry") }
|
||||
}
|
||||
}
|
||||
|
||||
is HistoryState.Success -> {
|
||||
val shouldLoadMore by remember {
|
||||
derivedStateOf {
|
||||
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()
|
||||
val totalItems = listState.layoutInfo.totalItemsCount
|
||||
lastVisible != null && lastVisible.index >= totalItems - 3
|
||||
}
|
||||
}
|
||||
LaunchedEffect(shouldLoadMore) {
|
||||
if (shouldLoadMore && s.canLoadMore) vm.loadMore()
|
||||
}
|
||||
|
||||
if (s.payments.isEmpty()) {
|
||||
Text(
|
||||
text = "No payments yet.",
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
} else {
|
||||
PullToRefreshBox(
|
||||
isRefreshing = s.isRefreshing,
|
||||
onRefresh = { vm.refresh() }
|
||||
) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(vertical = 8.dp)
|
||||
) {
|
||||
items(
|
||||
items = s.payments,
|
||||
key = { it.paymentHash }
|
||||
) { payment ->
|
||||
PaymentRow(
|
||||
payment = payment,
|
||||
fiatCurrency = fiatCurrency,
|
||||
fiatSatsPerUnit = fiatSatsPerUnit,
|
||||
onClick = { onPaymentClick(payment) }
|
||||
)
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
thickness = 0.5.dp
|
||||
)
|
||||
}
|
||||
if (s.canLoadMore) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sheets (outside Scaffold so they overlay correctly) ──────────────────
|
||||
if (filterSheetVisible) {
|
||||
FilterBottomSheet(
|
||||
currentFilter = filter,
|
||||
availableTypes = availableTypes,
|
||||
sheetState = sheetState,
|
||||
onDismiss = dismissFilterSheet,
|
||||
onDirectionSelected = { direction ->
|
||||
vm.setDirectionFilter(direction)
|
||||
dismissFilterSheet()
|
||||
},
|
||||
onStatusToggled = { vm.toggleStatusFilter(it) },
|
||||
onTypeToggled = { vm.toggleTypeFilter(it) },
|
||||
onAmountFilterSet = { min, max -> vm.setAmountFilter(min, max) },
|
||||
onDatePresetSelected = { vm.applyDatePreset(it) },
|
||||
onDateFilterSet = { min, max -> vm.setDateFilter(min, max, preset = null) },
|
||||
onDateClearRequested = { vm.clearDateFilter() }
|
||||
)
|
||||
}
|
||||
SearchInfoSheet(
|
||||
visible = searchInfoSheetVisible,
|
||||
onDismiss = { searchInfoSheetVisible = false }
|
||||
)
|
||||
}
|
||||
|
||||
// ── Search bar ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun SearchBar(
|
||||
query : String,
|
||||
focusRequester : FocusRequester,
|
||||
clipboard : androidx.compose.ui.platform.Clipboard,
|
||||
scope : kotlinx.coroutines.CoroutineScope,
|
||||
onQueryChange : (String) -> Unit,
|
||||
onClear : () -> Unit,
|
||||
onInfoClick : () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = onInfoClick) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Info,
|
||||
contentDescription = "Search help"
|
||||
)
|
||||
}
|
||||
TextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
placeholder = { Text("Search transactions") },
|
||||
singleLine = true,
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent
|
||||
),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.focusRequester(focusRequester)
|
||||
)
|
||||
IconButton(onClick = {
|
||||
scope.launch {
|
||||
val pasted = clipboard.getClipEntry()
|
||||
?.clipData?.getItemAt(0)?.text?.toString().orEmpty()
|
||||
onQueryChange(pasted)
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ContentPaste,
|
||||
contentDescription = "Paste"
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = onClear,
|
||||
enabled = query.isNotEmpty()
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Clear search",
|
||||
tint = if (query.isNotEmpty()) LocalContentColor.current
|
||||
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Active filter chips ──────────────────────────────────────────────────────
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ActiveFilterChips(
|
||||
filter : PaymentFilter,
|
||||
searchActive : Boolean,
|
||||
onClearDirection: () -> Unit,
|
||||
onClearStatus : (StatusFilter) -> Unit,
|
||||
onClearType : (String) -> Unit,
|
||||
onClearAmount : () -> Unit,
|
||||
onClearDate : () -> Unit,
|
||||
onClearSearch : () -> Unit
|
||||
) {
|
||||
FlowRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
if (filter.direction != DirectionFilter.ALL) {
|
||||
InputChip(
|
||||
selected = true,
|
||||
onClick = onClearDirection,
|
||||
label = {
|
||||
Text(
|
||||
when (filter.direction) {
|
||||
DirectionFilter.OUTGOING -> "Outgoing"
|
||||
else -> "Incoming"
|
||||
}
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Clear direction filter",
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
filter.statuses.forEach { s ->
|
||||
InputChip(
|
||||
selected = true,
|
||||
onClick = { onClearStatus(s) },
|
||||
label = {
|
||||
Text(
|
||||
when (s) {
|
||||
StatusFilter.COMPLETED -> "Completed"
|
||||
StatusFilter.PENDING -> "Pending"
|
||||
StatusFilter.FAILED -> "Failed"
|
||||
}
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Remove status filter",
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
filter.types.forEach { type ->
|
||||
InputChip(
|
||||
selected = true,
|
||||
onClick = { onClearType(type) },
|
||||
label = { Text(type.uppercase()) },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Remove type filter",
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
if (filter.minAmountSat != null || filter.maxAmountSat != null) {
|
||||
val label = when {
|
||||
filter.minAmountSat != null && filter.maxAmountSat != null ->
|
||||
"${filter.minAmountSat}–${filter.maxAmountSat} sats"
|
||||
filter.minAmountSat != null ->
|
||||
"≥ ${filter.minAmountSat} sats"
|
||||
else ->
|
||||
"≤ ${filter.maxAmountSat} sats"
|
||||
}
|
||||
InputChip(
|
||||
selected = true,
|
||||
onClick = onClearAmount,
|
||||
label = { Text(label) },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Clear amount filter",
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
val minCreatedAt = filter.minCreatedAt
|
||||
val maxCreatedAt = filter.maxCreatedAt
|
||||
if (minCreatedAt != null || maxCreatedAt != null) {
|
||||
val label = when (filter.datePreset) {
|
||||
DatePreset.THIS_WEEK -> "This week"
|
||||
DatePreset.THIS_MONTH -> "This month"
|
||||
DatePreset.THIS_YEAR -> "This year"
|
||||
null -> when {
|
||||
minCreatedAt != null && maxCreatedAt != null ->
|
||||
"${formatEpochShort(minCreatedAt)}–${formatEpochShort(maxCreatedAt)}"
|
||||
minCreatedAt != null ->
|
||||
"From ${formatEpochShort(minCreatedAt)}"
|
||||
else ->
|
||||
"Until ${formatEpochShort(maxCreatedAt!!)}"
|
||||
}
|
||||
}
|
||||
InputChip(
|
||||
selected = true,
|
||||
onClick = onClearDate,
|
||||
label = { Text(label) },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Clear date filter",
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
if (searchActive) {
|
||||
InputChip(
|
||||
selected = true,
|
||||
onClick = onClearSearch,
|
||||
label = { Text("search: …${filter.searchQuery.takeLast(8)}") },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Clear search",
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.bitcointxoko.gudariwallet.ui.history
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
|
||||
|
||||
// ── Pure helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
internal fun formatEpochShort(epoch: Long): String =
|
||||
java.time.format.DateTimeFormatter.ofPattern("dd MMM")
|
||||
.withZone(java.time.ZoneId.systemDefault())
|
||||
.format(java.time.Instant.ofEpochSecond(epoch))
|
||||
|
||||
internal fun formatTimestamp(createdAt: Long?, rawTime: String): String {
|
||||
val formatter = java.time.format.DateTimeFormatter
|
||||
.ofPattern("dd MMM yyyy, HH:mm")
|
||||
.withZone(java.time.ZoneId.systemDefault())
|
||||
if (createdAt != null && createdAt > 1_000_000_000L) { // sanity check
|
||||
return formatter.format(java.time.Instant.ofEpochSecond(createdAt))
|
||||
}
|
||||
// Fallback: only reached for payments not yet written through Room
|
||||
return runCatching {
|
||||
formatter.format(java.time.Instant.ofEpochSecond(rawTime.toLong()))
|
||||
}.recoverCatching {
|
||||
formatter.format(java.time.OffsetDateTime.parse(rawTime))
|
||||
}.getOrDefault(rawTime)
|
||||
}
|
||||
|
||||
internal fun isPendingStatus(status: String): Boolean =
|
||||
status.lowercase() in setOf("pending", "in_flight", "inflight")
|
||||
|
||||
// ── Composable helpers ───────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
internal fun paymentAmountColor(status: String, isOutgoing: Boolean): Color {
|
||||
val semantic = semanticColors()
|
||||
return when {
|
||||
isPendingStatus(status) -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
isOutgoing -> MaterialTheme.colorScheme.onSurface
|
||||
else -> semantic.success
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
package com.bitcointxoko.gudariwallet.ui.history
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.ArrowDownward
|
||||
import androidx.compose.material.icons.filled.ArrowUpward
|
||||
import androidx.compose.material.icons.outlined.ContentCopy
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||
import com.bitcointxoko.gudariwallet.ui.DetailState
|
||||
import com.bitcointxoko.gudariwallet.ui.HistoryViewModel
|
||||
import com.bitcointxoko.gudariwallet.util.feePpm
|
||||
import com.bitcointxoko.gudariwallet.util.formatFiatForSats
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// ── Detail screen ────────────────────────────────────────────────────────────
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PaymentDetailScreen(
|
||||
payment : PaymentRecord,
|
||||
vm : HistoryViewModel,
|
||||
onBack : () -> Unit
|
||||
) {
|
||||
val detailState by vm.detailState.collectAsState()
|
||||
val fiatCurrency by vm.fiatCurrency.collectAsState()
|
||||
val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState()
|
||||
|
||||
LaunchedEffect(payment.checkingId) { vm.loadDetail(payment.checkingId, record = payment) }
|
||||
DisposableEffect(Unit) { onDispose { vm.clearDetail() } }
|
||||
|
||||
val enriched: PaymentRecord = when (val s = detailState) {
|
||||
is DetailState.Success -> {
|
||||
val detail = s.detail.details
|
||||
when {
|
||||
detail == null -> payment
|
||||
else -> detail.copy(
|
||||
memo = detail.memo?.takeIf { it.isNotBlank() } ?: payment.memo,
|
||||
bolt11 = detail.bolt11?.takeIf { it.isNotBlank() } ?: payment.bolt11
|
||||
)
|
||||
}
|
||||
}
|
||||
is DetailState.Partial -> s.record
|
||||
else -> payment
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(if (payment.isOutgoing) "Outgoing payment" else "Incoming payment") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
when (detailState) {
|
||||
is DetailState.Loading -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(padding),
|
||||
contentAlignment = Alignment.Center
|
||||
) { CircularProgressIndicator() }
|
||||
}
|
||||
|
||||
is DetailState.Error -> {
|
||||
Column(Modifier.fillMaxSize().padding(padding)) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "Could not load full details",
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(12.dp)
|
||||
)
|
||||
}
|
||||
PaymentDetailContent(
|
||||
payment = enriched,
|
||||
fiatCurrency = fiatCurrency,
|
||||
fiatSatsPerUnit = fiatSatsPerUnit
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
PaymentDetailContent(
|
||||
payment = enriched,
|
||||
fiatCurrency = fiatCurrency,
|
||||
fiatSatsPerUnit = fiatSatsPerUnit,
|
||||
modifier = Modifier.padding(padding)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Detail content ───────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun PaymentDetailContent(
|
||||
payment : PaymentRecord,
|
||||
fiatCurrency : String?,
|
||||
fiatSatsPerUnit : Double?,
|
||||
modifier : Modifier = Modifier
|
||||
) {
|
||||
val amountColor = paymentAmountColor(payment.status, payment.isOutgoing)
|
||||
|
||||
val context = LocalContext.current
|
||||
val clipboard = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val pubkey = payment.pubkey
|
||||
val alias = payment.alias
|
||||
|
||||
val heroFiat: String? = remember(payment.amountSat, fiatSatsPerUnit, fiatCurrency) {
|
||||
if (fiatSatsPerUnit != null && fiatCurrency != null && payment.amountSat > 0)
|
||||
formatFiatForSats(payment.amountSat, fiatSatsPerUnit, fiatCurrency)
|
||||
else null
|
||||
}
|
||||
val feeFiat: String? = remember(payment.feeSat, fiatSatsPerUnit, fiatCurrency) {
|
||||
if (fiatSatsPerUnit != null && fiatCurrency != null && payment.feeSat > 0)
|
||||
formatFiatForSats(payment.feeSat, fiatSatsPerUnit, fiatCurrency)
|
||||
else null
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
|
||||
// ── Hero amount ──────────────────────────────────────────────────────
|
||||
item {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (payment.isOutgoing) Icons.Default.ArrowUpward
|
||||
else Icons.Default.ArrowDownward,
|
||||
contentDescription = null,
|
||||
tint = amountColor,
|
||||
modifier = Modifier.size(36.dp)
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
AmountWithFiatColumn(
|
||||
amountSat = payment.amountSat,
|
||||
isOutgoing = payment.isOutgoing,
|
||||
amountColor = amountColor,
|
||||
fiatLine = heroFiat,
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
)
|
||||
if (payment.feeSat > 0) {
|
||||
val ppm = feePpm(
|
||||
amountMsat = kotlin.math.abs(payment.amountMsat),
|
||||
feeMsat = kotlin.math.abs(payment.feeMsat)
|
||||
)
|
||||
val feeLabel = buildString {
|
||||
append("Fee: ${payment.feeSat} sats")
|
||||
if (feeFiat != null) append(" ($feeFiat)")
|
||||
if (ppm != null) append(" · $ppm ppm")
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = feeLabel,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
StatusChip(status = payment.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Basic info ───────────────────────────────────────────────────────
|
||||
item {
|
||||
DetailSection(title = "Details") {
|
||||
DetailRow("Date", formatTimestamp(payment.createdAt, payment.time))
|
||||
DetailRow("Memo", payment.memo?.takeIf { it.isNotBlank() } ?: "—")
|
||||
if (payment.extra?.tag != null) {
|
||||
DetailRow("Type", payment.extra.tag)
|
||||
}
|
||||
if (payment.extra?.comment?.isNotBlank() == true) {
|
||||
DetailRow("Comment", payment.extra.comment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Success action (LNURL) ───────────────────────────────────────────
|
||||
payment.extra?.successAction?.let { action ->
|
||||
item {
|
||||
DetailSection(title = "Success Action") {
|
||||
action.message?.let { DetailRow("Message", it) }
|
||||
action.url?.let { DetailRow("URL", it) }
|
||||
action.description?.let { DetailRow("Description", it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Technical ────────────────────────────────────────────────────────
|
||||
item {
|
||||
DetailSection(title = "Technical") {
|
||||
DetailRow(
|
||||
label = "Payment Hash",
|
||||
value = payment.paymentHash,
|
||||
monospace = true,
|
||||
copyable = true,
|
||||
onCopy = {
|
||||
scope.launch {
|
||||
clipboard.setClipEntry(
|
||||
ClipEntry(ClipData.newPlainText("Payment Hash", payment.paymentHash))
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
if (!payment.preimage.isNullOrBlank() && payment.preimage != "0".repeat(64)) {
|
||||
DetailRow(
|
||||
label = "Preimage",
|
||||
value = payment.preimage,
|
||||
monospace = true,
|
||||
copyable = true,
|
||||
onCopy = {
|
||||
scope.launch {
|
||||
clipboard.setClipEntry(
|
||||
ClipEntry(ClipData.newPlainText("Preimage", payment.preimage))
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
if (pubkey != null) {
|
||||
val ambossUrl = "https://amboss.space/node/$pubkey"
|
||||
val label = alias ?: "${pubkey.take(8)}…${pubkey.takeLast(8)}"
|
||||
DetailRow(
|
||||
label = "Destination",
|
||||
value = label,
|
||||
valueColor = MaterialTheme.colorScheme.primary,
|
||||
textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline,
|
||||
onValueClick = {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, ambossUrl.toUri()))
|
||||
},
|
||||
trailingContent = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
clipboard.setClipEntry(
|
||||
ClipEntry(ClipData.newPlainText("node_id", pubkey))
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.ContentCopy,
|
||||
contentDescription = "Copy node ID",
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
if (!payment.bolt11.isNullOrBlank()) {
|
||||
DetailRow(
|
||||
label = "BOLT11",
|
||||
value = payment.bolt11,
|
||||
monospace = true,
|
||||
copyable = true,
|
||||
maxLines = 3,
|
||||
onCopy = {
|
||||
scope.launch {
|
||||
clipboard.setClipEntry(
|
||||
ClipEntry(ClipData.newPlainText("BOLT11", payment.bolt11))
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.bitcointxoko.gudariwallet.ui.history
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
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.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.ArrowDownward
|
||||
import androidx.compose.material.icons.filled.ArrowUpward
|
||||
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.runtime.remember
|
||||
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.PaymentRecord
|
||||
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
|
||||
import com.bitcointxoko.gudariwallet.util.formatFiatForSats
|
||||
|
||||
@Composable
|
||||
internal fun PaymentRow(
|
||||
payment : PaymentRecord,
|
||||
fiatCurrency : String?,
|
||||
fiatSatsPerUnit : Double?,
|
||||
onClick : () -> Unit
|
||||
) {
|
||||
val semantic = semanticColors()
|
||||
|
||||
val isPending = isPendingStatus(payment.status)
|
||||
val amountColor = paymentAmountColor(payment.status, payment.isOutgoing)
|
||||
val icon = if (payment.isOutgoing) Icons.Default.ArrowUpward
|
||||
else Icons.Default.ArrowDownward
|
||||
|
||||
val fiatLine: String? = remember(payment.amountSat, fiatSatsPerUnit, fiatCurrency) {
|
||||
if (fiatSatsPerUnit != null && fiatCurrency != null && payment.amountSat > 0)
|
||||
formatFiatForSats(payment.amountSat, fiatSatsPerUnit, fiatCurrency)
|
||||
else null
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = if (payment.isOutgoing) "Sent" else "Received",
|
||||
tint = amountColor,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = payment.memo?.takeIf { it.isNotBlank() } ?: "No memo",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
val formattedTime = remember(payment.createdAt ?: payment.time) {
|
||||
formatTimestamp(payment.createdAt, payment.time)
|
||||
}
|
||||
Text(
|
||||
text = formattedTime,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
if (isPending) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.extraSmall,
|
||||
color = semantic.warningContainer,
|
||||
contentColor = semantic.onWarningContainer
|
||||
) {
|
||||
Text(
|
||||
text = "Pending",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
AmountWithFiatColumn(
|
||||
amountSat = payment.amountSat,
|
||||
isOutgoing = payment.isOutgoing,
|
||||
amountColor = amountColor,
|
||||
fiatLine = fiatLine
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.bitcointxoko.gudariwallet.ui.history
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun SearchInfoSheet(visible: Boolean, onDismiss: () -> Unit) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
if (!visible) return
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 24.dp, end = 24.dp, bottom = 40.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "What can I search for?",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Type any part of the following to find a payment:",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
|
||||
SearchInfoItem(
|
||||
title = "Memo",
|
||||
description = "The message or description attached to a payment. " +
|
||||
"For example, \"coffee\" or \"rent March\"."
|
||||
)
|
||||
SearchInfoItem(
|
||||
title = "Invoice",
|
||||
description = "The payment request you scanned or pasted to send " +
|
||||
"a payment. It usually starts with \"lnbc…\". " +
|
||||
"You can paste just the first or last few characters."
|
||||
)
|
||||
SearchInfoItem(
|
||||
title = "Hash",
|
||||
description = "A unique code that identifies this payment on the " +
|
||||
"Lightning Network. Useful if someone asks you to " +
|
||||
"confirm a specific transaction."
|
||||
)
|
||||
SearchInfoItem(
|
||||
title = "Preimage",
|
||||
description = "A secret code that proves a payment was received. " +
|
||||
"Only available after a payment completes successfully."
|
||||
)
|
||||
SearchInfoItem(
|
||||
title = "Node alias",
|
||||
description = "The name of the Lightning node you sent a payment to. " +
|
||||
"For example, \"ACINQ\" or \"Wallet of Satoshi\". " +
|
||||
"Aliases are resolved automatically for outgoing payments."
|
||||
)
|
||||
SearchInfoItem(
|
||||
title = "Node ID (pubkey)",
|
||||
description = "The public key of the destination node. " +
|
||||
"A 66-character hex string — you can paste just the first few characters."
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "You can paste a full value or search with just a few characters — " +
|
||||
"partial matches work too.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchInfoItem(title: String, description: String) {
|
||||
Column(modifier = Modifier.padding(bottom = 16.dp)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelLarge
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user