Initial commit

This commit is contained in:
2026-06-22 11:28:01 +08:00
commit ace9c94359
84 changed files with 8899 additions and 0 deletions

View File

@ -0,0 +1,482 @@
//
// LoginView.swift
// suixinkan
//
// Created by Codex on 2026/6/18.
//
import SwiftUI
///
struct LoginView: View {
@Environment(AppSession.self) private var appSession
@Environment(AccountContext.self) private var accountContext
@Environment(PermissionContext.self) private var permissionContext
@Environment(ToastCenter.self) private var toastCenter
@Environment(AuthAPI.self) private var authAPI
@Environment(ProfileAPI.self) private var profileAPI
@Environment(AccountContextAPI.self) private var accountContextAPI
@Environment(AuthSessionCoordinator.self) private var authSessionCoordinator
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var viewModel = LoginViewModel()
@FocusState private var focusedField: LoginField?
private var contentMaxWidth: CGFloat {
horizontalSizeClass == .regular ? 560 : .infinity
}
var body: some View {
NavigationStack {
GeometryReader { proxy in
ZStack(alignment: .top) {
Image("LoginBackground")
.resizable()
.scaledToFill()
.ignoresSafeArea()
.contentShape(Rectangle())
.onTapGesture {
focusedField = nil
}
ScrollView(.vertical) {
loginContent
.frame(maxWidth: contentMaxWidth, alignment: .top)
.padding(.horizontal, horizontalPadding(for: proxy.size.width))
.padding(.top, topPadding(for: proxy))
.padding(.bottom, AppMetrics.Spacing.xxLarge)
.frame(maxWidth: .infinity, alignment: .top)
}
.scrollDismissesKeyboard(.interactively)
}
}
.background(Color(hex: 0x0B1220).ignoresSafeArea())
.toolbar(.hidden, for: .navigationBar)
.sheet(isPresented: showsAgreementSheetBinding) {
LoginAgreementConsentSheet(
onOpenAgreement: { title in
toastCenter.show("\(title)页面待接入")
},
onAgreeAndContinue: {
viewModel.acceptAgreement()
loginAction()
}
)
}
.sheet(item: pendingAccountSelectionBinding) { payload in
AccountSelectionView(
payload: payload,
isLoading: viewModel.isSelectingAccount,
onCancel: {
viewModel.clearPendingAccountSelection()
},
onConfirm: { account in
selectAccount(account)
}
)
}
}
.onChange(of: viewModel.username) { _, _ in
viewModel.normalizeUsernameCountryCodeIfNeeded()
dismissLoginToastIfNeeded()
}
.onChange(of: viewModel.password) { _, _ in
dismissLoginToastIfNeeded()
}
.task {
viewModel.applyPreferences(authSessionCoordinator.loginPreferences())
}
}
private var loginContent: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxLarge) {
Text("欢迎使用\n随心瞰商家版")
.font(.system(size: AppMetrics.FontSize.largeTitle, weight: .semibold))
.foregroundStyle(.white)
.lineSpacing(AppMetrics.LineSpacing.title)
.accessibilityIdentifier("login.title")
loginCard
}
}
private var loginCard: some View {
VStack(spacing: 0) {
VStack(spacing: AppMetrics.Spacing.xSmall) {
textInput(title: "请输入手机号", text: usernameBinding, icon: "person")
passwordInput
}
Spacer().frame(height: AppMetrics.ControlSize.checkboxTapArea)
agreementRow
Spacer().frame(height: AppMetrics.Spacing.mediumLarge)
Button(action: loginAction) {
HStack {
if viewModel.isLoading {
ProgressView()
.tint(.white)
.frame(width: AppMetrics.ControlSize.progressWidth)
} else {
Spacer()
.frame(width: AppMetrics.ControlSize.progressWidth)
}
Text("登录")
.font(.system(size: AppMetrics.FontSize.body, weight: .medium))
.foregroundStyle(.white)
Spacer()
.frame(width: AppMetrics.ControlSize.progressWidth)
}
.frame(maxWidth: .infinity)
.frame(height: AppMetrics.ControlSize.primaryButtonHeight)
.background(viewModel.canSubmit ? AppDesign.primary : Color.gray)
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button))
}
.disabled(!viewModel.canSubmit || viewModel.isLoading)
.accessibilityIdentifier("login.submit")
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
.shadow(color: .black.opacity(0.12), radius: 18, x: 0, y: 8)
}
///
private func topPadding(for proxy: GeometryProxy) -> CGFloat {
max(proxy.safeAreaInsets.top + 24, 72)
}
///
private func horizontalPadding(for width: CGFloat) -> CGFloat {
min(max(width * 0.07, 20), 30)
}
///
private func loginAction() {
focusedField = nil
if let validationError = viewModel.validateForLogin() {
if validationError == .privacyUnchecked {
viewModel.showsAgreementSheet = true
} else {
toastCenter.show(validationError.message)
focusedField = validationError.focusField
}
return
}
Task {
do {
let resolution = try await viewModel.login(authAPI: authAPI)
switch resolution {
case let .completed(response):
await completeLogin(with: response)
case .needsAccountSelection:
break
}
} catch is CancellationError {
// Keep cancellation silent; the current task was abandoned by SwiftUI.
} catch {
toastCenter.show(error.localizedDescription)
}
}
}
/// 使 token
private func selectAccount(_ account: AccountSwitchAccount) {
Task {
do {
let response = try await viewModel.selectAccount(account, authAPI: authAPI)
await completeLogin(with: response)
} catch is CancellationError {
// Keep cancellation silent; the current task was abandoned by SwiftUI.
} catch {
toastCenter.show(error.localizedDescription)
}
}
}
///
private func completeLogin(with response: V9AuthResponse) async {
do {
try await authSessionCoordinator.completeLogin(
with: response,
username: viewModel.normalizedUsername,
privacyAgreementAccepted: viewModel.privacyChecked,
appSession: appSession,
accountContext: accountContext,
permissionContext: permissionContext,
profileAPI: profileAPI,
accountContextAPI: accountContextAPI
)
} catch {
toastCenter.show(error.localizedDescription)
}
}
/// Toast
private func dismissLoginToastIfNeeded() {
toastCenter.dismiss()
}
///
private func textInput(title: String, text: Binding<String>, icon: String) -> some View {
HStack(spacing: AppMetrics.Spacing.xSmall) {
Image(systemName: icon)
.font(.system(size: AppMetrics.ControlSize.smallIcon))
.foregroundStyle(AppDesign.textSecondary)
TextField(title, text: text)
.font(.system(size: AppMetrics.FontSize.body))
.textFieldStyle(.plain)
.foregroundStyle(AppDesign.textPrimary)
.tint(AppDesign.primary)
.keyboardType(.phonePad)
.textContentType(.telephoneNumber)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.submitLabel(.next)
.focused($focusedField, equals: .username)
.onSubmit {
focusedField = .password
}
.accessibilityIdentifier("login.username")
}
.padding(.horizontal, AppMetrics.Spacing.medium)
.frame(height: AppMetrics.ControlSize.inputHeight)
.background(Color(hex: 0xF1F5F9), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
.overlay(
RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input)
.stroke(Color(hex: 0xDDE3EA), lineWidth: 1)
)
.contentShape(Rectangle())
.onTapGesture {
focusedField = .username
}
}
private var passwordInput: some View {
HStack(spacing: AppMetrics.Spacing.xSmall) {
Image(systemName: "lock")
.font(.system(size: AppMetrics.ControlSize.smallIcon))
.foregroundStyle(AppDesign.textSecondary)
Group {
if viewModel.showsPassword {
TextField("请输入密码", text: passwordBinding)
} else {
SecureField("请输入密码", text: passwordBinding)
}
}
.font(.system(size: AppMetrics.FontSize.body))
.textFieldStyle(.plain)
.foregroundStyle(AppDesign.textPrimary)
.tint(AppDesign.primary)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.submitLabel(.go)
.focused($focusedField, equals: .password)
.onSubmit {
if viewModel.canSubmit && !viewModel.isLoading {
loginAction()
}
}
Button {
viewModel.showsPassword.toggle()
} label: {
Image(viewModel.showsPassword ? "LoginPwdVisible" : "LoginPwdInvisible")
.resizable()
.frame(width: AppMetrics.ControlSize.passwordIcon, height: AppMetrics.ControlSize.passwordIcon)
.frame(width: AppMetrics.ControlSize.iconTapArea, height: AppMetrics.ControlSize.iconTapArea)
}
.buttonStyle(.plain)
}
.padding(.horizontal, AppMetrics.Spacing.medium)
.frame(height: AppMetrics.ControlSize.inputHeight)
.background(Color(hex: 0xF1F5F9), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
.overlay(
RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input)
.stroke(Color(hex: 0xDDE3EA), lineWidth: 1)
)
.contentShape(Rectangle())
.onTapGesture {
focusedField = .password
}
.accessibilityIdentifier("login.password")
}
private var agreementRow: some View {
HStack(alignment: .top, spacing: AppMetrics.Spacing.xSmall) {
Button {
viewModel.privacyChecked.toggle()
} label: {
Image(viewModel.privacyChecked ? "LoginCheckboxChecked" : "LoginCheckboxUnchecked")
.resizable()
.frame(width: AppMetrics.ControlSize.checkboxIcon, height: AppMetrics.ControlSize.checkboxIcon)
.frame(width: AppMetrics.ControlSize.checkboxTapArea, height: AppMetrics.ControlSize.checkboxTapArea)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityIdentifier("login.privacy")
agreementText
.padding(.top, AppMetrics.Spacing.xxSmall)
}
.frame(maxWidth: .infinity, alignment: .topLeading)
}
private var agreementText: some View {
ViewThatFits(in: .horizontal) {
HStack(spacing: 0) {
agreementPrefix
userAgreementButton
agreementSeparator
privacyPolicyButton
}
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) {
HStack(spacing: 0) {
agreementPrefix
userAgreementButton
}
HStack(spacing: 0) {
agreementSeparator
privacyPolicyButton
}
}
}
.font(.system(size: AppMetrics.FontSize.subheadline))
}
private var agreementPrefix: some View {
Text("已阅读并同意")
.foregroundStyle(Color(hex: 0x666666))
}
private var agreementSeparator: some View {
Text("")
.foregroundStyle(Color(hex: 0x666666))
}
private var userAgreementButton: some View {
Button("《用户协议》") {
toastCenter.show("用户协议页面待接入")
}
.foregroundStyle(Color(hex: 0x208BFF))
.accessibilityIdentifier("login.userAgreement")
}
private var privacyPolicyButton: some View {
Button("《隐私政策》") {
toastCenter.show("隐私政策页面待接入")
}
.foregroundStyle(Color(hex: 0x208BFF))
.accessibilityIdentifier("login.privacyPolicy")
}
private var usernameBinding: Binding<String> {
Binding(
get: { viewModel.username },
set: { viewModel.username = $0 }
)
}
private var passwordBinding: Binding<String> {
Binding(
get: { viewModel.password },
set: { viewModel.password = $0 }
)
}
private var showsAgreementSheetBinding: Binding<Bool> {
Binding(
get: { viewModel.showsAgreementSheet },
set: { viewModel.showsAgreementSheet = $0 }
)
}
private var pendingAccountSelectionBinding: Binding<AccountSelectionPayload?> {
Binding(
get: { viewModel.pendingAccountSelection },
set: { viewModel.pendingAccountSelection = $0 }
)
}
}
///
private struct LoginAgreementConsentSheet: View {
private static let compactDetent = PresentationDetent.height(240)
let onOpenAgreement: (String) -> Void
let onAgreeAndContinue: () -> Void
var body: some View {
VStack(spacing: AppMetrics.Spacing.sheet) {
Text("请先阅读并同意相关协议")
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
.frame(maxWidth: .infinity, alignment: .center)
agreementDescription
Button(action: onAgreeAndContinue) {
Text("同意并继续")
.font(.system(size: AppMetrics.FontSize.body, weight: .medium))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: AppMetrics.ControlSize.sheetButtonHeight)
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button))
}
.buttonStyle(.plain)
.padding(.top, AppMetrics.Spacing.large)
.accessibilityIdentifier("login.agreement.continue")
}
.padding(.horizontal, AppMetrics.Spacing.large)
.padding(.top, AppMetrics.Spacing.sheet)
.padding(.bottom, AppMetrics.Spacing.mediumLarge)
.presentationDetents([Self.compactDetent])
}
private var agreementDescription: some View {
VStack(spacing: AppMetrics.Spacing.small) {
Text("为了保障你的账号安全和服务体验,请阅读并同意")
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textSecondary)
.multilineTextAlignment(.center)
HStack(spacing: 0) {
agreementToken(title: "用户协议")
Text("")
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textSecondary)
agreementToken(title: "隐私政策")
}
}
.frame(maxWidth: .infinity)
}
///
private func agreementToken(title: String) -> some View {
Button {
onOpenAgreement(title)
} label: {
Text("\(title)")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
.foregroundStyle(Color(hex: 0x208BFF))
}
.buttonStyle(.plain)
}
}
#Preview {
LoginView()
.environment(AppSession())
.environment(AccountContext())
.environment(PermissionContext())
.environment(ToastCenter())
.environment(AuthAPI(client: APIClient()))
.environment(ProfileAPI(client: APIClient()))
.environment(AccountContextAPI(client: APIClient()))
.environment(AuthSessionCoordinator())
}