diff --git a/Shared/Models/UserDefaultsStore.swift b/Shared/Models/UserDefaultsStore.swift index d8faf40..291b575 100644 --- a/Shared/Models/UserDefaultsStore.swift +++ b/Shared/Models/UserDefaultsStore.swift @@ -13,6 +13,7 @@ enum VotingLayoutStyle: Int, CaseIterable { case radial = 2 // Semi-circle/wheel arrangement case stacked = 3 // Full-width vertical list case aura = 4 // Atmospheric glowing orbs with flowing layout + case orbit = 5 // Celestial orbit with center core var displayName: String { switch self { @@ -21,6 +22,7 @@ enum VotingLayoutStyle: Int, CaseIterable { case .radial: return "Radial" case .stacked: return "Stacked" case .aura: return "Aura" + case .orbit: return "Orbit" } } } @@ -45,6 +47,7 @@ enum DayViewStyle: Int, CaseIterable { case glass = 16 // iOS 26 liquid glass with variable blur case motion = 17 // Accelerometer-driven parallax effect case micro = 18 // Ultra compact single-line entries + case orbit = 19 // Celestial circular orbital arrangement var displayName: String { switch self { @@ -67,6 +70,7 @@ enum DayViewStyle: Int, CaseIterable { case .glass: return "Glass" case .motion: return "Motion" case .micro: return "Micro" + case .orbit: return "Orbit" } } diff --git a/Shared/Views/AddMoodHeaderView.swift b/Shared/Views/AddMoodHeaderView.swift index de09ec9..c28d6a8 100644 --- a/Shared/Views/AddMoodHeaderView.swift +++ b/Shared/Views/AddMoodHeaderView.swift @@ -82,6 +82,8 @@ struct AddMoodHeaderView: View { StackedVotingView(moodTint: moodTint, onMoodSelected: addItem) case .aura: AuraVotingView(moodTint: moodTint, onMoodSelected: addItem) + case .orbit: + OrbitVotingView(moodTint: moodTint, onMoodSelected: addItem) } } @@ -369,6 +371,135 @@ struct AuraVotingView: View { } } +// MARK: - Layout 6: Orbit (Celestial circular arrangement) +struct OrbitVotingView: View { + let moodTint: MoodTints + let onMoodSelected: (Mood) -> Void + @Environment(\.colorScheme) private var colorScheme + + @State private var centerPulse: CGFloat = 1.0 + + private var isDark: Bool { colorScheme == .dark } + + var body: some View { + GeometryReader { geo in + let size = min(geo.size.width, geo.size.height) + let centerX = geo.size.width / 2 + let centerY = size / 2 + let orbitRadius = size * 0.38 + + ZStack { + orbitalRing(radius: orbitRadius, centerX: centerX, centerY: centerY) + centerCore(centerX: centerX, centerY: centerY) + moodPlanets(radius: orbitRadius, centerX: centerX, centerY: centerY) + } + } + .frame(height: 260) + .onAppear { + withAnimation(.easeInOut(duration: 2.0).repeatForever(autoreverses: true)) { + centerPulse = 1.1 + } + } + .accessibilityElement(children: .contain) + .accessibilityLabel(String(localized: "Mood selection")) + } + + private func orbitalRing(radius: CGFloat, centerX: CGFloat, centerY: CGFloat) -> some View { + let ringColor = isDark ? Color.white : Color.black + return Circle() + .stroke(ringColor.opacity(0.08), lineWidth: 1) + .frame(width: radius * 2, height: radius * 2) + .position(x: centerX, y: centerY) + } + + private func centerCore(centerX: CGFloat, centerY: CGFloat) -> some View { + let glowOpacity = isDark ? 0.15 : 0.3 + let coreOpacity = isDark ? 0.9 : 1.0 + + return ZStack { + Circle() + .fill(Color.white.opacity(glowOpacity)) + .frame(width: 80, height: 80) + .scaleEffect(centerPulse) + + Circle() + .fill(Color.white.opacity(coreOpacity)) + .frame(width: 36, height: 36) + .shadow(color: .white.opacity(0.5), radius: 10) + } + .position(x: centerX, y: centerY) + } + + private func moodPlanets(radius: CGFloat, centerX: CGFloat, centerY: CGFloat) -> some View { + ForEach(Array(Mood.allValues.enumerated()), id: \.element.id) { index, mood in + orbitMoodButton( + for: mood, + index: index, + radius: radius, + centerX: centerX, + centerY: centerY + ) + } + } + + private func orbitMoodButton(for mood: Mood, index: Int, radius: CGFloat, centerX: CGFloat, centerY: CGFloat) -> some View { + let angle = -Double.pi / 2 + (2 * Double.pi / 5) * Double(index) + let posX = centerX + cos(angle) * radius + let posY = centerY + sin(angle) * radius + let color = moodTint.color(forMood: mood) + + return Button(action: { onMoodSelected(mood) }) { + OrbitMoodButtonContent(mood: mood, color: color) + } + .buttonStyle(OrbitButtonStyle(color: color)) + .position(x: posX, y: posY) + .accessibilityLabel(mood.strValue) + .accessibilityHint(String(localized: "Select this mood")) + } +} + +struct OrbitMoodButtonContent: View { + let mood: Mood + let color: Color + + var body: some View { + ZStack { + Circle() + .fill(color.opacity(0.2)) + .frame(width: 70, height: 70) + + Circle() + .fill(color) + .frame(width: 52, height: 52) + .shadow(color: color.opacity(0.6), radius: 8, x: 0, y: 2) + + mood.icon + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 28, height: 28) + .foregroundColor(.white) + } + } +} + +// Button style for orbit moods +struct OrbitButtonStyle: ButtonStyle { + let color: Color + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 1.15 : 1.0) + .shadow( + color: configuration.isPressed ? color.opacity(0.8) : Color.clear, + radius: configuration.isPressed ? 20 : 0, + x: 0, + y: 0 + ) + .animation(reduceMotion ? nil : .spring(response: 0.3, dampingFraction: 0.6), value: configuration.isPressed) + } +} + // Custom button style for aura with glow effect on press struct AuraButtonStyle: ButtonStyle { let color: Color diff --git a/Shared/Views/CustomizeView/CustomizeView.swift b/Shared/Views/CustomizeView/CustomizeView.swift index 078ac01..0d470d4 100644 --- a/Shared/Views/CustomizeView/CustomizeView.swift +++ b/Shared/Views/CustomizeView/CustomizeView.swift @@ -571,6 +571,22 @@ struct VotingLayoutPickerCompact: View { } } } + case .orbit: + // Center core with orbiting planets + ZStack { + Circle() + .stroke(Color.primary.opacity(0.2), lineWidth: 1) + .frame(width: 32, height: 32) + Circle() + .fill(Color.primary.opacity(0.8)) + .frame(width: 8, height: 8) + ForEach(0..<5, id: \.self) { index in + Circle() + .fill(Color.accentColor) + .frame(width: 6, height: 6) + .offset(orbitOffset(index: index, total: 5, radius: 16)) + } + } } } @@ -578,6 +594,13 @@ struct VotingLayoutPickerCompact: View { let angle = Double.pi - (Double.pi * Double(index) / Double(total - 1)) return CGSize(width: radius * CGFloat(cos(angle)), height: -radius * CGFloat(sin(angle)) + 4) } + + private func orbitOffset(index: Int, total: Int, radius: CGFloat) -> CGSize { + let startAngle = -Double.pi / 2 + let angleStep = (2 * Double.pi) / Double(total) + let angle = startAngle + angleStep * Double(index) + return CGSize(width: radius * CGFloat(cos(angle)), height: radius * CGFloat(sin(angle))) + } } // MARK: - Custom Widget Section @@ -1216,6 +1239,20 @@ struct DayViewStylePickerCompact: View { .frame(width: 20, height: 4) } } + case .orbit: + // Celestial orbit style + ZStack { + Circle() + .stroke(Color.primary.opacity(0.15), lineWidth: 1) + .frame(width: 28, height: 28) + Circle() + .fill(Color.primary.opacity(0.8)) + .frame(width: 8, height: 8) + Circle() + .fill(Color.accentColor) + .frame(width: 10, height: 10) + .offset(x: 14, y: 0) + } } } } diff --git a/Shared/Views/CustomizeView/SubViews/VotingLayoutPickerView.swift b/Shared/Views/CustomizeView/SubViews/VotingLayoutPickerView.swift index b235e0b..c82c843 100644 --- a/Shared/Views/CustomizeView/SubViews/VotingLayoutPickerView.swift +++ b/Shared/Views/CustomizeView/SubViews/VotingLayoutPickerView.swift @@ -132,6 +132,25 @@ struct VotingLayoutPickerView: View { } } } + case .orbit: + // Center core with orbiting planets + ZStack { + // Orbital ring + Circle() + .stroke(Color.primary.opacity(0.2), lineWidth: 1) + .frame(width: 32, height: 32) + // Center core + Circle() + .fill(Color.primary.opacity(0.8)) + .frame(width: 8, height: 8) + // Orbiting planets + ForEach(0..<5, id: \.self) { index in + Circle() + .fill(Color.accentColor) + .frame(width: 6, height: 6) + .offset(orbitOffset(index: index, total: 5, radius: 16)) + } + } } } @@ -142,6 +161,17 @@ struct VotingLayoutPickerView: View { height: -radius * CGFloat(sin(angle)) + 4 ) } + + private func orbitOffset(index: Int, total: Int, radius: CGFloat) -> CGSize { + // Start from top (-π/2) and go clockwise + let startAngle = -Double.pi / 2 + let angleStep = (2 * Double.pi) / Double(total) + let angle = startAngle + angleStep * Double(index) + return CGSize( + width: radius * CGFloat(cos(angle)), + height: radius * CGFloat(sin(angle)) + ) + } } struct VotingLayoutPickerView_Previews: PreviewProvider { diff --git a/Shared/Views/EntryListView.swift b/Shared/Views/EntryListView.swift index b2c38ae..a5d0e71 100644 --- a/Shared/Views/EntryListView.swift +++ b/Shared/Views/EntryListView.swift @@ -66,6 +66,8 @@ struct EntryListView: View { motionStyle case .micro: microStyle + case .orbit: + orbitStyle } } .accessibilityElement(children: .combine) @@ -1767,6 +1769,163 @@ struct EntryListView: View { ) ) } + + // MARK: - Orbit Style (Celestial Circular) + private var orbitStyle: some View { + OrbitEntryView( + entry: entry, + imagePack: imagePack, + moodColor: moodColor, + textColor: textColor, + colorScheme: colorScheme, + isMissing: isMissing + ) + } +} + +// MARK: - Orbit Entry View +struct OrbitEntryView: View { + let entry: MoodEntryModel + let imagePack: MoodImages + let moodColor: Color + let textColor: Color + let colorScheme: ColorScheme + let isMissing: Bool + + 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(.white) + .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(entry.forDate, format: .dateTime.day()) + .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(entry.forDate, format: .dateTime.weekday(.wide)) + .font(.body.weight(.semibold)) + .foregroundColor(textColor) + + Text(entry.forDate, format: .dateTime.month(.abbreviated).year()) + .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