JustToThePoint English Website Version
JustToThePoint en español

Prayers Tab UI. Page Viewer and HTML Rendering

I have yet to see any problem, however complicated, which, when you looked at it in the right way, did not become still more complicated, Poul Anderson.

Fourier Series

We’re building the prayer page viewer – the central component that displays one page of a prayer at a time, with full HTML formatting, optional images, and page navigation.

Why react-native-render-html? Prayer pages are stored as HTML strings like

The prayer page content is stored as HTML strings like:

<h2>Presenceh2>
<p>What I pay attention to has a hold on my becoming.p>
<p>I reflect on the presence of God in this moment…p>

React Native’s <Text> component only renders plain text — it ignores HTML tags. There are three ways to display HTML in React Native:

Approach Pros Cons
Strip tags with .replace(/<[^>]+>/g, "") No dependency Loses all formatting
WebView with html content Full browser rendering Heavy, slow, overkill for text
react-native-render-html Native rendering, fast, lightweight Requires install

react-native-render-html (RRH) converts HTML to native React Native components. <h2> becomes a styled <Text>, <p> becomes a <Text>, <strong> becomes bold <Text>. It is the standard solution.

Step 1 — Install react-native-render-html

npx expo install react-native-render-html

Using npx expo install ensures you get a version compatible with your current Expo SDK.

Step 2 — Create src/components/PrayerPageViewer.tsx

We extract the page viewer into its own component so prayers.tsx stays readable. This component receives everything it needs as props.

  1. It makes prayers.tsx shorter and easier to read and understand.
  2. Makes the viewer independently testable.
  3. Allows reuse elsewhere (e.g., in a modal or a future “full‑screen prayer” screen).
// src/components/PrayerPageViewer.tsx
//
// PRAYER PAGE VIEWER
// ───────────────────
// Renders one page of a prayer with:
//   - HTML content via react-native-render-html
//   - Optional page image (base64 URI)
//   - Page indicator ("Page 2 of 5")
//   - Prev / Next navigation buttons
//
// USED BY: app/(tabs)/prayers.tsx
//
import React from "react";
import {
  View,
  Text,
  Image,
  TouchableOpacity,
  ScrollView,
  StyleSheet,
  useWindowDimensions,
} from "react-native";
import RenderHtml from "react-native-render-html";
import type { Page } from "../db/types";

// ── Colour constants ──────────────────────────────────────────────────────────
// These are copied directly from the app’s theme. \
// Repeating them here keeps the component independent for now.
const C = {
  bg:      "#1a1f2e",
  panel:   "#222840",
  border:  "#2d3550",
  accent:  "#5b8fd4",
  text:    "#dde4f0",
  muted:   "#9ca3af",
  faint:   "#6b7280",
  gold:    "#c9a84c",
  dim:     "#4b5563",
};

// ── Props ─────────────────────────────────────────────────────────────────────

interface PrayerPageViewerProps {
  /** The current Page object from the database, containing content (HTML) and optionally image (base64).
   * null when there are no pages at all. */
  page:        Page | null;
  /** 0-based index of the current page */
  currentPage: number;
  /** Total pages in this prayer */
  totalPages:  number;
  /** true if on the first page (disables Prev button) */
  isFirstPage: boolean;
  /** true if on the last page (disables Next button) */
  isLastPage:  boolean;
  /** Called when the user taps ◀ Prev.
   * The parent (the PrayersScreen) updates its state, and the component becomes a controlled component. */
  onPrev:      () => void;
  /** Called when the user taps Next ▶ */
  onNext:      () => void;
  /** Called when the user taps a page dot */
  onGoToPage:  (index: number) => void;
}

// ── Helpers ───────────────────────────────────────────────────────────────────

/**
 * Convert a raw base64 string to a data URI suitable for <Image source>.
 *
 * The database stores images as plain base64 strings (no prefix).
 * React Native's <Image source> requires a full data URI format:
 *   "data:image/jpeg;base64,<base64data>"
 *
 * We detect the prefix and add it if missing.
 * We default to image/jpeg because that is the most common format.
 */
function toImageUri(base64: string): string {
  if (base64.startsWith("data:")) {
    return base64;   // already a valid data URI
  }
  return `data:image/jpeg;base64,${base64}`;
}

// ── HTML render styles ────────────────────────────────────────────────────────
// react-native-render-html uses "tagsStyles" to style HTML elements.
// These replace what CSS would do in a browser and control the appearance of every HTML tag.
// They are passed to RenderHtml, e.g., h2 – gold, bold, larger font;
// p – regular light‑coloured body text with spacing; etc.
const HTML_STYLES = {
  h2: {
    color:        C.gold,
    fontSize:     20,
    fontWeight:   "700" as const,
    marginBottom: 8,
    marginTop:    4,
  },
  h3: {
    color:        C.accent,
    fontSize:     17,
    fontWeight:   "600" as const,
    marginBottom: 6,
  },
  p: {
    color:      C.text,
    fontSize:   15,
    lineHeight: 24,
    marginBottom: 12,
  },
  strong: {
    color:      C.gold,
    fontWeight: "700" as const,
  },
  em: {
    color:      C.muted,
    fontStyle:  "italic" as const,
  },
  blockquote: {
    borderLeftWidth: 3,
    borderLeftColor: C.gold,
    paddingLeft:     14,
    marginLeft:      0,
    color:           C.muted,
  },
};

// ── Component ─────────────────────────────────────────────────────────────────
/* The PrayerPageViewer component is the core reader for a single prayer’s pages.
It replaces the placeholder from the previous article with a working paginated view,
using react-native-render-html to display the stored HTML content.
The component does not own page‑navigation logic; it simply displays what it’s given and invokes callbacks.
It handles: */

