From 32749249373907f6968b73dc44866c98f7637eb8 Mon Sep 17 00:00:00 2001 From: Trey t Date: Fri, 26 Dec 2025 21:21:48 -0600 Subject: [PATCH] Add task completion animations and fix 7-day task count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- iosApp/ExportOptions.plist | 4 + .../iosApp/Data/DataManagerObservable.swift | 26 +- iosApp/iosApp/Helpers/WidgetDataManager.swift | 38 +- iosApp/iosApp/Localizable.xcstrings | 16 + .../AnimationTestingCard.swift | 100 ++ .../AnimationTestingView.swift | 301 +++++ .../AnimationTesting/TaskAnimations.swift | 1055 +++++++++++++++++ iosApp/iosApp/Profile/ProfileTabView.swift | 19 + 8 files changed, 1553 insertions(+), 6 deletions(-) create mode 100644 iosApp/iosApp/Profile/AnimationTesting/AnimationTestingCard.swift create mode 100644 iosApp/iosApp/Profile/AnimationTesting/AnimationTestingView.swift create mode 100644 iosApp/iosApp/Profile/AnimationTesting/TaskAnimations.swift diff --git a/iosApp/ExportOptions.plist b/iosApp/ExportOptions.plist index 1e55c72..50b53a4 100644 --- a/iosApp/ExportOptions.plist +++ b/iosApp/ExportOptions.plist @@ -8,9 +8,13 @@ upload signingStyle automatic + signingCertificate + Apple Distribution uploadSymbols manageAppVersionAndBuildNumber + teamID + V3PF3M6B6U diff --git a/iosApp/iosApp/Data/DataManagerObservable.swift b/iosApp/iosApp/Data/DataManagerObservable.swift index 9658fb6..b8cb24c 100644 --- a/iosApp/iosApp/Data/DataManagerObservable.swift +++ b/iosApp/iosApp/Data/DataManagerObservable.swift @@ -513,9 +513,29 @@ class DataManagerObservable: ObservableObject { } /// Convert TaskResponse array to WidgetTask array for shared calculator + /// isDueWithin7Days and isDue8To30Days are calculated from actual due dates, + /// NOT from API column membership (which uses different thresholds) private func toWidgetTasks(_ tasks: [TaskResponse], overdueIds: Set, dueWithin7DaysIds: Set, due8To30DaysIds: Set) -> [WidgetDataManager.WidgetTask] { + let today = Calendar.current.startOfDay(for: Date()) + return tasks.map { task in - WidgetDataManager.WidgetTask( + // Calculate actual days until due based on effectiveDueDate + var isDueWithin7Days = false + var isDue8To30Days = false + + if let dueDate = DateUtils.parseDate(task.effectiveDueDate) { + let dueDay = Calendar.current.startOfDay(for: dueDate) + let daysUntilDue = Calendar.current.dateComponents([.day], from: today, to: dueDay).day ?? 0 + + // Only count future tasks (not overdue) + if daysUntilDue >= 0 && daysUntilDue <= 7 { + isDueWithin7Days = true + } else if daysUntilDue > 7 && daysUntilDue <= 30 { + isDue8To30Days = true + } + } + + return WidgetDataManager.WidgetTask( id: Int(task.id), title: task.title, description: task.description_, @@ -525,8 +545,8 @@ class DataManagerObservable: ObservableObject { category: task.categoryName, residenceName: nil, isOverdue: overdueIds.contains(task.id), - isDueWithin7Days: dueWithin7DaysIds.contains(task.id), - isDue8To30Days: due8To30DaysIds.contains(task.id) + isDueWithin7Days: isDueWithin7Days, + isDue8To30Days: isDue8To30Days ) } } diff --git a/iosApp/iosApp/Helpers/WidgetDataManager.swift b/iosApp/iosApp/Helpers/WidgetDataManager.swift index 93fd517..ce94d33 100644 --- a/iosApp/iosApp/Helpers/WidgetDataManager.swift +++ b/iosApp/iosApp/Helpers/WidgetDataManager.swift @@ -280,6 +280,21 @@ final class WidgetDataManager { ) } + /// Parse a date string (handles both "yyyy-MM-dd" and ISO datetime formats) + /// Extracts date part if it includes time (e.g., "2025-12-26T00:00:00Z" -> "2025-12-26") + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() + + static func parseDate(_ dateString: String?) -> Date? { + guard let dateString = dateString, !dateString.isEmpty else { return nil } + // Extract date part if it includes time + let datePart = dateString.components(separatedBy: "T").first ?? dateString + return dateFormatter.date(from: datePart) + } + /// Get the shared App Group container URL private var sharedContainerURL: URL? { FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) @@ -300,6 +315,9 @@ final class WidgetDataManager { // Columns to exclude from widget (these are "done" states) let excludedColumns = [Self.completedColumn, Self.cancelledColumn] + // Date calculation setup + let today = Calendar.current.startOfDay(for: Date()) + // Extract tasks from active columns only and convert to WidgetTask var allTasks: [WidgetTask] = [] @@ -309,12 +327,26 @@ final class WidgetDataManager { continue } - // Determine flags based on column name (using shared constants) + // isOverdue is based on column (API correctly calculates this) let isOverdue = column.name == Self.overdueColumn - let isDueWithin7Days = column.name == Self.dueWithin7DaysColumn - let isDue8To30Days = column.name == Self.due8To30DaysColumn for task in column.tasks { + // Calculate isDueWithin7Days and isDue8To30Days from actual due date + var isDueWithin7Days = false + var isDue8To30Days = false + + if let dueDate = Self.parseDate(task.effectiveDueDate) { + let dueDay = Calendar.current.startOfDay(for: dueDate) + let daysUntilDue = Calendar.current.dateComponents([.day], from: today, to: dueDay).day ?? 0 + + // Only count future tasks (not overdue) + if daysUntilDue >= 0 && daysUntilDue <= 7 { + isDueWithin7Days = true + } else if daysUntilDue > 7 && daysUntilDue <= 30 { + isDue8To30Days = true + } + } + let widgetTask = WidgetTask( id: Int(task.id), title: task.title, diff --git a/iosApp/iosApp/Localizable.xcstrings b/iosApp/iosApp/Localizable.xcstrings index 8546470..e5875b5 100644 --- a/iosApp/iosApp/Localizable.xcstrings +++ b/iosApp/iosApp/Localizable.xcstrings @@ -171,6 +171,14 @@ "comment" : "A link that directs users to log in if they already have an account.", "isCommentAutoGenerated" : true }, + "Animation Testing" : { + "comment" : "The title of a view that tests different animations.", + "isCommentAutoGenerated" : true + }, + "Animation Type" : { + "comment" : "A label above the picker for selecting an animation type.", + "isCommentAutoGenerated" : true + }, "app_name" : { "extractionState" : "manual", "localizations" : { @@ -17440,6 +17448,10 @@ }, "No shared users" : { + }, + "No Tasks" : { + "comment" : "A description displayed when there are no tasks to display in a column view.", + "isCommentAutoGenerated" : true }, "No tasks yet" : { "comment" : "A description displayed when a user has no tasks.", @@ -21558,6 +21570,10 @@ "comment" : "A button that replays the current animation.", "isCommentAutoGenerated" : true }, + "Reset All Tasks" : { + "comment" : "A button label that resets all tasks.", + "isCommentAutoGenerated" : true + }, "Reset Password" : { "comment" : "The title of the screen where users can reset their passwords.", "isCommentAutoGenerated" : true diff --git a/iosApp/iosApp/Profile/AnimationTesting/AnimationTestingCard.swift b/iosApp/iosApp/Profile/AnimationTesting/AnimationTestingCard.swift new file mode 100644 index 0000000..ce06a57 --- /dev/null +++ b/iosApp/iosApp/Profile/AnimationTesting/AnimationTestingCard.swift @@ -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() + } +} diff --git a/iosApp/iosApp/Profile/AnimationTesting/AnimationTestingView.swift b/iosApp/iosApp/Profile/AnimationTesting/AnimationTestingView.swift new file mode 100644 index 0000000..2891f37 --- /dev/null +++ b/iosApp/iosApp/Profile/AnimationTesting/AnimationTestingView.swift @@ -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() +} diff --git a/iosApp/iosApp/Profile/AnimationTesting/TaskAnimations.swift b/iosApp/iosApp/Profile/AnimationTesting/TaskAnimations.swift new file mode 100644 index 0000000..0e114e3 --- /dev/null +++ b/iosApp/iosApp/Profile/AnimationTesting/TaskAnimations.swift @@ -0,0 +1,1055 @@ +import SwiftUI + +// MARK: - Animation Type Enum + +enum TaskAnimationType: String, CaseIterable, Identifiable { + case slide = "Slide" + case fade = "Fade" + case scale = "Scale" + case flip = "Flip" + case bounce = "Bounce" + case spring = "Spring" + case rotation = "Rotation" + case morph = "Morph" + case confetti = "Confetti" + case cascade = "Cascade" + case implode = "Implode" + case firework = "Firework" + case starburst = "Starburst" + case ripple = "Ripple" + + var id: String { rawValue } + + var icon: String { + switch self { + case .slide: return "arrow.right" + case .fade: return "circle.lefthalf.filled" + case .scale: return "arrow.up.left.and.arrow.down.right" + case .flip: return "arrow.triangle.2.circlepath" + case .bounce: return "arrow.up.arrow.down" + case .spring: return "waveform.path" + case .rotation: return "rotate.right" + case .morph: return "circle.hexagongrid.fill" + case .confetti: return "sparkles" + case .cascade: return "stairs" + case .implode: return "checkmark.circle" + case .firework: return "sparkle" + case .starburst: return "sun.max.fill" + case .ripple: return "circle.circle" + } + } + + var description: String { + switch self { + case .slide: return "Slides horizontally to next column" + case .fade: return "Fades out and in at destination" + case .scale: return "Shrinks, moves, then grows" + case .flip: return "3D flip rotation during move" + case .bounce: return "Bouncy overshoot animation" + case .spring: return "Physics-based spring motion" + case .rotation: return "Spins while moving" + case .morph: return "Shape morphs during transition" + case .confetti: return "Celebration particles on complete" + case .cascade: return "Multi-stage staggered animation" + case .implode: return "Sucks into center, becomes checkmark" + case .firework: return "Explodes into colorful sparks" + case .starburst: return "Radiating rays from checkmark" + case .ripple: return "Checkmark with expanding rings" + } + } + + /// Whether this animation needs special timing (longer hold for checkmark display) + var needsExtendedTiming: Bool { + switch self { + case .implode, .firework, .starburst, .ripple: + return true + default: + return false + } + } +} + +// MARK: - Animation Phase + +enum AnimationPhase: Equatable { + case idle + case exiting + case moving + case entering + case complete +} + +// MARK: - Test Data Models + +struct TestTask: Identifiable, Equatable { + let id: String + var title: String + var description: String? + var priority: TestPriority + var frequency: String + var dueDate: String + + enum TestPriority: String { + case high = "High" + case medium = "Medium" + case low = "Low" + + var color: Color { + switch self { + case .high: return Color.appError + case .medium: return Color.appAccent + case .low: return Color.appPrimary + } + } + } + + static let samples: [TestTask] = [ + TestTask( + id: "1", + title: "Clean Gutters", + description: "Remove debris from all gutters", + priority: .high, + frequency: "Monthly", + dueDate: "Dec 25" + ), + TestTask( + id: "2", + title: "Check HVAC Filters", + description: "Replace if dirty", + priority: .medium, + frequency: "Quarterly", + dueDate: "Dec 28" + ), + TestTask( + id: "3", + title: "Test Smoke Detectors", + description: nil, + priority: .high, + frequency: "Monthly", + dueDate: "Dec 30" + ) + ] +} + +struct TestColumn: Identifiable, Equatable { + let id: String + var name: String + var displayName: String + var color: Color + var tasks: [TestTask] + + static var defaultColumns: [TestColumn] { + [ + TestColumn( + id: "todo", + name: "To Do", + displayName: "To Do", + color: Color.appAccent, + tasks: TestTask.samples + ), + TestColumn( + id: "progress", + name: "In Progress", + displayName: "In Progress", + color: Color.appPrimary, + tasks: [] + ), + TestColumn( + id: "done", + name: "Done", + displayName: "Done", + color: Color.appPrimary.opacity(0.6), + tasks: [] + ) + ] + } +} + +// MARK: - Animation Chip + +struct AnimationChip: View { + let animation: TaskAnimationType + let isSelected: Bool + let onSelect: () -> Void + + var body: some View { + Button(action: onSelect) { + HStack(spacing: 6) { + Image(systemName: animation.icon) + .font(.system(size: 12, weight: .semibold)) + Text(animation.rawValue) + .font(.system(size: 13, weight: .semibold, design: .rounded)) + } + .foregroundColor(isSelected ? Color.appTextOnPrimary : Color.appTextPrimary) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(isSelected ? Color.appPrimary : Color.appBackgroundSecondary) + .cornerRadius(AppRadius.md) + } + .buttonStyle(.plain) + } +} + +// MARK: - Confetti Particle System + +struct ConfettiParticle: Identifiable { + let id: Int + let color: Color + let size: CGFloat + var x: CGFloat + var y: CGFloat + var rotation: Double + var opacity: Double +} + +struct ConfettiView: View { + @State private var particles: [ConfettiParticle] = [] + + private let colors: [Color] = [ + .appPrimary, .appAccent, .appSecondary, .green, .orange, .pink, .purple + ] + + var body: some View { + ZStack { + ForEach(particles) { particle in + RoundedRectangle(cornerRadius: 2) + .fill(particle.color) + .frame(width: particle.size, height: particle.size * 0.6) + .rotationEffect(.degrees(particle.rotation)) + .offset(x: particle.x, y: particle.y) + .opacity(particle.opacity) + } + } + .onAppear { generateParticles() } + } + + private func generateParticles() { + particles = (0..<25).map { i in + ConfettiParticle( + id: i, + color: colors.randomElement()!, + size: CGFloat.random(in: 6...10), + x: CGFloat.random(in: -60...60), + y: CGFloat.random(in: -20...20), + rotation: Double.random(in: 0...360), + opacity: 1.0 + ) + } + + withAnimation(.easeOut(duration: 1.2)) { + particles = particles.map { p in + var particle = p + particle.y += CGFloat.random(in: 80...150) + particle.x += CGFloat.random(in: -30...30) + particle.rotation += Double.random(in: 180...540) + particle.opacity = 0 + return particle + } + } + } +} + +// MARK: - Animation View Modifiers + +extension View { + @ViewBuilder + func taskAnimation(type: TaskAnimationType, phase: AnimationPhase) -> some View { + switch type { + case .slide: + self.slideAnimation(phase: phase) + case .fade: + self.fadeAnimation(phase: phase) + case .scale: + self.scaleAnimation(phase: phase) + case .flip: + self.flipAnimation(phase: phase) + case .bounce: + self.bounceAnimation(phase: phase) + case .spring: + self.springAnimation(phase: phase) + case .rotation: + self.rotationAnimation(phase: phase) + case .morph: + self.morphAnimation(phase: phase) + case .confetti: + self.confettiAnimation(phase: phase) + case .cascade: + self.cascadeAnimation(phase: phase) + case .implode: + self.implodeAnimation(phase: phase) + case .firework: + self.fireworkAnimation(phase: phase) + case .starburst: + self.starburstAnimation(phase: phase) + case .ripple: + self.rippleAnimation(phase: phase) + } + } +} + +// MARK: - Individual Animation Implementations + +private extension View { + + // 1. Slide - Horizontal slide with opacity + func slideAnimation(phase: AnimationPhase) -> some View { + self + .offset(x: phase == .exiting ? 80 : (phase == .entering ? -80 : 0)) + .opacity(phase == .exiting || phase == .entering ? 0.3 : 1.0) + .animation(.easeInOut(duration: 0.4), value: phase) + } + + // 2. Fade - Simple fade out/in + func fadeAnimation(phase: AnimationPhase) -> some View { + self + .opacity(phase == .idle || phase == .complete ? 1.0 : 0.0) + .animation(.easeInOut(duration: 0.35), value: phase) + } + + // 3. Scale - Shrink to point and expand + func scaleAnimation(phase: AnimationPhase) -> some View { + self + .scaleEffect(phase == .moving ? 0.1 : 1.0) + .opacity(phase == .moving ? 0.3 : 1.0) + .animation(.easeInOut(duration: 0.4), value: phase) + } + + // 4. Flip - 3D horizontal flip + func flipAnimation(phase: AnimationPhase) -> some View { + self + .rotation3DEffect( + .degrees(phase == .exiting ? 90 : (phase == .entering ? -90 : 0)), + axis: (x: 0, y: 1, z: 0), + perspective: 0.5 + ) + .opacity(phase == .moving ? 0 : 1.0) + .animation(.easeInOut(duration: 0.45), value: phase) + } + + // 5. Bounce - Overshoot with vertical movement + func bounceAnimation(phase: AnimationPhase) -> some View { + self + .offset(y: phase == .exiting ? -25 : (phase == .entering ? 15 : 0)) + .scaleEffect(phase == .entering ? 1.15 : (phase == .exiting ? 0.9 : 1.0)) + .animation(.interpolatingSpring(stiffness: 120, damping: 8), value: phase) + } + + // 6. Spring - Physics-based spring + func springAnimation(phase: AnimationPhase) -> some View { + self + .offset(x: phase == .exiting ? 40 : 0, y: phase == .moving ? -20 : 0) + .scaleEffect(phase == .entering ? 0.92 : 1.0) + .animation(.spring(response: 0.55, dampingFraction: 0.6, blendDuration: 0), value: phase) + } + + // 7. Rotation - 360 degree spin + func rotationAnimation(phase: AnimationPhase) -> some View { + self + .rotationEffect(.degrees(phase == .moving || phase == .exiting ? 360 : 0)) + .scaleEffect(phase == .moving ? 0.75 : 1.0) + .animation(.easeInOut(duration: 0.5), value: phase) + } + + // 8. Morph - Shape change during transition + func morphAnimation(phase: AnimationPhase) -> some View { + self + .clipShape(RoundedRectangle( + cornerRadius: phase == .moving ? 60 : AppRadius.lg, + style: .continuous + )) + .scaleEffect( + x: phase == .moving ? 0.6 : 1.0, + y: phase == .moving ? 1.3 : 1.0 + ) + .opacity(phase == .moving ? 0.7 : 1.0) + .animation(.easeInOut(duration: 0.45), value: phase) + } + + // 9. Confetti - Celebration burst + func confettiAnimation(phase: AnimationPhase) -> some View { + self + .overlay { + if phase == .complete { + ConfettiView() + .allowsHitTesting(false) + } + } + .scaleEffect(phase == .exiting ? 1.1 : (phase == .complete ? 1.05 : 1.0)) + .animation(.spring(response: 0.35, dampingFraction: 0.5), value: phase) + } + + // 10. Cascade - Multi-stage staggered animation + func cascadeAnimation(phase: AnimationPhase) -> some View { + self + .scaleEffect(phase == .exiting ? 0.85 : 1.0) + .rotationEffect(.degrees(phase == .exiting ? -8 : (phase == .entering ? 8 : 0))) + .offset(y: phase == .moving ? -35 : 0) + .opacity(phase == .moving ? 0.6 : 1.0) + .animation( + .easeInOut(duration: 0.25) + .delay(phase == .entering ? 0.15 : 0), + value: phase + ) + } + + // 11. Implode - Sucks into center, becomes checkmark, disappears + func implodeAnimation(phase: AnimationPhase) -> some View { + ZStack { + // Card content - shrinks and hides + self + .scaleEffect(phase == .idle ? 1.0 : 0.0) + .opacity(phase == .idle ? 1.0 : 0.0) + .animation(.easeIn(duration: 0.3), value: phase) + + // Checkmark overlay - NOT affected by the scale + if phase == .complete { + ImplodeCheckmarkView(phase: phase) + .allowsHitTesting(false) + } + } + } + + // 12. Firework - Explodes into colorful sparks with checkmark + func fireworkAnimation(phase: AnimationPhase) -> some View { + ZStack { + // Card content - shrinks and hides + self + .scaleEffect(phase == .idle ? 1.0 : 0.0) + .opacity(phase == .idle ? 1.0 : 0.0) + .animation(.easeIn(duration: 0.25), value: phase) + + // Firework effect + if phase == .complete { + FireworkCheckmarkView() + .allowsHitTesting(false) + } + } + } + + // 13. Starburst - Radiating rays from checkmark + func starburstAnimation(phase: AnimationPhase) -> some View { + ZStack { + // Card content - shrinks and hides + self + .scaleEffect(phase == .idle ? 1.0 : 0.0) + .opacity(phase == .idle ? 1.0 : 0.0) + .animation(.easeIn(duration: 0.3), value: phase) + + // Starburst effect + if phase == .complete { + StarburstCheckmarkView() + .allowsHitTesting(false) + } + } + } + + // 14. Ripple - Checkmark with expanding rings + func rippleAnimation(phase: AnimationPhase) -> some View { + ZStack { + // Card content - shrinks and hides + self + .scaleEffect(phase == .idle ? 1.0 : 0.0) + .opacity(phase == .idle ? 1.0 : 0.0) + .animation(.easeIn(duration: 0.3), value: phase) + + // Ripple effect + if phase == .complete { + RippleCheckmarkView() + .allowsHitTesting(false) + } + } + } +} + +// MARK: - Implode Checkmark Animation View + +struct ImplodeCheckmarkView: View { + let phase: AnimationPhase + + @State private var checkmarkPhase: CheckmarkPhase = .appearing + + private enum CheckmarkPhase { + case appearing + case visible + case disappearing + case gone + } + + var body: some View { + ZStack { + // Background circle that pulses + Circle() + .fill(Color.appPrimary.opacity(0.15)) + .frame(width: circleSize, height: circleSize) + .scaleEffect(circleScale) + + // Checkmark circle + Circle() + .fill(Color.appPrimary) + .frame(width: 56, height: 56) + .scaleEffect(checkmarkCircleScale) + .opacity(checkmarkOpacity) + + // Checkmark icon + Image(systemName: "checkmark") + .font(.system(size: 28, weight: .bold)) + .foregroundColor(.white) + .scaleEffect(checkmarkIconScale) + .opacity(checkmarkOpacity) + } + .onAppear { + startAnimation() + } + } + + private var circleSize: CGFloat { + switch checkmarkPhase { + case .appearing: return 20 + case .visible: return 80 + case .disappearing, .gone: return 100 + } + } + + private var circleScale: CGFloat { + switch checkmarkPhase { + case .appearing: return 0.2 + case .visible: return 1.0 + case .disappearing: return 1.3 + case .gone: return 0.0 + } + } + + private var checkmarkCircleScale: CGFloat { + switch checkmarkPhase { + case .appearing: return 0.0 + case .visible: return 1.0 + case .disappearing: return 1.1 + case .gone: return 0.0 + } + } + + private var checkmarkIconScale: CGFloat { + switch checkmarkPhase { + case .appearing: return 0.0 + case .visible: return 1.0 + case .disappearing: return 1.2 + case .gone: return 0.0 + } + } + + private var checkmarkOpacity: Double { + switch checkmarkPhase { + case .appearing: return 0.0 + case .visible: return 1.0 + case .disappearing: return 0.8 + case .gone: return 0.0 + } + } + + private func startAnimation() { + // Phase 1: Appear with spring + withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) { + checkmarkPhase = .visible + } + + // Phase 2: Hold for 1.5 seconds then start disappearing + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + withAnimation(.easeOut(duration: 0.4)) { + checkmarkPhase = .disappearing + } + } + + // Phase 3: Fade out completely + DispatchQueue.main.asyncAfter(deadline: .now() + 1.9) { + withAnimation(.easeOut(duration: 0.3)) { + checkmarkPhase = .gone + } + } + } +} + +// MARK: - Standalone Completion Animation + +// MARK: - Completion Animation Phase + +enum CompletionAnimationPhase { + case idle + case imploding + case checkmark + case fading + case done +} + +/// A standalone view that can be overlaid on any content to show the implode-to-checkmark animation +struct TaskCompletionAnimation: View { + @Binding var isAnimating: Bool + var onComplete: (() -> Void)? = nil + + @State private var phase: CompletionAnimationPhase = .idle + + var body: some View { + ZStack { + if phase != .idle && phase != .done { + // Radial lines sucking inward + ForEach(0..<8, id: \.self) { index in + ImplodeLine( + angle: Double(index) * 45, + phase: phase + ) + } + + // Center checkmark + if phase == .checkmark || phase == .fading { + ZStack { + // Glow effect + Circle() + .fill( + RadialGradient( + colors: [Color.appPrimary.opacity(0.4), Color.clear], + center: .center, + startRadius: 20, + endRadius: 60 + ) + ) + .frame(width: 120, height: 120) + .scaleEffect(phase == .fading ? 1.5 : 1.0) + .opacity(phase == .fading ? 0 : 1) + + // Main circle + Circle() + .fill(Color.appPrimary) + .frame(width: 60, height: 60) + .scaleEffect(checkmarkScale) + .opacity(phase == .fading ? 0 : 1) + + // Checkmark + Image(systemName: "checkmark") + .font(.system(size: 30, weight: .bold)) + .foregroundColor(.white) + .scaleEffect(checkmarkScale) + .opacity(phase == .fading ? 0 : 1) + } + .transition(.scale.combined(with: .opacity)) + } + } + } + .onChange(of: isAnimating) { _, newValue in + if newValue { + startAnimation() + } + } + } + + private var checkmarkScale: CGFloat { + switch phase { + case .checkmark: return 1.0 + case .fading: return 0.8 + default: return 0.0 + } + } + + private func startAnimation() { + phase = .idle + + // Phase 1: Implode lines animate inward + withAnimation(.easeIn(duration: 0.3)) { + phase = .imploding + } + + // Phase 2: Show checkmark with spring + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) { + phase = .checkmark + } + } + + // Phase 3: Hold longer, then fade + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + withAnimation(.easeOut(duration: 0.35)) { + phase = .fading + } + } + + // Phase 4: Complete + DispatchQueue.main.asyncAfter(deadline: .now() + 1.85) { + phase = .done + isAnimating = false + onComplete?() + } + } +} + + +/// Individual line that animates inward during implode +private struct ImplodeLine: View { + let angle: Double + let phase: CompletionAnimationPhase + + var body: some View { + Rectangle() + .fill( + LinearGradient( + colors: [Color.appPrimary.opacity(0), Color.appPrimary], + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(width: lineWidth, height: 3) + .offset(x: offset) + .rotationEffect(.degrees(angle)) + .opacity(lineOpacity) + .animation(.easeIn(duration: 0.3), value: phase) + } + + private var lineWidth: CGFloat { + switch phase { + case .idle: return 40 + case .imploding: return 0 + default: return 0 + } + } + + private var offset: CGFloat { + switch phase { + case .idle: return 60 + case .imploding: return 0 + default: return 0 + } + } + + private var lineOpacity: Double { + switch phase { + case .idle: return 1.0 + case .imploding: return 0.0 + default: return 0.0 + } + } +} + +// MARK: - Firework Checkmark Animation View + +struct FireworkCheckmarkView: View { + @State private var sparks: [FireworkSpark] = [] + @State private var checkmarkScale: CGFloat = 0 + @State private var checkmarkOpacity: Double = 0 + @State private var glowOpacity: Double = 0 + + private let sparkColors: [Color] = [ + .appPrimary, .appAccent, .orange, .yellow, .pink, .green, .cyan + ] + + var body: some View { + ZStack { + // Sparks + ForEach(sparks) { spark in + Circle() + .fill(spark.color) + .frame(width: spark.size, height: spark.size) + .offset(x: spark.x, y: spark.y) + .opacity(spark.opacity) + } + + // Glow behind checkmark + Circle() + .fill( + RadialGradient( + colors: [Color.appPrimary.opacity(0.5), Color.clear], + center: .center, + startRadius: 10, + endRadius: 50 + ) + ) + .frame(width: 100, height: 100) + .opacity(glowOpacity) + + // Checkmark circle + Circle() + .fill(Color.appPrimary) + .frame(width: 56, height: 56) + .scaleEffect(checkmarkScale) + .opacity(checkmarkOpacity) + + // Checkmark icon + Image(systemName: "checkmark") + .font(.system(size: 28, weight: .bold)) + .foregroundColor(.white) + .scaleEffect(checkmarkScale) + .opacity(checkmarkOpacity) + } + .onAppear { + startAnimation() + } + } + + private func startAnimation() { + // Generate sparks + sparks = (0..<20).map { i in + let angle = Double(i) * (360.0 / 20.0) * .pi / 180 + return FireworkSpark( + id: i, + color: sparkColors.randomElement()!, + size: CGFloat.random(in: 4...10), + x: 0, + y: 0, + targetX: cos(angle) * CGFloat.random(in: 60...100), + targetY: sin(angle) * CGFloat.random(in: 60...100), + opacity: 1.0 + ) + } + + // Explode sparks outward + withAnimation(.easeOut(duration: 0.4)) { + sparks = sparks.map { spark in + var s = spark + s.x = spark.targetX + s.y = spark.targetY + return s + } + } + + // Fade sparks and show glow + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + withAnimation(.easeOut(duration: 0.4)) { + sparks = sparks.map { spark in + var s = spark + s.opacity = 0 + s.y += 20 // Slight gravity effect + return s + } + glowOpacity = 1.0 + } + } + + // Show checkmark with bounce + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { + withAnimation(.spring(response: 0.4, dampingFraction: 0.5)) { + checkmarkScale = 1.0 + checkmarkOpacity = 1.0 + } + } + + // Fade out + DispatchQueue.main.asyncAfter(deadline: .now() + 1.8) { + withAnimation(.easeOut(duration: 0.3)) { + checkmarkScale = 0.8 + checkmarkOpacity = 0 + glowOpacity = 0 + } + } + } +} + +private struct FireworkSpark: Identifiable { + let id: Int + let color: Color + let size: CGFloat + var x: CGFloat + var y: CGFloat + let targetX: CGFloat + let targetY: CGFloat + var opacity: Double +} + +// MARK: - Starburst Checkmark Animation View + +struct StarburstCheckmarkView: View { + @State private var rayScale: CGFloat = 0 + @State private var rayOpacity: Double = 0 + @State private var rayRotation: Double = 0 + @State private var checkmarkScale: CGFloat = 0 + @State private var checkmarkOpacity: Double = 0 + @State private var pulseScale: CGFloat = 1.0 + + private let rayCount = 12 + + var body: some View { + ZStack { + // Radiating rays + ForEach(0..