From 06263df19a0f71a1c7f7698d45b96e6c5c93f9e4 Mon Sep 17 00:00:00 2001 From: Fuh Austin Date: Tue, 4 Aug 2026 14:29:50 +0100 Subject: [PATCH 1/2] fix(budget): Send resolvable target references when syncing budgets Budget targets went out under 'client_id' while the server validates 'targets.*.client_generated_id', so every offline-created budget with targets failed with a 422 and quarantined. Renaming the key alone is not enough: the queued payload is a snapshot, so a target created offline still carries a null id after its own entity syncs, and the server only ever attaches targets by numeric id. The handler now re-resolves targets from local before pushing, defers the push while any target is still unresolved (matching how transactions gate on their category and wallet), and declares budget's dependency on category, wallet and group so those push first. Also fixes getLocalByClientId/getLocalByServerId dropping targets entirely, which would have pushed a budget with its targets cleared. --- lib/core/sync/sync_dependency_manager.dart | 6 + .../budget/dtos/budget_complete_dto.dart | 2 +- lib/data/sync/budget_sync_handler.dart | 46 +++- lib/di/injection.config.dart | 259 +++++++++--------- test/unit/budget_target_sync_test.dart | 203 ++++++++++++++ 5 files changed, 380 insertions(+), 136 deletions(-) create mode 100644 test/unit/budget_target_sync_test.dart diff --git a/lib/core/sync/sync_dependency_manager.dart b/lib/core/sync/sync_dependency_manager.dart index ae542ef7..abe66700 100644 --- a/lib/core/sync/sync_dependency_manager.dart +++ b/lib/core/sync/sync_dependency_manager.dart @@ -1,4 +1,5 @@ import 'package:drift_sync_core/drift_sync_core.dart'; +import 'package:trakli/data/sync/budget_sync_handler.dart'; import 'package:trakli/data/sync/category_sync_handler.dart'; import 'package:trakli/data/sync/group_sync_handler.dart'; import 'package:trakli/data/sync/notification_sync_handler.dart'; @@ -24,6 +25,11 @@ class SyncDependencyManager extends DefaultSyncDependencyManager { PartySyncHandler.entity, GroupSyncHandler.entity, }, + BudgetSyncHandler.entity: { + CategorySyncHandler.entity, + WalletSyncHandler.entity, + GroupSyncHandler.entity, + }, CategorySyncHandler.entity: {}, WalletSyncHandler.entity: {}, PartySyncHandler.entity: {}, diff --git a/lib/data/datasources/budget/dtos/budget_complete_dto.dart b/lib/data/datasources/budget/dtos/budget_complete_dto.dart index ed46a943..6ef17eae 100644 --- a/lib/data/datasources/budget/dtos/budget_complete_dto.dart +++ b/lib/data/datasources/budget/dtos/budget_complete_dto.dart @@ -97,7 +97,7 @@ class BudgetCompleteDto { 'type': t.type.serverKey, if (t.id != null) 'id': t.id, if (t.clientId != null && t.clientId!.isNotEmpty) - 'client_id': t.clientId, + 'client_generated_id': t.clientId, }) .toList(), }; diff --git a/lib/data/sync/budget_sync_handler.dart b/lib/data/sync/budget_sync_handler.dart index 17c523da..6cee62a7 100644 --- a/lib/data/sync/budget_sync_handler.dart +++ b/lib/data/sync/budget_sync_handler.dart @@ -5,8 +5,10 @@ import 'package:trakli/core/utils/id_helper.dart'; import 'package:trakli/data/database/app_database.dart'; import 'package:trakli/data/database/tables/budgets.dart'; import 'package:trakli/data/database/tables/sync_table.dart'; +import 'package:trakli/data/datasources/budget/budget_local_datasource.dart'; import 'package:trakli/data/datasources/budget/budget_remote_datasource.dart'; import 'package:trakli/data/datasources/budget/dtos/budget_complete_dto.dart'; +import 'package:trakli/data/datasources/budget/dtos/budget_target_dto.dart'; import 'package:trakli/data/mappers/budget_mapper.dart'; @lazySingleton @@ -18,10 +20,12 @@ class BudgetSyncHandler BudgetSyncHandler( this.db, this.remoteDataSource, + this.localDataSource, ); final AppDatabase db; final BudgetRemoteDataSource remoteDataSource; + final BudgetLocalDataSource localDataSource; TableInfo get table => db.budgets; @@ -56,7 +60,26 @@ class BudgetSyncHandler } @override - Future shouldPersistRemote(BudgetCompleteDto entity) async => true; + Future shouldPersistRemote(BudgetCompleteDto entity) async { + final targets = await _resolvedTargets(entity.budget.clientId); + return targets.every((t) => t.id != null); + } + + /// The queued payload is a snapshot taken when the budget was saved, so a + /// target created offline still carries a null id there even after its own + /// entity has synced. Re-read from local before pushing. + Future> _resolvedTargets(String budgetClientId) async { + final resolved = + await localDataSource.getResolvedTargetsForBudget(budgetClientId); + return resolved + .map((r) => BudgetTargetDto( + type: r.type, + id: r.id, + clientId: r.clientId, + name: r.name, + )) + .toList(); + } @override Future> restGetAllRemote({ @@ -85,10 +108,15 @@ class BudgetSyncHandler @override Future restPutRemote(BudgetCompleteDto entity) async { - if (entity.budget.id == null) { - return remoteDataSource.insertBudget(entity); + final payload = BudgetCompleteDto( + budget: entity.budget, + targets: await _resolvedTargets(entity.budget.clientId), + progress: entity.progress, + ); + if (payload.budget.id == null) { + return remoteDataSource.insertBudget(payload); } else { - return remoteDataSource.updateBudget(entity); + return remoteDataSource.updateBudget(payload); } } @@ -151,7 +179,10 @@ class BudgetSyncHandler if (row == null) { throw Exception('Budget not found'); } - return BudgetCompleteDto(budget: row); + return BudgetCompleteDto( + budget: row, + targets: await _resolvedTargets(row.clientId), + ); } @override @@ -159,7 +190,10 @@ class BudgetSyncHandler try { final row = await (db.select(table)..where((t) => t.id.equals(serverId))) .getSingle(); - return BudgetCompleteDto(budget: row); + return BudgetCompleteDto( + budget: row, + targets: await _resolvedTargets(row.clientId), + ); } catch (_) { return null; } diff --git a/lib/di/injection.config.dart b/lib/di/injection.config.dart index 54881fe2..5ea1ab99 100644 --- a/lib/di/injection.config.dart +++ b/lib/di/injection.config.dart @@ -318,8 +318,8 @@ _i174.GetIt $initGetIt( gh.factory<_i624.OAuthService>(() => _i624.OAuthService()); gh.factory<_i1041.SyncCubit>(() => _i1041.SyncCubit()); gh.factory<_i363.StatisticsFilterCubit>(() => _i363.StatisticsFilterCubit()); - gh.singleton<_i196.FeatureRemoteConfig>(() => _i196.FeatureRemoteConfig()); gh.singleton<_i957.SyncService>(() => _i957.SyncService()); + gh.singleton<_i196.FeatureRemoteConfig>(() => _i196.FeatureRemoteConfig()); gh.lazySingleton<_i877.SyncDependencyManagerBase>( () => syncModule.provideSyncDependencyManager()); gh.lazySingleton<_i627.ThemeCubit>(() => _i627.ThemeCubit()); @@ -457,12 +457,12 @@ _i174.GetIt $initGetIt( () => _i738.StatsRemoteDataSourceImpl(dio: gh<_i361.Dio>())); gh.factory<_i961.GetCategoriesUseCase>( () => _i961.GetCategoriesUseCase(gh<_i410.CategoryRepository>())); - gh.factory<_i292.DeleteCategoryUseCase>( - () => _i292.DeleteCategoryUseCase(gh<_i410.CategoryRepository>())); gh.factory<_i986.UpdateCategoryUseCase>( () => _i986.UpdateCategoryUseCase(gh<_i410.CategoryRepository>())); gh.factory<_i445.AddCategoryUseCase>( () => _i445.AddCategoryUseCase(gh<_i410.CategoryRepository>())); + gh.factory<_i292.DeleteCategoryUseCase>( + () => _i292.DeleteCategoryUseCase(gh<_i410.CategoryRepository>())); gh.singleton<_i11.CloudBenefitRepository>(() => _i415.CloudBenefitRepositoryImpl( gh<_i61.CloudBenefitRemoteDataSource>())); @@ -545,20 +545,20 @@ _i174.GetIt $initGetIt( gh<_i961.GetCategoriesUseCase>(), gh<_i500.ListenToCategoriesUseCase>(), )); - gh.factory<_i444.StreamAuthStatus>( - () => _i444.StreamAuthStatus(gh<_i800.AuthRepository>())); - gh.factory<_i880.GetLoggedInUser>( - () => _i880.GetLoggedInUser(gh<_i800.AuthRepository>())); gh.factory<_i723.LoginWithPhonePassword>( () => _i723.LoginWithPhonePassword(gh<_i800.AuthRepository>())); - gh.factory<_i768.LoginWithEmailPassword>( - () => _i768.LoginWithEmailPassword(gh<_i800.AuthRepository>())); - gh.factory<_i42.LoginByEmailUsecase>( - () => _i42.LoginByEmailUsecase(gh<_i800.AuthRepository>())); gh.factory<_i2.OnboardingCompleted>( () => _i2.OnboardingCompleted(gh<_i800.AuthRepository>())); gh.factory<_i498.LoginByPhoneUsecase>( () => _i498.LoginByPhoneUsecase(gh<_i800.AuthRepository>())); + gh.factory<_i880.GetLoggedInUser>( + () => _i880.GetLoggedInUser(gh<_i800.AuthRepository>())); + gh.factory<_i768.LoginWithEmailPassword>( + () => _i768.LoginWithEmailPassword(gh<_i800.AuthRepository>())); + gh.factory<_i42.LoginByEmailUsecase>( + () => _i42.LoginByEmailUsecase(gh<_i800.AuthRepository>())); + gh.factory<_i444.StreamAuthStatus>( + () => _i444.StreamAuthStatus(gh<_i800.AuthRepository>())); gh.factory<_i828.IsOnboardingCompleted>( () => _i828.IsOnboardingCompleted(gh<_i800.AuthRepository>())); gh.lazySingleton<_i161.BudgetPeriodStateSyncHandler>( @@ -566,29 +566,25 @@ _i174.GetIt $initGetIt( gh<_i704.AppDatabase>(), gh<_i760.BudgetRemoteDataSource>(), )); - gh.lazySingleton<_i918.BudgetSyncHandler>(() => _i918.BudgetSyncHandler( - gh<_i704.AppDatabase>(), - gh<_i760.BudgetRemoteDataSource>(), - )); gh.factory<_i26.GetFinancialPositionUseCase>(() => _i26.GetFinancialPositionUseCase( gh<_i581.FinancialPositionRepository>())); + gh.factory<_i929.GetImportSessionsUseCase>( + () => _i929.GetImportSessionsUseCase(gh<_i32.ImportRepository>())); + gh.factory<_i36.ConfirmSessionUseCase>( + () => _i36.ConfirmSessionUseCase(gh<_i32.ImportRepository>())); gh.factory<_i661.GetImportSessionUseCase>( () => _i661.GetImportSessionUseCase(gh<_i32.ImportRepository>())); gh.factory<_i60.AnalyzeDocumentUseCase>( () => _i60.AnalyzeDocumentUseCase(gh<_i32.ImportRepository>())); - gh.factory<_i36.ConfirmSessionUseCase>( - () => _i36.ConfirmSessionUseCase(gh<_i32.ImportRepository>())); - gh.factory<_i929.GetImportSessionsUseCase>( - () => _i929.GetImportSessionsUseCase(gh<_i32.ImportRepository>())); gh.lazySingleton<_i3.ExportRepository>( () => _i739.ExportRepositoryImpl(gh<_i875.ExportRemoteDataSource>())); - gh.factory<_i911.UpdatePartyUseCase>( - () => _i911.UpdatePartyUseCase(gh<_i661.PartyRepository>())); - gh.factory<_i84.AddPartyUseCase>( - () => _i84.AddPartyUseCase(gh<_i661.PartyRepository>())); gh.factory<_i56.DeletePartyUseCase>( () => _i56.DeletePartyUseCase(gh<_i661.PartyRepository>())); + gh.factory<_i84.AddPartyUseCase>( + () => _i84.AddPartyUseCase(gh<_i661.PartyRepository>())); + gh.factory<_i911.UpdatePartyUseCase>( + () => _i911.UpdatePartyUseCase(gh<_i661.PartyRepository>())); gh.factory<_i12.GetPartiesUseCase>( () => _i12.GetPartiesUseCase(gh<_i661.PartyRepository>())); gh.factory<_i714.ListenToPartiesUseCase>( @@ -598,24 +594,24 @@ _i174.GetIt $initGetIt( gh<_i1057.HoldingLocalDataSource>(), gh<_i683.PreferenceManager>(), )); - gh.factory<_i995.CreateSessionUseCase>( - () => _i995.CreateSessionUseCase(gh<_i542.AiRepository>())); - gh.factory<_i391.RejectActionUseCase>( - () => _i391.RejectActionUseCase(gh<_i542.AiRepository>())); - gh.factory<_i752.GetSessionUseCase>( - () => _i752.GetSessionUseCase(gh<_i542.AiRepository>())); - gh.factory<_i605.CheckHealthUseCase>( - () => _i605.CheckHealthUseCase(gh<_i542.AiRepository>())); - gh.factory<_i505.ListSessionsUseCase>( - () => _i505.ListSessionsUseCase(gh<_i542.AiRepository>())); gh.factory<_i82.ConfirmActionUseCase>( () => _i82.ConfirmActionUseCase(gh<_i542.AiRepository>())); gh.factory<_i633.UploadFilesUseCase>( () => _i633.UploadFilesUseCase(gh<_i542.AiRepository>())); + gh.factory<_i995.CreateSessionUseCase>( + () => _i995.CreateSessionUseCase(gh<_i542.AiRepository>())); gh.factory<_i236.DeleteSessionUseCase>( () => _i236.DeleteSessionUseCase(gh<_i542.AiRepository>())); + gh.factory<_i752.GetSessionUseCase>( + () => _i752.GetSessionUseCase(gh<_i542.AiRepository>())); gh.factory<_i308.SendMessageUseCase>( () => _i308.SendMessageUseCase(gh<_i542.AiRepository>())); + gh.factory<_i605.CheckHealthUseCase>( + () => _i605.CheckHealthUseCase(gh<_i542.AiRepository>())); + gh.factory<_i391.RejectActionUseCase>( + () => _i391.RejectActionUseCase(gh<_i542.AiRepository>())); + gh.factory<_i505.ListSessionsUseCase>( + () => _i505.ListSessionsUseCase(gh<_i542.AiRepository>())); gh.factory<_i559.AppUpdateCubit>( () => _i559.AppUpdateCubit(gh<_i150.CheckAppUpdateUseCase>())); gh.factory<_i538.ImportCubit>(() => _i538.ImportCubit( @@ -633,36 +629,36 @@ _i174.GetIt $initGetIt( )); gh.factory<_i88.BenefitsCubit>( () => _i88.BenefitsCubit(gh<_i61.FetchBenefits>())); - gh.factory<_i684.DeleteAccountUseCase>( - () => _i684.DeleteAccountUseCase(gh<_i800.AuthRepository>())); gh.factory<_i640.LogoutUsecase>( () => _i640.LogoutUsecase(gh<_i800.AuthRepository>())); + gh.factory<_i684.DeleteAccountUseCase>( + () => _i684.DeleteAccountUseCase(gh<_i800.AuthRepository>())); gh.lazySingleton<_i368.WalletRepository>(() => _i305.WalletRepositoryImpl( syncHandler: gh<_i849.WalletSyncHandler>(), localDataSource: gh<_i849.WalletLocalDataSource>(), db: gh<_i704.AppDatabase>(), requestAuthorizationService: gh<_i877.RequestAuthorizationService>(), )); + gh.factory<_i679.UpdateHoldingUseCase>( + () => _i679.UpdateHoldingUseCase(gh<_i645.HoldingRepository>())); + gh.factory<_i301.DeleteHoldingUseCase>( + () => _i301.DeleteHoldingUseCase(gh<_i645.HoldingRepository>())); gh.factory<_i583.RepriceHoldingsUseCase>( () => _i583.RepriceHoldingsUseCase(gh<_i645.HoldingRepository>())); - gh.factory<_i83.GetHoldingsUseCase>( - () => _i83.GetHoldingsUseCase(gh<_i645.HoldingRepository>())); - gh.factory<_i573.WatchHoldingsUseCase>( - () => _i573.WatchHoldingsUseCase(gh<_i645.HoldingRepository>())); gh.factory<_i15.SearchCoinsUseCase>( () => _i15.SearchCoinsUseCase(gh<_i645.HoldingRepository>())); - gh.factory<_i679.UpdateHoldingUseCase>( - () => _i679.UpdateHoldingUseCase(gh<_i645.HoldingRepository>())); gh.factory<_i684.CreateHoldingUseCase>( () => _i684.CreateHoldingUseCase(gh<_i645.HoldingRepository>())); - gh.factory<_i301.DeleteHoldingUseCase>( - () => _i301.DeleteHoldingUseCase(gh<_i645.HoldingRepository>())); + gh.factory<_i83.GetHoldingsUseCase>( + () => _i83.GetHoldingsUseCase(gh<_i645.HoldingRepository>())); + gh.factory<_i573.WatchHoldingsUseCase>( + () => _i573.WatchHoldingsUseCase(gh<_i645.HoldingRepository>())); gh.factory<_i261.FinancialPositionCubit>(() => _i261.FinancialPositionCubit( getFinancialPositionUseCase: gh<_i26.GetFinancialPositionUseCase>())); - gh.factory<_i837.MarkNotificationAsReadUseCase>(() => - _i837.MarkNotificationAsReadUseCase(gh<_i965.NotificationRepository>())); gh.factory<_i422.GetNotificationsUseCase>( () => _i422.GetNotificationsUseCase(gh<_i965.NotificationRepository>())); + gh.factory<_i837.MarkNotificationAsReadUseCase>(() => + _i837.MarkNotificationAsReadUseCase(gh<_i965.NotificationRepository>())); gh.lazySingleton<_i957.GroupRepository>(() => _i875.GroupRepositoryImpl( syncHandler: gh<_i235.GroupSyncHandler>(), localDataSource: gh<_i873.GroupLocalDataSource>(), @@ -678,12 +674,12 @@ _i174.GetIt $initGetIt( repriceHoldingsUseCase: gh<_i583.RepriceHoldingsUseCase>(), searchCoinsUseCase: gh<_i15.SearchCoinsUseCase>(), )); - gh.factory<_i62.DeleteWalletUseCase>( - () => _i62.DeleteWalletUseCase(gh<_i368.WalletRepository>())); - gh.factory<_i418.UpdateWalletUseCase>( - () => _i418.UpdateWalletUseCase(gh<_i368.WalletRepository>())); gh.factory<_i80.AddWalletUseCase>( () => _i80.AddWalletUseCase(gh<_i368.WalletRepository>())); + gh.factory<_i418.UpdateWalletUseCase>( + () => _i418.UpdateWalletUseCase(gh<_i368.WalletRepository>())); + gh.factory<_i62.DeleteWalletUseCase>( + () => _i62.DeleteWalletUseCase(gh<_i368.WalletRepository>())); gh.factory<_i713.GetWalletsUseCase>( () => _i713.GetWalletsUseCase(gh<_i368.WalletRepository>())); gh.factory<_i314.FetchSubscriptionPlans>( @@ -702,16 +698,16 @@ _i174.GetIt $initGetIt( db: gh<_i704.AppDatabase>(), requestAuthorizationService: gh<_i877.RequestAuthorizationService>(), )); - gh.factory<_i436.UpdateConfigUseCase>( - () => _i436.UpdateConfigUseCase(gh<_i899.ConfigRepository>())); + gh.factory<_i132.GetConfigsUseCase>( + () => _i132.GetConfigsUseCase(gh<_i899.ConfigRepository>())); gh.factory<_i608.ListenToConfigsUseCase>( () => _i608.ListenToConfigsUseCase(gh<_i899.ConfigRepository>())); - gh.factory<_i833.SaveConfigUseCase>( - () => _i833.SaveConfigUseCase(gh<_i899.ConfigRepository>())); gh.factory<_i933.GetConfigUseCase>( () => _i933.GetConfigUseCase(gh<_i899.ConfigRepository>())); - gh.factory<_i132.GetConfigsUseCase>( - () => _i132.GetConfigsUseCase(gh<_i899.ConfigRepository>())); + gh.factory<_i833.SaveConfigUseCase>( + () => _i833.SaveConfigUseCase(gh<_i899.ConfigRepository>())); + gh.factory<_i436.UpdateConfigUseCase>( + () => _i436.UpdateConfigUseCase(gh<_i899.ConfigRepository>())); gh.factory<_i536.DeleteConfigUseCase>( () => _i536.DeleteConfigUseCase(gh<_i899.ConfigRepository>())); gh.singleton<_i1057.ExchangeRateRepository>( @@ -720,10 +716,10 @@ _i174.GetIt $initGetIt( localDataSource: gh<_i900.ExchangeRateLocalDataSource>(), configRepository: gh<_i899.ConfigRepository>(), )); - gh.factory<_i82.ListenToWalletsUseCase>( - () => _i82.ListenToWalletsUseCase(gh<_i368.WalletRepository>())); gh.factory<_i225.EnsureDefaultWalletExistsUseCase>(() => _i225.EnsureDefaultWalletExistsUseCase(gh<_i368.WalletRepository>())); + gh.factory<_i82.ListenToWalletsUseCase>( + () => _i82.ListenToWalletsUseCase(gh<_i368.WalletRepository>())); gh.lazySingleton<_i415.AiChatCubit>(() => _i415.AiChatCubit( listSessionsUseCase: gh<_i505.ListSessionsUseCase>(), getSessionUseCase: gh<_i752.GetSessionUseCase>(), @@ -734,20 +730,20 @@ _i174.GetIt $initGetIt( rejectActionUseCase: gh<_i391.RejectActionUseCase>(), uploadFilesUseCase: gh<_i633.UploadFilesUseCase>(), )); + gh.factory<_i524.LoginWithEmailUseCase>( + () => _i524.LoginWithEmailUseCase(gh<_i800.AuthRepository>())); gh.factory<_i705.RegisterUseCase>( () => _i705.RegisterUseCase(gh<_i800.AuthRepository>())); - gh.factory<_i400.LoginWithPhoneUseCase>( - () => _i400.LoginWithPhoneUseCase(gh<_i800.AuthRepository>())); gh.factory<_i402.GetOtpCodeUseCase>( () => _i402.GetOtpCodeUseCase(gh<_i800.AuthRepository>())); + gh.factory<_i400.LoginWithPhoneUseCase>( + () => _i400.LoginWithPhoneUseCase(gh<_i800.AuthRepository>())); + gh.factory<_i542.PasswordResetCodeUseCase>( + () => _i542.PasswordResetCodeUseCase(gh<_i800.AuthRepository>())); gh.factory<_i494.PasswordResetUseCase>( () => _i494.PasswordResetUseCase(gh<_i800.AuthRepository>())); gh.factory<_i100.VerifyEmailUseCase>( () => _i100.VerifyEmailUseCase(gh<_i800.AuthRepository>())); - gh.factory<_i542.PasswordResetCodeUseCase>( - () => _i542.PasswordResetCodeUseCase(gh<_i800.AuthRepository>())); - gh.factory<_i524.LoginWithEmailUseCase>( - () => _i524.LoginWithEmailUseCase(gh<_i800.AuthRepository>())); gh.factory<_i841.PartyCubit>(() => _i841.PartyCubit( getPartiesUseCase: gh<_i12.GetPartiesUseCase>(), addPartyUseCase: gh<_i84.AddPartyUseCase>(), @@ -801,12 +797,12 @@ _i174.GetIt $initGetIt( )); gh.factory<_i397.ListenExchangeRate>( () => _i397.ListenExchangeRate(gh<_i1057.ExchangeRateRepository>())); - gh.factory<_i146.ListenToGroupsUseCase>( - () => _i146.ListenToGroupsUseCase(gh<_i957.GroupRepository>())); - gh.factory<_i982.GetGroupsUseCase>( - () => _i982.GetGroupsUseCase(gh<_i957.GroupRepository>())); gh.factory<_i759.DeleteGroupUseCase>( () => _i759.DeleteGroupUseCase(gh<_i957.GroupRepository>())); + gh.factory<_i982.GetGroupsUseCase>( + () => _i982.GetGroupsUseCase(gh<_i957.GroupRepository>())); + gh.factory<_i146.ListenToGroupsUseCase>( + () => _i146.ListenToGroupsUseCase(gh<_i957.GroupRepository>())); gh.factory<_i353.AddGroupUseCase>( () => _i353.AddGroupUseCase(gh<_i957.GroupRepository>())); gh.factory<_i820.UpdateGroupUseCase>( @@ -816,20 +812,20 @@ _i174.GetIt $initGetIt( gh<_i704.AppDatabase>(), gh<_i1057.ExchangeRateRepository>(), )); - gh.factory<_i781.UpdateReminderUseCase>( - () => _i781.UpdateReminderUseCase(gh<_i868.ReminderRepository>())); - gh.factory<_i656.GetRemindersUseCase>( - () => _i656.GetRemindersUseCase(gh<_i868.ReminderRepository>())); + gh.factory<_i694.ListenToRemindersUseCase>( + () => _i694.ListenToRemindersUseCase(gh<_i868.ReminderRepository>())); gh.factory<_i405.DeleteReminderUseCase>( () => _i405.DeleteReminderUseCase(gh<_i868.ReminderRepository>())); gh.factory<_i913.SnoozeReminderUseCase>( () => _i913.SnoozeReminderUseCase(gh<_i868.ReminderRepository>())); - gh.factory<_i694.ListenToRemindersUseCase>( - () => _i694.ListenToRemindersUseCase(gh<_i868.ReminderRepository>())); gh.factory<_i268.PauseReminderUseCase>( () => _i268.PauseReminderUseCase(gh<_i868.ReminderRepository>())); + gh.factory<_i656.GetRemindersUseCase>( + () => _i656.GetRemindersUseCase(gh<_i868.ReminderRepository>())); gh.factory<_i485.CreateReminderUseCase>( () => _i485.CreateReminderUseCase(gh<_i868.ReminderRepository>())); + gh.factory<_i781.UpdateReminderUseCase>( + () => _i781.UpdateReminderUseCase(gh<_i868.ReminderRepository>())); gh.factory<_i305.ResumeReminderUseCase>( () => _i305.ResumeReminderUseCase(gh<_i868.ReminderRepository>())); gh.factory<_i408.ConfigCubit>(() => _i408.ConfigCubit( @@ -882,14 +878,14 @@ _i174.GetIt $initGetIt( gh<_i704.AppDatabase>(), gh<_i176.BudgetProgressRecomputer>(), )); - gh.factory<_i706.DeleteMediaUseCase>( - () => _i706.DeleteMediaUseCase(gh<_i442.MediaRepository>())); - gh.factory<_i843.AddMediaToTransactionUseCase>( - () => _i843.AddMediaToTransactionUseCase(gh<_i442.MediaRepository>())); gh.factory<_i150.GetFileContentUseCase>( () => _i150.GetFileContentUseCase(gh<_i442.MediaRepository>())); + gh.factory<_i706.DeleteMediaUseCase>( + () => _i706.DeleteMediaUseCase(gh<_i442.MediaRepository>())); gh.factory<_i1026.GetMediaForTransactionUseCase>( () => _i1026.GetMediaForTransactionUseCase(gh<_i442.MediaRepository>())); + gh.factory<_i843.AddMediaToTransactionUseCase>( + () => _i843.AddMediaToTransactionUseCase(gh<_i442.MediaRepository>())); gh.factory<_i484.CurrencyCubit>(() => _i484.CurrencyCubit( gh<_i933.GetConfigUseCase>(), gh<_i833.SaveConfigUseCase>(), @@ -898,13 +894,6 @@ _i174.GetIt $initGetIt( )); gh.factory<_i311.ExchangeRateCubit>( () => _i311.ExchangeRateCubit(gh<_i397.ListenExchangeRate>())); - gh.lazySingleton<_i340.BudgetRepository>(() => _i81.BudgetRepositoryImpl( - syncHandler: gh<_i918.BudgetSyncHandler>(), - localDataSource: gh<_i293.BudgetLocalDataSource>(), - remoteDataSource: gh<_i760.BudgetRemoteDataSource>(), - db: gh<_i704.AppDatabase>(), - requestAuthorizationService: gh<_i877.RequestAuthorizationService>(), - )); gh.lazySingleton<_i118.TransactionRepository>(() => _i114.TransactionRepositoryImpl( syncHandler: gh<_i893.TransactionSyncHandler>(), @@ -913,33 +902,18 @@ _i174.GetIt $initGetIt( db: gh<_i704.AppDatabase>(), requestAuthorizationService: gh<_i877.RequestAuthorizationService>(), )); - gh.factory<_i947.GetAllTransactionsUseCase>( - () => _i947.GetAllTransactionsUseCase(gh<_i118.TransactionRepository>())); + gh.factory<_i163.DeleteTransactionUseCase>( + () => _i163.DeleteTransactionUseCase(gh<_i118.TransactionRepository>())); gh.factory<_i973.ListenToTransactionsUseCase>(() => _i973.ListenToTransactionsUseCase(gh<_i118.TransactionRepository>())); + gh.factory<_i322.UnmarkTransactionRefundUseCase>(() => + _i322.UnmarkTransactionRefundUseCase(gh<_i118.TransactionRepository>())); gh.factory<_i241.UpdateTransactionUseCase>( () => _i241.UpdateTransactionUseCase(gh<_i118.TransactionRepository>())); gh.factory<_i468.MarkTransactionRefundUseCase>(() => _i468.MarkTransactionRefundUseCase(gh<_i118.TransactionRepository>())); - gh.factory<_i163.DeleteTransactionUseCase>( - () => _i163.DeleteTransactionUseCase(gh<_i118.TransactionRepository>())); - gh.factory<_i322.UnmarkTransactionRefundUseCase>(() => - _i322.UnmarkTransactionRefundUseCase(gh<_i118.TransactionRepository>())); - gh.lazySingleton>>( - () => syncModule.provideSyncTypeHandlers( - gh<_i463.CategorySyncHandler>(), - gh<_i480.ConfigSyncHandler>(), - gh<_i849.WalletSyncHandler>(), - gh<_i280.PartySyncHandler>(), - gh<_i235.GroupSyncHandler>(), - gh<_i217.NotificationSyncHandler>(), - gh<_i893.TransactionSyncHandler>(), - gh<_i225.TransferSyncHandler>(), - gh<_i382.MediaSyncHandler>(), - gh<_i918.BudgetSyncHandler>(), - gh<_i161.BudgetPeriodStateSyncHandler>(), - gh<_i139.ReminderSyncHandler>(), - )); + gh.factory<_i947.GetAllTransactionsUseCase>( + () => _i947.GetAllTransactionsUseCase(gh<_i118.TransactionRepository>())); gh.factory<_i669.CreateTransactionUseCase>( () => _i669.CreateTransactionUseCase( gh<_i118.TransactionRepository>(), @@ -969,24 +943,63 @@ _i174.GetIt $initGetIt( listenToTransactionsUseCase: gh<_i1022.ListenToTransactionsUseCase>(), getWalletsUseCase: gh<_i713.GetWalletsUseCase>(), )); - gh.factory<_i617.UpdateBudgetUseCase>( - () => _i617.UpdateBudgetUseCase(gh<_i340.BudgetRepository>())); - gh.factory<_i363.InsertBudgetUseCase>( - () => _i363.InsertBudgetUseCase(gh<_i340.BudgetRepository>())); - gh.factory<_i884.GetAllBudgetsUseCase>( - () => _i884.GetAllBudgetsUseCase(gh<_i340.BudgetRepository>())); - gh.factory<_i893.CloseBudgetPeriodUseCase>( - () => _i893.CloseBudgetPeriodUseCase(gh<_i340.BudgetRepository>())); - gh.factory<_i377.ListenToBudgetsUseCase>( - () => _i377.ListenToBudgetsUseCase(gh<_i340.BudgetRepository>())); + gh.lazySingleton<_i918.BudgetSyncHandler>(() => _i918.BudgetSyncHandler( + gh<_i704.AppDatabase>(), + gh<_i760.BudgetRemoteDataSource>(), + gh<_i293.BudgetLocalDataSource>(), + )); + gh.factory<_i1004.CreateTransferWithTransactionsUseCase>(() => + _i1004.CreateTransferWithTransactionsUseCase( + gh<_i55.TransferRepository>())); + gh.factory<_i453.AddTransferUseCase>( + () => _i453.AddTransferUseCase(gh<_i55.TransferRepository>())); + gh.factory<_i744.ListenToTransfersUseCase>( + () => _i744.ListenToTransfersUseCase(gh<_i55.TransferRepository>())); + gh.lazySingleton<_i340.BudgetRepository>(() => _i81.BudgetRepositoryImpl( + syncHandler: gh<_i918.BudgetSyncHandler>(), + localDataSource: gh<_i293.BudgetLocalDataSource>(), + remoteDataSource: gh<_i760.BudgetRemoteDataSource>(), + db: gh<_i704.AppDatabase>(), + requestAuthorizationService: gh<_i877.RequestAuthorizationService>(), + )); + gh.factory<_i611.TransferCubit>(() => _i611.TransferCubit( + createTransferWithTransactionsUseCase: + gh<_i1004.CreateTransferWithTransactionsUseCase>(), + listenToTransfersUseCase: gh<_i744.ListenToTransfersUseCase>(), + )); + gh.lazySingleton>>( + () => syncModule.provideSyncTypeHandlers( + gh<_i463.CategorySyncHandler>(), + gh<_i480.ConfigSyncHandler>(), + gh<_i849.WalletSyncHandler>(), + gh<_i280.PartySyncHandler>(), + gh<_i235.GroupSyncHandler>(), + gh<_i217.NotificationSyncHandler>(), + gh<_i893.TransactionSyncHandler>(), + gh<_i225.TransferSyncHandler>(), + gh<_i382.MediaSyncHandler>(), + gh<_i918.BudgetSyncHandler>(), + gh<_i161.BudgetPeriodStateSyncHandler>(), + gh<_i139.ReminderSyncHandler>(), + )); gh.factory<_i920.ListenToPeriodStatesUseCase>( () => _i920.ListenToPeriodStatesUseCase(gh<_i340.BudgetRepository>())); gh.factory<_i102.GetBudgetUseCase>( () => _i102.GetBudgetUseCase(gh<_i340.BudgetRepository>())); - gh.factory<_i748.DeleteBudgetUseCase>( - () => _i748.DeleteBudgetUseCase(gh<_i340.BudgetRepository>())); + gh.factory<_i377.ListenToBudgetsUseCase>( + () => _i377.ListenToBudgetsUseCase(gh<_i340.BudgetRepository>())); + gh.factory<_i893.CloseBudgetPeriodUseCase>( + () => _i893.CloseBudgetPeriodUseCase(gh<_i340.BudgetRepository>())); gh.factory<_i442.ListenToTargetsUseCase>( () => _i442.ListenToTargetsUseCase(gh<_i340.BudgetRepository>())); + gh.factory<_i617.UpdateBudgetUseCase>( + () => _i617.UpdateBudgetUseCase(gh<_i340.BudgetRepository>())); + gh.factory<_i363.InsertBudgetUseCase>( + () => _i363.InsertBudgetUseCase(gh<_i340.BudgetRepository>())); + gh.factory<_i748.DeleteBudgetUseCase>( + () => _i748.DeleteBudgetUseCase(gh<_i340.BudgetRepository>())); + gh.factory<_i884.GetAllBudgetsUseCase>( + () => _i884.GetAllBudgetsUseCase(gh<_i340.BudgetRepository>())); gh.lazySingleton<_i646.SynchAppDatabase>(() => _i646.SynchAppDatabase( appDatabase: gh<_i704.AppDatabase>(), typeHandlers: @@ -996,18 +1009,6 @@ _i174.GetIt $initGetIt( logger: gh<_i877.SyncLogger>(), crashReporter: gh<_i877.SyncCrashReporter>(), )); - gh.factory<_i453.AddTransferUseCase>( - () => _i453.AddTransferUseCase(gh<_i55.TransferRepository>())); - gh.factory<_i744.ListenToTransfersUseCase>( - () => _i744.ListenToTransfersUseCase(gh<_i55.TransferRepository>())); - gh.factory<_i1004.CreateTransferWithTransactionsUseCase>(() => - _i1004.CreateTransferWithTransactionsUseCase( - gh<_i55.TransferRepository>())); - gh.factory<_i611.TransferCubit>(() => _i611.TransferCubit( - createTransferWithTransactionsUseCase: - gh<_i1004.CreateTransferWithTransactionsUseCase>(), - listenToTransfersUseCase: gh<_i744.ListenToTransfersUseCase>(), - )); gh.factory<_i1064.BudgetCubit>(() => _i1064.BudgetCubit( getAllBudgetsUseCase: gh<_i884.GetAllBudgetsUseCase>(), insertBudgetUseCase: gh<_i363.InsertBudgetUseCase>(), diff --git a/test/unit/budget_target_sync_test.dart b/test/unit/budget_target_sync_test.dart new file mode 100644 index 00000000..87c9b80d --- /dev/null +++ b/test/unit/budget_target_sync_test.dart @@ -0,0 +1,203 @@ +import 'package:drift/drift.dart' hide isNull, isNotNull; +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/budget/budget_local_datasource.dart'; +import 'package:trakli/data/datasources/budget/budget_remote_datasource.dart'; +import 'package:trakli/data/datasources/budget/dtos/budget_complete_dto.dart'; +import 'package:trakli/data/datasources/budget/dtos/budget_target_dto.dart'; +import 'package:trakli/data/services/budget/budget_progress_recomputer.dart'; +import 'package:trakli/data/sync/budget_sync_handler.dart'; +import 'package:trakli/domain/repositories/exchange_rate_repository.dart'; +import 'package:trakli/presentation/utils/enums.dart'; + +class _MockBudgetRemote extends Mock implements BudgetRemoteDataSource {} + +class _MockExchangeRateRepository extends Mock + implements ExchangeRateRepository {} + +Budget _budget({int? id}) => Budget( + id: id, + clientId: 'b-1', + name: 'Grow budget', + slug: 'grow-budget', + ownerType: 'user', + amount: 20.0, + currency: 'USD', + periodType: BudgetPeriodType.monthly, + startDate: DateTime.utc(2026, 8, 1), + rolloverEnabled: true, + thresholdPercent: 85, + forecastAlertsEnabled: false, + isActive: true, + createdAt: DateTime.utc(2026, 8, 1, 10), + updatedAt: DateTime.utc(2026, 8, 1, 10), + ); + +void main() { + driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; + + group('BudgetCompleteDto.toServerJson targets', () { + test('an unresolved target goes out as client_generated_id', () { + // The server validates targets.*.client_generated_id; sending the same + // value under 'client_id' failed required_without with a 422. + final json = BudgetCompleteDto( + budget: _budget(), + targets: const [ + BudgetTargetDto( + type: BudgetTargetType.category, + clientId: 'device:cat-1', + ), + ], + ).toServerJson(); + + final target = (json['targets'] as List).single as Map; + expect(target['client_generated_id'], 'device:cat-1'); + expect(target.containsKey('client_id'), isFalse); + expect(target['type'], 'category'); + }); + + test('a resolved target carries its server id', () { + final json = BudgetCompleteDto( + budget: _budget(), + targets: const [ + BudgetTargetDto( + type: BudgetTargetType.category, + id: 501, + clientId: 'device:cat-1', + ), + ], + ).toServerJson(); + + final target = (json['targets'] as List).single as Map; + expect(target['id'], 501); + expect(target['client_generated_id'], 'device:cat-1'); + }); + }); + + group('BudgetSyncHandler target resolution', () { + late AppDatabase db; + late _MockBudgetRemote remote; + late BudgetSyncHandler handler; + + setUpAll(() { + registerFallbackValue(BudgetCompleteDto(budget: _budget())); + }); + + setUp(() async { + db = AppDatabase(NativeDatabase.memory()); + remote = _MockBudgetRemote(); + handler = BudgetSyncHandler( + db, + remote, + BudgetLocalDataSourceImpl( + db, + BudgetProgressRecomputer(db, _MockExchangeRateRepository()), + ), + ); + + await db.budgets.insertOne(BudgetsCompanion.insert( + name: 'Grow budget', + slug: 'grow-budget', + amount: 20.0, + currency: 'USD', + periodType: BudgetPeriodType.monthly, + startDate: DateTime.utc(2026, 8, 1), + clientId: const Value('b-1'), + )); + // Both target categories were created offline, so neither has a + // server id yet — the exact state that produced the 422. + await db.categories.insertOne(CategoriesCompanion.insert( + name: 'offline Income cat', + slug: 'offline-income-cat', + type: TransactionType.income, + clientId: const Value('device:cat-1'), + )); + await db.categories.insertOne(CategoriesCompanion.insert( + name: 'offline expence cat', + slug: 'offline-expence-cat', + type: TransactionType.expense, + clientId: const Value('device:cat-2'), + )); + for (final categoryClientId in ['device:cat-1', 'device:cat-2']) { + await db.budgetTargets.insertOne(BudgetTargetsCompanion.insert( + budgetClientId: 'b-1', + targetType: BudgetTargetType.category, + targetClientId: categoryClientId, + )); + } + }); + + tearDown(() async { + await db.close(); + }); + + Future assignServerId(String clientId, int id) async { + await (db.update(db.categories) + ..where((c) => c.clientId.equals(clientId))) + .write(CategoriesCompanion(id: Value(id))); + } + + test('push is deferred while any target lacks a server id', () async { + final entity = await handler.getLocalByClientId('b-1'); + + expect(await handler.shouldPersistRemote(entity), isFalse); + + await assignServerId('device:cat-1', 501); + expect(await handler.shouldPersistRemote(entity), isFalse); + + await assignServerId('device:cat-2', 502); + expect(await handler.shouldPersistRemote(entity), isTrue); + }); + + test('a stale queued payload is re-resolved before it is pushed', + () async { + await assignServerId('device:cat-1', 501); + await assignServerId('device:cat-2', 502); + + // The queued snapshot was marshaled before the categories synced, so + // its targets still carry null ids. + final queued = BudgetCompleteDto( + budget: _budget(), + targets: const [ + BudgetTargetDto( + type: BudgetTargetType.category, + clientId: 'device:cat-1', + ), + BudgetTargetDto( + type: BudgetTargetType.category, + clientId: 'device:cat-2', + ), + ], + ); + when(() => remote.insertBudget(any())) + .thenAnswer((inv) async => inv.positionalArguments.first + as BudgetCompleteDto); + + await handler.restPutRemote(queued); + + final sent = verify(() => remote.insertBudget(captureAny())) + .captured + .single as BudgetCompleteDto; + expect(sent.targets.map((t) => t.id), [501, 502]); + + final targets = (sent.toServerJson()['targets'] as List) + .cast>(); + expect(targets.map((t) => t['id']), [501, 502]); + }); + + test('local reads carry the budget targets', () async { + await assignServerId('device:cat-1', 501); + await assignServerId('device:cat-2', 502); + + final byClientId = await handler.getLocalByClientId('b-1'); + + expect(byClientId.targets, hasLength(2)); + expect( + byClientId.targets.map((t) => t.clientId), + containsAll(['device:cat-1', 'device:cat-2']), + ); + }); + }); +} From 1cbd6783b2fd8ffc79627df717bca5326a98f2cb Mon Sep 17 00:00:00 2001 From: Fuh Austin Date: Fri, 7 Aug 2026 11:11:57 +0100 Subject: [PATCH 2/2] fix(export): Surface server refusals and offer saving the file A PDF export over the server's 100-row cap came back as a helpful 422 telling the user to narrow the range or pick another format, but the app showed a generic unknown error: the datasource skipped the shared error handler, and the JSON error body arrived as bytes so the message extraction never matched. Downloads now route through the handler and byte bodies are decoded before reading the message. The finished export also only opened the share sheet, so dismissing it lost the file with no copy anywhere the user could find. A ready export now offers Save alongside Share; saving goes through the system file picker, which needs no storage permission and confirms with the file name. --- assets/translations/de.json | 4 +- assets/translations/en.json | 2 + assets/translations/es.json | 4 +- assets/translations/fr.json | 4 +- assets/translations/it.json | 4 +- assets/translations/ru.json | 2 + lib/core/error/error_handler.dart | 20 ++- .../export/export_remote_datasource.dart | 23 +-- lib/gen/translations/codegen_loader.g.dart | 136 +++++++++--------- lib/presentation/history_screen.dart | 75 +++++++++- test/unit/export_error_message_test.dart | 102 +++++++++++++ 11 files changed, 284 insertions(+), 92 deletions(-) create mode 100644 test/unit/export_error_message_test.dart diff --git a/assets/translations/de.json b/assets/translations/de.json index e0b73f70..79364d6f 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -169,6 +169,8 @@ "export": "Exportieren", "toPdf": "Als PDF", "toExcel": "Als Excel", + "exportSaved": "{name} gespeichert", + "exportSaveFailed": "Die Datei konnte nicht gespeichert werden.", "exportRequiresAccount": "Melden Sie sich an, um zu exportieren. Exporte werden auf dem Server erstellt.", "exportAwaitingSync": "Synchronisieren Sie zuerst, damit der Export Ihre Anderungen enthalt.", "exportPreparing": "Export wird vorbereitet...", @@ -297,7 +299,7 @@ "updateWallet": "Update wallet", "createWallet": "Create wallet", "phoneIsRequired": "Phone is required", - "save": "Save", + "save": "Speichern", "selectCategory": "Select category", "categoryIsRequired": "Category is required", "pleaseSelectWallet": "Please select a wallet", diff --git a/assets/translations/en.json b/assets/translations/en.json index 870a7898..a8b5dfda 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -171,6 +171,8 @@ "export": "Export", "toPdf": "To PDF", "toExcel": "To Excel", + "exportSaved": "Saved {name}", + "exportSaveFailed": "Could not save the file.", "exportRequiresAccount": "Sign in to export. Exports are generated on the server.", "exportAwaitingSync": "Sync your changes first so the export includes them.", "exportPreparing": "Preparing your export...", diff --git a/assets/translations/es.json b/assets/translations/es.json index aa22e2c3..0ac67126 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -171,6 +171,8 @@ "export": "Exportar", "toPdf": "A PDF", "toExcel": "A Excel", + "exportSaved": "{name} guardado", + "exportSaveFailed": "No se pudo guardar el archivo.", "exportRequiresAccount": "Inicia sesion para exportar. Las exportaciones se generan en el servidor.", "exportAwaitingSync": "Sincroniza tus cambios primero para que la exportacion los incluya.", "exportPreparing": "Preparando la exportacion...", @@ -300,7 +302,7 @@ "updateWallet": "Update wallet", "createWallet": "Create wallet", "phoneIsRequired": "Phone is required", - "save": "Save", + "save": "Guardar", "selectCategory": "Select category", "categoryIsRequired": "Category is required", "pleaseSelectWallet": "Please select a wallet", diff --git a/assets/translations/fr.json b/assets/translations/fr.json index a4c1b9ee..c6f442f7 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -170,6 +170,8 @@ "export": "Exporter", "toPdf": "En PDF", "toExcel": "En Excel", + "exportSaved": "{name} enregistré", + "exportSaveFailed": "Impossible d'enregistrer le fichier.", "exportRequiresAccount": "Connectez-vous pour exporter. Les exports sont generes sur le serveur.", "exportAwaitingSync": "Synchronisez vos modifications pour qu'elles figurent dans l'export.", "exportPreparing": "Preparation de votre export...", @@ -299,7 +301,7 @@ "updateWallet": "Update wallet", "createWallet": "Create wallet", "phoneIsRequired": "Phone is required", - "save": "Save", + "save": "Enregistrer", "selectCategory": "Select category", "categoryIsRequired": "Category is required", "pleaseSelectWallet": "Please select a wallet", diff --git a/assets/translations/it.json b/assets/translations/it.json index ed48dd2c..7f48f729 100644 --- a/assets/translations/it.json +++ b/assets/translations/it.json @@ -170,6 +170,8 @@ "export": "Esporta", "toPdf": "In PDF", "toExcel": "In Excel", + "exportSaved": "{name} salvato", + "exportSaveFailed": "Impossibile salvare il file.", "exportRequiresAccount": "Accedi per esportare. Le esportazioni vengono generate sul server.", "exportAwaitingSync": "Sincronizza prima le modifiche cosi l'esportazione le includera.", "exportPreparing": "Preparazione dell'esportazione...", @@ -298,7 +300,7 @@ "updateWallet": "Update wallet", "createWallet": "Create wallet", "phoneIsRequired": "Phone is required", - "save": "Save", + "save": "Salva", "selectCategory": "Select category", "categoryIsRequired": "Category is required", "pleaseSelectWallet": "Please select a wallet", diff --git a/assets/translations/ru.json b/assets/translations/ru.json index f984069f..d05c0628 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -171,6 +171,8 @@ "export": "Экспорт", "toPdf": "В PDF", "toExcel": "В Excel", + "exportSaved": "{name} сохранён", + "exportSaveFailed": "Не удалось сохранить файл.", "exportRequiresAccount": "Войдите в аккаунт, чтобы экспортировать. Файлы создаются на сервере.", "exportAwaitingSync": "Сначала синхронизируйте изменения, чтобы они попали в экспорт.", "exportPreparing": "Подготовка экспорта...", diff --git a/lib/core/error/error_handler.dart b/lib/core/error/error_handler.dart index a29894c5..e01017a9 100644 --- a/lib/core/error/error_handler.dart +++ b/lib/core/error/error_handler.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'package:dio/dio.dart'; @@ -37,11 +38,10 @@ class ErrorHandler { } final statusCode = err.response?.statusCode; - final responseDataForMessage = err.response?.data; - final message = responseDataForMessage is Map - ? (responseDataForMessage['message'] as String?) ?? 'Unknown error' + final data = _decodeBody(err.response?.data); + final message = data is Map + ? (data['message'] as String?) ?? 'Unknown error' : 'Unknown error'; - final data = err.response?.data; switch (statusCode) { case 400: @@ -75,6 +75,18 @@ class ErrorHandler { } } + /// A failed file download still carries a JSON body, but it arrives as bytes + /// because the request asked for bytes. Decode it so the server's message is + /// not lost behind a generic error. + static dynamic _decodeBody(dynamic data) { + if (data is! List) return data; + try { + return jsonDecode(utf8.decode(data)); + } catch (_) { + return null; + } + } + static void _recordApiError(DioException err) { if (_crashReportingService == null) return; diff --git a/lib/data/datasources/export/export_remote_datasource.dart b/lib/data/datasources/export/export_remote_datasource.dart index c2f00ef9..af850905 100644 --- a/lib/data/datasources/export/export_remote_datasource.dart +++ b/lib/data/datasources/export/export_remote_datasource.dart @@ -3,6 +3,7 @@ import 'dart:typed_data'; import 'package:dio/dio.dart'; import 'package:injectable/injectable.dart'; import 'package:intl/intl.dart'; +import 'package:trakli/core/error/error_handler.dart'; /// The file formats the export endpoints can produce. enum ExportFormat { @@ -43,16 +44,18 @@ class ExportRemoteDataSourceImpl implements ExportRemoteDataSource { List walletIds = const [], List categoryIds = const [], }) async { - final response = await dio.get>( - 'transactions/export', - queryParameters: { - 'format': format.key, - if (from != null) 'date_from': _ymd(from), - if (to != null) 'date_to': _ymd(to), - if (walletIds.isNotEmpty) 'wallet_ids': walletIds.join(','), - if (categoryIds.isNotEmpty) 'category_ids': categoryIds.join(','), - }, - options: Options(responseType: ResponseType.bytes), + final response = await ErrorHandler.handleApiCall( + () => dio.get>( + 'transactions/export', + queryParameters: { + 'format': format.key, + if (from != null) 'date_from': _ymd(from), + if (to != null) 'date_to': _ymd(to), + if (walletIds.isNotEmpty) 'wallet_ids': walletIds.join(','), + if (categoryIds.isNotEmpty) 'category_ids': categoryIds.join(','), + }, + options: Options(responseType: ResponseType.bytes), + ), ); return Uint8List.fromList(response.data ?? []); diff --git a/lib/gen/translations/codegen_loader.g.dart b/lib/gen/translations/codegen_loader.g.dart index 4621e866..6683e1c3 100644 --- a/lib/gen/translations/codegen_loader.g.dart +++ b/lib/gen/translations/codegen_loader.g.dart @@ -46,15 +46,6 @@ abstract class LocaleKeys { static const parties = 'parties'; static const partyAddParty = 'partyAddParty'; static const partyCreateParty = 'partyCreateParty'; - static const partyEditParty = 'partyEditParty'; - static const partyName = 'partyName'; - static const partyNameRequired = 'partyNameRequired'; - static const partyDescription = 'partyDescription'; - static const partyUpdate = 'partyUpdate'; - static const partyAdd = 'partyAdd'; - static const partyDeleteParty = 'partyDeleteParty'; - static const partyDeletePartyConfirm = 'partyDeletePartyConfirm'; - static const partyNoParties = 'partyNoParties'; static const partyPartyName = 'partyPartyName'; static const partyEnterPartyName = 'partyEnterPartyName'; static const partyPartyDescription = 'partyPartyDescription'; @@ -125,7 +116,6 @@ abstract class LocaleKeys { static const selectLanguage = 'selectLanguage'; static const seeAll = 'seeAll'; static const phoneNumber = 'phoneNumber'; - static const phoneNumberHint = 'phoneNumberHint'; static const payment = 'payment'; static const notifications = 'notifications'; static const langEnglish = 'langEnglish'; @@ -162,6 +152,15 @@ abstract class LocaleKeys { static const selectCurrency = 'selectCurrency'; static const defaultWalletName = 'defaultWalletName'; static const defaultWalletDescription = 'defaultWalletDescription'; + static const partyEditParty = 'partyEditParty'; + static const partyName = 'partyName'; + static const partyNameRequired = 'partyNameRequired'; + static const partyDescription = 'partyDescription'; + static const partyUpdate = 'partyUpdate'; + static const partyAdd = 'partyAdd'; + static const partyDeleteParty = 'partyDeleteParty'; + static const partyDeletePartyConfirm = 'partyDeletePartyConfirm'; + static const partyNoParties = 'partyNoParties'; static const groupUpdate = 'groupUpdate'; static const defaultGroupName = 'defaultGroupName'; static const accountInfo = 'accountInfo'; @@ -175,11 +174,14 @@ abstract class LocaleKeys { static const export = 'export'; static const toPdf = 'toPdf'; static const toExcel = 'toExcel'; + static const exportSaved = 'exportSaved'; + static const exportSaveFailed = 'exportSaveFailed'; static const exportRequiresAccount = 'exportRequiresAccount'; static const exportAwaitingSync = 'exportAwaitingSync'; static const exportPreparing = 'exportPreparing'; static const family = 'family'; static const searchHint = 'searchHint'; + static const totalIncome = 'totalIncome'; static const displaySettings = 'displaySettings'; static const transactionFormDisplayMode = 'transactionFormDisplayMode'; static const themeMode = 'themeMode'; @@ -193,7 +195,6 @@ abstract class LocaleKeys { static const anonymous = 'anonymous'; static const logOut = 'logOut'; static const logoutConfirm = 'logoutConfirm'; - static const alreadyHaveAccount = 'alreadyHaveAccount'; static const dontHaveAccount = 'dontHaveAccount'; static const benefitsAccount = 'benefitsAccount'; static const createAccountNow = 'createAccountNow'; @@ -231,6 +232,10 @@ abstract class LocaleKeys { static const to = 'to'; static const party = 'party'; static const category = 'category'; + static const description = 'description'; + static const attachment = 'attachment'; + static const date = 'date'; + static const time = 'time'; static const editCategory = 'editCategory'; static const addCategory = 'addCategory'; static const noCategoriesFound = 'noCategoriesFound'; @@ -239,14 +244,11 @@ abstract class LocaleKeys { static const addWallet = 'addWallet'; static const name = 'name'; static const nameIsRequired = 'nameIsRequired'; - static const description = 'description'; - static const attachment = 'attachment'; - static const date = 'date'; - static const time = 'time'; static const createSaving = 'createSaving'; static const pleaseSelectCurrency = 'pleaseSelectCurrency'; static const confirm = 'confirm'; static const categories = 'categories'; + static const edit = 'edit'; static const savings = 'savings'; static const addSaving = 'addSaving'; static const snapPicture = 'snapPicture'; @@ -307,6 +309,7 @@ abstract class LocaleKeys { static const selectCategory = 'selectCategory'; static const categoryIsRequired = 'categoryIsRequired'; static const pleaseSelectWallet = 'pleaseSelectWallet'; + static const phoneNumberHint = 'phoneNumberHint'; static const noWalletsYet = 'noWalletsYet'; static const deleteCategory = 'deleteCategory'; static const deleteCategoryConfirm = 'deleteCategoryConfirm'; @@ -320,8 +323,6 @@ abstract class LocaleKeys { static const transactionsIn = 'transactionsIn'; static const wallets = 'wallets'; static const noData = 'noData'; - static const totalIncome = 'totalIncome'; - static const totalExpense = 'totalExpense'; static const thisMonth = 'thisMonth'; static const thisWeek = 'thisWeek'; static const lastThreeMonths = 'lastThreeMonths'; @@ -336,7 +337,6 @@ abstract class LocaleKeys { static const categoryNameAlreadyExists = 'categoryNameAlreadyExists'; static const deleteWallet = 'deleteWallet'; static const deleteWalletConfirm = 'deleteWalletConfirm'; - static const edit = 'edit'; static const defaultName = 'defaultName'; static const officeElements = 'officeElements'; static const officeElementsDesc = 'officeElementsDesc'; @@ -504,6 +504,8 @@ abstract class LocaleKeys { static const createDefaultCategoriesDesc = 'createDefaultCategoriesDesc'; static const skipCategories = 'skipCategories'; static const skipCategoriesDesc = 'skipCategoriesDesc'; + static const alreadyHaveAccount = 'alreadyHaveAccount'; + static const totalExpense = 'totalExpense'; static const advanced = 'advanced'; static const synchronization = 'synchronization'; static const syncHistoryDesc = 'syncHistoryDesc'; @@ -659,6 +661,55 @@ abstract class LocaleKeys { static const partiesDesc = 'partiesDesc'; static const walletsDesc = 'walletsDesc'; static const cancelled = 'cancelled'; + static const imports = 'imports'; + static const importsDesc = 'importsDesc'; + static const importScanDocument = 'importScanDocument'; + static const importScanDocumentDesc = 'importScanDocumentDesc'; + static const importAnalyze = 'importAnalyze'; + static const importAmount = 'importAmount'; + static const importType = 'importType'; + static const importDescription = 'importDescription'; + static const importParty = 'importParty'; + static const importWallet = 'importWallet'; + static const importCategory = 'importCategory'; + static const importDate = 'importDate'; + static const importDocTypeLabel = 'importDocTypeLabel'; + static const importDocBankStatement = 'importDocBankStatement'; + static const importDocReceipt = 'importDocReceipt'; + static const importDocInvoice = 'importDocInvoice'; + static const importDocPayStub = 'importDocPayStub'; + static const importDocUtilityBill = 'importDocUtilityBill'; + static const importReviewSuggestions = 'importReviewSuggestions'; + static const importAnalyzing = 'importAnalyzing'; + static const importExtracting = 'importExtracting'; + static const importEnriching = 'importEnriching'; + static const importCheckingDuplicates = 'importCheckingDuplicates'; + static const importAnalysisFailed = 'importAnalysisFailed'; + static const importAnalysisFailedHint = 'importAnalysisFailedHint'; + static const importNoSuggestions = 'importNoSuggestions'; + static const importNoSuggestionsTitle = 'importNoSuggestionsTitle'; + static const importNoTransactionsFound = 'importNoTransactionsFound'; + static const importNoSuggestionsClose = 'importNoSuggestionsClose'; + static const importNoAcceptedSuggestions = 'importNoAcceptedSuggestions'; + static const importCreatedCount = 'importCreatedCount'; + static const importAutoCreateWallets = 'importAutoCreateWallets'; + static const importAutoCreateParties = 'importAutoCreateParties'; + static const importAutoCreateCategories = 'importAutoCreateCategories'; + static const importConfirm = 'importConfirm'; + static const importRecentActivity = 'importRecentActivity'; + static const importNoActivity = 'importNoActivity'; + static const importSourceLabel = 'importSourceLabel'; + static const importSourceCamera = 'importSourceCamera'; + static const importSourceCameraDesc = 'importSourceCameraDesc'; + static const importSourceGallery = 'importSourceGallery'; + static const importSourceGalleryDesc = 'importSourceGalleryDesc'; + static const importSourceFile = 'importSourceFile'; + static const importSourceFileDesc = 'importSourceFileDesc'; + static const importAiSuggested = 'importAiSuggested'; + static const importConfirmSheetTitle = 'importConfirmSheetTitle'; + static const importCreateTransactions = 'importCreateTransactions'; + static const importAutoCreateMissingHint = 'importAutoCreateMissingHint'; + static const importAutoCreateNoneNeeded = 'importAutoCreateNoneNeeded'; static const budget = 'budget'; static const budgets = 'budgets'; static const addBudget = 'addBudget'; @@ -683,55 +734,6 @@ abstract class LocaleKeys { static const budgetRefunds = 'budgetRefunds'; static const budgetRollover = 'budgetRollover'; static const budgetRolloverIn = 'budgetRolloverIn'; - static const importAiSuggested = 'importAiSuggested'; - static const importAmount = 'importAmount'; - static const importAnalysisFailed = 'importAnalysisFailed'; - static const importAnalysisFailedHint = 'importAnalysisFailedHint'; - static const importAnalyze = 'importAnalyze'; - static const importAnalyzing = 'importAnalyzing'; - static const importAutoCreateCategories = 'importAutoCreateCategories'; - static const importAutoCreateMissingHint = 'importAutoCreateMissingHint'; - static const importAutoCreateNoneNeeded = 'importAutoCreateNoneNeeded'; - static const importAutoCreateParties = 'importAutoCreateParties'; - static const importAutoCreateWallets = 'importAutoCreateWallets'; - static const importCategory = 'importCategory'; - static const importCheckingDuplicates = 'importCheckingDuplicates'; - static const importConfirm = 'importConfirm'; - static const importConfirmSheetTitle = 'importConfirmSheetTitle'; - static const importCreateTransactions = 'importCreateTransactions'; - static const importCreatedCount = 'importCreatedCount'; - static const importDate = 'importDate'; - static const importDescription = 'importDescription'; - static const importDocBankStatement = 'importDocBankStatement'; - static const importDocInvoice = 'importDocInvoice'; - static const importDocPayStub = 'importDocPayStub'; - static const importDocReceipt = 'importDocReceipt'; - static const importDocTypeLabel = 'importDocTypeLabel'; - static const importDocUtilityBill = 'importDocUtilityBill'; - static const importEnriching = 'importEnriching'; - static const importExtracting = 'importExtracting'; - static const importNoAcceptedSuggestions = 'importNoAcceptedSuggestions'; - static const importNoActivity = 'importNoActivity'; - static const importNoSuggestions = 'importNoSuggestions'; - static const importNoSuggestionsClose = 'importNoSuggestionsClose'; - static const importNoSuggestionsTitle = 'importNoSuggestionsTitle'; - static const importNoTransactionsFound = 'importNoTransactionsFound'; - static const importParty = 'importParty'; - static const importRecentActivity = 'importRecentActivity'; - static const importReviewSuggestions = 'importReviewSuggestions'; - static const importScanDocument = 'importScanDocument'; - static const importScanDocumentDesc = 'importScanDocumentDesc'; - static const importSourceCamera = 'importSourceCamera'; - static const importSourceCameraDesc = 'importSourceCameraDesc'; - static const importSourceFile = 'importSourceFile'; - static const importSourceFileDesc = 'importSourceFileDesc'; - static const importSourceGallery = 'importSourceGallery'; - static const importSourceGalleryDesc = 'importSourceGalleryDesc'; - static const importSourceLabel = 'importSourceLabel'; - static const importType = 'importType'; - static const importWallet = 'importWallet'; - static const imports = 'imports'; - static const importsDesc = 'importsDesc'; static const budgetPeriodOpen = 'budgetPeriodOpen'; static const closePeriodMessage = 'closePeriodMessage'; static const closePeriodTitle = 'closePeriodTitle'; diff --git a/lib/presentation/history_screen.dart b/lib/presentation/history_screen.dart index 842aae1e..a732482a 100644 --- a/lib/presentation/history_screen.dart +++ b/lib/presentation/history_screen.dart @@ -1,4 +1,5 @@ import 'package:easy_localization/easy_localization.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; @@ -111,13 +112,7 @@ class _HistoryScreenState extends State { if (state.file != null) { final file = state.file!; context.read().clearFile(); - Share.shareXFiles([ - XFile.fromData( - file.bytes, - name: file.name, - mimeType: file.mimeType, - ), - ], fileNameOverrides: [file.name]); + _showExportActions(file); return; } @@ -136,6 +131,72 @@ class _HistoryScreenState extends State { } } + /// The file is ready; let the user decide whether it should be kept on the + /// device or handed to another app. + void _showExportActions(ExportedFile file) { + showModalBottomSheet( + context: context, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: Text( + file.name, + style: TextStyle(fontWeight: FontWeight.w700, fontSize: 14.sp), + ), + ), + ListTile( + leading: const Icon(Icons.download_outlined), + title: Text(LocaleKeys.save.tr()), + onTap: () { + Navigator.pop(sheetContext); + _saveFile(file); + }, + ), + ListTile( + leading: const Icon(Icons.share_outlined), + title: Text(LocaleKeys.share.tr()), + onTap: () { + Navigator.pop(sheetContext); + _shareFile(file); + }, + ), + ], + ), + ), + ); + } + + void _shareFile(ExportedFile file) { + Share.shareXFiles([ + XFile.fromData( + file.bytes, + name: file.name, + mimeType: file.mimeType, + ), + ], fileNameOverrides: [file.name]); + } + + /// Writes the export through the system file picker, so the user chooses + /// where it lands and ends up with a copy they can find again. + Future _saveFile(ExportedFile file) async { + try { + final path = await FilePicker.platform.saveFile( + dialogTitle: LocaleKeys.export.tr(), + fileName: file.name, + bytes: file.bytes, + ); + if (path == null) return; + showSnackBar( + message: LocaleKeys.exportSaved.tr(namedArgs: {'name': file.name}), + isSuccess: true, + ); + } catch (_) { + showSnackBar(message: LocaleKeys.exportSaveFailed.tr()); + } + } + /// Server-side exports only cover synced records, so a filter pinned to a /// wallet or category that has not reached the server yet cannot be honoured. void _export(BuildContext context, ExportFormat format) { diff --git a/test/unit/export_error_message_test.dart b/test/unit/export_error_message_test.dart new file mode 100644 index 00000000..63a3912f --- /dev/null +++ b/test/unit/export_error_message_test.dart @@ -0,0 +1,102 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:trakli/core/error/error_handler.dart'; +import 'package:trakli/core/error/exceptions.dart'; +import 'package:trakli/core/error/failures/failures.dart'; +import 'package:trakli/core/error/repository_error_handler.dart'; + +/// Builds the failure a download endpoint returns: a JSON body delivered as +/// bytes, because the request asked for [ResponseType.bytes]. +DioException _bytesFailure(int statusCode, Map body) { + final options = RequestOptions( + path: 'transactions/export', + responseType: ResponseType.bytes, + ); + return DioException( + requestOptions: options, + type: DioExceptionType.badResponse, + response: Response>( + requestOptions: options, + statusCode: statusCode, + data: utf8.encode(jsonEncode(body)), + ), + ); +} + +void main() { + const rowLimitMessage = + 'This export covers 318 transactions, over the limit of 100 for PDF ' + 'files. Narrow the date range or wallets, or pick a format that holds ' + 'more.'; + + group('byte-bodied error responses', () { + test('a 422 keeps the server message instead of becoming unknown', () { + final exception = ErrorHandler.handleDioException( + _bytesFailure(422, { + 'success': false, + 'message': rowLimitMessage, + 'errors': {'count': 318, 'max_rows': 100, 'format': 'pdf'}, + }), + ); + + expect(exception, isA()); + expect(exception.message, rowLimitMessage); + }); + + test('the message survives all the way to a displayable failure', () async { + final result = await RepositoryErrorHandler.handleApiCall( + () async => throw ErrorHandler.handleDioException( + _bytesFailure(422, { + 'success': false, + 'message': rowLimitMessage, + 'errors': {'count': 318, 'max_rows': 100}, + }), + ), + ); + + final failure = result.getLeft().toNullable(); + expect(failure, isA()); + expect(failure!.customMessage, rowLimitMessage); + }); + + test('a body that is not JSON falls back rather than throwing', () { + final options = RequestOptions( + path: 'transactions/export', + responseType: ResponseType.bytes, + ); + final exception = ErrorHandler.handleDioException( + DioException( + requestOptions: options, + type: DioExceptionType.badResponse, + response: Response>( + requestOptions: options, + statusCode: 500, + data: const [0xff, 0xfe, 0xfd], + ), + ), + ); + + expect(exception, isA()); + }); + + test('a normal decoded body still works', () { + final options = RequestOptions(path: 'budgets'); + final exception = ErrorHandler.handleDioException( + DioException( + requestOptions: options, + type: DioExceptionType.badResponse, + response: Response>( + requestOptions: options, + statusCode: 422, + data: const {'message': 'The name field is required.'}, + ), + ), + ); + + expect(exception, isA()); + expect(exception.message, 'The name field is required.'); + }); + }); +}