203 changed files with 10748 additions and 410 deletions
+2
View File
@@ -0,0 +1,2 @@
/gradlew text eol=lf
/gradlew.bat text eol=crlf
-1
View File
@@ -230,7 +230,6 @@ dependencies {
implementation(libs.androidx.compose.foundation) implementation(libs.androidx.compose.foundation)
implementation(libs.firebase.crashlytics.buildtools)
compileOnly(libs.ksp.gradlePlugin) compileOnly(libs.ksp.gradlePlugin)
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<lint>
<!-- Glide is used only for image views (GlideEngine). No NotificationTarget,
notification posting, or foreground-service calls exist in app sources.
Match this library-only diagnostic; keep other permission errors enabled. -->
<issue id="NotificationPermission">
<ignore regexp=".*usage from com\.bumptech\.glide\.request\.target\.NotificationTarget.*" />
</issue>
</lint>
@@ -19,6 +19,6 @@ class ExampleInstrumentedTest {
fun useAppContext() { fun useAppContext() {
// Context of the app under test. // Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.zhifly.follow", appContext.packageName) assertEquals("com.yzx.kiosk", appContext.packageName)
} }
} }
@@ -44,6 +44,29 @@ class LocalAudioPlaybackTest {
} }
} }
@Test fun electronicCompletionSwitchesToDedicatedClipAndPreservesPrintAudio() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
lateinit var service: LocalAudioPlayService
instrumentation.runOnMainSync { service = LocalAudioPlayService(instrumentation.targetContext) }
try {
instrumentation.runOnMainSync { service.playByRoute(AppRoutes.PRINT_SUCCESS) }
awaitPlaying(service)
assertEquals(R.raw.print_complete_page, service.currentResId.value)
instrumentation.runOnMainSync {
service.playByRoute(AppRoutes.buildElectronicCompletionRoute("AUDIO-TEST-ORDER"))
}
awaitPlaying(service)
assertEquals(R.raw.electronic_complete_page, service.currentResId.value)
instrumentation.runOnMainSync { service.playByRoute(AppRoutes.PRINT_SUCCESS) }
awaitPlaying(service)
assertEquals(R.raw.print_complete_page, service.currentResId.value)
} finally {
instrumentation.runOnMainSync { service.release() }
}
}
private fun awaitPlaying(service: LocalAudioPlayService) { private fun awaitPlaying(service: LocalAudioPlayService) {
val deadline = System.currentTimeMillis() + 5000 val deadline = System.currentTimeMillis() + 5000
while (!service.isPlaying.value && System.currentTimeMillis() < deadline) Thread.sleep(20) while (!service.isPlaying.value && System.currentTimeMillis() < deadline) Thread.sleep(20)
@@ -0,0 +1,67 @@
package com.yzx.kiosk.ui.face
import android.graphics.Bitmap
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.test.platform.app.InstrumentationRegistry
import com.google.zxing.BinaryBitmap
import com.google.zxing.MultiFormatReader
import com.google.zxing.RGBLuminanceSource
import com.google.zxing.common.HybridBinarizer
import com.yzx.kiosk.theme.AppTheme
import com.yzx.kiosk.ui.common.view.FullScreenMode
import com.yzx.kiosk.ui.face.view.ElectronicCompletionContent
import com.yzx.kiosk.utils.QrCodeUtils
import java.io.File
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
class ElectronicCompletionScreenTest {
@get:Rule val compose = createComposeRule()
@Test fun downloadQrIsScannableAndReturnWorks() {
val url = "https://example.test/album/electronic-completion"
val qr = QrCodeUtils.generateStyledQrBitmap(url, size = 800, cornerRadius = 0f).asImageBitmap()
var returned = false
compose.setContent {
FullScreenMode()
AppTheme {
// Match the reference/kiosk aspect ratio even on a taller test phone.
Box(Modifier.fillMaxWidth().aspectRatio(941f / 1672f).testTag("completion")) {
ElectronicCompletionContent(qr, false, 90, "4001234567", {}, { returned = true })
}
}
}
compose.onNodeWithText("客服电话:4001234567").assertIsDisplayed()
val bitmap = compose.onNodeWithTag("completion").captureToImage().asAndroidBitmap()
val pixels = IntArray(bitmap.width * bitmap.height)
bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height)
assertEquals(url, MultiFormatReader().decode(BinaryBitmap(HybridBinarizer(
RGBLuminanceSource(bitmap.width, bitmap.height, pixels)
))).text)
val context = InstrumentationRegistry.getInstrumentation().targetContext
File(context.getExternalFilesDir(null), "electronic-completion.png").outputStream().use {
bitmap.compress(Bitmap.CompressFormat.PNG, 100, it)
}
compose.onNodeWithContentDescription("返回首页").performClick()
compose.runOnIdle { assertTrue(returned) }
}
@Test fun failedQrOffersWorkingRetry() {
var retried = false
compose.setContent {
AppTheme { ElectronicCompletionContent(null, true, 70, "4001234567", { retried = true }, {}) }
}
compose.onNodeWithText("二维码加载失败").assertIsDisplayed()
compose.onNodeWithText("重新加载").performClick()
compose.runOnIdle { assertTrue(retried) }
}
}
@@ -51,9 +51,19 @@ class RecentPhotosFlowTest {
private lateinit var createResultVm: () -> FaceRecognitionResultViewModel private lateinit var createResultVm: () -> FaceRecognitionResultViewModel
private val scopedResultVms = mutableListOf<FaceRecognitionResultViewModel>() private val scopedResultVms = mutableListOf<FaceRecognitionResultViewModel>()
private lateinit var faceVm: FaceRecognitionViewModel private lateinit var faceVm: FaceRecognitionViewModel
private lateinit var testSocket: WebSocketService
private lateinit var nav: AppNavigator private lateinit var nav: AppNavigator
private var restoreConfig: (() -> Unit)? = null private var restoreConfig: (() -> Unit)? = null
private val requests = CopyOnWriteArrayList<Request>() private val requests = CopyOnWriteArrayList<Request>()
private lateinit var testRepository: NetWorkRepository
private var completionVm: ElectronicCompletionViewModel? = null
@Volatile private var electronicAmount: String? = null
@Volatile private var completionStatus = 30
@Volatile private var payFails = false
@Volatile private var queryFails = false
@Volatile private var quoteFails = false
@Volatile private var completedMode = "electronic"
@Volatile private var qrFails = false
@Volatile private var recentCode = 200 @Volatile private var recentCode = 200
@Volatile private var recentBody = """{"count":0,"results":[]}""" @Volatile private var recentBody = """{"count":0,"results":[]}"""
@@ -63,6 +73,7 @@ class RecentPhotosFlowTest {
val component = (app as dagger.hilt.internal.GeneratedComponentManager<*>).generatedComponent() val component = (app as dagger.hilt.internal.GeneratedComponentManager<*>).generatedComponent()
val providerField = component.javaClass.getDeclaredField("webSocketServiceProvider").apply { isAccessible = true } val providerField = component.javaClass.getDeclaredField("webSocketServiceProvider").apply { isAccessible = true }
val socket = (providerField.get(component) as javax.inject.Provider<*>).get() as WebSocketService val socket = (providerField.get(component) as javax.inject.Provider<*>).get() as WebSocketService
testSocket = socket
val store = app.appStoreDataSource val store = app.appStoreDataSource
val oldRemote = store.getBindBoxUrl() val oldRemote = store.getBindBoxUrl()
val oldLan = store.getBindBoxLanUrl() val oldLan = store.getBindBoxLanUrl()
@@ -84,14 +95,28 @@ class RecentPhotosFlowTest {
val recent = req.url.encodedPath.endsWith("/api/photos/recent") val recent = req.url.encodedPath.endsWith("/api/photos/recent")
val body = when { val body = when {
recent -> recentBody recent -> recentBody
req.url.encodedPath.endsWith("verify-result") && quoteFails ->
"""{"code":100001,"msg":"项目已下线,请重新选择","data":{}}"""
req.url.encodedPath.endsWith("verify-result") -> { req.url.encodedPath.endsWith("verify-result") -> {
val buffer = okio.Buffer() val buffer = okio.Buffer()
req.body!!.writeTo(buffer) req.body!!.writeTo(buffer)
val ids = Gson().fromJson(buffer.readUtf8(), com.google.gson.JsonObject::class.java).getAsJsonArray("image_id") val ids = Gson().fromJson(buffer.readUtf8(), com.google.gson.JsonObject::class.java).getAsJsonArray("image_id")
val amount = "${ids.size() * 2}.00" val amount = "${ids.size() * 2}.00"
"""{"code":100000,"data":{"price_image":"2.00","amount":"$amount"}}""" val extra = electronicAmount?.let { ",\"price_electronic\":\"$it\",\"amount_electronic\":\"${it.toBigDecimal().multiply(ids.size().toBigDecimal()).toPlainString()}\"" }.orEmpty()
"""{"code":100000,"data":{"price_image":"2.00","amount":"$amount"$extra}}"""
} }
req.url.encodedPath.endsWith("get-pay-url") -> """{"code":100000,"data":{"url":"https://example.test/mock-pay","order_number":"MOCK-RECENT-1"}}""" req.url.encodedPath.endsWith("get-pay-url") && payFails ->
"""{"code":100001,"msg":"电子版价格不可用,请重新选择","data":{}}"""
req.url.encodedPath.endsWith("get-pay-url") -> if (electronicAmount == null)
"""{"code":100000,"data":{"url":"https://example.test/mock-pay","order_number":"MOCK-RECENT-1"}}"""
else """{"code":100000,"data":{"url":${if (electronicAmount == "0.00") "null" else "\"https://example.test/mock-pay\""},"order_number":"MOCK-RECENT-1","purchase_mode":"electronic","amount":"$electronicAmount","order_status":${if (electronicAmount == "0.00") 30 else 10}}}"""
req.url.encodedPath.endsWith("pay-success-message") && queryFails ->
"""{"code":100001,"msg":"预选缓存已失效,请重新选择照片","data":{}}"""
req.url.encodedPath.endsWith("pay-success-message") ->
"""{"code":100000,"data":{"order_status":$completionStatus,"type":5,"data":{"order_number":"MOCK-RECENT-1","capture_type":2,"image_id":[9],"purchase_mode":"$completedMode"}}}"""
req.url.encodedPath.endsWith("save-album-url") -> if (qrFails)
"""{"code":500,"msg":"mock failure"}""" else
"""{"code":100000,"data":{"url":"https://example.test/album/MOCK-RECENT-1"}}"""
else -> """{"count":0,"results":[]}""" else -> """{"count":0,"results":[]}"""
} }
Response.Builder().request(req).protocol(Protocol.HTTP_1_1).code(if (recent) recentCode else 200) Response.Builder().request(req).protocol(Protocol.HTTP_1_1).code(if (recent) recentCode else 200)
@@ -101,6 +126,7 @@ class RecentPhotosFlowTest {
.addConverterFactory(GsonConverterFactory.create()).build() .addConverterFactory(GsonConverterFactory.create()).build()
val service = retrofit.create(FaceSearchService::class.java) val service = retrofit.create(FaceSearchService::class.java)
val repository = NetWorkRepository(retrofit.create(NetworkService::class.java), retrofit.create(UploadService::class.java), Gson()) val repository = NetWorkRepository(retrofit.create(NetworkService::class.java), retrofit.create(UploadService::class.java), Gson())
testRepository = repository
compose.runOnUiThread { compose.runOnUiThread {
nav = AppNavigator() nav = AppNavigator()
createResultVm = { FaceRecognitionResultViewModel(nav, app.appState, repository, service, socket, app.appStoreDataSource) } createResultVm = { FaceRecognitionResultViewModel(nav, app.appState, repository, service, socket, app.appStoreDataSource) }
@@ -113,6 +139,7 @@ class RecentPhotosFlowTest {
compose.runOnUiThread { compose.runOnUiThread {
if (::resultVm.isInitialized) resultVm.viewModelScope.cancel() if (::resultVm.isInitialized) resultVm.viewModelScope.cancel()
if (::faceVm.isInitialized) faceVm.viewModelScope.cancel() if (::faceVm.isInitialized) faceVm.viewModelScope.cancel()
completionVm?.viewModelScope?.cancel()
scopedResultVms.forEach { it.viewModelScope.cancel() } scopedResultVms.forEach { it.viewModelScope.cancel() }
restoreConfig?.invoke() restoreConfig?.invoke()
} }
@@ -262,7 +289,7 @@ class RecentPhotosFlowTest {
compose.onNodeWithText("全选").performClick() compose.onNodeWithText("全选").performClick()
compose.waitUntil(10_000) { resultVm.totalPrice.value == "4.00" } compose.waitUntil(10_000) { resultVm.totalPrice.value == "4.00" }
assertEquals(2, resultVm.selectedPhotos.value.size) assertEquals(2, resultVm.selectedPhotos.value.size)
compose.onNodeWithText("支付4.00元").assertIsEnabled() compose.onNodeWithText("购买打印照片\n4元").assertIsEnabled()
screenshot("recent-photos-results") screenshot("recent-photos-results")
compose.onAllNodesWithText("点击预览")[0].performClick() compose.onAllNodesWithText("点击预览")[0].performClick()
compose.onNodeWithContentDescription("关闭").assertIsDisplayed().performClick() compose.onNodeWithContentDescription("关闭").assertIsDisplayed().performClick()
@@ -270,9 +297,10 @@ class RecentPhotosFlowTest {
assertEquals(1, resultVm.selectedPhotos.value.size) assertEquals(1, resultVm.selectedPhotos.value.size)
compose.runOnIdle { resultVm.toggleSelectAll() } compose.runOnIdle { resultVm.toggleSelectAll() }
assertEquals(2, resultVm.selectedPhotos.value.size) assertEquals(2, resultVm.selectedPhotos.value.size)
compose.waitUntil(10_000) { resultVm.totalPrice.value == "4.00" }
// Exercise the shared payment code against a fake response without opening polling UI. // Exercise the shared payment code against a fake response without opening polling UI.
var payUrl: String? = null var payUrl: String? = null
compose.runOnIdle { resultVm.getPayUrl { payUrl = it } } compose.runOnIdle { resultVm.getPayUrl(PurchaseMode.PRINT) { payUrl = it } }
compose.waitUntil(10_000) { payUrl != null } compose.waitUntil(10_000) { payUrl != null }
assertEquals("https://example.test/mock-pay", payUrl) assertEquals("https://example.test/mock-pay", payUrl)
val field = FaceRecognitionResultViewModel::class.java.getDeclaredField("activePaymentOrderNumber").apply { isAccessible = true } val field = FaceRecognitionResultViewModel::class.java.getDeclaredField("activePaymentOrderNumber").apply { isAccessible = true }
@@ -283,8 +311,8 @@ class RecentPhotosFlowTest {
scope.launchCollect(nav) { route = it } scope.launchCollect(nav) { route = it }
} }
compose.runOnIdle { compose.runOnIdle {
val method = FaceRecognitionResultViewModel::class.java.getDeclaredMethod("handlePaymentSuccess", String::class.java, Integer::class.java, List::class.java, String::class.java).apply { isAccessible = true } val method = FaceRecognitionResultViewModel::class.java.getDeclaredMethod("handlePaymentSuccess", String::class.java, Integer::class.java, List::class.java, String::class.java, String::class.java).apply { isAccessible = true }
method.invoke(resultVm, "MOCK-RECENT-1", 2, listOf(9, 3), "test") method.invoke(resultVm, "MOCK-RECENT-1", 2, listOf(9, 3), null, "test")
} }
compose.waitUntil(10_000) { route != null } compose.waitUntil(10_000) { route != null }
assertTrue(route!!.startsWith("printing?")) assertTrue(route!!.startsWith("printing?"))
@@ -292,12 +320,224 @@ class RecentPhotosFlowTest {
job.cancel() job.cancel()
} }
private fun showElectronicSelection(amount: String?) {
electronicAmount = amount
val image = Bitmap.createBitmap(160, 100, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.BLUE) }
val file = File(context.cacheDir, "electronic-fixture.png")
file.outputStream().use { image.compress(Bitmap.CompressFormat.PNG, 100, it) }
val url = file.toURI().toString()
val photos = listOf(FaceSearchResult(9, url, url, url, url))
compose.setContent { FaceRecognitionResultScreen(results = photos, viewModel = resultVm) }
compose.waitUntil(10_000) { resultVm.photoList.value.isNotEmpty() }
compose.runOnIdle { resultVm.toggleSelectAll() }
compose.waitUntil(10_000) { !resultVm.quoteLoading.value && resultVm.totalPrice.value == "2.00" }
}
@Test fun paidElectronicUsesOrderModeAndRejectsMismatchedCompletion() {
showElectronicSelection("1.00")
val routes = CopyOnWriteArrayList<String>()
val collector = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Main).launchCollect(nav) { routes.add(it) }
try {
completedMode = "print"
compose.onNodeWithText("打印版 2元/张").assertIsDisplayed()
compose.onNodeWithText("电子版 1元/张").assertIsDisplayed()
screenshot("purchase-two-modes")
compose.onNodeWithText("购买电子照片\n1元").performClick()
compose.waitUntil(10_000) { requests.any { it.url.encodedPath.endsWith("pay-success-message") } }
compose.onNodeWithText("微信支付").assertIsDisplayed()
compose.onNodeWithText("订单金额 1元").assertDoesNotExist()
assertTrue(routes.isEmpty())
completedMode = "electronic"
compose.waitUntil(10_000) { routes.size == 1 }
assertTrue(routes.single().startsWith("electronic_completion?"))
compose.runOnIdle {
val method = FaceRecognitionResultViewModel::class.java.getDeclaredMethod("handlePaymentSuccess", String::class.java, Integer::class.java, List::class.java, String::class.java, String::class.java).apply { isAccessible = true }
method.invoke(resultVm, "MOCK-RECENT-1", 2, listOf(9), "electronic", "duplicate WebSocket")
}
compose.waitForIdle()
assertEquals(1, routes.size)
val order = requests.first { it.url.encodedPath.endsWith("get-pay-url") }
val buffer = okio.Buffer().also { order.body!!.writeTo(it) }
val body = Gson().fromJson(buffer.readUtf8(), com.google.gson.JsonObject::class.java)
assertEquals("electronic", body.get("purchase_mode").asString)
assertEquals(0, body.getAsJsonArray("video_id").size())
} finally { collector.cancel() }
}
@Test fun zeroAndMissingElectronicPricesCannotCreateOrders() {
showElectronicSelection("0.00")
compose.onNodeWithText("电子版暂不可购买").assertIsNotEnabled()
compose.onNodeWithText("免费领取电子版").assertDoesNotExist()
compose.runOnIdle { resultVm.getPayUrl(PurchaseMode.ELECTRONIC) { error("zero price must not create a link") } }
assertFalse(requests.any { it.url.encodedPath.endsWith("get-pay-url") })
electronicAmount = null
compose.runOnIdle { resultVm.retryQuote() }
compose.waitUntil(10_000) { !resultVm.quoteLoading.value }
compose.onNodeWithText("电子版暂不可购买").assertIsNotEnabled()
compose.onNodeWithText("购买打印照片\n2元").assertIsEnabled()
}
private fun pushCompletion(number: String = "MOCK-RECENT-1", mode: String = "electronic") {
val json = """{"code":5,"data":{"sn":"MOCK","type":5,"data":{"order_number":"$number","capture_type":2,"image_id":[9],"purchase_mode":"$mode"}}}"""
val method = WebSocketService::class.java.getDeclaredMethod("handleMessage", String::class.java).apply { isAccessible = true }
method.invoke(testSocket, json)
}
@Test fun pendingTypeFiveCannotCompleteButNestedWebSocketCan() {
completionStatus = 10
showElectronicSelection("1.00")
val routes = CopyOnWriteArrayList<String>()
val collector = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Main).launchCollect(nav) { routes.add(it) }
try {
compose.onNodeWithText("购买电子照片\n1元").performClick()
compose.waitUntil(10_000) { requests.any { it.url.encodedPath.endsWith("pay-success-message") } }
compose.waitForIdle()
assertTrue(routes.isEmpty())
compose.onNodeWithText("微信支付").assertIsDisplayed()
compose.runOnIdle { pushCompletion(number = "OTHER") }
compose.waitForIdle()
assertTrue(routes.isEmpty())
compose.runOnIdle { pushCompletion() }
compose.waitUntil(10_000) { routes.size == 1 }
compose.runOnIdle { pushCompletion() }
compose.waitForIdle()
assertEquals(1, routes.size)
assertTrue(routes.single().startsWith("electronic_completion?"))
} finally { collector.cancel() }
}
@Test fun expiredPreselectionClosesPaymentAndRefreshesQuote() {
showElectronicSelection("1.00")
queryFails = true
val quotesBefore = requests.count { it.url.encodedPath.endsWith("verify-result") }
compose.onNodeWithText("购买电子照片\n1元").performClick()
compose.waitUntil(10_000) { requests.count { it.url.encodedPath.endsWith("verify-result") } > quotesBefore && !resultVm.quoteLoading.value }
assertNull(resultVm.payQrCodeUrl.value)
compose.onNodeWithText("微信支付").assertDoesNotExist()
compose.onNodeWithText("购买电子照片\n1元").assertIsEnabled()
}
@Test fun rejectedLinkRestoresButtonsAndQuoteBusinessErrorsRemainVisible() {
showElectronicSelection("1.00")
payFails = true
val quotesBefore = requests.count { it.url.encodedPath.endsWith("verify-result") }
compose.onNodeWithText("购买电子照片\n1元").performClick()
compose.waitUntil(10_000) { requests.count { it.url.encodedPath.endsWith("verify-result") } > quotesBefore && !resultVm.quoteLoading.value && !resultVm.creatingOrder.value }
assertNull(resultVm.payQrCodeUrl.value)
compose.onNodeWithText("购买电子照片\n1元").assertIsEnabled()
quoteFails = true
compose.runOnIdle { resultVm.retryQuote() }
compose.waitUntil(10_000) { resultVm.quoteError.value != null }
compose.onNodeWithText("项目已下线,请重新选择").assertIsDisplayed()
compose.onNodeWithText("购买打印照片\n--元").assertIsNotEnabled()
}
@Test fun selectionChangesDiscardTheOldQrAndOrder() {
completionStatus = 10
showElectronicSelection("1.00")
var url: String? = null
compose.runOnIdle { resultVm.getPayUrl(PurchaseMode.ELECTRONIC) { url = it } }
compose.waitUntil(10_000) { url != null }
assertNotNull(resultVm.payQrCodeUrl.value)
compose.runOnIdle { resultVm.toggleSelectAll() }
assertNull(resultVm.payQrCodeUrl.value)
val routes = CopyOnWriteArrayList<String>()
val collector = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Main).launchCollect(nav) { routes.add(it) }
try {
compose.runOnIdle { pushCompletion() }
compose.waitForIdle()
assertTrue(routes.isEmpty())
} finally { collector.cancel() }
}
@Test fun electronicCompletionRetriesQrWithoutPrintingOrPaperChanges() {
val app = context.applicationContext as com.yzx.kiosk.App
val papersBefore = app.appStoreDataSource.getRemainingPaperNum()
compose.runOnIdle {
completionVm = ElectronicCompletionViewModel(nav, app.appState, app.appStoreDataSource, testRepository)
}
qrFails = true
compose.setContent {
ElectronicCompletionScreen("MOCK-RECENT-1", viewModel = completionVm!!)
}
compose.waitUntil(10_000) { completionVm!!.qrCodeFailed.value }
compose.onNodeWithText("领取成功").assertDoesNotExist()
compose.onNodeWithText("支付成功").assertDoesNotExist()
compose.onNodeWithText("返回首页").assertDoesNotExist()
compose.onNodeWithText("二维码加载失败").assertIsDisplayed()
qrFails = false
compose.onNodeWithText("重新加载").performClick()
// URL receipt precedes the background QR bitmap generation.
compose.waitUntil(10_000) {
compose.onAllNodesWithContentDescription("电子版照片下载二维码").fetchSemanticsNodes().isNotEmpty()
}
compose.onNodeWithContentDescription("电子版照片下载二维码").assertIsDisplayed()
compose.onNodeWithText("请在屏幕下方拿取照片").assertDoesNotExist()
screenshot("electronic-completion")
compose.runOnIdle {
completionVm!!.initialize("MOCK-RECENT-1")
}
assertTrue(requests.all { it.url.encodedPath.endsWith("save-album-url") })
assertEquals(2, requests.size)
assertEquals(papersBefore, app.appStoreDataSource.getRemainingPaperNum())
}
@Test fun emptyRecentPhotosStillShowConfiguredUnitPrices() {
electronicAmount = "1.00"
compose.setContent { FaceRecognitionResultScreen(source = AppRoutes.RECENT_RESULT_SOURCE, viewModel = resultVm) }
compose.waitUntil(10_000) { resultVm.recentPhotosState.value == RecentPhotosState.EMPTY }
compose.waitForIdle()
assertTrue("Entering recent photos must request prices even when the photo list is empty",
requests.any { it.url.encodedPath.endsWith("verify-result") })
compose.waitUntil(10_000) { !resultVm.quoteLoading.value }
compose.onNodeWithText("打印版 2元/张").assertIsDisplayed()
compose.onNodeWithText("电子版 1元/张").assertIsDisplayed()
compose.onNodeWithText("购买打印照片\n0元").assertIsNotEnabled()
compose.onNodeWithText("购买电子照片\n0元").assertIsNotEnabled()
}
@Test fun recentPhotosSelectionUpdatesBothQuotesWithoutReloadReset() {
electronicAmount = "1.00"
val bitmap = Bitmap.createBitmap(160, 100, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.BLUE) }
val photos = (1..2).map { id ->
val file = File(context.cacheDir, "recent-price-$id.png")
file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
val url = file.toURI().toString()
FaceSearchResult(id, url, url, url, url)
}
recentBody = Gson().toJson(mapOf("count" to 2, "results" to photos))
compose.setContent { FaceRecognitionResultScreen(source = AppRoutes.RECENT_RESULT_SOURCE, viewModel = resultVm) }
compose.waitUntil(10_000) { resultVm.recentPhotosState.value == RecentPhotosState.READY && !resultVm.quoteLoading.value }
compose.onNodeWithText("打印版 2元/张").assertIsDisplayed()
compose.onNodeWithText("电子版 1元/张").assertIsDisplayed()
compose.onNodeWithText("购买电子照片\n0元").assertIsNotEnabled()
assertEquals(1, requests.count { it.url.encodedPath.endsWith("verify-result") })
compose.runOnIdle { resultVm.toggleSelectAll() }
compose.waitUntil(10_000) { !resultVm.quoteLoading.value && resultVm.electronicTotal.value == "2.00" }
compose.onNodeWithText("购买打印照片\n4元").assertIsEnabled()
compose.onNodeWithText("购买电子照片\n2元").assertIsEnabled()
compose.runOnIdle { resultVm.loadRecentPhotos() }
compose.waitForIdle()
assertEquals("4.00", resultVm.totalPrice.value)
assertEquals("2.00", resultVm.electronicTotal.value)
assertEquals(2, requests.count { it.url.encodedPath.endsWith("verify-result") })
compose.runOnIdle { resultVm.toggleSelectAll() }
compose.waitUntil(10_000) { !resultVm.quoteLoading.value && resultVm.totalPrice.value == "0.00" }
compose.onNodeWithText("打印版 2元/张").assertIsDisplayed()
compose.onNodeWithText("电子版 1元/张").assertIsDisplayed()
compose.onNodeWithText("购买电子照片\n0元").assertIsNotEnabled()
}
@Test fun errorCanRetryIntoEmptyState() { @Test fun errorCanRetryIntoEmptyState() {
electronicAmount = "1.00"
recentCode = 403 recentCode = 403
compose.setContent { FaceRecognitionResultScreen(source = AppRoutes.RECENT_RESULT_SOURCE, viewModel = resultVm) } compose.setContent { FaceRecognitionResultScreen(source = AppRoutes.RECENT_RESULT_SOURCE, viewModel = resultVm) }
compose.waitUntil(10_000) { resultVm.recentPhotosState.value == RecentPhotosState.ERROR } compose.waitUntil(10_000) { resultVm.recentPhotosState.value == RecentPhotosState.ERROR }
compose.onNodeWithText("照片加载失败,请重试").assertIsDisplayed() compose.onNodeWithText("照片加载失败,请重试").assertIsDisplayed()
compose.onNodeWithText("支付0元").assertIsNotEnabled() compose.waitUntil(10_000) { !resultVm.quoteLoading.value }
compose.onNodeWithText("打印版 2元/张").assertIsDisplayed()
compose.onNodeWithText("电子版 1元/张").assertIsDisplayed()
compose.onNodeWithText("购买打印照片\n0元").assertIsNotEnabled()
recentCode = 200 recentCode = 200
compose.onNodeWithText("重试").performClick() compose.onNodeWithText("重试").performClick()
compose.waitUntil(10_000) { resultVm.recentPhotosState.value == RecentPhotosState.EMPTY } compose.waitUntil(10_000) { resultVm.recentPhotosState.value == RecentPhotosState.EMPTY }
@@ -47,6 +47,7 @@ class LocalAudioPlayService @Inject constructor(
AppRoutes.PHOTO_SELECT to R.raw.upload_unpaid_result_page, AppRoutes.PHOTO_SELECT to R.raw.upload_unpaid_result_page,
AppRoutes.PRINTING to R.raw.printing_page, AppRoutes.PRINTING to R.raw.printing_page,
AppRoutes.PRINT_SUCCESS to R.raw.print_complete_page, AppRoutes.PRINT_SUCCESS to R.raw.print_complete_page,
ELECTRONIC_COMPLETION_AUDIO_KEY to R.raw.electronic_complete_page,
// 特殊状态页面 // 特殊状态页面
"face_recognition_recognizing" to R.raw.face_recognition_recognizing_page, "face_recognition_recognizing" to R.raw.face_recognition_recognizing_page,
"face_recognition_failed" to R.raw.face_recognition_failed_page, "face_recognition_failed" to R.raw.face_recognition_failed_page,
@@ -3,11 +3,16 @@ package com.yzx.kiosk.audio
import com.yzx.kiosk.navigation.routes.AppRoutes import com.yzx.kiosk.navigation.routes.AppRoutes
import java.net.URLDecoder import java.net.URLDecoder
internal const val ELECTRONIC_COMPLETION_AUDIO_KEY = "electronic_completion"
internal const val RECENT_PHOTOS_AUDIO_KEY = "recent_photos_result" internal const val RECENT_PHOTOS_AUDIO_KEY = "recent_photos_result"
/** Keep result-page audio tied to the entry source, not just the destination name. */ /** Keep result-page audio tied to the entry source, not just the destination name. */
internal fun localAudioKeyForRoute(route: String): String { internal fun localAudioKeyForRoute(route: String): String {
val baseRoute = route.substringBefore("?") val baseRoute = route.substringBefore("?")
if (baseRoute == AppRoutes.ELECTRONIC_COMPLETION) {
return ELECTRONIC_COMPLETION_AUDIO_KEY
}
if (baseRoute != AppRoutes.FACE_RECOGNITION_RESULT) return baseRoute if (baseRoute != AppRoutes.FACE_RECOGNITION_RESULT) return baseRoute
val source = route.substringAfter("?", "").substringBefore("#") val source = route.substringAfter("?", "").substringBefore("#")
@@ -30,6 +30,7 @@ import kotlinx.coroutines.flow.collectLatest
import java.net.URLDecoder import java.net.URLDecoder
import java.net.URLEncoder import java.net.URLEncoder
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
@Composable @Composable
fun AppNavHost( fun AppNavHost(
navigator: AppNavigator, navigator: AppNavigator,
@@ -209,6 +210,18 @@ fun AppNavHost(
) )
} }
// 电子版订单完成页,不创建打印页面或打印 ViewModel。
composable(
route = AppRoutes.ELECTRONIC_COMPLETION_PATTERN,
arguments = listOf(
navArgument("orderNumber") { type = NavType.StringType },
),
) { entry ->
com.yzx.kiosk.ui.face.view.ElectronicCompletionScreen(
orderNumber = entry.arguments?.getString("orderNumber").orEmpty(),
)
}
// 协议页面 // 协议页面
agreementScreen() agreementScreen()
} }
@@ -8,6 +8,12 @@ object AppRoutes {
const val UPLOAD_PHOTO = "upload_photo" const val UPLOAD_PHOTO = "upload_photo"
const val PHOTO_SELECT = "photo_select" const val PHOTO_SELECT = "photo_select"
const val PRINTING = "printing" const val PRINTING = "printing"
const val ELECTRONIC_COMPLETION = "electronic_completion"
const val ELECTRONIC_COMPLETION_PATTERN = "$ELECTRONIC_COMPLETION?orderNumber={orderNumber}"
fun buildElectronicCompletionRoute(orderNumber: String): String =
"$ELECTRONIC_COMPLETION?orderNumber=${java.net.URLEncoder.encode(orderNumber, "UTF-8")}"
const val PRINT_SUCCESS = "print_success" const val PRINT_SUCCESS = "print_success"
const val FACE_RECOGNITION = "face_recognition" const val FACE_RECOGNITION = "face_recognition"
const val FACE_RECOGNITION_RESULT = "face_recognition_result" const val FACE_RECOGNITION_RESULT = "face_recognition_result"
@@ -6,6 +6,10 @@ data class GetPayUrlRequest(
@SerializedName("type") @SerializedName("type")
val type: Int, val type: Int,
@SerializedName("image_id") @SerializedName("image_id")
val imageId: List<Int> val imageId: List<Int>,
@SerializedName("purchase_mode")
val purchaseMode: String? = null,
@SerializedName("video_id")
val videoId: List<Int>? = null
) )
@@ -6,6 +6,8 @@ data class VerifyResultRequest(
@SerializedName("type") @SerializedName("type")
val type: Int, val type: Int,
@SerializedName("image_id") @SerializedName("image_id")
val imageId: List<Int> val imageId: List<Int>,
@SerializedName("video_id")
val videoId: List<Int>? = null
) )
@@ -7,6 +7,12 @@ data class GetPayUrlResponse(
val url: String?, val url: String?,
@SerializedName("order_number") @SerializedName("order_number")
val orderNumber: String? val orderNumber: String?,
@SerializedName("purchase_mode")
val purchaseMode: String? = null,
@SerializedName("amount")
val amount: String? = null,
@SerializedName("order_status")
val orderStatus: Int? = null
) )
@@ -27,5 +27,8 @@ data class PaySuccessMessageData(
val captureType: Int?, val captureType: Int?,
@SerializedName("image_id") @SerializedName("image_id")
val imageIds: List<Int>? val imageIds: List<Int>?,
@SerializedName("purchase_mode")
val purchaseMode: String? = null
) )
@@ -6,6 +6,10 @@ data class VerifyResultResponse(
@SerializedName("price_image") @SerializedName("price_image")
val priceImage: String?, val priceImage: String?,
@SerializedName("amount") @SerializedName("amount")
val amount: String? val amount: String?,
@SerializedName("price_electronic")
val priceElectronic: String? = null,
@SerializedName("amount_electronic")
val amountElectronic: String? = null
) )
@@ -0,0 +1,187 @@
package com.yzx.kiosk.ui.face.view
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowLeft
import androidx.compose.material.icons.rounded.Call
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.tooling.preview.Preview
import androidx.hilt.navigation.compose.hiltViewModel
import com.yzx.kiosk.R
import com.yzx.kiosk.navigation.routes.AppRoutes
import com.yzx.kiosk.theme.AppTheme
import com.yzx.kiosk.ui.common.view.FullScreenMode
import com.yzx.kiosk.ui.face.viewmodel.ElectronicCompletionViewModel
import com.yzx.kiosk.utils.QrCodeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Composable
fun ElectronicCompletionScreen(
orderNumber: String,
viewModel: ElectronicCompletionViewModel = hiltViewModel(),
) {
val countdown by viewModel.countdown.collectAsState()
val qrUrl by viewModel.qrCodeUrl.collectAsState()
val failed by viewModel.qrCodeFailed.collectAsState()
val hotline by viewModel.hotline.collectAsState()
LaunchedEffect(orderNumber) { viewModel.initialize(orderNumber) }
var renderAttempt by remember { mutableIntStateOf(0) }
val renderedQr by produceState<Pair<ImageBitmap?, Boolean>>(null to false, qrUrl, renderAttempt) {
value = null to false
if (qrUrl.isNotBlank()) {
value = withContext(Dispatchers.Default) {
val result = runCatching {
QrCodeUtils.generateStyledQrBitmap(
content = qrUrl, size = 800, qrColor = android.graphics.Color.BLACK,
bgColor = android.graphics.Color.WHITE, cornerRadius = 0f,
).asImageBitmap()
}
result.getOrNull() to result.isFailure
}
}
}
val returnHome = { viewModel.closeAllExcept(AppRoutes.HOME) }
BackHandler(onBack = returnHome)
FullScreenMode()
ElectronicCompletionContent(
bitmap = renderedQr.first,
failed = failed || renderedQr.second,
countdown = countdown,
hotline = hotline,
onRetry = {
renderAttempt += 1
viewModel.retryQrCode()
},
onReturnHome = returnHome,
)
}
/** Reference is 941 × 1672. Uniform density keeps typography and spacing in proportion
* on the portrait kiosk, without stretching the QR code on other display sizes. */
@Composable
internal fun ElectronicCompletionContent(
bitmap: ImageBitmap?,
failed: Boolean,
countdown: Int,
hotline: String,
onRetry: () -> Unit,
onReturnHome: () -> Unit,
) {
val primary = MaterialTheme.colorScheme.primary
val resources = LocalContext.current.resources
// Only the decorative phone and footer are used from the artwork. The QR, copy,
// hotline, buttons and all panels below are native, live Compose content.
val artwork = remember(resources) {
val source = BitmapFactory.decodeResource(resources, R.drawable.electronic_completion_artwork)
val phone = Bitmap.createBitmap(source, 34, 354, 424, 635).asImageBitmap()
val footer = Bitmap.createBitmap(source, 0, 1440, 941, 232).asImageBitmap()
source.recycle()
phone to footer
}
BoxWithConstraints(Modifier.fillMaxSize().background(Color.White), contentAlignment = Alignment.Center) {
val density = LocalDensity.current
val scale = minOf(maxWidth.value / 941f, maxHeight.value / 1672f)
val canvasHeight = maxOf(1672f, maxHeight.value / scale)
CompositionLocalProvider(LocalDensity provides Density(density.density * scale, fontScale = 1f)) {
Box(Modifier.requiredSize(941.dp, canvasHeight.dp).background(Color.White)) {
Box(Modifier.fillMaxWidth().height(186.dp).background(primary)) {
IconButton(onClick = onReturnHome, modifier = Modifier.offset(20.dp, 91.dp).size(80.dp)) {
Icon(Icons.AutoMirrored.Rounded.KeyboardArrowLeft, "返回首页", tint = Color.White, modifier = Modifier.size(58.dp))
}
Text("获取电子照片", Modifier.align(Alignment.BottomCenter).padding(bottom = 30.dp),
color = Color.White, fontSize = 42.sp, fontWeight = FontWeight.Bold)
Text("${countdown}秒后返回首页", Modifier.align(Alignment.TopEnd).padding(top = 28.dp, end = 36.dp),
color = Color.White.copy(alpha = .9f), fontSize = 23.sp)
}
Box(Modifier.offset(y = 186.dp).fillMaxWidth().height(138.dp).background(primary.copy(alpha = .055f)),
contentAlignment = Alignment.Center) {
Text("请使用微信扫描下方二维码,获取电子版照片\n可多人多次扫描",
color = Color(0xFF55585C), fontSize = 29.sp, lineHeight = 46.sp, textAlign = TextAlign.Center)
}
Image(artwork.first, "电子照片保存到手机示意图", Modifier.offset(34.dp, 354.dp).size(424.dp, 635.dp),
contentScale = ContentScale.FillBounds)
Box(Modifier.offset(483.dp, 354.dp).size(424.dp, 635.dp)
.background(Color(0xFFF5F5F6), RoundedCornerShape(28.dp))) {
Box(Modifier.offset(27.dp, 34.dp).size(369.dp, 354.dp)
.background(Color.White, RoundedCornerShape(26.dp)), contentAlignment = Alignment.Center) {
when {
bitmap != null -> Image(bitmap, "电子版照片下载二维码", Modifier.size(340.dp),
filterQuality = FilterQuality.None)
failed -> Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("二维码加载失败", fontSize = 26.sp, color = Color(0xFF55585C))
TextButton(onClick = onRetry) { Text("重新加载", fontSize = 26.sp) }
}
else -> CircularProgressIndicator(Modifier.size(52.dp), color = primary)
}
}
Text("请使用微信扫描", Modifier.offset(y = 410.dp).fillMaxWidth(),
color = Color(0xFF444444), fontSize = 29.sp, textAlign = TextAlign.Center)
Text("获取电子版照片", Modifier.offset(y = 462.dp).fillMaxWidth(),
color = Color.Black, fontSize = 32.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center)
Text("可多人多次扫描", Modifier.offset(y = 530.dp).fillMaxWidth(),
color = Color(0xFF555555), fontSize = 29.sp, textAlign = TextAlign.Center)
}
Box(Modifier.offset(34.dp, 1020.dp).size(873.dp, 387.dp)
.background(primary.copy(alpha = .055f), RoundedCornerShape(28.dp))) {
Icon(Icons.Rounded.Info, null, Modifier.offset(39.dp, 37.dp).size(50.dp), tint = primary)
Text("温馨提示", Modifier.offset(109.dp, 35.dp), color = primary, fontSize = 40.sp, fontWeight = FontWeight.Bold)
TipRow(1, "请使用微信扫描二维码获取电子版照片。", Modifier.offset(40.dp, 122.dp))
TipRow(2, "照片将以高清原图的形式提供,可多次下载。", Modifier.offset(40.dp, 186.dp))
TipRow(3, "如遇问题,请联系客服。", Modifier.offset(40.dp, 250.dp))
Row(Modifier.offset(109.dp, 312.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Rounded.Call, null, Modifier.size(37.dp), tint = primary)
Spacer(Modifier.width(24.dp))
Text("客服电话:${hotline.takeIf { it.isNotBlank() } ?: "暂无"}",
fontSize = 31.sp, color = primary, fontWeight = FontWeight.SemiBold)
}
}
Image(artwork.second, null, Modifier.align(Alignment.BottomCenter).size(941.dp, 232.dp),
contentScale = ContentScale.FillBounds)
}
}
}
}
@Composable
private fun TipRow(number: Int, text: String, modifier: Modifier = Modifier) {
val primary = MaterialTheme.colorScheme.primary
Row(modifier, verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(46.dp).background(primary.copy(alpha = .1f), CircleShape), contentAlignment = Alignment.Center) {
Text(number.toString(), color = primary, fontSize = 30.sp)
}
Spacer(Modifier.width(23.dp))
Text(text, color = Color(0xFF55585C), fontSize = 29.sp, lineHeight = 40.sp)
}
}
@Preview(name = "电子版完成页", widthDp = 941, heightDp = 1672, showBackground = true)
@Composable
private fun ElectronicCompletionPreview() {
AppTheme {
ElectronicCompletionContent(null, false, 90, "12345678910", {}, {})
}
}
@@ -1,7 +1,10 @@
package com.yzx.kiosk.ui.face.view package com.yzx.kiosk.ui.face.view
import com.yzx.kiosk.utils.formatPriceDisplay
import android.graphics.Bitmap import android.graphics.Bitmap
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import com.yzx.kiosk.ui.face.viewmodel.PurchaseMode
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
@@ -77,6 +80,12 @@ fun FaceRecognitionResultScreen(
val selectedPhotos by viewModel.selectedPhotos.collectAsState() val selectedPhotos by viewModel.selectedPhotos.collectAsState()
val pricePerPhoto by viewModel.pricePerPhoto.collectAsState() val pricePerPhoto by viewModel.pricePerPhoto.collectAsState()
val totalPrice by viewModel.totalPrice.collectAsState() val totalPrice by viewModel.totalPrice.collectAsState()
val electronicPrice by viewModel.electronicPrice.collectAsState()
val electronicTotal by viewModel.electronicTotal.collectAsState()
val creatingOrder by viewModel.creatingOrder.collectAsState()
val quoteLoading by viewModel.quoteLoading.collectAsState()
val quoteError by viewModel.quoteError.collectAsState()
val paymentAmount by viewModel.paymentAmount.collectAsState()
val imageLoadStates by viewModel.imageLoadStates.collectAsState() val imageLoadStates by viewModel.imageLoadStates.collectAsState()
var isOpeningRecentPhotos by remember { mutableStateOf(false) } var isOpeningRecentPhotos by remember { mutableStateOf(false) }
@@ -139,65 +148,59 @@ fun FaceRecognitionResultScreen(
shape = RoundedCornerShape(18.dp), shape = RoundedCornerShape(18.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFFF4F4F4)) colors = CardDefaults.cardColors(containerColor = Color(0xFFF4F4F4))
) { ) {
Column(Modifier.fillMaxWidth().padding(24.dp)) {
Row( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.fillMaxWidth() verticalAlignment = Alignment.CenterVertically,
.padding(24.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) { ) {
Column { Text("已选择 ${selectedCount} 张", fontSize = 24.sp, fontWeight = FontWeight.Bold)
Row(verticalAlignment = Alignment.CenterVertically) { Spacer(Modifier.weight(1f))
Text( Row(
text = "已选择", horizontalArrangement = Arrangement.spacedBy(20.dp),
fontSize = 24.sp, verticalAlignment = Alignment.CenterVertically,
fontWeight = FontWeight.Bold, ) {
color = Color.Black Text("打印版 ${formatPriceDisplay(pricePerPhoto)}元/张", fontSize = 21.sp)
) Text("电子版 ${formatPriceDisplay(electronicPrice)}元/张", fontSize = 21.sp)
Text(
modifier = Modifier.padding(top = 4.dp),
text = selectedCount.toString(),
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFF0073FF)
)
Text(
text = "张",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = Color.Black
)
} }
Spacer(modifier = Modifier.height(18.dp))
Text(
text = "打印${pricePerPhoto}元/张",
fontSize = 21.sp,
color = Color(0xFF000000)
)
} }
Spacer(Modifier.height(16.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
PurchaseMode.entries.forEach { mode ->
val isPrint = mode == PurchaseMode.PRINT
val total = if (isPrint) totalPrice else electronicTotal
Column(Modifier.weight(1f)) {
Button( Button(
onClick = { onClick = { viewModel.getPayUrl(mode) { showPayDialog = true } },
viewModel.getPayUrl { url -> enabled = selectedCount > 0 && total != "--" && !creatingOrder && !quoteLoading,
showPayDialog = true modifier = Modifier.fillMaxWidth().heightIn(min = 88.dp),
}
},
modifier = Modifier
.height(72.dp)
.widthIn(min = 180.dp),
shape = RoundedCornerShape(18.dp), shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF0073FF)), colors = ButtonDefaults.buttonColors(
enabled = selectedCount > 0 containerColor = if (isPrint) Color(0xFF0073FF) else Color(0xFFE3EFFF),
contentColor = if (isPrint) Color.White else Color(0xFF1756A9),
),
) { ) {
Text( Text(
text = if (selectedCount > 0) "支付${totalPrice}元" else "支付0元", text = if (!isPrint && total == "--" && !quoteLoading)
"电子版暂不可购买" else "${if (isPrint) "购买打印照片" else "购买电子照片"}\n${formatPriceDisplay(total)}元",
textAlign = TextAlign.Center,
fontSize = 24.sp, fontSize = 24.sp,
color = Color.White, lineHeight = 32.sp,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold,
) )
} }
} }
} }
}
if (quoteLoading) {
Text("正在获取价格…", modifier = Modifier.padding(top = 12.dp))
} else if (quoteError != null || totalPrice == "--") {
Text(quoteError ?: "打印价格暂不可用", modifier = Modifier.padding(top = 12.dp))
TextButton(onClick = { viewModel.retryQuote() }, enabled = !creatingOrder && !quoteLoading) {
Text("重新获取价格")
}
}
}
}
// Keep selection and navigation as separate click targets in the same toolbar. // Keep selection and navigation as separate click targets in the same toolbar.
Row( Row(
@@ -343,7 +346,7 @@ fun FaceRecognitionResultScreen(
} }
// 支付弹框 // 支付弹框
if (showPayDialog && selectedCount > 0 && payQrCodeUrl != null) { if (showPayDialog && payQrCodeUrl != null) {
DisposableEffect(payQrCodeUrl) { DisposableEffect(payQrCodeUrl) {
viewModel.startPayStatusPolling() viewModel.startPayStatusPolling()
onDispose { onDispose {
@@ -353,7 +356,7 @@ fun FaceRecognitionResultScreen(
WechatPayDialog( WechatPayDialog(
qrCodeUrl = payQrCodeUrl!!, qrCodeUrl = payQrCodeUrl!!,
totalPrice = totalPrice, totalPrice = paymentAmount,
onDismiss = { onDismiss = {
showPayDialog = false showPayDialog = false
viewModel.onPayDialogDismissed() viewModel.onPayDialogDismissed()
@@ -0,0 +1,80 @@
package com.yzx.kiosk.ui.face.viewmodel
import androidx.lifecycle.viewModelScope
import com.yzx.kiosk.base.BaseViewModel
import com.yzx.kiosk.datastore.AppState
import com.yzx.kiosk.datastore.AppStoreDataSource
import com.yzx.kiosk.navigation.AppNavigator
import com.yzx.kiosk.navigation.routes.AppRoutes
import com.yzx.kiosk.network.model.request.SaveAlbumUrlRequest
import com.yzx.kiosk.network.repository.NetWorkRepository
import com.yzx.kiosk.utils.ToastUtils
import com.yzx.kiosk.utils.LogUtils
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
/** Download-only completion: deliberately has no printer or print-download dependencies. */
@HiltViewModel
class ElectronicCompletionViewModel @Inject constructor(
navigator: AppNavigator,
appState: AppState,
appStoreDataSource: AppStoreDataSource,
private val netWorkRepository: NetWorkRepository,
) : BaseViewModel(navigator = navigator, appState = appState) {
private val _countdown = MutableStateFlow(90)
val countdown = _countdown.asStateFlow()
private val _qrCodeUrl = MutableStateFlow("")
val qrCodeUrl = _qrCodeUrl.asStateFlow()
private val _qrCodeFailed = MutableStateFlow(false)
val qrCodeFailed = _qrCodeFailed.asStateFlow()
val hotline = MutableStateFlow(appStoreDataSource.getHomepageServicePhone()).asStateFlow()
private var orderNumber: String? = null
private var qrJob: Job? = null
fun initialize(orderNumber: String) {
if (this.orderNumber != null) return
if (orderNumber.isBlank()) {
_qrCodeFailed.value = true
return
}
this.orderNumber = orderNumber
retryQrCode()
viewModelScope.launch {
while (_countdown.value > 0) {
delay(1_000)
_countdown.value -= 1
}
navigator.closeAllExcept(AppRoutes.HOME)
}
}
fun retryQrCode() {
val number = orderNumber ?: return
if (qrJob?.isActive == true) return
_qrCodeFailed.value = false
qrJob = viewModelScope.launch {
try {
val response = netWorkRepository.saveAlbumUrl(SaveAlbumUrlRequest(number)).first()
val url = response.data?.url?.trim()
if (response.isSucceeded && !url.isNullOrEmpty()) {
_qrCodeUrl.value = url
} else {
_qrCodeFailed.value = true
ToastUtils.show(response.message?.takeIf { it.isNotBlank() } ?: "二维码加载失败,请重试")
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
_qrCodeFailed.value = true
LogUtils.e("ElectronicCompletion", "获取二维码失败: ${e.message}")
}
}
}
}
@@ -10,8 +10,8 @@ internal enum class FacePayStatusDecision {
internal fun decideFacePayStatus(orderStatus: Int?): FacePayStatusDecision = when (orderStatus) { internal fun decideFacePayStatus(orderStatus: Int?): FacePayStatusDecision = when (orderStatus) {
30 -> FacePayStatusDecision.COMPLETE_PAYMENT 30 -> FacePayStatusDecision.COMPLETE_PAYMENT
40, 50, 60 -> FacePayStatusDecision.STOP_WITH_TERMINAL_STATUS 10 -> FacePayStatusDecision.CONTINUE_POLLING
else -> FacePayStatusDecision.CONTINUE_POLLING else -> FacePayStatusDecision.STOP_WITH_TERMINAL_STATUS
} }
internal fun isValidFacePaySuccessMessage( internal fun isValidFacePaySuccessMessage(
@@ -19,7 +19,7 @@ internal fun isValidFacePaySuccessMessage(
message: PaySuccessMessageResponse? message: PaySuccessMessageResponse?
): Boolean { ): Boolean {
val paymentData = message?.data ?: return false val paymentData = message?.data ?: return false
return message.type == 5 && return message.orderStatus == 30 && message.type == 5 &&
paymentData.orderNumber == expectedOrderNumber && paymentData.orderNumber == expectedOrderNumber &&
paymentData.captureType == 2 && paymentData.captureType == 2 &&
!paymentData.imageIds.isNullOrEmpty() !paymentData.imageIds.isNullOrEmpty()
@@ -0,0 +1,60 @@
package com.yzx.kiosk.ui.face.viewmodel
import com.yzx.kiosk.network.model.response.GetPayUrlResponse
import java.math.BigDecimal
import java.math.RoundingMode
enum class PurchaseMode(val wireValue: String, val label: String) {
PRINT("print", "打印版"), ELECTRONIC("electronic", "电子版")
}
internal fun money(value: String?): String? {
if (value == null || !Regex("[0-9]+(?:\\.[0-9]{1,2})?").matches(value)) return null
return value.toBigDecimalOrNull()?.setScale(2, RoundingMode.UNNECESSARY)?.toPlainString()
}
internal fun isPositiveMoney(value: String?): Boolean =
money(value)?.toBigDecimal()?.let { it > BigDecimal.ZERO } == true
internal fun electronicQuoteAvailable(unit: String?, total: String?, hasPhotos: Boolean): Boolean =
isPositiveMoney(unit) && money(total) != null && (!hasPhotos || isPositiveMoney(total))
internal data class FacePaymentOrder(
val number: String,
val mode: PurchaseMode,
val imageIds: Set<Int>,
val amount: String,
val legacyPrint: Boolean,
) {
fun matches(number: String?, captureType: Int?, ids: List<Int>, purchaseMode: String?): Boolean =
this.number == number && captureType == 2 && ids.isNotEmpty() &&
ids.size == ids.toSet().size && imageIds == ids.toSet() &&
(purchaseMode == mode.wireValue || (legacyPrint && purchaseMode == null))
}
internal fun createFacePaymentOrder(
response: GetPayUrlResponse,
mode: PurchaseMode,
ids: List<Int>,
quotedAmount: String,
): FacePaymentOrder? {
val number = response.orderNumber?.trim()?.takeIf { it.isNotEmpty() } ?: return null
// Only an entirely old-style print response may omit the new order fields.
val legacy = mode == PurchaseMode.PRINT && response.purchaseMode == null && response.amount == null
if (!legacy && response.purchaseMode != mode.wireValue) return null
val amount = money(if (legacy) quotedAmount else response.amount) ?: return null
if (ids.isEmpty()) return null
val order = FacePaymentOrder(number, mode, ids.toSet(), amount, legacy)
if (mode == PurchaseMode.ELECTRONIC && !isPositiveMoney(amount)) return null
// The link represents a pending preselection, never proof of purchase.
if (!legacy && response.orderStatus != 10) return null
if (response.url.isNullOrBlank()) return null
return order
}
/** Versioning also rejects responses from an old A selection after A -> B -> A. */
internal class QuoteRevision {
private var revision = 0L
fun next(): Long = ++revision
fun isCurrent(candidate: Long): Boolean = revision == candidate
}
@@ -12,7 +12,6 @@ import com.yzx.kiosk.network.model.response.FaceSearchResult
import com.yzx.kiosk.network.service.FaceSearchService import com.yzx.kiosk.network.service.FaceSearchService
import com.yzx.kiosk.network.service.buildRecentPhotosUrl import com.yzx.kiosk.network.service.buildRecentPhotosUrl
import com.yzx.kiosk.network.repository.NetWorkRepository import com.yzx.kiosk.network.repository.NetWorkRepository
import com.yzx.kiosk.network.result.asResult
import com.yzx.kiosk.ui.upload.viewmodel.FileMapData import com.yzx.kiosk.ui.upload.viewmodel.FileMapData
import com.yzx.kiosk.ui.upload.viewmodel.PhotoData import com.yzx.kiosk.ui.upload.viewmodel.PhotoData
import com.yzx.kiosk.utils.ToastUtils import com.yzx.kiosk.utils.ToastUtils
@@ -75,7 +74,11 @@ class FaceRecognitionResultViewModel @Inject constructor(
toPage(AppRoutes.buildRecentPhotosRoute()) toPage(AppRoutes.buildRecentPhotosRoute())
} }
fun loadRecentPhotos() = recentLoader.load() fun loadRecentPhotos() {
// Pricing belongs to the page, not to a successful/non-empty photo response.
if (recentPhotosState.value == RecentPhotosState.IDLE) verifyResult(emptyList())
recentLoader.load()
}
fun retryRecentPhotos() = recentLoader.load(retry = true) fun retryRecentPhotos() = recentLoader.load(retry = true)
// 原始结果列表(用于获取图片id) // 原始结果列表(用于获取图片id)
@@ -100,6 +103,24 @@ class FaceRecognitionResultViewModel @Inject constructor(
private val _totalPrice = MutableStateFlow<String>("--") private val _totalPrice = MutableStateFlow<String>("--")
val totalPrice: StateFlow<String> = _totalPrice.asStateFlow() val totalPrice: StateFlow<String> = _totalPrice.asStateFlow()
private val _electronicPrice = MutableStateFlow("--")
val electronicPrice = _electronicPrice.asStateFlow()
private val _electronicTotal = MutableStateFlow("--")
val electronicTotal = _electronicTotal.asStateFlow()
private val _creatingOrder = MutableStateFlow(false)
val creatingOrder = _creatingOrder.asStateFlow()
private val _quoteLoading = MutableStateFlow(false)
val quoteLoading = _quoteLoading.asStateFlow()
private val _quoteError = MutableStateFlow<String?>(null)
val quoteError = _quoteError.asStateFlow()
private val _paymentAmount = MutableStateFlow("--")
val paymentAmount = _paymentAmount.asStateFlow()
private val _paymentMode = MutableStateFlow(PurchaseMode.PRINT)
val paymentMode = _paymentMode.asStateFlow()
private var activeOrder: FacePaymentOrder? = null
private val quoteRevision = QuoteRevision()
private var quoteJob: Job? = null
// 支付二维码URL // 支付二维码URL
private val _payQrCodeUrl = MutableStateFlow<String?>(null) private val _payQrCodeUrl = MutableStateFlow<String?>(null)
val payQrCodeUrl: StateFlow<String?> = _payQrCodeUrl.asStateFlow() val payQrCodeUrl: StateFlow<String?> = _payQrCodeUrl.asStateFlow()
@@ -139,6 +160,7 @@ class FaceRecognitionResultViewModel @Inject constructor(
orderNumber = event.orderNumber, orderNumber = event.orderNumber,
captureType = event.captureType, captureType = event.captureType,
imageIds = event.imageIds, imageIds = event.imageIds,
purchaseMode = event.purchaseMode,
source = "WebSocket" source = "WebSocket"
) )
} }
@@ -159,6 +181,7 @@ class FaceRecognitionResultViewModel @Inject constructor(
// Returning from a pushed recent-photos page must retain selection, pricing and image state. // Returning from a pushed recent-photos page must retain selection, pricing and image state.
if (faceResultsInitialized) return if (faceResultsInitialized) return
faceResultsInitialized = true faceResultsInitialized = true
verifyResult(emptyList())
viewModelScope.launch { applyPhotoList(results) } viewModelScope.launch { applyPhotoList(results) }
} }
@@ -197,8 +220,6 @@ class FaceRecognitionResultViewModel @Inject constructor(
// 初始化加载状态 // 初始化加载状态
_imageLoadStates.value = initialPhotos.associate { it.url to ImageLoadState() } _imageLoadStates.value = initialPhotos.associate { it.url to ImageLoadState() }
// 页面初始化时调用验证接口(空列表,type: 2),获取默认价格
verifyResult(emptyList())
LogUtils.d(TAG, "图片列表初始化完成,共 ${initialPhotos.size} 张,宽高比将在图片加载成功后逐张更新") LogUtils.d(TAG, "图片列表初始化完成,共 ${initialPhotos.size} 张,宽高比将在图片加载成功后逐张更新")
} }
@@ -234,6 +255,7 @@ class FaceRecognitionResultViewModel @Inject constructor(
* 切换单张图片选中状态 * 切换单张图片选中状态
*/ */
fun togglePhotoSelection(url: String) { fun togglePhotoSelection(url: String) {
if (_creatingOrder.value) return
val current = _selectedPhotos.value.toMutableSet() val current = _selectedPhotos.value.toMutableSet()
if (current.contains(url)) { if (current.contains(url)) {
current.remove(url) current.remove(url)
@@ -251,6 +273,7 @@ class FaceRecognitionResultViewModel @Inject constructor(
* 切换全选状态 * 切换全选状态
*/ */
fun toggleSelectAll() { fun toggleSelectAll() {
if (_creatingOrder.value) return
if (_photoList.value.isEmpty()) return if (_photoList.value.isEmpty()) return
val allUrls = _photoList.value.map { it.url }.toSet() val allUrls = _photoList.value.map { it.url }.toSet()
val newSelected = if (_selectedPhotos.value.size == allUrls.size) { val newSelected = if (_selectedPhotos.value.size == allUrls.size) {
@@ -280,100 +303,109 @@ class FaceRecognitionResultViewModel @Inject constructor(
* 验证结果接口 * 验证结果接口
*/ */
private fun verifyResult(imageIds: List<Int>) { private fun verifyResult(imageIds: List<Int>) {
viewModelScope.launch { clearActivePayment()
_quoteLoading.value = true
_quoteError.value = null
val revision = quoteRevision.next()
quoteJob?.cancel()
_pricePerPhoto.value = "--"
_totalPrice.value = "--"
_electronicPrice.value = "--"
_electronicTotal.value = "--"
quoteJob = viewModelScope.launch {
try { try {
val request = VerifyResultRequest( val response = netWorkRepository.verifyResult(VerifyResultRequest(2, imageIds.distinct(), videoId = emptyList())).first()
type = 2, if (!quoteRevision.isCurrent(revision)) return@launch
imageId = imageIds if (!response.isSucceeded) {
) _quoteError.value = response.message?.takeIf { it.isNotBlank() } ?: "获取报价失败,请重试"
handleResultWithData(
flow = netWorkRepository.verifyResult(request).asResult(),
showToast = false,
onData = { response ->
// 更新单价(使用接口返回的 price_image)
val priceImage = response.priceImage
if (!priceImage.isNullOrEmpty()) {
_pricePerPhoto.value = priceImage
} else {
_pricePerPhoto.value = "--"
}
// 更新总价(使用接口返回的 amount)
val amount = response.amount
if (!amount.isNullOrEmpty()) {
_totalPrice.value = amount
} else {
_totalPrice.value = "--"
}
LogUtils.d("FaceRecognitionResultViewModel", "验证结果成功 - 单价: $priceImage, 总价: $amount")
},
onError = { msg, _ ->
LogUtils.e("FaceRecognitionResultViewModel", "验证结果失败: $msg")
// 接口失败时,保持当前值或设置为默认值
if (imageIds.isEmpty()) {
_pricePerPhoto.value = "--"
_totalPrice.value = "--"
}
}
)
} catch (e: Exception) {
LogUtils.e("FaceRecognitionResultViewModel", "验证结果异常: ${e.message}")
if (imageIds.isEmpty()) {
_pricePerPhoto.value = "--"
_totalPrice.value = "--"
}
}
}
}
/**
* 获取支付URL
*/
fun getPayUrl(onSuccess: (String) -> Unit) {
viewModelScope.launch {
val selectedIds = getSelectedImageIds()
if (selectedIds.isEmpty()) {
ToastUtils.show("请选择要打印的照片")
return@launch return@launch
} }
val data = response.data
if (data == null) {
_quoteError.value = "报价数据不完整,请重试"
return@launch
}
val printUnit = money(data.priceImage)
val printTotal = money(data.amount)
if (printUnit != null && printTotal != null) {
_pricePerPhoto.value = printUnit
_totalPrice.value = printTotal
}
val electronicUnit = money(data.priceElectronic)
val electronicTotal = money(data.amountElectronic)
if (electronicQuoteAvailable(electronicUnit, electronicTotal, imageIds.isNotEmpty())) {
_electronicPrice.value = checkNotNull(electronicUnit)
_electronicTotal.value = checkNotNull(electronicTotal)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
LogUtils.e(TAG, "获取报价失败: ${e.message}")
if (quoteRevision.isCurrent(revision)) _quoteError.value = "网络异常,请重新获取价格"
} finally {
if (quoteRevision.isCurrent(revision)) _quoteLoading.value = false
}
}
}
private fun clearActivePayment() {
stopPayStatusPolling()
activeOrder = null
activePaymentOrderNumber = null
_payQrCodeUrl.value = null
_dismissPayDialogEvents.tryEmit(Unit)
}
private fun recoverPayment(message: String) {
clearActivePayment()
ToastUtils.show(message)
retryQuote()
}
fun retryQuote() = verifyResult(getSelectedImageIds())
fun getPayUrl(mode: PurchaseMode, onSuccess: (String) -> Unit) {
if (_creatingOrder.value) return
val selectedIds = getSelectedImageIds().distinct()
val quotedAmount = if (mode == PurchaseMode.PRINT) _totalPrice.value else _electronicTotal.value
if (selectedIds.isEmpty() || money(quotedAmount) == null || _quoteLoading.value) return
if (mode == PurchaseMode.ELECTRONIC && !isPositiveMoney(quotedAmount)) return
clearActivePayment()
_creatingOrder.value = true
viewModelScope.launch {
showLoading() showLoading()
try { try {
val request = GetPayUrlRequest( val response = netWorkRepository.getPayUrl(
type = 2, GetPayUrlRequest(2, selectedIds, mode.wireValue, videoId = emptyList())
imageId = selectedIds ).first()
) if (!response.isSucceeded) {
recoverPayment(response.message?.takeIf { it.isNotBlank() } ?: "获取支付链接失败,请重新选择")
handleResultWithData( return@launch
flow = netWorkRepository.getPayUrl(request).asResult(), }
showToast = false, val data = response.data
onData = { response -> val order = if (data != null)
val url = response.url?.trim() createFacePaymentOrder(data, mode, selectedIds, quotedAmount) else null
val orderNumber = response.orderNumber?.trim() if (order == null) {
if (url.isNullOrEmpty() || orderNumber.isNullOrEmpty()) { recoverPayment("支付链接信息无效,请重新选择")
_payQrCodeUrl.value = null return@launch
LogUtils.e(TAG, "获取支付二维码成功,但 url 或 order_number 为空") }
ToastUtils.show("获取支付二维码失败")
} else {
stopPayStatusPolling() stopPayStatusPolling()
activePaymentOrderNumber = orderNumber activeOrder = order
activePaymentOrderNumber = order.number
paymentHandled.set(false) paymentHandled.set(false)
_paymentAmount.value = order.amount
_paymentMode.value = order.mode
val url = checkNotNull(data?.url).trim()
_payQrCodeUrl.value = url _payQrCodeUrl.value = url
onSuccess(url) onSuccess(url)
} } catch (e: CancellationException) {
}, throw e
onError = { msg, _ ->
LogUtils.e("FaceRecognitionResultViewModel", "获取支付URL失败: $msg")
ToastUtils.show("获取支付二维码失败")
},
onEnd = {
hideLoading()
}
)
} catch (e: Exception) { } catch (e: Exception) {
LogUtils.e("FaceRecognitionResultViewModel", "获取支付URL异常: ${e.message}") LogUtils.e(TAG, "创建订单失败: ${e.message}")
ToastUtils.show("获取支付二维码失败") ToastUtils.show("创建订单失败,请重试")
} finally {
_creatingOrder.value = false
hideLoading()
} }
} }
} }
@@ -420,14 +452,17 @@ class FaceRecognitionResultViewModel @Inject constructor(
.getPaySuccessMessage(PaySuccessMessageRequest(orderNumber)) .getPaySuccessMessage(PaySuccessMessageRequest(orderNumber))
.first() .first()
if (activePaymentOrderNumber != orderNumber || paymentHandled.get()) return
if (!response.isSucceeded) { if (!response.isSucceeded) {
LogUtils.e( LogUtils.e(
TAG, TAG,
"查询支付状态业务失败 - order_number: $orderNumber, code: ${response.code}, msg: ${response.message}" "查询支付状态业务失败 - order_number: $orderNumber, code: ${response.code}, msg: ${response.message}"
) )
recoverPayment(response.message?.takeIf { it.isNotBlank() } ?: "订单已失效,请重新选择照片")
return return
} }
if (activePaymentOrderNumber != orderNumber) return
val message = response.data val message = response.data
when (decideFacePayStatus(message?.orderStatus)) { when (decideFacePayStatus(message?.orderStatus)) {
FacePayStatusDecision.CONTINUE_POLLING -> { FacePayStatusDecision.CONTINUE_POLLING -> {
@@ -448,6 +483,7 @@ class FaceRecognitionResultViewModel @Inject constructor(
orderNumber = paymentData.orderNumber, orderNumber = paymentData.orderNumber,
captureType = paymentData.captureType, captureType = paymentData.captureType,
imageIds = paymentData.imageIds.orEmpty(), imageIds = paymentData.imageIds.orEmpty(),
purchaseMode = paymentData.purchaseMode,
source = "HTTP" source = "HTTP"
) )
} }
@@ -470,16 +506,17 @@ class FaceRecognitionResultViewModel @Inject constructor(
stopPayStatusPolling() stopPayStatusPolling()
activePaymentOrderNumber = null activePaymentOrderNumber = null
activeOrder = null
_payQrCodeUrl.value = null _payQrCodeUrl.value = null
_dismissPayDialogEvents.tryEmit(Unit) _dismissPayDialogEvents.tryEmit(Unit)
val statusText = orderStatusName?.takeIf { it.isNotBlank() } ?: when (orderStatus) { val statusText = orderStatusName?.takeIf { it.isNotBlank() } ?: when (orderStatus) {
40 -> "已取消" 40 -> "已取消"
50 -> "已退款" 50 -> "已退款"
60 -> "部分退款"
else -> "状态异常" else -> "状态异常"
} }
ToastUtils.show("订单$statusText") ToastUtils.show("订单$statusText")
retryQuote()
LogUtils.i(TAG, "订单进入终态,停止支付轮询 - status: $orderStatus, name: $statusText") LogUtils.i(TAG, "订单进入终态,停止支付轮询 - status: $orderStatus, name: $statusText")
} }
@@ -487,19 +524,13 @@ class FaceRecognitionResultViewModel @Inject constructor(
orderNumber: String?, orderNumber: String?,
captureType: Int?, captureType: Int?,
imageIds: List<Int>, imageIds: List<Int>,
purchaseMode: String?,
source: String source: String
) { ) {
val expectedOrderNumber = activePaymentOrderNumber val expectedOrderNumber = activePaymentOrderNumber
if ( val order = activeOrder ?: return
expectedOrderNumber.isNullOrEmpty() || if (!order.matches(orderNumber, captureType, imageIds, purchaseMode)) {
orderNumber != expectedOrderNumber || LogUtils.i(TAG, "忽略不匹配的支付成功消息 - source: $source, expected: $expectedOrderNumber")
captureType != 2 ||
imageIds.isEmpty()
) {
LogUtils.i(
TAG,
"忽略不匹配的支付成功消息 - source: $source, expected: $expectedOrderNumber, actual: $orderNumber, capture_type: $captureType, image_ids: $imageIds"
)
return return
} }
@@ -518,16 +549,16 @@ class FaceRecognitionResultViewModel @Inject constructor(
stopPayStatusPolling() stopPayStatusPolling()
_payQrCodeUrl.value = null _payQrCodeUrl.value = null
_dismissPayDialogEvents.tryEmit(Unit) _dismissPayDialogEvents.tryEmit(Unit)
navigateToPrinting(orderNumber, captureType, imageIds) navigateAfterPurchase(orderNumber, captureType, imageIds, order)
} }
/** /**
* 支付成功后直接跳转到打印页面 * 按已确认订单的模式进入打印流程或独立电子版完成页
* @param orderNumber 订单号 * @param orderNumber 订单号
* @param captureType 抓拍类型 * @param captureType 抓拍类型
* @param imageIds 图片ID数组(从支付成功消息中获取) * @param imageIds 图片ID数组(从支付成功消息中获取)
*/ */
private fun navigateToPrinting(orderNumber: String?, captureType: Int?, imageIds: List<Int>) { private fun navigateAfterPurchase(orderNumber: String?, captureType: Int?, imageIds: List<Int>, order: FacePaymentOrder) {
viewModelScope.launch { viewModelScope.launch {
if (orderNumber.isNullOrBlank() || captureType == null) { if (orderNumber.isNullOrBlank() || captureType == null) {
LogUtils.e( LogUtils.e(
@@ -567,7 +598,11 @@ class FaceRecognitionResultViewModel @Inject constructor(
.setPopUpTo(AppRoutes.FACE_RECOGNITION_RESULT, inclusive = true) .setPopUpTo(AppRoutes.FACE_RECOGNITION_RESULT, inclusive = true)
.build() .build()
val route = AppRoutes.buildPrintingRoute(jsonArray, orderNumber, captureType) val route = if (order.mode == PurchaseMode.ELECTRONIC) {
AppRoutes.buildElectronicCompletionRoute(orderNumber)
} else {
AppRoutes.buildPrintingRoute(jsonArray, orderNumber, captureType)
}
toPage(route, navOptions) toPage(route, navOptions)
} }
} }
@@ -92,6 +92,7 @@ fun PosterScreenWithLiveOrSlideshow(
} }
} }
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
@Composable @Composable
private fun PosterLiveStreamLayer( private fun PosterLiveStreamLayer(
url: String, url: String,
@@ -1,5 +1,6 @@
package com.yzx.kiosk.ui.upload.view package com.yzx.kiosk.ui.upload.view
import com.yzx.kiosk.utils.formatPriceDisplay
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -150,7 +151,7 @@ fun PhotoSelectScreen(
} }
Spacer(modifier = Modifier.height(18.dp)) Spacer(modifier = Modifier.height(18.dp))
Text( Text(
text = "打印${pricePerPhoto}元/张", text = "打印${formatPriceDisplay(pricePerPhoto)}元/张",
fontSize = 21.sp, fontSize = 21.sp,
color = Color(0xFF000000) color = Color(0xFF000000)
) )
@@ -170,7 +171,7 @@ fun PhotoSelectScreen(
enabled = selectedCount > 0 enabled = selectedCount > 0
) { ) {
Text( Text(
text = if (selectedCount > 0) "支付${totalPrice}元" else "支付0元", text = if (selectedCount > 0) "支付${formatPriceDisplay(totalPrice)}元" else "支付0元",
fontSize = 24.sp, fontSize = 24.sp,
color = Color.White, color = Color.White,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
@@ -1,5 +1,6 @@
package com.yzx.kiosk.ui.upload.view package com.yzx.kiosk.ui.upload.view
import com.yzx.kiosk.utils.formatPriceDisplay
import android.graphics.Bitmap import android.graphics.Bitmap
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -259,7 +260,7 @@ fun UploadPhotoScreen(
// 打印价格 // 打印价格
ServiceInfoRow( ServiceInfoRow(
label = "打印价格", label = "打印价格",
value = printPrice, value = formatPriceDisplay(printPrice),
valueColor = Color(0xFFEF4444) valueColor = Color(0xFFEF4444)
) )
@@ -0,0 +1,8 @@
package com.yzx.kiosk.utils
private val decimalPrice = Regex("[0-9]+\\.[0-9]+")
/** Display only; also preserves units such as 元/张 and placeholders such as --. */
fun formatPriceDisplay(value: String): String = decimalPrice.replace(value) {
it.value.toBigDecimal().stripTrailingZeros().toPlainString()
}
@@ -0,0 +1,30 @@
package com.yzx.kiosk.websocket
import com.google.gson.JsonObject
/** New gateway: code -> data(type=5) -> data(order). Older gateways used a flat data object. */
internal fun parsePurchaseCompletedEvent(message: JsonObject): UploadPhotoEvent.PaySuccess? = runCatching {
if (message.get("code")?.asString != "5") return null
val envelope = message.getAsJsonObject("data") ?: return null
val order = if (envelope.has("data")) {
if (envelope.get("type")?.asInt != 5) return null
envelope.getAsJsonObject("data") ?: return null
} else {
if (envelope.has("type") && envelope.get("type")?.asInt != 5) return null
envelope
}
val number = order.get("order_number")?.takeUnless { it.isJsonNull }?.asString
?.takeIf { it.isNotBlank() } ?: return null
val captureType = order.get("capture_type")?.asInt ?: return null
if (captureType !in listOf(1, 2)) return null
val imageIds = order.get("image_id")?.takeUnless { it.isJsonNull }
?.asJsonArray?.map { it.asInt }.orEmpty()
if (captureType == 2 && imageIds.isEmpty()) return null
UploadPhotoEvent.PaySuccess(
orderNumber = number,
captureType = captureType,
imageIds = imageIds,
fileMap = order.get("file_map")?.takeUnless { it.isJsonNull }?.toString().orEmpty(),
purchaseMode = order.get("purchase_mode")?.takeUnless { it.isJsonNull }?.asString,
)
}.getOrNull()
@@ -390,46 +390,13 @@ class WebSocketService @Inject constructor(
} }
} }
"5" -> { "5" -> {
// 支付成功消息 val event = parsePurchaseCompletedEvent(jsonObject)
LogUtils.d(TAG, ">>> 收到支付成功消息 (code=5) <<<") if (event != null) {
try {
val dataElement = jsonObject.get("data")
if (dataElement != null && dataElement.isJsonObject) {
val dataObj = dataElement.asJsonObject
val orderNumber = dataObj.get("order_number")?.asString
val captureType = dataObj.get("capture_type")?.asInt
val imageIdList = mutableListOf<Int>()
if (dataObj.has("image_id")) {
val imageIdArray = dataObj.get("image_id")
if (imageIdArray != null && imageIdArray.isJsonArray) {
imageIdArray.asJsonArray.forEach { element ->
element.asInt.let { imageIdList.add(it) }
}
}
}
var fileMap = ""
if (dataObj.has("file_map")){
fileMap = dataObj.get("file_map").toString()
}
LogUtils.d(TAG, "支付成功 - order_number: $orderNumber, capture_type: $captureType, image_id: $imageIdList,fileMap:$fileMap")
// 发送支付成功事件
CoroutineScope(Dispatchers.Main).launch { CoroutineScope(Dispatchers.Main).launch {
_uploadPhotoEvents.emit( _uploadPhotoEvents.emit(event)
UploadPhotoEvent.PaySuccess(
orderNumber = orderNumber,
captureType = captureType,
imageIds = imageIdList,
fileMap = fileMap
)
)
} }
} } else {
} catch (e: Exception) { LogUtils.e(TAG, "忽略无效的购买完成消息")
LogUtils.e(TAG, "解析支付成功消息失败: ${e.message}")
} }
} }
"4" -> { "4" -> {
@@ -974,6 +941,7 @@ sealed class UploadPhotoEvent {
val orderNumber: String?, val orderNumber: String?,
val captureType: Int?, val captureType: Int?,
val fileMap:String, val fileMap:String,
val imageIds: List<Int> val imageIds: List<Int>,
val purchaseMode: String? = null
) : UploadPhotoEvent() ) : UploadPhotoEvent()
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 814 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 630 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 656 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 766 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 657 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 806 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 912 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 726 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 778 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 922 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 810 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 926 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 850 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 770 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 864 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 706 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 902 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 750 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 740 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 796 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 816 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 884 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 832 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 810 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 924 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1016 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 822 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 998 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 972 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Some files were not shown because too many files have changed in this diff Show More