package com.sw.dualscreen.activity import android.annotation.SuppressLint import android.content.Context import android.content.Intent import android.graphics.Bitmap import android.graphics.Outline import android.hardware.display.DisplayManager import android.net.Uri import android.os.Handler import android.os.Looper import android.text.TextUtils import android.view.Display import android.view.View import android.view.ViewOutlineProvider import android.widget.TextView import androidx.activity.OnBackPressedCallback import androidx.activity.viewModels import androidx.camera.core.CameraSelector import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageCapture import androidx.camera.core.ImageCaptureException import androidx.camera.core.Preview import androidx.camera.lifecycle.ProcessCameraProvider import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.common.util.concurrent.ListenableFuture import com.sw.dualscreen.GlobalData import com.sw.dualscreen.GlobalKey import com.sw.dualscreen.adapter.MainFoodListAdapter import com.sw.dualscreen.databinding.ActivityMainBinding import com.sw.dualscreen.databinding.LayoutEatDetailBinding import com.sw.dualscreen.databinding.LayoutMainFoodList2Binding import com.sw.dualscreen.databinding.LayoutMainFoodListBinding import com.sw.dualscreen.dialog.EnvSwitchDialog import com.sw.dualscreen.dialog.RemindDialog import com.sw.dualscreen.ext.dp import com.sw.dualscreen.ext.gone import com.sw.dualscreen.ext.invisible import com.sw.dualscreen.ext.load import com.sw.dualscreen.ext.visible import com.sw.dualscreen.model.response.ChargeModeEvent import com.sw.dualscreen.model.response.FoodInfo import com.sw.dualscreen.model.request.v2.PlaceOrderRequest import com.sw.dualscreen.model.response.PaySuccessEvent import com.sw.dualscreen.model.response.ResetRecognizeEvent import com.sw.dualscreen.model.response.UpdateRefreshEvent import com.sw.dualscreen.model.response.v2.FaceVO import com.sw.dualscreen.model.response.v2.NewFoodInfo import com.sw.dualscreen.model.response.v2.toFoodInfo import com.sw.dualscreen.objbox.Food import com.sw.dualscreen.objbox.FoodModule import com.sw.dualscreen.objbox.FoodModule.IdNameScore import com.sw.dualscreen.objbox.ObjectBox import com.sw.dualscreen.presentation.MainScreenPresentation import com.sw.dualscreen.sdk.SensorScaleUtils import com.sw.dualscreen.socket.TcpClient import com.sw.dualscreen.utils.ActivityManager import com.sw.dualscreen.utils.BitmapSaver import com.sw.dualscreen.utils.Debouncer import com.sw.dualscreen.utils.FoodVectorTool import com.sw.dualscreen.utils.GsonUtils import com.sw.dualscreen.utils.ImageUtil import com.sw.dualscreen.utils.IntervalExecutor import com.sw.dualscreen.utils.SPUtil import com.sw.dualscreen.utils.SpTool import com.sw.dualscreen.view.CustomBottomSheetDialog import com.sw.dualscreen.viewmodel.BaseViewModel import com.sw.dualscreen.viewmodel.NetViewModelV2 import com.sw.plate.utils.Base64 import com.sw.plate.utils.LightManager import com.sw.plate.utils.ToastUtils import com.sw.plate.utils.arcface.facedb.FaceDatabase import com.sw.plate.utils.arcface.facedb.entity.FaceEntity import com.sw.plate.utils.arcface.faceserver.FaceServer import com.sw.plate.utils.arcface.viewmodel.RecognizeViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode import org.json.JSONObject import timber.log.Timber import java.io.File import java.time.LocalDateTime import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.Locale import java.util.concurrent.Executors import kotlin.math.roundToInt /** * 服务员显示界面 */ @SuppressLint("NotifyDataSetChanged") class MainActivity : BaseActivity() { companion object { private const val TAG = "MainActivity" const val TIME_OUT = 5 * 60 * 1000L const val WEIGHT_CHANGE_VALUE = 20 const val WEIGHT_RESET_RECOGNIZE = 10 // 档口按重量计费的最小计费重量(克),净重超过此值才计费/下单 const val MIN_BILLABLE_WEIGHT = 10 } private val viewModel by viewModels() private var imageCapture: ImageCapture? = null var isAnalyzing = true // 控制是否进行图像分析 // private var presentation: SecondaryScreenPresentation? = null private var presentation: MainScreenPresentation? = null private val executor = Executors.newSingleThreadExecutor() private val handler by lazy { Handler(Looper.getMainLooper()) } private val adapter by lazy { MainFoodListAdapter(mutableListOf()).apply { setOnItemClickListener { _, _, position -> if (items[position].isChecked) { return@setOnItemClickListener } items.forEachIndexed { index, info -> items[index].isChecked = index == position } val item = items[position] log("itemClick ${item.foodName}, position = $position") item.photoUri = null checkedItem = item notifyDataSetChanged() resetSelectedFood(item) } } } var isSwitchFood = false /** * 用于切换菜品时重置数据 */ fun resetSelectedFood(foodInfo: FoodInfo) { isSwitchFood = true recognizeViewModel.resetFaceState() updateCurrentFood(foodInfo) if (settlementMode == 0) { //联合支付,切换菜品重新请求接口 presentation?.let { val userId = presentation?.currentUserId if (userId.isNullOrBlank()) { //切换菜品时,用户已离开重新打开识别功能 it.pauseCamera() it.step2FaceRecognizing(foodInfo, true) } else { //切换菜品时,用户未离开,重新请求接口 it.step3ShowRecognizeResult(userId) } } } else { //0-计费,1-不计费 val mode = SPUtil.getInstance().get(GlobalKey.KEY_CHARGE_MODE, 0) //不计费模式切换菜品,重新加载数据 if (mode == 1) { presentation?.isAgainLoadData = false presentation?.loadUnbilledMode() } } } var checkedItem: FoodInfo? = null private var imageAnalysis: ImageAnalysis? = null private var cameraProviderFuture: ListenableFuture? = null private var canIdentify: Boolean = false private var bottomSheetDialog: CustomBottomSheetDialog? = null private var lastWeight: Int = 0 var currentWeight: Int = 0 private var lastPhotoUri: Uri? = null // 最后拍照的图片 private var debouncer = Debouncer(2000) private var isRecognitionFood = true private var isRecognizing = false private var isFirstOpen = true private var isStartRecognize = false //0-联合支付,1-独立支付 var settlementMode: Int = 1 // val settlementMode by lazy { // SPUtil.getInstance().get(GlobalKey.KEY_SETTLEMENT_MODE, 1) // } private lateinit var rvFoodList: RecyclerView private lateinit var tvToSearch: TextView lateinit var layoutEatDetailBinding: LayoutEatDetailBinding override fun getViewModel(): BaseViewModel { return viewModel } override fun inflateViewBinding(): ActivityMainBinding { return ActivityMainBinding.inflate(layoutInflater) } override fun initialize() { super.initialize() settlementMode = SpTool.settlementMode isPageVisible = true // 拦截扫码枪按键事件,防止触发页面按钮点击 registerKeyEvent() addBackEventListener() initView() if (SpTool.firstGetFace) { viewModel.getFacePage(pageNum = 1) } setupCamera() initData() } private fun initLayoutEatDetailBinding() { Timber.tag(TAG).d("initLayoutEatDetailBinding,settlementMode=$settlementMode") binding.flFoodList.removeAllViews() if (settlementMode == 0) { //联合支付 val b = LayoutMainFoodList2Binding.inflate(layoutInflater, binding.flFoodList, true) rvFoodList = b.rvFoodList tvToSearch = b.tvToSearch layoutEatDetailBinding = b.detailInclude layoutEatDetailBinding.llFoodTitle.gone() // layoutEatDetailBinding.root.invisible() } else { //独立支付 val b = LayoutMainFoodListBinding.inflate(layoutInflater, binding.flFoodList, true) rvFoodList = b.rvFoodList tvToSearch = b.tvToSearch layoutEatDetailBinding = b.detailInclude layoutEatDetailBinding.llFoodTitle.gone() layoutEatDetailBinding.root.gone() } tvToSearch.setOnClickListener { bottomSheetDialog = CustomBottomSheetDialog.newInstance(viewModel) { checkedItem = it it.photoUri = null adapter.items.forEachIndexed { index, info -> adapter.items[index].isChecked = false } adapter.notifyDataSetChanged() resetSelectedFood(it) } bottomSheetDialog!!.show(supportFragmentManager, "CustomBottomSheetDialog") } rvFoodList.let { it.layoutManager = GridLayoutManager(this, 2, GridLayoutManager.VERTICAL, false) it.adapter = adapter } } private fun initData() { startFaceTask() } private fun initView() { viewModel.activeEngine() initLayoutEatDetailBinding() updateDateTime() binding.tvTitleTime.setOnClickListener { EnvSwitchDialog(context = this, onEnvChanged = { showWaitingDialog("重置人脸数据中……") lifecycleScope.launch { withContext(Dispatchers.IO) { FaceServer.getInstance().clearAllFaces() ObjectBox.removeAll() } FoodVectorTool.loadAndSaveFoodVector( userViewModel = viewModel, lifecycleScope = lifecycleScope, successBlock = { hideWaitingDialog() }, failureBlock = { hideWaitingDialog() ToastUtils.showToast(it) } ) } viewModel.getFacePage(pageNum = 1, onSuccess = { recognizeViewModel.refreshFaceList() hideWaitingDialog() ToastUtils.showToast("人脸数据已更新") }, onFailure = { errMsg -> hideWaitingDialog() ToastUtils.showToast(errMsg) }) }).show() } binding.ivSetting.setOnClickListener { isRefreshPage = false startActivity(Intent(this, SettingActivity::class.java)) } binding.previewView.outlineProvider = object : ViewOutlineProvider() { override fun getOutline(view: View, outline: Outline) { outline.setRoundRect(0, 0, view.width, view.height, 12f.dp) } } binding.previewView.clipToOutline = true binding.tvFoodName.setOnClickListener { SensorScaleUtils.zero() } binding.btnPay.setOnClickListener { clickPayButton() } } private var recognizeWeight = 0 fun updateCurrentFood(foodInfo: FoodInfo?) { if (foodInfo == null) { binding.tvFoodName.text = "-" binding.previewView.visibility = View.VISIBLE binding.ivImg.visibility = View.GONE resumeAnalysis() return } pauseAnalysis() recognizeWeight = currentWeight log("registerDataChange,副屏updateFood调用前耗时:${System.currentTimeMillis() - startTime}, recognizeWeight = ${recognizeWeight}") startTime = System.currentTimeMillis() presentation?.updateFood(foodInfo) binding.tvFoodName.text = foodInfo.foodName binding.previewView.gone() binding.ivImg.visible() binding.ivImg.load(foodInfo.foodImg) //pauseAnalysis() } fun updateImage(imgUrl: Any?) { binding.ivImg.load(imgUrl) } /** * 联合支付-即放即取模式 */ private fun loadJFJQBySettlement(weight: Int) { log("registerDataChange weight 联合支付-即放即取") //即放即取+联合支付人脸查询订单完成 if (presentation!!.jointPaymentQueryFinish) { log("registerDataChange weight 联合支付-即放即取,人脸查询订单完成") if (weight > WEIGHT_RESET_RECOGNIZE) { //已识别完成人脸并查询到订单数据,增加重量不再重新识别 log("registerDataChange weight 联合支付-即放即取,不再重新识别") return } log("registerDataChange weight 联合支付-即放即取,step1FoodRecognizing") //重量小于等于10克时改为识别中页面 isAnalyzing = false presentation?.step1FoodRecognizing() return } log("registerDataChange weight 联合支付-即放即取,人脸查询订单没有完成") if (weight <= WEIGHT_RESET_RECOGNIZE) { //秤上重量过小,显示识别中页面 log("registerDataChange weight 联合支付-即放即取,重量小于${WEIGHT_RESET_RECOGNIZE}g,step1FoodRecognizing") presentation?.step1FoodRecognizing() return } log("registerDataChange weight 联合支付-即放即取,recognizeByWeight") // val isWeightChange = abs(weight - lastWeight) > 0.02 val isWeightChange = weight - lastWeight > WEIGHT_CHANGE_VALUE if (weight != lastWeight) { log("registerDataChange weight 联合支付-即放即取,recognizeByWeight---00001,weight=$weight,lastWeight=$lastWeight,isWeightChange=$isWeightChange") } recognizeByWeight(weight, isWeightChange) } /** * 联合支付-余量计量模式 */ private fun loadYLJLBySettlement(weight: Int) { log("registerDataChange weight 联合支付-余量计量") if (weight <= WEIGHT_RESET_RECOGNIZE) { log("registerDataChange weight 联合支付-余量计量,重量小于${WEIGHT_RESET_RECOGNIZE}g,step1FoodRecognizing") if (presentation?.currentUserId.isNullOrBlank() .not() && presentation!!.jointPaymentQueryFinish ) { //联合支付+余量计量,已识别人脸的状态下,从秤上拿餐品后,不再读取数据,等待人脸离开提交订单 if (lastWeight != weight) { presentation?.updateWeight(weight) lastWeight = weight } return } //秤上重量过小,显示识别中页面 presentation?.step1FoodRecognizing() return } log("registerDataChange weight 联合支付-余量计量,订单完成状态:${presentation!!.jointPaymentQueryFinish}") //余量计量+联合支付人脸查询订单完成 if (presentation!!.jointPaymentQueryFinish) { log("registerDataChange weight 联合支付-余量计量,updateWeight") if (lastWeight != weight) { presentation?.updateWeight(weight) log("registerDataChange weight 重置lastWeight与weigh:${weight}一致----------------1000006") lastWeight = weight } return } val isWeightChange = weight - lastWeight > WEIGHT_CHANGE_VALUE recognizeByWeight(weight, isWeightChange) } private fun readWeight(weight: Int) { currentWeight = weight log("registerDataChange weight = $weight, lastWeight = $lastWeight") binding.tvShowWeight.let { if (it.text.toString() != weight.toString()) { it.text = "$weight" } } if (settlementMode == 0) { log("registerDataChange weight 联合支付模式") //联合支付------------------------------------------- //0-即放即取,1-余量计量 val mealPickupMode = SPUtil.getInstance().get(GlobalKey.KEY_PICKUP_MODE, 0) ?: 0 if (mealPickupMode == 1) { loadYLJLBySettlement(weight) return } if (presentation?.currentUserId.isNullOrBlank().not()) { //联合支付+即放即取,已识别人脸的状态下,从秤上拿餐品后,不再读取数据,等待人脸离开提交订单 return } loadJFJQBySettlement(weight) return } //独立支付------------------------------------------- //0-即放即取,1-余量计量 val mealPickupMode = SPUtil.getInstance().get(GlobalKey.KEY_PICKUP_MODE, 0) ?: 0 //0-计费,1-不计费 val chargeMode = SPUtil.getInstance().get(GlobalKey.KEY_CHARGE_MODE, 0) if (mealPickupMode == 0 && chargeMode == 1) { //即放即取+不计费模式, if (presentation?.currentUserId.isNullOrBlank().not()) { //已识别人脸的状态下,从秤上拿餐品后,不再读取数据,等待人脸离开提交订单 return } if (weight <= WEIGHT_RESET_RECOGNIZE) { log("registerDataChange weight 秤重量小于${WEIGHT_RESET_RECOGNIZE}g") //检测到秤上没有东西 presentation?.step1FoodRecognizing() return } } if (isPageVisible.not() || isStartRecognize.not()) { if (ActivityManager.currentActivity() is MainActivity) { isPageVisible = true isStartRecognize = true } return } val isWeightChange = weight - lastWeight > WEIGHT_CHANGE_VALUE recognizeByWeight(weight, isWeightChange) } override fun registerDataChange() { super.registerDataChange() SensorScaleUtils.addWeightListener { value -> runOnUiThread { if (foodRecognizeState.not()) { if (value <= WEIGHT_RESET_RECOGNIZE) { presentation?.step1FoodRecognizing() } return@runOnUiThread } readWeight(value) } } } private fun recognizeByWeight(weight: Int, isWeightChange: Boolean, block: () -> Unit = {}) { log("registerDataChange weight 开始识别") if (weight != lastWeight) { val payTypeDesc = if (settlementMode == 0) "联合支付" else "独立支付" log("registerDataChange weight readWeight-${payTypeDesc},weight=$weight,lastWeight=$lastWeight,isWeightChange=$isWeightChange") } // presentation?.updateWeight(weight) if (weight <= WEIGHT_RESET_RECOGNIZE) { log("registerDataChange weight recognizeByWeight---00002,秤重未超过${WEIGHT_RESET_RECOGNIZE}克") //检测到秤上没有东西,重新启动识别菜品 isRecognitionFood = true return } if (isWeightChange && isRecognitionFood) { log("registerDataChange weight recognizeByWeight---00003,mealPickupMode=${presentation?.mealPickupMode}") if (presentation?.mealPickupMode == 1 && checkedItem != null) { isRecognitionFood = false return } debouncer.debounce { log("registerDataChange weight recognizeByWeight---00004--recognizeFood") //保存当前重量 currentWeight = weight recognizeFood() } } else { log("registerDataChange weight recognizeByWeight---block") block() } if (presentation?.mealPickupMode == 1 || settlementMode == 0) { log("registerDataChange weight recognizeByWeight---lastWeight=${lastWeight}, weight=${weight}, checkedItem=${checkedItem}") if (weight - lastWeight > WEIGHT_CHANGE_VALUE && checkedItem == null) { debouncer.debounce { log("registerDataChange weight重新进行菜品识别 ") recognizeFood() } } } } private var startTime = 0L fun recognizeFood() { // 防重入:识别进行中则跳过,避免重量变化触发多次并发识别 if (isRecognizing) { log("recognizeFood 已在识别中,跳过重复调用") return } isRecognizing = true // 立即更新基准重量,缩小后续重量变化触发识别的窗口 lastWeight = currentWeight startTime = System.currentTimeMillis() // LightManager.openRedLight() LightManager.closeRedLight() LightManager.openGreenLight() showWaitingDialog("识别中,请稍后……") lifecycleScope.launch { delay(5000) withContext(Dispatchers.Main) { hideWaitingDialog() } } takePhoto(successCallback = { photoUri -> lifecycleScope.launch { onTakePhotoSuccess(photoUri) } failCount = 0 }, failureCallback = { log("registerDataChange main,拍照异常,请重试") //ToastUtils.showToast("拍照异常,请重试") failCount++ hideWaitingDialog() shutdownCamera() setupCamera() // 释放识别锁,允许递归重试时重新获取 isRecognizing = false binding.root.postDelayed({ recognizeFood() }, 1000) }) } /** * 识别相机异常次数 */ private var failCount = 0 /** * 是否进行识别:true-识别,false-不识别,默认开启识别 */ private var foodRecognizeState = true @SuppressLint("NotifyDataSetChanged") private fun updateFoodInfo(list: MutableList, scoreList: List) { val queryData = GsonUtils.toJson(list) val recData = GsonUtils.toJson(scoreList) log("registerDataChange识别后查询接口数据:$queryData,识别数据:$recData") if (list.isEmpty()) { foodRecognizeState = false isRecognizing = false tvToSearch.let { it.text = "未查询到,手动搜索" it.visible() } return } foodRecognizeState = true isRecognizing = false //查询到菜品信息,同步设置lastWeight=currentWeight lastWeight = currentWeight presentation?.updateWeight(currentWeight) list.forEach { foodInfo -> val scoreItem = scoreList.firstOrNull { it.name == foodInfo.foodName } val score = scoreItem?.score ?: 0.0 foodInfo.score = ((1 - score) * 10000).roundToInt() } //接口已处理排序 // val list2 = try { // val orderList = scoreList.map { it.name.trim() } // list.sortedBy { orderList.indexOf(it.foodName) } // } catch (e: Exception) { // e.printStackTrace() // list // } adapter.submitList(list) rvFoodList.visible() checkedItem = list[0].also { it.photoUri = lastPhotoUri it.isChecked = true } adapter.notifyItemChanged(0) updateCurrentFood(checkedItem) // lastPhotoUri = null LightManager.closeRedLight() tvToSearch.let { it.text = "以上都不是,手动搜索" it.visible() } log("registerDataChange识别完成:visible=${rvFoodList.isVisible},count=${rvFoodList.childCount}") } private fun setupCamera() { log("setupCamera") cameraProviderFuture = ProcessCameraProvider.getInstance(this) cameraProviderFuture!!.addListener({ val cameraProvider = cameraProviderFuture!!.get() // 1. 创建预览用例 val preview = Preview.Builder().build().also { it.setSurfaceProvider(binding.previewView.surfaceProvider) } // 2. 创建图像分析用例(用于副屏显示) imageAnalysis = ImageAnalysis.Builder() .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) // .setTargetResolution(Size(640, 480)) // 降低分辨率减轻负担 .build().also { analysis -> analysis.setAnalyzer(executor) { imageProxy -> // 仅在步骤1时传递 if (isAnalyzing) { val bitmap = imageProxy.toBitmap() presentation?.updateImage(bitmap) // 更新副屏 isStartRecognize = true } imageProxy.close() // 必须关闭以释放资源 } } // 3. 创建拍照用例 imageCapture = ImageCapture.Builder().setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) // .setTargetResolution(Size(1920, 1080)) // 设置拍照分辨率 .build() // 4. 选择摄像头 val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA try { // 解绑所有用例 cameraProvider.unbindAll() // 绑定三个用例到生命周期 cameraProvider.bindToLifecycle( this, cameraSelector, preview, imageAnalysis, imageCapture ) } catch (e: Exception) { log("setupCamera方法异常:${e.message}") } }, ContextCompat.getMainExecutor(this)) } fun pauseAnalysis() { log("pauseAnalysis") isAnalyzing = false imageAnalysis?.clearAnalyzer() } fun resumeAnalysis() { log("resumeAnalysis isAnalyzing = $isAnalyzing") if (isAnalyzing) return // isAnalyzing = true imageAnalysis?.setAnalyzer(executor) { imageProxy -> if (presentation?.currentStep == 1) { val bitmap = imageProxy.toBitmap() presentation?.updateImage(bitmap) } imageProxy.close() } } /** * 停止相机识别 */ private fun shutdownCamera() { log("shutdownCamera") try { // 1. 首先停止图像分析 imageAnalysis?.clearAnalyzer() // 2. 解除所有绑定 cameraProviderFuture?.get()?.unbindAll() // 3. 关闭相关资源 imageCapture = null imageAnalysis = null cameraProviderFuture = null // 4. 停止副屏更新 isAnalyzing = false } catch (e: Exception) { log("Error shutting down camera:${e.message}") } } // 拍照功能 private fun takePhoto(successCallback: (Uri) -> Unit, failureCallback: () -> Unit) { log("takePhoto") // 临时停止分析以避免干扰 isAnalyzing = false // 创建输出选项 val executor = ContextCompat.getMainExecutor(this) val cacheDir = cacheDir val photoFile = File.createTempFile( "IMG_${System.currentTimeMillis()}", ".jpg", cacheDir ) val cacheOutputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build() // 执行拍照 imageCapture?.takePicture( cacheOutputOptions, executor, object : ImageCapture.OnImageSavedCallback { override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) { val savedUri = outputFileResults.savedUri log("takePhoto savedUri = $savedUri") if (savedUri != null) { successCallback(savedUri) } // 恢复图像分析 isAnalyzing = true } override fun onError(exception: ImageCaptureException) { log("takePhoto拍照失败: ${exception.message}") failureCallback() //ToastUtils.showToast("拍照失败: ${exception.message}") // 恢复图像分析 isAnalyzing = true } }) } private fun getSecondaryDisplay(): Display? { val displayManager = getSystemService(Context.DISPLAY_SERVICE) as DisplayManager val displays = displayManager.displays // 查找副屏(通常索引为1) if (displays.size > 1) return displays[1] return null } private val recognizeViewModel by lazy { viewModels().value } private fun setupSecondaryDisplay() { val secondaryDisplay = getSecondaryDisplay() if (secondaryDisplay == null) { ToastUtils.showToast("获取副屏失败") return } // 创建副屏的Presentation presentation = MainScreenPresentation( activity = this, display = secondaryDisplay, userViewModel = viewModel, recognizeViewModel = recognizeViewModel ).apply { setStepChangeCallback { step -> if (step == 1) { log("setStepChangeCallback${step}") updateCurrentFood(null) bottomSheetDialog?.dismiss() } } show() } } @SuppressLint("NotifyDataSetChanged") fun clearRecognizeFood() { log("clearRecognizeFood") rvFoodList.invisible() layoutEatDetailBinding.root.invisible() adapter.submitList(null) tvToSearch.gone() binding.previewView.visible() binding.ivImg.gone() checkedItem = null lastWeight = 0 foodRecognizeState = true isRecognizing = false isSwitchFood = false } private fun updateDateTime() { val formatter = DateTimeFormatter.ofPattern("yyyy年MM月dd日 E", Locale.CHINA) val dateTime = formatter.format(LocalDateTime.now(ZoneId.of("Asia/Shanghai"))) binding.tvTitleTime.text = dateTime } public var isPageVisible = true override fun onResume() { super.onResume() isPageVisible = true // shutdownCamera 后 cameraProviderFuture 置为 null,需重新初始化相机 if (cameraProviderFuture == null) { setupCamera() } // 启动定时器 handler.postDelayed(timeoutRunnable, TIME_OUT) // ToastUtils.showToast("isPageVisible=$isPageVisible") if (isFirstOpen) { //首次 setupSecondaryDisplay() presentation?.step1FoodRecognizing() isFirstOpen = false } else { //副屏未显示重新加载 isRecognitionFood = true log("onResume-00001,isRefreshPage=$isRefreshPage") if (presentation == null || !presentation!!.isShowing) { log("onResume-00002,副屏未显示,加载副屏") lastWeight = 0 setupSecondaryDisplay() presentation?.step1FoodRecognizing() } else { log("onResume-00003,副屏显示,更新副屏, isRefreshPage=$isRefreshPage") if (isRefreshPage) { isRefreshPage = false //已显示副屏,则进行更新 val weight = lastWeight lastWeight = 0 presentation?.pauseCamera() presentation?.hideRecImage() presentation?.step1FoodRecognizing() log("onResume-recognizeByWeight") binding.root.postDelayed({ recognizeByWeight(weight, true) }, 500) } } } //根据付费模式确认是否显示按钮 binding.btnPay.run { //0-计费,1-不计费 val chargeMode = SPUtil.getInstance().get(GlobalKey.KEY_CHARGE_MODE, 0) if (settlementMode == 1 && chargeMode == 0) visible() else gone() } } override fun onPause() { super.onPause() isPageVisible = false // 取消定时器 handler.removeCallbacks(timeoutRunnable) } private var lastTouchTime = 0L private val timeoutRunnable = Runnable { //显示待机页面 presentation?.loadStandbyScreen() } override fun onUserInteraction() { super.onUserInteraction() if (System.currentTimeMillis() - lastTouchTime >= TIME_OUT) { //隐藏待机页面 presentation?.hideStandbyScreen() } // 重置定时器 handler.removeCallbacks(timeoutRunnable) handler.postDelayed(timeoutRunnable, TIME_OUT) lastTouchTime = System.currentTimeMillis() } fun resetTouchTime() { handler.removeCallbacks(timeoutRunnable) handler.postDelayed(timeoutRunnable, TIME_OUT) lastTouchTime = System.currentTimeMillis() } fun createOrder( foodInfo: FoodInfo, foodWeight: Int, eatWeight: Int, eatNum: Int, userId: String?, isMember: Boolean, notEnoughOneBlock: () -> Unit = {}, successBlock: () -> Unit ) { log("foodWeight=$foodWeight,eatWeight=$eatWeight") //0-计费,1-不计费 val chargeMode = SPUtil.getInstance().get(GlobalKey.KEY_CHARGE_MODE, 0) //0-即放即取,1-余量计量 val pickupMode = SPUtil.getInstance().get(GlobalKey.KEY_PICKUP_MODE, 0) ?: 0 // 按重量计费:净重不足最小计费重量时,提示并拦截下单 if (eatWeight < MIN_BILLABLE_WEIGHT) { ToastUtils.showToast("重量不足,无法下单") notEnoughOneBlock() return } showWaitingDialog("加载中,请稍候……") val request = PlaceOrderRequest( deviceId = GlobalData.deviceId, foodId = foodInfo.foodId.toLong(), foodName = foodInfo.foodName ?: "", foodMaterialId = foodInfo.foodMaterialId?.toLongOrNull(), specId = foodInfo.specId?.toLongOrNull(), foodWeight = foodWeight.toBigDecimal(), eatWeight = eatWeight.toBigDecimal(), eatNum = eatNum, userId = userId?.toLongOrNull(), notPay = chargeMode != 0, //即放即取-1,称重-2 mode = if (pickupMode == 1) 1 else 2, paymentFrom = if (settlementMode == 0) 1 else 2, member = isMember, cookOrderId = foodInfo.cookOrderId ) submitOrder(request, successBlock) } private fun submitOrder(request: PlaceOrderRequest, successBlock: () -> Unit) { viewModel.placeOrder( orderRequest = request, onSuccess = { orderId -> runOnUiThread { binding.root.postDelayed({ hideWaitingDialog() if (orderId.isNullOrBlank()) { return@postDelayed } foodOrderId = orderId successBlock() if (checkedItem?.isFromSearch == true) { lifecycleScope.launch { saveFoodVector(checkedItem!!) } } }, 300) } }, onFailure = { errorMsg -> runOnUiThread { hideWaitingDialog() ToastUtils.showToast(errorMsg) } } ) } fun getEatNum(realWeight: Double?, specWeight: Double?): Int { if (realWeight == null || realWeight <= 0.0 || specWeight == null || specWeight <= 0.0) { log("getEatNum:realWeight=$realWeight,specWeight=$specWeight") return 0 } val eatNum = (realWeight / specWeight).roundToInt() log("getEatNum:eatNum=$eatNum,realWeight=$realWeight,specWeight=$specWeight,realWeight/$specWeight=${realWeight / specWeight}}") return if (eatNum < 1) 1 else eatNum; } /** * 读取已配置的餐具重量(克)。 * 用于取餐提交时从就餐重量中扣减,默认 0 表示未配置、不扣减。 */ internal fun getDishWeight(): Int { return SPUtil.getInstance().get(GlobalKey.KEY_DISH_WEIGHT, 0) ?: 0 } private var foodOrderId: String = "" private var isExist = false fun addBackEventListener() { onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { if (isExist.not()) { ToastUtils.showToast("再按一次退出") isExist = true binding.root.postDelayed({ isExist = false }, 2000) return } finish() } }) } @Subscribe(threadMode = ThreadMode.MAIN) fun onUpdateRefreshEvent(event: UpdateRefreshEvent) { isRefreshPage = true checkedItem = null } @Subscribe(threadMode = ThreadMode.MAIN) fun onPaySuccessEvent(event: PaySuccessEvent) { isRefreshPage = true checkedItem = null //0-计费,1-不计费 val chargeMode = SPUtil.getInstance().get(GlobalKey.KEY_CHARGE_MODE, 0) ?: 0 presentation?.hideNutritionView(chargeMode) presentation?.cancel() } @Subscribe(threadMode = ThreadMode.MAIN) fun onResetRecognizeState(event: ResetRecognizeEvent) { // presentation?.step1FoodRecognizing() isRefreshPage = true checkedItem = null } @Subscribe(threadMode = ThreadMode.MAIN) fun onChargeModeEvent(event: ChargeModeEvent) { isRefreshPage = true isStartRecognize = false presentation?.hideNutritionView(event.chargeMode) // presentation?.cancel() if (event.chargeMode == 1) { presentation?.let { it.onStop() it.setupArcCamera() } } } private var isRefreshPage = false // @Subscribe(threadMode = ThreadMode.MAIN) // fun onClickBackEvent(event: ClickBackEvent) { // isRefreshPage = false // } private fun clickPayButton() { log("eatWeight:recognizeWeight=$recognizeWeight, currentWeight=$currentWeight, lastWeight=$lastWeight") if (checkedItem == null) { ToastUtils.showToast("暂无识别数据,请搜索选择") return } //val realWeight = (lastWeight * 1000).roundToInt() //0-即放即取,1-余量计量 val mealPickupMode = SPUtil.getInstance().get(GlobalKey.KEY_PICKUP_MODE, 0) ?: 0 //0-计费,1-不计费 val chargeMode = SPUtil.getInstance().get(GlobalKey.KEY_CHARGE_MODE, 0) ?: 0 // 餐具重量(克),提交就餐数据时从就餐重量中扣减 val dishWeight = getDishWeight() var foodWeight = 0 var eatWeight = 0 if (chargeMode == 0) { //计费 if (mealPickupMode == 0) { //即放即取 eatWeight = currentWeight foodWeight = currentWeight } else { //余量计量 eatWeight = recognizeWeight - currentWeight foodWeight = recognizeWeight log("eatWeight=$eatWeight, recognizeWeight=$recognizeWeight, currentWeight=$currentWeight, lastWeight=$lastWeight") } } else { //不计费 foodWeight = recognizeWeight eatWeight = if (mealPickupMode == 0) { //即放即取 recognizeWeight } else { //余量计量 recognizeWeight - currentWeight } } // 扣减餐具重量(仅影响就餐重量与份数,菜品原始重量 foodWeight 保持不变),不足则归零 val rawEatWeight = eatWeight eatWeight = (rawEatWeight - dishWeight).coerceAtLeast(0) log("clickPayButton dishWeight=$dishWeight, rawEatWeight=$rawEatWeight, deductedEatWeight=$eatWeight") val eatNum = getEatNum(eatWeight.toDouble(), checkedItem!!.specWeight) createOrder( foodInfo = checkedItem!!, foodWeight = foodWeight, eatWeight = eatWeight, eatNum = eatNum, userId = null, isMember = false ) { isRefreshPage = false shutdownCamera() startActivity(Intent(this, PayActivity::class.java).apply { putExtra(PayActivity.FOOD_INFO, checkedItem!!) putExtra(PayActivity.FOOD_EAT_NUM, eatNum) putExtra(PayActivity.FOOD_ORDER_ID, foodOrderId) }) } } /** * 将uri转为文件和向量数据并提交 */ private fun saveFoodVector(foodInfo: FoodInfo) { try { if (lastPhotoUri == null) { return } lifecycleScope.launch(Dispatchers.IO) { uri2File(lastPhotoUri!!) { imageFile, imageVector -> runBlocking { uploadCollectFoodPics(foodInfo, imageFile, imageVector) } } } } catch (e: Exception) { e.printStackTrace() } } /** * 上传菜品信息 */ private suspend fun uploadCollectFoodPics( foodInfo: FoodInfo, imageFile: File?, imageVector: FloatArray? ) { if (imageFile == null || imageVector == null) { log("是否null判断,imageFile == null:${imageFile == null},imageVector == null:${imageVector == null}") return } val foodId = foodInfo.foodId.toLongOrNull() ?: run { loadRemindDialog("foodId 格式错误: ${foodInfo.foodId}") return } val foodName = foodInfo.foodName ?: "" val foodModelVersion = GlobalData.foodModelVersion val vectors = listOf(imageVector) val foodVectorList = vectors.map { it.joinToString( separator = ",", prefix = "[", postfix = "]" ) } val foodVectorJson = foodVectorList.joinToString(separator = ",", prefix = "[", postfix = "]") log("json=$foodVectorJson") val idList = viewModel.uploadCollect( foodId = foodId, foodName = foodName, version = foodModelVersion, foodVector = foodVectorJson, fileList = listOf(imageFile) ) if (idList.isNullOrEmpty()) { loadRemindDialog("上传失败,未返回向量数据id") log("idList为空") return } val all = ObjectBox.getAll() val size = all.filter { it.isDel.not() }.size log("uploadCollectFoodPics-已采集向量总数:${size}条") val filterList = all.filter { it.collectId == idList[0] } if (filterList.isNotEmpty()) { loadRemindDialog("返回的id:${idList[0]}已存在") return } ObjectBox.put( Food( collectId = idList[0], foodId = foodId.toString(), foodName = foodName, foodVector = imageVector, version = foodModelVersion ) ) } private fun loadRemindDialog(msg: String) { runOnUiThread { RemindDialog( context = context, content = msg, confirmBlock = {}).show() } } private fun uri2File(uri: Uri, block: (File?, FloatArray?) -> Unit) { ImageUtil.uriToBitmap(this, uri)?.let { bitmap -> val imageVector = try { FoodModule.bitmap2FloatArray(bitmap, false) } catch (e: Exception) { e.printStackTrace() return@let } val imageFile = BitmapSaver.saveToAppFilesDir( bitmap, this, "IMG_CROP_${System.currentTimeMillis()}.jpg" ) log("${this.javaClass.simpleName}-cameraCallback-裁剪bitmap保存文件路径:${imageFile?.absolutePath}") block(imageFile, imageVector) if (bitmap.isRecycled.not()) { bitmap.recycle() } } } override fun onDestroy() { //SensorScaleUtils.closeScale() faceTaskJob?.cancel() presentation?.cancel() shutdownCamera() super.onDestroy() } private val intervalExecutor by lazy { IntervalExecutor() } private var faceTaskJob: Job? = null private val initialDelay = 60 * 1000L private val dealyMillis = 30 * 1000L // private var taskPageNo = 1 fun startFaceTask() { faceTaskJob = intervalExecutor.startIntervalTaskWithInitialDelay(initialDelay, dealyMillis) { val timestamp = SpTool.lastFaceTimestamp //if (timestamp == 0L) { // return@startIntervalTaskWithInitialDelay //} viewModel.getFaceIncrement( // pageNo = taskPageNo, timestamp = timestamp, onAllQueryFinished = { // taskPageNo = 1 }, onPageQueryFinished = { list -> runOnUiThread { if (list.isEmpty()) { return@runOnUiThread } lifecycleScope.launch { updateFaceData(list) } } }) } } private fun updateFaceData(list: List) { val faceList = mutableListOf() try { list.forEach { vo -> if (vo.faceDeleted == true) { //删除数据 lifecycleScope.launch(Dispatchers.IO) { FaceDatabase.getInstance(this@MainActivity).faceDao().deleteFaceById(vo.userId) } } else { // 保存数据:使用完整构造函数,映射后端全部字段到数据库 val faceEntity = FaceEntity( vo.userId, // userName Base64.decode(vo.faceFeature), // featureData vo.personType ?: if (vo.member == true) "1" else "2", // userType vo.cardNo ?: "", // cardNo vo.userId ?: "", // userId vo.userFaceId ?: "", // userFaceId vo.member ?: false, // member vo.faceUpdateTimestamp?.toLongOrNull() ?: 0L // faceUpdateTimestamp ) faceList.add(faceEntity) } } } catch (e: Exception) { e.printStackTrace() } try { if (faceList.isNotEmpty()) { lifecycleScope.launch(Dispatchers.IO) { FaceDatabase.getInstance(this@MainActivity).faceDao().insert(faceList) } } recognizeViewModel.refreshFaceList(); } catch (e: Exception) { e.printStackTrace() } } private fun sendFaceData(faceData: String) { // 给某个客户端发送 val jsonObject = JSONObject().also { it.put("type", "faceFeature") it.put("content", faceData) } tcpClient?.send(jsonObject) } private var tcpClient: TcpClient? = null private suspend fun onTakePhotoSuccess(photoUri: Uri) { lastPhotoUri = photoUri log("registerDataChange main,takePhoto耗时:${System.currentTimeMillis() - startTime}") startTime = System.currentTimeMillis() log("registerDataChange photoUri = ${photoUri.path}") // viewModel.getIdentifiedFoodList() val bitmap = withContext(Dispatchers.IO) { ImageUtil.uriToBitmap(this@MainActivity, photoUri) } bitmap?.let { queryFoodData(it) } } private suspend fun queryFoodData(bitmap: Bitmap) { log("registerDataChange main,uriToBitmap耗时:${System.currentTimeMillis() - startTime}") startTime = System.currentTimeMillis() log("registerDataChange photoUri 拿到bitmap") // val bmp = BitmapCropper.cropCenter(bitmap, 1300, 900) log("registerDataChange registerDataChange photoUri bitmap裁剪完成") val file = BitmapSaver.saveToAppFilesDir( bitmap, this, "IMG_CROP_${System.currentTimeMillis()}.jpg" ) log("registerDataChange main,saveToAppFilesDir耗时:${System.currentTimeMillis() - startTime}") startTime = System.currentTimeMillis() log("registerDataChange photoUri bitmap保存文件路径:${file?.absolutePath}") //val nameList = FoodModule.queryFood(bitmap) // 推理前同时暂停主屏相机和副屏人脸识别,让 ArcSoft 线程进入空闲后再启动 PyTorch withContext(Dispatchers.Main) { shutdownCamera() presentation?.pauseCamera() } // 等待相机 HAL 在内核层(uvcvideo URB / DMC system_status)完成异步清理 // 实测:unbindAll() 之后 HAL 仍会在 ~200ms 内写 sysfs_dmc/system_status, // 若此时 PyTorch forward() 同时占用大量 DRAM 带宽,RK3588 DMC 频率切换 // 与高带宽访问并发会触发硬件级故障导致设备重启。 // 1500ms 给 HAL 足够时间完成 DMC 带宽 hold 释放后再启动推理。 Timber.tag(TAG).d("queryFoodData: 等待相机HAL完成DMC清理 (1500ms)...") kotlinx.coroutines.delay(1500) Timber.tag(TAG).d("queryFoodData: 延迟结束,开始 forward()") val scoreList = try { FoodModule.getFoodScoreList(bitmap) } finally { withContext(Dispatchers.Main) { setupCamera() } } val foodName = if (scoreList.isNotEmpty()) { val recDataJson = GsonUtils.toJson(scoreList) log("registerDataChange main,getFoodScoreList耗时:${System.currentTimeMillis() - startTime}") startTime = System.currentTimeMillis() log("registerDataChange photoUri 拿到识别数据:$recDataJson") val nameList = scoreList.map { it.name } nameList.joinToString(separator = ",") } else "" hideWaitingDialog() log("registerDataChange photoUri 识别数据名称:$foodName") withContext(Dispatchers.Main) { binding.flPay.run { //0-计费,1-不计费 val chargeMode = SPUtil.getInstance().get(GlobalKey.KEY_CHARGE_MODE, 0) //独立支付+计费模式才显示“去结算”按钮,否则使用人脸识别 if (settlementMode == 1 && chargeMode == 0) visible() else gone() } } if (TextUtils.isEmpty(foodName)) { LightManager.closeGreenLight() LightManager.closeRedLight() //binding.layoutRescan.visibility = View.VISIBLE withContext(Dispatchers.Main) { isRecognizing = false tvToSearch.let { it.text = "未识别到,手动搜索" it.visible() } } } else { //binding.layoutRescan.visibility = View.GONE showWaitingDialog("正在查询菜品信息,请稍后……") viewModel.getFoodByNames(names = foodName, onSuccess = { list -> runOnUiThread { hideWaitingDialog() log("registerDataChange main,getFoodInfo耗时:${System.currentTimeMillis() - startTime}") startTime = System.currentTimeMillis() updateFoodInfo(list.map { it.toFoodInfo() }.toMutableList(), scoreList) } }, onFailure = { errorMsg -> hideWaitingDialog() ToastUtils.showToast(errorMsg) }) } } // @Subscribe(threadMode = ThreadMode.MAIN) // public fun onResetBox(event: ResetBoxEvent) { // ObjectBox.boxStore.close() // ObjectBox.init(this) // } }