~repos /only-bible-app
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.
new features
- lib/app.dart +1 -1
- lib/store/actions_state.dart +49 -3
- lib/store/app_state.dart +46 -11
- lib/widgets/highlight_history_sheet.dart +594 -208
- lib/widgets/menu_overlay.dart +6 -6
- lib/widgets/settings_sheet.dart +17 -5
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.
|
|
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
|
|
25
|
+
class SetThemeModeAction extends ReduxAction<AppState> {
|
|
26
|
+
final ThemeMode mode;
|
|
27
|
+
SetThemeModeAction(this.mode);
|
|
28
|
+
|
|
26
29
|
@override
|
|
27
|
-
AppState reduce() => state.copy(
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
//
|
|
38
|
+
// ── Filter state ──────────────────────────────────────────────────────────
|
|
39
|
+
bool _showFilter = false;
|
|
19
|
-
|
|
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
|
-
|
|
44
|
+
// ── Accordion state ───────────────────────────────────────────────────────
|
|
23
|
-
|
|
45
|
+
final Set<int> _collapsed = {};
|
|
46
|
+
|
|
24
|
-
|
|
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
|
-
|
|
52
|
+
case 1: return "${day}st";
|
|
32
|
-
case 2:
|
|
33
|
-
|
|
53
|
+
case 2: return "${day}nd";
|
|
34
|
-
case 3:
|
|
35
|
-
|
|
54
|
+
case 3: return "${day}rd";
|
|
36
|
-
default:
|
|
37
|
-
|
|
55
|
+
default: return "${day}th";
|
|
38
56
|
}
|
|
39
57
|
}
|
|
40
58
|
|
|
41
|
-
|
|
59
|
+
static const _weekdays = [
|
|
42
|
-
|
|
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
|
-
|
|
62
|
+
static const _monthNames = [
|
|
58
|
-
|
|
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
|
-
|
|
67
|
+
String _groupKey(DateTime dt) {
|
|
66
|
-
|
|
68
|
+
switch (_grouping) {
|
|
69
|
+
case _Grouping.day:
|
|
67
|
-
|
|
70
|
+
return "${_weekdays[dt.weekday - 1]} ${_ordinal(dt.day)} ${_monthNames[dt.month - 1]}, ${dt.year}";
|
|
68
|
-
|
|
71
|
+
case _Grouping.month:
|
|
69
|
-
|
|
72
|
+
return "${_monthNames[dt.month - 1]} ${dt.year}";
|
|
70
|
-
|
|
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
|
-
|
|
87
|
+
final seen = <String>{};
|
|
88
|
+
final result = <HighlightHistoryEntry>[];
|
|
89
|
+
for (final e in entries) {
|
|
73
|
-
|
|
90
|
+
if (seen.add("${e.book}:${e.chapter}:${e.verseIndex}")) result.add(e);
|
|
74
91
|
}
|
|
75
|
-
return
|
|
92
|
+
return result;
|
|
76
93
|
}
|
|
77
94
|
|
|
78
|
-
void _toggle(int index) {
|
|
95
|
+
void _toggle(int index) => setState(() {
|
|
79
|
-
setState(() {
|
|
80
|
-
|
|
96
|
+
if (_collapsed.contains(index)) {
|
|
81
|
-
|
|
97
|
+
_collapsed.remove(index);
|
|
82
|
-
|
|
98
|
+
} else {
|
|
83
|
-
|
|
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
|
|
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
|
-
//
|
|
131
|
+
// Filter then group.
|
|
95
|
-
final
|
|
132
|
+
final filtered = history.reversed.where(_entryPassesFilter).toList();
|
|
96
133
|
final Map<String, List<HighlightHistoryEntry>> grouped = {};
|
|
97
|
-
for (final entry in
|
|
134
|
+
for (final entry in filtered) {
|
|
98
|
-
final key = _formatDate(entry.timestamp);
|
|
99
|
-
grouped.putIfAbsent(
|
|
135
|
+
grouped.putIfAbsent(_groupKey(entry.timestamp), () => []).add(entry);
|
|
100
136
|
}
|
|
101
137
|
|
|
138
|
+
// Pre-compute list — zero context reads inside itemBuilder.
|
|
102
|
-
final
|
|
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
|
-
|
|
186
|
+
Text(
|
|
110
|
-
|
|
187
|
+
_filterSummary(),
|
|
111
|
-
|
|
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 (
|
|
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:
|
|
134
|
-
itemBuilder: (
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
509
|
+
const SizedBox(width: 10),
|
|
510
|
+
Expanded(
|
|
182
|
-
|
|
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
|
-
|
|
512
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
207
|
-
|
|
513
|
+
children: [
|
|
208
|
-
|
|
514
|
+
Row(
|
|
209
|
-
height: 20,
|
|
210
|
-
|
|
515
|
+
children: [
|
|
211
|
-
|
|
516
|
+
Expanded(
|
|
212
|
-
|
|
517
|
+
child: Text(
|
|
213
|
-
child: Container(
|
|
214
|
-
width: 14,
|
|
215
|
-
height: 14,
|
|
216
|
-
|
|
518
|
+
"$bookName $chapterNum:$verseNum",
|
|
217
|
-
color: highlightColor,
|
|
218
|
-
shape: BoxShape.circle,
|
|
219
|
-
border:
|
|
220
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
556
|
+
crossFadeState: data.isExpanded ? CrossFadeState.showSecond : CrossFadeState.showFirst,
|
|
275
|
-
|
|
557
|
+
duration: const Duration(milliseconds: 200),
|
|
276
|
-
|
|
558
|
+
),
|
|
277
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
57
|
+
color: isDark ? darkHighlights[0] : lightHighlights[0],
|
|
58
58
|
onHighlightSelected: onHighlight,
|
|
59
59
|
),
|
|
60
60
|
HighlightButton(
|
|
61
61
|
index: 1,
|
|
62
|
-
color:
|
|
62
|
+
color: isDark ? darkHighlights[1] : lightHighlights[1],
|
|
63
63
|
onHighlightSelected: onHighlight,
|
|
64
64
|
),
|
|
65
65
|
HighlightButton(
|
|
66
66
|
index: 2,
|
|
67
|
-
color:
|
|
67
|
+
color: isDark ? darkHighlights[2] : lightHighlights[2],
|
|
68
68
|
onHighlightSelected: onHighlight,
|
|
69
69
|
),
|
|
70
70
|
HighlightButton(
|
|
71
71
|
index: 3,
|
|
72
|
-
color:
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
],
|