feat: 订阅页面

This commit is contained in:
Junhui Chen 2025-08-19 20:53:57 +08:00
parent 7d40fe3203
commit 62c2defb1d
12 changed files with 911 additions and 257 deletions

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<Bucket
uuid = "55F37A93-4556-4005-B9BD-8F1A1D6A8474"
type = "1"
version = "2.0">
<Breakpoints>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
<BreakpointContent
uuid = "F1BBF7E2-4D6E-4646-83BC-F57E600056E4"
shouldBeEnabled = "Yes"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "wake/View/Subscribe/SubscribeView.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "65"
endingLineNumber = "65"
landmarkName = "body"
landmarkType = "24">
</BreakpointContent>
</BreakpointProxy>
</Breakpoints>
</Bucket>

View File

@ -23,7 +23,7 @@ struct Theme {
static let accent = Color(hex: "FF6B6B") //
// MARK: -
static let background = Color(hex: "F8F9FA") //
static let background = Color(hex: "FAFAFA") //
static let surface = Color.white //
static let surfaceSecondary = Color(hex: "F5F5F5") //
@ -40,14 +40,18 @@ struct Theme {
static let info = Color(hex: "3B82F6") //
// MARK: -
static let border = Color(hex: "E5E7EB") //
static let border = Color(hex: "D9D9D9") //
static let borderLight = Color(hex: "F3F4F6") //
static let borderDark = Color(hex: "D1D5DB") //
static let borderBlack = Color.black //
static let borderDark = borderBlack //
// MARK: -
static let freeBackground = primaryLight // Free
static let pioneerBackground = primary // Pioneer
static let subscribeButton = primary //
// MARK: -
static let cardBackground = Color.white //
}
// MARK: -
@ -59,9 +63,13 @@ struct Theme {
)
static let backgroundGradient = LinearGradient(
colors: [Colors.background, Colors.surface],
startPoint: .top,
endPoint: .bottom
gradient: Gradient(colors: [
Color(hex: "FBC063"),
Color(hex: "FEE9BE"),
Color(hex: "FAB851")
]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
static let accentGradient = LinearGradient(
@ -69,6 +77,12 @@ struct Theme {
startPoint: .leading,
endPoint: .trailing
)
// static let creditsInfoTooltip = LinearGradient(
// colors: [Colors(hex: "FFD38F"), Colors(hex: "FFF8DE"), Colors(hex: "FECE83")],
// startPoint: .topLeading,
// endPoint: .bottomTrailing
// )
}
// MARK: -

View File

@ -60,21 +60,21 @@ struct Typography {
/// - style:
/// - family: nil 使
/// - Returns: Font
static func font(for style: TypographyStyle, family: FontFamily? = nil) -> Font {
static func font(for style: TypographyStyle, family: FontFamily? = nil, size: CGFloat? = nil) -> Font {
let fontFamily = family ?? defaultFontFamily
guard let config = styleConfig[style] else {
return .body
}
//
if let customFont = UIFont(name: fontFamily.name, size: config.size) {
if let customFont = UIFont(name: fontFamily.name, size: size ?? config.size) {
let metrics = UIFontMetrics(forTextStyle: config.textStyle)
let scaledFont = metrics.scaledFont(for: customFont)
return Font(scaledFont)
}
// 退
let systemFont = UIFont.systemFont(ofSize: config.size, weight: config.weight)
let systemFont = UIFont.systemFont(ofSize: size ?? config.size, weight: config.weight)
let metrics = UIFontMetrics(forTextStyle: config.textStyle)
let scaledFont = metrics.scaledFont(for: systemFont)
return Font(scaledFont)

View File

@ -0,0 +1,288 @@
//
// CreditsDetailView.swift
// wake
//
// Created by fairclip on 2025/8/19.
//
import SwiftUI
// MARK: -
enum CreditTransactionType: String, CaseIterable {
case photoUnderstanding = "Photo Understanding"
case videoUnderstanding = "Video Understanding"
case mysteryBoxPurchase = "Mystery Box Purchase"
case dailyBonus = "Daily Bonus"
case subscriptionBonus = "Subscription Bonus"
var creditChange: Int {
switch self {
case .photoUnderstanding:
return -1
case .videoUnderstanding:
return -32
case .mysteryBoxPurchase:
return -100
case .dailyBonus:
return 200
case .subscriptionBonus:
return 500
}
}
var icon: String {
switch self {
case .photoUnderstanding:
return "photo"
case .videoUnderstanding:
return "video"
case .mysteryBoxPurchase:
return "gift"
case .dailyBonus:
return "calendar"
case .subscriptionBonus:
return "star.fill"
}
}
}
// MARK: -
struct CreditTransaction {
let id = UUID()
let type: CreditTransactionType
let date: Date
let creditChange: Int
init(type: CreditTransactionType, date: Date, creditChange: Int? = nil) {
self.type = type
self.date = date
self.creditChange = creditChange ?? type.creditChange
}
}
// MARK: -
struct CreditsDetailView: View {
@Environment(\.presentationMode) var presentationMode
@State private var showRules = false
//
private let totalCredits = 3290
private let expiringToday = 200
private let transactions: [CreditTransaction] = [
CreditTransaction(type: .photoUnderstanding, date: Calendar.current.date(byAdding: .hour, value: -2, to: Date()) ?? Date()),
CreditTransaction(type: .videoUnderstanding, date: Calendar.current.date(byAdding: .hour, value: -4, to: Date()) ?? Date()),
CreditTransaction(type: .mysteryBoxPurchase, date: Calendar.current.date(byAdding: .day, value: -1, to: Date()) ?? Date()),
CreditTransaction(type: .dailyBonus, date: Calendar.current.date(byAdding: .day, value: -1, to: Date()) ?? Date()),
CreditTransaction(type: .subscriptionBonus, date: Calendar.current.date(byAdding: .day, value: -2, to: Date()) ?? Date())
]
var body: some View {
NavigationView {
ScrollView {
VStack(spacing: 0) {
//
navigationHeader
//
mainCreditsCard
//
creditsHistorySection
Spacer(minLength: 100)
}
}
.background(Color(.systemGroupedBackground))
.navigationBarHidden(true)
}
}
// MARK: -
private var navigationHeader: some View {
NaviHeader(title: "Credits") {
presentationMode.wrappedValue.dismiss()
}
}
// MARK: -
private var mainCreditsCard: some View {
VStack(spacing: 0) {
//
HStack {
//
Circle()
.fill(Color.black)
.frame(width: 80, height: 80)
.overlay(
Image(systemName: "triangle.fill")
.foregroundColor(.white)
.font(.system(size: 24, weight: .bold))
)
Spacer()
//
VStack(alignment: .trailing, spacing: 8) {
HStack(spacing: 8) {
Circle()
.fill(Color.black)
.frame(width: 24, height: 24)
.overlay(
Image(systemName: "triangle.fill")
.foregroundColor(.white)
.font(.system(size: 8))
)
Text("\(totalCredits)")
.font(Typography.font(for: .headline, family: .quicksandBold, size: 36))
.foregroundColor(.black)
}
Text("Expiring Today : \(expiringToday)")
.font(Typography.font(for: .body, family: .quicksand))
.foregroundColor(.black.opacity(0.8))
}
}
.padding(Theme.Spacing.xl)
// 线
DashedLine()
.stroke(Color.black.opacity(0.3), style: StrokeStyle(lineWidth: 1, dash: [5, 5]))
.frame(height: 1)
.padding(.horizontal, Theme.Spacing.xl)
//
creditsRulesSection
}
.background(
LinearGradient(
colors: [
Color(hex: "FFB645"),
Color(hex: "FFA726")
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.cornerRadius(Theme.CornerRadius.large)
.padding(.horizontal, Theme.Spacing.xl)
.padding(.top, Theme.Spacing.xl)
}
// MARK: -
private var creditsRulesSection: some View {
VStack(spacing: 0) {
//
Button(action: {
withAnimation(.easeInOut(duration: 0.3)) {
showRules.toggle()
}
}) {
HStack {
Text("Credits Rules")
.font(Typography.font(for: .body, family: .quicksandBold))
.foregroundColor(.black)
Spacer()
Image(systemName: showRules ? "chevron.up" : "chevron.down")
.foregroundColor(.black)
.font(.system(size: 14, weight: .medium))
}
.padding(.horizontal, Theme.Spacing.xl)
.padding(.vertical, Theme.Spacing.lg)
}
//
if showRules {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
Text("Credits can be used for material indexing (1 credit per photo or per second of video) and for buying blind boxes (100 credits each).")
.font(Typography.font(for: .subtitle, family: .quicksand))
.foregroundColor(.black.opacity(0.8))
.multilineTextAlignment(.leading)
}
.padding(.horizontal, Theme.Spacing.xl)
.padding(.bottom, Theme.Spacing.lg)
}
}
}
// MARK: -
private var creditsHistorySection: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.lg) {
Text("Points History")
.font(Typography.font(for: .title, family: .quicksandBold))
.foregroundColor(Theme.Colors.textPrimary)
.padding(.horizontal, Theme.Spacing.xl)
LazyVStack(spacing: 0) {
ForEach(Array(transactions.enumerated()), id: \.element.id) { index, transaction in
CreditTransactionRow(
transaction: transaction,
isLast: index == transactions.count - 1
)
}
}
.background(Color(.systemBackground))
.cornerRadius(Theme.CornerRadius.medium)
.padding(.horizontal, Theme.Spacing.xl)
}
.padding(.top, Theme.Spacing.xl)
}
}
// MARK: -
struct CreditTransactionRow: View {
let transaction: CreditTransaction
let isLast: Bool
var body: some View {
VStack(spacing: 0) {
HStack(spacing: Theme.Spacing.lg) {
VStack(alignment: .leading, spacing: 4) {
Text(transaction.type.rawValue)
.font(Typography.font(for: .body, family: .quicksandBold))
.foregroundColor(Theme.Colors.textPrimary)
Text(formatDate(transaction.date))
.font(Typography.font(for: .caption, family: .quicksand))
.foregroundColor(Theme.Colors.textSecondary)
}
Spacer()
Text("\(transaction.creditChange > 0 ? "+" : "")\(transaction.creditChange)")
.font(Typography.font(for: .body, family: .quicksandBold))
.foregroundColor(transaction.creditChange > 0 ? Theme.Colors.success : Theme.Colors.textPrimary)
}
.padding(.horizontal, Theme.Spacing.lg)
.padding(.vertical, Theme.Spacing.lg)
if !isLast {
Divider()
.background(Theme.Colors.borderLight)
}
}
}
private func formatDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "MM-dd-yyyy"
return formatter.string(from: date)
}
}
// MARK: - 线
struct DashedLine: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: 0, y: 0))
path.addLine(to: CGPoint(x: rect.width, y: 0))
return path
}
}
// MARK: -
#Preview {
CreditsDetailView()
}

View File

@ -0,0 +1,107 @@
//
// CreditsInfoCard.swift
// wake
//
// Created by fairclip on 2025/8/19.
//
import SwiftUI
// MARK: -
struct CreditsInfoCard: View {
let totalCredits: Int
let onInfoTap: (() -> Void)?
let onDetailTap: (() -> Void)?
@State private var showInfoPopover = false
init(
totalCredits: Int,
onInfoTap: (() -> Void)? = nil,
onDetailTap: (() -> Void)? = nil
) {
self.totalCredits = totalCredits
self.onInfoTap = onInfoTap
self.onDetailTap = onDetailTap
}
var body: some View {
Button(action: {
onDetailTap?()
}) {
mainCreditsSection
}
.buttonStyle(PlainButtonStyle())
.background(Theme.Colors.primaryLight)
.cornerRadius(Theme.CornerRadius.extraLarge)
.shadow(color: Theme.Shadows.small, radius: Theme.Shadows.cardShadow.radius, x: Theme.Shadows.cardShadow.x, y: Theme.Shadows.cardShadow.y)
}
// MARK: -
private var mainCreditsSection: some View {
HStack(spacing: Theme.Spacing.md) {
//
HStack(spacing: Theme.Spacing.sm) {
Text("Credits:")
.font(Typography.font(for: .subtitle, family: .quicksandBold))
.foregroundColor(Theme.Colors.textPrimary)
Text("\(totalCredits)")
.font(Typography.font(for: .subtitle, family: .quicksandBold))
.foregroundColor(Theme.Colors.textPrimary)
}
//
HStack(spacing: Theme.Spacing.sm) {
//
Button(action: {
showInfoPopover = true
onInfoTap?()
}) {
Image(systemName: "questionmark.circle")
.foregroundColor(Theme.Colors.textSecondary)
.font(.system(size: 16))
}
.popover(isPresented: $showInfoPopover, attachmentAnchor: .point(.bottom), arrowEdge: .top) {
Text("Credits can be used for material indexing (1 credit per photo or per second of video) and for buying blind boxes (100 crediteach)")
.font(Typography.font(for: .caption, family: .quicksandRegular))
.multilineTextAlignment(.center)
.presentationBackground(Theme.Gradients.backgroundGradient)
.frame(minWidth: 240, maxWidth: UIScreen.main.bounds.width * 0.6)
.presentationCompactAdaptation(.popover)
.padding(.horizontal, Theme.Spacing.md)
.padding(.vertical, Theme.Spacing.sm)
}
Spacer()
//
Image(systemName: "chevron.right")
.foregroundColor(Theme.Colors.textPrimary)
.font(.system(size: 14, weight: .medium))
}
}
.padding(Theme.Spacing.lg)
}
}
// MARK: -
#Preview("Credits Info Card") {
VStack(spacing: 20) {
CreditsInfoCard(
totalCredits: 3290,
onInfoTap: {
print("Info tapped")
},
onDetailTap: {
print("Detail tapped")
}
)
}
.padding()
.background(Color(.systemGroupedBackground))
}

