iOS · SwiftUI · Android · Jetpack Compose · Offline-First · GitHub Actions

TOEFL Vocab: One Offline Vocabulary Trainer, Two Native Codebases, Neither Built Locally

The same study app for two classic TOEFL word lists, shipped twice — a SwiftUI iPhone build and a screen-for-screen Kotlin and Jetpack Compose Android port — both developed entirely on one Windows machine with no Mac, no Xcode GUI, no Android Studio, no local SDK and no simulator or emulator of any kind. Every commit for both platforms is compiled, tested and screenshotted on GitHub Actions runners, and the shared study engine — adaptive ordering, a five-answer cycle rule, and reporting — is deterministic and unit-tested because CI is the only feedback that exists.
iOS Repo Android Repo Download for iPhone Download for Android App Page Design Notes
Vocabulary Entries
0
Sections Across Two Books
0
Unit Tests Across Both Builds
0
Screens Captured per CI Run
0
Swift + Kotlin Source Files
0
Developer Machine
Windows
iOS Language
Swift 5.0
iOS Interface
SwiftUI
Android Language
Kotlin 2.0
Android Interface
Jetpack Compose
CI/CD
GitHub Actions
Delivery
Sideloadly · Signed APK
System Map

Constraint-Driven Application Architecture

Four layers, each shaped by the same constraint: the only machine that ever compiles this code is a cloud runner.

Content Layer
358 entries

Two bundled JSON files — words and ordering — merged at launch into an indexed catalog. Adding vocabulary is a data change with no Swift change.

Engine Layer
Deterministic

Weakness scoring, the five-answer cycle rule, and report aggregation. Pure functions with no randomness, so the same history always yields the same queue.

Interface Layer
SwiftUI

MVVM with a Router, five observable stores injected at the root, and a design system that treats light and dark as equally first-class.

Delivery Layer
4 CI jobs

XcodeGen regenerates the project, four jobs lint, test, screenshot and package, and tagged builds emit an unsigned .ipa for sideloading.

Content Foundation

Bundled Vocabulary Data

All content ships inside the app: no account, no server, no network call at any point.

