From a345a4cb7dce9048d94f834976b525ea0de8625d Mon Sep 17 00:00:00 2001 From: rasputin Date: Wed, 3 Jun 2026 19:11:03 +0200 Subject: [PATCH] refactor: extract HistoryScreen into separate components --- .../gudariwallet/ui/HistoryScreen.kt | 1266 ----------------- .../gudariwallet/ui/HistoryViewModel.kt | 60 +- .../gudariwallet/ui/WalletScreen.kt | 10 +- .../ui/history/FilterBottomSheet.kt | 271 ++++ .../ui/history/HistoryComponents.kt | 170 +++ .../gudariwallet/ui/history/HistoryScreen.kt | 503 +++++++ .../gudariwallet/ui/history/HistoryUtils.kt | 43 + .../ui/history/PaymentDetailScreen.kt | 324 +++++ .../gudariwallet/ui/history/PaymentRow.kt | 103 ++ .../ui/history/SearchInfoSheet.kt | 103 ++ 10 files changed, 1534 insertions(+), 1319 deletions(-) delete mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryScreen.kt create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/FilterBottomSheet.kt create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryComponents.kt create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryScreen.kt create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryUtils.kt create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentDetailScreen.kt create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentRow.kt create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/SearchInfoSheet.kt diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryScreen.kt deleted file mode 100644 index 6889eaf..0000000 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryScreen.kt +++ /dev/null @@ -1,1266 +0,0 @@ -package com.bitcointxoko.gudariwallet.ui - -import android.content.ClipData -import android.content.Intent -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.text.KeyboardOptions -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.filled.Close -import androidx.compose.material.icons.filled.ContentCopy -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.material.icons.outlined.ContentCopy -import androidx.compose.material3.* -import androidx.compose.material3.pulltorefresh.PullToRefreshBox -import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable -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.ClipEntry -import androidx.compose.ui.platform.LocalClipboard -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.core.net.toUri -import com.bitcointxoko.gudariwallet.api.PaymentRecord -import com.bitcointxoko.gudariwallet.ui.theme.semanticColors -import com.bitcointxoko.gudariwallet.util.feePpm -import com.bitcointxoko.gudariwallet.util.formatFiatForSats -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 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.direction != DirectionFilter.ALL - || filter.statuses.isNotEmpty() - || filter.types.isNotEmpty() - || filter.minAmountSat != null - || filter.maxAmountSat != null - || filter.minCreatedAt != null - || filter.maxCreatedAt != null - Scaffold( - topBar = { - TopAppBar( - title = { Text("History") }, - navigationIcon = { - IconButton(onClick = onClose) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = "Close" - ) - } - }, - actions = { - // Search button - BadgedBox( - badge = { if (filter.searchQuery.isNotBlank()) Badge() } - ) { - IconButton(onClick = { - searchVisible = !searchVisible - if (!searchVisible) vm.clearSearch() - }) { - Icon( - imageVector = Icons.Default.Search, - contentDescription = "Search payments" - ) - } - } - // Badge the filter icon when a filter is active - 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() - // Autofocus the TextField when the search bar opens - LaunchedEffect(searchVisible) { - if (searchVisible) focusRequester.requestFocus() - } - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - ) { - if (searchVisible) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Info button — opens the search help sheet - IconButton(onClick = { searchInfoSheetVisible = true }) { - Icon( - imageVector = Icons.Default.Info, - contentDescription = "Search help" - ) - } - // Text field — takes all available space - TextField( - value = filter.searchQuery, - onValueChange = { vm.setSearchQuery(it) }, - 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) - ) - // Paste button — always enabled - IconButton(onClick = { - scope.launch { - val pasted = clipboard.getClipEntry() - ?.clipData?.getItemAt(0)?.text?.toString().orEmpty() - vm.setSearchQuery(pasted) - } - }) { - Icon( - imageVector = Icons.Default.ContentPaste, - contentDescription = "Paste" - ) - } - // Clear button — only enabled when query is non-empty - IconButton( - onClick = { vm.clearSearch() }, - enabled = filter.searchQuery.isNotEmpty() - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = "Clear search", - tint = if (filter.searchQuery.isNotEmpty()) - LocalContentColor.current - else - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - ) - } - } - HorizontalDivider() - } - // ── Active filter chips ─────────────────────────────────────────── - val searchActive = filter.searchQuery.isNotBlank() - - if (isFiltered || searchActive) { - 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 = { vm.setDirectionFilter(DirectionFilter.ALL) }, - 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 = { vm.toggleStatusFilter(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 = { vm.toggleTypeFilter(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 = { vm.setAmountFilter(null, null) }, - 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 = { vm.clearDateFilter() }, - label = { Text(label) }, - trailingIcon = { - Icon( - imageVector = Icons.Default.Close, - contentDescription = "Clear date filter", - modifier = Modifier.size(16.dp) - ) - } - ) - } - if (searchActive) { - InputChip( - selected = true, - onClick = { - vm.clearSearch() - searchVisible = false - }, - label = { Text("search: …${filter.searchQuery.takeLast(8)}") }, - trailingIcon = { - Icon( - imageVector = Icons.Default.Close, - contentDescription = "Clear search", - modifier = Modifier.size(16.dp) - ) - } - ) - } - - } - } - - // ── 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 - ) - } - } - } - } - } - } - } - } - } - } - val availableTypes by vm.availableTypes.collectAsState() - // ── Filter bottom sheet ─────────────────────────────────────────────────── - 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 }) -} - -// ── Filter bottom sheet ─────────────────────────────────────────────────────── - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun FilterBottomSheet( - currentFilter : PaymentFilter, - availableTypes : List, - 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)) - - Text( - text = "Direction", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(Modifier.height(12.dp)) - - // Direction options as a segmented-style row of filter chips - 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" - } - ) - } - ) - } - } - if (availableTypes.isNotEmpty()) { - Spacer(Modifier.height(20.dp)) - 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)) - - // Local draft state — committed on focus-loss / field change - 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) } - } - } - } -} - -// ── Search info sheet ───────────────────────────────────────────────────────── - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private 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\u2026\". " + - "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 - ) - } -} - -// ── Payment color helpers ───────────────────────────────────────────────────── - -private fun isPendingStatus(status: String): Boolean = - status.lowercase() in setOf("pending", "in_flight", "inflight") - -@Composable -private fun paymentAmountColor(status: String, isOutgoing: Boolean): Color { - val semantic = semanticColors() - return when { - isPendingStatus(status) -> MaterialTheme.colorScheme.onSurfaceVariant // muted - isOutgoing -> MaterialTheme.colorScheme.onSurface // neutral - else -> semantic.success // green - } -} - -// ── Single row ─────────────────────────────────────────────────────────────── - -@Composable -private 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 - ) - } -} - -// ── 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, - vm = vm, - fiatCurrency = fiatCurrency, - fiatSatsPerUnit = fiatSatsPerUnit - ) - } - } - - else -> { - PaymentDetailContent( - payment = enriched, - vm = vm, - fiatCurrency = fiatCurrency, - fiatSatsPerUnit = fiatSatsPerUnit, - modifier = Modifier.padding(padding) - ) - } - } - } -} - -// ── Detail content ─────────────────────────────────────────────────────────── - -@Composable -private fun PaymentDetailContent( - payment : PaymentRecord, - vm : HistoryViewModel, - fiatCurrency : String?, // ← new - fiatSatsPerUnit : Double?, // ← new - 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 - - // Pre-compute fiat strings for hero + fee - 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 - ) - // Fee line (with fiat if available) - 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 = 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))) } } - ) - } - } - } - } -} - -// ── Reusable section wrapper ───────────────────────────────────────────────── -@Composable -private 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() - } - } -} - -// ── Single label/value row ─────────────────────────────────────────────────── -@Composable -private 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() - } - } -} - -// ── Status chip ────────────────────────────────────────────────────────────── -@Composable -private 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) - ) - } -} - -// ── Helpers ────────────────────────────────────────────────────────────────── - -private fun formatEpochShort(epoch: Long): String = - java.time.format.DateTimeFormatter.ofPattern("dd MMM") - .withZone(java.time.ZoneId.systemDefault()) - .format(java.time.Instant.ofEpochSecond(epoch)) -private 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) -} - - -@Composable -private fun AmountWithFiatColumn( - amountSat : Long, - isOutgoing : Boolean, - amountColor : Color, - fiatLine : String?, - style : androidx.compose.ui.text.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) - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryViewModel.kt index 14e8aa7..023b403 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryViewModel.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryViewModel.kt @@ -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 = _state // ── Filter state (search is just another filter field) ──────────────────── - private val _filter = MutableStateFlow(PaymentFilter()) val filter: StateFlow = _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,36 +184,20 @@ class HistoryViewModel( } // ── filteredState ───────────────────────────────────────────────────────── - val filteredState: StateFlow = 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 } - } - } - .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) + s.copy(payments = s.payments.applyInMemoryFilters(f)) + } else { + s.copy(payments = roomResults.applyInMemoryFilters(f), canLoadMore = false) } + }.stateIn(viewModelScope, SharingStarted.Eagerly, HistoryState.Loading) - // ── 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 } + private var currentOffset = 0 + private var loadJob: Job? = null + + private fun List.applyInMemoryFilters(f: PaymentFilter) = + distinctBy { it.paymentHash } .let { list -> when (f.direction) { DirectionFilter.ALL -> list @@ -235,13 +210,6 @@ class HistoryViewModel( 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 suspend fun syncNewPayments(maxPayments: Int = WalletConstants.PAYMENTS_SYNC_CHECKPOINT) { var syncOffset = 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") } } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt index 38987af..5d193a5 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt @@ -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 @@ -308,8 +310,8 @@ fun WalletScreen( ) { HistoryScreen( - vm = historyVm, - onClose = { + vm = historyVm, + onClose = { navController.popBackStack(ROUTE_HOME, inclusive = false) }, onPaymentClick = { payment -> @@ -332,8 +334,8 @@ fun WalletScreen( if (payment != null) { PaymentDetailScreen( payment = payment, - vm = historyVm, - onBack = { navController.popBackStack() } + vm = historyVm, + onBack = { navController.popBackStack() } ) } else { LaunchedEffect(paymentHash) { historyVm.refresh() } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/FilterBottomSheet.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/FilterBottomSheet.kt new file mode 100644 index 0000000..4da27b7 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/FilterBottomSheet.kt @@ -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, + 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) } + } + } + } +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryComponents.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryComponents.kt new file mode 100644 index 0000000..073c679 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryComponents.kt @@ -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() + } + } +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryScreen.kt new file mode 100644 index 0000000..7fd3238 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryScreen.kt @@ -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) + ) + } + ) + } + } +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryUtils.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryUtils.kt new file mode 100644 index 0000000..772f6ff --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryUtils.kt @@ -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 + } +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentDetailScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentDetailScreen.kt new file mode 100644 index 0000000..2633b45 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentDetailScreen.kt @@ -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)) + ) + } + } + ) + } + } + } + } +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentRow.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentRow.kt new file mode 100644 index 0000000..f855ef1 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentRow.kt @@ -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 + ) + } +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/SearchInfoSheet.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/SearchInfoSheet.kt new file mode 100644 index 0000000..83d2880 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/SearchInfoSheet.kt @@ -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 + ) + } +} \ No newline at end of file