模块化 AppStore 并完善素材管理与个人空间设置。
将会话、权限、位置、收款与排队存储拆分为独立模块,同步更新各 ViewModel 与单元测试,并补充素材管理 UI 与个人空间设置交互。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
448
suixinkan/UI/Profile/ProfileSpaceSettingsDialogs.swift
Normal file
448
suixinkan/UI/Profile/ProfileSpaceSettingsDialogs.swift
Normal file
@ -0,0 +1,448 @@
|
||||
//
|
||||
// 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)) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user