feat: 订阅卡片

This commit is contained in:
jinyaqiu 2025-09-02 09:29:49 +08:00
parent da842c8e7c
commit 8d5d69fb4a
7 changed files with 244 additions and 130 deletions

View File

@ -7,6 +7,8 @@
objects = {
/* Begin PBXBuildFile section */
AB6693CA2E65C94400BCAAC1 /* SVGKit in Frameworks */ = {isa = PBXBuildFile; productRef = AB6693C92E65C94400BCAAC1 /* SVGKit */; };
AB6693CC2E65C94400BCAAC1 /* SVGKitSwift in Frameworks */ = {isa = PBXBuildFile; productRef = AB6693CB2E65C94400BCAAC1 /* SVGKitSwift */; };
AB8773632E4E04400071CB53 /* .gitignore in Resources */ = {isa = PBXBuildFile; fileRef = AB8773622E4E040E0071CB53 /* .gitignore */; };
ABC150C12E5DB39A00A1F970 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = ABC150C02E5DB39A00A1F970 /* Lottie */; };
ABE8998E2E533A7100CD7BA6 /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = ABE8998D2E533A7100CD7BA6 /* Alamofire */; };
@ -57,6 +59,8 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
AB6693CC2E65C94400BCAAC1 /* SVGKitSwift in Frameworks */,
AB6693CA2E65C94400BCAAC1 /* SVGKit in Frameworks */,
ABE8998E2E533A7100CD7BA6 /* Alamofire in Frameworks */,
ABC150C12E5DB39A00A1F970 /* Lottie in Frameworks */,
);
@ -114,6 +118,8 @@
packageProductDependencies = (
ABE8998D2E533A7100CD7BA6 /* Alamofire */,
ABC150C02E5DB39A00A1F970 /* Lottie */,
AB6693C92E65C94400BCAAC1 /* SVGKit */,
AB6693CB2E65C94400BCAAC1 /* SVGKitSwift */,
);
productName = wake;
productReference = ABB4E2082E4B75D900660198 /* wake.app */;
@ -146,6 +152,7 @@
packageReferences = (
ABE8998C2E533A7100CD7BA6 /* XCRemoteSwiftPackageReference "Alamofire" */,
ABC150BF2E5DB39A00A1F970 /* XCRemoteSwiftPackageReference "lottie-spm" */,
AB6693C82E65C94400BCAAC1 /* XCRemoteSwiftPackageReference "SVGKit" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = ABB4E2092E4B75D900660198 /* Products */;
@ -392,6 +399,14 @@
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
AB6693C82E65C94400BCAAC1 /* XCRemoteSwiftPackageReference "SVGKit" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/SVGKit/SVGKit.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 3.0.0;
};
};
ABC150BF2E5DB39A00A1F970 /* XCRemoteSwiftPackageReference "lottie-spm" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/airbnb/lottie-spm.git";
@ -411,6 +426,16 @@
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
AB6693C92E65C94400BCAAC1 /* SVGKit */ = {
isa = XCSwiftPackageProductDependency;
package = AB6693C82E65C94400BCAAC1 /* XCRemoteSwiftPackageReference "SVGKit" */;
productName = SVGKit;
};
AB6693CB2E65C94400BCAAC1 /* SVGKitSwift */ = {
isa = XCSwiftPackageProductDependency;
package = AB6693C82E65C94400BCAAC1 /* XCRemoteSwiftPackageReference "SVGKit" */;
productName = SVGKitSwift;
};
ABC150C02E5DB39A00A1F970 /* Lottie */ = {
isa = XCSwiftPackageProductDependency;
package = ABC150BF2E5DB39A00A1F970 /* XCRemoteSwiftPackageReference "lottie-spm" */;

View File

@ -1,5 +1,5 @@
{
"originHash" : "0e95cd18402f001189cea942918f7d0c4c8b04175c6c482029650c892d28d55a",
"originHash" : "d4b9379b4bd658fe79a6ae528c96d3386427dfe9d23635a65dad6edf12af85ff",
"pins" : [
{
"identity" : "alamofire",
@ -10,6 +10,15 @@
"version" : "5.10.2"
}
},
{
"identity" : "cocoalumberjack",
"kind" : "remoteSourceControl",
"location" : "https://github.com/CocoaLumberjack/CocoaLumberjack.git",
"state" : {
"revision" : "a9ed4b6f9bdedce7d77046f43adfb8ce1fd54114",
"version" : "3.9.0"
}
},
{
"identity" : "lottie-spm",
"kind" : "remoteSourceControl",
@ -18,6 +27,24 @@
"revision" : "04f2fd18cc9404a0a0917265a449002674f24ec9",
"version" : "4.5.2"
}
},
{
"identity" : "svgkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SVGKit/SVGKit.git",
"state" : {
"revision" : "58152b9f7c85eab239160b36ffdfd364aa43d666",
"version" : "3.0.0"
}
},
{
"identity" : "swift-log",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log",
"state" : {
"revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2",
"version" : "1.6.4"
}
}
],
"version" : 3

View File

@ -182,6 +182,8 @@ struct BlindBoxView: View {
let mediaType: BlindBoxMediaType
@State private var showModal = false //
@State private var showSettings = false //
@State private var isMember = false //
@State private var memberDate = "" //
@State private var showLogin = false
@State private var memberProfile: MemberProfile? = nil
@State private var blindCount: BlindCount? = nil
@ -275,6 +277,8 @@ struct BlindBoxView: View {
switch result {
case .success(let response):
self.memberProfile = response.data
self.isMember = response.data.membershipLevel == "pioneer"
self.memberDate = response.data.membershipEndAt ?? ""
print("✅ 成功获取会员信息:", response.data)
print("✅ 用户ID:", response.data.userInfo.userId)
case .failure(let error):
@ -705,13 +709,9 @@ struct BlindBoxView: View {
ZStack {
// 1. SVG
if !showScalingOverlay {
SVGImage(svgName: "BlindBg")
.frame(
width: UIScreen.main.bounds.width * 1.8,
height: UIScreen.main.bounds.height * 0.85
)
.position(x: UIScreen.main.bounds.width / 2,
y: UIScreen.main.bounds.height * 0.325)
SVGImage(svgName: "BlindBg", contentMode: .fit)
// .position(x: UIScreen.main.bounds.width / 2,
// y: UIScreen.main.bounds.height * 0.325)
.opacity(showScalingOverlay ? 0 : 1)
.animation(.easeOut(duration: 1.5), value: showScalingOverlay)
}
@ -830,6 +830,7 @@ struct BlindBoxView: View {
.offset(x: -10, y: UIScreen.main.bounds.height * 0.2)
}
}
.padding()
.frame(
maxWidth: .infinity,
maxHeight: UIScreen.main.bounds.height * 0.65
@ -897,7 +898,9 @@ struct BlindBoxView: View {
) {
UserProfileModal(
showModal: $showModal,
showSettings: $showSettings
showSettings: $showSettings,
isMember: $isMember,
memberDate: $memberDate
)
}
.shadow(color: .black.opacity(0.3), radius: 10, x: 5, y: 0)

View File

@ -1,92 +1,144 @@
import SwiftUI
import WebKit
import SVGKit
struct SVGImage: UIViewRepresentable {
let svgName: String
var shouldFill: Bool = false
var contentMode: ContentMode = .fit
var tintColor: Color?
func makeUIView(context: Context) -> WKWebView {
let webView = WKWebView()
webView.isOpaque = false
webView.backgroundColor = .clear
webView.scrollView.isScrollEnabled = false
webView.scrollView.contentInsetAdjustmentBehavior = .never
guard let path = Bundle.main.path(forResource: svgName, ofType: "svg") else {
print("❌ 无法找到 SVG 文件: \(svgName).svg")
return webView
}
let fileURL = URL(fileURLWithPath: path)
let svgStyle = shouldFill ? """
width: 100%;
height: 100%;
object-fit: cover;
""" : """
max-width: 100%;
max-height: 100%;
object-fit: contain;
"""
let htmlString = """
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<style>
body, html {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
background-color: transparent;
}
.container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
svg {
\(svgStyle)
display: block;
}
</style>
</head>
<body>
<div class="container">
<object type="image/svg+xml" data="\(fileURL.lastPathComponent)"></object>
</div>
</body>
</html>
"""
webView.loadHTMLString(htmlString, baseURL: fileURL.deletingLastPathComponent())
return webView
private var svgPath: String {
return svgName
}
func updateUIView(_ uiView: WKWebView, context: Context) {}
private func createImageView() -> SVGKFastImageView {
let emptySVGString = """
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1" preserveAspectRatio="xMidYMid meet">
<rect width="1" height="1" fill="red" opacity="0.5"/>
</svg>
"""
if let data = emptySVGString.data(using: .utf8),
let svgImage = SVGKImage(data: data) {
let imageView = SVGKFastImageView(svgkImage: svgImage) ?? SVGKFastImageView()
imageView.contentMode = .scaleAspectFit
return imageView
}
return SVGKFastImageView()
}
func sizeThatFits(_ proposal: ProposedViewSize, uiView: WKWebView, context: Context) -> CGSize? {
func makeUIView(context: Context) -> SVGKFastImageView {
print("🔄 开始加载SVG: \(svgName)")
let imageView = createImageView()
loadSVG(into: imageView)
configureView(imageView)
return imageView
}
private func loadSVG(into imageView: SVGKFastImageView) {
guard let path = Bundle.main.path(forResource: svgPath, ofType: "svg") else {
print("⚠️ 在main bundle中找不到文件: \(svgPath).svg")
return
}
let url = URL(fileURLWithPath: path)
guard let svgImage = SVGKImage(contentsOf: url) else {
print("❌ 无法从URL创建SVG: \(path)")
return
}
// SVG
let containerSize = imageView.bounds.size
if containerSize != .zero {
svgImage.size = containerSize
} else {
//
svgImage.size = CGSize(width: 100, height: 100)
}
print("✅ 成功加载SVG: \(svgName), 尺寸: \(svgImage.size)")
DispatchQueue.main.async {
imageView.image = svgImage
imageView.setNeedsLayout()
imageView.layoutIfNeeded()
}
}
private func configureView(_ imageView: SVGKFastImageView) {
imageView.contentMode = contentMode == .fit ? .scaleAspectFit : .scaleAspectFill
imageView.clipsToBounds = true
imageView.translatesAutoresizingMaskIntoConstraints = false
//
imageView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
if let tintColor = tintColor?.uiColor {
imageView.tintColor = tintColor
DispatchQueue.main.async {
self.applyTintColor(tintColor, to: imageView.layer)
}
}
}
private func applyTintColor(_ color: UIColor, to layer: CALayer) {
if let shapeLayer = layer as? CAShapeLayer {
shapeLayer.fillColor = color.cgColor
}
layer.sublayers?.forEach { sublayer in
applyTintColor(color, to: sublayer)
}
}
func updateUIView(_ uiView: SVGKFastImageView, context: Context) {
loadSVG(into: uiView)
if let tintColor = tintColor?.uiColor {
uiView.tintColor = tintColor
DispatchQueue.main.async {
self.applyTintColor(tintColor, to: uiView.layer)
}
}
uiView.contentMode = contentMode == .fit ? .scaleAspectFit : .scaleAspectFill
}
func sizeThatFits(_ proposal: ProposedViewSize, uiView: SVGKFastImageView, context: Context) -> CGSize? {
return nil
}
}
// MARK: - ContentMode
extension SVGImage {
enum ContentMode {
case fit //
case fill //
}
}
// MARK: - Preview
#Preview {
VStack {
Text("Filled SVG")
SVGImage(svgName: "YourSVGName", shouldFill: true)
.frame(width: 200, height: 100)
VStack(spacing: 20) {
Text("IP SVG")
SVGImage(svgName: "IP")
.frame(width: 100, height: 100)
.background(Color.gray.opacity(0.2))
.border(Color.red, width: 1)
Text("Intrinsic Size SVG")
SVGImage(svgName: "YourSVGName", shouldFill: false)
.frame(width: 200, height: 100)
Text("Pioneer SVG")
SVGImage(svgName: "Pioneer", contentMode: .fill)
.frame(width: 100, height: 50)
.background(Color.gray.opacity(0.2))
.border(Color.blue, width: 1)
.clipped()
}
.padding()
}
// MARK: - Color Extension
private extension Color {
var uiColor: UIColor {
return UIColor(self)
}
}

View File

@ -25,11 +25,20 @@ struct APIResponse<T: Codable>: Codable {
struct UserProfileModal: View {
@Binding var showModal: Bool
@Binding var showSettings: Bool
@Binding var isMember: Bool
@Binding var memberDate: String
@State private var userProfile: UserProfile?
@State private var isLoading = false
@State private var errorMessage: String?
@State private var isCopied = false
init(showModal: Binding<Bool>, showSettings: Binding<Bool>, isMember: Binding<Bool>, memberDate: Binding<String>) {
self._showModal = showModal
self._showSettings = showSettings
self._isMember = isMember
self._memberDate = memberDate
}
var body: some View {
VStack(spacing: 20) {
Spacer()
@ -124,43 +133,8 @@ struct UserProfileModal: View {
)
.padding(.horizontal)
Button(action: {
Router.shared.navigate(to: .subscribe)
}) {
ZStack(alignment: .center) {
// SVG -
SVGImage(svgName: "Pioneer")
.scaledToFill()
.frame(maxWidth: .infinity, maxHeight: .infinity)
//
VStack(alignment: .leading, spacing: 6) {
Text("Pioneer")
.font(Typography.font(for: .title3))
.fontWeight(.bold)
.foregroundColor(.themeTextMessageMain)
.padding(.top, 6)
Text("Expires on :")
.font(.system(size: 12))
.foregroundColor(.themeTextMessageMain)
.padding(.top, 2)
Text("March 15, 2025")
.font(.system(size: 12))
.fontWeight(.bold)
.foregroundColor(.themeTextMessageMain)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.leading, 32)
// 使
.padding(.vertical, 12)
}
.frame(height: 112)
.frame(maxWidth: .infinity)
.cornerRadius(16)
.clipped() //
}
//
currentSubscriptionCard
VStack(spacing: 12) {
// upload
@ -269,7 +243,31 @@ struct UserProfileModal: View {
fetchUserInfo()
}
}
// MARK: -
private var currentSubscriptionCard: some View {
let status: SubscriptionStatus = {
if isMember {
let dateFormatter = ISO8601DateFormatter()
dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let expiryDate = dateFormatter.date(from: memberDate) ?? Date()
return .pioneer(expiryDate: expiryDate)
} else {
return .free
}
}()
return SubscriptionStatusBar(
status: status,
height: 112,
onSubscribeTap: {
//
Router.shared.navigate(to: .subscribe)
}
)
.padding(.horizontal, Theme.Spacing.xl)
}
private func fetchUserInfo() {
isLoading = true
errorMessage = nil
@ -295,5 +293,5 @@ struct UserProfileModal: View {
}
#Preview {
UserProfileModal(showModal: .constant(true), showSettings: .constant(false))
UserProfileModal(showModal: .constant(true), showSettings: .constant(false), isMember: .constant(true), memberDate: .constant(""))
}

View File

@ -53,16 +53,18 @@ enum SubscriptionStatus {
struct SubscriptionStatusBar: View {
let status: SubscriptionStatus
let onSubscribeTap: (() -> Void)?
private let height: CGFloat
init(status: SubscriptionStatus, onSubscribeTap: (() -> Void)? = nil) {
init(status: SubscriptionStatus, height: CGFloat? = nil, onSubscribeTap: (() -> Void)? = nil) {
self.status = status
self.height = height ?? 155 // 155
self.onSubscribeTap = onSubscribeTap
}
var body: some View {
ZStack(alignment: .topLeading) {
// Background SVG - First layer
SVGImage(svgName: status.backgroundImageName, shouldFill: true)
SVGImage(svgName: status.backgroundImageName)
.frame(maxWidth: .infinity, minHeight: 120)
.clipped()
@ -75,17 +77,20 @@ struct SubscriptionStatusBar: View {
.font(.system(size: 28, weight: .bold, design: .rounded))
.foregroundColor(status.textColor)
.padding(.leading, 24)
Spacer()
.frame(height: 10)
// Expiry date or subscribe button
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.9))
Text("Expires on :")
.font(.system(size: 12))
.foregroundColor(.themeTextMessageMain)
.padding(.top, 2)
Text(formatDate(expiryDate))
.font(.system(size: 16, weight: .semibold))
.foregroundColor(status.textColor)
.font(.system(size: 12))
.fontWeight(.bold)
.foregroundColor(.themeTextMessageMain)
}
.padding(.leading, 24)
.padding(.bottom, 20)
@ -93,10 +98,14 @@ struct SubscriptionStatusBar: View {
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
}
.frame(height: 155)
.frame(height: height) // 使
.frame(maxWidth: .infinity)
.cornerRadius(20)
.clipped()
.contentShape(Rectangle()) // Make entire area tappable
.onTapGesture {
onSubscribeTap?()
}
}
// MARK: -

View File

@ -139,7 +139,7 @@ struct SubscribeView: View {
}()
return SubscriptionStatusBar(
status: .pioneer(expiryDate: Date()),
status: status,
onSubscribeTap: {
//
handleSubscribe()