JustToThePoint English Website Version
JustToThePoint en español

Building a Settings Screen with Secure Storage

Assumption is the mother of all screw-ups.

If you choose not to decide, you still have made a choice, Neil Peart

alt

Documentation: Building a Settings Screen with Secure Storage

This part of the tutorial covers several important concepts for building a complete mobile app experience. Let me break down each learning objective:

  1. Stack Screen Navigation Over Tab Bar.
    You’ll learn how to create a screen that slides in from the right, covering the tab bar completely. This is different from tab navigation where you stay within the same screen - here you’re adding a new screen on top of everything. Think of it like opening a settings page that takes over the whole screen temporarily.

  2. Secure Storage for Sensitive Data.
    You’ll be introduced to expo-secure-store, which is a special type of storage that encrypts data. This is crucial for storing things like API keys that should never be visible or accessible to other apps or potential hackers. Regular storage (like AsyncStorage) saves data as plain text that could potentially be read.

  3. Understanding Different Storage Options.
    This is a key concept - knowing when to use different types of storage:
    AsyncStorage: For non-sensitive preferences and settings.
    SecureStore: For sensitive data like passwords, API keys, tokens.
    SQLite: For structured data that you need to query and search.
    File System: For large files like documents or images.

  4. Building Persistent Forms.
    You’ll learn how to create a form where users can type information that gets saved automatically, so when they come back to the screen later, their input is still there.

  5. Centralized Header Configuration.
    Instead of adding a settings icon to each tab screen individually, you’ll learn how to add it once in a layout file and have it appear on all tabs automatically.

What You’ll Actually Build

  1. Storage Utility Functions.
    You’ll create a file called storageUtils.ts that provides clean, type-safe functions for saving and retrieving data. This acts as a wrapper around the storage systems, making them easier to use throughout your app. Instead of writing complex storage code every time, you’ll have simple functions with clear names that handle all the details.

  2. Settings Screen.
    You’ll build a complete settings screen with four distinct sections. Each section will handle different types of settings and demonstrate different storage patterns.

  3. Navigation Integration.
    You’ll add a gear icon (⚙) to every tab’s header. When users tap this icon, they’ll be taken to the settings screen. This icon will be added in one place but appear everywhere.

Testing Your Work

After building this, you should be able to:

  1. Tap the gear icon on any tab and see the settings screen slide in
  2. Enter a fake API key, save it, and leave the settings
  3. Return to settings and confirm the key is still there (proving SecureStore worked)
  4. Reset to defaults and see the key disappear

Understanding Storage Options in Detail

This comparison table is crucial for understanding when to use each storage type:

  1. AsyncStorage. Simple key-value storage.
    Encryption: None - data is stored as plain text. Size limit: About 6 megabytes.
    Best for: User preferences, feature flags, non-sensitive settings.
    Example use: User preferences.

  2. SecureStore. Encrypted storage using the device’s built-in security.
    Encryption: Yes - uses iOS Keychain or Android Keystore. Size limit: About 2 kilobytes per item.
    Best for: API keys, passwords, authentication tokens.
    Example use: The YouVersion API key we’ll be storing.

  3. SQLite. A full database system.
    Encryption: None. Size limit: Gigabytes.
    Best for: Structured data you need to search and query.
    Example use: Storing prayers or journal entries that you need to search through.

  4. File System. Direct access to device storage.
    Encryption: None. Size limit: Whatever the device has available.
    Best for: Large files.
    Example use: Diary entries, large text files, images

The Golden Rule for Storage: If you would not write it on a sticky note on your monitor (i.e., sensitive enough), use SecureStore.

An API key is like a password - you wouldn’t leave it visible, so it goes in SecureStore. A preference like “which Bible translation to use” is harmless, so it can go in AsyncStorage.

Installing the Required Package

The first step is installing expo-secure-store using:

npx expo install expo-secure-store

The npx expo install command is special because it automatically installs the version that’s compatible with your current Expo SDK version. This is different from npm install which might install a version that doesn’t work with your setup. It’s like having a smart installer that knows exactly what version you need.

Create src/utils/storageUtils.ts

This file is a utility module that handles all data storage in our app. Think of it as a carefully organized filing cabinet system where:

The file creates a single, organized place for all storage operations, so the rest of our app doesn’t need to know the technical details of how storage works.

// src/utils/storageUtils.ts
//
// TYPED STORAGE WRAPPERS
// ───────────────────────
// Thin wrappers around AsyncStorage and SecureStore that:
//   AsyncStorage (@react-native-async-storage/async-storage). A simple key-value storage system, like a dictionary.
//   NOT encrypted, but each app has its own private storage area.
//   SecureStore (expo-secure-store). A secure storage system that encrypts data.
//   1. Provide a consistent API (get / set / remove) for both.
// Without this file, every part of your app would directly call storage functions.
//   2. Add type safety — callers never touch raw strings
//   3. Centralise all storage key names so typos are caught at compile time (like writing "seeded" vs "seed" vs "isSeeded")
//
// USAGE:
//   import { Prefs, Secrets } from "../utils/storageUtils";
//
//   // Read
//   const key = await Secrets.getYouVersionKey();
//
//   // Write
//   await Secrets.setYouVersionKey("abc123");
//
//   // Delete
//   await Secrets.removeYouVersionKey();

// The file starts by importing two storage libraries:
// AsyncStorage from React Native's storage package.
// SecureStore from Expo's secure storage package.
// These are pre-built solutions that handle the actual storage mechanics.
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as SecureStore from "expo-secure-store";


// ══════════════════════════════════════════════════════════════════════════════
// Centralized Key Names
// ══════════════════════════════════════════════════════════════════════════════
// The code defines two objects that act as a "directory" of all storage locations:
/** AsyncStorage keys for non-sensitive preferences */
const PREF_KEYS = {
  /** "true" once the seed has been inserted */
  seeded:          "prayers.seeded",
  /** User's custom daily plan path override (optional) */
  customPlanPath:  "settings.customPlanPath",
  /** User's display name (future feature) */
  displayName:     "settings.displayName",
} as const;

/** SecureStore keys for sensitive secrets */
const SECRET_KEYS = {
  /** YouVersion / Bible.com API key */
  youVersionKey:  "youversion.api_key",
} as const;


// ══════════════════════════════════════════════════════════════════════════════
// ASYNC STORAGE — non-sensitive preferences (non-sensitive Storage)
// Display names, User preferences, Non-sensitive settings, Can be easily read/written
// By separating them: (Secrets)
// 1. Better security - Sensitive data gets extra protection
// 2. Clearer code - You know which data needs careful handling
// 3. Easier maintenance - You can reset one without affecting the other
// AsyncStorage is like writing things in a notebook on your desk
// SecureStore is like putting things in a locked safe
// ══════════════════════════════════════════════════════════════════════════════

/**
 * Wrappers for non-sensitive user preferences.
 * Stored in AsyncStorage (not encrypted, but sandboxed to this app).
 */
// Prefs is a collection of functions for managing non-sensitive user preferences.
// A simple namespace-like object. No classes, no state — just pure functions.
export const Prefs = {

  // ── 🌱 Seed flag ─────────────────────────────────────────────────────────────
  // Tracks whether the app has already run its initial data seeding.
  // The seed flag is non-sensitive, so it belongs in AsyncStorage, not secure storage.
  async getSeedFlag(): Promise<boolean> {
    // It's a simple yes/no flag. The presence of this key means "yes, the app is seeded."
    // If the key exists → seeded → return true. Presence = yes.
    // If the key does not exist → not seeded → return false. Absence = no.
    const v = await AsyncStorage.getItem(PREF_KEYS.seeded);
    return v !== null;
  },
  async setSeedFlag(): Promise<void> {
    // You don’t store "false" ever. You only store "true".
    // This is the moment the app says, “Okay, seeding is done. Never run it again.”
    await AsyncStorage.setItem(PREF_KEYS.seeded, "true");
  },
  async removeSeedFlag(): Promise<void> {
    // It deletes the key. This is the only way to make the app think it has never seeded.
    // Removing the key is equivalent to: "Pretend the app was just installed."
    await AsyncStorage.removeItem(PREF_KEYS.seeded);
  },

  // ── Custom plan path ───────────────────────────────────────────────────────
  /**
   * Optional path to a user-provided daily_plan.txt file stored on the device.
   *
   * This preference is a simple string: if the user selects a custom plan file,
   * we store its absolute path. If the user clears the selection, we remove the
   * key entirely. Presence = “a custom plan is active”; absence = “use the
   * built‑in default plan”.
   *
   * We never store an empty string. An empty or whitespace-only path means
   * “no custom plan”, so the key is removed instead.
   */
  async getCustomPlanPath(): Promise<string> {
    // If the key is missing, return an empty string as the neutral fallback.
    // AsyncStorage.getItem returns Promise<string | null>.
    // Using the nullish coalescing operator (??) ensures that if the key is missing (returning null),
    // this method safely returns an empty string "" instead of throwing an error or passing null down the line.
    return (await AsyncStorage.getItem(PREF_KEYS.customPlanPath)) ?? "";
  },
  async setCustomPlanPath(path: string): Promise<void> {
    // Normalize input. A non-empty trimmed path is stored; an empty one [⭐] removes
    // the preference entirely. This keeps the storage clean and avoids storing
    // meaningless empty strings.
    if (path.trim()) {
      await AsyncStorage.setItem(PREF_KEYS.customPlanPath, path.trim());
    } else { // ⭐
      await AsyncStorage.removeItem(PREF_KEYS.customPlanPath);
    }
  },

  // A Promise is like a receipt you get when you order food.
  // It's not the food itself, but a promise that the food will come.
  // In programming, it represents a value that will be available in the future.
  // ── Display name ───────────────────────────────────────────────────────────
  // AsyncStorage is a simple storage system for React Native apps.
  // It's like a dictionary or a key-value pair system:
  // Key: A unique name to identify the data (like "displayName")
  // Value: The actual data stored (like "John")
  async getDisplayName(): Promise<string> {
    // The ?? is called the "nullish coalescing operator" (a fancy name for something simple). It means:
    // If the left side has a value, use it (try to get the display name)
    // If the left side is null or undefined (if there's no display name stored),
    // use the right side (return an empty string "" instead)
    return (await AsyncStorage.getItem(PREF_KEYS.displayName)) ?? "";
  },
  async setDisplayName(name: string): Promise<void> {
    // Checks if the name has actual content
    // name.trim() removes spaces from the beginning and end.
    // If there's still text after trimming, it's valid.
    if (name.trim()) {
      // If valid: Saves the trimmed name to storage.
      await AsyncStorage.setItem(PREF_KEYS.displayName, name.trim());
    } else {
      // If empty: Removes the name from storage (rather than saving an empty string).
      await AsyncStorage.removeItem(PREF_KEYS.displayName);
    }
  },

  // ── Reset all preferences ──────────────────────────────────────────────────
  /** Clear every non-sensitive preference (does NOT clear secrets). */
  async resetAll(): Promise<void> {
    // Object.values() takes all the values from an object (all the keys defined in PREF_KEYS) and puts them in a list
    // Removes all of them at once using multiRemove
    await AsyncStorage.multiRemove(Object.values(PREF_KEYS));
  },
};


// ══════════════════════════════════════════════════════════════════════════════
// SECURE STORE — sensitive secrets
// API keys, Authentication tokens, Passwords, Any sensitive data
// ══════════════════════════════════════════════════════════════════════════════

/**
 * SecureStore is different from AsyncStorage because it stores data in:
 * iOS: The Keychain (Apple's secure storage)
 * Android: The Keystore (Android's secure storage)
 * These are encrypted and much harder for malicious apps or people to access.
 * It's like putting things in a bank vault instead of a regular drawer.
 *
 * SecureStore Limitations:
 * 1. Size limit: Values must be less than ~2 KB (fine for API keys)
 * 2. Key naming: Keys can only contain letters, numbers, periods, hyphens, and underscores
 * 3. Web fallback: On web browsers, it uses localStorage which is NOT secure
 *
 * This is important because:
 * API keys are typically small enough (usually under 2 KB)
 * You can't use special characters in key names
 * The app isn't meant to run in a web browser for security reasons
 */
export const Secrets = {

  // ── YouVersion API key ─────────────────────────────────────────────────────
  async getYouVersionKey(): Promise<string> {
    // The try/catch is error handling
    // This is important because SecureStore might fail if:
    // (i) The user's device doesn't have proper security set up.
    // (ii) The data was corrupted.
    // (iii) The app doesn't have permission to access the secure storage.
    try {
      // Attempt to get the API key
      return (await SecureStore.getItemAsync(SECRET_KEYS.youVersionKey)) ?? "";
    } catch {
      // If anything goes wrong, return an empty string instead of crashing
      return "";
    }
  },
  async setYouVersionKey(key: string): Promise<void> {
    // 1. Trims the API key
    const trimmed = key.trim();
    if (trimmed) {
      // 2. If there's content, saves it securely
      await SecureStore.setItemAsync(SECRET_KEYS.youVersionKey, trimmed);
    } else {
      // 3. If empty, deletes it from secure storage
      await SecureStore.deleteItemAsync(SECRET_KEYS.youVersionKey);
    }
  },
  // This is a dedicated function just for removing the YouVersion key.
  async removeYouVersionKey(): Promise<void> {
    // The catch block with the comment "already absent" means it's okay if the key doesn't exist.
    try {
      await SecureStore.deleteItemAsync(SECRET_KEYS.youVersionKey);
    } catch { /* already absent */ }
  },

  // ── Reset all secrets ──────────────────────────────────────────────────────
  async resetAll(): Promise<void> {
    // 1. Loops through all secret keys
    for (const key of Object.values(SECRET_KEYS)) {
      try {
        // 2. Tries to delete each one
        await SecureStore.deleteItemAsync(key);
      } catch { /* 3. Continues even if some deletions fail (the catch blocks) */ }
    }
  },
};

Create app/settings.tsx

This is a comprehensive settings page that manages user preferences, displays statistics, and handles data reset functionality.

It’s where users can:

  1. Configure their YouVersion API key.
  2. Set a custom Bible plan path.
  3. Set their display name.
  4. View statistics about their prayer and diary usage.
  5. Reset the app to default settings.

Think of it like the settings menu in any app you use - it’s the control center for customization.

// app/settings.tsx
//
// SETTINGS SCREEN
// ────────────────
// Pushed onto the Stack (slides in from right) via router.push("/settings").
// Accessible from every tab's header gear icon.
//
// SECTIONS:
//   1. Account   — YouVersion API key (SecureStore)
//   2. Content   — Custom plan path (AsyncStorage)
//   3. Statistics — Prayer count, diary entry count
//   4. Data      — Reset to defaults


//useState - Manages local state (data that changes)
// useEffect - Runs code when the component loads
// useCallback - Remembers a function to avoid unnecessary re-runs
import React, { useEffect, useState, useCallback } from "react";
// TextInput - Where users type text (like API keys)
// TouchableOpacity - Tappable elements
// ScrollView - Allows scrolling when content is long
// ActivityIndicator - Loading spinner
// Alert - Popup messages
// Switch - Toggle switches
// Platform - Detects iOS vs Android
import {
  View, Text, TextInput, TouchableOpacity, ScrollView,
  StyleSheet, ActivityIndicator, Alert, Switch, Platform,
} from "react-native";
// useSQLiteContext - Gives access to your database
import { useSQLiteContext } from "expo-sqlite";
import { Prefs, Secrets } from "../src/utils/storageUtils";
// countPrayers - Function to count prayers in the database
import { countPrayers }   from "../src/db/prayerQueries";
// listEntries - Gets diary entries from the database
import { listEntries }    from "../src/services/diaryService";
// clearPlanCache - Clears cached Bible plan data
import { clearPlanCache } from "../src/services/bibleService";
import { T, S, FontSize, Spacing, Radius } from "../src/theme";

// Before the main screen, there are three helper components
// ── Section component ─────────────────────────────────────────────────────────
// This creates a section container with a title and a card-like container for the section's content
// The children prop is special - it's whatever you put between the Section tags. So if you write:
// <Section title="Account">
//   {/* This content becomes children */}
// </Section>
function Section({ title, children }: { title: string; children: React.ReactNode }) {
  return (
    <View style={s.section}>
      <Text style={s.sectionTitle}>{title}</Text>
      <View style={s.sectionCard}>{children}</View>
    </View>
  );
}

// ── SettingRow components ────────────────────────────────────────────────────────────
// This creates a row within a section with:
// * A label on the left (like "API Key")
// * An optional hint text below the label
// The hint? means the hint is optional - you don't have to provide it.
// * Controls on the right (text inputs, switches, etc.)
function SettingRow({ label, hint, children }: {
  label: string; hint?: string; children: React.ReactNode;
}) {
  return (
    <View style={s.row}>
      <View style={s.rowLabel}>
        <Text style={s.rowLabelText}>{label}</Text>
        {hint ? <Text style={s.rowHint}>{hint}</Text> : null}
      </View>
      <View style={s.rowControl}>{children}</View>
    </View>
  );
}

// StatRow Component
// This creates a simple row for statistics:
// * Label on the left (like "Prayers")
// * Value on the right (like "42")
function StatRow({ label, value }: { label: string; value: string | number }) {
  return (
    <View style={s.statRow}>
      <Text style={s.statLabel}>{label}</Text>
      <Text style={s.statValue}>{String(value)}</Text>
    </View>
  );
}

// ══════════════════════════════════════════════════════════════════════════════
// MAIN SCREEN
// ══════════════════════════════════════════════════════════════════════════════

export default function SettingsScreen() {
  const db = useSQLiteContext();
  // Understanding State Management
  // State is like a temporary storage area within a component.
  // When state changes, the component re-renders (updates what's shown on screen).
  // ── Form state ──────────────────────────────────────────────────────────────
  // What users type into inputs.
  // Starts as empty strings and gets filled with saved values when the screen loads.
  const [youVersionKey,  setYouVersionKey]  = useState("");
  const [customPlanPath, setCustomPlanPath] = useState("");
  const [displayName,    setDisplayName]    = useState("");

  // ── Stats state ─────────────────────────────────────────────────────────────
  // Data retrieved from the database.
  // The null initial value means "not loaded yet" - it will show a spinner until the actual data arrives.
  const [prayerCount, setPrayerCount] = useState<number | null>(null);
  const [diaryCount,  setDiaryCount]  = useState<number | null>(null);
  const [seedDone,    setSeedDone]    = useState<boolean | null>(null);

  // ── Saving indicator ────────────────────────────────────────────────────────
  // Tracks whether the app is currently saving (shows spinner) or just saved (shows confirmation).
  const [saving, setSaving] = useState(false);
  const [saved,  setSaved]  = useState(false);

  // ── Load all settings and stats on mount ────────────────────────────────────
  // This function loads ALL data at once.
  // useCallback remembers the function so it doesn't get recreated every render.
  const loadAll = useCallback(async () => {
    // Promise.all runs multiple async operations simultaneously instead of one after another.
    // It's like ordering at a restaurant -
    // all dishes are prepared at the same time rather than waiting for one to finish before starting the next.
    // This is called destructuring.
    // It's like opening a package that contains multiple items and assigning each item to a named variable.
    // The order matters: key gets the result of Secrets.getYouVersionKey(), path gets the result of Prefs.getCustomPlanPath(), etc.
    const [key, path, name, seeded, prayers, diary] = await Promise.all([
      Secrets.getYouVersionKey(),
      Prefs.getCustomPlanPath(),
      Prefs.getDisplayName(),
      Prefs.getSeedFlag(),
      countPrayers(db),
      // This is different because listEntries() returns a list (array) of entries,
      // but we only want the count. The .then(e => e.length) takes the list
      // and returns its length (how many items are in it).
      listEntries().then(e => e.length),
    ]);
    setYouVersionKey(key);
    setCustomPlanPath(path);
    setDisplayName(name);
    setSeedDone(seeded);
    setPrayerCount(prayers);
    setDiaryCount(diary);
    // The [db] dependency means the function is recreated only when db changes.
  }, [db]);

  // The useEffect Hook
  // This runs loadAll() when the component first appears on screen.
  // The [loadAll] dependency means it runs when the component mounts and whenever loadAll changes.
  useEffect(() => { loadAll(); }, [loadAll]);

  // ── Save all settings ────────────────────────────────────────────────────────
  // This saves all user inputs
  async function saveAll() {
    // 1. Show Saving Indicator. Turns on the loading spinner.
    setSaving(true);
    try {
      // 2. Save All Three Values
      // Saves the API key (securely), custom plan path, and display name all at once.
      await Promise.all([
        Secrets.setYouVersionKey(youVersionKey),
        Prefs.setCustomPlanPath(customPlanPath),
        Prefs.setDisplayName(displayName),
      ]);
      // 3. Clear Cache
      // If the custom plan path changed, clear the plan cache so the next
      // verse lookup re-reads the new file.
      clearPlanCache();
      // 4. Show Success
      // Shows "Saved!" message for 2 seconds (2000 milliseconds), then hides it.
      setSaved(true);
      setTimeout(() => setSaved(false), 2000);
    } catch (e) {
      // 5. Error Handling
      // If anything goes wrong, shows an alert with the error message.
      Alert.alert("Save error", String(e));
    } finally {
      // 6. Always Stop Saving
      // The finally block runs whether there was an error or not. This ensures the loading spinner always turns off.
      setSaving(false);
    }
  }

  // ── Reset to defaults ────────────────────────────────────────────────────────
  function confirmReset() {
    // This function displays a confirmation dialog to the user before resetting their settings.
    // It's a safety feature to prevent accidental data loss.
    Alert.alert(
      "Reset to defaults?", // Title. The bold heading of the popup
      // Message. The body text explaining what's happening.
      "This clears your YouVersion API key, custom plan path, and display name.\n\n" +
      "Your prayers and diary entries are NOT deleted.",
      // Buttons Array (third argument) - A list of buttons to show.
      [
        // Cancel Button: text: "Cancel" - The label;
        // style: "cancel" - Special styling that makes it bold on iOS (indicates it's the "safe" default choice)
        // No onPress handler - so it just closes the dialog
        { text: "Cancel", style: "cancel" },
        {
          // Reset Button: text: "Reset" - The label;
          text:    "Reset",
          // style: "destructive" - Red text, indicates danger/destruction;
          style:   "destructive",
          // onPress - The function that runs when tapped.
          onPress: async () => {
            // 1. Clear All Storage. Secrets.resetAll() - Clears the secure storage (API key)
            // Prefs.resetAll() - Clears the regular storage (display name, plan path)
            // Promise.all. It's like ordering multiple dishes at a restaurant - they all get prepared simultaneously.
            await Promise.all([Secrets.resetAll(), Prefs.resetAll()]);
            // 2. Clear Cache. This clears the Bible plan cache so the app doesn't use old data
            // - this erases that memory so it starts fresh.
            clearPlanCache();
            // 3. Reset Local State.
            // Empty strings for the text inputs (so the fields appear empty).
            // false for the seed flag (so the app knows it needs to re-seed prayers).
            setYouVersionKey("");
            setCustomPlanPath("");
            setDisplayName("");
            setSeedDone(false);
            // 4. Show Success Message
            // A second alert confirms the action was successful.
            Alert.alert("Reset complete",
              "Settings cleared. The app will re-seed prayers on next launch.");
          },
        },
      ]
    );
  }

The ScrollView Container

A ScrollView is a container that allows scrolling. If the content is taller than the screen, users can scroll up and down to see everything.

Remember, the Section component:

  1. It shows the title at the top.
  2. It wraps the content below in a styled “card”.
  3. It creates visual separation between different groups of settings

The SettingRow component creates a two-column layout: A. Left side: Label and hint text. B. Right side: The actual control (in this case, a TextInput).

  return (
    // The ScrollView Container
    // style={s.scroll} - Styles the ScrollView container itself (the "window" through which you see content).
    // contentContainerStyle={s.content} - Styles the CONTENT inside the ScrollView
    // (the actual stuff you're scrolling through).
    // keyboardShouldPersistTaps="handled" is a special prop for handling keyboard interactions.
    // The value "handled" means: When the keyboard is open and you tap something, the tap works immediately.
    <ScrollView style={s.scroll} contentContainerStyle={s.content}
                keyboardShouldPersistTaps="handled">

      {/* ── Account section ─────────────────────────────────────────────────── */}
      <Section title="🔑  Account">
        // Display Name Row
        <SettingRow
          label="Display name" //  - The main label text
          hint="Shown in the diary header (optional)" // - Extra info below the label
        >
          <TextInput
            style={s.input} // - Applies text input styling (border, padding, etc.)
            value={displayName} // - This is a controlled input. The TextInput displays whatever displayName contains
            // The user cannot directly change what's shown in the input.
            // Instead, when the user types, onChangeText(newValue) fires. You call setDisplayName(newValue).
            // React re-renders to show the new value (TextInput value={displayName} receives the updated value, the input shows the new text).
            // When we reset, setting state to "" empties the input.
            onChangeText={setDisplayName} // - Every time the user types, this function runs and updates state.
            placeholder="Your name" // - Gray text shown when input is empty
            placeholderTextColor={T.dim} // - Makes the placeholder text a dimmed color from your theme
            autoCapitalize="words" // - Automatically capitalizes the first letter of each word
            // (like "John Smith" not "john smith"). Perfect for names!
            returnKeyType="next" // Changes the keyboard's action key from "Done"/"Return" to "Next".
            // This signals to the user that there's another field below to fill in.
          /> // This is a self-closing tag. When a component has no children (no content between opening and closing tags),
          // you can combine them into one tag that ends with this.
        </SettingRow >
        // The Row Divider. A simple horizontal line separating the two rows.
        // In a card with multiple rows, dividers help visually group the content.
        <View style={s.rowDivider} />

        // The YouVersion API Key Row
        <SettingRow
          label="YouVersion API key"
          // This is reassurance UX. Users are (rightly) nervous about entering API keys.
          // Telling them upfront that the key is stored securely builds trust.
          hint="Stored encrypted in the OS keychain"
        >
          <TextInput
            // Unlike the display name field, this uses two styles combined:
            // (i) s.input — Base input styling (borders, padding, etc.)
            // (ii) s.inputMono — Additional styling that makes the font monospace
            style={[s.input, s.inputMono]}
            value={youVersionKey}
            onChangeText={setYouVersionKey}
            placeholder="Paste key here"
            placeholderTextColor={T.dim}
            // These three settings are essential for API key input:
            autoCapitalize="none"
            // Disables automatic capitalization. Disables automatic capitalization.
            autoCorrect={false}
            // Disables the phone's autocorrect. Autocorrect might:
            // "Fix" random characters into dictionary words; Add spaces between letter sequences;
            // Suggest completions that break the key. Turning autocorrect OFF is essential when typing any technical string.
            secureTextEntry={false}
            // Not secureTextEntry — the user needs to see/copy the key.
            // It is stored securely in SecureStore regardless of how it is displayed.
            // Users need to see the key to verify it pasted correctly.
            returnKeyType="done"
            // Since this is the last field in the Account section, the keyboard shows "Done" instead of "Next".
            // This tells the user: "You've reached the end of this group."
          />
        </SettingRow>

        // The Info Box. This is a helper/informational box displayed below the input rows.
        // This is a progressive disclosure pattern. Instead of cluttering the form with instructions, we:
        // 1. Keep the input row clean and focused
        // 2. Add a helper box below for users who need guidance
        // The box answers three common questions:
        // (*) "Where do I get a key?" → bible.com/developers
        // (*) "What is it used for?" → The Verse tab's daily verse
        // (*) "What if I don't have one?" → Falls back to OurManna and offline KJV
        <View style={s.infoBox}>
          <Text style={s.infoText}>
            // The {"\n"} is a special way to add a newline character inside JSX text.
            Get your free API key at bible.com/developers{"\n"}
            Used by the Verse tab to fetch YouVersion's daily verse.{"\n"}
            Without a key, the app uses OurManna and the offline KJV.
          </Text>
        </View>
      // Closing the Section
      </Section>

The Reading Plan Section

This section lets users point the app at a custom Bible reading plan file. This follows the same pattern as the API key row, and for good reason.

Why These Settings Are Similar to the API Key?

A file path has a lot in common with an API key:

That’s why autoCapitalize=“none”, autoCorrect={false}, and s.inputMono all make another appearance.

      {/* ── Content section ─────────────────────────────────────────────────── */}
      <Section title="📖  Reading Plan">

        <SettingRow
          label="Custom plan file path"
          // The Hint Text is a fallback explanation. The user learns:
          // * Leaving the field empty is perfectly fine
          // * The app ships with its own plan file
          // * They only need to fill this in if they want something custom
          hint="Leave blank to use the bundled daily_plan.txt"
        >
          <TextInput
            style={[s.input, s.inputMono]}
            value={customPlanPath}
            onChangeText={setCustomPlanPath}
            // The Example Placeholder
            // The placeholder shows a realistic example path.
            // The path uses /storage/emulated/0/ — this is the Android internal storage location.
            // The filename my_plan.txt — a friendly, descriptive example.
            placeholder="e.g. /storage/emulated/0/my_plan.txt"
            placeholderTextColor={T.dim}
            autoCapitalize="none"
            autoCorrect={false}
            returnKeyType="done"
          />
        </SettingRow>

        // The Info Box With Code Formatting
        // This is a structured help box that teaches the user the file format.
        // Two spaces are added before each example line. This creates visual indentation
        // — a way of saying "these lines are examples of what goes in the file, not instructions."
        <View style={s.infoBox}>
          <Text style={s.infoText}>
            // The info box is structured for a novice reader:
            // 1. Big picture
            Create a plain text file with one Bible reference per line.{"\n"}
            // 2. Concrete examples
            Example lines:{"\n"}
            {"  "}John 3:16{"\n"}
            {"  "}Psalm 23:1{"\n"}
            {"  "}Romans 8:28{"\n"}
            // 3. Advanced tip
            // This teaches users that they can annotate their plan file.
            Lines starting with # are treated as comments.
          </Text>
        </View>

      </Section>

      {/* ── Statistics section ───────────────────────────────────────────────── */}
      // This section is read-only — it shows information but doesn't let users change anything directly.
      <Section title="📊  Database Statistics">
        // The Conditional Loading Logic. IF prayerCount is null, show a spinner. OTHERWISE, show the stats rows.
        // This is a common pattern called a loading state. It prevents showing "0" (we haven't loaded the count yet)
        // or blank values while data is still being fetched.
        {prayerCount === null ? (
          // The spinner: ActivityIndicator with:
          // size="small" — a compact spinner; color={T.gold} — matches our theme
          // style={{ margin: 12 }} — some breathing room around it
          <ActivityIndicator size="small" color={T.gold} style={{ margin: 12 }} />
        ) : (
          <> // These are called fragments. They let you group multiple elements without wrapping them in an extra View.
            // If we wrapped these StatRows in a <View>, we'd introduce an extra container that might break styling
            // (for example, the dividers might not span the full width). Fragments are "invisible wrappers."
            // Row 1: Prayers in Library — displays the count from prayerCount.
            <StatRow label="Prayers in library"  value={prayerCount} />
            // Between each StatRow is a rowDivider — the same thin line used in the Account section.
            <View style={s.rowDivider} />
            // Row 2: Diary Entries. If diaryCount has a value, use it.
            // If it's null or undefined, show "—" (a placeholder that says "unknown" without looking like a number).
            <StatRow label="Diary entries"        value={diaryCount ?? "—"} />
            <View style={s.rowDivider} />
            // Row 3: Database Seeded.
            // If seedDone is true, show "Yes". Otherwise, show "No (re-seeds on next launch)".
            <StatRow label="Database seeded"
                     value={seedDone ? "Yes" : "No (re-seeds on next launch)"} />
          
        )}

        // The Refresh Button
        // A tappable button that re-runs loadAll — the same function that loads settings and stats on mount.
        <TouchableOpacity style={s.refreshStatsBtn} onPress={loadAll}>
          <Text style={s.refreshStatsBtnText}>↺  Refresh statistics</Text>
        </TouchableOpacity>

      </Section>

      {/* ── The Data (Danger) Section ─────────────────────────────────────────────────────── */}
      // The ⚠️ emoji warns the user: "This section is different — be careful."
      <Section title="⚠️  Data">
        // The Explanatory Info Box
        // Users are often scared of "Reset" buttons. Without clear information, they might:
        // * Avoid the button entirely (missing a useful feature)
        // * Tap it expecting to lose everything (and be surprised either way)
        // This is one of the most important info boxes on the screen. It answers several critical questions:
        <View style={s.infoBox}>
          <Text style={s.infoText}>
            // 1. "What will be cleared?"
            "Reset to defaults" clears your API key and custom plan path.{"\n"}
            // 2. "Will I lose my prayers/diary?" — No, they're kept
            Your prayers and diary entries are kept — this only resets settings.{"\n\n"}
            // Notice the double newline {"\n\n"} — it creates a blank line,
            // separating the "what" (reset clears X) from the "how" (delete things elsewhere).
            // This visual separation improves readability.
            // 3. "How do I actually delete prayers?"
            To delete all prayers, use the Prayers tab (swipe to delete, or delete individually).{"\n"}
            // 4. "How do I delete diary entries?"
            To delete diary entries, use the Diary tab's Delete button.
          </Text>
        </View>

The Reset Button

        // The Reset Button
        // This button calls confirmReset — the function we explained earlier that
        // shows a confirmation dialog before doing anything.
        // The two-step safety: (i) User taps "Reset to defaults"
        // (ii) Confirmation dialog appears ("Are you sure?")
        // (iii) Only if they tap "Reset" in the dialog does the reset happen
        <TouchableOpacity style={s.resetBtn} onPress={confirmReset}>
          <Text style={s.resetBtnText}>Reset to defaults</Text>
        </TouchableOpacity>

      </Section>

      {/* ── Save button (sticky at bottom) ──────────────────────────────────── */}
      // This is the main action button that saves all the user's input.
      <TouchableOpacity
        // This combines styles conditionally. Let me break down the logic:
        // Always: Apply s.saveBtn (the base save button style)
        // Only when saving: Also apply a different background color (T.border)
        // saving && {...}: If saving is true, the expression returns the style object
        // If saving is false, the expression returns false. React Native ignores false in style arrays, so it's skipped.
        style={[s.saveBtn, saving && { backgroundColor: T.border }]}
        onPress={saveAll}
        // The Disabled State
        // This actually prevents tapping when saving is true.
        // Disabling the button during save is standard defensive UI practice.
        disabled={saving}
      >
        {saving ? (
          // Outer ternary: If saving → show a spinner
          <ActivityIndicator color={T.white} size="small" />
        ) : (
          // Otherwise → show text
          <Text style={s.saveBtnText}>
            // Inner ternary (when not saving):
            // If saved → show "✅ Settings saved"
            // Otherwise → show "Save settings"
            {saved ? "✅  Settings saved" : "Save settings"}
          </Text>
        )}
      </TouchableOpacity>

So the button has three visual states:

State What’s shown When
Saving Spinner While save is happening
Just saved ✅ Settings saved For 2 seconds after save
Idle Save settings The rest of the time

Recall from saveAll(): It sets saved to true, then after 2 seconds sets it back to false. That’s what makes the “✅ Settings saved” state appear temporarily.

      {/* Version note */}
      // This is the final element at the bottom of the screen.
      // Two styles:
      // S.hint — A shared style from your theme (small, dim, secondary text)
      // Inline object — Additional styling for this specific use case
      // Why use S.hint instead of just inline styles?
      // (*) Consistency — the hint style matches other hints across the app
      // (*) Maintainability — if you change hint styling, this updates too
      // Why use an inline style for the rest?
      // textAlign: "center" is a one-off — this is the only centered hint on the screen
      // marginTop: 24 is specific to this position
      // This mix of shared + inline styles is a good practice for novices:
      // reuse styles when they match a shared pattern, use inline styles for one-off tweaks.
      <Text style={[S.hint, { textAlign: "center", marginTop: 24 }]}>
        Prayer Companion v1.0.0
      </Text>

    </ScrollView>
  );
}

// ══════════════════════════════════════════════════════════════════════════════
// STYLES
// ══════════════════════════════════════════════════════════════════════════════

const s = StyleSheet.create({
  scroll:           { flex: 1, backgroundColor: T.bg },
  content:          { padding: Spacing.lg, paddingBottom: 48 },

  // Sections
  section:          { marginBottom: Spacing.xl },
  sectionTitle:     { fontSize: FontSize.sm, fontWeight: "700", color: T.faint,
                       textTransform: "uppercase", letterSpacing: 0.8,
                       marginBottom: Spacing.sm },
  sectionCard:      { backgroundColor: T.panel2, borderRadius: Radius.lg,
                       borderWidth: 1, borderColor: T.border,
                       overflow: "hidden" },

  // Rows
  row:              { flexDirection: "row", alignItems: "flex-start",
                       padding: Spacing.md, gap: Spacing.md },
  rowLabel:         { flex: 1 },
  rowLabelText:     { fontSize: FontSize.base, color: T.text,
                       fontWeight: "600", marginBottom: 2 },
  rowHint:          { fontSize: FontSize.xs, color: T.faint, lineHeight: 14 },
  rowControl:       { flex: 1.2 },
  rowDivider:       { height: 1, backgroundColor: T.border,
                       marginHorizontal: Spacing.md },

  // Stat rows
  statRow:          { flexDirection: "row", justifyContent: "space-between",
                       alignItems: "center", padding: Spacing.md },
  statLabel:        { fontSize: FontSize.base, color: T.muted },
  statValue:        { fontSize: FontSize.base, color: T.text,
                       fontWeight: "600", fontFamily: "monospace" },

  // Inputs
  input:            { backgroundColor: T.bg, borderWidth: 1, borderColor: T.border,
                       borderRadius: Radius.base, paddingHorizontal: Spacing.md - 2,
                       paddingVertical: Spacing.sm + 1, color: T.text,
                       fontSize: FontSize.base },
  inputMono:        { fontFamily: Platform.OS === "ios" ? "Courier New" : "monospace",
                       fontSize: FontSize.sm },

  // Info box
  infoBox:          { margin: Spacing.md, marginTop: 0, padding: Spacing.md,
                       backgroundColor: T.bg, borderRadius: Radius.base,
                       borderWidth: 1, borderColor: T.border },
  infoText:         { fontSize: FontSize.xs, color: T.dim, lineHeight: 17 },

  // Refresh stats button
  refreshStatsBtn:  { padding: Spacing.md, alignItems: "center",
                       borderTopWidth: 1, borderTopColor: T.border },
  refreshStatsBtnText: { fontSize: FontSize.sm, color: T.accent, fontWeight: "600" },

  // Reset button
  resetBtn:         { margin: Spacing.md, padding: Spacing.md,
                       backgroundColor: T.errorBg, borderRadius: Radius.base,
                       borderWidth: 1, borderColor: T.errorBorder,
                       alignItems: "center" },
  resetBtnText:     { fontSize: FontSize.base, color: T.error, fontWeight: "700" },

  // Save button
  saveBtn:          { backgroundColor: T.accent, borderRadius: Radius.base,
                       padding: Spacing.base, alignItems: "center",
                       marginTop: Spacing.sm },
  saveBtnText:      { color: T.white, fontSize: FontSize.lg, fontWeight: "700" },
});

Add the gear icon to every tab header

We want a small gear button in the top-right corner of the header on every tab screen. When the user taps it, the app should navigate to a settings screen. Instead of adding that button to each tab one by one, you add it once inside the tab navigator’s shared screen options. That way, every tab gets the same button automatically.

The file we are going to edit is: app/(tabs)/_layout.tsx. This file defines the layout for the tab group. In Expo Router, a file named _layout.tsx describes a navigator. Here, the navigator is the bottom tab bar plus the top headers for each tab.

The Tabs component from Expo Router creates the bottom tab navigator. It accepts a prop called screenOptions. Anything you put in screenOptions becomes the default for every screen inside that navigator. If you put headerRight there, every tab’s header gets the same right-side element. That is why the gear appears on all tabs “simultaneously” — you configure it once, and all tabs inherit it.

The individual Tabs.Screen entries below remain unchanged. They define the tabs themselves, such as their names, titles, and icons. They do not need to know about the gear because the gear comes from the shared options.

// app/(tabs)/_layout.tsx — add headerRight to screenOptions

// The router object is used to navigate programmatically.
// When the gear is pressed, you call router.push("/settings") to go to the settings route.
import { Tabs, router } from "expo-router";
// It also adds TouchableOpacity to the import from react-native.
// TouchableOpacity is a built-in React Native component that makes something tappable
// and gives visual feedback by reducing its opacity when pressed.
import { Platform, Text, TouchableOpacity } from "react-native";
import { T } from "../../src/theme";

export default function TabLayout() {
  return (
    <Tabs
      // Inside the Tabs component, screenOptions is an object.
      screenOptions={{
        // We already have things like tabBarStyle and tabBarActiveTintColor.
        // Those control the bottom tab bar. We leave them exactly as they are.
        tabBarStyle: { ... },
        tabBarActiveTintColor: T.gold,
        // ... etc ...

        // Let's add a new property to that same object: headerRight.
        // This property is a function that returns a React element.
        // **React Navigation calls this function when it renders the header for a screen.**
        // Whatever it returns is placed on the right side of the header.
        // Because it is in screenOptions, it applies to every tab screen.
        // If a specific tab later defines its own headerRight in its Tabs.Screen options,
        // that tab’s own setting would override the shared one.
        // But if you do nothing else, all tabs show the gear.
        headerRight: () => (
          // The function is written as an arrow function that returns a single JSX element.
          // The returned element is a TouchableOpacity. The parentheses around the JSX are just for readability.
          <TouchableOpacity // This is the tappable area. It has several props:
            // 1. This is the function that runs when the user taps. It calls router.push("/settings").
            // That tells Expo Router to navigate to the route /settings.
            // In Expo Router, the route is based on files.
            // A file like app/settings.tsx would match /settings
            onPress={() => router.push("/settings")}
            // style: This is an object with marginRight and padding.
            // marginRight: 16 pushes the button away from the right edge of the header.
            // padding: 4 makes the touch area a little larger.
            style={{ marginRight: 16, padding: 4 }}
            // This is the text that screen readers announce.
            accessibilityLabel="Open settings"
            // This tells the operating system that the element behaves like a button.
            // It is set to "button". This helps screen readers and other assistive technologies.
            accessibilityRole="button"
          >
            // Inside the TouchableOpacity is a Text component. It displays the gear character ⚙.
            // That character is a Unicode symbol. It may look slightly different on iOS and Android.
            // For a simple version, this is fine. For a more consistent look, you could later replace it
            // with an icon from a library such as @expo/vector-icons.
            // The Text component has a style object with fontSize: 20 and color: T.faint.
            // fontSize controls the size of the gear. T.faint is a theme color, a light gray.
            <Text style={{ fontSize: 20, color: T.faint }}>⚙</Text>
          </TouchableOpacity>
        ),
      }}
    >
      {/* The Tabs.Screen entries stay as they are. They define the tabs.
      You do not need to add headerRight to each one because the shared screenOptions already covers them. */}
    </Tabs>
  );
}

Configure the Settings screen header in the root layout

The file is app/_layout.tsx. In Expo Router, any file inside the app folder becomes a route. The _layout.tsx file is special: it defines a layout that wraps all routes inside that folder (and its subfolders). The root app/_layout.tsx wraps the entire app.

Inside this root layout, we are using a Stack navigator. A stack navigator works like a deck of cards: you can push a new screen on top, and the previous screen stays underneath. The user can go back by tapping a back button or swiping. The Stack component comes from expo-router. It is a pre-configured version of React Navigation’s native stack.

import { Stack } from "expo-router";
// ... other imports ...

export default function RootLayout() {
  return (
    <Suspense fallback={...}>
      <SQLiteProvider databaseName={DB_NAME} onInit={initDatabase} useSuspense>
        <StatusBar style="light" />
        <Stack
          // The Stack component has a prop called screenOptions.
          // This is an object that sets default options for every screen in that stack.
          screenOptions={{
            headerStyle:      { backgroundColor: T.bg }, // background color of the header (T.bg).
            headerTintColor:  T.text, // color of the header text and back arrow (T.text).
            headerTitleStyle: { fontWeight: "bold", color: T.text }, // font weight and color of the title.
            contentStyle:     { backgroundColor: T.bg }, // background color of the screen’s main content area.
          }}
        >
          {/* A stack navigator works like a deck of cards */}
          // This does not create the Settings screen.
          // The Stack.Screen component is only a configuration instruction.
          // It tells the stack navigator: “When the route named settings is shown, use these options.”
          <Stack.Screen
            // The name prop must match the route name exactly.
            // For our file at app/settings.tsx, the route name is settings.tsx.
            name="settings"
            options={{
              // This sets the text that appears in the header bar when the Settings screen is active.
              title:              "Settings",
              // On iOS, when you push a new screen, the back button normally shows the title of the previous screen.
              // For example, if you came from a screen titled “Home”, the back button would say “Home”.
              // Setting headerBackTitle to "Back" overrides that text to always say “Back”.
              // On Android, this option is ignored because Android uses a simple back arrow icon instead of a text label.
              headerBackTitle:    "Back",
              // This controls how the screen is presented.
              // "card" is the standard push style: the new screen slides in from the right
              // and covers the previous screen. It is the default presentation.
              presentation:       "card",
              // animation: "slide_from_right". This is already the default on both iOS and Android. I
            }}
          />
          {/* The (tabs) group is picked up automatically — no entry needed */}
        </Stack>
      </SQLiteProvider>
    </Suspense>
  );
}

Run and test

# npx runs a package that is installed in your project.
# expo start launches the Expo development server.
# This server bundles your JavaScript and sends it to your phone, simulator, or emulator.
# It also watches your files and reloads the app when you save changes.
# --clear clears the Metro bundler cache.
# Metro is the tool that turns your modern JavaScript and React Native code into something the device can run.
# Over time, Metro stores cached versions of files.
# If you change configuration, install new packages, or get strange errors, the cache can become stale.
# Clearing it forces Metro to start fresh.
npx expo start --clear
Test Expected
Tap ⚙ on any tab Settings slides in from the right
Header shows “Settings” and the ← back arrow returns to the previous tab
Type “test-key-123” in YouVersion field This tests a basic text input. The YouVersion field is where the user enters an API key
Tap Save A message like “✅ Settings saved” flashes
Go back to a tab, return to Settings “test-key-123” is still there (SecureStore persisted it)
Statistics section Shows prayer count and diary entry count
Tap ↺ Refresh statistics (manual refresh) Counts update
“Reset to defaults” An Alert appears with Cancel and Reset buttons
Confirm Reset Fields clear, and the seed flag is removed
API key after reset The field is empty, and the SecureStore entry is deleted

What you just learned

// AsyncStorage is a simple key-value store. It is not encrypted.
await AsyncStorage.setItem("api_key", "secret123");
// On Android, the data is stored in a plain XML file inside the app’s private data folder.
// /data/data/.../shared_prefs/*.xml (plain text on Android)

// SecureStore is designed for secrets. It uses the iOS Keychain and the Android Keystore.
// These are hardware-backed or OS-backed secure storage systems. The data is encrypted.
// Access usually requires the device to be unlocked and may require the user’s PIN or biometric authentication.
await SecureStore.setItemAsync("api_key", "secret123");

Expo Router uses file-based routing. The folder structure determines the navigation structure.

app/_layout.tsx         ← The root layout contains a Stack.
  app/(tabs)/_layout.tsx   ← Inside that Stack is the tabs group.
    app/(tabs)/verse.tsx     ← Inside the tabs group are individual tab screens.
  app/settings.tsx  ← The Settings screen is a separate Stack screen at the same level as the tabs group.

Because Settings is outside the tabs group, pushing it onto the root Stack makes it cover the entire tab bar. It slides over everything. When you press Back, the Settings screen is popped, and you return to whichever tab was open before.

This is why calling a navigation function such as router.push("/settings") from inside a tab screen works so cleanly. The Settings screen is not part of the tabs. It is a sibling of the tabs in the root stack. This is a common pattern for screens that should take over the full screen, like settings, modals, or detail pages.

// When the Settings screen opens, it needs several pieces of data:
const [key, path, name, seeded, prayers, diary] = await Promise.all([
  // * The YouVersion API key from SecureStore
  Secrets.getYouVersionKey(),
  // * The custom plan path from preferences
  Prefs.getCustomPlanPath(),
  // * The display name from preferences
  Prefs.getDisplayName(),
  // * The seed flag from preferences
  Prefs.getSeedFlag(),
  // * The prayer count from the database
  countPrayers(db),
  // * The diary entry count from the database
  listEntries().then(e => e.length),
]);

If you awaited each one one-by-one, the total time would be the sum of all the waits. For example, if each takes 20 milliseconds, six sequential waits would take about 120 milliseconds.

Promise.all starts all six operations at the same time. It waits until all of them finish. The total time is then roughly the time of the slowest operation, not the sum. In the example, that might be about 20 milliseconds. This makes the screen feel faster.

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.