425 lines
17 KiB
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)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|