feat: 添加云盘与消息中心功能
This commit is contained in:
309
suixinkan/UI/MessageCenter/MessageCenterViewController.swift
Normal file
309
suixinkan/UI/MessageCenter/MessageCenterViewController.swift
Normal file
@ -0,0 +1,309 @@
|
||||
//
|
||||
// MessageCenterViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 消息中心列表页,对齐 Android `MsgScreen` 的列表展示与未读处理。
|
||||
final class MessageCenterViewController: BaseViewController, UITableViewDelegate {
|
||||
private enum Section {
|
||||
case main
|
||||
}
|
||||
|
||||
private let viewModel: MessageCenterViewModel
|
||||
private let api: any MessageCenterServing
|
||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||
private let refreshControl = UIRefreshControl()
|
||||
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
|
||||
private let footerSpinner = UIActivityIndicatorView(style: .medium)
|
||||
private let emptyLabel = UILabel()
|
||||
|
||||
private var dataSource: UITableViewDiffableDataSource<Section, MessageItem>!
|
||||
|
||||
/// 初始化消息中心列表页。
|
||||
init(
|
||||
viewModel: MessageCenterViewModel = MessageCenterViewModel(),
|
||||
api: any MessageCenterServing = NetworkServices.shared.messageCenterAPI
|
||||
) {
|
||||
self.viewModel = viewModel
|
||||
self.api = api
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
Task { @MainActor in
|
||||
await viewModel.loadInitial(api: api)
|
||||
}
|
||||
}
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "消息中心"
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = UIColor(hex: 0xF4F4F4)
|
||||
|
||||
tableView.backgroundColor = .clear
|
||||
tableView.separatorStyle = .none
|
||||
tableView.delegate = self
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.estimatedRowHeight = 120
|
||||
tableView.contentInset = UIEdgeInsets(top: 10, left: 0, bottom: 10, right: 0)
|
||||
tableView.refreshControl = refreshControl
|
||||
tableView.register(MessageCenterCell.self, forCellReuseIdentifier: MessageCenterCell.reuseIdentifier)
|
||||
|
||||
loadingIndicator.hidesWhenStopped = true
|
||||
loadingIndicator.color = AppColor.primary
|
||||
|
||||
footerSpinner.hidesWhenStopped = true
|
||||
footerSpinner.color = AppColor.primary
|
||||
|
||||
emptyLabel.text = "暂无消息"
|
||||
emptyLabel.font = .app(.body)
|
||||
emptyLabel.textColor = UIColor(hex: 0x7B8EAA)
|
||||
emptyLabel.textAlignment = .center
|
||||
|
||||
view.addSubview(tableView)
|
||||
view.addSubview(loadingIndicator)
|
||||
|
||||
configureDataSource()
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(view.safeAreaLayoutGuide)
|
||||
}
|
||||
loadingIndicator.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
refreshControl.addTarget(self, action: #selector(refreshTriggered), for: .valueChanged)
|
||||
viewModel.onStateChange = { [weak self] in
|
||||
Task { @MainActor in
|
||||
self?.applyState()
|
||||
}
|
||||
}
|
||||
viewModel.onShowMessage = { [weak self] message in
|
||||
Task { @MainActor in
|
||||
self?.showToast(message)
|
||||
}
|
||||
}
|
||||
viewModel.onOpenDetail = { [weak self] message in
|
||||
Task { @MainActor in
|
||||
self?.openDetail(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
|
||||
Task { @MainActor in
|
||||
await viewModel.selectMessage(id: item.id, api: api)
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
Task { @MainActor in
|
||||
await viewModel.loadMoreIfNeeded(lastVisibleIndex: indexPath.row, api: api)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func refreshTriggered() {
|
||||
Task { @MainActor in
|
||||
await viewModel.refresh(api: api)
|
||||
}
|
||||
}
|
||||
|
||||
private func configureDataSource() {
|
||||
dataSource = UITableViewDiffableDataSource<Section, MessageItem>(tableView: tableView) { tableView, indexPath, item in
|
||||
let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: MessageCenterCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as! MessageCenterCell
|
||||
cell.configure(with: item)
|
||||
return cell
|
||||
}
|
||||
applySnapshot(animated: false)
|
||||
}
|
||||
|
||||
private func applyState() {
|
||||
applySnapshot(animated: true)
|
||||
if viewModel.isRefreshing {
|
||||
if !refreshControl.isRefreshing { refreshControl.beginRefreshing() }
|
||||
} else {
|
||||
refreshControl.endRefreshing()
|
||||
}
|
||||
|
||||
if viewModel.isLoading {
|
||||
loadingIndicator.startAnimating()
|
||||
tableView.backgroundView = nil
|
||||
} else {
|
||||
loadingIndicator.stopAnimating()
|
||||
tableView.backgroundView = viewModel.items.isEmpty ? emptyBackgroundView() : nil
|
||||
}
|
||||
|
||||
tableView.tableFooterView = footerView()
|
||||
}
|
||||
|
||||
private func applySnapshot(animated: Bool) {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, MessageItem>()
|
||||
snapshot.appendSections([.main])
|
||||
snapshot.appendItems(viewModel.items)
|
||||
dataSource.apply(snapshot, animatingDifferences: animated)
|
||||
}
|
||||
|
||||
private func emptyBackgroundView() -> UIView {
|
||||
let container = UIView(frame: tableView.bounds)
|
||||
emptyLabel.frame = container.bounds
|
||||
emptyLabel.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
container.addSubview(emptyLabel)
|
||||
return container
|
||||
}
|
||||
|
||||
private func footerView() -> UIView? {
|
||||
guard !viewModel.items.isEmpty, viewModel.isLoadingMore else { return nil }
|
||||
let container = UIView(frame: CGRect(x: 0, y: 0, width: view.bounds.width, height: 44))
|
||||
footerSpinner.center = CGPoint(x: container.bounds.midX, y: container.bounds.midY)
|
||||
footerSpinner.autoresizingMask = [.flexibleLeftMargin, .flexibleRightMargin]
|
||||
container.addSubview(footerSpinner)
|
||||
footerSpinner.startAnimating()
|
||||
return container
|
||||
}
|
||||
|
||||
private func openDetail(_ message: MessageItem) {
|
||||
let controller = MessageDetailViewController(message: message, api: api)
|
||||
controller.onDeleted = { [weak self] id in
|
||||
self?.viewModel.removeMessage(id: id)
|
||||
}
|
||||
navigationController?.pushViewController(controller, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// 消息列表卡片 Cell,对齐 Android `MsgItem`。
|
||||
final class MessageCenterCell: UITableViewCell {
|
||||
static let reuseIdentifier = "MessageCenterCell"
|
||||
|
||||
private let cardView = UIView()
|
||||
private let typeImageView = UIImageView()
|
||||
private let titleLabel = UILabel()
|
||||
private let timeLabel = UILabel()
|
||||
private let unreadDot = UIView()
|
||||
private let contentLabel = UILabel()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
setupUI()
|
||||
setupConstraints()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 配置消息卡片内容。
|
||||
func configure(with item: MessageItem) {
|
||||
typeImageView.image = UIImage(named: MessageTypeIcon.assetName(for: item.type))
|
||||
titleLabel.text = item.displayTitle
|
||||
timeLabel.text = item.displayTime
|
||||
contentLabel.text = item.content
|
||||
unreadDot.isHidden = item.isRead
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
backgroundColor = .clear
|
||||
contentView.backgroundColor = .clear
|
||||
selectionStyle = .none
|
||||
|
||||
cardView.backgroundColor = .white
|
||||
cardView.layer.cornerRadius = 12
|
||||
cardView.clipsToBounds = true
|
||||
|
||||
typeImageView.contentMode = .scaleAspectFit
|
||||
|
||||
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
titleLabel.textColor = UIColor(hex: 0x333333)
|
||||
titleLabel.numberOfLines = 1
|
||||
titleLabel.lineBreakMode = .byTruncatingTail
|
||||
|
||||
timeLabel.font = .systemFont(ofSize: 12, weight: .thin)
|
||||
timeLabel.textColor = UIColor(hex: 0x7B8EAA)
|
||||
timeLabel.numberOfLines = 1
|
||||
|
||||
unreadDot.backgroundColor = UIColor(hex: 0xEF4444)
|
||||
unreadDot.layer.cornerRadius = 4
|
||||
unreadDot.clipsToBounds = true
|
||||
|
||||
contentLabel.font = .systemFont(ofSize: 14)
|
||||
contentLabel.textColor = UIColor(hex: 0x2E3746)
|
||||
contentLabel.numberOfLines = 2
|
||||
contentLabel.lineBreakMode = .byTruncatingTail
|
||||
|
||||
contentView.addSubview(cardView)
|
||||
[typeImageView, titleLabel, timeLabel, unreadDot, contentLabel].forEach(cardView.addSubview)
|
||||
}
|
||||
|
||||
private func setupConstraints() {
|
||||
cardView.snp.makeConstraints { make in
|
||||
make.top.bottom.equalToSuperview().inset(6)
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
}
|
||||
typeImageView.snp.makeConstraints { make in
|
||||
make.top.leading.equalToSuperview().offset(16)
|
||||
make.size.equalTo(50)
|
||||
}
|
||||
unreadDot.snp.makeConstraints { make in
|
||||
make.top.equalTo(typeImageView.snp.top).offset(2)
|
||||
make.trailing.equalToSuperview().inset(16)
|
||||
make.size.equalTo(8)
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(typeImageView.snp.top).offset(1)
|
||||
make.leading.equalTo(typeImageView.snp.trailing).offset(8)
|
||||
make.trailing.lessThanOrEqualTo(unreadDot.snp.leading).offset(-8)
|
||||
}
|
||||
timeLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(1)
|
||||
make.leading.equalTo(titleLabel)
|
||||
make.trailing.equalToSuperview().inset(16)
|
||||
}
|
||||
contentLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(typeImageView.snp.bottom).offset(8)
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
make.bottom.equalToSuperview().inset(16)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 消息类型图标映射,对齐 Android `getMessageTypeIcon`。
|
||||
enum MessageTypeIcon {
|
||||
static func assetName(for type: Int) -> String {
|
||||
switch type {
|
||||
case 1, 7:
|
||||
return "ic_msg1"
|
||||
case 2:
|
||||
return "ic_msg2"
|
||||
case 3:
|
||||
return "ic_msg3"
|
||||
case 4:
|
||||
return "ic_msg4"
|
||||
case 5:
|
||||
return "ic_msg5"
|
||||
case 8:
|
||||
return "ic_msg8"
|
||||
default:
|
||||
return "ic_msg999"
|
||||
}
|
||||
}
|
||||
}
|
||||
169
suixinkan/UI/MessageCenter/MessageDetailViewController.swift
Normal file
169
suixinkan/UI/MessageCenter/MessageDetailViewController.swift
Normal file
@ -0,0 +1,169 @@
|
||||
//
|
||||
// MessageDetailViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 消息详情页,对齐 Android `MsgDetailScreen`。
|
||||
final class MessageDetailViewController: BaseViewController {
|
||||
var onDeleted: ((Int) -> Void)?
|
||||
|
||||
private let viewModel: MessageDetailViewModel
|
||||
private let api: any MessageCenterServing
|
||||
|
||||
private let scrollView = UIScrollView()
|
||||
private let contentView = UIView()
|
||||
private let typeImageView = UIImageView()
|
||||
private let titleLabel = UILabel()
|
||||
private let timeContainer = UIView()
|
||||
private let timeLabel = UILabel()
|
||||
private let bodyLabel = UILabel()
|
||||
private let bottomBar = UIView()
|
||||
private let deleteButton = UIButton(type: .system)
|
||||
|
||||
/// 初始化消息详情页。
|
||||
init(message: MessageItem, api: any MessageCenterServing = NetworkServices.shared.messageCenterAPI) {
|
||||
viewModel = MessageDetailViewModel(message: message)
|
||||
self.api = api
|
||||
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 = .white
|
||||
|
||||
typeImageView.contentMode = .scaleAspectFit
|
||||
typeImageView.image = UIImage(named: MessageTypeIcon.assetName(for: viewModel.message.type))
|
||||
|
||||
titleLabel.text = viewModel.message.displayTitle
|
||||
titleLabel.font = .systemFont(ofSize: 20, weight: .bold)
|
||||
titleLabel.textColor = .black
|
||||
titleLabel.textAlignment = .center
|
||||
titleLabel.numberOfLines = 0
|
||||
|
||||
timeContainer.backgroundColor = UIColor(hex: 0xF4F4F4)
|
||||
timeContainer.layer.cornerRadius = 4
|
||||
timeContainer.clipsToBounds = true
|
||||
|
||||
timeLabel.text = viewModel.message.createdAt
|
||||
timeLabel.font = .systemFont(ofSize: 12)
|
||||
timeLabel.textColor = UIColor(hex: 0x4B5563)
|
||||
timeLabel.textAlignment = .center
|
||||
timeLabel.numberOfLines = 1
|
||||
|
||||
bodyLabel.text = viewModel.message.content
|
||||
bodyLabel.font = .systemFont(ofSize: 16)
|
||||
bodyLabel.textColor = .black
|
||||
bodyLabel.numberOfLines = 0
|
||||
bodyLabel.textAlignment = .left
|
||||
|
||||
bottomBar.backgroundColor = .white
|
||||
deleteButton.setTitle("删除并返回", for: .normal)
|
||||
deleteButton.setTitleColor(.white, for: .normal)
|
||||
deleteButton.titleLabel?.font = .systemFont(ofSize: 16)
|
||||
deleteButton.backgroundColor = UIColor(hex: 0xEF4444)
|
||||
deleteButton.layer.cornerRadius = 8
|
||||
deleteButton.clipsToBounds = true
|
||||
|
||||
view.addSubview(scrollView)
|
||||
scrollView.addSubview(contentView)
|
||||
[typeImageView, titleLabel, timeContainer, bodyLabel].forEach(contentView.addSubview)
|
||||
timeContainer.addSubview(timeLabel)
|
||||
view.addSubview(bottomBar)
|
||||
bottomBar.addSubview(deleteButton)
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
bottomBar.snp.makeConstraints { make in
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
deleteButton.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(16)
|
||||
make.leading.trailing.equalToSuperview().inset(32)
|
||||
make.height.equalTo(52)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16)
|
||||
}
|
||||
scrollView.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
|
||||
make.bottom.equalTo(bottomBar.snp.top)
|
||||
}
|
||||
contentView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(scrollView.contentLayoutGuide)
|
||||
make.width.equalTo(scrollView.frameLayoutGuide)
|
||||
make.height.greaterThanOrEqualTo(scrollView.frameLayoutGuide)
|
||||
}
|
||||
typeImageView.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(32)
|
||||
make.centerX.equalToSuperview()
|
||||
make.size.equalTo(64)
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(typeImageView.snp.bottom).offset(8)
|
||||
make.leading.trailing.equalToSuperview().inset(32)
|
||||
}
|
||||
timeContainer.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(8)
|
||||
make.centerX.equalToSuperview()
|
||||
}
|
||||
timeLabel.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 2, left: 4, bottom: 2, right: 4))
|
||||
}
|
||||
bodyLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(timeContainer.snp.bottom).offset(24)
|
||||
make.leading.trailing.equalToSuperview().inset(32)
|
||||
make.bottom.lessThanOrEqualToSuperview().inset(24)
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
|
||||
viewModel.onStateChange = { [weak self] in
|
||||
Task { @MainActor in
|
||||
self?.applyState()
|
||||
}
|
||||
}
|
||||
viewModel.onShowMessage = { [weak self] message in
|
||||
Task { @MainActor in
|
||||
self?.showToast(message)
|
||||
}
|
||||
}
|
||||
viewModel.onDeleted = { [weak self] id in
|
||||
Task { @MainActor in
|
||||
self?.onDeleted?(id)
|
||||
self?.navigationController?.popViewController(animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func deleteTapped() {
|
||||
let alert = UIAlertController(title: "删除消息", message: "确定要删除这条消息吗?", preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "确认", style: .destructive) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
await self.viewModel.delete(api: self.api)
|
||||
}
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func applyState() {
|
||||
deleteButton.isEnabled = !viewModel.isDeleting
|
||||
deleteButton.alpha = viewModel.isDeleting ? 0.65 : 1
|
||||
if viewModel.isDeleting {
|
||||
showLoading()
|
||||
} else {
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user