The app is built on two published word lists — 504 Absolutely Essential Words (Barron's) and 400 Must-Have Words for the TOEFL (McGraw-Hill). Both are bundled as JSON and read at launch. Each section carries two independent lists: main, the book's own words, and extras, additional words collected alongside them. Either may be absent, and a review section with no extras simply does not offer that list.

Fully offlineNothing is fetched. The word lists, the ordering metadata and the app icon are all inside the bundle.
Data-only growthAppending a day means editing two JSON files and running the validator — no Swift change is required.
Licensing boundaryMIT covers the code and tooling. The word selections remain their publishers'; the definitions are the author's own study notes.
Total Entries358
Main Words194
Extra Words164
Sections17
Infrastructure

Content Tooling & Dependencies

The content layer is deliberately thin — Foundation on the device, Python on the developer's Windows machine.

Foundation & CodableDecodes vocabs.json and catalog.json into typed models inside VocabCatalogLoader, with per-field defaults so an older or partial file still loads.
Bundle ResourcesBoth JSON files are declared in project.yml with buildPhase: resources, which copies them rather than handing them to the Swift compiler.
Python 3.12 ValidatorScripts/validate_content.py is the only local feedback loop — it runs on Windows in about a second and gates the whole CI pipeline.
Migration ScriptScripts/migrate_vocabs.py converts legacy {term: definition} blocks into the ordered-array form used by the current schema.
PillowScripts/make_app_icon.py squares off rounded or transparent corners — iOS applies its own mask and rejects an alpha channel — then resizes artwork to 1024×1024.
FoundationCodableJSONDecoderpython 3.12Pillow
Data Taxonomy

Two-File Content Model

Words live in one file, order and presentation copy in another — because JSON objects have no guaranteed key order.

ArtifactShapeRole
vocabs.jsonbook → section → categoryEvery word and definition. Word order is stored as an explicit array, not object keys.
catalog.jsonordered books[] arrayBook and section order, display titles, and the intro copy shown before each book and section.
mainarray of {term, definition}The book's own words for that section — 194 across the library.
extrasarray of {term, definition}Additional words collected alongside the section. Optional; review sections omit it.
schemaVersionintegerCurrently 1. Present so a future format change can be detected rather than guessed.
kindlesson | reviewDistinguishes a numbered day from a consolidation round, which the UI labels differently.
--- in a definitioninline markerEverything after it is a grammar usage note, not part of the meaning. Split out at load time into usageTip — 24 of the 358 entries carry one.
vocabs.json
{
  "504": {
    "day_1": {
      "main": [
        {
          "term": "abandon",
          "definition": "desert; leave without planning to come back"
        },
        {
          "term": "impact",
          "definition": "a strong influence --- followed by on or of"
        }
      ],
      "extras": [
        { "term": "disobey", "definition": "fail to obey" }
      ]
    }
  }
}
catalog.json
{
  "schemaVersion": 1,
  "books": [
    {
      "id": "504",
      "title": "504 Absolutely Essential Words",
      "shortTitle": "504 Essential",
      "author": "Barron's",
      "theme": "indigo",
      "sections": [
        { "id": "day_6",   "title": "Day 6",   "kind": "lesson" },
        { "id": "review_1","title": "Review 1","kind": "review" },
        { "id": "day_7",   "title": "Day 7",   "kind": "lesson" }
      ]
    }
  ]
}
Data Intelligence

Why Ordering Needs Its Own File

A real ordering problem, not ceremony: no sort function could place a review section correctly.

The Key-Order Problem
JSON
A decoded JSON object has no guaranteed key order, so section sequence can never be derived from vocabs.json itself. In the 504 book, review_1 belongs between day_6 and day_7 — alphabetical or numeric sorting would both place it wrong.
Graceful Degradation
Resilience
A section present in vocabs.json but missing from the catalog is not hidden — it is appended at the end with a generated title (day_9 becomes "Day 9") and a default intro. Forgetting the catalog line degrades presentation, never availability.
Loader Contract

What VocabCatalogLoader guarantees

  • Merges both files into an ordered, indexed VocabCatalog
  • Assigns each word a stable VocabID and an orderIndex
  • Splits a definition at --- into meaning and usageTip
  • Falls back to the legacy word format when it encounters one
  • Returns a usable catalog even when content is partially missing
Presentation Metadata

What the catalog adds

  • Book themeindigo for 504, teal for 400
  • shortTitle for compact rendering in Reports
  • Per-book and per-section intro copy
  • kind to separate lessons from review rounds
Record Modeling

Usage Tips Split from Definitions

Some definitions carry grammar advice after a --- marker. Advice about using a word is not what the word means, so it must not be memorised as the answer.

Two Facts in One Field
Parsing
The source lists write collocation notes inline: "a strong influence --- followed by on or of". Shown unsplit, the note becomes part of the text the learner is grading themselves against — so 24 of the 358 entries would train the wrong recall. The split happens once in VocabCatalogLoader, which keeps every screen downstream working with a clean definition and an optional usageTip rather than parsing strings itself.
Failing Toward the Meaning
Resilience
A row that is only a note, or that has nothing before the marker, keeps its original text and reports no tip. Losing a definition to an over-eager split would be far worse than rendering one unsplit line, so the guard is deliberately biased toward keeping the meaning. Further markers after the first belong to the note, not to a third field.
split_usage_tip.swift
static func splitUsageTip(from raw: String)
    -> (definition: String, usageTip: String?) {

    let whole = raw.trimmingCharacters(in: .whitespacesAndNewlines)

    let parts = whole.components(separatedBy: usageTipSeparator)
    guard parts.count > 1 else { return (whole, nil) }

    let definition = parts[0]
        .trimmingCharacters(in: .whitespacesAndNewlines)
    // Any further markers belong to the note, not to another field.
    let tip = parts.dropFirst()
        .joined(separator: usageTipSeparator)
        .trimmingCharacters(in: .whitespacesAndNewlines)

    guard !definition.isEmpty else { return (whole, nil) }
    return (definition, tip.isEmpty ? nil : tip)
}
usage_tip_view.swift
// Rendered only after the definition is revealed, and styled as an
// aside rather than as more dictionary text.
if let tip = item.usageTip {
    UsageTipView(tip: tip)
}

// Inside UsageTipView: the label repeats what the icon says, so the
// emoji is never the only cue, and the whole block is one VoiceOver
// element rather than three fragments.
Text(strings[.practiceTip].uppercased())
    .foregroundStyle(Palette.warning)
.background(Palette.warningSoft)
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(strings[.practiceTip]): \(tip)")
Presentation

An aside, not a second definition

The tip appears under the revealed definition in a tinted card with a lightbulb and a localised Tip / نکته label. Palette.warningSoft was added to the design system for it, in both light and dark variants, so the aside reads as advisory in either appearance without inventing a one-off colour.

Verification

Proven by the loader tests

The splitting rule is pure and total, so it is asserted directly: a plain definition passes through untouched, a marked one splits, a note-only row keeps its text, and repeated markers stay inside the tip. That is what took VocabCatalogLoaderTests from 14 tests to 16.

Implementation

Content Validation Pipeline

With no local compiler, the validator is the entire local feedback loop — and it gates every macOS runner minute.

What It Catches

validate_content.py

  • Duplicate terms within a section
  • Empty or whitespace-only definitions
  • Stray leading and trailing whitespace
  • Catalog entries referencing sections that do not exist
  • Sections present in data but absent from the catalog
Why It Runs First

Cost asymmetry

The validator runs on Ubuntu in roughly 15 seconds and gates both macOS jobs. A hand-edited JSON mistake fails in seconds instead of consuming a six-minute macOS build, and the same script runs unchanged on Windows before every push.

local_content_loop.sh
# The only verification available before code reaches CI
python Scripts/validate_content.py

# Convert legacy {term: definition} blocks to the ordered-array form
python Scripts/migrate_vocabs.py

# Rebuild the app icon: square the corners, strip alpha, resize to 1024
python Scripts/make_app_icon.py path/to/artwork.png

# The same check CI runs, with warnings promoted to failures
python Scripts/validate_content.py --strict
Analytics Layer

Library & Section Navigation

How the content model surfaces in the app. Click any image to enlarge.

Library screen listing both vocabulary books with progress meters
Library — both books with progress at a glance, driven by the catalog's ordering
Book introduction screen with section list
Book intro — the intro copy and section list come straight from catalog.json
Section screen with Main and Extra list pickers and a preview of queued words
Section — Main/Extra picker, with the adaptive queue previewed before starting
Content Synthesis

What the Data Layer Buys

Separating order from content is what makes the app extensible without touching Swift.

Design readout: Splitting content across two files costs one extra edit per new day and removes an entire class of bug. The app reads whatever is present at launch, unlisted sections degrade to a generated title rather than disappearing, and the validator catches the realistic mistakes — duplicates, empty definitions, catalog drift — before a single macOS runner minute is spent.

Engine Blueprint

Adaptive Practice Engine

The promise the app makes: reopen a section and the words you get wrong come first.

Words are presented one at a time. The user hears the word, decides whether they knew it, and the definition is then revealed. Five boxes under each word track the last five answers; when the fifth lands, the cycle banks and resets. Ordering is deterministic and score-based — no shuffling — so the same history always produces the same queue. That is what makes the behaviour explainable to the user and testable in CI, which matters more here than usual because CI is the only place the code runs.

Self-gradedTwo choices — I knew it or Didn't know — with the meaning revealed immediately after.
Untouched sectionsPlay in book order, because every word scores identically and the tie-break is source position.
Separate countersThe Extra Practice drill keeps its own statistics and never moves main progress.
Infrastructure

Engine Dependencies

Deliberately dependency-free: the engine is plain Swift so it can be tested without a running app.

FoundationThe only import in AdaptiveOrdering and StatsAggregator. Both are enum namespaces of static functions with no instance state.
OrderingWeightsA struct exposing every tunable constant, so the scoring behaviour is pinned down in tests instead of hidden as magic numbers.
WordStatsThe per-word record — lifetime totals, current cycle, banked cycle, streak — that both the ordering score and the reports read from.
MetricSummaryOne rolled-up shape reused at every level: whole library, one book, one section, one category.
Model Mechanics

The Five-Answer Cycle Rule

The checklist fills left to right; the fifth answer banks the cycle and starts a fresh row.

Bank, Then Reset
cycleLength = 5
When the fifth box lands, the cycle is copied into lastCycle and currentCycle is emptied. The banked row stays visible as a "last 5" recap until the user moves on, so the fifth answer is actually seen instead of the row blanking out underneath them.
Mastery
masteryStreak = 3
A word is mastered once it has finished at least one cycle in this run and is currently on a streak of three or more correct answers. Both conditions matter — a long-ago cycle with recent mistakes is not mastery.
FieldValuePurpose
attempts / correct / incorrectlifetimeNever reset, so Reports can show real history across runs.
currentCycle[Bool], 0–4 longThe row being filled right now.
lastCycle[Bool]?The banked five-answer recap, shown until the user advances.
completedCyclesThisRunintegerDrives "is this word done for this run?" and the global completion notice.
consecutiveCorrectintegerFeeds the mastery check and the ordering score's mastery decay.
maxStoredSessions250Caps session history so the progress file cannot grow without bound.
Mathematical Mechanics

Weakness Score & Deterministic Ordering

A Laplace-smoothed error rate, adjusted by recency and mastery, with a total-order tie-break.

Smoothed Error Rate
Laplace
$$e = \frac{\text{incorrect} + 0.5}{\text{attempts} + 1}$$
Smoothing stops a single wrong answer reading as a 100% error rate forever. It also places an unseen word at exactly 0.5 — below anything actually got wrong, above anything being got right.
Full Weakness Score
Ranking
$$s = w_e\,e + b\,[\text{last wrong}] - p\min(k, 5) - c\min(y, 5)$$
Where $k$ is the correct streak and $y$ the cycles finished this run. A recent mistake lifts a word above an unseen one immediately; mastery and completion push it back down.
WeightValueEffect
errorWeight1.0Multiplier on the smoothed error rate — the main signal.
recentMistakeBonus0.35Added when the most recent answer was wrong. Large enough to lift a word above an unseen one at once.
masteryPenaltyPerStreak0.06Subtracted per consecutive correct answer, so mastered words sink.
completedCyclePenalty0.04Subtracted per finished cycle this run, so genuinely done words stop crowding the front.
maximumStreakConsidered5Caps the mastery decay so a long streak cannot drive the score arbitrarily negative.
unseenScore0.5The score for a word with no history — the pivot the whole ranking is built around.

Why a total ordering: Swift's sort is not stable, so a comparator that only checks the score could shuffle equal-scoring words between launches. Ties break first on orderIndex, then on the raw VocabID, which makes the queue completely reproducible — and lets a test assert an exact sequence.

Optimization Strategy

Extra Practice Drill

A library-wide drill ranked by main-mode weakness, with its own counters.

Ranking Rule

Ranked by main, tie-broken by drill count

The queue is ordered by main-mode weakness, because that is where the real learning history lives. Among equally weak words the ones drilled least come first, so a long session keeps moving instead of looping over the same handful.

Scope

Weakest 25, 50, or everything

A scope selector caps the queue length. Running the full list weakest-to-strongest is what produces the promised behaviour: wrong words first, then the rest, then a full pass is complete and the queue starts over.

Isolation guarantee: PracticeMode routes every answer to either the main or the extra dictionary. A heavy drilling session can never flatter — or wreck — the study progress shown in the Library and Reports.

Implementation

Core Engine Code

The scoring function, the comparator, and the cycle rule as they appear in the source.

AdaptiveOrdering.swift
static func weakness(_ stats: WordStats?,
                     weights: OrderingWeights = .default) -> Double {
    guard let stats, stats.attempts > 0 else { return unseenScore }

    // Smoothed so one wrong answer is not a 100% error rate forever.
    let smoothedErrorRate =
        (Double(stats.incorrect) + 0.5) / (Double(stats.attempts) + 1.0)
    var score = weights.errorWeight * smoothedErrorRate

    if stats.lastAnswerWasCorrect == false {
        score += weights.recentMistakeBonus
    }

    let streak = min(stats.consecutiveCorrect, weights.maximumStreakConsidered)
    score -= Double(streak) * weights.masteryPenaltyPerStreak

    let cycles = min(stats.completedCyclesThisRun, weights.maximumCyclesConsidered)
    score -= Double(cycles) * weights.completedCyclePenalty

    return score
}
WordStats.record
// Records one self-graded answer and applies the five-step reset rule.
@discardableResult
mutating func record(correct isCorrect: Bool,
                     at date: Date = Date()) -> Bool {
    attempts += 1
    if isCorrect {
        correct += 1
        consecutiveCorrect += 1
    } else {
        incorrect += 1
        consecutiveCorrect = 0
    }
    lastAnsweredAt = date
    currentCycle.append(isCorrect)

    guard currentCycle.count >= Self.cycleLength else { return false }
    lastCycle = currentCycle
    currentCycle = []
    completedCycles += 1
    completedCyclesThisRun += 1
    return true
}
total_ordering.swift
// Weakest first. Ties break on source order, then on id, giving a total
// ordering — `sort` is not stable in Swift, so the comparator has to be
// complete or the queue could shuffle between launches.
items
    .map { (item: $0, score: weakness(stats($0.id), weights: weights)) }
    .sorted { lhs, rhs in
        if lhs.score != rhs.score { return lhs.score > rhs.score }
        if lhs.item.orderIndex != rhs.item.orderIndex {
            return lhs.item.orderIndex < rhs.item.orderIndex
        }
        return lhs.item.id.rawValue < rhs.item.id.rawValue
    }
    .map(\.item)
Analytics Layer

Practice Flow & Reporting

The engine as the user experiences it — question, reveal, summary, and the rolled-up report.

Practice screen showing a word, pronunciation control and the five-box checklist
Practice — the word, the pronunciation control, and the five-box checklist
Practice screen after answering, showing the revealed definition
Revealed — self-grade first, then the definition appears
Session summary showing accuracy ring and next-step options
Summary — accuracy, cycles completed, and where to go next
Reports screen with mastery ring and section heat grid
Reports — mastery ring, section heat grid, and the Extra Practice launcher
Evaluation Matrix

Aggregation & Report Semantics

How raw per-word records become the numbers on the Reports screen.

MetricDefinitionWhy It Is Defined That Way
seenWords with at least one attemptDistinguishes "never opened" from "opened and struggling".
completedFinished a cycle this runPer-run rather than lifetime, so a restart genuinely resets the goal.
masteredCompleted and on a 3+ streakRequires both history and current form, not one or the other.
needsWorkincorrect > 0 and not masteredDeliberately not a score threshold — the number has to match what a user would count by hand.
weakestTop 8 by weakness scoreOnly words answered wrong at least once qualify; an unseen word is new, not weak.
allWordsCompletedEvery word banked a cycleDrives the "you've been through everything" notice and the new-run offer.

Diagnostic breakdown: StatsAggregator is pure and synchronous — the same catalog and progress always return the same numbers. With only a few hundred words, a full recompute per render is cheaper than any caching scheme would be to maintain, and it keeps the whole reporting path trivially testable.

Interface Blueprint

SwiftUI Application Shell

MVVM with a Router standing in for a coordinator, and exactly one view model in the whole app.

Five observable stores are injected once at the root — ContentProvider, ProgressStore, SettingsStore, PronunciationService and Router. Only the practice screen has a dedicated view model; every other screen is a pure function of the stores, recomputed on render. The app targets iOS 16, is iPhone-only and portrait-only, and treats light and dark appearance as equally first-class — both are verified on every CI run.

One view modelPracticeViewModel owns the session state machine. Everything else derives from the stores.
No previews, no Interface BuilderThere is no local Simulator, so a layout is only ever seen once CI screenshots it.
BilingualEnglish and Persian, with automatic right-to-left layout when Persian is selected.
Infrastructure

Interface Libraries & Dependencies

Apple frameworks only — no third-party packages anywhere in the app target.

SwiftUIEvery screen, the tab shell, navigation stacks, the modal practice session, and the fileExporter/fileImporter flows used for progress backup.
AVFoundationAVSpeechSynthesizer for on-device pronunciation, plus AVAudioSession configured as .playback with .duckOthers.
UIKitFeedback generators wrapped in Haptics, and UIWindowScene reached through LayoutDirectionBridge to force the window's writing direction. Both are contained in one file each rather than imported across the app.
UniformTypeIdentifiersDeclares the JSON content type for ProgressBackupDocument, the FileDocument wrapper required by SwiftUI's exporter.
Strings (custom)A hand-rolled string table resolving English and Persian with RTL support, rather than .strings files — one Swift file is easier to keep in sync without a local build.
SwiftUIAVFoundationUIKitUniformTypeIdentifiersFoundation
Workflow Blueprint

Navigation & Module Structure

From the library down to a practice session, and back out through the summary.

01Library

Both books with progress meters and a "continue" offer from lastLocation.

02Book & Section

Intro copy, then a Main/Extra picker with the adaptive queue previewed.

03Practice

Modal session driven by PracticeViewModel and a PracticeConfiguration.

04Summary

Accuracy, cycles, and three exits: next section, practise again, back to menu.

ModuleResponsibility
App/RootView.swiftRoot tab shell and one-time dependency wiring for all five stores.
Navigation/Router.swiftTab and stack navigation plus modal practice sessions — a coordinator in all but name.
DesignSystem/Theme.swiftPalette, typography and the per-book indigo/teal accents.
DesignSystem/Components.swiftShared building blocks: checklist row, progress meter, section tiles, buttons, usage-tip card.
Core/Localization/LayoutDirectionBridge.swiftForces UIKit's writing direction to match the app's language setting — the whole UIKit surface area outside Haptics.
Features/Practice/PracticeViewModel.swiftThe practice session state machine — queue, reveal state, answer routing, completion.
Features/Reports/ReportsView.swiftMastery ring, section heat grid, weakest-words list, and the drill launcher.
Runtime Contract

On-Device Pronunciation

Three accents with no bundled audio and no network access.

Why Synthesis, Not Audio Files
Offline
US, UK and AU are three system voices that already ship with iOS. Nothing is bundled and nothing is downloaded, which keeps the .ipa small and means a word added to vocabs.json is instantly pronounceable with no extra work.
Quality Fallback
Graceful
Enhanced voices sound markedly better but are an optional download the user may not have. Voice selection falls through the tiers — enhanced, then any matching voice, then the locale, then en-US — so speech never simply fails.
voice_selection.swift
private static func voice(for accent: SpeechAccent) -> AVSpeechSynthesisVoice? {
    let matching = AVSpeechSynthesisVoice.speechVoices()
        .filter { $0.language == accent.localeIdentifier }

    if let enhanced = matching.first(where: { $0.quality == .enhanced }) {
        return enhanced
    }
    if let any = matching.first { return any }

    return AVSpeechSynthesisVoice(language: accent.localeIdentifier)
        ?? AVSpeechSynthesisVoice(language: "en-US")
}
audio_session.swift
// `.playback` so pronunciation still works with the ring/silent switch
// flipped to silent — a muted vocabulary app is a bug report.
// `.duckOthers` lowers the user's music instead of stopping it.
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playback,
                        mode: .spokenAudio,
                        options: [.duckOthers])
