diff --git a/mobile/lib/l10n/app_en.arb b/mobile/lib/l10n/app_en.arb index 3b04a56c1..68187912f 100644 --- a/mobile/lib/l10n/app_en.arb +++ b/mobile/lib/l10n/app_en.arb @@ -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." }, diff --git a/mobile/lib/l10n/app_localizations.dart b/mobile/lib/l10n/app_localizations.dart index 513b00217..a9e92d7cd 100644 --- a/mobile/lib/l10n/app_localizations.dart +++ b/mobile/lib/l10n/app_localizations.dart @@ -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: diff --git a/mobile/lib/l10n/app_localizations_en.dart b/mobile/lib/l10n/app_localizations_en.dart index b34be6abb..9ab333828 100644 --- a/mobile/lib/l10n/app_localizations_en.dart +++ b/mobile/lib/l10n/app_localizations_en.dart @@ -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'; diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index e5674b49a..7662bf71b 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -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( create: (_) => AccountsProvider(), update: (_, connectivityService, accountsProvider) { diff --git a/mobile/lib/providers/privacy_provider.dart b/mobile/lib/providers/privacy_provider.dart new file mode 100644 index 000000000..dc868981c --- /dev/null +++ b/mobile/lib/providers/privacy_provider.dart @@ -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 _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 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 toggle() => setHidden(!_hidden); +} diff --git a/mobile/lib/screens/calendar_screen.dart b/mobile/lib/screens/calendar_screen.dart index dd7624c37..074278970 100644 --- a/mobile/lib/screens/calendar_screen.dart +++ b/mobile/lib/screens/calendar_screen.dart @@ -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 { ).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().hidden; return AlertDialog( title: Text( formattedDate, @@ -224,7 +228,7 @@ class _CalendarScreenState extends State { 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 { ); } - 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 { ) : 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 { final l = AppLocalizations.of(context); final colorScheme = Theme.of(context).colorScheme; final accountsProvider = context.watch(); + final hideAmounts = context.watch().hidden; return Scaffold( appBar: AppBar( @@ -472,7 +480,10 @@ class _CalendarScreenState extends State { 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 { 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 { change, hasChange, colorScheme, + hideAmounts, ), ); }).toList(), @@ -579,7 +591,7 @@ class _CalendarScreenState extends State { } 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 { child: FittedBox( fit: BoxFit.scaleDown, child: Text( - _formatAmount(change), + MoneyMasker.mask( + _formatAmount(change), + hidden: hideAmounts, + ), style: TextStyle( fontSize: 10, color: textColor, diff --git a/mobile/lib/screens/main_navigation_screen.dart b/mobile/lib/screens/main_navigation_screen.dart index 0e5e25d04..f292edd62 100644 --- a/mobile/lib/screens/main_navigation_screen.dart +++ b/mobile/lib/screens/main_navigation_screen.dart @@ -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 { ), ), actions: [ + Padding( + padding: const EdgeInsets.only(right: 12), + child: Center( + child: Tooltip( + message: 'Toggle privacy', + child: InkWell( + onTap: () => context.read().toggle(), + child: SizedBox( + width: 36, + height: 36, + child: Icon( + context.watch().hidden + ? Icons.visibility_off_outlined + : Icons.visibility_outlined, + semanticLabel: 'Toggle privacy', + ), + ), + ), + ), + ), + ), Padding( padding: const EdgeInsets.only(right: 12), child: Center( diff --git a/mobile/lib/screens/recent_transactions_screen.dart b/mobile/lib/screens/recent_transactions_screen.dart index b5af0ba30..210c4a65e 100644 --- a/mobile/lib/screens/recent_transactions_screen.dart +++ b/mobile/lib/screens/recent_transactions_screen.dart @@ -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 { final l = AppLocalizations.of(context); final colorScheme = Theme.of(context).colorScheme; final transactionsProvider = context.watch(); + final hideAmounts = context.watch().hidden; final recentTransactions = _getSortedTransactions( transactionsProvider.transactions, @@ -143,6 +146,7 @@ class _RecentTransactionsScreenState extends State { context, transaction, colorScheme, + hideAmounts, ); }, ), @@ -179,8 +183,8 @@ class _RecentTransactionsScreenState extends State { ); } - 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 { ], ), 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, diff --git a/mobile/lib/screens/settings_screen.dart b/mobile/lib/screens/settings_screen.dart index 818fd7471..e9589a5dd 100644 --- a/mobile/lib/screens/settings_screen.dart +++ b/mobile/lib/screens/settings_screen.dart @@ -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 { 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().hidden, + onChanged: (value) => + context.read().setHidden(value), + ), + if (_biometricSupported) SwitchListTile( secondary: const Icon(Icons.fingerprint), title: Text(l.settingsBiometricLabel), @@ -770,7 +779,6 @@ class _SettingsScreenState extends State { value: _biometricEnabled, onChanged: _isTogglingBiometric ? null : _toggleBiometric, ), - ], const Divider(), diff --git a/mobile/lib/screens/transaction_form_screen.dart b/mobile/lib/screens/transaction_form_screen.dart index 66111ec0b..c3728ef81 100644 --- a/mobile/lib/screens/transaction_form_screen.dart +++ b/mobile/lib/screens/transaction_form_screen.dart @@ -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 { Widget build(BuildContext context) { final l = AppLocalizations.of(context); final colorScheme = Theme.of(context).colorScheme; + final hideAmounts = context.watch().hidden; return Container( decoration: BoxDecoration( @@ -322,7 +325,7 @@ class _TransactionFormScreenState extends State { ), 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 diff --git a/mobile/lib/screens/transactions_list_screen.dart b/mobile/lib/screens/transactions_list_screen.dart index 7d4c2593c..1aa156345 100644 --- a/mobile/lib/screens/transactions_list_screen.dart +++ b/mobile/lib/screens/transactions_list_screen.dart @@ -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 { Widget build(BuildContext context) { final l = AppLocalizations.of(context); final colorScheme = Theme.of(context).colorScheme; + final hideAmounts = context.watch().hidden; return Scaffold( appBar: AppBar( @@ -681,7 +684,10 @@ class _TransactionsListScreenState extends State { ), 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( diff --git a/mobile/lib/services/preferences_service.dart b/mobile/lib/services/preferences_service.dart index 05b2b46c7..5c24a848a 100644 --- a/mobile/lib/services/preferences_service.dart +++ b/mobile/lib/services/preferences_service.dart @@ -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 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 getMoneyHidden() async { + final prefs = await _preferences; + return prefs.getBool(_moneyHiddenKey) ?? false; + } + + Future setMoneyHidden(bool value) async { + final prefs = await _preferences; + await prefs.setBool(_moneyHiddenKey, value); + } + /// Returns 'light', 'dark', or 'system' (default). Future getThemeMode() async { final prefs = await _preferences; diff --git a/mobile/lib/utils/money_masker.dart b/mobile/lib/utils/money_masker.dart new file mode 100644 index 000000000..a16f6aa5d --- /dev/null +++ b/mobile/lib/utils/money_masker.dart @@ -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); + } +} diff --git a/mobile/lib/widgets/account_card.dart b/mobile/lib/widgets/account_card.dart index 1c04162bd..26bc1eb5b 100644 --- a/mobile/lib/widgets/account_card.dart +++ b/mobile/lib/widgets/account_card.dart @@ -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().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, diff --git a/mobile/lib/widgets/account_detail_header.dart b/mobile/lib/widgets/account_detail_header.dart index ad13285df..0bb013715 100644 --- a/mobile/lib/widgets/account_detail_header.dart +++ b/mobile/lib/widgets/account_detail_header.dart @@ -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 { final l = AppLocalizations.of(context); final colorScheme = Theme.of(context).colorScheme; final latestBalance = _balances.isNotEmpty ? _balances.first : null; + final hideAmounts = context.watch().hidden; return Card( margin: const EdgeInsets.fromLTRB(16, 8, 16, 8), @@ -180,7 +183,7 @@ class _AccountDetailHeaderState extends State { ), 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 { ), 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 { ), ), Text( - holding.amount, + MoneyMasker.mask(holding.amount, + hidden: hideAmounts), style: Theme.of(context) .textTheme .bodyMedium diff --git a/mobile/lib/widgets/net_worth_card.dart b/mobile/lib/widgets/net_worth_card.dart index f435514b8..68b8f97d0 100644 --- a/mobile/lib/widgets/net_worth_card.dart +++ b/mobile/lib/widgets/net_worth_card.dart @@ -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().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 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())); diff --git a/mobile/test/utils/money_masker_test.dart b/mobile/test/utils/money_masker_test.dart new file mode 100644 index 000000000..7fc2ad5e9 --- /dev/null +++ b/mobile/test/utils/money_masker_test.dart @@ -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); + }); + }); +} diff --git a/mobile/test/widgets/account_card_test.dart b/mobile/test/widgets/account_card_test.dart index 3533bf034..344fd8ff4 100644 --- a/mobile/test/widgets/account_card_test.dart +++ b/mobile/test/widgets/account_card_test.dart @@ -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 pump(WidgetTester tester, Account a) async { await tester.pumpWidget( - MaterialApp( - theme: SureTheme.light, - home: Scaffold(body: AccountCard(account: a)), + ChangeNotifierProvider( + 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', diff --git a/mobile/test/widgets/privacy_mode_test.dart b/mobile/test/widgets/privacy_mode_test.dart new file mode 100644 index 000000000..42fed04d3 --- /dev/null +++ b/mobile/test/widgets/privacy_mode_test.dart @@ -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.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 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)); + }); +}