punchnet-macos/punchnet/Views/Register/RegisterView.swift

499 lines
17 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.

//
// ResetPasswordView.swift
// punchnet
//
// Created by on 2026/3/9.
//
import SwiftUI
import Observation
// MARK: -
struct RegisterRootView: View {
@State private var registerModel = RegisterModel()
@Environment(AppContext.self) private var appContext: AppContext
var body: some View {
ZStack(alignment: .center) {
Color.clear
ZStack(alignment: .center) {
switch registerModel.stage {
case .requestVerifyCode:
RegisterRequestVerifyCodeView()
case .submitVerifyCode:
RegisterSubmitVerifyCodeView()
case .setPassword:
RegisterSetPasswordView()
case .success:
RegisterSuccessView()
}
}
.transition(.asymmetric(
insertion: .move(edge: registerModel.transitionEdge).combined(with: .opacity),
removal: .move(edge: registerModel.transitionEdge == .trailing ? .leading : .trailing).combined(with: .opacity)
))
}
.environment(registerModel)
// --- 使 overlay ---
.overlay(alignment: .topLeading) {
//
switch registerModel.stage {
case .success:
EmptyView()
default:
Button(action: {
//
withAnimation(.spring(duration: 0.6, bounce: 0.2)) {
self.appContext.loginScene = .login(username: nil)
}
}) {
HStack {
Image(systemName: "chevron.left")
.font(.system(size: 16, weight: .semibold))
.padding(5)
Text("首页")
.font(.system(size: 16, weight: .regular))
}
.contentShape(Rectangle()) //
}
.buttonStyle(.plain)
.padding([.top, .leading], 16) //
.transition(.opacity) //
}
}
}
}
// MARK: -
struct PunchTextField: View {
let icon: String
let placeholder: String
@Binding var text: String
var isSecure: Bool = false
var isDisabled: Bool = false
var body: some View {
HStack(spacing: 12) {
Image(systemName: icon)
.foregroundColor(.secondary)
.frame(width: 20)
if isSecure {
SecureField(placeholder, text: $text)
.textFieldStyle(.plain)
} else {
TextField(placeholder, text: $text)
.textFieldStyle(.plain)
.disabled(isDisabled)
}
}
.padding(10)
.background(Color.primary.opacity(isDisabled ? 0.02 : 0.05))
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.primary.opacity(0.1), lineWidth: 1)
)
}
}
// MARK: -
struct RegisterRequestVerifyCodeView: View {
@Environment(RegisterModel.self) var registerModel: RegisterModel
@State private var isProcessing = false
//
@State private var showAlert: Bool = false
@State private var errorMessage: String = ""
var body: some View {
@Bindable var model = registerModel
VStack(spacing: 24) {
headerSection(title: "创建个人网络", subtitle: "输入邮箱开始注册")
VStack(spacing: 16) {
PunchTextField(icon: "person.crop.circle", placeholder: "邮箱", text: $model.username)
}
.frame(width: 280)
Button(action: {
Task { @MainActor in
await self.requestVerifyCode(username: model.username)
}
}) {
Text("获取验证码")
.fontWeight(.medium)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.frame(width: 280)
.disabled(!SDLUtil.isValidIdentifyContact(model.username) || isProcessing)
}
.padding(40)
.alert(isPresented: $showAlert) {
Alert(title: Text("提示"), message: Text(self.errorMessage))
}
}
private func requestVerifyCode(username: String) async {
self.isProcessing = true
defer {
self.isProcessing = false
}
if username.isEmpty {
self.showAlert = true
self.errorMessage = "邮箱为空"
return
}
switch SDLUtil.identifyContact(username) {
case .email:
do {
let registerSession = try await self.registerModel.requestVerifyCode(username: username)
withAnimation(.spring(duration: 0.6, bounce: 0.2)) {
self.registerModel.stage = .submitVerifyCode
self.registerModel.username = username
self.registerModel.sessionId = registerSession.sessionId
self.registerModel.transitionEdge = .trailing
}
} catch let err as SDLAPIError {
self.showAlert = true
self.errorMessage = err.message
} catch let err {
self.showAlert = true
self.errorMessage = err.localizedDescription
}
default:
self.showAlert = true
self.errorMessage = "邮箱格式错误"
}
}
}
// MARK: -
struct RegisterSubmitVerifyCodeView: View {
@Environment(RegisterModel.self) var registerModel: RegisterModel
@State private var code: String = ""
@State private var isProcessing = false
//
@State private var showAlert: Bool = false
@State private var errorMessage: String = ""
// 使
@State private var isEnabled: Bool = false
@State private var remainingSeconds = 60
@State private var timer: Timer? = nil
//
var validInputCode: Bool {
return !self.code.isEmpty && self.code.count == 6 && self.code.allSatisfy {$0.isNumber}
}
var body: some View {
VStack(spacing: 24) {
headerSection(title: "身份验证", subtitle: "验证码已发送至 \(registerModel.username)")
VStack(alignment: .trailing, spacing: 16) {
PunchTextField(icon: "envelope.badge", placeholder: "输入 6 位验证码", text: $code)
Button {
Task { @MainActor in
await self.resendVerifyCodeAction(username: registerModel.username)
}
} label: {
if isEnabled {
Text("没有收到?重新获取")
} else {
Text("重新获取 (\(remainingSeconds)s)")
}
}
.buttonStyle(.link)
.font(.caption)
.disabled(!isEnabled) //
}
.frame(width: 280)
VStack(spacing: 12) {
Button(action: {
Task { @MainActor in
await self.submitVerifyCode(sessionId: registerModel.sessionId)
}
}) {
Text("验证并设置密码")
.fontWeight(.medium)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(!self.validInputCode)
Button("返回上一步") {
withAnimation(.spring(duration: 0.6, bounce: 0.2)) {
self.registerModel.stage = .requestVerifyCode
self.registerModel.transitionEdge = .leading
}
}
.buttonStyle(.plain)
.foregroundColor(.secondary)
}
.frame(width: 280)
}
.padding(40)
.alert(isPresented: $showAlert) {
Alert(title: Text("提示"), message: Text(errorMessage))
}
.task {
await self.startCountdown()
}
}
//
private func resendVerifyCodeAction(username: String) async {
do {
let result = try await self.registerModel.requestVerifyCode(username: username)
print("send verify code result: \(result)")
} catch let err {
print("resend verify get error: \(err)")
}
//
await self.startCountdown()
}
//
private func startCountdown() async {
self.isEnabled = false
self.remainingSeconds = 60
for sec in (1...self.remainingSeconds).reversed() {
self.remainingSeconds = sec
try? await Task.sleep(nanoseconds: 1_000_000_000)
}
self.isEnabled = true
}
//
private func submitVerifyCode(sessionId: Int) async {
self.isProcessing = true
defer {
self.isProcessing = false
}
do {
_ = try await self.registerModel.submitVerifyCode(sessionId: sessionId, verifyCode: self.code)
withAnimation(.spring(duration: 0.6, bounce: 0.2)) {
self.registerModel.stage = .setPassword
self.registerModel.transitionEdge = .trailing
}
} catch let err as SDLAPIError {
self.showAlert = true
self.errorMessage = err.message
} catch let err {
self.showAlert = true
self.errorMessage = err.localizedDescription
}
}
}
// MARK: -
struct RegisterSetPasswordView: View {
@Environment(RegisterModel.self) var registerModel: RegisterModel
@State private var password = ""
@State private var confirm = ""
@State private var isProcessing = false
//
@State private var showAlert: Bool = false
@State private var errorMessage: String = ""
//
var passwordError: String? {
if password.isEmpty || confirm.isEmpty {
return nil
}
if password != confirm {
return "两次输入的密码不一致"
}
if password.count < 8 {
return "密码至少需要 8 位"
}
return nil
}
var body: some View {
VStack(spacing: 24) {
headerSection(title: "设置安全密码", subtitle: "最后一步,请确保密码足够强大")
VStack(spacing: 12) {
PunchTextField(icon: "lock.shield", placeholder: "新密码", text: $password, isSecure: true)
PunchTextField(icon: "lock.shield", placeholder: "确认密码", text: $confirm, isSecure: true)
if let error = passwordError {
Text(error)
.font(.caption)
.foregroundColor(.red)
.frame(width: 280, alignment: .leading)
}
}
.frame(width: 280)
Button(action: {
Task { @MainActor in
await self.handleRegister(sessionId: registerModel.sessionId)
}
}) {
if isProcessing {
ProgressView()
.controlSize(.small)
} else {
Text("完成注册")
.fontWeight(.medium)
.frame(maxWidth: .infinity)
}
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.frame(width: 280)
.disabled(passwordError != nil)
}
.padding(40)
.alert(isPresented: $showAlert) {
Alert(title: Text("提示"), message: Text(self.errorMessage))
}
}
private func handleRegister(sessionId: Int) async {
self.isProcessing = true
defer {
self.isProcessing = false
}
do {
_ = try await self.registerModel.register(sessionId: sessionId, password: self.password)
withAnimation(.spring(duration: 0.6, bounce: 0.2)) {
self.registerModel.stage = .success
self.registerModel.transitionEdge = .trailing
}
} catch let err as SDLAPIError {
self.showAlert = true
self.errorMessage = err.message
} catch {
self.showAlert = true
self.errorMessage = "注册失败,重稍后重试"
}
}
}
// MARK:
struct RegisterSuccessView: View {
@Environment(AppContext.self) private var appContext: AppContext
@Environment(RegisterModel.self) private var registerModel: RegisterModel
// MARK: -
@State private var animateIcon: Bool = false //
@State private var animateText: Bool = false //
var body: some View {
VStack(spacing: 32) {
Spacer()
// MARK: - ()
ZStack {
//
Circle()
.fill(Color.green.opacity(0.1))
.frame(width: 100, height: 100)
//
.scaleEffect(animateIcon ? 1.1 : 0.95)
.opacity(animateIcon ? 0.8 : 1.0)
// Checkmark
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 56))
.foregroundStyle(.green.gradient)
// Checkmark
.scaleEffect(animateIcon ? 1.05 : 1.0)
}
//
.transition(.move(edge: .bottom).combined(with: .opacity))
// MARK: -
VStack(spacing: 32) {
VStack(spacing: 12) {
Text("注册成功")
.font(.title.bold())
Text("您的 PunchNet 账号已就绪。\n现在可以登录并开始构建您的私有网络了。")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
Button(action: {
//
withAnimation(.spring(duration: 0.6, bounce: 0.2)) {
self.appContext.loginScene = .login(username: registerModel.username)
}
}) {
Text("立即开始使用")
.fontWeight(.bold)
.frame(width: 200)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.tint(.green)
}
//
.opacity(animateText ? 1.0 : 0.0)
.offset(y: animateText ? 0 : 20)
}
.padding(40)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(NSColor.windowBackgroundColor)) //
.onAppear {
self.startAnimations()
}
}
// MARK: -
private func startAnimations() {
// 1.
withAnimation(.easeInOut(duration: 1.5).repeatForever(autoreverses: true)) {
animateIcon = true
}
// 2. 0.4 使
withAnimation(.spring(duration: 0.6, bounce: 0.3).delay(0.4)) {
animateText = true
}
}
}
// MARK: -
extension View {
func headerSection(title: String, subtitle: String) -> some View {
VStack(spacing: 8) {
Image(systemName: "shield.lefthalf.filled")
.font(.system(size: 42))
.foregroundStyle(.blue.gradient)
Text(title)
.font(.title2.bold())
Text(subtitle)
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
}
}