diff --git a/Feels.xcodeproj/project.pbxproj b/Feels.xcodeproj/project.pbxproj index 47d3b7c..83ef0fc 100644 --- a/Feels.xcodeproj/project.pbxproj +++ b/Feels.xcodeproj/project.pbxproj @@ -583,14 +583,14 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = Feels; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.tt.feelsDebug; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.88oakapps.feels.debug; INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0.2; - PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.feels.watch.debug; + PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.feels.debug.watch; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; SKIP_INSTALL = YES; @@ -832,7 +832,7 @@ ); MACOSX_DEPLOYMENT_TARGET = 12.1; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.tt.feels; + PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.feels; PRODUCT_NAME = Feels; SDKROOT = macosx; SWIFT_EMIT_LOC_STRINGS = YES; @@ -863,7 +863,7 @@ ); MACOSX_DEPLOYMENT_TARGET = 12.1; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.tt.feelsDebug; + PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.feels.debug; PRODUCT_NAME = Feels; SDKROOT = macosx; SWIFT_EMIT_LOC_STRINGS = YES; @@ -966,7 +966,7 @@ "@executable_path/../../Frameworks", ); MARKETING_VERSION = 1.0.2; - PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.feels.widget.debug; + PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.feels.debug.widget; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = iphoneos; @@ -1026,7 +1026,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = Feels; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.tt.feelsDebug; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.88oakapps.feels; INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", diff --git a/Shared/DemoAnimationManager.swift b/Shared/DemoAnimationManager.swift new file mode 100644 index 0000000..dd12836 --- /dev/null +++ b/Shared/DemoAnimationManager.swift @@ -0,0 +1,236 @@ +// +// DemoAnimationManager.swift +// Feels +// +// 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 + let monthDelay: TimeInterval = 0.3 + + /// 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) + } + + /// 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 + } + } +} diff --git a/Shared/Views/MonthView/MonthView.swift b/Shared/Views/MonthView/MonthView.swift index 79d35d5..8790a8e 100644 --- a/Shared/Views/MonthView/MonthView.swift +++ b/Shared/Views/MonthView/MonthView.swift @@ -44,6 +44,92 @@ struct MonthView: View { /// Cached sorted year/month data to avoid recalculating in ForEach @State private var cachedSortedData: [(year: Int, months: [(month: Int, entries: [MoodEntryModel])])] = [] + // MARK: - Demo Animation + @StateObject private var demoManager = DemoAnimationManager.shared + + /// Generate fake demo data for the past 12 months + private var demoSortedData: [(year: Int, months: [(month: Int, entries: [MoodEntryModel])])] { + var result: [(year: Int, months: [(month: Int, entries: [MoodEntryModel])])] = [] + let calendar = Calendar.current + let now = Date() + + // Group months by year + var yearDict: [Int: [(month: Int, entries: [MoodEntryModel])]] = [:] + + for monthOffset in 0..<12 { + guard let monthDate = calendar.date(byAdding: .month, value: -monthOffset, to: now) else { continue } + let year = calendar.component(.year, from: monthDate) + let month = calendar.component(.month, from: monthDate) + + let entries = generateDemoMonthEntries(year: year, month: month) + if yearDict[year] == nil { + yearDict[year] = [] + } + yearDict[year]?.append((month: month, entries: entries)) + } + + // Sort years descending, months descending within each year + for year in yearDict.keys.sorted(by: >) { + if let months = yearDict[year] { + result.append((year: year, months: months.sorted { $0.month > $1.month })) + } + } + + return result + } + + /// Generate fake entries for a demo month + private func generateDemoMonthEntries(year: Int, month: Int) -> [MoodEntryModel] { + let calendar = Calendar.current + var entries: [MoodEntryModel] = [] + + // Get first day of month + var components = DateComponents() + components.year = year + components.month = month + components.day = 1 + guard let firstOfMonth = calendar.date(from: components) else { return entries } + + // Get the weekday of first day (1 = Sunday, 7 = Saturday) + let firstWeekday = calendar.component(.weekday, from: firstOfMonth) + + // Add placeholder entries for days before the first + for i in 1.. [Int: [Int: [MoodEntryModel]]] { guard iapManager.shouldShowPaywall else { @@ -69,41 +155,47 @@ struct MonthView: View { .map { (year: $0.key, months: $0.value.sorted { $0.key > $1.key }.map { (month: $0.key, entries: $0.value) }) } } + /// Data to display - uses demo data when in demo mode, otherwise cached real data + private var displayData: [(year: Int, months: [(month: Int, entries: [MoodEntryModel])])] { + demoManager.isDemoMode ? demoSortedData : cachedSortedData + } + var body: some View { ZStack { - if viewModel.hasNoData { + if viewModel.hasNoData && !demoManager.isDemoMode { EmptyHomeView(showVote: false, viewModel: nil) .padding() } else { ScrollView { VStack(spacing: 16) { - ForEach(cachedSortedData, id: \.year) { yearData in - // for each month - ForEach(yearData.months, id: \.month) { monthData in - MonthCard( - month: monthData.month, - year: yearData.year, - entries: monthData.entries, - moodTint: moodTint, - imagePack: imagePack, - theme: theme, - filteredDays: filteredDays.currentFilters, - onTap: { - let detailView = MonthDetailView( - monthInt: monthData.month, - yearInt: yearData.year, - entries: monthData.entries, - parentViewModel: viewModel - ) - selectedDetail.selectedItem = detailView - selectedDetail.showSheet = true - }, - onShare: { image in - shareImage.selectedShareImage = image - shareImage.showSheet = true - } - ) - } + let allMonths = displayData.flatMap { yearData in + yearData.months.map { (year: yearData.year, month: $0.month, entries: $0.entries) } + } + ForEach(Array(allMonths.enumerated()), id: \.element.month) { monthIndex, monthData in + MonthCard( + month: monthData.month, + year: monthData.year, + entries: monthData.entries, + moodTint: moodTint, + imagePack: imagePack, + theme: theme, + filteredDays: filteredDays.currentFilters, + monthIndex: monthIndex, + onTap: { + let detailView = MonthDetailView( + monthInt: monthData.month, + yearInt: monthData.year, + entries: monthData.entries, + parentViewModel: viewModel + ) + selectedDetail.selectedItem = detailView + selectedDetail.showSheet = true + }, + onShare: { image in + shareImage.selectedShareImage = image + shareImage.showSheet = true + } + ) } } .padding(.horizontal) @@ -116,10 +208,10 @@ struct MonthView: View { } ) } - .scrollDisabled(iapManager.shouldShowPaywall) + .scrollDisabled(iapManager.shouldShowPaywall && !demoManager.isDemoMode) .mask( - // Fade effect when paywall should show: 100% at top, 0% halfway down - iapManager.shouldShowPaywall ? + // Fade effect when paywall should show: 100% at top, 0% halfway down (disabled in demo mode) + (iapManager.shouldShowPaywall && !demoManager.isDemoMode) ? AnyView( LinearGradient( gradient: Gradient(stops: [ @@ -134,8 +226,8 @@ struct MonthView: View { ) } - if iapManager.shouldShowPaywall { - // Premium month history prompt - bottom half + if iapManager.shouldShowPaywall && !demoManager.isDemoMode { + // Premium month history prompt - bottom half (hidden in demo mode) VStack(spacing: 20) { // Icon ZStack { @@ -201,7 +293,7 @@ struct MonthView: View { .frame(maxWidth: .infinity) .background(theme.currentTheme.bg) .frame(maxHeight: .infinity, alignment: .bottom) - } else if iapManager.shouldShowTrialWarning { + } else if iapManager.shouldShowTrialWarning && !demoManager.isDemoMode { VStack { Spacer() if !trialWarningHidden { @@ -237,6 +329,15 @@ struct MonthView: View { } .onAppear { cachedSortedData = computeSortedYearMonthData() + #if DEBUG + // Auto-start or restart demo mode for video recording + if demoManager.isDemoMode { + // Already in demo mode (e.g., came from YearView), restart animation + demoManager.restartAnimation() + } else { + demoManager.startDemoMode() + } + #endif } .onChange(of: viewModel.numberOfItems) { _, _ in // Use numberOfItems as a lightweight proxy for data changes @@ -247,6 +348,16 @@ struct MonthView: View { cachedSortedData = computeSortedYearMonthData() } .preferredColorScheme(theme.preferredColorScheme) + #if DEBUG + // Triple-tap to toggle demo mode for video recording + .onTapGesture(count: 3) { + if demoManager.isDemoMode { + demoManager.stopDemoMode() + } else { + demoManager.startDemoMode() + } + } + #endif } @@ -265,11 +376,15 @@ struct MonthCard: View, Equatable { let imagePack: MoodImages let theme: Theme let filteredDays: [Int] + let monthIndex: Int // Index for demo animation sequencing let onTap: () -> Void let onShare: (UIImage) -> Void private var labelColor: Color { theme.currentTheme.labelColor } + // Demo animation support + @ObservedObject private var demoManager = DemoAnimationManager.shared + // Equatable conformance to prevent unnecessary re-renders static func == (lhs: MonthCard, rhs: MonthCard) -> Bool { lhs.month == rhs.month && @@ -278,7 +393,8 @@ struct MonthCard: View, Equatable { lhs.moodTint == rhs.moodTint && lhs.imagePack == rhs.imagePack && lhs.filteredDays == rhs.filteredDays && - lhs.theme == rhs.theme + lhs.theme == rhs.theme && + lhs.monthIndex == rhs.monthIndex } @State private var showStats = true @@ -287,14 +403,29 @@ struct MonthCard: View, Equatable { private let weekdayLabels = ["S", "M", "T", "W", "T", "F", "S"] private let heatmapColumns = Array(repeating: GridItem(.flexible(), spacing: 2), count: 7) + // Animated metrics for demo mode - scales based on visible percentage + private var animatedMetrics: [MoodMetrics] { + guard demoManager.isDemoMode else { return cachedMetrics } + + let totalCells = entries.filter { $0.mood != .placeholder }.count + let visiblePercentage = demoManager.visiblePercentageForMonth(totalCells: totalCells, monthIndex: monthIndex) + + // Scale metrics by visible percentage + return cachedMetrics.map { metric in + let animatedTotal = Int(Double(metric.total) * visiblePercentage) + let animatedPercent = metric.percent * Float(visiblePercentage) + return MoodMetrics(mood: metric.mood, total: animatedTotal, percent: animatedPercent) + } + } + // Cached filtered/sorted metrics to avoid recalculating in ForEach private var displayMetrics: [MoodMetrics] { - cachedMetrics.filter { $0.total > 0 }.sorted { $0.mood.rawValue > $1.mood.rawValue } + animatedMetrics.filter { $0.total > 0 }.sorted { $0.mood.rawValue > $1.mood.rawValue } } // All 5 moods for share view (shows 0% for moods with no entries) private var allMoodMetrics: [MoodMetrics] { - cachedMetrics.sorted { $0.mood.rawValue > $1.mood.rawValue } + animatedMetrics.sorted { $0.mood.rawValue > $1.mood.rawValue } } private var topMood: Mood? { @@ -462,11 +593,21 @@ struct MonthCard: View, Equatable { // Heatmap Grid LazyVGrid(columns: heatmapColumns, spacing: 2) { - ForEach(entries, id: \.self) { entry in - HeatmapCell( + ForEach(Array(entries.enumerated()), id: \.element) { index, entry in + let row = index / 7 + let column = index % 7 + let totalRows = (entries.count + 6) / 7 + + DemoHeatmapCell( entry: entry, moodTint: moodTint, - isFiltered: filteredDays.contains(Int(entry.weekDay)) + isFiltered: filteredDays.contains(Int(entry.weekDay)), + row: row, + column: column, + totalRows: totalRows, + totalColumns: 7, + monthIndex: monthIndex, + demoManager: demoManager ) } } @@ -478,7 +619,7 @@ struct MonthCard: View, Equatable { Divider() .padding(.horizontal, 16) - MoodBarChart(metrics: cachedMetrics, moodTint: moodTint, imagePack: imagePack) + MoodBarChart(metrics: animatedMetrics, moodTint: moodTint, imagePack: imagePack) .padding(.horizontal, 16) .padding(.vertical, 12) .transition(.opacity.combined(with: .move(edge: .top))) @@ -544,6 +685,85 @@ struct HeatmapCell: View { } } +// MARK: - Demo Heatmap Cell (with animation support) +struct DemoHeatmapCell: View { + let entry: MoodEntryModel + let moodTint: MoodTints + let isFiltered: Bool + let row: Int + let column: Int + let totalRows: Int + let totalColumns: Int + let monthIndex: Int // Which month this is (0 = first/most recent) + @ObservedObject var demoManager: DemoAnimationManager + + /// Random mood for this cell (computed once and cached) + @State private var randomMood: Mood = .great + + var body: some View { + RoundedRectangle(cornerRadius: 4) + .fill(cellColor) + .aspectRatio(1, contentMode: .fit) + .scaleEffect(cellScale) + .opacity(cellOpacity) + .animation(.spring(response: 0.4, dampingFraction: 0.6), value: isVisible) + .onAppear { + // Generate random mood once when cell appears + randomMood = DemoAnimationManager.randomPositiveMood() + } + } + + /// Whether this cell should be visible in demo mode + private var isVisible: Bool { + if !demoManager.isDemoMode { + return true // Normal mode - always visible + } + if !demoManager.animationStarted { + return false // Demo mode but animation hasn't started - hide all + } + return demoManager.isCellVisible( + row: row, + column: column, + totalRows: totalRows, + totalColumns: totalColumns, + monthIndex: monthIndex + ) + } + + private var cellScale: CGFloat { + isVisible ? 1.0 : 0.3 + } + + private var cellOpacity: Double { + isVisible ? 1.0 : 0.0 + } + + private var cellColor: Color { + if demoManager.isDemoMode { + // In demo mode, show random positive mood colors + if !isVisible { + return Color.gray.opacity(0.1) + } + // Skip placeholder cells at the start of month + if entry.mood == .placeholder { + return Color.gray.opacity(0.1) + } + return moodTint.color(forMood: randomMood) + } + + // Normal mode - use actual entry data + if entry.mood == .placeholder { + return Color.gray.opacity(0.1) + } else if entry.mood == .missing { + return Color.gray.opacity(0.25) + } else if !isFiltered { + return Color.gray.opacity(0.1) + } else { + return moodTint.color(forMood: entry.mood) + } + } +} + // MARK: - Mini Bar Chart struct MoodBarChart: View { let metrics: [MoodMetrics] diff --git a/Shared/Views/YearView/YearView.swift b/Shared/Views/YearView/YearView.swift index 487aebc..71e0f91 100644 --- a/Shared/Views/YearView/YearView.swift +++ b/Shared/Views/YearView/YearView.swift @@ -26,26 +26,126 @@ struct YearView: View { /// Cached sorted year keys to avoid re-sorting in ForEach on every render @State private var cachedSortedYearKeys: [Int] = [] + // MARK: - Demo Animation + @StateObject private var demoManager = DemoAnimationManager.shared + // Heatmap-style grid: 12 columns for months private let heatmapColumns = Array(repeating: GridItem(.flexible(), spacing: 2), count: 12) + /// Generate demo year data for the past 3 years (full 12 months each) + private var demoYearData: [Int: [Int: [DayChartView]]] { + var result: [Int: [Int: [DayChartView]]] = [:] + let calendar = Calendar.current + let currentYear = calendar.component(.year, from: Date()) + + for yearOffset in 0..<3 { + let year = currentYear - yearOffset + var yearDict: [Int: [DayChartView]] = [:] + + // Generate all 12 months for demo (including future months) + for month in 1...12 { + var components = DateComponents() + components.year = year + components.month = month + components.day = 1 + guard let firstOfMonth = calendar.date(from: components), + let range = calendar.range(of: .day, in: .month, for: firstOfMonth) else { continue } + + var monthDays: [DayChartView] = [] + for day in 1...range.count { + components.day = day + if let date = calendar.date(from: components) { + let weekDay = calendar.component(.weekday, from: date) + // Use average mood color as placeholder (demo cell will assign random colors) + let dayView = DayChartView( + color: moodTint.color(forMood: .average), + weekDay: weekDay, + shape: .circle + ) + monthDays.append(dayView) + } + } + yearDict[month] = monthDays + } + result[year] = yearDict + } + return result + } + + /// Generate demo entries for metrics calculation (all 12 months) + private func demoEntriesForYear(_ year: Int) -> [MoodEntryModel] { + var entries: [MoodEntryModel] = [] + let calendar = Calendar.current + + // Generate all 12 months for demo + for month in 1...12 { + var components = DateComponents() + components.year = year + components.month = month + components.day = 1 + guard let firstOfMonth = calendar.date(from: components), + let range = calendar.range(of: .day, in: .month, for: firstOfMonth) else { continue } + + for day in 1...range.count { + components.day = day + if let date = calendar.date(from: components) { + let entry = MoodEntryModel( + forDate: date, + mood: DemoAnimationManager.randomPositiveMood(), + entryType: .listView, + canEdit: false, + canDelete: false + ) + entries.append(entry) + } + } + } + return entries + } + + /// Year keys to display - demo data or real data + private var displayYearKeys: [Int] { + if demoManager.isDemoMode { + return Array(demoYearData.keys.sorted(by: >)) + } + return cachedSortedYearKeys + } + + /// Year data for a specific year - demo or real + private func yearDataFor(_ year: Int) -> [Int: [DayChartView]] { + if demoManager.isDemoMode { + return demoYearData[year] ?? [:] + } + return viewModel.data[year] ?? [:] + } + + /// Entries for a specific year - demo or real + private func entriesFor(_ year: Int) -> [MoodEntryModel] { + if demoManager.isDemoMode { + return demoEntriesForYear(year) + } + return viewModel.entriesByYear[year] ?? [] + } + var body: some View { ZStack { - if self.viewModel.data.keys.isEmpty { + if self.viewModel.data.keys.isEmpty && !demoManager.isDemoMode { EmptyHomeView(showVote: false, viewModel: nil) .padding() } else { ScrollView { VStack(spacing: 16) { - ForEach(cachedSortedYearKeys, id: \.self) { yearKey in + ForEach(Array(displayYearKeys.enumerated()), id: \.element) { yearIndex, yearKey in YearCard( year: yearKey, - yearData: self.viewModel.data[yearKey]!, - yearEntries: self.viewModel.entriesByYear[yearKey] ?? [], + yearData: yearDataFor(yearKey), + yearEntries: entriesFor(yearKey), moodTint: moodTint, imagePack: imagePack, theme: theme, filteredDays: filteredDays.currentFilters, + yearIndex: yearIndex, + demoManager: demoManager, onShare: { image in shareImage.selectedShareImage = image shareImage.showSheet = true @@ -63,10 +163,10 @@ struct YearView: View { } ) } - .scrollDisabled(iapManager.shouldShowPaywall) + .scrollDisabled(iapManager.shouldShowPaywall && !demoManager.isDemoMode) .mask( - // Fade effect when paywall should show: 100% at top, 0% halfway down - iapManager.shouldShowPaywall ? + // Fade effect when paywall should show: 100% at top, 0% halfway down (disabled in demo mode) + (iapManager.shouldShowPaywall && !demoManager.isDemoMode) ? AnyView( LinearGradient( gradient: Gradient(stops: [ @@ -81,8 +181,8 @@ struct YearView: View { ) } - if iapManager.shouldShowPaywall { - // Premium year overview prompt - bottom half + if iapManager.shouldShowPaywall && !demoManager.isDemoMode { + // Premium year overview prompt - bottom half (hidden in demo mode) VStack(spacing: 20) { // Icon ZStack { @@ -148,7 +248,7 @@ struct YearView: View { .frame(maxWidth: .infinity) .background(theme.currentTheme.bg) .frame(maxHeight: .infinity, alignment: .bottom) - } else if iapManager.shouldShowTrialWarning { + } else if iapManager.shouldShowTrialWarning && !demoManager.isDemoMode { VStack { Spacer() if !trialWarningHidden { @@ -168,6 +268,15 @@ struct YearView: View { .onAppear(perform: { self.viewModel.filterEntries(startDate: Date(timeIntervalSince1970: 0), endDate: Date()) cachedSortedYearKeys = Array(viewModel.data.keys.sorted(by: >)) + #if DEBUG + // Auto-start or restart demo mode for video recording + if demoManager.isDemoMode { + // Already in demo mode (e.g., came from MonthView), restart animation + demoManager.restartAnimation() + } else { + demoManager.startDemoMode() + } + #endif }) .onChange(of: viewModel.data.keys.count) { _, _ in cachedSortedYearKeys = Array(viewModel.data.keys.sorted(by: >)) @@ -188,6 +297,16 @@ struct YearView: View { } .padding([.top]) .preferredColorScheme(theme.preferredColorScheme) + #if DEBUG + // Triple-tap to toggle demo mode for video recording + .onTapGesture(count: 3) { + if demoManager.isDemoMode { + demoManager.stopDemoMode() + } else { + demoManager.startDemoMode() + } + } + #endif } } @@ -200,6 +319,8 @@ struct YearCard: View, Equatable { let imagePack: MoodImages let theme: Theme let filteredDays: [Int] + let yearIndex: Int // Which year this is (0 = most recent) + @ObservedObject var demoManager: DemoAnimationManager let onShare: (UIImage) -> Void private var textColor: Color { theme.currentTheme.labelColor } @@ -211,7 +332,8 @@ struct YearCard: View, Equatable { lhs.moodTint == rhs.moodTint && lhs.imagePack == rhs.imagePack && lhs.filteredDays == rhs.filteredDays && - lhs.theme == rhs.theme + lhs.theme == rhs.theme && + lhs.yearIndex == rhs.yearIndex } @State private var showStats = true @@ -220,18 +342,41 @@ struct YearCard: View, Equatable { private let months = ["J", "F", "M", "A", "M", "J", "J", "A", "S", "O", "N", "D"] private let heatmapColumns = Array(repeating: GridItem(.flexible(), spacing: 2), count: 12) + // Animated metrics for demo mode - scales based on visible percentage + private var animatedMetrics: [MoodMetrics] { + guard demoManager.isDemoMode else { return cachedMetrics } + + let totalCells = yearEntries.filter { $0.mood != .placeholder && $0.mood != .missing }.count + let visiblePercentage = demoManager.visiblePercentageForYear(totalCells: totalCells, yearIndex: yearIndex) + + // Scale metrics by visible percentage + return cachedMetrics.map { metric in + let animatedTotal = Int(Double(metric.total) * visiblePercentage) + let animatedPercent = metric.percent * Float(visiblePercentage) + return MoodMetrics(mood: metric.mood, total: animatedTotal, percent: animatedPercent) + } + } + // Cached filtered/sorted metrics to avoid recalculating in ForEach private var displayMetrics: [MoodMetrics] { - cachedMetrics.filter { $0.total > 0 }.sorted { $0.mood.rawValue > $1.mood.rawValue } + animatedMetrics.filter { $0.total > 0 }.sorted { $0.mood.rawValue > $1.mood.rawValue } } // All 5 moods for share view (shows 0% for moods with no entries) private var allMoodMetrics: [MoodMetrics] { - cachedMetrics.sorted { $0.mood.rawValue > $1.mood.rawValue } + animatedMetrics.sorted { $0.mood.rawValue > $1.mood.rawValue } + } + + // Animated total entries for demo mode + private var animatedTotalEntries: Int { + let total = yearEntries.filter { ![Mood.missing, Mood.placeholder].contains($0.mood) }.count + guard demoManager.isDemoMode else { return total } + let visiblePercentage = demoManager.visiblePercentageForYear(totalCells: total, yearIndex: yearIndex) + return Int(Double(total) * visiblePercentage) } private var totalEntries: Int { - yearEntries.filter { ![Mood.missing, Mood.placeholder].contains($0.mood) }.count + animatedTotalEntries } private var topMood: Mood? { @@ -390,7 +535,7 @@ struct YearCard: View, Equatable { if showStats { HStack(spacing: 16) { // Donut Chart - MoodDonutChart(metrics: cachedMetrics, moodTint: moodTint) + MoodDonutChart(metrics: animatedMetrics, moodTint: moodTint) .frame(width: 100, height: 100) // Bar Chart @@ -449,7 +594,9 @@ struct YearCard: View, Equatable { YearHeatmapGrid( yearData: yearData, moodTint: moodTint, - filteredDays: filteredDays + filteredDays: filteredDays, + yearIndex: yearIndex, + demoManager: demoManager ) .padding(.horizontal, 16) .padding(.bottom, 16) @@ -472,6 +619,8 @@ struct YearHeatmapGrid: View { let yearData: [Int: [DayChartView]] let moodTint: MoodTints let filteredDays: [Int] + let yearIndex: Int // Which year this grid belongs to (0 = most recent) + @ObservedObject var demoManager: DemoAnimationManager private let heatmapColumns = Array(repeating: GridItem(.flexible(), spacing: 2), count: 12) @@ -482,14 +631,23 @@ struct YearHeatmapGrid: View { Array(yearData.keys.sorted(by: <)) } + /// Total days across all months for animation positioning + private var totalDays: Int { + yearData.values.reduce(0) { $0 + $1.count } + } + var body: some View { LazyVGrid(columns: heatmapColumns, spacing: 2) { - ForEach(sortedMonthKeys, id: \.self) { monthKey in + ForEach(Array(sortedMonthKeys.enumerated()), id: \.element) { monthIndex, monthKey in if let monthData = yearData[monthKey] { - MonthColumn( + DemoMonthColumn( monthData: monthData, moodTint: moodTint, - filteredDays: filteredDays + filteredDays: filteredDays, + monthIndex: monthIndex, + totalMonths: 12, + yearIndex: yearIndex, + demoManager: demoManager ) } } @@ -516,6 +674,36 @@ struct MonthColumn: View { } } +// MARK: - Demo Month Column (with animation support) +struct DemoMonthColumn: View { + let monthData: [DayChartView] + let moodTint: MoodTints + let filteredDays: [Int] + let monthIndex: Int + let totalMonths: Int + let yearIndex: Int // Which year this column belongs to + @ObservedObject var demoManager: DemoAnimationManager + + var body: some View { + VStack(spacing: 2) { + ForEach(Array(monthData.enumerated()), id: \.element) { dayIndex, dayView in + DemoYearHeatmapCell( + color: dayView.color, + weekDay: dayView.weekDay, + isFiltered: filteredDays.contains(dayView.weekDay), + row: dayIndex, + column: monthIndex, + totalRows: monthData.count, + totalColumns: totalMonths, + moodTint: moodTint, + yearIndex: yearIndex, + demoManager: demoManager + ) + } + } + } +} + // MARK: - Year Heatmap Cell struct YearHeatmapCell: View { let color: Color @@ -552,6 +740,86 @@ struct YearHeatmapCell: View { } } +// MARK: - Demo Year Heatmap Cell (with animation support) +struct DemoYearHeatmapCell: View { + let color: Color + let weekDay: Int + let isFiltered: Bool + let row: Int + let column: Int + let totalRows: Int + let totalColumns: Int + let moodTint: MoodTints + let yearIndex: Int // Which year this is (0 = most recent) + @ObservedObject var demoManager: DemoAnimationManager + + /// Random mood for this cell (computed once and cached) + @State private var randomMood: Mood = .great + + var body: some View { + RoundedRectangle(cornerRadius: 2) + .fill(cellColor) + .aspectRatio(1, contentMode: .fit) + .scaleEffect(cellScale) + .opacity(cellOpacity) + .animation(.spring(response: 0.35, dampingFraction: 0.5), value: isVisible) + .onAppear { + // Generate random mood once when cell appears + randomMood = DemoAnimationManager.randomPositiveMood() + } + } + + /// Whether this cell should be visible in demo mode + /// For year view, we animate column by column (left to right), then row by row within each column + private var isVisible: Bool { + if !demoManager.isDemoMode { + return true // Normal mode - always visible + } + if !demoManager.animationStarted { + return false // Demo mode but animation hasn't started - hide all + } + + return demoManager.isCellVisibleForYear( + row: row, + column: column, + totalRows: totalRows, + totalColumns: totalColumns, + yearIndex: yearIndex + ) + } + + private var cellScale: CGFloat { + isVisible ? 1.0 : 0.0 + } + + private var cellOpacity: Double { + isVisible ? 1.0 : 0.0 + } + + private var cellColor: Color { + if demoManager.isDemoMode { + // In demo mode, show random positive mood colors + if !isVisible { + return Color.gray.opacity(0.1) + } + // Skip placeholder/empty cells + if color == Mood.placeholder.color || color == Mood.missing.color { + return Color.gray.opacity(0.1) + } + return moodTint.color(forMood: randomMood) + } + + // Normal mode - use actual colors + if !isFiltered { + return Color.gray.opacity(0.1) + } else if color == Mood.placeholder.color || color == Mood.missing.color { + return Color.gray.opacity(0.2) + } else { + return color + } + } +} + // MARK: - Donut Chart struct MoodDonutChart: View { let metrics: [MoodMetrics]