diff --git a/Conjuga/Conjuga.xcodeproj/project.pbxproj b/Conjuga/Conjuga.xcodeproj/project.pbxproj index 5d12523..1b8e55c 100644 --- a/Conjuga/Conjuga.xcodeproj/project.pbxproj +++ b/Conjuga/Conjuga.xcodeproj/project.pbxproj @@ -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 = ""; }; 2931634BEB33B93429CE254F /* VocabFlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabFlashcardView.swift; sourceTree = ""; }; 30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreakCalendarView.swift; sourceTree = ""; }; - 3148F06ABA1BFCA36CB21E15 /* VocabImageService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VocabImageService.swift; sourceTree = ""; }; 340B1F22929DC7C1DEB0EA8A /* BookLibraryView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookLibraryView.swift; sourceTree = ""; }; 34C67DD1A1CB9B8B5A2BDCED /* CheckpointExamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckpointExamView.swift; sourceTree = ""; }; 3540936F058728CFD87B1A1E /* textbook_vocab.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = textbook_vocab.json; sourceTree = ""; }; @@ -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 */, diff --git a/Conjuga/Conjuga/ConjugaApp.swift b/Conjuga/Conjuga/ConjugaApp.swift index a57b00e..1062a72 100644 --- a/Conjuga/Conjuga/ConjugaApp.swift +++ b/Conjuga/Conjuga/ConjugaApp.swift @@ -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 { diff --git a/Conjuga/Conjuga/Services/VocabImageService.swift b/Conjuga/Conjuga/Services/VocabImageService.swift deleted file mode 100644 index 4af40dc..0000000 --- a/Conjuga/Conjuga/Services/VocabImageService.swift +++ /dev/null @@ -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] = [:] - - /// 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 { [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 - } -} diff --git a/Conjuga/Conjuga/Views/Practice/Vocab/VocabFlashcardPracticeView.swift b/Conjuga/Conjuga/Views/Practice/Vocab/VocabFlashcardPracticeView.swift index 5dbdfb3..66ec806 100644 --- a/Conjuga/Conjuga/Views/Practice/Vocab/VocabFlashcardPracticeView.swift +++ b/Conjuga/Conjuga/Views/Practice/Vocab/VocabFlashcardPracticeView.swift @@ -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 - } - } -} diff --git a/Conjuga/Conjuga/Views/Practice/Vocab/VocabMultipleChoicePracticeView.swift b/Conjuga/Conjuga/Views/Practice/Vocab/VocabMultipleChoicePracticeView.swift index a01d1fc..2ff7f6a 100644 --- a/Conjuga/Conjuga/Views/Practice/Vocab/VocabMultipleChoicePracticeView.swift +++ b/Conjuga/Conjuga/Views/Practice/Vocab/VocabMultipleChoicePracticeView.swift @@ -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 }