// // 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)) } } }