Files
Reflect/Shared/Services/WidgetExporter.swift
Trey t 74dc289a3d Fix Live Activity streak messaging and mislabeled widget text
Show "Start your streak!" instead of "Don't break your streak!" when
streak count is zero, and fix small widget incorrectly labeling total
entries as "day streak".

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 23:03:09 -06:00

780 lines
27 KiB
Swift

//
// 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<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)
}
}
}
// 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)
}
}
}
// 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..<count).map { index in
let mood = moods[index % moods.count]
let date = Calendar.current.date(byAdding: .day, value: -index, to: Date())!
return TimelineDataItem(
mood: mood,
date: date,
color: moodTint.color(forMood: mood),
image: moodImages.icon(forMood: mood)
)
}
}
}
// MARK: - Widget Container View
private struct WidgetContainer<Content: View>: 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) entries")
.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(streak > 0 ? "Don't break your streak!" : "Start your streak!")
.font(.headline)
Text("Tap to log your mood")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
Spacer()
}
.padding()
}
}
#endif