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>
374 lines
15 KiB
Swift
374 lines
15 KiB
Swift
//
|
||
// 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)
|
||
}
|
||
|
||
/// save个人中心Edits相关逻辑。
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|