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.
docs/superpowers/plans/2026-07-18-backup-export-import.md
# Backup Export/Import Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let users export their entire `AppState` (highlights, highlight history, reading position, display settings) to a JSON file via a native save dialog, and import a previously-exported file back (replacing current state), both from the settings bottom sheet.
**Architecture:** Add `file_picker` as the only new dependency. Extract the JSON-shape logic (filename, encode, decode+validate) into pure, unit-testable functions in a new `lib/store/actions_backup.dart`, then wrap them in two thin `ReduxAction<AppState>` classes (`ExportAppStateAction`, `ImportAppStateAction`) that call `file_picker` and the existing `lib/dialog.dart` helpers for user feedback. Wire two new rows into `lib/widgets/settings_sheet.dart`.
**Tech Stack:** Flutter/Dart, `async_redux` (`ReduxAction<AppState>`), `file_picker` (new), `dart:convert`, `dart:io`.
## Global Constraints
- Export/import operates on the **entire** `AppState` — highlights, `highlightHistory`, `savedBook`/`savedChapter`, `darkMode`, `fontSize`, `fontWeight`, `engTitles` — never a subset.
- Import **replaces** state wholesale after an explicit user confirmation; it never merges with existing state.
- The only new dependency is `file_picker`, added via `flutter pub add file_picker` (do not hand-write a version number into `pubspec.yaml`; let pub resolve one compatible with `sdk: '>=3.0.6 <4.0.0'`, `pubspec.yaml:7`).
- New actions follow the existing convention in `lib/store/actions_navigation.dart`/`lib/store/actions_state.dart`: `ReduxAction<AppState>` with a `BuildContext buildContext` constructor field when the action shows dialogs, `Future<AppState?> reduce() async`, and `showError`/`showAlert` from `lib/dialog.dart` for feedback. Always guard post-`await` `BuildContext` use with `buildContext.mounted`, matching `TogglePlayAction` (`lib/store/actions_state.dart:101`).
- Match the single existing project convention of *not* unit-testing actions that call into native plugin channels (see the comment at `test/app_logic_test.dart:262-270` explaining why `NextChapterAction`/`PreviousChapterAction`/`GoToChapterAction` aren't dispatched directly in tests) — apply the same reasoning to `file_picker` calls: test the pure logic they wrap, verify the actions themselves by running the app.
- Adding new UI to `SettingsSheet` will change its golden screenshot (`test/screenshot_test.dart:199-211`); the golden must be regenerated as part of this work, not left failing.
---
### Task 1: Add the `file_picker` dependency
**Files:**
- Modify: `pubspec.yaml`
**Interfaces:**
- Produces: `file_picker` package available for import as `package:file_picker/file_picker.dart` in later tasks.
- [ ] **Step 1: Add the dependency**
Run:
```bash
flutter pub add file_picker
```
- [ ] **Step 2: Verify it resolved and the project still analyzes cleanly**
Run: `flutter pub get && flutter analyze`
Expected: both commands exit 0; `pubspec.yaml` now has a `file_picker: ^<version>` line under `dependencies`.
- [ ] **Step 3: Commit**
```bash
git add pubspec.yaml pubspec.lock
git commit -m "Add file_picker dependency for backup export/import"
```
---
### Task 2: Backup JSON helpers (pure logic)
**Files:**
- Create: `lib/store/actions_backup.dart`
- Test: `test/actions_backup_test.dart`
**Interfaces:**
- Consumes: `AppState` (`lib/store/app_state.dart:40`, with `toJson()`/`fromJson()`), `Bible` (`lib/gen/bible.gen.dart`), `loadBible(String name) => Future<Bible>` (`lib/utils.dart:175`).
- Produces (used by Task 4/5):
- `String buildBackupFileName([DateTime? now])`
- `String buildBackupJson(AppState state)`
- `class BackupParseException implements Exception { final String message; }`
- `Future<AppState> parseBackupState(String contents, {Future<Bible> Function(String) loadBibleFn = loadBible})`
- [ ] **Step 1: Write the failing tests**
Create `test/actions_backup_test.dart`:
```dart
import "dart:convert";
import "package:flutter_test/flutter_test.dart";
import "package:only_bible_app/store/actions_backup.dart";
import "package:only_bible_app/store/app_state.dart";
import "app_logic_test.dart" show buildTestBible;
void main() {
group("buildBackupFileName", () {
test("formats a zero-padded date", () {
expect(
buildBackupFileName(DateTime.utc(2026, 3, 5)),
"only_bible_app_backup_2026-03-05.json",
);
});
});
group("buildBackupJson / parseBackupState", () {
test("round-trips highlights and settings through export and import", () async {
final bible = buildTestBible();
final state = AppState(
bible: bible,
darkMode: true,
fontSize: 22,
highlights: {"0:0:0": 2},
highlightHistory: [
HighlightHistoryEntry(book: 0, chapter: 0, verseIndex: 0, colorIndex: 2, timestamp: DateTime.utc(2026, 1, 1)),
],
);
final jsonString = buildBackupJson(state);
final restored = await parseBackupState(jsonString, loadBibleFn: (_) async => bible);
expect(restored.darkMode, true);
expect(restored.fontSize, 22);
expect(restored.highlights["0:0:0"], 2);
expect(restored.highlightHistory.single.colorIndex, 2);
});
test("rejects malformed JSON", () async {
expect(
() => parseBackupState("not json", loadBibleFn: (_) async => buildTestBible()),
throwsA(isA<BackupParseException>()),
);
});
test("rejects valid JSON that isn't an object", () async {
expect(
() => parseBackupState(jsonEncode([1, 2, 3]), loadBibleFn: (_) async => buildTestBible()),
throwsA(isA<BackupParseException>()),
);
});
test("rejects a bibleName the app can't load", () async {
expect(
() => parseBackupState(
jsonEncode({"bibleName": "does_not_exist"}),
loadBibleFn: (_) async => throw Exception("no such asset"),
),
throwsA(isA<BackupParseException>()),
);
});
});
}
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `flutter test test/actions_backup_test.dart`
Expected: FAIL — `lib/store/actions_backup.dart` doesn't exist yet (import error).
- [ ] **Step 3: Implement the helpers**
Create `lib/store/actions_backup.dart`:
```dart
import "dart:convert";
import "package:only_bible_app/gen/bible.gen.dart";
import "package:only_bible_app/store/app_state.dart";
import "package:only_bible_app/utils.dart";
String buildBackupFileName([DateTime? now]) {
final n = now ?? DateTime.now();
final y = n.year.toString().padLeft(4, "0");
final m = n.month.toString().padLeft(2, "0");
final d = n.day.toString().padLeft(2, "0");
return "only_bible_app_backup_$y-$m-$d.json";
}
String buildBackupJson(AppState state) {
return const JsonEncoder.withIndent(" ").convert(state.toJson());
}
class BackupParseException implements Exception {
final String message;
const BackupParseException(this.message);
@override
String toString() => message;
}
Future<AppState> parseBackupState(
String contents, {
Future<Bible> Function(String) loadBibleFn = loadBible,
}) async {
final Map<String, dynamic> json;
try {
final decoded = jsonDecode(contents);
if (decoded is! Map<String, dynamic>) {
throw const FormatException("backup is not a JSON object");
}
json = decoded;
} catch (_) {
throw const BackupParseException("That file isn't a valid backup");
}
final bibleName = json["bibleName"] as String? ?? "en_kjv";
final Bible bible;
try {
bible = await loadBibleFn(bibleName);
} catch (_) {
throw const BackupParseException("That file isn't a valid backup");
}
try {
return AppState.fromJson(json, bible);
} catch (_) {
throw const BackupParseException("That file isn't a valid backup");
}
}
```
- [ ] **Step 4: Run the tests to verify they pass**
Run: `flutter test test/actions_backup_test.dart`
Expected: PASS (all 5 tests)
- [ ] **Step 5: Commit**
```bash
git add lib/store/actions_backup.dart test/actions_backup_test.dart
git commit -m "Add pure backup JSON build/parse helpers with tests"
```
---
### Task 3: Add a reusable Yes/No confirmation dialog
**Files:**
- Modify: `lib/dialog.dart`
**Interfaces:**
- Produces: `Future<bool> showConfirm(BuildContext context, String title, String message)` — resolves `true` only if the user taps "Continue"; `false` on "Cancel", back button, or barrier dismiss.
- [ ] **Step 1: Add `showConfirm` to `lib/dialog.dart`**
Add this function after `showError` (around line 34), following the same style as `showAlert`/`showReportError` already in the file:
```dart
Future<bool> showConfirm(BuildContext context, String title, String message) async {
final result = await showDialog<bool>(
context: context,
barrierColor: Colors.black54,
builder: (dialogContext) {
return AlertDialog(
title: Text(title),
content: Text(message),
actionsAlignment: MainAxisAlignment.end,
actionsOverflowButtonSpacing: 8.0,
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text("Cancel"),
),
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text("Continue"),
),
],
);
},
);
return result ?? false;
}
```
There is no existing widget-test harness for dialogs in this codebase (only golden screenshot tests, which don't drive dialog interaction) — verified manually in Task 7.
- [ ] **Step 2: Verify it compiles**
Run: `flutter analyze`
Expected: no new errors.
- [ ] **Step 3: Commit**
```bash
git add lib/dialog.dart
git commit -m "Add showConfirm Yes/No dialog helper"
```
---
### Task 4: `ExportAppStateAction`
**Files:**
- Modify: `lib/store/actions_backup.dart`
**Interfaces:**
- Consumes: `buildBackupFileName()`, `buildBackupJson(AppState)` (Task 2); `showError`/`showAlert` (`lib/dialog.dart`); `FilePicker.saveFile` (`file_picker`, Task 1).
- Produces: `class ExportAppStateAction extends ReduxAction<AppState>` — constructor `ExportAppStateAction(BuildContext buildContext)`.
No automated test for this task: it calls `file_picker`'s platform channel, which (per the project's existing convention noted in Global Constraints) isn't exercised in headless `flutter test`. Correctness of the logic it depends on is covered by Task 2; the action's actual file-writing behavior is verified manually in Task 7.
- [ ] **Step 1: Implement the action**
Add to the top of `lib/store/actions_backup.dart`:
```dart
import "dart:io";
import "package:async_redux/async_redux.dart";
import "package:file_picker/file_picker.dart";
import "package:flutter/foundation.dart";
import "package:flutter/material.dart";
import "package:only_bible_app/dialog.dart";
```
(these go alongside the existing `dart:convert`/`gen/bible.gen.dart`/`app_state.dart`/`utils.dart` imports already there from Task 2)
Then append the action at the bottom of the file:
```dart
class ExportAppStateAction extends ReduxAction<AppState> {
final BuildContext buildContext;
ExportAppStateAction(this.buildContext);
@override
Future<AppState?> reduce() async {
final jsonString = buildBackupJson(state);
final fileName = buildBackupFileName();
String? path;
try {
path = await FilePicker.saveFile(
fileName: fileName,
bytes: kIsWeb ? utf8.encode(jsonString) : null,
);
if (path == null) return null; // user cancelled the dialog
if (!kIsWeb) {
await File(path).writeAsString(jsonString);
}
} catch (_) {
if (buildContext.mounted) {
showError(buildContext, "Failed to export backup");
}
return null;
}
if (buildContext.mounted) {
showAlert(buildContext, "Backup Exported", "Your backup was saved successfully.");
}
return null;
}
}
```
- [ ] **Step 2: Verify it compiles**
Run: `flutter analyze`
Expected: no new errors (in particular, no unused-import warnings — `dart:io`, `file_picker`, `flutter/foundation.dart`, `flutter/material.dart`, `only_bible_app/dialog.dart` are all now used).
- [ ] **Step 3: Commit**
```bash
git add lib/store/actions_backup.dart
git commit -m "Add ExportAppStateAction"
```
---
### Task 5: `ImportAppStateAction`
**Files:**
- Modify: `lib/store/actions_backup.dart`
**Interfaces:**
- Consumes: `parseBackupState`, `BackupParseException` (Task 2); `showConfirm`, `showError`, `showAlert` (`lib/dialog.dart`); `FilePicker.pickFiles` (`file_picker`).
- Produces: `class ImportAppStateAction extends ReduxAction<AppState>` — constructor `ImportAppStateAction(BuildContext buildContext)`.
Same testing note as Task 4: no automated test for the plugin-touching action itself; `parseBackupState`'s behavior is already covered by Task 2's tests; end-to-end behavior is verified manually in Task 7.
- [ ] **Step 1: Implement the action**
Append to `lib/store/actions_backup.dart`:
```dart
class ImportAppStateAction extends ReduxAction<AppState> {
final BuildContext buildContext;
ImportAppStateAction(this.buildContext);
@override
Future<AppState?> reduce() async {
final confirmed = await showConfirm(
buildContext,
"Import Backup",
"This will replace all your current highlights and settings. Continue?",
);
if (!confirmed) return null;
if (!buildContext.mounted) return null;
final result = await FilePicker.pickFiles(
type: FileType.custom,
allowedExtensions: ["json"],
withData: true,
);
if (result == null || result.files.isEmpty) return null; // user cancelled
final bytes = result.files.single.bytes;
if (bytes == null) {
if (buildContext.mounted) {
showError(buildContext, "That file isn't a valid backup");
}
return null;
}
try {
final newState = await parseBackupState(utf8.decode(bytes));
if (buildContext.mounted) {
showAlert(buildContext, "Backup Imported", "Your backup was restored successfully.");
}
return newState;
} on BackupParseException catch (err) {
if (buildContext.mounted) {
showError(buildContext, err.message);
}
return null;
}
}
}
```
- [ ] **Step 2: Verify it compiles**
Run: `flutter analyze`
Expected: no new errors.
- [ ] **Step 3: Commit**
```bash
git add lib/store/actions_backup.dart
git commit -m "Add ImportAppStateAction"
```
---
### Task 6: Wire the Backup section into `SettingsSheet`
**Files:**
- Modify: `lib/widgets/settings_sheet.dart`
- Modify (regenerate): `test/screenshot_test.dart` goldens for the `settings` screenshot
**Interfaces:**
- Consumes: `ExportAppStateAction`, `ImportAppStateAction` (Task 4/5).
- [ ] **Step 1: Add the import**
In `lib/widgets/settings_sheet.dart`, add alongside the existing imports (after line 4, `import "package:only_bible_app/store/actions_state.dart";` — check the actual current import list first since Task 4/5 changes don't touch this file):
```dart
import "package:only_bible_app/store/actions_backup.dart";
```
- [ ] **Step 2: Add the Backup section**
In `lib/widgets/settings_sheet.dart`, insert this block right after the "English titles toggle" `Material` card (i.e. after its closing `),` and before the final `const SizedBox(height: 16), Spacer(),`):
```dart
const SizedBox(height: 16),
Text(
"Backup",
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Material(
elevation: isDark ? 2 : 1,
borderRadius: BorderRadius.circular(12),
color: cardColor,
shadowColor: colorScheme.shadow,
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => context.dispatch(ExportAppStateAction(context)),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: cardBorderColor),
),
child: Row(
children: [
Icon(Icons.download_outlined, color: colorScheme.onSurface),
const SizedBox(width: 12),
const Expanded(child: Text("Export Backup")),
],
),
),
),
),
const SizedBox(height: 8),
Material(
elevation: isDark ? 2 : 1,
borderRadius: BorderRadius.circular(12),
color: cardColor,
shadowColor: colorScheme.shadow,
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => context.dispatch(ImportAppStateAction(context)),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: cardBorderColor),
),
child: Row(
children: [
Icon(Icons.upload_outlined, color: colorScheme.onSurface),
const SizedBox(width: 12),
const Expanded(child: Text("Import Backup")),
],
),
),
),
),
```
- [ ] **Step 3: Verify it compiles and analyzes cleanly**
Run: `flutter analyze`
Expected: no new errors.
- [ ] **Step 4: Regenerate the settings golden screenshots**
The new rows change the rendered height/content of `SettingsSheet`, so the existing `settings` golden (`test/screenshot_test.dart:211`, device loop covering `GoldenScreenshotDevices.iphone`/`ipad`/`androidPhone` at `test/screenshot_test.dart:103-105`) will now mismatch. Regenerate it:
Run: `flutter test test/screenshot_test.dart --update-goldens`
Expected: exits 0; this overwrites the committed master images:
- `ios/fastlane/screenshots/en-US/settings.png`
- `ios/fastlane/screenshots/en-US/ipad_settings.png`
- `android/fastlane/metadata/android/en-GB/images/phoneScreenshots/settings.png`
- [ ] **Step 5: Confirm the screenshot test now passes without `--update-goldens`**
Run: `flutter test test/screenshot_test.dart`
Expected: PASS
- [ ] **Step 6: Review the updated screenshots, then commit**
Open the three PNGs listed in Step 4 and confirm the new "Backup" section renders as expected (no clipping/overflow) on all three form factors before committing:
```bash
git add lib/widgets/settings_sheet.dart \
ios/fastlane/screenshots/en-US/settings.png \
ios/fastlane/screenshots/en-US/ipad_settings.png \
android/fastlane/metadata/android/en-GB/images/phoneScreenshots/settings.png
git commit -m "Add Export/Import Backup rows to settings sheet"
```
---
### Task 7: Manual end-to-end verification
**Files:** none (no code changes)
- [ ] **Step 1: Run the full test suite**
Run: `flutter test`
Expected: all tests pass, including the new `test/actions_backup_test.dart` and the regenerated `test/screenshot_test.dart`.
- [ ] **Step 2: Launch the app on macOS**
Run: `flutter run -d macos`
Expected: app launches to the last-read chapter.
- [ ] **Step 3: Highlight a verse, then export**
In the running app: highlight at least one verse, open Settings (gear icon), tap "Export Backup", choose a destination in the save dialog (e.g. Desktop).
Expected: a success dialog appears ("Backup Exported"); the chosen destination now contains a `only_bible_app_backup_<date>.json` file; open it in a text editor and confirm it contains a `"highlights"` object with your highlighted verse's key.
- [ ] **Step 4: Change state, then import the backup back**
In the running app: remove the highlight from Step 3 (so current state now differs from the backup), open Settings, tap "Import Backup", confirm the replace-warning dialog, and pick the file exported in Step 3.
Expected: a success dialog appears ("Backup Imported"); the highlight from Step 3 reappears in the reading view, confirming the state was replaced from the file.
- [ ] **Step 5: Verify cancel paths don't error**
Tap "Export Backup" and immediately cancel the save dialog; tap "Import Backup", confirm the warning, then cancel the file-open dialog.
Expected: no error dialog appears in either case, and app state is unchanged.
- [ ] **Step 6: Verify a malformed file is rejected on import**
Create a text file containing `not valid json` and try importing it via "Import Backup".
Expected: an error dialog appears with "That file isn't a valid backup"; app state is unchanged.