// // WidgetExporter.swift // Reflect // // Debug utility to export all widget previews to PNG files. // Uses the real widget view layouts from ExportableWidgetViews.swift. // #if DEBUG import SwiftUI import UIKit import os.log /// 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 // 382x382 pt = 1146x1146 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" } } } /// Live Activity lock screen size (iPhone 15 Pro Max) static let liveActivitySize = CGSize(width: 370, height: 100) // MARK: - Available Theme Combinations /// All available tint options for export static let allTints: [(name: String, tint: MoodTintable.Type)] = [ ("Default", DefaultMoodTint.self), ("Neon", NeonMoodTint.self), ("Pastel", PastelTint.self), ("Monochrome", MonoChromeTint.self) ] /// All available icon options for export static let allIcons: [(name: String, images: MoodImagable.Type)] = [ ("Emoji", EmojiMoodImages.self), ("FontAwesome", FontAwesomeMoodImages.self), ("Weather", WeatherMoodImages.self), ("Garden", GardenMoodImages.self), ("Hearts", HeartsMoodImages.self), ("Cosmic", CosmicMoodImages.self), ("HandEmoji", HandEmojiMoodImages.self) ] /// Sample mood stats for voted state exports static let sampleMoodCounts: [Mood: Int] = [.great: 45, .good: 42, .average: 18, .bad: 8, .horrible: 4] static let sampleTotalEntries = 117 // MARK: - Export All Widgets (All Variations) /// Exports all widget variations to disk /// - Returns: URL to the export directory, or nil if failed static func exportAllWidgets() async -> URL? { let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let exportPath = documentsPath.appendingPathComponent("WidgetExports", isDirectory: true) // Clean and create export directory try? FileManager.default.removeItem(at: exportPath) do { try FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true) } catch { AppLogger.export.error("Failed to create widget export directory: \(error)") return nil } var totalExported = 0 // Export all tint + icon combinations for tintOption in allTints { for iconOption in allIcons { let folderName = "\(tintOption.name)_\(iconOption.name)" let variantPath = exportPath.appendingPathComponent(folderName, isDirectory: true) do { try FileManager.default.createDirectory(at: variantPath, withIntermediateDirectories: true) } catch { AppLogger.export.error("Failed to create variant directory '\(folderName)': \(error)") continue } let config = WidgetExportConfig( moodTint: tintOption.tint, moodImages: iconOption.images ) let count = await exportWidgetsForConfig(config: config, to: variantPath) totalExported += count print(" Exported \(count) images to \(folderName)/") } } print("📸 Total \(totalExported) widgets exported to: \(exportPath.path)") return exportPath } /// Exports widgets for a single tint/icon configuration private static func exportWidgetsForConfig(config: WidgetExportConfig, to folder: URL) async -> Int { var count = 0 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, config: config, colorScheme: colorScheme, to: folder, name: "vote_\(schemeName)_small_notvoted") await exportVoteWidget(hasVoted: false, mood: nil, size: .medium, config: config, colorScheme: colorScheme, to: folder, name: "vote_\(schemeName)_medium_notvoted") count += 2 // Vote Widget - Voted (all moods) for mood in Mood.allValues { await exportVoteWidget(hasVoted: true, mood: mood, size: .small, config: config, colorScheme: colorScheme, to: folder, name: "vote_\(schemeName)_small_\(mood.strValue.lowercased())") await exportVoteWidget(hasVoted: true, mood: mood, size: .medium, config: config, colorScheme: colorScheme, to: folder, name: "vote_\(schemeName)_medium_\(mood.strValue.lowercased())") count += 2 } // Timeline Widget - Logged await exportTimelineWidget(hasVoted: true, size: .small, config: config, colorScheme: colorScheme, to: folder, name: "timeline_\(schemeName)_small_logged") await exportTimelineWidget(hasVoted: true, size: .medium, config: config, colorScheme: colorScheme, to: folder, name: "timeline_\(schemeName)_medium_logged") await exportTimelineWidget(hasVoted: true, size: .large, config: config, colorScheme: colorScheme, to: folder, name: "timeline_\(schemeName)_large_logged") count += 3 // Timeline Widget - Voting await exportTimelineWidget(hasVoted: false, size: .small, config: config, colorScheme: colorScheme, to: folder, name: "timeline_\(schemeName)_small_voting") await exportTimelineWidget(hasVoted: false, size: .medium, config: config, colorScheme: colorScheme, to: folder, name: "timeline_\(schemeName)_medium_voting") await exportTimelineWidget(hasVoted: false, size: .large, config: config, colorScheme: colorScheme, to: folder, name: "timeline_\(schemeName)_large_voting") count += 3 // Live Activity - Not Logged await exportLiveActivity(hasLogged: false, mood: nil, streak: 213, config: config, colorScheme: colorScheme, to: folder, name: "liveactivity_\(schemeName)_notlogged") count += 1 // Live Activity - All moods for mood in Mood.allValues { await exportLiveActivity(hasLogged: true, mood: mood, streak: 213, config: config, colorScheme: colorScheme, to: folder, name: "liveactivity_\(schemeName)_\(mood.strValue.lowercased())") count += 1 } } return count } // MARK: - Export Single Configuration (Current User Settings) /// Exports widgets using the user's current tint/icon settings static func exportCurrentConfiguration() async -> URL? { let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let exportPath = documentsPath.appendingPathComponent("WidgetExports_Current", isDirectory: true) try? FileManager.default.removeItem(at: exportPath) do { try FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true) } catch { AppLogger.export.error("Failed to create current config export directory: \(error)") return nil } let config = WidgetExportConfig( moodTint: UserDefaultsStore.moodTintable(), moodImages: UserDefaultsStore.moodMoodImagable() ) let count = await exportWidgetsForConfig(config: config, to: exportPath) print("📸 Exported \(count) widgets (current config) to: \(exportPath.path)") return exportPath } // MARK: - Export All Voting Layouts /// Exports all voting layout variations (small, medium, large) in all tint/icon combinations /// - Returns: URL to the export directory, or nil if failed static func exportAllVotingLayouts() async -> URL? { let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let exportPath = documentsPath.appendingPathComponent("VotingLayoutExports", isDirectory: true) // Clean and create export directory try? FileManager.default.removeItem(at: exportPath) do { try FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true) } catch { AppLogger.export.error("Failed to create voting layout export directory: \(error)") return nil } var totalExported = 0 // Export all tint + icon combinations for tintOption in allTints { for iconOption in allIcons { let folderName = "\(tintOption.name)_\(iconOption.name)" let variantPath = exportPath.appendingPathComponent(folderName, isDirectory: true) do { try FileManager.default.createDirectory(at: variantPath, withIntermediateDirectories: true) } catch { AppLogger.export.error("Failed to create voting variant directory '\(folderName)': \(error)") continue } let config = WidgetExportConfig( moodTint: tintOption.tint, moodImages: iconOption.images ) let count = await exportVotingLayoutsForConfig(config: config, to: variantPath) totalExported += count print(" Exported \(count) voting layouts to \(folderName)/") } } print("📸 Total \(totalExported) voting layouts exported to: \(exportPath.path)") return exportPath } /// Exports voting layouts for a single tint/icon configuration private static func exportVotingLayoutsForConfig(config: WidgetExportConfig, to folder: URL) async -> Int { var count = 0 for colorScheme in [ColorScheme.light, ColorScheme.dark] { let schemeName = colorScheme == .light ? "light" : "dark" // Small voting layout await exportVotingLayout(size: .small, config: config, colorScheme: colorScheme, to: folder, name: "voting_\(schemeName)_small") count += 1 // Medium voting layout await exportVotingLayout(size: .medium, config: config, colorScheme: colorScheme, to: folder, name: "voting_\(schemeName)_medium") count += 1 // Large voting layout await exportVotingLayout(size: .large, config: config, colorScheme: colorScheme, to: folder, name: "voting_\(schemeName)_large") count += 1 } return count } /// Exports a single voting layout private static func exportVotingLayout(size: WidgetSize, config: WidgetExportConfig, colorScheme: ColorScheme, to folder: URL, name: String) async { let votingSize: ExportableVotingView.Size switch size { case .small: votingSize = .small case .medium: votingSize = .medium case .large: votingSize = .large } let content = ExportableVotingView( size: votingSize, config: config, promptText: "How are you feeling today?" ) let view = ExportableWidgetContainer( width: size.pointSize.width, height: size.pointSize.height, colorScheme: colorScheme, useSystemBackground: false ) { content } await renderAndSave(view: view, size: size, to: folder, name: name) } // MARK: - Vote Widget Export private static func exportVoteWidget(hasVoted: Bool, mood: Mood?, size: WidgetSize, config: WidgetExportConfig, colorScheme: ColorScheme, to folder: URL, name: String) async { let content: AnyView if hasVoted, let mood = mood { content = AnyView( ExportableVotedStatsView( size: size == .small ? .small : .medium, config: config, mood: mood, totalEntries: sampleTotalEntries, moodCounts: sampleMoodCounts ) ) } else { content = AnyView( ExportableVotingView( size: size == .small ? .small : .medium, config: config, promptText: "How are you feeling today?" ) ) } let view = ExportableWidgetContainer( width: size.pointSize.width, height: size.pointSize.height, colorScheme: colorScheme, useSystemBackground: hasVoted ) { content } await renderAndSave(view: view, size: size, to: folder, name: name) } // MARK: - Timeline Widget Export private static func exportTimelineWidget(hasVoted: Bool, size: WidgetSize, config: WidgetExportConfig, colorScheme: ColorScheme, to folder: URL, name: String) async { let timelineData = config.createTimelineData(count: size == .large ? 10 : (size == .medium ? 5 : 1)) let promptText = "How are you feeling today?" let content: AnyView switch size { case .small: content = AnyView( ExportableTimelineSmallView( config: config, timelineData: timelineData.first, hasVoted: hasVoted, promptText: promptText ) ) case .medium: content = AnyView( ExportableTimelineMediumView( config: config, timelineData: Array(timelineData.prefix(5)), hasVoted: hasVoted, promptText: promptText ) ) case .large: content = AnyView( ExportableTimelineLargeView( config: config, timelineData: timelineData, hasVoted: hasVoted, promptText: promptText ) ) } let view = ExportableWidgetContainer( width: size.pointSize.width, height: size.pointSize.height, colorScheme: colorScheme, useSystemBackground: hasVoted ) { content } await renderAndSave(view: view, size: size, to: folder, name: name) } // MARK: - Live Activity Export private static func exportLiveActivity(hasLogged: Bool, mood: Mood?, streak: Int, config: WidgetExportConfig, colorScheme: ColorScheme, to folder: URL, name: String) async { // Opaque background colors for Live Activity (no transparency) let backgroundColor = colorScheme == .dark ? Color(red: 0.11, green: 0.11, blue: 0.12) : Color(red: 0.95, green: 0.95, blue: 0.97) let content = ExportableLiveActivityView( config: config, streak: streak, hasLoggedToday: hasLogged, mood: mood ) let view = content .environment(\.colorScheme, colorScheme) .frame(width: liveActivitySize.width, height: liveActivitySize.height) .background(backgroundColor) .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) await renderAndSaveLiveActivity(view: view, to: folder, name: name) } // 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() { do { try data.write(to: url) } catch { AppLogger.export.error("Failed to write widget image '\(name)': \(error)") } } } } 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() { do { try data.write(to: url) } catch { AppLogger.export.error("Failed to write live activity image '\(name)': \(error)") } } } } } #endif