Fix 7 data mutation layer risks identified in audit
- save()/saveAndRunDataListeners() now return @discardableResult Bool; listeners only fire on successful save - MoodLogger.updateMood() now recalculates streak, updates Live Activity, and notifies watch (was missing these side effects) - CSV import uses new addBatch()/importMoods() for O(1) side effects instead of O(n) per-row widget reloads and streak calcs - Foreground task ordering: fillInMissingDates() now runs before removeDuplicates() so backfill-created duplicates are caught same cycle - WidgetMoodSaver deletes ALL entries for date (was fetchLimit=1, leaving CloudKit sync duplicates behind) - cleanupPhotoIfNeeded logs warning on failed photo deletion instead of silently orphaning files Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ struct FeelsApp: App {
|
|||||||
@StateObject var healthKitManager = HealthKitManager.shared
|
@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
|
||||||
|
@State private var showStorageFallbackAlert = SharedModelContainer.isUsingInMemoryFallback
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
AnalyticsManager.shared.configure()
|
AnalyticsManager.shared.configure()
|
||||||
@@ -61,6 +62,17 @@ struct FeelsApp: App {
|
|||||||
showSubscriptionFromWidget = true
|
showSubscriptionFromWidget = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.alert("Data Storage Unavailable",
|
||||||
|
isPresented: $showStorageFallbackAlert) {
|
||||||
|
Button("OK", role: .cancel) { }
|
||||||
|
} message: {
|
||||||
|
Text("Your mood data cannot be saved permanently. Please restart the app. If the problem persists, reinstall the app.")
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
if SharedModelContainer.isUsingInMemoryFallback {
|
||||||
|
AnalyticsManager.shared.track(.storageFallbackActivated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Lock screen overlay
|
// Lock screen overlay
|
||||||
if authManager.isLockEnabled && !authManager.isUnlocked {
|
if authManager.isLockEnabled && !authManager.isUnlocked {
|
||||||
@@ -93,12 +105,12 @@ struct FeelsApp: App {
|
|||||||
// Refresh from disk to pick up widget/watch changes
|
// Refresh from disk to pick up widget/watch changes
|
||||||
DataController.shared.refreshFromDisk()
|
DataController.shared.refreshFromDisk()
|
||||||
|
|
||||||
// Clean up any duplicate entries first
|
|
||||||
DataController.shared.removeDuplicates()
|
|
||||||
|
|
||||||
// Fill in any missing dates (moved from AppDelegate)
|
// Fill in any missing dates (moved from AppDelegate)
|
||||||
DataController.shared.fillInMissingDates()
|
DataController.shared.fillInMissingDates()
|
||||||
|
|
||||||
|
// Clean up any duplicate entries (after backfill so backfill dupes are caught)
|
||||||
|
DataController.shared.removeDuplicates()
|
||||||
|
|
||||||
// Reschedule notifications for new title
|
// Reschedule notifications for new title
|
||||||
LocalNotification.rescheduleNotifiations()
|
LocalNotification.rescheduleNotifiations()
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,110 @@ final class MoodLogger {
|
|||||||
markSideEffectsApplied(for: date)
|
markSideEffectsApplied(for: date)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delete a mood entry for a specific date with all associated cleanup.
|
||||||
|
/// Replaces the entry with a .missing placeholder and cleans up HealthKit/Live Activity state.
|
||||||
|
///
|
||||||
|
/// - Parameter date: The date of the entry to delete
|
||||||
|
func deleteMood(forDate date: Date) {
|
||||||
|
// 1. Delete all entries for this date and replace with missing placeholder
|
||||||
|
DataController.shared.deleteAllEntries(forDate: date)
|
||||||
|
DataController.shared.add(mood: .missing, forDate: date, entryType: .filledInMissing)
|
||||||
|
|
||||||
|
Self.logger.info("Deleted mood entry for \(date)")
|
||||||
|
|
||||||
|
// 2. Delete HealthKit entry if enabled and user has access
|
||||||
|
let healthKitEnabled = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.healthKitEnabled.rawValue)
|
||||||
|
let hasAccess = !IAPManager.shared.shouldShowPaywall
|
||||||
|
if healthKitEnabled && hasAccess {
|
||||||
|
Task {
|
||||||
|
try? await HealthKitManager.shared.deleteMood(for: date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Recalculate streak and update Live Activity
|
||||||
|
let streak = calculateCurrentStreak()
|
||||||
|
LiveActivityManager.shared.updateActivity(streak: streak, mood: .missing)
|
||||||
|
LiveActivityScheduler.shared.invalidateCache()
|
||||||
|
LiveActivityScheduler.shared.scheduleBasedOnCurrentTime()
|
||||||
|
|
||||||
|
// 4. Reload widgets
|
||||||
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
|
|
||||||
|
// 5. Notify watch to refresh
|
||||||
|
WatchConnectivityManager.shared.notifyWatchToReload()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete all mood data with full cleanup of HealthKit and Live Activity state.
|
||||||
|
/// Used when user clears all data from settings.
|
||||||
|
func deleteAllData() {
|
||||||
|
// 1. Clear all entries from the database
|
||||||
|
DataController.shared.clearDB()
|
||||||
|
|
||||||
|
Self.logger.info("Cleared all mood data")
|
||||||
|
|
||||||
|
// 2. Delete all HealthKit entries if enabled and user has access
|
||||||
|
let healthKitEnabled = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.healthKitEnabled.rawValue)
|
||||||
|
let hasAccess = !IAPManager.shared.shouldShowPaywall
|
||||||
|
if healthKitEnabled && hasAccess {
|
||||||
|
Task {
|
||||||
|
try? await HealthKitManager.shared.deleteAllMoods()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. End all Live Activities
|
||||||
|
Task {
|
||||||
|
await LiveActivityManager.shared.endAllActivities()
|
||||||
|
}
|
||||||
|
LiveActivityScheduler.shared.invalidateCache()
|
||||||
|
|
||||||
|
// 4. Reload widgets
|
||||||
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
|
|
||||||
|
// 5. Notify watch to refresh
|
||||||
|
WatchConnectivityManager.shared.notifyWatchToReload()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update an existing mood entry with all associated side effects.
|
||||||
|
/// Centralizes the update logic that was previously split between ViewModel and DataController.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - entryDate: The date of the entry to update
|
||||||
|
/// - mood: The new mood value
|
||||||
|
/// - Returns: Whether the update was successful
|
||||||
|
@discardableResult
|
||||||
|
func updateMood(entryDate: Date, withMood mood: Mood) -> Bool {
|
||||||
|
let success = DataController.shared.update(entryDate: entryDate, withMood: mood)
|
||||||
|
guard success else {
|
||||||
|
Self.logger.error("Failed to update mood entry for \(entryDate)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip side effects for placeholder/missing moods
|
||||||
|
guard mood != .missing && mood != .placeholder else { return true }
|
||||||
|
|
||||||
|
// Sync to HealthKit if enabled and user has full access
|
||||||
|
let healthKitEnabled = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.healthKitEnabled.rawValue)
|
||||||
|
let hasAccess = !IAPManager.shared.shouldShowPaywall
|
||||||
|
if healthKitEnabled && hasAccess {
|
||||||
|
Task {
|
||||||
|
try? await HealthKitManager.shared.saveMood(mood, for: entryDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload widgets
|
||||||
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
|
|
||||||
|
// Recalculate streak and update Live Activity
|
||||||
|
let streak = calculateCurrentStreak()
|
||||||
|
LiveActivityManager.shared.updateActivity(streak: streak, mood: mood)
|
||||||
|
LiveActivityScheduler.shared.invalidateCache()
|
||||||
|
|
||||||
|
// Notify watch to refresh
|
||||||
|
WatchConnectivityManager.shared.notifyWatchToReload()
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
/// Check for and process any pending side effects from widget/extension votes.
|
/// Check for and process any pending side effects from widget/extension votes.
|
||||||
/// Call this when the app becomes active to ensure all side effects are applied.
|
/// Call this when the app becomes active to ensure all side effects are applied.
|
||||||
func processPendingSideEffects() {
|
func processPendingSideEffects() {
|
||||||
@@ -127,6 +231,19 @@ final class MoodLogger {
|
|||||||
applySideEffects(mood: entry.mood, for: entry.forDate)
|
applySideEffects(mood: entry.mood, for: entry.forDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Import mood entries in batch with a single round of side effects.
|
||||||
|
/// Used for CSV import to avoid O(n) widget reloads, streak calcs, etc.
|
||||||
|
func importMoods(_ entries: [(mood: Mood, date: Date, entryType: EntryType)]) {
|
||||||
|
guard !entries.isEmpty else { return }
|
||||||
|
|
||||||
|
DataController.shared.addBatch(entries: entries)
|
||||||
|
|
||||||
|
// Single round of side effects
|
||||||
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
|
WatchConnectivityManager.shared.notifyWatchToReload()
|
||||||
|
LiveActivityScheduler.shared.invalidateCache()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Side Effects Tracking
|
// MARK: - Side Effects Tracking
|
||||||
|
|
||||||
/// Mark that side effects have been applied for a given date
|
/// Mark that side effects have been applied for a given date
|
||||||
|
|||||||
@@ -52,19 +52,32 @@ final class DataController: ObservableObject {
|
|||||||
editedDataClosure.append(closure)
|
editedDataClosure.append(closure)
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveAndRunDataListeners() {
|
@discardableResult
|
||||||
save()
|
func saveAndRunDataListeners() -> Bool {
|
||||||
for closure in editedDataClosure {
|
let success = save()
|
||||||
closure()
|
if success {
|
||||||
|
for closure in editedDataClosure {
|
||||||
|
closure()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return success
|
||||||
}
|
}
|
||||||
|
|
||||||
func save() {
|
@discardableResult
|
||||||
guard modelContext.hasChanges else { return }
|
func save() -> Bool {
|
||||||
|
guard modelContext.hasChanges else { return true }
|
||||||
do {
|
do {
|
||||||
try modelContext.save()
|
try modelContext.save()
|
||||||
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
Self.logger.error("Failed to save context: \(error.localizedDescription)")
|
Self.logger.error("Failed to save context, retrying: \(error.localizedDescription)")
|
||||||
|
do {
|
||||||
|
try modelContext.save()
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
Self.logger.critical("Failed to save context after retry: \(error.localizedDescription)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,16 +7,24 @@
|
|||||||
|
|
||||||
import SwiftData
|
import SwiftData
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import os.log
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.tt.feels", category: "DataControllerADD")
|
||||||
|
|
||||||
extension DataController {
|
extension DataController {
|
||||||
func add(mood: Mood, forDate date: Date, entryType: EntryType) {
|
func add(mood: Mood, forDate date: Date, entryType: EntryType) {
|
||||||
// Delete ALL existing entries for this date (handles duplicates)
|
// Delete ALL existing entries for this date (handles duplicates)
|
||||||
let existing = getAllEntries(byDate: date)
|
let existing = getAllEntries(byDate: date)
|
||||||
for entry in existing {
|
for entry in existing {
|
||||||
|
cleanupPhotoIfNeeded(for: entry)
|
||||||
modelContext.delete(entry)
|
modelContext.delete(entry)
|
||||||
}
|
}
|
||||||
if !existing.isEmpty {
|
if !existing.isEmpty {
|
||||||
try? modelContext.save()
|
do {
|
||||||
|
try modelContext.save()
|
||||||
|
} catch {
|
||||||
|
logger.error("Failed to save after deleting existing entries: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let entry = MoodEntryModel(
|
let entry = MoodEntryModel(
|
||||||
@@ -34,21 +42,32 @@ extension DataController {
|
|||||||
let currentOnboarding = UserDefaultsStore.getOnboarding()
|
let currentOnboarding = UserDefaultsStore.getOnboarding()
|
||||||
var endDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: currentOnboarding)
|
var endDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: currentOnboarding)
|
||||||
// Since it's for views, take away the last date so vote is enabled
|
// Since it's for views, take away the last date so vote is enabled
|
||||||
endDate = Calendar.current.date(byAdding: .day, value: -1, to: endDate)!
|
guard let adjustedEndDate = Calendar.current.date(byAdding: .day, value: -1, to: endDate) else {
|
||||||
|
logger.error("Failed to calculate adjusted end date")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
endDate = adjustedEndDate
|
||||||
|
|
||||||
let descriptor = FetchDescriptor<MoodEntryModel>(
|
let descriptor = FetchDescriptor<MoodEntryModel>(
|
||||||
sortBy: [SortDescriptor(\.forDate, order: .reverse)]
|
sortBy: [SortDescriptor(\.forDate, order: .reverse)]
|
||||||
)
|
)
|
||||||
|
|
||||||
guard let entries = try? modelContext.fetch(descriptor),
|
let entries: [MoodEntryModel]
|
||||||
let firstEntry = entries.last else { return }
|
do {
|
||||||
|
entries = try modelContext.fetch(descriptor)
|
||||||
let allDates: [Date] = Date.dates(from: firstEntry.forDate, toDate: endDate, includingToDate: true).map {
|
} catch {
|
||||||
Calendar.current.date(bySettingHour: 0, minute: 0, second: 0, of: $0)!
|
logger.error("Failed to fetch entries for fill-in: \(error.localizedDescription)")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let existingDates: Set<Date> = Set(entries.map {
|
guard let firstEntry = entries.last else { return }
|
||||||
Calendar.current.date(bySettingHour: 0, minute: 0, second: 0, of: $0.forDate)!
|
|
||||||
|
let allDates: [Date] = Date.dates(from: firstEntry.forDate, toDate: endDate, includingToDate: true).compactMap {
|
||||||
|
Calendar.current.date(bySettingHour: 0, minute: 0, second: 0, of: $0)
|
||||||
|
}
|
||||||
|
|
||||||
|
let existingDates: Set<Date> = Set(entries.compactMap {
|
||||||
|
Calendar.current.date(bySettingHour: 0, minute: 0, second: 0, of: $0.forDate)
|
||||||
})
|
})
|
||||||
|
|
||||||
let missing = Array(Set(allDates).subtracting(existingDates)).sorted(by: >)
|
let missing = Array(Set(allDates).subtracting(existingDates)).sorted(by: >)
|
||||||
@@ -58,7 +77,10 @@ extension DataController {
|
|||||||
// Batch insert all missing dates without triggering listeners
|
// Batch insert all missing dates without triggering listeners
|
||||||
for date in missing {
|
for date in missing {
|
||||||
// Add 12 hours to avoid UTC offset issues
|
// Add 12 hours to avoid UTC offset issues
|
||||||
let adjustedDate = Calendar.current.date(byAdding: .hour, value: 12, to: date)!
|
guard let adjustedDate = Calendar.current.date(byAdding: .hour, value: 12, to: date) else {
|
||||||
|
logger.error("Failed to calculate adjusted date for \(date)")
|
||||||
|
continue
|
||||||
|
}
|
||||||
let entry = MoodEntryModel(
|
let entry = MoodEntryModel(
|
||||||
forDate: adjustedDate,
|
forDate: adjustedDate,
|
||||||
mood: .missing,
|
mood: .missing,
|
||||||
@@ -77,11 +99,26 @@ extension DataController {
|
|||||||
for entry in data {
|
for entry in data {
|
||||||
entry.weekDay = Calendar.current.component(.weekday, from: entry.forDate)
|
entry.weekDay = Calendar.current.component(.weekday, from: entry.forDate)
|
||||||
}
|
}
|
||||||
save()
|
saveAndRunDataListeners()
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeNoForDates() {
|
func removeNoForDates() {
|
||||||
// Note: With SwiftData's non-optional forDate, this is essentially a no-op
|
// Note: With SwiftData's non-optional forDate, this is essentially a no-op
|
||||||
// Keeping for API compatibility
|
// Keeping for API compatibility
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Batch insert mood entries without per-entry analytics or listener notifications.
|
||||||
|
/// Used for CSV import where side effects should fire once at the end.
|
||||||
|
func addBatch(entries: [(mood: Mood, date: Date, entryType: EntryType)]) {
|
||||||
|
for (mood, date, entryType) in entries {
|
||||||
|
let existing = getAllEntries(byDate: date)
|
||||||
|
for entry in existing {
|
||||||
|
cleanupPhotoIfNeeded(for: entry)
|
||||||
|
modelContext.delete(entry)
|
||||||
|
}
|
||||||
|
let entry = MoodEntryModel(forDate: date, mood: mood, entryType: entryType)
|
||||||
|
modelContext.insert(entry)
|
||||||
|
}
|
||||||
|
saveAndRunDataListeners()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,32 @@ import Foundation
|
|||||||
import os.log
|
import os.log
|
||||||
|
|
||||||
extension DataController {
|
extension DataController {
|
||||||
|
|
||||||
|
// MARK: - Photo Cleanup
|
||||||
|
|
||||||
|
func cleanupPhotoIfNeeded(for entry: MoodEntryModel) {
|
||||||
|
if let photoID = entry.photoID {
|
||||||
|
if !PhotoManager.shared.deletePhoto(id: photoID) {
|
||||||
|
AppLogger.general.warning("Failed to delete orphaned photo \(photoID.uuidString) for entry on \(entry.forDate)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Delete Operations
|
||||||
|
|
||||||
func clearDB() {
|
func clearDB() {
|
||||||
do {
|
do {
|
||||||
|
// Clean up photo files before batch delete
|
||||||
|
let descriptor = FetchDescriptor<MoodEntryModel>()
|
||||||
|
if let allEntries = try? modelContext.fetch(descriptor) {
|
||||||
|
for entry in allEntries {
|
||||||
|
cleanupPhotoIfNeeded(for: entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try modelContext.delete(model: MoodEntryModel.self)
|
try modelContext.delete(model: MoodEntryModel.self)
|
||||||
saveAndRunDataListeners()
|
saveAndRunDataListeners()
|
||||||
|
AnalyticsManager.shared.track(.allDataCleared)
|
||||||
} catch {
|
} catch {
|
||||||
AppLogger.general.error("Failed to clear database: \(error.localizedDescription)")
|
AppLogger.general.error("Failed to clear database: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
@@ -24,9 +46,10 @@ extension DataController {
|
|||||||
let entries = getData(startDate: startDate, endDate: Date(), includedDays: [])
|
let entries = getData(startDate: startDate, endDate: Date(), includedDays: [])
|
||||||
|
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
|
cleanupPhotoIfNeeded(for: entry)
|
||||||
modelContext.delete(entry)
|
modelContext.delete(entry)
|
||||||
}
|
}
|
||||||
save()
|
saveAndRunDataListeners()
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteRandomFromLast(numberOfEntries: Int) {
|
func deleteRandomFromLast(numberOfEntries: Int) {
|
||||||
@@ -34,9 +57,10 @@ extension DataController {
|
|||||||
let entries = getData(startDate: startDate, endDate: Date(), includedDays: [])
|
let entries = getData(startDate: startDate, endDate: Date(), includedDays: [])
|
||||||
|
|
||||||
for entry in entries where Bool.random() {
|
for entry in entries where Bool.random() {
|
||||||
|
cleanupPhotoIfNeeded(for: entry)
|
||||||
modelContext.delete(entry)
|
modelContext.delete(entry)
|
||||||
}
|
}
|
||||||
save()
|
saveAndRunDataListeners()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get ALL entries for a specific date (not just the first one)
|
/// Get ALL entries for a specific date (not just the first one)
|
||||||
@@ -46,7 +70,7 @@ extension DataController {
|
|||||||
|
|
||||||
let descriptor = FetchDescriptor<MoodEntryModel>(
|
let descriptor = FetchDescriptor<MoodEntryModel>(
|
||||||
predicate: #Predicate { entry in
|
predicate: #Predicate { entry in
|
||||||
entry.forDate >= startDate && entry.forDate <= endDate
|
entry.forDate >= startDate && entry.forDate < endDate
|
||||||
},
|
},
|
||||||
sortBy: [SortDescriptor(\.forDate, order: .forward)]
|
sortBy: [SortDescriptor(\.forDate, order: .forward)]
|
||||||
)
|
)
|
||||||
@@ -58,9 +82,10 @@ extension DataController {
|
|||||||
func deleteAllEntries(forDate date: Date) {
|
func deleteAllEntries(forDate date: Date) {
|
||||||
let entries = getAllEntries(byDate: date)
|
let entries = getAllEntries(byDate: date)
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
|
cleanupPhotoIfNeeded(for: entry)
|
||||||
modelContext.delete(entry)
|
modelContext.delete(entry)
|
||||||
}
|
}
|
||||||
save()
|
saveAndRunDataListeners()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find and remove duplicate entries, keeping only the most recent for each date
|
/// Find and remove duplicate entries, keeping only the most recent for each date
|
||||||
@@ -92,6 +117,7 @@ extension DataController {
|
|||||||
|
|
||||||
// Keep the first (best) entry, delete the rest
|
// Keep the first (best) entry, delete the rest
|
||||||
for entry in sorted.dropFirst() {
|
for entry in sorted.dropFirst() {
|
||||||
|
cleanupPhotoIfNeeded(for: entry)
|
||||||
modelContext.delete(entry)
|
modelContext.delete(entry)
|
||||||
duplicatesRemoved += 1
|
duplicatesRemoved += 1
|
||||||
}
|
}
|
||||||
@@ -100,6 +126,7 @@ extension DataController {
|
|||||||
if duplicatesRemoved > 0 {
|
if duplicatesRemoved > 0 {
|
||||||
saveAndRunDataListeners()
|
saveAndRunDataListeners()
|
||||||
AppLogger.general.info("Removed \(duplicatesRemoved) duplicate entries")
|
AppLogger.general.info("Removed \(duplicatesRemoved) duplicate entries")
|
||||||
|
AnalyticsManager.shared.track(.duplicatesRemoved(count: duplicatesRemoved))
|
||||||
}
|
}
|
||||||
|
|
||||||
return duplicatesRemoved
|
return duplicatesRemoved
|
||||||
|
|||||||
@@ -70,10 +70,12 @@ protocol MoodDataDeleting {
|
|||||||
@MainActor
|
@MainActor
|
||||||
protocol MoodDataPersisting {
|
protocol MoodDataPersisting {
|
||||||
/// Save pending changes
|
/// Save pending changes
|
||||||
func save()
|
@discardableResult
|
||||||
|
func save() -> Bool
|
||||||
|
|
||||||
/// Save and notify listeners
|
/// Save and notify listeners
|
||||||
func saveAndRunDataListeners()
|
@discardableResult
|
||||||
|
func saveAndRunDataListeners() -> Bool
|
||||||
|
|
||||||
/// Add a listener for data changes
|
/// Add a listener for data changes
|
||||||
func addNewDataListener(closure: @escaping (() -> Void))
|
func addNewDataListener(closure: @escaping (() -> Void))
|
||||||
|
|||||||
@@ -73,15 +73,37 @@ extension VoteMoodIntent: ForegroundContinuableIntent {}
|
|||||||
#if WIDGET_EXTENSION
|
#if WIDGET_EXTENSION
|
||||||
enum WidgetMoodSaver {
|
enum WidgetMoodSaver {
|
||||||
private static let logger = Logger(subsystem: "com.tt.feels.widget", category: "WidgetMoodSaver")
|
private static let logger = Logger(subsystem: "com.tt.feels.widget", category: "WidgetMoodSaver")
|
||||||
|
private static var cachedContainer: ModelContainer?
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
static func save(mood: Mood, date: Date) {
|
static func save(mood: Mood, date: Date) {
|
||||||
|
do {
|
||||||
|
let container = try getOrCreateContainer()
|
||||||
|
try performSave(mood: mood, date: date, container: container)
|
||||||
|
} catch {
|
||||||
|
// Container may be stale or corrupted — discard cache and retry once
|
||||||
|
logger.warning("First save attempt failed, retrying with fresh container: \(error.localizedDescription)")
|
||||||
|
cachedContainer = nil
|
||||||
|
do {
|
||||||
|
let container = try getOrCreateContainer()
|
||||||
|
try performSave(mood: mood, date: date, container: container)
|
||||||
|
} catch {
|
||||||
|
logger.error("Failed to save mood after retry: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func getOrCreateContainer() throws -> ModelContainer {
|
||||||
|
if let existing = cachedContainer {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
let schema = Schema([MoodEntryModel.self])
|
let schema = Schema([MoodEntryModel.self])
|
||||||
let appGroupID = Constants.currentGroupShareId
|
let appGroupID = Constants.currentGroupShareId
|
||||||
|
|
||||||
guard let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupID) else {
|
guard let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupID) else {
|
||||||
logger.error("App Group not available")
|
logger.error("App Group not available")
|
||||||
return
|
throw WidgetMoodSaverError.appGroupUnavailable
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
@@ -90,34 +112,55 @@ enum WidgetMoodSaver {
|
|||||||
let storeURL = containerURL.appendingPathComponent("Feels.store")
|
let storeURL = containerURL.appendingPathComponent("Feels.store")
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
do {
|
let config = ModelConfiguration(schema: schema, url: storeURL, cloudKitDatabase: .none)
|
||||||
let config = ModelConfiguration(schema: schema, url: storeURL, cloudKitDatabase: .none)
|
let container = try ModelContainer(for: schema, configurations: [config])
|
||||||
let container = try ModelContainer(for: schema, configurations: [config])
|
cachedContainer = container
|
||||||
let context = container.mainContext
|
return container
|
||||||
|
}
|
||||||
|
|
||||||
// Delete existing entry for this date
|
@MainActor
|
||||||
let startDate = Calendar.current.startOfDay(for: date)
|
private static func performSave(mood: Mood, date: Date, container: ModelContainer) throws {
|
||||||
let endDate = Calendar.current.date(byAdding: .day, value: 1, to: startDate)!
|
let context = container.mainContext
|
||||||
|
|
||||||
var descriptor = FetchDescriptor<MoodEntryModel>(
|
// Delete existing entry for this date
|
||||||
predicate: #Predicate { entry in
|
let startDate = Calendar.current.startOfDay(for: date)
|
||||||
entry.forDate >= startDate && entry.forDate <= endDate
|
let endDate = Calendar.current.date(byAdding: .day, value: 1, to: startDate)!
|
||||||
}
|
|
||||||
)
|
|
||||||
descriptor.fetchLimit = 1
|
|
||||||
|
|
||||||
if let existing = try? context.fetch(descriptor).first {
|
let descriptor = FetchDescriptor<MoodEntryModel>(
|
||||||
context.delete(existing)
|
predicate: #Predicate { entry in
|
||||||
try? context.save()
|
entry.forDate >= startDate && entry.forDate < endDate
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Create new entry
|
let existing = try context.fetch(descriptor)
|
||||||
let entry = MoodEntryModel(forDate: date, mood: mood, entryType: .widget)
|
if !existing.isEmpty {
|
||||||
context.insert(entry)
|
for entry in existing {
|
||||||
|
context.delete(entry)
|
||||||
|
}
|
||||||
try context.save()
|
try context.save()
|
||||||
logger.info("Saved mood \(mood.rawValue) from widget")
|
}
|
||||||
} catch {
|
|
||||||
logger.error("Failed to save mood: \(error.localizedDescription)")
|
// Create new entry
|
||||||
|
let entry = MoodEntryModel(forDate: date, mood: mood, entryType: .widget)
|
||||||
|
context.insert(entry)
|
||||||
|
try context.save()
|
||||||
|
logger.info("Saved mood \(mood.rawValue) from widget")
|
||||||
|
|
||||||
|
// Note: The widget cannot run full side effects (HealthKit, streaks, analytics, etc.)
|
||||||
|
// because it runs in a separate extension process without access to MoodLogger.
|
||||||
|
// When the main app returns to the foreground, it calls
|
||||||
|
// MoodLogger.shared.processPendingSideEffects() to catch up on any side effects
|
||||||
|
// that were missed from widget or watch entries.
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WidgetMoodSaverError: LocalizedError {
|
||||||
|
case appGroupUnavailable
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .appGroupUnavailable:
|
||||||
|
return "App Group container is not available"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -775,7 +775,7 @@ struct SettingsContentView: View {
|
|||||||
ZStack {
|
ZStack {
|
||||||
theme.currentTheme.secondaryBGColor
|
theme.currentTheme.secondaryBGColor
|
||||||
Button {
|
Button {
|
||||||
DataController.shared.clearDB()
|
MoodLogger.shared.deleteAllData()
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Image(systemName: "trash")
|
Image(systemName: "trash")
|
||||||
@@ -1347,6 +1347,7 @@ struct SettingsView: View {
|
|||||||
var rows = input.components(separatedBy: "\n")
|
var rows = input.components(separatedBy: "\n")
|
||||||
guard !rows.isEmpty else { return }
|
guard !rows.isEmpty else { return }
|
||||||
rows.removeFirst()
|
rows.removeFirst()
|
||||||
|
var importEntries: [(mood: Mood, date: Date, entryType: EntryType)] = []
|
||||||
for row in rows {
|
for row in rows {
|
||||||
let stripped = row.replacingOccurrences(of: " +0000", with: "")
|
let stripped = row.replacingOccurrences(of: " +0000", with: "")
|
||||||
let columns = stripped.components(separatedBy: ",")
|
let columns = stripped.components(separatedBy: ",")
|
||||||
@@ -1359,10 +1360,9 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
let mood = Mood(rawValue: moodValue) ?? .missing
|
let mood = Mood(rawValue: moodValue) ?? .missing
|
||||||
let entryType = EntryType(rawValue: Int(columns[2]) ?? 0) ?? .listView
|
let entryType = EntryType(rawValue: Int(columns[2]) ?? 0) ?? .listView
|
||||||
|
importEntries.append((mood: mood, date: forDate, entryType: entryType))
|
||||||
DataController.shared.add(mood: mood, forDate: forDate, entryType: entryType)
|
|
||||||
}
|
}
|
||||||
DataController.shared.saveAndRunDataListeners()
|
MoodLogger.shared.importMoods(importEntries)
|
||||||
AnalyticsManager.shared.track(.importSucceeded)
|
AnalyticsManager.shared.track(.importSucceeded)
|
||||||
} else {
|
} else {
|
||||||
AnalyticsManager.shared.track(.importFailed(error: nil))
|
AnalyticsManager.shared.track(.importFailed(error: nil))
|
||||||
@@ -1743,7 +1743,7 @@ struct SettingsView: View {
|
|||||||
ZStack {
|
ZStack {
|
||||||
theme.currentTheme.secondaryBGColor
|
theme.currentTheme.secondaryBGColor
|
||||||
Button(action: {
|
Button(action: {
|
||||||
DataController.shared.clearDB()
|
MoodLogger.shared.deleteAllData()
|
||||||
}, label: {
|
}, label: {
|
||||||
Text("Clear DB")
|
Text("Clear DB")
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
|
|||||||
Reference in New Issue
Block a user