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..