Files
Reflect/Shared/Views/AddMoodHeaderView.swift
Trey t 0442eab1f8 Rebrand entire project from Feels to Reflect
Complete rename across all bundle IDs, App Groups, CloudKit containers,
StoreKit product IDs, data store filenames, URL schemes, logger subsystems,
Swift identifiers, user-facing strings (7 languages), file names, directory
names, Xcode project, schemes, assets, and documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 11:47:16 -06:00

715 lines
26 KiB
Swift

//
// AddMoodHeaderView.swift
// Reflect
//
// Created by Trey Tartt on 1/5/22.
//
import Foundation
import SwiftUI
import SwiftData
struct AddMoodHeaderView: View {
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@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.votingLayoutStyle.rawValue, store: GroupUserDefaults.groupDefaults) private var votingLayoutStyle: Int = 0
@AppStorage(UserDefaultsStore.Keys.celebrationAnimation.rawValue, store: GroupUserDefaults.groupDefaults) private var celebrationAnimationIndex: Int = 0
private var textColor: Color { theme.currentTheme.labelColor }
@State var onboardingData = OnboardingDataDataManager.shared.savedOnboardingData
// Celebration animation state
@State private var showCelebration = false
@State private var celebrationMood: Mood = .great
@State private var celebrationAnimation: CelebrationAnimationType = .pulseWave
@State private var celebrationDate: Date = Date()
let addItemHeaderClosure: ((Mood, Date) -> Void)
init(addItemHeaderClosure: @escaping ((Mood, Date) -> Void)) {
self.addItemHeaderClosure = addItemHeaderClosure
}
private var layoutStyle: VotingLayoutStyle {
VotingLayoutStyle(rawValue: votingLayoutStyle) ?? .horizontal
}
var body: some View {
ZStack {
// Force re-render when image pack changes
Text(String(imagePack.rawValue))
.hidden()
VStack(spacing: 16) {
Text(ShowBasedOnVoteLogics.getVotingTitle(onboardingData: onboardingData))
.font(.title2.bold())
.foregroundColor(textColor)
.padding(.top)
votingLayoutContent
.padding(.bottom)
}
.padding(.horizontal)
.opacity(showCelebration ? 0 : 1)
// Celebration animation overlay
if showCelebration {
CelebrationOverlayView(
animationType: celebrationAnimation,
mood: celebrationMood
) {
// Animation complete - save the mood (parent will remove this view)
addItemHeaderClosure(celebrationMood, celebrationDate)
}
}
}
.background(theme.currentTheme.secondaryBGColor)
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
.fixedSize(horizontal: false, vertical: true)
.accessibilityIdentifier(AccessibilityID.DayView.moodHeader)
}
@ViewBuilder
private var votingLayoutContent: some View {
switch layoutStyle {
case .horizontal:
HorizontalVotingView(moodTint: moodTint, onMoodSelected: addItem)
case .cards:
CardVotingView(moodTint: moodTint, onMoodSelected: addItem)
case .stacked:
StackedVotingView(moodTint: moodTint, onMoodSelected: addItem)
case .aura:
AuraVotingView(moodTint: moodTint, onMoodSelected: addItem)
case .orbit:
OrbitVotingView(moodTint: moodTint, onMoodSelected: addItem)
case .neon:
NeonVotingView(moodTint: moodTint, onMoodSelected: addItem)
}
}
private func addItem(withMood mood: Mood) {
let impactFeedback = UIImpactFeedbackGenerator(style: .medium)
impactFeedback.impactOccurred()
// Store mood, date, and use saved animation preference
celebrationMood = mood
celebrationDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: onboardingData)
celebrationAnimation = CelebrationAnimationType.fromIndex(celebrationAnimationIndex)
// Show celebration - mood will be saved when animation completes
withAnimation(.easeInOut(duration: 0.3)) {
showCelebration = true
}
}
}
// MARK: - Layout 1: Horizontal (Polished version of current)
struct HorizontalVotingView: View {
let moodTint: MoodTints
let onMoodSelected: (Mood) -> Void
var body: some View {
HStack(spacing: 8) {
ForEach(Mood.allValues) { mood in
Button(action: { onMoodSelected(mood) }) {
mood.icon
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 55, height: 55)
.foregroundColor(moodTint.color(forMood: mood))
}
.buttonStyle(MoodButtonStyle())
.frame(maxWidth: .infinity)
.accessibilityIdentifier(AccessibilityID.MoodButton.id(for: mood.widgetDisplayName))
.accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Select this mood"))
}
}
.accessibilityElement(children: .contain)
.accessibilityLabel(String(localized: "Mood selection"))
}
}
// MARK: - Layout 2: Cards Grid
struct CardVotingView: View {
let moodTint: MoodTints
let onMoodSelected: (Mood) -> Void
var body: some View {
GeometryReader { geo in
let spacing: CGFloat = 12
let cardWidth = (geo.size.width - spacing * 2) / 3
// Offset to center bottom row cards between top row cards
// Each bottom card should be centered between two top cards
let bottomOffset = (cardWidth + spacing) / 2
VStack(spacing: spacing) {
// Top row: Great, Good, Average
HStack(spacing: spacing) {
ForEach(Array(Mood.allValues.prefix(3))) { mood in
cardButton(for: mood, width: cardWidth)
}
}
// Bottom row: Bad, Horrible - centered between top row items
HStack(spacing: spacing) {
ForEach(Array(Mood.allValues.suffix(2))) { mood in
cardButton(for: mood, width: cardWidth)
}
}
.padding(.leading, bottomOffset)
.padding(.trailing, bottomOffset)
}
}
.frame(height: 190)
.accessibilityElement(children: .contain)
.accessibilityLabel(String(localized: "Mood selection"))
}
private func cardButton(for mood: Mood, width: CGFloat) -> some View {
Button(action: { onMoodSelected(mood) }) {
mood.icon
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 40, height: 40)
.foregroundColor(moodTint.color(forMood: mood))
.frame(width: width)
.padding(.vertical, 20)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(moodTint.color(forMood: mood).opacity(0.15))
)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(moodTint.color(forMood: mood).opacity(0.3), lineWidth: 1)
)
}
.buttonStyle(CardButtonStyle())
.accessibilityIdentifier(AccessibilityID.MoodButton.id(for: mood.widgetDisplayName))
.accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Select this mood"))
}
}
// MARK: - Layout 3: Stacked Full-width
struct StackedVotingView: View {
let moodTint: MoodTints
let onMoodSelected: (Mood) -> Void
var body: some View {
VStack(spacing: 10) {
ForEach(Mood.allValues) { mood in
Button(action: { onMoodSelected(mood) }) {
HStack(spacing: 16) {
mood.icon
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 36, height: 36)
.foregroundColor(moodTint.color(forMood: mood))
Text(mood.strValue)
.font(.body.weight(.semibold))
.foregroundColor(moodTint.color(forMood: mood))
Spacer()
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundColor(moodTint.color(forMood: mood).opacity(0.5))
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(moodTint.color(forMood: mood).opacity(0.12))
)
}
.buttonStyle(CardButtonStyle())
.accessibilityIdentifier(AccessibilityID.MoodButton.id(for: mood.widgetDisplayName))
.accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Select this mood"))
}
}
.accessibilityElement(children: .contain)
.accessibilityLabel(String(localized: "Mood selection"))
}
}
// MARK: - Layout 5: Aura (Atmospheric glowing orbs)
struct AuraVotingView: View {
let moodTint: MoodTints
let onMoodSelected: (Mood) -> Void
@Environment(\.colorScheme) private var colorScheme
var body: some View {
VStack(spacing: 24) {
// Top row: 3 moods (Horrible, Bad, Average)
HStack(spacing: 16) {
ForEach(Array(Mood.allValues.prefix(3))) { mood in
auraButton(for: mood)
}
}
// Bottom row: 2 moods (Good, Great) - centered
HStack(spacing: 24) {
ForEach(Array(Mood.allValues.suffix(2))) { mood in
auraButton(for: mood)
}
}
}
.padding(.vertical, 8)
}
private func auraButton(for mood: Mood) -> some View {
let color = moodTint.color(forMood: mood)
return Button(action: { onMoodSelected(mood) }) {
// Glowing orb
ZStack {
// Outer atmospheric glow
Circle()
.fill(
RadialGradient(
colors: [
color.opacity(0.5),
color.opacity(0.2),
Color.clear
],
center: .center,
startRadius: 0,
endRadius: 45
)
)
.frame(width: 90, height: 90)
// Middle glow ring
Circle()
.fill(
RadialGradient(
colors: [
color.opacity(0.8),
color.opacity(0.4)
],
center: .center,
startRadius: 10,
endRadius: 30
)
)
.frame(width: 60, height: 60)
// Inner solid core
Circle()
.fill(color)
.frame(width: 48, height: 48)
.shadow(color: color.opacity(0.8), radius: 12, x: 0, y: 0)
// Icon
mood.icon
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 26, height: 26)
.foregroundColor(.white)
}
}
.buttonStyle(AuraButtonStyle(color: color))
.accessibilityIdentifier(AccessibilityID.MoodButton.id(for: mood.widgetDisplayName))
.accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Select this mood"))
}
}
// 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
}
}
.onDisappear {
centerPulse = 1.0
}
.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)
.accessibilityIdentifier(AccessibilityID.MoodButton.id(for: mood.widgetDisplayName))
.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
@Environment(\.accessibilityReduceMotion) private var reduceMotion
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.92 : 1.0)
.brightness(configuration.isPressed ? 0.1 : 0)
.animation(reduceMotion ? nil : .easeInOut(duration: 0.15), value: configuration.isPressed)
}
}
// MARK: - Button Styles
struct MoodButtonStyle: ButtonStyle {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.9 : 1.0)
.animation(reduceMotion ? nil : .easeInOut(duration: 0.15), value: configuration.isPressed)
}
}
struct CardButtonStyle: ButtonStyle {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.96 : 1.0)
.opacity(configuration.isPressed ? 0.8 : 1.0)
.animation(reduceMotion ? nil : .easeInOut(duration: 0.15), value: configuration.isPressed)
}
}
// MARK: - Previews
struct AddMoodHeaderView_Previews: PreviewProvider {
static var previews: some View {
Group {
AddMoodHeaderView(addItemHeaderClosure: { (_,_) in
}).modelContainer(DataController.shared.container)
AddMoodHeaderView(addItemHeaderClosure: { (_,_) in
}).preferredColorScheme(.dark).modelContainer(DataController.shared.container)
}
}
}
// MARK: - Layout 7: Neon (Synthwave Arcade Equalizer)
struct NeonVotingView: View {
let moodTint: MoodTints
let onMoodSelected: (Mood) -> Void
@State private var pulsePhase = false
@State private var hoveredMood: Mood?
// Synthwave color palette
private let neonCyan = Color(red: 0.0, green: 1.0, blue: 0.82)
private let neonMagenta = Color(red: 1.0, green: 0.0, blue: 0.8)
private let neonYellow = Color(red: 1.0, green: 0.9, blue: 0.0)
private let deepBlack = Color(red: 0.02, green: 0.02, blue: 0.04)
var body: some View {
ZStack {
// Grid background
neonGridBackground
// Equalizer bars
HStack(spacing: 8) {
ForEach(Mood.allValues, id: \.self) { mood in
NeonEqualizerBar(
mood: mood,
moodTint: moodTint,
isHovered: hoveredMood == mood,
pulsePhase: pulsePhase,
neonCyan: neonCyan,
neonMagenta: neonMagenta,
onTap: { onMoodSelected(mood) }
)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 20)
}
.frame(height: 200)
.clipShape(RoundedRectangle(cornerRadius: 16))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(
LinearGradient(
colors: [neonCyan.opacity(0.6), neonMagenta.opacity(0.6)],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
lineWidth: 1
)
)
.shadow(color: neonCyan.opacity(0.2), radius: 20, x: 0, y: 0)
.shadow(color: neonMagenta.opacity(0.15), radius: 30, x: 0, y: 10)
.onAppear {
withAnimation(.easeInOut(duration: 1.5).repeatForever(autoreverses: true)) {
pulsePhase = true
}
}
.onDisappear {
pulsePhase = false
}
}
private var neonGridBackground: some View {
ZStack {
// Deep black base
deepBlack
// Grid
Canvas { context, size in
let gridSpacing: CGFloat = 20
let cyanColor = Color(red: 0.0, green: 0.8, blue: 0.7)
// Horizontal lines
for y in stride(from: 0, to: 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(cyanColor.opacity(0.08)), lineWidth: 0.5)
}
// Vertical lines
for x in stride(from: 0, to: 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(cyanColor.opacity(0.08)), lineWidth: 0.5)
}
}
// Ambient glow at bottom
LinearGradient(
colors: [
neonMagenta.opacity(0.15),
Color.clear
],
startPoint: .bottom,
endPoint: .top
)
.blur(radius: 30)
}
}
}
struct NeonEqualizerBar: View {
let mood: Mood
let moodTint: MoodTints
let isHovered: Bool
let pulsePhase: Bool
let neonCyan: Color
let neonMagenta: Color
let onTap: () -> Void
@State private var isPressed = false
private var barHeight: CGFloat {
switch mood {
case .great: return 140
case .good: return 115
case .average: return 90
case .bad: return 65
case .horrible: return 45
default: return 90
}
}
private var barColor: Color {
switch mood {
case .great: return neonCyan
case .good: return Color(red: 0.2, green: 1.0, blue: 0.6)
case .average: return Color(red: 1.0, green: 0.9, blue: 0.0)
case .bad: return Color(red: 1.0, green: 0.5, blue: 0.0)
case .horrible: return neonMagenta
default: return Color(red: 1.0, green: 0.9, blue: 0.0)
}
}
var body: some View {
Button(action: onTap) {
// The equalizer bar
ZStack(alignment: .bottom) {
// Glow background
RoundedRectangle(cornerRadius: 6)
.fill(barColor.opacity(pulsePhase ? 0.15 : 0.08))
.frame(height: barHeight + 20)
.blur(radius: 15)
// Main bar
RoundedRectangle(cornerRadius: 6)
.fill(
LinearGradient(
colors: [
barColor,
barColor.opacity(0.7)
],
startPoint: .top,
endPoint: .bottom
)
)
.frame(height: isPressed ? barHeight * 0.9 : barHeight)
.shadow(color: barColor.opacity(0.8), radius: pulsePhase ? 12 : 8, x: 0, y: 0)
.shadow(color: barColor.opacity(0.4), radius: pulsePhase ? 20 : 15, x: 0, y: 5)
// Top highlight
RoundedRectangle(cornerRadius: 6)
.fill(
LinearGradient(
colors: [Color.white.opacity(0.5), Color.clear],
startPoint: .top,
endPoint: .center
)
)
.frame(height: isPressed ? barHeight * 0.9 : barHeight)
// Level indicators (horizontal lines)
VStack(spacing: 8) {
ForEach(0..<Int(barHeight / 15), id: \.self) { _ in
Rectangle()
.fill(Color.black.opacity(0.3))
.frame(height: 2)
}
}
.frame(height: isPressed ? barHeight * 0.9 - 10 : barHeight - 10)
.clipShape(RoundedRectangle(cornerRadius: 4))
.padding(.horizontal, 4)
.padding(.bottom, 5)
}
.frame(maxHeight: 180, alignment: .bottom)
}
.buttonStyle(NeonBarButtonStyle(isPressed: $isPressed))
.frame(maxWidth: .infinity)
.accessibilityIdentifier(AccessibilityID.MoodButton.id(for: mood.widgetDisplayName))
.accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Select this mood"))
}
}
struct NeonBarButtonStyle: ButtonStyle {
@Binding var isPressed: Bool
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.95 : 1.0)
.animation(.easeInOut(duration: 0.1), value: configuration.isPressed)
.onChange(of: configuration.isPressed) { _, newValue in
isPressed = newValue
}
}
}