View File

@ -0,0 +1,165 @@
//
// PlanCompare.swift
// wake
//
// Created by fairclip on 2025/8/20.
//
import SwiftUI
// MARK: -
struct PlanFeature {
let title: String
let subtitle: String?
let freeValue: String
let pioneerValue: String
let icon: String?
}
// MARK: -
struct PlanCompare: View {
// MARK: -
private let features: [PlanFeature] = [
PlanFeature(
title: "Mystery Box Purchase:",
subtitle: nil,
freeValue: "3 /week",
pioneerValue: "Free",
icon: nil
),
PlanFeature(
title: "Material Upload:",
subtitle: nil,
freeValue: "50 images and\n5 videos/day",
pioneerValue: "Unlimited",
icon: nil
),
PlanFeature(
title: "Free Credits:",
subtitle: "Expires the next day",
freeValue: "200 /day",
pioneerValue: "500 /day",
icon: nil
)
]
var body: some View {
HStack(spacing: 0) {
//
featureNamesColumn
.frame(minWidth: 163)
// Free
planColumn(title: "Free", isPioneer: false)
.layoutPriority(1)
// Pioneer
planColumn(title: "Pioneer", isPioneer: true)
.frame(width: 88)
}
.background(Theme.Colors.cardBackground)
.cornerRadius(Theme.CornerRadius.medium)
.shadow(
color: Theme.Shadows.small,
radius: Theme.Shadows.cardShadow.radius,
x: Theme.Shadows.cardShadow.x,
y: Theme.Shadows.cardShadow.y
)
}
// MARK: -
private var featureNamesColumn: some View {
VStack(spacing: 0) {
//
Text("")
.font(Typography.font(for: .title, family: .quicksandBold, size: 14))
.padding(.vertical, Theme.Spacing.sm)
.frame(maxWidth: .infinity, minHeight: 30)
//
ForEach(Array(features.enumerated()), id: \.offset) { index, feature in
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
Text(feature.title)
.font(Typography.font(for: .body, family: .quicksandBold, size: 12))
.foregroundColor(Theme.Colors.textPrimary)
.multilineTextAlignment(.leading)
if let subtitle = feature.subtitle {
Text(subtitle)
.font(Typography.font(for: .caption, family: .quicksandRegular))
.foregroundColor(Theme.Colors.textSecondary)
.multilineTextAlignment(.leading)
}
}
.frame(maxWidth: .infinity, minHeight: 30, alignment: .leading)
.padding(.horizontal, Theme.Spacing.sm)
.padding(.vertical, Theme.Spacing.sm)
}
}
.padding(Theme.Spacing.sm)
}
// MARK: -
private func planColumn(title: String, isPioneer: Bool) -> some View {
VStack(spacing: 0) {
//
VStack(spacing: Theme.Spacing.xs) {
Text(title)
.font(Typography.font(for: .title, family: .quicksandBold, size: 14))
.foregroundColor(Color.black)
}
.frame(maxWidth: .infinity)
.padding(.vertical, Theme.Spacing.sm)
//
ForEach(Array(features.enumerated()), id: \.offset) { index, feature in
let value = isPioneer ? feature.pioneerValue : feature.freeValue
Text(value)
.font(Typography.font(for: .body, family: .quicksandRegular, size: 12))
.foregroundColor(isPioneer ? Color.black : Theme.Colors.textSecondary)
.fontWeight(isPioneer ? .semibold : .regular)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity, minHeight: 30)
.padding(.vertical, Theme.Spacing.sm)
}
}
.frame(maxWidth: .infinity)
.background(isPioneer ? Theme.Colors.primaryLight : Color.white)
.cornerRadius(Theme.CornerRadius.medium)
.overlay(
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.stroke(
isPioneer ? Theme.Colors.primary : Theme.Colors.border,
lineWidth: isPioneer ? 1 : 0
)
)
.padding(Theme.Spacing.sm)
}
}
// MARK: -
#Preview("PlanCompare") {
ScrollView {
VStack(spacing: Theme.Spacing.xl) {
PlanCompare()
}
.padding()
}
.background(Theme.Colors.background)
}
#Preview("PlanCompare Dark") {
ScrollView {
VStack(spacing: Theme.Spacing.xl) {
PlanCompare()
}
.padding()
}
.background(Color.black)
.preferredColorScheme(.dark)
}

