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
fe24dd6 1
# Backup Export/Import Implementation Plan
fe24dd6 2
fe24dd6 3
> **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.
fe24dd6 4
fe24dd6 5
**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.
fe24dd6 6
fe24dd6 7
**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`.
fe24dd6 8
fe24dd6 9
**Tech Stack:** Flutter/Dart, `async_redux` (`ReduxAction<AppState>`), `file_picker` (new), `dart:convert`, `dart:io`.
fe24dd6 10
fe24dd6 11
## Global Constraints
fe24dd6 12
fe24dd6 13
- Export/import operates on the **entire** `AppState` — highlights, `highlightHistory`, `savedBook`/`savedChapter`, `darkMode`, `fontSize`, `fontWeight`, `engTitles` — never a subset.
fe24dd6 14
- Import **replaces** state wholesale after an explicit user confirmation; it never merges with existing state.
fe24dd6 15
- 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`).
fe24dd6 16
- 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`).
fe24dd6 17
- 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.
fe24dd6 18
- 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.
fe24dd6 19
fe24dd6 20
---
fe24dd6 21
fe24dd6 22
### Task 1: Add the `file_picker` dependency
fe24dd6 23
fe24dd6 24
**Files:**
fe24dd6 25
- Modify: `pubspec.yaml`
fe24dd6 26
fe24dd6 27
**Interfaces:**
fe24dd6 28
- Produces: `file_picker` package available for import as `package:file_picker/file_picker.dart` in later tasks.
fe24dd6 29
fe24dd6 30
- [ ] **Step 1: Add the dependency**
fe24dd6 31
fe24dd6 32
Run:
fe24dd6 33
```bash
fe24dd6 34
flutter pub add file_picker
fe24dd6 35
```
fe24dd6 36
fe24dd6 37
- [ ] **Step 2: Verify it resolved and the project still analyzes cleanly**
fe24dd6 38
fe24dd6 39
Run: `flutter pub get && flutter analyze`
fe24dd6 40
Expected: both commands exit 0; `pubspec.yaml` now has a `file_picker: ^<version>` line under `dependencies`.
fe24dd6 41
fe24dd6 42
- [ ] **Step 3: Commit**
fe24dd6 43
fe24dd6 44
```bash
fe24dd6 45
git add pubspec.yaml pubspec.lock
fe24dd6 46
git commit -m "Add file_picker dependency for backup export/import"
fe24dd6 47
```
fe24dd6 48
fe24dd6 49
---
fe24dd6 50
fe24dd6 51
### Task 2: Backup JSON helpers (pure logic)
fe24dd6 52
fe24dd6 53
**Files:**
fe24dd6 54
- Create: `lib/store/actions_backup.dart`
fe24dd6 55
- Test: `test/actions_backup_test.dart`
fe24dd6 56
fe24dd6 57
**Interfaces:**
fe24dd6 58
- 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`).
fe24dd6 59
- Produces (used by Task 4/5):
fe24dd6 60
  - `String buildBackupFileName([DateTime? now])`
fe24dd6 61
  - `String buildBackupJson(AppState state)`
fe24dd6 62
  - `class BackupParseException implements Exception { final String message; }`
fe24dd6 63
  - `Future<AppState> parseBackupState(String contents, {Future<Bible> Function(String) loadBibleFn = loadBible})`
