only-bible-app v3.7.2+24

#kotlin#android#ios

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.