Add task completion animations and fix 7-day task count

Animation Testing:
- Add AnimationTesting module with 14 animation types for task completion
- Include 4 celebration animations: Implode, Firework, Starburst, Ripple
- Card shrinks, shows checkmark with effect, then moves to next column
- Extended timing (2.2s) for celebration animations

Task Count Fix:
- Fix "7 Days" and "30 Days" counts on residence cards and dashboard
- Previously used API column membership (30-day "due soon" column)
- Now calculates actual days until due from task's effectiveDueDate
- Correctly counts tasks due within 7 days vs 8-30 days

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-26 21:21:48 -06:00
parent 556b187508
commit 3274924937
8 changed files with 1553 additions and 6 deletions

View File

@@ -0,0 +1,100 @@
import SwiftUI
struct AnimationTestingCard: View {
let task: TestTask
let onComplete: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: AppSpacing.sm) {
// Header with title and priority
HStack(alignment: .top) {
Text(task.title)
.font(.system(size: 16, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
.lineLimit(2)
Spacer()
priorityBadge
}
// Description
if let description = task.description {
Text(description)
.font(.system(size: 13, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.lineLimit(2)
}
// Metadata pills
HStack(spacing: AppSpacing.sm) {
metadataPill(icon: "repeat", text: task.frequency)
Spacer()
metadataPill(icon: "calendar", text: task.dueDate)
}
// Complete button
Button(action: onComplete) {
HStack(spacing: 6) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 14, weight: .semibold))
Text("Complete")
.font(.system(size: 14, weight: .semibold, design: .rounded))
}
.frame(maxWidth: .infinity)
.padding(.vertical, AppSpacing.sm)
.foregroundColor(Color.appTextOnPrimary)
.background(Color.appPrimary)
.cornerRadius(AppRadius.md)
}
.buttonStyle(.plain)
}
.padding(AppSpacing.md)
.background(Color.appBackgroundSecondary)
.cornerRadius(AppRadius.lg)
.shadow(color: Color.black.opacity(0.1), radius: 5, x: 0, y: 2)
}
private var priorityBadge: some View {
Text(task.priority.rawValue)
.font(.system(size: 10, weight: .bold, design: .rounded))
.foregroundColor(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(task.priority.color)
.cornerRadius(AppRadius.sm)
}
private func metadataPill(icon: String, text: String) -> some View {
HStack(spacing: 4) {
Image(systemName: icon)
.font(.system(size: 10))
Text(text)
.font(.system(size: 11, weight: .medium, design: .rounded))
}
.foregroundColor(Color.appTextSecondary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.appBackgroundPrimary.opacity(0.6))
.cornerRadius(AppRadius.sm)
}
}
// MARK: - Preview
#Preview {
ZStack {
Color.appBackgroundPrimary.ignoresSafeArea()
VStack(spacing: 16) {
AnimationTestingCard(task: TestTask.samples[0]) {
print("Complete tapped")
}
AnimationTestingCard(task: TestTask.samples[1]) {
print("Complete tapped")
}
}
.padding()
}
}

View File