fe24dd6 64
fe24dd6 65
- [ ] **Step 1: Write the failing tests**
fe24dd6 66
fe24dd6 67
Create `test/actions_backup_test.dart`:
fe24dd6 68
fe24dd6 69
```dart
fe24dd6 70
import "dart:convert";
fe24dd6 71
fe24dd6 72
import "package:flutter_test/flutter_test.dart";
fe24dd6 73
import "package:only_bible_app/store/actions_backup.dart";
fe24dd6 74
import "package:only_bible_app/store/app_state.dart";
fe24dd6 75
fe24dd6 76
import "app_logic_test.dart" show buildTestBible;
fe24dd6 77
fe24dd6 78
void main() {
fe24dd6 79
  group("buildBackupFileName", () {
fe24dd6 80
    test("formats a zero-padded date", () {
fe24dd6 81
      expect(
fe24dd6 82
        buildBackupFileName(DateTime.utc(2026, 3, 5)),
fe24dd6 83
        "only_bible_app_backup_2026-03-05.json",
fe24dd6 84
      );
fe24dd6 85
    });
fe24dd6 86
  });
fe24dd6 87
fe24dd6 88
  group("buildBackupJson / parseBackupState", () {
fe24dd6 89
    test("round-trips highlights and settings through export and import", () async {
fe24dd6 90
      final bible = buildTestBible();
fe24dd6 91
      final state = AppState(
fe24dd6 92
        bible: bible,
fe24dd6 93
        darkMode: true,
fe24dd6 94
        fontSize: 22,
fe24dd6 95
        highlights: {"0:0:0": 2},
fe24dd6 96
        highlightHistory: [
fe24dd6 97
          HighlightHistoryEntry(book: 0, chapter: 0, verseIndex: 0, colorIndex: 2, timestamp: DateTime.utc(2026, 1, 1)),
fe24dd6 98
        ],
fe24dd6 99
      );
fe24dd6 100
fe24dd6 101
      final jsonString = buildBackupJson(state);
fe24dd6 102
      final restored = await parseBackupState(jsonString, loadBibleFn: (_) async => bible);
fe24dd6 103
fe24dd6 104
      expect(restored.darkMode, true);
fe24dd6 105
      expect(restored.fontSize, 22);
fe24dd6 106
      expect(restored.highlights["0:0:0"], 2);
fe24dd6 107
      expect(restored.highlightHistory.single.colorIndex, 2);
fe24dd6 108
    });
fe24dd6 109
fe24dd6 110
    test("rejects malformed JSON", () async {
fe24dd6 111
      expect(
fe24dd6 112
        () => parseBackupState("not json", loadBibleFn: (_) async => buildTestBible()),
fe24dd6 113
        throwsA(isA<BackupParseException>()),
fe24dd6 114
      );
fe24dd6 115
    });
fe24dd6 116
fe24dd6 117
    test("rejects valid JSON that isn't an object", () async {
fe24dd6 118
      expect(
fe24dd6 119
        () => parseBackupState(jsonEncode([1, 2, 3]), loadBibleFn: (_) async => buildTestBible()),
fe24dd6 120
        throwsA(isA<BackupParseException>()),
fe24dd6 121
      );
fe24dd6 122
    });
fe24dd6 123
fe24dd6 124
    test("rejects a bibleName the app can't load", () async {
fe24dd6 125
      expect(
fe24dd6 126
        () => parseBackupState(
fe24dd6 127
          jsonEncode({"bibleName": "does_not_exist"}),
fe24dd6 128
          loadBibleFn: (_) async => throw Exception("no such asset"),
fe24dd6 129
        ),
fe24dd6 130
        throwsA(isA<BackupParseException>()),
fe24dd6 131
      );
fe24dd6 132
    });
fe24dd6 133
  });
fe24dd6 134
}
fe24dd6 135
```
fe24dd6 136
fe24dd6 137
- [ ] **Step 2: Run the tests to verify they fail**
fe24dd6 138
fe24dd6 139
Run: `flutter test test/actions_backup_test.dart`
fe24dd6 140
Expected: FAIL — `lib/store/actions_backup.dart` doesn't exist yet (import error).
fe24dd6 141
fe24dd6 142
- [ ] **Step 3: Implement the helpers**
fe24dd6 143
fe24dd6 144
Create `lib/store/actions_backup.dart`:
fe24dd6 145
fe24dd6 146
```dart
fe24dd6 147
import "dart:convert";
fe24dd6 148
fe24dd6 149
import "package:only_bible_app/gen/bible.gen.dart";
fe24dd6 150
import "package:only_bible_app/store/app_state.dart";
fe24dd6 151
import "package:only_bible_app/utils.dart";
fe24dd6 152
fe24dd6 153
String buildBackupFileName([DateTime? now]) {
fe24dd6 154
  final n = now ?? DateTime.now();
fe24dd6 155
  final y = n.year.toString().padLeft(4, "0");
fe24dd6 156
  final m = n.month.toString().padLeft(2, "0");
fe24dd6 157
  final d = n.day.toString().padLeft(2, "0");
fe24dd6 158
  return "only_bible_app_backup_$y-$m-$d.json";
fe24dd6 159
}
fe24dd6 160
fe24dd6 161
String buildBackupJson(AppState state) {
fe24dd6 162
  return const JsonEncoder.withIndent("  ").convert(state.toJson());
fe24dd6 163
}
fe24dd6 164
fe24dd6 165
class BackupParseException implements Exception {
fe24dd6 166
  final String message;
fe24dd6 167
fe24dd6 168
  const BackupParseException(this.message);
fe24dd6 169
fe24dd6 170
  @override
fe24dd6 171
  String toString() => message;
fe24dd6 172
}
fe24dd6 173
fe24dd6 174
Future<AppState> parseBackupState(
fe24dd6 175
  String contents, {
fe24dd6 176
  Future<Bible> Function(String) loadBibleFn = loadBible,
fe24dd6 177
}) async {
fe24dd6 178
  final Map<String, dynamic> json;
fe24dd6 179
  try {
fe24dd6 180
    final decoded = jsonDecode(contents);
fe24dd6 181
    if (decoded is! Map<String, dynamic>) {
fe24dd6 182
      throw const FormatException("backup is not a JSON object");
fe24dd6 183
    }
fe24dd6 184
    json = decoded;
fe24dd6 185
  } catch (_) {
fe24dd6 186
    throw const BackupParseException("That file isn't a valid backup");
fe24dd6 187
  }
fe24dd6 188
fe24dd6 189
  final bibleName = json["bibleName"] as String? ?? "en_kjv";
fe24dd6 190
  final Bible bible;
fe24dd6 191
  try {
fe24dd6 192
    bible = await loadBibleFn(bibleName);
fe24dd6 193
  } catch (_) {
fe24dd6 194
    throw const BackupParseException("That file isn't a valid backup");
fe24dd6 195
  }
fe24dd6 196
fe24dd6 197
  try {
fe24dd6 198
    return AppState.fromJson(json, bible);
fe24dd6 199
  } catch (_) {
fe24dd6 200
    throw const BackupParseException("That file isn't a valid backup");
fe24dd6 201
  }
fe24dd6 202
}
fe24dd6 203
```
fe24dd6 204
fe24dd6 205
- [ ] **Step 4: Run the tests to verify they pass**
fe24dd6 206
fe24dd6 207
Run: `flutter test test/actions_backup_test.dart`
fe24dd6 208
Expected: PASS (all 5 tests)
fe24dd6 209
fe24dd6 210
- [ ] **Step 5: Commit**
fe24dd6 211
fe24dd6 212
```bash
fe24dd6 213
git add lib/store/actions_backup.dart test/actions_backup_test.dart
fe24dd6 214
git commit -m "Add pure backup JSON build/parse helpers with tests"
fe24dd6 215
```
fe24dd6 216
fe24dd6 217
---
fe24dd6 218
fe24dd6 219
### Task 3: Add a reusable Yes/No confirmation dialog
fe24dd6 220
fe24dd6 221
**Files:**
fe24dd6 222
- Modify: `lib/dialog.dart`
fe24dd6 223
fe24dd6 224
**Interfaces:**
fe24dd6 225
- 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.
fe24dd6 226
fe24dd6 227
- [ ] **Step 1: Add `showConfirm` to `lib/dialog.dart`**
fe24dd6 228
fe24dd6 229
Add this function after `showError` (around line 34), following the same style as `showAlert`/`showReportError` already in the file:
fe24dd6 230
fe24dd6 231
```dart
fe24dd6 232
Future<bool> showConfirm(BuildContext context, String title, String message) async {
fe24dd6 233
  final result = await showDialog<bool>(
fe24dd6 234
    context: context,
fe24dd6 235
    barrierColor: Colors.black54,
fe24dd6 236
    builder: (dialogContext) {
fe24dd6 237
      return AlertDialog(
fe24dd6 238
        title: Text(title),
fe24dd6 239
        content: Text(message),
fe24dd6 240
        actionsAlignment: MainAxisAlignment.end,
fe24dd6 241
        actionsOverflowButtonSpacing: 8.0,
fe24dd6 242
        actions: [
fe24dd6 243
          TextButton(
fe24dd6 244
            onPressed: () => Navigator.of(dialogContext).pop(false),
fe24dd6 245
            child: const Text("Cancel"),
fe24dd6 246
          ),
fe24dd6 247
          TextButton(
fe24dd6 248
            onPressed: () => Navigator.of(dialogContext).pop(true),
fe24dd6 249
            child: const Text("Continue"),
fe24dd6 250
          ),
fe24dd6 251
        ],
fe24dd6 252
      );
fe24dd6 253
    },
fe24dd6 254
  );
fe24dd6 255
  return result ?? false;
fe24dd6 256
}
fe24dd6 257
```
fe24dd6 258
fe24dd6 259
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.
fe24dd6 260
fe24dd6 261
- [ ] **Step 2: Verify it compiles**
fe24dd6 262
fe24dd6 263
Run: `flutter analyze`
fe24dd6 264
Expected: no new errors.
fe24dd6 265
fe24dd6 266
- [ ] **Step 3: Commit**
fe24dd6 267
fe24dd6 268
```bash
fe24dd6 269
git add lib/dialog.dart
fe24dd6 270
git commit -m "Add showConfirm Yes/No dialog helper"
fe24dd6 271
```
fe24dd6 272
fe24dd6 273
---
fe24dd6 274
fe24dd6 275
### Task 4: `ExportAppStateAction`
fe24dd6 276
fe24dd6 277
**Files:**
fe24dd6 278
- Modify: `lib/store/actions_backup.dart`
fe24dd6 279
fe24dd6 280
**Interfaces:**
fe24dd6 281
- Consumes: `buildBackupFileName()`, `buildBackupJson(AppState)` (Task 2); `showError`/`showAlert` (`lib/dialog.dart`); `FilePicker.saveFile` (`file_picker`, Task 1).
fe24dd6 282
- Produces: `class ExportAppStateAction extends ReduxAction<AppState>` — constructor `ExportAppStateAction(BuildContext buildContext)`.
fe24dd6 283
fe24dd6 284
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.
fe24dd6 285
fe24dd6 286
- [ ] **Step 1: Implement the action**
fe24dd6 287
fe24dd6 288
Add to the top of `lib/store/actions_backup.dart`:
fe24dd6 289
fe24dd6 290
```dart
fe24dd6 291
import "dart:io";
fe24dd6 292
fe24dd6 293
import "package:async_redux/async_redux.dart";
fe24dd6 294
import "package:file_picker/file_picker.dart";
fe24dd6 295
import "package:flutter/foundation.dart";
fe24dd6 296
import "package:flutter/material.dart";
fe24dd6 297
import "package:only_bible_app/dialog.dart";
fe24dd6 298
```
fe24dd6 299
fe24dd6 300
(these go alongside the existing `dart:convert`/`gen/bible.gen.dart`/`app_state.dart`/`utils.dart` imports already there from Task 2)
fe24dd6 301
fe24dd6 302
Then append the action at the bottom of the file:
fe24dd6 303
fe24dd6 304
```dart
fe24dd6 305
class ExportAppStateAction extends ReduxAction<AppState> {
fe24dd6 306
  final BuildContext buildContext;
fe24dd6 307
fe24dd6 308
  ExportAppStateAction(this.buildContext);
fe24dd6 309
fe24dd6 310
  @override
fe24dd6 311
  Future<AppState?> reduce() async {
fe24dd6 312
    final jsonString = buildBackupJson(state);
fe24dd6 313
    final fileName = buildBackupFileName();
fe24dd6 314
    String? path;
fe24dd6 315
    try {
fe24dd6 316
      path = await FilePicker.saveFile(
fe24dd6 317
        fileName: fileName,
fe24dd6 318
        bytes: kIsWeb ? utf8.encode(jsonString) : null,
fe24dd6 319
      );
fe24dd6 320
      if (path == null) return null; // user cancelled the dialog
fe24dd6 321
      if (!kIsWeb) {
fe24dd6 322
        await File(path).writeAsString(jsonString);
fe24dd6 323
      }
fe24dd6 324
    } catch (_) {
fe24dd6 325
      if (buildContext.mounted) {
fe24dd6 326
        showError(buildContext, "Failed to export backup");
fe24dd6 327
      }
fe24dd6 328
      return null;
fe24dd6 329
    }
fe24dd6 330
    if (buildContext.mounted) {
fe24dd6 331
      showAlert(buildContext, "Backup Exported", "Your backup was saved successfully.");
fe24dd6 332
    }
fe24dd6 333
    return null;
fe24dd6 334
  }
fe24dd6 335
}
fe24dd6 336
```
fe24dd6 337
fe24dd6 338
- [ ] **Step 2: Verify it compiles**
fe24dd6 339
fe24dd6 340
Run: `flutter analyze`
fe24dd6 341
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).
fe24dd6 342
fe24dd6 343
- [ ] **Step 3: Commit**
fe24dd6 344
fe24dd6 345
```bash
fe24dd6 346
git add lib/store/actions_backup.dart
fe24dd6 347
git commit -m "Add ExportAppStateAction"
fe24dd6 348
```
fe24dd6 349
fe24dd6 350
---
fe24dd6 351
fe24dd6 352
### Task 5: `ImportAppStateAction`
fe24dd6 353
fe24dd6 354
**Files:**
fe24dd6 355
- Modify: `lib/store/actions_backup.dart`
fe24dd6 356
fe24dd6 357
**Interfaces:**
fe24dd6 358
- Consumes: `parseBackupState`, `BackupParseException` (Task 2); `showConfirm`, `showError`, `showAlert` (`lib/dialog.dart`); `FilePicker.pickFiles` (`file_picker`).
fe24dd6 359
- Produces: `class ImportAppStateAction extends ReduxAction<AppState>` — constructor `ImportAppStateAction(BuildContext buildContext)`.
fe24dd6 360
fe24dd6 361
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.
fe24dd6 362
fe24dd6 363
- [ ] **Step 1: Implement the action**
fe24dd6 364
fe24dd6 365
Append to `lib/store/actions_backup.dart`:
fe24dd6 366
fe24dd6 367
```dart
fe24dd6 368
class ImportAppStateAction extends ReduxAction<AppState> {
fe24dd6 369
  final BuildContext buildContext;
fe24dd6 370
fe24dd6 371
  ImportAppStateAction(this.buildContext);
fe24dd6 372
fe24dd6 373
  @override
fe24dd6 374
  Future<AppState?> reduce() async {
fe24dd6 375
    final confirmed = await showConfirm(
fe24dd6 376
      buildContext,
fe24dd6 377
      "Import Backup",
fe24dd6 378
      "This will replace all your current highlights and settings. Continue?",
fe24dd6 379
    );
fe24dd6 380
    if (!confirmed) return null;
fe24dd6 381
    if (!buildContext.mounted) return null;
fe24dd6 382
fe24dd6 383
    final result = await FilePicker.pickFiles(
fe24dd6 384
      type: FileType.custom,
fe24dd6 385
      allowedExtensions: ["json"],
fe24dd6 386
      withData: true,
fe24dd6 387
    );
fe24dd6 388
    if (result == null || result.files.isEmpty) return null; // user cancelled
fe24dd6 389
fe24dd6 390
    final bytes = result.files.single.bytes;
fe24dd6 391
    if (bytes == null) {
fe24dd6 392
      if (buildContext.mounted) {
fe24dd6 393
        showError(buildContext, "That file isn't a valid backup");
fe24dd6 394
      }
fe24dd6 395
      return null;
fe24dd6 396
    }
fe24dd6 397
fe24dd6 398
    try {
fe24dd6 399
      final newState = await parseBackupState(utf8.decode(bytes));
fe24dd6 400
      if (buildContext.mounted) {
fe24dd6 401
        showAlert(buildContext, "Backup Imported", "Your backup was restored successfully.");
fe24dd6 402
      }
fe24dd6 403
      return newState;
fe24dd6 404
    } on BackupParseException catch (err) {
fe24dd6 405
      if (buildContext.mounted) {
fe24dd6 406
        showError(buildContext, err.message);
fe24dd6 407
      }
fe24dd6 408
      return null;
fe24dd6 409
    }
fe24dd6 410
  }
fe24dd6 411
}
fe24dd6 412
```
fe24dd6 413
fe24dd6 414
- [ ] **Step 2: Verify it compiles**
fe24dd6 415
fe24dd6 416
Run: `flutter analyze`
fe24dd6 417
Expected: no new errors.
fe24dd6 418
fe24dd6 419
- [ ] **Step 3: Commit**
fe24dd6 420
fe24dd6 421
```bash
fe24dd6 422
git add lib/store/actions_backup.dart
fe24dd6 423
git commit -m "Add ImportAppStateAction"
fe24dd6 424
```
fe24dd6 425
fe24dd6 426
---
fe24dd6 427
fe24dd6 428
### Task 6: Wire the Backup section into `SettingsSheet`
fe24dd6 429
fe24dd6 430
**Files:**
fe24dd6 431
- Modify: `lib/widgets/settings_sheet.dart`
fe24dd6 432
- Modify (regenerate): `test/screenshot_test.dart` goldens for the `settings` screenshot
fe24dd6 433
fe24dd6 434
**Interfaces:**
fe24dd6 435
- Consumes: `ExportAppStateAction`, `ImportAppStateAction` (Task 4/5).
fe24dd6 436
fe24dd6 437
- [ ] **Step 1: Add the import**
fe24dd6 438
fe24dd6 439
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):
fe24dd6 440
fe24dd6 441
```dart
fe24dd6 442
import "package:only_bible_app/store/actions_backup.dart";
fe24dd6 443
```
fe24dd6 444
fe24dd6 445
- [ ] **Step 2: Add the Backup section**
fe24dd6 446
fe24dd6 447
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(),`):
fe24dd6 448
fe24dd6 449
```dart
fe24dd6 450
          const SizedBox(height: 16),
