- Add HealthKit State of Mind sync for mood entries - Add Live Activity with streak display and rating time window - Add App Shortcuts/Siri integration for voice mood logging - Add TipKit hints for feature discovery - Add centralized MoodLogger for consistent side effects - Add reminder time setting in Settings with time picker - Fix duplicate notifications when changing reminder time - Fix Live Activity streak showing 0 when not yet rated today - Fix slow tap response in entry detail mood selection - Update widget timeline to refresh at rating time - Sync widgets when reminder time changes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
394 lines
14 KiB
Swift
394 lines
14 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")
|
|
static var openAppWhenRun: Bool { false }
|
|
|
|
@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())
|
|
|
|
// Widget uses simplified mood logging since it can't access HealthKitManager/TipsManager
|
|
// Full side effects (HealthKit sync, TipKit) will run when main app opens via MoodLogger
|
|
DataController.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)
|
|
|
|
// Update Live Activity
|
|
let streak = calculateCurrentStreak()
|
|
LiveActivityManager.shared.updateActivity(streak: streak, mood: mood)
|
|
LiveActivityScheduler.shared.scheduleForNextDay()
|
|
|
|
// Reload widget timeline
|
|
WidgetCenter.shared.reloadTimelines(ofKind: "FeelsVoteWidget")
|
|
|
|
return .result()
|
|
}
|
|
|
|
@MainActor
|
|
private func calculateCurrentStreak() -> Int {
|
|
var streak = 0
|
|
var checkDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding())
|
|
|
|
while true {
|
|
let dayStart = Calendar.current.startOfDay(for: checkDate)
|
|
let dayEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: dayStart)!
|
|
|
|
let entry = DataController.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first
|
|
|
|
if let entry = entry, entry.mood != .missing && entry.mood != .placeholder {
|
|
streak += 1
|
|
checkDate = Calendar.current.date(byAdding: .day, value: -1, to: checkDate)!
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
|
|
return streak
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
|
|
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 = DataController.shared.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 = DataController.shared.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
|
|
let moods: [Mood] = [.horrible, .bad, .average, .good, .great]
|
|
|
|
private var moodTint: MoodTintable.Type {
|
|
UserDefaultsStore.moodTintable()
|
|
}
|
|
|
|
private var moodImages: MoodImagable.Type {
|
|
UserDefaultsStore.moodMoodImagable()
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 12) {
|
|
Text(promptText)
|
|
.font(.headline)
|
|
.foregroundStyle(.primary)
|
|
.multilineTextAlignment(.center)
|
|
.lineLimit(2)
|
|
.minimumScaleFactor(0.8)
|
|
|
|
HStack(spacing: 8) {
|
|
ForEach(moods, id: \.rawValue) { mood in
|
|
Button(intent: VoteMoodIntent(mood: mood)) {
|
|
moodImages.icon(forMood: mood)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: family == .systemSmall ? 36 : 44, height: family == .systemSmall ? 36 : 44)
|
|
.foregroundColor(moodTint.color(forMood: mood))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
|
|
// 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.widgetDisplayName)
|
|
.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)
|
|
|
|
GeometryReader { geo in
|
|
HStack(spacing: 2) {
|
|
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: 12)
|
|
}
|
|
}
|
|
}
|
|
.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, 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: "")
|
|
}
|