import SwiftUI import AppKit import AtollExtensionKit import Lottie import LottieUI import CryptoKit // MARK: - Color Conversion extension AtollColorDescriptor { var swiftUIColor: Color { if isAccent { return .accentColor } return Color(red: red, green: green, blue: blue, opacity: alpha) } var nsColor: NSColor { if isAccent { return NSColor.controlAccentColor } return NSColor(red: red, green: green, blue: blue, alpha: alpha) } } // MARK: - Font Conversion extension AtollFontDescriptor { func swiftUIFont() -> Font { let design = self.design.swiftUI let weight = self.weight.swiftUI var font = Font.system(size: size, weight: weight, design: design) if isMonospacedDigit { font = font.monospacedDigit() } return font } func nsFont() -> NSFont { let weight = weight.nsFont let font: NSFont switch design { case .serif: font = NSFont.userFont(ofSize: size) ?? NSFont.systemFont(ofSize: size, weight: weight) case .rounded: if let descriptor = NSFont.systemFont(ofSize: size, weight: weight).fontDescriptor.withDesign(.rounded) { font = NSFont(descriptor: descriptor, size: size) ?? NSFont.systemFont(ofSize: size, weight: weight) } else { font = NSFont.systemFont(ofSize: size, weight: weight) } case .monospaced: font = NSFont.monospacedSystemFont(ofSize: size, weight: weight) case .default: font = NSFont.systemFont(ofSize: size, weight: weight) } return font } } private extension AtollFontWeight { var swiftUI: Font.Weight { switch self { case .ultraLight: return .ultraLight case .thin: return .thin case .light: return .light case .regular: return .regular case .medium: return .medium case .semibold: return .semibold case .bold: return .bold case .heavy: return .heavy case .black: return .black } } var nsFont: NSFont.Weight { switch self { case .ultraLight: return .ultraLight case .thin: return .thin case .light: return .light case .regular: return .regular case .medium: return .medium case .semibold: return .semibold case .bold: return .bold case .heavy: return .heavy case .black: return .black } } } private extension AtollFontDesign { var swiftUI: Font.Design { switch self { case .default: return .default case .serif: return .serif case .rounded: return .rounded case .monospaced: return .monospaced } } } // MARK: - Icon Rendering struct ExtensionIconView: View { let descriptor: AtollIconDescriptor let tint: Color let size: CGSize let cornerRadius: CGFloat var body: some View { switch descriptor { case let .symbol(name, glyphSize, weight): Image(systemName: name) .font(.system(size: glyphSize, weight: weight.swiftUI)) .foregroundStyle(tint) .frame(width: size.width, height: size.height) case let .image(data, targetSize, radius): if let image = NSImage(data: data) { Image(nsImage: image) .resizable() .aspectRatio(contentMode: .fill) .frame(width: targetSize.width, height: targetSize.height) .clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous)) } else { fallbackSymbol } case let .appIcon(bundleIdentifier, targetSize, radius): if let icon = AppIconAsNSImage(for: bundleIdentifier) { Image(nsImage: icon) .resizable() .aspectRatio(contentMode: .fill) .frame(width: targetSize.width, height: targetSize.height) .clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous)) } else { fallbackSymbol } case let .lottie(animationData, targetSize): let resolvedSize = CGSize(width: min(targetSize.width, size.width), height: min(targetSize.height, size.height)) ExtensionLottieView(data: animationData, size: resolvedSize) .frame(width: size.width, height: size.height) .background( RoundedRectangle(cornerRadius: cornerRadius(for: descriptor), style: .continuous) .fill(Color.white.opacity(0.08)) ) case .none: fallbackSymbol } } private var fallbackSymbol: some View { Image(systemName: "app.dashed") .font(.system(size: size.width * 0.6, weight: .medium)) .foregroundStyle(tint) .frame(width: size.width, height: size.height) .background( RoundedRectangle(cornerRadius: cornerRadius(for: descriptor), style: .continuous) .fill(tint.opacity(0.15)) ) } private func cornerRadius(for descriptor: AtollIconDescriptor) -> CGFloat { switch descriptor { case .image(_, _, let radius): return radius case .appIcon(_, _, let radius): return radius default: return cornerRadius } } } struct ExtensionCompositeIconView: View { let leading: AtollIconDescriptor let badge: AtollIconDescriptor? let accent: Color let size: CGFloat var body: some View { ZStack(alignment: .bottomTrailing) { ExtensionIconView( descriptor: leading, tint: accent, size: CGSize(width: size, height: size), cornerRadius: size * 0.18 ) if let badge { ExtensionIconView( descriptor: badge, tint: .white, size: CGSize(width: max(size * 0.35, 12), height: max(size * 0.35, 12)), cornerRadius: size * 0.12 ) .background( Circle() .fill(Color.black.opacity(0.7)) .frame(width: size * 0.4, height: size * 0.4) ) } } .frame(width: size, height: size) } } // MARK: - Lottie Rendering struct ExtensionLottieView: View { let data: Data let size: CGSize var loopMode: LottieLoopMode = .loop @State private var cachedURL: URL? var body: some View { Group { if let cachedURL { LottieView( state: LUStateData( type: .loadedFrom(cachedURL), speed: 1.0, loopMode: loopMode ) ) } else { ProgressView() .controlSize(.small) .tint(.white.opacity(0.7)) } } .frame(width: size.width, height: size.height) .task { if cachedURL == nil { cachedURL = ExtensionLottieCache.shared.url(for: data) } } } } private final class ExtensionLottieCache { static let shared = ExtensionLottieCache() private let queue = DispatchQueue(label: "com.atoll.extension-lottie-cache") private var cache: [String: URL] = [:] func url(for data: Data) -> URL? { queue.sync { let key = Self.hash(data: data) if let existing = cache[key], FileManager.default.fileExists(atPath: existing.path) { return existing } let url = FileManager.default.temporaryDirectory.appendingPathComponent("extension-lottie-\(key).json", conformingTo: .json) do { try data.write(to: url, options: .atomic) cache[key] = url return url } catch { cache.removeValue(forKey: key) return nil } } } private static func hash(data: Data) -> String { let digest = SHA256.hash(data: data) return digest.map { String(format: "%02x", $0) }.joined() } } // MARK: - Progress Rendering struct ExtensionProgressIndicatorView: View { let indicator: AtollProgressIndicator let progress: Double let accent: Color let estimatedDuration: TimeInterval? let maxVisualHeight: CGFloat? init( indicator: AtollProgressIndicator, progress: Double, accent: Color, estimatedDuration: TimeInterval?, maxVisualHeight: CGFloat? = nil ) { self.indicator = indicator self.progress = progress self.accent = accent self.estimatedDuration = estimatedDuration self.maxVisualHeight = maxVisualHeight } var body: some View { switch indicator { case let .ring(diameter, strokeWidth, color): let ringColor = color?.swiftUIColor ?? accent let resolvedDiameter = resolvedRingDiameter(requested: diameter) ZStack { Circle() .stroke(Color.white.opacity(0.15), lineWidth: strokeWidth) Circle() .trim(from: 0, to: CGFloat(max(0, min(progress, 1)))) .stroke(ringColor, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round)) .rotationEffect(.degrees(-90)) .animation(.smooth(duration: 0.25), value: progress) } .frame(width: resolvedDiameter, height: resolvedDiameter) case let .bar(width, height, cornerRadius, color): let barColor = color?.swiftUIColor ?? accent Capsule() .fill(Color.white.opacity(0.18)) .frame(width: width ?? 80, height: height) .overlay(alignment: .leading) { Capsule() .fill(barColor) .frame(width: (width ?? 80) * CGFloat(max(0, min(progress, 1))), height: height) .animation(.smooth(duration: 0.25), value: progress) } case let .percentage(font, color): let textColor = color?.swiftUIColor ?? accent Text("\(Int(progress * 100))%") .font(font.swiftUIFont()) .foregroundStyle(textColor) .monospacedDigit() case let .countdown(font, color): let textColor = color?.swiftUIColor ?? accent let text = countdownText Text(text) .font(font.swiftUIFont()) .foregroundStyle(textColor) .monospacedDigit() .contentTransition(.numericText()) .animation(.smooth(duration: 0.25), value: text) case .lottie: Image(systemName: "sparkles") .font(.system(size: 14, weight: .medium)) .foregroundStyle(accent) case .none: EmptyView() } } private var countdownText: String { guard let estimatedDuration else { return formatPercent } let remaining = max(estimatedDuration * (1 - progress), 0) let hours = Int(remaining) / 3600 let minutes = (Int(remaining) % 3600) / 60 let seconds = Int(remaining) % 60 if hours > 0 { return String(format: "%02d:%02d:%02d", hours, minutes, seconds) } return String(format: "%02d:%02d", minutes, seconds) } private var formatPercent: String { "\(Int(progress * 100))%" } private func resolvedRingDiameter(requested: CGFloat) -> CGFloat { guard let maxVisualHeight else { return requested } let target = max(min(maxVisualHeight - 12, 24), 16) return min(requested, target) } } // MARK: - Edge Content Rendering struct ExtensionEdgeContentView: View { let content: AtollTrailingContent let accent: Color let availableWidth: CGFloat let alignment: Alignment var body: some View { switch content { case let .text(value, font: font, color: color): Text(value) .font(font.swiftUIFont()) .foregroundStyle(resolvedColor(color)) .lineLimit(1) .frame(maxWidth: .infinity, alignment: alignment) case let .marquee(value, font: font, minDuration: duration, color: color): MarqueeText( .constant(value), font: font.swiftUIFont(), nsFont: .body, textColor: resolvedColor(color), minDuration: duration, frameWidth: max(24, availableWidth - 12) ) case let .countdownText(targetDate, font: font, color: color): ExtensionCountdownTextView(targetDate: targetDate, font: font, accent: accent, customColor: color?.swiftUIColor) .frame(maxWidth: .infinity, alignment: alignment) case let .icon(descriptor): ExtensionIconView( descriptor: descriptor, tint: accent, size: CGSize(width: 26, height: 26), cornerRadius: 6 ) case let .spectrum(color: colorDescriptor): Rectangle() .fill((colorDescriptor.isAccent ? accent : colorDescriptor.swiftUIColor).gradient) .frame(width: 48, height: 14) .mask { AudioSpectrumView(isPlaying: .constant(true)) .frame(width: 16, height: 12) } case let .animation(data, size): let resolvedWidth = min(size.width, availableWidth) ExtensionLottieView(data: data, size: CGSize(width: resolvedWidth, height: size.height)) case .none: EmptyView() } } private func resolvedColor(_ descriptorColor: AtollColorDescriptor?) -> Color { descriptorColor?.swiftUIColor ?? accent } } struct ExtensionCountdownTextView: View { let targetDate: Date let font: AtollFontDescriptor let accent: Color let customColor: Color? var body: some View { TimelineView(.periodic(from: .now, by: 1)) { context in let remainingText = formattedRemaining(since: context.date) Text(remainingText) .font(font.swiftUIFont()) .foregroundStyle(customColor ?? accent) .monospacedDigit() .contentTransition(.numericText()) .animation(.smooth(duration: 0.25), value: remainingText) } } private func formattedRemaining(since date: Date) -> String { let remaining = max(Int(targetDate.timeIntervalSince(date)), 0) let hours = remaining / 3600 let minutes = (remaining % 3600) / 60 let seconds = remaining % 60 if hours > 0 { return String(format: "%02d:%02d:%02d", hours, minutes, seconds) } return String(format: "%02d:%02d", minutes, seconds) } } // MARK: - Layout Metrics enum ExtensionLayoutMetrics { static func trailingWidth(for payload: ExtensionLiveActivityPayload, baseWidth: CGFloat, maxWidth: CGFloat? = nil) -> CGFloat { let renderable = resolvedExtensionTrailingRenderable(for: payload.descriptor) var width: CGFloat switch renderable { case let .content(content): width = edgeWidth(for: content, baseWidth: baseWidth, maxWidth: maxWidth) case let .indicator(indicator): width = edgeWidth(for: .none, baseWidth: baseWidth, maxWidth: maxWidth) width = max(width, widthForProgress(indicator)) } if let maxWidth { width = min(width, maxWidth) } return width } static func edgeWidth(for content: AtollTrailingContent, baseWidth: CGFloat, maxWidth: CGFloat? = nil) -> CGFloat { var width = widthForContent(content, baseWidth: baseWidth) if let maxWidth { width = min(width, maxWidth) } return width } private static func widthForContent(_ content: AtollTrailingContent, baseWidth: CGFloat) -> CGFloat { switch content { case let .text(text, font: font, color: _): let measured = ExtensionTextMeasurer.width(for: text, font: font.nsFont()) return max(baseWidth, measured + 32) case let .marquee(text, font: font, minDuration: _, color: _): let measured = ExtensionTextMeasurer.width(for: text, font: font.nsFont()) return max(baseWidth, min(measured + 40, baseWidth * 2)) case let .countdownText(targetDate, font: font, color: _): let sample = targetDate.timeIntervalSinceNow >= 3600 ? "00:00:00" : "00:00" let measured = ExtensionTextMeasurer.width(for: sample, font: font.nsFont()) return max(baseWidth, measured + 24) case .icon: return max(baseWidth, 52) case .spectrum: return max(baseWidth, 56) case let .animation(data: _, size: size): return max(baseWidth, size.width + 16) case .none: return baseWidth } } private static func widthForProgress(_ indicator: AtollProgressIndicator) -> CGFloat { switch indicator { case let .ring(diameter, _, _): return diameter + 20 case let .bar(width, _, _, _): return (width ?? 72) + 12 case .percentage(_, _): return 60 case .countdown(_, _): return 74 case let .lottie(_, size): return size.width + 16 case .none: return 0 } } } enum ExtensionTextMeasurer { static func width(for text: String, font: NSFont) -> CGFloat { let attributes: [NSAttributedString.Key: Any] = [.font: font] return max(1, text.size(withAttributes: attributes).width) } } enum ExtensionTrailingRenderable { case content(AtollTrailingContent) case indicator(AtollProgressIndicator) } func resolvedExtensionLeadingContent(for descriptor: AtollLiveActivityDescriptor) -> AtollTrailingContent { guard let override = descriptor.leadingContent else { return .icon(descriptor.leadingIcon) } switch override { case .icon, .animation: return override default: return .icon(descriptor.leadingIcon) } } func resolvedExtensionTrailingRenderable(for descriptor: AtollLiveActivityDescriptor) -> ExtensionTrailingRenderable { if let indicator = descriptor.progressIndicator, indicator.isRenderable, descriptor.trailingContent == .none { return .indicator(indicator) } return .content(descriptor.trailingContent) } private extension AtollProgressIndicator { var isRenderable: Bool { switch self { case .none: return false default: return true } } }