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>
|
<key>com.apple.developer.healthkit</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.developer.healthkit.access</key>
|
<key>com.apple.developer.healthkit.access</key>
|
||||||
<array/>
|
<array>
|
||||||
|
<string>health-records</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -26,7 +26,9 @@
|
|||||||
<key>NSHealthShareUsageDescription</key>
|
<key>NSHealthShareUsageDescription</key>
|
||||||
<string>Feels uses your health data to find correlations between your activity, sleep, and mood patterns to provide personalized insights.</string>
|
<string>Feels uses your health data to find correlations between your activity, sleep, and mood patterns to provide personalized insights.</string>
|
||||||
<key>NSHealthUpdateUsageDescription</key>
|
<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>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>Feels uses the camera to take photos for your mood journal entries.</string>
|
<string>Feels uses the camera to take photos for your mood journal entries.</string>
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
|
|||||||
@@ -103,6 +103,7 @@
|
|||||||
Models/Shapes.swift,
|
Models/Shapes.swift,
|
||||||
Models/Theme.swift,
|
Models/Theme.swift,
|
||||||
Models/UserDefaultsStore.swift,
|
Models/UserDefaultsStore.swift,
|
||||||
|
MoodStreakActivity.swift,
|
||||||
Onboarding/OnboardingData.swift,
|
Onboarding/OnboardingData.swift,
|
||||||
Onboarding/views/OnboardingDay.swift,
|
Onboarding/views/OnboardingDay.swift,
|
||||||
Persisence/DataController.swift,
|
Persisence/DataController.swift,
|
||||||
|
|||||||
@@ -32,18 +32,46 @@ struct VoteMoodIntent: AppIntent {
|
|||||||
let mood = Mood(rawValue: moodValue) ?? .average
|
let mood = Mood(rawValue: moodValue) ?? .average
|
||||||
let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding())
|
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)
|
DataController.shared.add(mood: mood, forDate: votingDate, entryType: .widget)
|
||||||
|
|
||||||
// Store last voted date
|
// Store last voted date
|
||||||
let dateString = ISO8601DateFormatter().string(from: Calendar.current.startOfDay(for: votingDate))
|
let dateString = ISO8601DateFormatter().string(from: Calendar.current.startOfDay(for: votingDate))
|
||||||
GroupUserDefaults.groupDefaults.set(dateString, forKey: UserDefaultsStore.Keys.lastVotedDate.rawValue)
|
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
|
// Reload widget timeline
|
||||||
WidgetCenter.shared.reloadTimelines(ofKind: "FeelsVoteWidget")
|
WidgetCenter.shared.reloadTimelines(ofKind: "FeelsVoteWidget")
|
||||||
|
|
||||||
return .result()
|
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
|
// MARK: - Vote Widget Provider
|
||||||
@@ -75,13 +103,41 @@ struct VoteWidgetProvider: TimelineProvider {
|
|||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
let entry = createEntry()
|
let entry = createEntry()
|
||||||
|
|
||||||
// Refresh at midnight
|
// Calculate next refresh time
|
||||||
let midnight = Calendar.current.startOfDay(for: Calendar.current.date(byAdding: .day, value: 1, to: Date())!)
|
let nextRefresh = calculateNextRefreshDate()
|
||||||
let timeline = Timeline(entries: [entry], policy: .after(midnight))
|
let timeline = Timeline(entries: [entry], policy: .after(nextRefresh))
|
||||||
completion(timeline)
|
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
|
@MainActor
|
||||||
private func createEntry() -> VoteWidgetEntry {
|
private func createEntry() -> VoteWidgetEntry {
|
||||||
let hasSubscription = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.hasActiveSubscription.rawValue)
|
let hasSubscription = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.hasActiveSubscription.rawValue)
|
||||||
|
|||||||
@@ -9,6 +9,127 @@ import WidgetKit
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Intents
|
import Intents
|
||||||
import SwiftData
|
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 {
|
class WatchTimelineView: Identifiable {
|
||||||
let id = UUID()
|
let id = UUID()
|
||||||
@@ -355,12 +476,13 @@ struct LargeWidgetView: View {
|
|||||||
struct FeelsGraphicWidgetEntryView : View {
|
struct FeelsGraphicWidgetEntryView : View {
|
||||||
@Environment(\.sizeCategory) var sizeCategory
|
@Environment(\.sizeCategory) var sizeCategory
|
||||||
@Environment(\.widgetFamily) var family
|
@Environment(\.widgetFamily) var family
|
||||||
|
|
||||||
var entry: Provider.Entry
|
var entry: Provider.Entry
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
var body: some View {
|
var body: some View {
|
||||||
SmallGraphicWidgetView(entry: entry)
|
SmallGraphicWidgetView(entry: entry)
|
||||||
|
.containerBackground(.fill.tertiary, for: .widget)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,12 +517,13 @@ struct SmallGraphicWidgetView: View {
|
|||||||
struct FeelsIconWidgetEntryView : View {
|
struct FeelsIconWidgetEntryView : View {
|
||||||
@Environment(\.sizeCategory) var sizeCategory
|
@Environment(\.sizeCategory) var sizeCategory
|
||||||
@Environment(\.widgetFamily) var family
|
@Environment(\.widgetFamily) var family
|
||||||
|
|
||||||
var entry: Provider.Entry
|
var entry: Provider.Entry
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
var body: some View {
|
var body: some View {
|
||||||
SmallIconView(entry: entry)
|
SmallIconView(entry: entry)
|
||||||
|
.containerBackground(.fill.tertiary, for: .widget)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,6 +649,31 @@ struct FeelsBundle: WidgetBundle {
|
|||||||
FeelsGraphicWidget()
|
FeelsGraphicWidget()
|
||||||
FeelsIconWidget()
|
FeelsIconWidget()
|
||||||
FeelsVoteWidget()
|
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>
|
<key>NSExtensionPointIdentifier</key>
|
||||||
<string>com.apple.widgetkit-extension</string>
|
<string>com.apple.widgetkit-extension</string>
|
||||||
</dict>
|
</dict>
|
||||||
|
<key>NSSupportsLiveActivities</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
import UIKit
|
import UIKit
|
||||||
import WidgetKit
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
class AppDelegate: NSObject, UIApplicationDelegate {
|
class AppDelegate: NSObject, UIApplicationDelegate {
|
||||||
@@ -56,21 +55,25 @@ extension AppDelegate: @preconcurrency UNUserNotificationCenterDelegate {
|
|||||||
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
||||||
if let action = LocalNotification.ActionType(rawValue: response.actionIdentifier) {
|
if let action = LocalNotification.ActionType(rawValue: response.actionIdentifier) {
|
||||||
let date = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: savedOnboardingData)
|
let date = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: savedOnboardingData)
|
||||||
|
let mood: Mood
|
||||||
switch action {
|
switch action {
|
||||||
case .horrible:
|
case .horrible:
|
||||||
DataController.shared.add(mood: .horrible, forDate: date, entryType: .notification)
|
mood = .horrible
|
||||||
case .bad:
|
case .bad:
|
||||||
DataController.shared.add(mood: .bad, forDate: date, entryType: .notification)
|
mood = .bad
|
||||||
case .average:
|
case .average:
|
||||||
DataController.shared.add(mood: .average, forDate: date, entryType: .notification)
|
mood = .average
|
||||||
case .good:
|
case .good:
|
||||||
DataController.shared.add(mood: .good, forDate: date, entryType: .notification)
|
mood = .good
|
||||||
case .great:
|
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)
|
UNUserNotificationCenter.current().setBadgeCount(0)
|
||||||
}
|
}
|
||||||
WidgetCenter.shared.reloadAllTimelines()
|
|
||||||
completionHandler()
|
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 SwiftData
|
||||||
import BackgroundTasks
|
import BackgroundTasks
|
||||||
import WidgetKit
|
import WidgetKit
|
||||||
|
import TipKit
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct FeelsApp: App {
|
struct FeelsApp: App {
|
||||||
@@ -18,6 +19,7 @@ struct FeelsApp: App {
|
|||||||
let dataController = DataController.shared
|
let dataController = DataController.shared
|
||||||
@StateObject var iapManager = IAPManager()
|
@StateObject var iapManager = IAPManager()
|
||||||
@StateObject var authManager = BiometricAuthManager()
|
@StateObject var authManager = BiometricAuthManager()
|
||||||
|
@StateObject var healthKitManager = HealthKitManager.shared
|
||||||
@AppStorage(UserDefaultsStore.Keys.firstLaunchDate.rawValue, store: GroupUserDefaults.groupDefaults) private var firstLaunchDate = Date()
|
@AppStorage(UserDefaultsStore.Keys.firstLaunchDate.rawValue, store: GroupUserDefaults.groupDefaults) private var firstLaunchDate = Date()
|
||||||
@State private var showSubscriptionFromWidget = false
|
@State private var showSubscriptionFromWidget = false
|
||||||
|
|
||||||
@@ -27,6 +29,12 @@ struct FeelsApp: App {
|
|||||||
BGTask.runFillInMissingDatesTask(task: task as! BGProcessingTask)
|
BGTask.runFillInMissingDatesTask(task: task as! BGProcessingTask)
|
||||||
}
|
}
|
||||||
UNUserNotificationCenter.current().setBadgeCount(0)
|
UNUserNotificationCenter.current().setBadgeCount(0)
|
||||||
|
|
||||||
|
// Configure TipKit
|
||||||
|
TipsManager.shared.configure()
|
||||||
|
|
||||||
|
// Initialize Live Activity scheduler
|
||||||
|
LiveActivityScheduler.shared.scheduleBasedOnCurrentTime()
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
@@ -39,6 +47,7 @@ struct FeelsApp: App {
|
|||||||
.modelContainer(dataController.container)
|
.modelContainer(dataController.container)
|
||||||
.environmentObject(iapManager)
|
.environmentObject(iapManager)
|
||||||
.environmentObject(authManager)
|
.environmentObject(authManager)
|
||||||
|
.environmentObject(healthKitManager)
|
||||||
.sheet(isPresented: $showSubscriptionFromWidget) {
|
.sheet(isPresented: $showSubscriptionFromWidget) {
|
||||||
FeelsSubscriptionStoreView()
|
FeelsSubscriptionStoreView()
|
||||||
.environmentObject(iapManager)
|
.environmentObject(iapManager)
|
||||||
@@ -75,6 +84,8 @@ struct FeelsApp: App {
|
|||||||
await authManager.authenticate()
|
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 filledInMissing = 4
|
||||||
case notification = 5
|
case notification = 5
|
||||||
case header = 6
|
case header = 6
|
||||||
|
case siri = 7
|
||||||
|
case controlCenter = 8
|
||||||
|
case liveActivity = 9
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - SwiftData Model
|
// MARK: - SwiftData Model
|
||||||
|
|||||||
@@ -6,15 +6,27 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
final class OnboardingDataDataManager: ObservableObject {
|
final class OnboardingDataDataManager: ObservableObject {
|
||||||
static let shared = OnboardingDataDataManager()
|
static let shared = OnboardingDataDataManager()
|
||||||
|
|
||||||
@Published public private(set) var savedOnboardingData = UserDefaultsStore.getOnboarding()
|
@Published public private(set) var savedOnboardingData = UserDefaultsStore.getOnboarding()
|
||||||
|
|
||||||
public func updateOnboardingData(onboardingData: OnboardingData) {
|
public func updateOnboardingData(onboardingData: OnboardingData) {
|
||||||
let onboardingData = UserDefaultsStore.saveOnboarding(onboardingData: onboardingData)
|
let onboardingData = UserDefaultsStore.saveOnboarding(onboardingData: onboardingData)
|
||||||
savedOnboardingData = onboardingData
|
savedOnboardingData = onboardingData
|
||||||
LocalNotification.scheduleReminder(atTime: onboardingData.date)
|
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 {
|
enum PersonalityPack: Int, CaseIterable {
|
||||||
case Default = 0
|
case Default = 0
|
||||||
case Rude = 1
|
// case Rude = 1
|
||||||
case MotivationalCoach = 2
|
case MotivationalCoach = 1
|
||||||
case ZenMaster = 3
|
case ZenMaster = 2
|
||||||
case BestFriend = 4
|
case BestFriend = 3
|
||||||
case DataAnalyst = 5
|
case DataAnalyst = 4
|
||||||
|
|
||||||
func randomPushNotificationStrings() -> (title: String, body: String) {
|
func randomPushNotificationStrings() -> (title: String, body: String) {
|
||||||
let onboarding = UserDefaultsStore.getOnboarding()
|
let onboarding = UserDefaultsStore.getOnboarding()
|
||||||
@@ -33,12 +33,12 @@ enum PersonalityPack: Int, CaseIterable {
|
|||||||
case (.Default, .Previous):
|
case (.Default, .Previous):
|
||||||
return (DefaultTitles.notificationTitles.randomElement()!,
|
return (DefaultTitles.notificationTitles.randomElement()!,
|
||||||
DefaultTitles.notificationBodyYesterday.randomElement()!)
|
DefaultTitles.notificationBodyYesterday.randomElement()!)
|
||||||
case (.Rude, .Today):
|
// case (.Rude, .Today):
|
||||||
return (RudeTitles.notificationTitles.randomElement()!,
|
// return (RudeTitles.notificationTitles.randomElement()!,
|
||||||
RudeTitles.notificationBodyToday.randomElement()!)
|
// RudeTitles.notificationBodyToday.randomElement()!)
|
||||||
case (.Rude, .Previous):
|
// case (.Rude, .Previous):
|
||||||
return (RudeTitles.notificationTitles.randomElement()!,
|
// return (RudeTitles.notificationTitles.randomElement()!,
|
||||||
RudeTitles.notificationBodyYesterday.randomElement()!)
|
// RudeTitles.notificationBodyYesterday.randomElement()!)
|
||||||
case (.MotivationalCoach, .Today):
|
case (.MotivationalCoach, .Today):
|
||||||
return (MotivationalCoachTitles.notificationTitles.randomElement()!,
|
return (MotivationalCoachTitles.notificationTitles.randomElement()!,
|
||||||
MotivationalCoachTitles.notificationBodyToday.randomElement()!)
|
MotivationalCoachTitles.notificationBodyToday.randomElement()!)
|
||||||
@@ -70,8 +70,8 @@ enum PersonalityPack: Int, CaseIterable {
|
|||||||
switch self {
|
switch self {
|
||||||
case .Default:
|
case .Default:
|
||||||
return DefaultTitles.title
|
return DefaultTitles.title
|
||||||
case .Rude:
|
// case .Rude:
|
||||||
return RudeTitles.title
|
// return RudeTitles.title
|
||||||
case .MotivationalCoach:
|
case .MotivationalCoach:
|
||||||
return MotivationalCoachTitles.title
|
return MotivationalCoachTitles.title
|
||||||
case .ZenMaster:
|
case .ZenMaster:
|
||||||
@@ -86,7 +86,7 @@ enum PersonalityPack: Int, CaseIterable {
|
|||||||
var icon: String {
|
var icon: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .Default: return "face.smiling"
|
case .Default: return "face.smiling"
|
||||||
case .Rude: return "flame"
|
// case .Rude: return "flame"
|
||||||
case .MotivationalCoach: return "figure.run"
|
case .MotivationalCoach: return "figure.run"
|
||||||
case .ZenMaster: return "leaf"
|
case .ZenMaster: return "leaf"
|
||||||
case .BestFriend: return "heart"
|
case .BestFriend: return "heart"
|
||||||
@@ -97,7 +97,7 @@ enum PersonalityPack: Int, CaseIterable {
|
|||||||
var description: String {
|
var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .Default: return "Friendly and supportive"
|
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 .MotivationalCoach: return "High energy pump-up vibes"
|
||||||
case .ZenMaster: return "Calm and mindful"
|
case .ZenMaster: return "Calm and mindful"
|
||||||
case .BestFriend: return "Casual and supportive"
|
case .BestFriend: return "Casual and supportive"
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ class UserDefaultsStore {
|
|||||||
case dayViewStyle
|
case dayViewStyle
|
||||||
case privacyLockEnabled
|
case privacyLockEnabled
|
||||||
case healthKitEnabled
|
case healthKitEnabled
|
||||||
|
case healthKitSyncEnabled
|
||||||
|
|
||||||
case contentViewCurrentSelectedHeaderViewBackDays
|
case contentViewCurrentSelectedHeaderViewBackDays
|
||||||
case contentViewHeaderTag
|
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 .filledInMissing: return "Auto-filled"
|
||||||
case .notification: return "Notification"
|
case .notification: return "Notification"
|
||||||
case .header: return "Header"
|
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 {
|
switch personalityPack {
|
||||||
case .Default:
|
case .Default:
|
||||||
return defaultSystemInstructions
|
return defaultSystemInstructions
|
||||||
case .Rude:
|
// case .Rude:
|
||||||
return rudeSystemInstructions
|
// return rudeSystemInstructions
|
||||||
case .MotivationalCoach:
|
case .MotivationalCoach:
|
||||||
return coachSystemInstructions
|
return coachSystemInstructions
|
||||||
case .ZenMaster:
|
case .ZenMaster:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import StoreKit
|
import StoreKit
|
||||||
|
import TipKit
|
||||||
|
|
||||||
// MARK: - Customize Content View (for use in SettingsTabView)
|
// MARK: - Customize Content View (for use in SettingsTabView)
|
||||||
struct CustomizeContentView: View {
|
struct CustomizeContentView: View {
|
||||||
@@ -17,6 +18,10 @@ struct CustomizeContentView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 24) {
|
VStack(spacing: 24) {
|
||||||
|
// Customize tip
|
||||||
|
TipView(CustomizeLayoutTip())
|
||||||
|
.tipBackground(Color(.secondarySystemBackground))
|
||||||
|
|
||||||
// APPEARANCE
|
// APPEARANCE
|
||||||
SettingsSection(title: "Appearance") {
|
SettingsSection(title: "Appearance") {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
@@ -640,16 +645,16 @@ struct PersonalityPackPickerCompact: View {
|
|||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
ForEach(PersonalityPack.allCases, id: \.self) { aPack in
|
ForEach(PersonalityPack.allCases, id: \.self) { aPack in
|
||||||
Button(action: {
|
Button(action: {
|
||||||
if aPack.rawValue == PersonalityPack.Rude.rawValue && !showNSFW {
|
// if aPack.rawValue == PersonalityPack.Rude.rawValue && !showNSFW {
|
||||||
showOver18Alert = true
|
// showOver18Alert = true
|
||||||
EventLogger.log(event: "show_over_18_alert")
|
// EventLogger.log(event: "show_over_18_alert")
|
||||||
} else {
|
// } else {
|
||||||
let impactMed = UIImpactFeedbackGenerator(style: .medium)
|
let impactMed = UIImpactFeedbackGenerator(style: .medium)
|
||||||
impactMed.impactOccurred()
|
impactMed.impactOccurred()
|
||||||
personalityPack = aPack
|
personalityPack = aPack
|
||||||
EventLogger.log(event: "change_personality_pack", withData: ["pack_title": aPack.title()])
|
EventLogger.log(event: "change_personality_pack", withData: ["pack_title": aPack.title()])
|
||||||
LocalNotification.rescheduleNotifiations()
|
LocalNotification.rescheduleNotifiations()
|
||||||
}
|
// }
|
||||||
}) {
|
}) {
|
||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
@@ -681,7 +686,7 @@ struct PersonalityPackPickerCompact: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.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) {
|
.alert(isPresented: $showOver18Alert) {
|
||||||
|
|||||||
@@ -40,18 +40,18 @@ struct PersonalityPackPickerView: View {
|
|||||||
.padding(5)
|
.padding(5)
|
||||||
)
|
)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
if aPack.rawValue == PersonalityPack.Rude.rawValue && !showNSFW {
|
// if aPack.rawValue == PersonalityPack.Rude.rawValue && !showNSFW {
|
||||||
showOver18Alert = true
|
// showOver18Alert = true
|
||||||
EventLogger.log(event: "show_over_18_alert")
|
// EventLogger.log(event: "show_over_18_alert")
|
||||||
} else {
|
// } else {
|
||||||
let impactMed = UIImpactFeedbackGenerator(style: .heavy)
|
let impactMed = UIImpactFeedbackGenerator(style: .heavy)
|
||||||
impactMed.impactOccurred()
|
impactMed.impactOccurred()
|
||||||
personalityPack = aPack
|
personalityPack = aPack
|
||||||
EventLogger.log(event: "change_personality_pack", withData: ["pack_title": aPack.title()])
|
EventLogger.log(event: "change_personality_pack", withData: ["pack_title": aPack.title()])
|
||||||
LocalNotification.rescheduleNotifiations()
|
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) {
|
.alert(isPresented: $showOver18Alert) {
|
||||||
let primaryButton = Alert.Button.default(Text(String(localized: "customize_view_over18alert_ok"))) {
|
let primaryButton = Alert.Button.default(Text(String(localized: "customize_view_over18alert_ok"))) {
|
||||||
showNSFW = true
|
showNSFW = true
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
import Charts
|
import Charts
|
||||||
|
import TipKit
|
||||||
|
|
||||||
struct DayViewConstants {
|
struct DayViewConstants {
|
||||||
static let maxHeaderHeight = 200.0
|
static let maxHeaderHeight = 200.0
|
||||||
@@ -30,7 +31,6 @@ struct DayView: View {
|
|||||||
// MARK: edit row properties
|
// MARK: edit row properties
|
||||||
@State private var showingSheet = false
|
@State private var showingSheet = false
|
||||||
@State private var selectedEntry: MoodEntryModel?
|
@State private var selectedEntry: MoodEntryModel?
|
||||||
@State private var showEntryDetail = false
|
|
||||||
//
|
//
|
||||||
|
|
||||||
// MARK: ?? properties
|
// MARK: ?? properties
|
||||||
@@ -53,25 +53,16 @@ struct DayView: View {
|
|||||||
.sheet(isPresented: $showingSheet) {
|
.sheet(isPresented: $showingSheet) {
|
||||||
SettingsView()
|
SettingsView()
|
||||||
}
|
}
|
||||||
.onChange(of: selectedEntry) { _, newEntry in
|
.sheet(item: $selectedEntry) { entry in
|
||||||
if newEntry != nil {
|
EntryDetailView(
|
||||||
showEntryDetail = true
|
entry: entry,
|
||||||
}
|
onMoodUpdate: { newMood in
|
||||||
}
|
viewModel.update(entry: entry, toMood: newMood)
|
||||||
.sheet(isPresented: $showEntryDetail, onDismiss: {
|
},
|
||||||
selectedEntry = nil
|
onDelete: {
|
||||||
}) {
|
viewModel.update(entry: entry, toMood: .missing)
|
||||||
if let entry = selectedEntry {
|
}
|
||||||
EntryDetailView(
|
)
|
||||||
entry: entry,
|
|
||||||
onMoodUpdate: { newMood in
|
|
||||||
viewModel.update(entry: entry, toMood: newMood)
|
|
||||||
},
|
|
||||||
onDelete: {
|
|
||||||
viewModel.update(entry: entry, toMood: .missing)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding([.top])
|
.padding([.top])
|
||||||
@@ -107,6 +98,7 @@ struct DayView: View {
|
|||||||
AddMoodHeaderView(addItemHeaderClosure: { (mood, date) in
|
AddMoodHeaderView(addItemHeaderClosure: { (mood, date) in
|
||||||
viewModel.add(mood: mood, forDate: date, entryType: .header)
|
viewModel.add(mood: mood, forDate: date, entryType: .header)
|
||||||
})
|
})
|
||||||
|
.widgetVotingTip()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
class DayViewViewModel: ObservableObject {
|
class DayViewViewModel: ObservableObject {
|
||||||
@@ -60,12 +61,28 @@ class DayViewViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func add(mood: Mood, forDate date: Date, entryType: EntryType) {
|
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) {
|
public func update(entry: MoodEntryModel, toMood mood: Mood) {
|
||||||
if !DataController.shared.update(entryDate: entry.forDate, withMood: mood) {
|
if !DataController.shared.update(entryDate: entry.forDate, withMood: mood) {
|
||||||
print("Failed to update mood entry")
|
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 SwiftUI
|
||||||
|
import TipKit
|
||||||
|
|
||||||
struct InsightsView: View {
|
struct InsightsView: View {
|
||||||
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
|
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
|
||||||
@@ -48,6 +49,7 @@ struct InsightsView: View {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
|
.aiInsightsTip()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ struct MonthDetailView: View {
|
|||||||
ForEach(Mood.allValues) { mood in
|
ForEach(Mood.allValues) { mood in
|
||||||
Button(mood.strValue, action: {
|
Button(mood.strValue, action: {
|
||||||
if let selectedEntry = selectedEntry {
|
if let selectedEntry = selectedEntry {
|
||||||
DataController.shared.update(entryDate: selectedEntry.forDate, withMood: mood)
|
parentViewModel.update(entry: selectedEntry, toMood: mood)
|
||||||
}
|
}
|
||||||
updateEntries()
|
updateEntries()
|
||||||
showUpdateEntryAlert = false
|
showUpdateEntryAlert = false
|
||||||
@@ -102,7 +102,7 @@ struct MonthDetailView: View {
|
|||||||
deleteEnabled,
|
deleteEnabled,
|
||||||
selectedEntry.mood != .missing {
|
selectedEntry.mood != .missing {
|
||||||
Button(String(localized: "content_view_delete_entry"), action: {
|
Button(String(localized: "content_view_delete_entry"), action: {
|
||||||
DataController.shared.update(entryDate: selectedEntry.forDate, withMood: .missing)
|
parentViewModel.update(entry: selectedEntry, toMood: .missing)
|
||||||
updateEntries()
|
updateEntries()
|
||||||
showUpdateEntryAlert = false
|
showUpdateEntryAlert = false
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -151,9 +151,14 @@ struct EntryDetailView: View {
|
|||||||
@State private var showDeleteConfirmation = false
|
@State private var showDeleteConfirmation = false
|
||||||
@State private var showFullScreenPhoto = false
|
@State private var showFullScreenPhoto = false
|
||||||
@State private var selectedPhotoItem: PhotosPickerItem?
|
@State private var selectedPhotoItem: PhotosPickerItem?
|
||||||
|
@State private var selectedMood: Mood?
|
||||||
|
|
||||||
|
private var currentMood: Mood {
|
||||||
|
selectedMood ?? entry.mood
|
||||||
|
}
|
||||||
|
|
||||||
private var moodColor: Color {
|
private var moodColor: Color {
|
||||||
moodTint.color(forMood: entry.mood)
|
moodTint.color(forMood: currentMood)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func savePhoto(_ image: UIImage) {
|
private func savePhoto(_ image: UIImage) {
|
||||||
@@ -267,7 +272,7 @@ struct EntryDetailView: View {
|
|||||||
)
|
)
|
||||||
.frame(width: 60, height: 60)
|
.frame(width: 60, height: 60)
|
||||||
|
|
||||||
imagePack.icon(forMood: entry.mood)
|
imagePack.icon(forMood: currentMood)
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fit)
|
.aspectRatio(contentMode: .fit)
|
||||||
.frame(width: 34, height: 34)
|
.frame(width: 34, height: 34)
|
||||||
@@ -276,7 +281,7 @@ struct EntryDetailView: View {
|
|||||||
.shadow(color: moodColor.opacity(0.4), radius: 8, x: 0, y: 4)
|
.shadow(color: moodColor.opacity(0.4), radius: 8, x: 0, y: 4)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(entry.moodString)
|
Text(currentMood.strValue)
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
.foregroundColor(moodColor)
|
.foregroundColor(moodColor)
|
||||||
@@ -298,23 +303,28 @@ struct EntryDetailView: View {
|
|||||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 5), spacing: 12) {
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 5), spacing: 12) {
|
||||||
ForEach(Mood.allValues) { mood in
|
ForEach(Mood.allValues) { mood in
|
||||||
Button {
|
Button {
|
||||||
|
// Update local state immediately for instant feedback
|
||||||
|
withAnimation(.easeInOut(duration: 0.15)) {
|
||||||
|
selectedMood = mood
|
||||||
|
}
|
||||||
|
// Then persist the change
|
||||||
onMoodUpdate(mood)
|
onMoodUpdate(mood)
|
||||||
} label: {
|
} label: {
|
||||||
VStack(spacing: 6) {
|
VStack(spacing: 6) {
|
||||||
Circle()
|
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)
|
.frame(width: 50, height: 50)
|
||||||
.overlay(
|
.overlay(
|
||||||
imagePack.icon(forMood: mood)
|
imagePack.icon(forMood: mood)
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fit)
|
.aspectRatio(contentMode: .fit)
|
||||||
.frame(width: 28, height: 28)
|
.frame(width: 28, height: 28)
|
||||||
.foregroundColor(entry.mood == mood ? .white : .gray)
|
.foregroundColor(currentMood == mood ? .white : .gray)
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(mood.strValue)
|
Text(mood.strValue)
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundColor(entry.mood == mood ? moodTint.color(forMood: mood) : .secondary)
|
.foregroundColor(currentMood == mood ? moodTint.color(forMood: mood) : .secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import SwiftUI
|
|||||||
import CloudKitSyncMonitor
|
import CloudKitSyncMonitor
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
import StoreKit
|
import StoreKit
|
||||||
|
import TipKit
|
||||||
|
|
||||||
// MARK: - Settings Content View (for use in SettingsTabView)
|
// MARK: - Settings Content View (for use in SettingsTabView)
|
||||||
struct SettingsContentView: View {
|
struct SettingsContentView: View {
|
||||||
@@ -16,6 +17,7 @@ struct SettingsContentView: View {
|
|||||||
|
|
||||||
@State private var showOnboarding = false
|
@State private var showOnboarding = false
|
||||||
@State private var showExportView = false
|
@State private var showExportView = false
|
||||||
|
@State private var showReminderTimePicker = false
|
||||||
@StateObject private var healthService = HealthService.shared
|
@StateObject private var healthService = HealthService.shared
|
||||||
|
|
||||||
@AppStorage(UserDefaultsStore.Keys.deleteEnable.rawValue, store: GroupUserDefaults.groupDefaults) private var deleteEnabled = true
|
@AppStorage(UserDefaultsStore.Keys.deleteEnable.rawValue, store: GroupUserDefaults.groupDefaults) private var deleteEnabled = true
|
||||||
@@ -33,6 +35,7 @@ struct SettingsContentView: View {
|
|||||||
|
|
||||||
// Settings section
|
// Settings section
|
||||||
settingsSectionHeader
|
settingsSectionHeader
|
||||||
|
reminderTimeButton
|
||||||
canDelete
|
canDelete
|
||||||
showOnboardingButton
|
showOnboardingButton
|
||||||
|
|
||||||
@@ -64,11 +67,59 @@ struct SettingsContentView: View {
|
|||||||
includedDays: []
|
includedDays: []
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showReminderTimePicker) {
|
||||||
|
ReminderTimePickerView()
|
||||||
|
}
|
||||||
.onAppear(perform: {
|
.onAppear(perform: {
|
||||||
EventLogger.log(event: "show_settings_view")
|
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
|
// MARK: - Section Headers
|
||||||
|
|
||||||
private var featuresSectionHeader: some View {
|
private var featuresSectionHeader: some View {
|
||||||
@@ -80,6 +131,7 @@ struct SettingsContentView: View {
|
|||||||
}
|
}
|
||||||
.padding(.top, 20)
|
.padding(.top, 20)
|
||||||
.padding(.horizontal, 4)
|
.padding(.horizontal, 4)
|
||||||
|
.siriShortcutTip()
|
||||||
}
|
}
|
||||||
|
|
||||||
private var settingsSectionHeader: some View {
|
private var settingsSectionHeader: some View {
|
||||||
@@ -91,6 +143,7 @@ struct SettingsContentView: View {
|
|||||||
}
|
}
|
||||||
.padding(.top, 20)
|
.padding(.top, 20)
|
||||||
.padding(.horizontal, 4)
|
.padding(.horizontal, 4)
|
||||||
|
.controlCenterTip()
|
||||||
}
|
}
|
||||||
|
|
||||||
private var legalSectionHeader: some View {
|
private var legalSectionHeader: some View {
|
||||||
@@ -180,8 +233,15 @@ struct SettingsContentView: View {
|
|||||||
set: { newValue in
|
set: { newValue in
|
||||||
if newValue {
|
if newValue {
|
||||||
Task {
|
Task {
|
||||||
let success = await healthService.requestAuthorization()
|
// Request read permissions for health insights
|
||||||
if !success {
|
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")
|
EventLogger.log(event: "healthkit_enable_failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,6 +262,7 @@ struct SettingsContentView: View {
|
|||||||
}
|
}
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||||
|
.healthKitSyncTip()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Export Data Button
|
// 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)
|
// MARK: - Legacy SettingsView (sheet presentation with close button)
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@@ -583,8 +703,15 @@ struct SettingsView: View {
|
|||||||
set: { newValue in
|
set: { newValue in
|
||||||
if newValue {
|
if newValue {
|
||||||
Task {
|
Task {
|
||||||
let success = await healthService.requestAuthorization()
|
// Request read permissions for health insights
|
||||||
if !success {
|
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")
|
EventLogger.log(event: "healthkit_enable_failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user