refactor: extract HistoryScreen into separate components

This commit is contained in:
2026-06-03 19:11:03 +02:00
parent 57d718caf6
commit a345a4cb7d
10 changed files with 1534 additions and 1319 deletions
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.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
// ── Filter model ──────────────────────────────────────────────────────────────
data class PaymentFilter( data class PaymentFilter(
val searchQuery : String = "", val searchQuery : String = "",
val direction : DirectionFilter = DirectionFilter.ALL, val direction : DirectionFilter = DirectionFilter.ALL,
@@ -37,8 +35,7 @@ data class PaymentFilter(
) )
val PaymentFilter.isActive: Boolean val PaymentFilter.isActive: Boolean
get() = searchQuery.isNotBlank() get() = direction != DirectionFilter.ALL
|| direction != DirectionFilter.ALL
|| statuses.isNotEmpty() || statuses.isNotEmpty()
|| types.isNotEmpty() || types.isNotEmpty()
|| minAmountSat != null || minAmountSat != null
@@ -50,8 +47,6 @@ enum class DirectionFilter { ALL, OUTGOING, INCOMING }
enum class StatusFilter { COMPLETED, PENDING, FAILED } enum class StatusFilter { COMPLETED, PENDING, FAILED }
enum class DatePreset { THIS_WEEK, THIS_MONTH, THIS_YEAR } enum class DatePreset { THIS_WEEK, THIS_MONTH, THIS_YEAR }
// ── ViewModel ─────────────────────────────────────────────────────────────────
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
class HistoryViewModel( class HistoryViewModel(
private val repo : WalletRepository, private val repo : WalletRepository,
@@ -65,7 +60,6 @@ class HistoryViewModel(
val state: StateFlow<HistoryState> = _state val state: StateFlow<HistoryState> = _state
// ── Filter state (search is just another filter field) ──────────────────── // ── Filter state (search is just another filter field) ────────────────────
private val _filter = MutableStateFlow(PaymentFilter()) private val _filter = MutableStateFlow(PaymentFilter())
val filter: StateFlow<PaymentFilter> = _filter val filter: StateFlow<PaymentFilter> = _filter
@@ -90,9 +84,6 @@ class HistoryViewModel(
fun setAmountFilter(min: Long?, max: Long?) { fun setAmountFilter(min: Long?, max: Long?) {
_filter.update { it.copy(minAmountSat = min, maxAmountSat = max) } _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) { fun setDateFilter(min: Long?, max: Long?, preset: DatePreset? = null) {
_filter.update { it.copy(minCreatedAt = min, maxCreatedAt = max, datePreset = preset) } _filter.update { it.copy(minCreatedAt = min, maxCreatedAt = max, datePreset = preset) }
} }
@@ -193,56 +184,33 @@ class HistoryViewModel(
} }
// ── filteredState ───────────────────────────────────────────────────────── // ── filteredState ─────────────────────────────────────────────────────────
val filteredState: StateFlow<HistoryState> = combine( val filteredState = combine(_state, _filter, _roomResults) { s, f, roomResults ->
_state, _filter, _roomResults
) { s, f, roomResults ->
if (s !is HistoryState.Success) return@combine s if (s !is HistoryState.Success) return@combine s
// ── Path A: pure in-memory ────────────────────────────────────────────
if (roomResults == null) { if (roomResults == null) {
Log.d(TAG, "FILTER [IN-MEMORY] filteredState: base=${s.payments.size} payments") s.copy(payments = s.payments.applyInMemoryFilters(f))
val filtered = s.payments } else {
.distinctBy { it.paymentHash } s.copy(payments = roomResults.applyInMemoryFilters(f), canLoadMore = false)
.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 [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) }.stateIn(viewModelScope, SharingStarted.Eagerly, HistoryState.Loading)
private var currentOffset = 0 private var currentOffset = 0
private var loadJob: Job? = null 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) { private suspend fun syncNewPayments(maxPayments: Int = WalletConstants.PAYMENTS_SYNC_CHECKPOINT) {
var syncOffset = 0 var syncOffset = 0
var totalNew = 0 var totalNew = 0
@@ -445,14 +413,8 @@ class HistoryViewModel(
fun clearDetail() { _detailState.value = DetailState.Idle } fun clearDetail() { _detailState.value = DetailState.Idle }
// ── Destination resolution ────────────────────────────────────────────────
companion object { companion object {
private const val TAG = "HistoryViewModel" 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.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.bitcointxoko.gudariwallet.MainActivity 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.receive.ReceiveScreen
import com.bitcointxoko.gudariwallet.ui.send.SendScreen import com.bitcointxoko.gudariwallet.ui.send.SendScreen
import com.bitcointxoko.gudariwallet.util.SendInputType 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
)
}
}