From 181c1f78a30890865c0a3c537ea03a428df988ab Mon Sep 17 00:00:00 2001 From: nfebe Date: Wed, 5 Aug 2026 17:45:29 +0100 Subject: [PATCH] feat(export): Share a transaction export from the history screen The PDF and Excel options in the transaction history opened the menu and did nothing. They now fetch the file from the server and hand it to the share sheet, carrying the wallet, category and date filters that are active on screen. Exporting is refused while signed out, or while transactions are still waiting to sync, because the server cannot see those records and the file would quietly leave them out. --- assets/translations/de.json | 5 +- assets/translations/en.json | 5 +- assets/translations/es.json | 5 +- assets/translations/fr.json | 5 +- assets/translations/it.json | 5 +- assets/translations/ru.json | 5 +- .../export/export_remote_datasource.dart | 62 +++ .../repositories/export_repository_impl.dart | 34 ++ lib/di/injection.config.dart | 204 +++++---- .../repositories/export_repository.dart | 16 + .../export/export_transactions_usecase.dart | 47 ++ lib/gen/translations/codegen_loader.g.dart | 137 +++--- .../exports/cubit/export_cubit.dart | 87 ++++ .../exports/cubit/export_cubit.freezed.dart | 411 ++++++++++++++++++ .../exports/cubit/export_state.dart | 27 ++ lib/presentation/history_screen.dart | 81 +++- test/unit/export_cubit_test.dart | 132 ++++++ 17 files changed, 1099 insertions(+), 169 deletions(-) create mode 100644 lib/data/datasources/export/export_remote_datasource.dart create mode 100644 lib/data/repositories/export_repository_impl.dart create mode 100644 lib/domain/repositories/export_repository.dart create mode 100644 lib/domain/usecases/export/export_transactions_usecase.dart create mode 100644 lib/presentation/exports/cubit/export_cubit.dart create mode 100644 lib/presentation/exports/cubit/export_cubit.freezed.dart create mode 100644 lib/presentation/exports/cubit/export_state.dart create mode 100644 test/unit/export_cubit_test.dart 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); + }); +}