mirror of
https://github.com/we-promise/sure.git
synced 2026-07-12 04:45:19 +00:00
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:
@@ -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." },
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
74
mobile/lib/providers/privacy_provider.dart
Normal file
74
mobile/lib/providers/privacy_provider.dart
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
32
mobile/lib/utils/money_masker.dart
Normal file
32
mobile/lib/utils/money_masker.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()));
|
||||
|
||||
32
mobile/test/utils/money_masker_test.dart
Normal file
32
mobile/test/utils/money_masker_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
99
mobile/test/widgets/privacy_mode_test.dart
Normal file
99
mobile/test/widgets/privacy_mode_test.dart
Normal 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));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user