feat: wan

This commit is contained in:
jinyaqiu 2025-08-22 12:25:47 +08:00
parent c786c3192a
commit e539bb37d2
4 changed files with 285 additions and 131 deletions

View File

@ -41,3 +41,19 @@ struct LoginResponseData: Codable {
/// ///
typealias AuthResponse = BaseResponse<LoginResponseData> typealias AuthResponse = BaseResponse<LoginResponseData>
///
struct UserInfoData: Codable {
let userId: String
let username: String
let avatarFileId: String?
enum CodingKeys: String, CodingKey {
case userId = "user_id"
case username
case avatarFileId = "avatar_file_id"
}
}
///
typealias UserInfoResponse = BaseResponse<UserInfoData>

View File

@ -7,57 +7,89 @@ public struct AvatarPicker: View {
@Binding var selectedImage: UIImage? @Binding var selectedImage: UIImage?
@Binding var showUsername: Bool @Binding var showUsername: Bool
@Binding var isKeyboardVisible: Bool @Binding var isKeyboardVisible: Bool
@Binding var uploadedFileId: String?
public init(selectedImage: Binding<UIImage?>, showUsername: Binding<Bool>, isKeyboardVisible: Binding<Bool>) { // Animation state
@State private var isAnimating = false
public init(selectedImage: Binding<UIImage?>, showUsername: Binding<Bool>, isKeyboardVisible: Binding<Bool>, uploadedFileId: Binding<String?>) {
self._selectedImage = selectedImage self._selectedImage = selectedImage
self._showUsername = showUsername self._showUsername = showUsername
self._isKeyboardVisible = isKeyboardVisible self._isKeyboardVisible = isKeyboardVisible
self._uploadedFileId = uploadedFileId
}
private var avatarSize: CGFloat {
isKeyboardVisible ? 125 : 225
}
private var borderWidth: CGFloat {
isKeyboardVisible ? 3 : 4
} }
public var body: some View { public var body: some View {
VStack(spacing: 20) { VStack(spacing: 20) {
// Avatar Image // Avatar Image Button
Button(action: { Button(action: {
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
showMediaPicker = true showMediaPicker = true
}
}) { }) {
ZStack { ZStack {
if let selectedImage = selectedImage { if let selectedImage = selectedImage {
Image(uiImage: selectedImage) Image(uiImage: selectedImage)
.resizable() .resizable()
.scaledToFill() .scaledToFill()
.frame(width: isKeyboardVisible ? 125 : 225, .frame(width: avatarSize, height: avatarSize)
height: isKeyboardVisible ? 125 : 225)
.clipShape(RoundedRectangle(cornerRadius: 20)) .clipShape(RoundedRectangle(cornerRadius: 20))
.overlay( .overlay(
RoundedRectangle(cornerRadius: 20) RoundedRectangle(cornerRadius: 20)
.stroke(Color.themePrimary, lineWidth: 4) .stroke(Color.themePrimary, lineWidth: borderWidth)
) )
.animation(.spring(response: 0.4, dampingFraction: 1), value: isKeyboardVisible)
} else { } else {
// Default SVG avatar // Default SVG avatar with animated dashed border
SVGImage(svgName: "IP") SVGImage(svgName: "IP")
.frame(width: isKeyboardVisible ? 125 : 225, .frame(width: avatarSize, height: avatarSize)
height: isKeyboardVisible ? 125 : 225)
.contentShape(Rectangle()) .contentShape(Rectangle())
.overlay( .overlay(
RoundedRectangle(cornerRadius: 20) RoundedRectangle(cornerRadius: 20)
.stroke(style: StrokeStyle(lineWidth: 4, lineCap: .round, dash: [12, 8])) .stroke(style: StrokeStyle(
lineWidth: borderWidth,
lineCap: .round,
dash: [12, 8],
dashPhase: isAnimating ? 40 : 0
))
.foregroundColor(Color.themePrimary) .foregroundColor(Color.themePrimary)
) )
.animation(.spring(response: 0.4, dampingFraction: 1), value: isKeyboardVisible) .onAppear {
withAnimation(Animation.linear(duration: 1.5).repeatForever(autoreverses: false)) {
isAnimating = true
}
}
} }
// Upload indicator
if isUploading { if isUploading {
ProgressView() ProgressView()
.progressViewStyle(CircularProgressViewStyle()) .progressViewStyle(CircularProgressViewStyle(tint: .themePrimary))
.scaleEffect(1.5) .scaleEffect(1.5)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 20))
.transition(.opacity)
} }
} }
.frame(width: avatarSize, height: avatarSize)
.animation(.spring(response: 0.4, dampingFraction: 0.7), value: isKeyboardVisible)
} }
.buttonStyle(ScaleButtonStyle())
// Upload Button (only shown when username is not shown)
if !showUsername { if !showUsername {
// Upload button
Button(action: { Button(action: {
withAnimation {
showMediaPicker = true showMediaPicker = true
}
}) { }) {
Text("Upload from Gallery") Text("Upload from Gallery")
.font(Typography.font(for: .subtitle, family: .inter)) .font(Typography.font(for: .subtitle, family: .inter))
@ -71,8 +103,8 @@ public struct AvatarPicker: View {
) )
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.transition(.opacity.combined(with: .move(edge: .bottom)))
} }
} }
.sheet(isPresented: $showMediaPicker) { .sheet(isPresented: $showMediaPicker) {
MediaPicker( MediaPicker(
@ -84,7 +116,9 @@ public struct AvatarPicker: View {
onDismiss: { onDismiss: {
showMediaPicker = false showMediaPicker = false
if !uploadManager.selectedMedia.isEmpty { if !uploadManager.selectedMedia.isEmpty {
withAnimation {
isUploading = true isUploading = true
}
uploadManager.startUpload() uploadManager.startUpload()
} }
} }
@ -94,10 +128,40 @@ public struct AvatarPicker: View {
if let firstMedia = uploadManager.selectedMedia.first, if let firstMedia = uploadManager.selectedMedia.first,
case .image(let image) = firstMedia, case .image(let image) = firstMedia,
uploadManager.isAllUploaded { uploadManager.isAllUploaded {
withAnimation(.spring()) {
selectedImage = image selectedImage = image
isUploading = false isUploading = false
if let status = uploadManager.uploadStatus["0"],
case .completed(let fileId) = status {
uploadedFileId = fileId
}
uploadManager.clearAllMedia() uploadManager.clearAllMedia()
} }
} }
} }
}
}
// Button style for scale effect
private struct ScaleButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.95 : 1.0)
.animation(.easeInOut(duration: 0.2), value: configuration.isPressed)
}
}
// MARK: - Preview
struct AvatarPicker_Previews: PreviewProvider {
static var previews: some View {
AvatarPicker(
selectedImage: .constant(nil),
showUsername: .constant(false),
isKeyboardVisible: .constant(false),
uploadedFileId: .constant(nil)
)
.padding()
.background(Color.gray.opacity(0.1))
.previewLayout(.sizeThatFits)
}
} }

