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.


8b87a9bPeter John 2026-08-09T15:20:19+05:30
fix various bugs
Makefile CHANGED
@@ -37,20 +37,20 @@ list-devices:
37
37
  @echo "--- Android Emulators ---"
38
38
  emulator -list-avds 2>/dev/null || echo "No AVDs found (set ANDROID_HOME)"
39
39
 
40
- # Builds, uploads and publishes to the Play Store, App Store and Mac App Store
40
+ # Builds, uploads and publishes to the Play Store, App Store and Mac App
41
- # in one go. Screenshots are regenerated once here instead of once per
41
+ # Store in one go. Screenshots are left untouched by default - regenerating
42
- # platform (skip_screenshots:true tells each lane to reuse them).
42
+ # and re-uploading them on every release is slow and usually a no-op since
43
+ # the UI rarely changes. Set UPDATE_SCREENSHOTS=1 to regenerate and upload a
43
- release:
44
+ # fresh set as part of the release, e.g. `make release UPDATE_SCREENSHOTS=1`.
45
+ UPDATE_SCREENSHOTS_FLAG = $(if $(filter 1 true yes,$(UPDATE_SCREENSHOTS)),update_screenshots:true,)
46
+
44
- flutter test test/screenshot_test.dart --update-goldens
47
+ release: release-android release-ios release-macos
45
- $(MAKE) release-android
46
- $(MAKE) release-ios
47
- $(MAKE) release-macos
48
48
 
49
49
  release-android:
50
- cd android && fastlane deploy skip_screenshots:true
50
+ cd android && fastlane deploy $(UPDATE_SCREENSHOTS_FLAG)
51
51
 
52
52
  release-ios:
53
- cd ios && fastlane release skip_screenshots:true
53
+ cd ios && fastlane release $(UPDATE_SCREENSHOTS_FLAG)
54
54
 
55
55
  release-macos:
56
- cd macos && fastlane release skip_screenshots:true
56
+ cd macos && fastlane release $(UPDATE_SCREENSHOTS_FLAG)
android/fastlane/Fastfile CHANGED
@@ -15,6 +15,14 @@
15
15
 
16
16
  default_platform(:android)
17
17
 
18
+ # Screenshots are left untouched by default (regenerating/uploading them on
19
+ # every release is slow and usually a no-op since the UI rarely changes).
20
+ # Opt in with `fastlane deploy update_screenshots:true` or
21
+ # `UPDATE_SCREENSHOTS=true fastlane deploy`.
22
+ def update_screenshots_requested?(options)
23
+ options[:update_screenshots] || %w[1 true].include?(ENV["UPDATE_SCREENSHOTS"])
24
+ end
25
+
18
26
  platform :android do
19
27
  desc "Runs all the tests"
20
28
  lane :test do
@@ -37,7 +45,8 @@ platform :android do
37
45
 
38
46
  desc "Deploy a new version to the Google Play"
39
47
  lane :deploy do |options|
48
+ update_screenshots = update_screenshots_requested?(options)
40
- screenshots unless options[:skip_screenshots]
49
+ screenshots if update_screenshots
41
50
  # Invoking gradle directly (as the old `gradle(task: "clean assembleRelease")`
42
51
  # did) skips `flutter pub get`'s plugin-registrant regeneration, which is
43
52
  # what excludes dev-only plugins like integration_test from the release
@@ -45,13 +54,15 @@ platform :android do
45
54
  # GeneratedPluginRegistrant.java is correct for a release build. It also
46
55
  # produces an .aab (assembleRelease only produces an .apk).
47
56
  sh("cd ../.. && flutter build appbundle --release")
48
- upload
57
+ upload(update_screenshots: update_screenshots)
49
58
  end
50
59
 
51
60
  desc "Upload a pre-built AAB to the Google Play"
52
- lane :upload do
61
+ lane :upload do |options|
62
+ update_screenshots = update_screenshots_requested?(options)
53
63
  upload_to_play_store(
54
64
  aab: "../build/app/outputs/bundle/release/app-release.aab",
65
+ skip_upload_screenshots: !update_screenshots,
55
66
  )
56
67
  end
57
68
  end
ios/fastlane/Fastfile CHANGED
@@ -15,6 +15,14 @@
15
15
 
16
16
  default_platform(:ios)
17
17
 
18
+ # Screenshots are left untouched by default (regenerating/uploading them on
19
+ # every release is slow and usually a no-op since the UI rarely changes).
20
+ # Opt in with `fastlane release update_screenshots:true` or
21
+ # `UPDATE_SCREENSHOTS=true fastlane release`.
22
+ def update_screenshots_requested?(options)
23
+ options[:update_screenshots] || %w[1 true].include?(ENV["UPDATE_SCREENSHOTS"])
24
+ end
25
+
18
26
  platform :ios do
