diff --git a/Readmigo/Core/Badge/BadgeAssetManager.swift b/Readmigo/Core/Badge/BadgeAssetManager.swift new file mode 100644 index 0000000..862c670 --- /dev/null +++ b/Readmigo/Core/Badge/BadgeAssetManager.swift @@ -0,0 +1,242 @@ +import Foundation +import zlib + +/// Manages .badge file downloading, caching, and extraction. +/// Each .badge file is a zip archive containing models, textures, and manifest. +@MainActor +final class BadgeAssetManager: ObservableObject { + static let shared = BadgeAssetManager() + + @Published var downloadProgress: [String: Double] = [:] + + private let fileManager = FileManager.default + + private var cacheDirectory: URL { + let caches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first! + return caches.appendingPathComponent("BadgeAssets", isDirectory: true) + } + + private init() { + try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true) + } + + // MARK: - Public API + + /// Returns the extracted directory path for a badge, downloading and extracting if needed. + func ensureBadgeAvailable(badgeId: String, assetUrl: String) async throws -> String { + let extractDir = extractDirectory(for: badgeId) + + // Check if already extracted + if fileManager.fileExists(atPath: extractDir.appendingPathComponent("manifest.json").path) { + return extractDir.path + } + + // Download + let zipPath = try await downloadBadge(badgeId: badgeId, from: assetUrl) + + // Extract + try extractBadge(zipPath: zipPath, to: extractDir) + + return extractDir.path + } + + /// Check if a badge is already cached locally + func isCached(badgeId: String) -> Bool { + let manifestPath = extractDirectory(for: badgeId).appendingPathComponent("manifest.json") + return fileManager.fileExists(atPath: manifestPath.path) + } + + /// Clear cached badge assets + func clearCache() throws { + try fileManager.removeItem(at: cacheDirectory) + try fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true) + } + + /// Clear a specific badge from cache + func clearCache(for badgeId: String) throws { + let dir = extractDirectory(for: badgeId) + if fileManager.fileExists(atPath: dir.path) { + try fileManager.removeItem(at: dir) + } + let zipFile = cacheDirectory.appendingPathComponent("\(badgeId).badge") + if fileManager.fileExists(atPath: zipFile.path) { + try fileManager.removeItem(at: zipFile) + } + } + + /// Total size of cached badge assets + func cacheSize() -> Int64 { + guard let enumerator = fileManager.enumerator(at: cacheDirectory, includingPropertiesForKeys: [.fileSizeKey]) else { + return 0 + } + var totalSize: Int64 = 0 + for case let fileURL as URL in enumerator { + let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey]) + totalSize += Int64(values?.fileSize ?? 0) + } + return totalSize + } + + // MARK: - Private + + private func extractDirectory(for badgeId: String) -> URL { + cacheDirectory.appendingPathComponent(badgeId, isDirectory: true) + } + + private func downloadBadge(badgeId: String, from urlString: String) async throws -> URL { + guard let url = URL(string: urlString) else { + throw BadgeAssetError.invalidURL + } + + let destPath = cacheDirectory.appendingPathComponent("\(badgeId).badge") + + let (tempURL, response) = try await URLSession.shared.download(from: url, delegate: nil) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + throw BadgeAssetError.downloadFailed + } + + // Move to cache + if fileManager.fileExists(atPath: destPath.path) { + try fileManager.removeItem(at: destPath) + } + try fileManager.moveItem(at: tempURL, to: destPath) + + return destPath + } + + private func extractBadge(zipPath: URL, to destDir: URL) throws { + // Remove existing extraction if any + if fileManager.fileExists(atPath: destDir.path) { + try fileManager.removeItem(at: destDir) + } + try fileManager.createDirectory(at: destDir, withIntermediateDirectories: true) + + // .badge files are standard zip archives. + // Use Apple's built-in zip support via NSFileCoordinator/NSURL. + // We read the zip data and extract entries manually using zlib (linked by default on iOS). + guard let archive = try? Data(contentsOf: zipPath) else { + throw BadgeAssetError.extractionFailed + } + + // Rename to .zip for potential system handling, then extract + let zipCopy = destDir.appendingPathComponent("_temp.zip") + try archive.write(to: zipCopy) + + // Use spawned unzip task via posix_spawn on iOS + let task = URLSession.shared + _ = task // suppress warning + + // Fallback: iterate zip entries using minimal zip reader + try extractZipData(archive, to: destDir) + + // Clean up temp + try? fileManager.removeItem(at: zipCopy) + + // Verify manifest exists + let manifestPath = destDir.appendingPathComponent("manifest.json") + guard fileManager.fileExists(atPath: manifestPath.path) else { + throw BadgeAssetError.invalidBadgeFile + } + } + + /// Minimal zip extraction using zlib (available on all Apple platforms). + /// Handles standard deflate-compressed and stored entries. + private func extractZipData(_ data: Data, to destDir: URL) throws { + try data.withUnsafeBytes { rawBuffer in + guard let basePtr = rawBuffer.baseAddress else { throw BadgeAssetError.extractionFailed } + let bytes = basePtr.assumingMemoryBound(to: UInt8.self) + let count = data.count + var offset = 0 + + while offset + 30 <= count { + // Local file header signature: PK\x03\x04 + guard bytes[offset] == 0x50, bytes[offset+1] == 0x4B, + bytes[offset+2] == 0x03, bytes[offset+3] == 0x04 else { break } + + let compressionMethod = UInt16(bytes[offset+8]) | (UInt16(bytes[offset+9]) << 8) + let compressedSize = Int(UInt32(bytes[offset+18]) | (UInt32(bytes[offset+19]) << 8) | + (UInt32(bytes[offset+20]) << 16) | (UInt32(bytes[offset+21]) << 24)) + let uncompressedSize = Int(UInt32(bytes[offset+22]) | (UInt32(bytes[offset+23]) << 8) | + (UInt32(bytes[offset+24]) << 16) | (UInt32(bytes[offset+25]) << 24)) + let nameLength = Int(UInt16(bytes[offset+26]) | (UInt16(bytes[offset+27]) << 8)) + let extraLength = Int(UInt16(bytes[offset+28]) | (UInt16(bytes[offset+29]) << 8)) + + let nameStart = offset + 30 + guard nameStart + nameLength <= count else { break } + let nameData = Data(bytes: bytes + nameStart, count: nameLength) + guard let name = String(data: nameData, encoding: .utf8) else { break } + + let dataStart = nameStart + nameLength + extraLength + guard dataStart + compressedSize <= count else { break } + + let fileURL = destDir.appendingPathComponent(name) + + if name.hasSuffix("/") { + // Directory entry + try fileManager.createDirectory(at: fileURL, withIntermediateDirectories: true) + } else { + // Ensure parent directory exists + try fileManager.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) + + let compressedData = Data(bytes: bytes + dataStart, count: compressedSize) + + if compressionMethod == 0 { + // Stored (no compression) + try compressedData.write(to: fileURL) + } else if compressionMethod == 8 { + // Deflate + let decompressed = try decompressDeflate(compressedData, expectedSize: uncompressedSize) + try decompressed.write(to: fileURL) + } else { + // Unsupported compression method, skip + } + } + + offset = dataStart + compressedSize + } + } + } + + private func decompressDeflate(_ data: Data, expectedSize: Int) throws -> Data { + var decompressed = Data(count: expectedSize) + let result = decompressed.withUnsafeMutableBytes { destBuffer in + data.withUnsafeBytes { srcBuffer in + var stream = z_stream() + stream.next_in = UnsafeMutablePointer(mutating: srcBuffer.baseAddress!.assumingMemoryBound(to: UInt8.self)) + stream.avail_in = uInt(data.count) + stream.next_out = destBuffer.baseAddress!.assumingMemoryBound(to: UInt8.self) + stream.avail_out = uInt(expectedSize) + + // -MAX_WBITS for raw deflate (no zlib/gzip header) + guard inflateInit2_(&stream, -15, ZLIB_VERSION, Int32(MemoryLayout.size)) == Z_OK else { + return Z_DATA_ERROR + } + let ret = inflate(&stream, Z_FINISH) + inflateEnd(&stream) + return ret + } + } + guard result == Z_STREAM_END else { throw BadgeAssetError.extractionFailed } + return decompressed + } +} + +// MARK: - Errors + +enum BadgeAssetError: LocalizedError { + case invalidURL + case downloadFailed + case extractionFailed + case invalidBadgeFile + + var errorDescription: String? { + switch self { + case .invalidURL: return "Invalid badge asset URL" + case .downloadFailed: return "Failed to download badge asset" + case .extractionFailed: return "Failed to extract .badge archive" + case .invalidBadgeFile: return "Invalid .badge file: missing manifest.json" + } + } +} diff --git a/Readmigo/Core/Badge/BadgeEngineBridge.swift b/Readmigo/Core/Badge/BadgeEngineBridge.swift new file mode 100644 index 0000000..f345253 --- /dev/null +++ b/Readmigo/Core/Badge/BadgeEngineBridge.swift @@ -0,0 +1,138 @@ +import Foundation +import QuartzCore + +/// Swift wrapper around the badge-engine C API. +/// Maps C lifecycle (create/destroy) to Swift ARC. +/// All calls must happen on the main thread (same as render loop). +@MainActor +final class BadgeEngineBridge { + private var engine: OpaquePointer? + private var callbackBox: CallbackBox? + + init(width: UInt32, height: UInt32, renderMode: BadgeRenderMode, presetsPath: String) { + presetsPath.withCString { cstr in + var config = BadgeEngineConfig( + width: width, + height: height, + render_mode: renderMode, + presets_path: cstr + ) + engine = badge_engine_create(&config) + } + } + + deinit { + if let engine { + badge_engine_destroy(engine) + } + } + + // MARK: - Surface + + func setSurface(_ metalLayer: CAMetalLayer, width: UInt32, height: UInt32) -> Bool { + guard let engine else { return false } + let ptr = Unmanaged.passUnretained(metalLayer).toOpaque() + return badge_engine_set_surface(engine, ptr, width, height) == 0 + } + + // MARK: - Asset Loading + + func loadBadge(path: String) -> Bool { + guard let engine else { return false } + return badge_engine_load_badge(engine, path) == 0 + } + + func unloadBadge() { + guard let engine else { return } + badge_engine_unload_badge(engine) + } + + // MARK: - Render + + func setRenderMode(_ mode: BadgeRenderMode) { + guard let engine else { return } + badge_engine_set_render_mode(engine, mode) + } + + func renderFrame() { + guard let engine else { return } + badge_engine_render_frame(engine) + } + + // MARK: - Input + + func updateGyro(x: Float, y: Float, z: Float) { + guard let engine else { return } + badge_engine_update_gyro(engine, x, y, z) + } + + func onTouch(type: BadgeTouchType, x: Float, y: Float, + pointerCount: Int32 = 1, x2: Float = 0, y2: Float = 0) { + guard let engine else { return } + var event = BadgeTouchEvent( + type: type, x: x, y: y, + pointer_count: pointerCount, x2: x2, y2: y2 + ) + badge_engine_on_touch(engine, &event) + } + + // MARK: - Ceremony + + func playCeremony(_ type: BadgeCeremonyType) { + guard let engine else { return } + badge_engine_play_ceremony(engine, type) + } + + // MARK: - Orientation + + func setOrientation(rx: Float, ry: Float, rz: Float, scale: Float) { + guard let engine else { return } + badge_engine_set_orientation(engine, rx, ry, rz, scale) + } + + // MARK: - Snapshot + + func snapshot(width: UInt32, height: UInt32) -> Data? { + guard let engine else { return nil } + let byteCount = Int(width * height * 4) + var buffer = Data(count: byteCount) + let result = buffer.withUnsafeMutableBytes { ptr in + badge_engine_snapshot( + engine, + ptr.baseAddress!.assumingMemoryBound(to: UInt8.self), + width, height + ) + } + return result == 0 ? buffer : nil + } + + // MARK: - Callbacks + + func setCallback(_ handler: @escaping (BadgeEventType, Int32, String?) -> Void) { + guard let engine else { return } + + let box = CallbackBox(handler: handler) + let ptr = Unmanaged.passRetained(box).toOpaque() + + // Release previous box if any + if let oldBox = callbackBox { + Unmanaged.passUnretained(oldBox).release() + } + callbackBox = box + + badge_engine_set_callback(engine, { eventPtr, userData in + guard let eventPtr, let userData else { return } + let box = Unmanaged.fromOpaque(userData).takeUnretainedValue() + let event = eventPtr.pointee + let str: String? = event.data_str != nil ? String(cString: event.data_str) : nil + box.handler(event.type, event.data, str) + }, ptr) + } + + private class CallbackBox { + let handler: (BadgeEventType, Int32, String?) -> Void + init(handler: @escaping (BadgeEventType, Int32, String?) -> Void) { + self.handler = handler + } + } +} diff --git a/Readmigo/Core/Badge/BadgeEngineService.swift b/Readmigo/Core/Badge/BadgeEngineService.swift new file mode 100644 index 0000000..4952cbd --- /dev/null +++ b/Readmigo/Core/Badge/BadgeEngineService.swift @@ -0,0 +1,192 @@ +import Foundation +import CoreMotion +import QuartzCore +import UIKit + +/// Singleton service managing badge-engine lifecycle, render loop, gyroscope input, +/// and event callbacks (haptics, sounds, ceremony phases). +@MainActor +final class BadgeEngineService: ObservableObject { + static let shared = BadgeEngineService() + + private var bridge: BadgeEngineBridge? + private var displayLink: CADisplayLink? + private let motionManager = CMMotionManager() + + @Published var isReady = false + @Published var currentBadgePath: String? + @Published var ceremonyPhase: Int = -1 + @Published var ceremonyDone = false + + private init() {} + + // MARK: - Lifecycle + + func createEngine(width: UInt32, height: UInt32, mode: BadgeRenderMode) { + let presetsPath = Bundle.main.bundlePath + "/BadgePresets" + bridge = BadgeEngineBridge( + width: width, + height: height, + renderMode: mode, + presetsPath: presetsPath + ) + setupCallback() + } + + func bindSurface(_ layer: CAMetalLayer, width: UInt32, height: UInt32) -> Bool { + bridge?.setSurface(layer, width: width, height: height) ?? false + } + + func loadBadge(path: String) -> Bool { + let result = bridge?.loadBadge(path: path) ?? false + if result { currentBadgePath = path } + return result + } + + func unloadBadge() { + bridge?.unloadBadge() + currentBadgePath = nil + isReady = false + } + + func setRenderMode(_ mode: BadgeRenderMode) { + bridge?.setRenderMode(mode) + } + + // MARK: - Render Loop + + func startRenderLoop() { + guard displayLink == nil else { return } + let link = CADisplayLink(target: self, selector: #selector(renderFrame)) + link.add(to: .main, forMode: .common) + displayLink = link + } + + func stopRenderLoop() { + displayLink?.invalidate() + displayLink = nil + } + + @objc private func renderFrame() { + bridge?.renderFrame() + } + + // MARK: - Gyroscope + + func startGyroscope() { + guard motionManager.isGyroAvailable else { return } + motionManager.gyroUpdateInterval = 1.0 / 60.0 + motionManager.startGyroUpdates(to: .main) { [weak self] data, _ in + guard let data else { return } + self?.bridge?.updateGyro( + x: Float(data.rotationRate.x), + y: Float(data.rotationRate.y), + z: Float(data.rotationRate.z) + ) + } + } + + func stopGyroscope() { + motionManager.stopGyroUpdates() + } + + // MARK: - Touch Forwarding + + func onTouch(type: BadgeTouchType, x: Float, y: Float) { + bridge?.onTouch(type: type, x: x, y: y) + } + + func onPinch(type: BadgeTouchType, x1: Float, y1: Float, x2: Float, y2: Float) { + bridge?.onTouch(type: type, x: x1, y: y1, pointerCount: 2, x2: x2, y2: y2) + } + + // MARK: - Orientation + + func setOrientation(rx: Float, ry: Float, rz: Float, scale: Float) { + bridge?.setOrientation(rx: rx, ry: ry, rz: rz, scale: scale) + } + + // MARK: - Ceremony + + func playCeremony(_ type: BadgeCeremonyType = BADGE_CEREMONY_UNLOCK) { + ceremonyPhase = 0 + ceremonyDone = false + bridge?.playCeremony(type) + } + + // MARK: - Snapshot + + func snapshot(width: UInt32, height: UInt32) -> UIImage? { + guard let data = bridge?.snapshot(width: width, height: height) else { return nil } + let w = Int(width) + let h = Int(height) + let bitsPerComponent = 8 + let bytesPerRow = w * 4 + guard let provider = CGDataProvider(data: data as CFData), + let cgImage = CGImage( + width: w, height: h, + bitsPerComponent: bitsPerComponent, + bitsPerPixel: 32, + bytesPerRow: bytesPerRow, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue), + provider: provider, + decode: nil, + shouldInterpolate: false, + intent: .defaultIntent + ) else { return nil } + return UIImage(cgImage: cgImage) + } + + // MARK: - Cleanup + + func destroy() { + stopRenderLoop() + stopGyroscope() + bridge = nil + isReady = false + currentBadgePath = nil + ceremonyPhase = -1 + ceremonyDone = false + } + + // MARK: - Callback + + private func setupCallback() { + bridge?.setCallback { [weak self] type, data, dataStr in + guard let self else { return } + switch type { + case BADGE_EVENT_READY: + self.isReady = true + case BADGE_EVENT_CEREMONY_PHASE: + self.ceremonyPhase = Int(data) + case BADGE_EVENT_CEREMONY_DONE: + self.ceremonyDone = true + case BADGE_EVENT_HAPTIC: + self.triggerHaptic(style: Int(data)) + case BADGE_EVENT_SOUND: + if let name = dataStr { + self.playSound(name) + } + default: + break + } + } + } + + private func triggerHaptic(style: Int) { + let generator: UIImpactFeedbackGenerator + switch style { + case 0: generator = UIImpactFeedbackGenerator(style: .light) + case 1: generator = UIImpactFeedbackGenerator(style: .medium) + case 2: generator = UIImpactFeedbackGenerator(style: .heavy) + default: generator = UIImpactFeedbackGenerator(style: .medium) + } + generator.impactOccurred() + } + + private func playSound(_ name: String) { + // Sound playback will be integrated with the app's audio service + LoggingService.shared.debug(.app, "[BadgeEngine] Sound requested: \(name)", component: "BadgeEngineService") + } +} diff --git a/Readmigo/Core/Models/Badge.swift b/Readmigo/Core/Models/Badge.swift index 28c1669..9887757 100644 --- a/Readmigo/Core/Models/Badge.swift +++ b/Readmigo/Core/Models/Badge.swift @@ -12,11 +12,11 @@ enum BadgeCategory: String, Codable, CaseIterable { var displayName: String { switch self { - case .reading: return "Reading" - case .vocabulary: return "Vocabulary" - case .streak: return "Streak" - case .milestone: return "Milestone" - case .social: return "Social" + case .reading: return "badge.category.reading".localized + case .vocabulary: return "badge.category.vocabulary".localized + case .streak: return "badge.category.streak".localized + case .milestone: return "badge.category.milestone".localized + case .social: return "badge.category.social".localized } } @@ -41,68 +41,30 @@ enum BadgeTier: String, Codable, CaseIterable { var displayName: String { switch self { - case .bronze: return "Bronze" - case .silver: return "Silver" - case .gold: return "Gold" - case .platinum: return "Platinum" + case .bronze: return "badge.tier.bronze".localized + case .silver: return "badge.tier.silver".localized + case .gold: return "badge.tier.gold".localized + case .platinum: return "badge.tier.platinum".localized } } - /// Legacy hex color string (for light mode compatibility) - var color: String { + var color: Color { switch self { - case .bronze: return "#CD7F32" - case .silver: return "#C0C0C0" - case .gold: return "#FFD700" - case .platinum: return "#E5E4E2" + case .bronze: return Color(hex: "#CD7F32") + case .silver: return Color(hex: "#C0C0C0") + case .gold: return Color(hex: "#FFD700") + case .platinum: return Color(hex: "#E5E4E2") } } - /// Light mode hex color - private var colorLight: String { - switch self { - case .bronze: return "#CD7F32" - case .silver: return "#C0C0C0" - case .gold: return "#FFD700" - case .platinum: return "#E5E4E2" - } - } - - /// Dark mode hex color (slightly brighter for visibility) - private var colorDark: String { + var secondaryColor: Color { switch self { - case .bronze: return "#D4905A" - case .silver: return "#D8D8D8" - case .gold: return "#FFE14D" - case .platinum: return "#F0EFED" + case .bronze: return Color(hex: "#8B4513") + case .silver: return Color(hex: "#808080") + case .gold: return Color(hex: "#FFA500") + case .platinum: return Color(hex: "#A0A0A0") } } - - /// Dynamic color that adapts to color scheme - var adaptiveColor: Color { - Color(UIColor { traitCollection in - let hex = traitCollection.userInterfaceStyle == .dark ? colorDark : colorLight - return UIColor(hex: hex) - }) - } - - /// Secondary gradient color (darker variant) - var secondaryColor: Color { - Color(UIColor { traitCollection in - let hex: String - switch (self, traitCollection.userInterfaceStyle == .dark) { - case (.bronze, false): hex = "#8B4513" - case (.bronze, true): hex = "#A05020" - case (.silver, false): hex = "#808080" - case (.silver, true): hex = "#A0A0A0" - case (.gold, false): hex = "#FFA500" - case (.gold, true): hex = "#FFB833" - case (.platinum, false): hex = "#A0A0A0" - case (.platinum, true): hex = "#C0C0C0" - } - return UIColor(hex: hex) - }) - } } // MARK: - Badge Requirement @@ -113,31 +75,33 @@ struct BadgeRequirement: Codable { let description: String? } -// MARK: - Badge +// MARK: - Badge 3D -struct Badge: Codable, Identifiable { +struct Badge3D: Codable, Identifiable { let id: String let name: String let description: String - let iconUrl: String? let category: BadgeCategory let tier: BadgeTier let requirement: BadgeRequirement + let assetUrl: String + let presetId: String + let storyText: String? let sortOrder: Int? } // MARK: - User Badge (Earned) -struct UserBadge: Codable, Identifiable { +struct UserBadge3D: Codable, Identifiable { let id: String - let badge: Badge + let badge: Badge3D let earnedAt: Date } // MARK: - Badge Progress -struct BadgeProgress: Codable, Identifiable { - let badge: Badge +struct BadgeProgress3D: Codable, Identifiable { + let badge: Badge3D let currentValue: Int let targetValue: Int let progressPercent: Double @@ -151,15 +115,15 @@ struct BadgeProgress: Codable, Identifiable { // MARK: - Response Models -struct BadgesResponse: Codable { - let badges: [Badge] +struct Badge3DListResponse: Codable { + let badges: [Badge3D] } -struct UserBadgesResponse: Codable { - let badges: [UserBadge] +struct UserBadge3DResponse: Codable { + let badges: [UserBadge3D] let total: Int } -struct BadgeProgressResponse: Codable { - let progress: [BadgeProgress] +struct BadgeProgress3DResponse: Codable { + let progress: [BadgeProgress3D] } diff --git a/Readmigo/Core/Network/APIEndpoints.swift b/Readmigo/Core/Network/APIEndpoints.swift index 335a0d2..063cce5 100644 --- a/Readmigo/Core/Network/APIEndpoints.swift +++ b/Readmigo/Core/Network/APIEndpoints.swift @@ -62,11 +62,11 @@ enum APIEndpoints { static func quotesBook(_ bookId: String) -> String { "/quotes/book/\(bookId)" } static func quotesAuthor(_ author: String) -> String { "/quotes/author/\(author)" } - // Badges - static let badges = "/badges" - static let badgesUser = "/badges/user" - static let badgesProgress = "/badges/progress" - static func badge(_ id: String) -> String { "/badges/\(id)" } + // Badge 3D + static let badges3D = "/badges/3d" + static let badges3DUser = "/badges/3d/user" + static let badges3DProgress = "/badges/3d/progress" + static func badge3D(_ id: String) -> String { "/badges/3d/\(id)" } // BookLists static let booklists = "/booklists" diff --git a/Readmigo/Core/Services/ResponseCacheService.swift b/Readmigo/Core/Services/ResponseCacheService.swift index 92c60d1..76642a1 100644 --- a/Readmigo/Core/Services/ResponseCacheService.swift +++ b/Readmigo/Core/Services/ResponseCacheService.swift @@ -72,7 +72,7 @@ actor ResponseCacheService { "quotes_author_", // Quotes by author "quotes_tags", // Quote tags "quotes_authors", // Quote authors - "badges_all", // All badge definitions + "badge3d_all", // All 3D badge definitions "medals_all", // All medal definitions "series_list_", // Series lists "series_detail_", // Series detail @@ -325,7 +325,7 @@ actor ResponseCacheService { let userPrefixes = [ "user_library", "reading_progress_", "reading_stats", "reading_current", "reading_sessions", "favorites", "bookmarks_", "highlights_", - "browsing_history", "badges_user", "badges_progress", "medals_user", + "browsing_history", "badge3d_user", "badge3d_progress", "medals_user", "analytics_", "notifications_", "subscription_status", "usage_", "audiobooks_recently_listened", "postcards_mine", "quotes_favorites", "agora_posts_", "user_profile" @@ -591,10 +591,10 @@ extension ResponseCacheService { static func quotesAuthorsKey() -> String { "quotes_authors" } static func quotesFavoritesKey() -> String { "quotes_favorites" } - // Badges & Medals - static func badgesAllKey() -> String { "badges_all" } - static func badgesUserKey() -> String { "badges_user" } - static func badgesProgressKey() -> String { "badges_progress" } + // Badge 3D & Medals + static func badge3DAllKey() -> String { "badge3d_all" } + static func badge3DUserKey() -> String { "badge3d_user" } + static func badge3DProgressKey() -> String { "badge3d_progress" } // Series static func seriesListKey(page: Int) -> String { "series_list_\(page)" } diff --git a/Readmigo/Features/Badge/BadgeCeremonyView.swift b/Readmigo/Features/Badge/BadgeCeremonyView.swift new file mode 100644 index 0000000..a8731ec --- /dev/null +++ b/Readmigo/Features/Badge/BadgeCeremonyView.swift @@ -0,0 +1,184 @@ +import SwiftUI + +/// Full-screen overlay for badge unlock ceremony. +/// Displays the 3D badge with ceremony animation, haptics, and particle effects. +struct BadgeCeremonyView: View { + let badge: Badge3D + let badgePath: String + let onDismiss: () -> Void + + @StateObject private var engineService = BadgeEngineService.shared + @State private var showTitle = false + @State private var showParticles = false + @State private var backgroundOpacity: Double = 0 + + var body: some View { + ZStack { + // Dark background + Color.black.opacity(backgroundOpacity) + .ignoresSafeArea() + .onTapGesture { + if engineService.ceremonyDone { + dismiss() + } + } + + VStack(spacing: 32) { + Spacer() + + // 3D Badge + BadgeMetalView( + badgePath: badgePath, + renderMode: BADGE_RENDER_FULLSCREEN + ) + .frame(width: 240, height: 240) + .clipShape(Circle()) + .shadow(color: badge.tier.color.opacity(0.6), radius: 30) + + // Badge Name + if showTitle { + VStack(spacing: 12) { + Text("badge.unlocked".localized) + .font(.title3) + .fontWeight(.medium) + .foregroundColor(.white.opacity(0.8)) + + Text(badge.name) + .font(.title) + .fontWeight(.bold) + .foregroundColor(.white) + + Text(badge.tier.displayName) + .font(.headline) + .padding(.horizontal, 16) + .padding(.vertical, 6) + .background(badge.tier.color.opacity(0.3)) + .foregroundColor(badge.tier.color) + .cornerRadius(16) + } + .transition(.opacity.combined(with: .move(edge: .bottom))) + } + + Spacer() + + // Dismiss hint + if engineService.ceremonyDone { + Text("badge.tapToDismiss".localized) + .font(.caption) + .foregroundColor(.white.opacity(0.5)) + .padding(.bottom, 40) + .transition(.opacity) + } + } + + // Particle overlay + if showParticles { + ParticleOverlay(color: badge.tier.color) + .ignoresSafeArea() + .allowsHitTesting(false) + } + } + .onAppear { + startCeremony() + } + .onChange(of: engineService.ceremonyDone) { _, done in + if done { + withAnimation(.easeIn(duration: 0.5)) { + showTitle = true + } + } + } + } + + private func startCeremony() { + withAnimation(.easeIn(duration: 0.3)) { + backgroundOpacity = 0.85 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + engineService.playCeremony() + + withAnimation(.easeIn(duration: 0.3)) { + showParticles = true + } + } + } + + private func dismiss() { + withAnimation(.easeOut(duration: 0.3)) { + backgroundOpacity = 0 + showTitle = false + showParticles = false + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + onDismiss() + } + } +} + +// MARK: - Particle Overlay + +private struct ParticleOverlay: View { + let color: Color + + @State private var particles: [Particle] = [] + @State private var timer: Timer? + + var body: some View { + Canvas { context, size in + for particle in particles { + let rect = CGRect( + x: particle.x * size.width - 3, + y: particle.y * size.height - 3, + width: 6, height: 6 + ) + context.fill( + Circle().path(in: rect), + with: .color(color.opacity(particle.opacity)) + ) + } + } + .onAppear { + generateParticles() + } + .onDisappear { + timer?.invalidate() + } + } + + private func generateParticles() { + for _ in 0..<30 { + particles.append(Particle.random()) + } + + timer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { _ in + Task { @MainActor in + for i in particles.indices { + particles[i].y += particles[i].speed + particles[i].x += sin(particles[i].y * 10) * 0.002 + particles[i].opacity -= 0.005 + if particles[i].opacity <= 0 || particles[i].y > 1.2 { + particles[i] = Particle.random() + } + } + } + } + } + + struct Particle { + var x: Double + var y: Double + var speed: Double + var opacity: Double + + static func random() -> Particle { + Particle( + x: Double.random(in: 0...1), + y: Double.random(in: -0.3...0.5), + speed: Double.random(in: 0.002...0.008), + opacity: Double.random(in: 0.3...1.0) + ) + } + } +} diff --git a/Readmigo/Features/Badge/BadgeDetailView.swift b/Readmigo/Features/Badge/BadgeDetailView.swift new file mode 100644 index 0000000..9367ef0 --- /dev/null +++ b/Readmigo/Features/Badge/BadgeDetailView.swift @@ -0,0 +1,269 @@ +import SwiftUI + +struct BadgeDetailView: View { + let badge: Badge3D + let isEarned: Bool + let earnedDate: Date? + let progress: BadgeProgress3D? + + @StateObject private var engineService = BadgeEngineService.shared + @State private var badgePath: String? + @State private var isLoadingAsset = true + + var body: some View { + ScrollView { + VStack(spacing: 32) { + // 3D Badge Display + ZStack { + // Glow background + Circle() + .fill( + RadialGradient( + colors: [ + badge.tier.color.opacity(isEarned ? 0.5 : 0.2), + Color.clear + ], + center: .center, + startRadius: 60, + endRadius: 140 + ) + ) + .frame(width: 280, height: 280) + + if let path = badgePath { + BadgeMetalView( + badgePath: path, + renderMode: BADGE_RENDER_FULLSCREEN + ) + .frame(width: 200, height: 200) + .clipShape(Circle()) + } else { + // Fallback while loading + ZStack { + Circle() + .fill( + LinearGradient( + colors: [badge.tier.color, badge.tier.secondaryColor], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 120, height: 120) + + if isLoadingAsset { + ProgressView() + .tint(.white) + } else { + Image(systemName: badge.category.icon) + .font(.largeTitle) + .foregroundColor(.white) + } + + if !isEarned { + Circle() + .fill(Color.black.opacity(0.4)) + .frame(width: 120, height: 120) + Image(systemName: "lock.fill") + .font(.title) + .foregroundColor(.white.opacity(0.8)) + } + } + } + } + .padding(.top, 16) + + // Tier + Text(badge.tier.displayName) + .font(.headline) + .padding(.horizontal, 16) + .padding(.vertical, 6) + .background(badge.tier.color.opacity(0.2)) + .foregroundColor(badge.tier.color) + .cornerRadius(16) + + // Badge Info + VStack(spacing: 12) { + Text(badge.name) + .font(.title2) + .fontWeight(.bold) + + Text(badge.description) + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + HStack(spacing: 4) { + Image(systemName: badge.category.icon) + Text(badge.category.displayName) + } + .font(.subheadline) + .foregroundColor(.secondary) + } + .padding(.horizontal) + + // Status + statusSection + + // Requirement + requirementSection + + // Story (back of badge) + if let story = badge.storyText, isEarned { + VStack(alignment: .leading, spacing: 12) { + Text("badge.story".localized) + .font(.headline) + + Text(story) + .font(.body) + .foregroundColor(.secondary) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.systemBackground)) + .cornerRadius(12) + } + .padding(.horizontal) + } + + Spacer(minLength: 40) + } + .padding(.vertical, 16) + } + .background(Color(.systemGroupedBackground)) + .navigationTitle("badge.details".localized) + .navigationBarTitleDisplayMode(.inline) + .task { + await loadBadgeAsset() + } + } + + // MARK: - Status Section + + private var statusSection: some View { + VStack(spacing: 16) { + if isEarned { + VStack(spacing: 8) { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text("badge.earned".localized) + .font(.headline) + .foregroundColor(.green) + } + + if let date = earnedDate { + Text("badge.unlockedOn".localized(with: date.formatted(date: .long, time: .omitted))) + .font(.subheadline) + .foregroundColor(.secondary) + } + } + .padding() + .frame(maxWidth: .infinity) + .background(Color.green.opacity(0.1)) + .cornerRadius(12) + } else if let progress { + VStack(spacing: 12) { + HStack { + Text("badge.progress".localized) + .font(.headline) + Spacer() + Text("\(Int(progress.progressPercent))%") + .font(.headline) + .foregroundColor(badge.tier.color) + } + + ProgressView(value: progress.progressPercent / 100) + .tint(badge.tier.color) + .scaleEffect(y: 2) + + Text("\(progress.currentValue) / \(progress.targetValue)") + .font(.subheadline) + .foregroundColor(.secondary) + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(12) + .shadow(color: Color.black.opacity(0.05), radius: 5) + } else { + VStack(spacing: 8) { + Image(systemName: "lock.fill") + .font(.title2) + .foregroundColor(.secondary) + Text("badge.notStarted".localized) + .font(.subheadline) + .foregroundColor(.secondary) + } + .padding() + .frame(maxWidth: .infinity) + .background(Color(.systemGray6)) + .cornerRadius(12) + } + } + .padding(.horizontal) + } + + // MARK: - Requirement Section + + private var requirementSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("badge.howToEarn".localized) + .font(.headline) + + HStack(spacing: 12) { + Image(systemName: "target") + .foregroundColor(.accentColor) + .frame(width: 32) + + VStack(alignment: .leading, spacing: 4) { + Text(badge.requirement.description ?? requirementDescription) + .font(.body) + + Text("badge.requirementTarget".localized(with: badge.requirement.target)) + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.systemBackground)) + .cornerRadius(12) + } + .padding(.horizontal) + } + + private var requirementDescription: String { + let type = badge.requirement.type + let target = badge.requirement.target + + switch type { + case "books_finished": + return "Finish \(target) book\(target > 1 ? "s" : "")" + case "words_learned": + return "Learn \(target) words" + case "streak_days": + return "Maintain a \(target)-day reading streak" + case "reading_minutes": + return "Read for \(target) minutes total" + case "reviews_completed": + return "Complete \(target) vocabulary reviews" + default: + return "Complete the requirement" + } + } + + // MARK: - Asset Loading + + private func loadBadgeAsset() async { + isLoadingAsset = true + defer { isLoadingAsset = false } + + do { + let path = try await BadgeAssetManager.shared.ensureBadgeAvailable( + badgeId: badge.id, + assetUrl: badge.assetUrl + ) + badgePath = path + } catch { + LoggingService.shared.error(.app, "[Badge] Failed to load asset: \(error)", component: "BadgeDetailView") + } + } +} diff --git a/Readmigo/Features/Badge/BadgeListView.swift b/Readmigo/Features/Badge/BadgeListView.swift new file mode 100644 index 0000000..397c254 --- /dev/null +++ b/Readmigo/Features/Badge/BadgeListView.swift @@ -0,0 +1,331 @@ +import SwiftUI + +struct BadgeListView: View { + @EnvironmentObject var authManager: AuthManager + @StateObject private var viewModel = BadgeViewModel() + @State private var selectedBadge: Badge3D? + + var body: some View { + if !authManager.isAuthenticated { + LoginRequiredView(feature: "achievements") + } else { + ScrollView { + VStack(spacing: 24) { + // Earned Badges Carousel + if !viewModel.earnedBadges.isEmpty { + earnedSection + } + + // In Progress + if !viewModel.inProgressBadges.isEmpty { + inProgressSection + } + + // Category Filter + categoryFilter + + // All Badges Grid + badgeGrid + + Spacer(minLength: 40) + } + .padding(.vertical) + } + .navigationTitle("badge.achievements".localized) + .elegantRefreshable { + await viewModel.loadAll() + } + .overlay { + if viewModel.isLoading && viewModel.allBadges.isEmpty { + ProgressView() + } + } + .navigationDestination(item: $selectedBadge) { badge in + BadgeDetailView( + badge: badge, + isEarned: viewModel.isEarned(badge), + earnedDate: viewModel.earnedDate(for: badge), + progress: viewModel.progress(for: badge) + ) + } + .task { + await viewModel.loadAll() + } + } + } + + // MARK: - Earned Section + + private var earnedSection: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Image(systemName: "trophy.fill") + .foregroundColor(.yellow) + Text("badge.earned".localized) + .font(.title3) + .fontWeight(.bold) + Spacer() + Text("\(viewModel.earnedBadges.count)") + .font(.subheadline) + .foregroundColor(.secondary) + } + .padding(.horizontal) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 16) { + ForEach(viewModel.earnedBadges) { userBadge in + BadgeCardCell( + badge: userBadge.badge, + isEarned: true, + earnedDate: userBadge.earnedAt + ) { + selectedBadge = userBadge.badge + } + } + } + .padding(.horizontal) + } + } + } + + // MARK: - In Progress Section + + private var inProgressSection: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Image(systemName: "arrow.up.circle.fill") + .foregroundColor(.blue) + Text("badge.inProgress".localized) + .font(.title3) + .fontWeight(.bold) + Spacer() + } + .padding(.horizontal) + + VStack(spacing: 12) { + ForEach(viewModel.inProgressBadges) { progress in + BadgeProgressRow(progress: progress) { + selectedBadge = progress.badge + } + .padding(.horizontal) + } + } + } + } + + // MARK: - Category Filter + + private var categoryFilter: some View { + VStack(alignment: .leading, spacing: 16) { + Text("badge.all".localized) + .font(.title3) + .fontWeight(.bold) + .padding(.horizontal) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + CategoryChip( + title: "common.all".localized, + icon: "square.grid.2x2", + isSelected: viewModel.selectedCategory == nil + ) { + viewModel.selectedCategory = nil + } + + ForEach(BadgeCategory.allCases, id: \.self) { category in + CategoryChip( + title: category.displayName, + icon: category.icon, + isSelected: viewModel.selectedCategory == category + ) { + viewModel.selectedCategory = category + } + } + } + .padding(.horizontal) + } + } + } + + // MARK: - Badge Grid + + private var badgeGrid: some View { + LazyVGrid(columns: [ + GridItem(.adaptive(minimum: 100), spacing: 16) + ], spacing: 16) { + ForEach(viewModel.filteredBadges) { badge in + BadgeCardCell( + badge: badge, + isEarned: viewModel.isEarned(badge), + progress: viewModel.progress(for: badge) + ) { + selectedBadge = badge + } + } + } + .padding(.horizontal) + } +} + +// MARK: - Badge Card Cell + +private struct BadgeCardCell: View { + let badge: Badge3D + var isEarned: Bool = false + var earnedDate: Date? = nil + var progress: BadgeProgress3D? = nil + var onTap: (() -> Void)? + + var body: some View { + Button(action: { onTap?() }) { + VStack(spacing: 12) { + // Badge icon placeholder (will be replaced with 3D rendering) + ZStack { + Circle() + .fill( + LinearGradient( + colors: [badge.tier.color, badge.tier.secondaryColor], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 64, height: 64) + + Image(systemName: badge.category.icon) + .font(.title2) + .foregroundColor(.white) + + if !isEarned { + Circle() + .fill(Color.black.opacity(0.4)) + .frame(width: 64, height: 64) + + Image(systemName: "lock.fill") + .foregroundColor(.white.opacity(0.8)) + .font(.caption) + } + } + + Text(badge.name) + .font(.caption) + .fontWeight(.medium) + .foregroundColor(isEarned ? .primary : .secondary) + .lineLimit(2) + .multilineTextAlignment(.center) + + Text(badge.tier.displayName) + .font(.caption2) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(badge.tier.color.opacity(0.2)) + .foregroundColor(badge.tier.color) + .cornerRadius(4) + + if !isEarned, let progress { + VStack(spacing: 4) { + ProgressView(value: progress.progressPercent / 100) + .tint(badge.tier.color) + Text("\(progress.currentValue)/\(progress.targetValue)") + .font(.caption2) + .foregroundColor(.secondary) + } + } + + if isEarned, let date = earnedDate { + Text(date, style: .date) + .font(.caption2) + .foregroundColor(.secondary) + } + } + .frame(width: 100) + .padding() + .background(Color(.systemBackground)) + .cornerRadius(12) + .shadow(color: Color.black.opacity(0.05), radius: 5, x: 0, y: 2) + } + .buttonStyle(.plain) + } +} + +// MARK: - Badge Progress Row + +private struct BadgeProgressRow: View { + let progress: BadgeProgress3D + var onTap: (() -> Void)? + + var body: some View { + Button(action: { onTap?() }) { + HStack(spacing: 16) { + ZStack { + Circle() + .fill(progress.badge.tier.color.opacity(0.2)) + .frame(width: 48, height: 48) + Image(systemName: progress.badge.category.icon) + .foregroundColor(progress.badge.tier.color) + } + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(progress.badge.name) + .font(.subheadline) + .fontWeight(.medium) + Spacer() + Text("\(Int(progress.progressPercent))%") + .font(.caption) + .foregroundColor(.secondary) + } + + ProgressView(value: progress.progressPercent / 100) + .tint(progress.badge.tier.color) + + Text("\(progress.currentValue) / \(progress.targetValue)") + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(12) + .shadow(color: Color.black.opacity(0.05), radius: 5, x: 0, y: 2) + } + .buttonStyle(.plain) + } +} + +// MARK: - Category Chip + +private struct CategoryChip: View { + let title: String + let icon: String + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 6) { + Image(systemName: icon) + .font(.caption) + Text(title) + .font(.subheadline) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(isSelected ? Color.accentColor : Color(.systemGray6)) + .foregroundColor(isSelected ? .white : .primary) + .cornerRadius(20) + } + .buttonStyle(.plain) + } +} + +// MARK: - Badge3D Hashable for NavigationDestination + +extension Badge3D: Hashable { + static func == (lhs: Badge3D, rhs: Badge3D) -> Bool { + lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} diff --git a/Readmigo/Features/Badge/BadgeMetalView.swift b/Readmigo/Features/Badge/BadgeMetalView.swift new file mode 100644 index 0000000..824051f --- /dev/null +++ b/Readmigo/Features/Badge/BadgeMetalView.swift @@ -0,0 +1,139 @@ +import SwiftUI +import Metal +import QuartzCore + +/// UIViewRepresentable that hosts a CAMetalLayer for badge-engine rendering. +/// Each instance owns its own BadgeEngineBridge for independent rendering. +struct BadgeMetalView: UIViewRepresentable { + let badgePath: String + let renderMode: BadgeRenderMode + + func makeUIView(context: Context) -> BadgeMetalUIView { + let view = BadgeMetalUIView() + view.backgroundColor = .clear + context.coordinator.view = view + return view + } + + func updateUIView(_ view: BadgeMetalUIView, context: Context) { + context.coordinator.configure(badgePath: badgePath, renderMode: renderMode) + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + static func dismantleUIView(_ view: BadgeMetalUIView, coordinator: Coordinator) { + coordinator.tearDown() + } + + // MARK: - Coordinator + + @MainActor + class Coordinator { + weak var view: BadgeMetalUIView? + private var bridge: BadgeEngineBridge? + private var displayLink: CADisplayLink? + private var currentBadgePath: String? + private var isConfigured = false + + func configure(badgePath: String, renderMode: BadgeRenderMode) { + guard let view, !isConfigured || currentBadgePath != badgePath else { return } + + // Ensure metal layer is sized + let metalLayer = view.metalLayer + let bounds = view.bounds + guard bounds.width > 0, bounds.height > 0 else { + // View not yet laid out, will be called again + return + } + + metalLayer.frame = bounds + metalLayer.drawableSize = CGSize( + width: bounds.width * UIScreen.main.scale, + height: bounds.height * UIScreen.main.scale + ) + + let width = UInt32(metalLayer.drawableSize.width) + let height = UInt32(metalLayer.drawableSize.height) + + // Create engine if needed + if bridge == nil { + let presetsPath = Bundle.main.bundlePath + "/BadgePresets" + bridge = BadgeEngineBridge( + width: width, + height: height, + renderMode: renderMode, + presetsPath: presetsPath + ) + } + + // Bind surface + _ = bridge?.setSurface(metalLayer, width: width, height: height) + + // Load badge + if badge_engine_load_badge != nil { + _ = bridge?.loadBadge(path: badgePath) + } + currentBadgePath = badgePath + + // Start render loop + startRenderLoop(renderMode: renderMode) + isConfigured = true + } + + private func startRenderLoop(renderMode: BadgeRenderMode) { + guard displayLink == nil else { return } + let link = CADisplayLink(target: self, selector: #selector(renderFrame)) + // 30fps for embedded, 60fps for fullscreen + if renderMode == BADGE_RENDER_EMBEDDED { + link.preferredFrameRateRange = CAFrameRateRange(minimum: 24, maximum: 30, preferred: 30) + } + link.add(to: .main, forMode: .common) + displayLink = link + } + + @objc private func renderFrame() { + bridge?.renderFrame() + } + + func tearDown() { + displayLink?.invalidate() + displayLink = nil + bridge = nil + } + } +} + +// MARK: - Metal UIView + +class BadgeMetalUIView: UIView { + let metalLayer = CAMetalLayer() + + override init(frame: CGRect) { + super.init(frame: frame) + setupMetalLayer() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupMetalLayer() + } + + private func setupMetalLayer() { + metalLayer.device = MTLCreateSystemDefaultDevice() + metalLayer.pixelFormat = .bgra8Unorm + metalLayer.contentsScale = UIScreen.main.scale + metalLayer.isOpaque = false + layer.addSublayer(metalLayer) + } + + override func layoutSubviews() { + super.layoutSubviews() + metalLayer.frame = bounds + metalLayer.drawableSize = CGSize( + width: bounds.width * contentScaleFactor, + height: bounds.height * contentScaleFactor + ) + } +} diff --git a/Readmigo/Features/Badge/BadgeViewModel.swift b/Readmigo/Features/Badge/BadgeViewModel.swift new file mode 100644 index 0000000..8714771 --- /dev/null +++ b/Readmigo/Features/Badge/BadgeViewModel.swift @@ -0,0 +1,125 @@ +import Foundation + +/// ViewModel managing badge list, progress, and earned state. +/// Handles API calls and coordinates with BadgeAssetManager for 3D asset availability. +@MainActor +final class BadgeViewModel: ObservableObject { + @Published var allBadges: [Badge3D] = [] + @Published var earnedBadges: [UserBadge3D] = [] + @Published var badgeProgress: [BadgeProgress3D] = [] + @Published var isLoading = false + @Published var error: String? + @Published var selectedCategory: BadgeCategory? + + private let assetManager = BadgeAssetManager.shared + + // MARK: - Computed Properties + + var earnedBadgeIds: Set { + Set(earnedBadges.map { $0.badge.id }) + } + + var filteredBadges: [Badge3D] { + guard let category = selectedCategory else { return allBadges } + return allBadges.filter { $0.category == category } + } + + var inProgressBadges: [BadgeProgress3D] { + badgeProgress.filter { !$0.isComplete && $0.currentValue > 0 } + } + + func isEarned(_ badge: Badge3D) -> Bool { + earnedBadgeIds.contains(badge.id) + } + + func progress(for badge: Badge3D) -> BadgeProgress3D? { + badgeProgress.first { $0.badge.id == badge.id } + } + + func earnedDate(for badge: Badge3D) -> Date? { + earnedBadges.first { $0.badge.id == badge.id }?.earnedAt + } + + // MARK: - Data Loading + + func loadAll() async { + isLoading = true + error = nil + + await withTaskGroup(of: Void.self) { group in + group.addTask { await self.fetchAllBadges() } + group.addTask { await self.fetchEarnedBadges() } + group.addTask { await self.fetchProgress() } + } + + isLoading = false + } + + private func fetchAllBadges() async { + let cacheKey = CacheKeys.badge3DAllKey() + if let cached: Badge3DListResponse = await ResponseCacheService.shared.get(cacheKey, type: Badge3DListResponse.self) { + allBadges = cached.badges + return + } + + do { + let response: Badge3DListResponse = try await APIClient.shared.request( + endpoint: APIEndpoints.badges3D + ) + allBadges = response.badges + await ResponseCacheService.shared.set(response, for: cacheKey, ttl: .bookList) + } catch { + self.error = error.localizedDescription + } + } + + private func fetchEarnedBadges() async { + let cacheKey = CacheKeys.badge3DUserKey() + if let cached: UserBadge3DResponse = await ResponseCacheService.shared.get(cacheKey, type: UserBadge3DResponse.self) { + earnedBadges = cached.badges + return + } + + do { + let response: UserBadge3DResponse = try await APIClient.shared.request( + endpoint: APIEndpoints.badges3DUser + ) + earnedBadges = response.badges + await ResponseCacheService.shared.set(response, for: cacheKey, ttl: .daily) + } catch { + self.error = error.localizedDescription + } + } + + private func fetchProgress() async { + let cacheKey = CacheKeys.badge3DProgressKey() + if let cached: BadgeProgress3DResponse = await ResponseCacheService.shared.get(cacheKey, type: BadgeProgress3DResponse.self) { + badgeProgress = cached.progress + return + } + + do { + let response: BadgeProgress3DResponse = try await APIClient.shared.request( + endpoint: APIEndpoints.badges3DProgress + ) + badgeProgress = response.progress + await ResponseCacheService.shared.set(response, for: cacheKey, ttl: .daily) + } catch { + self.error = error.localizedDescription + } + } + + // MARK: - Asset Management + + func ensureAsset(for badge: Badge3D) async -> String? { + do { + return try await assetManager.ensureBadgeAvailable( + badgeId: badge.id, + assetUrl: badge.assetUrl + ) + } catch { + LoggingService.shared.error(.app, "[Badge] Asset download failed for \(badge.id): \(error)", component: "BadgeViewModel") + return nil + } + } +} diff --git a/Readmigo/Features/Badges/BadgeCardView.swift b/Readmigo/Features/Badges/BadgeCardView.swift deleted file mode 100644 index 68604cc..0000000 --- a/Readmigo/Features/Badges/BadgeCardView.swift +++ /dev/null @@ -1,143 +0,0 @@ -import SwiftUI - -struct BadgeCardView: View { - let badge: Badge - var isEarned: Bool = false - var earnedDate: Date? - var progress: BadgeProgress? - var onTap: (() -> Void)? - - var body: some View { - Button(action: { onTap?() }) { - VStack(spacing: 12) { - // Badge Icon - ZStack { - Circle() - .fill(tierGradient) - .frame(width: 64, height: 64) - - if let iconUrl = badge.iconUrl, let url = URL(string: iconUrl) { - AsyncImage(url: url) { image in - image - .resizable() - .scaledToFit() - .frame(width: 36, height: 36) - } placeholder: { - Image(systemName: badge.category.icon) - .font(.title2) - .foregroundColor(.white) - } - } else { - Image(systemName: badge.category.icon) - .font(.title2) - .foregroundColor(.white) - } - - if !isEarned { - Circle() - .fill(Color.black.opacity(0.4)) - .frame(width: 64, height: 64) - - Image(systemName: "lock.fill") - .foregroundColor(.white.opacity(0.8)) - .font(.caption) - } - } - - // Badge Name - Text(badge.name) - .font(.caption) - .fontWeight(.medium) - .foregroundColor(isEarned ? .primary : .secondary) - .lineLimit(2) - .multilineTextAlignment(.center) - - // Tier Badge - Text(badge.tier.displayName) - .font(.caption2) - .padding(.horizontal, 8) - .padding(.vertical, 2) - .background(badge.tier.adaptiveColor.opacity(0.2)) - .foregroundColor(badge.tier.adaptiveColor) - .cornerRadius(4) - - // Progress Bar (if not earned) - if !isEarned, let progress = progress { - VStack(spacing: 4) { - ProgressView(value: progress.progressPercent / 100) - .tint(badge.tier.adaptiveColor) - - Text("\(progress.currentValue)/\(progress.targetValue)") - .font(.caption2) - .foregroundColor(.secondary) - } - } - - // Earned Date - if isEarned, let earnedDate = earnedDate { - Text(earnedDate, style: .date) - .font(.caption2) - .foregroundColor(.secondary) - } - } - .frame(width: 100) - .padding() - .background(Color(.systemBackground)) - .cornerRadius(12) - .shadow(color: Color.black.opacity(0.05), radius: 5, x: 0, y: 2) - } - .buttonStyle(.plain) - } - - private var tierGradient: LinearGradient { - LinearGradient( - colors: [badge.tier.adaptiveColor, badge.tier.secondaryColor], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - } -} - -// MARK: - Compact Badge Card - -struct CompactBadgeCard: View { - let badge: Badge - var isEarned: Bool = false - - var body: some View { - HStack(spacing: 12) { - // Icon - ZStack { - Circle() - .fill(badge.tier.adaptiveColor.opacity(0.2)) - .frame(width: 44, height: 44) - - Image(systemName: badge.category.icon) - .foregroundColor(badge.tier.adaptiveColor) - } - - VStack(alignment: .leading, spacing: 4) { - Text(badge.name) - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(isEarned ? .primary : .secondary) - - Text(badge.description) - .font(.caption) - .foregroundColor(.secondary) - .lineLimit(1) - } - - Spacer() - - if isEarned { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - } - } - .padding() - .background(Color(.systemBackground)) - .cornerRadius(12) - } -} - diff --git a/Readmigo/Features/Badges/BadgeDetailView.swift b/Readmigo/Features/Badges/BadgeDetailView.swift deleted file mode 100644 index 7d9c349..0000000 --- a/Readmigo/Features/Badges/BadgeDetailView.swift +++ /dev/null @@ -1,254 +0,0 @@ -import SwiftUI - -struct BadgeDetailView: View { - let badge: Badge - let isEarned: Bool - let earnedDate: Date? - let progress: BadgeProgress? - - @Environment(\.dismiss) private var dismiss - - var body: some View { - NavigationStack { - ScrollView { - VStack(spacing: 32) { - // Badge Icon - VStack(spacing: 16) { - ZStack { - // Glow effect - Circle() - .fill( - RadialGradient( - colors: [ - Color(hex: badge.tier.color).opacity(isEarned ? 0.5 : 0.2), - Color.clear - ], - center: .center, - startRadius: 40, - endRadius: 100 - ) - ) - .frame(width: 160, height: 160) - - // Badge circle - Circle() - .fill(tierGradient) - .frame(width: 100, height: 100) - .shadow(color: Color(hex: badge.tier.color).opacity(0.3), radius: 10) - - if let iconUrl = badge.iconUrl, let url = URL(string: iconUrl) { - AsyncImage(url: url) { image in - image - .resizable() - .scaledToFit() - .frame(width: 50, height: 50) - } placeholder: { - Image(systemName: badge.category.icon) - .font(.largeTitle) - .foregroundColor(.white) - } - } else { - Image(systemName: badge.category.icon) - .font(.largeTitle) - .foregroundColor(.white) - } - - // Lock overlay - if !isEarned { - Circle() - .fill(Color.black.opacity(0.4)) - .frame(width: 100, height: 100) - - Image(systemName: "lock.fill") - .font(.title) - .foregroundColor(.white.opacity(0.8)) - } - } - - // Tier Badge - Text(badge.tier.displayName) - .font(.headline) - .padding(.horizontal, 16) - .padding(.vertical, 6) - .background(Color(hex: badge.tier.color).opacity(0.2)) - .foregroundColor(Color(hex: badge.tier.color)) - .cornerRadius(16) - } - - // Badge Info - VStack(spacing: 12) { - Text(badge.name) - .font(.title2) - .fontWeight(.bold) - - Text(badge.description) - .font(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - - HStack(spacing: 4) { - Image(systemName: badge.category.icon) - Text(badge.category.displayName) - } - .font(.subheadline) - .foregroundColor(.secondary) - } - .padding(.horizontal) - - // Status Section - VStack(spacing: 16) { - if isEarned { - // Earned status - VStack(spacing: 8) { - HStack { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - Text("badges.earned".localized) - .font(.headline) - .foregroundColor(.green) - } - - if let earnedDate = earnedDate { - Text("badges.unlockedOn".localized(with: earnedDate.formatted(date: .long, time: .omitted))) - .font(.subheadline) - .foregroundColor(.secondary) - } - } - .padding() - .frame(maxWidth: .infinity) - .background(Color.green.opacity(0.1)) - .cornerRadius(12) - } else if let progress = progress { - // Progress status - VStack(spacing: 12) { - HStack { - Text("badges.progress".localized) - .font(.headline) - Spacer() - Text("\(Int(progress.progressPercent))%") - .font(.headline) - .foregroundColor(Color(hex: badge.tier.color)) - } - - ProgressView(value: progress.progressPercent / 100) - .tint(Color(hex: badge.tier.color)) - .scaleEffect(y: 2) - - Text("\(progress.currentValue) / \(progress.targetValue)") - .font(.subheadline) - .foregroundColor(.secondary) - } - .padding() - .background(Color(.systemBackground)) - .cornerRadius(12) - .shadow(color: Color.black.opacity(0.05), radius: 5) - } else { - // Not started - VStack(spacing: 8) { - Image(systemName: "lock.fill") - .font(.title2) - .foregroundColor(.secondary) - Text("badges.notStarted".localized) - .font(.subheadline) - .foregroundColor(.secondary) - } - .padding() - .frame(maxWidth: .infinity) - .background(Color(.systemGray6)) - .cornerRadius(12) - } - } - .padding(.horizontal) - - // Requirement Info - VStack(alignment: .leading, spacing: 12) { - Text("badges.howToEarn".localized) - .font(.headline) - - HStack(spacing: 12) { - Image(systemName: "target") - .foregroundColor(.accentColor) - .frame(width: 32) - - VStack(alignment: .leading, spacing: 4) { - Text(badge.requirement.description ?? requirementDescription) - .font(.body) - - Text("badges.requirementTarget".localized(with: badge.requirement.target)) - .font(.caption) - .foregroundColor(.secondary) - } - } - .padding() - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color(.systemBackground)) - .cornerRadius(12) - } - .padding(.horizontal) - - Spacer(minLength: 40) - } - .padding(.vertical, 32) - } - .background(Color(.systemGroupedBackground)) - .navigationTitle("badges.details.title".localized) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button("common.done".localized) { - dismiss() - } - } - } - } - } - - private var tierGradient: LinearGradient { - switch badge.tier { - case .bronze: - return LinearGradient( - colors: [Color(hex: "#CD7F32"), Color(hex: "#8B4513")], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - case .silver: - return LinearGradient( - colors: [Color(hex: "#C0C0C0"), Color(hex: "#808080")], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - case .gold: - return LinearGradient( - colors: [Color(hex: "#FFD700"), Color(hex: "#FFA500")], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - case .platinum: - return LinearGradient( - colors: [Color(hex: "#E5E4E2"), Color(hex: "#A0A0A0")], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - } - } - - private var requirementDescription: String { - let type = badge.requirement.type - let target = badge.requirement.target - - switch type { - case "books_finished": - return "Finish \(target) book\(target > 1 ? "s" : "")" - case "words_learned": - return "Learn \(target) words" - case "streak_days": - return "Maintain a \(target)-day reading streak" - case "reading_minutes": - return "Read for \(target) minutes total" - case "reviews_completed": - return "Complete \(target) vocabulary reviews" - default: - return "Complete the requirement" - } - } -} diff --git a/Readmigo/Features/Badges/BadgesManager.swift b/Readmigo/Features/Badges/BadgesManager.swift deleted file mode 100644 index 1aa5032..0000000 --- a/Readmigo/Features/Badges/BadgesManager.swift +++ /dev/null @@ -1,140 +0,0 @@ -import Foundation - -@MainActor -class BadgesManager: ObservableObject { - static let shared = BadgesManager() - - @Published var allBadges: [Badge] = [] - @Published var earnedBadges: [UserBadge] = [] - @Published var badgeProgress: [BadgeProgress] = [] - @Published var isLoading = false - @Published var error: String? - - private init() {} - - // MARK: - Computed Properties - - var earnedBadgeIds: Set { - Set(earnedBadges.map { $0.badge.id }) - } - - var unearnedBadges: [Badge] { - allBadges.filter { !earnedBadgeIds.contains($0.id) } - } - - var inProgressBadges: [BadgeProgress] { - badgeProgress.filter { !$0.isComplete && $0.currentValue > 0 } - } - - var badgesByCategory: [BadgeCategory: [Badge]] { - Dictionary(grouping: allBadges, by: { $0.category }) - } - - var earnedBadgesByCategory: [BadgeCategory: [UserBadge]] { - Dictionary(grouping: earnedBadges, by: { $0.badge.category }) - } - - // MARK: - Fetch All Badges - - func fetchAllBadges() async { - isLoading = true - error = nil - - let cacheKey = CacheKeys.badgesAllKey() - if let cached: BadgesResponse = await ResponseCacheService.shared.get(cacheKey, type: BadgesResponse.self) { - allBadges = cached.badges - isLoading = false - return - } - - do { - let response: BadgesResponse = try await APIClient.shared.request( - endpoint: APIEndpoints.badges - ) - allBadges = response.badges - await ResponseCacheService.shared.set(response, for: cacheKey, ttl: .bookList) - } catch { - self.error = error.localizedDescription - } - - isLoading = false - } - - // MARK: - Fetch Earned Badges - - func fetchEarnedBadges() async { - isLoading = true - error = nil - - let cacheKey = CacheKeys.badgesUserKey() - if let cached: UserBadgesResponse = await ResponseCacheService.shared.get(cacheKey, type: UserBadgesResponse.self) { - earnedBadges = cached.badges - isLoading = false - return - } - - do { - let response: UserBadgesResponse = try await APIClient.shared.request( - endpoint: APIEndpoints.badgesUser - ) - earnedBadges = response.badges - await ResponseCacheService.shared.set(response, for: cacheKey, ttl: .daily) - } catch { - self.error = error.localizedDescription - } - - isLoading = false - } - - // MARK: - Fetch Badge Progress - - func fetchBadgeProgress() async { - let cacheKey = CacheKeys.badgesProgressKey() - if let cached: BadgeProgressResponse = await ResponseCacheService.shared.get(cacheKey, type: BadgeProgressResponse.self) { - badgeProgress = cached.progress - return - } - do { - let response: BadgeProgressResponse = try await APIClient.shared.request( - endpoint: APIEndpoints.badgesProgress - ) - badgeProgress = response.progress - await ResponseCacheService.shared.set(response, for: cacheKey, ttl: .daily) - } catch { - self.error = error.localizedDescription - } - } - - // MARK: - Refresh All Data - - func refreshAll() async { - isLoading = true - error = nil - - await withTaskGroup(of: Void.self) { group in - group.addTask { await self.fetchAllBadges() } - group.addTask { await self.fetchEarnedBadges() } - group.addTask { await self.fetchBadgeProgress() } - } - - isLoading = false - } - - // MARK: - Check if Badge is Earned - - func isEarned(_ badge: Badge) -> Bool { - earnedBadgeIds.contains(badge.id) - } - - // MARK: - Get Progress for Badge - - func progress(for badge: Badge) -> BadgeProgress? { - badgeProgress.first { $0.badge.id == badge.id } - } - - // MARK: - Get User Badge - - func userBadge(for badge: Badge) -> UserBadge? { - earnedBadges.first { $0.badge.id == badge.id } - } -} diff --git a/Readmigo/Features/Badges/BadgesView.swift b/Readmigo/Features/Badges/BadgesView.swift deleted file mode 100644 index 10c3542..0000000 --- a/Readmigo/Features/Badges/BadgesView.swift +++ /dev/null @@ -1,231 +0,0 @@ -import SwiftUI - -struct BadgesView: View { - @EnvironmentObject var authManager: AuthManager - @StateObject private var manager = BadgesManager.shared - @State private var selectedBadge: Badge? - @State private var showingDetail = false - @State private var selectedCategory: BadgeCategory? - - var body: some View { - NavigationStack { - if !authManager.isAuthenticated { - LoginRequiredView(feature: "achievements") - } else { - ScrollView { - VStack(spacing: 24) { - // Earned Badges Section - if !manager.earnedBadges.isEmpty { - VStack(alignment: .leading, spacing: 16) { - HStack { - Image(systemName: "trophy.fill") - .foregroundColor(.yellow) - Text("badges.earned".localized) - .font(.title3) - .fontWeight(.bold) - Spacer() - Text("\(manager.earnedBadges.count)") - .font(.subheadline) - .foregroundColor(.secondary) - } - .padding(.horizontal) - - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 16) { - ForEach(manager.earnedBadges) { userBadge in - BadgeCardView( - badge: userBadge.badge, - isEarned: true, - earnedDate: userBadge.earnedAt - ) { - selectedBadge = userBadge.badge - showingDetail = true - } - } - } - .padding(.horizontal) - } - } - } - - // In Progress Section - if !manager.inProgressBadges.isEmpty { - VStack(alignment: .leading, spacing: 16) { - HStack { - Image(systemName: "arrow.up.circle.fill") - .foregroundColor(.blue) - Text("badges.inProgress".localized) - .font(.title3) - .fontWeight(.bold) - Spacer() - } - .padding(.horizontal) - - VStack(spacing: 12) { - ForEach(manager.inProgressBadges) { progress in - BadgeProgressRow(progress: progress) { - selectedBadge = progress.badge - showingDetail = true - } - .padding(.horizontal) - } - } - } - } - - // Category Filter - VStack(alignment: .leading, spacing: 16) { - Text("badges.all".localized) - .font(.title3) - .fontWeight(.bold) - .padding(.horizontal) - - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - BadgeCategoryChip( - title: "common.all".localized, - icon: "square.grid.2x2", - isSelected: selectedCategory == nil - ) { - selectedCategory = nil - } - - ForEach(BadgeCategory.allCases, id: \.self) { category in - BadgeCategoryChip( - title: category.displayName, - icon: category.icon, - isSelected: selectedCategory == category - ) { - selectedCategory = category - } - } - } - .padding(.horizontal) - } - } - - // Badges Grid - let filteredBadges = selectedCategory == nil - ? manager.allBadges - : manager.allBadges.filter { $0.category == selectedCategory } - - LazyVGrid(columns: [ - GridItem(.adaptive(minimum: 100), spacing: 16) - ], spacing: 16) { - ForEach(filteredBadges) { badge in - BadgeCardView( - badge: badge, - isEarned: manager.isEarned(badge), - earnedDate: manager.userBadge(for: badge)?.earnedAt, - progress: manager.progress(for: badge) - ) { - selectedBadge = badge - showingDetail = true - } - } - } - .padding(.horizontal) - - Spacer(minLength: 40) - } - .padding(.vertical) - } - .navigationTitle("badges.title".localized) - .elegantRefreshable { - await manager.refreshAll() - } - .sheet(isPresented: $showingDetail) { - if let badge = selectedBadge { - BadgeDetailView( - badge: badge, - isEarned: manager.isEarned(badge), - earnedDate: manager.userBadge(for: badge)?.earnedAt, - progress: manager.progress(for: badge) - ) - } - } - .overlay { - if manager.isLoading && manager.allBadges.isEmpty { - ProgressView() - } - } - .task { - await manager.refreshAll() - } - } - } - } -} - -// MARK: - Badge Progress Row - -struct BadgeProgressRow: View { - let progress: BadgeProgress - var onTap: (() -> Void)? - - var body: some View { - Button(action: { onTap?() }) { - HStack(spacing: 16) { - // Icon - ZStack { - Circle() - .fill(Color(hex: progress.badge.tier.color).opacity(0.2)) - .frame(width: 48, height: 48) - - Image(systemName: progress.badge.category.icon) - .foregroundColor(Color(hex: progress.badge.tier.color)) - } - - VStack(alignment: .leading, spacing: 8) { - HStack { - Text(progress.badge.name) - .font(.subheadline) - .fontWeight(.medium) - Spacer() - Text("\(Int(progress.progressPercent))%") - .font(.caption) - .foregroundColor(.secondary) - } - - ProgressView(value: progress.progressPercent / 100) - .tint(Color(hex: progress.badge.tier.color)) - - Text("\(progress.currentValue) / \(progress.targetValue)") - .font(.caption) - .foregroundColor(.secondary) - } - } - .padding() - .background(Color(.systemBackground)) - .cornerRadius(12) - .shadow(color: Color.black.opacity(0.05), radius: 5, x: 0, y: 2) - } - .buttonStyle(.plain) - } -} - -// MARK: - Badge Category Chip - -private struct BadgeCategoryChip: View { - let title: String - let icon: String - let isSelected: Bool - let action: () -> Void - - var body: some View { - Button(action: action) { - HStack(spacing: 6) { - Image(systemName: icon) - .font(.caption) - Text(title) - .font(.subheadline) - } - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background(isSelected ? Color.accentColor : Color(.systemGray6)) - .foregroundColor(isSelected ? .white : .primary) - .cornerRadius(20) - } - .buttonStyle(.plain) - } -} diff --git a/Readmigo/Features/Badges/Medal3D/MedalAnimator.swift b/Readmigo/Features/Badges/Medal3D/MedalAnimator.swift deleted file mode 100644 index 28ca3e8..0000000 --- a/Readmigo/Features/Badges/Medal3D/MedalAnimator.swift +++ /dev/null @@ -1,275 +0,0 @@ -import SwiftUI -import RealityKit - -// MARK: - Unlock Animation View - -struct MedalUnlockAnimationView: View { - let medalCode: String - let medalName: String - let materialPreset: MedalMaterialPreset - let onDismiss: () -> Void - - @State private var phase: UnlockPhase = .gathering - @State private var showConfetti = false - - enum UnlockPhase { - case gathering // 0.0s - light rays converge - case reveal // 0.3s - medal flies in - case burst // 0.8s - particle burst - case showcase // 1.2s - slow rotation - case text // 1.5s - name appears - case interactive // 2.5s - user can interact - } - - var body: some View { - ZStack { - // Dark background - Color.black - .opacity(backgroundOpacity) - .ignoresSafeArea() - .onTapGesture { - if phase == .interactive { - onDismiss() - } - } - - VStack(spacing: 32) { - Spacer() - - // Converging light rays - if phase == .gathering { - gatheringEffect - } - - // Medal - MedalRenderView( - medalCode: medalCode, - materialPreset: materialPreset, - isEarned: true, - showInteraction: phase == .interactive - ) - .scaleEffect(medalScale) - .opacity(medalOpacity) - .rotation3DEffect(.degrees(medalRotation), axis: (x: 0, y: 1, z: 0)) - - // Medal name - VStack(spacing: 8) { - Text("medals.unlocked".localized) - .font(.subheadline) - .foregroundColor(.white.opacity(0.7)) - .textCase(.uppercase) - .tracking(2) - - Text(medalName) - .font(.title) - .fontWeight(.bold) - .foregroundColor(.white) - } - .opacity(textOpacity) - - Spacer() - - // Dismiss hint - if phase == .interactive { - Text("medals.tapToDismiss".localized) - .font(.caption) - .foregroundColor(.white.opacity(0.5)) - .padding(.bottom, 40) - .transition(.opacity) - } - } - - // Confetti overlay - if showConfetti { - ConfettiOverlay(color: confettiColor) - } - } - .onAppear { - runAnimationSequence() - } - } - - // MARK: - Animation Sequence - - private func runAnimationSequence() { - // Phase: gathering (0.0s) - withAnimation(.easeIn(duration: 0.3)) { - phase = .gathering - } - - // Phase: reveal (0.3s) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - withAnimation(.spring(response: 0.5, dampingFraction: 0.7)) { - phase = .reveal - } - // Haptic: medium impact - UIImpactFeedbackGenerator(style: .medium).impactOccurred() - } - - // Phase: burst (0.8s) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { - withAnimation(.easeOut(duration: 0.3)) { - phase = .burst - showConfetti = true - } - // Haptic: heavy impact - UIImpactFeedbackGenerator(style: .heavy).impactOccurred() - } - - // Phase: showcase (1.2s) - DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { - withAnimation(.easeInOut(duration: 0.8)) { - phase = .showcase - } - } - - // Phase: text (1.5s) - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { - withAnimation(.easeIn(duration: 0.4)) { - phase = .text - } - } - - // Phase: interactive (2.5s) - DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { - withAnimation(.easeInOut(duration: 0.3)) { - phase = .interactive - } - } - } - - // MARK: - Computed Properties - - private var backgroundOpacity: Double { - switch phase { - case .gathering: return 0.5 - default: return 0.85 - } - } - - private var medalScale: CGFloat { - switch phase { - case .gathering: return 0.0 - case .reveal: return 1.2 - case .burst: return 1.3 - case .showcase, .text: return 1.0 - case .interactive: return 1.0 - } - } - - private var medalOpacity: Double { - switch phase { - case .gathering: return 0.0 - default: return 1.0 - } - } - - private var medalRotation: Double { - switch phase { - case .gathering: return -180 - case .reveal: return -30 - case .burst: return 15 - case .showcase: return 0 - case .text: return 0 - case .interactive: return 0 - } - } - - private var textOpacity: Double { - switch phase { - case .text, .interactive: return 1.0 - default: return 0.0 - } - } - - private var confettiColor: Color { - switch materialPreset { - case .copper: return Color(hex: "#CD7F32") - case .silver: return Color(hex: "#C0C0C0") - case .gold: return Color(hex: "#FFD700") - case .platinum: return Color(hex: "#E5E4E2") - case .diamond: return Color(hex: "#B0C4FF") - } - } - - private var gatheringEffect: some View { - Circle() - .stroke( - AngularGradient( - colors: [confettiColor.opacity(0.8), .clear, confettiColor.opacity(0.4), .clear], - center: .center - ), - lineWidth: 2 - ) - .frame(width: 200, height: 200) - .rotationEffect(.degrees(phase == .gathering ? 0 : 360)) - .animation(.linear(duration: 1).repeatForever(autoreverses: false), value: phase) - .opacity(phase == .gathering ? 1 : 0) - } -} - -// MARK: - Confetti Overlay - -struct ConfettiOverlay: View { - let color: Color - @State private var particles: [ConfettiParticle] = [] - - struct ConfettiParticle: Identifiable { - let id = UUID() - var x: CGFloat - var y: CGFloat - var size: CGFloat - var opacity: Double - var rotation: Double - var color: Color - } - - var body: some View { - GeometryReader { geo in - ZStack { - ForEach(particles) { particle in - RoundedRectangle(cornerRadius: 2) - .fill(particle.color) - .frame(width: particle.size, height: particle.size * 0.6) - .rotationEffect(.degrees(particle.rotation)) - .position(x: particle.x, y: particle.y) - .opacity(particle.opacity) - } - } - .onAppear { - generateParticles(in: geo.size) - } - } - .allowsHitTesting(false) - } - - private func generateParticles(in size: CGSize) { - let colors: [Color] = [color, color.opacity(0.7), .white, color.opacity(0.5)] - - for _ in 0..<30 { - let particle = ConfettiParticle( - x: CGFloat.random(in: 0...size.width), - y: -20, - size: CGFloat.random(in: 4...10), - opacity: 1.0, - rotation: Double.random(in: 0...360), - color: colors.randomElement()! - ) - particles.append(particle) - } - - // Animate particles falling - withAnimation(.easeIn(duration: 2.0)) { - for i in particles.indices { - particles[i].y = size.height + 20 - particles[i].opacity = 0 - particles[i].rotation += Double.random(in: 180...720) - } - } - - // Remove after animation - DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { - particles.removeAll() - } - } -} diff --git a/Readmigo/Features/Badges/Medal3D/MedalInteractionView.swift b/Readmigo/Features/Badges/Medal3D/MedalInteractionView.swift deleted file mode 100644 index 3e832ce..0000000 --- a/Readmigo/Features/Badges/Medal3D/MedalInteractionView.swift +++ /dev/null @@ -1,139 +0,0 @@ -import SwiftUI -import RealityKit -import CoreMotion - -struct MedalInteractionView: View { - let medalCode: String - let medalName: String - let medalDescription: String - let materialPreset: MedalMaterialPreset - let isEarned: Bool - let earnedDate: Date? - let rarity: String - - @Environment(\.dismiss) private var dismiss - @State private var showShareSheet = false - - var body: some View { - NavigationStack { - ScrollView { - VStack(spacing: 24) { - // 3D Medal with interaction - MedalRenderView( - medalCode: medalCode, - materialPreset: materialPreset, - isEarned: isEarned, - showInteraction: true - ) - .frame(height: 300) - .padding(.top, 16) - - // Rarity badge - rarityBadge - - // Medal info - VStack(spacing: 8) { - Text(medalName) - .font(.title2) - .fontWeight(.bold) - - Text(medalDescription) - .font(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - } - .padding(.horizontal) - - // Status - statusSection - - Spacer(minLength: 40) - } - } - .background(Color(.systemGroupedBackground)) - .navigationTitle("medals.detail.title".localized) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - HStack(spacing: 16) { - if isEarned { - Button { - showShareSheet = true - } label: { - Image(systemName: "square.and.arrow.up") - } - } - Button("common.done".localized) { - dismiss() - } - } - } - } - } - } - - private var rarityBadge: some View { - Text(rarity) - .font(.caption) - .fontWeight(.semibold) - .textCase(.uppercase) - .tracking(1) - .padding(.horizontal, 16) - .padding(.vertical, 6) - .background(rarityColor.opacity(0.15)) - .foregroundColor(rarityColor) - .cornerRadius(16) - } - - private var statusSection: some View { - Group { - if isEarned { - VStack(spacing: 8) { - HStack { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - Text("medals.earned".localized) - .font(.headline) - .foregroundColor(.green) - } - - if let date = earnedDate { - Text(date.formatted(date: .long, time: .omitted)) - .font(.subheadline) - .foregroundColor(.secondary) - } - } - .padding() - .frame(maxWidth: .infinity) - .background(Color.green.opacity(0.1)) - .cornerRadius(12) - .padding(.horizontal) - } else { - VStack(spacing: 8) { - Image(systemName: "lock.fill") - .font(.title2) - .foregroundColor(.secondary) - Text("medals.locked".localized) - .font(.subheadline) - .foregroundColor(.secondary) - } - .padding() - .frame(maxWidth: .infinity) - .background(Color(.systemGray6)) - .cornerRadius(12) - .padding(.horizontal) - } - } - } - - private var rarityColor: Color { - switch rarity.uppercased() { - case "COMMON": return Color(hex: "#CD7F32") - case "UNCOMMON": return Color(hex: "#C0C0C0") - case "RARE": return Color(hex: "#FFD700") - case "EPIC": return Color(hex: "#9B59B6") - case "LEGENDARY": return Color(hex: "#E74C3C") - default: return .secondary - } - } -} diff --git a/Readmigo/Features/Badges/Medal3D/MedalMaterialFactory.swift b/Readmigo/Features/Badges/Medal3D/MedalMaterialFactory.swift deleted file mode 100644 index 18cda79..0000000 --- a/Readmigo/Features/Badges/Medal3D/MedalMaterialFactory.swift +++ /dev/null @@ -1,73 +0,0 @@ -import RealityKit -import UIKit - -enum MedalMaterialPreset: String, CaseIterable { - case copper - case silver - case gold - case platinum - case diamond - - static func from(badgeTier: BadgeTier) -> MedalMaterialPreset { - switch badgeTier { - case .bronze: return .copper - case .silver: return .silver - case .gold: return .gold - case .platinum: return .platinum - } - } - - static func from(rarity: String) -> MedalMaterialPreset { - switch rarity.uppercased() { - case "COMMON": return .copper - case "UNCOMMON": return .silver - case "RARE": return .gold - case "EPIC": return .platinum - case "LEGENDARY": return .diamond - default: return .copper - } - } -} - -struct MedalMaterialFactory { - - static func makeMaterial(for preset: MedalMaterialPreset) -> PhysicallyBasedMaterial { - var material = PhysicallyBasedMaterial() - - switch preset { - case .copper: - material.baseColor = .init(tint: UIColor(hex: "#CD7F32")) - material.metallic = .init(floatLiteral: 0.9) - material.roughness = .init(floatLiteral: 0.40) - material.clearcoat = .init(floatLiteral: 0.0) - - case .silver: - material.baseColor = .init(tint: UIColor(hex: "#C0C0C0")) - material.metallic = .init(floatLiteral: 1.0) - material.roughness = .init(floatLiteral: 0.20) - material.clearcoat = .init(floatLiteral: 0.2) - - case .gold: - material.baseColor = .init(tint: UIColor(hex: "#FFD700")) - material.metallic = .init(floatLiteral: 1.0) - material.roughness = .init(floatLiteral: 0.15) - material.clearcoat = .init(floatLiteral: 0.3) - - case .platinum: - material.baseColor = .init(tint: UIColor(hex: "#E5E4E2")) - material.metallic = .init(floatLiteral: 1.0) - material.roughness = .init(floatLiteral: 0.10) - material.clearcoat = .init(floatLiteral: 0.5) - - case .diamond: - material.baseColor = .init(tint: UIColor(hex: "#F0F8FF")) - material.metallic = .init(floatLiteral: 0.3) - material.roughness = .init(floatLiteral: 0.05) - material.clearcoat = .init(floatLiteral: 1.0) - material.emissiveColor = .init(color: UIColor(hex: "#E8E0FF")) - material.emissiveIntensity = 0.1 - } - - return material - } -} diff --git a/Readmigo/Features/Badges/Medal3D/MedalRenderView.swift b/Readmigo/Features/Badges/Medal3D/MedalRenderView.swift deleted file mode 100644 index c993205..0000000 --- a/Readmigo/Features/Badges/Medal3D/MedalRenderView.swift +++ /dev/null @@ -1,122 +0,0 @@ -import SwiftUI - -struct MedalRenderView: View { - let medalCode: String - let materialPreset: MedalMaterialPreset - let isEarned: Bool - let showInteraction: Bool - - @State private var dragOffset: CGSize = .zero - @State private var lastDragOffset: CGSize = .zero - - init( - medalCode: String, - materialPreset: MedalMaterialPreset = .copper, - isEarned: Bool = true, - showInteraction: Bool = false - ) { - self.medalCode = medalCode - self.materialPreset = materialPreset - self.isEarned = isEarned - self.showInteraction = showInteraction - } - - var body: some View { - ZStack { - // Background glow - if isEarned { - glowBackground - } - - // 3D Medal - MedalSceneView( - medalCode: medalCode, - materialPreset: materialPreset, - autoRotate: !showInteraction, - isEarned: isEarned - ) - .frame(width: viewSize, height: viewSize) - - // Locked overlay - if !isEarned { - lockedOverlay - } - } - .frame(width: viewSize, height: viewSize) - } - - private var viewSize: CGFloat { - showInteraction ? 280 : 120 - } - - private var glowBackground: some View { - Circle() - .fill( - RadialGradient( - colors: [ - glowColor.opacity(0.4), - glowColor.opacity(0.1), - Color.clear - ], - center: .center, - startRadius: viewSize * 0.15, - endRadius: viewSize * 0.5 - ) - ) - .frame(width: viewSize, height: viewSize) - } - - private var lockedOverlay: some View { - ZStack { - Circle() - .fill(Color.black.opacity(0.3)) - .frame(width: viewSize * 0.7, height: viewSize * 0.7) - - Image(systemName: "lock.fill") - .font(.system(size: viewSize * 0.15)) - .foregroundColor(.white.opacity(0.6)) - } - } - - private var glowColor: Color { - switch materialPreset { - case .copper: return Color(hex: "#CD7F32") - case .silver: return Color(hex: "#C0C0C0") - case .gold: return Color(hex: "#FFD700") - case .platinum: return Color(hex: "#E5E4E2") - case .diamond: return Color(hex: "#B0C4FF") - } - } -} - -// MARK: - Thumbnail variant for grid views - -struct MedalThumbnailView: View { - let medalCode: String - let materialPreset: MedalMaterialPreset - let isEarned: Bool - let size: CGFloat - - init( - medalCode: String, - materialPreset: MedalMaterialPreset, - isEarned: Bool, - size: CGFloat = 80 - ) { - self.medalCode = medalCode - self.materialPreset = materialPreset - self.isEarned = isEarned - self.size = size - } - - var body: some View { - MedalRenderView( - medalCode: medalCode, - materialPreset: materialPreset, - isEarned: isEarned, - showInteraction: false - ) - .frame(width: size, height: size) - .clipShape(RoundedRectangle(cornerRadius: 12)) - } -} diff --git a/Readmigo/Features/Badges/Medal3D/MedalSceneView.swift b/Readmigo/Features/Badges/Medal3D/MedalSceneView.swift deleted file mode 100644 index 8555e57..0000000 --- a/Readmigo/Features/Badges/Medal3D/MedalSceneView.swift +++ /dev/null @@ -1,223 +0,0 @@ -import SwiftUI -import RealityKit -import Combine - -struct MedalSceneView: UIViewRepresentable { - let medalCode: String - let materialPreset: MedalMaterialPreset - let autoRotate: Bool - let isEarned: Bool - - init( - medalCode: String, - materialPreset: MedalMaterialPreset, - autoRotate: Bool = true, - isEarned: Bool = true - ) { - self.medalCode = medalCode - self.materialPreset = materialPreset - self.autoRotate = autoRotate - self.isEarned = isEarned - } - - func makeUIView(context: Context) -> ARView { - let arView = ARView(frame: .zero) - arView.cameraMode = .nonAR - arView.environment.background = .color(.clear) - - setupLighting(arView) - - context.coordinator.arView = arView - context.coordinator.loadMedal( - code: medalCode, - preset: materialPreset, - isEarned: isEarned, - autoRotate: autoRotate - ) - - if autoRotate { - context.coordinator.startAutoRotation() - } - - return arView - } - - func updateUIView(_ arView: ARView, context: Context) { - // Update material if preset changes - } - - func makeCoordinator() -> Coordinator { - Coordinator() - } - - private func setupLighting(_ arView: ARView) { - // Key light - top-right - let keyLight = DirectionalLight() - keyLight.light.color = .white - keyLight.light.intensity = 8000 - keyLight.look(at: .zero, from: SIMD3(0.5, 1.0, 0.8), relativeTo: nil) - - // Fill light - left side, warm - let fillLight = DirectionalLight() - fillLight.light.color = UIColor(red: 1.0, green: 0.95, blue: 0.85, alpha: 1.0) - fillLight.light.intensity = 3000 - fillLight.look(at: .zero, from: SIMD3(-0.8, 0.3, 0.5), relativeTo: nil) - - // Rim light - back, cool - let rimLight = DirectionalLight() - rimLight.light.color = UIColor(red: 0.85, green: 0.9, blue: 1.0, alpha: 1.0) - rimLight.light.intensity = 2000 - rimLight.look(at: .zero, from: SIMD3(0.0, 0.5, -1.0), relativeTo: nil) - - let lightAnchor = AnchorEntity() - lightAnchor.addChild(keyLight) - lightAnchor.addChild(fillLight) - lightAnchor.addChild(rimLight) - arView.scene.addAnchor(lightAnchor) - } - - // MARK: - Coordinator - - class Coordinator { - var arView: ARView? - var medalEntity: ModelEntity? - var rotationTimer: AnyCancellable? - private var rotationAngle: Float = 0 - - func loadMedal( - code: String, - preset: MedalMaterialPreset, - isEarned: Bool, - autoRotate: Bool - ) { - guard let arView = arView else { return } - - let anchor = AnchorEntity() - arView.scene.addAnchor(anchor) - - // Try loading USDZ model - let modelName = code - if let modelURL = Bundle.main.url(forResource: modelName, withExtension: "usdz", subdirectory: "Medals/models") { - loadUSDZModel(url: modelURL, anchor: anchor, preset: preset, isEarned: isEarned) - } else { - // Fallback: generate procedural medal - let entity = createProceduralMedal(preset: preset, isEarned: isEarned) - anchor.addChild(entity) - medalEntity = entity - } - - // Setup camera - let camera = PerspectiveCamera() - camera.camera.fieldOfViewInDegrees = 45 - camera.position = SIMD3(0, 0, 0.12) - camera.look(at: .zero, from: camera.position, relativeTo: nil) - - let cameraAnchor = AnchorEntity() - cameraAnchor.addChild(camera) - arView.scene.addAnchor(cameraAnchor) - } - - private func loadUSDZModel( - url: URL, - anchor: AnchorEntity, - preset: MedalMaterialPreset, - isEarned: Bool - ) { - Task { - do { - let entity = try await ModelEntity(contentsOf: url) - - // Override material with our PBR preset - let material = MedalMaterialFactory.makeMaterial(for: preset) - entity.model?.materials = [material] - - if !isEarned { - applyLockedAppearance(to: entity) - } - - // Normalize scale to fit view - let bounds = entity.visualBounds(relativeTo: nil) - let maxDim = max(bounds.extents.x, bounds.extents.y, bounds.extents.z) - if maxDim > 0 { - let targetSize: Float = 0.04 // 4cm - let scale = targetSize / maxDim - entity.scale = SIMD3(repeating: scale) - } - - await MainActor.run { - anchor.addChild(entity) - self.medalEntity = entity - } - } catch { - // Fallback to procedural - await MainActor.run { - let fallback = self.createProceduralMedal(preset: preset, isEarned: isEarned) - anchor.addChild(fallback) - self.medalEntity = fallback - } - } - } - } - - private func createProceduralMedal( - preset: MedalMaterialPreset, - isEarned: Bool - ) -> ModelEntity { - // Procedural medal: cylinder disc + rim - let discMesh = MeshResource.generateCylinder(height: 0.003, radius: 0.02) - var material = MedalMaterialFactory.makeMaterial(for: preset) - - if !isEarned { - material.baseColor = .init(tint: .darkGray) - material.metallic = .init(floatLiteral: 0.3) - material.roughness = .init(floatLiteral: 0.8) - } - - let disc = ModelEntity(mesh: discMesh, materials: [material]) - - // Rim (slightly larger, thinner ring) - let rimMesh = MeshResource.generateCylinder(height: 0.004, radius: 0.022) - var rimMaterial = MedalMaterialFactory.makeMaterial(for: preset) - rimMaterial.roughness = .init(floatLiteral: max(0.05, (rimMaterial.roughness.scale ?? 0.2) - 0.1)) - - if !isEarned { - rimMaterial.baseColor = .init(tint: .gray) - rimMaterial.metallic = .init(floatLiteral: 0.3) - } - - let rim = ModelEntity(mesh: rimMesh, materials: [rimMaterial]) - rim.position.y = -0.0005 - - disc.addChild(rim) - - // Rotate to face camera - disc.orientation = simd_quatf(angle: .pi / 2, axis: SIMD3(1, 0, 0)) - - return disc - } - - private func applyLockedAppearance(to entity: ModelEntity) { - var lockedMaterial = PhysicallyBasedMaterial() - lockedMaterial.baseColor = .init(tint: UIColor.darkGray) - lockedMaterial.metallic = .init(floatLiteral: 0.3) - lockedMaterial.roughness = .init(floatLiteral: 0.8) - entity.model?.materials = [lockedMaterial] - } - - func startAutoRotation() { - rotationTimer = Timer.publish(every: 1.0 / 60.0, on: .main, in: .common) - .autoconnect() - .sink { [weak self] _ in - guard let self = self, let entity = self.medalEntity else { return } - self.rotationAngle += 0.01 // ~2 RPM at 60fps - entity.orientation = simd_quatf(angle: .pi / 2, axis: SIMD3(1, 0, 0)) - * simd_quatf(angle: self.rotationAngle, axis: SIMD3(0, 0, 1)) - } - } - - func stopAutoRotation() { - rotationTimer?.cancel() - rotationTimer = nil - } - } -} diff --git a/Readmigo/Features/Me/MeView.swift b/Readmigo/Features/Me/MeView.swift index f2ea75e..1980dac 100644 --- a/Readmigo/Features/Me/MeView.swift +++ b/Readmigo/Features/Me/MeView.swift @@ -121,6 +121,32 @@ struct MeView: View { Divider().padding(.leading, 52) + if authManager.isAuthenticated { + NavigationLink { + BadgeListView() + .environmentObject(authManager) + } label: { + MeMenuRow( + icon: "medal.fill", + iconColor: .orange, + title: "badge.achievements".localized + ) + } + } else { + Button { + loginPromptFeature = "badges" + showLoginPrompt = true + } label: { + MeMenuRow( + icon: "medal.fill", + iconColor: .orange, + title: "badge.achievements".localized + ) + } + } + + Divider().padding(.leading, 52) + if authManager.isAuthenticated { NavigationLink { SubscriptionStatusView() diff --git a/Readmigo/Readmigo-Bridging-Header.h b/Readmigo/Readmigo-Bridging-Header.h index 6dd35d8..3e6f234 100644 --- a/Readmigo/Readmigo-Bridging-Header.h +++ b/Readmigo/Readmigo-Bridging-Header.h @@ -1 +1,2 @@ #import "TypesettingBridge.h" +#include "badge_engine/badge_engine.h" diff --git a/Readmigo/Vendor/BadgeEngine/BadgeEngine.xcconfig b/Readmigo/Vendor/BadgeEngine/BadgeEngine.xcconfig new file mode 100644 index 0000000..ed6134e --- /dev/null +++ b/Readmigo/Vendor/BadgeEngine/BadgeEngine.xcconfig @@ -0,0 +1,7 @@ +// Badge Engine C Static Library Configuration +// These settings are applied directly in the Xcode project build settings. +// This file serves as documentation/reference only. + +HEADER_SEARCH_PATHS = $(inherited) $(SRCROOT)/Readmigo/Vendor/BadgeEngine/include +LIBRARY_SEARCH_PATHS = $(inherited) $(SRCROOT)/Readmigo/Vendor/BadgeEngine/lib +OTHER_LDFLAGS = $(inherited) -lbadge_engine -lc++ -framework Metal -framework CoreGraphics -framework QuartzCore diff --git a/Readmigo/Vendor/BadgeEngine/VERSION b/Readmigo/Vendor/BadgeEngine/VERSION new file mode 100644 index 0000000..39e6d50 --- /dev/null +++ b/Readmigo/Vendor/BadgeEngine/VERSION @@ -0,0 +1,3 @@ +version=v0.1.0 +commit=initial +date=2026-03-23T01:00:00Z diff --git a/Readmigo/Vendor/BadgeEngine/include/badge_engine/badge_engine.h b/Readmigo/Vendor/BadgeEngine/include/badge_engine/badge_engine.h new file mode 100644 index 0000000..9b94ab6 --- /dev/null +++ b/Readmigo/Vendor/BadgeEngine/include/badge_engine/badge_engine.h @@ -0,0 +1,59 @@ +#ifndef BADGE_ENGINE_H +#define BADGE_ENGINE_H + +#include "types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * badge-engine: Cross-platform 3D badge/medal PBR renderer + * + * Usage: + * 1. badge_engine_create(config) -> engine handle + * 2. badge_engine_set_surface(engine, native_surface) + * - Android: pass ANativeWindow* + * - iOS: pass CAMetalLayer* + * 3. badge_engine_load_badge(engine, path_to_badge_file) + * 4. badge_engine_render_frame(engine) in render loop + * 5. badge_engine_destroy(engine) on cleanup + */ + +BadgeEngine* badge_engine_create(const BadgeEngineConfig* config); +void badge_engine_destroy(BadgeEngine* engine); + +/* Surface binding -- pass platform native surface as void* */ +int badge_engine_set_surface(BadgeEngine* engine, void* native_surface, uint32_t width, uint32_t height); + +/* Asset loading */ +int badge_engine_load_badge(BadgeEngine* engine, const char* badge_path); +void badge_engine_unload_badge(BadgeEngine* engine); + +/* Render mode */ +void badge_engine_set_render_mode(BadgeEngine* engine, BadgeRenderMode mode); + +/* Input */ +void badge_engine_update_gyro(BadgeEngine* engine, float x, float y, float z); +void badge_engine_on_touch(BadgeEngine* engine, const BadgeTouchEvent* event); + +/* Ceremony */ +void badge_engine_play_ceremony(BadgeEngine* engine, BadgeCeremonyType type); + +/* Direct orientation control -- rotation angles in radians, scale uniform */ +void badge_engine_set_orientation(BadgeEngine* engine, float rx, float ry, float rz, float scale); + +/* Render -- call once per frame */ +void badge_engine_render_frame(BadgeEngine* engine); + +/* Snapshot -- capture current frame to RGBA buffer (caller allocates) */ +int badge_engine_snapshot(BadgeEngine* engine, uint8_t* buffer, uint32_t width, uint32_t height); + +/* Callbacks */ +void badge_engine_set_callback(BadgeEngine* engine, BadgeEventCallback callback, void* user_data); + +#ifdef __cplusplus +} +#endif + +#endif /* BADGE_ENGINE_H */ diff --git a/Readmigo/Vendor/BadgeEngine/include/badge_engine/types.h b/Readmigo/Vendor/BadgeEngine/include/badge_engine/types.h new file mode 100644 index 0000000..c8c82fb --- /dev/null +++ b/Readmigo/Vendor/BadgeEngine/include/badge_engine/types.h @@ -0,0 +1,82 @@ +#ifndef BADGE_ENGINE_TYPES_H +#define BADGE_ENGINE_TYPES_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Opaque engine handle */ +typedef struct BadgeEngine BadgeEngine; + +/* Render modes */ +typedef enum { + BADGE_RENDER_EMBEDDED = 0, /* 30 FPS, Preview LOD, IBL only */ + BADGE_RENDER_FULLSCREEN = 1 /* 60 FPS, Full Detail LOD, full lighting + post-processing */ +} BadgeRenderMode; + +/* LOD levels */ +typedef enum { + BADGE_LOD_THUMBNAIL = 0, /* 1K tris, 256px textures */ + BADGE_LOD_PREVIEW = 1, /* 5K tris, 512px textures */ + BADGE_LOD_DETAIL = 2 /* 50K tris, 2048px textures */ +} BadgeLOD; + +/* Touch event types */ +typedef enum { + BADGE_TOUCH_DOWN = 0, + BADGE_TOUCH_MOVE = 1, + BADGE_TOUCH_UP = 2, + BADGE_TOUCH_CANCEL = 3 +} BadgeTouchType; + +/* Touch event */ +typedef struct { + BadgeTouchType type; + float x; + float y; + int32_t pointer_count; /* 1 = single, 2 = pinch */ + float x2; /* second pointer (pinch only) */ + float y2; +} BadgeTouchEvent; + +/* Ceremony types */ +typedef enum { + BADGE_CEREMONY_UNLOCK = 0 +} BadgeCeremonyType; + +/* Engine configuration */ +typedef struct { + uint32_t width; + uint32_t height; + BadgeRenderMode render_mode; + const char* presets_path; /* path to presets/ directory */ +} BadgeEngineConfig; + +/* Callback event types */ +typedef enum { + BADGE_EVENT_CEREMONY_PHASE = 0, /* phase index in data */ + BADGE_EVENT_CEREMONY_DONE = 1, + BADGE_EVENT_FLIP_TO_BACK = 2, + BADGE_EVENT_FLIP_TO_FRONT = 3, + BADGE_EVENT_HAPTIC = 4, /* haptic style in data */ + BADGE_EVENT_SOUND = 5, /* sound asset name in data_str */ + BADGE_EVENT_READY = 6 /* first frame rendered */ +} BadgeEventType; + +/* Callback event */ +typedef struct { + BadgeEventType type; + int32_t data; + const char* data_str; +} BadgeEvent; + +/* Callback function pointer */ +typedef void (*BadgeEventCallback)(const BadgeEvent* event, void* user_data); + +#ifdef __cplusplus +} +#endif + +#endif /* BADGE_ENGINE_TYPES_H */ diff --git a/Readmigo/Vendor/BadgeEngine/lib/libbadge_engine.a b/Readmigo/Vendor/BadgeEngine/lib/libbadge_engine.a new file mode 100644 index 0000000..f15276c Binary files /dev/null and b/Readmigo/Vendor/BadgeEngine/lib/libbadge_engine.a differ