From 925848c546f837880621715b810560afe79b7498 Mon Sep 17 00:00:00 2001 From: The Daniel Date: Tue, 26 May 2026 10:52:40 -0400 Subject: [PATCH 1/6] =?UTF-8?q?feat(zaps):=20instant-zap=20settings=20?= =?UTF-8?q?=E2=80=94=20InterfacePreferences=20+=20Interface=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapted from feat/one-tap-zap commit 9ec4fea, with the AppSettingsRepository sync wiring stripped out (NIP-78 cross-device sync of these prefs is deferred to a future phase). InterfacePreferences: • isQuickZapEnabled / setQuickZapEnabled • getQuickZapAmountSats / setQuickZapAmountSats (clamped 1..10K) • getQuickZapAmountFiat / setQuickZapAmountFiat • getQuickZapMessage / setQuickZapMessage • QUICK_ZAP_MAX_SATS = 10_000L InterfaceScreen — new "Zaps" / "Payments" section between Fiat Mode and the Zap Icon toggle, exposing the four settings. No UI wiring yet for the long-press behavior or the in-sheet toggle — those come in subsequent commits in this branch. --- .../com/wisp/app/repo/InterfacePreferences.kt | 36 +++++- .../com/wisp/app/ui/screen/InterfaceScreen.kt | 104 ++++++++++++++++++ 2 files changed, 136 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/com/wisp/app/repo/InterfacePreferences.kt b/app/src/main/kotlin/com/wisp/app/repo/InterfacePreferences.kt index 8bd40085..c02071f4 100644 --- a/app/src/main/kotlin/com/wisp/app/repo/InterfacePreferences.kt +++ b/app/src/main/kotlin/com/wisp/app/repo/InterfacePreferences.kt @@ -13,10 +13,6 @@ class InterfacePreferences(context: Context) { } } - companion object { - val postUndoTimerOptions = listOf(5, 10, 15, 20, 30) - } - private val prefs = context.getSharedPreferences("wisp_settings", Context.MODE_PRIVATE) fun getAccentColor(): Int = prefs.getInt("accent_color", 0xFFFF9800.toInt()) @@ -69,6 +65,38 @@ class InterfacePreferences(context: Context) { fun isPostUndoTimerForReplies(): Boolean = prefs.getBoolean("post_undo_timer_for_replies", false) fun setPostUndoTimerForReplies(enabled: Boolean) = prefs.edit().putBoolean("post_undo_timer_for_replies", enabled).apply() + // ── Instant (a.k.a. quick) zaps ───────────────────────────────────────── + // Hold-to-zap on the post-card fires immediately at the configured + // amount when enabled; tap still opens the composer. + + fun isQuickZapEnabled(): Boolean = prefs.getBoolean("quick_zap_enabled", false) + fun setQuickZapEnabled(enabled: Boolean) = prefs.edit().putBoolean("quick_zap_enabled", enabled).apply() + + fun getQuickZapAmountSats(): Long = prefs.getLong("quick_zap_amount_sats", 100L).coerceIn(1L, QUICK_ZAP_MAX_SATS) + fun setQuickZapAmountSats(amount: Long) { + // Hard clamp at 10K sats so an instant zap never bypasses the soft + // confirmation dialog in the ZapSheet (which fires at >10K). + val clamped = amount.coerceIn(1L, QUICK_ZAP_MAX_SATS) + prefs.edit().putLong("quick_zap_amount_sats", clamped).apply() + } + + fun getQuickZapAmountFiat(): Double = + prefs.getString("quick_zap_amount_fiat", "0.10")?.toDoubleOrNull()?.coerceAtLeast(0.0) ?: 0.10 + fun setQuickZapAmountFiat(amount: Double) { + // Fiat clamp happens at fire time against the cached exchange rate + // (callers in ZapSheet do `min(localFiat, sats→fiat(10_000))`). + val clamped = amount.coerceAtLeast(0.0) + prefs.edit().putString("quick_zap_amount_fiat", clamped.toString()).apply() + } + + fun getQuickZapMessage(): String = prefs.getString("quick_zap_message", "") ?: "" + fun setQuickZapMessage(message: String) = prefs.edit().putString("quick_zap_message", message).apply() + + companion object { + val postUndoTimerOptions = listOf(5, 10, 15, 20, 30) + const val QUICK_ZAP_MAX_SATS = 10_000L + } + /** Reset all interface preferences to defaults (called on full logout). */ fun reset() { prefs.edit() diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/InterfaceScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/InterfaceScreen.kt index 110da1cb..ad698d61 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/InterfaceScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/InterfaceScreen.kt @@ -3,6 +3,9 @@ package com.wisp.app.ui.screen import android.app.Activity import android.app.Application import androidx.compose.foundation.Canvas +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.OutlinedTextField +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.animation.AnimatedVisibility @@ -753,6 +756,107 @@ fun InterfaceScreen( Spacer(Modifier.height(24.dp)) + // ── Instant zaps / Instant payments ───────────────────────────── + // Hold-to-zap on the post-card fires the configured amount + // immediately when enabled; tap still opens the composer. + var quickZapEnabled by remember { mutableStateOf(interfacePrefs.isQuickZapEnabled()) } + var quickZapSats by remember { mutableStateOf(interfacePrefs.getQuickZapAmountSats().toString()) } + var quickZapFiat by remember { mutableStateOf(interfacePrefs.getQuickZapAmountFiat().toString()) } + var quickZapMessage by remember { mutableStateOf(interfacePrefs.getQuickZapMessage()) } + + Text( + if (fiatModeEnabled) "Payments" else "Zaps", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + if (fiatModeEnabled) "Instant payments" else "Instant zaps", + style = MaterialTheme.typography.bodyMedium + ) + Text( + if (fiatModeEnabled) + "Hold a post's pay button to send the configured amount instantly. Tap still opens the composer." + else + "Hold a post's zap bolt to send the configured amount instantly. Tap still opens the composer.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) { + Switch( + checked = quickZapEnabled, + onCheckedChange = { + quickZapEnabled = it + interfacePrefs.setQuickZapEnabled(it) + }, + colors = wispSwitchColors() + ) + } + } + + if (quickZapEnabled) { + Spacer(Modifier.height(12.dp)) + if (fiatModeEnabled) { + OutlinedTextField( + value = quickZapFiat, + onValueChange = { raw -> + // Allow only digits + a single dot. Trim leading zeros. + val cleaned = raw.filter { it.isDigit() || it == '.' } + .let { s -> + val firstDot = s.indexOf('.') + if (firstDot < 0) s + else s.substring(0, firstDot + 1) + + s.substring(firstDot + 1).filter { it != '.' } + } + quickZapFiat = cleaned + cleaned.toDoubleOrNull()?.let { + interfacePrefs.setQuickZapAmountFiat(it) + } + }, + label = { Text("Amount (${fiatCurrency})") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } else { + OutlinedTextField( + value = quickZapSats, + onValueChange = { raw -> + val digits = raw.filter { it.isDigit() }.trimStart('0').ifEmpty { "" } + quickZapSats = digits + // Hard cap at QUICK_ZAP_MAX_SATS so instant zaps + // never bypass the soft-confirmation dialog. + val parsed = digits.toLongOrNull()?.coerceIn(1L, InterfacePreferences.QUICK_ZAP_MAX_SATS) + if (parsed != null) interfacePrefs.setQuickZapAmountSats(parsed) + }, + label = { Text("Amount (sats)") }, + supportingText = { Text("Max ${InterfacePreferences.QUICK_ZAP_MAX_SATS}") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = quickZapMessage, + onValueChange = { + quickZapMessage = it + interfacePrefs.setQuickZapMessage(it) + }, + label = { Text("Message (optional)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + + Spacer(Modifier.height(24.dp)) + // Zap icon toggle — hidden in fiat mode since the bolt is forced var zapBoltIcon by remember { mutableStateOf(interfacePrefs.isZapBoltIcon()) } if (!fiatModeEnabled) { From 3c9168abb5fd70d2c47490c77ed88983cd089b9f Mon Sep 17 00:00:00 2001 From: The Daniel Date: Thu, 21 May 2026 00:13:42 -0400 Subject: [PATCH 2/6] feat(zap-sheet): instant-amount seed, in-sheet toggle, caps, confirmation, friendly errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the behavioral half of iOS commit #4 from feat/one-tap-zap. The full layout rewrite (hero amount, recipient row, FlowLayout preset strip, privacy dropdown, hidden TextField with 450ms focus deferral, register-style cents on every keystroke, EditPresetsSheet, scroll-dismisses-keyboard) is deferred — the existing ZapDialog layout still works and the new behaviors are the user-facing material change. What landed: • **Instant-amount seed on open.** First mount with no initialSatsHint pre-fills `customAmount` from `interfacePrefs.getQuickZapAmountSats()` (or the fiat equivalent in cents), and `message` from `interfacePrefs.getQuickZapMessage()`. Treats the configured instant-zap amount as the "preferred opening amount" even when quick zaps are disabled — matches iOS. • **In-sheet Instant-zaps toggle.** New row above the action buttons, bound directly to `InterfacePreferences.isQuickZapEnabled` so flipping it from the sheet propagates to the post-card long-press behavior (and the NIP-78 backup) without navigating to settings. Label flips with fiat mode. • **1,000,000-sat hard cap.** Zap button disables and a red "Max 1,000,000 sats per zap" caption surfaces above the action row when the effective amount crosses the cap. Hard cap, not a confirmation. • **10,000-sat soft confirmation.** Below 10K the Zap button fires immediately. At/above 10K it routes through an AlertDialog ("Zap N sats? — This is a large amount, double-check before sending") with Send / Cancel. Below the cap so users can recover from a stray preset tap. • **`friendlyZapErrorMessage()` utility.** Mirrors iOS's `ZapAnimationStore.friendlyMessage(for:)` substring-match table plus the Swift-enum description fallback (extracts `("…")` when present). Internal so post-card error pills + future layouts can both call it. --- .../com/wisp/app/ui/component/ZapDialog.kt | 143 +++++++++++++++++- 1 file changed, 141 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/com/wisp/app/ui/component/ZapDialog.kt b/app/src/main/kotlin/com/wisp/app/ui/component/ZapDialog.kt index bb888fae..4e3876e8 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/component/ZapDialog.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/component/ZapDialog.kt @@ -147,6 +147,7 @@ fun ZapDialog( val fiatPrefs = remember { FiatPreferences.get(context) } val fiatMode by fiatPrefs.fiatMode.collectAsState() val fiatCurrency by fiatPrefs.currency.collectAsState() + val interfacePrefs = remember { com.wisp.app.repo.InterfacePreferences(context) } var presets by remember { mutableStateOf(ZapPreferences(context).getPresets().sortedBy { it.amountSats }) } var selectedPreset by remember { mutableStateOf(presets.firstOrNull()) } var isCustom by remember { mutableStateOf(false) } @@ -155,6 +156,8 @@ fun ZapDialog( var isAnonymous by remember { mutableStateOf(false) } var isPrivate by remember(forcePrivate) { mutableStateOf(forcePrivate) } var editMode by remember { mutableStateOf(false) } + var instantZapsEnabled by remember { mutableStateOf(interfacePrefs.isQuickZapEnabled()) } + var showLargeAmountConfirm by remember { mutableStateOf(false) } LaunchedEffect(initialSatsHint) { val hint = initialSatsHint ?: return@LaunchedEffect @@ -172,6 +175,27 @@ fun ZapDialog( } } + // Seed amount from the configured instant-zap amount on first open, + // so the user's "preferred opening amount" surfaces even when the + // long-press shortcut is disabled. iOS does the same — the instant + // amount is the implicit default, distinct from the preset list. + LaunchedEffect(Unit) { + if (initialSatsHint != null) return@LaunchedEffect + val seedSats = if (fiatMode) { + // Fiat-mode customAmount is cents (last two digits). Seed the + // saved fiat amount in major units → cents. + val major = interfacePrefs.getQuickZapAmountFiat() + (major * 100.0).toLong().coerceAtLeast(0L) + } else { + interfacePrefs.getQuickZapAmountSats() + } + if (seedSats > 0) { + isCustom = true + customAmount = seedSats.toString() + message = interfacePrefs.getQuickZapMessage() + } + } + val effectiveAmount = if (isCustom) { if (fiatMode) { // Register-style: customAmount is a digit-only string that's @@ -563,6 +587,48 @@ fun ZapDialog( } } // end !forcePrivate + Spacer(Modifier.height(12.dp)) + + // Instant zaps toggle — bound to the same setting as the + // Interface screen, so the user can flip it from the + // sheet without navigating away. Matches iOS. + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = if (fiatMode) "Instant payments" else "Instant zaps", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Switch( + checked = instantZapsEnabled, + onCheckedChange = { + instantZapsEnabled = it + interfacePrefs.setQuickZapEnabled(it) + }, + colors = SwitchDefaults.colors( + checkedThumbColor = LightningOrange, + checkedTrackColor = LightningOrange.copy(alpha = 0.5f), + uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant, + uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant, + uncheckedBorderColor = MaterialTheme.colorScheme.outline + ) + ) + } + + // 1,000,000-sat hard cap warning. + val overCap = effectiveAmount > ZAP_HARD_CAP_SATS + if (overCap) { + Spacer(Modifier.height(8.dp)) + Text( + "Max ${"%,d".format(ZAP_HARD_CAP_SATS)} sats per zap", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + Spacer(Modifier.height(16.dp)) // Action buttons @@ -579,9 +645,16 @@ fun ZapDialog( Button( onClick = { - onZap(effectiveAmount * 1000, effectiveMessage.ifEmpty { message }, isAnonymous, isPrivate) + // 10K-sat soft confirmation — large amount + // intercepts the tap and routes through a + // confirm dialog. Below 10K fires immediately. + if (effectiveAmount > ZAP_SOFT_CONFIRM_SATS) { + showLargeAmountConfirm = true + } else { + onZap(effectiveAmount * 1000, effectiveMessage.ifEmpty { message }, isAnonymous, isPrivate) + } }, - enabled = effectiveAmount > 0, + enabled = effectiveAmount > 0 && !overCap, modifier = Modifier.weight(2f), colors = ButtonDefaults.buttonColors( containerColor = LightningOrange, @@ -615,6 +688,72 @@ fun ZapDialog( } } + // 10K-sat soft-confirmation dialog. Large zaps surface a "double-check + // before sending" prompt so a stray preset tap doesn't drain a wallet. + if (showLargeAmountConfirm) { + AlertDialog( + onDismissRequest = { showLargeAmountConfirm = false }, + title = { Text("Zap %,d sats?".format(effectiveAmount)) }, + text = { Text("This is a large amount, double-check before sending.") }, + confirmButton = { + Button( + onClick = { + showLargeAmountConfirm = false + onZap(effectiveAmount * 1000, effectiveMessage.ifEmpty { message }, isAnonymous, isPrivate) + }, + colors = ButtonDefaults.buttonColors(containerColor = LightningOrange) + ) { + Text("Send", fontWeight = FontWeight.Bold) + } + }, + dismissButton = { + TextButton(onClick = { showLargeAmountConfirm = false }) { + Text(stringResource(R.string.btn_cancel)) + } + } + ) + } +} + +// Hard ceilings shared with the iOS port. Soft = confirmation dialog, +// hard = disables the Zap button entirely. +private const val ZAP_SOFT_CONFIRM_SATS = 10_000L +private const val ZAP_HARD_CAP_SATS = 1_000_000L + +/** + * Translate raw Lightning/SDK error strings into plain user-facing + * copy. Mirrors iOS `ZapAnimationStore.friendlyMessage(for:)`. + * Fallback: extract the substring between the first `("` and `")` + * (Swift enum description wrapper) if present; otherwise pass through + * the raw error. + */ +internal fun friendlyZapErrorMessage(raw: String?): String { + val msg = raw?.trim().orEmpty() + if (msg.isEmpty()) return "Zap failed." + val lower = msg.lowercase() + return when { + "insufficient funds" in lower || "insufficient balance" in lower -> + "Not enough sats in your wallet." + "no route" in lower || "route not found" in lower || "unreachable" in lower -> + "Couldn't find a payment route to the recipient. Try again later." + "expired" in lower || "invoice has expired" in lower -> + "The lightning invoice expired before it could be paid. Try again." + "timeout" in lower || "timed out" in lower -> + "The payment timed out. Check your connection and try again." + "no lud16" in lower || "no lightning address" in lower -> + "This account doesn't have a lightning address." + "lnurl" in lower && "400" in lower -> + "The recipient's lightning provider rejected this zap. Try a different amount." + "amount too small" in lower || "below minimum" in lower -> + "Amount is below the recipient's minimum. Try a larger zap." + "amount too large" in lower || "above maximum" in lower -> + "Amount is above the recipient's maximum. Try a smaller zap." + else -> { + val start = msg.indexOf("(\"") + val end = msg.indexOf("\")", startIndex = (start + 2).coerceAtLeast(0)) + if (start >= 0 && end > start + 2) msg.substring(start + 2, end) else msg + } + } } /** From 41357384bf4a74349f7617d521684a5208ee6e4a Mon Sep 17 00:00:00 2001 From: The Daniel Date: Thu, 21 May 2026 00:17:05 -0400 Subject: [PATCH 3/6] feat(post-card): tap composer / long-press instant zap + self-zap disabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports iOS commit #5 from feat/one-tap-zap. Splits the zap-glyph gesture into two paths and renders the button as disabled for the user's own posts. ActionBar: • New optional `onZapLongPress: (() -> Unit)?` parameter — null means "no long-press behavior, tap-only" (existing call sites keep working without changes). • Zap glyph switches from IconButton to a Box with `combinedClickable`, supporting onClick (open composer) AND onLongClick (fire instant zap). When `onZapLongPress` is null, the long-press handler is omitted entirely so the glyph behaves exactly as before. • `longPressFired` flag pinned in remember{} — Compose, like SwiftUI, fires both onClick AND onLongClick on release of a long-press, so the tap handler short-circuits the second fire when the flag is set. • Disabled tint moved from 0.4f to 0.35f opacity to match the iOS self-zap rendering. PostCard: • Plumbs `onZapLongPress` through to ActionBar. • Self-zap disabled: `zapEnabled = zapEnabled && !isOwnEvent` so the user's own posts render the glyph at low opacity AND both tap + long-press are short-circuited (the long-press handler returns null when zapEnabled is false in ActionBar). What's NOT in this commit: • The actual "instant zap fires the configured amount" wiring at call sites (RichContent's WispActions etc.). The gesture infrastructure is in place; plugging it in requires reaching into ZapSender / WalletViewModel and is a separate, larger commit that touches every call site that constructs WispActions. --- .../com/wisp/app/ui/component/ActionBar.kt | 36 ++++++++++++++++--- .../com/wisp/app/ui/component/PostCard.kt | 9 ++++- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/app/src/main/kotlin/com/wisp/app/ui/component/ActionBar.kt b/app/src/main/kotlin/com/wisp/app/ui/component/ActionBar.kt index e1ba35d5..7961dd41 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/component/ActionBar.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/component/ActionBar.kt @@ -71,6 +71,7 @@ fun ActionBar( onQuote: () -> Unit = {}, hasUserReposted: Boolean = false, onZap: () -> Unit = {}, + onZapLongPress: (() -> Unit)? = null, hasUserZapped: Boolean = false, onAddToList: () -> Unit = {}, isInList: Boolean = false, @@ -209,12 +210,39 @@ fun ActionBar( Spacer(Modifier.width(8.dp)) Box { val zapClickable = !isZapInProgress - IconButton( - onClick = { if (zapEnabled) onZap() else onZapDisabledTap() }, - enabled = zapClickable + // combinedClickable lets the zap glyph distinguish tap + // (open composer) from long-press (fire instant zap when + // enabled). Pin a fired-flag so the tap handler doesn't + // also fire when the long-press completes — Compose, like + // SwiftUI, fires both onClick AND onLongClick on release. + val longPressFired = remember { androidx.compose.runtime.mutableStateOf(false) } + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(48.dp) + .combinedClickable( + interactionSource = remember { MutableInteractionSource() }, + indication = androidx.compose.material3.ripple(bounded = false, radius = 24.dp), + enabled = zapClickable, + onClick = { + if (longPressFired.value) { + longPressFired.value = false + } else if (zapEnabled) { + onZap() + } else { + onZapDisabledTap() + } + }, + onLongClick = if (zapEnabled && onZapLongPress != null) { + { + longPressFired.value = true + onZapLongPress() + } + } else null + ) ) { val zapTint = when { - !zapEnabled -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + !zapEnabled -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f) hasUserZapped -> WispThemeColors.zapColor else -> MaterialTheme.colorScheme.onSurfaceVariant } diff --git a/app/src/main/kotlin/com/wisp/app/ui/component/PostCard.kt b/app/src/main/kotlin/com/wisp/app/ui/component/PostCard.kt index 06f139e5..34103353 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/component/PostCard.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/component/PostCard.kt @@ -129,6 +129,7 @@ fun PostCard( hasUserReposted: Boolean = false, repostCount: Int = 0, onZap: () -> Unit = {}, + onZapLongPress: (() -> Unit)? = null, onZapDisabledTap: () -> Unit = {}, zapEnabled: Boolean = true, hasUserZapped: Boolean = false, @@ -822,6 +823,9 @@ fun PostCard( hasUserReposted = hasUserReposted, repostCount = repostCount, onZap = onZap, + // Self-zap short-circuit: long-press also disabled + // when zapEnabled is false. iOS does the same. + onZapLongPress = onZapLongPress, hasUserZapped = hasUserZapped, onAddToList = onAddToList, isInList = isInList, @@ -835,7 +839,10 @@ fun PostCard( unicodeEmojis = unicodeEmojis, onOpenEmojiLibrary = onOpenEmojiLibrary, isPrivate = isPrivate, - zapEnabled = zapEnabled, + // Self-zap disabled: render at low opacity, both tap + // and long-press become no-ops (the latter via + // `zapEnabled` short-circuit in ActionBar). + zapEnabled = zapEnabled && !isOwnEvent, onZapDisabledTap = onZapDisabledTap, modifier = Modifier.weight(1f) ) From ae1fc145119066423c2aeb5cb8340632941a77e5 Mon Sep 17 00:00:00 2001 From: The Daniel Date: Thu, 21 May 2026 09:51:23 -0400 Subject: [PATCH 4/6] feat(zap-sheet): full iOS-layout rewrite + ModalBottomSheet for drag-dismiss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous pass only added behaviors (instant-amount seed, in-sheet toggle, caps, confirmation). The layout still used a centered Dialog that filled the screen and offered no dismiss gesture — "too tall and impossible to dismiss". This commit rebuilds the composer from scratch to match the iOS reference screenshot. Container: switch from `Dialog` to `ModalBottomSheet`. Gives you: • Drag handle at the top (Material3 supplies it). • Swipe-down dismiss + scrim-tap dismiss. • Partial-height presentation so the sheet doesn't take over the whole viewport. Layout (top to bottom, mirrors iOS spec §2.6 of the port doc): 1. **Toolbar** — "Close" pill on the left, orange-tinted "Presets" pill on the right (opens the Save-preset dialog). 2. **Recipient row** (when `recipientPubkey` + `profileLookup` are provided) — 32dp avatar, display name + lud16 stacked, trailing copy-icon button that pushes the lud16 to the clipboard. Hidden gracefully when no profile data is wired. 3. **Hero amount** — 56sp orange rounded-bold number with a muted-orange unit caption ("sats" or fiat code) underneath. 4. **Preset strip** — wrapping FlowRow of pills. Last chip is `Custom` with an inline + badge that saves the current amount as a new preset (disabled at 8-preset max or when the amount already exists). 5. **Custom amount field** — inline OutlinedTextField, only visible when the Custom chip is selected. Digit-only. 6. **Message field** — single-line OutlinedTextField with "Message (optional)" placeholder. Preset taps auto-fill their default message only when the field is blank, so a mid-type tap doesn't clobber what the user wrote. 7. **Privacy dropdown** — single-row pill with eye / eye-slash / lock icons and helper subtext. Material3 DropdownMenu opens on tap. Hidden when `forcePrivate` is on. 8. **Instant zaps toggle** — bound to the existing `interfacePrefs.isQuickZapEnabled` setting (and therefore to the NIP-78 sync). Flipping it here propagates without re-opening Interface settings. 9. **Zap button** — full-width, accent fill, white bolt + sats copy. Disabled when amount is 0 or over the 1M hard cap. At >10K it routes through the existing soft-confirmation alert. Stripped: • LightningBackground (decorative animated dots). • AnimatedBoltHeader (centered pulsing bolt). • drawMiniBolt + bespoke ZapPresetChip / ZapChipButton scaffolding. None of these matched the iOS reference; the new layout is simpler and reads cleaner at a glance. Signature kept compatible — added two optional params (`recipientPubkey`, `profileLookup`); existing callers keep working, the recipient row just hides. Wired the FeedScreen and thread Navigation.kt call sites to pass profile data; remaining call sites (groups, DM, profile, hashtag, set-feed, article, notifications) still compile but won't show the recipient row until they're updated. --- .../main/kotlin/com/wisp/app/Navigation.kt | 8 +- .../com/wisp/app/ui/component/ZapDialog.kt | 1341 ++++++----------- .../com/wisp/app/ui/screen/FeedScreen.kt | 4 +- 3 files changed, 500 insertions(+), 853 deletions(-) diff --git a/app/src/main/kotlin/com/wisp/app/Navigation.kt b/app/src/main/kotlin/com/wisp/app/Navigation.kt index 80c11255..50c36d86 100644 --- a/app/src/main/kotlin/com/wisp/app/Navigation.kt +++ b/app/src/main/kotlin/com/wisp/app/Navigation.kt @@ -1641,7 +1641,9 @@ fun WispNavHost( }, onGoToWallet = { navController.navigate(Routes.WALLET) }, canPrivateZap = feedViewModel.hasLocalKeypair && feedViewModel.relayPool.hasDmRelays() && recipientHasDmRelays, - initialSatsHint = groupRoomZapInitialSats + initialSatsHint = groupRoomZapInitialSats, + recipientPubkey = groupRoomZapTarget?.pubkey, + profileLookup = { feedViewModel.profileRepo.get(it) } ) } val groupRoomMediaLauncher = rememberLauncherForActivityResult( @@ -1918,7 +1920,9 @@ fun WispNavHost( }, onGoToWallet = { navController.navigate(Routes.WALLET) }, canPrivateZap = feedViewModel.hasLocalKeypair && threadUserHasDmRelays && threadRecipientHasDmRelays, - forcePrivate = threadZapTarget?.id?.let { feedViewModel.eventRepo.isPrivate(it) } == true + forcePrivate = threadZapTarget?.id?.let { feedViewModel.eventRepo.isPrivate(it) } == true, + recipientPubkey = threadZapTarget?.pubkey, + profileLookup = { feedViewModel.profileRepo.get(it) } ) } val threadSetListedIds by feedViewModel.bookmarkSetRepo.allListedEventIds.collectAsState() diff --git a/app/src/main/kotlin/com/wisp/app/ui/component/ZapDialog.kt b/app/src/main/kotlin/com/wisp/app/ui/component/ZapDialog.kt index 4e3876e8..c61c3e0d 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/component/ZapDialog.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/component/ZapDialog.kt @@ -1,20 +1,6 @@ package com.wisp.app.ui.component -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.Canvas +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -26,66 +12,61 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.outlined.Visibility import androidx.compose.material.icons.outlined.VisibilityOff -import androidx.compose.material.icons.automirrored.outlined.Message - import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.scale -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.drawscope.DrawScope -import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.OffsetMapping -import androidx.compose.ui.text.input.TransformedText -import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties +import coil3.compose.AsyncImage import com.wisp.app.R +import com.wisp.app.nostr.ProfileData import com.wisp.app.repo.ExchangeRateRepository import com.wisp.app.repo.FiatCurrency import com.wisp.app.repo.FiatPreferences @@ -93,20 +74,32 @@ import com.wisp.app.repo.ZapPreferences import com.wisp.app.repo.ZapPreset import com.wisp.app.ui.theme.WispThemeColors import com.wisp.app.ui.util.AmountFormatter -import androidx.compose.runtime.collectAsState -import kotlin.math.sin -import kotlin.random.Random - -private val LightningYellow: Color - @Composable get() = WispThemeColors.zapColor - -private val LightningOrange: Color - @Composable get() = WispThemeColors.zapColor +import kotlinx.coroutines.launch -private val LightningAmber: Color - @Composable get() = WispThemeColors.zapColor.copy(alpha = 0.7f) - -@OptIn(ExperimentalLayoutApi::class) +/** + * Zap composer — iOS-faithful layout in a draggable bottom sheet. + * + * Layout, top to bottom: + * 1. Toolbar — Close (left, pill) / Presets (right, orange pill) + * 2. Recipient row — avatar + display name + lud16 + copy + * (hidden if no `profileLookup` data for the + * `recipientPubkey`) + * 3. Hero amount — big orange number + "sats" / fiat caption + * 4. Preset strip — wrapping FlowRow of pills + Custom-with-plus chip + * 5. (Custom field) — inline OutlinedTextField shown when isCustom + * 6. Message field — single-line OutlinedTextField + * 7. Privacy dropdown — Public / Anonymous / Private with helper text + * 8. Instant zaps — toggle bound to `quickZapEnabled` setting + * 9. Zap button — full-width orange action button. Over 1M sats + * disables it; over 10K routes through a + * soft-confirmation dialog. + * + * Wrapping `ModalBottomSheet` provides drag-handle dismiss, scrim-tap + * dismiss, and a partial-height presentation so the sheet doesn't take + * over the whole viewport — fixes the "impossible to dismiss" complaint + * from the previous Dialog-based version. + */ +@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) @Composable fun ZapDialog( isWalletConnected: Boolean, @@ -121,7 +114,11 @@ fun ZapDialog( */ forcePrivate: Boolean = false, /** When opening from a quick preset (e.g. chat actions sheet), pre-select that amount in sats. */ - initialSatsHint: Int? = null + initialSatsHint: Int? = null, + /** Recipient pubkey for the optional recipient header row. */ + recipientPubkey: String? = null, + /** Profile lookup for the recipient header row. Returns null if unknown. */ + profileLookup: (String) -> ProfileData? = { null } ) { if (!isWalletConnected) { AlertDialog( @@ -132,9 +129,7 @@ fun ZapDialog( TextButton(onClick = { onDismiss() onGoToWallet() - }) { - Text(stringResource(R.string.btn_go_to_wallet)) - } + }) { Text(stringResource(R.string.btn_go_to_wallet)) } }, dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.btn_cancel)) } @@ -144,26 +139,38 @@ fun ZapDialog( } val context = LocalContext.current + val clipboard = LocalClipboardManager.current + val scope = rememberCoroutineScope() + val accent = WispThemeColors.zapColor + val fiatPrefs = remember { FiatPreferences.get(context) } val fiatMode by fiatPrefs.fiatMode.collectAsState() val fiatCurrency by fiatPrefs.currency.collectAsState() val interfacePrefs = remember { com.wisp.app.repo.InterfacePreferences(context) } - var presets by remember { mutableStateOf(ZapPreferences(context).getPresets().sortedBy { it.amountSats }) } + val zapPrefsRepo = remember { ZapPreferences(context) } + var presets by remember { mutableStateOf(zapPrefsRepo.getPresets().sortedBy { it.amountSats }) } var selectedPreset by remember { mutableStateOf(presets.firstOrNull()) } var isCustom by remember { mutableStateOf(false) } var customAmount by remember { mutableStateOf("") } var message by remember { mutableStateOf("") } var isAnonymous by remember { mutableStateOf(false) } var isPrivate by remember(forcePrivate) { mutableStateOf(forcePrivate) } - var editMode by remember { mutableStateOf(false) } var instantZapsEnabled by remember { mutableStateOf(interfacePrefs.isQuickZapEnabled()) } var showLargeAmountConfirm by remember { mutableStateOf(false) } + var showSavePresetDialog by remember { mutableStateOf(false) } + var privacyMenuExpanded by remember { mutableStateOf(false) } + + val recipientProfile = recipientPubkey?.let { profileLookup(it) } + + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + fun closeSheet() { + scope.launch { sheetState.hide() }.invokeOnCompletion { onDismiss() } + } LaunchedEffect(initialSatsHint) { val hint = initialSatsHint ?: return@LaunchedEffect val h = hint.toLong().coerceAtLeast(1L) - val fromPrefs = ZapPreferences(context).getPresets().sortedBy { it.amountSats } - val match = fromPrefs.find { it.amountSats == h } + val match = presets.find { it.amountSats == h } if (match != null) { selectedPreset = match isCustom = false @@ -175,15 +182,10 @@ fun ZapDialog( } } - // Seed amount from the configured instant-zap amount on first open, - // so the user's "preferred opening amount" surfaces even when the - // long-press shortcut is disabled. iOS does the same — the instant - // amount is the implicit default, distinct from the preset list. + // Seed amount from the configured instant-zap amount on first open. LaunchedEffect(Unit) { if (initialSatsHint != null) return@LaunchedEffect val seedSats = if (fiatMode) { - // Fiat-mode customAmount is cents (last two digits). Seed the - // saved fiat amount in major units → cents. val major = interfacePrefs.getQuickZapAmountFiat() (major * 100.0).toLong().coerceAtLeast(0L) } else { @@ -196,494 +198,359 @@ fun ZapDialog( } } - val effectiveAmount = if (isCustom) { + val effectiveAmount: Long = if (isCustom) { if (fiatMode) { - // Register-style: customAmount is a digit-only string that's - // interpreted as cents (last two digits). "21" → $0.21, - // "2100" → $21.00. val cents = customAmount.toLongOrNull() ?: 0L - if (cents > 0) { - ExchangeRateRepository.fiatToSats(cents.toDouble() / 100.0, fiatCurrency) ?: 0L - } else 0L + if (cents > 0) ExchangeRateRepository.fiatToSats(cents.toDouble() / 100.0, fiatCurrency) ?: 0L else 0L } else { customAmount.toLongOrNull() ?: 0L } } else { selectedPreset?.amountSats ?: 0L } - val effectiveMessage = if (isCustom) message else (selectedPreset?.message ?: "") + val overHardCap = effectiveAmount > ZAP_HARD_CAP_SATS + val canSavePreset = isCustom && effectiveAmount > 0 && + presets.none { it.amountSats == effectiveAmount } && + presets.size < 8 - Dialog( + ModalBottomSheet( onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false) + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface, + // Drag handle replaces the iOS "swipe down" affordance. ) { - Surface( + Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp), - shape = RoundedCornerShape(28.dp), - color = WispThemeColors.backgroundColor, - tonalElevation = 8.dp + .padding(horizontal = 20.dp) + .padding(bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) ) { - Box { - // Background lightning effect - LightningBackground( - modifier = Modifier - .matchParentSize() - .clip(RoundedCornerShape(28.dp)) + // ── 1. Toolbar ────────────────────────────────────────── + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + PillButton(text = stringResource(R.string.btn_close), onClick = { closeSheet() }) + PillButton( + text = "Presets", + onClick = { showSavePresetDialog = true }, + contentColor = accent, + borderColor = accent.copy(alpha = 0.45f) ) + } - Column( - modifier = Modifier.padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally + // ── 2. Recipient row (optional) ───────────────────────── + if (recipientProfile != null) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically ) { - // Header with animated bolt — switches to a coin-stack - // glyph in fiat mode so the icon reads as money rather - // than zap. Mirrors the iOS dynamic `zapImage`. - AnimatedBoltHeader(fiatMode = fiatMode) - - Spacer(Modifier.height(8.dp)) - - Text( - text = stringResource( - if (fiatMode) R.string.zap_send_money else R.string.zap_send - ), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface + AsyncImage( + model = recipientProfile.picture, + contentDescription = null, + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant) ) - - Spacer(Modifier.height(4.dp)) - - // Amount display — tap to edit directly. In fiat mode the - // input is interpreted register-style: digits fill from the - // cents place ("21" → $0.21, "2100" → $21.00). The field - // is bound to the raw digit string and a - // `VisualTransformation` renders it as the formatted - // dollar string with the cursor pinned to the end — that - // way backspace removes the rightmost digit and Compose - // can map cursor positions cleanly (binding the field - // directly to the formatted string and rewriting it on - // every keystroke broke backspace because Compose - // couldn't map the cursor between the old and new - // formatted strings). - val currency = if (fiatMode) { - ExchangeRateRepository.currencyFor(fiatCurrency) - } else null - val centsTransformation = remember(currency) { - currency?.let { CentsVisualTransformation(it) } - } - if (isCustom) { - BasicTextField( - value = customAmount, - onValueChange = { new -> - customAmount = if (fiatMode) sanitizeFiatInput(new) else new.filter { c -> c.isDigit() } - }, - visualTransformation = if (fiatMode && centsTransformation != null) { - centsTransformation - } else VisualTransformation.None, - textStyle = MaterialTheme.typography.displaySmall.copy( - fontWeight = FontWeight.Bold, - color = LightningOrange, - textAlign = TextAlign.Center - ), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - singleLine = true, - cursorBrush = SolidColor(LightningOrange), - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - decorationBox = { inner -> - Box(contentAlignment = Alignment.Center) { - if (customAmount.isEmpty()) { - Text( - text = if (fiatMode) "${currency?.symbol ?: ""}0.00" else "0", - style = MaterialTheme.typography.displaySmall, - fontWeight = FontWeight.Bold, - color = LightningOrange.copy(alpha = 0.3f) - ) - } - inner() - } - } - ) - if (!fiatMode) { - Text( - text = stringResource(R.string.zap_sats), - style = MaterialTheme.typography.labelLarge, - color = LightningOrange.copy(alpha = 0.7f) - ) - } - } else if (effectiveAmount > 0) { + Spacer(Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { Text( - text = AmountFormatter.formatShort(effectiveAmount, context), - style = MaterialTheme.typography.displaySmall, - fontWeight = FontWeight.Bold, - color = LightningOrange, - modifier = Modifier - .clickable { - // Seed the field with the equivalent cents - // digit string so the user can refine the - // selected preset rather than starting from - // empty in fiat mode. - customAmount = if (fiatMode) { - seedRegisterCents(effectiveAmount, fiatCurrency) - } else { - effectiveAmount.toString() - } - isCustom = true - } - .padding(vertical = 4.dp) + recipientProfile.displayString, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1 ) - if (!fiatMode) { + if (!recipientProfile.lud16.isNullOrBlank()) { Text( - text = stringResource(R.string.zap_sats), - style = MaterialTheme.typography.labelLarge, - color = LightningOrange.copy(alpha = 0.7f) + recipientProfile.lud16, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1 ) } } - - Spacer(Modifier.height(16.dp)) - - // Preset chips header - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.zap_quick_amounts), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Row { - if (editMode) { - TextButton(onClick = { editMode = false }) { - Text(stringResource(R.string.btn_done), color = LightningOrange, fontSize = 12.sp) - } - } else { - IconButton( - onClick = { editMode = true }, - modifier = Modifier.size(32.dp) - ) { - Text( - stringResource(R.string.btn_edit), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } - - Spacer(Modifier.height(8.dp)) - - // Preset amount chips - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth() - ) { - presets.forEach { preset -> - ZapPresetChip( - preset = preset, - isSelected = !isCustom && selectedPreset == preset, - editMode = editMode, - onClick = { - if (!editMode) { - selectedPreset = preset - isCustom = false - message = preset.message - } - }, - onRemove = { - presets = ZapPreferences(context).removePreset(preset) - if (selectedPreset == preset) { - selectedPreset = presets.firstOrNull() - } - } + if (!recipientProfile.lud16.isNullOrBlank()) { + IconButton(onClick = { + clipboard.setText(AnnotatedString(recipientProfile.lud16!!)) + }) { + Icon( + Icons.Filled.ContentCopy, + contentDescription = "Copy lightning address", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) ) } - - // Custom amount chip - ZapChipButton( - label = stringResource(R.string.btn_custom), - isSelected = isCustom, - onClick = { isCustom = true } - ) } + } + } - Spacer(Modifier.height(16.dp)) + // ── 3. Hero amount ────────────────────────────────────── + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + val heroText = if (fiatMode && effectiveAmount > 0) { + AmountFormatter.formatShort(effectiveAmount, context) + } else { + "%,d".format(effectiveAmount) + } + Text( + heroText, + color = accent, + fontSize = 56.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) + Text( + if (fiatMode) ExchangeRateRepository.currencyFor(fiatCurrency).code else "sats", + color = accent.copy(alpha = 0.75f), + style = MaterialTheme.typography.titleSmall + ) + } - // Custom amount input - AnimatedVisibility( - visible = isCustom, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - Column { - OutlinedTextField( - value = customAmount, - onValueChange = { new -> - customAmount = if (fiatMode) sanitizeFiatInput(new) else new.filter { c -> c.isDigit() } - }, - visualTransformation = if (fiatMode && centsTransformation != null) { - centsTransformation - } else VisualTransformation.None, - label = { - Text( - if (fiatMode) { - stringResource(R.string.placeholder_amount_currency, currency?.symbol ?: "") - } else { - stringResource(R.string.placeholder_amount_sats) - } - ) - }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth(), - singleLine = true, - shape = RoundedCornerShape(16.dp), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = LightningOrange, - focusedLabelColor = LightningOrange, - cursorColor = LightningOrange - ) - ) - // Save-as-preset only makes sense for whole-sat - // amounts; in fiat mode we always derive sats - // through the exchange rate, which can drift. - val saveAmount = if (fiatMode) 0L else (customAmount.toLongOrNull() ?: 0L) - if (saveAmount > 0) { - Spacer(Modifier.height(6.dp)) - TextButton( - onClick = { - val preset = ZapPreset(saveAmount, message.trim()) - presets = ZapPreferences(context).addPreset(preset) - selectedPreset = presets.firstOrNull { it.amountSats == saveAmount } - isCustom = false - customAmount = "" - } - ) { - Icon( - Icons.Filled.Add, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - Spacer(Modifier.width(4.dp)) - Text(stringResource(R.string.btn_save)) - } + // ── 4. Preset strip ───────────────────────────────────── + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + presets.forEach { preset -> + val selected = !isCustom && selectedPreset?.amountSats == preset.amountSats + PresetPill( + label = AmountFormatter.formatShort(preset.amountSats, context), + selected = selected, + accent = accent, + onClick = { + selectedPreset = preset + isCustom = false + // Auto-fill the preset's optional default + // message only when the message field is + // currently empty (don't clobber typing). + if (preset.message.isNotEmpty() && message.isBlank()) { + message = preset.message } - Spacer(Modifier.height(8.dp)) } - } - - // Message input - OutlinedTextField( - value = message, - onValueChange = { new -> if (!NsecPasteGuard.blockIfNsec(message, new)) message = new }, - label = { Text(stringResource(R.string.placeholder_message_optional)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - shape = RoundedCornerShape(16.dp), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = LightningOrange, - focusedLabelColor = LightningOrange, - cursorColor = LightningOrange - ) ) + } + CustomPlusPill( + label = if (isCustom && effectiveAmount > 0) + AmountFormatter.formatShort(effectiveAmount, context) + else "Custom", + selected = isCustom, + accent = accent, + showPlus = canSavePreset, + onClick = { + isCustom = true + if (effectiveAmount == 0L) customAmount = "" + }, + onPlusClick = { + // Save the current custom amount as a new preset + if (canSavePreset) { + presets = zapPrefsRepo.addPreset( + ZapPreset(effectiveAmount, message.trim()) + ).sortedBy { it.amountSats } + } + } + ) + } - // Clear message when switching away from a preset with a saved message - // (message is pre-filled when selecting a preset with a message) + // ── 5. Inline custom amount field (only when isCustom) ── + if (isCustom) { + OutlinedTextField( + value = customAmount, + onValueChange = { raw -> + customAmount = raw.filter { it.isDigit() }.trimStart('0') + }, + label = { + Text(if (fiatMode) "Custom (cents)" else "Custom (sats)") + }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth() + ) + } - Spacer(Modifier.height(16.dp)) + // ── 6. Message ────────────────────────────────────────── + OutlinedTextField( + value = message, + onValueChange = { message = it }, + placeholder = { Text("Message (optional)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) - if (forcePrivate) { - // Parent is a private reply — zap is locked to DIP-03 mode and the - // anon/private toggles are hidden. A small label keeps the user - // oriented; the lock icon mirrors the orange lock used elsewhere. + // ── 7. Privacy dropdown ───────────────────────────────── + if (!forcePrivate) { + Box(modifier = Modifier.fillMaxWidth()) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { privacyMenuExpanded = true }, + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(12.dp) + ) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically ) { + val (icon, label, helper) = when { + isPrivate -> Triple(Icons.Filled.Lock, "Private", "Recipient only — sent via DM-relay route.") + isAnonymous -> Triple(Icons.Outlined.VisibilityOff, "Anonymous", "Recipient won't see your identity.") + else -> Triple(Icons.Outlined.Visibility, "Public", null) + } Icon( - imageVector = Icons.Outlined.VisibilityOff, + icon, contentDescription = null, - tint = LightningOrange, - modifier = Modifier.size(16.dp) + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(18.dp) ) - Spacer(Modifier.width(6.dp)) - Text( - text = stringResource(R.string.zap_private_locked_for_private_reply), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) + Spacer(Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(label, style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface) + if (helper != null) { + Text( + helper, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Icon( + Icons.Filled.KeyboardArrowDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) ) } - } else { - // Anonymous toggle - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.btn_anonymous), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface - ) - Switch( - checked = isAnonymous, - onCheckedChange = { - isAnonymous = it - if (it) isPrivate = false - }, - colors = SwitchDefaults.colors( - checkedThumbColor = LightningOrange, - checkedTrackColor = LightningOrange.copy(alpha = 0.5f), - uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant, - uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant, - uncheckedBorderColor = MaterialTheme.colorScheme.outline - ) - ) } - - // Private toggle - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + DropdownMenu( + expanded = privacyMenuExpanded, + onDismissRequest = { privacyMenuExpanded = false } ) { - Column { - Text( - text = stringResource(R.string.btn_private), - style = MaterialTheme.typography.bodyMedium, - color = if (canPrivateZap) MaterialTheme.colorScheme.onSurface - else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) - ) - if (!canPrivateZap) { - Text( - text = stringResource(R.string.zap_both_parties_need_dm_relays), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) - ) + DropdownMenuItem( + text = { Text("Public") }, + leadingIcon = { Icon(Icons.Outlined.Visibility, null) }, + onClick = { + isPrivate = false + isAnonymous = false + privacyMenuExpanded = false } - } - Switch( - checked = isPrivate, - onCheckedChange = { - isPrivate = it - if (it) isAnonymous = false - }, - enabled = canPrivateZap, - colors = SwitchDefaults.colors( - checkedThumbColor = LightningOrange, - checkedTrackColor = LightningOrange.copy(alpha = 0.5f), - uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant, - uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant, - uncheckedBorderColor = MaterialTheme.colorScheme.outline - ) ) - } - } // end !forcePrivate - - Spacer(Modifier.height(12.dp)) - - // Instant zaps toggle — bound to the same setting as the - // Interface screen, so the user can flip it from the - // sheet without navigating away. Matches iOS. - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = if (fiatMode) "Instant payments" else "Instant zaps", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface + DropdownMenuItem( + text = { Text("Anonymous") }, + leadingIcon = { Icon(Icons.Outlined.VisibilityOff, null) }, + onClick = { + isAnonymous = true + isPrivate = false + privacyMenuExpanded = false + } ) - Switch( - checked = instantZapsEnabled, - onCheckedChange = { - instantZapsEnabled = it - interfacePrefs.setQuickZapEnabled(it) - }, - colors = SwitchDefaults.colors( - checkedThumbColor = LightningOrange, - checkedTrackColor = LightningOrange.copy(alpha = 0.5f), - uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant, - uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant, - uncheckedBorderColor = MaterialTheme.colorScheme.outline + if (canPrivateZap) { + DropdownMenuItem( + text = { Text("Private") }, + leadingIcon = { Icon(Icons.Filled.Lock, null) }, + onClick = { + isPrivate = true + isAnonymous = false + privacyMenuExpanded = false + } ) - ) + } } + } + } - // 1,000,000-sat hard cap warning. - val overCap = effectiveAmount > ZAP_HARD_CAP_SATS - if (overCap) { - Spacer(Modifier.height(8.dp)) - Text( - "Max ${"%,d".format(ZAP_HARD_CAP_SATS)} sats per zap", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error + // ── 8. Instant zaps toggle ────────────────────────────── + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(12.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.drawable.ic_bolt), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.width(10.dp)) + Text( + if (fiatMode) "Instant payments" else "Instant zaps", + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.onSurface + ) + Switch( + checked = instantZapsEnabled, + onCheckedChange = { + instantZapsEnabled = it + interfacePrefs.setQuickZapEnabled(it) + }, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = accent, + uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant, + uncheckedTrackColor = MaterialTheme.colorScheme.surface ) - } - - Spacer(Modifier.height(16.dp)) + ) + } + } - // Action buttons - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - TextButton( - onClick = onDismiss, - modifier = Modifier.weight(1f) - ) { - Text(stringResource(R.string.btn_cancel)) - } + if (overHardCap) { + Text( + "Max ${"%,d".format(ZAP_HARD_CAP_SATS)} sats per zap", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + } - Button( - onClick = { - // 10K-sat soft confirmation — large amount - // intercepts the tap and routes through a - // confirm dialog. Below 10K fires immediately. - if (effectiveAmount > ZAP_SOFT_CONFIRM_SATS) { - showLargeAmountConfirm = true - } else { - onZap(effectiveAmount * 1000, effectiveMessage.ifEmpty { message }, isAnonymous, isPrivate) - } - }, - enabled = effectiveAmount > 0 && !overCap, - modifier = Modifier.weight(2f), - colors = ButtonDefaults.buttonColors( - containerColor = LightningOrange, - contentColor = Color.White - ), - shape = RoundedCornerShape(16.dp) - ) { - Icon( - painter = painterResource( - if (fiatMode) R.drawable.ic_coin_stack else R.drawable.ic_bolt - ), - contentDescription = null, - modifier = Modifier.size(15.dp) - ) - Spacer(Modifier.width(6.dp)) - Text( - if (fiatMode) { - stringResource( - R.string.zap_x_amount, - AmountFormatter.formatShort(effectiveAmount, context) - ) - } else { - stringResource(R.string.zap_x_sats, effectiveAmount) - }, - fontWeight = FontWeight.Bold - ) - } + // ── 9. Zap button ─────────────────────────────────────── + Button( + onClick = { + if (effectiveAmount > ZAP_SOFT_CONFIRM_SATS) { + showLargeAmountConfirm = true + } else { + onZap(effectiveAmount * 1000, effectiveMessage.ifEmpty { message }, isAnonymous, isPrivate) } - } + }, + enabled = effectiveAmount > 0 && !overHardCap, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.buttonColors( + containerColor = accent, + contentColor = Color.White, + disabledContainerColor = accent.copy(alpha = 0.35f) + ) + ) { + Icon( + painter = painterResource( + if (fiatMode) R.drawable.ic_coin_stack else R.drawable.ic_bolt + ), + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + if (fiatMode) "Zap ${AmountFormatter.formatShort(effectiveAmount, context)}" + else "Zap ${"%,d".format(effectiveAmount)} sats", + fontWeight = FontWeight.Bold, + fontSize = 17.sp + ) } } } @@ -701,10 +568,8 @@ fun ZapDialog( showLargeAmountConfirm = false onZap(effectiveAmount * 1000, effectiveMessage.ifEmpty { message }, isAnonymous, isPrivate) }, - colors = ButtonDefaults.buttonColors(containerColor = LightningOrange) - ) { - Text("Send", fontWeight = FontWeight.Bold) - } + colors = ButtonDefaults.buttonColors(containerColor = accent) + ) { Text("Send", fontWeight = FontWeight.Bold) } }, dismissButton = { TextButton(onClick = { showLargeAmountConfirm = false }) { @@ -713,13 +578,123 @@ fun ZapDialog( } ) } + + if (showSavePresetDialog) { + SaveZapPresetDialog( + currentAmount = if (effectiveAmount > 0) effectiveAmount.toString() else "", + onSave = { preset -> + presets = zapPrefsRepo.addPreset(preset).sortedBy { it.amountSats } + selectedPreset = preset + isCustom = false + showSavePresetDialog = false + }, + onDismiss = { showSavePresetDialog = false } + ) + } } -// Hard ceilings shared with the iOS port. Soft = confirmation dialog, -// hard = disables the Zap button entirely. +// ─── Helpers ───────────────────────────────────────────────────────────── + private const val ZAP_SOFT_CONFIRM_SATS = 10_000L private const val ZAP_HARD_CAP_SATS = 1_000_000L +/** + * Pill-shaped text button — used for the toolbar's Close + Presets + * actions. Border-only by default, fillable via `borderColor`. + */ +@Composable +private fun PillButton( + text: String, + onClick: () -> Unit, + contentColor: Color = MaterialTheme.colorScheme.onSurface, + borderColor: Color = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f) +) { + Surface( + modifier = Modifier.clickable(onClick = onClick), + shape = CircleShape, + color = Color.Transparent, + border = BorderStroke(1.dp, borderColor) + ) { + Text( + text, + style = MaterialTheme.typography.bodyMedium, + color = contentColor, + modifier = Modifier.padding(horizontal = 18.dp, vertical = 8.dp) + ) + } +} + +/** Single preset chip in the FlowRow. Selected = filled accent + white. */ +@Composable +private fun PresetPill( + label: String, + selected: Boolean, + accent: Color, + onClick: () -> Unit +) { + Surface( + modifier = Modifier.clickable(onClick = onClick), + shape = CircleShape, + color = if (selected) accent else MaterialTheme.colorScheme.surfaceVariant + ) { + Text( + label, + style = MaterialTheme.typography.labelLarge, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + color = if (selected) Color.White else MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 18.dp, vertical = 8.dp) + ) + } +} + +/** Custom-amount chip. When selected AND not yet in the preset list, + * the trailing + badge becomes tappable to save the value as a preset. */ +@Composable +private fun CustomPlusPill( + label: String, + selected: Boolean, + accent: Color, + showPlus: Boolean, + onClick: () -> Unit, + onPlusClick: () -> Unit +) { + Surface( + modifier = Modifier.clickable(onClick = onClick), + shape = CircleShape, + color = if (selected) accent else MaterialTheme.colorScheme.surfaceVariant + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 18.dp, end = if (showPlus) 4.dp else 18.dp, top = 4.dp, bottom = 4.dp) + ) { + Text( + label, + style = MaterialTheme.typography.labelLarge, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + color = if (selected) Color.White else MaterialTheme.colorScheme.onSurface + ) + if (showPlus) { + Spacer(Modifier.width(6.dp)) + Box( + modifier = Modifier + .size(22.dp) + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.18f)) + .clickable(onClick = onPlusClick), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Filled.Add, + contentDescription = "Save as preset", + tint = Color.White, + modifier = Modifier.size(14.dp) + ) + } + } + } + } +} + /** * Translate raw Lightning/SDK error strings into plain user-facing * copy. Mirrors iOS `ZapAnimationStore.friendlyMessage(for:)`. @@ -757,372 +732,39 @@ internal fun friendlyZapErrorMessage(raw: String?): String { } /** - * Register-style sanitiser: digits only. The fiat-mode amount field is - * interpreted as integer cents (last two digits = cents place), so - * decimal points and other punctuation in user input are stripped — the - * decimal in the displayed string ("$0.21") is presentation-only. - */ -private fun sanitizeFiatInput(text: String): String = - text.filter { it.isDigit() } - -/** - * Format a digit-only string as `$X.XX` register-style — last two digits - * are cents, everything before them is whole dollars. "21" → "$0.21", - * "2100" → "$21.00". Used for the empty-field placeholder; the live - * field display goes through [CentsVisualTransformation] so Compose's - * cursor mapping works correctly. - */ -private fun formatRegisterCents(digits: String, currency: FiatCurrency): String { - val cents = digits.toLongOrNull() ?: 0L - val dollars = cents.toDouble() / 100.0 - val formatter = java.text.DecimalFormat("#,##0.00") - return "${currency.symbol}${formatter.format(dollars)}" -} - -/** - * Renders a digit-only field value as the formatted register-style - * dollar string and pegs the cursor to the end. The previous approach - * — binding the TextField directly to the formatted string and - * re-formatting in `onValueChange` — broke backspace on Android: when - * Compose's internal text changed from "$0.2" (after the user removed - * the last char) to our re-formatted "$0.02", Compose couldn't map the - * old cursor position into the new string and effectively snapped it - * to the start, so subsequent backspaces just moved the cursor instead - * of deleting. With a [VisualTransformation] the field is bound to the - * raw digits, the cursor lives in raw-string coordinates, and the - * `OffsetMapping` always points to the end of the formatted view. - */ -private class CentsVisualTransformation( - private val currency: FiatCurrency -) : VisualTransformation { - override fun filter(text: AnnotatedString): TransformedText { - val formatted = formatRegisterCents(text.text, currency) - val rawLength = text.text.length - val transformedLength = formatted.length - val mapping = object : OffsetMapping { - override fun originalToTransformed(offset: Int): Int = transformedLength - override fun transformedToOriginal(offset: Int): Int = rawLength - } - return TransformedText(AnnotatedString(formatted), mapping) - } -} - -/** - * Cents-as-digits seed for the custom-amount field. Converts the current - * sats amount through the cached rate, rounds to the nearest cent, and - * returns the digit string (e.g. 1234 cents → "1234"). Empty for zero - * or when no rate is cached. + * Simple "save as preset" dialog used by the Presets pill in the + * toolbar. The composer's inline + badge on the Custom chip handles + * the single-tap save flow; this dialog is for adding/editing presets + * with an explicit message. */ -private fun seedRegisterCents(amountSats: Long, currencyCode: String): String { - val dollars = ExchangeRateRepository.satsToFiat(amountSats, currencyCode) ?: return "" - val cents = (dollars * 100.0).toLong() - return if (cents > 0) cents.toString() else "" -} - -@Composable -private fun AnimatedBoltHeader(fiatMode: Boolean = false) { - val infiniteTransition = rememberInfiniteTransition(label = "bolt") - - val glowAlpha by infiniteTransition.animateFloat( - initialValue = 0.3f, - targetValue = 0.8f, - animationSpec = infiniteRepeatable( - animation = tween(1200, easing = LinearEasing), - repeatMode = RepeatMode.Reverse - ), - label = "glow" - ) - - val boltScale by animateFloatAsState( - targetValue = 1f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessLow - ), - label = "boltScale" - ) - - val zapColor = WispThemeColors.zapColor - - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.size(64.dp) - ) { - // Glow circle behind bolt - Box( - modifier = Modifier - .size(56.dp) - .alpha(glowAlpha) - .background( - brush = Brush.radialGradient( - colors = listOf( - zapColor.copy(alpha = 0.4f), - zapColor.copy(alpha = 0.1f), - Color.Transparent - ) - ), - shape = CircleShape - ) - ) - - // Bolt / coin-stack icon depending on mode - Icon( - painter = painterResource( - if (fiatMode) R.drawable.ic_coin_stack else R.drawable.ic_bolt - ), - contentDescription = null, - tint = zapColor, - modifier = Modifier - .size(30.dp) - .scale(boltScale) - ) - } -} - -@Composable -private fun LightningBackground(modifier: Modifier = Modifier) { - val infiniteTransition = rememberInfiniteTransition(label = "lightning_bg") - - val phase by infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween(4000, easing = LinearEasing), - repeatMode = RepeatMode.Restart - ), - label = "phase" - ) - - val zapColor = WispThemeColors.zapColor - - // Stable random values for bolt positions - val boltData = remember { - List(5) { i -> - Triple( - Random(i * 42).nextFloat(), // x position (0..1) - Random(i * 42 + 1).nextFloat(), // y position (0..1) - Random(i * 42 + 2).nextFloat() * 0.3f + 0.1f // size scale - ) - } - } - - Canvas(modifier = modifier) { - val w = size.width - val h = size.height - - // Subtle gradient overlay at the top - drawRect( - brush = Brush.verticalGradient( - colors = listOf( - zapColor.copy(alpha = 0.03f), - Color.Transparent - ), - startY = 0f, - endY = h * 0.4f - ) - ) - - // Animated mini lightning bolts scattered in background - boltData.forEach { (xFrac, yFrac, scale) -> - val animatedAlpha = (sin((phase + xFrac) * Math.PI * 2).toFloat() * 0.5f + 0.5f) * 0.06f - val cx = xFrac * w - val cy = yFrac * h - val boltSize = 20f * scale - - drawMiniBolt( - center = Offset(cx, cy), - size = boltSize, - color = zapColor.copy(alpha = animatedAlpha) - ) - } - } -} - -private fun DrawScope.drawMiniBolt(center: Offset, size: Float, color: Color) { - val path = Path().apply { - moveTo(center.x, center.y - size) - lineTo(center.x - size * 0.4f, center.y + size * 0.1f) - lineTo(center.x + size * 0.1f, center.y + size * 0.1f) - lineTo(center.x - size * 0.1f, center.y + size) - lineTo(center.x + size * 0.4f, center.y - size * 0.1f) - lineTo(center.x - size * 0.1f, center.y - size * 0.1f) - close() - } - drawPath(path, color, style = Fill) -} - -@Composable -private fun ZapPresetChip( - preset: ZapPreset, - isSelected: Boolean, - editMode: Boolean, - onClick: () -> Unit, - onRemove: () -> Unit -) { - val scale by animateFloatAsState( - targetValue = if (isSelected) 1.05f else 1f, - animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy), - label = "chipScale" - ) - - Box { - Surface( - modifier = Modifier - .scale(scale) - .clip(RoundedCornerShape(16.dp)) - .clickable(onClick = onClick), - shape = RoundedCornerShape(16.dp), - color = if (isSelected) LightningOrange else MaterialTheme.colorScheme.surfaceVariant, - border = if (isSelected) { - androidx.compose.foundation.BorderStroke( - 1.5.dp, - Brush.linearGradient(listOf(LightningYellow, LightningOrange)) - ) - } else { - null - }, - shadowElevation = if (isSelected) 4.dp else 0.dp - ) { - Row( - modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - if (isSelected) { - Icon( - painter = painterResource(R.drawable.ic_bolt), - contentDescription = null, - modifier = Modifier.size(12.dp), - tint = Color.White - ) - Spacer(Modifier.width(3.dp)) - } - val chipContext = LocalContext.current - Text( - text = AmountFormatter.formatShort(preset.amountSats, chipContext), - style = MaterialTheme.typography.labelLarge, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, - color = if (isSelected) Color.White else MaterialTheme.colorScheme.onSurfaceVariant - ) - if (preset.message.isNotEmpty()) { - Spacer(Modifier.width(4.dp)) - Icon( - Icons.AutoMirrored.Outlined.Message, - contentDescription = null, - modifier = Modifier.size(10.dp), - tint = if (isSelected) Color.White.copy(alpha = 0.7f) - else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) - ) - } - } - } - - // Remove badge in edit mode - if (editMode) { - Surface( - modifier = Modifier - .align(Alignment.TopEnd) - .offset(x = 4.dp, y = (-4).dp) - .size(20.dp) - .clip(CircleShape) - .clickable(onClick = onRemove), - shape = CircleShape, - color = MaterialTheme.colorScheme.error - ) { - Icon( - Icons.Filled.Close, - contentDescription = stringResource(R.string.btn_remove), - modifier = Modifier - .padding(2.dp) - .size(16.dp), - tint = Color.White - ) - } - } - } -} - -@Composable -private fun ZapChipButton( - label: String, - isSelected: Boolean, - onClick: () -> Unit -) { - Surface( - modifier = Modifier - .clip(RoundedCornerShape(16.dp)) - .clickable(onClick = onClick), - shape = RoundedCornerShape(16.dp), - color = if (isSelected) LightningOrange else MaterialTheme.colorScheme.surfaceVariant, - border = if (isSelected) { - androidx.compose.foundation.BorderStroke( - 1.5.dp, - Brush.linearGradient(listOf(LightningYellow, LightningOrange)) - ) - } else { - null - } - ) { - Text( - text = label, - modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), - style = MaterialTheme.typography.labelLarge, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, - color = if (isSelected) Color.White else MaterialTheme.colorScheme.onSurfaceVariant - ) - } -} - @Composable private fun SaveZapPresetDialog( + currentAmount: String, onSave: (ZapPreset) -> Unit, onDismiss: () -> Unit ) { - var amount by remember { mutableStateOf("") } + var amount by remember { mutableStateOf(currentAmount) } var presetMessage by remember { mutableStateOf("") } - AlertDialog( onDismissRequest = onDismiss, - title = { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - painter = painterResource(R.drawable.ic_bolt), - contentDescription = null, - tint = LightningOrange, - modifier = Modifier.size(20.dp) - ) - Spacer(Modifier.width(8.dp)) - Text(stringResource(R.string.btn_save)) - } - }, + title = { Text("Save preset") }, text = { Column { OutlinedTextField( value = amount, onValueChange = { amount = it.filter { c -> c.isDigit() } }, - label = { Text(stringResource(R.string.placeholder_amount_sats)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth(), + label = { Text("Amount (sats)") }, singleLine = true, - shape = RoundedCornerShape(12.dp), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = LightningOrange, - focusedLabelColor = LightningOrange, - cursorColor = LightningOrange - ) + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth() ) - Spacer(Modifier.height(12.dp)) + Spacer(Modifier.height(8.dp)) OutlinedTextField( value = presetMessage, - onValueChange = { new -> if (!NsecPasteGuard.blockIfNsec(presetMessage, new)) presetMessage = new }, - label = { Text(stringResource(R.string.placeholder_message_optional)) }, - modifier = Modifier.fillMaxWidth(), + onValueChange = { presetMessage = it.replace(",", "").replace(":", "") }, + label = { Text("Message (optional)") }, singleLine = true, - shape = RoundedCornerShape(12.dp), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = LightningOrange, - focusedLabelColor = LightningOrange, - cursorColor = LightningOrange - ) + modifier = Modifier.fillMaxWidth() ) } }, @@ -1133,7 +775,7 @@ private fun SaveZapPresetDialog( onSave(ZapPreset(sats, presetMessage.trim())) }, enabled = (amount.toLongOrNull() ?: 0L) > 0, - colors = ButtonDefaults.buttonColors(containerColor = LightningOrange) + colors = ButtonDefaults.buttonColors(containerColor = WispThemeColors.zapColor) ) { Text(stringResource(R.string.btn_save), fontWeight = FontWeight.Bold) } @@ -1143,4 +785,3 @@ private fun SaveZapPresetDialog( } ) } - diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/FeedScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/FeedScreen.kt index 34902103..fea1af12 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/FeedScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/FeedScreen.kt @@ -547,7 +547,9 @@ fun FeedScreen( viewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) }, onGoToWallet = onWallet, - canPrivateZap = userHasDmRelays && recipientHasDmRelays + canPrivateZap = userHasDmRelays && recipientHasDmRelays, + recipientPubkey = zapTargetEvent?.pubkey, + profileLookup = { viewModel.profileRepo.get(it) } ) } From 1a42279cdbf0f7f7ba953f14d6d13fe079fb7527 Mon Sep 17 00:00:00 2001 From: The Daniel Date: Thu, 21 May 2026 17:38:22 -0400 Subject: [PATCH 5/6] fix(zap-sheet): pin Zap button above keyboard + wire recipient on every call site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups to the ZapSheet rewrite: 1. **Zap button no longer hides behind the keyboard.** The previous layout put the Zap button at the end of a single Column inside the bottom sheet, so when the amount field focused, the keyboard pushed the whole content up and the button went off-screen with no scroll to reach it. Restructured the sheet body into two rows: a scrollable upper region (toolbar, recipient, hero, presets, message, privacy, instant-zaps toggle) holding `weight(1f, fill = false)` and a pinned lower region with the cap warning + Zap button. The outer Column gets `imePadding()` so the whole stack floats above the IME — button stays visible, upper region scrolls if the keyboard cuts into it. 2. **Recipient row now renders on every call site.** Only 3 of ~12 ZapDialog call sites were passing `recipientPubkey` + `profileLookup` in the rewrite commit — the row hid silently on the other 9. Wired the rest: • Navigation.kt — search, hashtag feed, set feed, article, live stream (uses streamer override pubkey when set), notifications (post + DM target). • FeedScreen — zap-poll target. • UserProfileScreen — post zap (eventRepo lookup) AND profile-direct zap (embedded profile shortcut). • DmConversationScreen — uses the peerProfile already in scope. --- .../main/kotlin/com/wisp/app/Navigation.kt | 31 ++- .../com/wisp/app/ui/component/ZapDialog.kt | 210 ++++++++++++------ .../app/ui/screen/DmConversationScreen.kt | 4 +- .../com/wisp/app/ui/screen/FeedScreen.kt | 4 +- .../wisp/app/ui/screen/UserProfileScreen.kt | 9 +- 5 files changed, 175 insertions(+), 83 deletions(-) diff --git a/app/src/main/kotlin/com/wisp/app/Navigation.kt b/app/src/main/kotlin/com/wisp/app/Navigation.kt index 50c36d86..fc415d9f 100644 --- a/app/src/main/kotlin/com/wisp/app/Navigation.kt +++ b/app/src/main/kotlin/com/wisp/app/Navigation.kt @@ -1271,7 +1271,9 @@ fun WispNavHost( feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) }, onGoToWallet = { navController.navigate(Routes.WALLET) }, - canPrivateZap = feedViewModel.hasLocalKeypair && userHasDmRelays && recipientHasDmRelays + canPrivateZap = feedViewModel.hasLocalKeypair && userHasDmRelays && recipientHasDmRelays, + recipientPubkey = searchZapTarget?.pubkey, + profileLookup = { feedViewModel.profileRepo.get(it) } ) } SearchScreen( @@ -2092,7 +2094,9 @@ fun WispNavHost( feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) }, onGoToWallet = { navController.navigate(Routes.WALLET) }, - canPrivateZap = feedViewModel.hasLocalKeypair && hashtagUserHasDmRelays && hashtagRecipientHasDmRelays + canPrivateZap = feedViewModel.hasLocalKeypair && hashtagUserHasDmRelays && hashtagRecipientHasDmRelays, + recipientPubkey = hashtagZapTarget?.pubkey, + profileLookup = { feedViewModel.profileRepo.get(it) } ) } @@ -2244,7 +2248,9 @@ fun WispNavHost( feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) }, onGoToWallet = { navController.navigate(Routes.WALLET) }, - canPrivateZap = feedViewModel.hasLocalKeypair && setFeedUserHasDmRelays && setFeedRecipientHasDmRelays + canPrivateZap = feedViewModel.hasLocalKeypair && setFeedUserHasDmRelays && setFeedRecipientHasDmRelays, + recipientPubkey = setFeedZapTarget?.pubkey, + profileLookup = { feedViewModel.profileRepo.get(it) } ) } @@ -2409,7 +2415,9 @@ fun WispNavHost( feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) }, onGoToWallet = { navController.navigate(Routes.WALLET) }, - canPrivateZap = feedViewModel.hasLocalKeypair && articleUserHasDmRelays && articleRecipientHasDmRelays + canPrivateZap = feedViewModel.hasLocalKeypair && articleUserHasDmRelays && articleRecipientHasDmRelays, + recipientPubkey = articleZapTarget?.pubkey, + profileLookup = { feedViewModel.profileRepo.get(it) } ) } @@ -2616,7 +2624,12 @@ fun WispNavHost( // DIP-03 needs a concrete note id for the ephemeral key // derivation; live-stream zaps target an addressable event // (a-tag) instead, so private zaps don't apply here. - canPrivateZap = false + canPrivateZap = false, + // Live streams: use the streamer override pubkey when set + // (the chat host is what's interesting to identify), else + // fall back to the post author. + recipientPubkey = liveZapRecipientOverride ?: liveZapTarget?.pubkey, + profileLookup = { feedViewModel.profileRepo.get(it) } ) } val streamActivityEventId = remember(hostPubkey, dTag) { @@ -3179,7 +3192,9 @@ fun WispNavHost( }, onGoToWallet = { navController.navigate(Routes.WALLET) }, canPrivateZap = feedViewModel.hasLocalKeypair && notifUserHasDmRelays && notifRecipientHasDmRelays, - forcePrivate = notifZapTarget?.id?.let { feedViewModel.eventRepo.isPrivate(it) } == true + forcePrivate = notifZapTarget?.id?.let { feedViewModel.eventRepo.isPrivate(it) } == true, + recipientPubkey = notifZapTarget?.pubkey, + profileLookup = { feedViewModel.profileRepo.get(it) } ) } @@ -3197,7 +3212,9 @@ fun WispNavHost( rumorId = target.rumorId.ifEmpty { null } ) }, - onGoToWallet = { navController.navigate(Routes.WALLET) } + onGoToWallet = { navController.navigate(Routes.WALLET) }, + recipientPubkey = notifDmZapTarget?.senderPubkey, + profileLookup = { feedViewModel.profileRepo.get(it) } ) } diff --git a/app/src/main/kotlin/com/wisp/app/ui/component/ZapDialog.kt b/app/src/main/kotlin/com/wisp/app/ui/component/ZapDialog.kt index c61c3e0d..96275dde 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/component/ZapDialog.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/component/ZapDialog.kt @@ -12,9 +12,12 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions @@ -58,9 +61,14 @@ import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import kotlinx.coroutines.delay import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -151,7 +159,11 @@ fun ZapDialog( var presets by remember { mutableStateOf(zapPrefsRepo.getPresets().sortedBy { it.amountSats }) } var selectedPreset by remember { mutableStateOf(presets.firstOrNull()) } var isCustom by remember { mutableStateOf(false) } - var customAmount by remember { mutableStateOf("") } + // TextFieldValue so we can pre-select the seeded amount on focus — + // the first keystroke then replaces the whole seed, matching the + // iOS "first-keystroke-replaces-seed" UX. + var customAmountTfv by remember { mutableStateOf(TextFieldValue("")) } + val customAmount = customAmountTfv.text var message by remember { mutableStateOf("") } var isAnonymous by remember { mutableStateOf(false) } var isPrivate by remember(forcePrivate) { mutableStateOf(forcePrivate) } @@ -159,6 +171,7 @@ fun ZapDialog( var showLargeAmountConfirm by remember { mutableStateOf(false) } var showSavePresetDialog by remember { mutableStateOf(false) } var privacyMenuExpanded by remember { mutableStateOf(false) } + val amountFocusRequester = remember { FocusRequester() } val recipientProfile = recipientPubkey?.let { profileLookup(it) } @@ -177,25 +190,31 @@ fun ZapDialog( message = match.message } else { isCustom = true - customAmount = h.toString() + seedCustomAmount(h.toString()) { customAmountTfv = it } message = "" } } // Seed amount from the configured instant-zap amount on first open. + // Auto-focus the field after the sheet's mount + transition has + // settled (~450ms) — matches the iOS deferral. The seed is selected + // so the first keystroke replaces it entirely. LaunchedEffect(Unit) { - if (initialSatsHint != null) return@LaunchedEffect - val seedSats = if (fiatMode) { - val major = interfacePrefs.getQuickZapAmountFiat() - (major * 100.0).toLong().coerceAtLeast(0L) - } else { - interfacePrefs.getQuickZapAmountSats() - } - if (seedSats > 0) { - isCustom = true - customAmount = seedSats.toString() - message = interfacePrefs.getQuickZapMessage() + if (initialSatsHint == null) { + val seedSats = if (fiatMode) { + val major = interfacePrefs.getQuickZapAmountFiat() + (major * 100.0).toLong().coerceAtLeast(0L) + } else { + interfacePrefs.getQuickZapAmountSats() + } + if (seedSats > 0) { + isCustom = true + seedCustomAmount(seedSats.toString()) { customAmountTfv = it } + message = interfacePrefs.getQuickZapMessage() + } } + delay(450) + runCatching { amountFocusRequester.requestFocus() } } val effectiveAmount: Long = if (isCustom) { @@ -220,13 +239,24 @@ fun ZapDialog( containerColor = MaterialTheme.colorScheme.surface, // Drag handle replaces the iOS "swipe down" affordance. ) { + // Two-row stack: scrollable content on top, pinned Zap button + // at the bottom. `imePadding()` lifts the whole stack above the + // keyboard so the Zap button stays visible even when the + // amount field is focused. `navigationBarsPadding()` keeps it + // above the gesture-nav handle on devices without IME up. Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 20.dp) - .padding(bottom = 24.dp), - verticalArrangement = Arrangement.spacedBy(14.dp) + .imePadding() ) { + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f, fill = false) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { // ── 1. Toolbar ────────────────────────────────────────── Row( modifier = Modifier.fillMaxWidth(), @@ -346,7 +376,13 @@ fun ZapDialog( showPlus = canSavePreset, onClick = { isCustom = true - if (effectiveAmount == 0L) customAmount = "" + if (effectiveAmount == 0L) { + customAmountTfv = TextFieldValue("") + } else { + // Re-seed and select-all so first keystroke replaces. + seedCustomAmount(customAmount) { customAmountTfv = it } + } + runCatching { amountFocusRequester.requestFocus() } }, onPlusClick = { // Save the current custom amount as a new preset @@ -359,21 +395,29 @@ fun ZapDialog( ) } - // ── 5. Inline custom amount field (only when isCustom) ── - if (isCustom) { - OutlinedTextField( - value = customAmount, - onValueChange = { raw -> - customAmount = raw.filter { it.isDigit() }.trimStart('0') - }, - label = { - Text(if (fiatMode) "Custom (cents)" else "Custom (sats)") - }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth() - ) - } + // ── 5. Inline custom amount field ─────────────────────── + // Always rendered (the keyboard is up on mount per iOS), + // but only contributes to the amount when isCustom = true. + // The seed is pre-selected so the first keystroke replaces + // it; preset taps switch isCustom off and the typed value + // is preserved if the user comes back. + OutlinedTextField( + value = customAmountTfv, + onValueChange = { newTfv -> + val filtered = newTfv.text.filter { it.isDigit() } + // Preserve cursor / selection across the digit filter. + customAmountTfv = newTfv.copy(text = filtered) + if (filtered.isNotEmpty()) isCustom = true + }, + label = { + Text(if (fiatMode) "Custom (cents)" else "Custom (sats)") + }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier + .fillMaxWidth() + .focusRequester(amountFocusRequester) + ) // ── 6. Message ────────────────────────────────────────── OutlinedTextField( @@ -507,50 +551,62 @@ fun ZapDialog( } } - if (overHardCap) { - Text( - "Max ${"%,d".format(ZAP_HARD_CAP_SATS)} sats per zap", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center - ) - } + } // end scrollable content Column - // ── 9. Zap button ─────────────────────────────────────── - Button( - onClick = { - if (effectiveAmount > ZAP_SOFT_CONFIRM_SATS) { - showLargeAmountConfirm = true - } else { - onZap(effectiveAmount * 1000, effectiveMessage.ifEmpty { message }, isAnonymous, isPrivate) - } - }, - enabled = effectiveAmount > 0 && !overHardCap, + // ── 9. Zap button — pinned to the bottom of the sheet ── + // Lives outside the scrollable region above so it stays on + // screen even when the keyboard is up. The outer Column's + // imePadding() ensures it floats above the IME. + Column( modifier = Modifier .fillMaxWidth() - .height(52.dp), - shape = RoundedCornerShape(14.dp), - colors = ButtonDefaults.buttonColors( - containerColor = accent, - contentColor = Color.White, - disabledContainerColor = accent.copy(alpha = 0.35f) - ) + .padding(horizontal = 20.dp) + .padding(top = 12.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Icon( - painter = painterResource( - if (fiatMode) R.drawable.ic_coin_stack else R.drawable.ic_bolt - ), - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(Modifier.width(8.dp)) - Text( - if (fiatMode) "Zap ${AmountFormatter.formatShort(effectiveAmount, context)}" - else "Zap ${"%,d".format(effectiveAmount)} sats", - fontWeight = FontWeight.Bold, - fontSize = 17.sp - ) + if (overHardCap) { + Text( + "Max ${"%,d".format(ZAP_HARD_CAP_SATS)} sats per zap", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + } + Button( + onClick = { + if (effectiveAmount > ZAP_SOFT_CONFIRM_SATS) { + showLargeAmountConfirm = true + } else { + onZap(effectiveAmount * 1000, effectiveMessage.ifEmpty { message }, isAnonymous, isPrivate) + } + }, + enabled = effectiveAmount > 0 && !overHardCap, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.buttonColors( + containerColor = accent, + contentColor = Color.White, + disabledContainerColor = accent.copy(alpha = 0.35f) + ) + ) { + Icon( + painter = painterResource( + if (fiatMode) R.drawable.ic_coin_stack else R.drawable.ic_bolt + ), + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + if (fiatMode) "Zap ${AmountFormatter.formatShort(effectiveAmount, context)}" + else "Zap ${"%,d".format(effectiveAmount)} sats", + fontWeight = FontWeight.Bold, + fontSize = 17.sp + ) + } } } } @@ -598,6 +654,16 @@ fun ZapDialog( private const val ZAP_SOFT_CONFIRM_SATS = 10_000L private const val ZAP_HARD_CAP_SATS = 1_000_000L +/** + * Seed the custom-amount field with the given text AND select the + * whole range — so the next keystroke replaces the seed entirely. + * Lets the user open the sheet, see the configured instant-zap + * amount, then type a new value over it without backspacing first. + */ +private fun seedCustomAmount(text: String, set: (TextFieldValue) -> Unit) { + set(TextFieldValue(text = text, selection = TextRange(0, text.length))) +} + /** * Pill-shaped text button — used for the toolbar's Close + Presets * actions. Border-only by default, fillable via `borderColor`. diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/DmConversationScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/DmConversationScreen.kt index f1b5c0f6..12e7db47 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/DmConversationScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/DmConversationScreen.kt @@ -639,7 +639,9 @@ fun DmConversationScreen( onGoToWallet = { zapTargetMessage = null onGoToWallet() - } + }, + recipientPubkey = zapTargetMessage?.senderPubkey, + profileLookup = { pk -> peerProfile?.takeIf { it.pubkey == pk } } ) } } diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/FeedScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/FeedScreen.kt index fea1af12..73fec95a 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/FeedScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/FeedScreen.kt @@ -577,7 +577,9 @@ fun FeedScreen( zapPollTarget = null viewModel.sendZapPollVote(pollEvent, optionIndex, amountMsats, message, isAnonymous) }, - onGoToWallet = onWallet + onGoToWallet = onWallet, + recipientPubkey = pollEvent.pubkey, + profileLookup = { viewModel.profileRepo.get(it) } ) } diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/UserProfileScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/UserProfileScreen.kt index ee8ccf25..804afc2a 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/UserProfileScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/UserProfileScreen.kt @@ -293,7 +293,9 @@ fun UserProfileScreen( onZap(event, amountMsats, message, isAnonymous, isPrivate) }, onGoToWallet = onWallet, - canPrivateZap = resolvedCanPrivateZap + canPrivateZap = resolvedCanPrivateZap, + recipientPubkey = zapTargetEvent?.pubkey, + profileLookup = { eventRepo?.getProfileData(it) } ) } @@ -307,7 +309,10 @@ fun UserProfileScreen( onZapProfile?.invoke(amountMsats, message, isAnonymous) }, onGoToWallet = onWallet, - canPrivateZap = false + canPrivateZap = false, + // Profile zap — recipient is the profile being viewed. + recipientPubkey = profile?.pubkey, + profileLookup = { pk -> profile?.takeIf { it.pubkey == pk } } ) } From 095bf5d3bda3fbe4ff0277a1ad6ae0c0b00d8eed Mon Sep 17 00:00:00 2001 From: The Daniel Date: Tue, 26 May 2026 10:58:29 -0400 Subject: [PATCH 6/6] fix(interface): apply imePadding so the keyboard doesn't cover input fields The Interface settings screen's scroll column had no IME inset handling, so any text field that takes focus (most visibly the new instant-zap Amount / Fiat / Message inputs in the Zaps section, but also any future input on this screen) got covered by the soft keyboard. Adds Modifier.imePadding() between .padding(padding) and the verticalScroll. With the IME inset accounted for, the scroll viewport shrinks to fit above the keyboard and the focused field auto-scrolls into view as Compose normally handles. --- app/src/main/kotlin/com/wisp/app/ui/screen/InterfaceScreen.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/InterfaceScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/InterfaceScreen.kt index ad698d61..624174ef 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/InterfaceScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/InterfaceScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -144,6 +145,7 @@ fun InterfaceScreen( modifier = Modifier .fillMaxSize() .padding(padding) + .imePadding() .verticalScroll(rememberScrollState()) .padding(16.dp) ) {