Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c93572fbc | ||
|
|
e6b671ef76 | ||
|
|
5fe95f9255 | ||
|
|
835d6ecd00 | ||
|
|
6d81f1a28b | ||
|
|
2231c38792 | ||
|
|
fa827fdd99 | ||
|
|
5f8586666c | ||
|
|
b1cdc1fe06 | ||
|
|
712f6154bc | ||
|
|
1d66bd18d1 |
@@ -0,0 +1,2 @@
|
||||
/gradlew text eol=lf
|
||||
/gradlew.bat text eol=crlf
|
||||
@@ -89,8 +89,8 @@ android {
|
||||
applicationId = "com.yzx.kiosk"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 23
|
||||
versionName = "1.1.3"
|
||||
versionCode = 24
|
||||
versionName = "1.1.4"
|
||||
//multiDexEnabled = true
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
@@ -230,7 +230,6 @@ dependencies {
|
||||
|
||||
|
||||
implementation(libs.androidx.compose.foundation)
|
||||
implementation(libs.firebase.crashlytics.buildtools)
|
||||
compileOnly(libs.ksp.gradlePlugin)
|
||||
|
||||
implementation(libs.androidx.core.ktx)
|
||||
|
||||
@@ -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() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("com.zhifly.follow", appContext.packageName)
|
||||
assertEquals("com.yzx.kiosk", appContext.packageName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.yzx.kiosk.audio
|
||||
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.yzx.kiosk.R
|
||||
import com.yzx.kiosk.navigation.routes.AppRoutes
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class LocalAudioPlaybackTest {
|
||||
@Test fun recentAndFaceEntriesSwitchAudioWithoutDuplicatePlayback() {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
lateinit var service: LocalAudioPlayService
|
||||
lateinit var player: ExoPlayer
|
||||
val transitions = AtomicInteger()
|
||||
instrumentation.runOnMainSync {
|
||||
service = LocalAudioPlayService(instrumentation.targetContext)
|
||||
player = LocalAudioPlayService::class.java.getDeclaredField("exoPlayer").apply { isAccessible = true }.get(service) as ExoPlayer
|
||||
player.addListener(object : Player.Listener {
|
||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { transitions.incrementAndGet() }
|
||||
})
|
||||
}
|
||||
try {
|
||||
instrumentation.runOnMainSync { service.playByRoute(AppRoutes.buildRecentPhotosRoute()) }
|
||||
awaitPlaying(service)
|
||||
assertEquals(R.raw.recent_photos_result_page, service.currentResId.value)
|
||||
assertEquals(1, transitions.get())
|
||||
instrumentation.runOnMainSync { service.playByRoute(AppRoutes.buildRecentPhotosRoute()) }
|
||||
instrumentation.waitForIdleSync()
|
||||
assertEquals("Repeated recent entry must not reset the media item", 1, transitions.get())
|
||||
|
||||
instrumentation.runOnMainSync { service.playByRoute("${AppRoutes.FACE_RECOGNITION_RESULT}?results=%5B%5D&source=face") }
|
||||
awaitPlaying(service)
|
||||
assertEquals(R.raw.face_recognition_success_result_page, service.currentResId.value)
|
||||
assertEquals(2, transitions.get())
|
||||
instrumentation.runOnMainSync { service.playByRoute(AppRoutes.FACE_RECOGNITION_RESULT) }
|
||||
instrumentation.waitForIdleSync()
|
||||
assertEquals("Legacy face route uses the same success clip", 2, transitions.get())
|
||||
} finally {
|
||||
instrumentation.runOnMainSync { service.release() }
|
||||
}
|
||||
}
|
||||
|
||||
@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) {
|
||||
val deadline = System.currentTimeMillis() + 5000
|
||||
while (!service.isPlaying.value && System.currentTimeMillis() < deadline) Thread.sleep(20)
|
||||
assertTrue("Local audio should enter playback", service.isPlaying.value)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
package com.yzx.kiosk.ui.face
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.graphics.asAndroidBitmap
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.compose.ui.semantics.SemanticsProperties
|
||||
import com.yzx.kiosk.network.model.response.FaceSearchResult
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.*
|
||||
import androidx.navigation.navArgument
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.google.gson.Gson
|
||||
import com.yzx.kiosk.audio.LocalAudioPlayService
|
||||
import com.yzx.kiosk.navigation.AppNavigator
|
||||
import com.yzx.kiosk.navigation.handleNavigationEvent
|
||||
import com.yzx.kiosk.navigation.routes.AppRoutes
|
||||
import com.yzx.kiosk.network.repository.NetWorkRepository
|
||||
import com.yzx.kiosk.network.service.*
|
||||
import com.yzx.kiosk.ui.face.resource.FaceCaptureFileStore
|
||||
import com.yzx.kiosk.ui.face.resource.FaceThumbnailDecoder
|
||||
import com.yzx.kiosk.ui.face.view.*
|
||||
import com.yzx.kiosk.ui.face.viewmodel.*
|
||||
import com.yzx.kiosk.websocket.WebSocketService
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import okhttp3.*
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.*
|
||||
import org.junit.Assert.*
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.io.File
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
/** All photo/order HTTP requests use an in-process interceptor; no orders or prints are submitted. */
|
||||
class RecentPhotosFlowTest {
|
||||
@get:Rule val compose = createComposeRule()
|
||||
private val context get() = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private lateinit var resultVm: FaceRecognitionResultViewModel
|
||||
private lateinit var createResultVm: () -> FaceRecognitionResultViewModel
|
||||
private val scopedResultVms = mutableListOf<FaceRecognitionResultViewModel>()
|
||||
private lateinit var faceVm: FaceRecognitionViewModel
|
||||
private lateinit var testSocket: WebSocketService
|
||||
private lateinit var nav: AppNavigator
|
||||
private var restoreConfig: (() -> Unit)? = null
|
||||
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 recentBody = """{"count":0,"results":[]}"""
|
||||
|
||||
@Before fun setup() {
|
||||
val app = context.applicationContext as com.yzx.kiosk.App
|
||||
// Access the existing singleton without launching MainActivity (which connects live services).
|
||||
val component = (app as dagger.hilt.internal.GeneratedComponentManager<*>).generatedComponent()
|
||||
val providerField = component.javaClass.getDeclaredField("webSocketServiceProvider").apply { isAccessible = true }
|
||||
val socket = (providerField.get(component) as javax.inject.Provider<*>).get() as WebSocketService
|
||||
testSocket = socket
|
||||
val store = app.appStoreDataSource
|
||||
val oldRemote = store.getBindBoxUrl()
|
||||
val oldLan = store.getBindBoxLanUrl()
|
||||
val oldSn = store.getBindBoxSn()
|
||||
val oldToken = store.getBindBoxApiToken()
|
||||
restoreConfig = {
|
||||
store.saveBindBoxUrl(oldRemote)
|
||||
store.saveBindBoxLanUrl(oldLan)
|
||||
store.saveBindBoxSn(oldSn)
|
||||
store.saveBindBoxApiToken(oldToken)
|
||||
}
|
||||
store.saveBindBoxUrl("https://example.test")
|
||||
store.saveBindBoxLanUrl("https://example.test")
|
||||
store.saveBindBoxSn("MOCK-BOX")
|
||||
store.saveBindBoxApiToken("test-token")
|
||||
val client = OkHttpClient.Builder().addInterceptor { chain ->
|
||||
val req = chain.request()
|
||||
requests.add(req)
|
||||
val recent = req.url.encodedPath.endsWith("/api/photos/recent")
|
||||
val body = when {
|
||||
recent -> recentBody
|
||||
req.url.encodedPath.endsWith("verify-result") && quoteFails ->
|
||||
"""{"code":100001,"msg":"项目已下线,请重新选择","data":{}}"""
|
||||
req.url.encodedPath.endsWith("verify-result") -> {
|
||||
val buffer = okio.Buffer()
|
||||
req.body!!.writeTo(buffer)
|
||||
val ids = Gson().fromJson(buffer.readUtf8(), com.google.gson.JsonObject::class.java).getAsJsonArray("image_id")
|
||||
val amount = "${ids.size() * 2}.00"
|
||||
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") && 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":[]}"""
|
||||
}
|
||||
Response.Builder().request(req).protocol(Protocol.HTTP_1_1).code(if (recent) recentCode else 200)
|
||||
.message("Mock").body(body.toResponseBody("application/json".toMediaType())).build()
|
||||
}.build()
|
||||
val retrofit = Retrofit.Builder().baseUrl("https://example.test/").client(client)
|
||||
.addConverterFactory(GsonConverterFactory.create()).build()
|
||||
val service = retrofit.create(FaceSearchService::class.java)
|
||||
val repository = NetWorkRepository(retrofit.create(NetworkService::class.java), retrofit.create(UploadService::class.java), Gson())
|
||||
testRepository = repository
|
||||
compose.runOnUiThread {
|
||||
nav = AppNavigator()
|
||||
createResultVm = { FaceRecognitionResultViewModel(nav, app.appState, repository, service, socket, app.appStoreDataSource) }
|
||||
resultVm = createResultVm()
|
||||
faceVm = FaceRecognitionViewModel(nav, app.appState, app.appStoreDataSource, LocalAudioPlayService(context), service, FaceCaptureFileStore(context), FaceThumbnailDecoder())
|
||||
}
|
||||
}
|
||||
|
||||
@After fun cleanup() {
|
||||
compose.runOnUiThread {
|
||||
if (::resultVm.isInitialized) resultVm.viewModelScope.cancel()
|
||||
if (::faceVm.isInitialized) faceVm.viewModelScope.cancel()
|
||||
completionVm?.viewModelScope?.cancel()
|
||||
scopedResultVms.forEach { it.viewModelScope.cancel() }
|
||||
restoreConfig?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
private fun screenshot(name: String) {
|
||||
val image = compose.onRoot().captureToImage().asAndroidBitmap()
|
||||
File(context.getExternalFilesDir(null), "$name.png").outputStream().use { image.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||
}
|
||||
|
||||
|
||||
@Test fun resultToolbarPushesIndependentPageAndRestoresSelectionAndScroll() {
|
||||
val bitmap = Bitmap.createBitmap(160, 100, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.BLUE) }
|
||||
val faceResults = (1..30).map { id ->
|
||||
val file = File(context.cacheDir, "toolbar-photo-$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 faceResults.takeLast(2)))
|
||||
lateinit var controller: NavHostController
|
||||
val factory = object : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||
createResultVm().also { scopedResultVms.add(it) } as T
|
||||
}
|
||||
compose.setContent {
|
||||
controller = rememberNavController()
|
||||
LaunchedEffect(Unit) { nav.navigationEvents.collect { controller.handleNavigationEvent(it) } }
|
||||
NavHost(controller, startDestination = AppRoutes.HOME) {
|
||||
composable(AppRoutes.HOME) { Text("测试首页") }
|
||||
composable(AppRoutes.FACE_RESULT_PATTERN, arguments = listOf(
|
||||
navArgument("results") { type = NavType.StringType; defaultValue = "" },
|
||||
navArgument("source") { type = NavType.StringType; defaultValue = AppRoutes.FACE_RESULT_SOURCE },
|
||||
)) { entry ->
|
||||
val vm: FaceRecognitionResultViewModel = viewModel(viewModelStoreOwner = entry, factory = factory)
|
||||
val source = entry.arguments?.getString("source")!!
|
||||
FaceRecognitionResultScreen(
|
||||
results = if (source == AppRoutes.FACE_RESULT_SOURCE) faceResults else emptyList(),
|
||||
source = source,
|
||||
viewModel = vm,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
compose.runOnIdle { controller.navigate(AppRoutes.FACE_RECOGNITION_RESULT) }
|
||||
compose.waitUntil(10_000) { scopedResultVms.firstOrNull()?.photoList?.value?.size == 30 }
|
||||
compose.onNodeWithText("人脸识别结果").assertIsDisplayed()
|
||||
val originalVm = scopedResultVms.first()
|
||||
compose.runOnIdle { originalVm.togglePhotoSelection(faceResults[0].thumbnailOssUrl!!) }
|
||||
compose.waitUntil(10_000) { originalVm.totalPrice.value == "2.00" }
|
||||
compose.onNodeWithText("查看最近照片").assertIsDisplayed().assertIsEnabled()
|
||||
screenshot("face-results-browse-button")
|
||||
val grid = compose.onNode(hasScrollToIndexAction())
|
||||
grid.performScrollToIndex(12)
|
||||
compose.waitForIdle()
|
||||
val scrollBefore = grid.fetchSemanticsNode().config[SemanticsProperties.VerticalScrollAxisRange].value()
|
||||
val selectedBefore = originalVm.selectedPhotos.value
|
||||
val totalBefore = originalVm.totalPrice.value
|
||||
var originalEntryId = ""
|
||||
compose.runOnIdle { originalEntryId = controller.currentBackStackEntry!!.id }
|
||||
// Two taps in one event cycle must produce only one pushed page.
|
||||
compose.onNodeWithText("查看最近照片").performTouchInput { doubleClick() }
|
||||
compose.waitUntil(10_000) { scopedResultVms.size == 2 && scopedResultVms[1].recentPhotosState.value == RecentPhotosState.READY }
|
||||
compose.onNodeWithText("最近照片").assertIsDisplayed()
|
||||
val recentVm = scopedResultVms[1]
|
||||
assertNotSame(originalVm, recentVm)
|
||||
assertTrue(recentVm.selectedPhotos.value.isEmpty())
|
||||
assertEquals(selectedBefore, originalVm.selectedPhotos.value)
|
||||
compose.onNodeWithText("查看最近照片").assertDoesNotExist()
|
||||
compose.runOnIdle {
|
||||
assertEquals(originalEntryId, controller.previousBackStackEntry!!.id)
|
||||
recentVm.toggleSelectAll()
|
||||
}
|
||||
compose.waitUntil(10_000) { recentVm.totalPrice.value == "4.00" }
|
||||
val verifyRequestsBeforeReturn = requests.count { it.url.encodedPath.endsWith("verify-result") }
|
||||
compose.runOnIdle { controller.popBackStack() }
|
||||
compose.onNodeWithText("人脸识别结果").assertIsDisplayed()
|
||||
compose.onNodeWithText("查看最近照片").assertIsDisplayed().assertIsEnabled()
|
||||
compose.runOnIdle {
|
||||
assertEquals(originalEntryId, controller.currentBackStackEntry!!.id)
|
||||
assertEquals(selectedBefore, originalVm.selectedPhotos.value)
|
||||
assertEquals(totalBefore, originalVm.totalPrice.value)
|
||||
}
|
||||
val scrollAfter = compose.onNode(hasScrollToIndexAction()).fetchSemanticsNode().config[SemanticsProperties.VerticalScrollAxisRange].value()
|
||||
assertEquals(scrollBefore, scrollAfter, 0.05f)
|
||||
assertEquals(verifyRequestsBeforeReturn, requests.count { it.url.encodedPath.endsWith("verify-result") })
|
||||
// Entry becomes available again after returning, and the next push gets fresh state.
|
||||
compose.onNodeWithText("查看最近照片").performClick()
|
||||
compose.waitUntil(10_000) { scopedResultVms.size == 3 && scopedResultVms[2].recentPhotosState.value == RecentPhotosState.READY }
|
||||
assertTrue(scopedResultVms[2].selectedPhotos.value.isEmpty())
|
||||
compose.onNodeWithText("查看最近照片").assertDoesNotExist()
|
||||
assertEquals(2, requests.count { it.url.encodedPath.endsWith("/api/photos/recent") })
|
||||
}
|
||||
|
||||
@Test fun failureButtonNavigatesOnceAndKeepsOriginalReturnPath() {
|
||||
lateinit var controller: NavHostController
|
||||
compose.setContent {
|
||||
controller = rememberNavController()
|
||||
LaunchedEffect(Unit) { nav.navigationEvents.collect { controller.handleNavigationEvent(it) } }
|
||||
NavHost(controller, startDestination = AppRoutes.HOME) {
|
||||
composable(AppRoutes.HOME) { Text("测试首页") }
|
||||
composable(AppRoutes.FACE_RECOGNITION) { FaceRecognitionScreen(viewModel = faceVm) }
|
||||
composable(AppRoutes.FACE_RESULT_PATTERN, arguments = listOf(
|
||||
navArgument("results") { type = NavType.StringType; defaultValue = "" },
|
||||
navArgument("source") { type = NavType.StringType; defaultValue = AppRoutes.FACE_RESULT_SOURCE },
|
||||
)) { entry ->
|
||||
FaceRecognitionResultScreen(source = entry.arguments?.getString("source")!!, viewModel = resultVm)
|
||||
}
|
||||
}
|
||||
}
|
||||
compose.runOnIdle { controller.navigate(AppRoutes.FACE_RECOGNITION) }
|
||||
compose.onNodeWithText("找不到自己?查看最近照片").assertDoesNotExist()
|
||||
compose.runOnIdle {
|
||||
val field = FaceRecognitionViewModel::class.java.getDeclaredField("_recognitionStatus").apply { isAccessible = true }
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(field.get(faceVm) as MutableStateFlow<RecognitionStatus>).value = RecognitionStatus.FAILED
|
||||
}
|
||||
compose.onNodeWithText("找不到自己?查看最近照片").assertIsDisplayed()
|
||||
screenshot("recent-photos-failure-button")
|
||||
compose.onNodeWithText("找不到自己?查看最近照片").performClick()
|
||||
compose.runOnIdle { faceVm.browseRecentPhotos() }
|
||||
compose.waitUntil(10_000) { resultVm.recentPhotosState.value == RecentPhotosState.EMPTY }
|
||||
compose.onNodeWithText("暂无可显示的最近照片").assertIsDisplayed()
|
||||
compose.runOnIdle {
|
||||
assertEquals(AppRoutes.RECENT_RESULT_SOURCE, controller.currentBackStackEntry?.arguments?.getString("source"))
|
||||
assertEquals(AppRoutes.HOME, controller.previousBackStackEntry?.destination?.route)
|
||||
// Verify the legacy route still resolves with source=face.
|
||||
controller.navigate("${AppRoutes.FACE_RECOGNITION_RESULT}?results=%5B%5D")
|
||||
assertEquals(AppRoutes.FACE_RESULT_SOURCE, controller.currentBackStackEntry?.arguments?.getString("source"))
|
||||
}
|
||||
assertEquals(1, requests.count { it.url.encodedPath.endsWith("/api/photos/recent") })
|
||||
}
|
||||
|
||||
@Test fun recentPhotosReuseSelectionPricingAndMockPaymentToPrintingRoute() {
|
||||
val image = Bitmap.createBitmap(160, 100, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.BLUE) }
|
||||
val file = File(context.cacheDir, "recent-photo-fixture.png")
|
||||
file.outputStream().use { image.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||
val url = file.toURI().toString()
|
||||
val second = File(context.cacheDir, "recent-photo-second.png")
|
||||
file.copyTo(second, overwrite = true)
|
||||
val secondUrl = second.toURI().toString()
|
||||
recentBody = """{"count":3,"results":[{"id":9,"thumbnail_oss_url":"$url","thumbnail_lan_url":"$url"},{"id":3,"thumbnail_oss_url":"$secondUrl","thumbnail_lan_url":"$secondUrl"},{"id":2,"thumbnail_oss_url":null,"thumbnail_lan_url":null}]}"""
|
||||
compose.setContent { FaceRecognitionResultScreen(source = AppRoutes.RECENT_RESULT_SOURCE, viewModel = resultVm) }
|
||||
compose.waitUntil(10_000) { resultVm.recentPhotosState.value == RecentPhotosState.READY }
|
||||
assertEquals(listOf(9, 3), resultVm.photoList.value.map { it.id })
|
||||
compose.onNodeWithText("全选").performClick()
|
||||
compose.waitUntil(10_000) { resultVm.totalPrice.value == "4.00" }
|
||||
assertEquals(2, resultVm.selectedPhotos.value.size)
|
||||
compose.onNodeWithText("购买打印照片\n4元").assertIsEnabled()
|
||||
screenshot("recent-photos-results")
|
||||
compose.onAllNodesWithText("点击预览")[0].performClick()
|
||||
compose.onNodeWithContentDescription("关闭").assertIsDisplayed().performClick()
|
||||
compose.runOnIdle { resultVm.togglePhotoSelection(resultVm.photoList.value[0].url) }
|
||||
assertEquals(1, resultVm.selectedPhotos.value.size)
|
||||
compose.runOnIdle { resultVm.toggleSelectAll() }
|
||||
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.
|
||||
var payUrl: String? = null
|
||||
compose.runOnIdle { resultVm.getPayUrl(PurchaseMode.PRINT) { payUrl = it } }
|
||||
compose.waitUntil(10_000) { payUrl != null }
|
||||
assertEquals("https://example.test/mock-pay", payUrl)
|
||||
val field = FaceRecognitionResultViewModel::class.java.getDeclaredField("activePaymentOrderNumber").apply { isAccessible = true }
|
||||
assertEquals("MOCK-RECENT-1", field.get(resultVm))
|
||||
// Inspect printing navigation only; never compose the real printing page.
|
||||
var route: String? = null
|
||||
val job = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Main).let { scope ->
|
||||
scope.launchCollect(nav) { route = it }
|
||||
}
|
||||
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, 3), null, "test")
|
||||
}
|
||||
compose.waitUntil(10_000) { route != null }
|
||||
assertTrue(route!!.startsWith("printing?"))
|
||||
assertTrue(route!!.contains("orderNumber=MOCK-RECENT-1"))
|
||||
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() {
|
||||
electronicAmount = "1.00"
|
||||
recentCode = 403
|
||||
compose.setContent { FaceRecognitionResultScreen(source = AppRoutes.RECENT_RESULT_SOURCE, viewModel = resultVm) }
|
||||
compose.waitUntil(10_000) { resultVm.recentPhotosState.value == RecentPhotosState.ERROR }
|
||||
compose.onNodeWithText("照片加载失败,请重试").assertIsDisplayed()
|
||||
compose.waitUntil(10_000) { !resultVm.quoteLoading.value }
|
||||
compose.onNodeWithText("打印版 2元/张").assertIsDisplayed()
|
||||
compose.onNodeWithText("电子版 1元/张").assertIsDisplayed()
|
||||
compose.onNodeWithText("购买打印照片\n0元").assertIsNotEnabled()
|
||||
recentCode = 200
|
||||
compose.onNodeWithText("重试").performClick()
|
||||
compose.waitUntil(10_000) { resultVm.recentPhotosState.value == RecentPhotosState.EMPTY }
|
||||
compose.onNodeWithText("暂无可显示的最近照片").assertIsDisplayed()
|
||||
assertEquals(2, requests.count { it.url.encodedPath.endsWith("/api/photos/recent") })
|
||||
}
|
||||
}
|
||||
|
||||
private fun kotlinx.coroutines.CoroutineScope.launchCollect(nav: AppNavigator, onRoute: (String) -> Unit) =
|
||||
launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
||||
nav.navigationEvents.collect { if (it is com.yzx.kiosk.navigation.NavigationEvent.NavigateTo) onRoute(it.route) }
|
||||
}
|
||||
@@ -42,10 +42,12 @@ class LocalAudioPlayService @Inject constructor(
|
||||
AppRoutes.HOME to R.raw.home_audio,
|
||||
AppRoutes.FACE_RECOGNITION to R.raw.face_recognition_page,
|
||||
AppRoutes.FACE_RECOGNITION_RESULT to R.raw.face_recognition_success_result_page,
|
||||
RECENT_PHOTOS_AUDIO_KEY to R.raw.recent_photos_result_page,
|
||||
AppRoutes.UPLOAD_PHOTO to R.raw.scan_qrcode_upload_page,
|
||||
AppRoutes.PHOTO_SELECT to R.raw.upload_unpaid_result_page,
|
||||
AppRoutes.PRINTING to R.raw.printing_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_failed" to R.raw.face_recognition_failed_page,
|
||||
@@ -109,12 +111,12 @@ class LocalAudioPlayService @Inject constructor(
|
||||
return
|
||||
}
|
||||
|
||||
// 提取基础路由名称(去掉参数部分)
|
||||
val baseRoute = route.substringBefore("?")
|
||||
// 同一结果页根据入口选择播报,避免最近照片误播识别成功。
|
||||
val audioKey = localAudioKeyForRoute(route)
|
||||
|
||||
val audioResId = ROUTE_TO_AUDIO_MAP[baseRoute]
|
||||
val audioResId = ROUTE_TO_AUDIO_MAP[audioKey]
|
||||
if (audioResId == null) {
|
||||
LogUtils.d(TAG, "路由 $baseRoute 没有对应的音频文件")
|
||||
LogUtils.d(TAG, "路由音频 $audioKey 没有对应的音频文件")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.yzx.kiosk.audio
|
||||
|
||||
import com.yzx.kiosk.navigation.routes.AppRoutes
|
||||
import java.net.URLDecoder
|
||||
|
||||
internal const val ELECTRONIC_COMPLETION_AUDIO_KEY = "electronic_completion"
|
||||
|
||||
internal const val RECENT_PHOTOS_AUDIO_KEY = "recent_photos_result"
|
||||
|
||||
/** Keep result-page audio tied to the entry source, not just the destination name. */
|
||||
internal fun localAudioKeyForRoute(route: String): String {
|
||||
val baseRoute = route.substringBefore("?")
|
||||
if (baseRoute == AppRoutes.ELECTRONIC_COMPLETION) {
|
||||
return ELECTRONIC_COMPLETION_AUDIO_KEY
|
||||
}
|
||||
if (baseRoute != AppRoutes.FACE_RECOGNITION_RESULT) return baseRoute
|
||||
|
||||
val source = route.substringAfter("?", "").substringBefore("#")
|
||||
.split("&")
|
||||
.firstOrNull { it.substringBefore("=") == "source" }
|
||||
?.substringAfter("=", "")
|
||||
?.let { runCatching { URLDecoder.decode(it, "UTF-8") }.getOrNull() }
|
||||
return if (source == AppRoutes.RECENT_RESULT_SOURCE) RECENT_PHOTOS_AUDIO_KEY else baseRoute
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import androidx.compose.animation.core.tween
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.navArgument
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
@@ -28,6 +30,7 @@ import kotlinx.coroutines.flow.collectLatest
|
||||
import java.net.URLDecoder
|
||||
import java.net.URLEncoder
|
||||
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
@Composable
|
||||
fun AppNavHost(
|
||||
navigator: AppNavigator,
|
||||
@@ -140,7 +143,13 @@ fun AppNavHost(
|
||||
}
|
||||
|
||||
// 人脸识别结果页面
|
||||
composable(route = "${AppRoutes.FACE_RECOGNITION_RESULT}?results={results}") { backStackEntry ->
|
||||
composable(
|
||||
route = AppRoutes.FACE_RESULT_PATTERN,
|
||||
arguments = listOf(
|
||||
navArgument("results") { type = NavType.StringType; defaultValue = "" },
|
||||
navArgument("source") { type = NavType.StringType; defaultValue = AppRoutes.FACE_RESULT_SOURCE },
|
||||
),
|
||||
) { backStackEntry ->
|
||||
val resultsParam = backStackEntry.arguments?.getString("results") ?: ""
|
||||
val results = if (resultsParam.isNotEmpty()) {
|
||||
try {
|
||||
@@ -153,7 +162,10 @@ fun AppNavHost(
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
FaceRecognitionResultScreen(results = results)
|
||||
FaceRecognitionResultScreen(
|
||||
results = results,
|
||||
source = backStackEntry.arguments?.getString("source") ?: AppRoutes.FACE_RESULT_SOURCE,
|
||||
)
|
||||
}
|
||||
|
||||
// 打印中页面
|
||||
@@ -198,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()
|
||||
}
|
||||
|
||||
@@ -8,10 +8,22 @@ object AppRoutes {
|
||||
const val UPLOAD_PHOTO = "upload_photo"
|
||||
const val PHOTO_SELECT = "photo_select"
|
||||
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 FACE_RECOGNITION = "face_recognition"
|
||||
const val FACE_RECOGNITION_RESULT = "face_recognition_result"
|
||||
|
||||
const val FACE_RESULT_SOURCE = "face"
|
||||
const val RECENT_RESULT_SOURCE = "recent"
|
||||
const val FACE_RESULT_PATTERN = "$FACE_RECOGNITION_RESULT?results={results}&source={source}"
|
||||
|
||||
fun buildRecentPhotosRoute(): String = "$FACE_RECOGNITION_RESULT?source=$RECENT_RESULT_SOURCE"
|
||||
|
||||
const val AGREEMENT = "agreement"
|
||||
|
||||
fun buildPrintingRoute(
|
||||
|
||||
@@ -6,6 +6,10 @@ data class GetPayUrlRequest(
|
||||
@SerializedName("type")
|
||||
val type: Int,
|
||||
@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")
|
||||
val type: Int,
|
||||
@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?,
|
||||
|
||||
@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?,
|
||||
|
||||
@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")
|
||||
val priceImage: String?,
|
||||
@SerializedName("amount")
|
||||
val amount: String?
|
||||
val amount: String?,
|
||||
@SerializedName("price_electronic")
|
||||
val priceElectronic: String? = null,
|
||||
@SerializedName("amount_electronic")
|
||||
val amountElectronic: String? = null
|
||||
)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.yzx.kiosk.network.model.response.FaceSearchResponse
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Header
|
||||
import retrofit2.http.Multipart
|
||||
import retrofit2.http.POST
|
||||
@@ -11,6 +12,12 @@ import retrofit2.http.Part
|
||||
import retrofit2.http.Url
|
||||
|
||||
interface FaceSearchService {
|
||||
@GET
|
||||
suspend fun recentPhotos(
|
||||
@Url url: String,
|
||||
@Header("Authorization") authorization: String,
|
||||
): Response<FaceSearchResponse>
|
||||
|
||||
@Multipart
|
||||
@POST
|
||||
suspend fun searchFace(
|
||||
|
||||
@@ -8,13 +8,14 @@ internal fun buildFaceSearchUrl(baseUrl: String, deviceSn: String): String {
|
||||
?: throw IllegalArgumentException("Invalid face search base URL")
|
||||
|
||||
return parsedBaseUrl.newBuilder()
|
||||
// Preserve the existing /{sn}/api/search endpoint semantics even if configuration
|
||||
// Preserve the /{sn}/api/v3/search endpoint semantics even if configuration
|
||||
// accidentally contains a path or query string.
|
||||
.encodedPath("/")
|
||||
.query(null)
|
||||
.fragment(null)
|
||||
.addPathSegment(deviceSn)
|
||||
.addPathSegment("api")
|
||||
.addPathSegment("v3")
|
||||
.addPathSegment("search")
|
||||
.build()
|
||||
.toString()
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.yzx.kiosk.network.service
|
||||
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
|
||||
internal fun buildRecentPhotosUrl(baseUrl: String, deviceSn: String): String {
|
||||
require(deviceSn.isNotBlank()) { "Face box SN is empty" }
|
||||
val parsedBaseUrl = baseUrl.trim().toHttpUrlOrNull()
|
||||
?: throw IllegalArgumentException("Invalid recent photos base URL")
|
||||
|
||||
// The recent-photos endpoint is unversioned and independent of the face-search API version.
|
||||
return parsedBaseUrl.newBuilder()
|
||||
.encodedPath("/")
|
||||
.query(null)
|
||||
.fragment(null)
|
||||
.addPathSegment(deviceSn)
|
||||
.addPathSegment("api")
|
||||
.addPathSegment("photos")
|
||||
.addPathSegment("recent")
|
||||
.build()
|
||||
.toString()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
import com.yzx.kiosk.utils.formatPriceDisplay
|
||||
import android.graphics.Bitmap
|
||||
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.background
|
||||
import androidx.compose.foundation.border
|
||||
@@ -26,6 +29,9 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -38,6 +44,8 @@ import coil.compose.SubcomposeAsyncImage
|
||||
import coil.compose.SubcomposeAsyncImageContent
|
||||
import coil.compose.SubcomposeAsyncImageScope
|
||||
import coil.request.ImageRequest
|
||||
import com.yzx.kiosk.navigation.routes.AppRoutes
|
||||
import com.yzx.kiosk.ui.face.viewmodel.RecentPhotosState
|
||||
import com.yzx.kiosk.R
|
||||
import com.yzx.kiosk.component.appbar.AppTitleBar
|
||||
import com.yzx.kiosk.component.appbar.FaceBarNoStatusBarPadding
|
||||
@@ -55,21 +63,41 @@ import kotlinx.coroutines.flow.collect
|
||||
@Composable
|
||||
fun FaceRecognitionResultScreen(
|
||||
results: List<FaceSearchResult> = emptyList(),
|
||||
source: String = AppRoutes.FACE_RESULT_SOURCE,
|
||||
viewModel: FaceRecognitionResultViewModel = hiltViewModel()
|
||||
) {
|
||||
// 如果传入了results列表,初始化图片列表
|
||||
LaunchedEffect(results) {
|
||||
if (results.isNotEmpty()) {
|
||||
LaunchedEffect(source, results) {
|
||||
if (source == AppRoutes.RECENT_RESULT_SOURCE) {
|
||||
viewModel.loadRecentPhotos()
|
||||
} else if (results.isNotEmpty()) {
|
||||
viewModel.initPhotoList(results)
|
||||
}
|
||||
}
|
||||
|
||||
val recentPhotosState by viewModel.recentPhotosState.collectAsState()
|
||||
val photoList by viewModel.photoList.collectAsState()
|
||||
val selectedPhotos by viewModel.selectedPhotos.collectAsState()
|
||||
val pricePerPhoto by viewModel.pricePerPhoto.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()
|
||||
|
||||
var isOpeningRecentPhotos by remember { mutableStateOf(false) }
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) isOpeningRecentPhotos = false
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
val selectedCount = selectedPhotos.size
|
||||
val isAllSelected = photoList.isNotEmpty() && selectedPhotos.size == photoList.size
|
||||
|
||||
@@ -94,7 +122,7 @@ fun FaceRecognitionResultScreen(
|
||||
Column {
|
||||
AppTitleBar(
|
||||
backgroundColor = Color.White,
|
||||
title = "人脸识别结果",
|
||||
title = if (source == AppRoutes.RECENT_RESULT_SOURCE) "最近照片" else "人脸识别结果",
|
||||
isShowBackIcon = true,
|
||||
onBackClick = { viewModel.navigateBack() },
|
||||
isShowButtonLine = false
|
||||
@@ -120,73 +148,69 @@ fun FaceRecognitionResultScreen(
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color(0xFFF4F4F4))
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth().padding(24.dp)) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "已选择",
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.Black
|
||||
)
|
||||
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
|
||||
)
|
||||
Text("已选择 ${selectedCount} 张", fontSize = 24.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(20.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("打印版 ${formatPriceDisplay(pricePerPhoto)}元/张", fontSize = 21.sp)
|
||||
Text("电子版 ${formatPriceDisplay(electronicPrice)}元/张", fontSize = 21.sp)
|
||||
}
|
||||
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(
|
||||
onClick = {
|
||||
viewModel.getPayUrl { url ->
|
||||
showPayDialog = true
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.height(72.dp)
|
||||
.widthIn(min = 180.dp),
|
||||
onClick = { viewModel.getPayUrl(mode) { showPayDialog = true } },
|
||||
enabled = selectedCount > 0 && total != "--" && !creatingOrder && !quoteLoading,
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 88.dp),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF0073FF)),
|
||||
enabled = selectedCount > 0
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (isPrint) Color(0xFF0073FF) else Color(0xFFE3EFFF),
|
||||
contentColor = if (isPrint) Color.White else Color(0xFF1756A9),
|
||||
),
|
||||
) {
|
||||
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,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold
|
||||
lineHeight = 32.sp,
|
||||
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.
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.clickable { viewModel.toggleSelectAll() },
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.weight(1f).heightIn(min = 48.dp)
|
||||
.clickable(enabled = photoList.isNotEmpty()) { viewModel.toggleSelectAll() },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// 复选框
|
||||
Box(
|
||||
@@ -224,6 +248,48 @@ fun FaceRecognitionResultScreen(
|
||||
color = Color.Black
|
||||
)
|
||||
}
|
||||
if (source != AppRoutes.RECENT_RESULT_SOURCE) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (!isOpeningRecentPhotos) {
|
||||
isOpeningRecentPhotos = true
|
||||
viewModel.browseRecentPhotos()
|
||||
}
|
||||
},
|
||||
enabled = !isOpeningRecentPhotos,
|
||||
modifier = Modifier.heightIn(min = 48.dp),
|
||||
contentPadding = PaddingValues(horizontal = 6.dp),
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = Color(0xFF2878CE)),
|
||||
) {
|
||||
Text("查看最近照片", fontSize = 16.sp, fontWeight = FontWeight.Medium)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("›", fontSize = 20.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (source == AppRoutes.RECENT_RESULT_SOURCE && recentPhotosState != RecentPhotosState.READY) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
if (recentPhotosState == RecentPhotosState.IDLE || recentPhotosState == RecentPhotosState.LOADING) {
|
||||
CircularProgressIndicator(Modifier.size(32.dp))
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
Text(
|
||||
text = when (recentPhotosState) {
|
||||
RecentPhotosState.ERROR -> "照片加载失败,请重试"
|
||||
RecentPhotosState.EMPTY -> "暂无可显示的最近照片"
|
||||
else -> "正在加载照片…"
|
||||
},
|
||||
fontSize = 18.sp,
|
||||
color = Color(0xFF666666),
|
||||
)
|
||||
if (recentPhotosState == RecentPhotosState.ERROR) {
|
||||
TextButton(onClick = viewModel::retryRecentPhotos) { Text("重试", fontSize = 18.sp) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 图片瀑布流列表
|
||||
LazyVerticalStaggeredGrid(
|
||||
@@ -280,7 +346,7 @@ fun FaceRecognitionResultScreen(
|
||||
}
|
||||
|
||||
// 支付弹框
|
||||
if (showPayDialog && selectedCount > 0 && payQrCodeUrl != null) {
|
||||
if (showPayDialog && payQrCodeUrl != null) {
|
||||
DisposableEffect(payQrCodeUrl) {
|
||||
viewModel.startPayStatusPolling()
|
||||
onDispose {
|
||||
@@ -290,7 +356,7 @@ fun FaceRecognitionResultScreen(
|
||||
|
||||
WechatPayDialog(
|
||||
qrCodeUrl = payQrCodeUrl!!,
|
||||
totalPrice = totalPrice,
|
||||
totalPrice = paymentAmount,
|
||||
onDismiss = {
|
||||
showPayDialog = false
|
||||
viewModel.onPayDialogDismissed()
|
||||
|
||||
@@ -87,7 +87,8 @@ fun FaceRecognitionScreen(
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 48.dp, vertical = 36.dp)
|
||||
.padding(horizontal = 48.dp)
|
||||
.padding(top = 36.dp)
|
||||
.aspectRatio(0.92f),
|
||||
shape = RoundedCornerShape(36.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color.Black)
|
||||
@@ -233,7 +234,22 @@ fun FaceRecognitionScreen(
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(36.dp))
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().height(72.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (recognitionStatus == RecognitionStatus.FAILED) {
|
||||
TextButton(
|
||||
onClick = viewModel::browseRecentPhotos,
|
||||
modifier = Modifier.heightIn(min = 48.dp),
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = Color(0xFF2878CE)),
|
||||
) {
|
||||
Text("找不到自己?查看最近照片", fontSize = 16.sp, fontWeight = FontWeight.Medium)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("›", fontSize = 20.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 倒计时提示
|
||||
Text(
|
||||
|
||||
@@ -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) {
|
||||
30 -> FacePayStatusDecision.COMPLETE_PAYMENT
|
||||
40, 50, 60 -> FacePayStatusDecision.STOP_WITH_TERMINAL_STATUS
|
||||
else -> FacePayStatusDecision.CONTINUE_POLLING
|
||||
10 -> FacePayStatusDecision.CONTINUE_POLLING
|
||||
else -> FacePayStatusDecision.STOP_WITH_TERMINAL_STATUS
|
||||
}
|
||||
|
||||
internal fun isValidFacePaySuccessMessage(
|
||||
@@ -19,7 +19,7 @@ internal fun isValidFacePaySuccessMessage(
|
||||
message: PaySuccessMessageResponse?
|
||||
): Boolean {
|
||||
val paymentData = message?.data ?: return false
|
||||
return message.type == 5 &&
|
||||
return message.orderStatus == 30 && message.type == 5 &&
|
||||
paymentData.orderNumber == expectedOrderNumber &&
|
||||
paymentData.captureType == 2 &&
|
||||
!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
|
||||
}
|
||||
@@ -9,8 +9,9 @@ import com.yzx.kiosk.network.model.request.GetPayUrlRequest
|
||||
import com.yzx.kiosk.network.model.request.PaySuccessMessageRequest
|
||||
import com.yzx.kiosk.network.model.request.VerifyResultRequest
|
||||
import com.yzx.kiosk.network.model.response.FaceSearchResult
|
||||
import com.yzx.kiosk.network.service.FaceSearchService
|
||||
import com.yzx.kiosk.network.service.buildRecentPhotosUrl
|
||||
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.PhotoData
|
||||
import com.yzx.kiosk.utils.ToastUtils
|
||||
@@ -43,12 +44,43 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
navigator: AppNavigator,
|
||||
appState: AppState,
|
||||
private val netWorkRepository: NetWorkRepository,
|
||||
private val faceSearchService: FaceSearchService,
|
||||
private val webSocketService: WebSocketService,
|
||||
private val appStoreDataSource: AppStoreDataSource,
|
||||
) : BaseViewModel(
|
||||
navigator = navigator,
|
||||
appState = appState
|
||||
) {
|
||||
private val recentLoader = RecentPhotosLoader(viewModelScope) {
|
||||
val baseUrl = if (appStoreDataSource.getUseLan()) {
|
||||
appStoreDataSource.getBindBoxLanUrl()
|
||||
} else {
|
||||
appStoreDataSource.getBindBoxUrl()
|
||||
}
|
||||
val response = faceSearchService.recentPhotos(
|
||||
buildRecentPhotosUrl(baseUrl, appStoreDataSource.getBindBoxSn()),
|
||||
"Bearer ${appStoreDataSource.getBindBoxApiToken()}",
|
||||
)
|
||||
check(response.isSuccessful) { "Recent photos HTTP ${response.code()}" }
|
||||
val body = checkNotNull(response.body()) { "Empty recent photos response" }
|
||||
applyPhotoList(body.results.orEmpty())
|
||||
_photoList.value.isNotEmpty()
|
||||
}
|
||||
val recentPhotosState = recentLoader.state
|
||||
|
||||
fun browseRecentPhotos() {
|
||||
// Deliberately push even though the destination pattern is the same: the two pages
|
||||
// need separate back-stack entries and ViewModels. Do not use launchSingleTop/popUpTo.
|
||||
toPage(AppRoutes.buildRecentPhotosRoute())
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// 原始结果列表(用于获取图片id)
|
||||
private var originalResults: List<FaceSearchResult> = emptyList()
|
||||
|
||||
@@ -71,6 +103,24 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
private val _totalPrice = MutableStateFlow<String>("--")
|
||||
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
|
||||
private val _payQrCodeUrl = MutableStateFlow<String?>(null)
|
||||
val payQrCodeUrl: StateFlow<String?> = _payQrCodeUrl.asStateFlow()
|
||||
@@ -110,6 +160,7 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
orderNumber = event.orderNumber,
|
||||
captureType = event.captureType,
|
||||
imageIds = event.imageIds,
|
||||
purchaseMode = event.purchaseMode,
|
||||
source = "WebSocket"
|
||||
)
|
||||
}
|
||||
@@ -124,8 +175,17 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
/**
|
||||
* 初始化图片列表(从人脸识别结果)
|
||||
*/
|
||||
private var faceResultsInitialized = false
|
||||
|
||||
fun initPhotoList(results: List<FaceSearchResult>) {
|
||||
viewModelScope.launch {
|
||||
// Returning from a pushed recent-photos page must retain selection, pricing and image state.
|
||||
if (faceResultsInitialized) return
|
||||
faceResultsInitialized = true
|
||||
verifyResult(emptyList())
|
||||
viewModelScope.launch { applyPhotoList(results) }
|
||||
}
|
||||
|
||||
private suspend fun applyPhotoList(results: List<FaceSearchResult>) {
|
||||
// 保存原始结果列表
|
||||
originalResults = results
|
||||
urlToIdMap.clear()
|
||||
@@ -153,18 +213,15 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
LogUtils.i("FaceRecognitionResultViewModel", "没有有效的图片URL")
|
||||
_photoList.value = emptyList()
|
||||
_imageLoadStates.value = emptyMap()
|
||||
return@launch
|
||||
return
|
||||
}
|
||||
|
||||
_photoList.value = initialPhotos
|
||||
// 初始化加载状态
|
||||
_imageLoadStates.value = initialPhotos.associate { it.url to ImageLoadState() }
|
||||
|
||||
// 页面初始化时调用验证接口(空列表,type: 2),获取默认价格
|
||||
verifyResult(emptyList())
|
||||
LogUtils.d(TAG, "图片列表初始化完成,共 ${initialPhotos.size} 张,宽高比将在图片加载成功后逐张更新")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用网格中已经加载成功的 Drawable 尺寸更新宽高比。
|
||||
@@ -198,6 +255,7 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
* 切换单张图片选中状态
|
||||
*/
|
||||
fun togglePhotoSelection(url: String) {
|
||||
if (_creatingOrder.value) return
|
||||
val current = _selectedPhotos.value.toMutableSet()
|
||||
if (current.contains(url)) {
|
||||
current.remove(url)
|
||||
@@ -215,6 +273,8 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
* 切换全选状态
|
||||
*/
|
||||
fun toggleSelectAll() {
|
||||
if (_creatingOrder.value) return
|
||||
if (_photoList.value.isEmpty()) return
|
||||
val allUrls = _photoList.value.map { it.url }.toSet()
|
||||
val newSelected = if (_selectedPhotos.value.size == allUrls.size) {
|
||||
// 当前全选,取消全选
|
||||
@@ -243,100 +303,109 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
* 验证结果接口
|
||||
*/
|
||||
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 {
|
||||
val request = VerifyResultRequest(
|
||||
type = 2,
|
||||
imageId = imageIds
|
||||
)
|
||||
|
||||
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("请选择要打印的照片")
|
||||
val response = netWorkRepository.verifyResult(VerifyResultRequest(2, imageIds.distinct(), videoId = emptyList())).first()
|
||||
if (!quoteRevision.isCurrent(revision)) return@launch
|
||||
if (!response.isSucceeded) {
|
||||
_quoteError.value = response.message?.takeIf { it.isNotBlank() } ?: "获取报价失败,请重试"
|
||||
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()
|
||||
try {
|
||||
val request = GetPayUrlRequest(
|
||||
type = 2,
|
||||
imageId = selectedIds
|
||||
)
|
||||
|
||||
handleResultWithData(
|
||||
flow = netWorkRepository.getPayUrl(request).asResult(),
|
||||
showToast = false,
|
||||
onData = { response ->
|
||||
val url = response.url?.trim()
|
||||
val orderNumber = response.orderNumber?.trim()
|
||||
if (url.isNullOrEmpty() || orderNumber.isNullOrEmpty()) {
|
||||
_payQrCodeUrl.value = null
|
||||
LogUtils.e(TAG, "获取支付二维码成功,但 url 或 order_number 为空")
|
||||
ToastUtils.show("获取支付二维码失败")
|
||||
} else {
|
||||
val response = netWorkRepository.getPayUrl(
|
||||
GetPayUrlRequest(2, selectedIds, mode.wireValue, videoId = emptyList())
|
||||
).first()
|
||||
if (!response.isSucceeded) {
|
||||
recoverPayment(response.message?.takeIf { it.isNotBlank() } ?: "获取支付链接失败,请重新选择")
|
||||
return@launch
|
||||
}
|
||||
val data = response.data
|
||||
val order = if (data != null)
|
||||
createFacePaymentOrder(data, mode, selectedIds, quotedAmount) else null
|
||||
if (order == null) {
|
||||
recoverPayment("支付链接信息无效,请重新选择")
|
||||
return@launch
|
||||
}
|
||||
stopPayStatusPolling()
|
||||
activePaymentOrderNumber = orderNumber
|
||||
activeOrder = order
|
||||
activePaymentOrderNumber = order.number
|
||||
paymentHandled.set(false)
|
||||
_paymentAmount.value = order.amount
|
||||
_paymentMode.value = order.mode
|
||||
val url = checkNotNull(data?.url).trim()
|
||||
_payQrCodeUrl.value = url
|
||||
onSuccess(url)
|
||||
}
|
||||
},
|
||||
onError = { msg, _ ->
|
||||
LogUtils.e("FaceRecognitionResultViewModel", "获取支付URL失败: $msg")
|
||||
ToastUtils.show("获取支付二维码失败")
|
||||
},
|
||||
onEnd = {
|
||||
hideLoading()
|
||||
}
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
LogUtils.e("FaceRecognitionResultViewModel", "获取支付URL异常: ${e.message}")
|
||||
ToastUtils.show("获取支付二维码失败")
|
||||
LogUtils.e(TAG, "创建订单失败: ${e.message}")
|
||||
ToastUtils.show("创建订单失败,请重试")
|
||||
} finally {
|
||||
_creatingOrder.value = false
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -383,14 +452,17 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
.getPaySuccessMessage(PaySuccessMessageRequest(orderNumber))
|
||||
.first()
|
||||
|
||||
if (activePaymentOrderNumber != orderNumber || paymentHandled.get()) return
|
||||
if (!response.isSucceeded) {
|
||||
LogUtils.e(
|
||||
TAG,
|
||||
"查询支付状态业务失败 - order_number: $orderNumber, code: ${response.code}, msg: ${response.message}"
|
||||
)
|
||||
recoverPayment(response.message?.takeIf { it.isNotBlank() } ?: "订单已失效,请重新选择照片")
|
||||
return
|
||||
}
|
||||
|
||||
if (activePaymentOrderNumber != orderNumber) return
|
||||
val message = response.data
|
||||
when (decideFacePayStatus(message?.orderStatus)) {
|
||||
FacePayStatusDecision.CONTINUE_POLLING -> {
|
||||
@@ -411,6 +483,7 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
orderNumber = paymentData.orderNumber,
|
||||
captureType = paymentData.captureType,
|
||||
imageIds = paymentData.imageIds.orEmpty(),
|
||||
purchaseMode = paymentData.purchaseMode,
|
||||
source = "HTTP"
|
||||
)
|
||||
}
|
||||
@@ -433,16 +506,17 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
|
||||
stopPayStatusPolling()
|
||||
activePaymentOrderNumber = null
|
||||
activeOrder = null
|
||||
_payQrCodeUrl.value = null
|
||||
_dismissPayDialogEvents.tryEmit(Unit)
|
||||
|
||||
val statusText = orderStatusName?.takeIf { it.isNotBlank() } ?: when (orderStatus) {
|
||||
40 -> "已取消"
|
||||
50 -> "已退款"
|
||||
60 -> "部分退款"
|
||||
else -> "状态异常"
|
||||
}
|
||||
ToastUtils.show("订单$statusText")
|
||||
retryQuote()
|
||||
LogUtils.i(TAG, "订单进入终态,停止支付轮询 - status: $orderStatus, name: $statusText")
|
||||
}
|
||||
|
||||
@@ -450,19 +524,13 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
orderNumber: String?,
|
||||
captureType: Int?,
|
||||
imageIds: List<Int>,
|
||||
purchaseMode: String?,
|
||||
source: String
|
||||
) {
|
||||
val expectedOrderNumber = activePaymentOrderNumber
|
||||
if (
|
||||
expectedOrderNumber.isNullOrEmpty() ||
|
||||
orderNumber != expectedOrderNumber ||
|
||||
captureType != 2 ||
|
||||
imageIds.isEmpty()
|
||||
) {
|
||||
LogUtils.i(
|
||||
TAG,
|
||||
"忽略不匹配的支付成功消息 - source: $source, expected: $expectedOrderNumber, actual: $orderNumber, capture_type: $captureType, image_ids: $imageIds"
|
||||
)
|
||||
val order = activeOrder ?: return
|
||||
if (!order.matches(orderNumber, captureType, imageIds, purchaseMode)) {
|
||||
LogUtils.i(TAG, "忽略不匹配的支付成功消息 - source: $source, expected: $expectedOrderNumber")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -481,16 +549,16 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
stopPayStatusPolling()
|
||||
_payQrCodeUrl.value = null
|
||||
_dismissPayDialogEvents.tryEmit(Unit)
|
||||
navigateToPrinting(orderNumber, captureType, imageIds)
|
||||
navigateAfterPurchase(orderNumber, captureType, imageIds, order)
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付成功后直接跳转到打印页面
|
||||
* 按已确认订单的模式进入打印流程或独立电子版完成页
|
||||
* @param orderNumber 订单号
|
||||
* @param captureType 抓拍类型
|
||||
* @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 {
|
||||
if (orderNumber.isNullOrBlank() || captureType == null) {
|
||||
LogUtils.e(
|
||||
@@ -530,7 +598,11 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
.setPopUpTo(AppRoutes.FACE_RECOGNITION_RESULT, inclusive = true)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,8 +39,6 @@ import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import javax.inject.Inject
|
||||
|
||||
enum class RecognitionStatus {
|
||||
@@ -316,9 +314,6 @@ class FaceRecognitionViewModel @Inject constructor(
|
||||
val thresholdValue = 0.45f
|
||||
val thresholdBody = thresholdValue.toString().toRequestBody("text/plain".toMediaType())
|
||||
|
||||
// index_date 参数:当前日期,格式 YYYYMMDD
|
||||
val dateFormat = SimpleDateFormat("yyyyMMdd", Locale.getDefault())
|
||||
val indexDate = dateFormat.format(Date())
|
||||
// Authorization header
|
||||
val authorization = "Bearer $faceSearchToken"
|
||||
|
||||
@@ -330,7 +325,6 @@ class FaceRecognitionViewModel @Inject constructor(
|
||||
LogUtils.d(TAG, "Parameters:")
|
||||
LogUtils.d(TAG, "image: ${imageFile.name} (${imageFile.length()} bytes)")
|
||||
LogUtils.d(TAG, "threshold: $thresholdValue")
|
||||
LogUtils.d(TAG, "index_date: $indexDate")
|
||||
LogUtils.d(TAG, "==========================================")
|
||||
|
||||
// 调用接口
|
||||
@@ -339,6 +333,7 @@ class FaceRecognitionViewModel @Inject constructor(
|
||||
authorization = authorization,
|
||||
image = imagePart,
|
||||
threshold = thresholdBody,
|
||||
// 不传日期,由 V3 服务端使用上海时区当天;人数及人脸大小也使用服务端默认值。
|
||||
indexDate = null
|
||||
)
|
||||
|
||||
@@ -413,6 +408,22 @@ class FaceRecognitionViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private var isOpeningRecentPhotos = false
|
||||
|
||||
fun browseRecentPhotos() {
|
||||
if (_recognitionStatus.value != RecognitionStatus.FAILED || isOpeningRecentPhotos) return
|
||||
isOpeningRecentPhotos = true
|
||||
countdownJob?.cancel()
|
||||
autoCaptureJob?.cancel()
|
||||
toPage(
|
||||
AppRoutes.buildRecentPhotosRoute(),
|
||||
NavOptions.Builder()
|
||||
.setPopUpTo(AppRoutes.FACE_RECOGNITION, inclusive = true)
|
||||
.setLaunchSingleTop(true)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
fun privatePolicy() {
|
||||
val args = mapOf(
|
||||
AgreementRoutes.AGREEMENT_URL to BuildConfig.BASE_URL + "/pages/oscar/private-policy.html",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.yzx.kiosk.ui.face.viewmodel
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
enum class RecentPhotosState { IDLE, LOADING, READY, EMPTY, ERROR }
|
||||
|
||||
/** One request per result-page lifetime; retries are explicit and cancellation propagates. */
|
||||
internal class RecentPhotosLoader(
|
||||
private val scope: CoroutineScope,
|
||||
private val fetchAndApply: suspend () -> Boolean,
|
||||
) {
|
||||
private val mutableState = MutableStateFlow(RecentPhotosState.IDLE)
|
||||
val state = mutableState.asStateFlow()
|
||||
private var job: Job? = null
|
||||
|
||||
fun load(retry: Boolean = false) {
|
||||
if (job?.isActive == true) return
|
||||
if (mutableState.value != RecentPhotosState.IDLE && !(retry && mutableState.value == RecentPhotosState.ERROR)) return
|
||||
mutableState.value = RecentPhotosState.LOADING
|
||||
job = scope.launch {
|
||||
try {
|
||||
mutableState.value = if (fetchAndApply()) RecentPhotosState.READY else RecentPhotosState.EMPTY
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
mutableState.value = RecentPhotosState.ERROR
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,7 @@ fun PosterScreenWithLiveOrSlideshow(
|
||||
}
|
||||
}
|
||||
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
@Composable
|
||||
private fun PosterLiveStreamLayer(
|
||||
url: String,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.yzx.kiosk.ui.upload.view
|
||||
|
||||
import com.yzx.kiosk.utils.formatPriceDisplay
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
@@ -150,7 +151,7 @@ fun PhotoSelectScreen(
|
||||
}
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
Text(
|
||||
text = "打印${pricePerPhoto}元/张",
|
||||
text = "打印${formatPriceDisplay(pricePerPhoto)}元/张",
|
||||
fontSize = 21.sp,
|
||||
color = Color(0xFF000000)
|
||||
)
|
||||
@@ -170,7 +171,7 @@ fun PhotoSelectScreen(
|
||||
enabled = selectedCount > 0
|
||||
) {
|
||||
Text(
|
||||
text = if (selectedCount > 0) "支付${totalPrice}元" else "支付0元",
|
||||
text = if (selectedCount > 0) "支付${formatPriceDisplay(totalPrice)}元" else "支付0元",
|
||||
fontSize = 24.sp,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.yzx.kiosk.ui.upload.view
|
||||
|
||||
import com.yzx.kiosk.utils.formatPriceDisplay
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
@@ -259,7 +260,7 @@ fun UploadPhotoScreen(
|
||||
// 打印价格
|
||||
ServiceInfoRow(
|
||||
label = "打印价格",
|
||||
value = printPrice,
|
||||
value = formatPriceDisplay(printPrice),
|
||||
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" -> {
|
||||
// 支付成功消息
|
||||
LogUtils.d(TAG, ">>> 收到支付成功消息 (code=5) <<<")
|
||||
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")
|
||||
|
||||
// 发送支付成功事件
|
||||
val event = parsePurchaseCompletedEvent(jsonObject)
|
||||
if (event != null) {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
_uploadPhotoEvents.emit(
|
||||
UploadPhotoEvent.PaySuccess(
|
||||
orderNumber = orderNumber,
|
||||
captureType = captureType,
|
||||
imageIds = imageIdList,
|
||||
fileMap = fileMap
|
||||
)
|
||||
)
|
||||
_uploadPhotoEvents.emit(event)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtils.e(TAG, "解析支付成功消息失败: ${e.message}")
|
||||
} else {
|
||||
LogUtils.e(TAG, "忽略无效的购买完成消息")
|
||||
}
|
||||
}
|
||||
"4" -> {
|
||||
@@ -974,6 +941,7 @@ sealed class UploadPhotoEvent {
|
||||
val orderNumber: String?,
|
||||
val captureType: Int?,
|
||||
val fileMap:String,
|
||||
val imageIds: List<Int>
|
||||
val imageIds: List<Int>,
|
||||
val purchaseMode: String? = null
|
||||
) : UploadPhotoEvent()
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 814 B |
|
Before Width: | Height: | Size: 974 B |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 630 B |
|
Before Width: | Height: | Size: 656 B |
|
Before Width: | Height: | Size: 766 B |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 657 B |
|
Before Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 806 B |
|
Before Width: | Height: | Size: 912 B |
|
Before Width: | Height: | Size: 726 B |
|
Before Width: | Height: | Size: 778 B |
|
Before Width: | Height: | Size: 922 B |
|
Before Width: | Height: | Size: 810 B |
|
Before Width: | Height: | Size: 926 B |
|
Before Width: | Height: | Size: 850 B |
|
Before Width: | Height: | Size: 862 B |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 770 B |
|
Before Width: | Height: | Size: 864 B |
|
Before Width: | Height: | Size: 706 B |
|
Before Width: | Height: | Size: 902 B |
|
Before Width: | Height: | Size: 750 B |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 740 B |
|
Before Width: | Height: | Size: 796 B |
|
Before Width: | Height: | Size: 816 B |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 884 B |
|
Before Width: | Height: | Size: 862 B |
|
Before Width: | Height: | Size: 832 B |
|
Before Width: | Height: | Size: 810 B |
|
Before Width: | Height: | Size: 704 B |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 924 B |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 1016 B |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 822 B |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 998 B |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 972 B |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |