Add widget exports, promo assets, and screenshot resources

- Add ExportableWidgetViews for widget screenshot generation
- Update WidgetExporter and WidgetSharedViews with layout fixes
- Add promo video assets (activity, month, year videos)
- Add LiveActivityAnimation and BackgroundStill components
- Add widget export screenshots and voting images
- Update localizations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-31 09:02:05 -06:00
parent 5d1b0b60fa
commit 70451804ba
4 changed files with 12987 additions and 12560 deletions

View File

@@ -928,6 +928,33 @@
} }
} }
} }
},
"%lld / %lld" : {
"comment" : "A text displaying the current and target streak counts for the live activity export. The first argument is the current streak count. The second argument is the target streak count.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "%1$lld / %2$lld"
}
}
}
},
"%lld / %lld days" : {
"comment" : "A text displaying the current and target number of days in a year. The first argument is the current number of days. The second argument is the target number of days.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "%1$lld / %2$lld days"
}
}
}
},
"%lld day streak" : {
}, },
"%lld days" : { "%lld days" : {
"comment" : "A secondary label below the year, showing the total number of days in that year. The argument is the total number of days in the year.", "comment" : "A secondary label below the year, showing the total number of days in that year. The argument is the total number of days in the year.",
@@ -1681,6 +1708,10 @@
} }
} }
}, },
"365 frames exported to:" : {
"comment" : "A label displayed below the path where the exported frames are saved.",
"isCommentAutoGenerated" : true
},
"Add" : { "Add" : {
"comment" : "A button that allows the user to add or edit a journal note.", "comment" : "A button that allows the user to add or edit a journal note.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@@ -2158,6 +2189,18 @@
} }
} }
}, },
"AI insights in light & dark mode" : {
"comment" : "A description of the feature that allows users to export insights from the app.",
"isCommentAutoGenerated" : true
},
"All sizes & theme variations" : {
"comment" : "A description of what the \"Export Voting Layouts\" button does.",
"isCommentAutoGenerated" : true
},
"All styles & complications" : {
"comment" : "A description of what the \"Export Watch Screenshots\" button does.",
"isCommentAutoGenerated" : true
},
"Allow deleting mood entries by swiping" : { "Allow deleting mood entries by swiping" : {
"comment" : "A hint describing the functionality of the \"Allow deleting mood entries by swiping\" toggle.", "comment" : "A hint describing the functionality of the \"Allow deleting mood entries by swiping\" toggle.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@@ -5773,6 +5816,10 @@
} }
} }
}, },
"Dismiss" : {
"comment" : "A button to dismiss the current view.",
"isCommentAutoGenerated" : true
},
"Don't break your streak!" : { "Don't break your streak!" : {
"comment" : "A description of the current streak or a motivational message.", "comment" : "A description of the current streak or a motivational message.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@@ -6651,6 +6698,10 @@
} }
} }
}, },
"Export Complete!" : {
"comment" : "A message shown when the live activity export is complete.",
"isCommentAutoGenerated" : true
},
"Export Data" : { "Export Data" : {
"comment" : "The title of the screen where users can export their mood data.", "comment" : "The title of the screen where users can export their mood data.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@@ -6735,6 +6786,10 @@
} }
} }
}, },
"Export Insights Screenshots" : {
"comment" : "A button label that lets users export screenshots of the app's interface to analyze its design.",
"isCommentAutoGenerated" : true
},
"Export Preview" : { "Export Preview" : {
"comment" : "A title for the preview section of the export view.", "comment" : "A title for the preview section of the export view.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@@ -6777,6 +6832,14 @@
} }
} }
}, },
"Export Voting Layouts" : {
"comment" : "A button label that triggers the export of all voting layout configurations.",
"isCommentAutoGenerated" : true
},
"Export Watch Screenshots" : {
"comment" : "A button label that allows users to export all watch face and complication previews to a file.",
"isCommentAutoGenerated" : true
},
"Export Widget Screenshots" : { "Export Widget Screenshots" : {
"comment" : "A button label that prompts the user to download their light and dark mode widget screenshots.", "comment" : "A button label that prompts the user to download their light and dark mode widget screenshots.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@@ -6861,6 +6924,10 @@
} }
} }
}, },
"Exporting frames..." : {
"comment" : "A message displayed while the live activity is exporting frames.",
"isCommentAutoGenerated" : true
},
"Exporting..." : { "Exporting..." : {
"comment" : "A label indicating that a mood data export is in progress.", "comment" : "A label indicating that a mood data export is in progress.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@@ -7734,6 +7801,9 @@
} }
} }
} }
},
"How do you feel?" : {
}, },
"How to add widgets" : { "How to add widgets" : {
"localizations" : { "localizations" : {
@@ -8417,6 +8487,16 @@
} }
} }
} }
},
"Live Activity Preview" : {
"comment" : "The title of the view.",
"isCommentAutoGenerated" : true
},
"Log" : {
},
"Log mood" : {
}, },
"Log Mood" : { "Log Mood" : {
"comment" : "A button that opens Feels to log a mood.", "comment" : "A button that opens Feels to log a mood.",
@@ -8581,6 +8661,9 @@
} }
} }
} }
},
"Log your mood" : {
}, },
"Logged!" : { "Logged!" : {
"comment" : "A message displayed when a mood is successfully logged.", "comment" : "A message displayed when a mood is successfully logged.",
@@ -10874,6 +10957,10 @@
} }
} }
}, },
"Pause" : {
"comment" : "A button label that pauses a live activity.",
"isCommentAutoGenerated" : true
},
"Paywall Styles" : { "Paywall Styles" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -12300,6 +12387,10 @@
} }
} }
}, },
"Recording Mode" : {
"comment" : "A button that enters a recording mode for live activity previews.",
"isCommentAutoGenerated" : true
},
"Reminder time" : { "Reminder time" : {
"comment" : "An accessibility label for the time picker in the onboarding flow.", "comment" : "An accessibility label for the time picker in the onboarding flow.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@@ -12552,6 +12643,10 @@
} }
} }
}, },
"Reset" : {
"comment" : "A button label that resets the live activity preview.",
"isCommentAutoGenerated" : true
},
"Reset All Tips" : { "Reset All Tips" : {
"comment" : "A button that resets all tips to their default state.", "comment" : "A button that resets all tips to their default state.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@@ -13195,6 +13290,18 @@
} }
} }
}, },
"Saved to Documents/InsightsExports" : {
"comment" : "A description of where the insights export file will be saved.",
"isCommentAutoGenerated" : true
},
"Saved to Documents/VotingLayoutExports" : {
"comment" : "A description of where the voting layouts are saved when exported.",
"isCommentAutoGenerated" : true
},
"Saved to Documents/WatchExports" : {
"comment" : "A description of where the watch view export file will be saved.",
"isCommentAutoGenerated" : true
},
"Saved to Documents/WidgetExports" : { "Saved to Documents/WidgetExports" : {
"comment" : "A description of where the exported widget screenshots are saved.", "comment" : "A description of where the exported widget screenshots are saved.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@@ -14427,6 +14534,10 @@
} }
} }
}, },
"Start" : {
"comment" : "A button label that starts an animation.",
"isCommentAutoGenerated" : true
},
"Start your streak!" : { "Start your streak!" : {
"comment" : "A title displayed in the center of the expanded view.", "comment" : "A title displayed in the center of the expanded view.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@@ -14941,6 +15052,10 @@
} }
} }
}, },
"Tap anywhere to start export" : {
"comment" : "A prompt displayed in the \"Live Activity Recording View\" that instructs the user to tap anywhere to start the export process.",
"isCommentAutoGenerated" : true
},
"Tap to add" : { "Tap to add" : {
"comment" : "A label displayed within a capsule in the entry list view, indicating that a user can tap on it to add a new entry.", "comment" : "A label displayed within a capsule in the entry list view, indicating that a user can tap on it to add a new entry.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@@ -15024,6 +15139,9 @@
} }
} }
} }
},
"Tap to log mood" : {
}, },
"Tap to log mood for this day" : { "Tap to log mood for this day" : {
"comment" : "A hint that appears when a user taps on a day with no mood logged, instructing them to log a mood.", "comment" : "A hint that appears when a user taps on a day with no mood logged, instructing them to log a mood.",

View File

@@ -53,20 +53,23 @@ struct VotingView: View {
} }
} }
// MARK: - Medium Widget: Vertical split - text top, voting bottom // MARK: - Medium Widget: 50/50 split, both centered
private var mediumLayout: some View { private var mediumLayout: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
// Top: Text left-aligned, centered horizontally // Top 50%: Text left-aligned, vertically centered
HStack {
Text(hasSubscription ? promptText : "Subscribe to track your mood") Text(hasSubscription ? promptText : "Subscribe to track your mood")
.font(.headline) .font(.system(size: 20, weight: .semibold))
.foregroundStyle(.primary) .foregroundStyle(.primary)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.lineLimit(2) .lineLimit(2)
.minimumScaleFactor(0.8) .minimumScaleFactor(0.8)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) Spacer()
}
.padding(.horizontal, 16) .padding(.horizontal, 16)
.frame(maxWidth: .infinity, maxHeight: .infinity)
// Bottom: Voting buttons with equal spacing, centered // Bottom 50%: Voting buttons centered
HStack(spacing: 0) { HStack(spacing: 0) {
ForEach([Mood.great, .good, .average, .bad, .horrible], id: \.rawValue) { mood in ForEach([Mood.great, .good, .average, .bad, .horrible], id: \.rawValue) { mood in
moodButtonMedium(for: mood) moodButtonMedium(for: mood)
@@ -106,7 +109,7 @@ struct VotingView: View {
let content = moodImages.icon(forMood: mood) let content = moodImages.icon(forMood: mood)
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(width: 36, height: 36) .frame(width: 54, height: 54)
.foregroundColor(moodTint.color(forMood: mood)) .foregroundColor(moodTint.color(forMood: mood))
if hasSubscription { if hasSubscription {
@@ -151,20 +154,19 @@ struct LargeVotingView: View {
var body: some View { var body: some View {
GeometryReader { geo in GeometryReader { geo in
VStack(spacing: 0) { VStack(spacing: 0) {
// Top 25%: Title centered x,y // Top 33%: Title centered
Text(hasSubscription ? promptText : "Subscribe to track your mood") Text(hasSubscription ? promptText : "Subscribe to track your mood")
.font(.title3.weight(.semibold)) .font(.system(size: 24, weight: .semibold))
.foregroundStyle(.primary) .foregroundStyle(.primary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.lineLimit(2) .lineLimit(2)
.minimumScaleFactor(0.8) .minimumScaleFactor(0.8)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(width: geo.size.width, height: geo.size.height * 0.33)
.frame(height: geo.size.height * 0.25)
// Bottom 75%: Voting buttons in two rows // Bottom 66%: Voting buttons in two rows
VStack(spacing: 0) { VStack(spacing: 0) {
// Top row at 33%: Great, Good, Average // Top row: Great, Good, Average
HStack(spacing: 16) { HStack(spacing: 16) {
ForEach([Mood.great, .good, .average], id: \.rawValue) { mood in ForEach([Mood.great, .good, .average], id: \.rawValue) { mood in
moodButton(for: mood) moodButton(for: mood)
@@ -172,7 +174,7 @@ struct LargeVotingView: View {
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
// Bottom row at 66%: Bad, Horrible // Bottom row: Bad, Horrible
HStack(spacing: 16) { HStack(spacing: 16) {
ForEach([Mood.bad, .horrible], id: \.rawValue) { mood in ForEach([Mood.bad, .horrible], id: \.rawValue) { mood in
moodButton(for: mood) moodButton(for: mood)
@@ -180,7 +182,7 @@ struct LargeVotingView: View {
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
} }
.frame(height: geo.size.height * 0.75) .frame(width: geo.size.width, height: geo.size.height * 0.67)
} }
} }
} }
@@ -208,11 +210,11 @@ struct LargeVotingView: View {
moodImages.icon(forMood: mood) moodImages.icon(forMood: mood)
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(width: 44, height: 44) .frame(width: 53, height: 53)
.foregroundColor(moodTint.color(forMood: mood)) .foregroundColor(moodTint.color(forMood: mood))
.padding(10) .padding(12)
.background( .background(
RoundedRectangle(cornerRadius: 12) RoundedRectangle(cornerRadius: 14)
.fill(moodTint.color(forMood: mood).opacity(0.15)) .fill(moodTint.color(forMood: mood).opacity(0.15))
) )
} }

View File

@@ -0,0 +1,694 @@
//
// ExportableWidgetViews.swift
// Feels
//
// Exportable widget views that match the real WidgetKit widgets pixel-for-pixel.
// These views accept tint/icon configuration as parameters for batch export.
//
#if DEBUG
import SwiftUI
// MARK: - Widget Theme Configuration
/// Configuration for widget export styling
struct WidgetExportConfig {
let moodTint: MoodTintable.Type
let moodImages: MoodImagable.Type
/// Creates sample timeline data for export
func createTimelineData(count: Int) -> [ExportTimelineItem] {
let moods: [Mood] = [.great, .good, .average, .good, .great, .average, .bad, .good, .great, .good]
return (0..<count).map { index in
let mood = moods[index % moods.count]
let date = Calendar.current.date(byAdding: .day, value: -index, to: Date())!
return ExportTimelineItem(
mood: mood,
date: date,
color: moodTint.color(forMood: mood),
image: moodImages.icon(forMood: mood)
)
}
}
}
/// Timeline data item for export
struct ExportTimelineItem: Identifiable {
let id = UUID()
let mood: Mood
let date: Date
let color: Color
let image: Image
}
// MARK: - Exportable Voting View (matches VotingView from WidgetSharedViews.swift)
struct ExportableVotingView: View {
enum Size {
case small
case medium
case large
}
let size: Size
let config: WidgetExportConfig
let promptText: String
var body: some View {
switch size {
case .small:
smallLayout
case .medium:
mediumLayout
case .large:
largeLayout
}
}
// MARK: - Small Widget: 3 over 2 grid centered in 50%|50% vertical split
private var smallLayout: some View {
VStack(spacing: 0) {
// Top half: Great, Good, Average
HStack(spacing: 12) {
ForEach([Mood.great, .good, .average], id: \.rawValue) { mood in
moodIcon(for: mood, size: 40)
.frame(minWidth: 44, minHeight: 44)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
// Bottom half: Bad, Horrible
HStack(spacing: 12) {
ForEach([Mood.bad, .horrible], id: \.rawValue) { mood in
moodIcon(for: mood, size: 40)
.frame(minWidth: 44, minHeight: 44)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
}
}
// MARK: - Medium Widget: 50/50 split, both centered
private var mediumLayout: some View {
VStack(spacing: 0) {
// Top 50%: Text left-aligned, vertically centered
HStack {
Text(promptText)
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(.primary)
.multilineTextAlignment(.leading)
.lineLimit(2)
.minimumScaleFactor(0.8)
Spacer()
}
.padding(.horizontal, 16)
.frame(maxWidth: .infinity, maxHeight: .infinity)
// Bottom 50%: Voting buttons centered
HStack(spacing: 0) {
ForEach([Mood.great, .good, .average, .bad, .horrible], id: \.rawValue) { mood in
config.moodImages.icon(forMood: mood)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 54, height: 54)
.foregroundColor(config.moodTint.color(forMood: mood))
.frame(maxWidth: .infinity)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: - Large Widget Layout: 33% header, 66% voting (2 rows)
private var largeLayout: some View {
Color.clear.overlay(
GeometryReader { geo in
VStack(spacing: 0) {
// Top 33%: Title centered
Text(promptText)
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(.primary)
.multilineTextAlignment(.center)
.lineLimit(2)
.minimumScaleFactor(0.8)
.padding(.horizontal, 12)
.frame(width: geo.size.width, height: geo.size.height * 0.33)
// Bottom 66%: Voting buttons in two rows
VStack(spacing: 0) {
// Top row: Great, Good, Average
HStack(spacing: 16) {
ForEach([Mood.great, .good, .average], id: \.rawValue) { mood in
largeMoodButton(for: mood)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
// Bottom row: Bad, Horrible
HStack(spacing: 16) {
ForEach([Mood.bad, .horrible], id: \.rawValue) { mood in
largeMoodButton(for: mood)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(width: geo.size.width, height: geo.size.height * 0.67)
}
}
)
}
private func largeMoodButton(for mood: Mood) -> some View {
config.moodImages.icon(forMood: mood)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 53, height: 53)
.foregroundColor(config.moodTint.color(forMood: mood))
.padding(12)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(config.moodTint.color(forMood: mood).opacity(0.15))
)
}
private func moodIcon(for mood: Mood, size: CGFloat) -> some View {
config.moodImages.icon(forMood: mood)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: size, height: size)
.foregroundColor(config.moodTint.color(forMood: mood))
}
}
// MARK: - Exportable Voted Stats View (matches VotedStatsView from FeelsVoteWidget.swift)
struct ExportableVotedStatsView: View {
enum Size {
case small
case medium
}
let size: Size
let config: WidgetExportConfig
let mood: Mood
let totalEntries: Int
let moodCounts: [Mood: Int]
/// Returns "Today" for display
private var votingDateString: String {
return String(localized: "Today")
}
var body: some View {
if size == .small {
smallLayout
} else {
mediumLayout
}
}
// MARK: - Small: Centered mood with checkmark and date
private var smallLayout: some View {
VStack(spacing: 8) {
// Large centered mood icon
ZStack(alignment: .bottomTrailing) {
config.moodImages.icon(forMood: mood)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 56, height: 56)
.foregroundColor(config.moodTint.color(forMood: mood))
// Checkmark badge
Image(systemName: "checkmark.circle.fill")
.font(.headline)
.foregroundColor(.green)
.background(Circle().fill(.white).frame(width: 14, height: 14))
.offset(x: 4, y: 4)
}
Text(votingDateString)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Text("\(totalEntries) entries")
.font(.caption2)
.foregroundStyle(.tertiary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(12)
}
// MARK: - Medium: Mood + stats bar
private var mediumLayout: some View {
HStack(alignment: .top, spacing: 20) {
// Left: Mood display
VStack(spacing: 6) {
config.moodImages.icon(forMood: mood)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 48, height: 48)
.foregroundColor(config.moodTint.color(forMood: mood))
Text(mood.widgetDisplayName)
.font(.subheadline.weight(.semibold))
.foregroundColor(config.moodTint.color(forMood: mood))
Text(votingDateString)
.font(.caption2)
.foregroundStyle(.secondary)
}
// Right: Stats with progress bar
VStack(alignment: .leading, spacing: 10) {
Text("\(totalEntries) entries")
.font(.headline.weight(.semibold))
.foregroundStyle(.primary)
// Mini mood breakdown
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(config.moodTint.color(forMood: m))
.frame(width: 8, height: 8)
Text("\(count)")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
}
// Progress bar
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(config.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: - Exportable Timeline Small View (matches SmallWidgetView from FeelsTimelineWidget.swift)
struct ExportableTimelineSmallView: View {
let config: WidgetExportConfig
let timelineData: ExportTimelineItem?
let hasVoted: Bool
let promptText: String
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 {
ExportableVotingView(size: .small, config: config, promptText: promptText)
} else if let today = timelineData {
VStack(spacing: 0) {
Spacer()
// Large mood icon
today.image
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 70, height: 70)
.foregroundColor(today.color)
Spacer()
.frame(height: 12)
// Date info
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)
}
}
}
// MARK: - Exportable Timeline Medium View (matches MediumWidgetView from FeelsTimelineWidget.swift)
struct ExportableTimelineMediumView: View {
let config: WidgetExportConfig
let timelineData: [ExportTimelineItem]
let hasVoted: Bool
let promptText: String
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 {
ExportableVotingView(size: .medium, config: config, promptText: promptText)
} else {
GeometryReader { geo in
let cellHeight = geo.size.height - 36
VStack(spacing: 4) {
// Header
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)
// Single row of 5 days
HStack(spacing: 8) {
ForEach(Array(timelineData.enumerated()), id: \.element.id) { index, item in
ExportableMediumDayCell(
dayLabel: dayFormatter.string(from: item.date),
dateLabel: dateFormatter.string(from: item.date),
image: item.image,
color: item.color,
isToday: index == 0,
height: cellHeight,
mood: item.mood
)
}
}
.padding(.horizontal, 10)
.padding(.bottom, 10)
}
}
}
}
}
// MARK: - Exportable Medium Day Cell
struct ExportableMediumDayCell: View {
let dayLabel: String
let dateLabel: String
let image: Image
let color: Color
let isToday: Bool
let height: CGFloat
let mood: Mood
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)
}
}
// MARK: - Exportable Timeline Large View (matches LargeWidgetView from FeelsTimelineWidget.swift)
struct ExportableTimelineLargeView: View {
let config: WidgetExportConfig
let timelineData: [ExportTimelineItem]
let hasVoted: Bool
let promptText: String
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 {
ExportableVotingView(size: .large, config: config, promptText: promptText)
} else {
GeometryReader { geo in
let cellHeight = (geo.size.height - 70) / 2
VStack(spacing: 6) {
// Header
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)
// Calendar grid - 2 rows of 5
VStack(spacing: 6) {
// First row (most recent 5)
HStack(spacing: 6) {
ForEach(Array(timelineData.prefix(5).enumerated()), id: \.element.id) { index, item in
ExportableDayCell(
dayLabel: dayFormatter.string(from: item.date),
dateLabel: dateFormatter.string(from: item.date),
image: item.image,
color: item.color,
isToday: index == 0,
height: cellHeight,
mood: item.mood
)
}
}
// Second row (older 5)
HStack(spacing: 6) {
ForEach(Array(timelineData.suffix(5).enumerated()), id: \.element.id) { _, item in
ExportableDayCell(
dayLabel: dayFormatter.string(from: item.date),
dateLabel: dateFormatter.string(from: item.date),
image: item.image,
color: item.color,
isToday: false,
height: cellHeight,
mood: item.mood
)
}
}
}
.padding(.horizontal, 10)
.padding(.bottom, 8)
}
}
}
}
}
// MARK: - Exportable Day Cell (for Large Widget)
struct ExportableDayCell: View {
let dayLabel: String
let dateLabel: String
let image: Image
let color: Color
let isToday: Bool
let height: CGFloat
let mood: Mood
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: - Exportable Live Activity View (matches lock screen Live Activity)
struct ExportableLiveActivityView: View {
let config: WidgetExportConfig
let streak: Int
let hasLoggedToday: Bool
let mood: Mood?
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(config.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()
}
}
// MARK: - Widget Container for Export
struct ExportableWidgetContainer<Content: View>: View {
let width: CGFloat
let height: CGFloat
let colorScheme: ColorScheme
let useSystemBackground: Bool
let content: Content
init(width: CGFloat, height: CGFloat, colorScheme: ColorScheme, useSystemBackground: Bool = false, @ViewBuilder content: () -> Content) {
self.width = width
self.height = height
self.colorScheme = colorScheme
self.useSystemBackground = useSystemBackground
self.content = content()
}
/// Opaque background color based on color scheme (no transparency)
private var backgroundColor: Color {
if useSystemBackground {
return colorScheme == .dark ? Color(red: 0.11, green: 0.11, blue: 0.12) : Color.white
} else {
// Tertiary fill equivalent - opaque
return colorScheme == .dark ? Color(red: 0.17, green: 0.17, blue: 0.18) : Color(red: 0.95, green: 0.95, blue: 0.97)
}
}
var body: some View {
content
.environment(\.colorScheme, colorScheme)
.frame(width: width, height: height)
.background(backgroundColor)
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
}
}
#endif

File diff suppressed because it is too large Load Diff