// // ReflectTimelineWidget.swift // ReflectWidget // // Timeline widget showing mood history (small, medium, large) // import WidgetKit import SwiftUI import Intents // MARK: - Widget Configuration struct ReflectWidget: Widget { let kind: String = "ReflectWidget" var body: some WidgetConfiguration { IntentConfiguration(kind: kind, intent: ConfigurationIntent.self, provider: Provider()) { entry in ReflectWidgetEntryView(entry: entry) } .configurationDisplayName("Reflect") .description("") .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) } } // MARK: - Entry View Router struct ReflectWidgetEntryView: View { @Environment(\.sizeCategory) var sizeCategory @Environment(\.widgetFamily) var family var entry: Provider.Entry private var showVotingForToday: Bool { !entry.hasVotedToday } @ViewBuilder var body: some View { Group { switch family { case .systemSmall: SmallWidgetView(entry: entry) case .systemMedium: MediumWidgetView(entry: entry) case .systemLarge: LargeWidgetView(entry: entry) case .systemExtraLarge: LargeWidgetView(entry: entry) case .accessoryCircular, .accessoryRectangular, .accessoryInline: SmallWidgetView(entry: entry) @unknown default: MediumWidgetView(entry: entry) } } .containerBackground(showVotingForToday ? Color.clear : Color(UIColor.systemBackground), for: .widget) } } // MARK: - Small Widget View struct SmallWidgetView: View { var entry: Provider.Entry var todayView: WatchTimelineView? private var showVotingForToday: Bool { !entry.hasVotedToday } 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 } private var isSampleData: Bool init(entry: Provider.Entry) { self.entry = entry let realData = TimeLineCreator.createViews(daysBack: 2) let hasRealData = realData.contains { view in let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable() return view.color != moodTint.color(forMood: .missing) } isSampleData = !hasRealData todayView = hasRealData ? realData.first : TimeLineCreator.createSampleViews(count: 1).first } var body: some View { if showVotingForToday { // Show interactive voting buttons (or open app links if expired) VotingView(family: .systemSmall, promptText: entry.promptText, hasSubscription: entry.hasSubscription) } else if let today = todayView { VStack(spacing: 0) { if isSampleData { Text(String(localized: "Log your first mood!")) .font(.caption2) .foregroundStyle(.secondary) .padding(.top, 8) } Spacer() // Large mood icon today.image .resizable() .aspectRatio(contentMode: .fit) .frame(width: 70, height: 70) .foregroundColor(today.color) .accessibilityLabel(today.mood.strValue) 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: - Medium Widget View struct MediumWidgetView: View { var entry: Provider.Entry var timeLineView = [WatchTimelineView]() private var showVotingForToday: Bool { !entry.hasVotedToday } 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 isSampleData: Bool init(entry: Provider.Entry) { self.entry = entry let realData = Array(TimeLineCreator.createViews(daysBack: 6).prefix(5)) let hasRealData = realData.contains { view in let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable() return view.color != moodTint.color(forMood: .missing) } isSampleData = !hasRealData timeLineView = hasRealData ? realData : TimeLineCreator.createSampleViews(count: 5) } private var headerDateRange: String { guard let first = timeLineView.first, let last = timeLineView.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 showVotingForToday { // Show interactive voting buttons (or open app links if expired) VotingView(family: .systemMedium, promptText: entry.promptText, hasSubscription: entry.hasSubscription) } 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) if isSampleData { Text("·") .foregroundStyle(.secondary) Text(String(localized: "Log your first mood!")) .font(.caption) .foregroundStyle(.secondary) } else { 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(timeLineView.enumerated()), id: \.element.id) { index, item in MediumDayCell( 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: - Medium Day Cell struct MediumDayCell: 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) .accessibilityLabel(mood.strValue) Text(dateLabel) .font(.caption.weight(isToday ? .bold : .semibold)) .foregroundStyle(isToday ? color : .secondary) } } .frame(maxWidth: .infinity) } } // MARK: - Large Widget View struct LargeWidgetView: View { var entry: Provider.Entry var timeLineView = [WatchTimelineView]() private var showVotingForToday: Bool { !entry.hasVotedToday } private var isSampleData: Bool init(entry: Provider.Entry) { self.entry = entry let realData = Array(TimeLineCreator.createViews(daysBack: 11).prefix(10)) let hasRealData = realData.contains { view in let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable() return view.color != moodTint.color(forMood: .missing) } isSampleData = !hasRealData timeLineView = hasRealData ? realData : TimeLineCreator.createSampleViews(count: 10) } private var dayFormatter: DateFormatter { let f = DateFormatter() f.dateFormat = "EEE" return f } private var dateFormatter: DateFormatter { let f = DateFormatter() f.dateFormat = "d" return f } var body: some View { if showVotingForToday { // Show interactive voting buttons for large widget (or open app links if expired) LargeVotingView(promptText: entry.promptText, hasSubscription: entry.hasSubscription) } else { GeometryReader { geo in let cellHeight = (geo.size.height - 70) / 2 // Subtract header height, divide by 2 rows VStack(spacing: 6) { // Header HStack { VStack(alignment: .leading, spacing: 2) { Text("Last 10 Days") .font(.subheadline.weight(.semibold)) .foregroundStyle(.primary) Text(isSampleData ? String(localized: "Log your first mood!") : 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(timeLineView.prefix(5).enumerated()), id: \.element.id) { index, item in DayCell( 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(timeLineView.suffix(5).enumerated()), id: \.element.id) { _, item in DayCell( 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) } } } } private var headerDateRange: String { guard let first = timeLineView.first, let last = timeLineView.last else { return "" } let formatter = DateFormatter() formatter.dateFormat = "MMM d" return "\(formatter.string(from: last.date)) - \(formatter.string(from: first.date))" } } // MARK: - Day Cell for Large Widget struct DayCell: 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) .accessibilityLabel(mood.strValue) Text(dateLabel) .font(.caption.weight(isToday ? .bold : .semibold)) .foregroundStyle(isToday ? color : .secondary) } } } .frame(maxWidth: .infinity) } } // MARK: - Supporting Views struct TimeHeaderView: View { let startDate: Date let endDate: Date var formatter: DateFormatter { let dateFormatter = DateFormatter() dateFormatter.dateStyle = .medium return dateFormatter } var body: some View { HStack { Text(startDate, formatter: formatter) .font(.system(.footnote)) Text(" - ") .font(.system(.footnote)) Text(endDate, formatter: formatter) .font(.system(.footnote)) } } } struct TimeBodyView: View { let group: [WatchTimelineView] var showVotingForToday: Bool = false var promptText: String = "" var hasSubscription: Bool = false var body: some View { if showVotingForToday { // Show voting view without extra background container InlineVotingView(promptText: promptText, hasSubscription: hasSubscription) .padding() } else { ZStack { Color(UIColor.secondarySystemBackground) HStack(spacing: 4) { ForEach(group) { watchView in EntryCard(timeLineView: watchView) } } .padding() } } } } struct EntryCard: View { var timeLineView: WatchTimelineView var body: some View { timeLineView.image .resizable() .aspectRatio(contentMode: .fit) .frame(width: 50, height: 50, alignment: .center) .foregroundColor(timeLineView.color) .accessibilityLabel(timeLineView.mood.strValue) } } // MARK: - Preview Helpers private enum WidgetPreviewHelpers { static func sampleTimelineViews(count: Int, startMood: Mood = .great) -> [WatchTimelineView] { let moods: [Mood] = [.great, .good, .average, .bad, .horrible] let startIndex = moods.firstIndex(of: startMood) ?? 0 return (0.. SimpleEntry { SimpleEntry( date: Date(), configuration: ConfigurationIntent(), timeLineViews: sampleTimelineViews(count: timelineCount, startMood: startMood), hasSubscription: hasSubscription, hasVotedToday: hasVotedToday, promptText: "How are you feeling today?" ) } } // MARK: - Previews // Small - Logged States #Preview("Timeline Small - Great", as: .systemSmall) { ReflectWidget() } timeline: { WidgetPreviewHelpers.sampleEntry(timelineCount: 1, startMood: .great) } #Preview("Timeline Small - Good", as: .systemSmall) { ReflectWidget() } timeline: { WidgetPreviewHelpers.sampleEntry(timelineCount: 1, startMood: .good) } #Preview("Timeline Small - Average", as: .systemSmall) { ReflectWidget() } timeline: { WidgetPreviewHelpers.sampleEntry(timelineCount: 1, startMood: .average) } #Preview("Timeline Small - Bad", as: .systemSmall) { ReflectWidget() } timeline: { WidgetPreviewHelpers.sampleEntry(timelineCount: 1, startMood: .bad) } #Preview("Timeline Small - Horrible", as: .systemSmall) { ReflectWidget() } timeline: { WidgetPreviewHelpers.sampleEntry(timelineCount: 1, startMood: .horrible) } // Small - Voting States #Preview("Timeline Small - Voting", as: .systemSmall) { ReflectWidget() } timeline: { WidgetPreviewHelpers.sampleEntry(timelineCount: 1, hasVotedToday: false) } #Preview("Timeline Small - Non-Subscriber", as: .systemSmall) { ReflectWidget() } timeline: { WidgetPreviewHelpers.sampleEntry(timelineCount: 1, hasVotedToday: false, hasSubscription: false) } // Medium - Logged States #Preview("Timeline Medium - Logged", as: .systemMedium) { ReflectWidget() } timeline: { WidgetPreviewHelpers.sampleEntry(timelineCount: 5) } // Medium - Voting States #Preview("Timeline Medium - Voting", as: .systemMedium) { ReflectWidget() } timeline: { WidgetPreviewHelpers.sampleEntry(timelineCount: 5, hasVotedToday: false) } #Preview("Timeline Medium - Non-Subscriber", as: .systemMedium) { ReflectWidget() } timeline: { WidgetPreviewHelpers.sampleEntry(timelineCount: 5, hasVotedToday: false, hasSubscription: false) } // Large - Logged States #Preview("Timeline Large - Logged", as: .systemLarge) { ReflectWidget() } timeline: { WidgetPreviewHelpers.sampleEntry(timelineCount: 10) } // Large - Voting States #Preview("Timeline Large - Voting", as: .systemLarge) { ReflectWidget() } timeline: { WidgetPreviewHelpers.sampleEntry(timelineCount: 10, hasVotedToday: false) } #Preview("Timeline Large - Non-Subscriber", as: .systemLarge) { ReflectWidget() } timeline: { WidgetPreviewHelpers.sampleEntry(timelineCount: 10, hasVotedToday: false, hasSubscription: false) }