
- 在swift-assistant-style.mdc中添加项目背景、代码结构、命名规范、Swift最佳实践、UI开发、性能、安全性、测试与质量、核心功能、开发流程、App Store指南等详细规则。 - 在yanaApp.swift中将SplashView替换为Splash,简化应用结构。
437 lines
14 KiB
Swift
437 lines
14 KiB
Swift
import SwiftUI
|
||
import Combine
|
||
|
||
// MARK: - RecoverPassword ViewModel
|
||
|
||
@MainActor
|
||
class RecoverPasswordViewModel: ObservableObject {
|
||
// MARK: - Published Properties
|
||
@Published var email: String = ""
|
||
@Published var verificationCode: String = ""
|
||
@Published var newPassword: String = ""
|
||
@Published var isNewPasswordVisible: Bool = false
|
||
@Published var countdown: Int = 0
|
||
@Published var isResetLoading: Bool = false
|
||
@Published var isCodeLoading: Bool = false
|
||
@Published var errorMessage: String?
|
||
@Published var isResetSuccess: Bool = false
|
||
|
||
// MARK: - Callbacks
|
||
var onBack: (() -> Void)?
|
||
|
||
// MARK: - Private Properties
|
||
private var timerCancellable: AnyCancellable?
|
||
|
||
// MARK: - Computed Properties
|
||
var isEmailValid: Bool {
|
||
!email.isEmpty
|
||
}
|
||
|
||
var isVerificationCodeValid: Bool {
|
||
!verificationCode.isEmpty
|
||
}
|
||
|
||
var isNewPasswordValid: Bool {
|
||
!newPassword.isEmpty
|
||
}
|
||
|
||
var isConfirmButtonEnabled: Bool {
|
||
!isResetLoading && isEmailValid && isVerificationCodeValid && isNewPasswordValid
|
||
}
|
||
|
||
var isGetCodeButtonEnabled: Bool {
|
||
!isCodeLoading && isEmailValid && countdown == 0
|
||
}
|
||
|
||
var getCodeButtonText: String {
|
||
if isCodeLoading {
|
||
return ""
|
||
} else if countdown > 0 {
|
||
return "\(countdown)s"
|
||
} else {
|
||
return LocalizedString("recover_password.get_code", comment: "")
|
||
}
|
||
}
|
||
|
||
// MARK: - Public Methods
|
||
func onBackTapped() {
|
||
onBack?()
|
||
}
|
||
|
||
func onEmailChanged(_ newEmail: String) {
|
||
email = newEmail
|
||
}
|
||
|
||
func onVerificationCodeChanged(_ newCode: String) {
|
||
verificationCode = newCode
|
||
}
|
||
|
||
func onNewPasswordChanged(_ newPassword: String) {
|
||
self.newPassword = newPassword
|
||
}
|
||
|
||
func onGetVerificationCodeTapped() {
|
||
guard isGetCodeButtonEnabled else { return }
|
||
|
||
isCodeLoading = true
|
||
errorMessage = nil
|
||
|
||
Task {
|
||
do {
|
||
let result = try await requestVerificationCode()
|
||
await MainActor.run {
|
||
self.handleCodeRequestResult(result)
|
||
}
|
||
} catch {
|
||
await MainActor.run {
|
||
self.handleCodeRequestError(error)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func onResetPasswordTapped() {
|
||
guard isConfirmButtonEnabled else { return }
|
||
|
||
isResetLoading = true
|
||
errorMessage = nil
|
||
|
||
Task {
|
||
do {
|
||
let result = try await resetPassword()
|
||
await MainActor.run {
|
||
self.handleResetResult(result)
|
||
}
|
||
} catch {
|
||
await MainActor.run {
|
||
self.handleResetError(error)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func resetState() {
|
||
email = ""
|
||
verificationCode = ""
|
||
newPassword = ""
|
||
isNewPasswordVisible = false
|
||
countdown = 0
|
||
isResetLoading = false
|
||
isCodeLoading = false
|
||
errorMessage = nil
|
||
isResetSuccess = false
|
||
stopCountdown()
|
||
|
||
#if DEBUG
|
||
email = "exzero@126.com"
|
||
#endif
|
||
}
|
||
|
||
// MARK: - Private Methods
|
||
private func requestVerificationCode() async throws -> Bool {
|
||
return false
|
||
// let request = EmailVerificationCodeRequest(email: email)
|
||
// let apiService = LiveAPIService()
|
||
// let response: EmailVerificationCodeResponse = try await apiService.request(request)
|
||
//
|
||
// if response.code == 200 {
|
||
// return true
|
||
// } else {
|
||
// throw APIError.serverError(response.message ?? "Failed to send verification code")
|
||
// }
|
||
}
|
||
|
||
private func resetPassword() async throws -> Bool {
|
||
return false
|
||
// let request = ResetPasswordRequest(
|
||
// email: email,
|
||
// verificationCode: verificationCode,
|
||
// newPassword: newPassword
|
||
// )
|
||
//
|
||
// let apiService = LiveAPIService()
|
||
// let response: ResetPasswordResponse = try await apiService.request(request)
|
||
//
|
||
// if response.code == 200 {
|
||
// return true
|
||
// } else {
|
||
// throw APIError.serverError(response.message ?? "Failed to reset password")
|
||
// }
|
||
}
|
||
|
||
private func handleCodeRequestResult(_ success: Bool) {
|
||
isCodeLoading = false
|
||
if success {
|
||
startCountdown()
|
||
}
|
||
}
|
||
|
||
private func handleCodeRequestError(_ error: Error) {
|
||
isCodeLoading = false
|
||
errorMessage = error.localizedDescription
|
||
}
|
||
|
||
private func handleResetResult(_ success: Bool) {
|
||
isResetLoading = false
|
||
if success {
|
||
isResetSuccess = true
|
||
onBack?()
|
||
}
|
||
}
|
||
|
||
private func handleResetError(_ error: Error) {
|
||
isResetLoading = false
|
||
errorMessage = error.localizedDescription
|
||
}
|
||
|
||
private func startCountdown() {
|
||
stopCountdown()
|
||
countdown = 60
|
||
|
||
timerCancellable = Timer.publish(every: 1.0, on: .main, in: .common)
|
||
.autoconnect()
|
||
.sink { _ in
|
||
if self.countdown > 0 {
|
||
self.countdown -= 1
|
||
} else {
|
||
self.stopCountdown()
|
||
}
|
||
}
|
||
}
|
||
|
||
private func stopCountdown() {
|
||
timerCancellable?.cancel()
|
||
timerCancellable = nil
|
||
countdown = 0
|
||
}
|
||
}
|
||
|
||
// MARK: - RecoverPassword View
|
||
|
||
struct RecoverPasswordPage: View {
|
||
@StateObject private var viewModel = RecoverPasswordViewModel()
|
||
let onBack: () -> Void
|
||
|
||
var body: some View {
|
||
GeometryReader { geometry in
|
||
ZStack {
|
||
// 背景图片
|
||
LoginBackgroundView()
|
||
|
||
VStack(spacing: 0) {
|
||
// 顶部导航栏
|
||
LoginHeaderView(onBack: {
|
||
viewModel.onBackTapped()
|
||
})
|
||
|
||
Spacer()
|
||
.frame(height: 60)
|
||
|
||
// 标题
|
||
Text(LocalizedString("recover_password.title", comment: ""))
|
||
.font(.system(size: 28, weight: .medium))
|
||
.foregroundColor(.white)
|
||
.padding(.bottom, 80)
|
||
|
||
// 输入框区域
|
||
VStack(spacing: 24) {
|
||
// 邮箱输入框
|
||
emailInputField
|
||
|
||
// 验证码输入框(带获取按钮)
|
||
verificationCodeInputField
|
||
|
||
// 新密码输入框
|
||
newPasswordInputField
|
||
}
|
||
.padding(.horizontal, 32)
|
||
|
||
Spacer()
|
||
.frame(height: 80)
|
||
|
||
// 确认按钮
|
||
confirmButton
|
||
|
||
// 错误信息
|
||
if let errorMessage = viewModel.errorMessage {
|
||
Text(errorMessage)
|
||
.font(.system(size: 14))
|
||
.foregroundColor(.red)
|
||
.padding(.top, 16)
|
||
.padding(.horizontal, 32)
|
||
}
|
||
|
||
Spacer()
|
||
}
|
||
}
|
||
}
|
||
.onAppear {
|
||
viewModel.onBack = onBack
|
||
viewModel.resetState()
|
||
}
|
||
.onDisappear {
|
||
// viewModel.stopCountdown()
|
||
}
|
||
.onChange(of: viewModel.email) { _, newEmail in
|
||
viewModel.onEmailChanged(newEmail)
|
||
}
|
||
.onChange(of: viewModel.verificationCode) { _, newCode in
|
||
viewModel.onVerificationCodeChanged(newCode)
|
||
}
|
||
.onChange(of: viewModel.newPassword) { _, newPassword in
|
||
viewModel.onNewPasswordChanged(newPassword)
|
||
}
|
||
}
|
||
|
||
// MARK: - UI Components
|
||
|
||
private var emailInputField: some View {
|
||
ZStack {
|
||
RoundedRectangle(cornerRadius: 25)
|
||
.fill(Color.white.opacity(0.1))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 25)
|
||
.stroke(Color.white.opacity(0.3), lineWidth: 1)
|
||
)
|
||
.frame(height: 56)
|
||
|
||
TextField("", text: $viewModel.email)
|
||
.placeholder(when: viewModel.email.isEmpty) {
|
||
Text(LocalizedString("recover_password.placeholder_email", comment: ""))
|
||
.foregroundColor(.white.opacity(0.6))
|
||
}
|
||
.foregroundColor(.white)
|
||
.font(.system(size: 16))
|
||
.padding(.horizontal, 24)
|
||
.keyboardType(.emailAddress)
|
||
.autocapitalization(.none)
|
||
}
|
||
}
|
||
|
||
private var verificationCodeInputField: some View {
|
||
ZStack {
|
||
RoundedRectangle(cornerRadius: 25)
|
||
.fill(Color.white.opacity(0.1))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 25)
|
||
.stroke(Color.white.opacity(0.3), lineWidth: 1)
|
||
)
|
||
.frame(height: 56)
|
||
|
||
HStack {
|
||
TextField("", text: $viewModel.verificationCode)
|
||
.placeholder(when: viewModel.verificationCode.isEmpty) {
|
||
Text(LocalizedString("recover_password.placeholder_verification_code", comment: ""))
|
||
.foregroundColor(.white.opacity(0.6))
|
||
}
|
||
.foregroundColor(.white)
|
||
.font(.system(size: 16))
|
||
.keyboardType(.numberPad)
|
||
|
||
// 获取验证码按钮
|
||
Button(action: {
|
||
viewModel.onGetVerificationCodeTapped()
|
||
}) {
|
||
ZStack {
|
||
if viewModel.isCodeLoading {
|
||
ProgressView()
|
||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||
.scaleEffect(0.7)
|
||
} else {
|
||
Text(viewModel.getCodeButtonText)
|
||
.font(.system(size: 14, weight: .medium))
|
||
.foregroundColor(.white)
|
||
}
|
||
}
|
||
.frame(width: 60, height: 36)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 15)
|
||
.fill(Color.white.opacity(viewModel.isGetCodeButtonEnabled ? 0.2 : 0.1))
|
||
)
|
||
}
|
||
.disabled(!viewModel.isGetCodeButtonEnabled || viewModel.isCodeLoading)
|
||
}
|
||
.padding(.horizontal, 24)
|
||
}
|
||
}
|
||
|
||
private var newPasswordInputField: some View {
|
||
ZStack {
|
||
RoundedRectangle(cornerRadius: 25)
|
||
.fill(Color.white.opacity(0.1))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 25)
|
||
.stroke(Color.white.opacity(0.3), lineWidth: 1)
|
||
)
|
||
.frame(height: 56)
|
||
|
||
HStack {
|
||
if viewModel.isNewPasswordVisible {
|
||
TextField("", text: $viewModel.newPassword)
|
||
.placeholder(when: viewModel.newPassword.isEmpty) {
|
||
Text(LocalizedString("recover_password.placeholder_new_password", comment: ""))
|
||
.foregroundColor(.white.opacity(0.6))
|
||
}
|
||
.foregroundColor(.white)
|
||
.font(.system(size: 16))
|
||
} else {
|
||
SecureField("", text: $viewModel.newPassword)
|
||
.placeholder(when: viewModel.newPassword.isEmpty) {
|
||
Text(LocalizedString("recover_password.placeholder_new_password", comment: ""))
|
||
.foregroundColor(.white.opacity(0.6))
|
||
}
|
||
.foregroundColor(.white)
|
||
.font(.system(size: 16))
|
||
}
|
||
|
||
Button(action: {
|
||
viewModel.isNewPasswordVisible.toggle()
|
||
}) {
|
||
Image(systemName: viewModel.isNewPasswordVisible ? "eye.slash" : "eye")
|
||
.foregroundColor(.white.opacity(0.7))
|
||
.font(.system(size: 18))
|
||
}
|
||
}
|
||
.padding(.horizontal, 24)
|
||
}
|
||
}
|
||
|
||
private var confirmButton: some View {
|
||
Button(action: {
|
||
viewModel.onResetPasswordTapped()
|
||
}) {
|
||
ZStack {
|
||
// 渐变背景
|
||
LinearGradient(
|
||
colors: [
|
||
Color(red: 0.85, green: 0.37, blue: 1.0), // #D85EFF
|
||
Color(red: 0.54, green: 0.31, blue: 1.0) // #8A4FFF
|
||
],
|
||
startPoint: .leading,
|
||
endPoint: .trailing
|
||
)
|
||
.clipShape(RoundedRectangle(cornerRadius: 28))
|
||
|
||
HStack {
|
||
if viewModel.isResetLoading {
|
||
ProgressView()
|
||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||
.scaleEffect(0.8)
|
||
}
|
||
Text(viewModel.isResetLoading ? LocalizedString("recover_password.resetting", comment: "") : LocalizedString("recover_password.confirm_button", comment: ""))
|
||
.font(.system(size: 18, weight: .semibold))
|
||
.foregroundColor(.white)
|
||
}
|
||
}
|
||
.frame(height: 56)
|
||
}
|
||
.disabled(!viewModel.isConfirmButtonEnabled)
|
||
.opacity(viewModel.isConfirmButtonEnabled ? 1.0 : 0.5)
|
||
.padding(.horizontal, 32)
|
||
}
|
||
}
|
||
|
||
#Preview {
|
||
RecoverPasswordPage(onBack: {})
|
||
}
|