diff --git a/app/src/main/kotlin/com/wisp/app/Navigation.kt b/app/src/main/kotlin/com/wisp/app/Navigation.kt index 80c11255..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( @@ -1641,7 +1643,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 +1922,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() @@ -2088,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) } ) } @@ -2240,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) } ) } @@ -2405,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) } ) } @@ -2612,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) { @@ -3175,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) } ) } @@ -3193,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/repo/InterfacePreferences.kt b/app/src/main/kotlin/com/wisp/app/repo/InterfacePreferences.kt index 8bd40085..1223a3ef 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,78 @@ 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. + // + // Keys are scoped per-account via activePubkey (companion object) so + // switching accounts never inherits the previous account's values. + // Call reload(pubkey) on every account switch to update the scope. + + private fun quickZapKey(base: String): String = + activePubkey?.let { "${base}_$it" } ?: base + + fun isQuickZapEnabled(): Boolean = prefs.getBoolean(quickZapKey("quick_zap_enabled"), false) + fun setQuickZapEnabled(enabled: Boolean) = + prefs.edit().putBoolean(quickZapKey("quick_zap_enabled"), enabled).apply() + + fun getQuickZapAmountSats(): Long = + prefs.getLong(quickZapKey("quick_zap_amount_sats"), 21L).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(quickZapKey("quick_zap_amount_sats"), clamped).apply() + } + + fun getQuickZapAmountFiat(): Double = + prefs.getString(quickZapKey("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(quickZapKey("quick_zap_amount_fiat"), clamped.toString()).apply() + } + + fun getQuickZapMessage(): String = prefs.getString(quickZapKey("quick_zap_message"), "") ?: "" + fun setQuickZapMessage(message: String) = + prefs.edit().putString(quickZapKey("quick_zap_message"), message).apply() + + /** + * Update the active account scope for instant-zap keys. Call on every + * account switch AND at initial app launch. On the very first call with + * a non-null pubkey (activePubkey was null), migrates any values that + * were stored under the old unscoped keys so existing settings survive + * the upgrade to per-account storage. + */ + fun reload(pubkey: String?) { + val wasNull = activePubkey == null + activePubkey = pubkey + if (wasNull && pubkey != null) migrateGlobalIfNeeded(pubkey) + } + + private fun migrateGlobalIfNeeded(pubkey: String) { + val migKey = "quick_zap_migrated_v1_$pubkey" + if (prefs.getBoolean(migKey, false)) return + val edit = prefs.edit().putBoolean(migKey, true) + if (!prefs.contains("quick_zap_amount_sats_$pubkey") && prefs.contains("quick_zap_amount_sats")) + edit.putLong("quick_zap_amount_sats_$pubkey", prefs.getLong("quick_zap_amount_sats", 21L)) + if (!prefs.contains("quick_zap_amount_fiat_$pubkey") && prefs.contains("quick_zap_amount_fiat")) + prefs.getString("quick_zap_amount_fiat", null)?.let { edit.putString("quick_zap_amount_fiat_$pubkey", it) } + if (!prefs.contains("quick_zap_enabled_$pubkey") && prefs.contains("quick_zap_enabled")) + edit.putBoolean("quick_zap_enabled_$pubkey", prefs.getBoolean("quick_zap_enabled", false)) + if (!prefs.contains("quick_zap_message_$pubkey") && prefs.contains("quick_zap_message")) + prefs.getString("quick_zap_message", null)?.let { edit.putString("quick_zap_message_$pubkey", it) } + edit.apply() + } + + companion object { + /** Shared across all InterfacePreferences instances — updated by reload(). */ + @Volatile var activePubkey: String? = null + 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/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) ) 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..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 @@ -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,69 @@ 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.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.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.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.OffsetMapping -import androidx.compose.ui.text.input.TransformedText -import androidx.compose.ui.text.input.VisualTransformation +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 -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 +82,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 - -private val LightningAmber: Color - @Composable get() = WispThemeColors.zapColor.copy(alpha = 0.7f) +import kotlinx.coroutines.launch -@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 +122,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 +137,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,846 +147,690 @@ 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() - var presets by remember { mutableStateOf(ZapPreferences(context).getPresets().sortedBy { it.amountSats }) } + val interfacePrefs = remember { com.wisp.app.repo.InterfacePreferences(context) } + 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("") } + // 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) } - 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 amountFocusRequester = remember { FocusRequester() } + + 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 message = match.message } else { isCustom = true - customAmount = h.toString() + seedCustomAmount(h.toString()) { customAmountTfv = it } message = "" } } - val effectiveAmount = if (isCustom) { + // 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) { + 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) { 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( + // 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 = 24.dp), - shape = RoundedCornerShape(28.dp), - color = WispThemeColors.backgroundColor, - tonalElevation = 8.dp + .imePadding() ) { - Box { - // Background lightning effect - LightningBackground( - modifier = Modifier - .matchParentSize() - .clip(RoundedCornerShape(28.dp)) + 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(), + 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) { + 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 + 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 ─────────────────────── + // 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) + ) - 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 + } + DropdownMenu( + expanded = privacyMenuExpanded, + onDismissRequest = { privacyMenuExpanded = false } ) { - Text( - text = stringResource(R.string.btn_anonymous), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface + DropdownMenuItem( + text = { Text("Public") }, + leadingIcon = { Icon(Icons.Outlined.Visibility, null) }, + onClick = { + isPrivate = false + isAnonymous = false + privacyMenuExpanded = false + } ) - 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 - ) + DropdownMenuItem( + text = { Text("Anonymous") }, + leadingIcon = { Icon(Icons.Outlined.VisibilityOff, null) }, + onClick = { + isAnonymous = true + isPrivate = false + privacyMenuExpanded = false + } ) - } - - // Private toggle - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - 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) { + DropdownMenuItem( + text = { Text("Private") }, + leadingIcon = { Icon(Icons.Filled.Lock, null) }, + onClick = { + isPrivate = true + isAnonymous = false + privacyMenuExpanded = false + } ) - 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) - ) - } } - 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 + } + } + + // ── 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)) + } // end scrollable content Column - // Action buttons - Row( + // ── 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() + .padding(horizontal = 20.dp) + .padding(top = 12.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (overHardCap) { + Text( + "Max ${"%,d".format(ZAP_HARD_CAP_SATS)} sats per zap", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - TextButton( - onClick = onDismiss, - modifier = Modifier.weight(1f) - ) { - Text(stringResource(R.string.btn_cancel)) - } - - Button( - onClick = { - onZap(effectiveAmount * 1000, effectiveMessage.ifEmpty { message }, isAnonymous, isPrivate) - }, - enabled = effectiveAmount > 0, - 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 - ) + 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 + ) } } } } + // 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 = accent) + ) { Text("Send", fontWeight = FontWeight.Bold) } + }, + dismissButton = { + TextButton(onClick = { showLargeAmountConfirm = false }) { + Text(stringResource(R.string.btn_cancel)) + } + } + ) + } + + 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 } + ) + } } -/** - * 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() } +// ─── Helpers ───────────────────────────────────────────────────────────── -/** - * 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)}" -} +private const val ZAP_SOFT_CONFIRM_SATS = 10_000L +private const val ZAP_HARD_CAP_SATS = 1_000_000L /** - * 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. + * 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 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) - } +private fun seedCustomAmount(text: String, set: (TextFieldValue) -> Unit) { + set(TextFieldValue(text = text, selection = TextRange(0, text.length))) } /** - * 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. + * Pill-shaped text button — used for the toolbar's Close + Presets + * actions. Border-only by default, fillable via `borderColor`. */ -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) +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) ) { - // 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) + 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 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 - ) +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) ) - - // 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) -} - +/** 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 ZapPresetChip( - preset: ZapPreset, - isSelected: Boolean, - editMode: Boolean, +private fun CustomPlusPill( + label: String, + selected: Boolean, + accent: Color, + showPlus: Boolean, onClick: () -> Unit, - onRemove: () -> Unit + onPlusClick: () -> 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 + 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) ) { - 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)) + 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.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) + Icons.Filled.Add, + contentDescription = "Save as preset", + tint = Color.White, + modifier = Modifier.size(14.dp) ) } } } - - // 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 +/** + * 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 } - ) { - 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 - ) } } +/** + * 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. + */ @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() ) } }, @@ -994,7 +841,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) } @@ -1004,4 +851,3 @@ private fun SaveZapPresetDialog( } ) } - 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 34902103..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 @@ -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) } ) } @@ -575,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/InterfaceScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/InterfaceScreen.kt index 110da1cb..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 @@ -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 @@ -17,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 @@ -141,6 +145,7 @@ fun InterfaceScreen( modifier = Modifier .fillMaxSize() .padding(padding) + .imePadding() .verticalScroll(rememberScrollState()) .padding(16.dp) ) { @@ -753,6 +758,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) { 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 } } ) } diff --git a/app/src/main/kotlin/com/wisp/app/viewmodel/FeedViewModel.kt b/app/src/main/kotlin/com/wisp/app/viewmodel/FeedViewModel.kt index 01dae422..09aff46f 100644 --- a/app/src/main/kotlin/com/wisp/app/viewmodel/FeedViewModel.kt +++ b/app/src/main/kotlin/com/wisp/app/viewmodel/FeedViewModel.kt @@ -428,7 +428,10 @@ class FeedViewModel(app: Application) : AndroidViewModel(app) { fun markLoadingComplete() = feedSub.markLoadingComplete() // -- Startup delegates -- - fun initRelays() = startup.initRelays() + fun initRelays() { + interfacePrefs.reload(getUserPubkey()) + startup.initRelays() + } fun resetForAccountSwitch() { startup.resetForAccountSwitch() groupRepo.clear() @@ -436,6 +439,7 @@ class FeedViewModel(app: Application) : AndroidViewModel(app) { } fun reloadForNewAccount() { safetyPrefs.reload(getUserPubkey()) + interfacePrefs.reload(getUserPubkey()) startup.reloadForNewAccount() groupRepo.reload(getUserPubkey()) }