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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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