fe24dd6 451
          Text(
fe24dd6 452
            "Backup",
fe24dd6 453
            style: Theme.of(context).textTheme.titleMedium,
fe24dd6 454
          ),
fe24dd6 455
          const SizedBox(height: 8),
fe24dd6 456
          Material(
fe24dd6 457
            elevation: isDark ? 2 : 1,
fe24dd6 458
            borderRadius: BorderRadius.circular(12),
fe24dd6 459
            color: cardColor,
fe24dd6 460
            shadowColor: colorScheme.shadow,
fe24dd6 461
            child: InkWell(
fe24dd6 462
              borderRadius: BorderRadius.circular(12),
fe24dd6 463
              onTap: () => context.dispatch(ExportAppStateAction(context)),
fe24dd6 464
              child: Container(
fe24dd6 465
                padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
fe24dd6 466
                decoration: BoxDecoration(
fe24dd6 467
                  borderRadius: BorderRadius.circular(12),
fe24dd6 468
                  border: Border.all(color: cardBorderColor),
fe24dd6 469
                ),
fe24dd6 470
                child: Row(
fe24dd6 471
                  children: [
fe24dd6 472
                    Icon(Icons.download_outlined, color: colorScheme.onSurface),
fe24dd6 473
                    const SizedBox(width: 12),
fe24dd6 474
                    const Expanded(child: Text("Export Backup")),
fe24dd6 475
                  ],
fe24dd6 476
                ),
fe24dd6 477
              ),
fe24dd6 478
            ),
fe24dd6 479
          ),
