// // FeelsVoteWidget.swift // FeelsWidget // // Interactive widget for mood voting (iOS 17+) // import WidgetKit import SwiftUI import AppIntents // MARK: - App Intent for Mood Voting struct VoteMoodIntent: AppIntent { static var title: LocalizedStringResource = "Vote Mood" static var description = IntentDescription("Record your mood for today") @Parameter(title: "Mood") var moodValue: Int init() { self.moodValue = 2 } init(mood: Mood) { self.moodValue = mood.rawValue } func perform() async throws -> some IntentResult { let mood = Mood(rawValue: moodValue) ?? .average let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding()) // Add mood entry PersistenceController.shared.add(mood: mood, forDate: votingDate, entryType: .widget) // Store last voted date let dateString = ISO8601DateFormatter().string(from: Calendar.current.startOfDay(for: votingDate)) GroupUserDefaults.groupDefaults.set(dateString, forKey: UserDefaultsStore.Keys.lastVotedDate.rawValue) // Reload widget timeline WidgetCenter.shared.reloadTimelines(ofKind: "FeelsVoteWidget") return .result() } } // MARK: - Vote Widget Provider struct VoteWidgetProvider: TimelineProvider { func placeholder(in context: Context) -> VoteWidgetEntry { VoteWidgetEntry(date: Date(), hasSubscription: true, hasVotedToday: false, todaysMood: nil, stats: nil) } func getSnapshot(in context: Context, completion: @escaping (VoteWidgetEntry) -> Void) { let entry = createEntry() completion(entry) } func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { let entry = createEntry() // Refresh at midnight let midnight = Calendar.current.startOfDay(for: Calendar.current.date(byAdding: .day, value: 1, to: Date())!) let timeline = Timeline(entries: [entry], policy: .after(midnight)) completion(timeline) } private func createEntry() -> VoteWidgetEntry { let hasSubscription = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.hasActiveSubscription.rawValue) 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 = PersistenceController.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first let hasVotedToday = todayEntry != nil && todayEntry?.mood != .missing && todayEntry?.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 = PersistenceController.shared.getAllData() let validEntries = allEntries.filter { $0.mood != .missing && $0.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) } } return VoteWidgetEntry( date: Date(), hasSubscription: hasSubscription, hasVotedToday: hasVotedToday, todaysMood: todaysMood, stats: stats ) } } // 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? } // 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) } } 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 moods: [Mood] = [.horrible, .bad, .average, .good, .great] var body: some View { VStack(spacing: 8) { Text("How are you feeling?") .font(.headline) .foregroundStyle(.primary) if family == .systemSmall { // Compact layout for small widget LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 8) { ForEach(moods, id: \.rawValue) { mood in MoodButton(mood: mood, isCompact: true) } } } else { // Horizontal layout for medium/large HStack(spacing: 12) { ForEach(moods, id: \.rawValue) { mood in MoodButton(mood: mood, isCompact: false) } } } } .padding() } } struct MoodButton: View { let mood: Mood let isCompact: Bool private var moodTint: MoodTintable.Type { UserDefaultsStore.moodTintable() } private var moodImages: MoodImagable.Type { UserDefaultsStore.moodMoodImagable() } var body: some View { Button(intent: VoteMoodIntent(mood: mood)) { VStack(spacing: 4) { moodImages.icon(forMood: mood) .resizable() .aspectRatio(contentMode: .fit) .frame(width: isCompact ? 28 : 36, height: isCompact ? 28 : 36) .foregroundColor(moodTint.color(forMood: mood)) if !isCompact { Text(mood.strValue) .font(.caption2) .foregroundStyle(.secondary) } } } .buttonStyle(.plain) } } // MARK: - Voted Stats View (shown after voting) struct VotedStatsView: View { let entry: VoteWidgetEntry private var moodTint: MoodTintable.Type { UserDefaultsStore.moodTintable() } private var moodImages: MoodImagable.Type { UserDefaultsStore.moodMoodImagable() } var body: some View { VStack(spacing: 12) { // Today's mood if let mood = entry.todaysMood { HStack(spacing: 8) { moodImages.icon(forMood: mood) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 32, height: 32) .foregroundColor(moodTint.color(forMood: mood)) VStack(alignment: .leading, spacing: 2) { Text("Today") .font(.caption) .foregroundStyle(.secondary) Text(mood.strValue) .font(.headline) .foregroundColor(moodTint.color(forMood: mood)) } Spacer() } } // Stats if let stats = entry.stats { Divider() VStack(spacing: 4) { Text("\(stats.totalEntries) entries") .font(.caption) .foregroundStyle(.secondary) HStack(spacing: 4) { 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, CGFloat(percentage) * 0.8)) } } } .frame(height: 8) } } } .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(.headline) .foregroundStyle(.primary) Text("Tap to subscribe") .font(.caption) .foregroundStyle(.secondary) .multilineTextAlignment(.center) } .padding() .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) VoteWidgetEntry(date: Date(), hasSubscription: true, hasVotedToday: true, todaysMood: .great, stats: MoodStats(totalEntries: 30, moodCounts: [.great: 10, .good: 12, .average: 5, .bad: 2, .horrible: 1])) VoteWidgetEntry(date: Date(), hasSubscription: false, hasVotedToday: false, todaysMood: nil, stats: nil) }