Add Apple platform features and UX improvements
- 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>
This commit is contained in:
@@ -17,6 +17,8 @@
|
||||
<key>com.apple.developer.healthkit</key>
|
||||
<true/>
|
||||
<key>com.apple.developer.healthkit.access</key>
|
||||
<array/>
|
||||
<array>
|
||||
<string>health-records</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -26,7 +26,9 @@
|
||||
<key>NSHealthShareUsageDescription</key>
|
||||
<string>Feels uses your health data to find correlations between your activity, sleep, and mood patterns to provide personalized insights.</string>
|
||||
<key>NSHealthUpdateUsageDescription</key>
|
||||
<string>Feels does not write any health data.</string>
|
||||
<string>Feels syncs your mood data to Apple Health so you can see how your emotions correlate with sleep, exercise, and other health metrics.</string>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Feels uses the camera to take photos for your mood journal entries.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
|
||||
@@ -103,6 +103,7 @@
|
||||
Models/Shapes.swift,
|
||||
Models/Theme.swift,
|
||||
Models/UserDefaultsStore.swift,
|
||||
MoodStreakActivity.swift,
|
||||
Onboarding/OnboardingData.swift,
|
||||
Onboarding/views/OnboardingDay.swift,
|
||||
Persisence/DataController.swift,
|
||||
|
||||
@@ -32,18 +32,46 @@ struct VoteMoodIntent: AppIntent {
|
||||
let mood = Mood(rawValue: moodValue) ?? .average
|
||||
let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding())
|
||||
|
||||
// Add mood entry
|
||||
// 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
|
||||
@@ -75,13 +103,41 @@ struct VoteWidgetProvider: TimelineProvider {
|
||||
Task { @MainActor in
|
||||
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))
|
||||
// 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)
|
||||
|
||||
@@ -9,6 +9,127 @@ import WidgetKit
|
||||
import SwiftUI
|
||||
import Intents
|
||||
import SwiftData
|
||||
import ActivityKit
|
||||
import AppIntents
|
||||
|
||||
// MARK: - Live Activity Widget
|
||||
// Note: MoodStreakAttributes is defined in MoodStreakActivity.swift (Shared folder)
|
||||
|
||||
struct MoodStreakLiveActivity: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
ActivityConfiguration(for: MoodStreakAttributes.self) { context in
|
||||
// Lock Screen / StandBy view
|
||||
MoodStreakLockScreenView(context: context)
|
||||
} dynamicIsland: { context in
|
||||
DynamicIsland {
|
||||
// Expanded view
|
||||
DynamicIslandExpandedRegion(.leading) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "flame.fill")
|
||||
.foregroundColor(.orange)
|
||||
Text("\(context.state.currentStreak)")
|
||||
.font(.title2.bold())
|
||||
}
|
||||
}
|
||||
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
if context.state.hasLoggedToday {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
.font(.title2)
|
||||
} else {
|
||||
Text("Log now")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
DynamicIslandExpandedRegion(.center) {
|
||||
Text(context.state.hasLoggedToday ? "Streak: \(context.state.currentStreak) days" : "Don't break your streak!")
|
||||
.font(.headline)
|
||||
}
|
||||
|
||||
DynamicIslandExpandedRegion(.bottom) {
|
||||
if !context.state.hasLoggedToday {
|
||||
Text("Voting closes at midnight")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(Color(hex: context.state.lastMoodColor))
|
||||
.frame(width: 20, height: 20)
|
||||
Text("Today: \(context.state.lastMoodLogged)")
|
||||
.font(.subheadline)
|
||||
}
|
||||
}
|
||||
}
|
||||
} compactLeading: {
|
||||
Image(systemName: "flame.fill")
|
||||
.foregroundColor(.orange)
|
||||
} compactTrailing: {
|
||||
Text("\(context.state.currentStreak)")
|
||||
.font(.caption.bold())
|
||||
} minimal: {
|
||||
Image(systemName: "flame.fill")
|
||||
.foregroundColor(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MoodStreakLockScreenView: View {
|
||||
let context: ActivityViewContext<MoodStreakAttributes>
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 16) {
|
||||
// Streak indicator
|
||||
VStack(spacing: 4) {
|
||||
Image(systemName: "flame.fill")
|
||||
.font(.title)
|
||||
.foregroundColor(.orange)
|
||||
Text("\(context.state.currentStreak)")
|
||||
.font(.title.bold())
|
||||
Text("day streak")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Divider()
|
||||
.frame(height: 50)
|
||||
|
||||
// Status
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if context.state.hasLoggedToday {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(Color(hex: context.state.lastMoodColor))
|
||||
.frame(width: 24, height: 24)
|
||||
VStack(alignment: .leading) {
|
||||
Text("Today's mood")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(context.state.lastMoodLogged)
|
||||
.font(.headline)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Don't break your streak!")
|
||||
.font(.headline)
|
||||
Text("Tap to log your mood")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.activityBackgroundTint(Color(.systemBackground).opacity(0.8))
|
||||
}
|
||||
}
|
||||
|
||||
class WatchTimelineView: Identifiable {
|
||||
let id = UUID()
|
||||
@@ -361,6 +482,7 @@ struct FeelsGraphicWidgetEntryView : View {
|
||||
@ViewBuilder
|
||||
var body: some View {
|
||||
SmallGraphicWidgetView(entry: entry)
|
||||
.containerBackground(.fill.tertiary, for: .widget)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,6 +523,7 @@ struct FeelsIconWidgetEntryView : View {
|
||||
@ViewBuilder
|
||||
var body: some View {
|
||||
SmallIconView(entry: entry)
|
||||
.containerBackground(.fill.tertiary, for: .widget)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -526,6 +649,31 @@ struct FeelsBundle: WidgetBundle {
|
||||
FeelsGraphicWidget()
|
||||
FeelsIconWidget()
|
||||
FeelsVoteWidget()
|
||||
FeelsMoodControlWidget()
|
||||
MoodStreakLiveActivity()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Control Center Widget
|
||||
struct FeelsMoodControlWidget: ControlWidget {
|
||||
var body: some ControlWidgetConfiguration {
|
||||
StaticControlConfiguration(kind: "FeelsMoodControl") {
|
||||
ControlWidgetButton(action: OpenFeelsIntent()) {
|
||||
Label("Log Mood", systemImage: "face.smiling")
|
||||
}
|
||||
}
|
||||
.displayName("Log Mood")
|
||||
.description("Open Feels to log your mood")
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenFeelsIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Open Feels"
|
||||
static var description = IntentDescription("Open the Feels app to log your mood")
|
||||
static var openAppWhenRun: Bool = true
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,5 +7,7 @@
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
import UIKit
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
@@ -56,21 +55,25 @@ extension AppDelegate: @preconcurrency UNUserNotificationCenterDelegate {
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
||||
if let action = LocalNotification.ActionType(rawValue: response.actionIdentifier) {
|
||||
let date = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: savedOnboardingData)
|
||||
let mood: Mood
|
||||
switch action {
|
||||
case .horrible:
|
||||
DataController.shared.add(mood: .horrible, forDate: date, entryType: .notification)
|
||||
mood = .horrible
|
||||
case .bad:
|
||||
DataController.shared.add(mood: .bad, forDate: date, entryType: .notification)
|
||||
mood = .bad
|
||||
case .average:
|
||||
DataController.shared.add(mood: .average, forDate: date, entryType: .notification)
|
||||
mood = .average
|
||||
case .good:
|
||||
DataController.shared.add(mood: .good, forDate: date, entryType: .notification)
|
||||
mood = .good
|
||||
case .great:
|
||||
DataController.shared.add(mood: .great, forDate: date, entryType: .notification)
|
||||
mood = .great
|
||||
}
|
||||
|
||||
// Use centralized mood logger
|
||||
MoodLogger.shared.logMood(mood, for: date, entryType: .notification)
|
||||
|
||||
UNUserNotificationCenter.current().setBadgeCount(0)
|
||||
}
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
completionHandler()
|
||||
}
|
||||
}
|
||||
|
||||
217
Shared/AppShortcuts.swift
Normal file
217
Shared/AppShortcuts.swift
Normal file
@@ -0,0 +1,217 @@
|
||||
//
|
||||
// AppShortcuts.swift
|
||||
// Feels
|
||||
//
|
||||
// App Intents and Siri Shortcuts for voice-activated mood logging
|
||||
//
|
||||
|
||||
import AppIntents
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Mood Entity for App Intents
|
||||
|
||||
struct MoodEntity: AppEntity {
|
||||
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Mood")
|
||||
|
||||
static var defaultQuery = MoodEntityQuery()
|
||||
|
||||
var id: Int
|
||||
var name: String
|
||||
var mood: Mood
|
||||
|
||||
var displayRepresentation: DisplayRepresentation {
|
||||
DisplayRepresentation(title: "\(name)")
|
||||
}
|
||||
|
||||
static let allMoods: [MoodEntity] = [
|
||||
MoodEntity(id: 0, name: "Horrible", mood: .horrible),
|
||||
MoodEntity(id: 1, name: "Bad", mood: .bad),
|
||||
MoodEntity(id: 2, name: "Average", mood: .average),
|
||||
MoodEntity(id: 3, name: "Good", mood: .good),
|
||||
MoodEntity(id: 4, name: "Great", mood: .great)
|
||||
]
|
||||
}
|
||||
|
||||
struct MoodEntityQuery: EntityQuery {
|
||||
func entities(for identifiers: [Int]) async throws -> [MoodEntity] {
|
||||
MoodEntity.allMoods.filter { identifiers.contains($0.id) }
|
||||
}
|
||||
|
||||
func suggestedEntities() async throws -> [MoodEntity] {
|
||||
MoodEntity.allMoods
|
||||
}
|
||||
|
||||
func defaultResult() async -> MoodEntity? {
|
||||
MoodEntity.allMoods.first { $0.mood == .average }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Log Mood Intent
|
||||
|
||||
struct LogMoodIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Log Mood"
|
||||
static var description = IntentDescription("Record your mood for today in Feels")
|
||||
static var openAppWhenRun: Bool = false
|
||||
|
||||
@Parameter(title: "Mood")
|
||||
var moodEntity: MoodEntity
|
||||
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
Summary("Log mood as \(\.$moodEntity)")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetView {
|
||||
let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding())
|
||||
|
||||
// Use centralized mood logger
|
||||
MoodLogger.shared.logMood(moodEntity.mood, for: votingDate, entryType: .siri)
|
||||
|
||||
let moodTint = UserDefaultsStore.moodTintable()
|
||||
let color = moodTint.color(forMood: moodEntity.mood)
|
||||
|
||||
return .result(
|
||||
dialog: "Got it! Logged \(moodEntity.name) for today.",
|
||||
view: MoodLoggedSnippetView(moodName: moodEntity.name, color: color)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Check Today's Mood Intent
|
||||
|
||||
struct CheckTodaysMoodIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Check Today's Mood"
|
||||
static var description = IntentDescription("See what mood you logged today in Feels")
|
||||
static var openAppWhenRun: Bool = false
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult & ProvidesDialog {
|
||||
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)!
|
||||
|
||||
let todayEntry = DataController.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first
|
||||
|
||||
if let entry = todayEntry, entry.mood != .missing && entry.mood != .placeholder {
|
||||
return .result(dialog: "Today you logged feeling \(entry.mood.widgetDisplayName).")
|
||||
} else {
|
||||
return .result(dialog: "You haven't logged your mood today yet. Would you like to log it now?")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Get Mood Streak Intent
|
||||
|
||||
struct GetMoodStreakIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Get Mood Streak"
|
||||
static var description = IntentDescription("Check your current mood logging streak")
|
||||
static var openAppWhenRun: Bool = false
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult & ProvidesDialog {
|
||||
let streak = calculateStreak()
|
||||
|
||||
if streak == 0 {
|
||||
return .result(dialog: "You don't have a streak yet. Log your mood today to start one!")
|
||||
} else if streak == 1 {
|
||||
return .result(dialog: "You have a 1 day streak. Keep it going!")
|
||||
} else {
|
||||
return .result(dialog: "Amazing! You have a \(streak) day streak. Keep it up!")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func calculateStreak() -> 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: - Snippet View for Mood Logged
|
||||
|
||||
struct MoodLoggedSnippetView: View {
|
||||
let moodName: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 44, height: 44)
|
||||
.overlay {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.title2.bold())
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Mood Logged")
|
||||
.font(.headline)
|
||||
Text(moodName)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(color)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - App Shortcuts Provider
|
||||
|
||||
struct FeelsShortcuts: AppShortcutsProvider {
|
||||
static var appShortcuts: [AppShortcut] {
|
||||
AppShortcut(
|
||||
intent: LogMoodIntent(),
|
||||
phrases: [
|
||||
"Log my mood in \(.applicationName)",
|
||||
"Log mood as \(\.$moodEntity) in \(.applicationName)",
|
||||
"Record my mood in \(.applicationName)",
|
||||
"I'm feeling \(\.$moodEntity) in \(.applicationName)",
|
||||
"Track my mood in \(.applicationName)"
|
||||
],
|
||||
shortTitle: "Log Mood",
|
||||
systemImageName: "face.smiling"
|
||||
)
|
||||
|
||||
AppShortcut(
|
||||
intent: CheckTodaysMoodIntent(),
|
||||
phrases: [
|
||||
"What's my mood today in \(.applicationName)",
|
||||
"Check today's mood in \(.applicationName)",
|
||||
"How am I feeling in \(.applicationName)"
|
||||
],
|
||||
shortTitle: "Today's Mood",
|
||||
systemImageName: "calendar"
|
||||
)
|
||||
|
||||
AppShortcut(
|
||||
intent: GetMoodStreakIntent(),
|
||||
phrases: [
|
||||
"What's my mood streak in \(.applicationName)",
|
||||
"Check my streak in \(.applicationName)",
|
||||
"How many days in a row in \(.applicationName)"
|
||||
],
|
||||
shortTitle: "Mood Streak",
|
||||
systemImageName: "flame"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import SwiftUI
|
||||
import SwiftData
|
||||
import BackgroundTasks
|
||||
import WidgetKit
|
||||
import TipKit
|
||||
|
||||
@main
|
||||
struct FeelsApp: App {
|
||||
@@ -18,6 +19,7 @@ struct FeelsApp: App {
|
||||
let dataController = DataController.shared
|
||||
@StateObject var iapManager = IAPManager()
|
||||
@StateObject var authManager = BiometricAuthManager()
|
||||
@StateObject var healthKitManager = HealthKitManager.shared
|
||||
@AppStorage(UserDefaultsStore.Keys.firstLaunchDate.rawValue, store: GroupUserDefaults.groupDefaults) private var firstLaunchDate = Date()
|
||||
@State private var showSubscriptionFromWidget = false
|
||||
|
||||
@@ -27,6 +29,12 @@ struct FeelsApp: App {
|
||||
BGTask.runFillInMissingDatesTask(task: task as! BGProcessingTask)
|
||||
}
|
||||
UNUserNotificationCenter.current().setBadgeCount(0)
|
||||
|
||||
// Configure TipKit
|
||||
TipsManager.shared.configure()
|
||||
|
||||
// Initialize Live Activity scheduler
|
||||
LiveActivityScheduler.shared.scheduleBasedOnCurrentTime()
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
@@ -39,6 +47,7 @@ struct FeelsApp: App {
|
||||
.modelContainer(dataController.container)
|
||||
.environmentObject(iapManager)
|
||||
.environmentObject(authManager)
|
||||
.environmentObject(healthKitManager)
|
||||
.sheet(isPresented: $showSubscriptionFromWidget) {
|
||||
FeelsSubscriptionStoreView()
|
||||
.environmentObject(iapManager)
|
||||
@@ -75,6 +84,8 @@ struct FeelsApp: App {
|
||||
await authManager.authenticate()
|
||||
}
|
||||
}
|
||||
// Reschedule Live Activity when app becomes active
|
||||
LiveActivityScheduler.shared.scheduleBasedOnCurrentTime()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
259
Shared/FeelsTips.swift
Normal file
259
Shared/FeelsTips.swift
Normal file
@@ -0,0 +1,259 @@
|
||||
//
|
||||
// FeelsTips.swift
|
||||
// Feels
|
||||
//
|
||||
// TipKit implementation for feature discovery and onboarding
|
||||
//
|
||||
|
||||
import TipKit
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Tip Definitions
|
||||
|
||||
/// Tip for customizing mood layouts
|
||||
struct CustomizeLayoutTip: Tip {
|
||||
var title: Text {
|
||||
Text("Personalize Your Experience")
|
||||
}
|
||||
|
||||
var message: Text? {
|
||||
Text("Tap here to customize mood icons, colors, and layouts.")
|
||||
}
|
||||
|
||||
var image: Image? {
|
||||
Image(systemName: "paintbrush")
|
||||
}
|
||||
}
|
||||
|
||||
/// Tip for AI Insights feature
|
||||
struct AIInsightsTip: Tip {
|
||||
var title: Text {
|
||||
Text("Discover AI Insights")
|
||||
}
|
||||
|
||||
var message: Text? {
|
||||
Text("Get personalized insights about your mood patterns powered by Apple Intelligence.")
|
||||
}
|
||||
|
||||
var image: Image? {
|
||||
Image(systemName: "brain")
|
||||
}
|
||||
|
||||
var rules: [Rule] {
|
||||
#Rule(Self.$hasLoggedMoods) { $0 >= 7 }
|
||||
}
|
||||
|
||||
@Parameter
|
||||
static var hasLoggedMoods: Int = 0
|
||||
}
|
||||
|
||||
/// Tip for Siri shortcuts
|
||||
struct SiriShortcutTip: Tip {
|
||||
var title: Text {
|
||||
Text("Use Siri to Log Moods")
|
||||
}
|
||||
|
||||
var message: Text? {
|
||||
Text("Say \"Hey Siri, log my mood as great in Feels\" for hands-free logging.")
|
||||
}
|
||||
|
||||
var image: Image? {
|
||||
Image(systemName: "mic.fill")
|
||||
}
|
||||
|
||||
var rules: [Rule] {
|
||||
#Rule(Self.$moodLogCount) { $0 >= 3 }
|
||||
}
|
||||
|
||||
@Parameter
|
||||
static var moodLogCount: Int = 0
|
||||
}
|
||||
|
||||
/// Tip for HealthKit sync
|
||||
struct HealthKitSyncTip: Tip {
|
||||
var title: Text {
|
||||
Text("Sync with Apple Health")
|
||||
}
|
||||
|
||||
var message: Text? {
|
||||
Text("Connect to Apple Health to see your mood data alongside sleep, exercise, and more.")
|
||||
}
|
||||
|
||||
var image: Image? {
|
||||
Image(systemName: "heart.fill")
|
||||
}
|
||||
|
||||
var rules: [Rule] {
|
||||
#Rule(Self.$hasSeenSettings) { $0 == true }
|
||||
}
|
||||
|
||||
@Parameter
|
||||
static var hasSeenSettings: Bool = false
|
||||
}
|
||||
|
||||
/// Tip for widget voting
|
||||
struct WidgetVotingTip: Tip {
|
||||
var title: Text {
|
||||
Text("Vote from Your Home Screen")
|
||||
}
|
||||
|
||||
var message: Text? {
|
||||
Text("Add the Mood Vote widget to quickly log your mood without opening the app.")
|
||||
}
|
||||
|
||||
var image: Image? {
|
||||
Image(systemName: "square.grid.2x2")
|
||||
}
|
||||
|
||||
var rules: [Rule] {
|
||||
#Rule(Self.$daysUsingApp) { $0 >= 2 }
|
||||
}
|
||||
|
||||
@Parameter
|
||||
static var daysUsingApp: Int = 0
|
||||
}
|
||||
|
||||
/// Tip for viewing different time periods
|
||||
struct TimeViewTip: Tip {
|
||||
var title: Text {
|
||||
Text("View Your History")
|
||||
}
|
||||
|
||||
var message: Text? {
|
||||
Text("Switch between Day, Month, and Year views to see your mood patterns over time.")
|
||||
}
|
||||
|
||||
var image: Image? {
|
||||
Image(systemName: "calendar")
|
||||
}
|
||||
}
|
||||
|
||||
/// Tip for mood streaks
|
||||
struct MoodStreakTip: Tip {
|
||||
var title: Text {
|
||||
Text("Build Your Streak!")
|
||||
}
|
||||
|
||||
var message: Text? {
|
||||
Text("Log your mood daily to build a streak. Consistency helps you understand your patterns.")
|
||||
}
|
||||
|
||||
var image: Image? {
|
||||
Image(systemName: "flame.fill")
|
||||
}
|
||||
|
||||
var rules: [Rule] {
|
||||
#Rule(Self.$currentStreak) { $0 >= 3 }
|
||||
}
|
||||
|
||||
@Parameter
|
||||
static var currentStreak: Int = 0
|
||||
}
|
||||
|
||||
/// Tip for Control Center widget
|
||||
struct ControlCenterTip: Tip {
|
||||
var title: Text {
|
||||
Text("Quick Access from Control Center")
|
||||
}
|
||||
|
||||
var message: Text? {
|
||||
Text("Add Feels to Control Center for one-tap mood logging from anywhere.")
|
||||
}
|
||||
|
||||
var image: Image? {
|
||||
Image(systemName: "slider.horizontal.3")
|
||||
}
|
||||
|
||||
var rules: [Rule] {
|
||||
#Rule(Self.$daysUsingApp) { $0 >= 5 }
|
||||
}
|
||||
|
||||
@Parameter
|
||||
static var daysUsingApp: Int = 0
|
||||
}
|
||||
|
||||
// MARK: - Tips Manager
|
||||
|
||||
@MainActor
|
||||
class TipsManager {
|
||||
static let shared = TipsManager()
|
||||
|
||||
private init() {}
|
||||
|
||||
func configure() {
|
||||
try? Tips.configure([
|
||||
.displayFrequency(.daily),
|
||||
.datastoreLocation(.applicationDefault)
|
||||
])
|
||||
}
|
||||
|
||||
func resetAllTips() {
|
||||
try? Tips.resetDatastore()
|
||||
}
|
||||
|
||||
// Update tip parameters based on user actions
|
||||
func onMoodLogged() {
|
||||
SiriShortcutTip.moodLogCount += 1
|
||||
AIInsightsTip.hasLoggedMoods += 1
|
||||
}
|
||||
|
||||
func onSettingsViewed() {
|
||||
HealthKitSyncTip.hasSeenSettings = true
|
||||
}
|
||||
|
||||
func updateDaysUsingApp(_ days: Int) {
|
||||
WidgetVotingTip.daysUsingApp = days
|
||||
ControlCenterTip.daysUsingApp = days
|
||||
}
|
||||
|
||||
func updateStreak(_ streak: Int) {
|
||||
MoodStreakTip.currentStreak = streak
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tip View Modifiers
|
||||
|
||||
extension View {
|
||||
func customizeLayoutTip() -> some View {
|
||||
self.popoverTip(CustomizeLayoutTip())
|
||||
}
|
||||
|
||||
func aiInsightsTip() -> some View {
|
||||
self.popoverTip(AIInsightsTip())
|
||||
}
|
||||
|
||||
func siriShortcutTip() -> some View {
|
||||
self.popoverTip(SiriShortcutTip())
|
||||
}
|
||||
|
||||
func healthKitSyncTip() -> some View {
|
||||
self.popoverTip(HealthKitSyncTip())
|
||||
}
|
||||
|
||||
func widgetVotingTip() -> some View {
|
||||
self.popoverTip(WidgetVotingTip())
|
||||
}
|
||||
|
||||
func timeViewTip() -> some View {
|
||||
self.popoverTip(TimeViewTip())
|
||||
}
|
||||
|
||||
func moodStreakTip() -> some View {
|
||||
self.popoverTip(MoodStreakTip())
|
||||
}
|
||||
|
||||
func controlCenterTip() -> some View {
|
||||
self.popoverTip(ControlCenterTip())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Inline Tip View
|
||||
|
||||
struct InlineTipView: View {
|
||||
let tip: any Tip
|
||||
|
||||
var body: some View {
|
||||
TipView(tip)
|
||||
.tipBackground(Color(.secondarySystemBackground))
|
||||
}
|
||||
}
|
||||
184
Shared/HealthKitManager.swift
Normal file
184
Shared/HealthKitManager.swift
Normal file
@@ -0,0 +1,184 @@
|
||||
//
|
||||
// HealthKitManager.swift
|
||||
// Feels
|
||||
//
|
||||
// HealthKit State of Mind API integration for syncing mood data with Apple Health
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import HealthKit
|
||||
|
||||
@MainActor
|
||||
class HealthKitManager: ObservableObject {
|
||||
static let shared = HealthKitManager()
|
||||
|
||||
private let healthStore = HKHealthStore()
|
||||
|
||||
@Published var isAuthorized = false
|
||||
@Published var authorizationError: Error?
|
||||
|
||||
// State of Mind sample type
|
||||
private var stateOfMindType: HKSampleType? {
|
||||
HKSampleType.stateOfMindType()
|
||||
}
|
||||
|
||||
// MARK: - Authorization
|
||||
|
||||
var isHealthKitAvailable: Bool {
|
||||
HKHealthStore.isHealthDataAvailable()
|
||||
}
|
||||
|
||||
func requestAuthorization() async throws {
|
||||
guard isHealthKitAvailable else {
|
||||
throw HealthKitError.notAvailable
|
||||
}
|
||||
|
||||
guard let stateOfMindType = stateOfMindType else {
|
||||
throw HealthKitError.typeNotAvailable
|
||||
}
|
||||
|
||||
let typesToShare: Set<HKSampleType> = [stateOfMindType]
|
||||
let typesToRead: Set<HKObjectType> = [stateOfMindType]
|
||||
|
||||
try await healthStore.requestAuthorization(toShare: typesToShare, read: typesToRead)
|
||||
|
||||
// Check authorization status
|
||||
let status = healthStore.authorizationStatus(for: stateOfMindType)
|
||||
isAuthorized = status == .sharingAuthorized
|
||||
}
|
||||
|
||||
func checkAuthorizationStatus() -> HKAuthorizationStatus {
|
||||
guard let stateOfMindType = stateOfMindType else {
|
||||
return .notDetermined
|
||||
}
|
||||
return healthStore.authorizationStatus(for: stateOfMindType)
|
||||
}
|
||||
|
||||
// MARK: - Save Mood to HealthKit
|
||||
|
||||
func saveMood(_ mood: Mood, for date: Date, note: String? = nil) async throws {
|
||||
guard isHealthKitAvailable else {
|
||||
throw HealthKitError.notAvailable
|
||||
}
|
||||
|
||||
guard checkAuthorizationStatus() == .sharingAuthorized else {
|
||||
throw HealthKitError.notAuthorized
|
||||
}
|
||||
|
||||
// Convert Feels mood to HealthKit valence (-1 to 1 scale)
|
||||
let valence = moodToValence(mood)
|
||||
|
||||
// Create State of Mind sample
|
||||
let stateOfMind = HKStateOfMind(
|
||||
date: date,
|
||||
kind: .dailyMood,
|
||||
valence: valence,
|
||||
labels: labelsForMood(mood),
|
||||
associations: [.currentEvents]
|
||||
)
|
||||
|
||||
try await healthStore.save(stateOfMind)
|
||||
}
|
||||
|
||||
// MARK: - Read Mood from HealthKit
|
||||
|
||||
func fetchMoods(from startDate: Date, to endDate: Date) async throws -> [HKStateOfMind] {
|
||||
guard isHealthKitAvailable else {
|
||||
throw HealthKitError.notAvailable
|
||||
}
|
||||
|
||||
guard let stateOfMindType = stateOfMindType else {
|
||||
throw HealthKitError.typeNotAvailable
|
||||
}
|
||||
|
||||
let predicate = HKQuery.predicateForSamples(
|
||||
withStart: startDate,
|
||||
end: endDate,
|
||||
options: .strictStartDate
|
||||
)
|
||||
|
||||
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
let query = HKSampleQuery(
|
||||
sampleType: stateOfMindType,
|
||||
predicate: predicate,
|
||||
limit: HKObjectQueryNoLimit,
|
||||
sortDescriptors: [sortDescriptor]
|
||||
) { _, samples, error in
|
||||
if let error = error {
|
||||
continuation.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
|
||||
let stateOfMindSamples = samples?.compactMap { $0 as? HKStateOfMind } ?? []
|
||||
continuation.resume(returning: stateOfMindSamples)
|
||||
}
|
||||
|
||||
healthStore.execute(query)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Conversion Helpers
|
||||
|
||||
/// Convert Feels Mood to HealthKit valence (-1 to 1)
|
||||
private func moodToValence(_ mood: Mood) -> Double {
|
||||
switch mood {
|
||||
case .horrible: return -1.0
|
||||
case .bad: return -0.5
|
||||
case .average: return 0.0
|
||||
case .good: return 0.5
|
||||
case .great: return 1.0
|
||||
case .missing, .placeholder: return 0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert HealthKit valence to Feels Mood
|
||||
func valenceToMood(_ valence: Double) -> Mood {
|
||||
switch valence {
|
||||
case ..<(-0.75): return .horrible
|
||||
case -0.75..<(-0.25): return .bad
|
||||
case -0.25..<0.25: return .average
|
||||
case 0.25..<0.75: return .good
|
||||
default: return .great
|
||||
}
|
||||
}
|
||||
|
||||
/// Get HealthKit labels for a mood
|
||||
private func labelsForMood(_ mood: Mood) -> [HKStateOfMind.Label] {
|
||||
switch mood {
|
||||
case .horrible:
|
||||
return [.sad, .stressed, .anxious]
|
||||
case .bad:
|
||||
return [.sad, .stressed]
|
||||
case .average:
|
||||
return [.calm, .indifferent]
|
||||
case .good:
|
||||
return [.happy, .calm, .content]
|
||||
case .great:
|
||||
return [.happy, .excited, .joyful]
|
||||
case .missing, .placeholder:
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum HealthKitError: LocalizedError {
|
||||
case notAvailable
|
||||
case notAuthorized
|
||||
case typeNotAvailable
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notAvailable:
|
||||
return "HealthKit is not available on this device"
|
||||
case .notAuthorized:
|
||||
return "HealthKit access not authorized"
|
||||
case .typeNotAvailable:
|
||||
return "State of Mind type not available"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ enum EntryType: Int, Codable {
|
||||
case filledInMissing = 4
|
||||
case notification = 5
|
||||
case header = 6
|
||||
case siri = 7
|
||||
case controlCenter = 8
|
||||
case liveActivity = 9
|
||||
}
|
||||
|
||||
// MARK: - SwiftData Model
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import WidgetKit
|
||||
|
||||
final class OnboardingDataDataManager: ObservableObject {
|
||||
static let shared = OnboardingDataDataManager()
|
||||
@@ -16,5 +17,16 @@ final class OnboardingDataDataManager: ObservableObject {
|
||||
let onboardingData = UserDefaultsStore.saveOnboarding(onboardingData: onboardingData)
|
||||
savedOnboardingData = onboardingData
|
||||
LocalNotification.scheduleReminder(atTime: onboardingData.date)
|
||||
|
||||
// Update Live Activity schedule when rating time changes
|
||||
Task { @MainActor in
|
||||
LiveActivityScheduler.shared.onRatingTimeUpdated()
|
||||
}
|
||||
|
||||
// Force sync UserDefaults to app group before reloading widgets
|
||||
GroupUserDefaults.groupDefaults.synchronize()
|
||||
|
||||
// Reload widgets so they show the correct view for new time
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,11 +17,11 @@ protocol PersonalityPackable {
|
||||
|
||||
enum PersonalityPack: Int, CaseIterable {
|
||||
case Default = 0
|
||||
case Rude = 1
|
||||
case MotivationalCoach = 2
|
||||
case ZenMaster = 3
|
||||
case BestFriend = 4
|
||||
case DataAnalyst = 5
|
||||
// case Rude = 1
|
||||
case MotivationalCoach = 1
|
||||
case ZenMaster = 2
|
||||
case BestFriend = 3
|
||||
case DataAnalyst = 4
|
||||
|
||||
func randomPushNotificationStrings() -> (title: String, body: String) {
|
||||
let onboarding = UserDefaultsStore.getOnboarding()
|
||||
@@ -33,12 +33,12 @@ enum PersonalityPack: Int, CaseIterable {
|
||||
case (.Default, .Previous):
|
||||
return (DefaultTitles.notificationTitles.randomElement()!,
|
||||
DefaultTitles.notificationBodyYesterday.randomElement()!)
|
||||
case (.Rude, .Today):
|
||||
return (RudeTitles.notificationTitles.randomElement()!,
|
||||
RudeTitles.notificationBodyToday.randomElement()!)
|
||||
case (.Rude, .Previous):
|
||||
return (RudeTitles.notificationTitles.randomElement()!,
|
||||
RudeTitles.notificationBodyYesterday.randomElement()!)
|
||||
// case (.Rude, .Today):
|
||||
// return (RudeTitles.notificationTitles.randomElement()!,
|
||||
// RudeTitles.notificationBodyToday.randomElement()!)
|
||||
// case (.Rude, .Previous):
|
||||
// return (RudeTitles.notificationTitles.randomElement()!,
|
||||
// RudeTitles.notificationBodyYesterday.randomElement()!)
|
||||
case (.MotivationalCoach, .Today):
|
||||
return (MotivationalCoachTitles.notificationTitles.randomElement()!,
|
||||
MotivationalCoachTitles.notificationBodyToday.randomElement()!)
|
||||
@@ -70,8 +70,8 @@ enum PersonalityPack: Int, CaseIterable {
|
||||
switch self {
|
||||
case .Default:
|
||||
return DefaultTitles.title
|
||||
case .Rude:
|
||||
return RudeTitles.title
|
||||
// case .Rude:
|
||||
// return RudeTitles.title
|
||||
case .MotivationalCoach:
|
||||
return MotivationalCoachTitles.title
|
||||
case .ZenMaster:
|
||||
@@ -86,7 +86,7 @@ enum PersonalityPack: Int, CaseIterable {
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .Default: return "face.smiling"
|
||||
case .Rude: return "flame"
|
||||
// case .Rude: return "flame"
|
||||
case .MotivationalCoach: return "figure.run"
|
||||
case .ZenMaster: return "leaf"
|
||||
case .BestFriend: return "heart"
|
||||
@@ -97,7 +97,7 @@ enum PersonalityPack: Int, CaseIterable {
|
||||
var description: String {
|
||||
switch self {
|
||||
case .Default: return "Friendly and supportive"
|
||||
case .Rude: return "Snarky with attitude"
|
||||
// case .Rude: return "Snarky with attitude"
|
||||
case .MotivationalCoach: return "High energy pump-up vibes"
|
||||
case .ZenMaster: return "Calm and mindful"
|
||||
case .BestFriend: return "Casual and supportive"
|
||||
|
||||
@@ -96,6 +96,7 @@ class UserDefaultsStore {
|
||||
case dayViewStyle
|
||||
case privacyLockEnabled
|
||||
case healthKitEnabled
|
||||
case healthKitSyncEnabled
|
||||
|
||||
case contentViewCurrentSelectedHeaderViewBackDays
|
||||
case contentViewHeaderTag
|
||||
|
||||
89
Shared/MoodLogger.swift
Normal file
89
Shared/MoodLogger.swift
Normal file
@@ -0,0 +1,89 @@
|
||||
//
|
||||
// MoodLogger.swift
|
||||
// Feels
|
||||
//
|
||||
// Centralized mood logging service that handles all side effects
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import WidgetKit
|
||||
|
||||
/// Centralized service for logging moods with all associated side effects.
|
||||
/// All mood entry points should use this service to ensure consistent behavior.
|
||||
@MainActor
|
||||
final class MoodLogger {
|
||||
static let shared = MoodLogger()
|
||||
|
||||
private init() {}
|
||||
|
||||
/// Log a mood entry with all associated side effects.
|
||||
/// This is the single source of truth for mood logging in the app.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - mood: The mood to log
|
||||
/// - date: The date for the mood entry
|
||||
/// - entryType: The source of the mood entry (header, widget, siri, etc.)
|
||||
/// - syncHealthKit: Whether to sync to HealthKit (default true, but widget can't access HealthKit)
|
||||
/// - updateTips: Whether to update TipKit parameters (default true, but widget can't access TipKit)
|
||||
func logMood(
|
||||
_ mood: Mood,
|
||||
for date: Date,
|
||||
entryType: EntryType,
|
||||
syncHealthKit: Bool = true,
|
||||
updateTips: Bool = true
|
||||
) {
|
||||
// 1. Add mood entry to data store
|
||||
DataController.shared.add(mood: mood, forDate: date, entryType: entryType)
|
||||
|
||||
// Skip side effects for placeholder/missing moods
|
||||
guard mood != .missing && mood != .placeholder else { return }
|
||||
|
||||
// 2. Sync to HealthKit if enabled and requested
|
||||
if syncHealthKit {
|
||||
let healthKitEnabled = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.healthKitEnabled.rawValue)
|
||||
if healthKitEnabled {
|
||||
Task {
|
||||
try? await HealthKitManager.shared.saveMood(mood, for: date)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Calculate current streak for Live Activity and TipKit
|
||||
let streak = calculateCurrentStreak()
|
||||
|
||||
// 4. Update Live Activity
|
||||
LiveActivityManager.shared.updateActivity(streak: streak, mood: mood)
|
||||
LiveActivityScheduler.shared.scheduleForNextDay()
|
||||
|
||||
// 5. Update TipKit parameters if requested
|
||||
if updateTips {
|
||||
TipsManager.shared.onMoodLogged()
|
||||
TipsManager.shared.updateStreak(streak)
|
||||
}
|
||||
|
||||
// 6. Reload widgets
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
|
||||
/// Calculate the current mood streak
|
||||
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
|
||||
}
|
||||
}
|
||||
350
Shared/MoodStreakActivity.swift
Normal file
350
Shared/MoodStreakActivity.swift
Normal file
@@ -0,0 +1,350 @@
|
||||
//
|
||||
// MoodStreakActivity.swift
|
||||
// Feels
|
||||
//
|
||||
// Live Activity for mood streak tracking on Lock Screen and Dynamic Island
|
||||
//
|
||||
|
||||
import ActivityKit
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
// MARK: - Activity Attributes
|
||||
// Note: This must be defined in both the main app and widget extension
|
||||
|
||||
struct MoodStreakAttributes: ActivityAttributes {
|
||||
public struct ContentState: Codable, Hashable {
|
||||
var currentStreak: Int
|
||||
var lastMoodLogged: String
|
||||
var lastMoodColor: String // Hex color string
|
||||
var hasLoggedToday: Bool
|
||||
var votingWindowEnd: Date
|
||||
}
|
||||
|
||||
var startDate: Date
|
||||
}
|
||||
|
||||
// MARK: - Live Activity Manager
|
||||
|
||||
@MainActor
|
||||
class LiveActivityManager: ObservableObject {
|
||||
static let shared = LiveActivityManager()
|
||||
|
||||
@Published var currentActivity: Activity<MoodStreakAttributes>?
|
||||
|
||||
private init() {}
|
||||
|
||||
// Start a mood streak Live Activity
|
||||
func startStreakActivity(streak: Int, lastMood: Mood?, hasLoggedToday: Bool) {
|
||||
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
|
||||
print("Live Activities not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
// End any existing activity first
|
||||
await endAllActivities()
|
||||
|
||||
// Now start the new activity
|
||||
await startActivityInternal(streak: streak, lastMood: lastMood, hasLoggedToday: hasLoggedToday)
|
||||
}
|
||||
}
|
||||
|
||||
private func startActivityInternal(streak: Int, lastMood: Mood?, hasLoggedToday: Bool) async {
|
||||
let moodTint = UserDefaultsStore.moodTintable()
|
||||
let moodName = lastMood?.widgetDisplayName ?? "None"
|
||||
let moodColor = lastMood != nil ? (moodTint.color(forMood: lastMood!).toHex() ?? "#888888") : "#888888"
|
||||
|
||||
// Calculate voting window end (end of current day)
|
||||
let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding())
|
||||
let votingWindowEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: votingDate) ?? Date()
|
||||
|
||||
let attributes = MoodStreakAttributes(startDate: Date())
|
||||
let initialState = MoodStreakAttributes.ContentState(
|
||||
currentStreak: streak,
|
||||
lastMoodLogged: moodName,
|
||||
lastMoodColor: moodColor,
|
||||
hasLoggedToday: hasLoggedToday,
|
||||
votingWindowEnd: votingWindowEnd
|
||||
)
|
||||
|
||||
do {
|
||||
let activity = try Activity.request(
|
||||
attributes: attributes,
|
||||
content: .init(state: initialState, staleDate: nil),
|
||||
pushType: nil
|
||||
)
|
||||
currentActivity = activity
|
||||
} catch {
|
||||
print("Error starting Live Activity: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// Update the Live Activity after mood is logged
|
||||
func updateActivity(streak: Int, mood: Mood) {
|
||||
guard let activity = currentActivity else { return }
|
||||
|
||||
let moodTint = UserDefaultsStore.moodTintable()
|
||||
|
||||
let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding())
|
||||
let votingWindowEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: votingDate) ?? Date()
|
||||
|
||||
let updatedState = MoodStreakAttributes.ContentState(
|
||||
currentStreak: streak,
|
||||
lastMoodLogged: mood.widgetDisplayName,
|
||||
lastMoodColor: moodTint.color(forMood: mood).toHex() ?? "#888888",
|
||||
hasLoggedToday: true,
|
||||
votingWindowEnd: votingWindowEnd
|
||||
)
|
||||
|
||||
Task {
|
||||
await activity.update(.init(state: updatedState, staleDate: nil))
|
||||
}
|
||||
}
|
||||
|
||||
// End all Live Activities
|
||||
func endAllActivities() async {
|
||||
for activity in Activity<MoodStreakAttributes>.activities {
|
||||
await activity.end(nil, dismissalPolicy: .immediate)
|
||||
}
|
||||
currentActivity = nil
|
||||
}
|
||||
|
||||
// End activity at midnight
|
||||
func scheduleActivityEnd() {
|
||||
guard let activity = currentActivity else { return }
|
||||
|
||||
let midnight = Calendar.current.startOfDay(for: Calendar.current.date(byAdding: .day, value: 1, to: Date())!)
|
||||
|
||||
Task {
|
||||
await activity.end(nil, dismissalPolicy: .after(midnight))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Live Activity Scheduler
|
||||
|
||||
/// Handles automatic scheduling of Live Activities based on user's rating time
|
||||
@MainActor
|
||||
class LiveActivityScheduler: ObservableObject {
|
||||
static let shared = LiveActivityScheduler()
|
||||
|
||||
private var startTimer: Timer?
|
||||
private var endTimer: Timer?
|
||||
|
||||
private init() {}
|
||||
|
||||
/// Get the user's configured rating time
|
||||
func getUserRatingTime() -> Date? {
|
||||
guard let data = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.savedOnboardingData.rawValue) as? Data,
|
||||
let onboardingData = try? JSONDecoder().decode(OnboardingData.self, from: data) else {
|
||||
return nil
|
||||
}
|
||||
return onboardingData.date
|
||||
}
|
||||
|
||||
/// Calculate the start time (at rating time)
|
||||
func getStartTime(for date: Date = Date()) -> Date? {
|
||||
guard let ratingTime = getUserRatingTime() else { return nil }
|
||||
|
||||
let calendar = Calendar.current
|
||||
let ratingComponents = calendar.dateComponents([.hour, .minute], from: ratingTime)
|
||||
|
||||
var startComponents = calendar.dateComponents([.year, .month, .day], from: date)
|
||||
startComponents.hour = ratingComponents.hour
|
||||
startComponents.minute = ratingComponents.minute
|
||||
|
||||
// Start at rating time
|
||||
return calendar.date(from: startComponents)
|
||||
}
|
||||
|
||||
/// Calculate the end time (5 hours after rating time)
|
||||
func getEndTime(for date: Date = Date()) -> Date? {
|
||||
guard let ratingTime = getUserRatingTime() else { return nil }
|
||||
|
||||
let calendar = Calendar.current
|
||||
let ratingComponents = calendar.dateComponents([.hour, .minute], from: ratingTime)
|
||||
|
||||
var endComponents = calendar.dateComponents([.year, .month, .day], from: date)
|
||||
endComponents.hour = ratingComponents.hour
|
||||
endComponents.minute = ratingComponents.minute
|
||||
|
||||
guard let ratingDateTime = calendar.date(from: endComponents) else { return nil }
|
||||
|
||||
// End 5 hours after rating time
|
||||
return calendar.date(byAdding: .hour, value: 5, to: ratingDateTime)
|
||||
}
|
||||
|
||||
/// Check if user has rated today
|
||||
func hasRatedToday() -> Bool {
|
||||
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)!
|
||||
|
||||
let entry = DataController.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first
|
||||
return entry != nil && entry?.mood != .missing && entry?.mood != .placeholder
|
||||
}
|
||||
|
||||
/// Calculate current streak
|
||||
func calculateStreak() -> Int {
|
||||
var streak = 0
|
||||
var checkDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding())
|
||||
|
||||
// Check if current voting date has an entry
|
||||
let currentDayStart = Calendar.current.startOfDay(for: checkDate)
|
||||
let currentDayEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: currentDayStart)!
|
||||
let currentEntry = DataController.shared.getData(startDate: currentDayStart, endDate: currentDayEnd, includedDays: []).first
|
||||
|
||||
// If no entry for current voting date, start counting from previous day
|
||||
// This ensures the streak shows correctly even if user hasn't rated today yet
|
||||
if currentEntry == nil || currentEntry?.mood == .missing || currentEntry?.mood == .placeholder {
|
||||
checkDate = Calendar.current.date(byAdding: .day, value: -1, to: checkDate)!
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/// Get today's mood if logged
|
||||
func getTodaysMood() -> Mood? {
|
||||
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)!
|
||||
|
||||
let entry = DataController.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first
|
||||
if let entry = entry, entry.mood != .missing && entry.mood != .placeholder {
|
||||
return entry.mood
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Schedule Live Activity based on current time and rating time
|
||||
func scheduleBasedOnCurrentTime() {
|
||||
invalidateTimers()
|
||||
|
||||
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
|
||||
print("[LiveActivity] Live Activities not enabled by user")
|
||||
return
|
||||
}
|
||||
|
||||
let now = Date()
|
||||
guard let startTime = getStartTime(),
|
||||
let endTime = getEndTime() else {
|
||||
print("[LiveActivity] No rating time configured - skipping")
|
||||
return
|
||||
}
|
||||
|
||||
let hasRated = hasRatedToday()
|
||||
print("[LiveActivity] Schedule check - now: \(now), start: \(startTime), end: \(endTime), hasRated: \(hasRated)")
|
||||
|
||||
// If user has already rated today, don't show activity - schedule for next day
|
||||
if hasRated {
|
||||
print("[LiveActivity] User already rated today - scheduling for next day")
|
||||
scheduleForNextDay()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we're within the activity window (rating time to 5 hrs after)
|
||||
if now >= startTime && now <= endTime {
|
||||
// Start activity immediately
|
||||
print("[LiveActivity] Within window - starting activity now")
|
||||
let streak = calculateStreak()
|
||||
LiveActivityManager.shared.startStreakActivity(streak: streak, lastMood: getTodaysMood(), hasLoggedToday: false)
|
||||
|
||||
// Schedule end
|
||||
scheduleEnd(at: endTime)
|
||||
} else if now < startTime {
|
||||
// Schedule start for later today
|
||||
print("[LiveActivity] Before window - scheduling start for \(startTime)")
|
||||
scheduleStart(at: startTime)
|
||||
scheduleEnd(at: endTime)
|
||||
} else {
|
||||
// Past the window for today, schedule for tomorrow
|
||||
print("[LiveActivity] Past window - scheduling for tomorrow")
|
||||
scheduleForNextDay()
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedule Live Activity to start at a specific time
|
||||
private func scheduleStart(at date: Date) {
|
||||
let interval = date.timeIntervalSince(Date())
|
||||
guard interval > 0 else { return }
|
||||
|
||||
startTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Check if user rated in the meantime
|
||||
if !self.hasRatedToday() {
|
||||
let streak = self.calculateStreak()
|
||||
LiveActivityManager.shared.startStreakActivity(streak: streak, lastMood: self.getTodaysMood(), hasLoggedToday: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedule Live Activity to end at a specific time
|
||||
private func scheduleEnd(at date: Date) {
|
||||
let interval = date.timeIntervalSince(Date())
|
||||
guard interval > 0 else { return }
|
||||
|
||||
endTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in
|
||||
Task { @MainActor in
|
||||
await LiveActivityManager.shared.endAllActivities()
|
||||
LiveActivityScheduler.shared.scheduleForNextDay()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedule for the next day (called after user rates or after window closes)
|
||||
func scheduleForNextDay() {
|
||||
invalidateTimers()
|
||||
|
||||
// End current activity if exists
|
||||
Task {
|
||||
await LiveActivityManager.shared.endAllActivities()
|
||||
}
|
||||
|
||||
guard let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: Date()),
|
||||
let startTime = getStartTime(for: tomorrow) else {
|
||||
return
|
||||
}
|
||||
|
||||
let interval = startTime.timeIntervalSince(Date())
|
||||
guard interval > 0 else { return }
|
||||
|
||||
startTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.scheduleBasedOnCurrentTime()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Called when user updates their rating time in settings
|
||||
func onRatingTimeUpdated() {
|
||||
// Reschedule based on new time
|
||||
scheduleBasedOnCurrentTime()
|
||||
}
|
||||
|
||||
/// Invalidate all timers
|
||||
private func invalidateTimers() {
|
||||
startTimer?.invalidate()
|
||||
startTimer = nil
|
||||
endTimer?.invalidate()
|
||||
endTimer = nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -773,6 +773,9 @@ extension EntryType: CustomStringConvertible {
|
||||
case .filledInMissing: return "Auto-filled"
|
||||
case .notification: return "Notification"
|
||||
case .header: return "Header"
|
||||
case .siri: return "Siri"
|
||||
case .controlCenter: return "Control Center"
|
||||
case .liveActivity: return "Live Activity"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,8 +95,8 @@ class FoundationModelsInsightService: ObservableObject {
|
||||
switch personalityPack {
|
||||
case .Default:
|
||||
return defaultSystemInstructions
|
||||
case .Rude:
|
||||
return rudeSystemInstructions
|
||||
// case .Rude:
|
||||
// return rudeSystemInstructions
|
||||
case .MotivationalCoach:
|
||||
return coachSystemInstructions
|
||||
case .ZenMaster:
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import SwiftUI
|
||||
import StoreKit
|
||||
import TipKit
|
||||
|
||||
// MARK: - Customize Content View (for use in SettingsTabView)
|
||||
struct CustomizeContentView: View {
|
||||
@@ -17,6 +18,10 @@ struct CustomizeContentView: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
// Customize tip
|
||||
TipView(CustomizeLayoutTip())
|
||||
.tipBackground(Color(.secondarySystemBackground))
|
||||
|
||||
// APPEARANCE
|
||||
SettingsSection(title: "Appearance") {
|
||||
VStack(spacing: 16) {
|
||||
@@ -640,16 +645,16 @@ struct PersonalityPackPickerCompact: View {
|
||||
VStack(spacing: 8) {
|
||||
ForEach(PersonalityPack.allCases, id: \.self) { aPack in
|
||||
Button(action: {
|
||||
if aPack.rawValue == PersonalityPack.Rude.rawValue && !showNSFW {
|
||||
showOver18Alert = true
|
||||
EventLogger.log(event: "show_over_18_alert")
|
||||
} else {
|
||||
// if aPack.rawValue == PersonalityPack.Rude.rawValue && !showNSFW {
|
||||
// showOver18Alert = true
|
||||
// EventLogger.log(event: "show_over_18_alert")
|
||||
// } else {
|
||||
let impactMed = UIImpactFeedbackGenerator(style: .medium)
|
||||
impactMed.impactOccurred()
|
||||
personalityPack = aPack
|
||||
EventLogger.log(event: "change_personality_pack", withData: ["pack_title": aPack.title()])
|
||||
LocalNotification.rescheduleNotifiations()
|
||||
}
|
||||
// }
|
||||
}) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
@@ -681,7 +686,7 @@ struct PersonalityPackPickerCompact: View {
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.blur(radius: aPack.rawValue == PersonalityPack.Rude.rawValue && !showNSFW ? 4 : 0)
|
||||
// .blur(radius: aPack.rawValue == PersonalityPack.Rude.rawValue && !showNSFW ? 4 : 0)
|
||||
}
|
||||
}
|
||||
.alert(isPresented: $showOver18Alert) {
|
||||
|
||||
@@ -40,18 +40,18 @@ struct PersonalityPackPickerView: View {
|
||||
.padding(5)
|
||||
)
|
||||
.onTapGesture {
|
||||
if aPack.rawValue == PersonalityPack.Rude.rawValue && !showNSFW {
|
||||
showOver18Alert = true
|
||||
EventLogger.log(event: "show_over_18_alert")
|
||||
} else {
|
||||
// if aPack.rawValue == PersonalityPack.Rude.rawValue && !showNSFW {
|
||||
// showOver18Alert = true
|
||||
// EventLogger.log(event: "show_over_18_alert")
|
||||
// } else {
|
||||
let impactMed = UIImpactFeedbackGenerator(style: .heavy)
|
||||
impactMed.impactOccurred()
|
||||
personalityPack = aPack
|
||||
EventLogger.log(event: "change_personality_pack", withData: ["pack_title": aPack.title()])
|
||||
LocalNotification.rescheduleNotifiations()
|
||||
}
|
||||
// }
|
||||
}
|
||||
.blur(radius: aPack.rawValue == PersonalityPack.Rude.rawValue && !showNSFW ? 5 : 0)
|
||||
// .blur(radius: aPack.rawValue == PersonalityPack.Rude.rawValue && !showNSFW ? 5 : 0)
|
||||
.alert(isPresented: $showOver18Alert) {
|
||||
let primaryButton = Alert.Button.default(Text(String(localized: "customize_view_over18alert_ok"))) {
|
||||
showNSFW = true
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import Charts
|
||||
import TipKit
|
||||
|
||||
struct DayViewConstants {
|
||||
static let maxHeaderHeight = 200.0
|
||||
@@ -30,7 +31,6 @@ struct DayView: View {
|
||||
// MARK: edit row properties
|
||||
@State private var showingSheet = false
|
||||
@State private var selectedEntry: MoodEntryModel?
|
||||
@State private var showEntryDetail = false
|
||||
//
|
||||
|
||||
// MARK: ?? properties
|
||||
@@ -53,25 +53,16 @@ struct DayView: View {
|
||||
.sheet(isPresented: $showingSheet) {
|
||||
SettingsView()
|
||||
}
|
||||
.onChange(of: selectedEntry) { _, newEntry in
|
||||
if newEntry != nil {
|
||||
showEntryDetail = true
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showEntryDetail, onDismiss: {
|
||||
selectedEntry = nil
|
||||
}) {
|
||||
if let entry = selectedEntry {
|
||||
EntryDetailView(
|
||||
entry: entry,
|
||||
onMoodUpdate: { newMood in
|
||||
viewModel.update(entry: entry, toMood: newMood)
|
||||
},
|
||||
onDelete: {
|
||||
viewModel.update(entry: entry, toMood: .missing)
|
||||
}
|
||||
)
|
||||
}
|
||||
.sheet(item: $selectedEntry) { entry in
|
||||
EntryDetailView(
|
||||
entry: entry,
|
||||
onMoodUpdate: { newMood in
|
||||
viewModel.update(entry: entry, toMood: newMood)
|
||||
},
|
||||
onDelete: {
|
||||
viewModel.update(entry: entry, toMood: .missing)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding([.top])
|
||||
@@ -107,6 +98,7 @@ struct DayView: View {
|
||||
AddMoodHeaderView(addItemHeaderClosure: { (mood, date) in
|
||||
viewModel.add(mood: mood, forDate: date, entryType: .header)
|
||||
})
|
||||
.widgetVotingTip()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import WidgetKit
|
||||
|
||||
@MainActor
|
||||
class DayViewViewModel: ObservableObject {
|
||||
@@ -60,12 +61,28 @@ class DayViewViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
public func add(mood: Mood, forDate date: Date, entryType: EntryType) {
|
||||
DataController.shared.add(mood: mood, forDate: date, entryType: entryType)
|
||||
MoodLogger.shared.logMood(mood, for: date, entryType: entryType)
|
||||
}
|
||||
|
||||
public func update(entry: MoodEntryModel, toMood mood: Mood) {
|
||||
if !DataController.shared.update(entryDate: entry.forDate, withMood: mood) {
|
||||
print("Failed to update mood entry")
|
||||
return
|
||||
}
|
||||
|
||||
// Sync to HealthKit for past day updates
|
||||
guard mood != .missing && mood != .placeholder else { return }
|
||||
|
||||
let healthKitEnabled = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.healthKitEnabled.rawValue)
|
||||
if healthKitEnabled {
|
||||
Task {
|
||||
try? await HealthKitManager.shared.saveMood(mood, for: entry.forDate)
|
||||
}
|
||||
}
|
||||
|
||||
// Reload widgets asynchronously to avoid UI delay
|
||||
Task { @MainActor in
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import TipKit
|
||||
|
||||
struct InsightsView: View {
|
||||
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
|
||||
@@ -48,6 +49,7 @@ struct InsightsView: View {
|
||||
)
|
||||
)
|
||||
.clipShape(Capsule())
|
||||
.aiInsightsTip()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
@@ -90,7 +90,7 @@ struct MonthDetailView: View {
|
||||
ForEach(Mood.allValues) { mood in
|
||||
Button(mood.strValue, action: {
|
||||
if let selectedEntry = selectedEntry {
|
||||
DataController.shared.update(entryDate: selectedEntry.forDate, withMood: mood)
|
||||
parentViewModel.update(entry: selectedEntry, toMood: mood)
|
||||
}
|
||||
updateEntries()
|
||||
showUpdateEntryAlert = false
|
||||
@@ -102,7 +102,7 @@ struct MonthDetailView: View {
|
||||
deleteEnabled,
|
||||
selectedEntry.mood != .missing {
|
||||
Button(String(localized: "content_view_delete_entry"), action: {
|
||||
DataController.shared.update(entryDate: selectedEntry.forDate, withMood: .missing)
|
||||
parentViewModel.update(entry: selectedEntry, toMood: .missing)
|
||||
updateEntries()
|
||||
showUpdateEntryAlert = false
|
||||
})
|
||||
|
||||
@@ -151,9 +151,14 @@ struct EntryDetailView: View {
|
||||
@State private var showDeleteConfirmation = false
|
||||
@State private var showFullScreenPhoto = false
|
||||
@State private var selectedPhotoItem: PhotosPickerItem?
|
||||
@State private var selectedMood: Mood?
|
||||
|
||||
private var currentMood: Mood {
|
||||
selectedMood ?? entry.mood
|
||||
}
|
||||
|
||||
private var moodColor: Color {
|
||||
moodTint.color(forMood: entry.mood)
|
||||
moodTint.color(forMood: currentMood)
|
||||
}
|
||||
|
||||
private func savePhoto(_ image: UIImage) {
|
||||
@@ -267,7 +272,7 @@ struct EntryDetailView: View {
|
||||
)
|
||||
.frame(width: 60, height: 60)
|
||||
|
||||
imagePack.icon(forMood: entry.mood)
|
||||
imagePack.icon(forMood: currentMood)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 34, height: 34)
|
||||
@@ -276,7 +281,7 @@ struct EntryDetailView: View {
|
||||
.shadow(color: moodColor.opacity(0.4), radius: 8, x: 0, y: 4)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(entry.moodString)
|
||||
Text(currentMood.strValue)
|
||||
.font(.title3)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(moodColor)
|
||||
@@ -298,23 +303,28 @@ struct EntryDetailView: View {
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 5), spacing: 12) {
|
||||
ForEach(Mood.allValues) { mood in
|
||||
Button {
|
||||
// Update local state immediately for instant feedback
|
||||
withAnimation(.easeInOut(duration: 0.15)) {
|
||||
selectedMood = mood
|
||||
}
|
||||
// Then persist the change
|
||||
onMoodUpdate(mood)
|
||||
} label: {
|
||||
VStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(entry.mood == mood ? moodTint.color(forMood: mood) : Color(.systemGray5))
|
||||
.fill(currentMood == mood ? moodTint.color(forMood: mood) : Color(.systemGray5))
|
||||
.frame(width: 50, height: 50)
|
||||
.overlay(
|
||||
imagePack.icon(forMood: mood)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 28, height: 28)
|
||||
.foregroundColor(entry.mood == mood ? .white : .gray)
|
||||
.foregroundColor(currentMood == mood ? .white : .gray)
|
||||
)
|
||||
|
||||
Text(mood.strValue)
|
||||
.font(.caption2)
|
||||
.foregroundColor(entry.mood == mood ? moodTint.color(forMood: mood) : .secondary)
|
||||
.foregroundColor(currentMood == mood ? moodTint.color(forMood: mood) : .secondary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
@@ -9,6 +9,7 @@ import SwiftUI
|
||||
import CloudKitSyncMonitor
|
||||
import UniformTypeIdentifiers
|
||||
import StoreKit
|
||||
import TipKit
|
||||
|
||||
// MARK: - Settings Content View (for use in SettingsTabView)
|
||||
struct SettingsContentView: View {
|
||||
@@ -16,6 +17,7 @@ struct SettingsContentView: View {
|
||||
|
||||
@State private var showOnboarding = false
|
||||
@State private var showExportView = false
|
||||
@State private var showReminderTimePicker = false
|
||||
@StateObject private var healthService = HealthService.shared
|
||||
|
||||
@AppStorage(UserDefaultsStore.Keys.deleteEnable.rawValue, store: GroupUserDefaults.groupDefaults) private var deleteEnabled = true
|
||||
@@ -33,6 +35,7 @@ struct SettingsContentView: View {
|
||||
|
||||
// Settings section
|
||||
settingsSectionHeader
|
||||
reminderTimeButton
|
||||
canDelete
|
||||
showOnboardingButton
|
||||
|
||||
@@ -64,11 +67,59 @@ struct SettingsContentView: View {
|
||||
includedDays: []
|
||||
))
|
||||
}
|
||||
.sheet(isPresented: $showReminderTimePicker) {
|
||||
ReminderTimePickerView()
|
||||
}
|
||||
.onAppear(perform: {
|
||||
EventLogger.log(event: "show_settings_view")
|
||||
TipsManager.shared.onSettingsViewed()
|
||||
})
|
||||
}
|
||||
|
||||
// MARK: - Reminder Time Button
|
||||
|
||||
private var reminderTimeButton: some View {
|
||||
ZStack {
|
||||
theme.currentTheme.secondaryBGColor
|
||||
Button(action: {
|
||||
EventLogger.log(event: "tap_reminder_time")
|
||||
showReminderTimePicker = true
|
||||
}, label: {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "clock.fill")
|
||||
.font(.title2)
|
||||
.foregroundColor(.orange)
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Reminder Time")
|
||||
.foregroundColor(textColor)
|
||||
|
||||
Text(formattedReminderTime)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding()
|
||||
})
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||
}
|
||||
|
||||
private var formattedReminderTime: String {
|
||||
let onboardingData = UserDefaultsStore.getOnboarding()
|
||||
let formatter = DateFormatter()
|
||||
formatter.timeStyle = .short
|
||||
return formatter.string(from: onboardingData.date)
|
||||
}
|
||||
|
||||
// MARK: - Section Headers
|
||||
|
||||
private var featuresSectionHeader: some View {
|
||||
@@ -80,6 +131,7 @@ struct SettingsContentView: View {
|
||||
}
|
||||
.padding(.top, 20)
|
||||
.padding(.horizontal, 4)
|
||||
.siriShortcutTip()
|
||||
}
|
||||
|
||||
private var settingsSectionHeader: some View {
|
||||
@@ -91,6 +143,7 @@ struct SettingsContentView: View {
|
||||
}
|
||||
.padding(.top, 20)
|
||||
.padding(.horizontal, 4)
|
||||
.controlCenterTip()
|
||||
}
|
||||
|
||||
private var legalSectionHeader: some View {
|
||||
@@ -180,8 +233,15 @@ struct SettingsContentView: View {
|
||||
set: { newValue in
|
||||
if newValue {
|
||||
Task {
|
||||
let success = await healthService.requestAuthorization()
|
||||
if !success {
|
||||
// Request read permissions for health insights
|
||||
let readSuccess = await healthService.requestAuthorization()
|
||||
// Request write permissions for State of Mind sync
|
||||
do {
|
||||
try await HealthKitManager.shared.requestAuthorization()
|
||||
} catch {
|
||||
print("HealthKit write authorization failed: \(error)")
|
||||
}
|
||||
if !readSuccess {
|
||||
EventLogger.log(event: "healthkit_enable_failed")
|
||||
}
|
||||
}
|
||||
@@ -202,6 +262,7 @@ struct SettingsContentView: View {
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||
.healthKitSyncTip()
|
||||
}
|
||||
|
||||
// MARK: - Export Data Button
|
||||
@@ -311,6 +372,65 @@ struct SettingsContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reminder Time Picker View
|
||||
|
||||
struct ReminderTimePickerView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var selectedTime: Date
|
||||
|
||||
init() {
|
||||
let onboardingData = UserDefaultsStore.getOnboarding()
|
||||
_selectedTime = State(initialValue: onboardingData.date)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 24) {
|
||||
Text("When would you like to be reminded to log your mood?")
|
||||
.font(.headline)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 20)
|
||||
|
||||
DatePicker(
|
||||
"Reminder Time",
|
||||
selection: $selectedTime,
|
||||
displayedComponents: .hourAndMinute
|
||||
)
|
||||
.datePickerStyle(.wheel)
|
||||
.labelsHidden()
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle("Reminder Time")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") {
|
||||
saveReminderTime()
|
||||
dismiss()
|
||||
}
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func saveReminderTime() {
|
||||
let onboardingData = UserDefaultsStore.getOnboarding()
|
||||
onboardingData.date = selectedTime
|
||||
// This handles notification scheduling and Live Activity rescheduling
|
||||
OnboardingDataDataManager.shared.updateOnboardingData(onboardingData: onboardingData)
|
||||
|
||||
EventLogger.log(event: "reminder_time_updated")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Legacy SettingsView (sheet presentation with close button)
|
||||
struct SettingsView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@@ -583,8 +703,15 @@ struct SettingsView: View {
|
||||
set: { newValue in
|
||||
if newValue {
|
||||
Task {
|
||||
let success = await healthService.requestAuthorization()
|
||||
if !success {
|
||||
// Request read permissions for health insights
|
||||
let readSuccess = await healthService.requestAuthorization()
|
||||
// Request write permissions for State of Mind sync
|
||||
do {
|
||||
try await HealthKitManager.shared.requestAuthorization()
|
||||
} catch {
|
||||
print("HealthKit write authorization failed: \(error)")
|
||||
}
|
||||
if !readSuccess {
|
||||
EventLogger.log(event: "healthkit_enable_failed")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user