Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions assets/translations/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
7 changes: 5 additions & 2 deletions assets/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
10 changes: 8 additions & 2 deletions assets/translations/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
10 changes: 8 additions & 2 deletions assets/translations/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
10 changes: 8 additions & 2 deletions assets/translations/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
10 changes: 8 additions & 2 deletions assets/translations/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -917,5 +917,11 @@
"fpHelpFlowsTitle": "Поступления и расходы",
"fpHelpFlowsBody": "Они показывают, что двигало ваши деньги за период. Некоторые строки — например, полученные или погашенные займы — двигают наличные, но не меняют капитал, поэтому помечены как \"Наличные, не капитал\".",
"fpHelpAsOfTitle": "По состоянию на",
"fpHelpAsOfBody": "Цифры рассчитываются на сервере и отражают ваши данные по состоянию на время, показанное внизу карточки."
}
"fpHelpAsOfBody": "Цифры рассчитываются на сервере и отражают ваши данные по состоянию на время, показанное внизу карточки.",
"budgetOf": "из",
"needsAttention": "Требует внимания",
"noQuarantinedChanges": "Нет изменений в карантине",
"quarantinedHint": "Эти изменения завершились с постоянной ошибкой и не будут повторяться автоматически. Повторите их после устранения причины или отклоните.",
"exchangeRateRequired": "Введите обменный курс — актуальный курс недоступен",
"currencySwitchRatesUnavailable": "Сейчас нельзя сменить валюту — обменные курсы недоступны. Повторите попытку позже."
}
Original file line number Diff line number Diff line change
@@ -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<String> 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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The method _record is not defined in this class. Based on the class definition and the CrashReportingInterface, you should call recordError instead. This is critical as it will cause a crash when a global error occurs.

Suggested change
unawaited(_record(error, stackTrace: stack, fatal: true));
unawaited(recordError(error, stackTrace: stack, fatal: true));

return true;
};
}
Expand Down
24 changes: 24 additions & 0 deletions lib/core/error/repository_error_handler.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a DioException is not a connectivity error (e.g., a 422 Validation Error or 400 Bad Request), simply returning an UnknownFailure hides the server's error message from the user. Since this PR aims to fix 'sync 422 divergence', we should extract and display the server's response message if available.

Suggested change
logger.e('UnknownFailure', error: e, stackTrace: stackTrace);
final serverMessage = e.response?.data?['message']?.toString();
if (serverMessage != null) {
return left(ServerFailure(serverMessage));
}
logger.e('UnknownFailure', error: e, stackTrace: stackTrace);
return left(const UnknownFailure());

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),
Expand Down
5 changes: 4 additions & 1 deletion lib/core/module/http_module.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -31,7 +33,8 @@ abstract class InjectHttpClientModule {
RemoveNullValuesInterceptor(),
TokenInterceptor(getIt<TokenManager>(), getIt<AuthService>()),
LocaleInterceptor(),
LoggerInterceptor()
LoggerInterceptor(),
CrashReportingInterceptor(getIt<CrashReportingService>()),
]);

return dio;
Expand Down
53 changes: 53 additions & 0 deletions lib/core/network/interceptors/crash_reporting_interceptor.dart
Original file line number Diff line number Diff line change
@@ -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<String> _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);
}
}
53 changes: 53 additions & 0 deletions lib/core/sync/sync_database.dart
Original file line number Diff line number Diff line change
@@ -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<AppDatabase> {
Expand All @@ -18,10 +22,59 @@ class SynchAppDatabase extends DriftSynchronizer<AppDatabase> {
classifyFailure: restFailureClassifier,
);

/// Entity types swept by [reconcileOrphanedLocalChanges] → backing tables.
static const Map<String, String> reconciledEntityTables = {
TransactionSyncHandler.entity: 'transactions',
TransferSyncHandler.entity: 'transfers',
};

final _syncStateController = StreamController<SyncState>.broadcast();

Stream<SyncState> get syncStateStream => _syncStateController.stream;

@override
Future<void> 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<int> 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 =
Comment thread
austin047 marked this conversation as resolved.
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<void> Function(SyncState previous, SyncState current)?
get onStateChanged =>
Expand Down
16 changes: 16 additions & 0 deletions lib/core/sync/sync_error_description.dart
Original file line number Diff line number Diff line change
@@ -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();
}
7 changes: 6 additions & 1 deletion lib/core/utils/date_util.dart
Original file line number Diff line number Diff line change
@@ -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();
Comment thread
austin047 marked this conversation as resolved.
return DateTime.fromMillisecondsSinceEpoch(
utc.millisecondsSinceEpoch,
isUtc: true,
).toIso8601String();
}

DateTime getNewFormattedUtcDateTime() {
Expand Down
16 changes: 15 additions & 1 deletion lib/data/database/app_database.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<List<String>> 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<String>('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
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading