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>
This commit is contained in:
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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
|
||||
|
||||
301
iosApp/Casera/CaseraIconView.swift
Normal file
301
iosApp/Casera/CaseraIconView.swift
Normal file
@@ -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..<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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,13 @@
|
||||
);
|
||||
target = 1C81F27F2EE41BB6000739EA /* CaseraQLThumbnail */;
|
||||
};
|
||||
1C81F38A2EE5E430000739EA /* Exceptions for "Casera" folder in "Casera" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
CaseraIconView.swift,
|
||||
);
|
||||
target = D4ADB376A7A4CFB73469E173 /* Casera */;
|
||||
};
|
||||
1C87A67A2EDCC3100081E450 /* Exceptions for "iosApp" folder in "CaseraExtension" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
@@ -150,6 +157,7 @@
|
||||
1C0789432EBC218B00392B46 /* Casera */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
1C81F38A2EE5E430000739EA /* Exceptions for "Casera" folder in "Casera" target */,
|
||||
1C0789572EBC218D00392B46 /* Exceptions for "Casera" folder in "CaseraExtension" target */,
|
||||
);
|
||||
path = Casera;
|
||||
|
||||
@@ -17410,6 +17410,10 @@
|
||||
"comment" : "A button label that says \"Mark Task In Progress\".",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"MyCrib Icon Animations" : {
|
||||
"comment" : "The title of the playground interface.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Need inspiration?" : {
|
||||
|
||||
},
|
||||
@@ -21334,6 +21338,10 @@
|
||||
},
|
||||
"Remove User" : {
|
||||
|
||||
},
|
||||
"Replay" : {
|
||||
"comment" : "A button that replays the current animation.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Reset Password" : {
|
||||
"comment" : "The title of the screen where users can reset their passwords.",
|
||||
|
||||
@@ -3,24 +3,27 @@ import ComposeApp
|
||||
|
||||
struct ResidenceCard: View {
|
||||
let residence: ResidenceResponse
|
||||
|
||||
|
||||
/// Check if this residence has any overdue tasks
|
||||
private var hasOverdueTasks: Bool {
|
||||
Int(residence.overdueCount) > 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"
|
||||
))
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user