~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/app.dart CHANGED
@@ -78,7 +78,7 @@ class App extends StatelessWidget {
78
78
  routerConfig: _router,
79
79
  title: "Only Bible App",
80
80
  debugShowCheckedModeBanner: false,
81
- themeMode: context.select((s) => s.darkMode) ? ThemeMode.dark : ThemeMode.light,
81
+ themeMode: context.select((s) => s.themeMode),
82
82
  theme: lightTheme,
83
83
  darkTheme: darkTheme,
84
84
  ),
lib/store/actions_state.dart CHANGED
@@ -1,8 +1,8 @@
1
1
  import "dart:developer";
2
2
 
3
3
  import "package:async_redux/async_redux.dart";
4
- import "package:flutter_soloud/flutter_soloud.dart";
5
4
  import "package:flutter/material.dart";
5
+ import "package:flutter_soloud/flutter_soloud.dart";
6
6
  import "package:only_bible_app/dialog.dart";
7
7
  import "package:only_bible_app/gen/bible.gen.dart";
8
8
  import "package:only_bible_app/store/app_state.dart";
@@ -22,9 +22,12 @@ class UpdateFontWeightAction extends ReduxAction<AppState> {
22
22
  AppState reduce() => state.copy(fontWeight: value);
23
23
  }
24
24
 
