import "package:flutter/gestures.dart";
import "package:flutter/material.dart";
import "package:only_bible_app/gen/bible.gen.dart";
import "package:only_bible_app/store/app_state.dart";
import "package:only_bible_app/theme.dart";
import "package:only_bible_app/widgets/home_app_bar.dart";
import "package:only_bible_app/store/actions_navigation.dart";
import "package:only_bible_app/store/app_navigator.dart";
import "package:only_bible_app/widgets/menu_overlay.dart";
import "package:only_bible_app/store/actions_state.dart";
class Home extends StatefulWidget {
final int? scrollToVerse;
const Home({super.key, required this.bookIndex, required this.chapterIndex, this.scrollToVerse});
State<Home> createState() => _HomeState();
class _HomeState extends State<Home> {
final Map<int, GlobalKey> _verseKeys = {};
GlobalKey _keyForVerse(int index) {
return _verseKeys.putIfAbsent(index, () => GlobalKey());
WidgetsBinding.instance.addPostFrameCallback((_) {
final scrollToVerse = widget.scrollToVerse;
if (scrollToVerse != null) {
final key = _verseKeys[scrollToVerse];
if (key?.currentContext != null) {
Scrollable.ensureVisible(
List<InlineSpan> _buildHeadingSpans(
List<Reference> references,
final spans = <InlineSpan>[];
spans.add(TextSpan(text: heading, style: style.copyWith(fontSize: fontSize)));
final refStyle = style.copyWith(
color: Colors.blueAccent,
fontStyle: FontStyle.italic,
fontWeight: FontWeight.w500,
final symbolStyle = refStyle.copyWith(
color: Theme.of(context).colorScheme.onSurface,
if (references.isNotEmpty) {
for (var i = 0; i < references.length; i++) {
final ref = references[i];
final bookName = bible.books![ref.book].name ?? "";
final label = "$bookName ${ref.chapter + 1}:${ref.verse + 1}";
recognizer: TapGestureRecognizer()
() => context.dispatch(GoToChapterAction(context.router, ref.book, ref.chapter, verse: ref.verse)),
spans.add(TextSpan(text: "\n", style: style));
Widget build(BuildContext context) {
final theme = Theme.of(context).textTheme;
final bible = context.select((s) => s.bible);
final book = bible.books![widget.bookIndex];
final chapter = book.chapters![widget.chapterIndex];
final (fontWeight, fontSize, selectedVerses, _, _) =
context.select((s) => (s.fontWeight, s.fontSize, s.selectedVerses, s.highlights, s.darkMode));
final appState = context.read();
final baseStyle = theme.bodyMedium!.copyWith(
fontWeight: FontWeight.values[(fontWeight ~/ 100) - 1],
appBar: HomeAppBar(bible: bible, book: book, chapter: chapter),
backgroundColor: Theme.of(context).colorScheme.surface,
context.dispatch(NextChapterAction(context.router, bible, chapter.book, chapter.index)),
context.dispatch(PreviousChapterAction(context.router, bible, chapter.book, chapter.index)),
final router = context.router;
context.dispatch(PreviousChapterAction(router, bible, chapter.book, chapter.index));
child: SingleChildScrollView(
key: ValueKey("${chapter.book}-${chapter.index}"),
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 20),
crossAxisAlignment: CrossAxisAlignment.start,
for (final v in chapter.verses!)
key: _keyForVerse(v.index),
padding: const EdgeInsets.only(bottom: 8),
onTap: () => context.dispatch(SelectVerseAction(v)),
behavior: HitTestBehavior.opaque,
buildHeadingSpans: _buildHeadingSpans,
highlight: widget.scrollToVerse == v.index,
if (selectedVerses.isNotEmpty) const SizedBox(height: 120),
if (selectedVerses.isNotEmpty)
bottom: MediaQuery.of(context).padding.bottom + 40,
child: Center(child: MenuOverlay(bible: bible)),
class _ChapterSwipeDetector extends StatefulWidget {
final VoidCallback onSwipeLeft;
final VoidCallback onSwipeRight;
final VoidCallback onEdgeSwipeRight;
const _ChapterSwipeDetector({
required this.onSwipeLeft,
required this.onSwipeRight,
required this.onEdgeSwipeRight,
State<_ChapterSwipeDetector> createState() => _ChapterSwipeDetectorState();
class _ChapterSwipeDetectorState extends State<_ChapterSwipeDetector> {
Widget build(BuildContext context) {
onHorizontalDragStart: (details) {
_startX = details.globalPosition.dx;
onHorizontalDragEnd: (details) {
if (startX == null) return;
final velocity = details.primaryVelocity ?? 0;
} else if (velocity > 300) {
widget.onEdgeSwipeRight();
class _VerseText extends StatefulWidget {
final TextStyle baseStyle;
final List<InlineSpan> Function(BuildContext, Bible, String, List<Reference>, TextStyle, double) buildHeadingSpans;
required this.buildHeadingSpans,
State<_VerseText> createState() => _VerseTextState();
class _VerseTextState extends State<_VerseText> with SingleTickerProviderStateMixin {
late final AnimationController _controller;
_controller = AnimationController(
duration: const Duration(milliseconds: 400),
reverseDuration: const Duration(milliseconds: 500),
WidgetsBinding.instance.addPostFrameCallback((_) {
_controller.forward().then((_) {
Future.delayed(const Duration(milliseconds: 600), () {
if (mounted) _controller.reverse();
static final _redTagPattern = RegExp(r"<red>(.*?)</red>");
Widget build(BuildContext context) {
final theme = widget.theme;
final appState = widget.appState;
List<TextSpan> buildVerseTextSpans(String text, TextStyle style) {
final spans = <TextSpan>[];
final redStyle = style.copyWith(color: const Color.fromARGB(255, 245, 6, 6));
for (final match in _redTagPattern.allMatches(text)) {
if (match.start > lastEnd) {
spans.add(TextSpan(text: text.substring(lastEnd, match.start), style: style));
spans.add(TextSpan(text: match.group(1), style: redStyle));
if (lastEnd < text.length) {
spans.add(TextSpan(text: text.substring(lastEnd), style: style));
Widget buildText(Color? bgColor) {
if (v.heading != null && v.heading!.isNotEmpty)
...widget.buildHeadingSpans(
v.headingReferences ?? [],
style: theme.labelMedium!.copyWith(
color: const Color(0xFF9A1111),
backgroundColor: bgColor,
? appState.getHighlightStyle(context, v).copyWith(backgroundColor: bgColor)
: appState.getHighlightStyle(context, v),
if (!widget.highlight) return buildText(null);
final highlightColor = appState.darkMode ? darkHighlights[1] : lightHighlights[1];
final bgColor = highlightColor.withValues(alpha: _controller.value);
return buildText(bgColor);