12 Commits
Author SHA1 Message Date
lvmeng 7e72ce413b feat(food): 实现食材识别和净材类型功能
- 在FoodAdapter中添加选中状态背景和文字颜色变化
- 修改FoodMaterialAdapter显示食材名称时包含净材类型
- 在FoodModule中解析食材名称中的净材类型信息
- 移除食材识别页面的净材类型选择弹窗流程
- 优化食材识别结果处理逻辑和数据映射
- 添加未识别到食材时的提示对话框
- 实现向量采集页面的搜索功能和实时监听
- 优化网络请求的文件上传媒体类型设置
- 调整食材列表项的布局约束和样式配置
2026-05-12 18:15:40 +08:00
lvmengandClaude Sonnet 4.6 5745791041 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>
2026-05-12 15:17:54 +08:00
lvmeng d412e03c6e perf(activity): 优化秤数据 StateFlow 重发时的无效遍历,添加 ts 去重跳过未变化帧;ScaleWebSocketServer 改为 trailing 节流并增加数据变化去重 2026-05-12 14:07:22 +08:00
lvmengandClaude Sonnet 4.6 b6389be898 feat(db): 支持早中晚三餐同一菜品独立保存及调料预填充优化
- 修复 getCookFoodById/deleteCookFood/markSubmittedAndDelete 查询条件缺少 dinnerType,
  导致同一 foodId 跨餐次保存时误判已存在、主记录和主辅材数据丢失的问题;
  查询键由三元组(canteenId+foodId+cookMode)升级为四元组(+dinnerType)
- SamplingModeActivity 构建 FoodRecord 时补充 dinnerType,确保删除操作能精确定位餐次
- SubmitFoodActivity 制作中模式下从数据库加载 goodsList 时预填充调料到 adapter,
  isCooking=false 时同样预填充前一页面带入的调料数据
- dm_cook_food 表新增 submitState 字段(DB v13),区分手动删除与接口提交删除
- 提交成功后改用 markSubmittedAndDelete 替代 deleteCookFoodAndGoods,正确置 submitState=1
- InitActivity onDestroy 不再停止秤服务,避免进程存活期间秤数据中断
- GoodsItem.isItemClicked 统一改为 isClicked,PrepareFoodActivity 食材选中逻辑同步更新
- AndroidManifest InitActivity 增加 singleTask 及 configChanges 配置

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 18:10:59 +08:00
lvmeng 919e43afba feat(dialog): 添加调料选择对话框的本地模拟数据功能
- 实现 loadMockData 函数用于加载本地模拟调料数据
- 在空布局上添加点击事件监听器,点击时加载模拟数据
- 将原有的 TODO 注释代码转换为正式的模拟数据加载功能
- 优化空布局的初始化逻辑并添加安全调用处理
2026-05-11 15:23:23 +08:00
lvmeng 0b9b3e8988 fix(activity): 修复调料 foodId 为空导致重新打开看不到调料数据 2026-05-11 14:39:12 +08:00
lvmeng 803ae75e40 fix(activity): 修复识图新增食材主辅材类型未显示
foodSelectCallback 新增 GoodsItem 时未将 isOriginalData 置为
false,导致 updateMaterialTypes 跳过计算,列表无法显示主辅材
2026-05-11 13:31:24 +08:00
lvmeng ec4c3f1232 refactor: 提取净材种类弹窗为工具类并统一各页面选择逻辑
- 新增 RawMaterialsDialogUtils,包含 showRawMaterialsDialog、
  buildSingleChoiceChipGroup、buildMockGoodsList 三个顶层函数
- FoodAdapter/list_item_food 新增净材种类选中结果展示
- VectorCollectionFragment、FoodRecognizeActivity、FoodSearchDialog
  的食材点击均改为弹出净材种类单选弹窗后再确认选中
