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

View File

@@ -893,7 +893,7 @@
}
},
"Add Test Data" : {
"comment" : "A button label that adds test data to the app.",
"comment" : "A button label that, when tapped, populates the app with sample mood entries for testing purposes.",
"isCommentAutoGenerated" : true
},
"Add the Mood Vote widget to quickly log your mood without opening the app." : {
@@ -1837,7 +1837,7 @@
"isCommentAutoGenerated" : true
},
"Clear All Data" : {
"comment" : "A button label that clears all data from the app.",
"comment" : "A button label that clears all user data.",
"isCommentAutoGenerated" : true
},
"Clear DB" : {
@@ -3032,11 +3032,11 @@
}
},
"Current Parameters" : {
"comment" : "A section header that lists various current settings and statistics of the app.",
"comment" : "A section header that lists various current parameters related to the app.",
"isCommentAutoGenerated" : true
},
"Current Streak" : {
"comment" : "A label for the current streak of using the feature.",
"comment" : "A label describing the user's current streak of using the app.",
"isCommentAutoGenerated" : true
},
"Current: %@" : {
@@ -3083,6 +3083,7 @@
},
"Custom" : {
"comment" : "The text that appears as a label for the custom color option in the tint picker.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true,
"localizations" : {
"de" : {
@@ -4225,7 +4226,7 @@
}
},
"Delete all mood entries" : {
"comment" : "A description of what the \"Clear All Data\" button does.",
"comment" : "A description of what happens when the \"Clear All Data\" button is tapped.",
"isCommentAutoGenerated" : true
},
"Delete Entry" : {
@@ -4811,8 +4812,7 @@
}
},
"Experiment with vote celebrations" : {
"comment" : "A description of a feature in the Animation Lab.",
"isCommentAutoGenerated" : true
},
"Explore Your Mood History" : {
"comment" : "A title for a feature that allows users to explore their mood history.",
@@ -5699,7 +5699,7 @@
"isCommentAutoGenerated" : true
},
"Has Seen Settings" : {
"comment" : "A label for whether the user has seen the settings screen.",
"comment" : "A label for whether the user has seen the settings section in the app.",
"isCommentAutoGenerated" : true
},
"How are you feeling?" : {
@@ -6789,7 +6789,7 @@
}
},
"Mood Log Count" : {
"comment" : "A label describing the count of mood logs.",
"comment" : "The title of a label displaying the count of mood logs.",
"isCommentAutoGenerated" : true
},
"Mood Logged" : {
@@ -9195,7 +9195,7 @@
},
"Preview subscription themes" : {
"comment" : "A description of the paywall preview feature.",
"comment" : "A description of what the paywall preview button does.",
"isCommentAutoGenerated" : true
},
"Privacy Lock" : {
@@ -10850,6 +10850,7 @@
},
"Sample Text" : {
"comment" : "A sample text used to demonstrate how the text color is applied in the UI.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true,
"localizations" : {
"de" : {
@@ -11726,7 +11727,7 @@
}
},
"Shown This Session" : {
"comment" : "A label showing whether the tip has been shown during the current session.",
"comment" : "A label displaying whether they have seen a tip during the current session.",
"isCommentAutoGenerated" : true
},
"SIDE A" : {
@@ -12589,7 +12590,7 @@
"isCommentAutoGenerated" : true
},
"Tap to preview" : {
"comment" : "A text label displayed above a list of tips, instructing the user to tap on them to view more information.",
"comment" : "A text label displayed above a list of tips, instructing the user to tap on an item to view more details.",
"isCommentAutoGenerated" : true
},
"Tap to record your mood for this day" : {

View File

@@ -12,7 +12,7 @@ import SwiftUI
class AppDelegate: NSObject, UIApplicationDelegate {
private let savedOnboardingData = UserDefaultsStore.getOnboarding()
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@MainActor
func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
@@ -20,7 +20,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
DataController.shared.fillInMissingDates()
UNUserNotificationCenter.current().delegate = self
UIPageControl.appearance().currentPageIndicatorTintColor = UIColor(textColor)
UIPageControl.appearance().currentPageIndicatorTintColor = UIColor(theme.currentTheme.labelColor)
UIPageControl.appearance().pageIndicatorTintColor = UIColor.systemGray
let appearance = UITabBarAppearance()
@@ -33,15 +33,6 @@ class AppDelegate: NSObject, UIApplicationDelegate {
return true
}
@MainActor
func applicationWillEnterForeground(_ application: UIApplication) {
DataController.shared.fillInMissingDates()
// reschedule notifications so there's a new title next notification
LocalNotification.rescheduleNotifiations()
EventLogger.log(event: "app_foregorund")
}
}
extension AppDelegate: @preconcurrency UNUserNotificationCenterDelegate {

View File

@@ -76,21 +76,46 @@ struct FeelsApp: App {
if newPhase == .active {
UNUserNotificationCenter.current().setBadgeCount(0)
// Check subscription status on each app launch
Task {
await iapManager.checkSubscriptionStatus()
}
// Authenticate if locked
// Authenticate if locked - this must happen immediately on main thread
if authManager.isLockEnabled && !authManager.isUnlocked {
Task {
await authManager.authenticate()
}
}
// Reschedule Live Activity when app becomes active
LiveActivityScheduler.shared.scheduleBasedOnCurrentTime()
// Defer all non-critical foreground work to avoid blocking UI
Task.detached(priority: .utility) { @MainActor in
// Refresh from disk to pick up widget/watch changes
DataController.shared.refreshFromDisk()
// Clean up any duplicate entries first
DataController.shared.removeDuplicates()
// Fill in any missing dates (moved from AppDelegate)
DataController.shared.fillInMissingDates()
// Reschedule notifications for new title
LocalNotification.rescheduleNotifiations()
// Log event
EventLogger.log(event: "app_foregorund")
}
// Defer Live Activity scheduling (heavy DB operations)
Task.detached(priority: .utility) {
await LiveActivityScheduler.shared.scheduleBasedOnCurrentTime()
}
// Catch up on side effects from widget/watch votes
MoodLogger.shared.processPendingSideEffects()
Task.detached(priority: .utility) {
await MoodLogger.shared.processPendingSideEffects()
}
// Check subscription status (network call) - throttled
Task.detached(priority: .background) {
await iapManager.checkSubscriptionStatus()
}
}
}
}

View File

@@ -75,6 +75,12 @@ class IAPManager: ObservableObject {
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
var isSubscribed: Bool {
@@ -138,9 +144,21 @@ class IAPManager: ObservableObject {
// MARK: - Public Methods
/// Check subscription status - call on app launch and when becoming active
/// Throttled to avoid excessive StoreKit calls on rapid foreground transitions
func checkSubscriptionStatus() async {
isLoading = true
defer { isLoading = false }
// Throttle: skip if we checked recently (unless state is unknown)
if state != .unknown,
let lastCheck = lastStatusCheckTime,
Date().timeIntervalSince(lastCheck) < statusCheckInterval {
return
}
// Only update isLoading if value actually changes to avoid unnecessary view updates
if !isLoading { isLoading = true }
defer {
if isLoading { isLoading = false }
lastStatusCheckTime = Date()
}
// Fetch available products
await loadProducts()

View File

@@ -7,12 +7,6 @@
import SwiftUI
struct DefaultTextColor {
static var textColor: Color {
Color(UIColor.label)
}
}
protocol MoodTintable {
static func color(forMood mood: Mood) -> Color
static func secondary(forMood mood: Mood) -> Color
@@ -232,37 +226,44 @@ final class AllRedMoodTint: MoodTintable {
}
final class NeonMoodTint: MoodTintable {
// Synthwave color palette matching the Neon voting style
private static let neonCyan = Color(red: 0.0, green: 1.0, blue: 0.82)
private static let neonLime = Color(red: 0.2, green: 1.0, blue: 0.6)
private static let neonYellow = Color(red: 1.0, green: 0.9, blue: 0.0)
private static let neonOrange = Color(red: 1.0, green: 0.5, blue: 0.0)
private static let neonMagenta = Color(red: 1.0, green: 0.0, blue: 0.8)
static func color(forMood mood: Mood) -> Color {
switch mood {
case .horrible:
return Color(hex: "#ff1818")
return neonMagenta
case .bad:
return Color(hex: "#FF5F1F")
return neonOrange
case .average:
return Color(hex: "#1F51FF")
return neonYellow
case .good:
return Color(hex: "#FFF01F")
return neonLime
case .great:
return Color(hex: "#39FF14")
return neonCyan
case .missing:
return Color(uiColor: UIColor.systemGray2)
case .placeholder:
return Color(uiColor: UIColor.systemGray2)
}
}
static func secondary(forMood mood: Mood) -> Color {
switch mood {
case .horrible:
return Color(hex: "#8b1113")
return neonMagenta.opacity(0.6)
case .bad:
return Color(hex: "#893315")
return neonOrange.opacity(0.6)
case .average:
return Color(hex: "#0f2a85")
return neonYellow.opacity(0.6)
case .good:
return Color(hex: "#807a18")
return neonLime.opacity(0.6)
case .great:
return Color(hex: "#218116")
return neonCyan.opacity(0.6)
case .missing:
return Color(uiColor: UIColor.label)
case .placeholder:

View File

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

View File

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

View File

@@ -147,25 +147,9 @@ final class MoodLogger {
return targetDay == lastDay
}
/// Calculate the current mood streak
/// Calculate the current mood streak using optimized batch query
private func calculateCurrentStreak() -> Int {
var streak = 0
var checkDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding())
while true {
let dayStart = Calendar.current.startOfDay(for: checkDate)
let dayEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: dayStart)!
let entry = DataController.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first
if let entry = entry, entry.mood != .missing && entry.mood != .placeholder {
streak += 1
checkDate = Calendar.current.date(byAdding: .day, value: -1, to: checkDate)!
} else {
break
}
}
return streak
let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding())
return DataController.shared.calculateStreak(from: votingDate).streak
}
}

View File

@@ -175,76 +175,80 @@ class LiveActivityScheduler: ObservableObject {
return calendar.date(byAdding: .hour, value: 5, to: ratingDateTime)
}
/// Check if user has rated today
func hasRatedToday() -> Bool {
/// Cached streak data to avoid redundant calculations
private var cachedStreakData: (streak: Int, todaysMood: Mood?, votingDate: Date)?
/// Get streak data using efficient batch query (cached per voting date)
private func getStreakData() -> (streak: Int, todaysMood: Mood?) {
let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding())
let dayStart = Calendar.current.startOfDay(for: votingDate)
let dayEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: dayStart)!
// Return cached data if still valid for current voting date
if let cached = cachedStreakData,
Calendar.current.isDate(cached.votingDate, inSameDayAs: votingDate) {
return (cached.streak, cached.todaysMood)
}
// Calculate and cache
#if WIDGET_EXTENSION
let entry = WidgetDataProvider.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first
#else
let entry = DataController.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first
#endif
return entry != nil && entry?.mood != .missing && entry?.mood != .placeholder
}
// Widget extension uses its own data provider
let calendar = Calendar.current
let dayStart = calendar.startOfDay(for: votingDate)
guard let yearAgo = calendar.date(byAdding: .day, value: -365, to: dayStart) else {
return (0, nil)
}
let entries = WidgetDataProvider.shared.getData(startDate: yearAgo, endDate: votingDate, includedDays: [])
.filter { $0.mood != .missing && $0.mood != .placeholder }
guard !entries.isEmpty else { return (0, nil) }
let datesWithEntries = Set(entries.map { calendar.startOfDay(for: $0.forDate) })
let todaysEntry = entries.first { calendar.isDate($0.forDate, inSameDayAs: votingDate) }
let todaysMood = todaysEntry?.mood
/// Calculate current streak
func calculateStreak() -> Int {
var streak = 0
var checkDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding())
// Check if current voting date has an entry
let currentDayStart = Calendar.current.startOfDay(for: checkDate)
let currentDayEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: currentDayStart)!
#if WIDGET_EXTENSION
let currentEntry = WidgetDataProvider.shared.getData(startDate: currentDayStart, endDate: currentDayEnd, includedDays: []).first
#else
let currentEntry = DataController.shared.getData(startDate: currentDayStart, endDate: currentDayEnd, includedDays: []).first
#endif
// If no entry for current voting date, start counting from previous day
// This ensures the streak shows correctly even if user hasn't rated today yet
if currentEntry == nil || currentEntry?.mood == .missing || currentEntry?.mood == .placeholder {
checkDate = Calendar.current.date(byAdding: .day, value: -1, to: checkDate)!
var checkDate = votingDate
if !datesWithEntries.contains(dayStart) {
checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate) ?? checkDate
}
while true {
let dayStart = Calendar.current.startOfDay(for: checkDate)
let dayEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: dayStart)!
#if WIDGET_EXTENSION
let entry = WidgetDataProvider.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first
#else
let entry = DataController.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first
#endif
if let entry = entry, entry.mood != .missing && entry.mood != .placeholder {
let checkDayStart = calendar.startOfDay(for: checkDate)
if datesWithEntries.contains(checkDayStart) {
streak += 1
checkDate = Calendar.current.date(byAdding: .day, value: -1, to: checkDate)!
checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate) ?? checkDate
} else {
break
}
}
return streak
cachedStreakData = (streak, todaysMood, votingDate)
return (streak, todaysMood)
#else
let result = DataController.shared.calculateStreak(from: votingDate)
cachedStreakData = (result.streak, result.todaysMood, votingDate)
return result
#endif
}
/// Check if user has rated today
func hasRatedToday() -> Bool {
return getStreakData().todaysMood != nil
}
/// Calculate current streak
func calculateStreak() -> Int {
return getStreakData().streak
}
/// Get today's mood if logged
func getTodaysMood() -> Mood? {
let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding())
let dayStart = Calendar.current.startOfDay(for: votingDate)
let dayEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: dayStart)!
return getStreakData().todaysMood
}
#if WIDGET_EXTENSION
let entry = WidgetDataProvider.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first
#else
let entry = DataController.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first
#endif
if let entry = entry, entry.mood != .missing && entry.mood != .placeholder {
return entry.mood
}
return nil
/// Invalidate cached streak data (call when mood is logged)
func invalidateCache() {
cachedStreakData = nil
}
/// Schedule Live Activity based on current time and rating time

