Integrate高德 SDK with simulator-safe build flags, add map views for operating area and punch points, refactor main tabs and key feature screens to UIKit with Diffable lists, and document Swift concurrency defaults in AGENTS.md. Co-authored-by: Cursor <cursoragent@cursor.com>
342 lines
13 KiB
Swift
342 lines
13 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"
|
||
}
|
||
|
||
/// 个人信息页,展示用户资料、账号状态和设置入口。
|
||
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() {
|
||
tableDataSource = UITableViewDiffableDataSource<ProfileTableSection, ProfileTableRow>(
|
||
tableView: tableView
|
||
) { [weak self] (_: UITableView, indexPath: IndexPath, row: ProfileTableRow) -> UITableViewCell? in
|
||
guard let self else { return UITableViewCell() }
|
||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||
switch row {
|
||
case ProfileRowID.logout:
|
||
cell.textLabel?.text = "退出登录"
|
||
cell.textLabel?.textColor = UIColor(hex: 0xEF4444)
|
||
cell.textLabel?.textAlignment = .center
|
||
cell.selectionStyle = .default
|
||
case ProfileRowID.name:
|
||
cell.textLabel?.text = "姓名"
|
||
cell.detailTextLabel?.text = self.viewModel.displayRealName
|
||
cell.selectionStyle = .none
|
||
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
|
||
cell.selectionStyle = .none
|
||
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,
|
||
appRouter: self.appServices.appRouter,
|
||
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:
|
||
HomeMenuRouting.pushProfile(.accountSwitch, from: self)
|
||
case ProfileRowID.realName:
|
||
HomeMenuRouting.pushProfile(.realNameAuth, from: self)
|
||
case ProfileRowID.settings:
|
||
HomeMenuRouting.pushProfile(.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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|