Files
Reflect/Shared/Views/SettingsView/LiveActivityPreviewView.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

417 lines
13 KiB
Swift

//
// 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)
}
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)
}
}
Button(action: { showRecordingMode = true }) {
Label("Recording Mode", systemImage: "record.circle")
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(Color.red)
.foregroundColor(.white)
.cornerRadius(12)
}
}
.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)
} 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))
}
}
}
.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
print("📁 Exporting frames to: \(exportPath)")
// Export frames on background queue
DispatchQueue.global(qos: .userInitiated).async {
for streak in 1...targetStreak {
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 = outputDir.appendingPathComponent(filename)
if let pngData = uiImage.pngData() {
try? pngData.write(to: fileURL)
}
}
DispatchQueue.main.async {
exportProgress = streak
}
}
DispatchQueue.main.async {
exportComplete = true
print("✅ Export complete! \(targetStreak) frames saved to: \(exportPath)")
}
}
}
}
// 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()
}