Update Neon colors and show color circles in theme picker

- Update NeonMoodTint to use synthwave colors matching Neon voting style
  (cyan, lime, yellow, orange, magenta)
- Replace text label with 5 color circles in theme preview Colors row
- Remove unused textColor customization code and picker views
- Add .id(moodTint) to Month/Year views for color refresh
- Clean up various unused color-related code

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-30 00:08:01 -06:00
parent 51c5777c03
commit bea2d3bbc9
58 changed files with 1142 additions and 967 deletions

View File

@@ -21,7 +21,6 @@
1CD90B6C278C7F78001C4FEA /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1CD90B6B278C7F78001C4FEA /* CloudKit.framework */; }; 1CD90B6C278C7F78001C4FEA /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1CD90B6B278C7F78001C4FEA /* CloudKit.framework */; };
1CD90B6E278C7F8B001C4FEA /* 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 */; }; 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 */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -46,13 +45,6 @@
remoteGlobalIDString = 1CD90B44278C7E7A001C4FEA; remoteGlobalIDString = 1CD90B44278C7E7A001C4FEA;
remoteInfo = FeelsWidgetExtension; remoteInfo = FeelsWidgetExtension;
}; };
51F6DCE106234B68B4F88529 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 1CD90AE6278C7DDF001C4FEA /* Project object */;
proxyType = 1;
remoteGlobalIDString = B1DB9E6543DE4A009DB00916;
remoteInfo = "Feels Watch App";
};
/* End PBXContainerItemProxy section */ /* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */ /* Begin PBXCopyFilesBuildPhase section */
@@ -67,17 +59,6 @@
name = "Embed Foundation Extensions"; name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0; 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 */ /* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
@@ -296,13 +277,11 @@
1CD90AF2278C7DE0001C4FEA /* Frameworks */, 1CD90AF2278C7DE0001C4FEA /* Frameworks */,
1CD90AF3278C7DE0001C4FEA /* Resources */, 1CD90AF3278C7DE0001C4FEA /* Resources */,
1CD90B5A278C7E7A001C4FEA /* Embed Foundation Extensions */, 1CD90B5A278C7E7A001C4FEA /* Embed Foundation Extensions */,
87A714924E734CD8948F0CD0 /* Embed Watch Content */,
); );
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
1CD90B55278C7E7A001C4FEA /* PBXTargetDependency */, 1CD90B55278C7E7A001C4FEA /* PBXTargetDependency */,
CB28ED3402234638800683C9 /* PBXTargetDependency */,
); );
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
1C00073D2EE9388A009C9ED5 /* Shared */, 1C00073D2EE9388A009C9ED5 /* Shared */,
@@ -588,11 +567,6 @@
target = 1CD90B44278C7E7A001C4FEA /* FeelsWidgetExtension */; target = 1CD90B44278C7E7A001C4FEA /* FeelsWidgetExtension */;
targetProxy = 1CD90B54278C7E7A001C4FEA /* PBXContainerItemProxy */; targetProxy = 1CD90B54278C7E7A001C4FEA /* PBXContainerItemProxy */;
}; };
CB28ED3402234638800683C9 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = B1DB9E6543DE4A009DB00916 /* Feels Watch App */;
targetProxy = 51F6DCE106234B68B4F88529 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */ /* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */

View File

