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:
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
Text(hasSubscription ? promptText : "Subscribe to track your mood")
|
HStack {
|
||||||
.font(.headline)
|
Text(hasSubscription ? promptText : "Subscribe to track your mood")
|
||||||
.foregroundStyle(.primary)
|
.font(.system(size: 20, weight: .semibold))
|
||||||
.multilineTextAlignment(.leading)
|
.foregroundStyle(.primary)
|
||||||
.lineLimit(2)
|
.multilineTextAlignment(.leading)
|
||||||
.minimumScaleFactor(0.8)
|
.lineLimit(2)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
.minimumScaleFactor(0.8)
|
||||||
.padding(.horizontal, 16)
|
Spacer()
|
||||||
|
}
|
||||||
|
.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))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
694
Shared/Services/ExportableWidgetViews.swift
Normal file
694
Shared/Services/ExportableWidgetViews.swift
Normal 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
Reference in New Issue
Block a user