Closer/iphone/Closer/Components/CommonViews.swift

473 lines
15 KiB
Swift

import SwiftUI
// MARK: - Loading View
struct LoadingView: View {
let message: String
var body: some View {
VStack(spacing: CloserSpacing.lg) {
CloserHeartLoader()
Text(message)
.font(CloserFont.callout)
.foregroundColor(.closerTextSecondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
struct CloserHeartLoader: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var fillProgress: CGFloat = 0.08
@State private var isPulsing = false
var size: CGFloat = 76
var body: some View {
ZStack {
CloserHeartShape()
.fill(
LinearGradient(
colors: [
Color(hex: "F7C8E4").opacity(0.22),
Color(hex: "D9B8FF").opacity(0.22)
],
startPoint: .leading,
endPoint: .trailing
)
)
HStack(spacing: 0) {
Color(hex: "F7C8E4")
Color(hex: "D9B8FF")
}
.mask(CloserHeartShape())
.mask(alignment: .bottom) {
Rectangle()
.frame(height: size * (reduceMotion ? 1 : fillProgress))
}
CloserHeartHighlightShape(side: .left)
.fill(Color(hex: "FFF4FA").opacity(reduceMotion ? 0.68 : 0.68 * fillProgress))
CloserHeartHighlightShape(side: .right)
.fill(Color(hex: "F3E8FF").opacity(reduceMotion ? 0.52 : 0.52 * fillProgress))
}
.frame(width: size, height: size)
.scaleEffect(reduceMotion ? 1 : (isPulsing ? 1.04 : 0.96))
.accessibilityHidden(true)
.onAppear {
guard !reduceMotion else {
fillProgress = 1
isPulsing = false
return
}
withAnimation(.easeInOut(duration: 1.5).repeatForever(autoreverses: false)) {
fillProgress = 1
}
withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) {
isPulsing = true
}
}
.onChange(of: reduceMotion) { _, newValue in
if newValue {
fillProgress = 1
isPulsing = false
}
}
}
}
private struct CloserHeartShape: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: point(54, 85, in: rect))
path.addCurve(
to: point(22, 48, in: rect),
control1: point(49, 79, in: rect),
control2: point(27, 62, in: rect)
)
path.addCurve(
to: point(37, 24, in: rect),
control1: point(17, 35, in: rect),
control2: point(24, 24, in: rect)
)
path.addCurve(
to: point(54, 35, in: rect),
control1: point(45, 24, in: rect),
control2: point(51, 28, in: rect)
)
path.addCurve(
to: point(71, 24, in: rect),
control1: point(57, 28, in: rect),
control2: point(63, 24, in: rect)
)
path.addCurve(
to: point(86, 48, in: rect),
control1: point(84, 24, in: rect),
control2: point(91, 35, in: rect)
)
path.addCurve(
to: point(54, 85, in: rect),
control1: point(81, 62, in: rect),
control2: point(59, 79, in: rect)
)
path.closeSubpath()
return path
}
}
private struct CloserHeartHighlightShape: Shape {
enum Side {
case left
case right
}
let side: Side
func path(in rect: CGRect) -> Path {
var path = Path()
switch side {
case .left:
path.move(to: point(27, 42, in: rect))
path.addCurve(
to: point(42, 27, in: rect),
control1: point(28, 32, in: rect),
control2: point(34, 27, in: rect)
)
path.addCurve(
to: point(54, 35, in: rect),
control1: point(48, 27, in: rect),
control2: point(52, 30, in: rect)
)
path.addLine(to: point(54, 41, in: rect))
path.addCurve(
to: point(27, 42, in: rect),
control1: point(47, 36, in: rect),
control2: point(37, 36, in: rect)
)
path.closeSubpath()
case .right:
path.move(to: point(54, 35, in: rect))
path.addCurve(
to: point(69, 27, in: rect),
control1: point(57, 30, in: rect),
control2: point(62, 27, in: rect)
)
path.addCurve(
to: point(85, 42, in: rect),
control1: point(78, 27, in: rect),
control2: point(84, 32, in: rect)
)
path.addCurve(
to: point(54, 41, in: rect),
control1: point(75, 36, in: rect),
control2: point(65, 36, in: rect)
)
path.closeSubpath()
}
return path
}
}
private func point(_ x: CGFloat, _ y: CGFloat, in rect: CGRect) -> CGPoint {
CGPoint(
x: rect.minX + rect.width * (x / 108),
y: rect.minY + rect.height * (y / 108)
)
}
// 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)
}
}