try session.setActive(true)
Accessibility Layer

Dynamic Type, VoiceOver & Bilingual Layout

Accessibility is structural here, not a pass at the end — it cannot be spot-checked locally.

Typography

Dynamic Type throughout

No fixed point sizes in body text. Because there is no local Simulator to sanity-check a layout at large text sizes, avoiding hard-coded sizes is the only reliable defence against clipping.

VoiceOver

Combined controls

Multi-part controls — a word with its checklist and pronunciation button — are combined into single readable elements, so VoiceOver announces one meaningful item rather than five fragments.

Localization

English and Persian

A custom Strings table resolves both languages, with automatic right-to-left layout for Persian. String coverage across both languages is asserted in the test suite rather than checked by eye — though, as the next section shows, the SwiftUI environment alone was not enough to make the direction actually stick.

Implementation Core

Language Switching & the UIKit Direction Bridge

A settings toggle that changes the writing direction is not a text change — it is a layout change in two frameworks at once.

The Symptom
Layout
Switching to Persian and back left English text on screen backwards. \.layoutDirection describes only the SwiftUI tree; the UIKit layer underneath — the window, and the scroll views backing ScrollView, List and Form — carries its own semanticContentAttribute, and UIKit mirrors a right-to-left scroll view by transforming it rather than laying it out differently. After a round trip the window was still right-to-left while the content had gone back to left-to-right, and a mirrored container holding unmirrored content is what put the text on backwards.
The Two-Part Fix
Deterministic
First, writing the attribute onto every connected window on each change, so the two layers agree. Second, keying the root view on the language so a switch rebuilds the tree instead of re-laying out the existing one — SwiftUI otherwise reuses a scroll view that was built for the other direction, and a reused container keeps a mirroring transform its new contents do not expect.
layout_direction_bridge.swift
@MainActor
static func apply(_ direction: LayoutDirection) {
    let attribute = attribute(for: direction)

    for scene in UIApplication.shared.connectedScenes {
        guard let windowScene = scene as? UIWindowScene else { continue }
        for window in windowScene.windows
        where window.semanticContentAttribute != attribute {
            window.semanticContentAttribute = attribute
            // The attribute alone marks the hierarchy dirty; this is
            // what makes already-visible views redraw in the new
            // direction instead of waiting for the next layout pass.
            window.subviews.forEach { $0.setNeedsLayout() }
        }
    }
}
app_scene.swift
RootView()
    .environment(\.strings, settings.strings)
    .environment(\.layoutDirection, layoutDirection)
    // Rebuild rather than re-lay-out on a language change.
    .id(language)
    .preferredColorScheme(settings.settings.theme.colorScheme)
    // Keep the UIKit layer pointing the same way as the SwiftUI one.
    .onAppear { LayoutDirectionBridge.apply(layoutDirection) }
    .onChange(of: layoutDirection) { LayoutDirectionBridge.apply($0) }

