diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index 7423be1d..df1d2d92 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -223,5 +223,13 @@ "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", + "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..ff72ffc6 --- /dev/null +++ b/lib/core/helpers/backup/backup_export.dart @@ -0,0 +1,228 @@ +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()); + + BackupGeneral.showInfoSnackbar(LocaleKeys.backup_successfull.tr()); + } catch (e) { + BackupGeneral.showInfoSnackbar(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(), + ); + + BackupGeneral.showInfoSnackbar(LocaleKeys.backup_successfull.tr()); + } catch (e) { + BackupGeneral.showInfoSnackbar(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( + listOfBookJSONs, + challengeTargets, + coverFiles, + ); + } catch (e) { + BackupGeneral.showInfoSnackbar(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( + 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) { + BackupGeneral.showInfoSnackbar(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..e84ae476 --- /dev/null +++ b/lib/core/helpers/backup/backup_general.dart @@ -0,0 +1,127 @@ +import 'dart:io'; + +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(String message) { + final snackBar = SnackBar(content: Text(message)); + snackbarKey.currentState?.showSnackBar(snackBar); + } + + 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, { + List allowedExtensions = const ['.backup', '.zip', '.png'], + }) 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: allowedExtensions, + 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..524611ef --- /dev/null +++ b/lib/core/helpers/backup/backup_import.dart @@ -0,0 +1,457 @@ +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: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; +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 { + BackupGeneral.showInfoSnackbar(LocaleKeys.backup_not_valid.tr()); + return; + } + } + + BackupGeneral.showInfoSnackbar(LocaleKeys.restore_successfull.tr()); + + if (context.mounted) { + Navigator.of(context).pop(); + Navigator.of(context).pop(); + } + } catch (e) { + BackupGeneral.showInfoSnackbar(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 { + BackupGeneral.showInfoSnackbar(LocaleKeys.backup_not_valid.tr()); + return; + } + } + + BackupGeneral.showInfoSnackbar(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) { + BackupGeneral.showInfoSnackbar(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('@@@@@'); + final booksCount = books.length; + int restoredBooks = 0; + + context + .read() + .updateString('$restoredBooks/$booksCount ${LocaleKeys.restored.tr()}'); + + 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, + ); + + restoredBooks++; + + // ignore: use_build_context_synchronously + 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(); + + // ignore: use_build_context_synchronously + await _restoreChallengeTargetsV4(context, tmpPath); + } + + static _restoreBackupVersion3( + BuildContext context, { + String? archivePath, + Uint8List? archiveFile, + required Directory tmpPath, + }) async { + late Uint8List archiveBytes; + try { + 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() + : []; + + final booksCount = books.length; + int restoredBooks = 0; + + // ignore: use_build_context_synchronously + context.read().updateString( + '$restoredBooks/$booksCount ${LocaleKeys.restored.tr()}'); + + 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( + 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); + } + + 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..251535bb --- /dev/null +++ b/lib/core/helpers/backup/csv_export.dart @@ -0,0 +1,163 @@ +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(); + 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)), + ); + + BackupGeneral.showInfoSnackbar(LocaleKeys.export_successful.tr()); + } catch (e) { + BackupGeneral.showInfoSnackbar(e.toString()); + } + } + + static Future exportCSV() async { + final csv = await _prepareCSVExport(); + 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)), + ); + + BackupGeneral.showInfoSnackbar(LocaleKeys.export_successful.tr()); + } catch (e) { + BackupGeneral.showInfoSnackbar(e.toString()); + } + } + + static 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) { + BackupGeneral.showInfoSnackbar(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..244dbfb1 --- /dev/null +++ b/lib/core/helpers/backup/csv_goodreads_import.dart @@ -0,0 +1,409 @@ +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, + allowedExtensions: ['.csv'], + ); + 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); + + BackupGeneral.showInfoSnackbar(LocaleKeys.import_successful.tr()); + } catch (e) { + BackupGeneral.showInfoSnackbar(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); + + BackupGeneral.showInfoSnackbar(LocaleKeys.import_successful.tr()); + } catch (e) { + BackupGeneral.showInfoSnackbar(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(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 c3b88faa..7b6cf040 100644 --- a/lib/generated/locale_keys.g.dart +++ b/lib/generated/locale_keys.g.dart @@ -230,4 +230,12 @@ 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_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/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/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/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/book_screen/book_screen.dart b/lib/ui/book_screen/book_screen.dart index cd31e798..dc09d7da 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,11 @@ class BookScreen extends StatelessWidget { @override Widget build(BuildContext context) { - final moreButtonOptions = [ - LocaleKeys.edit_book.tr(), - ]; + final mediaQuery = MediaQuery.of(context); 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( @@ -337,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( 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'; diff --git a/lib/ui/settings_screen/settings_backup_screen.dart b/lib/ui/settings_screen/settings_backup_screen.dart index d0ab5ced..739ced1b 100644 --- a/lib/ui/settings_screen/settings_backup_screen.dart +++ b/lib/ui/settings_screen/settings_backup_screen.dart @@ -1,33 +1,17 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:archive/archive_io.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/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/logic/cubit/backup_progress_cubit.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({ @@ -45,1075 +29,412 @@ class _SettingsBackupScreenState extends State { bool _creatingLocal = false; bool _creatingCloud = false; bool _restoringLocal = false; - int booksBackupLenght = 0; - int booksBackupDone = 0; - String restoredCounterText = ''; + bool _exportingCSV = false; + bool _importingGoodreadsCSV = false; - _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(); + if (androidInfo.version.sdkInt < 30) { + await BackupGeneral.requestStoragePermission(context); + await BackupExport.createLocalBackupLegacyStorage(context); } else { - await _createLocalBackupWithScopedStorage(); + await BackupExport.createLocalBackup(context); } setState(() => _creatingLocal = 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(), - ), - ), - ); - } - } - - Future _getChallengeTargets() async { - if (!mounted) return null; - - final state = BlocProvider.of(context).state; - - if (state is SetChallengeState) { - return state.yearlyChallenges; - } - - return null; - } + _startExportingCSV(context) async { + setState(() => _exportingCSV = true); - // 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; + if (androidInfo.version.sdkInt < 30) { + await BackupGeneral.requestStoragePermission(context); + await CSVExport.exportCSVLegacyStorage(context); + } else { + await CSVExport.exportCSV(); } - } - 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, - ), - ), - ); + setState(() => _exportingCSV = false); } - 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(), - ), - ), - ); + _startImportingGoodreadsCSV(context) async { + setState(() => _importingGoodreadsCSV = true); - return null; + if (androidInfo.version.sdkInt < 30) { + await BackupGeneral.requestStoragePermission(context); + await CSVGoodreadsImport.importGoodreadsCSVLegacyStorage(context); + } else { + await CSVGoodreadsImport.importGoodreadsCSV(context); } - } - - Future _getAppVersion() async { - PackageInfo packageInfo = await PackageInfo.fromPlatform(); - return packageInfo.version; + setState(() => _importingGoodreadsCSV = false); } - 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 _prepareBackupInfo() async { - final appVersion = await _getAppVersion(); - - return 'App version: $appVersion\nBackup version: 5'; - } + _startCreatingCloudBackup(context) async { + setState(() => _creatingCloud = true); - Future _createLocalBackupWithScopedStorage() async { - final tmpBackupPath = await _prepareTemporaryBackup(); + final tmpBackupPath = await BackupExport.prepareTemporaryBackup(context); 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(), - ), - ), - ); - } - } - - _deleteTmpData(Directory tmpDir) { - if (File('${tmpDir.path}/books.backup').existsSync()) { - File('${tmpDir.path}/books.backup').deleteSync(); - } + Share.shareXFiles([ + XFile(tmpBackupPath), + ]); - if (File('${tmpDir.path}/challenges.backup').existsSync()) { - File('${tmpDir.path}/challenges.backup').deleteSync(); - } + setState(() => _creatingCloud = false); } - 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); + _startRestoringLocalBackup(context) async { + setState(() => _restoringLocal = true); - // 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 < 30) { + await BackupGeneral.requestStoragePermission(context); + await BackupImport.restoreLocalBackupLegacyStorage(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 BackupImport.restoreLocalBackup(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(); } - // 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; - - final infoFileVersion = infoFileContentSplitted[1].split(': ')[1]; - - return int.tryParse(infoFileVersion); + _startMigratingV1ToV2() { + BlocProvider.of(context).add( + StartMigration(context: context, retrigger: true), + ); } - 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; + initDeviceInfoPlugin() async { + androidInfo = await DeviceInfoPlugin().androidInfo; } - _startLocalRestore(context) async { - setState(() => _restoringLocal = true); - - DeviceInfoPlugin deviceInfo = DeviceInfoPlugin(); - AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo; - - if (androidInfo.version.sdkInt <= 31) { - await _requestStoragePermission(); - await _restoreLocalBackup(); - } else { - await _restoreLocalBackupWithScopedStorage(); - } - - setState(() => _restoringLocal = false); + @override + void initState() { + initDeviceInfoPlugin(); + super.initState(); } - _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(), + @override + 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), ), ), - ); - - 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(), - ), + 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, + ), + ), + ), + ], + ), + ), + ], + ); + }, + ), + 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, + ), + ), + 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(); + }, + ), + ], ), ); - - 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(), - ), - ), + 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: () { + _startMigratingV1ToV2(); + Navigator.of(context).pop(); + }, + child: Text(LocaleKeys.yes.tr()), + ), + FilledButton.tonal( + onPressed: () => Navigator.of(context).pop(), + child: Text(LocaleKeys.no.tr()), + ), + ], + ); + }, ); - - 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, + SettingsTile _buildRestoreBackup(BuildContext builderContext) { + 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: 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: () { + _startRestoringLocalBackup(builderContext); + Navigator.of(context).pop(); + }, + child: Text(LocaleKeys.yes.tr()), + ), + FilledButton.tonal( + onPressed: () => Navigator.of(context).pop(), + child: Text(LocaleKeys.no.tr()), + ), + ], + ); + }); + }, + ); + }, ); } - 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, + SettingsTile _buildCreateCloudBackup() { + return SettingsTile( + title: Text( + LocaleKeys.create_cloud_backup.tr(), + style: const TextStyle( + fontSize: 16, ), - ); - } else { - BlocProvider.of(context).add( - const RemoveAllChallengesEvent(), - ); - } + ), + leading: (_creatingCloud) + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(), + ) + : const Icon(FontAwesomeIcons.cloudArrowUp), + description: Text( + LocaleKeys.create_cloud_backup_description.tr(), + ), + onPressed: _startCreatingCloudBackup, + ); } - 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, + 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: _startCreatingLocalBackup, ); } - _startMigrationV1ToV2() { - BlocProvider.of(context).add( - StartMigration(context: context, retrigger: true), + 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: _startExportingCSV, ); } - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text( - LocaleKeys.backup.tr(), - style: const TextStyle(fontSize: 18), + SettingsTile _buildImportGoodreadsCSV() { + return SettingsTile( + title: Text( + LocaleKeys.import_goodreads_csv.tr(), + style: const TextStyle( + fontSize: 16, ), ), - 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( - 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, - ), - 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()), - ), - ], - ); - }, - ); - }, - ), - ], - ), - ], - ); - }, - ), - ), - 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(); - }, - ), - ], - ), + leading: (_importingGoodreadsCSV) + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(), + ) + : const Icon(FontAwesomeIcons.g), + onPressed: _startImportingGoodreadsCSV, ); } } 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: