mirror of
https://github.com/we-promise/sure.git
synced 2026-06-08 04:09:04 +00:00
fix(mobile): redact sensitive diagnostic logs (#2199)
* fix(mobile): redact sensitive diagnostic logs * fix(mobile): tighten diagnostic log redaction * fix(mobile): harden sanitized diagnostics * fix(mobile): clarify offline fallback diagnostics * fix(mobile): refine diagnostic log redaction * fix(mobile): tighten diagnostic log redaction * fix(mobile): harden auth diagnostic failures
This commit is contained in:
@@ -58,6 +58,13 @@ class AuthProvider with ChangeNotifier {
|
||||
_loadStoredAuth();
|
||||
}
|
||||
|
||||
void _logAuthException(String operation, Object error) {
|
||||
LogService.instance.error(
|
||||
'AuthProvider',
|
||||
'$operation failed with ${error.runtimeType}',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadStoredAuth() async {
|
||||
_isLoading = true;
|
||||
_isInitializing = true;
|
||||
@@ -127,7 +134,10 @@ class AuthProvider with ChangeNotifier {
|
||||
otpCode: otpCode,
|
||||
);
|
||||
|
||||
LogService.instance.debug('AuthProvider', 'Login result: $result');
|
||||
LogService.instance.debug(
|
||||
'AuthProvider',
|
||||
'Login result received: success=${result['success'] == true}, mfa_required=${result['mfa_required'] == true}',
|
||||
);
|
||||
|
||||
if (result['success'] == true) {
|
||||
_tokens = result['tokens'] as AuthTokens?;
|
||||
@@ -141,7 +151,8 @@ class AuthProvider with ChangeNotifier {
|
||||
if (result['mfa_required'] == true) {
|
||||
_mfaRequired = true;
|
||||
_showMfaInput = true; // Show MFA input field
|
||||
LogService.instance.debug('AuthProvider', 'MFA required! Setting _showMfaInput to true');
|
||||
LogService.instance.debug(
|
||||
'AuthProvider', 'MFA required! Setting _showMfaInput to true');
|
||||
|
||||
// If user already submitted an OTP code, this is likely an invalid OTP error
|
||||
// Show the error message so user knows the code was wrong
|
||||
@@ -165,7 +176,9 @@ class AuthProvider with ChangeNotifier {
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
_errorMessage = 'Connection error: ${e.toString()}';
|
||||
_logAuthException('Login', e);
|
||||
_errorMessage =
|
||||
'Unable to connect. Please check your network and try again.';
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
return false;
|
||||
@@ -182,7 +195,10 @@ class AuthProvider with ChangeNotifier {
|
||||
try {
|
||||
final result = await _authService.loginWithApiKey(apiKey: apiKey);
|
||||
|
||||
LogService.instance.debug('AuthProvider', 'API key login result: $result');
|
||||
LogService.instance.debug(
|
||||
'AuthProvider',
|
||||
'API key login result received: success=${result['success'] == true}',
|
||||
);
|
||||
|
||||
if (result['success'] == true) {
|
||||
_apiKey = apiKey;
|
||||
@@ -197,9 +213,10 @@ class AuthProvider with ChangeNotifier {
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
LogService.instance.error('AuthProvider', 'API key login error: $e\n$stackTrace');
|
||||
_errorMessage = 'Unable to connect. Please check your network and try again.';
|
||||
} catch (e) {
|
||||
_logAuthException('API key login', e);
|
||||
_errorMessage =
|
||||
'Unable to connect. Please check your network and try again.';
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
return false;
|
||||
@@ -241,7 +258,9 @@ class AuthProvider with ChangeNotifier {
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
_errorMessage = 'Connection error: ${e.toString()}';
|
||||
_logAuthException('Signup', e);
|
||||
_errorMessage =
|
||||
'Unable to connect. Please check your network and try again.';
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
return false;
|
||||
@@ -260,12 +279,13 @@ class AuthProvider with ChangeNotifier {
|
||||
deviceInfo: deviceInfo,
|
||||
);
|
||||
|
||||
final launched = await launchUrl(Uri.parse(ssoUrl), mode: LaunchMode.externalApplication);
|
||||
final launched = await launchUrl(Uri.parse(ssoUrl),
|
||||
mode: LaunchMode.externalApplication);
|
||||
if (!launched) {
|
||||
_errorMessage = 'Unable to open browser for sign-in.';
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
LogService.instance.error('AuthProvider', 'SSO launch error: $e\n$stackTrace');
|
||||
} catch (e) {
|
||||
_logAuthException('SSO launch', e);
|
||||
_errorMessage = 'Unable to start sign-in. Please try again.';
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
@@ -306,8 +326,8 @@ class AuthProvider with ChangeNotifier {
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
LogService.instance.error('AuthProvider', 'SSO callback error: $e\n$stackTrace');
|
||||
} catch (e) {
|
||||
_logAuthException('SSO callback', e);
|
||||
_errorMessage = 'Sign-in failed. Please try again.';
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
@@ -349,8 +369,8 @@ class AuthProvider with ChangeNotifier {
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
LogService.instance.error('AuthProvider', 'SSO link error: $e\n$stackTrace');
|
||||
} catch (e) {
|
||||
_logAuthException('SSO link', e);
|
||||
_errorMessage = 'Failed to link account. Please try again.';
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
@@ -392,8 +412,8 @@ class AuthProvider with ChangeNotifier {
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
LogService.instance.error('AuthProvider', 'SSO create account error: $e\n$stackTrace');
|
||||
} catch (e) {
|
||||
_logAuthException('SSO create account', e);
|
||||
_errorMessage = 'Failed to create account. Please try again.';
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
|
||||
@@ -3,9 +3,11 @@ import 'package:flutter/foundation.dart';
|
||||
import '../models/chat.dart';
|
||||
import '../models/message.dart';
|
||||
import '../services/chat_service.dart';
|
||||
import '../services/log_service.dart';
|
||||
|
||||
class ChatProvider with ChangeNotifier {
|
||||
final ChatService _chatService = ChatService();
|
||||
final LogService _log = LogService.instance;
|
||||
|
||||
List<Chat> _chats = [];
|
||||
Chat? _currentChat;
|
||||
@@ -60,7 +62,7 @@ class ChatProvider with ChangeNotifier {
|
||||
_errorMessage = result['error'] ?? 'Failed to fetch chats';
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('fetchChats error: $e');
|
||||
_log.warning('ChatProvider', 'fetchChats failed: ${e.runtimeType}');
|
||||
_errorMessage = 'Something went wrong. Please try again.';
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
@@ -94,7 +96,7 @@ class ChatProvider with ChangeNotifier {
|
||||
_errorMessage = result['error'] ?? 'Failed to fetch chat';
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('fetchChat error: $e');
|
||||
_log.warning('ChatProvider', 'fetchChat failed: ${e.runtimeType}');
|
||||
_errorMessage = 'Something went wrong. Please try again.';
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
@@ -153,7 +155,7 @@ class ChatProvider with ChangeNotifier {
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('createChat error: $e');
|
||||
_log.warning('ChatProvider', 'createChat failed: ${e.runtimeType}');
|
||||
_errorMessage = 'Something went wrong. Please try again.';
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
@@ -164,9 +166,8 @@ class ChatProvider with ChangeNotifier {
|
||||
void _rollbackOptimisticMessage(String optimisticId, String chatId) {
|
||||
if (_currentChat != null && _currentChat!.id == chatId) {
|
||||
_currentChat = _currentChat!.copyWith(
|
||||
messages: _currentChat!.messages
|
||||
.where((m) => m.id != optimisticId)
|
||||
.toList(),
|
||||
messages:
|
||||
_currentChat!.messages.where((m) => m.id != optimisticId).toList(),
|
||||
);
|
||||
}
|
||||
_isWaitingForResponse = false;
|
||||
@@ -236,7 +237,7 @@ class ChatProvider with ChangeNotifier {
|
||||
} catch (e) {
|
||||
// Roll back the optimistic message on error.
|
||||
_rollbackOptimisticMessage(optimisticId, chatId);
|
||||
debugPrint('sendMessage error: $e');
|
||||
_log.warning('ChatProvider', 'sendMessage failed: ${e.runtimeType}');
|
||||
_errorMessage = 'Something went wrong. Please try again.';
|
||||
return false;
|
||||
} finally {
|
||||
@@ -282,7 +283,7 @@ class ChatProvider with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('updateChatTitle error: $e');
|
||||
_log.warning('ChatProvider', 'updateChatTitle failed: ${e.runtimeType}');
|
||||
_errorMessage = 'Something went wrong. Please try again.';
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -314,7 +315,7 @@ class ChatProvider with ChangeNotifier {
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('deleteChat error: $e');
|
||||
_log.warning('ChatProvider', 'deleteChat failed: ${e.runtimeType}');
|
||||
_errorMessage = 'Something went wrong. Please try again.';
|
||||
notifyListeners();
|
||||
return false;
|
||||
@@ -334,7 +335,8 @@ class ChatProvider with ChangeNotifier {
|
||||
|
||||
final deletedCount = (result['deletedCount'] as int?) ?? 0;
|
||||
if (result['success'] == true || deletedCount > 0) {
|
||||
final failedIds = ((result['failedIds'] as List?) ?? []).cast<String>().toSet();
|
||||
final failedIds =
|
||||
((result['failedIds'] as List?) ?? []).cast<String>().toSet();
|
||||
final deleted = chatIds.toSet().difference(failedIds);
|
||||
_chats.removeWhere((c) => deleted.contains(c.id));
|
||||
|
||||
@@ -350,7 +352,10 @@ class ChatProvider with ChangeNotifier {
|
||||
notifyListeners();
|
||||
return false;
|
||||
} catch (e) {
|
||||
debugPrint('deleteMultipleChats error: $e');
|
||||
_log.warning(
|
||||
'ChatProvider',
|
||||
'deleteMultipleChats failed: ${e.runtimeType}',
|
||||
);
|
||||
_errorMessage = 'Something went wrong. Please try again.';
|
||||
notifyListeners();
|
||||
return false;
|
||||
@@ -477,14 +482,15 @@ class ChatProvider with ChangeNotifier {
|
||||
} catch (e) {
|
||||
// Network error — allow polling to continue; timeout check below will
|
||||
// stop it if the deadline has passed.
|
||||
debugPrint('Polling error: ${e.toString()}');
|
||||
_log.warning('ChatProvider', 'Polling failed: ${e.runtimeType}');
|
||||
}
|
||||
|
||||
// Evaluate timeout only after the attempt, and only when no progress was made.
|
||||
if (_pollingStartTime != null &&
|
||||
DateTime.now().difference(_pollingStartTime!) >= _pollingTimeout) {
|
||||
_stopPolling();
|
||||
_errorMessage = 'The assistant took too long to respond. Please try again.';
|
||||
_errorMessage =
|
||||
'The assistant took too long to respond. Please try again.';
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,8 +102,10 @@ class TransactionsProvider with ChangeNotifier {
|
||||
accountId: accountId,
|
||||
);
|
||||
|
||||
_log.debug('TransactionsProvider',
|
||||
'Loaded ${localTransactions.length} transactions from local storage (accountId: $accountId)');
|
||||
_log.debug(
|
||||
'TransactionsProvider',
|
||||
'Loaded ${localTransactions.length} transactions from local storage${accountId != null ? " with account filter" : ""}',
|
||||
);
|
||||
|
||||
_transactions = localTransactions;
|
||||
notifyListeners();
|
||||
@@ -114,8 +116,10 @@ class TransactionsProvider with ChangeNotifier {
|
||||
'Online: $isOnline, ForceSync: $forceSync, LocalEmpty: ${localTransactions.isEmpty}');
|
||||
|
||||
if (isOnline && (forceSync || localTransactions.isEmpty)) {
|
||||
_log.debug('TransactionsProvider',
|
||||
'Syncing from server for accountId: $accountId');
|
||||
_log.debug(
|
||||
'TransactionsProvider',
|
||||
'Syncing transactions from server${accountId != null ? " with account filter" : ""}',
|
||||
);
|
||||
final result = await _syncService.syncFromServer(
|
||||
accessToken: accessToken,
|
||||
accountId: accountId,
|
||||
@@ -168,8 +172,10 @@ class TransactionsProvider with ChangeNotifier {
|
||||
try {
|
||||
final isOnline = _connectivityService?.isOnline ?? false;
|
||||
|
||||
_log.info('TransactionsProvider',
|
||||
'Creating transaction: $name, amount: $amount, online: $isOnline');
|
||||
_log.info(
|
||||
'TransactionsProvider',
|
||||
'Creating transaction locally, online: $isOnline',
|
||||
);
|
||||
|
||||
// ALWAYS save locally first (offline-first strategy)
|
||||
final localTransaction = await _offlineStorage.saveTransaction(
|
||||
@@ -189,8 +195,7 @@ class TransactionsProvider with ChangeNotifier {
|
||||
syncStatus: SyncStatus.pending, // Start as pending
|
||||
);
|
||||
|
||||
_log.info('TransactionsProvider',
|
||||
'Transaction saved locally with ID: ${localTransaction.localId}');
|
||||
_log.info('TransactionsProvider', 'Transaction saved locally');
|
||||
|
||||
// Reload transactions to show the new one immediately
|
||||
await fetchTransactions(accessToken: accessToken, accountId: accountId);
|
||||
@@ -446,8 +451,10 @@ class TransactionsProvider with ChangeNotifier {
|
||||
required String localId,
|
||||
required SyncStatus syncStatus,
|
||||
}) async {
|
||||
_log.info('TransactionsProvider',
|
||||
'Undoing transaction $localId with status $syncStatus');
|
||||
_log.info(
|
||||
'TransactionsProvider',
|
||||
'Undoing transaction with status $syncStatus',
|
||||
);
|
||||
|
||||
try {
|
||||
final success =
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:http/http.dart' as http;
|
||||
import '../models/custom_proxy_header.dart';
|
||||
import '../services/api_config.dart';
|
||||
import '../services/custom_proxy_headers_service.dart';
|
||||
import '../services/log_service.dart';
|
||||
import '../widgets/custom_proxy_headers_editor.dart';
|
||||
|
||||
class BackendConfigScreen extends StatefulWidget {
|
||||
@@ -47,10 +48,13 @@ class _BackendConfigScreenState extends State<BackendConfigScreen> {
|
||||
if (savedUrl != null && savedUrl.isNotEmpty) {
|
||||
urlToShow = savedUrl;
|
||||
}
|
||||
} catch (e, stack) {
|
||||
} catch (e) {
|
||||
// Swallow storage failures so the screen still becomes interactive with
|
||||
// sensible defaults; the user can re-enter and re-save.
|
||||
debugPrint('BackendConfigScreen: failed to load saved config: $e\n$stack');
|
||||
LogService.instance.warning(
|
||||
'BackendConfigScreen',
|
||||
'Failed to load saved backend config: $e',
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -75,9 +79,9 @@ class _BackendConfigScreenState extends State<BackendConfigScreen> {
|
||||
try {
|
||||
// Normalize base URL by removing trailing slashes
|
||||
final normalizedUrl = _urlController.text.trim().replaceAll(
|
||||
RegExp(r'/+$'),
|
||||
'',
|
||||
);
|
||||
RegExp(r'/+$'),
|
||||
'',
|
||||
);
|
||||
|
||||
// Apply the unsaved edits only for the duration of this probe so the
|
||||
// test reflects what the user is about to save. Restored in `finally`.
|
||||
@@ -85,23 +89,21 @@ class _BackendConfigScreenState extends State<BackendConfigScreen> {
|
||||
|
||||
// Check /sessions/new page to verify it's a Sure backend
|
||||
final sessionsUrl = Uri.parse('$normalizedUrl/sessions/new');
|
||||
final sessionsResponse = await http
|
||||
.get(sessionsUrl, headers: ApiConfig.htmlHeaders())
|
||||
.timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: () {
|
||||
throw Exception(
|
||||
'Connection timeout. Please check the URL and try again.',
|
||||
);
|
||||
},
|
||||
final sessionsResponse =
|
||||
await http.get(sessionsUrl, headers: ApiConfig.htmlHeaders()).timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: () {
|
||||
throw Exception(
|
||||
'Connection timeout. Please check the URL and try again.',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (sessionsResponse.statusCode >= 200 &&
|
||||
sessionsResponse.statusCode < 400) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_successMessage =
|
||||
'Connection successful!';
|
||||
_successMessage = 'Connection successful!';
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -139,9 +141,9 @@ class _BackendConfigScreenState extends State<BackendConfigScreen> {
|
||||
try {
|
||||
// Normalize base URL by removing trailing slashes
|
||||
final normalizedUrl = _urlController.text.trim().replaceAll(
|
||||
RegExp(r'/+$'),
|
||||
'',
|
||||
);
|
||||
RegExp(r'/+$'),
|
||||
'',
|
||||
);
|
||||
|
||||
// Save URL to SharedPreferences
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -223,17 +225,17 @@ class _BackendConfigScreenState extends State<BackendConfigScreen> {
|
||||
Text(
|
||||
'Configuration',
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Update your Sure server URL',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
@@ -430,8 +432,8 @@ class _BackendConfigScreenState extends State<BackendConfigScreen> {
|
||||
Text(
|
||||
'You can change this later in the settings.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -51,7 +51,8 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
||||
// Select first account of the selected type
|
||||
final filteredAccounts = _getFilteredAccounts(accountsProvider.accounts);
|
||||
setState(() {
|
||||
_selectedAccount = filteredAccounts.isNotEmpty ? filteredAccounts.first : null;
|
||||
_selectedAccount =
|
||||
filteredAccounts.isNotEmpty ? filteredAccounts.first : null;
|
||||
});
|
||||
if (_selectedAccount != null) {
|
||||
await _loadTransactionsForAccount();
|
||||
@@ -87,13 +88,17 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
||||
);
|
||||
|
||||
final transactions = transactionsProvider.transactions;
|
||||
_log.info('CalendarScreen', 'Loaded ${transactions.length} transactions for account ${_selectedAccount!.name}');
|
||||
_log.info(
|
||||
'CalendarScreen',
|
||||
'Loaded ${transactions.length} transactions for selected account',
|
||||
);
|
||||
|
||||
// Store transactions for date filtering
|
||||
_transactions = List.from(transactions);
|
||||
|
||||
_calculateDailyChanges(transactions);
|
||||
_log.info('CalendarScreen', 'Calculated ${_dailyChanges.length} days with changes');
|
||||
_log.info('CalendarScreen',
|
||||
'Calculated ${_dailyChanges.length} days with changes');
|
||||
}
|
||||
|
||||
setState(() {
|
||||
@@ -104,7 +109,8 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
||||
void _calculateDailyChanges(List<Transaction> transactions) {
|
||||
final changes = <String, double>{};
|
||||
|
||||
_log.debug('CalendarScreen', 'Starting to calculate daily changes for ${transactions.length} transactions');
|
||||
_log.debug('CalendarScreen',
|
||||
'Starting to calculate daily changes for ${transactions.length} transactions');
|
||||
|
||||
for (var transaction in transactions) {
|
||||
try {
|
||||
@@ -115,23 +121,24 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
||||
|
||||
// For asset accounts, flip the sign to match accounting conventions
|
||||
// For liability accounts, also flip the sign
|
||||
if (_selectedAccount?.isAsset == true || _selectedAccount?.isLiability == true) {
|
||||
if (_selectedAccount?.isAsset == true ||
|
||||
_selectedAccount?.isLiability == true) {
|
||||
amount = -amount;
|
||||
}
|
||||
|
||||
_log.debug('CalendarScreen', 'Processing transaction date: $dateKey, parsed amount sign adjusted');
|
||||
|
||||
changes[dateKey] = (changes[dateKey] ?? 0.0) + amount;
|
||||
_log.debug('CalendarScreen', 'Date $dateKey now has total: ${changes[dateKey]}');
|
||||
} catch (_) {
|
||||
_log.error('CalendarScreen', 'Failed to process transaction for calendar');
|
||||
} catch (e) {
|
||||
final sanitizedError = LogService.sanitize(e.toString());
|
||||
final errorSummary = sanitizedError.length > 120
|
||||
? '${sanitizedError.substring(0, 120)}...'
|
||||
: sanitizedError;
|
||||
_log.error('CalendarScreen',
|
||||
'Failed to process transaction for calendar: $errorSummary');
|
||||
}
|
||||
}
|
||||
|
||||
_log.info('CalendarScreen', 'Final changes map has ${changes.length} entries');
|
||||
changes.forEach((date, amount) {
|
||||
_log.debug('CalendarScreen', '$date -> $amount');
|
||||
});
|
||||
_log.info(
|
||||
'CalendarScreen', 'Final changes map has ${changes.length} entries');
|
||||
|
||||
setState(() {
|
||||
_dailyChanges = changes;
|
||||
@@ -172,7 +179,8 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
||||
return _transactions.where((transaction) {
|
||||
try {
|
||||
final transactionDate = DateTime.parse(transaction.date);
|
||||
final transactionDateKey = DateFormat('yyyy-MM-dd').format(transactionDate);
|
||||
final transactionDateKey =
|
||||
DateFormat('yyyy-MM-dd').format(transactionDate);
|
||||
return transactionDateKey == dateKey;
|
||||
} catch (e) {
|
||||
return false;
|
||||
@@ -237,7 +245,8 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
||||
}
|
||||
|
||||
// For asset accounts, flip the sign interpretation
|
||||
if (_selectedAccount?.isAsset == true || _selectedAccount?.isLiability == true) {
|
||||
if (_selectedAccount?.isAsset == true ||
|
||||
_selectedAccount?.isLiability == true) {
|
||||
isNegative = !isNegative;
|
||||
}
|
||||
|
||||
@@ -321,8 +330,8 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
||||
Text(
|
||||
'Account Type',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SegmentedButton<String>(
|
||||
@@ -343,11 +352,15 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
||||
setState(() {
|
||||
_accountType = newSelection.first;
|
||||
// Switch to first account of new type
|
||||
final filteredAccounts = _getFilteredAccounts(accountsProvider.accounts);
|
||||
_selectedAccount = filteredAccounts.isNotEmpty ? filteredAccounts.first : null;
|
||||
final filteredAccounts =
|
||||
_getFilteredAccounts(accountsProvider.accounts);
|
||||
_selectedAccount = filteredAccounts.isNotEmpty
|
||||
? filteredAccounts.first
|
||||
: null;
|
||||
_dailyChanges = {};
|
||||
_transactions = [];
|
||||
_selectedDate = null; // Clear selection when changing account type
|
||||
_selectedDate =
|
||||
null; // Clear selection when changing account type
|
||||
});
|
||||
if (_selectedAccount != null) {
|
||||
_loadTransactionsForAccount();
|
||||
@@ -382,7 +395,8 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
items: _getFilteredAccounts(accountsProvider.accounts).map((account) {
|
||||
items: _getFilteredAccounts(accountsProvider.accounts)
|
||||
.map((account) {
|
||||
return DropdownMenuItem(
|
||||
value: account,
|
||||
child: Text('${account.name} (${account.currency})'),
|
||||
@@ -453,11 +467,11 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
||||
Text(
|
||||
_formatCurrency(_getTotalForMonth()),
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
color: _getTotalForMonth() >= 0
|
||||
? Colors.green
|
||||
: Colors.red,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
color: _getTotalForMonth() >= 0
|
||||
? Colors.green
|
||||
: Colors.red,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -475,8 +489,10 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
||||
}
|
||||
|
||||
Widget _buildCalendar(ColorScheme colorScheme) {
|
||||
final firstDayOfMonth = DateTime(_currentMonth.year, _currentMonth.month, 1);
|
||||
final lastDayOfMonth = DateTime(_currentMonth.year, _currentMonth.month + 1, 0);
|
||||
final firstDayOfMonth =
|
||||
DateTime(_currentMonth.year, _currentMonth.month, 1);
|
||||
final lastDayOfMonth =
|
||||
DateTime(_currentMonth.year, _currentMonth.month + 1, 0);
|
||||
final daysInMonth = lastDayOfMonth.day;
|
||||
final startWeekday = firstDayOfMonth.weekday % 7; // 0 = Sunday
|
||||
|
||||
@@ -506,18 +522,21 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
||||
),
|
||||
|
||||
// Calendar grid
|
||||
...List.generate((daysInMonth + startWeekday + 6) ~/ 7, (weekIndex) {
|
||||
...List.generate((daysInMonth + startWeekday + 6) ~/ 7,
|
||||
(weekIndex) {
|
||||
return SizedBox(
|
||||
height: 70,
|
||||
child: Row(
|
||||
children: List.generate(7, (dayIndex) {
|
||||
final dayNumber = weekIndex * 7 + dayIndex - startWeekday + 1;
|
||||
final dayNumber =
|
||||
weekIndex * 7 + dayIndex - startWeekday + 1;
|
||||
|
||||
if (dayNumber < 1 || dayNumber > daysInMonth) {
|
||||
return const Expanded(child: SizedBox.shrink());
|
||||
}
|
||||
|
||||
final date = DateTime(_currentMonth.year, _currentMonth.month, dayNumber);
|
||||
final date = DateTime(
|
||||
_currentMonth.year, _currentMonth.month, dayNumber);
|
||||
final dateKey = DateFormat('yyyy-MM-dd').format(date);
|
||||
final change = _dailyChanges[dateKey] ?? 0.0;
|
||||
final hasChange = _dailyChanges.containsKey(dateKey);
|
||||
@@ -541,7 +560,8 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDayCell(DateTime date, int day, double change, bool hasChange, ColorScheme colorScheme) {
|
||||
Widget _buildDayCell(DateTime date, int day, double change, bool hasChange,
|
||||
ColorScheme colorScheme) {
|
||||
Color? backgroundColor;
|
||||
Color? textColor;
|
||||
|
||||
@@ -569,7 +589,9 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
||||
color: backgroundColor ?? colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: isSelected ? Theme.of(context).primaryColor : colorScheme.outlineVariant,
|
||||
color: isSelected
|
||||
? Theme.of(context).primaryColor
|
||||
: colorScheme.outlineVariant,
|
||||
width: isSelected ? 3 : 1,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -35,6 +35,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
bool _isTogglingBiometric = false;
|
||||
List<CustomProxyHeader> _customHeaders = [];
|
||||
|
||||
String _displayInitial(String? displayName) {
|
||||
final trimmed = displayName?.trim() ?? '';
|
||||
return trimmed.isEmpty ? 'U' : trimmed[0].toUpperCase();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -86,7 +91,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
if (mounted) {
|
||||
final build = packageInfo.buildNumber;
|
||||
final display = build.isNotEmpty
|
||||
? '${packageInfo.version} (${build})'
|
||||
? '${packageInfo.version} ($build)'
|
||||
: packageInfo.version;
|
||||
setState(() => _appVersion = display);
|
||||
}
|
||||
@@ -107,8 +112,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
if (mounted) {
|
||||
setState(() => _customHeaders = headers);
|
||||
}
|
||||
} catch (e, stack) {
|
||||
debugPrint('SettingsScreen: failed to load custom headers: $e\n$stack');
|
||||
} catch (e) {
|
||||
LogService.instance.warning(
|
||||
'SettingsScreen',
|
||||
'Failed to load custom headers: $e',
|
||||
);
|
||||
// Keep the existing _customHeaders state so the screen remains usable.
|
||||
}
|
||||
}
|
||||
@@ -119,10 +127,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Clear Local Data'),
|
||||
content: const Text(
|
||||
'This will delete all locally cached transactions and accounts. '
|
||||
'Your data on the server will not be affected. '
|
||||
'Are you sure you want to continue?'
|
||||
),
|
||||
'This will delete all locally cached transactions and accounts. '
|
||||
'Your data on the server will not be affected. '
|
||||
'Are you sure you want to continue?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
@@ -156,7 +163,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Local data cleared successfully. Pull to refresh to sync from server.'),
|
||||
content: Text(
|
||||
'Local data cleared successfully. Pull to refresh to sync from server.'),
|
||||
backgroundColor: Colors.green,
|
||||
duration: Duration(seconds: 3),
|
||||
),
|
||||
@@ -242,7 +250,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Account reset has been initiated. This may take a moment.'),
|
||||
content: Text(
|
||||
'Account reset has been initiated. This may take a moment.'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
@@ -298,7 +307,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await UserService().deleteAccount(accessToken: accessToken);
|
||||
final result =
|
||||
await UserService().deleteAccount(accessToken: accessToken);
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
@@ -344,7 +354,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
Future<void> _showCustomHeadersDialog() async {
|
||||
final formKey = GlobalKey<FormState>();
|
||||
final latestHeaders = await CustomProxyHeadersService.instance.loadHeaders();
|
||||
final latestHeaders =
|
||||
await CustomProxyHeadersService.instance.loadHeaders();
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() => _customHeaders = latestHeaders);
|
||||
@@ -438,7 +449,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
radius: 30,
|
||||
backgroundColor: colorScheme.primary,
|
||||
child: Text(
|
||||
authProvider.user?.displayName[0].toUpperCase() ?? 'U',
|
||||
_displayInitial(authProvider.user?.displayName),
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
color: colorScheme.onPrimary,
|
||||
@@ -453,9 +464,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
children: [
|
||||
Text(
|
||||
authProvider.user?.displayName ?? 'User',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
@@ -512,7 +526,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const LogViewerScreen()),
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const LogViewerScreen()),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -570,7 +585,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
],
|
||||
selected: {themeProvider.themeMode},
|
||||
onSelectionChanged: (modes) => themeProvider.setThemeMode(modes.first),
|
||||
onSelectionChanged: (modes) =>
|
||||
themeProvider.setThemeMode(modes.first),
|
||||
showSelectedIcon: false,
|
||||
),
|
||||
);
|
||||
@@ -627,7 +643,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
if (_biometricSupported) ...[
|
||||
const Divider(),
|
||||
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
@@ -639,11 +654,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.fingerprint),
|
||||
title: const Text('Biometric Lock'),
|
||||
subtitle: const Text('Require biometric authentication when resuming the app'),
|
||||
subtitle: const Text(
|
||||
'Require biometric authentication when resuming the app'),
|
||||
value: _biometricEnabled,
|
||||
onChanged: _isTogglingBiometric ? null : _toggleBiometric,
|
||||
),
|
||||
@@ -671,10 +686,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
'Delete all accounts, categories, merchants, and tags but keep your user account',
|
||||
),
|
||||
trailing: _isResettingAccount
|
||||
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: null,
|
||||
enabled: !_isResettingAccount && !_isDeletingAccount,
|
||||
onTap: _isResettingAccount || _isDeletingAccount ? null : () => _handleResetAccount(context),
|
||||
onTap: _isResettingAccount || _isDeletingAccount
|
||||
? null
|
||||
: () => _handleResetAccount(context),
|
||||
),
|
||||
|
||||
ListTile(
|
||||
@@ -684,10 +704,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
'Permanently remove all your data. This cannot be undone.',
|
||||
),
|
||||
trailing: _isDeletingAccount
|
||||
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: null,
|
||||
enabled: !_isDeletingAccount && !_isResettingAccount,
|
||||
onTap: _isDeletingAccount || _isResettingAccount ? null : () => _handleDeleteAccount(context),
|
||||
onTap: _isDeletingAccount || _isResettingAccount
|
||||
? null
|
||||
: () => _handleDeleteAccount(context),
|
||||
),
|
||||
|
||||
const Divider(),
|
||||
|
||||
@@ -15,6 +15,28 @@ class AuthService {
|
||||
static const String _apiKeyKey = 'api_key';
|
||||
static const String _authModeKey = 'auth_mode';
|
||||
|
||||
void _logAuthException(String operation, Object error) {
|
||||
LogService.instance.error(
|
||||
'AuthService',
|
||||
'$operation failed with ${error.runtimeType}',
|
||||
);
|
||||
}
|
||||
|
||||
String _responseError(Map<String, dynamic> responseData, String fallback) {
|
||||
final error = responseData['error'];
|
||||
if (error is String && error.isNotEmpty) return error;
|
||||
|
||||
final errors = responseData['errors'];
|
||||
if (errors is List) {
|
||||
final joined = errors.whereType<Object>().join(', ');
|
||||
if (joined.isNotEmpty) return joined;
|
||||
} else if (errors is String && errors.isNotEmpty) {
|
||||
return errors;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> login({
|
||||
required String email,
|
||||
required String password,
|
||||
@@ -34,14 +56,18 @@ class AuthService {
|
||||
body['otp_code'] = otpCode;
|
||||
}
|
||||
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: ApiConfig.jsonHeaders(),
|
||||
body: jsonEncode(body),
|
||||
).timeout(const Duration(seconds: 30));
|
||||
final response = await http
|
||||
.post(
|
||||
url,
|
||||
headers: ApiConfig.jsonHeaders(),
|
||||
body: jsonEncode(body),
|
||||
)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
|
||||
LogService.instance.debug('AuthService', 'Login response status: ${response.statusCode}');
|
||||
LogService.instance.debug('AuthService', 'Login response body: ${response.body}');
|
||||
LogService.instance.debug(
|
||||
'AuthService',
|
||||
'Login response received with status ${response.statusCode}',
|
||||
);
|
||||
|
||||
final responseData = jsonDecode(response.body);
|
||||
|
||||
@@ -54,7 +80,7 @@ class AuthService {
|
||||
User? user;
|
||||
if (responseData['user'] != null) {
|
||||
final rawUser = responseData['user'];
|
||||
_logRawUserPayload('login', rawUser);
|
||||
_logUserPayloadShape('login', rawUser);
|
||||
user = User.fromJson(rawUser);
|
||||
await _saveUser(user);
|
||||
}
|
||||
@@ -64,7 +90,8 @@ class AuthService {
|
||||
'tokens': tokens,
|
||||
'user': user,
|
||||
};
|
||||
} else if (response.statusCode == 401 && responseData['mfa_required'] == true) {
|
||||
} else if (response.statusCode == 401 &&
|
||||
responseData['mfa_required'] == true) {
|
||||
return {
|
||||
'success': false,
|
||||
'mfa_required': true,
|
||||
@@ -73,41 +100,41 @@ class AuthService {
|
||||
} else {
|
||||
return {
|
||||
'success': false,
|
||||
'error': responseData['error'] ?? responseData['errors']?.join(', ') ?? 'Login failed',
|
||||
'error': _responseError(responseData, 'Login failed'),
|
||||
};
|
||||
}
|
||||
} on SocketException catch (e, stackTrace) {
|
||||
LogService.instance.error('AuthService', 'Login SocketException: $e\n$stackTrace');
|
||||
} on SocketException catch (e) {
|
||||
_logAuthException('Login', e);
|
||||
return {
|
||||
'success': false,
|
||||
'error': 'Network unavailable',
|
||||
};
|
||||
} on TimeoutException catch (e, stackTrace) {
|
||||
LogService.instance.error('AuthService', 'Login TimeoutException: $e\n$stackTrace');
|
||||
} on TimeoutException catch (e) {
|
||||
_logAuthException('Login', e);
|
||||
return {
|
||||
'success': false,
|
||||
'error': 'Request timed out',
|
||||
};
|
||||
} on HttpException catch (e, stackTrace) {
|
||||
LogService.instance.error('AuthService', 'Login HttpException: $e\n$stackTrace');
|
||||
} on HttpException catch (e) {
|
||||
_logAuthException('Login', e);
|
||||
return {
|
||||
'success': false,
|
||||
'error': 'Invalid response from server',
|
||||
};
|
||||
} on FormatException catch (e, stackTrace) {
|
||||
LogService.instance.error('AuthService', 'Login FormatException: $e\n$stackTrace');
|
||||
} on FormatException catch (e) {
|
||||
_logAuthException('Login', e);
|
||||
return {
|
||||
'success': false,
|
||||
'error': 'Invalid response from server',
|
||||
};
|
||||
} on TypeError catch (e, stackTrace) {
|
||||
LogService.instance.error('AuthService', 'Login TypeError: $e\n$stackTrace');
|
||||
} on TypeError catch (e) {
|
||||
_logAuthException('Login', e);
|
||||
return {
|
||||
'success': false,
|
||||
'error': 'Invalid response from server',
|
||||
};
|
||||
} catch (e, stackTrace) {
|
||||
LogService.instance.error('AuthService', 'Login unexpected error: $e\n$stackTrace');
|
||||
} catch (e) {
|
||||
_logAuthException('Login', e);
|
||||
return {
|
||||
'success': false,
|
||||
'error': 'An unexpected error occurred',
|
||||
@@ -140,11 +167,13 @@ class AuthService {
|
||||
body['invite_code'] = inviteCode;
|
||||
}
|
||||
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: ApiConfig.jsonHeaders(),
|
||||
body: jsonEncode(body),
|
||||
).timeout(const Duration(seconds: 30));
|
||||
final response = await http
|
||||
.post(
|
||||
url,
|
||||
headers: ApiConfig.jsonHeaders(),
|
||||
body: jsonEncode(body),
|
||||
)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
|
||||
final responseData = jsonDecode(response.body);
|
||||
|
||||
@@ -157,7 +186,7 @@ class AuthService {
|
||||
User? user;
|
||||
if (responseData['user'] != null) {
|
||||
final rawUser = responseData['user'];
|
||||
_logRawUserPayload('signup', rawUser);
|
||||
_logUserPayloadShape('signup', rawUser);
|
||||
user = User.fromJson(rawUser);
|
||||
await _saveUser(user);
|
||||
}
|
||||
@@ -170,41 +199,41 @@ class AuthService {
|
||||
} else {
|
||||
return {
|
||||
'success': false,
|
||||
'error': responseData['error'] ?? responseData['errors']?.join(', ') ?? 'Signup failed',
|
||||
'error': _responseError(responseData, 'Signup failed'),
|
||||
};
|
||||
}
|
||||
} on SocketException catch (e, stackTrace) {
|
||||
LogService.instance.error('AuthService', 'Signup SocketException: $e\n$stackTrace');
|
||||
} on SocketException catch (e) {
|
||||
_logAuthException('Signup', e);
|
||||
return {
|
||||
'success': false,
|
||||
'error': 'Network unavailable',
|
||||
};
|
||||
} on TimeoutException catch (e, stackTrace) {
|
||||
LogService.instance.error('AuthService', 'Signup TimeoutException: $e\n$stackTrace');
|
||||
} on TimeoutException catch (e) {
|
||||
_logAuthException('Signup', e);
|
||||
return {
|
||||
'success': false,
|
||||
'error': 'Request timed out',
|
||||
};
|
||||
} on HttpException catch (e, stackTrace) {
|
||||
LogService.instance.error('AuthService', 'Signup HttpException: $e\n$stackTrace');
|
||||
} on HttpException catch (e) {
|
||||
_logAuthException('Signup', e);
|
||||
return {
|
||||
'success': false,
|
||||
'error': 'Invalid response from server',
|
||||
};
|
||||
} on FormatException catch (e, stackTrace) {
|
||||
LogService.instance.error('AuthService', 'Signup FormatException: $e\n$stackTrace');
|
||||
} on FormatException catch (e) {
|
||||
_logAuthException('Signup', e);
|
||||
return {
|
||||
'success': false,
|
||||
'error': 'Invalid response from server',
|
||||
};
|
||||
} on TypeError catch (e, stackTrace) {
|
||||
LogService.instance.error('AuthService', 'Signup TypeError: $e\n$stackTrace');
|
||||
} on TypeError catch (e) {
|
||||
_logAuthException('Signup', e);
|
||||
return {
|
||||
'success': false,
|
||||
'error': 'Invalid response from server',
|
||||
};
|
||||
} catch (e, stackTrace) {
|
||||
LogService.instance.error('AuthService', 'Signup unexpected error: $e\n$stackTrace');
|
||||
} catch (e) {
|
||||
_logAuthException('Signup', e);
|
||||
return {
|
||||
'success': false,
|
||||
'error': 'An unexpected error occurred',
|
||||
@@ -219,14 +248,16 @@ class AuthService {
|
||||
try {
|
||||
final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/auth/refresh');
|
||||
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: ApiConfig.jsonHeaders(),
|
||||
body: jsonEncode({
|
||||
'refresh_token': refreshToken,
|
||||
'device': deviceInfo,
|
||||
}),
|
||||
).timeout(const Duration(seconds: 30));
|
||||
final response = await http
|
||||
.post(
|
||||
url,
|
||||
headers: ApiConfig.jsonHeaders(),
|
||||
body: jsonEncode({
|
||||
'refresh_token': refreshToken,
|
||||
'device': deviceInfo,
|
||||
}),
|
||||
)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
|
||||
final responseData = jsonDecode(response.body);
|
||||
|
||||
@@ -244,38 +275,38 @@ class AuthService {
|
||||
'error': responseData['error'] ?? 'Token refresh failed',
|
||||
};
|
||||
}
|
||||
} on SocketException catch (e, stackTrace) {
|
||||
LogService.instance.error('AuthService', 'RefreshToken SocketException: $e\n$stackTrace');
|
||||
} on SocketException catch (e) {
|
||||
_logAuthException('RefreshToken', e);
|
||||
return {
|
||||
'success': false,
|
||||
'error': 'Network unavailable',
|
||||
};
|
||||
} on TimeoutException catch (e, stackTrace) {
|
||||
LogService.instance.error('AuthService', 'RefreshToken TimeoutException: $e\n$stackTrace');
|
||||
} on TimeoutException catch (e) {
|
||||
_logAuthException('RefreshToken', e);
|
||||
return {
|
||||
'success': false,
|
||||
'error': 'Request timed out',
|
||||
};
|
||||
} on HttpException catch (e, stackTrace) {
|
||||
LogService.instance.error('AuthService', 'RefreshToken HttpException: $e\n$stackTrace');
|
||||
} on HttpException catch (e) {
|
||||
_logAuthException('RefreshToken', e);
|
||||
return {
|
||||
'success': false,
|
||||
'error': 'Invalid response from server',
|
||||
};
|
||||
} on FormatException catch (e, stackTrace) {
|
||||
LogService.instance.error('AuthService', 'RefreshToken FormatException: $e\n$stackTrace');
|
||||
} on FormatException catch (e) {
|
||||
_logAuthException('RefreshToken', e);
|
||||
return {
|
||||
'success': false,
|
||||
'error': 'Invalid response from server',
|
||||
};
|
||||
} on TypeError catch (e, stackTrace) {
|
||||
LogService.instance.error('AuthService', 'RefreshToken TypeError: $e\n$stackTrace');
|
||||
} on TypeError catch (e) {
|
||||
_logAuthException('RefreshToken', e);
|
||||
return {
|
||||
'success': false,
|
||||
'error': 'Invalid response from server',
|
||||
};
|
||||
} catch (e, stackTrace) {
|
||||
LogService.instance.error('AuthService', 'RefreshToken unexpected error: $e\n$stackTrace');
|
||||
} catch (e) {
|
||||
_logAuthException('RefreshToken', e);
|
||||
return {
|
||||
'success': false,
|
||||
'error': 'An unexpected error occurred',
|
||||
@@ -298,7 +329,8 @@ class AuthService {
|
||||
},
|
||||
).timeout(const Duration(seconds: 30));
|
||||
|
||||
LogService.instance.debug('AuthService', 'API key login response status: ${response.statusCode}');
|
||||
LogService.instance.debug('AuthService',
|
||||
'API key login response status: ${response.statusCode}');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
await _saveApiKey(apiKey);
|
||||
@@ -316,20 +348,20 @@ class AuthService {
|
||||
'error': 'Login failed (status ${response.statusCode})',
|
||||
};
|
||||
}
|
||||
} on SocketException catch (e, stackTrace) {
|
||||
LogService.instance.error('AuthService', 'API key login SocketException: $e\n$stackTrace');
|
||||
} on SocketException catch (e) {
|
||||
_logAuthException('API key login', e);
|
||||
return {
|
||||
'success': false,
|
||||
'error': 'Network unavailable',
|
||||
};
|
||||
} on TimeoutException catch (e, stackTrace) {
|
||||
LogService.instance.error('AuthService', 'API key login TimeoutException: $e\n$stackTrace');
|
||||
} on TimeoutException catch (e) {
|
||||
_logAuthException('API key login', e);
|
||||
return {
|
||||
'success': false,
|
||||
'error': 'Request timed out',
|
||||
};
|
||||
} catch (e, stackTrace) {
|
||||
LogService.instance.error('AuthService', 'API key login unexpected error: $e\n$stackTrace');
|
||||
} catch (e) {
|
||||
_logAuthException('API key login', e);
|
||||
return {
|
||||
'success': false,
|
||||
'error': 'An unexpected error occurred',
|
||||
@@ -388,11 +420,13 @@ class AuthService {
|
||||
// Exchange authorization code for tokens via secure POST
|
||||
try {
|
||||
final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/auth/sso_exchange');
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: ApiConfig.jsonHeaders(),
|
||||
body: jsonEncode({'code': code}),
|
||||
).timeout(const Duration(seconds: 30));
|
||||
final response = await http
|
||||
.post(
|
||||
url,
|
||||
headers: ApiConfig.jsonHeaders(),
|
||||
body: jsonEncode({'code': code}),
|
||||
)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
final errorData = jsonDecode(response.body);
|
||||
@@ -413,7 +447,7 @@ class AuthService {
|
||||
});
|
||||
await _saveTokens(tokens);
|
||||
|
||||
_logRawUserPayload('sso_exchange', data['user']);
|
||||
_logUserPayloadShape('sso_exchange', data['user']);
|
||||
final user = User.fromJson(data['user']);
|
||||
await _saveUser(user);
|
||||
|
||||
@@ -422,20 +456,20 @@ class AuthService {
|
||||
'tokens': tokens,
|
||||
'user': user,
|
||||
};
|
||||
} on SocketException catch (e, stackTrace) {
|
||||
LogService.instance.error('AuthService', 'SSO exchange SocketException: $e\n$stackTrace');
|
||||
} on SocketException catch (e) {
|
||||
_logAuthException('SSO exchange', e);
|
||||
return {
|
||||
'success': false,
|
||||
'error': 'Network unavailable',
|
||||
};
|
||||
} on TimeoutException catch (e, stackTrace) {
|
||||
LogService.instance.error('AuthService', 'SSO exchange TimeoutException: $e\n$stackTrace');
|
||||
} on TimeoutException catch (e) {
|
||||
_logAuthException('SSO exchange', e);
|
||||
return {
|
||||
'success': false,
|
||||
'error': 'Request timed out',
|
||||
};
|
||||
} catch (e, stackTrace) {
|
||||
LogService.instance.error('AuthService', 'SSO exchange unexpected error: $e\n$stackTrace');
|
||||
} catch (e) {
|
||||
_logAuthException('SSO exchange', e);
|
||||
return {
|
||||
'success': false,
|
||||
'error': 'Failed to exchange authorization code',
|
||||
@@ -450,15 +484,17 @@ class AuthService {
|
||||
}) async {
|
||||
try {
|
||||
final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/auth/sso_link');
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: ApiConfig.jsonHeaders(),
|
||||
body: jsonEncode({
|
||||
'linking_code': linkingCode,
|
||||
'email': email,
|
||||
'password': password,
|
||||
}),
|
||||
).timeout(const Duration(seconds: 30));
|
||||
final response = await http
|
||||
.post(
|
||||
url,
|
||||
headers: ApiConfig.jsonHeaders(),
|
||||
body: jsonEncode({
|
||||
'linking_code': linkingCode,
|
||||
'email': email,
|
||||
'password': password,
|
||||
}),
|
||||
)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
|
||||
final responseData = jsonDecode(response.body);
|
||||
|
||||
@@ -468,7 +504,7 @@ class AuthService {
|
||||
|
||||
User? user;
|
||||
if (responseData['user'] != null) {
|
||||
_logRawUserPayload('sso_link', responseData['user']);
|
||||
_logUserPayloadShape('sso_link', responseData['user']);
|
||||
user = User.fromJson(responseData['user']);
|
||||
await _saveUser(user);
|
||||
}
|
||||
@@ -481,15 +517,17 @@ class AuthService {
|
||||
} else {
|
||||
return {
|
||||
'success': false,
|
||||
'error': responseData['error'] ?? responseData['errors']?.join(', ') ?? 'Account linking failed',
|
||||
'error': _responseError(responseData, 'Account linking failed'),
|
||||
};
|
||||
}
|
||||
} on SocketException {
|
||||
} on SocketException catch (e) {
|
||||
_logAuthException('SSO link', e);
|
||||
return {'success': false, 'error': 'Network unavailable'};
|
||||
} on TimeoutException {
|
||||
} on TimeoutException catch (e) {
|
||||
_logAuthException('SSO link', e);
|
||||
return {'success': false, 'error': 'Request timed out'};
|
||||
} catch (e, stackTrace) {
|
||||
LogService.instance.error('AuthService', 'SSO link error: $e\n$stackTrace');
|
||||
} catch (e) {
|
||||
_logAuthException('SSO link', e);
|
||||
return {'success': false, 'error': 'Failed to link account'};
|
||||
}
|
||||
}
|
||||
@@ -500,18 +538,21 @@ class AuthService {
|
||||
String? lastName,
|
||||
}) async {
|
||||
try {
|
||||
final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/auth/sso_create_account');
|
||||
final url =
|
||||
Uri.parse('${ApiConfig.baseUrl}/api/v1/auth/sso_create_account');
|
||||
final body = <String, dynamic>{
|
||||
'linking_code': linkingCode,
|
||||
};
|
||||
if (firstName != null) body['first_name'] = firstName;
|
||||
if (lastName != null) body['last_name'] = lastName;
|
||||
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: ApiConfig.jsonHeaders(),
|
||||
body: jsonEncode(body),
|
||||
).timeout(const Duration(seconds: 30));
|
||||
final response = await http
|
||||
.post(
|
||||
url,
|
||||
headers: ApiConfig.jsonHeaders(),
|
||||
body: jsonEncode(body),
|
||||
)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
|
||||
final responseData = jsonDecode(response.body);
|
||||
|
||||
@@ -521,7 +562,7 @@ class AuthService {
|
||||
|
||||
User? user;
|
||||
if (responseData['user'] != null) {
|
||||
_logRawUserPayload('sso_create_account', responseData['user']);
|
||||
_logUserPayloadShape('sso_create_account', responseData['user']);
|
||||
user = User.fromJson(responseData['user']);
|
||||
await _saveUser(user);
|
||||
}
|
||||
@@ -534,15 +575,17 @@ class AuthService {
|
||||
} else {
|
||||
return {
|
||||
'success': false,
|
||||
'error': responseData['error'] ?? responseData['errors']?.join(', ') ?? 'Account creation failed',
|
||||
'error': _responseError(responseData, 'Account creation failed'),
|
||||
};
|
||||
}
|
||||
} on SocketException {
|
||||
} on SocketException catch (e) {
|
||||
_logAuthException('SSO create account', e);
|
||||
return {'success': false, 'error': 'Network unavailable'};
|
||||
} on TimeoutException {
|
||||
} on TimeoutException catch (e) {
|
||||
_logAuthException('SSO create account', e);
|
||||
return {'success': false, 'error': 'Request timed out'};
|
||||
} catch (e, stackTrace) {
|
||||
LogService.instance.error('AuthService', 'SSO create account error: $e\n$stackTrace');
|
||||
} catch (e) {
|
||||
_logAuthException('SSO create account', e);
|
||||
return {'success': false, 'error': 'Failed to create account'};
|
||||
}
|
||||
}
|
||||
@@ -563,7 +606,7 @@ class AuthService {
|
||||
final responseData = jsonDecode(response.body);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
_logRawUserPayload('enable_ai', responseData['user']);
|
||||
_logUserPayloadShape('enable_ai', responseData['user']);
|
||||
final user = User.fromJson(responseData['user']);
|
||||
await _saveUser(user);
|
||||
return {
|
||||
@@ -574,12 +617,13 @@ class AuthService {
|
||||
|
||||
return {
|
||||
'success': false,
|
||||
'error': responseData['error'] ?? responseData['errors']?.join(', ') ?? 'Failed to enable AI',
|
||||
'error': _responseError(responseData, 'Failed to enable AI'),
|
||||
};
|
||||
} catch (e) {
|
||||
_logAuthException('Enable AI', e);
|
||||
return {
|
||||
'success': false,
|
||||
'error': 'Network error: ${e.toString()}',
|
||||
'error': 'Network error',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -594,7 +638,7 @@ class AuthService {
|
||||
Future<AuthTokens?> getStoredTokens() async {
|
||||
final tokensJson = await _storage.read(key: _tokenKey);
|
||||
if (tokensJson == null) return null;
|
||||
|
||||
|
||||
try {
|
||||
return AuthTokens.fromJson(jsonDecode(tokensJson));
|
||||
} catch (e) {
|
||||
@@ -605,7 +649,7 @@ class AuthService {
|
||||
Future<User?> getStoredUser() async {
|
||||
final userJson = await _storage.read(key: _userKey);
|
||||
if (userJson == null) return null;
|
||||
|
||||
|
||||
try {
|
||||
return User.fromJson(jsonDecode(userJson));
|
||||
} catch (e) {
|
||||
@@ -627,20 +671,25 @@ class AuthService {
|
||||
);
|
||||
}
|
||||
|
||||
void _logRawUserPayload(String source, dynamic userPayload) {
|
||||
void _logUserPayloadShape(String source, dynamic userPayload) {
|
||||
if (userPayload == null) {
|
||||
LogService.instance.debug('AuthService', '$source user payload: <missing>');
|
||||
LogService.instance.debug(
|
||||
'AuthService',
|
||||
'$source user payload missing',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (userPayload is Map<String, dynamic>) {
|
||||
try {
|
||||
LogService.instance.debug('AuthService', '$source user payload: ${jsonEncode(userPayload)}');
|
||||
} catch (_) {
|
||||
LogService.instance.debug('AuthService', '$source user payload: $userPayload');
|
||||
}
|
||||
LogService.instance.debug(
|
||||
'AuthService',
|
||||
'$source user payload received with ${userPayload.length} fields',
|
||||
);
|
||||
} else {
|
||||
LogService.instance.debug('AuthService', '$source user payload type: ${userPayload.runtimeType}');
|
||||
LogService.instance.debug(
|
||||
'AuthService',
|
||||
'$source user payload type: ${userPayload.runtimeType}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -201,8 +201,7 @@ class DatabaseHelper {
|
||||
return localId;
|
||||
}
|
||||
final db = await database;
|
||||
_log.debug('DatabaseHelper',
|
||||
'Inserting transaction: local_id=${transaction['local_id']}, account_id="${transaction['account_id']}", server_id=${transaction['server_id']}');
|
||||
_log.debug('DatabaseHelper', 'Inserting transaction into local database');
|
||||
await db.insert(
|
||||
'transactions',
|
||||
transaction,
|
||||
@@ -235,8 +234,7 @@ class DatabaseHelper {
|
||||
final db = await database;
|
||||
|
||||
if (accountId != null) {
|
||||
_log.debug('DatabaseHelper',
|
||||
'Querying transactions WHERE account_id = "$accountId"');
|
||||
_log.debug('DatabaseHelper', 'Querying scoped transactions');
|
||||
final results = await db.query(
|
||||
'transactions',
|
||||
where: 'account_id = ?',
|
||||
|
||||
@@ -32,17 +32,88 @@ class LogService with ChangeNotifier {
|
||||
|
||||
List<LogEntry> get logs => List.unmodifiable(_logs);
|
||||
|
||||
static final List<RegExp> _authPatterns = [
|
||||
RegExp(
|
||||
r'\b(authorization|x-api-key|api[-_]?key|access[-_]?token|refresh[-_]?token|auth[-_]?token|bearer|password|otp[-_]?code|linking[-_]?code|secret|custom[-_]?proxy[-_]?headers?)\b\s*[:=]\s*(Bearer\s+[A-Za-z0-9._~+/=-]+|"[^"]*"|[^\s,}]+)',
|
||||
caseSensitive: false,
|
||||
),
|
||||
RegExp(
|
||||
r'"(authorization|x-api-key|api[-_]?key|access[-_]?token|refresh[-_]?token|auth[-_]?token|password|otp[-_]?code|linking[-_]?code|secret)"\s*:\s*("[^"]*"|[0-9.]+|true|false|null)',
|
||||
caseSensitive: false,
|
||||
),
|
||||
];
|
||||
|
||||
static final List<RegExp> _businessDataPatterns = [
|
||||
RegExp(
|
||||
r'\b(local[-_]?id|account[-_]?id|server[-_]?id|transaction[-_]?id|merchant[-_]?id|category[-_]?id|tag[-_]?ids?|user[-_]?id|backend[-_]?url|base[-_]?url|amount|account[-_]?name|merchant[-_]?name|category[-_]?name|display[-_]?name|transaction[-_]?name|email|first[-_]?name|last[-_]?name)\b\s*[:=]\s*("[^"]*"|[^\s,}]+)',
|
||||
caseSensitive: false,
|
||||
),
|
||||
RegExp(
|
||||
r'"(local[-_]?id|account[-_]?id|server[-_]?id|transaction[-_]?id|merchant[-_]?id|category[-_]?id|tag[-_]?ids?|user[-_]?id|backend[-_]?url|base[-_]?url|amount|account[-_]?name|merchant[-_]?name|category[-_]?name|display[-_]?name|transaction[-_]?name|email|first[-_]?name|last[-_]?name)"\s*:\s*("[^"]*"|[0-9.]+|true|false|null)',
|
||||
caseSensitive: false,
|
||||
),
|
||||
];
|
||||
static final List<RegExp> _sensitiveKeyPatterns = [
|
||||
..._authPatterns,
|
||||
..._businessDataPatterns,
|
||||
];
|
||||
|
||||
static final RegExp _bearerTokenPattern =
|
||||
RegExp(r'\bBearer\s+[A-Za-z0-9._~+/=-]+', caseSensitive: false);
|
||||
static final RegExp _urlPattern = RegExp(r'https?://[^\s,}]+');
|
||||
static final RegExp _hostLookupPattern = RegExp(
|
||||
r'''(Failed host lookup:\s*)['"]?[^'"\s)]+['"]?''',
|
||||
caseSensitive: false,
|
||||
);
|
||||
static final RegExp _socketAddressPattern = RegExp(
|
||||
r'\b(address|host)\s*=\s*([^\s,}]+)',
|
||||
caseSensitive: false,
|
||||
);
|
||||
static final RegExp _emailPattern = RegExp(
|
||||
r'\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b',
|
||||
caseSensitive: false);
|
||||
static final RegExp _uuidPattern = RegExp(
|
||||
r'\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b',
|
||||
caseSensitive: false,
|
||||
);
|
||||
static final RegExp _longNumericIdPattern = RegExp(r'\b\d{14,}\b');
|
||||
|
||||
static String sanitize(String message) {
|
||||
var sanitized = message;
|
||||
|
||||
for (final pattern in _sensitiveKeyPatterns) {
|
||||
sanitized = sanitized.replaceAllMapped(pattern, (match) {
|
||||
final key = match.group(1) ?? 'value';
|
||||
return '$key=[redacted]';
|
||||
});
|
||||
}
|
||||
|
||||
sanitized = sanitized
|
||||
.replaceAll(_bearerTokenPattern, 'Bearer [redacted]')
|
||||
.replaceAll(_urlPattern, '[url]')
|
||||
.replaceAllMapped(
|
||||
_hostLookupPattern, (match) => '${match.group(1)}[host]')
|
||||
.replaceAllMapped(
|
||||
_socketAddressPattern, (match) => '${match.group(1)}=[host]')
|
||||
.replaceAll(_emailPattern, '[email]')
|
||||
.replaceAll(_uuidPattern, '[id]')
|
||||
.replaceAll(_longNumericIdPattern, '[id]');
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/// Call this when log viewer screen becomes active
|
||||
void setLogViewerActive(bool active) {
|
||||
_isLogViewerActive = active;
|
||||
}
|
||||
|
||||
void log(String tag, String message, {String level = 'INFO'}) {
|
||||
final sanitizedMessage = sanitize(message);
|
||||
final entry = LogEntry(
|
||||
timestamp: DateTime.now(),
|
||||
level: level,
|
||||
tag: tag,
|
||||
message: message,
|
||||
message: sanitizedMessage,
|
||||
);
|
||||
|
||||
_logs.add(entry);
|
||||
@@ -53,7 +124,7 @@ class LogService with ChangeNotifier {
|
||||
}
|
||||
|
||||
// Also print to console for development
|
||||
debugPrint('[$level][$tag] $message');
|
||||
debugPrint('[$level][$tag] $sanitizedMessage');
|
||||
|
||||
// Only notify listeners if log viewer is active to avoid unnecessary rebuilds
|
||||
if (_isLogViewerActive) {
|
||||
@@ -63,7 +134,8 @@ class LogService with ChangeNotifier {
|
||||
|
||||
void debug(String tag, String message) => log(tag, message, level: 'DEBUG');
|
||||
void info(String tag, String message) => log(tag, message, level: 'INFO');
|
||||
void warning(String tag, String message) => log(tag, message, level: 'WARNING');
|
||||
void warning(String tag, String message) =>
|
||||
log(tag, message, level: 'WARNING');
|
||||
void error(String tag, String message) => log(tag, message, level: 'ERROR');
|
||||
|
||||
void clear() {
|
||||
@@ -73,8 +145,10 @@ class LogService with ChangeNotifier {
|
||||
|
||||
String exportLogs() {
|
||||
final buffer = StringBuffer();
|
||||
// Log messages are sanitized before storage; export should preserve them.
|
||||
for (final log in _logs) {
|
||||
buffer.writeln('${log.formattedTime} [${log.level}][${log.tag}] ${log.message}');
|
||||
buffer.writeln(
|
||||
'${log.formattedTime} [${log.level}][${log.tag}] ${log.message}');
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
@@ -30,8 +30,10 @@ class OfflineStorageService {
|
||||
}) async {
|
||||
final localId = _uuid.v4();
|
||||
|
||||
_log.info('OfflineStorage',
|
||||
'saveTransaction called: localId=$localId, accountId=$accountId, syncStatus=$syncStatus');
|
||||
_log.info(
|
||||
'OfflineStorage',
|
||||
'saveTransaction called with syncStatus=$syncStatus',
|
||||
);
|
||||
|
||||
final transaction = OfflineTransaction(
|
||||
id: serverId,
|
||||
@@ -54,8 +56,7 @@ class OfflineStorageService {
|
||||
|
||||
try {
|
||||
await _dbHelper.insertTransaction(transaction.toDatabaseMap());
|
||||
_log.info('OfflineStorage',
|
||||
'Transaction saved successfully with localId: $localId');
|
||||
_log.info('OfflineStorage', 'Transaction saved successfully');
|
||||
return transaction;
|
||||
} catch (e) {
|
||||
_log.error('OfflineStorage', 'Failed to save transaction: $e');
|
||||
@@ -65,21 +66,14 @@ class OfflineStorageService {
|
||||
|
||||
Future<List<OfflineTransaction>> getTransactions({String? accountId}) async {
|
||||
_log.debug(
|
||||
'OfflineStorage', 'getTransactions called with accountId: $accountId');
|
||||
'OfflineStorage',
|
||||
'getTransactions called${accountId != null ? " with account filter" : ""}',
|
||||
);
|
||||
final transactionMaps =
|
||||
await _dbHelper.getTransactions(accountId: accountId);
|
||||
_log.debug('OfflineStorage',
|
||||
'Retrieved ${transactionMaps.length} transaction maps from database');
|
||||
|
||||
if (transactionMaps.isNotEmpty && accountId != null) {
|
||||
_log.debug('OfflineStorage', 'Sample transaction account_ids:');
|
||||
for (int i = 0; i < transactionMaps.take(3).length; i++) {
|
||||
final map = transactionMaps[i];
|
||||
_log.debug('OfflineStorage',
|
||||
' - Transaction ${map['server_id']}: account_id="${map['account_id']}"');
|
||||
}
|
||||
}
|
||||
|
||||
final transactions = transactionMaps
|
||||
.map((map) => OfflineTransaction.fromDatabaseMap(map))
|
||||
.toList();
|
||||
@@ -139,14 +133,15 @@ class OfflineStorageService {
|
||||
|
||||
/// Mark a transaction for pending deletion (offline delete)
|
||||
Future<void> markTransactionForDeletion(String serverId) async {
|
||||
_log.info(
|
||||
'OfflineStorage', 'Marking transaction $serverId for pending deletion');
|
||||
_log.info('OfflineStorage', 'Marking transaction for pending deletion');
|
||||
|
||||
// Find the transaction by server ID
|
||||
final existing = await getTransactionByServerId(serverId);
|
||||
if (existing == null) {
|
||||
_log.warning('OfflineStorage',
|
||||
'Transaction $serverId not found, cannot mark for deletion');
|
||||
_log.warning(
|
||||
'OfflineStorage',
|
||||
'Transaction not found, cannot mark for deletion',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -158,33 +153,34 @@ class OfflineStorageService {
|
||||
|
||||
await _dbHelper.updateTransaction(
|
||||
existing.localId, updated.toDatabaseMap());
|
||||
_log.info('OfflineStorage',
|
||||
'Transaction ${existing.localId} marked as pending_delete');
|
||||
_log.info('OfflineStorage', 'Transaction marked as pending_delete');
|
||||
}
|
||||
|
||||
/// Undo a pending transaction operation (either pending create or pending delete)
|
||||
Future<bool> undoPendingTransaction(
|
||||
String localId, SyncStatus currentStatus) async {
|
||||
_log.info('OfflineStorage',
|
||||
'Undoing pending transaction $localId with status $currentStatus');
|
||||
_log.info(
|
||||
'OfflineStorage',
|
||||
'Undoing pending transaction with status $currentStatus',
|
||||
);
|
||||
|
||||
final existing = await getTransactionByLocalId(localId);
|
||||
if (existing == null) {
|
||||
_log.warning(
|
||||
'OfflineStorage', 'Transaction $localId not found, cannot undo');
|
||||
_log.warning('OfflineStorage', 'Transaction not found, cannot undo');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentStatus == SyncStatus.pending) {
|
||||
// For pending creates: delete the transaction completely
|
||||
_log.info(
|
||||
'OfflineStorage', 'Deleting pending create transaction $localId');
|
||||
_log.info('OfflineStorage', 'Deleting pending create transaction');
|
||||
await deleteTransaction(localId);
|
||||
return true;
|
||||
} else if (currentStatus == SyncStatus.pendingDelete) {
|
||||
// For pending deletes: restore to synced status
|
||||
_log.info('OfflineStorage',
|
||||
'Restoring pending delete transaction $localId to synced');
|
||||
_log.info(
|
||||
'OfflineStorage',
|
||||
'Restoring pending delete transaction to synced',
|
||||
);
|
||||
final updated = existing.copyWith(
|
||||
syncStatus: SyncStatus.synced,
|
||||
updatedAt: DateTime.now(),
|
||||
@@ -203,13 +199,6 @@ class OfflineStorageService {
|
||||
_log.info('OfflineStorage',
|
||||
'syncTransactionsFromServer called with ${serverTransactions.length} transactions from server');
|
||||
|
||||
// Log first transaction's accountId for debugging
|
||||
if (serverTransactions.isNotEmpty) {
|
||||
final firstTx = serverTransactions.first;
|
||||
_log.info('OfflineStorage',
|
||||
'First transaction: id=${firstTx.id}, accountId="${firstTx.accountId}", name="${firstTx.name}"');
|
||||
}
|
||||
|
||||
// Use upsert logic instead of clear + insert to preserve recently uploaded transactions
|
||||
_log.info('OfflineStorage',
|
||||
'Upserting all transactions from server (preserving pending/failed)');
|
||||
@@ -251,8 +240,11 @@ class OfflineStorageService {
|
||||
|
||||
// Log if transaction has empty accountId
|
||||
if (transaction.accountId.isEmpty) {
|
||||
_log.warning('OfflineStorage',
|
||||
'Transaction ${transaction.id} has empty accountId from server! Provided accountId: $accountId, effective: $effectiveAccountId');
|
||||
final fallbackApplied = accountId != null && accountId.isNotEmpty;
|
||||
_log.warning(
|
||||
'OfflineStorage',
|
||||
'Transaction has empty accountId from server; fallbackApplied=$fallbackApplied',
|
||||
);
|
||||
}
|
||||
|
||||
// Check if we already have this transaction
|
||||
@@ -264,8 +256,10 @@ class OfflineStorageService {
|
||||
effectiveAccountId.isEmpty ? existing.accountId : effectiveAccountId;
|
||||
|
||||
if (finalAccountId.isEmpty) {
|
||||
_log.error('OfflineStorage',
|
||||
'CRITICAL: Updating transaction ${transaction.id} with EMPTY accountId!');
|
||||
_log.error(
|
||||
'OfflineStorage',
|
||||
'CRITICAL: Updating transaction with EMPTY accountId; operation=update fallbackApplied=false existingAccountIdPresent=false',
|
||||
);
|
||||
}
|
||||
|
||||
final updated = existing.mergeServerTransaction(
|
||||
@@ -277,8 +271,10 @@ class OfflineStorageService {
|
||||
} else {
|
||||
// Insert new transaction
|
||||
if (effectiveAccountId.isEmpty) {
|
||||
_log.error('OfflineStorage',
|
||||
'CRITICAL: Inserting transaction ${transaction.id} with EMPTY accountId!');
|
||||
_log.error(
|
||||
'OfflineStorage',
|
||||
'CRITICAL: Inserting transaction with EMPTY accountId; operation=insert fallbackApplied=false',
|
||||
);
|
||||
}
|
||||
|
||||
final offlineTransaction = OfflineTransaction(
|
||||
|
||||
@@ -30,7 +30,8 @@ class SyncService with ChangeNotifier {
|
||||
|
||||
try {
|
||||
final pendingDeletes = await _offlineStorage.getPendingDeletes();
|
||||
_log.info('SyncService', 'Found ${pendingDeletes.length} pending deletes to process');
|
||||
_log.info('SyncService',
|
||||
'Found ${pendingDeletes.length} pending deletes to process');
|
||||
|
||||
if (pendingDeletes.isEmpty) {
|
||||
return SyncResult(success: true, syncedCount: 0);
|
||||
@@ -40,14 +41,16 @@ class SyncService with ChangeNotifier {
|
||||
try {
|
||||
// Only attempt to delete on server if the transaction has a server ID
|
||||
if (transaction.id != null && transaction.id!.isNotEmpty) {
|
||||
_log.info('SyncService', 'Deleting transaction ${transaction.id} from server');
|
||||
_log.info(
|
||||
'SyncService', 'Deleting pending transaction from server');
|
||||
final result = await _transactionsService.deleteTransaction(
|
||||
accessToken: accessToken,
|
||||
transactionId: transaction.id!,
|
||||
);
|
||||
|
||||
if (result['success'] == true) {
|
||||
_log.info('SyncService', 'Delete success! Removing from local storage');
|
||||
_log.info(
|
||||
'SyncService', 'Delete success! Removing from local storage');
|
||||
// Delete from local storage completely
|
||||
await _offlineStorage.deleteTransaction(transaction.localId);
|
||||
successCount++;
|
||||
@@ -63,7 +66,10 @@ class SyncService with ChangeNotifier {
|
||||
}
|
||||
} else {
|
||||
// No server ID means it was never synced to server, just delete locally
|
||||
_log.info('SyncService', 'Transaction ${transaction.localId} has no server ID, deleting locally only');
|
||||
_log.info(
|
||||
'SyncService',
|
||||
'Pending delete has no server ID, deleting locally only',
|
||||
);
|
||||
await _offlineStorage.deleteTransaction(transaction.localId);
|
||||
successCount++;
|
||||
}
|
||||
@@ -79,7 +85,8 @@ class SyncService with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
_log.info('SyncService', 'Delete complete: $successCount success, $failureCount failed');
|
||||
_log.info('SyncService',
|
||||
'Delete complete: $successCount success, $failureCount failed');
|
||||
|
||||
return SyncResult(
|
||||
success: failureCount == 0,
|
||||
@@ -99,14 +106,17 @@ class SyncService with ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Sync pending transactions to server (internal method without sync lock check)
|
||||
Future<SyncResult> _syncPendingTransactionsInternal(String accessToken) async {
|
||||
Future<SyncResult> _syncPendingTransactionsInternal(
|
||||
String accessToken) async {
|
||||
int successCount = 0;
|
||||
int failureCount = 0;
|
||||
String? lastError;
|
||||
|
||||
try {
|
||||
final pendingTransactions = await _offlineStorage.getPendingTransactions();
|
||||
_log.info('SyncService', 'Found ${pendingTransactions.length} pending transactions to upload');
|
||||
final pendingTransactions =
|
||||
await _offlineStorage.getPendingTransactions();
|
||||
_log.info('SyncService',
|
||||
'Found ${pendingTransactions.length} pending transactions to upload');
|
||||
|
||||
if (pendingTransactions.isEmpty) {
|
||||
return SyncResult(success: true, syncedCount: 0);
|
||||
@@ -114,7 +124,7 @@ class SyncService with ChangeNotifier {
|
||||
|
||||
for (final transaction in pendingTransactions) {
|
||||
try {
|
||||
_log.info('SyncService', 'Uploading transaction ${transaction.localId} (${transaction.name})');
|
||||
_log.info('SyncService', 'Uploading pending transaction');
|
||||
// Upload transaction to server
|
||||
final result = await _transactionsService.createTransaction(
|
||||
accessToken: accessToken,
|
||||
@@ -133,7 +143,8 @@ class SyncService with ChangeNotifier {
|
||||
if (result['success'] == true) {
|
||||
// Update local transaction with server ID and mark as synced
|
||||
final serverTransaction = result['transaction'] as Transaction;
|
||||
_log.info('SyncService', 'Upload success! Server ID: ${serverTransaction.id}');
|
||||
_log.info('SyncService',
|
||||
'Upload success; local transaction marked synced');
|
||||
await _offlineStorage.updateTransactionSyncStatus(
|
||||
localId: transaction.localId,
|
||||
syncStatus: SyncStatus.synced,
|
||||
@@ -162,7 +173,8 @@ class SyncService with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
_log.info('SyncService', 'Upload complete: $successCount success, $failureCount failed');
|
||||
_log.info('SyncService',
|
||||
'Upload complete: $successCount success, $failureCount failed');
|
||||
|
||||
return SyncResult(
|
||||
success: failureCount == 0,
|
||||
@@ -221,7 +233,12 @@ class SyncService with ChangeNotifier {
|
||||
}) async {
|
||||
try {
|
||||
_log.info('SyncService', '========== SYNC FROM SERVER START ==========');
|
||||
_log.info('SyncService', 'Fetching transactions from server (accountId: ${accountId ?? "ALL"})');
|
||||
_log.info(
|
||||
'SyncService',
|
||||
accountId == null
|
||||
? 'Fetching transactions for all accounts'
|
||||
: 'Fetching transactions for scoped account',
|
||||
);
|
||||
|
||||
List<Transaction> allTransactions = [];
|
||||
int currentPage = 1;
|
||||
@@ -230,7 +247,8 @@ class SyncService with ChangeNotifier {
|
||||
|
||||
// Fetch all pages
|
||||
while (currentPage <= totalPages) {
|
||||
_log.info('SyncService', '>>> Fetching page $currentPage of $totalPages (perPage: $perPage)');
|
||||
_log.info('SyncService',
|
||||
'>>> Fetching page $currentPage of $totalPages (perPage: $perPage)');
|
||||
|
||||
final result = await _transactionsService.getTransactions(
|
||||
accessToken: accessToken,
|
||||
@@ -239,15 +257,19 @@ class SyncService with ChangeNotifier {
|
||||
perPage: perPage,
|
||||
);
|
||||
|
||||
_log.debug('SyncService', 'API call completed for page $currentPage, success: ${result['success']}');
|
||||
_log.debug('SyncService',
|
||||
'API call completed for page $currentPage, success: ${result['success']}');
|
||||
|
||||
if (result['success'] == true) {
|
||||
final pageTransactions = (result['transactions'] as List<dynamic>?)
|
||||
?.cast<Transaction>() ?? [];
|
||||
final pageTransactions =
|
||||
(result['transactions'] as List<dynamic>?)?.cast<Transaction>() ??
|
||||
[];
|
||||
|
||||
_log.info('SyncService', 'Page $currentPage returned ${pageTransactions.length} transactions');
|
||||
_log.info('SyncService',
|
||||
'Page $currentPage returned ${pageTransactions.length} transactions');
|
||||
allTransactions.addAll(pageTransactions);
|
||||
_log.info('SyncService', 'Total transactions accumulated: ${allTransactions.length}');
|
||||
_log.info('SyncService',
|
||||
'Total transactions accumulated: ${allTransactions.length}');
|
||||
|
||||
// Extract pagination info if available
|
||||
final pagination = result['pagination'] as Map<String, dynamic>?;
|
||||
@@ -255,24 +277,35 @@ class SyncService with ChangeNotifier {
|
||||
final prevTotalPages = totalPages;
|
||||
totalPages = pagination['total_pages'] as int? ?? 1;
|
||||
final totalCount = pagination['total_count'] as int? ?? 0;
|
||||
final currentPageFromApi = pagination['page'] as int? ?? currentPage;
|
||||
final currentPageFromApi =
|
||||
pagination['page'] as int? ?? currentPage;
|
||||
final perPageFromApi = pagination['per_page'] as int? ?? perPage;
|
||||
|
||||
_log.info('SyncService', 'Pagination info: page=$currentPageFromApi/$totalPages, per_page=$perPageFromApi, total_count=$totalCount');
|
||||
if (currentPageFromApi != currentPage) {
|
||||
_log.warning('SyncService',
|
||||
'Pagination page mismatch: expected $currentPage, received $currentPageFromApi of $totalPages');
|
||||
}
|
||||
|
||||
_log.info('SyncService',
|
||||
'Pagination info: page=$currentPageFromApi/$totalPages, per_page=$perPageFromApi, total_count=$totalCount');
|
||||
|
||||
if (prevTotalPages != totalPages) {
|
||||
_log.info('SyncService', 'Total pages updated from $prevTotalPages to $totalPages');
|
||||
_log.info('SyncService',
|
||||
'Total pages updated from $prevTotalPages to $totalPages');
|
||||
}
|
||||
} else {
|
||||
// No pagination info means this is the only page
|
||||
_log.warning('SyncService', 'No pagination info in response - assuming single page');
|
||||
_log.warning('SyncService',
|
||||
'No pagination info in response - assuming single page');
|
||||
totalPages = currentPage;
|
||||
}
|
||||
|
||||
_log.info('SyncService', 'Moving to next page (current: $currentPage, total: $totalPages)');
|
||||
_log.info('SyncService',
|
||||
'Moving to next page (current: $currentPage, total: $totalPages)');
|
||||
currentPage++;
|
||||
} else {
|
||||
_log.error('SyncService', 'Server returned error on page $currentPage: ${result['error']}');
|
||||
_log.error('SyncService',
|
||||
'Server returned error on page $currentPage: ${result['error']}');
|
||||
return SyncResult(
|
||||
success: false,
|
||||
error: result['error'] as String? ?? 'Failed to sync from server',
|
||||
@@ -280,17 +313,23 @@ class SyncService with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
_log.info('SyncService', '>>> Pagination loop completed. Fetched ${currentPage - 1} pages');
|
||||
_log.info('SyncService', '>>> Received total of ${allTransactions.length} transactions from server');
|
||||
_log.info('SyncService',
|
||||
'>>> Pagination loop completed. Fetched ${currentPage - 1} pages');
|
||||
_log.info('SyncService',
|
||||
'>>> Received total of ${allTransactions.length} transactions from server');
|
||||
|
||||
// Update local cache with server data
|
||||
_log.info('SyncService', '========== UPDATING LOCAL CACHE ==========');
|
||||
if (accountId == null) {
|
||||
_log.info('SyncService', 'Full sync - clearing and replacing all transactions');
|
||||
_log.info('SyncService',
|
||||
'Full sync - clearing and replacing all transactions');
|
||||
// Full sync - replace all transactions
|
||||
await _offlineStorage.syncTransactionsFromServer(allTransactions);
|
||||
} else {
|
||||
_log.info('SyncService', 'Partial sync - upserting ${allTransactions.length} transactions for account $accountId');
|
||||
_log.info(
|
||||
'SyncService',
|
||||
'Partial sync - upserting ${allTransactions.length} transactions',
|
||||
);
|
||||
// Partial sync - upsert transactions
|
||||
int upsertCount = 0;
|
||||
for (final transaction in allTransactions) {
|
||||
@@ -300,13 +339,16 @@ class SyncService with ChangeNotifier {
|
||||
);
|
||||
upsertCount++;
|
||||
if (upsertCount % 50 == 0) {
|
||||
_log.info('SyncService', 'Upserted $upsertCount/${allTransactions.length} transactions');
|
||||
_log.info('SyncService',
|
||||
'Upserted $upsertCount/${allTransactions.length} transactions');
|
||||
}
|
||||
}
|
||||
_log.info('SyncService', 'Completed upserting $upsertCount transactions');
|
||||
_log.info(
|
||||
'SyncService', 'Completed upserting $upsertCount transactions');
|
||||
}
|
||||
|
||||
_log.info('SyncService', '========== SYNC FROM SERVER COMPLETE ==========');
|
||||
_log.info(
|
||||
'SyncService', '========== SYNC FROM SERVER COMPLETE ==========');
|
||||
_lastSyncTime = DateTime.now();
|
||||
notifyListeners();
|
||||
|
||||
@@ -326,7 +368,8 @@ class SyncService with ChangeNotifier {
|
||||
/// Sync accounts from server and update local cache
|
||||
Future<SyncResult> syncAccounts(String accessToken) async {
|
||||
try {
|
||||
final result = await _accountsService.getAccounts(accessToken: accessToken);
|
||||
final result =
|
||||
await _accountsService.getAccounts(accessToken: accessToken);
|
||||
|
||||
if (result['success'] == true) {
|
||||
final accountsList = result['accounts'] as List<dynamic>? ?? [];
|
||||
@@ -374,17 +417,20 @@ class SyncService with ChangeNotifier {
|
||||
// Step 1: Process pending deletes (do this first to free up resources)
|
||||
_log.info('SyncService', 'Step 1: Processing pending deletes');
|
||||
final deleteResult = await _syncPendingDeletesInternal(accessToken);
|
||||
_log.info('SyncService', 'Step 1 complete: ${deleteResult.syncedCount ?? 0} deleted, ${deleteResult.failedCount ?? 0} failed');
|
||||
_log.info('SyncService',
|
||||
'Step 1 complete: ${deleteResult.syncedCount ?? 0} deleted, ${deleteResult.failedCount ?? 0} failed');
|
||||
|
||||
// Step 2: Upload pending transactions
|
||||
_log.info('SyncService', 'Step 2: Uploading pending transactions');
|
||||
final uploadResult = await _syncPendingTransactionsInternal(accessToken);
|
||||
_log.info('SyncService', 'Step 2 complete: ${uploadResult.syncedCount ?? 0} uploaded, ${uploadResult.failedCount ?? 0} failed');
|
||||
_log.info('SyncService',
|
||||
'Step 2 complete: ${uploadResult.syncedCount ?? 0} uploaded, ${uploadResult.failedCount ?? 0} failed');
|
||||
|
||||
// Step 3: Download transactions from server
|
||||
_log.info('SyncService', 'Step 3: Downloading transactions from server');
|
||||
final downloadResult = await syncFromServer(accessToken: accessToken);
|
||||
_log.info('SyncService', 'Step 3 complete: ${downloadResult.syncedCount ?? 0} downloaded');
|
||||
_log.info('SyncService',
|
||||
'Step 3 complete: ${downloadResult.syncedCount ?? 0} downloaded');
|
||||
|
||||
// Step 4: Sync accounts
|
||||
_log.info('SyncService', 'Step 4: Syncing accounts');
|
||||
@@ -394,17 +440,29 @@ class SyncService with ChangeNotifier {
|
||||
_isSyncing = false;
|
||||
_lastSyncTime = DateTime.now();
|
||||
|
||||
final allSuccess = deleteResult.success && uploadResult.success && downloadResult.success && accountsResult.success;
|
||||
_syncError = allSuccess ? null : (deleteResult.error ?? uploadResult.error ?? downloadResult.error ?? accountsResult.error);
|
||||
final allSuccess = deleteResult.success &&
|
||||
uploadResult.success &&
|
||||
downloadResult.success &&
|
||||
accountsResult.success;
|
||||
_syncError = allSuccess
|
||||
? null
|
||||
: (deleteResult.error ??
|
||||
uploadResult.error ??
|
||||
downloadResult.error ??
|
||||
accountsResult.error);
|
||||
|
||||
_log.info('SyncService', '==== Full Sync Complete: ${allSuccess ? "SUCCESS" : "PARTIAL/FAILED"} ====');
|
||||
_log.info('SyncService',
|
||||
'==== Full Sync Complete: ${allSuccess ? "SUCCESS" : "PARTIAL/FAILED"} ====');
|
||||
|
||||
notifyListeners();
|
||||
|
||||
return SyncResult(
|
||||
success: allSuccess,
|
||||
syncedCount: (deleteResult.syncedCount ?? 0) + (uploadResult.syncedCount ?? 0) + (downloadResult.syncedCount ?? 0),
|
||||
failedCount: (deleteResult.failedCount ?? 0) + (uploadResult.failedCount ?? 0),
|
||||
syncedCount: (deleteResult.syncedCount ?? 0) +
|
||||
(uploadResult.syncedCount ?? 0) +
|
||||
(downloadResult.syncedCount ?? 0),
|
||||
failedCount:
|
||||
(deleteResult.failedCount ?? 0) + (uploadResult.failedCount ?? 0),
|
||||
error: _syncError,
|
||||
);
|
||||
} catch (e) {
|
||||
@@ -421,7 +479,8 @@ class SyncService with ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Auto sync if online - to be called when app regains connectivity
|
||||
Future<void> autoSync(String accessToken, ConnectivityService connectivityService) async {
|
||||
Future<void> autoSync(
|
||||
String accessToken, ConnectivityService connectivityService) async {
|
||||
if (connectivityService.isOnline && !_isSyncing) {
|
||||
await performFullSync(accessToken);
|
||||
}
|
||||
|
||||
51
mobile/test/services/auth_service_test.dart
Normal file
51
mobile/test/services/auth_service_test.dart
Normal file
@@ -0,0 +1,51 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sure_mobile/services/api_config.dart';
|
||||
import 'package:sure_mobile/services/auth_service.dart';
|
||||
|
||||
void main() {
|
||||
group('AuthService', () {
|
||||
late HttpServer server;
|
||||
|
||||
setUp(() async {
|
||||
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
ApiConfig.clearApiKeyAuth();
|
||||
ApiConfig.setCustomProxyHeaders([]);
|
||||
ApiConfig.setBaseUrl('http://${server.address.host}:${server.port}');
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
ApiConfig.setBaseUrl(ApiConfig.defaultBaseUrl);
|
||||
await server.close(force: true);
|
||||
});
|
||||
|
||||
test('login handles string errors payloads without throwing', () async {
|
||||
final subscription = server.listen((request) {
|
||||
if (request.method != 'POST' ||
|
||||
request.uri.path != '/api/v1/auth/login') {
|
||||
request.response.statusCode = 404;
|
||||
request.response.close();
|
||||
return;
|
||||
}
|
||||
|
||||
request.response
|
||||
..statusCode = 422
|
||||
..headers.contentType = ContentType.json
|
||||
..write(jsonEncode({'errors': 'Invalid login payload'}))
|
||||
..close();
|
||||
});
|
||||
addTearDown(subscription.cancel);
|
||||
|
||||
final result = await AuthService().login(
|
||||
email: 'user@example.test',
|
||||
password: 'password',
|
||||
deviceInfo: const {'platform': 'test'},
|
||||
);
|
||||
|
||||
expect(result['success'], false);
|
||||
expect(result['error'], 'Invalid login payload');
|
||||
});
|
||||
});
|
||||
}
|
||||
166
mobile/test/services/log_service_test.dart
Normal file
166
mobile/test/services/log_service_test.dart
Normal file
@@ -0,0 +1,166 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sure_mobile/services/log_service.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
LogService.instance.clear();
|
||||
});
|
||||
|
||||
test('sanitize redacts authentication and identity values', () {
|
||||
final sanitized = LogService.sanitize(
|
||||
'Authorization: Bearer secret-token '
|
||||
'X-Api-Key=mobile-secret '
|
||||
'email=user@example.com '
|
||||
'accountId=123e4567-e89b-12d3-a456-426614174000 '
|
||||
'backendUrl=https://sure.example.test/api',
|
||||
);
|
||||
|
||||
expect(sanitized, isNot(contains('secret-token')));
|
||||
expect(sanitized, isNot(contains('mobile-secret')));
|
||||
expect(sanitized, isNot(contains('user@example.com')));
|
||||
expect(sanitized, isNot(contains('123e4567-e89b-12d3-a456-426614174000')));
|
||||
expect(sanitized, isNot(contains('sure.example.test')));
|
||||
expect(sanitized, contains('[redacted]'));
|
||||
});
|
||||
|
||||
test('sanitize redacts host-only socket error backends', () {
|
||||
final sanitized = LogService.sanitize(
|
||||
"SocketException: Failed host lookup: 'private.internal' "
|
||||
'(OS Error: nodename nor servname provided, errno = 8)',
|
||||
);
|
||||
|
||||
expect(sanitized, contains('Failed host lookup: [host]'));
|
||||
expect(sanitized, isNot(contains('private.internal')));
|
||||
|
||||
final unquoted = LogService.sanitize(
|
||||
'SocketException: Failed host lookup: private.internal '
|
||||
'(OS Error: lookup failed)',
|
||||
);
|
||||
expect(unquoted, contains('Failed host lookup: [host]'));
|
||||
expect(unquoted, isNot(contains('private.internal')));
|
||||
});
|
||||
|
||||
test('sanitize redacts financial and merchant values', () {
|
||||
final sanitized = LogService.sanitize(
|
||||
'transactionName=Coffee amount=123.45 merchantName="Corner Store" '
|
||||
'"transaction_id":"txn_123","local_id":"local_123"',
|
||||
);
|
||||
|
||||
expect(sanitized, contains('[redacted]'));
|
||||
expect(sanitized, isNot(contains('Coffee')));
|
||||
expect(sanitized, isNot(contains('123.45')));
|
||||
expect(sanitized, isNot(contains('Corner Store')));
|
||||
expect(sanitized, isNot(contains('txn_123')));
|
||||
expect(sanitized, isNot(contains('local_123')));
|
||||
});
|
||||
|
||||
test('sanitize redacts long numeric ids but preserves shorter counts', () {
|
||||
final longNumericId = LogService.sanitize('orderId=12345678901234');
|
||||
final shorterCount = LogService.sanitize('count=123456789012');
|
||||
final millisecondTimestamp = LogService.sanitize('timestamp=1717605296123');
|
||||
|
||||
expect(longNumericId, contains('orderId=[id]'));
|
||||
expect(longNumericId, isNot(contains('12345678901234')));
|
||||
expect(shorterCount, contains('123456789012'));
|
||||
expect(millisecondTimestamp, contains('timestamp=1717605296123'));
|
||||
});
|
||||
|
||||
test('sanitize handles empty and safe messages', () {
|
||||
expect(LogService.sanitize(''), '');
|
||||
|
||||
const message = 'Fetched 25 transactions on page 2 with syncStatus=pending '
|
||||
'filename=main.dart pageName=transactions hostname=localhost '
|
||||
'animationName=fadeIn';
|
||||
expect(LogService.sanitize(message), message);
|
||||
});
|
||||
|
||||
test('sanitize redacts repeated sensitive values', () {
|
||||
final sanitized = LogService.sanitize(
|
||||
'email=one@example.com email=two@example.com '
|
||||
'Authorization: Bearer first-token Authorization: Bearer second-token',
|
||||
);
|
||||
|
||||
expect(sanitized, isNot(contains('one@example.com')));
|
||||
expect(sanitized, isNot(contains('two@example.com')));
|
||||
expect(sanitized, isNot(contains('first-token')));
|
||||
expect(sanitized, isNot(contains('second-token')));
|
||||
expect(
|
||||
'[redacted]'.allMatches(sanitized), hasLength(greaterThanOrEqualTo(4)));
|
||||
});
|
||||
|
||||
test('auth service style errors redact credentials and backend values', () {
|
||||
LogService.instance.error(
|
||||
'AuthService',
|
||||
'Login SocketException: Failed host lookup: '
|
||||
"'private.internal' password=secret otp_code=123456 "
|
||||
'Authorization: Bearer token-123 email=user@example.com '
|
||||
'https://sure.example.test/login',
|
||||
);
|
||||
|
||||
final exported = LogService.instance.exportLogs();
|
||||
expect(exported, isNot(contains('private.internal')));
|
||||
expect(exported, isNot(contains('secret')));
|
||||
expect(exported, isNot(contains('123456')));
|
||||
expect(exported, isNot(contains('token-123')));
|
||||
expect(exported, isNot(contains('user@example.com')));
|
||||
expect(exported, isNot(contains('sure.example.test')));
|
||||
});
|
||||
|
||||
test('log storage and export use sanitized messages', () {
|
||||
LogService.instance.info(
|
||||
'Test',
|
||||
'Saved transaction transactionName=Coffee amount=9.99 email=user@example.com',
|
||||
);
|
||||
|
||||
expect(LogService.instance.logs, hasLength(1));
|
||||
expect(LogService.instance.logs.single.message, isNot(contains('Coffee')));
|
||||
expect(LogService.instance.logs.single.message, isNot(contains('9.99')));
|
||||
expect(LogService.instance.logs.single.message,
|
||||
isNot(contains('user@example.com')));
|
||||
|
||||
final exported = LogService.instance.exportLogs();
|
||||
expect(exported, isNot(contains('Coffee')));
|
||||
expect(exported, isNot(contains('9.99')));
|
||||
expect(exported, isNot(contains('user@example.com')));
|
||||
});
|
||||
|
||||
test('all log levels store sanitized messages', () {
|
||||
LogService.instance.debug('Test', 'accessToken=debug-secret');
|
||||
LogService.instance.warning('Test', 'merchantName=Warning Store');
|
||||
LogService.instance.error('Test', 'backendUrl=https://sure.example.test');
|
||||
|
||||
final exported = LogService.instance.exportLogs();
|
||||
expect(exported, isNot(contains('debug-secret')));
|
||||
expect(exported, isNot(contains('Warning Store')));
|
||||
expect(exported, isNot(contains('sure.example.test')));
|
||||
});
|
||||
|
||||
test('export redacts long interleaved diagnostic messages', () {
|
||||
final safeChunks = List.filled(50, 'sync page ok').join(' ');
|
||||
LogService.instance.info(
|
||||
'Test',
|
||||
'$safeChunks accountId=123e4567-e89b-12d3-a456-426614174000 '
|
||||
'amount=123456.78 merchantName="Big Store" $safeChunks',
|
||||
);
|
||||
|
||||
final exported = LogService.instance.exportLogs();
|
||||
expect(exported, contains('sync page ok'));
|
||||
expect(exported, isNot(contains('123e4567-e89b-12d3-a456-426614174000')));
|
||||
expect(exported, isNot(contains('123456.78')));
|
||||
expect(exported, isNot(contains('Big Store')));
|
||||
});
|
||||
|
||||
test('sanitize preserves safe operational diagnostics', () {
|
||||
final sanitized = LogService.sanitize(
|
||||
'Fetched 25 transactions on page 2 with syncStatus=pending '
|
||||
'filename=main.dart pageName=transactions hostname=localhost',
|
||||
);
|
||||
|
||||
expect(sanitized, contains('Fetched 25 transactions'));
|
||||
expect(sanitized, contains('page 2'));
|
||||
expect(sanitized, contains('syncStatus=pending'));
|
||||
expect(sanitized, contains('filename=main.dart'));
|
||||
expect(sanitized, contains('pageName=transactions'));
|
||||
expect(sanitized, contains('hostname=localhost'));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user