1092 lines
43 KiB
Swift
1092 lines
43 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 ossUploadService: OSSUploadService
|
||
|
||
private let scrollView = UIScrollView()
|
||
private let contentStack = UIStackView()
|
||
private let bottomBar = UIView()
|
||
private let saveButton = AppButton(title: "保存")
|
||
|
||
private let avatarImageView = UIImageView()
|
||
private let realNameLabel = UILabel()
|
||
private let nicknameField = UITextField()
|
||
private let editProfileButton = UIButton(type: .system)
|
||
private let introductionTextView = UITextView()
|
||
private let certificationWrap = ProfileTagWrapView()
|
||
|
||
private let attrWrap = ProfileTagWrapView()
|
||
private let shootWrap = ProfileTagWrapView()
|
||
private let attrField = UITextField()
|
||
private let shootField = UITextField()
|
||
|
||
private let businessStartButton = ProfileTimeButton()
|
||
private let businessEndButton = ProfileTimeButton()
|
||
private let holidayStartButton = ProfileTimeButton()
|
||
private let holidayEndButton = ProfileTimeButton()
|
||
|
||
private let deviceWrap = ProfileTagWrapView()
|
||
private let addDeviceButton = UIButton(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,
|
||
ossUploadService: OSSUploadService = NetworkServices.shared.ossUploadService
|
||
) {
|
||
self.viewModel = viewModel
|
||
self.profileAPI = profileAPI
|
||
self.ossUploadService = ossUploadService
|
||
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 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
|
||
|
||
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.edges.equalToSuperview().inset(UIEdgeInsets(top: 16, left: 15, bottom: 16, right: 15))
|
||
}
|
||
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)
|
||
addDeviceButton.addTarget(self, action: #selector(addDeviceTapped), for: .touchUpInside)
|
||
addScheduleButton.addTarget(self, action: #selector(addScheduleTapped), for: .touchUpInside)
|
||
[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
|
||
avatarImageView.loadRemoteImage(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: 0x565656)
|
||
editProfileButton.setImage(UIImage(systemName: viewModel.isEditingProfile ? "checkmark" : "square.and.pencil"), for: .normal)
|
||
certificationWrap.setItems(viewModel.scenicCertification, style: .certification, removable: false)
|
||
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
|
||
updateStatusButtons()
|
||
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.inputBackground
|
||
avatarImageView.contentMode = .scaleAspectFill
|
||
avatarImageView.image = UIImage(systemName: "person.crop.circle.fill")
|
||
avatarImageView.tintColor = UIColor(hex: 0xB6BECA)
|
||
avatarImageView.snp.makeConstraints { make in
|
||
make.width.height.equalTo(116)
|
||
}
|
||
|
||
let textStack = UIStackView()
|
||
textStack.axis = .vertical
|
||
textStack.spacing = 8
|
||
|
||
realNameLabel.font = .systemFont(ofSize: 14)
|
||
realNameLabel.textColor = UIColor(hex: 0x565656)
|
||
|
||
let nicknameRow = UIStackView()
|
||
nicknameRow.axis = .horizontal
|
||
nicknameRow.alignment = .center
|
||
nicknameRow.spacing = 8
|
||
nicknameField.font = .systemFont(ofSize: 16)
|
||
nicknameField.textColor = .black
|
||
nicknameField.borderStyle = .none
|
||
nicknameField.addTarget(self, action: #selector(nicknameChanged), for: .editingChanged)
|
||
editProfileButton.tintColor = AppColor.primary
|
||
editProfileButton.snp.makeConstraints { make in
|
||
make.width.height.equalTo(28)
|
||
}
|
||
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)
|
||
}
|
||
|
||
textStack.addArrangedSubview(realNameLabel)
|
||
textStack.addArrangedSubview(nicknameRow)
|
||
textStack.addArrangedSubview(introductionTextView)
|
||
textStack.addArrangedSubview(certificationWrap)
|
||
|
||
row.addArrangedSubview(avatarImageView)
|
||
row.addArrangedSubview(textStack)
|
||
card.addArrangedSubview(row)
|
||
contentStack.addArrangedSubview(card)
|
||
}
|
||
|
||
private func configureLabelCard() {
|
||
let card = makeCard()
|
||
card.addArrangedSubview(makeSectionTitle("标签"))
|
||
card.addArrangedSubview(attrWrap)
|
||
card.addArrangedSubview(makeAddRow(field: attrField, placeholder: "添加标签", action: #selector(addAttrTapped)))
|
||
card.addArrangedSubview(shootWrap)
|
||
card.addArrangedSubview(makeAddRow(field: shootField, placeholder: "添加拍摄说明", action: #selector(addShootTapped)))
|
||
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()
|
||
card.addArrangedSubview(makeSectionTitle("营业时间"))
|
||
card.addArrangedSubview(makeTimeRow(title: "工作日营业时间", start: businessStartButton, end: businessEndButton))
|
||
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.borderColor = AppColor.primary.cgColor
|
||
addDeviceButton.layer.borderWidth = 1
|
||
addDeviceButton.layer.cornerRadius = 4
|
||
addDeviceButton.snp.makeConstraints { make in
|
||
make.height.equalTo(32)
|
||
}
|
||
card.addArrangedSubview(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)
|
||
|
||
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 = UIColor(hex: 0x252525)
|
||
return label
|
||
}
|
||
|
||
private func makeAddRow(field: UITextField, 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.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)
|
||
}
|
||
let button = UIButton(type: .system)
|
||
button.setTitle("添加标签", for: .normal)
|
||
button.setTitleColor(.white, for: .normal)
|
||
button.backgroundColor = AppColor.primary
|
||
button.titleLabel?.font = .systemFont(ofSize: 14)
|
||
button.layer.cornerRadius = 4
|
||
button.addTarget(self, action: action, for: .touchUpInside)
|
||
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: 0x565656)
|
||
titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||
let toLabel = UILabel()
|
||
toLabel.text = "至"
|
||
toLabel.font = .systemFont(ofSize: 14)
|
||
toLabel.textColor = UIColor(hex: 0x565656)
|
||
row.addArrangedSubview(titleLabel)
|
||
row.addArrangedSubview(start)
|
||
row.addArrangedSubview(toLabel)
|
||
row.addArrangedSubview(end)
|
||
[start, end].forEach { button in
|
||
button.snp.makeConstraints { make in
|
||
make.width.equalTo(70)
|
||
make.height.equalTo(36)
|
||
}
|
||
}
|
||
return row
|
||
}
|
||
|
||
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: 0x565656), for: .normal)
|
||
}
|
||
}
|
||
|
||
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 alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||
alert.addAction(UIAlertAction(title: "确定", style: .destructive) { _ in onConfirm() })
|
||
present(alert, animated: true)
|
||
}
|
||
|
||
private func showTimePicker(initialTime: String?, onConfirm: @escaping (String) -> Void) {
|
||
let alert = UIAlertController(title: "选择时间", message: "\n\n\n\n\n\n\n", preferredStyle: .actionSheet)
|
||
let picker = UIDatePicker()
|
||
picker.datePickerMode = .time
|
||
picker.preferredDatePickerStyle = .wheels
|
||
picker.locale = Locale(identifier: "zh_CN")
|
||
if let initialTime {
|
||
picker.date = date(fromTime: initialTime)
|
||
}
|
||
alert.view.addSubview(picker)
|
||
picker.snp.makeConstraints { make in
|
||
make.leading.trailing.equalToSuperview().inset(16)
|
||
make.top.equalToSuperview().offset(42)
|
||
make.height.equalTo(160)
|
||
}
|
||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||
alert.addAction(UIAlertAction(title: "确定", style: .default) { _ in
|
||
onConfirm(self.timeString(from: picker.date))
|
||
})
|
||
present(alert, animated: true)
|
||
}
|
||
|
||
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 = ""
|
||
}
|
||
|
||
@objc private func addShootTapped() {
|
||
viewModel.addShootLabel(shootField.text ?? "")
|
||
shootField.text = ""
|
||
}
|
||
|
||
@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 alert = UIAlertController(title: "添加设备", message: nil, preferredStyle: .alert)
|
||
alert.addTextField { field in
|
||
field.placeholder = "请输入设备名称"
|
||
}
|
||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||
alert.addAction(UIAlertAction(title: "确定", style: .default) { [weak self] _ in
|
||
self?.viewModel.addDevice(alert.textFields?.first?.text ?? "")
|
||
})
|
||
present(alert, animated: true)
|
||
}
|
||
|
||
@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: UITextViewDelegate {
|
||
func textViewDidChange(_ textView: UITextView) {
|
||
viewModel.updateIntroduction(textView.text)
|
||
}
|
||
}
|
||
|
||
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 data = try await ProfileAvatarPickerLoader.loadImageData(from: provider)
|
||
let scenicId = AppStore.shared.currentScenicId
|
||
let fileURL = try await ossUploadService.uploadUserAvatar(
|
||
data: data,
|
||
fileName: "avatar_\(UUID().uuidString).jpg",
|
||
scenicId: scenicId
|
||
) { _ in }
|
||
try await profileAPI.updateUserInfo(nickname: nil, password: nil, avatar: fileURL)
|
||
await MainActor.run {
|
||
viewModel.updateAvatarURL(fileURL)
|
||
showToast("修改成功")
|
||
}
|
||
} 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 countLabel = UILabel()
|
||
|
||
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 setupUI() {
|
||
view.addSubview(contentStack)
|
||
view.addSubview(bottomBar)
|
||
bottomBar.addSubview(saveButton)
|
||
bottomBar.backgroundColor = .white
|
||
|
||
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.edges.equalToSuperview().inset(UIEdgeInsets(top: 16, left: 15, bottom: 16, right: 15))
|
||
}
|
||
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
|
||
}
|
||
|
||
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.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: 0x565656)
|
||
let toLabel = UILabel()
|
||
toLabel.text = "至"
|
||
toLabel.font = .systemFont(ofSize: 14)
|
||
toLabel.textColor = UIColor(hex: 0x565656)
|
||
[startButton, endButton].forEach { button in
|
||
button.setTitle("请选择", for: .normal)
|
||
button.snp.makeConstraints { make in
|
||
make.width.equalTo(78)
|
||
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
|
||
orderButton.contentEdgeInsets = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
|
||
}
|
||
|
||
private func configureRemarkView() {
|
||
remarkTextView.font = .systemFont(ofSize: 14)
|
||
remarkTextView.textColor = UIColor(hex: 0x565656)
|
||
remarkTextView.layer.borderColor = UIColor(hex: 0xE5E7EB).cgColor
|
||
remarkTextView.layer.borderWidth = 1
|
||
remarkTextView.layer.cornerRadius = 8
|
||
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 alert = UIAlertController(title: "选择时间", message: "\n\n\n\n\n\n\n", preferredStyle: .actionSheet)
|
||
let picker = UIDatePicker()
|
||
picker.datePickerMode = .time
|
||
picker.preferredDatePickerStyle = .wheels
|
||
picker.locale = Locale(identifier: "zh_CN")
|
||
alert.view.addSubview(picker)
|
||
picker.snp.makeConstraints { make in
|
||
make.leading.trailing.equalToSuperview().inset(16)
|
||
make.top.equalToSuperview().offset(42)
|
||
make.height.equalTo(160)
|
||
}
|
||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||
alert.addAction(UIAlertAction(title: "确定", style: .default) { _ in
|
||
let formatter = DateFormatter()
|
||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||
formatter.dateFormat = "HH:mm"
|
||
onConfirm(formatter.string(from: picker.date))
|
||
})
|
||
present(alert, animated: true)
|
||
_ = initialTime
|
||
}
|
||
|
||
@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)
|
||
}
|
||
}
|
||
|
||
@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)
|
||
}
|
||
}
|
||
|
||
@objc private func orderTapped() {
|
||
showToast("关联订单 (可选)")
|
||
}
|
||
|
||
@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: nil,
|
||
api: profileAPI
|
||
)
|
||
if success {
|
||
await MainActor.run { navigationController?.popViewController(animated: true) }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
extension ProfileAddScheduleViewController: UITextViewDelegate {
|
||
func textViewDidChange(_ textView: UITextView) {
|
||
if textView.text.count > 200 {
|
||
textView.text = String(textView.text.prefix(200))
|
||
}
|
||
countLabel.text = "\(textView.text.count)/200"
|
||
}
|
||
}
|
||
|
||
/// 个人空间配置时间按钮。
|
||
private final class ProfileTimeButton: UIButton {
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
titleLabel?.font = .systemFont(ofSize: 14)
|
||
setTitleColor(AppColor.textPrimary, for: .normal)
|
||
backgroundColor = .white
|
||
layer.cornerRadius = 8
|
||
layer.borderColor = AppColor.border.cgColor
|
||
layer.borderWidth = 1
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
}
|
||
|
||
/// 个人空间配置日期按钮。
|
||
private final class ProfileDateButton: UIButton {
|
||
var date: Date?
|
||
|
||
func configure(weekday: String, day: String, selected: Bool) {
|
||
let title = "\(weekday)\n\(day)"
|
||
setTitle(title, for: .normal)
|
||
titleLabel?.numberOfLines = 2
|
||
titleLabel?.textAlignment = .center
|
||
titleLabel?.font = .systemFont(ofSize: 14)
|
||
setTitleColor(selected ? .white : UIColor(hex: 0x565656), for: .normal)
|
||
backgroundColor = selected ? AppColor.primary : AppColor.inputBackground
|
||
layer.cornerRadius = 4
|
||
snp.makeConstraints { make in
|
||
make.height.equalTo(58)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 个人空间配置标签换行容器。
|
||
private final class ProfileTagWrapView: UIView {
|
||
enum Style {
|
||
case label
|
||
case certification
|
||
case device
|
||
}
|
||
|
||
private var itemViews: [UIView] = []
|
||
private var removeHandler: ((String) -> Void)?
|
||
|
||
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
|
||
}
|
||
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 {
|
||
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 {
|
||
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 == .certification ? 12 : 14)
|
||
button.contentEdgeInsets = UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 8)
|
||
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: 0x565656), for: .normal)
|
||
}
|
||
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: 0x565656)
|
||
title.numberOfLines = 1
|
||
let delete = UIButton(type: .system)
|
||
delete.setTitle("删除", for: .normal)
|
||
delete.setTitleColor(AppColor.danger, 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: 0x565656)
|
||
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 loadImageData(from provider: NSItemProvider) async throws -> Data {
|
||
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, let data = image.jpegData(compressionQuality: 0.9) else {
|
||
continuation.resume(throwing: OSSUploadError.emptyFile)
|
||
return
|
||
}
|
||
continuation.resume(returning: data)
|
||
}
|
||
}
|
||
}
|
||
}
|