Files
suixinkan_ios_uikit/suixinkan_ios/Features/Profile/ViewControllers/ProfileViewController.swift
汉秋 a1c031c9b7 Integrate CYLTabBar center scan button and unify UIKit navigation.
Add CYLTabBarController with a global scan entry, promote MainTabBarController to the window root after login, replace RouterPath-based navigation with AppNavigator, and fix Profile diffable cell reconfiguration crashes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 16:36:18 +08:00

374 lines
15 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.

//
// ProfileViewController.swift
// suixinkan
//
import PhotosUI
import SnapKit
import UIKit
// MARK: - Diffable
private typealias ProfileTableSection = Int
private typealias ProfileTableRow = String
/// section
private enum ProfileSectionID {
static let info = 0
static let logout = 1
}
///
private enum ProfileRowID {
static let name = "profile:name"
static let account = "profile:account"
static let phone = "profile:phone"
static let realName = "profile:realName"
static let settings = "profile:settings"
static let logout = "profile:logout"
}
/// value1 Cell Diffable reconfigure
private final class ProfileValueTableViewCell: UITableViewCell {
/// value1 Cell
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: .value1, reuseIdentifier: reuseIdentifier)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
private enum ProfileCellReuseID {
static let value = "profile.value"
static let logout = "profile.logout"
}
///
final class ProfileViewController: UIViewController, UITableViewDelegate {
private let viewModel = ProfileViewModel()
private let avatarImageView = UIImageView()
private let nicknameLabel = UILabel()
private let uidLabel = UILabel()
private let editButton = UIButton(type: .system)
private lazy var tableView: UITableView = {
let table = UITableView(frame: .zero, style: .insetGrouped)
table.delegate = self
return table
}()
private lazy var refreshControl = UIRefreshControl()
/// Diffable
private var tableDataSource: UITableViewDiffableDataSource<ProfileTableSection, ProfileTableRow>!
/// UI
override func viewDidLoad() {
super.viewDidLoad()
title = "个人信息"
view.backgroundColor = UIColor(hex: 0xF7FAFF)
setupHeader()
configureTableDataSource()
view.addSubview(tableView)
tableView.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(150)
make.leading.trailing.bottom.equalToSuperview()
}
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
tableView.refreshControl = refreshControl
viewModel.onChange = { [weak self] in self?.applyViewModel() }
editButton.addTarget(self, action: #selector(editTapped), for: .touchUpInside)
let avatarTap = UITapGestureRecognizer(target: self, action: #selector(pickAvatar))
avatarImageView.isUserInteractionEnabled = true
avatarImageView.addGestureRecognizer(avatarTap)
Task { await reloadProfile(showToast: false) }
}
/// Diffable
private func configureTableDataSource() {
tableView.register(ProfileValueTableViewCell.self, forCellReuseIdentifier: ProfileCellReuseID.value)
tableView.register(UITableViewCell.self, forCellReuseIdentifier: ProfileCellReuseID.logout)
tableDataSource = UITableViewDiffableDataSource<ProfileTableSection, ProfileTableRow>(
tableView: tableView
) { [weak self] (tableView: UITableView, indexPath: IndexPath, row: ProfileTableRow) -> UITableViewCell? in
guard let self else { return UITableViewCell() }
switch row {
case ProfileRowID.logout:
let cell = tableView.dequeueReusableCell(withIdentifier: ProfileCellReuseID.logout, for: indexPath)
cell.textLabel?.text = "退出登录"
cell.textLabel?.textColor = UIColor(hex: 0xEF4444)
cell.textLabel?.textAlignment = .center
cell.selectionStyle = .default
cell.accessoryType = .none
return cell
default:
let cell = tableView.dequeueReusableCell(
withIdentifier: ProfileCellReuseID.value,
for: indexPath
)
cell.textLabel?.textAlignment = .natural
cell.textLabel?.textColor = .label
cell.accessoryType = .none
cell.selectionStyle = .none
switch row {
case ProfileRowID.name:
cell.textLabel?.text = "姓名"
cell.detailTextLabel?.text = self.viewModel.displayRealName
case ProfileRowID.account:
cell.textLabel?.text = "当前账号"
cell.detailTextLabel?.text = self.appServices.accountContext.profile?.displayName ?? "--"
cell.accessoryType = .disclosureIndicator
cell.selectionStyle = .default
case ProfileRowID.phone:
cell.textLabel?.text = "手机号"
cell.detailTextLabel?.text = self.viewModel.displayPhone
case ProfileRowID.realName:
cell.textLabel?.text = "实名认证"
cell.detailTextLabel?.text = self.viewModel.realNameStatusText
cell.accessoryType = .disclosureIndicator
cell.selectionStyle = .default
case ProfileRowID.settings:
cell.textLabel?.text = "设置"
cell.accessoryType = .disclosureIndicator
cell.selectionStyle = .default
default:
break
}
return cell
}
}
applyTableSnapshot(animated: false)
}
/// Diffable snapshot
private func buildTableSnapshot() -> NSDiffableDataSourceSnapshot<ProfileTableSection, ProfileTableRow> {
var snapshot = NSDiffableDataSourceSnapshot<ProfileTableSection, ProfileTableRow>()
snapshot.appendSections([ProfileSectionID.info, ProfileSectionID.logout])
snapshot.appendItems([
ProfileRowID.name,
ProfileRowID.account,
ProfileRowID.phone,
ProfileRowID.realName,
ProfileRowID.settings
], toSection: ProfileSectionID.info)
snapshot.appendItems([ProfileRowID.logout], toSection: ProfileSectionID.logout)
return snapshot
}
/// snapshot Cell
private func applyTableSnapshot(animated: Bool = true, reconfigure: Bool = false) {
var snapshot = buildTableSnapshot()
if reconfigure {
snapshot.reconfigureItems(snapshot.itemIdentifiers)
}
tableDataSource.apply(snapshot, animatingDifferences: animated)
}
/// Header UI
private func setupHeader() {
let header = UIView()
header.backgroundColor = .white
header.layer.cornerRadius = 17
view.addSubview(header)
avatarImageView.layer.cornerRadius = 43
avatarImageView.clipsToBounds = true
avatarImageView.contentMode = .scaleAspectFill
avatarImageView.backgroundColor = AppDesignUIKit.primarySoft
nicknameLabel.font = .systemFont(ofSize: 24, weight: .semibold)
nicknameLabel.textColor = UIColor(hex: 0x252525)
uidLabel.font = .systemFont(ofSize: 18, weight: .semibold)
uidLabel.textColor = UIColor(hex: 0x606A7A)
editButton.setImage(UIImage(systemName: "pencil"), for: .normal)
editButton.tintColor = .white
editButton.backgroundColor = AppDesignUIKit.primary
editButton.layer.cornerRadius = 22
header.addSubview(avatarImageView)
header.addSubview(nicknameLabel)
header.addSubview(uidLabel)
header.addSubview(editButton)
header.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(16)
make.leading.trailing.equalToSuperview().inset(19)
make.height.equalTo(132)
}
avatarImageView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(18)
make.centerY.equalToSuperview()
make.width.height.equalTo(86)
}
nicknameLabel.snp.makeConstraints { make in
make.leading.equalTo(avatarImageView.snp.trailing).offset(14)
make.trailing.lessThanOrEqualTo(editButton.snp.leading).offset(-8)
make.top.equalTo(avatarImageView).offset(8)
}
uidLabel.snp.makeConstraints { make in
make.leading.equalTo(nicknameLabel)
make.top.equalTo(nicknameLabel.snp.bottom).offset(8)
}
editButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(18)
make.centerY.equalToSuperview()
make.width.height.equalTo(44)
}
}
/// apply
private func applyViewModel() {
nicknameLabel.text = viewModel.displayNickname
uidLabel.text = "UID: \(appServices.accountContext.profile?.userId ?? "--")"
if let data = viewModel.pendingAvatarData, let image = UIImage(data: data) {
avatarImageView.image = image
} else {
avatarImageView.loadRemoteAvatar(urlString: viewModel.displayAvatarURL)
}
editButton.setImage(UIImage(systemName: viewModel.isEditingProfile ? "checkmark" : "pencil"), for: .normal)
editButton.isEnabled = !viewModel.isSaving
applyTableSnapshot(animated: false, reconfigure: true)
}
/// Pulled
@objc private func refreshPulled() {
Task {
await reloadProfile(showToast: true)
refreshControl.endRefreshing()
}
}
/// Profile
private func reloadProfile(showToast: Bool) async {
do {
try await appServices.globalLoading.withOptionalLoading(!showToast && viewModel.userInfo == nil, message: "加载资料...") {
try await self.viewModel.reload(api: self.appServices.profileAPI)
}
} catch {
if showToast { self.showToast(error.localizedDescription) }
}
}
/// edit
@objc private func editTapped() {
if viewModel.isEditingProfile {
Task { await saveProfileEdits() }
} else {
viewModel.beginEditing()
presentNicknameEditor()
}
}
/// NicknameEditor
private func presentNicknameEditor() {
let alert = UIAlertController(title: "编辑昵称", message: nil, preferredStyle: .alert)
alert.addTextField { [weak self] field in
field.text = self?.viewModel.editingNickname
field.placeholder = "请输入昵称"
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel) { [weak self] _ in
self?.viewModel.cancelEditing()
})
alert.addAction(UIAlertAction(title: "保存", style: .default) { [weak self] _ in
guard let self else { return }
self.viewModel.editingNickname = alert.textFields?.first?.text ?? ""
Task { await self.saveProfileEdits() }
})
present(alert, animated: true)
}
/// saveEdits
private func saveProfileEdits() async {
guard let scenicId = appServices.accountContext.currentScenic?.id else {
showToast("请先选择景区")
return
}
do {
try await appServices.globalLoading.withLoading(message: "保存中...") {
try await self.viewModel.saveProfile(
api: self.appServices.profileAPI,
uploader: self.appServices.ossUploadService,
scenicId: scenicId
)
}
showToast("资料已更新")
} catch {
showToast(error.localizedDescription)
}
}
/// pick
@objc private func pickAvatar() {
var config = PHPickerConfiguration()
config.filter = .images
config.selectionLimit = 1
let picker = PHPickerViewController(configuration: config)
picker.delegate = self
present(picker, animated: true)
}
/// logout
@objc private func logoutTapped() {
let alert = UIAlertController(title: "确认退出当前账号?", message: nil, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "退出登录", style: .destructive) { [weak self] _ in
guard let self else { return }
self.appServices.authSessionCoordinator.logout(
appSession: self.appServices.appSession,
accountContext: self.appServices.accountContext,
permissionContext: self.appServices.permissionContext,
scenicSpotContext: self.appServices.scenicSpotContext,
appNavigator: self.appServices.appNavigator,
toastCenter: self.appServices.toastCenter
)
})
present(alert, animated: true)
}
/// UITableView
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard let row = tableDataSource.itemIdentifier(for: indexPath) else { return }
switch row {
case ProfileRowID.logout:
logoutTapped()
case ProfileRowID.account:
appServices.appNavigator.push(.profile(.accountSwitch), from: self)
case ProfileRowID.realName:
appServices.appNavigator.push(.profile(.realNameAuth), from: self)
case ProfileRowID.settings:
appServices.appNavigator.push(.profile(.settings), from: self)
default:
break
}
}
}
extension ProfileViewController: PHPickerViewControllerDelegate {
/// picker
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
picker.dismiss(animated: true)
guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return }
provider.loadObject(ofClass: UIImage.self) { [weak self] object, _ in
guard let self, let image = object as? UIImage, let data = image.jpegData(compressionQuality: 0.9) else { return }
DispatchQueue.main.async {
do {
try self.viewModel.prepareAvatarImage(data: data)
} catch {
self.showToast(error.localizedDescription)
}
}
}
}
}