Files
Reflect/Shared/DemoAnimationManager.swift
Trey t 0442eab1f8 Rebrand entire project from Feels to Reflect
Complete rename across all bundle IDs, App Groups, CloudKit containers,
StoreKit product IDs, data store filenames, URL schemes, logger subsystems,
Swift identifiers, user-facing strings (7 languages), file names, directory
names, Xcode project, schemes, assets, and documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 11:47:16 -06:00

254 lines
9.0 KiB
Swift

//
// 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
}
}
}