Add Orbit style for voting layout and entry views
- Add orbit case to VotingLayoutStyle enum with celestial design - Create OrbitVotingView with center core and orbiting mood planets - Add orbit case to DayViewStyle enum for entry list - Create OrbitEntryView with mood icon center and orbiting day number - Add orbit icons to voting and entry style pickers in CustomizeView 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ enum VotingLayoutStyle: Int, CaseIterable {
|
|||||||
case radial = 2 // Semi-circle/wheel arrangement
|
case radial = 2 // Semi-circle/wheel arrangement
|
||||||
case stacked = 3 // Full-width vertical list
|
case stacked = 3 // Full-width vertical list
|
||||||
case aura = 4 // Atmospheric glowing orbs with flowing layout
|
case aura = 4 // Atmospheric glowing orbs with flowing layout
|
||||||
|
case orbit = 5 // Celestial orbit with center core
|
||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -21,6 +22,7 @@ enum VotingLayoutStyle: Int, CaseIterable {
|
|||||||
case .radial: return "Radial"
|
case .radial: return "Radial"
|
||||||
case .stacked: return "Stacked"
|
case .stacked: return "Stacked"
|
||||||
case .aura: return "Aura"
|
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 glass = 16 // iOS 26 liquid glass with variable blur
|
||||||
case motion = 17 // Accelerometer-driven parallax effect
|
case motion = 17 // Accelerometer-driven parallax effect
|
||||||
case micro = 18 // Ultra compact single-line entries
|
case micro = 18 // Ultra compact single-line entries
|
||||||
|
case orbit = 19 // Celestial circular orbital arrangement
|
||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -67,6 +70,7 @@ enum DayViewStyle: Int, CaseIterable {
|
|||||||
case .glass: return "Glass"
|
case .glass: return "Glass"
|
||||||
case .motion: return "Motion"
|
case .motion: return "Motion"
|
||||||
case .micro: return "Micro"
|
case .micro: return "Micro"
|
||||||
|
case .orbit: return "Orbit"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ struct AddMoodHeaderView: View {
|
|||||||
StackedVotingView(moodTint: moodTint, onMoodSelected: addItem)
|
StackedVotingView(moodTint: moodTint, onMoodSelected: addItem)
|
||||||
case .aura:
|
case .aura:
|
||||||
AuraVotingView(moodTint: moodTint, onMoodSelected: addItem)
|
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
|
// Custom button style for aura with glow effect on press
|
||||||
struct AuraButtonStyle: ButtonStyle {
|
struct AuraButtonStyle: ButtonStyle {
|
||||||
let color: Color
|
let color: Color
|
||||||
|
|||||||
@@ -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))
|
let angle = Double.pi - (Double.pi * Double(index) / Double(total - 1))
|
||||||
return CGSize(width: radius * CGFloat(cos(angle)), height: -radius * CGFloat(sin(angle)) + 4)
|
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
|
// MARK: - Custom Widget Section
|
||||||
@@ -1216,6 +1239,20 @@ struct DayViewStylePickerCompact: View {
|
|||||||
.frame(width: 20, height: 4)
|
.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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
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 {
|
struct VotingLayoutPickerView_Previews: PreviewProvider {
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ struct EntryListView: View {
|
|||||||
motionStyle
|
motionStyle
|
||||||
case .micro:
|
case .micro:
|
||||||
microStyle
|
microStyle
|
||||||
|
case .orbit:
|
||||||
|
orbitStyle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.accessibilityElement(children: .combine)
|
.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
|
// MARK: - Motion Card with Accelerometer
|
||||||
|
|||||||
Reference in New Issue
Block a user