feat: Add continuous Bible reading mode, backend-driven translation API integration, localized book names, and preference caching
This commit is contained in:
88
Views/Bible.js
Normal file
88
Views/Bible.js
Normal file
@@ -0,0 +1,88 @@
|
||||
import React from "react";
|
||||
import { FlatList, View } from "react-native";
|
||||
import { Button, Chip, Text } from "react-native-paper";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { BIBLE_BOOKS, getBookChapterCount } from "../utils/bibleReferences.js";
|
||||
import i18n from "../i18nMessages.js";
|
||||
|
||||
const Bible = () => {
|
||||
const navigation = useNavigation();
|
||||
const [selectedBook, setSelectedBook] = React.useState("");
|
||||
const [activeStep, setActiveStep] = React.useState("book");
|
||||
|
||||
const selectedBookChapterCount = React.useMemo(() => getBookChapterCount(selectedBook), [selectedBook]);
|
||||
const chapterOptions = React.useMemo(
|
||||
() => Array.from({ length: selectedBookChapterCount }, (_v, i) => String(i + 1)),
|
||||
[selectedBookChapterCount]
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, paddingHorizontal: 12, paddingTop: 12 }}>
|
||||
<Text style={{ fontSize: 22, fontWeight: "700", marginBottom: 6 }}>{i18n.t("message.bible") || "Read the Bible"}</Text>
|
||||
<Text style={{ color: "#6b7280", marginBottom: 10 }}>
|
||||
{i18n.t("message.biblePickerSubtitlePost") || "Select a book and chapter to start reading."}
|
||||
</Text>
|
||||
|
||||
<View style={{ flexDirection: "row", marginBottom: 10 }}>
|
||||
<Button mode={activeStep === "book" ? "contained-tonal" : "text"} onPress={() => setActiveStep("book")}>
|
||||
{i18n.t("message.book") || "Book"}
|
||||
</Button>
|
||||
<Button
|
||||
mode={activeStep === "chapter" ? "contained-tonal" : "text"}
|
||||
disabled={!selectedBook}
|
||||
onPress={() => setActiveStep("chapter")}
|
||||
>
|
||||
{i18n.t("message.chapter") || "Chapter"}
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{activeStep === "book" ? (
|
||||
<>
|
||||
<Text style={{ fontSize: 14, fontWeight: "600", marginBottom: 8 }}>{i18n.t("message.books") || "Books"}</Text>
|
||||
<FlatList
|
||||
data={BIBLE_BOOKS}
|
||||
numColumns={2}
|
||||
keyExtractor={(item) => item}
|
||||
renderItem={({ item }) => (
|
||||
<Chip
|
||||
selected={selectedBook === item}
|
||||
style={{ marginRight: 6, marginBottom: 6, width: "48%" }}
|
||||
onPress={() => {
|
||||
setSelectedBook(item);
|
||||
setActiveStep("chapter");
|
||||
}}
|
||||
>
|
||||
{item}
|
||||
</Chip>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{activeStep === "chapter" ? (
|
||||
<>
|
||||
<Text style={{ fontSize: 14, fontWeight: "600", marginBottom: 8 }}>
|
||||
{i18n.t("message.chapters") || "Chapters"} {selectedBook ? `(${selectedBook})` : ""}
|
||||
</Text>
|
||||
<FlatList
|
||||
data={chapterOptions}
|
||||
numColumns={6}
|
||||
keyExtractor={(item) => item}
|
||||
renderItem={({ item }) => (
|
||||
<Chip
|
||||
style={{ marginRight: 6, marginBottom: 6, minWidth: 44 }}
|
||||
onPress={() => {
|
||||
navigation.navigate("BibleChapter", { reference: `${selectedBook} ${item}`, selectable: false });
|
||||
}}
|
||||
>
|
||||
{item}
|
||||
</Chip>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default Bible;
|
||||
@@ -1,35 +1,61 @@
|
||||
import React from "react";
|
||||
import { FlatList, Pressable, View } from "react-native";
|
||||
import { ActivityIndicator, Text } from "react-native-paper";
|
||||
import { ActivityIndicator, IconButton, Menu, Text, Button } from "react-native-paper";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { fetchBibleChapter, parseBibleReference } from "../utils/bibleReferences.js";
|
||||
import { fetchBibleChapter, parseBibleReference, AVAILABLE_TRANSLATIONS, BIBLE_BOOKS, getBookChapterCount } from "../utils/bibleReferences.js";
|
||||
import GlobalState from "../contexts/GlobalState.js";
|
||||
import { useSnapshot } from "valtio";
|
||||
import API from "../API.js";
|
||||
import i18n from "../i18nMessages.js";
|
||||
|
||||
const BibleChapterView = ({ route }) => {
|
||||
const navigation = useNavigation();
|
||||
const gState = useSnapshot(GlobalState);
|
||||
const reference = route?.params?.reference || "";
|
||||
const selectable = route?.params?.selectable === true;
|
||||
const { chapterReference, verse: selectedVerse } = parseBibleReference(reference);
|
||||
const { chapterReference, verse: selectedVerse, book, chapter } = parseBibleReference(reference);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState("");
|
||||
const [chapterData, setChapterData] = React.useState(null);
|
||||
const initialTranslation = gState.me?.data?.bibleTranslation || "";
|
||||
const [selectedTranslation, setSelectedTranslation] = React.useState(initialTranslation);
|
||||
const [translationMenuVisible, setTranslationMenuVisible] = React.useState(false);
|
||||
const listRef = React.useRef(null);
|
||||
const autoScrolledRef = React.useRef(false);
|
||||
|
||||
const bookIndex = BIBLE_BOOKS.indexOf(book);
|
||||
const chapterCount = getBookChapterCount(book);
|
||||
|
||||
let prevReference = null;
|
||||
if (chapter > 1) {
|
||||
prevReference = `${book} ${chapter - 1}`;
|
||||
} else if (bookIndex > 0) {
|
||||
const prevBook = BIBLE_BOOKS[bookIndex - 1];
|
||||
const prevBookChapters = getBookChapterCount(prevBook);
|
||||
prevReference = `${prevBook} ${prevBookChapters}`;
|
||||
}
|
||||
|
||||
let nextReference = null;
|
||||
if (chapter < chapterCount) {
|
||||
nextReference = `${book} ${chapter + 1}`;
|
||||
} else if (bookIndex >= 0 && bookIndex < BIBLE_BOOKS.length - 1) {
|
||||
const nextBook = BIBLE_BOOKS[bookIndex + 1];
|
||||
nextReference = `${nextBook} 1`;
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
let mounted = true;
|
||||
const loadChapter = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const payload = await fetchBibleChapter(chapterReference, i18n.locale);
|
||||
const payload = await fetchBibleChapter(chapterReference, i18n.locale, selectedTranslation);
|
||||
if (!mounted) return;
|
||||
setChapterData(payload);
|
||||
} catch (_error) {
|
||||
if (!mounted) return;
|
||||
setError(i18n.t("message.unableLoadChapter"));
|
||||
setError(i18n.t("message.unableLoadChapter") || "Unable to load chapter");
|
||||
setChapterData(null);
|
||||
} finally {
|
||||
if (mounted) setLoading(false);
|
||||
@@ -39,7 +65,7 @@ const BibleChapterView = ({ route }) => {
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [chapterReference]);
|
||||
}, [chapterReference, selectedTranslation]);
|
||||
|
||||
const verses = Array.isArray(chapterData?.verses) ? chapterData.verses : [];
|
||||
const selectedVerseNumber = Number(selectedVerse || 1);
|
||||
@@ -47,7 +73,7 @@ const BibleChapterView = ({ route }) => {
|
||||
|
||||
React.useEffect(() => {
|
||||
autoScrolledRef.current = false;
|
||||
}, [chapterReference, selectedVerseNumber]);
|
||||
}, [chapterReference, selectedTranslation, selectedVerseNumber]);
|
||||
|
||||
const scrollToSelectedVerse = React.useCallback((animated = false) => {
|
||||
if (autoScrolledRef.current) return;
|
||||
@@ -92,12 +118,48 @@ const BibleChapterView = ({ route }) => {
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1 }}>
|
||||
<View style={{ flex: 1, padding: 12 }}>
|
||||
<Text style={{ fontSize: 22, fontWeight: "700", marginBottom: 4 }}>
|
||||
{chapterData?.reference || chapterReference}
|
||||
</Text>
|
||||
<Text style={{ color: "#6b7280", marginBottom: 10 }}>
|
||||
{chapterData?.translation_name || chapterData?.translation_id || "KJV"}
|
||||
</Text>
|
||||
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{ fontSize: 22, fontWeight: "700", marginBottom: 4 }}>
|
||||
{chapterData?.reference || chapterReference}
|
||||
</Text>
|
||||
<Menu
|
||||
visible={translationMenuVisible}
|
||||
onDismiss={() => setTranslationMenuVisible(false)}
|
||||
anchor={
|
||||
<Pressable onPress={() => setTranslationMenuVisible(true)}>
|
||||
<Text style={{ color: "#2563eb", marginBottom: 10, fontWeight: "600", textDecorationLine: "underline" }}>
|
||||
{chapterData?.translation_name || chapterData?.translation_id || "KJV"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
}
|
||||
>
|
||||
{AVAILABLE_TRANSLATIONS.map((trans) => (
|
||||
<Menu.Item
|
||||
key={trans.id}
|
||||
onPress={() => {
|
||||
setSelectedTranslation(trans.id);
|
||||
setTranslationMenuVisible(false);
|
||||
if (GlobalState.me) {
|
||||
if (!GlobalState.me.data) GlobalState.me.data = {};
|
||||
GlobalState.me.data.bibleTranslation = trans.id;
|
||||
}
|
||||
API.setDataValue("bibleTranslation", trans.id);
|
||||
}}
|
||||
title={trans.name}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
</View>
|
||||
{!selectable ? (
|
||||
<IconButton
|
||||
icon="menu-book"
|
||||
size={28}
|
||||
onPress={() => navigation.navigate("Bible")}
|
||||
style={{ margin: 0, marginTop: -4 }}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
{selectable ? (
|
||||
<Text style={{ color: "#6b7280", marginBottom: 8 }}>{i18n.t("message.tapVerseToSelect")}</Text>
|
||||
) : null}
|
||||
@@ -122,6 +184,32 @@ const BibleChapterView = ({ route }) => {
|
||||
scrollToSelectedVerse(false);
|
||||
}, 120);
|
||||
}}
|
||||
ListFooterComponent={() => (
|
||||
<View style={{ flexDirection: "row", justifyContent: "space-between", marginTop: 16, marginBottom: 24, paddingHorizontal: 4 }}>
|
||||
<Button
|
||||
mode="outlined"
|
||||
disabled={!prevReference}
|
||||
onPress={() => {
|
||||
if (prevReference) {
|
||||
navigation.replace("BibleChapter", { reference: prevReference, selectable });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{i18n.t("message.previous") || "Previous"}
|
||||
</Button>
|
||||
<Button
|
||||
mode="outlined"
|
||||
disabled={!nextReference}
|
||||
onPress={() => {
|
||||
if (nextReference) {
|
||||
navigation.replace("BibleChapter", { reference: nextReference, selectable });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{i18n.t("message.next") || "Next"}
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
renderItem={({ item }) => {
|
||||
const verseNumber = Number(item?.verse || 0);
|
||||
const isSelected = verseNumber === selectedVerseNumber;
|
||||
@@ -129,19 +217,20 @@ const BibleChapterView = ({ route }) => {
|
||||
<Pressable
|
||||
onPress={() => handleVersePress(verseNumber)}
|
||||
style={{
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: 8,
|
||||
borderRadius: 8,
|
||||
marginBottom: 6,
|
||||
marginBottom: 2,
|
||||
backgroundColor: isSelected ? "#fff3cd" : "transparent",
|
||||
borderWidth: isSelected ? 1 : 0,
|
||||
borderColor: isSelected ? "#f59e0b" : "transparent",
|
||||
flexDirection: "row",
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontWeight: "700", color: isSelected ? "#92400e" : "#374151" }}>
|
||||
<Text style={{ fontWeight: "700", color: isSelected ? "#92400e" : "#374151", marginRight: 8, marginTop: 2, fontSize: 12 }}>
|
||||
{verseNumber}
|
||||
</Text>
|
||||
<Text style={{ color: "#111827", lineHeight: 21 }}>{item?.text || ""}</Text>
|
||||
<Text style={{ color: "#111827", lineHeight: 22, flex: 1, fontSize: 16 }}>{(item?.text || "").replace(/\n/g, " ").trim()}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -91,6 +91,7 @@ let MenuView = ({ navigation }) => {
|
||||
<List.Item key="Logout" title={i18n.t('message.logout')} onPress={() => { navigation.navigate("Logout") }} left={props => <List.Icon {...props} icon="logout" />} />
|
||||
</List.Section>
|
||||
<List.Section title={i18n.t("message.fellowshipApp")}>
|
||||
<List.Item key='Bible' title={i18n.t('message.bible') || 'Bible'} onPress={() => { navigation.navigate("Bible") }} left={props => <List.Icon {...props} icon="menu-book" />} />
|
||||
<List.Item key='Invite' title={i18n.t('message.invite')} onPress={() => { navigation.navigate("Invite") }} left={props => <List.Icon {...props} icon="person-add" />} />
|
||||
<List.Item key='About' title={i18n.t('message.about')} left={props => <List.Icon {...props} icon="more" />} />
|
||||
</List.Section>
|
||||
|
||||
Reference in New Issue
Block a user