@@ -0,0 +1,301 @@
import SwiftUI
struct AnimationTestingView: View {
@Environment(\.dismiss) private var dismiss
// Animation selection
@State private var selectedAnimation: TaskAnimationType = .slide
// Fake task data
@State private var columns: [TestColumn] = TestColumn.defaultColumns
// Track animating task
@State private var animatingTaskId: String? = nil
@State private var animationPhase: AnimationPhase = .idle
var body: some View {
NavigationStack {
ZStack {
WarmGradientBackground()
.ignoresSafeArea()
VStack(spacing: 0) {
// Animation picker at top
animationPicker
// Kanban columns
kanbanBoard
// Reset button at bottom
resetButton
}
}
.navigationTitle("Animation Testing")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
}
}
}
// MARK: - Animation Picker
private var animationPicker: some View {
VStack(alignment: .leading, spacing: AppSpacing.xs) {
HStack {
Text("Animation Type")
.font(.system(size: 13, weight: .semibold, design: .rounded))
.foregroundColor(Color.appTextSecondary)
Spacer()
Text(selectedAnimation.description)
.font(.system(size: 11, weight: .medium, design: .rounded))
.foregroundColor(Color.appTextSecondary)
.lineLimit(1)
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: AppSpacing.xs) {
ForEach(TaskAnimationType.allCases) { animation in
AnimationChip(
animation: animation,
isSelected: selectedAnimation == animation,
onSelect: {
withAnimation(.easeInOut(duration: 0.2)) {
selectedAnimation = animation
}
}
)
}
}
}
}
.padding(.horizontal, AppSpacing.md)
.padding(.vertical, AppSpacing.sm)
.background(Color.appBackgroundSecondary.opacity(0.95))
}
// MARK: - Kanban Board
private var kanbanBoard: some View {
GeometryReader { geometry in
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: AppSpacing.md) {
ForEach(columns) { column in
TestColumnView(
column: column,
columnWidth: min(geometry.size.width * 0.75, 300),
selectedAnimation: selectedAnimation,
animatingTaskId: animatingTaskId,
animationPhase: animationPhase,
onComplete: { task in
completeTask(task, fromColumn: column)
}
)
}
}
.padding(.horizontal, AppSpacing.md)
.padding(.vertical, AppSpacing.sm)
}
}
}
// MARK: - Reset Button
private var resetButton: some View {
Button(action: resetTasks) {
HStack(spacing: AppSpacing.sm) {
Image(systemName: "arrow.counterclockwise")
Text("Reset All Tasks")
}
.font(.system(size: 15, weight: .semibold, design: .rounded))
.foregroundColor(Color.appPrimary)
.frame(maxWidth: .infinity)
.padding(.vertical, AppSpacing.md)
.background(Color.appPrimary.opacity(0.1))
.cornerRadius(AppRadius.md)
}
.padding(.horizontal, AppSpacing.md)
.padding(.bottom, AppSpacing.md)
}
// MARK: - Actions
private func completeTask(_ task: TestTask, fromColumn: TestColumn) {
guard animatingTaskId == nil else { return } // Prevent concurrent animations
// Find the next column
guard let currentIndex = columns.firstIndex(where: { $0.id == fromColumn.id }),
currentIndex < columns.count - 1 else {
return // Already in last column
}
animatingTaskId = task.id
// Extended timing animations: shrink card, show checkmark, THEN move task
if selectedAnimation.needsExtendedTiming {
// Phase 1: Start shrinking
withAnimation {
animationPhase = .exiting
}
// Phase 2: Fully shrunk, show checkmark
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
withAnimation {
animationPhase = .complete // This triggers the checkmark overlay
}
}
// Phase 3: After checkmark animation, move the task
DispatchQueue.main.asyncAfter(deadline: .now() + 2.2) {
// Remove from current column
if let taskIndex = columns[currentIndex].tasks.firstIndex(where: { $0.id == task.id }) {
columns[currentIndex].tasks.remove(at: taskIndex)
}
// Add to next column
columns[currentIndex + 1].tasks.insert(task, at: 0)
// Reset
animatingTaskId = nil
animationPhase = .idle
}
return
}
// Standard animations: move task during animation
// Phase 1: Exiting
withAnimation {
animationPhase = .exiting
}
// Phase 2: Moving (after short delay)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
withAnimation {
animationPhase = .moving
}
}
// Phase 3: Actually move the task and enter
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
// Remove from current column
if let taskIndex = columns[currentIndex].tasks.firstIndex(where: { $0.id == task.id }) {
columns[currentIndex].tasks.remove(at: taskIndex)
}
// Add to next column
columns[currentIndex + 1].tasks.insert(task, at: 0)
withAnimation {
animationPhase = .entering
}
}
// Phase 4: Complete
DispatchQueue.main.asyncAfter(deadline: .now() + 0.55) {
withAnimation {
animationPhase = .complete
}
}
// Reset animation state
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
animatingTaskId = nil
animationPhase = .idle
}
}
private func resetTasks() {
withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) {
columns = TestColumn.defaultColumns
animatingTaskId = nil
animationPhase = .idle
}
}
}
// MARK: - Test Column View
struct TestColumnView: View {
let column: TestColumn
let columnWidth: CGFloat
let selectedAnimation: TaskAnimationType
let animatingTaskId: String?
let animationPhase: AnimationPhase
let onComplete: (TestTask) -> Void
var body: some View {
VStack(alignment: .leading, spacing: AppSpacing.sm) {
// Column header
HStack(spacing: AppSpacing.xs) {
Circle()
.fill(column.color)
.frame(width: 10, height: 10)
Text(column.displayName)
.font(.system(size: 15, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
Spacer()
Text("\(column.tasks.count)")
.font(.system(size: 13, weight: .semibold, design: .rounded))
.foregroundColor(Color.appTextOnPrimary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(column.color)
.cornerRadius(AppRadius.sm)
}
.padding(.horizontal, AppSpacing.sm)
.padding(.vertical, AppSpacing.xs)
// Tasks
if column.tasks.isEmpty {
emptyState
} else {
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: AppSpacing.sm) {
ForEach(column.tasks) { task in
AnimationTestingCard(task: task) {
onComplete(task)
}
.taskAnimation(
type: selectedAnimation,
phase: animatingTaskId == task.id ? animationPhase : .idle
)
}
}
}
}
Spacer(minLength: 0)
}
.frame(width: columnWidth)
.padding(AppSpacing.sm)
.background(Color.appBackgroundSecondary.opacity(0.5))
.cornerRadius(AppRadius.lg)
}
private var emptyState: some View {
VStack(spacing: AppSpacing.sm) {
Image(systemName: "tray")
.font(.system(size: 32))
.foregroundColor(Color.appTextSecondary.opacity(0.4))
Text("No Tasks")
.font(.system(size: 13, weight: .medium, design: .rounded))
.foregroundColor(Color.appTextSecondary.opacity(0.6))
}
.frame(maxWidth: .infinity)
.padding(.vertical, AppSpacing.xl)
}
}
// MARK: - Preview
#Preview {
AnimationTestingView()
}

File diff suppressed because it is too large Load Diff