diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/models/Residence.kt b/composeApp/src/commonMain/kotlin/com/example/casera/models/Residence.kt index d57daae..d8e30d0 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/models/Residence.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/models/Residence.kt @@ -51,6 +51,7 @@ data class ResidenceResponse( @SerialName("purchase_price") val purchasePrice: Double? = null, @SerialName("is_primary") val isPrimary: Boolean = false, @SerialName("is_active") val isActive: Boolean = true, + @SerialName("overdue_count") val overdueCount: Int = 0, @SerialName("created_at") val createdAt: String, @SerialName("updated_at") val updatedAt: String ) { diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt index 7a6059f..1379344 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt @@ -658,8 +658,8 @@ object APILayer { result.data.updatedTask?.let { updatedTask -> DataManager.updateTask(updatedTask) } - // Refresh summary counts (tasksDueNextWeek, etc.) - lightweight API call - refreshSummary() + // Refresh my-residences to update per-residence overdueCount and summary + refreshMyResidences() } return result @@ -678,8 +678,8 @@ object APILayer { result.data.updatedTask?.let { updatedTask -> DataManager.updateTask(updatedTask) } - // Refresh summary counts (tasksDueNextWeek, etc.) - lightweight API call - refreshSummary() + // Refresh my-residences to update per-residence overdueCount and summary + refreshMyResidences() } return result diff --git a/iosApp/Casera/CaseraIconView.swift b/iosApp/Casera/CaseraIconView.swift new file mode 100644 index 0000000..af61eb8 --- /dev/null +++ b/iosApp/Casera/CaseraIconView.swift @@ -0,0 +1,301 @@ +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.. 0 + } + var body: some View { VStack(alignment: .leading, spacing: AppSpacing.md) { - // Header with property type icon + // Header with property type icon (pulses when overdue tasks exist) HStack(spacing: AppSpacing.sm) { VStack { - Image("house_outline") - .resizable() - .frame(width: 44, height: 44) - .foregroundColor(Color.appTextOnPrimary) - .background(content: { - RoundedRectangle(cornerRadius: AppRadius.sm) - .fill(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing)) - .frame(width: 44, height: 44) - .shadow(color: Color.appPrimary.opacity(0.3), radius: 6, y: 3) - }) - .padding([.trailing], AppSpacing.md) - + if hasOverdueTasks { + PulsingIconView() + .frame(width: 44, height: 44) + .padding([.trailing], AppSpacing.md) + } else { + CaseraIconView() + .frame(width: 44, height: 44) + .padding([.trailing], AppSpacing.md) + } + Spacer() } @@ -132,6 +135,7 @@ struct ResidenceCard: View { purchasePrice: nil, isPrimary: true, isActive: true, + overdueCount: 1, createdAt: "2024-01-01T00:00:00Z", updatedAt: "2024-01-01T00:00:00Z" )) diff --git a/iosApp/iosApp/Subviews/Residence/SummaryCard.swift b/iosApp/iosApp/Subviews/Residence/SummaryCard.swift index 09d9a4c..035825a 100644 --- a/iosApp/iosApp/Subviews/Residence/SummaryCard.swift +++ b/iosApp/iosApp/Subviews/Residence/SummaryCard.swift @@ -7,8 +7,9 @@ struct SummaryCard: View { var body: some View { VStack(spacing: 16) { HStack { - Image(systemName: "chart.bar.doc.horizontal") - .font(.title3) + CaseraIconView() + .frame(width: 32, height: 32) + Text("Overview") .font(.title3) .fontWeight(.bold)