export function PrayerPageViewer({
  page, // the current page object (or undefined if none)
  currentPage, // zero‑based index of the active page
  totalPages, // total number of pages for this prayer
  isFirstPage, // true if on the very first page
  isLastPage, // true if on the very last page
  onPrev, // go to previous page
  onNext, // go to next page
  onGoToPage,  // jump to a specific page by index
}: PrayerPageViewerProps) {

  // useWindowDimensions is a React Native hook that returns the current screen width and height,
  // and automatically updates on rotation or window resize.
  // react-native-render-html REQUIRES the contentWidth prop to know
  // how wide to render HTML content.  Without it, text might render at an incorrect width and overflow.
  const { width } = useWindowDimensions();

  // The content area is the screen width minus the sidebar (200px) that exists in the Prayers tab layout
  // minus padding on both sides (20px × 2).
  // This gives the exact available width for the HTML content.
  const contentWidth = width - 200 - 40;

  // ── No pages case ──────────────────────────────────────────────────────────
  // Empty State. Gracefully handles the case where a prayer has zero pages
  // – e.g., right after creation or during development.
  // Why !page || totalPages === 0? page could be undefined if the pages array is empty, or if there’s a timing issue.
  // totalPages === 0 explicitly catches a prayer with zero pages.
  // The empty state is rendered as a centred, low‑opacity group (opacity: 0.6),
  // which doesn’t distract but clearly communicates the situation.
  if (!page || totalPages === 0) {
    return (
      <View style={s.emptyPages}>
        <Text style={s.emptyIcon}>📄</Text>
        <Text style={s.emptyText}>This prayer has no pages yet.</Text>
        <Text style={s.emptySubtext}>
          Add pages using the database seed or the Add Prayer form.
        </Text>
      </View>
    );
  }

  /* Main Layout Structure. container has flex: 1 to fill the remaining space in the right panel. */
  return (
    <View style={s.container}>
      {/* ── Page indicator ──────────────────────────────────────────────────── */}
      <View style={s.indicatorRow}>
        <Text style={s.pageIndicator}>
          Page {currentPage + 1} of {totalPages}
        </Text>
        {/* Page dots — tap any to jump directly to that page */}
        /* Each dot is a View inside a TouchableOpacity */
        <View style={s.dots}>
          {Array.from({ length: totalPages }).map((_, i) => (
            <TouchableOpacity
              key={i}
              onPress={() => onGoToPage(i)}
              hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
              // hitSlop enlarges the tap area without changing visual size —
              // important for small dots that are hard to tap precisely
            >
              <View style={[s.dot, i === currentPage && s.dotActive]} />
            </TouchableOpacity>
          ))}
        </View>
      </View>

      {/* ── Scrollable Content Area ────────────────────────────────────────── */}
      <ScrollView
        style={s.scroll}
        contentContainerStyle={s.scrollContent}
        showsVerticalScrollIndicator={false}
        // When the key prop changes (we use the page id as the key — when the key changes),
        // React unmounts and remounts the ScrollView. This resets its internal scroll position to the very top.
        key={page.id}
      >

        {/* HTML content from the database */}
        {/*
          RenderHtml props:
            contentWidth  — required: mandatory for the library to know the available width for text wrapping.
            source        — the HTML string wrapped in { html: "..." }
            tagsStyles    — a separate style object that maps HTML tags to React Native styles,
            giving proper formatting to headings, paragraphs, lists, etc.
            baseStyle     —  sets a default text colour (C.text) and font size (15) for all text.

          WHY source={{ html: content }} and not source={{ uri: ... }}?
            We have the HTML string already in memory (from SQLite).
            The `uri` variant would fetch from a remote URL, which we don’t.

          The component imports a constant HTML_STYLES, this object maps HTML tags to React Native styles.
        */}
        <RenderHtml
          contentWidth={contentWidth}
          source={{ html: page.content || "<p>No content.</p>" }}
          tagsStyles={HTML_STYLES}
          baseStyle={{
            color:    C.text,
            fontSize: 15,
          }}
          // Disable the default font scaling — we control font sizes explicitly
          enableExperimentalMarginCollapsing={true}
        />

        {/* Page image (optional). The image is shown below the HTML content.
        The container has a border, rounded corners, and overflow: "hidden" to clip the image nicely. */}
        {page.image ? (
          <View style={s.imageContainer}>
            <Image
              /* toImageUri (imported from a utility) converts a file path to a URI that React Native’s
              Image component can display (using file:// for local files). */
              source={{ uri: toImageUri(page.image) }}
              style={s.pageImage}
              /* It ensures the whole image fits without cropping. */
              resizeMode="contain"
              // accessibilityLabel helps screen readers describe the image
              accessibilityLabel="Prayer page image"
            />
          </View>
        ) : null}

      </ScrollView>

      {/* ── Navigation buttons ───────────────────────────────────────────────── */}
      <View style={s.navRow}>

        {/* ◀ Prev button */}
        <TouchableOpacity
          /* Use the accent colour (C.accent) when active,
          and a disabled style (backgroundColor: C.border) when at the boundary.*/
          style={[s.navBtn, isFirstPage && s.navBtnDisabled]}
          onPress={onPrev}
          disabled={isFirstPage}
          // accessibilityLabel: read aloud by screen readers
          accessibilityLabel="Previous page"
          // it tells the accessibility system that this is a button.
          accessibilityRole="button"
          // accessibilityState.disabled: tells screen readers the button is inactive
          accessibilityState={{ disabled: isFirstPage }}
          activeOpacity={0.75}
        >
          <Text style={[s.navBtnText, isFirstPage && s.navBtnTextDisabled]}>
            ◀  Prev
          </Text>
        </TouchableOpacity>

        {/* Centre: a simple fraction showing the current page number and total pages.
        Uses C.faint colour and a small font size to be unobtrusive. */}
        <Text style={s.navFraction}>
          {currentPage + 1} / {totalPages}
        </Text>

        {/* Next ▶ button */}
        <TouchableOpacity
          style={[s.navBtn, isLastPage && s.navBtnDisabled]}
          onPress={onNext}
          disabled={isLastPage}
          accessibilityLabel="Next page"
          accessibilityRole="button"
          accessibilityState={{ disabled: isLastPage }}
          activeOpacity={0.75}
        >
          <Text style={[s.navBtnText, isLastPage && s.navBtnTextDisabled]}>
            Next  ▶
          </Text>
        </TouchableOpacity>

      </View>
    </View>
  );
}

