feat(activity): 弹窗食材/调料查询使用独立 StateFlow,新增调料列表双布局切换功能

- NetViewModel 新增 foodSearchGoodsListState 和 queryFoodSearchGoodsList,与 goodsListState 隔离
- FoodSearchDialog、SeasoningSelectDialog 改用弹窗专用 State,避免影响 FoodRecognizeActivity 列表
- SeasoningWeightAdapter 支持 Grid(4列)/List(2列)双布局模式
- SubmitFoodActivity 添加调料列表布局切换按钮
- FoodModule 变量重命名并添加未初始化保护
- 注释掉 mock 食材数据,调整相关布局边距与文字截断

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-12 15:17:54 +08:00
co-authored by Claude Sonnet 4.6
parent d412e03c6e
commit 5745791041
12 changed files with 181 additions and 54 deletions
@@ -6,14 +6,25 @@ import android.view.ViewGroup
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.dish.match.databinding.ItemSeasoningWeightBinding
import com.shuwei.dish.match.databinding.ItemSeasoningWeightListBinding
import com.shuwei.dish.match.utils.ext.roundedDecimalPlace
/**
* 调料使用重量列表 Adapter
* 用于 SubmitFoodActivity 展示各调料槽位的实时用量
* 同名调料已在外部聚合,adapter 按 goodsName 作为唯一标识
* 支持两种布局模式:
* - VIEW_TYPE_GRID(默认):4 列卡片,名称与重量垂直排列
* - VIEW_TYPE_LIST:2 列,名称与重量水平排列,名称占剩余空间
*/
class SeasoningWeightAdapter : BaseQuickAdapter<SeasoningWeightAdapter.Item, SeasoningWeightAdapter.VH>() {
class SeasoningWeightAdapter : BaseQuickAdapter<SeasoningWeightAdapter.Item, QuickViewHolder>() {
companion object {
/** 4 列卡片模式 */
const val VIEW_TYPE_GRID = 0
/** 2 列水平模式 */
const val VIEW_TYPE_LIST = 1
}
/** 调料展示数据项,key = goodsName(同名调料已聚合) */
data class Item(
@@ -23,6 +34,13 @@ class SeasoningWeightAdapter : BaseQuickAdapter<SeasoningWeightAdapter.Item, Sea
val useWeight: Double
)
/** 当前布局模式,由外部切换后调用 notifyDataSetChanged 生效 */
var isListMode: Boolean = false
override fun getItemViewType(position: Int, list: List<Item>): Int {
return if (isListMode) VIEW_TYPE_LIST else VIEW_TYPE_GRID
}
/**
* 更新单个 item,按 goodsName 匹配;不存在则追加
*/
@@ -43,18 +61,21 @@ class SeasoningWeightAdapter : BaseQuickAdapter<SeasoningWeightAdapter.Item, Sea
if (idx >= 0) removeAt(idx)
}
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val binding = ItemSeasoningWeightBinding.inflate(LayoutInflater.from(context), parent, false)
return VH(binding)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): QuickViewHolder {
return if (viewType == VIEW_TYPE_LIST) {
val binding = ItemSeasoningWeightListBinding.inflate(LayoutInflater.from(context), parent, false)
QuickViewHolder(binding.root)
} else {
val binding = ItemSeasoningWeightBinding.inflate(LayoutInflater.from(context), parent, false)
QuickViewHolder(binding.root)
}
}
override fun onBindViewHolder(holder: VH, position: Int, item: Item?) {
override fun onBindViewHolder(holder: QuickViewHolder, position: Int, item: Item?) {
item ?: return
holder.binding.tvSeasoningName.text = item.goodsName
val showWeight = "${item.useWeight.roundedDecimalPlace(1)}g"
holder.binding.tvSeasoningWeight.text = showWeight
holder.setText(com.shuwei.dish.match.R.id.tvSeasoningName, item.goodsName)
holder.setText(com.shuwei.dish.match.R.id.tvSeasoningWeight, showWeight)
}
inner class VH(var binding: ItemSeasoningWeightBinding) : QuickViewHolder(binding.root)
}
@@ -116,7 +116,7 @@ class FoodSearchDialog(
}
binding.refreshLayout.run {
setEnableRefresh(false)
setEnableRefresh(true)
setEnableLoadMore(false)
setOnRefreshListener { pageNo = 1; getGoodsList() }
setOnLoadMoreListener { getGoodsList() }
@@ -175,12 +175,13 @@ class FoodSearchDialog(
}
/**
* 收集 goodsListState,统一处理 Loading / Success / Error 状态
* 收集 foodSearchGoodsListState,统一处理 Loading / Success / Error 状态
* 使用独立的 State,避免与 FoodRecognizeActivity 等页面共用 goodsListState 产生干扰
*/
private fun initObserver() {
activity.lifecycleScope.launch {
activity.repeatOnLifecycle(Lifecycle.State.STARTED) {
activity.netViewModel.goodsListState.collect { state ->
activity.netViewModel.foodSearchGoodsListState.collect { state ->
when (state) {
is UiState.Success -> loadGoodsList(state.data)
is UiState.Error -> {
@@ -208,10 +209,10 @@ class FoodSearchDialog(
}
/**
* 请求食材列表
* 请求食材列表,使用弹窗专用接口,结果写入 foodSearchGoodsListState
*/
private fun getGoodsList() {
activity.netViewModel.queryGoodsList(
activity.netViewModel.queryFoodSearchGoodsList(
goodsType = "0",
pageNum = pageNo,
pageSize = PAGE_SIZE,
@@ -102,7 +102,7 @@ class SeasoningSelectDialog(
}
binding.refreshLayout.run {
setEnableRefresh(false)
setEnableRefresh(true)
setEnableLoadMore(false)
setOnRefreshListener { pageNo = 1; getGoodsList() }
setOnLoadMoreListener { getGoodsList() }
@@ -117,7 +117,7 @@ class SeasoningSelectDialog(
super.show()
// 启动 StateFlow 收集,弹窗关闭时由 setOnDismissListener 取消
collectJob = activity.lifecycleScope.launch {
activity.netViewModel.goodsListState.collect { state ->
activity.netViewModel.foodSearchGoodsListState.collect { state ->
when (state) {
is UiState.Loading -> {}
is UiState.Success -> loadGoodsList(state.data)
@@ -186,7 +186,7 @@ class SeasoningSelectDialog(
* 请求调料列表
*/
private fun getGoodsList() {
activity.netViewModel.queryGoodsList(
activity.netViewModel.queryFoodSearchGoodsList(
goodsType = "1",
pageNum = pageNo,
pageSize = PAGE_SIZE,
@@ -102,6 +102,12 @@ class NetViewModel(
private val _goodsListState = MutableStateFlow<UiState<MutableList<GoodsItem>?>>(UiState.Idle)
val goodsListState: StateFlow<UiState<MutableList<GoodsItem>?>> = _goodsListState.asStateFlow()
/**
* 食材搜索弹窗专用的 UI 状态流,与 goodsListState 隔离,避免弹窗请求影响其他页面
*/
private val _foodSearchGoodsListState = MutableStateFlow<UiState<MutableList<GoodsItem>?>>(UiState.Idle)
val foodSearchGoodsListState: StateFlow<UiState<MutableList<GoodsItem>?>> = _foodSearchGoodsListState.asStateFlow()
/**
* 查询物品信息列表(食材 goodsType=0,调料 goodsType=1 共用)
* @param goodsType 物品类型:0=食材,1=调料
@@ -133,6 +139,37 @@ class NetViewModel(
}
}
/**
* 食材搜索弹窗专用的查询方法,结果写入 foodSearchGoodsListState,不影响 goodsListState
* @param goodsType 物品类型:0=食材,1=调料
* @param placeId 场所 ID,默认取当前食堂 ID
* @param pageNum 页码,默认第 1 页
* @param pageSize 每页条数,默认 50
* @param goodsName 按名称模糊搜索,为 null 时不传该字段
* @param goodsNames 按名称+净材种类列表批量查询,为 null 时不传该字段
*/
fun queryFoodSearchGoodsList(
goodsType: String,
placeId: String = BaseApp.canteenId,
pageNum: Int = 1,
pageSize: Int = 50,
goodsName: String? = null,
goodsNames: List<GoodsNameQueryDTO>? = null
) {
val param = mutableMapOf<String, Any>(
"goodsType" to goodsType,
"placeId" to placeId,
"pageNum" to pageNum,
"pageSize" to pageSize
)
goodsName?.let { param["goodsName"] = it }
goodsNames?.let { param["goodsNames"] = it }
viewModelScope.launch {
_foodSearchGoodsListState.value = UiState.Loading
_foodSearchGoodsListState.value = repository.queryGoodsList(param)
}
}
/**
* 上传采集菜品信息 UI 状态流,UI 层通过 collect 监听
*/
@@ -22,7 +22,7 @@ import java.io.InputStream
object FoodModule {
private lateinit var module_mobile: Module
private lateinit var module: Module
private val NO_MEAN_RGB = floatArrayOf(0.0f, 0.0f, 0.0f)
private val NO_STD_RGB = floatArrayOf(1.0f, 1.0f, 1.0f)
@@ -37,8 +37,8 @@ object FoodModule {
suspend fun init(context: Context) {
withContext(Dispatchers.IO) {
module_mobile = Module.load(copyAssetToCache(context, "best_embedding_model_mobile.pt"))
initDefFoodData(context)
module = Module.load(copyAssetToCache(context, "best_embedding_model_mobile.pt"))
// initDefFoodData(context)
}
}
@@ -51,6 +51,11 @@ object FoodModule {
// }
fun bitmap2FloatArray(originBitmap: Bitmap, isRecycle: Boolean): FloatArray? {
// 模型未初始化时直接返回 null,避免 UninitializedPropertyAccessException
if (!::module.isInitialized) {
logInfo("bitmap2FloatArray: module 尚未初始化,跳过推理")
return null
}
var rgb565Bitmap: Bitmap? = null
try {
val scaledBitmap = originBitmap.scale(MODEL_INPUT_WIDTH, MODEL_INPUT_HEIGHT)
@@ -64,7 +69,7 @@ object FoodModule {
NO_MEAN_RGB, // [0.485, 0.456, 0.406] TORCHVISION_NORM_MEAN_RGB
NO_STD_RGB // [0.229, 0.224, 0.225] TORCHVISION_NORM_STD_RGB
)
val outputTensor = module_mobile.forward(IValue.from(inputTensor)).toTensor()
val outputTensor = module.forward(IValue.from(inputTensor)).toTensor()
return outputTensor.dataAsFloatArray
} catch (e: OutOfMemoryError) {
e.printStackTrace()
@@ -156,7 +156,7 @@ class SubmitFoodActivity : BaseActivity() {
}
}
/** 初始化 RecyclerView24列 GridLayoutManager */
/** 初始化 RecyclerView默认 4 列 GridLayoutManager */
private fun setupRecyclerView() {
binding.rvSeasoning.let {
it.itemAnimator = null
@@ -303,6 +303,13 @@ class SubmitFoodActivity : BaseActivity() {
}
private fun addViewListener() {
// 切换调料列表布局:4 列卡片 ↔ 2 列水平
binding.ivSeasoningLayoutToggle.setOnClickListener {
seasoningAdapter.isListMode = !seasoningAdapter.isListMode
val spanCount = if (seasoningAdapter.isListMode) 2 else 4
(binding.rvSeasoning.layoutManager as? GridLayoutManager)?.spanCount = spanCount
seasoningAdapter.notifyDataSetChanged()
}
binding.btnCook.clickWithDebounce {
val content = getRemindSpannable("开始制作")
showRemindDialog(content) { cook() }
@@ -18,27 +18,27 @@ import com.shuwei.dish.match.utils.ext.toast
* TODO: 联调完成后删除
*/
fun buildMockGoodsList(): List<GoodsItem> = mutableListOf(
GoodsItem(goodsId = "1", goodsName = "土豆", rawMaterialsType = "土豆丝,土豆条,土豆片,土豆丁"),
GoodsItem(goodsId = "2", goodsName = "胡萝卜", rawMaterialsType = "胡萝卜丝,胡萝卜片,胡萝卜丁"),
GoodsItem(goodsId = "3", goodsName = "黄瓜", rawMaterialsType = "黄瓜丝,黄瓜片,黄瓜丁,黄瓜块"),
GoodsItem(goodsId = "4", goodsName = "洋葱", rawMaterialsType = "洋葱丝,洋葱丁,洋葱圈"),
GoodsItem(goodsId = "5", goodsName = "白菜", rawMaterialsType = "白菜丝,白菜块,白菜叶"),
GoodsItem(goodsId = "6", goodsName = "豆腐", rawMaterialsType = "豆腐块,豆腐丁"),
GoodsItem(goodsId = "7", goodsName = "猪肉", rawMaterialsType = "猪肉丝,猪肉片,猪肉丁,猪肉块"),
GoodsItem(goodsId = "8", goodsName = "鸡胸肉", rawMaterialsType = "鸡胸肉丝,鸡胸肉片,鸡胸肉丁"),
GoodsItem(goodsId = "9", goodsName = "牛肉", rawMaterialsType = "牛肉丝,牛肉片,牛肉块"),
GoodsItem(goodsId = "10", goodsName = "虾仁", rawMaterialsType = "整虾仁,切段虾仁"),
GoodsItem(goodsId = "11", goodsName = "茄子", rawMaterialsType = "茄子丝,茄子片,茄子块,茄子条"),
GoodsItem(goodsId = "12", goodsName = "青椒", rawMaterialsType = "青椒丝,青椒片,青椒块"),
GoodsItem(goodsId = "13", goodsName = "西红柿", rawMaterialsType = "西红柿片,西红柿块,西红柿丁"),
GoodsItem(goodsId = "14", goodsName = "芹菜", rawMaterialsType = "芹菜段,芹菜丝"),
GoodsItem(goodsId = "15", goodsName = "莲藕", rawMaterialsType = "莲藕片,莲藕丁,莲藕丝"),
// 以下5条 rawMaterialsType 为空,测试无净材数据场景
GoodsItem(goodsId = "16", goodsName = "菠菜"),
GoodsItem(goodsId = "17", goodsName = "生菜"),
GoodsItem(goodsId = "18", goodsName = "香菇"),
GoodsItem(goodsId = "19", goodsName = "木耳"),
GoodsItem(goodsId = "20", goodsName = "豆芽"),
// GoodsItem(goodsId = "1", goodsName = "土豆", rawMaterialsType = "土豆丝,土豆条,土豆片,土豆丁"),
// GoodsItem(goodsId = "2", goodsName = "胡萝卜", rawMaterialsType = "胡萝卜丝,胡萝卜片,胡萝卜丁"),
// GoodsItem(goodsId = "3", goodsName = "黄瓜", rawMaterialsType = "黄瓜丝,黄瓜片,黄瓜丁,黄瓜块"),
// GoodsItem(goodsId = "4", goodsName = "洋葱", rawMaterialsType = "洋葱丝,洋葱丁,洋葱圈"),
// GoodsItem(goodsId = "5", goodsName = "白菜", rawMaterialsType = "白菜丝,白菜块,白菜叶"),
// GoodsItem(goodsId = "6", goodsName = "豆腐", rawMaterialsType = "豆腐块,豆腐丁"),
// GoodsItem(goodsId = "7", goodsName = "猪肉", rawMaterialsType = "猪肉丝,猪肉片,猪肉丁,猪肉块"),
// GoodsItem(goodsId = "8", goodsName = "鸡胸肉", rawMaterialsType = "鸡胸肉丝,鸡胸肉片,鸡胸肉丁"),
// GoodsItem(goodsId = "9", goodsName = "牛肉", rawMaterialsType = "牛肉丝,牛肉片,牛肉块"),
// GoodsItem(goodsId = "10", goodsName = "虾仁", rawMaterialsType = "整虾仁,切段虾仁"),
// GoodsItem(goodsId = "11", goodsName = "茄子", rawMaterialsType = "茄子丝,茄子片,茄子块,茄子条"),
// GoodsItem(goodsId = "12", goodsName = "青椒", rawMaterialsType = "青椒丝,青椒片,青椒块"),
// GoodsItem(goodsId = "13", goodsName = "西红柿", rawMaterialsType = "西红柿片,西红柿块,西红柿丁"),
// GoodsItem(goodsId = "14", goodsName = "芹菜", rawMaterialsType = "芹菜段,芹菜丝"),
// GoodsItem(goodsId = "15", goodsName = "莲藕", rawMaterialsType = "莲藕片,莲藕丁,莲藕丝"),
// // 以下5条 rawMaterialsType 为空,测试无净材数据场景
// GoodsItem(goodsId = "16", goodsName = "菠菜"),
// GoodsItem(goodsId = "17", goodsName = "生菜"),
// GoodsItem(goodsId = "18", goodsName = "香菇"),
// GoodsItem(goodsId = "19", goodsName = "木耳"),
// GoodsItem(goodsId = "20", goodsName = "豆芽"),
)
/**
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -16,22 +16,39 @@
android:background="@drawable/shape_white_30_corners"
android:orientation="vertical">
<TextView
android:id="@+id/tvTitle"
android:layout_width="wrap_content"
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="30dp"
android:layout_marginStart="22dp"
android:layout_marginTop="40dp"
android:layout_marginEnd="30dp"
android:text="@string/seasoning"
android:textColor="@color/black666"
android:textSize="28sp" />
android:layout_marginEnd="22dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/tvTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/seasoning"
android:textColor="@color/black666"
android:textSize="28sp" />
<ImageView
android:id="@+id/ivSeasoningLayoutToggle"
android:layout_width="50dp"
android:layout_height="50dp"
android:paddingVertical="8dp"
android:contentDescription="切换布局"
android:src="@drawable/ic_switch_box"
android:background="?android:attr/selectableItemBackgroundBorderless" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvSeasoning"
android:layout_width="match_parent"
android:layout_height="660dp"
android:layout_marginTop="10dp"
android:layout_marginBottom="10dp"
android:layout_marginHorizontal="20dp"
tools:itemCount="26"
@@ -69,8 +69,6 @@
android:layout_width="match_parent"
android:layout_height="700dp"
android:layout_marginTop="10dp"
android:layout_marginStart="45dp"
android:layout_marginEnd="45dp"
app:srlEnableOverScrollDrag="false">
<com.scwang.smart.refresh.header.ClassicsHeader
@@ -81,6 +79,8 @@
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginStart="45dp"
android:layout_marginEnd="45dp"
android:layout_marginTop="15dp"
android:layout_marginBottom="15dp"
android:nestedScrollingEnabled="true"
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="80dp"
android:layout_marginHorizontal="5dp"
android:layout_marginVertical="4dp"
android:paddingHorizontal="16dp"
android:orientation="horizontal"
android:gravity="center_vertical"
android:background="@drawable/shape_white_fb_15_corners2">
<TextView
android:id="@+id/tvSeasoningName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/black"
android:textSize="22sp"
android:textStyle="bold"
tools:text="土豆丝" />
<TextView
android:id="@+id/tvSeasoningWeight"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:textColor="@color/dish_green"
android:ellipsize="end"
android:maxLines="1"
android:textSize="22sp"
android:textStyle="bold"
tools:text="100g" />
</LinearLayout>
@@ -26,6 +26,8 @@
android:layout_height="wrap_content"
android:gravity="center"
android:includeFontPadding="false"
android:maxLines="3"
android:ellipsize="end"
android:textColor="@color/black"
android:textStyle="bold" />