// // FeelsVoteWidget.swift // FeelsWidget // // Interactive widget for mood voting (iOS 17+) // // Note: VoteMoodIntent is defined in Shared/SharedMoodIntent.swift // import WidgetKit import SwiftUI import AppIntents // MARK: - Vote Widget Provider struct VoteWidgetProvider: TimelineProvider { func placeholder(in context: Context) -> VoteWidgetEntry { // Show sample "already voted" state for widget picker preview let sampleStats = MoodStats(totalEntries: 30, moodCounts: [.great: 10, .good: 12, .average: 5, .bad: 2, .horrible: 1]) let promptText = UserDefaultsStore.personalityPackable().randomPushNotificationStrings().body return VoteWidgetEntry(date: Date(), hasSubscription: true, hasVotedToday: true, todaysMood: .good, stats: sampleStats, promptText: promptText) } func getSnapshot(in context: Context, completion: @escaping (VoteWidgetEntry) -> Void) { // Show sample data for widget picker preview if context.isPreview { let sampleStats = MoodStats(totalEntries: 30, moodCounts: [.great: 10, .good: 12, .average: 5, .bad: 2, .horrible: 1]) let promptText = UserDefaultsStore.personalityPackable().randomPushNotificationStrings().body let entry = VoteWidgetEntry(date: Date(), hasSubscription: true, hasVotedToday: true, todaysMood: .good, stats: sampleStats, promptText: promptText) completion(entry) return } Task { @MainActor in let entry = createEntry() completion(entry) } } func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { Task { @MainActor in let entry = createEntry() // Calculate next refresh time let nextRefresh = calculateNextRefreshDate() let timeline = Timeline(entries: [entry], policy: .after(nextRefresh)) completion(timeline) } } /// Calculate when the widget should next refresh /// Refreshes at: rating time (to show voting view) and midnight (for new day) private func calculateNextRefreshDate() -> Date { let now = Date() let calendar = Calendar.current // Get the rating time from onboarding data let onboardingData = UserDefaultsStore.getOnboarding() let ratingTimeComponents = calendar.dateComponents([.hour, .minute], from: onboardingData.date) // Create today's rating time var todayRatingComponents = calendar.dateComponents([.year, .month, .day], from: now) todayRatingComponents.hour = ratingTimeComponents.hour todayRatingComponents.minute = ratingTimeComponents.minute let todayRatingTime = calendar.date(from: todayRatingComponents) ?? now // Tomorrow's midnight let midnight = calendar.startOfDay(for: calendar.date(byAdding: .day, value: 1, to: now)!) // If we haven't passed today's rating time, refresh at rating time if now < todayRatingTime { return todayRatingTime } // Otherwise refresh at midnight return midnight } @MainActor private func createEntry() -> VoteWidgetEntry { let hasSubscription = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.hasActiveSubscription.rawValue) // Use WidgetDataProvider for isolated read-only data access let dataProvider = WidgetDataProvider.shared let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding()) let dayStart = Calendar.current.startOfDay(for: votingDate) let dayEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: dayStart)! // Check if user has voted today let todayEntry = dataProvider.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first let hasVotedToday = todayEntry != nil && todayEntry?.mood != Mood.missing && todayEntry?.mood != Mood.placeholder // Get today's mood if voted let todaysMood: Mood? = hasVotedToday ? todayEntry?.mood : nil // Get stats for display after voting var stats: MoodStats? = nil if hasVotedToday { let allEntries = dataProvider.getData( startDate: Date(timeIntervalSince1970: 0), endDate: Date(), includedDays: [] ) let validEntries = allEntries.filter { $0.mood != Mood.missing && $0.mood != Mood.placeholder } let totalCount = validEntries.count if totalCount > 0 { var moodCounts: [Mood: Int] = [:] for entry in validEntries { moodCounts[entry.mood, default: 0] += 1 } stats = MoodStats(totalEntries: totalCount, moodCounts: moodCounts) } } // Get random prompt text for voting view let promptText = UserDefaultsStore.personalityPackable().randomPushNotificationStrings().body return VoteWidgetEntry( date: Date(), hasSubscription: hasSubscription, hasVotedToday: hasVotedToday, todaysMood: todaysMood, stats: stats, promptText: promptText ) } } // MARK: - Stats Model struct MoodStats { let totalEntries: Int let moodCounts: [Mood: Int] func percentage(for mood: Mood) -> Double { guard totalEntries > 0 else { return 0 } return Double(moodCounts[mood, default: 0]) / Double(totalEntries) * 100 } } // MARK: - Timeline Entry struct VoteWidgetEntry: TimelineEntry { let date: Date let hasSubscription: Bool let hasVotedToday: Bool let todaysMood: Mood? let stats: MoodStats? let promptText: String } // MARK: - Widget Views struct FeelsVoteWidgetEntryView: View { @Environment(\.widgetFamily) var family var entry: VoteWidgetProvider.Entry var body: some View { Group { if entry.hasSubscription { if entry.hasVotedToday { // Show stats after voting VotedStatsView(entry: entry) } else { // Show voting buttons VotingView(family: family, promptText: entry.promptText) } } else { // Non-subscriber view - tap to open app NonSubscriberView() } } .containerBackground(.fill.tertiary, for: .widget) } } // MARK: - Voting View (for subscribers who haven't voted) struct VotingView: View { let family: WidgetFamily let promptText: String private var moodTint: MoodTintable.Type { UserDefaultsStore.moodTintable() } private var moodImages: MoodImagable.Type { UserDefaultsStore.moodMoodImagable() } var body: some View { if family == .systemSmall { smallLayout } else { mediumLayout } } // MARK: - Small Widget: 3 over 2 grid private var smallLayout: some View { VStack(spacing: 0) { Text(promptText) .font(.caption) .foregroundStyle(.primary) .multilineTextAlignment(.center) .lineLimit(1) .minimumScaleFactor(0.7) .padding(.bottom, 10) // Top row: Great, Good, Average HStack(spacing: 12) { ForEach([Mood.great, .good, .average], id: \.rawValue) { mood in moodButton(for: mood, size: 36) } } .padding(.bottom, 6) // Bottom row: Bad, Horrible HStack(spacing: 12) { ForEach([Mood.bad, .horrible], id: \.rawValue) { mood in moodButton(for: mood, size: 36) } } } .padding(.horizontal, 8) .padding(.vertical, 4) } // MARK: - Medium Widget: Single row private var mediumLayout: some View { VStack { Text(promptText) .font(.headline) .foregroundStyle(.primary) .multilineTextAlignment(.center) .lineLimit(2) .minimumScaleFactor(0.8) .padding(.bottom, 20) HStack(spacing: 16) { ForEach([Mood.great, .good, .average, .bad, .horrible], id: \.rawValue) { mood in moodButton(for: mood, size: 44) } } } .padding() } private func moodButton(for mood: Mood, size: CGFloat) -> some View { Button(intent: VoteMoodIntent(mood: mood)) { moodImages.icon(forMood: mood) .resizable() .aspectRatio(contentMode: .fit) .frame(width: size, height: size) .foregroundColor(moodTint.color(forMood: mood)) } .buttonStyle(.plain) } } // MARK: - Voted Stats View (shown after voting) struct VotedStatsView: View { @Environment(\.widgetFamily) var family let entry: VoteWidgetEntry private var moodTint: MoodTintable.Type { UserDefaultsStore.moodTintable() } private var moodImages: MoodImagable.Type { UserDefaultsStore.moodMoodImagable() } var body: some View { if family == .systemSmall { smallLayout } else { mediumLayout } } // MARK: - Small: Centered mood with checkmark private var smallLayout: some View { VStack(spacing: 8) { if let mood = entry.todaysMood { // Large centered mood icon ZStack(alignment: .bottomTrailing) { moodImages.icon(forMood: mood) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 56, height: 56) .foregroundColor(moodTint.color(forMood: mood)) // Checkmark badge Image(systemName: "checkmark.circle.fill") .font(.system(size: 18)) .foregroundColor(.green) .background(Circle().fill(.white).frame(width: 14, height: 14)) .offset(x: 4, y: 4) } Text("Logged!") .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) if let stats = entry.stats { Text("\(stats.totalEntries) day streak") .font(.caption2) .foregroundStyle(.tertiary) } } } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(12) } // MARK: - Medium: Mood + stats bar private var mediumLayout: some View { HStack(spacing: 20) { if let mood = entry.todaysMood { // Left: Mood display 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) } // Right: Stats if let stats = entry.stats { VStack(alignment: .leading, spacing: 8) { Text("\(stats.totalEntries) entries") .font(.caption.weight(.medium)) .foregroundStyle(.primary) // Mini mood breakdown HStack(spacing: 6) { ForEach([Mood.great, .good, .average, .bad, .horrible], id: \.rawValue) { mood in let count = stats.moodCounts[mood, default: 0] if count > 0 { HStack(spacing: 2) { Circle() .fill(moodTint.color(forMood: mood)) .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) { mood in let percentage = stats.percentage(for: mood) if percentage > 0 { RoundedRectangle(cornerRadius: 2) .fill(moodTint.color(forMood: mood)) .frame(width: max(4, geo.size.width * CGFloat(percentage) / 100)) } } } } .frame(height: 8) .clipShape(RoundedRectangle(cornerRadius: 4)) } .frame(maxWidth: .infinity, alignment: .leading) } } } .padding() } } // MARK: - Non-Subscriber View struct NonSubscriberView: View { var body: some View { Link(destination: URL(string: "feels://subscribe")!) { VStack(spacing: 8) { Image(systemName: "heart.fill") .font(.largeTitle) .foregroundStyle(.pink) Text("Track Your Mood") .font(.subheadline.weight(.semibold)) .foregroundStyle(.primary) .minimumScaleFactor(0.8) Text("Tap to subscribe") .font(.caption2) .foregroundStyle(.secondary) } .padding(12) .frame(maxWidth: .infinity, maxHeight: .infinity) } } } // MARK: - Widget Configuration struct FeelsVoteWidget: Widget { let kind: String = "FeelsVoteWidget" var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: VoteWidgetProvider()) { entry in FeelsVoteWidgetEntryView(entry: entry) } .configurationDisplayName("Mood Vote") .description("Quickly rate your mood for today") .supportedFamilies([.systemSmall, .systemMedium]) } } // MARK: - Preview #Preview(as: .systemSmall) { FeelsVoteWidget() } timeline: { VoteWidgetEntry(date: Date(), hasSubscription: true, hasVotedToday: false, todaysMood: nil, stats: nil, promptText: "How are you feeling today?") VoteWidgetEntry(date: Date(), hasSubscription: true, hasVotedToday: true, todaysMood: .great, stats: MoodStats(totalEntries: 30, moodCounts: [.great: 10, .good: 12, .average: 5, .bad: 2, .horrible: 1]), promptText: "") VoteWidgetEntry(date: Date(), hasSubscription: false, hasVotedToday: false, todaysMood: nil, stats: nil, promptText: "") }