import SwiftUI // User profile model struct UserProfile: Codable { let userId: String let nickname: String let avatarUrl: String? let account: String let email: String enum CodingKeys: String, CodingKey { case userId = "user_id" case nickname case avatarUrl = "avatar_file_url" case account case email } } // API Response wrapper struct APIResponse: Codable { let code: Int let data: T } 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 @State private var isContentReady = false init(showModal: Binding, showSettings: Binding, isMember: Binding, memberDate: Binding) { self._showModal = showModal self._showSettings = showSettings self._isMember = isMember self._memberDate = memberDate } var body: some View { VStack(spacing: 20) { Spacer() .frame(height: UIApplication.shared.windows.first?.safeAreaInsets.top ?? 0) if isLoading { ProgressView() .padding() } else if let error = errorMessage { Text(error) .foregroundColor(.red) .padding() } else if isContentReady, let userProfile = userProfile { userProfileView(userProfile: userProfile) .opacity(isContentReady ? 1 : 0) .animation(.easeInOut(duration: 0.3), value: isContentReady) } else { // Empty view with same dimensions to prevent layout shifts Color.clear .frame(height: UIScreen.main.bounds.height * 0.7) } } .frame(width: UIScreen.main.bounds.width * 0.8) .background(Color.themeTextWhiteSecondary) .edgesIgnoringSafeArea(.all) .onAppear { fetchUserInfo() // Delay content appearance to sync with modal animation DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { withAnimation { isContentReady = true } } } } @ViewBuilder private func userProfileView(userProfile: UserProfile) -> some View { VStack(spacing: 20) { HStack(alignment: .center, spacing: 16) { if let avatarUrl = userProfile.avatarUrl, !avatarUrl.isEmpty, let url = URL(string: avatarUrl) { AsyncImage(url: url) { phase in switch phase { case .success(let image): image .resizable() .aspectRatio(contentMode: .fill) .frame(width: 60, height: 60) .clipShape(Circle()) default: Image(systemName: "person.circle.fill") .resizable() .aspectRatio(contentMode: .fill) .frame(width: 60, height: 60) .foregroundColor(.blue) } } .onTapGesture { Router.shared.navigate(to: .userInfo) } } else { Image(systemName: "person.circle.fill") .resizable() .aspectRatio(contentMode: .fill) .frame(width: 60, height: 60) .foregroundColor(.blue) } VStack(alignment: .leading, spacing: 10) { Text(userProfile.nickname) .font(Typography.font(for: .body)) .fontWeight(.bold) .foregroundColor(.themeTextMessageMain) HStack(spacing: 4) { Text("ID: \(userProfile.userId)") .font(.system(size: 14)) .foregroundColor(.themeTextMessageMain) .lineLimit(1) .truncationMode(.tail) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: 120) Button(action: copyUserId) { if isCopied { Image(systemName: "checkmark") .foregroundColor(.themePrimary) } else { Image(systemName: "doc.on.doc") } } .font(.system(size: 12)) .foregroundColor(.themeTextMessageMain) .animation(.easeInOut, value: isCopied) .contentShape(Rectangle()) .frame(width: 24, height: 24) } } Spacer() } .frame(maxWidth: .infinity) .padding() .background( RoundedRectangle(cornerRadius: 16) .fill(Color.white) ) .padding(.horizontal) // 当前订阅状态卡片 currentSubscriptionCard VStack(spacing: 12) { // upload Button(action: { Router.shared.navigate(to: .mediaUpload) }) { HStack(spacing: 16) { SVGImage(svgName: "Upload") .foregroundColor(.orange) .frame(width: 20, height: 20) Text("Upload Resources") .font(Typography.font(for: .body)) .fontWeight(.bold) .foregroundColor(.themeTextMessageMain) Spacer() } .padding() .cornerRadius(10) .contentShape(Rectangle()) } .buttonStyle(PlainButtonStyle()) // memories Button(action: { Router.shared.navigate(to: .memories) }) { HStack(spacing: 16) { SVGImage(svgName: "Memory") .foregroundColor(.orange) .frame(width: 20, height: 20) Text("My Memories") .font(Typography.font(for: .body)) .fontWeight(.bold) .foregroundColor(.themeTextMessageMain) Spacer() } .padding() .cornerRadius(10) .contentShape(Rectangle()) } .buttonStyle(PlainButtonStyle()) // Box // Button(action: { // Router.shared.navigate(to: .mediaUpload) // }) { // HStack(spacing: 16) { // SVGImage(svgName: "Box") // .foregroundColor(.orange) // .frame(width: 20, height: 20) // Text("My Blind Box") // .font(Typography.font(for: .body)) // .fontWeight(.bold) // .foregroundColor(.themeTextMessageMain) // Spacer() // } // .padding() // .cornerRadius(10) // .contentShape(Rectangle()) // } // .buttonStyle(PlainButtonStyle()) // setting Button(action: { withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { showSettings = true } }) { HStack(spacing: 16) { SVGImage(svgName: "Set") .foregroundColor(.orange) .frame(width: 20, height: 20) Text("Setting") .font(Typography.font(for: .body)) .fontWeight(.bold) .foregroundColor(.themeTextMessageMain) Spacer() } .padding() .cornerRadius(10) .contentShape(Rectangle()) } .buttonStyle(PlainButtonStyle()) } .frame(maxWidth: .infinity) .padding() .background( RoundedRectangle(cornerRadius: 16) .fill(Color.white) ) .padding(.horizontal) Spacer() } } private func copyUserId() { UIPasteboard.general.string = userProfile?.userId withAnimation { isCopied = true DispatchQueue.main.asyncAfter(deadline: .now() + 2) { withAnimation { isCopied = false } } } } // 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 NetworkService.shared.get( path: "/iam/user-info", parameters: nil ) { (result: Result, NetworkError>) in DispatchQueue.main.async { isLoading = false switch result { case .success(let response): self.userProfile = response.data print("✅ Successfully fetched user info:", response.data) case .failure(let error): self.errorMessage = error.localizedDescription print("❌ Failed to fetch user info:", error) } } } } } #Preview { UserProfileModal(showModal: .constant(true), showSettings: .constant(false), isMember: .constant(true), memberDate: .constant("")) }