only-bible-app v3.7.2+24
git clone https://git.pyrossh.dev/only-bible-app
The only bible app you will ever need. No ads. No in-app purchases. No distractions.
lib/store/actions_backup.dart
| fe24dd6 | 1 | import "dart:convert"; |
| fe24dd6 | 2 | import "dart:io"; |
| fe24dd6 | 3 | |
| fe24dd6 | 4 | import "package:async_redux/async_redux.dart"; |
| 0228ae8 | 5 | import "package:file_selector/file_selector.dart"; |
| fe24dd6 | 6 | import "package:flutter/foundation.dart"; |
| fe24dd6 | 7 | import "package:flutter/material.dart"; |
| fe24dd6 | 8 | import "package:only_bible_app/dialog.dart"; |
| fe24dd6 | 9 | import "package:only_bible_app/gen/bible.gen.dart"; |
| fe24dd6 | 10 | import "package:only_bible_app/store/app_state.dart"; |
| fe24dd6 | 11 | import "package:only_bible_app/utils.dart"; |
| 0228ae8 | 12 | import "package:path_provider/path_provider.dart"; |
| 0228ae8 | 13 | import "package:share_plus/share_plus.dart"; |
| fe24dd6 | 14 | |
| fe24dd6 | 15 | String buildBackupFileName([DateTime? now]) { |
| fe24dd6 | 16 | final n = now ?? DateTime.now(); |
| fe24dd6 | 17 | final y = n.year.toString().padLeft(4, "0"); |
| fe24dd6 | 18 | final m = n.month.toString().padLeft(2, "0"); |
| fe24dd6 | 19 | final d = n.day.toString().padLeft(2, "0"); |
| fe24dd6 | 20 | return "only_bible_app_backup_$y-$m-$d.json"; |
| fe24dd6 | 21 | } |
| fe24dd6 | 22 | |
| fe24dd6 | 23 | String buildBackupJson(AppState state) { |
| fe24dd6 | 24 | return const JsonEncoder.withIndent(" ").convert(state.toJson()); |
| fe24dd6 | 25 | } |
| fe24dd6 | 26 | |
| fe24dd6 | 27 | class BackupParseException implements Exception { |
| fe24dd6 | 28 | final String message; |
| fe24dd6 | 29 | |
| fe24dd6 | 30 | const BackupParseException(this.message); |
| fe24dd6 | 31 | |
| fe24dd6 | 32 | @override |
| fe24dd6 | 33 | String toString() => message; |
| fe24dd6 | 34 | } |
| fe24dd6 | 35 | |
| fe24dd6 | 36 | Future<AppState> parseBackupState( |
| fe24dd6 | 37 | String contents, { |
| fe24dd6 | 38 | Future<Bible> Function(String) loadBibleFn = loadBible, |
| fe24dd6 | 39 | }) async { |
| fe24dd6 | 40 | final Map<String, dynamic> json; |
| fe24dd6 | 41 | try { |
| fe24dd6 | 42 | final decoded = jsonDecode(contents); |
| fe24dd6 | 43 | if (decoded is! Map<String, dynamic>) { |
| fe24dd6 | 44 | throw const FormatException("backup is not a JSON object"); |
| fe24dd6 | 45 | } |
| fe24dd6 | 46 | json = decoded; |
| fe24dd6 | 47 | } catch (_) { |
| fe24dd6 | 48 | throw const BackupParseException("That file isn't a valid backup"); |
| fe24dd6 | 49 | } |
| fe24dd6 | 50 | |
| fe24dd6 | 51 | final bibleName = json["bibleName"] as String? ?? "en_kjv"; |
| fe24dd6 | 52 | final Bible bible; |
| fe24dd6 | 53 | try { |
| fe24dd6 | 54 | bible = await loadBibleFn(bibleName); |
| fe24dd6 | 55 | } catch (_) { |
| fe24dd6 | 56 | throw const BackupParseException("That file isn't a valid backup"); |
| fe24dd6 | 57 | } |
| fe24dd6 | 58 | |
| fe24dd6 | 59 | try { |
| fe24dd6 | 60 | return AppState.fromJson(json, bible); |
| fe24dd6 | 61 | } catch (_) { |
| fe24dd6 | 62 | throw const BackupParseException("That file isn't a valid backup"); |
| fe24dd6 | 63 | } |
| fe24dd6 | 64 | } |
| fe24dd6 | 65 | |
| fe24dd6 | 66 | class ExportAppStateAction extends ReduxAction<AppState> { |
| fe24dd6 | 67 | final BuildContext buildContext; |
| fe24dd6 | 68 | |
| fe24dd6 | 69 | ExportAppStateAction(this.buildContext); |
| fe24dd6 | 70 | |
| fe24dd6 | 71 | @override |
| fe24dd6 | 72 | Future<AppState?> reduce() async { |
| fe24dd6 | 73 | final jsonString = buildBackupJson(state); |
| fe24dd6 | 74 | final fileName = buildBackupFileName(); |
| 0228ae8 | 75 | // file_selector's save-location dialog isn't supported on Android/iOS |
| 0228ae8 | 76 | // (only desktop and web), so mobile exports go through the share sheet |
| 0228ae8 | 77 | // (Save to Files, AirDrop, etc.) instead. |
| 0228ae8 | 78 | final canPickSaveLocation = kIsWeb || isDesktop; |
| fe24dd6 | 79 | try { |
| 0228ae8 | 80 | if (canPickSaveLocation) { |
| 0228ae8 | 81 | final location = await getSaveLocation( |
| 0228ae8 | 82 | suggestedName: fileName, |
| 0228ae8 | 83 | acceptedTypeGroups: const [ |
| 0228ae8 | 84 | XTypeGroup(label: "JSON", extensions: ["json"]), |
| 0228ae8 | 85 | ], |
| 0228ae8 | 86 | ); |
| 0228ae8 | 87 | if (location == null) return null; // user cancelled |
| 0228ae8 | 88 | final file = XFile.fromData( |
| 0228ae8 | 89 | utf8.encode(jsonString), |
| 0228ae8 | 90 | mimeType: "application/json", |
| 0228ae8 | 91 | name: fileName, |
| 0228ae8 | 92 | ); |
| 0228ae8 | 93 | await file.saveTo(location.path); |
| 0228ae8 | 94 | } else { |
| 0228ae8 | 95 | final dir = await getTemporaryDirectory(); |
| 0228ae8 | 96 | final path = "${dir.path}/$fileName"; |
| fe24dd6 | 97 | await File(path).writeAsString(jsonString); |
| 0228ae8 | 98 | final result = await SharePlus.instance.share( |
| 0228ae8 | 99 | ShareParams(files: [XFile(path)]), |
| 0228ae8 | 100 | ); |
| 0228ae8 | 101 | if (result.status == ShareResultStatus.dismissed) return null; |
| fe24dd6 | 102 | } |
| fe24dd6 | 103 | } catch (_) { |
| fe24dd6 | 104 | if (buildContext.mounted) { |
| fe24dd6 | 105 | showError(buildContext, "Failed to export backup"); |
| fe24dd6 | 106 | } |
| fe24dd6 | 107 | return null; |
| fe24dd6 | 108 | } |
| fe24dd6 | 109 | if (buildContext.mounted) { |
| 0228ae8 | 110 | showAlert( |
| 0228ae8 | 111 | buildContext, |
| 0228ae8 | 112 | "Backup Exported", |
| 0228ae8 | 113 | "Your backup was saved successfully.", |
| 0228ae8 | 114 | ); |
| fe24dd6 | 115 | } |
| fe24dd6 | 116 | return null; |
| fe24dd6 | 117 | } |
| fe24dd6 | 118 | } |
| fe24dd6 | 119 | |
| fe24dd6 | 120 | class ImportAppStateAction extends ReduxAction<AppState> { |
| fe24dd6 | 121 | final BuildContext buildContext; |
| fe24dd6 | 122 | |
| fe24dd6 | 123 | ImportAppStateAction(this.buildContext); |
| fe24dd6 | 124 | |
| fe24dd6 | 125 | @override |
| fe24dd6 | 126 | Future<AppState?> reduce() async { |
| fe24dd6 | 127 | final confirmed = await showConfirm( |
| fe24dd6 | 128 | buildContext, |
| fe24dd6 | 129 | "Import Backup", |
| fe24dd6 | 130 | "This will replace all your current highlights and settings. Continue?", |
| fe24dd6 | 131 | ); |
| fe24dd6 | 132 | if (!confirmed) return null; |
| fe24dd6 | 133 | if (!buildContext.mounted) return null; |
| fe24dd6 | 134 | |
| 0228ae8 | 135 | final file = await openFile( |
| 0228ae8 | 136 | acceptedTypeGroups: const [ |
| 0228ae8 | 137 | XTypeGroup(label: "JSON", extensions: ["json"]), |
| 0228ae8 | 138 | ], |
| fe24dd6 | 139 | ); |
| 0228ae8 | 140 | if (file == null) return null; // user cancelled |
| fe24dd6 | 141 | |
| fe24dd6 | 142 | try { |
| 0228ae8 | 143 | final bytes = await file.readAsBytes(); |
| fe24dd6 | 144 | final newState = await parseBackupState(utf8.decode(bytes)); |
| fe24dd6 | 145 | if (buildContext.mounted) { |
| 0228ae8 | 146 | showAlert( |
| 0228ae8 | 147 | buildContext, |
| 0228ae8 | 148 | "Backup Imported", |
| 0228ae8 | 149 | "Your backup was restored successfully.", |
| 0228ae8 | 150 | ); |
| fe24dd6 | 151 | } |
| fe24dd6 | 152 | return newState; |
| fe24dd6 | 153 | } on BackupParseException catch (err) { |
| fe24dd6 | 154 | if (buildContext.mounted) { |
| fe24dd6 | 155 | showError(buildContext, err.message); |
| fe24dd6 | 156 | } |
| fe24dd6 | 157 | return null; |
| 0228ae8 | 158 | } catch (_) { |
| 0228ae8 | 159 | if (buildContext.mounted) { |
| 0228ae8 | 160 | showError(buildContext, "That file isn't a valid backup"); |
| 0228ae8 | 161 | } |
| 0228ae8 | 162 | return null; |
| fe24dd6 | 163 | } |
| fe24dd6 | 164 | } |
| fe24dd6 | 165 | } |