From 6500c7e5e87a8bb5779833be0fbbe6f8e9fa4288 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maty=C3=A1=C5=A1=20Caras?= Date: Mon, 22 Jan 2024 23:32:21 +0100 Subject: [PATCH] feat: import/export data closes #25 --- lib/api/wallet_manager.dart | 131 +++++++++++++++++++++ lib/l10n/app_cs.arb | 15 ++- lib/l10n/app_en.arb | 15 ++- lib/views/home.dart | 43 ++----- lib/views/settings/settings.dart | 188 +++++++++++++++++++++++++++++++ pubspec.lock | 130 ++------------------- pubspec.yaml | 3 +- 7 files changed, 370 insertions(+), 155 deletions(-) diff --git a/lib/api/wallet_manager.dart b/lib/api/wallet_manager.dart index 772c85a..2f07729 100644 --- a/lib/api/wallet_manager.dart +++ b/lib/api/wallet_manager.dart @@ -1,6 +1,10 @@ import 'dart:convert'; import 'dart:io'; +import 'package:archive/archive_io.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_file_dialog/flutter_file_dialog.dart'; +import 'package:intl/intl.dart'; import 'package:path_provider/path_provider.dart'; import 'package:prasule/api/wallet.dart'; import 'package:prasule/main.dart'; @@ -41,6 +45,126 @@ class WalletManager { } } + /// Creates a ZIP archive from all wallets + static Future exportAllWallets() async { + if (kIsWeb) { + // TODO + return; + } + + final archive = Archive(); + for (final w in Directory( + "${(await getApplicationDocumentsDirectory()).path}/wallets", + ).listSync()) { + if (w is! File) continue; + logger.i("Zipping ${w.path.split("/").last}"); + final wf = w; + archive.addFile( + ArchiveFile.stream( + wf.path.split("/").last, + wf.lengthSync(), + InputFileStream(wf.path), + ), + ); + } + if (!await FlutterFileDialog.isPickDirectorySupported()) { + File( + "${(await getApplicationDocumentsDirectory()).path}/export_${DateFormat("dd_MM_yyyy").format(DateTime.now())}.zip", + ).writeAsBytesSync(ZipEncoder().encode(archive) ?? []); + return; + } + final dir = await FlutterFileDialog.pickDirectory(); + if (dir == null) return; + await FlutterFileDialog.saveFileToDirectory( + directory: dir, + data: Uint8List.fromList(ZipEncoder().encode(archive) ?? []), + fileName: "export_${DateFormat("dd_MM_yyyy").format(DateTime.now())}.zip", + mimeType: "application/zip", + ); + } + + /// Exports a single [Wallet] + static Future exportWallet({Wallet? wallet, String? name}) async { + if (wallet == null && name == null) { + throw Exception("You need to specify either a wallet or a name"); + } + final n = name ?? wallet!.name; + + if (!await FlutterFileDialog.isPickDirectorySupported()) { + File("${(await getApplicationDocumentsDirectory()).path}/wallets/$n") + .copySync( + "${await getApplicationDocumentsDirectory()}/export_${n.replaceAll(RegExp('[|\\?*<":>+\[\]/\' ]+'), '_')}_${DateFormat("dd_MM_yyyy").format(DateTime.now())}.json", + ); + return; + } + final dir = await FlutterFileDialog.pickDirectory(); + if (dir == null) return; + await FlutterFileDialog.saveFileToDirectory( + directory: dir, + data: + File("${(await getApplicationDocumentsDirectory()).path}/wallets/$n") + .readAsBytesSync(), + fileName: + "export_${n.replaceAll(RegExp('[|\\?*<":>+\[\]/\' ]+'), '_')}_${DateFormat("dd_MM_yyyy").format(DateTime.now())}.json", + mimeType: "application/json", + ); + } + + /// Import a single wallet + static Future importWallet({String? data}) async { + var d = data ?? ""; + if (data == null) { + final filePath = await FlutterFileDialog.pickFile( + params: const OpenFileDialogParams( + mimeTypesFilter: ["application/json"], + fileExtensionsFilter: ["json"], + ), + ); + if (filePath == null) return; + d = File(filePath).readAsStringSync(); + } + final w = Wallet.fromJson(jsonDecode(d) as Map); + if (await WalletManager.exists(w.name)) { + throw Exception("Wallet already exists!"); + } + await WalletManager.saveWallet( + w, + ); + } + + /// Imports wallets from a ZIP archive + static Future importArchive() async { + final filePath = await FlutterFileDialog.pickFile( + params: const OpenFileDialogParams( + mimeTypesFilter: ["application/zip"], + fileExtensionsFilter: ["zip"], + ), + ); + if (filePath == null) return; + if (kIsWeb) { + // TODO + return; + } + final temp = Directory("${(await getTemporaryDirectory()).path}/data"); + if (temp.existsSync()) { + temp.deleteSync(); + } + temp.createSync(recursive: true); + final archive = ZipDecoder().decodeBuffer(InputFileStream(filePath)); + for (final file in archive.files) { + if (!file.isFile) { + logger.d(file.name); + continue; + } + file.writeContent(OutputFileStream("${temp.path}/${file.name}")); + } + for (final e in temp.listSync()) { + logger.d(e.path); + if (e is! File) continue; + await importWallet(data: e.readAsStringSync()); + } + } + /// Loads and returns a single [Wallet] by name static Future loadWallet(String name) async { final path = @@ -76,4 +200,11 @@ class WalletManager { Directory("${(await getApplicationDocumentsDirectory()).path}/wallets"); File("${path.path}/${w.name}").deleteSync(); } + + /// Checks if the wallet exists + static Future exists(String name) async { + return File( + "${(await getApplicationDocumentsDirectory()).path}/wallets/$name", + ).existsSync(); + } } diff --git a/lib/l10n/app_cs.arb b/lib/l10n/app_cs.arb index 0068830..489ffed 100644 --- a/lib/l10n/app_cs.arb +++ b/lib/l10n/app_cs.arb @@ -91,5 +91,18 @@ "evenMoney":"Váš stav je stejný jako minulý měsíc.", "balanceStatusA": "Váš stav je ", "balanceStatusB": " oproti minulému měsíci.", - "searchLabel":"Prohledat záznamy..." + "searchLabel":"Prohledat záznamy...", + "exportSingle":"Exportovat peněženku", + "exportSingleDesc":"Uloží vybranou peněženku do složky Stažené", + "exportArchive":"Exportovat archiv", + "exportArchiveDesc":"Exportuje všechny peněženky do archivu do složky Stažené", + "importSingle":"Importovat peněženku", + "importSingleDesc":"Importuje jednu peněženku ze souboru", + "importArchive":"Importovat archiv", + "importArchiveDesc":"Importuje všechny nepoškozené peněženky z archivu", + "settingsData":"Data", + "selectExportWallet":"Zvolte peněženku k exportování", + "exportError":"Při exportování peněženky nastala chyba", + "exportCompleted":"Export dokončen", + "importCompleted":"Import dokončen" } \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index dbc73a3..e65d3a6 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -207,5 +207,18 @@ "evenMoney":"You're on the same balance as last month.", "balanceStatusA": "Your balance is ", "balanceStatusB": " compared to last month.", - "searchLabel":"Search entries..." + "searchLabel":"Search entries...", + "exportSingle":"Export a wallet", + "exportSingleDesc":"Saves a single wallet into your downloads directory", + "exportArchive":"Export archive", + "exportArchiveDesc":"Exports all wallets into an archive to your downloads directory", + "importSingle":"Import a wallet", + "importSingleDesc":"Imports a single wallet from file", + "importArchive":"Import archive", + "importArchiveDesc":"Imports all valid wallets inside an archive", + "settingsData":"Data", + "selectExportWallet":"Select a wallet to export", + "exportError":"An error occured trying to export wallet", + "exportCompleted":"Export completed", + "importCompleted":"Import completed" } \ No newline at end of file diff --git a/lib/views/home.dart b/lib/views/home.dart index d5a13fc..2bc7a28 100644 --- a/lib/views/home.dart +++ b/lib/views/home.dart @@ -5,12 +5,12 @@ import 'dart:async'; import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_file_dialog/flutter_file_dialog.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:flutter_speed_dial/flutter_speed_dial.dart'; import 'package:flutter_tesseract_ocr/flutter_tesseract_ocr.dart'; import 'package:grouped_list/grouped_list.dart'; -import 'package:image_picker/image_picker.dart'; import 'package:intl/date_symbol_data_local.dart'; import 'package:intl/intl.dart'; import 'package:prasule/api/category.dart'; @@ -125,18 +125,15 @@ class _HomeViewState extends State { SpeedDialChild( child: const Icon(Icons.camera_alt), label: AppLocalizations.of(context).addCamera, - onTap: () async { - final picker = ImagePicker(); - final media = - await picker.pickImage(source: ImageSource.camera); - logger.i(media?.name); + onTap: () { + startOcr(SourceType.camera); }, ), SpeedDialChild( child: const Icon(Icons.image), label: AppLocalizations.of(context).addGallery, onTap: () { - startOcr(ImageSource.gallery); + startOcr(SourceType.photoLibrary); }, ), ], @@ -217,6 +214,7 @@ class _HomeViewState extends State { ), ) .then((value) async { + wallets = await WalletManager.listWallets(); selectedWallet = await WalletManager.loadWallet(selectedWallet!.name); }); @@ -593,7 +591,7 @@ class _HomeViewState extends State { ); } - Future startOcr(ImageSource imgSrc) async { + Future startOcr(SourceType sourceType) async { final availableLanguages = await TessdataApi.getDownloadedData(); if (availableLanguages.isEmpty) { if (!mounted) return; @@ -637,9 +635,11 @@ class _HomeViewState extends State { actions: [ TextButton( onPressed: () async { - final picker = ImagePicker(); - final media = await picker.pickImage(source: imgSrc); - if (media == null) { + final filePath = await FlutterFileDialog.pickFile( + params: OpenFileDialogParams( + dialogType: OpenFileDialogType.image, + sourceType: sourceType)); + if (filePath == null) { if (mounted) Navigator.of(context).pop(); return; } @@ -663,7 +663,7 @@ class _HomeViewState extends State { ), ); final string = await FlutterTesseractOcr.extractText( - media.path, + filePath, language: selected, args: { "psm": "4", @@ -758,23 +758,4 @@ class _HomeViewState extends State { ), ); } - - Future getLostData() async { - final picker = ImagePicker(); - final response = await picker.retrieveLostData(); - if (response.isEmpty) { - return; - } - final files = response.files; - if (files != null) { - logger.i("Found lost files"); - _handleLostFiles(files); - } else { - logger.e(response.exception); - } - } - - void _handleLostFiles(List files) { - // TODO: implement - } } diff --git a/lib/views/settings/settings.dart b/lib/views/settings/settings.dart index 0cce304..92e0ba0 100644 --- a/lib/views/settings/settings.dart +++ b/lib/views/settings/settings.dart @@ -1,9 +1,13 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:prasule/api/wallet_manager.dart'; import 'package:prasule/main.dart'; +import 'package:prasule/pw/platformbutton.dart'; import 'package:prasule/pw/platformroute.dart'; +import 'package:prasule/util/show_message.dart'; import 'package:prasule/views/settings/edit_categories.dart'; import 'package:prasule/views/settings/graph_type.dart'; import 'package:prasule/views/settings/tessdata_list.dart'; @@ -103,6 +107,190 @@ class _SettingsViewState extends State { ), ], ), + SettingsSection( + title: Text(AppLocalizations.of(context).settingsData), + tiles: [ + SettingsTile.navigation( + title: Text(AppLocalizations.of(context).exportSingle), + description: + Text(AppLocalizations.of(context).exportSingleDesc), + onPressed: (ctx) async { + final all = await WalletManager.listWallets(); + if (!ctx.mounted) return; + final w = await showAdaptiveDialog( + context: ctx, + builder: (ctx) => AlertDialog.adaptive( + title: Text( + AppLocalizations.of(context).selectExportWallet, + ), + actions: [ + PlatformButton( + text: AppLocalizations.of(context).cancel, + onPressed: () => Navigator.of(ctx).pop(), + ), + ], + content: SizedBox( + width: MediaQuery.of(context).size.width * 0.7, + height: MediaQuery.of(context).size.height * 0.3, + child: ListView.builder( + itemBuilder: (con, i) => InkWell( + onTap: () => Navigator.of(ctx).pop(all[i].name), + child: Padding( + padding: const EdgeInsets.all(8), + child: Text( + all[i].name, + textAlign: TextAlign.center, + ), + ), + ), + shrinkWrap: true, + itemCount: all.length, + ), + ), + ), + ); + if (w == null) return; + try { + await WalletManager.exportWallet(name: w); + } catch (e) { + if (!context.mounted) return; + unawaited( + showAdaptiveDialog( + context: context, + builder: (ctx) => AlertDialog.adaptive( + title: Text( + AppLocalizations.of(context).exportError, + ), + content: SingleChildScrollView( + child: Flexible( + child: Text(e.toString()), + ), + ), + ), + ), + ); + logger.e(e); + return; + } + if (!ctx.mounted) return; + unawaited( + showMessage( + AppLocalizations.of(ctx).exportCompleted, + ctx, + ), + ); + }, + ), + SettingsTile.navigation( + title: Text(AppLocalizations.of(context).exportArchive), + description: + Text(AppLocalizations.of(context).exportArchiveDesc), + onPressed: (ctx) async { + try { + await WalletManager.exportAllWallets(); + } catch (e) { + if (!ctx.mounted) return; + unawaited( + showAdaptiveDialog( + context: context, + builder: (ctx) => AlertDialog.adaptive( + title: Text( + AppLocalizations.of(context).exportError, + ), + content: SingleChildScrollView( + child: Flexible( + child: Text(e.toString()), + ), + ), + ), + ), + ); + logger.e(e); + return; + } + if (!ctx.mounted) return; + unawaited( + showMessage( + AppLocalizations.of(ctx).exportCompleted, + context, + ), + ); + }, + ), + SettingsTile.navigation( + title: Text(AppLocalizations.of(context).importSingle), + description: + Text(AppLocalizations.of(context).importSingleDesc), + onPressed: (ctx) async { + try { + await WalletManager.importWallet(); + } catch (e) { + if (!ctx.mounted) return; + unawaited( + showAdaptiveDialog( + context: context, + builder: (ctx) => AlertDialog.adaptive( + title: Text( + AppLocalizations.of(context).exportError, + ), + content: SingleChildScrollView( + child: Flexible( + child: Text(e.toString()), + ), + ), + ), + ), + ); + logger.e(e); + return; + } + if (!ctx.mounted) return; + unawaited( + showMessage( + AppLocalizations.of(ctx).importCompleted, + context, + ), + ); + }, + ), + SettingsTile.navigation( + title: Text(AppLocalizations.of(context).importArchive), + description: + Text(AppLocalizations.of(context).importArchiveDesc), + onPressed: (ctx) async { + try { + await WalletManager.importArchive(); + } catch (e) { + if (!ctx.mounted) return; + unawaited( + showAdaptiveDialog( + context: context, + builder: (ctx) => AlertDialog.adaptive( + title: Text( + AppLocalizations.of(context).exportError, + ), + content: SingleChildScrollView( + child: Flexible( + child: Text(e.toString()), + ), + ), + ), + ), + ); + logger.e(e); + return; + } + if (!ctx.mounted) return; + unawaited( + showMessage( + AppLocalizations.of(ctx).importCompleted, + context, + ), + ); + }, + ), + ], + ), ], ), ); diff --git a/pubspec.lock b/pubspec.lock index 759eca0..271729b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -18,7 +18,7 @@ packages: source: hosted version: "6.3.0" archive: - dependency: transitive + dependency: "direct main" description: name: archive sha256: "22600aa1e926be775fa5fe7e6894e7fb3df9efda8891c73f70fb3262399a432d" @@ -177,14 +177,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.7.2" - cross_file: - dependency: transitive - description: - name: cross_file - sha256: fedaadfa3a6996f75211d835aaeb8fede285dae94262485698afd832371b9a5e - url: "https://pub.dev" - source: hosted - version: "0.3.3+8" crypto: dependency: transitive description: @@ -273,38 +265,6 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.0" - file_selector_linux: - dependency: transitive - description: - name: file_selector_linux - sha256: "045d372bf19b02aeb69cacf8b4009555fb5f6f0b7ad8016e5f46dd1387ddd492" - url: "https://pub.dev" - source: hosted - version: "0.9.2+1" - file_selector_macos: - dependency: transitive - description: - name: file_selector_macos - sha256: b15c3da8bd4908b9918111fa486903f5808e388b8d1c559949f584725a6594d6 - url: "https://pub.dev" - source: hosted - version: "0.9.3+3" - file_selector_platform_interface: - dependency: transitive - description: - name: file_selector_platform_interface - sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b - url: "https://pub.dev" - source: hosted - version: "2.6.2" - file_selector_windows: - dependency: transitive - description: - name: file_selector_windows - sha256: d3547240c20cabf205c7c7f01a50ecdbc413755814d6677f3cb366f04abcead0 - url: "https://pub.dev" - source: hosted - version: "0.9.3+1" fixnum: dependency: transitive description: @@ -347,6 +307,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_file_dialog: + dependency: "direct main" + description: + name: flutter_file_dialog + sha256: "9344b8f07be6a1b6f9854b723fb0cf84a8094ba94761af1d213589d3cb087488" + url: "https://pub.dev" + source: hosted + version: "3.0.2" flutter_iconpicker: dependency: "direct main" description: @@ -424,14 +392,6 @@ packages: description: flutter source: sdk version: "0.0.0" - flutter_plugin_android_lifecycle: - dependency: transitive - description: - name: flutter_plugin_android_lifecycle - sha256: b068ffc46f82a55844acfa4fdbb61fad72fa2aef0905548419d97f0f95c456da - url: "https://pub.dev" - source: hosted - version: "2.0.17" flutter_slidable: dependency: "direct main" description: @@ -519,14 +479,6 @@ packages: url: "https://pub.dev" source: hosted version: "5.1.2" - http: - dependency: transitive - description: - name: http - sha256: a2bbf9d017fcced29139daa8ed2bba4ece450ab222871df93ca9eec6f80c34ba - url: "https://pub.dev" - source: hosted - version: "1.2.0" http_multi_server: dependency: transitive description: @@ -551,70 +503,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.4" - image_picker: - dependency: "direct main" - description: - name: image_picker - sha256: "26222b01a0c9a2c8fe02fc90b8208bd3325da5ed1f4a2acabf75939031ac0bdd" - url: "https://pub.dev" - source: hosted - version: "1.0.7" - image_picker_android: - dependency: transitive - description: - name: image_picker_android - sha256: "39f2bfe497e495450c81abcd44b62f56c2a36a37a175da7d137b4454977b51b1" - url: "https://pub.dev" - source: hosted - version: "0.8.9+3" - image_picker_for_web: - dependency: transitive - description: - name: image_picker_for_web - sha256: e2423c53a68b579a7c37a1eda967b8ae536c3d98518e5db95ca1fe5719a730a3 - url: "https://pub.dev" - source: hosted - version: "3.0.2" - image_picker_ios: - dependency: transitive - description: - name: image_picker_ios - sha256: fadafce49e8569257a0cad56d24438a6fa1f0cbd7ee0af9b631f7492818a4ca3 - url: "https://pub.dev" - source: hosted - version: "0.8.9+1" - image_picker_linux: - dependency: transitive - description: - name: image_picker_linux - sha256: "4ed1d9bb36f7cd60aa6e6cd479779cc56a4cb4e4de8f49d487b1aaad831300fa" - url: "https://pub.dev" - source: hosted - version: "0.2.1+1" - image_picker_macos: - dependency: transitive - description: - name: image_picker_macos - sha256: "3f5ad1e8112a9a6111c46d0b57a7be2286a9a07fc6e1976fdf5be2bd31d4ff62" - url: "https://pub.dev" - source: hosted - version: "0.2.1+1" - image_picker_platform_interface: - dependency: transitive - description: - name: image_picker_platform_interface - sha256: fa4e815e6fcada50e35718727d83ba1c92f1edf95c0b4436554cec301b56233b - url: "https://pub.dev" - source: hosted - version: "2.9.3" - image_picker_windows: - dependency: transitive - description: - name: image_picker_windows - sha256: "6ad07afc4eb1bc25f3a01084d28520496c4a3bb0cb13685435838167c9dcedeb" - url: "https://pub.dev" - source: hosted - version: "0.2.1+1" integration_test: dependency: "direct dev" description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 8f456f7..ca3d000 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,6 +13,7 @@ environment: # the latest version available on pub.dev. To see which dependencies have newer # versions available, run `flutter pub outdated`. dependencies: + archive: ^3.4.10 cupertino_icons: ^1.0.2 currency_picker: ^2.0.16 dio: ^5.3.0 @@ -21,6 +22,7 @@ dependencies: flex_color_picker: ^3.3.0 flutter: sdk: flutter + flutter_file_dialog: ^3.0.2 flutter_iconpicker: ^3.2.4 flutter_localizations: sdk: flutter @@ -29,7 +31,6 @@ dependencies: flutter_tesseract_ocr: ^0.4.23 fluttertoast: ^8.2.4 grouped_list: ^5.1.2 - image_picker: ^1.0.1 intl: any introduction_screen: ^3.1.11 json_annotation: ^4.8.1