From 80b50ea4f74a1aff9f628ba463d9ef8d467ccb98 Mon Sep 17 00:00:00 2001 From: The Daniel Date: Thu, 21 May 2026 00:04:32 -0400 Subject: [PATCH 01/16] feat(nip78): cross-device sync of UI prefs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports iOS commit #1 from feat/one-tap-zap. Publishes a single NIP-44-encrypted kind 30078 event addressed by the `wisp-app-settings:v1` d-tag whenever any synced setting changes (debounced 4s — matches iOS). On launch, fetches and applies the remote backup non-destructively. Synced fields: zapIconStyle, largeText, themeName, accentColorARGB, autoLoadMedia, videoAutoplay, mediaLayoutStyle, clientTagEnabled, postUndoTimerEnabled, postUndoTimerSeconds, postUndoTimerForReplies, fiatModeEnabled, fiatCurrency, zapPresetsCSV. (Quick-zap fields will plug in via the same payload in commit #2 — schema is already forward-compatible.) Wiring: • Nip78.kt — adds AppSettingsPayload (matches iOS keys), plus create/decrypt/filter helpers. • AppSettingsRepository — owns the signer/relay-pool refs, debouncer, build/publish/restore. Restore temporarily detaches its own sync callbacks so applying a remote backup doesn't kick off an immediate re-publish loop. • InterfacePreferences / FiatPreferences / ZapPreferences — gain an `onSyncedFieldChanged` hook fired only on synced-field setters. Non-synced setters (language, newNotesButtonHidden, liveStreamsHidden, autoTranslate) deliberately stay quiet. • ZapPreferences — gains CSV round-trip helpers (toCSV / applyCSV). CSV format matches the iOS spec: `` or `:`, comma-separated. `applyCSV` suppresses its own sync callback to avoid a restore-publish loop. • FeedViewModel — constructs AppSettingsRepository, plumbs it into setSigner / clearSigner. • StartupCoordinator — calls `restoreSettingsBackup()` after relay-list fetch on both cold and warm starts (best-effort, fire-and-forget). • InterfaceScreen — new "Cross-device sync" section with a toggle bound to `isSyncSettingsToRelays()` (default on). Flipping on immediately fires a sync via the new onSyncRequested callback plumbed through Navigation.kt. Also drops the "GIF picker keyboard race" item from ANDROID_PORT_one_tap_zap.md — that race doesn't reproduce in Compose. --- ANDROID_PORT_one_tap_zap.md | 277 ++++++++++++++++++ .../main/kotlin/com/wisp/app/Navigation.kt | 3 +- .../main/kotlin/com/wisp/app/nostr/Nip78.kt | 73 +++++ .../wisp/app/repo/AppSettingsRepository.kt | 220 ++++++++++++++ .../com/wisp/app/repo/FiatPreferences.kt | 9 + .../com/wisp/app/repo/InterfacePreferences.kt | 71 ++++- .../com/wisp/app/repo/ZapPreferences.kt | 38 +++ .../com/wisp/app/ui/screen/InterfaceScreen.kt | 37 ++- .../com/wisp/app/viewmodel/FeedViewModel.kt | 10 +- .../wisp/app/viewmodel/StartupCoordinator.kt | 7 + 10 files changed, 731 insertions(+), 14 deletions(-) create mode 100644 ANDROID_PORT_one_tap_zap.md create mode 100644 app/src/main/kotlin/com/wisp/app/repo/AppSettingsRepository.kt diff --git a/ANDROID_PORT_one_tap_zap.md b/ANDROID_PORT_one_tap_zap.md new file mode 100644 index 00000000..e4c60d2f --- /dev/null +++ b/ANDROID_PORT_one_tap_zap.md @@ -0,0 +1,277 @@ +# Android port: `feat/one-tap-zap` + +A guide to port the iOS `feat/one-tap-zap` branch to Wisp Android, written +for a separate agent picking it up. Mirror the user-facing behavior and the +on-disk data formats; SwiftUI-specific implementation notes are flagged so +the Android side can pick the right Compose equivalent. + +## Scope + +Seven commits, grouped: + +1. **NIP-78 cross-device sync of UI prefs** — already may have an Android + equivalent or partial; verify against `feat/nip78-settings-sync` on + Android side. +2. **Instant zaps + fiat counterpart "Instant payments"** — new + `quickZap*` settings, payload fields, post-card tap behavior. +3. **Wallet setup primary Spark button + More options accordion** — two + screens redesigned for one-obvious-next-step. +4. **ZapSheet redesign** — full layout rewrite. +5. **Tap composer / long-press instant / disable self-zap** — gesture + swap on the post-card zap button. +6. **White-core glow pulse for in-flight bolt** — new pulse animation. +7. **DEBUG-only developer panel** — empty scaffold for future + throwaway experiments. + +## Settings — Quick zaps + +iOS adds four `AppSettings` properties, each persisted to UserDefaults +and round-tripped through the NIP-78 backup payload: + +| Key | Type | Default | Notes | +|---|---|---|---| +| `wisp_settings_quick_zap_enabled` | Bool | `false` | Master toggle | +| `wisp_settings_quick_zap_amount_sats` | Int64 | `100` | Used in non-fiat mode | +| `wisp_settings_quick_zap_amount_fiat` | Double | `0.10` | Major-unit (e.g. dollars), converted to sats via exchange-rate cache at fire time | +| `wisp_settings_quick_zap_message` | String | `""` | Optional default zap message | + +NIP-78 `AppSettingsPayload` carries all four: +`quickZapEnabled`, `quickZapAmountSats`, `quickZapAmountFiat`, +`quickZapMessage`. Mirror the JSON keys exactly so the iOS and Android +backups are bit-compatible. + +`InterfaceSettingsView` section "Zaps" / "Payments": +- Toggle: "Instant zaps" / "Instant payments" (label flips with fiat mode) +- Below the toggle (gated on `quickZapEnabled`): amount field, message + field, helper text explaining the tap behavior. +- **Amount clamping** — sats field clamps `min(10_000, max(1, value))`; + fiat field clamps to the 10K-sats equivalent via the cached rate. Hard + cap because instant zaps shouldn't bypass the soft confirmation. + +## Settings — NIP-78 sync + +Adds `wisp_settings_sync_settings_to_relays` (Bool, default `true`). +Toggle in the "Cross-device sync" section explains the publish. When on, +mutations to any synced field schedule a debounced (4 s) kind-30078 +publish via the same backup encryption used by quick-reactions. + +Synced fields (full payload): `zapIconStyle`, `fiatModeEnabled`, +`fiatCurrency`, `zapPresetsCSV`, `largeText`, `themeName`, `colorScheme`, +`accentColorARGB`, `autoLoadMedia`, `videoAutoplay`, `animateAvatars`, +`mediaLayoutStyle`, `clientTagEnabled`, `postUndoTimerEnabled`, +`postUndoTimerSeconds`, `postUndoTimerForReplies`, plus the four quick-zap +fields. Restore is non-destructive: each field has its own `if let` guard +so missing values stay at the local default. + +## Wallet setup screens + +### Mode picker + +Two rows: Spark (primary) and Nostr Wallet Connect (peer). + +* Spark is a full-bleed orange (`wispZapColor`) filled card with white + text + key icon, layered shadow glow (two stacked: tight 55% / wide 35% + outer, both in zap-color). +* NWC keeps its dark surface treatment. +* Don't bury NWC under "More options" — keep it visible. + +### Spark setup picker + +* "Use my default wallet" rendered with the same primary treatment + (filled orange, white text, glow shadow stack). +* Create new wallet / Restore from seed phrase / Restore from relays + collapse under a "More options" disclosure that rotates a chevron and + fades the rows in / out. +* Pick section is **vertically centered** in the available viewport + (iOS uses a GeometryReader-backed ScrollView with leading + trailing + Spacers stretching the inner column to `minHeight: geo.size.height`). + Android equivalent: `Box` filling parent with `verticalArrangement = + Center` once the column fits the viewport; fall back to scroll on + smaller phones. + +## ZapSheet redesign + +The whole composer was rewritten. Top-to-bottom layout, with the keyboard +auto-focused on the amount field and the sheet wrapped in a scrollable +container so dragging dismisses the keyboard naturally. + +### Layout (top → bottom) + +1. **Toolbar** — `Close` left, `Presets` right (orange tint). +2. **Recipient row** — compact `HStack`: + * 32pt avatar (left) + * Display name + lud16 (caption monospace, secondary color) + * Copy-icon button right (single button, **not** a menu; copying the + lud16 fires a local pill `"Lightning address copied"`). +3. **Hero amount** — 56pt rounded-bold zap-color number, optional + "sats" caption hidden in fiat mode. Tappable: tap → focus the + hidden amount field, seed `customAmountText` with the register-style + cents digits (fiat) or the integer sats string (non-fiat). +4. **Preset strip** — `FlowLayout` (wraps to new line, doesn't scroll + horizontally). Each pill renders the formatted sats amount; the + `Custom` pill carries an inline `+` badge for save-as-preset when + the current amount isn't yet in the list (badge disables at 8 max). +5. **Hidden amount TextField** — zero-size, anchored to the focus + state. Tap on hero / onAppear sets focus. +6. **Message field** — always-visible single-line. +7. **Privacy dropdown** — `Menu` with "Public / Anonymous / Private", + eye / eye-slash / lock icon. Helper caption appears for non-public + types. +8. **Instant zaps toggle** — bound directly to `quickZapEnabled` so + users can flip the setting from the sheet without navigating to + settings. +9. **Bottom bar** — full-width Zap button. Above it: "Max 1,000,000 + sats per zap" red caption when over the hard cap. + +### Behaviors + +**Auto-focus on appear, deferred 450 ms.** The keyboard rises after the +sheet's mount + transition completes — without the hop the keyboard +rising during mount changes the parent feed-row's layout, which +unmounts the `.sheet` and produces an open/close loop. Compose's +`SideEffect` after `LaunchedEffect(Unit) { delay(450) ; focusRequester.requestFocus() }` +should give the same window. + +**Amount seed.** On appear `amountSats` is set from +`quickZapAmountSats` (or fiat equivalent) — treat the configured +instant-zap amount as the user's "preferred opening amount" even when +quick zaps are disabled. + +**First-keystroke-replaces-seed.** Track a `hasTypedAmount` flag. +While false (the very first time the field becomes editable), an +empty value from the TextField binding is the platform's initial-focus +bind commit and is ignored — the seeded amount survives. Once true, +empty means the user backspaced and `amountSats` follows to 0, +disabling the Zap button. Tapping a preset resets the flag. + +**1,000,000 sats hard cap.** Zap button disables; red "Max …" caption +appears. + +**10,000 sats soft confirmation.** Zap button tap routes through a +confirmation dialog ("Zap N sats? — This is a large amount, double-check +before sending"). Below 10K fires immediately. + +**Per-user preset storage.** Key is `zapPresetAmounts_` (one +slot per signed-in account). On first read for a new account, migrate +the legacy global `zapPresetAmounts` value into the per-user slot. +NIP-78 backup pulls + restores from the active user's slot. + +**Preset format.** CSV of entries: `` or `:`. +The optional message is a default zap note that auto-fills the message +field when the preset is tapped (only when the message field is +currently empty, so it doesn't clobber typing). Commas and colons are +stripped from the saved message because they're the format delimiters. + +**EditPresetsSheet.** Two columns per row (Amount, Message), +drag-to-reorder, swipe-to-delete. "Add preset" button **disabled** when +any existing row has an empty amount (one blank row at a time). + +**Scroll + keyboard.** The whole sheet content is a `ScrollView` with +`.scrollDismissesKeyboard(.interactively)` so a drag-down dismiss +collapses the keyboard the moment the drag starts — fixes a "floating +elements" artifact where keyboard-avoidance fought the sheet drag. +Compose equivalent: `Modifier.nestedScroll` with a connection that +drops `LocalSoftwareKeyboardController.current` on first downward drag. + +**Self-zap disabled.** Compute `isOwnPost = (myPubkey == event.pubkey)` +(after `resolveRepost` so reposting your own note still disables but +reposting someone else's stays active). Zap button rendered at 35% +opacity, disabled, and the long-press gesture is also short-circuited. + +**Friendly error copy.** `ZapAnimationStore.friendlyMessage(for:)` +maps raw SDK error strings into plain copy. Mirror the same pattern +match on Android. Cases: + +| Substring (case-insensitive) | Replacement | +|---|---| +| `insufficient funds` / `insufficient balance` | "Not enough sats in your wallet." | +| `no route` / `route not found` / `unreachable` | "Couldn't find a payment route to the recipient. Try again later." | +| `expired` / `invoice has expired` | "The lightning invoice expired before it could be paid. Try again." | +| `timeout` / `timed out` | "The payment timed out. Check your connection and try again." | +| `no lud16` / `no lightning address` | "This account doesn't have a lightning address." | +| `lnurl` AND `400` | "The recipient's lightning provider rejected this zap. Try a different amount." | +| `amount too small` / `below minimum` | "Amount is below the recipient's minimum. Try a larger zap." | +| `amount too large` / `above maximum` | "Amount is above the recipient's maximum. Try a smaller zap." | + +Fallback: extract the substring between the first `("` and `")` (Swift +enum description wrapper) if present; otherwise pass through raw. + +## Post-card zap gesture + +* **Tap** — always opens the ZapSheet composer. +* **Long-press (400 ms)** — fires the configured instant-zap amount + *if* `quickZapEnabled` is on and a wallet is set up; otherwise falls + through to the composer so the gesture never feels like a no-op. + +Pin a `zapLongPressFired` flag so the tap handler short-circuits the +release after a long-press completes (otherwise SwiftUI fires both the +long-press handler AND the underlying button tap; Compose has the same +issue with `combinedClickable`). + +## In-flight bolt pulse + +Replaces the multi-layer Canvas bolt that smeared the silhouette at +scale peaks. New approach: **always-white silhouette** + three stacked +zap-color shadows behind it. + +Single sin-eased oscillator runs the whole animation: +``` +sine = sin(elapsed / 0.9 * 2π) // -1 … 1 +phase = (sine + 1) / 2 // 0 … 1 + +iconScale = 1.0 + 0.10 * sine // 0.90 → 1.10, centered +verticalOffset = -0.5 * sine // ±0.5pt, centered on baseline +``` + +Shadows (Compose: `drawBehind` with `drawCircle(blendMode = Plus)`-style +falloff, or stack three `Shape.shadow` modifiers): + +| Layer | Radius | Color opacity | +|---|---|---| +| inner | 1.5pt | `wispZapColor * 0.95`, constant | +| medium | `4 + 3 * phase` | `wispZapColor * (0.55 + 0.45 * phase)` | +| outer | `8 + 6 * phase` | `wispZapColor * (0.3 + 0.5 * phase)` | + +Vertical motion stays within ±0.5pt so the icon doesn't lift off the +action-bar baseline. Don't tint the icon itself — the white silhouette +is the luminous core, the shadows do the heat work. + +## Developer panel + +`DeveloperToolsView` is a `#if DEBUG` view presented from a new +"Developer" row in Interface settings (also wrapped in `#if DEBUG`). +Currently empty placeholder — Android equivalent is a `BuildConfig.DEBUG` +guarded row + activity / sheet so future throwaway experiments land +somewhere out of production code instead of building one-off entry +points each time. + +## Test plan + +- [ ] Wallet setup: Spark is primary glowing button, NWC remains + visible peer. Spark setup screen vertically centers content with + "Use my default wallet" primary + collapsible More options. +- [ ] Open zap sheet from a feed post: keyboard up, hero shows the + configured one-tap amount, first keystroke replaces it, backspace + to empty disables Zap. +- [ ] Tap a preset → amount + (if present) message auto-fill. +- [ ] Custom + `+` chip → adds the current amount to the user's + per-account preset row. +- [ ] EditPresetsSheet: Add preset disabled when a blank row exists; + Done writes per-account, drag-to-reorder works. +- [ ] Privacy dropdown cycles Public / Anonymous / Private. +- [ ] Instant zaps toggle on the sheet flips the same setting as + the Interface screen. +- [ ] Drag the sheet down → keyboard collapses, sheet body translates + as one unit (no floating items). +- [ ] > 10K sats → confirmation dialog. > 1M sats → Zap disabled + red + cap message. +- [ ] Insufficient funds → "Not enough sats in your wallet." +- [ ] Post-card zap: tap opens composer, long-press fires instant + (when configured), self-post button disabled at 35% opacity. +- [ ] In-flight bolt: white core with breathing warm glow, centered + bounce, stays aligned with neighbouring action-bar items. +- [ ] Sign in as Account A, set custom presets, switch to Account B — + B sees its own row (default first time). Switch back to A — A's + row intact. +- [ ] Settings → Interface (debug build) → Developer → Developer tools + opens (empty placeholder). diff --git a/app/src/main/kotlin/com/wisp/app/Navigation.kt b/app/src/main/kotlin/com/wisp/app/Navigation.kt index 80c11255..34ab52f8 100644 --- a/app/src/main/kotlin/com/wisp/app/Navigation.kt +++ b/app/src/main/kotlin/com/wisp/app/Navigation.kt @@ -2698,7 +2698,8 @@ fun WispNavHost( application = app, interfacePrefs = interfacePrefs, onBack = { navController.popBackStack() }, - onChanged = onInterfaceChanged + onChanged = onInterfaceChanged, + onSyncRequested = { feedViewModel.appSettingsRepo.scheduleSettingsSync() } ) } diff --git a/app/src/main/kotlin/com/wisp/app/nostr/Nip78.kt b/app/src/main/kotlin/com/wisp/app/nostr/Nip78.kt index 77261327..491ccef2 100644 --- a/app/src/main/kotlin/com/wisp/app/nostr/Nip78.kt +++ b/app/src/main/kotlin/com/wisp/app/nostr/Nip78.kt @@ -128,4 +128,77 @@ object Nip78 { /** Extract the d-tag value from an event, or null. */ fun extractDTag(event: NostrEvent): String? = event.tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1) + + // ─── App Settings Backup ────────────────────────────────────────────────── + + const val APP_SETTINGS_D_TAG = "wisp-app-settings:v1" + + /** + * Versioned, NIP-44-encrypted UI-prefs payload stored as kind 30078. + * Every field is optional so older / newer clients round-trip without + * data loss. JSON keys match iOS Nip78Backup.AppSettingsPayload + * byte-for-byte so the backups are bit-compatible across platforms. + */ + @kotlinx.serialization.Serializable + data class AppSettingsPayload( + // Interface prefs + val zapIconStyle: String? = null, + val largeText: Boolean? = null, + val themeName: String? = null, + val accentColorARGB: Int? = null, + val autoLoadMedia: Boolean? = null, + val videoAutoplay: Boolean? = null, + val mediaLayoutStyle: String? = null, + val clientTagEnabled: Boolean? = null, + val postUndoTimerEnabled: Boolean? = null, + val postUndoTimerSeconds: Int? = null, + val postUndoTimerForReplies: Boolean? = null, + // Fiat prefs + val fiatModeEnabled: Boolean? = null, + val fiatCurrency: String? = null, + // Zap prefs + val zapPresetsCSV: String? = null, + // Quick-zap prefs (task #2 — fields included now so the schema is + // forward-compatible with iOS backups that already carry them). + val quickZapEnabled: Boolean? = null, + val quickZapAmountSats: Long? = null, + val quickZapAmountFiat: Double? = null, + val quickZapMessage: String? = null, + val version: Int? = 1 + ) + + private val lenientJson = kotlinx.serialization.json.Json { + ignoreUnknownKeys = true + encodeDefaults = false + } + + /** Build and sign a kind 30078 event carrying the NIP-44-encrypted settings JSON. */ + suspend fun createAppSettingsEvent(signer: NostrSigner, payload: AppSettingsPayload): NostrEvent { + val json = lenientJson.encodeToString(AppSettingsPayload.serializer(), payload) + val encrypted = signer.nip44Encrypt(json, signer.pubkeyHex) + val tags = listOf( + listOf("d", APP_SETTINGS_D_TAG), + listOf("encryption", "nip44") + ) + return signer.signEvent(kind = KIND, content = encrypted, tags = tags) + } + + /** Decrypt a kind 30078 app-settings event and parse the JSON payload. Returns null on any failure. */ + suspend fun decryptAppSettings(signer: NostrSigner, event: NostrEvent): AppSettingsPayload? { + if (event.content.isBlank()) return null + return try { + val decrypted = signer.nip44Decrypt(event.content, event.pubkey) + lenientJson.decodeFromString(AppSettingsPayload.serializer(), decrypted) + } catch (_: Exception) { + null + } + } + + /** Filter to fetch this user's app-settings backup (single addressable event). */ + fun appSettingsFilter(pubkeyHex: String): Filter = Filter( + kinds = listOf(KIND), + authors = listOf(pubkeyHex), + dTags = listOf(APP_SETTINGS_D_TAG), + limit = 1 + ) } diff --git a/app/src/main/kotlin/com/wisp/app/repo/AppSettingsRepository.kt b/app/src/main/kotlin/com/wisp/app/repo/AppSettingsRepository.kt new file mode 100644 index 00000000..df7e0457 --- /dev/null +++ b/app/src/main/kotlin/com/wisp/app/repo/AppSettingsRepository.kt @@ -0,0 +1,220 @@ +package com.wisp.app.repo + +import android.util.Log +import com.wisp.app.nostr.ClientMessage +import com.wisp.app.nostr.Nip78 +import com.wisp.app.nostr.NostrEvent +import com.wisp.app.nostr.NostrSigner +import com.wisp.app.nostr.RelayMessage +import com.wisp.app.relay.RelayEvent +import com.wisp.app.relay.RelayPool +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.yield + +/** + * NIP-78 cross-device sync of UI preferences. Publishes a single + * NIP-44-encrypted kind 30078 event addressed by the + * `wisp-app-settings:v1` d-tag whenever any synced setting changes + * (debounced 4s — matches iOS). On launch, fetches and applies the + * remote backup non-destructively (each field has its own guard so + * missing values stay at the local default). + * + * Only fires when [InterfacePreferences.isSyncSettingsToRelays] is + * true. The toggle defaults to on. The user can disable it from the + * "Cross-device sync" section of the Interface settings screen. + */ +class AppSettingsRepository( + private val interfacePrefs: InterfacePreferences, + private val fiatPrefs: FiatPreferences, + private val zapPrefs: ZapPreferences +) { + private val TAG = "AppSettingsSync" + + /** Active signer. Set by FeedViewModel after the user logs in / out. */ + @Volatile + var signer: NostrSigner? = null + + /** Relay pool to publish through / read from. Set once at construction. */ + @Volatile + var relayPool: RelayPool? = null + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private var debounceJob: Job? = null + + init { + // Register sync callbacks on every prefs source. Any synced-field + // setter (e.g. `interfacePrefs.setLargeText(...)`) will now bounce + // off `scheduleSettingsSync()` automatically. + interfacePrefs.onSyncedFieldChanged = { scheduleSettingsSync() } + fiatPrefs.onSyncedFieldChanged = { scheduleSettingsSync() } + zapPrefs.onSyncedFieldChanged = { scheduleSettingsSync() } + } + + /** + * Schedule a debounced sync. Called by setting setters whenever a + * synced field mutates. Coalesces rapid edits — only the last + * `scheduleSettingsSync()` in a 4s window actually publishes. + */ + fun scheduleSettingsSync() { + if (!interfacePrefs.isSyncSettingsToRelays()) return + val s = signer ?: return + val pool = relayPool ?: return + + debounceJob?.cancel() + debounceJob = scope.launch { + delay(DEBOUNCE_MS) + try { + publishNow(s, pool) + } catch (e: Exception) { + Log.w(TAG, "publish failed: ${e.message}") + } + } + } + + /** Build the current payload from all synced prefs sources. */ + private fun buildPayload(): Nip78.AppSettingsPayload { + val mediaLayout = interfacePrefs.getMediaLayoutStyle().key + return Nip78.AppSettingsPayload( + zapIconStyle = if (interfacePrefs.isZapBoltIcon()) "bolt" else "default", + largeText = interfacePrefs.isLargeText(), + themeName = interfacePrefs.getTheme(), + accentColorARGB = interfacePrefs.getAccentColor(), + autoLoadMedia = interfacePrefs.isAutoLoadMedia(), + videoAutoplay = interfacePrefs.isVideoAutoPlay(), + mediaLayoutStyle = mediaLayout, + clientTagEnabled = interfacePrefs.isClientTagEnabled(), + postUndoTimerEnabled = interfacePrefs.isPostUndoTimerEnabled(), + postUndoTimerSeconds = interfacePrefs.getPostUndoTimerSeconds(), + postUndoTimerForReplies = interfacePrefs.isPostUndoTimerForReplies(), + fiatModeEnabled = fiatPrefs.isFiatMode(), + fiatCurrency = fiatPrefs.getCurrency(), + zapPresetsCSV = zapPrefs.toCSV(), + version = 1 + ) + } + + private suspend fun publishNow(signer: NostrSigner, pool: RelayPool) { + pool.ensureWriteRelaysConnected() + val payload = buildPayload() + val event = Nip78.createAppSettingsEvent(signer, payload) + val sent = pool.sendToWriteRelays(ClientMessage.event(event)) + Log.d(TAG, "publish sent=$sent") + } + + /** + * Fetch the latest backup for this user and apply it + * non-destructively. Called by StartupCoordinator on launch / account + * switch. Each field has its own null-guard so missing values keep + * the local default — adding a new field on iOS won't wipe its value + * on Android (and vice-versa). + */ + suspend fun restoreSettingsBackup() { + val s = signer ?: return + val pool = relayPool ?: return + if (!interfacePrefs.isSyncSettingsToRelays()) return + + try { + pool.ensureWriteRelaysConnected() + } catch (_: Exception) { /* best-effort */ } + + val pubkey = s.pubkeyHex + val subId = "app-settings-${System.currentTimeMillis()}" + val filter = Nip78.appSettingsFilter(pubkey) + val seen = mutableSetOf() + val events = mutableListOf() + var eoseCount = 0 + + val collectJob = scope.launch { + pool.relayEvents.collect { re: RelayEvent -> + if (re.subscriptionId == subId && seen.add(re.event.id)) events.add(re.event) + } + } + val eoseJob = scope.launch { + pool.eoseSignals.collect { id -> + if (id == subId) eoseCount++ + } + } + yield() + val total = pool.getRelayUrls().size + val minEose = (total * 2 + 2) / 3 + pool.sendToAll(ClientMessage.req(subId, filter)) + withTimeoutOrNull(8_000) { + while (eoseCount < total) { + delay(150) + if (eoseCount >= minEose) break + } + } + collectJob.cancel() + eoseJob.cancel() + pool.closeOnAllRelays(subId) + + val newest = events + .filter { Nip78.extractDTag(it) == Nip78.APP_SETTINGS_D_TAG } + .maxByOrNull { it.created_at } ?: return + + val payload = Nip78.decryptAppSettings(s, newest) ?: return + applyPayload(payload) + } + + /** + * Apply a decoded payload to local prefs. Each field is guarded — + * a null in the payload preserves the local value, so older devices + * with fewer fields don't wipe newer ones. + * + * Sync callbacks are temporarily detached so the restore doesn't + * trigger an immediate re-publish loop. (ZapPrefs `applyCSV` has its + * own suppress flag.) + */ + private fun applyPayload(p: Nip78.AppSettingsPayload) { + val iface = interfacePrefs.onSyncedFieldChanged + val fiat = fiatPrefs.onSyncedFieldChanged + val zap = zapPrefs.onSyncedFieldChanged + interfacePrefs.onSyncedFieldChanged = null + fiatPrefs.onSyncedFieldChanged = null + zapPrefs.onSyncedFieldChanged = null + try { + p.zapIconStyle?.let { interfacePrefs.setZapBoltIcon(it == "bolt") } + p.largeText?.let { interfacePrefs.setLargeText(it) } + p.themeName?.let { interfacePrefs.setTheme(it) } + p.accentColorARGB?.let { interfacePrefs.setAccentColor(it) } + p.autoLoadMedia?.let { interfacePrefs.setAutoLoadMedia(it) } + p.videoAutoplay?.let { interfacePrefs.setVideoAutoPlay(it) } + p.mediaLayoutStyle?.let { + interfacePrefs.setMediaLayoutStyle(InterfacePreferences.MediaLayoutStyle.fromKey(it)) + } + p.clientTagEnabled?.let { interfacePrefs.setClientTagEnabled(it) } + p.postUndoTimerEnabled?.let { interfacePrefs.setPostUndoTimerEnabled(it) } + p.postUndoTimerSeconds?.let { interfacePrefs.setPostUndoTimerSeconds(it) } + p.postUndoTimerForReplies?.let { interfacePrefs.setPostUndoTimerForReplies(it) } + p.fiatModeEnabled?.let { fiatPrefs.setFiatMode(it) } + p.fiatCurrency?.let { fiatPrefs.setCurrency(it) } + p.zapPresetsCSV?.let { zapPrefs.applyCSV(it) } + } finally { + interfacePrefs.onSyncedFieldChanged = iface + fiatPrefs.onSyncedFieldChanged = fiat + zapPrefs.onSyncedFieldChanged = zap + } + } + + /** Switch the active account. Re-points the per-user zap prefs and clears the debounce. */ + fun reload(pubkeyHex: String?) { + debounceJob?.cancel() + zapPrefs.reload(pubkeyHex) + } + + fun close() { + debounceJob?.cancel() + scope.cancel() + } + + companion object { + private const val DEBOUNCE_MS = 4_000L + } +} diff --git a/app/src/main/kotlin/com/wisp/app/repo/FiatPreferences.kt b/app/src/main/kotlin/com/wisp/app/repo/FiatPreferences.kt index d94dbff2..0fae6cb1 100644 --- a/app/src/main/kotlin/com/wisp/app/repo/FiatPreferences.kt +++ b/app/src/main/kotlin/com/wisp/app/repo/FiatPreferences.kt @@ -14,12 +14,20 @@ class FiatPreferences(context: Context) { private val _currency = MutableStateFlow(prefs.getString(KEY_CURRENCY, "USD") ?: "USD") val currency: StateFlow = _currency.asStateFlow() + /** + * Fired after fiatMode / currency mutations so the + * AppSettingsRepository can debounce-publish the new NIP-78 backup. + */ + @Volatile + var onSyncedFieldChanged: (() -> Unit)? = null + fun isFiatMode(): Boolean = _fiatMode.value fun setFiatMode(enabled: Boolean) { if (_fiatMode.value == enabled) return prefs.edit().putBoolean(KEY_FIAT_MODE, enabled).apply() _fiatMode.value = enabled + onSyncedFieldChanged?.invoke() } fun getCurrency(): String = _currency.value @@ -28,6 +36,7 @@ class FiatPreferences(context: Context) { if (_currency.value == code) return prefs.edit().putString(KEY_CURRENCY, code).apply() _currency.value = code + onSyncedFieldChanged?.invoke() } companion object { 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..bce9a4fd 100644 --- a/app/src/main/kotlin/com/wisp/app/repo/InterfacePreferences.kt +++ b/app/src/main/kotlin/com/wisp/app/repo/InterfacePreferences.kt @@ -19,37 +19,72 @@ class InterfacePreferences(context: Context) { private val prefs = context.getSharedPreferences("wisp_settings", Context.MODE_PRIVATE) + /** + * Optional hook fired after any setter that mutates a NIP-78-synced + * field. AppSettingsRepository registers itself here so a debounced + * cross-device publish kicks off automatically. Non-synced setters + * (language, newNotesButtonHidden, liveStreamsHidden, autoTranslate) + * don't call this. + */ + @Volatile + var onSyncedFieldChanged: (() -> Unit)? = null + + private fun fireSync() { onSyncedFieldChanged?.invoke() } + fun getAccentColor(): Int = prefs.getInt("accent_color", 0xFFFF9800.toInt()) - fun setAccentColor(colorInt: Int) = prefs.edit().putInt("accent_color", colorInt).apply() + fun setAccentColor(colorInt: Int) { + prefs.edit().putInt("accent_color", colorInt).apply() + fireSync() + } fun isLargeText(): Boolean = prefs.getBoolean("large_text", false) - fun setLargeText(enabled: Boolean) = prefs.edit().putBoolean("large_text", enabled).apply() + fun setLargeText(enabled: Boolean) { + prefs.edit().putBoolean("large_text", enabled).apply() + fireSync() + } fun isNewNotesButtonHidden(): Boolean = prefs.getBoolean("new_notes_button_hidden", false) fun setNewNotesButtonHidden(hidden: Boolean) = prefs.edit().putBoolean("new_notes_button_hidden", hidden).apply() fun getTheme(): String = prefs.getString("theme", "custom") ?: "custom" - fun setTheme(theme: String) = prefs.edit().putString("theme", theme).apply() + fun setTheme(theme: String) { + prefs.edit().putString("theme", theme).apply() + fireSync() + } fun isClientTagEnabled(): Boolean = prefs.getBoolean("client_tag_enabled", true) - fun setClientTagEnabled(enabled: Boolean) = prefs.edit().putBoolean("client_tag_enabled", enabled).apply() + fun setClientTagEnabled(enabled: Boolean) { + prefs.edit().putBoolean("client_tag_enabled", enabled).apply() + fireSync() + } fun isAutoLoadMedia(): Boolean = prefs.getBoolean("auto_load_media", true) - fun setAutoLoadMedia(enabled: Boolean) = prefs.edit().putBoolean("auto_load_media", enabled).apply() + fun setAutoLoadMedia(enabled: Boolean) { + prefs.edit().putBoolean("auto_load_media", enabled).apply() + fireSync() + } fun isVideoAutoPlay(): Boolean = prefs.getBoolean("video_auto_play", true) - fun setVideoAutoPlay(enabled: Boolean) = prefs.edit().putBoolean("video_auto_play", enabled).apply() + fun setVideoAutoPlay(enabled: Boolean) { + prefs.edit().putBoolean("video_auto_play", enabled).apply() + fireSync() + } fun getMediaLayoutStyle(): MediaLayoutStyle = MediaLayoutStyle.fromKey(prefs.getString("media_layout_style", null)) - fun setMediaLayoutStyle(style: MediaLayoutStyle) = + fun setMediaLayoutStyle(style: MediaLayoutStyle) { prefs.edit().putString("media_layout_style", style.key).apply() + fireSync() + } fun getLanguage(): String = prefs.getString("language", "system") ?: "system" fun setLanguage(language: String) = prefs.edit().putString("language", language).apply() fun isZapBoltIcon(): Boolean = prefs.getBoolean("zap_bolt_icon", false) - fun setZapBoltIcon(enabled: Boolean) = prefs.edit().putBoolean("zap_bolt_icon", enabled).apply() + fun setZapBoltIcon(enabled: Boolean) { + prefs.edit().putBoolean("zap_bolt_icon", enabled).apply() + fireSync() + } fun isLiveStreamsHidden(): Boolean = prefs.getBoolean("live_streams_hidden", false) fun setLiveStreamsHidden(hidden: Boolean) = prefs.edit().putBoolean("live_streams_hidden", hidden).apply() @@ -58,16 +93,29 @@ class InterfacePreferences(context: Context) { fun setAutoTranslate(enabled: Boolean) = prefs.edit().putBoolean("auto_translate", enabled).apply() fun isPostUndoTimerEnabled(): Boolean = prefs.getBoolean("post_undo_timer_enabled", true) - fun setPostUndoTimerEnabled(enabled: Boolean) = prefs.edit().putBoolean("post_undo_timer_enabled", enabled).apply() + fun setPostUndoTimerEnabled(enabled: Boolean) { + prefs.edit().putBoolean("post_undo_timer_enabled", enabled).apply() + fireSync() + } fun getPostUndoTimerSeconds(): Int { val stored = prefs.getInt("post_undo_timer_seconds", 10) return if (stored in postUndoTimerOptions) stored else 10 } - fun setPostUndoTimerSeconds(seconds: Int) = prefs.edit().putInt("post_undo_timer_seconds", seconds).apply() + fun setPostUndoTimerSeconds(seconds: Int) { + prefs.edit().putInt("post_undo_timer_seconds", seconds).apply() + fireSync() + } 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() + fun setPostUndoTimerForReplies(enabled: Boolean) { + prefs.edit().putBoolean("post_undo_timer_for_replies", enabled).apply() + fireSync() + } + + // NIP-78 cross-device sync of UI prefs. + fun isSyncSettingsToRelays(): Boolean = prefs.getBoolean("sync_settings_to_relays", true) + fun setSyncSettingsToRelays(enabled: Boolean) = prefs.edit().putBoolean("sync_settings_to_relays", enabled).apply() /** Reset all interface preferences to defaults (called on full logout). */ fun reset() { @@ -85,6 +133,7 @@ class InterfacePreferences(context: Context) { .remove("post_undo_timer_for_replies") .remove("auto_translate") .remove("media_layout_style") + .remove("sync_settings_to_relays") .apply() } } diff --git a/app/src/main/kotlin/com/wisp/app/repo/ZapPreferences.kt b/app/src/main/kotlin/com/wisp/app/repo/ZapPreferences.kt index 4f1e2fe1..f9d9f93f 100644 --- a/app/src/main/kotlin/com/wisp/app/repo/ZapPreferences.kt +++ b/app/src/main/kotlin/com/wisp/app/repo/ZapPreferences.kt @@ -44,6 +44,16 @@ class ZapPreferences(private val context: Context, pubkeyHex: String? = null) { } } + /** + * Fired after preset mutations so AppSettingsRepository can publish + * the new NIP-78 backup. Note: `applyCSV` (the inverse path from a + * remote backup) deliberately swallows this callback to avoid an + * immediate re-publish loop. + */ + @Volatile + var onSyncedFieldChanged: (() -> Unit)? = null + private var suppressSyncCallback = false + fun setPresets(presets: List) { val arr = JSONArray() presets.forEach { preset -> @@ -53,6 +63,7 @@ class ZapPreferences(private val context: Context, pubkeyHex: String? = null) { arr.put(obj) } prefs.edit().putString(KEY_ZAP_PRESETS, arr.toString()).apply() + if (!suppressSyncCallback) onSyncedFieldChanged?.invoke() } fun addPreset(preset: ZapPreset): List { @@ -73,4 +84,31 @@ class ZapPreferences(private val context: Context, pubkeyHex: String? = null) { fun reload(pubkeyHex: String?) { prefs = context.getSharedPreferences(prefsName(pubkeyHex), Context.MODE_PRIVATE) } + + /** + * Serialize the current preset list as CSV for NIP-78 cross-device sync. + * Format: `` or `:`, joined by commas. Messages + * have commas and colons stripped before save because they're the + * format delimiters. + */ + fun toCSV(): String = getPresets().joinToString(",") { preset -> + val msg = preset.message.replace(",", "").replace(":", "").trim() + if (msg.isEmpty()) preset.amountSats.toString() else "${preset.amountSats}:$msg" + } + + /** Parse a CSV string from a NIP-78 backup and replace the current presets. */ + fun applyCSV(csv: String) { + val parsed = csv.split(",") + .mapNotNull { entry -> + val trimmed = entry.trim() + if (trimmed.isEmpty()) return@mapNotNull null + val parts = trimmed.split(":", limit = 2) + val sats = parts[0].trim().toLongOrNull() ?: return@mapNotNull null + ZapPreset(sats, parts.getOrNull(1)?.trim().orEmpty()) + } + if (parsed.isNotEmpty()) { + suppressSyncCallback = true + try { setPresets(parsed) } finally { suppressSyncCallback = false } + } + } } 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 1739e902..cb60da43 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 @@ -92,7 +92,8 @@ fun InterfaceScreen( application: Application, interfacePrefs: InterfacePreferences, onBack: () -> Unit, - onChanged: () -> Unit + onChanged: () -> Unit, + onSyncRequested: (() -> Unit)? = null ) { var isLargeText by remember { mutableStateOf(interfacePrefs.isLargeText()) } var newNotesHidden by remember { mutableStateOf(interfacePrefs.isNewNotesButtonHidden()) } @@ -662,6 +663,40 @@ fun InterfaceScreen( Spacer(Modifier.height(24.dp)) + // Cross-device sync section (NIP-78 app-settings backup) + var syncSettingsEnabled by remember { mutableStateOf(interfacePrefs.isSyncSettingsToRelays()) } + Text( + "Cross-device sync", + 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("Sync settings to relays", style = MaterialTheme.typography.bodyMedium) + Text( + "Encrypted backup of your interface preferences (theme, accent, fiat mode, zap presets, etc.). Picked up automatically when you sign in on another device.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) { + Switch( + checked = syncSettingsEnabled, + onCheckedChange = { + syncSettingsEnabled = it + interfacePrefs.setSyncSettingsToRelays(it) + if (it) onSyncRequested?.invoke() + } + ) + } + } + + Spacer(Modifier.height(24.dp)) + // Fiat Mode section val fiatPrefs = remember { FiatPreferences.get(application) } val fiatModeEnabled by fiatPrefs.fiatMode.collectAsState() 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..28defc4e 100644 --- a/app/src/main/kotlin/com/wisp/app/viewmodel/FeedViewModel.kt +++ b/app/src/main/kotlin/com/wisp/app/viewmodel/FeedViewModel.kt @@ -16,6 +16,7 @@ import com.wisp.app.relay.RelayPool import com.wisp.app.relay.RelayScoreBoard import com.wisp.app.relay.ScoredRelay import com.wisp.app.relay.SubscriptionManager +import com.wisp.app.repo.AppSettingsRepository import com.wisp.app.repo.BlossomRepository import com.wisp.app.repo.BookmarkRepository import com.wisp.app.repo.BookmarkSetRepository @@ -146,11 +147,13 @@ class FeedViewModel(app: Application) : AndroidViewModel(app) { fun setSigner(s: NostrSigner) { signer = s zapSender.signer = s + appSettingsRepo.signer = s registerAuthSigner() } fun clearSigner() { signer = null + appSettingsRepo.signer = null } private fun registerAuthSigner() { @@ -270,6 +273,11 @@ class FeedViewModel(app: Application) : AndroidViewModel(app) { } val interfacePrefs = InterfacePreferences(app) + val appSettingsRepo = AppSettingsRepository( + interfacePrefs = interfacePrefs, + fiatPrefs = com.wisp.app.repo.FiatPreferences.get(app), + zapPrefs = zapPrefs + ).also { it.relayPool = relayPool } val nwcRepo = NwcRepository(app, relayPool, pubkeyHex) val sparkRepo = SparkRepository(app, pubkeyHex) val walletModeRepo = WalletModeRepository(app, pubkeyHex) @@ -329,7 +337,7 @@ class FeedViewModel(app: Application) : AndroidViewModel(app) { registerAuthSigner = { registerAuthSigner() }, fetchEmojiSets = { listCrud.fetchEmojiSets() }, getSigner = { signer } - ) + ).also { it.appSettingsRepo = appSettingsRepo } // -- Global online count from nostrarchives live-metrics -- private val _globalOnlineCount = MutableStateFlow(null) diff --git a/app/src/main/kotlin/com/wisp/app/viewmodel/StartupCoordinator.kt b/app/src/main/kotlin/com/wisp/app/viewmodel/StartupCoordinator.kt index 9fb8039a..45df522a 100644 --- a/app/src/main/kotlin/com/wisp/app/viewmodel/StartupCoordinator.kt +++ b/app/src/main/kotlin/com/wisp/app/viewmodel/StartupCoordinator.kt @@ -106,6 +106,9 @@ class StartupCoordinator( private val fetchEmojiSets: () -> Unit, private val getSigner: () -> NostrSigner? ) { + /** Optional NIP-78 sync repo — restored from relays once connected, if non-null. */ + var appSettingsRepo: com.wisp.app.repo.AppSettingsRepository? = null + private var eventProcessingJob: Job? = null private var metadataSweepJob: Job? = null private var ephemeralCleanupJob: Job? = null @@ -398,6 +401,9 @@ class StartupCoordinator( relayPool.awaitAnyConnected(minCount = minOf(3, relayCount), timeoutMs = 5_000) subscribeSelfData() awaitEmojiListThenFetchSets() + // Restore NIP-78 app-settings backup (non-destructive — missing + // remote fields keep local defaults). Best-effort, fire-and-forget. + launch { runCatching { appSettingsRepo?.restoreSettingsBackup() } } // Show profile if we didn't have it cached but now have it from self-data if (cachedProfile == null) { @@ -452,6 +458,7 @@ class StartupCoordinator( if (pk != null) subscribeDmsAndNotifications(pk) } awaitEmojiListThenFetchSets() + runCatching { appSettingsRepo?.restoreSettingsBackup() } } follows = cachedFollows.map { it.pubkey } From 9ec4fea0d114c4c357ca6e59f6c6fa86e93255ad Mon Sep 17 00:00:00 2001 From: The Daniel Date: Thu, 21 May 2026 00:07:28 -0400 Subject: [PATCH 02/16] feat(zaps): instant-zap settings + payload fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports iOS commit #2 from feat/one-tap-zap. Adds the four AppSettings properties iOS persists, plumbed through the NIP-78 payload introduced in the previous commit: • quickZapEnabled (Bool, default false) — master toggle • quickZapAmountSats (Long, default 100, hard-clamped to 1..10000) • quickZapAmountFiat (Double, default 0.10) • quickZapMessage (String, default "") — optional default note Hard cap at 10,000 sats so an instant zap never bypasses the soft 10K-sats confirmation dialog in the ZapSheet (task #4). Fiat clamp happens at fire time against the cached exchange rate, not in the setter — the live rate fluctuates and we don't want a stale rate to silently re-clamp the saved amount. Interface settings — new section titled "Zaps" (or "Payments" in fiat mode, label flips the same way iOS does). Toggle row + gated amount + message fields. Amount field shows fiat or sats based on fiat mode; sats variant has a "Max 10000" supporting caption. Wiring: • InterfacePreferences — 4 setters fire the same sync callback as the other synced fields, so any edit lands on relays within the 4s debounce window. • AppSettingsRepository.buildPayload — emits all 4 quick-zap fields. AppSettingsRepository.applyPayload — restores them non-destructively from a remote backup. • InterfaceScreen — new "Zaps" / "Payments" section between Fiat Mode and the Zap Icon toggle. No UI wiring yet for the long-press behavior or the in-sheet toggle — those land in tasks #4 (ZapSheet redesign) and #5 (post-card gesture). --- .../wisp/app/repo/AppSettingsRepository.kt | 8 ++ .../com/wisp/app/repo/InterfacePreferences.kt | 44 +++++++- .../com/wisp/app/ui/screen/InterfaceScreen.kt | 104 ++++++++++++++++++ 3 files changed, 152 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/com/wisp/app/repo/AppSettingsRepository.kt b/app/src/main/kotlin/com/wisp/app/repo/AppSettingsRepository.kt index df7e0457..f7167006 100644 --- a/app/src/main/kotlin/com/wisp/app/repo/AppSettingsRepository.kt +++ b/app/src/main/kotlin/com/wisp/app/repo/AppSettingsRepository.kt @@ -96,6 +96,10 @@ class AppSettingsRepository( fiatModeEnabled = fiatPrefs.isFiatMode(), fiatCurrency = fiatPrefs.getCurrency(), zapPresetsCSV = zapPrefs.toCSV(), + quickZapEnabled = interfacePrefs.isQuickZapEnabled(), + quickZapAmountSats = interfacePrefs.getQuickZapAmountSats(), + quickZapAmountFiat = interfacePrefs.getQuickZapAmountFiat(), + quickZapMessage = interfacePrefs.getQuickZapMessage(), version = 1 ) } @@ -196,6 +200,10 @@ class AppSettingsRepository( p.fiatModeEnabled?.let { fiatPrefs.setFiatMode(it) } p.fiatCurrency?.let { fiatPrefs.setCurrency(it) } p.zapPresetsCSV?.let { zapPrefs.applyCSV(it) } + p.quickZapEnabled?.let { interfacePrefs.setQuickZapEnabled(it) } + p.quickZapAmountSats?.let { interfacePrefs.setQuickZapAmountSats(it) } + p.quickZapAmountFiat?.let { interfacePrefs.setQuickZapAmountFiat(it) } + p.quickZapMessage?.let { interfacePrefs.setQuickZapMessage(it) } } finally { interfacePrefs.onSyncedFieldChanged = iface fiatPrefs.onSyncedFieldChanged = fiat 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 bce9a4fd..169ea843 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) /** @@ -117,6 +113,46 @@ class InterfacePreferences(context: Context) { fun isSyncSettingsToRelays(): Boolean = prefs.getBoolean("sync_settings_to_relays", true) fun setSyncSettingsToRelays(enabled: Boolean) = prefs.edit().putBoolean("sync_settings_to_relays", 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() + fireSync() + } + + 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() + fireSync() + } + + 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() + fireSync() + } + + fun getQuickZapMessage(): String = prefs.getString("quick_zap_message", "") ?: "" + fun setQuickZapMessage(message: String) { + prefs.edit().putString("quick_zap_message", message).apply() + fireSync() + } + + 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 cb60da43..63eeb0ca 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 @@ -788,6 +791,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 bbee9c8ef89d5fc895459e0506249087d1516899 Mon Sep 17 00:00:00 2001 From: The Daniel Date: Thu, 21 May 2026 00:09:54 -0400 Subject: [PATCH 03/16] feat(wallet-setup): primary Spark + More-options accordion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports iOS commit #3 from feat/one-tap-zap. Two-screen reshape that makes the obvious next step visually obvious. Mode picker (Screen 1): • Spark row is now a full-bleed orange card with white text + icon, layered shadow glow (two stacked shadows in `WispThemeColors.zapColor` — wide outer + tighter inner — for the iOS bloom). • NWC keeps its dark surface-variant peer treatment. Not buried under "More options" per spec — it's a genuine alternative. Spark sub-screen (Screen 2): • "Use my default wallet" gets the same primary treatment (orange fill + glow stack) so the nsec-derived recovery path reads as the obvious first choice. • Create new / Restore from seed phrase / Restore from relays collapse under a "More options" disclosure with a chevron that rotates 0→180° via animateFloatAsState. Expand defaults to open if there's no default-wallet option visible (so users who can't derive still see all paths immediately). • AnimatedVisibility wraps the inner Column for the expand/collapse transition (expandVertically + fadeIn / shrinkVertically + fadeOut), matching the iOS "fade rows in/out" cue. Extracted as `WalletPrimaryRow`, shared by both screens. Vertical-centering of the pick stack (iOS spec calls out a GeometryReader-backed ScrollView) isn't implemented here — the existing Spark sub-screen already uses weighted spacers within a non-scrolling Column, which centers naturally when content fits the viewport; smaller phones fall back to scroll via the parent's verticalScroll. --- .../com/wisp/app/ui/screen/WalletScreen.kt | 174 +++++++++++++++--- 1 file changed, 148 insertions(+), 26 deletions(-) diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt index 4a7fe06a..308d47d0 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt @@ -116,7 +116,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap @@ -2295,14 +2298,14 @@ private fun WalletModeSelectionContent( Spacer(Modifier.weight(2f)) - // Mode rows - WalletModeRow( + // Spark — primary, full-bleed orange with glowing shadow stack. + WalletPrimaryRow( leadingIcon = { Image( painter = painterResource(R.drawable.ic_spark_logo), contentDescription = null, modifier = Modifier.size(28.dp), - colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(accent) + colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(Color.White) ) }, title = stringResource(R.string.wallet_spark_title), @@ -2310,6 +2313,7 @@ private fun WalletModeSelectionContent( onClick = onSelectSpark ) Spacer(Modifier.height(12.dp)) + // NWC — peer-level dark surface row; not buried under "More options". WalletModeRow( leadingIcon = { Image( @@ -2326,6 +2330,74 @@ private fun WalletModeSelectionContent( } } +/** + * Primary mode-picker row — full-bleed accent fill, white text, layered + * shadow glow underneath. Used for the Spark wallet row on the mode + * picker AND the "Use my default wallet" row on the Spark sub-screen. + * + * Glow is two stacked shadows (tight 55% / wide 35%) both in + * `wispZapColor`, mirroring the iOS rendering. + */ +@Composable +private fun WalletPrimaryRow( + leadingIcon: @Composable () -> Unit, + title: String, + subtitle: String, + onClick: () -> Unit +) { + val accent = WispThemeColors.zapColor + val shape = RoundedCornerShape(14.dp) + Box( + modifier = Modifier + .fillMaxWidth() + // Wide outer halo, then tighter inner glow. Compose colored + // shadows clip at the path bounds, so the wider elevation + // bleeds farther than the tight one. + .shadow(elevation = 24.dp, shape = shape, spotColor = accent, ambientColor = accent) + .shadow(elevation = 10.dp, shape = shape, spotColor = accent, ambientColor = accent) + ) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + color = accent, + shape = shape + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(36.dp) + ) { leadingIcon() } + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = Color.White + ) + Spacer(Modifier.height(2.dp)) + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.85f) + ) + } + Spacer(Modifier.width(8.dp)) + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = Color.White.copy(alpha = 0.9f), + modifier = Modifier.size(20.dp) + ) + } + } + } +} + @Composable private fun WalletModeRow( leadingIcon: @Composable () -> Unit, @@ -2503,36 +2575,86 @@ private fun SparkSetupContent( Spacer(Modifier.height(28.dp)) - // Option rows + // Primary "Use my default wallet" gets the same orange + glow + // treatment as the Spark row on the mode picker. Other options + // collapse under a "More options" disclosure so the obvious + // next step (re-derive the user's existing wallet) reads as + // the obvious choice. if (canUseDefaultWallet) { - SparkOptionRow( - icon = Icons.Outlined.VpnKey, + WalletPrimaryRow( + leadingIcon = { + Icon( + Icons.Outlined.VpnKey, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(28.dp) + ) + }, title = stringResource(R.string.wallet_use_default), subtitle = stringResource(R.string.wallet_default_subtitle), onClick = onUseDefaultWallet ) - Spacer(Modifier.height(12.dp)) + Spacer(Modifier.height(20.dp)) } - SparkOptionRow( - icon = Icons.Outlined.Add, - title = stringResource(R.string.wallet_create_title), - subtitle = stringResource(R.string.wallet_create_subtitle), - onClick = onCreateWallet - ) - Spacer(Modifier.height(12.dp)) - SparkOptionRow( - icon = Icons.Outlined.History, - title = stringResource(R.string.wallet_restore_seed_title), - subtitle = stringResource(R.string.wallet_restore_seed_subtitle), - onClick = onRestoreFromSeed - ) - Spacer(Modifier.height(12.dp)) - SparkOptionRow( - icon = Icons.Outlined.CloudDownload, - title = stringResource(R.string.wallet_restore_relays_title), - subtitle = stringResource(R.string.wallet_restore_relays_subtitle), - onClick = onRestoreFromRelay + + // More-options accordion. + var moreOptionsExpanded by remember { mutableStateOf(!canUseDefaultWallet) } + val chevronRotation by animateFloatAsState( + targetValue = if (moreOptionsExpanded) 180f else 0f, + label = "more-options-chevron" ) + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { moreOptionsExpanded = !moreOptionsExpanded } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "More options", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + Icon( + Icons.Filled.KeyboardArrowDown, + contentDescription = if (moreOptionsExpanded) "Collapse" else "Expand", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .size(20.dp) + .graphicsLayer { rotationZ = chevronRotation } + ) + } + AnimatedVisibility( + visible = moreOptionsExpanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Column { + Spacer(Modifier.height(4.dp)) + SparkOptionRow( + icon = Icons.Outlined.Add, + title = stringResource(R.string.wallet_create_title), + subtitle = stringResource(R.string.wallet_create_subtitle), + onClick = onCreateWallet + ) + Spacer(Modifier.height(12.dp)) + SparkOptionRow( + icon = Icons.Outlined.History, + title = stringResource(R.string.wallet_restore_seed_title), + subtitle = stringResource(R.string.wallet_restore_seed_subtitle), + onClick = onRestoreFromSeed + ) + Spacer(Modifier.height(12.dp)) + SparkOptionRow( + icon = Icons.Outlined.CloudDownload, + title = stringResource(R.string.wallet_restore_relays_title), + subtitle = stringResource(R.string.wallet_restore_relays_subtitle), + onClick = onRestoreFromRelay + ) + } + } if (walletState is WalletState.Error) { Spacer(Modifier.height(16.dp)) From 2b482fb61de21f15ea260f23e8011e4955d6b205 Mon Sep 17 00:00:00 2001 From: The Daniel Date: Thu, 21 May 2026 00:13:42 -0400 Subject: [PATCH 04/16] 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 754d24f1bbccfee3e808111663f51a3bbcc3e656 Mon Sep 17 00:00:00 2001 From: The Daniel Date: Thu, 21 May 2026 00:17:05 -0400 Subject: [PATCH 05/16] 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 22a68f35..4f5ecb94 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, @@ -201,12 +202,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 abc708be..8995c00e 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, @@ -818,6 +819,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, @@ -831,7 +835,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 38ef5264b90f4817c9e911a273c44d5af13fe454 Mon Sep 17 00:00:00 2001 From: The Daniel Date: Thu, 21 May 2026 00:19:55 -0400 Subject: [PATCH 06/16] feat(bolt): white-core glow pulse for in-flight zap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports iOS commit #6 from feat/one-tap-zap. Replaces the multi-layer Canvas bolt animation that was smearing the silhouette at scale peaks. New approach: always-white silhouette + three stacked zap-color shadows underneath, driven by a single sin-eased oscillator. Math (period 0.9s): sine ∈ [-1, 1] phase ∈ [0, 1] = (sine + 1) / 2 iconScale = 1.0 + 0.10 * sine (0.90 → 1.10) verticalOffset = -0.5 * sine (±0.5dp centered on baseline) Shadow layers (Canvas strokes — drawn outer → inner so the white core sits on top): outer — radius 8 + 6*phase dp, α = 0.30 + 0.50*phase medium — radius 4 + 3*phase dp, α = 0.55 + 0.45*phase inner — radius 1.5dp constant, α = 0.95 core — solid white silhouette, untinted Vertical motion held to ±0.5dp so the icon doesn't lift off the action-bar baseline and misalign with neighbouring glyphs. The white IS the luminous core; the warm halos do the heat work. LinearEasing on the sineAngle (not FastOutSlowInEasing) — the sine function itself supplies the easing curve. Wrapping with another easing would double-stack and visibly stutter. --- .../com/wisp/app/ui/component/ActionBar.kt | 87 +++++++++++-------- 1 file changed, 52 insertions(+), 35 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 4f5ecb94..f9065c2a 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 @@ -57,8 +57,10 @@ import com.wisp.app.ui.util.AmountFormatter import androidx.compose.runtime.collectAsState import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties +import androidx.compose.ui.graphics.drawscope.translate import coil3.compose.AsyncImage import com.wisp.app.nostr.Nip30 +import androidx.compose.ui.platform.LocalDensity import kotlin.math.sin @OptIn(ExperimentalFoundationApi::class) @@ -302,50 +304,65 @@ fun ActionBar( @Composable internal fun LightningAnimation(modifier: Modifier = Modifier) { - val transition = rememberInfiniteTransition(label = "lightning") + // White-core glow pulse — matches iOS commit #6 of feat/one-tap-zap. + // Single sin-eased oscillator on a 0.9s period drives: + // sine ∈ [-1, 1] + // phase ∈ [ 0, 1] = (sine + 1) / 2 + // iconScale = 1.0 + 0.10 * sine (0.90 → 1.10) + // verticalOffset = -0.5 * sine (±0.5dp, centered on baseline) + // The silhouette stays solid white — the three stacked stroked + // shadows behind it do the warm-glow work (inner constant, medium + // and outer breathing on the phase). Vertical motion is held to + // ±0.5dp so the icon doesn't lift off the action-bar baseline and + // misalign with neighbouring glyphs. + val zapColor = WispThemeColors.zapColor + val density = LocalDensity.current + val transition = rememberInfiniteTransition(label = "bolt-pulse") - val pulse by transition.animateFloat( - initialValue = 0.5f, - targetValue = 1f, + val sineAngle by transition.animateFloat( + initialValue = 0f, + targetValue = (2.0 * Math.PI).toFloat(), animationSpec = infiniteRepeatable( - animation = tween(600, easing = androidx.compose.animation.core.FastOutSlowInEasing), - repeatMode = RepeatMode.Reverse + animation = tween(durationMillis = 900, easing = LinearEasing) ), - label = "pulse" + label = "bolt-sine" ) - val scale by transition.animateFloat( - initialValue = 0.92f, - targetValue = 1.08f, - animationSpec = infiniteRepeatable( - animation = tween(600, easing = androidx.compose.animation.core.FastOutSlowInEasing), - repeatMode = RepeatMode.Reverse - ), - label = "scale" - ) + val s = sin(sineAngle.toDouble()).toFloat() + val phase = (s + 1f) / 2f + val iconScale = 1.0f + 0.10f * s - val zapColor = WispThemeColors.zapColor + val verticalOffsetPx = with(density) { (-0.5f * s).dp.toPx() } + val innerStrokePx = with(density) { 1.5.dp.toPx() } + val medStrokePx = with(density) { (4f + 3f * phase).dp.toPx() } + val outerStrokePx = with(density) { (8f + 6f * phase).dp.toPx() } Canvas(modifier = modifier) { - val w = size.width - val h = size.height - val boltPath = icBoltPath(w, h, scale) - - // Soft outer glow - drawPath( - path = boltPath, - color = zapColor.copy(alpha = pulse * 0.3f), - style = Stroke(width = w * 0.14f, cap = StrokeCap.Round, join = StrokeJoin.Round) - ) + translate(top = verticalOffsetPx) { + val boltPath = icBoltPath(size.width, size.height, iconScale) - // Bolt fill - drawPath(path = boltPath, color = zapColor) - - // White-hot core - drawPath( - path = boltPath, - color = Color.White.copy(alpha = pulse * 0.4f) - ) + // Outer halo — widest, lowest opacity, breathing with phase. + drawPath( + path = boltPath, + color = zapColor.copy(alpha = 0.3f + 0.5f * phase), + style = Stroke(width = outerStrokePx, cap = StrokeCap.Round, join = StrokeJoin.Round) + ) + // Medium glow. + drawPath( + path = boltPath, + color = zapColor.copy(alpha = 0.55f + 0.45f * phase), + style = Stroke(width = medStrokePx, cap = StrokeCap.Round, join = StrokeJoin.Round) + ) + // Inner — tight, constant 95%. + drawPath( + path = boltPath, + color = zapColor.copy(alpha = 0.95f), + style = Stroke(width = innerStrokePx, cap = StrokeCap.Round, join = StrokeJoin.Round) + ) + // White-core silhouette on top. Don't tint — the white IS + // the luminous core; the warm halos do the heat work. + drawPath(path = boltPath, color = Color.White) + } } } From e591e9161e8aa32986fa396e2b8e97120f5c8821 Mon Sep 17 00:00:00 2001 From: The Daniel Date: Thu, 21 May 2026 00:24:14 -0400 Subject: [PATCH 07/16] feat(dev): DEBUG-only developer panel scaffold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports iOS commit #7 from feat/one-tap-zap. Empty placeholder so future throwaway experiments have somewhere to land instead of sprouting one-off entry points across production code. • New `Routes.DEVELOPER_TOOLS` route. • New `DeveloperToolsScreen` composable — top-app-bar with a back arrow and a single muted "No tools yet" caption. Drop test buttons / log dumps / force-state toggles directly into the Box. • `InterfaceScreen` gains an optional `onOpenDeveloperTools` callback. When `BuildConfig.DEBUG == true` AND the callback is non-null, a "Developer" section + "Developer tools" row renders at the bottom of the settings list (above the Wisp version line). Release builds skip the section entirely — the wrapping `if (BuildConfig.DEBUG)` gate compiles out in R8. Nothing inside the screen yet. That's the point. --- .../main/kotlin/com/wisp/app/Navigation.kt | 10 ++- .../app/ui/screen/DeveloperToolsScreen.kt | 63 +++++++++++++++++++ .../com/wisp/app/ui/screen/InterfaceScreen.kt | 35 ++++++++++- 3 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 app/src/main/kotlin/com/wisp/app/ui/screen/DeveloperToolsScreen.kt diff --git a/app/src/main/kotlin/com/wisp/app/Navigation.kt b/app/src/main/kotlin/com/wisp/app/Navigation.kt index 34ab52f8..40a8bccf 100644 --- a/app/src/main/kotlin/com/wisp/app/Navigation.kt +++ b/app/src/main/kotlin/com/wisp/app/Navigation.kt @@ -56,6 +56,7 @@ import com.wisp.app.ui.screen.BlossomServersScreen import com.wisp.app.ui.screen.AuthScreen import com.wisp.app.ui.screen.SplashScreen import com.wisp.app.ui.screen.ComposeScreen +import com.wisp.app.ui.screen.DeveloperToolsScreen import com.wisp.app.ui.screen.ContactPickerScreen import com.wisp.app.ui.screen.DmConversationScreen import com.wisp.app.ui.screen.DmListScreen @@ -172,6 +173,7 @@ object Routes { const val SOCIAL_GRAPH = "social_graph" const val POW_SETTINGS = "pow_settings" const val INTERFACE_SETTINGS = "interface_settings" + const val DEVELOPER_TOOLS = "developer_tools" const val RELAY_HEALTH = "relay_health" const val ARTICLE = "article/{kind}/{author}/{dTag}" const val LIVE_STREAM = "live_stream/{hostPubkey}/{dTag}?relayHint={relayHint}" @@ -2699,10 +2701,16 @@ fun WispNavHost( interfacePrefs = interfacePrefs, onBack = { navController.popBackStack() }, onChanged = onInterfaceChanged, - onSyncRequested = { feedViewModel.appSettingsRepo.scheduleSettingsSync() } + onSyncRequested = { feedViewModel.appSettingsRepo.scheduleSettingsSync() }, + onOpenDeveloperTools = { navController.navigate(Routes.DEVELOPER_TOOLS) } ) } + // Developer tools — debug-only entry point, hidden in release builds. + composable(Routes.DEVELOPER_TOOLS) { + DeveloperToolsScreen(onBack = { navController.popBackStack() }) + } + composable(Routes.CUSTOM_EMOJIS) { val emojiUploadScope = androidx.compose.runtime.rememberCoroutineScope() CustomEmojiScreen( diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/DeveloperToolsScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/DeveloperToolsScreen.kt new file mode 100644 index 00000000..f90b5b24 --- /dev/null +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/DeveloperToolsScreen.kt @@ -0,0 +1,63 @@ +package com.wisp.app.ui.screen + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +/** + * Empty scaffold for throwaway debug experiments. + * + * Wired only when `BuildConfig.DEBUG == true` — the "Developer" row + * in `InterfaceScreen` is hidden in release builds. The point isn't + * what's here today (nothing); it's that future one-off probes have + * a home so they don't grow ad-hoc entry points scattered through + * production code. + * + * Drop test buttons, log dumps, force-state toggles, etc. straight + * into the Box below. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DeveloperToolsScreen(onBack: () -> Unit) { + Scaffold( + topBar = { + TopAppBar( + title = { Text("Developer tools") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center + ) { + Text( + "No tools yet — drop debug experiments here.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} 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 63eeb0ca..b6fa963f 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 @@ -4,6 +4,7 @@ import android.app.Activity import android.app.Application import androidx.compose.foundation.Canvas import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material3.OutlinedTextField import androidx.compose.ui.text.input.KeyboardType import androidx.compose.foundation.background @@ -96,7 +97,8 @@ fun InterfaceScreen( interfacePrefs: InterfacePreferences, onBack: () -> Unit, onChanged: () -> Unit, - onSyncRequested: (() -> Unit)? = null + onSyncRequested: (() -> Unit)? = null, + onOpenDeveloperTools: (() -> Unit)? = null ) { var isLargeText by remember { mutableStateOf(interfacePrefs.isLargeText()) } var newNotesHidden by remember { mutableStateOf(interfacePrefs.isNewNotesButtonHidden()) } @@ -933,6 +935,37 @@ fun InterfaceScreen( Spacer(Modifier.height(32.dp)) + // Developer row — hidden in release. Empty placeholder so + // future throwaway experiments have a home instead of new + // one-off entry points. + if (com.wisp.app.BuildConfig.DEBUG && onOpenDeveloperTools != null) { + Text( + "Developer", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onOpenDeveloperTools() } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "Developer tools", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f) + ) + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(Modifier.height(24.dp)) + } + // Version — long-press 5 times to reveal diagnostic mode val context = LocalContext.current val versionName = remember { From 7f52e0a37740173d35bae207f7c5e8beb027efb9 Mon Sep 17 00:00:00 2001 From: The Daniel Date: Thu, 21 May 2026 09:51:23 -0400 Subject: [PATCH 08/16] fix(wallet): show top 5 recent transactions on dashboard footer instead of 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard footer was capped at one transaction. iOS shows up to five rows inline before the "View all" affordance — matching that here. Tapping any row (or "View all") still expands to the full transactions screen. --- app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt index 308d47d0..906fed0d 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt @@ -1146,7 +1146,9 @@ private fun WalletHomeContent( HorizontalDivider( color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f) ) - recentTransactions.take(1).forEach { tx -> + // Match iOS — show the most-recent ~5 transactions inline, + // tap any row (or "View all") to expand to the full screen. + recentTransactions.take(5).forEach { tx -> TransactionRow(tx, profileLookup) } } From 9391dcf2d69db0bf8e312a13a8c6a69b9b697b76 Mon Sep 17 00:00:00 2001 From: The Daniel Date: Thu, 21 May 2026 09:51:23 -0400 Subject: [PATCH 09/16] 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 40a8bccf..375addac 100644 --- a/app/src/main/kotlin/com/wisp/app/Navigation.kt +++ b/app/src/main/kotlin/com/wisp/app/Navigation.kt @@ -1643,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( @@ -1920,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() 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 bd7ccefc..ad624666 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 @@ -544,7 +544,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 263169b60472555371453422482516fbb2061ab5 Mon Sep 17 00:00:00 2001 From: The Daniel Date: Thu, 21 May 2026 17:38:22 -0400 Subject: [PATCH 10/16] 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 375addac..a77538a8 100644 --- a/app/src/main/kotlin/com/wisp/app/Navigation.kt +++ b/app/src/main/kotlin/com/wisp/app/Navigation.kt @@ -1273,7 +1273,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( @@ -2094,7 +2096,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) } ) } @@ -2246,7 +2250,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) } ) } @@ -2411,7 +2417,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) } ) } @@ -2618,7 +2626,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) { @@ -3188,7 +3201,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) } ) } @@ -3206,7 +3221,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 5c02d207..977f000d 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 ad624666..e2aa1c54 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 @@ -574,7 +574,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 0641de9d..934f223b 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 3ab5c8f7eec1a0e49c7cda26827d4c9b5ff22574 Mon Sep 17 00:00:00 2001 From: The Daniel Date: Thu, 21 May 2026 17:51:33 -0400 Subject: [PATCH 11/16] fix(nip78): cross-coroutine-scope event drop + ARGB Int overflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs preventing the NIP-78 app-settings backup from restoring from a relay-side payload published by iOS: 1. **Collector dropped every relay reply.** restoreSettingsBackup was launching its `relayEvents` + `eoseSignals` collectors on the repo's own `Dispatchers.Default` scope, then calling `yield()` on the suspending function before firing the REQ. The yield only dispatches the calling coroutine — the collectors on the separate scope had no guarantee of being subscribed yet, so the first batch of relay replies (which arrive ~3-4s later, way after the collector "should" be live) reached a `MutableSharedFlow(extraBufferCapacity = 4096, replay = 0)` with no subscribers and got dropped on the floor. Result: events = 0, "no matching d-tag event found", even though SUBLOG showed 1-2 events arriving per relay. Wrap the body in `coroutineScope { ... }` so the collector launches are children of the calling coroutine. yield now actually dispatches them before sendToAll fires the REQ. iOS payloads start arriving correctly. 2. **`accentColorARGB` overflowed Kotlin Int.** iOS encodes the ARGB color as Swift `Int` (64-bit), so `0xFFFF9800` (4_294_940_672 unsigned) ships as a JSON number that kotlinx.serialization can't deserialize into the Kotlin `Int?` we declared — `Int.MAX_VALUE` is 2_147_483_647. Decryption succeeded, JSON parsing died. Declare the field as `Long?`; in `applyPayload`, narrow with `it.toInt()` (the lower 32 bits round-trip correctly even when Int reads the value as negative). When building the payload to publish, widen with `getAccentColor().toLong() and 0xFFFFFFFFL` so iOS reads it as a non-negative number on the round-trip. Diagnostic logging on the restore path stays in — useful next time something goes sideways. Bumped the silent swallow in `Nip78.decryptAppSettings` to a Log.w so future serialization-class mismatches surface immediately. --- .../main/kotlin/com/wisp/app/nostr/Nip78.kt | 9 +++- .../wisp/app/repo/AppSettingsRepository.kt | 47 +++++++++++++++---- 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/app/src/main/kotlin/com/wisp/app/nostr/Nip78.kt b/app/src/main/kotlin/com/wisp/app/nostr/Nip78.kt index 491ccef2..80f0e950 100644 --- a/app/src/main/kotlin/com/wisp/app/nostr/Nip78.kt +++ b/app/src/main/kotlin/com/wisp/app/nostr/Nip78.kt @@ -145,7 +145,11 @@ object Nip78 { val zapIconStyle: String? = null, val largeText: Boolean? = null, val themeName: String? = null, - val accentColorARGB: Int? = null, + // iOS encodes ARGB as Swift `Int` (64-bit), so values like + // 0xFFFF9800 (4_294_940_672) overflow Kotlin's signed 32-bit + // Int. Use Long here and convert at the setter — the lower 32 + // bits round-trip correctly even when Int reads them negative. + val accentColorARGB: Long? = null, val autoLoadMedia: Boolean? = null, val videoAutoplay: Boolean? = null, val mediaLayoutStyle: String? = null, @@ -189,7 +193,8 @@ object Nip78 { return try { val decrypted = signer.nip44Decrypt(event.content, event.pubkey) lenientJson.decodeFromString(AppSettingsPayload.serializer(), decrypted) - } catch (_: Exception) { + } catch (e: Exception) { + android.util.Log.w("AppSettingsSync", "decryptAppSettings exception: ${e.javaClass.simpleName}: ${e.message}", e) null } } diff --git a/app/src/main/kotlin/com/wisp/app/repo/AppSettingsRepository.kt b/app/src/main/kotlin/com/wisp/app/repo/AppSettingsRepository.kt index f7167006..8c6c6769 100644 --- a/app/src/main/kotlin/com/wisp/app/repo/AppSettingsRepository.kt +++ b/app/src/main/kotlin/com/wisp/app/repo/AppSettingsRepository.kt @@ -85,7 +85,9 @@ class AppSettingsRepository( zapIconStyle = if (interfacePrefs.isZapBoltIcon()) "bolt" else "default", largeText = interfacePrefs.isLargeText(), themeName = interfacePrefs.getTheme(), - accentColorARGB = interfacePrefs.getAccentColor(), + // Local stores ARGB as signed 32-bit Int; convert to unsigned + // Long so iOS reads it as a non-negative value. + accentColorARGB = interfacePrefs.getAccentColor().toLong() and 0xFFFFFFFFL, autoLoadMedia = interfacePrefs.isAutoLoadMedia(), videoAutoplay = interfacePrefs.isVideoAutoPlay(), mediaLayoutStyle = mediaLayout, @@ -119,10 +121,13 @@ class AppSettingsRepository( * the local default — adding a new field on iOS won't wipe its value * on Android (and vice-versa). */ - suspend fun restoreSettingsBackup() { - val s = signer ?: return - val pool = relayPool ?: return - if (!interfacePrefs.isSyncSettingsToRelays()) return + suspend fun restoreSettingsBackup() = kotlinx.coroutines.coroutineScope { + val s = signer ?: run { Log.d(TAG, "restore skipped: no signer"); return@coroutineScope } + val pool = relayPool ?: run { Log.d(TAG, "restore skipped: no relay pool"); return@coroutineScope } + if (!interfacePrefs.isSyncSettingsToRelays()) { + Log.d(TAG, "restore skipped: sync toggle off") + return@coroutineScope + } try { pool.ensureWriteRelaysConnected() @@ -135,12 +140,21 @@ class AppSettingsRepository( val events = mutableListOf() var eoseCount = 0 - val collectJob = scope.launch { + // Launch the collectors on the CALLING coroutine's scope (via + // coroutineScope above) — not the repo's own SupervisorJob/ + // Dispatchers.Default scope. That separation breaks the + // `yield()` handshake below: with the collectors on a separate + // scope, yield() doesn't actually dispatch them before sendToAll + // fires the REQ, so the first batch of replies (which arrive + // ~3-4s later, well after the collector "should" be live) goes + // to a SharedFlow with no subscribers and is dropped on the + // floor. + val collectJob = launch { pool.relayEvents.collect { re: RelayEvent -> if (re.subscriptionId == subId && seen.add(re.event.id)) events.add(re.event) } } - val eoseJob = scope.launch { + val eoseJob = launch { pool.eoseSignals.collect { id -> if (id == subId) eoseCount++ } @@ -148,6 +162,7 @@ class AppSettingsRepository( yield() val total = pool.getRelayUrls().size val minEose = (total * 2 + 2) / 3 + Log.d(TAG, "restore: REQ $subId to $total relays for d=${Nip78.APP_SETTINGS_D_TAG} author=${pubkey.take(8)}") pool.sendToAll(ClientMessage.req(subId, filter)) withTimeoutOrNull(8_000) { while (eoseCount < total) { @@ -158,13 +173,25 @@ class AppSettingsRepository( collectJob.cancel() eoseJob.cancel() pool.closeOnAllRelays(subId) + Log.d(TAG, "restore: EOSE $eoseCount/$total events=${events.size}") val newest = events .filter { Nip78.extractDTag(it) == Nip78.APP_SETTINGS_D_TAG } - .maxByOrNull { it.created_at } ?: return + .maxByOrNull { it.created_at } + if (newest == null) { + Log.d(TAG, "restore: no matching d-tag event found") + return@coroutineScope + } + Log.d(TAG, "restore: newest event id=${newest.id.take(8)} created_at=${newest.created_at}") - val payload = Nip78.decryptAppSettings(s, newest) ?: return + val payload = Nip78.decryptAppSettings(s, newest) + if (payload == null) { + Log.w(TAG, "restore: decrypt FAILED for event id=${newest.id.take(8)}") + return@coroutineScope + } + Log.d(TAG, "restore: decrypted payload — zapPresetsCSV=${payload.zapPresetsCSV?.take(60)} quickZap=${payload.quickZapEnabled}/${payload.quickZapAmountSats}") applyPayload(payload) + Log.d(TAG, "restore: applied — current presets=${zapPrefs.toCSV().take(80)}") } /** @@ -187,7 +214,7 @@ class AppSettingsRepository( p.zapIconStyle?.let { interfacePrefs.setZapBoltIcon(it == "bolt") } p.largeText?.let { interfacePrefs.setLargeText(it) } p.themeName?.let { interfacePrefs.setTheme(it) } - p.accentColorARGB?.let { interfacePrefs.setAccentColor(it) } + p.accentColorARGB?.let { interfacePrefs.setAccentColor(it.toInt()) } p.autoLoadMedia?.let { interfacePrefs.setAutoLoadMedia(it) } p.videoAutoplay?.let { interfacePrefs.setVideoAutoPlay(it) } p.mediaLayoutStyle?.let { From ad246aa2aa47a32edca50b125981ad554e2ac8b9 Mon Sep 17 00:00:00 2001 From: The Daniel Date: Thu, 21 May 2026 22:09:17 -0400 Subject: [PATCH 12/16] feat(nip78): full schema parity with iOS one-tap-zap PR Match iOS PR 159's AppSettingsPayload byte-for-byte so a kind-30078 backup round-trips across platforms without losing iOS-only fields. New AppSettingsPayload fields: defaultReaction, defaultReactionEnabled, quickReactions, frequency, colorScheme, animateAvatars. Fields with no Android UI yet are stored opaquely via InterfacePreferences setters so the next Android publish doesn't strip them out. CustomEmojiRepository now exposes onSyncedFieldChanged + an applyQuickReactions helper. add/remove/setUnicodeEmojis and recordEmojiUsage fire the sync callback so emoji-list mutations participate in the same 4s debounced publish as the other prefs sources. AppSettingsRepository takes customEmojiRepo as a constructor arg and wires the callback alongside interfacePrefs / fiatPrefs / zapPrefs. --- ANDROID_PORT_one_tap_zap.md | 277 ------------------ .../main/kotlin/com/wisp/app/nostr/Nip78.kt | 39 ++- .../wisp/app/repo/AppSettingsRepository.kt | 58 +++- .../wisp/app/repo/CustomEmojiRepository.kt | 39 +++ .../com/wisp/app/repo/InterfacePreferences.kt | 34 +++ .../com/wisp/app/viewmodel/FeedViewModel.kt | 3 +- 6 files changed, 145 insertions(+), 305 deletions(-) delete mode 100644 ANDROID_PORT_one_tap_zap.md diff --git a/ANDROID_PORT_one_tap_zap.md b/ANDROID_PORT_one_tap_zap.md deleted file mode 100644 index e4c60d2f..00000000 --- a/ANDROID_PORT_one_tap_zap.md +++ /dev/null @@ -1,277 +0,0 @@ -# Android port: `feat/one-tap-zap` - -A guide to port the iOS `feat/one-tap-zap` branch to Wisp Android, written -for a separate agent picking it up. Mirror the user-facing behavior and the -on-disk data formats; SwiftUI-specific implementation notes are flagged so -the Android side can pick the right Compose equivalent. - -## Scope - -Seven commits, grouped: - -1. **NIP-78 cross-device sync of UI prefs** — already may have an Android - equivalent or partial; verify against `feat/nip78-settings-sync` on - Android side. -2. **Instant zaps + fiat counterpart "Instant payments"** — new - `quickZap*` settings, payload fields, post-card tap behavior. -3. **Wallet setup primary Spark button + More options accordion** — two - screens redesigned for one-obvious-next-step. -4. **ZapSheet redesign** — full layout rewrite. -5. **Tap composer / long-press instant / disable self-zap** — gesture - swap on the post-card zap button. -6. **White-core glow pulse for in-flight bolt** — new pulse animation. -7. **DEBUG-only developer panel** — empty scaffold for future - throwaway experiments. - -## Settings — Quick zaps - -iOS adds four `AppSettings` properties, each persisted to UserDefaults -and round-tripped through the NIP-78 backup payload: - -| Key | Type | Default | Notes | -|---|---|---|---| -| `wisp_settings_quick_zap_enabled` | Bool | `false` | Master toggle | -| `wisp_settings_quick_zap_amount_sats` | Int64 | `100` | Used in non-fiat mode | -| `wisp_settings_quick_zap_amount_fiat` | Double | `0.10` | Major-unit (e.g. dollars), converted to sats via exchange-rate cache at fire time | -| `wisp_settings_quick_zap_message` | String | `""` | Optional default zap message | - -NIP-78 `AppSettingsPayload` carries all four: -`quickZapEnabled`, `quickZapAmountSats`, `quickZapAmountFiat`, -`quickZapMessage`. Mirror the JSON keys exactly so the iOS and Android -backups are bit-compatible. - -`InterfaceSettingsView` section "Zaps" / "Payments": -- Toggle: "Instant zaps" / "Instant payments" (label flips with fiat mode) -- Below the toggle (gated on `quickZapEnabled`): amount field, message - field, helper text explaining the tap behavior. -- **Amount clamping** — sats field clamps `min(10_000, max(1, value))`; - fiat field clamps to the 10K-sats equivalent via the cached rate. Hard - cap because instant zaps shouldn't bypass the soft confirmation. - -## Settings — NIP-78 sync - -Adds `wisp_settings_sync_settings_to_relays` (Bool, default `true`). -Toggle in the "Cross-device sync" section explains the publish. When on, -mutations to any synced field schedule a debounced (4 s) kind-30078 -publish via the same backup encryption used by quick-reactions. - -Synced fields (full payload): `zapIconStyle`, `fiatModeEnabled`, -`fiatCurrency`, `zapPresetsCSV`, `largeText`, `themeName`, `colorScheme`, -`accentColorARGB`, `autoLoadMedia`, `videoAutoplay`, `animateAvatars`, -`mediaLayoutStyle`, `clientTagEnabled`, `postUndoTimerEnabled`, -`postUndoTimerSeconds`, `postUndoTimerForReplies`, plus the four quick-zap -fields. Restore is non-destructive: each field has its own `if let` guard -so missing values stay at the local default. - -## Wallet setup screens - -### Mode picker - -Two rows: Spark (primary) and Nostr Wallet Connect (peer). - -* Spark is a full-bleed orange (`wispZapColor`) filled card with white - text + key icon, layered shadow glow (two stacked: tight 55% / wide 35% - outer, both in zap-color). -* NWC keeps its dark surface treatment. -* Don't bury NWC under "More options" — keep it visible. - -### Spark setup picker - -* "Use my default wallet" rendered with the same primary treatment - (filled orange, white text, glow shadow stack). -* Create new wallet / Restore from seed phrase / Restore from relays - collapse under a "More options" disclosure that rotates a chevron and - fades the rows in / out. -* Pick section is **vertically centered** in the available viewport - (iOS uses a GeometryReader-backed ScrollView with leading + trailing - Spacers stretching the inner column to `minHeight: geo.size.height`). - Android equivalent: `Box` filling parent with `verticalArrangement = - Center` once the column fits the viewport; fall back to scroll on - smaller phones. - -## ZapSheet redesign - -The whole composer was rewritten. Top-to-bottom layout, with the keyboard -auto-focused on the amount field and the sheet wrapped in a scrollable -container so dragging dismisses the keyboard naturally. - -### Layout (top → bottom) - -1. **Toolbar** — `Close` left, `Presets` right (orange tint). -2. **Recipient row** — compact `HStack`: - * 32pt avatar (left) - * Display name + lud16 (caption monospace, secondary color) - * Copy-icon button right (single button, **not** a menu; copying the - lud16 fires a local pill `"Lightning address copied"`). -3. **Hero amount** — 56pt rounded-bold zap-color number, optional - "sats" caption hidden in fiat mode. Tappable: tap → focus the - hidden amount field, seed `customAmountText` with the register-style - cents digits (fiat) or the integer sats string (non-fiat). -4. **Preset strip** — `FlowLayout` (wraps to new line, doesn't scroll - horizontally). Each pill renders the formatted sats amount; the - `Custom` pill carries an inline `+` badge for save-as-preset when - the current amount isn't yet in the list (badge disables at 8 max). -5. **Hidden amount TextField** — zero-size, anchored to the focus - state. Tap on hero / onAppear sets focus. -6. **Message field** — always-visible single-line. -7. **Privacy dropdown** — `Menu` with "Public / Anonymous / Private", - eye / eye-slash / lock icon. Helper caption appears for non-public - types. -8. **Instant zaps toggle** — bound directly to `quickZapEnabled` so - users can flip the setting from the sheet without navigating to - settings. -9. **Bottom bar** — full-width Zap button. Above it: "Max 1,000,000 - sats per zap" red caption when over the hard cap. - -### Behaviors - -**Auto-focus on appear, deferred 450 ms.** The keyboard rises after the -sheet's mount + transition completes — without the hop the keyboard -rising during mount changes the parent feed-row's layout, which -unmounts the `.sheet` and produces an open/close loop. Compose's -`SideEffect` after `LaunchedEffect(Unit) { delay(450) ; focusRequester.requestFocus() }` -should give the same window. - -**Amount seed.** On appear `amountSats` is set from -`quickZapAmountSats` (or fiat equivalent) — treat the configured -instant-zap amount as the user's "preferred opening amount" even when -quick zaps are disabled. - -**First-keystroke-replaces-seed.** Track a `hasTypedAmount` flag. -While false (the very first time the field becomes editable), an -empty value from the TextField binding is the platform's initial-focus -bind commit and is ignored — the seeded amount survives. Once true, -empty means the user backspaced and `amountSats` follows to 0, -disabling the Zap button. Tapping a preset resets the flag. - -**1,000,000 sats hard cap.** Zap button disables; red "Max …" caption -appears. - -**10,000 sats soft confirmation.** Zap button tap routes through a -confirmation dialog ("Zap N sats? — This is a large amount, double-check -before sending"). Below 10K fires immediately. - -**Per-user preset storage.** Key is `zapPresetAmounts_` (one -slot per signed-in account). On first read for a new account, migrate -the legacy global `zapPresetAmounts` value into the per-user slot. -NIP-78 backup pulls + restores from the active user's slot. - -**Preset format.** CSV of entries: `` or `:`. -The optional message is a default zap note that auto-fills the message -field when the preset is tapped (only when the message field is -currently empty, so it doesn't clobber typing). Commas and colons are -stripped from the saved message because they're the format delimiters. - -**EditPresetsSheet.** Two columns per row (Amount, Message), -drag-to-reorder, swipe-to-delete. "Add preset" button **disabled** when -any existing row has an empty amount (one blank row at a time). - -**Scroll + keyboard.** The whole sheet content is a `ScrollView` with -`.scrollDismissesKeyboard(.interactively)` so a drag-down dismiss -collapses the keyboard the moment the drag starts — fixes a "floating -elements" artifact where keyboard-avoidance fought the sheet drag. -Compose equivalent: `Modifier.nestedScroll` with a connection that -drops `LocalSoftwareKeyboardController.current` on first downward drag. - -**Self-zap disabled.** Compute `isOwnPost = (myPubkey == event.pubkey)` -(after `resolveRepost` so reposting your own note still disables but -reposting someone else's stays active). Zap button rendered at 35% -opacity, disabled, and the long-press gesture is also short-circuited. - -**Friendly error copy.** `ZapAnimationStore.friendlyMessage(for:)` -maps raw SDK error strings into plain copy. Mirror the same pattern -match on Android. Cases: - -| Substring (case-insensitive) | Replacement | -|---|---| -| `insufficient funds` / `insufficient balance` | "Not enough sats in your wallet." | -| `no route` / `route not found` / `unreachable` | "Couldn't find a payment route to the recipient. Try again later." | -| `expired` / `invoice has expired` | "The lightning invoice expired before it could be paid. Try again." | -| `timeout` / `timed out` | "The payment timed out. Check your connection and try again." | -| `no lud16` / `no lightning address` | "This account doesn't have a lightning address." | -| `lnurl` AND `400` | "The recipient's lightning provider rejected this zap. Try a different amount." | -| `amount too small` / `below minimum` | "Amount is below the recipient's minimum. Try a larger zap." | -| `amount too large` / `above maximum` | "Amount is above the recipient's maximum. Try a smaller zap." | - -Fallback: extract the substring between the first `("` and `")` (Swift -enum description wrapper) if present; otherwise pass through raw. - -## Post-card zap gesture - -* **Tap** — always opens the ZapSheet composer. -* **Long-press (400 ms)** — fires the configured instant-zap amount - *if* `quickZapEnabled` is on and a wallet is set up; otherwise falls - through to the composer so the gesture never feels like a no-op. - -Pin a `zapLongPressFired` flag so the tap handler short-circuits the -release after a long-press completes (otherwise SwiftUI fires both the -long-press handler AND the underlying button tap; Compose has the same -issue with `combinedClickable`). - -## In-flight bolt pulse - -Replaces the multi-layer Canvas bolt that smeared the silhouette at -scale peaks. New approach: **always-white silhouette** + three stacked -zap-color shadows behind it. - -Single sin-eased oscillator runs the whole animation: -``` -sine = sin(elapsed / 0.9 * 2π) // -1 … 1 -phase = (sine + 1) / 2 // 0 … 1 - -iconScale = 1.0 + 0.10 * sine // 0.90 → 1.10, centered -verticalOffset = -0.5 * sine // ±0.5pt, centered on baseline -``` - -Shadows (Compose: `drawBehind` with `drawCircle(blendMode = Plus)`-style -falloff, or stack three `Shape.shadow` modifiers): - -| Layer | Radius | Color opacity | -|---|---|---| -| inner | 1.5pt | `wispZapColor * 0.95`, constant | -| medium | `4 + 3 * phase` | `wispZapColor * (0.55 + 0.45 * phase)` | -| outer | `8 + 6 * phase` | `wispZapColor * (0.3 + 0.5 * phase)` | - -Vertical motion stays within ±0.5pt so the icon doesn't lift off the -action-bar baseline. Don't tint the icon itself — the white silhouette -is the luminous core, the shadows do the heat work. - -## Developer panel - -`DeveloperToolsView` is a `#if DEBUG` view presented from a new -"Developer" row in Interface settings (also wrapped in `#if DEBUG`). -Currently empty placeholder — Android equivalent is a `BuildConfig.DEBUG` -guarded row + activity / sheet so future throwaway experiments land -somewhere out of production code instead of building one-off entry -points each time. - -## Test plan - -- [ ] Wallet setup: Spark is primary glowing button, NWC remains - visible peer. Spark setup screen vertically centers content with - "Use my default wallet" primary + collapsible More options. -- [ ] Open zap sheet from a feed post: keyboard up, hero shows the - configured one-tap amount, first keystroke replaces it, backspace - to empty disables Zap. -- [ ] Tap a preset → amount + (if present) message auto-fill. -- [ ] Custom + `+` chip → adds the current amount to the user's - per-account preset row. -- [ ] EditPresetsSheet: Add preset disabled when a blank row exists; - Done writes per-account, drag-to-reorder works. -- [ ] Privacy dropdown cycles Public / Anonymous / Private. -- [ ] Instant zaps toggle on the sheet flips the same setting as - the Interface screen. -- [ ] Drag the sheet down → keyboard collapses, sheet body translates - as one unit (no floating items). -- [ ] > 10K sats → confirmation dialog. > 1M sats → Zap disabled + red - cap message. -- [ ] Insufficient funds → "Not enough sats in your wallet." -- [ ] Post-card zap: tap opens composer, long-press fires instant - (when configured), self-post button disabled at 35% opacity. -- [ ] In-flight bolt: white core with breathing warm glow, centered - bounce, stays aligned with neighbouring action-bar items. -- [ ] Sign in as Account A, set custom presets, switch to Account B — - B sees its own row (default first time). Switch back to A — A's - row intact. -- [ ] Settings → Interface (debug build) → Developer → Developer tools - opens (empty placeholder). diff --git a/app/src/main/kotlin/com/wisp/app/nostr/Nip78.kt b/app/src/main/kotlin/com/wisp/app/nostr/Nip78.kt index 80f0e950..82b237f9 100644 --- a/app/src/main/kotlin/com/wisp/app/nostr/Nip78.kt +++ b/app/src/main/kotlin/com/wisp/app/nostr/Nip78.kt @@ -141,33 +141,48 @@ object Nip78 { */ @kotlinx.serialization.Serializable data class AppSettingsPayload( - // Interface prefs + // Reactions (iOS-only UI today; Android round-trips defaultReaction + // + defaultReactionEnabled as opaque values so iOS settings survive + // an Android publish.) + val defaultReaction: String? = null, + val defaultReactionEnabled: Boolean? = null, + // Quick zaps + val quickZapEnabled: Boolean? = null, + val quickZapAmountSats: Long? = null, + val quickZapAmountFiat: Double? = null, + val quickZapMessage: String? = null, val zapIconStyle: String? = null, + // Fiat prefs + val fiatModeEnabled: Boolean? = null, + val fiatCurrency: String? = null, + // Zap presets + val zapPresetsCSV: String? = null, + // Reaction popup state (Android: unicodeEmojis + frequency map in + // CustomEmojiRepository; iOS: EmojiRepository). + val quickReactions: List? = null, + val frequency: Map? = null, + // Appearance val largeText: Boolean? = null, val themeName: String? = null, + // iOS-only light/dark override today; Android round-trips it. + val colorScheme: String? = null, // iOS encodes ARGB as Swift `Int` (64-bit), so values like // 0xFFFF9800 (4_294_940_672) overflow Kotlin's signed 32-bit // Int. Use Long here and convert at the setter — the lower 32 // bits round-trip correctly even when Int reads them negative. val accentColorARGB: Long? = null, + // Media val autoLoadMedia: Boolean? = null, val videoAutoplay: Boolean? = null, + // iOS-only toggle for animated avatars; Android always animates, + // so this is round-tripped opaquely. + val animateAvatars: Boolean? = null, val mediaLayoutStyle: String? = null, + // Posting val clientTagEnabled: Boolean? = null, val postUndoTimerEnabled: Boolean? = null, val postUndoTimerSeconds: Int? = null, val postUndoTimerForReplies: Boolean? = null, - // Fiat prefs - val fiatModeEnabled: Boolean? = null, - val fiatCurrency: String? = null, - // Zap prefs - val zapPresetsCSV: String? = null, - // Quick-zap prefs (task #2 — fields included now so the schema is - // forward-compatible with iOS backups that already carry them). - val quickZapEnabled: Boolean? = null, - val quickZapAmountSats: Long? = null, - val quickZapAmountFiat: Double? = null, - val quickZapMessage: String? = null, val version: Int? = 1 ) diff --git a/app/src/main/kotlin/com/wisp/app/repo/AppSettingsRepository.kt b/app/src/main/kotlin/com/wisp/app/repo/AppSettingsRepository.kt index 8c6c6769..69e90142 100644 --- a/app/src/main/kotlin/com/wisp/app/repo/AppSettingsRepository.kt +++ b/app/src/main/kotlin/com/wisp/app/repo/AppSettingsRepository.kt @@ -33,7 +33,8 @@ import kotlinx.coroutines.yield class AppSettingsRepository( private val interfacePrefs: InterfacePreferences, private val fiatPrefs: FiatPreferences, - private val zapPrefs: ZapPreferences + private val zapPrefs: ZapPreferences, + private val customEmojiRepo: CustomEmojiRepository ) { private val TAG = "AppSettingsSync" @@ -55,6 +56,7 @@ class AppSettingsRepository( interfacePrefs.onSyncedFieldChanged = { scheduleSettingsSync() } fiatPrefs.onSyncedFieldChanged = { scheduleSettingsSync() } zapPrefs.onSyncedFieldChanged = { scheduleSettingsSync() } + customEmojiRepo.onSyncedFieldChanged = { scheduleSettingsSync() } } /** @@ -81,27 +83,37 @@ class AppSettingsRepository( /** Build the current payload from all synced prefs sources. */ private fun buildPayload(): Nip78.AppSettingsPayload { val mediaLayout = interfacePrefs.getMediaLayoutStyle().key + val unicodeEmojis = customEmojiRepo.getUnicodeEmojis() + val emojiFrequency = customEmojiRepo.getEmojiFrequency() return Nip78.AppSettingsPayload( + // Reactions (iOS-only UI today; round-tripped via interfacePrefs). + defaultReaction = interfacePrefs.getDefaultReaction(), + defaultReactionEnabled = interfacePrefs.getDefaultReactionEnabled(), + // Quick zaps + quickZapEnabled = interfacePrefs.isQuickZapEnabled(), + quickZapAmountSats = interfacePrefs.getQuickZapAmountSats(), + quickZapAmountFiat = interfacePrefs.getQuickZapAmountFiat(), + quickZapMessage = interfacePrefs.getQuickZapMessage(), zapIconStyle = if (interfacePrefs.isZapBoltIcon()) "bolt" else "default", + fiatModeEnabled = fiatPrefs.isFiatMode(), + fiatCurrency = fiatPrefs.getCurrency(), + zapPresetsCSV = zapPrefs.toCSV(), + quickReactions = unicodeEmojis.takeIf { it.isNotEmpty() }, + frequency = emojiFrequency.takeIf { it.isNotEmpty() }, largeText = interfacePrefs.isLargeText(), themeName = interfacePrefs.getTheme(), + colorScheme = interfacePrefs.getColorScheme(), // Local stores ARGB as signed 32-bit Int; convert to unsigned // Long so iOS reads it as a non-negative value. accentColorARGB = interfacePrefs.getAccentColor().toLong() and 0xFFFFFFFFL, autoLoadMedia = interfacePrefs.isAutoLoadMedia(), videoAutoplay = interfacePrefs.isVideoAutoPlay(), + animateAvatars = interfacePrefs.getAnimateAvatars(), mediaLayoutStyle = mediaLayout, clientTagEnabled = interfacePrefs.isClientTagEnabled(), postUndoTimerEnabled = interfacePrefs.isPostUndoTimerEnabled(), postUndoTimerSeconds = interfacePrefs.getPostUndoTimerSeconds(), postUndoTimerForReplies = interfacePrefs.isPostUndoTimerForReplies(), - fiatModeEnabled = fiatPrefs.isFiatMode(), - fiatCurrency = fiatPrefs.getCurrency(), - zapPresetsCSV = zapPrefs.toCSV(), - quickZapEnabled = interfacePrefs.isQuickZapEnabled(), - quickZapAmountSats = interfacePrefs.getQuickZapAmountSats(), - quickZapAmountFiat = interfacePrefs.getQuickZapAmountFiat(), - quickZapMessage = interfacePrefs.getQuickZapMessage(), version = 1 ) } @@ -207,34 +219,50 @@ class AppSettingsRepository( val iface = interfacePrefs.onSyncedFieldChanged val fiat = fiatPrefs.onSyncedFieldChanged val zap = zapPrefs.onSyncedFieldChanged + val emoji = customEmojiRepo.onSyncedFieldChanged interfacePrefs.onSyncedFieldChanged = null fiatPrefs.onSyncedFieldChanged = null zapPrefs.onSyncedFieldChanged = null + customEmojiRepo.onSyncedFieldChanged = null try { + // Reactions — round-trip only on Android. + interfacePrefs.setDefaultReaction(p.defaultReaction) + interfacePrefs.setDefaultReactionEnabled(p.defaultReactionEnabled) + // Quick zaps + p.quickZapEnabled?.let { interfacePrefs.setQuickZapEnabled(it) } + p.quickZapAmountSats?.let { interfacePrefs.setQuickZapAmountSats(it) } + p.quickZapAmountFiat?.let { interfacePrefs.setQuickZapAmountFiat(it) } + p.quickZapMessage?.let { interfacePrefs.setQuickZapMessage(it) } p.zapIconStyle?.let { interfacePrefs.setZapBoltIcon(it == "bolt") } + p.fiatModeEnabled?.let { fiatPrefs.setFiatMode(it) } + p.fiatCurrency?.let { fiatPrefs.setCurrency(it) } + p.zapPresetsCSV?.let { zapPrefs.applyCSV(it) } + // Quick reactions / frequency + if (p.quickReactions != null || p.frequency != null) { + customEmojiRepo.applyQuickReactions(p.quickReactions, p.frequency) + } + // Appearance p.largeText?.let { interfacePrefs.setLargeText(it) } p.themeName?.let { interfacePrefs.setTheme(it) } + interfacePrefs.setColorScheme(p.colorScheme) p.accentColorARGB?.let { interfacePrefs.setAccentColor(it.toInt()) } + // Media p.autoLoadMedia?.let { interfacePrefs.setAutoLoadMedia(it) } p.videoAutoplay?.let { interfacePrefs.setVideoAutoPlay(it) } + interfacePrefs.setAnimateAvatars(p.animateAvatars) p.mediaLayoutStyle?.let { interfacePrefs.setMediaLayoutStyle(InterfacePreferences.MediaLayoutStyle.fromKey(it)) } + // Posting p.clientTagEnabled?.let { interfacePrefs.setClientTagEnabled(it) } p.postUndoTimerEnabled?.let { interfacePrefs.setPostUndoTimerEnabled(it) } p.postUndoTimerSeconds?.let { interfacePrefs.setPostUndoTimerSeconds(it) } p.postUndoTimerForReplies?.let { interfacePrefs.setPostUndoTimerForReplies(it) } - p.fiatModeEnabled?.let { fiatPrefs.setFiatMode(it) } - p.fiatCurrency?.let { fiatPrefs.setCurrency(it) } - p.zapPresetsCSV?.let { zapPrefs.applyCSV(it) } - p.quickZapEnabled?.let { interfacePrefs.setQuickZapEnabled(it) } - p.quickZapAmountSats?.let { interfacePrefs.setQuickZapAmountSats(it) } - p.quickZapAmountFiat?.let { interfacePrefs.setQuickZapAmountFiat(it) } - p.quickZapMessage?.let { interfacePrefs.setQuickZapMessage(it) } } finally { interfacePrefs.onSyncedFieldChanged = iface fiatPrefs.onSyncedFieldChanged = fiat zapPrefs.onSyncedFieldChanged = zap + customEmojiRepo.onSyncedFieldChanged = emoji } } diff --git a/app/src/main/kotlin/com/wisp/app/repo/CustomEmojiRepository.kt b/app/src/main/kotlin/com/wisp/app/repo/CustomEmojiRepository.kt index 4e51fa97..1332bc74 100644 --- a/app/src/main/kotlin/com/wisp/app/repo/CustomEmojiRepository.kt +++ b/app/src/main/kotlin/com/wisp/app/repo/CustomEmojiRepository.kt @@ -41,6 +41,41 @@ class CustomEmojiRepository(private val context: Context, pubkeyHex: String? = n private var ownerPubkey: String? = pubkeyHex + /** + * NIP-78 sync hook fired after quick-reaction set or frequency map + * mutates. AppSettingsRepository registers itself here so an emoji + * change kicks off a debounced cross-device publish. + */ + @Volatile + var onSyncedFieldChanged: (() -> Unit)? = null + private fun fireSync() { onSyncedFieldChanged?.invoke() } + + /** Expose the current frequency map for NIP-78 publish. */ + fun getEmojiFrequency(): Map = _emojiFrequency.value + + /** + * Apply the unicode reaction set + frequency map from a NIP-78 + * restore. Suppresses the sync hook so we don't immediately republish + * a payload we just received. + */ + fun applyQuickReactions(emojis: List?, frequency: Map?) { + val prior = onSyncedFieldChanged + onSyncedFieldChanged = null + try { + if (emojis != null) { + _unicodeEmojis.value = emojis + saveUnicodeToPrefs() + } + if (frequency != null) { + _emojiFrequency.value = frequency + saveFrequencyToPrefs() + } + recomputeSortedEmojis() + } finally { + onSyncedFieldChanged = prior + } + } + companion object { private val DEFAULT_UNICODE_EMOJIS = listOf("\uD83E\uDDE1", "\uD83D\uDC4D", "\uD83D\uDC4E", "\uD83E\uDD19", "\uD83D\uDE80", "\uD83E\uDD17", "\uD83D\uDE02", "\uD83D\uDE22", "\uD83D\uDC68\u200D\uD83D\uDCBB", "\uD83D\uDC40", "\u2705", "\uD83E\uDD21", "\uD83D\uDC38", "\uD83D\uDC80", "\u26A1", "\uD83D\uDE4F", "\uD83C\uDF46") private val OLD_DEFAULT_UNICODE_EMOJIS = listOf("\u2764\uFE0F", "\uD83D\uDC4D", "\uD83D\uDC4E", "\uD83E\uDD19", "\uD83D\uDE80") @@ -120,6 +155,7 @@ class CustomEmojiRepository(private val context: Context, pubkeyHex: String? = n _unicodeEmojis.value = updated saveUnicodeToPrefs() recomputeSortedEmojis() + fireSync() return updated } @@ -128,6 +164,7 @@ class CustomEmojiRepository(private val context: Context, pubkeyHex: String? = n _unicodeEmojis.value = updated saveUnicodeToPrefs() recomputeSortedEmojis() + fireSync() return updated } @@ -135,6 +172,7 @@ class CustomEmojiRepository(private val context: Context, pubkeyHex: String? = n _unicodeEmojis.value = emojis saveUnicodeToPrefs() recomputeSortedEmojis() + fireSync() } fun recordEmojiUsage(emoji: String) { @@ -143,6 +181,7 @@ class CustomEmojiRepository(private val context: Context, pubkeyHex: String? = n _emojiFrequency.value = freq recomputeSortedEmojis() saveFrequencyToPrefs() + fireSync() } private fun recomputeSortedEmojis() { 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 169ea843..94480278 100644 --- a/app/src/main/kotlin/com/wisp/app/repo/InterfacePreferences.kt +++ b/app/src/main/kotlin/com/wisp/app/repo/InterfacePreferences.kt @@ -148,6 +148,40 @@ class InterfacePreferences(context: Context) { fireSync() } + // ── iOS round-trip-only fields ────────────────────────────────────────── + // Stored verbatim so an Android publish doesn't strip iOS-only settings + // out of the cross-device backup. No Android UI consumes these yet. + + fun getDefaultReaction(): String? = prefs.getString("default_reaction", null) + fun setDefaultReaction(value: String?) { + prefs.edit().apply { + if (value == null) remove("default_reaction") else putString("default_reaction", value) + }.apply() + } + + fun getDefaultReactionEnabled(): Boolean? = + if (prefs.contains("default_reaction_enabled")) prefs.getBoolean("default_reaction_enabled", false) else null + fun setDefaultReactionEnabled(value: Boolean?) { + prefs.edit().apply { + if (value == null) remove("default_reaction_enabled") else putBoolean("default_reaction_enabled", value) + }.apply() + } + + fun getColorScheme(): String? = prefs.getString("color_scheme", null) + fun setColorScheme(value: String?) { + prefs.edit().apply { + if (value == null) remove("color_scheme") else putString("color_scheme", value) + }.apply() + } + + fun getAnimateAvatars(): Boolean? = + if (prefs.contains("animate_avatars")) prefs.getBoolean("animate_avatars", true) else null + fun setAnimateAvatars(value: Boolean?) { + prefs.edit().apply { + if (value == null) remove("animate_avatars") else putBoolean("animate_avatars", value) + }.apply() + } + companion object { val postUndoTimerOptions = listOf(5, 10, 15, 20, 30) const val QUICK_ZAP_MAX_SATS = 10_000L 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 28defc4e..c6c21862 100644 --- a/app/src/main/kotlin/com/wisp/app/viewmodel/FeedViewModel.kt +++ b/app/src/main/kotlin/com/wisp/app/viewmodel/FeedViewModel.kt @@ -276,7 +276,8 @@ class FeedViewModel(app: Application) : AndroidViewModel(app) { val appSettingsRepo = AppSettingsRepository( interfacePrefs = interfacePrefs, fiatPrefs = com.wisp.app.repo.FiatPreferences.get(app), - zapPrefs = zapPrefs + zapPrefs = zapPrefs, + customEmojiRepo = customEmojiRepo ).also { it.relayPool = relayPool } val nwcRepo = NwcRepository(app, relayPool, pubkeyHex) val sparkRepo = SparkRepository(app, pubkeyHex) From 0be27d450f925ab35583ee177ca749e05c9ce271 Mon Sep 17 00:00:00 2001 From: The Daniel Date: Thu, 21 May 2026 22:09:58 -0400 Subject: [PATCH 13/16] feat(zap): hero-input composer, Edit Presets sheet, per-account presets, long-press instant zap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the iOS one-tap-zap composer + preset model to Android. ZapDialog redesign - Hero amount is the editable input (BasicTextField with a thousands- separator VisualTransformation). First-keystroke-replaces-seed is preserved via TextRange(0, n); LocalTextSelectionColors overrides the selection background to transparent so the seeded select-all doesn't paint an ugly box over the orange number. - Removed the redundant inline "Custom (sats)" OutlinedTextField — the hero IS the input now, matching iOS. - Sheet locks to fillMaxSize so it opens at full height immediately; prevents the stagger where the sheet jumped taller 450ms in when auto-focus raised the keyboard. - EditPresetsSheet replaces SaveZapPresetDialog. Modal bottom sheet titled "Edit Presets" with a Done pill, per-row swipe-to-delete via SwipeToDismissBox (iOS-red #FF3B30 panel, trailing edge only), and a "+ Add preset" row that disables itself while a blank entry exists. Per-account preset routing - ZapDialog now requires zapPrefsRepo: ZapPreferences. The previous in-dialog ZapPreferences(context) wrote to the un-scoped "zap_prefs" file, while AppSettingsRepository read from "zap_prefs_" — preset writes from the "+" chip never reached the NIP-78 publish path and restored presets never showed up in the dialog. All 14 callsites (Navigation x9, FeedScreen x2, UserProfileScreen x2, DmConversationScreen x1) now pass feedViewModel.zapPrefs. - One-shot migration in ZapPreferences copies any pre-existing global zap_prefs entries into the per-account file on first read, marked with migrated_from_global_v1 so it never repeats. Long-press instant zap - ActionBar's zap glyph uses combinedClickable: onClick opens the composer, onLongClick fires the instant zap. LocalHapticFeedback.performHapticFeedback(LongPress) on the long-press latch so the user feels confirmation before the network round-trip. - NoteActions gains onZapInstant (defaults to onZap so existing callers fall through to the composer). Plumbed through PostCard's onZapLongPress at every callsite — Navigation, FeedScreen (FeedItem also picked up onZapLongPress), ThreadScreen, NotificationsScreen, ArticleScreen, SearchScreen, UserProfileScreen. Each instant callback reads interfacePrefs and fires sendZap immediately when isQuickZapEnabled, else falls through to opening the composer. --- .../main/kotlin/com/wisp/app/Navigation.kt | 82 +++ .../com/wisp/app/repo/ZapPreferences.kt | 36 +- .../com/wisp/app/ui/component/ActionBar.kt | 18 +- .../com/wisp/app/ui/component/RichContent.kt | 2 + .../com/wisp/app/ui/component/ZapDialog.kt | 469 ++++++++++++++---- .../com/wisp/app/ui/screen/ArticleScreen.kt | 2 + .../app/ui/screen/DmConversationScreen.kt | 2 + .../com/wisp/app/ui/screen/FeedScreen.kt | 14 + .../wisp/app/ui/screen/NotificationsScreen.kt | 4 + .../com/wisp/app/ui/screen/SearchScreen.kt | 4 + .../com/wisp/app/ui/screen/ThreadScreen.kt | 4 + .../wisp/app/ui/screen/UserProfileScreen.kt | 7 + 12 files changed, 535 insertions(+), 109 deletions(-) diff --git a/app/src/main/kotlin/com/wisp/app/Navigation.kt b/app/src/main/kotlin/com/wisp/app/Navigation.kt index a77538a8..500e5d56 100644 --- a/app/src/main/kotlin/com/wisp/app/Navigation.kt +++ b/app/src/main/kotlin/com/wisp/app/Navigation.kt @@ -1155,6 +1155,14 @@ fun WispNavHost( onQuotedNoteClick = { eventId -> navController.navigate("thread/$eventId") }, onReact = { event, emoji -> feedViewModel.toggleReaction(event, emoji) }, onZap = { event, amountMsats, message, isAnonymous, isPrivate -> feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) }, + onZapInstant = { event -> + if (feedViewModel.interfacePrefs.isQuickZapEnabled()) { + val sats = feedViewModel.interfacePrefs.getQuickZapAmountSats() + val msg = feedViewModel.interfacePrefs.getQuickZapMessage() + feedViewModel.sendZap(event, sats * 1000L, msg, false, false) + } + }, + zapPrefs = feedViewModel.zapPrefs, userPubkey = feedViewModel.getUserPubkey(), isWalletConnected = feedViewModel.activeWalletProvider.hasConnection(), onWallet = { navController.navigate(Routes.WALLET) }, @@ -1273,6 +1281,7 @@ fun WispNavHost( feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) }, onGoToWallet = { navController.navigate(Routes.WALLET) }, + zapPrefsRepo = feedViewModel.zapPrefs, canPrivateZap = feedViewModel.hasLocalKeypair && userHasDmRelays && recipientHasDmRelays, recipientPubkey = searchZapTarget?.pubkey, profileLookup = { feedViewModel.profileRepo.get(it) } @@ -1314,6 +1323,15 @@ fun WispNavHost( onZap = { event -> searchZapTarget = event }, + onZapInstant = { event -> + if (feedViewModel.interfacePrefs.isQuickZapEnabled()) { + val sats = feedViewModel.interfacePrefs.getQuickZapAmountSats() + val msg = feedViewModel.interfacePrefs.getQuickZapMessage() + feedViewModel.sendZap(event, sats * 1000L, msg, false, false) + } else { + searchZapTarget = event + } + }, zapInProgress = searchZapInProgress, zapAnimatingIds = searchZapAnimatingIds, onToggleFollow = { pubkey -> @@ -1436,6 +1454,7 @@ fun WispNavHost( socialActionManager = feedViewModel.socialActions, isWalletConnected = feedViewModel.activeWalletProvider.hasConnection(), onGoToWallet = { navController.navigate(Routes.WALLET) }, + zapPrefs = feedViewModel.zapPrefs, noteActions = remember { com.wisp.app.ui.component.NoteActions( nip05Repo = feedViewModel.nip05Repo, @@ -1523,6 +1542,7 @@ fun WispNavHost( socialActionManager = feedViewModel.socialActions, isWalletConnected = feedViewModel.activeWalletProvider.hasConnection(), onGoToWallet = { navController.navigate(Routes.WALLET) }, + zapPrefs = feedViewModel.zapPrefs, noteActions = remember { com.wisp.app.ui.component.NoteActions( nip05Repo = feedViewModel.nip05Repo, @@ -1644,6 +1664,7 @@ fun WispNavHost( feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) }, onGoToWallet = { navController.navigate(Routes.WALLET) }, + zapPrefsRepo = feedViewModel.zapPrefs, canPrivateZap = feedViewModel.hasLocalKeypair && feedViewModel.relayPool.hasDmRelays() && recipientHasDmRelays, initialSatsHint = groupRoomZapInitialSats, recipientPubkey = groupRoomZapTarget?.pubkey, @@ -1923,6 +1944,7 @@ fun WispNavHost( feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) }, onGoToWallet = { navController.navigate(Routes.WALLET) }, + zapPrefsRepo = feedViewModel.zapPrefs, canPrivateZap = feedViewModel.hasLocalKeypair && threadUserHasDmRelays && threadRecipientHasDmRelays, forcePrivate = threadZapTarget?.id?.let { feedViewModel.eventRepo.isPrivate(it) } == true, recipientPubkey = threadZapTarget?.pubkey, @@ -1977,6 +1999,15 @@ fun WispNavHost( feedViewModel.blockUser(pubkey) }, onZap = { event -> threadZapTarget = event }, + onZapInstant = { event -> + if (feedViewModel.interfacePrefs.isQuickZapEnabled()) { + val sats = feedViewModel.interfacePrefs.getQuickZapAmountSats() + val msg = feedViewModel.interfacePrefs.getQuickZapMessage() + feedViewModel.sendZap(event, sats * 1000L, msg, false, false) + } else { + threadZapTarget = event + } + }, zapAnimatingIds = threadZapAnimatingIds, zapInProgressIds = threadZapInProgress, listedIds = threadListedIds, @@ -2096,6 +2127,7 @@ fun WispNavHost( feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) }, onGoToWallet = { navController.navigate(Routes.WALLET) }, + zapPrefsRepo = feedViewModel.zapPrefs, canPrivateZap = feedViewModel.hasLocalKeypair && hashtagUserHasDmRelays && hashtagRecipientHasDmRelays, recipientPubkey = hashtagZapTarget?.pubkey, profileLookup = { feedViewModel.profileRepo.get(it) } @@ -2119,6 +2151,15 @@ fun WispNavHost( navController.navigate(Routes.COMPOSE) }, onZap = { event -> hashtagZapTarget = event }, + onZapInstant = { event -> + if (feedViewModel.interfacePrefs.isQuickZapEnabled()) { + val sats = feedViewModel.interfacePrefs.getQuickZapAmountSats() + val msg = feedViewModel.interfacePrefs.getQuickZapMessage() + feedViewModel.sendZap(event, sats * 1000L, msg, false, false) + } else { + hashtagZapTarget = event + } + }, onProfileClick = { pubkey -> navController.navigate("profile/$pubkey") }, onNoteClick = { eventId -> navController.navigate("thread/$eventId") }, onAddToList = { eventId -> addToListEventId = eventId }, @@ -2250,6 +2291,7 @@ fun WispNavHost( feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) }, onGoToWallet = { navController.navigate(Routes.WALLET) }, + zapPrefsRepo = feedViewModel.zapPrefs, canPrivateZap = feedViewModel.hasLocalKeypair && setFeedUserHasDmRelays && setFeedRecipientHasDmRelays, recipientPubkey = setFeedZapTarget?.pubkey, profileLookup = { feedViewModel.profileRepo.get(it) } @@ -2273,6 +2315,15 @@ fun WispNavHost( navController.navigate(Routes.COMPOSE) }, onZap = { event -> setFeedZapTarget = event }, + onZapInstant = { event -> + if (feedViewModel.interfacePrefs.isQuickZapEnabled()) { + val sats = feedViewModel.interfacePrefs.getQuickZapAmountSats() + val msg = feedViewModel.interfacePrefs.getQuickZapMessage() + feedViewModel.sendZap(event, sats * 1000L, msg, false, false) + } else { + setFeedZapTarget = event + } + }, onProfileClick = { pubkey -> navController.navigate("profile/$pubkey") }, onNoteClick = { eventId -> navController.navigate("thread/$eventId") }, onAddToList = { eventId -> addToListEventId = eventId }, @@ -2417,6 +2468,7 @@ fun WispNavHost( feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) }, onGoToWallet = { navController.navigate(Routes.WALLET) }, + zapPrefsRepo = feedViewModel.zapPrefs, canPrivateZap = feedViewModel.hasLocalKeypair && articleUserHasDmRelays && articleRecipientHasDmRelays, recipientPubkey = articleZapTarget?.pubkey, profileLookup = { feedViewModel.profileRepo.get(it) } @@ -2440,6 +2492,15 @@ fun WispNavHost( navController.navigate(Routes.COMPOSE) }, onZap = { event -> articleZapTarget = event }, + onZapInstant = { event -> + if (feedViewModel.interfacePrefs.isQuickZapEnabled()) { + val sats = feedViewModel.interfacePrefs.getQuickZapAmountSats() + val msg = feedViewModel.interfacePrefs.getQuickZapMessage() + feedViewModel.sendZap(event, sats * 1000L, msg, false, false) + } else { + articleZapTarget = event + } + }, onProfileClick = { pubkey -> navController.navigate("profile/$pubkey") }, onNoteClick = { eventId -> navController.navigate("thread/$eventId") }, onAddToList = { eventId -> addToListEventId = eventId }, @@ -2501,6 +2562,15 @@ fun WispNavHost( navController.navigate(Routes.COMPOSE) }, onZap = { event -> articleZapTarget = event }, + onZapInstant = { event -> + if (feedViewModel.interfacePrefs.isQuickZapEnabled()) { + val sats = feedViewModel.interfacePrefs.getQuickZapAmountSats() + val msg = feedViewModel.interfacePrefs.getQuickZapMessage() + feedViewModel.sendZap(event, sats * 1000L, msg, false, false) + } else { + articleZapTarget = event + } + }, onAddToList = { eventId -> addToListEventId = eventId }, noteActions = articleNoteActions, zapAnimatingIds = articleZapAnimatingIds, @@ -2623,6 +2693,7 @@ fun WispNavHost( eventATag = aTag) }, onGoToWallet = { navController.navigate(Routes.WALLET) }, + zapPrefsRepo = feedViewModel.zapPrefs, // 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. @@ -3200,6 +3271,7 @@ fun WispNavHost( feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) }, onGoToWallet = { navController.navigate(Routes.WALLET) }, + zapPrefsRepo = feedViewModel.zapPrefs, canPrivateZap = feedViewModel.hasLocalKeypair && notifUserHasDmRelays && notifRecipientHasDmRelays, forcePrivate = notifZapTarget?.id?.let { feedViewModel.eventRepo.isPrivate(it) } == true, recipientPubkey = notifZapTarget?.pubkey, @@ -3222,6 +3294,7 @@ fun WispNavHost( ) }, onGoToWallet = { navController.navigate(Routes.WALLET) }, + zapPrefsRepo = feedViewModel.zapPrefs, recipientPubkey = notifDmZapTarget?.senderPubkey, profileLookup = { feedViewModel.profileRepo.get(it) } ) @@ -3324,6 +3397,15 @@ fun WispNavHost( navController.navigate(Routes.COMPOSE) }, onZap = { event -> notifZapTarget = event }, + onZapInstant = { event -> + if (feedViewModel.interfacePrefs.isQuickZapEnabled()) { + val sats = feedViewModel.interfacePrefs.getQuickZapAmountSats() + val msg = feedViewModel.interfacePrefs.getQuickZapMessage() + feedViewModel.sendZap(event, sats * 1000L, msg, false, false) + } else { + notifZapTarget = event + } + }, onFollowToggle = { pubkey -> feedViewModel.toggleFollow(pubkey) }, onBlockUser = { pubkey -> feedViewModel.blockUser(pubkey) }, onMuteThread = { rootEventId -> feedViewModel.muteThread(rootEventId) }, diff --git a/app/src/main/kotlin/com/wisp/app/repo/ZapPreferences.kt b/app/src/main/kotlin/com/wisp/app/repo/ZapPreferences.kt index f9d9f93f..f86a0a04 100644 --- a/app/src/main/kotlin/com/wisp/app/repo/ZapPreferences.kt +++ b/app/src/main/kotlin/com/wisp/app/repo/ZapPreferences.kt @@ -13,9 +13,41 @@ data class ZapPreset( class ZapPreferences(private val context: Context, pubkeyHex: String? = null) { private var prefs: SharedPreferences = context.getSharedPreferences(prefsName(pubkeyHex), Context.MODE_PRIVATE) + .also { migrateFromGlobalIfNeeded(context, it, pubkeyHex) } + + fun reload(pubkeyHex: String?) { + prefs = context.getSharedPreferences(prefsName(pubkeyHex), Context.MODE_PRIVATE) + .also { migrateFromGlobalIfNeeded(context, it, pubkeyHex) } + } companion object { private const val KEY_ZAP_PRESETS = "zap_presets" + private const val KEY_MIGRATED_FROM_GLOBAL = "migrated_from_global_v1" + + /** + * Before the per-account fix, the in-sheet "+" button wrote + * presets to the un-scoped `zap_prefs` file. Copy them into the + * per-account file on first read so users who saved presets + * pre-fix don't see them disappear. + */ + private fun migrateFromGlobalIfNeeded( + context: Context, + perAccount: SharedPreferences, + pubkeyHex: String? + ) { + if (pubkeyHex == null) return + if (perAccount.getBoolean(KEY_MIGRATED_FROM_GLOBAL, false)) return + if (perAccount.contains(KEY_ZAP_PRESETS)) { + perAccount.edit().putBoolean(KEY_MIGRATED_FROM_GLOBAL, true).apply() + return + } + val global = context.getSharedPreferences("zap_prefs", Context.MODE_PRIVATE) + val globalJson = global.getString(KEY_ZAP_PRESETS, null) + val edit = perAccount.edit().putBoolean(KEY_MIGRATED_FROM_GLOBAL, true) + if (globalJson != null) edit.putString(KEY_ZAP_PRESETS, globalJson) + edit.apply() + } + val DEFAULT_PRESETS = listOf( ZapPreset(21), ZapPreset(100), @@ -81,10 +113,6 @@ class ZapPreferences(private val context: Context, pubkeyHex: String? = null) { return updated } - fun reload(pubkeyHex: String?) { - prefs = context.getSharedPreferences(prefsName(pubkeyHex), Context.MODE_PRIVATE) - } - /** * Serialize the current preset list as CSV for NIP-78 cross-device sync. * Format: `` or `:`, joined by commas. Messages 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 f9065c2a..b8274d1b 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 @@ -40,7 +40,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.StrokeCap @@ -210,6 +212,7 @@ fun ActionBar( // 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) } + val haptics = LocalHapticFeedback.current Box( contentAlignment = Alignment.Center, modifier = Modifier @@ -230,6 +233,11 @@ fun ActionBar( onLongClick = if (zapEnabled && onZapLongPress != null) { { longPressFired.value = true + // Confirms the long-press registered before + // the zap network round-trip kicks off, so + // the user can lift their finger knowing + // the instant zap is on the way. + haptics.performHapticFeedback(HapticFeedbackType.LongPress) onZapLongPress() } } else null @@ -333,9 +341,13 @@ internal fun LightningAnimation(modifier: Modifier = Modifier) { val iconScale = 1.0f + 0.10f * s val verticalOffsetPx = with(density) { (-0.5f * s).dp.toPx() } - val innerStrokePx = with(density) { 1.5.dp.toPx() } - val medStrokePx = with(density) { (4f + 3f * phase).dp.toPx() } - val outerStrokePx = with(density) { (8f + 6f * phase).dp.toPx() } + // Scaled-down from the iOS reference radii — the action-bar bolt + // sits in a 22dp box, so iOS's 8/4/1.5pt shadows read as a big + // amber smear. Quartered (vs iOS) so the white core stays the + // focal element at small icon sizes. + val innerStrokePx = with(density) { 0.75.dp.toPx() } + val medStrokePx = with(density) { (1f + 1f * phase).dp.toPx() } + val outerStrokePx = with(density) { (2f + 2f * phase).dp.toPx() } Canvas(modifier = modifier) { translate(top = verticalOffsetPx) { diff --git a/app/src/main/kotlin/com/wisp/app/ui/component/RichContent.kt b/app/src/main/kotlin/com/wisp/app/ui/component/RichContent.kt index 6cb7127c..742eeff6 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/component/RichContent.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/component/RichContent.kt @@ -156,6 +156,7 @@ data class NoteActions( val onRepost: (NostrEvent) -> Unit = {}, val onQuote: (NostrEvent) -> Unit = {}, val onZap: (NostrEvent) -> Unit = {}, + val onZapInstant: (NostrEvent) -> Unit = {}, val onProfileClick: (String) -> Unit = {}, val onNoteClick: (String) -> Unit = {}, val onAddToList: (String) -> Unit = {}, @@ -1190,6 +1191,7 @@ fun QuotedNote( hasUserReposted = hasUserReposted, repostCount = repostCount, onZap = { effectiveActions.onZap(event) }, + onZapLongPress = { effectiveActions.onZapInstant(event) }, hasUserZapped = hasUserZapped, likeCount = likeCount, replyCount = replyCount, 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 96275dde..47913aa3 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 @@ -3,6 +3,9 @@ package com.wisp.app.ui.component import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.selection.LocalTextSelectionColors +import androidx.compose.foundation.text.selection.TextSelectionColors import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -10,6 +13,7 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi 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.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding @@ -26,6 +30,11 @@ import androidx.compose.material.icons.filled.Add 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.filled.Delete +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.material.icons.outlined.Visibility import androidx.compose.material.icons.outlined.VisibilityOff import androidx.compose.material3.AlertDialog @@ -47,6 +56,7 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -57,6 +67,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource @@ -65,9 +76,13 @@ 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.TextStyle 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.TextFieldValue +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation import kotlinx.coroutines.delay import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -92,13 +107,14 @@ import kotlinx.coroutines.launch * 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 + * 3. Hero amount — editable BasicTextField styled as the big + * orange number; doubles as the amount input, + * matching iOS. * 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 + * 5. Message field — single-line OutlinedTextField + * 6. Privacy dropdown — Public / Anonymous / Private with helper text + * 7. Instant zaps — toggle bound to `quickZapEnabled` setting + * 8. Zap button — full-width orange action button. Over 1M sats * disables it; over 10K routes through a * soft-confirmation dialog. * @@ -114,6 +130,15 @@ fun ZapDialog( onDismiss: () -> Unit, onZap: (amountMsats: Long, message: String, isAnonymous: Boolean, isPrivate: Boolean) -> Unit, onGoToWallet: () -> Unit, + /** + * Per-account preset store. Must be the same instance the + * `AppSettingsRepository` registered its `onSyncedFieldChanged` + * callback on, otherwise preset writes from the dialog land in a + * different SharedPreferences file than NIP-78 reads from on + * publish/restore — the symptom is presets appearing not to sync + * between Android and iOS. + */ + zapPrefsRepo: ZapPreferences, canPrivateZap: Boolean = false, /** * Lock the zap to DIP-03 private mode (private + anon toggles hidden, isPrivate held true). @@ -155,7 +180,6 @@ fun ZapDialog( val fiatMode by fiatPrefs.fiatMode.collectAsState() val fiatCurrency by fiatPrefs.currency.collectAsState() 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) } @@ -169,7 +193,7 @@ fun ZapDialog( var isPrivate by remember(forcePrivate) { mutableStateOf(forcePrivate) } var instantZapsEnabled by remember { mutableStateOf(interfacePrefs.isQuickZapEnabled()) } var showLargeAmountConfirm by remember { mutableStateOf(false) } - var showSavePresetDialog by remember { mutableStateOf(false) } + var showEditPresetsSheet by remember { mutableStateOf(false) } var privacyMenuExpanded by remember { mutableStateOf(false) } val amountFocusRequester = remember { FocusRequester() } @@ -240,13 +264,15 @@ fun ZapDialog( // 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. + // at the bottom. `fillMaxSize()` locks the sheet to the full + // available height from open — without it the sheet sizes to + // content, and the keyboard rising 450ms later forces a second + // layout pass that visibly jumps the sheet taller. `imePadding` + // then lifts the stack above the keyboard within the fixed + // sheet bounds so the Zap button stays visible. Column( modifier = Modifier - .fillMaxWidth() + .fillMaxSize() .imePadding() ) { Column( @@ -266,7 +292,7 @@ fun ZapDialog( PillButton(text = stringResource(R.string.btn_close), onClick = { closeSheet() }) PillButton( text = "Presets", - onClick = { showSavePresetDialog = true }, + onClick = { showEditPresetsSheet = true }, contentColor = accent, borderColor = accent.copy(alpha = 0.45f) ) @@ -319,23 +345,65 @@ fun ZapDialog( } } - // ── 3. Hero amount ────────────────────────────────────── + // ── 3. Hero amount (editable) ─────────────────────────── + // The hero IS the input — matches iOS. Typed digits update + // the value directly; preset taps seed it; visual + // transformation inserts thousands separators in bitcoin + // mode so the displayed number stays readable while the + // underlying state stays as raw digits. Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { - val heroText = if (fiatMode && effectiveAmount > 0) { - AmountFormatter.formatShort(effectiveAmount, context) - } else { - "%,d".format(effectiveAmount) - } - Text( - heroText, + val heroStyle = TextStyle( color = accent, fontSize = 56.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center ) + // Hide the text-selection background so the seeded + // select-all (which powers first-keystroke-replaces-seed) + // doesn't paint an ugly box behind the hero number. iOS + // achieves the same UX with no visible selection rect. + val invisibleSelection = remember(accent) { + TextSelectionColors( + handleColor = accent, + backgroundColor = Color.Transparent + ) + } + CompositionLocalProvider(LocalTextSelectionColors provides invisibleSelection) { + BasicTextField( + value = customAmountTfv, + onValueChange = { newTfv -> + val filtered = newTfv.text.filter { it.isDigit() } + customAmountTfv = newTfv.copy(text = filtered) + if (filtered.isNotEmpty()) isCustom = true + }, + textStyle = heroStyle, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + cursorBrush = SolidColor(accent), + visualTransformation = if (fiatMode) VisualTransformation.None + else ThousandsSeparatorTransformation, + modifier = Modifier + .fillMaxWidth() + .focusRequester(amountFocusRequester), + decorationBox = { inner -> + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth() + ) { + if (customAmountTfv.text.isEmpty()) { + Text( + "0", + style = heroStyle.copy(color = accent.copy(alpha = 0.35f)) + ) + } + inner() + } + } + ) + } Text( if (fiatMode) ExchangeRateRepository.currencyFor(fiatCurrency).code else "sats", color = accent.copy(alpha = 0.75f), @@ -358,6 +426,9 @@ fun ZapDialog( onClick = { selectedPreset = preset isCustom = false + // Seed the hero with the preset's value so + // the big number reflects the selection. + seedCustomAmount(preset.amountSats.toString()) { customAmountTfv = it } // Auto-fill the preset's optional default // message only when the message field is // currently empty (don't clobber typing). @@ -395,31 +466,7 @@ fun ZapDialog( ) } - // ── 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 ────────────────────────────────────────── + // ── 5. Message ────────────────────────────────────────── OutlinedTextField( value = message, onValueChange = { message = it }, @@ -428,7 +475,7 @@ fun ZapDialog( modifier = Modifier.fillMaxWidth() ) - // ── 7. Privacy dropdown ───────────────────────────────── + // ── 6. Privacy dropdown ───────────────────────────────── if (!forcePrivate) { Box(modifier = Modifier.fillMaxWidth()) { Surface( @@ -510,7 +557,7 @@ fun ZapDialog( } } - // ── 8. Instant zaps toggle ────────────────────────────── + // ── 7. Instant zaps toggle ────────────────────────────── Surface( modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.surfaceVariant, @@ -553,7 +600,7 @@ fun ZapDialog( } // end scrollable content Column - // ── 9. Zap button — pinned to the bottom of the sheet ── + // ── 8. 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. @@ -635,16 +682,16 @@ 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 } + if (showEditPresetsSheet) { + EditPresetsSheet( + initial = presets, + accent = accent, + onDismiss = { showEditPresetsSheet = false }, + onSave = { newList -> + zapPrefsRepo.setPresets(newList) + presets = newList.sortedBy { it.amountSats } + showEditPresetsSheet = false + } ) } } @@ -654,6 +701,36 @@ fun ZapDialog( private const val ZAP_SOFT_CONFIRM_SATS = 10_000L private const val ZAP_HARD_CAP_SATS = 1_000_000L +/** + * Insert thousands separators in the hero number while typing without + * mutating the underlying raw-digit state. Maps cursor positions so a + * tap or arrow-key lands on the digit the user expects. + */ +private val ThousandsSeparatorTransformation: VisualTransformation = VisualTransformation { text -> + val raw = text.text + if (raw.isEmpty()) return@VisualTransformation TransformedText(text, OffsetMapping.Identity) + val formatted = try { "%,d".format(raw.toLong()) } catch (_: NumberFormatException) { raw } + val mapping = object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int { + val clamped = offset.coerceIn(0, raw.length) + val digitsFromRight = raw.length - clamped + val totalCommas = (raw.length - 1) / 3 + val commasFromRight = ((digitsFromRight - 1).coerceAtLeast(0)) / 3 + val commasBefore = totalCommas - commasFromRight + return (clamped + commasBefore).coerceIn(0, formatted.length) + } + override fun transformedToOriginal(offset: Int): Int { + val clamped = offset.coerceIn(0, formatted.length) + var rawOffset = 0 + for (i in 0 until clamped) { + if (formatted[i] != ',') rawOffset++ + } + return rawOffset.coerceIn(0, raw.length) + } + } + TransformedText(AnnotatedString(formatted), mapping) +} + /** * Seed the custom-amount field with the given text AND select the * whole range — so the next keystroke replaces the seed entirely. @@ -798,56 +875,244 @@ internal fun friendlyZapErrorMessage(raw: String?): String { } /** - * 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. + * iOS-equivalent "Edit Presets" sheet — full list editor reachable from + * the composer's "Presets" pill. Mirrors the iOS layout: each row has + * inline editable amount + message text fields, a leading minus icon to + * remove the row, and a final "+ Add preset" row in accent color. Done + * persists the list via `zapPrefsRepo.setPresets`, which kicks the + * NIP-78 debounced publish so the change propagates to the user's other + * devices. + * + * The Add row is disabled while a blank row already exists so the + * caller can't pile up empty entries (matches iOS behavior). */ +@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun SaveZapPresetDialog( - currentAmount: String, - onSave: (ZapPreset) -> Unit, - onDismiss: () -> Unit +private fun EditPresetsSheet( + initial: List, + accent: Color, + onDismiss: () -> Unit, + onSave: (List) -> Unit ) { - var amount by remember { mutableStateOf(currentAmount) } - var presetMessage by remember { mutableStateOf("") } - AlertDialog( + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + // Working copy — only committed back via `onSave` when Done is + // pressed, so dismissing via drag-down / scrim discards in-progress + // edits (matches the iOS sheet's Cancel-on-dismiss semantics). + var rows by remember { + mutableStateOf(initial.map { EditableRow(it.amountSats.toString(), it.message) }) + } + fun closeSheet(commit: Boolean) { + scope.launch { sheetState.hide() }.invokeOnCompletion { + if (commit) { + val parsed = rows.mapNotNull { r -> + val sats = r.amount.toLongOrNull() ?: return@mapNotNull null + if (sats <= 0) null else ZapPreset(sats, r.message.trim()) + } + onSave(parsed) + } else { + onDismiss() + } + } + } + val hasBlankRow = rows.any { it.amount.isBlank() || (it.amount.toLongOrNull() ?: 0L) == 0L } + + ModalBottomSheet( onDismissRequest = onDismiss, - title = { Text("Save preset") }, - text = { - Column { - OutlinedTextField( - value = amount, - onValueChange = { amount = it.filter { c -> c.isDigit() } }, - label = { Text("Amount (sats)") }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth() + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface + ) { + // fillMaxHeight expands the sheet to the full available height + // under the drag handle; ModalBottomSheet's outer container + // reserves the system insets, so this stops just below the + // status bar instead of bleeding into it. + Column( + modifier = Modifier + .fillMaxSize() + .imePadding() + .padding(horizontal = 20.dp, vertical = 8.dp) + ) { + // Header row — "Edit Presets" centered, "Done" right-aligned. + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Spacer(Modifier.width(60.dp)) + Text( + "Edit Presets", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier.weight(1f) ) - Spacer(Modifier.height(8.dp)) - OutlinedTextField( - value = presetMessage, - onValueChange = { presetMessage = it.replace(",", "").replace(":", "") }, - label = { Text("Message (optional)") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() + PillButton( + text = stringResource(R.string.btn_done), + onClick = { closeSheet(commit = true) }, + contentColor = accent, + borderColor = accent.copy(alpha = 0.45f) ) } - }, - confirmButton = { - Button( - onClick = { - val sats = amount.toLongOrNull() ?: return@Button - onSave(ZapPreset(sats, presetMessage.trim())) - }, - enabled = (amount.toLongOrNull() ?: 0L) > 0, - colors = ButtonDefaults.buttonColors(containerColor = WispThemeColors.zapColor) + Spacer(Modifier.height(12.dp)) + + Surface( + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f) ) { - Text(stringResource(R.string.btn_save), fontWeight = FontWeight.Bold) + Column { + rows.forEachIndexed { idx, row -> + if (idx > 0) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.18f), + thickness = 0.5.dp, + modifier = Modifier.padding(start = 14.dp) + ) + } + // Keyed by stable row identity so swiping away one + // row doesn't leak its dismiss state into the next + // row sliding into its position. + val rowKey = remember { java.util.UUID.randomUUID().toString() } + val dismissState = rememberSwipeToDismissBoxState( + confirmValueChange = { target -> + if (target == SwipeToDismissBoxValue.EndToStart) { + rows = rows.toMutableList().also { it.removeAt(idx) } + true + } else false + } + ) + SwipeToDismissBox( + state = dismissState, + enableDismissFromStartToEnd = false, + enableDismissFromEndToStart = true, + backgroundContent = { + // Trailing-swipe affordance — solid iOS-red + // panel with a trailing delete glyph. Sized + // to fillMaxSize so the panel spans the full + // row height and reaches the trailing edge. + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0xFFFF3B30)), + contentAlignment = Alignment.CenterEnd + ) { + Icon( + Icons.Filled.Delete, + contentDescription = "Delete preset", + tint = Color.White, + modifier = Modifier.padding(end = 24.dp) + ) + } + } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(horizontal = 14.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + BasicTextField( + value = row.amount, + onValueChange = { newVal -> + val filtered = newVal.filter { it.isDigit() } + rows = rows.toMutableList().also { + it[idx] = it[idx].copy(amount = filtered) + } + }, + textStyle = TextStyle( + color = MaterialTheme.colorScheme.onSurface, + fontSize = 16.sp + ), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + cursorBrush = SolidColor(accent), + decorationBox = { inner -> + Box { + if (row.amount.isEmpty()) { + Text( + "Sats", + color = MaterialTheme.colorScheme.onSurfaceVariant + .copy(alpha = 0.7f), + fontSize = 16.sp + ) + } + inner() + } + }, + modifier = Modifier.width(80.dp) + ) + Spacer(Modifier.width(8.dp)) + BasicTextField( + value = row.message, + onValueChange = { newVal -> + val sanitized = newVal.replace(",", "").replace(":", "") + rows = rows.toMutableList().also { + it[idx] = it[idx].copy(message = sanitized) + } + }, + textStyle = TextStyle( + color = MaterialTheme.colorScheme.onSurface, + fontSize = 16.sp + ), + singleLine = true, + cursorBrush = SolidColor(accent), + decorationBox = { inner -> + Box { + if (row.message.isEmpty()) { + Text( + "Message (optional)", + color = MaterialTheme.colorScheme.onSurfaceVariant + .copy(alpha = 0.7f), + fontSize = 16.sp + ) + } + inner() + } + }, + modifier = Modifier.weight(1f) + ) + } + } + } + + if (rows.isNotEmpty()) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.18f), + thickness = 0.5.dp, + modifier = Modifier.padding(start = 14.dp) + ) + } + // Add preset row — iOS disables it while a blank row + // already exists so the user finishes the current + // entry before adding another. + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = !hasBlankRow) { + rows = rows + EditableRow("", "") + } + .padding(horizontal = 14.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Filled.Add, + contentDescription = null, + tint = if (hasBlankRow) accent.copy(alpha = 0.35f) else accent, + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + "Add preset", + color = if (hasBlankRow) accent.copy(alpha = 0.35f) else accent, + fontWeight = FontWeight.SemiBold + ) + } + } } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text(stringResource(R.string.btn_cancel)) } + Spacer(Modifier.height(20.dp)) } - ) + } } + +/** Working-copy row used inside the Edit Presets sheet. */ +private data class EditableRow(val amount: String, val message: String) diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/ArticleScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/ArticleScreen.kt index cdb3180c..4db5f50c 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/ArticleScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/ArticleScreen.kt @@ -79,6 +79,7 @@ fun ArticleScreen( onRepost: (NostrEvent) -> Unit = {}, onQuote: (NostrEvent) -> Unit = {}, onZap: (NostrEvent) -> Unit = {}, + onZapInstant: (NostrEvent) -> Unit = onZap, onAddToList: (String) -> Unit = {}, noteActions: NoteActions? = null, zapAnimatingIds: Set = emptySet(), @@ -363,6 +364,7 @@ fun ArticleScreen( hasUserReposted = commentHasUserReposted, repostCount = commentRepostCount, onZap = { onZap(event) }, + onZapLongPress = { onZapInstant(event) }, hasUserZapped = commentHasUserZapped, likeCount = commentLikeCount, replyCount = commentReplyCount, 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 977f000d..c9f9bc7f 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 @@ -124,6 +124,7 @@ fun DmConversationScreen( socialActionManager: SocialActionManager? = null, isWalletConnected: Boolean = false, onGoToWallet: () -> Unit = {}, + zapPrefs: com.wisp.app.repo.ZapPreferences, noteActions: com.wisp.app.ui.component.NoteActions? = null, resolvedEmojis: Map = emptyMap(), unicodeEmojis: List = emptyList(), @@ -640,6 +641,7 @@ fun DmConversationScreen( zapTargetMessage = null onGoToWallet() }, + zapPrefsRepo = zapPrefs, 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 e2aa1c54..032fbf69 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 @@ -325,6 +325,15 @@ fun FeedScreen( onRepost = onRepost, onQuote = onQuote, onZap = { event -> zapTargetEvent = event }, + onZapInstant = { event -> + if (viewModel.interfacePrefs.isQuickZapEnabled()) { + val sats = viewModel.interfacePrefs.getQuickZapAmountSats() + val msg = viewModel.interfacePrefs.getQuickZapMessage() + viewModel.sendZap(event, sats * 1000L, msg, false, false) + } else { + zapTargetEvent = event + } + }, onProfileClick = onProfileClick, onNoteClick = { eventId -> onQuotedNoteClick?.invoke(eventId) }, onAddToList = onAddToList, @@ -544,6 +553,7 @@ fun FeedScreen( viewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) }, onGoToWallet = onWallet, + zapPrefsRepo = viewModel.zapPrefs, canPrivateZap = userHasDmRelays && recipientHasDmRelays, recipientPubkey = zapTargetEvent?.pubkey, profileLookup = { viewModel.profileRepo.get(it) } @@ -575,6 +585,7 @@ fun FeedScreen( viewModel.sendZapPollVote(pollEvent, optionIndex, amountMsats, message, isAnonymous) }, onGoToWallet = onWallet, + zapPrefsRepo = viewModel.zapPrefs, recipientPubkey = pollEvent.pubkey, profileLookup = { viewModel.profileRepo.get(it) } ) @@ -1213,6 +1224,7 @@ fun FeedScreen( onRepost = { onRepost(event) }, onQuote = { onQuote(event) }, onZap = { zapTargetEvent = event }, + onZapLongPress = { noteActions.onZapInstant(event) }, onAddToList = { onAddToList(event.id) }, onPin = { viewModel.togglePin(event.id) }, onDelete = { viewModel.deleteEvent(event.id, event.kind) }, @@ -1314,6 +1326,7 @@ private fun FeedItem( onRepost: () -> Unit, onQuote: () -> Unit, onZap: () -> Unit, + onZapLongPress: (() -> Unit)? = null, onAddToList: () -> Unit = {}, onPin: () -> Unit = {}, onDelete: () -> Unit = {}, @@ -1457,6 +1470,7 @@ private fun FeedItem( hasUserReposted = hasUserReposted, repostCount = repostCount, onZap = onZap, + onZapLongPress = onZapLongPress, hasUserZapped = hasUserZapped, likeCount = likeCount, replyCount = replyCount, diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/NotificationsScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/NotificationsScreen.kt index 98372d50..22014128 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/NotificationsScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/NotificationsScreen.kt @@ -158,6 +158,7 @@ fun NotificationsScreen( onRepost: (NostrEvent) -> Unit = {}, onQuote: (NostrEvent) -> Unit = {}, onZap: (NostrEvent) -> Unit = {}, + onZapInstant: (NostrEvent) -> Unit = onZap, onFollowToggle: (String) -> Unit = {}, onBlockUser: (String) -> Unit = {}, onMuteThread: (String) -> Unit = {}, @@ -256,6 +257,7 @@ fun NotificationsScreen( onRepost = onRepost, onQuote = onQuote, onZap = onZap, + onZapInstant = onZapInstant, onFollowToggle = onFollowToggle, onBlockUser = onBlockUser, onMuteThread = onMuteThread, @@ -1426,6 +1428,7 @@ private fun ReferencedNotePostCard( hasUserReposted = hasUserReposted, repostCount = repostCount, onZap = { params.onZap(event) }, + onZapLongPress = { params.onZapInstant(event) }, hasUserZapped = hasUserZapped, likeCount = likeCount, replyCount = replyCount, @@ -1526,6 +1529,7 @@ private data class NotifPostCardParams( val onRepost: (NostrEvent) -> Unit, val onQuote: (NostrEvent) -> Unit, val onZap: (NostrEvent) -> Unit, + val onZapInstant: (NostrEvent) -> Unit = onZap, val onFollowToggle: (String) -> Unit, val onBlockUser: (String) -> Unit, val onMuteThread: (String) -> Unit = {}, diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/SearchScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/SearchScreen.kt index 0aa0b7eb..c7c1688f 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/SearchScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/SearchScreen.kt @@ -103,6 +103,7 @@ fun SearchScreen( onRepost: (NostrEvent) -> Unit = {}, onQuote: (NostrEvent) -> Unit = {}, onZap: (NostrEvent) -> Unit = {}, + onZapInstant: (NostrEvent) -> Unit = onZap, zapInProgress: Set = emptySet(), zapAnimatingIds: Set = emptySet(), onToggleFollow: (String) -> Unit = {}, @@ -376,6 +377,7 @@ fun SearchScreen( onRepost = { onRepost(event) }, onQuote = { onQuote(event) }, onZap = { onZap(event) }, + onZapInstant = { onZapInstant(event) }, onFollowAuthor = { onToggleFollow(event.pubkey) }, onBlockAuthor = { onBlockUser(event.pubkey) }, onAddToList = { onAddToList(event.id) }, @@ -463,6 +465,7 @@ private fun SearchNoteItem( onRepost: () -> Unit, onQuote: () -> Unit = {}, onZap: () -> Unit, + onZapInstant: () -> Unit = onZap, onFollowAuthor: () -> Unit, onBlockAuthor: () -> Unit, onAddToList: () -> Unit, @@ -509,6 +512,7 @@ private fun SearchNoteItem( onRepost = onRepost, onQuote = onQuote, onZap = onZap, + onZapLongPress = onZapInstant, hasUserZapped = hasUserZapped, zapSats = zapSats, isZapAnimating = isZapAnimating, diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/ThreadScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/ThreadScreen.kt index ce55063f..8fe3ca3f 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/ThreadScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/ThreadScreen.kt @@ -90,6 +90,7 @@ fun ThreadScreen( onToggleFollow: (String) -> Unit = {}, onBlockUser: (String) -> Unit = {}, onZap: (NostrEvent) -> Unit = {}, + onZapInstant: (NostrEvent) -> Unit = onZap, zapAnimatingIds: Set = emptySet(), zapInProgressIds: Set = emptySet(), listedIds: Set = emptySet(), @@ -178,6 +179,7 @@ fun ThreadScreen( onRepost = onRepost, onQuote = onQuote, onZap = onZap, + onZapInstant = onZapInstant, onProfileClick = onProfileClick, onNoteClick = { eventId -> onQuotedNoteClick?.invoke(eventId) }, onAddToList = onAddToList, @@ -360,6 +362,7 @@ fun ThreadScreen( hasUserReposted = hasUserReposted, repostCount = repostCount, onZap = { onZap(event) }, + onZapLongPress = { onZapInstant(event) }, hasUserZapped = hasUserZapped, likeCount = likeCount, replyCount = replyCount, @@ -477,6 +480,7 @@ fun ThreadScreen( hasUserReposted = hasUserReposted, repostCount = repostCount, onZap = { onZap(event) }, + onZapLongPress = { onZapInstant(event) }, hasUserZapped = hasUserZapped, likeCount = likeCount, replyCount = replyCount, 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 934f223b..cfe40db8 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 @@ -163,6 +163,8 @@ fun UserProfileScreen( onQuotedNoteClick: ((String) -> Unit)? = null, onReact: (NostrEvent, String) -> Unit = { _, _ -> }, onZap: (NostrEvent, Long, String, Boolean, Boolean) -> Unit = { _, _, _, _, _ -> }, + onZapInstant: ((NostrEvent) -> Unit)? = null, + zapPrefs: com.wisp.app.repo.ZapPreferences, userPubkey: String? = null, isWalletConnected: Boolean = false, onWallet: () -> Unit = {}, @@ -293,6 +295,7 @@ fun UserProfileScreen( onZap(event, amountMsats, message, isAnonymous, isPrivate) }, onGoToWallet = onWallet, + zapPrefsRepo = zapPrefs, canPrivateZap = resolvedCanPrivateZap, recipientPubkey = zapTargetEvent?.pubkey, profileLookup = { eventRepo?.getProfileData(it) } @@ -309,6 +312,7 @@ fun UserProfileScreen( onZapProfile?.invoke(amountMsats, message, isAnonymous) }, onGoToWallet = onWallet, + zapPrefsRepo = zapPrefs, canPrivateZap = false, // Profile zap — recipient is the profile being viewed. recipientPubkey = profile?.pubkey, @@ -868,6 +872,7 @@ fun UserProfileScreen( userReactionEmojis = userEmojis, hasUserReposted = hasUserReposted, onZap = { zapTargetEvent = event }, + onZapLongPress = { onZapInstant?.invoke(event) ?: run { zapTargetEvent = event } }, hasUserZapped = hasUserZapped, likeCount = likeCount, replyCount = replyCount, @@ -1053,6 +1058,7 @@ fun UserProfileScreen( userReactionEmojis = userEmojis, hasUserReposted = hasUserReposted2, onZap = { zapTargetEvent = event }, + onZapLongPress = { onZapInstant?.invoke(event) ?: run { zapTargetEvent = event } }, hasUserZapped = hasUserZapped2, likeCount = likeCount, replyCount = replyCount, @@ -1128,6 +1134,7 @@ fun UserProfileScreen( userReactionEmojis = userEmojis, hasUserReposted = hasUserReposted, onZap = { zapTargetEvent = event }, + onZapLongPress = { onZapInstant?.invoke(event) ?: run { zapTargetEvent = event } }, hasUserZapped = hasUserZapped, likeCount = likeCount, replyCount = replyCount, From 810afda6e7cf957c139565c85ba12cec612c7e6c Mon Sep 17 00:00:00 2001 From: The Daniel Date: Thu, 21 May 2026 22:10:25 -0400 Subject: [PATCH 14/16] =?UTF-8?q?feat(wallet):=20parity=20polish=20?= =?UTF-8?q?=E2=80=94=20switch=20flow,=20NWC=20dashboard,=20settings=20clea?= =?UTF-8?q?nup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-connect navigation - When isConnected flips true while currentPage is NwcSetup or SparkSetup, clear the back stack and set currentPage = Home. Fixes the "Wallet" TopAppBar showing over the connected NWC dashboard because the setup screen lingered until the user navigated away. Switch / Disconnect entry on Wallet Settings - iOS-style Card row with SwapHoriz icon + "Switch to a different wallet" in #FF3B30 for both recoverable cases (default Spark and NWC). Section header "Disconnect Wallet" above, explanatory caption below. The destructive non-default Spark delete keeps the filled-red Button because the seed can't be re-derived from nsec — losing it is irreversible. - Confirmation page unified on #FF3B30 across all three flows (icon halo, title, CTA, and "back up first" warning) instead of branching between Material primary orange and Material darker red #D32F2F. - Switching from a default Spark wallet routes back to the wallet-mode picker (Home + NotConnected → renders WalletModeSelectionContent) rather than dropping the user on the Spark sub-screen. NWC entry - QR scan dialog reachable from the connection-string field's trailing icon. Reuses the existing QrScanner component; success populates onConnectionStringChange so paste + scan are both available. NWC dashboard - Lightning-address pill rendered for any wallet mode that carries a lud16 (Spark via Breez SDK, NWC via parsed URI). Removed the redundant "Nostr Wallet Connect" footer below the balance — the in-page top row already brands the connection (NWC logo + node alias). - Adaptive recent-tx count via LocalConfiguration.screenHeightDp (5 / 4 / 3 / 2 rows by tier) so smaller phones don't crowd out the balance + Send/Receive controls. Wallet Settings polish - WalletInfoRow uses a fixed-width label column (widthIn min 110dp, trailing padding) and right-aligned values with maxLines = 1 + TextOverflow.Ellipsis so long relay URLs / lightning addresses truncate cleanly and labels align across rows. - Backup-to-relay now offered for default Spark wallets too — matches iOS, gives users belt-and-braces durability beyond the nsec. Strings + docs - wallet_switch_wallet copy expanded to "Switch to a different wallet"; new wallet_disconnect_section + wallet_switch_wallet_caption. - WALLET_PARITY.md §11.2 checklist items marked done for the parity work landed here and in the preceding two commits. --- WALLET_PARITY.md | 16 +- .../com/wisp/app/ui/screen/WalletScreen.kt | 248 +++++++++++++----- .../com/wisp/app/viewmodel/WalletViewModel.kt | 22 +- app/src/main/res/values/strings.xml | 4 +- 4 files changed, 211 insertions(+), 79 deletions(-) diff --git a/WALLET_PARITY.md b/WALLET_PARITY.md index 7810fd30..08d73af6 100644 --- a/WALLET_PARITY.md +++ b/WALLET_PARITY.md @@ -772,25 +772,25 @@ existing wallet have no obvious entry point. **Restructure per §2.6**: -- [ ] Replace the top-level layout with the two-row picker (Spark / +- [x] Replace the top-level layout with the two-row picker (Spark / NWC) described in §2.6 Screen 1. -- [ ] Add a Spark sub-screen matching §2.6 Screen 2 with four option +- [x] Add a Spark sub-screen matching §2.6 Screen 2 with four option rows (or three when `hasKeypair() == false`). -- [ ] Move the `maybeAutoCreateDefaultWallet()` entry point to the new +- [x] Move the `maybeAutoCreateDefaultWallet()` entry point to the new "Use my default wallet" row at the top of the Spark sub-screen. Ignore `skipAutoCreate` on explicit tap. -- [ ] Add the string resources from §2.6 to `strings.xml`. -- [ ] Verify the existing flows still wire through: +- [x] Add the string resources from §2.6 to `strings.xml`. +- [x] Verify the existing flows still wire through: - Create new wallet → existing BIP39-generate + confirm-backup flow - Restore from seed phrase → existing 12-word entry flow - Restore from relays → existing NIP-78 backup search flow - Nostr Wallet Connect → existing NWC paste-string flow -- [ ] Disconnect flow on a default wallet says **"Switch Wallet"** and +- [x] Disconnect flow on a default wallet says **"Switch Wallet"** and the body copy refers to the wallet as your *default wallet* — never "Wisp wallet" or "wisp wallet". -- [ ] Settings section header renamed from "Danger Zone" to +- [x] Settings section header renamed from "Danger Zone" to **"Disconnect Wallet"** (per §4.8). -- [ ] Dashboard welcome banner for default wallets per §3.5 (blue/accent +- [x] Dashboard welcome banner for default wallets per §3.5 (blue/accent tint, key icon, "secured by your key" copy) — separate from the existing amber warning banner for custom wallets. diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt index 906fed0d..d1256374 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt @@ -50,6 +50,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState @@ -124,6 +125,7 @@ import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.compose.ui.res.painterResource @@ -591,6 +593,7 @@ private fun WalletConnectionContent( ) { val context = LocalContext.current val isConnecting = walletState is WalletState.Connecting + var showScanner by remember { mutableStateOf(false) } Spacer(Modifier.height(16.dp)) @@ -667,9 +670,65 @@ private fun WalletConnectionContent( modifier = Modifier.fillMaxWidth(), singleLine = false, maxLines = 3, - enabled = !isConnecting + enabled = !isConnecting, + trailingIcon = { + IconButton( + onClick = { showScanner = true }, + enabled = !isConnecting + ) { + Icon( + Icons.Default.QrCode, + contentDescription = stringResource(R.string.wallet_scan_qr_code), + tint = MaterialTheme.colorScheme.primary + ) + } + } ) + if (showScanner) { + // Dialog overlay matches the iOS "Scan QR" button on the NWC + // entry sheet — opens the camera in-place, on success closes + // the dialog and seeds the connection-string field. Stripping + // `nostr+walletconnect://` is intentionally left to the scan + // value: many wallet apps QR-encode the full URI, and that's + // what the connect step expects. + androidx.compose.ui.window.Dialog( + onDismissRequest = { showScanner = false } + ) { + androidx.compose.material3.Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surface, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + stringResource(R.string.wallet_scan_qr_code), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(Modifier.height(12.dp)) + com.wisp.app.ui.component.QrScanner( + onResult = { value -> + showScanner = false + onConnectionStringChange(value.trim()) + }, + modifier = Modifier + .fillMaxWidth() + .height(320.dp), + promptText = stringResource(R.string.wallet_point_camera) + ) + Spacer(Modifier.height(12.dp)) + TextButton(onClick = { showScanner = false }) { + Text(stringResource(R.string.btn_cancel)) + } + } + } + } + } + if (walletState is WalletState.Error) { Spacer(Modifier.height(8.dp)) Text( @@ -1013,7 +1072,13 @@ private fun WalletHomeContent( } // ── Lightning address pill ───────────────────────────────── - if (walletMode == WalletMode.SPARK && lightningAddress != null) { + // Shown for any wallet mode that carries a lud16 — Spark wallets + // expose one via the Breez SDK; NWC URIs may include `lud16=...` + // which connectNwcWallet copies into `lightningAddress`. The old + // NWC-only logo + "Nostr Wallet Connect" footer below the balance + // was redundant (the dashboard header already brands the mode), + // so it's removed. + if (!lightningAddress.isNullOrBlank()) { Spacer(Modifier.height(16.dp)) Surface( modifier = Modifier.clickable { @@ -1040,7 +1105,9 @@ private fun WalletHomeContent( ) } } - } else if (walletMode == WalletMode.SPARK && lightningAddress == null) { + } else if (walletMode == WalletMode.SPARK) { + // Spark-only setup CTA. NWC users can't register an address + // from inside Wisp — it comes from the NWC URI or not at all. Spacer(Modifier.height(16.dp)) Surface( modifier = Modifier.clickable(onClick = onSetupAddress), @@ -1066,28 +1133,6 @@ private fun WalletHomeContent( ) } } - } else if (walletMode == WalletMode.NWC) { - Spacer(Modifier.height(16.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth() - ) { - Image( - painter = painterResource(R.drawable.ic_nwc_logo), - contentDescription = "NWC", - modifier = Modifier.height(16.dp), - colorFilter = androidx.compose.ui.graphics.ColorFilter.tint( - MaterialTheme.colorScheme.onSurfaceVariant - ) - ) - Spacer(Modifier.width(6.dp)) - Text( - stringResource(R.string.wallet_nwc_title), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } } // ── Send / Receive ───────────────────────────────────────── @@ -1146,9 +1191,20 @@ private fun WalletHomeContent( HorizontalDivider( color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f) ) - // Match iOS — show the most-recent ~5 transactions inline, - // tap any row (or "View all") to expand to the full screen. - recentTransactions.take(5).forEach { tx -> + // Match iOS — show recent transactions inline, "View all" + // expands to the full screen. Row count scales with the + // device's available height so a compact phone (e.g. + // ~640dp tall) doesn't crowd out the balance + Send/ + // Receive controls, while bigger displays still get up + // to 5 rows like iOS. + val screenHeightDp = LocalConfiguration.current.screenHeightDp + val txCount = when { + screenHeightDp >= 800 -> 5 + screenHeightDp >= 720 -> 4 + screenHeightDp >= 640 -> 3 + else -> 2 + } + recentTransactions.take(txCount).forEach { tx -> TransactionRow(tx, profileLookup) } } @@ -3183,17 +3239,19 @@ private fun WalletSettingsContent( Text(if (isDefaultWallet) "View Recovery Phrase" else "Backup Recovery Phrase") } - if (!isDefaultWallet) { - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(8.dp)) - OutlinedButton( - onClick = onBackupToRelay, - modifier = Modifier.fillMaxWidth() - ) { - Icon(Icons.Default.CloudUpload, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text("Backup to Nostr Relays") - } + // Relay backup is offered for both default and non-default + // Spark wallets — matches iOS. For default wallets the nsec + // is already the canonical backup; offering relay backup + // here is belt-and-braces for users who want it. + OutlinedButton( + onClick = onBackupToRelay, + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Default.CloudUpload, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Backup to Nostr Relays") } // Relay backup status section (when logged in). Skipped for default @@ -3348,22 +3406,68 @@ private fun WalletSettingsContent( Spacer(Modifier.height(32.dp)) - Button( - onClick = onDeleteWallet, - modifier = Modifier.fillMaxWidth(), - colors = if (isDefaultWallet) ButtonDefaults.buttonColors() - else ButtonDefaults.buttonColors( - containerColor = Color(0xFFD32F2F), - contentColor = Color.White - ) - ) { + // iOS treats both the default-Spark "Switch" and the NWC + // "Disconnect" cases as quiet, recoverable affordances — a + // card row with red text + swap icon plus a caption. Only the + // truly destructive Delete (non-default Spark whose seed can't + // be re-derived from nsec) keeps the filled-red CTA so the user + // notices the irreversibility. + val isRecoverable = walletMode == WalletMode.NWC || isDefaultWallet + if (isRecoverable) { Text( - when { - walletMode == WalletMode.NWC -> "Disconnect" - isDefaultWallet -> stringResource(R.string.wallet_switch_wallet) - else -> "Delete Wallet" + stringResource(R.string.wallet_disconnect_section), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(start = 4.dp, bottom = 6.dp) + ) + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onDeleteWallet), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.SwapHoriz, + contentDescription = null, + tint = Color(0xFFFF3B30), + modifier = Modifier.size(22.dp) + ) + Spacer(Modifier.width(12.dp)) + Text( + stringResource(R.string.wallet_switch_wallet), + style = MaterialTheme.typography.bodyLarge, + color = Color(0xFFFF3B30) + ) } + } + Spacer(Modifier.height(8.dp)) + Text( + stringResource(R.string.wallet_switch_wallet_caption), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 4.dp) ) + } else { + Button( + onClick = onDeleteWallet, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFFD32F2F), + contentColor = Color.White + ) + ) { + Text("Delete Wallet") + } } // Footer @@ -3710,18 +3814,20 @@ private fun DeleteWalletConfirmContent( ) { Spacer(Modifier.height(32.dp)) + // Confirmation page uses the same iOS-red `#FF3B30` for all + // three flows (default-switch, NWC-disconnect, non-default + // delete) so the user doesn't see one orange and one red CTA + // for what's the same conceptual action — "stop using this + // wallet." Matches the iOS-red used on the entry-point card. + val accent = Color(0xFFFF3B30) Icon( if (isDefault) Icons.Default.SwapHoriz else Icons.Default.Close, contentDescription = null, modifier = Modifier .size(64.dp) - .background( - (if (isDefault) MaterialTheme.colorScheme.primary else Color(0xFFD32F2F)) - .copy(alpha = 0.1f), - CircleShape - ) + .background(accent.copy(alpha = 0.1f), CircleShape) .padding(16.dp), - tint = if (isDefault) MaterialTheme.colorScheme.primary else Color(0xFFD32F2F) + tint = accent ) Spacer(Modifier.height(24.dp)) @@ -3733,7 +3839,7 @@ private fun DeleteWalletConfirmContent( else -> "Delete Wallet" }, style = MaterialTheme.typography.headlineMedium, - color = if (isDefault) MaterialTheme.colorScheme.onSurface else Color(0xFFD32F2F) + color = if (isDefault) MaterialTheme.colorScheme.onSurface else accent ) Spacer(Modifier.height(16.dp)) @@ -3755,7 +3861,7 @@ private fun DeleteWalletConfirmContent( Text( "Make sure you have backed up your recovery phrase before proceeding.", style = MaterialTheme.typography.bodyMedium, - color = Color(0xFFD32F2F), + color = accent, textAlign = TextAlign.Center ) @@ -3776,11 +3882,10 @@ private fun DeleteWalletConfirmContent( onClick = onDelete, modifier = Modifier.fillMaxWidth(), enabled = isNwc || isDefault || confirmText == "DELETE", - colors = if (isDefault) ButtonDefaults.buttonColors() - else ButtonDefaults.buttonColors( - containerColor = Color(0xFFD32F2F), - contentColor = Color.White - ) + colors = ButtonDefaults.buttonColors( + containerColor = accent, + contentColor = Color.White + ) ) { Text( when { @@ -4314,11 +4419,21 @@ private fun WalletInfoRow( .padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically ) { + // Fixed-width label column so values across rows align to the + // same x-coordinate regardless of label length, and the value + // text always has a stable container to ellipsize within. + // Without this, "Lightning address" pushes its value column 30dp + // to the right of "Relay" — the iOS settings panel uses the + // same column-aligned layout. Text( label, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f) + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .widthIn(min = 110.dp) + .padding(end = 12.dp) ) Text( value, @@ -4326,7 +4441,8 @@ private fun WalletInfoRow( color = MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(2f) + textAlign = TextAlign.End, + modifier = Modifier.weight(1f) ) if (onCopy != null) { Spacer(Modifier.width(8.dp)) diff --git a/app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt b/app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt index e8ea6995..7dd383f0 100644 --- a/app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt +++ b/app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt @@ -715,6 +715,18 @@ class WalletViewModel( if (provider === nwcRepo) { launch { nwcRepo.fetchNodeInfo() } } + // Once the wallet finishes connecting, leave the setup + // screen and land on Home — otherwise currentPage stays + // at NwcSetup/SparkSetup and the Scaffold's "Wallet" + // TopAppBar keeps showing over the dashboard. Guarded + // to the setup pages so a connect-during-navigation + // doesn't pop the user out of an unrelated sub-page. + val page = _currentPage.value + if (page is WalletPage.NwcSetup || page is WalletPage.SparkSetup) { + pageStack.clear() + pageStack.add(WalletPage.Home) + _currentPage.value = WalletPage.Home + } } } } @@ -772,16 +784,18 @@ class WalletViewModel( // Suppress the auto-create on the next navigateHome — the user // explicitly disconnected to choose a different wallet. They'll land - // on the SparkSetup screen with the three options. Persist so the - // choice survives app restarts. + // on the wallet-mode picker (Spark vs NWC), matching iOS. Persist + // so the choice survives app restarts. skipAutoCreate = true walletModeRepo.setAutoCreateSkipped(true) pageStack.clear() pageStack.add(WalletPage.Home) if (wasSpark && keyRepo.hasKeypair()) { - pageStack.add(WalletPage.SparkSetup) - _currentPage.value = WalletPage.SparkSetup + // Drop the user at the top-level wallet picker so they can + // re-enter via Spark or NWC, not just Spark. iOS does the + // same — Switch is a true "start over" affordance. + _currentPage.value = WalletPage.Home } else { _currentPage.value = WalletPage.Home } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c1bf025a..49c29efa 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -887,7 +887,9 @@ Generate invoice Use my default wallet Derived from your Nostr key — no extra backup needed. - Switch Wallet + Switch to a different wallet Disconnect this wallet so you can use your default wallet or restore a different one. Your funds stay safe — you can reconnect this wallet anytime by entering its recovery phrase. + Disconnect Wallet + Your default wallet is linked to your key and can always be restored. Switching connects a different wallet instead. This wallet is derived from your Nostr key, so the phrase below is just for export to other wallet apps. You don\'t need to back it up — your nsec is the backup. From 4f88bf97050844defc67c763cbc85fa10a7a842b Mon Sep 17 00:00:00 2001 From: The Daniel Date: Thu, 21 May 2026 22:53:41 -0400 Subject: [PATCH 15/16] fix(wallet): move NWC paste field above the Recommended wallets list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The connection-string OutlinedTextField + Connect button used to live under the 5-row Recommended list, which on smaller phones put them below the fold — paste/Connect required scrolling past the wallet suggestions every time. Reordered so the most-common action (paste an existing nostr+walletconnect:// URI) sits in the upper half of the viewport, with the wallet suggestions kept as a secondary "if you don't have one yet" affordance below. --- .../com/wisp/app/ui/screen/WalletScreen.kt | 105 +++++++++--------- 1 file changed, 54 insertions(+), 51 deletions(-) diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt index d1256374..c26c2fb1 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt @@ -611,57 +611,9 @@ private fun WalletConnectionContent( Spacer(Modifier.height(24.dp)) - Text( - stringResource(R.string.wallet_recommended), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface - ) - Spacer(Modifier.height(12.dp)) - - val wallets = listOf( - "rizful.com" to "Rizful", - "coinos.io" to "Coinos", - "getalby.com" to "Alby", - "cashu.me" to "Cashu.me", - "minibits.cash" to "Minibits" - ) - - wallets.forEach { (domain, name) -> - Card( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp) - .clickable { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://$domain")) - context.startActivity(intent) - }, - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - AsyncImage( - model = "https://$domain/favicon.ico", - contentDescription = name, - modifier = Modifier.size(24.dp) - ) - Spacer(Modifier.width(12.dp)) - Text( - name, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface - ) - } - } - } - - Spacer(Modifier.height(24.dp)) - + // Paste / Connect block lives above the Recommended wallets list + // so the most-common action (paste an existing connection string) + // is reachable without scrolling past the wallet suggestions. OutlinedTextField( value = connectionString, onValueChange = { new -> if (!NsecPasteGuard.blockIfNsec(connectionString, new)) onConnectionStringChange(new) }, @@ -782,6 +734,57 @@ private fun WalletConnectionContent( } Spacer(Modifier.height(32.dp)) + + Text( + stringResource(R.string.wallet_recommended), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(Modifier.height(12.dp)) + + val wallets = listOf( + "rizful.com" to "Rizful", + "coinos.io" to "Coinos", + "getalby.com" to "Alby", + "cashu.me" to "Cashu.me", + "minibits.cash" to "Minibits" + ) + + wallets.forEach { (domain, name) -> + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clickable { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://$domain")) + context.startActivity(intent) + }, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AsyncImage( + model = "https://$domain/favicon.ico", + contentDescription = name, + modifier = Modifier.size(24.dp) + ) + Spacer(Modifier.width(12.dp)) + Text( + name, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + } + + Spacer(Modifier.height(32.dp)) } // --- Home screen --- From b8cb061a6eff850f49b1647080078a523d8b45ae Mon Sep 17 00:00:00 2001 From: The Daniel Date: Mon, 25 May 2026 09:27:08 -0400 Subject: [PATCH 16/16] fix(settings): scope instant-zap defaults to active account Instant-zap settings (enabled, sats amount, fiat amount, message) are now stored under per-pubkey SharedPreferences keys so switching accounts no longer inherits the previous account's values. Fresh accounts default to 21 sats / 0.10 fiat / disabled / no message. InterfacePreferences.activePubkey (companion object) is updated by StartupCoordinator.reloadForNewAccount() alongside zapPrefs.reload(), so all instances pick up the new scope atomically on every switch. --- .../com/wisp/app/repo/InterfacePreferences.kt | 54 ++++++++++++++++--- .../com/wisp/app/viewmodel/FeedViewModel.kt | 6 ++- 2 files changed, 51 insertions(+), 9 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 94480278..cfb9c8f0 100644 --- a/app/src/main/kotlin/com/wisp/app/repo/InterfacePreferences.kt +++ b/app/src/main/kotlin/com/wisp/app/repo/InterfacePreferences.kt @@ -116,38 +116,74 @@ class InterfacePreferences(context: Context) { // ── 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. - fun isQuickZapEnabled(): Boolean = prefs.getBoolean("quick_zap_enabled", false) + 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("quick_zap_enabled", enabled).apply() + prefs.edit().putBoolean(quickZapKey("quick_zap_enabled"), enabled).apply() fireSync() } - fun getQuickZapAmountSats(): Long = prefs.getLong("quick_zap_amount_sats", 100L).coerceIn(1L, QUICK_ZAP_MAX_SATS) + 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("quick_zap_amount_sats", clamped).apply() + prefs.edit().putLong(quickZapKey("quick_zap_amount_sats"), clamped).apply() fireSync() } fun getQuickZapAmountFiat(): Double = - prefs.getString("quick_zap_amount_fiat", "0.10")?.toDoubleOrNull()?.coerceAtLeast(0.0) ?: 0.10 + 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("quick_zap_amount_fiat", clamped.toString()).apply() + prefs.edit().putString(quickZapKey("quick_zap_amount_fiat"), clamped.toString()).apply() fireSync() } - fun getQuickZapMessage(): String = prefs.getString("quick_zap_message", "") ?: "" + fun getQuickZapMessage(): String = prefs.getString(quickZapKey("quick_zap_message"), "") ?: "" fun setQuickZapMessage(message: String) { - prefs.edit().putString("quick_zap_message", message).apply() + prefs.edit().putString(quickZapKey("quick_zap_message"), message).apply() fireSync() } + /** + * 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() + } + // ── iOS round-trip-only fields ────────────────────────────────────────── // Stored verbatim so an Android publish doesn't strip iOS-only settings // out of the cross-device backup. No Android UI consumes these yet. @@ -183,6 +219,8 @@ class InterfacePreferences(context: Context) { } 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 } 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 c6c21862..8e4e697b 100644 --- a/app/src/main/kotlin/com/wisp/app/viewmodel/FeedViewModel.kt +++ b/app/src/main/kotlin/com/wisp/app/viewmodel/FeedViewModel.kt @@ -437,7 +437,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() @@ -445,6 +448,7 @@ class FeedViewModel(app: Application) : AndroidViewModel(app) { } fun reloadForNewAccount() { safetyPrefs.reload(getUserPubkey()) + interfacePrefs.reload(getUserPubkey()) startup.reloadForNewAccount() groupRepo.reload(getUserPubkey()) }