fix: 优化收款码加载态,避免首屏错误占位闪现
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -28,6 +28,7 @@ final class PaymentCollectionDetailsViewModel {
|
|||||||
|
|
||||||
private var staticPayURL = ""
|
private var staticPayURL = ""
|
||||||
private var dynamicPayURL = ""
|
private var dynamicPayURL = ""
|
||||||
|
private var hasCompletedPayCodeRequest = false
|
||||||
private let appStore: AppStore
|
private let appStore: AppStore
|
||||||
private let payPageConfigCache: PayPageConfigCaching
|
private let payPageConfigCache: PayPageConfigCaching
|
||||||
|
|
||||||
@ -50,6 +51,11 @@ final class PaymentCollectionDetailsViewModel {
|
|||||||
qrImage != nil && !staticPayURL.isEmpty
|
qrImage != nil && !staticPayURL.isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 是否应在二维码区域展示加载状态,首次请求开始前也视为加载中,避免短暂闪现错误占位。
|
||||||
|
var isPayCodeLoading: Bool {
|
||||||
|
loading || !hasCompletedPayCodeRequest
|
||||||
|
}
|
||||||
|
|
||||||
var displayScenicName: String {
|
var displayScenicName: String {
|
||||||
scenicName.isEmpty ? "暂无景区" : scenicName
|
scenicName.isEmpty ? "暂无景区" : scenicName
|
||||||
}
|
}
|
||||||
@ -85,11 +91,11 @@ final class PaymentCollectionDetailsViewModel {
|
|||||||
|
|
||||||
func loadPayCode(api: PaymentAPI) async {
|
func loadPayCode(api: PaymentAPI) async {
|
||||||
guard !loading else { return }
|
guard !loading else { return }
|
||||||
refreshLocalState()
|
|
||||||
loading = true
|
loading = true
|
||||||
notifyStateChange()
|
refreshLocalState()
|
||||||
defer {
|
defer {
|
||||||
loading = false
|
loading = false
|
||||||
|
hasCompletedPayCodeRequest = true
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -28,6 +28,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
private let qrContainerView = UIView()
|
private let qrContainerView = UIView()
|
||||||
private let qrImageView = UIImageView()
|
private let qrImageView = UIImageView()
|
||||||
private let qrPlaceholderView = UIView()
|
private let qrPlaceholderView = UIView()
|
||||||
|
private let qrLoadingIndicator = UIActivityIndicatorView(style: .medium)
|
||||||
private let qrErrorLabel = UILabel()
|
private let qrErrorLabel = UILabel()
|
||||||
private let refreshButton = UIButton(type: .system)
|
private let refreshButton = UIButton(type: .system)
|
||||||
private let setAmountButton = UIButton(type: .system)
|
private let setAmountButton = UIButton(type: .system)
|
||||||
@ -127,6 +128,10 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
qrPlaceholderView.layer.cornerRadius = usesBranding ? 10 : 24
|
qrPlaceholderView.layer.cornerRadius = usesBranding ? 10 : 24
|
||||||
qrPlaceholderView.isHidden = true
|
qrPlaceholderView.isHidden = true
|
||||||
|
|
||||||
|
qrLoadingIndicator.color = AppColor.primary
|
||||||
|
qrLoadingIndicator.hidesWhenStopped = true
|
||||||
|
qrLoadingIndicator.accessibilityLabel = "收款码加载中"
|
||||||
|
|
||||||
qrErrorLabel.text = "未选景区或网络问题"
|
qrErrorLabel.text = "未选景区或网络问题"
|
||||||
qrErrorLabel.font = .systemFont(ofSize: 14)
|
qrErrorLabel.font = .systemFont(ofSize: 14)
|
||||||
qrErrorLabel.textColor = AppColor.danger
|
qrErrorLabel.textColor = AppColor.danger
|
||||||
@ -150,6 +155,9 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
trailing: 24
|
trailing: 24
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
actionRow.alpha = 0
|
||||||
|
actionRow.isUserInteractionEnabled = false
|
||||||
|
actionRow.accessibilityElementsHidden = true
|
||||||
|
|
||||||
configureLinkButton(setAmountButton, title: "设置金额")
|
configureLinkButton(setAmountButton, title: "设置金额")
|
||||||
configureLinkButton(saveQRButton, title: "保存二维码")
|
configureLinkButton(saveQRButton, title: "保存二维码")
|
||||||
@ -206,6 +214,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
qrContentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.lg
|
qrContentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.lg
|
||||||
qrCardView.addSubview(qrContentStack)
|
qrCardView.addSubview(qrContentStack)
|
||||||
|
|
||||||
|
qrPlaceholderView.addSubview(qrLoadingIndicator)
|
||||||
qrPlaceholderView.addSubview(qrErrorLabel)
|
qrPlaceholderView.addSubview(qrErrorLabel)
|
||||||
qrPlaceholderView.addSubview(refreshButton)
|
qrPlaceholderView.addSubview(refreshButton)
|
||||||
|
|
||||||
@ -275,6 +284,9 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
qrPlaceholderView.snp.makeConstraints { make in
|
qrPlaceholderView.snp.makeConstraints { make in
|
||||||
make.width.height.equalTo(usesBranding ? brandQRContainerSize : 182)
|
make.width.height.equalTo(usesBranding ? brandQRContainerSize : 182)
|
||||||
}
|
}
|
||||||
|
qrLoadingIndicator.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
}
|
||||||
qrErrorLabel.snp.makeConstraints { make in
|
qrErrorLabel.snp.makeConstraints { make in
|
||||||
make.top.equalToSuperview().offset(56)
|
make.top.equalToSuperview().offset(56)
|
||||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.sm)
|
make.leading.trailing.equalToSuperview().inset(AppSpacing.sm)
|
||||||
@ -370,9 +382,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
|
|
||||||
private func loadData() async {
|
private func loadData() async {
|
||||||
async let pageConfig: Void = viewModel.loadPayPageConfig(api: paymentAPI)
|
async let pageConfig: Void = viewModel.loadPayPageConfig(api: paymentAPI)
|
||||||
showLoading()
|
|
||||||
await viewModel.loadPayCode(api: paymentAPI)
|
await viewModel.loadPayCode(api: paymentAPI)
|
||||||
hideLoading()
|
|
||||||
await pageConfig
|
await pageConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -387,17 +397,33 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
voiceSwitch.isOn = viewModel.isVoiceBroadcastOpen
|
voiceSwitch.isOn = viewModel.isVoiceBroadcastOpen
|
||||||
applyBrandConfigIfNeeded()
|
applyBrandConfigIfNeeded()
|
||||||
|
|
||||||
if let image = viewModel.qrImage {
|
if viewModel.isPayCodeLoading {
|
||||||
|
qrImageView.isHidden = true
|
||||||
|
qrContainerView.isHidden = true
|
||||||
|
qrPlaceholderView.backgroundColor = viewModel.usesNalatiBranding
|
||||||
|
? .white
|
||||||
|
: AppColor.inputBackground
|
||||||
|
qrPlaceholderView.isHidden = false
|
||||||
|
qrErrorLabel.isHidden = true
|
||||||
|
refreshButton.isHidden = true
|
||||||
|
qrLoadingIndicator.startAnimating()
|
||||||
|
setActionRowAvailable(false)
|
||||||
|
} else if let image = viewModel.qrImage {
|
||||||
qrImageView.image = image
|
qrImageView.image = image
|
||||||
qrImageView.isHidden = false
|
qrImageView.isHidden = false
|
||||||
qrContainerView.isHidden = !viewModel.usesNalatiBranding
|
qrContainerView.isHidden = !viewModel.usesNalatiBranding
|
||||||
qrPlaceholderView.isHidden = true
|
qrPlaceholderView.isHidden = true
|
||||||
actionRow.isHidden = false
|
qrLoadingIndicator.stopAnimating()
|
||||||
|
setActionRowAvailable(true)
|
||||||
} else {
|
} else {
|
||||||
qrImageView.isHidden = true
|
qrImageView.isHidden = true
|
||||||
qrContainerView.isHidden = true
|
qrContainerView.isHidden = true
|
||||||
|
qrPlaceholderView.backgroundColor = AppColor.inputBackground
|
||||||
qrPlaceholderView.isHidden = false
|
qrPlaceholderView.isHidden = false
|
||||||
actionRow.isHidden = true
|
qrErrorLabel.isHidden = false
|
||||||
|
refreshButton.isHidden = false
|
||||||
|
qrLoadingIndicator.stopAnimating()
|
||||||
|
setActionRowAvailable(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
if viewModel.showAmountDialog {
|
if viewModel.showAmountDialog {
|
||||||
@ -408,6 +434,12 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func setActionRowAvailable(_ isAvailable: Bool) {
|
||||||
|
actionRow.alpha = isAvailable ? 1 : 0
|
||||||
|
actionRow.isUserInteractionEnabled = isAvailable
|
||||||
|
actionRow.accessibilityElementsHidden = !isAvailable
|
||||||
|
}
|
||||||
|
|
||||||
private func presentAmountDialogIfNeeded() {
|
private func presentAmountDialogIfNeeded() {
|
||||||
guard amountDialog == nil else {
|
guard amountDialog == nil else {
|
||||||
amountDialog?.apply(amount: viewModel.amount, remark: viewModel.remark)
|
amountDialog?.apply(amount: viewModel.amount, remark: viewModel.remark)
|
||||||
@ -625,9 +657,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
|
|
||||||
@objc private func refreshTapped() {
|
@objc private func refreshTapped() {
|
||||||
Task {
|
Task {
|
||||||
showLoading()
|
|
||||||
await viewModel.refresh(api: paymentAPI)
|
await viewModel.refresh(api: paymentAPI)
|
||||||
hideLoading()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -69,6 +69,26 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
|
|||||||
XCTAssertFalse(viewModel.showAmountDialog)
|
XCTAssertFalse(viewModel.showAmountDialog)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testPayCodeLoadingStateCoversInitialRenderAndRequest() async {
|
||||||
|
let payCodeJSON = """
|
||||||
|
{"code":100000,"msg":"success","data":{"static_pay_url":"https://pay.example.com/static","dynamic_pay_url":"https://pay.example.com/dynamic"}}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
let session = MockURLSession(responses: [payCodeJSON])
|
||||||
|
let api = PaymentAPI(client: APIClient(environment: .testing, session: session))
|
||||||
|
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
|
||||||
|
var loadingStates = [viewModel.isPayCodeLoading]
|
||||||
|
viewModel.onStateChange = {
|
||||||
|
loadingStates.append(viewModel.isPayCodeLoading)
|
||||||
|
}
|
||||||
|
|
||||||
|
await viewModel.loadPayCode(api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(loadingStates.first, true)
|
||||||
|
XCTAssertTrue(loadingStates.dropLast().allSatisfy { $0 })
|
||||||
|
XCTAssertEqual(loadingStates.last, false)
|
||||||
|
XCTAssertTrue(viewModel.hasPayCode)
|
||||||
|
}
|
||||||
|
|
||||||
func testToggleReceiveVoicePersistsInAppStore() {
|
func testToggleReceiveVoicePersistsInAppStore() {
|
||||||
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
|
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
|
||||||
viewModel.refreshLocalState()
|
viewModel.refreshLocalState()
|
||||||
@ -105,6 +125,7 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
|
|||||||
|
|
||||||
await viewModel.loadPayCode(api: api)
|
await viewModel.loadPayCode(api: api)
|
||||||
|
|
||||||
|
XCTAssertFalse(viewModel.isPayCodeLoading)
|
||||||
XCTAssertFalse(viewModel.hasPayCode)
|
XCTAssertFalse(viewModel.hasPayCode)
|
||||||
XCTAssertEqual(session.requests.count, 0)
|
XCTAssertEqual(session.requests.count, 0)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user