QuickStudy

Turn scans and PDFs into a study-ready deck in minutes.

Overview

Turn scans and PDFs into a study-ready deck in minutes.

QuickStudy takes you from notes to practice without the busywork. Scan a page or import a PDF, let the app generate flashcards, then approve what’s worth keeping before you study. The AI speeds up the process, but you stay in control of the deck.

Highlights

  • One flow from scan or PDF import to a study-ready deck
  • Review and approve cards before saving so your deck stays clean
  • Flashcard practice mode built around quick swipe sessions
  • Quiz mode generated from your approved set
  • Local-first with no account required
  • Saves sets on-device with AppStorage and UserDefaults

Tech Stack

  • SwiftUI
  • VisionKit
  • PDFKit
  • Foundation Models
  • AppStorage
  • UserDefaults

Team: Solo

Role: iOS Developer

Timeline: Dec 2025 - Feb 2026

Challenges & Solutions

01

OCR Messiness

Challenge: Scans can come back messy with VisionKit OCR, broken words, merged lines, and random characters that mess up what comes next.

Solution: I added a cleanup pass on the extracted text before generating anything. It trims junk characters, fixes spacing and line breaks, and reshapes the text into something the model can actually work with.

Result: Cleaner flashcards with way less manual correction.

02

AI Availability & Failures

Challenge: On-device generation with Foundation Models isn’t always available. Some devices don’t support it, and even supported devices can fail from low memory or a generation error.

Solution: I built a backup flow that still creates a usable deck by breaking the cleaned text into card-sized chunks when AI can’t run. I also added clear messaging in the UI so users know what happened instead of getting a silent failure.

Result: Card generation stays dependable across supported devices, not just the newest ones.

03

Noisy Generated Cards

Challenge: Not every generated flashcard is worth studying, repeats, filler, or low-value cards end up cluttering the set.

Solution: I built an approval step where users quickly toggle cards on or off before saving the deck. Only approved cards make it into study and quiz mode.

Result: Users practice only what they chose to keep, so decks feel focused instead of noisy.

Screenshots

Import Source screen

Import Source

Choose between scanning a handwritten note or importing an existing PDF. VisionKit handles the OCR pass before anything goes to the model.

Card Review screen

Card Review

Generated cards land in a review list. Toggle each one on or off, only approved cards move forward into the deck.

Flashcard Practice screen

Flashcard Practice

Swipe through approved cards in a focused practice loop. No distractions just the question, a tap to reveal, and a swipe to move on.

Quiz Mode screen

Quiz Mode

Tap into quiz mode and answer multiple-choice questions auto-generated from your approved cards. Wrong answers circle back at the end.

What I Built

  • Scan + PDF import pipeline using VisionKit OCR and PDFKit
  • Text cleanup pass to normalize OCR output before generation
  • On-device flashcard generation with Foundation Models
  • Backup generation path when on-device AI isn’t available
  • Approve and toggle flow to curate a deck before saving
  • Flashcard practice mode with swipe-first interaction
  • Quiz mode built from approved cards with distractor selection
  • On-device persistence for saved decks and study history using AppStorage and UserDefaults

Code Snippets

Highlights from the core systems I implemented in QuickStudy.

Handwritting Processing for OCR

Runs a Core Image pipeline to desaturate, boost contrast, and sharpen a captured image before passing it to Vision for OCR, significantly improving handwriting recognition accuracy.

static func preprocessForHandwriting(_ image: UIImage) -> CGImage? {
    guard let cgImage = image.cgImage else { return nil }
    let ciImage = CIImage(cgImage: cgImage)

    let controls = ciImage.applyingFilter(
        "CIColorControls",
        parameters: [
            kCIInputSaturationKey: 0.0,      // Grayscale
            kCIInputContrastKey: 1.45,       // Boost contrast
            kCIInputBrightnessKey: 0.05
        ]
    )

    let sharpened = controls.applyingFilter(
        "CIUnsharpMask",
        parameters: [kCIInputRadiusKey: 2.0, kCIInputIntensityKey: 0.85]
    )

    let context = CIContext(options: nil)
    return context.createCGImage(sharpened, from: sharpened.extent)
}

Quiz Generation Protocol

Defines a shared interface for card generation so the app can swap between on-device and external AI without changing any calling code.

protocol CardGenerating {
    func generateCards(from text: String) async throws -> [AIFlashcard]
    func generateDistractors(
        question: String, correctAnswer: String,
        otherAnswers: [String], sourceText: String
    ) async throws -> [String]
    func generateQuiz(
        cards: [(question: String, answer: String)],
        sourceText: String
    ) async throws -> [AIQuizQuestionModel]
}

Different API Requests

Handles both OpenAI-compatible and Anthropic APIs through a single request path, switching auth headers and body format based on the selected provider.

switch apiFormat {
case .openAI:
    request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
    let body = OpenAIChatRequest(
        model: model,
        messages: [
            .init(role: "system", content: systemMessage),
            .init(role: "user", content: prompt)
        ],
        temperature: 0.3
    )
    request.httpBody = try JSONEncoder().encode(body)

case .anthropic:
    request.setValue(apiKey, forHTTPHeaderField: "x-api-key")
    request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version")
    let body = AnthropicMessagesRequest(
        model: model,
        max_tokens: 4096,
        system: systemMessage,
        messages: [.init(role: "user", content: prompt)],
        temperature: 0.3
    )
    request.httpBody = try JSONEncoder().encode(body)
}

Secure API Key Storage

Stores API keys as encrypted generic passwords using iOS Keychain Services, keeping credentials out of plaintext storage.

enum KeychainManager {
    private static let service = "com.jaidenhenley.quickstudy"
    private static let account = "external-api-key"

    static func saveAPIKey(_ key: String) throws {
        let data = Data(key.utf8)
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account
        ]
        SecItemDelete(query as CFDictionary)
        let attributes = query.merging([kSecValueData as String: data]) { _, new in new }
        let status = SecItemAdd(attributes as CFDictionary, nil)
        guard status == errSecSuccess else {
            throw CardGenerationError.keychainError(status)
        }
    }
}

Next Iterations

Next I’m keeping it focused and polishing what’s already there. I want OCR to feel more consistent with clearer scan feedback and fewer messy results, and I want the review step to be faster with bulk approve and quick edits so fixing a bad card doesn’t slow everything down. On the study side, I’m tightening the swipe experience, making progress clearer, and adding a simple “review missed questions” loop in quiz mode. If I have time after that, I’ll add lightweight stats like accuracy and streaks so you can actually see improvement.

Outcome

QuickStudy came from me being tired of how much work it takes to turn notes into something I can actually study. Normally you scan or import, clean things up, manually write cards, then finally start reviewing. I built QuickStudy to collapse that into one flow so you can get to practice fast, and the approval step is the key, AI handles the heavy lifting, but nothing gets saved until you choose what belongs in your deck.

More Case Studies