Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c93572fbc | ||
|
|
e6b671ef76 | ||
|
|
5fe95f9255 | ||
|
|
835d6ecd00 | ||
|
|
6d81f1a28b | ||
|
|
2231c38792 | ||
|
|
fa827fdd99 | ||
|
|
5f8586666c | ||
|
|
b1cdc1fe06 | ||
|
|
712f6154bc | ||
|
|
1d66bd18d1 | ||
|
|
63944db6fb | ||
|
|
fb5d3b71df | ||
|
|
cd25ada951 | ||
|
|
4c829f74a9 | ||
|
|
86bc9528dd | ||
|
|
1000488c04 | ||
|
|
342655cbb5 |
@@ -0,0 +1,2 @@
|
|||||||
|
/gradlew text eol=lf
|
||||||
|
/gradlew.bat text eol=crlf
|
||||||
@@ -130,6 +130,7 @@ APK 文件名会自动包含应用名、版本号、版本代码、构建类型
|
|||||||
| 构建类型 | 后端环境 | 调试 | 代码压缩 |
|
| 构建类型 | 后端环境 | 调试 | 代码压缩 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `debug` | 测试环境 `api-test.zhifly.cn` | 开启 | 关闭 |
|
| `debug` | 测试环境 `api-test.zhifly.cn` | 开启 | 关闭 |
|
||||||
|
| `releaseDebug` | 正式环境 `api.zhifly.cn` | 开启 | 关闭 |
|
||||||
| `release` | 正式环境 `api.zhifly.cn` | 关闭 | 关闭 |
|
| `release` | 正式环境 `api.zhifly.cn` | 关闭 | 关闭 |
|
||||||
|
|
||||||
请勿使用 Release 包连接测试设备随意操作,Release 会访问正式接口并连接正式 WebSocket。
|
请勿使用 Release 包连接测试设备随意操作,Release 会访问正式接口并连接正式 WebSocket。
|
||||||
|
|||||||
@@ -89,8 +89,8 @@ android {
|
|||||||
applicationId = "com.yzx.kiosk"
|
applicationId = "com.yzx.kiosk"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 21
|
versionCode = 24
|
||||||
versionName = "1.1.1"
|
versionName = "1.1.4"
|
||||||
//multiDexEnabled = true
|
//multiDexEnabled = true
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
@@ -161,6 +161,13 @@ android {
|
|||||||
"\"https://vipsky.oss-cn-shanghai.aliyuncs.com/cloud_driver\""
|
"\"https://vipsky.oss-cn-shanghai.aliyuncs.com/cloud_driver\""
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
create("releaseDebug") {
|
||||||
|
initWith(getByName("release"))
|
||||||
|
isDebuggable = true
|
||||||
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
matchingFallbacks += listOf("release", "debug")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
@@ -223,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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.yzx.kiosk.network.model.request
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
|
data class PaySuccessMessageRequest(
|
||||||
|
@SerializedName("order_number")
|
||||||
|
val orderNumber: String
|
||||||
|
)
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,15 @@ import com.google.gson.annotations.SerializedName
|
|||||||
|
|
||||||
data class GetPayUrlResponse(
|
data class GetPayUrlResponse(
|
||||||
@SerializedName("url")
|
@SerializedName("url")
|
||||||
val url: String?
|
val url: String?,
|
||||||
|
|
||||||
|
@SerializedName("order_number")
|
||||||
|
val orderNumber: String?,
|
||||||
|
@SerializedName("purchase_mode")
|
||||||
|
val purchaseMode: String? = null,
|
||||||
|
@SerializedName("amount")
|
||||||
|
val amount: String? = null,
|
||||||
|
@SerializedName("order_status")
|
||||||
|
val orderStatus: Int? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.yzx.kiosk.network.model.response
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
|
data class PaySuccessMessageResponse(
|
||||||
|
@SerializedName("order_status")
|
||||||
|
val orderStatus: Int?,
|
||||||
|
|
||||||
|
@SerializedName("order_status_name")
|
||||||
|
val orderStatusName: String?,
|
||||||
|
|
||||||
|
@SerializedName("sn")
|
||||||
|
val sn: String?,
|
||||||
|
|
||||||
|
@SerializedName("type")
|
||||||
|
val type: Int?,
|
||||||
|
|
||||||
|
@SerializedName("data")
|
||||||
|
val data: PaySuccessMessageData?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PaySuccessMessageData(
|
||||||
|
@SerializedName("order_number")
|
||||||
|
val orderNumber: String?,
|
||||||
|
|
||||||
|
@SerializedName("capture_type")
|
||||||
|
val captureType: Int?,
|
||||||
|
|
||||||
|
@SerializedName("image_id")
|
||||||
|
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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.yzx.kiosk.network.repository
|
package com.yzx.kiosk.network.repository
|
||||||
|
|
||||||
|
import com.google.gson.Gson
|
||||||
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.PrintCompleteRequest
|
import com.yzx.kiosk.network.model.request.PrintCompleteRequest
|
||||||
import com.yzx.kiosk.network.model.request.PrintNotifyRequest
|
import com.yzx.kiosk.network.model.request.PrintNotifyRequest
|
||||||
import com.yzx.kiosk.network.model.request.SaveAlbumUrlRequest
|
import com.yzx.kiosk.network.model.request.SaveAlbumUrlRequest
|
||||||
@@ -13,6 +15,7 @@ import com.yzx.kiosk.network.model.response.GetPayUrlResponse
|
|||||||
import com.yzx.kiosk.network.model.response.GetPrintInfoResponse
|
import com.yzx.kiosk.network.model.response.GetPrintInfoResponse
|
||||||
import com.yzx.kiosk.network.model.response.NetworkResponse
|
import com.yzx.kiosk.network.model.response.NetworkResponse
|
||||||
import com.yzx.kiosk.network.model.response.OscarLivesData
|
import com.yzx.kiosk.network.model.response.OscarLivesData
|
||||||
|
import com.yzx.kiosk.network.model.response.PaySuccessMessageResponse
|
||||||
import com.yzx.kiosk.network.model.response.SocketTokenResponse
|
import com.yzx.kiosk.network.model.response.SocketTokenResponse
|
||||||
import com.yzx.kiosk.network.model.response.VerifyResultResponse
|
import com.yzx.kiosk.network.model.response.VerifyResultResponse
|
||||||
import com.yzx.kiosk.network.model.response.VersionResponse
|
import com.yzx.kiosk.network.model.response.VersionResponse
|
||||||
@@ -27,6 +30,7 @@ import javax.inject.Inject
|
|||||||
class NetWorkRepository @Inject constructor(
|
class NetWorkRepository @Inject constructor(
|
||||||
private val netService: NetworkService,
|
private val netService: NetworkService,
|
||||||
private val uploadService: UploadService,
|
private val uploadService: UploadService,
|
||||||
|
private val gson: Gson,
|
||||||
) {
|
) {
|
||||||
// 基础方法示例,可根据需要添加
|
// 基础方法示例,可根据需要添加
|
||||||
// fun example(): Flow<NetworkResponse<Any>> = flow {
|
// fun example(): Flow<NetworkResponse<Any>> = flow {
|
||||||
@@ -82,6 +86,23 @@ class NetWorkRepository @Inject constructor(
|
|||||||
emit(netService.getPayUrl(request))
|
emit(netService.getPayUrl(request))
|
||||||
}.flowOn(Dispatchers.IO)
|
}.flowOn(Dispatchers.IO)
|
||||||
|
|
||||||
|
fun getPaySuccessMessage(
|
||||||
|
request: PaySuccessMessageRequest
|
||||||
|
): Flow<NetworkResponse<PaySuccessMessageResponse>> = flow {
|
||||||
|
val response = netService.getPaySuccessMessage(request)
|
||||||
|
val parsedData = response.data
|
||||||
|
?.takeIf { it.isJsonObject }
|
||||||
|
?.let { gson.fromJson(it, PaySuccessMessageResponse::class.java) }
|
||||||
|
|
||||||
|
emit(
|
||||||
|
NetworkResponse(
|
||||||
|
data = parsedData,
|
||||||
|
code = response.code,
|
||||||
|
message = response.message
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}.flowOn(Dispatchers.IO)
|
||||||
|
|
||||||
fun getSocketToken(): Flow<NetworkResponse<SocketTokenResponse>> = flow {
|
fun getSocketToken(): Flow<NetworkResponse<SocketTokenResponse>> = flow {
|
||||||
emit(netService.getSocketToken())
|
emit(netService.getSocketToken())
|
||||||
}.flowOn(Dispatchers.IO)
|
}.flowOn(Dispatchers.IO)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.yzx.kiosk.network.service
|
package com.yzx.kiosk.network.service
|
||||||
|
|
||||||
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.PrintCompleteRequest
|
import com.yzx.kiosk.network.model.request.PrintCompleteRequest
|
||||||
import com.yzx.kiosk.network.model.request.PrintNotifyRequest
|
import com.yzx.kiosk.network.model.request.PrintNotifyRequest
|
||||||
import com.yzx.kiosk.network.model.request.SaveAlbumUrlRequest
|
import com.yzx.kiosk.network.model.request.SaveAlbumUrlRequest
|
||||||
@@ -16,6 +17,7 @@ import com.yzx.kiosk.network.model.response.OscarLivesData
|
|||||||
import com.yzx.kiosk.network.model.response.SocketTokenResponse
|
import com.yzx.kiosk.network.model.response.SocketTokenResponse
|
||||||
import com.yzx.kiosk.network.model.response.VerifyResultResponse
|
import com.yzx.kiosk.network.model.response.VerifyResultResponse
|
||||||
import com.yzx.kiosk.network.model.response.VersionResponse
|
import com.yzx.kiosk.network.model.response.VersionResponse
|
||||||
|
import com.google.gson.JsonElement
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
@@ -69,6 +71,11 @@ interface NetworkService {
|
|||||||
@POST("/api/oscar/order/get-pay-url")
|
@POST("/api/oscar/order/get-pay-url")
|
||||||
suspend fun getPayUrl(@Body request: GetPayUrlRequest): NetworkResponse<GetPayUrlResponse>
|
suspend fun getPayUrl(@Body request: GetPayUrlRequest): NetworkResponse<GetPayUrlResponse>
|
||||||
|
|
||||||
|
@POST("/api/oscar/order/pay-success-message")
|
||||||
|
suspend fun getPaySuccessMessage(
|
||||||
|
@Body request: PaySuccessMessageRequest
|
||||||
|
): NetworkResponse<JsonElement>
|
||||||
|
|
||||||
@GET("/api/oscar/socket-token")
|
@GET("/api/oscar/socket-token")
|
||||||
suspend fun getSocketToken(): NetworkResponse<SocketTokenResponse>
|
suspend fun getSocketToken(): NetworkResponse<SocketTokenResponse>
|
||||||
|
|
||||||
|
|||||||
@@ -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,303 @@
|
|||||||
|
package com.yzx.kiosk.priter
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.io.IOException
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打印图片完整性校验。
|
||||||
|
*
|
||||||
|
* 网络图片必须先完整下载为字节数组,再经过容器、尺寸和软件 Bitmap 解码校验;
|
||||||
|
* 最终 BMP 必须通过文件头、尺寸、位深和精确文件长度校验后才允许送入打印 SDK。
|
||||||
|
*/
|
||||||
|
object PrintImageIntegrityValidator {
|
||||||
|
const val MAX_DOWNLOAD_BYTES: Long = 50L * 1024L * 1024L
|
||||||
|
|
||||||
|
private const val MIN_IMAGE_BYTES = 128
|
||||||
|
private const val MAX_IMAGE_DIMENSION = 30_000
|
||||||
|
private const val MAX_DECODED_PIXELS = 24_000_000L
|
||||||
|
private const val TARGET_LONG_EDGE = 1_840
|
||||||
|
private const val TARGET_SHORT_EDGE = 1_240
|
||||||
|
|
||||||
|
data class DecodedImage(
|
||||||
|
val bitmap: Bitmap,
|
||||||
|
val format: String,
|
||||||
|
val mimeType: String?,
|
||||||
|
val sourceWidth: Int,
|
||||||
|
val sourceHeight: Int,
|
||||||
|
val sampleSize: Int,
|
||||||
|
val sha256: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class BmpValidation(
|
||||||
|
val width: Int,
|
||||||
|
val height: Int,
|
||||||
|
val bitsPerPixel: Int,
|
||||||
|
val fileSize: Long,
|
||||||
|
val sha256: String
|
||||||
|
)
|
||||||
|
|
||||||
|
fun decodeDownloadedImage(bytes: ByteArray): DecodedImage {
|
||||||
|
if (bytes.size < MIN_IMAGE_BYTES) {
|
||||||
|
throw IOException("图片数据过小: ${bytes.size} bytes")
|
||||||
|
}
|
||||||
|
if (bytes.size.toLong() > MAX_DOWNLOAD_BYTES) {
|
||||||
|
throw IOException("图片数据超过上限: ${bytes.size} bytes")
|
||||||
|
}
|
||||||
|
|
||||||
|
val format = detectAndValidateContainer(bytes)
|
||||||
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||||
|
|
||||||
|
val sourceWidth = bounds.outWidth
|
||||||
|
val sourceHeight = bounds.outHeight
|
||||||
|
if (sourceWidth <= 0 || sourceHeight <= 0) {
|
||||||
|
throw IOException("无法解析图片尺寸,format=$format, mime=${bounds.outMimeType}")
|
||||||
|
}
|
||||||
|
if (sourceWidth > MAX_IMAGE_DIMENSION || sourceHeight > MAX_IMAGE_DIMENSION) {
|
||||||
|
throw IOException("图片尺寸异常: ${sourceWidth}x${sourceHeight}")
|
||||||
|
}
|
||||||
|
|
||||||
|
val sampleSize = calculateInSampleSize(sourceWidth, sourceHeight)
|
||||||
|
val decodeOptions = BitmapFactory.Options().apply {
|
||||||
|
inJustDecodeBounds = false
|
||||||
|
inPreferredConfig = Bitmap.Config.ARGB_8888
|
||||||
|
inSampleSize = sampleSize
|
||||||
|
inScaled = false
|
||||||
|
}
|
||||||
|
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, decodeOptions)
|
||||||
|
?: throw IOException("图片完整解码失败: ${sourceWidth}x${sourceHeight}, format=$format")
|
||||||
|
|
||||||
|
if (decoded.width <= 0 || decoded.height <= 0 || decoded.isRecycled) {
|
||||||
|
decoded.recycle()
|
||||||
|
throw IOException("解码后的 Bitmap 无效")
|
||||||
|
}
|
||||||
|
if (decoded.config == Bitmap.Config.HARDWARE) {
|
||||||
|
decoded.recycle()
|
||||||
|
throw IOException("打印图片意外解码为硬件 Bitmap")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主动读取像素,确保 Bitmap 的像素缓冲区可访问。
|
||||||
|
try {
|
||||||
|
decoded.getPixel(decoded.width / 2, decoded.height / 2)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
decoded.recycle()
|
||||||
|
throw IOException("Bitmap 像素缓冲区不可读", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return DecodedImage(
|
||||||
|
bitmap = decoded,
|
||||||
|
format = format,
|
||||||
|
mimeType = bounds.outMimeType,
|
||||||
|
sourceWidth = sourceWidth,
|
||||||
|
sourceHeight = sourceHeight,
|
||||||
|
sampleSize = sampleSize,
|
||||||
|
sha256 = sha256(bytes)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun validateBmp(file: File, expectedWidth: Int, expectedHeight: Int): BmpValidation {
|
||||||
|
if (!file.isFile || file.length() < 54L) {
|
||||||
|
throw IOException("BMP 文件不存在或过小: ${file.absolutePath}")
|
||||||
|
}
|
||||||
|
|
||||||
|
val header = ByteArray(54)
|
||||||
|
FileInputStream(file).use { input ->
|
||||||
|
var offset = 0
|
||||||
|
while (offset < header.size) {
|
||||||
|
val read = input.read(header, offset, header.size - offset)
|
||||||
|
if (read < 0) break
|
||||||
|
offset += read
|
||||||
|
}
|
||||||
|
if (offset != header.size) {
|
||||||
|
throw IOException("BMP 文件头不完整: $offset/${header.size}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (header[0] != 'B'.code.toByte() || header[1] != 'M'.code.toByte()) {
|
||||||
|
throw IOException("BMP 文件签名错误")
|
||||||
|
}
|
||||||
|
|
||||||
|
val declaredFileSize = uint32Le(header, 2)
|
||||||
|
val pixelOffset = uint32Le(header, 10)
|
||||||
|
val dibHeaderSize = uint32Le(header, 14)
|
||||||
|
val width = int32Le(header, 18)
|
||||||
|
val rawHeight = int32Le(header, 22)
|
||||||
|
val height = abs(rawHeight)
|
||||||
|
val planes = uint16Le(header, 26)
|
||||||
|
val bitsPerPixel = uint16Le(header, 28)
|
||||||
|
val compression = uint32Le(header, 30)
|
||||||
|
|
||||||
|
if (dibHeaderSize < 40L || pixelOffset < 54L) {
|
||||||
|
throw IOException("BMP 文件头结构异常")
|
||||||
|
}
|
||||||
|
if (width != expectedWidth || height != expectedHeight) {
|
||||||
|
throw IOException("BMP 尺寸不匹配: ${width}x${height}, expected=${expectedWidth}x${expectedHeight}")
|
||||||
|
}
|
||||||
|
if (planes != 1 || bitsPerPixel != 24 || compression != 0L) {
|
||||||
|
throw IOException("BMP 格式不受支持: planes=$planes, bpp=$bitsPerPixel, compression=$compression")
|
||||||
|
}
|
||||||
|
|
||||||
|
val rowStride = ((width.toLong() * 3L + 3L) / 4L) * 4L
|
||||||
|
val expectedFileSize = pixelOffset + rowStride * height.toLong()
|
||||||
|
val actualFileSize = file.length()
|
||||||
|
if (declaredFileSize != actualFileSize || expectedFileSize != actualFileSize) {
|
||||||
|
throw IOException(
|
||||||
|
"BMP 文件长度异常: declared=$declaredFileSize, expected=$expectedFileSize, actual=$actualFileSize"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
BitmapFactory.decodeFile(file.absolutePath, bounds)
|
||||||
|
if (bounds.outWidth != expectedWidth || abs(bounds.outHeight) != expectedHeight) {
|
||||||
|
throw IOException("BMP 无法重新解析: ${bounds.outWidth}x${bounds.outHeight}")
|
||||||
|
}
|
||||||
|
|
||||||
|
return BmpValidation(
|
||||||
|
width = width,
|
||||||
|
height = height,
|
||||||
|
bitsPerPixel = bitsPerPixel,
|
||||||
|
fileSize = actualFileSize,
|
||||||
|
sha256 = sha256(file)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateInSampleSize(width: Int, height: Int): Int {
|
||||||
|
val longEdge = max(width, height)
|
||||||
|
val shortEdge = min(width, height)
|
||||||
|
var sampleSize = 1
|
||||||
|
|
||||||
|
while (
|
||||||
|
longEdge / (sampleSize * 2) >= TARGET_LONG_EDGE &&
|
||||||
|
shortEdge / (sampleSize * 2) >= TARGET_SHORT_EDGE
|
||||||
|
) {
|
||||||
|
sampleSize *= 2
|
||||||
|
}
|
||||||
|
|
||||||
|
while (
|
||||||
|
width.toLong() / sampleSize * (height.toLong() / sampleSize) > MAX_DECODED_PIXELS
|
||||||
|
) {
|
||||||
|
sampleSize *= 2
|
||||||
|
}
|
||||||
|
return sampleSize
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun detectAndValidateContainer(bytes: ByteArray): String {
|
||||||
|
return when {
|
||||||
|
isJpeg(bytes) -> {
|
||||||
|
val searchStart = max(2, bytes.size - 64)
|
||||||
|
var hasEndMarker = false
|
||||||
|
for (index in bytes.size - 2 downTo searchStart) {
|
||||||
|
if (byteAt(bytes, index) == 0xFF && byteAt(bytes, index + 1) == 0xD9) {
|
||||||
|
hasEndMarker = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hasEndMarker) throw IOException("JPEG 缺少结束标记,文件可能被截断")
|
||||||
|
"JPEG"
|
||||||
|
}
|
||||||
|
|
||||||
|
isPng(bytes) -> {
|
||||||
|
if (
|
||||||
|
bytes.size < 20 ||
|
||||||
|
bytes[bytes.size - 8] != 'I'.code.toByte() ||
|
||||||
|
bytes[bytes.size - 7] != 'E'.code.toByte() ||
|
||||||
|
bytes[bytes.size - 6] != 'N'.code.toByte() ||
|
||||||
|
bytes[bytes.size - 5] != 'D'.code.toByte()
|
||||||
|
) {
|
||||||
|
throw IOException("PNG 缺少 IEND,文件可能被截断")
|
||||||
|
}
|
||||||
|
"PNG"
|
||||||
|
}
|
||||||
|
|
||||||
|
isWebp(bytes) -> {
|
||||||
|
val declaredSize = uint32Le(bytes, 4) + 8L
|
||||||
|
if (declaredSize != bytes.size.toLong()) {
|
||||||
|
throw IOException("WebP 文件长度异常: declared=$declaredSize, actual=${bytes.size}")
|
||||||
|
}
|
||||||
|
"WEBP"
|
||||||
|
}
|
||||||
|
|
||||||
|
isGif(bytes) -> {
|
||||||
|
if (byteAt(bytes, bytes.lastIndex) != 0x3B) {
|
||||||
|
throw IOException("GIF 缺少结束标记,文件可能被截断")
|
||||||
|
}
|
||||||
|
"GIF"
|
||||||
|
}
|
||||||
|
|
||||||
|
isBmp(bytes) -> {
|
||||||
|
val declaredSize = uint32Le(bytes, 2)
|
||||||
|
if (declaredSize != bytes.size.toLong()) {
|
||||||
|
throw IOException("BMP 文件长度异常: declared=$declaredSize, actual=${bytes.size}")
|
||||||
|
}
|
||||||
|
"BMP"
|
||||||
|
}
|
||||||
|
|
||||||
|
isIsoBaseMedia(bytes) -> "HEIF/AVIF"
|
||||||
|
else -> throw IOException("不支持或无法识别的图片格式")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isJpeg(bytes: ByteArray): Boolean =
|
||||||
|
bytes.size >= 4 && byteAt(bytes, 0) == 0xFF && byteAt(bytes, 1) == 0xD8
|
||||||
|
|
||||||
|
private fun isPng(bytes: ByteArray): Boolean =
|
||||||
|
bytes.size >= 8 &&
|
||||||
|
byteAt(bytes, 0) == 0x89 && bytes[1] == 'P'.code.toByte() &&
|
||||||
|
bytes[2] == 'N'.code.toByte() && bytes[3] == 'G'.code.toByte() &&
|
||||||
|
byteAt(bytes, 4) == 0x0D && byteAt(bytes, 5) == 0x0A &&
|
||||||
|
byteAt(bytes, 6) == 0x1A && byteAt(bytes, 7) == 0x0A
|
||||||
|
|
||||||
|
private fun isWebp(bytes: ByteArray): Boolean =
|
||||||
|
bytes.size >= 12 && ascii(bytes, 0, 4) == "RIFF" && ascii(bytes, 8, 4) == "WEBP"
|
||||||
|
|
||||||
|
private fun isGif(bytes: ByteArray): Boolean =
|
||||||
|
bytes.size >= 6 && (ascii(bytes, 0, 6) == "GIF87a" || ascii(bytes, 0, 6) == "GIF89a")
|
||||||
|
|
||||||
|
private fun isBmp(bytes: ByteArray): Boolean =
|
||||||
|
bytes.size >= 14 && bytes[0] == 'B'.code.toByte() && bytes[1] == 'M'.code.toByte()
|
||||||
|
|
||||||
|
private fun isIsoBaseMedia(bytes: ByteArray): Boolean =
|
||||||
|
bytes.size >= 12 && ascii(bytes, 4, 4) == "ftyp"
|
||||||
|
|
||||||
|
private fun ascii(bytes: ByteArray, offset: Int, length: Int): String =
|
||||||
|
bytes.copyOfRange(offset, offset + length).toString(Charsets.US_ASCII)
|
||||||
|
|
||||||
|
private fun byteAt(bytes: ByteArray, index: Int): Int = bytes[index].toInt() and 0xFF
|
||||||
|
|
||||||
|
private fun uint16Le(bytes: ByteArray, offset: Int): Int =
|
||||||
|
byteAt(bytes, offset) or (byteAt(bytes, offset + 1) shl 8)
|
||||||
|
|
||||||
|
private fun int32Le(bytes: ByteArray, offset: Int): Int =
|
||||||
|
byteAt(bytes, offset) or
|
||||||
|
(byteAt(bytes, offset + 1) shl 8) or
|
||||||
|
(byteAt(bytes, offset + 2) shl 16) or
|
||||||
|
(byteAt(bytes, offset + 3) shl 24)
|
||||||
|
|
||||||
|
private fun uint32Le(bytes: ByteArray, offset: Int): Long =
|
||||||
|
int32Le(bytes, offset).toLong() and 0xFFFF_FFFFL
|
||||||
|
|
||||||
|
private fun sha256(bytes: ByteArray): String =
|
||||||
|
MessageDigest.getInstance("SHA-256").digest(bytes).toHex()
|
||||||
|
|
||||||
|
private fun sha256(file: File): String {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
FileInputStream(file).use { input ->
|
||||||
|
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||||
|
while (true) {
|
||||||
|
val count = input.read(buffer)
|
||||||
|
if (count < 0) break
|
||||||
|
if (count > 0) digest.update(buffer, 0, count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return digest.digest().toHex()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ByteArray.toHex(): String = joinToString(separator = "") { byte -> "%02x".format(byte) }
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ import android.graphics.Bitmap
|
|||||||
import android.graphics.Canvas
|
import android.graphics.Canvas
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.graphics.Matrix
|
import android.graphics.Matrix
|
||||||
import android.graphics.Paint
|
|
||||||
import com.yzx.kiosk.App
|
import com.yzx.kiosk.App
|
||||||
import com.yzx.kiosk.datastore.AppStoreDataSource
|
import com.yzx.kiosk.datastore.AppStoreDataSource
|
||||||
import com.yzx.kiosk.utils.LogUtils
|
import com.yzx.kiosk.utils.LogUtils
|
||||||
@@ -23,8 +22,11 @@ import jp.co.dnpLib.print.PrintManager
|
|||||||
import jp.co.dnpLib.print.PrintQueue
|
import jp.co.dnpLib.print.PrintQueue
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
|
|
||||||
@@ -40,9 +42,15 @@ class PrinterService(
|
|||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "PrinterService"
|
private const val TAG = "PrinterService"
|
||||||
|
|
||||||
|
// DNP BmpUtil 在 300 DPI 下会将 RX1 输入画布编码为 1844x1240 BMP。
|
||||||
|
// 1840x1240 仍是上层排版画布尺寸,不应用它校验 SDK 的最终文件头。
|
||||||
|
private const val RX1_ENCODED_BMP_WIDTH_300_DPI = 1844
|
||||||
|
private const val RX1_ENCODED_BMP_HEIGHT_300_DPI = 1240
|
||||||
}
|
}
|
||||||
|
|
||||||
private var mDnpPhotoPrint: DNPPhotoPrint? = null
|
private var mDnpPhotoPrint: DNPPhotoPrint? = null
|
||||||
|
private val printMutex = Mutex()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化打印机(耗时操作,需在IO线程调用)
|
* 初始化打印机(耗时操作,需在IO线程调用)
|
||||||
@@ -182,12 +190,29 @@ class PrinterService(
|
|||||||
* @param originalBitmap 要打印的原始图片
|
* @param originalBitmap 要打印的原始图片
|
||||||
* @param callback 打印结果回调
|
* @param callback 打印结果回调
|
||||||
*/
|
*/
|
||||||
suspend fun startPrint(originalBitmap: Bitmap?, callback: PrintCallback) = withContext(Dispatchers.IO) {
|
suspend fun startPrint(originalBitmap: Bitmap?, callback: PrintCallback) = printMutex.withLock {
|
||||||
|
startPrintInternal(originalBitmap, callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun startPrintInternal(
|
||||||
|
originalBitmap: Bitmap?,
|
||||||
|
callback: PrintCallback
|
||||||
|
) = withContext(Dispatchers.IO) {
|
||||||
if (originalBitmap == null) {
|
if (originalBitmap == null) {
|
||||||
LogUtils.e(TAG, "获取图片失败")
|
LogUtils.e(TAG, "获取图片失败")
|
||||||
callback.onPrintResult(false,"获取图片失败")
|
callback.onPrintResult(false,"获取图片失败")
|
||||||
return@withContext
|
return@withContext
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
originalBitmap.isRecycled ||
|
||||||
|
originalBitmap.width <= 0 ||
|
||||||
|
originalBitmap.height <= 0 ||
|
||||||
|
originalBitmap.config == Bitmap.Config.HARDWARE
|
||||||
|
) {
|
||||||
|
LogUtils.e(TAG, "图片 Bitmap 无效或为硬件 Bitmap,禁止打印")
|
||||||
|
callback.onPrintResult(false, "图片数据无效")
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
LogUtils.e(TAG, "获取图片成功")
|
LogUtils.e(TAG, "获取图片成功")
|
||||||
|
|
||||||
// 在开始打印前,检查打印机是否在线
|
// 在开始打印前,检查打印机是否在线
|
||||||
@@ -224,17 +249,22 @@ class PrinterService(
|
|||||||
LogUtils.d(TAG, "打印尺寸: ${printSize.width} x ${printSize.height}")
|
LogUtils.d(TAG, "打印尺寸: ${printSize.width} x ${printSize.height}")
|
||||||
|
|
||||||
// 准备打印图片(保持原比例,居中,白色背景)
|
// 准备打印图片(保持原比例,居中,白色背景)
|
||||||
val outputBitmap: Bitmap
|
var outputBitmap: Bitmap? = null
|
||||||
val outFile = File(
|
val outFile = File(
|
||||||
context.getExternalFilesDir(null),
|
context.getExternalFilesDir(null),
|
||||||
"ProcessedPhotos/${System.currentTimeMillis()}_print.bmp"
|
"ProcessedPhotos/${System.currentTimeMillis()}_print.bmp"
|
||||||
)
|
)
|
||||||
outFile.parentFile?.mkdirs()
|
val outputDirectory = outFile.parentFile
|
||||||
|
if (outputDirectory == null || (!outputDirectory.exists() && !outputDirectory.mkdirs())) {
|
||||||
|
printStatusManager.setIdle()
|
||||||
|
callback.onPrintResult(false, "无法创建打印文件目录")
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
outputBitmap = preparePrintBitmap(originalBitmap, printSize, printId)
|
outputBitmap = preparePrintBitmap(originalBitmap, printSize, printId)
|
||||||
|
|
||||||
if (printId != PrintManager.EPrinter.QW410_DEF.id) {
|
val saved = if (printId != PrintManager.EPrinter.QW410_DEF.id) {
|
||||||
BmpUtil.save(outputBitmap, outFile.absolutePath, EResolution.RESO300.mValue)
|
BmpUtil.save(outputBitmap, outFile.absolutePath, EResolution.RESO300.mValue)
|
||||||
} else {
|
} else {
|
||||||
AndroidBmpUtil.save(
|
AndroidBmpUtil.save(
|
||||||
@@ -244,12 +274,45 @@ class PrinterService(
|
|||||||
PRINTSIZE.QW410.height
|
PRINTSIZE.QW410.height
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!saved) {
|
||||||
|
throw IOException("BMP 保存接口返回失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
val expectedBmpWidth: Int
|
||||||
|
val expectedBmpHeight: Int
|
||||||
|
if (printId != PrintManager.EPrinter.QW410_DEF.id) {
|
||||||
|
expectedBmpWidth = RX1_ENCODED_BMP_WIDTH_300_DPI
|
||||||
|
expectedBmpHeight = RX1_ENCODED_BMP_HEIGHT_300_DPI
|
||||||
|
} else {
|
||||||
|
expectedBmpWidth = printSize.width
|
||||||
|
expectedBmpHeight = printSize.height
|
||||||
|
}
|
||||||
|
|
||||||
|
val validation = PrintImageIntegrityValidator.validateBmp(
|
||||||
|
file = outFile,
|
||||||
|
expectedWidth = expectedBmpWidth,
|
||||||
|
expectedHeight = expectedBmpHeight
|
||||||
|
)
|
||||||
|
LogUtils.d(
|
||||||
|
TAG,
|
||||||
|
"打印 BMP 校验成功 - path=${outFile.absolutePath}, " +
|
||||||
|
"size=${validation.width}x${validation.height}, " +
|
||||||
|
"bpp=${validation.bitsPerPixel}, bytes=${validation.fileSize}, " +
|
||||||
|
"sha256=${validation.sha256.take(16)}"
|
||||||
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
LogUtils.e(TAG, "图片处理或 BMP 完整性校验失败: ${e.message}")
|
||||||
|
outputBitmap?.let { bitmap ->
|
||||||
|
if (!bitmap.isRecycled) bitmap.recycle()
|
||||||
|
}
|
||||||
|
printStatusManager.setIdle()
|
||||||
callback.onPrintResult(false, "图片处理失败: ${e.message}")
|
callback.onPrintResult(false, "图片处理失败: ${e.message}")
|
||||||
return@withContext
|
return@withContext
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val validatedOutputBitmap = checkNotNull(outputBitmap)
|
||||||
|
|
||||||
// 创建打印任务
|
// 创建打印任务
|
||||||
val job = PrintJob(
|
val job = PrintJob(
|
||||||
outFile.absolutePath,
|
outFile.absolutePath,
|
||||||
@@ -269,8 +332,8 @@ class PrinterService(
|
|||||||
printStatusManager.setIdle()
|
printStatusManager.setIdle()
|
||||||
// 回收 outputBitmap
|
// 回收 outputBitmap
|
||||||
try {
|
try {
|
||||||
if (!outputBitmap.isRecycled) {
|
if (!validatedOutputBitmap.isRecycled) {
|
||||||
outputBitmap.recycle()
|
validatedOutputBitmap.recycle()
|
||||||
LogUtils.d(TAG, "outputBitmap 已回收")
|
LogUtils.d(TAG, "outputBitmap 已回收")
|
||||||
}
|
}
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
@@ -285,8 +348,8 @@ class PrinterService(
|
|||||||
printStatusManager.setIdle()
|
printStatusManager.setIdle()
|
||||||
// 回收 outputBitmap
|
// 回收 outputBitmap
|
||||||
try {
|
try {
|
||||||
if (!outputBitmap.isRecycled) {
|
if (!validatedOutputBitmap.isRecycled) {
|
||||||
outputBitmap.recycle()
|
validatedOutputBitmap.recycle()
|
||||||
LogUtils.d(TAG, "outputBitmap 已回收")
|
LogUtils.d(TAG, "outputBitmap 已回收")
|
||||||
}
|
}
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
|
|||||||
@@ -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,6 +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 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
|
||||||
@@ -24,6 +28,10 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.graphics.Color
|
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.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
|
||||||
@@ -35,6 +43,9 @@ import coil.compose.AsyncImage
|
|||||||
import coil.compose.SubcomposeAsyncImage
|
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 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
|
||||||
@@ -46,25 +57,46 @@ import com.yzx.kiosk.ui.face.viewmodel.FaceRecognitionResultViewModel
|
|||||||
import com.yzx.kiosk.ui.upload.view.PhotoPreviewDialog
|
import com.yzx.kiosk.ui.upload.view.PhotoPreviewDialog
|
||||||
import com.yzx.kiosk.ui.upload.view.RetryableAsyncImage
|
import com.yzx.kiosk.ui.upload.view.RetryableAsyncImage
|
||||||
import com.yzx.kiosk.utils.QrCodeUtils
|
import com.yzx.kiosk.utils.QrCodeUtils
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.flow.collect
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||||
@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()
|
||||||
|
|
||||||
|
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
|
||||||
@@ -77,6 +109,12 @@ fun FaceRecognitionResultScreen(
|
|||||||
var showPayDialog by remember { mutableStateOf(false) }
|
var showPayDialog by remember { mutableStateOf(false) }
|
||||||
val payQrCodeUrl by viewModel.payQrCodeUrl.collectAsState()
|
val payQrCodeUrl by viewModel.payQrCodeUrl.collectAsState()
|
||||||
|
|
||||||
|
LaunchedEffect(viewModel) {
|
||||||
|
viewModel.dismissPayDialogEvents.collect {
|
||||||
|
showPayDialog = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
FullScreenMode()
|
FullScreenMode()
|
||||||
|
|
||||||
AppScaffold(
|
AppScaffold(
|
||||||
@@ -84,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
|
||||||
@@ -110,73 +148,69 @@ fun FaceRecognitionResultScreen(
|
|||||||
shape = RoundedCornerShape(18.dp),
|
shape = RoundedCornerShape(18.dp),
|
||||||
colors = CardDefaults.cardColors(containerColor = Color(0xFFF4F4F4))
|
colors = CardDefaults.cardColors(containerColor = Color(0xFFF4F4F4))
|
||||||
) {
|
) {
|
||||||
|
Column(Modifier.fillMaxWidth().padding(24.dp)) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth(),
|
||||||
.fillMaxWidth()
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
.padding(24.dp),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
) {
|
||||||
Column {
|
Text("已选择 ${selectedCount} 张", fontSize = 24.sp, fontWeight = FontWeight.Bold)
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Spacer(Modifier.weight(1f))
|
||||||
Text(
|
Row(
|
||||||
text = "已选择",
|
horizontalArrangement = Arrangement.spacedBy(20.dp),
|
||||||
fontSize = 24.sp,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
fontWeight = FontWeight.Bold,
|
) {
|
||||||
color = Color.Black
|
Text("打印版 ${formatPriceDisplay(pricePerPhoto)}元/张", fontSize = 21.sp)
|
||||||
)
|
Text("电子版 ${formatPriceDisplay(electronicPrice)}元/张", fontSize = 21.sp)
|
||||||
Text(
|
|
||||||
modifier = Modifier.padding(top = 4.dp),
|
|
||||||
text = selectedCount.toString(),
|
|
||||||
fontSize = 28.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = Color(0xFF0073FF)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "张",
|
|
||||||
fontSize = 24.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = Color.Black
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(18.dp))
|
|
||||||
Text(
|
|
||||||
text = "打印${pricePerPhoto}元/张",
|
|
||||||
fontSize = 21.sp,
|
|
||||||
color = Color(0xFF000000)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
PurchaseMode.entries.forEach { mode ->
|
||||||
|
val isPrint = mode == PurchaseMode.PRINT
|
||||||
|
val total = if (isPrint) totalPrice else electronicTotal
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = { viewModel.getPayUrl(mode) { showPayDialog = true } },
|
||||||
viewModel.getPayUrl { url ->
|
enabled = selectedCount > 0 && total != "--" && !creatingOrder && !quoteLoading,
|
||||||
showPayDialog = true
|
modifier = Modifier.fillMaxWidth().heightIn(min = 88.dp),
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
|
||||||
.height(72.dp)
|
|
||||||
.widthIn(min = 180.dp),
|
|
||||||
shape = RoundedCornerShape(18.dp),
|
shape = RoundedCornerShape(18.dp),
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF0073FF)),
|
colors = ButtonDefaults.buttonColors(
|
||||||
enabled = selectedCount > 0
|
containerColor = if (isPrint) Color(0xFF0073FF) else Color(0xFFE3EFFF),
|
||||||
|
contentColor = if (isPrint) Color.White else Color(0xFF1756A9),
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = if (selectedCount > 0) "支付${totalPrice}元" else "支付0元",
|
text = if (!isPrint && total == "--" && !quoteLoading)
|
||||||
|
"电子版暂不可购买" else "${if (isPrint) "购买打印照片" else "购买电子照片"}\n${formatPriceDisplay(total)}元",
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
fontSize = 24.sp,
|
fontSize = 24.sp,
|
||||||
color = Color.White,
|
lineHeight = 32.sp,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (quoteLoading) {
|
||||||
|
Text("正在获取价格…", modifier = Modifier.padding(top = 12.dp))
|
||||||
|
} else if (quoteError != null || totalPrice == "--") {
|
||||||
|
Text(quoteError ?: "打印价格暂不可用", modifier = Modifier.padding(top = 12.dp))
|
||||||
|
TextButton(onClick = { viewModel.retryQuote() }, enabled = !creatingOrder && !quoteLoading) {
|
||||||
|
Text("重新获取价格")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 全选行
|
// Keep selection and navigation as separate click targets in the same toolbar.
|
||||||
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() },
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically
|
modifier = Modifier.weight(1f).heightIn(min = 48.dp)
|
||||||
|
.clickable(enabled = photoList.isNotEmpty()) { viewModel.toggleSelectAll() },
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
// 复选框
|
// 复选框
|
||||||
Box(
|
Box(
|
||||||
@@ -214,6 +248,48 @@ fun FaceRecognitionResultScreen(
|
|||||||
color = Color.Black
|
color = Color.Black
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (source != AppRoutes.RECENT_RESULT_SOURCE) {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
if (!isOpeningRecentPhotos) {
|
||||||
|
isOpeningRecentPhotos = true
|
||||||
|
viewModel.browseRecentPhotos()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = !isOpeningRecentPhotos,
|
||||||
|
modifier = Modifier.heightIn(min = 48.dp),
|
||||||
|
contentPadding = PaddingValues(horizontal = 6.dp),
|
||||||
|
colors = ButtonDefaults.textButtonColors(contentColor = Color(0xFF2878CE)),
|
||||||
|
) {
|
||||||
|
Text("查看最近照片", fontSize = 16.sp, fontWeight = FontWeight.Medium)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("›", fontSize = 20.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source == AppRoutes.RECENT_RESULT_SOURCE && recentPhotosState != RecentPhotosState.READY) {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
if (recentPhotosState == RecentPhotosState.IDLE || recentPhotosState == RecentPhotosState.LOADING) {
|
||||||
|
CircularProgressIndicator(Modifier.size(32.dp))
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = when (recentPhotosState) {
|
||||||
|
RecentPhotosState.ERROR -> "照片加载失败,请重试"
|
||||||
|
RecentPhotosState.EMPTY -> "暂无可显示的最近照片"
|
||||||
|
else -> "正在加载照片…"
|
||||||
|
},
|
||||||
|
fontSize = 18.sp,
|
||||||
|
color = Color(0xFF666666),
|
||||||
|
)
|
||||||
|
if (recentPhotosState == RecentPhotosState.ERROR) {
|
||||||
|
TextButton(onClick = viewModel::retryRecentPhotos) { Text("重试", fontSize = 18.sp) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 图片瀑布流列表
|
// 图片瀑布流列表
|
||||||
LazyVerticalStaggeredGrid(
|
LazyVerticalStaggeredGrid(
|
||||||
@@ -224,12 +300,15 @@ fun FaceRecognitionResultScreen(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(18.dp),
|
horizontalArrangement = Arrangement.spacedBy(18.dp),
|
||||||
verticalItemSpacing = 18.dp
|
verticalItemSpacing = 18.dp
|
||||||
) {
|
) {
|
||||||
itemsIndexed(photoList) { index, photo ->
|
itemsIndexed(
|
||||||
|
items = photoList,
|
||||||
|
key = { _, photo -> photo.id }
|
||||||
|
) { _, photo ->
|
||||||
val isSelected = selectedPhotos.contains(photo.url)
|
val isSelected = selectedPhotos.contains(photo.url)
|
||||||
val imageLoadStates by viewModel.imageLoadStates.collectAsState()
|
|
||||||
val loadState = imageLoadStates[photo.url] ?: FaceRecognitionResultViewModel.ImageLoadState()
|
val loadState = imageLoadStates[photo.url] ?: FaceRecognitionResultViewModel.ImageLoadState()
|
||||||
|
|
||||||
FaceRecognitionPhotoItem(
|
FaceRecognitionPhotoItem(
|
||||||
|
photoId = photo.id,
|
||||||
photoUrl = photo.url,
|
photoUrl = photo.url,
|
||||||
aspectRatio = photo.aspectRatio,
|
aspectRatio = photo.aspectRatio,
|
||||||
isSelected = isSelected,
|
isSelected = isSelected,
|
||||||
@@ -246,6 +325,9 @@ fun FaceRecognitionResultScreen(
|
|||||||
},
|
},
|
||||||
onLoadError = { viewModel.handleImageLoadError(photo.url) },
|
onLoadError = { viewModel.handleImageLoadError(photo.url) },
|
||||||
onLoadSuccess = { viewModel.handleImageLoadSuccess(photo.url) },
|
onLoadSuccess = { viewModel.handleImageLoadSuccess(photo.url) },
|
||||||
|
onImageDimensionsResolved = { width, height ->
|
||||||
|
viewModel.updatePhotoAspectRatio(photo.id, width, height)
|
||||||
|
},
|
||||||
onRetryClick = { viewModel.retryLoadImage(photo.url) }
|
onRetryClick = { viewModel.retryLoadImage(photo.url) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -264,16 +346,20 @@ fun FaceRecognitionResultScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 支付弹框
|
// 支付弹框
|
||||||
if (showPayDialog && selectedCount > 0 && payQrCodeUrl != null) {
|
if (showPayDialog && payQrCodeUrl != null) {
|
||||||
val selectedUrls = photoList.filter { selectedPhotos.contains(it.url) }.map { it.url }
|
DisposableEffect(payQrCodeUrl) {
|
||||||
|
viewModel.startPayStatusPolling()
|
||||||
|
onDispose {
|
||||||
|
viewModel.stopPayStatusPolling()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
WechatPayDialog(
|
WechatPayDialog(
|
||||||
qrCodeUrl = payQrCodeUrl!!,
|
qrCodeUrl = payQrCodeUrl!!,
|
||||||
totalPrice = totalPrice,
|
totalPrice = paymentAmount,
|
||||||
onDismiss = { showPayDialog = false },
|
onDismiss = {
|
||||||
onPaySuccess = {
|
|
||||||
showPayDialog = false
|
showPayDialog = false
|
||||||
// 支付成功,等待 WebSocket 消息(code=5)触发跳转到支付成功页面
|
viewModel.onPayDialogDismissed()
|
||||||
// 跳转逻辑由 FaceRecognitionResultViewModel 中的 WebSocket 监听处理
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -281,6 +367,7 @@ fun FaceRecognitionResultScreen(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun FaceRecognitionPhotoItem(
|
fun FaceRecognitionPhotoItem(
|
||||||
|
photoId: Int,
|
||||||
photoUrl: String,
|
photoUrl: String,
|
||||||
aspectRatio: Float,
|
aspectRatio: Float,
|
||||||
isSelected: Boolean,
|
isSelected: Boolean,
|
||||||
@@ -289,6 +376,7 @@ fun FaceRecognitionPhotoItem(
|
|||||||
onPreviewClick: () -> Unit,
|
onPreviewClick: () -> Unit,
|
||||||
onLoadError: () -> Unit = {},
|
onLoadError: () -> Unit = {},
|
||||||
onLoadSuccess: () -> Unit = {},
|
onLoadSuccess: () -> Unit = {},
|
||||||
|
onImageDimensionsResolved: (width: Int, height: Int) -> Unit = { _, _ -> },
|
||||||
onRetryClick: () -> Unit = {}
|
onRetryClick: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
// 确保 aspectRatio 是有效值
|
// 确保 aspectRatio 是有效值
|
||||||
@@ -306,11 +394,20 @@ fun FaceRecognitionPhotoItem(
|
|||||||
.clickable { onToggleSelection() }
|
.clickable { onToggleSelection() }
|
||||||
) {
|
) {
|
||||||
// 图片 - 按原比例显示
|
// 图片 - 按原比例显示
|
||||||
var lastErrorState by remember { mutableStateOf(false) }
|
var lastErrorState by remember(photoId, photoUrl) { mutableStateOf(false) }
|
||||||
var lastSuccessState by remember { mutableStateOf(false) }
|
var lastSuccessState by remember(photoId, photoUrl) { mutableStateOf(false) }
|
||||||
|
val context = LocalContext.current
|
||||||
|
val imageRequest = remember(context, photoUrl) {
|
||||||
|
ImageRequest.Builder(context)
|
||||||
|
.data(photoUrl)
|
||||||
|
.allowHardware(false)
|
||||||
|
.bitmapConfig(Bitmap.Config.ARGB_8888)
|
||||||
|
.crossfade(false)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
SubcomposeAsyncImage(
|
SubcomposeAsyncImage(
|
||||||
model = photoUrl,
|
model = imageRequest,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = ContentScale.Fit
|
contentScale = ContentScale.Fit
|
||||||
@@ -390,6 +487,11 @@ fun FaceRecognitionPhotoItem(
|
|||||||
if (state is coil.compose.AsyncImagePainter.State.Success && !lastSuccessState) {
|
if (state is coil.compose.AsyncImagePainter.State.Success && !lastSuccessState) {
|
||||||
lastSuccessState = true
|
lastSuccessState = true
|
||||||
lastErrorState = false
|
lastErrorState = false
|
||||||
|
val drawable = state.result.drawable
|
||||||
|
onImageDimensionsResolved(
|
||||||
|
drawable.intrinsicWidth,
|
||||||
|
drawable.intrinsicHeight
|
||||||
|
)
|
||||||
onLoadSuccess()
|
onLoadSuccess()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -453,8 +555,7 @@ fun FaceRecognitionPhotoItem(
|
|||||||
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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
AndroidView(
|
val previewView = remember(context) { PreviewView(context) }
|
||||||
factory = { ctx ->
|
val preview = remember { Preview.Builder().build() }
|
||||||
val previewView = PreviewView(ctx)
|
val imageCapture = remember {
|
||||||
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
|
ImageCapture.Builder()
|
||||||
|
|
||||||
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)
|
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
|
||||||
.build()
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
// 通知ViewModel ImageCapture已准备好
|
AndroidView(
|
||||||
onImageCaptureReady(imageCapture)
|
factory = { previewView },
|
||||||
|
modifier = modifier
|
||||||
// 绑定到生命周期
|
|
||||||
val cameraSelector = CameraSelector.DEFAULT_FRONT_CAMERA
|
|
||||||
try {
|
|
||||||
cameraProvider.unbindAll()
|
|
||||||
cameraProvider.bindToLifecycle(
|
|
||||||
lifecycleOwner,
|
|
||||||
cameraSelector,
|
|
||||||
preview,
|
|
||||||
imageCapture
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
}, ContextCompat.getMainExecutor(ctx))
|
}, executor)
|
||||||
|
|
||||||
previewView
|
onDispose {
|
||||||
},
|
disposed = true
|
||||||
modifier = modifier
|
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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.yzx.kiosk.ui.face.viewmodel
|
||||||
|
|
||||||
|
import com.yzx.kiosk.network.model.response.PaySuccessMessageResponse
|
||||||
|
|
||||||
|
internal enum class FacePayStatusDecision {
|
||||||
|
CONTINUE_POLLING,
|
||||||
|
COMPLETE_PAYMENT,
|
||||||
|
STOP_WITH_TERMINAL_STATUS
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun decideFacePayStatus(orderStatus: Int?): FacePayStatusDecision = when (orderStatus) {
|
||||||
|
30 -> FacePayStatusDecision.COMPLETE_PAYMENT
|
||||||
|
10 -> FacePayStatusDecision.CONTINUE_POLLING
|
||||||
|
else -> FacePayStatusDecision.STOP_WITH_TERMINAL_STATUS
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun isValidFacePaySuccessMessage(
|
||||||
|
expectedOrderNumber: String,
|
||||||
|
message: PaySuccessMessageResponse?
|
||||||
|
): Boolean {
|
||||||
|
val paymentData = message?.data ?: return false
|
||||||
|
return message.orderStatus == 30 && message.type == 5 &&
|
||||||
|
paymentData.orderNumber == expectedOrderNumber &&
|
||||||
|
paymentData.captureType == 2 &&
|
||||||
|
!paymentData.imageIds.isNullOrEmpty()
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.yzx.kiosk.ui.face.viewmodel
|
||||||
|
|
||||||
|
import com.yzx.kiosk.network.model.response.GetPayUrlResponse
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import java.math.RoundingMode
|
||||||
|
|
||||||
|
enum class PurchaseMode(val wireValue: String, val label: String) {
|
||||||
|
PRINT("print", "打印版"), ELECTRONIC("electronic", "电子版")
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun money(value: String?): String? {
|
||||||
|
if (value == null || !Regex("[0-9]+(?:\\.[0-9]{1,2})?").matches(value)) return null
|
||||||
|
return value.toBigDecimalOrNull()?.setScale(2, RoundingMode.UNNECESSARY)?.toPlainString()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun isPositiveMoney(value: String?): Boolean =
|
||||||
|
money(value)?.toBigDecimal()?.let { it > BigDecimal.ZERO } == true
|
||||||
|
|
||||||
|
internal fun electronicQuoteAvailable(unit: String?, total: String?, hasPhotos: Boolean): Boolean =
|
||||||
|
isPositiveMoney(unit) && money(total) != null && (!hasPhotos || isPositiveMoney(total))
|
||||||
|
|
||||||
|
internal data class FacePaymentOrder(
|
||||||
|
val number: String,
|
||||||
|
val mode: PurchaseMode,
|
||||||
|
val imageIds: Set<Int>,
|
||||||
|
val amount: String,
|
||||||
|
val legacyPrint: Boolean,
|
||||||
|
) {
|
||||||
|
fun matches(number: String?, captureType: Int?, ids: List<Int>, purchaseMode: String?): Boolean =
|
||||||
|
this.number == number && captureType == 2 && ids.isNotEmpty() &&
|
||||||
|
ids.size == ids.toSet().size && imageIds == ids.toSet() &&
|
||||||
|
(purchaseMode == mode.wireValue || (legacyPrint && purchaseMode == null))
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun createFacePaymentOrder(
|
||||||
|
response: GetPayUrlResponse,
|
||||||
|
mode: PurchaseMode,
|
||||||
|
ids: List<Int>,
|
||||||
|
quotedAmount: String,
|
||||||
|
): FacePaymentOrder? {
|
||||||
|
val number = response.orderNumber?.trim()?.takeIf { it.isNotEmpty() } ?: return null
|
||||||
|
// Only an entirely old-style print response may omit the new order fields.
|
||||||
|
val legacy = mode == PurchaseMode.PRINT && response.purchaseMode == null && response.amount == null
|
||||||
|
if (!legacy && response.purchaseMode != mode.wireValue) return null
|
||||||
|
val amount = money(if (legacy) quotedAmount else response.amount) ?: return null
|
||||||
|
if (ids.isEmpty()) return null
|
||||||
|
val order = FacePaymentOrder(number, mode, ids.toSet(), amount, legacy)
|
||||||
|
if (mode == PurchaseMode.ELECTRONIC && !isPositiveMoney(amount)) return null
|
||||||
|
// The link represents a pending preselection, never proof of purchase.
|
||||||
|
if (!legacy && response.orderStatus != 10) return null
|
||||||
|
if (response.url.isNullOrBlank()) return null
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Versioning also rejects responses from an old A selection after A -> B -> A. */
|
||||||
|
internal class QuoteRevision {
|
||||||
|
private var revision = 0L
|
||||||
|
fun next(): Long = ++revision
|
||||||
|
fun isCurrent(candidate: Long): Boolean = revision == candidate
|
||||||
|
}
|
||||||
@@ -1,20 +1,17 @@
|
|||||||
package com.yzx.kiosk.ui.face.viewmodel
|
package com.yzx.kiosk.ui.face.viewmodel
|
||||||
|
|
||||||
import android.graphics.ImageDecoder
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
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.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.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
|
||||||
@@ -25,15 +22,21 @@ import com.yzx.kiosk.datastore.AppStoreDataSource
|
|||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
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
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
@@ -41,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()
|
||||||
|
|
||||||
@@ -69,16 +103,44 @@ 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()
|
||||||
|
|
||||||
|
// 支付弹框关闭事件(支付完成、取消或退款时由 ViewModel 驱动关闭)
|
||||||
|
private val _dismissPayDialogEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||||
|
val dismissPayDialogEvents: SharedFlow<Unit> = _dismissPayDialogEvents.asSharedFlow()
|
||||||
|
|
||||||
|
private var activePaymentOrderNumber: String? = null
|
||||||
|
private var paymentPollingJob: Job? = null
|
||||||
|
private val paymentHandled = AtomicBoolean(false)
|
||||||
|
|
||||||
// 图片加载状态:URL -> (重试次数, 是否加载失败)
|
// 图片加载状态:URL -> (重试次数, 是否加载失败)
|
||||||
private val _imageLoadStates = MutableStateFlow<Map<String, ImageLoadState>>(emptyMap())
|
private val _imageLoadStates = MutableStateFlow<Map<String, ImageLoadState>>(emptyMap())
|
||||||
val imageLoadStates: StateFlow<Map<String, ImageLoadState>> = _imageLoadStates.asStateFlow()
|
val imageLoadStates: StateFlow<Map<String, ImageLoadState>> = _imageLoadStates.asStateFlow()
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
private const val TAG = "FaceRecognitionResultViewModel"
|
||||||
private const val MAX_RETRY_COUNT = 3
|
private const val MAX_RETRY_COUNT = 3
|
||||||
|
private const val PAY_STATUS_POLL_INTERVAL_MS = 2_000L
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ImageLoadState(
|
data class ImageLoadState(
|
||||||
@@ -93,9 +155,14 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
|||||||
.onEach { event ->
|
.onEach { event ->
|
||||||
when (event) {
|
when (event) {
|
||||||
is UploadPhotoEvent.PaySuccess -> {
|
is UploadPhotoEvent.PaySuccess -> {
|
||||||
// 支付成功,跳转到支付成功页面
|
LogUtils.d(TAG, "收到 WebSocket 支付成功事件 - order_number: ${event.orderNumber}, capture_type: ${event.captureType}, image_ids: ${event.imageIds}")
|
||||||
LogUtils.d("FaceRecognitionResultViewModel", "收到支付成功事件 - order_number: ${event.orderNumber}, capture_type: ${event.captureType}, image_ids: ${event.imageIds}")
|
handlePaymentSuccess(
|
||||||
navigateToPaySuccess(event.orderNumber, event.captureType, event.imageIds)
|
orderNumber = event.orderNumber,
|
||||||
|
captureType = event.captureType,
|
||||||
|
imageIds = event.imageIds,
|
||||||
|
purchaseMode = event.purchaseMode,
|
||||||
|
source = "WebSocket"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
// 其他事件不处理
|
// 其他事件不处理
|
||||||
@@ -108,10 +175,20 @@ 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
|
||||||
|
faceResultsInitialized = true
|
||||||
|
verifyResult(emptyList())
|
||||||
|
viewModelScope.launch { applyPhotoList(results) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun applyPhotoList(results: List<FaceSearchResult>) {
|
||||||
// 保存原始结果列表
|
// 保存原始结果列表
|
||||||
originalResults = results
|
originalResults = results
|
||||||
|
urlToIdMap.clear()
|
||||||
|
|
||||||
// 先使用默认宽高比创建列表
|
// 先使用默认宽高比创建列表
|
||||||
val initialPhotos = results.mapNotNull { result ->
|
val initialPhotos = results.mapNotNull { result ->
|
||||||
@@ -136,79 +213,41 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
|||||||
LogUtils.i("FaceRecognitionResultViewModel", "没有有效的图片URL")
|
LogUtils.i("FaceRecognitionResultViewModel", "没有有效的图片URL")
|
||||||
_photoList.value = emptyList()
|
_photoList.value = emptyList()
|
||||||
_imageLoadStates.value = emptyMap()
|
_imageLoadStates.value = emptyMap()
|
||||||
return@launch
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_photoList.value = initialPhotos
|
_photoList.value = initialPhotos
|
||||||
// 初始化加载状态
|
// 初始化加载状态
|
||||||
_imageLoadStates.value = initialPhotos.associate { it.url to ImageLoadState() }
|
_imageLoadStates.value = initialPhotos.associate { it.url to ImageLoadState() }
|
||||||
|
|
||||||
// 页面初始化时调用验证接口(空列表,type: 2),获取默认价格
|
LogUtils.d(TAG, "图片列表初始化完成,共 ${initialPhotos.size} 张,宽高比将在图片加载成功后逐张更新")
|
||||||
verifyResult(emptyList())
|
|
||||||
|
|
||||||
// 并发获取所有图片的真实尺寸并更新宽高比
|
|
||||||
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 {
|
|
||||||
LogUtils.i("FaceRecognitionResultViewModel", "图片 ${photo.url} 的宽高比无效: $aspectRatio,使用默认值")
|
|
||||||
1f
|
|
||||||
}
|
|
||||||
photo.copy(aspectRatio = validAspectRatio)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
LogUtils.e("FaceRecognitionResultViewModel", "获取图片 ${photo.url} 尺寸失败: ${e.message}")
|
|
||||||
// 获取失败,保持默认值
|
|
||||||
photo
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.awaitAll()
|
|
||||||
|
|
||||||
_photoList.value = updatedPhotos
|
|
||||||
LogUtils.d("FaceRecognitionResultViewModel", "图片列表初始化完成,共 ${updatedPhotos.size} 张")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取图片的宽高比
|
* 使用网格中已经加载成功的 Drawable 尺寸更新宽高比。
|
||||||
|
* 这样不会为了获取尺寸额外下载、解码一次图片。
|
||||||
*/
|
*/
|
||||||
private suspend fun getImageAspectRatio(imageUrl: String): Float {
|
fun updatePhotoAspectRatio(photoId: Int, width: Int, height: Int) {
|
||||||
return withTimeoutOrNull(10000) { // 10秒超时
|
if (width <= 0 || height <= 0) {
|
||||||
try {
|
LogUtils.i(TAG, "忽略无效图片尺寸 - id: $photoId, size: ${width}x${height}")
|
||||||
val imageLoader = ImageLoader(App.instance)
|
return
|
||||||
val request = ImageRequest.Builder(App.instance)
|
}
|
||||||
.data(imageUrl)
|
|
||||||
.size(Size.ORIGINAL)
|
|
||||||
.allowHardware(false) // 禁用硬件加速,避免某些图片无法获取尺寸
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val result = imageLoader.execute(request)
|
|
||||||
val drawable = result.drawable
|
|
||||||
|
|
||||||
if (drawable != null) {
|
|
||||||
val width = drawable.intrinsicWidth
|
|
||||||
val height = drawable.intrinsicHeight
|
|
||||||
|
|
||||||
if (width > 0 && height > 0) {
|
|
||||||
val aspectRatio = width.toFloat() / height.toFloat()
|
val aspectRatio = width.toFloat() / height.toFloat()
|
||||||
LogUtils.d("FaceRecognitionResultViewModel", "图片 $imageUrl 尺寸: ${width}x${height}, 宽高比: $aspectRatio")
|
if (!aspectRatio.isFinite() || aspectRatio !in 0.05f..20f) {
|
||||||
return@withTimeoutOrNull aspectRatio
|
LogUtils.i(TAG, "忽略异常图片宽高比 - id: $photoId, ratio: $aspectRatio")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_photoList.update { photos ->
|
||||||
|
photos.map { photo ->
|
||||||
|
if (photo.id == photoId && kotlin.math.abs(photo.aspectRatio - aspectRatio) > 0.01f) {
|
||||||
|
LogUtils.d(TAG, "更新图片比例 - id: $photoId, size: ${width}x${height}, ratio: $aspectRatio")
|
||||||
|
photo.copy(aspectRatio = aspectRatio)
|
||||||
} else {
|
} else {
|
||||||
LogUtils.i("FaceRecognitionResultViewModel", "图片 $imageUrl 尺寸无效: ${width}x${height}")
|
photo
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
LogUtils.i("FaceRecognitionResultViewModel", "图片 $imageUrl 加载失败,drawable为null")
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
|
||||||
LogUtils.e("FaceRecognitionResultViewModel"+"获取图片 $imageUrl 尺寸异常: ${e.message}")
|
|
||||||
}
|
|
||||||
null
|
|
||||||
} ?: run {
|
|
||||||
LogUtils.i("FaceRecognitionResultViewModel", "获取图片 $imageUrl 尺寸超时")
|
|
||||||
1f // 超时或失败,返回默认值
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,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)
|
||||||
@@ -233,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) {
|
||||||
// 当前全选,取消全选
|
// 当前全选,取消全选
|
||||||
@@ -261,106 +303,272 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
|||||||
* 验证结果接口
|
* 验证结果接口
|
||||||
*/
|
*/
|
||||||
private fun verifyResult(imageIds: List<Int>) {
|
private fun verifyResult(imageIds: List<Int>) {
|
||||||
viewModelScope.launch {
|
clearActivePayment()
|
||||||
|
_quoteLoading.value = true
|
||||||
|
_quoteError.value = null
|
||||||
|
val revision = quoteRevision.next()
|
||||||
|
quoteJob?.cancel()
|
||||||
|
_pricePerPhoto.value = "--"
|
||||||
|
_totalPrice.value = "--"
|
||||||
|
_electronicPrice.value = "--"
|
||||||
|
_electronicTotal.value = "--"
|
||||||
|
quoteJob = viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
val request = VerifyResultRequest(
|
val response = netWorkRepository.verifyResult(VerifyResultRequest(2, imageIds.distinct(), videoId = emptyList())).first()
|
||||||
type = 2,
|
if (!quoteRevision.isCurrent(revision)) return@launch
|
||||||
imageId = imageIds
|
if (!response.isSucceeded) {
|
||||||
)
|
_quoteError.value = response.message?.takeIf { it.isNotBlank() } ?: "获取报价失败,请重试"
|
||||||
|
|
||||||
handleResultWithData(
|
|
||||||
flow = netWorkRepository.verifyResult(request).asResult(),
|
|
||||||
showToast = false,
|
|
||||||
onData = { response ->
|
|
||||||
// 更新单价(使用接口返回的 price_image)
|
|
||||||
val priceImage = response.priceImage
|
|
||||||
if (!priceImage.isNullOrEmpty()) {
|
|
||||||
_pricePerPhoto.value = priceImage
|
|
||||||
} else {
|
|
||||||
_pricePerPhoto.value = "--"
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新总价(使用接口返回的 amount)
|
|
||||||
val amount = response.amount
|
|
||||||
if (!amount.isNullOrEmpty()) {
|
|
||||||
_totalPrice.value = amount
|
|
||||||
} else {
|
|
||||||
_totalPrice.value = "--"
|
|
||||||
}
|
|
||||||
|
|
||||||
LogUtils.d("FaceRecognitionResultViewModel", "验证结果成功 - 单价: $priceImage, 总价: $amount")
|
|
||||||
},
|
|
||||||
onError = { msg, _ ->
|
|
||||||
LogUtils.e("FaceRecognitionResultViewModel", "验证结果失败: $msg")
|
|
||||||
// 接口失败时,保持当前值或设置为默认值
|
|
||||||
if (imageIds.isEmpty()) {
|
|
||||||
_pricePerPhoto.value = "--"
|
|
||||||
_totalPrice.value = "--"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
LogUtils.e("FaceRecognitionResultViewModel", "验证结果异常: ${e.message}")
|
|
||||||
if (imageIds.isEmpty()) {
|
|
||||||
_pricePerPhoto.value = "--"
|
|
||||||
_totalPrice.value = "--"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取支付URL
|
|
||||||
*/
|
|
||||||
fun getPayUrl(onSuccess: (String) -> Unit) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
val selectedIds = getSelectedImageIds()
|
|
||||||
if (selectedIds.isEmpty()) {
|
|
||||||
ToastUtils.show("请选择要打印的照片")
|
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
val data = response.data
|
||||||
|
if (data == null) {
|
||||||
|
_quoteError.value = "报价数据不完整,请重试"
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val printUnit = money(data.priceImage)
|
||||||
|
val printTotal = money(data.amount)
|
||||||
|
if (printUnit != null && printTotal != null) {
|
||||||
|
_pricePerPhoto.value = printUnit
|
||||||
|
_totalPrice.value = printTotal
|
||||||
|
}
|
||||||
|
val electronicUnit = money(data.priceElectronic)
|
||||||
|
val electronicTotal = money(data.amountElectronic)
|
||||||
|
if (electronicQuoteAvailable(electronicUnit, electronicTotal, imageIds.isNotEmpty())) {
|
||||||
|
_electronicPrice.value = checkNotNull(electronicUnit)
|
||||||
|
_electronicTotal.value = checkNotNull(electronicTotal)
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtils.e(TAG, "获取报价失败: ${e.message}")
|
||||||
|
if (quoteRevision.isCurrent(revision)) _quoteError.value = "网络异常,请重新获取价格"
|
||||||
|
} finally {
|
||||||
|
if (quoteRevision.isCurrent(revision)) _quoteLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearActivePayment() {
|
||||||
|
stopPayStatusPolling()
|
||||||
|
activeOrder = null
|
||||||
|
activePaymentOrderNumber = null
|
||||||
|
_payQrCodeUrl.value = null
|
||||||
|
_dismissPayDialogEvents.tryEmit(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recoverPayment(message: String) {
|
||||||
|
clearActivePayment()
|
||||||
|
ToastUtils.show(message)
|
||||||
|
retryQuote()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun retryQuote() = verifyResult(getSelectedImageIds())
|
||||||
|
|
||||||
|
fun getPayUrl(mode: PurchaseMode, onSuccess: (String) -> Unit) {
|
||||||
|
if (_creatingOrder.value) return
|
||||||
|
val selectedIds = getSelectedImageIds().distinct()
|
||||||
|
val quotedAmount = if (mode == PurchaseMode.PRINT) _totalPrice.value else _electronicTotal.value
|
||||||
|
if (selectedIds.isEmpty() || money(quotedAmount) == null || _quoteLoading.value) return
|
||||||
|
if (mode == PurchaseMode.ELECTRONIC && !isPositiveMoney(quotedAmount)) return
|
||||||
|
clearActivePayment()
|
||||||
|
_creatingOrder.value = true
|
||||||
|
viewModelScope.launch {
|
||||||
showLoading()
|
showLoading()
|
||||||
try {
|
try {
|
||||||
val request = GetPayUrlRequest(
|
val response = netWorkRepository.getPayUrl(
|
||||||
type = 2,
|
GetPayUrlRequest(2, selectedIds, mode.wireValue, videoId = emptyList())
|
||||||
imageId = selectedIds
|
).first()
|
||||||
)
|
if (!response.isSucceeded) {
|
||||||
|
recoverPayment(response.message?.takeIf { it.isNotBlank() } ?: "获取支付链接失败,请重新选择")
|
||||||
handleResultWithData(
|
return@launch
|
||||||
flow = netWorkRepository.getPayUrl(request).asResult(),
|
}
|
||||||
showToast = false,
|
val data = response.data
|
||||||
onData = { response ->
|
val order = if (data != null)
|
||||||
val url = response.url
|
createFacePaymentOrder(data, mode, selectedIds, quotedAmount) else null
|
||||||
if (url.isNullOrEmpty()) {
|
if (order == null) {
|
||||||
ToastUtils.show("获取支付二维码失败")
|
recoverPayment("支付链接信息无效,请重新选择")
|
||||||
} else {
|
return@launch
|
||||||
|
}
|
||||||
|
stopPayStatusPolling()
|
||||||
|
activeOrder = order
|
||||||
|
activePaymentOrderNumber = order.number
|
||||||
|
paymentHandled.set(false)
|
||||||
|
_paymentAmount.value = order.amount
|
||||||
|
_paymentMode.value = order.mode
|
||||||
|
val url = checkNotNull(data?.url).trim()
|
||||||
_payQrCodeUrl.value = url
|
_payQrCodeUrl.value = url
|
||||||
onSuccess(url)
|
onSuccess(url)
|
||||||
}
|
} catch (e: CancellationException) {
|
||||||
},
|
throw e
|
||||||
onError = { msg, _ ->
|
|
||||||
LogUtils.e("FaceRecognitionResultViewModel", "获取支付URL失败: $msg")
|
|
||||||
ToastUtils.show("获取支付二维码失败")
|
|
||||||
},
|
|
||||||
onEnd = {
|
|
||||||
hideLoading()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
LogUtils.e("FaceRecognitionResultViewModel", "获取支付URL异常: ${e.message}")
|
LogUtils.e(TAG, "创建订单失败: ${e.message}")
|
||||||
ToastUtils.show("获取支付二维码失败")
|
ToastUtils.show("创建订单失败,请重试")
|
||||||
|
} finally {
|
||||||
|
_creatingOrder.value = false
|
||||||
|
hideLoading()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 跳转到支付成功页面
|
* 二维码弹框显示时启动支付状态轮询。重复调用不会创建多个轮询任务。
|
||||||
|
*/
|
||||||
|
fun startPayStatusPolling() {
|
||||||
|
val orderNumber = activePaymentOrderNumber
|
||||||
|
if (orderNumber.isNullOrEmpty() || paymentHandled.get()) {
|
||||||
|
LogUtils.i(TAG, "未启动支付状态轮询:当前没有有效待支付订单")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (paymentPollingJob?.isActive == true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
paymentPollingJob = viewModelScope.launch {
|
||||||
|
LogUtils.d(TAG, "开始轮询支付状态 - order_number: $orderNumber")
|
||||||
|
while (
|
||||||
|
isActive &&
|
||||||
|
!paymentHandled.get() &&
|
||||||
|
activePaymentOrderNumber == orderNumber
|
||||||
|
) {
|
||||||
|
queryPayStatus(orderNumber)
|
||||||
|
delay(PAY_STATUS_POLL_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 二维码弹框关闭时停止 HTTP 轮询,WebSocket 监听仍保持有效。 */
|
||||||
|
fun stopPayStatusPolling() {
|
||||||
|
paymentPollingJob?.cancel()
|
||||||
|
paymentPollingJob = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onPayDialogDismissed() {
|
||||||
|
stopPayStatusPolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun queryPayStatus(orderNumber: String) {
|
||||||
|
try {
|
||||||
|
val response = netWorkRepository
|
||||||
|
.getPaySuccessMessage(PaySuccessMessageRequest(orderNumber))
|
||||||
|
.first()
|
||||||
|
|
||||||
|
if (activePaymentOrderNumber != orderNumber || paymentHandled.get()) return
|
||||||
|
if (!response.isSucceeded) {
|
||||||
|
LogUtils.e(
|
||||||
|
TAG,
|
||||||
|
"查询支付状态业务失败 - order_number: $orderNumber, code: ${response.code}, msg: ${response.message}"
|
||||||
|
)
|
||||||
|
recoverPayment(response.message?.takeIf { it.isNotBlank() } ?: "订单已失效,请重新选择照片")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activePaymentOrderNumber != orderNumber) return
|
||||||
|
val message = response.data
|
||||||
|
when (decideFacePayStatus(message?.orderStatus)) {
|
||||||
|
FacePayStatusDecision.CONTINUE_POLLING -> {
|
||||||
|
LogUtils.d(
|
||||||
|
TAG,
|
||||||
|
"订单尚未完成,继续轮询 - order_number: $orderNumber, status: ${message?.orderStatus}, name: ${message?.orderStatusName}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
FacePayStatusDecision.COMPLETE_PAYMENT -> {
|
||||||
|
if (!isValidFacePaySuccessMessage(orderNumber, message)) {
|
||||||
|
LogUtils.e(TAG, "支付完成消息校验失败,等待下一次查询或 WebSocket - order_number: $orderNumber, message: $message")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val paymentData = checkNotNull(message?.data)
|
||||||
|
handlePaymentSuccess(
|
||||||
|
orderNumber = paymentData.orderNumber,
|
||||||
|
captureType = paymentData.captureType,
|
||||||
|
imageIds = paymentData.imageIds.orEmpty(),
|
||||||
|
purchaseMode = paymentData.purchaseMode,
|
||||||
|
source = "HTTP"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
FacePayStatusDecision.STOP_WITH_TERMINAL_STATUS -> {
|
||||||
|
handleTerminalPayStatus(message?.orderStatus, message?.orderStatusName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtils.e(TAG, "查询支付状态异常 - order_number: $orderNumber, error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleTerminalPayStatus(orderStatus: Int?, orderStatusName: String?) {
|
||||||
|
if (!paymentHandled.compareAndSet(false, true)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stopPayStatusPolling()
|
||||||
|
activePaymentOrderNumber = null
|
||||||
|
activeOrder = null
|
||||||
|
_payQrCodeUrl.value = null
|
||||||
|
_dismissPayDialogEvents.tryEmit(Unit)
|
||||||
|
|
||||||
|
val statusText = orderStatusName?.takeIf { it.isNotBlank() } ?: when (orderStatus) {
|
||||||
|
40 -> "已取消"
|
||||||
|
50 -> "已退款"
|
||||||
|
else -> "状态异常"
|
||||||
|
}
|
||||||
|
ToastUtils.show("订单$statusText")
|
||||||
|
retryQuote()
|
||||||
|
LogUtils.i(TAG, "订单进入终态,停止支付轮询 - status: $orderStatus, name: $statusText")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handlePaymentSuccess(
|
||||||
|
orderNumber: String?,
|
||||||
|
captureType: Int?,
|
||||||
|
imageIds: List<Int>,
|
||||||
|
purchaseMode: String?,
|
||||||
|
source: String
|
||||||
|
) {
|
||||||
|
val expectedOrderNumber = activePaymentOrderNumber
|
||||||
|
val order = activeOrder ?: return
|
||||||
|
if (!order.matches(orderNumber, captureType, imageIds, purchaseMode)) {
|
||||||
|
LogUtils.i(TAG, "忽略不匹配的支付成功消息 - source: $source, expected: $expectedOrderNumber")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val knownImageIds = originalResults.map { it.id }.toSet()
|
||||||
|
if (!knownImageIds.containsAll(imageIds)) {
|
||||||
|
LogUtils.e(TAG, "支付成功消息包含未知图片,暂不跳转 - source: $source, image_ids: $imageIds")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!paymentHandled.compareAndSet(false, true)) {
|
||||||
|
LogUtils.d(TAG, "支付成功已处理,忽略重复消息 - source: $source, order_number: $orderNumber")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
LogUtils.d(TAG, "确认支付成功 - source: $source, order_number: $orderNumber")
|
||||||
|
stopPayStatusPolling()
|
||||||
|
_payQrCodeUrl.value = null
|
||||||
|
_dismissPayDialogEvents.tryEmit(Unit)
|
||||||
|
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) }
|
||||||
|
|
||||||
@@ -384,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,16 +13,20 @@ 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
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -35,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 {
|
||||||
@@ -58,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
|
||||||
@@ -90,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
|
||||||
@@ -108,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 开始倒计时
|
* 开始倒计时
|
||||||
*/
|
*/
|
||||||
@@ -119,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,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()
|
||||||
@@ -166,77 +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
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
isCleared ||
|
||||||
|
imageCapture !== capture ||
|
||||||
|
activeCaptureToken != captureToken
|
||||||
|
) {
|
||||||
|
faceCaptureFileStore.delete(photoFile)
|
||||||
|
releaseCaptureGate(captureToken)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
activeCaptureFile = photoFile
|
||||||
|
|
||||||
// 创建输出选项
|
|
||||||
val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build()
|
val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build()
|
||||||
|
|
||||||
// 拍照
|
|
||||||
capture.takePicture(
|
capture.takePicture(
|
||||||
outputOptions,
|
outputOptions,
|
||||||
ContextCompat.getMainExecutor(App.instance),
|
ContextCompat.getMainExecutor(App.instance),
|
||||||
object : ImageCapture.OnImageSavedCallback {
|
object : ImageCapture.OnImageSavedCallback {
|
||||||
override fun onImageSaved(output: ImageCapture.OutputFileResults) {
|
override fun onImageSaved(output: ImageCapture.OutputFileResults) {
|
||||||
// 加载图片为Bitmap,并处理EXIF方向
|
if (
|
||||||
val bitmap = loadBitmapWithExifOrientation(photoFile.absolutePath)
|
isCleared ||
|
||||||
_capturedImage.value = bitmap
|
imageCapture !== capture ||
|
||||||
// 直接调用搜索接口
|
activeCaptureToken != captureToken
|
||||||
searchFaceDirectly(photoFile)
|
) {
|
||||||
|
finishCaptureFile(photoFile, captureToken)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recognizeSavedPhoto(photoFile, captureToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onError(exception: ImageCaptureException) {
|
override fun onError(exception: ImageCaptureException) {
|
||||||
LogUtils.e(TAG, "拍照失败: ${exception.message}")
|
LogUtils.e(TAG, "拍照失败: ${exception.message}")
|
||||||
|
finishCaptureFile(photoFile, captureToken)
|
||||||
|
if (!isCleared && imageCapture === capture) {
|
||||||
ToastUtils.show("拍照失败")
|
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) {
|
||||||
|
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
|
||||||
@@ -247,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:")
|
||||||
@@ -264,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
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -315,6 +358,9 @@ class FaceRecognitionViewModel @Inject constructor(
|
|||||||
localAudioPlayService.playByRoute("face_recognition_failed")
|
localAudioPlayService.playByRoute("face_recognition_failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
// 页面离开时保持协程取消语义,避免误显示 "Job was cancelled"
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
LogUtils.e("人脸识别失败: ${e.message}")
|
LogUtils.e("人脸识别失败: ${e.message}")
|
||||||
ToastUtils.show("识别失败: ${e.message}")
|
ToastUtils.show("识别失败: ${e.message}")
|
||||||
@@ -342,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)
|
||||||
@@ -362,82 +408,20 @@ class FaceRecognitionViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private var isOpeningRecentPhotos = false
|
||||||
* 加载Bitmap并处理EXIF方向信息
|
|
||||||
* 解决不同设备拍照后图片方向不正确的问题
|
fun browseRecentPhotos() {
|
||||||
*/
|
if (_recognitionStatus.value != RecognitionStatus.FAILED || isOpeningRecentPhotos) return
|
||||||
private fun loadBitmapWithExifOrientation(imagePath: String): Bitmap? {
|
isOpeningRecentPhotos = true
|
||||||
return try {
|
countdownJob?.cancel()
|
||||||
// 先读取EXIF方向信息
|
autoCaptureJob?.cancel()
|
||||||
val exif = ExifInterface(imagePath)
|
toPage(
|
||||||
val orientation = exif.getAttributeInt(
|
AppRoutes.buildRecentPhotosRoute(),
|
||||||
ExifInterface.TAG_ORIENTATION,
|
NavOptions.Builder()
|
||||||
ExifInterface.ORIENTATION_NORMAL
|
.setPopUpTo(AppRoutes.FACE_RECOGNITION, inclusive = true)
|
||||||
|
.setLaunchSingleTop(true)
|
||||||
|
.build(),
|
||||||
)
|
)
|
||||||
|
|
||||||
// 加载原始Bitmap
|
|
||||||
val bitmap = BitmapFactory.decodeFile(imagePath) ?: return null
|
|
||||||
|
|
||||||
// 根据EXIF方向旋转Bitmap
|
|
||||||
val rotationDegrees = when (orientation) {
|
|
||||||
ExifInterface.ORIENTATION_ROTATE_90 -> 90f
|
|
||||||
ExifInterface.ORIENTATION_ROTATE_180 -> 180f
|
|
||||||
ExifInterface.ORIENTATION_ROTATE_270 -> 270f
|
|
||||||
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> {
|
|
||||||
// 水平翻转
|
|
||||||
val matrix = Matrix()
|
|
||||||
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() {
|
||||||
@@ -469,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
package com.yzx.kiosk.ui.upload.viewmodel
|
package com.yzx.kiosk.ui.upload.viewmodel
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import androidx.lifecycle.viewModelScope
|
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.navigation.routes.AppRoutes
|
||||||
|
import com.yzx.kiosk.network.di.NetworkModule.CLIENT_DOWNLOAD
|
||||||
import com.yzx.kiosk.network.model.request.PrintCompleteRequest
|
import com.yzx.kiosk.network.model.request.PrintCompleteRequest
|
||||||
import com.yzx.kiosk.network.model.request.PrintNotifyRequest
|
import com.yzx.kiosk.network.model.request.PrintNotifyRequest
|
||||||
import com.yzx.kiosk.network.model.request.SaveAlbumUrlRequest
|
import com.yzx.kiosk.network.model.request.SaveAlbumUrlRequest
|
||||||
@@ -17,6 +17,7 @@ import com.yzx.kiosk.priter.PrintCallback
|
|||||||
import com.yzx.kiosk.priter.PrintRecord
|
import com.yzx.kiosk.priter.PrintRecord
|
||||||
import com.yzx.kiosk.priter.PrintStatusManager
|
import com.yzx.kiosk.priter.PrintStatusManager
|
||||||
import com.yzx.kiosk.priter.PrinterService
|
import com.yzx.kiosk.priter.PrinterService
|
||||||
|
import com.yzx.kiosk.priter.PrintImageIntegrityValidator
|
||||||
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 com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
@@ -35,9 +36,12 @@ import kotlinx.coroutines.flow.first
|
|||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import java.net.URL
|
import okhttp3.CacheControl
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Named
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class PrintingViewModel @Inject constructor(
|
class PrintingViewModel @Inject constructor(
|
||||||
@@ -46,7 +50,8 @@ class PrintingViewModel @Inject constructor(
|
|||||||
private val printerService: PrinterService,
|
private val printerService: PrinterService,
|
||||||
private val appStoreDataSource: AppStoreDataSource,
|
private val appStoreDataSource: AppStoreDataSource,
|
||||||
private val printStatusManager: PrintStatusManager,
|
private val printStatusManager: PrintStatusManager,
|
||||||
private val netWorkRepository: NetWorkRepository
|
private val netWorkRepository: NetWorkRepository,
|
||||||
|
@Named(CLIENT_DOWNLOAD) private val printDownloadClient: OkHttpClient
|
||||||
) : BaseViewModel(
|
) : BaseViewModel(
|
||||||
navigator = navigator,
|
navigator = navigator,
|
||||||
appState = appState
|
appState = appState
|
||||||
@@ -55,6 +60,7 @@ class PrintingViewModel @Inject constructor(
|
|||||||
private const val TAG = "PrintingViewModel"
|
private const val TAG = "PrintingViewModel"
|
||||||
private const val PRINT_TIME_PER_PHOTO = 15 // 每张照片预计打印时间(秒)
|
private const val PRINT_TIME_PER_PHOTO = 15 // 每张照片预计打印时间(秒)
|
||||||
private const val COUNTDOWN_AFTER_PRINT = 90 // 打印完成后倒计时(秒)
|
private const val COUNTDOWN_AFTER_PRINT = 90 // 打印完成后倒计时(秒)
|
||||||
|
private const val IMAGE_DOWNLOAD_RETRY_COUNT = 3
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -502,18 +508,65 @@ class PrintingViewModel @Inject constructor(
|
|||||||
* 下载网络图片
|
* 下载网络图片
|
||||||
*/
|
*/
|
||||||
private suspend fun downloadImage(url: String): Bitmap? = withContext(Dispatchers.IO) {
|
private suspend fun downloadImage(url: String): Bitmap? = withContext(Dispatchers.IO) {
|
||||||
|
val logUrl = url.substringBefore('?')
|
||||||
|
|
||||||
|
for (attempt in 1..IMAGE_DOWNLOAD_RETRY_COUNT) {
|
||||||
try {
|
try {
|
||||||
val connection = URL(url).openConnection()
|
val request = Request.Builder()
|
||||||
connection.connectTimeout = 10000
|
.url(url)
|
||||||
connection.readTimeout = 10000
|
.get()
|
||||||
connection.getInputStream().use { inputStream ->
|
.cacheControl(CacheControl.FORCE_NETWORK)
|
||||||
BitmapFactory.decodeStream(inputStream)
|
.build()
|
||||||
|
|
||||||
|
printDownloadClient.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
throw java.io.IOException("HTTP ${response.code}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val body = response.body ?: throw java.io.IOException("响应体为空")
|
||||||
|
val declaredLength = body.contentLength()
|
||||||
|
if (declaredLength > PrintImageIntegrityValidator.MAX_DOWNLOAD_BYTES) {
|
||||||
|
throw java.io.IOException("图片超过大小上限: $declaredLength bytes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// bytes() 会完整读取响应;服务端声明了 Content-Length 时,OkHttp 会校验实际长度。
|
||||||
|
val bytes = body.bytes()
|
||||||
|
if (declaredLength >= 0L && declaredLength != bytes.size.toLong()) {
|
||||||
|
throw java.io.IOException(
|
||||||
|
"响应长度不一致: declared=$declaredLength, actual=${bytes.size}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val decoded = PrintImageIntegrityValidator.decodeDownloadedImage(bytes)
|
||||||
|
LogUtils.d(
|
||||||
|
TAG,
|
||||||
|
"打印图片校验成功 - url=$logUrl, attempt=$attempt, bytes=${bytes.size}, " +
|
||||||
|
"format=${decoded.format}, mime=${decoded.mimeType}, " +
|
||||||
|
"source=${decoded.sourceWidth}x${decoded.sourceHeight}, " +
|
||||||
|
"decoded=${decoded.bitmap.width}x${decoded.bitmap.height}, " +
|
||||||
|
"sample=${decoded.sampleSize}, sha256=${decoded.sha256.take(16)}"
|
||||||
|
)
|
||||||
|
return@withContext decoded.bitmap
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: OutOfMemoryError) {
|
||||||
|
LogUtils.e(TAG, "打印图片解码内存不足 - url=$logUrl, attempt=$attempt")
|
||||||
|
return@withContext null
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
LogUtils.e(TAG, "下载图片失败: $url, ${e.message}")
|
LogUtils.e(
|
||||||
null
|
TAG,
|
||||||
|
"打印图片下载或校验失败 - url=$logUrl, attempt=$attempt/$IMAGE_DOWNLOAD_RETRY_COUNT, error=${e.message}"
|
||||||
|
)
|
||||||
|
if (attempt < IMAGE_DOWNLOAD_RETRY_COUNT) {
|
||||||
|
delay(500L * attempt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LogUtils.e(TAG, "打印图片连续 $IMAGE_DOWNLOAD_RETRY_COUNT 次校验失败,禁止送入打印机 - url=$logUrl")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取二维码 URL
|
* 获取二维码 URL
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.yzx.kiosk.utils
|
||||||
|
|
||||||
|
private val decimalPrice = Regex("[0-9]+\\.[0-9]+")
|
||||||
|
|
||||||
|
/** Display only; also preserves units such as 元/张 and placeholders such as --. */
|
||||||
|
fun formatPriceDisplay(value: String): String = decimalPrice.replace(value) {
|
||||||
|
it.value.toBigDecimal().stripTrailingZeros().toPlainString()
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.yzx.kiosk.websocket
|
||||||
|
|
||||||
|
import com.google.gson.JsonObject
|
||||||
|
|
||||||
|
/** New gateway: code -> data(type=5) -> data(order). Older gateways used a flat data object. */
|
||||||
|
internal fun parsePurchaseCompletedEvent(message: JsonObject): UploadPhotoEvent.PaySuccess? = runCatching {
|
||||||
|
if (message.get("code")?.asString != "5") return null
|
||||||
|
val envelope = message.getAsJsonObject("data") ?: return null
|
||||||
|
val order = if (envelope.has("data")) {
|
||||||
|
if (envelope.get("type")?.asInt != 5) return null
|
||||||
|
envelope.getAsJsonObject("data") ?: return null
|
||||||
|
} else {
|
||||||
|
if (envelope.has("type") && envelope.get("type")?.asInt != 5) return null
|
||||||
|
envelope
|
||||||
|
}
|
||||||
|
val number = order.get("order_number")?.takeUnless { it.isJsonNull }?.asString
|
||||||
|
?.takeIf { it.isNotBlank() } ?: return null
|
||||||
|
val captureType = order.get("capture_type")?.asInt ?: return null
|
||||||
|
if (captureType !in listOf(1, 2)) return null
|
||||||
|
val imageIds = order.get("image_id")?.takeUnless { it.isJsonNull }
|
||||||
|
?.asJsonArray?.map { it.asInt }.orEmpty()
|
||||||
|
if (captureType == 2 && imageIds.isEmpty()) return null
|
||||||
|
UploadPhotoEvent.PaySuccess(
|
||||||
|
orderNumber = number,
|
||||||
|
captureType = captureType,
|
||||||
|
imageIds = imageIds,
|
||||||
|
fileMap = order.get("file_map")?.takeUnless { it.isJsonNull }?.toString().orEmpty(),
|
||||||
|
purchaseMode = order.get("purchase_mode")?.takeUnless { it.isJsonNull }?.asString,
|
||||||
|
)
|
||||||
|
}.getOrNull()
|
||||||
@@ -390,46 +390,13 @@ class WebSocketService @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"5" -> {
|
"5" -> {
|
||||||
// 支付成功消息
|
val event = parsePurchaseCompletedEvent(jsonObject)
|
||||||
LogUtils.d(TAG, ">>> 收到支付成功消息 (code=5) <<<")
|
if (event != null) {
|
||||||
try {
|
|
||||||
val dataElement = jsonObject.get("data")
|
|
||||||
if (dataElement != null && dataElement.isJsonObject) {
|
|
||||||
val dataObj = dataElement.asJsonObject
|
|
||||||
val orderNumber = dataObj.get("order_number")?.asString
|
|
||||||
val captureType = dataObj.get("capture_type")?.asInt
|
|
||||||
|
|
||||||
val imageIdList = mutableListOf<Int>()
|
|
||||||
if (dataObj.has("image_id")) {
|
|
||||||
val imageIdArray = dataObj.get("image_id")
|
|
||||||
if (imageIdArray != null && imageIdArray.isJsonArray) {
|
|
||||||
imageIdArray.asJsonArray.forEach { element ->
|
|
||||||
element.asInt.let { imageIdList.add(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var fileMap = ""
|
|
||||||
if (dataObj.has("file_map")){
|
|
||||||
fileMap = dataObj.get("file_map").toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
LogUtils.d(TAG, "支付成功 - order_number: $orderNumber, capture_type: $captureType, image_id: $imageIdList,fileMap:$fileMap")
|
|
||||||
|
|
||||||
// 发送支付成功事件
|
|
||||||
CoroutineScope(Dispatchers.Main).launch {
|
CoroutineScope(Dispatchers.Main).launch {
|
||||||
_uploadPhotoEvents.emit(
|
_uploadPhotoEvents.emit(event)
|
||||||
UploadPhotoEvent.PaySuccess(
|
|
||||||
orderNumber = orderNumber,
|
|
||||||
captureType = captureType,
|
|
||||||
imageIds = imageIdList,
|
|
||||||
fileMap = fileMap
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
} catch (e: Exception) {
|
LogUtils.e(TAG, "忽略无效的购买完成消息")
|
||||||
LogUtils.e(TAG, "解析支付成功消息失败: ${e.message}")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"4" -> {
|
"4" -> {
|
||||||
@@ -974,6 +941,7 @@ sealed class UploadPhotoEvent {
|
|||||||
val orderNumber: String?,
|
val orderNumber: String?,
|
||||||
val captureType: Int?,
|
val captureType: Int?,
|
||||||
val fileMap:String,
|
val fileMap:String,
|
||||||
val imageIds: List<Int>
|
val imageIds: List<Int>,
|
||||||
|
val purchaseMode: String? = null
|
||||||
) : UploadPhotoEvent()
|
) : UploadPhotoEvent()
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 814 B |
|
Before Width: | Height: | Size: 974 B |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 630 B |
|
Before Width: | Height: | Size: 656 B |
|
Before Width: | Height: | Size: 766 B |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 657 B |
|
Before Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 806 B |
|
Before Width: | Height: | Size: 912 B |
|
Before Width: | Height: | Size: 726 B |
|
Before Width: | Height: | Size: 778 B |
|
Before Width: | Height: | Size: 922 B |
|
Before Width: | Height: | Size: 810 B |
|
Before Width: | Height: | Size: 926 B |
|
Before Width: | Height: | Size: 850 B |
|
Before Width: | Height: | Size: 862 B |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 770 B |
|
Before Width: | Height: | Size: 864 B |
|
Before Width: | Height: | Size: 706 B |
|
Before Width: | Height: | Size: 902 B |
|
Before Width: | Height: | Size: 750 B |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 740 B |
|
Before Width: | Height: | Size: 796 B |
|
Before Width: | Height: | Size: 816 B |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 884 B |
|
Before Width: | Height: | Size: 862 B |
|
Before Width: | Height: | Size: 832 B |
|
Before Width: | Height: | Size: 810 B |
|
Before Width: | Height: | Size: 704 B |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 924 B |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |