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



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 bookIndex;
final int chapterIndex;
final int? scrollToVerse;
const Home({super.key, required this.bookIndex, required this.chapterIndex, this.scrollToVerse});
@override
State<Home> createState() => _HomeState();
}
class _HomeState extends State<Home> {
final Map<int, GlobalKey> _verseKeys = {};
GlobalKey _keyForVerse(int index) {
return _verseKeys.putIfAbsent(index, () => GlobalKey());
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final scrollToVerse = widget.scrollToVerse;
if (scrollToVerse != null) {
final key = _verseKeys[scrollToVerse];
if (key?.currentContext != null) {
Scrollable.ensureVisible(
key!.currentContext!,
);
}
}
});
}
List<InlineSpan> _buildHeadingSpans(
BuildContext context,
Bible bible,
String heading,
List<Reference> references,
TextStyle style,
double fontSize,
) {
final spans = <InlineSpan>[];
spans.add(TextSpan(text: heading, style: style.copyWith(fontSize: fontSize)));
final refStyle = style.copyWith(
color: Colors.blueAccent,
fontStyle: FontStyle.italic,
fontSize: fontSize - 1,
fontWeight: FontWeight.w500,
);
final symbolStyle = refStyle.copyWith(
color: Theme.of(context).colorScheme.onSurface,
);
if (references.isNotEmpty) {
spans.add(
TextSpan(
text: "\n(",
style: symbolStyle,
),
);
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}";
if (i > 0) {
spans.add(
TextSpan(
text: "; ",
style: symbolStyle,
),
);
}
spans.add(
TextSpan(
text: label,
style: refStyle,
recognizer: TapGestureRecognizer()
..onTap =
() => context.dispatch(GoToChapterAction(context.router, ref.book, ref.chapter, verse: ref.verse)),
),
);
}
spans.add(
TextSpan(
text: ")",
style: symbolStyle,
),
);
}
spans.add(TextSpan(text: "\n", style: style));
return spans;
}
@override
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],
fontSize: fontSize,
);
return Scaffold(
appBar: HomeAppBar(bible: bible, book: book, chapter: chapter),
backgroundColor: Theme.of(context).colorScheme.surface,
body: SafeArea(
child: Stack(
children: [
_ChapterSwipeDetector(
onSwipeLeft: () =>
context.dispatch(NextChapterAction(context.router, bible, chapter.book, chapter.index)),
onSwipeRight: () =>
context.dispatch(PreviousChapterAction(context.router, bible, chapter.book, chapter.index)),
onEdgeSwipeRight: () {
final router = context.router;
if (router.canPop()) {
router.pop();
} else {
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),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final v in chapter.verses!)
Padding(
key: _keyForVerse(v.index),
padding: const EdgeInsets.only(bottom: 8),
child: GestureDetector(
onTap: () => context.dispatch(SelectVerseAction(v)),
behavior: HitTestBehavior.opaque,
child: _VerseText(
verse: v,
bible: bible,
baseStyle: baseStyle,
theme: theme,
appState: appState,
buildHeadingSpans: _buildHeadingSpans,
fontSize: fontSize,
highlight: widget.scrollToVerse == v.index,
),
),
),
if (selectedVerses.isNotEmpty) const SizedBox(height: 120),
],
),
),
),
if (selectedVerses.isNotEmpty)
Positioned(
left: 20,
right: 20,
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;
final Widget child;
const _ChapterSwipeDetector({
required this.onSwipeLeft,
required this.onSwipeRight,
required this.onEdgeSwipeRight,
required this.child,
});
@override
State<_ChapterSwipeDetector> createState() => _ChapterSwipeDetectorState();
}
class _ChapterSwipeDetectorState extends State<_ChapterSwipeDetector> {
double? _startX;
@override
Widget build(BuildContext context) {
return GestureDetector(
onHorizontalDragStart: (details) {
_startX = details.globalPosition.dx;
},
onHorizontalDragEnd: (details) {
final startX = _startX;
_startX = null;
if (startX == null) return;
final velocity = details.primaryVelocity ?? 0;
if (velocity < -300) {
widget.onSwipeLeft();
} else if (velocity > 300) {
if (startX < 40) {
widget.onEdgeSwipeRight();
} else {
widget.onSwipeRight();
}
}
},
child: widget.child,
);
}
}
class _VerseText extends StatefulWidget {
final Verse verse;
final Bible bible;
final TextStyle baseStyle;
final TextTheme theme;
final AppState appState;
final List<InlineSpan> Function(BuildContext, Bible, String, List<Reference>, TextStyle, double) buildHeadingSpans;
final double fontSize;
final bool highlight;
const _VerseText({
required this.verse,
required this.bible,
required this.baseStyle,
required this.theme,
required this.appState,
required this.buildHeadingSpans,
required this.fontSize,
this.highlight = false,
});
@override
State<_VerseText> createState() => _VerseTextState();
}
class _VerseTextState extends State<_VerseText> with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 400),
reverseDuration: const Duration(milliseconds: 500),
);
if (widget.highlight) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_controller.forward().then((_) {
Future.delayed(const Duration(milliseconds: 600), () {
if (mounted) _controller.reverse();
});
});
});
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
static final _redTagPattern = RegExp(r"<red>(.*?)</red>");
@override
Widget build(BuildContext context) {
final v = widget.verse;
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));
var lastEnd = 0;
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));
lastEnd = match.end;
}
if (lastEnd < text.length) {
spans.add(TextSpan(text: text.substring(lastEnd), style: style));
}
return spans;
}
Widget buildText(Color? bgColor) {
return Text.rich(
TextSpan(
style: widget.baseStyle,
children: [
if (v.heading != null && v.heading!.isNotEmpty)
...widget.buildHeadingSpans(
context,
widget.bible,
v.heading!,
v.headingReferences ?? [],
theme.labelLarge!,
widget.fontSize,
),
TextSpan(
text: "${v.index + 1} ",
style: theme.labelMedium!.copyWith(
color: const Color(0xFF9A1111),
backgroundColor: bgColor,
),
),
...buildVerseTextSpans(
v.text ?? "",
bgColor != null
? appState.getHighlightStyle(context, v).copyWith(backgroundColor: bgColor)
: appState.getHighlightStyle(context, v),
),
],
),
);
}
if (!widget.highlight) return buildText(null);
return AnimatedBuilder(
animation: _controller,
builder: (context, _) {
final highlightColor = appState.darkMode ? darkHighlights[1] : lightHighlights[1];
final bgColor = highlightColor.withValues(alpha: _controller.value);
return buildText(bgColor);
},
);
}
}