feat: 品牌化那拉提景区收款页

This commit is contained in:
2026-07-23 15:39:34 +08:00
parent 74f7370aab
commit 787a166263
13 changed files with 840 additions and 31 deletions

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "payment_nalati_background.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "payment_nalati_logo.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

View File

@ -9,10 +9,40 @@ import Foundation
struct RolePermissionResponse: Codable, Equatable, Sendable {
let role: RoleInfo
let scenic: [ScenicInfo]
let store: [RolePermissionStoreInfo]
init(role: RoleInfo, scenic: [ScenicInfo] = []) {
enum CodingKeys: String, CodingKey {
case role
case scenic
case store
}
init(
role: RoleInfo,
scenic: [ScenicInfo] = [],
store: [RolePermissionStoreInfo] = []
) {
self.role = role
self.scenic = scenic
self.store = store
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
role = try container.decode(RoleInfo.self, forKey: .role)
scenic = try container.decodeIfPresent([ScenicInfo].self, forKey: .scenic) ?? []
store = try container.decodeIfPresent([RolePermissionStoreInfo].self, forKey: .store) ?? []
}
}
/// 使 `role-permission` `store`
struct RolePermissionStoreInfo: Codable, Equatable, Sendable {
let id: Int
let name: String
init(id: Int = 0, name: String = "") {
self.id = id
self.name = name
}
}

View File

@ -25,6 +25,17 @@ final class PaymentAPI {
)
}
/// Logo
func payPageConfig(scenicId: Int) async throws -> PayPageConfigResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/pay-page-config",
queryItems: [URLQueryItem(name: "scenic_id", value: String(scenicId))]
)
)
}
/// 7
func collectionRecord(scenicId: Int) async throws -> RepaymentCollectionRecordResponse {
try await client.send(

View File

@ -5,6 +5,83 @@
import Foundation
///
struct PayPageConfigResponse: Decodable, Sendable, Equatable {
let background: String
let logo: String
let title: String
let subtitle: String
init(background: String, logo: String, title: String, subtitle: String) {
self.background = background
self.logo = logo
self.title = title
self.subtitle = subtitle
}
}
///
struct PayPageConfig: Codable, Sendable, Equatable {
let background: String
let logo: String
let title: String
let subtitle: String
///
static let nalatiDefault = PayPageConfig(
background: "",
logo: "",
title: "那拉提旅拍管理收费平台",
subtitle: "那拉提景区"
)
///
func merging(_ response: PayPageConfigResponse) -> PayPageConfig {
PayPageConfig(
background: Self.validRemoteImageURL(response.background) ?? background,
logo: Self.validRemoteImageURL(response.logo) ?? logo,
title: response.title.trimmedNonEmpty ?? title,
subtitle: response.subtitle.trimmedNonEmpty ?? subtitle
)
}
private static func validRemoteImageURL(_ value: String) -> String? {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard let url = URL(string: trimmed),
url.scheme?.lowercased() == "https",
url.host != nil else {
return nil
}
return trimmed
}
}
///
enum PayPageBrandingPolicy {
/// ID
static let nalatiScenicId = 128
///
static func isNalati(scenicId: Int) -> Bool {
scenicId == nalatiScenicId
}
}
///
enum PaymentAccountDisplayFormatter {
/// 使
static func photographerName(userName: String, realName: String) -> String {
userName.trimmedNonEmpty ?? realName.trimmedNonEmpty ?? "-"
}
///
static func storeNames(_ names: [String]) -> String {
var seen = Set<String>()
let resolved = names.compactMap(\.trimmedNonEmpty).filter { seen.insert($0).inserted }
return resolved.isEmpty ? "-" : resolved.joined(separator: "")
}
}
/// URL
struct PayCodeResponse: Decodable, Sendable, Equatable {
let staticPayURL: String
@ -101,3 +178,10 @@ struct PaymentMerchantInfo: Sendable, Equatable {
company: "扬州元智享网络科技有限公司"
)
}
private extension String {
var trimmedNonEmpty: String? {
let value = trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
}

View File

@ -0,0 +1,51 @@
//
// PayPageConfigCache.swift
// suixinkan
//
import Foundation
/// ViewModel
protocol PayPageConfigCaching {
///
func config(scenicId: Int) -> PayPageConfig?
///
func save(_ config: PayPageConfig, scenicId: Int)
}
/// 使 UserDefaults
final class PayPageConfigCache: PayPageConfigCaching {
private static let version = "v1"
private let defaults: UserDefaults
private let environmentNamespace: String
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
/// 使 API
init(
defaults: UserDefaults = .standard,
environment: APIEnvironment = .current
) {
self.defaults = defaults
environmentNamespace = environment.baseURL.host ?? environment.baseURL.absoluteString
}
func config(scenicId: Int) -> PayPageConfig? {
guard scenicId > 0,
let data = defaults.data(forKey: key(scenicId: scenicId)) else {
return nil
}
return try? decoder.decode(PayPageConfig.self, from: data)
}
func save(_ config: PayPageConfig, scenicId: Int) {
guard scenicId > 0, let data = try? encoder.encode(config) else { return }
defaults.set(data, forKey: key(scenicId: scenicId))
}
private func key(scenicId: Int) -> String {
"payment.pay_page_config.\(Self.version).\(environmentNamespace).\(scenicId)"
}
}

View File

@ -17,6 +17,9 @@ final class PaymentCollectionDetailsViewModel {
private(set) var showAmountDialog = false
private(set) var amount = ""
private(set) var remark = ""
private(set) var scenicId = 0
private(set) var payPageConfig = PayPageConfig.nalatiDefault
private(set) var brandingRefreshVersion = 0
let merchantInfo = PaymentMerchantInfo.defaultInfo
@ -26,9 +29,21 @@ final class PaymentCollectionDetailsViewModel {
private var staticPayURL = ""
private var dynamicPayURL = ""
private let appStore: AppStore
private let payPageConfigCache: PayPageConfigCaching
init(appStore: AppStore = .shared) {
init(
appStore: AppStore = .shared,
payPageConfigCache: PayPageConfigCaching = PayPageConfigCache()
) {
self.appStore = appStore
self.payPageConfigCache = payPageConfigCache
scenicId = appStore.session.currentScenicId
scenicName = appStore.session.currentScenicName
staffId = appStore.session.userId
isVoiceBroadcastOpen = appStore.payment.isOpenReceiveVoice
if PayPageBrandingPolicy.isNalati(scenicId: scenicId) {
payPageConfig = payPageConfigCache.config(scenicId: scenicId) ?? .nalatiDefault
}
}
var hasPayCode: Bool {
@ -39,10 +54,32 @@ final class PaymentCollectionDetailsViewModel {
scenicName.isEmpty ? "暂无景区" : scenicName
}
var usesNalatiBranding: Bool {
PayPageBrandingPolicy.isNalati(scenicId: scenicId)
}
var displayPhotographerName: String {
PaymentAccountDisplayFormatter.photographerName(
userName: appStore.session.userName,
realName: appStore.session.realName
)
}
var displayStoreNames: String {
let names = appStore.permissions.rolePermissionList().flatMap(\.store).map(\.name)
return PaymentAccountDisplayFormatter.storeNames(names)
}
func refreshLocalState() {
scenicId = appStore.session.currentScenicId
scenicName = appStore.session.currentScenicName
staffId = appStore.session.userId
isVoiceBroadcastOpen = appStore.payment.isOpenReceiveVoice
if usesNalatiBranding {
payPageConfig = payPageConfigCache.config(scenicId: scenicId) ?? .nalatiDefault
} else {
payPageConfig = .nalatiDefault
}
notifyStateChange()
}
@ -56,7 +93,7 @@ final class PaymentCollectionDetailsViewModel {
notifyStateChange()
}
let scenicId = appStore.session.currentScenicId
let scenicId = self.scenicId
guard scenicId > 0 else {
clearPayCode()
return
@ -72,8 +109,27 @@ final class PaymentCollectionDetailsViewModel {
}
}
func loadPayPageConfig(api: PaymentAPI) async {
let requestedScenicId = appStore.session.currentScenicId
guard PayPageBrandingPolicy.isNalati(scenicId: requestedScenicId) else { return }
do {
let response = try await api.payPageConfig(scenicId: requestedScenicId)
guard appStore.session.currentScenicId == requestedScenicId else { return }
let resolved = payPageConfig.merging(response)
payPageConfigCache.save(resolved, scenicId: requestedScenicId)
payPageConfig = resolved
brandingRefreshVersion += 1
notifyStateChange()
} catch {
//
}
}
func refresh(api: PaymentAPI) async {
await loadPayCode(api: api)
async let payCode: Void = loadPayCode(api: api)
async let pageConfig: Void = loadPayPageConfig(api: api)
_ = await (payCode, pageConfig)
}
func showSetAmountDialog() {

View File

@ -3,6 +3,7 @@
// suixinkan
//
import Kingfisher
import SnapKit
import UIKit
@ -13,10 +14,18 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
private let paymentAPI = NetworkServices.shared.paymentAPI
private let scrollView = UIScrollView()
private let contentContainerView = UIView()
private let contentStack = UIStackView()
private let backgroundImageView = UIImageView()
private let brandHeaderStack = UIStackView()
private let brandLogoImageView = UIImageView()
private let brandTitleLabel = UILabel()
private let brandSubtitleLabel = UILabel()
private let qrCardView = UIView()
private let scenicNameLabel = UILabel()
private let qrContainerView = UIView()
private let qrImageView = UIImageView()
private let qrPlaceholderView = UIView()
private let qrErrorLabel = UILabel()
@ -29,26 +38,75 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
private let scenicRow = PaymentInfoRowView(title: "景区名称")
private let merchantRow = PaymentInfoRowView(title: "收款方")
private let staffRow = PaymentInfoRowView(title: "员工 ID")
private let photographerRow = PaymentInfoRowView(title: "摄影师名称")
private let storeRow = PaymentInfoRowView(title: "店铺名称")
private let recordRow = PaymentNavigationRowView(title: "收款记录")
private let settingsCardView = UIView()
private let voiceCardView = UIView()
private let voiceTitleLabel = UILabel()
private let voiceSwitch = UISwitch()
private var amountDialog: PaymentSetAmountDialogView?
private var appliedBrandConfig: PayPageConfig?
private var appliedBrandingRefreshVersion = -1
private var previousStandardAppearance: UINavigationBarAppearance?
private var previousScrollEdgeAppearance: UINavigationBarAppearance?
private var previousCompactAppearance: UINavigationBarAppearance?
private var previousNavigationTintColor: UIColor?
private var previousNavigationTranslucency = false
private var brandScreenHeight: CGFloat {
UIScreen.main.bounds.height
}
private var brandLogoHeight: CGFloat {
if brandScreenHeight <= 700 { return 82 }
if brandScreenHeight <= 820 { return 96 }
return 112
}
private var brandQRContainerSize: CGFloat {
if brandScreenHeight <= 700 { return 170 }
if brandScreenHeight <= 820 { return 190 }
return 210
}
private var brandQRImageSize: CGFloat {
if brandScreenHeight <= 700 { return 146 }
if brandScreenHeight <= 820 { return 166 }
return 182
}
private var brandSectionSpacing: CGFloat {
if brandScreenHeight <= 700 { return 8 }
if brandScreenHeight <= 820 { return 10 }
return 12
}
private var brandHeaderToQRSpacing: CGFloat {
if brandScreenHeight <= 700 { return 16 }
if brandScreenHeight <= 820 { return 18 }
return 20
}
override func setupNavigationBar() {
title = "收款详情"
title = viewModel.usesNalatiBranding ? nil : "收款详情"
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackground
let usesBranding = viewModel.usesNalatiBranding
view.backgroundColor = usesBranding ? .clear : AppColor.pageBackground
scrollView.showsVerticalScrollIndicator = !usesBranding
scrollView.isScrollEnabled = !usesBranding
scrollView.alwaysBounceVertical = false
contentStack.axis = .vertical
contentStack.spacing = AppSpacing.md
contentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.md
qrCardView.backgroundColor = .white
qrCardView.backgroundColor = usesBranding ? .clear : .white
qrCardView.layer.cornerRadius = AppRadius.lg
scenicNameLabel.font = .systemFont(ofSize: 16, weight: .medium)
@ -57,9 +115,16 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
qrImageView.contentMode = .scaleAspectFit
qrImageView.isHidden = true
if usesBranding {
qrContainerView.backgroundColor = .white
qrContainerView.layer.cornerRadius = 10
qrContainerView.clipsToBounds = true
qrContainerView.isHidden = true
qrContainerView.addSubview(qrImageView)
}
qrPlaceholderView.backgroundColor = AppColor.inputBackground
qrPlaceholderView.layer.cornerRadius = 24
qrPlaceholderView.layer.cornerRadius = usesBranding ? 10 : 24
qrPlaceholderView.isHidden = true
qrErrorLabel.text = "未选景区或网络问题"
@ -73,19 +138,29 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
actionRow.axis = .horizontal
actionRow.alignment = .center
actionRow.spacing = AppSpacing.xl
actionRow.spacing = usesBranding ? AppSpacing.lg : AppSpacing.xl
if usesBranding {
actionRow.backgroundColor = UIColor.white.withAlphaComponent(0.88)
actionRow.layer.cornerRadius = 24
actionRow.isLayoutMarginsRelativeArrangement = true
actionRow.directionalLayoutMargins = NSDirectionalEdgeInsets(
top: 10,
leading: 24,
bottom: 10,
trailing: 24
)
}
configureLinkButton(setAmountButton, title: "设置金额")
configureLinkButton(saveQRButton, title: "保存二维码")
infoCardView.backgroundColor = .white
infoCardView.layer.cornerRadius = AppRadius.lg
configureCard(infoCardView, branded: usesBranding)
recordRow.backgroundColor = .white
recordRow.layer.cornerRadius = AppRadius.lg
recordRow.backgroundColor = usesBranding ? .clear : .white
recordRow.layer.cornerRadius = usesBranding ? 0 : AppRadius.lg
voiceCardView.backgroundColor = .white
voiceCardView.layer.cornerRadius = AppRadius.lg
voiceCardView.backgroundColor = usesBranding ? .clear : .white
voiceCardView.layer.cornerRadius = usesBranding ? 0 : AppRadius.lg
voiceTitleLabel.text = "收款到账语音提醒"
voiceTitleLabel.font = .systemFont(ofSize: 14)
@ -93,49 +168,112 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
voiceSwitch.onTintColor = AppColor.primary
view.addSubview(scrollView)
scrollView.addSubview(contentStack)
if usesBranding {
configureBrandHeader()
backgroundImageView.contentMode = .scaleAspectFill
backgroundImageView.clipsToBounds = true
backgroundImageView.image = UIImage(named: "payment_nalati_background")
view.addSubview(backgroundImageView)
}
view.addSubview(scrollView)
scrollView.addSubview(contentContainerView)
contentContainerView.addSubview(contentStack)
if usesBranding {
contentStack.addArrangedSubview(brandHeaderStack)
contentStack.setCustomSpacing(brandHeaderToQRSpacing, after: brandHeaderStack)
}
contentStack.addArrangedSubview(qrCardView)
contentStack.addArrangedSubview(infoCardView)
contentStack.addArrangedSubview(recordRow)
contentStack.addArrangedSubview(voiceCardView)
if usesBranding {
configureCard(settingsCardView, branded: true)
contentStack.addArrangedSubview(settingsCardView)
} else {
contentStack.addArrangedSubview(recordRow)
contentStack.addArrangedSubview(voiceCardView)
}
let qrDisplayView = usesBranding ? qrContainerView : qrImageView
let qrContentStack = UIStackView(arrangedSubviews: [
scenicNameLabel,
qrImageView,
qrDisplayView,
qrPlaceholderView,
actionRow,
])
qrContentStack.axis = .vertical
qrContentStack.alignment = .center
qrContentStack.spacing = AppSpacing.lg
qrContentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.lg
qrCardView.addSubview(qrContentStack)
qrPlaceholderView.addSubview(qrErrorLabel)
qrPlaceholderView.addSubview(refreshButton)
actionRow.addArrangedSubview(setAmountButton)
if usesBranding {
let divider = UIView()
divider.backgroundColor = UIColor(hex: 0xD6E1EE)
actionRow.addArrangedSubview(divider)
divider.snp.makeConstraints { make in
make.width.equalTo(1)
make.height.equalTo(24)
}
}
actionRow.addArrangedSubview(saveQRButton)
let infoStack = UIStackView(arrangedSubviews: [scenicRow, merchantRow, staffRow])
let infoRows = usesBranding
? [photographerRow, storeRow]
: [scenicRow, merchantRow, staffRow]
let infoStack = UIStackView(arrangedSubviews: infoRows)
infoStack.axis = .vertical
infoCardView.addSubview(infoStack)
voiceCardView.addSubview(voiceTitleLabel)
voiceCardView.addSubview(voiceSwitch)
qrContentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 32, left: 16, bottom: 32, right: 16))
if usesBranding {
let settingsStack = UIStackView(arrangedSubviews: [recordRow, voiceCardView])
settingsStack.axis = .vertical
settingsCardView.addSubview(settingsStack)
settingsStack.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
let divider = UIView()
divider.backgroundColor = UIColor(hex: 0xD9E2EA).withAlphaComponent(0.75)
settingsCardView.addSubview(divider)
divider.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.centerY.equalToSuperview()
make.height.equalTo(0.5)
}
}
qrContentStack.snp.makeConstraints { make in
let verticalInset: CGFloat = usesBranding ? 0 : 32
make.edges.equalToSuperview().inset(
UIEdgeInsets(top: verticalInset, left: 16, bottom: verticalInset, right: 16)
)
}
scenicNameLabel.isHidden = usesBranding
scenicNameLabel.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview()
}
qrImageView.snp.makeConstraints { make in
make.width.height.equalTo(182)
if usesBranding {
qrContainerView.snp.makeConstraints { make in
make.width.height.equalTo(brandQRContainerSize)
}
qrImageView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.width.height.equalTo(brandQRImageSize)
}
} else {
qrImageView.snp.makeConstraints { make in
make.width.height.equalTo(182)
}
}
qrPlaceholderView.snp.makeConstraints { make in
make.width.height.equalTo(182)
make.width.height.equalTo(usesBranding ? brandQRContainerSize : 182)
}
qrErrorLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(56)
@ -162,12 +300,36 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
}
override func setupConstraints() {
if viewModel.usesNalatiBranding {
backgroundImageView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
scrollView.snp.makeConstraints { make in
make.edges.equalTo(view.safeAreaLayoutGuide)
if viewModel.usesNalatiBranding {
make.leading.trailing.equalToSuperview()
make.top.equalTo(view.safeAreaLayoutGuide.snp.top).offset(-44)
make.bottom.equalTo(view.safeAreaLayoutGuide)
} else {
make.edges.equalTo(view.safeAreaLayoutGuide)
}
}
contentContainerView.snp.makeConstraints { make in
make.edges.equalToSuperview()
make.width.equalTo(scrollView.snp.width)
if viewModel.usesNalatiBranding {
make.height.equalTo(scrollView.snp.height)
}
}
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.screenHorizontalInset * 2)
if viewModel.usesNalatiBranding {
make.centerX.centerY.equalToSuperview()
make.top.greaterThanOrEqualToSuperview()
make.bottom.lessThanOrEqualToSuperview()
} else {
make.edges.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
}
}
}
@ -188,13 +350,30 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
override func viewDidLoad() {
super.viewDidLoad()
applyViewModel()
Task { await loadData() }
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
applyBrandNavigationAppearanceIfNeeded()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
restoreNavigationAppearanceIfNeeded()
}
override var preferredStatusBarStyle: UIStatusBarStyle {
viewModel.usesNalatiBranding ? .darkContent : .default
}
private func loadData() async {
async let pageConfig: Void = viewModel.loadPayPageConfig(api: paymentAPI)
showLoading()
await viewModel.loadPayCode(api: paymentAPI)
hideLoading()
await pageConfig
}
@MainActor
@ -203,15 +382,20 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
scenicRow.setValue(viewModel.displayScenicName)
merchantRow.setValue(viewModel.merchantInfo.company)
staffRow.setValue(viewModel.staffId.isEmpty ? "-" : viewModel.staffId)
photographerRow.setValue(viewModel.displayPhotographerName)
storeRow.setValue(viewModel.displayStoreNames)
voiceSwitch.isOn = viewModel.isVoiceBroadcastOpen
applyBrandConfigIfNeeded()
if let image = viewModel.qrImage {
qrImageView.image = image
qrImageView.isHidden = false
qrContainerView.isHidden = !viewModel.usesNalatiBranding
qrPlaceholderView.isHidden = true
actionRow.isHidden = false
} else {
qrImageView.isHidden = true
qrContainerView.isHidden = true
qrPlaceholderView.isHidden = false
actionRow.isHidden = true
}
@ -254,6 +438,181 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
button.titleLabel?.font = .systemFont(ofSize: 14)
}
private func configureCard(_ view: UIView, branded: Bool) {
view.backgroundColor = branded ? UIColor.white.withAlphaComponent(0.9) : .white
view.layer.cornerRadius = branded ? 18 : AppRadius.lg
view.clipsToBounds = true
}
private func configureBrandHeader() {
brandHeaderStack.axis = .vertical
brandHeaderStack.alignment = .center
brandHeaderStack.spacing = brandScreenHeight <= 700 ? 6 : 8
brandLogoImageView.image = UIImage(named: "payment_nalati_logo")
brandLogoImageView.contentMode = .scaleAspectFit
brandLogoImageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
brandTitleLabel.font = .systemFont(
ofSize: brandScreenHeight <= 700 ? 21 : 23,
weight: .bold
)
brandTitleLabel.textColor = UIColor(hex: 0x102D54)
brandTitleLabel.textAlignment = .center
brandTitleLabel.numberOfLines = 1
brandTitleLabel.adjustsFontSizeToFitWidth = true
brandTitleLabel.minimumScaleFactor = 0.8
brandSubtitleLabel.font = .systemFont(
ofSize: brandScreenHeight <= 700 ? 15 : 16,
weight: .medium
)
brandSubtitleLabel.textColor = UIColor(hex: 0x183A61)
brandSubtitleLabel.textAlignment = .center
let subtitleRow = UIStackView()
subtitleRow.axis = .horizontal
subtitleRow.alignment = .center
subtitleRow.spacing = 9
let leftLine = makeBrandLine()
let leftDiamond = makeBrandDiamond()
let rightDiamond = makeBrandDiamond()
let rightLine = makeBrandLine()
[leftLine, leftDiamond, brandSubtitleLabel, rightDiamond, rightLine].forEach(
subtitleRow.addArrangedSubview
)
brandHeaderStack.addArrangedSubview(brandLogoImageView)
brandHeaderStack.addArrangedSubview(brandTitleLabel)
brandHeaderStack.addArrangedSubview(subtitleRow)
brandHeaderStack.setCustomSpacing(
brandScreenHeight <= 700 ? 8 : 10,
after: brandLogoImageView
)
brandLogoImageView.snp.makeConstraints { make in
make.width.equalToSuperview().inset(8)
make.height.equalTo(brandLogoHeight)
}
brandTitleLabel.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(8)
}
[leftLine, rightLine].forEach { line in
line.snp.makeConstraints { make in
make.width.equalTo(42)
make.height.equalTo(1)
}
}
[leftDiamond, rightDiamond].forEach { diamond in
diamond.snp.makeConstraints { make in
make.width.height.equalTo(6)
}
}
}
private func makeBrandLine() -> UIView {
let view = UIView()
view.backgroundColor = UIColor(hex: 0x27496D).withAlphaComponent(0.75)
return view
}
private func makeBrandDiamond() -> UIView {
let view = UIView()
view.backgroundColor = UIColor(hex: 0x27496D)
view.transform = CGAffineTransform(rotationAngle: .pi / 4)
return view
}
@MainActor
private func applyBrandConfigIfNeeded() {
guard viewModel.usesNalatiBranding else { return }
let config = viewModel.payPageConfig
brandTitleLabel.text = config.title
brandSubtitleLabel.text = config.subtitle
let versionChanged = appliedBrandingRefreshVersion != viewModel.brandingRefreshVersion
guard appliedBrandConfig != config || versionChanged else { return }
let forceRefresh = appliedBrandingRefreshVersion >= 0 && versionChanged
setBrandImage(
backgroundImageView,
urlString: config.background,
fallbackName: "payment_nalati_background",
forceRefresh: forceRefresh
)
setBrandImage(
brandLogoImageView,
urlString: config.logo,
fallbackName: "payment_nalati_logo",
forceRefresh: forceRefresh
)
appliedBrandConfig = config
appliedBrandingRefreshVersion = viewModel.brandingRefreshVersion
}
private func setBrandImage(
_ imageView: UIImageView,
urlString: String,
fallbackName: String,
forceRefresh: Bool
) {
let fallback = UIImage(named: fallbackName)
let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
guard let url = URL(string: trimmed),
url.scheme?.lowercased() == "https",
url.host != nil else {
imageView.image = fallback
return
}
var options: KingfisherOptionsInfo = [
.keepCurrentImageWhileLoading,
.cacheOriginalImage,
.transition(.fade(0.2)),
]
if forceRefresh {
options.append(.forceRefresh)
}
imageView.kf.setImage(with: url, placeholder: fallback, options: options)
}
private func applyBrandNavigationAppearanceIfNeeded() {
guard viewModel.usesNalatiBranding, let navigationBar = navigationController?.navigationBar else { return }
previousStandardAppearance = navigationBar.standardAppearance
previousScrollEdgeAppearance = navigationBar.scrollEdgeAppearance
previousCompactAppearance = navigationBar.compactAppearance
previousNavigationTintColor = navigationBar.tintColor
previousNavigationTranslucency = navigationBar.isTranslucent
let appearance = UINavigationBarAppearance()
appearance.configureWithTransparentBackground()
appearance.shadowColor = .clear
appearance.titleTextAttributes = [
.foregroundColor: UIColor(hex: 0x111827),
.font: UIFont.app(.title),
]
navigationBar.standardAppearance = appearance
navigationBar.scrollEdgeAppearance = appearance
navigationBar.compactAppearance = appearance
navigationBar.tintColor = UIColor(hex: 0x111827)
navigationBar.isTranslucent = true
}
private func restoreNavigationAppearanceIfNeeded() {
guard viewModel.usesNalatiBranding, let navigationBar = navigationController?.navigationBar else { return }
if let previousStandardAppearance {
navigationBar.standardAppearance = previousStandardAppearance
}
navigationBar.scrollEdgeAppearance = previousScrollEdgeAppearance
navigationBar.compactAppearance = previousCompactAppearance
navigationBar.tintColor = previousNavigationTintColor
navigationBar.isTranslucent = previousNavigationTranslucency
previousStandardAppearance = nil
previousScrollEdgeAppearance = nil
previousCompactAppearance = nil
previousNavigationTintColor = nil
}
@objc private func setAmountTapped() {
viewModel.showSetAmountDialog()
}
@ -310,7 +669,7 @@ private final class PaymentInfoRowView: UIView {
valueLabel.font = .systemFont(ofSize: 14)
valueLabel.textColor = AppColor.textPrimary
valueLabel.textAlignment = .right
valueLabel.numberOfLines = 2
valueLabel.numberOfLines = 0
divider.backgroundColor = UIColor(hex: 0xE5E7EB)