Files
suixinkan_uikit/suixinkan/UI/Profile/ProfileSpaceSettingsViewController.swift

1174 lines
46 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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 introductionPlaceholderLabel = UILabel()
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
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: 0x565656)
updateIntroductionPlaceholder()
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.placeholder = "请输入昵称"
nicknameField.returnKeyType = .done
nicknameField.delegate = self
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)
}
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.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.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)
}
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(.required, for: .horizontal)
let toLabel = UILabel()
toLabel.text = ""
toLabel.font = .systemFont(ofSize: 14)
toLabel.textColor = UIColor(hex: 0x565656)
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(70)
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.inputBackground
avatarImageView.image = UIImage(systemName: "person.crop.circle.fill")
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
}
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 = "请输入设备名称"
field.returnKeyType = .done
field.delegate = self
}
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: UITextFieldDelegate, UITextViewDelegate {
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.resignFirstResponder()
return true
}
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 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 remarkPlaceholderLabel = UILabel()
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.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: 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
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 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: 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)
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)
}
}
}
}