Why this one was expensive: a direction bug is exactly the class of defect the no-local-Simulator constraint punishes hardest. It only appears after a settings round trip, so no single screenshot can show it — the CI gallery relaunches the app for each screen, so every image looked correct. This is the one place in the app where reaching past SwiftUI into UIKit was the right answer rather than a shortcut.

Analytics Layer

Light and Dark Appearance

Both appearances are captured for every screen on every CI run — the same screen, both ways.

Reports — Light
Reports screen in light appearance
Reports — Dark
Reports screen in dark appearance
Settings screen in light appearance
Settings — accent, speech rate, language, and progress backup controls
About screen
About — version, licensing, and the content attribution notice
Practice screen in light appearance
Practice in light appearance — the same layout, re-themed
Interface Synthesis

Designing Without Seeing

What building a UI with no previews and no local Simulator actually changes.

Design readout: Without SwiftUI Previews, every layout decision is a hypothesis until CI renders it. That pushes the design toward things that fail safely — Dynamic Type instead of fixed sizes, stores instead of scattered state, one view model instead of many — and makes the automatic screenshot job the real design tool. Eighteen images per run is what replaces the canvas.

Persistence Blueprint

One File, Written Atomically

All progress is a single Codable JSON file in Application Support — no database, no sync.

ProgressStore owns all mutable user progress and its one file on disk. Answers arrive a tap at a time, so writes are debounced by 600 ms and batched to keep the app off the filesystem during a fast session; anything that must not be lost — a restore, a new run, a reset — writes through immediately instead.

