mirror of
https://github.com/we-promise/sure.git
synced 2026-08-05 08:32:15 +00:00
* 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.
180 lines
5.2 KiB
Dart
180 lines
5.2 KiB
Dart
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';
|
|
|
|
class AccountCard extends StatelessWidget {
|
|
final Account account;
|
|
final VoidCallback? onTap;
|
|
final VoidCallback? onSwipe;
|
|
|
|
const AccountCard({
|
|
super.key,
|
|
required this.account,
|
|
this.onTap,
|
|
this.onSwipe,
|
|
});
|
|
|
|
String _getAccountIconName() {
|
|
switch (account.accountType) {
|
|
case 'depository':
|
|
return SureIcons.landmark;
|
|
case 'credit_card':
|
|
return SureIcons.creditCard;
|
|
case 'investment':
|
|
return SureIcons.trendingUp;
|
|
case 'loan':
|
|
return SureIcons.receipt;
|
|
case 'property':
|
|
return SureIcons.house;
|
|
case 'vehicle':
|
|
return SureIcons.car;
|
|
case 'crypto':
|
|
return SureIcons.bitcoin;
|
|
case 'other_asset':
|
|
return SureIcons.shapes;
|
|
case 'other_liability':
|
|
return SureIcons.handCoins;
|
|
default:
|
|
return SureIcons.wallet;
|
|
}
|
|
}
|
|
|
|
Color _getAccountColor(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
if (account.isAsset) {
|
|
return SureColors.of(context).palette.success;
|
|
} else if (account.isLiability) {
|
|
return SureColors.of(context).palette.destructive;
|
|
}
|
|
return colorScheme.primary;
|
|
}
|
|
|
|
@override
|
|
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),
|
|
onTap: onTap,
|
|
child: Row(
|
|
children: [
|
|
// Account icon
|
|
Container(
|
|
width: 48,
|
|
height: 48,
|
|
decoration: BoxDecoration(
|
|
color: accountColor.withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: SureIcon(
|
|
_getAccountIconName(),
|
|
color: accountColor,
|
|
size: SureIconSize.lg,
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
|
|
// Account info
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
account.name,
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: SureTokens.weightMedium,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
account.displayAccountType,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// Balance
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Text(
|
|
MoneyMasker.mask(account.balance, hidden: hideAmounts),
|
|
style: SureMoney.tabular(
|
|
Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: SureTokens.weightMedium,
|
|
color: account.isLiability
|
|
? SureColors.of(context).palette.destructive
|
|
: null,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
account.currency,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
// If onSwipe is provided, wrap with Dismissible
|
|
if (onSwipe != null) {
|
|
return Dismissible(
|
|
key: Key('account_${account.id}'),
|
|
direction: DismissDirection.endToStart,
|
|
confirmDismiss: (direction) async {
|
|
// Don't actually dismiss, just trigger the swipe action
|
|
onSwipe?.call();
|
|
return false; // Don't remove the item
|
|
},
|
|
background: Container(
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
alignment: Alignment.centerRight,
|
|
padding: const EdgeInsets.only(right: 20),
|
|
decoration: BoxDecoration(
|
|
color: Colors.blue,
|
|
borderRadius: BorderRadius.circular(SureTokens.radiusLg),
|
|
),
|
|
child: const Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
SureIcon(
|
|
SureIcons.receipt,
|
|
color: Colors.white,
|
|
size: SureIconSize.xl,
|
|
),
|
|
SizedBox(height: 4),
|
|
Text(
|
|
'Transactions',
|
|
style: TextStyle(color: Colors.white, fontSize: 12),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
child: cardContent,
|
|
);
|
|
}
|
|
|
|
return cardContent;
|
|
}
|
|
}
|