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.
final List<HighlightHistoryEntry> entries;
required this.groupTitle,
required this.isExpanded,
class HighlightHistorySheet extends StatefulWidget {
const HighlightHistorySheet({super.key, required this.bible});
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";
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) {
return "${_weekdays[dt.weekday - 1]} ${_ordinal(dt.day)} ${_monthNames[dt.month - 1]}, ${dt.year}";
return "${_monthNames[dt.month - 1]} ${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;
// ── Dedup ─────────────────────────────────────────────────────────────────
List<HighlightHistoryEntry> _dedup(List<HighlightHistoryEntry> entries) {
final result = <HighlightHistoryEntry>[];
for (final e in entries) {
if (seen.add("${e.book}:${e.chapter}:${e.verseIndex}")) result.add(e);
void _toggle(int index) => setState(() {
if (_collapsed.contains(index)) {
_collapsed.remove(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";
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
.where((e) => e.timestamp.year == _selectedYear)
.map((e) => e.timestamp.month)
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) {
groupTitle: groupTitles[e.key] ?? "",
entries: _dedup(e.value),
isExpanded: !_collapsed.contains(i),
final hasActiveFilter = _selectedYear != null || _grouping != _Grouping.day;
padding: const EdgeInsets.symmetric(horizontal: 20),
crossAxisAlignment: CrossAxisAlignment.start,
// ── Header ────────────────────────────────────────────────────────
child: Text("Highlight History", style: Theme.of(context).textTheme.headlineMedium),
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),
color: (_showFilter || hasActiveFilter) ? colorScheme.primary : colorScheme.outlineVariant,
mainAxisSize: MainAxisSize.min,
color: (_showFilter || hasActiveFilter) ? colorScheme.onPrimary : colorScheme.onSurfaceVariant,
const SizedBox(width: 5),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: (_showFilter || hasActiveFilter)
: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
// ── Filter panel ──────────────────────────────────────────────────
duration: const Duration(milliseconds: 200),
crossFadeState: _showFilter ? CrossFadeState.showSecond : CrossFadeState.showFirst,
firstChild: const SizedBox.shrink(),
margin: const EdgeInsets.only(top: 10),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
crossAxisAlignment: CrossAxisAlignment.start,
style: Theme.of(context).textTheme.labelSmall?.copyWith(color: colorScheme.outline),
const SizedBox(height: 6),
selected: _grouping == _Grouping.day,
onTap: () => setState(() {
_grouping = _Grouping.day;
const SizedBox(width: 6),
selected: _grouping == _Grouping.month,
onTap: () => setState(() {
_grouping = _Grouping.month;
const SizedBox(width: 6),
selected: _grouping == _Grouping.year,
onTap: () => setState(() {
_grouping = _Grouping.year;
// Row 2 — Year (hidden when grouping == year)
if (_grouping != _Grouping.year && allYears.isNotEmpty) ...[
const SizedBox(height: 10),
style: Theme.of(context).textTheme.labelSmall?.copyWith(color: colorScheme.outline),
const SizedBox(height: 6),
scrollDirection: Axis.horizontal,
selected: _selectedYear == null,
onTap: () => setState(() {
...allYears.map((y) => Padding(
padding: const EdgeInsets.only(left: 6),
selected: _selectedYear == y,
onTap: () => setState(() {
// Row 3 — Month (only when grouping == day and a year is selected)
if (_grouping == _Grouping.day && _selectedYear != null && availableMonths.isNotEmpty) ...[
const SizedBox(height: 10),
style: Theme.of(context).textTheme.labelSmall?.copyWith(color: colorScheme.outline),
const SizedBox(height: 6),
scrollDirection: Axis.horizontal,
selected: _selectedMonth == null,
onTap: () => setState(() { _selectedMonth = null; _collapsed.clear(); }),
...availableMonths.map((m) => Padding(
padding: const EdgeInsets.only(left: 6),
label: _monthNames[m - 1],
selected: _selectedMonth == m,
onTap: () => setState(() { _selectedMonth = m; _collapsed.clear(); }),
const SizedBox(height: 12),
// ── Empty state ───────────────────────────────────────────────────
mainAxisSize: MainAxisSize.min,
Icon(Icons.highlight_outlined, size: 48, color: colorScheme.outlineVariant),
const SizedBox(height: 12),
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: colorScheme.outline),
// ── Accordion list ─────────────────────────────────────────────
itemCount: groups.length,
itemBuilder: (_, i) => _DayGroup(
onToggle: () => _toggle(i),
const SizedBox(height: 16),
// ── Per-group accordion widget ─────────────────────────────────────────────
class _DayGroup extends StatelessWidget {
final VoidCallback onToggle;
static List<InlineSpan> _parseRedText(String raw, TextStyle base, TextStyle redStyle) {
final spans = <InlineSpan>[];
final exp = RegExp(r"<red>(.*?)<\/red>", dotAll: true);
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));
if (cursor < raw.length) {
spans.add(TextSpan(text: raw.substring(cursor), style: base));
static String _formatTime(DateTime dt) {
final h = dt.hour.toString().padLeft(2, "0");
final m = dt.minute.toString().padLeft(2, "0");
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
crossAxisAlignment: CrossAxisAlignment.start,
borderRadius: BorderRadius.circular(6),
padding: const EdgeInsets.symmetric(vertical: 10),
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.bold,
"${data.entries.length}",
style: Theme.of(context).textTheme.labelSmall?.copyWith(color: colorScheme.outline),
const SizedBox(width: 4),
turns: data.isExpanded ? 0.5 : 0,
duration: const Duration(milliseconds: 200),
child: Icon(Icons.keyboard_arrow_down, size: 20, color: colorScheme.primary),
_GroupTitleField(groupKey: data.dateLabel, initialValue: data.groupTitle),
firstChild: const SizedBox.shrink(),
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;
"${entry.book}:${entry.chapter}:${entry.verseIndex}:${entry.timestamp.millisecondsSinceEpoch}",
direction: DismissDirection.endToStart,
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(
verseIndex: entry.verseIndex,
timestamp: entry.timestamp,
Navigator.of(context).pop();
context.dispatch(GoToChapterAction(
borderRadius: BorderRadius.circular(6),
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 2),
crossAxisAlignment: CrossAxisAlignment.start,
decoration: BoxDecoration(
border: Border.all(color: colorScheme.outlineVariant, width: 0.5),
const SizedBox(width: 10),
crossAxisAlignment: CrossAxisAlignment.start,
"$bookName $chapterNum:$verseNum",
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
_formatTime(entry.timestamp),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.outline,
if (verseText != null && verseText.isNotEmpty) ...[
const SizedBox(height: 2),
Theme.of(context).textTheme.bodySmall!.copyWith(
color: colorScheme.onSurfaceVariant,
Theme.of(context).textTheme.bodySmall!.copyWith(color: Colors.red),
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 initialValue;
const _GroupTitleField({required this.groupKey, required this.initialValue});
State<_GroupTitleField> createState() => _GroupTitleFieldState();
class _GroupTitleFieldState extends State<_GroupTitleField> {
late final TextEditingController _controller;
_controller = TextEditingController(text: widget.initialValue);
void didUpdateWidget(_GroupTitleField old) {
super.didUpdateWidget(old);
if (old.initialValue != widget.initialValue && _controller.text != widget.initialValue) {
_controller.text = widget.initialValue;
void _save() => context.dispatch(SetHighlightGroupTitleAction(widget.groupKey, _controller.text));
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
padding: const EdgeInsets.only(bottom: 4),
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,
contentPadding: const EdgeInsets.symmetric(vertical: 4, horizontal: 0),
border: InputBorder.none,
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(color: colorScheme.primary, width: 1),
enabledBorder: widget.initialValue.isEmpty
borderSide: BorderSide(color: colorScheme.outlineVariant, width: 0.5),
onEditingComplete: _save,
onTapOutside: (_) { FocusScope.of(context).unfocus(); _save(); },
textInputAction: TextInputAction.done,
// ── Pill selector ──────────────────────────────────────────────────────────
class _Pill extends StatelessWidget {
final VoidCallback onTap;
const _Pill({required this.label, required this.selected, required this.onTap});
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
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),
color: selected ? colorScheme.primary : colorScheme.outlineVariant,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: selected ? colorScheme.onPrimary : colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,