Initial commit: suixinkan_ios UIKit rewrite project.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 14:33:31 +08:00
commit 9edf993432
297 changed files with 47151 additions and 0 deletions

View File

@ -0,0 +1,111 @@
//
// AppContentUnavailableView.swift
// suixinkan
//
// Created by Codex on 2026/6/26.
//
import SnapKit
import UIKit
/// iOS 16
final class AppContentUnavailableView: UIView {
private let contentStack = UIStackView()
private let labelStack = UIStackView()
private let iconView = UIImageView()
private let titleLabel = UILabel()
private let descriptionLabel = UILabel()
private let actionsContainer = UIStackView()
/// 使
init(
title: String,
systemImage: String,
description: String? = nil,
actions: [UIView] = []
) {
super.init(frame: .zero)
configureViews(title: title, systemImage: systemImage, description: description, actions: actions)
}
required init?(coder: NSCoder) {
nil
}
///
func update(title: String, systemImage: String, description: String? = nil) {
titleLabel.text = title
iconView.image = UIImage(systemName: systemImage)
descriptionLabel.text = description
descriptionLabel.isHidden = description?.isEmpty != false
}
///
func setActions(_ actions: [UIView]) {
actionsContainer.arrangedSubviews.forEach { view in
actionsContainer.removeArrangedSubview(view)
view.removeFromSuperview()
}
actions.forEach { actionsContainer.addArrangedSubview($0) }
actionsContainer.isHidden = actions.isEmpty
}
private func configureViews(
title: String,
systemImage: String,
description: String?,
actions: [UIView]
) {
contentStack.axis = .vertical
contentStack.alignment = .center
contentStack.spacing = AppMetrics.Spacing.small
labelStack.axis = .vertical
labelStack.alignment = .center
labelStack.spacing = AppMetrics.Spacing.xSmall
iconView.image = UIImage(systemName: systemImage)
iconView.tintColor = AppDesign.textSecondary
iconView.contentMode = .scaleAspectFit
iconView.setContentHuggingPriority(.required, for: .vertical)
titleLabel.text = title
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.title3, weight: .semibold)
titleLabel.textColor = AppDesign.textPrimary
titleLabel.textAlignment = .center
titleLabel.numberOfLines = 0
descriptionLabel.text = description
descriptionLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
descriptionLabel.textColor = AppDesign.textSecondary
descriptionLabel.textAlignment = .center
descriptionLabel.numberOfLines = 0
descriptionLabel.isHidden = description?.isEmpty != false
actionsContainer.axis = .vertical
actionsContainer.alignment = .center
actionsContainer.spacing = AppMetrics.Spacing.xSmall
actionsContainer.isHidden = actions.isEmpty
actions.forEach { actionsContainer.addArrangedSubview($0) }
labelStack.addArrangedSubview(iconView)
labelStack.addArrangedSubview(titleLabel)
if description?.isEmpty == false {
labelStack.addArrangedSubview(descriptionLabel)
}
contentStack.addArrangedSubview(labelStack)
if !actions.isEmpty {
contentStack.addArrangedSubview(actionsContainer)
}
addSubview(contentStack)
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(AppMetrics.Spacing.large)
}
iconView.snp.makeConstraints { make in
make.width.height.equalTo(44)
}
}
}

View File

@ -0,0 +1,21 @@
//
// AppDesign.swift
// suixinkan
//
// Created by Codex on 2026/6/18.
//
import UIKit
///
enum AppDesign {
static let primary = UIColor(hex: 0x0073FF)
static let primarySoft = UIColor(hex: 0xEFF6FF)
static let textPrimary = UIColor(hex: 0x1F2937)
static let textSecondary = UIColor(hex: 0x6B7280)
static let placeholder = UIColor(hex: 0xA8B2C1)
static let success = UIColor(hex: 0x14964A)
static let warning = UIColor(hex: 0xFF7B00)
static let inputBackground = UIColor(hex: 0xF8FAFC)
static let inputBorder = UIColor(hex: 0xE2E8F0)
}

View File

