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.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.ui)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4) 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.ArrowUpward
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentCopy 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.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.material.icons.outlined.ContentCopy
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshBox
@@ -22,6 +25,8 @@ import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.graphics.Color
import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalClipboard
@@ -49,12 +54,15 @@ fun HistoryScreen(
) { ) {
val state by vm.filteredState.collectAsState() val state by vm.filteredState.collectAsState()
val filter by vm.filter.collectAsState() val filter by vm.filter.collectAsState()
val searchQuery by vm.searchQuery.collectAsState()
val fiatCurrency by vm.fiatCurrency.collectAsState() val fiatCurrency by vm.fiatCurrency.collectAsState()
val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState() val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState()
val listState = rememberLazyListState() val listState = rememberLazyListState()
var filterSheetVisible by rememberSaveable { mutableStateOf(false) } var filterSheetVisible by rememberSaveable { mutableStateOf(false) }
val dismissFilterSheet = { filterSheetVisible = false } val dismissFilterSheet = { filterSheetVisible = false }
var searchVisible by rememberSaveable { mutableStateOf(false) }
var searchInfoSheetVisible by remember { mutableStateOf(false) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val isFiltered = filter.direction != DirectionFilter.ALL val isFiltered = filter.direction != DirectionFilter.ALL
@@ -64,11 +72,10 @@ fun HistoryScreen(
|| filter.maxAmountSat != null || filter.maxAmountSat != null
|| filter.minCreatedAt != null || filter.minCreatedAt != null
|| filter.maxCreatedAt != null || filter.maxCreatedAt != null
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("History") }, title = { Text("History") },
navigationIcon = { navigationIcon = {
IconButton(onClick = onClose) { IconButton(onClick = onClose) {
Icon( Icon(
@@ -78,11 +85,23 @@ fun HistoryScreen(
} }
}, },
actions = { actions = {
// Badge the icon when a filter is active // Search button
BadgedBox( BadgedBox(
badge = { badge = { if (searchQuery.isNotBlank()) Badge() }
if (isFiltered) 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 }) { IconButton(onClick = { filterSheetVisible = true }) {
Icon( Icon(
@@ -91,16 +110,86 @@ fun HistoryScreen(
) )
} }
} }
} }
) )
} }
) { padding -> ) { 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( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding) .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 ─────────────────────────────────────────── // ── Active filter chips ───────────────────────────────────────────
val searchActive = searchQuery.isNotBlank()
if (isFiltered) { if (isFiltered) {
FlowRow( FlowRow(
modifier = Modifier 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) }, onDatePresetSelected = { vm.applyDatePreset(it) },
onDateFilterSet = { min, max -> vm.setDateFilter(min, max, preset = null) }, onDateFilterSet = { min, max -> vm.setDateFilter(min, max, preset = null) },
onDateClearRequested = { vm.clearDateFilter() } onDateClearRequested = { vm.clearDateFilter() }
) )
} }
SearchInfoSheet(visible = searchInfoSheetVisible, onDismiss = { searchInfoSheetVisible = false })
} }
// ── Filter bottom sheet ─────────────────────────────────────────────────────── // ── 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 ───────────────────────────────────────────────────── // ── Payment color helpers ─────────────────────────────────────────────────────
private fun isPendingStatus(status: String): Boolean = private fun isPendingStatus(status: String): Boolean =
@@ -52,6 +52,14 @@ class HistoryViewModel(
private val _state = MutableStateFlow<HistoryState>(HistoryState.Loading) private val _state = MutableStateFlow<HistoryState>(HistoryState.Loading)
val state: StateFlow<HistoryState> = _state 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 ────────────────────────────────────────────────────────── // ── Filter state ──────────────────────────────────────────────────────────
private val _filter = MutableStateFlow(PaymentFilter()) private val _filter = MutableStateFlow(PaymentFilter())
@@ -116,7 +124,7 @@ class HistoryViewModel(
/** The list shown in the UI — raw state with filter applied. */ /** 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 if (s !is HistoryState.Success) return@combine s
val filtered = s.payments val filtered = s.payments
.let { list -> .let { list ->
@@ -154,12 +162,23 @@ class HistoryViewModel(
if (f.minCreatedAt == null && f.maxCreatedAt == null) list if (f.minCreatedAt == null && f.maxCreatedAt == null) list
else list.filter { payment -> else list.filter { payment ->
val epoch = payment.createdAt val epoch = payment.createdAt
?: payment.time.toLongOrNull() // try the raw string field ?: payment.time.toLongOrNull()
?: return@filter false // no usable timestamp → exclude ?: return@filter false
(f.minCreatedAt == null || epoch >= f.minCreatedAt) && (f.minCreatedAt == null || epoch >= f.minCreatedAt) &&
(f.maxCreatedAt == null || epoch <= f.maxCreatedAt) (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) s.copy(payments = filtered)
}.stateIn(viewModelScope, SharingStarted.Eagerly, HistoryState.Loading) }.stateIn(viewModelScope, SharingStarted.Eagerly, HistoryState.Loading)
+2
View File
@@ -25,6 +25,7 @@ datastore = "1.2.1"
protobuf = "4.26.1" protobuf = "4.26.1"
protobufPlugin = "0.10.0" protobufPlugin = "0.10.0"
tink = "1.13.0" tink = "1.13.0"
ui = "1.11.2"
[libraries] [libraries]
androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" }
@@ -64,6 +65,7 @@ datastore-proto = { group = "androidx.datastore", name = "data
protobuf-kotlin-lite = { group = "com.google.protobuf", name = "protobuf-kotlin-lite", version.ref = "protobuf" } protobuf-kotlin-lite = { group = "com.google.protobuf", name = "protobuf-kotlin-lite", version.ref = "protobuf" }
protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" } protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" }
tink-android = { group = "com.google.crypto.tink", name = "tink-android", version.ref = "tink" } tink-android = { group = "com.google.crypto.tink", name = "tink-android", version.ref = "tink" }
androidx-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "ui" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }