// // LiveActivityPreviewView.swift // Reflect // // Preview Live Activity with animated streak counter for promotional videos. // import SwiftUI #if canImport(UIKit) import UIKit #endif struct LiveActivityPreviewView: View { @Environment(\.dismiss) private var dismiss @State private var currentStreak: Int = 0 @State private var isAnimating: Bool = false @State private var animationTimer: Timer? @State private var showRecordingMode: Bool = false private let targetStreak = 365 private let animationDuration: TimeInterval = 8.0 // Total animation duration in seconds var body: some View { VStack(spacing: 40) { Text("Live Activity Preview") .font(.title2.bold()) // Mock Live Activity Lock Screen View liveActivityView .frame(maxWidth: .infinity) .background(Color(.systemBackground).opacity(0.9)) .cornerRadius(20) .shadow(color: .black.opacity(0.2), radius: 10, x: 0, y: 4) .padding(.horizontal, 20) // Progress bar showing 0 to 365 VStack(spacing: 12) { ProgressView(value: Double(currentStreak), total: Double(targetStreak)) .tint(.orange) .scaleEffect(x: 1, y: 2, anchor: .center) Text("\(currentStreak) / \(targetStreak) days") .font(.headline) .foregroundColor(.secondary) } .padding(.horizontal, 40) Spacer() // Control buttons VStack(spacing: 16) { HStack(spacing: 20) { Button(action: resetAnimation) { Label("Reset", systemImage: "arrow.counterclockwise") .font(.headline) .frame(maxWidth: .infinity) .padding() .background(Color.gray.opacity(0.2)) .cornerRadius(12) } .accessibilityIdentifier(AccessibilityID.Debug.liveActivityResetButton) Button(action: toggleAnimation) { Label(isAnimating ? "Pause" : "Start", systemImage: isAnimating ? "pause.fill" : "play.fill") .font(.headline) .frame(maxWidth: .infinity) .padding() .background(Color.orange) .foregroundColor(.white) .cornerRadius(12) } .accessibilityIdentifier(AccessibilityID.Debug.liveActivityToggleButton) } Button(action: { showRecordingMode = true }) { Label("Recording Mode", systemImage: "record.circle") .font(.headline) .frame(maxWidth: .infinity) .padding() .background(Color.red) .foregroundColor(.white) .cornerRadius(12) } .accessibilityIdentifier(AccessibilityID.Debug.liveActivityRecordButton) } .padding(.horizontal, 20) .padding(.bottom, 40) } .padding(.top, 40) .background(Color(.systemGroupedBackground)) .onDisappear { animationTimer?.invalidate() } .fullScreenCover(isPresented: $showRecordingMode) { LiveActivityRecordingView() } } // MARK: - Live Activity View (mimics lock screen appearance) private var liveActivityView: some View { HStack(spacing: 16) { // Streak indicator VStack(spacing: 4) { Image(systemName: "flame.fill") .font(.title) .foregroundColor(.orange) .symbolEffect(.bounce, value: currentStreak) Text("\(currentStreak)") .font(.title.bold()) .contentTransition(.numericText()) Text("day streak") .font(.caption) .foregroundColor(.secondary) } Divider() .frame(height: 50) // Status VStack(alignment: .leading, spacing: 8) { if currentStreak > 0 { HStack(spacing: 8) { Circle() .fill(moodColor) .frame(width: 24, height: 24) VStack(alignment: .leading) { Text("Today's mood") .font(.caption) .foregroundColor(.secondary) Text(moodName) .font(.headline) } } } else { VStack(alignment: .leading) { Text("Start your streak!") .font(.headline) Text("Tap to log your mood") .font(.caption) .foregroundColor(.secondary) } } } Spacer() } .padding() } // MARK: - Mood Display Helpers private var moodColor: Color { let progress = Double(currentStreak) / Double(targetStreak) // Transition through mood colors based on progress if progress < 0.2 { return Color(hex: "F44336") // Horrible } else if progress < 0.4 { return Color(hex: "FF9800") // Bad } else if progress < 0.6 { return Color(hex: "FFC107") // Average } else if progress < 0.8 { return Color(hex: "8BC34A") // Good } else { return Color(hex: "4CAF50") // Great } } private var moodName: String { let progress = Double(currentStreak) / Double(targetStreak) if progress < 0.2 { return "Horrible" } else if progress < 0.4 { return "Bad" } else if progress < 0.6 { return "Average" } else if progress < 0.8 { return "Good" } else { return "Great" } } // MARK: - Animation Controls private func toggleAnimation() { if isAnimating { pauseAnimation() } else { startAnimation() } } private func startAnimation() { guard currentStreak < targetStreak else { resetAnimation() return } isAnimating = true // Calculate interval to reach target in animationDuration seconds let remainingDays = targetStreak - currentStreak let interval = animationDuration / Double(remainingDays) animationTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { timer in withAnimation(.easeOut(duration: 0.1)) { currentStreak += 1 } if currentStreak >= targetStreak { timer.invalidate() isAnimating = false } } } private func pauseAnimation() { animationTimer?.invalidate() animationTimer = nil isAnimating = false } private func resetAnimation() { pauseAnimation() withAnimation { currentStreak = 0 } } } // MARK: - Recording Mode View (Exports 365 PNG frames) struct LiveActivityRecordingView: View { @Environment(\.dismiss) private var dismiss @State private var isExporting: Bool = false @State private var exportProgress: Int = 0 @State private var exportPath: String = "" @State private var exportComplete: Bool = false private let targetStreak = 365 var body: some View { ZStack { Color(hex: "111111") .ignoresSafeArea() VStack(spacing: 20) { if exportComplete { Text("Export Complete!") .font(.title2.bold()) .foregroundColor(.green) Text("365 frames exported to:") .foregroundColor(.white) Text(exportPath) .font(.caption) .foregroundColor(.white.opacity(0.7)) .multilineTextAlignment(.center) .padding(.horizontal) Button("Dismiss") { dismiss() } .padding() .background(Color.orange) .foregroundColor(.white) .cornerRadius(12) .accessibilityIdentifier(AccessibilityID.Debug.liveActivityDismissButton) } else if isExporting { Text("Exporting frames...") .font(.title2.bold()) .foregroundColor(.white) ProgressView(value: Double(exportProgress), total: Double(targetStreak)) .tint(.orange) .padding(.horizontal, 40) Text("\(exportProgress) / \(targetStreak)") .foregroundColor(.white.opacity(0.7)) } else { Text("Tap anywhere to start export") .font(.caption) .foregroundColor(.white.opacity(0.5)) } } } .accessibilityIdentifier(AccessibilityID.Debug.liveActivityExportButton) .onTapGesture { if !isExporting && !exportComplete { startExport() } } .statusBarHidden(true) } private func getMoodForStreak(_ streak: Int) -> (name: String, color: Color) { let moods: [(String, Color)] = [ ("Average", Color(hex: "FFC107")), ("Good", Color(hex: "8BC34A")), ("Great", Color(hex: "4CAF50")) ] // Must end on Great if streak >= 355 { return moods[2] // Great } // Change mood every 10 frames, pseudo-random based on streak let segment = streak / 10 let index = (segment * 7 + 3) % 3 // Pseudo-random pattern return moods[index] } private func startExport() { isExporting = true // Create output directory let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let outputDir = documentsPath.appendingPathComponent("LiveActivityFrames") try? FileManager.default.removeItem(at: outputDir) try? FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true) exportPath = outputDir.path #if DEBUG print("📁 Exporting frames to: \(exportPath)") #endif let target = targetStreak let outDir = outputDir let outPath = exportPath Task.detached(priority: .userInitiated) { for streak in 1...target { let mood = getMoodForStreak(streak) let cardView = LiveActivityCardView( streak: streak, moodName: mood.name, moodColor: mood.color ) let renderer = ImageRenderer(content: cardView) renderer.scale = 3.0 // @3x for high resolution if let uiImage = renderer.uiImage { let filename = String(format: "frame_%04d.png", streak) let fileURL = outDir.appendingPathComponent(filename) if let pngData = uiImage.pngData() { try? pngData.write(to: fileURL) } } await MainActor.run { exportProgress = streak } } await MainActor.run { exportComplete = true #if DEBUG print("✅ Export complete! \(target) frames saved to: \(outPath)") #endif } } } } // MARK: - Standalone Card View for Rendering struct LiveActivityCardView: View { let streak: Int let moodName: String let moodColor: Color var body: some View { HStack(spacing: 16) { // Streak indicator VStack(spacing: 4) { Image(systemName: "flame.fill") .font(.title) .foregroundColor(.orange) Text("\(streak)") .font(.title.bold()) Text("day streak") .font(.caption) .foregroundColor(.secondary) } Divider() .frame(height: 50) // Status HStack(spacing: 8) { Circle() .fill(moodColor) .frame(width: 24, height: 24) VStack(alignment: .leading) { Text("Today's mood") .font(.caption) .foregroundColor(.secondary) Text(moodName) .font(.headline) } } Spacer() } .padding() .background(Color(hex: "2C2C2E")) .clipShape(RoundedRectangle(cornerRadius: 16)) .environment(\.colorScheme, .dark) } } #Preview { LiveActivityPreviewView() } #Preview("Recording Mode") { LiveActivityRecordingView() }