// ── Styles ────────────────────────────────────────────────────────────────────
/* The styles object s is created with StyleSheet.create and references a shared colour constant C. */
const s = StyleSheet.create({

  container: {
    flex:           1,
    // Fills the parent (flex: 1), no extra padding.
  },

  // ── Empty state ───────────────────────────────────────────────────────────────
  // Centered, low‑opacity block for the “no pages” state.
  emptyPages: {
    flex:           1,
    alignItems:     "center",
    justifyContent: "center",
    gap:            10,
    opacity:        0.6,
  },
  // Emoji + messages in the empty state.
  emptyIcon:    { fontSize: 36 },
  emptyText:    { fontSize: 14, color: C.muted, textAlign: "center" },
  emptySubtext: { fontSize: 12, color: C.faint, textAlign: "center",
                  lineHeight: 18 },

  // ── Page indicator ─────────────────────────────────────────────────────────────
  // Horizontal row for page label and dots, with a bottom border.
  indicatorRow: {
    flexDirection:  "row",
    alignItems:     "center",
    justifyContent: "space-between",
    marginBottom:   14,
    paddingBottom:  12,
    borderBottomWidth: 1,
    borderBottomColor: C.border,
  },
  // Uppercase, blue, bold label (“PAGE 2 OF 5”).
  pageIndicator: {
    fontSize:      12,
    color:         C.accent,
    fontWeight:    "700",
    textTransform: "uppercase",
    letterSpacing: 0.8,
  },

  // ── Page dots ──────────────────────────────────────────────────────────────────
  // Row of touchable dots, with gap.
  dots: {
    flexDirection: "row",
    gap:           7,
    alignItems:    "center",
  },
  // Small circles; active one is larger and gold.
  dot: {
    width:           8,
    height:          8,
    borderRadius:    4,
    backgroundColor: C.border,
  },
  dotActive: {
    backgroundColor: C.gold,
    width:           10,
    height:          10,
    borderRadius:    5,
  },

  // ── Scroll area ────────────────────────────────────────────────────────────────
  // Flex‑1 container for the page content.
  scroll: {
    flex: 1,
  },
  // Bottom padding so the last line isn’t flush with the navigation bar.
  scrollContent: {
    paddingBottom: 16,
  },

  // ── Page image ─────────────────────────────────────────────────────────────────
  // Card for the optional page image, with border and corner clipping.
  imageContainer: {
    marginTop:    16,
    borderRadius: 10,
    overflow:     "hidden",   // clips the image to the border radius
    borderWidth:  1,
    borderColor:  C.border,
  },
  // Full‑width, fixed height (200), contain‑resized image.
  pageImage: {
    width:      "100%",
    height:     200,
  },

  // ── Navigation bar ─────────────────────────────────────────────────────────────
  // Navigation footer with top border, space‑between layout.
  navRow: {
    flexDirection:  "row",
    alignItems:     "center",
    justifyContent: "space-between",
    paddingTop:     14,
    marginTop:      8,
    borderTopWidth: 1,
    borderTopColor: C.border,
    gap:            8,
  },
  // Blue button; disabled version uses border colour.
  navBtn: {
    backgroundColor:  C.accent,
    borderRadius:     8,
    paddingVertical:  10,
    paddingHorizontal: 18,
    minWidth:         90,
    alignItems:       "center",
  },
  navBtnDisabled: {
    backgroundColor: C.border,
  },
  // White text; disabled version is faint.
  navBtnText: {
    color:      "#ffffff",
    fontSize:   14,
    fontWeight: "600",
  },
  navBtnTextDisabled: {
    color: C.faint,
  },
  // Centered fraction text, flexible width.
  navFraction: {
    flex:      1,
    textAlign: "center",
    color:     C.faint,
    fontSize:  13,
  },
});

Step 3 — Create src/components/PrayerCoverImage.tsx

