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 */; };
|
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 */,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user