// // DemoAnimationManager.swift // Reflect // // Manages demo animation mode for promotional videos. // Animates filling mood entries from top-left to bottom-right. // import SwiftUI import Combine /// Manages demo animation state for promotional video recording @MainActor final class DemoAnimationManager: ObservableObject { static let shared = DemoAnimationManager() /// Whether demo mode is active @Published var isDemoMode: Bool = false /// Whether the animation has started (after the 3-second delay) @Published var animationStarted: Bool = false /// Current animation progress (0.0 to 1.0) @Published var animationProgress: Double = 0.0 /// The delay before starting the fill animation (in seconds) let startDelay: TimeInterval = 3.0 /// Total animation duration for filling all cells in one month let monthAnimationDuration: TimeInterval = 1.5 /// Delay between months (0 for fluid continuous animation) let monthDelay: TimeInterval = 0.0 /// Delay between each cell in year view let yearCellDelay: TimeInterval = 0.05 /// Delay between month columns in year view let yearMonthDelay: TimeInterval = 0.1 /// Timer for animation progress private var animationTimer: Timer? private var startTime: Date? private init() {} /// Start demo mode - will begin animation after delay func startDemoMode() { isDemoMode = true animationStarted = false animationProgress = 0.0 // Start animation after delay DispatchQueue.main.asyncAfter(deadline: .now() + startDelay) { [weak self] in self?.beginAnimation() } } /// Restart demo mode animation (for switching between tabs) func restartAnimation() { animationTimer?.invalidate() animationTimer = nil animationStarted = false animationProgress = 0.0 startTime = nil // Start animation after delay DispatchQueue.main.asyncAfter(deadline: .now() + startDelay) { [weak self] in self?.beginAnimation() } } /// Stop demo mode and reset func stopDemoMode() { isDemoMode = false animationStarted = false animationProgress = 0.0 animationTimer?.invalidate() animationTimer = nil startTime = nil } /// Begin the fill animation private func beginAnimation() { guard isDemoMode else { return } animationStarted = true startTime = Date() // Update progress at 60fps animationTimer = Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { [weak self] _ in Task { @MainActor in self?.updateProgress() } } } /// Update animation progress - runs continuously for multi-month animation private func updateProgress() { guard let startTime = startTime else { return } let elapsed = Date().timeIntervalSince(startTime) // Progress goes from 0 to a large number (we use elapsed time directly) animationProgress = elapsed // Stop after a reasonable max time (300 seconds for year view with 0.65s per cell) if elapsed >= 300.0 { animationTimer?.invalidate() animationTimer = nil } } /// Calculate the animation time for a specific month (0-indexed from most recent) /// MonthView shows months in reverse chronological order, so monthIndex 0 is the current month func monthStartTime(monthIndex: Int) -> Double { return Double(monthIndex) * (monthAnimationDuration + monthDelay) } /// Returns the current month index being animated (for auto-scrolling) var currentAnimatingMonthIndex: Int { guard animationStarted else { return 0 } let monthDuration = monthAnimationDuration + monthDelay return Int(animationProgress / monthDuration) } /// Returns the approximate row being animated within current month (0-5 typically) func currentAnimatingRow(totalRows: Int, totalColumns: Int) -> Int { guard animationStarted else { return 0 } let monthDuration = monthAnimationDuration + monthDelay let progressInMonth = animationProgress.truncatingRemainder(dividingBy: monthDuration) let normalizedProgress = min(1.0, progressInMonth / monthAnimationDuration) let cellIndex = Int(normalizedProgress * Double(totalRows * totalColumns)) return cellIndex / totalColumns } /// Check if a cell should be visible based on current animation progress /// For MonthView: animates cells within each month from top-left to bottom-right /// - Parameters: /// - row: Row index within the month grid /// - column: Column index within the month grid /// - totalRows: Total number of rows in this month /// - totalColumns: Total number of columns (7 for weekdays) /// - monthIndex: Which month this is (0 = most recent/current month) /// - Returns: True if cell should be visible func isCellVisible(row: Int, column: Int, totalRows: Int, totalColumns: Int, monthIndex: Int = 0) -> Bool { guard animationStarted else { return false } // Calculate when this month's animation starts let monthStart = monthStartTime(monthIndex: monthIndex) // Check if we've reached this month yet if animationProgress < monthStart { return false } // Calculate position within this month (top-left to bottom-right) let totalCells = Double(totalRows * totalColumns) let cellIndex = Double(row * totalColumns + column) let normalizedPosition = cellIndex / max(1, totalCells - 1) // Calculate when this specific cell should appear let cellDelay = monthStart + (normalizedPosition * monthAnimationDuration) return animationProgress >= cellDelay } /// Check if a cell should be visible for YearView /// For YearView: animates column by column (month by month) within each year /// Each cell waits for the previous cell to complete before starting func isCellVisibleForYear(row: Int, column: Int, totalRows: Int, totalColumns: Int, yearIndex: Int = 0) -> Bool { guard animationStarted else { return false } // Calculate total cells in previous years let cellsPerYear = 31 * 12 // approximate max cells per year let yearStart = Double(yearIndex) * (Double(cellsPerYear) * yearCellDelay + yearMonthDelay) if animationProgress < yearStart { return false } // Calculate cell position: column-major order (month by month, then day within month) // Each month column has up to 31 days let cellsBeforeThisMonth = column * 31 let cellIndex = cellsBeforeThisMonth + row // Add month delay between columns let monthDelays = Double(column) * yearMonthDelay // Each cell starts after the previous cell's delay let cellStart = yearStart + Double(cellIndex) * yearCellDelay + monthDelays return animationProgress >= cellStart } /// Calculate the percentage of cells visible for a month (0.0 to 1.0) /// Used for animating charts in demo mode func visiblePercentageForMonth(totalCells: Int, monthIndex: Int) -> Double { guard animationStarted else { return 0.0 } let monthStart = monthStartTime(monthIndex: monthIndex) // Before this month starts if animationProgress < monthStart { return 0.0 } // Calculate how far through the month animation we are let progressInMonth = animationProgress - monthStart let percentage = min(1.0, progressInMonth / monthAnimationDuration) return percentage } /// Calculate the percentage of cells visible for a year (0.0 to 1.0) /// Used for animating charts in year view demo mode func visiblePercentageForYear(totalCells: Int, yearIndex: Int) -> Double { guard animationStarted else { return 0.0 } // Calculate when this year's animation starts let cellsPerYear = 31 * 12 let yearStart = Double(yearIndex) * (Double(cellsPerYear) * yearCellDelay + yearMonthDelay) // Before this year starts if animationProgress < yearStart { return 0.0 } // Total year duration based on cell delays and month delays let yearDuration = Double(cellsPerYear) * yearCellDelay + 12.0 * yearMonthDelay let progressInYear = animationProgress - yearStart let percentage = min(1.0, progressInYear / yearDuration) return percentage } /// Generate a random mood biased towards positive values /// Distribution: Great 35%, Good 30%, Average 20%, Bad 10%, Horrible 5% static func randomPositiveMood() -> Mood { let random = Double.random(in: 0...1) switch random { case 0..<0.35: return .great case 0.35..<0.65: return .good case 0.65..<0.85: return .average case 0.85..<0.95: return .bad default: return .horrible } } }