View File

@ -0,0 +1,135 @@
//
// PlanSelector.swift
// wake
//
// Created by fairclip on 2025/8/19.
//
import SwiftUI
// MARK: -
struct PlanSelector: View {
@Binding var selectedPlan: SubscriptionPlan?
let onPlanSelected: (SubscriptionPlan) -> Void
private let plans: [SubscriptionPlan] = [.free, .pioneer]
init(
selectedPlan: Binding<SubscriptionPlan?>,
onPlanSelected: @escaping (SubscriptionPlan) -> Void = { _ in }
) {
self._selectedPlan = selectedPlan
self.onPlanSelected = onPlanSelected
}
var body: some View {
HStack(spacing: Theme.Spacing.md) {
ForEach(plans, id: \.self) { plan in
PlanCard(
plan: plan,
isSelected: selectedPlan == plan,
onTap: {
selectedPlan = plan
onPlanSelected(plan)
}
)
}
}
}
}
// MARK: -
struct PlanCard: View {
let plan: SubscriptionPlan
let isSelected: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
ZStack {
//
VStack(spacing: Theme.Spacing.sm) {
// Popular
if plan.isPopular {
VStack {
HStack {
Spacer()
Text("Popular")
.font(Typography.font(for: .caption, family: .quicksandRegular))
.foregroundColor(Color.white)
.padding(.horizontal, Theme.Spacing.sm)
.padding(.vertical, Theme.Spacing.xs)
.background(Color.black)
.cornerRadius(Theme.CornerRadius.round, corners: [.bottomLeft])
}
Spacer()
VStack {
//
Text(plan.displayName)
.font(Typography.font(for: .title, family: .quicksandBold, size: 18))
.foregroundColor(plan == .pioneer ? Theme.Colors.textPrimary: Theme.Colors.textTertiary )
//
if plan == .pioneer {
Text(plan.price)
.font(Typography.font(for: .body, family: .quicksandBold, size: 20))
.foregroundColor(Theme.Colors.textPrimary)
}
}
Spacer()
Spacer()
}
}
else {
//
Text(plan.displayName)
.font(Typography.font(for: .title, family: .quicksandBold, size: 18))
.foregroundColor(plan == .pioneer ? Theme.Colors.textPrimary: Theme.Colors.textTertiary )
//
if plan == .pioneer {
Text(plan.price)
.font(Typography.font(for: .body, family: .quicksandBold, size: 20))
.foregroundColor(Theme.Colors.textPrimary)
}
}
}
.frame(maxWidth: .infinity)
.frame(height: 120)
.background(
plan == .pioneer ?
Theme.Colors.primary :
Theme.Colors.surface
)
.overlay(
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.stroke(
isSelected ? Theme.Colors.borderDark : Theme.Colors.border,
lineWidth: 2
)
)
.cornerRadius(Theme.CornerRadius.medium)
}
}
.buttonStyle(PlainButtonStyle())
}
}
// MARK: -
#Preview("Plan Selector") {
@State var selectedPlan: SubscriptionPlan? = .pioneer
VStack(spacing: 20) {
PlanSelector(
selectedPlan: $selectedPlan,
onPlanSelected: { plan in
print("Selected plan: \(plan.displayName)")
}
)
}
.padding()
.background(Color(.systemGroupedBackground))
}