View File

@@ -47,24 +47,6 @@ struct OnboardingCustomizeTwo: View {
.fixedSize(horizontal: false, vertical: true)
.foregroundColor(.white)
ImagePackPickerView()
Text(String(localized: "onboarding_title_customize_two_section_two_title"))
.font(.title3)
.padding()
.fixedSize(horizontal: false, vertical: true)
.foregroundColor(.white)
TintPickerView()
Text(String(localized: "onboarding_title_customize_two_section_three_title"))
.font(.title3)
.padding()
.fixedSize(horizontal: false, vertical: true)
.foregroundColor(.white)
TextColorPickerView()
}
}
}

View File

@@ -67,4 +67,21 @@ final class DataController: ObservableObject {
Self.logger.error("Failed to save context: \(error.localizedDescription)")
}
}
/// Refresh data from disk to pick up changes made by extensions (widget/watch).
/// Call this when app becomes active.
func refreshFromDisk() {
// SwiftData doesn't have a direct "refresh from disk" API.
// We achieve this by:
// 1. Rolling back any unsaved changes (ensures clean state)
// 2. Triggering listeners to re-fetch data (which will read from disk)
modelContext.rollback()
// Notify listeners to re-fetch their data
for closure in editedDataClosure {
closure()
}
Self.logger.debug("Refreshed data from disk")
}
}

View File

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

View File