fe24dd6 480
          const SizedBox(height: 8),
fe24dd6 481
          Material(
fe24dd6 482
            elevation: isDark ? 2 : 1,
fe24dd6 483
            borderRadius: BorderRadius.circular(12),
fe24dd6 484
            color: cardColor,
fe24dd6 485
            shadowColor: colorScheme.shadow,
fe24dd6 486
            child: InkWell(
fe24dd6 487
              borderRadius: BorderRadius.circular(12),
fe24dd6 488
              onTap: () => context.dispatch(ImportAppStateAction(context)),
fe24dd6 489
              child: Container(
fe24dd6 490
                padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
fe24dd6 491
                decoration: BoxDecoration(
fe24dd6 492
                  borderRadius: BorderRadius.circular(12),
fe24dd6 493
                  border: Border.all(color: cardBorderColor),
fe24dd6 494
                ),
fe24dd6 495
                child: Row(
fe24dd6 496
                  children: [
fe24dd6 497
                    Icon(Icons.upload_outlined, color: colorScheme.onSurface),
fe24dd6 498
                    const SizedBox(width: 12),
fe24dd6 499
                    const Expanded(child: Text("Import Backup")),
fe24dd6 500
                  ],
fe24dd6 501
                ),
fe24dd6 502
              ),
fe24dd6 503
            ),
fe24dd6 504
          ),
