wake-ios/wake/View/Owner/UserInfo/UserInfo.swift
2025-09-06 17:17:31 +08:00

420 lines
18 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
struct UserInfo: View {
@Environment(\.dismiss) private var dismiss
@StateObject private var router = Router.shared
// Sample user data - replace with your actual data model
@State private var userName = ""
@State private var userEmail = "memo@example.com"
@State private var notificationsEnabled = true
@State private var darkModeEnabled = false
@State private var showLogoutAlert = false
@State private var avatarImage: UIImage?
@State private var showUsername: Bool = false
@State private var uploadedFileId: String? // Add state for file ID
@State private var errorMessage: String = ""
@State private var showError: Bool = false
@State private var hasTriggeredFirstBox: Bool = false //
//
@State private var isKeyboardVisible = false
@FocusState private var isTextFieldFocused: Bool
// 使
private static let keyboardPreloader: Void = {
let textField = UITextField()
textField.autocorrectionType = .no
textField.autocapitalizationType = .none
textField.spellCheckingType = .no
textField.isHidden = true
if let window = UIApplication.shared.windows.first {
window.addSubview(textField)
textField.becomeFirstResponder()
textField.resignFirstResponder()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
textField.removeFromSuperview()
}
}
}()
private let keyboardPublisher = NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)
.map { _ in true }
.merge(with: NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)
.map { _ in false })
.receive(on: RunLoop.main)
init() {
//
_ = UserInfo.keyboardPreloader
}
var body: some View {
ZStack {
//
Color.themeTextWhiteSecondary
.edgesIgnoringSafeArea(.all)
VStack(spacing: 0) {
//
HStack {
Button(action: {
dismiss()
}) {
Image(systemName: "chevron.left")
.font(.system(size: 17, weight: .semibold))
.foregroundColor(.themeTextMessageMain)
}
.padding(.leading, 16)
Spacer()
Text("Complete Your Profile")
.font(Typography.font(for: .title2, family: .quicksandBold))
.foregroundColor(.themeTextMessageMain)
Spacer()
//
Color.clear
.frame(width: 24, height: 24)
.padding(.trailing, 16)
}
.padding(.vertical, 12)
.background(Color.themeTextWhiteSecondary)
.zIndex(1) //
// Dynamic text that changes based on keyboard state
HStack(spacing: 6) {
SVGImage(svgName: "Tips")
.frame(width: 16, height: 16)
.padding(.leading,6)
Text("Choose a photo as your avatar, and we'll generate a video mystery box for you.")
.font(Typography.font(for: .caption))
.foregroundColor(.black)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(3)
}
.animation(.easeInOut(duration: 0.3), value: isKeyboardVisible)
.transition(.opacity)
.background(
Color.themeTextWhite
.cornerRadius(12)
)
.padding(10)
//
GeometryReader { geometry in
ZStack(alignment: .bottom) {
ScrollView {
VStack(spacing: 0) {
// Spacer -
if !isKeyboardVisible {
Spacer(minLength: 0)
.frame(height: geometry.size.height * 0.1) // 10% of available height
}
// Content VStack
VStack(spacing: 20) {
// Title
Text(showUsername ? "Add Your Avatar" : "What's Your Name?")
.font(Typography.font(for: .body, family: .quicksandBold))
.frame(maxWidth: .infinity, alignment: .center)
// Avatar
AvatarPicker(
selectedImage: $avatarImage,
showUsername: $showUsername,
isKeyboardVisible: $isKeyboardVisible,
uploadedFileId: $uploadedFileId
)
.padding(.top, isKeyboardVisible ? 0 : 20)
if showUsername {
TextField("Username", text: $userName)
.font(Typography.font(for: .subtitle, family: .inter))
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.padding()
.foregroundColor(.black)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color.themePrimaryLight)
)
.focused($isTextFieldFocused)
.submitLabel(.done)
.onSubmit {
isTextFieldFocused = false
}
}
}
.padding()
.background(Color(.white))
.cornerRadius(20)
.padding(.horizontal)
//
Spacer(minLength: 40) // +
}
.frame(minHeight: geometry.size.height) //
.padding(.bottom, isKeyboardVisible ? 300 : 0) //
}
// Fixed Button at bottom
VStack {
Spacer()
Button(action: {
if showUsername {
let parameters: [String: Any] = [
"username": userName,
"avatar_file_id": uploadedFileId ?? ""
]
NetworkService.shared.postWithToken(
path: "/iam/user/info",
parameters: parameters
) { (result: Result<UserInfoResponse, NetworkError>) in
DispatchQueue.main.async {
switch result {
case .success(let response):
print("✅ 用户信息更新成功")
if let userData = response.data {
self.userName = userData.username
}
Router.shared.navigate(to: .blindBox(mediaType: .image))
case .failure(let error):
print("❌ 用户信息更新失败: \(error.localizedDescription)")
self.errorMessage = "更新失败: \(error.localizedDescription)"
self.showError = true
}
}
}
} else {
withAnimation {
showUsername = true
}
}
}) {
Text(showUsername ? "Open" : "Continue")
.font(Typography.font(for: .body))
.fontWeight(.bold)
.frame(maxWidth: .infinity)
.padding()
.foregroundColor(.black)
.background(
RoundedRectangle(cornerRadius: 25)
.fill(Color.themePrimary)
)
}
.padding(.horizontal, 32)
.padding(.bottom, isKeyboardVisible ? 20 : 40)
.animation(.easeInOut, value: showUsername)
}
}
}
.background(Color.themeTextWhiteSecondary)
}
.navigationBarHidden(true)
.navigationBarBackButtonHidden(true)
//
if isKeyboardVisible {
Color.black.opacity(0.001)
.edgesIgnoringSafeArea(.all)
.onTapGesture {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
}
//
if showError {
VStack {
Text(errorMessage)
.font(Typography.font(for: .body))
.foregroundColor(.red)
.padding()
.background(Color.white)
.cornerRadius(10)
.shadow(radius: 2)
}
.frame(maxWidth: .infinity, alignment: .center)
.padding()
}
}
.onAppear {
// Set up keyboard notifications
NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { _ in
withAnimation(.easeInOut(duration: 0.3)) {
isKeyboardVisible = true
}
}
NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: .main) { _ in
withAnimation(.easeInOut(duration: 0.3)) {
isKeyboardVisible = false
// TextField
}
}
}
.onDisappear {
// Clean up observers
NotificationCenter.default.removeObserver(self)
}
//
.onChange(of: uploadedFileId) { newValue in
guard let fileId = newValue, !fileId.isEmpty else { return }
guard !hasTriggeredFirstBox else { return }
hasTriggeredFirstBox = true
generateFirstBlindBox(with: fileId)
}
.onReceive(keyboardPublisher) { isVisible in
withAnimation(.easeInOut(duration: 0.2)) {
isKeyboardVisible = isVisible
}
}
.onChange(of: isTextFieldFocused) { newValue in
withAnimation(.easeInOut(duration: 0.2)) {
isKeyboardVisible = newValue
}
}
}
}
// MARK: -
extension UserInfo {
private struct MaterialSubmitResponse: Codable {
let code: Int
let data: [String]?
}
private struct GenerateFileInfo: Codable {
let id: String
let fileName: String?
let url: String?
let metadata: [String: String]?
enum CodingKeys: String, CodingKey {
case id
case fileName = "file_name"
case url
case metadata
}
}
private struct GenerateData: Codable {
let id: Int64?
let boxType: String?
let status: String?
let resultFile: GenerateFileInfo?
enum CodingKeys: String, CodingKey {
case id
case boxType = "box_type"
case status
case resultFile = "result_file"
}
}
private struct GenerateResponse: Codable {
let code: Int
let data: GenerateData?
}
private func generateFirstBlindBox(with fileId: String) {
// 1)
let materialPayload: [[String: String]] = [[
"file_id": fileId,
"preview_file_id": fileId
]]
NetworkService.shared.postWithToken(
path: "/material",
parameters: materialPayload
) { (matResult: Result<MaterialSubmitResponse, NetworkError>) in
DispatchQueue.main.async {
switch matResult {
case .success(let matResp):
guard matResp.code == 0, let materialIds = matResp.data, let materialId = materialIds.first else {
self.errorMessage = "Submit material failed"
self.showError = true
return
}
// 2) material_id
let params: [String: Any] = [
"box_type": "First",
"material_ids": [materialId]
]
NetworkService.shared.postWithToken(
path: "/blind_box/generate",
parameters: params
) { (result: Result<GenerateResponse, NetworkError>) in
DispatchQueue.main.async {
switch result {
case .success(let response):
guard response.code == 0, let urlStr = response.data?.resultFile?.url, let url = URL(string: urlStr) else {
self.errorMessage = "Create first blind box failed: invalid response"
self.showError = true
return
}
//
URLSession.shared.dataTask(with: url) { data, _, error in
if let error = error {
DispatchQueue.main.async {
self.errorMessage = "Load result image failed: \(error.localizedDescription)"
self.showError = true
}
return
}
guard let data = data, let image = UIImage(data: data) else {
DispatchQueue.main.async {
self.errorMessage = "Invalid image data"
self.showError = true
}
return
}
DispatchQueue.main.async {
Router.shared.navigate(to: .blindOutcome(media: .image(image), time: nil, description: nil))
}
}.resume()
case .failure(let error):
self.errorMessage = "Create first blind box failed: \(error.localizedDescription)"
self.showError = true
}
}
}
case .failure(let error):
self.errorMessage = "Submit material failed: \(error.localizedDescription)"
self.showError = true
}
}
}
}
}
// MARK: - Settings Row View
struct SettingsRow: View {
let icon: String
let title: String
let color: Color
var body: some View {
HStack {
Image(systemName: icon)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 20, height: 20)
.padding(6)
.background(color.opacity(0.1))
.foregroundColor(color)
.cornerRadius(6)
Text(title)
.padding(.leading, 5)
}
.padding(.vertical, 4)
}
}
// MARK: - Preview
struct UserInfo_Previews: PreviewProvider {
static var previews: some View {
UserInfo()
}
}