A simple personal flashcard app (Expo / React Native, Android). Create decks, add cards manually or via CSV import, and practice with lightweight spaced repetition. All data is stored locally on the device in SQLite — no accounts, no internet required.
- Multiple named decks.
- Add cards manually (front / back), or add many at once on the Add cards in bulk screen — paste a list (one card per line) or import a CSV file, with a configurable front/back separator.
- Practice mode: up to 10 due cards per batch, flip to reveal, then rate it —
Hard / Good / Easy (the middle rating is stored internally as
'fine'for data compatibility). Each card carries a Familiarity level (a Leitner "box"); the rating moves that level, which sets how long the card is put away.- Hard → forgot: reset to level 0, stays due now.
- Good → recalled: climb one level (longer interval).
- Easy → easy: climb two levels.
- Intervals grow along the ladder 1 → 3 → 7 → 14 → 30 → 60 days (capped at 60), so a card you keep getting right returns less and less often.
- When a card's timer passes, it automatically becomes due again.
- Swipe (or arrow keys on web) to move between cards without rating them — Familiarity is left untouched, so a skipped card stays in the set for later. Swiping forward past the last card skips it and ends the session.
- Session summary after each batch, with the option to practice the next 10.
- Progress screen (chart icon on the home screen): current/best practice streak,
total reviews, a 14-day activity chart, an upcoming-due forecast, and each deck's
familiarity-level breakdown. Backed by a local
reviewshistory table — one row per rating. Deleting cards or decks (or restoring a backup) keeps the history; it is deliberately not included in the backup file (the backup format is unchanged).
On the Add cards in bulk screen (deck "⋯" menu) you set a front/back
separator (defaults to -), then either paste lines or import a CSV/text file.
One card per line, no header row:
hola - hello
gato - cat
buenos días - good morning
- Each line is split on the first occurrence of the separator, so the back may
itself contain the separator (e.g. set it to
;forhola;hellofiles). - Surrounding whitespace (around the separator and at line ends) is stripped.
- Blank lines and lines without the separator are skipped.
bun install # first time only (Bun is the package manager)
npx expo start # then scan the QR code with the Expo Go app on your Android phonePress a to open in an Android emulator if you have Android Studio set up.
Uses EAS Build (free tier, builds in the cloud):
bun add -g eas-cli
eas login # create / sign in to a free Expo account
eas build -p android --profile previewWhen the build finishes, EAS gives you a download link for an .apk. Open that link on your
phone and install it. Data persists across restarts.
src/
app/ expo-router screens
_layout.tsx root Stack navigator
index.tsx decks list (create deck, see due counts)
deck/[id]/index.tsx deck detail (stats, practice, add card, bulk add, rename/delete)
deck/[id]/card.tsx add / edit a single card (?cardId=... to edit)
deck/[id]/bulk.tsx add many cards at once (paste or CSV file, custom separator)
deck/[id]/practice.tsx practice batch + session summary
stats.tsx Progress screen (streaks, activity, forecast, deck levels)
db/
database.ts opens SQLite, creates schema
decks.ts deck queries
cards.ts card queries + spaced-repetition rules (due_at)
reviews.ts review history: day counts + streak math
stats.ts per-deck level breakdown + due forecast
lib/csv.ts delimited line parser (custom separator)
lib/haptics.ts haptic vocabulary (no-op on web)
components/ shared UI (Button, PressableScale, OverflowMenu,
ModalCard, EmptyState, Icon, Screen, ...)
theme.ts colors / type scale / spacing / press feedback
Each card has a due_at timestamp (epoch ms) and a familiarity level (a Leitner box,
0 = new/lapsed). A card is due / unlearned when due_at <= now. New and imported cards
start at familiarity = 0, due_at = 0 (immediately due). Rating a card runs nextReview
(src/db/cards.ts): the rating moves its familiarity level, and the new level indexes the
interval ladder INTERVAL_DAYS = [0, 1, 3, 7, 14, 30, 60] (days) to set the next due_at.
Hard keeps the card due now (it stays in the practice set); Good and Easy push it
out along the ladder. These two fields drive both the practice filter and the automatic
return of expired cards — no background job is needed.
Every rating also inserts a row into the reviews table (rating, level_before,
level_after, reviewed_at, plus nullable card_id/deck_id references that are
SET NULL on delete, so pruning decks never erases your streak). The Progress screen
buckets timestamps by the device's local calendar day in JS (src/db/reviews.ts);
a streak survives overnight as long as no full day passes without practice.