Files
Reflect/Shared/Services/WidgetExporter.swift

425 lines
17 KiB
Swift

//
// WidgetExporter.swift
// Reflect
//
// Debug utility to export all widget previews to PNG files.
// Uses the real widget view layouts from ExportableWidgetViews.swift.
//
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<V: View>(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<V: View>(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)")
}
}
}
}
}