fe24dd6 505
```
fe24dd6 506
fe24dd6 507
- [ ] **Step 3: Verify it compiles and analyzes cleanly**
fe24dd6 508
fe24dd6 509
Run: `flutter analyze`
fe24dd6 510
Expected: no new errors.
fe24dd6 511
fe24dd6 512
- [ ] **Step 4: Regenerate the settings golden screenshots**
fe24dd6 513
fe24dd6 514
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:
fe24dd6 515
fe24dd6 516
Run: `flutter test test/screenshot_test.dart --update-goldens`
fe24dd6 517
Expected: exits 0; this overwrites the committed master images:
fe24dd6 518
- `ios/fastlane/screenshots/en-US/settings.png`
fe24dd6 519
- `ios/fastlane/screenshots/en-US/ipad_settings.png`
fe24dd6 520
- `android/fastlane/metadata/android/en-GB/images/phoneScreenshots/settings.png`
fe24dd6 521
fe24dd6 522
- [ ] **Step 5: Confirm the screenshot test now passes without `--update-goldens`**
fe24dd6 523
fe24dd6 524
Run: `flutter test test/screenshot_test.dart`
fe24dd6 525
Expected: PASS
fe24dd6 526
fe24dd6 527
- [ ] **Step 6: Review the updated screenshots, then commit**
fe24dd6 528
fe24dd6 529
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:
fe24dd6 530
fe24dd6 531
```bash
fe24dd6 532
git add lib/widgets/settings_sheet.dart \
fe24dd6 533
  ios/fastlane/screenshots/en-US/settings.png \
