From 613d9fade398ff3e7127751b5973c53b5207c4c2 Mon Sep 17 00:00:00 2001 From: mateusz-bak <32651935+mateusz-bak@users.noreply.github.com> Date: Sat, 23 Sep 2023 21:42:32 +0100 Subject: [PATCH 01/10] feat: Added CSV export --- assets/translations/en-US.json | 7 +- lib/generated/locale_keys.g.dart | 5 + .../settings_backup_screen.dart | 542 ++++++++++++------ pubspec.lock | 8 + pubspec.yaml | 1 + 5 files changed, 402 insertions(+), 161 deletions(-) diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index 7423be1d..ea14625c 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -223,5 +223,10 @@ "dark_mode_amoled": "AMOLED dark mode", "selected": "Selected", "change_book_type": "Change book type", - "update_successful_message": "Updated successfully!" + "update_successful_message": "Updated successfully!", + "export_successful": "Export successful", + "openreads_backup": "Openreads backup", + "csv": "CSV", + "export_csv": "Export books as a CSV file", + "export_csv_description_1": "Covers are not exported" } diff --git a/lib/generated/locale_keys.g.dart b/lib/generated/locale_keys.g.dart index c3b88faa..fb1a3f7b 100644 --- a/lib/generated/locale_keys.g.dart +++ b/lib/generated/locale_keys.g.dart @@ -230,4 +230,9 @@ abstract class LocaleKeys { static const selected = 'selected'; static const change_book_type = 'change_book_type'; static const update_successful_message = 'update_successful_message'; + static const export_successful = 'export_successful'; + static const openreads_backup = 'openreads_backup'; + static const csv = 'csv'; + static const export_csv = 'export_csv'; + static const export_csv_description_1 = 'export_csv'; } diff --git a/lib/ui/settings_screen/settings_backup_screen.dart b/lib/ui/settings_screen/settings_backup_screen.dart index d0ab5ced..1535f7e8 100644 --- a/lib/ui/settings_screen/settings_backup_screen.dart +++ b/lib/ui/settings_screen/settings_backup_screen.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:archive/archive_io.dart'; +import 'package:csv/csv.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:filesystem_picker/filesystem_picker.dart'; @@ -9,6 +10,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:openreads/core/constants/enums.dart'; import 'package:openreads/logic/bloc/migration_v1_to_v2_bloc/migration_v1_to_v2_bloc.dart'; import 'package:openreads/generated/locale_keys.g.dart'; import 'package:openreads/logic/bloc/challenge_bloc/challenge_bloc.dart'; @@ -45,6 +47,7 @@ class _SettingsBackupScreenState extends State { bool _creatingLocal = false; bool _creatingCloud = false; bool _restoringLocal = false; + bool _exportingCSV = false; int booksBackupLenght = 0; int booksBackupDone = 0; String restoredCounterText = ''; @@ -65,6 +68,22 @@ class _SettingsBackupScreenState extends State { setState(() => _creatingLocal = false); } + _startCSVExport(context) async { + setState(() => _exportingCSV = true); + + DeviceInfoPlugin deviceInfo = DeviceInfoPlugin(); + AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo; + + if (androidInfo.version.sdkInt <= 31) { + await _requestStoragePermission(); + await _createLocalBackup(); + } else { + await _exportCSVWithScopedStorage(); + } + + setState(() => _exportingCSV = false); + } + _startCloudBackup(context) async { setState(() => _creatingCloud = true); @@ -332,6 +351,103 @@ class _SettingsBackupScreenState extends State { } } + Future _prepareCSVExport() async { + try { + await bookCubit.getAllBooks(tags: true); + + final books = await bookCubit.allBooks.first; + final rows = List>.empty(growable: true); + + final firstRow = [ + ('id'), + ('title'), + ('subtitle'), + ('author'), + ('book_type'), + ('description'), + ('pages'), + ('isbn'), + ('olid'), + ('publication_year'), + ('status'), + ('rating'), + ('favourite'), + ('has_cover'), + ('deleted'), + ('start_date'), + ('finish_date'), + ('my_review'), + ('tags'), + ]; + + rows.add(firstRow); + + for (var book in books) { + final newRow = List.empty(growable: true); + + newRow.add(book.id != null ? book.id.toString() : ''); + newRow.add(book.title); + newRow.add(book.subtitle ?? ''); + newRow.add(book.author); + newRow.add(book.bookType == BookType.paper + ? 'paper' + : book.bookType == BookType.ebook + ? 'ebook' + : book.bookType == BookType.audiobook + ? 'audiobook' + : ''); + newRow.add(book.description ?? ''); + newRow.add(book.pages != null ? book.pages.toString() : ''); + newRow.add(book.isbn ?? ''); + newRow.add(book.olid ?? ''); + newRow.add(book.publicationYear != null + ? book.publicationYear.toString() + : ''); + newRow.add(book.status == 0 + ? 'finished' + : book.status == 1 + ? 'in_progress' + : book.status == 2 + ? 'planned' + : book.status == 4 + ? 'abandoned' + : 'unknown'); + + newRow.add(book.rating != null ? (book.rating! / 10).toString() : ''); + newRow.add(book.favourite.toString()); + newRow.add(book.hasCover.toString()); + newRow.add(book.deleted.toString()); + newRow.add( + book.startDate != null ? book.startDate!.toIso8601String() : ''); + newRow.add( + book.finishDate != null ? book.finishDate!.toIso8601String() : ''); + newRow.add(book.myReview ?? ''); + newRow.add(book.tags ?? ''); + + rows.add(newRow); + } + + return const ListToCsvConverter().convert( + rows, + textDelimiter: '"', + textEndDelimiter: '"', + ); + } catch (e) { + setState(() => _creatingLocal = false); + + if (!mounted) return null; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + e.toString(), + ), + ), + ); + + return null; + } + } + Future _getAppVersion() async { PackageInfo packageInfo = await PackageInfo.fromPlatform(); @@ -346,6 +462,15 @@ class _SettingsBackupScreenState extends State { return 'Openreads-$backupDate.backup'; } + Future _prepareCSVExportFileName() async { + final date = DateTime.now(); + + final exportDate = + '${date.year}_${date.month}_${date.day}-${date.hour}_${date.minute}_${date.second}'; + + return 'Openreads-$exportDate.csv'; + } + Future _prepareBackupInfo() async { final appVersion = await _getAppVersion(); @@ -396,6 +521,51 @@ class _SettingsBackupScreenState extends State { } } + Future _exportCSVWithScopedStorage() async { + final csv = await _prepareCSVExport(); + if (csv == null) return; + + final selectedUriDir = await openDocumentTree(); + + if (selectedUriDir == null) { + setState(() => _exportingCSV = false); + return; + } + + final fileName = await _prepareCSVExportFileName(); + + try { + createFileAsBytes( + selectedUriDir, + mimeType: 'text/csv', + displayName: fileName, + bytes: Uint8List.fromList(utf8.encode(csv)), + ); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + LocaleKeys.export_successful.tr(), + ), + ), + ); + } + } catch (e) { + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + e.toString(), + ), + ), + ); + } + + setState(() => _exportingCSV = false); + } + _deleteTmpData(Directory tmpDir) { if (File('${tmpDir.path}/books.backup').existsSync()) { File('${tmpDir.path}/books.backup').deleteSync(); @@ -923,168 +1093,32 @@ class _SettingsBackupScreenState extends State { ), sections: [ SettingsSection( - tiles: [ - SettingsTile( - title: Text( - LocaleKeys.create_local_backup.tr(), - style: const TextStyle( - fontSize: 16, - ), - ), - leading: (_creatingLocal) - ? const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(), - ) - : const Icon(FontAwesomeIcons.solidFloppyDisk), - description: Text( - LocaleKeys.create_local_backup_description.tr(), - ), - onPressed: _startLocalBackup, - ), - SettingsTile( - title: Text( - LocaleKeys.create_cloud_backup.tr(), - style: const TextStyle( - fontSize: 16, - ), - ), - leading: (_creatingCloud) - ? const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(), - ) - : const Icon(FontAwesomeIcons.cloudArrowUp), - description: Text( - LocaleKeys.create_cloud_backup_description.tr(), - ), - onPressed: _startCloudBackup, + title: Text( + LocaleKeys.openreads_backup.tr(), + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, ), - SettingsTile( - title: Text( - LocaleKeys.restore_backup.tr(), - style: const TextStyle( - fontSize: 16, - ), - ), - leading: (_restoringLocal) - ? const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(), - ) - : const Icon(FontAwesomeIcons.arrowUpFromBracket), - description: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - restoredCounterText.isNotEmpty - ? Text( - restoredCounterText, - style: const TextStyle( - fontWeight: FontWeight.bold, - ), - ) - : const SizedBox(), - Text( - '${LocaleKeys.restore_backup_description_1.tr()}\n${LocaleKeys.restore_backup_description_2.tr()}', - ), - ], - ), - onPressed: (context) { - showDialog( - context: context, - builder: (context) { - return Builder(builder: (context) { - return AlertDialog( - title: Text( - LocaleKeys.are_you_sure.tr(), - ), - content: Text( - LocaleKeys.restore_backup_alert_content - .tr(), - ), - actionsAlignment: - MainAxisAlignment.spaceBetween, - actions: [ - FilledButton.tonal( - onPressed: () { - _startLocalRestore(context); - Navigator.of(context).pop(); - }, - child: Text(LocaleKeys.yes.tr()), - ), - FilledButton.tonal( - onPressed: () => - Navigator.of(context).pop(), - child: Text(LocaleKeys.no.tr()), - ), - ], - ); - }); - }, - ); - }, - ), - SettingsTile( - title: Text( - LocaleKeys.migration_v1_to_v2_retrigger.tr(), - style: TextStyle( - fontSize: 16, - color: widget.autoMigrationV1ToV2 - ? Theme.of(context).colorScheme.primary - : null, - ), - ), - leading: Icon( - FontAwesomeIcons.wrench, - color: widget.autoMigrationV1ToV2 - ? Theme.of(context).colorScheme.primary - : null, - ), - description: Text( - LocaleKeys.migration_v1_to_v2_retrigger_description - .tr(), - style: TextStyle( - color: widget.autoMigrationV1ToV2 - ? Theme.of(context).colorScheme.primary - : null, - ), - ), - onPressed: (context) { - showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: Text( - LocaleKeys.are_you_sure.tr(), - ), - content: Text( - LocaleKeys.restore_backup_alert_content - .tr(), - ), - actionsAlignment: - MainAxisAlignment.spaceBetween, - actions: [ - FilledButton.tonal( - onPressed: () { - _startMigrationV1ToV2(); - Navigator.of(context).pop(); - }, - child: Text(LocaleKeys.yes.tr()), - ), - FilledButton.tonal( - onPressed: () => - Navigator.of(context).pop(), - child: Text(LocaleKeys.no.tr()), - ), - ], - ); - }, - ); - }, + ), + tiles: [ + _buildCreateLocalBackup(), + _buildCreateCloudBackup(), + _buildRestoreBackup(), + _buildV1ToV2Migration(context), + ], + ), + SettingsSection( + title: Text( + LocaleKeys.csv.tr(), + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, ), + ), + tiles: [ + _buildExportAsCSV(), ], ), ], @@ -1116,4 +1150,192 @@ class _SettingsBackupScreenState extends State { ), ); } + + SettingsTile _buildV1ToV2Migration(BuildContext context) { + return SettingsTile( + title: Text( + LocaleKeys.migration_v1_to_v2_retrigger.tr(), + style: TextStyle( + fontSize: 16, + color: widget.autoMigrationV1ToV2 + ? Theme.of(context).colorScheme.primary + : null, + ), + ), + leading: Icon( + FontAwesomeIcons.wrench, + color: widget.autoMigrationV1ToV2 + ? Theme.of(context).colorScheme.primary + : null, + ), + description: Text( + LocaleKeys.migration_v1_to_v2_retrigger_description.tr(), + style: TextStyle( + color: widget.autoMigrationV1ToV2 + ? Theme.of(context).colorScheme.primary + : null, + ), + ), + onPressed: (context) { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text( + LocaleKeys.are_you_sure.tr(), + ), + content: Text( + LocaleKeys.restore_backup_alert_content.tr(), + ), + actionsAlignment: MainAxisAlignment.spaceBetween, + actions: [ + FilledButton.tonal( + onPressed: () { + _startMigrationV1ToV2(); + Navigator.of(context).pop(); + }, + child: Text(LocaleKeys.yes.tr()), + ), + FilledButton.tonal( + onPressed: () => Navigator.of(context).pop(), + child: Text(LocaleKeys.no.tr()), + ), + ], + ); + }, + ); + }, + ); + } + + SettingsTile _buildRestoreBackup() { + return SettingsTile( + title: Text( + LocaleKeys.restore_backup.tr(), + style: const TextStyle( + fontSize: 16, + ), + ), + leading: (_restoringLocal) + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(), + ) + : const Icon(FontAwesomeIcons.arrowUpFromBracket), + description: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + restoredCounterText.isNotEmpty + ? Text( + restoredCounterText, + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ) + : const SizedBox(), + Text( + '${LocaleKeys.restore_backup_description_1.tr()}\n${LocaleKeys.restore_backup_description_2.tr()}', + ), + ], + ), + onPressed: (context) { + showDialog( + context: context, + builder: (context) { + return Builder(builder: (context) { + return AlertDialog( + title: Text( + LocaleKeys.are_you_sure.tr(), + ), + content: Text( + LocaleKeys.restore_backup_alert_content.tr(), + ), + actionsAlignment: MainAxisAlignment.spaceBetween, + actions: [ + FilledButton.tonal( + onPressed: () { + _startLocalRestore(context); + Navigator.of(context).pop(); + }, + child: Text(LocaleKeys.yes.tr()), + ), + FilledButton.tonal( + onPressed: () => Navigator.of(context).pop(), + child: Text(LocaleKeys.no.tr()), + ), + ], + ); + }); + }, + ); + }, + ); + } + + SettingsTile _buildCreateCloudBackup() { + return SettingsTile( + title: Text( + LocaleKeys.create_cloud_backup.tr(), + style: const TextStyle( + fontSize: 16, + ), + ), + leading: (_creatingCloud) + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(), + ) + : const Icon(FontAwesomeIcons.cloudArrowUp), + description: Text( + LocaleKeys.create_cloud_backup_description.tr(), + ), + onPressed: _startCloudBackup, + ); + } + + SettingsTile _buildCreateLocalBackup() { + return SettingsTile( + title: Text( + LocaleKeys.create_local_backup.tr(), + style: const TextStyle( + fontSize: 16, + ), + ), + leading: (_creatingLocal) + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(), + ) + : const Icon(FontAwesomeIcons.solidFloppyDisk), + description: Text( + LocaleKeys.create_local_backup_description.tr(), + ), + onPressed: _startLocalBackup, + ); + } + + SettingsTile _buildExportAsCSV() { + return SettingsTile( + title: Text( + LocaleKeys.export_csv.tr(), + style: const TextStyle( + fontSize: 16, + ), + ), + leading: (_exportingCSV) + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(), + ) + : const Icon(FontAwesomeIcons.fileCsv), + description: Text( + LocaleKeys.export_csv_description_1.tr(), + ), + onPressed: _startCSVExport, + ); + } } diff --git a/pubspec.lock b/pubspec.lock index 84b904e1..78990c85 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -196,6 +196,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + csv: + dependency: "direct main" + description: + name: csv + sha256: "016b31a51a913744a0a1655c74ff13c9379e1200e246a03d96c81c5d9ed297b5" + url: "https://pub.dev" + source: hosted + version: "5.0.2" cupertino_icons: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 786cb8f5..5543d291 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -61,6 +61,7 @@ dependencies: flex_color_picker: ^3.2.2 flutter_speed_dial: ^7.0.0 blurhash_dart: ^1.2.1 + csv: ^5.0.2 dev_dependencies: flutter_test: From bce5e99a05dc39f22c3f4f9a0c2b67cc9da35b3c Mon Sep 17 00:00:00 2001 From: mateusz-bak <32651935+mateusz-bak@users.noreply.github.com> Date: Sun, 24 Sep 2023 09:11:30 +0100 Subject: [PATCH 02/10] feat: Added CSV export for SDK <= 31 --- .../settings_backup_screen.dart | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/lib/ui/settings_screen/settings_backup_screen.dart b/lib/ui/settings_screen/settings_backup_screen.dart index 1535f7e8..eb4fb7a8 100644 --- a/lib/ui/settings_screen/settings_backup_screen.dart +++ b/lib/ui/settings_screen/settings_backup_screen.dart @@ -76,7 +76,7 @@ class _SettingsBackupScreenState extends State { if (androidInfo.version.sdkInt <= 31) { await _requestStoragePermission(); - await _createLocalBackup(); + await _exportCSV(); } else { await _exportCSVWithScopedStorage(); } @@ -151,6 +151,45 @@ class _SettingsBackupScreenState extends State { } } + _exportCSV() async { + final csv = await _prepareCSVExport(); + if (csv == null) return; + + final exportPath = await _openFolderPicker(); + if (exportPath == null) return; + + final fileName = await _prepareCSVExportFileName(); + final filePath = '$exportPath/$fileName'; + + try { + createFileAsBytes( + Uri(path: filePath), + mimeType: 'text/csv', + displayName: fileName, + bytes: Uint8List.fromList(utf8.encode(csv)), + ); + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + LocaleKeys.backup_successfull.tr(), + ), + ), + ); + } catch (e) { + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + e.toString(), + ), + ), + ); + } + } + Future _getChallengeTargets() async { if (!mounted) return null; From 5e4701581880988fe1c343355adf215cd09811de Mon Sep 17 00:00:00 2001 From: mateusz-bak <32651935+mateusz-bak@users.noreply.github.com> Date: Sun, 24 Sep 2023 19:16:30 +0100 Subject: [PATCH 03/10] feat: Import CSV from Goodreads, code refactor and small fixes. --- assets/translations/en-US.json | 5 +- lib/core/helpers/backup/backup_export.dart | 254 ++++ lib/core/helpers/backup/backup_general.dart | 131 ++ lib/core/helpers/backup/backup_helpers.dart | 5 + lib/core/helpers/backup/backup_import.dart | 449 +++++++ lib/core/helpers/backup/csv_export.dart | 177 +++ .../helpers/backup/csv_goodreads_import.dart | 416 +++++++ lib/generated/locale_keys.g.dart | 5 +- lib/logic/cubit/book_cubit.dart | 9 + .../settings_backup_screen.dart | 1095 +---------------- 10 files changed, 1515 insertions(+), 1031 deletions(-) create mode 100644 lib/core/helpers/backup/backup_export.dart create mode 100644 lib/core/helpers/backup/backup_general.dart create mode 100644 lib/core/helpers/backup/backup_helpers.dart create mode 100644 lib/core/helpers/backup/backup_import.dart create mode 100644 lib/core/helpers/backup/csv_export.dart create mode 100644 lib/core/helpers/backup/csv_goodreads_import.dart diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index ea14625c..df1d2d92 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -228,5 +228,8 @@ "openreads_backup": "Openreads backup", "csv": "CSV", "export_csv": "Export books as a CSV file", - "export_csv_description_1": "Covers are not exported" + "export_csv_description_1": "Covers are not exported", + "import_goodreads_csv": "Import Goodreads CSV", + "choose_not_finished_shelf": "Choose a shelf with not finished books:", + "import_successful": "Import successful" } diff --git a/lib/core/helpers/backup/backup_export.dart b/lib/core/helpers/backup/backup_export.dart new file mode 100644 index 00000000..957a0390 --- /dev/null +++ b/lib/core/helpers/backup/backup_export.dart @@ -0,0 +1,254 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import 'package:archive/archive.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:shared_storage/shared_storage.dart'; + +import 'package:openreads/core/helpers/backup/backup_helpers.dart'; +import 'package:openreads/generated/locale_keys.g.dart'; +import 'package:openreads/logic/bloc/challenge_bloc/challenge_bloc.dart'; +import 'package:openreads/main.dart'; + +class BackupExport { + static createLocalBackupLegacyStorage(BuildContext context) async { + final tmpBackupPath = await prepareTemporaryBackup(context); + if (tmpBackupPath == null) return; + + try { + // ignore: use_build_context_synchronously + final backupPath = await BackupGeneral.openFolderPicker(context); + final fileName = await _prepareBackupFileName(); + + final filePath = '$backupPath/$fileName'; + + File(filePath).writeAsBytesSync(File(tmpBackupPath).readAsBytesSync()); + + // ignore: use_build_context_synchronously + BackupGeneral.showInfoSnackbar( + context, + LocaleKeys.backup_successfull.tr(), + ); + } catch (e) { + // ignore: use_build_context_synchronously + BackupGeneral.showInfoSnackbar( + context, + e.toString(), + ); + } + } + + static Future createLocalBackup(BuildContext context) async { + final tmpBackupPath = await prepareTemporaryBackup(context); + if (tmpBackupPath == null) return; + + final selectedUriDir = await openDocumentTree(); + + if (selectedUriDir == null) { + return; + } + + final fileName = await _prepareBackupFileName(); + + try { + createFileAsBytes( + selectedUriDir, + mimeType: '', + displayName: fileName, + bytes: File(tmpBackupPath).readAsBytesSync(), + ); + + // ignore: use_build_context_synchronously + BackupGeneral.showInfoSnackbar( + context, + LocaleKeys.backup_successfull.tr(), + ); + } catch (e) { + // ignore: use_build_context_synchronously + BackupGeneral.showInfoSnackbar( + context, + e.toString(), + ); + } + } + + static Future prepareTemporaryBackup(BuildContext context) async { + try { + await bookCubit.getAllBooks(tags: true); + + final books = await bookCubit.allBooks.first; + final listOfBookJSONs = List.empty(growable: true); + final coverFiles = List.empty(growable: true); + + for (var book in books) { + // Making sure no covers are stored as JSON + final bookWithCoverNull = book.copyWithNullCover(); + + listOfBookJSONs.add(jsonEncode(bookWithCoverNull.toJSON())); + + // Creating a list of current cover files + if (book.hasCover) { + // Check if cover file exists, if not then skip + if (!File('${appDocumentsDirectory.path}/${book.id}.jpg') + .existsSync()) { + continue; + } + + final coverFile = File( + '${appDocumentsDirectory.path}/${book.id}.jpg', + ); + coverFiles.add(coverFile); + } + + await Future.delayed(const Duration(milliseconds: 50)); + } + + // ignore: use_build_context_synchronously + final challengeTargets = await _getChallengeTargets(context); + + // ignore: use_build_context_synchronously + return await _writeTempBackupFile( + context, + listOfBookJSONs, + challengeTargets, + coverFiles, + ); + } catch (e) { + // ignore: use_build_context_synchronously + BackupGeneral.showInfoSnackbar( + context, + e.toString(), + ); + + return null; + } + } + + static Future _getChallengeTargets(BuildContext context) async { + if (!context.mounted) return null; + + final state = BlocProvider.of(context).state; + + if (state is SetChallengeState) { + return state.yearlyChallenges; + } + + return null; + } + + // Current backup version: 5 + static Future _writeTempBackupFile( + BuildContext context, + List listOfBookJSONs, + String? challengeTargets, + List? coverFiles, + ) async { + final data = listOfBookJSONs.join('@@@@@'); + final fileName = await _prepareBackupFileName(); + final tmpFilePath = '${appTempDirectory.path}/$fileName'; + + try { + // Saving books to temp file + File('${appTempDirectory.path}/books.backup').writeAsStringSync(data); + + // Reading books temp file to memory + final booksBytes = + File('${appTempDirectory.path}/books.backup').readAsBytesSync(); + + final archivedBooks = ArchiveFile( + 'books.backup', + booksBytes.length, + booksBytes, + ); + + // Prepare main archive + final archive = Archive(); + archive.addFile(archivedBooks); + + if (challengeTargets != null) { + // Saving challenges to temp file + File('${appTempDirectory.path}/challenges.backup') + .writeAsStringSync(challengeTargets); + + // Reading challenges temp file to memory + final challengeTargetsBytes = + File('${appTempDirectory.path}/challenges.backup') + .readAsBytesSync(); + + final archivedChallengeTargets = ArchiveFile( + 'challenges.backup', + challengeTargetsBytes.length, + challengeTargetsBytes, + ); + + archive.addFile(archivedChallengeTargets); + } + + // Adding covers to the backup file + if (coverFiles != null && coverFiles.isNotEmpty) { + for (var coverFile in coverFiles) { + final coverBytes = coverFile.readAsBytesSync(); + + final archivedCover = ArchiveFile( + coverFile.path.split('/').last, + coverBytes.length, + coverBytes, + ); + + archive.addFile(archivedCover); + } + } + + // Add info file + final info = await _prepareBackupInfo(); + final infoBytes = utf8.encode(info); + + final archivedInfo = ArchiveFile( + 'info.txt', + infoBytes.length, + infoBytes, + ); + archive.addFile(archivedInfo); + + final encodedArchive = ZipEncoder().encode(archive); + + if (encodedArchive == null) return null; + + File(tmpFilePath).writeAsBytesSync(encodedArchive); + + return tmpFilePath; + } catch (e) { + // ignore: use_build_context_synchronously + BackupGeneral.showInfoSnackbar( + context, + e.toString(), + ); + + return null; + } + } + + static Future _getAppVersion() async { + PackageInfo packageInfo = await PackageInfo.fromPlatform(); + + return packageInfo.version; + } + + static Future _prepareBackupFileName() async { + final date = DateTime.now(); + final backupDate = + '${date.year}_${date.month}_${date.day}-${date.hour}_${date.minute}_${date.second}'; + + return 'Openreads-$backupDate.backup'; + } + + static Future _prepareBackupInfo() async { + final appVersion = await _getAppVersion(); + + return 'App version: $appVersion\nBackup version: 5'; + } +} diff --git a/lib/core/helpers/backup/backup_general.dart b/lib/core/helpers/backup/backup_general.dart new file mode 100644 index 00000000..270a887c --- /dev/null +++ b/lib/core/helpers/backup/backup_general.dart @@ -0,0 +1,131 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:filesystem_picker/filesystem_picker.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import 'package:openreads/generated/locale_keys.g.dart'; + +class BackupGeneral { + static showInfoSnackbar( + BuildContext context, + String message, + ) { + if (!context.mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + ), + ); + } + + static Future requestStoragePermission(BuildContext context) async { + if (await Permission.storage.isPermanentlyDenied) { + // ignore: use_build_context_synchronously + _openSystemSettings(context); + return false; + } else if (await Permission.storage.status.isDenied) { + if (await Permission.storage.request().isGranted) { + return true; + } else { + // ignore: use_build_context_synchronously + _openSystemSettings(context); + return false; + } + } else if (await Permission.storage.status.isGranted) { + return true; + } + return false; + } + + static Future openFolderPicker(BuildContext context) async { + if (!context.mounted) return null; + + return await FilesystemPicker.open( + context: context, + title: LocaleKeys.choose_backup_folder.tr(), + pickText: LocaleKeys.save_file_to_this_folder.tr(), + fsType: FilesystemType.folder, + rootDirectory: Directory('/storage/emulated/0/'), + contextActions: [ + FilesystemPickerNewFolderContextAction( + icon: Icon( + Icons.create_new_folder, + color: Theme.of(context).colorScheme.primary, + size: 24, + ), + ), + ], + theme: FilesystemPickerTheme( + backgroundColor: Theme.of(context).colorScheme.surface, + pickerAction: FilesystemPickerActionThemeData( + foregroundColor: Theme.of(context).colorScheme.primary, + backgroundColor: Theme.of(context).colorScheme.surface, + ), + fileList: FilesystemPickerFileListThemeData( + iconSize: 24, + upIconSize: 24, + checkIconSize: 24, + folderTextStyle: const TextStyle(fontSize: 16), + ), + topBar: FilesystemPickerTopBarThemeData( + backgroundColor: Theme.of(context).colorScheme.surface, + titleTextStyle: const TextStyle(fontSize: 18), + elevation: 0, + shadowColor: Colors.transparent, + ), + ), + ); + } + + static Future openFilePicker(BuildContext context) async { + if (!context.mounted) return null; + + return await FilesystemPicker.open( + context: context, + title: LocaleKeys.choose_backup_file.tr(), + pickText: LocaleKeys.use_this_file.tr(), + fsType: FilesystemType.file, + rootDirectory: Directory('/storage/emulated/0/'), + fileTileSelectMode: FileTileSelectMode.wholeTile, + allowedExtensions: ['.backup', '.zip', '.png'], + theme: FilesystemPickerTheme( + backgroundColor: Theme.of(context).colorScheme.surface, + fileList: FilesystemPickerFileListThemeData( + iconSize: 24, + upIconSize: 24, + checkIconSize: 24, + folderTextStyle: const TextStyle(fontSize: 16), + ), + topBar: FilesystemPickerTopBarThemeData( + titleTextStyle: const TextStyle(fontSize: 18), + shadowColor: Colors.transparent, + foregroundColor: Theme.of(context).colorScheme.onSurface, + ), + ), + ); + } + + static _openSystemSettings(BuildContext context) { + if (!context.mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + LocaleKeys.need_storage_permission.tr(), + ), + action: SnackBarAction( + label: LocaleKeys.open_settings.tr(), + onPressed: () { + if (context.mounted) { + openAppSettings(); + } + }, + ), + ), + ); + } +} diff --git a/lib/core/helpers/backup/backup_helpers.dart b/lib/core/helpers/backup/backup_helpers.dart new file mode 100644 index 00000000..fbec817f --- /dev/null +++ b/lib/core/helpers/backup/backup_helpers.dart @@ -0,0 +1,5 @@ +export 'backup_export.dart'; +export 'backup_general.dart'; +export 'backup_import.dart'; +export 'csv_export.dart'; +export 'csv_goodreads_import.dart'; diff --git a/lib/core/helpers/backup/backup_import.dart b/lib/core/helpers/backup/backup_import.dart new file mode 100644 index 00000000..b5944700 --- /dev/null +++ b/lib/core/helpers/backup/backup_import.dart @@ -0,0 +1,449 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:archive/archive_io.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:shared_storage/shared_storage.dart'; +import 'package:sqflite/sqflite.dart'; +import 'package:path/path.dart' as path; +import 'package:blurhash_dart/blurhash_dart.dart' as blurhash_dart; +import 'package:image/image.dart' as img; + +import 'package:openreads/core/helpers/backup/backup_helpers.dart'; +import 'package:openreads/generated/locale_keys.g.dart'; +import 'package:openreads/logic/bloc/challenge_bloc/challenge_bloc.dart'; +import 'package:openreads/main.dart'; +import 'package:openreads/model/book.dart'; +import 'package:openreads/model/book_from_backup_v3.dart'; +import 'package:openreads/model/year_from_backup_v3.dart'; +import 'package:openreads/model/yearly_challenge.dart'; + +class BackupImport { + static restoreLocalBackupLegacyStorage(BuildContext context) async { + final tmpDir = appTempDirectory.absolute; + _deleteTmpData(tmpDir); + + try { + final archivePath = await BackupGeneral.openFilePicker(context); + if (archivePath == null) return; + + if (archivePath.contains('Openreads-4-')) { + // ignore: use_build_context_synchronously + await _restoreBackupVersion4( + context, + archivePath: archivePath, + tmpPath: tmpDir, + ); + } else if (archivePath.contains('openreads_3_')) { + // ignore: use_build_context_synchronously + await _restoreBackupVersion3( + context, + archivePath: archivePath, + tmpPath: tmpDir, + ); + } else { + // Because file name is not always possible to read + // backups v5 is recognized by the info.txt file + final infoFileVersion = + _checkInfoFileVersion(File(archivePath).readAsBytesSync(), tmpDir); + if (infoFileVersion == 5) { + // ignore: use_build_context_synchronously + await _restoreBackupVersion5( + context, + File(archivePath).readAsBytesSync(), + tmpDir, + ); + } else { + // ignore: use_build_context_synchronously + BackupGeneral.showInfoSnackbar( + context, + LocaleKeys.backup_not_valid.tr(), + ); + return; + } + } + + // ignore: use_build_context_synchronously + BackupGeneral.showInfoSnackbar( + context, + LocaleKeys.restore_successfull.tr(), + ); + + if (context.mounted) { + Navigator.of(context).pop(); + Navigator.of(context).pop(); + } + } catch (e) { + // ignore: use_build_context_synchronously + BackupGeneral.showInfoSnackbar(context, e.toString()); + } + } + + static Future restoreLocalBackup(BuildContext context) async { + final tmpDir = appTempDirectory.absolute; + _deleteTmpData(tmpDir); + + final selectedUris = await openDocument(multiple: false); + + if (selectedUris == null || selectedUris.isEmpty) { + return; + } + + final selectedUriDir = selectedUris[0]; + final backupFile = await getDocumentContent(selectedUriDir); + + // Backups v3 and v4 are recognized by their file name + if (selectedUriDir.path.contains('Openreads-4-')) { + // ignore: use_build_context_synchronously + await _restoreBackupVersion4( + context, + archiveFile: backupFile, + tmpPath: tmpDir, + ); + } else if (selectedUriDir.path.contains('openreads_3_')) { + // ignore: use_build_context_synchronously + await _restoreBackupVersion3( + context, + archiveFile: backupFile, + tmpPath: tmpDir, + ); + } else { + // Because file name is not always possible to read + // backups v5 is recognized by the info.txt file + final infoFileVersion = _checkInfoFileVersion(backupFile, tmpDir); + if (infoFileVersion == 5) { + // ignore: use_build_context_synchronously + await _restoreBackupVersion5(context, backupFile, tmpDir); + } else { + // ignore: use_build_context_synchronously + BackupGeneral.showInfoSnackbar( + context, + LocaleKeys.backup_not_valid.tr(), + ); + return; + } + } + + // ignore: use_build_context_synchronously + BackupGeneral.showInfoSnackbar( + context, + LocaleKeys.restore_successfull.tr(), + ); + + if (context.mounted) { + Navigator.of(context).pop(); + Navigator.of(context).pop(); + } + } + + static _restoreBackupVersion5( + BuildContext context, + Uint8List? archiveBytes, + Directory tmpPath, + ) async { + if (archiveBytes == null) { + return; + } + + final archive = ZipDecoder().decodeBytes(archiveBytes); + extractArchiveToDisk(archive, tmpPath.path); + + var booksData = File('${tmpPath.path}/books.backup').readAsStringSync(); + + // First backups of v2 used ||||| as separation between books, + // That caused problems because this is as well a separator for tags + // Now @@@@@ is a separator for books but some backups may need below line + booksData = booksData.replaceAll('}|||||{', '}@@@@@{'); + + final bookStrings = booksData.split('@@@@@'); + + await bookCubit.removeAllBooks(); + + for (var bookString in bookStrings) { + try { + final newBook = Book.fromJSON(jsonDecode(bookString)); + File? coverFile; + + if (newBook.hasCover) { + coverFile = File('${tmpPath.path}/${newBook.id}.jpg'); + + // Making sure cover is not stored in the Book object + newBook.cover = null; + } + + bookCubit.addBook( + newBook, + refreshBooks: false, + coverFile: coverFile, + ); + } catch (e) { + // ignore: use_build_context_synchronously + BackupGeneral.showInfoSnackbar(context, e.toString()); + } + } + + bookCubit.getAllBooksByStatus(); + bookCubit.getAllBooks(); + + // No changes in challenges since v4 so we can use the v4 method + // ignore: use_build_context_synchronously + await _restoreChallengeTargetsV4(context, tmpPath); + } + + static _restoreBackupVersion4( + BuildContext context, { + String? archivePath, + Uint8List? archiveFile, + required Directory tmpPath, + }) async { + late Uint8List archiveBytes; + + if (archivePath != null) { + archiveBytes = File(archivePath).readAsBytesSync(); + } else if (archiveFile != null) { + archiveBytes = archiveFile; + } else { + return; + } + + final archive = ZipDecoder().decodeBytes(archiveBytes); + extractArchiveToDisk(archive, tmpPath.path); + + var booksData = File('${tmpPath.path}/books.backup').readAsStringSync(); + + // First backups of v2 used ||||| as separation between books, + // That caused problems because this is as well a separator for tags + // Now @@@@@ is a separator for books but some backups may need below line + booksData = booksData.replaceAll('}|||||{', '}@@@@@{'); + + final books = booksData.split('@@@@@'); + + await bookCubit.removeAllBooks(); + + for (var book in books) { + try { + final newBook = Book.fromJSON(jsonDecode(book)); + File? coverFile; + + if (newBook.cover != null) { + coverFile = File('${tmpPath.path}/${newBook.id}.jpg'); + coverFile.writeAsBytesSync(newBook.cover!); + + newBook.hasCover = true; + newBook.cover = null; + } + + bookCubit.addBook( + newBook, + refreshBooks: false, + coverFile: coverFile, + ); + } catch (e) { + // ignore: use_build_context_synchronously + BackupGeneral.showInfoSnackbar(context, e.toString()); + } + } + + bookCubit.getAllBooksByStatus(); + bookCubit.getAllBooks(); + + // ignore: use_build_context_synchronously + await _restoreChallengeTargetsV4(context, tmpPath); + } + + static _restoreBackupVersion3( + BuildContext context, { + String? archivePath, + Uint8List? archiveFile, + required Directory tmpPath, + }) async { + // int booksBackupLenght = 0; + // int booksBackupDone = 0; + + late Uint8List archiveBytes; + + if (archivePath != null) { + archiveBytes = File(archivePath).readAsBytesSync(); + } else if (archiveFile != null) { + archiveBytes = archiveFile; + } else { + return; + } + + final archive = ZipDecoder().decodeBytes(archiveBytes); + + extractArchiveToDisk(archive, tmpPath.path); + + final booksDB = await openDatabase(path.join(tmpPath.path, 'books.sql')); + final result = await booksDB.query("Book"); + + final List books = result.isNotEmpty + ? result.map((item) => BookFromBackupV3.fromJson(item)).toList() + : []; + + await bookCubit.removeAllBooks(); + + for (var book in books) { + // ignore: use_build_context_synchronously + await _addBookFromBackupV3(context, book); + } + + bookCubit.getAllBooksByStatus(); + bookCubit.getAllBooks(); + + // ignore: use_build_context_synchronously + await _restoreChallengeTargetsFromBackup3(context, tmpPath); + } + + static _restoreChallengeTargetsFromBackup3( + BuildContext context, Directory tmpPath) async { + if (!context.mounted) return; + if (!File(path.join(tmpPath.path, 'years.sql')).existsSync()) return; + + final booksDB = await openDatabase(path.join(tmpPath.path, 'years.sql')); + final result = await booksDB.query("Year"); + + final List? years = result.isNotEmpty + ? result.map((item) => YearFromBackupV3.fromJson(item)).toList() + : null; + + if (!context.mounted) return; + BlocProvider.of(context).add( + const RemoveAllChallengesEvent(), + ); + + String newChallenges = ''; + + if (years == null) return; + + for (var year in years) { + if (newChallenges.isEmpty) { + if (year.year != null) { + final newJson = json + .encode(YearlyChallenge( + year: int.parse(year.year!), + books: year.yearChallengeBooks, + pages: year.yearChallengePages, + ).toJSON()) + .toString(); + + newChallenges = [ + newJson, + ].join('|||||'); + } + } else { + final splittedNewChallenges = newChallenges.split('|||||'); + + final newJson = json + .encode(YearlyChallenge( + year: int.parse(year.year!), + books: year.yearChallengeBooks, + pages: year.yearChallengePages, + ).toJSON()) + .toString(); + + splittedNewChallenges.add(newJson); + + newChallenges = splittedNewChallenges.join('|||||'); + } + } + + BlocProvider.of(context).add( + RestoreChallengesEvent( + challenges: newChallenges, + ), + ); + } + + static Future _addBookFromBackupV3( + BuildContext context, BookFromBackupV3 book) async { + final tmpDir = (appTempDirectory).absolute; + + final blurHash = await compute(_generateBlurHash, book.bookCoverImg); + final newBook = Book.fromBookFromBackupV3(book, blurHash); + + File? coverFile; + + if (newBook.cover != null) { + coverFile = File('${tmpDir.path}/${newBook.id}.jpg'); + coverFile.writeAsBytesSync(newBook.cover!); + + newBook.hasCover = true; + newBook.cover = null; + } + + bookCubit.addBook(newBook, refreshBooks: false, coverFile: coverFile); + + if (!context.mounted) return; + } + + static String? _generateBlurHash(Uint8List? cover) { + if (cover == null) return null; + + return blurhash_dart.BlurHash.encode( + img.decodeImage(cover)!, + numCompX: 4, + numCompY: 3, + ).hash; + } + + static _restoreChallengeTargetsV4( + BuildContext context, + Directory tmpPath, + ) async { + if (!context.mounted) return; + + if (File('${tmpPath.path}/challenges.backup').existsSync()) { + final challengesData = + File('${tmpPath.path}/challenges.backup').readAsStringSync(); + + BlocProvider.of(context).add( + const RemoveAllChallengesEvent(), + ); + + BlocProvider.of(context).add( + RestoreChallengesEvent( + challenges: challengesData, + ), + ); + } else { + BlocProvider.of(context).add( + const RemoveAllChallengesEvent(), + ); + } + } + + // Open the info.txt file and check the backup version + static int? _checkInfoFileVersion(Uint8List? backupFile, Directory tmpDir) { + if (backupFile == null) return null; + + final archive = ZipDecoder().decodeBytes(backupFile); + extractArchiveToDisk(archive, tmpDir.path); + + final infoFile = File('${tmpDir.path}/info.txt'); + if (!infoFile.existsSync()) return null; + + final infoFileContent = infoFile.readAsStringSync(); + final infoFileContentSplitted = infoFileContent.split('\n'); + + if (infoFileContentSplitted.isEmpty) return null; + + final infoFileVersion = infoFileContentSplitted[1].split(': ')[1]; + + return int.tryParse(infoFileVersion); + } + + static _deleteTmpData(Directory tmpDir) { + if (File('${tmpDir.path}/books.backup').existsSync()) { + File('${tmpDir.path}/books.backup').deleteSync(); + } + + if (File('${tmpDir.path}/challenges.backup').existsSync()) { + File('${tmpDir.path}/challenges.backup').deleteSync(); + } + } +} diff --git a/lib/core/helpers/backup/csv_export.dart b/lib/core/helpers/backup/csv_export.dart new file mode 100644 index 00000000..abdd30b1 --- /dev/null +++ b/lib/core/helpers/backup/csv_export.dart @@ -0,0 +1,177 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:csv/csv.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:shared_storage/shared_storage.dart'; + +import 'package:openreads/core/constants/enums.dart'; +import 'package:openreads/core/helpers/backup/backup_helpers.dart'; +import 'package:openreads/generated/locale_keys.g.dart'; +import 'package:openreads/main.dart'; + +class CSVExport { + static exportCSVLegacyStorage(BuildContext context) async { + final csv = await _prepareCSVExport(context); + if (csv == null) return; + + // ignore: use_build_context_synchronously + final exportPath = await BackupGeneral.openFolderPicker(context); + if (exportPath == null) return; + + final fileName = await _prepareCSVExportFileName(); + final filePath = '$exportPath/$fileName'; + + try { + createFileAsBytes( + Uri(path: filePath), + mimeType: 'text/csv', + displayName: fileName, + bytes: Uint8List.fromList(utf8.encode(csv)), + ); + + // ignore: use_build_context_synchronously + BackupGeneral.showInfoSnackbar( + context, + LocaleKeys.export_successful.tr(), + ); + } catch (e) { + // ignore: use_build_context_synchronously + BackupGeneral.showInfoSnackbar( + context, + e.toString(), + ); + } + } + + static Future exportCSV(BuildContext context) async { + final csv = await _prepareCSVExport(context); + if (csv == null) return; + + final selectedUriDir = await openDocumentTree(); + + if (selectedUriDir == null) { + return; + } + + final fileName = await _prepareCSVExportFileName(); + + try { + createFileAsBytes( + selectedUriDir, + mimeType: 'text/csv', + displayName: fileName, + bytes: Uint8List.fromList(utf8.encode(csv)), + ); + + // ignore: use_build_context_synchronously + BackupGeneral.showInfoSnackbar( + context, + LocaleKeys.export_successful.tr(), + ); + } catch (e) { + // ignore: use_build_context_synchronously + BackupGeneral.showInfoSnackbar(context, e.toString()); + } + } + + static Future _prepareCSVExport(BuildContext context) async { + try { + await bookCubit.getAllBooks(tags: true); + + final books = await bookCubit.allBooks.first; + final rows = List>.empty(growable: true); + + final firstRow = [ + ('id'), + ('title'), + ('subtitle'), + ('author'), + ('book_type'), + ('description'), + ('pages'), + ('isbn'), + ('olid'), + ('publication_year'), + ('status'), + ('rating'), + ('favourite'), + ('has_cover'), + ('deleted'), + ('start_date'), + ('finish_date'), + ('my_review'), + ('tags'), + ]; + + rows.add(firstRow); + + for (var book in books) { + final newRow = List.empty(growable: true); + + newRow.add(book.id != null ? book.id.toString() : ''); + newRow.add(book.title); + newRow.add(book.subtitle ?? ''); + newRow.add(book.author); + newRow.add(book.bookType == BookType.paper + ? 'paper' + : book.bookType == BookType.ebook + ? 'ebook' + : book.bookType == BookType.audiobook + ? 'audiobook' + : ''); + newRow.add(book.description ?? ''); + newRow.add(book.pages != null ? book.pages.toString() : ''); + newRow.add(book.isbn ?? ''); + newRow.add(book.olid ?? ''); + newRow.add(book.publicationYear != null + ? book.publicationYear.toString() + : ''); + newRow.add(book.status == 0 + ? 'finished' + : book.status == 1 + ? 'in_progress' + : book.status == 2 + ? 'planned' + : book.status == 4 + ? 'abandoned' + : 'unknown'); + + newRow.add(book.rating != null ? (book.rating! / 10).toString() : ''); + newRow.add(book.favourite.toString()); + newRow.add(book.hasCover.toString()); + newRow.add(book.deleted.toString()); + newRow.add( + book.startDate != null ? book.startDate!.toIso8601String() : ''); + newRow.add( + book.finishDate != null ? book.finishDate!.toIso8601String() : ''); + newRow.add(book.myReview ?? ''); + newRow.add(book.tags ?? ''); + + rows.add(newRow); + } + + return const ListToCsvConverter().convert( + rows, + textDelimiter: '"', + textEndDelimiter: '"', + ); + } catch (e) { + // ignore: use_build_context_synchronously + BackupGeneral.showInfoSnackbar(context, e.toString()); + + return null; + } + } + + static Future _prepareCSVExportFileName() async { + final date = DateTime.now(); + + final exportDate = + '${date.year}_${date.month}_${date.day}-${date.hour}_${date.minute}_${date.second}'; + + return 'Openreads-$exportDate.csv'; + } +} diff --git a/lib/core/helpers/backup/csv_goodreads_import.dart b/lib/core/helpers/backup/csv_goodreads_import.dart new file mode 100644 index 00000000..18ebaeb7 --- /dev/null +++ b/lib/core/helpers/backup/csv_goodreads_import.dart @@ -0,0 +1,416 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:csv/csv.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:shared_storage/shared_storage.dart'; + +import 'package:openreads/core/constants/enums.dart'; +import 'package:openreads/core/helpers/backup/backup_helpers.dart'; +import 'package:openreads/generated/locale_keys.g.dart'; +import 'package:openreads/model/book.dart'; +import 'package:openreads/main.dart'; + +class CSVGoodreadsImport { + static importGoodreadsCSVLegacyStorage(BuildContext context) async { + try { + final csvPath = await BackupGeneral.openFilePicker(context); + if (csvPath == null) return; + + final csvBytes = await File(csvPath).readAsBytes(); + + // ignore: use_build_context_synchronously + final books = await _parseGoodreadsCSV(context, csvBytes); + await bookCubit.importAdditionalBooks(books); + + // ignore: use_build_context_synchronously + BackupGeneral.showInfoSnackbar( + context, + LocaleKeys.import_successful.tr(), + ); + } catch (e) { + // ignore: use_build_context_synchronously + BackupGeneral.showInfoSnackbar(context, e.toString()); + } + } + + static Future importGoodreadsCSV(BuildContext context) async { + try { + final csvURI = await openDocument(multiple: false); + if (csvURI == null || csvURI.isEmpty) return; + + final csvBytes = await getDocumentContent(csvURI[0]); + if (csvBytes == null) return; + + // ignore: use_build_context_synchronously + final books = await _parseGoodreadsCSV(context, csvBytes); + await bookCubit.importAdditionalBooks(books); + + // ignore: use_build_context_synchronously + BackupGeneral.showInfoSnackbar( + context, + LocaleKeys.import_successful.tr(), + ); + } catch (e) { + // ignore: use_build_context_synchronously + BackupGeneral.showInfoSnackbar(context, e.toString()); + } + } + + static Future> _parseGoodreadsCSV( + BuildContext context, + Uint8List csvBytes, + ) async { + final books = List.empty(growable: true); + + final csvString = utf8.decode(csvBytes); + final csv = const CsvToListConverter().convert(csvString, eol: '\n'); + + final headers = csv[0]; + + // Get propositions for which exclusive shelf is "not finished" + final notFinishedShelfPropositions = _getNotFinishedShelfPropositions(csv); + String? notFinishedShelf; + + // If there is only one proposition for "not finished" shelf + // and it is one of the most popular ones, then use it + notFinishedShelf = _getDefaultNotFinishedShelf( + notFinishedShelfPropositions, + ); + + // show dialog to choose which shelf is "not finished" + if (notFinishedShelfPropositions.isNotEmpty && notFinishedShelf == null) { + notFinishedShelf = await _askUserForNotFinshedShelf( + context, + notFinishedShelfPropositions, + ); + } + + for (var i = 0; i < csv.length; i++) { + // Skip first row with headers + if (i == 0) continue; + + // ignore: use_build_context_synchronously + final book = _parseGoodreadsBook( + context, + i, + csv: csv, + headers: headers, + notFinishedShelf: notFinishedShelf, + ); + + if (book != null) { + books.add(book); + } + } + + return books; + } + + static List _getNotFinishedShelfPropositions( + List> csv, + ) { + final headers = csv[0]; + + final notFinishedShelfPropositions = List.empty(growable: true); + for (var i = 0; i < csv.length; i++) { + if (i == 0) continue; + + final exclusiveShelf = csv[i][headers.indexOf('Exclusive Shelf')]; + + // If the exclusive shelf is one of the default ones, then skip + if (exclusiveShelf == "read" || + exclusiveShelf == "currently-reading" || + exclusiveShelf == "to-read") { + continue; + } + + if (!notFinishedShelfPropositions.contains(exclusiveShelf)) { + notFinishedShelfPropositions.add(exclusiveShelf); + } + } + + return notFinishedShelfPropositions; + } + + static String? _getDefaultNotFinishedShelf( + List notFinishedShelfPropositions, + ) { + if (notFinishedShelfPropositions.length == 1) { + if (notFinishedShelfPropositions[0] == 'abandoned') { + return 'abandoned'; + } else if (notFinishedShelfPropositions[0] == 'did-not-finish') { + return 'did-not-finish'; + } + } + + return null; + } + + static Future _askUserForNotFinshedShelf( + BuildContext context, + List notFinishedShelfPropositions, + ) async { + if (!context.mounted) return null; + + return await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text( + LocaleKeys.choose_not_finished_shelf.tr(), + ), + content: SingleChildScrollView( + child: ListBody( + children: notFinishedShelfPropositions + .map( + (e) => ListTile( + title: Text(e), + onTap: () => Navigator.of(context).pop(e), + ), + ) + .toList(), + ), + ), + ); + }, + ); + } + + static Book? _parseGoodreadsBook( + BuildContext context, + int i, { + required List csv, + required List headers, + required String? notFinishedShelf, + }) { + if (!context.mounted) return null; + + try { + return Book( + title: _getTitle(i, csv, headers), + author: _getAuthor(i, csv, headers), + isbn: _getISBN(i, csv, headers), + rating: _getRating(i, csv, headers), + pages: _getPages(i, csv, headers), + publicationYear: _getPublicationYear(i, csv, headers), + myReview: _getMyReview(i, csv, headers), + status: _getStatus(i, csv, headers, notFinishedShelf: notFinishedShelf), + tags: _getTags(i, csv, headers, notFinishedShelf: notFinishedShelf), + finishDate: _getFinishDate(i, csv, headers), + bookType: _getBookType(i, csv, headers), + ); + } catch (e) { + BackupGeneral.showInfoSnackbar(context, e.toString()); + + return null; + } + } + + static String? _getISBN(int i, List> csv, List headers) { + // Example ISBN fields: + // ="0300259360" + // ="" + final isbn10 = csv[i][headers.indexOf('ISBN')] + .toString() + .replaceAll("\"", "") + .replaceAll("=", ""); + + // Example ISBN13 fields: + // ="9780300259360" + // ="" + final isbn13 = csv[i][headers.indexOf('ISBN13')] + .toString() + .replaceAll("\"", "") + .replaceAll("=", ""); + + if (isbn13.isNotEmpty) { + return isbn13; + } else if (isbn10.isNotEmpty) { + return isbn10; + } else { + return null; + } + } + + static String _getAuthor(int i, List> csv, List headers) { + String author = csv[i][headers.indexOf('Author')].toString(); + if (csv[i][headers.indexOf('Additional Authors')].toString().isNotEmpty) { + author += ', ${csv[i][headers.indexOf('Additional Authors')]}'; + } + + return author; + } + + static String _getTitle(int i, List> csv, List headers) { + return csv[i][headers.indexOf('Title')].toString(); + } + + static int? _getRating(int i, List> csv, List headers) { + // Example My Rating fields: + // 0 + // 5 + final rating = int.parse( + csv[i][headers.indexOf('My Rating')].toString(), + ) * + 10; + + return rating != 0 ? rating : null; + } + + static int? _getPages(int i, List> csv, List headers) { + // Example Number of Pages fields: + // 336 + final pagesField = csv[i][headers.indexOf('Number of Pages')].toString(); + return pagesField.isNotEmpty ? int.parse(pagesField) : null; + } + + static int? _getPublicationYear( + int i, List> csv, List headers) { + // Example Year Published fields: + // 2021 + final publicationYearField = + csv[i][headers.indexOf('Year Published')].toString(); + return publicationYearField.isNotEmpty + ? int.parse(publicationYearField) + : null; + } + + static String? _getMyReview(int i, List> csv, List headers) { + // Example My Review fields: + // https://example.com/some_url + // Lorem ipsum dolor sit amet + final myReview = csv[i][headers.indexOf('My Review')].toString(); + + return myReview.isNotEmpty ? myReview : null; + } + + static int _getStatus( + int i, + List> csv, + List headers, { + String? notFinishedShelf, + }) { + // Default Exclusive Shelf fields: + // read + // currently-reading + // to-read + // Custom Exclusive Shelf fields: + // abandoned + // did-not-finish + final exclusiveShelfField = + csv[i][headers.indexOf('Exclusive Shelf')].toString(); + return exclusiveShelfField == 'read' + ? 0 + : exclusiveShelfField == 'currently-reading' + ? 1 + : exclusiveShelfField == 'to-read' + ? 2 + : notFinishedShelf != null && + exclusiveShelfField == notFinishedShelf + ? 3 + : 0; + } + + static String? _getTags( + int i, + List> csv, + List headers, { + String? notFinishedShelf, + }) { + // Example Bookshelves fields: + // read + // currently-reading + // to-read + // lorem ipsum + final bookselvesField = csv[i][headers.indexOf('Bookshelves')].toString(); + List? bookshelves = bookselvesField.isNotEmpty + ? bookselvesField.split(',').map((e) => e.trim()).toList() + : null; + + if (bookshelves != null) { + if (bookshelves.contains('read')) { + bookshelves.remove('read'); + } + if (bookshelves.contains('currently-reading')) { + bookshelves.remove('currently-reading'); + } + if (bookshelves.contains('to-read')) { + bookshelves.remove('to-read'); + } + if (notFinishedShelf != null && bookshelves.contains(notFinishedShelf)) { + bookshelves.remove(notFinishedShelf); + } + + if (bookshelves.isEmpty) { + bookshelves = null; + } + } + + return bookshelves?.join('|||||'); + } + + static DateTime? _getFinishDate( + int i, List> csv, List headers) { + // Example Date Read fields: + // 2021/04/06 + // 2022/04/27 + final dateReadField = csv[i][headers.indexOf('Date Read')].toString(); + DateTime? dateRead; + + if (dateReadField.isNotEmpty) { + final splittedDate = dateReadField.split('/'); + if (splittedDate.length == 3) { + final year = int.parse(splittedDate[0]); + final month = int.parse(splittedDate[1]); + final day = int.parse(splittedDate[2]); + + dateRead = DateTime(year, month, day); + } + } + + return dateRead; + } + + static BookType _getBookType(int i, List> csv, List headers) { + // Example Binding fields: + // Audible Audio + // Audio Cassette + // Audio CD + // Audiobook + // Board Book + // Chapbook + // ebook + // Hardcover + // Kindle Edition + // Mass Market Paperback + // Nook + // Paperback + final bindingField = csv[i][headers.indexOf('Binding')].toString(); + late BookType bookType; + + if (bindingField == 'Audible Audio' || + bindingField == 'Audio Cassette' || + bindingField == 'Audio CD' || + bindingField == 'Audiobook') { + bookType = BookType.audiobook; + } else if (bindingField == 'Kindle Edition' || + bindingField == 'Nook' || + bindingField == 'ebook') { + bookType = BookType.ebook; + } else if (bindingField == 'Hardcover' || + bindingField == 'Mass Market Paperback' || + bindingField == 'Paperback' || + bindingField == 'Chapbook' || + bindingField == 'Board Book') { + bookType = BookType.paper; + } else { + bookType = BookType.paper; + } + + return bookType; + } +} diff --git a/lib/generated/locale_keys.g.dart b/lib/generated/locale_keys.g.dart index fb1a3f7b..7b6cf040 100644 --- a/lib/generated/locale_keys.g.dart +++ b/lib/generated/locale_keys.g.dart @@ -234,5 +234,8 @@ abstract class LocaleKeys { static const openreads_backup = 'openreads_backup'; static const csv = 'csv'; static const export_csv = 'export_csv'; - static const export_csv_description_1 = 'export_csv'; + static const export_csv_description_1 = 'export_csv_description_1'; + static const import_goodreads_csv = 'import_goodreads_csv'; + static const choose_not_finished_shelf = 'choose_not_finished_shelf'; + static const import_successful = 'import_successful'; } diff --git a/lib/logic/cubit/book_cubit.dart b/lib/logic/cubit/book_cubit.dart index 068ff884..1a1414d2 100644 --- a/lib/logic/cubit/book_cubit.dart +++ b/lib/logic/cubit/book_cubit.dart @@ -127,6 +127,15 @@ class BookCubit extends Cubit { } } + Future importAdditionalBooks(List books) async { + for (var book in books) { + await repository.insertBook(book); + } + + getAllBooksByStatus(); + getAllBooks(); + } + Future _saveCoverToStorage(int? bookID, File? coverFile) async { if (bookID == null || coverFile == null) return; diff --git a/lib/ui/settings_screen/settings_backup_screen.dart b/lib/ui/settings_screen/settings_backup_screen.dart index eb4fb7a8..ac7e5adb 100644 --- a/lib/ui/settings_screen/settings_backup_screen.dart +++ b/lib/ui/settings_screen/settings_backup_screen.dart @@ -1,35 +1,16 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:archive/archive_io.dart'; -import 'package:csv/csv.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:filesystem_picker/filesystem_picker.dart'; -import 'package:flutter/foundation.dart'; + import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:openreads/core/constants/enums.dart'; +import 'package:openreads/core/helpers/backup/backup_helpers.dart'; import 'package:openreads/logic/bloc/migration_v1_to_v2_bloc/migration_v1_to_v2_bloc.dart'; import 'package:openreads/generated/locale_keys.g.dart'; -import 'package:openreads/logic/bloc/challenge_bloc/challenge_bloc.dart'; import 'package:openreads/logic/bloc/theme_bloc/theme_bloc.dart'; -import 'package:openreads/main.dart'; -import 'package:openreads/model/book.dart'; -import 'package:openreads/model/book_from_backup_v3.dart'; -import 'package:openreads/model/year_from_backup_v3.dart'; -import 'package:openreads/model/yearly_challenge.dart'; import 'package:openreads/ui/welcome_screen/widgets/widgets.dart'; -import 'package:package_info_plus/package_info_plus.dart'; -import 'package:path/path.dart' as path; -import 'package:permission_handler/permission_handler.dart'; import 'package:settings_ui/settings_ui.dart'; import 'package:share_plus/share_plus.dart'; -import 'package:shared_storage/shared_storage.dart'; -import 'package:sqflite/sqflite.dart'; -import 'package:blurhash_dart/blurhash_dart.dart' as blurhash_dart; -import 'package:image/image.dart' as img; class SettingsBackupScreen extends StatefulWidget { SettingsBackupScreen({ @@ -48,1053 +29,89 @@ class _SettingsBackupScreenState extends State { bool _creatingCloud = false; bool _restoringLocal = false; bool _exportingCSV = false; - int booksBackupLenght = 0; - int booksBackupDone = 0; + bool _importingGoodreadsCSV = false; + String restoredCounterText = ''; - _startLocalBackup(context) async { - setState(() => _creatingLocal = true); + late DeviceInfoPlugin deviceInfo; + late AndroidDeviceInfo androidInfo; - DeviceInfoPlugin deviceInfo = DeviceInfoPlugin(); - AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo; + _startCreatingLocalBackup(context) async { + setState(() => _creatingLocal = true); if (androidInfo.version.sdkInt <= 31) { - await _requestStoragePermission(); - await _createLocalBackup(); + await BackupGeneral.requestStoragePermission(context); + await BackupExport.createLocalBackupLegacyStorage(context); } else { - await _createLocalBackupWithScopedStorage(); + await BackupExport.createLocalBackup(context); } setState(() => _creatingLocal = false); } - _startCSVExport(context) async { + _startExportingCSV(context) async { setState(() => _exportingCSV = true); - DeviceInfoPlugin deviceInfo = DeviceInfoPlugin(); - AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo; - if (androidInfo.version.sdkInt <= 31) { - await _requestStoragePermission(); - await _exportCSV(); + await BackupGeneral.requestStoragePermission(context); + await CSVExport.exportCSVLegacyStorage(context); } else { - await _exportCSVWithScopedStorage(); - } - - setState(() => _exportingCSV = false); - } - - _startCloudBackup(context) async { - setState(() => _creatingCloud = true); - - final tmpBackupPath = await _prepareTemporaryBackup(); - if (tmpBackupPath == null) return; - - Share.shareXFiles([ - XFile(tmpBackupPath), - ]); - - setState(() => _creatingCloud = false); - } - - _openSystemSettings() { - if (!mounted) return; - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - LocaleKeys.need_storage_permission.tr(), - ), - action: SnackBarAction( - label: LocaleKeys.open_settings.tr(), - onPressed: () { - if (mounted) { - openAppSettings(); - } - }, - ), - ), - ); - } - - _createLocalBackup() async { - final tmpBackupPath = await _prepareTemporaryBackup(); - if (tmpBackupPath == null) return; - - try { - final backupPath = await _openFolderPicker(); - final fileName = await _prepareBackupFileName(); - - final filePath = '$backupPath/$fileName'; - - File(filePath).writeAsBytesSync(File(tmpBackupPath).readAsBytesSync()); - - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - LocaleKeys.backup_successfull.tr(), - ), - ), - ); - } catch (e) { - setState(() => _creatingLocal = false); - if (!mounted) return; - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - e.toString(), - ), - ), - ); - } - } - - _exportCSV() async { - final csv = await _prepareCSVExport(); - if (csv == null) return; - - final exportPath = await _openFolderPicker(); - if (exportPath == null) return; - - final fileName = await _prepareCSVExportFileName(); - final filePath = '$exportPath/$fileName'; - - try { - createFileAsBytes( - Uri(path: filePath), - mimeType: 'text/csv', - displayName: fileName, - bytes: Uint8List.fromList(utf8.encode(csv)), - ); - - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - LocaleKeys.backup_successfull.tr(), - ), - ), - ); - } catch (e) { - if (!mounted) return; - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - e.toString(), - ), - ), - ); - } - } - - Future _getChallengeTargets() async { - if (!mounted) return null; - - final state = BlocProvider.of(context).state; - - if (state is SetChallengeState) { - return state.yearlyChallenges; - } - - return null; - } - - // Current backup version: 5 - Future _writeTempBackupFile( - List listOfBookJSONs, - String? challengeTargets, - List? coverFiles, - ) async { - final data = listOfBookJSONs.join('@@@@@'); - final fileName = await _prepareBackupFileName(); - final tmpFilePath = '${appTempDirectory.path}/$fileName'; - - try { - // Saving books to temp file - File('${appTempDirectory.path}/books.backup').writeAsStringSync(data); - - // Reading books temp file to memory - final booksBytes = - File('${appTempDirectory.path}/books.backup').readAsBytesSync(); - - final archivedBooks = ArchiveFile( - 'books.backup', - booksBytes.length, - booksBytes, - ); - - // Prepare main archive - final archive = Archive(); - archive.addFile(archivedBooks); - - if (challengeTargets != null) { - // Saving challenges to temp file - File('${appTempDirectory.path}/challenges.backup') - .writeAsStringSync(challengeTargets); - - // Reading challenges temp file to memory - final challengeTargetsBytes = - File('${appTempDirectory.path}/challenges.backup') - .readAsBytesSync(); - - final archivedChallengeTargets = ArchiveFile( - 'challenges.backup', - challengeTargetsBytes.length, - challengeTargetsBytes, - ); - - archive.addFile(archivedChallengeTargets); - } - - // Adding covers to the backup file - if (coverFiles != null && coverFiles.isNotEmpty) { - for (var coverFile in coverFiles) { - final coverBytes = coverFile.readAsBytesSync(); - - final archivedCover = ArchiveFile( - coverFile.path.split('/').last, - coverBytes.length, - coverBytes, - ); - - archive.addFile(archivedCover); - } - } - - // Add info file - final info = await _prepareBackupInfo(); - final infoBytes = utf8.encode(info); - - final archivedInfo = ArchiveFile( - 'info.txt', - infoBytes.length, - infoBytes, - ); - archive.addFile(archivedInfo); - - final encodedArchive = ZipEncoder().encode(archive); - - if (encodedArchive == null) return null; - - File(tmpFilePath).writeAsBytesSync(encodedArchive); - - return tmpFilePath; - } catch (e) { - setState(() => _creatingLocal = false); - if (!mounted) return null; - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - e.toString(), - ), - ), - ); - - return null; - } - } - - Future _openFolderPicker() async { - return await FilesystemPicker.open( - context: context, - title: LocaleKeys.choose_backup_folder.tr(), - pickText: LocaleKeys.save_file_to_this_folder.tr(), - fsType: FilesystemType.folder, - rootDirectory: Directory('/storage/emulated/0/'), - contextActions: [ - FilesystemPickerNewFolderContextAction( - icon: Icon( - Icons.create_new_folder, - color: Theme.of(context).colorScheme.primary, - size: 24, - ), - ), - ], - theme: FilesystemPickerTheme( - backgroundColor: Theme.of(context).colorScheme.surface, - pickerAction: FilesystemPickerActionThemeData( - foregroundColor: Theme.of(context).colorScheme.primary, - backgroundColor: Theme.of(context).colorScheme.surface, - ), - fileList: FilesystemPickerFileListThemeData( - iconSize: 24, - upIconSize: 24, - checkIconSize: 24, - folderTextStyle: const TextStyle(fontSize: 16), - ), - topBar: FilesystemPickerTopBarThemeData( - backgroundColor: Theme.of(context).colorScheme.surface, - titleTextStyle: const TextStyle(fontSize: 18), - elevation: 0, - shadowColor: Colors.transparent, - ), - ), - ); - } - - Future _prepareTemporaryBackup() async { - try { - await bookCubit.getAllBooks(tags: true); - - final books = await bookCubit.allBooks.first; - final listOfBookJSONs = List.empty(growable: true); - final coverFiles = List.empty(growable: true); - - for (var book in books) { - // Making sure no covers are stored as JSON - final bookWithCoverNull = book.copyWithNullCover(); - - listOfBookJSONs.add(jsonEncode(bookWithCoverNull.toJSON())); - - // Creating a list of current cover files - if (book.hasCover) { - // Check if cover file exists, if not then skip - if (!File('${appDocumentsDirectory.path}/${book.id}.jpg') - .existsSync()) { - continue; - } - - final coverFile = File( - '${appDocumentsDirectory.path}/${book.id}.jpg', - ); - coverFiles.add(coverFile); - } - - await Future.delayed(const Duration(milliseconds: 50)); - } - - final challengeTargets = await _getChallengeTargets(); - - return await _writeTempBackupFile( - listOfBookJSONs, - challengeTargets, - coverFiles, - ); - } catch (e) { - setState(() => _creatingLocal = false); - - if (!mounted) return null; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - e.toString(), - ), - ), - ); - - return null; - } - } - - Future _prepareCSVExport() async { - try { - await bookCubit.getAllBooks(tags: true); - - final books = await bookCubit.allBooks.first; - final rows = List>.empty(growable: true); - - final firstRow = [ - ('id'), - ('title'), - ('subtitle'), - ('author'), - ('book_type'), - ('description'), - ('pages'), - ('isbn'), - ('olid'), - ('publication_year'), - ('status'), - ('rating'), - ('favourite'), - ('has_cover'), - ('deleted'), - ('start_date'), - ('finish_date'), - ('my_review'), - ('tags'), - ]; - - rows.add(firstRow); - - for (var book in books) { - final newRow = List.empty(growable: true); - - newRow.add(book.id != null ? book.id.toString() : ''); - newRow.add(book.title); - newRow.add(book.subtitle ?? ''); - newRow.add(book.author); - newRow.add(book.bookType == BookType.paper - ? 'paper' - : book.bookType == BookType.ebook - ? 'ebook' - : book.bookType == BookType.audiobook - ? 'audiobook' - : ''); - newRow.add(book.description ?? ''); - newRow.add(book.pages != null ? book.pages.toString() : ''); - newRow.add(book.isbn ?? ''); - newRow.add(book.olid ?? ''); - newRow.add(book.publicationYear != null - ? book.publicationYear.toString() - : ''); - newRow.add(book.status == 0 - ? 'finished' - : book.status == 1 - ? 'in_progress' - : book.status == 2 - ? 'planned' - : book.status == 4 - ? 'abandoned' - : 'unknown'); - - newRow.add(book.rating != null ? (book.rating! / 10).toString() : ''); - newRow.add(book.favourite.toString()); - newRow.add(book.hasCover.toString()); - newRow.add(book.deleted.toString()); - newRow.add( - book.startDate != null ? book.startDate!.toIso8601String() : ''); - newRow.add( - book.finishDate != null ? book.finishDate!.toIso8601String() : ''); - newRow.add(book.myReview ?? ''); - newRow.add(book.tags ?? ''); - - rows.add(newRow); - } - - return const ListToCsvConverter().convert( - rows, - textDelimiter: '"', - textEndDelimiter: '"', - ); - } catch (e) { - setState(() => _creatingLocal = false); - - if (!mounted) return null; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - e.toString(), - ), - ), - ); - - return null; - } - } - - Future _getAppVersion() async { - PackageInfo packageInfo = await PackageInfo.fromPlatform(); - - return packageInfo.version; - } - - Future _prepareBackupFileName() async { - final date = DateTime.now(); - final backupDate = - '${date.year}_${date.month}_${date.day}-${date.hour}_${date.minute}_${date.second}'; - - return 'Openreads-$backupDate.backup'; - } - - Future _prepareCSVExportFileName() async { - final date = DateTime.now(); - - final exportDate = - '${date.year}_${date.month}_${date.day}-${date.hour}_${date.minute}_${date.second}'; - - return 'Openreads-$exportDate.csv'; - } - - Future _prepareBackupInfo() async { - final appVersion = await _getAppVersion(); - - return 'App version: $appVersion\nBackup version: 5'; - } - - Future _createLocalBackupWithScopedStorage() async { - final tmpBackupPath = await _prepareTemporaryBackup(); - if (tmpBackupPath == null) return; - - final selectedUriDir = await openDocumentTree(); - - if (selectedUriDir == null) { - setState(() => _creatingLocal = false); - return; - } - - final fileName = await _prepareBackupFileName(); - - try { - createFileAsBytes( - selectedUriDir, - mimeType: '', - displayName: fileName, - bytes: File(tmpBackupPath).readAsBytesSync(), - ); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - LocaleKeys.backup_successfull.tr(), - ), - ), - ); - } - } catch (e) { - setState(() => _creatingLocal = false); - if (!mounted) return; - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - e.toString(), - ), - ), - ); - } - } - - Future _exportCSVWithScopedStorage() async { - final csv = await _prepareCSVExport(); - if (csv == null) return; - - final selectedUriDir = await openDocumentTree(); - - if (selectedUriDir == null) { - setState(() => _exportingCSV = false); - return; - } - - final fileName = await _prepareCSVExportFileName(); - - try { - createFileAsBytes( - selectedUriDir, - mimeType: 'text/csv', - displayName: fileName, - bytes: Uint8List.fromList(utf8.encode(csv)), - ); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - LocaleKeys.export_successful.tr(), - ), - ), - ); - } - } catch (e) { - if (!mounted) return; - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - e.toString(), - ), - ), - ); + await CSVExport.exportCSV(context); } setState(() => _exportingCSV = false); } - _deleteTmpData(Directory tmpDir) { - if (File('${tmpDir.path}/books.backup').existsSync()) { - File('${tmpDir.path}/books.backup').deleteSync(); - } + _startImportingGoodreadsCSV(context) async { + setState(() => _importingGoodreadsCSV = true); - if (File('${tmpDir.path}/challenges.backup').existsSync()) { - File('${tmpDir.path}/challenges.backup').deleteSync(); - } - } - - Future _restoreLocalBackupWithScopedStorage() async { - final tmpDir = appTempDirectory.absolute; - _deleteTmpData(tmpDir); - - final selectedUris = await openDocument(multiple: false); - - if (selectedUris == null || selectedUris.isEmpty) { - setState(() => _restoringLocal = false); - return; - } - - final selectedUriDir = selectedUris[0]; - final backupFile = await getDocumentContent(selectedUriDir); - - // Backups v3 and v4 are recognized by their file name - if (selectedUriDir.path.contains('Openreads-4-')) { - await restoreBackupVersion4(archiveFile: backupFile, tmpPath: tmpDir); - } else if (selectedUriDir.path.contains('openreads_3_')) { - await restoreBackupVersion3(archiveFile: backupFile, tmpPath: tmpDir); + if (androidInfo.version.sdkInt <= 31) { + await BackupGeneral.requestStoragePermission(context); + await CSVGoodreadsImport.importGoodreadsCSVLegacyStorage(context); } else { - // Because file name is not always possible to read - // backups v5 is recognized by the info.txt file - final infoFileVersion = _checkInfoFileVersion(backupFile, tmpDir); - if (infoFileVersion == 5) { - await _restoreBackupVersion5(backupFile, tmpDir); - } else { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - LocaleKeys.backup_not_valid.tr(), - ), - ), - ); - } + await CSVGoodreadsImport.importGoodreadsCSV(context); } - setState(() => _restoringLocal = false); - - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - LocaleKeys.restore_successfull.tr(), - ), - ), - ); - - Navigator.of(context).pop(); - Navigator.of(context).pop(); + setState(() => _importingGoodreadsCSV = false); } - // Open the info.txt file and check the backup version - int? _checkInfoFileVersion(Uint8List? backupFile, Directory tmpDir) { - if (backupFile == null) return null; - - final archive = ZipDecoder().decodeBytes(backupFile); - extractArchiveToDisk(archive, tmpDir.path); - - final infoFile = File('${tmpDir.path}/info.txt'); - if (!infoFile.existsSync()) return null; - - final infoFileContent = infoFile.readAsStringSync(); - final infoFileContentSplitted = infoFileContent.split('\n'); - - if (infoFileContentSplitted.isEmpty) return null; + _startCreatingCloudBackup(context) async { + setState(() => _creatingCloud = true); - final infoFileVersion = infoFileContentSplitted[1].split(': ')[1]; + final tmpBackupPath = await BackupExport.prepareTemporaryBackup(context); + if (tmpBackupPath == null) return; - return int.tryParse(infoFileVersion); - } + Share.shareXFiles([ + XFile(tmpBackupPath), + ]); - Future _requestStoragePermission() async { - if (await Permission.storage.isPermanentlyDenied) { - _openSystemSettings(); - return false; - } else if (await Permission.storage.status.isDenied) { - if (await Permission.storage.request().isGranted) { - return true; - } else { - _openSystemSettings(); - return false; - } - } else if (await Permission.storage.status.isGranted) { - return true; - } - return false; + setState(() => _creatingCloud = false); } - _startLocalRestore(context) async { + _startRestoringLocalBackup(context) async { setState(() => _restoringLocal = true); - DeviceInfoPlugin deviceInfo = DeviceInfoPlugin(); - AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo; - if (androidInfo.version.sdkInt <= 31) { - await _requestStoragePermission(); - await _restoreLocalBackup(); + await BackupGeneral.requestStoragePermission(context); + await BackupImport.restoreLocalBackupLegacyStorage(context); } else { - await _restoreLocalBackupWithScopedStorage(); + await BackupImport.restoreLocalBackup(context); } setState(() => _restoringLocal = false); } - _restoreLocalBackup() async { - final tmpDir = appTempDirectory.absolute; - _deleteTmpData(tmpDir); - - try { - final archivePath = await _openFilePicker(); - if (archivePath == null) return; - - if (archivePath.contains('Openreads-4-')) { - await restoreBackupVersion4(archivePath: archivePath, tmpPath: tmpDir); - } else if (archivePath.contains('openreads_3_')) { - await restoreBackupVersion3(archivePath: archivePath, tmpPath: tmpDir); - } else { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - LocaleKeys.backup_not_valid.tr(), - ), - ), - ); - - return; - } - - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - LocaleKeys.restore_successfull.tr(), - ), - ), - ); - - Navigator.of(context).pop(); - Navigator.of(context).pop(); - } catch (e) { - setState(() => _restoringLocal = false); - if (!mounted) return; - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - e.toString(), - ), - ), - ); - } - } - - _restoreBackupVersion5( - Uint8List? archiveBytes, - Directory tmpPath, - ) async { - if (archiveBytes == null) { - setState(() => _restoringLocal = false); - return; - } - - final archive = ZipDecoder().decodeBytes(archiveBytes); - extractArchiveToDisk(archive, tmpPath.path); - - var booksData = File('${tmpPath.path}/books.backup').readAsStringSync(); - - // First backups of v2 used ||||| as separation between books, - // That caused problems because this is as well a separator for tags - // Now @@@@@ is a separator for books but some backups may need below line - booksData = booksData.replaceAll('}|||||{', '}@@@@@{'); - - final bookStrings = booksData.split('@@@@@'); - - await bookCubit.removeAllBooks(); - - for (var bookString in bookStrings) { - try { - final newBook = Book.fromJSON(jsonDecode(bookString)); - File? coverFile; - - if (newBook.hasCover) { - coverFile = File('${tmpPath.path}/${newBook.id}.jpg'); - - // Making sure cover is not stored in the Book object - newBook.cover = null; - } - - bookCubit.addBook( - newBook, - refreshBooks: false, - coverFile: coverFile, - ); - } catch (e) { - if (!mounted) return; - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - e.toString(), - ), - ), - ); - - setState(() => _restoringLocal = false); - } - } - - bookCubit.getAllBooksByStatus(); - bookCubit.getAllBooks(); - - // No changes in challenges since v4 so we can use the v4 method - await _restoreChallengeTargetsV4(tmpPath); - } - - restoreBackupVersion4({ - String? archivePath, - Uint8List? archiveFile, - required Directory tmpPath, - }) async { - late Uint8List archiveBytes; - - if (archivePath != null) { - archiveBytes = File(archivePath).readAsBytesSync(); - } else if (archiveFile != null) { - archiveBytes = archiveFile; - } else { - setState(() => _restoringLocal = false); - - return; - } - - final archive = ZipDecoder().decodeBytes(archiveBytes); - extractArchiveToDisk(archive, tmpPath.path); - - var booksData = File('${tmpPath.path}/books.backup').readAsStringSync(); - - // First backups of v2 used ||||| as separation between books, - // That caused problems because this is as well a separator for tags - // Now @@@@@ is a separator for books but some backups may need below line - booksData = booksData.replaceAll('}|||||{', '}@@@@@{'); - - final books = booksData.split('@@@@@'); - - await bookCubit.removeAllBooks(); - - for (var book in books) { - try { - final newBook = Book.fromJSON(jsonDecode(book)); - File? coverFile; - - if (newBook.cover != null) { - coverFile = File('${tmpPath.path}/${newBook.id}.jpg'); - coverFile.writeAsBytesSync(newBook.cover!); - - newBook.hasCover = true; - newBook.cover = null; - } - - bookCubit.addBook( - newBook, - refreshBooks: false, - coverFile: coverFile, - ); - } catch (e) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - e.toString(), - ), - ), - ); - - setState(() => _restoringLocal = false); - } - } - - bookCubit.getAllBooksByStatus(); - bookCubit.getAllBooks(); - - await _restoreChallengeTargetsV4(tmpPath); - } - - restoreBackupVersion3({ - String? archivePath, - Uint8List? archiveFile, - required Directory tmpPath, - }) async { - late Uint8List archiveBytes; - - if (archivePath != null) { - archiveBytes = File(archivePath).readAsBytesSync(); - } else if (archiveFile != null) { - archiveBytes = archiveFile; - } else { - setState(() => _restoringLocal = false); - - return; - } - - final archive = ZipDecoder().decodeBytes(archiveBytes); - - extractArchiveToDisk(archive, tmpPath.path); - - final booksDB = await openDatabase(path.join(tmpPath.path, 'books.sql')); - final result = await booksDB.query("Book"); - - final List books = result.isNotEmpty - ? result.map((item) => BookFromBackupV3.fromJson(item)).toList() - : []; - - booksBackupLenght = books.length; - booksBackupDone = 0; - - await bookCubit.removeAllBooks(); - - for (var book in books) { - await _addBookFromBackupV3(book); - } - - setState(() { - restoredCounterText = ''; - }); - - bookCubit.getAllBooksByStatus(); - bookCubit.getAllBooks(); - - await _restoreChallengeTargetsFromBackup3(tmpPath); - } - - _restoreChallengeTargetsFromBackup3(Directory tmpPath) async { - if (!mounted) return; - if (!File(path.join(tmpPath.path, 'years.sql')).existsSync()) return; - - final booksDB = await openDatabase(path.join(tmpPath.path, 'years.sql')); - final result = await booksDB.query("Year"); - - final List? years = result.isNotEmpty - ? result.map((item) => YearFromBackupV3.fromJson(item)).toList() - : null; - - BlocProvider.of(context).add( - const RemoveAllChallengesEvent(), - ); - - String newChallenges = ''; - - if (years == null) return; - - for (var year in years) { - if (newChallenges.isEmpty) { - if (year.year != null) { - final newJson = json - .encode(YearlyChallenge( - year: int.parse(year.year!), - books: year.yearChallengeBooks, - pages: year.yearChallengePages, - ).toJSON()) - .toString(); - - newChallenges = [ - newJson, - ].join('|||||'); - } - } else { - final splittedNewChallenges = newChallenges.split('|||||'); - - final newJson = json - .encode(YearlyChallenge( - year: int.parse(year.year!), - books: year.yearChallengeBooks, - pages: year.yearChallengePages, - ).toJSON()) - .toString(); - - splittedNewChallenges.add(newJson); - - newChallenges = splittedNewChallenges.join('|||||'); - } - } - - BlocProvider.of(context).add( - RestoreChallengesEvent( - challenges: newChallenges, - ), + _startMigratingV1ToV2() { + BlocProvider.of(context).add( + StartMigration(context: context, retrigger: true), ); } - Future _addBookFromBackupV3(BookFromBackupV3 book) async { - final tmpDir = (appTempDirectory).absolute; - - final blurHash = await compute(_generateBlurHash, book.bookCoverImg); - final newBook = Book.fromBookFromBackupV3(book, blurHash); - - File? coverFile; - - if (newBook.cover != null) { - coverFile = File('${tmpDir.path}/${newBook.id}.jpg'); - coverFile.writeAsBytesSync(newBook.cover!); - - newBook.hasCover = true; - newBook.cover = null; - } - - bookCubit.addBook(newBook, refreshBooks: false, coverFile: coverFile); - booksBackupDone = booksBackupDone + 1; - - if (!mounted) return; - - setState(() { - restoredCounterText = - '${LocaleKeys.restored.tr()} $booksBackupDone/$booksBackupLenght\n'; - }); - } - - static String? _generateBlurHash(Uint8List? cover) { - if (cover == null) return null; - - return blurhash_dart.BlurHash.encode( - img.decodeImage(cover)!, - numCompX: 4, - numCompY: 3, - ).hash; - } - - _restoreChallengeTargetsV4(Directory tmpPath) async { - if (!mounted) return; - - if (File('${tmpPath.path}/challenges.backup').existsSync()) { - final challengesData = - File('${tmpPath.path}/challenges.backup').readAsStringSync(); - - BlocProvider.of(context).add( - const RemoveAllChallengesEvent(), - ); - - BlocProvider.of(context).add( - RestoreChallengesEvent( - challenges: challengesData, - ), - ); - } else { - BlocProvider.of(context).add( - const RemoveAllChallengesEvent(), - ); - } - } - - Future _openFilePicker() async { - return await FilesystemPicker.open( - context: context, - title: LocaleKeys.choose_backup_file.tr(), - pickText: LocaleKeys.use_this_file.tr(), - fsType: FilesystemType.file, - rootDirectory: Directory('/storage/emulated/0/'), - fileTileSelectMode: FileTileSelectMode.wholeTile, - allowedExtensions: ['.backup', '.zip', '.png'], - theme: FilesystemPickerTheme( - backgroundColor: Theme.of(context).colorScheme.surface, - fileList: FilesystemPickerFileListThemeData( - iconSize: 24, - upIconSize: 24, - checkIconSize: 24, - folderTextStyle: const TextStyle(fontSize: 16), - ), - topBar: FilesystemPickerTopBarThemeData( - titleTextStyle: const TextStyle(fontSize: 18), - shadowColor: Colors.transparent, - foregroundColor: Theme.of(context).colorScheme.onSurface, - ), - ), - ); - } + @override + void initState() async { + androidInfo = await DeviceInfoPlugin().androidInfo; - _startMigrationV1ToV2() { - BlocProvider.of(context).add( - StartMigration(context: context, retrigger: true), - ); + super.initState(); } @override @@ -1158,6 +175,7 @@ class _SettingsBackupScreenState extends State { ), tiles: [ _buildExportAsCSV(), + _buildImportGoodreadsCSV(), ], ), ], @@ -1230,7 +248,7 @@ class _SettingsBackupScreenState extends State { actions: [ FilledButton.tonal( onPressed: () { - _startMigrationV1ToV2(); + _startMigratingV1ToV2(); Navigator.of(context).pop(); }, child: Text(LocaleKeys.yes.tr()), @@ -1294,7 +312,7 @@ class _SettingsBackupScreenState extends State { actions: [ FilledButton.tonal( onPressed: () { - _startLocalRestore(context); + _startRestoringLocalBackup(context); Navigator.of(context).pop(); }, child: Text(LocaleKeys.yes.tr()), @@ -1330,7 +348,7 @@ class _SettingsBackupScreenState extends State { description: Text( LocaleKeys.create_cloud_backup_description.tr(), ), - onPressed: _startCloudBackup, + onPressed: _startCreatingCloudBackup, ); } @@ -1352,7 +370,7 @@ class _SettingsBackupScreenState extends State { description: Text( LocaleKeys.create_local_backup_description.tr(), ), - onPressed: _startLocalBackup, + onPressed: _startCreatingLocalBackup, ); } @@ -1374,7 +392,26 @@ class _SettingsBackupScreenState extends State { description: Text( LocaleKeys.export_csv_description_1.tr(), ), - onPressed: _startCSVExport, + onPressed: _startExportingCSV, + ); + } + + SettingsTile _buildImportGoodreadsCSV() { + return SettingsTile( + title: Text( + LocaleKeys.import_goodreads_csv.tr(), + style: const TextStyle( + fontSize: 16, + ), + ), + leading: (_importingGoodreadsCSV) + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(), + ) + : const Icon(FontAwesomeIcons.g), + onPressed: _startImportingGoodreadsCSV, ); } } From 23ec6a925850c842aa8311db0736c3d5badcec15 Mon Sep 17 00:00:00 2001 From: mateusz-bak <32651935+mateusz-bak@users.noreply.github.com> Date: Mon, 25 Sep 2023 23:23:58 +0100 Subject: [PATCH 04/10] feat: Backup Snackbar is not using context anymore --- lib/core/helpers/backup/backup_export.dart | 38 +++---------------- lib/core/helpers/backup/backup_general.dart | 15 ++------ lib/core/helpers/backup/backup_import.dart | 35 ++++------------- lib/core/helpers/backup/csv_export.dart | 32 +++++----------- .../helpers/backup/csv_goodreads_import.dart | 20 +++------- lib/main.dart | 5 ++- .../settings_backup_screen.dart | 2 +- 7 files changed, 36 insertions(+), 111 deletions(-) diff --git a/lib/core/helpers/backup/backup_export.dart b/lib/core/helpers/backup/backup_export.dart index 957a0390..ff72ffc6 100644 --- a/lib/core/helpers/backup/backup_export.dart +++ b/lib/core/helpers/backup/backup_export.dart @@ -28,17 +28,9 @@ class BackupExport { File(filePath).writeAsBytesSync(File(tmpBackupPath).readAsBytesSync()); - // ignore: use_build_context_synchronously - BackupGeneral.showInfoSnackbar( - context, - LocaleKeys.backup_successfull.tr(), - ); + BackupGeneral.showInfoSnackbar(LocaleKeys.backup_successfull.tr()); } catch (e) { - // ignore: use_build_context_synchronously - BackupGeneral.showInfoSnackbar( - context, - e.toString(), - ); + BackupGeneral.showInfoSnackbar(e.toString()); } } @@ -62,17 +54,9 @@ class BackupExport { bytes: File(tmpBackupPath).readAsBytesSync(), ); - // ignore: use_build_context_synchronously - BackupGeneral.showInfoSnackbar( - context, - LocaleKeys.backup_successfull.tr(), - ); + BackupGeneral.showInfoSnackbar(LocaleKeys.backup_successfull.tr()); } catch (e) { - // ignore: use_build_context_synchronously - BackupGeneral.showInfoSnackbar( - context, - e.toString(), - ); + BackupGeneral.showInfoSnackbar(e.toString()); } } @@ -112,17 +96,12 @@ class BackupExport { // ignore: use_build_context_synchronously return await _writeTempBackupFile( - context, listOfBookJSONs, challengeTargets, coverFiles, ); } catch (e) { - // ignore: use_build_context_synchronously - BackupGeneral.showInfoSnackbar( - context, - e.toString(), - ); + BackupGeneral.showInfoSnackbar(e.toString()); return null; } @@ -142,7 +121,6 @@ class BackupExport { // Current backup version: 5 static Future _writeTempBackupFile( - BuildContext context, List listOfBookJSONs, String? challengeTargets, List? coverFiles, @@ -222,11 +200,7 @@ class BackupExport { return tmpFilePath; } catch (e) { - // ignore: use_build_context_synchronously - BackupGeneral.showInfoSnackbar( - context, - e.toString(), - ); + BackupGeneral.showInfoSnackbar(e.toString()); return null; } diff --git a/lib/core/helpers/backup/backup_general.dart b/lib/core/helpers/backup/backup_general.dart index 270a887c..b7220f38 100644 --- a/lib/core/helpers/backup/backup_general.dart +++ b/lib/core/helpers/backup/backup_general.dart @@ -4,22 +4,15 @@ import 'package:flutter/material.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:filesystem_picker/filesystem_picker.dart'; +import 'package:openreads/main.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:openreads/generated/locale_keys.g.dart'; class BackupGeneral { - static showInfoSnackbar( - BuildContext context, - String message, - ) { - if (!context.mounted) return; - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(message), - ), - ); + static showInfoSnackbar(String message) { + final snackBar = SnackBar(content: Text(message)); + snackbarKey.currentState?.showSnackBar(snackBar); } static Future requestStoragePermission(BuildContext context) async { diff --git a/lib/core/helpers/backup/backup_import.dart b/lib/core/helpers/backup/backup_import.dart index b5944700..094c5e88 100644 --- a/lib/core/helpers/backup/backup_import.dart +++ b/lib/core/helpers/backup/backup_import.dart @@ -58,28 +58,19 @@ class BackupImport { tmpDir, ); } else { - // ignore: use_build_context_synchronously - BackupGeneral.showInfoSnackbar( - context, - LocaleKeys.backup_not_valid.tr(), - ); + BackupGeneral.showInfoSnackbar(LocaleKeys.backup_not_valid.tr()); return; } } - // ignore: use_build_context_synchronously - BackupGeneral.showInfoSnackbar( - context, - LocaleKeys.restore_successfull.tr(), - ); + BackupGeneral.showInfoSnackbar(LocaleKeys.restore_successfull.tr()); if (context.mounted) { Navigator.of(context).pop(); Navigator.of(context).pop(); } } catch (e) { - // ignore: use_build_context_synchronously - BackupGeneral.showInfoSnackbar(context, e.toString()); + BackupGeneral.showInfoSnackbar(e.toString()); } } @@ -119,20 +110,12 @@ class BackupImport { // ignore: use_build_context_synchronously await _restoreBackupVersion5(context, backupFile, tmpDir); } else { - // ignore: use_build_context_synchronously - BackupGeneral.showInfoSnackbar( - context, - LocaleKeys.backup_not_valid.tr(), - ); + BackupGeneral.showInfoSnackbar(LocaleKeys.backup_not_valid.tr()); return; } } - // ignore: use_build_context_synchronously - BackupGeneral.showInfoSnackbar( - context, - LocaleKeys.restore_successfull.tr(), - ); + BackupGeneral.showInfoSnackbar(LocaleKeys.restore_successfull.tr()); if (context.mounted) { Navigator.of(context).pop(); @@ -181,8 +164,7 @@ class BackupImport { coverFile: coverFile, ); } catch (e) { - // ignore: use_build_context_synchronously - BackupGeneral.showInfoSnackbar(context, e.toString()); + BackupGeneral.showInfoSnackbar(e.toString()); } } @@ -295,8 +277,7 @@ class BackupImport { bookCubit.getAllBooksByStatus(); bookCubit.getAllBooks(); - // ignore: use_build_context_synchronously - await _restoreChallengeTargetsFromBackup3(context, tmpPath); + BackupGeneral.showInfoSnackbar(e.toString()); } static _restoreChallengeTargetsFromBackup3( @@ -377,8 +358,6 @@ class BackupImport { } bookCubit.addBook(newBook, refreshBooks: false, coverFile: coverFile); - - if (!context.mounted) return; } static String? _generateBlurHash(Uint8List? cover) { diff --git a/lib/core/helpers/backup/csv_export.dart b/lib/core/helpers/backup/csv_export.dart index abdd30b1..251535bb 100644 --- a/lib/core/helpers/backup/csv_export.dart +++ b/lib/core/helpers/backup/csv_export.dart @@ -14,7 +14,7 @@ import 'package:openreads/main.dart'; class CSVExport { static exportCSVLegacyStorage(BuildContext context) async { - final csv = await _prepareCSVExport(context); + final csv = await _prepareCSVExport(); if (csv == null) return; // ignore: use_build_context_synchronously @@ -32,22 +32,14 @@ class CSVExport { bytes: Uint8List.fromList(utf8.encode(csv)), ); - // ignore: use_build_context_synchronously - BackupGeneral.showInfoSnackbar( - context, - LocaleKeys.export_successful.tr(), - ); + BackupGeneral.showInfoSnackbar(LocaleKeys.export_successful.tr()); } catch (e) { - // ignore: use_build_context_synchronously - BackupGeneral.showInfoSnackbar( - context, - e.toString(), - ); + BackupGeneral.showInfoSnackbar(e.toString()); } } - static Future exportCSV(BuildContext context) async { - final csv = await _prepareCSVExport(context); + static Future exportCSV() async { + final csv = await _prepareCSVExport(); if (csv == null) return; final selectedUriDir = await openDocumentTree(); @@ -66,18 +58,13 @@ class CSVExport { bytes: Uint8List.fromList(utf8.encode(csv)), ); - // ignore: use_build_context_synchronously - BackupGeneral.showInfoSnackbar( - context, - LocaleKeys.export_successful.tr(), - ); + BackupGeneral.showInfoSnackbar(LocaleKeys.export_successful.tr()); } catch (e) { - // ignore: use_build_context_synchronously - BackupGeneral.showInfoSnackbar(context, e.toString()); + BackupGeneral.showInfoSnackbar(e.toString()); } } - static Future _prepareCSVExport(BuildContext context) async { + static Future _prepareCSVExport() async { try { await bookCubit.getAllBooks(tags: true); @@ -159,8 +146,7 @@ class CSVExport { textEndDelimiter: '"', ); } catch (e) { - // ignore: use_build_context_synchronously - BackupGeneral.showInfoSnackbar(context, e.toString()); + BackupGeneral.showInfoSnackbar(e.toString()); return null; } diff --git a/lib/core/helpers/backup/csv_goodreads_import.dart b/lib/core/helpers/backup/csv_goodreads_import.dart index 18ebaeb7..addf2902 100644 --- a/lib/core/helpers/backup/csv_goodreads_import.dart +++ b/lib/core/helpers/backup/csv_goodreads_import.dart @@ -26,14 +26,9 @@ class CSVGoodreadsImport { final books = await _parseGoodreadsCSV(context, csvBytes); await bookCubit.importAdditionalBooks(books); - // ignore: use_build_context_synchronously - BackupGeneral.showInfoSnackbar( - context, - LocaleKeys.import_successful.tr(), - ); + BackupGeneral.showInfoSnackbar(LocaleKeys.import_successful.tr()); } catch (e) { - // ignore: use_build_context_synchronously - BackupGeneral.showInfoSnackbar(context, e.toString()); + BackupGeneral.showInfoSnackbar(e.toString()); } } @@ -49,14 +44,9 @@ class CSVGoodreadsImport { final books = await _parseGoodreadsCSV(context, csvBytes); await bookCubit.importAdditionalBooks(books); - // ignore: use_build_context_synchronously - BackupGeneral.showInfoSnackbar( - context, - LocaleKeys.import_successful.tr(), - ); + BackupGeneral.showInfoSnackbar(LocaleKeys.import_successful.tr()); } catch (e) { - // ignore: use_build_context_synchronously - BackupGeneral.showInfoSnackbar(context, e.toString()); + BackupGeneral.showInfoSnackbar(e.toString()); } } @@ -204,7 +194,7 @@ class CSVGoodreadsImport { bookType: _getBookType(i, csv, headers), ); } catch (e) { - BackupGeneral.showInfoSnackbar(context, e.toString()); + BackupGeneral.showInfoSnackbar(e.toString()); return null; } diff --git a/lib/main.dart b/lib/main.dart index e1945d82..890bcae9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -28,6 +28,7 @@ import 'package:path_provider/path_provider.dart'; late BookCubit bookCubit; late Directory appDocumentsDirectory; late Directory appTempDirectory; +late GlobalKey snackbarKey; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -40,8 +41,9 @@ void main() async { appDocumentsDirectory = await getApplicationDocumentsDirectory(); appTempDirectory = await getTemporaryDirectory(); + snackbarKey = GlobalKey(); - bookCubit = BookCubit(); + bookCubit = BookCubit(); // TODO: move to app's context final localeCodes = supportedLocales.map((e) => e.locale).toList(); @@ -197,6 +199,7 @@ class _OpenreadsAppState extends State ), child: MaterialApp( title: 'Openreads', + scaffoldMessengerKey: snackbarKey, builder: (context, child) => MediaQuery( // Temporary fix for https://github.com/AbdulRahmanAlHamali/flutter_typeahead/issues/463 data: MediaQuery.of(context).copyWith( diff --git a/lib/ui/settings_screen/settings_backup_screen.dart b/lib/ui/settings_screen/settings_backup_screen.dart index ac7e5adb..0ef5f1ca 100644 --- a/lib/ui/settings_screen/settings_backup_screen.dart +++ b/lib/ui/settings_screen/settings_backup_screen.dart @@ -56,7 +56,7 @@ class _SettingsBackupScreenState extends State { await BackupGeneral.requestStoragePermission(context); await CSVExport.exportCSVLegacyStorage(context); } else { - await CSVExport.exportCSV(context); + await CSVExport.exportCSV(); } setState(() => _exportingCSV = false); From fd0114ba68f5e157ea866c5c43f8bbc1e5f7c520 Mon Sep 17 00:00:00 2001 From: mateusz-bak <32651935+mateusz-bak@users.noreply.github.com> Date: Mon, 25 Sep 2023 23:25:44 +0100 Subject: [PATCH 05/10] feat: Added progress for restoring books. Only applicable for backups version3. --- lib/core/helpers/backup/backup_import.dart | 81 +++++-- lib/logic/cubit/backup_progress_cubit.dart | 13 + .../settings_backup_screen.dart | 228 ++++++++++-------- 3 files changed, 192 insertions(+), 130 deletions(-) create mode 100644 lib/logic/cubit/backup_progress_cubit.dart diff --git a/lib/core/helpers/backup/backup_import.dart b/lib/core/helpers/backup/backup_import.dart index 094c5e88..524611ef 100644 --- a/lib/core/helpers/backup/backup_import.dart +++ b/lib/core/helpers/backup/backup_import.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:archive/archive_io.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:openreads/logic/cubit/backup_progress_cubit.dart'; import 'package:shared_storage/shared_storage.dart'; import 'package:sqflite/sqflite.dart'; import 'package:path/path.dart' as path; @@ -203,6 +204,12 @@ class BackupImport { booksData = booksData.replaceAll('}|||||{', '}@@@@@{'); final books = booksData.split('@@@@@'); + final booksCount = books.length; + int restoredBooks = 0; + + context + .read() + .updateString('$restoredBooks/$booksCount ${LocaleKeys.restored.tr()}'); await bookCubit.removeAllBooks(); @@ -224,12 +231,19 @@ class BackupImport { refreshBooks: false, coverFile: coverFile, ); - } catch (e) { + + restoredBooks++; + // ignore: use_build_context_synchronously - BackupGeneral.showInfoSnackbar(context, e.toString()); + context.read().updateString( + '$restoredBooks/$booksCount ${LocaleKeys.restored.tr()}'); + } catch (e) { + BackupGeneral.showInfoSnackbar(e.toString()); } } + await Future.delayed(const Duration(milliseconds: 500)); + bookCubit.getAllBooksByStatus(); bookCubit.getAllBooks(); @@ -243,41 +257,56 @@ class BackupImport { Uint8List? archiveFile, required Directory tmpPath, }) async { - // int booksBackupLenght = 0; - // int booksBackupDone = 0; - late Uint8List archiveBytes; + try { + if (archivePath != null) { + archiveBytes = File(archivePath).readAsBytesSync(); + } else if (archiveFile != null) { + archiveBytes = archiveFile; + } else { + return; + } - if (archivePath != null) { - archiveBytes = File(archivePath).readAsBytesSync(); - } else if (archiveFile != null) { - archiveBytes = archiveFile; - } else { - return; - } - - final archive = ZipDecoder().decodeBytes(archiveBytes); + final archive = ZipDecoder().decodeBytes(archiveBytes); - extractArchiveToDisk(archive, tmpPath.path); + extractArchiveToDisk(archive, tmpPath.path); - final booksDB = await openDatabase(path.join(tmpPath.path, 'books.sql')); - final result = await booksDB.query("Book"); + final booksDB = await openDatabase(path.join(tmpPath.path, 'books.sql')); + final result = await booksDB.query("Book"); - final List books = result.isNotEmpty - ? result.map((item) => BookFromBackupV3.fromJson(item)).toList() - : []; + final List books = result.isNotEmpty + ? result.map((item) => BookFromBackupV3.fromJson(item)).toList() + : []; - await bookCubit.removeAllBooks(); + final booksCount = books.length; + int restoredBooks = 0; - for (var book in books) { // ignore: use_build_context_synchronously - await _addBookFromBackupV3(context, book); - } + context.read().updateString( + '$restoredBooks/$booksCount ${LocaleKeys.restored.tr()}'); - bookCubit.getAllBooksByStatus(); - bookCubit.getAllBooks(); + await bookCubit.removeAllBooks(); + + for (var book in books) { + // ignore: use_build_context_synchronously + await _addBookFromBackupV3(context, book); + + restoredBooks += 1; + // ignore: use_build_context_synchronously + context.read().updateString( + '$restoredBooks/$booksCount ${LocaleKeys.restored.tr()}'); + } + + bookCubit.getAllBooksByStatus(); + bookCubit.getAllBooks(); + // ignore: use_build_context_synchronously + await _restoreChallengeTargetsFromBackup3(context, tmpPath); + + await Future.delayed(const Duration(seconds: 2)); + } catch (e) { BackupGeneral.showInfoSnackbar(e.toString()); + } } static _restoreChallengeTargetsFromBackup3( diff --git a/lib/logic/cubit/backup_progress_cubit.dart b/lib/logic/cubit/backup_progress_cubit.dart new file mode 100644 index 00000000..f971b3c6 --- /dev/null +++ b/lib/logic/cubit/backup_progress_cubit.dart @@ -0,0 +1,13 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; + +class BackupProgressCubit extends Cubit { + BackupProgressCubit() : super(null); + + void updateString(String progress) { + emit(progress); + } + + void resetString() { + emit(null); + } +} diff --git a/lib/ui/settings_screen/settings_backup_screen.dart b/lib/ui/settings_screen/settings_backup_screen.dart index 0ef5f1ca..263f2013 100644 --- a/lib/ui/settings_screen/settings_backup_screen.dart +++ b/lib/ui/settings_screen/settings_backup_screen.dart @@ -8,6 +8,7 @@ import 'package:openreads/core/helpers/backup/backup_helpers.dart'; import 'package:openreads/logic/bloc/migration_v1_to_v2_bloc/migration_v1_to_v2_bloc.dart'; import 'package:openreads/generated/locale_keys.g.dart'; import 'package:openreads/logic/bloc/theme_bloc/theme_bloc.dart'; +import 'package:openreads/logic/cubit/backup_progress_cubit.dart'; import 'package:openreads/ui/welcome_screen/widgets/widgets.dart'; import 'package:settings_ui/settings_ui.dart'; import 'package:share_plus/share_plus.dart'; @@ -31,8 +32,6 @@ class _SettingsBackupScreenState extends State { bool _exportingCSV = false; bool _importingGoodreadsCSV = false; - String restoredCounterText = ''; - late DeviceInfoPlugin deviceInfo; late AndroidDeviceInfo androidInfo; @@ -115,96 +114,130 @@ class _SettingsBackupScreenState extends State { } @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text( - LocaleKeys.backup.tr(), - style: const TextStyle(fontSize: 18), - ), - ), - body: Column( - children: [ - Expanded( - child: BlocBuilder( - builder: (context, state) { - late final bool amoledDark; - - if (state is SetThemeState) { - amoledDark = state.amoledDark; - } else { - amoledDark = false; - } - - return SettingsList( - contentPadding: const EdgeInsets.only(top: 10), - darkTheme: SettingsThemeData( - settingsListBackground: amoledDark - ? Colors.black - : Theme.of(context).colorScheme.surface, - ), - lightTheme: SettingsThemeData( - settingsListBackground: - Theme.of(context).colorScheme.surface, - ), - sections: [ - SettingsSection( - title: Text( - LocaleKeys.openreads_backup.tr(), - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, + Widget build(BuildContext originalContext) { + return BlocProvider( + create: (context) => BackupProgressCubit(), + child: Builder(builder: (context) { + return Scaffold( + appBar: AppBar( + title: Text( + LocaleKeys.backup.tr(), + style: const TextStyle(fontSize: 18), + ), + ), + body: Column( + children: [ + BlocBuilder( + builder: (context, state) { + if (state == null) return const SizedBox(); + + return Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const LinearProgressIndicator(), + Container( + padding: const EdgeInsets.fromLTRB(20, 10, 20, 0), + child: Text( + state, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + ], ), ), - tiles: [ - _buildCreateLocalBackup(), - _buildCreateCloudBackup(), - _buildRestoreBackup(), - _buildV1ToV2Migration(context), - ], - ), - SettingsSection( - title: Text( - LocaleKeys.csv.tr(), - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - ), + ], + ); + }, + ), + Expanded( + child: BlocBuilder( + builder: (context, state) { + late final bool amoledDark; + + if (state is SetThemeState) { + amoledDark = state.amoledDark; + } else { + amoledDark = false; + } + + return SettingsList( + contentPadding: const EdgeInsets.only(top: 10), + darkTheme: SettingsThemeData( + settingsListBackground: amoledDark + ? Colors.black + : Theme.of(context).colorScheme.surface, + ), + lightTheme: SettingsThemeData( + settingsListBackground: + Theme.of(context).colorScheme.surface, ), - tiles: [ - _buildExportAsCSV(), - _buildImportGoodreadsCSV(), + sections: [ + SettingsSection( + title: Text( + LocaleKeys.openreads_backup.tr(), + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), + ), + tiles: [ + _buildCreateLocalBackup(), + _buildCreateCloudBackup(), + _buildRestoreBackup(context), + _buildV1ToV2Migration(context), + ], + ), + SettingsSection( + title: Text( + LocaleKeys.csv.tr(), + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), + ), + tiles: [ + _buildExportAsCSV(), + _buildImportGoodreadsCSV(), + ], + ), ], - ), - ], - ); - }, - ), - ), - BlocBuilder( - builder: (context, migrationState) { - if (migrationState is MigrationOnging) { - return MigrationNotification( - done: migrationState.done, - total: migrationState.total, - ); - } else if (migrationState is MigrationFailed) { - return MigrationNotification( - error: migrationState.error, - ); - } else if (migrationState is MigrationSucceded) { - return const MigrationNotification( - success: true, - ); - } - - return const SizedBox(); - }, + ); + }, + ), + ), + BlocBuilder( + builder: (context, migrationState) { + if (migrationState is MigrationOnging) { + return MigrationNotification( + done: migrationState.done, + total: migrationState.total, + ); + } else if (migrationState is MigrationFailed) { + return MigrationNotification( + error: migrationState.error, + ); + } else if (migrationState is MigrationSucceded) { + return const MigrationNotification( + success: true, + ); + } + + return const SizedBox(); + }, + ), + ], ), - ], - ), + ); + }), ); } @@ -265,7 +298,7 @@ class _SettingsBackupScreenState extends State { ); } - SettingsTile _buildRestoreBackup() { + SettingsTile _buildRestoreBackup(BuildContext builderContext) { return SettingsTile( title: Text( LocaleKeys.restore_backup.tr(), @@ -280,21 +313,8 @@ class _SettingsBackupScreenState extends State { child: CircularProgressIndicator(), ) : const Icon(FontAwesomeIcons.arrowUpFromBracket), - description: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - restoredCounterText.isNotEmpty - ? Text( - restoredCounterText, - style: const TextStyle( - fontWeight: FontWeight.bold, - ), - ) - : const SizedBox(), - Text( - '${LocaleKeys.restore_backup_description_1.tr()}\n${LocaleKeys.restore_backup_description_2.tr()}', - ), - ], + description: Text( + '${LocaleKeys.restore_backup_description_1.tr()}\n${LocaleKeys.restore_backup_description_2.tr()}', ), onPressed: (context) { showDialog( @@ -312,7 +332,7 @@ class _SettingsBackupScreenState extends State { actions: [ FilledButton.tonal( onPressed: () { - _startRestoringLocalBackup(context); + _startRestoringLocalBackup(builderContext); Navigator.of(context).pop(); }, child: Text(LocaleKeys.yes.tr()), From ea1860949b28fbdf313e6e2fff02d2cf3db61224 Mon Sep 17 00:00:00 2001 From: mateusz-bak <32651935+mateusz-bak@users.noreply.github.com> Date: Mon, 25 Sep 2023 23:26:04 +0100 Subject: [PATCH 06/10] fix: Removed aync from initState --- lib/ui/settings_screen/settings_backup_screen.dart | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/ui/settings_screen/settings_backup_screen.dart b/lib/ui/settings_screen/settings_backup_screen.dart index 263f2013..44870196 100644 --- a/lib/ui/settings_screen/settings_backup_screen.dart +++ b/lib/ui/settings_screen/settings_backup_screen.dart @@ -106,10 +106,13 @@ class _SettingsBackupScreenState extends State { ); } - @override - void initState() async { + initDeviceInfoPlugin() async { androidInfo = await DeviceInfoPlugin().androidInfo; + } + @override + void initState() { + initDeviceInfoPlugin(); super.initState(); } From 1c204a1457e3459afc50bc1cce6a5936dc66dd9e Mon Sep 17 00:00:00 2001 From: mateusz-bak <32651935+mateusz-bak@users.noreply.github.com> Date: Mon, 25 Sep 2023 23:36:13 +0100 Subject: [PATCH 07/10] refactor: Extracted BookScreenAppBar widget. --- lib/ui/book_screen/book_screen.dart | 165 +--------------- .../widgets/book_screen_app_bar.dart | 181 ++++++++++++++++++ lib/ui/book_screen/widgets/widgets.dart | 1 + 3 files changed, 183 insertions(+), 164 deletions(-) create mode 100644 lib/ui/book_screen/widgets/book_screen_app_bar.dart diff --git a/lib/ui/book_screen/book_screen.dart b/lib/ui/book_screen/book_screen.dart index cd31e798..2e6a5d01 100644 --- a/lib/ui/book_screen/book_screen.dart +++ b/lib/ui/book_screen/book_screen.dart @@ -1,15 +1,11 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:openreads/core/themes/app_theme.dart'; import 'package:openreads/generated/locale_keys.g.dart'; -import 'package:openreads/logic/bloc/theme_bloc/theme_bloc.dart'; import 'package:openreads/logic/cubit/current_book_cubit.dart'; -import 'package:openreads/logic/cubit/edit_book_cubit.dart'; import 'package:openreads/main.dart'; import 'package:openreads/model/book.dart'; -import 'package:openreads/ui/add_book_screen/add_book_screen.dart'; import 'package:openreads/ui/book_screen/widgets/widgets.dart'; class BookScreen extends StatelessWidget { @@ -29,75 +25,6 @@ class BookScreen extends StatelessWidget { context.read().setBook(book); } - _showDeleteRestoreDialog( - BuildContext context, - bool deleted, - bool? deletePermanently, - Book book, - ) { - showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(cornerRadius), - ), - title: Text( - deleted - ? deletePermanently == true - ? LocaleKeys.delete_perm_question.tr() - : LocaleKeys.delete_book_question.tr() - : LocaleKeys.restore_book_question.tr(), - style: const TextStyle(fontSize: 18), - ), - actionsAlignment: MainAxisAlignment.spaceBetween, - actions: [ - FilledButton.tonal( - onPressed: () { - Navigator.of(context).pop(); - }, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10), - child: Text(LocaleKeys.no.tr()), - ), - ), - FilledButton( - onPressed: () { - if (deletePermanently == true) { - _deleteBookPermanently(book); - } else { - _changeDeleteStatus(deleted, book); - } - - Navigator.of(context).pop(); - Navigator.of(context).pop(); - }, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10), - child: Text(LocaleKeys.yes.tr()), - ), - ), - ], - ); - }); - } - - Future _changeDeleteStatus(bool deleted, Book book) async { - await bookCubit.updateBook(book.copyWith( - deleted: deleted, - )); - - bookCubit.getDeletedBooks(); - } - - _deleteBookPermanently(Book book) async { - if (book.id != null) { - await bookCubit.deleteBook(book.id!); - } - - bookCubit.getDeletedBooks(); - } - IconData? _decideStatusIcon(int? status) { if (status == 0) { return Icons.done; @@ -232,99 +159,9 @@ class BookScreen extends StatelessWidget { @override Widget build(BuildContext context) { - final moreButtonOptions = [ - LocaleKeys.edit_book.tr(), - ]; - return Scaffold( extendBodyBehindAppBar: true, - appBar: PreferredSize( - preferredSize: AppBar().preferredSize, - // Needed to add BlocBuilder because the status bar was changing - // to different color then in BooksScreen - child: BlocBuilder( - builder: (context, state) { - final themeMode = (state as SetThemeState).themeMode; - - return AppBar( - backgroundColor: Colors.transparent, - scrolledUnderElevation: 0, - systemOverlayStyle: SystemUiOverlayStyle( - statusBarColor: Colors.transparent, - statusBarIconBrightness: themeMode == ThemeMode.system - ? MediaQuery.platformBrightnessOf(context) == - Brightness.dark - ? Brightness.light - : Brightness.dark - : themeMode == ThemeMode.dark - ? Brightness.light - : Brightness.dark, - ), - actions: [ - BlocBuilder( - builder: (context, state) { - if (moreButtonOptions.length == 1) { - if (state.deleted == true) { - moreButtonOptions.add(LocaleKeys.restore_book.tr()); - moreButtonOptions.add( - LocaleKeys.delete_permanently.tr(), - ); - } else { - moreButtonOptions.add(LocaleKeys.delete_book.tr()); - } - } - - return PopupMenuButton( - onSelected: (_) {}, - itemBuilder: (_) { - return moreButtonOptions.map((String choice) { - return PopupMenuItem( - value: choice, - child: Text(choice), - onTap: () async { - context.read().setBook(state); - - await Future.delayed(const Duration( - milliseconds: 0, - )); - if (!context.mounted) return; - - if (choice == moreButtonOptions[0]) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const AddBookScreen( - editingExistingBook: true, - ), - ), - ); - } else if (choice == moreButtonOptions[1]) { - if (state.deleted == false) { - _showDeleteRestoreDialog( - context, true, null, state); - } else { - _showDeleteRestoreDialog( - context, false, null, state); - } - } else if (choice == moreButtonOptions[2]) { - _showDeleteRestoreDialog( - context, - true, - true, - state, - ); - } - }, - ); - }).toList(); - }, - ); - }, - ), - ], - ); - }, - ), - ), + appBar: const BookScreenAppBar(), body: BlocBuilder( builder: (context, state) { return SingleChildScrollView( diff --git a/lib/ui/book_screen/widgets/book_screen_app_bar.dart b/lib/ui/book_screen/widgets/book_screen_app_bar.dart new file mode 100644 index 00000000..fd1cfa38 --- /dev/null +++ b/lib/ui/book_screen/widgets/book_screen_app_bar.dart @@ -0,0 +1,181 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:openreads/core/themes/app_theme.dart'; +import 'package:openreads/generated/locale_keys.g.dart'; +import 'package:openreads/logic/bloc/theme_bloc/theme_bloc.dart'; +import 'package:openreads/logic/cubit/current_book_cubit.dart'; +import 'package:openreads/logic/cubit/edit_book_cubit.dart'; +import 'package:openreads/main.dart'; +import 'package:openreads/model/book.dart'; +import 'package:openreads/ui/add_book_screen/add_book_screen.dart'; + +class BookScreenAppBar extends StatelessWidget implements PreferredSizeWidget { + const BookScreenAppBar({super.key}); + + static final _appBar = AppBar(); + + @override + Size get preferredSize => _appBar.preferredSize; + + _showDeleteRestoreDialog( + BuildContext context, + bool deleted, + bool? deletePermanently, + Book book, + ) { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(cornerRadius), + ), + title: Text( + deleted + ? deletePermanently == true + ? LocaleKeys.delete_perm_question.tr() + : LocaleKeys.delete_book_question.tr() + : LocaleKeys.restore_book_question.tr(), + style: const TextStyle(fontSize: 18), + ), + actionsAlignment: MainAxisAlignment.spaceBetween, + actions: [ + FilledButton.tonal( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Text(LocaleKeys.no.tr()), + ), + ), + FilledButton( + onPressed: () { + if (deletePermanently == true) { + _deleteBookPermanently(book); + } else { + _changeDeleteStatus(deleted, book); + } + + Navigator.of(context).pop(); + Navigator.of(context).pop(); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Text(LocaleKeys.yes.tr()), + ), + ), + ], + ); + }); + } + + Future _changeDeleteStatus(bool deleted, Book book) async { + await bookCubit.updateBook(book.copyWith( + deleted: deleted, + )); + + bookCubit.getDeletedBooks(); + } + + _deleteBookPermanently(Book book) async { + if (book.id != null) { + await bookCubit.deleteBook(book.id!); + } + + bookCubit.getDeletedBooks(); + } + + @override + Widget build(BuildContext context) { + final moreButtonOptions = [ + LocaleKeys.edit_book.tr(), + ]; + + // Needed to add BlocBuilder because the status bar was changing + // to different color then in BooksScreen + return BlocBuilder( + builder: (context, state) { + final themeMode = (state as SetThemeState).themeMode; + + return AppBar( + backgroundColor: Colors.transparent, + scrolledUnderElevation: 0, + systemOverlayStyle: SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: themeMode == ThemeMode.system + ? MediaQuery.platformBrightnessOf(context) == Brightness.dark + ? Brightness.light + : Brightness.dark + : themeMode == ThemeMode.dark + ? Brightness.light + : Brightness.dark, + ), + actions: [ + BlocBuilder( + builder: (context, state) { + if (moreButtonOptions.length == 1) { + if (state.deleted == true) { + moreButtonOptions.add(LocaleKeys.restore_book.tr()); + moreButtonOptions.add( + LocaleKeys.delete_permanently.tr(), + ); + } else { + moreButtonOptions.add(LocaleKeys.delete_book.tr()); + } + } + + return PopupMenuButton( + onSelected: (_) {}, + itemBuilder: (_) { + return moreButtonOptions.map((String choice) { + return PopupMenuItem( + value: choice, + child: Text(choice), + onTap: () async { + context.read().setBook(state); + + await Future.delayed(const Duration( + milliseconds: 0, + )); + if (!context.mounted) return; + + if (choice == moreButtonOptions[0]) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const AddBookScreen( + editingExistingBook: true, + ), + ), + ); + } else if (choice == moreButtonOptions[1]) { + if (state.deleted == false) { + _showDeleteRestoreDialog( + context, true, null, state); + } else { + _showDeleteRestoreDialog( + context, false, null, state); + } + } else if (choice == moreButtonOptions[2]) { + _showDeleteRestoreDialog( + context, + true, + true, + state, + ); + } + }, + ); + }).toList(); + }, + ); + }, + ), + ], + ); + }, + ); + } +} diff --git a/lib/ui/book_screen/widgets/widgets.dart b/lib/ui/book_screen/widgets/widgets.dart index 0b74e2df..05f2d279 100644 --- a/lib/ui/book_screen/widgets/widgets.dart +++ b/lib/ui/book_screen/widgets/widgets.dart @@ -4,3 +4,4 @@ export 'book_title_detail.dart'; export 'cover_view.dart'; export 'cover_background.dart'; export 'quick_rating.dart'; +export 'book_screen_app_bar.dart'; From a80be735e3603cf79315371466a379a120664196 Mon Sep 17 00:00:00 2001 From: mateusz-bak <32651935+mateusz-bak@users.noreply.github.com> Date: Mon, 25 Sep 2023 23:38:14 +0100 Subject: [PATCH 08/10] fix: FIxed book screen display with no cover --- lib/ui/book_screen/book_screen.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/ui/book_screen/book_screen.dart b/lib/ui/book_screen/book_screen.dart index 2e6a5d01..dc09d7da 100644 --- a/lib/ui/book_screen/book_screen.dart +++ b/lib/ui/book_screen/book_screen.dart @@ -159,6 +159,8 @@ class BookScreen extends StatelessWidget { @override Widget build(BuildContext context) { + final mediaQuery = MediaQuery.of(context); + return Scaffold( extendBodyBehindAppBar: true, appBar: const BookScreenAppBar(), @@ -174,7 +176,10 @@ class BookScreen extends StatelessWidget { book: state, ), ) - : const SizedBox(), + : SizedBox( + height: mediaQuery.padding.top + + AppBar().preferredSize.height, + ), Padding( padding: const EdgeInsets.fromLTRB(5, 0, 5, 5), child: Column( From 86000b21e9de30cd4402a5b7778e03f987cdb5bc Mon Sep 17 00:00:00 2001 From: mateusz-bak <32651935+mateusz-bak@users.noreply.github.com> Date: Tue, 26 Sep 2023 22:23:19 +0100 Subject: [PATCH 09/10] fix: Android SDK 30&31 will use scoped storage --- lib/ui/settings_screen/settings_backup_screen.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/ui/settings_screen/settings_backup_screen.dart b/lib/ui/settings_screen/settings_backup_screen.dart index 44870196..739ced1b 100644 --- a/lib/ui/settings_screen/settings_backup_screen.dart +++ b/lib/ui/settings_screen/settings_backup_screen.dart @@ -38,7 +38,7 @@ class _SettingsBackupScreenState extends State { _startCreatingLocalBackup(context) async { setState(() => _creatingLocal = true); - if (androidInfo.version.sdkInt <= 31) { + if (androidInfo.version.sdkInt < 30) { await BackupGeneral.requestStoragePermission(context); await BackupExport.createLocalBackupLegacyStorage(context); } else { @@ -51,7 +51,7 @@ class _SettingsBackupScreenState extends State { _startExportingCSV(context) async { setState(() => _exportingCSV = true); - if (androidInfo.version.sdkInt <= 31) { + if (androidInfo.version.sdkInt < 30) { await BackupGeneral.requestStoragePermission(context); await CSVExport.exportCSVLegacyStorage(context); } else { @@ -64,7 +64,7 @@ class _SettingsBackupScreenState extends State { _startImportingGoodreadsCSV(context) async { setState(() => _importingGoodreadsCSV = true); - if (androidInfo.version.sdkInt <= 31) { + if (androidInfo.version.sdkInt < 30) { await BackupGeneral.requestStoragePermission(context); await CSVGoodreadsImport.importGoodreadsCSVLegacyStorage(context); } else { @@ -90,7 +90,7 @@ class _SettingsBackupScreenState extends State { _startRestoringLocalBackup(context) async { setState(() => _restoringLocal = true); - if (androidInfo.version.sdkInt <= 31) { + if (androidInfo.version.sdkInt < 30) { await BackupGeneral.requestStoragePermission(context); await BackupImport.restoreLocalBackupLegacyStorage(context); } else { From 3fedc552e7fe470f5c3413de7432271baefa1d04 Mon Sep 17 00:00:00 2001 From: mateusz-bak <32651935+mateusz-bak@users.noreply.github.com> Date: Tue, 26 Sep 2023 22:23:47 +0100 Subject: [PATCH 10/10] fix: Corrected CSV file extension for legacy file picker --- lib/core/helpers/backup/backup_general.dart | 7 +++++-- lib/core/helpers/backup/csv_goodreads_import.dart | 5 ++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/core/helpers/backup/backup_general.dart b/lib/core/helpers/backup/backup_general.dart index b7220f38..e84ae476 100644 --- a/lib/core/helpers/backup/backup_general.dart +++ b/lib/core/helpers/backup/backup_general.dart @@ -74,7 +74,10 @@ class BackupGeneral { ); } - static Future openFilePicker(BuildContext context) async { + static Future openFilePicker( + BuildContext context, { + List allowedExtensions = const ['.backup', '.zip', '.png'], + }) async { if (!context.mounted) return null; return await FilesystemPicker.open( @@ -84,7 +87,7 @@ class BackupGeneral { fsType: FilesystemType.file, rootDirectory: Directory('/storage/emulated/0/'), fileTileSelectMode: FileTileSelectMode.wholeTile, - allowedExtensions: ['.backup', '.zip', '.png'], + allowedExtensions: allowedExtensions, theme: FilesystemPickerTheme( backgroundColor: Theme.of(context).colorScheme.surface, fileList: FilesystemPickerFileListThemeData( diff --git a/lib/core/helpers/backup/csv_goodreads_import.dart b/lib/core/helpers/backup/csv_goodreads_import.dart index addf2902..244dbfb1 100644 --- a/lib/core/helpers/backup/csv_goodreads_import.dart +++ b/lib/core/helpers/backup/csv_goodreads_import.dart @@ -17,7 +17,10 @@ import 'package:openreads/main.dart'; class CSVGoodreadsImport { static importGoodreadsCSVLegacyStorage(BuildContext context) async { try { - final csvPath = await BackupGeneral.openFilePicker(context); + final csvPath = await BackupGeneral.openFilePicker( + context, + allowedExtensions: ['.csv'], + ); if (csvPath == null) return; final csvBytes = await File(csvPath).readAsBytes();