Files
suixinkan_uikit/suixinkan/UI/Profile/ProfileSpaceSettingsDialogs.swift
汉秋 005349f8e6 模块化 AppStore 并完善素材管理与个人空间设置。
将会话、权限、位置、收款与排队存储拆分为独立模块,同步更新各 ViewModel 与单元测试,并补充素材管理 UI 与个人空间设置交互。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 11:01:08 +08:00

449 lines
18 KiB
Swift
Raw Permalink 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.

//
// ProfileSpaceSettingsDialogs.swift
// suixinkan
//
import SnapKit
import UIKit
/// 使 Android
enum ProfileSpaceIcon {
/// 16pt
static var edit: UIImage? {
UIImage(named: "profile_edit")
}
}
/// 使 Android
enum ProfileNavigationStyle {
static func apply(to navigationController: UINavigationController?, showsDivider: Bool) {
guard let navigationBar = navigationController?.navigationBar else { return }
let appearance = UINavigationBarAppearance()
appearance.configureWithOpaqueBackground()
appearance.backgroundColor = .white
appearance.shadowColor = showsDivider ? AppColor.border : .clear
appearance.titleTextAttributes = [
.foregroundColor: UIColor(hex: 0x333333),
.font: UIFont.systemFont(ofSize: 18),
]
navigationBar.standardAppearance = appearance
navigationBar.scrollEdgeAppearance = appearance
navigationBar.compactAppearance = appearance
navigationBar.tintColor = .black
}
static func restore(_ navigationController: UINavigationController?) {
guard let navigationBar = navigationController?.navigationBar else { return }
let appearance = UINavigationBarAppearance()
appearance.configureWithOpaqueBackground()
appearance.backgroundColor = AppColor.cardBackground
appearance.shadowColor = .clear
appearance.titleTextAttributes = [
.foregroundColor: AppColor.textPrimary,
.font: UIFont.app(.title),
]
navigationBar.standardAppearance = appearance
navigationBar.scrollEdgeAppearance = appearance
navigationBar.compactAppearance = appearance
navigationBar.tintColor = AppColor.primary
}
}
/// Android 线
final class ProfileDashedBorderButton: UIButton {
private let dashedLayer = CAShapeLayer()
override init(frame: CGRect) {
super.init(frame: frame)
dashedLayer.strokeColor = AppColor.primary.cgColor
dashedLayer.fillColor = UIColor.clear.cgColor
dashedLayer.lineWidth = 1
dashedLayer.lineDashPattern = [2, 2]
layer.addSublayer(dashedLayer)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
dashedLayer.frame = bounds
dashedLayer.path = UIBezierPath(roundedRect: bounds.insetBy(dx: 0.5, dy: 0.5), cornerRadius: 4).cgPath
}
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
bounds.insetBy(dx: -8, dy: -8).contains(point)
}
}
/// Android
class ProfileBaseDialogViewController: UIViewController {
let cardView = UIView()
init() {
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .overFullScreen
modalTransitionStyle = .crossDissolve
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor.black.withAlphaComponent(0.2)
cardView.backgroundColor = .white
view.addSubview(cardView)
let outsideTap = UITapGestureRecognizer(target: self, action: #selector(outsideTapped(_:)))
outsideTap.cancelsTouchesInView = false
view.addGestureRecognizer(outsideTap)
cardView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(32)
}
}
func makePrimaryButton(title: String, height: CGFloat = 45) -> UIButton {
let button = UIButton(type: .system)
button.setTitle(title, for: .normal)
button.setTitleColor(.white, for: .normal)
button.setTitleColor(.white, for: .disabled)
button.titleLabel?.font = .systemFont(ofSize: 17, weight: .medium)
button.backgroundColor = AppColor.primary
button.layer.cornerRadius = 10
button.snp.makeConstraints { make in make.height.equalTo(height) }
return button
}
@objc private func outsideTapped(_ gesture: UITapGestureRecognizer) {
let location = gesture.location(in: view)
guard !cardView.frame.contains(location) else { return }
dismiss(animated: false)
}
}
/// Android
final class ProfileTimePickerDialogViewController: ProfileBaseDialogViewController {
private let picker = UIDatePicker()
private let confirmButton: UIButton
private let onConfirm: (Date) -> Void
init(initialDate: Date, onConfirm: @escaping (Date) -> Void) {
self.onConfirm = onConfirm
confirmButton = UIButton(type: .system)
super.init()
picker.date = initialDate
}
override func viewDidLoad() {
super.viewDidLoad()
cardView.layer.cornerRadius = 10
picker.datePickerMode = .time
picker.preferredDatePickerStyle = .wheels
picker.locale = Locale(identifier: "zh_CN")
confirmButton.setTitle("确认", for: .normal)
confirmButton.setTitleColor(.white, for: .normal)
confirmButton.titleLabel?.font = .systemFont(ofSize: 17, weight: .medium)
confirmButton.backgroundColor = AppColor.primary
confirmButton.layer.cornerRadius = 10
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
cardView.addSubview(picker)
cardView.addSubview(confirmButton)
picker.snp.makeConstraints { make in
make.top.equalToSuperview().offset(24)
make.leading.trailing.equalToSuperview().inset(15)
make.height.equalTo(180)
}
confirmButton.snp.makeConstraints { make in
make.top.equalTo(picker.snp.bottom).offset(24)
make.leading.trailing.equalToSuperview().inset(15)
make.height.equalTo(45)
make.bottom.equalToSuperview().inset(24)
}
}
@objc private func confirmTapped() {
onConfirm(picker.date)
dismiss(animated: false)
}
}
/// Android
final class ProfileDeviceDialogViewController: ProfileBaseDialogViewController, UITextFieldDelegate {
private let textField = UITextField()
private let confirmButton = UIButton(type: .system)
private let onConfirm: (String) -> Void
init(onConfirm: @escaping (String) -> Void) {
self.onConfirm = onConfirm
super.init()
}
override func viewDidLoad() {
super.viewDidLoad()
cardView.layer.cornerRadius = 10
textField.placeholder = "请输入设备名"
textField.font = .systemFont(ofSize: 14)
textField.layer.borderColor = AppColor.border.cgColor
textField.layer.borderWidth = 1
textField.layer.cornerRadius = 8
textField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 1))
textField.leftViewMode = .always
textField.returnKeyType = .done
textField.delegate = self
textField.addTarget(self, action: #selector(textChanged), for: .editingChanged)
confirmButton.setTitle("确认", for: .normal)
confirmButton.setTitleColor(.white, for: .normal)
confirmButton.setTitleColor(.white, for: .disabled)
confirmButton.titleLabel?.font = .systemFont(ofSize: 17, weight: .medium)
confirmButton.layer.cornerRadius = 10
confirmButton.isEnabled = false
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
cardView.addSubview(textField)
cardView.addSubview(confirmButton)
textField.snp.makeConstraints { make in
make.top.equalToSuperview().offset(24)
make.leading.trailing.equalToSuperview().inset(15)
make.height.equalTo(46)
}
confirmButton.snp.makeConstraints { make in
make.top.equalTo(textField.snp.bottom).offset(24)
make.leading.trailing.equalToSuperview().inset(15)
make.height.equalTo(45)
make.bottom.equalToSuperview().inset(24)
}
textChanged()
DispatchQueue.main.async { [weak self] in self?.textField.becomeFirstResponder() }
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
guard confirmButton.isEnabled else { return false }
confirmTapped()
return true
}
@objc private func textChanged() {
let enabled = !(textField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
confirmButton.isEnabled = enabled
confirmButton.backgroundColor = enabled ? AppColor.primary : AppColor.buttonDisabled
}
@objc private func confirmTapped() {
let value = (textField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { return }
onConfirm(value)
dismiss(animated: false)
}
}
/// Android
final class ProfileDeleteDialogViewController: ProfileBaseDialogViewController {
private let dialogTitle: String
private let dialogMessage: String
private let onConfirm: () -> Void
init(title: String, message: String, onConfirm: @escaping () -> Void) {
dialogTitle = title
dialogMessage = message
self.onConfirm = onConfirm
super.init()
}
override func viewDidLoad() {
super.viewDidLoad()
cardView.layer.cornerRadius = 16
let stack = UIStackView()
stack.axis = .vertical
stack.spacing = 16
stack.layoutMargins = UIEdgeInsets(top: 24, left: 24, bottom: 24, right: 24)
stack.isLayoutMarginsRelativeArrangement = true
let titleLabel = UILabel()
titleLabel.text = dialogTitle
titleLabel.font = .systemFont(ofSize: 18, weight: .bold)
titleLabel.textColor = UIColor(hex: 0xFF0000)
let messageLabel = UILabel()
messageLabel.font = .systemFont(ofSize: 16)
messageLabel.textColor = UIColor(hex: 0x333333)
messageLabel.numberOfLines = 0
let message = NSMutableAttributedString(
string: dialogMessage,
attributes: [.font: UIFont.systemFont(ofSize: 16), .foregroundColor: UIColor(hex: 0x333333)]
)
let prefix = "您确定要删除"
if dialogMessage.hasPrefix(prefix), dialogTitle != "删除日程" {
let suffixes = ["标签吗?", "拍摄说明吗?", "设备吗?"]
if let suffix = suffixes.first(where: dialogMessage.hasSuffix) {
let nameStart = prefix.count
let nameLength = dialogMessage.count - prefix.count - suffix.count
if nameLength > 0 {
message.addAttribute(.font, value: UIFont.systemFont(ofSize: 16, weight: .bold), range: NSRange(location: nameStart, length: nameLength))
}
}
}
messageLabel.attributedText = message
let buttons = UIStackView()
buttons.axis = .horizontal
buttons.spacing = 12
buttons.distribution = .fillEqually
let cancel = UIButton(type: .system)
cancel.setTitle("取消", for: .normal)
cancel.setTitleColor(UIColor(hex: 0xFF0000), for: .normal)
cancel.backgroundColor = UIColor(hex: 0xFFE5E5)
cancel.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
cancel.layer.cornerRadius = 8
cancel.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
let confirm = UIButton(type: .system)
confirm.setTitle("确认", for: .normal)
confirm.setTitleColor(.white, for: .normal)
confirm.backgroundColor = UIColor(hex: 0xFF0000)
confirm.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
confirm.layer.cornerRadius = 8
confirm.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
[cancel, confirm].forEach { button in
button.snp.makeConstraints { make in make.height.equalTo(44) }
buttons.addArrangedSubview(button)
}
stack.addArrangedSubview(titleLabel)
stack.addArrangedSubview(messageLabel)
stack.setCustomSpacing(24, after: messageLabel)
stack.addArrangedSubview(buttons)
cardView.addSubview(stack)
stack.snp.makeConstraints { make in make.edges.equalToSuperview() }
}
@objc private func cancelTapped() { dismiss(animated: false) }
@objc private func confirmTapped() {
dismiss(animated: false) { [onConfirm] in onConfirm() }
}
}
/// Android 480×480 JPEG
final class ProfileAvatarCropViewController: UIViewController, UIScrollViewDelegate {
private let sourceImage: UIImage
private let onConfirm: (Data) -> Void
private let scrollView = UIScrollView()
private let imageView = UIImageView()
private let cancelButton = UIButton(type: .system)
private let confirmButton = UIButton(type: .system)
private var didConfigureZoom = false
init(image: UIImage, onConfirm: @escaping (Data) -> Void) {
sourceImage = Self.normalized(image)
self.onConfirm = onConfirm
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
scrollView.delegate = self
scrollView.clipsToBounds = true
scrollView.layer.borderColor = UIColor.white.cgColor
scrollView.layer.borderWidth = 1
scrollView.showsHorizontalScrollIndicator = false
scrollView.showsVerticalScrollIndicator = false
imageView.image = sourceImage
imageView.contentMode = .scaleAspectFit
imageView.frame = CGRect(origin: .zero, size: sourceImage.size)
scrollView.addSubview(imageView)
scrollView.contentSize = sourceImage.size
cancelButton.setTitle("取消", for: .normal)
confirmButton.setTitle("确认", for: .normal)
[cancelButton, confirmButton].forEach {
$0.setTitleColor(.white, for: .normal)
$0.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
}
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
view.addSubview(scrollView)
view.addSubview(cancelButton)
view.addSubview(confirmButton)
scrollView.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview()
make.centerY.equalToSuperview()
make.height.equalTo(scrollView.snp.width)
}
cancelButton.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(20)
make.top.equalTo(view.safeAreaLayoutGuide).offset(10)
make.width.height.greaterThanOrEqualTo(44)
}
confirmButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(20)
make.top.equalTo(view.safeAreaLayoutGuide).offset(10)
make.width.height.greaterThanOrEqualTo(44)
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
guard !didConfigureZoom, scrollView.bounds.width > 0 else { return }
didConfigureZoom = true
let minimumZoom = max(
scrollView.bounds.width / sourceImage.size.width,
scrollView.bounds.height / sourceImage.size.height
)
scrollView.minimumZoomScale = minimumZoom
scrollView.maximumZoomScale = max(minimumZoom * 6, 6)
scrollView.zoomScale = minimumZoom
let contentWidth = sourceImage.size.width * minimumZoom
let contentHeight = sourceImage.size.height * minimumZoom
scrollView.contentOffset = CGPoint(
x: max(0, (contentWidth - scrollView.bounds.width) / 2),
y: max(0, (contentHeight - scrollView.bounds.height) / 2)
)
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? { imageView }
@objc private func cancelTapped() { dismiss(animated: true) }
@objc private func confirmTapped() {
guard let data = renderedCropData() else { return }
dismiss(animated: true) { [onConfirm] in onConfirm(data) }
}
func renderedCropData() -> Data? {
let cropRectInImageView = scrollView.convert(scrollView.bounds, to: imageView)
let scaleX = sourceImage.size.width / imageView.bounds.width
let scaleY = sourceImage.size.height / imageView.bounds.height
var cropRect = CGRect(
x: cropRectInImageView.minX * scaleX,
y: cropRectInImageView.minY * scaleY,
width: cropRectInImageView.width * scaleX,
height: cropRectInImageView.height * scaleY
).integral
cropRect = cropRect.intersection(CGRect(origin: .zero, size: sourceImage.size))
guard !cropRect.isEmpty, let cgImage = sourceImage.cgImage?.cropping(to: cropRect) else { return nil }
let cropped = UIImage(cgImage: cgImage)
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 480, height: 480))
return renderer.image { _ in
cropped.draw(in: CGRect(x: 0, y: 0, width: 480, height: 480))
}.jpegData(compressionQuality: 0.9)
}
private static func normalized(_ image: UIImage) -> UIImage {
guard image.imageOrientation != .up else { return image }
let renderer = UIGraphicsImageRenderer(size: image.size)
return renderer.image { _ in image.draw(in: CGRect(origin: .zero, size: image.size)) }
}
}