feat: add search by memo, bolt11, payment_hash and preimage
This commit is contained in:
@@ -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,7 +72,6 @@ fun HistoryScreen(
|
||||
|| filter.maxAmountSat != null
|
||||
|| filter.minCreatedAt != null
|
||||
|| filter.maxCreatedAt != null
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ datastore = "1.2.1"
|
||||
protobuf = "4.26.1"
|
||||
protobufPlugin = "0.10.0"
|
||||
tink = "1.13.0"
|
||||
ui = "1.11.2"
|
||||
|
||||
[libraries]
|
||||
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-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" }
|
||||
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]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
Reference in New Issue
Block a user