19
27
  desc "Get App Store Connect API key"
20
28
  lane :api_key do
@@ -41,7 +49,8 @@ platform :ios do
41
49
 
42
50
  desc "Push a new release build to the App Store"
43
51
  lane :release do |options|
52
+ update_screenshots = update_screenshots_requested?(options)
44
- screenshots unless options[:skip_screenshots]
53
+ screenshots if update_screenshots
45
54
  certs
46
55
  increment_build_number(xcodeproj: "Runner.xcodeproj")
47
56
  build_app(
@@ -51,18 +60,19 @@ platform :ios do
51
60
  provisioningProfiles: {
52
61
  "sh.pyros.only-bible-app" => lane_context[SharedValues::SIGH_NAME]
53
62
  }
54
- }
63
+ },
55
- )
56
- api_key = api_key()
57
- upload_to_app_store(
64
+ # Matches the path the `upload` lane's `deliver` call below expects -
58
- api_key: api_key,
65
+ # gym's default ("./Runner.ipa") doesn't, which would make `upload`
59
- overwrite_screenshots: true,
66
+ # look for an ipa that was never built there.
67
+ output_directory: "../build/ios/ipa",
60
- precheck_include_in_app_purchases: false
68
+ output_name: "only-bible-app"
61
69
  )
70
+ upload(update_screenshots: update_screenshots)
62
71
  end
63
72
 
64
73
  desc "Upload a pre-built IPA to the App Store"
65
- lane :upload do
74
+ lane :upload do |options|
75
+ update_screenshots = update_screenshots_requested?(options)
66
76
  api_key = api_key()
67
77
  deliver(
68
78
  api_key: api_key,
@@ -70,7 +80,8 @@ platform :ios do
70
80
  automatic_release: true,
71
81
  submit_for_review: true,
72
82
  precheck_include_in_app_purchases: false,
83
+ skip_screenshots: !update_screenshots,
73
- overwrite_screenshots: true
84
+ overwrite_screenshots: update_screenshots
74
85
  )
75
86
  end
76
87
  end
lib/app.dart CHANGED
@@ -79,8 +79,23 @@ class App extends StatelessWidget {
79
79
  }
80
80
 
