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/Navigation.kt b/app/src/main/kotlin/com/wisp/app/Navigation.kt index 80c11255..500e5d56 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}" @@ -1153,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) }, @@ -1271,7 +1281,10 @@ fun WispNavHost( feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) }, onGoToWallet = { navController.navigate(Routes.WALLET) }, - canPrivateZap = feedViewModel.hasLocalKeypair && userHasDmRelays && recipientHasDmRelays + zapPrefsRepo = feedViewModel.zapPrefs, + canPrivateZap = feedViewModel.hasLocalKeypair && userHasDmRelays && recipientHasDmRelays, + recipientPubkey = searchZapTarget?.pubkey, + profileLookup = { feedViewModel.profileRepo.get(it) } ) } SearchScreen( @@ -1310,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 -> @@ -1432,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, @@ -1519,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, @@ -1640,8 +1664,11 @@ 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 + initialSatsHint = groupRoomZapInitialSats, + recipientPubkey = groupRoomZapTarget?.pubkey, + profileLookup = { feedViewModel.profileRepo.get(it) } ) } val groupRoomMediaLauncher = rememberLauncherForActivityResult( @@ -1917,8 +1944,11 @@ 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 + forcePrivate = threadZapTarget?.id?.let { feedViewModel.eventRepo.isPrivate(it) } == true, + recipientPubkey = threadZapTarget?.pubkey, + profileLookup = { feedViewModel.profileRepo.get(it) } ) } val threadSetListedIds by feedViewModel.bookmarkSetRepo.allListedEventIds.collectAsState() @@ -1969,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, @@ -2088,7 +2127,10 @@ fun WispNavHost( feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) }, onGoToWallet = { navController.navigate(Routes.WALLET) }, - canPrivateZap = feedViewModel.hasLocalKeypair && hashtagUserHasDmRelays && hashtagRecipientHasDmRelays + zapPrefsRepo = feedViewModel.zapPrefs, + canPrivateZap = feedViewModel.hasLocalKeypair && hashtagUserHasDmRelays && hashtagRecipientHasDmRelays, + recipientPubkey = hashtagZapTarget?.pubkey, + profileLookup = { feedViewModel.profileRepo.get(it) } ) } @@ -2109,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 }, @@ -2240,7 +2291,10 @@ fun WispNavHost( feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) }, onGoToWallet = { navController.navigate(Routes.WALLET) }, - canPrivateZap = feedViewModel.hasLocalKeypair && setFeedUserHasDmRelays && setFeedRecipientHasDmRelays + zapPrefsRepo = feedViewModel.zapPrefs, + canPrivateZap = feedViewModel.hasLocalKeypair && setFeedUserHasDmRelays && setFeedRecipientHasDmRelays, + recipientPubkey = setFeedZapTarget?.pubkey, + profileLookup = { feedViewModel.profileRepo.get(it) } ) } @@ -2261,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 }, @@ -2405,7 +2468,10 @@ fun WispNavHost( feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) }, onGoToWallet = { navController.navigate(Routes.WALLET) }, - canPrivateZap = feedViewModel.hasLocalKeypair && articleUserHasDmRelays && articleRecipientHasDmRelays + zapPrefsRepo = feedViewModel.zapPrefs, + canPrivateZap = feedViewModel.hasLocalKeypair && articleUserHasDmRelays && articleRecipientHasDmRelays, + recipientPubkey = articleZapTarget?.pubkey, + profileLookup = { feedViewModel.profileRepo.get(it) } ) } @@ -2426,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 }, @@ -2487,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, @@ -2609,10 +2693,16 @@ 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. - 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) { @@ -2698,10 +2788,17 @@ fun WispNavHost( application = app, interfacePrefs = interfacePrefs, onBack = { navController.popBackStack() }, - onChanged = onInterfaceChanged + onChanged = onInterfaceChanged, + 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( @@ -3174,8 +3271,11 @@ 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 + forcePrivate = notifZapTarget?.id?.let { feedViewModel.eventRepo.isPrivate(it) } == true, + recipientPubkey = notifZapTarget?.pubkey, + profileLookup = { feedViewModel.profileRepo.get(it) } ) } @@ -3193,7 +3293,10 @@ fun WispNavHost( rumorId = target.rumorId.ifEmpty { null } ) }, - onGoToWallet = { navController.navigate(Routes.WALLET) } + onGoToWallet = { navController.navigate(Routes.WALLET) }, + zapPrefsRepo = feedViewModel.zapPrefs, + recipientPubkey = notifDmZapTarget?.senderPubkey, + profileLookup = { feedViewModel.profileRepo.get(it) } ) } @@ -3294,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/nostr/Nip78.kt b/app/src/main/kotlin/com/wisp/app/nostr/Nip78.kt index 77261327..82b237f9 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,97 @@ 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( + // 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, + 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 (e: Exception) { + android.util.Log.w("AppSettingsSync", "decryptAppSettings exception: ${e.javaClass.simpleName}: ${e.message}", e) + 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..69e90142 --- /dev/null +++ b/app/src/main/kotlin/com/wisp/app/repo/AppSettingsRepository.kt @@ -0,0 +1,283 @@ +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 customEmojiRepo: CustomEmojiRepository +) { + 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() } + customEmojiRepo.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 + 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(), + 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() = 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() + } 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 + + // 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 = launch { + pool.eoseSignals.collect { id -> + if (id == subId) eoseCount++ + } + } + 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) { + delay(150) + if (eoseCount >= minEose) break + } + } + 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 } + 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) + 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)}") + } + + /** + * 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 + 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) } + } finally { + interfacePrefs.onSyncedFieldChanged = iface + fiatPrefs.onSyncedFieldChanged = fiat + zapPrefs.onSyncedFieldChanged = zap + customEmojiRepo.onSyncedFieldChanged = emoji + } + } + + /** 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/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/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..94480278 100644 --- a/app/src/main/kotlin/com/wisp/app/repo/InterfacePreferences.kt +++ b/app/src/main/kotlin/com/wisp/app/repo/InterfacePreferences.kt @@ -13,43 +13,74 @@ class InterfacePreferences(context: Context) { } } - companion object { - val postUndoTimerOptions = listOf(5, 10, 15, 20, 30) - } - 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 +89,103 @@ 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() + + // ── 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() + } + + // ── 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 + } /** Reset all interface preferences to defaults (called on full logout). */ fun reset() { @@ -85,6 +203,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..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), @@ -44,6 +76,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 +95,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 { @@ -70,7 +113,30 @@ 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 + * 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/component/ActionBar.kt b/app/src/main/kotlin/com/wisp/app/ui/component/ActionBar.kt index e1ba35d5..870ac092 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 @@ -57,8 +59,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) @@ -71,6 +75,7 @@ fun ActionBar( onQuote: () -> Unit = {}, hasUserReposted: Boolean = false, onZap: () -> Unit = {}, + onZapLongPress: (() -> Unit)? = null, hasUserZapped: Boolean = false, onAddToList: () -> Unit = {}, isInList: Boolean = false, @@ -209,12 +214,45 @@ 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) } + val haptics = LocalHapticFeedback.current + 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 + // 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 + ) ) { 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 } @@ -282,50 +320,69 @@ 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() } + // 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) { - 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) + } } } diff --git a/app/src/main/kotlin/com/wisp/app/ui/component/PostCard.kt b/app/src/main/kotlin/com/wisp/app/ui/component/PostCard.kt index 06f139e5..34103353 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/component/PostCard.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/component/PostCard.kt @@ -129,6 +129,7 @@ fun PostCard( hasUserReposted: Boolean = false, repostCount: Int = 0, onZap: () -> Unit = {}, + onZapLongPress: (() -> Unit)? = null, onZapDisabledTap: () -> Unit = {}, zapEnabled: Boolean = true, hasUserZapped: Boolean = false, @@ -822,6 +823,9 @@ fun PostCard( hasUserReposted = hasUserReposted, repostCount = repostCount, onZap = onZap, + // Self-zap short-circuit: long-press also disabled + // when zapEnabled is false. iOS does the same. + onZapLongPress = onZapLongPress, hasUserZapped = hasUserZapped, onAddToList = onAddToList, isInList = isInList, @@ -835,7 +839,10 @@ fun PostCard( unicodeEmojis = unicodeEmojis, onOpenEmojiLibrary = onOpenEmojiLibrary, isPrivate = isPrivate, - zapEnabled = zapEnabled, + // Self-zap disabled: render at low opacity, both tap + // and long-press become no-ops (the latter via + // `zapEnabled` short-circuit in ActionBar). + zapEnabled = zapEnabled && !isOwnEvent, onZapDisabledTap = onZapDisabledTap, modifier = Modifier.weight(1f) ) diff --git a/app/src/main/kotlin/com/wisp/app/ui/component/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 bb888fae..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 @@ -1,22 +1,11 @@ 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.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 @@ -24,68 +13,83 @@ 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.offset +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.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.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.CompositionLocalProvider +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.scale -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.drawscope.DrawScope -import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.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 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,26 +97,48 @@ 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 — 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. 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. + * + * 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, 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). @@ -121,7 +147,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 +162,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,864 +172,947 @@ fun ZapDialog( } val context = LocalContext.current + val clipboard = LocalClipboardManager.current + val scope = rememberCoroutineScope() + val accent = WispThemeColors.zapColor + val fiatPrefs = remember { FiatPreferences.get(context) } val fiatMode by fiatPrefs.fiatMode.collectAsState() val fiatCurrency by fiatPrefs.currency.collectAsState() - var presets by remember { mutableStateOf(ZapPreferences(context).getPresets().sortedBy { it.amountSats }) } + val interfacePrefs = remember { com.wisp.app.repo.InterfacePreferences(context) } + var presets by remember { mutableStateOf(zapPrefsRepo.getPresets().sortedBy { it.amountSats }) } var selectedPreset by remember { mutableStateOf(presets.firstOrNull()) } var isCustom by remember { mutableStateOf(false) } - var customAmount by remember { mutableStateOf("") } + // TextFieldValue so we can pre-select the seeded amount on focus — + // the first keystroke then replaces the whole seed, matching the + // iOS "first-keystroke-replaces-seed" UX. + var customAmountTfv by remember { mutableStateOf(TextFieldValue("")) } + val customAmount = customAmountTfv.text var message by remember { mutableStateOf("") } var isAnonymous by remember { mutableStateOf(false) } var isPrivate by remember(forcePrivate) { mutableStateOf(forcePrivate) } - var editMode by remember { mutableStateOf(false) } + var instantZapsEnabled by remember { mutableStateOf(interfacePrefs.isQuickZapEnabled()) } + var showLargeAmountConfirm by remember { mutableStateOf(false) } + var showEditPresetsSheet by remember { mutableStateOf(false) } + var privacyMenuExpanded by remember { mutableStateOf(false) } + val amountFocusRequester = remember { FocusRequester() } + + val recipientProfile = recipientPubkey?.let { profileLookup(it) } + + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + fun closeSheet() { + scope.launch { sheetState.hide() }.invokeOnCompletion { onDismiss() } + } LaunchedEffect(initialSatsHint) { val hint = initialSatsHint ?: return@LaunchedEffect val h = hint.toLong().coerceAtLeast(1L) - val fromPrefs = ZapPreferences(context).getPresets().sortedBy { it.amountSats } - val match = fromPrefs.find { it.amountSats == h } + val match = presets.find { it.amountSats == h } if (match != null) { selectedPreset = match isCustom = false message = match.message } else { isCustom = true - customAmount = h.toString() + seedCustomAmount(h.toString()) { customAmountTfv = it } message = "" } } - val effectiveAmount = if (isCustom) { + // Seed amount from the configured instant-zap amount on first open. + // Auto-focus the field after the sheet's mount + transition has + // settled (~450ms) — matches the iOS deferral. The seed is selected + // so the first keystroke replaces it entirely. + LaunchedEffect(Unit) { + if (initialSatsHint == null) { + val seedSats = if (fiatMode) { + val major = interfacePrefs.getQuickZapAmountFiat() + (major * 100.0).toLong().coerceAtLeast(0L) + } else { + interfacePrefs.getQuickZapAmountSats() + } + if (seedSats > 0) { + isCustom = true + seedCustomAmount(seedSats.toString()) { customAmountTfv = it } + message = interfacePrefs.getQuickZapMessage() + } + } + delay(450) + runCatching { amountFocusRequester.requestFocus() } + } + + val effectiveAmount: Long = if (isCustom) { if (fiatMode) { - // Register-style: customAmount is a digit-only string that's - // interpreted as cents (last two digits). "21" → $0.21, - // "2100" → $21.00. val cents = customAmount.toLongOrNull() ?: 0L - if (cents > 0) { - ExchangeRateRepository.fiatToSats(cents.toDouble() / 100.0, fiatCurrency) ?: 0L - } else 0L + if (cents > 0) ExchangeRateRepository.fiatToSats(cents.toDouble() / 100.0, fiatCurrency) ?: 0L else 0L } else { customAmount.toLongOrNull() ?: 0L } } else { selectedPreset?.amountSats ?: 0L } - val effectiveMessage = if (isCustom) message else (selectedPreset?.message ?: "") + val overHardCap = effectiveAmount > ZAP_HARD_CAP_SATS + val canSavePreset = isCustom && effectiveAmount > 0 && + presets.none { it.amountSats == effectiveAmount } && + presets.size < 8 - Dialog( + ModalBottomSheet( onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false) + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface, + // Drag handle replaces the iOS "swipe down" affordance. ) { - Surface( + // Two-row stack: scrollable content on top, pinned Zap button + // at the bottom. `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() - .padding(horizontal = 24.dp), - shape = RoundedCornerShape(28.dp), - color = WispThemeColors.backgroundColor, - tonalElevation = 8.dp + .fillMaxSize() + .imePadding() ) { - Box { - // Background lightning effect - LightningBackground( - modifier = Modifier - .matchParentSize() - .clip(RoundedCornerShape(28.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f, fill = false) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + // ── 1. Toolbar ────────────────────────────────────────── + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + PillButton(text = stringResource(R.string.btn_close), onClick = { closeSheet() }) + PillButton( + text = "Presets", + onClick = { showEditPresetsSheet = 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)) - - // 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 = { + // ── 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 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( - if (fiatMode) { - stringResource(R.string.placeholder_amount_currency, currency?.symbol ?: "") - } else { - stringResource(R.string.placeholder_amount_sats) - } + "0", + style = heroStyle.copy(color = accent.copy(alpha = 0.35f)) ) - }, - 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)) } + inner() } - 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 - ) ) + } + Text( + if (fiatMode) ExchangeRateRepository.currencyFor(fiatCurrency).code else "sats", + color = accent.copy(alpha = 0.75f), + style = MaterialTheme.typography.titleSmall + ) + } - // Clear message when switching away from a preset with a saved message - // (message is pre-filled when selecting a preset with a message) + // ── 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 + // 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). + if (preset.message.isNotEmpty() && message.isBlank()) { + message = preset.message + } + } + ) + } + CustomPlusPill( + label = if (isCustom && effectiveAmount > 0) + AmountFormatter.formatShort(effectiveAmount, context) + else "Custom", + selected = isCustom, + accent = accent, + showPlus = canSavePreset, + onClick = { + isCustom = true + if (effectiveAmount == 0L) { + customAmountTfv = TextFieldValue("") + } else { + // Re-seed and select-all so first keystroke replaces. + seedCustomAmount(customAmount) { customAmountTfv = it } + } + runCatching { amountFocusRequester.requestFocus() } + }, + onPlusClick = { + // Save the current custom amount as a new preset + if (canSavePreset) { + presets = zapPrefsRepo.addPreset( + ZapPreset(effectiveAmount, message.trim()) + ).sortedBy { it.amountSats } + } + } + ) + } - Spacer(Modifier.height(16.dp)) + // ── 5. 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. + // ── 6. Privacy dropdown ───────────────────────────────── + if (!forcePrivate) { + Box(modifier = Modifier.fillMaxWidth()) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { privacyMenuExpanded = true }, + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(12.dp) + ) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically ) { + val (icon, label, helper) = when { + isPrivate -> Triple(Icons.Filled.Lock, "Private", "Recipient only — sent via DM-relay route.") + isAnonymous -> Triple(Icons.Outlined.VisibilityOff, "Anonymous", "Recipient won't see your identity.") + else -> Triple(Icons.Outlined.Visibility, "Public", null) + } Icon( - imageVector = Icons.Outlined.VisibilityOff, + icon, contentDescription = null, - tint = LightningOrange, - modifier = Modifier.size(16.dp) + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(18.dp) ) - Spacer(Modifier.width(6.dp)) - Text( - text = stringResource(R.string.zap_private_locked_for_private_reply), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) + Spacer(Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(label, style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface) + if (helper != null) { + Text( + helper, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Icon( + Icons.Filled.KeyboardArrowDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) ) } - } else { - // Anonymous toggle - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + } + DropdownMenu( + expanded = privacyMenuExpanded, + onDismissRequest = { privacyMenuExpanded = false } ) { - Text( - text = stringResource(R.string.btn_anonymous), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface + DropdownMenuItem( + text = { Text("Public") }, + leadingIcon = { Icon(Icons.Outlined.Visibility, null) }, + onClick = { + isPrivate = false + isAnonymous = false + privacyMenuExpanded = false + } ) - Switch( - checked = isAnonymous, - onCheckedChange = { - isAnonymous = it - if (it) isPrivate = false - }, - colors = SwitchDefaults.colors( - checkedThumbColor = LightningOrange, - checkedTrackColor = LightningOrange.copy(alpha = 0.5f), - uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant, - uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant, - uncheckedBorderColor = MaterialTheme.colorScheme.outline - ) + DropdownMenuItem( + text = { Text("Anonymous") }, + leadingIcon = { Icon(Icons.Outlined.VisibilityOff, null) }, + onClick = { + isAnonymous = true + isPrivate = false + privacyMenuExpanded = false + } ) - } - - // Private toggle - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column { - Text( - text = stringResource(R.string.btn_private), - style = MaterialTheme.typography.bodyMedium, - color = if (canPrivateZap) MaterialTheme.colorScheme.onSurface - else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) + if (canPrivateZap) { + DropdownMenuItem( + text = { Text("Private") }, + leadingIcon = { Icon(Icons.Filled.Lock, null) }, + onClick = { + isPrivate = true + isAnonymous = false + privacyMenuExpanded = false + } ) - if (!canPrivateZap) { - Text( - text = stringResource(R.string.zap_both_parties_need_dm_relays), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) - ) - } } - Switch( - checked = isPrivate, - onCheckedChange = { - isPrivate = it - if (it) isAnonymous = false - }, - enabled = canPrivateZap, - colors = SwitchDefaults.colors( - checkedThumbColor = LightningOrange, - checkedTrackColor = LightningOrange.copy(alpha = 0.5f), - uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant, - uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant, - uncheckedBorderColor = MaterialTheme.colorScheme.outline - ) - ) } - } // end !forcePrivate + } + } - Spacer(Modifier.height(16.dp)) + // ── 7. 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 + ) + ) + } + } - // Action buttons - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - TextButton( - onClick = onDismiss, - modifier = Modifier.weight(1f) - ) { - Text(stringResource(R.string.btn_cancel)) - } + } // end scrollable content Column - Button( - onClick = { - onZap(effectiveAmount * 1000, effectiveMessage.ifEmpty { message }, isAnonymous, isPrivate) - }, - enabled = effectiveAmount > 0, - modifier = Modifier.weight(2f), - colors = ButtonDefaults.buttonColors( - containerColor = LightningOrange, - contentColor = Color.White - ), - shape = RoundedCornerShape(16.dp) - ) { - Icon( - painter = painterResource( - if (fiatMode) R.drawable.ic_coin_stack else R.drawable.ic_bolt - ), - contentDescription = null, - modifier = Modifier.size(15.dp) - ) - Spacer(Modifier.width(6.dp)) - Text( - if (fiatMode) { - stringResource( - R.string.zap_x_amount, - AmountFormatter.formatShort(effectiveAmount, context) - ) - } else { - stringResource(R.string.zap_x_sats, effectiveAmount) - }, - fontWeight = FontWeight.Bold - ) + // ── 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. + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(top = 12.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (overHardCap) { + Text( + "Max ${"%,d".format(ZAP_HARD_CAP_SATS)} sats per zap", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + } + Button( + onClick = { + if (effectiveAmount > ZAP_SOFT_CONFIRM_SATS) { + showLargeAmountConfirm = true + } else { + onZap(effectiveAmount * 1000, effectiveMessage.ifEmpty { message }, isAnonymous, isPrivate) } - } + }, + enabled = effectiveAmount > 0 && !overHardCap, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.buttonColors( + containerColor = accent, + contentColor = Color.White, + disabledContainerColor = accent.copy(alpha = 0.35f) + ) + ) { + Icon( + painter = painterResource( + if (fiatMode) R.drawable.ic_coin_stack else R.drawable.ic_bolt + ), + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + if (fiatMode) "Zap ${AmountFormatter.formatShort(effectiveAmount, context)}" + else "Zap ${"%,d".format(effectiveAmount)} sats", + fontWeight = FontWeight.Bold, + fontSize = 17.sp + ) } } } } + // 10K-sat soft-confirmation dialog. Large zaps surface a "double-check + // before sending" prompt so a stray preset tap doesn't drain a wallet. + if (showLargeAmountConfirm) { + AlertDialog( + onDismissRequest = { showLargeAmountConfirm = false }, + title = { Text("Zap %,d sats?".format(effectiveAmount)) }, + text = { Text("This is a large amount, double-check before sending.") }, + confirmButton = { + Button( + onClick = { + showLargeAmountConfirm = false + onZap(effectiveAmount * 1000, effectiveMessage.ifEmpty { message }, isAnonymous, isPrivate) + }, + colors = ButtonDefaults.buttonColors(containerColor = accent) + ) { Text("Send", fontWeight = FontWeight.Bold) } + }, + dismissButton = { + TextButton(onClick = { showLargeAmountConfirm = false }) { + Text(stringResource(R.string.btn_cancel)) + } + } + ) + } + + if (showEditPresetsSheet) { + EditPresetsSheet( + initial = presets, + accent = accent, + onDismiss = { showEditPresetsSheet = false }, + onSave = { newList -> + zapPrefsRepo.setPresets(newList) + presets = newList.sortedBy { it.amountSats } + showEditPresetsSheet = false + } + ) + } } -/** - * Register-style sanitiser: digits only. The fiat-mode amount field is - * interpreted as integer cents (last two digits = cents place), so - * decimal points and other punctuation in user input are stripped — the - * decimal in the displayed string ("$0.21") is presentation-only. - */ -private fun sanitizeFiatInput(text: String): String = - text.filter { it.isDigit() } +// ─── Helpers ───────────────────────────────────────────────────────────── -/** - * Format a digit-only string as `$X.XX` register-style — last two digits - * are cents, everything before them is whole dollars. "21" → "$0.21", - * "2100" → "$21.00". Used for the empty-field placeholder; the live - * field display goes through [CentsVisualTransformation] so Compose's - * cursor mapping works correctly. - */ -private fun formatRegisterCents(digits: String, currency: FiatCurrency): String { - val cents = digits.toLongOrNull() ?: 0L - val dollars = cents.toDouble() / 100.0 - val formatter = java.text.DecimalFormat("#,##0.00") - return "${currency.symbol}${formatter.format(dollars)}" -} +private const val ZAP_SOFT_CONFIRM_SATS = 10_000L +private const val ZAP_HARD_CAP_SATS = 1_000_000L /** - * Renders a digit-only field value as the formatted register-style - * dollar string and pegs the cursor to the end. The previous approach - * — binding the TextField directly to the formatted string and - * re-formatting in `onValueChange` — broke backspace on Android: when - * Compose's internal text changed from "$0.2" (after the user removed - * the last char) to our re-formatted "$0.02", Compose couldn't map the - * old cursor position into the new string and effectively snapped it - * to the start, so subsequent backspaces just moved the cursor instead - * of deleting. With a [VisualTransformation] the field is bound to the - * raw digits, the cursor lives in raw-string coordinates, and the - * `OffsetMapping` always points to the end of the formatted view. + * 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 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 +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) } - return TransformedText(AnnotatedString(formatted), mapping) } + 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. + * 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 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 "" +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`. + */ @Composable -private fun AnimatedBoltHeader(fiatMode: Boolean = false) { - val infiniteTransition = rememberInfiniteTransition(label = "bolt") - - val glowAlpha by infiniteTransition.animateFloat( - initialValue = 0.3f, - targetValue = 0.8f, - animationSpec = infiniteRepeatable( - animation = tween(1200, easing = LinearEasing), - repeatMode = RepeatMode.Reverse - ), - label = "glow" - ) - - val boltScale by animateFloatAsState( - targetValue = 1f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessLow - ), - label = "boltScale" - ) - - val zapColor = WispThemeColors.zapColor - - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.size(64.dp) +private fun PillButton( + text: String, + onClick: () -> Unit, + contentColor: Color = MaterialTheme.colorScheme.onSurface, + borderColor: Color = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f) +) { + Surface( + modifier = Modifier.clickable(onClick = onClick), + shape = CircleShape, + color = Color.Transparent, + border = BorderStroke(1.dp, borderColor) ) { - // Glow circle behind bolt - Box( - modifier = Modifier - .size(56.dp) - .alpha(glowAlpha) - .background( - brush = Brush.radialGradient( - colors = listOf( - zapColor.copy(alpha = 0.4f), - zapColor.copy(alpha = 0.1f), - Color.Transparent - ) - ), - shape = CircleShape - ) - ) - - // Bolt / coin-stack icon depending on mode - Icon( - painter = painterResource( - if (fiatMode) R.drawable.ic_coin_stack else R.drawable.ic_bolt - ), - contentDescription = null, - tint = zapColor, - modifier = Modifier - .size(30.dp) - .scale(boltScale) + Text( + text, + style = MaterialTheme.typography.bodyMedium, + color = contentColor, + modifier = Modifier.padding(horizontal = 18.dp, vertical = 8.dp) ) } } +/** Single preset chip in the FlowRow. Selected = filled accent + white. */ @Composable -private fun LightningBackground(modifier: Modifier = Modifier) { - val infiniteTransition = rememberInfiniteTransition(label = "lightning_bg") - - val phase by infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween(4000, easing = LinearEasing), - repeatMode = RepeatMode.Restart - ), - label = "phase" - ) - - val zapColor = WispThemeColors.zapColor - - // Stable random values for bolt positions - val boltData = remember { - List(5) { i -> - Triple( - Random(i * 42).nextFloat(), // x position (0..1) - Random(i * 42 + 1).nextFloat(), // y position (0..1) - Random(i * 42 + 2).nextFloat() * 0.3f + 0.1f // size scale - ) - } - } - - Canvas(modifier = modifier) { - val w = size.width - val h = size.height - - // Subtle gradient overlay at the top - drawRect( - brush = Brush.verticalGradient( - colors = listOf( - zapColor.copy(alpha = 0.03f), - Color.Transparent - ), - startY = 0f, - endY = h * 0.4f - ) +private fun PresetPill( + label: String, + selected: Boolean, + accent: Color, + onClick: () -> Unit +) { + Surface( + modifier = Modifier.clickable(onClick = onClick), + shape = CircleShape, + color = if (selected) accent else MaterialTheme.colorScheme.surfaceVariant + ) { + Text( + label, + style = MaterialTheme.typography.labelLarge, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + color = if (selected) Color.White else MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 18.dp, vertical = 8.dp) ) - - // Animated mini lightning bolts scattered in background - boltData.forEach { (xFrac, yFrac, scale) -> - val animatedAlpha = (sin((phase + xFrac) * Math.PI * 2).toFloat() * 0.5f + 0.5f) * 0.06f - val cx = xFrac * w - val cy = yFrac * h - val boltSize = 20f * scale - - drawMiniBolt( - center = Offset(cx, cy), - size = boltSize, - color = zapColor.copy(alpha = animatedAlpha) - ) - } } } -private fun DrawScope.drawMiniBolt(center: Offset, size: Float, color: Color) { - val path = Path().apply { - moveTo(center.x, center.y - size) - lineTo(center.x - size * 0.4f, center.y + size * 0.1f) - lineTo(center.x + size * 0.1f, center.y + size * 0.1f) - lineTo(center.x - size * 0.1f, center.y + size) - lineTo(center.x + size * 0.4f, center.y - size * 0.1f) - lineTo(center.x - size * 0.1f, center.y - size * 0.1f) - close() - } - drawPath(path, color, style = Fill) -} - +/** Custom-amount chip. When selected AND not yet in the preset list, + * the trailing + badge becomes tappable to save the value as a preset. */ @Composable -private fun ZapPresetChip( - preset: ZapPreset, - isSelected: Boolean, - editMode: Boolean, +private fun CustomPlusPill( + label: String, + selected: Boolean, + accent: Color, + showPlus: Boolean, onClick: () -> Unit, - onRemove: () -> Unit + onPlusClick: () -> Unit ) { - val scale by animateFloatAsState( - targetValue = if (isSelected) 1.05f else 1f, - animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy), - label = "chipScale" - ) - - Box { - Surface( - modifier = Modifier - .scale(scale) - .clip(RoundedCornerShape(16.dp)) - .clickable(onClick = onClick), - shape = RoundedCornerShape(16.dp), - color = if (isSelected) LightningOrange else MaterialTheme.colorScheme.surfaceVariant, - border = if (isSelected) { - androidx.compose.foundation.BorderStroke( - 1.5.dp, - Brush.linearGradient(listOf(LightningYellow, LightningOrange)) - ) - } else { - null - }, - shadowElevation = if (isSelected) 4.dp else 0.dp + Surface( + modifier = Modifier.clickable(onClick = onClick), + shape = CircleShape, + color = if (selected) accent else MaterialTheme.colorScheme.surfaceVariant + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 18.dp, end = if (showPlus) 4.dp else 18.dp, top = 4.dp, bottom = 4.dp) ) { - Row( - modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - if (isSelected) { - Icon( - painter = painterResource(R.drawable.ic_bolt), - contentDescription = null, - modifier = Modifier.size(12.dp), - tint = Color.White - ) - Spacer(Modifier.width(3.dp)) - } - val chipContext = LocalContext.current - Text( - text = AmountFormatter.formatShort(preset.amountSats, chipContext), - style = MaterialTheme.typography.labelLarge, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, - color = if (isSelected) Color.White else MaterialTheme.colorScheme.onSurfaceVariant - ) - if (preset.message.isNotEmpty()) { - Spacer(Modifier.width(4.dp)) + Text( + label, + style = MaterialTheme.typography.labelLarge, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + color = if (selected) Color.White else MaterialTheme.colorScheme.onSurface + ) + if (showPlus) { + Spacer(Modifier.width(6.dp)) + Box( + modifier = Modifier + .size(22.dp) + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.18f)) + .clickable(onClick = onPlusClick), + contentAlignment = Alignment.Center + ) { Icon( - Icons.AutoMirrored.Outlined.Message, - contentDescription = null, - modifier = Modifier.size(10.dp), - tint = if (isSelected) Color.White.copy(alpha = 0.7f) - else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + Icons.Filled.Add, + contentDescription = "Save as preset", + tint = Color.White, + modifier = Modifier.size(14.dp) ) } } } - - // Remove badge in edit mode - if (editMode) { - Surface( - modifier = Modifier - .align(Alignment.TopEnd) - .offset(x = 4.dp, y = (-4).dp) - .size(20.dp) - .clip(CircleShape) - .clickable(onClick = onRemove), - shape = CircleShape, - color = MaterialTheme.colorScheme.error - ) { - Icon( - Icons.Filled.Close, - contentDescription = stringResource(R.string.btn_remove), - modifier = Modifier - .padding(2.dp) - .size(16.dp), - tint = Color.White - ) - } - } } } -@Composable -private fun ZapChipButton( - label: String, - isSelected: Boolean, - onClick: () -> Unit -) { - Surface( - modifier = Modifier - .clip(RoundedCornerShape(16.dp)) - .clickable(onClick = onClick), - shape = RoundedCornerShape(16.dp), - color = if (isSelected) LightningOrange else MaterialTheme.colorScheme.surfaceVariant, - border = if (isSelected) { - androidx.compose.foundation.BorderStroke( - 1.5.dp, - Brush.linearGradient(listOf(LightningYellow, LightningOrange)) - ) - } else { - null +/** + * Translate raw Lightning/SDK error strings into plain user-facing + * copy. Mirrors iOS `ZapAnimationStore.friendlyMessage(for:)`. + * Fallback: extract the substring between the first `("` and `")` + * (Swift enum description wrapper) if present; otherwise pass through + * the raw error. + */ +internal fun friendlyZapErrorMessage(raw: String?): String { + val msg = raw?.trim().orEmpty() + if (msg.isEmpty()) return "Zap failed." + val lower = msg.lowercase() + return when { + "insufficient funds" in lower || "insufficient balance" in lower -> + "Not enough sats in your wallet." + "no route" in lower || "route not found" in lower || "unreachable" in lower -> + "Couldn't find a payment route to the recipient. Try again later." + "expired" in lower || "invoice has expired" in lower -> + "The lightning invoice expired before it could be paid. Try again." + "timeout" in lower || "timed out" in lower -> + "The payment timed out. Check your connection and try again." + "no lud16" in lower || "no lightning address" in lower -> + "This account doesn't have a lightning address." + "lnurl" in lower && "400" in lower -> + "The recipient's lightning provider rejected this zap. Try a different amount." + "amount too small" in lower || "below minimum" in lower -> + "Amount is below the recipient's minimum. Try a larger zap." + "amount too large" in lower || "above maximum" in lower -> + "Amount is above the recipient's maximum. Try a smaller zap." + else -> { + val start = msg.indexOf("(\"") + val end = msg.indexOf("\")", startIndex = (start + 2).coerceAtLeast(0)) + if (start >= 0 && end > start + 2) msg.substring(start + 2, end) else msg } - ) { - Text( - text = label, - modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), - style = MaterialTheme.typography.labelLarge, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, - color = if (isSelected) Color.White else MaterialTheme.colorScheme.onSurfaceVariant - ) } } +/** + * 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( - onSave: (ZapPreset) -> Unit, - onDismiss: () -> Unit +private fun EditPresetsSheet( + initial: List, + accent: Color, + onDismiss: () -> Unit, + onSave: (List) -> Unit ) { - var amount by remember { mutableStateOf("") } - var presetMessage by remember { mutableStateOf("") } + 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 } - AlertDialog( + ModalBottomSheet( 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)) - } - }, - 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(), - singleLine = true, - shape = RoundedCornerShape(12.dp), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = LightningOrange, - focusedLabelColor = LightningOrange, - cursorColor = LightningOrange - ) + 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(12.dp)) - OutlinedTextField( - value = presetMessage, - onValueChange = { new -> if (!NsecPasteGuard.blockIfNsec(presetMessage, new)) presetMessage = new }, - label = { Text(stringResource(R.string.placeholder_message_optional)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - shape = RoundedCornerShape(12.dp), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = LightningOrange, - focusedLabelColor = LightningOrange, - cursorColor = LightningOrange - ) + 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 = LightningOrange) + 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 3537fc4e..0e4d2525 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/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/DmConversationScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/DmConversationScreen.kt index f1b5c0f6..5554eab6 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(), @@ -639,7 +640,10 @@ fun DmConversationScreen( onGoToWallet = { 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 34902103..a5a6be12 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 @@ -328,6 +328,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, @@ -547,7 +556,10 @@ fun FeedScreen( viewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) }, onGoToWallet = onWallet, - canPrivateZap = userHasDmRelays && recipientHasDmRelays + zapPrefsRepo = viewModel.zapPrefs, + canPrivateZap = userHasDmRelays && recipientHasDmRelays, + recipientPubkey = zapTargetEvent?.pubkey, + profileLookup = { viewModel.profileRepo.get(it) } ) } @@ -575,7 +587,10 @@ fun FeedScreen( zapPollTarget = null viewModel.sendZapPollVote(pollEvent, optionIndex, amountMsats, message, isAnonymous) }, - onGoToWallet = onWallet + onGoToWallet = onWallet, + zapPrefsRepo = viewModel.zapPrefs, + recipientPubkey = pollEvent.pubkey, + profileLookup = { viewModel.profileRepo.get(it) } ) } @@ -1223,6 +1238,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) }, @@ -1324,6 +1340,7 @@ private fun FeedItem( onRepost: () -> Unit, onQuote: () -> Unit, onZap: () -> Unit, + onZapLongPress: (() -> Unit)? = null, onAddToList: () -> Unit = {}, onPin: () -> Unit = {}, onDelete: () -> Unit = {}, @@ -1467,6 +1484,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/InterfaceScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/InterfaceScreen.kt index 110da1cb..3c3fa24b 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,10 @@ 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.material.icons.automirrored.filled.KeyboardArrowRight +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 @@ -92,7 +96,9 @@ fun InterfaceScreen( application: Application, interfacePrefs: InterfacePreferences, onBack: () -> Unit, - onChanged: () -> Unit + onChanged: () -> Unit, + onSyncRequested: (() -> Unit)? = null, + onOpenDeveloperTools: (() -> Unit)? = null ) { var isLargeText by remember { mutableStateOf(interfacePrefs.isLargeText()) } var newNotesHidden by remember { mutableStateOf(interfacePrefs.isNewNotesButtonHidden()) } @@ -662,6 +668,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() @@ -753,6 +793,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) { @@ -794,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 { 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 5ed61e69..1343a74c 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 b97e729e..631eff8f 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 ee8ccf25..60a064f9 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,7 +295,10 @@ fun UserProfileScreen( onZap(event, amountMsats, message, isAnonymous, isPrivate) }, onGoToWallet = onWallet, - canPrivateZap = resolvedCanPrivateZap + zapPrefsRepo = zapPrefs, + canPrivateZap = resolvedCanPrivateZap, + recipientPubkey = zapTargetEvent?.pubkey, + profileLookup = { eventRepo?.getProfileData(it) } ) } @@ -307,7 +312,11 @@ fun UserProfileScreen( onZapProfile?.invoke(amountMsats, message, isAnonymous) }, onGoToWallet = onWallet, - canPrivateZap = false + zapPrefsRepo = zapPrefs, + canPrivateZap = false, + // Profile zap — recipient is the profile being viewed. + recipientPubkey = profile?.pubkey, + profileLookup = { pk -> profile?.takeIf { it.pubkey == pk } } ) } @@ -868,6 +877,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 +1063,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 +1139,7 @@ fun UserProfileScreen( userReactionEmojis = userEmojis, hasUserReposted = hasUserReposted, onZap = { zapTargetEvent = event }, + onZapLongPress = { onZapInstant?.invoke(event) ?: run { zapTargetEvent = event } }, hasUserZapped = hasUserZapped, likeCount = likeCount, replyCount = replyCount, 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 a6f1f93d..2d3059a8 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 @@ -51,6 +51,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 @@ -119,11 +120,15 @@ 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 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 @@ -787,6 +792,50 @@ private fun WalletConnectionContent( ) } + 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( @@ -1221,7 +1270,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 { @@ -1248,7 +1303,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), @@ -1274,28 +1331,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 ───────────────────────────────────────── @@ -1354,7 +1389,20 @@ private fun WalletHomeContent( HorizontalDivider( color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f) ) - recentTransactions.take(1).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, balanceDisplay) } } @@ -2559,14 +2607,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), @@ -2574,6 +2622,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( @@ -2590,6 +2639,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, @@ -2768,36 +2885,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)) @@ -3324,17 +3491,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 @@ -3489,22 +3658,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 @@ -3851,18 +4066,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)) @@ -3874,7 +4091,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)) @@ -3896,7 +4113,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 ) @@ -3917,11 +4134,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 { @@ -4455,11 +4671,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, @@ -4467,7 +4693,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/FeedViewModel.kt b/app/src/main/kotlin/com/wisp/app/viewmodel/FeedViewModel.kt index 01dae422..c6c21862 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,12 @@ 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, + customEmojiRepo = customEmojiRepo + ).also { it.relayPool = relayPool } val nwcRepo = NwcRepository(app, relayPool, pubkeyHex) val sparkRepo = SparkRepository(app, pubkeyHex) val walletModeRepo = WalletModeRepository(app, pubkeyHex) @@ -329,7 +338,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 } 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 1aa20571..79b4fec0 100644 --- a/app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt +++ b/app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt @@ -761,6 +761,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 + } } } } @@ -818,16 +830,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 565d7414..35b44841 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -889,7 +889,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.