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:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String> = []
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
@@ -15,13 +15,15 @@ final class ImageCache {
|
||||
private let cache = NSCache<NSString, UIImage>()
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<V: Equatable>(_ 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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user