fe24dd6 534
  ios/fastlane/screenshots/en-US/ipad_settings.png \
fe24dd6 535
  android/fastlane/metadata/android/en-GB/images/phoneScreenshots/settings.png
fe24dd6 536
git commit -m "Add Export/Import Backup rows to settings sheet"
fe24dd6 537
```
fe24dd6 538
fe24dd6 539
---
fe24dd6 540
fe24dd6 541
### Task 7: Manual end-to-end verification
fe24dd6 542
fe24dd6 543
**Files:** none (no code changes)
fe24dd6 544
fe24dd6 545
- [ ] **Step 1: Run the full test suite**
fe24dd6 546
fe24dd6 547
Run: `flutter test`
fe24dd6 548
Expected: all tests pass, including the new `test/actions_backup_test.dart` and the regenerated `test/screenshot_test.dart`.
fe24dd6 549
fe24dd6 550
- [ ] **Step 2: Launch the app on macOS**
fe24dd6 551
fe24dd6 552
Run: `flutter run -d macos`
fe24dd6 553
Expected: app launches to the last-read chapter.
fe24dd6 554
fe24dd6 555
- [ ] **Step 3: Highlight a verse, then export**
fe24dd6 556
fe24dd6 557
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).
fe24dd6 558
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.
fe24dd6 559
fe24dd6 560
- [ ] **Step 4: Change state, then import the backup back**
fe24dd6 561
fe24dd6 562
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.
fe24dd6 563
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.
fe24dd6 564
fe24dd6 565
- [ ] **Step 5: Verify cancel paths don't error**
fe24dd6 566
fe24dd6 567
Tap "Export Backup" and immediately cancel the save dialog; tap "Import Backup", confirm the warning, then cancel the file-open dialog.
fe24dd6 568
Expected: no error dialog appears in either case, and app state is unchanged.
fe24dd6 569
fe24dd6 570
- [ ] **Step 6: Verify a malformed file is rejected on import**
fe24dd6 571
fe24dd6 572
Create a text file containing `not valid json` and try importing it via "Import Backup".
fe24dd6 573
Expected: an error dialog appears with "That file isn't a valid backup"; app state is unchanged.