Fix 25 audit issues: memory leaks, concurrency, performance, accessibility

Address findings from comprehensive audit across 5 workstreams:

- Memory: Token-based DataController listeners (prevent closure leaks),
  static DateFormatters, ImageCache observer cleanup, MotionManager
  reference counting, FoundationModels dedup guard
- Concurrency: Replace Task.detached with Task in FeelsApp (preserve
  MainActor isolation), wrap WatchConnectivity handler in MainActor
- Performance: Cache sortedGroupedData in DayViewViewModel, cache demo
  data in MonthView/YearView, remove broken ReduceMotionModifier
- Accessibility: VoiceOver support for LockScreen, DemoHeatmapCell
  labels, MonthCard button labels, InsightsView header traits,
  Smart Invert protection on neon headers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-19 09:11:48 -06:00
parent b58dfd5093
commit c22d246865
18 changed files with 175 additions and 73 deletions

View File

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

View File

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