Atomic writesA single Data.write(to:options:[.atomic]) call, so a crash mid-save cannot leave a half-written file.
Never crashes on loadAn unreadable file is moved aside as progress-corrupt-<timestamp>.json and the app starts clean.
Bounded growthSession history is capped at 250 records, so the file cannot grow without limit.
Infrastructure

Storage Dependencies

Foundation only — the decision not to adopt SwiftData is itself the main dependency choice.

JSONEncoder / JSONDecoderConfigured with .iso8601 dates and .sortedKeys output, so a saved file diffs cleanly and can be read by a human.
FileManagerResolves applicationSupportDirectory for progress.json and performs the quarantine move when a file fails to decode.
Swift ConcurrencyThe debounced save is a cancellable Task; each new answer cancels the pending write and reschedules it.
FileDocument & UTTypeProgressBackupDocument bridges the encoded backup into SwiftUI's fileExporter and fileImporter.
Persistence Layer

Why a Codable File and Not SwiftData

Three reasons, all downstream of having no local Simulator.

Deployment Target
iOS 16
SwiftData requires iOS 17, which would cut off older devices for a sideloaded build that already carries enough install friction.
Debug Cost
CI round trip
A schema or migration bug costs one full CI round trip to observe and another to confirm a fix. One atomic write has far less that can go wrong.
Actual Load
358 records
The whole dataset is a few hundred small records. There is no query load here that would justify a database engine.

The door stays open: ProgressState is the only shape that would need porting, and ProgressStore is the only type the UI talks to. The migration is contained by construction rather than by promise.

Data Integrity

Defensive Decoding & Quarantine

Every persisted type decodes field by field with defaults, because a hard throw would wipe real study history.

Failure ModeHandlingRationale
Field added since the file was writtendecodeIfPresent with a defaultA save from an older build must still load rather than throw.
completedCyclesThisRun missingFalls back to completedCyclesPreserves meaning for files written before the per-run counter existed.
Hand-edited or truncated cycletrimmed to 4 marksA currentCycle at or over capacity would never bank; trimming restores the invariant.
File fails to decode entirelyMoved to progress-corrupt-<ts>.jsonThe user gets a working app and the original is still there to inspect.
Save throwsLogged in DEBUG, otherwise ignoredA failed write must not crash a study session; the next debounce retries.
Session list grows unboundedOldest records dropped past 250Keeps the file small enough to encode on every debounce without cost.
Portability

Progress Export & Import

Sideloaded builds expire weekly, which makes a manual backup path a real requirement rather than a nicety.

Why an Envelope
Validation
A bare ProgressState would round-trip fine, but any .json the user picked would decode into something and silently replace real history. The app marker and format field make a mistaken pick fail loudly.
Decode Before Touching
Safe restore
Import validates completely before anything is replaced, so an invalid file leaves existing progress intact. A confirmed restore then writes through immediately rather than waiting for the debounce.
ProgressStore.swift
// Answers arrive one tap at a time; batching keeps the app off
// the filesystem during a fast session.
private static let saveDebounceNanoseconds: UInt64 = 600_000_000

private func scheduleSave() {
    saveTask?.cancel()
    saveTask = Task { [weak self] in
        try? await Task.sleep(nanoseconds: ProgressStore.saveDebounceNanoseconds)
        guard !Task.isCancelled, let self else { return }
        self.saveNow()
    }
}

