diff --git a/Shared/FeelsApp.swift b/Shared/FeelsApp.swift index f251932..ad14be3 100644 --- a/Shared/FeelsApp.swift +++ b/Shared/FeelsApp.swift @@ -106,7 +106,7 @@ struct FeelsApp: App { } // Defer all non-critical foreground work to avoid blocking UI - Task.detached(priority: .utility) { @MainActor in + Task(priority: .utility) { // Refresh from disk to pick up widget/watch changes DataController.shared.refreshFromDisk() @@ -124,17 +124,17 @@ struct FeelsApp: App { } // Defer Live Activity scheduling (heavy DB operations) - Task.detached(priority: .utility) { + Task(priority: .utility) { await LiveActivityScheduler.shared.scheduleBasedOnCurrentTime() } // Catch up on side effects from widget/watch votes - Task.detached(priority: .utility) { + Task(priority: .utility) { await MoodLogger.shared.processPendingSideEffects() } // Check subscription status (network call) - throttled - Task.detached(priority: .background) { + Task(priority: .background) { await iapManager.checkSubscriptionStatus() await iapManager.trackSubscriptionAnalytics(source: "app_foreground") } diff --git a/Shared/MoodLogger.swift b/Shared/MoodLogger.swift index 38c591c..7aeca10 100644 --- a/Shared/MoodLogger.swift +++ b/Shared/MoodLogger.swift @@ -19,6 +19,7 @@ final class MoodLogger { /// Key for tracking the last date side effects were applied private static let lastSideEffectsDateKey = "lastSideEffectsAppliedDate" + private static let sideEffectsDateFormatter = ISO8601DateFormatter() private init() {} @@ -248,14 +249,14 @@ final class MoodLogger { /// Mark that side effects have been applied for a given date private func markSideEffectsApplied(for date: Date) { - let dateString = ISO8601DateFormatter().string(from: Calendar.current.startOfDay(for: date)) + let dateString = Self.sideEffectsDateFormatter.string(from: Calendar.current.startOfDay(for: date)) GroupUserDefaults.groupDefaults.set(dateString, forKey: Self.lastSideEffectsDateKey) } /// Check if side effects have been applied for a given date private func sideEffectsApplied(for date: Date) -> Bool { guard let lastDateString = GroupUserDefaults.groupDefaults.string(forKey: Self.lastSideEffectsDateKey), - let lastDate = ISO8601DateFormatter().date(from: lastDateString) else { + let lastDate = Self.sideEffectsDateFormatter.date(from: lastDateString) else { return false } diff --git a/Shared/Persisence/DataController.swift b/Shared/Persisence/DataController.swift index 190e364..250dcae 100644 --- a/Shared/Persisence/DataController.swift +++ b/Shared/Persisence/DataController.swift @@ -22,7 +22,8 @@ final class DataController: ObservableObject { // Listeners for data changes (keeping existing pattern) - private var editedDataClosure = [() -> Void]() + typealias DataListenerToken = UUID + private var dataListeners: [DataListenerToken: () -> Void] = [:] // Computed properties for earliest/latest entries var earliestEntry: MoodEntryModel? { @@ -48,15 +49,22 @@ final class DataController: ObservableObject { // MARK: - Listener Management - func addNewDataListener(closure: @escaping (() -> Void)) { - editedDataClosure.append(closure) + @discardableResult + func addNewDataListener(closure: @escaping (() -> Void)) -> DataListenerToken { + let token = DataListenerToken() + dataListeners[token] = closure + return token + } + + func removeDataListener(token: DataListenerToken) { + dataListeners.removeValue(forKey: token) } @discardableResult func saveAndRunDataListeners() -> Bool { let success = save() if success { - for closure in editedDataClosure { + for closure in dataListeners.values { closure() } } @@ -91,7 +99,7 @@ final class DataController: ObservableObject { modelContext.rollback() // Notify listeners to re-fetch their data - for closure in editedDataClosure { + for closure in dataListeners.values { closure() } diff --git a/Shared/Persisence/DataControllerProtocol.swift b/Shared/Persisence/DataControllerProtocol.swift index ccd80be..e3825a7 100644 --- a/Shared/Persisence/DataControllerProtocol.swift +++ b/Shared/Persisence/DataControllerProtocol.swift @@ -77,8 +77,12 @@ protocol MoodDataPersisting { @discardableResult func saveAndRunDataListeners() -> Bool - /// Add a listener for data changes - func addNewDataListener(closure: @escaping (() -> Void)) + /// Add a listener for data changes, returns a token for removal + @discardableResult + func addNewDataListener(closure: @escaping (() -> Void)) -> DataController.DataListenerToken + + /// Remove a previously registered data listener + func removeDataListener(token: DataController.DataListenerToken) } /// Combined protocol for full data controller functionality diff --git a/Shared/Services/FoundationModelsInsightService.swift b/Shared/Services/FoundationModelsInsightService.swift index d5eac92..c7a8b4c 100644 --- a/Shared/Services/FoundationModelsInsightService.swift +++ b/Shared/Services/FoundationModelsInsightService.swift @@ -47,6 +47,7 @@ class FoundationModelsInsightService: ObservableObject { private var cachedInsights: [String: (insights: [Insight], timestamp: Date)] = [:] private let cacheValidityDuration: TimeInterval = 3600 // 1 hour + private var inProgressPeriods: Set = [] // MARK: - Initialization @@ -199,6 +200,14 @@ class FoundationModelsInsightService: ObservableObject { return cached.insights } + // Prevent duplicate concurrent generation for the same period + guard !inProgressPeriods.contains(periodName) else { + // Already generating for this period, wait for cache + return cachedInsights[periodName]?.insights ?? [] + } + inProgressPeriods.insert(periodName) + defer { inProgressPeriods.remove(periodName) } + guard isAvailable else { throw InsightGenerationError.modelUnavailable(reason: lastError?.localizedDescription ?? "Model not available") } diff --git a/Shared/Services/ImageCache.swift b/Shared/Services/ImageCache.swift index ce4e554..80a4c09 100644 --- a/Shared/Services/ImageCache.swift +++ b/Shared/Services/ImageCache.swift @@ -15,13 +15,15 @@ final class ImageCache { private let cache = NSCache() private let queue = DispatchQueue(label: "com.tt.feels.imagecache", qos: .userInitiated) + private var memoryWarningToken: NSObjectProtocol? + private init() { // Configure cache limits cache.countLimit = 100 // Max 100 images cache.totalCostLimit = 50 * 1024 * 1024 // 50 MB max // Clear cache on memory warning - NotificationCenter.default.addObserver( + memoryWarningToken = NotificationCenter.default.addObserver( forName: UIApplication.didReceiveMemoryWarningNotification, object: nil, queue: .main @@ -30,6 +32,12 @@ final class ImageCache { } } + deinit { + if let token = memoryWarningToken { + NotificationCenter.default.removeObserver(token) + } + } + // MARK: - Public API /// Get image from cache diff --git a/Shared/Services/WatchConnectivityManager.swift b/Shared/Services/WatchConnectivityManager.swift index d7e5b67..43afc94 100644 --- a/Shared/Services/WatchConnectivityManager.swift +++ b/Shared/Services/WatchConnectivityManager.swift @@ -153,35 +153,32 @@ extension WatchConnectivityManager: WCSessionDelegate { } private func handleReceivedMessage(_ message: [String: Any]) { - guard let action = message["action"] as? String else { - Self.logger.error("No action in message") - return - } - - switch action { - case "logMood": - guard let moodRaw = message["mood"] as? Int, - let mood = Mood(rawValue: moodRaw), - let timestamp = message["date"] as? TimeInterval else { - Self.logger.error("Invalid mood message format") + Task { @MainActor in + guard let action = message["action"] as? String else { + Self.logger.error("No action in message") return } - let date = Date(timeIntervalSince1970: timestamp) - Self.logger.info("Processing mood \(moodRaw) from watch for \(date)") + switch action { + case "logMood": + guard let moodRaw = message["mood"] as? Int, + let mood = Mood(rawValue: moodRaw), + let timestamp = message["date"] as? TimeInterval else { + Self.logger.error("Invalid mood message format") + return + } - Task { @MainActor in + let date = Date(timeIntervalSince1970: timestamp) + Self.logger.info("Processing mood \(moodRaw) from watch for \(date)") MoodLogger.shared.logMood(mood, for: date, entryType: .watch) - } - case "reloadWidgets": - Self.logger.info("Received reloadWidgets from watch") - Task { @MainActor in + case "reloadWidgets": + Self.logger.info("Received reloadWidgets from watch") WidgetCenter.shared.reloadAllTimelines() - } - default: - Self.logger.warning("Unknown action: \(action)") + default: + Self.logger.warning("Unknown action: \(action)") + } } } #endif diff --git a/Shared/Utilities/AccessibilityHelpers.swift b/Shared/Utilities/AccessibilityHelpers.swift index e13000d..2792601 100644 --- a/Shared/Utilities/AccessibilityHelpers.swift +++ b/Shared/Utilities/AccessibilityHelpers.swift @@ -21,25 +21,7 @@ extension EnvironmentValues { } } -/// View modifier that respects reduce motion preference -struct ReduceMotionModifier: ViewModifier { - @Environment(\.accessibilityReduceMotion) var reduceMotion - - let animation: Animation? - let reducedAnimation: Animation? - - func body(content: Content) -> some View { - content - .animation(reduceMotion ? reducedAnimation : animation, value: UUID()) - } -} - extension View { - /// Applies animation only when reduce motion is disabled - func accessibleAnimation(_ animation: Animation? = .default, reduced: Animation? = nil) -> some View { - modifier(ReduceMotionModifier(animation: animation, reducedAnimation: reduced)) - } - /// Wraps content in withAnimation respecting reduce motion func withAccessibleAnimation(_ animation: Animation? = .default, value: V, action: @escaping () -> Void) -> some View { self.onChange(of: value) { _, _ in @@ -59,11 +41,10 @@ extension View { extension View { /// Adds accessibility label with optional hint func accessibleMoodCell(mood: Mood, date: Date) -> some View { - let formatter = DateFormatter() - formatter.dateStyle = .medium + let dateString = DateFormattingCache.shared.string(for: date, format: .dateMedium) return self - .accessibilityLabel("\(mood.strValue) on \(formatter.string(from: date))") + .accessibilityLabel("\(mood.strValue) on \(dateString)") .accessibilityHint("Double tap to edit mood") } diff --git a/Shared/Views/DayView/DayView.swift b/Shared/Views/DayView/DayView.swift index 78cee70..9eaad2c 100644 --- a/Shared/Views/DayView/DayView.swift +++ b/Shared/Views/DayView/DayView.swift @@ -95,11 +95,9 @@ struct DayView: View { } } - /// Cached sorted year/month data to avoid sorting dictionaries in ForEach + /// Sorted year/month data cached in ViewModel — avoids re-sorting on every render private var sortedGroupedData: [(year: Int, months: [(month: Int, entries: [MoodEntryModel])])] { - viewModel.grouped - .sorted { $0.key > $1.key } - .map { (year: $0.key, months: $0.value.sorted { $0.key > $1.key }.map { (month: $0.key, entries: $0.value) }) } + viewModel.sortedGroupedData } private var listView: some View { @@ -313,6 +311,7 @@ extension DayView { } } ) + .accessibilityIgnoresInvertColors(true) } private func inkSectionHeader(month: Int, year: Int) -> some View { diff --git a/Shared/Views/DayView/DayViewViewModel.swift b/Shared/Views/DayView/DayViewViewModel.swift index a4d33c3..fca5565 100644 --- a/Shared/Views/DayView/DayViewViewModel.swift +++ b/Shared/Views/DayView/DayViewViewModel.swift @@ -12,6 +12,7 @@ import SwiftData class DayViewViewModel: ObservableObject { @Published var grouped = [Int: [Int: [MoodEntryModel]]]() @Published var numberOfItems = 0 + @Published var sortedGroupedData: [(year: Int, months: [(month: Int, entries: [MoodEntryModel])])] = [] let addMonthStartWeekdayPadding: Bool @@ -28,10 +29,12 @@ class DayViewViewModel: ObservableObject { grouped.values.flatMap(\.values).reduce(0) { $0 + $1.count } } + private var dataListenerToken: DataController.DataListenerToken? + init(addMonthStartWeekdayPadding: Bool) { self.addMonthStartWeekdayPadding = addMonthStartWeekdayPadding - DataController.shared.addNewDataListener { [weak self] in + dataListenerToken = DataController.shared.addNewDataListener { [weak self] in guard let self = self else { return } // Avoid withAnimation for bulk data updates - it causes expensive view diffing self.updateData() @@ -39,6 +42,14 @@ class DayViewViewModel: ObservableObject { updateData() } + deinit { + if let token = dataListenerToken { + Task { @MainActor in + DataController.shared.removeDataListener(token: token) + } + } + } + private func getGroupedData(addMonthStartWeekdayPadding: Bool) { var newStuff = DataController.shared.splitIntoYearMonth(includedDays: [1,2,3,4,5,6,7]) if addMonthStartWeekdayPadding { @@ -50,6 +61,9 @@ class DayViewViewModel: ObservableObject { public func updateData() { getGroupedData(addMonthStartWeekdayPadding: self.addMonthStartWeekdayPadding) + sortedGroupedData = grouped + .sorted { $0.key > $1.key } + .map { (year: $0.key, months: $0.value.sorted { $0.key > $1.key }.map { (month: $0.key, entries: $0.value) }) } } public func add(mood: Mood, forDate date: Date, entryType: EntryType) { diff --git a/Shared/Views/EntryListView.swift b/Shared/Views/EntryListView.swift index 79a1fdf..2ddd9cc 100644 --- a/Shared/Views/EntryListView.swift +++ b/Shared/Views/EntryListView.swift @@ -2224,6 +2224,9 @@ struct MotionCardView: View { .onAppear { motionManager.startIfNeeded() } + .onDisappear { + motionManager.stopIfNoConsumers() + } } } @@ -2237,10 +2240,13 @@ class MotionManager: ObservableObject { @Published var yOffset: CGFloat = 0 private var isRunning = false + private var activeConsumers = 0 private init() {} func startIfNeeded() { + activeConsumers += 1 + guard !isRunning, motionManager.isDeviceMotionAvailable, !UIAccessibility.isReduceMotionEnabled else { return } @@ -2257,8 +2263,18 @@ class MotionManager: ObservableObject { } } + func stopIfNoConsumers() { + activeConsumers = max(0, activeConsumers - 1) + guard activeConsumers == 0, isRunning else { return } + isRunning = false + motionManager.stopDeviceMotionUpdates() + xOffset = 0 + yOffset = 0 + } + func stop() { guard isRunning else { return } + activeConsumers = 0 isRunning = false motionManager.stopDeviceMotionUpdates() } diff --git a/Shared/Views/InsightsView/InsightsView.swift b/Shared/Views/InsightsView/InsightsView.swift index 80e1149..ed0d690 100644 --- a/Shared/Views/InsightsView/InsightsView.swift +++ b/Shared/Views/InsightsView/InsightsView.swift @@ -237,6 +237,7 @@ struct InsightsSectionView: View { .padding(.vertical, 14) } .buttonStyle(.plain) + .accessibilityAddTraits(.isHeader) // Insights List (collapsible) if isExpanded { @@ -415,6 +416,7 @@ struct InsightCardView: View { RoundedRectangle(cornerRadius: 12) .fill(colorScheme == .dark ? Color(.systemGray5) : Color(.systemGray6)) ) + .accessibilityElement(children: .combine) } } diff --git a/Shared/Views/InsightsView/InsightsViewModel.swift b/Shared/Views/InsightsView/InsightsViewModel.swift index c529275..674cf30 100644 --- a/Shared/Views/InsightsView/InsightsViewModel.swift +++ b/Shared/Views/InsightsView/InsightsViewModel.swift @@ -49,14 +49,24 @@ class InsightsViewModel: ObservableObject { // MARK: - Initialization + private var dataListenerToken: DataController.DataListenerToken? + init() { isAIAvailable = insightService.isAvailable - DataController.shared.addNewDataListener { [weak self] in + dataListenerToken = DataController.shared.addNewDataListener { [weak self] in self?.onDataChanged() } } + deinit { + if let token = dataListenerToken { + Task { @MainActor in + DataController.shared.removeDataListener(token: token) + } + } + } + /// Called when mood data changes in another tab. Invalidates cached insights /// so they are regenerated with fresh data on next view appearance. private func onDataChanged() { diff --git a/Shared/Views/LockScreenView.swift b/Shared/Views/LockScreenView.swift index eb47c3a..47228c8 100644 --- a/Shared/Views/LockScreenView.swift +++ b/Shared/Views/LockScreenView.swift @@ -1616,10 +1616,12 @@ struct LockScreenView: View { ZStack { // Themed background backgroundView + .accessibilityHidden(true) // Floating particles (Aurora only) if lockScreenStyle == .aurora { FloatingParticlesView() + .accessibilityHidden(true) } // Main content @@ -1630,6 +1632,7 @@ struct LockScreenView: View { centralElement .opacity(showContent ? 1 : 0) .scaleEffect(showContent ? 1 : 0.8) + .accessibilityHidden(true) Spacer() .frame(height: 50) @@ -1649,6 +1652,7 @@ struct LockScreenView: View { .multilineTextAlignment(.center) .opacity(showContent ? 1 : 0) .offset(y: showContent ? 0 : 20) + .accessibilityElement(children: .combine) Spacer() .frame(height: 16) @@ -1666,6 +1670,8 @@ struct LockScreenView: View { .opacity(showContent ? 1 : 0) .offset(y: showContent ? 0 : 30) .padding(.horizontal, 32) + .accessibilityLabel("Unlock") + .accessibilityHint("Double tap to authenticate with \(authManager.biometricName)") // Passcode button if authManager.canUseDevicePasscode { @@ -1684,6 +1690,8 @@ struct LockScreenView: View { .disabled(authManager.isAuthenticating) .padding(.top, 16) .opacity(showContent ? 1 : 0) + .accessibilityLabel("Use device passcode") + .accessibilityHint("Double tap to authenticate with your device passcode") } Spacer() diff --git a/Shared/Views/MonthView/MonthView.swift b/Shared/Views/MonthView/MonthView.swift index 160b5c4..e1ccae7 100644 --- a/Shared/Views/MonthView/MonthView.swift +++ b/Shared/Views/MonthView/MonthView.swift @@ -44,12 +44,13 @@ struct MonthView: View { /// Cached sorted year/month data to avoid recalculating in ForEach @State private var cachedSortedData: [(year: Int, months: [(month: Int, entries: [MoodEntryModel])])] = [] + @State private var cachedDemoSortedData: [(year: Int, months: [(month: Int, entries: [MoodEntryModel])])] = [] // MARK: - Demo Animation @StateObject private var demoManager = DemoAnimationManager.shared /// Generate fake demo data for the past 12 months - private var demoSortedData: [(year: Int, months: [(month: Int, entries: [MoodEntryModel])])] { + private func computeDemoSortedData() -> [(year: Int, months: [(month: Int, entries: [MoodEntryModel])])] { var result: [(year: Int, months: [(month: Int, entries: [MoodEntryModel])])] = [] let calendar = Calendar.current let now = Date() @@ -158,7 +159,7 @@ struct MonthView: View { /// Data to display - uses demo data when in demo mode, otherwise cached real data private var displayData: [(year: Int, months: [(month: Int, entries: [MoodEntryModel])])] { - demoManager.isDemoMode ? demoSortedData : cachedSortedData + demoManager.isDemoMode ? cachedDemoSortedData : cachedSortedData } var body: some View { @@ -362,7 +363,7 @@ struct MonthView: View { } .onAppear { cachedSortedData = computeSortedYearMonthData() - // Demo mode is toggled manually via triple-tap + cachedDemoSortedData = computeDemoSortedData() } .onChange(of: viewModel.numberOfItems) { _, _ in // Use numberOfItems as a lightweight proxy for data changes @@ -588,6 +589,8 @@ struct MonthCard: View, Equatable { } } .buttonStyle(.plain) + .accessibilityLabel("\(Random.monthName(fromMonthInt: month)) \(String(year)), \(showStats ? "expanded" : "collapsed")") + .accessibilityHint("Double tap to toggle statistics") Spacer() @@ -599,6 +602,7 @@ struct MonthCard: View, Equatable { .foregroundColor(labelColor.opacity(0.6)) } .buttonStyle(.plain) + .accessibilityLabel("Share \(Random.monthName(fromMonthInt: month)) \(String(year)) mood data") } .padding(.horizontal, 16) .padding(.vertical, 12) @@ -753,6 +757,7 @@ struct DemoHeatmapCell: View { // Generate random mood once when cell appears randomMood = DemoAnimationManager.randomPositiveMood() } + .accessibilityLabel(accessibilityDescription) } /// Whether this cell has been animated (filled with color) @@ -784,6 +789,18 @@ struct DemoHeatmapCell: View { return moodTint.color(forMood: entry.mood) } } + + private var accessibilityDescription: String { + if entry.mood == .placeholder { + return "Empty day" + } else if entry.mood == .missing { + return "No mood logged for \(DateFormattingCache.shared.string(for: entry.forDate, format: .dateMedium))" + } else if !isFiltered { + return "\(DateFormattingCache.shared.string(for: entry.forDate, format: .dateMedium)): \(entry.mood.strValue) (filtered out)" + } else { + return "\(DateFormattingCache.shared.string(for: entry.forDate, format: .dateMedium)): \(entry.mood.strValue)" + } + } } // MARK: - Mini Bar Chart diff --git a/Shared/Views/SettingsView/SettingsView.swift b/Shared/Views/SettingsView/SettingsView.swift index e3e3b08..e5b4d9d 100644 --- a/Shared/Views/SettingsView/SettingsView.swift +++ b/Shared/Views/SettingsView/SettingsView.swift @@ -155,11 +155,15 @@ struct SettingsContentView: View { .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } - private var formattedReminderTime: String { - let onboardingData = UserDefaultsStore.getOnboarding() + private static let timeFormatter: DateFormatter = { let formatter = DateFormatter() formatter.timeStyle = .short - return formatter.string(from: onboardingData.date) + return formatter + }() + + private var formattedReminderTime: String { + let onboardingData = UserDefaultsStore.getOnboarding() + return Self.timeFormatter.string(from: onboardingData.date) } // MARK: - Section Headers diff --git a/Shared/Views/YearView/YearView.swift b/Shared/Views/YearView/YearView.swift index c0f3fdb..6eb62f5 100644 --- a/Shared/Views/YearView/YearView.swift +++ b/Shared/Views/YearView/YearView.swift @@ -26,6 +26,7 @@ struct YearView: View { /// Cached sorted year keys to avoid re-sorting in ForEach on every render @State private var cachedSortedYearKeys: [Int] = [] + @State private var cachedDemoYearData: [Int: [Int: [DayChartView]]] = [:] // MARK: - Demo Animation @StateObject private var demoManager = DemoAnimationManager.shared @@ -34,7 +35,7 @@ struct YearView: View { private let heatmapColumns = Array(repeating: GridItem(.flexible(), spacing: 2), count: 12) /// Generate demo year data for the past 3 years (full 12 months each) - private var demoYearData: [Int: [Int: [DayChartView]]] { + private func computeDemoYearData() -> [Int: [Int: [DayChartView]]] { var result: [Int: [Int: [DayChartView]]] = [:] let calendar = Calendar.current let currentYear = calendar.component(.year, from: Date()) @@ -107,7 +108,7 @@ struct YearView: View { /// Year keys to display - demo data or real data private var displayYearKeys: [Int] { if demoManager.isDemoMode { - return Array(demoYearData.keys.sorted(by: >)) + return Array(cachedDemoYearData.keys.sorted(by: >)) } return cachedSortedYearKeys } @@ -115,7 +116,7 @@ struct YearView: View { /// Year data for a specific year - demo or real private func yearDataFor(_ year: Int) -> [Int: [DayChartView]] { if demoManager.isDemoMode { - return demoYearData[year] ?? [:] + return cachedDemoYearData[year] ?? [:] } return viewModel.data[year] ?? [:] } @@ -282,7 +283,7 @@ struct YearView: View { .onAppear(perform: { self.viewModel.filterEntries(startDate: Date(timeIntervalSince1970: 0), endDate: Date()) cachedSortedYearKeys = Array(viewModel.data.keys.sorted(by: >)) - // Demo mode is toggled manually via triple-tap + cachedDemoYearData = computeDemoYearData() }) .onChange(of: viewModel.data.keys.count) { _, _ in cachedSortedYearKeys = Array(viewModel.data.keys.sorted(by: >)) @@ -787,6 +788,19 @@ struct DemoYearHeatmapCell: View { // Generate random mood once when cell appears randomMood = DemoAnimationManager.randomPositiveMood() } + .accessibilityLabel(accessibilityDescription) + } + + private var accessibilityDescription: String { + if !isFiltered { + return "Filtered out" + } else if color == Mood.placeholder.color { + return "Empty" + } else if color == Mood.missing.color { + return "No mood logged" + } else { + return "Mood entry" + } } /// Whether this cell has been animated (filled with color) diff --git a/Shared/Views/YearView/YearViewModel.swift b/Shared/Views/YearView/YearViewModel.swift index 8f9c4e7..6c83bc4 100644 --- a/Shared/Views/YearView/YearViewModel.swift +++ b/Shared/Views/YearView/YearViewModel.swift @@ -24,13 +24,23 @@ class YearViewModel: ObservableObject { } } + private var dataListenerToken: DataController.DataListenerToken? + init() { - DataController.shared.addNewDataListener { [weak self] in + dataListenerToken = DataController.shared.addNewDataListener { [weak self] in self?.refreshData() } updateData() } + deinit { + if let token = dataListenerToken { + Task { @MainActor in + DataController.shared.removeDataListener(token: token) + } + } + } + /// Re-fetch data using the current date range. Called by the data listener /// when mood entries change in other tabs. public func refreshData() {