- Completion animations: play user-selected animation on task card after completing, with DataManager guard to prevent race condition during animation playback. Works in both AllTasksView and ResidenceDetailView. Animation preference persisted via @AppStorage and configurable from Settings. - Subscription: add trial fields (trialStart, trialEnd, trialActive) and subscriptionSource to model, cross-platform purchase guard, trial banner in upgrade prompt, and platform-aware subscription management in profile. - Analytics: disable PostHog SDK debug logging and remove console print statements to reduce debug console noise. - Documents: remove redundant nested do-catch blocks in ViewModel wrapper. - Widgets: add debounced timeline reloads and thread-safe file I/O queue. - Onboarding: fix animation leak on disappear, remove unused state vars. - Remove unused files (ContentView, StateFlowExtensions, CustomView). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
316 lines
10 KiB
Swift
316 lines
10 KiB
Swift
import SwiftUI
|
|
|
|
struct AnimationTestingView: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
// Animation selection (persisted)
|
|
@StateObject private var animationPreference = AnimationPreference.shared
|
|
private var selectedAnimation: TaskAnimationType {
|
|
get { animationPreference.selectedAnimation }
|
|
}
|
|
|
|
// 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("Completion Animation")
|
|
.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.selectableCases) { animation in
|
|
AnimationChip(
|
|
animation: animation,
|
|
isSelected: selectedAnimation == animation,
|
|
onSelect: {
|
|
withAnimation(.easeInOut(duration: 0.2)) {
|
|
animationPreference.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
|
|
|
|
// No animation: instant move
|
|
if selectedAnimation == .none {
|
|
if let taskIndex = columns[currentIndex].tasks.firstIndex(where: { $0.id == task.id }) {
|
|
columns[currentIndex].tasks.remove(at: taskIndex)
|
|
}
|
|
columns[currentIndex + 1].tasks.insert(task, at: 0)
|
|
animatingTaskId = nil
|
|
animationPhase = .idle
|
|
return
|
|
}
|
|
|
|
// 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()
|
|
}
|