~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 only-bible-app
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.


lib/store/actions_navigation.dart CHANGED
@@ -6,11 +6,50 @@ import "package:only_bible_app/store/app_state.dart";
6
6
  import "package:only_bible_app/utils.dart";
7
7
  import "package:only_bible_app/widgets/book_select_sheet.dart";
8
8
 
9
+ import "package:only_bible_app/widgets/highlight_history_sheet.dart";
9
10
  import "package:only_bible_app/widgets/settings_sheet.dart";
10
11
  import "package:share_plus/share_plus.dart";
11
12
 
12
13
  String _stripRedTags(String text) => text.replaceAll(RegExp(r"</?red>"), "");
13
14
 
15
+ class ShowHighlightHistoryAction extends ReduxAction<AppState> {
16
+ final BuildContext buildContext;
17
+ final Bible bible;
18
+
19
+ ShowHighlightHistoryAction(this.buildContext, this.bible);
20
+
21
+ @override
22
+ AppState? reduce() {
23
+ showModalBottomSheet(
24
+ context: buildContext,
25
+ isDismissible: true,
26
+ enableDrag: true,
27
+ showDragHandle: true,
28
+ useSafeArea: true,
29
+ isScrollControlled: true,
30
+ builder: (context) {
31
+ final surface = Theme.of(context).colorScheme.surface;
32
+ return Theme(
33
+ data: Theme.of(context).copyWith(
34
+ bottomSheetTheme: BottomSheetThemeData(
35
+ backgroundColor: surface,
36
+ dragHandleColor: Theme.of(context).colorScheme.onSurfaceVariant,
37
+ ),
38
+ ),
39
+ child: DraggableScrollableSheet(
40
+ expand: false,
41
+ initialChildSize: 0.6,
42
+ minChildSize: 0.4,
43
+ maxChildSize: 0.9,
44
+ builder: (_, __) => HighlightHistorySheet(bible: bible),
45
+ ),
46
+ );
47
+ },
48
+ );
49
+ return null;
50
+ }
51
+ }
52
+
14
53
  class ShowSettingsAction extends ReduxAction<AppState> {
15
54
  final BuildContext buildContext;
16
55
  final Bible bible;
lib/store/actions_state.dart CHANGED
@@ -139,10 +139,21 @@ class SetHighlightAction extends ReduxAction<AppState> {
139
139
  @override
140
140
  AppState reduce() {
141
141
  final updated = Map<String, int>.from(state.highlights);
142
+ final now = DateTime.now();
142
- for (final v in verses) {
143
+ final newEntries = verses.map((v) {
143
144
  updated["${v.book}:${v.chapter}:${v.index}"] = colorIndex;
145
+ return HighlightHistoryEntry(
146
+ book: v.book,
147
+ chapter: v.chapter,
148
+ verseIndex: v.index,
149
+ colorIndex: colorIndex,
150
+ timestamp: now,
144
- }
151
+ );
152
+ }).toList();
153
+ // Keep last 500 entries
154
+ final updatedHistory = [...state.highlightHistory, ...newEntries];
155
+ final trimmed = updatedHistory.length > 500 ? updatedHistory.sublist(updatedHistory.length - 500) : updatedHistory;
145
- return state.copy(highlights: updated, selectedVerses: []);
156
+ return state.copy(highlights: updated, selectedVerses: [], highlightHistory: trimmed);
146
157
  }
147
158
  }
148
159
 
lib/store/app_state.dart CHANGED
@@ -4,6 +4,38 @@ import "package:only_bible_app/theme.dart";
4
4
  import "package:async_redux/async_redux.dart";
5
5
  export "package:async_redux/async_redux.dart" show BuildContextExtensionForProviderAndConnector;
6
6
 
7
+ class HighlightHistoryEntry {
8
+ final int book;
9
+ final int chapter;
10
+ final int verseIndex;
11
+ final int colorIndex;
12
+ final DateTime timestamp;
13
+
14
+ const HighlightHistoryEntry({
15
+ required this.book,
16
+ required this.chapter,
17
+ required this.verseIndex,
18
+ required this.colorIndex,
19
+ required this.timestamp,
20
+ });
21
+
22
+ Map<String, dynamic> toJson() => {
23
+ "book": book,
24
+ "chapter": chapter,
25
+ "verseIndex": verseIndex,
26
+ "colorIndex": colorIndex,
27
+ "timestamp": timestamp.toIso8601String(),
28
+ };
29
+
30
+ factory HighlightHistoryEntry.fromJson(Map<String, dynamic> json) => HighlightHistoryEntry(
31
+ book: json["book"] as int,
32
+ chapter: json["chapter"] as int,
33
+ verseIndex: json["verseIndex"] as int,
34
+ colorIndex: json["colorIndex"] as int,
35
+ timestamp: DateTime.parse(json["timestamp"] as String),
36
+ );
37
+ }
38
+
7
39
  class AppState {
8
40
  final bool engTitles;
9
41
  final int fontWeight;
@@ -13,6 +45,7 @@ class AppState {
13
45
  final int savedChapter;
14
46
  final List<Verse> selectedVerses;
15
47
  final Map<String, int> highlights;
48
+ final List<HighlightHistoryEntry> highlightHistory;
16
49
  final Bible bible;
17
50
 
18
51
  AppState({
@@ -24,6 +57,7 @@ class AppState {
24
57
  this.savedChapter = 0,
25
58
  this.selectedVerses = const [],
26
59
  this.highlights = const {},
60
+ this.highlightHistory = const [],
27
61
  required this.bible,
28
62
  });
29
63
 
@@ -36,6 +70,7 @@ class AppState {
36
70
  int? savedChapter,
37
71
  List<Verse>? selectedVerses,
38
72
  Map<String, int>? highlights,
73
+ List<HighlightHistoryEntry>? highlightHistory,
39
74
  Bible? bible,
40
75
  }) {
41
76
  return AppState(
@@ -47,6 +82,7 @@ class AppState {
47
82
  savedChapter: savedChapter ?? this.savedChapter,
48
83
  selectedVerses: selectedVerses ?? this.selectedVerses,
49
84
  highlights: highlights ?? this.highlights,
85
+ highlightHistory: highlightHistory ?? this.highlightHistory,
50
86
  bible: bible ?? this.bible,
51
87
  );
52
88
  }
@@ -60,6 +96,7 @@ class AppState {
60
96
  "savedBook": savedBook,
61
97
  "savedChapter": savedChapter,
62
98
  "highlights": highlights,
99
+ "highlightHistory": highlightHistory.map((e) => e.toJson()).toList(),
63
100
  };
64
101
 
65
102
  factory AppState.fromJson(Map<String, dynamic> json, Bible bible) => AppState(
@@ -70,6 +107,10 @@ class AppState {
70
107
  savedBook: json["savedBook"] as int? ?? 0,
71
108
  savedChapter: json["savedChapter"] as int? ?? 0,
72
109
  highlights: (json["highlights"] as Map<String, dynamic>?)?.map((k, v) => MapEntry(k, v as int)) ?? const {},
110
+ highlightHistory: (json["highlightHistory"] as List<dynamic>?)
111
+ ?.map((e) => HighlightHistoryEntry.fromJson(e as Map<String, dynamic>))
112
+ .toList() ??
113
+ const [],
73
114
  bible: bible,
74
115
  );
75
116
 
lib/widgets/highlight_history_sheet.dart ADDED
@@ -0,0 +1,288 @@
1
+ import "package:flutter/material.dart";
2
+ import "package:only_bible_app/gen/bible.gen.dart";
3
+ import "package:only_bible_app/store/actions_navigation.dart";
4
+ import "package:only_bible_app/store/app_navigator.dart";
5
+ import "package:only_bible_app/store/app_state.dart";
6
+ import "package:only_bible_app/theme.dart";
7
+
8
+ class HighlightHistorySheet extends StatefulWidget {
9
+ final Bible bible;
10
+
11
+ const HighlightHistorySheet({super.key, required this.bible});
12
+
13
+ @override
14
+ State<HighlightHistorySheet> createState() => _HighlightHistorySheetState();
15
+ }
16
+
17
+ class _HighlightHistorySheetState extends State<HighlightHistorySheet> {
18
+ // Tracks which day groups are expanded; today (index 0) starts open.
19
+ final Set<int> _expanded = {0};
20
+
21
+ String _formatTime(DateTime dt) {
22
+ final h = dt.hour.toString().padLeft(2, "0");
23
+ final m = dt.minute.toString().padLeft(2, "0");
24
+ return "$h:$m";
25
+ }
26
+
27
+ String _ordinal(int day) {
28
+ if (day >= 11 && day <= 13) return "${day}th";
29
+ switch (day % 10) {
30
+ case 1:
31
+ return "${day}st";
32
+ case 2:
33
+ return "${day}nd";
34
+ case 3:
35
+ return "${day}rd";
36
+ default:
37
+ return "${day}th";
38
+ }
39
+ }
40
+
41
+ String _formatDate(DateTime dt) {
42
+ const weekdays = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
43
+ const months = [
44
+ "January",
45
+ "February",
46
+ "March",
47
+ "April",
48
+ "May",
49
+ "June",
50
+ "July",
51
+ "August",
52
+ "September",
53
+ "October",
54
+ "November",
55
+ "December",
56
+ ];
57
+ final weekday = weekdays[dt.weekday - 1];
58
+ return "$weekday ${_ordinal(dt.day)} ${months[dt.month - 1]}, ${dt.year}";
59
+ }
60
+
61
+ List<InlineSpan> _parseRedText(String raw, TextStyle base, TextStyle redStyle) {
62
+ final spans = <InlineSpan>[];
63
+ final exp = RegExp(r"<red>(.*?)<\/red>", dotAll: true);
64
+ int cursor = 0;
65
+ for (final match in exp.allMatches(raw)) {
66
+ if (match.start > cursor) {
67
+ spans.add(TextSpan(text: raw.substring(cursor, match.start), style: base));
68
+ }
69
+ spans.add(TextSpan(text: match.group(1), style: redStyle));
70
+ cursor = match.end;
71
+ }
72
+ if (cursor < raw.length) {
73
+ spans.add(TextSpan(text: raw.substring(cursor), style: base));
74
+ }
75
+ return spans;
76
+ }
77
+
78
+ void _toggle(int index) {
79
+ setState(() {
80
+ if (_expanded.contains(index)) {
81
+ _expanded.remove(index);
82
+ } else {
83
+ _expanded.add(index);
84
+ }
85
+ });
86
+ }
87
+
88
+ @override
89
+ Widget build(BuildContext context) {
90
+ final history = context.select((s) => s.highlightHistory);
91
+ final darkMode = context.select((s) => s.darkMode);
92
+ final colorScheme = Theme.of(context).colorScheme;
93
+
94
+ // Group by date (newest first)
95
+ final reversed = history.reversed.toList();
96
+ final Map<String, List<HighlightHistoryEntry>> grouped = {};
97
+ for (final entry in reversed) {
98
+ final key = _formatDate(entry.timestamp);
99
+ grouped.putIfAbsent(key, () => []).add(entry);
100
+ }
101
+
102
+ final dateKeys = grouped.keys.toList();
103
+
104
+ return Padding(
105
+ padding: const EdgeInsets.symmetric(horizontal: 20),
106
+ child: Column(
107
+ crossAxisAlignment: CrossAxisAlignment.start,
108
+ children: [
109
+ Text(
110
+ "Highlight History",
111
+ style: Theme.of(context).textTheme.headlineMedium,
112
+ ),
113
+ const SizedBox(height: 12),
114
+ if (grouped.isEmpty)
115
+ Expanded(
116
+ child: Center(
117
+ child: Column(
118
+ mainAxisSize: MainAxisSize.min,
119
+ children: [
120
+ Icon(Icons.highlight_outlined, size: 48, color: colorScheme.outlineVariant),
121
+ const SizedBox(height: 12),
122
+ Text(
123
+ "No highlights yet",
124
+ style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: colorScheme.outline),
125
+ ),
126
+ ],
127
+ ),
128
+ ),
129
+ )
130
+ else
131
+ Expanded(
132
+ child: ListView.builder(
133
+ itemCount: dateKeys.length,
134
+ itemBuilder: (context, i) {
135
+ final dateLabel = dateKeys[i];
136
+ final entries = grouped[dateLabel]!;
137
+ final isExpanded = _expanded.contains(i);
138
+
139
+ return Column(
140
+ crossAxisAlignment: CrossAxisAlignment.start,
141
+ children: [
142
+ // Accordion header
143
+ InkWell(
144
+ onTap: () => _toggle(i),
145
+ borderRadius: BorderRadius.circular(6),
146
+ child: Padding(
147
+ padding: const EdgeInsets.symmetric(vertical: 10),
148
+ child: Row(
149
+ children: [
150
+ Expanded(
151
+ child: Text(
152
+ dateLabel,
153
+ style: Theme.of(context).textTheme.labelLarge?.copyWith(
154
+ color: colorScheme.primary,
155
+ fontWeight: FontWeight.bold,
156
+ ),
157
+ ),
158
+ ),
159
+ Text(
160
+ "${entries.length}",
161
+ style: Theme.of(context).textTheme.labelSmall?.copyWith(
162
+ color: colorScheme.outline,
163
+ ),
164
+ ),
165
+ const SizedBox(width: 4),
166
+ AnimatedRotation(
167
+ turns: isExpanded ? 0.5 : 0,
168
+ duration: const Duration(milliseconds: 200),
169
+ child: Icon(
170
+ Icons.keyboard_arrow_down,
171
+ size: 20,
172
+ color: colorScheme.primary,
173
+ ),
174
+ ),
175
+ ],
176
+ ),
177
+ ),
178
+ ),
179
+ // Accordion body
180
+ AnimatedCrossFade(
181
+ firstChild: const SizedBox.shrink(),
182
+ secondChild: Column(
183
+ children: entries.map((entry) {
184
+ final bookName = widget.bible.books?[entry.book].name ?? "Book ${entry.book + 1}";
185
+ final chapterNum = entry.chapter + 1;
186
+ final verseNum = entry.verseIndex + 1;
187
+ final highlightColor =
188
+ darkMode ? darkHighlights[entry.colorIndex] : lightHighlights[entry.colorIndex];
189
+ final verseText =
190
+ widget.bible.books?[entry.book].chapters?[entry.chapter].verses?[entry.verseIndex].text;
191
+
192
+ return InkWell(
193
+ onTap: () {
194
+ Navigator.of(context).pop();
195
+ context.dispatch(GoToChapterAction(
196
+ context.router,
197
+ entry.book,
198
+ entry.chapter,
199
+ verse: entry.verseIndex,
200
+ ));
201
+ },
202
+ borderRadius: BorderRadius.circular(6),
203
+ child: Padding(
204
+ padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 2),
205
+ child: Row(
206
+ crossAxisAlignment: CrossAxisAlignment.start,
207
+ children: [
208
+ SizedBox(
209
+ height: 20,
210
+ child: Padding(
211
+ padding: const EdgeInsets.only(top: 4),
212
+ child: Center(
213
+ child: Container(
214
+ width: 14,
215
+ height: 14,
216
+ decoration: BoxDecoration(
217
+ color: highlightColor,
218
+ shape: BoxShape.circle,
219
+ border:
220
+ Border.all(color: Theme.of(context).colorScheme.onSurface, width: 1),
221
+ ),
222
+ ),
223
+ ),
224
+ ),
225
+ ),
226
+ const SizedBox(width: 10),
227
+ Expanded(
228
+ child: Column(
229
+ crossAxisAlignment: CrossAxisAlignment.start,
230
+ children: [
231
+ Row(
232
+ children: [
233
+ Expanded(
234
+ child: Text(
235
+ "$bookName $chapterNum:$verseNum",
236
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
237
+ fontWeight: FontWeight.w600,
238
+ ),
239
+ ),
240
+ ),
241
+ Text(
242
+ _formatTime(entry.timestamp),
243
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
244
+ color: colorScheme.outline,
245
+ ),
246
+ ),
247
+ ],
248
+ ),
249
+ if (verseText != null && verseText.isNotEmpty) ...[
250
+ const SizedBox(height: 2),
251
+ RichText(
252
+ text: TextSpan(
253
+ children: _parseRedText(
254
+ verseText,
255
+ Theme.of(context).textTheme.bodySmall!.copyWith(
256
+ color: colorScheme.onSurfaceVariant,
257
+ ),
258
+ Theme.of(context).textTheme.bodySmall!.copyWith(
259
+ color: Colors.red,
260
+ ),
261
+ ),
262
+ ),
263
+ ),
264
+ ],
265
+ ],
266
+ ),
267
+ ),
268
+ ],
269
+ ),
270
+ ),
271
+ );
272
+ }).toList(),
273
+ ),
274
+ crossFadeState: isExpanded ? CrossFadeState.showSecond : CrossFadeState.showFirst,
275
+ duration: const Duration(milliseconds: 200),
276
+ ),
277
+ Divider(color: colorScheme.outlineVariant, height: 1),
278
+ ],
279
+ );
280
+ },
281
+ ),
282
+ ),
283
+ const SizedBox(height: 16),
284
+ ],
285
+ ),
286
+ );
287
+ }
288
+ }
lib/widgets/home_app_bar.dart CHANGED
@@ -69,6 +69,13 @@ class HomeAppBar extends StatelessWidget implements PreferredSizeWidget {
69
69
  // ),
70
70
  // ),
71
71
  // ),
72
+ IconButton(
73
+ enableFeedback: true,
74
+ key: const Key("historyButton"),
75
+ padding: EdgeInsets.zero,
76
+ icon: const Icon(Icons.history, size: 28),
77
+ onPressed: () => context.dispatch(ShowHighlightHistoryAction(context, bible)),
78
+ ),
72
79
  IconButton(
73
80
  enableFeedback: true,
74
81
  key: const Key("bible"),