diff --git a/Shared/Color+Codable.swift b/Shared/Color+Codable.swift index 4ad023d..324166e 100644 --- a/Shared/Color+Codable.swift +++ b/Shared/Color+Codable.swift @@ -99,6 +99,40 @@ extension String { } } +// MARK: - Luminance & Contrast + +extension Color { + /// Calculate relative luminance using WCAG formula + /// Returns value between 0 (darkest) and 1 (lightest) + var luminance: Double { + guard let components = cgColor?.components, components.count >= 3 else { + return 0.5 // Default to mid-gray if can't determine + } + + let r = components[0] + let g = components[1] + let b = components[2] + + // Apply gamma correction per WCAG 2.0 + func adjust(_ component: CGFloat) -> Double { + let c = Double(component) + return c <= 0.03928 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4) + } + + return 0.2126 * adjust(r) + 0.7152 * adjust(g) + 0.0722 * adjust(b) + } + + /// Returns true if the color is considered "light" (needs dark text for contrast) + var isLight: Bool { + luminance > 0.5 + } + + /// Returns black or white depending on which provides better contrast + var contrastingTextColor: Color { + isLight ? .black : .white + } +} + extension Color: @retroactive RawRepresentable { // TODO: Sort out alpha public init?(rawValue: Int) { diff --git a/Shared/Models/MoodTintable.swift b/Shared/Models/MoodTintable.swift index d01f621..f1389d5 100644 --- a/Shared/Models/MoodTintable.swift +++ b/Shared/Models/MoodTintable.swift @@ -53,7 +53,12 @@ enum MoodTints: Int, CaseIterable { return PastelTint.secondary(forMood: mood) } } - + + /// Returns black or white text color based on the mood's background luminance + func contrastingTextColor(forMood mood: Mood) -> Color { + color(forMood: mood).contrastingTextColor + } + var moodTints: MoodTintable.Type { switch self { case .Default: diff --git a/Shared/Views/CustomizeView/SubViews/AppThemePickerView.swift b/Shared/Views/CustomizeView/SubViews/AppThemePickerView.swift index 072dc87..9870488 100644 --- a/Shared/Views/CustomizeView/SubViews/AppThemePickerView.swift +++ b/Shared/Views/CustomizeView/SubViews/AppThemePickerView.swift @@ -252,8 +252,7 @@ struct AppThemePreviewSheet: View { } } } - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) + .presentationDetents([.large]) } private var heroSection: some View { diff --git a/Shared/Views/EntryListView.swift b/Shared/Views/EntryListView.swift index 945e9c9..2d144e5 100644 --- a/Shared/Views/EntryListView.swift +++ b/Shared/Views/EntryListView.swift @@ -21,6 +21,11 @@ struct EntryListView: View { moodTint.color(forMood: entry.mood) } + /// Text color that contrasts with the mood's background color + private var moodContrastingTextColor: Color { + moodTint.contrastingTextColor(forMood: entry.mood) + } + private var isMissing: Bool { entry.moodValue == Mood.missing.rawValue } @@ -109,7 +114,7 @@ struct EntryListView: View { .resizable() .aspectRatio(contentMode: .fit) .frame(width: 32, height: 32) - .foregroundColor(isMissing ? .gray : .white) + .foregroundColor(isMissing ? .gray : moodContrastingTextColor) .accessibilityLabel(entry.mood.strValue) } .shadow(color: isMissing ? .clear : moodColor.opacity(0.4), radius: 8, x: 0, y: 4) @@ -284,13 +289,13 @@ struct EntryListView: View { .resizable() .aspectRatio(contentMode: .fit) .frame(width: 28, height: 28) - .foregroundColor(isMissing ? .gray : .white) + .foregroundColor(isMissing ? .gray : moodContrastingTextColor) .accessibilityLabel(entry.mood.strValue) VStack(alignment: .leading, spacing: 2) { Text(entry.forDate, format: .dateTime.weekday(.wide).day()) .font(.subheadline.weight(.semibold)) - .foregroundColor(isMissing ? textColor : .white) + .foregroundColor(isMissing ? textColor : moodContrastingTextColor) if isMissing { Text(String(localized: "mood_value_missing_tap_to_add")) @@ -299,7 +304,7 @@ struct EntryListView: View { } else { Text(entry.moodString) .font(.caption.weight(.medium)) - .foregroundColor(.white.opacity(0.85)) + .foregroundColor(moodContrastingTextColor.opacity(0.85)) } } @@ -307,7 +312,7 @@ struct EntryListView: View { Image(systemName: "chevron.right") .font(.caption.weight(.semibold)) - .foregroundColor(isMissing ? textColor.opacity(0.3) : .white.opacity(0.6)) + .foregroundColor(isMissing ? textColor.opacity(0.3) : moodContrastingTextColor.opacity(0.6)) } .padding(.horizontal, 18) .padding(.vertical, 14) @@ -345,7 +350,7 @@ struct EntryListView: View { .resizable() .aspectRatio(contentMode: .fit) .padding(16) - .foregroundColor(isMissing ? .gray : .white) + .foregroundColor(isMissing ? .gray : moodContrastingTextColor) .accessibilityLabel(entry.mood.strValue) } .shadow( @@ -429,7 +434,7 @@ struct EntryListView: View { .resizable() .aspectRatio(contentMode: .fit) .frame(width: 20, height: 20) - .foregroundColor(isMissing ? .gray : .white) + .foregroundColor(isMissing ? .gray : moodContrastingTextColor) .accessibilityLabel(entry.mood.strValue) } @@ -974,7 +979,7 @@ struct EntryListView: View { .resizable() .aspectRatio(contentMode: .fit) .frame(width: 26, height: 26) - .foregroundColor(isMissing ? .gray : .white) + .foregroundColor(isMissing ? .gray : moodContrastingTextColor) .accessibilityLabel(entry.mood.strValue) } @@ -1182,7 +1187,7 @@ struct EntryListView: View { .resizable() .aspectRatio(contentMode: .fit) .frame(width: 24, height: 24) - .foregroundColor(.white) + .foregroundColor(moodContrastingTextColor) .accessibilityLabel(entry.mood.strValue) } @@ -1379,7 +1384,7 @@ struct EntryListView: View { .resizable() .aspectRatio(contentMode: .fit) .frame(width: 28, height: 28) - .foregroundColor(isMissing ? .gray : .white) + .foregroundColor(isMissing ? .gray : moodContrastingTextColor) .accessibilityLabel(entry.mood.strValue) .padding(.leading, 16) @@ -1390,7 +1395,7 @@ struct EntryListView: View { } else { Text(entry.moodString) .font(.body.weight(.semibold)) - .foregroundColor(.white) + .foregroundColor(moodContrastingTextColor) } Spacer() @@ -1398,7 +1403,7 @@ struct EntryListView: View { // Month indicator Text(entry.forDate, format: .dateTime.month(.abbreviated)) .font(.caption2.weight(.bold)) - .foregroundColor(isMissing ? .gray : .white.opacity(0.7)) + .foregroundColor(isMissing ? .gray : moodContrastingTextColor.opacity(0.7)) .padding(.trailing, 16) } } @@ -1426,7 +1431,7 @@ struct EntryListView: View { .resizable() .aspectRatio(contentMode: .fit) .frame(width: 28, height: 28) - .foregroundColor(.white) + .foregroundColor(isMissing ? .gray : moodContrastingTextColor) .accessibilityLabel(entry.mood.strValue) } @@ -1585,7 +1590,7 @@ struct EntryListView: View { .resizable() .aspectRatio(contentMode: .fit) .frame(width: 22, height: 22) - .foregroundColor(.white) + .foregroundColor(isMissing ? .gray : moodContrastingTextColor) .shadow(color: Color.black.opacity(0.3), radius: 1, x: 0, y: 1) .accessibilityLabel(entry.mood.strValue) } @@ -1718,7 +1723,7 @@ struct EntryListView: View { .resizable() .aspectRatio(contentMode: .fit) .frame(width: 24, height: 24) - .foregroundColor(isMissing ? .gray : .white) + .foregroundColor(isMissing ? .gray : moodContrastingTextColor) .accessibilityLabel(entry.mood.strValue) } @@ -1946,7 +1951,7 @@ struct OrbitEntryView: View { .resizable() .aspectRatio(contentMode: .fit) .frame(width: 24, height: 24) - .foregroundColor(.white) + .foregroundColor(isMissing ? .gray : moodColor.contrastingTextColor) .accessibilityLabel(entry.mood.strValue) } @@ -2130,7 +2135,7 @@ struct MotionCardView: View { .resizable() .aspectRatio(contentMode: .fit) .frame(width: 30, height: 30) - .foregroundColor(.white) + .foregroundColor(isMissing ? .gray : moodColor.contrastingTextColor) .offset( x: -motionManager.xOffset * 0.3, y: -motionManager.yOffset * 0.3