Files
sure/mobile/lib/widgets/account_detail_header.dart
ghost 401cd6ab08 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.
2026-06-30 06:49:05 +02:00

451 lines
14 KiB
Dart

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
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;
final AccountDetailService? accountDetailService;
const AccountDetailHeader({
super.key,
required this.account,
this.accountDetailService,
});
@override
State<AccountDetailHeader> createState() => _AccountDetailHeaderState();
}
class _AccountDetailHeaderState extends State<AccountDetailHeader> {
late final AccountDetailService _accountDetailService =
widget.accountDetailService ?? AccountDetailService();
late final bool _ownsAccountDetailService =
widget.accountDetailService == null;
late Account _account;
bool _isLoading = false;
bool _detailsUnavailable = false;
List<AccountBalance> _balances = [];
List<AccountHolding> _holdings = [];
bool _disposed = false;
bool _accountDetailServiceClosed = false;
int _activeDetailLoads = 0;
@override
void initState() {
super.initState();
_account = widget.account;
_loadDetails();
}
Future<void> _loadDetails() async {
if (_disposed) return;
// NOTE: this runs synchronously from initState(); do not touch inherited
// widgets (e.g. AppLocalizations.of) here. The failure message is localized
// at render time in build() via the _detailsUnavailable flag.
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final accessToken = await authProvider.getValidAccessToken();
if (_disposed) return;
if (accessToken == null) {
await authProvider.logout();
return;
}
_activeDetailLoads += 1;
if (mounted) {
setState(() {
_isLoading = true;
_detailsUnavailable = false;
});
}
try {
final results = await Future.wait([
_accountDetailService.getAccountDetail(
accessToken: accessToken,
accountId: widget.account.id,
),
_accountDetailService.getBalances(
accessToken: accessToken,
accountId: widget.account.id,
),
]);
final accountResult = results[0];
final balancesResult = results[1];
final resolvedAccount = accountResult['success'] == true &&
accountResult['account'] is Account
? accountResult['account'] as Account
: _account;
Map<String, dynamic>? holdingsResult;
if (_supportsHoldings(resolvedAccount)) {
holdingsResult = await _accountDetailService.getHoldings(
accessToken: accessToken,
accountId: widget.account.id,
);
}
if (!mounted || _disposed) return;
if (accountResult['error'] == 'unauthorized' ||
balancesResult['error'] == 'unauthorized' ||
holdingsResult?['error'] == 'unauthorized') {
await authProvider.logout();
return;
}
setState(() {
if (accountResult['success'] == true &&
accountResult['account'] is Account) {
_account = resolvedAccount;
}
if (balancesResult['success'] == true) {
_balances = (balancesResult['balances'] as List<dynamic>? ?? [])
.whereType<AccountBalance>()
.toList();
}
if (holdingsResult?['success'] == true) {
_holdings = (holdingsResult?['holdings'] as List<dynamic>? ?? [])
.whereType<AccountHolding>()
.toList();
}
if (accountResult['success'] != true &&
balancesResult['success'] != true) {
_detailsUnavailable = true;
}
_isLoading = false;
});
} finally {
_activeDetailLoads -= 1;
_closeOwnedAccountDetailServiceIfIdle();
}
}
bool _supportsHoldings(Account account) {
return account.accountType == 'investment' ||
account.accountType == 'crypto';
}
@override
void dispose() {
_disposed = true;
_closeOwnedAccountDetailServiceIfIdle();
super.dispose();
}
void _closeOwnedAccountDetailServiceIfIdle() {
if (_ownsAccountDetailService &&
_disposed &&
_activeDetailLoads == 0 &&
!_accountDetailServiceClosed) {
_accountDetailService.close();
_accountDetailServiceClosed = true;
}
}
@override
Widget build(BuildContext context) {
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),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_account.displayAccountType,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
MoneyMasker.mask(_account.balance, hidden: hideAmounts),
style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith(
fontWeight: FontWeight.bold,
color: _account.isLiability ? Colors.red : null,
),
),
],
),
),
if (_isLoading)
const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
else
IconButton(
icon: const Icon(Icons.refresh),
tooltip: l.accountDetailRefreshTooltip,
onPressed: _loadDetails,
),
],
),
if (_account.institutionName != null ||
_account.subtype != null ||
_account.cashBalance != null ||
_account.status != null) ...[
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
if (_account.institutionName != null)
_DetailChip(
label: _account.institutionName!,
icon: Icons.account_balance,
),
if (_account.subtype != null)
_DetailChip(
label: _account.subtype!,
icon: Icons.category_outlined,
),
if (_account.cashBalance != null)
_DetailChip(
label: l.accountDetailCashChip(
MoneyMasker.mask(_account.cashBalance!,
hidden: hideAmounts),
),
icon: Icons.payments_outlined,
),
if (_account.status != null)
_DetailChip(
label: _account.status!,
icon: Icons.sync,
),
],
),
],
if (_balances.length >= 2) ...[
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: Text(
l.accountDetailRecentBalanceHistory,
style: Theme.of(context).textTheme.titleSmall,
),
),
Text(
latestBalance == null
? ''
: DateFormat.yMMMd(
Localizations.localeOf(context).toString(),
).format(latestBalance.date),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 8),
SizedBox(
height: 56,
child: _BalanceSparkline(balances: _balances),
),
],
if (_holdings.isNotEmpty) ...[
const SizedBox(height: 16),
Text(
l.accountDetailTopHoldings,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
..._holdings.take(3).map(
(holding) => Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
children: [
Expanded(
child: Text(
holding.ticker?.isNotEmpty == true
? holding.ticker!
: holding.securityName?.trim().isNotEmpty == true
? holding.securityName!
: l.accountDetailHoldingFallback,
overflow: TextOverflow.ellipsis,
),
),
Text(
MoneyMasker.mask(holding.amount,
hidden: hideAmounts),
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
),
),
],
if (_detailsUnavailable) ...[
const SizedBox(height: 8),
Text(
l.accountDetailUnavailable,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.error,
),
),
],
],
),
),
);
}
}
class _DetailChip extends StatelessWidget {
final String label;
final IconData icon;
const _DetailChip({
required this.label,
required this.icon,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: colorScheme.onSurfaceVariant),
const SizedBox(width: 4),
Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
);
}
}
class _BalanceSparkline extends StatelessWidget {
final List<AccountBalance> balances;
const _BalanceSparkline({required this.balances});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final points = balances
.where((balance) => balance.balanceCents != null)
.toList()
.reversed
.toList();
if (points.length < 2) {
return const SizedBox.shrink();
}
return CustomPaint(
painter: _BalanceSparklinePainter(
values:
points.map((balance) => balance.balanceCents!.toDouble()).toList(),
color: colorScheme.primary,
),
child: const SizedBox.expand(),
);
}
}
class _BalanceSparklinePainter extends CustomPainter {
final List<double> values;
final Color color;
const _BalanceSparklinePainter({
required this.values,
required this.color,
});
@override
void paint(Canvas canvas, Size size) {
if (values.length < 2) return;
var minValue = values.first;
var maxValue = values.first;
for (final value in values) {
if (value < minValue) minValue = value;
if (value > maxValue) maxValue = value;
}
final range = maxValue - minValue;
final path = Path();
for (var index = 0; index < values.length; index++) {
final x = size.width * (index / (values.length - 1));
final normalized = range == 0 ? 0.5 : (values[index] - minValue) / range;
final y = size.height - (normalized * size.height);
if (index == 0) {
path.moveTo(x, y);
} else {
path.lineTo(x, y);
}
}
final guidePaint = Paint()
..color = color.withValues(alpha: 0.12)
..strokeWidth = 1;
canvas.drawLine(
Offset(0, size.height / 2),
Offset(size.width, size.height / 2),
guidePaint,
);
final linePaint = Paint()
..color = color
..strokeWidth = 2
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
canvas.drawPath(path, linePaint);
}
@override
bool shouldRepaint(covariant _BalanceSparklinePainter oldDelegate) {
return !listEquals(oldDelegate.values, values) ||
oldDelegate.color != color;
}
}