@ -0,0 +1,68 @@
//
// AppMetrics.swift
// suixinkan
//
// Created by Codex on 2026/6/20.
//
import CoreGraphics
/// App
final class AppMetrics {
private init() {}
/// App
enum FontSize {
static let caption: CGFloat = 12
static let footnote: CGFloat = 13
static let subheadline: CGFloat = 14
static let body: CGFloat = 16
static let callout: CGFloat = 17
static let title3: CGFloat = 18
static let title2: CGFloat = 20
static let title: CGFloat = 24
static let largeTitle: CGFloat = 30
}
///
enum Spacing {
static let xxxSmall: CGFloat = 2
static let xxSmall: CGFloat = 4
static let xSmall: CGFloat = 8
static let small: CGFloat = 12
static let substantial: CGFloat = 14
static let medium: CGFloat = 16
static let mediumLarge: CGFloat = 18
static let large: CGFloat = 20
static let sheet: CGFloat = 22
static let xLarge: CGFloat = 24
static let xxLarge: CGFloat = 30
static let pageHorizontal: CGFloat = 16
static let pageVertical: CGFloat = 24
}
///
enum ControlSize {
static let smallIcon: CGFloat = 16
static let checkboxIcon: CGFloat = 20
static let passwordIcon: CGFloat = 22
static let progressWidth: CGFloat = 22
static let checkboxTapArea: CGFloat = 28
static let iconTapArea: CGFloat = 32
static let sheetButtonHeight: CGFloat = 48
static let primaryButtonHeight: CGFloat = 50
static let inputHeight: CGFloat = 52
}
///
enum LineSpacing {
static let title: CGFloat = 3
}
///
enum CornerRadius {
static let input: CGFloat = 12
static let button: CGFloat = 12
static let card: CGFloat = 16
}
}

View File