@@ -38,4 +38,70 @@ extension DataController {
}
save()
}
/// Get ALL entries for a specific date (not just the first one)
func getAllEntries(byDate date: Date) -> [MoodEntryModel] {
let startDate = Calendar.current.startOfDay(for: date)
let endDate = Calendar.current.date(byAdding: .day, value: 1, to: startDate)!
let descriptor = FetchDescriptor<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)) ?? []
}
/// Calculate the current mood streak efficiently using a single batch query.
/// Returns a tuple of (streak count, last logged mood if today has entry).
func calculateStreak(from votingDate: Date) -> (streak: Int, todaysMood: Mood?) {
let calendar = Calendar.current
let dayStart = calendar.startOfDay(for: votingDate)
// Fetch last 365 days of entries in a single query
guard let yearAgo = calendar.date(byAdding: .day, value: -365, to: dayStart) else {
return (0, nil)
}
let entries = getData(startDate: yearAgo, endDate: votingDate, includedDays: [])
.filter { $0.mood != .missing && $0.mood != .placeholder }
guard !entries.isEmpty else { return (0, nil) }
// Build a Set of dates that have valid entries for O(1) lookup
let datesWithEntries = Set(entries.map { calendar.startOfDay(for: $0.forDate) })
// Check for today's entry
let todaysEntry = entries.first { calendar.isDate($0.forDate, inSameDayAs: votingDate) }
let todaysMood = todaysEntry?.mood
// Calculate streak walking backwards
var streak = 0
var checkDate = votingDate
// If no entry for current voting date, start counting from previous day
if !datesWithEntries.contains(dayStart) {
checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate) ?? checkDate
}
while true {
let checkDayStart = calendar.startOfDay(for: checkDate)
if datesWithEntries.contains(checkDayStart) {
streak += 1
checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate) ?? checkDate
} else {
break
}
}
return (streak, todaysMood)
}
func splitIntoYearMonth(includedDays: [Int]) -> [Int: [Int: [MoodEntryModel]]] {
// Single query to fetch all data - avoid N*12 queries
let data = getData(
startDate: Date(timeIntervalSince1970: 0),
endDate: Date(),
includedDays: includedDays
).sorted { $0.forDate < $1.forDate }
)
guard let earliest = data.first,
let latest = data.last else { return [:] }
guard !data.isEmpty else { return [:] }
let calendar = Calendar.current
let earliestYear = calendar.component(.year, from: earliest.forDate)
let latestYear = calendar.component(.year, from: latest.forDate)
// Group entries by year and month in memory (single pass)
var result = [Int: [Int: [MoodEntryModel]]]()
for year in earliestYear...latestYear {
var monthData = [Int: [MoodEntryModel]]()
for entry in data {
let year = calendar.component(.year, from: entry.forDate)
let month = calendar.component(.month, from: entry.forDate)
for month in 1...12 {
var components = DateComponents()
components.year = year
components.month = month
components.day = 1
guard let startOfMonth = calendar.date(from: components) else { continue }
let items = getData(
startDate: startOfMonth,
endDate: startOfMonth.endOfMonth,
includedDays: [1, 2, 3, 4, 5, 6, 7]
)
if !items.isEmpty {
monthData[month] = items
}
if result[year] == nil {
result[year] = [:]
}
result[year] = monthData
if result[year]![month] == nil {
result[year]![month] = []
}
result[year]![month]!.append(entry)
}
return result

View File

@@ -174,9 +174,12 @@ final class ExtensionDataProvider {
/// - date: The date for the entry
/// - entryType: The source of the entry (widget, watch, etc.)
func add(mood: Mood, forDate date: Date, entryType: EntryType) {
// Delete existing entry for this date if present
if let existing = getEntry(byDate: date) {
modelContext.delete(existing)
// Delete ALL existing entries for this date (handles duplicates)
let existing = getAllEntries(byDate: date)
for entry in existing {
modelContext.delete(entry)
}
if !existing.isEmpty {
try? modelContext.save()
}
@@ -199,6 +202,21 @@ final class ExtensionDataProvider {
}
}
/// Get ALL entries for a specific date (not just the first one)
func getAllEntries(byDate date: Date) -> [MoodEntryModel] {
let startDate = Calendar.current.startOfDay(for: date)
let endDate = Calendar.current.date(byAdding: .day, value: 1, to: startDate)!
let descriptor = FetchDescriptor<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)
func invalidateCache() {
_container = nil

View File

@@ -18,87 +18,92 @@ protocol ChartDataBuildable {
extension ChartDataBuildable {
public func buildGridData(withData data: [MoodEntryModel]) -> [Year: [Month: [ChartType]]] {
var returnData = [Int: [Int: [ChartType]]]()
if let earliestEntry = data.first,
let lastEntry = data.last {
let calendar = Calendar.current
let components = calendar.dateComponents([.year], from: earliestEntry.forDate)
let earliestYear = components.year!
let latestComponents = calendar.dateComponents([.year], from: lastEntry.forDate)
let latestYear = latestComponents.year!
for year in earliestYear...latestYear {
var allMonths = [Int: [ChartType]]()
// add back in if months header has leading (-1, ""),
// and add back gridItem
// var dayViews = [DayChartView]()
// for day in 0...32 {
// let view = DayChartView(color: Mood.missing.color,
// weekDay: 2,
// viewType: .text(String(day+1)))
// dayViews.append(view)
// }
// allMonths[0] = dayViews
for month in (1...12) {
var components = DateComponents()
components.month = month
components.year = year
let startDateOfMonth = Calendar.current.date(from: components)!
let items = data.filter({ entry in
let components = calendar.dateComponents([.month, .year], from: startDateOfMonth)
let entryComponents = calendar.dateComponents([.month, .year], from: entry.forDate)
return (components.month == entryComponents.month && components.year == entryComponents.year)
})
allMonths[month] = createViewFor(monthEntries: items, forMonth: startDateOfMonth)
}
returnData[year] = allMonths
}
}
return returnData
}
private func createViewFor(monthEntries: [MoodEntryModel], forMonth month: Date) -> [ChartType] {
var filledOutArray = [ChartType]()
guard let earliestEntry = data.first,
let lastEntry = data.last else { return [:] }
let calendar = Calendar.current
let range = calendar.range(of: .day, in: .month, for: month)!
// Pre-group entries by year/month/day in a single pass - O(n) instead of O(n*m)
// Key: "year-month-day" -> entry
var entriesByDate = [String: MoodEntryModel]()
for entry in data {
let components = calendar.dateComponents([.year, .month, .day], from: entry.forDate)
let key = "\(components.year!)-\(components.month!)-\(components.day!)"
entriesByDate[key] = entry
}
let earliestYear = calendar.component(.year, from: earliestEntry.forDate)
let latestYear = calendar.component(.year, from: lastEntry.forDate)
// Cache expensive lookups
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
let shape = UserDefaultsStore.getCustomBGShape()
var returnData = [Int: [Int: [ChartType]]]()
for year in earliestYear...latestYear {
var allMonths = [Int: [ChartType]]()
for month in 1...12 {
var components = DateComponents()
components.month = month
components.year = year
guard let startDateOfMonth = calendar.date(from: components) else { continue }
allMonths[month] = createViewFor(
year: year,
month: month,
forMonth: startDateOfMonth,
entriesByDate: entriesByDate,
moodTint: moodTint,
shape: shape,
calendar: calendar
)
}
returnData[year] = allMonths
}
return returnData
}
private func createViewFor(
year: Int,
month: Int,
forMonth monthDate: Date,
entriesByDate: [String: MoodEntryModel],
moodTint: MoodTintable.Type,
shape: BGShape,
calendar: Calendar
) -> [ChartType] {
var filledOutArray = [ChartType]()
let range = calendar.range(of: .day, in: .month, for: monthDate)!
let numDays = range.count
for day in 1...numDays {
if let item = monthEntries.filter({ entry in
let components = calendar.dateComponents([.day], from: entry.forDate)
let date = components.day
return day == date
}).first {
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
let key = "\(year)-\(month)-\(day)"
if let item = entriesByDate[key] {
// O(1) dictionary lookup instead of O(n) filter
let view = ChartType(color: moodTint.color(forMood: item.mood),
weekDay: Int(item.weekDay),
shape: UserDefaultsStore.getCustomBGShape())
shape: shape)
filledOutArray.append(view)
} else {
let thisDate = Calendar.current.date(bySetting: .day, value: day, of: month)!
let thisDate = calendar.date(bySetting: .day, value: day, of: monthDate)!
let view = ChartType(color: Mood.placeholder.color,
weekDay: Calendar.current.component(.weekday, from: thisDate),
shape: UserDefaultsStore.getCustomBGShape())
weekDay: calendar.component(.weekday, from: thisDate),
shape: shape)
filledOutArray.append(view)
}
}
for _ in filledOutArray.count...32 {
let view = ChartType(color: Mood.placeholder.color,
weekDay: 2,
shape: UserDefaultsStore.getCustomBGShape())
shape: shape)
filledOutArray.append(view)
}
return filledOutArray
}
}

View File

@@ -58,9 +58,11 @@ class Random {
return newValue
}
/// Cached month symbols to avoid creating DateFormatter repeatedly
private static let monthSymbols: [String] = DateFormatter().monthSymbols
static func monthName(fromMonthInt: Int) -> String {
let monthName = DateFormatter().monthSymbols[fromMonthInt-1]
return monthName
return monthSymbols[fromMonthInt-1]
}
static var existingDayFormat = [NSNumber: String]()
@@ -165,7 +167,15 @@ extension Color {
}
extension String {
/// Cache for rendered emoji images to avoid expensive re-rendering
private static var textToImageCache = [String: UIImage]()
func textToImage() -> UIImage? {
// Return cached image if available
if let cached = Self.textToImageCache[self] {
return cached
}
let nsString = (self as NSString)
let font = UIFont.systemFont(ofSize: 100) // you can change your font size here
let stringAttributes = [NSAttributedString.Key.font: font]
@@ -178,7 +188,12 @@ extension String {
let image = UIGraphicsGetImageFromCurrentImageContext() // create image from context
UIGraphicsEndImageContext() // end image context
return image ?? UIImage()
let result = image ?? UIImage()
// Cache the rendered image
Self.textToImageCache[self] = result
return result
}
}
@@ -218,6 +233,179 @@ extension UIColor {
}
#endif
// MARK: - Date Formatting Cache
/// High-performance date formatting cache to eliminate repeated ICU Calendar operations.
/// Caches formatted date strings keyed by (date's day start, format type).
final class DateFormattingCache {
static let shared = DateFormattingCache()
enum Format: Int {
case day // "15"
case weekdayWide // "Monday"
case weekdayAbbreviated // "Mon"
case weekdayWideDay // "Monday 15"
case monthWide // "January"
case monthAbbreviated // "Jan"
case monthAbbreviatedDay // "Jan 15"
case monthAbbreviatedYear // "Jan 2025"
case monthWideYear // "January 2025"
case weekdayAbbrevMonthAbbrev // "Mon Jan"
case weekdayWideMonthAbbrev // "Monday Jan"
case yearMonthDayDigits // "2025/01/15"
case dateMedium // "Jan 15, 2025" (dateStyle = .medium)
case dateFull // "Monday, January 15, 2025" (dateStyle = .full)
}
private var cache = [Int: [Format: String]]()
private let calendar = Calendar.current
// Reusable formatters (creating DateFormatter is expensive)
private lazy var dayFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "d"
return f
}()
private lazy var weekdayWideFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "EEEE"
return f
}()
private lazy var weekdayAbbrevFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "EEE"
return f
}()
private lazy var weekdayWideDayFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "EEEE d"
return f
}()
private lazy var monthWideFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "MMMM"
return f
}()
private lazy var monthAbbrevFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "MMM"
return f
}()
private lazy var monthAbbrevDayFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "MMM d"
return f
}()
private lazy var monthAbbrevYearFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "MMM yyyy"
return f
}()
private lazy var monthWideYearFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "MMMM yyyy"
return f
}()
private lazy var weekdayAbbrevMonthAbbrevFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "EEE MMM"
return f
}()
private lazy var weekdayWideMonthAbbrevFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "EEEE MMM"
return f
}()
private lazy var yearMonthDayDigitsFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy/MM/dd"
return f
}()
private lazy var dateMediumFormatter: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium
return f
}()
private lazy var dateFullFormatter: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .full
return f
}()
private init() {}
/// Get cached formatted string for a date
func string(for date: Date, format: Format) -> String {
let dayKey = dayIdentifier(for: date)
// Check cache
if let formatCache = cache[dayKey], let cached = formatCache[format] {
return cached
}
// Format and cache
let formatted = formatDate(date, format: format)
if cache[dayKey] == nil {
cache[dayKey] = [:]
}
cache[dayKey]?[format] = formatted
return formatted
}
private func dayIdentifier(for date: Date) -> Int {
// Use days since reference date as unique key
Int(calendar.startOfDay(for: date).timeIntervalSinceReferenceDate / 86400)
}
private func formatDate(_ date: Date, format: Format) -> String {
switch format {
case .day:
return dayFormatter.string(from: date)
case .weekdayWide:
return weekdayWideFormatter.string(from: date)
case .weekdayAbbreviated:
return weekdayAbbrevFormatter.string(from: date)
case .weekdayWideDay:
return weekdayWideDayFormatter.string(from: date)
case .monthWide:
return monthWideFormatter.string(from: date)
case .monthAbbreviated:
return monthAbbrevFormatter.string(from: date)
case .monthAbbreviatedDay:
return monthAbbrevDayFormatter.string(from: date)
case .monthAbbreviatedYear:
return monthAbbrevYearFormatter.string(from: date)
case .monthWideYear:
return monthWideYearFormatter.string(from: date)
case .weekdayAbbrevMonthAbbrev:
return weekdayAbbrevMonthAbbrevFormatter.string(from: date)
case .weekdayWideMonthAbbrev:
return weekdayWideMonthAbbrevFormatter.string(from: date)
case .yearMonthDayDigits:
return yearMonthDayDigitsFormatter.string(from: date)
case .dateMedium:
return dateMediumFormatter.string(from: date)
case .dateFull:
return dateFullFormatter.string(from: date)
}
}
}
extension Bundle {
var appName: String {
return infoDictionary?["CFBundleName"] as! String

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.moodImages.rawValue, store: GroupUserDefaults.groupDefaults) private var imagePack: MoodImages = .FontAwesome
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
@AppStorage(UserDefaultsStore.Keys.votingLayoutStyle.rawValue, store: GroupUserDefaults.groupDefaults) private var votingLayoutStyle: Int = 0
private var textColor: Color { theme.currentTheme.labelColor }
@State var onboardingData = OnboardingDataDataManager.shared.savedOnboardingData
// Celebration animation state

View File

@@ -10,8 +10,9 @@ import SwiftUI
struct CreateWidgetView: View {
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@Environment(\.dismiss) var dismiss
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
private var textColor: Color { theme.currentTheme.labelColor }
@StateObject private var customWidget: CustomWidgetModel
@State private var mouth: CustomWidgetMouthOptions = CustomWidgetMouthOptions.defaultOption

View File

@@ -65,18 +65,8 @@ struct CustomizeContentView: View {
// APPEARANCE
SettingsSection(title: "Appearance") {
VStack(spacing: 16) {
// Theme
SettingsRow(title: "Theme") {
ThemePickerCompact()
}
Divider()
// Text Color
SettingsRow(title: "Text Color") {
TextColorPickerCompact()
}
SettingsRow(title: "Theme") {
ThemePickerCompact()
}
}
@@ -90,13 +80,6 @@ struct CustomizeContentView: View {
Divider()
// Mood Colors
SettingsRow(title: "Colors") {
TintPickerCompact()
}
Divider()
// Day View Style
SettingsRow(title: "Entry Style") {
DayViewStylePickerCompact()
@@ -143,7 +126,6 @@ struct CustomizeView: View {
@Environment(\.colorScheme) private var colorScheme
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
var body: some View {
ScrollView {
@@ -157,18 +139,8 @@ struct CustomizeView: View {
// APPEARANCE
SettingsSection(title: "Appearance") {
VStack(spacing: 16) {
// Theme
SettingsRow(title: "Theme") {
ThemePickerCompact()
}
Divider()
// Text Color
SettingsRow(title: "Text Color") {
TextColorPickerCompact()
}
SettingsRow(title: "Theme") {
ThemePickerCompact()
}
}
@@ -182,13 +154,6 @@ struct CustomizeView: View {
Divider()
// Mood Colors
SettingsRow(title: "Colors") {
TintPickerCompact()
}
Divider()
// Day View Style
SettingsRow(title: "Entry Style") {
DayViewStylePickerCompact()
@@ -237,7 +202,7 @@ struct CustomizeView: View {
HStack {
Text("Customize")
.font(.title.weight(.bold))
.foregroundColor(textColor)
.foregroundColor(theme.currentTheme.labelColor)
Spacer()
}
@@ -251,13 +216,13 @@ struct SettingsSection<Content: View>: View {
@ViewBuilder let content: Content
@Environment(\.colorScheme) private var colorScheme
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(title.uppercased())
.font(.caption.weight(.semibold))
.foregroundColor(textColor.opacity(0.4))
.foregroundColor(theme.currentTheme.labelColor.opacity(0.4))
.tracking(0.5)
VStack(spacing: 0) {
@@ -277,13 +242,13 @@ struct SettingsRow<Content: View>: View {
let title: String
@ViewBuilder let content: Content
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(title)
.font(.subheadline.weight(.medium))
.foregroundColor(textColor.opacity(0.7))
.foregroundColor(theme.currentTheme.labelColor.opacity(0.7))
content
}
@@ -294,14 +259,12 @@ struct SettingsRow<Content: View>: View {
struct ThemePickerCompact: View {
@Environment(\.colorScheme) var colorScheme
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
var body: some View {
HStack(spacing: 20) {
ForEach(Theme.allCases, id: \.rawValue) { aTheme in
Button(action: {
theme = aTheme
changeTextColor(forTheme: aTheme)
EventLogger.log(event: "change_theme_id", withData: ["id": aTheme.rawValue])
}) {
VStack(spacing: 8) {
@@ -323,7 +286,7 @@ struct ThemePickerCompact: View {
Text(aTheme.title)
.font(.caption.weight(.medium))
.foregroundColor(theme == aTheme ? .accentColor : textColor.opacity(0.6))
.foregroundColor(theme == aTheme ? .accentColor : theme.currentTheme.labelColor.opacity(0.6))
}
}
.buttonStyle(BorderlessButtonStyle())
@@ -331,33 +294,6 @@ struct ThemePickerCompact: View {
Spacer()
}
}
private func changeTextColor(forTheme theme: Theme) {
if [Theme.iFeel, Theme.system].contains(theme) {
let currentSystemScheme = UITraitCollection.current.userInterfaceStyle
textColor = currentSystemScheme == .dark ? .white : .black
}
if theme == Theme.dark { textColor = .white }
if theme == Theme.light { textColor = .black }
}
}
// MARK: - Text Color Picker
struct TextColorPickerCompact: View {
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
var body: some View {
HStack(spacing: 16) {
ColorPicker("", selection: $textColor)
.labelsHidden()
Text("Sample Text")
.font(.body.weight(.medium))
.foregroundColor(textColor)
Spacer()
}
}
}
// MARK: - Image Pack Picker
@@ -412,111 +348,10 @@ struct ImagePackPickerCompact: View {
}
}
// MARK: - Tint Picker
struct TintPickerCompact: View {
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
@AppStorage(UserDefaultsStore.Keys.customMoodTintUpdateNumber.rawValue, store: GroupUserDefaults.groupDefaults) private var customMoodTintUpdateNumber: Int = 0
@StateObject private var customMoodTint = UserDefaultsStore.getCustomMoodTint()
@Environment(\.colorScheme) private var colorScheme
var body: some View {
VStack(spacing: 8) {
ForEach(MoodTints.defaultOptions, id: \.rawValue) { tint in
Button(action: {
let impactMed = UIImpactFeedbackGenerator(style: .medium)
impactMed.impactOccurred()
moodTint = tint
EventLogger.log(event: "change_mood_tint_id", withData: ["id": tint.rawValue])
}) {
HStack {
HStack(spacing: 12) {
ForEach(Mood.allValues, id: \.self) { mood in
Circle()
.fill(tint.color(forMood: mood))
.frame(width: 28, height: 28)
}
}
Spacer()
if moodTint == tint {
Image(systemName: "checkmark.circle.fill")
.font(.title2)
.foregroundColor(.accentColor)
}
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(moodTint == tint
? Color.accentColor.opacity(0.08)
: (colorScheme == .dark ? Color(.systemGray5) : Color(.systemGray6)))
)
}
.buttonStyle(.plain)
}
// Custom colors
Button(action: {
moodTint = .Custom
}) {
HStack {
HStack(spacing: 12) {
ForEach(0..<5, id: \.self) { index in
ColorPicker("", selection: colorBinding(for: index))
.labelsHidden()
.onChange(of: colorBinding(for: index).wrappedValue) {
saveCustomMoodTint()
}
}
}
Spacer()
if moodTint == .Custom {
Image(systemName: "checkmark.circle.fill")
.font(.title2)
.foregroundColor(.accentColor)
} else {
Text("Custom")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(moodTint == .Custom
? Color.accentColor.opacity(0.08)
: (colorScheme == .dark ? Color(.systemGray5) : Color(.systemGray6)))
)
}
.buttonStyle(.plain)
}
}
private func colorBinding(for index: Int) -> Binding<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
struct VotingLayoutPickerCompact: View {
@AppStorage(UserDefaultsStore.Keys.votingLayoutStyle.rawValue, store: GroupUserDefaults.groupDefaults) private var votingLayoutStyle: Int = 0
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@Environment(\.colorScheme) private var colorScheme
private var currentLayout: VotingLayoutStyle {
@@ -540,11 +375,11 @@ struct VotingLayoutPickerCompact: View {
VStack(spacing: 6) {
layoutIcon(for: layout)
.frame(width: 44, height: 44)
.foregroundColor(currentLayout == layout ? .accentColor : textColor.opacity(0.4))
.foregroundColor(currentLayout == layout ? .accentColor : theme.currentTheme.labelColor.opacity(0.4))
Text(layout.displayName)
.font(.caption2.weight(.medium))
.foregroundColor(currentLayout == layout ? .accentColor : textColor.opacity(0.5))
.foregroundColor(currentLayout == layout ? .accentColor : theme.currentTheme.labelColor.opacity(0.5))
}
.frame(width: 70)
.padding(.vertical, 12)
@@ -669,7 +504,6 @@ struct VotingLayoutPickerCompact: View {
// MARK: - Custom Widget Section
struct CustomWidgetSection: View {
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
@StateObject private var selectedWidget = CustomWidgetStateViewModel()
var body: some View {
@@ -728,7 +562,7 @@ struct CustomWidgetSection: View {
struct PersonalityPackPickerCompact: View {
@AppStorage(UserDefaultsStore.Keys.personalityPack.rawValue, store: GroupUserDefaults.groupDefaults) private var personalityPack: PersonalityPack = .Default
@AppStorage(UserDefaultsStore.Keys.showNSFW.rawValue, store: GroupUserDefaults.groupDefaults) private var showNSFW: Bool = false
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@State private var showOver18Alert = false
@Environment(\.colorScheme) private var colorScheme
@@ -751,12 +585,12 @@ struct PersonalityPackPickerCompact: View {
VStack(alignment: .leading, spacing: 4) {
Text(String(aPack.title()))
.font(.subheadline.weight(.semibold))
.foregroundColor(textColor)
.foregroundColor(theme.currentTheme.labelColor)
let strings = aPack.randomPushNotificationStrings()
Text(strings.body)
.font(.caption)
.foregroundColor(textColor.opacity(0.5))
.foregroundColor(theme.currentTheme.labelColor.opacity(0.5))
.lineLimit(2)
}
@@ -798,7 +632,7 @@ struct PersonalityPackPickerCompact: View {
// MARK: - Day Filter Picker
struct DayFilterPickerCompact: View {
@StateObject private var filteredDays = DaysFilterClass.shared
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@Environment(\.colorScheme) private var colorScheme
let weekdays = [(Calendar.current.shortWeekdaySymbols[0], 1),
@@ -828,7 +662,7 @@ struct DayFilterPickerCompact: View {
}) {
Text(day.prefix(2).uppercased())
.font(.caption.weight(.semibold))
.foregroundColor(isActive ? .white : textColor.opacity(0.5))
.foregroundColor(isActive ? .white : theme.currentTheme.labelColor.opacity(0.5))
.frame(maxWidth: .infinity)
.frame(height: 40)
.background(
@@ -842,7 +676,7 @@ struct DayFilterPickerCompact: View {
Text(String(localized: "day_picker_view_text"))
.font(.caption)
.foregroundColor(textColor.opacity(0.5))
.foregroundColor(theme.currentTheme.labelColor.opacity(0.5))
.multilineTextAlignment(.center)
}
}
@@ -953,7 +787,7 @@ struct SubscriptionBannerView: View {
// MARK: - Day View Style Picker
struct DayViewStylePickerCompact: View {
@AppStorage(UserDefaultsStore.Keys.dayViewStyle.rawValue, store: GroupUserDefaults.groupDefaults) private var dayViewStyle: DayViewStyle = .classic
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@Environment(\.colorScheme) private var colorScheme
var body: some View {
@@ -975,11 +809,11 @@ struct DayViewStylePickerCompact: View {
VStack(spacing: 6) {
styleIcon(for: style)
.frame(width: 44, height: 44)
.foregroundColor(dayViewStyle == style ? .accentColor : textColor.opacity(0.4))
.foregroundColor(dayViewStyle == style ? .accentColor : theme.currentTheme.labelColor.opacity(0.4))
Text(style.displayName)
.font(.caption2.weight(.medium))
.foregroundColor(dayViewStyle == style ? .accentColor : textColor.opacity(0.5))
.foregroundColor(dayViewStyle == style ? .accentColor : theme.currentTheme.labelColor.opacity(0.5))
}
.frame(width: 70)
.padding(.vertical, 12)

View File

@@ -288,12 +288,10 @@ struct AppThemePreviewSheet: View {
.padding(.horizontal, 20)
VStack(spacing: 12) {
ThemeComponentRow(
ThemeColorRow(
icon: "paintpalette.fill",
title: "Colors",
value: theme.colorTint == .Default ? "Default" :
theme.colorTint == .Neon ? "Neon" :
theme.colorTint == .Pastel ? "Pastel" : "Custom",
moodTint: theme.colorTint,
color: .orange
)
@@ -404,6 +402,48 @@ struct ThemeComponentRow: View {
}
}
// MARK: - Color Row with Circles
struct ThemeColorRow: View {
let icon: String
let title: String
let moodTint: MoodTints
let color: Color
@Environment(\.colorScheme) private var colorScheme
private let moods: [Mood] = [.great, .good, .average, .bad, .horrible]
var body: some View {
HStack(spacing: 14) {
Image(systemName: icon)
.font(.title3)
.foregroundColor(color)
.frame(width: 36, height: 36)
.background(color.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 8))
Text(title)
.font(.subheadline)
.foregroundColor(.secondary)
Spacer()
HStack(spacing: 6) {
ForEach(moods, id: \.rawValue) { mood in
Circle()
.fill(moodTint.color(forMood: mood))
.frame(width: 20, height: 20)
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(colorScheme == .dark ? Color(.systemGray6) : .white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
// MARK: - Preview
struct AppThemePickerView_Previews: PreviewProvider {

View File

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

View File

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

View File

@@ -12,8 +12,9 @@ struct PersonalityPackPickerView: View {
@AppStorage(UserDefaultsStore.Keys.personalityPack.rawValue, store: GroupUserDefaults.groupDefaults) private var personalityPack: PersonalityPack = .Default
@State private var showOver18Alert = false
@AppStorage(UserDefaultsStore.Keys.showNSFW.rawValue, store: GroupUserDefaults.groupDefaults) private var showNSFW: Bool = false
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
private var textColor: Color { theme.currentTheme.labelColor }
var body: some View {
ZStack {
theme.currentTheme.secondaryBGColor

View File

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

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
@State private var selectedTheme: Theme = UserDefaultsStore.theme()
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
private var textColor: Color { selectedTheme.currentTheme.labelColor }
var body: some View {
ZStack {
@@ -70,31 +71,8 @@ struct ThemePickerView: View {
selectedTheme = theme
}
changeTextColor(forTheme: theme)
EventLogger.log(event: "change_theme_id", withData: ["id": theme.rawValue])
}
private func changeTextColor(forTheme theme: Theme) {
if [Theme.iFeel, Theme.system].contains(theme) {
let currentSystemScheme = UITraitCollection.current.userInterfaceStyle
switch currentSystemScheme {
case .unspecified:
textColor = .black
case .light:
textColor = .black
case .dark:
textColor = .white
@unknown default:
textColor = .black
}
}
if theme == Theme.dark {
textColor = .white
}
if theme == Theme.light {
textColor = .black
}
}
}
struct ThemePickerView_Previews: PreviewProvider {

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

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

View File

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

View File

@@ -9,7 +9,8 @@ import SwiftUI
struct EmptyHomeView: View {
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
private var textColor: Color { theme.currentTheme.labelColor }
let showVote: Bool
let viewModel: DayViewViewModel?

View File

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

View File

@@ -10,7 +10,9 @@ import SwiftUI
struct ExportView: View {
@Environment(\.dismiss) private var dismiss
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
private var textColor: Color { theme.currentTheme.labelColor }
@State private var selectedFormat: ExportFormat = .csv
@State private var selectedRange: DateRange = .allTime

View File

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

View File

@@ -9,7 +9,8 @@ import SwiftUI
struct IAPWarningView: View {
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
private var textColor: Color { theme.currentTheme.labelColor }
@ObservedObject var iapManager: IAPManager

View File

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

View File

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

View File

@@ -11,7 +11,8 @@ struct MainTabView: View {
@AppStorage(UserDefaultsStore.Keys.needsOnboarding.rawValue, store: GroupUserDefaults.groupDefaults) private var needsOnboarding = true
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
private var textColor: Color { theme.currentTheme.labelColor }
let onboardingData = OnboardingDataDataManager.shared.savedOnboardingData

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.deleteEnable.rawValue, store: GroupUserDefaults.groupDefaults) private var deleteEnabled = true
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
private var textColor: Color { theme.currentTheme.labelColor }
@StateObject private var shareImage = ShareImageStateViewModel()

View File

@@ -12,17 +12,15 @@ struct MonthView: View {
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
private var labelColor: Color { theme.currentTheme.labelColor }
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
@AppStorage(UserDefaultsStore.Keys.moodImages.rawValue, store: GroupUserDefaults.groupDefaults) private var imagePack: MoodImages = .FontAwesome
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
@AppStorage(UserDefaultsStore.Keys.shape.rawValue, store: GroupUserDefaults.groupDefaults) private var shape: BGShape = .circle
@StateObject private var shareImage = ShareImageStateViewModel()
// store a value that gets changed when user updates custom colors to update the view since the moodTint doesn't change
@AppStorage(UserDefaultsStore.Keys.customMoodTintUpdateNumber.rawValue, store: GroupUserDefaults.groupDefaults) private var customMoodTintUpdateNumber: Int = 0
@EnvironmentObject var iapManager: IAPManager
@StateObject private var selectedDetail = DetailViewStateViewModel()
@State private var showingSheet = false
@@ -43,8 +41,11 @@ struct MonthView: View {
@State private var trialWarningHidden = false
@State private var showSubscriptionStore = false
/// Cached sorted year/month data to avoid recalculating in ForEach
@State private var cachedSortedData: [(year: Int, months: [(month: Int, entries: [MoodEntryModel])])] = []
/// Filters month data to only current month when subscription/trial expired
private var filteredMonthData: [Int: [Int: [MoodEntryModel]]] {
private func computeFilteredMonthData() -> [Int: [Int: [MoodEntryModel]]] {
guard iapManager.shouldShowPaywall else {
return viewModel.grouped
}
@@ -61,6 +62,13 @@ struct MonthView: View {
return filtered
}
/// Sorts the filtered month data - called only when source data changes
private func computeSortedYearMonthData() -> [(year: Int, months: [(month: Int, entries: [MoodEntryModel])])] {
computeFilteredMonthData()
.sorted { $0.key > $1.key }
.map { (year: $0.key, months: $0.value.sorted { $0.key > $1.key }.map { (month: $0.key, entries: $0.value) }) }
}
var body: some View {
ZStack {
if viewModel.hasNoData {
@@ -69,23 +77,22 @@ struct MonthView: View {
} else {
ScrollView {
VStack(spacing: 16) {
ForEach(filteredMonthData.sorted(by: { $0.key > $1.key }), id: \.key) { year, months in
ForEach(cachedSortedData, id: \.year) { yearData in
// for each month
ForEach(months.sorted(by: { $0.key > $1.key }), id: \.key) { month, entries in
ForEach(yearData.months, id: \.month) { monthData in
MonthCard(
month: month,
year: year,
entries: entries,
month: monthData.month,
year: yearData.year,
entries: monthData.entries,
moodTint: moodTint,
imagePack: imagePack,
textColor: textColor,
theme: theme,
filteredDays: filteredDays.currentFilters,
onTap: {
let detailView = MonthDetailView(
monthInt: month,
yearInt: year,
entries: entries,
monthInt: monthData.month,
yearInt: yearData.year,
entries: monthData.entries,
parentViewModel: viewModel
)
selectedDetail.selectedItem = detailView
@@ -101,6 +108,7 @@ struct MonthView: View {
}
.padding(.horizontal)
.padding(.bottom, 100)
.id(moodTint) // Force complete refresh when mood tint changes
.background(
GeometryReader { proxy in
let offset = proxy.frame(in: .named("scroll")).minY
@@ -126,10 +134,6 @@ struct MonthView: View {
)
}
// Hidden text to trigger updates when custom tint changes
Text(String(customMoodTintUpdateNumber))
.hidden()
if iapManager.shouldShowPaywall {
// Premium month history prompt - bottom half
VStack(spacing: 20) {
@@ -160,12 +164,12 @@ struct MonthView: View {
VStack(spacing: 10) {
Text("Explore Your Mood History")
.font(.title3.weight(.bold))
.foregroundColor(textColor)
.foregroundColor(labelColor)
.multilineTextAlignment(.center)
Text("See your complete monthly journey. Track patterns and understand what shapes your days.")
.font(.subheadline)
.foregroundColor(textColor.opacity(0.7))
.foregroundColor(labelColor.opacity(0.7))
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
}
@@ -231,6 +235,18 @@ struct MonthView: View {
trialWarningHidden = value < 0
}
}
.onAppear {
cachedSortedData = computeSortedYearMonthData()
}
.onChange(of: viewModel.numberOfItems) { _, _ in
// Use numberOfItems as a lightweight proxy for data changes
// instead of comparing the entire grouped dictionary
cachedSortedData = computeSortedYearMonthData()
}
.onChange(of: iapManager.shouldShowPaywall) { _, _ in
cachedSortedData = computeSortedYearMonthData()
}
.preferredColorScheme(theme.preferredColorScheme)
}
@@ -241,31 +257,43 @@ struct MonthView: View {
}
// MARK: - Month Card Component
struct MonthCard: View {
struct MonthCard: View, Equatable {
let month: Int
let year: Int
let entries: [MoodEntryModel]
let moodTint: MoodTints
let imagePack: MoodImages
let textColor: Color
let theme: Theme
let filteredDays: [Int]
let onTap: () -> Void
let onShare: (UIImage) -> Void
private var labelColor: Color { theme.currentTheme.labelColor }
// Equatable conformance to prevent unnecessary re-renders
static func == (lhs: MonthCard, rhs: MonthCard) -> Bool {
lhs.month == rhs.month &&
lhs.year == rhs.year &&
lhs.entries.count == rhs.entries.count &&
lhs.moodTint == rhs.moodTint &&
lhs.imagePack == rhs.imagePack &&
lhs.filteredDays == rhs.filteredDays &&
lhs.theme == rhs.theme
}
@State private var showStats = true
@State private var cachedMetrics: [MoodMetrics] = []
private let weekdayLabels = ["S", "M", "T", "W", "T", "F", "S"]
private let heatmapColumns = Array(repeating: GridItem(.flexible(), spacing: 2), count: 7)
private var metrics: [MoodMetrics] {
let (startDate, endDate) = Date.dateRange(monthInt: month, yearInt: year)
let monthEntries = DataController.shared.getData(startDate: startDate, endDate: endDate, includedDays: [1,2,3,4,5,6,7])
return Random.createTotalPerc(fromEntries: monthEntries)
// Cached filtered/sorted metrics to avoid recalculating in ForEach
private var displayMetrics: [MoodMetrics] {
cachedMetrics.filter { $0.total > 0 }.sorted { $0.mood.rawValue > $1.mood.rawValue }
}
private var topMood: Mood? {
metrics.filter { $0.total > 0 }.max(by: { $0.total < $1.total })?.mood
displayMetrics.max(by: { $0.total < $1.total })?.mood
}
private var totalTrackedDays: Int {
@@ -277,13 +305,13 @@ struct MonthCard: View {
// Header with month/year
Text("\(Random.monthName(fromMonthInt: month).uppercased()) \(String(year))")
.font(.title.weight(.heavy))
.foregroundColor(textColor)
.foregroundColor(labelColor)
.padding(.top, 40)
.padding(.bottom, 8)
Text("Monthly Mood Wrap")
.font(.body.weight(.medium))
.foregroundColor(textColor.opacity(0.6))
.foregroundColor(labelColor.opacity(0.6))
.padding(.bottom, 30)
// Top mood highlight
@@ -304,7 +332,7 @@ struct MonthCard: View {
Text("Top Mood")
.font(.subheadline.weight(.medium))
.foregroundColor(textColor.opacity(0.5))
.foregroundColor(labelColor.opacity(0.5))
Text(topMood.strValue.uppercased())
.font(.title3.weight(.bold))
@@ -318,10 +346,10 @@ struct MonthCard: View {
VStack(spacing: 4) {
Text("\(totalTrackedDays)")
.font(.largeTitle.weight(.bold))
.foregroundColor(textColor)
.foregroundColor(labelColor)
Text("Days Tracked")
.font(.caption.weight(.medium))
.foregroundColor(textColor.opacity(0.5))
.foregroundColor(labelColor.opacity(0.5))
}
.frame(maxWidth: .infinity)
}
@@ -329,7 +357,7 @@ struct MonthCard: View {
// Mood breakdown with bars
VStack(spacing: 12) {
ForEach(metrics.filter { $0.total > 0 }.sorted(by: { $0.mood.rawValue > $1.mood.rawValue })) { metric in
ForEach(displayMetrics) { metric in
HStack(spacing: 12) {
Circle()
.fill(moodTint.color(forMood: metric.mood))
@@ -357,7 +385,7 @@ struct MonthCard: View {
Text("\(Int(metric.percent))%")
.font(.subheadline.weight(.semibold))
.foregroundColor(textColor)
.foregroundColor(labelColor)
.frame(width: 40, alignment: .trailing)
}
}
@@ -368,7 +396,7 @@ struct MonthCard: View {
// App branding
Text("ifeel")
.font(.subheadline.weight(.medium))
.foregroundColor(textColor.opacity(0.3))
.foregroundColor(labelColor.opacity(0.3))
.padding(.bottom, 20)
}
.frame(width: 400)
@@ -389,11 +417,11 @@ struct MonthCard: View {
HStack {
Text("\(Random.monthName(fromMonthInt: month)) \(String(year))")
.font(.title3.bold())
.foregroundColor(textColor)
.foregroundColor(labelColor)
Image(systemName: showStats ? "chevron.up" : "chevron.down")
.font(.caption.weight(.semibold))
.foregroundColor(textColor.opacity(0.5))
.foregroundColor(labelColor.opacity(0.5))
}
}
.buttonStyle(.plain)
@@ -406,7 +434,7 @@ struct MonthCard: View {
}) {
Image(systemName: "square.and.arrow.up")
.font(.subheadline.weight(.medium))
.foregroundColor(textColor.opacity(0.6))
.foregroundColor(labelColor.opacity(0.6))
}
.buttonStyle(.plain)
}
@@ -418,7 +446,7 @@ struct MonthCard: View {
ForEach(weekdayLabels.indices, id: \.self) { index in
Text(weekdayLabels[index])
.font(.caption2.weight(.medium))
.foregroundColor(textColor.opacity(0.5))
.foregroundColor(labelColor.opacity(0.5))
.frame(maxWidth: .infinity)
}
}
@@ -443,7 +471,7 @@ struct MonthCard: View {
Divider()
.padding(.horizontal, 16)
MoodBarChart(metrics: metrics, moodTint: moodTint, imagePack: imagePack)
MoodBarChart(metrics: cachedMetrics, moodTint: moodTint, imagePack: imagePack)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.transition(.opacity.combined(with: .move(edge: .top)))
@@ -457,6 +485,12 @@ struct MonthCard: View {
.onTapGesture {
onTap()
}
.onAppear {
// Cache metrics calculation on first appearance
if cachedMetrics.isEmpty {
cachedMetrics = Random.createTotalPerc(fromEntries: entries)
}
}
}
}
@@ -487,9 +521,7 @@ struct HeatmapCell: View {
}
private var formattedDate: String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
return formatter.string(from: entry.forDate)
DateFormattingCache.shared.string(for: entry.forDate, format: .dateMedium)
}
private var cellColor: Color {

View File

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

View File

@@ -10,7 +10,8 @@ import StoreKit
struct PurchaseButtonView: View {
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
private var textColor: Color { theme.currentTheme.labelColor }
@ObservedObject var iapManager: IAPManager

View File

@@ -10,7 +10,6 @@ import SwiftUI
struct SampleEntryView: View {
@State private var sampleListEntry = DataController.shared.generateObjectNotInArray(forDate: Date(), withMood: Mood.great)
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
var body: some View {
ZStack {

View File

@@ -14,7 +14,8 @@ struct DebugAnimationSettingsView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
private var textColor: Color { theme.currentTheme.labelColor }
@State private var selectedAnimation: CelebrationAnimationType = .vortexCheckmark
@State private var isAnimating = false

View File

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

View File

@@ -26,7 +26,8 @@ struct SettingsContentView: View {
@AppStorage(UserDefaultsStore.Keys.deleteEnable.rawValue, store: GroupUserDefaults.groupDefaults) private var deleteEnabled = true
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
private var textColor: Color { theme.currentTheme.labelColor }
var body: some View {
ScrollView {
@@ -48,6 +49,8 @@ struct SettingsContentView: View {
eulaButton
privacyButton
addTestDataButton
#if DEBUG
// Debug section
debugSectionHeader
@@ -55,7 +58,7 @@ struct SettingsContentView: View {
animationLabButton
paywallPreviewButton
tipsPreviewButton
addTestDataButton
clearDataButton
#endif
@@ -379,36 +382,6 @@ struct SettingsContentView: View {
}
}
private var addTestDataButton: some View {
ZStack {
theme.currentTheme.secondaryBGColor
Button {
DataController.shared.populateTestData()
} label: {
HStack(spacing: 12) {
Image(systemName: "plus.square.on.square")
.font(.title2)
.foregroundColor(.green)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Add Test Data")
.foregroundColor(textColor)
Text("Populate with sample mood entries")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding()
}
}
.fixedSize(horizontal: false, vertical: true)
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
}
private var clearDataButton: some View {
ZStack {
theme.currentTheme.secondaryBGColor
@@ -494,6 +467,36 @@ struct SettingsContentView: View {
@ObservedObject private var healthKitManager = HealthKitManager.shared
private var addTestDataButton: some View {
ZStack {
theme.currentTheme.secondaryBGColor
Button {
DataController.shared.populateTestData()
} label: {
HStack(spacing: 12) {
Image(systemName: "plus.square.on.square")
.font(.title2)
.foregroundColor(.green)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Add Test Data")
.foregroundColor(textColor)
Text("Populate with sample mood entries")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding()
}
}
.fixedSize(horizontal: false, vertical: true)
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
}
private var healthKitToggle: some View {
VStack(spacing: 0) {
HStack(spacing: 12) {
@@ -795,10 +798,11 @@ struct SettingsView: View {
@AppStorage(UserDefaultsStore.Keys.deleteEnable.rawValue, store: GroupUserDefaults.groupDefaults) private var deleteEnabled = true
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
@AppStorage(UserDefaultsStore.Keys.firstLaunchDate.rawValue, store: GroupUserDefaults.groupDefaults) private var firstLaunchDate = Date()
@AppStorage(UserDefaultsStore.Keys.privacyLockEnabled.rawValue, store: GroupUserDefaults.groupDefaults) private var privacyLockEnabled = false
private var textColor: Color { theme.currentTheme.labelColor }
var body: some View {
ScrollView {
VStack {

View File

@@ -24,7 +24,8 @@ struct WrappedSharable: Hashable, Equatable {
struct SharingListView: View {
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
private var textColor: Color { theme.currentTheme.labelColor }
class ShareStateViewModel: ObservableObject {
@Published var selectedItem: WrappedSharable? = nil

View File

@@ -20,7 +20,8 @@ struct AllMoodsTotalTemplate: View, SharingTemplate {
@Environment(\.presentationMode) var presentationMode
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = .white
private var textColor: Color { theme.currentTheme.labelColor }
@StateObject private var shareImage = ShareImageStateViewModel()
private var entries = [MoodMetrics]()

View File

@@ -23,7 +23,8 @@ struct CurrentStreakTemplate: View, SharingTemplate {
@Environment(\.presentationMode) var presentationMode
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
private var textColor: Color { theme.currentTheme.labelColor }
let columns = [
GridItem(.flexible(minimum: 5, maximum: .infinity), alignment: .center),

View File

@@ -32,7 +32,8 @@ struct LongestStreakTemplate: View, SharingTemplate {
@Environment(\.presentationMode) var presentationMode
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = .white
private var textColor: Color { theme.currentTheme.labelColor }
let columns = [
GridItem(.flexible(minimum: 5, maximum: .infinity), alignment: .center),

View File

@@ -25,7 +25,8 @@ struct MonthTotalTemplate: View, SharingTemplate {
@Environment(\.presentationMode) var presentationMode
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = .white
private var textColor: Color { theme.currentTheme.labelColor }
private var moodMetrics = [MoodMetrics]()
private var moodEntries = [MoodEntryModel]()

View File

@@ -10,10 +10,10 @@ import SwiftUI
struct SmallRollUpHeaderView: View {
@Binding var viewType: MainSwitchableViewType
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
@AppStorage(UserDefaultsStore.Keys.shape.rawValue, store: GroupUserDefaults.groupDefaults) private var shape: BGShape = .circle
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
private var textColor: Color { theme.currentTheme.labelColor }
let entries: [MoodEntryModel]
private var moodMetrics = [MoodMetrics]()

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.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = .white
// store a value that gets changed when user updates custom colors to update the view since the moodTint doesn't change
@AppStorage(UserDefaultsStore.Keys.customMoodTintUpdateNumber.rawValue, store: GroupUserDefaults.groupDefaults) private var customMoodTintUpdateNumber: Int = 0
private var textColor: Color { theme.currentTheme.labelColor }
init(daysBack: Int, viewType: Binding<MainSwitchableViewType>, headerTypeChanged: @escaping ((MainSwitchableViewType) -> Void)) {
self.daysBack = daysBack
self.headerTypeChanged = headerTypeChanged
@@ -66,9 +64,6 @@ struct SwitchableView: View {
var body: some View {
VStack {
ZStack {
Text(String(customMoodTintUpdateNumber))
.hidden()
mainViews
.padding([.top, .bottom])

View File

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

View File

@@ -6,20 +6,15 @@
//
import SwiftUI
import SwiftData
struct YearView: View {
let months = [(0, "J"), (1, "F"), (2,"M"), (3,"A"), (4,"M"), (5, "J"), (6,"J"), (7,"A"), (8,"S"), (9,"O"), (10, "N"), (11,"D")]
@State private var toggle = true
@Query(sort: \MoodEntryModel.forDate, order: .reverse)
private var items: [MoodEntryModel]
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
@AppStorage(UserDefaultsStore.Keys.moodImages.rawValue, store: GroupUserDefaults.groupDefaults) private var imagePack: MoodImages = .FontAwesome
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
@EnvironmentObject var iapManager: IAPManager
@StateObject public var viewModel: YearViewModel
@@ -28,6 +23,9 @@ struct YearView: View {
@State private var trialWarningHidden = false
@State private var showSubscriptionStore = false
/// Cached sorted year keys to avoid re-sorting in ForEach on every render
@State private var cachedSortedYearKeys: [Int] = []
// Heatmap-style grid: 12 columns for months
private let heatmapColumns = Array(repeating: GridItem(.flexible(), spacing: 2), count: 12)
@@ -39,13 +37,13 @@ struct YearView: View {
} else {
ScrollView {
VStack(spacing: 16) {
ForEach(Array(self.viewModel.data.keys.sorted(by: >)), id: \.self) { yearKey in
ForEach(cachedSortedYearKeys, id: \.self) { yearKey in
YearCard(
year: yearKey,
yearData: self.viewModel.data[yearKey]!,
yearEntries: self.viewModel.entriesByYear[yearKey] ?? [],
moodTint: moodTint,
imagePack: imagePack,
textColor: textColor,
theme: theme,
filteredDays: filteredDays.currentFilters,
onShare: { image in
@@ -57,6 +55,7 @@ struct YearView: View {
}
.padding(.horizontal)
.padding(.bottom, 100)
.id(moodTint) // Force complete refresh when mood tint changes
.background(
GeometryReader { proxy in
let offset = proxy.frame(in: .named("scroll")).minY
@@ -112,12 +111,12 @@ struct YearView: View {
VStack(spacing: 10) {
Text("See Your Year at a Glance")
.font(.title3.weight(.bold))
.foregroundColor(textColor)
.foregroundColor(theme.currentTheme.labelColor)
.multilineTextAlignment(.center)
Text("Discover your emotional rhythm across the year. Spot trends and celebrate how far you've come.")
.font(.subheadline)
.foregroundColor(textColor.opacity(0.7))
.foregroundColor(theme.currentTheme.labelColor.opacity(0.7))
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
}
@@ -168,7 +167,16 @@ struct YearView: View {
}
.onAppear(perform: {
self.viewModel.filterEntries(startDate: Date(timeIntervalSince1970: 0), endDate: Date())
cachedSortedYearKeys = Array(viewModel.data.keys.sorted(by: >))
})
.onChange(of: viewModel.data.keys.count) { _, _ in
cachedSortedYearKeys = Array(viewModel.data.keys.sorted(by: >))
}
.onChange(of: moodTint) { _, _ in
// Rebuild chart data when mood tint changes to update colors
self.viewModel.filterEntries(startDate: Date(timeIntervalSince1970: 0), endDate: Date())
cachedSortedYearKeys = Array(viewModel.data.keys.sorted(by: >))
}
.background(
theme.currentTheme.bg
.edgesIgnoringSafeArea(.all)
@@ -179,33 +187,42 @@ struct YearView: View {
}
}
.padding([.top])
.preferredColorScheme(theme.preferredColorScheme)
}
}
// MARK: - Year Card Component
struct YearCard: View {
struct YearCard: View, Equatable {
let year: Int
let yearData: [Int: [DayChartView]]
let yearEntries: [MoodEntryModel]
let moodTint: MoodTints
let imagePack: MoodImages
let textColor: Color
let theme: Theme
let filteredDays: [Int]
let onShare: (UIImage) -> Void
private var textColor: Color { theme.currentTheme.labelColor }
// Equatable conformance to prevent unnecessary re-renders
static func == (lhs: YearCard, rhs: YearCard) -> Bool {
lhs.year == rhs.year &&
lhs.yearEntries.count == rhs.yearEntries.count &&
lhs.moodTint == rhs.moodTint &&
lhs.imagePack == rhs.imagePack &&
lhs.filteredDays == rhs.filteredDays &&
lhs.theme == rhs.theme
}
@State private var showStats = true
@State private var cachedMetrics: [MoodMetrics] = []
private let months = ["J", "F", "M", "A", "M", "J", "J", "A", "S", "O", "N", "D"]
private let heatmapColumns = Array(repeating: GridItem(.flexible(), spacing: 2), count: 12)
private var yearEntries: [MoodEntryModel] {
let firstOfYear = Calendar.current.date(from: DateComponents(year: year, month: 1, day: 1))!
let lastOfYear = Calendar.current.date(from: DateComponents(year: year + 1, month: 1, day: 1))!
return DataController.shared.getData(startDate: firstOfYear, endDate: lastOfYear, includedDays: filteredDays)
}
private var metrics: [MoodMetrics] {
return Random.createTotalPerc(fromEntries: yearEntries)
// Cached filtered/sorted metrics to avoid recalculating in ForEach
private var displayMetrics: [MoodMetrics] {
cachedMetrics.filter { $0.total > 0 }.sorted { $0.mood.rawValue > $1.mood.rawValue }
}
private var totalEntries: Int {
@@ -213,7 +230,7 @@ struct YearCard: View {
}
private var topMood: Mood? {
metrics.filter { $0.total > 0 }.max(by: { $0.total < $1.total })?.mood
displayMetrics.max(by: { $0.total < $1.total })?.mood
}
private var shareableView: some View {
@@ -273,7 +290,7 @@ struct YearCard: View {
// Mood breakdown with bars
VStack(spacing: 14) {
ForEach(metrics.filter { $0.total > 0 }.sorted(by: { $0.mood.rawValue > $1.mood.rawValue })) { metric in
ForEach(displayMetrics) { metric in
HStack(spacing: 14) {
Circle()
.fill(moodTint.color(forMood: metric.mood))
@@ -366,12 +383,12 @@ struct YearCard: View {
if showStats {
HStack(spacing: 16) {
// Donut Chart
MoodDonutChart(metrics: metrics, moodTint: moodTint)
MoodDonutChart(metrics: cachedMetrics, moodTint: moodTint)
.frame(width: 100, height: 100)
// Bar Chart
VStack(spacing: 6) {
ForEach(metrics.filter { $0.total > 0 }) { metric in
ForEach(displayMetrics) { metric in
HStack(spacing: 8) {
imagePack.icon(forMood: metric.mood)
.resizable()
@@ -434,6 +451,12 @@ struct YearCard: View {
RoundedRectangle(cornerRadius: 16)
.fill(theme.currentTheme.secondaryBGColor)
)
.onAppear {
// Cache metrics calculation on first appearance
if cachedMetrics.isEmpty {
cachedMetrics = Random.createTotalPerc(fromEntries: yearEntries)
}
}
}
}
@@ -445,9 +468,16 @@ struct YearHeatmapGrid: View {
private let heatmapColumns = Array(repeating: GridItem(.flexible(), spacing: 2), count: 12)
/// Pre-sorted month keys to avoid sorting in ForEach on every render
private var sortedMonthKeys: [Int] {
// This is computed once per yearData change, not on every body access
// since yearData is a let constant passed from parent
Array(yearData.keys.sorted(by: <))
}
var body: some View {
LazyVGrid(columns: heatmapColumns, spacing: 2) {
ForEach(Array(yearData.keys.sorted(by: <)), id: \.self) { monthKey in
ForEach(sortedMonthKeys, id: \.self) { monthKey in
if let monthData = yearData[monthKey] {
MonthColumn(
monthData: monthData,

View File

@@ -16,6 +16,8 @@ class YearViewModel: ObservableObject {
// year, month, items
@Published public private(set) var data = [Int: [Int: [DayChartView]]]()
@Published public private(set) var numberOfRatings: Int = 0
/// Entries organized by year for efficient access
@Published public private(set) var entriesByYear = [Int: [MoodEntryModel]]()
public private(set) var uncategorizedData = [MoodEntryModel]() {
didSet {
self.numberOfRatings = uncategorizedData.count
@@ -44,8 +46,16 @@ class YearViewModel: ObservableObject {
endDate: endDate,
includedDays: selectedDays)
data.removeAll()
entriesByYear.removeAll()
let filledOutData = chartViewBuilder.buildGridData(withData: filteredEntries)
data = filledOutData
uncategorizedData = filteredEntries
// Organize entries by year for efficient access in YearCard
let calendar = Calendar.current
for entry in filteredEntries {
let year = calendar.component(.year, from: entry.forDate)
entriesByYear[year, default: []].append(entry)
}
}
}