PrayerCoverImage is a reusable presentational component that displays a prayer’s cover image stored as a Base64-encoded string in the SQLite database or nothing if absent. It is designed to be dropped into the prayer detail panel without needing the parent to worry about converting the data to a URI or handling missing images.

Why a separate component?

// src/components/PrayerCoverImage.tsx
//
// Renders a prayer's cover image from a base64 string.
// Shows nothing (returns null) if no image is stored.

import React from "react";
import { Image, View, StyleSheet } from "react-native";

interface PrayerCoverImageProps {
  /** The raw Base64-encoded image data, exactly as stored in the database.
   * If null or falsy, the component renders nothing.
   * Why base64 can be null? Many prayers won’t have a cover image. */
  base64: string | null;
  /** The height of the image container in logical pixels. (default 140).
   * The width is always "100%" of the parent. */
  height?: number;
}

/**
 * Convert raw base64 to a data URI for <Image>.
 * Adds "data:image/jpeg;base64," prefix if missing.
 */
function toDataUri(base64: string): string {
  /* If the string already starts with data:, it is already a valid data URI. We pass it through unchanged.
  Otherwise, we prepend data:image/jpeg;base64, treating the content as JPEG. */
  return base64.startsWith("data:")
    ? base64
    : `data:image/jpeg;base64,${base64}`;
}

/* Component Logic */
export function PrayerCoverImage({ base64, height = 140 }: PrayerCoverImageProps) {
  /* If base64 is null, undefined, or an empty string, the component renders nothing (null).
  This is the common case, and it avoids wasting layout space. */
  if (!base64) return null;

  return (
    /* The Container View is not just decorative: It has a fixed height (default 140,
    overridable via the height prop). This height is defined on the container so that
    the layout never collapses even if the image hasn’t loaded yet.
    borderRadius: 10 and overflow: "hidden" ensure the inner <Image> is clipped to rounded corners.
    borderWidth: 1 with borderColor: "#2d3550" (the app’s subtle border colour) gives the image a delicate frame,
    marginBottom: 14 gives spacing between the image and whatever follows. */
    <View style={[s.container, { height }]}>
      <Image
        /* It converts the raw string to a proper data URI and passes it to the native image loader. */
        source={{ uri: toDataUri(base64) }}
        /* style={s.image} – width: "100%" and height: "100%" make the image fill the container completely. */
        style={s.image}
        /* It scales the image to cover the entire container while maintaining aspect ratio.  */
        resizeMode="cover"
        /* It provides a descriptive label for screen readers. */
        accessibilityLabel="Prayer cover image"
      />
    </View>
  );
}

const s = StyleSheet.create({
  container: {
    width:        "100%",
    borderRadius: 10,
    overflow:     "hidden",
    marginBottom: 14,
    borderWidth:  1,
    borderColor:  "#2d3550",
  },
  image: {
    width:  "100%",
    height: "100%",
  },
});

Step 4. Update app/(tabs)/prayers.tsx

By swapping the old placeholder-laden PrayerDetail with this new version, we integrate the real cover image renderer and the multi‑page viewer into the two‑column layout.

The component remains purely presentational — it receives everything from the prayers hook.

Replace the PrayerDetail function and add the import. Only two changes:

1. Add to the imports at the top of prayers.tsx:

// We bring in PrayerPageViewer and PrayerCoverImage, the two components we just built.
import { PrayerPageViewer } from "../../src/components/PrayerPageViewer";
import { PrayerCoverImage } from "../../src/components/PrayerCoverImage";

2. Replace the entire PrayerDetail function with this version.

function PrayerDetail({ prayers }: { prayers: ReturnType<typeof usePrayers> }) {
  /* Unchanged: Empty State.
  This part is identical to previous posts. When no prayer is loaded, the empty state is shown. */
  if (!prayers.prayer) {
    return (
      <View style={s.main}>
        <EmptyState
          icon="✝"
          title="No prayer selected"
          subtitle={
            prayers.prayerCount > 0
              ? "Tap a prayer in the list to open it."
              : "Tap + to add your first prayer."
          }
        />
      </View>
    );
  }

  const { prayer } = prayers;

  return (
    // The main panel uses flex: 1, column direction (default).
    // The header has a fixed height, the page viewer takes the rest.
    <View style={s.main}>

      {/* ── Cover image ───────────────────────────────────────────────────────
      No conditional rendering — PrayerCoverImage itself returns null if base64 is falsy.
      The parent doesn’t need an {prayer.image ? ...} check.
      height={120} matches the original placeholder height.
      The image now actually displays the prayer’s cover, if present. */}
      <PrayerCoverImage base64={prayer.image} height={120} />

      {/* ── Prayer header ────────────────────────────────────────────────────── */}
      <View style={s.detailHeader}>

        {/* Title + date row.
        detailTitleRow. Layout: flexDirection: "row", justifyContent: "space-between".
        The title is on the left, the date + badge on the right. */}
        <View style={s.detailTitleRow}>
          <Text style={s.detailTitle} numberOfLines={2}>
            {prayer.title}
          </Text>
          <View style={s.detailDateCol}>
            <Text style={s.detailDate}>{prayer.date || "—"}</Text>
            {isToday(prayer.date) && (
              <View style={s.todayBadge}>
                <Text style={s.todayBadgeText}>Today</Text>
              </View>
            )}
          </View>
        </View>

        {/* Description. numberOfLines={3} clips long descriptions. */}
        {prayer.description ? (
          <Text style={s.detailDescription} numberOfLines={3}>
            {prayer.description}
          </Text>
        ) : null}

      </View>

      {/* ── Horizontal divider ──────────────────────────────────────────────────
      A thin horizontal line that separates the header from the page content. */}
      <View style={s.detailDivider} />

      {/* ── Page viewer ──────────────────────────────────────────────────────── */}
      {/*
        flex: 1 makes PrayerPageViewer take ALL remaining space in the main panel.
        This means the scroll area inside it fills the screen correctly.
        All page‑navigation logic remains in the usePrayers hook. PrayerDetail merely pipes the data and callbacks through.
      */}
      <View style={s.pageViewerContainer}>
        <PrayerPageViewer
          page={prayers.pageContent} // The current page object with id, content (HTML), image, etc.
          currentPage={prayers.currentPage} // Zero‑based index of the active page.
          totalPages={prayers.totalPages} // Total number of pages in this prayer.
          isFirstPage={prayers.isFirstPage} // true if on page 0.
          isLastPage={prayers.isLastPage} // true if on the last page.
          onPrev={prayers.goPrev} // Callback to navigate to previous page.
          onNext={prayers.goNext} // Callback to navigate to next page.
          onGoToPage={prayers.goToPage} // Callback to jump to a specific page by index.
        />
      </View>

    </View>
  );
}

The following style additions should be placed after the existing detailDescription style in the StyleSheet.create block

// Add to the styles object in prayers.tsx:

  detailTitleRow: {
    flexDirection:  "row",
    alignItems:     "flex-start",
    justifyContent: "space-between",
    marginBottom:   6,
    gap:            10,
  },
  detailTitle: {
    flex:        1,
    fontSize:    20,
    fontWeight:  "bold",
    color:       "#c9a84c",
    lineHeight:  26,
  },
  detailDateCol: {
    alignItems: "flex-end",
    gap:        4,
    flexShrink: 0,
  },
  detailDate: {
    fontSize:   11,
    color:      "#6b7280",
    fontFamily: "monospace",
  },
  todayBadge: {
    backgroundColor:  "#2a2010",
    borderRadius:     4,
    paddingHorizontal: 6,
    paddingVertical:   2,
    borderWidth:      1,
    borderColor:      "#c9a84c",
  },
  todayBadgeText: {
    fontSize:   10,
    color:      "#c9a84c",
    fontWeight: "600",
  },
  detailDescription: {
    fontSize:    13,
    color:       "#9ca3af",
    lineHeight:  20,
    marginBottom: 12,
  },
  detailDivider: {
    height:          1,
    backgroundColor: "#2d3550",
    marginBottom:    14,
  },
  pageViewerContainer: {
    flex: 1,   // takes all remaining vertical space — critical for scrolling
  },

