diff --git a/assets/translations/de.json b/assets/translations/de.json index 6fc80932..e0b73f70 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -169,6 +169,9 @@ "export": "Exportieren", "toPdf": "Als PDF", "toExcel": "Als Excel", + "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...", "family": "Familie", "searchHint": "Suchen...", "displaySettings": "Anzeigeeinstellungen", @@ -924,4 +927,4 @@ "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 c14cf3da..870a7898 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -171,6 +171,9 @@ "export": "Export", "toPdf": "To PDF", "toExcel": "To Excel", + "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...", "family": "Family", "searchHint": "Search...", "displaySettings": "Display settings", @@ -924,4 +927,4 @@ "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 25291c1b..aa22e2c3 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -171,6 +171,9 @@ "export": "Exportar", "toPdf": "A PDF", "toExcel": "A Excel", + "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...", "family": "Familia", "searchHint": "Buscar...", "totalIncome": "Ingresos totales", @@ -924,4 +927,4 @@ "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 a8f716f2..a4c1b9ee 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -170,6 +170,9 @@ "export": "Exporter", "toPdf": "En PDF", "toExcel": "En Excel", + "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...", "family": "Famille", "searchHint": "Rechercher...", "totalIncome": "Revenu total", @@ -924,4 +927,4 @@ "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 029652d5..ed48dd2c 100644 --- a/assets/translations/it.json +++ b/assets/translations/it.json @@ -170,6 +170,9 @@ "export": "Esporta", "toPdf": "In PDF", "toExcel": "In Excel", + "exportRequiresAccount": "Accedi per esportare. Le esportazioni vengono generate sul server.", + "exportAwaitingSync": "Sincronizza prima le modifiche cosi l'esportazione le includera.", + "exportPreparing": "Preparazione dell'esportazione...", "family": "Famiglia", "searchHint": "Cerca...", "totalIncome": "Reddito totale", @@ -924,4 +927,4 @@ "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 a2381bf2..f984069f 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -171,6 +171,9 @@ "export": "Экспорт", "toPdf": "В PDF", "toExcel": "В Excel", + "exportRequiresAccount": "Войдите в аккаунт, чтобы экспортировать. Файлы создаются на сервере.", + "exportAwaitingSync": "Сначала синхронизируйте изменения, чтобы они попали в экспорт.", + "exportPreparing": "Подготовка экспорта...", "family": "Семья", "searchHint": "Поиск...", "displaySettings": "Настройки отображения", @@ -924,4 +927,4 @@ "quarantinedHint": "Эти изменения завершились с постоянной ошибкой и не будут повторяться автоматически. Повторите их после устранения причины или отклоните.", "exchangeRateRequired": "Введите обменный курс — актуальный курс недоступен", "currencySwitchRatesUnavailable": "Сейчас нельзя сменить валюту — обменные курсы недоступны. Повторите попытку позже." -} \ No newline at end of file +} diff --git a/lib/data/datasources/export/export_remote_datasource.dart b/lib/data/datasources/export/export_remote_datasource.dart new file mode 100644 index 00000000..c2f00ef9 --- /dev/null +++ b/lib/data/datasources/export/export_remote_datasource.dart @@ -0,0 +1,62 @@ +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:injectable/injectable.dart'; +import 'package:intl/intl.dart'; + +/// The file formats the export endpoints can produce. +enum ExportFormat { + pdf('pdf', 'application/pdf'), + xlsx('xlsx', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'), + csv('csv', 'text/csv'); + + const ExportFormat(this.key, this.mimeType); + + final String key; + final String mimeType; +} + +abstract class ExportRemoteDataSource { + /// Downloads the filtered transaction list. Filters mirror the list endpoint + /// so the file matches what the screen is showing. + Future exportTransactions({ + required ExportFormat format, + DateTime? from, + DateTime? to, + List walletIds = const [], + List categoryIds = const [], + }); +} + +@Injectable(as: ExportRemoteDataSource) +class ExportRemoteDataSourceImpl implements ExportRemoteDataSource { + final Dio dio; + + ExportRemoteDataSourceImpl({required this.dio}); + + @override + Future exportTransactions({ + required ExportFormat format, + DateTime? from, + DateTime? to, + 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), + ); + + return Uint8List.fromList(response.data ?? []); + } + + String _ymd(DateTime date) => DateFormat('yyyy-MM-dd').format(date); +} diff --git a/lib/data/repositories/export_repository_impl.dart b/lib/data/repositories/export_repository_impl.dart new file mode 100644 index 00000000..3fe91318 --- /dev/null +++ b/lib/data/repositories/export_repository_impl.dart @@ -0,0 +1,34 @@ +import 'dart:typed_data'; + +import 'package:fpdart/fpdart.dart'; +import 'package:injectable/injectable.dart'; +import 'package:trakli/core/error/failures/failures.dart'; +import 'package:trakli/core/error/repository_error_handler.dart'; +import 'package:trakli/data/datasources/export/export_remote_datasource.dart'; +import 'package:trakli/domain/repositories/export_repository.dart'; + +@LazySingleton(as: ExportRepository) +class ExportRepositoryImpl implements ExportRepository { + final ExportRemoteDataSource _remote; + + ExportRepositoryImpl(this._remote); + + @override + Future> exportTransactions({ + required ExportFormat format, + DateTime? from, + DateTime? to, + List walletIds = const [], + List categoryIds = const [], + }) { + return RepositoryErrorHandler.handleApiCall( + () => _remote.exportTransactions( + format: format, + from: from, + to: to, + walletIds: walletIds, + categoryIds: categoryIds, + ), + ); + } +} diff --git a/lib/di/injection.config.dart b/lib/di/injection.config.dart index c4524215..54881fe2 100644 --- a/lib/di/injection.config.dart +++ b/lib/di/injection.config.dart @@ -55,6 +55,7 @@ import '../data/datasources/exchange-rate/exchange_rate_local_datasource.dart' as _i900; import '../data/datasources/exchange-rate/exchange_rate_remote_datasource.dart' as _i632; +import '../data/datasources/export/export_remote_datasource.dart' as _i875; import '../data/datasources/group/group_local_datasource.dart' as _i873; import '../data/datasources/group/group_remote_datasource.dart' as _i478; import '../data/datasources/holding/holding_local_datasource.dart' as _i1057; @@ -92,6 +93,7 @@ import '../data/repositories/category_repository_impl.dart' as _i324; import '../data/repositories/cloud_benefit_repository_imp.dart' as _i415; import '../data/repositories/config_repository_impl.dart' as _i379; import '../data/repositories/exchange_rate_imp.dart' as _i827; +import '../data/repositories/export_repository_impl.dart' as _i739; import '../data/repositories/financial_position_repository_impl.dart' as _i569; import '../data/repositories/group_repository_impl.dart' as _i875; import '../data/repositories/holding_repository_impl.dart' as _i204; @@ -124,6 +126,7 @@ import '../domain/repositories/category_repository.dart' as _i410; import '../domain/repositories/cloud_benefit_repository.dart' as _i11; import '../domain/repositories/config_repository.dart' as _i899; import '../domain/repositories/exchange_rate_repository.dart' as _i1057; +import '../domain/repositories/export_repository.dart' as _i3; import '../domain/repositories/financial_position_repository.dart' as _i581; import '../domain/repositories/group_repository.dart' as _i957; import '../domain/repositories/holding_repository.dart' as _i645; @@ -188,6 +191,7 @@ import '../domain/usecases/configs/update_config_usecase.dart' as _i436; import '../domain/usecases/exchange_rate/listen_to_exchange_rate.dart' as _i397; import '../domain/usecases/exchange_rate/update_default_currency_usecase.dart' as _i798; +import '../domain/usecases/export/export_transactions_usecase.dart' as _i795; import '../domain/usecases/financial_position/get_financial_position_usecase.dart' as _i26; import '../domain/usecases/group/add_group_usecase.dart' as _i353; @@ -278,6 +282,7 @@ import '../presentation/config/cubit/config_cubit.dart' as _i408; import '../presentation/config/theme_cubit/theme_cubit.dart' as _i627; import '../presentation/currency/cubit/currency_cubit.dart' as _i484; import '../presentation/exchange_rate/cubit/exchange_rate_cubit.dart' as _i311; +import '../presentation/exports/cubit/export_cubit.dart' as _i435; import '../presentation/financial_position/cubit/financial_position_cubit.dart' as _i261; import '../presentation/groups/cubit/group_cubit.dart' as _i676; @@ -313,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<_i957.SyncService>(() => _i957.SyncService()); gh.singleton<_i196.FeatureRemoteConfig>(() => _i196.FeatureRemoteConfig()); + gh.singleton<_i957.SyncService>(() => _i957.SyncService()); gh.lazySingleton<_i877.SyncDependencyManagerBase>( () => syncModule.provideSyncDependencyManager()); gh.lazySingleton<_i627.ThemeCubit>(() => _i627.ThemeCubit()); @@ -417,6 +422,8 @@ _i174.GetIt $initGetIt( gh<_i704.AppDatabase>(), gh<_i656.PartyRemoteDataSource>(), )); + gh.factory<_i875.ExportRemoteDataSource>( + () => _i875.ExportRemoteDataSourceImpl(dio: gh<_i361.Dio>())); gh.factory<_i76.ImportRemoteDataSource>( () => _i76.ImportRemoteDataSourceImpl(dio: gh<_i361.Dio>())); gh.factory<_i624.WalletRemoteDataSource>( @@ -450,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>())); @@ -538,20 +545,20 @@ _i174.GetIt $initGetIt( gh<_i961.GetCategoriesUseCase>(), gh<_i500.ListenToCategoriesUseCase>(), )); - gh.factory<_i723.LoginWithPhonePassword>( - () => _i723.LoginWithPhonePassword(gh<_i800.AuthRepository>())); - gh.factory<_i2.OnboardingCompleted>( - () => _i2.OnboardingCompleted(gh<_i800.AuthRepository>())); - gh.factory<_i498.LoginByPhoneUsecase>( - () => _i498.LoginByPhoneUsecase(gh<_i800.AuthRepository>())); + 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<_i444.StreamAuthStatus>( - () => _i444.StreamAuthStatus(gh<_i800.AuthRepository>())); + gh.factory<_i2.OnboardingCompleted>( + () => _i2.OnboardingCompleted(gh<_i800.AuthRepository>())); + gh.factory<_i498.LoginByPhoneUsecase>( + () => _i498.LoginByPhoneUsecase(gh<_i800.AuthRepository>())); gh.factory<_i828.IsOnboardingCompleted>( () => _i828.IsOnboardingCompleted(gh<_i800.AuthRepository>())); gh.lazySingleton<_i161.BudgetPeriodStateSyncHandler>( @@ -566,20 +573,22 @@ _i174.GetIt $initGetIt( 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<_i56.DeletePartyUseCase>( - () => _i56.DeletePartyUseCase(gh<_i661.PartyRepository>())); - gh.factory<_i84.AddPartyUseCase>( - () => _i84.AddPartyUseCase(gh<_i661.PartyRepository>())); + 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<_i12.GetPartiesUseCase>( () => _i12.GetPartiesUseCase(gh<_i661.PartyRepository>())); gh.factory<_i714.ListenToPartiesUseCase>( @@ -589,24 +598,24 @@ _i174.GetIt $initGetIt( gh<_i1057.HoldingLocalDataSource>(), gh<_i683.PreferenceManager>(), )); - 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<_i391.RejectActionUseCase>( + () => _i391.RejectActionUseCase(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<_i82.ConfirmActionUseCase>( + () => _i82.ConfirmActionUseCase(gh<_i542.AiRepository>())); + gh.factory<_i633.UploadFilesUseCase>( + () => _i633.UploadFilesUseCase(gh<_i542.AiRepository>())); + gh.factory<_i236.DeleteSessionUseCase>( + () => _i236.DeleteSessionUseCase(gh<_i542.AiRepository>())); + gh.factory<_i308.SendMessageUseCase>( + () => _i308.SendMessageUseCase(gh<_i542.AiRepository>())); gh.factory<_i559.AppUpdateCubit>( () => _i559.AppUpdateCubit(gh<_i150.CheckAppUpdateUseCase>())); gh.factory<_i538.ImportCubit>(() => _i538.ImportCubit( @@ -624,36 +633,36 @@ _i174.GetIt $initGetIt( )); gh.factory<_i88.BenefitsCubit>( () => _i88.BenefitsCubit(gh<_i61.FetchBenefits>())); - gh.factory<_i640.LogoutUsecase>( - () => _i640.LogoutUsecase(gh<_i800.AuthRepository>())); gh.factory<_i684.DeleteAccountUseCase>( () => _i684.DeleteAccountUseCase(gh<_i800.AuthRepository>())); + gh.factory<_i640.LogoutUsecase>( + () => _i640.LogoutUsecase(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<_i15.SearchCoinsUseCase>( - () => _i15.SearchCoinsUseCase(gh<_i645.HoldingRepository>())); - gh.factory<_i684.CreateHoldingUseCase>( - () => _i684.CreateHoldingUseCase(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<_i261.FinancialPositionCubit>(() => _i261.FinancialPositionCubit( getFinancialPositionUseCase: gh<_i26.GetFinancialPositionUseCase>())); - gh.factory<_i422.GetNotificationsUseCase>( - () => _i422.GetNotificationsUseCase(gh<_i965.NotificationRepository>())); gh.factory<_i837.MarkNotificationAsReadUseCase>(() => _i837.MarkNotificationAsReadUseCase(gh<_i965.NotificationRepository>())); + gh.factory<_i422.GetNotificationsUseCase>( + () => _i422.GetNotificationsUseCase(gh<_i965.NotificationRepository>())); gh.lazySingleton<_i957.GroupRepository>(() => _i875.GroupRepositoryImpl( syncHandler: gh<_i235.GroupSyncHandler>(), localDataSource: gh<_i873.GroupLocalDataSource>(), @@ -669,12 +678,12 @@ _i174.GetIt $initGetIt( repriceHoldingsUseCase: gh<_i583.RepriceHoldingsUseCase>(), searchCoinsUseCase: gh<_i15.SearchCoinsUseCase>(), )); - 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<_i418.UpdateWalletUseCase>( + () => _i418.UpdateWalletUseCase(gh<_i368.WalletRepository>())); + gh.factory<_i80.AddWalletUseCase>( + () => _i80.AddWalletUseCase(gh<_i368.WalletRepository>())); gh.factory<_i713.GetWalletsUseCase>( () => _i713.GetWalletsUseCase(gh<_i368.WalletRepository>())); gh.factory<_i314.FetchSubscriptionPlans>( @@ -693,16 +702,16 @@ _i174.GetIt $initGetIt( db: gh<_i704.AppDatabase>(), requestAuthorizationService: gh<_i877.RequestAuthorizationService>(), )); - gh.factory<_i132.GetConfigsUseCase>( - () => _i132.GetConfigsUseCase(gh<_i899.ConfigRepository>())); + gh.factory<_i436.UpdateConfigUseCase>( + () => _i436.UpdateConfigUseCase(gh<_i899.ConfigRepository>())); gh.factory<_i608.ListenToConfigsUseCase>( () => _i608.ListenToConfigsUseCase(gh<_i899.ConfigRepository>())); - gh.factory<_i933.GetConfigUseCase>( - () => _i933.GetConfigUseCase(gh<_i899.ConfigRepository>())); gh.factory<_i833.SaveConfigUseCase>( () => _i833.SaveConfigUseCase(gh<_i899.ConfigRepository>())); - gh.factory<_i436.UpdateConfigUseCase>( - () => _i436.UpdateConfigUseCase(gh<_i899.ConfigRepository>())); + gh.factory<_i933.GetConfigUseCase>( + () => _i933.GetConfigUseCase(gh<_i899.ConfigRepository>())); + gh.factory<_i132.GetConfigsUseCase>( + () => _i132.GetConfigsUseCase(gh<_i899.ConfigRepository>())); gh.factory<_i536.DeleteConfigUseCase>( () => _i536.DeleteConfigUseCase(gh<_i899.ConfigRepository>())); gh.singleton<_i1057.ExchangeRateRepository>( @@ -711,10 +720,10 @@ _i174.GetIt $initGetIt( localDataSource: gh<_i900.ExchangeRateLocalDataSource>(), configRepository: gh<_i899.ConfigRepository>(), )); - gh.factory<_i225.EnsureDefaultWalletExistsUseCase>(() => - _i225.EnsureDefaultWalletExistsUseCase(gh<_i368.WalletRepository>())); gh.factory<_i82.ListenToWalletsUseCase>( () => _i82.ListenToWalletsUseCase(gh<_i368.WalletRepository>())); + gh.factory<_i225.EnsureDefaultWalletExistsUseCase>(() => + _i225.EnsureDefaultWalletExistsUseCase(gh<_i368.WalletRepository>())); gh.lazySingleton<_i415.AiChatCubit>(() => _i415.AiChatCubit( listSessionsUseCase: gh<_i505.ListSessionsUseCase>(), getSessionUseCase: gh<_i752.GetSessionUseCase>(), @@ -725,20 +734,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<_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<_i402.GetOtpCodeUseCase>( + () => _i402.GetOtpCodeUseCase(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>(), @@ -746,6 +755,8 @@ _i174.GetIt $initGetIt( deletePartyUseCase: gh<_i56.DeletePartyUseCase>(), listenToPartiesUseCase: gh<_i714.ListenToPartiesUseCase>(), )); + gh.factory<_i795.ExportTransactionsUseCase>( + () => _i795.ExportTransactionsUseCase(gh<_i3.ExportRepository>())); gh.factory<_i15.LoginCubit>(() => _i15.LoginCubit( gh<_i768.LoginWithEmailPassword>(), gh<_i723.LoginWithPhonePassword>(), @@ -783,14 +794,19 @@ _i174.GetIt $initGetIt( ensureDefaultWalletExistsUseCase: gh<_i225.EnsureDefaultWalletExistsUseCase>(), )); + gh.factory<_i435.ExportCubit>(() => _i435.ExportCubit( + gh<_i795.ExportTransactionsUseCase>(), + gh<_i377.AuthService>(), + gh<_i704.AppDatabase>(), + )); gh.factory<_i397.ListenExchangeRate>( () => _i397.ListenExchangeRate(gh<_i1057.ExchangeRateRepository>())); - 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<_i982.GetGroupsUseCase>( + () => _i982.GetGroupsUseCase(gh<_i957.GroupRepository>())); + gh.factory<_i759.DeleteGroupUseCase>( + () => _i759.DeleteGroupUseCase(gh<_i957.GroupRepository>())); gh.factory<_i353.AddGroupUseCase>( () => _i353.AddGroupUseCase(gh<_i957.GroupRepository>())); gh.factory<_i820.UpdateGroupUseCase>( @@ -800,20 +816,20 @@ _i174.GetIt $initGetIt( gh<_i704.AppDatabase>(), gh<_i1057.ExchangeRateRepository>(), )); - gh.factory<_i694.ListenToRemindersUseCase>( - () => _i694.ListenToRemindersUseCase(gh<_i868.ReminderRepository>())); + gh.factory<_i781.UpdateReminderUseCase>( + () => _i781.UpdateReminderUseCase(gh<_i868.ReminderRepository>())); + gh.factory<_i656.GetRemindersUseCase>( + () => _i656.GetRemindersUseCase(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( @@ -866,14 +882,14 @@ _i174.GetIt $initGetIt( gh<_i704.AppDatabase>(), gh<_i176.BudgetProgressRecomputer>(), )); - 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<_i150.GetFileContentUseCase>( + () => _i150.GetFileContentUseCase(gh<_i442.MediaRepository>())); + gh.factory<_i1026.GetMediaForTransactionUseCase>( + () => _i1026.GetMediaForTransactionUseCase(gh<_i442.MediaRepository>())); gh.factory<_i484.CurrencyCubit>(() => _i484.CurrencyCubit( gh<_i933.GetConfigUseCase>(), gh<_i833.SaveConfigUseCase>(), @@ -897,18 +913,18 @@ _i174.GetIt $initGetIt( db: gh<_i704.AppDatabase>(), requestAuthorizationService: gh<_i877.RequestAuthorizationService>(), )); - gh.factory<_i163.DeleteTransactionUseCase>( - () => _i163.DeleteTransactionUseCase(gh<_i118.TransactionRepository>())); + gh.factory<_i947.GetAllTransactionsUseCase>( + () => _i947.GetAllTransactionsUseCase(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<_i947.GetAllTransactionsUseCase>( - () => _i947.GetAllTransactionsUseCase(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>(), @@ -953,24 +969,24 @@ _i174.GetIt $initGetIt( listenToTransactionsUseCase: gh<_i1022.ListenToTransactionsUseCase>(), getWalletsUseCase: gh<_i713.GetWalletsUseCase>(), )); - gh.factory<_i920.ListenToPeriodStatesUseCase>( - () => _i920.ListenToPeriodStatesUseCase(gh<_i340.BudgetRepository>())); - gh.factory<_i102.GetBudgetUseCase>( - () => _i102.GetBudgetUseCase(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.factory<_i893.CloseBudgetPeriodUseCase>( + () => _i893.CloseBudgetPeriodUseCase(gh<_i340.BudgetRepository>())); + gh.factory<_i377.ListenToBudgetsUseCase>( + () => _i377.ListenToBudgetsUseCase(gh<_i340.BudgetRepository>())); + 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<_i442.ListenToTargetsUseCase>( + () => _i442.ListenToTargetsUseCase(gh<_i340.BudgetRepository>())); gh.lazySingleton<_i646.SynchAppDatabase>(() => _i646.SynchAppDatabase( appDatabase: gh<_i704.AppDatabase>(), typeHandlers: @@ -980,13 +996,13 @@ _i174.GetIt $initGetIt( logger: gh<_i877.SyncLogger>(), crashReporter: gh<_i877.SyncCrashReporter>(), )); - 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.factory<_i1004.CreateTransferWithTransactionsUseCase>(() => + _i1004.CreateTransferWithTransactionsUseCase( + gh<_i55.TransferRepository>())); gh.factory<_i611.TransferCubit>(() => _i611.TransferCubit( createTransferWithTransactionsUseCase: gh<_i1004.CreateTransferWithTransactionsUseCase>(), diff --git a/lib/domain/repositories/export_repository.dart b/lib/domain/repositories/export_repository.dart new file mode 100644 index 00000000..5baf786a --- /dev/null +++ b/lib/domain/repositories/export_repository.dart @@ -0,0 +1,16 @@ +import 'dart:typed_data'; + +import 'package:fpdart/fpdart.dart'; +import 'package:trakli/core/error/failures/failures.dart'; +import 'package:trakli/data/datasources/export/export_remote_datasource.dart'; + +abstract class ExportRepository { + /// Download the filtered transaction list rendered by the server. + Future> exportTransactions({ + required ExportFormat format, + DateTime? from, + DateTime? to, + List walletIds = const [], + List categoryIds = const [], + }); +} diff --git a/lib/domain/usecases/export/export_transactions_usecase.dart b/lib/domain/usecases/export/export_transactions_usecase.dart new file mode 100644 index 00000000..a5b2e003 --- /dev/null +++ b/lib/domain/usecases/export/export_transactions_usecase.dart @@ -0,0 +1,47 @@ +import 'dart:typed_data'; + +import 'package:equatable/equatable.dart'; +import 'package:fpdart/fpdart.dart'; +import 'package:injectable/injectable.dart'; +import 'package:trakli/core/error/failures/failures.dart'; +import 'package:trakli/core/usecases/usecase.dart'; +import 'package:trakli/data/datasources/export/export_remote_datasource.dart'; +import 'package:trakli/domain/repositories/export_repository.dart'; + +@injectable +class ExportTransactionsUseCase + implements UseCase { + final ExportRepository repository; + + ExportTransactionsUseCase(this.repository); + + @override + Future> call(ExportTransactionsParams params) { + return repository.exportTransactions( + format: params.format, + from: params.from, + to: params.to, + walletIds: params.walletIds, + categoryIds: params.categoryIds, + ); + } +} + +class ExportTransactionsParams extends Equatable { + final ExportFormat format; + final DateTime? from; + final DateTime? to; + final List walletIds; + final List categoryIds; + + const ExportTransactionsParams({ + required this.format, + this.from, + this.to, + this.walletIds = const [], + this.categoryIds = const [], + }); + + @override + List get props => [format, from, to, walletIds, categoryIds]; +} diff --git a/lib/gen/translations/codegen_loader.g.dart b/lib/gen/translations/codegen_loader.g.dart index a0761dc4..4621e866 100644 --- a/lib/gen/translations/codegen_loader.g.dart +++ b/lib/gen/translations/codegen_loader.g.dart @@ -46,6 +46,15 @@ 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'; @@ -116,6 +125,7 @@ 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'; @@ -152,15 +162,6 @@ 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'; @@ -174,9 +175,11 @@ abstract class LocaleKeys { static const export = 'export'; static const toPdf = 'toPdf'; static const toExcel = 'toExcel'; + 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'; @@ -190,6 +193,7 @@ 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'; @@ -227,10 +231,6 @@ 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,11 +239,14 @@ 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'; @@ -304,7 +307,6 @@ 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'; @@ -318,6 +320,8 @@ 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'; @@ -332,6 +336,7 @@ 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'; @@ -499,8 +504,6 @@ 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'; @@ -656,55 +659,6 @@ 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'; @@ -729,6 +683,55 @@ 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/exports/cubit/export_cubit.dart b/lib/presentation/exports/cubit/export_cubit.dart new file mode 100644 index 00000000..3ffed156 --- /dev/null +++ b/lib/presentation/exports/cubit/export_cubit.dart @@ -0,0 +1,87 @@ +import 'dart:typed_data'; + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:injectable/injectable.dart'; +import 'package:intl/intl.dart'; +import 'package:trakli/core/error/failures/failures.dart'; +import 'package:trakli/core/services/auth_service.dart'; +import 'package:trakli/data/database/app_database.dart'; +import 'package:trakli/data/datasources/export/export_remote_datasource.dart'; +import 'package:trakli/domain/usecases/export/export_transactions_usecase.dart'; + +part 'export_state.dart'; +part 'export_cubit.freezed.dart'; + +/// Downloads a server-rendered transaction export. The file is produced by the +/// API rather than on the device, so the export is refused while the account is +/// signed out or while local changes are still waiting to sync. Otherwise the +/// user would share a file that silently omits what they just entered. +@injectable +class ExportCubit extends Cubit { + final ExportTransactionsUseCase _exportTransactions; + final AuthService _authService; + final AppDatabase _db; + + ExportCubit( + this._exportTransactions, + this._authService, + this._db, + ) : super(const ExportState()); + + Future exportTransactions({ + required ExportFormat format, + DateTime? from, + DateTime? to, + List walletIds = const [], + List categoryIds = const [], + }) async { + if (state.isExporting) return; + + emit(state.copyWith( + inProgress: format, + failure: const Failure.none(), + file: null, + blocker: ExportBlocker.none, + )); + + if (!await _authService.isAuthenticated()) { + emit(state.copyWith(inProgress: null, blocker: ExportBlocker.signedOut)); + return; + } + + if (await _db.hasPendingTransactionChanges()) { + emit(state.copyWith(inProgress: null, blocker: ExportBlocker.pendingSync)); + return; + } + + final result = await _exportTransactions(ExportTransactionsParams( + format: format, + from: from, + to: to, + walletIds: walletIds, + categoryIds: categoryIds, + )); + + result.fold( + (failure) => emit(state.copyWith(inProgress: null, failure: failure)), + (bytes) => emit(state.copyWith( + inProgress: null, + file: ExportedFile( + bytes: bytes, + name: _fileName(format), + mimeType: format.mimeType, + ), + )), + ); + } + + /// Clears the produced file once it has been handed to the share sheet, so a + /// rebuild does not open it a second time. + void clearFile() => emit(state.copyWith(file: null)); + + String _fileName(ExportFormat format) { + final stamp = DateFormat('yyyy-MM-dd').format(DateTime.now()); + return 'transactions-$stamp.${format.key}'; + } +} diff --git a/lib/presentation/exports/cubit/export_cubit.freezed.dart b/lib/presentation/exports/cubit/export_cubit.freezed.dart new file mode 100644 index 00000000..c821359e --- /dev/null +++ b/lib/presentation/exports/cubit/export_cubit.freezed.dart @@ -0,0 +1,411 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'export_cubit.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +/// @nodoc +mixin _$ExportedFile { + Uint8List get bytes => throw _privateConstructorUsedError; + String get name => throw _privateConstructorUsedError; + String get mimeType => throw _privateConstructorUsedError; + + /// Create a copy of ExportedFile + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $ExportedFileCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $ExportedFileCopyWith<$Res> { + factory $ExportedFileCopyWith( + ExportedFile value, $Res Function(ExportedFile) then) = + _$ExportedFileCopyWithImpl<$Res, ExportedFile>; + @useResult + $Res call({Uint8List bytes, String name, String mimeType}); +} + +/// @nodoc +class _$ExportedFileCopyWithImpl<$Res, $Val extends ExportedFile> + implements $ExportedFileCopyWith<$Res> { + _$ExportedFileCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of ExportedFile + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? bytes = null, + Object? name = null, + Object? mimeType = null, + }) { + return _then(_value.copyWith( + bytes: null == bytes + ? _value.bytes + : bytes // ignore: cast_nullable_to_non_nullable + as Uint8List, + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + mimeType: null == mimeType + ? _value.mimeType + : mimeType // ignore: cast_nullable_to_non_nullable + as String, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$ExportedFileImplCopyWith<$Res> + implements $ExportedFileCopyWith<$Res> { + factory _$$ExportedFileImplCopyWith( + _$ExportedFileImpl value, $Res Function(_$ExportedFileImpl) then) = + __$$ExportedFileImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({Uint8List bytes, String name, String mimeType}); +} + +/// @nodoc +class __$$ExportedFileImplCopyWithImpl<$Res> + extends _$ExportedFileCopyWithImpl<$Res, _$ExportedFileImpl> + implements _$$ExportedFileImplCopyWith<$Res> { + __$$ExportedFileImplCopyWithImpl( + _$ExportedFileImpl _value, $Res Function(_$ExportedFileImpl) _then) + : super(_value, _then); + + /// Create a copy of ExportedFile + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? bytes = null, + Object? name = null, + Object? mimeType = null, + }) { + return _then(_$ExportedFileImpl( + bytes: null == bytes + ? _value.bytes + : bytes // ignore: cast_nullable_to_non_nullable + as Uint8List, + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + mimeType: null == mimeType + ? _value.mimeType + : mimeType // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +/// @nodoc + +class _$ExportedFileImpl implements _ExportedFile { + const _$ExportedFileImpl( + {required this.bytes, required this.name, required this.mimeType}); + + @override + final Uint8List bytes; + @override + final String name; + @override + final String mimeType; + + @override + String toString() { + return 'ExportedFile(bytes: $bytes, name: $name, mimeType: $mimeType)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$ExportedFileImpl && + const DeepCollectionEquality().equals(other.bytes, bytes) && + (identical(other.name, name) || other.name == name) && + (identical(other.mimeType, mimeType) || + other.mimeType == mimeType)); + } + + @override + int get hashCode => Object.hash( + runtimeType, const DeepCollectionEquality().hash(bytes), name, mimeType); + + /// Create a copy of ExportedFile + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$ExportedFileImplCopyWith<_$ExportedFileImpl> get copyWith => + __$$ExportedFileImplCopyWithImpl<_$ExportedFileImpl>(this, _$identity); +} + +abstract class _ExportedFile implements ExportedFile { + const factory _ExportedFile( + {required final Uint8List bytes, + required final String name, + required final String mimeType}) = _$ExportedFileImpl; + + @override + Uint8List get bytes; + @override + String get name; + @override + String get mimeType; + + /// Create a copy of ExportedFile + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$ExportedFileImplCopyWith<_$ExportedFileImpl> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +mixin _$ExportState { + ExportFormat? get inProgress => throw _privateConstructorUsedError; + Failure get failure => throw _privateConstructorUsedError; + ExportedFile? get file => throw _privateConstructorUsedError; + ExportBlocker get blocker => throw _privateConstructorUsedError; + + /// Create a copy of ExportState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $ExportStateCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $ExportStateCopyWith<$Res> { + factory $ExportStateCopyWith( + ExportState value, $Res Function(ExportState) then) = + _$ExportStateCopyWithImpl<$Res, ExportState>; + @useResult + $Res call( + {ExportFormat? inProgress, + Failure failure, + ExportedFile? file, + ExportBlocker blocker}); + + $FailureCopyWith<$Res> get failure; + $ExportedFileCopyWith<$Res>? get file; +} + +/// @nodoc +class _$ExportStateCopyWithImpl<$Res, $Val extends ExportState> + implements $ExportStateCopyWith<$Res> { + _$ExportStateCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of ExportState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? inProgress = freezed, + Object? failure = null, + Object? file = freezed, + Object? blocker = null, + }) { + return _then(_value.copyWith( + inProgress: freezed == inProgress + ? _value.inProgress + : inProgress // ignore: cast_nullable_to_non_nullable + as ExportFormat?, + failure: null == failure + ? _value.failure + : failure // ignore: cast_nullable_to_non_nullable + as Failure, + file: freezed == file + ? _value.file + : file // ignore: cast_nullable_to_non_nullable + as ExportedFile?, + blocker: null == blocker + ? _value.blocker + : blocker // ignore: cast_nullable_to_non_nullable + as ExportBlocker, + ) as $Val); + } + + /// Create a copy of ExportState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $FailureCopyWith<$Res> get failure { + return $FailureCopyWith<$Res>(_value.failure, (value) { + return _then(_value.copyWith(failure: value) as $Val); + }); + } + + /// Create a copy of ExportState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $ExportedFileCopyWith<$Res>? get file { + if (_value.file == null) { + return null; + } + + return $ExportedFileCopyWith<$Res>(_value.file!, (value) { + return _then(_value.copyWith(file: value) as $Val); + }); + } +} + +/// @nodoc +abstract class _$$ExportStateImplCopyWith<$Res> + implements $ExportStateCopyWith<$Res> { + factory _$$ExportStateImplCopyWith( + _$ExportStateImpl value, $Res Function(_$ExportStateImpl) then) = + __$$ExportStateImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {ExportFormat? inProgress, + Failure failure, + ExportedFile? file, + ExportBlocker blocker}); + + @override + $FailureCopyWith<$Res> get failure; + @override + $ExportedFileCopyWith<$Res>? get file; +} + +/// @nodoc +class __$$ExportStateImplCopyWithImpl<$Res> + extends _$ExportStateCopyWithImpl<$Res, _$ExportStateImpl> + implements _$$ExportStateImplCopyWith<$Res> { + __$$ExportStateImplCopyWithImpl( + _$ExportStateImpl _value, $Res Function(_$ExportStateImpl) _then) + : super(_value, _then); + + /// Create a copy of ExportState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? inProgress = freezed, + Object? failure = null, + Object? file = freezed, + Object? blocker = null, + }) { + return _then(_$ExportStateImpl( + inProgress: freezed == inProgress + ? _value.inProgress + : inProgress // ignore: cast_nullable_to_non_nullable + as ExportFormat?, + failure: null == failure + ? _value.failure + : failure // ignore: cast_nullable_to_non_nullable + as Failure, + file: freezed == file + ? _value.file + : file // ignore: cast_nullable_to_non_nullable + as ExportedFile?, + blocker: null == blocker + ? _value.blocker + : blocker // ignore: cast_nullable_to_non_nullable + as ExportBlocker, + )); + } +} + +/// @nodoc + +class _$ExportStateImpl extends _ExportState { + const _$ExportStateImpl( + {this.inProgress, + this.failure = const Failure.none(), + this.file, + this.blocker = ExportBlocker.none}) + : super._(); + + @override + final ExportFormat? inProgress; + @override + @JsonKey() + final Failure failure; + @override + final ExportedFile? file; + @override + @JsonKey() + final ExportBlocker blocker; + + @override + String toString() { + return 'ExportState(inProgress: $inProgress, failure: $failure, file: $file, blocker: $blocker)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$ExportStateImpl && + (identical(other.inProgress, inProgress) || + other.inProgress == inProgress) && + (identical(other.failure, failure) || other.failure == failure) && + (identical(other.file, file) || other.file == file) && + (identical(other.blocker, blocker) || other.blocker == blocker)); + } + + @override + int get hashCode => + Object.hash(runtimeType, inProgress, failure, file, blocker); + + /// Create a copy of ExportState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$ExportStateImplCopyWith<_$ExportStateImpl> get copyWith => + __$$ExportStateImplCopyWithImpl<_$ExportStateImpl>(this, _$identity); +} + +abstract class _ExportState extends ExportState { + const factory _ExportState( + {final ExportFormat? inProgress, + final Failure failure, + final ExportedFile? file, + final ExportBlocker blocker}) = _$ExportStateImpl; + const _ExportState._() : super._(); + + @override + ExportFormat? get inProgress; + @override + Failure get failure; + @override + ExportedFile? get file; + @override + ExportBlocker get blocker; + + /// Create a copy of ExportState + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$ExportStateImplCopyWith<_$ExportStateImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/presentation/exports/cubit/export_state.dart b/lib/presentation/exports/cubit/export_state.dart new file mode 100644 index 00000000..e3770c52 --- /dev/null +++ b/lib/presentation/exports/cubit/export_state.dart @@ -0,0 +1,27 @@ +part of 'export_cubit.dart'; + +/// Why an export could not be attempted, as opposed to why it failed. +enum ExportBlocker { none, signedOut, pendingSync } + +@freezed +class ExportedFile with _$ExportedFile { + const factory ExportedFile({ + required Uint8List bytes, + required String name, + required String mimeType, + }) = _ExportedFile; +} + +@freezed +class ExportState with _$ExportState { + const factory ExportState({ + ExportFormat? inProgress, + @Default(Failure.none()) Failure failure, + ExportedFile? file, + @Default(ExportBlocker.none) ExportBlocker blocker, + }) = _ExportState; + + const ExportState._(); + + bool get isExporting => inProgress != null; +} diff --git a/lib/presentation/history_screen.dart b/lib/presentation/history_screen.dart index d4894b37..842aae1e 100644 --- a/lib/presentation/history_screen.dart +++ b/lib/presentation/history_screen.dart @@ -3,9 +3,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:share_plus/share_plus.dart'; import 'package:syncfusion_flutter_datepicker/datepicker.dart' show PickerDateRange; import 'package:trakli/core/utils/currency_formater.dart'; +import 'package:trakli/data/datasources/export/export_remote_datasource.dart'; +import 'package:trakli/di/injection.dart'; import 'package:trakli/domain/entities/category_entity.dart'; import 'package:trakli/domain/entities/transaction_complete_entity.dart'; import 'package:trakli/domain/entities/wallet_entity.dart'; @@ -13,6 +16,7 @@ import 'package:trakli/gen/assets.gen.dart'; import 'package:trakli/gen/translations/codegen_loader.g.dart'; import 'package:trakli/presentation/add_transaction_screen.dart'; import 'package:trakli/presentation/exchange_rate/cubit/exchange_rate_cubit.dart'; +import 'package:trakli/presentation/exports/cubit/export_cubit.dart'; import 'package:trakli/presentation/info_interfaces/data.dart'; import 'package:trakli/presentation/info_interfaces/info_interface.dart'; import 'package:trakli/presentation/transactions/cubit/transaction_cubit.dart'; @@ -90,6 +94,77 @@ class _HistoryScreenState extends State { @override Widget build(BuildContext context) { final tones = context.tones; + return BlocProvider( + create: (_) => getIt(), + child: BlocListener( + listenWhen: (previous, current) => + previous.file != current.file || + previous.failure != current.failure || + previous.blocker != current.blocker, + listener: _onExportStateChanged, + child: _buildScaffold(tones), + ), + ); + } + + void _onExportStateChanged(BuildContext context, ExportState 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]); + return; + } + + if (state.failure.hasError) { + showSnackBar(message: state.failure); + return; + } + + switch (state.blocker) { + case ExportBlocker.signedOut: + showSnackBar(message: LocaleKeys.exportRequiresAccount.tr()); + case ExportBlocker.pendingSync: + showSnackBar(message: LocaleKeys.exportAwaitingSync.tr()); + case ExportBlocker.none: + break; + } + } + + /// 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) { + final wallets = selectedItems.whereType().toList(); + final categories = selectedItems.whereType().toList(); + + if (wallets.any((wallet) => wallet.id == null) || + categories.any((category) => category.id == null)) { + showSnackBar(message: LocaleKeys.exportAwaitingSync.tr()); + return; + } + + final start = dateRange?.startDate; + + showSnackBar( + message: LocaleKeys.exportPreparing.tr(), + isSuccess: true, + ); + + context.read().exportTransactions( + format: format, + from: start, + to: start == null ? null : (dateRange?.endDate ?? start), + walletIds: wallets.map((wallet) => wallet.id!).toList(), + categoryIds: categories.map((category) => category.id!).toList(), + ); + } + + Widget _buildScaffold(AppTones tones) { return BlocBuilder( builder: (context, state) { final exchangeRateEntity = @@ -253,7 +328,8 @@ class _HistoryScreenState extends State { itemBuilder: (context) { return [ PopupMenuItem( - onTap: () {}, + onTap: () => + _export(context, ExportFormat.pdf), child: Row( spacing: 8.w, children: [ @@ -267,7 +343,8 @@ class _HistoryScreenState extends State { ), ), PopupMenuItem( - onTap: () {}, + onTap: () => + _export(context, ExportFormat.xlsx), child: Row( spacing: 8.w, children: [ diff --git a/test/unit/export_cubit_test.dart b/test/unit/export_cubit_test.dart new file mode 100644 index 00000000..842153a0 --- /dev/null +++ b/test/unit/export_cubit_test.dart @@ -0,0 +1,132 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fpdart/fpdart.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:trakli/core/error/failures/failures.dart'; +import 'package:trakli/core/services/auth_service.dart'; +import 'package:trakli/data/database/app_database.dart'; +import 'package:trakli/data/datasources/export/export_remote_datasource.dart'; +import 'package:trakli/domain/usecases/export/export_transactions_usecase.dart'; +import 'package:trakli/presentation/exports/cubit/export_cubit.dart'; + +class _MockExportTransactions extends Mock + implements ExportTransactionsUseCase {} + +class _MockAuthService extends Mock implements AuthService {} + +class _MockDatabase extends Mock implements AppDatabase {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late _MockExportTransactions exportTransactions; + late _MockAuthService authService; + late _MockDatabase db; + + final bytes = Uint8List.fromList([1, 2, 3]); + + setUpAll(() { + registerFallbackValue( + const ExportTransactionsParams(format: ExportFormat.pdf), + ); + }); + + setUp(() { + exportTransactions = _MockExportTransactions(); + authService = _MockAuthService(); + db = _MockDatabase(); + + when(() => authService.isAuthenticated()).thenAnswer((_) async => true); + when(() => db.hasPendingTransactionChanges()) + .thenAnswer((_) async => false); + }); + + ExportCubit buildCubit() => ExportCubit(exportTransactions, authService, db); + + test('produces a file named for the requested format', () async { + when(() => exportTransactions(any())) + .thenAnswer((_) async => Right(bytes)); + + final cubit = buildCubit(); + await cubit.exportTransactions(format: ExportFormat.xlsx); + + expect(cubit.state.isExporting, isFalse); + expect(cubit.state.file, isNotNull); + expect(cubit.state.file!.name, endsWith('.xlsx')); + expect(cubit.state.file!.mimeType, ExportFormat.xlsx.mimeType); + expect(cubit.state.file!.bytes, bytes); + }); + + test('forwards the active filters to the use case', () async { + when(() => exportTransactions(any())) + .thenAnswer((_) async => Right(bytes)); + + final from = DateTime(2026, 3, 1); + final to = DateTime(2026, 3, 31); + + await buildCubit().exportTransactions( + format: ExportFormat.csv, + from: from, + to: to, + walletIds: const [7], + categoryIds: const [11, 12], + ); + + final captured = verify(() => exportTransactions(captureAny())) + .captured + .single as ExportTransactionsParams; + + expect(captured.format, ExportFormat.csv); + expect(captured.from, from); + expect(captured.to, to); + expect(captured.walletIds, [7]); + expect(captured.categoryIds, [11, 12]); + }); + + test('refuses to export while signed out', () async { + when(() => authService.isAuthenticated()).thenAnswer((_) async => false); + + final cubit = buildCubit(); + await cubit.exportTransactions(format: ExportFormat.pdf); + + expect(cubit.state.blocker, ExportBlocker.signedOut); + expect(cubit.state.file, isNull); + verifyNever(() => exportTransactions(any())); + }); + + test('refuses to export while local changes are unsynced', () async { + when(() => db.hasPendingTransactionChanges()).thenAnswer((_) async => true); + + final cubit = buildCubit(); + await cubit.exportTransactions(format: ExportFormat.pdf); + + expect(cubit.state.blocker, ExportBlocker.pendingSync); + expect(cubit.state.file, isNull); + verifyNever(() => exportTransactions(any())); + }); + + test('surfaces a failure instead of a file', () async { + when(() => exportTransactions(any())) + .thenAnswer((_) async => const Left(NetworkFailure())); + + final cubit = buildCubit(); + await cubit.exportTransactions(format: ExportFormat.pdf); + + expect(cubit.state.failure, const Failure.networkError()); + expect(cubit.state.file, isNull); + expect(cubit.state.isExporting, isFalse); + }); + + test('clearFile stops the same export being shared twice', () async { + when(() => exportTransactions(any())) + .thenAnswer((_) async => Right(bytes)); + + final cubit = buildCubit(); + await cubit.exportTransactions(format: ExportFormat.pdf); + expect(cubit.state.file, isNotNull); + + cubit.clearFile(); + expect(cubit.state.file, isNull); + }); +}