View File

@ -0,0 +1,69 @@
import SwiftUI
// MARK: - Subscribe Button
struct SubscribeButton: View {
let title: String
let isLoading: Bool
let action: () -> Void
init(
title: String = "Subscribe",
isLoading: Bool,
action: @escaping () -> Void
) {
self.title = title
self.isLoading = isLoading
self.action = action
}
var body: some View {
VStack(spacing: Theme.Spacing.xs) {
Button(action: {
guard !isLoading else { return }
action()
}) {
HStack(spacing: Theme.Spacing.sm) {
if isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.textInverse))
}
VStack {
Spacer()
Spacer()
Text(title)
.font(Typography.font(for: .body, family: .quicksandBold))
Spacer()
// Fixed subtitle text as requested
Text("And get 5,000 Permanent Credits")
.font(Typography.font(for: .caption, family: .quicksandRegular))
.foregroundColor(Theme.Colors.textPrimary)
Spacer()
Spacer()
}
}
.frame(height: 56)
.frame(maxWidth: .infinity)
.background(Theme.Colors.primary) // primary color background
.clipShape(Capsule())
.shadow(
color: Theme.Shadows.buttonShadow.color,
radius: Theme.Shadows.buttonShadow.radius,
x: Theme.Shadows.buttonShadow.x,
y: Theme.Shadows.buttonShadow.y
)
}
.buttonStyle(.plain)
.disabled(isLoading)
}
}
}
#Preview("SubscribeButton") {
VStack(spacing: Theme.Spacing.xl) {
SubscribeButton(isLoading: false) {}
SubscribeButton(isLoading: true) {}
}
.padding()
.background(Theme.Colors.background)
}

