176 changed files with 9259 additions and 201 deletions
+2
View File
@@ -0,0 +1,2 @@
/gradlew text eol=lf
/gradlew.bat text eol=crlf
-1
View File
@@ -230,7 +230,6 @@ dependencies {
implementation(libs.androidx.compose.foundation) implementation(libs.androidx.compose.foundation)
implementation(libs.firebase.crashlytics.buildtools)
compileOnly(libs.ksp.gradlePlugin) compileOnly(libs.ksp.gradlePlugin)
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<lint>
<!-- Glide is used only for image views (GlideEngine). No NotificationTarget,
notification posting, or foreground-service calls exist in app sources.
Match this library-only diagnostic; keep other permission errors enabled. -->
<issue id="NotificationPermission">
<ignore regexp=".*usage from com\.bumptech\.glide\.request\.target\.NotificationTarget.*" />
</issue>
</lint>
@@ -19,6 +19,6 @@ class ExampleInstrumentedTest {
fun useAppContext() { fun useAppContext() {
// Context of the app under test. // Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.zhifly.follow", appContext.packageName) assertEquals("com.yzx.kiosk", appContext.packageName)
} }
} }
@@ -44,6 +44,29 @@ class LocalAudioPlaybackTest {
} }
} }
@Test fun electronicCompletionSwitchesToDedicatedClipAndPreservesPrintAudio() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
lateinit var service: LocalAudioPlayService
instrumentation.runOnMainSync { service = LocalAudioPlayService(instrumentation.targetContext) }
try {
instrumentation.runOnMainSync { service.playByRoute(AppRoutes.PRINT_SUCCESS) }
awaitPlaying(service)
assertEquals(R.raw.print_complete_page, service.currentResId.value)
instrumentation.runOnMainSync {
service.playByRoute(AppRoutes.buildElectronicCompletionRoute("AUDIO-TEST-ORDER"))
}
awaitPlaying(service)
assertEquals(R.raw.electronic_complete_page, service.currentResId.value)
instrumentation.runOnMainSync { service.playByRoute(AppRoutes.PRINT_SUCCESS) }
awaitPlaying(service)
assertEquals(R.raw.print_complete_page, service.currentResId.value)
} finally {
instrumentation.runOnMainSync { service.release() }
}
}
private fun awaitPlaying(service: LocalAudioPlayService) { private fun awaitPlaying(service: LocalAudioPlayService) {
val deadline = System.currentTimeMillis() + 5000 val deadline = System.currentTimeMillis() + 5000
while (!service.isPlaying.value && System.currentTimeMillis() < deadline) Thread.sleep(20) while (!service.isPlaying.value && System.currentTimeMillis() < deadline) Thread.sleep(20)
@@ -102,7 +102,7 @@ class RecentPhotosFlowTest {
req.body!!.writeTo(buffer) req.body!!.writeTo(buffer)
val ids = Gson().fromJson(buffer.readUtf8(), com.google.gson.JsonObject::class.java).getAsJsonArray("image_id") val ids = Gson().fromJson(buffer.readUtf8(), com.google.gson.JsonObject::class.java).getAsJsonArray("image_id")
val amount = "${ids.size() * 2}.00" val amount = "${ids.size() * 2}.00"
val extra = electronicAmount?.let { ",\"price_electronic\":\"$it\",\"amount_electronic\":\"$it\"" }.orEmpty() 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}}""" """{"code":100000,"data":{"price_image":"2.00","amount":"$amount"$extra}}"""
} }
req.url.encodedPath.endsWith("get-pay-url") && payFails -> req.url.encodedPath.endsWith("get-pay-url") && payFails ->
@@ -467,7 +467,10 @@ class RecentPhotosFlowTest {
compose.onNodeWithText("二维码加载失败").assertIsDisplayed() compose.onNodeWithText("二维码加载失败").assertIsDisplayed()
qrFails = false qrFails = false
compose.onNodeWithText("重新加载").performClick() compose.onNodeWithText("重新加载").performClick()
compose.waitUntil(10_000) { completionVm!!.qrCodeUrl.value.isNotEmpty() } // URL receipt precedes the background QR bitmap generation.
compose.waitUntil(10_000) {
compose.onAllNodesWithContentDescription("电子版照片下载二维码").fetchSemanticsNodes().isNotEmpty()
}
compose.onNodeWithContentDescription("电子版照片下载二维码").assertIsDisplayed() compose.onNodeWithContentDescription("电子版照片下载二维码").assertIsDisplayed()
compose.onNodeWithText("请在屏幕下方拿取照片").assertDoesNotExist() compose.onNodeWithText("请在屏幕下方拿取照片").assertDoesNotExist()
screenshot("electronic-completion") screenshot("electronic-completion")
@@ -479,12 +482,62 @@ class RecentPhotosFlowTest {
assertEquals(papersBefore, app.appStoreDataSource.getRemainingPaperNum()) assertEquals(papersBefore, app.appStoreDataSource.getRemainingPaperNum())
} }
@Test fun emptyRecentPhotosStillShowConfiguredUnitPrices() {
electronicAmount = "1.00"
compose.setContent { FaceRecognitionResultScreen(source = AppRoutes.RECENT_RESULT_SOURCE, viewModel = resultVm) }
compose.waitUntil(10_000) { resultVm.recentPhotosState.value == RecentPhotosState.EMPTY }
compose.waitForIdle()
assertTrue("Entering recent photos must request prices even when the photo list is empty",
requests.any { it.url.encodedPath.endsWith("verify-result") })
compose.waitUntil(10_000) { !resultVm.quoteLoading.value }
compose.onNodeWithText("打印版 2元/张").assertIsDisplayed()
compose.onNodeWithText("电子版 1元/张").assertIsDisplayed()
compose.onNodeWithText("购买打印照片\n0元").assertIsNotEnabled()
compose.onNodeWithText("购买电子照片\n0元").assertIsNotEnabled()
}
@Test fun recentPhotosSelectionUpdatesBothQuotesWithoutReloadReset() {
electronicAmount = "1.00"
val bitmap = Bitmap.createBitmap(160, 100, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.BLUE) }
val photos = (1..2).map { id ->
val file = File(context.cacheDir, "recent-price-$id.png")
file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
val url = file.toURI().toString()
FaceSearchResult(id, url, url, url, url)
}
recentBody = Gson().toJson(mapOf("count" to 2, "results" to photos))
compose.setContent { FaceRecognitionResultScreen(source = AppRoutes.RECENT_RESULT_SOURCE, viewModel = resultVm) }
compose.waitUntil(10_000) { resultVm.recentPhotosState.value == RecentPhotosState.READY && !resultVm.quoteLoading.value }
compose.onNodeWithText("打印版 2元/张").assertIsDisplayed()
compose.onNodeWithText("电子版 1元/张").assertIsDisplayed()
compose.onNodeWithText("购买电子照片\n0元").assertIsNotEnabled()
assertEquals(1, requests.count { it.url.encodedPath.endsWith("verify-result") })
compose.runOnIdle { resultVm.toggleSelectAll() }
compose.waitUntil(10_000) { !resultVm.quoteLoading.value && resultVm.electronicTotal.value == "2.00" }
compose.onNodeWithText("购买打印照片\n4元").assertIsEnabled()
compose.onNodeWithText("购买电子照片\n2元").assertIsEnabled()
compose.runOnIdle { resultVm.loadRecentPhotos() }
compose.waitForIdle()
assertEquals("4.00", resultVm.totalPrice.value)
assertEquals("2.00", resultVm.electronicTotal.value)
assertEquals(2, requests.count { it.url.encodedPath.endsWith("verify-result") })
compose.runOnIdle { resultVm.toggleSelectAll() }
compose.waitUntil(10_000) { !resultVm.quoteLoading.value && resultVm.totalPrice.value == "0.00" }
compose.onNodeWithText("打印版 2元/张").assertIsDisplayed()
compose.onNodeWithText("电子版 1元/张").assertIsDisplayed()
compose.onNodeWithText("购买电子照片\n0元").assertIsNotEnabled()
}
@Test fun errorCanRetryIntoEmptyState() { @Test fun errorCanRetryIntoEmptyState() {
electronicAmount = "1.00"
recentCode = 403 recentCode = 403
compose.setContent { FaceRecognitionResultScreen(source = AppRoutes.RECENT_RESULT_SOURCE, viewModel = resultVm) } compose.setContent { FaceRecognitionResultScreen(source = AppRoutes.RECENT_RESULT_SOURCE, viewModel = resultVm) }
compose.waitUntil(10_000) { resultVm.recentPhotosState.value == RecentPhotosState.ERROR } compose.waitUntil(10_000) { resultVm.recentPhotosState.value == RecentPhotosState.ERROR }
compose.onNodeWithText("照片加载失败,请重试").assertIsDisplayed() compose.onNodeWithText("照片加载失败,请重试").assertIsDisplayed()
compose.onNodeWithText("购买打印照片\n--元").assertIsNotEnabled() compose.waitUntil(10_000) { !resultVm.quoteLoading.value }
compose.onNodeWithText("打印版 2元/张").assertIsDisplayed()
compose.onNodeWithText("电子版 1元/张").assertIsDisplayed()
compose.onNodeWithText("购买打印照片\n0元").assertIsNotEnabled()
recentCode = 200 recentCode = 200
compose.onNodeWithText("重试").performClick() compose.onNodeWithText("重试").performClick()
compose.waitUntil(10_000) { resultVm.recentPhotosState.value == RecentPhotosState.EMPTY } compose.waitUntil(10_000) { resultVm.recentPhotosState.value == RecentPhotosState.EMPTY }
@@ -47,6 +47,7 @@ class LocalAudioPlayService @Inject constructor(
AppRoutes.PHOTO_SELECT to R.raw.upload_unpaid_result_page, AppRoutes.PHOTO_SELECT to R.raw.upload_unpaid_result_page,
AppRoutes.PRINTING to R.raw.printing_page, AppRoutes.PRINTING to R.raw.printing_page,
AppRoutes.PRINT_SUCCESS to R.raw.print_complete_page, AppRoutes.PRINT_SUCCESS to R.raw.print_complete_page,
ELECTRONIC_COMPLETION_AUDIO_KEY to R.raw.electronic_complete_page,
// 特殊状态页面 // 特殊状态页面
"face_recognition_recognizing" to R.raw.face_recognition_recognizing_page, "face_recognition_recognizing" to R.raw.face_recognition_recognizing_page,
"face_recognition_failed" to R.raw.face_recognition_failed_page, "face_recognition_failed" to R.raw.face_recognition_failed_page,
@@ -112,10 +113,6 @@ class LocalAudioPlayService @Inject constructor(
// 同一结果页根据入口选择播报,避免最近照片误播识别成功。 // 同一结果页根据入口选择播报,避免最近照片误播识别成功。
val audioKey = localAudioKeyForRoute(route) val audioKey = localAudioKeyForRoute(route)
if (audioKey == ELECTRONIC_COMPLETION_AUDIO_KEY) {
stop()
return
}
val audioResId = ROUTE_TO_AUDIO_MAP[audioKey] val audioResId = ROUTE_TO_AUDIO_MAP[audioKey]
if (audioResId == null) { if (audioResId == null) {
@@ -3,7 +3,7 @@ package com.yzx.kiosk.audio
import com.yzx.kiosk.navigation.routes.AppRoutes import com.yzx.kiosk.navigation.routes.AppRoutes
import java.net.URLDecoder import java.net.URLDecoder
internal const val ELECTRONIC_COMPLETION_AUDIO_KEY = "electronic_completion_silent" internal const val ELECTRONIC_COMPLETION_AUDIO_KEY = "electronic_completion"
internal const val RECENT_PHOTOS_AUDIO_KEY = "recent_photos_result" internal const val RECENT_PHOTOS_AUDIO_KEY = "recent_photos_result"
@@ -30,6 +30,7 @@ import kotlinx.coroutines.flow.collectLatest
import java.net.URLDecoder import java.net.URLDecoder
import java.net.URLEncoder import java.net.URLEncoder
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
@Composable @Composable
fun AppNavHost( fun AppNavHost(
navigator: AppNavigator, navigator: AppNavigator,
@@ -74,7 +74,11 @@ class FaceRecognitionResultViewModel @Inject constructor(
toPage(AppRoutes.buildRecentPhotosRoute()) toPage(AppRoutes.buildRecentPhotosRoute())
} }
fun loadRecentPhotos() = recentLoader.load() fun loadRecentPhotos() {
// Pricing belongs to the page, not to a successful/non-empty photo response.
if (recentPhotosState.value == RecentPhotosState.IDLE) verifyResult(emptyList())
recentLoader.load()
}
fun retryRecentPhotos() = recentLoader.load(retry = true) fun retryRecentPhotos() = recentLoader.load(retry = true)
// 原始结果列表(用于获取图片id) // 原始结果列表(用于获取图片id)
@@ -177,6 +181,7 @@ class FaceRecognitionResultViewModel @Inject constructor(
// Returning from a pushed recent-photos page must retain selection, pricing and image state. // Returning from a pushed recent-photos page must retain selection, pricing and image state.
if (faceResultsInitialized) return if (faceResultsInitialized) return
faceResultsInitialized = true faceResultsInitialized = true
verifyResult(emptyList())
viewModelScope.launch { applyPhotoList(results) } viewModelScope.launch { applyPhotoList(results) }
} }
@@ -215,8 +220,6 @@ class FaceRecognitionResultViewModel @Inject constructor(
// 初始化加载状态 // 初始化加载状态
_imageLoadStates.value = initialPhotos.associate { it.url to ImageLoadState() } _imageLoadStates.value = initialPhotos.associate { it.url to ImageLoadState() }
// 页面初始化时调用验证接口(空列表,type: 2),获取默认价格
verifyResult(emptyList())
LogUtils.d(TAG, "图片列表初始化完成,共 ${initialPhotos.size} 张,宽高比将在图片加载成功后逐张更新") LogUtils.d(TAG, "图片列表初始化完成,共 ${initialPhotos.size} 张,宽高比将在图片加载成功后逐张更新")
} }
@@ -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,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 814 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 630 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 656 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 766 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 657 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 806 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 912 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 726 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 778 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 922 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 810 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 926 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 850 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 770 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 864 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 706 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 902 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 750 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 740 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 796 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 816 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 884 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 832 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 810 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 924 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1016 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 822 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 998 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 972 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 874 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 920 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 914 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 966 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 638 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

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