- Wrap 30+ production print() statements in #if DEBUG guards across 18 files - Add VoiceOver labels, hints, and traits to Watch app, Live Activities, widgets - Add .accessibilityAddTraits(.isButton) to 15+ onTapGesture views - Add text alternatives for color-only indicators (progress dots, mood circles) - Localize raw string literals in NoteEditorView, EntryDetailView, widgets - Replace 25+ silent try? with do/catch + AppLogger error logging - Replace hardcoded font sizes with semantic Dynamic Type fonts - Fix FIXME in IconPickerView (log icon change errors) - Extract magic animation delays to named constants across 8 files - Add widget empty state "Log your first mood!" messaging - Hide decorative images from VoiceOver, add labels to ColorPickers - Remove stale TODO in Color+Codable (alpha change deferred for migration) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2328 lines
90 KiB
Swift
2328 lines
90 KiB
Swift
//
|
|
// EntryListView.swift
|
|
// Reflect (iOS)
|
|
//
|
|
// Created by Trey Tartt on 3/6/22.
|
|
//
|
|
|
|
import SwiftUI
|
|
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.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 {
|
|
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
|
|
}
|
|
|
|
private var hasNotes: Bool {
|
|
if let notes = entry.notes, !notes.isEmpty { return true }
|
|
return false
|
|
}
|
|
|
|
private var hasReflection: Bool {
|
|
if let json = entry.reflectionJSON,
|
|
let reflection = GuidedReflection.decode(from: json),
|
|
reflection.answeredCount > 0 { return true }
|
|
return false
|
|
}
|
|
|
|
// 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 {
|
|
case .classic:
|
|
classicStyle
|
|
case .minimal:
|
|
minimalStyle
|
|
case .compact:
|
|
compactStyle
|
|
case .bubble:
|
|
bubbleStyle
|
|
case .grid:
|
|
gridStyle
|
|
case .aura:
|
|
auraStyle
|
|
case .chronicle:
|
|
chronicleStyle
|
|
case .neon:
|
|
neonStyle
|
|
case .ink:
|
|
inkStyle
|
|
case .prism:
|
|
prismStyle
|
|
case .tape:
|
|
tapeStyle
|
|
case .morph:
|
|
morphStyle
|
|
case .stack:
|
|
stackStyle
|
|
case .wave:
|
|
waveStyle
|
|
case .pattern:
|
|
patternStyle
|
|
case .leather:
|
|
leatherStyle
|
|
case .glass:
|
|
glassStyle
|
|
case .motion:
|
|
motionStyle
|
|
case .micro:
|
|
microStyle
|
|
case .orbit:
|
|
orbitStyle
|
|
}
|
|
}
|
|
.overlay(alignment: .bottomTrailing) {
|
|
if !isMissing && (hasNotes || hasReflection) {
|
|
HStack(spacing: 4) {
|
|
if hasNotes {
|
|
Image(systemName: "note.text")
|
|
.font(.caption2)
|
|
.accessibilityHidden(true)
|
|
}
|
|
if hasReflection {
|
|
Image(systemName: "sparkles")
|
|
.font(.caption2)
|
|
.accessibilityHidden(true)
|
|
}
|
|
}
|
|
.foregroundStyle(.secondary)
|
|
.padding(.trailing, 12)
|
|
.padding(.bottom, 6)
|
|
}
|
|
}
|
|
.accessibilityElement(children: .combine)
|
|
.accessibilityIdentifier(AccessibilityID.DayView.entryRow(dateString: cachedYearMonthDayDigits))
|
|
.accessibilityLabel(accessibilityDescription)
|
|
.accessibilityHint(isMissing ? String(localized: "Tap to log mood for this day") : String(localized: "Tap to view or edit"))
|
|
.accessibilityAddTraits(.isButton)
|
|
}
|
|
|
|
private var accessibilityDescription: String {
|
|
let dateString = DateFormattingCache.shared.string(for: entry.forDate, format: .dateFull)
|
|
|
|
if isMissing {
|
|
return String(localized: "\(dateString), no mood logged")
|
|
} else {
|
|
var description = "\(dateString), \(entry.mood.strValue)"
|
|
if hasNotes { description += String(localized: ", has notes") }
|
|
if hasReflection { description += String(localized: ", has reflection") }
|
|
return description
|
|
}
|
|
}
|
|
|
|
// MARK: - Classic Style (Original)
|
|
private var classicStyle: some View {
|
|
HStack(spacing: 16) {
|
|
// Large mood icon with gradient background
|
|
ZStack {
|
|
Circle()
|
|
.fill(
|
|
LinearGradient(
|
|
colors: isMissing
|
|
? [Color.gray.opacity(0.3), Color.gray.opacity(0.1)]
|
|
: [moodColor.opacity(0.8), moodColor.opacity(0.4)],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
)
|
|
.frame(width: 56, height: 56)
|
|
|
|
imagePack.icon(forMood: entry.mood)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 32, height: 32)
|
|
.foregroundColor(isMissing ? .gray : moodContrastingTextColor)
|
|
.accessibilityLabel(entry.mood.strValue)
|
|
}
|
|
.shadow(color: isMissing ? .clear : moodColor.opacity(0.4), radius: 8, x: 0, y: 4)
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack(spacing: 6) {
|
|
Text(Random.weekdayName(fromDate: entry.forDate))
|
|
.font(.body.weight(.semibold))
|
|
.foregroundColor(textColor)
|
|
|
|
Text("•")
|
|
.foregroundColor(textColor.opacity(0.4))
|
|
|
|
Text(Random.dayFormat(fromDate: entry.forDate))
|
|
.font(.body.weight(.medium))
|
|
.foregroundColor(textColor.opacity(0.8))
|
|
}
|
|
|
|
if isMissing {
|
|
Text(String(localized: "mood_value_missing_tap_to_add"))
|
|
.font(.subheadline.weight(.medium))
|
|
.foregroundColor(.gray)
|
|
} else {
|
|
Text(entry.moodString)
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundColor(moodColor)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 4)
|
|
.background(
|
|
Capsule()
|
|
.fill(moodColor.opacity(0.15))
|
|
)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Image(systemName: "chevron.right")
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundColor(textColor.opacity(0.3))
|
|
}
|
|
.padding(.horizontal, 18)
|
|
.padding(.vertical, 16)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 18)
|
|
.fill(colorScheme == .dark ? Color(.systemGray6) : .white)
|
|
.shadow(
|
|
color: isMissing ? .clear : moodColor.opacity(colorScheme == .dark ? 0.2 : 0.12),
|
|
radius: 12,
|
|
x: 0,
|
|
y: 4
|
|
)
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 18)
|
|
.stroke(
|
|
isMissing
|
|
? Color.gray.opacity(0.2)
|
|
: moodColor.opacity(0.2),
|
|
lineWidth: 1
|
|
)
|
|
)
|
|
}
|
|
|
|
// MARK: - Minimal Style
|
|
private var minimalStyle: some View {
|
|
HStack(spacing: 14) {
|
|
// Simple flat circle with icon
|
|
Circle()
|
|
.fill(isMissing ? Color.gray.opacity(0.15) : moodColor.opacity(0.15))
|
|
.frame(width: 44, height: 44)
|
|
.overlay(
|
|
imagePack.icon(forMood: entry.mood)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 22, height: 22)
|
|
.foregroundColor(isMissing ? .gray : moodColor)
|
|
.accessibilityLabel(entry.mood.strValue)
|
|
)
|
|
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(cachedWeekdayWideDay)
|
|
.font(.subheadline.weight(.medium))
|
|
.foregroundColor(textColor)
|
|
|
|
if isMissing {
|
|
Text(String(localized: "mood_value_missing_tap_to_add"))
|
|
.font(.caption)
|
|
.foregroundColor(.gray)
|
|
} else {
|
|
Text(entry.moodString)
|
|
.font(.caption.weight(.medium))
|
|
.foregroundColor(moodColor)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 14)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.fill(colorScheme == .dark ? Color(.systemGray6) : .white)
|
|
)
|
|
}
|
|
|
|
// MARK: - Compact Style (Timeline)
|
|
private var compactStyle: some View {
|
|
HStack(spacing: 12) {
|
|
// Timeline indicator
|
|
VStack(spacing: 0) {
|
|
Circle()
|
|
.fill(isMissing ? Color.gray.opacity(0.4) : moodColor)
|
|
.frame(width: 12, height: 12)
|
|
}
|
|
|
|
// Date column
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
Text(cachedDay)
|
|
.font(.title3.weight(.bold))
|
|
.foregroundColor(textColor)
|
|
Text(cachedWeekdayAbbrev)
|
|
.font(.caption2.weight(.medium))
|
|
.foregroundColor(textColor.opacity(0.5))
|
|
.textCase(.uppercase)
|
|
}
|
|
.frame(width: 36)
|
|
|
|
// Mood indicator bar
|
|
if isMissing {
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.fill(Color.gray.opacity(0.15))
|
|
.frame(height: 32)
|
|
.overlay(
|
|
Text(String(localized: "mood_value_missing_tap_to_add"))
|
|
.font(.caption.weight(.medium))
|
|
.foregroundColor(.gray)
|
|
)
|
|
} else {
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.fill(moodColor.opacity(0.2))
|
|
.frame(height: 32)
|
|
.overlay(
|
|
HStack(spacing: 6) {
|
|
imagePack.icon(forMood: entry.mood)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 16, height: 16)
|
|
.foregroundColor(moodColor)
|
|
.accessibilityLabel(entry.mood.strValue)
|
|
|
|
Text(entry.moodString)
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundColor(moodColor)
|
|
}
|
|
)
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 10)
|
|
}
|
|
|
|
// MARK: - Bubble Style
|
|
private var bubbleStyle: some View {
|
|
HStack(spacing: 0) {
|
|
// Full-width colored background
|
|
HStack(spacing: 14) {
|
|
// Icon
|
|
imagePack.icon(forMood: entry.mood)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 28, height: 28)
|
|
.foregroundColor(isMissing ? .gray : moodContrastingTextColor)
|
|
.accessibilityLabel(entry.mood.strValue)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(cachedWeekdayWideDay)
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundColor(isMissing ? textColor : moodContrastingTextColor)
|
|
|
|
if isMissing {
|
|
Text(String(localized: "mood_value_missing_tap_to_add"))
|
|
.font(.caption)
|
|
.foregroundColor(.gray)
|
|
} else {
|
|
Text(entry.moodString)
|
|
.font(.caption.weight(.medium))
|
|
.foregroundColor(moodContrastingTextColor.opacity(0.85))
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundColor(isMissing ? textColor.opacity(0.3) : moodContrastingTextColor.opacity(0.6))
|
|
}
|
|
.padding(.horizontal, 18)
|
|
.padding(.vertical, 14)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 16)
|
|
.fill(
|
|
isMissing
|
|
? (colorScheme == .dark ? Color(.systemGray5) : Color(.systemGray6))
|
|
: moodColor
|
|
)
|
|
)
|
|
.shadow(
|
|
color: isMissing ? .clear : moodColor.opacity(0.35),
|
|
radius: 8,
|
|
x: 0,
|
|
y: 4
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Grid Style (3 per row)
|
|
private var gridStyle: some View {
|
|
VStack(spacing: 6) {
|
|
// Mood icon circle
|
|
ZStack {
|
|
Circle()
|
|
.fill(
|
|
isMissing
|
|
? Color.gray.opacity(0.2)
|
|
: moodColor
|
|
)
|
|
.aspectRatio(1, contentMode: .fit)
|
|
|
|
imagePack.icon(forMood: entry.mood)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.padding(16)
|
|
.foregroundColor(isMissing ? .gray : moodContrastingTextColor)
|
|
.accessibilityLabel(entry.mood.strValue)
|
|
}
|
|
.shadow(
|
|
color: isMissing ? .clear : moodColor.opacity(0.3),
|
|
radius: 6,
|
|
x: 0,
|
|
y: 3
|
|
)
|
|
|
|
// Day number
|
|
Text(cachedDay)
|
|
.font(.subheadline.weight(.bold))
|
|
.foregroundColor(textColor)
|
|
|
|
// Weekday abbreviation
|
|
Text(cachedWeekdayAbbrev)
|
|
.font(.caption2.weight(.medium))
|
|
.foregroundColor(textColor.opacity(0.5))
|
|
.textCase(.uppercase)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 12)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 14)
|
|
.fill(colorScheme == .dark ? Color(.systemGray6) : .white)
|
|
)
|
|
}
|
|
|
|
// MARK: - Aura Style (Atmospheric glowing entries)
|
|
private var auraStyle: some View {
|
|
HStack(spacing: 0) {
|
|
// Giant day number - the visual hero
|
|
Text(cachedDay)
|
|
.font(.largeTitle.weight(.black))
|
|
.foregroundStyle(
|
|
isMissing
|
|
? LinearGradient(colors: [Color.gray.opacity(0.3), Color.gray.opacity(0.15)], startPoint: .top, endPoint: .bottom)
|
|
: LinearGradient(colors: [moodColor, moodColor.opacity(0.6)], startPoint: .top, endPoint: .bottom)
|
|
)
|
|
.frame(width: 90)
|
|
.shadow(color: isMissing ? .clear : moodColor.opacity(0.5), radius: 20, x: 0, y: 0)
|
|
|
|
// Content area with glowing aura
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
// Weekday with elegant typography
|
|
Text(cachedWeekdayWide)
|
|
.font(.caption.weight(.semibold))
|
|
.textCase(.uppercase)
|
|
.tracking(2)
|
|
.foregroundColor(textColor.opacity(0.5))
|
|
|
|
// Mood display with icon
|
|
HStack(spacing: 10) {
|
|
// Glowing mood orb
|
|
ZStack {
|
|
// Outer glow
|
|
Circle()
|
|
.fill(
|
|
RadialGradient(
|
|
colors: isMissing
|
|
? [Color.gray.opacity(0.2), Color.clear]
|
|
: [moodColor.opacity(0.4), Color.clear],
|
|
center: .center,
|
|
startRadius: 0,
|
|
endRadius: 30
|
|
)
|
|
)
|
|
.frame(width: 60, height: 60)
|
|
|
|
// Inner solid circle
|
|
Circle()
|
|
.fill(
|
|
isMissing
|
|
? Color.gray.opacity(0.3)
|
|
: moodColor
|
|
)
|
|
.frame(width: 38, height: 38)
|
|
|
|
// Icon
|
|
imagePack.icon(forMood: entry.mood)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 20, height: 20)
|
|
.foregroundColor(isMissing ? .gray : moodContrastingTextColor)
|
|
.accessibilityLabel(entry.mood.strValue)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
if isMissing {
|
|
Text(String(localized: "mood_value_missing_tap_to_add"))
|
|
.font(.subheadline.weight(.medium))
|
|
.foregroundColor(.gray)
|
|
} else {
|
|
Text(entry.moodString)
|
|
.font(.title3.weight(.bold))
|
|
.foregroundColor(textColor)
|
|
|
|
// Month context
|
|
Text(cachedMonthWide)
|
|
.font(.caption.weight(.medium))
|
|
.foregroundColor(textColor.opacity(0.4))
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Subtle indicator
|
|
if !isMissing {
|
|
Circle()
|
|
.fill(moodColor)
|
|
.frame(width: 8, height: 8)
|
|
.shadow(color: moodColor, radius: 4, x: 0, y: 0)
|
|
}
|
|
}
|
|
}
|
|
.padding(.leading, 4)
|
|
.padding(.trailing, 16)
|
|
}
|
|
.padding(.vertical, 20)
|
|
.padding(.horizontal, 16)
|
|
.background(
|
|
ZStack {
|
|
// Base layer
|
|
RoundedRectangle(cornerRadius: 24)
|
|
.fill(colorScheme == .dark ? Color(.systemGray6) : .white)
|
|
|
|
// Mood glow overlay (subtle gradient at edges)
|
|
if !isMissing {
|
|
RoundedRectangle(cornerRadius: 24)
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [
|
|
moodColor.opacity(colorScheme == .dark ? 0.15 : 0.08),
|
|
Color.clear,
|
|
Color.clear
|
|
],
|
|
startPoint: .leading,
|
|
endPoint: .trailing
|
|
)
|
|
)
|
|
}
|
|
}
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 24)
|
|
.stroke(
|
|
isMissing
|
|
? Color.gray.opacity(0.15)
|
|
: moodColor.opacity(colorScheme == .dark ? 0.3 : 0.2),
|
|
lineWidth: 1
|
|
)
|
|
)
|
|
.shadow(
|
|
color: isMissing ? Color.black.opacity(0.05) : moodColor.opacity(colorScheme == .dark ? 0.25 : 0.15),
|
|
radius: 16,
|
|
x: 0,
|
|
y: 8
|
|
)
|
|
}
|
|
|
|
// MARK: - Chronicle Style (Editorial/Magazine)
|
|
private var chronicleStyle: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
// Top rule line
|
|
Rectangle()
|
|
.fill(isMissing ? Color.gray.opacity(0.3) : moodColor)
|
|
.frame(height: 3)
|
|
|
|
HStack(alignment: .top, spacing: 16) {
|
|
// Left column: Giant day number in serif
|
|
VStack(alignment: .trailing, spacing: 0) {
|
|
Text(cachedDay)
|
|
.font(.largeTitle.weight(.regular))
|
|
.foregroundColor(textColor)
|
|
.frame(width: 80)
|
|
|
|
Text(cachedWeekdayAbbrevMonthAbbrev)
|
|
.font(.caption2.weight(.regular))
|
|
.italic()
|
|
.foregroundColor(textColor.opacity(0.5))
|
|
.textCase(.uppercase)
|
|
}
|
|
|
|
// Vertical divider
|
|
Rectangle()
|
|
.fill(textColor.opacity(0.15))
|
|
.frame(width: 1)
|
|
|
|
// Right column: Mood content
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
if isMissing {
|
|
Text("Entry Missing")
|
|
.font(.title2.weight(.regular))
|
|
.italic()
|
|
.foregroundColor(.gray)
|
|
|
|
Text("Tap to record your mood for this day")
|
|
.font(.caption.weight(.regular))
|
|
.foregroundColor(.gray.opacity(0.7))
|
|
} else {
|
|
// Pull-quote style mood name
|
|
HStack(spacing: 8) {
|
|
Rectangle()
|
|
.fill(moodColor)
|
|
.frame(width: 4)
|
|
|
|
Text("\"\(entry.moodString)\"")
|
|
.font(.title.weight(.regular))
|
|
.italic()
|
|
.foregroundColor(textColor)
|
|
}
|
|
|
|
// Icon and descriptor
|
|
HStack(spacing: 10) {
|
|
imagePack.icon(forMood: entry.mood)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 20, height: 20)
|
|
.foregroundColor(moodColor)
|
|
.accessibilityLabel(entry.mood.strValue)
|
|
|
|
Text("Recorded mood entry")
|
|
.font(.caption.weight(.regular))
|
|
.foregroundColor(textColor.opacity(0.5))
|
|
.textCase(.uppercase)
|
|
.tracking(1.5)
|
|
}
|
|
}
|
|
}
|
|
.padding(.vertical, 8)
|
|
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
|
|
// Bottom rule line (thinner)
|
|
Rectangle()
|
|
.fill(textColor.opacity(0.1))
|
|
.frame(height: 1)
|
|
}
|
|
.background(colorScheme == .dark ? Color(.systemGray6) : .white)
|
|
}
|
|
|
|
// MARK: - Neon Style (Synthwave Arcade)
|
|
private var neonStyle: some View {
|
|
let neonCyan = Color(red: 0.0, green: 1.0, blue: 0.82)
|
|
let neonMagenta = Color(red: 1.0, green: 0.0, blue: 0.8)
|
|
let deepBlack = Color(red: 0.02, green: 0.02, blue: 0.04)
|
|
|
|
// Map mood to synthwave color spectrum
|
|
let synthwaveColor: Color = {
|
|
switch entry.mood {
|
|
case .great: return neonCyan
|
|
case .good: return Color(red: 0.0, green: 0.9, blue: 0.6) // Cyan-green
|
|
case .average: return Color(red: 0.9, green: 0.9, blue: 0.2) // Neon yellow
|
|
case .bad: return Color(red: 1.0, green: 0.5, blue: 0.3) // Neon orange
|
|
case .horrible: return neonMagenta
|
|
default: return Color(red: 0.9, green: 0.9, blue: 0.2) // Fallback yellow
|
|
}
|
|
}()
|
|
|
|
return ZStack {
|
|
// Deep black base
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.fill(deepBlack)
|
|
|
|
// Grid background pattern
|
|
Canvas { context, size in
|
|
let gridSpacing: CGFloat = 12
|
|
let gridColor = neonCyan.opacity(0.08)
|
|
|
|
// Horizontal grid lines
|
|
for y in stride(from: 0, through: size.height, by: gridSpacing) {
|
|
var path = Path()
|
|
path.move(to: CGPoint(x: 0, y: y))
|
|
path.addLine(to: CGPoint(x: size.width, y: y))
|
|
context.stroke(path, with: .color(gridColor), lineWidth: 0.5)
|
|
}
|
|
|
|
// Vertical grid lines
|
|
for x in stride(from: 0, through: size.width, by: gridSpacing) {
|
|
var path = Path()
|
|
path.move(to: CGPoint(x: x, y: 0))
|
|
path.addLine(to: CGPoint(x: x, y: size.height))
|
|
context.stroke(path, with: .color(gridColor), lineWidth: 0.5)
|
|
}
|
|
}
|
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
|
|
|
// 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))
|
|
|
|
// Content
|
|
HStack(spacing: 16) {
|
|
// Neon equalizer-style mood indicator
|
|
ZStack {
|
|
// Outer glow ring
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.stroke(
|
|
LinearGradient(
|
|
colors: isMissing
|
|
? [Color.gray.opacity(0.3)]
|
|
: [neonCyan.opacity(0.6), neonMagenta.opacity(0.6)],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
),
|
|
lineWidth: 2
|
|
)
|
|
.blur(radius: 4)
|
|
|
|
// Inner border
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.stroke(
|
|
LinearGradient(
|
|
colors: isMissing
|
|
? [Color.gray.opacity(0.4)]
|
|
: [neonCyan, neonMagenta],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
),
|
|
lineWidth: 1.5
|
|
)
|
|
|
|
// Mood icon with synthwave glow
|
|
imagePack.icon(forMood: entry.mood)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 28, height: 28)
|
|
.foregroundColor(isMissing ? .gray : synthwaveColor)
|
|
.shadow(color: isMissing ? .clear : synthwaveColor.opacity(0.9), radius: 8, x: 0, y: 0)
|
|
.shadow(color: isMissing ? .clear : synthwaveColor.opacity(0.5), radius: 16, x: 0, y: 0)
|
|
.accessibilityLabel(entry.mood.strValue)
|
|
}
|
|
.frame(width: 54, height: 54)
|
|
|
|
VStack(alignment: .leading, spacing: 5) {
|
|
// Date in cyan monospace
|
|
Text(cachedYearMonthDayDigits)
|
|
.font(.system(.caption, design: .monospaced).weight(.semibold))
|
|
.foregroundColor(neonCyan)
|
|
.shadow(color: neonCyan.opacity(0.5), radius: 4, x: 0, y: 0)
|
|
|
|
if isMissing {
|
|
Text("NO_DATA")
|
|
.font(.system(.headline, design: .monospaced).weight(.black))
|
|
.foregroundColor(.gray.opacity(0.6))
|
|
} else {
|
|
// Mood text with synthwave gradient glow
|
|
Text(entry.moodString.uppercased())
|
|
.font(.system(.headline, design: .monospaced).weight(.black))
|
|
.foregroundStyle(
|
|
LinearGradient(
|
|
colors: [neonCyan, synthwaveColor, neonMagenta],
|
|
startPoint: .leading,
|
|
endPoint: .trailing
|
|
)
|
|
)
|
|
.shadow(color: synthwaveColor.opacity(0.8), radius: 6, x: 0, y: 0)
|
|
.shadow(color: neonMagenta.opacity(0.3), radius: 12, x: 0, y: 0)
|
|
}
|
|
|
|
// Weekday in magenta
|
|
Text(cachedWeekdayWide)
|
|
.font(.system(.caption2, design: .monospaced).weight(.medium))
|
|
.foregroundColor(neonMagenta.opacity(0.7))
|
|
.textCase(.uppercase)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Equalizer bars indicator (mini visualization)
|
|
if !isMissing {
|
|
HStack(spacing: 2) {
|
|
ForEach(0..<5, id: \.self) { index in
|
|
let barHeight: CGFloat = {
|
|
let moodIndex = Mood.allValues.firstIndex(of: entry.mood) ?? 2
|
|
let heights: [[CGFloat]] = [
|
|
[28, 22, 16, 10, 6], // Great
|
|
[24, 28, 18, 12, 8], // Good
|
|
[16, 20, 28, 20, 16], // Okay
|
|
[8, 12, 18, 28, 24], // Bad
|
|
[6, 10, 16, 22, 28] // Awful
|
|
]
|
|
return heights[moodIndex][index]
|
|
}()
|
|
|
|
RoundedRectangle(cornerRadius: 1)
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [neonCyan, neonMagenta],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
)
|
|
.frame(width: 3, height: barHeight)
|
|
.shadow(color: neonCyan.opacity(0.5), radius: 2, x: 0, y: 0)
|
|
}
|
|
}
|
|
.padding(.trailing, 4)
|
|
}
|
|
|
|
// Chevron with gradient glow
|
|
Image(systemName: "chevron.right")
|
|
.font(.subheadline.weight(.bold))
|
|
.foregroundStyle(
|
|
isMissing
|
|
? AnyShapeStyle(Color.gray.opacity(0.4))
|
|
: AnyShapeStyle(LinearGradient(
|
|
colors: [neonCyan, neonMagenta],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
))
|
|
)
|
|
.shadow(color: isMissing ? .clear : neonCyan.opacity(0.5), radius: 4, x: 0, y: 0)
|
|
}
|
|
.padding(.horizontal, 18)
|
|
.padding(.vertical, 14)
|
|
|
|
// Cyan-to-magenta gradient border
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.stroke(
|
|
LinearGradient(
|
|
colors: isMissing
|
|
? [Color.gray.opacity(0.2), Color.gray.opacity(0.1)]
|
|
: [neonCyan.opacity(0.7), neonMagenta.opacity(0.7)],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
),
|
|
lineWidth: 1
|
|
)
|
|
}
|
|
// Outer glow effect
|
|
.shadow(color: isMissing ? .clear : neonCyan.opacity(0.2), radius: 12, x: 0, y: 2)
|
|
.shadow(color: isMissing ? .clear : neonMagenta.opacity(0.15), radius: 20, x: 0, y: 4)
|
|
}
|
|
|
|
// MARK: - Ink Style (Japanese Zen/Calligraphy)
|
|
private var inkStyle: some View {
|
|
HStack(spacing: 20) {
|
|
// Ensō (Zen circle) representing mood
|
|
ZStack {
|
|
// Brush stroke circle effect
|
|
Circle()
|
|
.stroke(
|
|
isMissing
|
|
? Color.gray.opacity(0.2)
|
|
: moodColor.opacity(0.7),
|
|
style: StrokeStyle(lineWidth: 6, lineCap: .round)
|
|
)
|
|
.frame(width: 56, height: 56)
|
|
.rotationEffect(.degrees(-30))
|
|
|
|
// Gap in the circle (zen incomplete circle)
|
|
Circle()
|
|
.trim(from: 0, to: 0.85)
|
|
.stroke(
|
|
isMissing
|
|
? Color.gray.opacity(0.4)
|
|
: moodColor,
|
|
style: StrokeStyle(lineWidth: 4, lineCap: .round)
|
|
)
|
|
.frame(width: 44, height: 44)
|
|
.rotationEffect(.degrees(20))
|
|
|
|
// Small icon in center
|
|
imagePack.icon(forMood: entry.mood)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 18, height: 18)
|
|
.foregroundColor(isMissing ? .gray.opacity(0.5) : moodColor.opacity(0.8))
|
|
.accessibilityLabel(entry.mood.strValue)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
// Day number with brush-like weight variation
|
|
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
|
Text(cachedDay)
|
|
.font(.title.weight(.thin))
|
|
.foregroundColor(textColor)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(cachedMonthWide)
|
|
.font(.caption2.weight(.light))
|
|
.foregroundColor(textColor.opacity(0.5))
|
|
.textCase(.uppercase)
|
|
.tracking(2)
|
|
|
|
Text(cachedWeekdayWide)
|
|
.font(.caption2.weight(.light))
|
|
.foregroundColor(textColor.opacity(0.35))
|
|
}
|
|
}
|
|
|
|
if isMissing {
|
|
Text("—")
|
|
.font(.title3.weight(.ultraLight))
|
|
.foregroundColor(.gray.opacity(0.4))
|
|
} else {
|
|
// Mood in delicate typography
|
|
Text(entry.moodString)
|
|
.font(.body.weight(.light))
|
|
.foregroundColor(moodColor)
|
|
.tracking(1)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Subtle ink dot indicator
|
|
if !isMissing {
|
|
Circle()
|
|
.fill(moodColor.opacity(0.6))
|
|
.frame(width: 6, height: 6)
|
|
}
|
|
}
|
|
.padding(.horizontal, 24)
|
|
.padding(.vertical, 24)
|
|
.background(
|
|
ZStack {
|
|
// Paper-like texture base
|
|
RoundedRectangle(cornerRadius: 2)
|
|
.fill(colorScheme == .dark ? Color(.systemGray6) : Color(white: 0.98))
|
|
|
|
// Subtle ink wash at edge
|
|
if !isMissing {
|
|
HStack {
|
|
Rectangle()
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [moodColor.opacity(0.08), Color.clear],
|
|
startPoint: .leading,
|
|
endPoint: .trailing
|
|
)
|
|
)
|
|
.frame(width: 80)
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
)
|
|
.overlay(
|
|
// Thin ink border
|
|
RoundedRectangle(cornerRadius: 2)
|
|
.stroke(textColor.opacity(0.08), lineWidth: 0.5)
|
|
)
|
|
}
|
|
|
|
// MARK: - Prism Style (Premium Glassmorphism)
|
|
private var prismStyle: some View {
|
|
ZStack {
|
|
// Rainbow refraction edge effect
|
|
RoundedRectangle(cornerRadius: 20)
|
|
.fill(
|
|
AngularGradient(
|
|
colors: [.red, .orange, .yellow, .green, .blue, .purple, .red],
|
|
center: .center
|
|
)
|
|
)
|
|
.blur(radius: 8)
|
|
.opacity(isMissing ? 0 : 0.4)
|
|
|
|
// Frosted glass card
|
|
RoundedRectangle(cornerRadius: 20)
|
|
.fill(.ultraThinMaterial)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 20)
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [
|
|
Color.white.opacity(colorScheme == .dark ? 0.1 : 0.5),
|
|
Color.white.opacity(colorScheme == .dark ? 0.05 : 0.2)
|
|
],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
)
|
|
)
|
|
|
|
// Content
|
|
HStack(spacing: 16) {
|
|
// Glass orb with mood
|
|
ZStack {
|
|
Circle()
|
|
.fill(.ultraThinMaterial)
|
|
.frame(width: 56, height: 56)
|
|
|
|
Circle()
|
|
.fill(
|
|
RadialGradient(
|
|
colors: isMissing
|
|
? [Color.gray.opacity(0.3), Color.gray.opacity(0.1)]
|
|
: [moodColor.opacity(0.6), moodColor.opacity(0.2)],
|
|
center: .topLeading,
|
|
startRadius: 0,
|
|
endRadius: 40
|
|
)
|
|
)
|
|
.frame(width: 52, height: 52)
|
|
|
|
// Light reflection
|
|
Circle()
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [Color.white.opacity(0.6), Color.clear],
|
|
startPoint: .topLeading,
|
|
endPoint: .center
|
|
)
|
|
)
|
|
.frame(width: 52, height: 52)
|
|
.mask(
|
|
Circle()
|
|
.frame(width: 20, height: 20)
|
|
.offset(x: -10, y: -10)
|
|
)
|
|
|
|
imagePack.icon(forMood: entry.mood)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 26, height: 26)
|
|
.foregroundColor(isMissing ? .gray : moodContrastingTextColor)
|
|
.accessibilityLabel(entry.mood.strValue)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(cachedWeekdayWide)
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundColor(textColor)
|
|
|
|
HStack(spacing: 8) {
|
|
Text(cachedMonthAbbrevDay)
|
|
.font(.caption.weight(.medium))
|
|
.foregroundColor(textColor.opacity(0.6))
|
|
|
|
if !isMissing {
|
|
Capsule()
|
|
.fill(moodColor.opacity(0.2))
|
|
.frame(width: 4, height: 4)
|
|
|
|
Text(entry.moodString)
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundColor(moodColor)
|
|
} else {
|
|
Text("Tap to add")
|
|
.font(.caption)
|
|
.foregroundColor(.gray)
|
|
}
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Prismatic chevron
|
|
Image(systemName: "chevron.right")
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(
|
|
isMissing
|
|
? AnyShapeStyle(Color.gray.opacity(0.3))
|
|
: AnyShapeStyle(
|
|
LinearGradient(
|
|
colors: [moodColor, moodColor.opacity(0.5)],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
)
|
|
)
|
|
}
|
|
.padding(16)
|
|
}
|
|
.frame(height: 88)
|
|
.shadow(color: isMissing ? .clear : moodColor.opacity(0.2), radius: 20, x: 0, y: 10)
|
|
}
|
|
|
|
// MARK: - Tape Style (Retro Cassette/Mixtape)
|
|
private var tapeStyle: some View {
|
|
HStack(spacing: 0) {
|
|
// Track number column
|
|
VStack {
|
|
Text(cachedDay)
|
|
.font(.title2.weight(.bold).monospaced())
|
|
.foregroundColor(isMissing ? .gray : moodColor)
|
|
}
|
|
.frame(width: 50)
|
|
.padding(.vertical, 16)
|
|
.background(
|
|
Rectangle()
|
|
.fill(isMissing ? Color.gray.opacity(0.1) : moodColor.opacity(0.15))
|
|
)
|
|
|
|
// Tape reel visualization
|
|
HStack(spacing: 12) {
|
|
// Left reel
|
|
ZStack {
|
|
Circle()
|
|
.stroke(isMissing ? Color.gray.opacity(0.3) : moodColor.opacity(0.4), lineWidth: 3)
|
|
.frame(width: 32, height: 32)
|
|
|
|
Circle()
|
|
.fill(isMissing ? Color.gray.opacity(0.2) : moodColor.opacity(0.2))
|
|
.frame(width: 18, height: 18)
|
|
|
|
Circle()
|
|
.fill(colorScheme == .dark ? Color(.systemGray5) : .white)
|
|
.frame(width: 8, height: 8)
|
|
}
|
|
|
|
// Track info
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(cachedWeekdayWideMonthAbbrev)
|
|
.font(.caption2.weight(.medium).monospaced())
|
|
.foregroundColor(textColor.opacity(0.5))
|
|
.textCase(.uppercase)
|
|
|
|
if isMissing {
|
|
Text("SIDE B - NO RECORDING")
|
|
.font(.subheadline.weight(.bold))
|
|
.foregroundColor(.gray)
|
|
} else {
|
|
Text(entry.moodString.uppercased())
|
|
.font(.subheadline.weight(.black))
|
|
.foregroundColor(textColor)
|
|
.tracking(1)
|
|
}
|
|
|
|
// Tape progress bar
|
|
GeometryReader { geo in
|
|
ZStack(alignment: .leading) {
|
|
Capsule()
|
|
.fill(textColor.opacity(0.1))
|
|
.frame(height: 4)
|
|
|
|
Capsule()
|
|
.fill(isMissing ? Color.gray : moodColor)
|
|
.frame(width: geo.size.width * (isMissing ? 0.2 : 0.7), height: 4)
|
|
}
|
|
}
|
|
.frame(height: 4)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Right reel
|
|
ZStack {
|
|
Circle()
|
|
.stroke(isMissing ? Color.gray.opacity(0.3) : moodColor.opacity(0.4), lineWidth: 3)
|
|
.frame(width: 32, height: 32)
|
|
|
|
imagePack.icon(forMood: entry.mood)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 16, height: 16)
|
|
.foregroundColor(isMissing ? .gray : moodColor)
|
|
.accessibilityLabel(entry.mood.strValue)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 14)
|
|
}
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.fill(colorScheme == .dark ? Color(.systemGray6) : Color(white: 0.96))
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.stroke(isMissing ? Color.gray.opacity(0.2) : moodColor.opacity(0.3), lineWidth: 1)
|
|
)
|
|
}
|
|
|
|
// MARK: - Morph Style (Liquid Organic Blobs)
|
|
private var morphStyle: some View {
|
|
ZStack {
|
|
// Organic blob background
|
|
GeometryReader { geo in
|
|
ZStack {
|
|
// Main blob
|
|
Ellipse()
|
|
.fill(
|
|
RadialGradient(
|
|
colors: isMissing
|
|
? [Color.gray.opacity(0.2), Color.gray.opacity(0.05)]
|
|
: [moodColor.opacity(0.5), moodColor.opacity(0.1)],
|
|
center: .center,
|
|
startRadius: 0,
|
|
endRadius: geo.size.width * 0.6
|
|
)
|
|
)
|
|
.frame(width: geo.size.width * 0.9, height: geo.size.height * 1.2)
|
|
.offset(x: -geo.size.width * 0.1, y: 0)
|
|
.blur(radius: 20)
|
|
|
|
// Secondary blob
|
|
if !isMissing {
|
|
Ellipse()
|
|
.fill(moodColor.opacity(0.3))
|
|
.frame(width: geo.size.width * 0.4, height: geo.size.height * 0.8)
|
|
.offset(x: geo.size.width * 0.25, y: geo.size.height * 0.1)
|
|
.blur(radius: 15)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Content overlay
|
|
HStack(spacing: 20) {
|
|
// Morphing mood indicator
|
|
ZStack {
|
|
// Outer glow blob
|
|
Circle()
|
|
.fill(isMissing ? Color.gray.opacity(0.2) : moodColor.opacity(0.4))
|
|
.frame(width: 70, height: 70)
|
|
.blur(radius: 10)
|
|
|
|
// Inner solid
|
|
Circle()
|
|
.fill(
|
|
LinearGradient(
|
|
colors: isMissing
|
|
? [Color.gray.opacity(0.4), Color.gray.opacity(0.2)]
|
|
: [moodColor, moodColor.opacity(0.7)],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
)
|
|
.frame(width: 50, height: 50)
|
|
|
|
imagePack.icon(forMood: entry.mood)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 24, height: 24)
|
|
.foregroundColor(moodContrastingTextColor)
|
|
.accessibilityLabel(entry.mood.strValue)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
// Date with organic flow
|
|
HStack(spacing: 0) {
|
|
Text(cachedDay)
|
|
.font(.title.weight(.light))
|
|
.foregroundColor(textColor)
|
|
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
Text(cachedMonthAbbrev)
|
|
.font(.caption.weight(.medium))
|
|
.foregroundColor(textColor.opacity(0.6))
|
|
Text(cachedWeekdayAbbrev)
|
|
.font(.caption2.weight(.regular))
|
|
.foregroundColor(textColor.opacity(0.4))
|
|
}
|
|
.padding(.leading, 6)
|
|
}
|
|
|
|
if isMissing {
|
|
Text("No mood recorded")
|
|
.font(.subheadline.weight(.medium))
|
|
.foregroundColor(.gray)
|
|
} else {
|
|
Text(entry.moodString)
|
|
.font(.headline.weight(.semibold))
|
|
.foregroundColor(moodColor)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.padding(20)
|
|
}
|
|
.frame(height: 110)
|
|
.clipShape(RoundedRectangle(cornerRadius: 28))
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 28)
|
|
.fill(colorScheme == .dark ? Color(.systemGray6) : .white)
|
|
)
|
|
}
|
|
|
|
// MARK: - Stack Style (Layered Paper Notes)
|
|
private var stackStyle: some View {
|
|
ZStack {
|
|
// Back paper layers (offset for depth)
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.fill(colorScheme == .dark ? Color(.systemGray5) : Color(white: 0.92))
|
|
.offset(x: 4, y: 4)
|
|
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.fill(colorScheme == .dark ? Color(.systemGray5).opacity(0.7) : Color(white: 0.95))
|
|
.offset(x: 2, y: 2)
|
|
|
|
// Main note
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
// 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)
|
|
|
|
HStack(spacing: 16) {
|
|
// Handwritten-style date
|
|
VStack(alignment: .center, spacing: 2) {
|
|
Text(cachedDay)
|
|
.font(.title.weight(.light))
|
|
.foregroundColor(textColor)
|
|
|
|
Text(cachedWeekdayAbbrev)
|
|
.font(.caption.weight(.medium))
|
|
.foregroundColor(textColor.opacity(0.5))
|
|
.textCase(.uppercase)
|
|
}
|
|
.frame(width: 60)
|
|
|
|
// Vertical line divider (like notebook margin)
|
|
Rectangle()
|
|
.fill(Color.red.opacity(0.3))
|
|
.frame(width: 1)
|
|
|
|
// Content area
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
// Lined paper effect
|
|
Text(cachedMonthWideYear)
|
|
.font(.caption.weight(.regular))
|
|
.foregroundColor(textColor.opacity(0.4))
|
|
|
|
if isMissing {
|
|
Text("nothing written...")
|
|
.font(.headline.weight(.regular))
|
|
.italic()
|
|
.foregroundColor(.gray)
|
|
} else {
|
|
HStack(spacing: 10) {
|
|
imagePack.icon(forMood: entry.mood)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 24, height: 24)
|
|
.foregroundColor(moodColor)
|
|
.accessibilityLabel(entry.mood.strValue)
|
|
|
|
Text(entry.moodString)
|
|
.font(.title3.weight(.medium))
|
|
.foregroundColor(textColor)
|
|
}
|
|
}
|
|
|
|
// Notebook lines
|
|
VStack(spacing: 8) {
|
|
ForEach(0..<2, id: \.self) { _ in
|
|
Rectangle()
|
|
.fill(Color.blue.opacity(0.1))
|
|
.frame(height: 1)
|
|
}
|
|
}
|
|
}
|
|
.padding(.vertical, 8)
|
|
|
|
Spacer()
|
|
}
|
|
.padding(16)
|
|
}
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.fill(colorScheme == .dark ? Color(.systemGray6) : Color(white: 0.98))
|
|
)
|
|
.shadow(color: Color.black.opacity(0.1), radius: 8, x: 2, y: 4)
|
|
}
|
|
}
|
|
|
|
// MARK: - Wave Style (Horizontal Gradient River)
|
|
private var waveStyle: some View {
|
|
HStack(spacing: 0) {
|
|
// Date column - minimal
|
|
VStack(alignment: .trailing, spacing: 2) {
|
|
Text(cachedDay)
|
|
.font(.title.weight(.thin))
|
|
.foregroundColor(textColor)
|
|
|
|
Text(cachedWeekdayAbbrev)
|
|
.font(.caption2.weight(.medium))
|
|
.foregroundColor(textColor.opacity(0.4))
|
|
.textCase(.uppercase)
|
|
}
|
|
.frame(width: 50)
|
|
.padding(.trailing, 12)
|
|
|
|
// Wave gradient bar
|
|
ZStack(alignment: .leading) {
|
|
// Gradient wave
|
|
RoundedRectangle(cornerRadius: 16)
|
|
.fill(
|
|
LinearGradient(
|
|
colors: isMissing
|
|
? [Color.gray.opacity(0.2), Color.gray.opacity(0.1)]
|
|
: [
|
|
moodColor,
|
|
moodColor.opacity(0.7),
|
|
moodColor.opacity(0.4)
|
|
],
|
|
startPoint: .leading,
|
|
endPoint: .trailing
|
|
)
|
|
)
|
|
|
|
// Wave texture overlay
|
|
if !isMissing {
|
|
GeometryReader { geo in
|
|
Path { path in
|
|
path.move(to: CGPoint(x: 0, y: geo.size.height * 0.5))
|
|
for x in stride(from: 0, to: geo.size.width, by: 10) {
|
|
let y = geo.size.height * 0.5 + sin(x / 20) * 8
|
|
path.addLine(to: CGPoint(x: x, y: y))
|
|
}
|
|
path.addLine(to: CGPoint(x: geo.size.width, y: geo.size.height))
|
|
path.addLine(to: CGPoint(x: 0, y: geo.size.height))
|
|
path.closeSubpath()
|
|
}
|
|
.fill(Color.white.opacity(0.15))
|
|
}
|
|
}
|
|
|
|
// Content on wave
|
|
HStack {
|
|
// Icon
|
|
imagePack.icon(forMood: entry.mood)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 28, height: 28)
|
|
.foregroundColor(isMissing ? .gray : moodContrastingTextColor)
|
|
.accessibilityLabel(entry.mood.strValue)
|
|
.padding(.leading, 16)
|
|
|
|
if isMissing {
|
|
Text("No entry")
|
|
.font(.subheadline.weight(.medium))
|
|
.foregroundColor(.gray)
|
|
} else {
|
|
Text(entry.moodString)
|
|
.font(.body.weight(.semibold))
|
|
.foregroundColor(moodContrastingTextColor)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Month indicator
|
|
Text(cachedMonthAbbrev)
|
|
.font(.caption2.weight(.bold))
|
|
.foregroundColor(isMissing ? .gray : moodContrastingTextColor.opacity(0.7))
|
|
.padding(.trailing, 16)
|
|
}
|
|
}
|
|
.frame(height: 64)
|
|
.shadow(
|
|
color: isMissing ? .clear : moodColor.opacity(0.4),
|
|
radius: 12,
|
|
x: 0,
|
|
y: 6
|
|
)
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
|
|
// MARK: - Pattern Style (Mood icons as repeating background)
|
|
private var patternStyle: some View {
|
|
HStack(spacing: 16) {
|
|
// Large mood icon
|
|
ZStack {
|
|
Circle()
|
|
.fill(isMissing ? Color.gray.opacity(0.2) : moodColor)
|
|
.frame(width: 54, height: 54)
|
|
|
|
imagePack.icon(forMood: entry.mood)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 28, height: 28)
|
|
.foregroundColor(isMissing ? .gray : moodContrastingTextColor)
|
|
.accessibilityLabel(entry.mood.strValue)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(cachedWeekdayWideDay)
|
|
.font(.body.weight(.semibold))
|
|
.foregroundColor(textColor)
|
|
|
|
if isMissing {
|
|
Text("No mood recorded")
|
|
.font(.subheadline)
|
|
.foregroundColor(.gray)
|
|
} else {
|
|
Text(entry.moodString)
|
|
.font(.subheadline.weight(.medium))
|
|
.foregroundColor(moodColor)
|
|
}
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 10))
|
|
|
|
Spacer()
|
|
|
|
Text(cachedMonthAbbrev)
|
|
.font(.caption.weight(.medium))
|
|
.foregroundColor(textColor.opacity(0.6))
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 6)
|
|
.background(.ultraThinMaterial, in: Capsule())
|
|
}
|
|
.padding(16)
|
|
.frame(height: 86)
|
|
.background {
|
|
// Simplified pattern background using Canvas for better performance
|
|
Canvas { context, size in
|
|
let iconSize: CGFloat = 20
|
|
let spacing: CGFloat = 28
|
|
let patternColor = (isMissing ? Color.gray : moodColor).opacity(0.15)
|
|
|
|
// 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(
|
|
RoundedRectangle(cornerRadius: 16)
|
|
.stroke(isMissing ? Color.gray.opacity(0.15) : moodColor.opacity(0.3), lineWidth: 1)
|
|
)
|
|
}
|
|
|
|
// MARK: - Leather Style (Skeuomorphic)
|
|
private var leatherStyle: some View {
|
|
ZStack {
|
|
// Leather texture base
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [
|
|
Color(red: 0.45, green: 0.30, blue: 0.20),
|
|
Color(red: 0.35, green: 0.22, blue: 0.15),
|
|
Color(red: 0.40, green: 0.26, blue: 0.18)
|
|
],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
)
|
|
|
|
// Grain texture overlay
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.fill(
|
|
RadialGradient(
|
|
colors: [Color.white.opacity(0.08), Color.clear, Color.black.opacity(0.1)],
|
|
center: .topLeading,
|
|
startRadius: 0,
|
|
endRadius: 300
|
|
)
|
|
)
|
|
|
|
// Inner shadow (pressed effect)
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.stroke(Color.black.opacity(0.3), lineWidth: 2)
|
|
.blur(radius: 2)
|
|
.offset(x: 1, y: 2)
|
|
.mask(RoundedRectangle(cornerRadius: 12))
|
|
|
|
// Highlight on top edge
|
|
VStack {
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.fill(Color.white.opacity(0.15))
|
|
.frame(height: 2)
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 4)
|
|
.padding(.top, 2)
|
|
|
|
// Stitching border
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.strokeBorder(
|
|
style: StrokeStyle(lineWidth: 1.5, dash: [4, 3])
|
|
)
|
|
.foregroundColor(Color(red: 0.7, green: 0.55, blue: 0.35).opacity(0.6))
|
|
.padding(6)
|
|
|
|
// Content
|
|
HStack(spacing: 16) {
|
|
// Embossed mood badge
|
|
ZStack {
|
|
// Outer ring shadow
|
|
Circle()
|
|
.fill(Color.black.opacity(0.3))
|
|
.frame(width: 50, height: 50)
|
|
.offset(y: 2)
|
|
.blur(radius: 2)
|
|
|
|
// Metal ring
|
|
Circle()
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [
|
|
Color(red: 0.85, green: 0.75, blue: 0.55),
|
|
Color(red: 0.65, green: 0.55, blue: 0.35),
|
|
Color(red: 0.85, green: 0.75, blue: 0.55)
|
|
],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
)
|
|
.frame(width: 48, height: 48)
|
|
|
|
Circle()
|
|
.fill(isMissing ? Color.gray : moodColor)
|
|
.frame(width: 40, height: 40)
|
|
|
|
imagePack.icon(forMood: entry.mood)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 22, height: 22)
|
|
.foregroundColor(isMissing ? .gray : moodContrastingTextColor)
|
|
.shadow(color: Color.black.opacity(0.3), radius: 1, x: 0, y: 1)
|
|
.accessibilityLabel(entry.mood.strValue)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
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(cachedMonthAbbrevDay)
|
|
.font(.caption.weight(.medium))
|
|
.foregroundColor(Color(red: 0.8, green: 0.7, blue: 0.55))
|
|
|
|
if !isMissing {
|
|
Text(entry.moodString)
|
|
.font(.subheadline.weight(.bold))
|
|
.foregroundColor(Color(red: 0.95, green: 0.90, blue: 0.80))
|
|
.shadow(color: Color.black.opacity(0.5), radius: 1, x: 0, y: 1)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Metal rivet detail
|
|
ZStack {
|
|
Circle()
|
|
.fill(Color.black.opacity(0.4))
|
|
.frame(width: 10, height: 10)
|
|
.offset(y: 1)
|
|
|
|
Circle()
|
|
.fill(
|
|
RadialGradient(
|
|
colors: [
|
|
Color(red: 0.9, green: 0.8, blue: 0.6),
|
|
Color(red: 0.6, green: 0.5, blue: 0.3)
|
|
],
|
|
center: .topLeading,
|
|
startRadius: 0,
|
|
endRadius: 6
|
|
)
|
|
)
|
|
.frame(width: 8, height: 8)
|
|
}
|
|
}
|
|
.padding(.horizontal, 18)
|
|
.padding(.vertical, 14)
|
|
}
|
|
.frame(height: 90)
|
|
.shadow(color: Color.black.opacity(0.4), radius: 6, x: 0, y: 4)
|
|
}
|
|
|
|
// MARK: - Glass Style (iOS 26 Liquid Glass)
|
|
private var glassStyle: some View {
|
|
ZStack {
|
|
// Variable blur background simulation
|
|
RoundedRectangle(cornerRadius: 24)
|
|
.fill(.ultraThinMaterial)
|
|
|
|
// Glass refraction layer
|
|
RoundedRectangle(cornerRadius: 24)
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [
|
|
Color.white.opacity(colorScheme == .dark ? 0.15 : 0.6),
|
|
Color.white.opacity(colorScheme == .dark ? 0.05 : 0.2),
|
|
Color.white.opacity(colorScheme == .dark ? 0.08 : 0.3)
|
|
],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
)
|
|
|
|
// Specular highlight (liquid glass shine)
|
|
GeometryReader { geo in
|
|
Ellipse()
|
|
.fill(
|
|
RadialGradient(
|
|
colors: [Color.white.opacity(0.4), Color.clear],
|
|
center: .center,
|
|
startRadius: 0,
|
|
endRadius: 80
|
|
)
|
|
)
|
|
.frame(width: 160, height: 50)
|
|
.offset(x: -20, y: -10)
|
|
.blur(radius: 10)
|
|
}
|
|
.clipped()
|
|
|
|
// Content with depth
|
|
HStack(spacing: 18) {
|
|
// Floating glass orb for mood
|
|
ZStack {
|
|
// Orb shadow
|
|
Circle()
|
|
.fill(Color.black.opacity(0.15))
|
|
.frame(width: 54, height: 54)
|
|
.offset(y: 4)
|
|
.blur(radius: 8)
|
|
|
|
// Glass orb
|
|
Circle()
|
|
.fill(.ultraThinMaterial)
|
|
.frame(width: 52, height: 52)
|
|
|
|
// Inner color
|
|
Circle()
|
|
.fill(
|
|
RadialGradient(
|
|
colors: isMissing
|
|
? [Color.gray.opacity(0.4), Color.gray.opacity(0.2)]
|
|
: [moodColor.opacity(0.7), moodColor.opacity(0.4)],
|
|
center: .topLeading,
|
|
startRadius: 0,
|
|
endRadius: 35
|
|
)
|
|
)
|
|
.frame(width: 46, height: 46)
|
|
|
|
// Glass highlight
|
|
Circle()
|
|
.trim(from: 0, to: 0.3)
|
|
.stroke(Color.white.opacity(0.5), lineWidth: 2)
|
|
.frame(width: 40, height: 40)
|
|
.rotationEffect(.degrees(-45))
|
|
|
|
imagePack.icon(forMood: entry.mood)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 24, height: 24)
|
|
.foregroundColor(isMissing ? .gray : moodContrastingTextColor)
|
|
.accessibilityLabel(entry.mood.strValue)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(cachedWeekdayWide)
|
|
.font(.body.weight(.semibold))
|
|
.foregroundColor(textColor)
|
|
|
|
HStack(spacing: 6) {
|
|
Text(cachedMonthAbbrevDay)
|
|
.font(.caption)
|
|
.foregroundColor(textColor.opacity(0.6))
|
|
|
|
if !isMissing {
|
|
// Glass pill for mood
|
|
Text(entry.moodString)
|
|
.font(.caption.weight(.medium))
|
|
.foregroundColor(moodColor)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 4)
|
|
.background(
|
|
Capsule()
|
|
.fill(.ultraThinMaterial)
|
|
.overlay(
|
|
Capsule()
|
|
.fill(moodColor.opacity(0.15))
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Glass chevron
|
|
Image(systemName: "chevron.right")
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundColor(textColor.opacity(0.4))
|
|
}
|
|
.padding(18)
|
|
|
|
// Bottom edge highlight
|
|
VStack {
|
|
Spacer()
|
|
RoundedRectangle(cornerRadius: 24)
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [Color.clear, Color.white.opacity(0.1)],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
)
|
|
.frame(height: 20)
|
|
}
|
|
.clipShape(RoundedRectangle(cornerRadius: 24))
|
|
}
|
|
.frame(height: 88)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 24)
|
|
.stroke(
|
|
LinearGradient(
|
|
colors: [
|
|
Color.white.opacity(colorScheme == .dark ? 0.3 : 0.6),
|
|
Color.white.opacity(colorScheme == .dark ? 0.1 : 0.2),
|
|
Color.white.opacity(colorScheme == .dark ? 0.2 : 0.4)
|
|
],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
),
|
|
lineWidth: 1
|
|
)
|
|
)
|
|
.shadow(color: Color.black.opacity(0.1), radius: 20, x: 0, y: 10)
|
|
}
|
|
|
|
// MARK: - Motion Style (Accelerometer Parallax)
|
|
private var motionStyle: some View {
|
|
MotionCardView(
|
|
entry: entry,
|
|
imagePack: imagePack,
|
|
moodTint: moodTint,
|
|
theme: theme,
|
|
colorScheme: colorScheme,
|
|
isMissing: isMissing,
|
|
moodColor: moodColor
|
|
)
|
|
}
|
|
|
|
// MARK: - Micro Style (Ultra Compact)
|
|
private var microStyle: some View {
|
|
HStack(spacing: 10) {
|
|
// Tiny mood indicator dot
|
|
Circle()
|
|
.fill(isMissing ? Color.gray.opacity(0.4) : moodColor)
|
|
.frame(width: 8, height: 8)
|
|
|
|
// Compact icon
|
|
imagePack.icon(forMood: entry.mood)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 18, height: 18)
|
|
.foregroundColor(isMissing ? .gray : moodColor)
|
|
.accessibilityLabel(entry.mood.strValue)
|
|
|
|
// Date - very compact
|
|
Text(cachedMonthAbbrevDay)
|
|
.font(.caption.weight(.medium).monospaced())
|
|
.foregroundColor(textColor.opacity(0.7))
|
|
|
|
// Weekday initial
|
|
Text(String(Random.weekdayName(fromDate: entry.forDate).prefix(3)))
|
|
.font(.caption2.weight(.semibold))
|
|
.foregroundColor(textColor.opacity(0.4))
|
|
.frame(width: 28)
|
|
|
|
Spacer()
|
|
|
|
// Mood as tiny pill
|
|
if !isMissing {
|
|
Text(entry.moodString)
|
|
.font(.caption2.weight(.semibold))
|
|
.foregroundColor(moodColor)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 3)
|
|
.background(
|
|
Capsule()
|
|
.fill(moodColor.opacity(0.15))
|
|
)
|
|
} else {
|
|
Text("tap")
|
|
.font(.caption2.weight(.medium))
|
|
.foregroundColor(.gray.opacity(0.6))
|
|
}
|
|
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption2.weight(.semibold))
|
|
.foregroundColor(textColor.opacity(0.25))
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 10)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.fill(colorScheme == .dark ? Color(.systemGray6).opacity(0.6) : Color.white.opacity(0.8))
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.stroke(
|
|
isMissing ? Color.gray.opacity(0.15) : moodColor.opacity(0.2),
|
|
lineWidth: 0.5
|
|
)
|
|
)
|
|
}
|
|
|
|
// MARK: - Orbit Style (Celestial Circular)
|
|
private var orbitStyle: some View {
|
|
OrbitEntryView(
|
|
entry: entry,
|
|
imagePack: imagePack,
|
|
moodColor: moodColor,
|
|
theme: theme,
|
|
colorScheme: colorScheme,
|
|
isMissing: isMissing
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Orbit Entry View
|
|
struct OrbitEntryView: View {
|
|
let entry: MoodEntryModel
|
|
let imagePack: MoodImages
|
|
let moodColor: 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
|
|
orbitalSystem
|
|
.frame(width: 100, height: 100)
|
|
|
|
// Content on right
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
moodDisplay
|
|
dateDisplay
|
|
}
|
|
.padding(.leading, 8)
|
|
|
|
Spacer()
|
|
|
|
chevron
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
.background(backgroundLayer)
|
|
.overlay(borderLayer)
|
|
}
|
|
|
|
private var orbitalSystem: some View {
|
|
ZStack {
|
|
// Orbital ring
|
|
Circle()
|
|
.stroke(
|
|
(colorScheme == .dark ? Color.white : Color.black).opacity(0.1),
|
|
lineWidth: 1
|
|
)
|
|
.frame(width: 80, height: 80)
|
|
|
|
// Center core (mood icon)
|
|
ZStack {
|
|
// Glow
|
|
Circle()
|
|
.fill(isMissing ? Color.gray.opacity(0.2) : moodColor.opacity(0.3))
|
|
.frame(width: 60, height: 60)
|
|
.blur(radius: 8)
|
|
|
|
// Planet
|
|
Circle()
|
|
.fill(isMissing ? Color.gray.opacity(0.4) : moodColor)
|
|
.frame(width: 44, height: 44)
|
|
.shadow(color: (isMissing ? Color.gray : moodColor).opacity(0.5), radius: 6, x: 0, y: 2)
|
|
|
|
// Icon
|
|
imagePack.icon(forMood: entry.mood)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 24, height: 24)
|
|
.foregroundColor(isMissing ? .gray : moodColor.contrastingTextColor)
|
|
.accessibilityLabel(entry.mood.strValue)
|
|
}
|
|
|
|
// Orbiting day number
|
|
orbitingDay
|
|
}
|
|
}
|
|
|
|
private var orbitingDay: some View {
|
|
let angle = -Double.pi / 4 // Position at top-right
|
|
let radius: CGFloat = 40
|
|
|
|
return ZStack {
|
|
Circle()
|
|
.fill(Color.white.opacity(colorScheme == .dark ? 0.9 : 1.0))
|
|
.frame(width: 28, height: 28)
|
|
.shadow(color: .black.opacity(0.15), radius: 4)
|
|
|
|
Text(cachedDay)
|
|
.font(.caption.weight(.bold))
|
|
.foregroundColor(.black.opacity(0.7))
|
|
}
|
|
.offset(x: cos(angle) * radius, y: sin(angle) * radius)
|
|
}
|
|
|
|
private var dateDisplay: some View {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(cachedWeekdayWide)
|
|
.font(.body.weight(.semibold))
|
|
.foregroundColor(textColor)
|
|
|
|
Text(cachedMonthAbbrevYear)
|
|
.font(.caption)
|
|
.foregroundColor(textColor.opacity(0.5))
|
|
}
|
|
}
|
|
|
|
private var moodDisplay: some View {
|
|
Group {
|
|
if isMissing {
|
|
Text("No mood recorded")
|
|
.font(.subheadline)
|
|
.foregroundColor(.gray)
|
|
} else {
|
|
Text(entry.moodString)
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundColor(moodColor)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 5)
|
|
.background(
|
|
Capsule()
|
|
.fill(moodColor.opacity(0.15))
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var chevron: some View {
|
|
Image(systemName: "chevron.right")
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundColor(textColor.opacity(0.3))
|
|
}
|
|
|
|
private var backgroundLayer: some View {
|
|
RoundedRectangle(cornerRadius: 20)
|
|
.fill(colorScheme == .dark ? Color(.systemGray6) : .white)
|
|
.shadow(
|
|
color: isMissing ? Color.black.opacity(0.05) : moodColor.opacity(0.15),
|
|
radius: 12,
|
|
x: 0,
|
|
y: 6
|
|
)
|
|
}
|
|
|
|
private var borderLayer: some View {
|
|
RoundedRectangle(cornerRadius: 20)
|
|
.stroke(
|
|
isMissing ? Color.gray.opacity(0.15) : moodColor.opacity(0.2),
|
|
lineWidth: 1
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Motion Card with Accelerometer
|
|
struct MotionCardView: View {
|
|
let entry: MoodEntryModel
|
|
let imagePack: MoodImages
|
|
let moodTint: MoodTints
|
|
let theme: Theme
|
|
let colorScheme: ColorScheme
|
|
let isMissing: Bool
|
|
let moodColor: Color
|
|
|
|
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 {
|
|
|
|
ZStack {
|
|
// Background with parallax offset
|
|
RoundedRectangle(cornerRadius: 22)
|
|
.fill(
|
|
LinearGradient(
|
|
colors: isMissing
|
|
? [Color.gray.opacity(0.1), Color.gray.opacity(0.05)]
|
|
: [moodColor.opacity(0.2), moodColor.opacity(0.05)],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
)
|
|
.offset(
|
|
x: motionManager.xOffset * 0.5,
|
|
y: motionManager.yOffset * 0.5
|
|
)
|
|
|
|
// Floating orbs that move with device
|
|
GeometryReader { geo in
|
|
Circle()
|
|
.fill(
|
|
RadialGradient(
|
|
colors: isMissing
|
|
? [Color.gray.opacity(0.3), Color.clear]
|
|
: [moodColor.opacity(0.4), Color.clear],
|
|
center: .center,
|
|
startRadius: 0,
|
|
endRadius: 40
|
|
)
|
|
)
|
|
.frame(width: 80, height: 80)
|
|
.offset(
|
|
x: geo.size.width * 0.7 + motionManager.xOffset * 2,
|
|
y: -10 + motionManager.yOffset * 2
|
|
)
|
|
|
|
Circle()
|
|
.fill(
|
|
RadialGradient(
|
|
colors: isMissing
|
|
? [Color.gray.opacity(0.2), Color.clear]
|
|
: [moodColor.opacity(0.3), Color.clear],
|
|
center: .center,
|
|
startRadius: 0,
|
|
endRadius: 30
|
|
)
|
|
)
|
|
.frame(width: 50, height: 50)
|
|
.offset(
|
|
x: 20 - motionManager.xOffset * 1.5,
|
|
y: geo.size.height * 0.6 - motionManager.yOffset * 1.5
|
|
)
|
|
}
|
|
|
|
// Main content
|
|
HStack(spacing: 16) {
|
|
// Icon with enhanced parallax
|
|
ZStack {
|
|
// Glow behind icon
|
|
Circle()
|
|
.fill(isMissing ? Color.gray.opacity(0.2) : moodColor.opacity(0.4))
|
|
.frame(width: 60, height: 60)
|
|
.blur(radius: 10)
|
|
.offset(
|
|
x: motionManager.xOffset * 0.8,
|
|
y: motionManager.yOffset * 0.8
|
|
)
|
|
|
|
Circle()
|
|
.fill(
|
|
LinearGradient(
|
|
colors: isMissing
|
|
? [Color.gray.opacity(0.5), Color.gray.opacity(0.3)]
|
|
: [moodColor.opacity(0.9), moodColor.opacity(0.6)],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
)
|
|
.frame(width: 54, height: 54)
|
|
.shadow(color: (isMissing ? Color.gray : moodColor).opacity(0.4), radius: 8, x: 0, y: 4)
|
|
|
|
imagePack.icon(forMood: entry.mood)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 30, height: 30)
|
|
.foregroundColor(isMissing ? .gray : moodColor.contrastingTextColor)
|
|
.offset(
|
|
x: -motionManager.xOffset * 0.3,
|
|
y: -motionManager.yOffset * 0.3
|
|
)
|
|
.accessibilityLabel(entry.mood.strValue)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
// Day with motion
|
|
Text(cachedDay)
|
|
.font(.title.weight(.bold))
|
|
.foregroundColor(isMissing ? .gray : moodColor)
|
|
.offset(
|
|
x: motionManager.xOffset * 0.2,
|
|
y: motionManager.yOffset * 0.2
|
|
)
|
|
|
|
HStack(spacing: 6) {
|
|
Text(Random.weekdayName(fromDate: entry.forDate))
|
|
.font(.subheadline.weight(.medium))
|
|
.foregroundColor(textColor.opacity(0.7))
|
|
|
|
if !isMissing {
|
|
Circle()
|
|
.fill(moodColor)
|
|
.frame(width: 4, height: 4)
|
|
Text(entry.moodString)
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundColor(moodColor)
|
|
}
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Image(systemName: "chevron.right")
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundColor(textColor.opacity(0.3))
|
|
.offset(
|
|
x: motionManager.xOffset * 0.5,
|
|
y: 0
|
|
)
|
|
}
|
|
.padding(.horizontal, 18)
|
|
.padding(.vertical, 14)
|
|
}
|
|
.frame(height: 90)
|
|
.clipShape(RoundedRectangle(cornerRadius: 22))
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 22)
|
|
.fill(colorScheme == .dark ? Color(.systemGray6) : .white)
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 22)
|
|
.stroke(
|
|
isMissing ? Color.gray.opacity(0.2) : moodColor.opacity(0.3),
|
|
lineWidth: 1
|
|
)
|
|
)
|
|
.shadow(color: (isMissing ? Color.gray : moodColor).opacity(0.15), radius: 12, x: 0, y: 6)
|
|
.onAppear {
|
|
motionManager.startIfNeeded()
|
|
}
|
|
.onDisappear {
|
|
motionManager.stopIfNoConsumers()
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
|
|
private var isRunning = false
|
|
private var activeConsumers = 0
|
|
|
|
private init() {}
|
|
|
|
func startIfNeeded() {
|
|
activeConsumers += 1
|
|
|
|
guard !isRunning,
|
|
motionManager.isDeviceMotionAvailable,
|
|
!UIAccessibility.isReduceMotionEnabled else { return }
|
|
|
|
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)) {
|
|
self?.xOffset = CGFloat(motion.attitude.roll) * 15
|
|
self?.yOffset = CGFloat(motion.attitude.pitch) * 15
|
|
}
|
|
}
|
|
}
|
|
|
|
func stopIfNoConsumers() {
|
|
activeConsumers = max(0, activeConsumers - 1)
|
|
guard activeConsumers == 0, isRunning else { return }
|
|
isRunning = false
|
|
motionManager.stopDeviceMotionUpdates()
|
|
xOffset = 0
|
|
yOffset = 0
|
|
}
|
|
|
|
func stop() {
|
|
guard isRunning else { return }
|
|
activeConsumers = 0
|
|
isRunning = false
|
|
motionManager.stopDeviceMotionUpdates()
|
|
}
|
|
}
|
|
|
|
struct EntryListView_Previews: PreviewProvider {
|
|
@MainActor static let fakeData = DataController.shared.randomEntries(count: 1).first!
|
|
|
|
static var previews: some View {
|
|
VStack(spacing: 8) {
|
|
EntryListView(entry: EntryListView_Previews.fakeData)
|
|
EntryListView(entry: EntryListView_Previews.fakeData)
|
|
}
|
|
.padding()
|
|
}
|
|
}
|