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 from Scanned Content

Builds multiple-choice questions from approved flashcards, preferring distractors with similar answer length to avoid obvious wrong answers and falling back to placeholder options when the pool is too small.

struct QuizGenerator {
    

    func generateQuiz(from flashcards: [Flashcard]) -> [QuizQuestion] {
        let approvedCards = flashcards.filter { $0.approved }
        guard !approvedCards.isEmpty else { return [] }
        
        var questions: [QuizQuestion] = []
        questions.reserveCapacity(approvedCards.count)
        
        let allAnswers = uniqueAnswers(from: flashcards.map { $0.answer })
        
        for (index, card) in approvedCards.enumerated() {
            let correctAnswer = card.answer
            let normalizedCorrect = normalizedAnswer(correctAnswer)
    
            var pool = allAnswers.filter { normalizedAnswer($0) != normalizedCorrect }

            let similarLengthPool = pool.filter { abs($0.count - correctAnswer.count) <= 10 }
            if !similarLengthPool.isEmpty {
                pool = similarLengthPool
            }
            
            var distractors: [String] = []
            for answer in pool.shuffled() {
                distractors.append(answer)
                if distractors.count == 3 { break }
            }

            if distractors.count < 3 {
                let fallbacks = ["None of the above", "Not sure", "Not listed"]
                for fallback in fallbacks {
                    let normalizedFallback = normalizedAnswer(fallback)
                    // Ensure fallback isn't duplicate or matching correct answer
                    if normalizedFallback != normalizedCorrect
                        && !distractors.contains(where: { normalizedAnswer($0) == normalizedFallback }) {
                        distractors.append(fallback)
                    }
                    if distractors.count == 3 { break }
                }
            }
            
            var choices: [String] = [correctAnswer] + distractors
            choices.shuffle()
            
            let correctIndex = choices.firstIndex(of: correctAnswer) ?? 0
            let question = QuizQuestion(
                prompt: card.question,
                choices: choices,
                correctIndex: correctIndex,
                explanation: card.answer,
                sourceIndex: index
            )
            questions.append(question)
        }
        
        return questions
    }
    
    private func normalizedAnswer(_ answer: String) -> String {
        answer.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
    }
    
    private func uniqueAnswers(from answers: [String]) -> [String] {
        var seen: Set<String> = []
        var result: [String] = []
        result.reserveCapacity(answers.count)
        
        for answer in answers {
            let key = normalizedAnswer(answer)
            if seen.insert(key).inserted {
                result.append(answer)
            }
        }
        return result
    }
}

AI Flashcard Generation

Sends scanned text to an on-device LanguageModelSession and uses the @Generable macro for structured, type-safe output, returning ready-to-review flashcard pairs without any network calls.

struct AICardGenerator {

    func generateCards(from text: String) async throws -> [Flashcard] {
        let session = LanguageModelSession()

        let prompt = """
        Analyze the following scanned text and extract the most important concepts.
        Create a set of high quality flashcards for a student.

        TEXT:
        \(text)
        """

        let response = try await session.respond(to: prompt, generating: FlashcardSetModel.self)

        return response.content.cards.map {
            Flashcard(question: $0.question, answer: $0.answer, approved: false)
        }
    }
}

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