- Add backgroundColor parameter to CaseraIconView and PulsingIconView - Update ResidenceCard to pass Color.appPrimary for themed icon background - Icon now matches the app's current theme like PropertyHeaderCard 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
317 lines
10 KiB
Swift
317 lines
10 KiB
Swift
import SwiftUI
|
|
|
|
// MARK: - Centered Icon View
|
|
|
|
struct CaseraIconView: View {
|
|
var houseProgress: CGFloat = 1.0
|
|
var windowScale: CGFloat = 1.0
|
|
var checkmarkScale: CGFloat = 1.0
|
|
var foregroundColor: Color = Color(red: 1.0, green: 0.96, blue: 0.92)
|
|
var backgroundColor: Color? = nil // nil uses default gradient, otherwise uses theme color
|
|
|
|
var body: some View {
|
|
GeometryReader { geo in
|
|
let size = min(geo.size.width, geo.size.height)
|
|
let center = CGPoint(x: geo.size.width / 2, y: geo.size.height / 2)
|
|
|
|
ZStack {
|
|
// Background - use provided color or default gradient
|
|
if let bgColor = backgroundColor {
|
|
RoundedRectangle(cornerRadius: size * 0.195)
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [bgColor, bgColor.opacity(0.85)],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
)
|
|
.frame(width: size * 0.906, height: size * 0.906)
|
|
.position(center)
|
|
} else {
|
|
RoundedRectangle(cornerRadius: size * 0.195)
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [
|
|
Color(red: 1.0, green: 0.64, blue: 0.28),
|
|
Color(red: 0.96, green: 0.51, blue: 0.20)
|
|
],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
)
|
|
.frame(width: size * 0.906, height: size * 0.906)
|
|
.position(center)
|
|
}
|
|
|
|
// House outline
|
|
HousePath(progress: houseProgress)
|
|
.stroke(foregroundColor, style: StrokeStyle(
|
|
lineWidth: size * 0.055,
|
|
lineCap: .round,
|
|
lineJoin: .round
|
|
))
|
|
.frame(width: size, height: size)
|
|
.position(center)
|
|
|
|
// Window
|
|
RoundedRectangle(cornerRadius: size * 0.023)
|
|
.fill(foregroundColor)
|
|
.frame(width: size * 0.102, height: size * 0.102)
|
|
.scaleEffect(windowScale)
|
|
.position(x: center.x, y: center.y - size * 0.09)
|
|
|
|
// Checkmark
|
|
CheckmarkShape()
|
|
.stroke(foregroundColor, style: StrokeStyle(
|
|
lineWidth: size * 0.0625,
|
|
lineCap: .round,
|
|
lineJoin: .round
|
|
))
|
|
.frame(width: size, height: size)
|
|
.scaleEffect(checkmarkScale)
|
|
.position(center)
|
|
}
|
|
}
|
|
.aspectRatio(1, contentMode: .fit)
|
|
}
|
|
}
|
|
|
|
// MARK: - House Path (both sides)
|
|
|
|
struct HousePath: Shape {
|
|
var progress: CGFloat = 1.0
|
|
|
|
var animatableData: CGFloat {
|
|
get { progress }
|
|
set { progress = newValue }
|
|
}
|
|
|
|
func path(in rect: CGRect) -> Path {
|
|
let w = rect.width
|
|
let h = rect.height
|
|
let cx = rect.midX
|
|
let cy = rect.midY
|
|
|
|
var path = Path()
|
|
|
|
// Left side: roof peak -> down left wall -> foot
|
|
path.move(to: CGPoint(x: cx, y: cy - h * 0.27))
|
|
path.addLine(to: CGPoint(x: cx - w * 0.232, y: cy - h * 0.09))
|
|
path.addQuadCurve(
|
|
to: CGPoint(x: cx - w * 0.266, y: cy + h * 0.02),
|
|
control: CGPoint(x: cx - w * 0.266, y: cy - h * 0.055)
|
|
)
|
|
path.addLine(to: CGPoint(x: cx - w * 0.266, y: cy + h * 0.195))
|
|
path.addQuadCurve(
|
|
to: CGPoint(x: cx - w * 0.207, y: cy + h * 0.254),
|
|
control: CGPoint(x: cx - w * 0.266, y: cy + h * 0.254)
|
|
)
|
|
path.addLine(to: CGPoint(x: cx - w * 0.154, y: cy + h * 0.254))
|
|
|
|
// Right side: roof peak -> down right wall -> foot
|
|
path.move(to: CGPoint(x: cx, y: cy - h * 0.27))
|
|
path.addLine(to: CGPoint(x: cx + w * 0.232, y: cy - h * 0.09))
|
|
path.addQuadCurve(
|
|
to: CGPoint(x: cx + w * 0.266, y: cy + h * 0.02),
|
|
control: CGPoint(x: cx + w * 0.266, y: cy - h * 0.055)
|
|
)
|
|
path.addLine(to: CGPoint(x: cx + w * 0.266, y: cy + h * 0.195))
|
|
path.addQuadCurve(
|
|
to: CGPoint(x: cx + w * 0.207, y: cy + h * 0.254),
|
|
control: CGPoint(x: cx + w * 0.266, y: cy + h * 0.254)
|
|
)
|
|
path.addLine(to: CGPoint(x: cx + w * 0.154, y: cy + h * 0.254))
|
|
|
|
return path.trimmedPath(from: 0, to: progress)
|
|
}
|
|
}
|
|
|
|
// MARK: - Checkmark Shape
|
|
|
|
struct CheckmarkShape: Shape {
|
|
var progress: CGFloat = 1.0
|
|
|
|
var animatableData: CGFloat {
|
|
get { progress }
|
|
set { progress = newValue }
|
|
}
|
|
|
|
func path(in rect: CGRect) -> Path {
|
|
let w = rect.width
|
|
let h = rect.height
|
|
let cx = rect.midX
|
|
let cy = rect.midY
|
|
|
|
var path = Path()
|
|
// Checkmark: starts bottom-left, goes to bottom-center, then up-right
|
|
path.move(to: CGPoint(x: cx - w * 0.158, y: cy + h * 0.145))
|
|
path.addLine(to: CGPoint(x: cx - w * 0.041, y: cy + h * 0.263))
|
|
path.addLine(to: CGPoint(x: cx + w * 0.193, y: cy + h * 0.01))
|
|
|
|
return path.trimmedPath(from: 0, to: progress)
|
|
}
|
|
}
|
|
|
|
// MARK: - Animations
|
|
|
|
struct FullIntroAnimationView: View {
|
|
@State private var houseProgress: CGFloat = 0
|
|
@State private var windowScale: CGFloat = 0
|
|
@State private var checkScale: CGFloat = 0
|
|
|
|
var body: some View {
|
|
CaseraIconView(
|
|
houseProgress: houseProgress,
|
|
windowScale: windowScale,
|
|
checkmarkScale: checkScale
|
|
)
|
|
.onAppear { animate() }
|
|
}
|
|
|
|
func animate() {
|
|
withAnimation(.easeOut(duration: 0.6)) {
|
|
houseProgress = 1.0
|
|
}
|
|
withAnimation(.spring(response: 0.4, dampingFraction: 0.6).delay(0.5)) {
|
|
windowScale = 1.0
|
|
}
|
|
withAnimation(.easeOut(duration: 0.25).delay(0.9)) {
|
|
checkScale = 1.2
|
|
}
|
|
withAnimation(.easeInOut(duration: 0.15).delay(1.15)) {
|
|
checkScale = 1.0
|
|
}
|
|
}
|
|
}
|
|
|
|
struct PulsatingCheckmarkView: View {
|
|
@State private var checkScale: CGFloat = 1.0
|
|
|
|
var body: some View {
|
|
CaseraIconView(checkmarkScale: checkScale)
|
|
.onAppear {
|
|
withAnimation(.easeInOut(duration: 0.5).repeatForever(autoreverses: true)) {
|
|
checkScale = 1.3
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct PulsingIconView: View {
|
|
@State private var scale: CGFloat = 1.0
|
|
var backgroundColor: Color? = nil
|
|
|
|
var body: some View {
|
|
CaseraIconView(backgroundColor: backgroundColor)
|
|
.scaleEffect(scale)
|
|
.onAppear {
|
|
withAnimation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true)) {
|
|
scale = 1.08
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct BouncyIconView: View {
|
|
@State private var offset: CGFloat = -300
|
|
@State private var scale: CGFloat = 0.5
|
|
|
|
var body: some View {
|
|
CaseraIconView()
|
|
.scaleEffect(scale)
|
|
.offset(y: offset)
|
|
.onAppear {
|
|
withAnimation(.spring(response: 0.6, dampingFraction: 0.5)) {
|
|
offset = 0
|
|
scale = 1.0
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct WigglingIconView: View {
|
|
@State private var angle: Double = 0
|
|
|
|
var body: some View {
|
|
CaseraIconView()
|
|
.rotationEffect(.degrees(angle))
|
|
.onAppear {
|
|
withAnimation(.easeInOut(duration: 0.1).repeatForever(autoreverses: true)) {
|
|
angle = 5
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Playground UI
|
|
|
|
struct PlaygroundContentView: View {
|
|
@State private var selectedAnimation = 0
|
|
@State private var animationKey = UUID()
|
|
|
|
let animations = ["Full Intro", "Pulsating", "Pulse", "Bounce", "Wiggle"]
|
|
|
|
var body: some View {
|
|
VStack(spacing: 20) {
|
|
Text("MyCrib Icon Animations")
|
|
.font(.title)
|
|
.fontWeight(.bold)
|
|
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 20)
|
|
.fill(Color(.systemGray6))
|
|
.frame(height: 250)
|
|
|
|
currentAnimation
|
|
.frame(width: 150, height: 150)
|
|
.id(animationKey)
|
|
}
|
|
.padding(.horizontal)
|
|
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 10) {
|
|
ForEach(0..<animations.count, id: \.self) { index in
|
|
Button {
|
|
selectedAnimation = index
|
|
animationKey = UUID()
|
|
} label: {
|
|
Text(animations[index])
|
|
.font(.caption)
|
|
.fontWeight(selectedAnimation == index ? .bold : .regular)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
.background(
|
|
Capsule().fill(selectedAnimation == index ? Color.orange : Color(.systemGray5))
|
|
)
|
|
.foregroundColor(selectedAnimation == index ? .white : .primary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.padding(.horizontal)
|
|
}
|
|
|
|
Button("Replay") { animationKey = UUID() }
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(.orange)
|
|
|
|
Spacer()
|
|
}
|
|
.padding(.top, 30)
|
|
.frame(width: 450, height: 500)
|
|
.background(Color(.systemBackground))
|
|
}
|
|
|
|
@ViewBuilder
|
|
var currentAnimation: some View {
|
|
switch selectedAnimation {
|
|
case 0: FullIntroAnimationView()
|
|
case 1: PulsatingCheckmarkView()
|
|
case 2: PulsingIconView()
|
|
case 3: BouncyIconView()
|
|
case 4: WigglingIconView()
|
|
default: CaseraIconView()
|
|
}
|
|
}
|
|
}
|