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.
425 lines
14 KiB
Swift
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()
|
|
}
|