Update Neon colors and show color circles in theme picker

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

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

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

View File

@@ -13,9 +13,10 @@ struct AddMoodHeaderView: View {
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@AppStorage(UserDefaultsStore.Keys.moodImages.rawValue, store: GroupUserDefaults.groupDefaults) private var imagePack: MoodImages = .FontAwesome
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
@AppStorage(UserDefaultsStore.Keys.votingLayoutStyle.rawValue, store: GroupUserDefaults.groupDefaults) private var votingLayoutStyle: Int = 0
private var textColor: Color { theme.currentTheme.labelColor }
@State var onboardingData = OnboardingDataDataManager.shared.savedOnboardingData
// Celebration animation state

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,30 +0,0 @@
//
// TextColorPickerView.swift
// Feels (iOS)
//
// Created by Trey Tartt on 4/2/22.
//
import SwiftUI
struct TextColorPickerView: View {
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
var body: some View {
ZStack {
theme.currentTheme.secondaryBGColor
ColorPicker(String(localized: "customize_view_view_text_color"), selection: $textColor)
.padding()
.foregroundColor(textColor)
}
.fixedSize(horizontal: false, vertical: true)
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
}
}
struct TextColorPickerView_Previews: PreviewProvider {
static var previews: some View {
TextColorPickerView()
}
}

View File

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

View File

@@ -1,125 +0,0 @@
//
// TintPickerView.swift
// Feels (iOS)
//
// Created by Trey Tartt on 4/2/22.
//
import SwiftUI
struct TintPickerView: View {
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@AppStorage(UserDefaultsStore.Keys.customMoodTintUpdateNumber.rawValue, store: GroupUserDefaults.groupDefaults) private var customMoodTintUpdateNumber: Int = 0
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
@StateObject private var customMoodTint = UserDefaultsStore.getCustomMoodTint()
var body: some View {
ZStack {
theme.currentTheme.secondaryBGColor
VStack {
ForEach(MoodTints.defaultOptions, id: \.rawValue) { tint in
HStack {
ForEach(Mood.allValues, id: \.self) { mood in
Circle()
.frame(width: 35, height: 35)
.foregroundColor(
tint.color(forMood: mood)
)
}
.frame(minWidth: 0, maxWidth: .infinity)
}
.contentShape(Rectangle())
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(moodTint == tint ? theme.currentTheme.bgColor : .clear)
.padding([.top, .bottom], -3)
)
.onTapGesture {
let impactMed = UIImpactFeedbackGenerator(style: .heavy)
impactMed.impactOccurred()
moodTint = tint
EventLogger.log(event: "change_mood_tint_id", withData: ["id": tint.rawValue])
}
Divider()
}
ZStack {
Color.clear
Rectangle()
.frame(height: 35)
.frame(minWidth: 0, maxWidth: .infinity)
.foregroundColor(.clear)
.contentShape(Rectangle())
.onTapGesture {
moodTint = .Custom
let impactMed = UIImpactFeedbackGenerator(style: .heavy)
impactMed.impactOccurred()
}
HStack {
ColorPicker("", selection: $customMoodTint.colorOne)
.onChange(of: customMoodTint.colorOne) {
saveCustomMoodTint()
}
.labelsHidden()
.frame(minWidth: 0, maxWidth: .infinity)
ColorPicker("", selection: $customMoodTint.colorTwo)
.labelsHidden()
.frame(minWidth: 0, maxWidth: .infinity)
.onChange(of: customMoodTint.colorTwo) {
saveCustomMoodTint()
}
ColorPicker("", selection: $customMoodTint.colorThree)
.labelsHidden()
.frame(minWidth: 0, maxWidth: .infinity)
.onChange(of: customMoodTint.colorThree) {
saveCustomMoodTint()
}
ColorPicker("", selection: $customMoodTint.colorFour)
.labelsHidden()
.frame(minWidth: 0, maxWidth: .infinity)
.onChange(of: customMoodTint.colorFour) {
saveCustomMoodTint()
}
ColorPicker("", selection: $customMoodTint.colorFive)
.labelsHidden()
.frame(minWidth: 0, maxWidth: .infinity)
.onChange(of: customMoodTint.colorFive) {
saveCustomMoodTint()
}
}
.background(
Color.clear
)
}
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(moodTint == .Custom ? theme.currentTheme.bgColor : .clear)
.padding([.top, .bottom], -3)
)
}
.padding()
}
.fixedSize(horizontal: false, vertical: true)
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
}
private func saveCustomMoodTint() {
UserDefaultsStore.saveCustomMoodTint(customTint: customMoodTint)
moodTint = .Custom
EventLogger.log(event: "change_mood_tint_id", withData: ["id": MoodTints.Custom.rawValue])
customMoodTintUpdateNumber += 1
}
}
struct TintPickerView_Previews: PreviewProvider {
static var previews: some View {
TintPickerView()
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,8 +12,9 @@ struct ImagePickerGridView: View {
@Environment(\.presentationMode) var presentationMode
@State var column = Array(repeating: GridItem(.flexible(), spacing: 10), count: 7)
let pickedImageClosure: ((CustomWidgeImageOptions) -> Void)
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
private var textColor: Color { theme.currentTheme.labelColor }
let imageOptions = CustomWidgeImageOptions.allCases.sorted(by: {
$0.rawValue < $1.rawValue
})

View File

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

View File

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

View File

@@ -11,7 +11,8 @@ struct MonthDetailView: View {
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@AppStorage(UserDefaultsStore.Keys.deleteEnable.rawValue, store: GroupUserDefaults.groupDefaults) private var deleteEnabled = true
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
private var textColor: Color { theme.currentTheme.labelColor }
@StateObject private var shareImage = ShareImageStateViewModel()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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