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>
254 lines
9.0 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|