mirror of
https://github.com/we-promise/sure.git
synced 2026-07-21 01:05:28 +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.
518 lines
19 KiB
Dart
518 lines
19 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:intl/intl.dart';
|
|
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';
|
|
|
|
class TransactionFormScreen extends StatefulWidget {
|
|
final Account account;
|
|
|
|
const TransactionFormScreen({super.key, required this.account});
|
|
|
|
@override
|
|
State<TransactionFormScreen> createState() => _TransactionFormScreenState();
|
|
}
|
|
|
|
class _TransactionFormScreenState extends State<TransactionFormScreen> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
final _amountController = TextEditingController();
|
|
final _dateController = TextEditingController();
|
|
final _nameController = TextEditingController();
|
|
final _log = LogService.instance;
|
|
|
|
String _nature = 'expense';
|
|
bool _showMoreFields = false;
|
|
bool _isSubmitting = false;
|
|
models.Category? _selectedCategory;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
// Set default values
|
|
final now = DateTime.now();
|
|
final formattedDate = DateFormat('yyyy/MM/dd').format(now);
|
|
_dateController.text = formattedDate;
|
|
_nameController.text = 'SureApp';
|
|
_fetchCategories();
|
|
}
|
|
|
|
Future<void> _fetchCategories() async {
|
|
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
|
final categoriesProvider = Provider.of<CategoriesProvider>(
|
|
context,
|
|
listen: false,
|
|
);
|
|
final accessToken = await authProvider.getValidAccessToken();
|
|
if (accessToken != null) {
|
|
categoriesProvider.fetchCategories(accessToken: accessToken);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_amountController.dispose();
|
|
_dateController.dispose();
|
|
_nameController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
String? _validateAmount(String? value) {
|
|
final l = AppLocalizations.of(context);
|
|
if (value == null || value.trim().isEmpty) {
|
|
return l.transactionFormAmountRequiredPrompt;
|
|
}
|
|
|
|
final double amount;
|
|
try {
|
|
amount = AmountParser.parse(value, locale: _currentLocaleName()).value;
|
|
} on FormatException {
|
|
return l.transactionFormAmountInvalidNumber;
|
|
}
|
|
|
|
if (amount <= 0) {
|
|
return l.transactionFormAmountTooSmall;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
String _currentLocaleName() {
|
|
return Localizations.maybeLocaleOf(context)?.toString() ??
|
|
Intl.getCurrentLocale();
|
|
}
|
|
|
|
Future<void> _selectDate() async {
|
|
final DateTime? picked = await showDatePicker(
|
|
context: context,
|
|
initialDate: DateTime.now(),
|
|
firstDate: DateTime(2000),
|
|
lastDate: DateTime(2100),
|
|
);
|
|
|
|
if (picked != null && mounted) {
|
|
setState(() {
|
|
_dateController.text = DateFormat('yyyy/MM/dd').format(picked);
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _handleSubmit() async {
|
|
if (!_formKey.currentState!.validate()) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_isSubmitting = true;
|
|
});
|
|
|
|
_log.info('TransactionForm', 'Starting transaction creation...');
|
|
|
|
final l = AppLocalizations.of(context);
|
|
|
|
try {
|
|
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
|
final transactionsProvider = Provider.of<TransactionsProvider>(
|
|
context,
|
|
listen: false,
|
|
);
|
|
final accessToken = await authProvider.getValidAccessToken();
|
|
|
|
if (accessToken == null) {
|
|
_log.warning(
|
|
'TransactionForm',
|
|
'Access token is null, session expired',
|
|
);
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(l.transactionFormSessionExpired),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
await authProvider.logout();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Convert date format from yyyy/MM/dd to yyyy-MM-dd
|
|
final parsedDate = DateFormat('yyyy/MM/dd').parse(_dateController.text);
|
|
final apiDate = DateFormat('yyyy-MM-dd').format(parsedDate);
|
|
final canonicalAmount = AmountParser.canonicalize(
|
|
_amountController.text,
|
|
locale: _currentLocaleName(),
|
|
);
|
|
|
|
_log.info(
|
|
'TransactionForm',
|
|
'Calling TransactionsProvider.createTransaction (offline-first)',
|
|
);
|
|
|
|
// Use TransactionsProvider for offline-first transaction creation
|
|
final success = await transactionsProvider.createTransaction(
|
|
accessToken: accessToken,
|
|
accountId: widget.account.id,
|
|
name: _nameController.text.trim(),
|
|
date: apiDate,
|
|
amount: canonicalAmount,
|
|
currency: widget.account.currency,
|
|
nature: _nature,
|
|
notes: 'This transaction via mobile app.',
|
|
categoryId: _selectedCategory?.id,
|
|
categoryName: _selectedCategory?.name,
|
|
);
|
|
|
|
if (mounted) {
|
|
if (success) {
|
|
_log.info(
|
|
'TransactionForm',
|
|
'Transaction created successfully (saved locally)',
|
|
);
|
|
|
|
// Check current connectivity status to show appropriate message
|
|
final connectivityService = Provider.of<ConnectivityService>(
|
|
context,
|
|
listen: false,
|
|
);
|
|
final isOnline = connectivityService.isOnline;
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
isOnline
|
|
? l.transactionFormCreateSuccessOnline
|
|
: l.transactionFormCreateSuccessOffline,
|
|
),
|
|
backgroundColor: Colors.green,
|
|
),
|
|
);
|
|
Navigator.pop(context, true); // Return true to indicate success
|
|
} else {
|
|
_log.error('TransactionForm', 'Failed to create transaction');
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(l.transactionFormCreateFailed),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
_log.error(
|
|
'TransactionForm',
|
|
'Exception during transaction creation: $e',
|
|
);
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(l.transactionFormGenericError(e.toString())),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
}
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_isSubmitting = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
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(
|
|
color: Theme.of(context).scaffoldBackgroundColor,
|
|
borderRadius: const BorderRadius.only(
|
|
topLeft: Radius.circular(20),
|
|
topRight: Radius.circular(20),
|
|
),
|
|
),
|
|
child: DraggableScrollableSheet(
|
|
initialChildSize: 0.9,
|
|
minChildSize: 0.5,
|
|
maxChildSize: 0.95,
|
|
expand: false,
|
|
builder: (context, scrollController) {
|
|
return Column(
|
|
children: [
|
|
// Handle bar
|
|
Container(
|
|
margin: const EdgeInsets.only(top: 12, bottom: 8),
|
|
width: 40,
|
|
height: 4,
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey[300],
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
// Title
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 24,
|
|
vertical: 8,
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
l.transactionFormNewTitle,
|
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.close),
|
|
onPressed: () => Navigator.pop(context),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Divider(height: 1),
|
|
// Form content
|
|
Expanded(
|
|
child: SingleChildScrollView(
|
|
controller: scrollController,
|
|
padding: EdgeInsets.only(
|
|
left: 24,
|
|
right: 24,
|
|
top: 16,
|
|
bottom: MediaQuery.of(context).viewInsets.bottom + 24,
|
|
),
|
|
child: Form(
|
|
key: _formKey,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Account info card
|
|
Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.account_balance_wallet,
|
|
color: colorScheme.primary,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment:
|
|
CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
widget.account.name,
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.titleMedium
|
|
?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'${MoneyMasker.mask(widget.account.balance, hidden: hideAmounts)} ${widget.account.currency}',
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.bodyMedium
|
|
?.copyWith(
|
|
color:
|
|
colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
|
|
// Transaction type selection
|
|
Text(
|
|
l.transactionFormTypeLabel,
|
|
style: Theme.of(context).textTheme.titleSmall
|
|
?.copyWith(fontWeight: FontWeight.w600),
|
|
),
|
|
const SizedBox(height: 8),
|
|
SureSegmentedControl<String>(
|
|
selected: _nature,
|
|
onChanged: (value) {
|
|
setState(() {
|
|
_nature = value;
|
|
});
|
|
},
|
|
segments: [
|
|
SureSegment<String>(
|
|
value: 'expense',
|
|
label: l.transactionFormTypeExpense,
|
|
icon: const Icon(Icons.arrow_downward),
|
|
),
|
|
SureSegment<String>(
|
|
value: 'income',
|
|
label: l.transactionFormTypeIncome,
|
|
icon: const Icon(Icons.arrow_upward),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 24),
|
|
|
|
// Amount field
|
|
TextFormField(
|
|
controller: _amountController,
|
|
keyboardType: const TextInputType.numberWithOptions(
|
|
decimal: true,
|
|
),
|
|
decoration: InputDecoration(
|
|
labelText: '${l.transactionFormAmountLabel} *',
|
|
prefixIcon: const Icon(Icons.attach_money),
|
|
suffixText: widget.account.currency,
|
|
helperText: l.transactionFormAmountHelper,
|
|
),
|
|
validator: _validateAmount,
|
|
),
|
|
const SizedBox(height: 24),
|
|
|
|
// More button
|
|
TextButton.icon(
|
|
onPressed: () {
|
|
setState(() {
|
|
_showMoreFields = !_showMoreFields;
|
|
});
|
|
},
|
|
icon: Icon(
|
|
_showMoreFields
|
|
? Icons.expand_less
|
|
: Icons.expand_more,
|
|
),
|
|
label: Text(
|
|
_showMoreFields
|
|
? l.transactionFormLess
|
|
: l.transactionFormMore,
|
|
),
|
|
),
|
|
|
|
// Optional fields (shown when More is clicked)
|
|
if (_showMoreFields) ...[
|
|
const SizedBox(height: 16),
|
|
|
|
// Date field
|
|
TextFormField(
|
|
controller: _dateController,
|
|
readOnly: true,
|
|
decoration: InputDecoration(
|
|
labelText: l.transactionFormDateLabel,
|
|
prefixIcon: const Icon(Icons.calendar_today),
|
|
helperText: l.transactionFormDateHelper,
|
|
),
|
|
onTap: _selectDate,
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Name field
|
|
TextFormField(
|
|
controller: _nameController,
|
|
decoration: InputDecoration(
|
|
labelText: l.transactionFormNameLabel,
|
|
prefixIcon: const Icon(Icons.label),
|
|
helperText: l.transactionFormNameHelper,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Category picker
|
|
Consumer<CategoriesProvider>(
|
|
builder: (context, categoriesProvider, _) {
|
|
if (categoriesProvider.isLoading) {
|
|
return InputDecorator(
|
|
decoration: InputDecoration(
|
|
labelText: l.transactionFormCategoryLabel,
|
|
prefixIcon: const Icon(Icons.category),
|
|
),
|
|
child: Text(
|
|
l.transactionFormCategoryLoading,
|
|
),
|
|
);
|
|
}
|
|
|
|
final categories = categoriesProvider.categories;
|
|
|
|
return DropdownButtonFormField<String?>(
|
|
value: _selectedCategory?.id,
|
|
decoration: InputDecoration(
|
|
labelText: l.transactionFormCategoryLabel,
|
|
prefixIcon: const Icon(Icons.category),
|
|
helperText: l.transactionFormCategoryHelper,
|
|
),
|
|
isExpanded: true,
|
|
items: [
|
|
DropdownMenuItem<String?>(
|
|
value: null,
|
|
child: Text(l.transactionFormNoCategory),
|
|
),
|
|
...categories.map((category) {
|
|
return DropdownMenuItem<String?>(
|
|
value: category.id,
|
|
child: Text(category.displayName),
|
|
);
|
|
}),
|
|
],
|
|
onChanged: (value) {
|
|
setState(() {
|
|
if (value == null) {
|
|
_selectedCategory = null;
|
|
} else {
|
|
_selectedCategory = categories.firstWhere(
|
|
(c) => c.id == value,
|
|
);
|
|
}
|
|
});
|
|
},
|
|
);
|
|
},
|
|
),
|
|
],
|
|
|
|
const SizedBox(height: 32),
|
|
|
|
// Submit button
|
|
ElevatedButton(
|
|
onPressed: _isSubmitting ? null : _handleSubmit,
|
|
child: _isSubmitting
|
|
? const SizedBox(
|
|
height: 20,
|
|
width: 20,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
),
|
|
)
|
|
: Text(l.transactionFormCreateButton),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|