func saveNow() {
    saveTask?.cancel()
    saveTask = nil
    guard let fileURL else { return }
    let data = try ProgressStore.encoder.encode(state)
    try data.write(to: fileURL, options: [.atomic])
}
ProgressBackup.swift
// Throws rather than returning a partial result, so a caller can
// never half-apply a bad file.
static func decode(from data: Data) throws -> ProgressBackup {
    let backup: ProgressBackup
    do {
        backup = try decoder.decode(ProgressBackup.self, from: data)
    } catch {
        throw BackupError.notABackupFile
    }

    guard backup.app == marker else { throw BackupError.notABackupFile }
    guard backup.format <= currentFormat else {
        throw BackupError.newerFormat(backup.format)
    }
    return backup
}
Persistence Synthesis

Storage Readout

The cheapest thing that could possibly work, chosen on purpose.

Diagnostic breakdown: Every persistence decision here trades capability for failure surface, because the cost of observing a failure is a full CI round trip. Atomic single-file writes, field-by-field decoding with defaults, quarantine instead of crash, and a validated backup envelope together mean the realistic bad days — an old save file, a hand edit, a wrong file picked at import — all degrade instead of destroying study history.

Quality Blueprint

Tests as the Only Fast Feedback

With no local Simulator, the unit tests carry more weight than they would in a normal iOS project.

69 tests across five files target exactly the logic a screenshot cannot show: the cycle rule, the ordering formula, catalog assembly and definition splitting, backup validation, and bilingual string coverage. They run on a macOS runner in their own CI job, kept separate from the screenshot job so a failing test still produces a picture of the UI.

Total Tests69
Test Files5
Coverage GatheringEnabled
Swift LOC Under Test5,853
Infrastructure

Testing Dependencies

XCTest and in-memory doubles — no mocking framework, no fixture files.

XCTestThe whole suite. Declared as a bundle.unit-test target in project.yml with gatherCoverageData: true on the scheme.
ProgressStore.inMemoryA file-less store used by tests and previews, so persistence behaviour can be exercised without touching the filesystem.
OrderingWeights injectionEvery scoring constant is a parameter with a default, so a test can pin exact weights instead of asserting against tuned magic numbers.
Inline JSON fixturesCatalog and progress fixtures are built in Swift inside the tests rather than loaded from disk, keeping each test self-contained.
Coverage Map

What Each Test File Proves

Each file maps to one part of the engine that has no visual signal.

Test FileTestsCovers
ReportingAndStringsTests.swift16Stats aggregation, run-completion logic, and bilingual string-table coverage.
VocabCatalogLoaderTests.swift16Book/section ordering against catalog.json, usage-tip splitting and its edge cases, legacy word-format fallback, graceful degradation on missing content.
AdaptiveOrderingTests.swift14The weakness-scoring formula, ordering determinism, and the Extra Practice queue.
WordStatsCycleTests.swift13The five-answer cycle rule, recap display, and resilience against a hand-edited or truncated save file.
ProgressBackupTests.swift10Backup envelope validation, format rejection, and non-destructive failure on a wrong file.
Test Selection

What is deliberately not tested

There are no view tests and no UI automation. A screenshot proves the layout renders; a unit test proves the numbers underneath it are right. Duplicating either in the other place would add maintenance without adding signal.

Determinism

Why exact assertions are possible

Because ordering has a total tie-break and the aggregator is pure, tests assert exact sequences and exact counts rather than tolerances. Any drift in the scoring constants shows up as a hard failure.

Executable Examples

Running the Suite

Runnable only inside CI or on a real Mac — there is no local target for it.

run_tests.sh
# Generate the project first — TOEFLVocab.xcodeproj is never committed
xcodegen generate

# Pick whatever iPhone simulator the runner happens to provide
UDID=$(xcrun simctl list devices available \
  | grep -m 1 "iPhone" \
  | grep -Eo '[0-9A-F]{8}-([0-9A-F]{4}-){3}[0-9A-F]{12}')

xcodebuild test \
  -project TOEFLVocab.xcodeproj \
  -scheme TOEFLVocab \
  -destination "id=$UDID" \
  -derivedDataPath buildTests \
  CODE_SIGNING_ALLOWED=NO

Why the simulator is discovered, not pinned: hard-coding a device name breaks whenever the runner image changes its installed simulators. Grepping the first available iPhone keeps the workflow stable across Xcode updates without pinning an image version.

Automation Blueprint

The No-Local-Build Workflow

The constraint that shaped the repository: the only machine that compiles this code is a GitHub runner.

Development happens entirely on Windows. There is no Mac, no Xcode GUI, no Interface Builder, no SwiftUI Previews and no local Simulator — a change is only verified once it reaches CI. The .xcodeproj is never written by hand: XcodeGen regenerates it from project.yml on every run, and it is excluded from version control entirely.

Public by necessityA public repository is what keeps GitHub Actions' macOS runner minutes unlimited and free.
Free-tier signingA free personal Apple ID means nothing can depend on paid entitlements — no push, no iCloud, no App Groups.
Disposable project fileGenerated fresh every run, so project drift and merge conflicts in a .pbxproj simply cannot happen.
Automation Infrastructure

Pipeline Tools & Services

Everything that runs between a push on Windows and an app on a phone.

GitHub ActionsFour jobs on ubuntu-latest and macos-14, with a concurrency group that cancels superseded runs on the same ref.
XcodeGenInstalled via Homebrew on each macOS job and run as xcodegen generate to rebuild the project from project.yml.
xcodebuildDrives test, Simulator build, and the Release device build with CODE_SIGNING_ALLOWED=NO.
simctlBoots a simulator, sets light/dark appearance, launches the app with a screenshot argument, and captures each frame.
Artifact UploadPublishes the 18-image screenshot set on every run, and an unsigned .ipa on tagged runs.
SideloadlySigns and installs the downloaded .ipa with a free Apple ID — the only step performed by hand.
Pipeline Topology

Four-Job CI Pipeline

A cheap Ubuntu gate first, then two parallel macOS jobs, then a tag-only release build.

01content-lint

Ubuntu, ~15s. Validates both JSON files and gates everything below.

02unit-tests

macOS. xcodebuild test — the only fast feedback on the engine.

03simulator-check

macOS. Builds, verifies the bundle, screenshots every screen twice.

04device-build

Tags only. Release build packaged as an unsigned .ipa.

JobRunner / TriggerPurpose
content-lintubuntu · every push/PRvalidate_content.py --strict. Gates the macOS jobs so a bad JSON edit never costs a runner minute.
unit-testsmacos-14 · every push/PRRuns all 69 tests. Kept separate so a test failure still lets the screenshot job produce a picture.
simulator-checkmacos-14 · every push/PRBuilds, verifies bundled JSON and the compiled icon are really inside the .app, then captures 18 screenshots.
device-buildmacos-14 · tags matching v*Release build with signing disabled, zipped into a Payload/ structure as an unsigned .ipa.
Verification Layer

