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:
Trey t
2026-02-14 23:09:11 -06:00
parent 3023475f66
commit f1cd81c395
8 changed files with 306 additions and 55 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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