A Japanese flashcard app with real spaced repetition, built entirely by hand, with no AI-generated code. This page walks through how it actually works: the scheduling algorithm, the architecture, and the gesture physics, with the real code.
I'm learning Japanese, and the first wall every learner hits is memorizing two full syllabaries: hiragana and katakana, 208 characters once you count the voiced, semi-voiced, and contracted variants. Plenty of flashcard apps exist. I wanted two things they didn't give me: a review schedule backed by an actual spaced-repetition algorithm rather than "shuffle and repeat," and cards that feel like physical objects instead of buttons.
There was a second reason. Most of my shipped apps were built fast with AI tools in the loop. KanaCard is the opposite experiment: every line written by hand, as proof to myself (and to anyone reading the code) that I can design and build a complete app from a blank Xcode project.
Architecture
The app is MVVM with a single @Observable view model owning all deck state, and SwiftData for persistence. Each card is a @Model that carries both its content and its own scheduling state:
@Model
class Kana: Identifiable, Comparable {
var character: String // "あ"
var romaji: String // "a"
var type: KanaType // .hiragana or .katakana
var variant: KanaVariant // .seion, .dakuon, .handakuon, .yoon
// Spaced-repetition state
var dueDate: Date // when this card re-enters the deck
var interval: Int // days to wait before the next review
var easeFactor: Double // growth multiplier, starts at 2.5
var repetitions: Int // consecutive correct answers
}
Kana.swift: the model owns its own scheduling state, so persistence is free with SwiftData.
The decision I like most: the review queue isn't a separate data structure, it's a filter. A card is "due" when its dueDate has passed, so the active deck is just a computed property over the fetched cards. Rating a card pushes its due date into the future, and it falls out of the queue on the next read. No queue bookkeeping, nothing to get out of sync:
var filteredKana: [Kana] {
// ... script-type and variant filters build `allowed` ...
return fetchedKana.filter { kana in
allowed.contains(kana.variant) && kana.dueDate <= Date()
}
}
KanaViewModel.swift: the daily review queue is a computed property, not a managed collection.
The SM-2 scheduler
Scheduling uses the SM-2 algorithm (the one behind Anki), implemented from scratch. After each review you rate the card fail / hard / good / easy. The rating adjusts two numbers: the interval (days until you see the card again) and the ease factor (how fast that interval grows). Fail resets you to the start; easy compounds the interval by the ease factor with a bonus. The ease factor is clamped at 1.3 so a rough patch can't trap a card in review hell:
KanaViewModel.swift: the full rating pipeline, lightly condensed. A card counts as "mastered" once its interval crosses 21 days.
The same scheduling state feeds a Swift Charts dashboard: a seven-day forecast of upcoming reviews (computed by bucketing due dates per calendar day), mastery breakdowns split by script, and a rating-history chart, all derived from the cards themselves, with no separate analytics store.
Making cards feel physical
The card is the whole UI, so it had to feel right. Three pieces work together. First, the flip is a real 3D rotation: rotation3DEffect around the y-axis, with the romaji face pre-rotated 180° so text reads correctly on the far side. Second, dragging uses an interactive spring, and the release handler decides the card's fate: past a 50-point threshold it gets flung off-screen (an animated offset to ±1000) and the deck advances in the animation's completion block; under the threshold it springs back to center:
DragGesture(minimumDistance: 15)
.onChanged { value in
withAnimation(.interactiveSpring()) {
dragDisplacement = value.translation
}
}
.onEnded { value in
withAnimation {
if value.translation.width > 50 { dragDisplacement.width = 1000 }
else if value.translation.width < -50 { dragDisplacement.width = -1000 }
else { dragDisplacement = .zero } // spring back
} completion: {
// advance the deck only after the fling animation lands
if value.translation.width > 50 { kanaViewModel.nextCard() }
else if value.translation.width < -50 { kanaViewModel.previousCard() }
dragDisplacement = .zero
}
}
CardView.swift: fling past the threshold and the deck advances in the animation's completion; otherwise the spring pulls the card home.
Third, the physics extras that sell it: the card tilts as you drag (rotationEffect of one degree per ten points of displacement), and the drop shadow is computed from the drag vector and flip angle, so light appears to come from a fixed point above the card. The shadow slides opposite your finger and tightens mid-flip when the card is edge-on:
let shadowX = -dragDisplacement.width / 10.0
let shadowY = -dragDisplacement.height / 10.0
let shadowAmount = 5.0 + 15.0 * (1.0 - abs(90 - angle) / 90.0)
CardView.swift: the shadow moves against the drag and collapses as the card turns edge-on, faking a fixed overhead light.
What's not done yet
An honest list, because a case study that pretends everything works is just marketing:
The glass card background has a specular RadialGradient highlight designed to track your finger, but it currently holds its own drag state that never updates, so the highlight sits centered. The fix is sharing the drag displacement through the view model instead of duplicating @State.
The SM-2 math is pure logic begging for unit tests, and the test target is still the Xcode template. Extracting the scheduler out of the view model into its own testable type is the next refactor.
Review history is derived from current card state, which means the stats can't show trends over time. A proper review-log model would fix that.
Read the code yourself
Every line of KanaCard is on GitHub: models, view model, gesture work, and all.