Files
Reflect/Shared/Views/SettingsView/LiveActivityPreviewView.swift
Trey T ed8205cd88 Complete accessibility identifier coverage across all 152 project files
Exhaustive file-by-file audit of every Swift file in the project (iOS app,
Watch app, Widget extension). Every interactive UI element — buttons, toggles,
pickers, links, menus, tap gestures, text editors, color pickers, photo
pickers — now has an accessibilityIdentifier for XCUITest automation.

46 files changed across Shared/, Onboarding/, Watch App/, and Widget targets.
Added ~100 new ID definitions covering settings debug controls, export/photo
views, sharing templates, customization subviews, onboarding flows, tip
modals, widget voting buttons, and watch mood buttons.
2026-03-26 08:34:56 -05:00

425 lines
14 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)
}
.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
print("📁 Exporting frames to: \(exportPath)")
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
print("✅ Export complete! \(target) frames saved to: \(outPath)")
}
}
}
}
// 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()
}