From f2a99b1d59ed55134f3e41af689620650dec8fb2 Mon Sep 17 00:00:00 2001 From: Fuh Austin Date: Wed, 29 Jul 2026 13:18:52 +0100 Subject: [PATCH 1/6] fix(sync): Repair serialization contract violations and heal orphaned records Server datetimes are truncated to milliseconds (the API rejects Dart's 6-digit microseconds with 422), multipart booleans go as 1/0, transactions post as JSON unless files are attached, budgets marshal round-trippably for the local change queue (with a patch for legacy server-shaped payloads), and transactions tolerate payloads without categories. A reconciliation sweep before each sync cycle re-enqueues records that have no server id and no local_changes row, and transfer upserts backfill transferClientId on their legs so legs that arrive before their transfer stop rendering as editable plain transactions. --- lib/core/sync/sync_database.dart | 53 +++++ lib/core/utils/date_util.dart | 7 +- lib/data/database/app_database.dart | 16 +- .../budget/dtos/budget_complete_dto.dart | 38 ++++ .../dto/transaction_complete_dto.dart | 15 +- .../dto/transaction_complete_dto.freezed.dart | 45 ++-- .../dto/transaction_complete_dto.g.dart | 13 +- .../transaction/dto/transaction_dto.dart | 1 + .../transaction/dto/transaction_dto.g.dart | 2 +- .../transaction_remote_datasource.dart | 42 ++-- lib/data/sync/budget_sync_handler.dart | 4 +- lib/data/sync/transfer_sync_handler.dart | 15 ++ test/unit/budget_marshal_roundtrip_test.dart | 76 +++++++ test/unit/date_util_test.dart | 41 ++++ test/unit/offline_dependency_sync_test.dart | 19 +- .../orphaned_change_reconciliation_test.dart | 194 ++++++++++++++++++ test/unit/sync_snapshot_unmarshal_test.dart | 21 ++ test/unit/transaction_server_json_test.dart | 78 +++++++ test/unit/transfer_leg_link_test.dart | 74 +++++++ 19 files changed, 702 insertions(+), 52 deletions(-) create mode 100644 test/unit/budget_marshal_roundtrip_test.dart create mode 100644 test/unit/date_util_test.dart create mode 100644 test/unit/orphaned_change_reconciliation_test.dart create mode 100644 test/unit/transaction_server_json_test.dart create mode 100644 test/unit/transfer_leg_link_test.dart diff --git a/lib/core/sync/sync_database.dart b/lib/core/sync/sync_database.dart index 8daef8c5..04e8f494 100644 --- a/lib/core/sync/sync_database.dart +++ b/lib/core/sync/sync_database.dart @@ -1,8 +1,12 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:drift_sync_core/drift_sync_core.dart'; import 'package:injectable/injectable.dart'; +import 'package:trakli/core/utils/services/logger.dart' as app_logger; import 'package:trakli/data/database/app_database.dart'; +import 'package:trakli/data/sync/transaction_sync_handler.dart'; +import 'package:trakli/data/sync/transfer_sync_handler.dart'; @lazySingleton class SynchAppDatabase extends DriftSynchronizer { @@ -18,10 +22,59 @@ class SynchAppDatabase extends DriftSynchronizer { classifyFailure: restFailureClassifier, ); + /// Entity types swept by [reconcileOrphanedLocalChanges] → backing tables. + static const Map reconciledEntityTables = { + TransactionSyncHandler.entity: 'transactions', + TransferSyncHandler.entity: 'transfers', + }; + final _syncStateController = StreamController.broadcast(); Stream get syncStateStream => _syncStateController.stream; + @override + Future sync() async { + try { + await reconcileOrphanedLocalChanges(); + } catch (e) { + app_logger.logger.w('[sync] orphan reconciliation failed: $e'); + } + return super.sync(); + } + + /// Re-enqueues rows that have no server id and no local_changes entry. + Future reconcileOrphanedLocalChanges() async { + var enqueued = 0; + for (final entry in reconciledEntityTables.entries) { + final handler = + typeHandlers.where((h) => h.entityType == entry.key).firstOrNull; + if (handler == null) continue; + + final orphanIds = + await appDatabase.getOrphanedClientIds(entry.value, entry.key); + for (final clientId in orphanIds) { + try { + final entity = await handler.getLocalByClientId(clientId); + await appDatabase.insertLocalChange(PendingLocalChange.put( + entityType: entry.key, + entityData: handler.marshal(entity), + entityId: clientId, + entityRev: handler.getRev(entity), + )); + enqueued++; + } catch (e) { + app_logger.logger + .w('[sync] could not re-enqueue ${entry.key} $clientId: $e'); + } + } + } + if (enqueued > 0) { + app_logger.logger + .i('[sync] re-enqueued $enqueued orphaned local change(s)'); + } + return enqueued; + } + @override Future Function(SyncState previous, SyncState current)? get onStateChanged => diff --git a/lib/core/utils/date_util.dart b/lib/core/utils/date_util.dart index f14be996..73e90f2e 100644 --- a/lib/core/utils/date_util.dart +++ b/lib/core/utils/date_util.dart @@ -1,5 +1,10 @@ +/// Truncated to milliseconds: the API rejects 6-digit fractional seconds. String formatServerIsoDateTimeString(DateTime dateTime) { - return dateTime.toUtc().toIso8601String(); + final utc = dateTime.toUtc(); + return DateTime.fromMillisecondsSinceEpoch( + utc.millisecondsSinceEpoch, + isUtc: true, + ).toIso8601String(); } DateTime getNewFormattedUtcDateTime() { diff --git a/lib/data/database/app_database.dart b/lib/data/database/app_database.dart index 7dad9a0c..937c2e9a 100644 --- a/lib/data/database/app_database.dart +++ b/lib/data/database/app_database.dart @@ -6,6 +6,7 @@ import 'package:drift/native.dart'; import 'package:drift_sync_core/drift_sync_core.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; +import 'package:trakli/core/sync/sync_error_description.dart'; import 'package:trakli/core/utils/services/logger.dart'; import 'package:trakli/data/database/converters/budget_progress_json_converter.dart'; import 'package:trakli/data/database/converters/media_converter.dart'; @@ -164,6 +165,19 @@ class AppDatabase extends _$AppDatabase with SynchronizerDb { await delete(localChanges).go(); } + /// Client ids with no server id and no local_changes row in any state. + Future> getOrphanedClientIds( + String tableName, String entityType) async { + final rows = await customSelect( + 'SELECT t.client_id AS client_id FROM $tableName t ' + 'WHERE t.id IS NULL AND t.deleted_at IS NULL AND NOT EXISTS (' + 'SELECT 1 FROM local_changes lc ' + 'WHERE lc.entity_type = ?1 AND lc.entity_id = t.client_id)', + variables: [Variable.withString(entityType)], + ).get(); + return rows.map((r) => r.read('client_id')).toList(); + } + /// True while transaction or transfer changes are still waiting to sync — /// server /stats cannot include them yet. Dismissed and quarantined /// changes don't count: dismissed changes never sync, and quarantined @@ -219,7 +233,7 @@ class AppDatabase extends _$AppDatabase with SynchronizerDb { .write( LocalChangesCompanion( concludedMoment: Value(DateTime.now()), - error: Value(error.toString()), + error: Value(describeSyncError(error)), concluded: const Value(true), attemptCount: Value(localChange.attemptCount + 1), quarantinedAt: diff --git a/lib/data/datasources/budget/dtos/budget_complete_dto.dart b/lib/data/datasources/budget/dtos/budget_complete_dto.dart index 1edd2305..ed46a943 100644 --- a/lib/data/datasources/budget/dtos/budget_complete_dto.dart +++ b/lib/data/datasources/budget/dtos/budget_complete_dto.dart @@ -38,6 +38,44 @@ class BudgetCompleteDto { ); } + /// Round-trippable queue format; the server payload shape is [toServerJson]. + Map toJson() { + return { + 'budget': budget.toJson(), + 'targets': targets.map((t) => t.toJson()).toList(), + }; + } + + factory BudgetCompleteDto.fromJson(Map json) { + final rawBudget = json['budget']; + if (rawBudget is Map) { + final rawTargets = json['targets']; + return BudgetCompleteDto( + budget: Budget.fromJson(rawBudget), + targets: (rawTargets is List) + ? rawTargets + .whereType>() + .map(BudgetTargetDto.fromJson) + .toList() + : [], + ); + } + // Legacy server-shaped queue payload: restore drift-required fields. + final patched = Map.from(json); + patched[JsonDefaultsHelper.clientGeneratedIdField] ??= + patched['client_id']; + patched['slug'] ??= + ((patched['name'] as String?) ?? '').toLowerCase().replaceAll(' ', '-'); + patched['owner_type'] ??= 'user'; + final amount = patched['amount']; + if (amount is num) patched['amount'] = amount.toString(); + final fallbackMoment = + patched['start_date'] ?? DateTime.now().toUtc().toIso8601String(); + patched['created_at'] ??= fallbackMoment; + patched['updated_at'] ??= fallbackMoment; + return BudgetCompleteDto.fromServerJson(patched); + } + Map toServerJson() { return { if (budget.clientId.isNotEmpty) 'client_id': budget.clientId, diff --git a/lib/data/datasources/transaction/dto/transaction_complete_dto.dart b/lib/data/datasources/transaction/dto/transaction_complete_dto.dart index 6b2d05ed..422ea167 100644 --- a/lib/data/datasources/transaction/dto/transaction_complete_dto.dart +++ b/lib/data/datasources/transaction/dto/transaction_complete_dto.dart @@ -122,11 +122,17 @@ class TransactionCompleteDto with _$TransactionCompleteDto { const factory TransactionCompleteDto({ @TransactionConverter() required Transaction transaction, - @CategoryConverter() @Default([]) List categories, + @CategoryConverter() + @JsonKey(defaultValue: []) + @Default([]) + List categories, @WalletConverter() required Wallet wallet, @PartyConverter() Party? party, @GroupConverter() Group? group, - @MediaFileListConverter() @Default([]) List files, + @MediaFileListConverter() + @JsonKey(defaultValue: []) + @Default([]) + List files, }) = _TransactionCompleteDto; factory TransactionCompleteDto.fromTransaction({ @@ -200,8 +206,9 @@ class TransactionCompleteDto with _$TransactionCompleteDto { factory TransactionCompleteDto.fromServerJson(Map json) { final transactionDto = TransactionDTO.fromJson(json); - final categories = (json['categories'] as List) - .map((c) => Category.fromJson(c as Map)) + final categories = (json['categories'] as List? ?? const []) + .whereType>() + .map(Category.fromJson) .toList(); final wallet = diff --git a/lib/data/datasources/transaction/dto/transaction_complete_dto.freezed.dart b/lib/data/datasources/transaction/dto/transaction_complete_dto.freezed.dart index 232a5777..deda7c74 100644 --- a/lib/data/datasources/transaction/dto/transaction_complete_dto.freezed.dart +++ b/lib/data/datasources/transaction/dto/transaction_complete_dto.freezed.dart @@ -19,6 +19,7 @@ mixin _$TransactionCompleteDto { @TransactionConverter() Transaction get transaction => throw _privateConstructorUsedError; @CategoryConverter() + @JsonKey(defaultValue: []) List get categories => throw _privateConstructorUsedError; @WalletConverter() Wallet get wallet => throw _privateConstructorUsedError; @@ -27,6 +28,7 @@ mixin _$TransactionCompleteDto { @GroupConverter() Group? get group => throw _privateConstructorUsedError; @MediaFileListConverter() + @JsonKey(defaultValue: []) List get files => throw _privateConstructorUsedError; /// Create a copy of TransactionCompleteDto @@ -44,11 +46,13 @@ abstract class $TransactionCompleteDtoCopyWith<$Res> { @useResult $Res call( {@TransactionConverter() Transaction transaction, - @CategoryConverter() List categories, + @CategoryConverter() @JsonKey(defaultValue: []) List categories, @WalletConverter() Wallet wallet, @PartyConverter() Party? party, @GroupConverter() Group? group, - @MediaFileListConverter() List files}); + @MediaFileListConverter() + @JsonKey(defaultValue: []) + List files}); } /// @nodoc @@ -114,11 +118,13 @@ abstract class _$$TransactionCompleteDtoImplCopyWith<$Res> @useResult $Res call( {@TransactionConverter() Transaction transaction, - @CategoryConverter() List categories, + @CategoryConverter() @JsonKey(defaultValue: []) List categories, @WalletConverter() Wallet wallet, @PartyConverter() Party? party, @GroupConverter() Group? group, - @MediaFileListConverter() List files}); + @MediaFileListConverter() + @JsonKey(defaultValue: []) + List files}); } /// @nodoc @@ -177,11 +183,15 @@ class __$$TransactionCompleteDtoImplCopyWithImpl<$Res> class _$TransactionCompleteDtoImpl extends _TransactionCompleteDto { const _$TransactionCompleteDtoImpl( {@TransactionConverter() required this.transaction, - @CategoryConverter() final List categories = const [], + @CategoryConverter() + @JsonKey(defaultValue: []) + final List categories = const [], @WalletConverter() required this.wallet, @PartyConverter() this.party, @GroupConverter() this.group, - @MediaFileListConverter() final List files = const []}) + @MediaFileListConverter() + @JsonKey(defaultValue: []) + final List files = const []}) : _categories = categories, _files = files, super._(); @@ -191,8 +201,8 @@ class _$TransactionCompleteDtoImpl extends _TransactionCompleteDto { final Transaction transaction; final List _categories; @override - @JsonKey() @CategoryConverter() + @JsonKey(defaultValue: []) List get categories { if (_categories is EqualUnmodifiableListView) return _categories; // ignore: implicit_dynamic_type @@ -210,8 +220,8 @@ class _$TransactionCompleteDtoImpl extends _TransactionCompleteDto { final Group? group; final List _files; @override - @JsonKey() @MediaFileListConverter() + @JsonKey(defaultValue: []) List get files { if (_files is EqualUnmodifiableListView) return _files; // ignore: implicit_dynamic_type @@ -260,13 +270,16 @@ class _$TransactionCompleteDtoImpl extends _TransactionCompleteDto { abstract class _TransactionCompleteDto extends TransactionCompleteDto { const factory _TransactionCompleteDto( - {@TransactionConverter() required final Transaction transaction, - @CategoryConverter() final List categories, - @WalletConverter() required final Wallet wallet, - @PartyConverter() final Party? party, - @GroupConverter() final Group? group, - @MediaFileListConverter() final List files}) = - _$TransactionCompleteDtoImpl; + {@TransactionConverter() required final Transaction transaction, + @CategoryConverter() + @JsonKey(defaultValue: []) + final List categories, + @WalletConverter() required final Wallet wallet, + @PartyConverter() final Party? party, + @GroupConverter() final Group? group, + @MediaFileListConverter() + @JsonKey(defaultValue: []) + final List files}) = _$TransactionCompleteDtoImpl; const _TransactionCompleteDto._() : super._(); @override @@ -274,6 +287,7 @@ abstract class _TransactionCompleteDto extends TransactionCompleteDto { Transaction get transaction; @override @CategoryConverter() + @JsonKey(defaultValue: []) List get categories; @override @WalletConverter() @@ -286,6 +300,7 @@ abstract class _TransactionCompleteDto extends TransactionCompleteDto { Group? get group; @override @MediaFileListConverter() + @JsonKey(defaultValue: []) List get files; /// Create a copy of TransactionCompleteDto diff --git a/lib/data/datasources/transaction/dto/transaction_complete_dto.g.dart b/lib/data/datasources/transaction/dto/transaction_complete_dto.g.dart index 49b1f3a1..2f5762f1 100644 --- a/lib/data/datasources/transaction/dto/transaction_complete_dto.g.dart +++ b/lib/data/datasources/transaction/dto/transaction_complete_dto.g.dart @@ -11,17 +11,20 @@ TransactionCompleteDto _$TransactionCompleteDtoFromJson( TransactionCompleteDto( transaction: const TransactionConverter() .fromJson(json['transaction'] as Map), - categories: (json['categories'] as List) - .map((e) => - const CategoryConverter().fromJson(e as Map)) - .toList(), + categories: (json['categories'] as List?) + ?.map((e) => + const CategoryConverter().fromJson(e as Map)) + .toList() ?? + [], wallet: const WalletConverter() .fromJson(json['wallet'] as Map), party: const PartyConverter() .fromJson(json['party'] as Map?), group: const GroupConverter() .fromJson(json['group'] as Map?), - files: const MediaFileListConverter().fromJson(json['files'] as List), + files: json['files'] == null + ? [] + : const MediaFileListConverter().fromJson(json['files'] as List), ); Map _$TransactionCompleteDtoToJson( diff --git a/lib/data/datasources/transaction/dto/transaction_dto.dart b/lib/data/datasources/transaction/dto/transaction_dto.dart index e4bb983c..8c1f5902 100644 --- a/lib/data/datasources/transaction/dto/transaction_dto.dart +++ b/lib/data/datasources/transaction/dto/transaction_dto.dart @@ -26,6 +26,7 @@ class TransactionDTO { @JsonKey(name: 'user_id') final int userId; final WalletDto? wallet; + @JsonKey(defaultValue: []) final List categories; @JsonKey(name: 'last_synced_at') final DateTime lastSyncedAt; diff --git a/lib/data/datasources/transaction/dto/transaction_dto.g.dart b/lib/data/datasources/transaction/dto/transaction_dto.g.dart index 835f4cab..9697b4c1 100644 --- a/lib/data/datasources/transaction/dto/transaction_dto.g.dart +++ b/lib/data/datasources/transaction/dto/transaction_dto.g.dart @@ -21,7 +21,7 @@ TransactionDTO _$TransactionDTOFromJson(Map json) => wallet: json['wallet'] == null ? null : WalletDto.fromJson(json['wallet'] as Map), - categories: json['categories'] as List, + categories: json['categories'] as List? ?? [], lastSyncedAt: DateTime.parse(json['last_synced_at'] as String), deletedAt: json['deleted_at'] == null ? null diff --git a/lib/data/datasources/transaction/transaction_remote_datasource.dart b/lib/data/datasources/transaction/transaction_remote_datasource.dart index 6fb54878..818a4888 100644 --- a/lib/data/datasources/transaction/transaction_remote_datasource.dart +++ b/lib/data/datasources/transaction/transaction_remote_datasource.dart @@ -9,6 +9,12 @@ import 'package:trakli/data/datasources/core/api_response.dart'; import 'package:trakli/data/datasources/core/pagination_response.dart'; import 'package:trakli/data/datasources/transaction/dto/transaction_complete_dto.dart'; +/// Booleans as '1'/'0': Laravel's boolean rule rejects 'true'/'false'. +String formDataFieldValue(dynamic value) { + if (value is bool) return value ? '1' : '0'; + return value.toString(); +} + abstract class TransactionRemoteDataSource { Future> getAllTransactions( {DateTime? syncedSince, bool? noClientId}); @@ -82,7 +88,7 @@ class TransactionRemoteDataSourceImpl implements TransactionRemoteDataSource { 'page': currentPage, }; if (syncedSince != null) { - queryParams['synced_since'] = syncedSince.toIso8601String(); + queryParams['synced_since'] = formatServerIsoDateTimeString(syncedSince); } if (noClientId != null) { queryParams['no_client_id'] = noClientId; @@ -127,23 +133,31 @@ class TransactionRemoteDataSourceImpl implements TransactionRemoteDataSource { )); } } - // API expects all fields at the same level (client_id, amount, type, ..., files[]). - final formData = FormData(); - for (final e in serverJson.entries) { - if (e.value == null) continue; - if (e.value is List) { - for (final item in e.value as List) { - formData.fields.add(MapEntry('${e.key}[]', item.toString())); + // JSON preserves bool/int types; multipart only when files are attached. + final Object payload; + if (multipartFiles.isEmpty) { + payload = serverJson; + } else { + // API expects all fields at the same level (client_id, amount, ..., files[]). + final formData = FormData(); + for (final e in serverJson.entries) { + if (e.value == null) continue; + if (e.value is List) { + for (final item in e.value as List) { + formData.fields + .add(MapEntry('${e.key}[]', formDataFieldValue(item))); + } + } else { + formData.fields.add(MapEntry(e.key, formDataFieldValue(e.value))); } - } else { - formData.fields.add(MapEntry(e.key, e.value.toString())); } - } - for (final f in multipartFiles) { - formData.files.add(MapEntry('files[]', f)); + for (final f in multipartFiles) { + formData.files.add(MapEntry('files[]', f)); + } + payload = formData; } - final response = await dio.post('transactions', data: formData); + final response = await dio.post('transactions', data: payload); final data = response.data; final apiResponse = ApiResponse.fromJson(data as Map); diff --git a/lib/data/sync/budget_sync_handler.dart b/lib/data/sync/budget_sync_handler.dart index 6ffbf922..17c523da 100644 --- a/lib/data/sync/budget_sync_handler.dart +++ b/lib/data/sync/budget_sync_handler.dart @@ -47,12 +47,12 @@ class BudgetSyncHandler @override Future unmarshal(Map entityJson) async { - return BudgetCompleteDto.fromServerJson(entityJson); + return BudgetCompleteDto.fromJson(entityJson); } @override Map marshal(BudgetCompleteDto entity) { - return entity.toServerJson(); + return entity.toJson(); } @override diff --git a/lib/data/sync/transfer_sync_handler.dart b/lib/data/sync/transfer_sync_handler.dart index 3230523c..083a7251 100644 --- a/lib/data/sync/transfer_sync_handler.dart +++ b/lib/data/sync/transfer_sync_handler.dart @@ -152,9 +152,23 @@ class TransferSyncHandler extends SyncTypeHandler await table.deleteWhere((t) => t.clientId.equals(entity.clientId)); } + /// The UI keys transfer presentation on the legs' transferClientId, so + /// legs that arrived before their transfer must be linked here. + Future _linkLegs(Transfer entity) async { + if (entity.deletedAt != null || entity.clientId.isEmpty) return; + final legIds = [ + entity.expenseTransactionClientId, + entity.incomeTransactionClientId, + ].whereType().where((id) => id.isNotEmpty).toList(); + if (legIds.isEmpty) return; + await (db.update(db.transactions)..where((t) => t.clientId.isIn(legIds))) + .write(TransactionsCompanion(transferClientId: Value(entity.clientId))); + } + @override Future upsertLocal(Transfer entity) async { await table.insertOne(entity, mode: InsertMode.insertOrReplace); + await _linkLegs(entity); } @override @@ -168,6 +182,7 @@ class TransferSyncHandler extends SyncTypeHandler await table.deleteWhere((t) => t.clientId.equals(entity.clientId)); } else { await table.insertOnConflictUpdate(entity); + await _linkLegs(entity); } } } diff --git a/test/unit/budget_marshal_roundtrip_test.dart b/test/unit/budget_marshal_roundtrip_test.dart new file mode 100644 index 00000000..b58be946 --- /dev/null +++ b/test/unit/budget_marshal_roundtrip_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:trakli/data/database/app_database.dart'; +import 'package:trakli/data/datasources/budget/dtos/budget_complete_dto.dart'; +import 'package:trakli/data/datasources/transaction/transaction_remote_datasource.dart'; +import 'package:trakli/presentation/utils/enums.dart'; + +void main() { + group('BudgetCompleteDto queue marshaling', () { + final budget = Budget( + clientId: 'client-1', + name: 'Groceries', + slug: 'groceries', + ownerType: 'user', + amount: 800.0, + currency: 'USD', + periodType: BudgetPeriodType.monthly, + startDate: DateTime.utc(2026, 7, 1), + rolloverEnabled: false, + thresholdPercent: 80, + forecastAlertsEnabled: false, + isActive: true, + createdAt: DateTime.utc(2026, 7, 1, 10), + updatedAt: DateTime.utc(2026, 7, 2, 10), + ); + + test('toJson/fromJson round-trips the full budget', () { + final dto = BudgetCompleteDto(budget: budget); + + final restored = BudgetCompleteDto.fromJson(dto.toJson()); + + expect(restored.budget.clientId, 'client-1'); + expect(restored.budget.name, 'Groceries'); + expect(restored.budget.createdAt.toUtc(), budget.createdAt); + expect(restored.budget.updatedAt.toUtc(), budget.updatedAt); + expect(restored.budget.startDate.toUtc(), budget.startDate); + }); + + test('legacy server-shaped queue payload unmarshals without crashing', () { + // Exact shape produced by toServerJson() on v1.0.5 queued changes: + // no created_at/updated_at, client id under 'client_id'. + final legacy = { + 'client_id': 'client-legacy', + 'name': 'Same', + 'amount': 800.0, + 'currency': 'USD', + 'period_type': 'monthly', + 'start_date': '2026-07-29T04:15:27.967Z', + 'rollover_enabled': false, + 'threshold_percent': 80, + 'forecast_alerts_enabled': false, + 'is_active': true, + 'targets': >[], + }; + + final restored = BudgetCompleteDto.fromJson(legacy); + + expect(restored.budget.clientId, 'client-legacy'); + expect(restored.budget.name, 'Same'); + expect(restored.budget.createdAt, isNotNull); + expect(restored.budget.updatedAt, isNotNull); + }); + }); + + group('formDataFieldValue', () { + test('maps booleans to 1/0 for Laravel boolean validation', () { + expect(formDataFieldValue(true), '1'); + expect(formDataFieldValue(false), '0'); + }); + + test('stringifies non-boolean values unchanged', () { + expect(formDataFieldValue(12.5), '12.5'); + expect(formDataFieldValue('expense'), 'expense'); + expect(formDataFieldValue(7), '7'); + }); + }); +} diff --git a/test/unit/date_util_test.dart b/test/unit/date_util_test.dart new file mode 100644 index 00000000..005eb531 --- /dev/null +++ b/test/unit/date_util_test.dart @@ -0,0 +1,41 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:trakli/core/utils/date_util.dart'; + +void main() { + group('formatServerIsoDateTimeString', () { + test('truncates microseconds to exactly 3 fractional digits', () { + final withMicros = DateTime.utc(2026, 7, 29, 0, 15, 54, 120, 123); + expect( + formatServerIsoDateTimeString(withMicros), + '2026-07-29T00:15:54.120Z', + ); + }); + + test('keeps 3 fractional digits when microseconds are zero', () { + final millisOnly = DateTime.utc(2026, 7, 29, 0, 15, 54, 120); + expect( + formatServerIsoDateTimeString(millisOnly), + '2026-07-29T00:15:54.120Z', + ); + }); + + test('formats whole seconds with .000 millis', () { + final wholeSeconds = DateTime.utc(2026, 7, 29, 0, 15, 54); + expect( + formatServerIsoDateTimeString(wholeSeconds), + '2026-07-29T00:15:54.000Z', + ); + }); + + test('converts local times to UTC', () { + final local = DateTime(2026, 7, 29, 1, 15, 54, 120, 999); + final result = formatServerIsoDateTimeString(local); + expect(result, endsWith('Z')); + expect( + RegExp(r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$') + .hasMatch(result), + isTrue, + ); + }); + }); +} diff --git a/test/unit/offline_dependency_sync_test.dart b/test/unit/offline_dependency_sync_test.dart index 3568b1ef..5a935b6e 100644 --- a/test/unit/offline_dependency_sync_test.dart +++ b/test/unit/offline_dependency_sync_test.dart @@ -116,23 +116,24 @@ void main() { 'transaction deferred by the dependency gate is retried and synced ' 'once its category syncs', () async { // Pass 1: the category push fails server-side. The transaction must be - // deferred by the gate — still queued, not errored. + // deferred by the gate — still queued, with the wait recorded. when(() => categoryRemote.insertCategory(any())) .thenThrow(Exception('HTTP 500')); await synchronizer.uploadLocalChanges(); expect((await changeOf('category')).error, isNotNull); - expect((await changeOf('transaction')).error, isNull); + final deferred = await changeOf('transaction'); + expect(deferred.error, contains('dependencies')); + expect(deferred.quarantinedAt, isNull); verifyNever(() => transactionRemote.insertTransaction(any())); - // The retry delay elapses. - await (db.update(db.localChanges) - ..where((lc) => lc.entityType.equals('category'))) - .write(LocalChangesCompanion( - concludedMoment: Value( - DateTime.now().subtract(AppDatabase.failedChangeRetryDelay * 2)), - )); + // The retry delay elapses for both rows (the deferred transaction now + // carries a recorded wait, so it backs off like any failed change). + await db.update(db.localChanges).write(LocalChangesCompanion( + concludedMoment: Value( + DateTime.now().subtract(AppDatabase.failedChangeRetryDelay * 2)), + )); // Pass 2: the server recovers. The category gains its server id and the // transaction follows in the same pass. diff --git a/test/unit/orphaned_change_reconciliation_test.dart b/test/unit/orphaned_change_reconciliation_test.dart new file mode 100644 index 00000000..93e66177 --- /dev/null +++ b/test/unit/orphaned_change_reconciliation_test.dart @@ -0,0 +1,194 @@ +import 'package:drift/drift.dart' hide isNull; +import 'package:drift/native.dart'; +import 'package:drift_sync_core/drift_sync_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:trakli/core/sync/sync_database.dart'; +import 'package:trakli/data/database/app_database.dart'; +import 'package:trakli/presentation/utils/enums.dart'; + +class _StubHandler implements SyncTypeHandler { + _StubHandler(this.entityType, {this.failFor = const {}}); + + @override + final String entityType; + + final Set failFor; + + @override + Future getLocalByClientId(String clientId) async { + if (failFor.contains(clientId)) throw Exception('load failed'); + return clientId; + } + + @override + Map marshal(Object entity) => + {'entity_type': entityType, 'client_id': entity}; + + @override + String getRev(Object entity) => '1'; + + @override + dynamic noSuchMethod(Invocation invocation) => + throw UnimplementedError('${invocation.memberName}'); +} + +class _FakeAuth implements RequestAuthorizationService { + @override + Future canSync() async => false; +} + +void main() { + driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; + late AppDatabase db; + + setUp(() { + db = AppDatabase(NativeDatabase.memory()); + }); + + tearDown(() async { + await db.close(); + }); + + Future insertTransaction( + String clientId, { + int? serverId, + DateTime? deletedAt, + }) { + return db.into(db.transactions).insert(TransactionsCompanion.insert( + amount: 10, + type: TransactionType.expense, + walletClientId: 'w1', + clientId: Value(clientId), + id: Value(serverId), + deletedAt: Value(deletedAt), + )); + } + + Future insertTransfer(String clientId, {int? serverId}) { + return db.into(db.transfers).insert(TransfersCompanion.insert( + amount: 10, + datetime: DateTime(2026), + fromWalletClientId: const Value('w1'), + toWalletClientId: const Value('w2'), + clientId: Value(clientId), + id: Value(serverId), + )); + } + + Future insertChange( + String entityType, + String entityId, { + DateTime? quarantinedAt, + }) { + return db.insertLocalChange(PendingLocalChange( + entityType: entityType, + entityId: entityId, + entityRev: '1', + deleted: false, + data: const {}, + createMoment: DateTime(2026), + quarantinedAt: quarantinedAt, + )); + } + + group('getOrphanedClientIds', () { + test('returns never-synced rows with no local_changes entry', () async { + await insertTransaction('t1'); + await insertTransaction('t2', serverId: 42); + + final orphans = + await db.getOrphanedClientIds('transactions', 'transaction'); + expect(orphans, ['t1']); + }); + + test('excludes rows that already have a change in any state', () async { + await insertTransaction('pending'); + await insertTransaction('quarantined'); + await insertChange('transaction', 'pending'); + await insertChange('transaction', 'quarantined', + quarantinedAt: DateTime(2026)); + + final orphans = + await db.getOrphanedClientIds('transactions', 'transaction'); + expect(orphans, isEmpty, + reason: 'quarantined changes must not be resurrected'); + }); + + test('ignores changes belonging to another entity type', () async { + await insertTransaction('t1'); + await insertChange('transfer', 't1'); + + final orphans = + await db.getOrphanedClientIds('transactions', 'transaction'); + expect(orphans, ['t1']); + }); + + test('excludes soft-deleted rows', () async { + await insertTransaction('gone', deletedAt: DateTime(2026)); + + final orphans = + await db.getOrphanedClientIds('transactions', 'transaction'); + expect(orphans, isEmpty); + }); + + test('works for transfers', () async { + await insertTransfer('tr1'); + await insertTransfer('tr2', serverId: 9); + + final orphans = await db.getOrphanedClientIds('transfers', 'transfer'); + expect(orphans, ['tr1']); + }); + }); + + group('reconcileOrphanedLocalChanges', () { + SynchAppDatabase buildSync({Set failFor = const {}}) { + return SynchAppDatabase( + appDatabase: db, + typeHandlers: { + _StubHandler('transaction', failFor: failFor), + _StubHandler('transfer'), + }, + dependencyManager: DefaultSyncDependencyManager(), + requestAuthorizationService: _FakeAuth(), + logger: const NoopSyncLogger(), + ); + } + + test('enqueues orphaned transactions and transfers', () async { + await insertTransaction('t1'); + await insertTransfer('tr1'); + + final enqueued = await buildSync().reconcileOrphanedLocalChanges(); + + expect(enqueued, 2); + final pending = await db.select(db.localChanges).get(); + expect( + {for (final c in pending) c.entityId: c.entityType}, + {'t1': 'transaction', 'tr1': 'transfer'}, + ); + final data = pending.firstWhere((c) => c.entityId == 't1').data; + expect(data, {'entity_type': 'transaction', 'client_id': 't1'}); + }); + + test('is idempotent across runs', () async { + await insertTransaction('t1'); + final sync = buildSync(); + + expect(await sync.reconcileOrphanedLocalChanges(), 1); + expect(await sync.reconcileOrphanedLocalChanges(), 0); + expect((await db.select(db.localChanges).get()), hasLength(1)); + }); + + test('one failing record does not block the others', () async { + await insertTransaction('bad'); + await insertTransaction('good'); + + final enqueued = + await buildSync(failFor: {'bad'}).reconcileOrphanedLocalChanges(); + + expect(enqueued, 1); + final pending = await db.select(db.localChanges).get(); + expect(pending.single.entityId, 'good'); + }); + }); +} diff --git a/test/unit/sync_snapshot_unmarshal_test.dart b/test/unit/sync_snapshot_unmarshal_test.dart index f65d7901..429dcbaa 100644 --- a/test/unit/sync_snapshot_unmarshal_test.dart +++ b/test/unit/sync_snapshot_unmarshal_test.dart @@ -79,6 +79,27 @@ void main() { }); }); + test('snapshot without outer categories/files keys still unmarshals', + () async { + await db.transactions.insertOne(TransactionsCompanion.insert( + amount: 5000, + type: TransactionType.expense, + walletClientId: 'w-1', + clientId: const Value('t-old'), + datetime: Value(DateTime(2026, 7, 1)), + )); + final dto = await transactionHandler.getLocalByClientId('t-old'); + + final snapshot = transactionHandler.marshal(dto); + snapshot.remove('categories'); + snapshot.remove('files'); + + final restored = await transactionHandler.unmarshal(snapshot); + expect(restored.categories, isEmpty); + expect(restored.files, isEmpty); + expect(restored.transaction.clientId, 't-old'); + }); + test('pre-v6 transaction snapshot without intent gets the column default', () async { await db.transactions.insertOne(TransactionsCompanion.insert( diff --git a/test/unit/transaction_server_json_test.dart b/test/unit/transaction_server_json_test.dart new file mode 100644 index 00000000..c887ab74 --- /dev/null +++ b/test/unit/transaction_server_json_test.dart @@ -0,0 +1,78 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:trakli/data/datasources/transaction/dto/transaction_complete_dto.dart'; + +void main() { + Map serverTransaction({bool withCategories = true}) { + return { + 'id': 792, + 'client_generated_id': 'client-1', + 'amount': '500.00', + 'type': 'expense', + 'datetime': '2026-07-29T00:22:00.000000Z', + 'created_at': '2026-07-29T00:23:27.000000Z', + 'updated_at': '2026-07-29T00:23:28.000000Z', + 'wallet_id': 129, + 'user_id': 73, + 'last_synced_at': '2026-07-29T00:23:28.000000Z', + 'sync_state': { + 'id': 4650, + 'syncable_type': 'App\\Models\\Transaction', + 'syncable_id': 792, + 'client_generated_id': 'client-1', + 'last_synced_at': '2026-07-29 00:23:28', + 'created_at': '2026-07-29T00:23:27.000000Z', + 'updated_at': '2026-07-29T00:23:28.000000Z', + }, + if (withCategories) + 'categories': [ + { + 'id': 1, + 'client_generated_id': 'cat-1', + 'name': 'Food', + 'slug': 'food', + 'type': 'expense', + 'created_at': '2026-05-19T17:31:51.000000Z', + 'updated_at': '2026-05-19T17:31:51.000000Z', + }, + ], + 'wallet': { + 'id': 129, + 'client_generated_id': 'wallet-1', + 'name': 'Main Account', + 'slug': 'main-account', + 'type': 'cash', + 'balance': 73200, + 'currency': 'XAF', + 'user_id': 73, + 'created_at': '2026-05-19T17:31:51.000000Z', + 'updated_at': '2026-07-29T00:23:28.000000Z', + 'sync_state': { + 'id': 3173, + 'syncable_type': 'App\\Models\\Wallet', + 'syncable_id': 129, + 'client_generated_id': 'wallet-1', + 'last_synced_at': '2026-05-19 17:31:51', + 'created_at': '2026-05-19T17:31:51.000000Z', + 'updated_at': '2026-07-29T00:23:28.000000Z', + }, + }, + }; + } + + group('TransactionCompleteDto.fromServerJson', () { + test('parses a payload with categories', () { + final dto = + TransactionCompleteDto.fromServerJson(serverTransaction()); + expect(dto.categories, hasLength(1)); + expect(dto.categories.first.name, 'Food'); + }); + + test('tolerates a payload without categories', () { + final dto = TransactionCompleteDto.fromServerJson( + serverTransaction(withCategories: false), + ); + expect(dto.categories, isEmpty); + expect(dto.transaction.clientId, 'client-1'); + }); + }); +} diff --git a/test/unit/transfer_leg_link_test.dart b/test/unit/transfer_leg_link_test.dart new file mode 100644 index 00000000..94f0c7e4 --- /dev/null +++ b/test/unit/transfer_leg_link_test.dart @@ -0,0 +1,74 @@ +import 'package:drift/drift.dart' hide isNull; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:trakli/data/database/app_database.dart'; +import 'package:trakli/data/datasources/transaction/transaction_local_datasource.dart'; +import 'package:trakli/data/datasources/transfer/transfer_remote_datasource.dart'; +import 'package:trakli/data/sync/transfer_sync_handler.dart'; +import 'package:trakli/presentation/utils/enums.dart'; + +class _MockRemote extends Mock implements TransferRemoteDataSource {} + +class _MockTxnLocal extends Mock implements TransactionLocalDataSource {} + +void main() { + driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; + late AppDatabase db; + late TransferSyncHandler handler; + + setUp(() { + db = AppDatabase(NativeDatabase.memory()); + handler = TransferSyncHandler(db, _MockRemote(), _MockTxnLocal()); + }); + + tearDown(() async { + await db.close(); + }); + + Future insertLeg(String clientId, TransactionType type) { + return db.into(db.transactions).insert(TransactionsCompanion.insert( + amount: 100, + type: type, + walletClientId: 'w1', + clientId: Value(clientId), + )); + } + + Transfer transfer(String clientId) => Transfer( + clientId: clientId, + amount: 100, + datetime: DateTime(2026, 7, 29), + createdAt: DateTime(2026, 7, 29), + updatedAt: DateTime(2026, 7, 29), + expenseTransactionClientId: 'leg-out', + incomeTransactionClientId: 'leg-in', + ); + + Future legLink(String clientId) async { + final row = await (db.select(db.transactions) + ..where((t) => t.clientId.equals(clientId))) + .getSingle(); + return row.transferClientId; + } + + test('upsertLocal backfills transferClientId on both legs', () async { + await insertLeg('leg-out', TransactionType.expense); + await insertLeg('leg-in', TransactionType.income); + + await handler.upsertLocal(transfer('tr-1')); + + expect(await legLink('leg-out'), 'tr-1'); + expect(await legLink('leg-in'), 'tr-1'); + }); + + test('upsertAllLocal links legs that arrived before the transfer', () async { + await insertLeg('leg-out', TransactionType.expense); + await insertLeg('leg-in', TransactionType.income); + + await handler.upsertAllLocal([transfer('tr-2')]); + + expect(await legLink('leg-out'), 'tr-2'); + expect(await legLink('leg-in'), 'tr-2'); + }); +} From 7c429cc8593dddc29e3f8a9ccb75208b53096692 Mon Sep 17 00:00:00 2001 From: Fuh Austin Date: Wed, 29 Jul 2026 13:18:52 +0100 Subject: [PATCH 2/6] feat(observability): Surface HTTP failures in Crashlytics with full context Dio errors recorded to Crashlytics now include method, URL, status and a truncated response body (never headers), the unhandled-error hook routes through the same enrichment, and a Dio interceptor reports 5xx and 422 responses as non-fatals deduplicated per method+path+status per session. --- .../firebase_crashlytics_service.dart | 36 ++++- lib/core/module/http_module.dart | 5 +- .../crash_reporting_interceptor.dart | 53 ++++++++ lib/core/sync/sync_error_description.dart | 16 +++ .../crash_reporting_interceptor_test.dart | 125 ++++++++++++++++++ test/unit/crashlytics_dio_error_test.dart | 75 +++++++++++ test/unit/sync_error_description_test.dart | 38 ++++++ 7 files changed, 341 insertions(+), 7 deletions(-) create mode 100644 lib/core/network/interceptors/crash_reporting_interceptor.dart create mode 100644 lib/core/sync/sync_error_description.dart create mode 100644 test/unit/crash_reporting_interceptor_test.dart create mode 100644 test/unit/crashlytics_dio_error_test.dart create mode 100644 test/unit/sync_error_description_test.dart diff --git a/lib/core/error/crash_reporting/implementations/firebase_crashlytics_service.dart b/lib/core/error/crash_reporting/implementations/firebase_crashlytics_service.dart index 3e58dfe0..accfa94a 100644 --- a/lib/core/error/crash_reporting/implementations/firebase_crashlytics_service.dart +++ b/lib/core/error/crash_reporting/implementations/firebase_crashlytics_service.dart @@ -1,9 +1,33 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:flutter/foundation.dart'; import 'package:injectable/injectable.dart'; import 'package:trakli/core/error/crash_reporting/crash_reporting_interface.dart'; import 'package:trakli/core/utils/services/logger.dart'; +/// Adds the response body DioException.toString() omits; headers excluded. +@visibleForTesting +String dioErrorReason(DioException error) { + final options = error.requestOptions; + final status = error.response?.statusCode; + return 'HTTP ${status ?? 'error'} on ${options.method} ${options.uri.path}'; +} + +@visibleForTesting +List dioErrorInformation(DioException error, {int maxBodyLength = 2000}) { + final options = error.requestOptions; + final body = error.response?.data?.toString() ?? ''; + return [ + '${options.method} ${options.uri}', + if (error.response?.statusCode != null) + 'status: ${error.response!.statusCode}', + if (body.isNotEmpty) + 'response: ${body.length > maxBodyLength ? body.substring(0, maxBodyLength) : body}', + ]; +} + @Injectable(as: CrashReportingInterface) class FirebaseCrashlyticsService implements CrashReportingInterface { FirebaseCrashlytics? _crashlytics; @@ -69,11 +93,11 @@ class FirebaseCrashlyticsService implements CrashReportingInterface { await _crashlyticsInstance.recordError( error, stackTrace, - reason: reason, - information: information?.entries - .map((e) => '{ ${e.key}: ${e.value} }') - .toList() ?? - const [], + reason: reason ?? (error is DioException ? dioErrorReason(error) : null), + information: [ + ...?information?.entries.map((e) => '{ ${e.key}: ${e.value} }'), + if (error is DioException) ...dioErrorInformation(error), + ], fatal: fatal, ); } catch (e, st) { @@ -140,7 +164,7 @@ class FirebaseCrashlyticsService implements CrashReportingInterface { }; PlatformDispatcher.instance.onError = (error, stack) { - FirebaseCrashlytics.instance.recordError(error, stack, fatal: true); + unawaited(_record(error, stackTrace: stack, fatal: true)); return true; }; } diff --git a/lib/core/module/http_module.dart b/lib/core/module/http_module.dart index 3787ed8f..f41e4eac 100644 --- a/lib/core/module/http_module.dart +++ b/lib/core/module/http_module.dart @@ -1,5 +1,7 @@ import 'package:dio/dio.dart'; import 'package:injectable/injectable.dart'; +import 'package:trakli/core/error/crash_reporting/crash_reporting_service.dart'; +import 'package:trakli/core/network/interceptors/crash_reporting_interceptor.dart'; import 'package:trakli/core/network/interceptors/locale_interceptor.dart'; import 'package:trakli/core/network/interceptors/logger_interceptor.dart'; import 'package:trakli/core/network/interceptors/remove_null_exceptions.dart'; @@ -31,7 +33,8 @@ abstract class InjectHttpClientModule { RemoveNullValuesInterceptor(), TokenInterceptor(getIt(), getIt()), LocaleInterceptor(), - LoggerInterceptor() + LoggerInterceptor(), + CrashReportingInterceptor(getIt()), ]); return dio; diff --git a/lib/core/network/interceptors/crash_reporting_interceptor.dart b/lib/core/network/interceptors/crash_reporting_interceptor.dart new file mode 100644 index 00000000..c6dc3fc2 --- /dev/null +++ b/lib/core/network/interceptors/crash_reporting_interceptor.dart @@ -0,0 +1,53 @@ +import 'package:dio/dio.dart'; +import 'package:trakli/core/error/crash_reporting/crash_reporting_service.dart'; + +/// Reports 5xx and 422 responses to Crashlytics as non-fatals, deduplicated +/// per method+path+status per session. +class CrashReportingInterceptor extends Interceptor { + CrashReportingInterceptor(this._crashReporting); + + final CrashReportingService _crashReporting; + final Set _reported = {}; + + static const int _maxBodyLength = 2000; + + bool shouldReport(int? status) => + status != null && (status >= 500 || status == 422); + + /// Path with numeric/UUID segments replaced so the same endpoint + /// deduplicates across different record ids. + static String normalizePath(String path) { + return path + .split('/') + .map((s) => + RegExp(r'^(\d+|[0-9a-fA-F:-]{8,})$').hasMatch(s) ? '{id}' : s) + .join('/'); + } + + @override + void onError(DioException err, ErrorInterceptorHandler handler) { + final status = err.response?.statusCode; + if (shouldReport(status)) { + final options = err.requestOptions; + final key = + '${options.method} ${normalizePath(options.uri.path)} $status'; + if (_reported.add(key)) { + final body = err.response?.data?.toString() ?? ''; + _crashReporting.recordError( + err, + stackTrace: err.stackTrace, + reason: 'HTTP $status on ${options.method} ${options.uri.path}', + information: { + 'url': options.uri.toString(), + 'status': status, + if (body.isNotEmpty) + 'response': body.length > _maxBodyLength + ? body.substring(0, _maxBodyLength) + : body, + }, + ); + } + } + handler.next(err); + } +} diff --git a/lib/core/sync/sync_error_description.dart b/lib/core/sync/sync_error_description.dart new file mode 100644 index 00000000..4b52d596 --- /dev/null +++ b/lib/core/sync/sync_error_description.dart @@ -0,0 +1,16 @@ +import 'package:dio/dio.dart'; + +/// Human-readable form of a sync failure for local_changes.error, carrying +/// the response body DioException.toString() omits. +String describeSyncError(Object error, {int maxBodyLength = 500}) { + if (error is DioException) { + final options = error.requestOptions; + final status = error.response?.statusCode; + final head = + 'HTTP ${status ?? 'error'} on ${options.method} ${options.uri.path}'; + final body = error.response?.data?.toString() ?? ''; + if (body.isEmpty) return '$head — ${error.message ?? error.type.name}'; + return '$head — ${body.length > maxBodyLength ? body.substring(0, maxBodyLength) : body}'; + } + return error.toString(); +} diff --git a/test/unit/crash_reporting_interceptor_test.dart b/test/unit/crash_reporting_interceptor_test.dart new file mode 100644 index 00000000..218572d7 --- /dev/null +++ b/test/unit/crash_reporting_interceptor_test.dart @@ -0,0 +1,125 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:trakli/core/error/crash_reporting/crash_reporting_interface.dart'; +import 'package:trakli/core/error/crash_reporting/crash_reporting_service.dart'; +import 'package:trakli/core/network/interceptors/crash_reporting_interceptor.dart'; + +class _RecordingCrashReporter implements CrashReportingInterface { + final List reasons = []; + + @override + Future recordError( + Object error, { + StackTrace? stackTrace, + String? reason, + Map? information, + }) async { + reasons.add(reason); + } + + @override + Future recordFatalError( + Object error, { + StackTrace? stackTrace, + String? reason, + Map? information, + }) async {} + + @override + Future initialize() async {} + + @override + Future log(String message, {String? level}) async {} + + @override + Future setCustomKey(String key, dynamic value) async {} + + @override + Future setUserId(String userId) async {} + + @override + Future setUserProperties(Map properties) async {} +} + +class _NoopHandler extends ErrorInterceptorHandler { + @override + void next(DioException err) {} +} + +void main() { + late _RecordingCrashReporter reporter; + late CrashReportingInterceptor interceptor; + + setUp(() { + reporter = _RecordingCrashReporter(); + interceptor = CrashReportingInterceptor(CrashReportingService(reporter)); + }); + + DioException errorWith(int? status, {String path = 'wallets'}) { + final options = RequestOptions( + path: path, + baseUrl: 'https://api.trakli.app/api/v1/', + method: 'POST', + ); + return DioException( + requestOptions: options, + response: status == null + ? null + : Response( + requestOptions: options, + statusCode: status, + data: {'success': false, 'message': 'boom'}, + ), + ); + } + + Future pump() => Future.delayed(Duration.zero); + + test('reports 5xx and 422, skips 401/404 and connection errors', () async { + interceptor.onError(errorWith(500), _NoopHandler()); + interceptor.onError(errorWith(422, path: 'transactions'), _NoopHandler()); + interceptor.onError(errorWith(401, path: 'user'), _NoopHandler()); + interceptor.onError(errorWith(404, path: 'plans'), _NoopHandler()); + interceptor.onError(errorWith(null, path: 'stats'), _NoopHandler()); + await pump(); + + expect(reporter.reasons, [ + 'HTTP 500 on POST /api/v1/wallets', + 'HTTP 422 on POST /api/v1/transactions', + ]); + }); + + test('dedupes repeats of the same method+path+status', () async { + for (var i = 0; i < 5; i++) { + interceptor.onError(errorWith(500), _NoopHandler()); + } + interceptor.onError(errorWith(503), _NoopHandler()); + await pump(); + + expect(reporter.reasons, hasLength(2)); + }); + + test('dedupes across different record ids on the same endpoint', () async { + interceptor.onError( + errorWith(500, path: 'transactions/2234'), _NoopHandler()); + interceptor.onError( + errorWith(500, path: 'transactions/2235'), _NoopHandler()); + interceptor.onError( + errorWith(500, + path: 'transactions/' + 'f0b7d8c6-9e9a-51ca-3567-578900000000:918caaf3-335c'), + _NoopHandler()); + await pump(); + + expect(reporter.reasons, hasLength(1)); + }); + + test('normalizePath keeps ordinary segments intact', () { + expect(CrashReportingInterceptor.normalizePath('/api/v1/transactions/2234'), + '/api/v1/transactions/{id}'); + expect( + CrashReportingInterceptor.normalizePath( + '/api/v1/configurations/default-currency'), + '/api/v1/configurations/default-currency'); + }); +} diff --git a/test/unit/crashlytics_dio_error_test.dart b/test/unit/crashlytics_dio_error_test.dart new file mode 100644 index 00000000..9d8146c0 --- /dev/null +++ b/test/unit/crashlytics_dio_error_test.dart @@ -0,0 +1,75 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:trakli/core/error/crash_reporting/implementations/firebase_crashlytics_service.dart'; + +void main() { + DioException buildDioError({dynamic responseData, int? statusCode}) { + final options = RequestOptions( + path: 'transactions', + baseUrl: 'https://api.trakli.app/api/v1/', + method: 'POST', + headers: {'Authorization': 'Bearer secret-token'}, + ); + return DioException( + requestOptions: options, + response: statusCode == null + ? null + : Response( + requestOptions: options, + statusCode: statusCode, + data: responseData, + ), + ); + } + + group('dioErrorReason', () { + test('includes status, method and path', () { + final error = buildDioError(statusCode: 422, responseData: {}); + expect(dioErrorReason(error), 'HTTP 422 on POST /api/v1/transactions'); + }); + + test('handles missing response', () { + final error = buildDioError(); + expect(dioErrorReason(error), 'HTTP error on POST /api/v1/transactions'); + }); + }); + + group('dioErrorInformation', () { + test('includes url, status and response body', () { + final error = buildDioError( + statusCode: 422, + responseData: { + 'errors': { + 'datetime': ['must be a valid ISO 8601 datetime'], + }, + }, + ); + final info = dioErrorInformation(error); + expect(info[0], 'POST https://api.trakli.app/api/v1/transactions'); + expect(info[1], 'status: 422'); + expect(info[2], contains('must be a valid ISO 8601 datetime')); + }); + + test('omits body entry when there is no response data', () { + final info = dioErrorInformation(buildDioError()); + expect(info, ['POST https://api.trakli.app/api/v1/transactions']); + }); + + test('truncates oversized response bodies', () { + final error = buildDioError( + statusCode: 500, + responseData: 'x' * 5000, + ); + final body = dioErrorInformation(error, maxBodyLength: 100).last; + expect(body.length, 'response: '.length + 100); + }); + + test('never includes request headers', () { + final error = buildDioError(statusCode: 422, responseData: {'a': 1}); + expect( + dioErrorInformation(error).join(), + isNot(contains('secret-token')), + ); + }); + }); +} diff --git a/test/unit/sync_error_description_test.dart b/test/unit/sync_error_description_test.dart new file mode 100644 index 00000000..bcd560c4 --- /dev/null +++ b/test/unit/sync_error_description_test.dart @@ -0,0 +1,38 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:trakli/core/sync/sync_error_description.dart'; + +void main() { + DioException dioError({int? status, dynamic body}) { + final options = RequestOptions( + path: 'transactions', + baseUrl: 'https://api.trakli.app/api/v1/', + method: 'POST', + ); + return DioException( + requestOptions: options, + response: status == null + ? null + : Response(requestOptions: options, statusCode: status, data: body), + ); + } + + test('includes status, endpoint and response body', () { + final text = describeSyncError(dioError( + status: 500, + body: {'message': 'recurrence_interval must be int'}, + )); + expect(text, contains('HTTP 500 on POST /api/v1/transactions')); + expect(text, contains('recurrence_interval must be int')); + }); + + test('truncates oversized bodies', () { + final text = + describeSyncError(dioError(status: 500, body: 'x' * 2000)); + expect(text.length, lessThan(600)); + }); + + test('falls back to toString for non-Dio errors', () { + expect(describeSyncError(StateError('boom')), contains('boom')); + }); +} From b5fa77fea3e90a8cad123ec80fdd7d0458ddcda8 Mon Sep 17 00:00:00 2001 From: Fuh Austin Date: Wed, 29 Jul 2026 13:18:52 +0100 Subject: [PATCH 3/6] fix(l10n): Add missing and new UI strings across all locales Restores the sync-history quarantine strings that existed only in the generated keys file, and adds keys for the budget card, transfer rate validation and the gated currency switch. --- assets/translations/de.json | 10 +++- assets/translations/en.json | 7 ++- assets/translations/es.json | 10 +++- assets/translations/fr.json | 10 +++- assets/translations/it.json | 10 +++- assets/translations/ru.json | 10 +++- lib/gen/translations/codegen_loader.g.dart | 9 ++- lib/gen/translations/locale_keys.g.dart | 69 +++++++++++++++++++++- 8 files changed, 119 insertions(+), 16 deletions(-) diff --git a/assets/translations/de.json b/assets/translations/de.json index c644645c..6fc80932 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -917,5 +917,11 @@ "fpHelpFlowsTitle": "Geld ein & Geld aus", "fpHelpFlowsBody": "Diese zeigen, was dein Geld im Zeitraum bewegt hat. Einige Zeilen – etwa erhaltene oder zurückgezahlte Darlehen – bewegen Bargeld, ändern aber dein Vermögen nicht und sind daher als \"Bargeld, kein Vermögen\" markiert.", "fpHelpAsOfTitle": "Stand", - "fpHelpAsOfBody": "Die Zahlen werden auf dem Server berechnet und geben deine Daten zum Zeitpunkt wieder, der unten auf der Karte angezeigt wird." -} + "fpHelpAsOfBody": "Die Zahlen werden auf dem Server berechnet und geben deine Daten zum Zeitpunkt wieder, der unten auf der Karte angezeigt wird.", + "budgetOf": "von", + "needsAttention": "Erfordert Aufmerksamkeit", + "noQuarantinedChanges": "Keine isolierten Änderungen", + "quarantinedHint": "Diese Änderungen sind dauerhaft fehlgeschlagen und werden nicht automatisch wiederholt. Nach Behebung der Ursache erneut versuchen oder verwerfen.", + "exchangeRateRequired": "Wechselkurs eingeben — kein aktueller Kurs verfügbar", + "currencySwitchRatesUnavailable": "Währungswechsel derzeit nicht möglich — Wechselkurse sind nicht verfügbar. Versuchen Sie es später erneut." +} \ No newline at end of file diff --git a/assets/translations/en.json b/assets/translations/en.json index e43bfc9f..c14cf3da 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -920,5 +920,8 @@ "fpHelpFlowsTitle": "Money in & money out", "fpHelpFlowsBody": "These show what moved your money during the period. Some rows — such as loans received or repaid — move cash but don't change your net worth, so they're marked \"Cash, not net worth\".", "fpHelpAsOfTitle": "As of", - "fpHelpAsOfBody": "Figures are calculated on the server and reflect your data as of the time shown at the bottom of the card." -} + "fpHelpAsOfBody": "Figures are calculated on the server and reflect your data as of the time shown at the bottom of the card.", + "budgetOf": "of", + "exchangeRateRequired": "Enter the exchange rate — no current rate is available", + "currencySwitchRatesUnavailable": "Can't switch currency right now — exchange rates are unavailable. Try again later." +} \ No newline at end of file diff --git a/assets/translations/es.json b/assets/translations/es.json index 442b2760..25291c1b 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -917,5 +917,11 @@ "fpHelpFlowsTitle": "Entradas y salidas", "fpHelpFlowsBody": "Muestran qué movió tu dinero durante el período. Algunas filas, como préstamos recibidos o devueltos, mueven efectivo pero no cambian tu patrimonio, por lo que se marcan como \"Efectivo, no patrimonio\".", "fpHelpAsOfTitle": "A fecha de", - "fpHelpAsOfBody": "Las cifras se calculan en el servidor y reflejan tus datos a fecha de la hora que se muestra en la parte inferior de la tarjeta." -} + "fpHelpAsOfBody": "Las cifras se calculan en el servidor y reflejan tus datos a fecha de la hora que se muestra en la parte inferior de la tarjeta.", + "budgetOf": "de", + "needsAttention": "Requiere atención", + "noQuarantinedChanges": "No hay cambios en cuarentena", + "quarantinedHint": "Estos cambios fallaron permanentemente y no se reintentarán solos. Reinténtalos tras corregir la causa o descártalos.", + "exchangeRateRequired": "Introduce el tipo de cambio: no hay un tipo actual disponible", + "currencySwitchRatesUnavailable": "No se puede cambiar la moneda ahora: los tipos de cambio no están disponibles. Inténtalo de nuevo más tarde." +} \ No newline at end of file diff --git a/assets/translations/fr.json b/assets/translations/fr.json index d99def20..a8f716f2 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -917,5 +917,11 @@ "fpHelpFlowsTitle": "Entrées et sorties", "fpHelpFlowsBody": "Elles indiquent ce qui a fait bouger votre argent pendant la période. Certaines lignes, comme les prêts reçus ou remboursés, déplacent des liquidités mais ne changent pas votre patrimoine ; elles sont donc marquées « Liquidités, pas patrimoine ».", "fpHelpAsOfTitle": "Au", - "fpHelpAsOfBody": "Les chiffres sont calculés sur le serveur et reflètent vos données à l'heure indiquée au bas de la carte." -} + "fpHelpAsOfBody": "Les chiffres sont calculés sur le serveur et reflètent vos données à l'heure indiquée au bas de la carte.", + "budgetOf": "sur", + "needsAttention": "Attention requise", + "noQuarantinedChanges": "Aucun changement en quarantaine", + "quarantinedHint": "Ces changements ont échoué définitivement et ne seront pas réessayés automatiquement. Réessayez-les après avoir corrigé la cause, ou ignorez-les.", + "exchangeRateRequired": "Saisissez le taux de change — aucun taux actuel disponible", + "currencySwitchRatesUnavailable": "Impossible de changer de devise pour le moment — les taux de change sont indisponibles. Réessayez plus tard." +} \ No newline at end of file diff --git a/assets/translations/it.json b/assets/translations/it.json index a5a68b47..029652d5 100644 --- a/assets/translations/it.json +++ b/assets/translations/it.json @@ -917,5 +917,11 @@ "fpHelpFlowsTitle": "Entrate e uscite", "fpHelpFlowsBody": "Mostrano cosa ha mosso il tuo denaro durante il periodo. Alcune righe, come prestiti ricevuti o rimborsati, muovono contanti ma non cambiano il tuo patrimonio, quindi sono contrassegnate come \"Contanti, non patrimonio\".", "fpHelpAsOfTitle": "Al", - "fpHelpAsOfBody": "Le cifre sono calcolate sul server e riflettono i tuoi dati al momento indicato in fondo alla scheda." -} + "fpHelpAsOfBody": "Le cifre sono calcolate sul server e riflettono i tuoi dati al momento indicato in fondo alla scheda.", + "budgetOf": "di", + "needsAttention": "Richiede attenzione", + "noQuarantinedChanges": "Nessuna modifica in quarantena", + "quarantinedHint": "Queste modifiche sono fallite definitivamente e non verranno ritentate automaticamente. Riprovale dopo aver corretto la causa oppure ignorale.", + "exchangeRateRequired": "Inserisci il tasso di cambio — nessun tasso attuale disponibile", + "currencySwitchRatesUnavailable": "Impossibile cambiare valuta al momento — i tassi di cambio non sono disponibili. Riprova più tardi." +} \ No newline at end of file diff --git a/assets/translations/ru.json b/assets/translations/ru.json index 323aa698..a2381bf2 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -917,5 +917,11 @@ "fpHelpFlowsTitle": "Поступления и расходы", "fpHelpFlowsBody": "Они показывают, что двигало ваши деньги за период. Некоторые строки — например, полученные или погашенные займы — двигают наличные, но не меняют капитал, поэтому помечены как \"Наличные, не капитал\".", "fpHelpAsOfTitle": "По состоянию на", - "fpHelpAsOfBody": "Цифры рассчитываются на сервере и отражают ваши данные по состоянию на время, показанное внизу карточки." -} + "fpHelpAsOfBody": "Цифры рассчитываются на сервере и отражают ваши данные по состоянию на время, показанное внизу карточки.", + "budgetOf": "из", + "needsAttention": "Требует внимания", + "noQuarantinedChanges": "Нет изменений в карантине", + "quarantinedHint": "Эти изменения завершились с постоянной ошибкой и не будут повторяться автоматически. Повторите их после устранения причины или отклоните.", + "exchangeRateRequired": "Введите обменный курс — актуальный курс недоступен", + "currencySwitchRatesUnavailable": "Сейчас нельзя сменить валюту — обменные курсы недоступны. Повторите попытку позже." +} \ No newline at end of file diff --git a/lib/gen/translations/codegen_loader.g.dart b/lib/gen/translations/codegen_loader.g.dart index ad229e0e..a0761dc4 100644 --- a/lib/gen/translations/codegen_loader.g.dart +++ b/lib/gen/translations/codegen_loader.g.dart @@ -510,9 +510,6 @@ abstract class LocaleKeys { static const noPendingChanges = 'noPendingChanges'; static const failedChanges = 'failedChanges'; static const noFailedChanges = 'noFailedChanges'; - static const needsAttention = 'needsAttention'; - static const noQuarantinedChanges = 'noQuarantinedChanges'; - static const quarantinedHint = 'quarantinedHint'; static const dismiss = 'dismiss'; static const categorySalary = 'categorySalary'; static const categorySalaryDesc = 'categorySalaryDesc'; @@ -925,5 +922,11 @@ abstract class LocaleKeys { static const fpHelpFlowsBody = 'fpHelpFlowsBody'; static const fpHelpAsOfTitle = 'fpHelpAsOfTitle'; static const fpHelpAsOfBody = 'fpHelpAsOfBody'; + static const budgetOf = 'budgetOf'; + static const needsAttention = 'needsAttention'; + static const noQuarantinedChanges = 'noQuarantinedChanges'; + static const quarantinedHint = 'quarantinedHint'; + static const exchangeRateRequired = 'exchangeRateRequired'; + static const currencySwitchRatesUnavailable = 'currencySwitchRatesUnavailable'; } diff --git a/lib/gen/translations/locale_keys.g.dart b/lib/gen/translations/locale_keys.g.dart index e52f9e63..a0761dc4 100644 --- a/lib/gen/translations/locale_keys.g.dart +++ b/lib/gen/translations/locale_keys.g.dart @@ -314,7 +314,6 @@ abstract class LocaleKeys { static const removeRefund = 'removeRefund'; static const selectOriginalExpense = 'selectOriginalExpense'; static const selectOriginalExpenseHint = 'selectOriginalExpenseHint'; - static const markWithoutLinking = 'markWithoutLinking'; static const noExpensesToLink = 'noExpensesToLink'; static const transactionsIn = 'transactionsIn'; static const wallets = 'wallets'; @@ -338,6 +337,22 @@ abstract class LocaleKeys { static const officeElementsDesc = 'officeElementsDesc'; static const monthly = 'monthly'; static const yearly = 'yearly'; + static const daily = 'daily'; + static const weekly = 'weekly'; + static const recurring = 'recurring'; + static const repeatTransaction = 'repeatTransaction'; + static const repeatEvery = 'repeatEvery'; + static const makeRecurring = 'makeRecurring'; + static const recurrencePeriod = 'recurrencePeriod'; + static const thisIsARefund = 'thisIsARefund'; + static const refundHelper = 'refundHelper'; + static const refundOf = 'refundOf'; + static const refundOfHint = 'refundOfHint'; + static const searchExpenses = 'searchExpenses'; + static const recurrenceInterval = 'recurrenceInterval'; + static const recurrenceIntervalError = 'recurrenceIntervalError'; + static const recurrenceEndDate = 'recurrenceEndDate'; + static const noEndDate = 'noEndDate'; static const recommended = 'recommended'; static const loremIpsum = 'loremIpsum'; static const full = 'full'; @@ -548,6 +563,52 @@ abstract class LocaleKeys { static const inAppNotifications = 'inAppNotifications'; static const inAppNotificationsDesc = 'inAppNotificationsDesc'; static const reminders = 'reminders'; + static const noReminders = 'noReminders'; + static const addReminder = 'addReminder'; + static const editReminder = 'editReminder'; + static const deleteReminder = 'deleteReminder'; + static const deleteReminderConfirmation = 'deleteReminderConfirmation'; + static const snooze = 'snooze'; + static const pause = 'pause'; + static const resume = 'resume'; + static const reminderTitle = 'reminderTitle'; + static const reminderTitleRequired = 'reminderTitleRequired'; + static const reminderDescription = 'reminderDescription'; + static const reminderType = 'reminderType'; + static const reminderWhen = 'reminderWhen'; + static const reminderNoTime = 'reminderNoTime'; + static const reminderPriority = 'reminderPriority'; + static const reminderPriorityNormal = 'reminderPriorityNormal'; + static const reminderPriorityHigh = 'reminderPriorityHigh'; + static const reminderPriorityUrgent = 'reminderPriorityUrgent'; + static const reminderTimezone = 'reminderTimezone'; + static const reminderRepeat = 'reminderRepeat'; + static const reminderRepeatNone = 'reminderRepeatNone'; + static const reminderRepeatDaily = 'reminderRepeatDaily'; + static const reminderRepeatWeekly = 'reminderRepeatWeekly'; + static const reminderRepeatMonthly = 'reminderRepeatMonthly'; + static const reminderRepeatRecurring = 'reminderRepeatRecurring'; + static const removeSnooze = 'removeSnooze'; + static const reminderDateTimeRequired = 'reminderDateTimeRequired'; + static const reminderSnoozedUntil = 'reminderSnoozedUntil'; + static const snoozeUntil = 'snoozeUntil'; + static const snoozeOneHour = 'snoozeOneHour'; + static const snoozeTwoHours = 'snoozeTwoHours'; + static const snoozeTomorrow = 'snoozeTomorrow'; + static const snoozeNextWeek = 'snoozeNextWeek'; + static const snoozeCustom = 'snoozeCustom'; + static const saveReminder = 'saveReminder'; + static const reminderTypeDailyTracking = 'reminderTypeDailyTracking'; + static const reminderTypeWeeklyReview = 'reminderTypeWeeklyReview'; + static const reminderTypeMonthlySummary = 'reminderTypeMonthlySummary'; + static const reminderTypeBillDue = 'reminderTypeBillDue'; + static const reminderTypeBudgetAlert = 'reminderTypeBudgetAlert'; + static const reminderTypeCustom = 'reminderTypeCustom'; + static const reminderStatusActive = 'reminderStatusActive'; + static const reminderStatusPaused = 'reminderStatusPaused'; + static const reminderStatusSnoozed = 'reminderStatusSnoozed'; + static const reminderStatusCompleted = 'reminderStatusCompleted'; + static const reminderStatusCancelled = 'reminderStatusCancelled'; static const remindersDesc = 'remindersDesc'; static const financialInsights = 'financialInsights'; static const financialInsightsDesc = 'financialInsightsDesc'; @@ -861,5 +922,11 @@ abstract class LocaleKeys { static const fpHelpFlowsBody = 'fpHelpFlowsBody'; static const fpHelpAsOfTitle = 'fpHelpAsOfTitle'; static const fpHelpAsOfBody = 'fpHelpAsOfBody'; + static const budgetOf = 'budgetOf'; + static const needsAttention = 'needsAttention'; + static const noQuarantinedChanges = 'noQuarantinedChanges'; + static const quarantinedHint = 'quarantinedHint'; + static const exchangeRateRequired = 'exchangeRateRequired'; + static const currencySwitchRatesUnavailable = 'currencySwitchRatesUnavailable'; } From 82dab9e212863ae654be631632249f95407a3146 Mon Sep 17 00:00:00 2001 From: Fuh Austin Date: Wed, 29 Jul 2026 13:19:08 +0100 Subject: [PATCH 4/6] fix(errors): Honest offline messaging and guaranteed exchange rates Connection-level Dio failures map to the network failure message instead of a generic error (Financial Position, Holdings, Ask Trakli). Exchange-rate refreshes never crash the stream and are fetched right after login; a stale cached rate survives a failed refresh. Cross- currency transfers require an explicit rate when none is known (empty field, blocked submit) instead of a silent 1:1. Switching an established default currency is gated on rate availability for the new base, except during a total outage when the current base has no rates either; the first selection stays ungated so onboarding cannot block. --- lib/core/error/repository_error_handler.dart | 24 ++ lib/data/repositories/exchange_rate_imp.dart | 51 ++- lib/presentation/ai_chat/ai_chat_screen.dart | 6 +- lib/presentation/app_widget.dart | 2 + .../currency/cubit/currency_cubit.dart | 56 ++- .../defaults_settings_screen.dart | 329 +++++++++--------- .../transfers/wallet_transfer_screen.dart | 81 ++--- test/unit/currency_cubit_gate_test.dart | 142 ++++++++ test/unit/repository_error_handler_test.dart | 66 ++++ 9 files changed, 519 insertions(+), 238 deletions(-) create mode 100644 test/unit/currency_cubit_gate_test.dart create mode 100644 test/unit/repository_error_handler_test.dart diff --git a/lib/core/error/repository_error_handler.dart b/lib/core/error/repository_error_handler.dart index 31ba2b10..a95b8bee 100644 --- a/lib/core/error/repository_error_handler.dart +++ b/lib/core/error/repository_error_handler.dart @@ -1,3 +1,6 @@ +import 'dart:io'; + +import 'package:dio/dio.dart'; import 'package:fpdart/fpdart.dart'; import 'package:trakli/core/error/exceptions.dart'; import 'package:trakli/core/error/failures/failures.dart'; @@ -33,12 +36,33 @@ class RepositoryErrorHandler { return left(DuplicateFailure(e.message)); } on NotFoundException { return left(const NotFoundFailure()); + } on DioException catch (e, stackTrace) { + if (isConnectivityError(e)) { + return left(const NetworkFailure()); + } + logger.e('UnknownFailure', error: e, stackTrace: stackTrace); + return left(const UnknownFailure()); } catch (e, stackTrace) { logger.e('UnknownFailure', error: e, stackTrace: stackTrace); return left(const UnknownFailure()); } } + /// True when the request never reached the server (offline, DNS, timeout). + static bool isConnectivityError(DioException e) { + switch (e.type) { + case DioExceptionType.connectionError: + case DioExceptionType.connectionTimeout: + case DioExceptionType.sendTimeout: + case DioExceptionType.receiveTimeout: + return true; + case DioExceptionType.unknown: + return e.error is SocketException; + default: + return false; + } + } + static ApiException mapFailureToException(Failure failure) { return failure.map( serverError: (ServerFailure f) => ServerException(f.message), diff --git a/lib/data/repositories/exchange_rate_imp.dart b/lib/data/repositories/exchange_rate_imp.dart index 1c13e480..ecb5d0ef 100644 --- a/lib/data/repositories/exchange_rate_imp.dart +++ b/lib/data/repositories/exchange_rate_imp.dart @@ -1,13 +1,14 @@ import 'dart:async'; -import 'package:dio/dio.dart'; import 'package:fpdart/fpdart.dart'; import 'package:injectable/injectable.dart'; import 'package:trakli/core/constants/config_constants.dart'; import 'package:trakli/core/constants/key_constants.dart'; -import 'package:trakli/core/error/error_handler.dart'; +import 'package:trakli/core/error/crash_reporting/crash_reporting_service.dart'; import 'package:trakli/core/error/failures/failures.dart'; +import 'package:trakli/core/utils/services/logger.dart'; import 'package:trakli/core/error/repository_error_handler.dart'; +import 'package:trakli/di/injection.dart'; import 'package:trakli/data/datasources/exchange-rate/exchange_rate_local_datasource.dart'; import 'package:trakli/data/datasources/exchange-rate/exchange_rate_remote_datasource.dart'; import 'package:trakli/data/mappers/exchange_rate_mapper.dart'; @@ -79,10 +80,16 @@ class ExchangeRateRepositoryImpl extends ExchangeRateRepository { _rateCachedController.add(exchangeRateEntity); yield exchangeRateEntity; - } on DioException catch (err) { - throw ErrorHandler.handleDioException(err); - } catch (error, stacktrace) { - throw ErrorHandler.handleUnknownException(error, stacktrace); + } catch (error, stackTrace) { + // Failed refreshes must not error the stream; report as a counted + // non-fatal instead. + logger.w('[exchange-rate] refresh failed: $error'); + getIt().recordError( + error, + stackTrace: stackTrace, + reason: 'exchange_rate_refresh_failed', + information: {'currency': currencyCode}, + ); } } @@ -145,21 +152,27 @@ class ExchangeRateRepositoryImpl extends ExchangeRateRepository { // Check if we have the exchange rate for this currency final existingRate = await localDataSource.getExchangeRate(currencyCode); - // If no rate exists or it's outdated, fetch new rates + // Fetch when missing or stale; a stale cache survives a failed fetch. if (existingRate == null || existingRate.timeNextUpdated.isBefore(DateTime.now())) { - final exchangeRateRemote = - await remoteDataSource.getExchangeRate(currencyCode); - await localDataSource.saveExchangeRate( - exchangeRateRemote.baseCode, - exchangeRateRemote, - ); - - final exchangeRateEntity = - ExchangeRateMapper.toDomain(exchangeRateRemote); - _exchangeRateController.add(exchangeRateEntity); - _rateCachedController.add(exchangeRateEntity); - return exchangeRateEntity; + try { + final exchangeRateRemote = + await remoteDataSource.getExchangeRate(currencyCode); + await localDataSource.saveExchangeRate( + exchangeRateRemote.baseCode, + exchangeRateRemote, + ); + + final exchangeRateEntity = + ExchangeRateMapper.toDomain(exchangeRateRemote); + _exchangeRateController.add(exchangeRateEntity); + _rateCachedController.add(exchangeRateEntity); + return exchangeRateEntity; + } catch (error) { + if (existingRate == null) rethrow; + logger.w('[exchange-rate] refresh for $currencyCode failed, ' + 'keeping stale cached rates: $error'); + } } final exchangeRateEntity = ExchangeRateMapper.toDomain(existingRate); diff --git a/lib/presentation/ai_chat/ai_chat_screen.dart b/lib/presentation/ai_chat/ai_chat_screen.dart index ddce365d..126d62f5 100644 --- a/lib/presentation/ai_chat/ai_chat_screen.dart +++ b/lib/presentation/ai_chat/ai_chat_screen.dart @@ -225,7 +225,11 @@ class _ErrorBanner extends StatelessWidget { SizedBox(width: 10.w), Expanded( child: Text( - LocaleKeys.aiChatError.tr(), + state.failure?.maybeMap( + networkError: (f) => f.customMessage, + orElse: () => LocaleKeys.aiChatError.tr(), + ) ?? + LocaleKeys.aiChatError.tr(), style: TextStyle( color: tones.expense.deep, fontSize: 13.sp, diff --git a/lib/presentation/app_widget.dart b/lib/presentation/app_widget.dart index 7f198fc4..9bd82b37 100644 --- a/lib/presentation/app_widget.dart +++ b/lib/presentation/app_widget.dart @@ -183,6 +183,8 @@ class _AppViewState extends State { state.maybeWhen( authenticated: (user) async { unawaited(getIt().sync()); + // Login proves connectivity — best moment to cache exchange rates. + context.read().getExchangeRate(); final isOnboardingComplete = await _isOnboardingCompleteWithDefaults(); diff --git a/lib/presentation/currency/cubit/currency_cubit.dart b/lib/presentation/currency/cubit/currency_cubit.dart index 4b841108..7621aa5e 100644 --- a/lib/presentation/currency/cubit/currency_cubit.dart +++ b/lib/presentation/currency/cubit/currency_cubit.dart @@ -1,12 +1,15 @@ import 'dart:async'; import 'package:collection/collection.dart'; import 'package:currency_picker/currency_picker.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:injectable/injectable.dart'; import 'package:trakli/core/constants/config_constants.dart'; import 'package:trakli/core/error/failures/failures.dart'; import 'package:trakli/core/usecases/usecase.dart'; +import 'package:trakli/core/utils/services/logger.dart'; +import 'package:trakli/gen/translations/codegen_loader.g.dart'; import 'package:trakli/domain/usecases/configs/get_config_usecase.dart'; import 'package:trakli/domain/usecases/configs/listen_to_configs_usecase.dart'; import 'package:trakli/domain/usecases/configs/save_config_usecase.dart'; @@ -94,6 +97,32 @@ class CurrencyCubit extends Cubit { Future setCurrency(Currency currency) async { emit(const CurrencyState.loading()); + + final existingResult = await _getConfigUseCase( + GetConfigUseCaseParams(key: ConfigConstants.defaultCurrency), + ); + final existingCode = existingResult.fold( + (_) => null, + (config) => config.value as String?, + ); + final isFirstSelection = existingCode == null || existingCode.isEmpty; + final isSameCurrency = existingCode == currency.code; + + // Switching requires rates for the new base; on failure nothing + // changes. First selection is exempt so onboarding never blocks. + if (!isFirstSelection && !isSameCurrency) { + final targetRate = await _updateDefaultCurrencyUseCase( + UpdateDefaultCurrencyParams(currencyCode: currency.code), + ); + if (targetRate.isLeft()) { + emit(CurrencyState.error(Failure.validationError( + LocaleKeys.currencySwitchRatesUnavailable.tr(), + errors: const [], + ))); + return; + } + } + final saveResult = await _saveConfigUseCase( SaveConfigUseCaseParams( key: ConfigConstants.defaultCurrency, @@ -101,19 +130,22 @@ class CurrencyCubit extends Cubit { value: currency.code, ), ); - saveResult.fold( - (failure) => emit(CurrencyState.error(failure)), + await saveResult.fold( + (failure) async => emit(CurrencyState.error(failure)), (_) async { - // Update the exchange rate with the new default currency - final updateResult = await _updateDefaultCurrencyUseCase( - UpdateDefaultCurrencyParams( - currencyCode: currency.code, - ), - ); - updateResult.fold( - (failure) => emit(CurrencyState.error(failure)), - (_) => emit(CurrencyState.loaded(currency)), - ); + if (isFirstSelection || isSameCurrency) { + // Best-effort refresh only. + final updateResult = await _updateDefaultCurrencyUseCase( + UpdateDefaultCurrencyParams(currencyCode: currency.code), + ); + updateResult.fold( + (failure) => logger.w( + '[currency] rate refresh failed after currency change: ' + '${failure.customMessage}'), + (_) {}, + ); + } + emit(CurrencyState.loaded(currency)); }, ); } diff --git a/lib/presentation/defaults_settings_screen.dart b/lib/presentation/defaults_settings_screen.dart index 860c5df9..f4cf9eea 100644 --- a/lib/presentation/defaults_settings_screen.dart +++ b/lib/presentation/defaults_settings_screen.dart @@ -38,177 +38,192 @@ class DefaultsSettingsScreen extends StatelessWidget { final wallet = wallets .firstWhereOrNull((entity) => entity.clientId == defaultWalletId); - return Scaffold( - appBar: PageAppBar( - title: LocaleKeys.defaults.tr(), - ), - body: SingleChildScrollView( - padding: EdgeInsets.symmetric( - horizontal: 16.w, - vertical: 16.h, + return BlocListener( + listener: (context, state) { + state.maybeWhen( + loading: showLoader, + error: (failure) { + hideLoader(); + showSnackBar(message: failure.customMessage, borderRadius: 8.r); + }, + orElse: hideLoader, + ); + }, + child: Scaffold( + appBar: PageAppBar( + title: LocaleKeys.defaults.tr(), ), - child: Column( - children: [ - // Default Currency - BlocBuilder( - builder: (context, currencyState) { - final currency = currencyState.currency; - return ListTile( - contentPadding: EdgeInsets.zero, - onTap: () { - showCurrencyPicker( - context: context, - theme: CurrencyPickerThemeData( - bottomSheetHeight: 0.7.sh, - backgroundColor: - Theme.of(context).scaffoldBackgroundColor, - flagSize: 24.sp, - subtitleTextStyle: TextStyle( - fontSize: 12.sp, - color: Theme.of(context).primaryColor, + body: SingleChildScrollView( + padding: EdgeInsets.symmetric( + horizontal: 16.w, + vertical: 16.h, + ), + child: Column( + children: [ + // Default Currency + BlocBuilder( + builder: (context, currencyState) { + final currency = currencyState.currency; + return ListTile( + contentPadding: EdgeInsets.zero, + onTap: () { + showCurrencyPicker( + context: context, + theme: CurrencyPickerThemeData( + bottomSheetHeight: 0.7.sh, + backgroundColor: + Theme.of(context).scaffoldBackgroundColor, + flagSize: 24.sp, + subtitleTextStyle: TextStyle( + fontSize: 12.sp, + color: Theme.of(context).primaryColor, + ), ), + onSelect: (Currency currencyValue) { + context + .read() + .setCurrency(currencyValue); + }, + ); + }, + leading: Container( + width: 40.w, + height: 40.h, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8.r), + color: Theme.of(context) + .primaryColor + .withValues(alpha: 0.2), + ), + child: Icon( + Icons.currency_exchange, + color: Theme.of(context).primaryColor, ), - onSelect: (Currency currencyValue) { - context - .read() - .setCurrency(currencyValue); - }, - ); - }, - leading: Container( - width: 40.w, - height: 40.h, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8.r), - color: - Theme.of(context).primaryColor.withValues(alpha: 0.2), ), - child: Icon( - Icons.currency_exchange, - color: Theme.of(context).primaryColor, + title: Text(LocaleKeys.defaultCurrency.tr()), + subtitle: currency != null + ? Text( + currency.code, + style: TextStyle( + fontSize: 12.sp, + color: Theme.of(context).primaryColor, + ), + ) + : null, + trailing: Icon( + Icons.arrow_forward_ios, + size: 16.sp, ), + ); + }, + ), + // Default Group + ListTile( + contentPadding: EdgeInsets.zero, + leading: Container( + width: 40.w, + height: 40.h, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8.r), + color: + Theme.of(context).primaryColor.withValues(alpha: 0.2), ), - title: Text(LocaleKeys.defaultCurrency.tr()), - subtitle: currency != null - ? Text( - currency.code, - style: TextStyle( - fontSize: 12.sp, - color: Theme.of(context).primaryColor, - ), - ) - : null, - trailing: Icon( - Icons.arrow_forward_ios, - size: 16.sp, + child: Icon( + Icons.folder_outlined, + color: Theme.of(context).primaryColor, ), - ); - }, - ), - // Default Group - ListTile( - contentPadding: EdgeInsets.zero, - leading: Container( - width: 40.w, - height: 40.h, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8.r), - color: Theme.of(context).primaryColor.withValues(alpha: 0.2), ), - child: Icon( - Icons.folder_outlined, - color: Theme.of(context).primaryColor, + title: Text(LocaleKeys.switchDefaultGroup.tr()), + subtitle: Text( + group?.name ?? "", + style: TextStyle( + fontSize: 12.sp, + color: Theme.of(context).primaryColor, + ), ), + onTap: groups.length > 1 + ? () async { + final pickedGroup = + await showCustomBottomSheet( + context, + color: Theme.of(context).scaffoldBackgroundColor, + widget: PickGroupBottomSheet( + group: group, + ), + ); + if (pickedGroup != null && context.mounted) { + context.read().saveConfig( + key: ConfigConstants.defaultGroup, + type: ConfigType.string, + value: pickedGroup.clientId, + ); + } + } + : null, + trailing: groups.length > 1 + ? Icon( + Icons.arrow_forward_ios, + size: 16.sp, + ) + : null, ), - title: Text(LocaleKeys.switchDefaultGroup.tr()), - subtitle: Text( - group?.name ?? "", - style: TextStyle( - fontSize: 12.sp, - color: Theme.of(context).primaryColor, + // Default Wallet + ListTile( + contentPadding: EdgeInsets.zero, + leading: Container( + width: 40.w, + height: 40.h, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8.r), + color: + Theme.of(context).primaryColor.withValues(alpha: 0.2), + ), + child: Icon( + Icons.account_balance_wallet, + color: Theme.of(context).primaryColor, + ), ), - ), - onTap: groups.length > 1 - ? () async { - final pickedGroup = - await showCustomBottomSheet( - context, - color: Theme.of(context).scaffoldBackgroundColor, - widget: PickGroupBottomSheet( - group: group, + title: Text(LocaleKeys.defaultWallet.tr()), + subtitle: wallet != null + ? Text( + wallet.name, + style: TextStyle( + fontSize: 12.sp, + color: Theme.of(context).primaryColor, ), - ); - if (pickedGroup != null && context.mounted) { - context.read().saveConfig( - key: ConfigConstants.defaultGroup, - type: ConfigType.string, - value: pickedGroup.clientId, - ); + ) + : null, + onTap: wallets.isNotEmpty + ? () async { + await showCustomBottomSheet( + context, + color: Theme.of(context).scaffoldBackgroundColor, + widget: SelectWalletBottomSheet( + wallets: wallets, + onSelect: (selectedWallet) { + if (context.mounted) { + context.read().saveConfig( + key: ConfigConstants.defaultWallet, + type: ConfigType.string, + value: selectedWallet.clientId, + ); + Navigator.of(context).pop(); + } + }, + ), + ); } - } - : null, - trailing: groups.length > 1 - ? Icon( - Icons.arrow_forward_ios, - size: 16.sp, - ) - : null, - ), - // Default Wallet - ListTile( - contentPadding: EdgeInsets.zero, - leading: Container( - width: 40.w, - height: 40.h, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8.r), - color: Theme.of(context).primaryColor.withValues(alpha: 0.2), - ), - child: Icon( - Icons.account_balance_wallet, - color: Theme.of(context).primaryColor, - ), + : null, + trailing: wallets.isNotEmpty + ? Icon( + Icons.arrow_forward_ios, + size: 16.sp, + ) + : null, ), - title: Text(LocaleKeys.defaultWallet.tr()), - subtitle: wallet != null - ? Text( - wallet.name, - style: TextStyle( - fontSize: 12.sp, - color: Theme.of(context).primaryColor, - ), - ) - : null, - onTap: wallets.isNotEmpty - ? () async { - await showCustomBottomSheet( - context, - color: Theme.of(context).scaffoldBackgroundColor, - widget: SelectWalletBottomSheet( - wallets: wallets, - onSelect: (selectedWallet) { - if (context.mounted) { - context.read().saveConfig( - key: ConfigConstants.defaultWallet, - type: ConfigType.string, - value: selectedWallet.clientId, - ); - Navigator.of(context).pop(); - } - }, - ), - ); - } - : null, - trailing: wallets.isNotEmpty - ? Icon( - Icons.arrow_forward_ios, - size: 16.sp, - ) - : null, - ), - SizedBox(height: 24.h), - ], + SizedBox(height: 24.h), + ], + ), ), ), ); diff --git a/lib/presentation/transfers/wallet_transfer_screen.dart b/lib/presentation/transfers/wallet_transfer_screen.dart index e0111e07..0a927dae 100644 --- a/lib/presentation/transfers/wallet_transfer_screen.dart +++ b/lib/presentation/transfers/wallet_transfer_screen.dart @@ -104,22 +104,7 @@ class _WalletTransferScreenState extends State { orElse: () => wallets.length > 1 ? wallets[1] : selectedFromWallet!, ); - // Initialize default exchange rate based on current wallets - final exchangeRateEntity = - context.read().state.entity; - double defaultRate = 1.0; - if (exchangeRateEntity != null && - selectedFromWallet!.currencyCode != - selectedToWallet!.currencyCode) { - final fromRate = - exchangeRateEntity.rates[selectedFromWallet!.currencyCode] ?? - 1.0; - final toRate = - exchangeRateEntity.rates[selectedToWallet!.currencyCode] ?? 1.0; - defaultRate = toRate / fromRate; - } - _exchangeRateController.text = - formatExchangeRateForDisplay(defaultRate); + _prefillExchangeRate(); }); } }); @@ -134,6 +119,23 @@ class _WalletTransferScreenState extends State { super.dispose(); } + /// Null when no rate is known — the field stays empty, never a silent 1:1. + double? _defaultRateFor(WalletEntity? from, WalletEntity? to) { + if (from == null || to == null) return null; + if (from.currencyCode == to.currencyCode) return 1.0; + final entity = context.read().state.entity; + final fromRate = entity?.rates[from.currencyCode]; + final toRate = entity?.rates[to.currencyCode]; + if (fromRate == null || toRate == null) return null; + return toRate / fromRate; + } + + void _prefillExchangeRate() { + final rate = _defaultRateFor(selectedFromWallet, selectedToWallet); + _exchangeRateController.text = + rate == null ? '' : formatExchangeRateForDisplay(rate); + } + void _showWalletSelector({ required bool isFromWallet, required List availableWallets, @@ -217,24 +219,7 @@ class _WalletTransferScreenState extends State { } // Recompute default exchange rate when wallets change - final exchangeRateEntity = - context.read().state.entity; - double defaultRate = 1.0; - if (exchangeRateEntity != null && - selectedFromWallet != null && - selectedToWallet != null && - selectedFromWallet!.currencyCode != - selectedToWallet!.currencyCode) { - final fromRate = exchangeRateEntity - .rates[selectedFromWallet!.currencyCode] ?? - 1.0; - final toRate = exchangeRateEntity - .rates[selectedToWallet!.currencyCode] ?? - 1.0; - defaultRate = toRate / fromRate; - } - _exchangeRateController.text = - formatExchangeRateForDisplay(defaultRate); + _prefillExchangeRate(); }); Navigator.pop(context); }, @@ -285,7 +270,10 @@ class _WalletTransferScreenState extends State { selectedToWallet != null && selectedFromWallet!.currencyCode != selectedToWallet!.currencyCode) { final parsedRate = parseAmount(_exchangeRateController.text.trim()); - exchangeRate = parsedRate > 0 ? parsedRate : 1.0; + if (parsedRate <= 0) { + return LocaleKeys.exchangeRateRequired.tr(); + } + exchangeRate = parsedRate; } final receiveAmount = (selectedFromWallet != null && selectedToWallet != null && @@ -323,13 +311,13 @@ class _WalletTransferScreenState extends State { if (_formKey.currentState!.validate()) { final amount = parseAmount(_amountController.text.trim()); - // Use exchange rate from the editable field when cross-currency double exchangeRate = 1.0; if (selectedFromWallet != null && selectedToWallet != null && selectedFromWallet!.currencyCode != selectedToWallet!.currencyCode) { final parsedRate = parseAmount(_exchangeRateController.text.trim()); - exchangeRate = parsedRate > 0 ? parsedRate : 1.0; + if (parsedRate <= 0) return; + exchangeRate = parsedRate; } final receiveAmount = (selectedFromWallet != null && @@ -402,19 +390,14 @@ class _WalletTransferScreenState extends State { final amount = parseAmount(_amountController.text.trim()); - // Use the current exchange rate from the editable field - double exchangeRate = 1.0; final parsedRate = parseAmount(_exchangeRateController.text.trim()); - if (parsedRate > 0) { - exchangeRate = parsedRate; - } - - final receiveAmount = amount * exchangeRate; - final formatted = CurrencyFormater.formatAmountWithSymbol( - context, - receiveAmount, - currency: selectedToWallet!.currency, - ); + final formatted = parsedRate > 0 + ? CurrencyFormater.formatAmountWithSymbol( + context, + amount * parsedRate, + currency: selectedToWallet!.currency, + ) + : '—'; return Container( width: double.infinity, diff --git a/test/unit/currency_cubit_gate_test.dart b/test/unit/currency_cubit_gate_test.dart new file mode 100644 index 00000000..11c0fdc9 --- /dev/null +++ b/test/unit/currency_cubit_gate_test.dart @@ -0,0 +1,142 @@ +import 'package:currency_picker/currency_picker.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fpdart/fpdart.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:trakli/core/constants/config_constants.dart'; +import 'package:trakli/core/error/failures/failures.dart'; +import 'package:trakli/domain/entities/config_entity.dart'; +import 'package:trakli/domain/entities/exchange_rate_entity.dart'; +import 'package:trakli/core/usecases/usecase.dart'; +import 'package:trakli/domain/usecases/configs/get_config_usecase.dart'; +import 'package:trakli/domain/usecases/configs/listen_to_configs_usecase.dart'; +import 'package:trakli/domain/usecases/configs/save_config_usecase.dart'; +import 'package:trakli/domain/usecases/exchange_rate/update_default_currency_usecase.dart'; +import 'package:trakli/presentation/currency/cubit/currency_cubit.dart'; + +class _MockGetConfig extends Mock implements GetConfigUseCase {} + +class _MockSaveConfig extends Mock implements SaveConfigUseCase {} + +class _MockListenConfigs extends Mock implements ListenToConfigsUseCase {} + +class _MockUpdateDefaultCurrency extends Mock + implements UpdateDefaultCurrencyUseCase {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + EasyLocalization.logger.enableBuildModes = []; + + late _MockGetConfig getConfig; + late _MockSaveConfig saveConfig; + late _MockListenConfigs listenConfigs; + late _MockUpdateDefaultCurrency updateDefaultCurrency; + + final gbp = CurrencyService().findByCode('GBP')!; + + ConfigEntity currencyConfig(String code) => ConfigEntity( + id: 1, + userId: 1, + key: ConfigConstants.defaultCurrency, + type: ConfigType.string, + value: code, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + ); + + final rateEntity = ExchangeRateEntity( + provider: 'trakli', + baseCode: 'GBP', + rates: const {'GBP': 1.0}, + timeLastUpdated: DateTime(2026), + timeNextUpdated: DateTime(2027), + ); + + setUpAll(() { + registerFallbackValue(NoParams()); + registerFallbackValue( + GetConfigUseCaseParams(key: ConfigConstants.defaultCurrency)); + registerFallbackValue(SaveConfigUseCaseParams( + key: ConfigConstants.defaultCurrency, + type: ConfigType.string, + value: 'GBP', + )); + registerFallbackValue( + const UpdateDefaultCurrencyParams(currencyCode: 'GBP')); + }); + + setUp(() { + getConfig = _MockGetConfig(); + saveConfig = _MockSaveConfig(); + listenConfigs = _MockListenConfigs(); + updateDefaultCurrency = _MockUpdateDefaultCurrency(); + + when(() => listenConfigs(any())) + .thenAnswer((_) => const Stream.empty()); + when(() => saveConfig(any())) + .thenAnswer((_) async => right(currencyConfig('GBP'))); + }); + + CurrencyCubit build() => CurrencyCubit( + getConfig, + saveConfig, + listenConfigs, + updateDefaultCurrency, + ); + + test('blocks the switch and keeps the currency when no rate is available', + () async { + when(() => getConfig(any())) + .thenAnswer((_) async => right(currencyConfig('XAF'))); + when(() => updateDefaultCurrency(any())) + .thenAnswer((_) async => left(const Failure.notFound())); + + final cubit = build(); + await cubit.setCurrency(gbp); + + expect( + cubit.state.whenOrNull(error: (f) => f.customMessage), + isNotNull, + ); + verifyNever(() => saveConfig(any())); + }); + + test('switches when a rate is available', () async { + when(() => getConfig(any())) + .thenAnswer((_) async => right(currencyConfig('XAF'))); + when(() => updateDefaultCurrency(any())) + .thenAnswer((_) async => right(rateEntity)); + + final cubit = build(); + await cubit.setCurrency(gbp); + + expect(cubit.state, CurrencyState.loaded(gbp)); + verify(() => saveConfig(any())).called(1); + }); + + test('first selection saves even when the rate fetch fails', () async { + when(() => getConfig(any())) + .thenAnswer((_) async => left(const Failure.notFound())); + when(() => updateDefaultCurrency(any())) + .thenAnswer((_) async => left(const Failure.notFound())); + + final cubit = build(); + await cubit.setCurrency(gbp); + + expect(cubit.state, CurrencyState.loaded(gbp)); + verify(() => saveConfig(any())).called(1); + }); + + test('re-selecting the same currency never gates', () async { + when(() => getConfig(any())) + .thenAnswer((_) async => right(currencyConfig('GBP'))); + when(() => updateDefaultCurrency(any())) + .thenAnswer((_) async => left(const Failure.notFound())); + + final cubit = build(); + await cubit.setCurrency(gbp); + + expect(cubit.state, CurrencyState.loaded(gbp)); + verify(() => saveConfig(any())).called(1); + }); +} diff --git a/test/unit/repository_error_handler_test.dart b/test/unit/repository_error_handler_test.dart new file mode 100644 index 00000000..6767df71 --- /dev/null +++ b/test/unit/repository_error_handler_test.dart @@ -0,0 +1,66 @@ +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:trakli/core/error/failures/failures.dart'; +import 'package:trakli/core/error/repository_error_handler.dart'; + +void main() { + DioException dioError(DioExceptionType type, {Object? error}) { + return DioException( + requestOptions: RequestOptions(path: 'stats'), + type: type, + error: error, + ); + } + + group('handleApiCall connectivity mapping', () { + test('connection error becomes NetworkFailure', () async { + final result = await RepositoryErrorHandler.handleApiCall( + () async => throw dioError(DioExceptionType.connectionError), + ); + expect(result.fold((f) => f, (_) => null), const Failure.networkError()); + }); + + test('timeouts become NetworkFailure', () async { + for (final type in [ + DioExceptionType.connectionTimeout, + DioExceptionType.sendTimeout, + DioExceptionType.receiveTimeout, + ]) { + final result = await RepositoryErrorHandler.handleApiCall( + () async => throw dioError(type), + ); + expect( + result.fold((f) => f, (_) => null), + const Failure.networkError(), + reason: '$type should map to NetworkFailure', + ); + } + }); + + test('unknown Dio error wrapping a SocketException becomes NetworkFailure', + () async { + final result = await RepositoryErrorHandler.handleApiCall( + () async => throw dioError( + DioExceptionType.unknown, + error: const SocketException('Failed host lookup'), + ), + ); + expect(result.fold((f) => f, (_) => null), const Failure.networkError()); + }); + + test('server rejection stays a non-network failure', () async { + final result = await RepositoryErrorHandler.handleApiCall( + () async => throw dioError(DioExceptionType.badResponse), + ); + expect(result.fold((f) => f, (_) => null), const Failure.unknownError()); + }); + + test('network failure message tells the user to check their connection', + () { + const failure = Failure.networkError(); + expect(failure.customMessage.toLowerCase(), contains('internet')); + }); + }); +} From 83d0f6dc02e0526d287ffd3469471fbbdac3e662 Mon Sep 17 00:00:00 2001 From: Fuh Austin Date: Wed, 29 Jul 2026 13:19:08 +0100 Subject: [PATCH 5/6] feat(budget): Align the budget card with the web app Status chip, scope pills, status-colored progress with net spent of effective limit and percent, and a remaining footer with refunds chip, matching BudgetCard.vue. Removes the drawer's duplicate budget entry now that Budgets is a bottom-navigation tab. --- lib/presentation/budget/budget_screen.dart | 190 ++++++++++++++++----- lib/presentation/utils/custom_drawer.dart | 34 ---- 2 files changed, 143 insertions(+), 81 deletions(-) diff --git a/lib/presentation/budget/budget_screen.dart b/lib/presentation/budget/budget_screen.dart index 2c26709d..a4c630ac 100644 --- a/lib/presentation/budget/budget_screen.dart +++ b/lib/presentation/budget/budget_screen.dart @@ -179,23 +179,14 @@ class _BudgetCard extends StatelessWidget { }; } - String _periodShort() { - return switch (budget.periodType) { - BudgetPeriodType.weekly => LocaleKeys.periodWeeklyShort.tr(), - BudgetPeriodType.monthly => LocaleKeys.periodMonthlyShort.tr(), - BudgetPeriodType.yearly => LocaleKeys.periodYearlyShort.tr(), - BudgetPeriodType.custom => '', - }; - } - - String _scopeText() { - if (budget.targets.isEmpty) return LocaleKeys.scopeAllTransactions.tr(); + List _scopeLabels() { + if (budget.targets.isEmpty) return [LocaleKeys.scopeAllTransactions.tr()]; final byType = {}; for (final t in budget.targets) { byType[t.type] = (byType[t.type] ?? 0) + 1; } - final parts = byType.entries.map((e) { - final label = switch (e.key) { + return byType.entries.map((e) { + return switch (e.key) { BudgetTargetType.category => '${e.value} ${e.value == 1 ? LocaleKeys.targetTypeCategorySingular.tr() : LocaleKeys.categories.tr()}', BudgetTargetType.wallet => @@ -203,14 +194,48 @@ class _BudgetCard extends StatelessWidget { BudgetTargetType.group => '${e.value} ${e.value == 1 ? LocaleKeys.targetTypeGroupSingular.tr() : LocaleKeys.groups.tr()}', }; - return label; - }); - return parts.join(' · '); + }).toList(); + } + + Color _statusColor(BuildContext context, BudgetStatus? status) { + final tones = context.tones; + return switch (status) { + BudgetStatus.overBudget => Colors.redAccent, + BudgetStatus.forecastBreach => Colors.orangeAccent, + BudgetStatus.nearLimit => Colors.amber.shade700, + BudgetStatus.onTrack => tones.brand.accent, + null => tones.textMuted, + }; + } + + String _statusLabel(BudgetStatus? status) { + return switch (status) { + BudgetStatus.overBudget => LocaleKeys.budgetStatusOverBudget.tr(), + BudgetStatus.forecastBreach => LocaleKeys.budgetStatusForecastBreach.tr(), + BudgetStatus.nearLimit => LocaleKeys.budgetStatusNearLimit.tr(), + BudgetStatus.onTrack => LocaleKeys.budgetStatusOnTrack.tr(), + null => LocaleKeys.budgetStatusAwaitingSync.tr(), + }; + } + + String _money(double value) { + return '${budget.currency} ${NumberFormat('#,##0.##').format(value)}'; } @override Widget build(BuildContext context) { final tones = context.tones; + final progress = budget.progress; + final statusColor = _statusColor(context, progress?.status); + final netSpent = progress?.netSpent ?? 0.0; + final limit = progress?.effectiveLimit ?? budget.amount; + final remaining = progress?.remaining ?? limit; + final percent = progress?.percentUsed ?? 0.0; + final refunds = progress?.refunds ?? 0.0; + final periodLine = progress != null + ? '${_periodLabel()} · ${DateFormat.MMMd().format(progress.periodStart)}' + : _periodLabel(); + return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(AppRadii.lg), @@ -229,27 +254,32 @@ class _BudgetCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( - child: Text( - budget.name, - style: TextStyle( - fontSize: 15.sp, - fontWeight: FontWeight.w700, - color: tones.textPrimary, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + budget.name, + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w700, + color: tones.textPrimary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + SizedBox(height: 2.h), + Text( + periodLine, + style: TextStyle( + fontSize: 11.sp, + color: tones.textMuted, + ), + ), + ], ), ), SizedBox(width: 8.w), - Text( - '${budget.currency} ${budget.amount.toStringAsFixed(0)}${_periodShort()}', - style: TextStyle( - fontSize: 14.sp, - fontWeight: FontWeight.w700, - color: tones.textPrimary, - ), - ), - SizedBox(width: 4.w), + _MiniChip(label: _statusLabel(progress?.status), color: statusColor), PopupMenuButton( padding: EdgeInsets.zero, iconSize: 18.sp, @@ -266,14 +296,41 @@ class _BudgetCard extends StatelessWidget { ), ], ), + SizedBox(height: 8.h), + Wrap( + spacing: 6.w, + runSpacing: 6.h, + children: [ + for (final label in _scopeLabels()) _MiniChip(label: label), + if (!budget.isActive) + _MiniChip(label: LocaleKeys.statusInactive.tr(), muted: true), + ], + ), + SizedBox(height: 10.h), + ClipRRect( + borderRadius: BorderRadius.circular(AppRadii.pill), + child: LinearProgressIndicator( + value: (percent / 100).clamp(0.0, 1.0), + minHeight: 8.h, + backgroundColor: tones.bgPage, + valueColor: AlwaysStoppedAnimation(statusColor), + ), + ), SizedBox(height: 6.h), Row( children: [ - _MiniChip(label: _periodLabel()), - SizedBox(width: 8.w), - Flexible( - child: Text( - _scopeText(), + Expanded( + child: Text.rich( + TextSpan( + children: [ + TextSpan( + text: _money(netSpent), + style: const TextStyle(fontWeight: FontWeight.w700), + ), + TextSpan(text: ' ${LocaleKeys.budgetOf.tr()} '), + TextSpan(text: _money(limit)), + ], + ), style: TextStyle( fontSize: 12.sp, color: tones.textSecondary, @@ -282,10 +339,48 @@ class _BudgetCard extends StatelessWidget { overflow: TextOverflow.ellipsis, ), ), - if (!budget.isActive) ...[ - SizedBox(width: 8.w), - _MiniChip(label: LocaleKeys.statusInactive.tr(), muted: true), - ], + Text( + '${percent.round()}%', + style: TextStyle( + fontSize: 12.sp, + fontWeight: FontWeight.w700, + color: tones.textSecondary, + ), + ), + ], + ), + SizedBox(height: 10.h), + Container(height: 1, color: tones.borderLight), + SizedBox(height: 8.h), + Row( + children: [ + Text( + LocaleKeys.budgetRemaining.tr().toUpperCase(), + style: TextStyle( + fontSize: 10.sp, + letterSpacing: 0.5, + color: tones.textMuted, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(width: 6.w), + Expanded( + child: Text( + _money(remaining), + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w700, + color: + remaining < 0 ? Colors.redAccent : tones.textPrimary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (refunds > 0) + _MiniChip( + label: '↩ ${_money(refunds)}', + ), ], ), ], @@ -298,16 +393,17 @@ class _BudgetCard extends StatelessWidget { class _MiniChip extends StatelessWidget { final String label; final bool muted; - const _MiniChip({required this.label, this.muted = false}); + final Color? color; + const _MiniChip({required this.label, this.muted = false, this.color}); @override Widget build(BuildContext context) { final tones = context.tones; + final accent = color ?? tones.brand.accent; return Container( padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 2.h), decoration: BoxDecoration( - color: - muted ? tones.bgPage : tones.brand.accent.withValues(alpha: 0.12), + color: muted ? tones.bgPage : accent.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(AppRadii.pill), ), child: Text( @@ -315,7 +411,7 @@ class _MiniChip extends StatelessWidget { style: TextStyle( fontSize: 11.sp, fontWeight: FontWeight.w600, - color: muted ? tones.textMuted : tones.brand.accent, + color: muted ? tones.textMuted : accent, ), ), ); diff --git a/lib/presentation/utils/custom_drawer.dart b/lib/presentation/utils/custom_drawer.dart index a047b57a..ab3df2e4 100644 --- a/lib/presentation/utils/custom_drawer.dart +++ b/lib/presentation/utils/custom_drawer.dart @@ -8,7 +8,6 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:trakli/di/injection.dart'; import 'package:trakli/gen/assets.gen.dart'; import 'package:trakli/presentation/auth/cubits/auth/auth_cubit.dart'; -import 'package:trakli/presentation/budget/budget_screen.dart'; import 'package:trakli/gen/translations/codegen_loader.g.dart'; import 'package:trakli/presentation/category/category_screen.dart'; import 'package:trakli/presentation/config/cubit/config_cubit.dart'; @@ -199,39 +198,6 @@ class CustomDrawer extends StatelessWidget { iconPath: Assets.images.people, subtitle: LocaleKeys.groupsDesc.tr(), ), - InkWell( - onTap: () => AppNavigator.push( - context, - const BudgetScreen(), - ), - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: 16.w, - vertical: 8.h, - ), - child: Row( - children: [ - Icon( - Icons.savings_outlined, - size: 20.sp, - color: Theme.of(context).colorScheme.onSurface, - ), - SizedBox(width: 14.w), - Expanded( - child: Text( - LocaleKeys.drawerBudgets.tr(), - style: TextStyle( - fontSize: 14.sp, - fontWeight: FontWeight.w600, - color: - Theme.of(context).colorScheme.onSurface, - ), - ), - ), - ], - ), - ), - ), _iconMenuItem( context, icon: Icons.balance, From f01fa6dc4e8456023d782b1c1c2f22731ebf31b9 Mon Sep 17 00:00:00 2001 From: Fuh Austin Date: Wed, 29 Jul 2026 13:19:08 +0100 Subject: [PATCH 6/6] chore(release): Bump version to 1.0.6+5 and adopt drift_sync_core v0.3.2 drift_sync_core v0.3.2 enqueues pending changes before remote attempts and attaches queued payloads to upload crash reports. The v1.0.5 tag shipped still reading 1.0.4+4, so installed builds under-reported their version. --- pubspec.lock | 6 +++--- pubspec.yaml | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 12ed0e58..084be50e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -429,11 +429,11 @@ packages: dependency: "direct main" description: path: "packages/drift_sync_core" - ref: "drift_sync_core-v0.3.0" - resolved-ref: "0aa018599a40da32e07a1f7a6fa4e92db94ac525" + ref: "drift_sync_core-v0.3.3" + resolved-ref: dae6a056a7295cf196d13800c9194e2cd4ad2951 url: "https://github.com/whilesmartflutter/drift_sync.git" source: git - version: "0.2.0" + version: "0.3.3" drift_sync_flutter: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 8cfda4c2..4452fc60 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.4+4 +version: 1.0.6+5 environment: sdk: ">=3.4.3 <4.0.0" @@ -65,7 +65,7 @@ dependencies: drift_sync_core: git: url: https://github.com/whilesmartflutter/drift_sync.git - ref: drift_sync_core-v0.3.0 + ref: drift_sync_core-v0.3.3 path: packages/drift_sync_core drift_sync_flutter: git: