Example app · Pro

Field Notes: local-first Expo journal app

A standalone Expo app in `examples/notes`, with its own package.json, lockfile and .env.example. Notes are written to the device first. Accounts, cloud sync and the example's paid gate switch on as you add keys.

Screens

What it is

One note per moment: a title, a few lines, optionally a photo, and where you were. The Where I am stamp adds the place name (expo-location reverse geocoding) and the weather at that time (Open-Meteo), both keyless.

Sync is a pure function: last-writer-wins on updatedAt, with tombstones for deletes, unit-tested in src/features/entries/merge.ts. Supabase push and pull run on sign-in, on foreground and after edits (src/lib/sync.ts).

The notes live in a zustand store persisted on the device (src/stores/entries.ts). The table and its row-level security are in supabase/entries.sql.

Flows included

Built on

Also in the app, with no /stack/ page: expo-image-picker for photos, @react-native-community/datetimepicker for the reminder time, and Maestro flows for end-to-end tests.

What works without keys

Keys and credentials

Env namesWhat it unlocks
EXPO_PUBLIC_SUPABASE_URL, EXPO_PUBLIC_SUPABASE_ANON_KEYAccounts and cloud sync. Run supabase/entries.sql in the SQL editor first.
EXPO_PUBLIC_REVENUECAT_IOS_KEY, EXPO_PUBLIC_REVENUECAT_ANDROID_KEYThe Free and Pro gate: 10 notes and no export until the pro entitlement is active. Needs a current offering.
EXPO_PUBLIC_POSTHOG_KEY, EXPO_PUBLIC_SENTRY_DSNAnalytics and crash reports, only after the user consents.
POSTHOG_PERSONAL_API_KEY, POSTHOG_PROJECT_ID, REVENUECAT_SECRET_KEY (server only)Account deletion also erases the user at PostHog and RevenueCat.

Limitations

Tier

Pro only. examples/ ships with Pro alone (packages/cli/lib/tier.ts: examples on for Pro, off for Free and Starter). The pro entitlement is this demo's own paid tier. It is not the ReadyNative Pro you buy.

From the source

From examples/notes/src/features/entries/merge.ts:

export function mergeRemote(local: EntryMap, remote: Entry[]): EntryMap {
  const next: EntryMap = { ...local };
  for (const incoming of remote) {
    const current = next[incoming.id];
    if (!current || incoming.updatedAt > current.updatedAt) {
      next[incoming.id] = { ...incoming, photoUri: current?.photoUri ?? incoming.photoUri };
    } else if (incoming.updatedAt === current.updatedAt && current.syncedAt !== incoming.syncedAt) {
      // Same content both sides: just record that the server has it.
      next[incoming.id] = { ...current, syncedAt: incoming.syncedAt };
    }
  }
  return next;
}

Read more