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:
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user