25
- class ToggleDarkModeAction extends ReduxAction<AppState> {
25
+ class SetThemeModeAction extends ReduxAction<AppState> {
26
+ final ThemeMode mode;
27
+ SetThemeModeAction(this.mode);
28
+
26
29
  @override
27
- AppState reduce() => state.copy(darkMode: !state.darkMode);
30
+ AppState reduce() => state.copy(themeMode: mode);
28
31
  }
29
32
 
30
33
  class UpdateFontSizeAction extends ReduxAction<AppState> {
@@ -157,6 +160,49 @@ class SetHighlightAction extends ReduxAction<AppState> {
157
160
  }
158
161
  }
159
162
 
163
+ class SetHighlightGroupTitleAction extends ReduxAction<AppState> {
164
+ final String groupKey;
165
+ final String title;
166
+
167
+ SetHighlightGroupTitleAction(this.groupKey, this.title);
168
+
169
+ @override
170
+ AppState reduce() {
171
+ final updated = Map<String, String>.from(state.highlightGroupTitles);
172
+ if (title.trim().isEmpty) {
173
+ updated.remove(groupKey);
174
+ } else {
175
+ updated[groupKey] = title.trim();
176
+ }
177
+ return state.copy(highlightGroupTitles: updated);
178
+ }
179
+ }
180
+
181
+ class RemoveHighlightHistoryEntryAction extends ReduxAction<AppState> {
182
+ final int book;
183
+ final int chapter;
184
+ final int verseIndex;
185
+ final DateTime timestamp;
186
+
187
+ RemoveHighlightHistoryEntryAction({
188
+ required this.book,
189
+ required this.chapter,
190
+ required this.verseIndex,
191
+ required this.timestamp,
192
+ });
193
+
194
+ @override
195
+ AppState reduce() {
196
+ final updated = state.highlightHistory.where((e) {
197
+ return !(e.book == book &&
198
+ e.chapter == chapter &&
199
+ e.verseIndex == verseIndex &&
200
+ e.timestamp == timestamp);
201
+ }).toList();
202
+ return state.copy(highlightHistory: updated);
203
+ }
204
+ }
205
+
160
206
  class RemoveHighlightAction extends ReduxAction<AppState> {
161
207
  final List<Verse> verses;
162
208
 
lib/store/app_state.dart CHANGED
@@ -4,6 +4,22 @@ 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
+ ThemeMode _themeModeFromString(String? s) {
8
+ switch (s) {
9
+ case "dark": return ThemeMode.dark;
10
+ case "system": return ThemeMode.system;
11
+ default: return ThemeMode.light;
12
+ }
13
+ }
14
+
15
+ String _themeModeToString(ThemeMode m) {
16
+ switch (m) {
17
+ case ThemeMode.dark: return "dark";
18
+ case ThemeMode.system: return "system";
19
+ case ThemeMode.light: return "light";
20
+ }
21
+ }
22
+
7
23
  class HighlightHistoryEntry {
8
24
  final int book;
9
25
  final int chapter;
@@ -39,50 +55,59 @@ class HighlightHistoryEntry {
39
55
  class AppState {
40
56
  final bool engTitles;
41
57
  final int fontWeight;
42
- final bool darkMode;
58
+ final ThemeMode themeMode;
43
59
  final double fontSize;
44
60
  final int savedBook;
45
61
  final int savedChapter;
46
62
  final List<Verse> selectedVerses;
47
63
  final Map<String, int> highlights;
48
64
  final List<HighlightHistoryEntry> highlightHistory;
65
+ // Keys are group-label strings (e.g. "Monday 6th June, 2026"). Values are user-written titles.
66
+ final Map<String, String> highlightGroupTitles;
49
67
  final Bible bible;
50
68
 
51
69
  AppState({
52
70
  this.engTitles = false,
53
71
  this.fontWeight = 400,
54
- this.darkMode = false,
72
+ this.themeMode = ThemeMode.system,
55
73
  this.fontSize = 17.0,
56
74
  this.savedBook = 0,
57
75
  this.savedChapter = 0,
58
76
  this.selectedVerses = const [],
59
77
  this.highlights = const {},
60
78
  this.highlightHistory = const [],
79
+ this.highlightGroupTitles = const {},
61
80
  required this.bible,
62
81
  });
63
82
 
83
+ /// True only when dark mode is explicitly selected (not system).
84
+ /// Use Theme.of(context).brightness for system-aware checks in widgets.
85
+ bool get darkMode => themeMode == ThemeMode.dark;
86
+
64
87
  AppState copy({
65
88
  bool? engTitles,
66
89
  int? fontWeight,
67
- bool? darkMode,
90
+ ThemeMode? themeMode,
68
91
  double? fontSize,
69
92
  int? savedBook,
70
93
  int? savedChapter,
71
94
  List<Verse>? selectedVerses,
72
95
  Map<String, int>? highlights,
73
96
  List<HighlightHistoryEntry>? highlightHistory,
97
+ Map<String, String>? highlightGroupTitles,
74
98
  Bible? bible,
75
99
  }) {
76
100
  return AppState(
77
101
  engTitles: engTitles ?? this.engTitles,
78
102
  fontWeight: fontWeight ?? this.fontWeight,
79
- darkMode: darkMode ?? this.darkMode,
103
+ themeMode: themeMode ?? this.themeMode,
80
104
  fontSize: fontSize ?? this.fontSize,
81
105
  savedBook: savedBook ?? this.savedBook,
82
106
  savedChapter: savedChapter ?? this.savedChapter,
83
107
  selectedVerses: selectedVerses ?? this.selectedVerses,
84
108
  highlights: highlights ?? this.highlights,
85
109
  highlightHistory: highlightHistory ?? this.highlightHistory,
110
+ highlightGroupTitles: highlightGroupTitles ?? this.highlightGroupTitles,
86
111
  bible: bible ?? this.bible,
87
112
  );
88
113
  }
@@ -91,18 +116,24 @@ class AppState {
91
116
  "bibleName": bible.name,
92
117
  "engTitles": engTitles,
93
118
  "fontWeight": fontWeight,
94
- "darkMode": darkMode,
119
+ "themeMode": _themeModeToString(themeMode),
95
120
  "fontSize": fontSize,
96
121
  "savedBook": savedBook,
97
122
  "savedChapter": savedChapter,
98
123
  "highlights": highlights,
99
124
  "highlightHistory": highlightHistory.map((e) => e.toJson()).toList(),
125
+ "highlightGroupTitles": highlightGroupTitles,
100
126
  };
101
127
 
102
128
  factory AppState.fromJson(Map<String, dynamic> json, Bible bible) => AppState(
103
129
  engTitles: json["engTitles"] as bool? ?? false,
104
130
  fontWeight: json["fontWeight"] as int? ?? (json["boldFont"] == true ? 500 : 400),
131
+ // Migrate legacy bool "darkMode" → ThemeMode string; new installs default to system
105
- darkMode: json["darkMode"] as bool? ?? false,
132
+ themeMode: json["themeMode"] != null
133
+ ? _themeModeFromString(json["themeMode"] as String?)
134
+ : (json.containsKey("darkMode")
135
+ ? ((json["darkMode"] as bool? ?? false) ? ThemeMode.dark : ThemeMode.light)
136
+ : ThemeMode.system),
106
137
  fontSize: (json["fontSize"] as num?)?.toDouble() ?? ((json["textScale"] as num?)?.toDouble() ?? 0.0) + 16.0,
107
138
  savedBook: json["savedBook"] as int? ?? 0,
108
139
  savedChapter: json["savedChapter"] as int? ?? 0,
@@ -111,14 +142,17 @@ class AppState {
111
142
  ?.map((e) => HighlightHistoryEntry.fromJson(e as Map<String, dynamic>))
112
143
  .toList() ??
113
144
  const [],
145
+ highlightGroupTitles:
146
+ (json["highlightGroupTitles"] as Map<String, dynamic>?)?.map((k, v) => MapEntry(k, v as String)) ??
147
+ const {},
114
148
  bible: bible,
115
149
  );
116
150
 
117
- Color? getHighlight(Verse v) {
151
+ Color? getHighlight(Verse v, {required bool effectiveDark}) {
118
152
  final key = "${v.book}:${v.chapter}:${v.index}";
119
153
  final index = highlights[key];
120
154
  if (index == null) return null;
121
- return darkMode ? darkHighlights[index] : lightHighlights[index];
155
+ return effectiveDark ? darkHighlights[index] : lightHighlights[index];
122
156
  }
123
157
 
124
158
  bool isVerseSelected(Verse v) {
@@ -128,6 +162,7 @@ class AppState {
128
162
  }
129
163
 
130
164
  TextStyle getHighlightStyle(BuildContext context, Verse v) {
165
+ final effectiveDark = Theme.of(context).brightness == Brightness.dark;
131
166
  final isSelected = selectedVerses.any(
132
167
  (el) => el.book == v.book && el.chapter == v.chapter && el.index == v.index,
133
168
  );
@@ -136,13 +171,13 @@ class AppState {
136
171
  backgroundColor: Theme.of(context).textSelectionTheme.selectionColor,
137
172
  );
138
173
  }
139
- if (darkMode) {
174
+ if (effectiveDark) {
140
175
  return TextStyle(
141
- color: getHighlight(v) ?? Theme.of(context).colorScheme.onSurface,
176
+ color: getHighlight(v, effectiveDark: true) ?? Theme.of(context).colorScheme.onSurface,
142
177
  );
143
178
  }
144
179
  return TextStyle(
145
- backgroundColor: getHighlight(v) ?? Theme.of(context).colorScheme.surface,
180
+ backgroundColor: getHighlight(v, effectiveDark: false) ?? Theme.of(context).colorScheme.surface,
146
181
  );
147
182
  }
148
183
  }
lib/widgets/highlight_history_sheet.dart CHANGED
@@ -1,10 +1,30 @@
1
1
  import "package:flutter/material.dart";
2
2
  import "package:only_bible_app/gen/bible.gen.dart";
3
3
  import "package:only_bible_app/store/actions_navigation.dart";
4
+ import "package:only_bible_app/store/actions_state.dart";
4
5
  import "package:only_bible_app/store/app_navigator.dart";
5
6
  import "package:only_bible_app/store/app_state.dart";
6
7
  import "package:only_bible_app/theme.dart";
7
8
 
9
+ enum _Grouping { day, month, year }
10
+
11
+ // Pre-computed data for one accordion group, passed to _DayGroup.
12
+ class _GroupData {
13
+ final int index;
14
+ final String dateLabel;
15
+ final String groupTitle;
16
+ final List<HighlightHistoryEntry> entries;
17
+ final bool isExpanded;
18
+
19
+ const _GroupData({
20
+ required this.index,
21
+ required this.dateLabel,
22
+ required this.groupTitle,
23
+ required this.entries,
24
+ required this.isExpanded,
25
+ });
26
+ }
27
+
8
28
  class HighlightHistorySheet extends StatefulWidget {
9
29
  final Bible bible;
10
30
 
@@ -15,103 +35,301 @@ class HighlightHistorySheet extends StatefulWidget {
15
35
  }
16
36
 
17
37
  class _HighlightHistorySheetState extends State<HighlightHistorySheet> {
18
- // Tracks which day groups are expanded; today (index 0) starts open.
38
+ // ── Filter state ──────────────────────────────────────────────────────────
39
+ bool _showFilter = false;
19
- final Set<int> _expanded = {0};
40
+ _Grouping _grouping = _Grouping.day;
41
+ int? _selectedYear; // null = all years
42
+ int? _selectedMonth; // null = all months (only used when grouping == day)
20
43
 
21
- String _formatTime(DateTime dt) {
22
- final h = dt.hour.toString().padLeft(2, "0");
44
+ // ── Accordion state ───────────────────────────────────────────────────────
23
- final m = dt.minute.toString().padLeft(2, "0");
45
+ final Set<int> _collapsed = {};
46
+
24
- return "$h:$m";
47
+ // ── Formatting helpers ────────────────────────────────────────────────────
25
- }
26
48
 
27
49
  String _ordinal(int day) {
28
50
  if (day >= 11 && day <= 13) return "${day}th";
29
51
  switch (day % 10) {
30
- case 1:
31
- return "${day}st";
52
+ case 1: return "${day}st";
32
- case 2:
33
- return "${day}nd";
53
+ case 2: return "${day}nd";
34
- case 3:
35
- return "${day}rd";
54
+ case 3: return "${day}rd";
36
- default:
37
- return "${day}th";
55
+ default: return "${day}th";
38
56
  }
39
57
  }
40
58
 
41
- String _formatDate(DateTime dt) {
59
+ static const _weekdays = [
42
- const weekdays = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
60
+ "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
- ];
61
+ ];
57
- final weekday = weekdays[dt.weekday - 1];
62
+ static const _monthNames = [
58
- return "$weekday ${_ordinal(dt.day)} ${months[dt.month - 1]}, ${dt.year}";
63
+ "January", "February", "March", "April", "May", "June",
64
+ "July", "August", "September", "October", "November", "December",
59
- }
65
+ ];
60
66
 
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)) {
67
+ String _groupKey(DateTime dt) {
66
- if (match.start > cursor) {
68
+ switch (_grouping) {
69
+ case _Grouping.day:
67
- spans.add(TextSpan(text: raw.substring(cursor, match.start), style: base));
70
+ return "${_weekdays[dt.weekday - 1]} ${_ordinal(dt.day)} ${_monthNames[dt.month - 1]}, ${dt.year}";
68
- }
71
+ case _Grouping.month:
69
- spans.add(TextSpan(text: match.group(1), style: redStyle));
72
+ return "${_monthNames[dt.month - 1]} ${dt.year}";
70
- cursor = match.end;
73
+ case _Grouping.year:
74
+ return "${dt.year}";
71
75
  }
76
+ }
77
+
78
+ bool _entryPassesFilter(HighlightHistoryEntry e) {
79
+ if (_selectedYear != null && e.timestamp.year != _selectedYear) return false;
80
+ if (_grouping == _Grouping.day && _selectedMonth != null && e.timestamp.month != _selectedMonth) return false;
81
+ return true;
82
+ }
83
+
84
+ // ── Dedup ─────────────────────────────────────────────────────────────────
85
+
86
+ List<HighlightHistoryEntry> _dedup(List<HighlightHistoryEntry> entries) {
72
- if (cursor < raw.length) {
87
+ final seen = <String>{};
88
+ final result = <HighlightHistoryEntry>[];
89
+ for (final e in entries) {
73
- spans.add(TextSpan(text: raw.substring(cursor), style: base));
90
+ if (seen.add("${e.book}:${e.chapter}:${e.verseIndex}")) result.add(e);
74
91
  }
75
- return spans;
92
+ return result;
76
93
  }
77
94
 
78
- void _toggle(int index) {
95
+ void _toggle(int index) => setState(() {
79
- setState(() {
80
- if (_expanded.contains(index)) {
96
+ if (_collapsed.contains(index)) {
81
- _expanded.remove(index);
97
+ _collapsed.remove(index);
82
- } else {
98
+ } else {
83
- _expanded.add(index);
99
+ _collapsed.add(index);
84
- }
100
+ }
85
- });
101
+ });
102
+
103
+ String _filterSummary() {
104
+ final groupLabel = _grouping.name[0].toUpperCase() + _grouping.name.substring(1);
105
+ if (_selectedYear == null) return groupLabel;
106
+ if (_grouping == _Grouping.day && _selectedMonth != null) {
107
+ return "$groupLabel · ${_monthNames[_selectedMonth! - 1]} $_selectedYear";
108
+ }
109
+ if (_grouping == _Grouping.month) return "$groupLabel · $_selectedYear";
110
+ return "$groupLabel · $_selectedYear";
86
111
  }
87
112
 
88
113
  @override
89
114
  Widget build(BuildContext context) {
90
115
  final history = context.select((s) => s.highlightHistory);
91
- final darkMode = context.select((s) => s.darkMode);
116
+ final groupTitles = context.select((s) => s.highlightGroupTitles);
117
+ final darkMode = Theme.of(context).brightness == Brightness.dark;
92
118
  final colorScheme = Theme.of(context).colorScheme;
93
119
 
120
+ // Derive available years / months from the full history.
121
+ final allYears = history.map((e) => e.timestamp.year).toSet().toList()..sort((a, b) => b.compareTo(a));
122
+ final availableMonths = _selectedYear == null
123
+ ? <int>[]
124
+ : history
125
+ .where((e) => e.timestamp.year == _selectedYear)
126
+ .map((e) => e.timestamp.month)
127
+ .toSet()
128
+ .toList()
129
+ ..sort();
130
+
94
- // Group by date (newest first)
131
+ // Filter then group.
95
- final reversed = history.reversed.toList();
132
+ final filtered = history.reversed.where(_entryPassesFilter).toList();
96
133
  final Map<String, List<HighlightHistoryEntry>> grouped = {};
97
- for (final entry in reversed) {
134
+ for (final entry in filtered) {
98
- final key = _formatDate(entry.timestamp);
99
- grouped.putIfAbsent(key, () => []).add(entry);
135
+ grouped.putIfAbsent(_groupKey(entry.timestamp), () => []).add(entry);
100
136
  }
101
137
 
138
+ // Pre-compute list — zero context reads inside itemBuilder.
102
- final dateKeys = grouped.keys.toList();
139
+ final groups = grouped.entries.toList().asMap().entries.map((kv) {
140
+ final i = kv.key;
141
+ final e = kv.value;
142
+ return _GroupData(
143
+ index: i,
144
+ dateLabel: e.key,
145
+ groupTitle: groupTitles[e.key] ?? "",
146
+ entries: _dedup(e.value),
147
+ isExpanded: !_collapsed.contains(i),
148
+ );
149
+ }).toList();
150
+
151
+ final hasActiveFilter = _selectedYear != null || _grouping != _Grouping.day;
103
152
 
104
153
  return Padding(
105
154
  padding: const EdgeInsets.symmetric(horizontal: 20),
106
155
  child: Column(
107
156
  crossAxisAlignment: CrossAxisAlignment.start,
108
157
  children: [
158
+ // ── Header ────────────────────────────────────────────────────────
159
+ Row(
160
+ children: [
161
+ Expanded(
162
+ child: Text("Highlight History", style: Theme.of(context).textTheme.headlineMedium),
163
+ ),
164
+ // Filter button
165
+ GestureDetector(
166
+ onTap: () => setState(() => _showFilter = !_showFilter),
167
+ child: AnimatedContainer(
168
+ duration: const Duration(milliseconds: 150),
169
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
170
+ decoration: BoxDecoration(
171
+ color: (_showFilter || hasActiveFilter) ? colorScheme.primary : Colors.transparent,
172
+ borderRadius: BorderRadius.circular(20),
173
+ border: Border.all(
174
+ color: (_showFilter || hasActiveFilter) ? colorScheme.primary : colorScheme.outlineVariant,
175
+ ),
176
+ ),
177
+ child: Row(
178
+ mainAxisSize: MainAxisSize.min,
179
+ children: [
180
+ Icon(
181
+ Icons.tune,
182
+ size: 14,
183
+ color: (_showFilter || hasActiveFilter) ? colorScheme.onPrimary : colorScheme.onSurfaceVariant,
184
+ ),
185
+ const SizedBox(width: 5),
109
- Text(
186
+ Text(
110
- "Highlight History",
187
+ _filterSummary(),
111
- style: Theme.of(context).textTheme.headlineMedium,
188
+ style: Theme.of(context).textTheme.labelSmall?.copyWith(
189
+ color: (_showFilter || hasActiveFilter)
190
+ ? colorScheme.onPrimary
191
+ : colorScheme.onSurfaceVariant,
192
+ fontWeight: FontWeight.w600,
193
+ ),
194
+ ),
195
+ ],
196
+ ),
197
+ ),
198
+ ),
199
+ ],
200
+ ),
201
+
202
+ // ── Filter panel ──────────────────────────────────────────────────
203
+ AnimatedCrossFade(
204
+ duration: const Duration(milliseconds: 200),
205
+ crossFadeState: _showFilter ? CrossFadeState.showSecond : CrossFadeState.showFirst,
206
+ firstChild: const SizedBox.shrink(),
207
+ secondChild: Container(
208
+ margin: const EdgeInsets.only(top: 10),
209
+ padding: const EdgeInsets.all(12),
210
+ decoration: BoxDecoration(
211
+ color: colorScheme.surfaceContainerHighest,
212
+ borderRadius: BorderRadius.circular(12),
213
+ ),
214
+ child: Column(
215
+ crossAxisAlignment: CrossAxisAlignment.start,
216
+ children: [
217
+ // Row 1 — Grouping
218
+ Text(
219
+ "Group by",
220
+ style: Theme.of(context).textTheme.labelSmall?.copyWith(color: colorScheme.outline),
221
+ ),
222
+ const SizedBox(height: 6),
223
+ Row(
224
+ children: [
225
+ _Pill(
226
+ label: "Day",
227
+ selected: _grouping == _Grouping.day,
228
+ onTap: () => setState(() {
229
+ _grouping = _Grouping.day;
230
+ _collapsed.clear();
231
+ }),
232
+ ),
233
+ const SizedBox(width: 6),
234
+ _Pill(
235
+ label: "Month",
236
+ selected: _grouping == _Grouping.month,
237
+ onTap: () => setState(() {
238
+ _grouping = _Grouping.month;
239
+ _selectedMonth = null;
240
+ _collapsed.clear();
241
+ }),
242
+ ),
243
+ const SizedBox(width: 6),
244
+ _Pill(
245
+ label: "Year",
246
+ selected: _grouping == _Grouping.year,
247
+ onTap: () => setState(() {
248
+ _grouping = _Grouping.year;
249
+ _selectedYear = null;
250
+ _selectedMonth = null;
251
+ _collapsed.clear();
252
+ }),
253
+ ),
254
+ ],
255
+ ),
256
+
257
+ // Row 2 — Year (hidden when grouping == year)
258
+ if (_grouping != _Grouping.year && allYears.isNotEmpty) ...[
259
+ const SizedBox(height: 10),
260
+ Text(
261
+ "Year",
262
+ style: Theme.of(context).textTheme.labelSmall?.copyWith(color: colorScheme.outline),
263
+ ),
264
+ const SizedBox(height: 6),
265
+ SingleChildScrollView(
266
+ scrollDirection: Axis.horizontal,
267
+ child: Row(
268
+ children: [
269
+ _Pill(
270
+ label: "All",
271
+ selected: _selectedYear == null,
272
+ onTap: () => setState(() {
273
+ _selectedYear = null;
274
+ _selectedMonth = null;
275
+ _collapsed.clear();
276
+ }),
277
+ ),
278
+ ...allYears.map((y) => Padding(
279
+ padding: const EdgeInsets.only(left: 6),
280
+ child: _Pill(
281
+ label: "$y",
282
+ selected: _selectedYear == y,
283
+ onTap: () => setState(() {
284
+ _selectedYear = y;
285
+ _selectedMonth = null;
286
+ _collapsed.clear();
287
+ }),
288
+ ),
289
+ )),
290
+ ],
291
+ ),
292
+ ),
293
+ ],
294
+
295
+ // Row 3 — Month (only when grouping == day and a year is selected)
296
+ if (_grouping == _Grouping.day && _selectedYear != null && availableMonths.isNotEmpty) ...[
297
+ const SizedBox(height: 10),
298
+ Text(
299
+ "Month",
300
+ style: Theme.of(context).textTheme.labelSmall?.copyWith(color: colorScheme.outline),
301
+ ),
302
+ const SizedBox(height: 6),
303
+ SingleChildScrollView(
304
+ scrollDirection: Axis.horizontal,
305
+ child: Row(
306
+ children: [
307
+ _Pill(
308
+ label: "All",
309
+ selected: _selectedMonth == null,
310
+ onTap: () => setState(() { _selectedMonth = null; _collapsed.clear(); }),
311
+ ),
312
+ ...availableMonths.map((m) => Padding(
313
+ padding: const EdgeInsets.only(left: 6),
314
+ child: _Pill(
315
+ label: _monthNames[m - 1],
316
+ selected: _selectedMonth == m,
317
+ onTap: () => setState(() { _selectedMonth = m; _collapsed.clear(); }),
318
+ ),
319
+ )),
320
+ ],
321
+ ),
322
+ ),
323
+ ],
324
+ ],
325
+ ),
326
+ ),
112
327
  ),
328
+
113
329
  const SizedBox(height: 12),
330
+
331
+ // ── Empty state ───────────────────────────────────────────────────
114
- if (grouped.isEmpty)
332
+ if (groups.isEmpty)
115
333
  Expanded(
116
334
  child: Center(
117
335
  child: Column(
@@ -128,160 +346,328 @@ class _HighlightHistorySheetState extends State<HighlightHistorySheet> {
128
346
  ),
129
347
  )
130
348
  else
349
+ // ── Accordion list ─────────────────────────────────────────────
131
350
  Expanded(
132
351
  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
- ),
352
+ itemCount: groups.length,
353
+ itemBuilder: (_, i) => _DayGroup(
354
+ data: groups[i],
355
+ bible: widget.bible,
356
+ darkMode: darkMode,
357
+ onToggle: () => _toggle(i),
358
+ ),
359
+ ),
360
+ ),
361
+ const SizedBox(height: 16),
362
+ ],
363
+ ),
364
+ );
365
+ }
366
+ }
367
+
368
+ // ── Per-group accordion widget ─────────────────────────────────────────────
369
+
370
+ class _DayGroup extends StatelessWidget {
371
+ final _GroupData data;
372
+ final Bible bible;
373
+ final bool darkMode;
374
+ final VoidCallback onToggle;
375
+
376
+ const _DayGroup({
377
+ required this.data,
378
+ required this.bible,
379
+ required this.darkMode,
380
+ required this.onToggle,
381
+ });
382
+
383
+ static List<InlineSpan> _parseRedText(String raw, TextStyle base, TextStyle redStyle) {
384
+ final spans = <InlineSpan>[];
385
+ final exp = RegExp(r"<red>(.*?)<\/red>", dotAll: true);
386
+ int cursor = 0;
387
+ for (final match in exp.allMatches(raw)) {
388
+ if (match.start > cursor) {
389
+ spans.add(TextSpan(text: raw.substring(cursor, match.start), style: base));
390
+ }
391
+ spans.add(TextSpan(text: match.group(1), style: redStyle));
392
+ cursor = match.end;
393
+ }
394
+ if (cursor < raw.length) {
395
+ spans.add(TextSpan(text: raw.substring(cursor), style: base));
396
+ }
397
+ return spans;
398
+ }
399
+
400
+ static String _formatTime(DateTime dt) {
401
+ final h = dt.hour.toString().padLeft(2, "0");
402
+ final m = dt.minute.toString().padLeft(2, "0");
403
+ return "$h:$m";
404
+ }
405
+
406
+ @override
407
+ Widget build(BuildContext context) {
408
+ final colorScheme = Theme.of(context).colorScheme;
409
+
410
+ return Column(
411
+ crossAxisAlignment: CrossAxisAlignment.start,
412
+ children: [
413
+ // Accordion header
414
+ InkWell(
415
+ onTap: onToggle,
416
+ borderRadius: BorderRadius.circular(6),
417
+ child: Padding(
418
+ padding: const EdgeInsets.symmetric(vertical: 10),
419
+ child: Row(
420
+ children: [
421
+ Expanded(
422
+ child: Text(
423
+ data.dateLabel,
424
+ style: Theme.of(context).textTheme.labelLarge?.copyWith(
425
+ color: colorScheme.primary,
426
+ fontWeight: FontWeight.bold,
427
+ ),
428
+ ),
429
+ ),
430
+ Text(
431
+ "${data.entries.length}",
432
+ style: Theme.of(context).textTheme.labelSmall?.copyWith(color: colorScheme.outline),
433
+ ),
434
+ const SizedBox(width: 4),
435
+ AnimatedRotation(
436
+ turns: data.isExpanded ? 0.5 : 0,
437
+ duration: const Duration(milliseconds: 200),
438
+ child: Icon(Icons.keyboard_arrow_down, size: 20, color: colorScheme.primary),
439
+ ),
440
+ ],
441
+ ),
442
+ ),
443
+ ),
444
+ // Editable group title
445
+ _GroupTitleField(groupKey: data.dateLabel, initialValue: data.groupTitle),
446
+ // Accordion body
447
+ AnimatedCrossFade(
448
+ firstChild: const SizedBox.shrink(),
449
+ secondChild: Column(
450
+ children: data.entries.map((entry) {
451
+ final bookName = bible.books?[entry.book].name ?? "Book ${entry.book + 1}";
452
+ final chapterNum = entry.chapter + 1;
453
+ final verseNum = entry.verseIndex + 1;
454
+ final highlightColor = darkMode ? darkHighlights[entry.colorIndex] : lightHighlights[entry.colorIndex];
455
+ final verseText = bible.books?[entry.book].chapters?[entry.chapter].verses?[entry.verseIndex].text;
456
+
457
+ return Dismissible(
458
+ key: ValueKey(
459
+ "${entry.book}:${entry.chapter}:${entry.verseIndex}:${entry.timestamp.millisecondsSinceEpoch}",
460
+ ),
461
+ direction: DismissDirection.endToStart,
462
+ background: Container(
463
+ alignment: Alignment.centerRight,
464
+ padding: const EdgeInsets.only(right: 16),
465
+ decoration: BoxDecoration(
466
+ color: colorScheme.errorContainer,
467
+ borderRadius: BorderRadius.circular(6),
468
+ ),
469
+ child: Icon(Icons.delete_outline, color: colorScheme.onErrorContainer),
470
+ ),
471
+ onDismissed: (_) => context.dispatch(
472
+ RemoveHighlightHistoryEntryAction(
473
+ book: entry.book,
474
+ chapter: entry.chapter,
475
+ verseIndex: entry.verseIndex,
476
+ timestamp: entry.timestamp,
477
+ ),
478
+ ),
479
+ child: InkWell(
480
+ onTap: () {
481
+ Navigator.of(context).pop();
482
+ context.dispatch(GoToChapterAction(
483
+ context.router,
484
+ entry.book,
485
+ entry.chapter,
486
+ verse: entry.verseIndex,
487
+ ));
488
+ },
489
+ borderRadius: BorderRadius.circular(6),
490
+ child: Padding(
491
+ padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 2),
492
+ child: Row(
493
+ crossAxisAlignment: CrossAxisAlignment.start,
494
+ children: [
495
+ SizedBox(
496
+ height: 20,
497
+ child: Center(
498
+ child: Container(
499
+ width: 14,
500
+ height: 14,
501
+ decoration: BoxDecoration(
502
+ color: highlightColor,
503
+ shape: BoxShape.circle,
504
+ border: Border.all(color: colorScheme.outlineVariant, width: 0.5),
174
505
  ),
175
- ],
506
+ ),
176
507
  ),
177
508
  ),
178
- ),
179
- // Accordion body
180
- AnimatedCrossFade(
181
- firstChild: const SizedBox.shrink(),
509
+ const SizedBox(width: 10),
510
+ Expanded(
182
- secondChild: Column(
511
+ child: 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,
512
+ crossAxisAlignment: CrossAxisAlignment.start,
207
- children: [
513
+ children: [
208
- SizedBox(
514
+ Row(
209
- height: 20,
210
- child: Padding(
515
+ children: [
211
- padding: const EdgeInsets.only(top: 4),
516
+ Expanded(
212
- child: Center(
517
+ child: Text(
213
- child: Container(
214
- width: 14,
215
- height: 14,
216
- decoration: BoxDecoration(
518
+ "$bookName $chapterNum:$verseNum",
217
- color: highlightColor,
218
- shape: BoxShape.circle,
219
- border:
220
- Border.all(color: Theme.of(context).colorScheme.onSurface, width: 1),
519
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
221
- ),
520
+ fontWeight: FontWeight.w600,
222
521
  ),
223
- ),
224
- ),
225
522
  ),
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
- ),
523
+ ),
524
+ Text(
525
+ _formatTime(entry.timestamp),
526
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
527
+ color: colorScheme.outline,
239
- ),
528
+ ),
240
- ),
529
+ ),
241
- Text(
530
+ ],
242
- _formatTime(entry.timestamp),
243
- style: Theme.of(context).textTheme.bodySmall?.copyWith(
244
- color: colorScheme.outline,
245
- ),
531
+ ),
532
+ if (verseText != null && verseText.isNotEmpty) ...[
533
+ const SizedBox(height: 2),
534
+ RichText(
535
+ text: TextSpan(
536
+ children: _parseRedText(
246
- ),
537
+ verseText,
538
+ Theme.of(context).textTheme.bodySmall!.copyWith(
247
- ],
539
+ color: colorScheme.onSurfaceVariant,
248
540
  ),
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(
541
+ Theme.of(context).textTheme.bodySmall!.copyWith(color: Colors.red),
256
- color: colorScheme.onSurfaceVariant,
257
- ),
258
- Theme.of(context).textTheme.bodySmall!.copyWith(
259
- color: Colors.red,
260
- ),
261
- ),
262
- ),
263
- ),
264
- ],
265
- ],
266
- ),
267
542
  ),
268
- ],
543
+ ),
269
544
  ),
545
+ ],
546
+ ],
270
- ),
547
+ ),
271
- );
272
- }).toList(),
273
548
  ),
549
+ ],
550
+ ),
551
+ ),
552
+ ),
553
+ );
554
+ }).toList(),
555
+ ),
274
- crossFadeState: isExpanded ? CrossFadeState.showSecond : CrossFadeState.showFirst,
556
+ crossFadeState: data.isExpanded ? CrossFadeState.showSecond : CrossFadeState.showFirst,
275
- duration: const Duration(milliseconds: 200),
557
+ duration: const Duration(milliseconds: 200),
276
- ),
558
+ ),
277
- Divider(color: colorScheme.outlineVariant, height: 1),
559
+ Divider(color: colorScheme.outlineVariant, height: 1),
278
- ],
560
+ ],
279
- );
561
+ );
280
- },
562
+ }
281
- ),
563
+ }
564
+
565
+ // ── Editable group title field ─────────────────────────────────────────────
566
+
567
+ class _GroupTitleField extends StatefulWidget {
568
+ final String groupKey;
569
+ final String initialValue;
570
+
571
+ const _GroupTitleField({required this.groupKey, required this.initialValue});
572
+
573
+ @override
574
+ State<_GroupTitleField> createState() => _GroupTitleFieldState();
575
+ }
576
+
577
+ class _GroupTitleFieldState extends State<_GroupTitleField> {
578
+ late final TextEditingController _controller;
579
+
580
+ @override
581
+ void initState() {
582
+ super.initState();
583
+ _controller = TextEditingController(text: widget.initialValue);
584
+ }
585
+
586
+ @override
587
+ void didUpdateWidget(_GroupTitleField old) {
588
+ super.didUpdateWidget(old);
589
+ if (old.initialValue != widget.initialValue && _controller.text != widget.initialValue) {
590
+ _controller.text = widget.initialValue;
591
+ }
592
+ }
593
+
594
+ @override
595
+ void dispose() {
596
+ _controller.dispose();
597
+ super.dispose();
598
+ }
599
+
600
+ void _save() => context.dispatch(SetHighlightGroupTitleAction(widget.groupKey, _controller.text));
601
+
602
+ @override
603
+ Widget build(BuildContext context) {
604
+ final colorScheme = Theme.of(context).colorScheme;
605
+ return Padding(
606
+ padding: const EdgeInsets.only(bottom: 4),
607
+ child: TextField(
608
+ controller: _controller,
609
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
610
+ color: colorScheme.onSurfaceVariant,
611
+ fontStyle: FontStyle.italic,
282
612
  ),
613
+ decoration: InputDecoration(
614
+ hintText: "Add a title…",
615
+ hintStyle: Theme.of(context).textTheme.bodySmall?.copyWith(
616
+ color: colorScheme.outlineVariant,
283
- const SizedBox(height: 16),
617
+ fontStyle: FontStyle.italic,
284
- ],
618
+ ),
619
+ isDense: true,
620
+ contentPadding: const EdgeInsets.symmetric(vertical: 4, horizontal: 0),
621
+ border: InputBorder.none,
622
+ focusedBorder: UnderlineInputBorder(
623
+ borderSide: BorderSide(color: colorScheme.primary, width: 1),
624
+ ),
625
+ enabledBorder: widget.initialValue.isEmpty
626
+ ? InputBorder.none
627
+ : UnderlineInputBorder(
628
+ borderSide: BorderSide(color: colorScheme.outlineVariant, width: 0.5),
629
+ ),
630
+ ),
631
+ onEditingComplete: _save,
632
+ onTapOutside: (_) { FocusScope.of(context).unfocus(); _save(); },
633
+ textInputAction: TextInputAction.done,
634
+ maxLines: 1,
635
+ ),
636
+ );
637
+ }
638
+ }
639
+
640
+ // ── Pill selector ──────────────────────────────────────────────────────────
641
+
642
+ class _Pill extends StatelessWidget {
643
+ final String label;
644
+ final bool selected;
645
+ final VoidCallback onTap;
646
+
647
+ const _Pill({required this.label, required this.selected, required this.onTap});
648
+
649
+ @override
650
+ Widget build(BuildContext context) {
651
+ final colorScheme = Theme.of(context).colorScheme;
652
+ return GestureDetector(
653
+ onTap: onTap,
654
+ child: AnimatedContainer(
655
+ duration: const Duration(milliseconds: 150),
656
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
657
+ decoration: BoxDecoration(
658
+ color: selected ? colorScheme.primary : colorScheme.surface,
659
+ borderRadius: BorderRadius.circular(20),
660
+ border: Border.all(
661
+ color: selected ? colorScheme.primary : colorScheme.outlineVariant,
662
+ ),
663
+ ),
664
+ child: Text(
665
+ label,
666
+ style: Theme.of(context).textTheme.labelSmall?.copyWith(
667
+ color: selected ? colorScheme.onPrimary : colorScheme.onSurfaceVariant,
668
+ fontWeight: FontWeight.w600,
669
+ ),
670
+ ),
285
671
  ),
286
672
  );
287
673
  }
lib/widgets/menu_overlay.dart CHANGED
@@ -13,9 +13,9 @@ class MenuOverlay extends StatelessWidget {
13
13
 
14
14
  @override
15
15
  Widget build(BuildContext context) {
16
- final darkMode = context.select((s) => s.darkMode);
17
16
  final isPlaying = context.isWaiting(TogglePlayAction);
17
+ final isDark = Theme.of(context).brightness == Brightness.dark;
18
- final iconColor = darkMode ? Colors.white.withOpacity(0.9) : Colors.black.withOpacity(0.9);
18
+ final iconColor = isDark ? Colors.white.withOpacity(0.9) : Colors.black.withOpacity(0.9);
19
19
  final audioIcon = isPlaying ? Icons.pause_circle_outline : Icons.play_circle_outline;
20
20
 
21
21
  void onHighlight(int index) {
@@ -54,22 +54,22 @@ class MenuOverlay extends StatelessWidget {
54
54
  ),
55
55
  HighlightButton(
56
56
  index: 0,
57
- color: darkMode ? darkHighlights[0] : lightHighlights[0],
57
+ color: isDark ? darkHighlights[0] : lightHighlights[0],
58
58
  onHighlightSelected: onHighlight,
59
59
  ),
60
60
  HighlightButton(
61
61
  index: 1,
62
- color: darkMode ? darkHighlights[1] : lightHighlights[1],
62
+ color: isDark ? darkHighlights[1] : lightHighlights[1],
63
63
  onHighlightSelected: onHighlight,
64
64
  ),
65
65
  HighlightButton(
66
66
  index: 2,
67
- color: darkMode ? darkHighlights[2] : lightHighlights[2],
67
+ color: isDark ? darkHighlights[2] : lightHighlights[2],
68
68
  onHighlightSelected: onHighlight,
69
69
  ),
70
70
  HighlightButton(
71
71
  index: 3,
72
- color: darkMode ? darkHighlights[3] : lightHighlights[3],
72
+ color: isDark ? darkHighlights[3] : lightHighlights[3],
73
73
  onHighlightSelected: onHighlight,
74
74
  ),
75
75
  IconButton(
lib/widgets/settings_sheet.dart CHANGED
@@ -1,7 +1,7 @@
1
1
  import "package:flutter/material.dart";
2
2
  import "package:only_bible_app/gen/bible.gen.dart";
3
- import "package:only_bible_app/store/app_state.dart";
4
3
  import "package:only_bible_app/store/actions_state.dart";
4
+ import "package:only_bible_app/store/app_state.dart";
5
5
 
6
6
  class SettingsSheet extends StatelessWidget {
7
7
  final Bible bible;
@@ -10,7 +10,7 @@ class SettingsSheet extends StatelessWidget {
10
10
 
11
11
  @override
12
12
  Widget build(BuildContext context) {
13
- final darkMode = context.select((s) => s.darkMode);
13
+ final themeMode = context.select((s) => s.themeMode);
14
14
  final fontWeight = context.select((s) => s.fontWeight);
15
15
  final engTitles = context.select((s) => s.engTitles);
16
16
  final fontSize = context.select((s) => s.fontSize);
@@ -48,19 +48,31 @@ class SettingsSheet extends StatelessWidget {
48
48
  const SizedBox(width: 12),
49
49
  Expanded(child: Text(bible.themeTitle!)),
50
50
  ToggleButtons(
51
+ onPressed: (i) {
52
+ const modes = [ThemeMode.light, ThemeMode.dark, ThemeMode.system];
51
- onPressed: (_) => context.dispatch(ToggleDarkModeAction()),
53
+ context.dispatch(SetThemeModeAction(modes[i]));
54
+ },
52
55
  highlightColor: Colors.transparent,
53
56
  borderColor: Colors.grey,
54
57
  borderRadius: const BorderRadius.all(Radius.circular(25)),
55
- selectedColor: darkMode ? Colors.lightBlue.shade300 : Colors.yellowAccent.shade700,
58
+ selectedColor: themeMode == ThemeMode.dark
59
+ ? Colors.lightBlue.shade300
60
+ : themeMode == ThemeMode.system
61
+ ? Colors.purple.shade300
62
+ : Colors.yellowAccent.shade700,
56
63
  selectedBorderColor: Colors.grey,
57
64
  color: Colors.grey,
58
65
  fillColor: Colors.transparent,
59
66
  constraints: const BoxConstraints(minHeight: 36, minWidth: 50),
67
+ isSelected: [
68
+ themeMode == ThemeMode.light,
60
- isSelected: [!darkMode, darkMode],
69
+ themeMode == ThemeMode.dark,
70
+ themeMode == ThemeMode.system,
71
+ ],
61
72
  children: const [
62
73
  Icon(Icons.light_mode),
63
74
  Icon(Icons.dark_mode),
75
+ Icon(Icons.brightness_auto),
64
76
  ],
65
77
  ),
66
78
  ],