View File

@ -11,9 +11,19 @@ struct UserInfo: View {
@State private var showLogoutAlert = false @State private var showLogoutAlert = false
@State private var avatarImage: UIImage? @State private var avatarImage: UIImage?
@State private var showUsername: Bool = false @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 isKeyboardVisible = false @State private var isKeyboardVisible = false
@FocusState private var isTextFieldFocused: Bool
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)
var body: some View { var body: some View {
ZStack { ZStack {
@ -49,77 +59,54 @@ struct UserInfo: View {
.padding(.vertical, 12) .padding(.vertical, 12)
.background(Color.themeTextWhiteSecondary) .background(Color.themeTextWhiteSecondary)
.zIndex(1) // .zIndex(1) //
// Dynamic text that changes based on keyboard state
HStack(spacing: 20) {
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)
.lineLimit(isKeyboardVisible ? 1 : 2)
.padding(6)
.background(
LinearGradient(
gradient: Gradient(colors: [
Color(red: 1.0, green: 0.97, blue: 0.87),
.white,
Color(red: 1.0, green: 0.97, blue: 0.84)
]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
}
.padding(10)
.animation(.easeInOut(duration: 0.3), value: isKeyboardVisible)
.transition(.opacity)
// //
GeometryReader { geometry in
ScrollView { ScrollView {
VStack(spacing: 0) { VStack(spacing: 0) {
// // Spacer -
if isKeyboardVisible { if !isKeyboardVisible {
HStack(spacing: 20) { Spacer(minLength: 0)
Text("Choose a photo as your avatar, and we'll generate a video mystery box for you.") .frame(height: geometry.size.height * 0.1) // 10% of available height
.font(Typography.font(for: .caption))
.foregroundColor(.black)
.frame(maxWidth: .infinity, alignment: .leading)
.lineLimit(1)
.padding(6)
.background(
LinearGradient(
gradient: Gradient(colors: [
Color(red: 1.0, green: 0.97, blue: 0.87),
.white,
Color(red: 1.0, green: 0.97, blue: 0.84)
]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
}
.padding(10)
.transition(AnyTransition.opacity.combined(with: .move(edge: .top)))
.animation(.easeInOut(duration: 0.4), value: isKeyboardVisible)
}
//
else {
HStack(spacing: 20) {
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)
.lineLimit(2)
.padding(6)
.background(
LinearGradient(
gradient: Gradient(colors: [
Color(red: 1.0, green: 0.97, blue: 0.87),
.white,
Color(red: 1.0, green: 0.97, blue: 0.84)
]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
}
.padding(10)
.transition(AnyTransition.opacity.combined(with: .move(edge: .top)))
.animation(.easeInOut(duration: 0.25), value: isKeyboardVisible)
} }
if !isKeyboardVisible { Spacer() } // Content VStack
VStack(spacing: 20) { VStack(spacing: 20) {
// Title // Title
Text(showUsername ? "Add Your Avatar" : "Whats Your Name") Text(showUsername ? "Add Your Avatar" : "What's Your Name?")
.font(Typography.font(for: .body, family: .quicksandBold)) .font(Typography.font(for: .body, family: .quicksandBold))
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
// Avatar // Avatar
ZStack {
AvatarPicker( AvatarPicker(
selectedImage: $avatarImage, selectedImage: $avatarImage,
showUsername: $showUsername, showUsername: $showUsername,
isKeyboardVisible: $isKeyboardVisible isKeyboardVisible: $isKeyboardVisible,
uploadedFileId: $uploadedFileId
) )
}
.padding(.top, isKeyboardVisible ? 0 : 20) .padding(.top, isKeyboardVisible ? 0 : 20)
if !showUsername { if !showUsername {
@ -150,6 +137,11 @@ struct UserInfo: View {
RoundedRectangle(cornerRadius: 16) RoundedRectangle(cornerRadius: 16)
.fill(Color.themePrimaryLight) .fill(Color.themePrimaryLight)
) )
.focused($isTextFieldFocused)
.submitLabel(.done)
.onSubmit {
isTextFieldFocused = false
}
} }
} }
.padding() .padding()
@ -157,24 +149,76 @@ struct UserInfo: View {
.cornerRadius(20) .cornerRadius(20)
.padding(.horizontal) .padding(.horizontal)
if !isKeyboardVisible { Spacer() } // Spacer -
if !isKeyboardVisible {
Spacer(minLength: 0)
.frame(height: geometry.size.height * 0.1) // 10% of available height
}
// Continue Button
Button(action: { 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("✅ 用户信息更新成功")
// Update local state with the new user info
if let userData = response.data {
self.userName = userData.username
// You can update other user data here if needed
}
// Show success message or navigate back
self.dismiss()
case .failure(let error):
print("❌ 用户信息更新失败: \(error.localizedDescription)")
// Show error message to user
// You can use an @State variable to show an alert or toast
self.errorMessage = "更新失败: \(error.localizedDescription)"
self.showError = true
}
}
}
} else {
withAnimation {
showUsername = true showUsername = true
}
}
}) { }) {
Text("Continue") Text(showUsername ? "Open" : "Continue")
.font(Typography.font(for: .body)) .font(Typography.font(for: .body))
.fontWeight(.bold) .fontWeight(.bold)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(16) .padding()
.foregroundColor(.black) .foregroundColor(.black)
.background( .background(
RoundedRectangle(cornerRadius: 25) RoundedRectangle(cornerRadius: 25)
.fill(Color.themePrimary) .fill(Color.themePrimary)
) )
} }
.padding() .padding(.horizontal)
.padding(.bottom, isKeyboardVisible ? 20 : 40) .padding(.bottom, isKeyboardVisible ? 20 : 40)
.disabled(showUsername && userName.trimmingCharacters(in: .whitespaces).isEmpty)
.opacity((showUsername && userName.trimmingCharacters(in: .whitespaces).isEmpty) ? 0.6 : 1.0)
.animation(.easeInOut, value: showUsername)
.frame(maxWidth: .infinity)
//
if isKeyboardVisible {
Spacer(minLength: 0)
.frame(height: 20) //
}
}
.frame(minHeight: geometry.size.height) //
} }
} }
.background(Color.themeTextWhiteSecondary) .background(Color.themeTextWhiteSecondary)
@ -190,19 +234,49 @@ struct UserInfo: View {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) 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 { .onAppear {
// Set up keyboard notifications
NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { _ in NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { _ in
withAnimation(.easeInOut(duration: 0.3)) {
isKeyboardVisible = true isKeyboardVisible = true
} }
}
NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: .main) { _ in NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: .main) { _ in
withAnimation(.easeInOut(duration: 0.3)) {
isKeyboardVisible = false isKeyboardVisible = false
} }
} }
}
.onDisappear { .onDisappear {
NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillShowNotification, object: nil) // Clean up observers
NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil) NotificationCenter.default.removeObserver(self)
}
.onReceive(keyboardPublisher) { isVisible in
withAnimation(.easeInOut(duration: 0.2)) {
isKeyboardVisible = isVisible
}
}
.onChange(of: isTextFieldFocused) { newValue in
withAnimation(.easeInOut(duration: 0.2)) {
isKeyboardVisible = newValue
}
} }
} }
} }