Add debug widget/live activity export and competitor research
Debug features (DEBUG builds only): - WidgetExporter: Export all widget variants to PNG (light/dark modes) - Live Activity lock screen export with configurable streak - Test notifications button to preview all personality packs - Settings buttons for export and notification testing Research: - Competitor analysis docs (Daylio, Bearable, Reflectly, etc.) - App Store screenshot reference materials 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
779
Shared/Services/WidgetExporter.swift
Normal file
779
Shared/Services/WidgetExporter.swift
Normal file
@@ -0,0 +1,779 @@
|
||||
//
|
||||
// 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) day streak")
|
||||
.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("Don't break your streak!")
|
||||
.font(.headline)
|
||||
Text("Tap to log your mood")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Reference in New Issue
Block a user