Files
Reflect/Shared/Views/EntryListView.swift
Trey t be84825aba Fix widget layout clipping and add comprehensive widget previews
- Fix LargeVotingView mood icons getting clipped at edges by using
  flexible HStack spacing with maxWidth: .infinity
- Fix VotingView medium layout with smaller icons and even distribution
- Add comprehensive #Preview macros for all widget states:
  - Vote widget: small/medium, voted/not voted, all mood states
  - Timeline widget: small/medium/large with various data states
- Reduce icon sizes and padding to fit within widget bounds
- Update accessibility labels and hints across views

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 09:53:40 -06:00

1979 lines
76 KiB
Swift

//
// EntryListView.swift
// Feels (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.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
public let entry: MoodEntryModel
private var moodColor: Color {
moodTint.color(forMood: entry.mood)
}
private var isMissing: Bool {
entry.moodValue == Mood.missing.rawValue
}
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
}
}
.accessibilityElement(children: .combine)
.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 dateFormatter = DateFormatter()
dateFormatter.dateStyle = .full
let dateString = dateFormatter.string(from: entry.forDate)
if isMissing {
return String(localized: "\(dateString), no mood logged")
} else {
return "\(dateString), \(entry.mood.strValue)"
}
}
// 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 : .white)
.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(entry.forDate, format: .dateTime.weekday(.wide).day())
.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(entry.forDate, format: .dateTime.day())
.font(.title3.weight(.bold))
.foregroundColor(textColor)
Text(entry.forDate, format: .dateTime.weekday(.abbreviated))
.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 : .white)
.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)
if isMissing {
Text(String(localized: "mood_value_missing_tap_to_add"))
.font(.caption)
.foregroundColor(.gray)
} else {
Text(entry.moodString)
.font(.caption.weight(.medium))
.foregroundColor(.white.opacity(0.85))
}
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundColor(isMissing ? textColor.opacity(0.3) : .white.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 : .white)
.accessibilityLabel(entry.mood.strValue)
}
.shadow(
color: isMissing ? .clear : moodColor.opacity(0.3),
radius: 6,
x: 0,
y: 3
)
// Day number
Text(entry.forDate, format: .dateTime.day())
.font(.subheadline.weight(.bold))
.foregroundColor(textColor)
// Weekday abbreviation
Text(entry.forDate, format: .dateTime.weekday(.abbreviated))
.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(entry.forDate, format: .dateTime.day())
.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(entry.forDate, format: .dateTime.weekday(.wide))
.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 : .white)
.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(entry.forDate, format: .dateTime.month(.wide))
.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(entry.forDate, format: .dateTime.day())
.font(.largeTitle.weight(.regular))
.foregroundColor(textColor)
.frame(width: 80)
Text(entry.forDate, format: .dateTime.weekday(.abbreviated).month(.abbreviated))
.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 (Cyberpunk/Synthwave)
private var neonStyle: some View {
ZStack {
// Dark base with scanline effect
RoundedRectangle(cornerRadius: 4)
.fill(Color.black)
// Scanline overlay
VStack(spacing: 2) {
ForEach(0..<20, id: \.self) { _ in
Rectangle()
.fill(Color.white.opacity(0.03))
.frame(height: 1)
Spacer().frame(height: 3)
}
}
.clipShape(RoundedRectangle(cornerRadius: 4))
// Content
HStack(spacing: 16) {
// Neon-outlined mood indicator
ZStack {
// Glow effect
RoundedRectangle(cornerRadius: 8)
.stroke(isMissing ? Color.gray : moodColor, lineWidth: 2)
.blur(radius: 4)
.opacity(0.8)
RoundedRectangle(cornerRadius: 8)
.stroke(isMissing ? Color.gray : moodColor, lineWidth: 2)
imagePack.icon(forMood: entry.mood)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 28, height: 28)
.foregroundColor(isMissing ? .gray : moodColor)
.shadow(color: isMissing ? .clear : moodColor, radius: 8, x: 0, y: 0)
.accessibilityLabel(entry.mood.strValue)
}
.frame(width: 52, height: 52)
VStack(alignment: .leading, spacing: 6) {
// Date in monospace terminal style
Text(entry.forDate, format: .dateTime.year().month(.twoDigits).day(.twoDigits))
.font(.caption.weight(.medium).monospaced())
.foregroundColor(Color(red: 0.4, green: 1.0, blue: 0.4)) // Terminal green
if isMissing {
Text("NO_DATA")
.font(.headline.weight(.bold).monospaced())
.foregroundColor(.gray)
} else {
// Mood in glowing text
Text(entry.moodString.uppercased())
.font(.headline.weight(.black))
.foregroundColor(moodColor)
.shadow(color: moodColor.opacity(0.8), radius: 6, x: 0, y: 0)
.shadow(color: moodColor.opacity(0.4), radius: 12, x: 0, y: 0)
}
// Weekday
Text(entry.forDate, format: .dateTime.weekday(.wide))
.font(.caption2.weight(.medium).monospaced())
.foregroundColor(.white.opacity(0.4))
.textCase(.uppercase)
}
Spacer()
// Chevron with glow
Image(systemName: "chevron.right")
.font(.subheadline.weight(.bold))
.foregroundColor(isMissing ? .gray : moodColor)
.shadow(color: isMissing ? .clear : moodColor, radius: 4, x: 0, y: 0)
}
.padding(.horizontal, 18)
.padding(.vertical, 16)
// Neon border
RoundedRectangle(cornerRadius: 4)
.stroke(
LinearGradient(
colors: isMissing
? [Color.gray.opacity(0.3), Color.gray.opacity(0.1)]
: [moodColor.opacity(0.8), moodColor.opacity(0.3)],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
lineWidth: 1
)
}
.shadow(
color: isMissing ? .clear : moodColor.opacity(0.3),
radius: 12,
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(entry.forDate, format: .dateTime.day())
.font(.title.weight(.thin))
.foregroundColor(textColor)
VStack(alignment: .leading, spacing: 2) {
Text(entry.forDate, format: .dateTime.month(.wide))
.font(.caption2.weight(.light))
.foregroundColor(textColor.opacity(0.5))
.textCase(.uppercase)
.tracking(2)
Text(entry.forDate, format: .dateTime.weekday(.wide))
.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 : .white)
.accessibilityLabel(entry.mood.strValue)
}
VStack(alignment: .leading, spacing: 6) {
Text(entry.forDate, format: .dateTime.weekday(.wide))
.font(.subheadline.weight(.semibold))
.foregroundColor(textColor)
HStack(spacing: 8) {
Text(entry.forDate, format: .dateTime.month(.abbreviated).day())
.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(entry.forDate, format: .dateTime.day())
.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(entry.forDate, format: .dateTime.weekday(.wide).month(.abbreviated))
.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(.white)
.accessibilityLabel(entry.mood.strValue)
}
VStack(alignment: .leading, spacing: 8) {
// Date with organic flow
HStack(spacing: 0) {
Text(entry.forDate, format: .dateTime.day())
.font(.title.weight(.light))
.foregroundColor(textColor)
VStack(alignment: .leading, spacing: 0) {
Text(entry.forDate, format: .dateTime.month(.abbreviated))
.font(.caption.weight(.medium))
.foregroundColor(textColor.opacity(0.6))
Text(entry.forDate, format: .dateTime.weekday(.abbreviated))
.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
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))
}
}
.frame(height: 6)
HStack(spacing: 16) {
// Handwritten-style date
VStack(alignment: .center, spacing: 2) {
Text(entry.forDate, format: .dateTime.day())
.font(.title.weight(.light))
.foregroundColor(textColor)
Text(entry.forDate, format: .dateTime.weekday(.abbreviated))
.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(entry.forDate, format: .dateTime.month(.wide).year())
.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(entry.forDate, format: .dateTime.day())
.font(.title.weight(.thin))
.foregroundColor(textColor)
Text(entry.forDate, format: .dateTime.weekday(.abbreviated))
.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 : .white)
.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(.white)
}
Spacer()
// Month indicator
Text(entry.forDate, format: .dateTime.month(.abbreviated))
.font(.caption2.weight(.bold))
.foregroundColor(isMissing ? .gray : .white.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(.white)
.accessibilityLabel(entry.mood.strValue)
}
VStack(alignment: .leading, spacing: 6) {
Text(entry.forDate, format: .dateTime.weekday(.wide).day())
.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(entry.forDate, format: .dateTime.month(.abbreviated))
.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 {
// Repeating mood icon pattern background
GeometryReader { geo 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
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
)
}
}
}
}
.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(.white)
.shadow(color: Color.black.opacity(0.3), radius: 1, x: 0, y: 1)
.accessibilityLabel(entry.mood.strValue)
}
VStack(alignment: .leading, spacing: 4) {
Text(entry.forDate, format: .dateTime.weekday(.wide))
.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())
.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 : .white)
.accessibilityLabel(entry.mood.strValue)
}
VStack(alignment: .leading, spacing: 6) {
Text(entry.forDate, format: .dateTime.weekday(.wide))
.font(.body.weight(.semibold))
.foregroundColor(textColor)
HStack(spacing: 6) {
Text(entry.forDate, format: .dateTime.month(.abbreviated).day())
.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,
textColor: textColor,
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(entry.forDate, format: .dateTime.month(.abbreviated).day())
.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: - Motion Card with Accelerometer
struct MotionCardView: View {
let entry: MoodEntryModel
let imagePack: MoodImages
let moodTint: MoodTints
let textColor: Color
let colorScheme: ColorScheme
let isMissing: Bool
let moodColor: Color
@StateObject private var motionManager = MotionManager()
var body: some View {
let dayNumber = Calendar.current.component(.day, from: entry.forDate)
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(.white)
.offset(
x: -motionManager.xOffset * 0.3,
y: -motionManager.yOffset * 0.3
)
.accessibilityLabel(entry.mood.strValue)
}
VStack(alignment: .leading, spacing: 4) {
// Day with motion
Text("\(dayNumber)")
.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)
}
}
// MARK: - Motion Manager for Accelerometer
class MotionManager: ObservableObject {
private let motionManager = CMMotionManager()
@Published var xOffset: CGFloat = 0
@Published var yOffset: CGFloat = 0
init() {
startMotionUpdates()
}
private func startMotionUpdates() {
// Respect Reduce Motion preference - skip parallax effect entirely
guard motionManager.isDeviceMotionAvailable,
!UIAccessibility.isReduceMotionEnabled else { return }
motionManager.deviceMotionUpdateInterval = 1/60
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 {
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()
}
}