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:
Trey t
2025-12-19 17:21:55 -06:00
parent e123df1790
commit 440b04159e
27 changed files with 1577 additions and 81 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,

View File

@@ -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)

View File

@@ -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()
}
}

View File

@@ -7,5 +7,7 @@
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
<key>NSSupportsLiveActivities</key>
<true/>
</dict>
</plist>

View File

@@ -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
View 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"
)
}
}

View File

@@ -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
View 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))
}
}

View 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"
}
}
}

View File

@@ -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

View File

@@ -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()
}
}

View File

@@ -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"

View File

@@ -96,6 +96,7 @@ class UserDefaultsStore {
case dayViewStyle
case privacyLockEnabled
case healthKitEnabled
case healthKitSyncEnabled
case contentViewCurrentSelectedHeaderViewBackDays
case contentViewHeaderTag

89
Shared/MoodLogger.swift Normal file
View 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
}
}

View 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
}
}

View File

@@ -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"
}
}
}

View File

@@ -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:

View File

@@ -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) {

View File

@@ -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

View File

@@ -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()
}
}
}

View File

@@ -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()
}
}

View File

@@ -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)

View File

@@ -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
})

View File

@@ -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)

View File

@@ -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")
}
}