- FoodSearchDialog 空布局补充点击加载测试数据
- setting_border 新增圆角
2026-05-11 12:07:06 +08:00
lvmeng 63911fe432 feat(network): queryGoodsList 入参 goodsNames 改为带净材种类的对象列表 2026-05-11 11:15:27 +08:00
lvmeng 8f15c0efb7 refactor(network): 菜品详情接口改用 DTO 与 Room 实体解耦 2026-05-11 11:10:45 +08:00
lvmeng 879af8426f feat(fragment): 食材采集页改用 ChipGroup 选择净材并新增列表空布局 2026-05-11 10:20:54 +08:00
lvmeng dc947df6e3 fix(network): 修复 uploadFoodVectorData 接口 PartMap 类型导致的 Retrofit 解析异常 2026-05-11 10:20:47 +08:00
39 changed files with 1074 additions and 379 deletions
+2
View File
@@ -45,6 +45,8 @@
<activity
android:name=".ui.InitActivity"
android:exported="true"
android:launchMode="singleTask"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden|keyboard|navigation|uiMode|locale|layoutDirection|fontScale|density"
tools:ignore="DiscouragedApi,LockedOrientationActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -25,12 +25,14 @@ class FoodAdapter(list: MutableList<GoodsItem>) :
item: GoodsItem?
) {
item ?: return
holder.binding.tvGoodsInfo.run {
text = item.goodsName
holder.binding.root.run {
setBackgroundResource(
if (item.isClicked) R.drawable.shape_green_stroke
else R.drawable.shape_white_12_corners
)
}
holder.binding.tvGoodsInfo.run {
text = item.goodsName
setTextColor(
ContextCompat.getColor(
context,
@@ -46,6 +48,15 @@ class FoodAdapter(list: MutableList<GoodsItem>) :
it.visible()
val foodScoreText = "${(item.foodScore / 100.0).roundedDecimalPlace(2)}%"
it.text = foodScoreText
}
}
if (item.rawMaterialsType.isNullOrBlank()) {
holder.binding.tvRawMaterialsType.gone()
} else {
holder.binding.tvRawMaterialsType.let {
it.visible()
it.text = item.rawMaterialsType
}
}
}
@@ -36,8 +36,11 @@ class FoodMaterialAdapter(list: MutableList<GoodsItem>) :
clBlock.setOnClickListener {
onItemClick?.invoke(holder.bindingAdapterPosition)
}
tvDishName.text = data.goodsName
if (data.isOriginalData) {
tvDishName.text = data.goodsName
} else {
tvDishName.text = data.goodsName + "-${data.rawMaterialsType}"
}
tvDishType.text = if (data.materialType == 1) "主辅材:主材" else if (data.materialType == 2) "主辅材:辅材" else ""
tvDishWeight.text =
if (data.useWeight == null || data.useWeight == 0.toDouble()) "" else "${data.useWeight!!.roundedOneDecimalPlace()}"
@@ -69,7 +72,7 @@ class FoodMaterialAdapter(list: MutableList<GoodsItem>) :
else R.drawable.ic_dish_unselected
)
clBlock.setBackgroundResource(
if (data.isItemClicked) R.drawable.shape_item_cook_dish
if (data.isClicked) R.drawable.shape_item_cook_dish
else R.drawable.shape_white_fb_15_corners
)
}
@@ -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)
}
@@ -25,7 +25,7 @@ import com.shuwei.dish.match.entity.SeasoningSlotEntity
CookFoodGoodsEntity::class,
SeasoningSlotEntity::class,
],
version = 12,
version = 13,
exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
@@ -248,6 +248,14 @@ val MIGRATION_11_12 = MigrationImpl(11, 12) { db ->
db.execSQL("ALTER TABLE dm_seasoning ADD COLUMN rawMaterialsType TEXT DEFAULT NULL")
}
/**
* 版本12→13:为 dm_cook_food 表新增 submitState(接口提交状态)字段
* 0-未提交(本地草稿/手动删除),1-接口提交成功后删除
*/
val MIGRATION_12_13 = MigrationImpl(12, 13) { db ->
db.execSQL("ALTER TABLE dm_cook_food ADD COLUMN submitState INTEGER NOT NULL DEFAULT 0")
}
class DatabaseProvider(private val context: Context) {
// 单例模式确保全局唯一实例‌
val instance: AppDatabase by lazy {
@@ -271,6 +279,7 @@ class DatabaseProvider(private val context: Context) {
.addMigrations(MIGRATION_9_10)
.addMigrations(MIGRATION_10_11)
.addMigrations(MIGRATION_11_12)
.addMigrations(MIGRATION_12_13)
.build()
}
}
@@ -21,8 +21,8 @@ class DbRepository {
db.cookFoodDao().updateCookFood(item)
}
suspend fun getCookFoodById(foodId: String, cookMode: Int) = withContext(Dispatchers.IO) {
db.cookFoodDao().getCookFoodById(BaseApp.canteenId, foodId, cookMode)
suspend fun getCookFoodById(foodId: String, cookMode: Int, dinnerType: String) = withContext(Dispatchers.IO) {
db.cookFoodDao().getCookFoodById(BaseApp.canteenId, foodId, cookMode, dinnerType)
}
suspend fun getCookFoodList(cookMode: Int, dinnerType: String) = withContext(Dispatchers.IO) {
@@ -33,13 +33,23 @@ class DbRepository {
db.cookFoodDao().countCookFood(BaseApp.canteenId, cookMode)
}
suspend fun deleteCookFood(foodId: String, cookMode: Int) = withContext(Dispatchers.IO) {
db.cookFoodDao().deleteCookFood(BaseApp.canteenId, foodId, cookMode)
suspend fun deleteCookFood(foodId: String, cookMode: Int, dinnerType: String) = withContext(Dispatchers.IO) {
db.cookFoodDao().deleteCookFood(BaseApp.canteenId, foodId, cookMode, dinnerType)
}
/** 接口提交成功后逻辑删除菜品,同时把 submitState 置 1 */
suspend fun markSubmittedAndDelete(foodId: String, cookMode: Int, dinnerType: String) = withContext(Dispatchers.IO) {
db.cookFoodDao().markSubmittedAndDelete(BaseApp.canteenId, foodId, cookMode, dinnerType)
}
suspend fun deleteCookFoodGoodsList(pid: Long, foodId: String) = withContext(Dispatchers.IO) {
db.cookFoodGoodsDao().deleteCookFoodGoodsList(pid, foodId)
}
/** 仅逻辑删除调料行(materialType = 3),主辅材不动 */
suspend fun deleteSeasoningOnly(pid: Long, foodId: String) = withContext(Dispatchers.IO) {
db.cookFoodGoodsDao().deleteSeasoningOnly(pid, foodId)
}
//--------------------------------------------------------------------------------------------
suspend fun insertGoodsList(items: MutableList<CookFoodGoodsEntity>) =
@@ -28,9 +28,9 @@ class DbViewModel : ViewModel() {
private val _cookFoodDetailState = MutableStateFlow<CookFoodEntity?>(null)
val cookFoodDetailState: StateFlow<CookFoodEntity?> = _cookFoodDetailState.asStateFlow()
fun getCookFoodById(foodId: String, cookMode: Int) {
fun getCookFoodById(foodId: String, cookMode: Int, dinnerType: String) {
viewModelScope.launch {
_cookFoodDetailState.value = rep.getCookFoodById(foodId, cookMode)
_cookFoodDetailState.value = rep.getCookFoodById(foodId, cookMode, dinnerType)
}
}
@@ -68,28 +68,49 @@ class DbViewModel : ViewModel() {
entity: CookFoodEntity,
list: MutableList<CookFoodGoodsEntity>?
) {
val data = rep.getCookFoodById(entity.foodId, cookMode)
if (data != null) {
// 存在数据先删除
rep.deleteCookFood(entity.foodId, cookMode)
rep.deleteCookFoodGoodsList(data.id, entity.foodId)
}
// 重新保存数据
rep.insertCookFood(entity)
val newData = rep.getCookFoodById(entity.foodId, cookMode)
list?.let { it ->
it.forEach { goods ->
goods.id = 0
goods.pid = newData?.id ?: 0
val data = rep.getCookFoodById(entity.foodId, cookMode, entity.dinnerType ?: "0")
if (data == null) {
// 首次保存:主表 + 主辅材 + 调料 全量 insert
rep.insertCookFood(entity)
val newData = rep.getCookFoodById(entity.foodId, cookMode, entity.dinnerType ?: "0")
list?.let {
it.forEach { goods ->
goods.id = 0
goods.pid = newData?.id ?: 0
}
rep.insertGoodsList(it)
}
} else {
// 已存在:主表与主辅材保持不变,仅重建调料部分,避免主表/主辅材累积逻辑删除脏数据
rep.deleteSeasoningOnly(data.id, entity.foodId)
val seasoningList = list?.filter { it.materialType == 3 }?.toMutableList()
if (!seasoningList.isNullOrEmpty()) {
seasoningList.forEach { goods ->
goods.id = 0
goods.pid = data.id
goods.foodId = entity.foodId
}
rep.insertGoodsList(seasoningList)
}
rep.insertGoodsList(it)
}
}
suspend fun deleteCookFoodAndGoods(cookMode: Int, foodId: String) {
val data = rep.getCookFoodById(foodId, cookMode)
suspend fun deleteCookFoodAndGoods(cookMode: Int, foodId: String, dinnerType: String) {
val data = rep.getCookFoodById(foodId, cookMode, dinnerType)
if (data != null) {
rep.deleteCookFood(foodId = foodId, cookMode = cookMode)
rep.deleteCookFood(foodId = foodId, cookMode = cookMode, dinnerType = dinnerType)
rep.deleteCookFoodGoodsList(pid = data.id, foodId = foodId)
}
}
/**
* 接口提交成功后删除本地菜品草稿
* 与 [deleteCookFoodAndGoods] 的区别:主表 submitState 会被置 1,便于审计区分手动删除与接口删除
*/
suspend fun markSubmittedAndDelete(cookMode: Int, foodId: String, dinnerType: String) {
val data = rep.getCookFoodById(foodId, cookMode, dinnerType)
if (data != null) {
rep.markSubmittedAndDelete(foodId = foodId, cookMode = cookMode, dinnerType = dinnerType)
rep.deleteCookFoodGoodsList(pid = data.id, foodId = foodId)
}
}
@@ -98,9 +119,10 @@ class DbViewModel : ViewModel() {
suspend fun getCookFoodGoodsList(
foodId: String,
cookMode: Int
cookMode: Int,
dinnerType: String
): MutableList<CookFoodGoodsEntity> {
val cookFood = rep.getCookFoodById(foodId, cookMode)
val cookFood = rep.getCookFoodById(foodId, cookMode, dinnerType)
return if (cookFood != null) rep.getCookFoodGoodsList(pid = cookFood.id, foodId = cookFood.foodId)
else mutableListOf()
}
@@ -343,7 +365,8 @@ class DbViewModel : ViewModel() {
fields = listOf(
"id" to "${e.id}", "foodId" to "${e.foodId}", "foodName" to "${e.foodName}",
"canteenId" to "${e.canteenId}", "foodWeight" to "${e.foodWeight}", "cookMode" to "${e.cookMode}",
"dinnerType" to "${e.dinnerType}", "isDel" to "${e.isDel}", "createTime" to "${e.createTime}"
"dinnerType" to "${e.dinnerType}", "isDel" to "${e.isDel}", "submitState" to "${e.submitState}",
"createTime" to "${e.createTime}"
)
)
}
@@ -16,8 +16,8 @@ interface CookFoodDao {
@Update
suspend fun updateCookFood(item: CookFoodEntity)
@Query("SELECT * FROM dm_cook_food WHERE isDel = 0 AND canteenId = :canteenId AND foodId = :foodId AND cookMode = :cookMode")
suspend fun getCookFoodById(canteenId: String, foodId: String, cookMode: Int): CookFoodEntity?
@Query("SELECT * FROM dm_cook_food WHERE isDel = 0 AND canteenId = :canteenId AND foodId = :foodId AND cookMode = :cookMode AND dinnerType = :dinnerType")
suspend fun getCookFoodById(canteenId: String, foodId: String, cookMode: Int, dinnerType: String): CookFoodEntity?
@Query("SELECT * FROM dm_cook_food WHERE isDel = 0 AND canteenId = :canteenId AND cookMode = :cookMode AND dinnerType = :dinnerType ORDER BY createTime DESC")
suspend fun getCookFoodList(canteenId: String, cookMode: Int, dinnerType: String): MutableList<CookFoodEntity>?
@@ -26,8 +26,12 @@ interface CookFoodDao {
suspend fun countCookFood(canteenId: String, cookMode: Int): Int
// @Query("DELETE FROM dm_cook_food WHERE canteenId = :canteenId AND foodId = :foodId AND cookMode = :cookMode")
@Query("UPDATE dm_cook_food SET isDel = 1 WHERE isDel = 0 AND canteenId = :canteenId AND foodId = :foodId AND cookMode = :cookMode")
suspend fun deleteCookFood(canteenId: String, foodId: String, cookMode: Int)
@Query("UPDATE dm_cook_food SET isDel = 1 WHERE isDel = 0 AND canteenId = :canteenId AND foodId = :foodId AND cookMode = :cookMode AND dinnerType = :dinnerType")
suspend fun deleteCookFood(canteenId: String, foodId: String, cookMode: Int, dinnerType: String)
/** 接口提交成功后逻辑删除菜品,同时把 submitState 置 1 以便在数据库审计中区分手动删除 */
@Query("UPDATE dm_cook_food SET isDel = 1, submitState = 1 WHERE isDel = 0 AND canteenId = :canteenId AND foodId = :foodId AND cookMode = :cookMode AND dinnerType = :dinnerType")
suspend fun markSubmittedAndDelete(canteenId: String, foodId: String, cookMode: Int, dinnerType: String)
/** 查询全部菜品记录(含已删除),用于数据库调试查看 */
@Query("SELECT * FROM dm_cook_food ORDER BY createTime DESC")
@@ -29,6 +29,10 @@ interface CookFoodGoodsDao {
@Query("UPDATE dm_cook_food_goods SET isDel = 1 WHERE isDel = 0 AND pid = :pid AND foodId = :foodId")
suspend fun deleteCookFoodGoodsList(pid: Long, foodId: String)
/** 仅逻辑删除调料行(materialType = 3),主辅材不动,用于 cook 时只重建调料部分 */
@Query("UPDATE dm_cook_food_goods SET isDel = 1 WHERE isDel = 0 AND pid = :pid AND foodId = :foodId AND materialType = 3")
suspend fun deleteSeasoningOnly(pid: Long, foodId: String)
/** 查询全部食材记录(含已删除),用于数据库调试查看 */
@Query("SELECT * FROM dm_cook_food_goods ORDER BY createTime DESC")
suspend fun getAllCookFoodGoods(): MutableList<CookFoodGoodsEntity>
@@ -18,6 +18,7 @@ import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.chad.library.adapter4.util.setOnDebouncedItemClick
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.shuwei.dish.match.R
import com.shuwei.dish.match.adapter.FoodAdapter
@@ -28,6 +29,8 @@ import com.shuwei.dish.match.dialog.SeasoningSelectDialog.Companion.PAGE_SIZE
import com.shuwei.dish.match.entity.GoodsItem
import com.shuwei.dish.match.net.UiState
import com.shuwei.dish.match.utils.KeyboardUtil
import com.shuwei.dish.match.utils.buildMockGoodsList
import com.shuwei.dish.match.utils.showRawMaterialsDialog
import com.shuwei.dish.match.utils.ext.addOnActionSearchListener
import com.shuwei.dish.match.utils.ext.toast
import kotlinx.coroutines.launch
@@ -50,36 +53,25 @@ class FoodSearchDialog(
private val binding = DialogFoodSearchBinding.inflate(LayoutInflater.from(activity))
private val list = mutableListOf<GoodsItem>()
// .apply {
// add(GoodsItem(goodsId = "200001", goodsName = "土豆丝"))
// add(GoodsItem(goodsId = "200002", goodsName = "土豆片"))
// add(GoodsItem(goodsId = "200003", goodsName = "土豆丁"))
// add(GoodsItem(goodsId = "200004", goodsName = "胡萝卜丝"))
// add(GoodsItem(goodsId = "200005", goodsName = "胡萝卜片"))
// add(GoodsItem(goodsId = "200006", goodsName = "胡萝卜丁"))
// add(GoodsItem(goodsId = "200007", goodsName = "黄瓜丝"))
// add(GoodsItem(goodsId = "200008", goodsName = "黄瓜片"))
// add(GoodsItem(goodsId = "200009", goodsName = "黄瓜丁"))
// add(GoodsItem(goodsId = "200010", goodsName = "洋葱丝"))
// add(GoodsItem(goodsId = "200011", goodsName = "洋葱丁"))
// add(GoodsItem(goodsId = "200012", goodsName = "A"))
// add(GoodsItem(goodsId = "200013", goodsName = "B"))
// add(GoodsItem(goodsId = "200014", goodsName = "C"))
// add(GoodsItem(goodsId = "200015", goodsName = "D"))
// add(GoodsItem(goodsId = "200016", goodsName = "E"))
// add(GoodsItem(goodsId = "200017", goodsName = "F"))
// add(GoodsItem(goodsId = "200018", goodsName = "G"))
// add(GoodsItem(goodsId = "200019", goodsName = "H"))
// }
private val adapter = FoodAdapter(list).apply {
isStateViewEnable = true
setOnItemClickListener { _, _, position ->
list[position].isClicked = true
notifyItemChanged(position)
binding.root.postDelayed({
onItemSelected(list[position])
dismiss()
}, 300)
setOnDebouncedItemClick { _, _, position ->
val item = list[position]
// 弹出净材种类单选弹窗,确认后再回调并关闭弹窗
showRawMaterialsDialog(
context = activity,
types = item.rawMaterialsTypes,
checkedType = item.rawMaterialsType
) { selectedType ->
item.rawMaterialsType = selectedType
item.isClicked = true
notifyItemChanged(position)
binding.root.postDelayed({
onItemSelected(item)
dismiss()
}, 300)
}
}
}
@@ -124,7 +116,7 @@ class FoodSearchDialog(
}
binding.refreshLayout.run {
setEnableRefresh(false)
setEnableRefresh(true)
setEnableLoadMore(false)
setOnRefreshListener { pageNo = 1; getGoodsList() }
setOnLoadMoreListener { getGoodsList() }
@@ -183,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 -> {
@@ -216,10 +209,10 @@ class FoodSearchDialog(
}
/**
* 请求食材列表
* 请求食材列表,使用弹窗专用接口,结果写入 foodSearchGoodsListState
*/
private fun getGoodsList() {
activity.netViewModel.queryGoodsList(
activity.netViewModel.queryFoodSearchGoodsList(
goodsType = "0",
pageNum = pageNo,
pageSize = PAGE_SIZE,
@@ -241,6 +234,11 @@ class FoodSearchDialog(
}
binding.recyclerView.layoutManager = GridLayoutManager(activity, 2, GridLayoutManager.VERTICAL, false)
if (pageNo == 1) list.clear()
// 将接口返回的 rawMaterialsType(全部种类)移存到 rawMaterialsTypes,供弹窗使用
records.forEach {
it.rawMaterialsTypes = it.rawMaterialsType
it.rawMaterialsType = null
}
list.addAll(records)
adapter.notifyDataSetChanged()
val isLoadMoreEnable = records.size >= PAGE_SIZE
@@ -269,16 +267,19 @@ class FoodSearchDialog(
false
)
}
emptyViewBinding!!.tvContent.text = "暂无数据"
emptyViewBinding!!.tvSubContent.text = "未查询到食材信息,请联系管理员添加"
emptyViewBinding!!.root.layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
)
binding.recyclerView.layoutManager = LinearLayoutManager(activity)
binding.recyclerView.post {
emptyViewBinding!!.root.minimumHeight = binding.recyclerView.height
adapter.stateView = emptyViewBinding!!.root
emptyViewBinding?.let {
it.tvContent.text = "暂无数据"
it.tvSubContent.text = "未查询到食材信息,请联系管理员添加"
it.root.setOnClickListener { loadGoodsList(buildMockGoodsList().toMutableList()) }
it.root.layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
)
binding.recyclerView.layoutManager = LinearLayoutManager(activity)
binding.recyclerView.post {
it.root.minimumHeight = binding.recyclerView.height
adapter.stateView = it.root
}
}
} catch (e: Exception) {
e.printStackTrace()
@@ -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,25 +186,29 @@ class SeasoningSelectDialog(
* 请求调料列表
*/
private fun getGoodsList() {
activity.netViewModel.queryGoodsList(
activity.netViewModel.queryFoodSearchGoodsList(
goodsType = "1",
pageNum = pageNo,
pageSize = PAGE_SIZE,
goodsName = goodsName.takeUnless { it.isNullOrBlank() }
)
}
// // TODO: 联调时注释掉下方模拟数据,改为真实接口调用
// val nameList = listOf(
// "盐", "白砂糖","冰糖", "鸡精", "味精", "陈醋", "老抽", "生抽", "番茄酱", "胡椒粉", "孜然", "十三香",
// "料酒", "白醋", "蚝油", "辣椒面", "辣椒酱", "豆瓣酱", "淀粉", "葱", "蒜", "姜", "香菜", "油"
// )
//
// val mockData = mutableListOf<GoodsItem>()
// nameList.forEachIndexed { index, name ->
// val padString = "${index + 1}".padStart(2, '0')
// mockData.add(GoodsItem(goodsId = "1000$padString", goodsName = name))
// }
// loadGoodsList(mockData)
/**
* 加载本地模拟数据,仅用于联调时点击空布局快速验证 UI
*/
private fun loadMockData() {
val nameList = listOf(
"", "白砂糖", "冰糖", "鸡精", "味精", "陈醋", "老抽", "生抽", "番茄酱", "胡椒粉", "孜然", "十三香",
"料酒", "白醋", "蚝油", "辣椒面", "辣椒酱", "豆瓣酱", "淀粉", "", "", "", "香菜", ""
)
val mockData = mutableListOf<GoodsItem>()
nameList.forEachIndexed { index, name ->
val padString = "${index + 1}".padStart(2, '0')
mockData.add(GoodsItem(goodsId = "1000$padString", goodsName = name))
}
pageNo = 1
loadGoodsList(mockData)
}
/**
@@ -251,16 +255,20 @@ class SeasoningSelectDialog(
false
)
}
emptyViewBinding!!.tvContent.text = "暂无数据"
emptyViewBinding!!.tvSubContent.text = "未查询到调料信息,请联系管理员添加"
emptyViewBinding!!.root.layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
)
binding.recyclerView.layoutManager = LinearLayoutManager(activity)
binding.recyclerView.post {
emptyViewBinding!!.root.minimumHeight = binding.recyclerView.height
adapter.stateView = emptyViewBinding!!.root
emptyViewBinding?.let {
it.tvContent.text = "暂无数据"
it.tvSubContent.text = "未查询到调料信息,请联系管理员添加"
// 点击空布局加载本地模拟数据,方便联调时验证 UI
it.root.setOnClickListener { loadMockData() }
it.root.layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
)
binding.recyclerView.layoutManager = LinearLayoutManager(activity)
binding.recyclerView.post {
it.root.minimumHeight = binding.recyclerView.height
adapter.stateView = it.root
}
}
} catch (e: Exception) {
e.printStackTrace()
@@ -0,0 +1,41 @@
package com.shuwei.dish.match.entity
import java.io.Serializable
/**
* 菜品详情接口传输对象(DTO),不依赖 Room
* 对应接口:getConstituteByFoodId / saveConstitute
*/
data class CookFoodDTO(
var foodId: String = "",
var foodName: String? = null,
var canteenId: String? = null,
var foodWeight: Double = 0.0,
// 0-制作模式,1-采样模式,2-品控模式
var cookMode: Int = 0,
var dinnerType: String? = "0",
// 接口返回的食材构成列表
var matchingConstituteInfoList: MutableList<CookFoodGoodsDTO>? = null,
var foodConstituteList: MutableList<CookFoodGoodsDTO>? = null
) : Serializable {
/**
* 转换为 Room 实体,用于写入本地数据库
* id/isDel/createTime 由 Room 自行管理,不从 DTO 携带
*/
fun toEntity(): CookFoodEntity = CookFoodEntity(
foodId = foodId,
foodName = foodName,
canteenId = canteenId,
foodWeight = foodWeight,
cookMode = cookMode,
dinnerType = dinnerType
).also { entity ->
entity.matchingConstituteInfoList = matchingConstituteInfoList
?.map { it.toEntity() }
?.toMutableList()
entity.foodConstituteList = foodConstituteList
?.map { it.toEntity() }
?.toMutableList()
}
}
@@ -18,6 +18,8 @@ data class CookFoodEntity(
//0-制作模式,1-采样模式,2-品控模式
var cookMode:Int = 0,
var isDel: Int = 0,
/** 接口提交状态:0-未提交(本地草稿或手动删除),1-接口提交成功后删除 */
var submitState: Int = 0,
var createTime: String = DateTimeUtil.formatDateTime(LocalDateTime.now()),
var dinnerType: String? = "0",
) : BaseEntity {
@@ -0,0 +1,117 @@
package com.shuwei.dish.match.entity
import java.io.Serializable
/**
* 菜品构成(主辅料/调料)接口传输对象(DTO),不依赖 Room
* 对应 CookFoodDTO 中 foodConstituteList、matchingConstituteInfoList 的元素
*/
data class CookFoodGoodsDTO(
var goodsId: String = "",
var goodsName: String? = null,
var foodId: String? = "",
var goodsOrRelationCode: String? = "",
// 调料数据顺序
var sort: Int = 0,
// 食材原材料:1,预制品:2
var relateionType: Int = 0,
// 物品类型:1主料 2辅料 3调料
var materialType: Int = 0,
// 物品 是否全部可食:true-全部可食,false-部分可食
var allEdible: Boolean = true,
// 物品 用料重量
var useWeight: Double? = 0.0,
var popularName: String? = "",
var canteenId: String? = "",
var relateionType_dictText: String? = "",
var zjmCode: String? = "",
var materId: String? = "",
// 物料编码
var goodsCode: String? = "",
// 净材种类
var rawMaterialsType: String? = null
) : Serializable {
/**
* 转换为 GoodsItem,用于 UI 层
*/
fun toGoodsItem(): GoodsItem = GoodsItem(
goodsId = goodsId,
goodsName = goodsName,
popularName = popularName,
zjmCode = zjmCode,
materId = materId,
materCode = goodsCode,
materialType = materialType,
useWeight = useWeight,
relateionType = relateionType,
allEdible = allEdible,
goodsOrRelationCode = goodsOrRelationCode,
rawMaterialsType = rawMaterialsType
)
/**
* 转换为 Room 实体,用于写入本地数据库
* id/pid/isDel/createTime 由 Room 自行管理,不从 DTO 携带
*/
fun toEntity(): CookFoodGoodsEntity = CookFoodGoodsEntity(
goodsId = goodsId,
goodsName = goodsName,
foodId = foodId,
goodsOrRelationCode = goodsOrRelationCode,
sort = sort,
relateionType = relateionType,
materialType = materialType,
allEdible = allEdible,
useWeight = useWeight,
popularName = popularName,
canteenId = canteenId,
relateionType_dictText = relateionType_dictText,
zjmCode = zjmCode,
materId = materId,
goodsCode = goodsCode,
rawMaterialsType = rawMaterialsType
)
}
/**
* Room 实体 → DTO 反向映射,用于将本地数据转为接口提交格式
* id/pid/isDel/createTime 为本地数据库字段,不参与提交
*/
fun CookFoodGoodsEntity.toDTO(): CookFoodGoodsDTO = CookFoodGoodsDTO(
goodsId = goodsId,
goodsName = goodsName,
foodId = foodId,
goodsOrRelationCode = goodsOrRelationCode,
sort = sort,
relateionType = relateionType,
materialType = materialType,
allEdible = allEdible,
useWeight = useWeight,
popularName = popularName,
canteenId = canteenId,
relateionType_dictText = relateionType_dictText,
zjmCode = zjmCode,
materId = materId,
goodsCode = goodsCode,
rawMaterialsType = rawMaterialsType
)
/**
* Room 实体 → DTO 反向映射,用于将本地数据转为接口提交格式
* id/isDel/createTime 为本地数据库字段,不参与提交
*/
fun CookFoodEntity.toDTO(): CookFoodDTO = CookFoodDTO(
foodId = foodId,
foodName = foodName,
canteenId = canteenId,
foodWeight = foodWeight,
cookMode = cookMode,
dinnerType = dinnerType,
matchingConstituteInfoList = matchingConstituteInfoList
?.map { it.toDTO() }
?.toMutableList(),
foodConstituteList = foodConstituteList
?.map { it.toDTO() }
?.toMutableList()
)
@@ -24,7 +24,7 @@ data class GoodsItem(
// 从 CookFoodGoodsEntity @Ignore 迁移的 UI 状态字段
var isSetFinished: Boolean = false,
var isItemClicked: Boolean = false,
// var isItemClicked: Boolean = false,
var isSamplingPage: Boolean = false,
var isNewDishType: Boolean = false,
var isClicked: Boolean = false,
@@ -0,0 +1,12 @@
package com.shuwei.dish.match.entity
/**
* 物品名称批量查询入参 DTO
* 用于 queryGoodsInfoList 接口 goodsNames 字段的元素类型
* @param goodsName 物品名称
* @param rawMaterialsType 净材种类
*/
data class GoodsNameQueryDTO(
val goodsName: String,
val rawMaterialsType: String
)
@@ -1,7 +1,7 @@
package com.shuwei.dish.match.net
import com.shuwei.dish.match.base.GlobalData
import com.shuwei.dish.match.entity.CookFoodEntity
import com.shuwei.dish.match.entity.CookFoodDTO
import com.shuwei.dish.match.entity.FoodRecord
import com.shuwei.dish.match.entity.GoodsItem
import okhttp3.MultipartBody
@@ -24,7 +24,7 @@ interface ApiService {
suspend fun getFoodDetail(
@Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/getConstituteByFoodId",
@Query("foodId") foodId: String
): ApiResponse<CookFoodEntity?>
): ApiResponse<CookFoodDTO?>
/**
@@ -33,7 +33,7 @@ interface ApiService {
@POST
suspend fun submitCookFood(
@Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/saveConstitute",
@Body param: CookFoodEntity
@Body param: CookFoodDTO
): ApiResponse<Any?>
/**
@@ -70,7 +70,7 @@ interface ApiService {
@POST
suspend fun uploadFoodVectorData(
@Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/addWeighingCollectionData",
@PartMap params: Map<String, RequestBody>,
@PartMap params: MutableMap<String, RequestBody>,
@Part foodPics: List<MultipartBody.Part>
): ApiResponse<List<String>?>
}
@@ -3,9 +3,10 @@ package com.shuwei.dish.match.net
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.entity.CookFoodEntity
import com.shuwei.dish.match.entity.CookFoodDTO
import com.shuwei.dish.match.entity.FoodRecord
import com.shuwei.dish.match.entity.GoodsItem
import com.shuwei.dish.match.entity.GoodsNameQueryDTO
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -33,7 +34,7 @@ class NetViewModel(
/**
* 提交制作菜品
*/
fun submitCookFood(entity: CookFoodEntity) {
fun submitCookFood(entity: CookFoodDTO) {
viewModelScope.launch {
_submitCookFoodState.value = UiState.Loading
_submitCookFoodState.value = repository.submitCookFood(entity)
@@ -43,8 +44,8 @@ class NetViewModel(
/**
* 查询菜品详情的 UI 状态流,UI 层通过 collect 监听
*/
private val _foodDetailState = MutableStateFlow<UiState<CookFoodEntity?>>(UiState.Idle)
val foodDetailState: StateFlow<UiState<CookFoodEntity?>> = _foodDetailState.asStateFlow()
private val _foodDetailState = MutableStateFlow<UiState<CookFoodDTO?>>(UiState.Idle)
val foodDetailState: StateFlow<UiState<CookFoodDTO?>> = _foodDetailState.asStateFlow()
/**
* 查询菜品详情
@@ -101,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=调料
@@ -108,7 +115,7 @@ class NetViewModel(
* @param pageNum 页码,默认第 1 页
* @param pageSize 每页条数,默认 50
* @param goodsName 按名称模糊搜索,为 null 时不传该字段
* @param goodsNames 按名称列表批量查询,为 null 时不传该字段
* @param goodsNames 按名称+净材种类列表批量查询,为 null 时不传该字段
*/
fun queryGoodsList(
goodsType: String,
@@ -116,7 +123,7 @@ class NetViewModel(
pageNum: Int = 1,
pageSize: Int = 50,
goodsName: String? = null,
goodsNames: List<String>? = null
goodsNames: List<GoodsNameQueryDTO>? = null
) {
val param = mutableMapOf<String, Any>(
"goodsType" to goodsType,
@@ -132,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 监听
*/
@@ -146,7 +184,7 @@ class NetViewModel(
params: MutableMap<String, RequestBody>
) {
val fileParts = fileList.map { file ->
file.asRequestBody("multipart/form-data".toMediaTypeOrNull())
file.asRequestBody("image/jpeg".toMediaTypeOrNull())
.let { MultipartBody.Part.createFormData("foodPics", file.name, it) }
}
viewModelScope.launch {
@@ -166,6 +204,7 @@ class NetViewModel(
params: MutableMap<String, RequestBody>
): UiState<List<String>?> {
val fileParts = fileList.map { file ->
//multipart/form-data image/jpeg
file.asRequestBody("multipart/form-data".toMediaTypeOrNull())
.let { MultipartBody.Part.createFormData("foodPics", file.name, it) }
}
@@ -1,6 +1,6 @@
package com.shuwei.dish.match.net
import com.shuwei.dish.match.entity.CookFoodEntity
import com.shuwei.dish.match.entity.CookFoodDTO
import com.shuwei.dish.match.entity.FoodRecord
import com.shuwei.dish.match.entity.GoodsItem
import okhttp3.MultipartBody
@@ -18,7 +18,7 @@ class RemoteRepository {
* @param entity 菜品实体
* @return UiState 包装的结果,Success 表示提交成功,Error 携带错误信息
*/
suspend fun submitCookFood(entity: CookFoodEntity): UiState<Any?> {
suspend fun submitCookFood(entity: CookFoodDTO): UiState<Any?> {
return try {
val resp = apiService.submitCookFood(param = entity)
if (resp.isSuccess()) UiState.Success(resp.data)
@@ -34,7 +34,7 @@ class RemoteRepository {
* @param foodId 菜品 ID
* @return UiState 包装的结果,Success 携带详情数据,Error 携带错误信息
*/
suspend fun getFoodDetail(foodId: String): UiState<CookFoodEntity?> {
suspend fun getFoodDetail(foodId: String): UiState<CookFoodDTO?> {
return try {
val resp = apiService.getFoodDetail(foodId = foodId)
if (resp.isSuccess()) UiState.Success(resp.data)
@@ -100,7 +100,7 @@ class RemoteRepository {
* @return UiState 包装的结果,Success 携带列表数据,Error 携带错误信息
*/
suspend fun uploadFoodVectorData(
params: Map<String, RequestBody>,
params: MutableMap<String, RequestBody>,
foodPics: List<MultipartBody.Part>
): UiState<List<String>?> {
return try {
@@ -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()
@@ -124,7 +129,12 @@ object FoodModule {
val nameScoreList = mutableListOf<IdNameScore>()
idScoreList.forEach {
val name = foodMap[it.id]?.name ?: ""
nameScoreList.add(IdNameScore(id = it.id, name = name, score = it.score))
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()}")
return nameScoreList
@@ -176,7 +186,8 @@ object FoodModule {
data class IdNameScore(
val id: Long,
var name: String,
val score: Double
val score: Double,
val rawMaterialsType: String
)
suspend fun initDefFoodData(context: Context, action: () -> Unit = {}) {
@@ -10,6 +10,7 @@ import org.java_websocket.handshake.ClientHandshake
import org.java_websocket.server.WebSocketServer
import java.net.InetSocketAddress
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledFuture
import java.util.concurrent.TimeUnit
/**
@@ -50,6 +51,9 @@ class ScaleWebSocketServer(private val deviceId: String, private val context: Co
/** 缓存各秤最新数据,新客户端连接时立即同步当前状态 */
private val latestData = mutableMapOf<Int, ScaleData>()
/** 节流窗口内的延迟推送任务,key=address;保证窗口结束时最新数据被推送 */
private val pendingPushTasks = mutableMapOf<Int, ScheduledFuture<*>>()
/**
* 启动 WebSocket 服务器,并注册 WeightUtil 回调
*/
@@ -61,11 +65,12 @@ class ScaleWebSocketServer(private val deviceId: String, private val context: Co
// 注册本机秤数据监听
WeightUtil.addWeightListener(TAG) { address, state, weight ->
Log.d(TAG, "WebSocket 设备${deviceId}推送数据, address=$address, state=$state, weight=$weight")
val now = System.currentTimeMillis()
// 节流:同一地址 100ms 内只推送一次
if (now - (lastPushTime[address] ?: 0L) < THROTTLE_MS) return@addWeightListener
// 方案三:数据未变化时直接跳过,避免无效推送
val last = latestData[address]
if (last?.weight == weight && last.state == state) return@addWeightListener
lastPushTime[address] = now
val data = ScaleData(
deviceId = deviceId,
address = address,
@@ -74,8 +79,28 @@ class ScaleWebSocketServer(private val deviceId: String, private val context: Co
ts = now,
ip = NetworkUtil.getLocalIpAddress(context)
)
// 始终更新 latestData,保证最新一帧不丢失
latestData[address] = data
broadcast(gson.toJson(data))
val lastPush = lastPushTime[address] ?: 0L
if (now - lastPush >= THROTTLE_MS) {
// 窗口已过:立即推送,并取消可能存在的延迟任务
lastPushTime[address] = now
pendingPushTasks.remove(address)?.cancel(false)
broadcast(gson.toJson(data))
} else if (pendingPushTasks[address] == null) {
// 方案一:窗口内首次触发时安排一个 trailing 推送,保证窗口末尾推最新值
val delay = THROTTLE_MS - (now - lastPush)
if (!scheduler.isShutdown) {
pendingPushTasks[address] = scheduler.schedule({
val latest = latestData[address] ?: return@schedule
lastPushTime[address] = System.currentTimeMillis()
pendingPushTasks.remove(address)
broadcast(gson.toJson(latest))
}, delay, TimeUnit.MILLISECONDS)
}
}
// 窗口内且已安排过 trailing 任务:仅更新 latestData,等 trailing 触发时推送
}
}
@@ -96,6 +121,8 @@ class ScaleWebSocketServer(private val deviceId: String, private val context: Co
server = null
latestData.clear()
lastPushTime.clear()
pendingPushTasks.values.forEach { it.cancel(false) }
pendingPushTasks.clear()
Log.d(TAG, "WebSocket 服务端已停止")
}
@@ -140,9 +140,9 @@ class CookingModeActivity : BaseActivity() {
}
}
fun deleteCookFoodAndGoods(foodId: String, action: () -> Unit) {
fun deleteCookFoodAndGoods(foodId: String, dinnerType: String, action: () -> Unit) {
lifecycleScope.launch {
appViewModel.deleteCookFoodAndGoods(cookMode = 0, foodId = foodId)
appViewModel.deleteCookFoodAndGoods(cookMode = 0, foodId = foodId, dinnerType = dinnerType)
action()
}
}
@@ -12,6 +12,7 @@ import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.databinding.ActivityFoodRecognizeBinding
import com.shuwei.dish.match.dialog.FoodSearchDialog
import com.shuwei.dish.match.entity.GoodsItem
import com.shuwei.dish.match.entity.GoodsNameQueryDTO
import com.shuwei.dish.match.utils.ActivityManager
import com.shuwei.dish.match.utils.WeightUtil
import com.shuwei.dish.match.utils.ext.clickWithDebounce
@@ -24,6 +25,7 @@ import com.shuwei.dish.match.net.UiState
import com.shuwei.dish.match.objbox.FoodModule
import com.shuwei.dish.match.utils.ext.dp
import com.shuwei.dish.match.utils.ext.toast
import com.shuwei.dish.match.utils.showRawMaterialsDialog
import kotlinx.coroutines.launch
/**
@@ -110,8 +112,8 @@ class FoodRecognizeActivity : BaseActivity() {
/** 通过name查询IdNameScore */
private val nameScoreMap = mutableMapOf<String, FoodModule.IdNameScore>()
/** 识别物品名称列表 */
private val goodsNameList = mutableListOf<String>()
/** 识别物品名称列表(名称 + 净材种类,用于批量查询接口) */
private val goodsNameList = mutableListOf<GoodsNameQueryDTO>()
private val scrollListener = object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
@@ -121,12 +123,32 @@ class FoodRecognizeActivity : BaseActivity() {
private val adapter = FoodAdapter(list).apply {
setOnItemClickListener { _, _, position ->
list.forEachIndexed { i, item -> item.isClicked = (i == position) }
// rawMaterialsRemind(position)
val item = list[position]
list.forEachIndexed { i, it -> it.isClicked = (i == position) }
// 点击选择时记录当前秤重量
list[position].useWeight = currentWeight
item.useWeight = currentWeight
notifyDataSetChanged()
selectedPosition = position
binding.tvSelectedFood.text = list[position].goodsName ?: "-"
binding.tvSelectedFood.text = item.goodsName ?: "-"
}
}
private fun rawMaterialsRemind(position: Int) {
val item = list[position]
// 弹出净材种类单选弹窗,确认后再选中该食材
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 ?: "-"
}
}
@@ -167,9 +189,11 @@ class FoodRecognizeActivity : BaseActivity() {
/**
* 从 Intent 读取数据并初始化页面
* Reads data from Intent and initializes the page
*/
private fun initData() {
// 加载食材照片
// Load food photo
val imageUri = intent.getStringExtra(EXTRA_IMAGE_URI)
if (!imageUri.isNullOrBlank()) {
binding.ivFoodPhoto.setImageURI(imageUri.toUri())
@@ -177,10 +201,15 @@ class FoodRecognizeActivity : BaseActivity() {
currentWeight = intent.getDoubleExtra(EXTRA_CURRENT_WEIGHT, 0.0)
binding.tvWeight.text = "${currentWeight}g"
// val tempList = arrayListOf<GoodsItem>()
// Process recognized name list if available
recognizeNameList?.apply {
// Iterate through each recognized item
forEach {
// Extract goods name by splitting "WP" and taking the first part
val goodsName = it.name.split("WP").first()
goodsNameList.add(goodsName)
val rawMaterialsType = it.rawMaterialsType
// Add to goods name list and name-score mapping
goodsNameList.add(GoodsNameQueryDTO(goodsName, rawMaterialsType))
nameScoreMap.put(goodsName, it)
// // TODO: 测试数据,临时使用-------------------------
@@ -191,11 +220,16 @@ class FoodRecognizeActivity : BaseActivity() {
// // TODO: 测试数据,临时使用-------------------------
// loadRecognizeList(tempList)
// // TODO: 测试数据,临时使用-------------------------
// Clear the recognize name list after processing
recognizeNameList = null
}
//TODO: 注释掉等接口正常再调用,临时使用测试数据
// If goods name list is not empty, query goods list from network
// if (goodsNameList.isNotEmpty()){
netViewModel.queryGoodsList(goodsType = "0", goodsNames = goodsNameList)
// } else {
// loadRecognizeList(emptyList())
// }
}
private fun buildTestData(item: FoodModule.IdNameScore): GoodsItem {
@@ -256,8 +290,13 @@ class FoodRecognizeActivity : BaseActivity() {
binding.tvNoData.gone()
list.clear()
goodsList.forEach {
val food = nameScoreMap[it.goodsName]
val foodScore = ((1 - (food?.score ?: 0.0)) * 10000).toInt()
var foodScore = -1
nameScoreMap[it.goodsName]?.let { food ->
foodScore = ((1 - food.score) * 10000).toInt()
}
// // 将接口返回的 rawMaterialsType(全部种类)移存到 rawMaterialsTypes,供弹窗使用
// it.rawMaterialsTypes = it.rawMaterialsType
// it.rawMaterialsType = null
list.add(it.also { it.foodScore = foodScore })
}
adapter.notifyDataSetChanged()
@@ -143,7 +143,8 @@ class InitActivity : BaseActivity() {
override fun onDestroy() {
countdownHandler.removeCallbacksAndMessages(null)
WeightUtil.stopContinuousRead()
ScaleServiceManager.stop()
// 秤服务伴随进程生命周期,无需在 Activity 销毁时停止;进程退出时 OS 会自动回收所有资源
// ScaleServiceManager.stop()
super.onDestroy()
}
@@ -19,7 +19,7 @@ import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.databinding.ActivityPrepareFoodBinding
import com.shuwei.dish.match.databinding.LayoutCameraPreviewBinding
import com.shuwei.dish.match.dialog.CommonDialog
import com.shuwei.dish.match.entity.CookFoodEntity
import com.shuwei.dish.match.entity.CookFoodDTO
import com.shuwei.dish.match.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.entity.FoodRecord
import com.shuwei.dish.match.entity.GoodsItem
@@ -129,6 +129,16 @@ class PrepareFoodActivity : BaseActivity() {
WeightUtil.tareTwo(AddressUtil.ONE)
delayDismissLoading()
}
// TODO: 测试功能-------------------------------
binding.btnTestConfirm.clickWithDebounce {
if (currentWeight <= 0.0) return@clickWithDebounce
val (index, item) = list.withIndex().find { (_, it) -> it.isOriginalData && it.isClicked && (it.useWeight?:0.0) <= 0.0 } ?: return@clickWithDebounce
item.useWeight = currentWeight
item.isSetFinished = true
list.forEachIndexed { i, it -> it.isClicked = i == index }
materialAdapter.notifyDataSetChanged()
}
// TODO: 测试功能-------------------------------
//binding.root.setOnClickListener { hideKeyboard() }
WeightUtil.addWeightListener(
weightKey = TAG,
@@ -221,6 +231,7 @@ class PrepareFoodActivity : BaseActivity() {
}
if (
!pageVisible || manualCancelFlag || isTakingPhoto.get() || showRecognizePage ||
notRecognizeDialog?.isShowing == true ||
abs(lastWeight - weight) <= WEIGHT_CHANGE_VALUE
) {
Log.d(
@@ -242,16 +253,25 @@ class PrepareFoodActivity : BaseActivity() {
* - list 中不含该 goodsId:直接新增,isNewDishType = true
* - list 中已含该 goodsId:累加 useWeight
*/
private val foodSelectCallback: (GoodsItem) -> Unit = { entity ->
val existing = list.firstOrNull { it.goodsId == entity.goodsId }
if (existing != null) {
existing.useWeight = (existing.useWeight ?: 0.0) + (entity.useWeight ?: 0.0)
existing.isSetFinished = true
materialAdapter.notifyItemChanged(list.indexOf(existing))
private val foodSelectCallback: (GoodsItem) -> Unit = foodSelectCallback@{ entity ->
val filterValue = list.withIndex().find { (_, it) -> it.goodsId == entity.goodsId }
if (filterValue != null) {
val index = filterValue.index
val item = filterValue.value
item.useWeight = (item.useWeight ?: 0.0) + (entity.useWeight ?: 0.0)
item.isSetFinished = true
list.forEachIndexed { i, it ->
item.isClicked = i == index
}
} else {
materialAdapter.add(entity.also {
list.forEach { it.isClicked = false }
list.add(entity.also {
it.isNewDishType = true
it.isSetFinished = true
// 标记为非接口数据,否则 updateMaterialTypes 不会为其计算 materialType
// 导致列表中不显示"主辅材:主材/辅材"
it.isOriginalData = false
it.isClicked = true
})
binding.rvMaterialList.smoothScrollToPosition(list.size - 1)
}
@@ -349,7 +369,7 @@ class PrepareFoodActivity : BaseActivity() {
}
private fun loadDishDetail(detail: CookFoodEntity) {
private fun loadDishDetail(detail: CookFoodDTO) {
// val voList = detail.stFoodInfoConstituteList
val voList = detail.foodConstituteList
if (voList.isNullOrEmpty()) {
@@ -373,7 +393,7 @@ class PrepareFoodActivity : BaseActivity() {
FoodMaterialAdapter(list).apply {
onItemClick = { positon ->
list.forEachIndexed { index, entity ->
entity.isItemClicked = index == positon
entity.isClicked = index == positon
}
notifyDataSetChanged()
}
@@ -555,6 +575,9 @@ class PrepareFoodActivity : BaseActivity() {
dismissLoading()
Log.d(TAG, "takePhoto foodScoreList is empty")
isTakingPhoto.set(false)
withContext(Dispatchers.Main) {
showNotRecognizeDialog()
}
return@withContext
}
withContext(Dispatchers.Main) {
@@ -567,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()
}
}
/**
* 拍照失败回调
@@ -75,6 +75,7 @@ class SamplingModeActivity : BaseActivity() {
foodName = entity.foodName
cookMode = 1
isCooking = true
dinnerType = entity.dinnerType ?: "0"
})
}
list.clear()
@@ -146,9 +147,9 @@ class SamplingModeActivity : BaseActivity() {
}
}
fun deleteCookFoodAndGoods(foodId: String, action: () -> Unit) {
fun deleteCookFoodAndGoods(foodId: String, dinnerType: String, action: () -> Unit) {
lifecycleScope.launch {
appViewModel.deleteCookFoodAndGoods(cookMode = 1, foodId = foodId)
appViewModel.deleteCookFoodAndGoods(cookMode = 1, foodId = foodId, dinnerType = dinnerType)
action()
}
}
@@ -210,7 +211,7 @@ class SamplingModeActivity : BaseActivity() {
.setContent("确定要删除「${item.foodName}」吗?")
.setNegativeButton("取消")
.setPositiveButton("删除") {
deleteCookFoodAndGoods(foodId = item.foodId ?: "") {
deleteCookFoodAndGoods(foodId = item.foodId ?: "", dinnerType = item.dinnerType) {
if (list.size > 1) {
dishAdapter.removeAt(position)
} else {
@@ -20,6 +20,7 @@ import com.shuwei.dish.match.dialog.CommonDialog
import com.shuwei.dish.match.entity.CookFoodEntity
import com.shuwei.dish.match.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.entity.FoodRecord
import com.shuwei.dish.match.entity.toDTO
import com.shuwei.dish.match.net.UiState
import com.shuwei.dish.match.scale.ScaleDeviceConfig
import com.shuwei.dish.match.scale.ScaleServiceManager
@@ -45,6 +46,9 @@ class SubmitFoodActivity : BaseActivity() {
const val TAG = "SubmitFoodActivity"
const val FOOD_ITEM = "foodItem"
const val GOODS_LIST = "goodsList"
/** 本地临时 foodId 前缀;以此开头说明菜品尚未在服务器登记,提交时需清空让服务器分配真实 ID */
const val LOCAL_ID_PREFIX = "localId_"
}
private lateinit var binding: ActivitySubmitFoodBinding
@@ -91,6 +95,11 @@ class SubmitFoodActivity : BaseActivity() {
*/
private val baseSeasoningMap = mutableMapOf<String, Pair<String, Double>>()
/**
* 各秤上一次处理过的 ts,用于跳过 StateFlow 因其他秤变化而重发的相同帧
*/
private val lastTsMap = mutableMapOf<String, Long>()
@Suppress("unchecked_cast", "DEPRECATION")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -147,7 +156,7 @@ class SubmitFoodActivity : BaseActivity() {
}
}
/** 初始化 RecyclerView24列 GridLayoutManager */
/** 初始化 RecyclerView默认 4 列 GridLayoutManager */
private fun setupRecyclerView() {
binding.rvSeasoning.let {
it.itemAnimator = null
@@ -158,7 +167,8 @@ class SubmitFoodActivity : BaseActivity() {
/**
* 从 Room 加载槽位配置后,开始订阅子设备秤数据
* isCooking=true 时从数据库查询 goodsList,筛选调料预填充 adapter,再开始观测
* isCooking=true 时从数据库查询 goodsList预填充调料 adapter
* isCooking=false 时从前一页面带入的 goodsList 中预填充调料 adapter,再开始观测
*/
private fun loadSlotsThenObserveScales() {
lifecycleScope.launch {
@@ -170,24 +180,34 @@ class SubmitFoodActivity : BaseActivity() {
// 制作中:从数据库查询 goodsList,筛选调料数据预填充 adapter
val foodId = food?.foodId ?: return@launch
val cookMode = food?.cookMode ?: return@launch
val list = appViewModel.getCookFoodGoodsList(foodId, cookMode)
val list =
appViewModel.getCookFoodGoodsList(foodId, cookMode, food?.dinnerType ?: "0")
goodsList = list
list.filter { it.materialType == 3 }.forEach { item ->
val name = item.goodsName ?: ""
val weight = item.useWeight ?: 0.0
baseSeasoningMap[name] = (item.goodsId) to weight
seasoningAdapter.updateItem(
SeasoningWeightAdapter.Item(
goodsId = item.goodsId,
goodsName = name,
useWeight = weight
)
)
}
observeScaleData()
prefillSeasoningFromGoodsList(list)
} else {
observeScaleData()
// 非制作中:从前一页面带入的 goodsList 中筛选调料数据预填充 adapter
goodsList?.let { prefillSeasoningFromGoodsList(it) }
}
observeScaleData()
}
}
/**
* 从 goodsList 中筛选 materialType==3 的调料数据,
* 写入 baseSeasoningMap 作为历史用量基准,并预填充到 adapter
*/
private fun prefillSeasoningFromGoodsList(list: List<CookFoodGoodsEntity>) {
list.filter { it.materialType == 3 }.forEach { item ->
val name = item.goodsName ?: ""
val weight = item.useWeight ?: 0.0
baseSeasoningMap[name] = item.goodsId to weight
seasoningAdapter.updateItem(
SeasoningWeightAdapter.Item(
goodsId = item.goodsId,
goodsName = name,
useWeight = weight
)
)
}
}
@@ -199,13 +219,20 @@ class SubmitFoodActivity : BaseActivity() {
val flow = ScaleServiceManager.allScales ?: return
lifecycleScope.launch {
flow.collectLatest { scaleMap ->
//Log.d(TAG, "observeScaleData,scaleMap=${scaleMap.toJsonString()}")
scaleMap.forEach { (key, data) ->
// ts 未变化说明是其他秤更新触发的 StateFlow 重发,跳过
if (lastTsMap[key] == data.ts) return@forEach
lastTsMap[key] = data.ts
// 排除主设备 2格秤
if (data.deviceId == ScaleDeviceConfig.DEVICE_ID_2) return@forEach
// 从槽位配置读取调料名,未配置则跳过
val (goodsId, goodsName) = slotMap[key] ?: return@forEach
Log.d(
TAG,
"observeScaleData,1key=$key, goodsId=$goodsId, goodsName=$goodsName, data=$data"
)
// 首次收到稳定数据时记录基准重量
val baseline = baselineMap[key]
if (baseline == null) {
@@ -214,19 +241,28 @@ class SubmitFoodActivity : BaseActivity() {
}
return@forEach
}
Log.d(TAG, "observeScaleData,2key=$key, baseline=$baseline, data.weight=${data.weight}")
// 用量 = 初始重量 - 当前重量,负值归零
val useWeight = (baseline - data.weight)
.coerceAtLeast(0.0)
.roundedOneDecimalPlace()
// 更新原始用量 map,再按 goodsName 聚合刷新 adapter
if (useWeight < 0.5) {
rawWeightMap.remove(key)
// 放回场景:必须稳定后才移除,避免拿取过程中误删
if (data.state != WeightUtil.STATE_STABLE) return@forEach
if (rawWeightMap.remove(key) != null) {
Log.d(TAG, "observeScaleData,移除key=${key}, goodsName=$goodsName")
refreshAdapterByName(goodsName)
}
} else {
rawWeightMap[key] = Triple(goodsId, goodsName, useWeight)
// 拿取场景:实时响应,无需等待稳定
val newTriple = Triple(goodsId, goodsName, useWeight)
if (rawWeightMap[key] != newTriple) {
Log.d(TAG, "observeScaleData,新增:$newTriple")
rawWeightMap[key] = newTriple
refreshAdapterByName(goodsName)
}
}
refreshAdapterByName(goodsName)
}
}
}
@@ -235,30 +271,45 @@ class SubmitFoodActivity : BaseActivity() {
/**
* 按 goodsName 聚合 rawWeightMap,叠加历史用量后推给 adapter
* isCooking=true 时只新增,不移除(历史调料数据不可减少)
* 移除前检查 item 是否存在,更新前比较数据是否变化,避免无效操作
*/
private fun refreshAdapterByName(goodsName: String) {
val grouped = rawWeightMap.values.filter { it.second == goodsName }
val newWeight = grouped.sumOf { it.third }.roundedOneDecimalPlace()
val (baseGoodsId, baseWeight) = baseSeasoningMap[goodsName] ?: ("" to 0.0)
val totalWeight = (newWeight + baseWeight).roundedOneDecimalPlace()
Log.d(TAG, "refreshAdapterByName, goodsName=$goodsName, newWeight=$newWeight, baseWeight=$baseWeight, totalWeight=$totalWeight, isCooking=${food?.isCooking}")
if (totalWeight < 0.5) {
// 制作中模式:已有历史数据,不允许移除
if (food?.isCooking != true) {
seasoningAdapter.removeItem(goodsName)
Log.d(TAG, "refreshAdapterByName,重量小于0.5g, isCooking=${food?.isCooking}, goodsName=$goodsName, totalWeight=$totalWeight")
// 仅保护 baseSeasoningMap 中的历史调料;本次会话新加的调料可以移除
if (!baseSeasoningMap.containsKey(goodsName)) {
// 只有 item 确实存在时才调用 remove,避免无效遍历
if (seasoningAdapter.items.any { it.goodsName == goodsName }) {
seasoningAdapter.removeItem(goodsName)
}
}
return
}
val goodsId = grouped.firstOrNull()?.first?.takeIf { it.isNotEmpty() } ?: baseGoodsId
seasoningAdapter.updateItem(
SeasoningWeightAdapter.Item(
goodsId = goodsId,
goodsName = goodsName,
useWeight = totalWeight
)
// 数据未变化时跳过,避免触发不必要的 RecyclerView 刷新
if (seasoningAdapter.items.any { it.goodsName == goodsName && it.useWeight == totalWeight && it.goodsId == goodsId }) return
val item = SeasoningWeightAdapter.Item(
goodsId = goodsId,
goodsName = goodsName,
useWeight = totalWeight
)
Log.d(TAG, "refreshAdapterByName,更新数据item=${item.toJsonString()}")
seasoningAdapter.updateItem(item)
}
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() }
@@ -297,9 +348,11 @@ class SubmitFoodActivity : BaseActivity() {
val isSamplingData = food!!.cookMode == 1
if (isSamplingData) {
val tempFoodId = cookFoodEntity.foodId.ifBlank { "${System.currentTimeMillis()}" }
val tempFoodId = cookFoodEntity.foodId
.ifBlank { "$LOCAL_ID_PREFIX${System.currentTimeMillis()}" }
cookFoodEntity.foodId = tempFoodId
}
// 主辅材统一在此赋 foodId;新加的调料行 foodId 由 buildSeasoningEntity 内部设置
goodsList?.forEach { it.foodId = cookFoodEntity.foodId }
lifecycleScope.launch {
@@ -336,6 +389,8 @@ class SubmitFoodActivity : BaseActivity() {
lifecycleScope.launch {
// 先移除 goodsList 中已有的调料数据(materialType==3),再以 adapter 中最新调料覆盖
goodsList?.removeAll { it.materialType == 3 }
// 主辅材 foodId 统一以菜品 foodId 为准;新加调料行的 foodId 由 buildSeasoningEntity 内部设置
goodsList?.forEach { it.foodId = cookFoodEntity.foodId }
seasoningAdapter.items
.filter { it.useWeight > 0.0 }
.forEach { goodsList?.add(buildSeasoningEntity(it)) }
@@ -350,14 +405,15 @@ class SubmitFoodActivity : BaseActivity() {
}
}
val isSamplingData = food!!.cookMode == 1
if (isSamplingData) {
// 仅本地生成的临时 foodId 需清空,由服务器分配真实 ID;服务器下发的真实 foodId 直接保留
if (cookFoodEntity.foodId.startsWith(LOCAL_ID_PREFIX)) {
cookFoodEntity.foodId = ""
goodsList?.forEach { it.foodId = "" }
}
Log.d(TAG, "submit: json=${cookFoodEntity.toJsonString()}")
netViewModel.submitCookFood(entity = cookFoodEntity)
// 接口提交使用 DTO,与本地 Room 实体解耦
netViewModel.submitCookFood(entity = cookFoodEntity.toDTO())
}
}
@@ -368,6 +424,7 @@ class SubmitFoodActivity : BaseActivity() {
private suspend fun buildSeasoningEntity(item: SeasoningWeightAdapter.Item): CookFoodGoodsEntity {
val seasoning = appViewModel.getSeasoningByGoodsId(item.goodsId)
return CookFoodGoodsEntity().also { entity ->
entity.foodId = cookFoodEntity.foodId
entity.goodsId = item.goodsId
entity.goodsName = item.goodsName
entity.useWeight = item.useWeight
@@ -389,9 +446,10 @@ class SubmitFoodActivity : BaseActivity() {
return
}
lifecycleScope.launch {
appViewModel.deleteCookFoodAndGoods(
appViewModel.markSubmittedAndDelete(
cookMode = food!!.cookMode,
foodId = food!!.foodId!!
foodId = food!!.foodId!!,
dinnerType = food!!.dinnerType
)
dismissLoading()
jumpPage(isSamplingData, false)
@@ -109,7 +109,7 @@ class FoodListFragment : BaseFragment<FragmentFoodListBinding>() {
.setContent("确定要删除「${item.foodName}」吗?")
.setNegativeButton("取消")
.setPositiveButton("删除") {
activity.deleteCookFoodAndGoods(foodId = item.foodId ?: "") {
activity.deleteCookFoodAndGoods(foodId = item.foodId ?: "", dinnerType = item.dinnerType) {
activity.toast("已删除")
pageNo = 1
getDishList()
@@ -11,14 +11,16 @@ import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.view.Gravity
import android.widget.RadioButton
import android.widget.RadioGroup
import androidx.camera.view.PreviewView
import androidx.core.view.doOnLayout
import androidx.core.view.updateLayoutParams
import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import com.chad.library.adapter4.util.setOnDebouncedItemClick
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -28,7 +30,7 @@ import com.shuwei.dish.match.adapter.Food3Adapter
import com.shuwei.dish.match.base.BaseFragment
import com.shuwei.dish.match.databinding.FragmentVectorCollectionBinding
import com.shuwei.dish.match.databinding.LayoutCameraPreviewBinding
import com.shuwei.dish.match.dialog.CommonDialog
import com.shuwei.dish.match.databinding.LayoutEmptyViewBinding
import com.shuwei.dish.match.dialog.Loading
import com.shuwei.dish.match.entity.FoodCollectionBean
import com.shuwei.dish.match.entity.GoodsItem
@@ -44,13 +46,16 @@ import com.shuwei.dish.match.utils.Debouncer
import com.shuwei.dish.match.utils.ImageUploader
import com.shuwei.dish.match.utils.ImageUtil
import com.shuwei.dish.match.utils.LogSaveUtil
import com.shuwei.dish.match.utils.buildSingleChoiceChipGroup
import com.shuwei.dish.match.utils.buildMockGoodsList
import com.shuwei.dish.match.utils.showRawMaterialsDialog
import com.shuwei.dish.match.utils.ext.clickWithDebounce
import com.shuwei.dish.match.utils.ext.dp
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.toast
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import androidx.core.content.withStyledAttributes
import com.shuwei.dish.match.utils.ext.toJsonString
@SuppressLint("NotifyDataSetChanged")
class VectorCollectionFragment : BaseFragment<FragmentVectorCollectionBinding>() {
@@ -73,11 +78,31 @@ class VectorCollectionFragment : BaseFragment<FragmentVectorCollectionBinding>()
private var checkedItem: GoodsItem? = null
private val searchAdapter by lazy {
Food3Adapter(searchList).apply {
setOnItemClickListener { _, _, position ->
// 开启空状态视图能力,否则 stateView 赋值不会生效
isStateViewEnable = true
setOnDebouncedItemClick { _, _, position ->
val item = searchList[position]
val types = item.rawMaterialsTypes
// 弹出净材种类单选弹窗
showRawMaterialsDialog(item, position, types)
showRawMaterialsDialog(
context = requireContext(),
types = item.rawMaterialsTypes,
checkedType = item.rawMaterialsType
) { selectedType ->
// 清空其他已选项的状态
searchList.forEachIndexed { index, it ->
if (index != position) {
it.isClicked = false
it.rawMaterialsType = null
}
}
// 设置当前选中项
item.isClicked = true
item.rawMaterialsType = selectedType
checkedItem = item
selectedFoodId = item.goodsId
selectedFoodName = item.goodsName
notifyDataSetChanged()
}
}
}
}
@@ -246,15 +271,23 @@ class VectorCollectionFragment : BaseFragment<FragmentVectorCollectionBinding>()
upload()
}
binding.editFoodName.setOnEditorActionListener { v, actionId, event ->
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
binding.editFoodName.let {
it.addTextChangedListener { editable ->
if (editable?.isEmpty() == true) {
searchFood()
return@addTextChangedListener
}
}
it.setOnEditorActionListener { v, actionId, event ->
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
}
}
}
@@ -275,6 +308,22 @@ class VectorCollectionFragment : BaseFragment<FragmentVectorCollectionBinding>()
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()
}
@@ -285,8 +334,8 @@ class VectorCollectionFragment : BaseFragment<FragmentVectorCollectionBinding>()
val params = mutableMapOf<String, RequestBody>()
params["goodsId"] = checkedItem!!.goodsId.toRequestBody()
params["goodsName"] = checkedItem!!.goodsName!!.toRequestBody()
//净材种类,逗号分隔
// params["rawMaterialsTypes"] = "".toRequestBody()
//净材种类
params["rawMaterialsTypes"] = checkedItem!!.rawMaterialsType!!.toRequestBody()
//params["version"] = "1.0.0".toRequestBody()
ImageUploader(totalList = vectorList, uploadImage = { batch ->
@@ -302,12 +351,14 @@ class VectorCollectionFragment : BaseFragment<FragmentVectorCollectionBinding>()
params["foodVector"] = foodVectorJson.toRequestBody()
// 直接 await 单批上传结果,成功返回 id 列表,失败返回 null 触发 onError
val result = currentActivity.netViewModel.uploadFoodVectorDataBatch(files, params)
log("uploadImage返回:result=${result.toJsonString()}")
if (result is UiState.Success) result.data else null
}, onProgress = { count, batch, idList ->
showWaitingDialog("图片上传中$count/$totalFileCount")
val foodList = batch.mapIndexed { index, it ->
val name = checkedItem!!.goodsName + "&&" + checkedItem!!.rawMaterialsType
Food(
name = checkedItem!!.goodsName,
name = name,
foodVector = it.imageVector,
)
}
@@ -332,141 +383,73 @@ class VectorCollectionFragment : BaseFragment<FragmentVectorCollectionBinding>()
fun searchFood() {
debouncer.debounce {
val keyword = binding.editFoodName.text.toString().trim()
// 通过 NetViewModel 查询净材数据(goodsType=0 食材)
// 通过 NetViewModel 查询净材数据(goodsType=0 食材),结果由 initialize() 中的 observer 统一处理
currentActivity.netViewModel.queryGoodsList(
goodsType = "0",
goodsName = keyword.ifEmpty { null }
)
// 监听查询结果(一次性收集,避免重复订阅)
lifecycleScope.launch {
currentActivity.netViewModel.goodsListState.collect { state ->
when (state) {
is UiState.Success -> {
var list: List<GoodsItem> = state.data ?: emptyList()
// TODO: 接口暂无数据时使用假数据进行效果测试,联调完成后删除
if (list.isEmpty()) {
list = 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 = "豆芽"),
)
}
// 将 rawMaterialsType 转存到 rawMaterialsTypes,并清空 rawMaterialsType
list.forEach { item ->
item.rawMaterialsTypes = item.rawMaterialsType
item.rawMaterialsType = null
}
searchList.clear()
searchList.addAll(list)
searchAdapter.notifyDataSetChanged()
}
else -> Unit
}
}
}
}
}
/**
* 弹出净材种类单选弹窗
* @param item 当前点击的食材
* @param position 在 searchList 中的位置
* @param types 净材种类字符串(逗号分隔),为空时提示无数据
* 将数据列表填充到 searchAdapter 并切换为网格布局
* @param list 查询到的食材列表
*/
@SuppressLint("NotifyDataSetChanged")
private fun showRawMaterialsDialog(item: GoodsItem, position: Int, types: String?) {
val typeList = types?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList()
val dialog = CommonDialog(requireContext())
.setTitle("选择净材种类")
.setNegativeButton("取消")
if (typeList.isEmpty()) {
// 无净材数据时仅展示提示文字
dialog.setContent("无净材数据")
.setPositiveButton("确认") {}
} else {
// 构建单选 RadioGroup 作为自定义内容
val radioGroup = RadioGroup(requireContext()).apply {
orientation = RadioGroup.VERTICAL
// RadioGroup 本身居中,子项 wrap_content 宽度,整体图标+文字自然居中
gravity = Gravity.CENTER_HORIZONTAL
typeList.forEachIndexed { index, typeName ->
val rb = RadioButton(requireContext()).apply {
id = index
text = typeName
textSize = 26f
layoutParams = RadioGroup.LayoutParams(
RadioGroup.LayoutParams.WRAP_CONTENT,
RadioGroup.LayoutParams.WRAP_CONTENT
)
setPadding(30.dp, 15.dp, 30.dp, 15.dp)
// 移除默认点击水波纹背景,改用 foreground 实现 selectableItemBackground 效果
setBackgroundResource(0)
val attrs = intArrayOf(android.R.attr.selectableItemBackground)
context.withStyledAttributes(null, attrs) {
foreground = getDrawable(0)
}
// 若当前项已有选中值则默认勾选对应选项
if (typeName == item.rawMaterialsType) isChecked = true
// 通过 InsetDrawable 在 buttonDrawable 右侧追加 80dp 间距,实现图标与文字的间距控制
buttonDrawable?.let { orig ->
buttonDrawable = android.graphics.drawable.InsetDrawable(
orig, 30.dp, 0, 30.dp, 0
)
}
}
addView(rb)
}
}
dialog.addContentView(radioGroup) { tvContent ->
tvContent.gone()
}
dialog.setPositiveButtonInterceptable("确认") {
val checkedId = radioGroup.checkedRadioButtonId
if (checkedId == -1) {
toast("请选择净材种类")
// 返回 false 阻止弹窗关闭
return@setPositiveButtonInterceptable false
}
val selectedType = typeList[checkedId]
// 清空其他已选项的状态
searchList.forEachIndexed { index, it ->
if (index != position) {
it.isClicked = false
it.rawMaterialsType = null
}
}
// 设置当前选中项
item.isClicked = true
item.rawMaterialsType = selectedType
checkedItem = item
selectedFoodId = item.goodsId
selectedFoodName = item.goodsName
searchAdapter.notifyDataSetChanged()
true
}
private fun fillSearchList(list: List<GoodsItem>) {
// 将 rawMaterialsType 转存到 rawMaterialsTypes,并清空 rawMaterialsType
list.forEach { item ->
item.rawMaterialsTypes = item.rawMaterialsType
item.rawMaterialsType = null
}
// 空布局切过 LinearLayoutManager,此处恢复为 2 列 GridLayoutManager
if (binding.rvSearchFood.layoutManager !is GridLayoutManager) {
binding.rvSearchFood.layoutManager = GridLayoutManager(context, 2)
}
// 清空 stateView 否则空视图会一直占位导致无法显示列表项
searchAdapter.stateView = null
searchList.clear()
searchList.addAll(list)
searchAdapter.notifyDataSetChanged()
Log.d(TAG, "fillSearchList,${searchList.toJsonString()}")
}
dialog.show()
private var emptyViewBinding: LayoutEmptyViewBinding? = null
/**
* 展示空布局
* 默认 GridLayoutManager 会把 stateView 限制在单列宽度内,切换为 LinearLayoutManager 让空布局横向铺满;
* 点击空布局时临时填充假数据用于效果测试,联调完成后可去除点击逻辑
*/
@SuppressLint("NotifyDataSetChanged")
private fun loadEmptyView() {
try {
searchList.clear()
searchAdapter.notifyDataSetChanged()
// 切换为线性布局让空视图横向铺满,避免 Grid 模式下空布局只占一列
binding.rvSearchFood.layoutManager = LinearLayoutManager(requireContext())
if (emptyViewBinding == null) {
emptyViewBinding = LayoutEmptyViewBinding.inflate(
LayoutInflater.from(requireContext()),
binding.rvSearchFood,
false
)
}
emptyViewBinding?.let {
it.tvContent.text = "暂无数据"
it.tvSubContent.text = "点击此处重新查询"
// 点击空布局填充本地假数据,便于 UI 效果测试
it.root.setOnClickListener {
fillSearchList(buildMockGoodsList())
}
binding.rvSearchFood.post {
it.root.minimumHeight = binding.rvSearchFood.height
searchAdapter.stateView = it.root
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
var clickIndex = -1
@@ -0,0 +1,151 @@
package com.shuwei.dish.match.utils
import android.content.Context
import android.content.res.ColorStateList
import android.view.View
import androidx.core.content.ContextCompat
import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipGroup
import com.shuwei.dish.match.R
import com.shuwei.dish.match.dialog.CommonDialog
import com.shuwei.dish.match.entity.GoodsItem
import com.shuwei.dish.match.utils.ext.dp
import com.shuwei.dish.match.utils.ext.gone
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 = "豆芽"),
)
/**
* 弹出净材种类单选弹窗
* 确认选中后通过 onConfirmed 回调返回选中的种类文本,业务逻辑由调用方自行处理;
* 无净材数据时展示提示文字,点击确认直接关闭弹窗。
*
* @param context 上下文
* @param types 净材种类字符串(逗号分隔),为空时提示无数据
* @param checkedType 弹窗打开时的默认选中项(与 types 中元素对比),null 表示无默认选中
* @param onConfirmed 用户选中并确认后的回调,参数为选中的净材种类文本
*/
fun showRawMaterialsDialog(
context: Context,
types: String?,
checkedType: String?,
onConfirmed: (selectedType: String) -> Unit
) {
val typeList = types?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList()
val dialog = CommonDialog(context)
.setTitle("选择净材种类")
.setNegativeButton("取消")
if (typeList.isEmpty()) {
// 无净材数据时仅展示提示文字
dialog.setContent("无净材数据")
.setPositiveButton("确认") {}
} else {
// 构建单选 ChipGroup 作为自定义内容
val chipGroup = buildSingleChoiceChipGroup(context, typeList, checkedType)
dialog.addContentView(chipGroup) { tvContent -> tvContent.gone() }
dialog.setPositiveButtonInterceptable("确认") {
val checkedId = chipGroup.checkedChipId
if (checkedId == View.NO_ID) {
context.toast("请选择净材种类")
// 返回 false 阻止弹窗关闭
return@setPositiveButtonInterceptable false
}
onConfirmed(typeList[checkedId])
true
}
}
dialog.show()
}
/**
* 构建单选样式的 ChipGroup
*
* @param context 上下文
* @param typeList 选项文本列表,每一项作为一个 Chip 的文字
* @param checkedType 默认选中项的文本,与 typeList 中元素相等的 Chip 会被勾选;为空则无默认选中
* @return 配置完成的 ChipGroup,每个 Chip 的 id 即为其在 typeList 中的下标
*/
fun buildSingleChoiceChipGroup(
context: Context,
typeList: List<String>,
checkedType: String?
): ChipGroup {
return ChipGroup(context).apply {
isSingleSelection = true
isSelectionRequired = false
setPadding(5.dp, 40.dp, 5.dp, 40.dp)
chipSpacingVertical = 10.dp
chipSpacingHorizontal = 20.dp
typeList.forEachIndexed { index, typeName ->
val chip = Chip(context).apply {
id = index
text = typeName
textSize = 22f
isCheckable = true
chipStartPadding = 20.dp.toFloat()
chipEndPadding = 20.dp.toFloat()
chipMinHeight = (textSize + 15.dp * 2).toFloat()
// 选中/未选中态背景色:主题绿 / 浅灰
chipBackgroundColor = ColorStateList(
arrayOf(
intArrayOf(android.R.attr.state_checked),
intArrayOf(-android.R.attr.state_checked)
),
intArrayOf(
ContextCompat.getColor(context, R.color.dish_green),
ContextCompat.getColor(context, R.color.gray_eb)
)
)
// 选中/未选中态文字色:白 / 深灰
setTextColor(
ColorStateList(
arrayOf(
intArrayOf(android.R.attr.state_checked),
intArrayOf(-android.R.attr.state_checked)
),
intArrayOf(
ContextCompat.getColor(context, R.color.white),
ContextCompat.getColor(context, R.color.black333)
)
)
)
// 隐藏默认的左侧勾选图标,让填充色作为唯一选中反馈
isCheckedIconVisible = false
// 去除描边
chipStrokeWidth = 0f
// 若当前项已有选中值则默认勾选对应选项
isChecked = (typeName == checkedType)
}
addView(chip)
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -3,4 +3,5 @@
android:shape="rectangle">
<solid android:color="#FFF4FAFF"/>
<stroke android:width="2dp" android:color="@color/white"/>
<corners android:radius="8dp"/>
</shape>
@@ -63,6 +63,7 @@
android:gravity="center_vertical">
<TextView
android:id="@+id/btnTestConfirm"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start"
@@ -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>
+42 -18
View File
@@ -1,35 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
android:layout_height="100dp"
android:layout_margin="15dp"
tools:background="@drawable/shape_white_12_corners">
<TextView
android:id="@+id/tvGoodsInfo"
android:layout_width="match_parent"
android:layout_height="100dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:gravity="center"
android:maxLines="1"
android:paddingHorizontal="5dp"
android:textColor="@color/black666"
android:textSize="30sp"
android:paddingHorizontal="5dp"
android:gravity="center"
android:layout_margin="15dp"
tools:text="椒盐"
android:maxLines="1"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="椒盐" />
<TextView
android:id="@+id/tvRawMaterialsType"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:background="@drawable/shape_white_12_corners"/>
android:gravity="center"
android:maxLines="1"
android:textColor="@color/black999"
android:textSize="18sp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/tvFoodScore"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tvGoodsInfo"
tools:text="切丝" />
<TextView
android:id="@+id/tvFoodScore"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/black666"
android:textSize="16sp"
android:layout_marginBottom="20dp"
android:layout_gravity="bottom|center_horizontal"
tools:text="100%"
android:maxLines="1"
android:ellipsize="end"
android:visibility="gone"/>
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/tvRawMaterialsType"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/tvRawMaterialsType"
app:layout_constraintTop_toTopOf="@id/tvRawMaterialsType"
tools:text="100%" />
</FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
+3 -4
View File
@@ -4,10 +4,7 @@
android:layout_width="match_parent"
android:layout_height="100dp"
android:layout_marginHorizontal="14dp"
android:layout_marginVertical="14dp"
android:background="@drawable/bg_item_search"
android:paddingHorizontal="15dp"
android:paddingVertical="10dp">
android:layout_marginVertical="14dp">
<!-- 食材名称,选中状态通过 CheckedTextView 的 checked 驱动背景/文字色变化 -->
<CheckedTextView
@@ -21,6 +18,7 @@
android:textColor="@color/color_item_search"
android:textSize="28sp"
android:textStyle="bold"
android:background="@drawable/bg_item_search"
tools:text="土豆丝" />
<!-- 当前选中的净材种类,小字显示,无数据时隐藏 -->
@@ -30,6 +28,7 @@
android:layout_height="wrap_content"
android:gravity="center"
android:layout_gravity="center|bottom"
android:layout_marginBottom="3dp"
android:textColor="@color/black999"
android:textSize="18sp"
android:visibility="gone"
@@ -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" />