For instance, on the planet Earth, man had always assumed that he was more intelligent than dolphins because he had achieved so much—the wheel, New York, wars and so on—whilst all the dolphins had ever done was muck about in the water having a good time. But conversely, the dolphins had always believed that they were far more intelligent than man —for precisely the same reasons, Douglas Adams, The Hitchhiker’s Guide to the Galaxy.
Far out in the uncharted backwaters of the unfashionable end of the western spiral arm of the Galaxy lies a small unregarded yellow sun. Orbiting this at a distance of roughly ninety-two million miles is an utterly insignificant little blue green planet whose ape-descended life forms are so amazingly primitive that they still think digital watches are a pretty neat idea, Douglas Adams, The Hitchhiker’s Guide to the Galaxy.

We’re building a modal form that lets users create a new prayer from the Prayers tab. The modal slides up, offers a title (required), an optional description, and a date (pre‑filled with today). Pressing Save validates the form, shows an inline error if the title is missing, and on success closes the modal and adds the prayer to the left‑hand list.
The fundamental difference (React Native vs Qt’s QDialog):
Qt QDialog React Native Modal
────────────────────────────────────── ──────────────────────────────────────
dlg = QDialog(self) <Modal visible={isOpen}>
dlg.exec() <View>...</View>
</Modal>
dlg.exec() blocks the calling thread until the dialog is closed. In React Native, there is no blocking. The modal is always rendered; we only toggle its visibility with a boolean prop.
The result (accepted/rejected) is obtained after exec() returns. In React Native, the modal communicates via callback props like onSave and onClose.
In Qt’s, the dialog manages its own UI and state. In Reactive, the parent component manages the state (visible, the new prayer data) and passes it down.
import { Modal, View, Text, Button } from 'react-native';
const [modalVisible, setModalVisible] = useState(false);
<Modal
/* visible – boolean that controls whether the modal is shown. */
visible={modalVisible}
/* animationType – "slide", "fade", or "none". */
animationType="slide"
/* onRequestClose – required on Android for handling the hardware back button.
Callbacks (onSave, onClose) are the React way to let the modal report back to its parent. */
onRequestClose={() => setModalVisible(false)}
>
<View style={{ flex: 1, padding: 20 }}>
<Text>My Modal Content</Text>
<Button title="Close" onPress={() => setModalVisible(false)} />
</View>
</Modal>
In Qt you read the current text with input.text() at submit time. In React Native you keep the text in state and update it on every keystroke:
// Qt approach — read at submit time
def submit():
title = self.title_input.text()
// React approach — always have the current value in state
// title always holds the latest input
const [title, setTitle] = useState("");
// TextInput is the basic text field component.
<TextInput
value={title} // The current text (controlled component).
onChangeText={(text) => setTitle(text)} // Called with the new text on every change (on every keystroke).
/>
// At submit time, `title` already has the current value
This is called a controlled input. Why controlled?
src/components/AddPrayerModal.tsxAddPrayerModal is a slide‑up form that collects a title, an optional description, and a date. On save it:
// src/components/AddPrayerModal.tsx
//
// ADD PRAYER MODAL
// ─────────────────
// A slide-up form for creating a new prayer entry.
//
// FIELDS
// Title (required) — prayer display name
// Description (optional) — one-sentence summary
// Date (optional) — ISO date, pre-filled with today
//
// LIFECYCLE
// React Native’s Modal is always mounted. We only toggle it with the visible prop.
// (*) The component is rendered even when visible={false},
// so local state (the form values) must be explicitly reset when the modal opens.
// (*) Callbacks (onClose, onSave) replace Qt’s accepted/rejected signals.
// Parent renders <AddPrayerModal visible={open} onClose={...} onSave={...} />
// Modal is always mounted but hidden when visible=false.
import React, { useState, useCallback } from "react";
import {
Modal,
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
KeyboardAvoidingView,
Platform,
ScrollView,
ActivityIndicator,
TouchableWithoutFeedback,
Keyboard,
} from "react-native";
import { useSQLiteContext } from "expo-sqlite";
import { addPrayer } from "../db/prayerQueries";
import { todayISO } from "../utils/dateUtils";
// ── Colour palette (copied from theme — centralised in Chunk 27) ──────────────
const C = {
bg: "#1a1f2e",
panel: "#222840",
panelAlt: "#2a3050",
border: "#2d3550",
accent: "#5b8fd4",
text: "#dde4f0",
muted: "#9ca3af",
faint: "#6b7280",
gold: "#c9a84c",
error: "#f87171",
success: "#86efac",
overlay: "rgba(0, 0, 0, 0.65)",
};
// ── Props ──────────────────────────────────────────────────────────────────────
// This defines the public API of our modal. The parent component decides:
interface AddPrayerModalProps {
/** Whether the modal is visible.
* The modal itself does not decide visibility — it is controlled externally. */
visible: boolean;
/** Called when the user dismisses the modal without saving.
* The modal does not hide itself; it asks the parent to hide it. */
onClose: () => void;
/**
* Called after a prayer is successfully inserted into the database.
* The modal passes the new prayer’s ID so the parent can select it, highlight it, or reload the list.
*/
onSave: (newPrayerId: number) => void;
}
// ── Form state type ────────────────────────────────────────────────────────────
// This is the shape of the form data:
interface FormState {
title: string;
description: string;
date: string;
}
interface FormErrors {
title?: string;
description?: string;
date?: string;
}
// ══════════════════════════════════════════════════════════════════════════════
// Component State: What the Modal Manages Internally
// ══════════════════════════════════════════════════════════════════════════════
export function AddPrayerModal({ visible, onClose, onSave }: AddPrayerModalProps) {
const db = useSQLiteContext();
// ── Form state ───────────────────────────────────────────────────────────────
// Instead of one useState setter per field, we keep all form values in a single form state object.
// Each field is a controlled input — the value in state IS what is displayed.
const [form, setForm] = useState<FormState>({
title: "",
description: "",
date: todayISO(), // pre-filled with today's date
});
// If validation fails, you fill in messages, e.g., setErrors({ title: "Required", date: "Invalid date" });
const [errors, setErrors] = useState<FormErrors>({});
// Tracks whether the “Save” button should: show a spinner, be disabled, or prevent double submissions.
const [saving, setSaving] = useState(false);
// A short-lived “Success!” flash after saving. This is purely UI sugar.
const [saved, setSaved] = useState(false);
// ── Helper: update one field ──────────────────────────────────────────────────
// The helper updateField merges the changed field and simultaneously clears
// any validation error for that field.
// Each TextInput is controlled: its value comes from form.field, its onChangeText calls updateField.
function updateField(field: keyof FormState, value: string) {
// React state updates are asynchronous. So if you need the previous state,
// you must use the functional form: setForm((prev) => { ... })
// prev is the previous form object
// Spread operator: { ...prev } creates a new object with the same fields.
// [field]: value updates one property dynamically
setForm((prev) => ({ ...prev, [field]: value }));
// Clear the error for this field as the user types
// meaning that if the user starts typing in a field that had an error, remove the error message for that field.
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: undefined }));
}
}
// ── Validation ────────────────────────────────────────────────────────────────
function validate(): boolean {
const newErrors: FormErrors = {};
/* trim() before validation prevents the user from submitting a string of spaces. */
if (!form.title.trim()) {
newErrors.title = "Title is required.";
} else if (form.title.trim().length > 120) {
newErrors.title = "Title must be 120 characters or fewer.";
}
/* Date format validation uses a strict regex ^\d{4}-\d{2}-\d{2}$.
This doesn’t validate semantic correctness (e.g., 2026‑13‑45 would pass), but it catches common typos. */
if (form.date && !/^\d{4}-\d{2}-\d{2}$/.test(form.date.trim())) {
newErrors.date = "Date must be in YYYY-MM-DD format (e.g. 2026-05-14).";
}
/* The function both sets errors (side effect) and returns the validity. */
setErrors(newErrors);
// Returns true only if there are no error messages
return Object.keys(newErrors).length === 0;
}
// ── Submission ────────────────────────────────────────────────────────────────────
const handleSave = useCallback(async () => {
// 1. Dismiss keyboard — Keyboard.dismiss() first,
// so the keyboard doesn’t linger while the async operation runs.
Keyboard.dismiss();
// 2. Validate — if validation fails, errors are already set and the function stops.
// The user sees red error messages and can fix the form.
if (!validate()) return;
// 3. Set saving — the Save button switches to a spinner.
setSaving(true);
try {
// 4. Call addPrayer — this is the async database operation from the hook. Arguments:
const newId = await addPrayer(
db, // — the open SQLite database.
form.title.trim(), // — ensures no leading/trailing spaces are saved.
form.description.trim(),
form.date.trim() || todayISO(), // — if the date field is empty, default to today’s date.
null // no cover image — image support will be added later.
);
// 5. On success: (i) Set saved to true (triggers success UI).
// (ii) After a 600ms delay, reset the form (*), clear errors,
// call onSave(newId) so the parent can select the new prayer,
// and call onClose() to dismiss the modal.
// The delay lets the user see the “✅ Saved!” feedback briefly before the modal disappears.
setSaved(true);
setTimeout(() => {
setSaved(false);
// Reset the form for next use (*).
setForm({ title: "", description: "", date: todayISO() });
setErrors({});
onSave(newId);
onClose();
}, 600);
} catch (e) {
// 6. On error: Catches any exception and sets errors.title with the error message.
// This displays the error under the title field (since we can’t possibly know which field caused it,
// title is a reasonable default). The user sees the message and can retry.
setErrors({ title: String(e) });
} finally {
// 7. finally — always sets saving to false.
setSaving(false);
}
}, [db, form, onSave, onClose]);
// ── Dismissal: close without saving ─────────────────────────────────────────────
/* If the user dismisses by tapping outside or pressing Android back,
we want the form to be clean on next open. Resetting on close guarantees this. */
function handleClose() {
// Dismisses the keyboard.
Keyboard.dismiss();
// Reset form on close so that when the modal reopens, the form is fresh.
setForm({ title: "", description: "", date: todayISO() });
setErrors({});
setSaved(false);
// Calls onClose() to hide the modal from the parent.
onClose();
}
The structure inside the modal (layel by layer):
Modal /* The actual modal. Everything else is just UI inside that modal. */
└── TouchableWithoutFeedback (outer)
└── View overlay (dark background)
/* This is the dimmed background behind the card. Tapping it closes the modal. */
└── TouchableWithoutFeedback (inner)
<TouchableWithoutFeedback onPress={() => {}}>
/* This prevents taps inside the card from closing the modal. */
└── KeyboardAvoidingView
└── View card ← THIS is the visible modal card
/* This is what users think of as “the modal”, but technically it’s just a view inside the modal. */
├── Header
├── ScrollView (form)
└── Save button
// ── Render ────────────────────────────────────────────────────────────────────
// The Modal Component
return (
<Modal
visible={visible} // The parent shows/hides the modal.
transparent={true} // the dark overlay behind the modal is our own View
animationType="slide" // The modal slides up from the bottom, a native‑like gesture.
onRequestClose={handleClose} // Mandatory on Android
// — called when the hardware back button is pressed (it closes the modal).
statusBarTranslucent={true} // The modal content can extend under the translucent status bar,
// creating a seamless overlay.
>
{/*
**The Overlay**.
The outer TouchableWithoutFeedback covers the entire screen (s.overlay has flex: 1).
Tapping anywhere, outside the modal card dismisses the modal.
This matches the behaviour of Material Design bottom sheets.
*/}
<TouchableWithoutFeedback onPress={handleClose}>
<View style={s.overlay}>
{/*
**The Inner Absorber**
The modal card itself is wrapped in a second TouchableWithoutFeedback with an empty onPress.
This absorbs the tap event, preventing it from propagating to the outer overlay.
TouchableWithoutFeedback propagates presses to its children.
We need to stop the press from the modal card reaching the overlay,
otherwise tapping inside the form would close the modal.
*/}
<TouchableWithoutFeedback onPress={() => { /* absorb press */ }}>
{/*
KeyboardAvoidingView shifts the form upward when the
software keyboard opens so the active (or focused) TextInput stays visible.
behavior="padding" — adds padding at the bottom (best for iOS)
behavior="height" — shrinks the view (better for some Android setups)
*/}
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={s.avoidingView}
>
/* Card Structure.
Inside the KeyboardAvoidingView, a ScrollView contains the form fields, and a Save button sits below it. */
<View style={s.card}>
{/* ── Modal header ──────────────────────────────────────────── */}
<View style={s.header}>
/* Drag handle — a small grey bar at the top, purely decorative,
hinting that the card can be dismissed by swiping (though swiping isn’t implemented; it’s a visual cue). */
<View style={s.dragHandle} />
<Text style={s.headerTitle}>Add Prayer</Text>
/* Close button — an explicit ✕ button for accessibility. */
<TouchableOpacity
onPress={handleClose}
style={s.closeBtn}
accessibilityLabel="Close dialog"
>
<Text style={s.closeBtnText}>✕</Text>
</TouchableOpacity>
</View>
{/* ── Form ──────────────────────────────────────────────────── */}
<ScrollView
keyboardShouldPersistTaps="handled"
// keyboardShouldPersistTaps="handled" means:
// tapping a button while the keyboard is open fires the
// button's onPress immediately AND the keyboard stays open.
showsVerticalScrollIndicator={false}
>
{/* FormField Wrappers. Title field (required) */}
/* The FormField (reusable) component provides: a label; a asterisk; a hint text; and an error message. */
<FormField
label="Title"
required={true}
error={errors.title}
>
<TextInput
style={[s.input, errors.title && s.inputError]}
value={form.title}
onChangeText={(t) => updateField("title", t)}
placeholder="e.g. Morning Prayer"
placeholderTextColor={C.faint}
autoFocus={true} // — the cursor lands here when the modal opens,
// so the user can start typing immediately.
returnKeyType="next"
// — on iOS/Android, the return key shows “next” instead of “done”, suggesting more fields follow
maxLength={120} // — enforced at the native level; the user can’t type more.
accessibilityLabel="Prayer title"
/>
{/* Character counter */}
<Text style={s.charCount}>
{form.title.length} / 120
</Text>
</FormField>
{/* Description field (optional) */}
<FormField
label="Description"
hint="One sentence describing this prayer"
error={errors.description}
>
<TextInput
style={[s.input, s.inputMultiline, errors.description && s.inputError]}
value={form.description}
onChangeText={(t) => updateField("description", t)}
placeholder="e.g. A quiet review of the day in God's presence"
placeholderTextColor={C.faint}
/* multiline={true} and numberOfLines={3} — gives a larger input area. */
multiline={true}
numberOfLines={3}
returnKeyType="next"
blurOnSubmit={false} // keep keyboard open after pressing Enter.
// It inserts a newline instead of dismissing the keyboard.
maxLength={300}
accessibilityLabel="Prayer description"
/>
</FormField>
{/* Date field */}
<FormField
label="Date"
hint="YYYY-MM-DD · Leave blank for today"
error={errors.date}
>
<TextInput
style={[s.input, s.inputMono, errors.date && s.inputError]}
value={form.date}
onChangeText={(t) => updateField("date", t)}
placeholder="2026-05-14"
placeholderTextColor={C.faint}
keyboardType="numbers-and-punctuation" // It brings up a keyboard suited for dates.
/* returnKeyType="done" and onSubmitEditing={handleSave}:
pressing “done” on the keyboard triggers save directly. */
returnKeyType="done"
onSubmitEditing={handleSave}
maxLength={10}
accessibilityLabel="Prayer date"
/>
{/* Quick date buttons — “Today” (sets to today’s date) and “Clear”
(empties the field, which will later default to today). These shortcuts make date entry trivial. */}
<View style={s.dateShortcuts}>
<TouchableOpacity
style={s.shortcutBtn}
onPress={() => updateField("date", todayISO())}
>
<Text style={s.shortcutBtnText}>Today</Text>
</TouchableOpacity>
<TouchableOpacity
style={s.shortcutBtn}
onPress={() => updateField("date", "")}
>
<Text style={s.shortcutBtnText}>Clear</Text>
</TouchableOpacity>
</View>
</FormField>
{/* A temporary note that explains the user cannot add pages yet.
This manages expectations and points to future functionality. */}
<View style={s.infoBox}>
<Text style={s.infoText}>
ℹ️ Pages are added after the prayer is created.
Use the prayer database (prayer_db) or the edit
screen (coming later) to add prayer steps.
</Text>
</View>
</ScrollView>
{/* ── Save button ────────────────────────────────────────────── */}
<TouchableOpacity
style={[
s.saveBtn,
saving && s.saveBtnLoading,
saved && s.saveBtnSuccess,
]}
onPress={handleSave}
/* The button is disabled while saving or during the success flash. */
disabled={saving || saved}
accessibilityLabel="Save prayer"
accessibilityRole="button"
>
/* Three visual states:
(i) Normal: “Save Prayer” (blue background).
(ii) Saving: a spinner replaces the text; the button is disabled.
(iii) Saved: “✅ Saved!” (likely a green background), shown for 600ms.*/
{saving ? (
<ActivityIndicator color="#ffffff" size="small" />
) : saved ? (
<Text style={s.saveBtnText}>✅ Saved!</Text>
) : (
<Text style={s.saveBtnText}>Save Prayer</Text>
)}
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
);
}
/* It is very important to notice that: */
<FormField label="Title" required={true} error={errors.title}>
<TextInput ... />
</FormField>
/* We are creating a React element tree, where: <FormField> is a component
Everything inside it becomes its children. Props like label="Title" are just object fields, and is equivalent to: */
FormField({
label: "Title",
required: true,
error: errors.title,
children: <TextInput ... />
});
/* Consider this block */
<FormField
label="Title"
required={true}
error={errors.title}
>
<TextInput
style={[s.input, errors.title && s.inputError]}
value={form.title}
onChangeText={(t) => updateField("title", t)}
placeholder="e.g. Morning Prayer"
placeholderTextColor={C.faint}
autoFocus={true}
returnKeyType="next"
maxLength={120}
accessibilityLabel="Prayer title"
/>
<Text style={s.charCount}>
{form.title.length} / 120
</Text>
</FormField>
/* What React sees internally: */
FormField({
label: "Title",
required: true,
error: errors.title,
children: [
<TextInput ... />,
<Text ... />
]
});
src/components/AddPrayerModal.tsx continued…// ══════════════════════════════════════════════════════════════════════════════
// FORM FIELD WRAPPER
// Each input group (title, description, date) follows the same pattern: label → input → error.
// Without a wrapper, the modal JSX would repeat the same layout code three times, making it harder to read and maintain.
// FormField encapsulates the presentation logic, leaving the modal to focus on data binding.
// A reusable component that renders a label, the input (children),
// an optional hint, and an optional error message below the input.
// ══════════════════════════════════════════════════════════════════════════════
interface FormFieldProps {
label: string; // The field name displayed above the input
required?: boolean; // If true, a red asterisk (*) is appended to the label
hint?: string; // Optional extra text shown next to the label, usually to clarify format
error?: string; // Validation error message; if provided, it’s shown below the input in red
children: React.ReactNode; // The actual input component (e.g., TextInput), whatever JSX is inside FormField
}
function FormField({ label, required, hint, error, children }: FormFieldProps) {
/* Internal Structure */
return (
/* Container – marginBottom: 18 gives consistent spacing between fields. */
<View style={f.container}>
{/* Label row. A horizontal row with flexWrap:
"wrap" so long hints can drop to the next line without overlapping. */}
<View style={f.labelRow}>
<Text style={f.label}>{label}</Text> /* Uppercase, bold, muted colour – a classic form label style. */
{required && <Text style={f.required}> *</Text>} /* Red * with heavy weight, immediately visible. */
{hint && <Text style={f.hint}>{hint}</Text>} /* Faint, small text pushed to the right via marginLeft: "auto".
This ensures the hint sits at the far right, while the label stays left. */
</View>
{/* The actual input (whatever we put inside the component) is passed in,
so FormField remains agnostic to the type of input (TextInput, Picker, etc.). */}
{children}
{/* Error message – conditionally rendered in red (only shown when it is set),
with a small margin top and line height for readability. */}
{error ? (
<Text style={f.error}>{error}</Text>
) : null}
</View>
);
}
const f = StyleSheet.create({
container: { marginBottom: 18 },
labelRow: { flexDirection: "row", alignItems: "baseline",
marginBottom: 6, flexWrap: "wrap", gap: 4 },
label: { fontSize: 13, fontWeight: "600", color: "#9ca3af",
textTransform: "uppercase", letterSpacing: 0.5 },
required: { fontSize: 13, color: "#f87171", fontWeight: "700" },
hint: { fontSize: 11, color: "#4b5563", marginLeft: "auto" as any },
error: { fontSize: 12, color: "#f87171", marginTop: 5,
lineHeight: 16 },
});
// ══════════════════════════════════════════════════════════════════════════════
// STYLES
// ══════════════════════════════════════════════════════════════════════════════
const s = StyleSheet.create({
// ── Overlay ────────────────────────────────────────────────────────────────────
// A dark, semi‑transparent layer behind the modal card.
overlay: {
flex: 1, // – fills the entire screen.
backgroundColor: C.overlay,
justifyContent: "flex-end", // aligns the card to the bottom of the screen,
// creating the “bottom sheet” effect. This is why the card appears anchored at the bottom.
},
// ── Keyboard avoiding wrapper ──────────────────────────────────────────────────
// Wrapper inside the overlay that allows the card to shift up when the keyboard appears.
avoidingView: {
// No flex: 1 here — we want the card to be only as tall as needed.
// The card’s height is determined by its content.
},
// ── Modal card ─────────────────────────────────────────────────────────────────
card: {
backgroundColor: C.panel, // the card itself is a slightly lighter dark colour
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
padding: 24,
paddingBottom: Platform.OS === "ios" ? 40 : 24,
// maxHeight limits the modal on tablets — avoids it filling the whole screen
maxHeight: "85%",
// Shadow on iOS. A subtle shadow that lifts the card above the overlay.
shadowColor: "#000",
shadowOffset: { width: 0, height: -4 },
shadowOpacity: 0.3,
shadowRadius: 12,
// Android’s equivalent of shadow; a high value creates a deep, realistic shadow.
elevation: 20,
},
// ── Header ─────────────────────────────────────────────────────────────────────
header: {
flexDirection: "row",
/* Horizontal layout with alignItems: "center" vertically centres the title and close button. */
alignItems: "center",
marginBottom: 20,
},
dragHandle: {
position: "absolute",
top: -14,
left: "50%",
// marginLeft centers the handle — "50%" is from the left edge
marginLeft: -20,
width: 40,
height: 4,
borderRadius: 2,
backgroundColor: C.border,
},
headerTitle: {
flex: 1,
fontSize: 18,
fontWeight: "bold",
color: C.gold,
},
closeBtn: {
padding: 8,
borderRadius: 6,
backgroundColor: C.panelAlt,
},
closeBtnText: {
fontSize: 14,
color: C.faint,
},
// ── Text inputs ─────────────────────────────────────────────────────────────────
/* input – The base style: dark background, subtle border, comfortable padding.
The text colour is C.text (light) so it’s readable against the dark input field. */
input: {
backgroundColor: C.bg,
borderWidth: 1,
borderColor: C.border,
borderRadius: 8,
paddingHorizontal: 14,
paddingVertical: 11,
color: C.text,
fontSize: 15,
},
/* Red border and slightly thicker (1.5) when validation fails. */
inputError: {
borderColor: C.error,
borderWidth: 1.5,
},
/* minHeight: 80 ensures a decent size for the description field.
textAlignVertical: "top" (Android) makes the cursor start at the top‑left, not the centre. */
inputMultiline: {
minHeight: 80,
textAlignVertical: "top", // Android: cursor starts at top-left
},
/* Monospace font for the date field, with platform‑specific font families and extra letter spacing.
This makes dates like 2026-05-14 look neat and distinct. */
inputMono: {
fontFamily: Platform.OS === "ios" ? "Courier" : "monospace",
letterSpacing: 0.5,
},
charCount: {
fontSize: 11,
color: C.faint,
textAlign: "right",
marginTop: 4,
},
// ── Date shortcuts ──────────────────────────────────────────────────────────────
/* Row of buttons – “Today” and “Clear” sit side by side below the date input. */
dateShortcuts: {
flexDirection: "row",
gap: 8,
marginTop: 8,
},
shortcutBtn: {
backgroundColor: C.panelAlt,
borderRadius: 6,
paddingHorizontal: 12,
paddingVertical: 6,
borderWidth: 1,
borderColor: C.border,
},
shortcutBtnText: {
fontSize: 12,
color: C.muted,
},
// ── Info box ────────────────────────────────────────────────────────────────────
/* A distinct container for the informational note about pages.
#1a2438 is a blue‑tinted dark colour, subtly different from the card background,
making it stand out as a block quote or notice. */
infoBox: {
backgroundColor: "#1a2438",
borderRadius: 8,
padding: 12,
marginBottom: 20,
borderWidth: 1,
borderColor: C.border,
},
infoText: {
fontSize: 12,
color: C.faint,
lineHeight: 18,
},
// ── Save button ─────────────────────────────────────────────────────────────────
/* Full‑width button with a prominent blue accent colour, rounded corners, and generous vertical padding.
The text is white, bold, and large for high visibility. */
saveBtn: {
backgroundColor: C.accent,
borderRadius: 10,
paddingVertical: 15,
alignItems: "center",
marginTop: 4,
},
saveBtnLoading: {
backgroundColor: C.border,
},
saveBtnSuccess: {
backgroundColor: "#166534", // dark green for success flash
},
saveBtnText: {
color: "#ffffff",
fontSize: 16,
fontWeight: "700",
},
});
app/(tabs)/prayers.tsxPrayersScreen is the main screen of the Prayers tab. It is responsible for:
This screen is the orchestrator:
This step adds the ability to create new prayers from the main prayers screen. We will integrate a modal component, wire its save action to refresh the prayer list, and automatically select the newly created prayer.
Four changes:
AddPrayerModal. Bring in the modal component that handles prayer creation.+ header button. Provide a visible entry point for users to add a prayer.onSave to refresh + select the new prayer. After saving, refresh the list and select the new prayer immediately.// Add the following imports to the top of prayers.tsx.
// These bring in the modal component, the navigation hook, and a React hook for side effects:
import { AddPrayerModal } from "../../src/components/AddPrayerModal"; // – the UI for creating a new prayer.
import { useNavigation } from "expo-router";
import { useLayoutEffect } from "react";
PrayersScreenCopy the code below into your file. It replaces the existing PrayersScreen implementation.
export default function PrayersScreen() {
const prayers = usePrayers(); // Custom hook for prayer data management
// this custom hook provides prayer (today’s selected prayer), prayerList, prayerCount
// loading, error, refresh() (reload everything from DB)), loadById(id) — load a specific prayer
const navigation = useNavigation(); // Access to header configuration (so we can add the + button)
// This controls whether the Add Prayer modal is visible.
// The modal is always mounted, but only visible when modalOpen === true.
const [modalOpen, setModalOpen] = useState(false);
// Dynamic sidebar width
// This makes the sidebar: 35% of the screen width, but never wider than 200px
const { width: screenWidth } = useWindowDimensions();
const sidebarWidth = Math.min(200, Math.floor(screenWidth * 0.35));
// Add + button to the navigation header.
// This is what “adds the ability to create new prayers from the main screen”.
// useLayoutEffect fires before the screen renders — this prevents
// the header from flickering without the button on first paint.
useLayoutEffect(() => {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity
onPress={() => setModalOpen(true)}
style={{ marginRight: 16, padding: 4 }}
accessibilityLabel="Add new prayer"
accessibilityRole="button"
>
<Text style={{ color: "#c9a84c", fontSize: 28, fontWeight: "300" }}>
+
</Text>
</TouchableOpacity>
),
});
}, [navigation]); // Only re-run if navigation changes
// Loading state. This is shown while: the hook loads today’s prayer
// the hook loads the list, the hook loads the count
if (prayers.loading) return <LoadingSpinner message="Loading prayers…" />;
// Error state (the hook encountered a DB error) – show a friendly retry option
if (prayers.error) {
return (
<EmptyState
icon="⚠️"
title="Something went wrong"
subtitle={prayers.error}
actionLabel="Retry"
onAction={prayers.refresh}
/>
);
}
// Main layout
return (
<View style={s.root}>
/* PrayerList receives the entire prayers object. It uses prayerList, prayerCount, and loadById. */
<PrayerList prayers={prayers} sidebarWidth={sidebarWidth} />
<View style={s.divider} />
/* PrayerDetail receives the same object. It uses prayer (today’s or selected prayer). */
<PrayerDetail prayers={prayers} />
{/* Add Prayer Modal — always mounted, shown when modalOpen=true */}
<AddPrayerModal
visible={modalOpen}
onClose={() => setModalOpen(false)}
/* What happens when the user saves a new prayer. */
onSave={async (newId) => {
// 1. Close the "Add Prayer" modal.
setModalOpen(false);
// 2. Refresh the list (prayerList) so the new prayer appears
await prayers.refresh();
// 3. Select the newly created prayer immediately
await prayers.loadById(newId);
// This gives the user immediate feedback: they see the prayer they just created.
}}
/>
</View>
);
}
npx expo start --clear