From 0f932b02296ee77eca9f13fd58d4c2ffc0916762 Mon Sep 17 00:00:00 2001 From: rasputin Date: Wed, 3 Jun 2026 14:27:59 +0200 Subject: [PATCH] feat: add search by memo, bolt11, payment_hash and preimage --- app/build.gradle.kts | 1 + .../gudariwallet/ui/HistoryScreen.kt | 198 +++++++++++++++++- .../gudariwallet/ui/HistoryViewModel.kt | 25 ++- gradle/libs.versions.toml | 2 + 4 files changed, 217 insertions(+), 9 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dad0536..5d53484 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryScreen.kt index 6130773..8a9b22d 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryScreen.kt @@ -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 = 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 90fed98..fb8025d 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryViewModel.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryViewModel.kt @@ -52,6 +52,14 @@ class HistoryViewModel( private val _state = MutableStateFlow(HistoryState.Loading) val state: StateFlow = _state + // ── Search state ─────────────────────────────────────────────────────── + + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _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 = combine(_state, _filter) { s, f -> + val filteredState: StateFlow = 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) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fb16f15..ff519f8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }