Author SHA1 Message Date
lujiuyin 3c93572fbc docs: record KIOSK cleanup results and verification evidence 2026-09-20 10:48:56 +08:00
lujiuyin e6b671ef76 chore: remove unused bitmaps and Crashlytics buildtools runtime dependency 2026-09-20 10:46:57 +08:00
lujiuyin 5fe95f9255 fix: restore wrapper and Android quality checks 2026-09-20 10:44:22 +08:00
lujiuyin 835d6ecd00 docs: record current engineering review and UI verification 2026-09-20 10:06:50 +08:00
lujiuyin 6d81f1a28b feat: play supplied audio on electronic photo completion 2026-09-15 16:21:44 +08:00
lujiuyin 2231c38792 fix: load recent photo prices independently of photo results 2026-09-15 16:21:44 +08:00
lujiuyin fa827fdd99 fix: remove unsupported video copy from electronic completion 2026-09-15 11:50:58 +08:00
lujiuyin 5f8586666c fix: align photo unit prices and update purchase labels 2026-09-15 11:23:48 +08:00
lujiuyin b1cdc1fe06 feat: support electronic photo purchases and download completion 2026-09-15 10:51:38 +08:00
lujiuyin 712f6154bc chore: bump version to 1.1.4 2026-09-14 13:36:06 +08:00
lujiuyin 1d66bd18d1 feat: add recent photo browsing with source-aware titles and audio
Add recent-photo entry points to face recognition and its result page, preserve selection on back navigation, and reuse photo selection and payment flows. Switch face search to v3 with server defaults. Include cropped guidance audio, request and UI coverage, and verification artifacts.
2026-09-08 15:48:42 +08:00
lujiuyin 63944db6fb fix: optimize face recognition resource lifecycle 2026-08-25 13:42:09 +08:00
lujiuyin fb5d3b71df 移除人脸识别成功 Toast 2026-08-25 11:20:47 +08:00
lujiuyin cd25ada951 feat: 支付成功后直接进入打印页 2026-08-24 15:29:24 +08:00
230 changed files with 12407 additions and 1386 deletions
+2
View File
@@ -0,0 +1,2 @@
/gradlew text eol=lf
/gradlew.bat text eol=crlf
+2 -3
View File
@@ -89,8 +89,8 @@ android {
applicationId = "com.yzx.kiosk" applicationId = "com.yzx.kiosk"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 23 versionCode = 24
versionName = "1.1.3" versionName = "1.1.4"
//multiDexEnabled = true //multiDexEnabled = true
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -230,7 +230,6 @@ dependencies {
implementation(libs.androidx.compose.foundation) implementation(libs.androidx.compose.foundation)
implementation(libs.firebase.crashlytics.buildtools)
compileOnly(libs.ksp.gradlePlugin) compileOnly(libs.ksp.gradlePlugin)
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<lint>
<!-- Glide is used only for image views (GlideEngine). No NotificationTarget,
notification posting, or foreground-service calls exist in app sources.
Match this library-only diagnostic; keep other permission errors enabled. -->
<issue id="NotificationPermission">
<ignore regexp=".*usage from com\.bumptech\.glide\.request\.target\.NotificationTarget.*" />
</issue>
</lint>
@@ -19,6 +19,6 @@ class ExampleInstrumentedTest {
fun useAppContext() { fun useAppContext() {
// Context of the app under test. // Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.zhifly.follow", appContext.packageName) assertEquals("com.yzx.kiosk", appContext.packageName)
} }
} }
@@ -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) }
}
@@ -0,0 +1,98 @@
package com.yzx.kiosk.ui.face.resource
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import androidx.exifinterface.media.ExifInterface
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
@RunWith(AndroidJUnit4::class)
class FaceThumbnailDecoderInstrumentedTest {
private lateinit var testDirectory: File
@Before
fun setUp() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
testDirectory = File(context.cacheDir, "face_thumbnail_test").apply {
deleteRecursively()
mkdirs()
}
}
@After
fun tearDown() {
testDirectory.deleteRecursively()
}
@Test
fun largeImagesAreBoundedAndAllExifOrientationsAreHandled() {
val orientations = listOf(
ExifInterface.ORIENTATION_NORMAL to false,
ExifInterface.ORIENTATION_FLIP_HORIZONTAL to false,
ExifInterface.ORIENTATION_ROTATE_180 to false,
ExifInterface.ORIENTATION_FLIP_VERTICAL to false,
ExifInterface.ORIENTATION_TRANSPOSE to true,
ExifInterface.ORIENTATION_ROTATE_90 to true,
ExifInterface.ORIENTATION_TRANSVERSE to true,
ExifInterface.ORIENTATION_ROTATE_270 to true,
)
val decoder = FaceThumbnailDecoder()
orientations.forEachIndexed { index, (orientation, swapsDimensions) ->
val imageFile = File(testDirectory, "orientation_$index.jpg")
createLargeJpeg(imageFile)
ExifInterface(imageFile).apply {
setAttribute(ExifInterface.TAG_ORIENTATION, orientation.toString())
saveAttributes()
}
val decoded = decoder.decode(imageFile.absolutePath)
assertNotNull(decoded)
decoded!!
assertFalse(decoded.isRecycled)
assertTrue(maxOf(decoded.width, decoded.height) <= FaceThumbnailDecoder.MAX_PREVIEW_EDGE_PX)
if (swapsDimensions) {
assertEquals(360, decoded.width)
assertEquals(720, decoded.height)
} else {
assertEquals(720, decoded.width)
assertEquals(360, decoded.height)
}
decoded.recycle()
}
}
@Test
fun captureFilesAreCreatedInOwnedDirectoryAndDeletedIdempotently() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val fileStore = FaceCaptureFileStore(context)
val captureFile = fileStore.createCaptureFile()
assertTrue(captureFile.exists())
assertEquals(FaceCaptureFileStore.CAPTURE_DIRECTORY, captureFile.parentFile?.name)
fileStore.delete(captureFile)
fileStore.delete(captureFile)
assertFalse(captureFile.exists())
}
private fun createLargeJpeg(file: File) {
val bitmap = Bitmap.createBitmap(1600, 800, Bitmap.Config.ARGB_8888)
Canvas(bitmap).drawColor(Color.MAGENTA)
file.outputStream().use { output ->
assertTrue(bitmap.compress(Bitmap.CompressFormat.JPEG, 95, output))
}
bitmap.recycle()
}
}
+14
View File
@@ -12,6 +12,7 @@ import com.luck.picture.lib.basic.PictureSelectorSupporterActivity
import com.luck.picture.lib.basic.PictureSelectorTransparentActivity import com.luck.picture.lib.basic.PictureSelectorTransparentActivity
import com.yzx.kiosk.datastore.AppState import com.yzx.kiosk.datastore.AppState
import com.yzx.kiosk.datastore.AppStoreDataSource import com.yzx.kiosk.datastore.AppStoreDataSource
import com.yzx.kiosk.ui.face.resource.FaceCaptureFileStore
import com.yzx.kiosk.utils.LogUtils import com.yzx.kiosk.utils.LogUtils
import com.yzx.kiosk.utils.MMKVUtils import com.yzx.kiosk.utils.MMKVUtils
import com.yzx.kiosk.utils.NavigationBarUtil import com.yzx.kiosk.utils.NavigationBarUtil
@@ -42,6 +43,9 @@ class App : Application() {
@Inject @Inject
lateinit var appStoreDataSource: AppStoreDataSource lateinit var appStoreDataSource: AppStoreDataSource
@Inject
lateinit var faceCaptureFileStore: FaceCaptureFileStore
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
instance = this instance = this
@@ -59,6 +63,16 @@ class App : Application() {
LogUtils.init(BuildConfig.DEBUG) LogUtils.init(BuildConfig.DEBUG)
MMKVUtils.init(this) MMKVUtils.init(this)
runCatching { faceCaptureFileStore.cleanupOrphans() }
.onSuccess { deletedFaceCaptures ->
if (deletedFaceCaptures > 0) {
LogUtils.i("App", "Cleaned $deletedFaceCaptures orphaned face capture file(s)")
}
}
.onFailure { error ->
LogUtils.e("App", "Failed to clean orphaned face captures: ${error.message}")
}
appState.initialize() appState.initialize()
initCoil() initCoil()
@@ -42,11 +42,12 @@ class LocalAudioPlayService @Inject constructor(
AppRoutes.HOME to R.raw.home_audio, AppRoutes.HOME to R.raw.home_audio,
AppRoutes.FACE_RECOGNITION to R.raw.face_recognition_page, AppRoutes.FACE_RECOGNITION to R.raw.face_recognition_page,
AppRoutes.FACE_RECOGNITION_RESULT to R.raw.face_recognition_success_result_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.UPLOAD_PHOTO to R.raw.scan_qrcode_upload_page,
AppRoutes.PHOTO_SELECT to R.raw.upload_unpaid_result_page, AppRoutes.PHOTO_SELECT to R.raw.upload_unpaid_result_page,
AppRoutes.PAY_SUCCESS to R.raw.pay_success_select_photo_print_page,
AppRoutes.PRINTING to R.raw.printing_page, AppRoutes.PRINTING to R.raw.printing_page,
AppRoutes.PRINT_SUCCESS to R.raw.print_complete_page, AppRoutes.PRINT_SUCCESS to R.raw.print_complete_page,
ELECTRONIC_COMPLETION_AUDIO_KEY to R.raw.electronic_complete_page,
// 特殊状态页面 // 特殊状态页面
"face_recognition_recognizing" to R.raw.face_recognition_recognizing_page, "face_recognition_recognizing" to R.raw.face_recognition_recognizing_page,
"face_recognition_failed" to R.raw.face_recognition_failed_page, "face_recognition_failed" to R.raw.face_recognition_failed_page,
@@ -110,12 +111,12 @@ class LocalAudioPlayService @Inject constructor(
return 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) { if (audioResId == null) {
LogUtils.d(TAG, "路由 $baseRoute 没有对应的音频文件") LogUtils.d(TAG, "路由音频 $audioKey 没有对应的音频文件")
return 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.navigation.NavType
import androidx.navigation.navArgument
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
@@ -17,7 +19,6 @@ import com.yzx.kiosk.ui.printer.view.PrinterManageScreen
import com.yzx.kiosk.ui.setting.view.SettingScreen import com.yzx.kiosk.ui.setting.view.SettingScreen
import com.yzx.kiosk.ui.face.view.FaceRecognitionScreen import com.yzx.kiosk.ui.face.view.FaceRecognitionScreen
import com.yzx.kiosk.ui.face.view.FaceRecognitionResultScreen import com.yzx.kiosk.ui.face.view.FaceRecognitionResultScreen
import com.yzx.kiosk.ui.upload.view.PaySuccessScreen
import com.yzx.kiosk.ui.upload.view.PhotoSelectScreen import com.yzx.kiosk.ui.upload.view.PhotoSelectScreen
import com.yzx.kiosk.ui.upload.view.PrintingScreen import com.yzx.kiosk.ui.upload.view.PrintingScreen
import com.yzx.kiosk.ui.upload.view.UploadPhotoScreen import com.yzx.kiosk.ui.upload.view.UploadPhotoScreen
@@ -29,6 +30,7 @@ import kotlinx.coroutines.flow.collectLatest
import java.net.URLDecoder import java.net.URLDecoder
import java.net.URLEncoder import java.net.URLEncoder
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
@Composable @Composable
fun AppNavHost( fun AppNavHost(
navigator: AppNavigator, navigator: AppNavigator,
@@ -141,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 resultsParam = backStackEntry.arguments?.getString("results") ?: ""
val results = if (resultsParam.isNotEmpty()) { val results = if (resultsParam.isNotEmpty()) {
try { try {
@@ -154,48 +162,9 @@ fun AppNavHost(
} else { } else {
emptyList() emptyList()
} }
FaceRecognitionResultScreen(results = results) FaceRecognitionResultScreen(
} results = results,
source = backStackEntry.arguments?.getString("source") ?: AppRoutes.FACE_RESULT_SOURCE,
// 支付成功页面
composable(route = "${AppRoutes.PAY_SUCCESS}?urls={urls}&orderNumber={orderNumber}&captureType={captureType}") { backStackEntry ->
val urlsParam = backStackEntry.arguments?.getString("urls") ?: ""
val orderNumberParam = backStackEntry.arguments?.getString("orderNumber") ?: ""
val captureTypeParam = backStackEntry.arguments?.getString("captureType") ?: ""
val photoUrls = if (urlsParam.isNotEmpty()) {
try {
val decoded = URLDecoder.decode(urlsParam, "UTF-8")
// 如果是 JSON 格式(以 [ 开头),作为单个元素传递
if (decoded.startsWith("[")) {
listOf(decoded)
} else {
// 旧格式:逗号分隔的 URL 列表
decoded.split(",")
}
} catch (e: Exception) {
emptyList()
}
} else {
emptyList()
}
val orderNumber = if (orderNumberParam.isNotEmpty()) {
try {
URLDecoder.decode(orderNumberParam, "UTF-8")
} catch (e: Exception) {
""
}
} else {
""
}
val captureType = captureTypeParam.toIntOrNull()
PaySuccessScreen(
photoUrls = photoUrls,
orderNumber = orderNumber,
captureType = captureType
) )
} }
@@ -241,6 +210,18 @@ fun AppNavHost(
) )
} }
// 电子版订单完成页,不创建打印页面或打印 ViewModel。
composable(
route = AppRoutes.ELECTRONIC_COMPLETION_PATTERN,
arguments = listOf(
navArgument("orderNumber") { type = NavType.StringType },
),
) { entry ->
com.yzx.kiosk.ui.face.view.ElectronicCompletionScreen(
orderNumber = entry.arguments?.getString("orderNumber").orEmpty(),
)
}
// 协议页面 // 协议页面
agreementScreen() agreementScreen()
} }
@@ -7,11 +7,36 @@ object AppRoutes {
const val DEVICE_CONFIG = "device_config" const val DEVICE_CONFIG = "device_config"
const val UPLOAD_PHOTO = "upload_photo" const val UPLOAD_PHOTO = "upload_photo"
const val PHOTO_SELECT = "photo_select" const val PHOTO_SELECT = "photo_select"
const val PAY_SUCCESS = "pay_success"
const val PRINTING = "printing" const val PRINTING = "printing"
const val ELECTRONIC_COMPLETION = "electronic_completion"
const val ELECTRONIC_COMPLETION_PATTERN = "$ELECTRONIC_COMPLETION?orderNumber={orderNumber}"
fun buildElectronicCompletionRoute(orderNumber: String): String =
"$ELECTRONIC_COMPLETION?orderNumber=${java.net.URLEncoder.encode(orderNumber, "UTF-8")}"
const val PRINT_SUCCESS = "print_success" const val PRINT_SUCCESS = "print_success"
const val FACE_RECOGNITION = "face_recognition" const val FACE_RECOGNITION = "face_recognition"
const val FACE_RECOGNITION_RESULT = "face_recognition_result" const val FACE_RECOGNITION_RESULT = "face_recognition_result"
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" const val AGREEMENT = "agreement"
fun buildPrintingRoute(
photoData: String,
orderNumber: String?,
captureType: Int?
): String {
val urlsParam = java.net.URLEncoder.encode(photoData, "UTF-8")
val orderNumberParam = orderNumber
?.let { java.net.URLEncoder.encode(it, "UTF-8") }
.orEmpty()
val captureTypeParam = captureType?.toString().orEmpty()
return "$PRINTING?urls=$urlsParam&orderNumber=$orderNumberParam&captureType=$captureTypeParam"
}
} }
@@ -28,8 +28,10 @@ object NetworkModule {
private const val CLIENT_DEFAULT = "defaultOkHttpClient" private const val CLIENT_DEFAULT = "defaultOkHttpClient"
const val CLIENT_UPLOAD = "uploadOkHttpClient" const val CLIENT_UPLOAD = "uploadOkHttpClient"
const val CLIENT_DOWNLOAD = "downloadOkHttpClient" const val CLIENT_DOWNLOAD = "downloadOkHttpClient"
const val CLIENT_FACE = "faceOkHttpClient"
const val RETROFIT_DEFAULT = "defaultRetrofit" const val RETROFIT_DEFAULT = "defaultRetrofit"
const val RETROFIT_UPLOAD = "uploadRetrofit" const val RETROFIT_UPLOAD = "uploadRetrofit"
const val RETROFIT_FACE = "faceRetrofit"
const val TRANSFRTVIEWMODEL = "transferViewModel" const val TRANSFRTVIEWMODEL = "transferViewModel"
@Provides @Provides
@@ -104,6 +106,17 @@ object NetworkModule {
} }
.build() .build()
@Provides
@Singleton
@Named(CLIENT_FACE)
fun provideFaceOkHttpClient(): OkHttpClient = OkHttpClient.Builder()
.apply {
if (BuildConfig.DEBUG) {
addInterceptor(DebugNetworkLoggingInterceptor("face-search"))
}
}
.build()
private fun buildRetrofit( private fun buildRetrofit(
baseUrl: String, baseUrl: String,
okHttpClient: OkHttpClient, okHttpClient: OkHttpClient,
@@ -131,4 +144,13 @@ object NetworkModule {
gson: Gson, gson: Gson,
@Named(BASE_URL) baseUrl: String @Named(BASE_URL) baseUrl: String
): Retrofit = buildRetrofit(baseUrl, okHttpClient, gson) ): Retrofit = buildRetrofit(baseUrl, okHttpClient, gson)
@Provides
@Singleton
@Named(RETROFIT_FACE)
fun provideFaceRetrofit(
@Named(CLIENT_FACE) okHttpClient: OkHttpClient,
gson: Gson,
@Named(BASE_URL) baseUrl: String,
): Retrofit = buildRetrofit(baseUrl, okHttpClient, gson)
} }
@@ -4,6 +4,7 @@ import android.content.Context
import com.yzx.kiosk.audio.AudioPlayService import com.yzx.kiosk.audio.AudioPlayService
import com.yzx.kiosk.network.service.NetworkService import com.yzx.kiosk.network.service.NetworkService
import com.yzx.kiosk.network.service.UploadService import com.yzx.kiosk.network.service.UploadService
import com.yzx.kiosk.network.service.FaceSearchService
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@@ -11,6 +12,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import com.yzx.kiosk.network.di.NetworkModule.RETROFIT_DEFAULT import com.yzx.kiosk.network.di.NetworkModule.RETROFIT_DEFAULT
import com.yzx.kiosk.network.di.NetworkModule.RETROFIT_UPLOAD import com.yzx.kiosk.network.di.NetworkModule.RETROFIT_UPLOAD
import com.yzx.kiosk.network.di.NetworkModule.RETROFIT_FACE
import retrofit2.Retrofit import retrofit2.Retrofit
import javax.inject.Named import javax.inject.Named
import javax.inject.Singleton import javax.inject.Singleton
@@ -31,6 +33,12 @@ object ServiceModule {
@Named(RETROFIT_UPLOAD) retrofit: Retrofit @Named(RETROFIT_UPLOAD) retrofit: Retrofit
): UploadService = retrofit.create(UploadService::class.java) ): UploadService = retrofit.create(UploadService::class.java)
@Provides
@Singleton
fun provideFaceSearchService(
@Named(RETROFIT_FACE) retrofit: Retrofit,
): FaceSearchService = retrofit.create(FaceSearchService::class.java)
@Provides @Provides
@Singleton @Singleton
fun provideAudioPlayService( fun provideAudioPlayService(
@@ -6,6 +6,10 @@ data class GetPayUrlRequest(
@SerializedName("type") @SerializedName("type")
val type: Int, val type: Int,
@SerializedName("image_id") @SerializedName("image_id")
val imageId: List<Int> val imageId: List<Int>,
@SerializedName("purchase_mode")
val purchaseMode: String? = null,
@SerializedName("video_id")
val videoId: List<Int>? = null
) )
@@ -6,6 +6,8 @@ data class VerifyResultRequest(
@SerializedName("type") @SerializedName("type")
val type: Int, val type: Int,
@SerializedName("image_id") @SerializedName("image_id")
val imageId: List<Int> val imageId: List<Int>,
@SerializedName("video_id")
val videoId: List<Int>? = null
) )
@@ -7,6 +7,12 @@ data class GetPayUrlResponse(
val url: String?, val url: String?,
@SerializedName("order_number") @SerializedName("order_number")
val orderNumber: String? val orderNumber: String?,
@SerializedName("purchase_mode")
val purchaseMode: String? = null,
@SerializedName("amount")
val amount: String? = null,
@SerializedName("order_status")
val orderStatus: Int? = null
) )
@@ -27,5 +27,8 @@ data class PaySuccessMessageData(
val captureType: Int?, val captureType: Int?,
@SerializedName("image_id") @SerializedName("image_id")
val imageIds: List<Int>? val imageIds: List<Int>?,
@SerializedName("purchase_mode")
val purchaseMode: String? = null
) )
@@ -6,6 +6,10 @@ data class VerifyResultResponse(
@SerializedName("price_image") @SerializedName("price_image")
val priceImage: String?, val priceImage: String?,
@SerializedName("amount") @SerializedName("amount")
val amount: String? val amount: String?,
@SerializedName("price_electronic")
val priceElectronic: String? = null,
@SerializedName("amount_electronic")
val amountElectronic: String? = null
) )
@@ -4,17 +4,24 @@ import com.yzx.kiosk.network.model.response.FaceSearchResponse
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.RequestBody import okhttp3.RequestBody
import retrofit2.Response import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Header import retrofit2.http.Header
import retrofit2.http.Multipart import retrofit2.http.Multipart
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.Part import retrofit2.http.Part
import retrofit2.http.Path import retrofit2.http.Url
interface FaceSearchService { interface FaceSearchService {
@GET
suspend fun recentPhotos(
@Url url: String,
@Header("Authorization") authorization: String,
): Response<FaceSearchResponse>
@Multipart @Multipart
@POST("/{sn}/api/search") @POST
suspend fun searchFace( suspend fun searchFace(
@Path("sn") sn: String, @Url url: String,
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
@Part image: MultipartBody.Part, @Part image: MultipartBody.Part,
@Part("threshold") threshold: RequestBody?, @Part("threshold") threshold: RequestBody?,
@@ -0,0 +1,22 @@
package com.yzx.kiosk.network.service
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
internal fun buildFaceSearchUrl(baseUrl: String, deviceSn: String): String {
require(deviceSn.isNotBlank()) { "Face box SN is empty" }
val parsedBaseUrl = baseUrl.trim().toHttpUrlOrNull()
?: throw IllegalArgumentException("Invalid face search base URL")
return parsedBaseUrl.newBuilder()
// 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,19 @@
package com.yzx.kiosk.ui.face.resource
import java.util.concurrent.atomic.AtomicLong
internal class CaptureGate {
private val nextToken = AtomicLong(0L)
private val activeToken = AtomicLong(NO_TOKEN)
fun tryAcquire(): Long? {
val token = nextToken.incrementAndGet()
return if (activeToken.compareAndSet(NO_TOKEN, token)) token else null
}
fun release(token: Long): Boolean = activeToken.compareAndSet(token, NO_TOKEN)
companion object {
private const val NO_TOKEN = 0L
}
}
@@ -0,0 +1,65 @@
package com.yzx.kiosk.ui.face.resource
import android.content.Context
import com.yzx.kiosk.utils.LogUtils
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
/** Owns the short-lived files created for face-search requests. */
@Singleton
class FaceCaptureFileStore @Inject constructor(
@ApplicationContext private val context: Context,
) {
fun createCaptureFile(): File {
val directory = File(context.cacheDir, CAPTURE_DIRECTORY)
check(directory.exists() || directory.mkdirs()) {
"Unable to create face capture cache directory"
}
return File.createTempFile(CAPTURE_PREFIX, CAPTURE_SUFFIX, directory)
}
fun delete(file: File?) {
if (file == null || !file.exists()) return
if (!file.delete()) {
LogUtils.e(TAG, "Failed to delete face capture file: ${file.name}")
}
}
/**
* A capture cannot remain in use across a process restart, so every owned file is orphaned
* when the application starts. Legacy root-cache captures are removed during migration too.
*/
fun cleanupOrphans(): Int = cleanupOrphans(context.cacheDir)
companion object {
private const val TAG = "FaceCaptureFileStore"
internal const val CAPTURE_DIRECTORY = "face_recognition"
internal const val CAPTURE_PREFIX = "capture_"
internal const val CAPTURE_SUFFIX = ".jpg"
internal const val LEGACY_PREFIX = "IMG_"
internal fun cleanupOrphans(cacheDirectory: File): Int {
var deletedCount = 0
val captureDirectory = File(cacheDirectory, CAPTURE_DIRECTORY)
captureDirectory.listFiles()?.forEach { file ->
if (file.deleteRecursively()) deletedCount++
}
cacheDirectory.listFiles()?.forEach { file ->
if (
file.isFile &&
file.name.startsWith(LEGACY_PREFIX) &&
file.name.endsWith(CAPTURE_SUFFIX, ignoreCase = true) &&
file.delete()
) {
deletedCount++
}
}
return deletedCount
}
}
}
@@ -0,0 +1,96 @@
package com.yzx.kiosk.ui.face.resource
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import androidx.core.graphics.scale
import androidx.exifinterface.media.ExifInterface
import com.yzx.kiosk.utils.LogUtils
import javax.inject.Inject
class FaceThumbnailDecoder @Inject constructor() {
fun decode(imagePath: String): Bitmap? {
var ownedBitmap: Bitmap? = null
return try {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(imagePath, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
val options = BitmapFactory.Options().apply {
inPreferredConfig = Bitmap.Config.ARGB_8888
inSampleSize = calculateInSampleSize(
bounds.outWidth,
bounds.outHeight,
MAX_PREVIEW_EDGE_PX,
)
}
ownedBitmap = BitmapFactory.decodeFile(imagePath, options) ?: return null
val orientation = ExifInterface(imagePath).getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL,
)
val oriented = transformForExif(ownedBitmap, orientation)
if (oriented !== ownedBitmap) {
ownedBitmap.recycle()
ownedBitmap = oriented
}
val scaled = scaleDown(ownedBitmap, MAX_PREVIEW_EDGE_PX)
if (scaled !== ownedBitmap) {
ownedBitmap.recycle()
}
ownedBitmap = null
scaled
} catch (e: Exception) {
ownedBitmap?.let { if (!it.isRecycled) it.recycle() }
LogUtils.e(TAG, "Failed to decode face preview: ${e.message}")
null
}
}
private fun transformForExif(source: Bitmap, orientation: Int): Bitmap {
val matrix = Matrix()
when (orientation) {
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.setScale(-1f, 1f)
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.setRotate(180f)
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.setScale(1f, -1f)
ExifInterface.ORIENTATION_TRANSPOSE -> {
matrix.setRotate(90f)
matrix.postScale(-1f, 1f)
}
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.setRotate(90f)
ExifInterface.ORIENTATION_TRANSVERSE -> {
matrix.setRotate(270f)
matrix.postScale(-1f, 1f)
}
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.setRotate(270f)
else -> return source
}
return Bitmap.createBitmap(source, 0, 0, source.width, source.height, matrix, true)
}
private fun scaleDown(source: Bitmap, maxEdgePx: Int): Bitmap {
val currentMaxEdge = maxOf(source.width, source.height)
if (currentMaxEdge <= maxEdgePx) return source
val scale = maxEdgePx.toFloat() / currentMaxEdge
val targetWidth = (source.width * scale).toInt().coerceAtLeast(1)
val targetHeight = (source.height * scale).toInt().coerceAtLeast(1)
return source.scale(targetWidth, targetHeight)
}
companion object {
private const val TAG = "FaceThumbnailDecoder"
const val MAX_PREVIEW_EDGE_PX = 720
internal fun calculateInSampleSize(width: Int, height: Int, maxEdgePx: Int): Int {
if (width <= 0 || height <= 0 || maxEdgePx <= 0) return 1
var sampleSize = 1
while (maxOf(width / (sampleSize * 2), height / (sampleSize * 2)) > maxEdgePx) {
sampleSize *= 2
}
return sampleSize
}
}
}
@@ -0,0 +1,187 @@
package com.yzx.kiosk.ui.face.view
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowLeft
import androidx.compose.material.icons.rounded.Call
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.tooling.preview.Preview
import androidx.hilt.navigation.compose.hiltViewModel
import com.yzx.kiosk.R
import com.yzx.kiosk.navigation.routes.AppRoutes
import com.yzx.kiosk.theme.AppTheme
import com.yzx.kiosk.ui.common.view.FullScreenMode
import com.yzx.kiosk.ui.face.viewmodel.ElectronicCompletionViewModel
import com.yzx.kiosk.utils.QrCodeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Composable
fun ElectronicCompletionScreen(
orderNumber: String,
viewModel: ElectronicCompletionViewModel = hiltViewModel(),
) {
val countdown by viewModel.countdown.collectAsState()
val qrUrl by viewModel.qrCodeUrl.collectAsState()
val failed by viewModel.qrCodeFailed.collectAsState()
val hotline by viewModel.hotline.collectAsState()
LaunchedEffect(orderNumber) { viewModel.initialize(orderNumber) }
var renderAttempt by remember { mutableIntStateOf(0) }
val renderedQr by produceState<Pair<ImageBitmap?, Boolean>>(null to false, qrUrl, renderAttempt) {
value = null to false
if (qrUrl.isNotBlank()) {
value = withContext(Dispatchers.Default) {
val result = runCatching {
QrCodeUtils.generateStyledQrBitmap(
content = qrUrl, size = 800, qrColor = android.graphics.Color.BLACK,
bgColor = android.graphics.Color.WHITE, cornerRadius = 0f,
).asImageBitmap()
}
result.getOrNull() to result.isFailure
}
}
}
val returnHome = { viewModel.closeAllExcept(AppRoutes.HOME) }
BackHandler(onBack = returnHome)
FullScreenMode()
ElectronicCompletionContent(
bitmap = renderedQr.first,
failed = failed || renderedQr.second,
countdown = countdown,
hotline = hotline,
onRetry = {
renderAttempt += 1
viewModel.retryQrCode()
},
onReturnHome = returnHome,
)
}
/** Reference is 941 × 1672. Uniform density keeps typography and spacing in proportion
* on the portrait kiosk, without stretching the QR code on other display sizes. */
@Composable
internal fun ElectronicCompletionContent(
bitmap: ImageBitmap?,
failed: Boolean,
countdown: Int,
hotline: String,
onRetry: () -> Unit,
onReturnHome: () -> Unit,
) {
val primary = MaterialTheme.colorScheme.primary
val resources = LocalContext.current.resources
// Only the decorative phone and footer are used from the artwork. The QR, copy,
// hotline, buttons and all panels below are native, live Compose content.
val artwork = remember(resources) {
val source = BitmapFactory.decodeResource(resources, R.drawable.electronic_completion_artwork)
val phone = Bitmap.createBitmap(source, 34, 354, 424, 635).asImageBitmap()
val footer = Bitmap.createBitmap(source, 0, 1440, 941, 232).asImageBitmap()
source.recycle()
phone to footer
}
BoxWithConstraints(Modifier.fillMaxSize().background(Color.White), contentAlignment = Alignment.Center) {
val density = LocalDensity.current
val scale = minOf(maxWidth.value / 941f, maxHeight.value / 1672f)
val canvasHeight = maxOf(1672f, maxHeight.value / scale)
CompositionLocalProvider(LocalDensity provides Density(density.density * scale, fontScale = 1f)) {
Box(Modifier.requiredSize(941.dp, canvasHeight.dp).background(Color.White)) {
Box(Modifier.fillMaxWidth().height(186.dp).background(primary)) {
IconButton(onClick = onReturnHome, modifier = Modifier.offset(20.dp, 91.dp).size(80.dp)) {
Icon(Icons.AutoMirrored.Rounded.KeyboardArrowLeft, "返回首页", tint = Color.White, modifier = Modifier.size(58.dp))
}
Text("获取电子照片", Modifier.align(Alignment.BottomCenter).padding(bottom = 30.dp),
color = Color.White, fontSize = 42.sp, fontWeight = FontWeight.Bold)
Text("${countdown}秒后返回首页", Modifier.align(Alignment.TopEnd).padding(top = 28.dp, end = 36.dp),
color = Color.White.copy(alpha = .9f), fontSize = 23.sp)
}
Box(Modifier.offset(y = 186.dp).fillMaxWidth().height(138.dp).background(primary.copy(alpha = .055f)),
contentAlignment = Alignment.Center) {
Text("请使用微信扫描下方二维码,获取电子版照片\n可多人多次扫描",
color = Color(0xFF55585C), fontSize = 29.sp, lineHeight = 46.sp, textAlign = TextAlign.Center)
}
Image(artwork.first, "电子照片保存到手机示意图", Modifier.offset(34.dp, 354.dp).size(424.dp, 635.dp),
contentScale = ContentScale.FillBounds)
Box(Modifier.offset(483.dp, 354.dp).size(424.dp, 635.dp)
.background(Color(0xFFF5F5F6), RoundedCornerShape(28.dp))) {
Box(Modifier.offset(27.dp, 34.dp).size(369.dp, 354.dp)
.background(Color.White, RoundedCornerShape(26.dp)), contentAlignment = Alignment.Center) {
when {
bitmap != null -> Image(bitmap, "电子版照片下载二维码", Modifier.size(340.dp),
filterQuality = FilterQuality.None)
failed -> Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("二维码加载失败", fontSize = 26.sp, color = Color(0xFF55585C))
TextButton(onClick = onRetry) { Text("重新加载", fontSize = 26.sp) }
}
else -> CircularProgressIndicator(Modifier.size(52.dp), color = primary)
}
}
Text("请使用微信扫描", Modifier.offset(y = 410.dp).fillMaxWidth(),
color = Color(0xFF444444), fontSize = 29.sp, textAlign = TextAlign.Center)
Text("获取电子版照片", Modifier.offset(y = 462.dp).fillMaxWidth(),
color = Color.Black, fontSize = 32.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center)
Text("可多人多次扫描", Modifier.offset(y = 530.dp).fillMaxWidth(),
color = Color(0xFF555555), fontSize = 29.sp, textAlign = TextAlign.Center)
}
Box(Modifier.offset(34.dp, 1020.dp).size(873.dp, 387.dp)
.background(primary.copy(alpha = .055f), RoundedCornerShape(28.dp))) {
Icon(Icons.Rounded.Info, null, Modifier.offset(39.dp, 37.dp).size(50.dp), tint = primary)
Text("温馨提示", Modifier.offset(109.dp, 35.dp), color = primary, fontSize = 40.sp, fontWeight = FontWeight.Bold)
TipRow(1, "请使用微信扫描二维码获取电子版照片。", Modifier.offset(40.dp, 122.dp))
TipRow(2, "照片将以高清原图的形式提供,可多次下载。", Modifier.offset(40.dp, 186.dp))
TipRow(3, "如遇问题,请联系客服。", Modifier.offset(40.dp, 250.dp))
Row(Modifier.offset(109.dp, 312.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Rounded.Call, null, Modifier.size(37.dp), tint = primary)
Spacer(Modifier.width(24.dp))
Text("客服电话:${hotline.takeIf { it.isNotBlank() } ?: "暂无"}",
fontSize = 31.sp, color = primary, fontWeight = FontWeight.SemiBold)
}
}
Image(artwork.second, null, Modifier.align(Alignment.BottomCenter).size(941.dp, 232.dp),
contentScale = ContentScale.FillBounds)
}
}
}
}
@Composable
private fun TipRow(number: Int, text: String, modifier: Modifier = Modifier) {
val primary = MaterialTheme.colorScheme.primary
Row(modifier, verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(46.dp).background(primary.copy(alpha = .1f), CircleShape), contentAlignment = Alignment.Center) {
Text(number.toString(), color = primary, fontSize = 30.sp)
}
Spacer(Modifier.width(23.dp))
Text(text, color = Color(0xFF55585C), fontSize = 29.sp, lineHeight = 40.sp)
}
}
@Preview(name = "电子版完成页", widthDp = 941, heightDp = 1672, showBackground = true)
@Composable
private fun ElectronicCompletionPreview() {
AppTheme {
ElectronicCompletionContent(null, false, 90, "12345678910", {}, {})
}
}
@@ -1,7 +1,10 @@
package com.yzx.kiosk.ui.face.view package com.yzx.kiosk.ui.face.view
import com.yzx.kiosk.utils.formatPriceDisplay
import android.graphics.Bitmap import android.graphics.Bitmap
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import com.yzx.kiosk.ui.face.viewmodel.PurchaseMode
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
@@ -26,6 +29,9 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext 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.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -38,6 +44,8 @@ import coil.compose.SubcomposeAsyncImage
import coil.compose.SubcomposeAsyncImageContent import coil.compose.SubcomposeAsyncImageContent
import coil.compose.SubcomposeAsyncImageScope import coil.compose.SubcomposeAsyncImageScope
import coil.request.ImageRequest 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.R
import com.yzx.kiosk.component.appbar.AppTitleBar import com.yzx.kiosk.component.appbar.AppTitleBar
import com.yzx.kiosk.component.appbar.FaceBarNoStatusBarPadding import com.yzx.kiosk.component.appbar.FaceBarNoStatusBarPadding
@@ -55,21 +63,41 @@ import kotlinx.coroutines.flow.collect
@Composable @Composable
fun FaceRecognitionResultScreen( fun FaceRecognitionResultScreen(
results: List<FaceSearchResult> = emptyList(), results: List<FaceSearchResult> = emptyList(),
source: String = AppRoutes.FACE_RESULT_SOURCE,
viewModel: FaceRecognitionResultViewModel = hiltViewModel() viewModel: FaceRecognitionResultViewModel = hiltViewModel()
) { ) {
// 如果传入了results列表,初始化图片列表 // 如果传入了results列表,初始化图片列表
LaunchedEffect(results) { LaunchedEffect(source, results) {
if (results.isNotEmpty()) { if (source == AppRoutes.RECENT_RESULT_SOURCE) {
viewModel.loadRecentPhotos()
} else if (results.isNotEmpty()) {
viewModel.initPhotoList(results) viewModel.initPhotoList(results)
} }
} }
val recentPhotosState by viewModel.recentPhotosState.collectAsState()
val photoList by viewModel.photoList.collectAsState() val photoList by viewModel.photoList.collectAsState()
val selectedPhotos by viewModel.selectedPhotos.collectAsState() val selectedPhotos by viewModel.selectedPhotos.collectAsState()
val pricePerPhoto by viewModel.pricePerPhoto.collectAsState() val pricePerPhoto by viewModel.pricePerPhoto.collectAsState()
val totalPrice by viewModel.totalPrice.collectAsState() val totalPrice by viewModel.totalPrice.collectAsState()
val electronicPrice by viewModel.electronicPrice.collectAsState()
val electronicTotal by viewModel.electronicTotal.collectAsState()
val creatingOrder by viewModel.creatingOrder.collectAsState()
val quoteLoading by viewModel.quoteLoading.collectAsState()
val quoteError by viewModel.quoteError.collectAsState()
val paymentAmount by viewModel.paymentAmount.collectAsState()
val imageLoadStates by viewModel.imageLoadStates.collectAsState() val imageLoadStates by viewModel.imageLoadStates.collectAsState()
var isOpeningRecentPhotos by remember { mutableStateOf(false) }
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 selectedCount = selectedPhotos.size
val isAllSelected = photoList.isNotEmpty() && selectedPhotos.size == photoList.size val isAllSelected = photoList.isNotEmpty() && selectedPhotos.size == photoList.size
@@ -94,7 +122,7 @@ fun FaceRecognitionResultScreen(
Column { Column {
AppTitleBar( AppTitleBar(
backgroundColor = Color.White, backgroundColor = Color.White,
title = "人脸识别结果", title = if (source == AppRoutes.RECENT_RESULT_SOURCE) "最近照片" else "人脸识别结果",
isShowBackIcon = true, isShowBackIcon = true,
onBackClick = { viewModel.navigateBack() }, onBackClick = { viewModel.navigateBack() },
isShowButtonLine = false isShowButtonLine = false
@@ -120,109 +148,147 @@ fun FaceRecognitionResultScreen(
shape = RoundedCornerShape(18.dp), shape = RoundedCornerShape(18.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFFF4F4F4)) colors = CardDefaults.cardColors(containerColor = Color(0xFFF4F4F4))
) { ) {
Row( Column(Modifier.fillMaxWidth().padding(24.dp)) {
modifier = Modifier Row(
.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
.padding(24.dp), verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
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
)
}
Spacer(modifier = Modifier.height(18.dp))
Text(
text = "打印${pricePerPhoto}元/张",
fontSize = 21.sp,
color = Color(0xFF000000)
)
}
Button(
onClick = {
viewModel.getPayUrl { url ->
showPayDialog = true
}
},
modifier = Modifier
.height(72.dp)
.widthIn(min = 180.dp),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF0073FF)),
enabled = selectedCount > 0
) { ) {
Text( Text("已选择 ${selectedCount} 张", fontSize = 24.sp, fontWeight = FontWeight.Bold)
text = if (selectedCount > 0) "支付${totalPrice}元" else "支付0元", Spacer(Modifier.weight(1f))
fontSize = 24.sp, Row(
color = Color.White, horizontalArrangement = Arrangement.spacedBy(20.dp),
fontWeight = FontWeight.Bold verticalAlignment = Alignment.CenterVertically,
) ) {
Text("打印版 ${formatPriceDisplay(pricePerPhoto)}元/张", fontSize = 21.sp)
Text("电子版 ${formatPriceDisplay(electronicPrice)}元/张", fontSize = 21.sp)
}
}
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(mode) { showPayDialog = true } },
enabled = selectedCount > 0 && total != "--" && !creatingOrder && !quoteLoading,
modifier = Modifier.fillMaxWidth().heightIn(min = 88.dp),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (isPrint) Color(0xFF0073FF) else Color(0xFFE3EFFF),
contentColor = if (isPrint) Color.White else Color(0xFF1756A9),
),
) {
Text(
text = if (!isPrint && total == "--" && !quoteLoading)
"电子版暂不可购买" else "${if (isPrint) "购买打印照片" else "购买电子照片"}\n${formatPriceDisplay(total)}元",
textAlign = TextAlign.Center,
fontSize = 24.sp,
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( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
.fillMaxWidth() verticalAlignment = Alignment.CenterVertically,
.padding(horizontal = 16.dp, vertical = 8.dp)
.clickable { viewModel.toggleSelectAll() },
verticalAlignment = Alignment.CenterVertically
) { ) {
// 复选框 Row(
Box( modifier = Modifier.weight(1f).heightIn(min = 48.dp)
modifier = Modifier .clickable(enabled = photoList.isNotEmpty()) { viewModel.toggleSelectAll() },
.size(24.dp) verticalAlignment = Alignment.CenterVertically,
.clip(RoundedCornerShape(6.dp))
.background(if (isAllSelected) Color(0xFF0073FF) else Color.White)
.border(
width = 2.dp,
color = if (isAllSelected) Color(0xFF0073FF) else Color(0xFFCCCCCC),
shape = RoundedCornerShape(4.dp)
),
contentAlignment = Alignment.Center
) { ) {
if (isAllSelected) { // 复选框
Icon( Box(
imageVector = Icons.Default.Check, modifier = Modifier
contentDescription = null, .size(24.dp)
tint = Color.White, .clip(RoundedCornerShape(6.dp))
modifier = Modifier.size(24.dp) .background(if (isAllSelected) Color(0xFF0073FF) else Color.White)
) .border(
width = 2.dp,
color = if (isAllSelected) Color(0xFF0073FF) else Color(0xFFCCCCCC),
shape = RoundedCornerShape(4.dp)
),
contentAlignment = Alignment.Center
) {
if (isAllSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(24.dp)
)
}
}
Spacer(modifier = Modifier.width(18.dp))
Text(
text = "全选",
fontSize = 24.sp,
color = Color.Black
)
Text(
text = "(共${photoList.size}张)",
fontSize = 24.sp,
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)
} }
} }
}
Spacer(modifier = Modifier.width(18.dp)) if (source == AppRoutes.RECENT_RESULT_SOURCE && recentPhotosState != RecentPhotosState.READY) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text( Column(horizontalAlignment = Alignment.CenterHorizontally) {
text = "全选", if (recentPhotosState == RecentPhotosState.IDLE || recentPhotosState == RecentPhotosState.LOADING) {
fontSize = 24.sp, CircularProgressIndicator(Modifier.size(32.dp))
color = Color.Black Spacer(Modifier.height(16.dp))
) }
Text( Text(
text = "(共${photoList.size}张)", text = when (recentPhotosState) {
fontSize = 24.sp, RecentPhotosState.ERROR -> "照片加载失败,请重试"
color = Color.Black RecentPhotosState.EMPTY -> "暂无可显示的最近照片"
) else -> "正在加载照片…"
},
fontSize = 18.sp,
color = Color(0xFF666666),
)
if (recentPhotosState == RecentPhotosState.ERROR) {
TextButton(onClick = viewModel::retryRecentPhotos) { Text("重试", fontSize = 18.sp) }
}
}
}
} }
// 图片瀑布流列表 // 图片瀑布流列表
@@ -280,7 +346,7 @@ fun FaceRecognitionResultScreen(
} }
// 支付弹框 // 支付弹框
if (showPayDialog && selectedCount > 0 && payQrCodeUrl != null) { if (showPayDialog && payQrCodeUrl != null) {
DisposableEffect(payQrCodeUrl) { DisposableEffect(payQrCodeUrl) {
viewModel.startPayStatusPolling() viewModel.startPayStatusPolling()
onDispose { onDispose {
@@ -290,7 +356,7 @@ fun FaceRecognitionResultScreen(
WechatPayDialog( WechatPayDialog(
qrCodeUrl = payQrCodeUrl!!, qrCodeUrl = payQrCodeUrl!!,
totalPrice = totalPrice, totalPrice = paymentAmount,
onDismiss = { onDismiss = {
showPayDialog = false showPayDialog = false
viewModel.onPayDialogDismissed() viewModel.onPayDialogDismissed()
@@ -2,8 +2,6 @@ package com.yzx.kiosk.ui.face.view
import android.Manifest import android.Manifest
import android.graphics.Bitmap import android.graphics.Bitmap
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.camera.core.* import androidx.camera.core.*
import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView import androidx.camera.view.PreviewView
@@ -39,9 +37,6 @@ import com.yzx.kiosk.component.scaffold.AppScaffold
import com.yzx.kiosk.ui.common.view.FullScreenMode import com.yzx.kiosk.ui.common.view.FullScreenMode
import com.yzx.kiosk.ui.face.viewmodel.FaceRecognitionViewModel import com.yzx.kiosk.ui.face.viewmodel.FaceRecognitionViewModel
import com.yzx.kiosk.ui.face.viewmodel.RecognitionStatus import com.yzx.kiosk.ui.face.viewmodel.RecognitionStatus
import java.util.concurrent.Executor
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class)
@Composable @Composable
@@ -56,6 +51,7 @@ fun FaceRecognitionScreen(
val recognitionStatus by viewModel.recognitionStatus.collectAsState() val recognitionStatus by viewModel.recognitionStatus.collectAsState()
val capturedImage by viewModel.capturedImage.collectAsState() val capturedImage by viewModel.capturedImage.collectAsState()
val isRecognizing by viewModel.isRecognizing.collectAsState() val isRecognizing by viewModel.isRecognizing.collectAsState()
val isCaptureInProgress by viewModel.isCaptureInProgress.collectAsState()
// 相机权限 // 相机权限
val permissionsState = rememberMultiplePermissionsState( val permissionsState = rememberMultiplePermissionsState(
@@ -91,7 +87,8 @@ fun FaceRecognitionScreen(
Card( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 48.dp, vertical = 36.dp) .padding(horizontal = 48.dp)
.padding(top = 36.dp)
.aspectRatio(0.92f), .aspectRatio(0.92f),
shape = RoundedCornerShape(36.dp), shape = RoundedCornerShape(36.dp),
colors = CardDefaults.cardColors(containerColor = Color.Black) colors = CardDefaults.cardColors(containerColor = Color.Black)
@@ -103,6 +100,9 @@ fun FaceRecognitionScreen(
onImageCaptureReady = { imageCapture -> onImageCaptureReady = { imageCapture ->
viewModel.setImageCapture(imageCapture) viewModel.setImageCapture(imageCapture)
}, },
onImageCaptureReleased = { imageCapture ->
viewModel.clearImageCapture(imageCapture)
},
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
@@ -201,7 +201,7 @@ fun FaceRecognitionScreen(
} }
} }
// 拍照按钮(5秒倒计时期间也显示,识别失败时显示"重新拍照",其他情况显示"拍照") // 拍照按钮(5秒倒计时期间也显示,识别失败时显示"重新拍照",其他情况显示"拍照")
if (!isRecognizing) { if (!isRecognizing && !isCaptureInProgress) {
Button( Button(
onClick = { onClick = {
if (recognitionStatus == RecognitionStatus.FAILED) { if (recognitionStatus == RecognitionStatus.FAILED) {
@@ -234,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( Text(
@@ -309,50 +324,62 @@ fun FaceRecognitionScreen(
@Composable @Composable
fun CameraPreview( fun CameraPreview(
onImageCaptureReady: (ImageCapture) -> Unit, onImageCaptureReady: (ImageCapture) -> Unit,
onImageCaptureReleased: (ImageCapture) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
val currentOnImageCaptureReady by rememberUpdatedState(onImageCaptureReady)
val currentOnImageCaptureReleased by rememberUpdatedState(onImageCaptureReleased)
val previewView = remember(context) { PreviewView(context) }
val preview = remember { Preview.Builder().build() }
val imageCapture = remember {
ImageCapture.Builder()
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
.build()
}
AndroidView( AndroidView(
factory = { ctx -> factory = { previewView },
val previewView = PreviewView(ctx)
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
cameraProviderFuture.addListener({
val cameraProvider = cameraProviderFuture.get()
// 创建预览
val preview = Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
// 创建图像捕获
val imageCapture = ImageCapture.Builder()
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
.build()
// 通知ViewModel ImageCapture已准备好
onImageCaptureReady(imageCapture)
// 绑定到生命周期
val cameraSelector = CameraSelector.DEFAULT_FRONT_CAMERA
try {
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
lifecycleOwner,
cameraSelector,
preview,
imageCapture
)
} catch (e: Exception) {
e.printStackTrace()
}
}, ContextCompat.getMainExecutor(ctx))
previewView
},
modifier = modifier modifier = modifier
) )
DisposableEffect(context, lifecycleOwner, preview, imageCapture) {
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
val executor = ContextCompat.getMainExecutor(context)
var cameraProvider: ProcessCameraProvider? = null
var disposed = false
preview.setSurfaceProvider(previewView.surfaceProvider)
cameraProviderFuture.addListener({
try {
val resolvedProvider = cameraProviderFuture.get()
if (disposed) {
resolvedProvider.unbind(preview, imageCapture)
return@addListener
}
cameraProvider = resolvedProvider
resolvedProvider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_FRONT_CAMERA,
preview,
imageCapture,
)
currentOnImageCaptureReady(imageCapture)
} catch (e: Exception) {
e.printStackTrace()
}
}, executor)
onDispose {
disposed = true
currentOnImageCaptureReleased(imageCapture)
preview.setSurfaceProvider(null)
cameraProvider?.unbind(preview, imageCapture)
}
}
} }
@@ -0,0 +1,80 @@
package com.yzx.kiosk.ui.face.viewmodel
import androidx.lifecycle.viewModelScope
import com.yzx.kiosk.base.BaseViewModel
import com.yzx.kiosk.datastore.AppState
import com.yzx.kiosk.datastore.AppStoreDataSource
import com.yzx.kiosk.navigation.AppNavigator
import com.yzx.kiosk.navigation.routes.AppRoutes
import com.yzx.kiosk.network.model.request.SaveAlbumUrlRequest
import com.yzx.kiosk.network.repository.NetWorkRepository
import com.yzx.kiosk.utils.ToastUtils
import com.yzx.kiosk.utils.LogUtils
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
/** Download-only completion: deliberately has no printer or print-download dependencies. */
@HiltViewModel
class ElectronicCompletionViewModel @Inject constructor(
navigator: AppNavigator,
appState: AppState,
appStoreDataSource: AppStoreDataSource,
private val netWorkRepository: NetWorkRepository,
) : BaseViewModel(navigator = navigator, appState = appState) {
private val _countdown = MutableStateFlow(90)
val countdown = _countdown.asStateFlow()
private val _qrCodeUrl = MutableStateFlow("")
val qrCodeUrl = _qrCodeUrl.asStateFlow()
private val _qrCodeFailed = MutableStateFlow(false)
val qrCodeFailed = _qrCodeFailed.asStateFlow()
val hotline = MutableStateFlow(appStoreDataSource.getHomepageServicePhone()).asStateFlow()
private var orderNumber: String? = null
private var qrJob: Job? = null
fun initialize(orderNumber: String) {
if (this.orderNumber != null) return
if (orderNumber.isBlank()) {
_qrCodeFailed.value = true
return
}
this.orderNumber = orderNumber
retryQrCode()
viewModelScope.launch {
while (_countdown.value > 0) {
delay(1_000)
_countdown.value -= 1
}
navigator.closeAllExcept(AppRoutes.HOME)
}
}
fun retryQrCode() {
val number = orderNumber ?: return
if (qrJob?.isActive == true) return
_qrCodeFailed.value = false
qrJob = viewModelScope.launch {
try {
val response = netWorkRepository.saveAlbumUrl(SaveAlbumUrlRequest(number)).first()
val url = response.data?.url?.trim()
if (response.isSucceeded && !url.isNullOrEmpty()) {
_qrCodeUrl.value = url
} else {
_qrCodeFailed.value = true
ToastUtils.show(response.message?.takeIf { it.isNotBlank() } ?: "二维码加载失败,请重试")
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
_qrCodeFailed.value = true
LogUtils.e("ElectronicCompletion", "获取二维码失败: ${e.message}")
}
}
}
}
@@ -10,8 +10,8 @@ internal enum class FacePayStatusDecision {
internal fun decideFacePayStatus(orderStatus: Int?): FacePayStatusDecision = when (orderStatus) { internal fun decideFacePayStatus(orderStatus: Int?): FacePayStatusDecision = when (orderStatus) {
30 -> FacePayStatusDecision.COMPLETE_PAYMENT 30 -> FacePayStatusDecision.COMPLETE_PAYMENT
40, 50, 60 -> FacePayStatusDecision.STOP_WITH_TERMINAL_STATUS 10 -> FacePayStatusDecision.CONTINUE_POLLING
else -> FacePayStatusDecision.CONTINUE_POLLING else -> FacePayStatusDecision.STOP_WITH_TERMINAL_STATUS
} }
internal fun isValidFacePaySuccessMessage( internal fun isValidFacePaySuccessMessage(
@@ -19,7 +19,7 @@ internal fun isValidFacePaySuccessMessage(
message: PaySuccessMessageResponse? message: PaySuccessMessageResponse?
): Boolean { ): Boolean {
val paymentData = message?.data ?: return false val paymentData = message?.data ?: return false
return message.type == 5 && return message.orderStatus == 30 && message.type == 5 &&
paymentData.orderNumber == expectedOrderNumber && paymentData.orderNumber == expectedOrderNumber &&
paymentData.captureType == 2 && paymentData.captureType == 2 &&
!paymentData.imageIds.isNullOrEmpty() !paymentData.imageIds.isNullOrEmpty()
@@ -0,0 +1,60 @@
package com.yzx.kiosk.ui.face.viewmodel
import com.yzx.kiosk.network.model.response.GetPayUrlResponse
import java.math.BigDecimal
import java.math.RoundingMode
enum class PurchaseMode(val wireValue: String, val label: String) {
PRINT("print", "打印版"), ELECTRONIC("electronic", "电子版")
}
internal fun money(value: String?): String? {
if (value == null || !Regex("[0-9]+(?:\\.[0-9]{1,2})?").matches(value)) return null
return value.toBigDecimalOrNull()?.setScale(2, RoundingMode.UNNECESSARY)?.toPlainString()
}
internal fun isPositiveMoney(value: String?): Boolean =
money(value)?.toBigDecimal()?.let { it > BigDecimal.ZERO } == true
internal fun electronicQuoteAvailable(unit: String?, total: String?, hasPhotos: Boolean): Boolean =
isPositiveMoney(unit) && money(total) != null && (!hasPhotos || isPositiveMoney(total))
internal data class FacePaymentOrder(
val number: String,
val mode: PurchaseMode,
val imageIds: Set<Int>,
val amount: String,
val legacyPrint: Boolean,
) {
fun matches(number: String?, captureType: Int?, ids: List<Int>, purchaseMode: String?): Boolean =
this.number == number && captureType == 2 && ids.isNotEmpty() &&
ids.size == ids.toSet().size && imageIds == ids.toSet() &&
(purchaseMode == mode.wireValue || (legacyPrint && purchaseMode == null))
}
internal fun createFacePaymentOrder(
response: GetPayUrlResponse,
mode: PurchaseMode,
ids: List<Int>,
quotedAmount: String,
): FacePaymentOrder? {
val number = response.orderNumber?.trim()?.takeIf { it.isNotEmpty() } ?: return null
// Only an entirely old-style print response may omit the new order fields.
val legacy = mode == PurchaseMode.PRINT && response.purchaseMode == null && response.amount == null
if (!legacy && response.purchaseMode != mode.wireValue) return null
val amount = money(if (legacy) quotedAmount else response.amount) ?: return null
if (ids.isEmpty()) return null
val order = FacePaymentOrder(number, mode, ids.toSet(), amount, legacy)
if (mode == PurchaseMode.ELECTRONIC && !isPositiveMoney(amount)) return null
// The link represents a pending preselection, never proof of purchase.
if (!legacy && response.orderStatus != 10) return null
if (response.url.isNullOrBlank()) return null
return order
}
/** Versioning also rejects responses from an old A selection after A -> B -> A. */
internal class QuoteRevision {
private var revision = 0L
fun next(): Long = ++revision
fun isCurrent(candidate: Long): Boolean = revision == candidate
}
@@ -4,12 +4,14 @@ import androidx.lifecycle.viewModelScope
import com.yzx.kiosk.base.BaseViewModel import com.yzx.kiosk.base.BaseViewModel
import com.yzx.kiosk.datastore.AppState import com.yzx.kiosk.datastore.AppState
import com.yzx.kiosk.navigation.AppNavigator import com.yzx.kiosk.navigation.AppNavigator
import com.yzx.kiosk.navigation.routes.AppRoutes
import com.yzx.kiosk.network.model.request.GetPayUrlRequest import com.yzx.kiosk.network.model.request.GetPayUrlRequest
import com.yzx.kiosk.network.model.request.PaySuccessMessageRequest import com.yzx.kiosk.network.model.request.PaySuccessMessageRequest
import com.yzx.kiosk.network.model.request.VerifyResultRequest import com.yzx.kiosk.network.model.request.VerifyResultRequest
import com.yzx.kiosk.network.model.response.FaceSearchResult 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.repository.NetWorkRepository
import com.yzx.kiosk.network.result.asResult
import com.yzx.kiosk.ui.upload.viewmodel.FileMapData import com.yzx.kiosk.ui.upload.viewmodel.FileMapData
import com.yzx.kiosk.ui.upload.viewmodel.PhotoData import com.yzx.kiosk.ui.upload.viewmodel.PhotoData
import com.yzx.kiosk.utils.ToastUtils import com.yzx.kiosk.utils.ToastUtils
@@ -42,12 +44,43 @@ class FaceRecognitionResultViewModel @Inject constructor(
navigator: AppNavigator, navigator: AppNavigator,
appState: AppState, appState: AppState,
private val netWorkRepository: NetWorkRepository, private val netWorkRepository: NetWorkRepository,
private val faceSearchService: FaceSearchService,
private val webSocketService: WebSocketService, private val webSocketService: WebSocketService,
private val appStoreDataSource: AppStoreDataSource, private val appStoreDataSource: AppStoreDataSource,
) : BaseViewModel( ) : BaseViewModel(
navigator = navigator, navigator = navigator,
appState = appState 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) // 原始结果列表(用于获取图片id)
private var originalResults: List<FaceSearchResult> = emptyList() private var originalResults: List<FaceSearchResult> = emptyList()
@@ -70,6 +103,24 @@ class FaceRecognitionResultViewModel @Inject constructor(
private val _totalPrice = MutableStateFlow<String>("--") private val _totalPrice = MutableStateFlow<String>("--")
val totalPrice: StateFlow<String> = _totalPrice.asStateFlow() val totalPrice: StateFlow<String> = _totalPrice.asStateFlow()
private val _electronicPrice = MutableStateFlow("--")
val electronicPrice = _electronicPrice.asStateFlow()
private val _electronicTotal = MutableStateFlow("--")
val electronicTotal = _electronicTotal.asStateFlow()
private val _creatingOrder = MutableStateFlow(false)
val creatingOrder = _creatingOrder.asStateFlow()
private val _quoteLoading = MutableStateFlow(false)
val quoteLoading = _quoteLoading.asStateFlow()
private val _quoteError = MutableStateFlow<String?>(null)
val quoteError = _quoteError.asStateFlow()
private val _paymentAmount = MutableStateFlow("--")
val paymentAmount = _paymentAmount.asStateFlow()
private val _paymentMode = MutableStateFlow(PurchaseMode.PRINT)
val paymentMode = _paymentMode.asStateFlow()
private var activeOrder: FacePaymentOrder? = null
private val quoteRevision = QuoteRevision()
private var quoteJob: Job? = null
// 支付二维码URL // 支付二维码URL
private val _payQrCodeUrl = MutableStateFlow<String?>(null) private val _payQrCodeUrl = MutableStateFlow<String?>(null)
val payQrCodeUrl: StateFlow<String?> = _payQrCodeUrl.asStateFlow() val payQrCodeUrl: StateFlow<String?> = _payQrCodeUrl.asStateFlow()
@@ -109,6 +160,7 @@ class FaceRecognitionResultViewModel @Inject constructor(
orderNumber = event.orderNumber, orderNumber = event.orderNumber,
captureType = event.captureType, captureType = event.captureType,
imageIds = event.imageIds, imageIds = event.imageIds,
purchaseMode = event.purchaseMode,
source = "WebSocket" source = "WebSocket"
) )
} }
@@ -123,46 +175,52 @@ class FaceRecognitionResultViewModel @Inject constructor(
/** /**
* 初始化图片列表(从人脸识别结果) * 初始化图片列表(从人脸识别结果)
*/ */
private var faceResultsInitialized = false
fun initPhotoList(results: List<FaceSearchResult>) { fun initPhotoList(results: List<FaceSearchResult>) {
viewModelScope.launch { // Returning from a pushed recent-photos page must retain selection, pricing and image state.
// 保存原始结果列表 if (faceResultsInitialized) return
originalResults = results faceResultsInitialized = true
urlToIdMap.clear() verifyResult(emptyList())
viewModelScope.launch { applyPhotoList(results) }
}
// 先使用默认宽高比创建列表 private suspend fun applyPhotoList(results: List<FaceSearchResult>) {
val initialPhotos = results.mapNotNull { result -> // 保存原始结果列表
// 使用 thumbnail_oss_url originalResults = results
val imageUrl = if (appStoreDataSource.getUseLan()) result.thumbnailLanUrl else result.thumbnailOssUrl urlToIdMap.clear()
if (imageUrl.isNullOrEmpty()) {
LogUtils.i("FaceRecognitionResultViewModel", "图片URL为空,跳过") // 先使用默认宽高比创建列表
null val initialPhotos = results.mapNotNull { result ->
} else { // 使用 thumbnail_oss_url
val trimmedUrl = imageUrl.trim() val imageUrl = if (appStoreDataSource.getUseLan()) result.thumbnailLanUrl else result.thumbnailOssUrl
// 建立URL到ID的映射 if (imageUrl.isNullOrEmpty()) {
urlToIdMap[trimmedUrl] = result.id LogUtils.i("FaceRecognitionResultViewModel", "图片URL为空,跳过")
PhotoData( null
id = result.id, // 使用图片ID } else {
url = trimmedUrl, val trimmedUrl = imageUrl.trim()
aspectRatio = 1f // 默认1:1,后续会更新为真实宽高比 // 建立URL到ID的映射
) urlToIdMap[trimmedUrl] = result.id
} PhotoData(
id = result.id, // 使用图片ID
url = trimmedUrl,
aspectRatio = 1f // 默认1:1,后续会更新为真实宽高比
)
} }
if (initialPhotos.isEmpty()) {
LogUtils.i("FaceRecognitionResultViewModel", "没有有效的图片URL")
_photoList.value = emptyList()
_imageLoadStates.value = emptyMap()
return@launch
}
_photoList.value = initialPhotos
// 初始化加载状态
_imageLoadStates.value = initialPhotos.associate { it.url to ImageLoadState() }
// 页面初始化时调用验证接口(空列表,type: 2),获取默认价格
verifyResult(emptyList())
LogUtils.d(TAG, "图片列表初始化完成,共 ${initialPhotos.size} 张,宽高比将在图片加载成功后逐张更新")
} }
if (initialPhotos.isEmpty()) {
LogUtils.i("FaceRecognitionResultViewModel", "没有有效的图片URL")
_photoList.value = emptyList()
_imageLoadStates.value = emptyMap()
return
}
_photoList.value = initialPhotos
// 初始化加载状态
_imageLoadStates.value = initialPhotos.associate { it.url to ImageLoadState() }
LogUtils.d(TAG, "图片列表初始化完成,共 ${initialPhotos.size} 张,宽高比将在图片加载成功后逐张更新")
} }
/** /**
@@ -197,6 +255,7 @@ class FaceRecognitionResultViewModel @Inject constructor(
* 切换单张图片选中状态 * 切换单张图片选中状态
*/ */
fun togglePhotoSelection(url: String) { fun togglePhotoSelection(url: String) {
if (_creatingOrder.value) return
val current = _selectedPhotos.value.toMutableSet() val current = _selectedPhotos.value.toMutableSet()
if (current.contains(url)) { if (current.contains(url)) {
current.remove(url) current.remove(url)
@@ -214,6 +273,8 @@ class FaceRecognitionResultViewModel @Inject constructor(
* 切换全选状态 * 切换全选状态
*/ */
fun toggleSelectAll() { fun toggleSelectAll() {
if (_creatingOrder.value) return
if (_photoList.value.isEmpty()) return
val allUrls = _photoList.value.map { it.url }.toSet() val allUrls = _photoList.value.map { it.url }.toSet()
val newSelected = if (_selectedPhotos.value.size == allUrls.size) { val newSelected = if (_selectedPhotos.value.size == allUrls.size) {
// 当前全选,取消全选 // 当前全选,取消全选
@@ -242,100 +303,109 @@ class FaceRecognitionResultViewModel @Inject constructor(
* 验证结果接口 * 验证结果接口
*/ */
private fun verifyResult(imageIds: List<Int>) { private fun verifyResult(imageIds: List<Int>) {
viewModelScope.launch { clearActivePayment()
_quoteLoading.value = true
_quoteError.value = null
val revision = quoteRevision.next()
quoteJob?.cancel()
_pricePerPhoto.value = "--"
_totalPrice.value = "--"
_electronicPrice.value = "--"
_electronicTotal.value = "--"
quoteJob = viewModelScope.launch {
try { try {
val request = VerifyResultRequest( val response = netWorkRepository.verifyResult(VerifyResultRequest(2, imageIds.distinct(), videoId = emptyList())).first()
type = 2, if (!quoteRevision.isCurrent(revision)) return@launch
imageId = imageIds if (!response.isSucceeded) {
) _quoteError.value = response.message?.takeIf { it.isNotBlank() } ?: "获取报价失败,请重试"
return@launch
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 = "--"
} }
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() {
* 获取支付URL stopPayStatusPolling()
*/ activeOrder = null
fun getPayUrl(onSuccess: (String) -> Unit) { 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 { viewModelScope.launch {
val selectedIds = getSelectedImageIds()
if (selectedIds.isEmpty()) {
ToastUtils.show("请选择要打印的照片")
return@launch
}
showLoading() showLoading()
try { try {
val request = GetPayUrlRequest( val response = netWorkRepository.getPayUrl(
type = 2, GetPayUrlRequest(2, selectedIds, mode.wireValue, videoId = emptyList())
imageId = selectedIds ).first()
) if (!response.isSucceeded) {
recoverPayment(response.message?.takeIf { it.isNotBlank() } ?: "获取支付链接失败,请重新选择")
handleResultWithData( return@launch
flow = netWorkRepository.getPayUrl(request).asResult(), }
showToast = false, val data = response.data
onData = { response -> val order = if (data != null)
val url = response.url?.trim() createFacePaymentOrder(data, mode, selectedIds, quotedAmount) else null
val orderNumber = response.orderNumber?.trim() if (order == null) {
if (url.isNullOrEmpty() || orderNumber.isNullOrEmpty()) { recoverPayment("支付链接信息无效,请重新选择")
_payQrCodeUrl.value = null return@launch
LogUtils.e(TAG, "获取支付二维码成功,但 url 或 order_number 为空") }
ToastUtils.show("获取支付二维码失败") stopPayStatusPolling()
} else { activeOrder = order
stopPayStatusPolling() activePaymentOrderNumber = order.number
activePaymentOrderNumber = orderNumber paymentHandled.set(false)
paymentHandled.set(false) _paymentAmount.value = order.amount
_payQrCodeUrl.value = url _paymentMode.value = order.mode
onSuccess(url) val url = checkNotNull(data?.url).trim()
} _payQrCodeUrl.value = url
}, onSuccess(url)
onError = { msg, _ -> } catch (e: CancellationException) {
LogUtils.e("FaceRecognitionResultViewModel", "获取支付URL失败: $msg") throw e
ToastUtils.show("获取支付二维码失败")
},
onEnd = {
hideLoading()
}
)
} catch (e: Exception) { } catch (e: Exception) {
LogUtils.e("FaceRecognitionResultViewModel", "获取支付URL异常: ${e.message}") LogUtils.e(TAG, "创建订单失败: ${e.message}")
ToastUtils.show("获取支付二维码失败") ToastUtils.show("创建订单失败,请重试")
} finally {
_creatingOrder.value = false
hideLoading()
} }
} }
} }
@@ -382,14 +452,17 @@ class FaceRecognitionResultViewModel @Inject constructor(
.getPaySuccessMessage(PaySuccessMessageRequest(orderNumber)) .getPaySuccessMessage(PaySuccessMessageRequest(orderNumber))
.first() .first()
if (activePaymentOrderNumber != orderNumber || paymentHandled.get()) return
if (!response.isSucceeded) { if (!response.isSucceeded) {
LogUtils.e( LogUtils.e(
TAG, TAG,
"查询支付状态业务失败 - order_number: $orderNumber, code: ${response.code}, msg: ${response.message}" "查询支付状态业务失败 - order_number: $orderNumber, code: ${response.code}, msg: ${response.message}"
) )
recoverPayment(response.message?.takeIf { it.isNotBlank() } ?: "订单已失效,请重新选择照片")
return return
} }
if (activePaymentOrderNumber != orderNumber) return
val message = response.data val message = response.data
when (decideFacePayStatus(message?.orderStatus)) { when (decideFacePayStatus(message?.orderStatus)) {
FacePayStatusDecision.CONTINUE_POLLING -> { FacePayStatusDecision.CONTINUE_POLLING -> {
@@ -410,6 +483,7 @@ class FaceRecognitionResultViewModel @Inject constructor(
orderNumber = paymentData.orderNumber, orderNumber = paymentData.orderNumber,
captureType = paymentData.captureType, captureType = paymentData.captureType,
imageIds = paymentData.imageIds.orEmpty(), imageIds = paymentData.imageIds.orEmpty(),
purchaseMode = paymentData.purchaseMode,
source = "HTTP" source = "HTTP"
) )
} }
@@ -432,16 +506,17 @@ class FaceRecognitionResultViewModel @Inject constructor(
stopPayStatusPolling() stopPayStatusPolling()
activePaymentOrderNumber = null activePaymentOrderNumber = null
activeOrder = null
_payQrCodeUrl.value = null _payQrCodeUrl.value = null
_dismissPayDialogEvents.tryEmit(Unit) _dismissPayDialogEvents.tryEmit(Unit)
val statusText = orderStatusName?.takeIf { it.isNotBlank() } ?: when (orderStatus) { val statusText = orderStatusName?.takeIf { it.isNotBlank() } ?: when (orderStatus) {
40 -> "已取消" 40 -> "已取消"
50 -> "已退款" 50 -> "已退款"
60 -> "部分退款"
else -> "状态异常" else -> "状态异常"
} }
ToastUtils.show("订单$statusText") ToastUtils.show("订单$statusText")
retryQuote()
LogUtils.i(TAG, "订单进入终态,停止支付轮询 - status: $orderStatus, name: $statusText") LogUtils.i(TAG, "订单进入终态,停止支付轮询 - status: $orderStatus, name: $statusText")
} }
@@ -449,19 +524,13 @@ class FaceRecognitionResultViewModel @Inject constructor(
orderNumber: String?, orderNumber: String?,
captureType: Int?, captureType: Int?,
imageIds: List<Int>, imageIds: List<Int>,
purchaseMode: String?,
source: String source: String
) { ) {
val expectedOrderNumber = activePaymentOrderNumber val expectedOrderNumber = activePaymentOrderNumber
if ( val order = activeOrder ?: return
expectedOrderNumber.isNullOrEmpty() || if (!order.matches(orderNumber, captureType, imageIds, purchaseMode)) {
orderNumber != expectedOrderNumber || LogUtils.i(TAG, "忽略不匹配的支付成功消息 - source: $source, expected: $expectedOrderNumber")
captureType != 2 ||
imageIds.isEmpty()
) {
LogUtils.i(
TAG,
"忽略不匹配的支付成功消息 - source: $source, expected: $expectedOrderNumber, actual: $orderNumber, capture_type: $captureType, image_ids: $imageIds"
)
return return
} }
@@ -480,17 +549,26 @@ class FaceRecognitionResultViewModel @Inject constructor(
stopPayStatusPolling() stopPayStatusPolling()
_payQrCodeUrl.value = null _payQrCodeUrl.value = null
_dismissPayDialogEvents.tryEmit(Unit) _dismissPayDialogEvents.tryEmit(Unit)
navigateToPaySuccess(orderNumber, captureType, imageIds) navigateAfterPurchase(orderNumber, captureType, imageIds, order)
} }
/** /**
* 跳转到支付成功页面 * 按已确认订单的模式进入打印流程或独立电子版完成页
* @param orderNumber 订单号 * @param orderNumber 订单号
* @param captureType 抓拍类型 * @param captureType 抓拍类型
* @param imageIds 图片ID数组(从支付成功消息中获取) * @param imageIds 图片ID数组(从支付成功消息中获取)
*/ */
private fun navigateToPaySuccess(orderNumber: String?, captureType: Int?, imageIds: List<Int>) { private fun navigateAfterPurchase(orderNumber: String?, captureType: Int?, imageIds: List<Int>, order: FacePaymentOrder) {
viewModelScope.launch { viewModelScope.launch {
if (orderNumber.isNullOrBlank() || captureType == null) {
LogUtils.e(
TAG,
"支付成功消息订单信息不完整 - orderNumber: $orderNumber, captureType: $captureType"
)
ToastUtils.show("支付成功,但订单信息不完整")
return@launch
}
// 根据 imageIds 从 originalResults 中获取对应的 FaceSearchResult // 根据 imageIds 从 originalResults 中获取对应的 FaceSearchResult
val selectedResults = originalResults.filter { imageIds.contains(it.id) } val selectedResults = originalResults.filter { imageIds.contains(it.id) }
@@ -514,19 +592,17 @@ class FaceRecognitionResultViewModel @Inject constructor(
// 将 FileMapData 数组序列化为 JSON 数组字符串 // 将 FileMapData 数组序列化为 JSON 数组字符串
val gson = Gson() val gson = Gson()
val jsonArray = gson.toJson(fileMapDataList) val jsonArray = gson.toJson(fileMapDataList)
val encodedJson = java.net.URLEncoder.encode(jsonArray, "UTF-8")
// 构建路由参数
val orderNumberParam = orderNumber?.let { java.net.URLEncoder.encode(it, "UTF-8") } ?: ""
val captureTypeParam = captureType?.toString() ?: ""
// 创建 NavOptions,清除当前页面 // 创建 NavOptions,清除当前页面
val navOptions = androidx.navigation.NavOptions.Builder() val navOptions = androidx.navigation.NavOptions.Builder()
.setPopUpTo(com.yzx.kiosk.navigation.routes.AppRoutes.FACE_RECOGNITION_RESULT, inclusive = true) .setPopUpTo(AppRoutes.FACE_RECOGNITION_RESULT, inclusive = true)
.build() .build()
// 跳转到支付成功页面 val route = if (order.mode == PurchaseMode.ELECTRONIC) {
val route = "${com.yzx.kiosk.navigation.routes.AppRoutes.PAY_SUCCESS}?urls=$encodedJson&orderNumber=$orderNumberParam&captureType=$captureTypeParam" AppRoutes.buildElectronicCompletionRoute(orderNumber)
} else {
AppRoutes.buildPrintingRoute(jsonArray, orderNumber, captureType)
}
toPage(route, navOptions) toPage(route, navOptions)
} }
} }
@@ -1,10 +1,7 @@
package com.yzx.kiosk.ui.face.viewmodel package com.yzx.kiosk.ui.face.viewmodel
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import androidx.camera.core.ImageCapture import androidx.camera.core.ImageCapture
import androidx.exifinterface.media.ExifInterface
import androidx.camera.core.ImageCaptureException import androidx.camera.core.ImageCaptureException
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@@ -16,12 +13,15 @@ import com.yzx.kiosk.datastore.AppStoreDataSource
import com.google.gson.Gson import com.google.gson.Gson
import com.yzx.kiosk.network.model.response.FaceSearchResponse import com.yzx.kiosk.network.model.response.FaceSearchResponse
import com.yzx.kiosk.network.service.FaceSearchService import com.yzx.kiosk.network.service.FaceSearchService
import com.yzx.kiosk.network.service.buildFaceSearchUrl
import com.yzx.kiosk.App import com.yzx.kiosk.App
import com.yzx.kiosk.BuildConfig import com.yzx.kiosk.BuildConfig
import com.yzx.kiosk.network.interceptor.DebugNetworkLoggingInterceptor
import com.yzx.kiosk.navigation.AppNavigator import com.yzx.kiosk.navigation.AppNavigator
import com.yzx.kiosk.navigation.routes.AppRoutes import com.yzx.kiosk.navigation.routes.AppRoutes
import com.yzx.kiosk.ui.setting.navigation.AgreementRoutes import com.yzx.kiosk.ui.setting.navigation.AgreementRoutes
import com.yzx.kiosk.ui.face.resource.CaptureGate
import com.yzx.kiosk.ui.face.resource.FaceCaptureFileStore
import com.yzx.kiosk.ui.face.resource.FaceThumbnailDecoder
import com.yzx.kiosk.utils.LogUtils import com.yzx.kiosk.utils.LogUtils
import com.yzx.kiosk.utils.ToastUtils import com.yzx.kiosk.utils.ToastUtils
import java.net.URLEncoder import java.net.URLEncoder
@@ -36,15 +36,9 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.io.File import java.io.File
import java.text.SimpleDateFormat
import java.util.*
import javax.inject.Inject import javax.inject.Inject
enum class RecognitionStatus { enum class RecognitionStatus {
@@ -59,7 +53,10 @@ class FaceRecognitionViewModel @Inject constructor(
navigator: AppNavigator, navigator: AppNavigator,
appState: AppState, appState: AppState,
private val appStoreDataSource: AppStoreDataSource, private val appStoreDataSource: AppStoreDataSource,
private val localAudioPlayService: LocalAudioPlayService private val localAudioPlayService: LocalAudioPlayService,
private val faceSearchService: FaceSearchService,
private val faceCaptureFileStore: FaceCaptureFileStore,
private val faceThumbnailDecoder: FaceThumbnailDecoder,
) : BaseViewModel( ) : BaseViewModel(
navigator = navigator, navigator = navigator,
appState = appState appState = appState
@@ -91,9 +88,16 @@ class FaceRecognitionViewModel @Inject constructor(
private val _isRecognizing = MutableStateFlow(false) private val _isRecognizing = MutableStateFlow(false)
val isRecognizing: StateFlow<Boolean> = _isRecognizing.asStateFlow() val isRecognizing: StateFlow<Boolean> = _isRecognizing.asStateFlow()
private val _isCaptureInProgress = MutableStateFlow(false)
val isCaptureInProgress: StateFlow<Boolean> = _isCaptureInProgress.asStateFlow()
// 倒计时Job // 倒计时Job
private var countdownJob: kotlinx.coroutines.Job? = null private var countdownJob: kotlinx.coroutines.Job? = null
private var autoCaptureJob: kotlinx.coroutines.Job? = null private var autoCaptureJob: kotlinx.coroutines.Job? = null
private val captureGate = CaptureGate()
private var activeCaptureToken: Long? = null
private var activeCaptureFile: File? = null
private var isCleared = false
// ImageCapture实例 // ImageCapture实例
private var imageCapture: ImageCapture? = null private var imageCapture: ImageCapture? = null
@@ -109,6 +113,15 @@ class FaceRecognitionViewModel @Inject constructor(
this.imageCapture = imageCapture this.imageCapture = imageCapture
} }
fun clearImageCapture(imageCapture: ImageCapture) {
if (this.imageCapture === imageCapture) {
this.imageCapture = null
activeCaptureToken?.let { captureToken ->
finishCaptureFile(activeCaptureFile, captureToken)
}
}
}
/** /**
* 开始倒计时 * 开始倒计时
*/ */
@@ -120,7 +133,7 @@ class FaceRecognitionViewModel @Inject constructor(
_countdown.value = _countdown.value - 1 _countdown.value = _countdown.value - 1
} }
// 倒计时结束,自动返回首页 // 倒计时结束,自动返回首页
toPage(com.yzx.kiosk.navigation.routes.AppRoutes.HOME) closeAllExcept(AppRoutes.HOME)
} }
} }
@@ -154,6 +167,12 @@ class FaceRecognitionViewModel @Inject constructor(
* 手动拍照 * 手动拍照
*/ */
fun takePhoto() { fun takePhoto() {
if (_isRecognizing.value) return
val captureToken = captureGate.tryAcquire() ?: return
activeCaptureToken = captureToken
_isCaptureInProgress.value = true
// 如果正在自动拍照倒计时,先取消 // 如果正在自动拍照倒计时,先取消
if (_captureCountdown.value > 0) { if (_captureCountdown.value > 0) {
cancelAutoCapture() cancelAutoCapture()
@@ -167,80 +186,124 @@ class FaceRecognitionViewModel @Inject constructor(
} }
val capture = imageCapture ?: run { val capture = imageCapture ?: run {
releaseCaptureGate(captureToken)
ToastUtils.show("相机未准备好") ToastUtils.show("相机未准备好")
return return
} }
viewModelScope.launch { viewModelScope.launch {
// 创建输出文件 // 创建输出文件
val photoFile = withContext(Dispatchers.IO) { var photoFile: File? = null
File.createTempFile( try {
"IMG_${SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())}", photoFile = withContext(Dispatchers.IO) {
".jpg", faceCaptureFileStore.createCaptureFile()
App.instance.cacheDir
)
}
// 创建输出选项
val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build()
// 拍照
capture.takePicture(
outputOptions,
ContextCompat.getMainExecutor(App.instance),
object : ImageCapture.OnImageSavedCallback {
override fun onImageSaved(output: ImageCapture.OutputFileResults) {
// 加载图片为Bitmap,并处理EXIF方向
val bitmap = loadBitmapWithExifOrientation(photoFile.absolutePath)
_capturedImage.value = bitmap
// 直接调用搜索接口
searchFaceDirectly(photoFile)
}
override fun onError(exception: ImageCaptureException) {
LogUtils.e(TAG, "拍照失败: ${exception.message}")
ToastUtils.show("拍照失败")
}
} }
) if (
isCleared ||
imageCapture !== capture ||
activeCaptureToken != captureToken
) {
faceCaptureFileStore.delete(photoFile)
releaseCaptureGate(captureToken)
return@launch
}
activeCaptureFile = photoFile
val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build()
capture.takePicture(
outputOptions,
ContextCompat.getMainExecutor(App.instance),
object : ImageCapture.OnImageSavedCallback {
override fun onImageSaved(output: ImageCapture.OutputFileResults) {
if (
isCleared ||
imageCapture !== capture ||
activeCaptureToken != captureToken
) {
finishCaptureFile(photoFile, captureToken)
return
}
recognizeSavedPhoto(photoFile, captureToken)
}
override fun onError(exception: ImageCaptureException) {
LogUtils.e(TAG, "拍照失败: ${exception.message}")
finishCaptureFile(photoFile, captureToken)
if (!isCleared && imageCapture === capture) {
ToastUtils.show("拍照失败")
}
}
},
)
} catch (e: CancellationException) {
finishCaptureFile(photoFile, captureToken)
throw e
} catch (e: Exception) {
LogUtils.e(TAG, "创建人脸拍照文件失败: ${e.message}")
finishCaptureFile(photoFile, captureToken)
ToastUtils.show("拍照失败")
}
} }
} }
/** private fun recognizeSavedPhoto(photoFile: File, captureToken: Long) {
* 直接调用人脸搜索接口
*/
private fun searchFaceDirectly(photoFile: File) {
viewModelScope.launch { viewModelScope.launch {
_isRecognizing.value = true _isRecognizing.value = true
_recognitionStatus.value = RecognitionStatus.RECOGNIZING _recognitionStatus.value = RecognitionStatus.RECOGNIZING
// 播放识别中音频 releaseCaptureGate(captureToken)
localAudioPlayService.playByRoute("face_recognition_recognizing")
// 播放识别中音频
localAudioPlayService.playByRoute("face_recognition_recognizing") localAudioPlayService.playByRoute("face_recognition_recognizing")
try { try {
// 直接调用人脸识别接口 _capturedImage.value = withContext(Dispatchers.IO) {
faceThumbnailDecoder.decode(photoFile.absolutePath)
}
searchFace(photoFile) searchFace(photoFile)
} catch (e: CancellationException) { } catch (e: CancellationException) {
// 页面跳转或 ViewModel 销毁时的正常协程取消,不向用户报错
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
LogUtils.e(TAG, "处理失败: ${e.message}") LogUtils.e(TAG, "处理失败: ${e.message}")
ToastUtils.show("处理失败: ${e.message}") ToastUtils.show("处理失败: ${e.message}")
_isRecognizing.value = false _isRecognizing.value = false
_recognitionStatus.value = RecognitionStatus.FAILED _recognitionStatus.value = RecognitionStatus.FAILED
} finally {
finishCaptureFile(photoFile, captureToken)
} }
} }
} }
private fun releaseCaptureGate(captureToken: Long) {
if (captureGate.release(captureToken)) {
if (activeCaptureToken == captureToken) {
activeCaptureToken = null
}
_isCaptureInProgress.value = false
}
}
private fun finishCaptureFile(photoFile: File?, captureToken: Long) {
faceCaptureFileStore.delete(photoFile)
if (activeCaptureFile === photoFile) {
activeCaptureFile = null
}
releaseCaptureGate(captureToken)
}
/** /**
* 搜索人脸 * 搜索人脸
*/ */
private suspend fun searchFace(imageFile: File) = withContext(Dispatchers.IO) { private suspend fun searchFace(imageFile: File) = withContext(Dispatchers.IO) {
try { try {
val FACE_SEARCH_BASE_URL = if (appStoreDataSource.getUseLan()) appStoreDataSource.getBindBoxLanUrl() else appStoreDataSource.getBindBoxUrl() val faceSearchBaseUrl = if (appStoreDataSource.getUseLan()) {
val FACE_SEARCH_TOKEN = appStoreDataSource.getBindBoxApiToken() appStoreDataSource.getBindBoxLanUrl()
} else {
appStoreDataSource.getBindBoxUrl()
}
val faceSearchToken = appStoreDataSource.getBindBoxApiToken()
val requestUrl = buildFaceSearchUrl(
baseUrl = faceSearchBaseUrl,
deviceSn = appStoreDataSource.getBindBoxSn(),
)
// 创建MultipartBody // 创建MultipartBody
@@ -251,16 +314,10 @@ class FaceRecognitionViewModel @Inject constructor(
val thresholdValue = 0.45f val thresholdValue = 0.45f
val thresholdBody = thresholdValue.toString().toRequestBody("text/plain".toMediaType()) val thresholdBody = thresholdValue.toString().toRequestBody("text/plain".toMediaType())
// index_date 参数:当前日期,格式 YYYYMMDD
val dateFormat = SimpleDateFormat("yyyyMMdd", Locale.getDefault())
val indexDate = dateFormat.format(Date())
val indexDateBody = indexDate.toRequestBody("text/plain".toMediaType())
// Authorization header // Authorization header
val authorization = "Bearer $FACE_SEARCH_TOKEN" val authorization = "Bearer $faceSearchToken"
// 打印请求信息 // 打印请求信息
val requestUrl = "$FACE_SEARCH_BASE_URL/api/search"
LogUtils.d(TAG, "========== 人脸识别接口请求信息 ==========") LogUtils.d(TAG, "========== 人脸识别接口请求信息 ==========")
LogUtils.d(TAG, "URL: $requestUrl") LogUtils.d(TAG, "URL: $requestUrl")
LogUtils.d(TAG, "Headers:") LogUtils.d(TAG, "Headers:")
@@ -268,33 +325,15 @@ class FaceRecognitionViewModel @Inject constructor(
LogUtils.d(TAG, "Parameters:") LogUtils.d(TAG, "Parameters:")
LogUtils.d(TAG, "image: ${imageFile.name} (${imageFile.length()} bytes)") LogUtils.d(TAG, "image: ${imageFile.name} (${imageFile.length()} bytes)")
LogUtils.d(TAG, "threshold: $thresholdValue") LogUtils.d(TAG, "threshold: $thresholdValue")
LogUtils.d(TAG, "index_date: $indexDate")
LogUtils.d(TAG, "==========================================") LogUtils.d(TAG, "==========================================")
// Debug 环境记录请求与响应;图片等大文件只记录元数据,不读取文件内容
val client = OkHttpClient.Builder()
.apply {
if (BuildConfig.DEBUG) {
addInterceptor(DebugNetworkLoggingInterceptor("face-search"))
}
}
.build()
// 创建Retrofit实例
val retrofit = Retrofit.Builder()
.baseUrl("$FACE_SEARCH_BASE_URL/")
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
val service = retrofit.create(FaceSearchService::class.java)
// 调用接口 // 调用接口
val response = service.searchFace( val response = faceSearchService.searchFace(
sn = appStoreDataSource.getBindBoxSn(), url = requestUrl,
authorization = authorization, authorization = authorization,
image = imagePart, image = imagePart,
threshold = thresholdBody, threshold = thresholdBody,
// 不传日期,由 V3 服务端使用上海时区当天;人数及人脸大小也使用服务端默认值。
indexDate = null indexDate = null
) )
@@ -349,7 +388,7 @@ class FaceRecognitionViewModel @Inject constructor(
} else { } else {
// 识别成功 // 识别成功
_recognitionStatus.value = RecognitionStatus.SUCCESS _recognitionStatus.value = RecognitionStatus.SUCCESS
ToastUtils.show("找到 ${result.count} 张照片") LogUtils.i(TAG, "找到 ${result.count} 张照片")
// 播放识别成功音频(在跳转前播放) // 播放识别成功音频(在跳转前播放)
localAudioPlayService.playByRoute(com.yzx.kiosk.navigation.routes.AppRoutes.FACE_RECOGNITION_RESULT) localAudioPlayService.playByRoute(com.yzx.kiosk.navigation.routes.AppRoutes.FACE_RECOGNITION_RESULT)
@@ -369,82 +408,20 @@ class FaceRecognitionViewModel @Inject constructor(
} }
} }
/** private var isOpeningRecentPhotos = false
* 加载Bitmap并处理EXIF方向信息
* 解决不同设备拍照后图片方向不正确的问题
*/
private fun loadBitmapWithExifOrientation(imagePath: String): Bitmap? {
return try {
// 先读取EXIF方向信息
val exif = ExifInterface(imagePath)
val orientation = exif.getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL
)
// 加载原始Bitmap fun browseRecentPhotos() {
val bitmap = BitmapFactory.decodeFile(imagePath) ?: return null if (_recognitionStatus.value != RecognitionStatus.FAILED || isOpeningRecentPhotos) return
isOpeningRecentPhotos = true
// 根据EXIF方向旋转Bitmap countdownJob?.cancel()
val rotationDegrees = when (orientation) { autoCaptureJob?.cancel()
ExifInterface.ORIENTATION_ROTATE_90 -> 90f toPage(
ExifInterface.ORIENTATION_ROTATE_180 -> 180f AppRoutes.buildRecentPhotosRoute(),
ExifInterface.ORIENTATION_ROTATE_270 -> 270f NavOptions.Builder()
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> { .setPopUpTo(AppRoutes.FACE_RECOGNITION, inclusive = true)
// 水平翻转 .setLaunchSingleTop(true)
val matrix = Matrix() .build(),
matrix.setScale(-1f, 1f) )
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}
ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
// 垂直翻转
val matrix = Matrix()
matrix.setScale(1f, -1f)
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}
ExifInterface.ORIENTATION_TRANSPOSE -> {
// 转置(旋转90度+水平翻转)
val matrix = Matrix()
matrix.setRotate(90f)
matrix.postScale(-1f, 1f)
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}
ExifInterface.ORIENTATION_TRANSVERSE -> {
// 横向(旋转270度+水平翻转)
val matrix = Matrix()
matrix.setRotate(270f)
matrix.postScale(-1f, 1f)
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}
else -> 0f // 不需要旋转
}
// 如果需要旋转
if (rotationDegrees != 0f) {
val matrix = Matrix()
matrix.postRotate(rotationDegrees)
val rotatedBitmap = Bitmap.createBitmap(
bitmap,
0,
0,
bitmap.width,
bitmap.height,
matrix,
true
)
// 回收原始bitmap
if (rotatedBitmap != bitmap) {
bitmap.recycle()
}
rotatedBitmap
} else {
bitmap
}
} catch (e: Exception) {
LogUtils.e(TAG, "加载图片并处理EXIF方向失败: ${e.message}")
// 如果处理失败,返回原始Bitmap
BitmapFactory.decodeFile(imagePath)
}
} }
fun privatePolicy() { fun privatePolicy() {
@@ -476,9 +453,15 @@ class FaceRecognitionViewModel @Inject constructor(
} }
override fun onCleared() { override fun onCleared() {
super.onCleared() isCleared = true
countdownJob?.cancel() countdownJob?.cancel()
autoCaptureJob?.cancel() autoCaptureJob?.cancel()
imageCapture = null
_capturedImage.value = null
faceCaptureFileStore.delete(activeCaptureFile)
activeCaptureFile = null
activeCaptureToken?.let(::releaseCaptureGate)
super.onCleared()
} }
} }
@@ -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 @Composable
private fun PosterLiveStreamLayer( private fun PosterLiveStreamLayer(
url: String, url: String,
@@ -1,338 +0,0 @@
package com.yzx.kiosk.ui.upload.view
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
import androidx.compose.foundation.lazy.staggeredgrid.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.Easing
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import com.yzx.kiosk.R
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage
import com.yzx.kiosk.component.appbar.AppTitleBarNoStatusBarPadding
import com.yzx.kiosk.component.appbar.FaceBarNoStatusBarPadding
import com.yzx.kiosk.component.scaffold.AppScaffold
import com.yzx.kiosk.ui.common.view.FullScreenMode
import com.yzx.kiosk.ui.upload.viewmodel.PaySuccessViewModel
/** 与心跳动画 target一致;槽位高度 = 72.dp * 该值,避免放大时盖住下方区域 */
private const val PRINT_BTN_HEARTBEAT_MAX_SCALE = 1.09f
private fun isValidRemoteImageUrl(url: String): Boolean {
val t = url.trim()
if (t.isEmpty()) return false
return t.startsWith("http://", ignoreCase = true) ||
t.startsWith("https://", ignoreCase = true)
}
/** 按钮左右缩进,宽度变短;同等缩放比例下横向多出的像素更少,不易贴边/溢出 */
private val PRINT_BTN_HORIZONTAL_INSET = 20.dp
/**
* 心跳 `tween` 的缓动曲线(两头「略缓」强弱可在这里换):
* - [FastOutSlowInEasing]:Material 默认,起止略慢、中间略快
* - LinearEasing:匀速,无额外缓急(需自行 `import …LinearEasing` 并赋值)
* - [CubicBezierEasing]:自定义贝塞尔;(x1,y1) 靠近起点、(x2,y2) 靠近终点,数值越大往往起止越「肉」、需边调边看
*/
private val PRINT_BTN_HEARTBEAT_EASING: Easing = LinearEasing
// private val PRINT_BTN_HEARTBEAT_EASING = CubicBezierEasing(0.45f, 0f, 0.55f, 1f) // 示例:两头再柔和一点
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PaySuccessScreen(
photoUrls: List<String>,
orderNumber: String = "",
captureType: Int? = null,
viewModel: PaySuccessViewModel = hiltViewModel()
) {
val photoList by viewModel.photoList.collectAsState()
val selectedPhotoIds by viewModel.selectedPhotoIds.collectAsState()
val buttonPrintUrl by viewModel.buttonPrintUrl.collectAsState()
val printBtnRemoteUrl = buttonPrintUrl.trim()
val useRemotePrintButton = isValidRemoteImageUrl(printBtnRemoteUrl)
// 初始化照片列表和订单信息
LaunchedEffect(photoUrls, orderNumber, captureType) {
viewModel.initPhotoList(photoUrls, orderNumber, captureType)
}
val selectedCount = selectedPhotoIds.size
val isAllSelected = photoList.isNotEmpty() && selectedPhotoIds.size == photoList.size
FullScreenMode()
AppScaffold(
topBar = {
Column {
AppTitleBarNoStatusBarPadding(
backgroundColor = Color(0xFF4CAF50), // 绿色
title = "支付成功",
isWhite = true,
isShowBackIcon = true,
onBackClick = { viewModel.navigateBack() },
isShowButtonLine = false
)
FaceBarNoStatusBarPadding(
title = "为保护您的隐私与权益,系统不保存人脸照片。素材将在 24 小时后自动清理"
)
}
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.background(Color.White)
) {
// 打印选中照片按钮:心跳缩放走 graphicsLayer;外层槽高为 72dp×最大缩放,预留上下空间,不压盖下方
val heartbeatTransition = rememberInfiniteTransition(label = "printBtnHeartbeat")
val pulseScale by heartbeatTransition.animateFloat(
initialValue = 1f,
targetValue = PRINT_BTN_HEARTBEAT_MAX_SCALE,
animationSpec = infiniteRepeatable(
animation = tween(600, easing = PRINT_BTN_HEARTBEAT_EASING),
repeatMode = RepeatMode.Reverse
),
label = "pulseScale"
)
val drawScale = if (selectedCount > 0) pulseScale else 1f
Box(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp)
.height(72.dp * PRINT_BTN_HEARTBEAT_MAX_SCALE),
contentAlignment = Alignment.Center
) {
Button(
onClick = { viewModel.onPrintClick() },
modifier = Modifier
.padding(horizontal = PRINT_BTN_HORIZONTAL_INSET)
.fillMaxWidth()
.height(72.dp)
.graphicsLayer {
scaleX = drawScale
scaleY = drawScale
transformOrigin = TransformOrigin.Center
},
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF0073FF)),
enabled = selectedCount > 0
) {
// Row(
// modifier = Modifier.fillMaxWidth(),
// horizontalArrangement = Arrangement.Center,
// verticalAlignment = Alignment.CenterVertically
// ) {
// Text(
// text = "选中照片点击打印",
// fontSize = 24.sp,
// color = Color.White,
// fontWeight = FontWeight.Bold
// )
// Spacer(modifier = Modifier.width(6.dp))
if (useRemotePrintButton) {
AsyncImage(
model = printBtnRemoteUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit,
placeholder = painterResource(id = R.mipmap.btn_pay_success),
error = painterResource(id = R.mipmap.btn_pay_success)
)
} else {
Icon(
painter = painterResource(id = R.mipmap.btn_pay_success),
contentDescription = null,
tint = Color.Unspecified,
modifier = Modifier.fillMaxSize()
)
}
// }
}
}
// 全选行
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier.clickable { viewModel.toggleSelectAll() },
verticalAlignment = Alignment.CenterVertically
) {
// 复选框
Box(
modifier = Modifier
.size(24.dp)
.clip(RoundedCornerShape(6.dp))
.background(if (isAllSelected) Color(0xFF2196F3) else Color.White)
.border(
width = 2.dp,
color = if (isAllSelected) Color(0xFF2196F3) else Color(0xFFCCCCCC),
shape = RoundedCornerShape(6.dp)
),
contentAlignment = Alignment.Center
) {
if (isAllSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(24.dp)
)
}
}
Spacer(modifier = Modifier.width(18.dp))
Text(
text = "全选",
fontSize = 24.sp,
color = Color.Black
)
Text(
text = "(共${photoList.size}张)",
fontSize = 24.sp,
color = Color.Black
)
}
// 已选择数量
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "已选择",
fontSize = 24.sp,
color = Color.Black
)
Text(
modifier = Modifier.padding(top = 4.dp),
text = selectedCount.toString(),
fontSize = 28.sp,
fontWeight = FontWeight.Medium,
color = Color(0xFF2196F3)
)
Text(
text = "张",
fontSize = 24.sp,
color = Color.Black
)
}
}
Spacer(modifier = Modifier.height(18.dp))
// 图片瀑布流列表
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(2),
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
horizontalArrangement = Arrangement.spacedBy(18.dp),
verticalItemSpacing = 18.dp
) {
itemsIndexed(photoList) { index, photo ->
val isSelected = selectedPhotoIds.contains(photo.id)
PaySuccessPhotoItem(
photoUrl = photo.url,
aspectRatio = photo.aspectRatio,
isSelected = isSelected,
onToggleSelection = { viewModel.togglePhotoSelection(photo.id) }
)
}
}
}
}
}
@Composable
fun PaySuccessPhotoItem(
photoUrl: String,
aspectRatio: Float,
isSelected: Boolean,
onToggleSelection: () -> Unit
) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(aspectRatio) // 根据原比例计算高度
.clip(RoundedCornerShape(18.dp))
.clickable { onToggleSelection() }
) {
// 图片 - 按原比例显示
AsyncImage(
model = photoUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit
)
// 选中遮罩
Box(
modifier = Modifier
.fillMaxSize()
.background(
if (isSelected) Color.Black.copy(alpha = 0.3f) else Color.Transparent
)
)
// 右上角复选框
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(18.dp)
.size(24.dp)
.clip(RoundedCornerShape(6.dp))
.background(if (isSelected) Color(0xFF2196F3) else Color.Black.copy(alpha = 0.5f))
.border(
width = 2.dp,
color = if (isSelected) Color(0xFF2196F3) else Color(0xFF666666),
shape = RoundedCornerShape(6.dp)
),
contentAlignment = Alignment.Center
) {
if (isSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(24.dp)
)
}
}
}
}
@@ -1,5 +1,6 @@
package com.yzx.kiosk.ui.upload.view package com.yzx.kiosk.ui.upload.view
import com.yzx.kiosk.utils.formatPriceDisplay
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -150,7 +151,7 @@ fun PhotoSelectScreen(
} }
Spacer(modifier = Modifier.height(18.dp)) Spacer(modifier = Modifier.height(18.dp))
Text( Text(
text = "打印${pricePerPhoto}元/张", text = "打印${formatPriceDisplay(pricePerPhoto)}元/张",
fontSize = 21.sp, fontSize = 21.sp,
color = Color(0xFF000000) color = Color(0xFF000000)
) )
@@ -170,7 +171,7 @@ fun PhotoSelectScreen(
enabled = selectedCount > 0 enabled = selectedCount > 0
) { ) {
Text( Text(
text = if (selectedCount > 0) "支付${totalPrice}元" else "支付0元", text = if (selectedCount > 0) "支付${formatPriceDisplay(totalPrice)}元" else "支付0元",
fontSize = 24.sp, fontSize = 24.sp,
color = Color.White, color = Color.White,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
@@ -275,16 +276,10 @@ fun PhotoSelectScreen(
// 支付弹框 // 支付弹框
if (showPayDialog && selectedCount > 0 && payQrCodeUrl != null) { if (showPayDialog && selectedCount > 0 && payQrCodeUrl != null) {
val selectedUrls = photoList.filter { selectedPhotoIds.contains(it.id) }.map { it.url }
WechatPayDialog( WechatPayDialog(
qrCodeUrl = payQrCodeUrl!!, qrCodeUrl = payQrCodeUrl!!,
totalPrice = if (totalPrice != "--") totalPrice else "0", totalPrice = if (totalPrice != "--") totalPrice else "0",
onDismiss = { showPayDialog = false }, onDismiss = { showPayDialog = false }
onPaySuccess = {
showPayDialog = false
// 支付成功,等待 WebSocket 消息(code=5)触发跳转到支付成功页面
// 跳转逻辑在 PhotoSelectViewModel 的 navigateToPaySuccess 方法中处理
}
) )
} }
} }
@@ -453,8 +448,7 @@ fun PhotoItem(
fun WechatPayDialog( fun WechatPayDialog(
qrCodeUrl: String, qrCodeUrl: String,
totalPrice: String, totalPrice: String,
onDismiss: () -> Unit, onDismiss: () -> Unit
onPaySuccess: () -> Unit
) { ) {
// 使用接口返回的URL生成支付二维码 // 使用接口返回的URL生成支付二维码
val qrCodeBitmap = remember(qrCodeUrl) { val qrCodeBitmap = remember(qrCodeUrl) {
@@ -1,5 +1,6 @@
package com.yzx.kiosk.ui.upload.view package com.yzx.kiosk.ui.upload.view
import com.yzx.kiosk.utils.formatPriceDisplay
import android.graphics.Bitmap import android.graphics.Bitmap
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -259,7 +260,7 @@ fun UploadPhotoScreen(
// 打印价格 // 打印价格
ServiceInfoRow( ServiceInfoRow(
label = "打印价格", label = "打印价格",
value = printPrice, value = formatPriceDisplay(printPrice),
valueColor = Color(0xFFEF4444) valueColor = Color(0xFFEF4444)
) )
@@ -1,273 +0,0 @@
package com.yzx.kiosk.ui.upload.viewmodel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavOptions
import coil.ImageLoader
import coil.request.ImageRequest
import coil.size.Size
import com.yzx.kiosk.App
import com.yzx.kiosk.base.BaseViewModel
import com.yzx.kiosk.datastore.AppState
import com.yzx.kiosk.navigation.AppNavigator
import com.yzx.kiosk.navigation.routes.AppRoutes
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.yzx.kiosk.datastore.AppStoreDataSource
import com.yzx.kiosk.utils.LogUtils
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
@HiltViewModel
class PaySuccessViewModel @Inject constructor(
navigator: AppNavigator,
private val appStoreDataSource: AppStoreDataSource,
appState: AppState
) : BaseViewModel(
navigator = navigator,
appState = appState
) {
// 图片列表(包含宽高比)
private val _photoList = MutableStateFlow<List<PhotoData>>(emptyList())
val photoList: StateFlow<List<PhotoData>> = _photoList.asStateFlow()
// 已选中的图片ID
private val _selectedPhotoIds = MutableStateFlow<Set<Int>>(emptySet())
val selectedPhotoIds: StateFlow<Set<Int>> = _selectedPhotoIds.asStateFlow()
// 订单号
private val _orderNumber = MutableStateFlow<String>("")
val orderNumber: StateFlow<String> = _orderNumber.asStateFlow()
// 抓拍类型
private val _captureType = MutableStateFlow<Int?>(null)
val captureType: StateFlow<Int?> = _captureType.asStateFlow()
/** 设备配置下发的打印按钮图 URL(可能为空) */
private val _buttonPrintUrl = MutableStateFlow("")
val buttonPrintUrl: StateFlow<String> = _buttonPrintUrl.asStateFlow()
init {
_buttonPrintUrl.value = appStoreDataSource.getButtonPrintUrl()
}
/**
* 初始化图片列表,默认全选
* @param photoUrls 图片URL列表(JSON 格式或旧格式)
* @param orderNumber 订单号
* @param captureType 抓拍类型
* urls 格式:
* - JSON 格式(新):FileMapData 数组的 JSON 字符串
* - id:url 格式(兼容):id:url,id:url
* - url 格式(兼容):url,url
*/
fun initPhotoList(photoUrls: List<String>, orderNumber: String = "", captureType: Int? = null) {
// 保存订单信息
_orderNumber.value = orderNumber
_captureType.value = captureType
LogUtils.d("PaySuccessViewModel", "初始化 - orderNumber: $orderNumber, captureType: $captureType")
if (photoUrls.isEmpty()) {
_photoList.value = emptyList()
_selectedPhotoIds.value = emptySet()
return
}
viewModelScope.launch {
var initialPhotos: List<PhotoData>
// 尝试解析为 JSON 格式(新格式:file_map 数组的 JSON 字符串)
if (photoUrls.size == 1 && photoUrls[0].startsWith("[")) {
// JSON 数组格式:将 file_map 列表序列化后的 JSON 数组字符串反序列化为 FileMapData 对象数组
try {
val jsonString = java.net.URLDecoder.decode(photoUrls[0], "UTF-8")
LogUtils.d("PaySuccessViewModel", "接收到 JSON 数组字符串: $jsonString")
// 使用 Gson 将 JSON 数组反序列化为 FileMapData 对象数组
val type = object : TypeToken<List<FileMapData>>() {}.type
val fileMapList: List<FileMapData> = Gson().fromJson(jsonString, type) ?: emptyList()
LogUtils.d("PaySuccessViewModel", "反序列化成功,FileMapData 对象数量: ${fileMapList.size}")
// 将 FileMapData 对象数组转换为 PhotoData 列表
initialPhotos = fileMapList.mapNotNull { fileMap ->
val thumbnailUrl = if (appStoreDataSource.getUseLan()) fileMap.thumbnailLanUrl else fileMap.thumbnailOssUrl
if (thumbnailUrl.isNullOrEmpty()) {
LogUtils.i("PaySuccessViewModel", "FileMapData id=${fileMap.id} 的 thumbnailOssUrl 为空,跳过")
null
} else {
PhotoData(
id = fileMap.id,
url = thumbnailUrl.trim(),
aspectRatio = 1f,
fileMapData = fileMap // 保存完整的 FileMapData 对象,供后续使用
)
}
}
LogUtils.d("PaySuccessViewModel", "成功创建 PhotoData 列表,数量: ${initialPhotos.size}")
} catch (e: Exception) {
LogUtils.e("PaySuccessViewModel", "解析 JSON 格式失败: ${e.message}")
e.printStackTrace()
initialPhotos = emptyList()
}
} else {
// 兼容旧格式:id:url 或 url
initialPhotos = photoUrls.mapNotNull { urlStr ->
val trimmed = urlStr.trim()
if (trimmed.contains(":")) {
// id:url 格式
val parts = trimmed.split(":", limit = 2)
if (parts.size == 2) {
val id = parts[0].toIntOrNull()
val url = parts[1]
if (id != null && url.isNotEmpty()) {
PhotoData(
id = id,
url = url,
aspectRatio = 1f
)
} else null
} else null
} else {
// 只有 url,使用 hashCode 作为临时 id(不推荐,但兼容)
PhotoData(
id = trimmed.hashCode(),
url = trimmed,
aspectRatio = 1f
)
}
}
}
if (initialPhotos.isEmpty()) {
_photoList.value = emptyList()
_selectedPhotoIds.value = emptySet()
return@launch
}
_photoList.value = initialPhotos
// 默认全选(使用 id)
_selectedPhotoIds.value = initialPhotos.map { it.id }.toSet()
// 并发获取所有图片的真实尺寸并更新宽高比
val updatedPhotos = initialPhotos.mapIndexed { index, photo ->
async(Dispatchers.IO) {
try {
val aspectRatio = getImageAspectRatio(photo.url)
// 确保 aspectRatio 是有效值
val validAspectRatio = if (aspectRatio > 0 && aspectRatio.isFinite()) {
aspectRatio
} else {
1f
}
photo.copy(aspectRatio = validAspectRatio)
} catch (e: Exception) {
LogUtils.e("PaySuccessViewModel", "获取图片 ${photo.url} 尺寸失败: ${e.message}")
// 获取失败,保持默认值
photo
}
}
}.awaitAll()
_photoList.value = updatedPhotos
}
}
/**
* 获取图片的宽高比
*/
private suspend fun getImageAspectRatio(imageUrl: String): Float = withContext(Dispatchers.IO) {
try {
val imageLoader = ImageLoader(App.instance)
val request = ImageRequest.Builder(App.instance)
.data(imageUrl)
.size(Size.ORIGINAL)
.build()
val drawable = imageLoader.execute(request).drawable
if (drawable != null) {
val width = drawable.intrinsicWidth
val height = drawable.intrinsicHeight
if (width > 0 && height > 0) {
return@withContext width.toFloat() / height.toFloat()
}
}
} catch (e: Exception) {
// 获取失败,使用默认值
}
return@withContext 1f // 默认1:1
}
/**
* 切换单张图片选中状态
*/
fun togglePhotoSelection(id: Int) {
val current = _selectedPhotoIds.value.toMutableSet()
if (current.contains(id)) {
current.remove(id)
} else {
current.add(id)
}
_selectedPhotoIds.value = current
}
/**
* 切换全选状态
*/
fun toggleSelectAll() {
val allIds = _photoList.value.map { it.id }.toSet()
val newSelected = if (_selectedPhotoIds.value.size == allIds.size) {
// 当前全选,取消全选
emptySet()
} else {
// 全选
allIds
}
_selectedPhotoIds.value = newSelected
}
/**
* 点击打印
*/
fun onPrintClick() {
val selectedIds = _selectedPhotoIds.value.toList()
if (selectedIds.isEmpty()) return
// 获取选中的图片的完整 FileMapData 列表
val selectedFileMapData = _photoList.value
.filter { selectedIds.contains(it.id) }
.mapNotNull { it.fileMapData }
if (selectedFileMapData.isEmpty()) {
LogUtils.e("PaySuccessViewModel", "选中的图片没有 FileMapData,无法打印")
return
}
// 将 FileMapData 数组序列化为 JSON 数组字符串
val gson = Gson()
val jsonArray = gson.toJson(selectedFileMapData)
val encodedJson = java.net.URLEncoder.encode(jsonArray, "UTF-8")
// 构建路由参数
val orderNumberParam = _orderNumber.value.let {
if (it.isNotEmpty()) java.net.URLEncoder.encode(it, "UTF-8") else ""
}
val captureTypeParam = _captureType.value?.toString() ?: ""
// 创建 NavOptions,清除回退栈(清除到支付成功页面,包括它自己)
val navOptions = NavOptions.Builder()
.setPopUpTo(AppRoutes.PAY_SUCCESS, inclusive = true)
.build()
// 跳转到打印页面
val route = "${AppRoutes.PRINTING}?urls=$encodedJson&orderNumber=$orderNumberParam&captureType=$captureTypeParam"
toPage(route, navOptions)
}
}
@@ -8,6 +8,7 @@ import com.yzx.kiosk.App
import com.yzx.kiosk.base.BaseViewModel import com.yzx.kiosk.base.BaseViewModel
import com.yzx.kiosk.datastore.AppState import com.yzx.kiosk.datastore.AppState
import com.yzx.kiosk.navigation.AppNavigator import com.yzx.kiosk.navigation.AppNavigator
import com.yzx.kiosk.navigation.routes.AppRoutes
import com.yzx.kiosk.network.model.request.GetPayUrlRequest import com.yzx.kiosk.network.model.request.GetPayUrlRequest
import com.yzx.kiosk.network.model.request.VerifyResultRequest import com.yzx.kiosk.network.model.request.VerifyResultRequest
import com.yzx.kiosk.network.repository.NetWorkRepository import com.yzx.kiosk.network.repository.NetWorkRepository
@@ -32,6 +33,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import com.yzx.kiosk.utils.LogUtils import com.yzx.kiosk.utils.LogUtils
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject import javax.inject.Inject
/** /**
@@ -80,6 +82,8 @@ class PhotoSelectViewModel @Inject constructor(
navigator = navigator, navigator = navigator,
appState = appState appState = appState
) { ) {
private val paymentHandled = AtomicBoolean(false)
// 图片列表 // 图片列表
private val _photoList = MutableStateFlow<List<PhotoData>>(emptyList()) private val _photoList = MutableStateFlow<List<PhotoData>>(emptyList())
val photoList: StateFlow<List<PhotoData>> = _photoList.asStateFlow() val photoList: StateFlow<List<PhotoData>> = _photoList.asStateFlow()
@@ -126,9 +130,9 @@ class PhotoSelectViewModel @Inject constructor(
} }
} }
is UploadPhotoEvent.PaySuccess -> { is UploadPhotoEvent.PaySuccess -> {
// 支付成功,跳转到支付成功页面 // 支付成功,直接进入打印页面
LogUtils.d("PhotoSelectViewModel", "收到支付成功事件 - order_number: ${event.orderNumber}, capture_type: ${event.captureType}, fileMap: ${event.fileMap}") LogUtils.d("PhotoSelectViewModel", "收到支付成功事件 - order_number: ${event.orderNumber}, capture_type: ${event.captureType}, fileMap: ${event.fileMap}")
navigateToPaySuccess(event.orderNumber, event.captureType, event.fileMap) navigateToPrinting(event.orderNumber, event.captureType, event.fileMap)
} }
else -> { else -> {
// 其他事件不处理 // 其他事件不处理
@@ -472,6 +476,7 @@ class PhotoSelectViewModel @Inject constructor(
if (url.isNullOrEmpty()) { if (url.isNullOrEmpty()) {
ToastUtils.show("获取支付二维码失败") ToastUtils.show("获取支付二维码失败")
} else { } else {
paymentHandled.set(false)
_payQrCodeUrl.value = url _payQrCodeUrl.value = url
onSuccess(url) onSuccess(url)
} }
@@ -501,13 +506,26 @@ class PhotoSelectViewModel @Inject constructor(
} }
/** /**
* 跳转到支付成功页面 * 支付成功后直接跳转到打印页面
* @param orderNumber 订单号 * @param orderNumber 订单号
* @param captureType 抓拍类型 * @param captureType 抓拍类型
* @param imageIds 图片ID数组(从支付成功消息中获取) * @param fileMap 支付成功消息中确认的图片数据
*/ */
private fun navigateToPaySuccess(orderNumber: String?, captureType: Int?, fileMap: String) { private fun navigateToPrinting(orderNumber: String?, captureType: Int?, fileMap: String) {
viewModelScope.launch { viewModelScope.launch {
if (captureType != 1) {
LogUtils.i(
"PhotoSelectViewModel",
"忽略非手机上传流程的支付成功消息 - captureType: $captureType"
)
return@launch
}
if (orderNumber.isNullOrBlank()) {
LogUtils.e("PhotoSelectViewModel", "支付成功消息缺少订单号")
ToastUtils.show("支付成功,但订单信息不完整")
return@launch
}
if (fileMap.isBlank()) { if (fileMap.isBlank()) {
LogUtils.e("PhotoSelectViewModel", "未找到对应的 FileMapData,fileMap: $fileMap") LogUtils.e("PhotoSelectViewModel", "未找到对应的 FileMapData,fileMap: $fileMap")
@@ -515,18 +533,33 @@ class PhotoSelectViewModel @Inject constructor(
return@launch return@launch
} }
val paidFileMapList = try {
val type = object : TypeToken<List<FileMapData>>() {}.type
Gson().fromJson<List<FileMapData>>(fileMap, type).orEmpty()
} catch (e: Exception) {
LogUtils.e("PhotoSelectViewModel", "解析支付成功图片数据失败: ${e.message}")
emptyList()
}
if (paidFileMapList.isEmpty()) {
ToastUtils.show("支付成功,但未找到对应的图片数据")
return@launch
}
// 构建路由参数 if (!paymentHandled.compareAndSet(false, true)) {
val orderNumberParam = orderNumber?.let { java.net.URLEncoder.encode(it, "UTF-8") } ?: "" LogUtils.d("PhotoSelectViewModel", "支付成功已处理,忽略重复消息 - orderNumber: $orderNumber")
val captureTypeParam = captureType?.toString() ?: "" return@launch
}
// 创建 NavOptions,清除选择页面 // 创建 NavOptions,清除选择页面
val navOptions = androidx.navigation.NavOptions.Builder() val navOptions = androidx.navigation.NavOptions.Builder()
.setPopUpTo(com.yzx.kiosk.navigation.routes.AppRoutes.PHOTO_SELECT, inclusive = true) .setPopUpTo(AppRoutes.PHOTO_SELECT, inclusive = true)
.build() .build()
// 跳转到支付成功页面 val route = AppRoutes.buildPrintingRoute(
val route = "${com.yzx.kiosk.navigation.routes.AppRoutes.PAY_SUCCESS}?urls=$fileMap&orderNumber=$orderNumberParam&captureType=$captureTypeParam" photoData = Gson().toJson(paidFileMapList),
orderNumber = orderNumber,
captureType = captureType
)
toPage(route, navOptions) toPage(route, navOptions)
} }
} }
@@ -0,0 +1,8 @@
package com.yzx.kiosk.utils
private val decimalPrice = Regex("[0-9]+\\.[0-9]+")
/** Display only; also preserves units such as 元/张 and placeholders such as --. */
fun formatPriceDisplay(value: String): String = decimalPrice.replace(value) {
it.value.toBigDecimal().stripTrailingZeros().toPlainString()
}
@@ -0,0 +1,30 @@
package com.yzx.kiosk.websocket
import com.google.gson.JsonObject
/** New gateway: code -> data(type=5) -> data(order). Older gateways used a flat data object. */
internal fun parsePurchaseCompletedEvent(message: JsonObject): UploadPhotoEvent.PaySuccess? = runCatching {
if (message.get("code")?.asString != "5") return null
val envelope = message.getAsJsonObject("data") ?: return null
val order = if (envelope.has("data")) {
if (envelope.get("type")?.asInt != 5) return null
envelope.getAsJsonObject("data") ?: return null
} else {
if (envelope.has("type") && envelope.get("type")?.asInt != 5) return null
envelope
}
val number = order.get("order_number")?.takeUnless { it.isJsonNull }?.asString
?.takeIf { it.isNotBlank() } ?: return null
val captureType = order.get("capture_type")?.asInt ?: return null
if (captureType !in listOf(1, 2)) return null
val imageIds = order.get("image_id")?.takeUnless { it.isJsonNull }
?.asJsonArray?.map { it.asInt }.orEmpty()
if (captureType == 2 && imageIds.isEmpty()) return null
UploadPhotoEvent.PaySuccess(
orderNumber = number,
captureType = captureType,
imageIds = imageIds,
fileMap = order.get("file_map")?.takeUnless { it.isJsonNull }?.toString().orEmpty(),
purchaseMode = order.get("purchase_mode")?.takeUnless { it.isJsonNull }?.asString,
)
}.getOrNull()
@@ -390,46 +390,13 @@ class WebSocketService @Inject constructor(
} }
} }
"5" -> { "5" -> {
// 支付成功消息 val event = parsePurchaseCompletedEvent(jsonObject)
LogUtils.d(TAG, ">>> 收到支付成功消息 (code=5) <<<") if (event != null) {
try { CoroutineScope(Dispatchers.Main).launch {
val dataElement = jsonObject.get("data") _uploadPhotoEvents.emit(event)
if (dataElement != null && dataElement.isJsonObject) {
val dataObj = dataElement.asJsonObject
val orderNumber = dataObj.get("order_number")?.asString
val captureType = dataObj.get("capture_type")?.asInt
val imageIdList = mutableListOf<Int>()
if (dataObj.has("image_id")) {
val imageIdArray = dataObj.get("image_id")
if (imageIdArray != null && imageIdArray.isJsonArray) {
imageIdArray.asJsonArray.forEach { element ->
element.asInt.let { imageIdList.add(it) }
}
}
}
var fileMap = ""
if (dataObj.has("file_map")){
fileMap = dataObj.get("file_map").toString()
}
LogUtils.d(TAG, "支付成功 - order_number: $orderNumber, capture_type: $captureType, image_id: $imageIdList,fileMap:$fileMap")
// 发送支付成功事件
CoroutineScope(Dispatchers.Main).launch {
_uploadPhotoEvents.emit(
UploadPhotoEvent.PaySuccess(
orderNumber = orderNumber,
captureType = captureType,
imageIds = imageIdList,
fileMap = fileMap
)
)
}
} }
} catch (e: Exception) { } else {
LogUtils.e(TAG, "解析支付成功消息失败: ${e.message}") LogUtils.e(TAG, "忽略无效的购买完成消息")
} }
} }
"4" -> { "4" -> {
@@ -974,6 +941,7 @@ sealed class UploadPhotoEvent {
val orderNumber: String?, val orderNumber: String?,
val captureType: Int?, val captureType: Int?,
val fileMap:String, val fileMap:String,
val imageIds: List<Int> val imageIds: List<Int>,
val purchaseMode: String? = null
) : UploadPhotoEvent() ) : UploadPhotoEvent()
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 814 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 630 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 656 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 766 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 657 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 806 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 912 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 726 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 778 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 922 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 810 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 926 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 850 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 770 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 864 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 706 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 902 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 750 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 740 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 796 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 816 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 884 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 832 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 810 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 924 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

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