对齐 Android 无权限强制弹窗、角色下拉与申请页交互,补充 UIButton configuration 适配,并增强景区排队 TTS 与相关单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
1390 lines
55 KiB
Swift
1390 lines
55 KiB
Swift
//
|
||
// ProfileSpaceSettingsViewController.swift
|
||
// suixinkan
|
||
//
|
||
|
||
import Kingfisher
|
||
import PhotosUI
|
||
import SnapKit
|
||
import UIKit
|
||
|
||
/// 个人空间配置页面,对齐 Android `ProfileInfoScreen`。
|
||
final class ProfileSpaceSettingsViewController: BaseViewController {
|
||
|
||
private let viewModel: ProfileSpaceSettingsViewModel
|
||
private let profileAPI: ProfileAPI
|
||
|
||
private let scrollView = UIScrollView()
|
||
private let contentStack = UIStackView()
|
||
private let bottomBar = UIView()
|
||
private let saveButton = AppButton(title: "保存")
|
||
|
||
private let avatarImageView = UIImageView()
|
||
private let avatarChangeOverlay = UIView()
|
||
private let avatarChangeLabel = UILabel()
|
||
private let realNameLabel = UILabel()
|
||
private let nicknameField = UITextField()
|
||
private let editProfileButton = UIButton(type: .system)
|
||
private let introductionTextView = UITextView()
|
||
private let introductionPlaceholderLabel = UILabel()
|
||
private let certificationWrap = ProfileTagWrapView()
|
||
private let certificationExpandButton = UIButton(type: .system)
|
||
|
||
private let attrWrap = ProfileTagWrapView()
|
||
private let shootWrap = ProfileTagWrapView()
|
||
private let attrField = UITextField()
|
||
private let shootField = UITextField()
|
||
private let attrAddButton = UIButton(type: .system)
|
||
private let shootAddButton = UIButton(type: .system)
|
||
|
||
private let businessStartButton = ProfileTimeButton()
|
||
private let businessEndButton = ProfileTimeButton()
|
||
private let holidayStartButton = ProfileTimeButton()
|
||
private let holidayEndButton = ProfileTimeButton()
|
||
|
||
private let deviceWrap = ProfileTagWrapView()
|
||
private let addDeviceButton = ProfileDashedBorderButton(type: .system)
|
||
|
||
private var statusButtons: [UIButton] = []
|
||
private let dateStack = UIStackView()
|
||
private let selectedDateLabel = UILabel()
|
||
private let scheduleStack = UIStackView()
|
||
private let addScheduleButton = UIButton(type: .system)
|
||
|
||
init(
|
||
viewModel: ProfileSpaceSettingsViewModel = ProfileSpaceSettingsViewModel(),
|
||
profileAPI: ProfileAPI = NetworkServices.shared.profileAPI
|
||
) {
|
||
self.viewModel = viewModel
|
||
self.profileAPI = profileAPI
|
||
super.init(nibName: nil, bundle: nil)
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
override func setupNavigationBar() {
|
||
title = "个人空间配置"
|
||
navigationItem.backButtonDisplayMode = .minimal
|
||
}
|
||
|
||
override func viewWillAppear(_ animated: Bool) {
|
||
super.viewWillAppear(animated)
|
||
ProfileNavigationStyle.apply(to: navigationController, showsDivider: false)
|
||
}
|
||
|
||
override func viewWillDisappear(_ animated: Bool) {
|
||
super.viewWillDisappear(animated)
|
||
ProfileNavigationStyle.restore(navigationController)
|
||
}
|
||
|
||
override func setupUI() {
|
||
view.backgroundColor = AppColor.pageBackground
|
||
scrollView.alwaysBounceVertical = true
|
||
view.addSubview(scrollView)
|
||
view.addSubview(bottomBar)
|
||
scrollView.addSubview(contentStack)
|
||
bottomBar.addSubview(saveButton)
|
||
|
||
contentStack.axis = .vertical
|
||
contentStack.spacing = 16
|
||
contentStack.layoutMargins = UIEdgeInsets(top: 15, left: 15, bottom: 15, right: 15)
|
||
contentStack.isLayoutMarginsRelativeArrangement = true
|
||
|
||
bottomBar.backgroundColor = .white
|
||
saveButton.isEnabled = false
|
||
saveButton.snp.updateConstraints { make in make.height.equalTo(48) }
|
||
|
||
configureProfileCard()
|
||
configureLabelCard()
|
||
configureBusinessHoursCard()
|
||
configureDeviceCard()
|
||
configureOrderStatusCard()
|
||
configureScheduleCard()
|
||
}
|
||
|
||
override func setupConstraints() {
|
||
bottomBar.snp.makeConstraints { make in
|
||
make.leading.trailing.bottom.equalToSuperview()
|
||
}
|
||
saveButton.snp.makeConstraints { make in
|
||
make.top.equalToSuperview().offset(16)
|
||
make.leading.trailing.equalToSuperview().inset(15)
|
||
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16)
|
||
}
|
||
scrollView.snp.makeConstraints { make in
|
||
make.top.leading.trailing.equalToSuperview()
|
||
make.bottom.equalTo(bottomBar.snp.top)
|
||
}
|
||
contentStack.snp.makeConstraints { make in
|
||
make.edges.equalToSuperview()
|
||
make.width.equalTo(scrollView.snp.width)
|
||
}
|
||
}
|
||
|
||
override func bindActions() {
|
||
viewModel.onStateChange = { [weak self] in
|
||
Task { @MainActor in self?.applyViewModel() }
|
||
}
|
||
viewModel.onShowMessage = { [weak self] message in
|
||
Task { @MainActor in self?.showToast(message) }
|
||
}
|
||
saveButton.addTarget(self, action: #selector(saveTapped), for: .touchUpInside)
|
||
editProfileButton.addTarget(self, action: #selector(editProfileTapped), for: .touchUpInside)
|
||
certificationExpandButton.addTarget(self, action: #selector(certificationExpandTapped), for: .touchUpInside)
|
||
addDeviceButton.addTarget(self, action: #selector(addDeviceTapped), for: .touchUpInside)
|
||
addScheduleButton.addTarget(self, action: #selector(addScheduleTapped), for: .touchUpInside)
|
||
attrField.addTarget(self, action: #selector(labelInputChanged), for: .editingChanged)
|
||
shootField.addTarget(self, action: #selector(labelInputChanged), for: .editingChanged)
|
||
[businessStartButton, businessEndButton, holidayStartButton, holidayEndButton].forEach {
|
||
$0.addTarget(self, action: #selector(timeButtonTapped(_:)), for: .touchUpInside)
|
||
}
|
||
let avatarTap = UITapGestureRecognizer(target: self, action: #selector(avatarTapped))
|
||
avatarImageView.addGestureRecognizer(avatarTap)
|
||
avatarImageView.isUserInteractionEnabled = true
|
||
}
|
||
|
||
override func viewDidLoad() {
|
||
super.viewDidLoad()
|
||
Task { await viewModel.load(api: profileAPI) }
|
||
}
|
||
|
||
@MainActor
|
||
private func applyViewModel() {
|
||
if viewModel.isLoading {
|
||
showLoading()
|
||
} else {
|
||
hideLoading()
|
||
}
|
||
saveButton.isEnabled = viewModel.hasChanges
|
||
updateAvatar(urlString: viewModel.avatarURL)
|
||
realNameLabel.text = "姓名: \(viewModel.realName)"
|
||
nicknameField.text = viewModel.isEditingProfile ? viewModel.editingNickname : "昵称: \(viewModel.nickname)"
|
||
nicknameField.isEnabled = viewModel.isEditingProfile
|
||
introductionTextView.isEditable = viewModel.isEditingProfile
|
||
introductionTextView.text = viewModel.introduction.isEmpty && !viewModel.isEditingProfile ? "暂无简介" : viewModel.introduction
|
||
introductionTextView.textColor = viewModel.introduction.isEmpty && !viewModel.isEditingProfile ? UIColor(hex: 0xB6BECA) : UIColor(hex: 0x4B5563)
|
||
updateIntroductionPlaceholder()
|
||
editProfileButton.setImage(ProfileSpaceIcon.edit, for: .normal)
|
||
avatarChangeOverlay.isHidden = !viewModel.isEditingProfile
|
||
let visibleCertifications = viewModel.isCertificationExpanded
|
||
? viewModel.scenicCertification
|
||
: Array(viewModel.scenicCertification.prefix(3))
|
||
certificationWrap.setItems(visibleCertifications, style: .certification, removable: false)
|
||
certificationExpandButton.isHidden = viewModel.scenicCertification.count <= 3
|
||
certificationExpandButton.setTitle(viewModel.isCertificationExpanded ? "收起" : "展开", for: .normal)
|
||
attrWrap.setItems(viewModel.attrLabels, style: .label, removable: true) { [weak self] label in
|
||
self?.confirmDelete(title: "删除标签", message: "您确定要删除\(label)标签吗?") {
|
||
self?.viewModel.removeAttrLabel(label)
|
||
}
|
||
}
|
||
shootWrap.setItems(viewModel.shootLabels, style: .label, removable: true) { [weak self] label in
|
||
self?.confirmDelete(title: "删除拍摄说明", message: "您确定要删除\(label)拍摄说明吗?") {
|
||
self?.viewModel.removeShootLabel(label)
|
||
}
|
||
}
|
||
businessStartButton.setTitle(viewModel.businessStartTime ?? "00:00", for: .normal)
|
||
businessEndButton.setTitle(viewModel.businessEndTime ?? "00:00", for: .normal)
|
||
holidayStartButton.setTitle(viewModel.holidayStartTime ?? "00:00", for: .normal)
|
||
holidayEndButton.setTitle(viewModel.holidayEndTime ?? "00:00", for: .normal)
|
||
deviceWrap.setItems(viewModel.cameraDevices, style: .device, removable: true) { [weak self] device in
|
||
self?.confirmDelete(title: "删除设备", message: "您确定要删除\(device)设备吗?") {
|
||
self?.viewModel.removeDevice(device)
|
||
}
|
||
}
|
||
addDeviceButton.isHidden = viewModel.cameraDevices.count > 10
|
||
deviceWrap.invalidateIntrinsicContentSize()
|
||
deviceWrap.setNeedsLayout()
|
||
updateStatusButtons()
|
||
updateLabelAddButtons()
|
||
rebuildDateButtons()
|
||
rebuildSchedules()
|
||
}
|
||
|
||
private func configureProfileCard() {
|
||
let card = makeCard()
|
||
let row = UIStackView()
|
||
row.axis = .horizontal
|
||
row.alignment = .top
|
||
row.spacing = 16
|
||
|
||
avatarImageView.layer.cornerRadius = 58
|
||
avatarImageView.clipsToBounds = true
|
||
avatarImageView.backgroundColor = AppColor.primaryLight
|
||
avatarImageView.contentMode = .scaleAspectFill
|
||
avatarImageView.image = UIImage(named: "profile_avatar_default")
|
||
avatarImageView.tintColor = UIColor(hex: 0xB6BECA)
|
||
avatarImageView.accessibilityLabel = "头像"
|
||
avatarImageView.snp.makeConstraints { make in
|
||
make.width.height.equalTo(116)
|
||
}
|
||
avatarImageView.addSubview(avatarChangeOverlay)
|
||
avatarChangeOverlay.backgroundColor = UIColor.black.withAlphaComponent(0.35)
|
||
avatarChangeOverlay.isHidden = true
|
||
avatarChangeOverlay.addSubview(avatarChangeLabel)
|
||
avatarChangeLabel.text = "点击更换"
|
||
avatarChangeLabel.textColor = .white
|
||
avatarChangeLabel.font = .systemFont(ofSize: 12, weight: .medium)
|
||
avatarChangeOverlay.snp.makeConstraints { make in
|
||
make.edges.equalToSuperview()
|
||
}
|
||
avatarChangeLabel.snp.makeConstraints { make in
|
||
make.center.equalToSuperview()
|
||
}
|
||
|
||
let textStack = UIStackView()
|
||
textStack.axis = .vertical
|
||
textStack.spacing = 0
|
||
|
||
realNameLabel.font = .systemFont(ofSize: 14)
|
||
realNameLabel.textColor = UIColor(hex: 0x4B5563)
|
||
|
||
let nicknameRow = UIStackView()
|
||
nicknameRow.axis = .horizontal
|
||
nicknameRow.alignment = .center
|
||
nicknameRow.spacing = 8
|
||
nicknameField.font = .systemFont(ofSize: 16)
|
||
nicknameField.textColor = .black
|
||
nicknameField.borderStyle = .none
|
||
nicknameField.placeholder = "请输入昵称"
|
||
nicknameField.returnKeyType = .done
|
||
nicknameField.delegate = self
|
||
nicknameField.addTarget(self, action: #selector(nicknameChanged), for: .editingChanged)
|
||
editProfileButton.tintColor = AppColor.primary
|
||
editProfileButton.accessibilityLabel = "编辑个人信息"
|
||
editProfileButton.snp.makeConstraints { make in
|
||
make.width.height.equalTo(44)
|
||
}
|
||
nicknameRow.addArrangedSubview(nicknameField)
|
||
nicknameRow.addArrangedSubview(editProfileButton)
|
||
|
||
introductionTextView.font = .systemFont(ofSize: 14)
|
||
introductionTextView.backgroundColor = .clear
|
||
introductionTextView.textContainerInset = .zero
|
||
introductionTextView.textContainer.lineFragmentPadding = 0
|
||
introductionTextView.delegate = self
|
||
introductionTextView.isScrollEnabled = false
|
||
introductionTextView.snp.makeConstraints { make in
|
||
make.height.greaterThanOrEqualTo(24)
|
||
}
|
||
introductionPlaceholderLabel.text = "请输入个人简介"
|
||
introductionPlaceholderLabel.font = introductionTextView.font
|
||
introductionPlaceholderLabel.textColor = UIColor(hex: 0xB6BECA)
|
||
introductionTextView.addSubview(introductionPlaceholderLabel)
|
||
introductionPlaceholderLabel.snp.makeConstraints { make in
|
||
make.top.leading.trailing.equalToSuperview()
|
||
}
|
||
|
||
textStack.addArrangedSubview(realNameLabel)
|
||
textStack.addArrangedSubview(nicknameRow)
|
||
textStack.setCustomSpacing(8, after: nicknameRow)
|
||
textStack.addArrangedSubview(introductionTextView)
|
||
textStack.setCustomSpacing(4, after: introductionTextView)
|
||
textStack.addArrangedSubview(certificationWrap)
|
||
certificationExpandButton.setTitleColor(AppColor.primary, for: .normal)
|
||
certificationExpandButton.titleLabel?.font = .systemFont(ofSize: 14)
|
||
certificationExpandButton.contentHorizontalAlignment = .left
|
||
certificationExpandButton.isHidden = true
|
||
certificationExpandButton.snp.makeConstraints { make in
|
||
make.height.greaterThanOrEqualTo(20)
|
||
}
|
||
textStack.addArrangedSubview(certificationExpandButton)
|
||
|
||
row.addArrangedSubview(avatarImageView)
|
||
row.addArrangedSubview(textStack)
|
||
card.addArrangedSubview(row)
|
||
contentStack.addArrangedSubview(card)
|
||
}
|
||
|
||
private func configureLabelCard() {
|
||
let card = makeCard()
|
||
card.spacing = 0
|
||
let title = makeSectionTitle("标签")
|
||
card.addArrangedSubview(title)
|
||
card.setCustomSpacing(12, after: title)
|
||
card.addArrangedSubview(attrWrap)
|
||
card.setCustomSpacing(8, after: attrWrap)
|
||
let attrRow = makeAddRow(
|
||
field: attrField,
|
||
button: attrAddButton,
|
||
placeholder: "添加标签",
|
||
action: #selector(addAttrTapped)
|
||
)
|
||
card.addArrangedSubview(attrRow)
|
||
card.setCustomSpacing(20, after: attrRow)
|
||
card.addArrangedSubview(shootWrap)
|
||
card.setCustomSpacing(12, after: shootWrap)
|
||
let shootRow = makeAddRow(
|
||
field: shootField,
|
||
button: shootAddButton,
|
||
placeholder: "添加拍摄说明",
|
||
action: #selector(addShootTapped)
|
||
)
|
||
card.addArrangedSubview(shootRow)
|
||
card.setCustomSpacing(8, after: shootRow)
|
||
let hint = UILabel()
|
||
hint.text = "每个标签最多20个字符,最多8个标签"
|
||
hint.font = .systemFont(ofSize: 12)
|
||
hint.textColor = UIColor(hex: 0xB6BECA)
|
||
card.addArrangedSubview(hint)
|
||
contentStack.addArrangedSubview(card)
|
||
}
|
||
|
||
private func configureBusinessHoursCard() {
|
||
let card = makeCard()
|
||
let title = makeSectionTitle("营业时间")
|
||
let workdayRow = makeTimeRow(title: "工作日营业时间", start: businessStartButton, end: businessEndButton)
|
||
card.addArrangedSubview(title)
|
||
card.addArrangedSubview(workdayRow)
|
||
card.setCustomSpacing(20, after: workdayRow)
|
||
card.addArrangedSubview(makeTimeRow(title: "法定节假日营业时间", start: holidayStartButton, end: holidayEndButton))
|
||
contentStack.addArrangedSubview(card)
|
||
}
|
||
|
||
private func configureDeviceCard() {
|
||
let card = makeCard()
|
||
card.addArrangedSubview(makeSectionTitle("相机设备"))
|
||
card.addArrangedSubview(deviceWrap)
|
||
addDeviceButton.setTitle("+ 添加设备", for: .normal)
|
||
addDeviceButton.setTitleColor(AppColor.primary, for: .normal)
|
||
addDeviceButton.titleLabel?.font = .systemFont(ofSize: 14)
|
||
addDeviceButton.layer.cornerRadius = 4
|
||
addDeviceButton.setConfigurationContentInsets(
|
||
NSDirectionalEdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)
|
||
)
|
||
addDeviceButton.contentHorizontalAlignment = .left
|
||
addDeviceButton.snp.makeConstraints { make in make.height.equalTo(28) }
|
||
deviceWrap.setTrailingView(addDeviceButton)
|
||
contentStack.addArrangedSubview(card)
|
||
}
|
||
|
||
private func configureOrderStatusCard() {
|
||
let card = makeCard()
|
||
card.addArrangedSubview(makeSectionTitle("接单状态"))
|
||
let labels = ["可接单", "暂停接单", "已约满", "下线"]
|
||
for rowIndex in 0..<2 {
|
||
let row = UIStackView()
|
||
row.axis = .horizontal
|
||
row.spacing = 15
|
||
row.distribution = .fillEqually
|
||
for index in 0..<2 {
|
||
let status = rowIndex * 2 + index + 1
|
||
let button = UIButton(type: .system)
|
||
button.setTitle(labels[status - 1], for: .normal)
|
||
button.titleLabel?.font = .systemFont(ofSize: 14)
|
||
button.tag = status
|
||
button.layer.cornerRadius = 8
|
||
button.layer.borderWidth = 1
|
||
button.addTarget(self, action: #selector(statusTapped(_:)), for: .touchUpInside)
|
||
button.snp.makeConstraints { make in
|
||
make.height.equalTo(52)
|
||
}
|
||
statusButtons.append(button)
|
||
row.addArrangedSubview(button)
|
||
}
|
||
card.addArrangedSubview(row)
|
||
}
|
||
contentStack.addArrangedSubview(card)
|
||
}
|
||
|
||
private func configureScheduleCard() {
|
||
let card = makeCard()
|
||
card.addArrangedSubview(makeSectionTitle("日程安排"))
|
||
dateStack.axis = .horizontal
|
||
dateStack.spacing = 4
|
||
dateStack.distribution = .fillEqually
|
||
card.addArrangedSubview(dateStack)
|
||
|
||
let header = UIStackView()
|
||
header.axis = .horizontal
|
||
header.alignment = .center
|
||
header.spacing = 12
|
||
selectedDateLabel.font = .systemFont(ofSize: 14)
|
||
selectedDateLabel.textColor = UIColor(hex: 0x252525)
|
||
addScheduleButton.setTitle("添加日程", for: .normal)
|
||
addScheduleButton.setTitleColor(AppColor.primary, for: .normal)
|
||
addScheduleButton.titleLabel?.font = .systemFont(ofSize: 14)
|
||
header.addArrangedSubview(selectedDateLabel)
|
||
header.addArrangedSubview(UIView())
|
||
header.addArrangedSubview(addScheduleButton)
|
||
card.addArrangedSubview(header)
|
||
card.setCustomSpacing(8, after: header)
|
||
|
||
scheduleStack.axis = .vertical
|
||
scheduleStack.spacing = 12
|
||
card.addArrangedSubview(scheduleStack)
|
||
contentStack.addArrangedSubview(card)
|
||
}
|
||
|
||
private func makeCard() -> UIStackView {
|
||
let stack = UIStackView()
|
||
stack.axis = .vertical
|
||
stack.spacing = 16
|
||
stack.backgroundColor = .white
|
||
stack.layer.cornerRadius = 12
|
||
stack.clipsToBounds = true
|
||
stack.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)
|
||
stack.isLayoutMarginsRelativeArrangement = true
|
||
return stack
|
||
}
|
||
|
||
private func makeSectionTitle(_ title: String) -> UILabel {
|
||
let label = UILabel()
|
||
label.text = title
|
||
label.font = .systemFont(ofSize: 14, weight: .medium)
|
||
label.textColor = ["相机设备", "接单状态", "日程安排"].contains(title)
|
||
? UIColor(hex: 0x4B5563)
|
||
: UIColor(hex: 0x252525)
|
||
return label
|
||
}
|
||
|
||
private func makeAddRow(
|
||
field: UITextField,
|
||
button: UIButton,
|
||
placeholder: String,
|
||
action: Selector
|
||
) -> UIView {
|
||
let row = UIStackView()
|
||
row.axis = .horizontal
|
||
row.spacing = 8
|
||
row.alignment = .fill
|
||
field.placeholder = placeholder
|
||
field.font = .systemFont(ofSize: 14)
|
||
field.returnKeyType = .done
|
||
field.delegate = self
|
||
field.layer.cornerRadius = 8
|
||
field.layer.borderColor = AppColor.border.cgColor
|
||
field.layer.borderWidth = 1
|
||
field.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 1))
|
||
field.leftViewMode = .always
|
||
field.snp.makeConstraints { make in
|
||
make.height.equalTo(36)
|
||
}
|
||
button.setTitle("添加标签", for: .normal)
|
||
button.setTitleColor(.white, for: .normal)
|
||
button.setTitleColor(.white, for: .disabled)
|
||
button.backgroundColor = AppColor.primary
|
||
button.titleLabel?.font = .systemFont(ofSize: 14)
|
||
button.layer.cornerRadius = 4
|
||
button.addTarget(self, action: action, for: .touchUpInside)
|
||
button.isEnabled = false
|
||
button.snp.makeConstraints { make in
|
||
make.width.equalTo(80)
|
||
make.height.equalTo(36)
|
||
}
|
||
row.addArrangedSubview(field)
|
||
row.addArrangedSubview(button)
|
||
return row
|
||
}
|
||
|
||
private func makeTimeRow(title: String, start: ProfileTimeButton, end: ProfileTimeButton) -> UIView {
|
||
let row = UIStackView()
|
||
row.axis = .horizontal
|
||
row.alignment = .center
|
||
row.spacing = 10
|
||
let titleLabel = UILabel()
|
||
titleLabel.text = title
|
||
titleLabel.font = .systemFont(ofSize: 14)
|
||
titleLabel.textColor = UIColor(hex: 0x4B5563)
|
||
titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||
let toLabel = UILabel()
|
||
toLabel.text = "至"
|
||
toLabel.font = .systemFont(ofSize: 14)
|
||
toLabel.textColor = UIColor(hex: 0x4B5563)
|
||
let spacer = UIView()
|
||
row.addArrangedSubview(titleLabel)
|
||
row.addArrangedSubview(spacer)
|
||
row.addArrangedSubview(start)
|
||
row.addArrangedSubview(toLabel)
|
||
row.addArrangedSubview(end)
|
||
[start, end].forEach { button in
|
||
button.snp.makeConstraints { make in
|
||
make.width.equalTo(60)
|
||
make.height.equalTo(36)
|
||
}
|
||
}
|
||
return row
|
||
}
|
||
|
||
private func updateAvatar(urlString: String?) {
|
||
let trimmed = urlString?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||
guard !trimmed.isEmpty else {
|
||
avatarImageView.kf.cancelDownloadTask()
|
||
avatarImageView.backgroundColor = AppColor.primaryLight
|
||
avatarImageView.image = UIImage(named: "profile_avatar_default")
|
||
avatarImageView.tintColor = UIColor(hex: 0xB6BECA)
|
||
avatarImageView.contentMode = .scaleAspectFit
|
||
return
|
||
}
|
||
avatarImageView.contentMode = .scaleAspectFill
|
||
avatarImageView.loadRemoteImage(urlString: trimmed)
|
||
}
|
||
|
||
private func updateIntroductionPlaceholder() {
|
||
introductionPlaceholderLabel.isHidden = !viewModel.isEditingProfile || !introductionTextView.text.isEmpty
|
||
introductionTextView.layer.borderWidth = viewModel.isEditingProfile ? 1 : 0
|
||
introductionTextView.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor
|
||
introductionTextView.layer.cornerRadius = 4
|
||
introductionTextView.textContainerInset = viewModel.isEditingProfile
|
||
? UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)
|
||
: .zero
|
||
introductionPlaceholderLabel.snp.remakeConstraints { make in
|
||
make.top.equalToSuperview().offset(viewModel.isEditingProfile ? 8 : 0)
|
||
make.leading.equalToSuperview().offset(viewModel.isEditingProfile ? 8 : 0)
|
||
make.trailing.equalToSuperview().inset(viewModel.isEditingProfile ? 8 : 0)
|
||
}
|
||
}
|
||
|
||
private func updateLabelAddButtons() {
|
||
let pairs = [(attrField, attrAddButton), (shootField, shootAddButton)]
|
||
for (field, button) in pairs {
|
||
let enabled = !(field.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||
button.isEnabled = enabled
|
||
button.backgroundColor = enabled ? AppColor.primary : AppColor.buttonDisabled
|
||
}
|
||
}
|
||
|
||
private func updateStatusButtons() {
|
||
for button in statusButtons {
|
||
let selected = button.tag == viewModel.acceptOrderStatus
|
||
button.backgroundColor = selected ? AppColor.primary : .white
|
||
button.layer.borderColor = (selected ? AppColor.primary : UIColor(hex: 0xB6BECA)).cgColor
|
||
button.setTitleColor(selected ? .white : UIColor(hex: 0x4B5563), for: .normal)
|
||
button.accessibilityTraits = selected ? [.button, .selected] : .button
|
||
}
|
||
}
|
||
|
||
private func rebuildDateButtons() {
|
||
dateStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||
for date in viewModel.weekDates {
|
||
let button = ProfileDateButton()
|
||
button.configure(
|
||
weekday: ProfileSpaceSettingsViewModel.displayWeekday(date),
|
||
day: ProfileSpaceSettingsViewModel.displayDay(date),
|
||
selected: Calendar.current.isDate(date, inSameDayAs: viewModel.selectedDate)
|
||
)
|
||
button.date = date
|
||
button.addTarget(self, action: #selector(dateTapped(_:)), for: .touchUpInside)
|
||
dateStack.addArrangedSubview(button)
|
||
}
|
||
selectedDateLabel.text = ProfileSpaceSettingsViewModel.displayDate(viewModel.selectedDate)
|
||
}
|
||
|
||
private func rebuildSchedules() {
|
||
scheduleStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||
let schedules = viewModel.selectedDateSchedules
|
||
guard !schedules.isEmpty else {
|
||
let empty = UILabel()
|
||
empty.text = "该日无日程"
|
||
empty.textColor = AppColor.textTertiary
|
||
empty.font = .systemFont(ofSize: 14)
|
||
empty.textAlignment = .center
|
||
empty.snp.makeConstraints { make in
|
||
make.height.equalTo(76)
|
||
}
|
||
scheduleStack.addArrangedSubview(empty)
|
||
return
|
||
}
|
||
for schedule in schedules {
|
||
let item = ProfileScheduleItemView(schedule: schedule)
|
||
item.onDelete = { [weak self] in
|
||
self?.confirmDelete(title: "删除日程", message: "您确定要删除该日程吗?") {
|
||
guard let self else { return }
|
||
Task { await self.viewModel.deleteSchedule(id: schedule.id, api: self.profileAPI) }
|
||
}
|
||
}
|
||
item.onCall = { [weak self] phone in
|
||
self?.callPhone(phone)
|
||
}
|
||
scheduleStack.addArrangedSubview(item)
|
||
}
|
||
}
|
||
|
||
private func confirmDelete(title: String, message: String, onConfirm: @escaping () -> Void) {
|
||
let dialog = ProfileDeleteDialogViewController(title: title, message: message, onConfirm: onConfirm)
|
||
present(dialog, animated: false)
|
||
}
|
||
|
||
private func showTimePicker(initialTime: String?, onConfirm: @escaping (String) -> Void) {
|
||
let dialog = ProfileTimePickerDialogViewController(
|
||
initialDate: date(fromTime: initialTime ?? "00:00"),
|
||
onConfirm: { [weak self] date in
|
||
guard let self else { return }
|
||
onConfirm(timeString(from: date))
|
||
}
|
||
)
|
||
present(dialog, animated: false)
|
||
}
|
||
|
||
private func date(fromTime value: String) -> Date {
|
||
var components = DateComponents()
|
||
let pieces = value.split(separator: ":").compactMap { Int($0) }
|
||
components.hour = pieces.first ?? 0
|
||
components.minute = pieces.dropFirst().first ?? 0
|
||
return Calendar.current.date(from: components) ?? Date()
|
||
}
|
||
|
||
private func timeString(from date: Date) -> String {
|
||
let formatter = DateFormatter()
|
||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||
formatter.dateFormat = "HH:mm"
|
||
return formatter.string(from: date)
|
||
}
|
||
|
||
private func callPhone(_ phone: String) {
|
||
let digits = phone.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard let url = URL(string: "tel://\(digits)"), UIApplication.shared.canOpenURL(url) else { return }
|
||
UIApplication.shared.open(url)
|
||
}
|
||
|
||
@objc private func saveTapped() {
|
||
view.endEditing(true)
|
||
Task { await viewModel.save(api: profileAPI) }
|
||
}
|
||
|
||
@objc private func editProfileTapped() {
|
||
view.endEditing(true)
|
||
Task { await viewModel.toggleProfileEditing(api: profileAPI) }
|
||
}
|
||
|
||
@objc private func nicknameChanged() {
|
||
viewModel.updateEditingNickname(nicknameField.text ?? "")
|
||
}
|
||
|
||
@objc private func addAttrTapped() {
|
||
viewModel.addAttrLabel(attrField.text ?? "")
|
||
attrField.text = ""
|
||
updateLabelAddButtons()
|
||
}
|
||
|
||
@objc private func addShootTapped() {
|
||
viewModel.addShootLabel(shootField.text ?? "")
|
||
shootField.text = ""
|
||
updateLabelAddButtons()
|
||
}
|
||
|
||
@objc private func labelInputChanged() {
|
||
updateLabelAddButtons()
|
||
}
|
||
|
||
@objc private func certificationExpandTapped() {
|
||
viewModel.toggleCertificationExpanded()
|
||
}
|
||
|
||
@objc private func timeButtonTapped(_ sender: ProfileTimeButton) {
|
||
if sender === businessStartButton {
|
||
showTimePicker(initialTime: viewModel.businessStartTime) { [weak self] in self?.viewModel.updateBusinessStartTime($0) }
|
||
} else if sender === businessEndButton {
|
||
showTimePicker(initialTime: viewModel.businessEndTime) { [weak self] in self?.viewModel.updateBusinessEndTime($0) }
|
||
} else if sender === holidayStartButton {
|
||
showTimePicker(initialTime: viewModel.holidayStartTime) { [weak self] in self?.viewModel.updateHolidayStartTime($0) }
|
||
} else {
|
||
showTimePicker(initialTime: viewModel.holidayEndTime) { [weak self] in self?.viewModel.updateHolidayEndTime($0) }
|
||
}
|
||
}
|
||
|
||
@objc private func addDeviceTapped() {
|
||
let dialog = ProfileDeviceDialogViewController { [weak self] value in
|
||
self?.viewModel.addDevice(value)
|
||
}
|
||
present(dialog, animated: false)
|
||
}
|
||
|
||
@objc private func statusTapped(_ sender: UIButton) {
|
||
viewModel.updateAcceptOrderStatus(sender.tag)
|
||
}
|
||
|
||
@objc private func dateTapped(_ sender: ProfileDateButton) {
|
||
guard let date = sender.date else { return }
|
||
viewModel.selectDate(date)
|
||
}
|
||
|
||
@objc private func addScheduleTapped() {
|
||
let controller = ProfileAddScheduleViewController(
|
||
selectedDate: viewModel.selectedDate,
|
||
viewModel: viewModel,
|
||
profileAPI: profileAPI
|
||
)
|
||
navigationController?.pushViewController(controller, animated: true)
|
||
}
|
||
|
||
@objc private func avatarTapped() {
|
||
guard viewModel.isEditingProfile else { return }
|
||
var configuration = PHPickerConfiguration(photoLibrary: .shared())
|
||
configuration.filter = .images
|
||
configuration.selectionLimit = 1
|
||
let picker = PHPickerViewController(configuration: configuration)
|
||
picker.delegate = self
|
||
present(picker, animated: true)
|
||
}
|
||
}
|
||
|
||
extension ProfileSpaceSettingsViewController: UITextFieldDelegate, UITextViewDelegate {
|
||
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||
textField.resignFirstResponder()
|
||
return true
|
||
}
|
||
|
||
func textField(
|
||
_ textField: UITextField,
|
||
shouldChangeCharactersIn range: NSRange,
|
||
replacementString string: String
|
||
) -> Bool {
|
||
guard textField === attrField || textField === shootField else { return true }
|
||
let current = textField.text ?? ""
|
||
guard let swiftRange = Range(range, in: current) else { return false }
|
||
return current.replacingCharacters(in: swiftRange, with: string).count <= 20
|
||
}
|
||
|
||
func textViewDidChange(_ textView: UITextView) {
|
||
viewModel.updateIntroduction(textView.text)
|
||
updateIntroductionPlaceholder()
|
||
}
|
||
|
||
func textView(
|
||
_ textView: UITextView,
|
||
shouldChangeTextIn range: NSRange,
|
||
replacementText text: String
|
||
) -> Bool {
|
||
guard text == "\n" else { return true }
|
||
textView.resignFirstResponder()
|
||
return false
|
||
}
|
||
}
|
||
|
||
extension ProfileSpaceSettingsViewController: PHPickerViewControllerDelegate {
|
||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||
picker.dismiss(animated: true)
|
||
guard let provider = results.first?.itemProvider else { return }
|
||
Task {
|
||
do {
|
||
let image = try await ProfileAvatarPickerLoader.loadImage(from: provider)
|
||
await MainActor.run {
|
||
let crop = ProfileAvatarCropViewController(image: image) { [weak self] data in
|
||
guard let self else { return }
|
||
Task {
|
||
await self.viewModel.uploadAvatar(
|
||
data: data,
|
||
fileName: "avatar_\(UUID().uuidString).jpg",
|
||
api: self.profileAPI
|
||
)
|
||
}
|
||
}
|
||
self.present(crop, animated: true)
|
||
}
|
||
} catch {
|
||
await MainActor.run { showToast(error.localizedDescription) }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 添加日程页面,对齐 Android `AddScheduleScreen`。
|
||
final class ProfileAddScheduleViewController: BaseViewController {
|
||
|
||
private let selectedDate: Date
|
||
private let viewModel: ProfileSpaceSettingsViewModel
|
||
private let profileAPI: ProfileAPI
|
||
|
||
private let contentStack = UIStackView()
|
||
private let bottomBar = UIView()
|
||
private let saveButton = AppButton(title: "保存")
|
||
private let nameField = UITextField()
|
||
private let startButton = ProfileTimeButton()
|
||
private let endButton = ProfileTimeButton()
|
||
private let orderButton = UIButton(type: .system)
|
||
private let remarkTextView = UITextView()
|
||
private let remarkPlaceholderLabel = UILabel()
|
||
private let countLabel = UILabel()
|
||
private var selectedOrderNumber: String?
|
||
|
||
init(selectedDate: Date, viewModel: ProfileSpaceSettingsViewModel, profileAPI: ProfileAPI) {
|
||
self.selectedDate = selectedDate
|
||
self.viewModel = viewModel
|
||
self.profileAPI = profileAPI
|
||
super.init(nibName: nil, bundle: nil)
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
override func setupNavigationBar() {
|
||
title = "添加日程"
|
||
}
|
||
|
||
override func viewWillAppear(_ animated: Bool) {
|
||
super.viewWillAppear(animated)
|
||
ProfileNavigationStyle.apply(to: navigationController, showsDivider: true)
|
||
}
|
||
|
||
override func viewWillDisappear(_ animated: Bool) {
|
||
super.viewWillDisappear(animated)
|
||
ProfileNavigationStyle.restore(navigationController)
|
||
}
|
||
|
||
override func setupUI() {
|
||
view.addSubview(contentStack)
|
||
view.addSubview(bottomBar)
|
||
bottomBar.addSubview(saveButton)
|
||
bottomBar.backgroundColor = .white
|
||
view.backgroundColor = AppColor.pageBackground
|
||
saveButton.isEnabled = false
|
||
saveButton.snp.updateConstraints { make in make.height.equalTo(48) }
|
||
|
||
contentStack.axis = .vertical
|
||
contentStack.spacing = 20
|
||
contentStack.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)
|
||
contentStack.isLayoutMarginsRelativeArrangement = true
|
||
contentStack.backgroundColor = .white
|
||
contentStack.layer.cornerRadius = 12
|
||
|
||
contentStack.addArrangedSubview(makeTitle("日程安排"))
|
||
configureNameField()
|
||
contentStack.addArrangedSubview(nameField)
|
||
contentStack.addArrangedSubview(makeTimeRow())
|
||
configureOrderButton()
|
||
contentStack.addArrangedSubview(orderButton)
|
||
configureRemarkView()
|
||
contentStack.addArrangedSubview(remarkTextView)
|
||
contentStack.addArrangedSubview(countLabel)
|
||
}
|
||
|
||
override func setupConstraints() {
|
||
bottomBar.snp.makeConstraints { make in
|
||
make.leading.trailing.bottom.equalToSuperview()
|
||
}
|
||
saveButton.snp.makeConstraints { make in
|
||
make.top.equalToSuperview().offset(16)
|
||
make.leading.trailing.equalToSuperview().inset(15)
|
||
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16)
|
||
}
|
||
contentStack.snp.makeConstraints { make in
|
||
make.top.equalTo(view.safeAreaLayoutGuide).offset(16)
|
||
make.leading.trailing.equalToSuperview().inset(16)
|
||
}
|
||
nameField.snp.makeConstraints { make in
|
||
make.height.equalTo(44)
|
||
}
|
||
orderButton.snp.makeConstraints { make in
|
||
make.height.equalTo(44)
|
||
}
|
||
remarkTextView.snp.makeConstraints { make in
|
||
make.height.equalTo(100)
|
||
}
|
||
}
|
||
|
||
override func bindActions() {
|
||
saveButton.addTarget(self, action: #selector(saveTapped), for: .touchUpInside)
|
||
startButton.addTarget(self, action: #selector(startTapped), for: .touchUpInside)
|
||
endButton.addTarget(self, action: #selector(endTapped), for: .touchUpInside)
|
||
orderButton.addTarget(self, action: #selector(orderTapped), for: .touchUpInside)
|
||
remarkTextView.delegate = self
|
||
nameField.addTarget(self, action: #selector(formValueChanged), for: .editingChanged)
|
||
}
|
||
|
||
private func makeTitle(_ title: String) -> UILabel {
|
||
let label = UILabel()
|
||
label.text = title
|
||
label.font = .systemFont(ofSize: 14, weight: .medium)
|
||
label.textColor = .black
|
||
return label
|
||
}
|
||
|
||
private func configureNameField() {
|
||
nameField.placeholder = "请输入日程名称"
|
||
nameField.font = .systemFont(ofSize: 14)
|
||
nameField.returnKeyType = .done
|
||
nameField.delegate = self
|
||
nameField.layer.borderColor = AppColor.border.cgColor
|
||
nameField.layer.borderWidth = 1
|
||
nameField.layer.cornerRadius = 8
|
||
nameField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 1))
|
||
nameField.leftViewMode = .always
|
||
}
|
||
|
||
private func makeTimeRow() -> UIView {
|
||
let row = UIStackView()
|
||
row.axis = .horizontal
|
||
row.alignment = .center
|
||
row.spacing = 12
|
||
let title = UILabel()
|
||
title.text = "时间"
|
||
title.font = .systemFont(ofSize: 14)
|
||
title.textColor = UIColor(hex: 0x4B5563)
|
||
let toLabel = UILabel()
|
||
toLabel.text = "至"
|
||
toLabel.font = .systemFont(ofSize: 14)
|
||
toLabel.textColor = UIColor(hex: 0x4B5563)
|
||
[startButton, endButton].forEach { button in
|
||
button.setTitle("请选择", for: .normal)
|
||
button.snp.makeConstraints { make in
|
||
make.width.equalTo(60)
|
||
make.height.equalTo(36)
|
||
}
|
||
}
|
||
row.addArrangedSubview(title)
|
||
row.addArrangedSubview(UIView())
|
||
row.addArrangedSubview(startButton)
|
||
row.addArrangedSubview(toLabel)
|
||
row.addArrangedSubview(endButton)
|
||
return row
|
||
}
|
||
|
||
private func configureOrderButton() {
|
||
orderButton.setTitle("关联订单 (可选)", for: .normal)
|
||
orderButton.setTitleColor(.black, for: .normal)
|
||
orderButton.contentHorizontalAlignment = .left
|
||
orderButton.titleLabel?.font = .systemFont(ofSize: 14)
|
||
orderButton.layer.borderColor = AppColor.border.cgColor
|
||
orderButton.layer.borderWidth = 1
|
||
orderButton.layer.cornerRadius = 8
|
||
var configuration = UIButton.Configuration.plain()
|
||
configuration.title = "关联订单 (可选)"
|
||
configuration.image = UIImage(systemName: "chevron.down")
|
||
configuration.imagePlacement = .trailing
|
||
configuration.imagePadding = 8
|
||
configuration.baseForegroundColor = .black
|
||
configuration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)
|
||
orderButton.configuration = configuration
|
||
}
|
||
|
||
private func configureRemarkView() {
|
||
remarkTextView.font = .systemFont(ofSize: 14)
|
||
remarkTextView.textColor = UIColor(hex: 0x4B5563)
|
||
remarkTextView.layer.borderColor = UIColor(hex: 0xE5E7EB).cgColor
|
||
remarkTextView.layer.borderWidth = 1
|
||
remarkTextView.layer.cornerRadius = 8
|
||
remarkTextView.textContainerInset = UIEdgeInsets(top: 10, left: 12, bottom: 10, right: 12)
|
||
remarkTextView.textContainer.lineFragmentPadding = 0
|
||
remarkPlaceholderLabel.text = "请输入日程内容"
|
||
remarkPlaceholderLabel.font = remarkTextView.font
|
||
remarkPlaceholderLabel.textColor = UIColor(hex: 0xB6BECA)
|
||
remarkTextView.addSubview(remarkPlaceholderLabel)
|
||
remarkPlaceholderLabel.snp.makeConstraints { make in
|
||
make.top.equalToSuperview().offset(10)
|
||
make.leading.equalToSuperview().offset(12)
|
||
make.trailing.equalToSuperview().inset(12)
|
||
}
|
||
countLabel.text = "0/200"
|
||
countLabel.textAlignment = .right
|
||
countLabel.font = .systemFont(ofSize: 12)
|
||
countLabel.textColor = UIColor(hex: 0xB6BECA)
|
||
}
|
||
|
||
private func showTimePicker(initialTime: String?, onConfirm: @escaping (String) -> Void) {
|
||
let date = Self.date(from: initialTime)
|
||
let dialog = ProfileTimePickerDialogViewController(initialDate: date) { selectedDate in
|
||
let formatter = DateFormatter()
|
||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||
formatter.dateFormat = "HH:mm"
|
||
onConfirm(formatter.string(from: selectedDate))
|
||
}
|
||
present(dialog, animated: false)
|
||
}
|
||
|
||
@objc private func startTapped() {
|
||
showTimePicker(initialTime: startButton.title(for: .normal)) { [weak self] value in
|
||
if let end = self?.endButton.title(for: .normal), end != "请选择", !ProfileSpaceSettingsViewModel.isStart(value, before: end) {
|
||
self?.showToast("结束时间必须晚于开始时间,已清除结束时间")
|
||
self?.endButton.setTitle("请选择", for: .normal)
|
||
}
|
||
self?.startButton.setTitle(value, for: .normal)
|
||
self?.updateSaveEnabled()
|
||
}
|
||
}
|
||
|
||
@objc private func endTapped() {
|
||
showTimePicker(initialTime: endButton.title(for: .normal)) { [weak self] value in
|
||
if let start = self?.startButton.title(for: .normal), start != "请选择", !ProfileSpaceSettingsViewModel.isStart(start, before: value) {
|
||
self?.showToast("结束时间必须晚于开始时间")
|
||
return
|
||
}
|
||
self?.endButton.setTitle(value, for: .normal)
|
||
self?.updateSaveEnabled()
|
||
}
|
||
}
|
||
|
||
@objc private func orderTapped() {
|
||
let controller = TaskOrderSelectViewController(initialOrderNumber: selectedOrderNumber)
|
||
controller.onConfirmed = { [weak self] orderNumber in
|
||
self?.selectedOrderNumber = orderNumber
|
||
self?.applySelectedOrder()
|
||
}
|
||
navigationController?.pushViewController(controller, animated: true)
|
||
}
|
||
|
||
@objc private func saveTapped() {
|
||
view.endEditing(true)
|
||
let start = startButton.title(for: .normal) == "请选择" ? nil : startButton.title(for: .normal)
|
||
let end = endButton.title(for: .normal) == "请选择" ? nil : endButton.title(for: .normal)
|
||
Task {
|
||
let success = await viewModel.addSchedule(
|
||
name: nameField.text ?? "",
|
||
remark: remarkTextView.text ?? "",
|
||
startTime: start,
|
||
endTime: end,
|
||
scheduleDate: selectedDate,
|
||
orderNumber: selectedOrderNumber,
|
||
api: profileAPI
|
||
)
|
||
if success {
|
||
await MainActor.run { navigationController?.popViewController(animated: true) }
|
||
}
|
||
}
|
||
}
|
||
|
||
@objc private func formValueChanged() {
|
||
updateSaveEnabled()
|
||
}
|
||
|
||
private func updateSaveEnabled() {
|
||
let name = (nameField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let start = startButton.title(for: .normal)
|
||
let end = endButton.title(for: .normal)
|
||
saveButton.isEnabled = !name.isEmpty
|
||
&& start != nil && start != "请选择"
|
||
&& end != nil && end != "请选择"
|
||
&& ProfileSpaceSettingsViewModel.isStart(start ?? "", before: end ?? "")
|
||
}
|
||
|
||
private func applySelectedOrder() {
|
||
var configuration = orderButton.configuration ?? .plain()
|
||
configuration.title = selectedOrderNumber ?? "关联订单 (可选)"
|
||
configuration.baseForegroundColor = .black
|
||
orderButton.configuration = configuration
|
||
}
|
||
|
||
private static func date(from time: String?) -> Date {
|
||
let formatter = DateFormatter()
|
||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||
formatter.dateFormat = "HH:mm"
|
||
return formatter.date(from: time ?? "")
|
||
?? formatter.date(from: "00:00")
|
||
?? Date(timeIntervalSince1970: 0)
|
||
}
|
||
}
|
||
|
||
extension ProfileAddScheduleViewController: UITextFieldDelegate, UITextViewDelegate {
|
||
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||
textField.resignFirstResponder()
|
||
return true
|
||
}
|
||
|
||
func textViewDidChange(_ textView: UITextView) {
|
||
if textView.text.count > 200 {
|
||
textView.text = String(textView.text.prefix(200))
|
||
}
|
||
remarkPlaceholderLabel.isHidden = !textView.text.isEmpty
|
||
countLabel.text = "\(textView.text.count)/200"
|
||
}
|
||
|
||
func textView(
|
||
_ textView: UITextView,
|
||
shouldChangeTextIn range: NSRange,
|
||
replacementText text: String
|
||
) -> Bool {
|
||
guard text == "\n" else { return true }
|
||
textView.resignFirstResponder()
|
||
return false
|
||
}
|
||
}
|
||
|
||
/// 个人空间配置时间按钮。
|
||
private final class ProfileTimeButton: UIButton {
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
titleLabel?.font = .systemFont(ofSize: 14)
|
||
titleLabel?.lineBreakMode = .byClipping
|
||
setTitleColor(UIColor(hex: 0x4B5563), for: .normal)
|
||
backgroundColor = AppColor.inputBackground
|
||
layer.cornerRadius = 4
|
||
setConfigurationContentInsets(
|
||
NSDirectionalEdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 8)
|
||
)
|
||
contentHorizontalAlignment = .center
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
|
||
bounds.insetBy(dx: -4, dy: -4).contains(point)
|
||
}
|
||
}
|
||
|
||
/// 个人空间配置日期按钮。
|
||
private final class ProfileDateButton: UIButton {
|
||
var date: Date?
|
||
|
||
private let weekdayLabel = UILabel()
|
||
private let dayContainer = UIView()
|
||
private let dayLabel = UILabel()
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
weekdayLabel.font = .systemFont(ofSize: 14)
|
||
weekdayLabel.textAlignment = .center
|
||
weekdayLabel.textColor = UIColor(hex: 0x4B5563)
|
||
dayContainer.layer.cornerRadius = 4
|
||
dayContainer.isUserInteractionEnabled = false
|
||
dayLabel.font = .systemFont(ofSize: 14)
|
||
dayLabel.textAlignment = .center
|
||
dayContainer.addSubview(dayLabel)
|
||
addSubview(weekdayLabel)
|
||
addSubview(dayContainer)
|
||
weekdayLabel.snp.makeConstraints { make in
|
||
make.top.leading.trailing.equalToSuperview()
|
||
make.height.equalTo(18)
|
||
}
|
||
dayContainer.snp.makeConstraints { make in
|
||
make.top.equalTo(weekdayLabel.snp.bottom).offset(12)
|
||
make.centerX.equalToSuperview()
|
||
make.width.height.equalTo(36)
|
||
make.bottom.equalToSuperview()
|
||
}
|
||
dayLabel.snp.makeConstraints { make in make.center.equalToSuperview() }
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
func configure(weekday: String, day: String, selected: Bool) {
|
||
weekdayLabel.text = weekday
|
||
dayLabel.text = day
|
||
dayLabel.textColor = selected ? .white : UIColor(hex: 0x4B5563)
|
||
dayContainer.backgroundColor = selected ? AppColor.primary : AppColor.inputBackground
|
||
accessibilityLabel = "星期\(weekday),\(day)日"
|
||
accessibilityTraits = selected ? [.button, .selected] : .button
|
||
}
|
||
}
|
||
|
||
/// 个人空间配置标签换行容器。
|
||
private final class ProfileTagWrapView: UIView {
|
||
enum Style {
|
||
case label
|
||
case certification
|
||
case device
|
||
}
|
||
|
||
private var itemViews: [UIView] = []
|
||
private var removeHandler: ((String) -> Void)?
|
||
private weak var trailingView: UIView?
|
||
|
||
func setTrailingView(_ view: UIView) {
|
||
trailingView = view
|
||
if view.superview !== self {
|
||
addSubview(view)
|
||
}
|
||
itemViews.append(view)
|
||
invalidateIntrinsicContentSize()
|
||
setNeedsLayout()
|
||
}
|
||
|
||
func setItems(_ items: [String], style: Style, removable: Bool, onRemove: ((String) -> Void)? = nil) {
|
||
subviews.forEach { $0.removeFromSuperview() }
|
||
itemViews = items.map { item in
|
||
let view = makeItem(title: item, style: style, removable: removable)
|
||
addSubview(view)
|
||
return view
|
||
}
|
||
if let trailingView {
|
||
addSubview(trailingView)
|
||
itemViews.append(trailingView)
|
||
}
|
||
removeHandler = onRemove
|
||
invalidateIntrinsicContentSize()
|
||
setNeedsLayout()
|
||
}
|
||
|
||
override var intrinsicContentSize: CGSize {
|
||
CGSize(width: UIView.noIntrinsicMetric, height: measuredHeight(for: bounds.width > 0 ? bounds.width : UIScreen.main.bounds.width - 62))
|
||
}
|
||
|
||
override func layoutSubviews() {
|
||
super.layoutSubviews()
|
||
layoutItems(width: bounds.width)
|
||
}
|
||
|
||
private func layoutItems(width: CGFloat) {
|
||
let spacing: CGFloat = 8
|
||
var x: CGFloat = 0
|
||
var y: CGFloat = 0
|
||
var rowHeight: CGFloat = 0
|
||
for view in itemViews {
|
||
guard !view.isHidden else { continue }
|
||
let size = view.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
|
||
if x > 0, x + size.width > width {
|
||
x = 0
|
||
y += rowHeight + spacing
|
||
rowHeight = 0
|
||
}
|
||
view.frame = CGRect(x: x, y: y, width: size.width, height: size.height)
|
||
x += size.width + spacing
|
||
rowHeight = max(rowHeight, size.height)
|
||
}
|
||
}
|
||
|
||
private func measuredHeight(for width: CGFloat) -> CGFloat {
|
||
guard !itemViews.isEmpty else { return 1 }
|
||
let spacing: CGFloat = 8
|
||
var x: CGFloat = 0
|
||
var height: CGFloat = 0
|
||
var rowHeight: CGFloat = 0
|
||
for view in itemViews {
|
||
guard !view.isHidden else { continue }
|
||
let size = view.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
|
||
if x > 0, x + size.width > width {
|
||
x = 0
|
||
height += rowHeight + spacing
|
||
rowHeight = 0
|
||
}
|
||
x += size.width + spacing
|
||
rowHeight = max(rowHeight, size.height)
|
||
}
|
||
return height + rowHeight
|
||
}
|
||
|
||
private func makeItem(title: String, style: Style, removable: Bool) -> UIView {
|
||
let button = UIButton(type: .system)
|
||
button.setTitle(removable ? "\(title) ×" : title, for: .normal)
|
||
button.titleLabel?.font = .systemFont(ofSize: style == .device ? 14 : 12)
|
||
let horizontalInset: CGFloat = style == .device ? 8 : 4
|
||
button.setConfigurationContentInsets(
|
||
NSDirectionalEdgeInsets(top: 2, leading: horizontalInset, bottom: 2, trailing: horizontalInset)
|
||
)
|
||
button.layer.cornerRadius = 4
|
||
switch style {
|
||
case .label:
|
||
button.backgroundColor = AppColor.primaryLight
|
||
button.setTitleColor(AppColor.primary, for: .normal)
|
||
case .certification:
|
||
button.backgroundColor = AppColor.warningBackground
|
||
button.setTitleColor(AppColor.warning, for: .normal)
|
||
case .device:
|
||
button.backgroundColor = AppColor.inputBackground
|
||
button.setTitleColor(UIColor(hex: 0x4B5563), for: .normal)
|
||
button.snp.makeConstraints { make in
|
||
make.height.equalTo(28)
|
||
}
|
||
}
|
||
if removable {
|
||
button.addAction(UIAction { [weak self] _ in
|
||
self?.removeHandler?(title)
|
||
}, for: .touchUpInside)
|
||
}
|
||
return button
|
||
}
|
||
}
|
||
|
||
/// 个人空间配置日程列表项。
|
||
private final class ProfileScheduleItemView: UIStackView {
|
||
var onDelete: (() -> Void)?
|
||
var onCall: ((String) -> Void)?
|
||
|
||
private let schedule: PhotographerSchedule
|
||
|
||
init(schedule: PhotographerSchedule) {
|
||
self.schedule = schedule
|
||
super.init(frame: .zero)
|
||
setupUI()
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
private func setupUI() {
|
||
axis = .vertical
|
||
spacing = 4
|
||
backgroundColor = UIColor(hex: 0xF9FAFB)
|
||
layer.cornerRadius = 8
|
||
layoutMargins = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12)
|
||
isLayoutMarginsRelativeArrangement = true
|
||
|
||
let top = UIStackView()
|
||
top.axis = .horizontal
|
||
top.spacing = 8
|
||
let title = UILabel()
|
||
title.text = "\(schedule.startTime) - \(schedule.endTime) \(schedule.name)"
|
||
title.font = .systemFont(ofSize: 14)
|
||
title.textColor = UIColor(hex: 0x4B5563)
|
||
title.numberOfLines = 1
|
||
let delete = UIButton(type: .system)
|
||
delete.setTitle("删除", for: .normal)
|
||
delete.setTitleColor(UIColor(hex: 0xFF5656), for: .normal)
|
||
delete.titleLabel?.font = .systemFont(ofSize: 14)
|
||
delete.addAction(UIAction { [weak self] _ in self?.onDelete?() }, for: .touchUpInside)
|
||
top.addArrangedSubview(title)
|
||
top.addArrangedSubview(delete)
|
||
addArrangedSubview(top)
|
||
|
||
if let order = schedule.orderNumber, !order.isEmpty {
|
||
addArrangedSubview(makeLine("订单号: \(order)"))
|
||
}
|
||
if let phone = schedule.userPhone, !phone.isEmpty {
|
||
let row = UIStackView()
|
||
row.axis = .horizontal
|
||
row.spacing = 16
|
||
row.addArrangedSubview(makeLine("客户手机号: \(Self.maskPhone(phone))"))
|
||
let call = UIButton(type: .system)
|
||
call.setTitle("拨打", for: .normal)
|
||
call.setTitleColor(AppColor.primary, for: .normal)
|
||
call.titleLabel?.font = .systemFont(ofSize: 14)
|
||
call.addAction(UIAction { [weak self] _ in self?.onCall?(phone) }, for: .touchUpInside)
|
||
row.addArrangedSubview(call)
|
||
addArrangedSubview(row)
|
||
}
|
||
if !schedule.remark.isEmpty {
|
||
addArrangedSubview(makeLine("备注信息: \(schedule.remark)"))
|
||
}
|
||
}
|
||
|
||
private func makeLine(_ text: String) -> UILabel {
|
||
let label = UILabel()
|
||
label.text = text
|
||
label.font = .systemFont(ofSize: 14)
|
||
label.textColor = UIColor(hex: 0x4B5563)
|
||
label.numberOfLines = 1
|
||
return label
|
||
}
|
||
|
||
private static func maskPhone(_ phone: String) -> String {
|
||
guard phone.count >= 11 else { return phone }
|
||
let prefix = phone.prefix(3)
|
||
let suffix = phone.suffix(phone.count - 7)
|
||
return "\(prefix)****\(suffix)"
|
||
}
|
||
}
|
||
|
||
/// 头像选择器数据加载器。
|
||
private enum ProfileAvatarPickerLoader {
|
||
static func loadImage(from provider: NSItemProvider) async throws -> UIImage {
|
||
try await withCheckedThrowingContinuation { continuation in
|
||
guard provider.canLoadObject(ofClass: UIImage.self) else {
|
||
continuation.resume(throwing: OSSUploadError.unsupportedFileType)
|
||
return
|
||
}
|
||
provider.loadObject(ofClass: UIImage.self) { object, error in
|
||
if let error {
|
||
continuation.resume(throwing: error)
|
||
return
|
||
}
|
||
guard let image = object as? UIImage else {
|
||
continuation.resume(throwing: OSSUploadError.emptyFile)
|
||
return
|
||
}
|
||
continuation.resume(returning: image)
|
||
}
|
||
}
|
||
}
|
||
}
|