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:
@@ -18,7 +18,6 @@
|
||||
14242FD1F500D296D41E927C /* FeatureReferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C3E36BDC2540AF2A67AEEB1 /* FeatureReferenceView.swift */; };
|
||||
1A230C01A045F0C095BFBD35 /* PracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EA0FA4F9149B9D8E197ADE9 /* PracticeView.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 */; };
|
||||
218E982FC4267949F82AABAD /* SharedModels in Frameworks */ = {isa = PBXBuildFile; productRef = 4A4D7B02884EBA9ACD93F0FD /* SharedModels */; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -368,7 +366,6 @@
|
||||
AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */,
|
||||
221920B9BD6DC6F084093975 /* ExtraStudyStore.swift */,
|
||||
A661ADF1141176EE96774138 /* BookSpeechController.swift */,
|
||||
3148F06ABA1BFCA36CB21E15 /* VocabImageService.swift */,
|
||||
A2ACC4C35491174257770941 /* VerbReviewStore.swift */,
|
||||
);
|
||||
path = Services;
|
||||
@@ -799,7 +796,6 @@
|
||||
33BFEC0F0DEFC8A0E1FD8009 /* BookSpeechController.swift in Sources */,
|
||||
2CB1E7454C1C04C2A9A06D57 /* BookVoicePickerSheet.swift in Sources */,
|
||||
C3F567971CE8379CCC0AA9ED /* GuideCrossLinks.swift in Sources */,
|
||||
200E933E672F8B011DC16769 /* VocabImageService.swift in Sources */,
|
||||
419DF099F0270E65D53932FD /* VocabFlashcardPracticeView.swift in Sources */,
|
||||
12117B0704F799292AF1DBB6 /* VocabMultipleChoicePracticeView.swift in Sources */,
|
||||
5D8A5E1ED1BE4F86A9907FA7 /* VerbReviewStore.swift in Sources */,
|
||||
|
||||
@@ -43,7 +43,6 @@ struct ConjugaApp: App {
|
||||
@State private var verbExampleCache = VerbExampleCache()
|
||||
@State private var reflexiveStore = ReflexiveVerbStore()
|
||||
@State private var youtubeVideoStore = YouTubeVideoStore()
|
||||
@State private var vocabImageService = VocabImageService()
|
||||
|
||||
let localContainer: ModelContainer
|
||||
let cloudContainer: ModelContainer
|
||||
@@ -120,7 +119,6 @@ struct ConjugaApp: App {
|
||||
.environment(verbExampleCache)
|
||||
.environment(reflexiveStore)
|
||||
.environment(youtubeVideoStore)
|
||||
.environment(vocabImageService)
|
||||
.task {
|
||||
let needsSeed = await DataLoader.needsSeeding(container: localContainer)
|
||||
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))
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
VerbIllustration(verb: verb)
|
||||
|
||||
exampleBlock(for: verb)
|
||||
|
||||
ratingButtons
|
||||
@@ -279,81 +277,3 @@ enum VocabVerbPool {
|
||||
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 {
|
||||
VStack(spacing: 16) {
|
||||
answerFeedback(verb)
|
||||
VerbIllustration(verb: verb)
|
||||
exampleBlock(for: verb)
|
||||
ratingButtons
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user