// // ReflectVoteWidget.swift // ReflectWidget // // Interactive widget for mood voting (iOS 17+) // import WidgetKit import SwiftUI import AppIntents // MARK: - Widget Configuration struct ReflectVoteWidget: Widget { let kind: String = "ReflectVoteWidget" var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: VoteWidgetProvider()) { entry in ReflectVoteWidgetEntryView(entry: entry) } .configurationDisplayName("Mood Vote") .description("Quickly rate your mood for today") .supportedFamilies([.systemSmall, .systemMedium]) } } // MARK: - Entry View struct ReflectVoteWidgetEntryView: View { @Environment(\.widgetFamily) var family var entry: VoteWidgetProvider.Entry var body: some View { Group { if entry.hasVotedToday { // Already voted today - show stats (regardless of subscription status) VotedStatsView(entry: entry) } else { // Not voted yet - show voting buttons // If subscribed/in trial: buttons record votes // If trial expired: buttons open app VotingView(family: family, promptText: entry.promptText, hasSubscription: entry.hasSubscription) } } .containerBackground(.fill.tertiary, for: .widget) } } // 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() } /// Returns "Today" if the voting date is today, otherwise returns formatted date like "Sun, Dec 28th" private var votingDateString: String { if Calendar.current.isDateInToday(entry.votingDate) { return String(localized: "Today") } else { let dayFormatter = DateFormatter() dayFormatter.dateFormat = "EEE" // "Sun" let dateFormatter = DateFormatter() dateFormatter.dateFormat = "MMM d" // "Dec 28" let day = dayFormatter.string(from: entry.votingDate) let date = dateFormatter.string(from: entry.votingDate) return "\(day), \(date)" } } var body: some View { if family == .systemSmall { smallLayout } else { mediumLayout } } // MARK: - Small: Centered mood with checkmark and date 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(.headline) .foregroundColor(.green) .background(Circle().fill(.white).frame(width: 14, height: 14)) .offset(x: 4, y: 4) } .accessibilityElement(children: .combine) .accessibilityLabel(String(localized: "Mood logged: \(mood.strValue)")) Text(votingDateString) .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) if let stats = entry.stats { Text("\(stats.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) { 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)) .accessibilityLabel(mood.strValue) Text(mood.widgetDisplayName) .font(.subheadline.weight(.semibold)) .foregroundColor(moodTint.color(forMood: mood)) Text(votingDateString) .font(.caption2) .foregroundStyle(.secondary) } // Right: Stats with progress bar aligned under title if let stats = entry.stats { VStack(alignment: .leading, spacing: 10) { Text("\(stats.totalEntries) entries") .font(.headline.weight(.semibold)) .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) .accessibilityHidden(true) Text("\(count)") .font(.caption2) .foregroundStyle(.secondary) } .accessibilityElement(children: .combine) .accessibilityLabel("\(count) \(mood.strValue)") } } } // Progress bar - aligned with title GeometryReader { geo in HStack(spacing: 1) { ForEach([Mood.great, .good, .average, .bad, .horrible], id: \.rawValue) { m in let percentage = stats.percentage(for: m) if percentage > 0 { RoundedRectangle(cornerRadius: 2) .fill(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: - Non-Subscriber View struct NonSubscriberView: View { var body: some View { Link(destination: URL(string: "reflect://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) } .accessibilityLabel(String(localized: "Track Your Mood")) .accessibilityHint(String(localized: "Tap to open app and subscribe")) .accessibilityIdentifier(AccessibilityID.Widget.subscribeLink) } } // MARK: - Preview Helpers private enum VoteWidgetPreviewHelpers { static let sampleStats = MoodStats( totalEntries: 30, moodCounts: [.great: 10, .good: 12, .average: 5, .bad: 2, .horrible: 1] ) static let largeStats = MoodStats( totalEntries: 100, moodCounts: [.great: 35, .good: 40, .average: 15, .bad: 7, .horrible: 3] ) } // MARK: - Small Widget Previews #Preview("Vote Small - Not Voted", as: .systemSmall) { ReflectVoteWidget() } timeline: { VoteWidgetEntry( date: Date(), votingDate: Date(), hasSubscription: true, hasVotedToday: false, todaysMood: nil, stats: nil, promptText: "How are you feeling today?" ) } #Preview("Vote Small - Voted Great", as: .systemSmall) { ReflectVoteWidget() } timeline: { VoteWidgetEntry( date: Date(), votingDate: Date(), hasSubscription: true, hasVotedToday: true, todaysMood: .great, stats: VoteWidgetPreviewHelpers.sampleStats, promptText: "" ) } #Preview("Vote Small - Voted Good", as: .systemSmall) { ReflectVoteWidget() } timeline: { VoteWidgetEntry( date: Date(), votingDate: Date(), hasSubscription: true, hasVotedToday: true, todaysMood: .good, stats: VoteWidgetPreviewHelpers.sampleStats, promptText: "" ) } #Preview("Vote Small - Voted Average", as: .systemSmall) { ReflectVoteWidget() } timeline: { VoteWidgetEntry( date: Date(), votingDate: Date(), hasSubscription: true, hasVotedToday: true, todaysMood: .average, stats: VoteWidgetPreviewHelpers.sampleStats, promptText: "" ) } #Preview("Vote Small - Voted Bad", as: .systemSmall) { ReflectVoteWidget() } timeline: { VoteWidgetEntry( date: Date(), votingDate: Date(), hasSubscription: true, hasVotedToday: true, todaysMood: .bad, stats: VoteWidgetPreviewHelpers.sampleStats, promptText: "" ) } #Preview("Vote Small - Voted Horrible", as: .systemSmall) { ReflectVoteWidget() } timeline: { VoteWidgetEntry( date: Date(), votingDate: Date(), hasSubscription: true, hasVotedToday: true, todaysMood: .horrible, stats: VoteWidgetPreviewHelpers.sampleStats, promptText: "" ) } #Preview("Vote Small - Non-Subscriber", as: .systemSmall) { ReflectVoteWidget() } timeline: { VoteWidgetEntry( date: Date(), votingDate: Date(), hasSubscription: false, hasVotedToday: false, todaysMood: nil, stats: nil, promptText: "" ) } // MARK: - Medium Widget Previews #Preview("Vote Medium - Not Voted", as: .systemMedium) { ReflectVoteWidget() } timeline: { VoteWidgetEntry( date: Date(), votingDate: Date(), hasSubscription: true, hasVotedToday: false, todaysMood: nil, stats: nil, promptText: "How are you feeling today?" ) } #Preview("Vote Medium - Voted Great", as: .systemMedium) { ReflectVoteWidget() } timeline: { VoteWidgetEntry( date: Date(), votingDate: Date(), hasSubscription: true, hasVotedToday: true, todaysMood: .great, stats: VoteWidgetPreviewHelpers.largeStats, promptText: "" ) } #Preview("Vote Medium - Voted Good", as: .systemMedium) { ReflectVoteWidget() } timeline: { VoteWidgetEntry( date: Date(), votingDate: Date(), hasSubscription: true, hasVotedToday: true, todaysMood: .good, stats: VoteWidgetPreviewHelpers.largeStats, promptText: "" ) } #Preview("Vote Medium - Voted Average", as: .systemMedium) { ReflectVoteWidget() } timeline: { VoteWidgetEntry( date: Date(), votingDate: Date(), hasSubscription: true, hasVotedToday: true, todaysMood: .average, stats: VoteWidgetPreviewHelpers.sampleStats, promptText: "" ) } #Preview("Vote Medium - Voted Bad", as: .systemMedium) { ReflectVoteWidget() } timeline: { VoteWidgetEntry( date: Date(), votingDate: Date(), hasSubscription: true, hasVotedToday: true, todaysMood: .bad, stats: VoteWidgetPreviewHelpers.sampleStats, promptText: "" ) } #Preview("Vote Medium - Voted Horrible", as: .systemMedium) { ReflectVoteWidget() } timeline: { VoteWidgetEntry( date: Date(), votingDate: Date(), hasSubscription: true, hasVotedToday: true, todaysMood: .horrible, stats: VoteWidgetPreviewHelpers.sampleStats, promptText: "" ) } #Preview("Vote Medium - Non-Subscriber", as: .systemMedium) { ReflectVoteWidget() } timeline: { VoteWidgetEntry( date: Date(), votingDate: Date(), hasSubscription: false, hasVotedToday: false, todaysMood: nil, stats: nil, promptText: "" ) }