// // WidgetExporter.swift // Feels // // Debug utility to export all widget previews to PNG files // #if DEBUG import SwiftUI import UIKit /// Exports widget previews to PNG files for App Store screenshots @MainActor class WidgetExporter { // MARK: - Widget Sizes (iPhone 15 Pro Max @ 3x) enum WidgetSize { case small // 170x170 pt = 510x510 px case medium // 364x170 pt = 1092x510 px case large // 364x382 pt = 1092x1146 px var pointSize: CGSize { switch self { case .small: return CGSize(width: 170, height: 170) case .medium: return CGSize(width: 364, height: 170) case .large: return CGSize(width: 382, height: 382) } } var name: String { switch self { case .small: return "small" case .medium: return "medium" case .large: return "large" } } } // MARK: - Export All Widgets static func exportAllWidgets() async -> URL? { // Create export directory let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let exportPath = documentsPath.appendingPathComponent("WidgetExports", isDirectory: true) try? FileManager.default.removeItem(at: exportPath) try? FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true) // Export each widget type in both color schemes for colorScheme in [ColorScheme.light, ColorScheme.dark] { let schemeName = colorScheme == .light ? "light" : "dark" // Vote Widget - Not Voted await exportVoteWidget(hasVoted: false, mood: nil, size: .small, colorScheme: colorScheme, to: exportPath, name: "vote_\(schemeName)_small_notvoted") await exportVoteWidget(hasVoted: false, mood: nil, size: .medium, colorScheme: colorScheme, to: exportPath, name: "vote_\(schemeName)_medium_notvoted") // Vote Widget - Voted (all moods) for mood in Mood.allValues { await exportVoteWidget(hasVoted: true, mood: mood, size: .small, colorScheme: colorScheme, to: exportPath, name: "vote_\(schemeName)_small_\(mood.strValue.lowercased())") await exportVoteWidget(hasVoted: true, mood: mood, size: .medium, colorScheme: colorScheme, to: exportPath, name: "vote_\(schemeName)_medium_\(mood.strValue.lowercased())") } // Timeline Widget - Logged await exportTimelineWidget(hasVoted: true, size: .small, colorScheme: colorScheme, to: exportPath, name: "timeline_\(schemeName)_small_logged") await exportTimelineWidget(hasVoted: true, size: .medium, colorScheme: colorScheme, to: exportPath, name: "timeline_\(schemeName)_medium_logged") await exportTimelineWidget(hasVoted: true, size: .large, colorScheme: colorScheme, to: exportPath, name: "timeline_\(schemeName)_large_logged") // Timeline Widget - Voting await exportTimelineWidget(hasVoted: false, size: .small, colorScheme: colorScheme, to: exportPath, name: "timeline_\(schemeName)_small_voting") await exportTimelineWidget(hasVoted: false, size: .medium, colorScheme: colorScheme, to: exportPath, name: "timeline_\(schemeName)_medium_voting") await exportTimelineWidget(hasVoted: false, size: .large, colorScheme: colorScheme, to: exportPath, name: "timeline_\(schemeName)_large_voting") // Live Activity - Lock Screen (213 streak, all moods + not logged) await exportLiveActivity(hasLogged: false, mood: nil, streak: 213, colorScheme: colorScheme, to: exportPath, name: "liveactivity_\(schemeName)_notlogged") for mood in Mood.allValues { await exportLiveActivity(hasLogged: true, mood: mood, streak: 213, colorScheme: colorScheme, to: exportPath, name: "liveactivity_\(schemeName)_\(mood.strValue.lowercased())") } } print("📸 Widgets exported to: \(exportPath.path)") return exportPath } // MARK: - Vote Widget Export private static func exportVoteWidget(hasVoted: Bool, mood: Mood?, size: WidgetSize, colorScheme: ColorScheme, to folder: URL, name: String) async { let content: AnyView if hasVoted, let mood = mood { // Voted state content = AnyView( ExportVotedStatsView(mood: mood, totalEntries: 117, isSmall: size == .small) ) } else { // Not voted state - show voting buttons content = AnyView( ExportVotingView(isSmall: size == .small) ) } let view = WidgetContainer(size: size, colorScheme: colorScheme, content: content) await renderAndSave(view: view, size: size, to: folder, name: name) } // MARK: - Timeline Widget Export private static func exportTimelineWidget(hasVoted: Bool, size: WidgetSize, colorScheme: ColorScheme, to folder: URL, name: String) async { let timelineData = createSampleTimelineData(count: size == .large ? 10 : (size == .medium ? 5 : 1)) let content: AnyView switch size { case .small: content = AnyView( TimelineSmallExportView(timelineData: timelineData.first, hasVoted: hasVoted) ) case .medium: content = AnyView( TimelineMediumExportView(timelineData: Array(timelineData.prefix(5)), hasVoted: hasVoted) ) case .large: content = AnyView( TimelineLargeExportView(timelineData: timelineData, hasVoted: hasVoted) ) } let view = WidgetContainer(size: size, colorScheme: colorScheme, content: content, useSystemBackground: hasVoted) await renderAndSave(view: view, size: size, to: folder, name: name) } // MARK: - Live Activity Export /// Live Activity lock screen size (iPhone 15 Pro Max) static let liveActivitySize = CGSize(width: 370, height: 100) private static func exportLiveActivity(hasLogged: Bool, mood: Mood?, streak: Int, colorScheme: ColorScheme, to folder: URL, name: String) async { let content = ExportLiveActivityView( streak: streak, hasLoggedToday: hasLogged, mood: mood ) let view = content .frame(width: liveActivitySize.width, height: liveActivitySize.height) .background( colorScheme == .dark ? Color(UIColor.systemBackground).opacity(0.8) : Color(UIColor.secondarySystemBackground) ) .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) .environment(\.colorScheme, colorScheme) await renderAndSaveLiveActivity(view: view, to: folder, name: name) } private static func renderAndSaveLiveActivity(view: V, to folder: URL, name: String) async { let renderer = ImageRenderer(content: view.frame(width: liveActivitySize.width, height: liveActivitySize.height)) renderer.scale = 3.0 if let image = renderer.uiImage { let url = folder.appendingPathComponent("\(name).png") if let data = image.pngData() { try? data.write(to: url) } } } // MARK: - Render and Save private static func renderAndSave(view: V, size: WidgetSize, to folder: URL, name: String) async { let renderer = ImageRenderer(content: view.frame(width: size.pointSize.width, height: size.pointSize.height)) renderer.scale = 3.0 // 3x for high res if let image = renderer.uiImage { let url = folder.appendingPathComponent("\(name).png") if let data = image.pngData() { try? data.write(to: url) } } } // MARK: - Sample Data struct TimelineDataItem: Identifiable { let id = UUID() let mood: Mood let date: Date let color: Color let image: Image } private static func createSampleTimelineData(count: Int) -> [TimelineDataItem] { let moods: [Mood] = [.great, .good, .average, .good, .great, .average, .bad, .good, .great, .good] let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable() let moodImages: MoodImagable.Type = UserDefaultsStore.moodMoodImagable() return (0..: View { let size: WidgetExporter.WidgetSize let colorScheme: ColorScheme let content: Content var useSystemBackground: Bool = false var body: some View { content .frame(width: size.pointSize.width, height: size.pointSize.height) .background(useSystemBackground ? Color(UIColor.systemBackground) : Color(UIColor.tertiarySystemFill)) .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) .environment(\.colorScheme, colorScheme) } } // MARK: - Export Voting View (static, no interactive intents) private struct ExportVotingView: View { let isSmall: Bool private var moodTint: MoodTintable.Type { UserDefaultsStore.moodTintable() } private var moodImages: MoodImagable.Type { UserDefaultsStore.moodMoodImagable() } var body: some View { if isSmall { smallLayout } else { mediumLayout } } private var smallLayout: some View { VStack(spacing: 8) { // Top row: Great, Good, Average HStack(spacing: 12) { ForEach([Mood.great, .good, .average], id: \.rawValue) { mood in moodIcon(for: mood, size: 40) } } // Bottom row: Bad, Horrible HStack(spacing: 12) { ForEach([Mood.bad, .horrible], id: \.rawValue) { mood in moodIcon(for: mood, size: 40) } } } .padding(.horizontal, 8) .padding(.vertical, 8) } private var mediumLayout: some View { VStack(spacing: 12) { Text("How are you feeling?") .font(.headline) .foregroundStyle(.primary) .multilineTextAlignment(.center) HStack(spacing: 0) { ForEach([Mood.great, .good, .average, .bad, .horrible], id: \.rawValue) { mood in moodImages.icon(forMood: mood) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 36, height: 36) .foregroundColor(moodTint.color(forMood: mood)) .frame(maxWidth: .infinity) } } } .padding(.horizontal, 12) .padding(.vertical, 16) } private func moodIcon(for mood: Mood, size: CGFloat) -> some View { moodImages.icon(forMood: mood) .resizable() .aspectRatio(contentMode: .fit) .frame(width: size, height: size) .foregroundColor(moodTint.color(forMood: mood)) } } // MARK: - Export Voted Stats View private struct ExportVotedStatsView: View { let mood: Mood let totalEntries: Int let isSmall: Bool private var moodTint: MoodTintable.Type { UserDefaultsStore.moodTintable() } private var moodImages: MoodImagable.Type { UserDefaultsStore.moodMoodImagable() } private let moodCounts: [Mood: Int] = [.great: 45, .good: 42, .average: 18, .bad: 8, .horrible: 4] var body: some View { if isSmall { smallLayout } else { mediumLayout } } private var smallLayout: some View { VStack(spacing: 8) { ZStack(alignment: .bottomTrailing) { moodImages.icon(forMood: mood) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 56, height: 56) .foregroundColor(moodTint.color(forMood: mood)) Image(systemName: "checkmark.circle.fill") .font(.headline) .foregroundColor(.green) .background(Circle().fill(.white).frame(width: 14, height: 14)) .offset(x: 4, y: 4) } Text("Today") .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) Text("\(totalEntries) day streak") .font(.caption2) .foregroundStyle(.tertiary) } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(12) } private var mediumLayout: some View { HStack(alignment: .top, spacing: 20) { VStack(spacing: 6) { moodImages.icon(forMood: mood) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 48, height: 48) .foregroundColor(moodTint.color(forMood: mood)) Text(mood.widgetDisplayName) .font(.subheadline.weight(.semibold)) .foregroundColor(moodTint.color(forMood: mood)) Text("Today") .font(.caption2) .foregroundStyle(.secondary) } VStack(alignment: .leading, spacing: 10) { Text("\(totalEntries) entries") .font(.headline.weight(.semibold)) .foregroundStyle(.primary) HStack(spacing: 6) { ForEach([Mood.great, .good, .average, .bad, .horrible], id: \.rawValue) { m in let count = moodCounts[m, default: 0] if count > 0 { HStack(spacing: 2) { Circle() .fill(moodTint.color(forMood: m)) .frame(width: 8, height: 8) Text("\(count)") .font(.caption2) .foregroundStyle(.secondary) } } } } GeometryReader { geo in HStack(spacing: 1) { ForEach([Mood.great, .good, .average, .bad, .horrible], id: \.rawValue) { m in let percentage = Double(moodCounts[m, default: 0]) / Double(totalEntries) * 100 if percentage > 0 { RoundedRectangle(cornerRadius: 2) .fill(moodTint.color(forMood: m)) .frame(width: max(4, geo.size.width * CGFloat(percentage) / 100)) } } } } .frame(height: 10) .clipShape(RoundedRectangle(cornerRadius: 5)) } .frame(maxWidth: .infinity, alignment: .leading) } .padding() } } // MARK: - Timeline Export Views private struct TimelineSmallExportView: View { let timelineData: WidgetExporter.TimelineDataItem? let hasVoted: Bool private var dayFormatter: DateFormatter { let f = DateFormatter() f.dateFormat = "EEEE" return f } private var dateFormatter: DateFormatter { let f = DateFormatter() f.dateFormat = "MMM d" return f } var body: some View { if !hasVoted { ExportVotingView(isSmall: true) } else if let today = timelineData { VStack(spacing: 0) { Spacer() today.image .resizable() .aspectRatio(contentMode: .fit) .frame(width: 70, height: 70) .foregroundColor(today.color) Spacer().frame(height: 12) VStack(spacing: 2) { Text(dayFormatter.string(from: today.date)) .font(.caption2.weight(.medium)) .foregroundStyle(.secondary) .textCase(.uppercase) Text(dateFormatter.string(from: today.date)) .font(.subheadline.weight(.semibold)) .foregroundStyle(.primary) } Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) } } } private struct TimelineMediumExportView: View { let timelineData: [WidgetExporter.TimelineDataItem] let hasVoted: Bool private var dayFormatter: DateFormatter { let f = DateFormatter() f.dateFormat = "EEE" return f } private var dateFormatter: DateFormatter { let f = DateFormatter() f.dateFormat = "d" return f } private var headerDateRange: String { guard let first = timelineData.first, let last = timelineData.last else { return "" } let formatter = DateFormatter() formatter.dateFormat = "MMM d" return "\(formatter.string(from: last.date)) - \(formatter.string(from: first.date))" } var body: some View { if !hasVoted { ExportVotingView(isSmall: false) } else { GeometryReader { geo in let cellHeight = geo.size.height - 36 VStack(spacing: 4) { HStack { Text("Last 5 Days") .font(.subheadline.weight(.semibold)) .foregroundStyle(.primary) Text("·") .foregroundStyle(.secondary) Text(headerDateRange) .font(.caption) .foregroundStyle(.secondary) Spacer() } .padding(.horizontal, 14) .padding(.top, 10) HStack(spacing: 8) { ForEach(Array(timelineData.enumerated()), id: \.element.id) { index, item in ExportMediumDayCell( dayLabel: dayFormatter.string(from: item.date), dateLabel: dateFormatter.string(from: item.date), image: item.image, color: item.color, isToday: index == 0, height: cellHeight ) } } .padding(.horizontal, 10) .padding(.bottom, 10) } } } } } private struct ExportMediumDayCell: View { let dayLabel: String let dateLabel: String let image: Image let color: Color let isToday: Bool let height: CGFloat var body: some View { ZStack { RoundedRectangle(cornerRadius: 14) .fill(color.opacity(isToday ? 0.25 : 0.12)) .frame(height: height) VStack(spacing: 4) { Text(dayLabel) .font(.caption2.weight(isToday ? .bold : .medium)) .foregroundStyle(isToday ? .primary : .secondary) .textCase(.uppercase) image .resizable() .aspectRatio(contentMode: .fit) .frame(width: 36, height: 36) .foregroundColor(color) Text(dateLabel) .font(.caption.weight(isToday ? .bold : .semibold)) .foregroundStyle(isToday ? color : .secondary) } } .frame(maxWidth: .infinity) } } private struct TimelineLargeExportView: View { let timelineData: [WidgetExporter.TimelineDataItem] let hasVoted: Bool private var dayFormatter: DateFormatter { let f = DateFormatter() f.dateFormat = "EEE" return f } private var dateFormatter: DateFormatter { let f = DateFormatter() f.dateFormat = "d" return f } private var headerDateRange: String { guard let first = timelineData.first, let last = timelineData.last else { return "" } let formatter = DateFormatter() formatter.dateFormat = "MMM d" return "\(formatter.string(from: last.date)) - \(formatter.string(from: first.date))" } var body: some View { if !hasVoted { ExportLargeVotingView() } else { GeometryReader { geo in let cellHeight = (geo.size.height - 70) / 2 VStack(spacing: 6) { HStack { VStack(alignment: .leading, spacing: 2) { Text("Last 10 Days") .font(.subheadline.weight(.semibold)) .foregroundStyle(.primary) Text(headerDateRange) .font(.caption2) .foregroundStyle(.secondary) } Spacer() } .padding(.horizontal, 12) .padding(.top, 8) VStack(spacing: 6) { HStack(spacing: 6) { ForEach(Array(timelineData.prefix(5).enumerated()), id: \.element.id) { index, item in ExportDayCell( dayLabel: dayFormatter.string(from: item.date), dateLabel: dateFormatter.string(from: item.date), image: item.image, color: item.color, isToday: index == 0, height: cellHeight ) } } HStack(spacing: 6) { ForEach(Array(timelineData.suffix(5).enumerated()), id: \.element.id) { _, item in ExportDayCell( dayLabel: dayFormatter.string(from: item.date), dateLabel: dateFormatter.string(from: item.date), image: item.image, color: item.color, isToday: false, height: cellHeight ) } } } .padding(.horizontal, 10) .padding(.bottom, 8) } } } } } private struct ExportLargeVotingView: View { private var moodTint: MoodTintable.Type { UserDefaultsStore.moodTintable() } private var moodImages: MoodImagable.Type { UserDefaultsStore.moodMoodImagable() } var body: some View { VStack(spacing: 16) { Spacer() Text("How are you feeling?") .font(.title3.weight(.semibold)) .foregroundStyle(.primary) .multilineTextAlignment(.center) HStack(spacing: 0) { ForEach([Mood.great, .good, .average, .bad, .horrible], id: \.rawValue) { mood in moodImages.icon(forMood: mood) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 44, height: 44) .foregroundColor(moodTint.color(forMood: mood)) .padding(10) .background( RoundedRectangle(cornerRadius: 12) .fill(moodTint.color(forMood: mood).opacity(0.15)) ) .frame(maxWidth: .infinity) } } Spacer() } .padding(.horizontal, 12) .padding(.vertical, 16) } } private struct ExportDayCell: View { let dayLabel: String let dateLabel: String let image: Image let color: Color let isToday: Bool let height: CGFloat var body: some View { VStack(spacing: 2) { Text(dayLabel) .font(.caption2.weight(isToday ? .bold : .medium)) .foregroundStyle(isToday ? .primary : .secondary) .textCase(.uppercase) ZStack { RoundedRectangle(cornerRadius: 14) .fill(color.opacity(isToday ? 0.25 : 0.12)) .frame(height: height - 16) VStack(spacing: 6) { image .resizable() .aspectRatio(contentMode: .fit) .frame(width: 38, height: 38) .foregroundColor(color) Text(dateLabel) .font(.caption.weight(isToday ? .bold : .semibold)) .foregroundStyle(isToday ? color : .secondary) } } } .frame(maxWidth: .infinity) } } // MARK: - Export Live Activity View (Lock Screen) private struct ExportLiveActivityView: View { let streak: Int let hasLoggedToday: Bool let mood: Mood? private var moodTint: MoodTintable.Type { UserDefaultsStore.moodTintable() } var body: some View { HStack(spacing: 16) { // Streak indicator VStack(spacing: 4) { Image(systemName: "flame.fill") .font(.title) .foregroundColor(.orange) Text("\(streak)") .font(.title.bold()) Text("day streak") .font(.caption) .foregroundColor(.secondary) } Divider() .frame(height: 50) // Status VStack(alignment: .leading, spacing: 8) { if hasLoggedToday, let mood = mood { HStack(spacing: 8) { Circle() .fill(moodTint.color(forMood: mood)) .frame(width: 24, height: 24) VStack(alignment: .leading) { Text("Today's mood") .font(.caption) .foregroundColor(.secondary) Text(mood.widgetDisplayName) .font(.headline) } } } else { VStack(alignment: .leading) { Text("Don't break your streak!") .font(.headline) Text("Tap to log your mood") .font(.caption) .foregroundColor(.secondary) } } } Spacer() } .padding() } } #endif