~repos /only-bible-app

#kotlin#android#ios

GIT_CONFIG_PARAMETERS="'http.version=HTTP/1.1'" git clone https://git.pyrossh.dev/only-bible-app.git
Discussions: https://groups.google.com/g/rust-embed-devs

The only bible app you will ever need. No ads. No in-app purchases. No distractions.


Files changed (3) hide show
  1. lib/app.dart +8 -5
  2. lib/home.dart +149 -30
  3. lib/store/actions_navigation.dart +16 -9
lib/app.dart CHANGED
@@ -22,11 +22,14 @@ class App extends StatelessWidget {
22
22
  pageBuilder: (context, state) {
23
23
  final bookIndex = int.parse(state.pathParameters["bookIndex"]!);
24
24
  final chapterIndex = int.parse(state.pathParameters["chapterIndex"]!);
25
+ final extra = state.extra as ({TextDirection? slideDir, int? scrollToVerse})?;
25
- final slideDir = state.extra as TextDirection?;
26
+ final slideDir = extra?.slideDir;
27
+ final scrollToVerse = extra?.scrollToVerse;
28
+ final pageKey = ValueKey('$bookIndex-$chapterIndex');
26
29
  if (slideDir != null) {
27
30
  return CustomTransitionPage(
28
- key: state.pageKey,
31
+ key: pageKey,
29
- child: Home(bookIndex: bookIndex, chapterIndex: chapterIndex),
32
+ child: Home(bookIndex: bookIndex, chapterIndex: chapterIndex, scrollToVerse: scrollToVerse),
30
33
  transitionsBuilder: (context, animation, secondaryAnimation, child) {
31
34
  const begin = Offset(1.0, 0.0);
32
35
  const end = Offset.zero;
@@ -41,8 +44,8 @@ class App extends StatelessWidget {
41
44
  );
42
45
  }
43
46
  return NoTransitionPage(
44
- key: state.pageKey,
47
+ key: pageKey,
45
- child: Home(bookIndex: bookIndex, chapterIndex: chapterIndex),
48
+ child: Home(bookIndex: bookIndex, chapterIndex: chapterIndex, scrollToVerse: scrollToVerse),
46
49
  );
47
50
  },
48
51
  ),
lib/home.dart CHANGED
@@ -2,6 +2,7 @@ import "package:flutter/gestures.dart";
2
2
  import "package:flutter/material.dart";
3
3
  import "package:only_bible_app/gen/bible.gen.dart";
4
4
  import "package:only_bible_app/store/app_state.dart";
5
+ import "package:only_bible_app/theme.dart";
5
6
  import "package:only_bible_app/widgets/home_app_bar.dart";
6
7
  import "package:flutter_swipe_detector/flutter_swipe_detector.dart";
7
8
  import "package:only_bible_app/store/actions_navigation.dart";
@@ -9,11 +10,39 @@ import "package:only_bible_app/store/app_navigator.dart";
9
10
  import "package:only_bible_app/widgets/menu_overlay.dart";
10
11
  import "package:only_bible_app/store/actions_state.dart";
11
12
 
12
- class Home extends StatelessWidget {
13
+ class Home extends StatefulWidget {
13
14
  final int bookIndex;
14
15
  final int chapterIndex;
16
+ final int? scrollToVerse;
15
17
 
16
- const Home({super.key, required this.bookIndex, required this.chapterIndex});
18
+ const Home({super.key, required this.bookIndex, required this.chapterIndex, this.scrollToVerse});
19
+
20
+ @override
21
+ State<Home> createState() => _HomeState();
22
+ }
23
+
24
+ class _HomeState extends State<Home> {
25
+ final Map<int, GlobalKey> _verseKeys = {};
26
+
27
+ GlobalKey _keyForVerse(int index) {
28
+ return _verseKeys.putIfAbsent(index, () => GlobalKey());
29
+ }
30
+
31
+ @override
32
+ void initState() {
33
+ super.initState();
34
+ WidgetsBinding.instance.addPostFrameCallback((_) {
35
+ final scrollToVerse = widget.scrollToVerse;
36
+ if (scrollToVerse != null) {
37
+ final key = _verseKeys[scrollToVerse];
38
+ if (key?.currentContext != null) {
39
+ Scrollable.ensureVisible(
40
+ key!.currentContext!,
41
+ );
42
+ }
43
+ }
44
+ });
45
+ }
17
46
 
18
47
  List<InlineSpan> _buildHeadingSpans(
19
48
  BuildContext context,
@@ -58,7 +87,8 @@ class Home extends StatelessWidget {
58
87
  text: label,
59
88
  style: refStyle,
60
89
  recognizer: TapGestureRecognizer()
90
+ ..onTap =
61
- ..onTap = () => context.dispatch(GoToChapterAction(context.router, ref.book, ref.chapter)),
91
+ () => context.dispatch(GoToChapterAction(context.router, ref.book, ref.chapter, verse: ref.verse)),
62
92
  ),
63
93
  );
64
94
  }
@@ -77,8 +107,8 @@ class Home extends StatelessWidget {
77
107
  Widget build(BuildContext context) {
78
108
  final theme = Theme.of(context).textTheme;
79
109
  final bible = context.select((s) => s.bible);
80
- final book = bible.books![bookIndex];
110
+ final book = bible.books![widget.bookIndex];
81
- final chapter = book.chapters![chapterIndex];
111
+ final chapter = book.chapters![widget.chapterIndex];
82
112
  final (fontWeight, fontSize, selectedVerses, _, _) =
83
113
  context.select((s) => (s.fontWeight, s.fontSize, s.selectedVerses, s.highlights, s.darkMode));
84
114
  final appState = context.read();
@@ -106,35 +136,20 @@ class Home extends StatelessWidget {
106
136
  children: [
107
137
  for (final v in chapter.verses!)
108
138
  Padding(
139
+ key: _keyForVerse(v.index),
109
140
  padding: const EdgeInsets.only(bottom: 8),
110
141
  child: GestureDetector(
111
142
  onTap: () => context.dispatch(SelectVerseAction(v)),
112
143
  behavior: HitTestBehavior.opaque,
113
- child: Text.rich(
144
+ child: _VerseText(
114
- TextSpan(
145
+ verse: v,
146
+ bible: bible,
115
- style: baseStyle,
147
+ baseStyle: baseStyle,
116
- children: [
117
- if (v.heading != null && v.heading!.isNotEmpty)
118
- ..._buildHeadingSpans(
119
- context,
120
- bible,
121
- v.heading!,
122
- v.headingReferences ?? [],
123
- theme.labelLarge!,
148
+ theme: theme,
149
+ appState: appState,
150
+ buildHeadingSpans: _buildHeadingSpans,
124
- fontSize,
151
+ fontSize: fontSize,
125
- ),
126
- TextSpan(
127
- text: "${v.index + 1} ",
152
+ highlight: widget.scrollToVerse == v.index,
128
- style: theme.labelMedium!.copyWith(
129
- color: const Color(0xFF9A1111),
130
- ),
131
- ),
132
- TextSpan(
133
- text: v.text ?? "",
134
- style: appState.getHighlightStyle(context, v),
135
- ),
136
- ],
137
- ),
138
153
  ),
139
154
  ),
140
155
  ),
@@ -156,3 +171,107 @@ class Home extends StatelessWidget {
156
171
  );
157
172
  }
158
173
  }
174
+
175
+ class _VerseText extends StatefulWidget {
176
+ final Verse verse;
177
+ final Bible bible;
178
+ final TextStyle baseStyle;
179
+ final TextTheme theme;
180
+ final AppState appState;
181
+ final List<InlineSpan> Function(BuildContext, Bible, String, List<Reference>, TextStyle, double) buildHeadingSpans;
182
+ final double fontSize;
183
+ final bool highlight;
184
+
185
+ const _VerseText({
186
+ required this.verse,
187
+ required this.bible,
188
+ required this.baseStyle,
189
+ required this.theme,
190
+ required this.appState,
191
+ required this.buildHeadingSpans,
192
+ required this.fontSize,
193
+ this.highlight = false,
194
+ });
195
+
196
+ @override
197
+ State<_VerseText> createState() => _VerseTextState();
198
+ }
199
+
200
+ class _VerseTextState extends State<_VerseText> with SingleTickerProviderStateMixin {
201
+ late final AnimationController _controller;
202
+
203
+ @override
204
+ void initState() {
205
+ super.initState();
206
+ _controller = AnimationController(
207
+ vsync: this,
208
+ duration: const Duration(milliseconds: 400),
209
+ reverseDuration: const Duration(milliseconds: 500),
210
+ );
211
+ if (widget.highlight) {
212
+ WidgetsBinding.instance.addPostFrameCallback((_) {
213
+ _controller.forward().then((_) {
214
+ Future.delayed(const Duration(milliseconds: 600), () {
215
+ if (mounted) _controller.reverse();
216
+ });
217
+ });
218
+ });
219
+ }
220
+ }
221
+
222
+ @override
223
+ void dispose() {
224
+ _controller.dispose();
225
+ super.dispose();
226
+ }
227
+
228
+ @override
229
+ Widget build(BuildContext context) {
230
+ final v = widget.verse;
231
+ final theme = widget.theme;
232
+ final appState = widget.appState;
233
+
234
+ Widget buildText(Color? bgColor) {
235
+ return Text.rich(
236
+ TextSpan(
237
+ style: widget.baseStyle,
238
+ children: [
239
+ if (v.heading != null && v.heading!.isNotEmpty)
240
+ ...widget.buildHeadingSpans(
241
+ context,
242
+ widget.bible,
243
+ v.heading!,
244
+ v.headingReferences ?? [],
245
+ theme.labelLarge!,
246
+ widget.fontSize,
247
+ ),
248
+ TextSpan(
249
+ text: "${v.index + 1} ",
250
+ style: theme.labelMedium!.copyWith(
251
+ color: const Color(0xFF9A1111),
252
+ backgroundColor: bgColor,
253
+ ),
254
+ ),
255
+ TextSpan(
256
+ text: v.text ?? "",
257
+ style: bgColor != null
258
+ ? appState.getHighlightStyle(context, v).copyWith(backgroundColor: bgColor)
259
+ : appState.getHighlightStyle(context, v),
260
+ ),
261
+ ],
262
+ ),
263
+ );
264
+ }
265
+
266
+ if (!widget.highlight) return buildText(null);
267
+
268
+ return AnimatedBuilder(
269
+ animation: _controller,
270
+ builder: (context, _) {
271
+ final highlightColor = appState.darkMode ? darkHighlights[1] : lightHighlights[1];
272
+ final bgColor = highlightColor.withValues(alpha: _controller.value);
273
+ return buildText(bgColor);
274
+ },
275
+ );
276
+ }
277
+ }
lib/store/actions_navigation.dart CHANGED
@@ -107,13 +107,20 @@ class GoToChapterAction extends ReduxAction<AppState> {
107
107
  final GoRouter router;
108
108
  final int book;
109
109
  final int chapter;
110
+ final int? verse;
110
111
 
111
- GoToChapterAction(this.router, this.book, this.chapter);
112
+ GoToChapterAction(this.router, this.book, this.chapter, {this.verse});
112
113
 
113
114
  @override
114
115
  AppState reduce() {
115
116
  SoLoud.instance.disposeAllSources();
117
+ if (book > state.savedBook || (book == state.savedBook && chapter > state.savedChapter)) {
116
- router.push("/chapter/$book/$chapter");
118
+ router.push("/chapter/$book/$chapter", extra: (slideDir: TextDirection.ltr, scrollToVerse: verse));
119
+ } else if (book < state.savedBook || (book == state.savedBook && chapter < state.savedChapter)) {
120
+ router.push("/chapter/$book/$chapter", extra: (slideDir: TextDirection.rtl, scrollToVerse: verse));
121
+ } else {
122
+ router.push("/chapter/$book/$chapter", extra: (slideDir: null as TextDirection?, scrollToVerse: verse));
123
+ }
117
124
  return state.copy(
118
125
  savedBook: book,
119
126
  savedChapter: chapter,
@@ -149,7 +156,7 @@ class NextChapterAction extends ReduxAction<AppState> {
149
156
  SoLoud.instance.disposeAllSources();
150
157
  router.pushReplacement(
151
158
  "/chapter/$newBook/$newChapter",
152
- extra: TextDirection.ltr,
159
+ extra: (slideDir: TextDirection.ltr, scrollToVerse: null as int?),
153
160
  );
154
161
  return state.copy(
155
162
  savedBook: newBook,
@@ -173,6 +180,11 @@ class PreviousChapterAction extends ReduxAction<AppState> {
173
180
  int? newBook;
174
181
  int? newChapter;
175
182
 
183
+ if (router.canPop()) {
184
+ router.pop();
185
+ return null;
186
+ }
187
+
176
188
  if (chapter - 1 >= 0) {
177
189
  newBook = selectedBook.index;
178
190
  newChapter = chapter - 1;
@@ -182,16 +194,11 @@ class PreviousChapterAction extends ReduxAction<AppState> {
182
194
  newChapter = prevBook.chapters!.length - 1;
183
195
  }
184
196
 
185
- if (router.canPop()) {
186
- router.pop();
187
- return null;
188
- }
189
-
190
197
  if (newBook == null) return null;
191
198
  SoLoud.instance.disposeAllSources();
192
199
  router.pushReplacement(
193
200
  "/chapter/$newBook/$newChapter",
194
- extra: TextDirection.rtl,
201
+ extra: (slideDir: TextDirection.rtl, scrollToVerse: null as int?),
195
202
  );
196
203
  return state.copy(
197
204
  savedBook: newBook,