feat: add recent photo browsing with source-aware titles and audio

Add recent-photo entry points to face recognition and its result page, preserve selection on back navigation, and reuse photo selection and payment flows. Switch face search to v3 with server defaults. Include cropped guidance audio, request and UI coverage, and verification artifacts.
This commit is contained in:
2026-09-08 15:48:42 +08:00
parent 63944db6fb
commit 1d66bd18d1
24 changed files with 913 additions and 93 deletions
@@ -42,6 +42,7 @@ class LocalAudioPlayService @Inject constructor(
AppRoutes.HOME to R.raw.home_audio,
AppRoutes.FACE_RECOGNITION to R.raw.face_recognition_page,
AppRoutes.FACE_RECOGNITION_RESULT to R.raw.face_recognition_success_result_page,
RECENT_PHOTOS_AUDIO_KEY to R.raw.recent_photos_result_page,
AppRoutes.UPLOAD_PHOTO to R.raw.scan_qrcode_upload_page,
AppRoutes.PHOTO_SELECT to R.raw.upload_unpaid_result_page,
AppRoutes.PRINTING to R.raw.printing_page,
@@ -109,12 +110,12 @@ class LocalAudioPlayService @Inject constructor(
return
}
// 提取基础路由名称(去掉参数部分)
val baseRoute = route.substringBefore("?")
// 同一结果页根据入口选择播报,避免最近照片误播识别成功。
val audioKey = localAudioKeyForRoute(route)
val audioResId = ROUTE_TO_AUDIO_MAP[baseRoute]
val audioResId = ROUTE_TO_AUDIO_MAP[audioKey]
if (audioResId == null) {
LogUtils.d(TAG, "路由 $baseRoute 没有对应的音频文件")
LogUtils.d(TAG, "路由音频 $audioKey 没有对应的音频文件")
return
}
@@ -0,0 +1,19 @@
package com.yzx.kiosk.audio
import com.yzx.kiosk.navigation.routes.AppRoutes
import java.net.URLDecoder
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.FACE_RECOGNITION_RESULT) return baseRoute
val source = route.substringAfter("?", "").substringBefore("#")
.split("&")
.firstOrNull { it.substringBefore("=") == "source" }
?.substringAfter("=", "")
?.let { runCatching { URLDecoder.decode(it, "UTF-8") }.getOrNull() }
return if (source == AppRoutes.RECENT_RESULT_SOURCE) RECENT_PHOTOS_AUDIO_KEY else baseRoute
}
@@ -5,6 +5,8 @@ import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.navigation.NavType
import androidx.navigation.navArgument
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
@@ -140,7 +142,13 @@ fun AppNavHost(
}
// 人脸识别结果页面
composable(route = "${AppRoutes.FACE_RECOGNITION_RESULT}?results={results}") { backStackEntry ->
composable(
route = AppRoutes.FACE_RESULT_PATTERN,
arguments = listOf(
navArgument("results") { type = NavType.StringType; defaultValue = "" },
navArgument("source") { type = NavType.StringType; defaultValue = AppRoutes.FACE_RESULT_SOURCE },
),
) { backStackEntry ->
val resultsParam = backStackEntry.arguments?.getString("results") ?: ""
val results = if (resultsParam.isNotEmpty()) {
try {
@@ -153,7 +161,10 @@ fun AppNavHost(
} else {
emptyList()
}
FaceRecognitionResultScreen(results = results)
FaceRecognitionResultScreen(
results = results,
source = backStackEntry.arguments?.getString("source") ?: AppRoutes.FACE_RESULT_SOURCE,
)
}
// 打印中页面
@@ -12,6 +12,12 @@ object AppRoutes {
const val FACE_RECOGNITION = "face_recognition"
const val FACE_RECOGNITION_RESULT = "face_recognition_result"
const val FACE_RESULT_SOURCE = "face"
const val RECENT_RESULT_SOURCE = "recent"
const val FACE_RESULT_PATTERN = "$FACE_RECOGNITION_RESULT?results={results}&source={source}"
fun buildRecentPhotosRoute(): String = "$FACE_RECOGNITION_RESULT?source=$RECENT_RESULT_SOURCE"
const val AGREEMENT = "agreement"
fun buildPrintingRoute(
@@ -4,6 +4,7 @@ import com.yzx.kiosk.network.model.response.FaceSearchResponse
import okhttp3.MultipartBody
import okhttp3.RequestBody
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Multipart
import retrofit2.http.POST
@@ -11,6 +12,12 @@ import retrofit2.http.Part
import retrofit2.http.Url
interface FaceSearchService {
@GET
suspend fun recentPhotos(
@Url url: String,
@Header("Authorization") authorization: String,
): Response<FaceSearchResponse>
@Multipart
@POST
suspend fun searchFace(
@@ -8,13 +8,14 @@ internal fun buildFaceSearchUrl(baseUrl: String, deviceSn: String): String {
?: throw IllegalArgumentException("Invalid face search base URL")
return parsedBaseUrl.newBuilder()
// Preserve the existing /{sn}/api/search endpoint semantics even if configuration
// Preserve the /{sn}/api/v3/search endpoint semantics even if configuration
// accidentally contains a path or query string.
.encodedPath("/")
.query(null)
.fragment(null)
.addPathSegment(deviceSn)
.addPathSegment("api")
.addPathSegment("v3")
.addPathSegment("search")
.build()
.toString()
@@ -0,0 +1,21 @@
package com.yzx.kiosk.network.service
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
internal fun buildRecentPhotosUrl(baseUrl: String, deviceSn: String): String {
require(deviceSn.isNotBlank()) { "Face box SN is empty" }
val parsedBaseUrl = baseUrl.trim().toHttpUrlOrNull()
?: throw IllegalArgumentException("Invalid recent photos base URL")
// The recent-photos endpoint is unversioned and independent of the face-search API version.
return parsedBaseUrl.newBuilder()
.encodedPath("/")
.query(null)
.fragment(null)
.addPathSegment(deviceSn)
.addPathSegment("api")
.addPathSegment("photos")
.addPathSegment("recent")
.build()
.toString()
}
@@ -26,6 +26,9 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -38,6 +41,8 @@ import coil.compose.SubcomposeAsyncImage
import coil.compose.SubcomposeAsyncImageContent
import coil.compose.SubcomposeAsyncImageScope
import coil.request.ImageRequest
import com.yzx.kiosk.navigation.routes.AppRoutes
import com.yzx.kiosk.ui.face.viewmodel.RecentPhotosState
import com.yzx.kiosk.R
import com.yzx.kiosk.component.appbar.AppTitleBar
import com.yzx.kiosk.component.appbar.FaceBarNoStatusBarPadding
@@ -55,21 +60,35 @@ import kotlinx.coroutines.flow.collect
@Composable
fun FaceRecognitionResultScreen(
results: List<FaceSearchResult> = emptyList(),
source: String = AppRoutes.FACE_RESULT_SOURCE,
viewModel: FaceRecognitionResultViewModel = hiltViewModel()
) {
// 如果传入了results列表,初始化图片列表
LaunchedEffect(results) {
if (results.isNotEmpty()) {
LaunchedEffect(source, results) {
if (source == AppRoutes.RECENT_RESULT_SOURCE) {
viewModel.loadRecentPhotos()
} else if (results.isNotEmpty()) {
viewModel.initPhotoList(results)
}
}
val recentPhotosState by viewModel.recentPhotosState.collectAsState()
val photoList by viewModel.photoList.collectAsState()
val selectedPhotos by viewModel.selectedPhotos.collectAsState()
val pricePerPhoto by viewModel.pricePerPhoto.collectAsState()
val totalPrice by viewModel.totalPrice.collectAsState()
val imageLoadStates by viewModel.imageLoadStates.collectAsState()
var isOpeningRecentPhotos by remember { mutableStateOf(false) }
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) isOpeningRecentPhotos = false
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
val selectedCount = selectedPhotos.size
val isAllSelected = photoList.isNotEmpty() && selectedPhotos.size == photoList.size
@@ -94,7 +113,7 @@ fun FaceRecognitionResultScreen(
Column {
AppTitleBar(
backgroundColor = Color.White,
title = "人脸识别结果",
title = if (source == AppRoutes.RECENT_RESULT_SOURCE) "最近照片" else "人脸识别结果",
isShowBackIcon = true,
onBackClick = { viewModel.navigateBack() },
isShowButtonLine = false
@@ -180,49 +199,93 @@ fun FaceRecognitionResultScreen(
}
}
// 全选行
// Keep selection and navigation as separate click targets in the same toolbar.
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
.clickable { viewModel.toggleSelectAll() },
verticalAlignment = Alignment.CenterVertically
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// 复选框
Box(
modifier = Modifier
.size(24.dp)
.clip(RoundedCornerShape(6.dp))
.background(if (isAllSelected) Color(0xFF0073FF) else Color.White)
.border(
width = 2.dp,
color = if (isAllSelected) Color(0xFF0073FF) else Color(0xFFCCCCCC),
shape = RoundedCornerShape(4.dp)
),
contentAlignment = Alignment.Center
Row(
modifier = Modifier.weight(1f).heightIn(min = 48.dp)
.clickable(enabled = photoList.isNotEmpty()) { viewModel.toggleSelectAll() },
verticalAlignment = Alignment.CenterVertically,
) {
if (isAllSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(24.dp)
)
// 复选框
Box(
modifier = Modifier
.size(24.dp)
.clip(RoundedCornerShape(6.dp))
.background(if (isAllSelected) Color(0xFF0073FF) else Color.White)
.border(
width = 2.dp,
color = if (isAllSelected) Color(0xFF0073FF) else Color(0xFFCCCCCC),
shape = RoundedCornerShape(4.dp)
),
contentAlignment = Alignment.Center
) {
if (isAllSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(24.dp)
)
}
}
Spacer(modifier = Modifier.width(18.dp))
Text(
text = "全选",
fontSize = 24.sp,
color = Color.Black
)
Text(
text = "(共${photoList.size}张)",
fontSize = 24.sp,
color = Color.Black
)
}
if (source != AppRoutes.RECENT_RESULT_SOURCE) {
TextButton(
onClick = {
if (!isOpeningRecentPhotos) {
isOpeningRecentPhotos = true
viewModel.browseRecentPhotos()
}
},
enabled = !isOpeningRecentPhotos,
modifier = Modifier.heightIn(min = 48.dp),
contentPadding = PaddingValues(horizontal = 6.dp),
colors = ButtonDefaults.textButtonColors(contentColor = Color(0xFF2878CE)),
) {
Text("查看最近照片", fontSize = 16.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.width(8.dp))
Text("›", fontSize = 20.sp)
}
}
}
Spacer(modifier = Modifier.width(18.dp))
Text(
text = "全选",
fontSize = 24.sp,
color = Color.Black
)
Text(
text = "(共${photoList.size}张)",
fontSize = 24.sp,
color = Color.Black
)
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) }
}
}
}
}
// 图片瀑布流列表
@@ -87,7 +87,8 @@ fun FaceRecognitionScreen(
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 48.dp, vertical = 36.dp)
.padding(horizontal = 48.dp)
.padding(top = 36.dp)
.aspectRatio(0.92f),
shape = RoundedCornerShape(36.dp),
colors = CardDefaults.cardColors(containerColor = Color.Black)
@@ -233,7 +234,22 @@ fun FaceRecognitionScreen(
}
}
Spacer(modifier = Modifier.height(36.dp))
Box(
modifier = Modifier.fillMaxWidth().height(72.dp),
contentAlignment = Alignment.Center,
) {
if (recognitionStatus == RecognitionStatus.FAILED) {
TextButton(
onClick = viewModel::browseRecentPhotos,
modifier = Modifier.heightIn(min = 48.dp),
colors = ButtonDefaults.textButtonColors(contentColor = Color(0xFF2878CE)),
) {
Text("找不到自己?查看最近照片", fontSize = 16.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.width(8.dp))
Text("›", fontSize = 20.sp)
}
}
}
// 倒计时提示
Text(
@@ -9,6 +9,8 @@ import com.yzx.kiosk.network.model.request.GetPayUrlRequest
import com.yzx.kiosk.network.model.request.PaySuccessMessageRequest
import com.yzx.kiosk.network.model.request.VerifyResultRequest
import com.yzx.kiosk.network.model.response.FaceSearchResult
import com.yzx.kiosk.network.service.FaceSearchService
import com.yzx.kiosk.network.service.buildRecentPhotosUrl
import com.yzx.kiosk.network.repository.NetWorkRepository
import com.yzx.kiosk.network.result.asResult
import com.yzx.kiosk.ui.upload.viewmodel.FileMapData
@@ -43,12 +45,39 @@ class FaceRecognitionResultViewModel @Inject constructor(
navigator: AppNavigator,
appState: AppState,
private val netWorkRepository: NetWorkRepository,
private val faceSearchService: FaceSearchService,
private val webSocketService: WebSocketService,
private val appStoreDataSource: AppStoreDataSource,
) : BaseViewModel(
navigator = navigator,
appState = appState
) {
private val recentLoader = RecentPhotosLoader(viewModelScope) {
val baseUrl = if (appStoreDataSource.getUseLan()) {
appStoreDataSource.getBindBoxLanUrl()
} else {
appStoreDataSource.getBindBoxUrl()
}
val response = faceSearchService.recentPhotos(
buildRecentPhotosUrl(baseUrl, appStoreDataSource.getBindBoxSn()),
"Bearer ${appStoreDataSource.getBindBoxApiToken()}",
)
check(response.isSuccessful) { "Recent photos HTTP ${response.code()}" }
val body = checkNotNull(response.body()) { "Empty recent photos response" }
applyPhotoList(body.results.orEmpty())
_photoList.value.isNotEmpty()
}
val recentPhotosState = recentLoader.state
fun browseRecentPhotos() {
// Deliberately push even though the destination pattern is the same: the two pages
// need separate back-stack entries and ViewModels. Do not use launchSingleTop/popUpTo.
toPage(AppRoutes.buildRecentPhotosRoute())
}
fun loadRecentPhotos() = recentLoader.load()
fun retryRecentPhotos() = recentLoader.load(retry = true)
// 原始结果列表(用于获取图片id)
private var originalResults: List<FaceSearchResult> = emptyList()
@@ -124,46 +153,53 @@ class FaceRecognitionResultViewModel @Inject constructor(
/**
* 初始化图片列表(从人脸识别结果)
*/
private var faceResultsInitialized = false
fun initPhotoList(results: List<FaceSearchResult>) {
viewModelScope.launch {
// 保存原始结果列表
originalResults = results
urlToIdMap.clear()
// Returning from a pushed recent-photos page must retain selection, pricing and image state.
if (faceResultsInitialized) return
faceResultsInitialized = true
viewModelScope.launch { applyPhotoList(results) }
}
// 先使用默认宽高比创建列表
val initialPhotos = results.mapNotNull { result ->
// 使用 thumbnail_oss_url
val imageUrl = if (appStoreDataSource.getUseLan()) result.thumbnailLanUrl else result.thumbnailOssUrl
if (imageUrl.isNullOrEmpty()) {
LogUtils.i("FaceRecognitionResultViewModel", "图片URL为空,跳过")
null
} else {
val trimmedUrl = imageUrl.trim()
// 建立URL到ID的映射
urlToIdMap[trimmedUrl] = result.id
PhotoData(
id = result.id, // 使用图片ID
url = trimmedUrl,
aspectRatio = 1f // 默认1:1,后续会更新为真实宽高比
)
}
private suspend fun applyPhotoList(results: List<FaceSearchResult>) {
// 保存原始结果列表
originalResults = results
urlToIdMap.clear()
// 先使用默认宽高比创建列表
val initialPhotos = results.mapNotNull { result ->
// 使用 thumbnail_oss_url
val imageUrl = if (appStoreDataSource.getUseLan()) result.thumbnailLanUrl else result.thumbnailOssUrl
if (imageUrl.isNullOrEmpty()) {
LogUtils.i("FaceRecognitionResultViewModel", "图片URL为空,跳过")
null
} else {
val trimmedUrl = imageUrl.trim()
// 建立URL到ID的映射
urlToIdMap[trimmedUrl] = result.id
PhotoData(
id = result.id, // 使用图片ID
url = trimmedUrl,
aspectRatio = 1f // 默认1:1,后续会更新为真实宽高比
)
}
if (initialPhotos.isEmpty()) {
LogUtils.i("FaceRecognitionResultViewModel", "没有有效的图片URL")
_photoList.value = emptyList()
_imageLoadStates.value = emptyMap()
return@launch
}
_photoList.value = initialPhotos
// 初始化加载状态
_imageLoadStates.value = initialPhotos.associate { it.url to ImageLoadState() }
// 页面初始化时调用验证接口(空列表,type: 2),获取默认价格
verifyResult(emptyList())
LogUtils.d(TAG, "图片列表初始化完成,共 ${initialPhotos.size} 张,宽高比将在图片加载成功后逐张更新")
}
if (initialPhotos.isEmpty()) {
LogUtils.i("FaceRecognitionResultViewModel", "没有有效的图片URL")
_photoList.value = emptyList()
_imageLoadStates.value = emptyMap()
return
}
_photoList.value = initialPhotos
// 初始化加载状态
_imageLoadStates.value = initialPhotos.associate { it.url to ImageLoadState() }
// 页面初始化时调用验证接口(空列表,type: 2),获取默认价格
verifyResult(emptyList())
LogUtils.d(TAG, "图片列表初始化完成,共 ${initialPhotos.size} 张,宽高比将在图片加载成功后逐张更新")
}
/**
@@ -215,6 +251,7 @@ class FaceRecognitionResultViewModel @Inject constructor(
* 切换全选状态
*/
fun toggleSelectAll() {
if (_photoList.value.isEmpty()) return
val allUrls = _photoList.value.map { it.url }.toSet()
val newSelected = if (_selectedPhotos.value.size == allUrls.size) {
// 当前全选,取消全选
@@ -39,8 +39,6 @@ import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
import javax.inject.Inject
enum class RecognitionStatus {
@@ -316,9 +314,6 @@ class FaceRecognitionViewModel @Inject constructor(
val thresholdValue = 0.45f
val thresholdBody = thresholdValue.toString().toRequestBody("text/plain".toMediaType())
// index_date 参数:当前日期,格式 YYYYMMDD
val dateFormat = SimpleDateFormat("yyyyMMdd", Locale.getDefault())
val indexDate = dateFormat.format(Date())
// Authorization header
val authorization = "Bearer $faceSearchToken"
@@ -330,7 +325,6 @@ class FaceRecognitionViewModel @Inject constructor(
LogUtils.d(TAG, "Parameters:")
LogUtils.d(TAG, "image: ${imageFile.name} (${imageFile.length()} bytes)")
LogUtils.d(TAG, "threshold: $thresholdValue")
LogUtils.d(TAG, "index_date: $indexDate")
LogUtils.d(TAG, "==========================================")
// 调用接口
@@ -339,6 +333,7 @@ class FaceRecognitionViewModel @Inject constructor(
authorization = authorization,
image = imagePart,
threshold = thresholdBody,
// 不传日期,由 V3 服务端使用上海时区当天;人数及人脸大小也使用服务端默认值。
indexDate = null
)
@@ -413,6 +408,22 @@ class FaceRecognitionViewModel @Inject constructor(
}
}
private var isOpeningRecentPhotos = false
fun browseRecentPhotos() {
if (_recognitionStatus.value != RecognitionStatus.FAILED || isOpeningRecentPhotos) return
isOpeningRecentPhotos = true
countdownJob?.cancel()
autoCaptureJob?.cancel()
toPage(
AppRoutes.buildRecentPhotosRoute(),
NavOptions.Builder()
.setPopUpTo(AppRoutes.FACE_RECOGNITION, inclusive = true)
.setLaunchSingleTop(true)
.build(),
)
}
fun privatePolicy() {
val args = mapOf(
AgreementRoutes.AGREEMENT_URL to BuildConfig.BASE_URL + "/pages/oscar/private-policy.html",
@@ -0,0 +1,35 @@
package com.yzx.kiosk.ui.face.viewmodel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
enum class RecentPhotosState { IDLE, LOADING, READY, EMPTY, ERROR }
/** One request per result-page lifetime; retries are explicit and cancellation propagates. */
internal class RecentPhotosLoader(
private val scope: CoroutineScope,
private val fetchAndApply: suspend () -> Boolean,
) {
private val mutableState = MutableStateFlow(RecentPhotosState.IDLE)
val state = mutableState.asStateFlow()
private var job: Job? = null
fun load(retry: Boolean = false) {
if (job?.isActive == true) return
if (mutableState.value != RecentPhotosState.IDLE && !(retry && mutableState.value == RecentPhotosState.ERROR)) return
mutableState.value = RecentPhotosState.LOADING
job = scope.launch {
try {
mutableState.value = if (fetchAndApply()) RecentPhotosState.READY else RecentPhotosState.EMPTY
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
mutableState.value = RecentPhotosState.ERROR
}
}
}
}
Binary file not shown.