The root container is a row (flexDirection: “row”):

┌────────────────┬──────────────────────────────────────────────┐
│                │                                              │
│   SIDEBAR      │              MAIN PANEL                      │
│   (200px)      │              (flex: 1)                       │
│                │                                              │
│  ┌──────────┐  │  ┌──────────────────────────────────────────┐│
│  │ Prayer   │  │  │  Cover Image (120px height, or hidden)   ││
│  │ List     │  │  ├──────────────────────────────────────────┤│
│  │ (FlatList│  │  │  Title + Date (auto height)              ││
│  │ )        │  │  ├──────────────────────────────────────────┤│
│  │          │  │  │  Description (auto height)               ││
│  │          │  │  ├──────────────────────────────────────────┤│
│  │          │  │  │────────── divider (1px) ─────────────────││
│  │          │  │  ├──────────────────────────────────────────┤│
│  │          │  │  │                                          ││
│  │          │  │  │   PAGE VIEWER                            ││
│  │          │  │  │   (flex: 1 – fills all remaining space)  ││
│  │          │  │  │   ScrollView with HTML content,          ││
│  │          │  │  │   page dots, nav buttons                 ││
│  │          │  │  │                                          ││
│  └──────────┘  │  └──────────────────────────────────────────┘│
└────────────────┴──────────────────────────────────────────────┘

The right column is a column (default flexDirection: “column”) with the following children, from top to bottom:

┌──────────────────────────────────────────┐
│  COVER IMAGE (height: 120 or absent)     │   ← fixed height
├──────────────────────────────────────────┤
│  HEADER                                  │
│  ┌────────────────────────┬────────────┐ │
│  │ Title (flex:1)         │ Date +     │ │   ← auto height
│  │                        │ Today Badge│ │
│  └────────────────────────┴────────────┘ │
│  Description (optional, auto height)     │
├──────────────────────────────────────────┤
│  DIVIDER (1px, margin bottom)            │   ← fixed height
├──────────────────────────────────────────┤
│                                          │
│   PAGE VIEWER                            │
│   (flex: 1)                              │   ← takes ALL remaining
│   ┌────────────────────────────────────┐ │       vertical space
│   │ Page indicator + dots              │ │
│   │ ────────────────────────────────── │ │
│   │                                    │ │
│   │ ScrollView with HTML content       │ │
│   │ (scrolls independently)            │ │
│   │                                    │ │
│   ├────────────────────────────────────┤ │
│   │ ◀ Prev      1 / 5       Next ▶   │ │
│   └────────────────────────────────────┘ │
└──────────────────────────────────────────┘

The crucial detail is the pageViewerContainer with flex: 1. Because the main panel is a column with a finite height (determined by the root’s flex: 1 and the screen height), flex: 1 causes that container to expand to consume any space not used by the cover image, header, and divider.

Step 5 — Run and test

Run npx expo start --clear, navigate to the ✝ Prayers tab, and check.

Action Expected
Open Prayers tab Ignatian Morning Prayer, Page 1 of 5
Page 1 content “Presence” in gold heading, paragraph below
◀ Prev button Greyed out, disabled
Tap Next ▶ Page 2 of 5, “Freedom” heading appears
Tap Next ▶ × 3 Page 5 of 5, “Review” heading
Next ▶ Greyed out, disabled
Tap dot 1 Jumps to Page 1
Tap “Evening Examen” Switches prayer, resets to Page 1 of 4
Bitcoin donation

JustToThePoint Copyright © 2011 - 2026 Anawim. ALL RIGHTS RESERVED. Bilingual e-books, articles, and videos to help your child and your entire family succeed, develop a healthy lifestyle, and have a lot of fun. Social Issues, Join us.

This website uses cookies to improve your navigation experience.
By continuing, you are consenting to our use of cookies, in accordance with our Cookies Policy and Website Terms and Conditions of use.