Files
Reflect/FeelsWidget2/FeelsVoteWidget.swift
Trey t 2a703a8969 Add 4 new mood icon styles and improve widget layouts
New mood icon styles:
- Weather (☀️⛈️)
- Garden (🌸🥀)
- Hearts (💖💔)
- Cosmic (🕳️)

Widget improvements:
- Small vote widget: 3-over-2 grid layout
- Medium vote widget: single horizontal row
- Redesigned voted stats view with checkmark badge
- Fixed text truncation on non-subscriber view
- Added comprehensive previews for all widget types

Bug fix:
- Voting header now updates when mood image style changes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 19:06:05 -06:00

479 lines
17 KiB
Swift

//
// 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")
// Run in main app process - enables full MoodLogger with watch sync
static var openAppWhenRun: Bool { true }
@Parameter(title: "Mood")
var moodValue: Int
init() {
self.moodValue = 2
}
init(mood: Mood) {
self.moodValue = mood.rawValue
}
@MainActor
func perform() async throws -> some IntentResult {
let mood = Mood(rawValue: moodValue) ?? .average
let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding())
// This code runs in the main app process (openAppWhenRun = true)
// Use conditional compilation for widget extension to compile
#if !WIDGET_EXTENSION
// Main app: use MoodLogger for all side effects including watch sync
MoodLogger.shared.logMood(mood, for: votingDate, entryType: .widget)
#else
// Widget extension compilation path (never executed at runtime)
WidgetDataProvider.shared.add(mood: mood, forDate: votingDate, entryType: .widget)
WidgetCenter.shared.reloadAllTimelines()
#endif
// Store last voted date
let dateString = ISO8601DateFormatter().string(from: Calendar.current.startOfDay(for: votingDate))
GroupUserDefaults.groupDefaults.set(dateString, forKey: UserDefaultsStore.Keys.lastVotedDate.rawValue)
return .result()
}
}
// 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<VoteWidgetEntry>) -> 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: "")
}