@ -0,0 +1,266 @@
//
// GlobalLoadingCenter.swift
// suixinkan
//
// Created by Codex on 2026/6/23.
//
import Foundation
import UIKit
#if canImport(Lottie)
import Lottie
#endif
@MainActor
/// Loading
final class GlobalLoadingCenter {
fileprivate let state = GlobalLoadingState()
private weak var overlayView: GlobalLoadingOverlayView?
/// Loading
func show(message: String = "") {
if !message.isEmpty {
state.message = message
}
state.activeCount += 1
state.isVisible = true
}
/// Loading
func hide() {
state.activeCount = max(0, state.activeCount - 1)
guard state.activeCount == 0 else { return }
state.isVisible = false
state.message = ""
}
/// Loading Loading
func updateMessage(_ message: String) {
guard state.isVisible, !message.isEmpty else { return }
state.message = message
}
/// Loading
func withLoading<T>(
message: String = "",
operation: () async throws -> T
) async rethrows -> T {
show(message: message)
defer { hide() }
return try await operation()
}
/// Loading loading
func withOptionalLoading<T>(
_ enabled: Bool,
message: String = "",
operation: () async throws -> T
) async rethrows -> T {
if enabled {
return try await withLoading(message: message, operation: operation)
}
return try await operation()
}
/// Loading
func attachToWindow(_ window: UIWindow) {
if let overlayView, overlayView.superview === window {
return
}
let overlay = GlobalLoadingOverlayView(state: state)
overlay.frame = window.bounds
overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
window.addSubview(overlay)
overlayView = overlay
}
#if DEBUG
/// Loading
var snapshotForTests: GlobalLoadingSnapshot {
GlobalLoadingSnapshot(
isVisible: state.isVisible,
message: state.message,
activeCount: state.activeCount
)
}
#endif
}
@MainActor
/// Loading Overlay
final class GlobalLoadingState {
var onChange: (() -> Void)?
fileprivate var isVisible = false { didSet { onChange?() } }
fileprivate var message = "" { didSet { onChange?() } }
fileprivate var activeCount = 0 { didSet { onChange?() } }
}
/// Loading Loading
fileprivate final class GlobalLoadingOverlayView: UIView {
private let state: GlobalLoadingState
private let dimmingView = UIView()
private let cardView = UIView()
private let animationContainer = UIView()
private var animationView: UIView?
/// 使 Loading
init(state: GlobalLoadingState) {
self.state = state
super.init(frame: .zero)
configureViews()
state.onChange = { [weak self] in
self?.refreshPresentation(animated: true)
}
refreshPresentation(animated: false)
}
required init?(coder: NSCoder) {
nil
}
private func configureViews() {
isUserInteractionEnabled = true
accessibilityLabel = "加载中"
dimmingView.backgroundColor = UIColor.black.withAlphaComponent(0.28)
cardView.backgroundColor = .white
cardView.layer.cornerRadius = 18
cardView.layer.cornerCurve = .continuous
cardView.layer.shadowColor = UIColor.black.cgColor
cardView.layer.shadowOpacity = 0.12
cardView.layer.shadowRadius = 24
cardView.layer.shadowOffset = CGSize(width: 0, height: 10)
animationView = Self.makeAnimationView()
if let animationView {
animationContainer.addSubview(animationView)
animationView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
animationView.leadingAnchor.constraint(equalTo: animationContainer.leadingAnchor),
animationView.trailingAnchor.constraint(equalTo: animationContainer.trailingAnchor),
animationView.topAnchor.constraint(equalTo: animationContainer.topAnchor),
animationView.bottomAnchor.constraint(equalTo: animationContainer.bottomAnchor)
])
}
addSubview(dimmingView)
addSubview(cardView)
cardView.addSubview(animationContainer)
dimmingView.translatesAutoresizingMaskIntoConstraints = false
cardView.translatesAutoresizingMaskIntoConstraints = false
animationContainer.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
dimmingView.leadingAnchor.constraint(equalTo: leadingAnchor),
dimmingView.trailingAnchor.constraint(equalTo: trailingAnchor),
dimmingView.topAnchor.constraint(equalTo: topAnchor),
dimmingView.bottomAnchor.constraint(equalTo: bottomAnchor),
cardView.centerXAnchor.constraint(equalTo: centerXAnchor),
cardView.centerYAnchor.constraint(equalTo: centerYAnchor),
animationContainer.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 10),
animationContainer.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -10),
animationContainer.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 10),
animationContainer.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -10),
animationContainer.widthAnchor.constraint(equalToConstant: 132),
animationContainer.heightAnchor.constraint(equalToConstant: 132)
])
}
private func refreshPresentation(animated: Bool) {
let shouldShow = state.isVisible
let updates = {
self.isHidden = !shouldShow
self.alpha = shouldShow ? 1 : 0
}
guard animated else {
updates()
return
}
if shouldShow {
isHidden = false
alpha = 0
UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseInOut]) {
self.alpha = 1
}
} else {
UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseInOut], animations: {
self.alpha = 0
}, completion: { _ in
self.isHidden = true
})
}
}
/// Loading
private static var hasAnimationResource: Bool {
loadAnimation() != nil
}
/// Lottie
private static func makeAnimationView() -> UIView? {
#if canImport(Lottie)
if hasAnimationResource {
let container = UIView()
container.backgroundColor = .clear
let animationView = LottieAnimationView()
animationView.translatesAutoresizingMaskIntoConstraints = false
animationView.contentMode = .scaleAspectFit
animationView.loopMode = .loop
animationView.backgroundBehavior = .pauseAndRestore
animationView.animation = loadAnimation()
animationView.play()
container.addSubview(animationView)
NSLayoutConstraint.activate([
animationView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
animationView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
animationView.topAnchor.constraint(equalTo: container.topAnchor),
animationView.bottomAnchor.constraint(equalTo: container.bottomAnchor)
])
return container
}
#endif
let indicator = UIActivityIndicatorView(style: .large)
indicator.color = AppDesign.primary
indicator.startAnimating()
return indicator
}
/// loading Resources
#if canImport(Lottie)
private static func loadAnimation() -> LottieAnimation? {
if let animation = LottieAnimation.named("loading") {
return animation
}
if let url = Bundle.main.url(forResource: "loading", withExtension: "json", subdirectory: "Resources") {
return LottieAnimation.filepath(url.path)
}
if let url = Bundle.main.url(forResource: "loading", withExtension: "json") {
return LottieAnimation.filepath(url.path)
}
return nil
}
#else
private static func loadAnimation() -> Any? {
nil
}
#endif
}
#if DEBUG
/// Loading
struct GlobalLoadingSnapshot: Equatable {
let isVisible: Bool
let message: String
let activeCount: Int
}
#endif