~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/widgets/highlight_history_sheet.dart



import "package:flutter/material.dart";
import "package:only_bible_app/gen/bible.gen.dart";
import "package:only_bible_app/store/actions_navigation.dart";
import "package:only_bible_app/store/actions_state.dart";
import "package:only_bible_app/store/app_navigator.dart";
import "package:only_bible_app/store/app_state.dart";
import "package:only_bible_app/theme.dart";
enum _Grouping { day, month, year }
// Pre-computed data for one accordion group, passed to _DayGroup.
class _GroupData {
final int index;
final String dateLabel;
final String groupTitle;
final List<HighlightHistoryEntry> entries;
final bool isExpanded;
const _GroupData({
required this.index,
required this.dateLabel,
required this.groupTitle,
required this.entries,
required this.isExpanded,
});
}
class HighlightHistorySheet extends StatefulWidget {
final Bible bible;
const HighlightHistorySheet({super.key, required this.bible});
@override
State<HighlightHistorySheet> createState() => _HighlightHistorySheetState();
}
class _HighlightHistorySheetState extends State<HighlightHistorySheet> {
// ── Filter state ──────────────────────────────────────────────────────────
bool _showFilter = false;
_Grouping _grouping = _Grouping.day;
int? _selectedYear; // null = all years
int? _selectedMonth; // null = all months (only used when grouping == day)
// ── Accordion state ───────────────────────────────────────────────────────
final Set<int> _collapsed = {};
// ── Formatting helpers ────────────────────────────────────────────────────
String _ordinal(int day) {
if (day >= 11 && day <= 13) return "${day}th";
switch (day % 10) {
case 1: return "${day}st";
case 2: return "${day}nd";
case 3: return "${day}rd";
default: return "${day}th";
}
}
static const _weekdays = [
"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday",
];
static const _monthNames = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
];
String _groupKey(DateTime dt) {
switch (_grouping) {
case _Grouping.day:
return "${_weekdays[dt.weekday - 1]} ${_ordinal(dt.day)} ${_monthNames[dt.month - 1]}, ${dt.year}";
case _Grouping.month:
return "${_monthNames[dt.month - 1]} ${dt.year}";
case _Grouping.year:
return "${dt.year}";
}
}
bool _entryPassesFilter(HighlightHistoryEntry e) {
if (_selectedYear != null && e.timestamp.year != _selectedYear) return false;
if (_grouping == _Grouping.day && _selectedMonth != null && e.timestamp.month != _selectedMonth) return false;
return true;
}
// ── Dedup ─────────────────────────────────────────────────────────────────
List<HighlightHistoryEntry> _dedup(List<HighlightHistoryEntry> entries) {
final seen = <String>{};
final result = <HighlightHistoryEntry>[];
for (final e in entries) {
if (seen.add("${e.book}:${e.chapter}:${e.verseIndex}")) result.add(e);
}
return result;
}
void _toggle(int index) => setState(() {
if (_collapsed.contains(index)) {
_collapsed.remove(index);
} else {
_collapsed.add(index);
}
});
String _filterSummary() {
final groupLabel = _grouping.name[0].toUpperCase() + _grouping.name.substring(1);
if (_selectedYear == null) return groupLabel;
if (_grouping == _Grouping.day && _selectedMonth != null) {
return "$groupLabel · ${_monthNames[_selectedMonth! - 1]} $_selectedYear";
}
if (_grouping == _Grouping.month) return "$groupLabel · $_selectedYear";
return "$groupLabel · $_selectedYear";
}
@override
Widget build(BuildContext context) {
final history = context.select((s) => s.highlightHistory);
final groupTitles = context.select((s) => s.highlightGroupTitles);
final darkMode = Theme.of(context).brightness == Brightness.dark;
final colorScheme = Theme.of(context).colorScheme;
// Derive available years / months from the full history.
final allYears = history.map((e) => e.timestamp.year).toSet().toList()..sort((a, b) => b.compareTo(a));
final availableMonths = _selectedYear == null
? <int>[]
: history
.where((e) => e.timestamp.year == _selectedYear)
.map((e) => e.timestamp.month)
.toSet()
.toList()
..sort();
// Filter then group.
final filtered = history.reversed.where(_entryPassesFilter).toList();
final Map<String, List<HighlightHistoryEntry>> grouped = {};
for (final entry in filtered) {
grouped.putIfAbsent(_groupKey(entry.timestamp), () => []).add(entry);
}
// Pre-compute list — zero context reads inside itemBuilder.
final groups = grouped.entries.toList().asMap().entries.map((kv) {
final i = kv.key;
final e = kv.value;
return _GroupData(
index: i,
dateLabel: e.key,
groupTitle: groupTitles[e.key] ?? "",
entries: _dedup(e.value),
isExpanded: !_collapsed.contains(i),
);
}).toList();
final hasActiveFilter = _selectedYear != null || _grouping != _Grouping.day;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Header ────────────────────────────────────────────────────────
Row(
children: [
Expanded(
child: Text("Highlight History", style: Theme.of(context).textTheme.headlineMedium),
),
// Filter button
GestureDetector(
onTap: () => setState(() => _showFilter = !_showFilter),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: (_showFilter || hasActiveFilter) ? colorScheme.primary : Colors.transparent,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: (_showFilter || hasActiveFilter) ? colorScheme.primary : colorScheme.outlineVariant,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.tune,
size: 14,
color: (_showFilter || hasActiveFilter) ? colorScheme.onPrimary : colorScheme.onSurfaceVariant,
),
const SizedBox(width: 5),
Text(
_filterSummary(),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: (_showFilter || hasActiveFilter)
? colorScheme.onPrimary
: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
],
),
// ── Filter panel ──────────────────────────────────────────────────
AnimatedCrossFade(
duration: const Duration(milliseconds: 200),
crossFadeState: _showFilter ? CrossFadeState.showSecond : CrossFadeState.showFirst,
firstChild: const SizedBox.shrink(),
secondChild: Container(
margin: const EdgeInsets.only(top: 10),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Row 1 — Grouping
Text(
"Group by",
style: Theme.of(context).textTheme.labelSmall?.copyWith(color: colorScheme.outline),
),
const SizedBox(height: 6),
Row(
children: [
_Pill(
label: "Day",
selected: _grouping == _Grouping.day,
onTap: () => setState(() {
_grouping = _Grouping.day;
_collapsed.clear();
}),
),
const SizedBox(width: 6),
_Pill(
label: "Month",
selected: _grouping == _Grouping.month,
onTap: () => setState(() {
_grouping = _Grouping.month;
_selectedMonth = null;
_collapsed.clear();
}),
),
const SizedBox(width: 6),
_Pill(
label: "Year",
selected: _grouping == _Grouping.year,
onTap: () => setState(() {
_grouping = _Grouping.year;
_selectedYear = null;
_selectedMonth = null;
_collapsed.clear();
}),
),
],
),
// Row 2 — Year (hidden when grouping == year)
if (_grouping != _Grouping.year && allYears.isNotEmpty) ...[
const SizedBox(height: 10),
Text(
"Year",
style: Theme.of(context).textTheme.labelSmall?.copyWith(color: colorScheme.outline),
),
const SizedBox(height: 6),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_Pill(
label: "All",
selected: _selectedYear == null,
onTap: () => setState(() {
_selectedYear = null;
_selectedMonth = null;
_collapsed.clear();
}),
),
...allYears.map((y) => Padding(
padding: const EdgeInsets.only(left: 6),
child: _Pill(
label: "$y",
selected: _selectedYear == y,
onTap: () => setState(() {
_selectedYear = y;
_selectedMonth = null;
_collapsed.clear();
}),
),
)),
],
),
),
],
// Row 3 — Month (only when grouping == day and a year is selected)
if (_grouping == _Grouping.day && _selectedYear != null && availableMonths.isNotEmpty) ...[
const SizedBox(height: 10),
Text(
"Month",
style: Theme.of(context).textTheme.labelSmall?.copyWith(color: colorScheme.outline),
),
const SizedBox(height: 6),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_Pill(
label: "All",
selected: _selectedMonth == null,
onTap: () => setState(() { _selectedMonth = null; _collapsed.clear(); }),
),
...availableMonths.map((m) => Padding(
padding: const EdgeInsets.only(left: 6),
child: _Pill(
label: _monthNames[m - 1],
selected: _selectedMonth == m,
onTap: () => setState(() { _selectedMonth = m; _collapsed.clear(); }),
),
)),
],
),
),
],
],
),
),
),
const SizedBox(height: 12),
// ── Empty state ───────────────────────────────────────────────────
if (groups.isEmpty)
Expanded(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.highlight_outlined, size: 48, color: colorScheme.outlineVariant),
const SizedBox(height: 12),
Text(
"No highlights yet",
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: colorScheme.outline),
),
],
),
),
)
else
// ── Accordion list ─────────────────────────────────────────────
Expanded(
child: ListView.builder(
itemCount: groups.length,
itemBuilder: (_, i) => _DayGroup(
data: groups[i],
bible: widget.bible,
darkMode: darkMode,
onToggle: () => _toggle(i),
),
),
),
const SizedBox(height: 16),
],
),
);
}
}
// ── Per-group accordion widget ─────────────────────────────────────────────
class _DayGroup extends StatelessWidget {
final _GroupData data;
final Bible bible;
final bool darkMode;
final VoidCallback onToggle;
const _DayGroup({
required this.data,
required this.bible,
required this.darkMode,
required this.onToggle,
});
static List<InlineSpan> _parseRedText(String raw, TextStyle base, TextStyle redStyle) {
final spans = <InlineSpan>[];
final exp = RegExp(r"<red>(.*?)<\/red>", dotAll: true);
int cursor = 0;
for (final match in exp.allMatches(raw)) {
if (match.start > cursor) {
spans.add(TextSpan(text: raw.substring(cursor, match.start), style: base));
}
spans.add(TextSpan(text: match.group(1), style: redStyle));
cursor = match.end;
}
if (cursor < raw.length) {
spans.add(TextSpan(text: raw.substring(cursor), style: base));
}
return spans;
}
static String _formatTime(DateTime dt) {
final h = dt.hour.toString().padLeft(2, "0");
final m = dt.minute.toString().padLeft(2, "0");
return "$h:$m";
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Accordion header
InkWell(
onTap: onToggle,
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
children: [
Expanded(
child: Text(
data.dateLabel,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
Text(
"${data.entries.length}",
style: Theme.of(context).textTheme.labelSmall?.copyWith(color: colorScheme.outline),
),
const SizedBox(width: 4),
AnimatedRotation(
turns: data.isExpanded ? 0.5 : 0,
duration: const Duration(milliseconds: 200),
child: Icon(Icons.keyboard_arrow_down, size: 20, color: colorScheme.primary),
),
],
),
),
),
// Editable group title
_GroupTitleField(groupKey: data.dateLabel, initialValue: data.groupTitle),
// Accordion body
AnimatedCrossFade(
firstChild: const SizedBox.shrink(),
secondChild: Column(
children: data.entries.map((entry) {
final bookName = bible.books?[entry.book].name ?? "Book ${entry.book + 1}";
final chapterNum = entry.chapter + 1;
final verseNum = entry.verseIndex + 1;
final highlightColor = darkMode ? darkHighlights[entry.colorIndex] : lightHighlights[entry.colorIndex];
final verseText = bible.books?[entry.book].chapters?[entry.chapter].verses?[entry.verseIndex].text;
return Dismissible(
key: ValueKey(
"${entry.book}:${entry.chapter}:${entry.verseIndex}:${entry.timestamp.millisecondsSinceEpoch}",
),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 16),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
borderRadius: BorderRadius.circular(6),
),
child: Icon(Icons.delete_outline, color: colorScheme.onErrorContainer),
),
onDismissed: (_) => context.dispatch(
RemoveHighlightHistoryEntryAction(
book: entry.book,
chapter: entry.chapter,
verseIndex: entry.verseIndex,
timestamp: entry.timestamp,
),
),
child: InkWell(
onTap: () {
Navigator.of(context).pop();
context.dispatch(GoToChapterAction(
context.router,
entry.book,
entry.chapter,
verse: entry.verseIndex,
));
},
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 20,
child: Center(
child: Container(
width: 14,
height: 14,
decoration: BoxDecoration(
color: highlightColor,
shape: BoxShape.circle,
border: Border.all(color: colorScheme.outlineVariant, width: 0.5),
),
),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
"$bookName $chapterNum:$verseNum",
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
Text(
_formatTime(entry.timestamp),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.outline,
),
),
],
),
if (verseText != null && verseText.isNotEmpty) ...[
const SizedBox(height: 2),
RichText(
text: TextSpan(
children: _parseRedText(
verseText,
Theme.of(context).textTheme.bodySmall!.copyWith(
color: colorScheme.onSurfaceVariant,
),
Theme.of(context).textTheme.bodySmall!.copyWith(color: Colors.red),
),
),
),
],
],
),
),
],
),
),
),
);
}).toList(),
),
crossFadeState: data.isExpanded ? CrossFadeState.showSecond : CrossFadeState.showFirst,
duration: const Duration(milliseconds: 200),
),
Divider(color: colorScheme.outlineVariant, height: 1),
],
);
}
}
// ── Editable group title field ─────────────────────────────────────────────
class _GroupTitleField extends StatefulWidget {
final String groupKey;
final String initialValue;
const _GroupTitleField({required this.groupKey, required this.initialValue});
@override
State<_GroupTitleField> createState() => _GroupTitleFieldState();
}
class _GroupTitleFieldState extends State<_GroupTitleField> {
late final TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.initialValue);
}
@override
void didUpdateWidget(_GroupTitleField old) {
super.didUpdateWidget(old);
if (old.initialValue != widget.initialValue && _controller.text != widget.initialValue) {
_controller.text = widget.initialValue;
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _save() => context.dispatch(SetHighlightGroupTitleAction(widget.groupKey, _controller.text));
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: TextField(
controller: _controller,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
decoration: InputDecoration(
hintText: "Add a title…",
hintStyle: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.outlineVariant,
fontStyle: FontStyle.italic,
),
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 4, horizontal: 0),
border: InputBorder.none,
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(color: colorScheme.primary, width: 1),
),
enabledBorder: widget.initialValue.isEmpty
? InputBorder.none
: UnderlineInputBorder(
borderSide: BorderSide(color: colorScheme.outlineVariant, width: 0.5),
),
),
onEditingComplete: _save,
onTapOutside: (_) { FocusScope.of(context).unfocus(); _save(); },
textInputAction: TextInputAction.done,
maxLines: 1,
),
);
}
}
// ── Pill selector ──────────────────────────────────────────────────────────
class _Pill extends StatelessWidget {
final String label;
final bool selected;
final VoidCallback onTap;
const _Pill({required this.label, required this.selected, required this.onTap});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
decoration: BoxDecoration(
color: selected ? colorScheme.primary : colorScheme.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: selected ? colorScheme.primary : colorScheme.outlineVariant,
),
),
child: Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: selected ? colorScheme.onPrimary : colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
),
);
}
}