View File

@ -61,21 +61,21 @@ struct SubscriptionStatusBar: View {
var body: some View {
HStack(spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: 20) {
//
Text(status.title)
.font(.system(size: 28, weight: .bold, design: .rounded))
.font(Typography.font(for: .headline, family: .quicksandBold, size: 32))
.foregroundColor(status.textColor)
//
if case .pioneer(let expiryDate) = status {
VStack(alignment: .leading, spacing: 4) {
Text("Expires on :")
.font(.system(size: 14, weight: .medium))
.foregroundColor(status.textColor.opacity(0.8))
.font(Typography.font(for: .body, family: .quicksandRegular))
.foregroundColor(status.textColor.opacity(0.7))
Text(formatDate(expiryDate))
.font(.system(size: 16, weight: .semibold))
.font(Typography.font(for: .body, family: .quicksandRegular))
.foregroundColor(status.textColor)
}
} else {
@ -83,12 +83,12 @@ struct SubscriptionStatusBar: View {
onSubscribeTap?()
}) {
Text("Subscribe")
.font(.system(size: 14, weight: .semibold))
.font(Typography.font(for: .title, family: .quicksandRegular, size: 16))
.foregroundColor(Theme.Colors.textPrimary)
.padding(.horizontal, 20)
.padding(.vertical, 8)
.background(Theme.Colors.subscribeButton)
.cornerRadius(Theme.CornerRadius.large)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Theme.Gradients.backgroundGradient)
.cornerRadius(Theme.CornerRadius.extraLarge)
}
}
}

