Files
Reflect/Shared/Services/WidgetExporter.swift
Trey t 0442eab1f8 Rebrand entire project from Feels to Reflect
Complete rename across all bundle IDs, App Groups, CloudKit containers,
StoreKit product IDs, data store filenames, URL schemes, logger subsystems,
Swift identifiers, user-facing strings (7 languages), file names, directory
names, Xcode project, schemes, assets, and documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 11:47:16 -06:00

393 lines
16 KiB
Swift

//
// 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
/// 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)
try? FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true)
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)
try? FileManager.default.createDirectory(at: variantPath, withIntermediateDirectories: true)
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)
try? FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true)
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)
try? FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true)
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)
try? FileManager.default.createDirectory(at: variantPath, withIntermediateDirectories: true)
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() {
try? data.write(to: url)
}
}
}
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() {
try? data.write(to: url)
}
}
}
}
#endif