From 277117a1a793d6745cfb129ed17ca91b4d62f1a9 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sun, 8 Dec 2024 00:24:27 +0800 Subject: [PATCH 1/3] update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 705ceb6..9e70104 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Beta next (0.9.0) +## Beta 0.9.0 ### New features From 4bf44d9a6c24e834a07016be436b8153069311cc Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sun, 8 Dec 2024 22:30:34 +0800 Subject: [PATCH 2/3] feat: backup v2 draft v1 --- assets/l10n/en_IN.json | 5 +- assets/l10n/en_US.json | 5 +- assets/l10n/it_IT.json | 5 +- assets/l10n/mn_MN.json | 6 +- lib/objectbox.dart | 2 + lib/routes/export/export_history_page.dart | 2 +- lib/routes/export_options_page.dart | 31 ++ lib/routes/home/profile_tab.dart | 16 - lib/routes/import_page.dart | 6 +- lib/routes/import_wizard/v1.dart | 4 +- lib/routes/import_wizard/v2.dart | 79 +++++ lib/sync/export.dart | 21 +- lib/sync/export/export_v2.dart | 65 ++++ lib/sync/export/mode.dart | 8 +- lib/sync/import.dart | 91 ++++-- lib/sync/import/import_v1.dart | 3 + lib/sync/import/import_v2.dart | 294 ++++++++++++++++++ lib/sync/model/model_v2.dart | 33 ++ lib/sync/model/model_v2.g.dart | 40 +++ lib/sync/sync.dart | 2 +- .../export_history}/backup_entry_card.dart | 0 lib/widgets/import_wizard/backup_info.dart | 30 ++ .../{backup_info.dart => backup_info_v1.dart} | 10 +- .../import_wizard/v2/backup_info_v2.dart | 99 ++++++ .../select_image_flow_icon_sheet.dart | 4 +- pubspec.lock | 2 +- pubspec.yaml | 1 + 27 files changed, 807 insertions(+), 57 deletions(-) create mode 100644 lib/routes/import_wizard/v2.dart create mode 100644 lib/sync/export/export_v2.dart create mode 100644 lib/sync/import/import_v2.dart create mode 100644 lib/sync/model/model_v2.dart create mode 100644 lib/sync/model/model_v2.g.dart rename lib/{sync/export/history => widgets/export/export_history}/backup_entry_card.dart (100%) create mode 100644 lib/widgets/import_wizard/backup_info.dart rename lib/widgets/import_wizard/v1/{backup_info.dart => backup_info_v1.dart} (93%) create mode 100644 lib/widgets/import_wizard/v2/backup_info_v2.dart diff --git a/assets/l10n/en_IN.json b/assets/l10n/en_IN.json index 3740fd4..906a3ee 100644 --- a/assets/l10n/en_IN.json +++ b/assets/l10n/en_IN.json @@ -285,8 +285,10 @@ "sync.export.type": "Export ({type})", "sync.export.asCSV": "As CSV", "sync.export.asCSV.description": "Cannot be used for restore/import! Ideal for opening in software like Google Sheets", + "sync.export.asZIP": "As backup (zip)", + "sync.export.asZIP.description": "Can be fully restored later", "sync.export.asJSON": "As backup (json)", - "sync.export.asJSON.description": "Can be restored later", + "sync.export.asJSON.description": "Can be partially restored. Omits non-essential data (e.g., account image)", "sync.export.autoBackup": "Auto-backup", "sync.export.autoBackup.iCloudAlreadySyncs": "If you're using Flow on iOS or macOS, your data will be synced to your iCloud", "sync.export.onDeviceWarning": "All backups are stored on-device, meaning when you uninstall Flow or reset your device, all the backups will be gone!", @@ -357,6 +359,7 @@ "error.input.noImagePicked": "No image was selected", "error.input.cropFailed": "An error occured during cropping the picture", "error.input.wrongFileType": "Please choose a {type} file", + "error.input.invalidZip": "Not a valid Flow zip file", "error.input.pasteFormatMismatch": "Unable to parse", "error.sync.invalidBackupFile": "Invalid backup file", "error.sync.safetyBackupFailed": "Unable to start import", diff --git a/assets/l10n/en_US.json b/assets/l10n/en_US.json index 6f38c5f..cff0cfa 100644 --- a/assets/l10n/en_US.json +++ b/assets/l10n/en_US.json @@ -285,8 +285,10 @@ "sync.export.type": "Export ({type})", "sync.export.asCSV": "As CSV", "sync.export.asCSV.description": "Cannot be used for restore/import! Ideal for opening in software like Google Sheets", + "sync.export.asZIP": "As backup (zip)", + "sync.export.asZIP.description": "Can be fully restored later", "sync.export.asJSON": "As backup (json)", - "sync.export.asJSON.description": "Can be restored later", + "sync.export.asJSON.description": "Can be partially restored. Omits non-essential data (e.g., account image)", "sync.export.autoBackup": "Auto-backup", "sync.export.autoBackup.iCloudAlreadySyncs": "If you're using Flow on iOS or macOS, your data will be synced to your iCloud", "sync.export.onDeviceWarning": "All backups are stored on-device, meaning when you uninstall Flow or reset your device, all the backups will be gone!", @@ -357,6 +359,7 @@ "error.input.noImagePicked": "No image was selected", "error.input.cropFailed": "An error occured during cropping the picture", "error.input.wrongFileType": "Please choose a {type} file", + "error.input.invalidZip": "Not a valid Flow zip file", "error.sync.invalidBackupFile": "Invalid backup file", "error.input.pasteFormatMismatch": "Unable to parse", "error.sync.safetyBackupFailed": "Unable to start import", diff --git a/assets/l10n/it_IT.json b/assets/l10n/it_IT.json index c6d6ed2..3c4f940 100644 --- a/assets/l10n/it_IT.json +++ b/assets/l10n/it_IT.json @@ -285,8 +285,10 @@ "sync.export.type": "Esporta ({type})", "sync.export.asCSV": "Come CSV", "sync.export.asCSV.description": "Non può essere utilizzato per il ripristino/importazione! Ideale per l'apertura in software come Google Sheets", + "sync.export.asZIP": "Come backup (zip)", + "sync.export.asZIP.description": "Può essere completamente ripristinato in seguito", "sync.export.asJSON": "Come backup (json)", - "sync.export.asJSON.description": "Può essere ripristinato in seguito", + "sync.export.asJSON.description": "Può essere parzialmente ripristinato. Omette dati non essenziali (ad esempio, immagine dell'account)", "sync.export.autoBackup": "Backup automatico", "sync.export.autoBackup.iCloudAlreadySyncs": "Se usi Flow su iOS o macOS, i tuoi dati saranno sincronizzati con il tuo iCloud", "sync.export.onDeviceWarning": "Tutti i backup sono memorizzati sul dispositivo, il che significa che quando disinstalli Flow o reimposti il tuo dispositivo, tutti i backup saranno persi!", @@ -357,6 +359,7 @@ "error.input.noImagePicked": "Nessuna immagine selezionata", "error.input.cropFailed": "Si è verificato un errore durante il ritaglio dell'immagine", "error.input.wrongFileType": "Si prega di scegliere un file {type}", + "error.input.invalidZip": "File non valido", "error.input.pasteFormatMismatch": "Impossibile analizzare", "error.sync.invalidBackupFile": "File di backup non valido", "error.sync.safetyBackupFailed": "Impossibile avviare l'importazione", diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index 348e318..5849688 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -279,14 +279,17 @@ "sync.import.emergencyBackup": "Аливаа эсрдлээс сэрэмжилж Flow таны одоо байгаа файлыг төхөөрөмж дээр тань нөөцлөхийг оролдох болно", "sync.import.emergencyBackup.successful": "Таны өмнөх өгөгдлүүдийг автоматаар нөөцөллөө. Файлыг Нөөцлөх > Нөөцийн түүх цэсрүү орж хадгалж аваарай", "sync.import.start": "Эхлүүлэх", + "sync.import.zipWarning": "Зөвхөн Flow аппаас нөөцөлж авсан ZIP файлыг сонгоно уу", "sync.import.success": "Өгөгдлийг амжилттай сэргээлээ!", "sync.export": "Нөөцлөх", "sync.export.type": "Нөөцлөх ({type})", "sync.export.asCSV": "CSV хүснэгтээр", "sync.export.asCSV.description": "Буцааж сэргээх боломжгүй! Google Sheets гэх мэт программуудад нээж харахад тохиромжтой", + "sync.export.asZIP": "Нөөц файл (ZIP)", + "sync.export.asZIP.description": "Дараа сэргээхэд зориулсан формат", "sync.export.asJSON": "Нөөц файл (JSON)", - "sync.export.asJSON.description": "Дараа сэргээхэд зориулсан формат", + "sync.export.asJSON.description": "Сэргээх боломжтой. Нэмэлт мэдээллүүд сэргэхгүй (дансны зураг гэх мэт)", "sync.export.autoBackup": "Автоматаар хадгалах", "sync.export.autoBackup.iCloudAlreadySyncs": "Хэрэв та iOS эсвэл macOS систем дээр Flow-г ашиглаж байгаа бол таны iCloud-д автоматаар хадгалагдах болно", "sync.export.onDeviceWarning": "Таны нөөцөлсөн өгөгдөл аппыг устгах болон утасны өгөгдлийг шинэчлэх үед хамт устахыг анхаарна уу!", @@ -357,6 +360,7 @@ "error.input.noImagePicked": "Та зураг сонгоогүй байна", "error.input.cropFailed": "Зураг хайчлах үед алдаа гарлаа", "error.input.wrongFileType": "Зөвхөн {} төрлийн файл сонгох боломжтой", + "error.input.invalidZip": "Flow zip файлыг таньж чадсангүй", "error.input.pasteFormatMismatch": "Өгөгдлийг таньж чадсангүй", "error.sync.invalidBackupFile": "Нөөц файл алдаатай байна", "error.sync.safetyBackupFailed": "Сэргээх үйлдэл эхлэх боломжгүй", diff --git a/lib/objectbox.dart b/lib/objectbox.dart index 86c9867..6667b8c 100644 --- a/lib/objectbox.dart +++ b/lib/objectbox.dart @@ -20,6 +20,8 @@ class ObjectBox { static late String appDataDirectory; + static String get imagesDirectory => path.join(appDataDirectory, "images"); + /// A subdirectory to store app data. /// /// This is useful if you want to separate multiple user data or just diff --git a/lib/routes/export/export_history_page.dart b/lib/routes/export/export_history_page.dart index db88575..c046e59 100644 --- a/lib/routes/export/export_history_page.dart +++ b/lib/routes/export/export_history_page.dart @@ -2,7 +2,7 @@ import "package:flow/entity/backup_entry.dart"; import "package:flow/l10n/extensions.dart"; import "package:flow/objectbox.dart"; import "package:flow/objectbox/objectbox.g.dart"; -import "package:flow/sync/export/history/backup_entry_card.dart"; +import "package:flow/widgets/export/export_history/backup_entry_card.dart"; import "package:flow/widgets/export/export_history/no_backups.dart"; import "package:flow/widgets/general/spinner.dart"; import "package:flutter/material.dart"; diff --git a/lib/routes/export_options_page.dart b/lib/routes/export_options_page.dart index ce6fcae..94ea752 100644 --- a/lib/routes/export_options_page.dart +++ b/lib/routes/export_options_page.dart @@ -55,6 +55,37 @@ class _ExportOptionsPageState extends State { ), ), const SizedBox(height: 16.0), + ActionCard( + onTap: () => context.push("/export/zip"), + builder: (context) => Padding( + padding: const EdgeInsets.symmetric( + horizontal: 24.0, + vertical: 16.0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + FlowIcon( + FlowIconData.icon(Symbols.folder_zip_rounded), + size: 80.0, + plated: true, + ), + const SizedBox(height: 8.0), + Text( + "sync.export.asZIP".t(context), + style: context.textTheme.headlineSmall, + ), + const SizedBox(height: 8.0), + Text( + "sync.export.asZIP.description".t(context), + style: context.textTheme.bodySmall, + ), + ], + ), + ), + ), + const SizedBox(height: 16.0), ActionCard( onTap: () => context.push("/export/json"), builder: (context) => Padding( diff --git a/lib/routes/home/profile_tab.dart b/lib/routes/home/profile_tab.dart index f701220..fc99290 100644 --- a/lib/routes/home/profile_tab.dart +++ b/lib/routes/home/profile_tab.dart @@ -5,7 +5,6 @@ import "package:flow/l10n/extensions.dart"; import "package:flow/objectbox.dart"; import "package:flow/prefs.dart"; import "package:flow/services/exchange_rates.dart"; -import "package:flow/sync/import.dart"; import "package:flow/theme/color_themes/registry.dart"; import "package:flow/theme/theme.dart"; import "package:flow/utils/utils.dart"; @@ -246,19 +245,4 @@ class _ProfileTabState extends State { void clearExchangeRatesCache() { ExchangeRatesService().debugClearCache(); } - - void import() async { - try { - await importBackupV1(); - if (mounted) { - context.showToast( - text: "sync.import.successful".t(context), - ); - } - } catch (e) { - if (mounted) { - context.showErrorToast(error: e); - } - } - } } diff --git a/lib/routes/import_page.dart b/lib/routes/import_page.dart index 7351063..00f3701 100644 --- a/lib/routes/import_page.dart +++ b/lib/routes/import_page.dart @@ -4,6 +4,7 @@ import "dart:io"; import "package:flow/l10n/extensions.dart"; import "package:flow/sync/import.dart"; import "package:flow/sync/import/base.dart"; +import "package:flow/sync/import/import_v2.dart"; import "package:flow/utils/extensions/toast.dart"; import "package:flow/widgets/general/spinner.dart"; import "package:flow/widgets/import/file_select_area.dart"; @@ -50,7 +51,7 @@ class _ImportPageState extends State { }); try { - importer = await importBackupV1( + importer = await importBackup( backupFile: backupFile, ); @@ -59,6 +60,9 @@ class _ImportPageState extends State { case ImportV1 importV1: context.pushReplacement("/import/wizard/v1", extra: importV1); break; + case ImportV2 importV2: + context.pushReplacement("/import/wizard/v2", extra: importV2); + break; case null: context.showErrorToast( error: "error.input.noFilePicked".t(context), diff --git a/lib/routes/import_wizard/v1.dart b/lib/routes/import_wizard/v1.dart index 94a0076..9244881 100644 --- a/lib/routes/import_wizard/v1.dart +++ b/lib/routes/import_wizard/v1.dart @@ -3,8 +3,8 @@ import "package:flow/l10n/named_enum.dart"; import "package:flow/sync/import/import_v1.dart"; import "package:flow/utils/utils.dart"; import "package:flow/widgets/general/spinner.dart"; +import "package:flow/widgets/import_wizard/backup_info.dart"; import "package:flow/widgets/import_wizard/import_success.dart"; -import "package:flow/widgets/import_wizard/v1/backup_info.dart"; import "package:flutter/material.dart"; class ImportWizardV1Page extends StatefulWidget { @@ -33,7 +33,7 @@ class _ImportWizardV1PageState extends State { builder: (context, value, child) => switch (value) { ImportV1Progress.waitingConfirmation => BackupInfo( importer: importer, - onTap: _start, + onClickStart: _start, ), ImportV1Progress.error => Text(error.toString()), ImportV1Progress.success => const ImportSuccess(), diff --git a/lib/routes/import_wizard/v2.dart b/lib/routes/import_wizard/v2.dart new file mode 100644 index 0000000..7b2e4cd --- /dev/null +++ b/lib/routes/import_wizard/v2.dart @@ -0,0 +1,79 @@ +import "package:flow/l10n/extensions.dart"; +import "package:flow/l10n/named_enum.dart"; +import "package:flow/sync/import/import_v2.dart"; +import "package:flow/utils/utils.dart"; +import "package:flow/widgets/general/spinner.dart"; +import "package:flow/widgets/import_wizard/backup_info.dart"; +import "package:flow/widgets/import_wizard/import_success.dart"; +import "package:flutter/material.dart"; + +class ImportWizardV2Page extends StatefulWidget { + final ImportV2 importer; + + const ImportWizardV2Page({super.key, required this.importer}); + + @override + State createState() => _ImportWizardV2PageState(); +} + +class _ImportWizardV2PageState extends State { + ImportV2 get importer => widget.importer; + + dynamic error; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text("sync.import".t(context)), + ), + body: SafeArea( + child: ValueListenableBuilder( + valueListenable: importer.progressNotifier, + builder: (context, value, child) => switch (value) { + ImportV2Progress.waitingConfirmation => BackupInfo( + importer: importer, + onClickStart: _start, + ), + ImportV2Progress.error => Text(error.toString()), + ImportV2Progress.success => const ImportSuccess(), + _ => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Spinner.center(), + Center( + child: Text( + value.localizedNameContext(context), + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + }, + ), + ), + ); + } + + void _start() async { + final bool? confirm = await context.showConfirmDialog( + title: "sync.import.eraseWarning".t(context), + isDeletionConfirmation: true, + mainActionLabelOverride: "general.confirm".t(context), + ); + + if (confirm != true) return; + + try { + await importer.execute(); + } catch (e) { + error = e; + } finally { + if (mounted) { + setState(() {}); + } + } + } +} diff --git a/lib/sync/export.dart b/lib/sync/export.dart index 6718945..914adf2 100644 --- a/lib/sync/export.dart +++ b/lib/sync/export.dart @@ -2,10 +2,12 @@ import "dart:async"; import "dart:developer"; import "dart:io"; import "dart:math" as math; +import "dart:typed_data"; import "package:flow/entity/backup_entry.dart"; import "package:flow/objectbox.dart"; import "package:flow/sync/export/export_v1.dart"; +import "package:flow/sync/export/export_v2.dart"; import "package:flow/sync/export/mode.dart"; import "package:flow/sync/sync.dart"; import "package:flow/utils/utils.dart"; @@ -35,6 +37,9 @@ Future export({ final backupContent = switch ((mode, latestSyncModelVersion)) { (ExportMode.csv, 1) => await generateCSVContentV1(), (ExportMode.json, 1) => await generateBackupContentV1(), + (ExportMode.csv, 2) => await generateCSVContentV2(), + (ExportMode.json, 2) => await generateBackupJSONContentV2(), + (ExportMode.zip, 2) => await generateBackupJSONContentV2(), _ => throw UnimplementedError(), }; final savedFilePath = await saveBackupFile( @@ -71,7 +76,7 @@ Future export({ /// Returns file path after successfully saving it Future saveBackupFile( - String backupContent, + dynamic backupContent, bool isShareSupported, { required String fileExt, required BackupEntryType type, @@ -91,7 +96,19 @@ Future saveBackupFile( final File f = File(path.join(saveDir.path, subfolder ?? "", filename)); f.createSync(recursive: true); - f.writeAsStringSync(backupContent); + switch (backupContent) { + case String utf8: + f.writeAsStringSync(utf8); + break; + case Uint8List bytes: + f.writeAsBytesSync(bytes); + break; + case File file: + file.copySync(f.path); + break; + default: + throw UnimplementedError(); + } log("[Flow Sync] Write successful. See file at: ${f.path}"); diff --git a/lib/sync/export/export_v2.dart b/lib/sync/export/export_v2.dart new file mode 100644 index 0000000..611fd58 --- /dev/null +++ b/lib/sync/export/export_v2.dart @@ -0,0 +1,65 @@ +import "dart:convert"; +import "dart:developer"; +import "dart:io"; +import "dart:typed_data"; + +import "package:flow/constants.dart"; +import "package:flow/entity/account.dart"; +import "package:flow/entity/category.dart"; +import "package:flow/entity/profile.dart"; +import "package:flow/entity/transaction.dart"; +import "package:flow/objectbox.dart"; +import "package:flow/objectbox/objectbox.g.dart"; +import "package:flow/prefs.dart"; +import "package:flow/sync/export/export_v1.dart"; +import "package:flow/sync/model/model_v2.dart"; + +Future generateBackupJSONContentV2() async { + const int versionCode = 2; + log("[Flow Sync] Initiating export, version code = $versionCode"); + + final List transactions = + await ObjectBox().box().getAllAsync(); + log("[Flow Sync] Finished fetching transactions"); + + final List accounts = await ObjectBox().box().getAllAsync(); + log("[Flow Sync] Finished fetching accounts"); + + final List categories = + await ObjectBox().box().getAllAsync(); + log("[Flow Sync] Finished fetching categories"); + + final DateTime exportDate = DateTime.now().toUtc(); + + final Query firstProfileQuery = + ObjectBox().box().query().build(); + + final Profile? profile = firstProfileQuery.findFirst(); + + final String username = profile?.name ?? "Default Profile"; + + firstProfileQuery.close(); + + final SyncModelV2 obj = SyncModelV2( + versionCode: versionCode, + exportDate: exportDate, + username: username, + appVersion: appVersion, + transactions: transactions, + accounts: accounts, + categories: categories, + profile: profile, + primaryCurrency: LocalPreferences().getPrimaryCurrency(), + ); + + return jsonEncode(obj.toJson()); +} + +Future generateBackupZipV2() async { + final String jsonContent = await generateBackupJSONContentV2(); + + final Directory tempDir = await Directory.systemTemp.createTemp("flow"); +} + +/// I mean what there is to say, it's the same thing. +Future generateCSVContentV2() async => await generateCSVContentV1(); diff --git a/lib/sync/export/mode.dart b/lib/sync/export/mode.dart index 6879dc5..5eb76b7 100644 --- a/lib/sync/export/mode.dart +++ b/lib/sync/export/mode.dart @@ -8,9 +8,13 @@ enum ExportMode { /// /// Intended for full backups. Will be versioned, we plan to support /// importing older backups to newer versions. + json(fileExt: "json"), + + /// Can be fully recovered from /// - /// More about versioning [here] - json(fileExt: "json"); + /// Includes [json] inside it, plus other files like images that cannot be + /// fit into a JSON file. + zip(fileExt: "zip"); final String fileExt; diff --git a/lib/sync/import.dart b/lib/sync/import.dart index 5d0bc1d..18c5a02 100644 --- a/lib/sync/import.dart +++ b/lib/sync/import.dart @@ -1,34 +1,35 @@ import "dart:convert"; +import "dart:core"; import "dart:io"; +import "dart:typed_data"; +import "package:archive/archive_io.dart"; import "package:flow/entity/account.dart"; import "package:flow/entity/category.dart"; import "package:flow/entity/transaction.dart"; -import "package:flow/objectbox.dart"; import "package:flow/sync/exception.dart"; - +import "package:flow/sync/import/base.dart"; import "package:flow/sync/import/import_v1.dart"; +import "package:flow/sync/import/import_v2.dart"; import "package:flow/sync/import/mode.dart"; -export "package:flow/sync/import/import_v1.dart"; - import "package:flow/sync/model/model_v1.dart"; -export "package:flow/sync/model/model_v1.dart"; - +import "package:flow/sync/model/model_v2.dart"; import "package:flow/utils/utils.dart"; - import "package:path/path.dart" as path; +import "package:path_provider/path_provider.dart"; + +export "package:flow/sync/import/import_v1.dart"; +export "package:flow/sync/model/model_v1.dart"; /// We have to recover following models: /// * Account /// * Category /// * Transactions +/// * Profile /// -/// Because we need to resolve dependencies thru `UUID`s, we'll populate -/// [ObjectBox] in following order: -/// 1. [Category] (no dependency) -/// 2. [Account] (no dependency) -/// 3. [Transaction] (Account, Category) -Future importBackupV1({ +/// We need to resolve [Transaction]s last cause it references both [Account] and +/// [Category] UUID. +Future importBackup({ ImportMode mode = ImportMode.eraseAndWrite, File? backupFile, }) async { @@ -39,23 +40,75 @@ Future importBackupV1({ "No file was picked to proceed with the import", l10nKey: "error.input.noFilePicked", ); - } else if (path.extension(file.path).toLowerCase() != ".json") { - // We also might want to recover data from ObjectBox file, but not sure - // how user friendly it'd be... Something to consider in the future. + } + + final String ext = path.extension(file.path).toLowerCase(); + final bool isSupportedExtension = [".json", ".zip"].contains(ext); + if (!isSupportedExtension) { throw const ImportException( "No file was picked to proceed with the import", l10nKey: "error.input.wrongFileType", - l10nArgs: "JSON", + l10nArgs: "JSON, ZIP", ); } - final Map parsed = await file.readAsString().then( - (raw) => jsonDecode(raw), + late final Map parsed; + String? assetsRoot; + String? cleanupPath; + + if (ext == ".zip") { + final Uint8List bytes = await file.readAsBytes(); + final Archive zip = ZipDecoder().decodeBytes(bytes); + + late final String? jsonRelativePath; + + try { + jsonRelativePath = zip.files + .singleWhere((archiveFile) => + archiveFile.isFile && + !archiveFile.isSymbolicLink && + path.extension(archiveFile.name).toLowerCase() == ".json") + .name; + } catch (e) { + jsonRelativePath = null; + throw ImportException( + "No JSON file was found in the ZIP archive", + l10nKey: "error.input.invalidZip", ); + } + + final Directory tempDir = await getTemporaryDirectory(); + + final String dir = path.join(tempDir.path, + "flow_unzipped_${DateTime.now().millisecondsSinceEpoch.toRadixString(36)}"); + + await Directory(dir).create(); + + await extractArchiveToDiskAsync(zip, dir); + + final File jsonFile = File(path.join(dir, jsonRelativePath)); + + assetsRoot = path.join(dir, "assets"); + cleanupPath = dir; + + parsed = await jsonFile.readAsString().then( + (raw) => jsonDecode(raw), + ); + } else if (ext == ".json") { + parsed = await file.readAsString().then( + (raw) => jsonDecode(raw), + ); + } return switch (parsed["versionCode"]) { 1 => ImportV1(SyncModelV1.fromJson(parsed), mode: mode), + 2 => ImportV2( + SyncModelV2.fromJson(parsed), + mode: mode, + cleanupFolder: cleanupPath, + assetsRoot: assetsRoot, + ), _ => throw UnimplementedError() }; } diff --git a/lib/sync/import/import_v1.dart b/lib/sync/import/import_v1.dart index 8afa866..01dde5a 100644 --- a/lib/sync/import/import_v1.dart +++ b/lib/sync/import/import_v1.dart @@ -67,6 +67,7 @@ class ImportV1 extends Importer { throw const ImportException( "Safety backup failed, aborting mission", l10nKey: "error.sync.safetyBackupFailed", + versionCode: 1, ); } } @@ -192,6 +193,7 @@ class ImportV1 extends Importer { if (memoizeAccounts[accountUuid] == 0) { throw ImportException( "Failed to link account to transaction because: Cannot find account ($accountUuid)", + versionCode: 1, ); } @@ -222,6 +224,7 @@ class ImportV1 extends Importer { if (memoizeCategories[categoryUuid] == 0) { throw ImportException( "Failed to link category to transaction because: Cannot find category ($categoryUuid)", + versionCode: 1, ); } diff --git a/lib/sync/import/import_v2.dart b/lib/sync/import/import_v2.dart new file mode 100644 index 0000000..bb06556 --- /dev/null +++ b/lib/sync/import/import_v2.dart @@ -0,0 +1,294 @@ +import "dart:developer"; +import "dart:io"; + +import "package:path/path.dart" as path; + +import "package:flow/data/currencies.dart"; +import "package:flow/entity/account.dart"; +import "package:flow/entity/backup_entry.dart"; +import "package:flow/entity/category.dart"; +import "package:flow/entity/profile.dart"; +import "package:flow/entity/transaction.dart"; +import "package:flow/l10n/named_enum.dart"; +import "package:flow/objectbox.dart"; +import "package:flow/objectbox/objectbox.g.dart"; +import "package:flow/prefs.dart"; +import "package:flow/sync/exception.dart"; +import "package:flow/sync/import/base.dart"; +import "package:flow/sync/import/mode.dart"; +import "package:flow/sync/model/model_v2.dart"; +import "package:flow/sync/sync.dart"; +import "package:flutter/widgets.dart"; + +/// Used to report current status to user +enum ImportV2Progress implements LocalizedEnum { + unzipping, + waitingConfirmation, + erasing, + writingCategories, + writingAccounts, + resolvingTransactions, + writingTransactions, + writingProfile, + settingPrimaryCurrency, + copyingImages, + success, + error; + + @override + String get localizationEnumValue => name; + @override + String get localizationEnumName => "ImportV2Progress"; +} + +class ImportV2 extends Importer { + @override + final SyncModelV2 data; + final String? assetsRoot; + final String? cleanupFolder; + + dynamic error; + + @override + final ImportMode mode; + + final Map memoizeAccounts = {}; + final Map memoizeCategories = {}; + + @override + final ValueNotifier progressNotifier = + ValueNotifier(ImportV2Progress.unzipping); + + ImportV2( + this.data, { + this.cleanupFolder, + this.assetsRoot, + this.mode = ImportMode.merge, + }); + + @override + Future execute({bool ignoreSafetyBackupFail = false}) async { + String? safetyBackupFilePath; + + try { + // Backup data before ruining everything + await export( + subfolder: "automated_backups", + showShareDialog: false, + type: BackupEntryType.preImport, + ).then((value) => safetyBackupFilePath = value.filePath); + } catch (e) { + if (!ignoreSafetyBackupFail) { + throw const ImportException( + "Safety backup failed, aborting mission", + l10nKey: "error.sync.safetyBackupFailed", + ); + } + } + + try { + switch (mode) { + case ImportMode.eraseAndWrite: + await _eraseAndWrite(); + break; + case ImportMode.merge: + await _merge(); + break; + } + } catch (e) { + if (cleanupFolder != null) { + await Directory(cleanupFolder!).delete(recursive: true); + } + progressNotifier.value = ImportV2Progress.error; + rethrow; + } + + return safetyBackupFilePath; + } + + /// Because we need to resolve dependencies thru `UUID`s, we'll populate + /// [ObjectBox] in following order: + /// 1. [Category] (no dependency) + /// 2. [Account] (no dependency) + /// 3. [Transaction] (Account, Category) + Future _eraseAndWrite() async { + // 0. Erase current data + progressNotifier.value = ImportV2Progress.erasing; + await ObjectBox().eraseMainData(); + + // 1. Resurrect [Category]s + progressNotifier.value = ImportV2Progress.writingCategories; + await ObjectBox().box().putManyAsync(data.categories); + + // 2. Resurrect [Account]s + progressNotifier.value = ImportV2Progress.writingAccounts; + await ObjectBox().box().putManyAsync(data.accounts); + + // 3. Resurrect [Transaction]s + // + // Resolve ToOne [account] and [category] by `uuid`. + progressNotifier.value = ImportV2Progress.resolvingTransactions; + final transformedTransactions = data.transactions + .map((transaction) { + try { + transaction = _resolveAccountForTransaction(transaction); + } catch (e) { + if (e is ImportException) { + log(e.toString()); + } + return null; + } + + try { + transaction = _resolveCategoryForTransaction(transaction); + } catch (e) { + if (e is ImportException) { + log(e.toString()); + } + // Still proceed without category + } + + return transaction; + }) + .nonNulls + .toList(); + + progressNotifier.value = ImportV2Progress.writingTransactions; + await ObjectBox().box().putManyAsync(transformedTransactions); + + if (data.profile != null) { + progressNotifier.value = ImportV2Progress.writingProfile; + await ObjectBox().box().putAsync(data.profile!); + } + + if (data.primaryCurrency != null && + isCurrencyCodeValid(data.primaryCurrency!)) { + progressNotifier.value = ImportV2Progress.settingPrimaryCurrency; + try { + await LocalPreferences().primaryCurrency.set(data.primaryCurrency!); + } catch (e) { + log("[Flow Sync Import v2] Failed to set primary currency, ignoring", + error: e); + } + } + + if (assetsRoot != null) { + progressNotifier.value = ImportV2Progress.copyingImages; + try { + final List assetsList = + Directory(path.join(assetsRoot!, "images")) + .listSync(followLinks: false); + + for (final asset in assetsList) { + if (path.extension(asset.path).toLowerCase() == ".png") { + final String assetName = path.basename(asset.path); + final String targetPath = + path.join(ObjectBox.imagesDirectory, assetName); + + try { + await File(asset.path).copy(targetPath); + } catch (e) { + log("[Flow Sync Import v2] Failed to copy asset: $assetName", + error: e); + } + } else { + log("[Flow Sync Import v2] Skipping non-PNG asset: ${path.basename(asset.path)}"); + } + } + } catch (e) { + log("[Flow Sync Import v2] Failed to copy assets, ignoring", error: e); + } + } + + progressNotifier.value = ImportV2Progress.success; + } + + Future _merge() async { + // Here, we might have an interactive selection screen for resolving + // conflicts. For now, we'll ignore this. + + throw UnimplementedError(); + + // // 1. Resurrect [Category]s + // progressNotifier.value = ImportV1Progress.loadingCategories; + // final currentCategories = await ObjectBox().box().getAllAsync(); + // await ObjectBox().box().putManyAsync(data.categories + // .where((incomingCategory) => !currentCategories.any( + // (currentCategory) => currentCategory.uuid == incomingCategory.uuid)) + // .toList()); + + // // 2. Resurrect [Account]s + // progressNotifier.value = ImportV1Progress.loadingAccounts; + // final currentAccounts = await ObjectBox().box().getAllAsync(); + // await ObjectBox().box().putManyAsync(data.accounts + // .where((incomingAccount) => !currentAccounts.any((currentAccount) => + // currentAccount.uuid == incomingAccount.uuid || + // currentAccount.name == incomingAccount.name)) + // .toList()); + + // // 3. Resurrect [Transaction]s + // progressNotifier.value = ImportV1Progress.loadingTransactions; + // final currentTransactions = + // await ObjectBox().box().getAllAsync(); + } + + Transaction _resolveAccountForTransaction(Transaction transaction) { + if (transaction.accountUuid == null) { + throw Exception("This transaction lacks `accountUuid`"); + } + + final String accountUuid = transaction.accountUuid!; + + // If the `id` is 0, we've already encountered it + if (memoizeAccounts[accountUuid] != 0) { + final Query accountQuery = ObjectBox() + .box() + .query(Account_.uuid.equals(accountUuid)) + .build(); + + memoizeAccounts[accountUuid] ??= accountQuery.findFirst()?.id ?? 0; + + accountQuery.close(); + } + + if (memoizeAccounts[accountUuid] == 0) { + throw ImportException( + "Failed to link account to transaction because: Cannot find account ($accountUuid)", + ); + } + + transaction.account.targetId = memoizeAccounts[accountUuid]!; + + return transaction; + } + + Transaction _resolveCategoryForTransaction(Transaction transaction) { + if (transaction.categoryUuid == null) { + throw Exception("This transaction lacks `categoryUuid`"); + } + + final String categoryUuid = transaction.categoryUuid!; + + // If the `id` is 0, we've already encountered it + if (memoizeCategories[categoryUuid] != 0) { + final Query categoryQuery = ObjectBox() + .box() + .query(Category_.uuid.equals(categoryUuid)) + .build(); + + memoizeCategories[categoryUuid] ??= categoryQuery.findFirst()?.id ?? 0; + + categoryQuery.close(); + } + + if (memoizeCategories[categoryUuid] == 0) { + throw ImportException( + "Failed to link category to transaction because: Cannot find category ($categoryUuid)", + ); + } + + transaction.category.targetId = memoizeCategories[categoryUuid]!; + + return transaction; + } +} diff --git a/lib/sync/model/model_v2.dart b/lib/sync/model/model_v2.dart new file mode 100644 index 0000000..d6dabe4 --- /dev/null +++ b/lib/sync/model/model_v2.dart @@ -0,0 +1,33 @@ +import "package:flow/entity/account.dart"; +import "package:flow/entity/category.dart"; +import "package:flow/entity/profile.dart"; +import "package:flow/entity/transaction.dart"; +import "package:flow/sync/model/base.dart"; +import "package:json_annotation/json_annotation.dart"; + +part "model_v2.g.dart"; + +@JsonSerializable() +class SyncModelV2 extends SyncModelBase { + final List transactions; + final List accounts; + final List categories; + final Profile? profile; + final String? primaryCurrency; + + const SyncModelV2({ + required super.versionCode, + required super.exportDate, + required super.username, + required super.appVersion, + required this.transactions, + required this.accounts, + required this.categories, + required this.profile, + required this.primaryCurrency, + }); + + factory SyncModelV2.fromJson(Map json) => + _$SyncModelV2FromJson(json); + Map toJson() => _$SyncModelV2ToJson(this); +} diff --git a/lib/sync/model/model_v2.g.dart b/lib/sync/model/model_v2.g.dart new file mode 100644 index 0000000..a0740dd --- /dev/null +++ b/lib/sync/model/model_v2.g.dart @@ -0,0 +1,40 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'model_v2.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SyncModelV2 _$SyncModelV2FromJson(Map json) => SyncModelV2( + versionCode: (json['versionCode'] as num).toInt(), + exportDate: DateTime.parse(json['exportDate'] as String), + username: json['username'] as String, + appVersion: json['appVersion'] as String, + transactions: (json['transactions'] as List) + .map((e) => Transaction.fromJson(e as Map)) + .toList(), + accounts: (json['accounts'] as List) + .map((e) => Account.fromJson(e as Map)) + .toList(), + categories: (json['categories'] as List) + .map((e) => Category.fromJson(e as Map)) + .toList(), + profile: json['profile'] == null + ? null + : Profile.fromJson(json['profile'] as Map), + primaryCurrency: json['primaryCurrency'] as String?, + ); + +Map _$SyncModelV2ToJson(SyncModelV2 instance) => + { + 'versionCode': instance.versionCode, + 'exportDate': instance.exportDate.toIso8601String(), + 'username': instance.username, + 'appVersion': instance.appVersion, + 'transactions': instance.transactions, + 'accounts': instance.accounts, + 'categories': instance.categories, + 'profile': instance.profile, + 'primaryCurrency': instance.primaryCurrency, + }; diff --git a/lib/sync/sync.dart b/lib/sync/sync.dart index b4fa7e2..9949bca 100644 --- a/lib/sync/sync.dart +++ b/lib/sync/sync.dart @@ -3,4 +3,4 @@ export "export.dart"; /// To be increased with everytime there's change /// in the structure of the database model. -const int latestSyncModelVersion = 1; +const int latestSyncModelVersion = 2; diff --git a/lib/sync/export/history/backup_entry_card.dart b/lib/widgets/export/export_history/backup_entry_card.dart similarity index 100% rename from lib/sync/export/history/backup_entry_card.dart rename to lib/widgets/export/export_history/backup_entry_card.dart diff --git a/lib/widgets/import_wizard/backup_info.dart b/lib/widgets/import_wizard/backup_info.dart new file mode 100644 index 0000000..6de1ac0 --- /dev/null +++ b/lib/widgets/import_wizard/backup_info.dart @@ -0,0 +1,30 @@ +import "package:flow/sync/import/base.dart"; +import "package:flow/sync/import/import_v1.dart"; +import "package:flow/sync/import/import_v2.dart"; +import "package:flow/widgets/import_wizard/v1/backup_info_v1.dart"; +import "package:flow/widgets/import_wizard/v2/backup_info_v2.dart"; +import "package:flutter/widgets.dart"; + +class BackupInfo extends StatelessWidget { + final Importer importer; + final VoidCallback onClickStart; + + const BackupInfo({ + super.key, + required this.importer, + required this.onClickStart, + }); + + @override + Widget build(BuildContext context) => switch (importer) { + ImportV1 v1 => BackupInfoV1( + importer: v1, + onClickStart: onClickStart, + ), + ImportV2 v2 => BackupInfoV2( + importer: v2, + onClickStart: onClickStart, + ), + _ => Container(), + }; +} diff --git a/lib/widgets/import_wizard/v1/backup_info.dart b/lib/widgets/import_wizard/v1/backup_info_v1.dart similarity index 93% rename from lib/widgets/import_wizard/v1/backup_info.dart rename to lib/widgets/import_wizard/v1/backup_info_v1.dart index cf3ed7a..0fc4316 100644 --- a/lib/widgets/import_wizard/v1/backup_info.dart +++ b/lib/widgets/import_wizard/v1/backup_info_v1.dart @@ -9,13 +9,13 @@ import "package:flow/widgets/general/info_text.dart"; import "package:flutter/material.dart"; import "package:material_symbols_icons/symbols.dart"; -class BackupInfo extends StatelessWidget { - final VoidCallback onTap; +class BackupInfoV1 extends StatelessWidget { + final VoidCallback onClickStart; final ImportV1 importer; - const BackupInfo({ + const BackupInfoV1({ super.key, - required this.onTap, + required this.onClickStart, required this.importer, }); @@ -67,7 +67,7 @@ class BackupInfo extends StatelessWidget { ), const SizedBox(height: 16.0), Button( - onTap: onTap, + onTap: onClickStart, leading: FlowIcon( FlowIconData.icon(Symbols.download_rounded), ), diff --git a/lib/widgets/import_wizard/v2/backup_info_v2.dart b/lib/widgets/import_wizard/v2/backup_info_v2.dart new file mode 100644 index 0000000..075350f --- /dev/null +++ b/lib/widgets/import_wizard/v2/backup_info_v2.dart @@ -0,0 +1,99 @@ +import "package:flow/data/flow_icon.dart"; +import "package:flow/l10n/extensions.dart"; +import "package:flow/sync/import/import_v2.dart"; +import "package:flow/widgets/general/button.dart"; +import "package:flow/widgets/general/flow_icon.dart"; +import "package:flow/widgets/general/info_text.dart"; +import "package:flow/widgets/general/list_header.dart"; +import "package:flow/widgets/import_wizard/import_item_list_tile.dart"; +import "package:flutter/material.dart"; +import "package:material_symbols_icons/symbols.dart"; + +class BackupInfoV2 extends StatelessWidget { + final VoidCallback onClickStart; + final ImportV2 importer; + + const BackupInfoV2({ + super.key, + required this.onClickStart, + required this.importer, + }); + + @override + Widget build(BuildContext context) { + final String profileName = + importer.data.profile?.name ?? importer.data.username; + final String? primaryCurrency = importer.data.primaryCurrency; + + return Padding( + padding: const EdgeInsets.all( + 16.0, + ), + child: Column( + children: [ + Align( + alignment: Alignment.topLeft, + child: ListHeader("sync.import.syncData.parsedEstimate".t(context)), + ), + const SizedBox(height: 16.0), + ImportItemListTile( + icon: FlowIconData.icon(Symbols.account_circle_rounded), + label: Text(profileName), + ), + const SizedBox(height: 8.0), + ImportItemListTile( + icon: FlowIconData.icon(Symbols.wallet_rounded), + label: Text( + "sync.import.syncData.parsedEstimate.accountCount".t( + context, + importer.data.accounts.length, + ), + ), + ), + const SizedBox(height: 8.0), + ImportItemListTile( + icon: FlowIconData.icon(Symbols.list_alt_rounded), + label: Text( + "sync.import.syncData.parsedEstimate.transactionCount".t( + context, + importer.data.transactions.length, + ), + ), + ), + const SizedBox(height: 8.0), + ImportItemListTile( + icon: FlowIconData.icon(Symbols.category_rounded), + label: Text( + "sync.import.syncData.parsedEstimate.categoryCount".t( + context, + importer.data.categories.length, + ), + ), + ), + if (primaryCurrency != null) ...[ + const SizedBox(height: 8.0), + ImportItemListTile( + icon: FlowIconData.icon(Symbols.currency_exchange_rounded), + label: Text(primaryCurrency), + ), + ], + const Spacer(), + InfoText( + child: Text("sync.import.emergencyBackup".t(context)), + ), + const SizedBox(height: 16.0), + Button( + onTap: onClickStart, + leading: FlowIcon( + FlowIconData.icon(Symbols.download_rounded), + ), + child: Text( + "sync.import.start".t(context), + ), + ), + const SizedBox(height: 24.0), + ], + ), + ); + } +} diff --git a/lib/widgets/select_flow_icon_sheet/select_image_flow_icon_sheet.dart b/lib/widgets/select_flow_icon_sheet/select_image_flow_icon_sheet.dart index ecbc4e7..3e0f3f2 100644 --- a/lib/widgets/select_flow_icon_sheet/select_image_flow_icon_sheet.dart +++ b/lib/widgets/select_flow_icon_sheet/select_image_flow_icon_sheet.dart @@ -135,11 +135,9 @@ class _SelectImageFlowIconSheetState extends State { if (bytes == null) throw ""; - final dataDirectory = ObjectBox.appDataDirectory; final fileName = "${const Uuid().v4()}.png"; final file = File(path.join( - dataDirectory, - "images", + ObjectBox.imagesDirectory, fileName, )); await file.create(recursive: true); diff --git a/pubspec.lock b/pubspec.lock index 3530452..5d0fcb2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -31,7 +31,7 @@ packages: source: hosted version: "5.1.1" archive: - dependency: transitive + dependency: "direct main" description: name: archive sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d diff --git a/pubspec.yaml b/pubspec.yaml index e801a36..a0da09e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,6 +10,7 @@ environment: dependencies: app_settings: ^5.1.1 + archive: ^3.6.1 auto_size_text: ^3.0.0 crop_image: ^1.0.12 cross_file: ^0.3.3+8 From db325e7931d45be59cf84ad04b904a2f8ca40907 Mon Sep 17 00:00:00 2001 From: Batmend Ganbaatar Date: Sun, 22 Dec 2024 15:28:03 +0800 Subject: [PATCH 3/3] backup v2 draft --- CHANGELOG.md | 15 +++++++ assets/l10n/en_IN.json | 13 ++++++ assets/l10n/en_US.json | 15 ++++++- assets/l10n/it_IT.json | 15 ++++++- assets/l10n/mn_MN.json | 12 +++++ lib/routes.dart | 19 ++++++-- lib/routes/import_wizard/v2.dart | 3 ++ lib/sync/export.dart | 15 ++++--- lib/sync/export/export_v2.dart | 45 ++++++++++++++++++- lib/sync/export/mode.dart | 13 ++++++ lib/sync/import.dart | 2 +- lib/sync/import/import_v2.dart | 12 ++++- .../select_image_flow_icon_sheet.dart | 9 +++- pubspec.lock | 16 +++++-- pubspec.yaml | 4 +- 15 files changed, 184 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e70104..6b9399c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## Beta 0.10.0 + +### New features + +* N/A + +### Changes + +* N/A + +### Fixes and enhancements + +* Now you can do ZIP backups that include account/profile photos, fixes [#173](https://github.com/flow-mn/flow/issues/173) + and [#204](https://github.com/flow-mn/flow/issues/204) + ## Beta 0.9.0 ### New features diff --git a/assets/l10n/en_IN.json b/assets/l10n/en_IN.json index 906a3ee..d59af78 100644 --- a/assets/l10n/en_IN.json +++ b/assets/l10n/en_IN.json @@ -279,6 +279,7 @@ "sync.import.emergencyBackup": "As a precaution, Flow will try to backup current data to your device before proceeding", "sync.import.emergencyBackup.successful": "Previous data was backed up. You can save the backup file from Backup > Backup history", "sync.import.start": "Start importing", + "sync.import.zipWarning": "Make sure to import ZIP file produced by Flow app!", "sync.import.success": "Import successful!", "sync.export": "Export", @@ -340,6 +341,18 @@ "enum.ImportV1Progress@success": "Success", "enum.ImportV1Progress@error": "Something went wrong ({error})", + "enum.ImportV2Progress@waitingConfirmation": "Waiting for confirmation", + "enum.ImportV2Progress@erasing": "Erasing current data", + "enum.ImportV2Progress@writingCategories": "Writing categories", + "enum.ImportV2Progress@writingAccounts": "Writing accounts", + "enum.ImportV2Progress@resolvingTransactions": "Sorting out transactions", + "enum.ImportV2Progress@writingTransactions": "Writing transactions", + "enum.ImportV2Progress@writingProfile": "Writing profile data", + "enum.ImportV2Progress@settingPrimaryCurrency": "Setting primary currency", + "enum.ImportV2Progress@copyingImages": "Copying images", + "enum.ImportV2Progress@success": "Success", + "enum.ImportV2Progress@error": "Something went wrong ({error})", + "enum.BackupEntryType@manual": "Manual", "enum.BackupEntryType@manual.description": "Backup created by user", "enum.BackupEntryType@automated": "Auto-backup", diff --git a/assets/l10n/en_US.json b/assets/l10n/en_US.json index cff0cfa..ae8a04a 100644 --- a/assets/l10n/en_US.json +++ b/assets/l10n/en_US.json @@ -279,6 +279,7 @@ "sync.import.emergencyBackup": "As a precaution, Flow will try to backup current data to your device before proceeding", "sync.import.emergencyBackup.successful": "Previous data was backed up. You can save the backup file from Backup > Backup history", "sync.import.start": "Start importing", + "sync.import.zipWarning": "Make sure to import ZIP file produced by Flow app!", "sync.import.success": "Import successful!", "sync.export": "Export", @@ -335,11 +336,23 @@ "enum.ImportV1Progress@erasing": "Erasing current data", "enum.ImportV1Progress@writingCategories": "Writing categories", "enum.ImportV1Progress@writingAccounts": "Writing accounts", - "enum.ImportV1Progress@resolvingTransactions": "Soring out transactions", + "enum.ImportV1Progress@resolvingTransactions": "Sorting out transactions", "enum.ImportV1Progress@writingTransactions": "Writing transactions", "enum.ImportV1Progress@success": "Success", "enum.ImportV1Progress@error": "Something went wrong ({error})", + "enum.ImportV2Progress@waitingConfirmation": "Waiting for confirmation", + "enum.ImportV2Progress@erasing": "Erasing current data", + "enum.ImportV2Progress@writingCategories": "Writing categories", + "enum.ImportV2Progress@writingAccounts": "Writing accounts", + "enum.ImportV2Progress@resolvingTransactions": "Sorting out transactions", + "enum.ImportV2Progress@writingTransactions": "Writing transactions", + "enum.ImportV2Progress@writingProfile": "Writing profile data", + "enum.ImportV2Progress@settingPrimaryCurrency": "Setting primary currency", + "enum.ImportV2Progress@copyingImages": "Copying images", + "enum.ImportV2Progress@success": "Success", + "enum.ImportV2Progress@error": "Something went wrong ({error})", + "enum.BackupEntryType@manual": "Manual", "enum.BackupEntryType@manual.description": "Backup created by user", "enum.BackupEntryType@automated": "Auto-backup", diff --git a/assets/l10n/it_IT.json b/assets/l10n/it_IT.json index 3c4f940..e8a0629 100644 --- a/assets/l10n/it_IT.json +++ b/assets/l10n/it_IT.json @@ -279,6 +279,7 @@ "sync.import.emergencyBackup": "Come precauzione, Flow tenterà di eseguire il backup dei dati attuali sul tuo dispositivo prima di procedere", "sync.import.emergencyBackup.successful": "I dati precedenti sono stati eseguiti in backup. Puoi salvare il file di backup da Backup > Cronologia backup", "sync.import.start": "Inizia l'importazione", + "sync.import.zipWarning": "Assicurarsi di importare il file ZIP prodotto dall'app Flow!", "sync.import.success": "Importazione riuscita!", "sync.export": "Esporta", @@ -337,9 +338,21 @@ "enum.ImportV1Progress@writingAccounts": "Scrittura dei conti", "enum.ImportV1Progress@resolvingTransactions": "Risoluzione delle transazioni", "enum.ImportV1Progress@writingTransactions": "Scrittura delle transazioni", - "enum.ImportV1Progress@success": "Successo", + "enum.ImportV1Progress@success": "Operazione completata", "enum.ImportV1Progress@error": "Qualcosa è andato storto ({error})", + "enum.ImportV2Progress@waitingConfirmation": "In attesa di conferma", + "enum.ImportV2Progress@erasing": "Cancellazione dei dati correnti", + "enum.ImportV2Progress@writingCategories": "Scrittura delle categorie", + "enum.ImportV2Progress@writingAccounts": "Scrittura dei conti", + "enum.ImportV2Progress@resolvingTransactions": "Risoluzione delle transazioni", + "enum.ImportV2Progress@writingTransactions": "Scrittura delle transazioni", + "enum.ImportV2Progress@writingProfile": "Scrittura dei dati del profilo", + "enum.ImportV2Progress@settingPrimaryCurrency": "Impostazione della valuta principale", + "enum.ImportV2Progress@copyingImages": "Copia delle immagini", + "enum.ImportV2Progress@success": "Operazione completata", + "enum.ImportV2Progress@error": "Qualcosa è andato storto ({error})", + "enum.BackupEntryType@manual": "Manuale", "enum.BackupEntryType@manual.description": "Backup creato dall'utente", "enum.BackupEntryType@automated": "Backup automatico", diff --git a/assets/l10n/mn_MN.json b/assets/l10n/mn_MN.json index 5849688..2204b34 100644 --- a/assets/l10n/mn_MN.json +++ b/assets/l10n/mn_MN.json @@ -341,6 +341,18 @@ "enum.ImportV1Progress@success": "Амжилттай", "enum.ImportV1Progress@error": "Алдаа гарлаа ({error})", + "enum.ImportV2Progress@waitingConfirmation": "Баталгаажуулалт хүлээж байна", + "enum.ImportV2Progress@erasing": "Хуучин өгөгдлийг цэвэрлэж байна", + "enum.ImportV2Progress@writingCategories": "Ангиллуудыг бичиж байна", + "enum.ImportV2Progress@writingAccounts": "Данснуудыг бичиж байна", + "enum.ImportV2Progress@resolvingTransactions": "Гүйлгээнүүдийг хуваарилж байна", + "enum.ImportV2Progress@writingTransactions": "Гүйлгээнүүдийг бичиж байна", + "enum.ImportV2Progress@writingProfile": "Бүртгэлийг бичиж байна", + "enum.ImportV2Progress@settingPrimaryCurrency": "Үндсэн валют тохируулж байна", + "enum.ImportV2Progress@copyingImages": "Зургуудыг хуулж байна", + "enum.ImportV2Progress@success": "Амжилттай", + "enum.ImportV2Progress@error": "Алдаа гарлаа ({error})", + "enum.BackupEntryType@manual": "Хэрэглэгч үүсгэсэн", "enum.BackupEntryType@manual.description": "Хэрэглэгч өөрөө үүсгэсэн нөөц", "enum.BackupEntryType@automated": "Автомат", diff --git a/lib/routes.dart b/lib/routes.dart index 6f9ac2a..f227d63 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -12,6 +12,7 @@ import "package:flow/routes/export_page.dart"; import "package:flow/routes/home_page.dart"; import "package:flow/routes/import_page.dart"; import "package:flow/routes/import_wizard/v1.dart"; +import "package:flow/routes/import_wizard/v2.dart"; import "package:flow/routes/preferences/button_order_preferences_page.dart"; import "package:flow/routes/preferences/haptics_preferences_page.dart"; import "package:flow/routes/preferences/money_formatting_preferences_page.dart"; @@ -36,6 +37,7 @@ import "package:flow/routes/utils/crop_square_image_page.dart"; import "package:flow/routes/utils/edit_markdown_page.dart"; import "package:flow/sync/export/mode.dart"; import "package:flow/sync/import/import_v1.dart"; +import "package:flow/sync/import/import_v2.dart"; import "package:flow/utils/utils.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; @@ -253,6 +255,18 @@ final router = GoRouter( ); }, ), + GoRoute( + path: "/import/wizard/v2", + builder: (context, state) { + if (state.extra case ImportV2 importV2) { + return ImportWizardV2Page(importer: importV2); + } + + return ErrorPage( + error: "error.sync.invalidBackupFile".t(context), + ); + }, + ), GoRoute( path: "/export/history", builder: (context, state) => const ExportHistoryPage(), @@ -260,9 +274,8 @@ final router = GoRouter( GoRoute( path: "/export/:type", builder: (context, state) => ExportPage( - state.pathParameters["type"] == "csv" - ? ExportMode.csv - : ExportMode.json, + ExportMode.tryParse(state.pathParameters["type"] ?? "zip") ?? + ExportMode.zip, ), ), GoRoute( diff --git a/lib/routes/import_wizard/v2.dart b/lib/routes/import_wizard/v2.dart index 7b2e4cd..a40c8b6 100644 --- a/lib/routes/import_wizard/v2.dart +++ b/lib/routes/import_wizard/v2.dart @@ -1,3 +1,5 @@ +import "dart:developer"; + import "package:flow/l10n/extensions.dart"; import "package:flow/l10n/named_enum.dart"; import "package:flow/sync/import/import_v2.dart"; @@ -70,6 +72,7 @@ class _ImportWizardV2PageState extends State { await importer.execute(); } catch (e) { error = e; + log("[Flow Sync V2] Import failed", error: e); } finally { if (mounted) { setState(() {}); diff --git a/lib/sync/export.dart b/lib/sync/export.dart index 914adf2..08b6a78 100644 --- a/lib/sync/export.dart +++ b/lib/sync/export.dart @@ -25,7 +25,7 @@ typedef ExportStatus = ({bool shareDialogSucceeded, String filePath}); /// automated backups Future export({ required BackupEntryType type, - ExportMode mode = ExportMode.json, + ExportMode mode = ExportMode.zip, bool showShareDialog = true, String? subfolder, }) async { @@ -39,7 +39,7 @@ Future export({ (ExportMode.json, 1) => await generateBackupContentV1(), (ExportMode.csv, 2) => await generateCSVContentV2(), (ExportMode.json, 2) => await generateBackupJSONContentV2(), - (ExportMode.zip, 2) => await generateBackupJSONContentV2(), + (ExportMode.zip, 2) => await generateBackupZipV2(), _ => throw UnimplementedError(), }; final savedFilePath = await saveBackupFile( @@ -87,10 +87,7 @@ Future saveBackupFile( final Directory saveDir = Directory(path.join(ObjectBox.appDataDirectory, "backups")); - - final String dateTime = Moment.now().lll.replaceAll(RegExp("\\s"), "_"); - final String randomValue = math.Random().nextInt(536870912).toRadixString(36); - final String filename = "flow_backup_${dateTime}_$randomValue.$fileExt"; + final String filename = generateBackupFileName(fileExt); log("[Flow Sync] Writing to ${path.join(saveDir.path, filename)}"); @@ -148,3 +145,9 @@ Future showFileSaveDialog( return (shareDialogSucceeded: shareSuccess, filePath: savedFilePath); } + +String generateBackupFileName(String fileExt) { + final String dateTime = Moment.now().lll.replaceAll(RegExp("\\s"), "_"); + final String randomValue = math.Random().nextInt(536870912).toRadixString(36); + return "flow_backup_${dateTime}_$randomValue.$fileExt"; +} diff --git a/lib/sync/export/export_v2.dart b/lib/sync/export/export_v2.dart index 611fd58..73b2003 100644 --- a/lib/sync/export/export_v2.dart +++ b/lib/sync/export/export_v2.dart @@ -1,8 +1,8 @@ import "dart:convert"; import "dart:developer"; import "dart:io"; -import "dart:typed_data"; +import "package:archive/archive_io.dart"; import "package:flow/constants.dart"; import "package:flow/entity/account.dart"; import "package:flow/entity/category.dart"; @@ -11,8 +11,10 @@ import "package:flow/entity/transaction.dart"; import "package:flow/objectbox.dart"; import "package:flow/objectbox/objectbox.g.dart"; import "package:flow/prefs.dart"; +import "package:flow/sync/export.dart"; import "package:flow/sync/export/export_v1.dart"; import "package:flow/sync/model/model_v2.dart"; +import "package:path/path.dart" as path; Future generateBackupJSONContentV2() async { const int versionCode = 2; @@ -56,9 +58,48 @@ Future generateBackupJSONContentV2() async { } Future generateBackupZipV2() async { + final String jsonFileName = generateBackupFileName("json"); + final String zipFileName = generateBackupFileName("zip"); + final String jsonContent = await generateBackupJSONContentV2(); - final Directory tempDir = await Directory.systemTemp.createTemp("flow"); + final Directory tempDir = + await Directory.systemTemp.createTemp("flow_export_v2"); + + await File(path.join(tempDir.path, jsonFileName)).writeAsString(jsonContent); + + final Directory imagesDir = + Directory(path.join(tempDir.path, "assets", "images")); + + try { + await imagesDir.create(recursive: true); + + final List filesList = + Directory(ObjectBox.imagesDirectory) + .listSync(followLinks: false, recursive: false); + final List pngsList = filesList + .where((file) => path.extension(file.path).toLowerCase() == ".png") + .map((file) => File(file.path)) + .toList(); + + await Future.wait(pngsList.map((png) => + png.copy(path.join(imagesDir.path, path.basename(png.path))))) + .catchError((error) { + log("[Flow Sync] Failed to copy some or all of the images to temp directory", + error: error); + return []; + }); + } catch (e) { + log("[Flow Sync] Failed to copy some or all of the images to temp directory", + error: e); + } + + final File result = File(path.join(Directory.systemTemp.path, zipFileName)); + + final ZipFileEncoder encoder = ZipFileEncoder(); + await encoder.zipDirectory(tempDir, filename: result.path); + + return result; } /// I mean what there is to say, it's the same thing. diff --git a/lib/sync/export/mode.dart b/lib/sync/export/mode.dart index 5eb76b7..d72b79d 100644 --- a/lib/sync/export/mode.dart +++ b/lib/sync/export/mode.dart @@ -19,4 +19,17 @@ enum ExportMode { final String fileExt; const ExportMode({required this.fileExt}); + + static ExportMode? tryParse(String value) { + switch (value) { + case "csv": + return ExportMode.csv; + case "json": + return ExportMode.json; + case "zip": + return ExportMode.zip; + default: + return null; + } + } } diff --git a/lib/sync/import.dart b/lib/sync/import.dart index 18c5a02..e8f2c96 100644 --- a/lib/sync/import.dart +++ b/lib/sync/import.dart @@ -85,7 +85,7 @@ Future importBackup({ await Directory(dir).create(); - await extractArchiveToDiskAsync(zip, dir); + await extractArchiveToDisk(zip, dir); final File jsonFile = File(path.join(dir, jsonRelativePath)); diff --git a/lib/sync/import/import_v2.dart b/lib/sync/import/import_v2.dart index bb06556..f6c2b2d 100644 --- a/lib/sync/import/import_v2.dart +++ b/lib/sync/import/import_v2.dart @@ -22,7 +22,6 @@ import "package:flutter/widgets.dart"; /// Used to report current status to user enum ImportV2Progress implements LocalizedEnum { - unzipping, waitingConfirmation, erasing, writingCategories, @@ -57,7 +56,7 @@ class ImportV2 extends Importer { @override final ValueNotifier progressNotifier = - ValueNotifier(ImportV2Progress.unzipping); + ValueNotifier(ImportV2Progress.waitingConfirmation); ImportV2( this.data, { @@ -157,6 +156,15 @@ class ImportV2 extends Importer { await ObjectBox().box().putManyAsync(transformedTransactions); if (data.profile != null) { + try { + await ObjectBox().box().removeAllAsync(); + } catch (e) { + log( + "[Flow Sync Import v2] Failed to remove existing profile, ignoring", + error: e, + ); + } + progressNotifier.value = ImportV2Progress.writingProfile; await ObjectBox().box().putAsync(data.profile!); } diff --git a/lib/widgets/select_flow_icon_sheet/select_image_flow_icon_sheet.dart b/lib/widgets/select_flow_icon_sheet/select_image_flow_icon_sheet.dart index 3e0f3f2..5665588 100644 --- a/lib/widgets/select_flow_icon_sheet/select_image_flow_icon_sheet.dart +++ b/lib/widgets/select_flow_icon_sheet/select_image_flow_icon_sheet.dart @@ -1,3 +1,4 @@ +import "dart:async"; import "dart:developer"; import "dart:io"; import "dart:ui" as ui; @@ -56,12 +57,16 @@ class _SelectImageFlowIconSheetState extends State { } } - File( + final File oldImage = File( path.join( ObjectBox.appDataDirectory, initialImagePath, ), - ).deleteSync(); + ); + + unawaited(oldImage.exists().then((_) { + unawaited(oldImage.delete()); + })); }; } else { cleanUpImage = null; diff --git a/pubspec.lock b/pubspec.lock index 5d0fcb2..67b39ed 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -34,10 +34,10 @@ packages: dependency: "direct main" description: name: archive - sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d + sha256: "6199c74e3db4fbfbd04f66d739e72fe11c8a8957d5f219f1f4482dbde6420b5a" url: "https://pub.dev" source: hosted - version: "3.6.1" + version: "4.0.2" args: dependency: transitive description: @@ -662,10 +662,10 @@ packages: dependency: transitive description: name: image - sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d + sha256: "8346ad4b5173924b5ddddab782fc7d8a6300178c8b1dc427775405a01701c4a6" url: "https://pub.dev" source: hosted - version: "4.3.0" + version: "4.5.2" image_picker: dependency: "direct main" description: @@ -1146,6 +1146,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.1" + posix: + dependency: transitive + description: + name: posix + sha256: a0117dc2167805aa9125b82eee515cc891819bac2f538c83646d355b16f58b9a + url: "https://pub.dev" + source: hosted + version: "6.0.1" proj4dart: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a0da09e..75cfae5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,14 +3,14 @@ description: A personal finance managing app publish_to: "none" # Remove this line if you wish to publish to pub.dev -version: "0.9.0+87" +version: "0.10.0+88" environment: sdk: ">=3.5.0 <4.0.0" dependencies: app_settings: ^5.1.1 - archive: ^3.6.1 + archive: ^4.0.2 auto_size_text: ^3.0.0 crop_image: ^1.0.12 cross_file: ^0.3.3+8