feat(mobile): privacy mode to mask money values (#2386)

* feat(mobile): add privacy mode to mask money values

Adds an app-wide "privacy mode" so users can hide monetary amounts from
over-the-shoulder view.

- PrivacyProvider (ChangeNotifier) backed by PreferencesService, so the
  choice persists across launches and every money widget rebuilds on
  toggle.
- MoneyMasker.mask() collapses an amount's numeric portion into a short
  fixed run of bullets while keeping the currency symbol and sign
  (e.g. CA$1,234.56 -> CA$••••). A fixed run avoids leaking the value's
  magnitude and reads cleanly without stray separators. Currency- and
  locale-agnostic — it operates on already-formatted strings.
- Masking applied at every money render site: net worth + per-currency
  totals + breakdown sheet (NetWorthCard), account balances (AccountCard,
  AccountDetailHeader, transaction form account selector), and transaction
  amounts (transactions list, recent transactions, calendar).
- Two entry points: a "Hide amounts" switch in Settings -> Security, and a
  quick eye toggle in the top bar (visible on every tab).

Tests: MoneyMasker unit tests (fixed-run mask, magnitude hidden, symbol/
sign kept, passthrough, idempotent) + a widget test asserting the net
worth masks/unmasks as the provider flips; account_card_test updated to
provide the new provider. flutter analyze: no new issues; full suite
(123) green.

* fix(mobile): address privacy-mode review feedback

- Startup masking (Codex P1): read the privacy preference in main() before
  runApp and seed PrivacyProvider with it, so the first frame already has
  the correct value — money is never briefly rendered unmasked for a user
  who enabled "Hide amounts". Provider stays fail-closed otherwise: starts
  masked, SureApp's no-arg default is masked, and a failed read keeps it
  masked. A late-completing initial load no longer clobbers an explicit
  user toggle.
- setHidden() reverts the in-memory state (and logs) if persistence fails,
  keeping the UI consistent with what's actually stored.
- Mask the cash-balance detail chip in AccountDetailHeader (was leaking
  the cash position in privacy mode).
- Privacy top-bar toggle gets a "Toggle privacy" tooltip + icon semantic
  label for accessibility (kept as an InkWell to match the adjacent
  settings control).
- Tests: assert fail-closed initial state; assert the exact masked count;
  test the persistence round-trip (set -> reload); add
  PreferencesService.resetForTest() and reset between tests so the cached
  singleton can't leak state.

125 tests pass; flutter analyze: no new issues.

* refactor(mobile): thread hideAmounts through calendar tiles

Per review: the calendar tile builders read PrivacyProvider via
context.read, relying implicitly on the parent build()'s context.watch to
rebuild them — fragile if a tile is later extracted or wrapped in a
RepaintBoundary. Pass hideAmounts down explicitly instead, matching the
recent_transactions_screen pattern:

- build() (context.watch) -> _buildCalendar -> _buildDayCell
- _showTransactionsDialog reads once when the modal opens ->
  _buildTransactionTile

No more context.read inside tile methods. 125 tests pass; analyze clean.

* fix(mobile): watch PrivacyProvider inside calendar dialog builder

Moving the hideAmounts read inside the showDialog builder and switching
from context.read to context.watch ensures the dialog re-masks transaction
amounts if the user toggles privacy mode while the dialog is open.
This commit is contained in:
ghost
2026-06-29 21:49:05 -07:00
committed by GitHub
parent 6910518e81
commit 401cd6ab08
19 changed files with 445 additions and 40 deletions

View File

@@ -292,6 +292,12 @@
"settingsProxyHeadersLabel": "Custom Proxy Headers",
"@settingsProxyHeadersLabel": { "description": "Label for the custom proxy headers setting." },
"settingsPrivacyHideAmountsLabel": "Hide amounts",
"@settingsPrivacyHideAmountsLabel": { "description": "Label for the toggle that masks monetary amounts across the app." },
"settingsPrivacyHideAmountsContent": "Mask money values across the app",
"@settingsPrivacyHideAmountsContent": { "description": "Subtitle describing the hide-amounts toggle." },
"settingsBiometricLabel": "Biometric Lock",
"@settingsBiometricLabel": { "description": "Label for the biometric lock toggle." },

View File

@@ -658,6 +658,18 @@ abstract class AppLocalizations {
/// **'Custom Proxy Headers'**
String get settingsProxyHeadersLabel;
/// Label for the toggle that masks monetary amounts across the app.
///
/// In en, this message translates to:
/// **'Hide amounts'**
String get settingsPrivacyHideAmountsLabel;
/// Subtitle describing the hide-amounts toggle.
///
/// In en, this message translates to:
/// **'Mask money values across the app'**
String get settingsPrivacyHideAmountsContent;
/// Label for the biometric lock toggle.
///
/// In en, this message translates to:

View File

@@ -313,6 +313,13 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get settingsProxyHeadersLabel => 'Custom Proxy Headers';
@override
String get settingsPrivacyHideAmountsLabel => 'Hide amounts';
@override
String get settingsPrivacyHideAmountsContent =>
'Mask money values across the app';
@override
String get settingsBiometricLabel => 'Biometric Lock';

View File

@@ -10,6 +10,7 @@ import 'providers/tags_provider.dart';
import 'providers/transactions_provider.dart';
import 'providers/chat_provider.dart';
import 'providers/theme_provider.dart';
import 'providers/privacy_provider.dart';
import 'screens/backend_config_screen.dart';
import 'screens/login_screen.dart';
import 'screens/biometric_lock_screen.dart';
@@ -31,13 +32,31 @@ void main() async {
// Add initial log entry
LogService.instance.info('App', 'Sure app starting...');
// Read the privacy preference before the first frame so money values are
// never briefly rendered unmasked for a user who enabled "Hide amounts".
// Default to masked (fail-closed) if it can't be read.
bool moneyHidden = true;
try {
moneyHidden = await PreferencesService.instance.getMoneyHidden();
} catch (e) {
LogService.instance.warning(
'App',
'Failed to read privacy preference at startup with ${e.runtimeType}',
);
}
await TelemetryService.instance.initialize(
appRunner: () => runApp(const SureApp()),
appRunner: () => runApp(SureApp(moneyHidden: moneyHidden)),
);
}
class SureApp extends StatelessWidget {
const SureApp({super.key});
// Fail-closed default (masked) for the no-argument path; main() always passes
// the persisted value explicitly.
const SureApp({super.key, this.moneyHidden = true});
/// The persisted "hide amounts" state, read before `runApp` (see `main`).
final bool moneyHidden;
@override
Widget build(BuildContext context) {
@@ -51,6 +70,8 @@ class SureApp extends StatelessWidget {
ChangeNotifierProvider(create: (_) => MerchantsProvider()),
ChangeNotifierProvider(create: (_) => TagsProvider()),
ChangeNotifierProvider(create: (_) => ThemeProvider()),
ChangeNotifierProvider(
create: (_) => PrivacyProvider(initialHidden: moneyHidden)),
ChangeNotifierProxyProvider<ConnectivityService, AccountsProvider>(
create: (_) => AccountsProvider(),
update: (_, connectivityService, accountsProvider) {

View File

@@ -0,0 +1,74 @@
import 'package:flutter/foundation.dart';
import '../services/log_service.dart';
import '../services/preferences_service.dart';
/// App-wide "privacy mode" toggle. When [hidden] is true, money values are
/// masked across the app (see [MoneyMasker]). The choice is persisted so it
/// survives relaunches, and changes notify listeners so every money widget
/// rebuilds immediately.
///
/// The preference is read before `runApp` and passed in as [initialHidden], so
/// the very first build already has the correct value — no startup window where
/// balances could flash. When [initialHidden] is omitted (e.g. in tests) the
/// provider starts masked (fail-closed) and hydrates asynchronously, so a user
/// who had privacy mode on still never flashes their balances.
class PrivacyProvider extends ChangeNotifier {
// Fail closed: assume masked until the stored preference is known.
bool _hidden;
// Set once the user explicitly toggles, so a late-completing initial load
// can't clobber their choice (see _load).
bool _userOverrode = false;
/// Whether monetary values should be masked.
bool get hidden => _hidden;
PrivacyProvider({bool? initialHidden}) : _hidden = initialHidden ?? true {
if (initialHidden == null) {
_load();
}
}
Future<void> _load() async {
bool? stored;
try {
stored = await PreferencesService.instance.getMoneyHidden();
} catch (e) {
// Keep the fail-closed default (masked) if the preference can't be read.
LogService.instance.warning(
'PrivacyProvider',
'Failed to load privacy preference with ${e.runtimeType}',
);
}
// Only apply the loaded value if the user hasn't toggled in the meantime,
// so the initial hydration never overwrites an explicit choice.
if (!_userOverrode && stored != null) {
_hidden = stored;
}
notifyListeners();
}
/// Sets the masked state and persists it. No-ops if unchanged. If persistence
/// fails the in-memory state is reverted so the UI stays consistent with what
/// is actually stored.
Future<void> setHidden(bool value) async {
_userOverrode = true;
if (_hidden == value) return;
final previous = _hidden;
_hidden = value;
notifyListeners();
try {
await PreferencesService.instance.setMoneyHidden(value);
} catch (e) {
_hidden = previous;
notifyListeners();
LogService.instance.warning(
'PrivacyProvider',
'Failed to persist privacy preference with ${e.runtimeType}',
);
}
}
/// Flips the masked state.
Future<void> toggle() => setHidden(!_hidden);
}

View File

@@ -6,9 +6,11 @@ import '../models/transaction.dart';
import '../providers/accounts_provider.dart';
import '../providers/transactions_provider.dart';
import '../providers/auth_provider.dart';
import '../providers/privacy_provider.dart';
import '../services/log_service.dart';
import '../utils/amount_parser.dart';
import '../l10n/app_localizations.dart';
import '../utils/money_masker.dart';
class CalendarScreen extends StatefulWidget {
const CalendarScreen({super.key});
@@ -196,10 +198,12 @@ class _CalendarScreenState extends State<CalendarScreen> {
).format(date);
final colorScheme = Theme.of(context).colorScheme;
final l = AppLocalizations.of(context);
showDialog(
context: context,
builder: (BuildContext context) {
// Watch inside the dialog builder so the amounts re-mask if the user
// toggles privacy while the dialog is open.
final hideAmounts = context.watch<PrivacyProvider>().hidden;
return AlertDialog(
title: Text(
formattedDate,
@@ -224,7 +228,7 @@ class _CalendarScreenState extends State<CalendarScreen> {
itemCount: transactions.length,
itemBuilder: (context, index) {
final transaction = transactions[index];
return _buildTransactionTile(transaction);
return _buildTransactionTile(transaction, hideAmounts);
},
),
),
@@ -239,7 +243,7 @@ class _CalendarScreenState extends State<CalendarScreen> {
);
}
Widget _buildTransactionTile(Transaction transaction) {
Widget _buildTransactionTile(Transaction transaction, bool hideAmounts) {
// Parse amount to determine if positive or negative
var isNegative = false;
try {
@@ -283,7 +287,10 @@ class _CalendarScreenState extends State<CalendarScreen> {
)
: null,
trailing: Text(
transaction.amount,
MoneyMasker.mask(
transaction.amount,
hidden: hideAmounts,
),
style: TextStyle(
color: amountColor,
fontWeight: FontWeight.bold,
@@ -310,6 +317,7 @@ class _CalendarScreenState extends State<CalendarScreen> {
final l = AppLocalizations.of(context);
final colorScheme = Theme.of(context).colorScheme;
final accountsProvider = context.watch<AccountsProvider>();
final hideAmounts = context.watch<PrivacyProvider>().hidden;
return Scaffold(
appBar: AppBar(
@@ -472,7 +480,10 @@ class _CalendarScreenState extends State<CalendarScreen> {
style: Theme.of(context).textTheme.titleMedium,
),
Text(
_formatCurrency(_getTotalForMonth()),
MoneyMasker.mask(
_formatCurrency(_getTotalForMonth()),
hidden: hideAmounts,
),
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: _getTotalForMonth() >= 0
? Colors.green
@@ -488,14 +499,14 @@ class _CalendarScreenState extends State<CalendarScreen> {
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _buildCalendar(colorScheme),
: _buildCalendar(colorScheme, hideAmounts),
),
],
),
);
}
Widget _buildCalendar(ColorScheme colorScheme) {
Widget _buildCalendar(ColorScheme colorScheme, bool hideAmounts) {
final firstDayOfMonth =
DateTime(_currentMonth.year, _currentMonth.month, 1);
final lastDayOfMonth =
@@ -566,6 +577,7 @@ class _CalendarScreenState extends State<CalendarScreen> {
change,
hasChange,
colorScheme,
hideAmounts,
),
);
}).toList(),
@@ -579,7 +591,7 @@ class _CalendarScreenState extends State<CalendarScreen> {
}
Widget _buildDayCell(DateTime date, int day, double change, bool hasChange,
ColorScheme colorScheme) {
ColorScheme colorScheme, bool hideAmounts) {
Color? backgroundColor;
Color? textColor;
@@ -632,7 +644,10 @@ class _CalendarScreenState extends State<CalendarScreen> {
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
_formatAmount(change),
MoneyMasker.mask(
_formatAmount(change),
hidden: hideAmounts,
),
style: TextStyle(
fontSize: 10,
color: textColor,

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/auth_provider.dart';
import '../providers/privacy_provider.dart';
import '../widgets/sure_logo.dart';
import 'chat_list_screen.dart';
import 'dashboard_screen.dart';
@@ -135,6 +136,27 @@ class _MainNavigationScreenState extends State<MainNavigationScreen> {
),
),
actions: [
Padding(
padding: const EdgeInsets.only(right: 12),
child: Center(
child: Tooltip(
message: 'Toggle privacy',
child: InkWell(
onTap: () => context.read<PrivacyProvider>().toggle(),
child: SizedBox(
width: 36,
height: 36,
child: Icon(
context.watch<PrivacyProvider>().hidden
? Icons.visibility_off_outlined
: Icons.visibility_outlined,
semanticLabel: 'Toggle privacy',
),
),
),
),
),
),
Padding(
padding: const EdgeInsets.only(right: 12),
child: Center(

View File

@@ -7,7 +7,9 @@ import '../providers/transactions_provider.dart';
import '../providers/accounts_provider.dart';
import '../providers/auth_provider.dart';
import '../theme/sure_tokens.dart';
import '../providers/privacy_provider.dart';
import '../utils/amount_parser.dart';
import '../utils/money_masker.dart';
import '../widgets/money_text.dart';
import '../l10n/app_localizations.dart';
@@ -89,6 +91,7 @@ class _RecentTransactionsScreenState extends State<RecentTransactionsScreen> {
final l = AppLocalizations.of(context);
final colorScheme = Theme.of(context).colorScheme;
final transactionsProvider = context.watch<TransactionsProvider>();
final hideAmounts = context.watch<PrivacyProvider>().hidden;
final recentTransactions = _getSortedTransactions(
transactionsProvider.transactions,
@@ -143,6 +146,7 @@ class _RecentTransactionsScreenState extends State<RecentTransactionsScreen> {
context,
transaction,
colorScheme,
hideAmounts,
);
},
),
@@ -179,8 +183,8 @@ class _RecentTransactionsScreenState extends State<RecentTransactionsScreen> {
);
}
Widget _buildTransactionItem(
BuildContext context, Transaction transaction, ColorScheme colorScheme) {
Widget _buildTransactionItem(BuildContext context, Transaction transaction,
ColorScheme colorScheme, bool hideAmounts) {
final account = _getAccount(transaction.accountId);
final accountName = account?.name ??
AppLocalizations.of(context).recentTransactionsUnknownAccount;
@@ -276,9 +280,12 @@ class _RecentTransactionsScreenState extends State<RecentTransactionsScreen> {
],
),
trailing: MoneyText(
amount == null
? transaction.amount
: '$sign${transaction.currency} ${_formatAmount(amount.abs())}',
MoneyMasker.mask(
amount == null
? transaction.amount
: '$sign${transaction.currency} ${_formatAmount(amount.abs())}',
hidden: hideAmounts,
),
trend: moneyTrend,
style: const TextStyle(
fontWeight: SureTokens.weightMedium,

View File

@@ -8,6 +8,7 @@ import '../providers/categories_provider.dart';
import '../providers/merchants_provider.dart';
import '../providers/tags_provider.dart';
import '../providers/theme_provider.dart';
import '../providers/privacy_provider.dart';
import '../services/offline_storage_service.dart';
import '../services/log_service.dart';
import '../services/biometric_service.dart';
@@ -750,19 +751,27 @@ class _SettingsScreenState extends State<SettingsScreen> {
onTap: () => _handleClearLocalData(context),
),
if (_biometricSupported) ...[
const Divider(),
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
l.settingsSectionSecurity,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.grey,
),
const Divider(),
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
l.settingsSectionSecurity,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.grey,
),
),
),
SwitchListTile(
secondary: const Icon(Icons.visibility_off_outlined),
title: Text(l.settingsPrivacyHideAmountsLabel),
subtitle: Text(l.settingsPrivacyHideAmountsContent),
value: context.watch<PrivacyProvider>().hidden,
onChanged: (value) =>
context.read<PrivacyProvider>().setHidden(value),
),
if (_biometricSupported)
SwitchListTile(
secondary: const Icon(Icons.fingerprint),
title: Text(l.settingsBiometricLabel),
@@ -770,7 +779,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
value: _biometricEnabled,
onChanged: _isTogglingBiometric ? null : _toggleBiometric,
),
],
const Divider(),

View File

@@ -5,10 +5,12 @@ import '../models/account.dart';
import '../models/category.dart' as models;
import '../providers/auth_provider.dart';
import '../providers/categories_provider.dart';
import '../providers/privacy_provider.dart';
import '../providers/transactions_provider.dart';
import '../services/log_service.dart';
import '../services/connectivity_service.dart';
import '../utils/amount_parser.dart';
import '../utils/money_masker.dart';
import '../widgets/sure_segmented_control.dart';
import '../l10n/app_localizations.dart';
@@ -230,6 +232,7 @@ class _TransactionFormScreenState extends State<TransactionFormScreen> {
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
final colorScheme = Theme.of(context).colorScheme;
final hideAmounts = context.watch<PrivacyProvider>().hidden;
return Container(
decoration: BoxDecoration(
@@ -322,7 +325,7 @@ class _TransactionFormScreenState extends State<TransactionFormScreen> {
),
const SizedBox(height: 4),
Text(
'${widget.account.balance} ${widget.account.currency}',
'${MoneyMasker.mask(widget.account.balance, hidden: hideAmounts)} ${widget.account.currency}',
style: Theme.of(context)
.textTheme
.bodyMedium

View File

@@ -13,7 +13,9 @@ import '../widgets/category_filter.dart';
import '../widgets/sync_status_badge.dart';
import '../services/log_service.dart';
import '../theme/sure_tokens.dart';
import '../providers/privacy_provider.dart';
import '../utils/amount_parser.dart';
import '../utils/money_masker.dart';
import '../widgets/money_text.dart';
import '../l10n/app_localizations.dart';
@@ -366,6 +368,7 @@ class _TransactionsListScreenState extends State<TransactionsListScreen> {
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
final colorScheme = Theme.of(context).colorScheme;
final hideAmounts = context.watch<PrivacyProvider>().hidden;
return Scaffold(
appBar: AppBar(
@@ -681,7 +684,10 @@ class _TransactionsListScreenState extends State<TransactionsListScreen> {
),
Flexible(
child: MoneyText(
'${displayInfo['prefix']}${displayInfo['displayAmount']}',
MoneyMasker.mask(
'${displayInfo['prefix']}${displayInfo['displayAmount']}',
hidden: hideAmounts,
),
trend: displayInfo['trend'] as MoneyTrend,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium?.copyWith(

View File

@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
class PreferencesService {
@@ -5,6 +6,7 @@ class PreferencesService {
static const _biometricEnabledKey = 'biometric_enabled';
static const _showCategoryFilterKey = 'dashboard_show_category_filter';
static const _themeModeKey = 'theme_mode';
static const _moneyHiddenKey = 'privacy_money_hidden';
static PreferencesService? _instance;
SharedPreferences? _prefs;
@@ -16,6 +18,13 @@ class PreferencesService {
return _instance!;
}
/// Drops the cached instance (and its cached [SharedPreferences]) so tests
/// can re-read values from freshly mocked storage. Test-only.
@visibleForTesting
static void resetForTest() {
_instance = null;
}
Future<SharedPreferences> get _preferences async {
_prefs ??= await SharedPreferences.getInstance();
return _prefs!;
@@ -51,6 +60,17 @@ class PreferencesService {
await prefs.setBool(_showCategoryFilterKey, value);
}
/// Whether money values are masked app-wide ("privacy mode"). Default false.
Future<bool> getMoneyHidden() async {
final prefs = await _preferences;
return prefs.getBool(_moneyHiddenKey) ?? false;
}
Future<void> setMoneyHidden(bool value) async {
final prefs = await _preferences;
await prefs.setBool(_moneyHiddenKey, value);
}
/// Returns 'light', 'dark', or 'system' (default).
Future<String> getThemeMode() async {
final prefs = await _preferences;

View File

@@ -0,0 +1,32 @@
/// Masks monetary values for "privacy mode", where the user wants amounts
/// hidden from over-the-shoulder view.
///
/// The numeric portion of an amount (its digits and any embedded grouping/
/// decimal separators) is collapsed into a short, fixed run of bullets, while
/// the currency symbol/code and sign are kept. So `CA$1,234.56` -> `CA$••••`
/// and `-$42,078.35` -> `-$••••`. A fixed run (rather than one bullet per
/// digit) avoids leaking the value's magnitude and reads cleanly without stray
/// separators. Non-numeric characters are untouched, so the masker is currency-
/// and locale-agnostic — it works on any already-formatted amount string.
class MoneyMasker {
const MoneyMasker._();
/// The character used to mask digits.
static const String maskChar = ''; // •
/// The fixed run of [maskChar] that replaces the numeric portion of an amount.
static const String maskedNumber = '$maskChar$maskChar$maskChar$maskChar';
/// A maximal run of digits and the separators embedded within them, requiring
/// at least one digit so symbol-only strings (e.g. the `--` placeholder) are
/// left alone.
static final RegExp _numericRun = RegExp(r'[\d.,]*\d[\d.,]*');
/// Returns [formatted] with its numeric portion replaced by [maskedNumber],
/// preserving the currency symbol/code and sign. If [hidden] is false,
/// [formatted] is returned unchanged.
static String mask(String formatted, {bool hidden = true}) {
if (!hidden) return formatted;
return formatted.replaceAll(_numericRun, maskedNumber);
}
}

View File

@@ -1,7 +1,10 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/account.dart';
import '../providers/privacy_provider.dart';
import '../theme/sure_colors.dart';
import '../theme/sure_tokens.dart';
import '../utils/money_masker.dart';
import 'money_text.dart';
import 'sure_card.dart';
import 'sure_icon.dart';
@@ -58,6 +61,7 @@ class AccountCard extends StatelessWidget {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final accountColor = _getAccountColor(context);
final hideAmounts = context.watch<PrivacyProvider>().hidden;
final cardContent = SureCard(
margin: const EdgeInsets.only(bottom: 12),
@@ -109,7 +113,7 @@ class AccountCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
account.balance,
MoneyMasker.mask(account.balance, hidden: hideAmounts),
style: SureMoney.tabular(
Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: SureTokens.weightMedium,

View File

@@ -6,8 +6,10 @@ import '../models/account.dart';
import '../models/account_balance.dart';
import '../models/account_holding.dart';
import '../providers/auth_provider.dart';
import '../providers/privacy_provider.dart';
import '../services/account_detail_service.dart';
import '../l10n/app_localizations.dart';
import '../utils/money_masker.dart';
class AccountDetailHeader extends StatefulWidget {
final Account account;
@@ -158,6 +160,7 @@ class _AccountDetailHeaderState extends State<AccountDetailHeader> {
final l = AppLocalizations.of(context);
final colorScheme = Theme.of(context).colorScheme;
final latestBalance = _balances.isNotEmpty ? _balances.first : null;
final hideAmounts = context.watch<PrivacyProvider>().hidden;
return Card(
margin: const EdgeInsets.fromLTRB(16, 8, 16, 8),
@@ -180,7 +183,7 @@ class _AccountDetailHeaderState extends State<AccountDetailHeader> {
),
const SizedBox(height: 4),
Text(
_account.balance,
MoneyMasker.mask(_account.balance, hidden: hideAmounts),
style: Theme.of(context)
.textTheme
.headlineSmall
@@ -227,7 +230,10 @@ class _AccountDetailHeaderState extends State<AccountDetailHeader> {
),
if (_account.cashBalance != null)
_DetailChip(
label: l.accountDetailCashChip(_account.cashBalance!),
label: l.accountDetailCashChip(
MoneyMasker.mask(_account.cashBalance!,
hidden: hideAmounts),
),
icon: Icons.payments_outlined,
),
if (_account.status != null)
@@ -289,7 +295,8 @@ class _AccountDetailHeaderState extends State<AccountDetailHeader> {
),
),
Text(
holding.amount,
MoneyMasker.mask(holding.amount,
hidden: hideAmounts),
style: Theme.of(context)
.textTheme
.bodyMedium

View File

@@ -1,7 +1,10 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/privacy_provider.dart';
import '../theme/sure_colors.dart';
import '../theme/sure_tokens.dart';
import '../utils/money_masker.dart';
import 'money_text.dart';
import 'sure_icon.dart';
@@ -31,6 +34,12 @@ class NetWorthCard extends StatelessWidget {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final sureColors = SureColors.of(context);
final hideAmounts = context.watch<PrivacyProvider>().hidden;
final maskedNetWorth = netWorthFormatted == null
? '--'
: MoneyMasker.mask(netWorthFormatted!, hidden: hideAmounts);
String maskedFormat(String currency, double amount) =>
MoneyMasker.mask(formatAmount(currency, amount), hidden: hideAmounts);
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
@@ -79,7 +88,7 @@ class NetWorthCard extends StatelessWidget {
),
const SizedBox(height: 4),
Text(
netWorthFormatted ?? '--',
maskedNetWorth,
style: SureMoney.tabular(
Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: SureTokens.weightMedium,
@@ -121,8 +130,9 @@ class NetWorthCard extends StatelessWidget {
'Assets',
assetTotalsByCurrency,
sureColors.palette.success,
maskedFormat,
),
formatAmount: formatAmount,
formatAmount: maskedFormat,
),
),
@@ -150,8 +160,9 @@ class NetWorthCard extends StatelessWidget {
'Liabilities',
liabilityTotalsByCurrency,
sureColors.palette.destructive,
maskedFormat,
),
formatAmount: formatAmount,
formatAmount: maskedFormat,
),
),
],
@@ -167,6 +178,7 @@ class NetWorthCard extends StatelessWidget {
String title,
Map<String, double> totals,
Color color,
String Function(String currency, double amount) formatAmount,
) {
final sortedEntries = totals.entries.toList()
..sort((a, b) => b.value.abs().compareTo(a.value.abs()));

View File

@@ -0,0 +1,32 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sure_mobile/utils/money_masker.dart';
void main() {
group('MoneyMasker.mask', () {
test('collapses the numeric portion into a fixed run, keeping symbol/sign', () {
expect(MoneyMasker.mask(r'$29,669.71'), r'$••••');
expect(MoneyMasker.mask(r'CA$42,078.35'), r'CA$••••');
expect(MoneyMasker.mask(r'-$1,234'), r'-$••••');
expect(MoneyMasker.mask('+CAD 42,078.35'), '+CAD ••••');
});
test('hides magnitude — different-sized amounts mask identically', () {
expect(MoneyMasker.mask(r'$5.00'), MoneyMasker.mask(r'$5,000,000.00'));
});
test('leaves symbol-only / non-numeric strings untouched', () {
expect(MoneyMasker.mask('--'), '--');
expect(MoneyMasker.mask('€0.00'), '€••••');
expect(MoneyMasker.mask('₿0.40000000'), '₿••••');
});
test('returns the input unchanged when hidden is false', () {
expect(MoneyMasker.mask(r'$29,669.71', hidden: false), r'$29,669.71');
});
test('is idempotent on already-masked input', () {
final once = MoneyMasker.mask(r'$1,234.56');
expect(MoneyMasker.mask(once), once);
});
});
}

View File

@@ -1,11 +1,20 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:sure_mobile/models/account.dart';
import 'package:sure_mobile/providers/privacy_provider.dart';
import 'package:sure_mobile/services/preferences_service.dart';
import 'package:sure_mobile/theme/sure_theme.dart';
import 'package:sure_mobile/theme/sure_tokens.dart';
import 'package:sure_mobile/widgets/account_card.dart';
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
PreferencesService.resetForTest();
});
Account account(String classification) => Account(
id: '1',
name: 'Test account',
@@ -18,11 +27,20 @@ void main() {
Future<void> pump(WidgetTester tester, Account a) async {
await tester.pumpWidget(
MaterialApp(
theme: SureTheme.light,
home: Scaffold(body: AccountCard(account: a)),
ChangeNotifierProvider<PrivacyProvider>(
create: (_) => PrivacyProvider(),
child: MaterialApp(
theme: SureTheme.light,
home: Scaffold(body: AccountCard(account: a)),
),
),
);
// PrivacyProvider is fail-closed: it starts masked and reveals once the
// (mock-empty -> privacy off) preference load completes. Pump a few frames
// so the real balance is shown before asserting on it.
for (var i = 0; i < 8; i++) {
await tester.pump(const Duration(milliseconds: 10));
}
}
testWidgets('liability balance uses the destructive design-system token',

View File

@@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:sure_mobile/providers/privacy_provider.dart';
import 'package:sure_mobile/services/preferences_service.dart';
import 'package:sure_mobile/widgets/net_worth_card.dart';
void main() {
setUp(() {
// PrivacyProvider persists through SharedPreferences; mock it so the
// provider can load/save without the platform channel, and reset the
// cached PreferencesService so state never leaks between tests.
SharedPreferences.setMockInitialValues({});
PreferencesService.resetForTest();
});
Widget harness(PrivacyProvider privacy) {
return ChangeNotifierProvider<PrivacyProvider>.value(
value: privacy,
child: MaterialApp(
home: Scaffold(
body: NetWorthCard(
assetTotalsByCurrency: const {'USD': 29669.71},
liabilityTotalsByCurrency: const {},
currentFilter: AccountFilter.all,
onFilterChanged: (_) {},
formatAmount: (currency, amount) =>
'\$${amount.toStringAsFixed(2)}',
netWorthFormatted: r'$29,669.71',
),
),
),
);
}
// PrivacyProvider starts masked (fail-closed) and reveals only once the async
// preference load completes, so pump a few frames to let it hydrate.
Future<void> settleLoad(WidgetTester tester) async {
for (var i = 0; i < 8; i++) {
await tester.pump(const Duration(milliseconds: 10));
}
}
testWidgets('starts masked (fail-closed) before the preference loads',
(tester) async {
final provider = PrivacyProvider();
// Checked synchronously, before the async load can turn the event loop:
// the provider masks by default until the stored value is known.
expect(provider.hidden, isTrue);
// Let the load settle so the widget tree and any pending work complete.
await tester.pumpWidget(harness(provider));
await settleLoad(tester);
});
testWidgets('net worth is visible after hydration when privacy mode is off',
(tester) async {
await tester.pumpWidget(harness(PrivacyProvider()));
await settleLoad(tester);
expect(find.text(r'$29,669.71'), findsOneWidget);
expect(find.text(r'$••••'), findsNothing);
});
testWidgets('toggling privacy mode masks the net worth', (tester) async {
final privacy = PrivacyProvider();
await tester.pumpWidget(harness(privacy));
await settleLoad(tester);
await privacy.setHidden(true);
await tester.pump();
// The real value is gone, and both amounts the harness renders — the
// net-worth headline and the single USD asset total — collapse to the same
// fixed mask, so exactly two appear.
expect(find.text(r'$29,669.71'), findsNothing);
expect(find.text(r'$••••'), findsNWidgets(2));
});
testWidgets('hidden state persists across provider instances',
(tester) async {
// First provider enables privacy mode, which persists the choice.
final first = PrivacyProvider();
await tester.pumpWidget(harness(first));
await settleLoad(tester);
await first.setHidden(true);
await tester.pump();
// A fresh provider reading the same persisted store loads "hidden" and
// masks from the start.
await tester.pumpWidget(harness(PrivacyProvider()));
await settleLoad(tester);
// Net-worth headline + the single USD asset total both mask.
expect(find.text(r'$29,669.71'), findsNothing);
expect(find.text(r'$••••'), findsNWidgets(2));
});
}