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