Files
honeyDueKMP/iosApp/Casera/CaseraIconView.swift
Trey t bb4ff216b1 Add pulsing icon for residences with overdue tasks
- Add overdueCount field to ResidenceResponse model
- ResidenceCard shows PulsingIconView when residence has overdue tasks
- SummaryCard uses static CaseraIconView (never pulses)
- APILayer refreshes residence data after task completion to update
  overdue counts and animation state

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-07 12:19:28 -06:00

302 lines
9.6 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 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
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 body: some View {
CaseraIconView()
.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()
}
}
}