// // ExportableWidgetViews.swift // Reflect // // Exportable widget views that match the real WidgetKit widgets pixel-for-pixel. // These views accept tint/icon configuration as parameters for batch export. // 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.. 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 ReflectVoteWidget.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 ReflectTimelineWidget.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 ReflectTimelineWidget.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 ReflectTimelineWidget.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: 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)) } }