View File

@ -38,7 +38,7 @@ struct SubscriptionFeature {
}
struct SubscribeView: View {
@State private var selectedPlan: SubscriptionPlan = .free
@State private var selectedPlan: SubscriptionPlan? = .pioneer
@State private var isLoading = false
@Environment(\.presentationMode) var presentationMode
@ -62,11 +62,18 @@ struct SubscribeView: View {
//
creditsSection
VStack {
//
subscriptionPlansSection
//
specialOfferBanner
}
.background(Theme.Colors.cardBackground)
.cornerRadius(Theme.CornerRadius.medium)
.padding(.horizontal, Theme.Spacing.lg)
.padding(.vertical, Theme.Spacing.lg)
//
featureComparisonTable
@ -80,7 +87,7 @@ struct SubscribeView: View {
Spacer(minLength: 100)
}
}
.background(Color(.systemGroupedBackground))
.background(Theme.Colors.background)
.navigationBarHidden(true)
}
}
@ -94,197 +101,127 @@ struct SubscribeView: View {
// MARK: -
private var currentSubscriptionCard: some View {
VStack(spacing: 0) {
HStack {
VStack(alignment: .leading, spacing: 8) {
Text("Free")
.font(Typography.font(for: .headline, family: .quicksand))
.fontWeight(.bold)
Button(action: {
SubscriptionStatusBar(
status: .pioneer(expiryDate: Date()) ,
onSubscribeTap: {
//
}) {
Text("Subscribe")
.font(Typography.font(for: .subtitle, family: .quicksand))
.fontWeight(.medium)
.foregroundColor(.black)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Color.orange)
.cornerRadius(20)
handleSubscribe()
}
}
Spacer()
//
Circle()
.fill(Color.black)
.frame(width: 60, height: 60)
.overlay(
Image(systemName: "play.fill")
.foregroundColor(.white)
.font(.title2)
)
}
.padding(20)
.background(Color.orange.opacity(0.2))
.cornerRadius(16)
.padding(.horizontal, 20)
}
.padding(.horizontal, Theme.Spacing.xl)
}
// MARK: -
private var creditsSection: some View {
HStack {
Text("Credits: 3290")
.font(Typography.font(for: .body, family: .quicksand))
.fontWeight(.medium)
Button(action: {
//
}) {
Image(systemName: "info.circle")
.foregroundColor(.gray)
VStack(spacing: 16) {
CreditsInfoCard(
totalCredits: 3290,
onInfoTap: {
//
},
onDetailTap: {
//
}
)
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.gray)
.font(.caption)
}
.padding(.horizontal, 20)
.padding(.vertical, 16)
.background(Color(.systemBackground))
.cornerRadius(12)
.padding(.horizontal, 20)
.padding(.top, 20)
.padding(.horizontal, Theme.Spacing.xl)
.padding(.top, Theme.Spacing.xl)
}
// MARK: -
private var subscriptionPlansSection: some View {
HStack(spacing: 16) {
// Free
SubscriptionPlanCard(
plan: .free,
isSelected: selectedPlan == .free,
onTap: { selectedPlan = .free }
)
// Pioneer
SubscriptionPlanCard(
plan: .pioneer,
isSelected: selectedPlan == .pioneer,
onTap: { selectedPlan = .pioneer }
)
PlanSelector(
selectedPlan: $selectedPlan,
onPlanSelected: { plan in
print("Selected plan: \(plan.displayName)")
}
.padding(.horizontal, 20)
.padding(.top, 20)
)
.padding(.horizontal, Theme.Spacing.xl)
.padding(.top, Theme.Spacing.xl)
}
// MARK: -
private var specialOfferBanner: some View {
Text("First 100 users get a special deal: just $1 for your first month!")
.font(Typography.font(for: .caption, family: .quicksand))
HStack(spacing: 0) {
Text("First")
.font(Typography.font(for: .footnote, family: .quicksandRegular))
.foregroundColor(Theme.Colors.textPrimary)
Text(" 100")
.font(Typography.font(for: .footnote, family: .quicksandBold))
.foregroundColor(Theme.Colors.textPrimary)
Text(" users get a special deal: justs")
.font(Typography.font(for: .footnote, family: .quicksandRegular))
.foregroundColor(Theme.Colors.textPrimary)
Text(" $1")
.font(Typography.font(for: .footnote, family: .quicksandBold))
.foregroundColor(Theme.Colors.textPrimary)
Text(" for your first month!")
.font(Typography.font(for: .footnote, family: .quicksandRegular))
.foregroundColor(Theme.Colors.textPrimary)
}
.multilineTextAlignment(.center)
.padding(.horizontal, 20)
.padding(.top, 12)
.foregroundColor(.secondary)
.padding(.horizontal, Theme.Spacing.lg)
.padding(.top, Theme.Spacing.sm)
.padding(.bottom, Theme.Spacing.lg)
}
// MARK: -
private var featureComparisonTable: some View {
VStack(spacing: 0) {
//
HStack {
Spacer()
Text("Free")
.font(Typography.font(for: .subtitle, family: .quicksand))
.fontWeight(.medium)
.frame(maxWidth: .infinity)
.foregroundColor(.gray)
Text("Pro")
.font(Typography.font(for: .subtitle, family: .quicksand))
.fontWeight(.medium)
.frame(maxWidth: .infinity)
.foregroundColor(.gray)
}
.padding(.vertical, 16)
.background(Color(.systemGray6))
//
ForEach(Array(features.enumerated()), id: \.offset) { index, feature in
FeatureRow(feature: feature, isLast: index == features.count - 1)
}
}
.background(Color(.systemBackground))
.cornerRadius(12)
.padding(.horizontal, 20)
.padding(.top, 20)
.shadow(color: Color.black.opacity(0.05), radius: 2, x: 0, y: 1)
PlanCompare()
.padding(.horizontal, Theme.Spacing.lg)
}
// MARK: -
private var subscribeButton: some View {
VStack(spacing: 12) {
Button(action: {
handleSubscribe()
}) {
if isLoading {
HStack {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(0.8)
Text("Subscribe")
.font(Typography.font(for: .body, family: .quicksand))
.fontWeight(.semibold)
SubscribeButton(
title: "Subscribe",
isLoading: isLoading,
action: handleSubscribe
)
}
} else {
Text("Subscribe")
.font(Typography.font(for: .body, family: .quicksand))
.fontWeight(.semibold)
}
}
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(Color.blue)
.cornerRadius(25)
.disabled(isLoading)
Text("Get 5,000 Permanent Credits")
.font(Typography.font(for: .caption, family: .quicksand))
.foregroundColor(.secondary)
}
.padding(.horizontal, 20)
.padding(.top, 30)
.padding(.horizontal, Theme.Spacing.xl)
.padding(.top, Theme.Spacing.lg)
}
// MARK: -
private var legalLinks: some View {
HStack(spacing: 8) {
Button("Terms of Service") {
Button(action: {
//
}) {
Text("Terms of Service")
.underline()
}
Text("|")
.foregroundColor(.secondary)
Button("Privacy Policy") {
Button(action: {
//
}) {
Text("Privacy Policy")
.underline()
}
Text("|")
.foregroundColor(.secondary)
Button("Restore Purchase") {
Button(action: {
//
}) {
Text("Restore Purchase")
.underline()
}
}
.font(Typography.font(for: .caption, family: .quicksand))
.font(Typography.font(for: .caption, family: .quicksandRegular))
.foregroundColor(.secondary)
.padding(.top, 16)
.padding(.top, Theme.Spacing.sm)
}
// MARK: -
@ -299,91 +236,6 @@ struct SubscribeView: View {
}
}
// MARK: -
struct SubscriptionPlanCard: View {
let plan: SubscriptionPlan
let isSelected: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
VStack(spacing: 12) {
if plan.isPopular {
HStack {
Spacer()
Text("Popular")
.font(Typography.font(for: .caption, family: .quicksand))
.fontWeight(.medium)
.foregroundColor(.white)
.padding(.horizontal, 12)
.padding(.vertical, 4)
.background(Color.black)
.cornerRadius(12)
}
.padding(.top, -8)
}
Text(plan.displayName)
.font(Typography.font(for: .title, family: .quicksand))
.fontWeight(.bold)
.foregroundColor(plan == .pioneer ? .white : .gray)
Text(plan.price)
.font(Typography.font(for: .body, family: .quicksand))
.fontWeight(.medium)
.foregroundColor(plan == .pioneer ? .white : .gray)
Spacer()
}
.frame(maxWidth: .infinity, minHeight: 120)
.padding(16)
.background(plan == .pioneer ? Color.orange : Color.white)
.cornerRadius(16)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(isSelected ? Color.blue : (plan == .free ? Color.blue : Color.clear), lineWidth: 2)
)
.shadow(color: Color.black.opacity(0.1), radius: 4, x: 0, y: 2)
}
}
}
// MARK: -
struct FeatureRow: View {
let feature: SubscriptionFeature
let isLast: Bool
var body: some View {
HStack {
Text(feature.name)
.font(Typography.font(for: .subtitle, family: .quicksand))
.fontWeight(.medium)
.frame(maxWidth: .infinity, alignment: .leading)
.foregroundColor(.primary)
Text(feature.freeValue)
.font(Typography.font(for: .caption, family: .quicksand))
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.foregroundColor(.gray)
Text(feature.proValue)
.font(Typography.font(for: .caption, family: .quicksand))
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.foregroundColor(.gray)
}
.padding(.vertical, 12)
.padding(.horizontal, 16)
.background(Color(.systemBackground))
if !isLast {
Divider()
.padding(.leading, 16)
}
}
}
#Preview {
SubscribeView()
}