diff --git a/Feels.xcodeproj/project.pbxproj b/Feels.xcodeproj/project.pbxproj index 760c2a9..17c3efc 100644 --- a/Feels.xcodeproj/project.pbxproj +++ b/Feels.xcodeproj/project.pbxproj @@ -21,7 +21,6 @@ 1CD90B6C278C7F78001C4FEA /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1CD90B6B278C7F78001C4FEA /* CloudKit.framework */; }; 1CD90B6E278C7F8B001C4FEA /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1CD90B6B278C7F78001C4FEA /* CloudKit.framework */; }; 46F07FA9D330456697C9AC29 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1CD90B47278C7E7A001C4FEA /* WidgetKit.framework */; }; - 69674916178A409ABDEA4126 /* Feels Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 1E594AEAB5F046E3B3ED7C47 /* Feels Watch App.app */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -46,13 +45,6 @@ remoteGlobalIDString = 1CD90B44278C7E7A001C4FEA; remoteInfo = FeelsWidgetExtension; }; - 51F6DCE106234B68B4F88529 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 1CD90AE6278C7DDF001C4FEA /* Project object */; - proxyType = 1; - remoteGlobalIDString = B1DB9E6543DE4A009DB00916; - remoteInfo = "Feels Watch App"; - }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -67,17 +59,6 @@ name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; }; - 87A714924E734CD8948F0CD0 /* Embed Watch Content */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; - dstSubfolderSpec = 16; - files = ( - 69674916178A409ABDEA4126 /* Feels Watch App.app in Embed Watch Content */, - ); - name = "Embed Watch Content"; - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -296,13 +277,11 @@ 1CD90AF2278C7DE0001C4FEA /* Frameworks */, 1CD90AF3278C7DE0001C4FEA /* Resources */, 1CD90B5A278C7E7A001C4FEA /* Embed Foundation Extensions */, - 87A714924E734CD8948F0CD0 /* Embed Watch Content */, ); buildRules = ( ); dependencies = ( 1CD90B55278C7E7A001C4FEA /* PBXTargetDependency */, - CB28ED3402234638800683C9 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 1C00073D2EE9388A009C9ED5 /* Shared */, @@ -588,11 +567,6 @@ target = 1CD90B44278C7E7A001C4FEA /* FeelsWidgetExtension */; targetProxy = 1CD90B54278C7E7A001C4FEA /* PBXContainerItemProxy */; }; - CB28ED3402234638800683C9 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = B1DB9E6543DE4A009DB00916 /* Feels Watch App */; - targetProxy = 51F6DCE106234B68B4F88529 /* PBXContainerItemProxy */; - }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ diff --git a/Feels/Localizable.xcstrings b/Feels/Localizable.xcstrings index a099f3a..a10ee9b 100644 --- a/Feels/Localizable.xcstrings +++ b/Feels/Localizable.xcstrings @@ -893,7 +893,7 @@ } }, "Add Test Data" : { - "comment" : "A button label that adds test data to the app.", + "comment" : "A button label that, when tapped, populates the app with sample mood entries for testing purposes.", "isCommentAutoGenerated" : true }, "Add the Mood Vote widget to quickly log your mood without opening the app." : { @@ -1837,7 +1837,7 @@ "isCommentAutoGenerated" : true }, "Clear All Data" : { - "comment" : "A button label that clears all data from the app.", + "comment" : "A button label that clears all user data.", "isCommentAutoGenerated" : true }, "Clear DB" : { @@ -3032,11 +3032,11 @@ } }, "Current Parameters" : { - "comment" : "A section header that lists various current settings and statistics of the app.", + "comment" : "A section header that lists various current parameters related to the app.", "isCommentAutoGenerated" : true }, "Current Streak" : { - "comment" : "A label for the current streak of using the feature.", + "comment" : "A label describing the user's current streak of using the app.", "isCommentAutoGenerated" : true }, "Current: %@" : { @@ -3083,6 +3083,7 @@ }, "Custom" : { "comment" : "The text that appears as a label for the custom color option in the tint picker.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "de" : { @@ -4225,7 +4226,7 @@ } }, "Delete all mood entries" : { - "comment" : "A description of what the \"Clear All Data\" button does.", + "comment" : "A description of what happens when the \"Clear All Data\" button is tapped.", "isCommentAutoGenerated" : true }, "Delete Entry" : { @@ -4811,8 +4812,7 @@ } }, "Experiment with vote celebrations" : { - "comment" : "A description of a feature in the Animation Lab.", - "isCommentAutoGenerated" : true + }, "Explore Your Mood History" : { "comment" : "A title for a feature that allows users to explore their mood history.", @@ -5699,7 +5699,7 @@ "isCommentAutoGenerated" : true }, "Has Seen Settings" : { - "comment" : "A label for whether the user has seen the settings screen.", + "comment" : "A label for whether the user has seen the settings section in the app.", "isCommentAutoGenerated" : true }, "How are you feeling?" : { @@ -6789,7 +6789,7 @@ } }, "Mood Log Count" : { - "comment" : "A label describing the count of mood logs.", + "comment" : "The title of a label displaying the count of mood logs.", "isCommentAutoGenerated" : true }, "Mood Logged" : { @@ -9195,7 +9195,7 @@ }, "Preview subscription themes" : { - "comment" : "A description of the paywall preview feature.", + "comment" : "A description of what the paywall preview button does.", "isCommentAutoGenerated" : true }, "Privacy Lock" : { @@ -10850,6 +10850,7 @@ }, "Sample Text" : { "comment" : "A sample text used to demonstrate how the text color is applied in the UI.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "de" : { @@ -11726,7 +11727,7 @@ } }, "Shown This Session" : { - "comment" : "A label showing whether the tip has been shown during the current session.", + "comment" : "A label displaying whether they have seen a tip during the current session.", "isCommentAutoGenerated" : true }, "SIDE A" : { @@ -12589,7 +12590,7 @@ "isCommentAutoGenerated" : true }, "Tap to preview" : { - "comment" : "A text label displayed above a list of tips, instructing the user to tap on them to view more information.", + "comment" : "A text label displayed above a list of tips, instructing the user to tap on an item to view more details.", "isCommentAutoGenerated" : true }, "Tap to record your mood for this day" : { diff --git a/Shared/AppDelegate.swift b/Shared/AppDelegate.swift index e0197f8..94cd249 100644 --- a/Shared/AppDelegate.swift +++ b/Shared/AppDelegate.swift @@ -12,7 +12,7 @@ import SwiftUI class AppDelegate: NSObject, UIApplicationDelegate { private let savedOnboardingData = UserDefaultsStore.getOnboarding() - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor + @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @MainActor func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { @@ -20,7 +20,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { DataController.shared.fillInMissingDates() UNUserNotificationCenter.current().delegate = self - UIPageControl.appearance().currentPageIndicatorTintColor = UIColor(textColor) + UIPageControl.appearance().currentPageIndicatorTintColor = UIColor(theme.currentTheme.labelColor) UIPageControl.appearance().pageIndicatorTintColor = UIColor.systemGray let appearance = UITabBarAppearance() @@ -33,15 +33,6 @@ class AppDelegate: NSObject, UIApplicationDelegate { return true } - @MainActor - func applicationWillEnterForeground(_ application: UIApplication) { - DataController.shared.fillInMissingDates() - - // reschedule notifications so there's a new title next notification - LocalNotification.rescheduleNotifiations() - - EventLogger.log(event: "app_foregorund") - } } extension AppDelegate: @preconcurrency UNUserNotificationCenterDelegate { diff --git a/Shared/FeelsApp.swift b/Shared/FeelsApp.swift index 0203181..4079a0d 100644 --- a/Shared/FeelsApp.swift +++ b/Shared/FeelsApp.swift @@ -76,21 +76,46 @@ struct FeelsApp: App { if newPhase == .active { UNUserNotificationCenter.current().setBadgeCount(0) - // Check subscription status on each app launch - Task { - await iapManager.checkSubscriptionStatus() - } - // Authenticate if locked + + // Authenticate if locked - this must happen immediately on main thread if authManager.isLockEnabled && !authManager.isUnlocked { Task { await authManager.authenticate() } } - // Reschedule Live Activity when app becomes active - LiveActivityScheduler.shared.scheduleBasedOnCurrentTime() + + // Defer all non-critical foreground work to avoid blocking UI + Task.detached(priority: .utility) { @MainActor in + // Refresh from disk to pick up widget/watch changes + DataController.shared.refreshFromDisk() + + // Clean up any duplicate entries first + DataController.shared.removeDuplicates() + + // Fill in any missing dates (moved from AppDelegate) + DataController.shared.fillInMissingDates() + + // Reschedule notifications for new title + LocalNotification.rescheduleNotifiations() + + // Log event + EventLogger.log(event: "app_foregorund") + } + + // Defer Live Activity scheduling (heavy DB operations) + Task.detached(priority: .utility) { + await LiveActivityScheduler.shared.scheduleBasedOnCurrentTime() + } // Catch up on side effects from widget/watch votes - MoodLogger.shared.processPendingSideEffects() + Task.detached(priority: .utility) { + await MoodLogger.shared.processPendingSideEffects() + } + + // Check subscription status (network call) - throttled + Task.detached(priority: .background) { + await iapManager.checkSubscriptionStatus() + } } } } diff --git a/Shared/IAPManager.swift b/Shared/IAPManager.swift index 44f7631..9125fae 100644 --- a/Shared/IAPManager.swift +++ b/Shared/IAPManager.swift @@ -75,6 +75,12 @@ class IAPManager: ObservableObject { private var updateListenerTask: Task? + /// Last time subscription status was checked (for throttling) + private var lastStatusCheckTime: Date? + + /// Minimum interval between status checks (5 minutes) + private let statusCheckInterval: TimeInterval = 300 + // MARK: - Computed Properties var isSubscribed: Bool { @@ -138,9 +144,21 @@ class IAPManager: ObservableObject { // MARK: - Public Methods /// Check subscription status - call on app launch and when becoming active + /// Throttled to avoid excessive StoreKit calls on rapid foreground transitions func checkSubscriptionStatus() async { - isLoading = true - defer { isLoading = false } + // Throttle: skip if we checked recently (unless state is unknown) + if state != .unknown, + let lastCheck = lastStatusCheckTime, + Date().timeIntervalSince(lastCheck) < statusCheckInterval { + return + } + + // Only update isLoading if value actually changes to avoid unnecessary view updates + if !isLoading { isLoading = true } + defer { + if isLoading { isLoading = false } + lastStatusCheckTime = Date() + } // Fetch available products await loadProducts() diff --git a/Shared/Models/MoodTintable.swift b/Shared/Models/MoodTintable.swift index f1389d5..c2d24a0 100644 --- a/Shared/Models/MoodTintable.swift +++ b/Shared/Models/MoodTintable.swift @@ -7,12 +7,6 @@ import SwiftUI -struct DefaultTextColor { - static var textColor: Color { - Color(UIColor.label) - } -} - protocol MoodTintable { static func color(forMood mood: Mood) -> Color static func secondary(forMood mood: Mood) -> Color @@ -232,37 +226,44 @@ final class AllRedMoodTint: MoodTintable { } final class NeonMoodTint: MoodTintable { + // Synthwave color palette matching the Neon voting style + private static let neonCyan = Color(red: 0.0, green: 1.0, blue: 0.82) + private static let neonLime = Color(red: 0.2, green: 1.0, blue: 0.6) + private static let neonYellow = Color(red: 1.0, green: 0.9, blue: 0.0) + private static let neonOrange = Color(red: 1.0, green: 0.5, blue: 0.0) + private static let neonMagenta = Color(red: 1.0, green: 0.0, blue: 0.8) + static func color(forMood mood: Mood) -> Color { switch mood { case .horrible: - return Color(hex: "#ff1818") + return neonMagenta case .bad: - return Color(hex: "#FF5F1F") + return neonOrange case .average: - return Color(hex: "#1F51FF") + return neonYellow case .good: - return Color(hex: "#FFF01F") + return neonLime case .great: - return Color(hex: "#39FF14") + return neonCyan case .missing: return Color(uiColor: UIColor.systemGray2) case .placeholder: return Color(uiColor: UIColor.systemGray2) } } - + static func secondary(forMood mood: Mood) -> Color { switch mood { case .horrible: - return Color(hex: "#8b1113") + return neonMagenta.opacity(0.6) case .bad: - return Color(hex: "#893315") + return neonOrange.opacity(0.6) case .average: - return Color(hex: "#0f2a85") + return neonYellow.opacity(0.6) case .good: - return Color(hex: "#807a18") + return neonLime.opacity(0.6) case .great: - return Color(hex: "#218116") + return neonCyan.opacity(0.6) case .missing: return Color(uiColor: UIColor.label) case .placeholder: diff --git a/Shared/Models/Theme.swift b/Shared/Models/Theme.swift index 96e5cc2..5ef0211 100644 --- a/Shared/Models/Theme.swift +++ b/Shared/Models/Theme.swift @@ -43,7 +43,7 @@ enum Theme: String, CaseIterable { var currentTheme: Themeable { switch self { - + case .system: return SystemTheme() case .iFeel: @@ -54,6 +54,17 @@ enum Theme: String, CaseIterable { return AlwaysLight() } } + + var preferredColorScheme: ColorScheme? { + switch self { + case .system, .iFeel: + return nil // Follow system + case .dark: + return .dark + case .light: + return .light + } + } } protocol Themeable { diff --git a/Shared/Models/UserDefaultsStore.swift b/Shared/Models/UserDefaultsStore.swift index b4e6d7a..7da9d68 100644 --- a/Shared/Models/UserDefaultsStore.swift +++ b/Shared/Models/UserDefaultsStore.swift @@ -178,7 +178,6 @@ class UserDefaultsStore { case customWidget case customMoodTint case customMoodTintUpdateNumber - case textColor case showNSFW case shape case daysFilter @@ -200,24 +199,47 @@ class UserDefaultsStore { case currentSelectedHeaderViewViewType } + /// Cached onboarding data to avoid repeated JSON decoding + private static var cachedOnboardingData: OnboardingData? + static func getOnboarding() -> OnboardingData { + // Return cached data if available + if let cached = cachedOnboardingData { + return cached + } + + // Decode and cache if let data = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.savedOnboardingData.rawValue) as? Data, let model = try? JSONDecoder().decode(OnboardingData.self, from: data) { + cachedOnboardingData = model return model } else { - return OnboardingData() + let defaultData = OnboardingData() + cachedOnboardingData = defaultData + return defaultData } } - + + /// Invalidate cached onboarding data (call when data might have changed externally) + static func invalidateOnboardingCache() { + cachedOnboardingData = nil + } + @discardableResult static func saveOnboarding(onboardingData: OnboardingData) -> OnboardingData { + // Invalidate cache before saving + cachedOnboardingData = nil + do { let data = try JSONEncoder().encode(onboardingData) GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.savedOnboardingData.rawValue) } catch { print("Error saving onboarding: \(error)") } - return UserDefaultsStore.getOnboarding() + + // Re-cache the saved data + cachedOnboardingData = onboardingData + return onboardingData } static func moodMoodImagable() -> MoodImagable.Type { diff --git a/Shared/MoodLogger.swift b/Shared/MoodLogger.swift index a3ddc07..a38f314 100644 --- a/Shared/MoodLogger.swift +++ b/Shared/MoodLogger.swift @@ -147,25 +147,9 @@ final class MoodLogger { return targetDay == lastDay } - /// Calculate the current mood streak + /// Calculate the current mood streak using optimized batch query private func calculateCurrentStreak() -> Int { - var streak = 0 - var checkDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding()) - - while true { - let dayStart = Calendar.current.startOfDay(for: checkDate) - let dayEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: dayStart)! - - let entry = DataController.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first - - if let entry = entry, entry.mood != .missing && entry.mood != .placeholder { - streak += 1 - checkDate = Calendar.current.date(byAdding: .day, value: -1, to: checkDate)! - } else { - break - } - } - - return streak + let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding()) + return DataController.shared.calculateStreak(from: votingDate).streak } } diff --git a/Shared/MoodStreakActivity.swift b/Shared/MoodStreakActivity.swift index 39e331d..e622da5 100644 --- a/Shared/MoodStreakActivity.swift +++ b/Shared/MoodStreakActivity.swift @@ -175,76 +175,80 @@ class LiveActivityScheduler: ObservableObject { return calendar.date(byAdding: .hour, value: 5, to: ratingDateTime) } - /// Check if user has rated today - func hasRatedToday() -> Bool { + /// Cached streak data to avoid redundant calculations + private var cachedStreakData: (streak: Int, todaysMood: Mood?, votingDate: Date)? + + /// Get streak data using efficient batch query (cached per voting date) + private func getStreakData() -> (streak: Int, todaysMood: Mood?) { let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding()) - let dayStart = Calendar.current.startOfDay(for: votingDate) - let dayEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: dayStart)! + // Return cached data if still valid for current voting date + if let cached = cachedStreakData, + Calendar.current.isDate(cached.votingDate, inSameDayAs: votingDate) { + return (cached.streak, cached.todaysMood) + } + + // Calculate and cache #if WIDGET_EXTENSION - let entry = WidgetDataProvider.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first - #else - let entry = DataController.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first - #endif - return entry != nil && entry?.mood != .missing && entry?.mood != .placeholder - } + // Widget extension uses its own data provider + let calendar = Calendar.current + let dayStart = calendar.startOfDay(for: votingDate) + guard let yearAgo = calendar.date(byAdding: .day, value: -365, to: dayStart) else { + return (0, nil) + } + + let entries = WidgetDataProvider.shared.getData(startDate: yearAgo, endDate: votingDate, includedDays: []) + .filter { $0.mood != .missing && $0.mood != .placeholder } + + guard !entries.isEmpty else { return (0, nil) } + + let datesWithEntries = Set(entries.map { calendar.startOfDay(for: $0.forDate) }) + let todaysEntry = entries.first { calendar.isDate($0.forDate, inSameDayAs: votingDate) } + let todaysMood = todaysEntry?.mood - /// Calculate current streak - func calculateStreak() -> Int { var streak = 0 - var checkDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding()) - - // Check if current voting date has an entry - let currentDayStart = Calendar.current.startOfDay(for: checkDate) - let currentDayEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: currentDayStart)! - #if WIDGET_EXTENSION - let currentEntry = WidgetDataProvider.shared.getData(startDate: currentDayStart, endDate: currentDayEnd, includedDays: []).first - #else - let currentEntry = DataController.shared.getData(startDate: currentDayStart, endDate: currentDayEnd, includedDays: []).first - #endif - - // If no entry for current voting date, start counting from previous day - // This ensures the streak shows correctly even if user hasn't rated today yet - if currentEntry == nil || currentEntry?.mood == .missing || currentEntry?.mood == .placeholder { - checkDate = Calendar.current.date(byAdding: .day, value: -1, to: checkDate)! + var checkDate = votingDate + if !datesWithEntries.contains(dayStart) { + checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate) ?? checkDate } while true { - let dayStart = Calendar.current.startOfDay(for: checkDate) - let dayEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: dayStart)! - - #if WIDGET_EXTENSION - let entry = WidgetDataProvider.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first - #else - let entry = DataController.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first - #endif - - if let entry = entry, entry.mood != .missing && entry.mood != .placeholder { + let checkDayStart = calendar.startOfDay(for: checkDate) + if datesWithEntries.contains(checkDayStart) { streak += 1 - checkDate = Calendar.current.date(byAdding: .day, value: -1, to: checkDate)! + checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate) ?? checkDate } else { break } } - return streak + cachedStreakData = (streak, todaysMood, votingDate) + return (streak, todaysMood) + #else + let result = DataController.shared.calculateStreak(from: votingDate) + cachedStreakData = (result.streak, result.todaysMood, votingDate) + return result + #endif + } + + /// Check if user has rated today + func hasRatedToday() -> Bool { + return getStreakData().todaysMood != nil + } + + /// Calculate current streak + func calculateStreak() -> Int { + return getStreakData().streak } /// Get today's mood if logged func getTodaysMood() -> Mood? { - let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding()) - let dayStart = Calendar.current.startOfDay(for: votingDate) - let dayEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: dayStart)! + return getStreakData().todaysMood + } - #if WIDGET_EXTENSION - let entry = WidgetDataProvider.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first - #else - let entry = DataController.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first - #endif - if let entry = entry, entry.mood != .missing && entry.mood != .placeholder { - return entry.mood - } - return nil + /// Invalidate cached streak data (call when mood is logged) + func invalidateCache() { + cachedStreakData = nil } /// Schedule Live Activity based on current time and rating time diff --git a/Shared/Onboarding/views/OnboardingCustomizeTwo.swift b/Shared/Onboarding/views/OnboardingCustomizeTwo.swift index 7491187..5a99d90 100644 --- a/Shared/Onboarding/views/OnboardingCustomizeTwo.swift +++ b/Shared/Onboarding/views/OnboardingCustomizeTwo.swift @@ -47,24 +47,6 @@ struct OnboardingCustomizeTwo: View { .fixedSize(horizontal: false, vertical: true) .foregroundColor(.white) ImagePackPickerView() - - Text(String(localized: "onboarding_title_customize_two_section_two_title")) - .font(.title3) - .padding() - .fixedSize(horizontal: false, vertical: true) - .foregroundColor(.white) - - - TintPickerView() - - Text(String(localized: "onboarding_title_customize_two_section_three_title")) - .font(.title3) - .padding() - .fixedSize(horizontal: false, vertical: true) - .foregroundColor(.white) - - - TextColorPickerView() } } } diff --git a/Shared/Persisence/DataController.swift b/Shared/Persisence/DataController.swift index 2164931..58e6335 100644 --- a/Shared/Persisence/DataController.swift +++ b/Shared/Persisence/DataController.swift @@ -67,4 +67,21 @@ final class DataController: ObservableObject { Self.logger.error("Failed to save context: \(error.localizedDescription)") } } + + /// Refresh data from disk to pick up changes made by extensions (widget/watch). + /// Call this when app becomes active. + func refreshFromDisk() { + // SwiftData doesn't have a direct "refresh from disk" API. + // We achieve this by: + // 1. Rolling back any unsaved changes (ensures clean state) + // 2. Triggering listeners to re-fetch data (which will read from disk) + modelContext.rollback() + + // Notify listeners to re-fetch their data + for closure in editedDataClosure { + closure() + } + + Self.logger.debug("Refreshed data from disk") + } } diff --git a/Shared/Persisence/DataControllerADD.swift b/Shared/Persisence/DataControllerADD.swift index 9c80411..5001135 100644 --- a/Shared/Persisence/DataControllerADD.swift +++ b/Shared/Persisence/DataControllerADD.swift @@ -10,9 +10,12 @@ import Foundation extension DataController { func add(mood: Mood, forDate date: Date, entryType: EntryType) { - // Delete existing entry for this date if present - if let existing = getEntry(byDate: date) { - modelContext.delete(existing) + // Delete ALL existing entries for this date (handles duplicates) + let existing = getAllEntries(byDate: date) + for entry in existing { + modelContext.delete(entry) + } + if !existing.isEmpty { try? modelContext.save() } @@ -50,15 +53,23 @@ extension DataController { let missing = Array(Set(allDates).subtracting(existingDates)).sorted(by: >) + guard !missing.isEmpty else { return } + + // Batch insert all missing dates without triggering listeners for date in missing { // Add 12 hours to avoid UTC offset issues let adjustedDate = Calendar.current.date(byAdding: .hour, value: 12, to: date)! - add(mood: .missing, forDate: adjustedDate, entryType: .filledInMissing) + let entry = MoodEntryModel( + forDate: adjustedDate, + mood: .missing, + entryType: .filledInMissing + ) + modelContext.insert(entry) } - if !missing.isEmpty { - EventLogger.log(event: "filled_in_missing_entries", withData: ["count": missing.count]) - } + // Single save and listener notification at the end + saveAndRunDataListeners() + EventLogger.log(event: "filled_in_missing_entries", withData: ["count": missing.count]) } func fixWrongWeekdays() { diff --git a/Shared/Persisence/DataControllerDELETE.swift b/Shared/Persisence/DataControllerDELETE.swift index e223615..f6c3eb9 100644 --- a/Shared/Persisence/DataControllerDELETE.swift +++ b/Shared/Persisence/DataControllerDELETE.swift @@ -38,4 +38,70 @@ extension DataController { } save() } + + /// Get ALL entries for a specific date (not just the first one) + func getAllEntries(byDate date: Date) -> [MoodEntryModel] { + let startDate = Calendar.current.startOfDay(for: date) + let endDate = Calendar.current.date(byAdding: .day, value: 1, to: startDate)! + + let descriptor = FetchDescriptor( + predicate: #Predicate { entry in + entry.forDate >= startDate && entry.forDate <= endDate + }, + sortBy: [SortDescriptor(\.forDate, order: .forward)] + ) + + return (try? modelContext.fetch(descriptor)) ?? [] + } + + /// Delete ALL entries for a specific date + func deleteAllEntries(forDate date: Date) { + let entries = getAllEntries(byDate: date) + for entry in entries { + modelContext.delete(entry) + } + save() + } + + /// Find and remove duplicate entries, keeping only the most recent for each date + /// Returns the number of duplicates removed + @discardableResult + func removeDuplicates() -> Int { + let allEntries = getData(startDate: Date(timeIntervalSince1970: 0), endDate: Date(), includedDays: []) + + // Group by day + var entriesByDay = [Date: [MoodEntryModel]]() + let calendar = Calendar.current + + for entry in allEntries { + let dayStart = calendar.startOfDay(for: entry.forDate) + entriesByDay[dayStart, default: []].append(entry) + } + + var duplicatesRemoved = 0 + + for (_, dayEntries) in entriesByDay where dayEntries.count > 1 { + // Sort by timestamp (most recent first), preferring non-missing moods + let sorted = dayEntries.sorted { a, b in + // Prefer non-missing moods + if a.mood != .missing && b.mood == .missing { return true } + if a.mood == .missing && b.mood != .missing { return false } + // Then by timestamp (most recent first) + return a.timestamp > b.timestamp + } + + // Keep the first (best) entry, delete the rest + for entry in sorted.dropFirst() { + modelContext.delete(entry) + duplicatesRemoved += 1 + } + } + + if duplicatesRemoved > 0 { + saveAndRunDataListeners() + AppLogger.general.info("Removed \(duplicatesRemoved) duplicate entries") + } + + return duplicatesRemoved + } } diff --git a/Shared/Persisence/DataControllerGET.swift b/Shared/Persisence/DataControllerGET.swift index a0d2777..8f34b4a 100644 --- a/Shared/Persisence/DataControllerGET.swift +++ b/Shared/Persisence/DataControllerGET.swift @@ -39,45 +39,77 @@ extension DataController { return (try? modelContext.fetch(descriptor)) ?? [] } + /// Calculate the current mood streak efficiently using a single batch query. + /// Returns a tuple of (streak count, last logged mood if today has entry). + func calculateStreak(from votingDate: Date) -> (streak: Int, todaysMood: Mood?) { + let calendar = Calendar.current + let dayStart = calendar.startOfDay(for: votingDate) + + // Fetch last 365 days of entries in a single query + guard let yearAgo = calendar.date(byAdding: .day, value: -365, to: dayStart) else { + return (0, nil) + } + + let entries = getData(startDate: yearAgo, endDate: votingDate, includedDays: []) + .filter { $0.mood != .missing && $0.mood != .placeholder } + + guard !entries.isEmpty else { return (0, nil) } + + // Build a Set of dates that have valid entries for O(1) lookup + let datesWithEntries = Set(entries.map { calendar.startOfDay(for: $0.forDate) }) + + // Check for today's entry + let todaysEntry = entries.first { calendar.isDate($0.forDate, inSameDayAs: votingDate) } + let todaysMood = todaysEntry?.mood + + // Calculate streak walking backwards + var streak = 0 + var checkDate = votingDate + + // If no entry for current voting date, start counting from previous day + if !datesWithEntries.contains(dayStart) { + checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate) ?? checkDate + } + + while true { + let checkDayStart = calendar.startOfDay(for: checkDate) + if datesWithEntries.contains(checkDayStart) { + streak += 1 + checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate) ?? checkDate + } else { + break + } + } + + return (streak, todaysMood) + } + func splitIntoYearMonth(includedDays: [Int]) -> [Int: [Int: [MoodEntryModel]]] { + // Single query to fetch all data - avoid N*12 queries let data = getData( startDate: Date(timeIntervalSince1970: 0), endDate: Date(), includedDays: includedDays - ).sorted { $0.forDate < $1.forDate } + ) - guard let earliest = data.first, - let latest = data.last else { return [:] } + guard !data.isEmpty else { return [:] } let calendar = Calendar.current - let earliestYear = calendar.component(.year, from: earliest.forDate) - let latestYear = calendar.component(.year, from: latest.forDate) + // Group entries by year and month in memory (single pass) var result = [Int: [Int: [MoodEntryModel]]]() - for year in earliestYear...latestYear { - var monthData = [Int: [MoodEntryModel]]() + for entry in data { + let year = calendar.component(.year, from: entry.forDate) + let month = calendar.component(.month, from: entry.forDate) - for month in 1...12 { - var components = DateComponents() - components.year = year - components.month = month - components.day = 1 - - guard let startOfMonth = calendar.date(from: components) else { continue } - - let items = getData( - startDate: startOfMonth, - endDate: startOfMonth.endOfMonth, - includedDays: [1, 2, 3, 4, 5, 6, 7] - ) - - if !items.isEmpty { - monthData[month] = items - } + if result[year] == nil { + result[year] = [:] } - - result[year] = monthData + if result[year]![month] == nil { + result[year]![month] = [] + } + result[year]![month]!.append(entry) } return result diff --git a/Shared/Persisence/ExtensionDataProvider.swift b/Shared/Persisence/ExtensionDataProvider.swift index f9fe145..ed304f2 100644 --- a/Shared/Persisence/ExtensionDataProvider.swift +++ b/Shared/Persisence/ExtensionDataProvider.swift @@ -174,9 +174,12 @@ final class ExtensionDataProvider { /// - date: The date for the entry /// - entryType: The source of the entry (widget, watch, etc.) func add(mood: Mood, forDate date: Date, entryType: EntryType) { - // Delete existing entry for this date if present - if let existing = getEntry(byDate: date) { - modelContext.delete(existing) + // Delete ALL existing entries for this date (handles duplicates) + let existing = getAllEntries(byDate: date) + for entry in existing { + modelContext.delete(entry) + } + if !existing.isEmpty { try? modelContext.save() } @@ -199,6 +202,21 @@ final class ExtensionDataProvider { } } + /// Get ALL entries for a specific date (not just the first one) + func getAllEntries(byDate date: Date) -> [MoodEntryModel] { + let startDate = Calendar.current.startOfDay(for: date) + let endDate = Calendar.current.date(byAdding: .day, value: 1, to: startDate)! + + let descriptor = FetchDescriptor( + predicate: #Predicate { entry in + entry.forDate >= startDate && entry.forDate <= endDate + }, + sortBy: [SortDescriptor(\.forDate, order: .forward)] + ) + + return (try? modelContext.fetch(descriptor)) ?? [] + } + /// Invalidate cached container (call when data might have changed) func invalidateCache() { _container = nil diff --git a/Shared/Protocols/ChartDataBuildable.swift b/Shared/Protocols/ChartDataBuildable.swift index d10c596..e41ed6c 100644 --- a/Shared/Protocols/ChartDataBuildable.swift +++ b/Shared/Protocols/ChartDataBuildable.swift @@ -18,87 +18,92 @@ protocol ChartDataBuildable { extension ChartDataBuildable { public func buildGridData(withData data: [MoodEntryModel]) -> [Year: [Month: [ChartType]]] { - var returnData = [Int: [Int: [ChartType]]]() - - if let earliestEntry = data.first, - let lastEntry = data.last { - - let calendar = Calendar.current - let components = calendar.dateComponents([.year], from: earliestEntry.forDate) - let earliestYear = components.year! - - let latestComponents = calendar.dateComponents([.year], from: lastEntry.forDate) - let latestYear = latestComponents.year! - - for year in earliestYear...latestYear { - var allMonths = [Int: [ChartType]]() - - // add back in if months header has leading (-1, ""), - // and add back gridItem - // var dayViews = [DayChartView]() - // for day in 0...32 { - // let view = DayChartView(color: Mood.missing.color, - // weekDay: 2, - // viewType: .text(String(day+1))) - // dayViews.append(view) - // } - // allMonths[0] = dayViews - - for month in (1...12) { - var components = DateComponents() - components.month = month - components.year = year - let startDateOfMonth = Calendar.current.date(from: components)! - - let items = data.filter({ entry in - let components = calendar.dateComponents([.month, .year], from: startDateOfMonth) - let entryComponents = calendar.dateComponents([.month, .year], from: entry.forDate) - return (components.month == entryComponents.month && components.year == entryComponents.year) - }) - - allMonths[month] = createViewFor(monthEntries: items, forMonth: startDateOfMonth) - } - returnData[year] = allMonths - } - } - return returnData - } - - private func createViewFor(monthEntries: [MoodEntryModel], forMonth month: Date) -> [ChartType] { - var filledOutArray = [ChartType]() + guard let earliestEntry = data.first, + let lastEntry = data.last else { return [:] } let calendar = Calendar.current - let range = calendar.range(of: .day, in: .month, for: month)! + + // Pre-group entries by year/month/day in a single pass - O(n) instead of O(n*m) + // Key: "year-month-day" -> entry + var entriesByDate = [String: MoodEntryModel]() + for entry in data { + let components = calendar.dateComponents([.year, .month, .day], from: entry.forDate) + let key = "\(components.year!)-\(components.month!)-\(components.day!)" + entriesByDate[key] = entry + } + + let earliestYear = calendar.component(.year, from: earliestEntry.forDate) + let latestYear = calendar.component(.year, from: lastEntry.forDate) + + // Cache expensive lookups + let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable() + let shape = UserDefaultsStore.getCustomBGShape() + + var returnData = [Int: [Int: [ChartType]]]() + + for year in earliestYear...latestYear { + var allMonths = [Int: [ChartType]]() + + for month in 1...12 { + var components = DateComponents() + components.month = month + components.year = year + guard let startDateOfMonth = calendar.date(from: components) else { continue } + + allMonths[month] = createViewFor( + year: year, + month: month, + forMonth: startDateOfMonth, + entriesByDate: entriesByDate, + moodTint: moodTint, + shape: shape, + calendar: calendar + ) + } + returnData[year] = allMonths + } + + return returnData + } + + private func createViewFor( + year: Int, + month: Int, + forMonth monthDate: Date, + entriesByDate: [String: MoodEntryModel], + moodTint: MoodTintable.Type, + shape: BGShape, + calendar: Calendar + ) -> [ChartType] { + var filledOutArray = [ChartType]() + + let range = calendar.range(of: .day, in: .month, for: monthDate)! let numDays = range.count for day in 1...numDays { - if let item = monthEntries.filter({ entry in - let components = calendar.dateComponents([.day], from: entry.forDate) - let date = components.day - return day == date - }).first { - let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable() - + let key = "\(year)-\(month)-\(day)" + if let item = entriesByDate[key] { + // O(1) dictionary lookup instead of O(n) filter let view = ChartType(color: moodTint.color(forMood: item.mood), weekDay: Int(item.weekDay), - shape: UserDefaultsStore.getCustomBGShape()) + shape: shape) filledOutArray.append(view) } else { - let thisDate = Calendar.current.date(bySetting: .day, value: day, of: month)! + let thisDate = calendar.date(bySetting: .day, value: day, of: monthDate)! let view = ChartType(color: Mood.placeholder.color, - weekDay: Calendar.current.component(.weekday, from: thisDate), - shape: UserDefaultsStore.getCustomBGShape()) + weekDay: calendar.component(.weekday, from: thisDate), + shape: shape) filledOutArray.append(view) } } - + for _ in filledOutArray.count...32 { let view = ChartType(color: Mood.placeholder.color, weekDay: 2, - shape: UserDefaultsStore.getCustomBGShape()) + shape: shape) filledOutArray.append(view) } - + return filledOutArray } } diff --git a/Shared/Random.swift b/Shared/Random.swift index 0e7b9b0..c349aaa 100644 --- a/Shared/Random.swift +++ b/Shared/Random.swift @@ -58,9 +58,11 @@ class Random { return newValue } + /// Cached month symbols to avoid creating DateFormatter repeatedly + private static let monthSymbols: [String] = DateFormatter().monthSymbols + static func monthName(fromMonthInt: Int) -> String { - let monthName = DateFormatter().monthSymbols[fromMonthInt-1] - return monthName + return monthSymbols[fromMonthInt-1] } static var existingDayFormat = [NSNumber: String]() @@ -165,7 +167,15 @@ extension Color { } extension String { + /// Cache for rendered emoji images to avoid expensive re-rendering + private static var textToImageCache = [String: UIImage]() + func textToImage() -> UIImage? { + // Return cached image if available + if let cached = Self.textToImageCache[self] { + return cached + } + let nsString = (self as NSString) let font = UIFont.systemFont(ofSize: 100) // you can change your font size here let stringAttributes = [NSAttributedString.Key.font: font] @@ -178,7 +188,12 @@ extension String { let image = UIGraphicsGetImageFromCurrentImageContext() // create image from context UIGraphicsEndImageContext() // end image context - return image ?? UIImage() + let result = image ?? UIImage() + + // Cache the rendered image + Self.textToImageCache[self] = result + + return result } } @@ -218,6 +233,179 @@ extension UIColor { } #endif +// MARK: - Date Formatting Cache + +/// High-performance date formatting cache to eliminate repeated ICU Calendar operations. +/// Caches formatted date strings keyed by (date's day start, format type). +final class DateFormattingCache { + static let shared = DateFormattingCache() + + enum Format: Int { + case day // "15" + case weekdayWide // "Monday" + case weekdayAbbreviated // "Mon" + case weekdayWideDay // "Monday 15" + case monthWide // "January" + case monthAbbreviated // "Jan" + case monthAbbreviatedDay // "Jan 15" + case monthAbbreviatedYear // "Jan 2025" + case monthWideYear // "January 2025" + case weekdayAbbrevMonthAbbrev // "Mon Jan" + case weekdayWideMonthAbbrev // "Monday Jan" + case yearMonthDayDigits // "2025/01/15" + case dateMedium // "Jan 15, 2025" (dateStyle = .medium) + case dateFull // "Monday, January 15, 2025" (dateStyle = .full) + } + + private var cache = [Int: [Format: String]]() + private let calendar = Calendar.current + + // Reusable formatters (creating DateFormatter is expensive) + private lazy var dayFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "d" + return f + }() + + private lazy var weekdayWideFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "EEEE" + return f + }() + + private lazy var weekdayAbbrevFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "EEE" + return f + }() + + private lazy var weekdayWideDayFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "EEEE d" + return f + }() + + private lazy var monthWideFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "MMMM" + return f + }() + + private lazy var monthAbbrevFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "MMM" + return f + }() + + private lazy var monthAbbrevDayFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "MMM d" + return f + }() + + private lazy var monthAbbrevYearFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "MMM yyyy" + return f + }() + + private lazy var monthWideYearFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "MMMM yyyy" + return f + }() + + private lazy var weekdayAbbrevMonthAbbrevFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "EEE MMM" + return f + }() + + private lazy var weekdayWideMonthAbbrevFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "EEEE MMM" + return f + }() + + private lazy var yearMonthDayDigitsFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy/MM/dd" + return f + }() + + private lazy var dateMediumFormatter: DateFormatter = { + let f = DateFormatter() + f.dateStyle = .medium + return f + }() + + private lazy var dateFullFormatter: DateFormatter = { + let f = DateFormatter() + f.dateStyle = .full + return f + }() + + private init() {} + + /// Get cached formatted string for a date + func string(for date: Date, format: Format) -> String { + let dayKey = dayIdentifier(for: date) + + // Check cache + if let formatCache = cache[dayKey], let cached = formatCache[format] { + return cached + } + + // Format and cache + let formatted = formatDate(date, format: format) + + if cache[dayKey] == nil { + cache[dayKey] = [:] + } + cache[dayKey]?[format] = formatted + + return formatted + } + + private func dayIdentifier(for date: Date) -> Int { + // Use days since reference date as unique key + Int(calendar.startOfDay(for: date).timeIntervalSinceReferenceDate / 86400) + } + + private func formatDate(_ date: Date, format: Format) -> String { + switch format { + case .day: + return dayFormatter.string(from: date) + case .weekdayWide: + return weekdayWideFormatter.string(from: date) + case .weekdayAbbreviated: + return weekdayAbbrevFormatter.string(from: date) + case .weekdayWideDay: + return weekdayWideDayFormatter.string(from: date) + case .monthWide: + return monthWideFormatter.string(from: date) + case .monthAbbreviated: + return monthAbbrevFormatter.string(from: date) + case .monthAbbreviatedDay: + return monthAbbrevDayFormatter.string(from: date) + case .monthAbbreviatedYear: + return monthAbbrevYearFormatter.string(from: date) + case .monthWideYear: + return monthWideYearFormatter.string(from: date) + case .weekdayAbbrevMonthAbbrev: + return weekdayAbbrevMonthAbbrevFormatter.string(from: date) + case .weekdayWideMonthAbbrev: + return weekdayWideMonthAbbrevFormatter.string(from: date) + case .yearMonthDayDigits: + return yearMonthDayDigitsFormatter.string(from: date) + case .dateMedium: + return dateMediumFormatter.string(from: date) + case .dateFull: + return dateFullFormatter.string(from: date) + } + } +} + extension Bundle { var appName: String { return infoDictionary?["CFBundleName"] as! String diff --git a/Shared/Views/AddMoodHeaderView.swift b/Shared/Views/AddMoodHeaderView.swift index eff2aa4..c8138a7 100644 --- a/Shared/Views/AddMoodHeaderView.swift +++ b/Shared/Views/AddMoodHeaderView.swift @@ -13,9 +13,10 @@ struct AddMoodHeaderView: View { @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @AppStorage(UserDefaultsStore.Keys.moodImages.rawValue, store: GroupUserDefaults.groupDefaults) private var imagePack: MoodImages = .FontAwesome @AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor @AppStorage(UserDefaultsStore.Keys.votingLayoutStyle.rawValue, store: GroupUserDefaults.groupDefaults) private var votingLayoutStyle: Int = 0 + private var textColor: Color { theme.currentTheme.labelColor } + @State var onboardingData = OnboardingDataDataManager.shared.savedOnboardingData // Celebration animation state diff --git a/Shared/Views/CustomIcon/CreateWidgetView.swift b/Shared/Views/CustomIcon/CreateWidgetView.swift index 0341526..a35d4da 100644 --- a/Shared/Views/CustomIcon/CreateWidgetView.swift +++ b/Shared/Views/CustomIcon/CreateWidgetView.swift @@ -10,8 +10,9 @@ import SwiftUI struct CreateWidgetView: View { @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @Environment(\.dismiss) var dismiss - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor - + + private var textColor: Color { theme.currentTheme.labelColor } + @StateObject private var customWidget: CustomWidgetModel @State private var mouth: CustomWidgetMouthOptions = CustomWidgetMouthOptions.defaultOption diff --git a/Shared/Views/CustomizeView/CustomizeView.swift b/Shared/Views/CustomizeView/CustomizeView.swift index 113ac8c..efa44b5 100644 --- a/Shared/Views/CustomizeView/CustomizeView.swift +++ b/Shared/Views/CustomizeView/CustomizeView.swift @@ -65,18 +65,8 @@ struct CustomizeContentView: View { // APPEARANCE SettingsSection(title: "Appearance") { - VStack(spacing: 16) { - // Theme - SettingsRow(title: "Theme") { - ThemePickerCompact() - } - - Divider() - - // Text Color - SettingsRow(title: "Text Color") { - TextColorPickerCompact() - } + SettingsRow(title: "Theme") { + ThemePickerCompact() } } @@ -90,13 +80,6 @@ struct CustomizeContentView: View { Divider() - // Mood Colors - SettingsRow(title: "Colors") { - TintPickerCompact() - } - - Divider() - // Day View Style SettingsRow(title: "Entry Style") { DayViewStylePickerCompact() @@ -143,7 +126,6 @@ struct CustomizeView: View { @Environment(\.colorScheme) private var colorScheme @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor var body: some View { ScrollView { @@ -157,18 +139,8 @@ struct CustomizeView: View { // APPEARANCE SettingsSection(title: "Appearance") { - VStack(spacing: 16) { - // Theme - SettingsRow(title: "Theme") { - ThemePickerCompact() - } - - Divider() - - // Text Color - SettingsRow(title: "Text Color") { - TextColorPickerCompact() - } + SettingsRow(title: "Theme") { + ThemePickerCompact() } } @@ -182,13 +154,6 @@ struct CustomizeView: View { Divider() - // Mood Colors - SettingsRow(title: "Colors") { - TintPickerCompact() - } - - Divider() - // Day View Style SettingsRow(title: "Entry Style") { DayViewStylePickerCompact() @@ -237,7 +202,7 @@ struct CustomizeView: View { HStack { Text("Customize") .font(.title.weight(.bold)) - .foregroundColor(textColor) + .foregroundColor(theme.currentTheme.labelColor) Spacer() } @@ -251,13 +216,13 @@ struct SettingsSection: View { @ViewBuilder let content: Content @Environment(\.colorScheme) private var colorScheme - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor + @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system var body: some View { VStack(alignment: .leading, spacing: 12) { Text(title.uppercased()) .font(.caption.weight(.semibold)) - .foregroundColor(textColor.opacity(0.4)) + .foregroundColor(theme.currentTheme.labelColor.opacity(0.4)) .tracking(0.5) VStack(spacing: 0) { @@ -277,13 +242,13 @@ struct SettingsRow: View { let title: String @ViewBuilder let content: Content - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor + @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system var body: some View { VStack(alignment: .leading, spacing: 12) { Text(title) .font(.subheadline.weight(.medium)) - .foregroundColor(textColor.opacity(0.7)) + .foregroundColor(theme.currentTheme.labelColor.opacity(0.7)) content } @@ -294,14 +259,12 @@ struct SettingsRow: View { struct ThemePickerCompact: View { @Environment(\.colorScheme) var colorScheme @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor var body: some View { HStack(spacing: 20) { ForEach(Theme.allCases, id: \.rawValue) { aTheme in Button(action: { theme = aTheme - changeTextColor(forTheme: aTheme) EventLogger.log(event: "change_theme_id", withData: ["id": aTheme.rawValue]) }) { VStack(spacing: 8) { @@ -323,7 +286,7 @@ struct ThemePickerCompact: View { Text(aTheme.title) .font(.caption.weight(.medium)) - .foregroundColor(theme == aTheme ? .accentColor : textColor.opacity(0.6)) + .foregroundColor(theme == aTheme ? .accentColor : theme.currentTheme.labelColor.opacity(0.6)) } } .buttonStyle(BorderlessButtonStyle()) @@ -331,33 +294,6 @@ struct ThemePickerCompact: View { Spacer() } } - - private func changeTextColor(forTheme theme: Theme) { - if [Theme.iFeel, Theme.system].contains(theme) { - let currentSystemScheme = UITraitCollection.current.userInterfaceStyle - textColor = currentSystemScheme == .dark ? .white : .black - } - if theme == Theme.dark { textColor = .white } - if theme == Theme.light { textColor = .black } - } -} - -// MARK: - Text Color Picker -struct TextColorPickerCompact: View { - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor - - var body: some View { - HStack(spacing: 16) { - ColorPicker("", selection: $textColor) - .labelsHidden() - - Text("Sample Text") - .font(.body.weight(.medium)) - .foregroundColor(textColor) - - Spacer() - } - } } // MARK: - Image Pack Picker @@ -412,111 +348,10 @@ struct ImagePackPickerCompact: View { } } -// MARK: - Tint Picker -struct TintPickerCompact: View { - @AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default - @AppStorage(UserDefaultsStore.Keys.customMoodTintUpdateNumber.rawValue, store: GroupUserDefaults.groupDefaults) private var customMoodTintUpdateNumber: Int = 0 - @StateObject private var customMoodTint = UserDefaultsStore.getCustomMoodTint() - @Environment(\.colorScheme) private var colorScheme - - var body: some View { - VStack(spacing: 8) { - ForEach(MoodTints.defaultOptions, id: \.rawValue) { tint in - Button(action: { - let impactMed = UIImpactFeedbackGenerator(style: .medium) - impactMed.impactOccurred() - moodTint = tint - EventLogger.log(event: "change_mood_tint_id", withData: ["id": tint.rawValue]) - }) { - HStack { - HStack(spacing: 12) { - ForEach(Mood.allValues, id: \.self) { mood in - Circle() - .fill(tint.color(forMood: mood)) - .frame(width: 28, height: 28) - } - } - - Spacer() - - if moodTint == tint { - Image(systemName: "checkmark.circle.fill") - .font(.title2) - .foregroundColor(.accentColor) - } - } - .padding(14) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(moodTint == tint - ? Color.accentColor.opacity(0.08) - : (colorScheme == .dark ? Color(.systemGray5) : Color(.systemGray6))) - ) - } - .buttonStyle(.plain) - } - - // Custom colors - Button(action: { - moodTint = .Custom - }) { - HStack { - HStack(spacing: 12) { - ForEach(0..<5, id: \.self) { index in - ColorPicker("", selection: colorBinding(for: index)) - .labelsHidden() - .onChange(of: colorBinding(for: index).wrappedValue) { - saveCustomMoodTint() - } - } - } - - Spacer() - - if moodTint == .Custom { - Image(systemName: "checkmark.circle.fill") - .font(.title2) - .foregroundColor(.accentColor) - } else { - Text("Custom") - .font(.caption) - .foregroundColor(.secondary) - } - } - .padding(14) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(moodTint == .Custom - ? Color.accentColor.opacity(0.08) - : (colorScheme == .dark ? Color(.systemGray5) : Color(.systemGray6))) - ) - } - .buttonStyle(.plain) - } - } - - private func colorBinding(for index: Int) -> Binding { - switch index { - case 0: return $customMoodTint.colorOne - case 1: return $customMoodTint.colorTwo - case 2: return $customMoodTint.colorThree - case 3: return $customMoodTint.colorFour - default: return $customMoodTint.colorFive - } - } - - private func saveCustomMoodTint() { - UserDefaultsStore.saveCustomMoodTint(customTint: customMoodTint) - moodTint = .Custom - EventLogger.log(event: "change_mood_tint_id", withData: ["id": MoodTints.Custom.rawValue]) - customMoodTintUpdateNumber += 1 - } -} - // MARK: - Voting Layout Picker struct VotingLayoutPickerCompact: View { @AppStorage(UserDefaultsStore.Keys.votingLayoutStyle.rawValue, store: GroupUserDefaults.groupDefaults) private var votingLayoutStyle: Int = 0 - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor + @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @Environment(\.colorScheme) private var colorScheme private var currentLayout: VotingLayoutStyle { @@ -540,11 +375,11 @@ struct VotingLayoutPickerCompact: View { VStack(spacing: 6) { layoutIcon(for: layout) .frame(width: 44, height: 44) - .foregroundColor(currentLayout == layout ? .accentColor : textColor.opacity(0.4)) + .foregroundColor(currentLayout == layout ? .accentColor : theme.currentTheme.labelColor.opacity(0.4)) Text(layout.displayName) .font(.caption2.weight(.medium)) - .foregroundColor(currentLayout == layout ? .accentColor : textColor.opacity(0.5)) + .foregroundColor(currentLayout == layout ? .accentColor : theme.currentTheme.labelColor.opacity(0.5)) } .frame(width: 70) .padding(.vertical, 12) @@ -669,7 +504,6 @@ struct VotingLayoutPickerCompact: View { // MARK: - Custom Widget Section struct CustomWidgetSection: View { @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor @StateObject private var selectedWidget = CustomWidgetStateViewModel() var body: some View { @@ -728,7 +562,7 @@ struct CustomWidgetSection: View { struct PersonalityPackPickerCompact: View { @AppStorage(UserDefaultsStore.Keys.personalityPack.rawValue, store: GroupUserDefaults.groupDefaults) private var personalityPack: PersonalityPack = .Default @AppStorage(UserDefaultsStore.Keys.showNSFW.rawValue, store: GroupUserDefaults.groupDefaults) private var showNSFW: Bool = false - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor + @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @State private var showOver18Alert = false @Environment(\.colorScheme) private var colorScheme @@ -751,12 +585,12 @@ struct PersonalityPackPickerCompact: View { VStack(alignment: .leading, spacing: 4) { Text(String(aPack.title())) .font(.subheadline.weight(.semibold)) - .foregroundColor(textColor) + .foregroundColor(theme.currentTheme.labelColor) let strings = aPack.randomPushNotificationStrings() Text(strings.body) .font(.caption) - .foregroundColor(textColor.opacity(0.5)) + .foregroundColor(theme.currentTheme.labelColor.opacity(0.5)) .lineLimit(2) } @@ -798,7 +632,7 @@ struct PersonalityPackPickerCompact: View { // MARK: - Day Filter Picker struct DayFilterPickerCompact: View { @StateObject private var filteredDays = DaysFilterClass.shared - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor + @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @Environment(\.colorScheme) private var colorScheme let weekdays = [(Calendar.current.shortWeekdaySymbols[0], 1), @@ -828,7 +662,7 @@ struct DayFilterPickerCompact: View { }) { Text(day.prefix(2).uppercased()) .font(.caption.weight(.semibold)) - .foregroundColor(isActive ? .white : textColor.opacity(0.5)) + .foregroundColor(isActive ? .white : theme.currentTheme.labelColor.opacity(0.5)) .frame(maxWidth: .infinity) .frame(height: 40) .background( @@ -842,7 +676,7 @@ struct DayFilterPickerCompact: View { Text(String(localized: "day_picker_view_text")) .font(.caption) - .foregroundColor(textColor.opacity(0.5)) + .foregroundColor(theme.currentTheme.labelColor.opacity(0.5)) .multilineTextAlignment(.center) } } @@ -953,7 +787,7 @@ struct SubscriptionBannerView: View { // MARK: - Day View Style Picker struct DayViewStylePickerCompact: View { @AppStorage(UserDefaultsStore.Keys.dayViewStyle.rawValue, store: GroupUserDefaults.groupDefaults) private var dayViewStyle: DayViewStyle = .classic - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor + @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @Environment(\.colorScheme) private var colorScheme var body: some View { @@ -975,11 +809,11 @@ struct DayViewStylePickerCompact: View { VStack(spacing: 6) { styleIcon(for: style) .frame(width: 44, height: 44) - .foregroundColor(dayViewStyle == style ? .accentColor : textColor.opacity(0.4)) + .foregroundColor(dayViewStyle == style ? .accentColor : theme.currentTheme.labelColor.opacity(0.4)) Text(style.displayName) .font(.caption2.weight(.medium)) - .foregroundColor(dayViewStyle == style ? .accentColor : textColor.opacity(0.5)) + .foregroundColor(dayViewStyle == style ? .accentColor : theme.currentTheme.labelColor.opacity(0.5)) } .frame(width: 70) .padding(.vertical, 12) diff --git a/Shared/Views/CustomizeView/SubViews/AppThemePickerView.swift b/Shared/Views/CustomizeView/SubViews/AppThemePickerView.swift index 9870488..0be0642 100644 --- a/Shared/Views/CustomizeView/SubViews/AppThemePickerView.swift +++ b/Shared/Views/CustomizeView/SubViews/AppThemePickerView.swift @@ -288,12 +288,10 @@ struct AppThemePreviewSheet: View { .padding(.horizontal, 20) VStack(spacing: 12) { - ThemeComponentRow( + ThemeColorRow( icon: "paintpalette.fill", title: "Colors", - value: theme.colorTint == .Default ? "Default" : - theme.colorTint == .Neon ? "Neon" : - theme.colorTint == .Pastel ? "Pastel" : "Custom", + moodTint: theme.colorTint, color: .orange ) @@ -404,6 +402,48 @@ struct ThemeComponentRow: View { } } +// MARK: - Color Row with Circles + +struct ThemeColorRow: View { + let icon: String + let title: String + let moodTint: MoodTints + let color: Color + + @Environment(\.colorScheme) private var colorScheme + + private let moods: [Mood] = [.great, .good, .average, .bad, .horrible] + + var body: some View { + HStack(spacing: 14) { + Image(systemName: icon) + .font(.title3) + .foregroundColor(color) + .frame(width: 36, height: 36) + .background(color.opacity(0.15)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + Text(title) + .font(.subheadline) + .foregroundColor(.secondary) + + Spacer() + + HStack(spacing: 6) { + ForEach(moods, id: \.rawValue) { mood in + Circle() + .fill(moodTint.color(forMood: mood)) + .frame(width: 20, height: 20) + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(colorScheme == .dark ? Color(.systemGray6) : .white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } +} + // MARK: - Preview struct AppThemePickerView_Previews: PreviewProvider { diff --git a/Shared/Views/CustomizeView/SubViews/CustomWigetView.swift b/Shared/Views/CustomizeView/SubViews/CustomWigetView.swift index 0e17b2b..7da002e 100644 --- a/Shared/Views/CustomizeView/SubViews/CustomWigetView.swift +++ b/Shared/Views/CustomizeView/SubViews/CustomWigetView.swift @@ -9,9 +9,10 @@ import SwiftUI struct CustomWigetView: View { @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor @StateObject private var selectedWidget = CustomWidgetStateViewModel() + private var textColor: Color { theme.currentTheme.labelColor } + var body: some View { ZStack { theme.currentTheme.secondaryBGColor diff --git a/Shared/Views/CustomizeView/SubViews/DayFilterPickerView.swift b/Shared/Views/CustomizeView/SubViews/DayFilterPickerView.swift index 91ec702..bf72c38 100644 --- a/Shared/Views/CustomizeView/SubViews/DayFilterPickerView.swift +++ b/Shared/Views/CustomizeView/SubViews/DayFilterPickerView.swift @@ -9,8 +9,9 @@ import SwiftUI struct DayFilterPickerView: View { @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor @StateObject private var filteredDays = DaysFilterClass.shared + + private var textColor: Color { theme.currentTheme.labelColor } let weekdays = [(Calendar.current.shortWeekdaySymbols[0], 1), (Calendar.current.shortWeekdaySymbols[1], 2), diff --git a/Shared/Views/CustomizeView/SubViews/PersonalityPackPickerView.swift b/Shared/Views/CustomizeView/SubViews/PersonalityPackPickerView.swift index a54e318..e5c3f4e 100644 --- a/Shared/Views/CustomizeView/SubViews/PersonalityPackPickerView.swift +++ b/Shared/Views/CustomizeView/SubViews/PersonalityPackPickerView.swift @@ -12,8 +12,9 @@ struct PersonalityPackPickerView: View { @AppStorage(UserDefaultsStore.Keys.personalityPack.rawValue, store: GroupUserDefaults.groupDefaults) private var personalityPack: PersonalityPack = .Default @State private var showOver18Alert = false @AppStorage(UserDefaultsStore.Keys.showNSFW.rawValue, store: GroupUserDefaults.groupDefaults) private var showNSFW: Bool = false - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor - + + private var textColor: Color { theme.currentTheme.labelColor } + var body: some View { ZStack { theme.currentTheme.secondaryBGColor diff --git a/Shared/Views/CustomizeView/SubViews/ShapePickerView.swift b/Shared/Views/CustomizeView/SubViews/ShapePickerView.swift index 6e5b699..9eadff8 100644 --- a/Shared/Views/CustomizeView/SubViews/ShapePickerView.swift +++ b/Shared/Views/CustomizeView/SubViews/ShapePickerView.swift @@ -10,10 +10,11 @@ import SwiftUI struct ShapePickerView: View { @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @State var shapeRefreshToggleThing: Bool = false - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor @AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default @AppStorage(UserDefaultsStore.Keys.shape.rawValue, store: GroupUserDefaults.groupDefaults) private var shape: BGShape = .circle - + + private var textColor: Color { theme.currentTheme.labelColor } + var body: some View { ZStack { theme.currentTheme.secondaryBGColor diff --git a/Shared/Views/CustomizeView/SubViews/TextColorPickerView.swift b/Shared/Views/CustomizeView/SubViews/TextColorPickerView.swift deleted file mode 100644 index 9fad18e..0000000 --- a/Shared/Views/CustomizeView/SubViews/TextColorPickerView.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// TextColorPickerView.swift -// Feels (iOS) -// -// Created by Trey Tartt on 4/2/22. -// - -import SwiftUI - -struct TextColorPickerView: View { - @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor - - var body: some View { - ZStack { - theme.currentTheme.secondaryBGColor - ColorPicker(String(localized: "customize_view_view_text_color"), selection: $textColor) - .padding() - .foregroundColor(textColor) - } - .fixedSize(horizontal: false, vertical: true) - .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) - } -} - -struct TextColorPickerView_Previews: PreviewProvider { - static var previews: some View { - TextColorPickerView() - } -} diff --git a/Shared/Views/CustomizeView/SubViews/ThemePickerView.swift b/Shared/Views/CustomizeView/SubViews/ThemePickerView.swift index 9be1347..073228c 100644 --- a/Shared/Views/CustomizeView/SubViews/ThemePickerView.swift +++ b/Shared/Views/CustomizeView/SubViews/ThemePickerView.swift @@ -11,7 +11,8 @@ struct ThemePickerView: View { @Environment(\.colorScheme) var colorScheme @State private var selectedTheme: Theme = UserDefaultsStore.theme() - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor + + private var textColor: Color { selectedTheme.currentTheme.labelColor } var body: some View { ZStack { @@ -70,31 +71,8 @@ struct ThemePickerView: View { selectedTheme = theme } - changeTextColor(forTheme: theme) EventLogger.log(event: "change_theme_id", withData: ["id": theme.rawValue]) } - - private func changeTextColor(forTheme theme: Theme) { - if [Theme.iFeel, Theme.system].contains(theme) { - let currentSystemScheme = UITraitCollection.current.userInterfaceStyle - switch currentSystemScheme { - case .unspecified: - textColor = .black - case .light: - textColor = .black - case .dark: - textColor = .white - @unknown default: - textColor = .black - } - } - if theme == Theme.dark { - textColor = .white - } - if theme == Theme.light { - textColor = .black - } - } } struct ThemePickerView_Previews: PreviewProvider { diff --git a/Shared/Views/CustomizeView/SubViews/TintPickerView.swift b/Shared/Views/CustomizeView/SubViews/TintPickerView.swift deleted file mode 100644 index 2f75642..0000000 --- a/Shared/Views/CustomizeView/SubViews/TintPickerView.swift +++ /dev/null @@ -1,125 +0,0 @@ -// -// TintPickerView.swift -// Feels (iOS) -// -// Created by Trey Tartt on 4/2/22. -// - -import SwiftUI - -struct TintPickerView: View { - @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system - @AppStorage(UserDefaultsStore.Keys.customMoodTintUpdateNumber.rawValue, store: GroupUserDefaults.groupDefaults) private var customMoodTintUpdateNumber: Int = 0 - @AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default - @StateObject private var customMoodTint = UserDefaultsStore.getCustomMoodTint() - - var body: some View { - ZStack { - theme.currentTheme.secondaryBGColor - VStack { - ForEach(MoodTints.defaultOptions, id: \.rawValue) { tint in - HStack { - ForEach(Mood.allValues, id: \.self) { mood in - Circle() - .frame(width: 35, height: 35) - .foregroundColor( - tint.color(forMood: mood) - ) - } - .frame(minWidth: 0, maxWidth: .infinity) - } - .contentShape(Rectangle()) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(moodTint == tint ? theme.currentTheme.bgColor : .clear) - .padding([.top, .bottom], -3) - - ) - .onTapGesture { - let impactMed = UIImpactFeedbackGenerator(style: .heavy) - impactMed.impactOccurred() - moodTint = tint - EventLogger.log(event: "change_mood_tint_id", withData: ["id": tint.rawValue]) - } - Divider() - } - - ZStack { - Color.clear - - Rectangle() - .frame(height: 35) - .frame(minWidth: 0, maxWidth: .infinity) - .foregroundColor(.clear) - .contentShape(Rectangle()) - .onTapGesture { - moodTint = .Custom - let impactMed = UIImpactFeedbackGenerator(style: .heavy) - impactMed.impactOccurred() - } - - HStack { - ColorPicker("", selection: $customMoodTint.colorOne) - .onChange(of: customMoodTint.colorOne) { - saveCustomMoodTint() - } - .labelsHidden() - .frame(minWidth: 0, maxWidth: .infinity) - - ColorPicker("", selection: $customMoodTint.colorTwo) - .labelsHidden() - .frame(minWidth: 0, maxWidth: .infinity) - .onChange(of: customMoodTint.colorTwo) { - saveCustomMoodTint() - } - - ColorPicker("", selection: $customMoodTint.colorThree) - .labelsHidden() - .frame(minWidth: 0, maxWidth: .infinity) - .onChange(of: customMoodTint.colorThree) { - saveCustomMoodTint() - } - - ColorPicker("", selection: $customMoodTint.colorFour) - .labelsHidden() - .frame(minWidth: 0, maxWidth: .infinity) - .onChange(of: customMoodTint.colorFour) { - saveCustomMoodTint() - } - - ColorPicker("", selection: $customMoodTint.colorFive) - .labelsHidden() - .frame(minWidth: 0, maxWidth: .infinity) - .onChange(of: customMoodTint.colorFive) { - saveCustomMoodTint() - } - } - .background( - Color.clear - ) - } - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(moodTint == .Custom ? theme.currentTheme.bgColor : .clear) - .padding([.top, .bottom], -3) - ) - } - .padding() - } - .fixedSize(horizontal: false, vertical: true) - .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) - } - - private func saveCustomMoodTint() { - UserDefaultsStore.saveCustomMoodTint(customTint: customMoodTint) - moodTint = .Custom - EventLogger.log(event: "change_mood_tint_id", withData: ["id": MoodTints.Custom.rawValue]) - customMoodTintUpdateNumber += 1 - } -} - -struct TintPickerView_Previews: PreviewProvider { - static var previews: some View { - TintPickerView() - } -} diff --git a/Shared/Views/CustomizeView/SubViews/VotingLayoutPickerView.swift b/Shared/Views/CustomizeView/SubViews/VotingLayoutPickerView.swift index 6061813..5fd720c 100644 --- a/Shared/Views/CustomizeView/SubViews/VotingLayoutPickerView.swift +++ b/Shared/Views/CustomizeView/SubViews/VotingLayoutPickerView.swift @@ -9,9 +9,10 @@ import SwiftUI struct VotingLayoutPickerView: View { @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor @AppStorage(UserDefaultsStore.Keys.votingLayoutStyle.rawValue, store: GroupUserDefaults.groupDefaults) private var votingLayoutStyle: Int = 0 + private var textColor: Color { theme.currentTheme.labelColor } + private var currentLayout: VotingLayoutStyle { VotingLayoutStyle(rawValue: votingLayoutStyle) ?? .horizontal } diff --git a/Shared/Views/DayView/DayView.swift b/Shared/Views/DayView/DayView.swift index 13457bf..2e1c4b3 100644 --- a/Shared/Views/DayView/DayView.swift +++ b/Shared/Views/DayView/DayView.swift @@ -20,13 +20,9 @@ struct DayView: View { @AppStorage(UserDefaultsStore.Keys.moodImages.rawValue, store: GroupUserDefaults.groupDefaults) private var imagePack: MoodImages = .FontAwesome @AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor @AppStorage(UserDefaultsStore.Keys.dayViewStyle.rawValue, store: GroupUserDefaults.groupDefaults) private var dayViewStyle: DayViewStyle = .classic @Environment(\.colorScheme) private var colorScheme - // store a value that gets changed when user updates custom colors to update the view since the moodTint doesn't change - @AppStorage(UserDefaultsStore.Keys.customMoodTintUpdateNumber.rawValue, store: GroupUserDefaults.groupDefaults) private var customMoodTintUpdateNumber: Int = 0 - // MARK: edit row properties @State private var showingSheet = false @State private var selectedEntry: MoodEntryModel? @@ -42,9 +38,6 @@ struct DayView: View { var body: some View { ZStack { - Text(String(customMoodTintUpdateNumber)) - .hidden() - mainView .onAppear(perform: { EventLogger.log(event: "show_home_view") @@ -65,6 +58,7 @@ struct DayView: View { } } .padding([.top]) + .preferredColorScheme(theme.preferredColorScheme) } @@ -83,7 +77,6 @@ struct DayView: View { } .padding([.leading, .trailing]) .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in - DataController.shared.fillInMissingDates() viewModel.updateData() } .background( @@ -102,19 +95,20 @@ struct DayView: View { } } + /// Cached sorted year/month data to avoid sorting dictionaries in ForEach + 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) }) } + } + private var listView: some View { ScrollView { LazyVStack(spacing: 8, pinnedViews: [.sectionHeaders]) { - ForEach(viewModel.grouped.sorted(by: { - $0.key > $1.key - }), id: \.key) { year, months in - - // for reach month - ForEach(months.sorted(by: { - $0.key > $1.key - }), id: \.key) { month, entries in - Section(header: SectionHeaderView(month: month, year: year, entries: entries)) { - monthListView(month: month, year: year, entries: entries) + ForEach(sortedGroupedData, id: \.year) { yearData in + ForEach(yearData.months, id: \.month) { monthData in + Section(header: SectionHeaderView(month: monthData.month, year: yearData.year, entries: monthData.entries)) { + monthListView(month: monthData.month, year: yearData.year, entries: monthData.entries) } } } @@ -172,12 +166,12 @@ extension DayView { // Calendar icon Image(systemName: "calendar") .font(.body.weight(.semibold)) - .foregroundColor(textColor.opacity(0.6)) + .foregroundColor(theme.currentTheme.labelColor.opacity(0.6)) .accessibilityHidden(true) Text("\(Random.monthName(fromMonthInt: month)) \(String(year))") .font(.title3.weight(.bold)) - .foregroundColor(textColor) + .foregroundColor(theme.currentTheme.labelColor) Spacer() } @@ -196,7 +190,7 @@ extension DayView { .font(.largeTitle.weight(.black)) .foregroundStyle( LinearGradient( - colors: [textColor, textColor.opacity(0.4)], + colors: [theme.currentTheme.labelColor, theme.currentTheme.labelColor.opacity(0.4)], startPoint: .top, endPoint: .bottom ) @@ -207,18 +201,18 @@ extension DayView { Text(Random.monthName(fromMonthInt: month).uppercased()) .font(.subheadline.weight(.bold)) .tracking(3) - .foregroundColor(textColor) + .foregroundColor(theme.currentTheme.labelColor) Text(String(year)) .font(.caption.weight(.medium)) - .foregroundColor(textColor.opacity(0.5)) + .foregroundColor(theme.currentTheme.labelColor.opacity(0.5)) } Spacer() // Decorative element Circle() - .fill(textColor.opacity(0.1)) + .fill(theme.currentTheme.labelColor.opacity(0.1)) .frame(width: 8, height: 8) } .padding(.horizontal, 20) @@ -232,7 +226,7 @@ extension DayView { // Subtle gradient accent LinearGradient( colors: [ - textColor.opacity(0.03), + theme.currentTheme.labelColor.opacity(0.03), Color.clear ], startPoint: .leading, @@ -246,34 +240,34 @@ extension DayView { VStack(alignment: .leading, spacing: 0) { // Thick editorial rule Rectangle() - .fill(textColor) + .fill(theme.currentTheme.labelColor) .frame(height: 4) HStack(alignment: .firstTextBaseline, spacing: 12) { // Large serif month name Text(Random.monthName(fromMonthInt: month).uppercased()) .font(.title.weight(.regular)) - .foregroundColor(textColor) + .foregroundColor(theme.currentTheme.labelColor) // Year in lighter weight Text(String(year)) .font(.body.weight(.light)) .italic() - .foregroundColor(textColor.opacity(0.5)) + .foregroundColor(theme.currentTheme.labelColor.opacity(0.5)) Spacer() // Decorative flourish Text("§") .font(.title3.weight(.regular)) - .foregroundColor(textColor.opacity(0.3)) + .foregroundColor(theme.currentTheme.labelColor.opacity(0.3)) } .padding(.horizontal, 16) .padding(.vertical, 16) // Thin bottom rule Rectangle() - .fill(textColor.opacity(0.2)) + .fill(theme.currentTheme.labelColor.opacity(0.2)) .frame(height: 1) } .background(.ultraThinMaterial) @@ -306,13 +300,14 @@ extension DayView { ZStack { Color.black - // Scanlines - VStack(spacing: 3) { - ForEach(0..<8, id: \.self) { _ in - Rectangle() - .fill(Color.white.opacity(0.02)) - .frame(height: 1) - Spacer().frame(height: 3) + // Scanlines - simplified with Canvas for performance + Canvas { context, size in + let lineHeight: CGFloat = 4 + var y: CGFloat = 0 + while y < size.height { + let rect = CGRect(x: 0, y: y, width: size.width, height: 1) + context.fill(Path(rect), with: .color(.white.opacity(0.02))) + y += lineHeight } } } @@ -323,18 +318,18 @@ extension DayView { HStack(alignment: .center, spacing: 16) { // Brush stroke accent Capsule() - .fill(textColor.opacity(0.15)) + .fill(theme.currentTheme.labelColor.opacity(0.15)) .frame(width: 40, height: 3) VStack(alignment: .leading, spacing: 2) { Text(Random.monthName(fromMonthInt: month)) .font(.headline.weight(.thin)) .tracking(4) - .foregroundColor(textColor) + .foregroundColor(theme.currentTheme.labelColor) Text(String(year)) .font(.caption2.weight(.ultraLight)) - .foregroundColor(textColor.opacity(0.4)) + .foregroundColor(theme.currentTheme.labelColor.opacity(0.4)) } Spacer() @@ -342,7 +337,7 @@ extension DayView { // Zen circle ornament Circle() .trim(from: 0, to: 0.7) - .stroke(textColor.opacity(0.2), style: StrokeStyle(lineWidth: 2, lineCap: .round)) + .stroke(theme.currentTheme.labelColor.opacity(0.2), style: StrokeStyle(lineWidth: 2, lineCap: .round)) .frame(width: 20, height: 20) .rotationEffect(.degrees(-60)) } @@ -371,15 +366,15 @@ extension DayView { HStack(spacing: 12) { Text(Random.monthName(fromMonthInt: month)) .font(.title3.weight(.semibold)) - .foregroundColor(textColor) + .foregroundColor(theme.currentTheme.labelColor) Capsule() - .fill(textColor.opacity(0.2)) + .fill(theme.currentTheme.labelColor.opacity(0.2)) .frame(width: 4, height: 4) Text(String(year)) .font(.body.weight(.medium)) - .foregroundColor(textColor.opacity(0.6)) + .foregroundColor(theme.currentTheme.labelColor.opacity(0.6)) Spacer() } @@ -395,21 +390,21 @@ extension DayView { // Tape reel icon ZStack { Circle() - .stroke(textColor.opacity(0.3), lineWidth: 2) + .stroke(theme.currentTheme.labelColor.opacity(0.3), lineWidth: 2) .frame(width: 24, height: 24) Circle() - .fill(textColor.opacity(0.2)) + .fill(theme.currentTheme.labelColor.opacity(0.2)) .frame(width: 10, height: 10) } VStack(alignment: .leading, spacing: 2) { Text("SIDE A") .font(.caption2.weight(.bold).monospaced()) - .foregroundColor(textColor.opacity(0.4)) + .foregroundColor(theme.currentTheme.labelColor.opacity(0.4)) Text("\(Random.monthName(fromMonthInt: month).uppercased()) '\(String(year).suffix(2))") .font(.body.weight(.black)) - .foregroundColor(textColor) + .foregroundColor(theme.currentTheme.labelColor) .tracking(1) } @@ -418,7 +413,7 @@ extension DayView { // Track counter Text(String(format: "%02d", month)) .font(.title3.weight(.bold).monospaced()) - .foregroundColor(textColor.opacity(0.3)) + .foregroundColor(theme.currentTheme.labelColor.opacity(0.3)) } .padding(.horizontal, 16) .padding(.vertical, 14) @@ -433,7 +428,7 @@ extension DayView { // Organic blob background HStack { Ellipse() - .fill(textColor.opacity(0.08)) + .fill(theme.currentTheme.labelColor.opacity(0.08)) .frame(width: 120, height: 60) .blur(radius: 15) .offset(x: -20) @@ -443,17 +438,17 @@ extension DayView { HStack(spacing: 16) { Text(Random.monthName(fromMonthInt: month)) .font(.title2.weight(.light)) - .foregroundColor(textColor) + .foregroundColor(theme.currentTheme.labelColor) Text(String(year)) .font(.subheadline.weight(.regular)) - .foregroundColor(textColor.opacity(0.4)) + .foregroundColor(theme.currentTheme.labelColor.opacity(0.4)) Spacer() // Blob indicator Circle() - .fill(textColor.opacity(0.15)) + .fill(theme.currentTheme.labelColor.opacity(0.15)) .frame(width: 12, height: 12) .blur(radius: 2) } @@ -465,12 +460,13 @@ extension DayView { private func stackSectionHeader(month: Int, year: Int) -> some View { VStack(alignment: .leading, spacing: 0) { - // Torn edge - HStack(spacing: 0) { - ForEach(0..<30, id: \.self) { _ in - Rectangle() - .fill(textColor.opacity(0.2)) - .frame(height: CGFloat.random(in: 2...4)) + // Torn edge - simplified with Canvas for performance + Canvas { context, size in + let segmentWidth = size.width / 15 + for i in 0..<15 { + let height = CGFloat(2 + (i * 5) % 3) // Deterministic pseudo-random heights + let rect = CGRect(x: CGFloat(i) * segmentWidth, y: 0, width: segmentWidth, height: height) + context.fill(Path(rect), with: .color(Color(uiColor: UIColor.label).opacity(0.2))) } } .frame(height: 4) @@ -483,12 +479,12 @@ extension DayView { Text(Random.monthName(fromMonthInt: month)) .font(.headline.weight(.regular)) - .foregroundColor(textColor) + .foregroundColor(theme.currentTheme.labelColor) Text(String(year)) .font(.subheadline.weight(.light)) .italic() - .foregroundColor(textColor.opacity(0.5)) + .foregroundColor(theme.currentTheme.labelColor.opacity(0.5)) Spacer() } @@ -516,7 +512,7 @@ extension DayView { } }() - let barColor = hasData ? moodTint.color(forMood: moodForColor) : textColor.opacity(0.2) + let barColor = hasData ? moodTint.color(forMood: moodForColor) : theme.currentTheme.labelColor.opacity(0.2) // Width percentage based on average (0=20%, 4=100%) let widthPercent = hasData ? 0.2 + (averageMood / 4.0) * 0.8 : 0.2 @@ -524,7 +520,7 @@ extension DayView { // Month number Text(String(format: "%02d", month)) .font(.title.weight(.thin)) - .foregroundColor(hasData ? barColor.opacity(0.6) : textColor.opacity(0.3)) + .foregroundColor(hasData ? barColor.opacity(0.6) : theme.currentTheme.labelColor.opacity(0.3)) .frame(width: 50) // Gradient bar sized by average mood @@ -532,7 +528,7 @@ extension DayView { ZStack(alignment: .leading) { // Background track Capsule() - .fill(textColor.opacity(0.1)) + .fill(theme.currentTheme.labelColor.opacity(0.1)) .frame(height: 8) // Colored bar based on average @@ -554,7 +550,7 @@ extension DayView { VStack(alignment: .trailing, spacing: 2) { Text(Random.monthName(fromMonthInt: month)) .font(.subheadline.weight(.medium)) - .foregroundColor(textColor) + .foregroundColor(theme.currentTheme.labelColor) if hasData { Text(String(format: "%.1f avg", averageMood + 1)) // Display as 1-5 @@ -563,7 +559,7 @@ extension DayView { } else { Text(String(year)) .font(.caption2.weight(.regular)) - .foregroundColor(textColor.opacity(0.4)) + .foregroundColor(theme.currentTheme.labelColor.opacity(0.4)) } } } @@ -576,11 +572,11 @@ extension DayView { HStack(spacing: 10) { Image(systemName: "calendar") .font(.body.weight(.semibold)) - .foregroundColor(textColor.opacity(0.6)) + .foregroundColor(theme.currentTheme.labelColor.opacity(0.6)) Text("\(Random.monthName(fromMonthInt: month)) \(String(year))") .font(.title3.weight(.bold)) - .foregroundColor(textColor) + .foregroundColor(theme.currentTheme.labelColor) Spacer() } @@ -726,17 +722,17 @@ extension DayView { Text(String(format: "%02d", month)) .font(.subheadline.weight(.semibold)) - .foregroundColor(textColor) + .foregroundColor(theme.currentTheme.labelColor) } VStack(alignment: .leading, spacing: 2) { Text(Random.monthName(fromMonthInt: month)) .font(.headline.weight(.medium)) - .foregroundColor(textColor) + .foregroundColor(theme.currentTheme.labelColor) Text(String(year)) .font(.caption.weight(.regular)) - .foregroundColor(textColor.opacity(0.5)) + .foregroundColor(theme.currentTheme.labelColor.opacity(0.5)) } Spacer() @@ -818,11 +814,11 @@ extension DayView { VStack(alignment: .leading, spacing: 2) { Text(Random.monthName(fromMonthInt: month)) .font(.title3.weight(.semibold)) - .foregroundColor(textColor) + .foregroundColor(theme.currentTheme.labelColor) Text(String(year)) .font(.caption.weight(.medium)) - .foregroundColor(textColor.opacity(0.5)) + .foregroundColor(theme.currentTheme.labelColor.opacity(0.5)) } Spacer() @@ -830,7 +826,7 @@ extension DayView { // Tilt indicator Image(systemName: "iphone.gen3.radiowaves.left.and.right") .font(.headline) - .foregroundColor(textColor.opacity(0.3)) + .foregroundColor(theme.currentTheme.labelColor.opacity(0.3)) } .padding(.horizontal, 18) } @@ -847,24 +843,24 @@ extension DayView { HStack(spacing: 8) { // Minimal colored bar RoundedRectangle(cornerRadius: 2) - .fill(textColor.opacity(0.3)) + .fill(theme.currentTheme.labelColor.opacity(0.3)) .frame(width: 3, height: 16) Text("\(Random.monthName(fromMonthInt: month).prefix(3).uppercased())") .font(.caption2.weight(.bold).monospaced()) - .foregroundColor(textColor.opacity(0.6)) + .foregroundColor(theme.currentTheme.labelColor.opacity(0.6)) Text("•") .font(.caption2) - .foregroundColor(textColor.opacity(0.3)) + .foregroundColor(theme.currentTheme.labelColor.opacity(0.3)) Text(String(year)) .font(.caption2.weight(.medium).monospaced()) - .foregroundColor(textColor.opacity(0.4)) + .foregroundColor(theme.currentTheme.labelColor.opacity(0.4)) // Thin separator line Rectangle() - .fill(textColor.opacity(0.1)) + .fill(theme.currentTheme.labelColor.opacity(0.1)) .frame(height: 1) } .padding(.horizontal, 14) diff --git a/Shared/Views/DayView/DayViewViewModel.swift b/Shared/Views/DayView/DayViewViewModel.swift index 502941a..0576bba 100644 --- a/Shared/Views/DayView/DayViewViewModel.swift +++ b/Shared/Views/DayView/DayViewViewModel.swift @@ -37,9 +37,8 @@ class DayViewViewModel: ObservableObject { DataController.shared.addNewDataListener { [weak self] in guard let self = self else { return } - withAnimation { - self.updateData() - } + // Avoid withAnimation for bulk data updates - it causes expensive view diffing + self.updateData() } updateData() } diff --git a/Shared/Views/EmptyView.swift b/Shared/Views/EmptyView.swift index d84cc9b..bdd1f4c 100644 --- a/Shared/Views/EmptyView.swift +++ b/Shared/Views/EmptyView.swift @@ -9,7 +9,8 @@ import SwiftUI struct EmptyHomeView: View { @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor + + private var textColor: Color { theme.currentTheme.labelColor } let showVote: Bool let viewModel: DayViewViewModel? diff --git a/Shared/Views/EntryListView.swift b/Shared/Views/EntryListView.swift index 2d144e5..c6b6713 100644 --- a/Shared/Views/EntryListView.swift +++ b/Shared/Views/EntryListView.swift @@ -11,10 +11,12 @@ import CoreMotion struct EntryListView: View { @AppStorage(UserDefaultsStore.Keys.moodImages.rawValue, store: GroupUserDefaults.groupDefaults) private var imagePack: MoodImages = .FontAwesome @AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor @AppStorage(UserDefaultsStore.Keys.dayViewStyle.rawValue, store: GroupUserDefaults.groupDefaults) private var dayViewStyle: DayViewStyle = .classic + @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @Environment(\.colorScheme) private var colorScheme + private var textColor: Color { theme.currentTheme.labelColor } + public let entry: MoodEntryModel private var moodColor: Color { @@ -30,6 +32,21 @@ struct EntryListView: View { entry.moodValue == Mood.missing.rawValue } + // MARK: - Cached Date Strings (avoids repeated ICU/Calendar operations) + private var dateCache: DateFormattingCache { DateFormattingCache.shared } + private var cachedDay: String { dateCache.string(for: entry.forDate, format: .day) } + private var cachedWeekdayWide: String { dateCache.string(for: entry.forDate, format: .weekdayWide) } + private var cachedWeekdayAbbrev: String { dateCache.string(for: entry.forDate, format: .weekdayAbbreviated) } + private var cachedWeekdayWideDay: String { dateCache.string(for: entry.forDate, format: .weekdayWideDay) } + private var cachedMonthWide: String { dateCache.string(for: entry.forDate, format: .monthWide) } + private var cachedMonthAbbrev: String { dateCache.string(for: entry.forDate, format: .monthAbbreviated) } + private var cachedMonthAbbrevDay: String { dateCache.string(for: entry.forDate, format: .monthAbbreviatedDay) } + private var cachedMonthAbbrevYear: String { dateCache.string(for: entry.forDate, format: .monthAbbreviatedYear) } + private var cachedMonthWideYear: String { dateCache.string(for: entry.forDate, format: .monthWideYear) } + private var cachedWeekdayAbbrevMonthAbbrev: String { dateCache.string(for: entry.forDate, format: .weekdayAbbrevMonthAbbrev) } + private var cachedWeekdayWideMonthAbbrev: String { dateCache.string(for: entry.forDate, format: .weekdayWideMonthAbbrev) } + private var cachedYearMonthDayDigits: String { dateCache.string(for: entry.forDate, format: .yearMonthDayDigits) } + var body: some View { Group { switch dayViewStyle { @@ -82,9 +99,7 @@ struct EntryListView: View { } private var accessibilityDescription: String { - let dateFormatter = DateFormatter() - dateFormatter.dateStyle = .full - let dateString = dateFormatter.string(from: entry.forDate) + let dateString = DateFormattingCache.shared.string(for: entry.forDate, format: .dateFull) if isMissing { return String(localized: "\(dateString), no mood logged") @@ -196,7 +211,7 @@ struct EntryListView: View { ) VStack(alignment: .leading, spacing: 3) { - Text(entry.forDate, format: .dateTime.weekday(.wide).day()) + Text(cachedWeekdayWideDay) .font(.subheadline.weight(.medium)) .foregroundColor(textColor) @@ -233,10 +248,10 @@ struct EntryListView: View { // Date column VStack(alignment: .leading, spacing: 0) { - Text(entry.forDate, format: .dateTime.day()) + Text(cachedDay) .font(.title3.weight(.bold)) .foregroundColor(textColor) - Text(entry.forDate, format: .dateTime.weekday(.abbreviated)) + Text(cachedWeekdayAbbrev) .font(.caption2.weight(.medium)) .foregroundColor(textColor.opacity(0.5)) .textCase(.uppercase) @@ -293,7 +308,7 @@ struct EntryListView: View { .accessibilityLabel(entry.mood.strValue) VStack(alignment: .leading, spacing: 2) { - Text(entry.forDate, format: .dateTime.weekday(.wide).day()) + Text(cachedWeekdayWideDay) .font(.subheadline.weight(.semibold)) .foregroundColor(isMissing ? textColor : moodContrastingTextColor) @@ -361,12 +376,12 @@ struct EntryListView: View { ) // Day number - Text(entry.forDate, format: .dateTime.day()) + Text(cachedDay) .font(.subheadline.weight(.bold)) .foregroundColor(textColor) // Weekday abbreviation - Text(entry.forDate, format: .dateTime.weekday(.abbreviated)) + Text(cachedWeekdayAbbrev) .font(.caption2.weight(.medium)) .foregroundColor(textColor.opacity(0.5)) .textCase(.uppercase) @@ -383,7 +398,7 @@ struct EntryListView: View { private var auraStyle: some View { HStack(spacing: 0) { // Giant day number - the visual hero - Text(entry.forDate, format: .dateTime.day()) + Text(cachedDay) .font(.largeTitle.weight(.black)) .foregroundStyle( isMissing @@ -396,7 +411,7 @@ struct EntryListView: View { // Content area with glowing aura VStack(alignment: .leading, spacing: 8) { // Weekday with elegant typography - Text(entry.forDate, format: .dateTime.weekday(.wide)) + Text(cachedWeekdayWide) .font(.caption.weight(.semibold)) .textCase(.uppercase) .tracking(2) @@ -449,7 +464,7 @@ struct EntryListView: View { .foregroundColor(textColor) // Month context - Text(entry.forDate, format: .dateTime.month(.wide)) + Text(cachedMonthWide) .font(.caption.weight(.medium)) .foregroundColor(textColor.opacity(0.4)) } @@ -522,12 +537,12 @@ struct EntryListView: View { HStack(alignment: .top, spacing: 16) { // Left column: Giant day number in serif VStack(alignment: .trailing, spacing: 0) { - Text(entry.forDate, format: .dateTime.day()) + Text(cachedDay) .font(.largeTitle.weight(.regular)) .foregroundColor(textColor) .frame(width: 80) - Text(entry.forDate, format: .dateTime.weekday(.abbreviated).month(.abbreviated)) + Text(cachedWeekdayAbbrevMonthAbbrev) .font(.caption2.weight(.regular)) .italic() .foregroundColor(textColor.opacity(0.5)) @@ -641,13 +656,14 @@ struct EntryListView: View { } .clipShape(RoundedRectangle(cornerRadius: 6)) - // Scanline overlay for CRT effect - VStack(spacing: 0) { - ForEach(0..<30, id: \.self) { _ in - Rectangle() - .fill(Color.white.opacity(0.015)) - .frame(height: 1) - Spacer().frame(height: 2) + // Scanline overlay for CRT effect - simplified with gradient stripes + Canvas { context, size in + let lineHeight: CGFloat = 3 + var y: CGFloat = 0 + while y < size.height { + let rect = CGRect(x: 0, y: y, width: size.width, height: 1) + context.fill(Path(rect), with: .color(.white.opacity(0.015))) + y += lineHeight } } .clipShape(RoundedRectangle(cornerRadius: 6)) @@ -697,7 +713,7 @@ struct EntryListView: View { VStack(alignment: .leading, spacing: 5) { // Date in cyan monospace - Text(entry.forDate, format: .dateTime.year().month(.twoDigits).day(.twoDigits)) + Text(cachedYearMonthDayDigits) .font(.system(.caption, design: .monospaced).weight(.semibold)) .foregroundColor(neonCyan) .shadow(color: neonCyan.opacity(0.5), radius: 4, x: 0, y: 0) @@ -722,7 +738,7 @@ struct EntryListView: View { } // Weekday in magenta - Text(entry.forDate, format: .dateTime.weekday(.wide)) + Text(cachedWeekdayWide) .font(.system(.caption2, design: .monospaced).weight(.medium)) .foregroundColor(neonMagenta.opacity(0.7)) .textCase(.uppercase) @@ -836,18 +852,18 @@ struct EntryListView: View { VStack(alignment: .leading, spacing: 8) { // Day number with brush-like weight variation HStack(alignment: .firstTextBaseline, spacing: 4) { - Text(entry.forDate, format: .dateTime.day()) + Text(cachedDay) .font(.title.weight(.thin)) .foregroundColor(textColor) VStack(alignment: .leading, spacing: 2) { - Text(entry.forDate, format: .dateTime.month(.wide)) + Text(cachedMonthWide) .font(.caption2.weight(.light)) .foregroundColor(textColor.opacity(0.5)) .textCase(.uppercase) .tracking(2) - Text(entry.forDate, format: .dateTime.weekday(.wide)) + Text(cachedWeekdayWide) .font(.caption2.weight(.light)) .foregroundColor(textColor.opacity(0.35)) } @@ -984,12 +1000,12 @@ struct EntryListView: View { } VStack(alignment: .leading, spacing: 6) { - Text(entry.forDate, format: .dateTime.weekday(.wide)) + Text(cachedWeekdayWide) .font(.subheadline.weight(.semibold)) .foregroundColor(textColor) HStack(spacing: 8) { - Text(entry.forDate, format: .dateTime.month(.abbreviated).day()) + Text(cachedMonthAbbrevDay) .font(.caption.weight(.medium)) .foregroundColor(textColor.opacity(0.6)) @@ -1037,7 +1053,7 @@ struct EntryListView: View { HStack(spacing: 0) { // Track number column VStack { - Text(entry.forDate, format: .dateTime.day()) + Text(cachedDay) .font(.title2.weight(.bold).monospaced()) .foregroundColor(isMissing ? .gray : moodColor) } @@ -1067,7 +1083,7 @@ struct EntryListView: View { // Track info VStack(alignment: .leading, spacing: 4) { - Text(entry.forDate, format: .dateTime.weekday(.wide).month(.abbreviated)) + Text(cachedWeekdayWideMonthAbbrev) .font(.caption2.weight(.medium).monospaced()) .foregroundColor(textColor.opacity(0.5)) .textCase(.uppercase) @@ -1194,15 +1210,15 @@ struct EntryListView: View { VStack(alignment: .leading, spacing: 8) { // Date with organic flow HStack(spacing: 0) { - Text(entry.forDate, format: .dateTime.day()) + Text(cachedDay) .font(.title.weight(.light)) .foregroundColor(textColor) VStack(alignment: .leading, spacing: 0) { - Text(entry.forDate, format: .dateTime.month(.abbreviated)) + Text(cachedMonthAbbrev) .font(.caption.weight(.medium)) .foregroundColor(textColor.opacity(0.6)) - Text(entry.forDate, format: .dateTime.weekday(.abbreviated)) + Text(cachedWeekdayAbbrev) .font(.caption2.weight(.regular)) .foregroundColor(textColor.opacity(0.4)) } @@ -1246,12 +1262,14 @@ struct EntryListView: View { // Main note VStack(alignment: .leading, spacing: 0) { - // Torn paper edge effect - HStack(spacing: 0) { - ForEach(0..<20, id: \.self) { i in - Rectangle() - .fill(isMissing ? Color.gray.opacity(0.3) : moodColor.opacity(0.6)) - .frame(width: .infinity, height: CGFloat.random(in: 3...6)) + // Torn paper edge effect - simplified with Canvas + Canvas { context, size in + let segmentWidth = size.width / 10 + let color = (isMissing ? Color.gray.opacity(0.3) : moodColor.opacity(0.6)) + for i in 0..<10 { + let height = CGFloat(3 + (i * 7) % 4) // Deterministic pseudo-random heights + let rect = CGRect(x: CGFloat(i) * segmentWidth, y: 0, width: segmentWidth, height: height) + context.fill(Path(rect), with: .color(color)) } } .frame(height: 6) @@ -1259,11 +1277,11 @@ struct EntryListView: View { HStack(spacing: 16) { // Handwritten-style date VStack(alignment: .center, spacing: 2) { - Text(entry.forDate, format: .dateTime.day()) + Text(cachedDay) .font(.title.weight(.light)) .foregroundColor(textColor) - Text(entry.forDate, format: .dateTime.weekday(.abbreviated)) + Text(cachedWeekdayAbbrev) .font(.caption.weight(.medium)) .foregroundColor(textColor.opacity(0.5)) .textCase(.uppercase) @@ -1278,7 +1296,7 @@ struct EntryListView: View { // Content area VStack(alignment: .leading, spacing: 8) { // Lined paper effect - Text(entry.forDate, format: .dateTime.month(.wide).year()) + Text(cachedMonthWideYear) .font(.caption.weight(.regular)) .foregroundColor(textColor.opacity(0.4)) @@ -1330,11 +1348,11 @@ struct EntryListView: View { HStack(spacing: 0) { // Date column - minimal VStack(alignment: .trailing, spacing: 2) { - Text(entry.forDate, format: .dateTime.day()) + Text(cachedDay) .font(.title.weight(.thin)) .foregroundColor(textColor) - Text(entry.forDate, format: .dateTime.weekday(.abbreviated)) + Text(cachedWeekdayAbbrev) .font(.caption2.weight(.medium)) .foregroundColor(textColor.opacity(0.4)) .textCase(.uppercase) @@ -1401,7 +1419,7 @@ struct EntryListView: View { Spacer() // Month indicator - Text(entry.forDate, format: .dateTime.month(.abbreviated)) + Text(cachedMonthAbbrev) .font(.caption2.weight(.bold)) .foregroundColor(isMissing ? .gray : moodContrastingTextColor.opacity(0.7)) .padding(.trailing, 16) @@ -1436,7 +1454,7 @@ struct EntryListView: View { } VStack(alignment: .leading, spacing: 6) { - Text(entry.forDate, format: .dateTime.weekday(.wide).day()) + Text(cachedWeekdayWideDay) .font(.body.weight(.semibold)) .foregroundColor(textColor) @@ -1456,7 +1474,7 @@ struct EntryListView: View { Spacer() - Text(entry.forDate, format: .dateTime.month(.abbreviated)) + Text(cachedMonthAbbrev) .font(.caption.weight(.medium)) .foregroundColor(textColor.opacity(0.6)) .padding(.horizontal, 10) @@ -1466,35 +1484,30 @@ struct EntryListView: View { .padding(16) .frame(height: 86) .background { - // Repeating mood icon pattern background - GeometryReader { geo in + // Simplified pattern background using Canvas for better performance + Canvas { context, size in let iconSize: CGFloat = 20 let spacing: CGFloat = 28 - let cols = Int(geo.size.width / spacing) + 2 - let rows = Int(geo.size.height / spacing) + 2 + let patternColor = (isMissing ? Color.gray : moodColor).opacity(0.15) - ZStack { - // Base background color - RoundedRectangle(cornerRadius: 16) - .fill(colorScheme == .dark ? Color(.systemGray6) : Color(.systemGray6).opacity(0.5)) - - // Pattern overlay - ForEach(0.. Void) - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor - + + private var textColor: Color { theme.currentTheme.labelColor } + let imageOptions = CustomWidgeImageOptions.allCases.sorted(by: { $0.rawValue < $1.rawValue }) diff --git a/Shared/Views/InsightsView/InsightsView.swift b/Shared/Views/InsightsView/InsightsView.swift index a76f44f..941dc89 100644 --- a/Shared/Views/InsightsView/InsightsView.swift +++ b/Shared/Views/InsightsView/InsightsView.swift @@ -9,11 +9,12 @@ import SwiftUI struct InsightsView: View { @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor @AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default @AppStorage(UserDefaultsStore.Keys.moodImages.rawValue, store: GroupUserDefaults.groupDefaults) private var imagePack: MoodImages = .FontAwesome @Environment(\.colorScheme) private var colorScheme + private var textColor: Color { theme.currentTheme.labelColor } + @StateObject private var viewModel = InsightsViewModel() @EnvironmentObject var iapManager: IAPManager @State private var showSubscriptionStore = false diff --git a/Shared/Views/MainTabView.swift b/Shared/Views/MainTabView.swift index 418252a..71689d9 100644 --- a/Shared/Views/MainTabView.swift +++ b/Shared/Views/MainTabView.swift @@ -11,7 +11,8 @@ struct MainTabView: View { @AppStorage(UserDefaultsStore.Keys.needsOnboarding.rawValue, store: GroupUserDefaults.groupDefaults) private var needsOnboarding = true @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor + + private var textColor: Color { theme.currentTheme.labelColor } let onboardingData = OnboardingDataDataManager.shared.savedOnboardingData diff --git a/Shared/Views/MonthView/MonthDetailView.swift b/Shared/Views/MonthView/MonthDetailView.swift index 1ca5d35..563fad2 100644 --- a/Shared/Views/MonthView/MonthDetailView.swift +++ b/Shared/Views/MonthView/MonthDetailView.swift @@ -11,7 +11,8 @@ struct MonthDetailView: View { @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @AppStorage(UserDefaultsStore.Keys.deleteEnable.rawValue, store: GroupUserDefaults.groupDefaults) private var deleteEnabled = true @AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor + + private var textColor: Color { theme.currentTheme.labelColor } @StateObject private var shareImage = ShareImageStateViewModel() diff --git a/Shared/Views/MonthView/MonthView.swift b/Shared/Views/MonthView/MonthView.swift index bfbd501..dbb7350 100644 --- a/Shared/Views/MonthView/MonthView.swift +++ b/Shared/Views/MonthView/MonthView.swift @@ -12,17 +12,15 @@ struct MonthView: View { @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system + private var labelColor: Color { theme.currentTheme.labelColor } + @AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default @AppStorage(UserDefaultsStore.Keys.moodImages.rawValue, store: GroupUserDefaults.groupDefaults) private var imagePack: MoodImages = .FontAwesome - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor @AppStorage(UserDefaultsStore.Keys.shape.rawValue, store: GroupUserDefaults.groupDefaults) private var shape: BGShape = .circle @StateObject private var shareImage = ShareImageStateViewModel() - // store a value that gets changed when user updates custom colors to update the view since the moodTint doesn't change - @AppStorage(UserDefaultsStore.Keys.customMoodTintUpdateNumber.rawValue, store: GroupUserDefaults.groupDefaults) private var customMoodTintUpdateNumber: Int = 0 - @EnvironmentObject var iapManager: IAPManager @StateObject private var selectedDetail = DetailViewStateViewModel() @State private var showingSheet = false @@ -43,8 +41,11 @@ struct MonthView: View { @State private var trialWarningHidden = false @State private var showSubscriptionStore = false + /// Cached sorted year/month data to avoid recalculating in ForEach + @State private var cachedSortedData: [(year: Int, months: [(month: Int, entries: [MoodEntryModel])])] = [] + /// Filters month data to only current month when subscription/trial expired - private var filteredMonthData: [Int: [Int: [MoodEntryModel]]] { + private func computeFilteredMonthData() -> [Int: [Int: [MoodEntryModel]]] { guard iapManager.shouldShowPaywall else { return viewModel.grouped } @@ -61,6 +62,13 @@ struct MonthView: View { return filtered } + /// Sorts the filtered month data - called only when source data changes + private func computeSortedYearMonthData() -> [(year: Int, months: [(month: Int, entries: [MoodEntryModel])])] { + computeFilteredMonthData() + .sorted { $0.key > $1.key } + .map { (year: $0.key, months: $0.value.sorted { $0.key > $1.key }.map { (month: $0.key, entries: $0.value) }) } + } + var body: some View { ZStack { if viewModel.hasNoData { @@ -69,23 +77,22 @@ struct MonthView: View { } else { ScrollView { VStack(spacing: 16) { - ForEach(filteredMonthData.sorted(by: { $0.key > $1.key }), id: \.key) { year, months in + ForEach(cachedSortedData, id: \.year) { yearData in // for each month - ForEach(months.sorted(by: { $0.key > $1.key }), id: \.key) { month, entries in + ForEach(yearData.months, id: \.month) { monthData in MonthCard( - month: month, - year: year, - entries: entries, + month: monthData.month, + year: yearData.year, + entries: monthData.entries, moodTint: moodTint, imagePack: imagePack, - textColor: textColor, theme: theme, filteredDays: filteredDays.currentFilters, onTap: { let detailView = MonthDetailView( - monthInt: month, - yearInt: year, - entries: entries, + monthInt: monthData.month, + yearInt: yearData.year, + entries: monthData.entries, parentViewModel: viewModel ) selectedDetail.selectedItem = detailView @@ -101,6 +108,7 @@ struct MonthView: View { } .padding(.horizontal) .padding(.bottom, 100) + .id(moodTint) // Force complete refresh when mood tint changes .background( GeometryReader { proxy in let offset = proxy.frame(in: .named("scroll")).minY @@ -126,10 +134,6 @@ struct MonthView: View { ) } - // Hidden text to trigger updates when custom tint changes - Text(String(customMoodTintUpdateNumber)) - .hidden() - if iapManager.shouldShowPaywall { // Premium month history prompt - bottom half VStack(spacing: 20) { @@ -160,12 +164,12 @@ struct MonthView: View { VStack(spacing: 10) { Text("Explore Your Mood History") .font(.title3.weight(.bold)) - .foregroundColor(textColor) + .foregroundColor(labelColor) .multilineTextAlignment(.center) Text("See your complete monthly journey. Track patterns and understand what shapes your days.") .font(.subheadline) - .foregroundColor(textColor.opacity(0.7)) + .foregroundColor(labelColor.opacity(0.7)) .multilineTextAlignment(.center) .padding(.horizontal, 24) } @@ -231,6 +235,18 @@ struct MonthView: View { trialWarningHidden = value < 0 } } + .onAppear { + cachedSortedData = computeSortedYearMonthData() + } + .onChange(of: viewModel.numberOfItems) { _, _ in + // Use numberOfItems as a lightweight proxy for data changes + // instead of comparing the entire grouped dictionary + cachedSortedData = computeSortedYearMonthData() + } + .onChange(of: iapManager.shouldShowPaywall) { _, _ in + cachedSortedData = computeSortedYearMonthData() + } + .preferredColorScheme(theme.preferredColorScheme) } @@ -241,31 +257,43 @@ struct MonthView: View { } // MARK: - Month Card Component -struct MonthCard: View { +struct MonthCard: View, Equatable { let month: Int let year: Int let entries: [MoodEntryModel] let moodTint: MoodTints let imagePack: MoodImages - let textColor: Color let theme: Theme let filteredDays: [Int] let onTap: () -> Void let onShare: (UIImage) -> Void + private var labelColor: Color { theme.currentTheme.labelColor } + + // Equatable conformance to prevent unnecessary re-renders + static func == (lhs: MonthCard, rhs: MonthCard) -> Bool { + lhs.month == rhs.month && + lhs.year == rhs.year && + lhs.entries.count == rhs.entries.count && + lhs.moodTint == rhs.moodTint && + lhs.imagePack == rhs.imagePack && + lhs.filteredDays == rhs.filteredDays && + lhs.theme == rhs.theme + } + @State private var showStats = true + @State private var cachedMetrics: [MoodMetrics] = [] private let weekdayLabels = ["S", "M", "T", "W", "T", "F", "S"] private let heatmapColumns = Array(repeating: GridItem(.flexible(), spacing: 2), count: 7) - private var metrics: [MoodMetrics] { - let (startDate, endDate) = Date.dateRange(monthInt: month, yearInt: year) - let monthEntries = DataController.shared.getData(startDate: startDate, endDate: endDate, includedDays: [1,2,3,4,5,6,7]) - return Random.createTotalPerc(fromEntries: monthEntries) + // Cached filtered/sorted metrics to avoid recalculating in ForEach + private var displayMetrics: [MoodMetrics] { + cachedMetrics.filter { $0.total > 0 }.sorted { $0.mood.rawValue > $1.mood.rawValue } } private var topMood: Mood? { - metrics.filter { $0.total > 0 }.max(by: { $0.total < $1.total })?.mood + displayMetrics.max(by: { $0.total < $1.total })?.mood } private var totalTrackedDays: Int { @@ -277,13 +305,13 @@ struct MonthCard: View { // Header with month/year Text("\(Random.monthName(fromMonthInt: month).uppercased()) \(String(year))") .font(.title.weight(.heavy)) - .foregroundColor(textColor) + .foregroundColor(labelColor) .padding(.top, 40) .padding(.bottom, 8) Text("Monthly Mood Wrap") .font(.body.weight(.medium)) - .foregroundColor(textColor.opacity(0.6)) + .foregroundColor(labelColor.opacity(0.6)) .padding(.bottom, 30) // Top mood highlight @@ -304,7 +332,7 @@ struct MonthCard: View { Text("Top Mood") .font(.subheadline.weight(.medium)) - .foregroundColor(textColor.opacity(0.5)) + .foregroundColor(labelColor.opacity(0.5)) Text(topMood.strValue.uppercased()) .font(.title3.weight(.bold)) @@ -318,10 +346,10 @@ struct MonthCard: View { VStack(spacing: 4) { Text("\(totalTrackedDays)") .font(.largeTitle.weight(.bold)) - .foregroundColor(textColor) + .foregroundColor(labelColor) Text("Days Tracked") .font(.caption.weight(.medium)) - .foregroundColor(textColor.opacity(0.5)) + .foregroundColor(labelColor.opacity(0.5)) } .frame(maxWidth: .infinity) } @@ -329,7 +357,7 @@ struct MonthCard: View { // Mood breakdown with bars VStack(spacing: 12) { - ForEach(metrics.filter { $0.total > 0 }.sorted(by: { $0.mood.rawValue > $1.mood.rawValue })) { metric in + ForEach(displayMetrics) { metric in HStack(spacing: 12) { Circle() .fill(moodTint.color(forMood: metric.mood)) @@ -357,7 +385,7 @@ struct MonthCard: View { Text("\(Int(metric.percent))%") .font(.subheadline.weight(.semibold)) - .foregroundColor(textColor) + .foregroundColor(labelColor) .frame(width: 40, alignment: .trailing) } } @@ -368,7 +396,7 @@ struct MonthCard: View { // App branding Text("ifeel") .font(.subheadline.weight(.medium)) - .foregroundColor(textColor.opacity(0.3)) + .foregroundColor(labelColor.opacity(0.3)) .padding(.bottom, 20) } .frame(width: 400) @@ -389,11 +417,11 @@ struct MonthCard: View { HStack { Text("\(Random.monthName(fromMonthInt: month)) \(String(year))") .font(.title3.bold()) - .foregroundColor(textColor) + .foregroundColor(labelColor) Image(systemName: showStats ? "chevron.up" : "chevron.down") .font(.caption.weight(.semibold)) - .foregroundColor(textColor.opacity(0.5)) + .foregroundColor(labelColor.opacity(0.5)) } } .buttonStyle(.plain) @@ -406,7 +434,7 @@ struct MonthCard: View { }) { Image(systemName: "square.and.arrow.up") .font(.subheadline.weight(.medium)) - .foregroundColor(textColor.opacity(0.6)) + .foregroundColor(labelColor.opacity(0.6)) } .buttonStyle(.plain) } @@ -418,7 +446,7 @@ struct MonthCard: View { ForEach(weekdayLabels.indices, id: \.self) { index in Text(weekdayLabels[index]) .font(.caption2.weight(.medium)) - .foregroundColor(textColor.opacity(0.5)) + .foregroundColor(labelColor.opacity(0.5)) .frame(maxWidth: .infinity) } } @@ -443,7 +471,7 @@ struct MonthCard: View { Divider() .padding(.horizontal, 16) - MoodBarChart(metrics: metrics, moodTint: moodTint, imagePack: imagePack) + MoodBarChart(metrics: cachedMetrics, moodTint: moodTint, imagePack: imagePack) .padding(.horizontal, 16) .padding(.vertical, 12) .transition(.opacity.combined(with: .move(edge: .top))) @@ -457,6 +485,12 @@ struct MonthCard: View { .onTapGesture { onTap() } + .onAppear { + // Cache metrics calculation on first appearance + if cachedMetrics.isEmpty { + cachedMetrics = Random.createTotalPerc(fromEntries: entries) + } + } } } @@ -487,9 +521,7 @@ struct HeatmapCell: View { } private var formattedDate: String { - let formatter = DateFormatter() - formatter.dateStyle = .medium - return formatter.string(from: entry.forDate) + DateFormattingCache.shared.string(for: entry.forDate, format: .dateMedium) } private var cellColor: Color { diff --git a/Shared/Views/NoteEditorView.swift b/Shared/Views/NoteEditorView.swift index 6e47988..cdda4be 100644 --- a/Shared/Views/NoteEditorView.swift +++ b/Shared/Views/NoteEditorView.swift @@ -11,7 +11,9 @@ import PhotosUI struct NoteEditorView: View { @Environment(\.dismiss) private var dismiss - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor + @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system + + private var textColor: Color { theme.currentTheme.labelColor } let entry: MoodEntryModel @State private var noteText: String @@ -137,9 +139,11 @@ struct EntryDetailView: View { @Environment(\.dismiss) private var dismiss @AppStorage(UserDefaultsStore.Keys.moodImages.rawValue, store: GroupUserDefaults.groupDefaults) private var imagePack: MoodImages = .FontAwesome @AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor + @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @AppStorage(UserDefaultsStore.Keys.deleteEnable.rawValue, store: GroupUserDefaults.groupDefaults) private var deleteEnabled = true + private var textColor: Color { theme.currentTheme.labelColor } + let entry: MoodEntryModel let onMoodUpdate: (Mood) -> Void let onDelete: () -> Void diff --git a/Shared/Views/PurchaseButtonView.swift b/Shared/Views/PurchaseButtonView.swift index 6912ee0..66cf21a 100644 --- a/Shared/Views/PurchaseButtonView.swift +++ b/Shared/Views/PurchaseButtonView.swift @@ -10,7 +10,8 @@ import StoreKit struct PurchaseButtonView: View { @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor + + private var textColor: Color { theme.currentTheme.labelColor } @ObservedObject var iapManager: IAPManager diff --git a/Shared/Views/SampleEntryView.swift b/Shared/Views/SampleEntryView.swift index a516d29..d7fa572 100644 --- a/Shared/Views/SampleEntryView.swift +++ b/Shared/Views/SampleEntryView.swift @@ -10,7 +10,6 @@ import SwiftUI struct SampleEntryView: View { @State private var sampleListEntry = DataController.shared.generateObjectNotInArray(forDate: Date(), withMood: Mood.great) @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor var body: some View { ZStack { diff --git a/Shared/Views/SettingsView/DebugAnimationSettingsView.swift b/Shared/Views/SettingsView/DebugAnimationSettingsView.swift index 3c6cf86..0f37177 100644 --- a/Shared/Views/SettingsView/DebugAnimationSettingsView.swift +++ b/Shared/Views/SettingsView/DebugAnimationSettingsView.swift @@ -14,7 +14,8 @@ struct DebugAnimationSettingsView: View { @Environment(\.dismiss) private var dismiss @Environment(\.colorScheme) private var colorScheme @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor + + private var textColor: Color { theme.currentTheme.labelColor } @State private var selectedAnimation: CelebrationAnimationType = .vortexCheckmark @State private var isAnimating = false diff --git a/Shared/Views/SettingsView/SettingsTabView.swift b/Shared/Views/SettingsView/SettingsTabView.swift index 6ea80e4..e01769d 100644 --- a/Shared/Views/SettingsView/SettingsTabView.swift +++ b/Shared/Views/SettingsView/SettingsTabView.swift @@ -22,7 +22,8 @@ struct SettingsTabView: View { @EnvironmentObject var iapManager: IAPManager @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor + + private var textColor: Color { theme.currentTheme.labelColor } var body: some View { VStack(spacing: 0) { @@ -85,7 +86,9 @@ struct UpgradeBannerView: View { let trialExpirationDate: Date? @Environment(\.colorScheme) private var colorScheme - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor + @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system + + private var textColor: Color { theme.currentTheme.labelColor } var body: some View { VStack(spacing: 12) { diff --git a/Shared/Views/SettingsView/SettingsView.swift b/Shared/Views/SettingsView/SettingsView.swift index fe29874..37f68b6 100644 --- a/Shared/Views/SettingsView/SettingsView.swift +++ b/Shared/Views/SettingsView/SettingsView.swift @@ -26,7 +26,8 @@ struct SettingsContentView: View { @AppStorage(UserDefaultsStore.Keys.deleteEnable.rawValue, store: GroupUserDefaults.groupDefaults) private var deleteEnabled = true @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor + + private var textColor: Color { theme.currentTheme.labelColor } var body: some View { ScrollView { @@ -48,6 +49,8 @@ struct SettingsContentView: View { eulaButton privacyButton + addTestDataButton + #if DEBUG // Debug section debugSectionHeader @@ -55,7 +58,7 @@ struct SettingsContentView: View { animationLabButton paywallPreviewButton tipsPreviewButton - addTestDataButton + clearDataButton #endif @@ -379,36 +382,6 @@ struct SettingsContentView: View { } } - private var addTestDataButton: some View { - ZStack { - theme.currentTheme.secondaryBGColor - Button { - DataController.shared.populateTestData() - } label: { - HStack(spacing: 12) { - Image(systemName: "plus.square.on.square") - .font(.title2) - .foregroundColor(.green) - .frame(width: 32) - - VStack(alignment: .leading, spacing: 2) { - Text("Add Test Data") - .foregroundColor(textColor) - - Text("Populate with sample mood entries") - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer() - } - .padding() - } - } - .fixedSize(horizontal: false, vertical: true) - .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) - } - private var clearDataButton: some View { ZStack { theme.currentTheme.secondaryBGColor @@ -494,6 +467,36 @@ struct SettingsContentView: View { @ObservedObject private var healthKitManager = HealthKitManager.shared + private var addTestDataButton: some View { + ZStack { + theme.currentTheme.secondaryBGColor + Button { + DataController.shared.populateTestData() + } label: { + HStack(spacing: 12) { + Image(systemName: "plus.square.on.square") + .font(.title2) + .foregroundColor(.green) + .frame(width: 32) + + VStack(alignment: .leading, spacing: 2) { + Text("Add Test Data") + .foregroundColor(textColor) + + Text("Populate with sample mood entries") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + } + .padding() + } + } + .fixedSize(horizontal: false, vertical: true) + .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) + } + private var healthKitToggle: some View { VStack(spacing: 0) { HStack(spacing: 12) { @@ -795,10 +798,11 @@ struct SettingsView: View { @AppStorage(UserDefaultsStore.Keys.deleteEnable.rawValue, store: GroupUserDefaults.groupDefaults) private var deleteEnabled = true @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor @AppStorage(UserDefaultsStore.Keys.firstLaunchDate.rawValue, store: GroupUserDefaults.groupDefaults) private var firstLaunchDate = Date() @AppStorage(UserDefaultsStore.Keys.privacyLockEnabled.rawValue, store: GroupUserDefaults.groupDefaults) private var privacyLockEnabled = false - + + private var textColor: Color { theme.currentTheme.labelColor } + var body: some View { ScrollView { VStack { diff --git a/Shared/Views/Sharing/SharingListView.swift b/Shared/Views/Sharing/SharingListView.swift index 3df77cb..a3ad218 100644 --- a/Shared/Views/Sharing/SharingListView.swift +++ b/Shared/Views/Sharing/SharingListView.swift @@ -24,7 +24,8 @@ struct WrappedSharable: Hashable, Equatable { struct SharingListView: View { @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor + + private var textColor: Color { theme.currentTheme.labelColor } class ShareStateViewModel: ObservableObject { @Published var selectedItem: WrappedSharable? = nil diff --git a/Shared/Views/SharingTemplates/AllMoodsTotalTemplate.swift b/Shared/Views/SharingTemplates/AllMoodsTotalTemplate.swift index 60ab62f..1a91ecc 100644 --- a/Shared/Views/SharingTemplates/AllMoodsTotalTemplate.swift +++ b/Shared/Views/SharingTemplates/AllMoodsTotalTemplate.swift @@ -20,7 +20,8 @@ struct AllMoodsTotalTemplate: View, SharingTemplate { @Environment(\.presentationMode) var presentationMode @AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = .white + + private var textColor: Color { theme.currentTheme.labelColor } @StateObject private var shareImage = ShareImageStateViewModel() private var entries = [MoodMetrics]() diff --git a/Shared/Views/SharingTemplates/CurrentStreakTemplate.swift b/Shared/Views/SharingTemplates/CurrentStreakTemplate.swift index 32c10bc..14ee72e 100644 --- a/Shared/Views/SharingTemplates/CurrentStreakTemplate.swift +++ b/Shared/Views/SharingTemplates/CurrentStreakTemplate.swift @@ -23,7 +23,8 @@ struct CurrentStreakTemplate: View, SharingTemplate { @Environment(\.presentationMode) var presentationMode @AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor + + private var textColor: Color { theme.currentTheme.labelColor } let columns = [ GridItem(.flexible(minimum: 5, maximum: .infinity), alignment: .center), diff --git a/Shared/Views/SharingTemplates/LongestStreakTemplate.swift b/Shared/Views/SharingTemplates/LongestStreakTemplate.swift index 953942f..335f39a 100644 --- a/Shared/Views/SharingTemplates/LongestStreakTemplate.swift +++ b/Shared/Views/SharingTemplates/LongestStreakTemplate.swift @@ -32,7 +32,8 @@ struct LongestStreakTemplate: View, SharingTemplate { @Environment(\.presentationMode) var presentationMode @AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = .white + + private var textColor: Color { theme.currentTheme.labelColor } let columns = [ GridItem(.flexible(minimum: 5, maximum: .infinity), alignment: .center), diff --git a/Shared/Views/SharingTemplates/MonthTotalTemplate.swift b/Shared/Views/SharingTemplates/MonthTotalTemplate.swift index 52da7d5..95400ee 100644 --- a/Shared/Views/SharingTemplates/MonthTotalTemplate.swift +++ b/Shared/Views/SharingTemplates/MonthTotalTemplate.swift @@ -25,7 +25,8 @@ struct MonthTotalTemplate: View, SharingTemplate { @Environment(\.presentationMode) var presentationMode @AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = .white + + private var textColor: Color { theme.currentTheme.labelColor } private var moodMetrics = [MoodMetrics]() private var moodEntries = [MoodEntryModel]() diff --git a/Shared/Views/SmallRollUpHeaderView.swift b/Shared/Views/SmallRollUpHeaderView.swift index 01b0a0a..66b9682 100644 --- a/Shared/Views/SmallRollUpHeaderView.swift +++ b/Shared/Views/SmallRollUpHeaderView.swift @@ -10,10 +10,10 @@ import SwiftUI struct SmallRollUpHeaderView: View { @Binding var viewType: MainSwitchableViewType @AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default - @AppStorage(UserDefaultsStore.Keys.shape.rawValue, store: GroupUserDefaults.groupDefaults) private var shape: BGShape = .circle + @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor + private var textColor: Color { theme.currentTheme.labelColor } let entries: [MoodEntryModel] private var moodMetrics = [MoodMetrics]() diff --git a/Shared/Views/SwitchableView.swift b/Shared/Views/SwitchableView.swift index 8fccafd..5efcfaa 100644 --- a/Shared/Views/SwitchableView.swift +++ b/Shared/Views/SwitchableView.swift @@ -29,11 +29,9 @@ struct SwitchableView: View { @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = .white - - // store a value that gets changed when user updates custom colors to update the view since the moodTint doesn't change - @AppStorage(UserDefaultsStore.Keys.customMoodTintUpdateNumber.rawValue, store: GroupUserDefaults.groupDefaults) private var customMoodTintUpdateNumber: Int = 0 - + + private var textColor: Color { theme.currentTheme.labelColor } + init(daysBack: Int, viewType: Binding, headerTypeChanged: @escaping ((MainSwitchableViewType) -> Void)) { self.daysBack = daysBack self.headerTypeChanged = headerTypeChanged @@ -66,9 +64,6 @@ struct SwitchableView: View { var body: some View { VStack { ZStack { - Text(String(customMoodTintUpdateNumber)) - .hidden() - mainViews .padding([.top, .bottom]) diff --git a/Shared/Views/TipModalView.swift b/Shared/Views/TipModalView.swift index 3426caa..ef04751 100644 --- a/Shared/Views/TipModalView.swift +++ b/Shared/Views/TipModalView.swift @@ -15,8 +15,10 @@ struct TipModalView: View { let onDismiss: () -> Void @Environment(\.colorScheme) private var colorScheme - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) - private var textColor: Color = DefaultTextColor.textColor + @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) + private var theme: Theme = .system + + private var textColor: Color { theme.currentTheme.labelColor } @State private var appeared = false diff --git a/Shared/Views/YearView/YearView.swift b/Shared/Views/YearView/YearView.swift index cef2ca6..2ffb798 100644 --- a/Shared/Views/YearView/YearView.swift +++ b/Shared/Views/YearView/YearView.swift @@ -6,20 +6,15 @@ // import SwiftUI -import SwiftData struct YearView: View { let months = [(0, "J"), (1, "F"), (2,"M"), (3,"A"), (4,"M"), (5, "J"), (6,"J"), (7,"A"), (8,"S"), (9,"O"), (10, "N"), (11,"D")] @State private var toggle = true - @Query(sort: \MoodEntryModel.forDate, order: .reverse) - private var items: [MoodEntryModel] - @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default @AppStorage(UserDefaultsStore.Keys.moodImages.rawValue, store: GroupUserDefaults.groupDefaults) private var imagePack: MoodImages = .FontAwesome - @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor @EnvironmentObject var iapManager: IAPManager @StateObject public var viewModel: YearViewModel @@ -28,6 +23,9 @@ struct YearView: View { @State private var trialWarningHidden = false @State private var showSubscriptionStore = false + /// Cached sorted year keys to avoid re-sorting in ForEach on every render + @State private var cachedSortedYearKeys: [Int] = [] + // Heatmap-style grid: 12 columns for months private let heatmapColumns = Array(repeating: GridItem(.flexible(), spacing: 2), count: 12) @@ -39,13 +37,13 @@ struct YearView: View { } else { ScrollView { VStack(spacing: 16) { - ForEach(Array(self.viewModel.data.keys.sorted(by: >)), id: \.self) { yearKey in + ForEach(cachedSortedYearKeys, id: \.self) { yearKey in YearCard( year: yearKey, yearData: self.viewModel.data[yearKey]!, + yearEntries: self.viewModel.entriesByYear[yearKey] ?? [], moodTint: moodTint, imagePack: imagePack, - textColor: textColor, theme: theme, filteredDays: filteredDays.currentFilters, onShare: { image in @@ -57,6 +55,7 @@ struct YearView: View { } .padding(.horizontal) .padding(.bottom, 100) + .id(moodTint) // Force complete refresh when mood tint changes .background( GeometryReader { proxy in let offset = proxy.frame(in: .named("scroll")).minY @@ -112,12 +111,12 @@ struct YearView: View { VStack(spacing: 10) { Text("See Your Year at a Glance") .font(.title3.weight(.bold)) - .foregroundColor(textColor) + .foregroundColor(theme.currentTheme.labelColor) .multilineTextAlignment(.center) Text("Discover your emotional rhythm across the year. Spot trends and celebrate how far you've come.") .font(.subheadline) - .foregroundColor(textColor.opacity(0.7)) + .foregroundColor(theme.currentTheme.labelColor.opacity(0.7)) .multilineTextAlignment(.center) .padding(.horizontal, 24) } @@ -168,7 +167,16 @@ struct YearView: View { } .onAppear(perform: { self.viewModel.filterEntries(startDate: Date(timeIntervalSince1970: 0), endDate: Date()) + cachedSortedYearKeys = Array(viewModel.data.keys.sorted(by: >)) }) + .onChange(of: viewModel.data.keys.count) { _, _ in + cachedSortedYearKeys = Array(viewModel.data.keys.sorted(by: >)) + } + .onChange(of: moodTint) { _, _ in + // Rebuild chart data when mood tint changes to update colors + self.viewModel.filterEntries(startDate: Date(timeIntervalSince1970: 0), endDate: Date()) + cachedSortedYearKeys = Array(viewModel.data.keys.sorted(by: >)) + } .background( theme.currentTheme.bg .edgesIgnoringSafeArea(.all) @@ -179,33 +187,42 @@ struct YearView: View { } } .padding([.top]) + .preferredColorScheme(theme.preferredColorScheme) } } // MARK: - Year Card Component -struct YearCard: View { +struct YearCard: View, Equatable { let year: Int let yearData: [Int: [DayChartView]] + let yearEntries: [MoodEntryModel] let moodTint: MoodTints let imagePack: MoodImages - let textColor: Color let theme: Theme let filteredDays: [Int] let onShare: (UIImage) -> Void + private var textColor: Color { theme.currentTheme.labelColor } + + // Equatable conformance to prevent unnecessary re-renders + static func == (lhs: YearCard, rhs: YearCard) -> Bool { + lhs.year == rhs.year && + lhs.yearEntries.count == rhs.yearEntries.count && + lhs.moodTint == rhs.moodTint && + lhs.imagePack == rhs.imagePack && + lhs.filteredDays == rhs.filteredDays && + lhs.theme == rhs.theme + } + @State private var showStats = true + @State private var cachedMetrics: [MoodMetrics] = [] private let months = ["J", "F", "M", "A", "M", "J", "J", "A", "S", "O", "N", "D"] private let heatmapColumns = Array(repeating: GridItem(.flexible(), spacing: 2), count: 12) - private var yearEntries: [MoodEntryModel] { - let firstOfYear = Calendar.current.date(from: DateComponents(year: year, month: 1, day: 1))! - let lastOfYear = Calendar.current.date(from: DateComponents(year: year + 1, month: 1, day: 1))! - return DataController.shared.getData(startDate: firstOfYear, endDate: lastOfYear, includedDays: filteredDays) - } - - private var metrics: [MoodMetrics] { - return Random.createTotalPerc(fromEntries: yearEntries) + // Cached filtered/sorted metrics to avoid recalculating in ForEach + private var displayMetrics: [MoodMetrics] { + cachedMetrics.filter { $0.total > 0 }.sorted { $0.mood.rawValue > $1.mood.rawValue } } private var totalEntries: Int { @@ -213,7 +230,7 @@ struct YearCard: View { } private var topMood: Mood? { - metrics.filter { $0.total > 0 }.max(by: { $0.total < $1.total })?.mood + displayMetrics.max(by: { $0.total < $1.total })?.mood } private var shareableView: some View { @@ -273,7 +290,7 @@ struct YearCard: View { // Mood breakdown with bars VStack(spacing: 14) { - ForEach(metrics.filter { $0.total > 0 }.sorted(by: { $0.mood.rawValue > $1.mood.rawValue })) { metric in + ForEach(displayMetrics) { metric in HStack(spacing: 14) { Circle() .fill(moodTint.color(forMood: metric.mood)) @@ -366,12 +383,12 @@ struct YearCard: View { if showStats { HStack(spacing: 16) { // Donut Chart - MoodDonutChart(metrics: metrics, moodTint: moodTint) + MoodDonutChart(metrics: cachedMetrics, moodTint: moodTint) .frame(width: 100, height: 100) // Bar Chart VStack(spacing: 6) { - ForEach(metrics.filter { $0.total > 0 }) { metric in + ForEach(displayMetrics) { metric in HStack(spacing: 8) { imagePack.icon(forMood: metric.mood) .resizable() @@ -434,6 +451,12 @@ struct YearCard: View { RoundedRectangle(cornerRadius: 16) .fill(theme.currentTheme.secondaryBGColor) ) + .onAppear { + // Cache metrics calculation on first appearance + if cachedMetrics.isEmpty { + cachedMetrics = Random.createTotalPerc(fromEntries: yearEntries) + } + } } } @@ -445,9 +468,16 @@ struct YearHeatmapGrid: View { private let heatmapColumns = Array(repeating: GridItem(.flexible(), spacing: 2), count: 12) + /// Pre-sorted month keys to avoid sorting in ForEach on every render + private var sortedMonthKeys: [Int] { + // This is computed once per yearData change, not on every body access + // since yearData is a let constant passed from parent + Array(yearData.keys.sorted(by: <)) + } + var body: some View { LazyVGrid(columns: heatmapColumns, spacing: 2) { - ForEach(Array(yearData.keys.sorted(by: <)), id: \.self) { monthKey in + ForEach(sortedMonthKeys, id: \.self) { monthKey in if let monthData = yearData[monthKey] { MonthColumn( monthData: monthData, diff --git a/Shared/Views/YearView/YearViewModel.swift b/Shared/Views/YearView/YearViewModel.swift index 92e1b82..3e41fd2 100644 --- a/Shared/Views/YearView/YearViewModel.swift +++ b/Shared/Views/YearView/YearViewModel.swift @@ -16,6 +16,8 @@ class YearViewModel: ObservableObject { // year, month, items @Published public private(set) var data = [Int: [Int: [DayChartView]]]() @Published public private(set) var numberOfRatings: Int = 0 + /// Entries organized by year for efficient access + @Published public private(set) var entriesByYear = [Int: [MoodEntryModel]]() public private(set) var uncategorizedData = [MoodEntryModel]() { didSet { self.numberOfRatings = uncategorizedData.count @@ -44,8 +46,16 @@ class YearViewModel: ObservableObject { endDate: endDate, includedDays: selectedDays) data.removeAll() + entriesByYear.removeAll() let filledOutData = chartViewBuilder.buildGridData(withData: filteredEntries) data = filledOutData uncategorizedData = filteredEntries + + // Organize entries by year for efficient access in YearCard + let calendar = Calendar.current + for entry in filteredEntries { + let year = calendar.component(.year, from: entry.forDate) + entriesByYear[year, default: []].append(entry) + } } }