diff --git a/.idea/misc.xml b/.idea/misc.xml index 74dd639..b2c751a 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,4 +1,3 @@ - diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e0896e1..43dc4b9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -102,4 +102,6 @@ dependencies { implementation(libs.androidx.activity.ktx) implementation(libs.androidx.fragment.ktx) + // 图片加载 + implementation(libs.glide) } \ No newline at end of file diff --git a/app/src/main/java/com/sw/dualscreen/activity/MainActivity.kt b/app/src/main/java/com/sw/dualscreen/activity/MainActivity.kt index a7efc8f..0ba683f 100644 --- a/app/src/main/java/com/sw/dualscreen/activity/MainActivity.kt +++ b/app/src/main/java/com/sw/dualscreen/activity/MainActivity.kt @@ -2,11 +2,13 @@ package com.sw.dualscreen.activity import android.content.Context import android.graphics.Outline +import android.graphics.Typeface import android.hardware.display.DisplayManager import android.os.Bundle import android.view.View import android.view.ViewOutlineProvider import android.widget.AdapterView +import android.widget.ArrayAdapter import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity @@ -17,27 +19,41 @@ import androidx.camera.core.ImageCaptureException import androidx.camera.core.Preview import androidx.camera.lifecycle.ProcessCameraProvider import androidx.core.content.ContextCompat +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.GridLayoutManager import com.google.common.util.concurrent.ListenableFuture +import com.sw.dualscreen.R +import com.sw.dualscreen.adapter.GenericItemAdapter +import com.sw.dualscreen.adapter.GridSpacingItemDecoration +import com.sw.dualscreen.adapter.dpToPx import com.sw.dualscreen.databinding.ActivityMainBinding +import com.sw.dualscreen.databinding.ItemFoodInfoBinding import com.sw.dualscreen.ext.dp +import com.sw.dualscreen.model.response.FoodInfo import com.sw.dualscreen.presentation.SecondaryScreenPresentation +import com.sw.dualscreen.sdk.SensorScaleUtils +import com.sw.dualscreen.utils.GlideUtils import com.sw.dualscreen.view.CustomBottomSheetDialog import com.sw.dualscreen.viewmodel.UserViewModel import com.sw.plate.utils.AppUtil import com.sw.plate.utils.ToastUtils import com.sw.plate.utils.arcface.viewmodel.RecognizeViewModel +import kotlinx.coroutines.launch import timber.log.Timber import java.io.File import java.util.concurrent.Executors class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding - private lateinit var cameraProviderFuture: ListenableFuture private val viewModel by viewModels() - private lateinit var imageCapture: ImageCapture + private var imageCapture: ImageCapture? = null private var isAnalyzing = true // 控制是否进行图像分析 private lateinit var presentation: SecondaryScreenPresentation private val executor = Executors.newSingleThreadExecutor() + private lateinit var adapter: GenericItemAdapter + private var checkedItem: FoodInfo? = null + private var imageAnalysis: ImageAnalysis? = null + private var cameraProviderFuture: ListenableFuture? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -54,6 +70,7 @@ class MainActivity : AppCompatActivity() { private fun initData() { viewModel.getEquipmentToken() + presentation.step1() } private fun initView() { @@ -64,12 +81,20 @@ class MainActivity : AppCompatActivity() { } } binding.previewView.clipToOutline = true - binding.tvToSearch.setOnClickListener { - val bottomSheetDialog = CustomBottomSheetDialog.newInstance() + val bottomSheetDialog = CustomBottomSheetDialog.newInstance(viewModel) { + updateCurrentFood(it) + } bottomSheetDialog.show(supportFragmentManager, "CustomBottomSheetDialog") } - binding.spinner.setItems(arrayOf("即放即取", "持续取餐")) + + val spinnerAdapter = ArrayAdapter( + this, + R.layout.item_for_custom_spinner, + arrayOf("即放即取", "持续取餐") + ) + binding.spinner.adapter = spinnerAdapter + binding.spinner.setSelection(0) binding.spinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { override fun onItemSelected( parent: AdapterView<*>?, @@ -84,13 +109,90 @@ class MainActivity : AppCompatActivity() { } } + adapter = createAdapter() + binding.recyclerview.layoutManager = GridLayoutManager(this, 2) + // 添加间距装饰(12dp) + binding.recyclerview.addItemDecoration( + GridSpacingItemDecoration( + spanCount = 2, + spacing = dpToPx(30), + includeEdge = false // 包含边缘间距 + ) + ) + binding.recyclerview.adapter = adapter + binding.tvFoodName.setOnClickListener { + presentation.step3() + } + registerDataChange() } + fun updateCurrentFood(foodInfo: FoodInfo?) { + presentation.updateFood(foodInfo) + if (foodInfo != null) { + binding.tvFoodName.text = foodInfo.foodName + binding.previewView.visibility = View.GONE + binding.ivImg.visibility = View.VISIBLE + GlideUtils.loadRoundCornerImage(this, foodInfo.imgUrl, binding.ivImg, 12) + pauseAnalysis() + } else { + binding.tvFoodName.text = "-" + binding.previewView.visibility = View.VISIBLE + binding.ivImg.visibility = View.GONE + resumeAnalysis() + viewModel.cleanIdentifiedFoodInfoList() + } + } + + private fun registerDataChange() { + lifecycleScope.launch { + viewModel.identifiedFoodInfoList.collect { list -> + binding.recyclerview + adapter.updateData(list) +// presentation.step2() + } + } + viewModel.getIdentifiedFoodList() +// SensorScaleUtils.startScale { weight -> +// Timber.d("weight = $weight") +// if (weight > 0.0) { +// if (canIndentify) { +// viewModel.getIdentifiedFoodList() +// } +// } +// } + } + + private fun createAdapter(): GenericItemAdapter { + return GenericItemAdapter( + items = emptyList(), + bindingInflater = ItemFoodInfoBinding::inflate, + bindCallback = { item, position -> + this.tvName.text = item.foodName + if (item.id != checkedItem?.id) { + this.tvName.typeface = Typeface.defaultFromStyle(Typeface.NORMAL) + this.tvName.setTextColor(resources.getColor(R.color.identify_normal)) + this.llRoot.setBackgroundResource(R.drawable.grid_item_normal) + } else { + this.tvName.typeface = Typeface.defaultFromStyle(Typeface.BOLD) + this.tvName.setTextColor(resources.getColor(R.color.identify_checked)) + this.llRoot.setBackgroundResource(R.drawable.grid_item_checked) + } +// + this.llRoot.setOnClickListener { + Timber.d("itemClick ${item.foodName}, position = $position") + checkedItem = item + adapter.notifyDataSetChanged() + updateCurrentFood(item) + } + } + ) + } private fun setupCamera() { - val cameraProviderFuture = ProcessCameraProvider.getInstance(this) - cameraProviderFuture.addListener({ - val cameraProvider = cameraProviderFuture.get() + Timber.d("setupCamera") + cameraProviderFuture = ProcessCameraProvider.getInstance(this) + cameraProviderFuture!!.addListener({ + val cameraProvider = cameraProviderFuture!!.get() // 1. 创建预览用例 val preview = Preview.Builder() @@ -100,17 +202,16 @@ class MainActivity : AppCompatActivity() { } // 2. 创建图像分析用例(用于副屏显示) - val imageAnalysis = ImageAnalysis.Builder() + 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() - runOnUiThread { - presentation.updateImage(bitmap) // 更新副屏 - } + presentation.updateImage(bitmap) // 更新副屏 } imageProxy.close() // 必须关闭以释放资源 } @@ -139,6 +240,48 @@ class MainActivity : AppCompatActivity() { }, ContextCompat.getMainExecutor(this)) } + fun pauseAnalysis() { + Timber.d("pauseAnalysis") + isAnalyzing = false + imageAnalysis?.clearAnalyzer() + } + + fun resumeAnalysis() { + Timber.d("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() { + Timber.d("shutdownCamera") + try { + // 1. 首先停止图像分析 + imageAnalysis?.clearAnalyzer() + + // 2. 解除所有绑定 + cameraProviderFuture?.get()?.unbindAll() + + // 3. 关闭相关资源 + imageCapture = null + imageAnalysis = null + + // 4. 停止副屏更新 + isAnalyzing = false + } catch (e: Exception) { + Timber.e(e, "Error shutting down camera") + } + } + // 拍照功能 private fun takePhoto() { // 临时停止分析以避免干扰 @@ -151,7 +294,7 @@ class MainActivity : AppCompatActivity() { ) ).build() // 执行拍照 - imageCapture.takePicture( + imageCapture?.takePicture( outputFileOptions, executor, object : ImageCapture.OnImageSavedCallback { @@ -191,8 +334,18 @@ class MainActivity : AppCompatActivity() { viewModel = viewModel, recognizeViewModel = viewModels().value ) - + presentation.setStepChangeCallback { step -> + if (step == 1) { + updateCurrentFood(null) + } + } presentation.show() } } + + override fun onDestroy() { + SensorScaleUtils.closeScale() + presentation.dismiss() + super.onDestroy() + } } \ No newline at end of file diff --git a/app/src/main/java/com/sw/dualscreen/ext/CommonExt.kt b/app/src/main/java/com/sw/dualscreen/ext/CommonExt.kt index c726f74..2bd27a8 100644 --- a/app/src/main/java/com/sw/dualscreen/ext/CommonExt.kt +++ b/app/src/main/java/com/sw/dualscreen/ext/CommonExt.kt @@ -56,7 +56,7 @@ val Float.sp: Float ) // 添加扩展函数 -fun ImageProxy.toBitmap(): Bitmap { +fun ImageProxy.toSafeBitmap(): Bitmap { val yBuffer = planes[0].buffer // Y val uBuffer = planes[1].buffer // U val vBuffer = planes[2].buffer // V diff --git a/app/src/main/java/com/sw/dualscreen/ext/StringExt.kt b/app/src/main/java/com/sw/dualscreen/ext/StringExt.kt new file mode 100644 index 0000000..1d1a1e9 --- /dev/null +++ b/app/src/main/java/com/sw/dualscreen/ext/StringExt.kt @@ -0,0 +1,37 @@ +package com.sw.dualscreen.ext + +/** + * 姓名脱敏处理 + * 2个字:张* + * 多于2个字:张*某 + */ +fun String?.maskName(): String { + if (this == null || this.isEmpty()) return "" + + return when { + length == 2 -> "${this[0]}*" + length > 2 -> "${this[0]}*${this[length - 1]}" + else -> this // 1个字的情况原样返回 + } +} + +/** + * 手机号脱敏处理 + * 将第4-7位替换为* + * 例如:138****1234 + */ +fun String?.maskPhone(): String { + if (this == null || this.isEmpty()) return "" + if (this.length < 11) { + return this + } + + val start = 3 // 第4位(索引从0开始) + val end = 6 // 第7位 + + val sb = StringBuilder(this) + for (i in start..end) { + sb.setCharAt(i, '*') + } + return sb.toString() +} \ No newline at end of file diff --git a/app/src/main/java/com/sw/dualscreen/presentation/SecondaryScreenPresentation.kt b/app/src/main/java/com/sw/dualscreen/presentation/SecondaryScreenPresentation.kt index 89e332c..a18cbe9 100644 --- a/app/src/main/java/com/sw/dualscreen/presentation/SecondaryScreenPresentation.kt +++ b/app/src/main/java/com/sw/dualscreen/presentation/SecondaryScreenPresentation.kt @@ -5,11 +5,11 @@ import android.app.Presentation import android.content.Context import android.content.pm.PackageManager import android.graphics.Bitmap +import android.graphics.Outline import android.graphics.Point import android.hardware.Camera import android.hardware.camera2.CameraDevice import android.hardware.camera2.CameraManager -import android.net.Uri import android.os.Build import android.os.Bundle import android.util.DisplayMetrics @@ -17,16 +17,22 @@ import android.util.Log import android.view.Display import android.view.View import android.view.ViewGroup +import android.view.ViewOutlineProvider import android.view.ViewTreeObserver import android.widget.Toast import androidx.annotation.RequiresApi -import androidx.camera.view.PreviewView import androidx.core.content.ContextCompat import androidx.lifecycle.Observer +import androidx.lifecycle.lifecycleScope import com.arcsoft.face.ErrorInfo import com.sw.dualscreen.R import com.sw.dualscreen.activity.MainActivity +import com.sw.dualscreen.databinding.ItemUserNutritionBinding import com.sw.dualscreen.databinding.PresentationSecondaryScreenBinding +import com.sw.dualscreen.ext.dp +import com.sw.dualscreen.ext.maskName +import com.sw.dualscreen.model.response.FoodInfo +import com.sw.dualscreen.utils.GlideUtils import com.sw.dualscreen.viewmodel.UserViewModel import com.sw.plate.utils.ToastUtils import com.sw.plate.utils.arcface.ConfigUtil @@ -41,6 +47,7 @@ import com.sw.plate.utils.arcface.face.constants.LivenessType import com.sw.plate.utils.arcface.face.model.FacePreviewInfo import com.sw.plate.utils.arcface.face.model.RecognizeConfiguration import com.sw.plate.utils.arcface.viewmodel.RecognizeViewModel +import kotlinx.coroutines.launch import timber.log.Timber /** @@ -50,7 +57,7 @@ class SecondaryScreenPresentation( val activity: MainActivity, context: Context, display: Display, - viewModel: UserViewModel, + val viewModel: UserViewModel, private val onDismissListener: () -> Unit = {}, val recognizeViewModel: RecognizeViewModel ) : Presentation(context, display), ViewTreeObserver.OnGlobalLayoutListener { @@ -60,7 +67,14 @@ class SecondaryScreenPresentation( // private var cameraCaptureSession: CameraCaptureSession? = null private lateinit var cameraManager: CameraManager private var cameraIdList: Array = arrayOf() - lateinit var previewView: PreviewView + + // 当前食物信息 + private var currentFood: FoodInfo? = null + private lateinit var itemUserNutritionBinding: ItemUserNutritionBinding + private lateinit var stepChangeCallback: (Int) -> Unit + + // 当前步骤 + var currentStep: Int = 0 override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -68,9 +82,48 @@ class SecondaryScreenPresentation( binding = PresentationSecondaryScreenBinding.inflate(layoutInflater) setContentView(binding.root) window?.setBackgroundDrawableResource(android.R.color.transparent) + initView() +// binding.includeUserHeat.ivHeat.showText("1070千卡") + registerDataChange() + } - previewView = binding.previewView + private fun registerDataChange() { + activity.lifecycleScope.launch { + viewModel.nutritionData.collect { + Timber.d("registerDataChange nutritionData = $it") + if (it == null) return@collect + itemUserNutritionBinding.tvUserName.text = it.userName.maskName() + itemUserNutritionBinding.tvRecommendHeat.text = + "推荐热量:${it.recommendMin}-${it.recommendMax}" + } + } + } + private fun initView() { + binding.ivImg.outlineProvider = object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + outline.setRoundRect(0, 0, view.width, view.height, 12f.dp) + } + } + binding.ivImg.clipToOutline = true + itemUserNutritionBinding = binding.includeUserNutrition + } + + override fun onDisplayRemoved() { + super.onDisplayRemoved() + onDismissListener() + } + + fun updateImage(bitmap: Bitmap) { + activity.runOnUiThread { + binding.ivImg.setImageBitmap(bitmap) + } + } + + /** + * 启用虹软人脸失败 + */ + private fun setupArcCamera() { cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager // 获取摄像头列表 cameraIdList = cameraManager.cameraIdList @@ -87,42 +140,95 @@ class SecondaryScreenPresentation( initArcView() openRectInfoDraw = true openCamera(cameraIdList.last()) -// binding.includeUserHeat.ivHeat.showText("1070千卡") - } - - override fun onDisplayRemoved() { - super.onDisplayRemoved() - onDismissListener() - } - - fun updateImage(bitmap: Bitmap) { - activity.runOnUiThread { - binding.ivImg.setImageBitmap(bitmap) - } } /** * 用户1 显示相机预览 */ - fun step1(foodImg: Uri) { + fun step1() { + Timber.d("step1") + currentStep = 1 + stepChangeCallback(currentStep) + binding.tvFoodInfo.text = "菜品识别中" + binding.flPreview.visibility = View.VISIBLE +// binding.previewView.visibility = View.VISIBLE + binding.ivImg.visibility = View.VISIBLE + binding.flFace.visibility = View.GONE + pauseCamera() + binding.tvBottomTip.visibility = View.VISIBLE + itemUserNutritionBinding.clNutritionData.visibility = View.GONE } /** * 显示人脸识别 */ - fun step2() { + fun step2(foodInfo: FoodInfo) { + Timber.d("step2") + currentStep = 2 + stepChangeCallback(currentStep) + currentFood = foodInfo + resumeCamera() + binding.tvFoodInfo.text = foodInfo.foodName + binding.flPreview.visibility = View.GONE + + binding.flFace.visibility = View.VISIBLE + binding.ivFaceBg.visibility = View.VISIBLE + binding.tvFaceTip.visibility = View.VISIBLE + + setupArcCamera() + } + + fun updateFood(foodInfo: FoodInfo?) { + Timber.d("step2 updateFood") + currentFood = foodInfo + if (foodInfo != null) { + if (currentStep != 2) { + step2(foodInfo) + } else { + binding.tvFoodInfo.text = foodInfo.foodName + } + } } /** * 显示识别出的菜品信息 */ fun step3() { + Timber.d("step3") + currentStep = 3 + stepChangeCallback(currentStep) + if (currentFood == null) { + ToastUtils.showToast("选中物品为空3") + return + } + binding.tvFoodInfo.text = currentFood!!.foodName + binding.flPreview.visibility = View.VISIBLE +// binding.previewView.visibility = View.GONE + binding.ivImg.visibility = View.VISIBLE + + GlideUtils.loadRoundCornerImage( + context, + url = currentFood!!.imgUrl, + imageView = binding.ivImg, + radius = 12 + ) + + binding.flFace.visibility = View.GONE + binding.ivFaceBg.visibility = View.GONE + binding.tvFaceTip.visibility = View.GONE + + binding.tvBottomTip.visibility = View.GONE + itemUserNutritionBinding.clNutritionData.visibility = View.VISIBLE + + viewModel.getUserNutritionData( + userId = "187c96f7bf294c08bc1d1e57b90a7210", + foodId = currentFood!!.id!! + ) } - private fun openCamera(cameraId: String) { if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED @@ -158,6 +264,10 @@ class SecondaryScreenPresentation( } } + fun setStepChangeCallback(callback: (Int) -> Unit) { + stepChangeCallback = callback + } + // 虹软人脸配置 ⬇ private var isRecognition = false private var rgbCameraHelper: DualCameraHelper? = null @@ -206,6 +316,9 @@ class SecondaryScreenPresentation( }) recognizeViewModel.recognizeNotice.observe(activity, Observer { notice: String? -> Timber.i("recognizeNotice observe notice = $notice") + if (currentStep == 3 && notice == "leave") { + step1() + } }) recognizeViewModel.recognizeUserId.observe(activity, Observer { userId: String? -> diff --git a/app/src/main/java/com/sw/dualscreen/repository/RemoteRepository.kt b/app/src/main/java/com/sw/dualscreen/repository/RemoteRepository.kt index fbf34a1..3daba10 100644 --- a/app/src/main/java/com/sw/dualscreen/repository/RemoteRepository.kt +++ b/app/src/main/java/com/sw/dualscreen/repository/RemoteRepository.kt @@ -65,7 +65,7 @@ class RemoteRepository constructor( */ suspend fun getRestInfoFoodsByType( restId: String = GlobalData.restId, - type: Int = 1, + type: Int = 0, foodName: String, ): ApiResponse> { return safeApiCall { diff --git a/lib_face/src/main/java/com/sw/plate/sdk/SensorScaleUtils.kt b/app/src/main/java/com/sw/dualscreen/sdk/SensorScaleUtils.kt similarity index 92% rename from lib_face/src/main/java/com/sw/plate/sdk/SensorScaleUtils.kt rename to app/src/main/java/com/sw/dualscreen/sdk/SensorScaleUtils.kt index a7b3912..dea005e 100644 --- a/lib_face/src/main/java/com/sw/plate/sdk/SensorScaleUtils.kt +++ b/app/src/main/java/com/sw/dualscreen/sdk/SensorScaleUtils.kt @@ -1,11 +1,10 @@ -package com.sw.plate.sdk +package com.sw.dualscreen.sdk import android.util.Log -import com.sw.plate.utils.ThreadUtils import com.sw.plate.utils.ToastUtils import com.wabon.wbintelligenthardwaresdk.api.SensorScale import com.wabon.wbintelligenthardwaresdk.api.SensorScale.OnScaleResult -import kotlinx.coroutines.delay +import timber.log.Timber typealias Callback = (Double) -> Unit @@ -65,16 +64,19 @@ object SensorScaleUtils { } this.callback = callback init() + mSensorScale?.zero { + Timber.d("startScale: zero") + } mSensorScale?.openScale(serialPort, baudRate) { open -> isOpened = open - Log.d(TAG, "isOpened = $isOpened") + Timber.d("isOpened = $isOpened") if (open) { if (autoScale) { - ThreadUtils.launchOnIo { - delay(1000) + Thread({ + Thread.sleep(1000) // 打开后需要等待后才能调用,否则会 1001 SDK未初始化 mSensorScale?.startContinuousRead() - } + }).start() } } } diff --git a/app/src/main/java/com/sw/dualscreen/utils/Debouncer.kt b/app/src/main/java/com/sw/dualscreen/utils/Debouncer.kt new file mode 100644 index 0000000..47892d2 --- /dev/null +++ b/app/src/main/java/com/sw/dualscreen/utils/Debouncer.kt @@ -0,0 +1,25 @@ +package com.sw.dualscreen.utils + +class Debouncer(private val delayMillis: Long) { + private var lastActionTime = 0L + + /** + * 执行防抖操作 + * @param action 要执行的操作 + * @return Boolean 是否执行了操作 (true=已执行, false=被防抖) + */ + fun debounce(action: () -> Unit): Boolean { + val currentTime = System.currentTimeMillis() + if (currentTime - lastActionTime >= delayMillis) { + lastActionTime = currentTime + action() + return true + } + return false + } + + // 重置防抖计时 + fun reset() { + lastActionTime = 0L + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sw/dualscreen/utils/GlideUtils.kt b/app/src/main/java/com/sw/dualscreen/utils/GlideUtils.kt new file mode 100644 index 0000000..a0aa45f --- /dev/null +++ b/app/src/main/java/com/sw/dualscreen/utils/GlideUtils.kt @@ -0,0 +1,236 @@ +package com.sw.dualscreen.utils + +import android.content.Context +import android.graphics.Bitmap +import android.widget.ImageView +import androidx.annotation.DrawableRes +import com.bumptech.glide.Glide +import com.bumptech.glide.RequestBuilder +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.load.resource.bitmap.CircleCrop +import com.bumptech.glide.load.resource.bitmap.RoundedCorners +import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.RequestOptions +import com.bumptech.glide.request.target.Target +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.io.File + +/** + * Glide图片加载工具类 + */ +object GlideUtils { + + // 默认配置 + private val defaultOptions = RequestOptions() + .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) + .skipMemoryCache(false) + + /** + * 普通加载图片 + * @param context 上下文 + * @param url 图片地址 + * @param imageView 目标ImageView + * @param placeholderResId 占位图资源ID + * @param errorResId 错误图资源ID + */ + fun loadImage( + context: Context, + url: Any?, + imageView: ImageView, + @DrawableRes placeholderResId: Int = 0, + @DrawableRes errorResId: Int = 0 + ) { + val requestBuilder = Glide.with(context) + .load(url) + .apply(defaultOptions) + + applyPlaceholder(requestBuilder, placeholderResId, errorResId) + .transition(DrawableTransitionOptions.withCrossFade()) + .into(imageView) + } + + /** + * 加载圆形图片 + * @param context 上下文 + * @param url 图片地址 + * @param imageView 目标ImageView + * @param placeholderResId 占位图资源ID + * @param errorResId 错误图资源ID + */ + fun loadCircleImage( + context: Context, + url: Any?, + imageView: ImageView, + @DrawableRes placeholderResId: Int = 0, + @DrawableRes errorResId: Int = 0 + ) { + val requestBuilder = Glide.with(context) + .load(url) + .apply(defaultOptions) + .transform(CircleCrop()) + + applyPlaceholder(requestBuilder, placeholderResId, errorResId) + .into(imageView) + } + + /** + * 加载圆角图片 + * @param context 上下文 + * @param url 图片地址 + * @param imageView 目标ImageView + * @param radius 圆角半径(px) + * @param placeholderResId 占位图资源ID + * @param errorResId 错误图资源ID + */ + fun loadRoundCornerImage( + context: Context, + url: Any?, + imageView: ImageView, + radius: Int, + @DrawableRes placeholderResId: Int = 0, + @DrawableRes errorResId: Int = 0 + ) { + val requestBuilder = Glide.with(context) + .load(url) + .apply(defaultOptions) + .transform(RoundedCorners(radius)) + + applyPlaceholder(requestBuilder, placeholderResId, errorResId) + .into(imageView) + } + + /** + * 加载图片并获取Bitmap + * @param context 上下文 + * @param url 图片地址 + * @param callback 加载回调 + */ + fun loadImageAsBitmap( + context: Context, + url: Any?, + callback: (Bitmap?) -> Unit + ) { + Glide.with(context) + .asBitmap() + .load(url) + .apply(defaultOptions) + .addListener(object : RequestListener { + + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target, + isFirstResource: Boolean + ): Boolean { + callback(null) + return false + } + + override fun onResourceReady( + resource: Bitmap, + model: Any, + target: Target?, + dataSource: DataSource, + isFirstResource: Boolean + ): Boolean { + callback(resource) + return false + } + }) + .submit() + } + + /** + * 清除内存缓存 + * @param context 上下文 + */ + fun clearMemoryCache(context: Context) { + Glide.get(context).clearMemory() + } + + /** + * 清除磁盘缓存 + * @param context 上下文 + */ + fun clearDiskCache(context: Context) { + CoroutineScope(Dispatchers.IO).launch { + Glide.get(context).clearDiskCache() + } + } + + /** + * 获取缓存文件 + * @param context 上下文 + * @param url 图片地址 + * @param callback 回调 + */ + fun getImageCacheFile( + context: Context, + url: String, + callback: (File?) -> Unit + ) { + Glide.with(context) + .downloadOnly() + .load(url) + .addListener(object : RequestListener { + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target, + isFirstResource: Boolean + ): Boolean { + callback(null) + return false + } + + override fun onResourceReady( + resource: File, + model: Any, + target: Target?, + dataSource: DataSource, + isFirstResource: Boolean + ): Boolean { + callback(resource) + return false + } + }) + .submit() + } + + /** + * 暂停请求 + * @param context 上下文 + */ + fun pauseRequests(context: Context) { + Glide.with(context).pauseRequests() + } + + /** + * 恢复请求 + * @param context 上下文 + */ + fun resumeRequests(context: Context) { + Glide.with(context).resumeRequests() + } + + // 私有方法:应用占位图和错误图 + private fun applyPlaceholder( + requestBuilder: RequestBuilder, + @DrawableRes placeholderResId: Int, + @DrawableRes errorResId: Int + ): RequestBuilder { + return requestBuilder.apply { + if (placeholderResId != 0) { + placeholder(placeholderResId) + } + if (errorResId != 0) { + error(errorResId) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sw/dualscreen/view/CustomBottomSheetDialog.kt b/app/src/main/java/com/sw/dualscreen/view/CustomBottomSheetDialog.kt index 4904b8f..3801ee8 100644 --- a/app/src/main/java/com/sw/dualscreen/view/CustomBottomSheetDialog.kt +++ b/app/src/main/java/com/sw/dualscreen/view/CustomBottomSheetDialog.kt @@ -1,14 +1,14 @@ package com.sw.dualscreen.view import android.graphics.Color +import android.graphics.Typeface import android.graphics.drawable.ColorDrawable import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.GridLayoutManager -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.sw.dualscreen.R import com.sw.dualscreen.adapter.GenericItemAdapter @@ -17,8 +17,15 @@ import com.sw.dualscreen.adapter.dpToPx import com.sw.dualscreen.databinding.BottomSheetDialogBinding import com.sw.dualscreen.databinding.ItemSearchFoodInfoBinding import com.sw.dualscreen.model.response.FoodInfo +import com.sw.dualscreen.utils.Debouncer +import com.sw.dualscreen.viewmodel.UserViewModel +import kotlinx.coroutines.launch +import timber.log.Timber -class CustomBottomSheetDialog : BottomSheetDialogFragment() { +class CustomBottomSheetDialog( + val viewModel: UserViewModel, + val itemClickCallback: (FoodInfo) -> Unit +) : BottomSheetDialogFragment() { private lateinit var binding: BottomSheetDialogBinding private lateinit var adapter: GenericItemAdapter private var checkedItem: FoodInfo? = null @@ -34,9 +41,12 @@ class CustomBottomSheetDialog : BottomSheetDialogFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - + val debouncer = Debouncer(3000) + initView() binding.ivSearch.setOnClickListener { - + debouncer.debounce { + viewModel.searchByFoodName(binding.etSearch.text.toString()) + } } } @@ -63,8 +73,11 @@ class CustomBottomSheetDialog : BottomSheetDialogFragment() { } companion object { - fun newInstance(): CustomBottomSheetDialog { - return CustomBottomSheetDialog() + fun newInstance( + viewModel: UserViewModel, + itemClickCallback: (FoodInfo) -> Unit + ): CustomBottomSheetDialog { + return CustomBottomSheetDialog(viewModel, itemClickCallback) } } @@ -75,28 +88,20 @@ class CustomBottomSheetDialog : BottomSheetDialogFragment() { binding.recyclerview.addItemDecoration( GridSpacingItemDecoration( spanCount = 2, - spacing = requireContext().dpToPx(6), + spacing = requireContext().dpToPx(30), includeEdge = false // 包含边缘间距 ) ) binding.recyclerview.adapter = adapter - binding.recyclerview.addOnScrollListener(object : RecyclerView.OnScrollListener() { - override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - super.onScrolled(recyclerView, dx, dy) - val layoutManager = recyclerView.layoutManager as LinearLayoutManager - val lastVisibleItem = layoutManager.findLastVisibleItemPosition() - val totalItems = layoutManager.itemCount + registerDataChange() + } -// if (!viewModel.isLoading && viewModel.canLoadMore -// && lastVisibleItem >= totalItems - 3 -// ) { -// viewModel.getSearchMemberList( -// param = lastText, -// pageNum = viewModel.currentPage -// ) -// } + private fun registerDataChange() { + lifecycleScope.launch { + viewModel.searchFoodInfoList.collect { + adapter.updateData(it) } - }) + } } private fun createAdapter(): GenericItemAdapter { @@ -104,20 +109,30 @@ class CustomBottomSheetDialog : BottomSheetDialogFragment() { items = emptyList(), bindingInflater = ItemSearchFoodInfoBinding::inflate, bindCallback = { item, position -> -// this.tvName.text = item.name.maskName() -// this.tvPhone.text = item.phone.maskPhone() -// if (item.id != checkedItem?.id) { -// this.llRoot.setBackgroundResource(R.drawable.grid_item_bind) -// } else { -// this.llRoot.setBackgroundResource(R.drawable.grid_item_unbind) -// } -// -// this.llRoot.setOnClickListener { -// Timber.d("itemClick ${item.name}, position = $position") -// checkedItem = item -// adapter.notifyDataSetChanged() -// } + this.tvName.text = item.foodName + if (item.id != checkedItem?.id) { + this.tvName.typeface = Typeface.defaultFromStyle(Typeface.NORMAL) + this.tvName.setTextColor(resources.getColor(R.color.search_normal)) + this.llRoot.setBackgroundResource(R.drawable.grid_search_item_normal) + } else { + this.tvName.typeface = Typeface.defaultFromStyle(Typeface.BOLD) + this.tvName.setTextColor(resources.getColor(R.color.search_checked)) + this.llRoot.setBackgroundResource(R.drawable.grid_search_item_checked) + } + + this.llRoot.setOnClickListener { + Timber.d("itemClick ${item.foodName}, position = $position") + checkedItem = item +// viewModel.updateCurrentItem(item) + itemClickCallback(item) + adapter.notifyDataSetChanged() + } } ) } + + override fun dismiss() { + viewModel.cleanSearchFoodInfoList() + super.dismiss() + } } \ No newline at end of file diff --git a/app/src/main/java/com/sw/dualscreen/viewmodel/UserViewModel.kt b/app/src/main/java/com/sw/dualscreen/viewmodel/UserViewModel.kt index 77712fe..ee0f61f 100644 --- a/app/src/main/java/com/sw/dualscreen/viewmodel/UserViewModel.kt +++ b/app/src/main/java/com/sw/dualscreen/viewmodel/UserViewModel.kt @@ -27,16 +27,12 @@ class UserViewModel : BaseViewModel() { private val faceApi: FaceApi = FaceApi() // 识别出的列表 - private val _identifiedFoodInfoList = MutableStateFlow?>(null) - val identifiedFoodInfoList: StateFlow?> = _identifiedFoodInfoList + private val _identifiedFoodInfoList = MutableStateFlow>(emptyList()) + val identifiedFoodInfoList: StateFlow> = _identifiedFoodInfoList // 搜索出的食物列表 - private val _foodInfoList = MutableStateFlow?>(null) - val foodInfoList: StateFlow?> = _foodInfoList - - // 当前食物信息 - private val _currentFoodInfo = MutableStateFlow(null) - val currentFoodInfo: StateFlow = _currentFoodInfo + private val _searchFoodInfoList = MutableStateFlow>(emptyList()) + val searchFoodInfoList: StateFlow> = _searchFoodInfoList // 就餐数据 private val _nutritionData = MutableStateFlow(null) @@ -46,6 +42,7 @@ class UserViewModel : BaseViewModel() { * 获取token */ fun getEquipmentToken(qrcodeId: String = "3ea47dc0-3cf0-3c2f-909c-265a9a65572e") { + Timber.d("getEquipmentToken") launchWithLoading { val response = repository.getEquipmentToken(qrcodeId) if (parseResponse(response)) { @@ -131,35 +128,49 @@ class UserViewModel : BaseViewModel() { Timber.d("resetUserInfo") } - fun identifiedFoodList() { + fun getIdentifiedFoodList() { + Timber.d("identifiedFoodList") launchWithLoading { delay(5000) _identifiedFoodInfoList.value = List(6) { FoodInfo( id = "185225109358109491${it}", foodName = "豆芽炒粉条$it", - imgUrl = "http://101.201.149.156:9002/temp/20221119/62dc2d17-6853-4277-96ed-a4ef4c8654ea_1668840050258.png" + imgUrl = "http://101.201.149.156:9002/temp/20230912/f54b2d76-45e1-4c6b-a7a0-5fd1eace99d1_1694484601879.png" ) } +// _currentFoodInfo.value = _identifiedFoodInfoList.value[0] } } + fun cleanIdentifiedFoodInfoList() { + Timber.d("cleanIdentifiedFoodInfoList") + _identifiedFoodInfoList.value = emptyList() + } + /** * 搜索食物 */ fun searchByFoodName(foodName: String) { + Timber.d("searchByFoodName foodName = $foodName") launchWithLoading { val response = repository.getRestInfoFoodsByType(foodName = foodName) if (parseResponse(response)) { - _foodInfoList.value = response.result + _searchFoodInfoList.value = response.result ?: emptyList() } } } + fun cleanSearchFoodInfoList() { + Timber.d("cleanSearchFoodInfoList") + _searchFoodInfoList.value = emptyList() + } + /** * 获取用户就餐数据 */ fun getUserNutritionData(userId: String, foodId: String) { + Timber.d("getUserNutritionData userId = ${userId}, foodId = $foodId") launchWithLoading { val response = repository.getUserNutritionData(userId = userId, foodId = foodId) if (parseResponse(response)) { @@ -172,6 +183,7 @@ class UserViewModel : BaseViewModel() { * 提交就餐数据 */ fun postUserNutritionData(param: UserNutritionParam) { + Timber.d("postUserNutritionData param = $param") launchWithLoading { val response = repository.postUserNutritionData(param) if (parseResponse(response)) { diff --git a/app/src/main/res/drawable/bg_avatar.png b/app/src/main/res/drawable/bg_avatar.png deleted file mode 100644 index a910cfb..0000000 Binary files a/app/src/main/res/drawable/bg_avatar.png and /dev/null differ diff --git a/app/src/main/res/drawable/bg_detection_selector.xml b/app/src/main/res/drawable/bg_detection_selector.xml index ff573cb..c14e587 100644 --- a/app/src/main/res/drawable/bg_detection_selector.xml +++ b/app/src/main/res/drawable/bg_detection_selector.xml @@ -8,5 +8,5 @@ android:topRightRadius="12dp" /> + android:color="#33343B" /> \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_face.png b/app/src/main/res/drawable/bg_face.png index b280fc3..1fe7afb 100644 Binary files a/app/src/main/res/drawable/bg_face.png and b/app/src/main/res/drawable/bg_face.png differ diff --git a/app/src/main/res/drawable/grid_item_normal.xml b/app/src/main/res/drawable/grid_item_normal.xml index 1daa2e0..edc660b 100644 --- a/app/src/main/res/drawable/grid_item_normal.xml +++ b/app/src/main/res/drawable/grid_item_normal.xml @@ -5,4 +5,7 @@ android:bottomRightRadius="12dp" android:topLeftRadius="12dp" android:topRightRadius="12dp" /> + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_for_custom_spinner.xml b/app/src/main/res/drawable/shape_for_custom_spinner.xml new file mode 100644 index 0000000..865effc --- /dev/null +++ b/app/src/main/res/drawable/shape_for_custom_spinner.xml @@ -0,0 +1,10 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index bd5ccf5..86d8eaf 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,6 +1,5 @@ - + style="@style/customSpinnerStyle" + android:layout_gravity="end" + android:padding="0dp" + android:popupBackground="@drawable/shape_for_custom_spinner" /> @@ -55,6 +55,13 @@ android:layout_width="match_parent" android:layout_height="match_parent" /> + + + android:paddingVertical="40dp"> + android:textSize="36sp" /> \ No newline at end of file diff --git a/app/src/main/res/layout/item_for_custom_spinner.xml b/app/src/main/res/layout/item_for_custom_spinner.xml new file mode 100644 index 0000000..92d9099 --- /dev/null +++ b/app/src/main/res/layout/item_for_custom_spinner.xml @@ -0,0 +1,12 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_search_food_info.xml b/app/src/main/res/layout/item_search_food_info.xml index a392cd4..e6a41e4 100644 --- a/app/src/main/res/layout/item_search_food_info.xml +++ b/app/src/main/res/layout/item_search_food_info.xml @@ -6,7 +6,7 @@ android:background="@drawable/grid_search_item_checked" android:gravity="center" android:orientation="vertical" - android:paddingVertical="42dp"> + android:paddingVertical="35dp"> + android:textSize="30sp" /> \ No newline at end of file diff --git a/app/src/main/res/layout/item_user_heat.xml b/app/src/main/res/layout/item_user_nutrition.xml similarity index 89% rename from app/src/main/res/layout/item_user_heat.xml rename to app/src/main/res/layout/item_user_nutrition.xml index 94fd94f..de095dc 100644 --- a/app/src/main/res/layout/item_user_heat.xml +++ b/app/src/main/res/layout/item_user_nutrition.xml @@ -1,26 +1,28 @@ + android:src="#99CC99" /> + android:layout_height="match_parent" + android:layout_gravity="bottom|center" /> + android:layout_height="match_parent" + android:layout_gravity="bottom|center" /> - - + + + + + + + + + + + android:orientation="vertical" + android:padding="80dp"> + tools:text="羊肉臊子荞面饸饹" /> - - - - - - - - - - - - + android:layout_marginTop="174dp" + android:visibility="visible"> - + + + + + + android:contentDescription="img" + android:visibility="visible" /> + android:layout_height="486dp" + android:layout_marginTop="633dp" + android:background="@drawable/rounded_border_transparent" + android:gravity="center"> - + - - - - + - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 63739fc..7bc1573 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -7,4 +7,9 @@ #ECE7D0 #6498DD #031127 + + #031127 + #6498DD + #FF9900 + #666666 \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index b32edff..a976193 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -60,4 +60,10 @@ 26sp #ffece7d0 + + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 83166ae..e9a4dcd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,6 @@ [versions] agp = "8.10.1" +glide = "4.16.0" kotlin = "2.0.21" coreKtx = "1.10.1" junit = "4.13.2" @@ -24,6 +25,7 @@ lifecycleViewmodelKtx = "2.8.3" lifecycleRuntimeKtx = "2.8.3" [libraries] +glide = { module = "com.github.bumptech.glide:glide", version.ref = "glide" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } diff --git a/lib_face/src/main/java/com/sw/plate/utils/arcface/face/FaceHelper.java b/lib_face/src/main/java/com/sw/plate/utils/arcface/face/FaceHelper.java index 411fdf1..b3c8675 100644 --- a/lib_face/src/main/java/com/sw/plate/utils/arcface/face/FaceHelper.java +++ b/lib_face/src/main/java/com/sw/plate/utils/arcface/face/FaceHelper.java @@ -422,7 +422,7 @@ public class FaceHelper implements FaceListener { if (!contained) { RecognizeInfo recognizeInfo = recognizeInfoMap.remove(key); if (recognizeInfo != null) { - recognizeCallback.onNoticeChanged(""); + recognizeCallback.onNoticeChanged("leave"); // 人脸离开时,通知特征提取线程,避免一直等待活体结果 synchronized (recognizeInfo.getWaitLock()) { recognizeInfo.getWaitLock().notifyAll();