// // LNSkillDetailViewController.swift // Lanu // // Created by OneeChan on 2025/11/28. // import Foundation import UIKit import SnapKit import Combine import AutoCodable extension UIView { func pushToSkillDetail(_ skillId: String) { let vc = LNSkillDetailViewController(skillId: skillId) navigationController?.pushViewController(vc, animated: true) } } class LNSkillDetailViewController: LNViewController { private let skillId: String private let avatar = UIImageView() private let titleLabel = UILabel() private let followButton = UIButton() private let profileButton = UIButton() private let moreButton = UIButton() private let cover = UIImageView() private let userInfoView = LNSkillUserInfoView() private let gameNameLabel = UILabel() private let descLabel = UILabel() private let tagView = LNSkillTagView() private let photosView = LNSkillPhotosView() private let commentsView = LNSkillCommentsView() private let bottomMenu = LNSkillBottomMenuView() private var detail: LNGameMateSkillDetailVO? private var stayTimer: String? init(skillId: String) { self.skillId = skillId super.init(nibName: nil, bundle: nil) } override func viewDidLoad() { super.viewDidLoad() setupViews() loadSkillInfo() LNEventDeliver.addObserver(self) } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) if detail?.userNo.isMyUid == true { loadSkillInfo() } else { triggerAutoReplay() } } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) LNDelayTask.cancel(key: stayTimer) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } private extension LNSkillDetailViewController { func triggerAutoReplay() { guard let uid = detail?.userNo else { return } if !uid.isMyUid { stayTimer = LNDelayTask.perform(delay: 3) { [weak self] in guard let self else { return } LNGameMateManager.shared.triggerAutoReplayIfNeed(uid: uid) } } } func loadSkillInfo() { LNGameMateManager.shared.getSkillDetail(skillId: skillId) { [weak self] info in guard let self else { return } guard let info else { return } if detail == nil { LNStatisticManager.shared.reportVisitor(uid: info.userNo) { _ in } LNStatisticManager.shared.reportViewPlaymate(uid: info.userNo) } self.detail = info cover.sd_setImage(with: URL(string: info.cover.isEmpty ? info.avatar : info.cover)) userInfoView.update(info) gameNameLabel.text = info.categoryName descLabel.isHidden = info.summary.isEmpty descLabel.text = info.summary tagView.update(info.labels) photosView.update(info) commentsView.update(info) bottomMenu.update(info) avatar.showAvatar(info.avatar) titleLabel.text = info.nickname followButton.isHidden = info.follow || info.userNo.isMyUid moreButton.isHidden = info.userNo.isMyUid triggerAutoReplay() } } } extension LNSkillDetailViewController: LNRelationManagerNotify { func onUserRelationChanged(uid: String, relation: LNUserRelationShip) { guard uid == detail?.userNo else { return } followButton.isHidden = relation.contains(.followed) } } extension LNSkillDetailViewController: UIScrollViewDelegate { func scrollViewDidScroll(_ scrollView: UIScrollView) { let offset = cover.bounds.height - 35 - fakeNaviBgView.bounds.height let min = offset - UIView.statusBarHeight let progress: Double = if scrollView.contentOffset.y < min { 0.0 } else { (scrollView.contentOffset.y - min) / UIView.statusBarHeight } updateProgress(progress) } } extension LNSkillDetailViewController { private func showMoreMenu() { let panel = LNBottomSheetMenu() panel.update([ .init(key: "A00043"), detail?.userNo.isInMyBlackList != true ? .init(key: "A00044") : .init(key: "A00045") ]) { [weak self] index, _ in guard let self else { return } guard let detail else { return } if index == 0 { view.pushToReport(uid: detail.userNo) } else if index == 1 { if detail.userNo.isInMyBlackList { LNRelationManager.shared.blackListUser(uid: detail.userNo, black: false, handler: nil) } else { LNCommonAlertView.showBlackAlert(uid: detail.userNo) } } } panel.popup() } private func updateProgress(_ progress: Double) { navigationBarColor = .white.withAlphaComponent(progress) avatar.alpha = progress titleLabel.alpha = progress let tintColor = UIColor.white.interpolateHSB(to: .text_4, progress: progress) backButton.tintColor = tintColor followButton.tintColor = tintColor profileButton.tintColor = tintColor moreButton.tintColor = tintColor let background = UIColor.black.withAlphaComponent(0.4 * (1 - progress)) followButton.backgroundColor = background profileButton.backgroundColor = background moreButton.backgroundColor = background } private func setupViews() { setupNavBar() let stackView = UIStackView() stackView.axis = .vertical view.addSubview(stackView) stackView.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview() make.top.equalTo(fakeNaviBgView) make.bottom.equalTo(view.safeAreaLayoutGuide.snp.bottom) } let scrollView = UIScrollView() scrollView.delegate = self scrollView.clipsToBounds = false scrollView.backgroundColor = .fill scrollView.contentInsetAdjustmentBehavior = .never scrollView.showsVerticalScrollIndicator = false scrollView.showsHorizontalScrollIndicator = false scrollView.contentInset = .init(top: 0, left: 0, bottom: -view.commonBottomInset + 47, right: 0) stackView.addArrangedSubview(scrollView) let fakeView = UIView() scrollView.addSubview(fakeView) fakeView.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview() make.width.equalToSuperview() make.top.equalToSuperview() make.height.equalTo(0) } let cover = buildCover() scrollView.addSubview(cover) cover.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview() make.top.equalToSuperview() } let gameView = buildGameView() scrollView.addSubview(gameView) gameView.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview() make.top.equalTo(cover.snp.bottom).offset(-35) make.bottom.equalToSuperview() } let userInfoView = buildUserInfoView() scrollView.addSubview(userInfoView) userInfoView.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview() make.bottom.equalTo(gameView.snp.top).offset(-10) } let menu = buildBottomMenu() view.addSubview(menu) menu.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview() make.bottom.equalToSuperview() } } private func setupNavBar() { let menu = buildButtonMenu() setRightButton(menu) let container = UIView() avatar.alpha = 0.0 avatar.layer.cornerRadius = 16 avatar.clipsToBounds = true container.addSubview(avatar) avatar.snp.makeConstraints { make in make.leading.equalToSuperview() make.centerY.equalToSuperview() make.width.height.equalTo(32) } titleLabel.alpha = 0.0 titleLabel.font = .heading_h3 titleLabel.textColor = .text_5 titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) container.addSubview(titleLabel) titleLabel.snp.makeConstraints { make in make.leading.equalTo(avatar.snp.trailing).offset(12) make.centerY.equalToSuperview() make.trailing.lessThanOrEqualToSuperview().offset(-37) } setTitleView(container) container.snp.makeConstraints { make in make.width.equalTo(view.bounds.width).priority(.medium) make.height.equalTo(44) } updateProgress(0.0) } private func buildButtonMenu() -> UIView { let stackView = UIStackView() stackView.axis = .horizontal stackView.spacing = 14 followButton.layer.cornerRadius = 16 followButton.isHidden = true followButton.setImage(.icSkillFollow.withRenderingMode(.alwaysTemplate), for: .normal) followButton.snp.makeConstraints { make in make.width.height.equalTo(32) } followButton.addAction(UIAction(handler: { [weak self] _ in guard let self else { return } guard let detail else { return } LNRelationManager.shared.operateFollow(uid: detail.userNo, follow: true, handler: nil) }), for: .touchUpInside) stackView.addArrangedSubview(followButton) profileButton.layer.cornerRadius = 16 profileButton.setImage(.icSkillToProfile.withRenderingMode(.alwaysTemplate), for: .normal) profileButton.snp.makeConstraints { make in make.width.height.equalTo(32) } profileButton.addAction(UIAction(handler: { [weak self] _ in guard let self else { return } guard let detail else { return } view.pushToProfile(uid: detail.userNo) }), for: .touchUpInside) stackView.addArrangedSubview(profileButton) moreButton.layer.cornerRadius = 16 moreButton.setImage(.icMore.withRenderingMode(.alwaysTemplate), for: .normal) moreButton.snp.makeConstraints { make in make.width.height.equalTo(32) } moreButton.addAction(UIAction(handler: { [weak self] _ in guard let self else { return } showMoreMenu() }), for: .touchUpInside) stackView.addArrangedSubview(moreButton) return stackView } private func buildCover() -> UIView { let coverGradient = CAGradientLayer() coverGradient.colors = [UIColor.black.withAlphaComponent(0).cgColor, UIColor.black.cgColor] coverGradient.locations = [0, 1] coverGradient.startPoint = .init(x: 0, y: 0) coverGradient.endPoint = .init(x: 0, y: 1) cover.layer.addSublayer(coverGradient) cover.publisher(for: \.bounds).removeDuplicates().sink { [weak coverGradient] newValue in guard let coverGradient else { return } coverGradient.frame = .init( x: 0, y: newValue.height * 0.5, width: newValue.width, height: newValue.height * 0.5 ) }.store(in: &cancellables) cover.contentMode = .scaleAspectFill cover.clipsToBounds = true cover.onTap { [weak self] in guard let self else { return } guard let cover = detail?.cover.isEmpty != false ? detail?.avatar : detail?.cover else { return } view.presentImagePreview([cover], 0) } cover.snp.makeConstraints { make in make.height.equalTo(cover.snp.width).multipliedBy(363.0/375.0) } return cover } private func buildUserInfoView() -> UIView { return userInfoView } private func buildGameView() -> UIView { let container = UIView() container.backgroundColor = .fill container.layer.cornerRadius = 20 container.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] gameNameLabel.font = .heading_h3 gameNameLabel.textColor = .text_5 container.addSubview(gameNameLabel) gameNameLabel.snp.makeConstraints { make in make.leading.equalToSuperview().offset(16) make.top.equalToSuperview().offset(16) } let line = UIView() line.backgroundColor = .fill_2 container.addSubview(line) line.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview().inset(16) make.top.equalTo(gameNameLabel.snp.bottom).offset(10) make.height.equalTo(1) } let stackView = UIStackView() stackView.axis = .vertical stackView.spacing = 12 container.addSubview(stackView) stackView.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview().inset(16) make.top.equalTo(line.snp.bottom).offset(10) make.bottom.equalToSuperview() } descLabel.font = .body_m descLabel.textColor = .text_4 descLabel.numberOfLines = 0 descLabel.isHidden = true stackView.addArrangedSubview(descLabel) tagView.isHidden = true stackView.addArrangedSubview(tagView) photosView.isHidden = true stackView.addArrangedSubview(photosView) commentsView.isHidden = true stackView.addArrangedSubview(commentsView) return container } private func buildBottomMenu() -> UIView { return bottomMenu } } #if DEBUG import SwiftUI struct LNSkillDetailViewControllerPreview: UIViewControllerRepresentable { func makeUIViewController(context: Context) -> some UIViewController { LNNavigationController(rootViewController: LNSkillDetailViewController(skillId: "")) } func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { } } #Preview(body: { LNSkillDetailViewControllerPreview() }) #endif