473 lines
15 KiB
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)
|
|
}
|
|
}
|