81
81
  void _syncCurrentChapter() {
82
+ // currentConfiguration.uri only reflects declarative (go()) matches - it
83
+ // silently ignores push()/pushReplacement(), which wrap the actual
84
+ // current location in an ImperativeRouteMatch instead of updating it.
85
+ // Reading .uri directly here would report the last go()'d/initial
86
+ // location instead of where push() actually navigated to, clobbering
87
+ // savedBook/savedChapter back to a stale value right after every
88
+ // GoToChapterAction/NextChapterAction/PreviousChapterAction. Drill into
82
- final segments =
89
+ // the match list to find the real current location instead.
83
- _router.routerDelegate.currentConfiguration.uri.pathSegments;
90
+ var matchList = _router.routerDelegate.currentConfiguration;
91
+ if (matchList.matches.isEmpty) return;
92
+ var match = matchList.matches.last;
93
+ while (match is ImperativeRouteMatch) {
94
+ matchList = match.matches;
95
+ if (matchList.matches.isEmpty) return;
96
+ match = matchList.matches.last;
97
+ }
98
+ final segments = matchList.uri.pathSegments;
84
99
  if (segments.length != 3 || segments.first != "chapter") return;
85
100
 
86
101
  final book = int.tryParse(segments[1]);
macos/fastlane/Fastfile CHANGED
@@ -15,6 +15,14 @@
15
15
 
16
16
  default_platform(:mac)
17
17
 
18
+ # Screenshots are left untouched by default (regenerating/uploading them on
19
+ # every release is slow and usually a no-op since the UI rarely changes).
20
+ # Opt in with `fastlane release update_screenshots:true` or
21
+ # `UPDATE_SCREENSHOTS=true fastlane release`.
22
+ def update_screenshots_requested?(options)
23
+ options[:update_screenshots] || %w[1 true].include?(ENV["UPDATE_SCREENSHOTS"])
24
+ end
25
+
18
26
  platform :mac do
19
27
  desc "Get App Store Connect API key"
20
28
  lane :api_key do
@@ -61,20 +69,23 @@ platform :mac do
61
69
 
62
70
  desc "Push a new release build to the Mac App Store"
63
71
  lane :release do |options|
72
+ update_screenshots = update_screenshots_requested?(options)
64
- screenshots unless options[:skip_screenshots]
73
+ screenshots if update_screenshots
65
74
  build
66
75
  api_key = api_key()
67
76
  upload_to_app_store(
68
77
  api_key: api_key,
69
78
  platform: "osx",
70
79
  pkg: lane_context[SharedValues::PKG_OUTPUT_PATH],
80
+ skip_screenshots: !update_screenshots,
71
- overwrite_screenshots: true,
81
+ overwrite_screenshots: update_screenshots,
72
82
  precheck_include_in_app_purchases: false
73
83
  )
74
84
  end
75
85
 
76
86
  desc "Upload a pre-built package to the Mac App Store"
77
- lane :upload do
87
+ lane :upload do |options|
88
+ update_screenshots = update_screenshots_requested?(options)
78
89
  api_key = api_key()
79
90
  deliver(
80
91
  api_key: api_key,
@@ -83,7 +94,8 @@ platform :mac do
83
94
  automatic_release: true,
84
95
  submit_for_review: true,
85
96
  precheck_include_in_app_purchases: false,
97
+ skip_screenshots: !update_screenshots,
86
- overwrite_screenshots: true
98
+ overwrite_screenshots: update_screenshots
87
99
  )
88
100
  end
89
101
  end
readme.md CHANGED
@@ -59,13 +59,15 @@ signing details; iOS/macOS certs and provisioning profiles are fetched automatic
59
59
 
60
60
  ## Release Process
61
61
  * Create file fastlane/metadata/android/en-GB/changelogs/$versionCode.txt and add change details
62
- * Screenshots are regenerated automatically as the first step of each platform's
62
+ * Screenshots are left untouched by default regenerating and re-uploading them
63
- `deploy`/`release` lane below (via the `screenshots` lane, which runs
63
+ on every release is slow and usually a no-op since the UI rarely changes. To
64
+ update them as part of a release, opt in with `UPDATE_SCREENSHOTS=1` (or the
65
+ fastlane lane option `update_screenshots:true`). This regenerates them via
64
- `flutter test test/screenshot_test.dart --update-goldens`), so store listings
66
+ `flutter test test/screenshot_test.dart --update-goldens` and uploads them
65
- always ship with up-to-date screenshots. To update them without doing a full
66
- release, run the platform's `screenshots` lane directly, e.g. `cd android; fastlane screenshots`.
67
- For iOS/macOS, `overwrite_screenshots: true` is set so App Store Connect's existing
67
+ with `overwrite_screenshots: true` so App Store Connect's/Play Store's existing
68
- screenshots are always replaced rather than skipped as duplicates.
68
+ screenshots are replaced rather than skipped as duplicates. To update them
69
+ without doing a full release, run the platform's `screenshots` lane directly,
70
+ e.g. `cd android; fastlane screenshots`.
69
71
 
70
72
  ### All platforms at once
71
73
 
@@ -73,12 +75,15 @@ signing details; iOS/macOS certs and provisioning profiles are fetched automatic
73
75
  make release
74
76
  ```
75
77
 
76
- Regenerates screenshots once, then builds, uploads and submits the appbundle to
78
+ Builds, uploads and submits the appbundle to the Play Store, the ipa to the
77
- the Play Store, the ipa to the App Store, and the pkg to the Mac App Store,
79
+ App Store, and the pkg to the Mac App Store, one after another, without
78
- one after another. Equivalent to running `make release-android`,
80
+ touching screenshots. Equivalent to running `make release-android`,
79
81
  `make release-ios` and `make release-macos` in sequence — use those
80
82
  individually to release just one platform without the others.
81
83
 
84
+ Add `UPDATE_SCREENSHOTS=1` to also regenerate and upload fresh screenshots as
85
+ part of the release, e.g. `make release UPDATE_SCREENSHOTS=1`.
86
+
82
87
 
83
88
  # Data Backup
84
89
 
test/app_logic_test.dart CHANGED
@@ -5,10 +5,12 @@ import "package:flutter/material.dart";
5
5
  import "package:flutter_test/flutter_test.dart";
6
6
  import "package:go_router/go_router.dart";
7
7
  import "package:path_provider_platform_interface/path_provider_platform_interface.dart";
8
+ import "package:only_bible_app/app.dart";
8
9
  import "package:only_bible_app/env.dart";
9
10
  import "package:only_bible_app/gen/bible.gen.dart";
10
11
  import "package:only_bible_app/store/actions_navigation.dart";
11
12
  import "package:only_bible_app/store/actions_state.dart";
13
+ import "package:only_bible_app/store/app_navigator.dart";
12
14
  import "package:only_bible_app/store/app_persistor.dart";
13
15
  import "package:only_bible_app/store/app_state.dart";
14
16
  import "package:only_bible_app/theme.dart";
@@ -119,18 +121,19 @@ Bible buildTestBible() {
119
121
  return Bible(builder.toBytes());
120
122
  }
121
123
 
122
- /// A minimal router matching app.dart's real "/chapter/:bookIndex/:chapterIndex"
124
+ /// Pumps the real [App] widget - not a bare test [GoRouter] - and returns its
123
- /// route shape, for exercising navigation actions that call `router.push`/`pop`.
125
+ /// router. Navigation actions dispatch fine against a bare router too, but a
124
- GoRouter buildTestRouter() {
125
- return GoRouter(
126
- initialLocation: "/chapter/0/0",
126
+ /// bare router never attaches App._syncCurrentChapter's routerDelegate
127
- routes: [
127
+ /// listener, which is exactly the listener that had a bug (silently reading
128
- GoRoute(
129
- path: "/chapter/:bookIndex/:chapterIndex",
128
+ /// the wrong location for push()/pushReplacement() and clobbering
129
+ /// savedBook/savedChapter back to a stale value). Testing against a bare
130
+ /// router would give false confidence by never exercising it at all.
131
+ Future<GoRouter> pumpRealApp(WidgetTester tester, Store<AppState> store) async {
132
+ await tester.pumpWidget(
130
- builder: (context, state) => const SizedBox(),
133
+ App(globalNavigatorKey: GlobalKey<NavigatorState>(), store: store),
131
- ),
132
- ],
133
134
  );
135
+ await tester.pumpAndSettle();
136
+ return tester.element(find.byType(MaterialApp)).router;
134
137
  }
135
138
 
136
139
  class _FakePathProviderPlatform extends PathProviderPlatform {
@@ -427,51 +430,116 @@ void main() {
427
430
  // test. Switching to audioplayers (a plain platform-channel plugin, lazily
428
431
  // constructed) removed that: stopAudioPlayback() is a no-op whenever
429
432
  // playback was never started, which is always true here.
433
+ //
434
+ // All of these mount the real App widget via pumpRealApp rather than a
435
+ // bare test GoRouter, specifically so App._syncCurrentChapter's
436
+ // routerDelegate listener is genuinely exercised - see the regression test
437
+ // below for why that distinction mattered.
430
438
  group("GoToChapterAction", () {
431
- test("updates savedBook/savedChapter and pushes the route", () async {
439
+ testWidgets("updates savedBook/savedChapter and pushes the route", (
440
+ tester,
441
+ ) async {
432
442
  final bible = buildTestBible();
433
443
  final store = Store<AppState>(
434
444
  initialState: AppState(bible: bible, savedBook: 0, savedChapter: 0),
435
445
  );
436
- final router = buildTestRouter();
446
+ final router = await pumpRealApp(tester, store);
437
447
  await store.dispatchAndWait(GoToChapterAction(router, 2, 1));
448
+ await tester.pumpAndSettle();
438
449
  expect(store.state.savedBook, 2);
439
450
  expect(store.state.savedChapter, 1);
440
451
  });
452
+
453
+ testWidgets(
454
+ "savedBook/savedChapter stay put on a second read after navigating "
455
+ "(regression: App._syncCurrentChapter read the wrong location for "
456
+ "push(), clobbering them back to the pre-navigation chapter - visible "
457
+ "as the book/chapter selector reopening on the old chapter)",
458
+ (tester) async {
459
+ final bible = buildTestBible();
460
+ final store = Store<AppState>(
461
+ initialState: AppState(bible: bible, savedBook: 0, savedChapter: 0),
462
+ );
463
+ final router = await pumpRealApp(tester, store);
464
+
465
+ await store.dispatchAndWait(GoToChapterAction(router, 2, 1));
466
+ await tester.pumpAndSettle();
467
+
468
+ // Simulate "reopening the selector": read state again after the
469
+ // dust settles, the same way BookSelectSheet.initState() would.
470
+ expect(store.state.savedBook, 2);
471
+ expect(store.state.savedChapter, 1);
472
+ },
473
+ );
441
474
  });
442
475
 
443
476
  group("NextChapterAction", () {
444
- test("advances to the next chapter and updates saved state", () async {
477
+ testWidgets("advances to the next chapter and updates saved state", (
478
+ tester,
479
+ ) async {
445
480
  final bible = buildTestBible();
446
481
  final store = Store<AppState>(
447
482
  initialState: AppState(bible: bible, savedBook: 0, savedChapter: 0),
448
483
  );
449
- final router = buildTestRouter();
484
+ final router = await pumpRealApp(tester, store);
450
485
  await store.dispatchAndWait(NextChapterAction(router, bible, 0, 0));
486
+ await tester.pumpAndSettle();
451
487
  expect(store.state.savedBook, 0);
452
488
  expect(store.state.savedChapter, 1);
453
489
  });
454
490
 
455
- test("is a no-op after the very last chapter of the last book", () async {
491
+ testWidgets("is a no-op after the very last chapter of the last book", (
492
+ tester,
493
+ ) async {
456
494
  final bible = buildTestBible();
457
495
  final store = Store<AppState>(
458
496
  initialState: AppState(bible: bible, savedBook: 2, savedChapter: 1),
459
497
  );
460
- final router = buildTestRouter();
498
+ final router = await pumpRealApp(tester, store);
461
499
  await store.dispatchAndWait(NextChapterAction(router, bible, 2, 1));
500
+ await tester.pumpAndSettle();
462
501
  expect(store.state.savedBook, 2);
463
502
  expect(store.state.savedChapter, 1);
464
503
  });
504
+
505
+ testWidgets(
506
+ "still lands correctly when swiping right after a prior book/chapter-picker "
507
+ "push (pushReplacement on a multi-entry stack also gets wrapped in an "
508
+ "ImperativeRouteMatch, hitting the same drilling logic as push())",
509
+ (tester) async {
510
+ final bible = buildTestBible();
511
+ final store = Store<AppState>(
512
+ initialState: AppState(bible: bible, savedBook: 0, savedChapter: 0),
513
+ );
514
+ final router = await pumpRealApp(tester, store);
515
+
516
+ // Push to Revelation 1 the way the book/chapter picker does.
517
+ await store.dispatchAndWait(GoToChapterAction(router, 2, 0));
518
+ await tester.pumpAndSettle();
519
+ expect(store.state.savedBook, 2);
520
+ expect(store.state.savedChapter, 0);
521
+
522
+ // Swipe to Revelation 2 - a pushReplacement on top of the still
523
+ // 2-entry stack left by the push above.
524
+ await store.dispatchAndWait(NextChapterAction(router, bible, 2, 0));
525
+ await tester.pumpAndSettle();
526
+ expect(store.state.savedBook, 2);
527
+ expect(store.state.savedChapter, 1);
528
+ },
529
+ );
465
530
  });
466
531
 
467
532
  group("PreviousChapterAction", () {
468
- test("moves to the previous chapter and updates saved state", () async {
533
+ testWidgets("moves to the previous chapter and updates saved state", (
534
+ tester,
535
+ ) async {
469
536
  final bible = buildTestBible();
470
537
  final store = Store<AppState>(
471
538
  initialState: AppState(bible: bible, savedBook: 1, savedChapter: 0),
472
539
  );
473
- final router = buildTestRouter();
540
+ final router = await pumpRealApp(tester, store);
474
541
  await store.dispatchAndWait(PreviousChapterAction(router, bible, 1, 0));
542
+ await tester.pumpAndSettle();
475
543
  expect(store.state.savedBook, 0);
476
544
  expect(store.state.savedChapter, 1);
477
545
  });
@@ -486,11 +554,7 @@ void main() {
486
554
  final store = Store<AppState>(
487
555
  initialState: AppState(bible: bible, savedBook: 0, savedChapter: 0),
488
556
  );
489
- final router = buildTestRouter();
557
+ final router = await pumpRealApp(tester, store);
490
- // A mounted Navigator is required for router.pop() to find something
491
- // to pop — router.push() alone (used by the other actions above)
492
- // doesn't need one.
493
- await tester.pumpWidget(MaterialApp.router(routerConfig: router));
494
558
 
495
559
  await store.dispatchAndWait(GoToChapterAction(router, 2, 1));
496
560
  await tester.pumpAndSettle();
@@ -512,8 +576,7 @@ void main() {
512
576
  final store = Store<AppState>(
513
577
  initialState: AppState(bible: bible, savedBook: 0, savedChapter: 0),
514
578
  );
515
- final router = buildTestRouter();
579
+ final router = await pumpRealApp(tester, store);
516
- await tester.pumpWidget(MaterialApp.router(routerConfig: router));
517
580
 
518
581
  // Navigate to another chapter the way the book/chapter picker does
519
582
  // (push, not pushReplacement) - this is what used to leave a stale