Deterministic Screenshot Capture

Nine screens in two appearances, seeded so nothing is ever photographed empty.

Launch-Argument Harness
DEBUG only
The app is relaunched once per screen with a screenshot:<name> argument. The harness seeds deterministic progress and opens straight to that page — so there is no UI automation script to go stale as the interface changes.
Count as a Guard
18 expected
A crash on launch still yields a screenshot — of the home screen — so a plausible file count is not proof of success. A short count, however, is proof of failure, and the job fails below 18.
capture_every_screen.sh
BUNDLE=io.github.a1mohamad.toeflvocab
SCREENS="library book section practice practice-revealed summary reports settings about"

for appearance in light dark; do
  xcrun simctl ui "$UDID" appearance "$appearance"
  for screen in $SCREENS; do
    xcrun simctl terminate "$UDID" "$BUNDLE" || true
    xcrun simctl launch "$UDID" "$BUNDLE" "screenshot:$screen"
    sleep 3
    xcrun simctl io "$UDID" screenshot "screenshots/${screen}-${appearance}.png"
  done
done

COUNT=$(ls -1 screenshots | wc -l | tr -d ' ')
if [ "$COUNT" -lt 18 ]; then
  echo "::error::expected 18 screenshots, got $COUNT"
  exit 1
fi
Release Confidence

The Bundle Verification Guard

A failure mode that builds, launches and screenshots perfectly while shipping nothing.

If the buildPhase: resources entry in project.yml were ever dropped, XcodeGen would silently produce a working app with no vocabulary data at all. It would build, launch, and screenshot to an empty library — a failure that looks exactly like a UI bug and would cost a full round trip to diagnose. The job checks the artifact instead of trusting the build.

What Is Asserted

Three checks on the .app

  • vocabs.json and catalog.json present in the bundle
  • Assets.car exists — the asset catalog really compiled
  • At least one AppIcon*.png at the bundle root
The Subtle Part

Why the icon check keys on PNGs

A single-size asset catalog makes actool write CFBundleIcons rather than CFBundleIconName, so asserting on the latter is a false alarm. The rendered PNGs at the bundle root are what iOS actually draws, so their presence is the real signal.

Why it matters here specifically: a missing icon is invisible until the app is on a home screen, which on this project means a tag, an artifact download and a Sideloadly run. Catching it in CI turns a multi-step manual discovery into a red check mark.

Distribution Step

Tagged Release & Sideloading

Regular pushes only run the free Simulator check; a real installable build needs a version tag.

release.sh
# Only a v* tag triggers the device-build job
git tag v1.2
git push origin v1.2

# Then: download the unsigned .ipa artifact from the run,
# sign and install it with Sideloadly using a free Apple ID.
package_unsigned_ipa.sh
xcodebuild build \
  -project TOEFLVocab.xcodeproj \
  -scheme TOEFLVocab \
  -sdk iphoneos \
  -configuration Release \
  CODE_SIGNING_ALLOWED=NO \
  -derivedDataPath buildDevice

APP_PATH=$(find buildDevice/Build/Products -maxdepth 2 -name "*.app" | head -n 1)
mkdir -p Payload
cp -R "$APP_PATH" Payload/
zip -qr TOEFLVocab.ipa Payload
Free-Tier LimitConsequenceStatus
Sideloaded builds expireRe-signing needed roughly weeklyKnown limit
App ID registration capOnly a few IDs per rolling 7-day windowKnown limit
No paid entitlementsNo push, iCloud sync, or App GroupsBy design
No App Store distributionInstall is manual via SideloadlyBy design
Port Blueprint

The Same App, Rebuilt in Kotlin

Screen for screen, rule for rule. What changed is the platform underneath — and the constraint that shaped it did not change at all.

Identical Content
Byte-for-byte

vocabs.json and catalog.json ship in the Android APK unchanged from the iOS bundle. Adding a word updates both apps; neither file was forked.

Identical Engine
Deterministic

The Laplace-smoothed weakness score, the five-answer cycle rule and the Extra Practice queue were reimplemented in Kotlin to produce the same ordering for the same history.

Identical Save File
Interchangeable

The serialized progress format matches, so a backup exported on iPhone restores on Android and back again. Cross-platform migration needs no converter.

Rebuilt Surface
35 Kotlin files

Every view, icon, chart, navigation route and speech call had to be re-chosen for the platform. 8,373 lines of Kotlin against 5,853 of Swift.

Infrastructure

Android Libraries & Dependencies

Platform libraries and one serialization package — the dependency budget stayed as tight as the iOS build's.

Jetpack Compose (BOM 2024.12)Every screen, the tab shell, the modal practice session and the Reports bar chart, which is drawn on a Canvas rather than pulling in a charting library.
Material 3 + Material Icons ExtendedThe icon set behind the AppSymbol enum, including the auto-mirrored chevron and back-arrow variants that keep Persian flipping correctly.
android.speech.tts.TextToSpeechOn-device pronunciation with US, UK and Australian accents. Falls back through the other two locales when an engine reports LANG_MISSING_DATA.
kotlinx.serialization 1.7.3The only non-platform dependency in the app target — decodes the bundled catalog and encodes the progress file.
Gradle (Kotlin DSL) + AGP 8.7Versions live in a single libs.versions.toml catalog. R8 folds BuildConfig.DEBUG and strips the screenshot harness from release builds.
Jetpack ComposeMaterial 3kotlinx.serializationTextToSpeechGradleJUnit
Translation Matrix

Every Place the Two Codebases Genuinely Differ

Everything a user can see or do is the same. These are the decisions the platform forced, and the reasoning behind each.

AreaiOSAndroidWhy It Changed
IconsSF Symbols, named by stringAppSymbol enum → Material IconsThe two icon sets do not correspond, so each had to be re-chosen. An enum turns a wrong name into a compile error instead of a blank square discovered in CI.
Rounded font.system(design: .rounded)Platform defaultAndroid has no rounded system face. The weights carrying the hierarchy are unchanged.
ChartsSwift ChartsCompose CanvasSwift Charts is a system framework; every Android equivalent is third-party. Twelve bars did not justify a dependency.
NavigationNavigationStack(path:)List<Route> + BackHandlerThe original rewrites its path array wholesale — "back to menu" empties it. A navigation graph would turn those single state changes into multi-step animations.
Value typesMutating structsImmutable data classesSwift gets value semantics free. In Kotlin a shared mutable record would both alias into the progress map and fail to trigger recomposition.
Speech rateAVSpeechUtterance, 0.5 is normalTextToSpeech, 1.0 is normalThe stored value keeps AVFoundation's scale so a settings blob stays interchangeable; conversion happens at the point the service talks to the engine.
Rate clampingClamped in initclampedSpeechRate accessorA Kotlin data class cannot rewrite a val in init, so every read site uses the clamped accessor instead.
Voice availabilityEvery English locale shipsMay report LANG_MISSING_DATAAndroid engines can lack a locale the user never downloaded, so the accent falls back rather than going silent.
HapticsUIFeedbackGeneratorView.performHapticFeedbackThe View API is what respects the system-wide haptics setting and needs no permission.
Backup filesfileExporter / fileImporterStorage Access FrameworkSame two-step flow — decode and validate before prompting to overwrite.
Reduce motionaccessibilityReduceMotionAnimator duration scale of 0Android has no single switch; the duration scale is the signal apps are expected to read.
Debug harness#if DEBUG, launch argumentBuildConfig.DEBUG, intent extraam start passes extras, not argv. R8 folds the constant and strips the branch from release.
DistributionSideloadly, expires weeklySigned APK, no expiryAndroid has no equivalent of the free-provisioning seven-day limit.
Implementation Core

Value Semantics: The One Difference That Reached Every File

Swift's mutating struct is the single idiom with no safe Kotlin equivalent — and correcting it changed the shape of the whole model layer.

Why a Direct Translation Breaks
Two failures
A Swift struct copies on assignment, so stats.record(...) mutates a private copy that is then written back into the map. Translating that to a Kotlin class with var fields produces a record that is shared, not copied — so mutating it aliases straight into the stored progress map. Worse for Compose, mutating an object in place leaves its identity unchanged, so recomposition never fires and the checklist silently stops updating on screen.
The Kotlin Shape
Immutable
record() returns a new WordStats via copy() rather than mutating the receiver, and returns it wrapped in a RecordResult that also carries whether the five-answer cycle completed — the same boolean the Swift version returns through its mutating call. The caller stores the returned value, which gives Compose a new identity to diff against and makes aliasing structurally impossible.
PracticeModels.swift
mutating func record(correct isCorrect: Bool,
                     at date: Date = Date()) -> Bool {
    attempts += 1
    if isCorrect {
        correct += 1
        consecutiveCorrect += 1
    } else {
        incorrect += 1
        consecutiveCorrect = 0
    }
    lastAnsweredAt = date
    currentCycle.append(isCorrect)

    guard currentCycle.count >= Self.cycleLength else { return false }
    // ... bank the cycle, then reset it
}
PracticeModels.kt
fun record(correct: Boolean,
           at: Instant = Instant.now()): RecordResult {
    val nextCycle = currentCycle + correct
    val base = copy(
        attempts = attempts + 1,
        correct = if (correct) this.correct + 1 else this.correct,
        incorrect = if (correct) this.incorrect else this.incorrect + 1,
        consecutiveCorrect = if (correct) consecutiveCorrect + 1 else 0,
        lastAnsweredAt = at,
        currentCycle = nextCycle,
    )

    if (nextCycle.size < CYCLE_LENGTH) return RecordResult(base, false)
    // ... bank the cycle into lastCycle, then clear it
}
Port Synthesis

What Shipping Twice Actually Proved

The second build is the evidence that the first one was not a one-off.

The constraint held on a second platform. The iOS build proved a native app could be produced on Windows with no Mac. The Android build repeats the method against a different toolchain — no Android Studio, no local SDK, no emulator, no Compose Previews — and reaches the same place: 59 JVM unit tests, an 18-image screenshot suite per run, and a signed, installable artifact, none of it ever compiled on the developer's machine.

The engine survived translation intact. Because the ordering algorithm is deterministic and score-based, the Kotlin port could be verified against the same expectations as the Swift original rather than by eye. That is what made a screen-for-screen port tractable without an emulator to check it in.

The port exposed one genuine design weakness. Swift's mutating value types had quietly carried a guarantee — copy on assignment — that the Kotlin translation had to make explicit. The Android model layer is arguably the clearer of the two as a result.

Conclusion

Project Synthesis & Roadmap

What a hard constraint produced, and where the project would go if it were lifted.

Delivered: The same complete offline vocabulary trainer on two platforms — 358 entries across 17 sections and two books, an adaptive practice engine with a deterministic weakness score, a five-answer cycle rule, library-wide Extra Practice, full reporting, on-device pronunciation in three accents, grammar usage tips split out of the definitions that carry them, English/Persian UI with RTL held consistent, and progress backup and restore that is interchangeable between the two builds. 29 Swift files / 5,853 lines and 35 Kotlin files / 8,373 lines, covered by 128 unit tests in total.

Engineering result: Both repositories are an answer to the same constraint — the developer's machine can build neither platform. That produced a generated-not-committed Xcode project, a shared Python content validator as the only local feedback loop, a four-job pipeline on each side that gates expensive runners behind a 15-second check, an 18-image screenshot suite per platform standing in for SwiftUI Previews and Compose Previews alike, and an artifact-verification step that catches a build which succeeds while shipping nothing.

Where it stops: Progress still does not sync across devices, and there are no push reminders on either platform. The iOS build additionally expires roughly weekly — a consequence of the free Apple ID tier that the Android build, signed with a self-managed keystore, does not share.

Moving forward:

  • Grow the library beyond 358 entries — a data-only change that now updates both apps at once, already validated by shared tooling.
  • Add spaced-repetition scheduling on top of the existing weakness score, using lastAnsweredAt, which is already recorded on both platforms but unused for timing.
  • Revisit SwiftData once iOS 17 is a safe floor; ProgressState is the only shape that would need porting, and the Kotlin side would have to keep writing the current format to stay interchangeable.
  • Store distribution and cloud sync — gated behind the paid Apple Developer Program on one side and a Play Console account on the other, both deliberately avoided so far.
Amir Mohamad Askari · Mobile Development Lab · 2026