feat: add search by memo, bolt11, payment_hash and preimage

This commit is contained in:
2026-06-03 14:27:59 +02:00
parent 52da6a37ad
commit 0f932b0229
4 changed files with 217 additions and 9 deletions
+1
View File
@@ -68,6 +68,7 @@ dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.ui)
testImplementation(libs.junit)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
@@ -14,7 +14,10 @@ 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
@@ -22,6 +25,8 @@ 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
@@ -49,12 +54,15 @@ fun HistoryScreen(
) {
val state by vm.filteredState.collectAsState()
val filter by vm.filter.collectAsState()
val searchQuery by vm.searchQuery.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
@@ -64,11 +72,10 @@ fun HistoryScreen(
|| filter.maxAmountSat != null
|| filter.minCreatedAt != null
|| filter.maxCreatedAt != null
Scaffold(
topBar = {
TopAppBar(
title = { Text("History") },
title = { Text("History") },
navigationIcon = {
IconButton(onClick = onClose) {
Icon(
@@ -78,11 +85,23 @@ fun HistoryScreen(
}
},
actions = {
// Badge the icon when a filter is active
// Search button
BadgedBox(
badge = {
if (isFiltered) Badge()
badge = { if (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(
@@ -91,16 +110,86 @@ fun HistoryScreen(
)
}
}
}
)
}
) { 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 = 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 = searchQuery.isNotEmpty()
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Clear search",
tint = if (searchQuery.isNotEmpty())
LocalContentColor.current
else
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
)
}
}
HorizontalDivider()
}
// ── Active filter chips ───────────────────────────────────────────
val searchActive = searchQuery.isNotBlank()
if (isFiltered) {
FlowRow(
modifier = Modifier
@@ -216,6 +305,24 @@ fun HistoryScreen(
}
)
}
if (searchActive) {
InputChip(
selected = true,
onClick = {
vm.clearSearch()
searchVisible = false
},
label = { Text("search: …${searchQuery.takeLast(8)}") },
trailingIcon = {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Clear search",
modifier = Modifier.size(16.dp)
)
}
)
}
}
}
@@ -325,9 +432,9 @@ fun HistoryScreen(
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 ───────────────────────────────────────────────────────
@@ -560,6 +667,85 @@ private fun FilterBottomSheet(
}
}
// ── 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."
)
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 =
@@ -52,6 +52,14 @@ class HistoryViewModel(
private val _state = MutableStateFlow<HistoryState>(HistoryState.Loading)
val state: StateFlow<HistoryState> = _state
// ── Search state ───────────────────────────────────────────────────────
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery
fun setSearchQuery(q: String) { _searchQuery.update { q.trim() } }
fun clearSearch() { _searchQuery.value = "" }
// ── Filter state ──────────────────────────────────────────────────────────
private val _filter = MutableStateFlow(PaymentFilter())
@@ -116,7 +124,7 @@ class HistoryViewModel(
/** The list shown in the UI — raw state with filter applied. */
val filteredState: StateFlow<HistoryState> = combine(_state, _filter) { s, f ->
val filteredState: StateFlow<HistoryState> = combine(_state, _filter, _searchQuery) { s, f, q ->
if (s !is HistoryState.Success) return@combine s
val filtered = s.payments
.let { list ->
@@ -154,12 +162,23 @@ class HistoryViewModel(
if (f.minCreatedAt == null && f.maxCreatedAt == null) list
else list.filter { payment ->
val epoch = payment.createdAt
?: payment.time.toLongOrNull() // try the raw string field
?: return@filter false // no usable timestamp → exclude
?: payment.time.toLongOrNull()
?: return@filter false
(f.minCreatedAt == null || epoch >= f.minCreatedAt) &&
(f.maxCreatedAt == null || epoch <= f.maxCreatedAt)
}
}
.let { list ->
// ── Search (last stage, applied after all filters) ─────────
val query = q.lowercase()
if (query.isBlank()) list
else list.filter { payment ->
payment.bolt11?.lowercase()?.contains(query) == true ||
payment.paymentHash.lowercase().contains(query) ||
payment.memo?.lowercase()?.contains(query) == true ||
payment.preimage?.lowercase()?.contains(query) == true
}
}
s.copy(payments = filtered)
}.stateIn(viewModelScope, SharingStarted.Eagerly, HistoryState.Loading)