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:
@@ -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 */
|
||||
|
||||
@@ -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" : {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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]()
|
||||
|
||||
@@ -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]()
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user