feat(wallet): cycle balance display between sats, fiat, and hidden

Ports iOS PR barrydeen/wisp-ios#166 to Android. Tapping the wallet
dashboard balance now cycles through three states instead of the
prior plain hide/show toggle:

1. SATS   — current default rendering ("21,000 sats" or app-wide
            fiat when [FiatPreferences.isFiatMode] is on).
2. FIAT   — wallet-screen-scoped fiat. Renders the balance + each tx
            row's amount/fee in the user's currently-selected fiat
            currency without flipping the app-wide fiat-mode flag, so
            feed sat counts elsewhere stay in sats.
3. HIDDEN — masks the dashboard balance AND every per-row amount +
            fee in the transaction history, useful for screenshots /
            shoulder-surfing scenarios.

Storage: new per-pubkey key `walletBalanceDisplay_<pubkey>` in the
existing `wisp_settings` SharedPreferences. The legacy global
`balance_hidden` Bool is read once per pubkey when no per-pubkey
entry exists (true → HIDDEN, false → SATS) and then a migrated value
is written; legacy key is left in place so older builds rolled back
keep their preference.

Storage-key format matches iOS, so the cross-platform port doc lives
on the iOS side (see PR #166). Companion enum
`WalletBalanceDisplayMode` carries the `next()` cycle helper and the
read/write helpers; both `WalletHomeContent` and the transaction
history `TransactionRow` read the same key.
This commit is contained in:
The Daniel
2026-05-22 17:18:34 -04:00
parent 6833fba03e
commit ca18158ec5
2 changed files with 230 additions and 51 deletions
@@ -0,0 +1,75 @@
package com.wisp.app.repo
import android.content.SharedPreferences
/**
* Tri-state balance display for the wallet dashboard. Tapping the
* balance cycles `SATS → FIAT → HIDDEN → SATS`. Persisted per wallet
* pubkey under the `walletBalanceDisplay_<pubkey>` key in the
* `wisp_settings` SharedPreferences file.
*
* `FIAT` is scoped to the wallet screen — it renders the balance in
* the user's currently-selected fiat currency
* ([FiatPreferences.getCurrency]) but does NOT flip the app-wide
* [FiatPreferences.isFiatMode] flag, so feed timestamps / sat counts
* elsewhere in the app still respect that global setting.
*
* `HIDDEN` masks the dashboard balance AND every per-row amount + fee
* in the transaction history view — useful for screenshots / shoulder-
* surfing scenarios.
*
* Mirrors iOS [feat/wallet-balance-toggle](https://github.com/barrydeen/wisp-ios/pull/166)
* with the same storage-key format so cross-platform agents stay in
* lockstep. Legacy Android global `balance_hidden` Bool is read once
* per pubkey when no per-pubkey entry exists, and the per-pubkey key
* is written from it (true → HIDDEN, false → SATS).
*/
enum class WalletBalanceDisplayMode {
SATS, FIAT, HIDDEN;
/** Next state in the tap cycle. */
fun next(): WalletBalanceDisplayMode = when (this) {
SATS -> FIAT
FIAT -> HIDDEN
HIDDEN -> SATS
}
companion object {
private const val KEY_PREFIX = "walletBalanceDisplay_"
private const val LEGACY_HIDDEN_KEY = "balance_hidden"
fun storageKey(pubkey: String): String = "$KEY_PREFIX$pubkey"
/**
* Read the persisted mode for [pubkey]. Falls back to legacy
* global `balance_hidden` Bool for the first read of a given
* pubkey, then writes the migrated value so subsequent reads
* don't depend on the legacy key staying in place. The legacy
* key itself is left untouched — older builds rolled back keep
* the prior preference intact.
*
* When [pubkey] is null (no signed-in account yet), returns
* the legacy global state (SATS / HIDDEN only) without
* touching storage.
*/
fun read(prefs: SharedPreferences, pubkey: String?): WalletBalanceDisplayMode {
if (pubkey.isNullOrBlank()) {
return if (prefs.getBoolean(LEGACY_HIDDEN_KEY, false)) HIDDEN else SATS
}
val key = storageKey(pubkey)
val raw = prefs.getString(key, null)
if (raw != null) {
return values().firstOrNull { it.name.equals(raw, ignoreCase = true) } ?: SATS
}
val initial = if (prefs.getBoolean(LEGACY_HIDDEN_KEY, false)) HIDDEN else SATS
prefs.edit().putString(key, initial.name.lowercase()).apply()
return initial
}
/** Persist [mode] for [pubkey]. No-op when [pubkey] is null. */
fun write(prefs: SharedPreferences, pubkey: String?, mode: WalletBalanceDisplayMode) {
if (pubkey.isNullOrBlank()) return
prefs.edit().putString(storageKey(pubkey), mode.name.lowercase()).apply()
}
}
}
@@ -145,6 +145,7 @@ import com.google.zxing.qrcode.QRCodeWriter
import com.wisp.app.BuildConfig
import com.wisp.app.R
import com.wisp.app.repo.BalanceUnit
import com.wisp.app.repo.WalletBalanceDisplayMode
import com.wisp.app.repo.FiatPreferences
import com.wisp.app.repo.WalletMode
import com.wisp.app.repo.WalletTransaction
@@ -348,6 +349,7 @@ fun WalletScreen(
recentTransactions = viewModel.transactions.collectAsState().value,
profileLookup = remember(profileKey) { { viewModel.getProfileData(it) } },
nwcNodeAlias = viewModel.nwcNodeAlias.collectAsState().value,
pubkey = viewModel.keyRepo.getPubkeyHex(),
modifier = Modifier.padding(padding)
)
}
@@ -453,6 +455,7 @@ fun WalletScreen(
onLoadMore = { viewModel.loadMoreTransactions() },
profileLookup = { viewModel.getProfileData(it) },
profileRefreshKey = profileKey,
pubkey = viewModel.keyRepo.getPubkeyHex(),
modifier = Modifier.padding(padding)
)
}
@@ -566,6 +569,7 @@ fun WalletScreen(
recentTransactions = viewModel.transactions.collectAsState().value,
profileLookup = remember(profileKey) { { viewModel.getProfileData(it) } },
nwcNodeAlias = viewModel.nwcNodeAlias.collectAsState().value,
pubkey = viewModel.keyRepo.getPubkeyHex(),
modifier = Modifier.padding(padding)
)
}
@@ -745,15 +749,23 @@ private fun WalletHomeContent(
recentTransactions: List<WalletTransaction> = emptyList(),
profileLookup: (String) -> com.wisp.app.nostr.ProfileData? = { null },
nwcNodeAlias: String? = null,
pubkey: String? = null,
modifier: Modifier = Modifier
) {
val balanceSats = balanceMsats / 1000
val context = LocalContext.current
val prefs = remember { context.getSharedPreferences("wisp_settings", android.content.Context.MODE_PRIVATE) }
var balanceHidden by remember { mutableStateOf(prefs.getBoolean("balance_hidden", false)) }
// Tri-state balance display (sats / fiat / hidden) — tap the
// dashboard balance to cycle. Per-pubkey storage; migrates the
// legacy global `balance_hidden` Bool on first read for a given
// pubkey. iOS port of feat/wallet-balance-toggle (wisp-ios #166).
var balanceDisplay by remember(pubkey) {
mutableStateOf(WalletBalanceDisplayMode.read(prefs, pubkey))
}
val balanceHidden = balanceDisplay == WalletBalanceDisplayMode.HIDDEN
val fiatPrefs = remember { FiatPreferences.get(context) }
val fiatMode by fiatPrefs.fiatMode.collectAsState()
@Suppress("unused_variable") val fiatCurrency by fiatPrefs.currency.collectAsState()
val fiatCurrency by fiatPrefs.currency.collectAsState()
val clipboard = remember { context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager }
val accent = WispThemeColors.zapColor
@@ -965,47 +977,86 @@ private fun WalletHomeContent(
Spacer(Modifier.weight(1f))
// ── Balance ─────────────────────────────────────────────────
// Tap to cycle sats → fiat → hidden. `fiat` is wallet-screen-
// scoped: it renders the balance in the user's currently-set
// fiat currency but does NOT flip the app-wide
// [FiatPreferences.isFiatMode] flag (still respected by feed
// counts / timestamps elsewhere).
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.clickable {
balanceHidden = !balanceHidden
prefs.edit().putBoolean("balance_hidden", balanceHidden).apply()
balanceDisplay = balanceDisplay.next()
WalletBalanceDisplayMode.write(prefs, pubkey, balanceDisplay)
}
) {
if (balanceHidden) {
Text(
"* * * * *",
style = MaterialTheme.typography.displayLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(4.dp))
Text(
stringResource(R.string.wallet_tap_to_reveal),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
if (fiatMode) {
when (balanceDisplay) {
WalletBalanceDisplayMode.HIDDEN -> {
Text(
AmountFormatter.formatShort(balanceSats, context),
"* * * * *",
style = MaterialTheme.typography.displayLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
} else {
Text(
"%,d".format(balanceSats),
style = MaterialTheme.typography.displayLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(4.dp))
Text(
stringResource(R.string.wallet_sats),
style = MaterialTheme.typography.bodyMedium,
stringResource(R.string.wallet_tap_to_reveal),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
WalletBalanceDisplayMode.FIAT -> {
val fiat = AmountFormatter.formatFiat(balanceSats, fiatCurrency)
if (fiat != null) {
Text(
fiat,
style = MaterialTheme.typography.displayLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
} else {
// Exchange-rate cache hasn't loaded yet — fall
// back to the sats display so the dashboard
// doesn't show a blank or a placeholder.
Text(
"%,d".format(balanceSats),
style = MaterialTheme.typography.displayLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(Modifier.height(4.dp))
Text(
stringResource(R.string.wallet_sats),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
WalletBalanceDisplayMode.SATS -> {
// App-wide fiat mode still wins when the user has
// it on AND the wallet display is in its default
// (sats) state — same behaviour as before this
// tri-state landed.
if (fiatMode) {
Text(
AmountFormatter.formatShort(balanceSats, context),
style = MaterialTheme.typography.displayLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
} else {
Text(
"%,d".format(balanceSats),
style = MaterialTheme.typography.displayLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(Modifier.height(4.dp))
Text(
stringResource(R.string.wallet_sats),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@@ -1144,7 +1195,7 @@ private fun WalletHomeContent(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)
)
recentTransactions.take(1).forEach { tx ->
TransactionRow(tx, profileLookup)
TransactionRow(tx, profileLookup, balanceDisplay)
}
}
} else {
@@ -2045,8 +2096,16 @@ private fun TransactionHistoryContent(
onLoadMore: () -> Unit = {},
profileLookup: (String) -> com.wisp.app.nostr.ProfileData?,
profileRefreshKey: Int = 0,
pubkey: String? = null,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val prefs = remember { context.getSharedPreferences("wisp_settings", android.content.Context.MODE_PRIVATE) }
// Mirror the dashboard's tri-state display mode on tx rows so a
// HIDDEN state masks both the dashboard balance AND every per-row
// amount + fee. iOS port keeps these in lockstep via the same
// per-pubkey storage key (`walletBalanceDisplay_<pubkey>`).
val displayMode = remember(pubkey) { WalletBalanceDisplayMode.read(prefs, pubkey) }
Column(
modifier = modifier.fillMaxSize()
) {
@@ -2101,7 +2160,7 @@ private fun TransactionHistoryContent(
else -> {
LazyColumn {
items(transactions) { tx ->
TransactionRow(tx, profileLookup)
TransactionRow(tx, profileLookup, displayMode)
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.outlineVariant
@@ -2137,13 +2196,17 @@ private fun TransactionHistoryContent(
@Composable
private fun TransactionRow(
tx: WalletTransaction,
profileLookup: (String) -> com.wisp.app.nostr.ProfileData?
profileLookup: (String) -> com.wisp.app.nostr.ProfileData?,
displayMode: WalletBalanceDisplayMode = WalletBalanceDisplayMode.SATS
) {
val isIncoming = tx.type == "incoming"
val amountSats = tx.amountMsats / 1000
val profile = tx.counterpartyPubkey?.let { profileLookup(it) }
val ctx = LocalContext.current
val fiatMode by FiatPreferences.get(ctx).fiatMode.collectAsState()
val fiatCurrency by FiatPreferences.get(ctx).currency.collectAsState()
val isHidden = displayMode == WalletBalanceDisplayMode.HIDDEN
val isWalletFiat = displayMode == WalletBalanceDisplayMode.FIAT
Row(
modifier = Modifier
@@ -2207,35 +2270,76 @@ private fun TransactionRow(
)
}
// Amount + fee
// Amount + fee. In HIDDEN mode every number is masked so a
// "show my wallet without showing the numbers" screenshot
// works. Wallet-screen FIAT mode renders amounts in the user's
// selected fiat currency without flipping the app-wide flag.
Column(horizontalAlignment = Alignment.End) {
Row(verticalAlignment = Alignment.CenterVertically) {
val sign = if (isIncoming) "+" else "-"
if (fiatMode) {
Text(
val signColor = if (isIncoming) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error
when {
isHidden -> Text(
"$sign* * *",
style = MaterialTheme.typography.titleMedium,
color = signColor
)
isWalletFiat -> {
val fiat = AmountFormatter.formatFiat(amountSats, fiatCurrency)
if (fiat != null) {
Text(
"$sign$fiat",
style = MaterialTheme.typography.titleMedium,
color = signColor
)
} else {
Text(
"$sign%,d".format(amountSats),
style = MaterialTheme.typography.titleMedium,
color = signColor
)
Spacer(Modifier.width(4.dp))
Text(
stringResource(R.string.wallet_sats),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
fiatMode -> Text(
"$sign${AmountFormatter.formatFull(amountSats, ctx)}",
style = MaterialTheme.typography.titleMedium,
color = if (isIncoming) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error
)
} else {
Text(
"$sign%,d".format(amountSats),
style = MaterialTheme.typography.titleMedium,
color = if (isIncoming) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error
)
Spacer(Modifier.width(4.dp))
Text(
stringResource(R.string.wallet_sats),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = signColor
)
else -> {
Text(
"$sign%,d".format(amountSats),
style = MaterialTheme.typography.titleMedium,
color = signColor
)
Spacer(Modifier.width(4.dp))
Text(
stringResource(R.string.wallet_sats),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
if (!isIncoming && tx.feeMsats > 0) {
val feeSats = tx.feeMsats / 1000
val feeText = when {
isHidden -> stringResource(R.string.wallet_fee, 0).replace("0", "***")
isWalletFiat -> {
val fiat = AmountFormatter.formatFiat(feeSats, fiatCurrency)
if (fiat != null) stringResource(R.string.wallet_fee_money, fiat)
else stringResource(R.string.wallet_fee, feeSats)
}
fiatMode -> stringResource(R.string.wallet_fee_money, AmountFormatter.formatFull(feeSats, ctx))
else -> stringResource(R.string.wallet_fee, feeSats)
}
Text(
if (fiatMode) stringResource(R.string.wallet_fee_money, AmountFormatter.formatFull(feeSats, ctx))
else stringResource(R.string.wallet_fee, feeSats),
feeText,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)