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:
@@ -7,6 +7,7 @@
|
||||
|
||||
import SwiftUI
|
||||
import StoreKit
|
||||
import TipKit
|
||||
|
||||
// MARK: - Customize Content View (for use in SettingsTabView)
|
||||
struct CustomizeContentView: View {
|
||||
@@ -17,6 +18,10 @@ struct CustomizeContentView: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
// Customize tip
|
||||
TipView(CustomizeLayoutTip())
|
||||
.tipBackground(Color(.secondarySystemBackground))
|
||||
|
||||
// APPEARANCE
|
||||
SettingsSection(title: "Appearance") {
|
||||
VStack(spacing: 16) {
|
||||
@@ -640,16 +645,16 @@ struct PersonalityPackPickerCompact: View {
|
||||
VStack(spacing: 8) {
|
||||
ForEach(PersonalityPack.allCases, id: \.self) { aPack in
|
||||
Button(action: {
|
||||
if aPack.rawValue == PersonalityPack.Rude.rawValue && !showNSFW {
|
||||
showOver18Alert = true
|
||||
EventLogger.log(event: "show_over_18_alert")
|
||||
} else {
|
||||
// if aPack.rawValue == PersonalityPack.Rude.rawValue && !showNSFW {
|
||||
// showOver18Alert = true
|
||||
// EventLogger.log(event: "show_over_18_alert")
|
||||
// } else {
|
||||
let impactMed = UIImpactFeedbackGenerator(style: .medium)
|
||||
impactMed.impactOccurred()
|
||||
personalityPack = aPack
|
||||
EventLogger.log(event: "change_personality_pack", withData: ["pack_title": aPack.title()])
|
||||
LocalNotification.rescheduleNotifiations()
|
||||
}
|
||||
// }
|
||||
}) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
@@ -681,7 +686,7 @@ struct PersonalityPackPickerCompact: View {
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.blur(radius: aPack.rawValue == PersonalityPack.Rude.rawValue && !showNSFW ? 4 : 0)
|
||||
// .blur(radius: aPack.rawValue == PersonalityPack.Rude.rawValue && !showNSFW ? 4 : 0)
|
||||
}
|
||||
}
|
||||
.alert(isPresented: $showOver18Alert) {
|
||||
|
||||
@@ -40,18 +40,18 @@ struct PersonalityPackPickerView: View {
|
||||
.padding(5)
|
||||
)
|
||||
.onTapGesture {
|
||||
if aPack.rawValue == PersonalityPack.Rude.rawValue && !showNSFW {
|
||||
showOver18Alert = true
|
||||
EventLogger.log(event: "show_over_18_alert")
|
||||
} else {
|
||||
// if aPack.rawValue == PersonalityPack.Rude.rawValue && !showNSFW {
|
||||
// showOver18Alert = true
|
||||
// EventLogger.log(event: "show_over_18_alert")
|
||||
// } else {
|
||||
let impactMed = UIImpactFeedbackGenerator(style: .heavy)
|
||||
impactMed.impactOccurred()
|
||||
personalityPack = aPack
|
||||
EventLogger.log(event: "change_personality_pack", withData: ["pack_title": aPack.title()])
|
||||
LocalNotification.rescheduleNotifiations()
|
||||
}
|
||||
// }
|
||||
}
|
||||
.blur(radius: aPack.rawValue == PersonalityPack.Rude.rawValue && !showNSFW ? 5 : 0)
|
||||
// .blur(radius: aPack.rawValue == PersonalityPack.Rude.rawValue && !showNSFW ? 5 : 0)
|
||||
.alert(isPresented: $showOver18Alert) {
|
||||
let primaryButton = Alert.Button.default(Text(String(localized: "customize_view_over18alert_ok"))) {
|
||||
showNSFW = true
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import Charts
|
||||
import TipKit
|
||||
|
||||
struct DayViewConstants {
|
||||
static let maxHeaderHeight = 200.0
|
||||
@@ -30,7 +31,6 @@ struct DayView: View {
|
||||
// MARK: edit row properties
|
||||
@State private var showingSheet = false
|
||||
@State private var selectedEntry: MoodEntryModel?
|
||||
@State private var showEntryDetail = false
|
||||
//
|
||||
|
||||
// MARK: ?? properties
|
||||
@@ -53,25 +53,16 @@ struct DayView: View {
|
||||
.sheet(isPresented: $showingSheet) {
|
||||
SettingsView()
|
||||
}
|
||||
.onChange(of: selectedEntry) { _, newEntry in
|
||||
if newEntry != nil {
|
||||
showEntryDetail = true
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showEntryDetail, onDismiss: {
|
||||
selectedEntry = nil
|
||||
}) {
|
||||
if let entry = selectedEntry {
|
||||
EntryDetailView(
|
||||
entry: entry,
|
||||
onMoodUpdate: { newMood in
|
||||
viewModel.update(entry: entry, toMood: newMood)
|
||||
},
|
||||
onDelete: {
|
||||
viewModel.update(entry: entry, toMood: .missing)
|
||||
}
|
||||
)
|
||||
}
|
||||
.sheet(item: $selectedEntry) { entry in
|
||||
EntryDetailView(
|
||||
entry: entry,
|
||||
onMoodUpdate: { newMood in
|
||||
viewModel.update(entry: entry, toMood: newMood)
|
||||
},
|
||||
onDelete: {
|
||||
viewModel.update(entry: entry, toMood: .missing)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding([.top])
|
||||
@@ -107,6 +98,7 @@ struct DayView: View {
|
||||
AddMoodHeaderView(addItemHeaderClosure: { (mood, date) in
|
||||
viewModel.add(mood: mood, forDate: date, entryType: .header)
|
||||
})
|
||||
.widgetVotingTip()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import WidgetKit
|
||||
|
||||
@MainActor
|
||||
class DayViewViewModel: ObservableObject {
|
||||
@@ -60,12 +61,28 @@ class DayViewViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
public func add(mood: Mood, forDate date: Date, entryType: EntryType) {
|
||||
DataController.shared.add(mood: mood, forDate: date, entryType: entryType)
|
||||
MoodLogger.shared.logMood(mood, for: date, entryType: entryType)
|
||||
}
|
||||
|
||||
public func update(entry: MoodEntryModel, toMood mood: Mood) {
|
||||
if !DataController.shared.update(entryDate: entry.forDate, withMood: mood) {
|
||||
print("Failed to update mood entry")
|
||||
return
|
||||
}
|
||||
|
||||
// Sync to HealthKit for past day updates
|
||||
guard mood != .missing && mood != .placeholder else { return }
|
||||
|
||||
let healthKitEnabled = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.healthKitEnabled.rawValue)
|
||||
if healthKitEnabled {
|
||||
Task {
|
||||
try? await HealthKitManager.shared.saveMood(mood, for: entry.forDate)
|
||||
}
|
||||
}
|
||||
|
||||
// Reload widgets asynchronously to avoid UI delay
|
||||
Task { @MainActor in
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import TipKit
|
||||
|
||||
struct InsightsView: View {
|
||||
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
|
||||
@@ -48,6 +49,7 @@ struct InsightsView: View {
|
||||
)
|
||||
)
|
||||
.clipShape(Capsule())
|
||||
.aiInsightsTip()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
@@ -90,7 +90,7 @@ struct MonthDetailView: View {
|
||||
ForEach(Mood.allValues) { mood in
|
||||
Button(mood.strValue, action: {
|
||||
if let selectedEntry = selectedEntry {
|
||||
DataController.shared.update(entryDate: selectedEntry.forDate, withMood: mood)
|
||||
parentViewModel.update(entry: selectedEntry, toMood: mood)
|
||||
}
|
||||
updateEntries()
|
||||
showUpdateEntryAlert = false
|
||||
@@ -102,7 +102,7 @@ struct MonthDetailView: View {
|
||||
deleteEnabled,
|
||||
selectedEntry.mood != .missing {
|
||||
Button(String(localized: "content_view_delete_entry"), action: {
|
||||
DataController.shared.update(entryDate: selectedEntry.forDate, withMood: .missing)
|
||||
parentViewModel.update(entry: selectedEntry, toMood: .missing)
|
||||
updateEntries()
|
||||
showUpdateEntryAlert = false
|
||||
})
|
||||
|
||||
@@ -151,9 +151,14 @@ struct EntryDetailView: View {
|
||||
@State private var showDeleteConfirmation = false
|
||||
@State private var showFullScreenPhoto = false
|
||||
@State private var selectedPhotoItem: PhotosPickerItem?
|
||||
@State private var selectedMood: Mood?
|
||||
|
||||
private var currentMood: Mood {
|
||||
selectedMood ?? entry.mood
|
||||
}
|
||||
|
||||
private var moodColor: Color {
|
||||
moodTint.color(forMood: entry.mood)
|
||||
moodTint.color(forMood: currentMood)
|
||||
}
|
||||
|
||||
private func savePhoto(_ image: UIImage) {
|
||||
@@ -267,7 +272,7 @@ struct EntryDetailView: View {
|
||||
)
|
||||
.frame(width: 60, height: 60)
|
||||
|
||||
imagePack.icon(forMood: entry.mood)
|
||||
imagePack.icon(forMood: currentMood)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 34, height: 34)
|
||||
@@ -276,7 +281,7 @@ struct EntryDetailView: View {
|
||||
.shadow(color: moodColor.opacity(0.4), radius: 8, x: 0, y: 4)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(entry.moodString)
|
||||
Text(currentMood.strValue)
|
||||
.font(.title3)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(moodColor)
|
||||
@@ -298,23 +303,28 @@ struct EntryDetailView: View {
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 5), spacing: 12) {
|
||||
ForEach(Mood.allValues) { mood in
|
||||
Button {
|
||||
// Update local state immediately for instant feedback
|
||||
withAnimation(.easeInOut(duration: 0.15)) {
|
||||
selectedMood = mood
|
||||
}
|
||||
// Then persist the change
|
||||
onMoodUpdate(mood)
|
||||
} label: {
|
||||
VStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(entry.mood == mood ? moodTint.color(forMood: mood) : Color(.systemGray5))
|
||||
.fill(currentMood == mood ? moodTint.color(forMood: mood) : Color(.systemGray5))
|
||||
.frame(width: 50, height: 50)
|
||||
.overlay(
|
||||
imagePack.icon(forMood: mood)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 28, height: 28)
|
||||
.foregroundColor(entry.mood == mood ? .white : .gray)
|
||||
.foregroundColor(currentMood == mood ? .white : .gray)
|
||||
)
|
||||
|
||||
Text(mood.strValue)
|
||||
.font(.caption2)
|
||||
.foregroundColor(entry.mood == mood ? moodTint.color(forMood: mood) : .secondary)
|
||||
.foregroundColor(currentMood == mood ? moodTint.color(forMood: mood) : .secondary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
@@ -9,6 +9,7 @@ import SwiftUI
|
||||
import CloudKitSyncMonitor
|
||||
import UniformTypeIdentifiers
|
||||
import StoreKit
|
||||
import TipKit
|
||||
|
||||
// MARK: - Settings Content View (for use in SettingsTabView)
|
||||
struct SettingsContentView: View {
|
||||
@@ -16,6 +17,7 @@ struct SettingsContentView: View {
|
||||
|
||||
@State private var showOnboarding = false
|
||||
@State private var showExportView = false
|
||||
@State private var showReminderTimePicker = false
|
||||
@StateObject private var healthService = HealthService.shared
|
||||
|
||||
@AppStorage(UserDefaultsStore.Keys.deleteEnable.rawValue, store: GroupUserDefaults.groupDefaults) private var deleteEnabled = true
|
||||
@@ -33,6 +35,7 @@ struct SettingsContentView: View {
|
||||
|
||||
// Settings section
|
||||
settingsSectionHeader
|
||||
reminderTimeButton
|
||||
canDelete
|
||||
showOnboardingButton
|
||||
|
||||
@@ -64,11 +67,59 @@ struct SettingsContentView: View {
|
||||
includedDays: []
|
||||
))
|
||||
}
|
||||
.sheet(isPresented: $showReminderTimePicker) {
|
||||
ReminderTimePickerView()
|
||||
}
|
||||
.onAppear(perform: {
|
||||
EventLogger.log(event: "show_settings_view")
|
||||
TipsManager.shared.onSettingsViewed()
|
||||
})
|
||||
}
|
||||
|
||||
// MARK: - Reminder Time Button
|
||||
|
||||
private var reminderTimeButton: some View {
|
||||
ZStack {
|
||||
theme.currentTheme.secondaryBGColor
|
||||
Button(action: {
|
||||
EventLogger.log(event: "tap_reminder_time")
|
||||
showReminderTimePicker = true
|
||||
}, label: {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "clock.fill")
|
||||
.font(.title2)
|
||||
.foregroundColor(.orange)
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Reminder Time")
|
||||
.foregroundColor(textColor)
|
||||
|
||||
Text(formattedReminderTime)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding()
|
||||
})
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||
}
|
||||
|
||||
private var formattedReminderTime: String {
|
||||
let onboardingData = UserDefaultsStore.getOnboarding()
|
||||
let formatter = DateFormatter()
|
||||
formatter.timeStyle = .short
|
||||
return formatter.string(from: onboardingData.date)
|
||||
}
|
||||
|
||||
// MARK: - Section Headers
|
||||
|
||||
private var featuresSectionHeader: some View {
|
||||
@@ -80,6 +131,7 @@ struct SettingsContentView: View {
|
||||
}
|
||||
.padding(.top, 20)
|
||||
.padding(.horizontal, 4)
|
||||
.siriShortcutTip()
|
||||
}
|
||||
|
||||
private var settingsSectionHeader: some View {
|
||||
@@ -91,6 +143,7 @@ struct SettingsContentView: View {
|
||||
}
|
||||
.padding(.top, 20)
|
||||
.padding(.horizontal, 4)
|
||||
.controlCenterTip()
|
||||
}
|
||||
|
||||
private var legalSectionHeader: some View {
|
||||
@@ -180,8 +233,15 @@ struct SettingsContentView: View {
|
||||
set: { newValue in
|
||||
if newValue {
|
||||
Task {
|
||||
let success = await healthService.requestAuthorization()
|
||||
if !success {
|
||||
// Request read permissions for health insights
|
||||
let readSuccess = await healthService.requestAuthorization()
|
||||
// Request write permissions for State of Mind sync
|
||||
do {
|
||||
try await HealthKitManager.shared.requestAuthorization()
|
||||
} catch {
|
||||
print("HealthKit write authorization failed: \(error)")
|
||||
}
|
||||
if !readSuccess {
|
||||
EventLogger.log(event: "healthkit_enable_failed")
|
||||
}
|
||||
}
|
||||
@@ -202,6 +262,7 @@ struct SettingsContentView: View {
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||
.healthKitSyncTip()
|
||||
}
|
||||
|
||||
// MARK: - Export Data Button
|
||||
@@ -311,6 +372,65 @@ struct SettingsContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reminder Time Picker View
|
||||
|
||||
struct ReminderTimePickerView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var selectedTime: Date
|
||||
|
||||
init() {
|
||||
let onboardingData = UserDefaultsStore.getOnboarding()
|
||||
_selectedTime = State(initialValue: onboardingData.date)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 24) {
|
||||
Text("When would you like to be reminded to log your mood?")
|
||||
.font(.headline)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 20)
|
||||
|
||||
DatePicker(
|
||||
"Reminder Time",
|
||||
selection: $selectedTime,
|
||||
displayedComponents: .hourAndMinute
|
||||
)
|
||||
.datePickerStyle(.wheel)
|
||||
.labelsHidden()
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle("Reminder Time")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") {
|
||||
saveReminderTime()
|
||||
dismiss()
|
||||
}
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func saveReminderTime() {
|
||||
let onboardingData = UserDefaultsStore.getOnboarding()
|
||||
onboardingData.date = selectedTime
|
||||
// This handles notification scheduling and Live Activity rescheduling
|
||||
OnboardingDataDataManager.shared.updateOnboardingData(onboardingData: onboardingData)
|
||||
|
||||
EventLogger.log(event: "reminder_time_updated")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Legacy SettingsView (sheet presentation with close button)
|
||||
struct SettingsView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@@ -583,8 +703,15 @@ struct SettingsView: View {
|
||||
set: { newValue in
|
||||
if newValue {
|
||||
Task {
|
||||
let success = await healthService.requestAuthorization()
|
||||
if !success {
|
||||
// Request read permissions for health insights
|
||||
let readSuccess = await healthService.requestAuthorization()
|
||||
// Request write permissions for State of Mind sync
|
||||
do {
|
||||
try await HealthKitManager.shared.requestAuthorization()
|
||||
} catch {
|
||||
print("HealthKit write authorization failed: \(error)")
|
||||
}
|
||||
if !readSuccess {
|
||||
EventLogger.log(event: "healthkit_enable_failed")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user