feat(cashu): add-recommendation input + suggest only on typed text

Two changes in the Cashu Wallet Settings flow:

1. My Mint Recommendations now has its own input row.

   A new OutlinedTextField under the section header lets the user paste
   a mint URL and tap "+" to publish a kind:38000 recommendation
   without having to leave Settings, find the mint elsewhere, and
   thumbs-up it. As the user types, the same cache-backed directory
   autocomplete that AddCashuWallet uses surfaces matching mints from
   the kind:10019 / kind:38000 / kind:38172 the cache already holds —
   tap a suggestion to one-shot recommend (publish + clear the field),
   useful for chaining several adds.

   Suggestions are filtered to drop URLs the user has already
   recommended (de-duped by the lowercased / trailing-slash-stripped
   mint URL across the user's own kind:38000s) so the same row never
   appears in both the autocomplete and the list directly below.

2. Autocomplete reacts only to typed text, not to an empty field.

   The first iteration showed the whole directory the moment the
   field gained focus — i.e. on the Edit Cashu Wallet mint-URL popup
   the user saw mint URLs without typing anything, which felt like the
   form was pre-populating itself. Both call sites
   (AddCashuWalletScreen + CashuWalletSettingsScreen) now early-return
   an empty suggestion list when the trimmed input is blank, so the
   dropdown is purely a reaction to what the user types.

https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
This commit is contained in:
Claude
2026-05-27 15:17:43 +00:00
parent 94121c71c9
commit 0cb07761ce
3 changed files with 160 additions and 5 deletions
@@ -244,14 +244,20 @@ fun AddCashuWalletScreen(
// the current input (no point suggesting what they already
// typed). Wrapped in `derivedStateOf` so the recompute only
// fires when `mintInput` or `mints` change, not on every
// recomposition of the surrounding form.
// recomposition of the surrounding form. An empty field
// shows no suggestions — the autocomplete should react to
// typing, not dump every mint we've ever seen unsolicited.
val suggestions by remember(mints) {
derivedStateOf {
val typed = mintInput.trim().trimEnd('/').lowercase()
val alreadyAdded = mints.map { it.lowercase().trimEnd('/') }.toSet()
LocalCache.mintDirectory
.suggest(typed, limit = 6)
.filter { it != typed && it !in alreadyAdded }
if (typed.isEmpty()) {
emptyList()
} else {
val alreadyAdded = mints.map { it.lowercase().trimEnd('/') }.toSet()
LocalCache.mintDirectory
.suggest(typed, limit = 6)
.filter { it != typed && it !in alreadyAdded }
}
}
}
if (suggestions.isNotEmpty()) {
@@ -41,12 +41,15 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
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
@@ -60,6 +63,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -92,6 +96,11 @@ fun CashuWalletSettingsScreen(
val recommendations by viewModel.ownRecommendations.collectAsState()
var pendingDelete by remember { mutableStateOf<MintRecommendationEvent?>(null) }
var newRecommendationInput by remember { mutableStateOf("") }
// Kick the one-shot directory backfill so the autocomplete is useful
// on first screen open instead of waiting for new relay deliveries.
LaunchedEffect(Unit) { LocalCache.ensureMintDirectoryBackfilled() }
Scaffold(
topBar = {
@@ -136,6 +145,65 @@ fun CashuWalletSettingsScreen(
)
}
// Add-recommendation input + autocomplete. Suggestions come
// from LocalCache.mintDirectory (kind:10019 / kind:38000 /
// kind:38172 the cache has seen), filtered to drop URLs the
// user has already recommended so they can't double-publish
// and the same row doesn't show up twice on screen.
item {
val alreadyRecommended =
remember(recommendations) {
recommendations
.flatMap { it.mintUrls() }
.map { it.lowercase().trimEnd('/') }
.toSet()
}
val suggestions by remember(newRecommendationInput, alreadyRecommended) {
derivedStateOf {
val typed = newRecommendationInput.trim().trimEnd('/').lowercase()
// Only react to what the user types — never show
// the full directory on an empty field, which
// would dump every mint we've ever seen as
// unsolicited suggestions.
if (typed.isEmpty()) {
emptyList()
} else {
LocalCache.mintDirectory
.suggest(typed, limit = 6)
.filter { it != typed && it !in alreadyRecommended }
}
}
}
AddRecommendationRow(
input = newRecommendationInput,
onInputChange = { newRecommendationInput = it },
canAdd =
newRecommendationInput.isNotBlank() &&
newRecommendationInput.trim().trimEnd('/').lowercase() !in alreadyRecommended,
onAdd = {
val trimmed = newRecommendationInput.trim().trimEnd('/')
if (trimmed.isNotEmpty()) {
viewModel.recommendMint(trimmed)
newRecommendationInput = ""
}
},
)
if (suggestions.isNotEmpty()) {
Spacer(modifier = Modifier.height(6.dp))
RecommendationSuggestionList(
suggestions = suggestions,
onPick = { url ->
// One-tap recommend: publish straight from
// the suggestion and clear the field so the
// user can chain multiple adds without
// re-tapping the text input.
viewModel.recommendMint(url)
newRecommendationInput = ""
},
)
}
}
if (recommendations.isEmpty()) {
item { EmptyRecommendationsHint() }
} else {
@@ -228,6 +296,86 @@ private fun SettingsRow(
}
}
@Composable
private fun AddRecommendationRow(
input: String,
onInputChange: (String) -> Unit,
canAdd: Boolean,
onAdd: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = input,
onValueChange = onInputChange,
label = { Text(stringRes(R.string.cashu_settings_add_recommendation)) },
placeholder = { Text("https://mint.example.com") },
singleLine = true,
modifier = Modifier.weight(1f),
)
Spacer(modifier = Modifier.width(8.dp))
IconButton(onClick = onAdd, enabled = canAdd) {
Icon(
symbol = MaterialSymbols.Add,
contentDescription = stringRes(R.string.cashu_settings_add_recommendation),
modifier = Modifier.size(22.dp),
tint =
if (canAdd) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
}
@Composable
private fun RecommendationSuggestionList(
suggestions: List<String>,
onPick: (String) -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(modifier = Modifier.padding(vertical = 4.dp)) {
suggestions.forEach { url ->
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable { onPick(url) }
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
symbol = MaterialSymbols.AccountBalanceWallet,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = url,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
)
Icon(
symbol = MaterialSymbols.ThumbUp,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
@Composable
private fun EmptyRecommendationsHint() {
Card(
+1
View File
@@ -1926,6 +1926,7 @@
<string name="cashu_settings_delete_recommendation">Delete recommendation</string>
<string name="cashu_settings_delete_confirm_title">Retract recommendation?</string>
<string name="cashu_settings_delete_confirm_body">Publish a delete request for your kind:38000 recommendation of %1$s. Relays that honor NIP-09 will drop it.</string>
<string name="cashu_settings_add_recommendation">Recommend a mint</string>
<string name="cashu_pay_invoice">Pay invoice</string>
<string name="cashu_get_quote">Get quote</string>
<string name="cashu_getting_quote">Asking mint for a quote…</string>