模块化 AppStore 并完善素材管理与个人空间设置。

将会话、权限、位置、收款与排队存储拆分为独立模块,同步更新各 ViewModel 与单元测试,并补充素材管理 UI 与个人空间设置交互。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-13 11:01:08 +08:00
parent ceca780ab3
commit 005349f8e6
128 changed files with 2953 additions and 1123 deletions

View File

@ -146,17 +146,15 @@ final class MaterialDetailViewController: BaseViewController {
}
@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
let dialog = MaterialDeleteDialogViewController(itemName: viewModel.detail?.name ?? "") { [weak self] in
guard let self else { return }
Task {
self.showLoading()
await self.viewModel.delete(api: self.api)
self.hideLoading()
}
})
present(alert, animated: true)
}
present(dialog, animated: true)
}
@objc private func editTapped() {
@ -170,6 +168,28 @@ final class MaterialDetailViewController: BaseViewController {
}
}
///
struct MaterialCarouselIndexMapper {
///
static func initialRenderedPage(mediaCount: Int) -> Int {
mediaCount > 1 ? 1 : 0
}
///
static func normalizedRenderedPage(_ page: Int, mediaCount: Int) -> Int {
guard mediaCount > 1 else { return 0 }
if page <= 0 { return mediaCount }
if page >= mediaCount + 1 { return 1 }
return page
}
/// 使
static func logicalPage(renderedPage: Int, mediaCount: Int) -> Int {
guard mediaCount > 1 else { return 0 }
return normalizedRenderedPage(renderedPage, mediaCount: mediaCount) - 1
}
}
///
///
private final class MaterialMediaCarouselView: UIView, UIScrollViewDelegate {
@ -177,8 +197,11 @@ private final class MaterialMediaCarouselView: UIView, UIScrollViewDelegate {
private let pageControl = UIPageControl()
private let placeholderLabel = UILabel()
private var media: [MaterialDetailMediaItem] = []
private var renderedMedia: [MaterialDetailMediaItem] = []
private var players: [Int: AVPlayer] = [:]
private var playerViews: [Int: MaterialPlayerLayerView] = [:]
private var timer: Timer?
private var renderedPage = 0
override init(frame: CGRect) {
super.init(frame: frame)
@ -224,7 +247,9 @@ private final class MaterialMediaCarouselView: UIView, UIScrollViewDelegate {
NotificationCenter.default.removeObserver(self)
players.values.forEach { $0.pause() }
players.removeAll()
playerViews.removeAll()
self.media = media
renderedMedia = media.count > 1 ? [media.last!] + media + [media.first!] : media
scrollView.subviews.forEach { $0.removeFromSuperview() }
pageControl.numberOfPages = media.count
pageControl.currentPage = 0
@ -233,7 +258,8 @@ private final class MaterialMediaCarouselView: UIView, UIScrollViewDelegate {
scrollView.isHidden = media.isEmpty
guard !media.isEmpty else { return }
for (index, item) in media.enumerated() {
renderedPage = MaterialCarouselIndexMapper.initialRenderedPage(mediaCount: media.count)
for (index, item) in renderedMedia.enumerated() {
let page = UIView()
page.backgroundColor = UIColor(hex: 0xF5F5F5)
scrollView.addSubview(page)
@ -245,6 +271,7 @@ private final class MaterialMediaCarouselView: UIView, UIScrollViewDelegate {
}
setNeedsLayout()
layoutIfNeeded()
scrollView.contentOffset = CGPoint(x: CGFloat(renderedPage) * bounds.width, y: 0)
playCurrentPageIfNeeded()
}
@ -283,7 +310,8 @@ private final class MaterialMediaCarouselView: UIView, UIScrollViewDelegate {
let player = AVPlayer(url: url)
players[index] = player
let playerView = MaterialPlayerLayerView()
playerView.playerLayer.player = player
playerView.configure(player: player)
playerViews[index] = playerView
page.addSubview(playerView)
playerView.snp.makeConstraints { make in
make.edges.equalToSuperview()
@ -302,29 +330,40 @@ private final class MaterialMediaCarouselView: UIView, UIScrollViewDelegate {
for (index, page) in scrollView.subviews.enumerated() {
page.frame = CGRect(x: CGFloat(index) * size.width, y: 0, width: size.width, height: size.height)
}
scrollView.contentSize = CGSize(width: size.width * CGFloat(media.count), height: size.height)
scrollView.contentOffset = CGPoint(x: CGFloat(pageControl.currentPage) * size.width, y: 0)
scrollView.contentSize = CGSize(width: size.width * CGFloat(renderedMedia.count), height: size.height)
scrollView.contentOffset = CGPoint(x: CGFloat(renderedPage) * size.width, y: 0)
}
private func updateCurrentPage() {
guard bounds.width > 0 else { return }
let page = Int(round(scrollView.contentOffset.x / bounds.width))
pageControl.currentPage = max(0, min(page, media.count - 1))
let rawPage = Int(round(scrollView.contentOffset.x / bounds.width))
let page = MaterialCarouselIndexMapper.normalizedRenderedPage(rawPage, mediaCount: media.count)
if media.count > 1 {
if page != rawPage {
scrollView.setContentOffset(CGPoint(x: CGFloat(page) * bounds.width, y: 0), animated: false)
}
pageControl.currentPage = MaterialCarouselIndexMapper.logicalPage(renderedPage: page, mediaCount: media.count)
} else {
pageControl.currentPage = 0
}
renderedPage = page
playCurrentPageIfNeeded()
}
private func playCurrentPageIfNeeded() {
stopTimer()
players.forEach { index, player in
if index == pageControl.currentPage {
if index == renderedPage {
player.seek(to: .zero)
player.play()
playerViews[index]?.setPlaying(true)
} else {
player.pause()
playerViews[index]?.setPlaying(false)
}
}
guard !media.isEmpty else { return }
if media[pageControl.currentPage].type == 1 {
guard renderedMedia.indices.contains(renderedPage) else { return }
if renderedMedia[renderedPage].type == 1 {
startImageTimer()
}
}
@ -342,13 +381,13 @@ private final class MaterialMediaCarouselView: UIView, UIScrollViewDelegate {
}
@objc private func videoDidEnd(_ notification: Notification) {
guard players[renderedPage]?.currentItem === notification.object as? AVPlayerItem else { return }
advancePage()
}
private func advancePage() {
guard media.count > 1, bounds.width > 0 else { return }
let nextPage = (pageControl.currentPage + 1) % media.count
pageControl.currentPage = nextPage
let nextPage = renderedPage + 1
scrollView.setContentOffset(CGPoint(x: CGFloat(nextPage) * bounds.width, y: 0), animated: true)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
self?.playCurrentPageIfNeeded()
@ -361,6 +400,64 @@ private final class MaterialMediaCarouselView: UIView, UIScrollViewDelegate {
private final class MaterialPlayerLayerView: UIView {
override class var layerClass: AnyClass { AVPlayerLayer.self }
var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer }
private let controlButton = UIButton(type: .system)
private weak var player: AVPlayer?
private var hideWorkItem: DispatchWorkItem?
override init(frame: CGRect) {
super.init(frame: frame)
controlButton.tintColor = .white
controlButton.backgroundColor = UIColor.black.withAlphaComponent(0.5)
controlButton.layer.cornerRadius = 24
controlButton.addTarget(self, action: #selector(controlTapped), for: .touchUpInside)
addSubview(controlButton)
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(showControls)))
controlButton.snp.makeConstraints { make in make.center.equalToSuperview(); make.size.equalTo(48) }
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func configure(player: AVPlayer) {
self.player = player
playerLayer.player = player
playerLayer.videoGravity = .resizeAspect
setPlaying(false)
}
func setPlaying(_ playing: Bool) {
controlButton.setImage(UIImage(systemName: playing ? "pause.fill" : "play.fill"), for: .normal)
if playing {
scheduleHide()
} else {
hideWorkItem?.cancel()
controlButton.isHidden = false
}
}
@objc private func controlTapped() {
guard let player else { return }
if player.timeControlStatus == .playing {
player.pause()
setPlaying(false)
} else {
player.play()
setPlaying(true)
}
}
@objc private func showControls() {
controlButton.isHidden = false
if player?.timeControlStatus == .playing { scheduleHide() }
}
private func scheduleHide() {
hideWorkItem?.cancel()
controlButton.isHidden = false
let work = DispatchWorkItem { [weak self] in self?.controlButton.isHidden = true }
hideWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + 3, execute: work)
}
}
///
@ -370,7 +467,7 @@ private final class MaterialDetailInfoSection: UIView {
private let listingBadge = UILabel()
private let tagStack = UIStackView()
private let statsStack = UIStackView()
private let downloadStat = MaterialDetailStatView(systemIconName: "arrow.down.circle")
private let downloadStat = MaterialDetailStatView(iconName: "material_ic_download_count")
private let likeStat = MaterialDetailStatView(iconName: "sample_ic_like_count")
private let collectStat = MaterialDetailStatView(iconName: "sample_ic_collect_count")