1050 lines
43 KiB
Swift
1050 lines
43 KiB
Swift
//
|
||
// TravelAlbumAIRetouchTemplateViewController.swift
|
||
// suixinkan
|
||
//
|
||
|
||
import Kingfisher
|
||
import SnapKit
|
||
import UIKit
|
||
|
||
/// AI 修图模板选择 Sheet,按工作流展示所需横向模板列表与固定底部操作。
|
||
final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
||
/// 页面使用的 collection section。
|
||
private enum Section: Hashable {
|
||
case overview
|
||
case templates(TravelAlbumAIRetouchTemplateCategory)
|
||
case mode
|
||
}
|
||
|
||
/// 页面使用的 diffable item。
|
||
private enum Item: Hashable {
|
||
case overview
|
||
case template(TravelAlbumAIRetouchTemplateCategory, TravelAlbumAIRetouchTemplate)
|
||
case mode
|
||
}
|
||
|
||
private let viewModel: TravelAlbumAIRetouchTemplateViewModel
|
||
private let api: any TravelAlbumServing
|
||
private let onSubmitted: () -> Void
|
||
|
||
private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
|
||
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||
private let statusContainer = UIView()
|
||
private let statusIndicator = UIActivityIndicatorView(style: .medium)
|
||
private let statusLabel = UILabel()
|
||
private let retryButton = UIButton(type: .system)
|
||
private let bottomBar = UIView()
|
||
private let bottomDivider = UIView()
|
||
private let footerStack = UIStackView()
|
||
private let validationLabel = UILabel()
|
||
private let actionStack = UIStackView()
|
||
private let cancelButton = UIButton(type: .system)
|
||
private let confirmButton = AIRetouchGradientButton(type: .system)
|
||
|
||
/// 创建 AI 修图模板选择 Sheet。
|
||
init(
|
||
viewModel: TravelAlbumAIRetouchTemplateViewModel,
|
||
api: any TravelAlbumServing,
|
||
onSubmitted: @escaping () -> Void
|
||
) {
|
||
self.viewModel = viewModel
|
||
self.api = api
|
||
self.onSubmitted = onSubmitted
|
||
super.init(nibName: nil, bundle: nil)
|
||
modalPresentationStyle = .pageSheet
|
||
configureSheetPresentation()
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
override func setupUI() {
|
||
view.backgroundColor = AIRetouchTemplateStyle.pageBackground
|
||
view.accessibilityIdentifier = "travelAlbum.aiRetouchTemplateSheet"
|
||
|
||
collectionView.backgroundColor = .clear
|
||
collectionView.showsVerticalScrollIndicator = false
|
||
collectionView.alwaysBounceVertical = true
|
||
collectionView.delegate = self
|
||
collectionView.accessibilityIdentifier = "travelAlbum.aiRetouchTemplateCollection"
|
||
collectionView.register(
|
||
TravelAlbumAIRetouchTemplateCell.self,
|
||
forCellWithReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier
|
||
)
|
||
collectionView.register(
|
||
TravelAlbumAIRetouchOverviewCell.self,
|
||
forCellWithReuseIdentifier: TravelAlbumAIRetouchOverviewCell.reuseIdentifier
|
||
)
|
||
collectionView.register(
|
||
TravelAlbumAIRetouchModeCell.self,
|
||
forCellWithReuseIdentifier: TravelAlbumAIRetouchModeCell.reuseIdentifier
|
||
)
|
||
collectionView.register(
|
||
TravelAlbumAIRetouchSectionHeader.self,
|
||
forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
|
||
withReuseIdentifier: TravelAlbumAIRetouchSectionHeader.reuseIdentifier
|
||
)
|
||
configureDataSource()
|
||
|
||
statusLabel.textColor = AIRetouchTemplateStyle.textSecondary
|
||
statusLabel.font = .systemFont(ofSize: 14)
|
||
statusLabel.textAlignment = .center
|
||
statusLabel.numberOfLines = 0
|
||
retryButton.setTitle("重试", for: .normal)
|
||
retryButton.titleLabel?.font = .systemFont(ofSize: 15, weight: .semibold)
|
||
retryButton.accessibilityIdentifier = "travelAlbum.aiRetouchRetryButton"
|
||
|
||
bottomBar.backgroundColor = .white
|
||
bottomBar.accessibilityIdentifier = "travelAlbum.aiRetouchBottomBar"
|
||
bottomDivider.backgroundColor = AIRetouchTemplateStyle.border
|
||
validationLabel.textColor = AIRetouchTemplateStyle.danger
|
||
validationLabel.font = .systemFont(ofSize: 12, weight: .medium)
|
||
validationLabel.textAlignment = .center
|
||
validationLabel.numberOfLines = 0
|
||
validationLabel.accessibilityIdentifier = "travelAlbum.aiRetouchValidationLabel"
|
||
|
||
footerStack.axis = .vertical
|
||
footerStack.spacing = 8
|
||
footerStack.alignment = .fill
|
||
actionStack.axis = .horizontal
|
||
actionStack.spacing = 12
|
||
actionStack.distribution = .fill
|
||
actionStack.alignment = .fill
|
||
configureCancelButton()
|
||
configureConfirmButton()
|
||
actionStack.addArrangedSubview(cancelButton)
|
||
actionStack.addArrangedSubview(confirmButton)
|
||
footerStack.addArrangedSubview(validationLabel)
|
||
footerStack.addArrangedSubview(actionStack)
|
||
|
||
view.addSubview(collectionView)
|
||
view.addSubview(statusContainer)
|
||
statusContainer.addSubview(statusIndicator)
|
||
statusContainer.addSubview(statusLabel)
|
||
statusContainer.addSubview(retryButton)
|
||
view.addSubview(bottomBar)
|
||
bottomBar.addSubview(bottomDivider)
|
||
bottomBar.addSubview(footerStack)
|
||
}
|
||
|
||
override func setupConstraints() {
|
||
bottomBar.snp.makeConstraints { make in
|
||
make.leading.trailing.bottom.equalToSuperview()
|
||
}
|
||
bottomDivider.snp.makeConstraints { make in
|
||
make.top.leading.trailing.equalToSuperview()
|
||
make.height.equalTo(0.5)
|
||
}
|
||
footerStack.snp.makeConstraints { make in
|
||
make.top.equalToSuperview().offset(10)
|
||
make.leading.trailing.equalToSuperview().inset(18)
|
||
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
|
||
}
|
||
actionStack.snp.makeConstraints { make in
|
||
make.height.equalTo(56)
|
||
}
|
||
cancelButton.snp.makeConstraints { make in
|
||
make.width.equalTo(confirmButton.snp.width).multipliedBy(0.78)
|
||
}
|
||
collectionView.snp.makeConstraints { make in
|
||
make.top.equalTo(view.safeAreaLayoutGuide)
|
||
make.leading.trailing.equalToSuperview()
|
||
make.bottom.equalTo(bottomBar.snp.top)
|
||
}
|
||
statusContainer.snp.makeConstraints { make in
|
||
make.edges.equalTo(collectionView)
|
||
}
|
||
statusIndicator.snp.makeConstraints { make in
|
||
make.centerX.equalToSuperview()
|
||
make.centerY.equalToSuperview().offset(-32)
|
||
}
|
||
statusLabel.snp.makeConstraints { make in
|
||
make.top.equalTo(statusIndicator.snp.bottom).offset(14)
|
||
make.leading.trailing.equalToSuperview().inset(40)
|
||
}
|
||
retryButton.snp.makeConstraints { make in
|
||
make.top.equalTo(statusLabel.snp.bottom).offset(12)
|
||
make.centerX.equalToSuperview()
|
||
make.height.equalTo(36)
|
||
make.bottom.lessThanOrEqualToSuperview()
|
||
}
|
||
}
|
||
|
||
override func bindActions() {
|
||
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
||
retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside)
|
||
viewModel.onStateChange = { [weak self] in
|
||
Task { @MainActor in self?.applyViewModel() }
|
||
}
|
||
viewModel.onShowMessage = { [weak self] message in
|
||
Task { @MainActor in self?.showToast(message) }
|
||
}
|
||
viewModel.onSubmitted = { [weak self] in
|
||
Task { @MainActor in
|
||
guard let self else { return }
|
||
self.dismiss(animated: true, completion: self.onSubmitted)
|
||
}
|
||
}
|
||
}
|
||
|
||
override func viewDidLoad() {
|
||
super.viewDidLoad()
|
||
applyViewModel()
|
||
Task { await viewModel.loadTemplates(api: api) }
|
||
}
|
||
|
||
private func configureSheetPresentation() {
|
||
guard let sheet = sheetPresentationController else { return }
|
||
sheet.detents = [.large()]
|
||
sheet.selectedDetentIdentifier = .large
|
||
sheet.prefersGrabberVisible = true
|
||
sheet.prefersScrollingExpandsWhenScrolledToEdge = false
|
||
}
|
||
|
||
private func configureCancelButton() {
|
||
cancelButton.setTitle("取消", for: .normal)
|
||
cancelButton.setTitleColor(AIRetouchTemplateStyle.primary, for: .normal)
|
||
cancelButton.setTitleColor(AIRetouchTemplateStyle.primary.withAlphaComponent(0.45), for: .disabled)
|
||
cancelButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
|
||
cancelButton.backgroundColor = AIRetouchTemplateStyle.cardBackground
|
||
cancelButton.layer.cornerRadius = 12
|
||
cancelButton.layer.borderColor = AIRetouchTemplateStyle.primary.cgColor
|
||
cancelButton.layer.borderWidth = 1
|
||
cancelButton.accessibilityIdentifier = "travelAlbum.aiRetouchCancelButton"
|
||
}
|
||
|
||
private func configureConfirmButton() {
|
||
confirmButton.setTitle("确定", for: .normal)
|
||
confirmButton.setTitleColor(.white, for: .normal)
|
||
confirmButton.setTitleColor(.white, for: .disabled)
|
||
confirmButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
|
||
confirmButton.accessibilityIdentifier = "travelAlbum.aiRetouchConfirmButton"
|
||
}
|
||
|
||
private func configureDataSource() {
|
||
dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) {
|
||
[weak self] collectionView, indexPath, item in
|
||
guard let self else { return nil }
|
||
switch item {
|
||
case .overview:
|
||
let cell = collectionView.dequeueReusableCell(
|
||
withReuseIdentifier: TravelAlbumAIRetouchOverviewCell.reuseIdentifier,
|
||
for: indexPath
|
||
) as! TravelAlbumAIRetouchOverviewCell
|
||
cell.apply(
|
||
selectedPhotoCount: self.viewModel.selectedPhotoCount,
|
||
tips: self.viewModel.shouldShowInitialTips ? self.viewModel.initialTipsText : nil
|
||
)
|
||
return cell
|
||
case .template(let category, let template):
|
||
let cell = collectionView.dequeueReusableCell(
|
||
withReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier,
|
||
for: indexPath
|
||
) as! TravelAlbumAIRetouchTemplateCell
|
||
cell.apply(
|
||
template: template,
|
||
selected: self.viewModel.selectedTemplateId(for: category) == template.id
|
||
)
|
||
cell.onPreviewTapped = { [weak self] in
|
||
self?.showPreview(for: template)
|
||
}
|
||
return cell
|
||
case .mode:
|
||
let cell = collectionView.dequeueReusableCell(
|
||
withReuseIdentifier: TravelAlbumAIRetouchModeCell.reuseIdentifier,
|
||
for: indexPath
|
||
) as! TravelAlbumAIRetouchModeCell
|
||
cell.apply(
|
||
requiredQuota: self.viewModel.requiredQuota,
|
||
remainingQuota: self.viewModel.remainingQuota,
|
||
insufficient: self.viewModel.remainingQuota.map {
|
||
self.viewModel.requiredQuota > $0
|
||
} ?? false
|
||
)
|
||
return cell
|
||
}
|
||
}
|
||
dataSource.supplementaryViewProvider = { [weak self] collectionView, kind, indexPath in
|
||
guard kind == UICollectionView.elementKindSectionHeader,
|
||
let self,
|
||
self.dataSource.snapshot().sectionIdentifiers.indices.contains(indexPath.section) else {
|
||
return nil
|
||
}
|
||
let section = self.dataSource.snapshot().sectionIdentifiers[indexPath.section]
|
||
let header = collectionView.dequeueReusableSupplementaryView(
|
||
ofKind: kind,
|
||
withReuseIdentifier: TravelAlbumAIRetouchSectionHeader.reuseIdentifier,
|
||
for: indexPath
|
||
) as! TravelAlbumAIRetouchSectionHeader
|
||
switch section {
|
||
case .templates(let category):
|
||
header.apply(
|
||
title: category.title,
|
||
badge: category == .cover
|
||
? .gift
|
||
: (self.viewModel.isOptional(category) ? .optional : .required)
|
||
)
|
||
case .mode:
|
||
return nil
|
||
case .overview:
|
||
return nil
|
||
}
|
||
return header
|
||
}
|
||
}
|
||
|
||
private func makeLayout() -> UICollectionViewCompositionalLayout {
|
||
UICollectionViewCompositionalLayout { [weak self] sectionIndex, layoutEnvironment in
|
||
guard let self,
|
||
self.dataSource != nil,
|
||
self.dataSource.snapshot().sectionIdentifiers.indices.contains(sectionIndex) else {
|
||
return nil
|
||
}
|
||
switch self.dataSource.snapshot().sectionIdentifiers[sectionIndex] {
|
||
case .overview:
|
||
let size = NSCollectionLayoutSize(
|
||
widthDimension: .fractionalWidth(1),
|
||
heightDimension: .absolute(self.viewModel.shouldShowInitialTips ? 150 : 82)
|
||
)
|
||
let item = NSCollectionLayoutItem(layoutSize: size)
|
||
let group = NSCollectionLayoutGroup.vertical(layoutSize: size, subitems: [item])
|
||
return NSCollectionLayoutSection(group: group)
|
||
case .templates:
|
||
let availableWidth = layoutEnvironment.container.effectiveContentSize.width
|
||
let cardWidth = min(118, floor((availableWidth - 56) / 3))
|
||
let itemSize = NSCollectionLayoutSize(
|
||
widthDimension: .absolute(cardWidth),
|
||
heightDimension: .absolute(156)
|
||
)
|
||
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
||
let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [item])
|
||
let section = NSCollectionLayoutSection(group: group)
|
||
section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary
|
||
section.interGroupSpacing = 10
|
||
section.contentInsets = NSDirectionalEdgeInsets(
|
||
top: 4,
|
||
leading: 18,
|
||
bottom: 14,
|
||
trailing: 18
|
||
)
|
||
section.boundarySupplementaryItems = [
|
||
NSCollectionLayoutBoundarySupplementaryItem(
|
||
layoutSize: NSCollectionLayoutSize(
|
||
widthDimension: .fractionalWidth(1),
|
||
heightDimension: .absolute(40)
|
||
),
|
||
elementKind: UICollectionView.elementKindSectionHeader,
|
||
alignment: .top
|
||
),
|
||
]
|
||
return section
|
||
case .mode:
|
||
let itemSize = NSCollectionLayoutSize(
|
||
widthDimension: .fractionalWidth(1),
|
||
heightDimension: .absolute(78)
|
||
)
|
||
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
||
let group = NSCollectionLayoutGroup.vertical(layoutSize: itemSize, subitems: [item])
|
||
let section = NSCollectionLayoutSection(group: group)
|
||
section.contentInsets = NSDirectionalEdgeInsets(
|
||
top: 8,
|
||
leading: 18,
|
||
bottom: 18,
|
||
trailing: 18
|
||
)
|
||
return section
|
||
}
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
private func applyViewModel() {
|
||
let isLoadFailure = viewModel.loadErrorMessage != nil
|
||
statusContainer.isHidden = !viewModel.isLoading && !isLoadFailure
|
||
collectionView.isHidden = viewModel.isLoading || isLoadFailure
|
||
if viewModel.isLoading {
|
||
statusIndicator.startAnimating()
|
||
statusLabel.text = "正在加载修图模板…"
|
||
retryButton.isHidden = true
|
||
} else if let message = viewModel.loadErrorMessage {
|
||
statusIndicator.stopAnimating()
|
||
statusLabel.text = message
|
||
retryButton.isHidden = false
|
||
} else {
|
||
statusIndicator.stopAnimating()
|
||
statusLabel.text = nil
|
||
retryButton.isHidden = true
|
||
}
|
||
|
||
validationLabel.text = viewModel.validationMessage
|
||
validationLabel.isHidden = viewModel.validationMessage == nil
|
||
cancelButton.isEnabled = !viewModel.isSubmitting
|
||
confirmButton.isEnabled = viewModel.canSubmit
|
||
confirmButton.alpha = viewModel.canSubmit ? 1 : 0.45
|
||
confirmButton.setGradientEnabled(viewModel.canSubmit)
|
||
confirmButton.setTitle(viewModel.isSubmitting ? "提交中" : "确定", for: .normal)
|
||
confirmButton.setLoading(viewModel.isSubmitting)
|
||
isModalInPresentation = viewModel.isSubmitting
|
||
applySnapshot()
|
||
}
|
||
|
||
private func applySnapshot() {
|
||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||
snapshot.appendSections([.overview])
|
||
snapshot.appendItems([.overview], toSection: .overview)
|
||
for category in viewModel.visibleCategories {
|
||
appendTemplates(category, to: &snapshot)
|
||
}
|
||
snapshot.appendSections([.mode])
|
||
snapshot.appendItems([.mode], toSection: .mode)
|
||
snapshot.reconfigureItems(snapshot.itemIdentifiers)
|
||
dataSource.apply(snapshot, animatingDifferences: true)
|
||
}
|
||
|
||
private func appendTemplates(
|
||
_ category: TravelAlbumAIRetouchTemplateCategory,
|
||
to snapshot: inout NSDiffableDataSourceSnapshot<Section, Item>
|
||
) {
|
||
let section = Section.templates(category)
|
||
snapshot.appendSections([section])
|
||
snapshot.appendItems(
|
||
viewModel.templates(for: category).map { Item.template(category, $0) },
|
||
toSection: section
|
||
)
|
||
}
|
||
|
||
@objc private func cancelTapped() {
|
||
guard !viewModel.isSubmitting else { return }
|
||
dismiss(animated: true)
|
||
}
|
||
|
||
@objc private func confirmTapped() {
|
||
guard viewModel.canSubmit else {
|
||
if let message = viewModel.validationMessage { showToast(message) }
|
||
return
|
||
}
|
||
Task { await viewModel.submit(api: api) }
|
||
}
|
||
|
||
@objc private func retryTapped() {
|
||
Task { await viewModel.loadTemplates(api: api) }
|
||
}
|
||
|
||
private func showPreview(for template: TravelAlbumAIRetouchTemplate) {
|
||
let controller = TravelAlbumAIRetouchTemplatePreviewViewController(template: template)
|
||
controller.modalPresentationStyle = .fullScreen
|
||
present(controller, animated: true)
|
||
}
|
||
}
|
||
|
||
extension TravelAlbumAIRetouchTemplateViewController: UICollectionViewDelegate {
|
||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||
guard let item = dataSource.itemIdentifier(for: indexPath),
|
||
case .template(let category, let template) = item else { return }
|
||
collectionView.scrollToItem(
|
||
at: indexPath,
|
||
at: .centeredHorizontally,
|
||
animated: !UIAccessibility.isReduceMotionEnabled
|
||
)
|
||
viewModel.toggleTemplate(id: template.id, category: category)
|
||
}
|
||
}
|
||
|
||
/// AI 修图模板页顶部说明卡,随模板列表一起滚动。
|
||
final class TravelAlbumAIRetouchOverviewCell: UICollectionViewCell {
|
||
static let reuseIdentifier = "TravelAlbumAIRetouchOverviewCell"
|
||
|
||
private let stackView = UIStackView()
|
||
private let titleLabel = UILabel()
|
||
private let selectionCountLabel = UILabel()
|
||
private let tipsContainer = UIView()
|
||
private let tipsIconContainer = UIView()
|
||
private let tipsIconView = UIImageView()
|
||
private let tipsLabel = UILabel()
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
titleLabel.text = "AI修图"
|
||
titleLabel.textColor = AIRetouchTemplateStyle.textPrimary
|
||
titleLabel.font = .systemFont(ofSize: 22, weight: .bold)
|
||
titleLabel.textAlignment = .center
|
||
titleLabel.accessibilityTraits = .header
|
||
|
||
selectionCountLabel.textColor = AIRetouchTemplateStyle.textSecondary
|
||
selectionCountLabel.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(
|
||
for: .systemFont(ofSize: 15, weight: .regular)
|
||
)
|
||
selectionCountLabel.adjustsFontForContentSizeCategory = true
|
||
selectionCountLabel.textAlignment = .center
|
||
selectionCountLabel.accessibilityIdentifier = "travelAlbum.aiRetouchSelectionCountLabel"
|
||
|
||
tipsContainer.backgroundColor = AIRetouchTemplateStyle.tipsBackground
|
||
tipsContainer.layer.cornerRadius = 12
|
||
tipsContainer.layer.borderWidth = 1
|
||
tipsContainer.layer.borderColor = AIRetouchTemplateStyle.tipsBorder.cgColor
|
||
tipsContainer.accessibilityIdentifier = "travelAlbum.aiRetouchTipsContainer"
|
||
|
||
tipsIconContainer.backgroundColor = AIRetouchTemplateStyle.primary
|
||
tipsIconContainer.layer.cornerRadius = 8
|
||
tipsIconView.image = UIImage(systemName: "wand.and.stars")
|
||
tipsIconView.tintColor = .white
|
||
tipsIconView.contentMode = .scaleAspectFit
|
||
|
||
tipsLabel.textColor = AIRetouchTemplateStyle.tipsText
|
||
tipsLabel.font = UIFontMetrics(forTextStyle: .footnote).scaledFont(
|
||
for: .systemFont(ofSize: 12, weight: .regular)
|
||
)
|
||
tipsLabel.adjustsFontForContentSizeCategory = true
|
||
tipsLabel.numberOfLines = 2
|
||
tipsLabel.accessibilityIdentifier = "travelAlbum.aiRetouchTipsLabel"
|
||
|
||
stackView.axis = .vertical
|
||
stackView.alignment = .fill
|
||
stackView.spacing = 2
|
||
stackView.addArrangedSubview(titleLabel)
|
||
stackView.addArrangedSubview(selectionCountLabel)
|
||
stackView.addArrangedSubview(tipsContainer)
|
||
stackView.setCustomSpacing(14, after: selectionCountLabel)
|
||
|
||
contentView.addSubview(stackView)
|
||
tipsContainer.addSubview(tipsIconContainer)
|
||
tipsIconContainer.addSubview(tipsIconView)
|
||
tipsContainer.addSubview(tipsLabel)
|
||
stackView.snp.makeConstraints { make in
|
||
make.top.equalToSuperview().offset(8)
|
||
make.leading.trailing.equalToSuperview().inset(18)
|
||
make.bottom.lessThanOrEqualToSuperview().offset(-8)
|
||
}
|
||
titleLabel.snp.makeConstraints { make in
|
||
make.height.equalTo(32)
|
||
}
|
||
selectionCountLabel.snp.makeConstraints { make in
|
||
make.height.equalTo(22)
|
||
}
|
||
tipsContainer.snp.makeConstraints { make in
|
||
make.height.equalTo(52)
|
||
}
|
||
tipsIconContainer.snp.makeConstraints { make in
|
||
make.leading.equalToSuperview().offset(12)
|
||
make.centerY.equalToSuperview()
|
||
make.size.equalTo(32)
|
||
}
|
||
tipsIconView.snp.makeConstraints { make in
|
||
make.edges.equalToSuperview().inset(7)
|
||
}
|
||
tipsLabel.snp.makeConstraints { make in
|
||
make.leading.equalTo(tipsIconContainer.snp.trailing).offset(10)
|
||
make.trailing.equalToSuperview().offset(-12)
|
||
make.centerY.equalToSuperview()
|
||
}
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
/// 更新选中照片数量和当前工作流说明。
|
||
func apply(selectedPhotoCount: Int, tips: String?) {
|
||
selectionCountLabel.text = "已选择 \(selectedPhotoCount) 张照片"
|
||
tipsLabel.text = tips
|
||
tipsContainer.isHidden = tips == nil
|
||
tipsIconContainer.isHidden = tips == nil
|
||
tipsLabel.isHidden = tips == nil
|
||
}
|
||
}
|
||
|
||
/// AI 修图模板卡片,展示模板预览、名称和明确选中态。
|
||
final class TravelAlbumAIRetouchTemplateCell: UICollectionViewCell {
|
||
static let reuseIdentifier = "TravelAlbumAIRetouchTemplateCell"
|
||
|
||
private let previewImageView = UIImageView()
|
||
private let nameLabel = UILabel()
|
||
private let checkView = UIImageView()
|
||
private let previewButton = AIRetouchTemplatePreviewButton(type: .system)
|
||
|
||
var onPreviewTapped: (() -> Void)?
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
contentView.backgroundColor = AIRetouchTemplateStyle.cardBackground
|
||
contentView.layer.cornerRadius = 12
|
||
contentView.layer.borderWidth = 1
|
||
contentView.clipsToBounds = true
|
||
layer.shadowColor = UIColor(hex: 0x254A7A).cgColor
|
||
layer.shadowOpacity = 0.08
|
||
layer.shadowRadius = 7
|
||
layer.shadowOffset = CGSize(width: 0, height: 3)
|
||
layer.masksToBounds = false
|
||
|
||
previewImageView.contentMode = .scaleAspectFill
|
||
previewImageView.clipsToBounds = true
|
||
previewImageView.backgroundColor = UIColor(hex: 0xEDF2F8)
|
||
previewImageView.accessibilityIdentifier = "travelAlbum.aiRetouchTemplateImage"
|
||
nameLabel.font = .systemFont(ofSize: 13, weight: .semibold)
|
||
nameLabel.textColor = AIRetouchTemplateStyle.textPrimary
|
||
nameLabel.textAlignment = .center
|
||
nameLabel.lineBreakMode = .byTruncatingTail
|
||
checkView.image = UIImage(systemName: "checkmark.circle.fill")
|
||
checkView.tintColor = AIRetouchTemplateStyle.primary
|
||
checkView.backgroundColor = .white
|
||
checkView.layer.cornerRadius = 11
|
||
var previewConfiguration = UIButton.Configuration.filled()
|
||
previewConfiguration.title = "预览"
|
||
previewConfiguration.baseForegroundColor = .white
|
||
previewConfiguration.baseBackgroundColor = UIColor.black.withAlphaComponent(0.58)
|
||
previewConfiguration.background.cornerRadius = 14
|
||
previewConfiguration.contentInsets = .zero
|
||
previewConfiguration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer {
|
||
attributes in
|
||
var attributes = attributes
|
||
attributes.font = .systemFont(ofSize: 12, weight: .regular)
|
||
return attributes
|
||
}
|
||
previewButton.configuration = previewConfiguration
|
||
previewButton.accessibilityIdentifier = "travelAlbum.aiRetouchTemplatePreviewButton"
|
||
previewButton.addTarget(self, action: #selector(previewTapped), for: .touchUpInside)
|
||
|
||
contentView.addSubview(previewImageView)
|
||
contentView.addSubview(nameLabel)
|
||
contentView.addSubview(checkView)
|
||
contentView.addSubview(previewButton)
|
||
previewImageView.snp.makeConstraints { make in
|
||
make.top.leading.trailing.equalToSuperview()
|
||
make.height.equalTo(118)
|
||
}
|
||
nameLabel.snp.makeConstraints { make in
|
||
make.top.equalTo(previewImageView.snp.bottom)
|
||
make.leading.trailing.equalToSuperview().inset(6)
|
||
make.bottom.equalToSuperview()
|
||
}
|
||
checkView.snp.makeConstraints { make in
|
||
make.top.trailing.equalToSuperview().inset(7)
|
||
make.size.equalTo(22)
|
||
}
|
||
previewButton.snp.makeConstraints { make in
|
||
make.trailing.bottom.equalTo(previewImageView).inset(4)
|
||
make.width.equalTo(48)
|
||
make.height.equalTo(28)
|
||
}
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
override func prepareForReuse() {
|
||
super.prepareForReuse()
|
||
previewImageView.kf.cancelDownloadTask()
|
||
previewImageView.image = nil
|
||
onPreviewTapped = nil
|
||
}
|
||
|
||
/// 更新模板内容与选中状态。
|
||
func apply(template: TravelAlbumAIRetouchTemplate, selected: Bool) {
|
||
nameLabel.text = template.name
|
||
let placeholder = UIImage(named: "ai_retouch_template_placeholder")
|
||
if let url = URL(string: template.previewURL), !template.previewURL.isEmpty {
|
||
previewImageView.contentMode = .scaleAspectFill
|
||
previewImageView.kf.setImage(
|
||
with: url,
|
||
placeholder: placeholder
|
||
)
|
||
} else {
|
||
previewImageView.image = placeholder
|
||
previewImageView.contentMode = .scaleAspectFill
|
||
}
|
||
contentView.layer.borderColor = (
|
||
selected ? AIRetouchTemplateStyle.primary : AIRetouchTemplateStyle.border
|
||
).cgColor
|
||
contentView.layer.borderWidth = selected ? 2 : 1
|
||
layer.shadowOpacity = selected ? 0.16 : 0.08
|
||
checkView.isHidden = !selected
|
||
isSelected = selected
|
||
accessibilityLabel = template.name
|
||
accessibilityValue = selected ? "已选择" : "未选择"
|
||
accessibilityTraits = selected ? [.button, .selected] : [.button]
|
||
}
|
||
|
||
@objc private func previewTapped() {
|
||
onPreviewTapped?()
|
||
}
|
||
}
|
||
|
||
/// 扩大模板预览按钮的触摸区域,同时保持截图中的胶囊尺寸。
|
||
private final class AIRetouchTemplatePreviewButton: UIButton {
|
||
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
|
||
bounds.insetBy(dx: -8, dy: -8).contains(point)
|
||
}
|
||
}
|
||
|
||
/// 模板选择页主操作按钮,使用品牌蓝渐变并支持禁用态切换。
|
||
private final class AIRetouchGradientButton: UIButton {
|
||
private let gradientLayer = CAGradientLayer()
|
||
private let activityIndicator = UIActivityIndicatorView(style: .medium)
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
gradientLayer.colors = [
|
||
AIRetouchTemplateStyle.primary.cgColor,
|
||
AIRetouchTemplateStyle.primaryGradientEnd.cgColor,
|
||
]
|
||
gradientLayer.startPoint = CGPoint(x: 0, y: 0.5)
|
||
gradientLayer.endPoint = CGPoint(x: 1, y: 0.5)
|
||
gradientLayer.cornerRadius = 12
|
||
layer.insertSublayer(gradientLayer, at: 0)
|
||
activityIndicator.color = .white
|
||
activityIndicator.hidesWhenStopped = true
|
||
activityIndicator.isUserInteractionEnabled = false
|
||
addSubview(activityIndicator)
|
||
clipsToBounds = true
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
override func layoutSubviews() {
|
||
super.layoutSubviews()
|
||
gradientLayer.frame = bounds
|
||
activityIndicator.center = CGPoint(x: bounds.midX - 44, y: bounds.midY)
|
||
}
|
||
|
||
/// 根据提交条件切换品牌渐变和禁用灰色。
|
||
func setGradientEnabled(_ enabled: Bool) {
|
||
gradientLayer.colors = enabled
|
||
? [
|
||
AIRetouchTemplateStyle.primary.cgColor,
|
||
AIRetouchTemplateStyle.primaryGradientEnd.cgColor,
|
||
]
|
||
: [
|
||
AIRetouchTemplateStyle.disabledStart.cgColor,
|
||
AIRetouchTemplateStyle.disabledEnd.cgColor,
|
||
]
|
||
}
|
||
|
||
/// 切换提交中状态,并在标题左侧展示活动指示器。
|
||
func setLoading(_ loading: Bool) {
|
||
if loading {
|
||
activityIndicator.startAnimating()
|
||
} else {
|
||
activityIndicator.stopAnimating()
|
||
}
|
||
setNeedsLayout()
|
||
}
|
||
}
|
||
|
||
/// AI 修图计费模式卡,展示按张收费规则与剩余额度。
|
||
final class TravelAlbumAIRetouchModeCell: UICollectionViewCell {
|
||
static let reuseIdentifier = "TravelAlbumAIRetouchModeCell"
|
||
|
||
private let iconContainer = UIView()
|
||
private let iconView = UIImageView()
|
||
private let modeLabel = UILabel()
|
||
private let quotaStack = UIStackView()
|
||
private let requiredQuotaContainer = UIView()
|
||
private let requiredQuotaLabel = UILabel()
|
||
private let quotaLabel = UILabel()
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
contentView.backgroundColor = .white
|
||
contentView.layer.cornerRadius = 12
|
||
contentView.layer.borderWidth = 1
|
||
contentView.layer.borderColor = AIRetouchTemplateStyle.border.cgColor
|
||
contentView.layer.shadowColor = UIColor(hex: 0x254A7A).cgColor
|
||
contentView.layer.shadowOpacity = 0.06
|
||
contentView.layer.shadowRadius = 8
|
||
contentView.layer.shadowOffset = CGSize(width: 0, height: 3)
|
||
|
||
iconContainer.backgroundColor = AIRetouchTemplateStyle.primary
|
||
iconContainer.layer.cornerRadius = 10
|
||
iconView.image = UIImage(systemName: "wand.and.stars")
|
||
iconView.tintColor = .white
|
||
iconView.contentMode = .scaleAspectFit
|
||
modeLabel.text = "AI精修 · 按张收费"
|
||
modeLabel.textColor = AIRetouchTemplateStyle.textPrimary
|
||
modeLabel.font = .systemFont(ofSize: 15, weight: .semibold)
|
||
|
||
quotaStack.axis = .vertical
|
||
quotaStack.alignment = .trailing
|
||
quotaStack.spacing = 3
|
||
requiredQuotaContainer.backgroundColor = AIRetouchTemplateStyle.primary.withAlphaComponent(0.1)
|
||
requiredQuotaContainer.layer.cornerRadius = 12
|
||
requiredQuotaLabel.font = .monospacedDigitSystemFont(ofSize: 12, weight: .semibold)
|
||
requiredQuotaLabel.textColor = AIRetouchTemplateStyle.primary
|
||
requiredQuotaLabel.textAlignment = .center
|
||
requiredQuotaLabel.accessibilityIdentifier = "travelAlbum.aiRetouchRequiredQuotaLabel"
|
||
quotaLabel.font = .monospacedDigitSystemFont(ofSize: 12, weight: .regular)
|
||
quotaLabel.textColor = AIRetouchTemplateStyle.textSecondary
|
||
quotaLabel.textAlignment = .right
|
||
quotaLabel.accessibilityIdentifier = "travelAlbum.aiRetouchRemainingQuotaLabel"
|
||
|
||
contentView.addSubview(iconContainer)
|
||
iconContainer.addSubview(iconView)
|
||
contentView.addSubview(modeLabel)
|
||
contentView.addSubview(quotaStack)
|
||
quotaStack.addArrangedSubview(requiredQuotaContainer)
|
||
requiredQuotaContainer.addSubview(requiredQuotaLabel)
|
||
quotaStack.addArrangedSubview(quotaLabel)
|
||
iconContainer.snp.makeConstraints { make in
|
||
make.leading.equalToSuperview().offset(14)
|
||
make.centerY.equalToSuperview()
|
||
make.size.equalTo(38)
|
||
}
|
||
iconView.snp.makeConstraints { make in
|
||
make.edges.equalToSuperview().inset(9)
|
||
}
|
||
modeLabel.snp.makeConstraints { make in
|
||
make.leading.equalTo(iconContainer.snp.trailing).offset(10)
|
||
make.centerY.equalToSuperview()
|
||
make.trailing.lessThanOrEqualTo(quotaStack.snp.leading).offset(-10)
|
||
}
|
||
quotaStack.snp.makeConstraints { make in
|
||
make.trailing.equalToSuperview().offset(-14)
|
||
make.centerY.equalToSuperview()
|
||
}
|
||
requiredQuotaContainer.snp.makeConstraints { make in
|
||
make.height.equalTo(24)
|
||
make.width.greaterThanOrEqualTo(108)
|
||
}
|
||
requiredQuotaLabel.snp.makeConstraints { make in
|
||
make.edges.equalToSuperview()
|
||
}
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
/// 更新预计消耗和剩余精修张数,并在额度不足时切换为警示色。
|
||
func apply(requiredQuota: Int, remainingQuota: Int?, insufficient: Bool) {
|
||
requiredQuotaLabel.text = "预计消耗 \(requiredQuota) 张"
|
||
requiredQuotaLabel.accessibilityLabel = "预计消耗\(requiredQuota)张修图额度"
|
||
quotaLabel.text = remainingQuota.map { "剩余 \($0) 张" } ?? "剩余 -- 张"
|
||
quotaLabel.accessibilityLabel = remainingQuota.map { "剩余\($0)张修图额度" }
|
||
?? "剩余修图额度暂不可用"
|
||
requiredQuotaLabel.textColor = insufficient
|
||
? AIRetouchTemplateStyle.danger
|
||
: AIRetouchTemplateStyle.primary
|
||
requiredQuotaContainer.backgroundColor = requiredQuotaLabel.textColor.withAlphaComponent(0.1)
|
||
quotaLabel.textColor = insufficient ? AIRetouchTemplateStyle.danger : AIRetouchTemplateStyle.textSecondary
|
||
}
|
||
}
|
||
|
||
/// 模板分组标题右侧的业务标记。
|
||
fileprivate enum AIRetouchTemplateSectionBadge {
|
||
case required
|
||
case optional
|
||
case gift
|
||
}
|
||
|
||
/// AI 修图模板分组标题,可附带选填或赠送标记。
|
||
final class TravelAlbumAIRetouchSectionHeader: UICollectionReusableView {
|
||
static let reuseIdentifier = "TravelAlbumAIRetouchSectionHeader"
|
||
|
||
private let titleLabel = UILabel()
|
||
private let optionalLabel = UILabel()
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
|
||
titleLabel.textColor = AIRetouchTemplateStyle.textPrimary
|
||
optionalLabel.font = .systemFont(ofSize: 11, weight: .medium)
|
||
optionalLabel.textAlignment = .center
|
||
optionalLabel.layer.cornerRadius = 10
|
||
optionalLabel.clipsToBounds = true
|
||
|
||
addSubview(titleLabel)
|
||
addSubview(optionalLabel)
|
||
titleLabel.snp.makeConstraints { make in
|
||
make.leading.equalToSuperview().offset(18)
|
||
make.centerY.equalToSuperview()
|
||
}
|
||
optionalLabel.snp.makeConstraints { make in
|
||
make.leading.equalTo(titleLabel.snp.trailing).offset(8)
|
||
make.centerY.equalTo(titleLabel)
|
||
make.width.greaterThanOrEqualTo(38)
|
||
make.height.equalTo(20)
|
||
make.trailing.lessThanOrEqualToSuperview().offset(-18)
|
||
}
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
/// 更新分组标题与业务标记。
|
||
fileprivate func apply(title: String, badge: AIRetouchTemplateSectionBadge?) {
|
||
titleLabel.text = title
|
||
optionalLabel.isHidden = badge == nil
|
||
switch badge {
|
||
case .required:
|
||
optionalLabel.text = " 必选 "
|
||
optionalLabel.textColor = AIRetouchTemplateStyle.primary
|
||
optionalLabel.backgroundColor = AIRetouchTemplateStyle.primary.withAlphaComponent(0.1)
|
||
case .optional:
|
||
optionalLabel.text = " 选填 "
|
||
optionalLabel.textColor = AIRetouchTemplateStyle.primary
|
||
optionalLabel.backgroundColor = AIRetouchTemplateStyle.primary.withAlphaComponent(0.1)
|
||
case .gift:
|
||
optionalLabel.text = " 赠送 · 不占额度 "
|
||
optionalLabel.textColor = AIRetouchTemplateStyle.gift
|
||
optionalLabel.backgroundColor = AIRetouchTemplateStyle.giftBackground
|
||
case nil:
|
||
optionalLabel.text = nil
|
||
}
|
||
let badgeText = optionalLabel.text?.trimmingCharacters(in: .whitespaces)
|
||
accessibilityLabel = badgeText.map { "\(title),\($0)" } ?? title
|
||
accessibilityTraits = .header
|
||
}
|
||
}
|
||
|
||
/// AI 修图模板全屏预览,支持双指缩放查看模板细节。
|
||
final class TravelAlbumAIRetouchTemplatePreviewViewController: UIViewController {
|
||
private let template: TravelAlbumAIRetouchTemplate
|
||
private let backButton = UIButton(type: .system)
|
||
private let titleLabel = UILabel()
|
||
private let subtitleLabel = UILabel()
|
||
private let scrollView = UIScrollView()
|
||
private let imageView = UIImageView()
|
||
|
||
/// 创建指定模板的全屏预览页。
|
||
init(template: TravelAlbumAIRetouchTemplate) {
|
||
self.template = template
|
||
super.init(nibName: nil, bundle: nil)
|
||
modalPresentationStyle = .fullScreen
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
override func viewDidLoad() {
|
||
super.viewDidLoad()
|
||
setupUI()
|
||
setupConstraints()
|
||
loadPreview()
|
||
}
|
||
|
||
private func setupUI() {
|
||
view.backgroundColor = .black
|
||
view.accessibilityIdentifier = "travelAlbum.aiRetouchTemplatePreview"
|
||
|
||
var backConfiguration = UIButton.Configuration.filled()
|
||
backConfiguration.image = UIImage(systemName: "chevron.left")
|
||
backConfiguration.baseForegroundColor = .white
|
||
backConfiguration.baseBackgroundColor = UIColor.white.withAlphaComponent(0.12)
|
||
backConfiguration.background.cornerRadius = 24
|
||
backButton.configuration = backConfiguration
|
||
backButton.accessibilityLabel = "返回模板选择"
|
||
backButton.accessibilityIdentifier = "travelAlbum.aiRetouchTemplatePreviewBackButton"
|
||
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
|
||
|
||
titleLabel.text = template.name
|
||
titleLabel.textColor = .white
|
||
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
|
||
titleLabel.textAlignment = .center
|
||
titleLabel.lineBreakMode = .byTruncatingTail
|
||
|
||
subtitleLabel.text = "双指缩放查看细节"
|
||
subtitleLabel.textColor = UIColor.white.withAlphaComponent(0.52)
|
||
subtitleLabel.font = .systemFont(ofSize: 14, weight: .regular)
|
||
subtitleLabel.textAlignment = .center
|
||
|
||
scrollView.minimumZoomScale = 1
|
||
scrollView.maximumZoomScale = 4
|
||
scrollView.delegate = self
|
||
scrollView.showsHorizontalScrollIndicator = false
|
||
scrollView.showsVerticalScrollIndicator = false
|
||
scrollView.accessibilityIdentifier = "travelAlbum.aiRetouchTemplatePreviewScrollView"
|
||
imageView.contentMode = .scaleAspectFit
|
||
imageView.clipsToBounds = true
|
||
imageView.backgroundColor = UIColor(hex: 0x111111)
|
||
imageView.accessibilityLabel = "\(template.name)模板预览图"
|
||
|
||
view.addSubview(backButton)
|
||
view.addSubview(titleLabel)
|
||
view.addSubview(subtitleLabel)
|
||
view.addSubview(scrollView)
|
||
scrollView.addSubview(imageView)
|
||
}
|
||
|
||
private func setupConstraints() {
|
||
backButton.snp.makeConstraints { make in
|
||
make.top.equalTo(view.safeAreaLayoutGuide).offset(20)
|
||
make.leading.equalToSuperview().offset(18)
|
||
make.size.equalTo(48)
|
||
}
|
||
titleLabel.snp.makeConstraints { make in
|
||
make.top.equalTo(view.safeAreaLayoutGuide).offset(20)
|
||
make.leading.greaterThanOrEqualTo(backButton.snp.trailing).offset(12)
|
||
make.centerX.equalToSuperview()
|
||
make.trailing.lessThanOrEqualToSuperview().offset(-66)
|
||
make.height.equalTo(26)
|
||
}
|
||
subtitleLabel.snp.makeConstraints { make in
|
||
make.top.equalTo(titleLabel.snp.bottom).offset(2)
|
||
make.centerX.equalToSuperview()
|
||
}
|
||
scrollView.snp.makeConstraints { make in
|
||
make.top.equalTo(subtitleLabel.snp.bottom).offset(18)
|
||
make.leading.trailing.bottom.equalToSuperview()
|
||
}
|
||
imageView.snp.makeConstraints { make in
|
||
make.center.equalTo(scrollView.frameLayoutGuide)
|
||
make.width.equalTo(scrollView.frameLayoutGuide)
|
||
make.height.equalTo(imageView.snp.width).multipliedBy(0.75)
|
||
}
|
||
}
|
||
|
||
private func loadPreview() {
|
||
let placeholder = UIImage(named: "ai_retouch_template_placeholder")
|
||
guard let url = URL(string: template.previewURL), !template.previewURL.isEmpty else {
|
||
imageView.image = placeholder
|
||
return
|
||
}
|
||
imageView.kf.setImage(
|
||
with: url,
|
||
placeholder: placeholder
|
||
)
|
||
}
|
||
|
||
@objc private func backTapped() {
|
||
dismiss(animated: true)
|
||
}
|
||
}
|
||
|
||
extension TravelAlbumAIRetouchTemplatePreviewViewController: UIScrollViewDelegate {
|
||
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
|
||
imageView
|
||
}
|
||
}
|
||
|
||
/// AI 修图模板页视觉常量。
|
||
private enum AIRetouchTemplateStyle {
|
||
static let primary = UIColor(hex: 0x1677FF)
|
||
static let primaryGradientEnd = UIColor(hex: 0x4B9BFF)
|
||
static let pageBackground = UIColor(hex: 0xF7FAFF)
|
||
static let cardBackground = UIColor.white
|
||
static let textPrimary = UIColor(hex: 0x172033)
|
||
static let textSecondary = UIColor(hex: 0x7D8799)
|
||
static let tipsBackground = UIColor(hex: 0xF4F8FF)
|
||
static let tipsBorder = UIColor(hex: 0xD9E7FA)
|
||
static let tipsText = UIColor(hex: 0x385778)
|
||
static let gift = UIColor(hex: 0xFF9418)
|
||
static let giftBackground = UIColor(hex: 0xFFF1D9)
|
||
static let border = UIColor(hex: 0xDCE4EF)
|
||
static let danger = UIColor(hex: 0xE53935)
|
||
static let disabledStart = UIColor(hex: 0xA9C8EE)
|
||
static let disabledEnd = UIColor(hex: 0xC6DAF3)
|
||
}
|