350 lines
11 KiB
Swift
350 lines
11 KiB
Swift
import SwiftUI
|
|
|
|
// MARK: - Loading View
|
|
|
|
struct LoadingView: View {
|
|
let message: String
|
|
|
|
var body: some View {
|
|
VStack(spacing: CloserSpacing.lg) {
|
|
CloserMarkLoader()
|
|
Text(message)
|
|
.font(CloserFont.callout)
|
|
.foregroundColor(.closerTextSecondary)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
}
|
|
|
|
struct CloserHeartLoader: View {
|
|
var size: CGFloat = 76
|
|
|
|
var body: some View {
|
|
CloserMarkLoader(size: size)
|
|
}
|
|
}
|
|
|
|
struct CloserMarkLoader: View {
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
@State private var isPulsing = false
|
|
|
|
var size: CGFloat = 76
|
|
|
|
var body: some View {
|
|
Image("closer-mark-loader")
|
|
.resizable()
|
|
.scaledToFit()
|
|
.frame(width: size, height: size)
|
|
.scaleEffect(reduceMotion ? 1 : (isPulsing ? 1.04 : 0.96))
|
|
.accessibilityHidden(true)
|
|
.onAppear {
|
|
guard !reduceMotion else {
|
|
isPulsing = false
|
|
return
|
|
}
|
|
|
|
withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) {
|
|
isPulsing = true
|
|
}
|
|
}
|
|
.onChange(of: reduceMotion) { _, newValue in
|
|
if newValue {
|
|
isPulsing = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Error View
|
|
|
|
struct ErrorView: View {
|
|
let message: String
|
|
let retryAction: (() -> Void)?
|
|
|
|
var body: some View {
|
|
VStack(spacing: CloserSpacing.lg) {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.font(.system(size: 48))
|
|
.foregroundColor(.closerWarning)
|
|
Text("Something went wrong")
|
|
.font(CloserFont.title3)
|
|
.foregroundColor(.closerText)
|
|
Text(message)
|
|
.font(CloserFont.callout)
|
|
.foregroundColor(.closerTextSecondary)
|
|
.multilineTextAlignment(.center)
|
|
|
|
if let retry = retryAction {
|
|
Button(action: retry) {
|
|
Label("Try Again", systemImage: "arrow.clockwise")
|
|
}
|
|
.buttonStyle(PrimaryButtonStyle())
|
|
.frame(maxWidth: 200)
|
|
}
|
|
}
|
|
.padding(CloserSpacing.xl)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
}
|
|
|
|
// MARK: - Empty State View
|
|
|
|
struct EmptyStateView: View {
|
|
let icon: String
|
|
let title: String
|
|
let message: String
|
|
var illustrationName: String? = nil
|
|
var action: (title: String, handler: () -> Void)? = nil
|
|
|
|
var body: some View {
|
|
VStack(spacing: CloserSpacing.lg) {
|
|
if let illustrationName {
|
|
CloserIllustrationView(imageName: illustrationName, size: 132)
|
|
} else {
|
|
Image(systemName: icon)
|
|
.font(.system(size: 48))
|
|
.foregroundColor(.closerPrimary.opacity(0.6))
|
|
}
|
|
|
|
Text(title)
|
|
.font(CloserFont.title3)
|
|
.foregroundColor(.closerText)
|
|
|
|
Text(message)
|
|
.font(CloserFont.callout)
|
|
.foregroundColor(.closerTextSecondary)
|
|
.multilineTextAlignment(.center)
|
|
|
|
if let action = action {
|
|
Button(action.title, action: action.handler)
|
|
.buttonStyle(PrimaryButtonStyle())
|
|
.frame(maxWidth: 200)
|
|
}
|
|
}
|
|
.padding(CloserSpacing.xl)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
}
|
|
|
|
// MARK: - Illustration View
|
|
|
|
struct CloserIllustrationView: View {
|
|
let imageName: String
|
|
var size: CGFloat = 220
|
|
var cornerRadius: CGFloat = CloserRadius.xlarge
|
|
|
|
var body: some View {
|
|
Image(imageName)
|
|
.resizable()
|
|
.scaledToFill()
|
|
.frame(width: size, height: size)
|
|
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
|
.accessibilityHidden(true)
|
|
}
|
|
}
|
|
|
|
// MARK: - Partner Status Row
|
|
|
|
struct PartnerStatusRow: View {
|
|
let displayName: String
|
|
let answered: Bool
|
|
let lastActive: Date?
|
|
|
|
var body: some View {
|
|
HStack(spacing: CloserSpacing.md) {
|
|
// Avatar circle
|
|
ZStack {
|
|
Circle()
|
|
.fill(answered ? .closerPrimary : .closerDivider)
|
|
.frame(width: 44, height: 44)
|
|
Image(systemName: "person.fill")
|
|
.foregroundColor(.white)
|
|
.font(.system(size: 18))
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(displayName)
|
|
.font(CloserFont.headline)
|
|
.foregroundColor(.closerText)
|
|
|
|
if answered {
|
|
Label("Answered", systemImage: "checkmark.circle.fill")
|
|
.font(CloserFont.caption)
|
|
.foregroundColor(.closerSuccess)
|
|
} else {
|
|
Label("Waiting for answer", systemImage: "clock.fill")
|
|
.font(CloserFont.caption)
|
|
.foregroundColor(.closerWarning)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.padding(CloserSpacing.md)
|
|
.background(Color.closerSurface)
|
|
.cornerRadius(CloserRadius.medium)
|
|
}
|
|
}
|
|
|
|
// MARK: - Premium Badge
|
|
|
|
struct PremiumBadge: View {
|
|
var body: some View {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "crown.fill")
|
|
.font(.system(size: 10))
|
|
Text("Premium")
|
|
.font(CloserFont.caption)
|
|
}
|
|
.foregroundColor(.closerGold)
|
|
.padding(.horizontal, CloserSpacing.sm)
|
|
.padding(.vertical, 4)
|
|
.background(Color.closerGold.opacity(0.15))
|
|
.cornerRadius(CloserRadius.full)
|
|
}
|
|
}
|
|
|
|
// MARK: - Streak Indicator
|
|
|
|
struct StreakIndicator: View {
|
|
let count: Int
|
|
let isActive: Bool
|
|
|
|
var body: some View {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: isActive ? "flame.fill" : "flame")
|
|
.foregroundColor(isActive ? .closerDanger : .closerTextSecondary)
|
|
Text("\(count) day\(count == 1 ? "" : "s")")
|
|
.font(CloserFont.footnote)
|
|
.foregroundColor(.closerTextSecondary)
|
|
}
|
|
.padding(.horizontal, CloserSpacing.sm)
|
|
.padding(.vertical, 4)
|
|
.background(isActive ? Color.closerDanger.opacity(0.1) : Color.closerDivider.opacity(0.3))
|
|
.cornerRadius(CloserRadius.full)
|
|
}
|
|
}
|
|
|
|
// MARK: - Category Glyph
|
|
|
|
struct CategoryGlyph: View {
|
|
let name: String
|
|
let color: Color
|
|
var isLarge: Bool = false
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Circle()
|
|
.fill(color.opacity(0.15))
|
|
.frame(width: isLarge ? 64 : 48, height: isLarge ? 64 : 48)
|
|
|
|
Image(systemName: iconForCategory(name))
|
|
.font(.system(size: isLarge ? 24 : 18))
|
|
.foregroundColor(color)
|
|
}
|
|
}
|
|
|
|
private func iconForCategory(_ name: String) -> String {
|
|
switch name.lowercased() {
|
|
case let s where s.contains("communication"): return "bubble.left.and.bubble.right.fill"
|
|
case let s where s.contains("intimacy"): return "heart.fill"
|
|
case let s where s.contains("fun"): return "gamecontroller.fill"
|
|
case let s where s.contains("goal"): return "target"
|
|
case let s where s.contains("adventure"): return "paperplane.fill"
|
|
case let s where s.contains("romance"): return "sparkles"
|
|
case let s where s.contains("deep"): return "brain.head.profile"
|
|
default: return "questionmark.circle.fill"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Answer Card
|
|
|
|
struct AnswerCard: View {
|
|
let text: String
|
|
let date: Date
|
|
let isRevealed: Bool
|
|
let onTap: (() -> Void)?
|
|
|
|
var body: some View {
|
|
Button(action: { onTap?() }) {
|
|
VStack(alignment: .leading, spacing: CloserSpacing.sm) {
|
|
HStack {
|
|
Text(date, style: .date)
|
|
.font(CloserFont.caption)
|
|
.foregroundColor(.closerTextSecondary)
|
|
Spacer()
|
|
if isRevealed {
|
|
Image(systemName: "eye.fill")
|
|
.foregroundColor(.closerPrimary)
|
|
} else {
|
|
Image(systemName: "eye.slash.fill")
|
|
.foregroundColor(.closerTextSecondary)
|
|
}
|
|
}
|
|
|
|
Text(isRevealed ? text : "Tap to reveal partner's answer")
|
|
.font(CloserFont.body)
|
|
.foregroundColor(isRevealed ? .closerText : .closerTextSecondary)
|
|
.lineLimit(3)
|
|
}
|
|
.padding(CloserSpacing.md)
|
|
.background(Color.closerSurface)
|
|
.cornerRadius(CloserRadius.medium)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
// MARK: - Heart Animation View
|
|
|
|
struct HeartBurstView: View {
|
|
@State private var animate = false
|
|
|
|
var body: some View {
|
|
Image(systemName: "heart.fill")
|
|
.font(.system(size: 80))
|
|
.foregroundColor(.closerDanger)
|
|
.scaleEffect(animate ? 1.2 : 0.5)
|
|
.opacity(animate ? 0.8 : 0)
|
|
.animation(.spring(response: 0.6, dampingFraction: 0.5).repeatCount(1, autoreverses: true), value: animate)
|
|
.onAppear { animate = true }
|
|
}
|
|
}
|
|
|
|
// MARK: - Premium Feature Gate
|
|
|
|
struct PremiumGateView: View {
|
|
let featureName: String
|
|
let onUnlock: () -> Void
|
|
let onDismiss: () -> Void
|
|
|
|
var body: some View {
|
|
VStack(spacing: CloserSpacing.xl) {
|
|
Image(systemName: "crown.fill")
|
|
.font(.system(size: 64))
|
|
.foregroundColor(.closerGold)
|
|
|
|
Text("Unlock \(featureName)")
|
|
.font(CloserFont.title2)
|
|
.foregroundColor(.closerText)
|
|
|
|
Text("This feature requires a premium subscription. Upgrade to access all games, unlimited questions, and more.")
|
|
.font(CloserFont.callout)
|
|
.foregroundColor(.closerTextSecondary)
|
|
.multilineTextAlignment(.center)
|
|
|
|
Button("See Plans", action: onUnlock)
|
|
.buttonStyle(PrimaryButtonStyle())
|
|
|
|
Button("Maybe Later", action: onDismiss)
|
|
.buttonStyle(SecondaryButtonStyle())
|
|
}
|
|
.padding(CloserSpacing.xxl)
|
|
.background(Color.closerBackground)
|
|
.cornerRadius(CloserRadius.xlarge)
|
|
.closerShadow(level: .large)
|
|
.padding(CloserSpacing.xl)
|
|
}
|
|
}
|