@@ -893,7 +893,7 @@
} }
}, },
"Add Test Data" : { "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 "isCommentAutoGenerated" : true
}, },
"Add the Mood Vote widget to quickly log your mood without opening the app." : { "Add the Mood Vote widget to quickly log your mood without opening the app." : {
@@ -1837,7 +1837,7 @@
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Clear All Data" : { "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 "isCommentAutoGenerated" : true
}, },
"Clear DB" : { "Clear DB" : {
@@ -3032,11 +3032,11 @@
} }
}, },
"Current Parameters" : { "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 "isCommentAutoGenerated" : true
}, },
"Current Streak" : { "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 "isCommentAutoGenerated" : true
}, },
"Current: %@" : { "Current: %@" : {
@@ -3083,6 +3083,7 @@
}, },
"Custom" : { "Custom" : {
"comment" : "The text that appears as a label for the custom color option in the tint picker.", "comment" : "The text that appears as a label for the custom color option in the tint picker.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -4225,7 +4226,7 @@
} }
}, },
"Delete all mood entries" : { "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 "isCommentAutoGenerated" : true
}, },
"Delete Entry" : { "Delete Entry" : {
@@ -4811,8 +4812,7 @@
} }
}, },
"Experiment with vote celebrations" : { "Experiment with vote celebrations" : {
"comment" : "A description of a feature in the Animation Lab.",
"isCommentAutoGenerated" : true
}, },
"Explore Your Mood History" : { "Explore Your Mood History" : {
"comment" : "A title for a feature that allows users to explore their mood history.", "comment" : "A title for a feature that allows users to explore their mood history.",
@@ -5699,7 +5699,7 @@
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Has Seen Settings" : { "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 "isCommentAutoGenerated" : true
}, },
"How are you feeling?" : { "How are you feeling?" : {
@@ -6789,7 +6789,7 @@
} }
}, },
"Mood Log Count" : { "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 "isCommentAutoGenerated" : true
}, },
"Mood Logged" : { "Mood Logged" : {
@@ -9195,7 +9195,7 @@
}, },
"Preview subscription themes" : { "Preview subscription themes" : {
"comment" : "A description of the paywall preview feature.", "comment" : "A description of what the paywall preview button does.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Privacy Lock" : { "Privacy Lock" : {
@@ -10850,6 +10850,7 @@
}, },
"Sample Text" : { "Sample Text" : {
"comment" : "A sample text used to demonstrate how the text color is applied in the UI.", "comment" : "A sample text used to demonstrate how the text color is applied in the UI.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -11726,7 +11727,7 @@
} }
}, },
"Shown This Session" : { "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 "isCommentAutoGenerated" : true
}, },
"SIDE A" : { "SIDE A" : {
@@ -12589,7 +12590,7 @@
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Tap to preview" : { "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 "isCommentAutoGenerated" : true
}, },
"Tap to record your mood for this day" : { "Tap to record your mood for this day" : {

View File

@@ -12,7 +12,7 @@ import SwiftUI
class AppDelegate: NSObject, UIApplicationDelegate { class AppDelegate: NSObject, UIApplicationDelegate {
private let savedOnboardingData = UserDefaultsStore.getOnboarding() 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 @MainActor
func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
@@ -20,7 +20,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
DataController.shared.fillInMissingDates() DataController.shared.fillInMissingDates()
UNUserNotificationCenter.current().delegate = self UNUserNotificationCenter.current().delegate = self
UIPageControl.appearance().currentPageIndicatorTintColor = UIColor(textColor) UIPageControl.appearance().currentPageIndicatorTintColor = UIColor(theme.currentTheme.labelColor)
UIPageControl.appearance().pageIndicatorTintColor = UIColor.systemGray UIPageControl.appearance().pageIndicatorTintColor = UIColor.systemGray
let appearance = UITabBarAppearance() let appearance = UITabBarAppearance()
@@ -33,15 +33,6 @@ class AppDelegate: NSObject, UIApplicationDelegate {
return true 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 { extension AppDelegate: @preconcurrency UNUserNotificationCenterDelegate {

View File

@@ -76,21 +76,46 @@ struct FeelsApp: App {
if newPhase == .active { if newPhase == .active {
UNUserNotificationCenter.current().setBadgeCount(0) UNUserNotificationCenter.current().setBadgeCount(0)
// Check subscription status on each app launch
Task { // Authenticate if locked - this must happen immediately on main thread
await iapManager.checkSubscriptionStatus()
}
// Authenticate if locked
if authManager.isLockEnabled && !authManager.isUnlocked { if authManager.isLockEnabled && !authManager.isUnlocked {
Task { Task {
await authManager.authenticate() 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 // 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()
}
} }
} }
} }

View File

@@ -75,6 +75,12 @@ class IAPManager: ObservableObject {
private var updateListenerTask: Task<Void, Error>? private var updateListenerTask: Task<Void, Error>?
/// 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 // MARK: - Computed Properties
var isSubscribed: Bool { var isSubscribed: Bool {
@@ -138,9 +144,21 @@ class IAPManager: ObservableObject {
// MARK: - Public Methods // MARK: - Public Methods
/// Check subscription status - call on app launch and when becoming active /// Check subscription status - call on app launch and when becoming active
/// Throttled to avoid excessive StoreKit calls on rapid foreground transitions
func checkSubscriptionStatus() async { func checkSubscriptionStatus() async {
isLoading = true // Throttle: skip if we checked recently (unless state is unknown)
defer { isLoading = false } 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 // Fetch available products
await loadProducts() await loadProducts()

View File

@@ -7,12 +7,6 @@
import SwiftUI import SwiftUI
struct DefaultTextColor {
static var textColor: Color {
Color(UIColor.label)
}
}
protocol MoodTintable { protocol MoodTintable {
static func color(forMood mood: Mood) -> Color static func color(forMood mood: Mood) -> Color
static func secondary(forMood mood: Mood) -> Color static func secondary(forMood mood: Mood) -> Color
@@ -232,37 +226,44 @@ final class AllRedMoodTint: MoodTintable {
} }
final class NeonMoodTint: 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 { static func color(forMood mood: Mood) -> Color {
switch mood { switch mood {
case .horrible: case .horrible:
return Color(hex: "#ff1818") return neonMagenta
case .bad: case .bad:
return Color(hex: "#FF5F1F") return neonOrange
case .average: case .average:
return Color(hex: "#1F51FF") return neonYellow
case .good: case .good:
return Color(hex: "#FFF01F") return neonLime
case .great: case .great:
return Color(hex: "#39FF14") return neonCyan
case .missing: case .missing:
return Color(uiColor: UIColor.systemGray2) return Color(uiColor: UIColor.systemGray2)
case .placeholder: case .placeholder:
return Color(uiColor: UIColor.systemGray2) return Color(uiColor: UIColor.systemGray2)
} }
} }
static func secondary(forMood mood: Mood) -> Color { static func secondary(forMood mood: Mood) -> Color {
switch mood { switch mood {
case .horrible: case .horrible:
return Color(hex: "#8b1113") return neonMagenta.opacity(0.6)
case .bad: case .bad:
return Color(hex: "#893315") return neonOrange.opacity(0.6)
case .average: case .average:
return Color(hex: "#0f2a85") return neonYellow.opacity(0.6)
case .good: case .good:
return Color(hex: "#807a18") return neonLime.opacity(0.6)
case .great: case .great:
return Color(hex: "#218116") return neonCyan.opacity(0.6)
case .missing: case .missing:
return Color(uiColor: UIColor.label) return Color(uiColor: UIColor.label)
case .placeholder: case .placeholder:

View File

@@ -43,7 +43,7 @@ enum Theme: String, CaseIterable {
var currentTheme: Themeable { var currentTheme: Themeable {
switch self { switch self {
case .system: case .system:
return SystemTheme() return SystemTheme()
case .iFeel: case .iFeel:
@@ -54,6 +54,17 @@ enum Theme: String, CaseIterable {
return AlwaysLight() return AlwaysLight()
} }
} }
var preferredColorScheme: ColorScheme? {
switch self {
case .system, .iFeel:
return nil // Follow system
case .dark:
return .dark
case .light:
return .light
}
}
} }
protocol Themeable { protocol Themeable {

View File

@@ -178,7 +178,6 @@ class UserDefaultsStore {
case customWidget case customWidget
case customMoodTint case customMoodTint
case customMoodTintUpdateNumber case customMoodTintUpdateNumber
case textColor
case showNSFW case showNSFW
case shape case shape
case daysFilter case daysFilter
@@ -200,24 +199,47 @@ class UserDefaultsStore {
case currentSelectedHeaderViewViewType case currentSelectedHeaderViewViewType
} }
/// Cached onboarding data to avoid repeated JSON decoding
private static var cachedOnboardingData: OnboardingData?
static func getOnboarding() -> 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, if let data = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.savedOnboardingData.rawValue) as? Data,
let model = try? JSONDecoder().decode(OnboardingData.self, from: data) { let model = try? JSONDecoder().decode(OnboardingData.self, from: data) {
cachedOnboardingData = model
return model return model
} else { } 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 @discardableResult
static func saveOnboarding(onboardingData: OnboardingData) -> OnboardingData { static func saveOnboarding(onboardingData: OnboardingData) -> OnboardingData {
// Invalidate cache before saving
cachedOnboardingData = nil
do { do {
let data = try JSONEncoder().encode(onboardingData) let data = try JSONEncoder().encode(onboardingData)
GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.savedOnboardingData.rawValue) GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.savedOnboardingData.rawValue)
} catch { } catch {
print("Error saving onboarding: \(error)") print("Error saving onboarding: \(error)")
} }
return UserDefaultsStore.getOnboarding()
// Re-cache the saved data
cachedOnboardingData = onboardingData
return onboardingData
} }
static func moodMoodImagable() -> MoodImagable.Type { static func moodMoodImagable() -> MoodImagable.Type {

View File

@@ -147,25 +147,9 @@ final class MoodLogger {
return targetDay == lastDay return targetDay == lastDay
} }
/// Calculate the current mood streak /// Calculate the current mood streak using optimized batch query
private func calculateCurrentStreak() -> Int { private func calculateCurrentStreak() -> Int {
var streak = 0 let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding())
var checkDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding()) return DataController.shared.calculateStreak(from: votingDate).streak
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
} }
} }

View File

@@ -175,76 +175,80 @@ class LiveActivityScheduler: ObservableObject {
return calendar.date(byAdding: .hour, value: 5, to: ratingDateTime) return calendar.date(byAdding: .hour, value: 5, to: ratingDateTime)
} }
/// Check if user has rated today /// Cached streak data to avoid redundant calculations
func hasRatedToday() -> Bool { 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 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 #if WIDGET_EXTENSION
let entry = WidgetDataProvider.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first // Widget extension uses its own data provider
#else let calendar = Calendar.current
let entry = DataController.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first let dayStart = calendar.startOfDay(for: votingDate)
#endif guard let yearAgo = calendar.date(byAdding: .day, value: -365, to: dayStart) else {
return entry != nil && entry?.mood != .missing && entry?.mood != .placeholder 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 streak = 0
var checkDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding()) var checkDate = votingDate
if !datesWithEntries.contains(dayStart) {
// Check if current voting date has an entry checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate) ?? checkDate
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)!
} }
while true { while true {
let dayStart = Calendar.current.startOfDay(for: checkDate) let checkDayStart = calendar.startOfDay(for: checkDate)
let dayEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: dayStart)! if datesWithEntries.contains(checkDayStart) {
#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 {
streak += 1 streak += 1
checkDate = Calendar.current.date(byAdding: .day, value: -1, to: checkDate)! checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate) ?? checkDate
} else { } else {
break 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 /// Get today's mood if logged
func getTodaysMood() -> Mood? { func getTodaysMood() -> Mood? {
let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding()) return getStreakData().todaysMood
let dayStart = Calendar.current.startOfDay(for: votingDate) }
let dayEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: dayStart)!
#if WIDGET_EXTENSION /// Invalidate cached streak data (call when mood is logged)
let entry = WidgetDataProvider.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first func invalidateCache() {
#else cachedStreakData = nil
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
} }
/// Schedule Live Activity based on current time and rating time /// Schedule Live Activity based on current time and rating time

View File

@@ -47,24 +47,6 @@ struct OnboardingCustomizeTwo: View {
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.foregroundColor(.white) .foregroundColor(.white)
ImagePackPickerView() 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()
} }
} }
} }

View File

@@ -67,4 +67,21 @@ final class DataController: ObservableObject {
Self.logger.error("Failed to save context: \(error.localizedDescription)") 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")
}
} }

View File

@@ -10,9 +10,12 @@ import Foundation
extension DataController { extension DataController {
func add(mood: Mood, forDate date: Date, entryType: EntryType) { func add(mood: Mood, forDate date: Date, entryType: EntryType) {
// Delete existing entry for this date if present // Delete ALL existing entries for this date (handles duplicates)
if let existing = getEntry(byDate: date) { let existing = getAllEntries(byDate: date)
modelContext.delete(existing) for entry in existing {
modelContext.delete(entry)
}
if !existing.isEmpty {
try? modelContext.save() try? modelContext.save()
} }
@@ -50,15 +53,23 @@ extension DataController {
let missing = Array(Set(allDates).subtracting(existingDates)).sorted(by: >) 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 { for date in missing {
// Add 12 hours to avoid UTC offset issues // Add 12 hours to avoid UTC offset issues
let adjustedDate = Calendar.current.date(byAdding: .hour, value: 12, to: date)! 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 { // Single save and listener notification at the end
EventLogger.log(event: "filled_in_missing_entries", withData: ["count": missing.count]) saveAndRunDataListeners()
} EventLogger.log(event: "filled_in_missing_entries", withData: ["count": missing.count])
} }
func fixWrongWeekdays() { func fixWrongWeekdays() {

View File

@@ -38,4 +38,70 @@ extension DataController {
} }
save() 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<MoodEntryModel>(
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
}
} }

View File

@@ -39,45 +39,77 @@ extension DataController {
return (try? modelContext.fetch(descriptor)) ?? [] 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]]] { func splitIntoYearMonth(includedDays: [Int]) -> [Int: [Int: [MoodEntryModel]]] {
// Single query to fetch all data - avoid N*12 queries
let data = getData( let data = getData(
startDate: Date(timeIntervalSince1970: 0), startDate: Date(timeIntervalSince1970: 0),
endDate: Date(), endDate: Date(),
includedDays: includedDays includedDays: includedDays
).sorted { $0.forDate < $1.forDate } )
guard let earliest = data.first, guard !data.isEmpty else { return [:] }
let latest = data.last else { return [:] }
let calendar = Calendar.current 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]]]() var result = [Int: [Int: [MoodEntryModel]]]()
for year in earliestYear...latestYear { for entry in data {
var monthData = [Int: [MoodEntryModel]]() let year = calendar.component(.year, from: entry.forDate)
let month = calendar.component(.month, from: entry.forDate)
for month in 1...12 { if result[year] == nil {
var components = DateComponents() result[year] = [:]
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]![month] == nil {
result[year] = monthData result[year]![month] = []
}
result[year]![month]!.append(entry)
} }
return result return result

View File

@@ -174,9 +174,12 @@ final class ExtensionDataProvider {
/// - date: The date for the entry /// - date: The date for the entry
/// - entryType: The source of the entry (widget, watch, etc.) /// - entryType: The source of the entry (widget, watch, etc.)
func add(mood: Mood, forDate date: Date, entryType: EntryType) { func add(mood: Mood, forDate date: Date, entryType: EntryType) {
// Delete existing entry for this date if present // Delete ALL existing entries for this date (handles duplicates)
if let existing = getEntry(byDate: date) { let existing = getAllEntries(byDate: date)
modelContext.delete(existing) for entry in existing {
modelContext.delete(entry)
}
if !existing.isEmpty {
try? modelContext.save() 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<MoodEntryModel>(
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) /// Invalidate cached container (call when data might have changed)
func invalidateCache() { func invalidateCache() {
_container = nil _container = nil

View File

@@ -18,87 +18,92 @@ protocol ChartDataBuildable {
extension ChartDataBuildable { extension ChartDataBuildable {
public func buildGridData(withData data: [MoodEntryModel]) -> [Year: [Month: [ChartType]]] { public func buildGridData(withData data: [MoodEntryModel]) -> [Year: [Month: [ChartType]]] {
var returnData = [Int: [Int: [ChartType]]]() guard let earliestEntry = data.first,
let lastEntry = data.last else { return [:] }
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]()
let calendar = Calendar.current 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 let numDays = range.count
for day in 1...numDays { for day in 1...numDays {
if let item = monthEntries.filter({ entry in let key = "\(year)-\(month)-\(day)"
let components = calendar.dateComponents([.day], from: entry.forDate) if let item = entriesByDate[key] {
let date = components.day // O(1) dictionary lookup instead of O(n) filter
return day == date
}).first {
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
let view = ChartType(color: moodTint.color(forMood: item.mood), let view = ChartType(color: moodTint.color(forMood: item.mood),
weekDay: Int(item.weekDay), weekDay: Int(item.weekDay),
shape: UserDefaultsStore.getCustomBGShape()) shape: shape)
filledOutArray.append(view) filledOutArray.append(view)
} else { } 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, let view = ChartType(color: Mood.placeholder.color,
weekDay: Calendar.current.component(.weekday, from: thisDate), weekDay: calendar.component(.weekday, from: thisDate),
shape: UserDefaultsStore.getCustomBGShape()) shape: shape)
filledOutArray.append(view) filledOutArray.append(view)
} }
} }
for _ in filledOutArray.count...32 { for _ in filledOutArray.count...32 {
let view = ChartType(color: Mood.placeholder.color, let view = ChartType(color: Mood.placeholder.color,
weekDay: 2, weekDay: 2,
shape: UserDefaultsStore.getCustomBGShape()) shape: shape)
filledOutArray.append(view) filledOutArray.append(view)
} }
return filledOutArray return filledOutArray
} }
} }

View File

@@ -58,9 +58,11 @@ class Random {
return newValue return newValue
} }
/// Cached month symbols to avoid creating DateFormatter repeatedly
private static let monthSymbols: [String] = DateFormatter().monthSymbols
static func monthName(fromMonthInt: Int) -> String { static func monthName(fromMonthInt: Int) -> String {
let monthName = DateFormatter().monthSymbols[fromMonthInt-1] return monthSymbols[fromMonthInt-1]
return monthName
} }
static var existingDayFormat = [NSNumber: String]() static var existingDayFormat = [NSNumber: String]()
@@ -165,7 +167,15 @@ extension Color {
} }
extension String { extension String {
/// Cache for rendered emoji images to avoid expensive re-rendering
private static var textToImageCache = [String: UIImage]()
func textToImage() -> UIImage? { func textToImage() -> UIImage? {
// Return cached image if available
if let cached = Self.textToImageCache[self] {
return cached
}
let nsString = (self as NSString) let nsString = (self as NSString)
let font = UIFont.systemFont(ofSize: 100) // you can change your font size here let font = UIFont.systemFont(ofSize: 100) // you can change your font size here
let stringAttributes = [NSAttributedString.Key.font: font] let stringAttributes = [NSAttributedString.Key.font: font]
@@ -178,7 +188,12 @@ extension String {
let image = UIGraphicsGetImageFromCurrentImageContext() // create image from context let image = UIGraphicsGetImageFromCurrentImageContext() // create image from context
UIGraphicsEndImageContext() // end image 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 #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 { extension Bundle {
var appName: String { var appName: String {
return infoDictionary?["CFBundleName"] as! String return infoDictionary?["CFBundleName"] as! String

View File

@@ -13,9 +13,10 @@ struct AddMoodHeaderView: View {
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @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.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.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 @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 @State var onboardingData = OnboardingDataDataManager.shared.savedOnboardingData
// Celebration animation state // Celebration animation state

View File

@@ -10,8 +10,9 @@ import SwiftUI
struct CreateWidgetView: View { struct CreateWidgetView: View {
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@Environment(\.dismiss) var dismiss @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 @StateObject private var customWidget: CustomWidgetModel
@State private var mouth: CustomWidgetMouthOptions = CustomWidgetMouthOptions.defaultOption @State private var mouth: CustomWidgetMouthOptions = CustomWidgetMouthOptions.defaultOption

View File

@@ -65,18 +65,8 @@ struct CustomizeContentView: View {
// APPEARANCE // APPEARANCE
SettingsSection(title: "Appearance") { SettingsSection(title: "Appearance") {
VStack(spacing: 16) { SettingsRow(title: "Theme") {
// Theme ThemePickerCompact()
SettingsRow(title: "Theme") {
ThemePickerCompact()
}
Divider()
// Text Color
SettingsRow(title: "Text Color") {
TextColorPickerCompact()
}
} }
} }
@@ -90,13 +80,6 @@ struct CustomizeContentView: View {
Divider() Divider()
// Mood Colors
SettingsRow(title: "Colors") {
TintPickerCompact()
}
Divider()
// Day View Style // Day View Style
SettingsRow(title: "Entry Style") { SettingsRow(title: "Entry Style") {
DayViewStylePickerCompact() DayViewStylePickerCompact()
@@ -143,7 +126,6 @@ struct CustomizeView: View {
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @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 { var body: some View {
ScrollView { ScrollView {
@@ -157,18 +139,8 @@ struct CustomizeView: View {
// APPEARANCE // APPEARANCE
SettingsSection(title: "Appearance") { SettingsSection(title: "Appearance") {
VStack(spacing: 16) { SettingsRow(title: "Theme") {
// Theme ThemePickerCompact()
SettingsRow(title: "Theme") {
ThemePickerCompact()
}
Divider()
// Text Color
SettingsRow(title: "Text Color") {
TextColorPickerCompact()
}
} }
} }
@@ -182,13 +154,6 @@ struct CustomizeView: View {
Divider() Divider()
// Mood Colors
SettingsRow(title: "Colors") {
TintPickerCompact()
}
Divider()
// Day View Style // Day View Style
SettingsRow(title: "Entry Style") { SettingsRow(title: "Entry Style") {
DayViewStylePickerCompact() DayViewStylePickerCompact()
@@ -237,7 +202,7 @@ struct CustomizeView: View {
HStack { HStack {
Text("Customize") Text("Customize")
.font(.title.weight(.bold)) .font(.title.weight(.bold))
.foregroundColor(textColor) .foregroundColor(theme.currentTheme.labelColor)
Spacer() Spacer()
} }
@@ -251,13 +216,13 @@ struct SettingsSection<Content: View>: View {
@ViewBuilder let content: Content @ViewBuilder let content: Content
@Environment(\.colorScheme) private var colorScheme @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 { var body: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Text(title.uppercased()) Text(title.uppercased())
.font(.caption.weight(.semibold)) .font(.caption.weight(.semibold))
.foregroundColor(textColor.opacity(0.4)) .foregroundColor(theme.currentTheme.labelColor.opacity(0.4))
.tracking(0.5) .tracking(0.5)
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -277,13 +242,13 @@ struct SettingsRow<Content: View>: View {
let title: String let title: String
@ViewBuilder let content: Content @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 { var body: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Text(title) Text(title)
.font(.subheadline.weight(.medium)) .font(.subheadline.weight(.medium))
.foregroundColor(textColor.opacity(0.7)) .foregroundColor(theme.currentTheme.labelColor.opacity(0.7))
content content
} }
@@ -294,14 +259,12 @@ struct SettingsRow<Content: View>: View {
struct ThemePickerCompact: View { struct ThemePickerCompact: View {
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @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 { var body: some View {
HStack(spacing: 20) { HStack(spacing: 20) {
ForEach(Theme.allCases, id: \.rawValue) { aTheme in ForEach(Theme.allCases, id: \.rawValue) { aTheme in
Button(action: { Button(action: {
theme = aTheme theme = aTheme
changeTextColor(forTheme: aTheme)
EventLogger.log(event: "change_theme_id", withData: ["id": aTheme.rawValue]) EventLogger.log(event: "change_theme_id", withData: ["id": aTheme.rawValue])
}) { }) {
VStack(spacing: 8) { VStack(spacing: 8) {
@@ -323,7 +286,7 @@ struct ThemePickerCompact: View {
Text(aTheme.title) Text(aTheme.title)
.font(.caption.weight(.medium)) .font(.caption.weight(.medium))
.foregroundColor(theme == aTheme ? .accentColor : textColor.opacity(0.6)) .foregroundColor(theme == aTheme ? .accentColor : theme.currentTheme.labelColor.opacity(0.6))
} }
} }
.buttonStyle(BorderlessButtonStyle()) .buttonStyle(BorderlessButtonStyle())
@@ -331,33 +294,6 @@ struct ThemePickerCompact: View {
Spacer() 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 // 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<Color> {
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 // MARK: - Voting Layout Picker
struct VotingLayoutPickerCompact: View { struct VotingLayoutPickerCompact: View {
@AppStorage(UserDefaultsStore.Keys.votingLayoutStyle.rawValue, store: GroupUserDefaults.groupDefaults) private var votingLayoutStyle: Int = 0 @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 @Environment(\.colorScheme) private var colorScheme
private var currentLayout: VotingLayoutStyle { private var currentLayout: VotingLayoutStyle {
@@ -540,11 +375,11 @@ struct VotingLayoutPickerCompact: View {
VStack(spacing: 6) { VStack(spacing: 6) {
layoutIcon(for: layout) layoutIcon(for: layout)
.frame(width: 44, height: 44) .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) Text(layout.displayName)
.font(.caption2.weight(.medium)) .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) .frame(width: 70)
.padding(.vertical, 12) .padding(.vertical, 12)
@@ -669,7 +504,6 @@ struct VotingLayoutPickerCompact: View {
// MARK: - Custom Widget Section // MARK: - Custom Widget Section
struct CustomWidgetSection: View { struct CustomWidgetSection: View {
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @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() @StateObject private var selectedWidget = CustomWidgetStateViewModel()
var body: some View { var body: some View {
@@ -728,7 +562,7 @@ struct CustomWidgetSection: View {
struct PersonalityPackPickerCompact: View { struct PersonalityPackPickerCompact: View {
@AppStorage(UserDefaultsStore.Keys.personalityPack.rawValue, store: GroupUserDefaults.groupDefaults) private var personalityPack: PersonalityPack = .Default @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.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 @State private var showOver18Alert = false
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
@@ -751,12 +585,12 @@ struct PersonalityPackPickerCompact: View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(String(aPack.title())) Text(String(aPack.title()))
.font(.subheadline.weight(.semibold)) .font(.subheadline.weight(.semibold))
.foregroundColor(textColor) .foregroundColor(theme.currentTheme.labelColor)
let strings = aPack.randomPushNotificationStrings() let strings = aPack.randomPushNotificationStrings()
Text(strings.body) Text(strings.body)
.font(.caption) .font(.caption)
.foregroundColor(textColor.opacity(0.5)) .foregroundColor(theme.currentTheme.labelColor.opacity(0.5))
.lineLimit(2) .lineLimit(2)
} }
@@ -798,7 +632,7 @@ struct PersonalityPackPickerCompact: View {
// MARK: - Day Filter Picker // MARK: - Day Filter Picker
struct DayFilterPickerCompact: View { struct DayFilterPickerCompact: View {
@StateObject private var filteredDays = DaysFilterClass.shared @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 @Environment(\.colorScheme) private var colorScheme
let weekdays = [(Calendar.current.shortWeekdaySymbols[0], 1), let weekdays = [(Calendar.current.shortWeekdaySymbols[0], 1),
@@ -828,7 +662,7 @@ struct DayFilterPickerCompact: View {
}) { }) {
Text(day.prefix(2).uppercased()) Text(day.prefix(2).uppercased())
.font(.caption.weight(.semibold)) .font(.caption.weight(.semibold))
.foregroundColor(isActive ? .white : textColor.opacity(0.5)) .foregroundColor(isActive ? .white : theme.currentTheme.labelColor.opacity(0.5))
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.frame(height: 40) .frame(height: 40)
.background( .background(
@@ -842,7 +676,7 @@ struct DayFilterPickerCompact: View {
Text(String(localized: "day_picker_view_text")) Text(String(localized: "day_picker_view_text"))
.font(.caption) .font(.caption)
.foregroundColor(textColor.opacity(0.5)) .foregroundColor(theme.currentTheme.labelColor.opacity(0.5))
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
} }
} }
@@ -953,7 +787,7 @@ struct SubscriptionBannerView: View {
// MARK: - Day View Style Picker // MARK: - Day View Style Picker
struct DayViewStylePickerCompact: View { struct DayViewStylePickerCompact: View {
@AppStorage(UserDefaultsStore.Keys.dayViewStyle.rawValue, store: GroupUserDefaults.groupDefaults) private var dayViewStyle: DayViewStyle = .classic @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 @Environment(\.colorScheme) private var colorScheme
var body: some View { var body: some View {
@@ -975,11 +809,11 @@ struct DayViewStylePickerCompact: View {
VStack(spacing: 6) { VStack(spacing: 6) {
styleIcon(for: style) styleIcon(for: style)
.frame(width: 44, height: 44) .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) Text(style.displayName)
.font(.caption2.weight(.medium)) .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) .frame(width: 70)
.padding(.vertical, 12) .padding(.vertical, 12)

View File

@@ -288,12 +288,10 @@ struct AppThemePreviewSheet: View {
.padding(.horizontal, 20) .padding(.horizontal, 20)
VStack(spacing: 12) { VStack(spacing: 12) {
ThemeComponentRow( ThemeColorRow(
icon: "paintpalette.fill", icon: "paintpalette.fill",
title: "Colors", title: "Colors",
value: theme.colorTint == .Default ? "Default" : moodTint: theme.colorTint,
theme.colorTint == .Neon ? "Neon" :
theme.colorTint == .Pastel ? "Pastel" : "Custom",
color: .orange 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 // MARK: - Preview
struct AppThemePickerView_Previews: PreviewProvider { struct AppThemePickerView_Previews: PreviewProvider {

View File

@@ -9,9 +9,10 @@ import SwiftUI
struct CustomWigetView: View { struct CustomWigetView: View {
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @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() @StateObject private var selectedWidget = CustomWidgetStateViewModel()
private var textColor: Color { theme.currentTheme.labelColor }
var body: some View { var body: some View {
ZStack { ZStack {
theme.currentTheme.secondaryBGColor theme.currentTheme.secondaryBGColor

View File

@@ -9,8 +9,9 @@ import SwiftUI
struct DayFilterPickerView: View { struct DayFilterPickerView: View {
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @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 @StateObject private var filteredDays = DaysFilterClass.shared
private var textColor: Color { theme.currentTheme.labelColor }
let weekdays = [(Calendar.current.shortWeekdaySymbols[0], 1), let weekdays = [(Calendar.current.shortWeekdaySymbols[0], 1),
(Calendar.current.shortWeekdaySymbols[1], 2), (Calendar.current.shortWeekdaySymbols[1], 2),

View File

@@ -12,8 +12,9 @@ struct PersonalityPackPickerView: View {
@AppStorage(UserDefaultsStore.Keys.personalityPack.rawValue, store: GroupUserDefaults.groupDefaults) private var personalityPack: PersonalityPack = .Default @AppStorage(UserDefaultsStore.Keys.personalityPack.rawValue, store: GroupUserDefaults.groupDefaults) private var personalityPack: PersonalityPack = .Default
@State private var showOver18Alert = false @State private var showOver18Alert = false
@AppStorage(UserDefaultsStore.Keys.showNSFW.rawValue, store: GroupUserDefaults.groupDefaults) private var showNSFW: Bool = 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 { var body: some View {
ZStack { ZStack {
theme.currentTheme.secondaryBGColor theme.currentTheme.secondaryBGColor

View File

@@ -10,10 +10,11 @@ import SwiftUI
struct ShapePickerView: View { struct ShapePickerView: View {
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@State var shapeRefreshToggleThing: Bool = false @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.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.shape.rawValue, store: GroupUserDefaults.groupDefaults) private var shape: BGShape = .circle
private var textColor: Color { theme.currentTheme.labelColor }
var body: some View { var body: some View {
ZStack { ZStack {
theme.currentTheme.secondaryBGColor theme.currentTheme.secondaryBGColor

View File

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

View File

@@ -11,7 +11,8 @@ struct ThemePickerView: View {
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@State private var selectedTheme: Theme = UserDefaultsStore.theme() @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 { var body: some View {
ZStack { ZStack {
@@ -70,31 +71,8 @@ struct ThemePickerView: View {
selectedTheme = theme selectedTheme = theme
} }
changeTextColor(forTheme: theme)
EventLogger.log(event: "change_theme_id", withData: ["id": theme.rawValue]) 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 { struct ThemePickerView_Previews: PreviewProvider {

View File

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

View File

@@ -9,9 +9,10 @@ import SwiftUI
struct VotingLayoutPickerView: View { struct VotingLayoutPickerView: View {
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @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 @AppStorage(UserDefaultsStore.Keys.votingLayoutStyle.rawValue, store: GroupUserDefaults.groupDefaults) private var votingLayoutStyle: Int = 0
private var textColor: Color { theme.currentTheme.labelColor }
private var currentLayout: VotingLayoutStyle { private var currentLayout: VotingLayoutStyle {
VotingLayoutStyle(rawValue: votingLayoutStyle) ?? .horizontal VotingLayoutStyle(rawValue: votingLayoutStyle) ?? .horizontal
} }

View File

@@ -20,13 +20,9 @@ struct DayView: View {
@AppStorage(UserDefaultsStore.Keys.moodImages.rawValue, store: GroupUserDefaults.groupDefaults) private var imagePack: MoodImages = .FontAwesome @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.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.dayViewStyle.rawValue, store: GroupUserDefaults.groupDefaults) private var dayViewStyle: DayViewStyle = .classic
@Environment(\.colorScheme) private var colorScheme @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 // MARK: edit row properties
@State private var showingSheet = false @State private var showingSheet = false
@State private var selectedEntry: MoodEntryModel? @State private var selectedEntry: MoodEntryModel?
@@ -42,9 +38,6 @@ struct DayView: View {
var body: some View { var body: some View {
ZStack { ZStack {
Text(String(customMoodTintUpdateNumber))
.hidden()
mainView mainView
.onAppear(perform: { .onAppear(perform: {
EventLogger.log(event: "show_home_view") EventLogger.log(event: "show_home_view")
@@ -65,6 +58,7 @@ struct DayView: View {
} }
} }
.padding([.top]) .padding([.top])
.preferredColorScheme(theme.preferredColorScheme)
} }
@@ -83,7 +77,6 @@ struct DayView: View {
} }
.padding([.leading, .trailing]) .padding([.leading, .trailing])
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in
DataController.shared.fillInMissingDates()
viewModel.updateData() viewModel.updateData()
} }
.background( .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 { private var listView: some View {
ScrollView { ScrollView {
LazyVStack(spacing: 8, pinnedViews: [.sectionHeaders]) { LazyVStack(spacing: 8, pinnedViews: [.sectionHeaders]) {
ForEach(viewModel.grouped.sorted(by: { ForEach(sortedGroupedData, id: \.year) { yearData in
$0.key > $1.key ForEach(yearData.months, id: \.month) { monthData in
}), id: \.key) { year, months in Section(header: SectionHeaderView(month: monthData.month, year: yearData.year, entries: monthData.entries)) {
monthListView(month: monthData.month, year: yearData.year, entries: monthData.entries)
// 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)
} }
} }
} }
@@ -172,12 +166,12 @@ extension DayView {
// Calendar icon // Calendar icon
Image(systemName: "calendar") Image(systemName: "calendar")
.font(.body.weight(.semibold)) .font(.body.weight(.semibold))
.foregroundColor(textColor.opacity(0.6)) .foregroundColor(theme.currentTheme.labelColor.opacity(0.6))
.accessibilityHidden(true) .accessibilityHidden(true)
Text("\(Random.monthName(fromMonthInt: month)) \(String(year))") Text("\(Random.monthName(fromMonthInt: month)) \(String(year))")
.font(.title3.weight(.bold)) .font(.title3.weight(.bold))
.foregroundColor(textColor) .foregroundColor(theme.currentTheme.labelColor)
Spacer() Spacer()
} }
@@ -196,7 +190,7 @@ extension DayView {
.font(.largeTitle.weight(.black)) .font(.largeTitle.weight(.black))
.foregroundStyle( .foregroundStyle(
LinearGradient( LinearGradient(
colors: [textColor, textColor.opacity(0.4)], colors: [theme.currentTheme.labelColor, theme.currentTheme.labelColor.opacity(0.4)],
startPoint: .top, startPoint: .top,
endPoint: .bottom endPoint: .bottom
) )
@@ -207,18 +201,18 @@ extension DayView {
Text(Random.monthName(fromMonthInt: month).uppercased()) Text(Random.monthName(fromMonthInt: month).uppercased())
.font(.subheadline.weight(.bold)) .font(.subheadline.weight(.bold))
.tracking(3) .tracking(3)
.foregroundColor(textColor) .foregroundColor(theme.currentTheme.labelColor)
Text(String(year)) Text(String(year))
.font(.caption.weight(.medium)) .font(.caption.weight(.medium))
.foregroundColor(textColor.opacity(0.5)) .foregroundColor(theme.currentTheme.labelColor.opacity(0.5))
} }
Spacer() Spacer()
// Decorative element // Decorative element
Circle() Circle()
.fill(textColor.opacity(0.1)) .fill(theme.currentTheme.labelColor.opacity(0.1))
.frame(width: 8, height: 8) .frame(width: 8, height: 8)
} }
.padding(.horizontal, 20) .padding(.horizontal, 20)
@@ -232,7 +226,7 @@ extension DayView {
// Subtle gradient accent // Subtle gradient accent
LinearGradient( LinearGradient(
colors: [ colors: [
textColor.opacity(0.03), theme.currentTheme.labelColor.opacity(0.03),
Color.clear Color.clear
], ],
startPoint: .leading, startPoint: .leading,
@@ -246,34 +240,34 @@ extension DayView {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
// Thick editorial rule // Thick editorial rule
Rectangle() Rectangle()
.fill(textColor) .fill(theme.currentTheme.labelColor)
.frame(height: 4) .frame(height: 4)
HStack(alignment: .firstTextBaseline, spacing: 12) { HStack(alignment: .firstTextBaseline, spacing: 12) {
// Large serif month name // Large serif month name
Text(Random.monthName(fromMonthInt: month).uppercased()) Text(Random.monthName(fromMonthInt: month).uppercased())
.font(.title.weight(.regular)) .font(.title.weight(.regular))
.foregroundColor(textColor) .foregroundColor(theme.currentTheme.labelColor)
// Year in lighter weight // Year in lighter weight
Text(String(year)) Text(String(year))
.font(.body.weight(.light)) .font(.body.weight(.light))
.italic() .italic()
.foregroundColor(textColor.opacity(0.5)) .foregroundColor(theme.currentTheme.labelColor.opacity(0.5))
Spacer() Spacer()
// Decorative flourish // Decorative flourish
Text("§") Text("§")
.font(.title3.weight(.regular)) .font(.title3.weight(.regular))
.foregroundColor(textColor.opacity(0.3)) .foregroundColor(theme.currentTheme.labelColor.opacity(0.3))
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 16) .padding(.vertical, 16)
// Thin bottom rule // Thin bottom rule
Rectangle() Rectangle()
.fill(textColor.opacity(0.2)) .fill(theme.currentTheme.labelColor.opacity(0.2))
.frame(height: 1) .frame(height: 1)
} }
.background(.ultraThinMaterial) .background(.ultraThinMaterial)
@@ -306,13 +300,14 @@ extension DayView {
ZStack { ZStack {
Color.black Color.black
// Scanlines // Scanlines - simplified with Canvas for performance
VStack(spacing: 3) { Canvas { context, size in
ForEach(0..<8, id: \.self) { _ in let lineHeight: CGFloat = 4
Rectangle() var y: CGFloat = 0
.fill(Color.white.opacity(0.02)) while y < size.height {
.frame(height: 1) let rect = CGRect(x: 0, y: y, width: size.width, height: 1)
Spacer().frame(height: 3) context.fill(Path(rect), with: .color(.white.opacity(0.02)))
y += lineHeight
} }
} }
} }
@@ -323,18 +318,18 @@ extension DayView {
HStack(alignment: .center, spacing: 16) { HStack(alignment: .center, spacing: 16) {
// Brush stroke accent // Brush stroke accent
Capsule() Capsule()
.fill(textColor.opacity(0.15)) .fill(theme.currentTheme.labelColor.opacity(0.15))
.frame(width: 40, height: 3) .frame(width: 40, height: 3)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(Random.monthName(fromMonthInt: month)) Text(Random.monthName(fromMonthInt: month))
.font(.headline.weight(.thin)) .font(.headline.weight(.thin))
.tracking(4) .tracking(4)
.foregroundColor(textColor) .foregroundColor(theme.currentTheme.labelColor)
Text(String(year)) Text(String(year))
.font(.caption2.weight(.ultraLight)) .font(.caption2.weight(.ultraLight))
.foregroundColor(textColor.opacity(0.4)) .foregroundColor(theme.currentTheme.labelColor.opacity(0.4))
} }
Spacer() Spacer()
@@ -342,7 +337,7 @@ extension DayView {
// Zen circle ornament // Zen circle ornament
Circle() Circle()
.trim(from: 0, to: 0.7) .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) .frame(width: 20, height: 20)
.rotationEffect(.degrees(-60)) .rotationEffect(.degrees(-60))
} }
@@ -371,15 +366,15 @@ extension DayView {
HStack(spacing: 12) { HStack(spacing: 12) {
Text(Random.monthName(fromMonthInt: month)) Text(Random.monthName(fromMonthInt: month))
.font(.title3.weight(.semibold)) .font(.title3.weight(.semibold))
.foregroundColor(textColor) .foregroundColor(theme.currentTheme.labelColor)
Capsule() Capsule()
.fill(textColor.opacity(0.2)) .fill(theme.currentTheme.labelColor.opacity(0.2))
.frame(width: 4, height: 4) .frame(width: 4, height: 4)
Text(String(year)) Text(String(year))
.font(.body.weight(.medium)) .font(.body.weight(.medium))
.foregroundColor(textColor.opacity(0.6)) .foregroundColor(theme.currentTheme.labelColor.opacity(0.6))
Spacer() Spacer()
} }
@@ -395,21 +390,21 @@ extension DayView {
// Tape reel icon // Tape reel icon
ZStack { ZStack {
Circle() Circle()
.stroke(textColor.opacity(0.3), lineWidth: 2) .stroke(theme.currentTheme.labelColor.opacity(0.3), lineWidth: 2)
.frame(width: 24, height: 24) .frame(width: 24, height: 24)
Circle() Circle()
.fill(textColor.opacity(0.2)) .fill(theme.currentTheme.labelColor.opacity(0.2))
.frame(width: 10, height: 10) .frame(width: 10, height: 10)
} }
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("SIDE A") Text("SIDE A")
.font(.caption2.weight(.bold).monospaced()) .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))") Text("\(Random.monthName(fromMonthInt: month).uppercased()) '\(String(year).suffix(2))")
.font(.body.weight(.black)) .font(.body.weight(.black))
.foregroundColor(textColor) .foregroundColor(theme.currentTheme.labelColor)
.tracking(1) .tracking(1)
} }
@@ -418,7 +413,7 @@ extension DayView {
// Track counter // Track counter
Text(String(format: "%02d", month)) Text(String(format: "%02d", month))
.font(.title3.weight(.bold).monospaced()) .font(.title3.weight(.bold).monospaced())
.foregroundColor(textColor.opacity(0.3)) .foregroundColor(theme.currentTheme.labelColor.opacity(0.3))
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 14) .padding(.vertical, 14)
@@ -433,7 +428,7 @@ extension DayView {
// Organic blob background // Organic blob background
HStack { HStack {
Ellipse() Ellipse()
.fill(textColor.opacity(0.08)) .fill(theme.currentTheme.labelColor.opacity(0.08))
.frame(width: 120, height: 60) .frame(width: 120, height: 60)
.blur(radius: 15) .blur(radius: 15)
.offset(x: -20) .offset(x: -20)
@@ -443,17 +438,17 @@ extension DayView {
HStack(spacing: 16) { HStack(spacing: 16) {
Text(Random.monthName(fromMonthInt: month)) Text(Random.monthName(fromMonthInt: month))
.font(.title2.weight(.light)) .font(.title2.weight(.light))
.foregroundColor(textColor) .foregroundColor(theme.currentTheme.labelColor)
Text(String(year)) Text(String(year))
.font(.subheadline.weight(.regular)) .font(.subheadline.weight(.regular))
.foregroundColor(textColor.opacity(0.4)) .foregroundColor(theme.currentTheme.labelColor.opacity(0.4))
Spacer() Spacer()
// Blob indicator // Blob indicator
Circle() Circle()
.fill(textColor.opacity(0.15)) .fill(theme.currentTheme.labelColor.opacity(0.15))
.frame(width: 12, height: 12) .frame(width: 12, height: 12)
.blur(radius: 2) .blur(radius: 2)
} }
@@ -465,12 +460,13 @@ extension DayView {
private func stackSectionHeader(month: Int, year: Int) -> some View { private func stackSectionHeader(month: Int, year: Int) -> some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
// Torn edge // Torn edge - simplified with Canvas for performance
HStack(spacing: 0) { Canvas { context, size in
ForEach(0..<30, id: \.self) { _ in let segmentWidth = size.width / 15
Rectangle() for i in 0..<15 {
.fill(textColor.opacity(0.2)) let height = CGFloat(2 + (i * 5) % 3) // Deterministic pseudo-random heights
.frame(height: CGFloat.random(in: 2...4)) 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) .frame(height: 4)
@@ -483,12 +479,12 @@ extension DayView {
Text(Random.monthName(fromMonthInt: month)) Text(Random.monthName(fromMonthInt: month))
.font(.headline.weight(.regular)) .font(.headline.weight(.regular))
.foregroundColor(textColor) .foregroundColor(theme.currentTheme.labelColor)
Text(String(year)) Text(String(year))
.font(.subheadline.weight(.light)) .font(.subheadline.weight(.light))
.italic() .italic()
.foregroundColor(textColor.opacity(0.5)) .foregroundColor(theme.currentTheme.labelColor.opacity(0.5))
Spacer() 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%) // Width percentage based on average (0=20%, 4=100%)
let widthPercent = hasData ? 0.2 + (averageMood / 4.0) * 0.8 : 0.2 let widthPercent = hasData ? 0.2 + (averageMood / 4.0) * 0.8 : 0.2
@@ -524,7 +520,7 @@ extension DayView {
// Month number // Month number
Text(String(format: "%02d", month)) Text(String(format: "%02d", month))
.font(.title.weight(.thin)) .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) .frame(width: 50)
// Gradient bar sized by average mood // Gradient bar sized by average mood
@@ -532,7 +528,7 @@ extension DayView {
ZStack(alignment: .leading) { ZStack(alignment: .leading) {
// Background track // Background track
Capsule() Capsule()
.fill(textColor.opacity(0.1)) .fill(theme.currentTheme.labelColor.opacity(0.1))
.frame(height: 8) .frame(height: 8)
// Colored bar based on average // Colored bar based on average
@@ -554,7 +550,7 @@ extension DayView {
VStack(alignment: .trailing, spacing: 2) { VStack(alignment: .trailing, spacing: 2) {
Text(Random.monthName(fromMonthInt: month)) Text(Random.monthName(fromMonthInt: month))
.font(.subheadline.weight(.medium)) .font(.subheadline.weight(.medium))
.foregroundColor(textColor) .foregroundColor(theme.currentTheme.labelColor)
if hasData { if hasData {
Text(String(format: "%.1f avg", averageMood + 1)) // Display as 1-5 Text(String(format: "%.1f avg", averageMood + 1)) // Display as 1-5
@@ -563,7 +559,7 @@ extension DayView {
} else { } else {
Text(String(year)) Text(String(year))
.font(.caption2.weight(.regular)) .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) { HStack(spacing: 10) {
Image(systemName: "calendar") Image(systemName: "calendar")
.font(.body.weight(.semibold)) .font(.body.weight(.semibold))
.foregroundColor(textColor.opacity(0.6)) .foregroundColor(theme.currentTheme.labelColor.opacity(0.6))
Text("\(Random.monthName(fromMonthInt: month)) \(String(year))") Text("\(Random.monthName(fromMonthInt: month)) \(String(year))")
.font(.title3.weight(.bold)) .font(.title3.weight(.bold))
.foregroundColor(textColor) .foregroundColor(theme.currentTheme.labelColor)
Spacer() Spacer()
} }
@@ -726,17 +722,17 @@ extension DayView {
Text(String(format: "%02d", month)) Text(String(format: "%02d", month))
.font(.subheadline.weight(.semibold)) .font(.subheadline.weight(.semibold))
.foregroundColor(textColor) .foregroundColor(theme.currentTheme.labelColor)
} }
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(Random.monthName(fromMonthInt: month)) Text(Random.monthName(fromMonthInt: month))
.font(.headline.weight(.medium)) .font(.headline.weight(.medium))
.foregroundColor(textColor) .foregroundColor(theme.currentTheme.labelColor)
Text(String(year)) Text(String(year))
.font(.caption.weight(.regular)) .font(.caption.weight(.regular))
.foregroundColor(textColor.opacity(0.5)) .foregroundColor(theme.currentTheme.labelColor.opacity(0.5))
} }
Spacer() Spacer()
@@ -818,11 +814,11 @@ extension DayView {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(Random.monthName(fromMonthInt: month)) Text(Random.monthName(fromMonthInt: month))
.font(.title3.weight(.semibold)) .font(.title3.weight(.semibold))
.foregroundColor(textColor) .foregroundColor(theme.currentTheme.labelColor)
Text(String(year)) Text(String(year))
.font(.caption.weight(.medium)) .font(.caption.weight(.medium))
.foregroundColor(textColor.opacity(0.5)) .foregroundColor(theme.currentTheme.labelColor.opacity(0.5))
} }
Spacer() Spacer()
@@ -830,7 +826,7 @@ extension DayView {
// Tilt indicator // Tilt indicator
Image(systemName: "iphone.gen3.radiowaves.left.and.right") Image(systemName: "iphone.gen3.radiowaves.left.and.right")
.font(.headline) .font(.headline)
.foregroundColor(textColor.opacity(0.3)) .foregroundColor(theme.currentTheme.labelColor.opacity(0.3))
} }
.padding(.horizontal, 18) .padding(.horizontal, 18)
} }
@@ -847,24 +843,24 @@ extension DayView {
HStack(spacing: 8) { HStack(spacing: 8) {
// Minimal colored bar // Minimal colored bar
RoundedRectangle(cornerRadius: 2) RoundedRectangle(cornerRadius: 2)
.fill(textColor.opacity(0.3)) .fill(theme.currentTheme.labelColor.opacity(0.3))
.frame(width: 3, height: 16) .frame(width: 3, height: 16)
Text("\(Random.monthName(fromMonthInt: month).prefix(3).uppercased())") Text("\(Random.monthName(fromMonthInt: month).prefix(3).uppercased())")
.font(.caption2.weight(.bold).monospaced()) .font(.caption2.weight(.bold).monospaced())
.foregroundColor(textColor.opacity(0.6)) .foregroundColor(theme.currentTheme.labelColor.opacity(0.6))
Text("") Text("")
.font(.caption2) .font(.caption2)
.foregroundColor(textColor.opacity(0.3)) .foregroundColor(theme.currentTheme.labelColor.opacity(0.3))
Text(String(year)) Text(String(year))
.font(.caption2.weight(.medium).monospaced()) .font(.caption2.weight(.medium).monospaced())
.foregroundColor(textColor.opacity(0.4)) .foregroundColor(theme.currentTheme.labelColor.opacity(0.4))
// Thin separator line // Thin separator line
Rectangle() Rectangle()
.fill(textColor.opacity(0.1)) .fill(theme.currentTheme.labelColor.opacity(0.1))
.frame(height: 1) .frame(height: 1)
} }
.padding(.horizontal, 14) .padding(.horizontal, 14)

View File

@@ -37,9 +37,8 @@ class DayViewViewModel: ObservableObject {
DataController.shared.addNewDataListener { [weak self] in DataController.shared.addNewDataListener { [weak self] in
guard let self = self else { return } guard let self = self else { return }
withAnimation { // Avoid withAnimation for bulk data updates - it causes expensive view diffing
self.updateData() self.updateData()
}
} }
updateData() updateData()
} }

View File

@@ -9,7 +9,8 @@ import SwiftUI
struct EmptyHomeView: View { struct EmptyHomeView: View {
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @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 showVote: Bool
let viewModel: DayViewViewModel? let viewModel: DayViewViewModel?

View File

@@ -11,10 +11,12 @@ import CoreMotion
struct EntryListView: View { struct EntryListView: View {
@AppStorage(UserDefaultsStore.Keys.moodImages.rawValue, store: GroupUserDefaults.groupDefaults) private var imagePack: MoodImages = .FontAwesome @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.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.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 @Environment(\.colorScheme) private var colorScheme
private var textColor: Color { theme.currentTheme.labelColor }
public let entry: MoodEntryModel public let entry: MoodEntryModel
private var moodColor: Color { private var moodColor: Color {
@@ -30,6 +32,21 @@ struct EntryListView: View {
entry.moodValue == Mood.missing.rawValue 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 { var body: some View {
Group { Group {
switch dayViewStyle { switch dayViewStyle {
@@ -82,9 +99,7 @@ struct EntryListView: View {
} }
private var accessibilityDescription: String { private var accessibilityDescription: String {
let dateFormatter = DateFormatter() let dateString = DateFormattingCache.shared.string(for: entry.forDate, format: .dateFull)
dateFormatter.dateStyle = .full
let dateString = dateFormatter.string(from: entry.forDate)
if isMissing { if isMissing {
return String(localized: "\(dateString), no mood logged") return String(localized: "\(dateString), no mood logged")
@@ -196,7 +211,7 @@ struct EntryListView: View {
) )
VStack(alignment: .leading, spacing: 3) { VStack(alignment: .leading, spacing: 3) {
Text(entry.forDate, format: .dateTime.weekday(.wide).day()) Text(cachedWeekdayWideDay)
.font(.subheadline.weight(.medium)) .font(.subheadline.weight(.medium))
.foregroundColor(textColor) .foregroundColor(textColor)
@@ -233,10 +248,10 @@ struct EntryListView: View {
// Date column // Date column
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
Text(entry.forDate, format: .dateTime.day()) Text(cachedDay)
.font(.title3.weight(.bold)) .font(.title3.weight(.bold))
.foregroundColor(textColor) .foregroundColor(textColor)
Text(entry.forDate, format: .dateTime.weekday(.abbreviated)) Text(cachedWeekdayAbbrev)
.font(.caption2.weight(.medium)) .font(.caption2.weight(.medium))
.foregroundColor(textColor.opacity(0.5)) .foregroundColor(textColor.opacity(0.5))
.textCase(.uppercase) .textCase(.uppercase)
@@ -293,7 +308,7 @@ struct EntryListView: View {
.accessibilityLabel(entry.mood.strValue) .accessibilityLabel(entry.mood.strValue)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(entry.forDate, format: .dateTime.weekday(.wide).day()) Text(cachedWeekdayWideDay)
.font(.subheadline.weight(.semibold)) .font(.subheadline.weight(.semibold))
.foregroundColor(isMissing ? textColor : moodContrastingTextColor) .foregroundColor(isMissing ? textColor : moodContrastingTextColor)
@@ -361,12 +376,12 @@ struct EntryListView: View {
) )
// Day number // Day number
Text(entry.forDate, format: .dateTime.day()) Text(cachedDay)
.font(.subheadline.weight(.bold)) .font(.subheadline.weight(.bold))
.foregroundColor(textColor) .foregroundColor(textColor)
// Weekday abbreviation // Weekday abbreviation
Text(entry.forDate, format: .dateTime.weekday(.abbreviated)) Text(cachedWeekdayAbbrev)
.font(.caption2.weight(.medium)) .font(.caption2.weight(.medium))
.foregroundColor(textColor.opacity(0.5)) .foregroundColor(textColor.opacity(0.5))
.textCase(.uppercase) .textCase(.uppercase)
@@ -383,7 +398,7 @@ struct EntryListView: View {
private var auraStyle: some View { private var auraStyle: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
// Giant day number - the visual hero // Giant day number - the visual hero
Text(entry.forDate, format: .dateTime.day()) Text(cachedDay)
.font(.largeTitle.weight(.black)) .font(.largeTitle.weight(.black))
.foregroundStyle( .foregroundStyle(
isMissing isMissing
@@ -396,7 +411,7 @@ struct EntryListView: View {
// Content area with glowing aura // Content area with glowing aura
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
// Weekday with elegant typography // Weekday with elegant typography
Text(entry.forDate, format: .dateTime.weekday(.wide)) Text(cachedWeekdayWide)
.font(.caption.weight(.semibold)) .font(.caption.weight(.semibold))
.textCase(.uppercase) .textCase(.uppercase)
.tracking(2) .tracking(2)
@@ -449,7 +464,7 @@ struct EntryListView: View {
.foregroundColor(textColor) .foregroundColor(textColor)
// Month context // Month context
Text(entry.forDate, format: .dateTime.month(.wide)) Text(cachedMonthWide)
.font(.caption.weight(.medium)) .font(.caption.weight(.medium))
.foregroundColor(textColor.opacity(0.4)) .foregroundColor(textColor.opacity(0.4))
} }
@@ -522,12 +537,12 @@ struct EntryListView: View {
HStack(alignment: .top, spacing: 16) { HStack(alignment: .top, spacing: 16) {
// Left column: Giant day number in serif // Left column: Giant day number in serif
VStack(alignment: .trailing, spacing: 0) { VStack(alignment: .trailing, spacing: 0) {
Text(entry.forDate, format: .dateTime.day()) Text(cachedDay)
.font(.largeTitle.weight(.regular)) .font(.largeTitle.weight(.regular))
.foregroundColor(textColor) .foregroundColor(textColor)
.frame(width: 80) .frame(width: 80)
Text(entry.forDate, format: .dateTime.weekday(.abbreviated).month(.abbreviated)) Text(cachedWeekdayAbbrevMonthAbbrev)
.font(.caption2.weight(.regular)) .font(.caption2.weight(.regular))
.italic() .italic()
.foregroundColor(textColor.opacity(0.5)) .foregroundColor(textColor.opacity(0.5))
@@ -641,13 +656,14 @@ struct EntryListView: View {
} }
.clipShape(RoundedRectangle(cornerRadius: 6)) .clipShape(RoundedRectangle(cornerRadius: 6))
// Scanline overlay for CRT effect // Scanline overlay for CRT effect - simplified with gradient stripes
VStack(spacing: 0) { Canvas { context, size in
ForEach(0..<30, id: \.self) { _ in let lineHeight: CGFloat = 3
Rectangle() var y: CGFloat = 0
.fill(Color.white.opacity(0.015)) while y < size.height {
.frame(height: 1) let rect = CGRect(x: 0, y: y, width: size.width, height: 1)
Spacer().frame(height: 2) context.fill(Path(rect), with: .color(.white.opacity(0.015)))
y += lineHeight
} }
} }
.clipShape(RoundedRectangle(cornerRadius: 6)) .clipShape(RoundedRectangle(cornerRadius: 6))
@@ -697,7 +713,7 @@ struct EntryListView: View {
VStack(alignment: .leading, spacing: 5) { VStack(alignment: .leading, spacing: 5) {
// Date in cyan monospace // Date in cyan monospace
Text(entry.forDate, format: .dateTime.year().month(.twoDigits).day(.twoDigits)) Text(cachedYearMonthDayDigits)
.font(.system(.caption, design: .monospaced).weight(.semibold)) .font(.system(.caption, design: .monospaced).weight(.semibold))
.foregroundColor(neonCyan) .foregroundColor(neonCyan)
.shadow(color: neonCyan.opacity(0.5), radius: 4, x: 0, y: 0) .shadow(color: neonCyan.opacity(0.5), radius: 4, x: 0, y: 0)
@@ -722,7 +738,7 @@ struct EntryListView: View {
} }
// Weekday in magenta // Weekday in magenta
Text(entry.forDate, format: .dateTime.weekday(.wide)) Text(cachedWeekdayWide)
.font(.system(.caption2, design: .monospaced).weight(.medium)) .font(.system(.caption2, design: .monospaced).weight(.medium))
.foregroundColor(neonMagenta.opacity(0.7)) .foregroundColor(neonMagenta.opacity(0.7))
.textCase(.uppercase) .textCase(.uppercase)
@@ -836,18 +852,18 @@ struct EntryListView: View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
// Day number with brush-like weight variation // Day number with brush-like weight variation
HStack(alignment: .firstTextBaseline, spacing: 4) { HStack(alignment: .firstTextBaseline, spacing: 4) {
Text(entry.forDate, format: .dateTime.day()) Text(cachedDay)
.font(.title.weight(.thin)) .font(.title.weight(.thin))
.foregroundColor(textColor) .foregroundColor(textColor)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(entry.forDate, format: .dateTime.month(.wide)) Text(cachedMonthWide)
.font(.caption2.weight(.light)) .font(.caption2.weight(.light))
.foregroundColor(textColor.opacity(0.5)) .foregroundColor(textColor.opacity(0.5))
.textCase(.uppercase) .textCase(.uppercase)
.tracking(2) .tracking(2)
Text(entry.forDate, format: .dateTime.weekday(.wide)) Text(cachedWeekdayWide)
.font(.caption2.weight(.light)) .font(.caption2.weight(.light))
.foregroundColor(textColor.opacity(0.35)) .foregroundColor(textColor.opacity(0.35))
} }
@@ -984,12 +1000,12 @@ struct EntryListView: View {
} }
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text(entry.forDate, format: .dateTime.weekday(.wide)) Text(cachedWeekdayWide)
.font(.subheadline.weight(.semibold)) .font(.subheadline.weight(.semibold))
.foregroundColor(textColor) .foregroundColor(textColor)
HStack(spacing: 8) { HStack(spacing: 8) {
Text(entry.forDate, format: .dateTime.month(.abbreviated).day()) Text(cachedMonthAbbrevDay)
.font(.caption.weight(.medium)) .font(.caption.weight(.medium))
.foregroundColor(textColor.opacity(0.6)) .foregroundColor(textColor.opacity(0.6))
@@ -1037,7 +1053,7 @@ struct EntryListView: View {
HStack(spacing: 0) { HStack(spacing: 0) {
// Track number column // Track number column
VStack { VStack {
Text(entry.forDate, format: .dateTime.day()) Text(cachedDay)
.font(.title2.weight(.bold).monospaced()) .font(.title2.weight(.bold).monospaced())
.foregroundColor(isMissing ? .gray : moodColor) .foregroundColor(isMissing ? .gray : moodColor)
} }
@@ -1067,7 +1083,7 @@ struct EntryListView: View {
// Track info // Track info
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(entry.forDate, format: .dateTime.weekday(.wide).month(.abbreviated)) Text(cachedWeekdayWideMonthAbbrev)
.font(.caption2.weight(.medium).monospaced()) .font(.caption2.weight(.medium).monospaced())
.foregroundColor(textColor.opacity(0.5)) .foregroundColor(textColor.opacity(0.5))
.textCase(.uppercase) .textCase(.uppercase)
@@ -1194,15 +1210,15 @@ struct EntryListView: View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
// Date with organic flow // Date with organic flow
HStack(spacing: 0) { HStack(spacing: 0) {
Text(entry.forDate, format: .dateTime.day()) Text(cachedDay)
.font(.title.weight(.light)) .font(.title.weight(.light))
.foregroundColor(textColor) .foregroundColor(textColor)
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
Text(entry.forDate, format: .dateTime.month(.abbreviated)) Text(cachedMonthAbbrev)
.font(.caption.weight(.medium)) .font(.caption.weight(.medium))
.foregroundColor(textColor.opacity(0.6)) .foregroundColor(textColor.opacity(0.6))
Text(entry.forDate, format: .dateTime.weekday(.abbreviated)) Text(cachedWeekdayAbbrev)
.font(.caption2.weight(.regular)) .font(.caption2.weight(.regular))
.foregroundColor(textColor.opacity(0.4)) .foregroundColor(textColor.opacity(0.4))
} }
@@ -1246,12 +1262,14 @@ struct EntryListView: View {
// Main note // Main note
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
// Torn paper edge effect // Torn paper edge effect - simplified with Canvas
HStack(spacing: 0) { Canvas { context, size in
ForEach(0..<20, id: \.self) { i in let segmentWidth = size.width / 10
Rectangle() let color = (isMissing ? Color.gray.opacity(0.3) : moodColor.opacity(0.6))
.fill(isMissing ? Color.gray.opacity(0.3) : moodColor.opacity(0.6)) for i in 0..<10 {
.frame(width: .infinity, height: CGFloat.random(in: 3...6)) 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) .frame(height: 6)
@@ -1259,11 +1277,11 @@ struct EntryListView: View {
HStack(spacing: 16) { HStack(spacing: 16) {
// Handwritten-style date // Handwritten-style date
VStack(alignment: .center, spacing: 2) { VStack(alignment: .center, spacing: 2) {
Text(entry.forDate, format: .dateTime.day()) Text(cachedDay)
.font(.title.weight(.light)) .font(.title.weight(.light))
.foregroundColor(textColor) .foregroundColor(textColor)
Text(entry.forDate, format: .dateTime.weekday(.abbreviated)) Text(cachedWeekdayAbbrev)
.font(.caption.weight(.medium)) .font(.caption.weight(.medium))
.foregroundColor(textColor.opacity(0.5)) .foregroundColor(textColor.opacity(0.5))
.textCase(.uppercase) .textCase(.uppercase)
@@ -1278,7 +1296,7 @@ struct EntryListView: View {
// Content area // Content area
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
// Lined paper effect // Lined paper effect
Text(entry.forDate, format: .dateTime.month(.wide).year()) Text(cachedMonthWideYear)
.font(.caption.weight(.regular)) .font(.caption.weight(.regular))
.foregroundColor(textColor.opacity(0.4)) .foregroundColor(textColor.opacity(0.4))
@@ -1330,11 +1348,11 @@ struct EntryListView: View {
HStack(spacing: 0) { HStack(spacing: 0) {
// Date column - minimal // Date column - minimal
VStack(alignment: .trailing, spacing: 2) { VStack(alignment: .trailing, spacing: 2) {
Text(entry.forDate, format: .dateTime.day()) Text(cachedDay)
.font(.title.weight(.thin)) .font(.title.weight(.thin))
.foregroundColor(textColor) .foregroundColor(textColor)
Text(entry.forDate, format: .dateTime.weekday(.abbreviated)) Text(cachedWeekdayAbbrev)
.font(.caption2.weight(.medium)) .font(.caption2.weight(.medium))
.foregroundColor(textColor.opacity(0.4)) .foregroundColor(textColor.opacity(0.4))
.textCase(.uppercase) .textCase(.uppercase)
@@ -1401,7 +1419,7 @@ struct EntryListView: View {
Spacer() Spacer()
// Month indicator // Month indicator
Text(entry.forDate, format: .dateTime.month(.abbreviated)) Text(cachedMonthAbbrev)
.font(.caption2.weight(.bold)) .font(.caption2.weight(.bold))
.foregroundColor(isMissing ? .gray : moodContrastingTextColor.opacity(0.7)) .foregroundColor(isMissing ? .gray : moodContrastingTextColor.opacity(0.7))
.padding(.trailing, 16) .padding(.trailing, 16)
@@ -1436,7 +1454,7 @@ struct EntryListView: View {
} }
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text(entry.forDate, format: .dateTime.weekday(.wide).day()) Text(cachedWeekdayWideDay)
.font(.body.weight(.semibold)) .font(.body.weight(.semibold))
.foregroundColor(textColor) .foregroundColor(textColor)
@@ -1456,7 +1474,7 @@ struct EntryListView: View {
Spacer() Spacer()
Text(entry.forDate, format: .dateTime.month(.abbreviated)) Text(cachedMonthAbbrev)
.font(.caption.weight(.medium)) .font(.caption.weight(.medium))
.foregroundColor(textColor.opacity(0.6)) .foregroundColor(textColor.opacity(0.6))
.padding(.horizontal, 10) .padding(.horizontal, 10)
@@ -1466,35 +1484,30 @@ struct EntryListView: View {
.padding(16) .padding(16)
.frame(height: 86) .frame(height: 86)
.background { .background {
// Repeating mood icon pattern background // Simplified pattern background using Canvas for better performance
GeometryReader { geo in Canvas { context, size in
let iconSize: CGFloat = 20 let iconSize: CGFloat = 20
let spacing: CGFloat = 28 let spacing: CGFloat = 28
let cols = Int(geo.size.width / spacing) + 2 let patternColor = (isMissing ? Color.gray : moodColor).opacity(0.15)
let rows = Int(geo.size.height / spacing) + 2
ZStack { // Draw simple circles as pattern instead of complex icons
// Base background color var row = 0
RoundedRectangle(cornerRadius: 16) var y: CGFloat = 0
.fill(colorScheme == .dark ? Color(.systemGray6) : Color(.systemGray6).opacity(0.5)) while y < size.height + spacing {
var x: CGFloat = row.isMultiple(of: 2) ? spacing / 2 : 0
// Pattern overlay while x < size.width + spacing {
ForEach(0..<rows, id: \.self) { row in let rect = CGRect(x: x - iconSize/2, y: y - iconSize/2, width: iconSize, height: iconSize)
ForEach(0..<cols, id: \.self) { col in context.fill(Circle().path(in: rect), with: .color(patternColor))
imagePack.icon(forMood: entry.mood) x += spacing
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: iconSize, height: iconSize)
.foregroundColor(isMissing ? Color.gray.opacity(0.15) : moodColor.opacity(0.2))
.accessibilityHidden(true)
.position(
x: CGFloat(col) * spacing + (row.isMultiple(of: 2) ? spacing/2 : 0),
y: CGFloat(row) * spacing
)
}
} }
y += spacing
row += 1
} }
} }
.background(
RoundedRectangle(cornerRadius: 16)
.fill(colorScheme == .dark ? Color(.systemGray6) : Color(.systemGray6).opacity(0.5))
)
.clipShape(RoundedRectangle(cornerRadius: 16)) .clipShape(RoundedRectangle(cornerRadius: 16))
} }
.overlay( .overlay(
@@ -1596,12 +1609,12 @@ struct EntryListView: View {
} }
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(entry.forDate, format: .dateTime.weekday(.wide)) Text(cachedWeekdayWide)
.font(.subheadline.weight(.semibold)) .font(.subheadline.weight(.semibold))
.foregroundColor(Color(red: 0.95, green: 0.90, blue: 0.80)) .foregroundColor(Color(red: 0.95, green: 0.90, blue: 0.80))
.shadow(color: Color.black.opacity(0.5), radius: 1, x: 0, y: 1) .shadow(color: Color.black.opacity(0.5), radius: 1, x: 0, y: 1)
Text(entry.forDate, format: .dateTime.month(.abbreviated).day()) Text(cachedMonthAbbrevDay)
.font(.caption.weight(.medium)) .font(.caption.weight(.medium))
.foregroundColor(Color(red: 0.8, green: 0.7, blue: 0.55)) .foregroundColor(Color(red: 0.8, green: 0.7, blue: 0.55))
@@ -1728,12 +1741,12 @@ struct EntryListView: View {
} }
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text(entry.forDate, format: .dateTime.weekday(.wide)) Text(cachedWeekdayWide)
.font(.body.weight(.semibold)) .font(.body.weight(.semibold))
.foregroundColor(textColor) .foregroundColor(textColor)
HStack(spacing: 6) { HStack(spacing: 6) {
Text(entry.forDate, format: .dateTime.month(.abbreviated).day()) Text(cachedMonthAbbrevDay)
.font(.caption) .font(.caption)
.foregroundColor(textColor.opacity(0.6)) .foregroundColor(textColor.opacity(0.6))
@@ -1805,7 +1818,7 @@ struct EntryListView: View {
entry: entry, entry: entry,
imagePack: imagePack, imagePack: imagePack,
moodTint: moodTint, moodTint: moodTint,
textColor: textColor, theme: theme,
colorScheme: colorScheme, colorScheme: colorScheme,
isMissing: isMissing, isMissing: isMissing,
moodColor: moodColor moodColor: moodColor
@@ -1829,7 +1842,7 @@ struct EntryListView: View {
.accessibilityLabel(entry.mood.strValue) .accessibilityLabel(entry.mood.strValue)
// Date - very compact // Date - very compact
Text(entry.forDate, format: .dateTime.month(.abbreviated).day()) Text(cachedMonthAbbrevDay)
.font(.caption.weight(.medium).monospaced()) .font(.caption.weight(.medium).monospaced())
.foregroundColor(textColor.opacity(0.7)) .foregroundColor(textColor.opacity(0.7))
@@ -1883,7 +1896,7 @@ struct EntryListView: View {
entry: entry, entry: entry,
imagePack: imagePack, imagePack: imagePack,
moodColor: moodColor, moodColor: moodColor,
textColor: textColor, theme: theme,
colorScheme: colorScheme, colorScheme: colorScheme,
isMissing: isMissing isMissing: isMissing
) )
@@ -1895,10 +1908,17 @@ struct OrbitEntryView: View {
let entry: MoodEntryModel let entry: MoodEntryModel
let imagePack: MoodImages let imagePack: MoodImages
let moodColor: Color let moodColor: Color
let textColor: Color let theme: Theme
let colorScheme: ColorScheme let colorScheme: ColorScheme
let isMissing: Bool let isMissing: Bool
private var textColor: Color { theme.currentTheme.labelColor }
// Cached date strings
private var cachedDay: String { DateFormattingCache.shared.string(for: entry.forDate, format: .day) }
private var cachedWeekdayWide: String { DateFormattingCache.shared.string(for: entry.forDate, format: .weekdayWide) }
private var cachedMonthAbbrevYear: String { DateFormattingCache.shared.string(for: entry.forDate, format: .monthAbbreviatedYear) }
var body: some View { var body: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
// Orbital system on left // Orbital system on left
@@ -1970,7 +1990,7 @@ struct OrbitEntryView: View {
.frame(width: 28, height: 28) .frame(width: 28, height: 28)
.shadow(color: .black.opacity(0.15), radius: 4) .shadow(color: .black.opacity(0.15), radius: 4)
Text(entry.forDate, format: .dateTime.day()) Text(cachedDay)
.font(.caption.weight(.bold)) .font(.caption.weight(.bold))
.foregroundColor(.black.opacity(0.7)) .foregroundColor(.black.opacity(0.7))
} }
@@ -1979,11 +1999,11 @@ struct OrbitEntryView: View {
private var dateDisplay: some View { private var dateDisplay: some View {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(entry.forDate, format: .dateTime.weekday(.wide)) Text(cachedWeekdayWide)
.font(.body.weight(.semibold)) .font(.body.weight(.semibold))
.foregroundColor(textColor) .foregroundColor(textColor)
Text(entry.forDate, format: .dateTime.month(.abbreviated).year()) Text(cachedMonthAbbrevYear)
.font(.caption) .font(.caption)
.foregroundColor(textColor.opacity(0.5)) .foregroundColor(textColor.opacity(0.5))
} }
@@ -2040,15 +2060,19 @@ struct MotionCardView: View {
let entry: MoodEntryModel let entry: MoodEntryModel
let imagePack: MoodImages let imagePack: MoodImages
let moodTint: MoodTints let moodTint: MoodTints
let textColor: Color let theme: Theme
let colorScheme: ColorScheme let colorScheme: ColorScheme
let isMissing: Bool let isMissing: Bool
let moodColor: Color let moodColor: Color
@StateObject private var motionManager = MotionManager() private var textColor: Color { theme.currentTheme.labelColor }
// Cached date string
private var cachedDay: String { DateFormattingCache.shared.string(for: entry.forDate, format: .day) }
@ObservedObject private var motionManager = MotionManager.shared
var body: some View { var body: some View {
let dayNumber = Calendar.current.component(.day, from: entry.forDate)
ZStack { ZStack {
// Background with parallax offset // Background with parallax offset
@@ -2145,7 +2169,7 @@ struct MotionCardView: View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
// Day with motion // Day with motion
Text("\(dayNumber)") Text(cachedDay)
.font(.title.weight(.bold)) .font(.title.weight(.bold))
.foregroundColor(isMissing ? .gray : moodColor) .foregroundColor(isMissing ? .gray : moodColor)
.offset( .offset(
@@ -2196,37 +2220,45 @@ struct MotionCardView: View {
) )
) )
.shadow(color: (isMissing ? Color.gray : moodColor).opacity(0.15), radius: 12, x: 0, y: 6) .shadow(color: (isMissing ? Color.gray : moodColor).opacity(0.15), radius: 12, x: 0, y: 6)
.onAppear {
motionManager.startIfNeeded()
}
} }
} }
// MARK: - Motion Manager for Accelerometer // MARK: - Motion Manager for Accelerometer (Shared Singleton)
class MotionManager: ObservableObject { class MotionManager: ObservableObject {
/// Shared singleton - avoids creating per-cell instances
static let shared = MotionManager()
private let motionManager = CMMotionManager() private let motionManager = CMMotionManager()
@Published var xOffset: CGFloat = 0 @Published var xOffset: CGFloat = 0
@Published var yOffset: CGFloat = 0 @Published var yOffset: CGFloat = 0
init() { private var isRunning = false
startMotionUpdates()
}
private func startMotionUpdates() { private init() {}
// Respect Reduce Motion preference - skip parallax effect entirely
guard motionManager.isDeviceMotionAvailable, func startIfNeeded() {
guard !isRunning,
motionManager.isDeviceMotionAvailable,
!UIAccessibility.isReduceMotionEnabled else { return } !UIAccessibility.isReduceMotionEnabled else { return }
motionManager.deviceMotionUpdateInterval = 1/60 isRunning = true
motionManager.deviceMotionUpdateInterval = 1/30 // Reduced from 60fps to 30fps
motionManager.startDeviceMotionUpdates(to: .main) { [weak self] motion, error in motionManager.startDeviceMotionUpdates(to: .main) { [weak self] motion, error in
guard let motion = motion, error == nil else { return } guard let motion = motion, error == nil else { return }
withAnimation(.interactiveSpring(response: 0.15, dampingFraction: 0.8)) { withAnimation(.interactiveSpring(response: 0.15, dampingFraction: 0.8)) {
// Multiply by factor to make movement more noticeable
self?.xOffset = CGFloat(motion.attitude.roll) * 15 self?.xOffset = CGFloat(motion.attitude.roll) * 15
self?.yOffset = CGFloat(motion.attitude.pitch) * 15 self?.yOffset = CGFloat(motion.attitude.pitch) * 15
} }
} }
} }
deinit { func stop() {
guard isRunning else { return }
isRunning = false
motionManager.stopDeviceMotionUpdates() motionManager.stopDeviceMotionUpdates()
} }
} }

View File

@@ -10,7 +10,9 @@ import SwiftUI
struct ExportView: View { struct ExportView: View {
@Environment(\.dismiss) private var dismiss @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 }
@State private var selectedFormat: ExportFormat = .csv @State private var selectedFormat: ExportFormat = .csv
@State private var selectedRange: DateRange = .allTime @State private var selectedRange: DateRange = .allTime

View File

@@ -14,11 +14,11 @@ enum PercViewType {
struct HeaderPercView: View { struct HeaderPercView: View {
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default @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.shape.rawValue, store: GroupUserDefaults.groupDefaults) private var shape: BGShape = .circle @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
private var textColor: Color { theme.currentTheme.labelColor }
@State private var entries = [MoodMetrics]() @State private var entries = [MoodMetrics]()
let backDays: Int let backDays: Int
let type: PercViewType let type: PercViewType

View File

@@ -9,7 +9,8 @@ import SwiftUI
struct IAPWarningView: View { struct IAPWarningView: View {
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @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 @ObservedObject var iapManager: IAPManager

View File

@@ -12,8 +12,9 @@ struct ImagePickerGridView: View {
@Environment(\.presentationMode) var presentationMode @Environment(\.presentationMode) var presentationMode
@State var column = Array(repeating: GridItem(.flexible(), spacing: 10), count: 7) @State var column = Array(repeating: GridItem(.flexible(), spacing: 10), count: 7)
let pickedImageClosure: ((CustomWidgeImageOptions) -> Void) let pickedImageClosure: ((CustomWidgeImageOptions) -> 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: { let imageOptions = CustomWidgeImageOptions.allCases.sorted(by: {
$0.rawValue < $1.rawValue $0.rawValue < $1.rawValue
}) })

View File

@@ -9,11 +9,12 @@ import SwiftUI
struct InsightsView: View { struct InsightsView: View {
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @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.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.moodImages.rawValue, store: GroupUserDefaults.groupDefaults) private var imagePack: MoodImages = .FontAwesome
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
private var textColor: Color { theme.currentTheme.labelColor }
@StateObject private var viewModel = InsightsViewModel() @StateObject private var viewModel = InsightsViewModel()
@EnvironmentObject var iapManager: IAPManager @EnvironmentObject var iapManager: IAPManager
@State private var showSubscriptionStore = false @State private var showSubscriptionStore = false

View File

@@ -11,7 +11,8 @@ struct MainTabView: View {
@AppStorage(UserDefaultsStore.Keys.needsOnboarding.rawValue, store: GroupUserDefaults.groupDefaults) private var needsOnboarding = true @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.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.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 let onboardingData = OnboardingDataDataManager.shared.savedOnboardingData

View File

@@ -11,7 +11,8 @@ struct MonthDetailView: View {
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @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.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.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() @StateObject private var shareImage = ShareImageStateViewModel()

View File

@@ -12,17 +12,15 @@ struct MonthView: View {
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @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.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.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 @AppStorage(UserDefaultsStore.Keys.shape.rawValue, store: GroupUserDefaults.groupDefaults) private var shape: BGShape = .circle
@StateObject private var shareImage = ShareImageStateViewModel() @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 @EnvironmentObject var iapManager: IAPManager
@StateObject private var selectedDetail = DetailViewStateViewModel() @StateObject private var selectedDetail = DetailViewStateViewModel()
@State private var showingSheet = false @State private var showingSheet = false
@@ -43,8 +41,11 @@ struct MonthView: View {
@State private var trialWarningHidden = false @State private var trialWarningHidden = false
@State private var showSubscriptionStore = 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 /// 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 { guard iapManager.shouldShowPaywall else {
return viewModel.grouped return viewModel.grouped
} }
@@ -61,6 +62,13 @@ struct MonthView: View {
return filtered 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 { var body: some View {
ZStack { ZStack {
if viewModel.hasNoData { if viewModel.hasNoData {
@@ -69,23 +77,22 @@ struct MonthView: View {
} else { } else {
ScrollView { ScrollView {
VStack(spacing: 16) { 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 // for each month
ForEach(months.sorted(by: { $0.key > $1.key }), id: \.key) { month, entries in ForEach(yearData.months, id: \.month) { monthData in
MonthCard( MonthCard(
month: month, month: monthData.month,
year: year, year: yearData.year,
entries: entries, entries: monthData.entries,
moodTint: moodTint, moodTint: moodTint,
imagePack: imagePack, imagePack: imagePack,
textColor: textColor,
theme: theme, theme: theme,
filteredDays: filteredDays.currentFilters, filteredDays: filteredDays.currentFilters,
onTap: { onTap: {
let detailView = MonthDetailView( let detailView = MonthDetailView(
monthInt: month, monthInt: monthData.month,
yearInt: year, yearInt: yearData.year,
entries: entries, entries: monthData.entries,
parentViewModel: viewModel parentViewModel: viewModel
) )
selectedDetail.selectedItem = detailView selectedDetail.selectedItem = detailView
@@ -101,6 +108,7 @@ struct MonthView: View {
} }
.padding(.horizontal) .padding(.horizontal)
.padding(.bottom, 100) .padding(.bottom, 100)
.id(moodTint) // Force complete refresh when mood tint changes
.background( .background(
GeometryReader { proxy in GeometryReader { proxy in
let offset = proxy.frame(in: .named("scroll")).minY 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 { if iapManager.shouldShowPaywall {
// Premium month history prompt - bottom half // Premium month history prompt - bottom half
VStack(spacing: 20) { VStack(spacing: 20) {
@@ -160,12 +164,12 @@ struct MonthView: View {
VStack(spacing: 10) { VStack(spacing: 10) {
Text("Explore Your Mood History") Text("Explore Your Mood History")
.font(.title3.weight(.bold)) .font(.title3.weight(.bold))
.foregroundColor(textColor) .foregroundColor(labelColor)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
Text("See your complete monthly journey. Track patterns and understand what shapes your days.") Text("See your complete monthly journey. Track patterns and understand what shapes your days.")
.font(.subheadline) .font(.subheadline)
.foregroundColor(textColor.opacity(0.7)) .foregroundColor(labelColor.opacity(0.7))
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.horizontal, 24) .padding(.horizontal, 24)
} }
@@ -231,6 +235,18 @@ struct MonthView: View {
trialWarningHidden = value < 0 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 // MARK: - Month Card Component
struct MonthCard: View { struct MonthCard: View, Equatable {
let month: Int let month: Int
let year: Int let year: Int
let entries: [MoodEntryModel] let entries: [MoodEntryModel]
let moodTint: MoodTints let moodTint: MoodTints
let imagePack: MoodImages let imagePack: MoodImages
let textColor: Color
let theme: Theme let theme: Theme
let filteredDays: [Int] let filteredDays: [Int]
let onTap: () -> Void let onTap: () -> Void
let onShare: (UIImage) -> 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 showStats = true
@State private var cachedMetrics: [MoodMetrics] = []
private let weekdayLabels = ["S", "M", "T", "W", "T", "F", "S"] private let weekdayLabels = ["S", "M", "T", "W", "T", "F", "S"]
private let heatmapColumns = Array(repeating: GridItem(.flexible(), spacing: 2), count: 7) private let heatmapColumns = Array(repeating: GridItem(.flexible(), spacing: 2), count: 7)
private var metrics: [MoodMetrics] { // Cached filtered/sorted metrics to avoid recalculating in ForEach
let (startDate, endDate) = Date.dateRange(monthInt: month, yearInt: year) private var displayMetrics: [MoodMetrics] {
let monthEntries = DataController.shared.getData(startDate: startDate, endDate: endDate, includedDays: [1,2,3,4,5,6,7]) cachedMetrics.filter { $0.total > 0 }.sorted { $0.mood.rawValue > $1.mood.rawValue }
return Random.createTotalPerc(fromEntries: monthEntries)
} }
private var topMood: Mood? { 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 { private var totalTrackedDays: Int {
@@ -277,13 +305,13 @@ struct MonthCard: View {
// Header with month/year // Header with month/year
Text("\(Random.monthName(fromMonthInt: month).uppercased()) \(String(year))") Text("\(Random.monthName(fromMonthInt: month).uppercased()) \(String(year))")
.font(.title.weight(.heavy)) .font(.title.weight(.heavy))
.foregroundColor(textColor) .foregroundColor(labelColor)
.padding(.top, 40) .padding(.top, 40)
.padding(.bottom, 8) .padding(.bottom, 8)
Text("Monthly Mood Wrap") Text("Monthly Mood Wrap")
.font(.body.weight(.medium)) .font(.body.weight(.medium))
.foregroundColor(textColor.opacity(0.6)) .foregroundColor(labelColor.opacity(0.6))
.padding(.bottom, 30) .padding(.bottom, 30)
// Top mood highlight // Top mood highlight
@@ -304,7 +332,7 @@ struct MonthCard: View {
Text("Top Mood") Text("Top Mood")
.font(.subheadline.weight(.medium)) .font(.subheadline.weight(.medium))
.foregroundColor(textColor.opacity(0.5)) .foregroundColor(labelColor.opacity(0.5))
Text(topMood.strValue.uppercased()) Text(topMood.strValue.uppercased())
.font(.title3.weight(.bold)) .font(.title3.weight(.bold))
@@ -318,10 +346,10 @@ struct MonthCard: View {
VStack(spacing: 4) { VStack(spacing: 4) {
Text("\(totalTrackedDays)") Text("\(totalTrackedDays)")
.font(.largeTitle.weight(.bold)) .font(.largeTitle.weight(.bold))
.foregroundColor(textColor) .foregroundColor(labelColor)
Text("Days Tracked") Text("Days Tracked")
.font(.caption.weight(.medium)) .font(.caption.weight(.medium))
.foregroundColor(textColor.opacity(0.5)) .foregroundColor(labelColor.opacity(0.5))
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
@@ -329,7 +357,7 @@ struct MonthCard: View {
// Mood breakdown with bars // Mood breakdown with bars
VStack(spacing: 12) { 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) { HStack(spacing: 12) {
Circle() Circle()
.fill(moodTint.color(forMood: metric.mood)) .fill(moodTint.color(forMood: metric.mood))
@@ -357,7 +385,7 @@ struct MonthCard: View {
Text("\(Int(metric.percent))%") Text("\(Int(metric.percent))%")
.font(.subheadline.weight(.semibold)) .font(.subheadline.weight(.semibold))
.foregroundColor(textColor) .foregroundColor(labelColor)
.frame(width: 40, alignment: .trailing) .frame(width: 40, alignment: .trailing)
} }
} }
@@ -368,7 +396,7 @@ struct MonthCard: View {
// App branding // App branding
Text("ifeel") Text("ifeel")
.font(.subheadline.weight(.medium)) .font(.subheadline.weight(.medium))
.foregroundColor(textColor.opacity(0.3)) .foregroundColor(labelColor.opacity(0.3))
.padding(.bottom, 20) .padding(.bottom, 20)
} }
.frame(width: 400) .frame(width: 400)
@@ -389,11 +417,11 @@ struct MonthCard: View {
HStack { HStack {
Text("\(Random.monthName(fromMonthInt: month)) \(String(year))") Text("\(Random.monthName(fromMonthInt: month)) \(String(year))")
.font(.title3.bold()) .font(.title3.bold())
.foregroundColor(textColor) .foregroundColor(labelColor)
Image(systemName: showStats ? "chevron.up" : "chevron.down") Image(systemName: showStats ? "chevron.up" : "chevron.down")
.font(.caption.weight(.semibold)) .font(.caption.weight(.semibold))
.foregroundColor(textColor.opacity(0.5)) .foregroundColor(labelColor.opacity(0.5))
} }
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -406,7 +434,7 @@ struct MonthCard: View {
}) { }) {
Image(systemName: "square.and.arrow.up") Image(systemName: "square.and.arrow.up")
.font(.subheadline.weight(.medium)) .font(.subheadline.weight(.medium))
.foregroundColor(textColor.opacity(0.6)) .foregroundColor(labelColor.opacity(0.6))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
@@ -418,7 +446,7 @@ struct MonthCard: View {
ForEach(weekdayLabels.indices, id: \.self) { index in ForEach(weekdayLabels.indices, id: \.self) { index in
Text(weekdayLabels[index]) Text(weekdayLabels[index])
.font(.caption2.weight(.medium)) .font(.caption2.weight(.medium))
.foregroundColor(textColor.opacity(0.5)) .foregroundColor(labelColor.opacity(0.5))
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
} }
@@ -443,7 +471,7 @@ struct MonthCard: View {
Divider() Divider()
.padding(.horizontal, 16) .padding(.horizontal, 16)
MoodBarChart(metrics: metrics, moodTint: moodTint, imagePack: imagePack) MoodBarChart(metrics: cachedMetrics, moodTint: moodTint, imagePack: imagePack)
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 12) .padding(.vertical, 12)
.transition(.opacity.combined(with: .move(edge: .top))) .transition(.opacity.combined(with: .move(edge: .top)))
@@ -457,6 +485,12 @@ struct MonthCard: View {
.onTapGesture { .onTapGesture {
onTap() 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 { private var formattedDate: String {
let formatter = DateFormatter() DateFormattingCache.shared.string(for: entry.forDate, format: .dateMedium)
formatter.dateStyle = .medium
return formatter.string(from: entry.forDate)
} }
private var cellColor: Color { private var cellColor: Color {

View File

@@ -11,7 +11,9 @@ import PhotosUI
struct NoteEditorView: View { struct NoteEditorView: View {
@Environment(\.dismiss) private var dismiss @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 let entry: MoodEntryModel
@State private var noteText: String @State private var noteText: String
@@ -137,9 +139,11 @@ struct EntryDetailView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@AppStorage(UserDefaultsStore.Keys.moodImages.rawValue, store: GroupUserDefaults.groupDefaults) private var imagePack: MoodImages = .FontAwesome @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.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 @AppStorage(UserDefaultsStore.Keys.deleteEnable.rawValue, store: GroupUserDefaults.groupDefaults) private var deleteEnabled = true
private var textColor: Color { theme.currentTheme.labelColor }
let entry: MoodEntryModel let entry: MoodEntryModel
let onMoodUpdate: (Mood) -> Void let onMoodUpdate: (Mood) -> Void
let onDelete: () -> Void let onDelete: () -> Void

View File

@@ -10,7 +10,8 @@ import StoreKit
struct PurchaseButtonView: View { struct PurchaseButtonView: View {
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @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 @ObservedObject var iapManager: IAPManager

View File

@@ -10,7 +10,6 @@ import SwiftUI
struct SampleEntryView: View { struct SampleEntryView: View {
@State private var sampleListEntry = DataController.shared.generateObjectNotInArray(forDate: Date(), withMood: Mood.great) @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.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 { var body: some View {
ZStack { ZStack {

View File

@@ -14,7 +14,8 @@ struct DebugAnimationSettingsView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @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 selectedAnimation: CelebrationAnimationType = .vortexCheckmark
@State private var isAnimating = false @State private var isAnimating = false

View File

@@ -22,7 +22,8 @@ struct SettingsTabView: View {
@EnvironmentObject var iapManager: IAPManager @EnvironmentObject var iapManager: IAPManager
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @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 { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -85,7 +86,9 @@ struct UpgradeBannerView: View {
let trialExpirationDate: Date? let trialExpirationDate: Date?
@Environment(\.colorScheme) private var colorScheme @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 { var body: some View {
VStack(spacing: 12) { VStack(spacing: 12) {

View File

@@ -26,7 +26,8 @@ struct SettingsContentView: View {
@AppStorage(UserDefaultsStore.Keys.deleteEnable.rawValue, store: GroupUserDefaults.groupDefaults) private var deleteEnabled = true @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.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 { var body: some View {
ScrollView { ScrollView {
@@ -48,6 +49,8 @@ struct SettingsContentView: View {
eulaButton eulaButton
privacyButton privacyButton
addTestDataButton
#if DEBUG #if DEBUG
// Debug section // Debug section
debugSectionHeader debugSectionHeader
@@ -55,7 +58,7 @@ struct SettingsContentView: View {
animationLabButton animationLabButton
paywallPreviewButton paywallPreviewButton
tipsPreviewButton tipsPreviewButton
addTestDataButton
clearDataButton clearDataButton
#endif #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 { private var clearDataButton: some View {
ZStack { ZStack {
theme.currentTheme.secondaryBGColor theme.currentTheme.secondaryBGColor
@@ -494,6 +467,36 @@ struct SettingsContentView: View {
@ObservedObject private var healthKitManager = HealthKitManager.shared @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 { private var healthKitToggle: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
HStack(spacing: 12) { 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.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.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.firstLaunchDate.rawValue, store: GroupUserDefaults.groupDefaults) private var firstLaunchDate = Date()
@AppStorage(UserDefaultsStore.Keys.privacyLockEnabled.rawValue, store: GroupUserDefaults.groupDefaults) private var privacyLockEnabled = false @AppStorage(UserDefaultsStore.Keys.privacyLockEnabled.rawValue, store: GroupUserDefaults.groupDefaults) private var privacyLockEnabled = false
private var textColor: Color { theme.currentTheme.labelColor }
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack { VStack {

View File

@@ -24,7 +24,8 @@ struct WrappedSharable: Hashable, Equatable {
struct SharingListView: View { struct SharingListView: View {
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @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 { class ShareStateViewModel: ObservableObject {
@Published var selectedItem: WrappedSharable? = nil @Published var selectedItem: WrappedSharable? = nil

View File

@@ -20,7 +20,8 @@ struct AllMoodsTotalTemplate: View, SharingTemplate {
@Environment(\.presentationMode) var presentationMode @Environment(\.presentationMode) var presentationMode
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default @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.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() @StateObject private var shareImage = ShareImageStateViewModel()
private var entries = [MoodMetrics]() private var entries = [MoodMetrics]()

View File

@@ -23,7 +23,8 @@ struct CurrentStreakTemplate: View, SharingTemplate {
@Environment(\.presentationMode) var presentationMode @Environment(\.presentationMode) var presentationMode
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default @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.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 = [ let columns = [
GridItem(.flexible(minimum: 5, maximum: .infinity), alignment: .center), GridItem(.flexible(minimum: 5, maximum: .infinity), alignment: .center),

View File

@@ -32,7 +32,8 @@ struct LongestStreakTemplate: View, SharingTemplate {
@Environment(\.presentationMode) var presentationMode @Environment(\.presentationMode) var presentationMode
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default @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.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 = [ let columns = [
GridItem(.flexible(minimum: 5, maximum: .infinity), alignment: .center), GridItem(.flexible(minimum: 5, maximum: .infinity), alignment: .center),

View File

@@ -25,7 +25,8 @@ struct MonthTotalTemplate: View, SharingTemplate {
@Environment(\.presentationMode) var presentationMode @Environment(\.presentationMode) var presentationMode
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default @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.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 moodMetrics = [MoodMetrics]()
private var moodEntries = [MoodEntryModel]() private var moodEntries = [MoodEntryModel]()

View File

@@ -10,10 +10,10 @@ import SwiftUI
struct SmallRollUpHeaderView: View { struct SmallRollUpHeaderView: View {
@Binding var viewType: MainSwitchableViewType @Binding var viewType: MainSwitchableViewType
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default @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.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] let entries: [MoodEntryModel]
private var moodMetrics = [MoodMetrics]() private var moodMetrics = [MoodMetrics]()

View File

@@ -29,11 +29,9 @@ struct SwitchableView: View {
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @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.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = .white
private var textColor: Color { theme.currentTheme.labelColor }
// 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
init(daysBack: Int, viewType: Binding<MainSwitchableViewType>, headerTypeChanged: @escaping ((MainSwitchableViewType) -> Void)) { init(daysBack: Int, viewType: Binding<MainSwitchableViewType>, headerTypeChanged: @escaping ((MainSwitchableViewType) -> Void)) {
self.daysBack = daysBack self.daysBack = daysBack
self.headerTypeChanged = headerTypeChanged self.headerTypeChanged = headerTypeChanged
@@ -66,9 +64,6 @@ struct SwitchableView: View {
var body: some View { var body: some View {
VStack { VStack {
ZStack { ZStack {
Text(String(customMoodTintUpdateNumber))
.hidden()
mainViews mainViews
.padding([.top, .bottom]) .padding([.top, .bottom])

View File

@@ -15,8 +15,10 @@ struct TipModalView: View {
let onDismiss: () -> Void let onDismiss: () -> Void
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults)
private var textColor: Color = DefaultTextColor.textColor private var theme: Theme = .system
private var textColor: Color { theme.currentTheme.labelColor }
@State private var appeared = false @State private var appeared = false

View File

@@ -6,20 +6,15 @@
// //
import SwiftUI import SwiftUI
import SwiftData
struct YearView: View { 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")] 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 @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.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.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.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 @EnvironmentObject var iapManager: IAPManager
@StateObject public var viewModel: YearViewModel @StateObject public var viewModel: YearViewModel
@@ -28,6 +23,9 @@ struct YearView: View {
@State private var trialWarningHidden = false @State private var trialWarningHidden = false
@State private var showSubscriptionStore = 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 // Heatmap-style grid: 12 columns for months
private let heatmapColumns = Array(repeating: GridItem(.flexible(), spacing: 2), count: 12) private let heatmapColumns = Array(repeating: GridItem(.flexible(), spacing: 2), count: 12)
@@ -39,13 +37,13 @@ struct YearView: View {
} else { } else {
ScrollView { ScrollView {
VStack(spacing: 16) { VStack(spacing: 16) {
ForEach(Array(self.viewModel.data.keys.sorted(by: >)), id: \.self) { yearKey in ForEach(cachedSortedYearKeys, id: \.self) { yearKey in
YearCard( YearCard(
year: yearKey, year: yearKey,
yearData: self.viewModel.data[yearKey]!, yearData: self.viewModel.data[yearKey]!,
yearEntries: self.viewModel.entriesByYear[yearKey] ?? [],
moodTint: moodTint, moodTint: moodTint,
imagePack: imagePack, imagePack: imagePack,
textColor: textColor,
theme: theme, theme: theme,
filteredDays: filteredDays.currentFilters, filteredDays: filteredDays.currentFilters,
onShare: { image in onShare: { image in
@@ -57,6 +55,7 @@ struct YearView: View {
} }
.padding(.horizontal) .padding(.horizontal)
.padding(.bottom, 100) .padding(.bottom, 100)
.id(moodTint) // Force complete refresh when mood tint changes
.background( .background(
GeometryReader { proxy in GeometryReader { proxy in
let offset = proxy.frame(in: .named("scroll")).minY let offset = proxy.frame(in: .named("scroll")).minY
@@ -112,12 +111,12 @@ struct YearView: View {
VStack(spacing: 10) { VStack(spacing: 10) {
Text("See Your Year at a Glance") Text("See Your Year at a Glance")
.font(.title3.weight(.bold)) .font(.title3.weight(.bold))
.foregroundColor(textColor) .foregroundColor(theme.currentTheme.labelColor)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
Text("Discover your emotional rhythm across the year. Spot trends and celebrate how far you've come.") Text("Discover your emotional rhythm across the year. Spot trends and celebrate how far you've come.")
.font(.subheadline) .font(.subheadline)
.foregroundColor(textColor.opacity(0.7)) .foregroundColor(theme.currentTheme.labelColor.opacity(0.7))
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.horizontal, 24) .padding(.horizontal, 24)
} }
@@ -168,7 +167,16 @@ struct YearView: View {
} }
.onAppear(perform: { .onAppear(perform: {
self.viewModel.filterEntries(startDate: Date(timeIntervalSince1970: 0), endDate: Date()) 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( .background(
theme.currentTheme.bg theme.currentTheme.bg
.edgesIgnoringSafeArea(.all) .edgesIgnoringSafeArea(.all)
@@ -179,33 +187,42 @@ struct YearView: View {
} }
} }
.padding([.top]) .padding([.top])
.preferredColorScheme(theme.preferredColorScheme)
} }
} }
// MARK: - Year Card Component // MARK: - Year Card Component
struct YearCard: View { struct YearCard: View, Equatable {
let year: Int let year: Int
let yearData: [Int: [DayChartView]] let yearData: [Int: [DayChartView]]
let yearEntries: [MoodEntryModel]
let moodTint: MoodTints let moodTint: MoodTints
let imagePack: MoodImages let imagePack: MoodImages
let textColor: Color
let theme: Theme let theme: Theme
let filteredDays: [Int] let filteredDays: [Int]
let onShare: (UIImage) -> Void 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 showStats = true
@State private var cachedMetrics: [MoodMetrics] = []
private let months = ["J", "F", "M", "A", "M", "J", "J", "A", "S", "O", "N", "D"] 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 let heatmapColumns = Array(repeating: GridItem(.flexible(), spacing: 2), count: 12)
private var yearEntries: [MoodEntryModel] { // Cached filtered/sorted metrics to avoid recalculating in ForEach
let firstOfYear = Calendar.current.date(from: DateComponents(year: year, month: 1, day: 1))! private var displayMetrics: [MoodMetrics] {
let lastOfYear = Calendar.current.date(from: DateComponents(year: year + 1, month: 1, day: 1))! cachedMetrics.filter { $0.total > 0 }.sorted { $0.mood.rawValue > $1.mood.rawValue }
return DataController.shared.getData(startDate: firstOfYear, endDate: lastOfYear, includedDays: filteredDays)
}
private var metrics: [MoodMetrics] {
return Random.createTotalPerc(fromEntries: yearEntries)
} }
private var totalEntries: Int { private var totalEntries: Int {
@@ -213,7 +230,7 @@ struct YearCard: View {
} }
private var topMood: Mood? { 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 { private var shareableView: some View {
@@ -273,7 +290,7 @@ struct YearCard: View {
// Mood breakdown with bars // Mood breakdown with bars
VStack(spacing: 14) { 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) { HStack(spacing: 14) {
Circle() Circle()
.fill(moodTint.color(forMood: metric.mood)) .fill(moodTint.color(forMood: metric.mood))
@@ -366,12 +383,12 @@ struct YearCard: View {
if showStats { if showStats {
HStack(spacing: 16) { HStack(spacing: 16) {
// Donut Chart // Donut Chart
MoodDonutChart(metrics: metrics, moodTint: moodTint) MoodDonutChart(metrics: cachedMetrics, moodTint: moodTint)
.frame(width: 100, height: 100) .frame(width: 100, height: 100)
// Bar Chart // Bar Chart
VStack(spacing: 6) { VStack(spacing: 6) {
ForEach(metrics.filter { $0.total > 0 }) { metric in ForEach(displayMetrics) { metric in
HStack(spacing: 8) { HStack(spacing: 8) {
imagePack.icon(forMood: metric.mood) imagePack.icon(forMood: metric.mood)
.resizable() .resizable()
@@ -434,6 +451,12 @@ struct YearCard: View {
RoundedRectangle(cornerRadius: 16) RoundedRectangle(cornerRadius: 16)
.fill(theme.currentTheme.secondaryBGColor) .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) 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 { var body: some View {
LazyVGrid(columns: heatmapColumns, spacing: 2) { 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] { if let monthData = yearData[monthKey] {
MonthColumn( MonthColumn(
monthData: monthData, monthData: monthData,

View File

@@ -16,6 +16,8 @@ class YearViewModel: ObservableObject {
// year, month, items // year, month, items
@Published public private(set) var data = [Int: [Int: [DayChartView]]]() @Published public private(set) var data = [Int: [Int: [DayChartView]]]()
@Published public private(set) var numberOfRatings: Int = 0 @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]() { public private(set) var uncategorizedData = [MoodEntryModel]() {
didSet { didSet {
self.numberOfRatings = uncategorizedData.count self.numberOfRatings = uncategorizedData.count
@@ -44,8 +46,16 @@ class YearViewModel: ObservableObject {
endDate: endDate, endDate: endDate,
includedDays: selectedDays) includedDays: selectedDays)
data.removeAll() data.removeAll()
entriesByYear.removeAll()
let filledOutData = chartViewBuilder.buildGridData(withData: filteredEntries) let filledOutData = chartViewBuilder.buildGridData(withData: filteredEntries)
data = filledOutData data = filledOutData
uncategorizedData = filteredEntries 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)
}
} }
} }