feat(food): 实现食材识别和净材类型功能

- 在FoodAdapter中添加选中状态背景和文字颜色变化
- 修改FoodMaterialAdapter显示食材名称时包含净材类型
- 在FoodModule中解析食材名称中的净材类型信息
- 移除食材识别页面的净材类型选择弹窗流程
- 优化食材识别结果处理逻辑和数据映射
- 添加未识别到食材时的提示对话框
- 实现向量采集页面的搜索功能和实时监听
- 优化网络请求的文件上传媒体类型设置
- 调整食材列表项的布局约束和样式配置
This commit is contained in:
2026-05-12 18:15:40 +08:00
parent 5745791041
commit 7e72ce413b
8 changed files with 131 additions and 62 deletions
@@ -25,12 +25,14 @@ class FoodAdapter(list: MutableList<GoodsItem>) :
item: GoodsItem? item: GoodsItem?
) { ) {
item ?: return item ?: return
holder.binding.tvGoodsInfo.run { holder.binding.root.run {
text = item.goodsName
setBackgroundResource( setBackgroundResource(
if (item.isClicked) R.drawable.shape_green_stroke if (item.isClicked) R.drawable.shape_green_stroke
else R.drawable.shape_white_12_corners else R.drawable.shape_white_12_corners
) )
}
holder.binding.tvGoodsInfo.run {
text = item.goodsName
setTextColor( setTextColor(
ContextCompat.getColor( ContextCompat.getColor(
context, context,
@@ -46,6 +48,7 @@ class FoodAdapter(list: MutableList<GoodsItem>) :
it.visible() it.visible()
val foodScoreText = "${(item.foodScore / 100.0).roundedDecimalPlace(2)}%" val foodScoreText = "${(item.foodScore / 100.0).roundedDecimalPlace(2)}%"
it.text = foodScoreText it.text = foodScoreText
} }
} }
if (item.rawMaterialsType.isNullOrBlank()) { if (item.rawMaterialsType.isNullOrBlank()) {
@@ -36,8 +36,11 @@ class FoodMaterialAdapter(list: MutableList<GoodsItem>) :
clBlock.setOnClickListener { clBlock.setOnClickListener {
onItemClick?.invoke(holder.bindingAdapterPosition) onItemClick?.invoke(holder.bindingAdapterPosition)
} }
if (data.isOriginalData) {
tvDishName.text = data.goodsName tvDishName.text = data.goodsName
} else {
tvDishName.text = data.goodsName + "-${data.rawMaterialsType}"
}
tvDishType.text = if (data.materialType == 1) "主辅材:主材" else if (data.materialType == 2) "主辅材:辅材" else "" tvDishType.text = if (data.materialType == 1) "主辅材:主材" else if (data.materialType == 2) "主辅材:辅材" else ""
tvDishWeight.text = tvDishWeight.text =
if (data.useWeight == null || data.useWeight == 0.toDouble()) "" else "${data.useWeight!!.roundedOneDecimalPlace()}" if (data.useWeight == null || data.useWeight == 0.toDouble()) "" else "${data.useWeight!!.roundedOneDecimalPlace()}"
@@ -184,7 +184,7 @@ class NetViewModel(
params: MutableMap<String, RequestBody> params: MutableMap<String, RequestBody>
) { ) {
val fileParts = fileList.map { file -> val fileParts = fileList.map { file ->
file.asRequestBody("multipart/form-data".toMediaTypeOrNull()) file.asRequestBody("image/jpeg".toMediaTypeOrNull())
.let { MultipartBody.Part.createFormData("foodPics", file.name, it) } .let { MultipartBody.Part.createFormData("foodPics", file.name, it) }
} }
viewModelScope.launch { viewModelScope.launch {
@@ -204,6 +204,7 @@ class NetViewModel(
params: MutableMap<String, RequestBody> params: MutableMap<String, RequestBody>
): UiState<List<String>?> { ): UiState<List<String>?> {
val fileParts = fileList.map { file -> val fileParts = fileList.map { file ->
//multipart/form-data image/jpeg
file.asRequestBody("multipart/form-data".toMediaTypeOrNull()) file.asRequestBody("multipart/form-data".toMediaTypeOrNull())
.let { MultipartBody.Part.createFormData("foodPics", file.name, it) } .let { MultipartBody.Part.createFormData("foodPics", file.name, it) }
} }
@@ -129,7 +129,12 @@ object FoodModule {
val nameScoreList = mutableListOf<IdNameScore>() val nameScoreList = mutableListOf<IdNameScore>()
idScoreList.forEach { idScoreList.forEach {
val name = foodMap[it.id]?.name ?: "" val name = foodMap[it.id]?.name ?: ""
nameScoreList.add(IdNameScore(id = it.id, name = name, score = it.score, rawMaterialsType = "")) val arr = name.split("&&")
var rawMaterialsType = ""
if (arr.size > 1) {
rawMaterialsType = arr[1]
}
nameScoreList.add(IdNameScore(id = it.id, name = arr[0], score = it.score, rawMaterialsType = rawMaterialsType))
} }
logInfo("queryFood数据:${nameScoreList.toJsonString()}") logInfo("queryFood数据:${nameScoreList.toJsonString()}")
return nameScoreList return nameScoreList
@@ -123,21 +123,32 @@ class FoodRecognizeActivity : BaseActivity() {
private val adapter = FoodAdapter(list).apply { private val adapter = FoodAdapter(list).apply {
setOnItemClickListener { _, _, position -> setOnItemClickListener { _, _, position ->
// rawMaterialsRemind(position)
val item = list[position] val item = list[position]
// 弹出净材种类单选弹窗,确认后再选中该食材 list.forEachIndexed { i, it -> it.isClicked = (i == position) }
showRawMaterialsDialog( // 点击选择时记录当前秤重量
context = this@FoodRecognizeActivity, item.useWeight = currentWeight
types = item.rawMaterialsTypes, notifyDataSetChanged()
checkedType = item.rawMaterialsType selectedPosition = position
) { selectedType -> binding.tvSelectedFood.text = item.goodsName ?: "-"
item.rawMaterialsType = selectedType }
list.forEachIndexed { i, it -> it.isClicked = (i == position) } }
// 点击选择时记录当前秤重量
item.useWeight = currentWeight private fun rawMaterialsRemind(position: Int) {
notifyDataSetChanged() val item = list[position]
selectedPosition = position // 弹出净材种类单选弹窗,确认后再选中该食材
binding.tvSelectedFood.text = item.goodsName ?: "-" showRawMaterialsDialog(
} context = this@FoodRecognizeActivity,
types = item.rawMaterialsTypes,
checkedType = item.rawMaterialsType
) { selectedType ->
item.rawMaterialsType = selectedType
list.forEachIndexed { i, it -> it.isClicked = (i == position) }
// 点击选择时记录当前秤重量
item.useWeight = currentWeight
adapter.notifyDataSetChanged()
selectedPosition = position
binding.tvSelectedFood.text = item.goodsName ?: "-"
} }
} }
@@ -178,9 +189,11 @@ class FoodRecognizeActivity : BaseActivity() {
/** /**
* 从 Intent 读取数据并初始化页面 * 从 Intent 读取数据并初始化页面
* Reads data from Intent and initializes the page
*/ */
private fun initData() { private fun initData() {
// 加载食材照片 // 加载食材照片
// Load food photo
val imageUri = intent.getStringExtra(EXTRA_IMAGE_URI) val imageUri = intent.getStringExtra(EXTRA_IMAGE_URI)
if (!imageUri.isNullOrBlank()) { if (!imageUri.isNullOrBlank()) {
binding.ivFoodPhoto.setImageURI(imageUri.toUri()) binding.ivFoodPhoto.setImageURI(imageUri.toUri())
@@ -188,10 +201,14 @@ class FoodRecognizeActivity : BaseActivity() {
currentWeight = intent.getDoubleExtra(EXTRA_CURRENT_WEIGHT, 0.0) currentWeight = intent.getDoubleExtra(EXTRA_CURRENT_WEIGHT, 0.0)
binding.tvWeight.text = "${currentWeight}g" binding.tvWeight.text = "${currentWeight}g"
// val tempList = arrayListOf<GoodsItem>() // val tempList = arrayListOf<GoodsItem>()
// Process recognized name list if available
recognizeNameList?.apply { recognizeNameList?.apply {
// Iterate through each recognized item
forEach { forEach {
// Extract goods name by splitting "WP" and taking the first part
val goodsName = it.name.split("WP").first() val goodsName = it.name.split("WP").first()
val rawMaterialsType = it.rawMaterialsType val rawMaterialsType = it.rawMaterialsType
// Add to goods name list and name-score mapping
goodsNameList.add(GoodsNameQueryDTO(goodsName, rawMaterialsType)) goodsNameList.add(GoodsNameQueryDTO(goodsName, rawMaterialsType))
nameScoreMap.put(goodsName, it) nameScoreMap.put(goodsName, it)
@@ -203,11 +220,16 @@ class FoodRecognizeActivity : BaseActivity() {
// // TODO: 测试数据,临时使用------------------------- // // TODO: 测试数据,临时使用-------------------------
// loadRecognizeList(tempList) // loadRecognizeList(tempList)
// // TODO: 测试数据,临时使用------------------------- // // TODO: 测试数据,临时使用-------------------------
// Clear the recognize name list after processing
recognizeNameList = null recognizeNameList = null
} }
//TODO: 注释掉等接口正常再调用,临时使用测试数据 // If goods name list is not empty, query goods list from network
// if (goodsNameList.isNotEmpty()){
netViewModel.queryGoodsList(goodsType = "0", goodsNames = goodsNameList) netViewModel.queryGoodsList(goodsType = "0", goodsNames = goodsNameList)
// } else {
// loadRecognizeList(emptyList())
// }
} }
private fun buildTestData(item: FoodModule.IdNameScore): GoodsItem { private fun buildTestData(item: FoodModule.IdNameScore): GoodsItem {
@@ -268,11 +290,13 @@ class FoodRecognizeActivity : BaseActivity() {
binding.tvNoData.gone() binding.tvNoData.gone()
list.clear() list.clear()
goodsList.forEach { goodsList.forEach {
val food = nameScoreMap[it.goodsName] var foodScore = -1
val foodScore = ((1 - (food?.score ?: 0.0)) * 10000).toInt() nameScoreMap[it.goodsName]?.let { food ->
// 将接口返回的 rawMaterialsType(全部种类)移存到 rawMaterialsTypes,供弹窗使用 foodScore = ((1 - food.score) * 10000).toInt()
it.rawMaterialsTypes = it.rawMaterialsType }
it.rawMaterialsType = null // // 将接口返回的 rawMaterialsType(全部种类)移存到 rawMaterialsTypes,供弹窗使用
// it.rawMaterialsTypes = it.rawMaterialsType
// it.rawMaterialsType = null
list.add(it.also { it.foodScore = foodScore }) list.add(it.also { it.foodScore = foodScore })
} }
adapter.notifyDataSetChanged() adapter.notifyDataSetChanged()
@@ -231,6 +231,7 @@ class PrepareFoodActivity : BaseActivity() {
} }
if ( if (
!pageVisible || manualCancelFlag || isTakingPhoto.get() || showRecognizePage || !pageVisible || manualCancelFlag || isTakingPhoto.get() || showRecognizePage ||
notRecognizeDialog?.isShowing == true ||
abs(lastWeight - weight) <= WEIGHT_CHANGE_VALUE abs(lastWeight - weight) <= WEIGHT_CHANGE_VALUE
) { ) {
Log.d( Log.d(
@@ -574,6 +575,9 @@ class PrepareFoodActivity : BaseActivity() {
dismissLoading() dismissLoading()
Log.d(TAG, "takePhoto foodScoreList is empty") Log.d(TAG, "takePhoto foodScoreList is empty")
isTakingPhoto.set(false) isTakingPhoto.set(false)
withContext(Dispatchers.Main) {
showNotRecognizeDialog()
}
return@withContext return@withContext
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@@ -586,6 +590,19 @@ class PrepareFoodActivity : BaseActivity() {
} }
} }
} }
private var notRecognizeDialog: CommonDialog? = null
private fun showNotRecognizeDialog() {
notRecognizeDialog = CommonDialog(this)
.setTitle("提示")
.setContent("未识别到食材,请稍后重试")
.setNegativeButton("取消")
.setPositiveButton("手动设置"){
loadRecognizeResultPage(emptyList())
}
.also {
it.show()
}
}
/** /**
* 拍照失败回调 * 拍照失败回调
@@ -14,7 +14,10 @@ import android.view.inputmethod.InputMethodManager
import androidx.camera.view.PreviewView import androidx.camera.view.PreviewView
import androidx.core.view.doOnLayout import androidx.core.view.doOnLayout
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.chad.library.adapter4.util.setOnDebouncedItemClick import com.chad.library.adapter4.util.setOnDebouncedItemClick
@@ -268,15 +271,23 @@ class VectorCollectionFragment : BaseFragment<FragmentVectorCollectionBinding>()
upload() upload()
} }
binding.editFoodName.setOnEditorActionListener { v, actionId, event -> binding.editFoodName.let {
if (actionId == EditorInfo.IME_ACTION_SEARCH) { it.addTextChangedListener { editable ->
searchFood() if (editable?.isEmpty() == true) {
val imm = searchFood()
v.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager return@addTextChangedListener
imm.hideSoftInputFromWindow(v.windowToken, 0) }
true }
} else { it.setOnEditorActionListener { v, actionId, event ->
false if (actionId == EditorInfo.IME_ACTION_SEARCH) {
searchFood()
val imm =
v.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(v.windowToken, 0)
true
} else {
false
}
} }
} }
@@ -297,6 +308,22 @@ class VectorCollectionFragment : BaseFragment<FragmentVectorCollectionBinding>()
it.adapter = searchAdapter it.adapter = searchAdapter
} }
// 统一订阅一次,避免每次 searchFood() 重复创建 collect 协程导致多次回调
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
currentActivity.netViewModel.goodsListState.collect { state ->
when (state) {
is UiState.Success -> {
val list = state.data ?: emptyList()
if (list.isEmpty()) loadEmptyView() else fillSearchList(list)
}
else -> Unit
}
}
}
}
searchFood() searchFood()
} }
@@ -329,8 +356,9 @@ class VectorCollectionFragment : BaseFragment<FragmentVectorCollectionBinding>()
}, onProgress = { count, batch, idList -> }, onProgress = { count, batch, idList ->
showWaitingDialog("图片上传中$count/$totalFileCount") showWaitingDialog("图片上传中$count/$totalFileCount")
val foodList = batch.mapIndexed { index, it -> val foodList = batch.mapIndexed { index, it ->
val name = checkedItem!!.goodsName + "&&" + checkedItem!!.rawMaterialsType
Food( Food(
name = checkedItem!!.goodsName, name = name,
foodVector = it.imageVector, foodVector = it.imageVector,
) )
} }
@@ -355,28 +383,11 @@ class VectorCollectionFragment : BaseFragment<FragmentVectorCollectionBinding>()
fun searchFood() { fun searchFood() {
debouncer.debounce { debouncer.debounce {
val keyword = binding.editFoodName.text.toString().trim() val keyword = binding.editFoodName.text.toString().trim()
// 通过 NetViewModel 查询净材数据(goodsType=0 食材) // 通过 NetViewModel 查询净材数据(goodsType=0 食材),结果由 initialize() 中的 observer 统一处理
currentActivity.netViewModel.queryGoodsList( currentActivity.netViewModel.queryGoodsList(
goodsType = "0", goodsType = "0",
goodsName = keyword.ifEmpty { null } goodsName = keyword.ifEmpty { null }
) )
// 监听查询结果(一次性收集,避免重复订阅)
lifecycleScope.launch {
currentActivity.netViewModel.goodsListState.collect { state ->
when (state) {
is UiState.Success -> {
val list: List<GoodsItem> = state.data ?: emptyList()
if (list.isEmpty()) {
// 接口无数据时展示空布局,点击空布局可填充本地假数据用于效果测试
loadEmptyView()
} else {
fillSearchList(list)
}
}
else -> Unit
}
}
}
} }
} }
@@ -400,6 +411,7 @@ class VectorCollectionFragment : BaseFragment<FragmentVectorCollectionBinding>()
searchList.clear() searchList.clear()
searchList.addAll(list) searchList.addAll(list)
searchAdapter.notifyDataSetChanged() searchAdapter.notifyDataSetChanged()
Log.d(TAG, "fillSearchList,${searchList.toJsonString()}")
} }
private var emptyViewBinding: LayoutEmptyViewBinding? = null private var emptyViewBinding: LayoutEmptyViewBinding? = null
+12 -8
View File
@@ -4,19 +4,23 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="100dp" android:layout_height="100dp"
android:layout_margin="15dp"> android:layout_margin="15dp"
tools:background="@drawable/shape_white_12_corners">
<TextView <TextView
android:id="@+id/tvGoodsInfo" android:id="@+id/tvGoodsInfo"
android:layout_width="match_parent" android:layout_width="wrap_content"
android:layout_height="match_parent" android:layout_height="wrap_content"
android:background="@drawable/shape_white_12_corners"
android:ellipsize="end" android:ellipsize="end"
android:gravity="center" android:gravity="center"
android:maxLines="1" android:maxLines="1"
android:paddingHorizontal="5dp" android:paddingHorizontal="5dp"
android:textColor="@color/black666" android:textColor="@color/black666"
android:textSize="30sp" android:textSize="30sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="椒盐" /> tools:text="椒盐" />
<TextView <TextView
@@ -29,27 +33,27 @@
android:textColor="@color/black999" android:textColor="@color/black999"
android:textSize="18sp" android:textSize="18sp"
android:visibility="gone" android:visibility="gone"
android:layout_marginBottom="5dp"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/tvFoodScore" app:layout_constraintEnd_toStartOf="@id/tvFoodScore"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tvGoodsInfo"
tools:text="切丝" /> tools:text="切丝" />
<TextView <TextView
android:id="@+id/tvFoodScore" android:id="@+id/tvFoodScore"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:ellipsize="end" android:ellipsize="end"
android:maxLines="1" android:maxLines="1"
android:textColor="@color/black666" android:textColor="@color/black666"
android:layout_marginStart="10dp"
android:textSize="16sp" android:textSize="16sp"
android:visibility="gone" android:visibility="gone"
app:layout_constraintTop_toTopOf="@id/tvRawMaterialsType"
app:layout_constraintBottom_toBottomOf="@id/tvRawMaterialsType" app:layout_constraintBottom_toBottomOf="@id/tvRawMaterialsType"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/tvRawMaterialsType" app:layout_constraintStart_toEndOf="@id/tvRawMaterialsType"
app:layout_constraintTop_toTopOf="@id/tvRawMaterialsType"
tools:text="100%" /> tools:text="100%" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>