Fixes #34 — drop AI-generated images from verb vocab practice

Image Playground's .illustration style can't depict abstract verbs —
"dar" (to give) generated a Shiba Inu in a meadow, "saber" a cake.
Verbs are actions, not objects; a static illustration rarely helps and
the irrelevant output was pure noise.

Removed:
  - VerbIllustration view (was in VocabFlashcardPracticeView).
  - VocabImageService.swift entirely — VerbIllustration was its only
    consumer.
  - vocabImageService from the app environment in ConjugaApp.
  - The illustration row from both vocab session reveal panes.

Vocab card reveal is now: Spanish infinitive + example sentence +
rating buttons. The example sentence already carries the learning
context.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-05-15 14:38:01 -05:00
parent f0eb75a28a
commit 900a927f95
5 changed files with 0 additions and 221 deletions
@@ -18,7 +18,6 @@
14242FD1F500D296D41E927C /* FeatureReferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C3E36BDC2540AF2A67AEEB1 /* FeatureReferenceView.swift */; }; 14242FD1F500D296D41E927C /* FeatureReferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C3E36BDC2540AF2A67AEEB1 /* FeatureReferenceView.swift */; };
1A230C01A045F0C095BFBD35 /* PracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */; }; 1A230C01A045F0C095BFBD35 /* PracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */; };
1C2636790E70B6BC7FFCC904 /* DailyLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0313D24F96E6A0039C34341F /* DailyLog.swift */; }; 1C2636790E70B6BC7FFCC904 /* DailyLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0313D24F96E6A0039C34341F /* DailyLog.swift */; };
200E933E672F8B011DC16769 /* VocabImageService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3148F06ABA1BFCA36CB21E15 /* VocabImageService.swift */; };
20B71403A8D305C29C73ADA2 /* StemChangeConjugationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F92BCE1A6720E47FCD26BADC /* StemChangeConjugationView.swift */; }; 20B71403A8D305C29C73ADA2 /* StemChangeConjugationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F92BCE1A6720E47FCD26BADC /* StemChangeConjugationView.swift */; };
218E982FC4267949F82AABAD /* SharedModels in Frameworks */ = {isa = PBXBuildFile; productRef = 4A4D7B02884EBA9ACD93F0FD /* SharedModels */; }; 218E982FC4267949F82AABAD /* SharedModels in Frameworks */ = {isa = PBXBuildFile; productRef = 4A4D7B02884EBA9ACD93F0FD /* SharedModels */; };
261E582449BED6EF41881B04 /* AdaptiveContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B16FF4C52457CD8CD703532 /* AdaptiveContainer.swift */; }; 261E582449BED6EF41881B04 /* AdaptiveContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B16FF4C52457CD8CD703532 /* AdaptiveContainer.swift */; };
@@ -179,7 +178,6 @@
2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataReader.swift; sourceTree = "<group>"; }; 2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataReader.swift; sourceTree = "<group>"; };
2931634BEB33B93429CE254F /* VocabFlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabFlashcardView.swift; sourceTree = "<group>"; }; 2931634BEB33B93429CE254F /* VocabFlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabFlashcardView.swift; sourceTree = "<group>"; };
30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreakCalendarView.swift; sourceTree = "<group>"; }; 30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreakCalendarView.swift; sourceTree = "<group>"; };
3148F06ABA1BFCA36CB21E15 /* VocabImageService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VocabImageService.swift; sourceTree = "<group>"; };
340B1F22929DC7C1DEB0EA8A /* BookLibraryView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookLibraryView.swift; sourceTree = "<group>"; }; 340B1F22929DC7C1DEB0EA8A /* BookLibraryView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookLibraryView.swift; sourceTree = "<group>"; };
34C67DD1A1CB9B8B5A2BDCED /* CheckpointExamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckpointExamView.swift; sourceTree = "<group>"; }; 34C67DD1A1CB9B8B5A2BDCED /* CheckpointExamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckpointExamView.swift; sourceTree = "<group>"; };
3540936F058728CFD87B1A1E /* textbook_vocab.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = textbook_vocab.json; sourceTree = "<group>"; }; 3540936F058728CFD87B1A1E /* textbook_vocab.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = textbook_vocab.json; sourceTree = "<group>"; };
@@ -368,7 +366,6 @@
AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */, AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */,
221920B9BD6DC6F084093975 /* ExtraStudyStore.swift */, 221920B9BD6DC6F084093975 /* ExtraStudyStore.swift */,
A661ADF1141176EE96774138 /* BookSpeechController.swift */, A661ADF1141176EE96774138 /* BookSpeechController.swift */,
3148F06ABA1BFCA36CB21E15 /* VocabImageService.swift */,
A2ACC4C35491174257770941 /* VerbReviewStore.swift */, A2ACC4C35491174257770941 /* VerbReviewStore.swift */,
); );
path = Services; path = Services;
@@ -799,7 +796,6 @@
33BFEC0F0DEFC8A0E1FD8009 /* BookSpeechController.swift in Sources */, 33BFEC0F0DEFC8A0E1FD8009 /* BookSpeechController.swift in Sources */,
2CB1E7454C1C04C2A9A06D57 /* BookVoicePickerSheet.swift in Sources */, 2CB1E7454C1C04C2A9A06D57 /* BookVoicePickerSheet.swift in Sources */,
C3F567971CE8379CCC0AA9ED /* GuideCrossLinks.swift in Sources */, C3F567971CE8379CCC0AA9ED /* GuideCrossLinks.swift in Sources */,
200E933E672F8B011DC16769 /* VocabImageService.swift in Sources */,
419DF099F0270E65D53932FD /* VocabFlashcardPracticeView.swift in Sources */, 419DF099F0270E65D53932FD /* VocabFlashcardPracticeView.swift in Sources */,
12117B0704F799292AF1DBB6 /* VocabMultipleChoicePracticeView.swift in Sources */, 12117B0704F799292AF1DBB6 /* VocabMultipleChoicePracticeView.swift in Sources */,
5D8A5E1ED1BE4F86A9907FA7 /* VerbReviewStore.swift in Sources */, 5D8A5E1ED1BE4F86A9907FA7 /* VerbReviewStore.swift in Sources */,
-2
View File
@@ -43,7 +43,6 @@ struct ConjugaApp: App {
@State private var verbExampleCache = VerbExampleCache() @State private var verbExampleCache = VerbExampleCache()
@State private var reflexiveStore = ReflexiveVerbStore() @State private var reflexiveStore = ReflexiveVerbStore()
@State private var youtubeVideoStore = YouTubeVideoStore() @State private var youtubeVideoStore = YouTubeVideoStore()
@State private var vocabImageService = VocabImageService()
let localContainer: ModelContainer let localContainer: ModelContainer
let cloudContainer: ModelContainer let cloudContainer: ModelContainer
@@ -120,7 +119,6 @@ struct ConjugaApp: App {
.environment(verbExampleCache) .environment(verbExampleCache)
.environment(reflexiveStore) .environment(reflexiveStore)
.environment(youtubeVideoStore) .environment(youtubeVideoStore)
.environment(vocabImageService)
.task { .task {
let needsSeed = await DataLoader.needsSeeding(container: localContainer) let needsSeed = await DataLoader.needsSeeding(container: localContainer)
if needsSeed { if needsSeed {
@@ -1,134 +0,0 @@
import CryptoKit
import Foundation
import ImagePlayground
import SwiftUI
import UIKit
/// On-device illustrative images for vocab cards, via Apple Intelligence
/// Image Playground (iOS 18.2+). Generated once per (deck, ES, EN) tuple,
/// cached to disk in the app's Caches directory.
///
/// On devices/versions without Image Playground the service still works
/// it just always returns nil and the calling view falls back to a placeholder.
@MainActor
@Observable
final class VocabImageService {
private let cacheRoot: URL = {
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
let dir = caches.appendingPathComponent("VocabImages", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}()
/// In-flight generations keyed by cache key so callers asking for the same
/// image while it's being produced share the same task.
private var inFlight: [String: Task<UIImage?, Never>] = [:]
/// Look up a cached image synchronously. Returns nil if not yet generated.
func cachedImage(forKey key: String) -> UIImage? {
let url = cacheFileURL(forKey: key)
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
return UIImage(contentsOfFile: url.path)
}
/// Generate (or fetch cached) image for the given English concept.
/// Returns nil on unsupported devices or if generation fails.
func image(
forKey key: String,
concept englishConcept: String
) async -> UIImage? {
if let cached = cachedImage(forKey: key) {
return cached
}
if let existing = inFlight[key] {
return await existing.value
}
let task = Task<UIImage?, Never> { [cacheRoot] in
await Self.generate(concept: englishConcept, savingTo: Self.fileURL(in: cacheRoot, forKey: key))
}
inFlight[key] = task
let result = await task.value
inFlight[key] = nil
return result
}
/// Stable cache key for a vocab card. Hash so it survives weird characters
/// in the front/back text.
static func cacheKey(deckId: String, spanish: String, english: String) -> String {
let raw = "\(deckId)|\(spanish)|\(english)"
let digest = SHA256.hash(data: Data(raw.utf8))
return digest.map { String(format: "%02x", $0) }.joined()
}
/// Cached result of a one-time probe true if `ImageCreator()` succeeds
/// on this device. Apple Intelligence only lights up on compatible
/// hardware + when the user has it enabled in Settings; `ImageCreator()`
/// throws otherwise.
nonisolated(unsafe) private static var probedAvailability: Bool? = nil
nonisolated(unsafe) private static let probeLock = NSLock()
/// Whether on-device image generation is available. Cached after first call.
static var isAvailable: Bool {
probeLock.lock()
defer { probeLock.unlock() }
if let cached = probedAvailability { return cached }
// Synchronous probe via Task isn't possible here; default to true on
// iOS 18.2+ and let the first actual generation succeed-or-fail
// produce the real signal. Older OS = never available.
guard #available(iOS 18.2, *) else {
probedAvailability = false
return false
}
return true
}
/// Mark the service as unavailable based on a real failure (called from
/// `generate(...)` when ImageCreator() init throws).
private static func markUnavailable() {
probeLock.lock()
probedAvailability = false
probeLock.unlock()
}
// MARK: - Internal
private func cacheFileURL(forKey key: String) -> URL {
Self.fileURL(in: cacheRoot, forKey: key)
}
private static func fileURL(in root: URL, forKey key: String) -> URL {
root.appendingPathComponent("\(key).png")
}
private static func generate(concept: String, savingTo destination: URL) async -> UIImage? {
guard #available(iOS 18.2, *) else { return nil }
let creator: ImageCreator
do {
creator = try await ImageCreator()
} catch {
print("[VocabImageService] ImageCreator unavailable: \(error)")
markUnavailable()
return nil
}
let sequence = creator.images(
for: [.text(concept)],
style: .illustration,
limit: 1
)
do {
for try await result in sequence {
let cgImage = result.cgImage
let uiImage = UIImage(cgImage: cgImage)
if let png = uiImage.pngData() {
try? png.write(to: destination, options: .atomic)
}
return uiImage
}
} catch {
print("[VocabImageService] generation failed: \(error)")
}
return nil
}
}
@@ -104,8 +104,6 @@ struct VocabFlashcardPracticeView: View {
.font(.title.weight(.semibold)) .font(.title.weight(.semibold))
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
VerbIllustration(verb: verb)
exampleBlock(for: verb) exampleBlock(for: verb)
ratingButtons ratingButtons
@@ -279,81 +277,3 @@ enum VocabVerbPool {
return pool.shuffled() return pool.shuffled()
} }
} }
// MARK: - Verb illustration
/// AI-generated image for the verb's English concept. Generates on first
/// reveal and caches to disk. Falls back to a styled SF Symbol when Image
/// Playground is unavailable.
struct VerbIllustration: View {
let verb: Verb
@Environment(VocabImageService.self) private var service
@State private var image: UIImage?
@State private var isGenerating: Bool = false
private var cacheKey: String {
VocabImageService.cacheKey(
deckId: "verb",
spanish: verb.infinitive,
english: verb.english
)
}
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 16)
.fill(.purple.opacity(0.08))
.frame(height: 200)
if let image {
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(maxHeight: 200)
.clipShape(RoundedRectangle(cornerRadius: 16))
} else if isGenerating {
VStack(spacing: 6) {
ProgressView()
Text("Generating illustration…")
.font(.caption2)
.foregroundStyle(.secondary)
}
} else if !VocabImageService.isAvailable {
placeholder
} else {
ProgressView()
}
}
.frame(maxWidth: .infinity)
.task(id: cacheKey, loadImage)
}
private var placeholder: some View {
VStack(spacing: 6) {
Image(systemName: "photo")
.font(.title2)
.foregroundStyle(.purple.opacity(0.55))
Text("Image generation unavailable on this device")
.font(.caption2)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
}
}
@Sendable
private func loadImage() async {
if let cached = service.cachedImage(forKey: cacheKey) {
image = cached
return
}
guard VocabImageService.isAvailable else { return }
isGenerating = true
let result = await service.image(forKey: cacheKey, concept: verb.english)
isGenerating = false
if let result {
image = result
}
}
}
@@ -95,7 +95,6 @@ struct VocabMultipleChoicePracticeView: View {
private func revealedContent(_ verb: Verb) -> some View { private func revealedContent(_ verb: Verb) -> some View {
VStack(spacing: 16) { VStack(spacing: 16) {
answerFeedback(verb) answerFeedback(verb)
VerbIllustration(verb: verb)
exampleBlock(for: verb) exampleBlock(for: verb)
ratingButtons ratingButtons
} }