diff --git a/IosClientKit/Sources/FeatureAuth/AuthScreen.swift b/IosClientKit/Sources/FeatureAuth/AuthScreen.swift index c1f3cd3..2946e0a 100644 --- a/IosClientKit/Sources/FeatureAuth/AuthScreen.swift +++ b/IosClientKit/Sources/FeatureAuth/AuthScreen.swift @@ -1,10 +1,100 @@ import SwiftUI +import DesignSystem -/// FeatureAuth 占位(后续实现登录 / 注册)。 public struct AuthScreen: View { - public init() {} + private let viewModel: AuthViewModel + private let onLoggedIn: () -> Void + + @State private var phone = "" + @State private var code = "" + @State private var privacyAgreed = false + + @Environment(\.dismiss) private var dismiss + + public init(viewModel: AuthViewModel, onLoggedIn: @escaping () -> Void) { + self.viewModel = viewModel + self.onLoggedIn = onLoggedIn + } public var body: some View { - ContentUnavailableView("登录", systemImage: "person.crop.circle") + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: DS.Spacing.md) { + Text("手机号登录") + .font(.title2.bold()) + .foregroundStyle(DS.Color.brandDark) + Text("手机号仅用于账号绑定,不会公开展示") + .font(.footnote) + .foregroundStyle(DS.Color.faintText) + + TextField("手机号", text: Binding(get: { phone }, set: { phone = digits($0, max: 11) })) + .textFieldStyle(.roundedBorder) + + HStack(spacing: DS.Spacing.sm) { + TextField("验证码", text: Binding(get: { code }, set: { code = digits($0, max: 6) })) + .textFieldStyle(.roundedBorder) + Button(getCodeTitle) { viewModel.requestSmsCode(phone: phone) } + .buttonStyle(.bordered) + .disabled(!canSendCode) + } + + Button { privacyAgreed.toggle() } label: { + HStack(spacing: DS.Spacing.xs) { + Image(systemName: privacyAgreed ? "checkmark.square.fill" : "square") + .foregroundStyle(privacyAgreed ? DS.Color.brandGold : DS.Color.brandMuted) + Text("我已阅读并同意《用户协议》和《隐私政策》") + .font(.caption) + .foregroundStyle(DS.Color.faintText) + } + } + .buttonStyle(.plain) + + if let errorMessage = viewModel.errorMessage { + Text(errorMessage) + .font(.footnote) + .foregroundStyle(DS.Color.danger) + } + + Button(viewModel.isSubmitting ? "登录中..." : "登录") { + viewModel.login(phone: phone, code: code) + } + .buttonStyle(.borderedProminent) + .frame(maxWidth: .infinity) + .disabled(!canSubmit) + } + .padding(DS.Spacing.lg) + } + .navigationTitle("登录") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("取消") { dismiss() } + } + } + } + .onChange(of: viewModel.didLogin) { _, loggedIn in + if loggedIn { onLoggedIn() } + } + } + + private var phoneValid: Bool { + phone.range(of: #"^1\d{10}$"#, options: .regularExpression) != nil + } + + private var canSendCode: Bool { + phoneValid && viewModel.countdownSeconds == 0 && !viewModel.isSendingCode + } + + private var canSubmit: Bool { + phoneValid && !code.isEmpty && privacyAgreed && !viewModel.isSubmitting + } + + private var getCodeTitle: String { + if viewModel.countdownSeconds > 0 { return "\(viewModel.countdownSeconds)s 后重发" } + if viewModel.isSendingCode { return "发送中" } + return "获取验证码" + } + + private func digits(_ string: String, max: Int) -> String { + String(string.filter(\.isNumber).prefix(max)) } } diff --git a/IosClientKit/Sources/FeatureAuth/AuthViewModel.swift b/IosClientKit/Sources/FeatureAuth/AuthViewModel.swift new file mode 100644 index 0000000..e77f16f --- /dev/null +++ b/IosClientKit/Sources/FeatureAuth/AuthViewModel.swift @@ -0,0 +1,65 @@ +import CoreDomain +import Foundation +import Observation + +@MainActor +@Observable +public final class AuthViewModel { + public var isSendingCode = false + public var countdownSeconds = 0 + public var isSubmitting = false + public var errorMessage: String? + public var didLogin = false + + private let sessionUseCase: SessionUseCase + private var countdownTask: Task? + + public init(sessionUseCase: SessionUseCase) { + self.sessionUseCase = sessionUseCase + } + + public func requestSmsCode(phone: String) { + guard !isSendingCode, countdownSeconds == 0 else { return } + isSendingCode = true + errorMessage = nil + Task { [weak self] in + guard let self else { return } + do { + try await self.sessionUseCase.requestSmsCode(phone: phone) + self.startCountdown() + } catch { + self.errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + } + self.isSendingCode = false + } + } + + public func login(phone: String, code: String) { + guard !isSubmitting else { return } + isSubmitting = true + errorMessage = nil + Task { [weak self] in + guard let self else { return } + do { + try await self.sessionUseCase.login(phone: phone, code: code) + self.didLogin = true + } catch { + self.errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + } + self.isSubmitting = false + } + } + + private func startCountdown() { + countdownTask?.cancel() + countdownSeconds = 60 + countdownTask = Task { [weak self] in + guard let self else { return } + while self.countdownSeconds > 0 { + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled else { return } + self.countdownSeconds -= 1 + } + } + } +}