feat(fragment): 食材采集页改用 ChipGroup 选择净材并新增列表空布局

This commit is contained in:
2026-05-11 10:20:54 +08:00
parent dc947df6e3
commit 879af8426f
2 changed files with 176 additions and 80 deletions
@@ -3,6 +3,7 @@ package com.shuwei.dish.match.ui.fragment
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Intent import android.content.Intent
import android.content.Context import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Bitmap import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
@@ -11,14 +12,16 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.view.Gravity import com.google.android.material.chip.Chip
import android.widget.RadioButton import com.google.android.material.chip.ChipGroup
import android.widget.RadioGroup
import androidx.camera.view.PreviewView import androidx.camera.view.PreviewView
import androidx.core.content.ContextCompat
import androidx.core.view.doOnLayout import androidx.core.view.doOnLayout
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import com.chad.library.adapter4.util.setOnDebouncedItemClick
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -28,6 +31,7 @@ import com.shuwei.dish.match.adapter.Food3Adapter
import com.shuwei.dish.match.base.BaseFragment import com.shuwei.dish.match.base.BaseFragment
import com.shuwei.dish.match.databinding.FragmentVectorCollectionBinding import com.shuwei.dish.match.databinding.FragmentVectorCollectionBinding
import com.shuwei.dish.match.databinding.LayoutCameraPreviewBinding import com.shuwei.dish.match.databinding.LayoutCameraPreviewBinding
import com.shuwei.dish.match.databinding.LayoutEmptyViewBinding
import com.shuwei.dish.match.dialog.CommonDialog import com.shuwei.dish.match.dialog.CommonDialog
import com.shuwei.dish.match.dialog.Loading import com.shuwei.dish.match.dialog.Loading
import com.shuwei.dish.match.entity.FoodCollectionBean import com.shuwei.dish.match.entity.FoodCollectionBean
@@ -50,7 +54,7 @@ import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.toast import com.shuwei.dish.match.utils.ext.toast
import okhttp3.RequestBody import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import androidx.core.content.withStyledAttributes import com.shuwei.dish.match.utils.ext.toJsonString
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
class VectorCollectionFragment : BaseFragment<FragmentVectorCollectionBinding>() { class VectorCollectionFragment : BaseFragment<FragmentVectorCollectionBinding>() {
@@ -73,7 +77,9 @@ class VectorCollectionFragment : BaseFragment<FragmentVectorCollectionBinding>()
private var checkedItem: GoodsItem? = null private var checkedItem: GoodsItem? = null
private val searchAdapter by lazy { private val searchAdapter by lazy {
Food3Adapter(searchList).apply { Food3Adapter(searchList).apply {
setOnItemClickListener { _, _, position -> // 开启空状态视图能力,否则 stateView 赋值不会生效
isStateViewEnable = true
setOnDebouncedItemClick { _, _, position ->
val item = searchList[position] val item = searchList[position]
val types = item.rawMaterialsTypes val types = item.rawMaterialsTypes
// 弹出净材种类单选弹窗 // 弹出净材种类单选弹窗
@@ -285,8 +291,8 @@ class VectorCollectionFragment : BaseFragment<FragmentVectorCollectionBinding>()
val params = mutableMapOf<String, RequestBody>() val params = mutableMapOf<String, RequestBody>()
params["goodsId"] = checkedItem!!.goodsId.toRequestBody() params["goodsId"] = checkedItem!!.goodsId.toRequestBody()
params["goodsName"] = checkedItem!!.goodsName!!.toRequestBody() params["goodsName"] = checkedItem!!.goodsName!!.toRequestBody()
//净材种类,逗号分隔 //净材种类
// params["rawMaterialsTypes"] = "".toRequestBody() params["rawMaterialsTypes"] = checkedItem!!.rawMaterialsType!!.toRequestBody()
//params["version"] = "1.0.0".toRequestBody() //params["version"] = "1.0.0".toRequestBody()
ImageUploader(totalList = vectorList, uploadImage = { batch -> ImageUploader(totalList = vectorList, uploadImage = { batch ->
@@ -302,6 +308,7 @@ class VectorCollectionFragment : BaseFragment<FragmentVectorCollectionBinding>()
params["foodVector"] = foodVectorJson.toRequestBody() params["foodVector"] = foodVectorJson.toRequestBody()
// 直接 await 单批上传结果,成功返回 id 列表,失败返回 null 触发 onError // 直接 await 单批上传结果,成功返回 id 列表,失败返回 null 触发 onError
val result = currentActivity.netViewModel.uploadFoodVectorDataBatch(files, params) val result = currentActivity.netViewModel.uploadFoodVectorDataBatch(files, params)
log("uploadImage返回:result=${result.toJsonString()}")
if (result is UiState.Success) result.data else null if (result is UiState.Success) result.data else null
}, onProgress = { count, batch, idList -> }, onProgress = { count, batch, idList ->
showWaitingDialog("图片上传中$count/$totalFileCount") showWaitingDialog("图片上传中$count/$totalFileCount")
@@ -342,41 +349,13 @@ class VectorCollectionFragment : BaseFragment<FragmentVectorCollectionBinding>()
currentActivity.netViewModel.goodsListState.collect { state -> currentActivity.netViewModel.goodsListState.collect { state ->
when (state) { when (state) {
is UiState.Success -> { is UiState.Success -> {
var list: List<GoodsItem> = state.data ?: emptyList() val list: List<GoodsItem> = state.data ?: emptyList()
// TODO: 接口暂无数据时使用假数据进行效果测试,联调完成后删除
if (list.isEmpty()) { if (list.isEmpty()) {
list = mutableListOf( // 接口无数据时展示空布局,点击空布局可填充本地假数据用于效果测试
GoodsItem(goodsId = "1", goodsName = "土豆", rawMaterialsType = "土豆丝,土豆条,土豆片,土豆丁"), loadEmptyView()
GoodsItem(goodsId = "2", goodsName = "胡萝卜", rawMaterialsType = "胡萝卜丝,胡萝卜片,胡萝卜丁"), } else {
GoodsItem(goodsId = "3", goodsName = "黄瓜", rawMaterialsType = "黄瓜丝,黄瓜片,黄瓜丁,黄瓜块"), fillSearchList(list)
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 else -> Unit
} }
@@ -385,6 +364,94 @@ class VectorCollectionFragment : BaseFragment<FragmentVectorCollectionBinding>()
} }
} }
/**
* 将数据列表填充到 searchAdapter 并切换为网格布局
* @param list 查询到的食材列表
*/
@SuppressLint("NotifyDataSetChanged")
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()
}
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()
}
}
/**
* 构造用于效果测试的食材假数据
* TODO: 联调完成后删除
*/
private 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 = "豆芽"),
)
/** /**
* 弹出净材种类单选弹窗 * 弹出净材种类单选弹窗
* @param item 当前点击的食材项 * @param item 当前点击的食材项
@@ -404,45 +471,14 @@ class VectorCollectionFragment : BaseFragment<FragmentVectorCollectionBinding>()
dialog.setContent("无净材数据") dialog.setContent("无净材数据")
.setPositiveButton("确认") {} .setPositiveButton("确认") {}
} else { } else {
// 构建单选 RadioGroup 作为自定义内容 // 构建单选 ChipGroup 作为自定义内容
val radioGroup = RadioGroup(requireContext()).apply { val chipGroup = buildSingleChoiceChipGroup(typeList, item.rawMaterialsType)
orientation = RadioGroup.VERTICAL dialog.addContentView(chipGroup) { tvContent ->
// 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() tvContent.gone()
} }
dialog.setPositiveButtonInterceptable("确认") { dialog.setPositiveButtonInterceptable("确认") {
val checkedId = radioGroup.checkedRadioButtonId val checkedId = chipGroup.checkedChipId
if (checkedId == -1) { if (checkedId == View.NO_ID) {
toast("请选择净材种类") toast("请选择净材种类")
// 返回 false 阻止弹窗关闭 // 返回 false 阻止弹窗关闭
return@setPositiveButtonInterceptable false return@setPositiveButtonInterceptable false
@@ -469,6 +505,67 @@ class VectorCollectionFragment : BaseFragment<FragmentVectorCollectionBinding>()
dialog.show() dialog.show()
} }
/**
* 构建单选样式的 ChipGroup
* @param typeList 选项文本列表,列表中每一项作为一个 Chip 的文字
* @param checkedType 默认选中项的文本,与 typeList 中元素相等的 Chip 会被勾选;为空则无默认选中
* @return 配置完成的 ChipGroup,每个 Chip 的 id 即为其在 typeList 中的下标
*/
private fun buildSingleChoiceChipGroup(
typeList: List<String>,
checkedType: String?
): ChipGroup {
return ChipGroup(requireContext()).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(requireContext()).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(requireContext(), R.color.dish_green),
ContextCompat.getColor(requireContext(), R.color.gray_eb)
)
)
// 选中/未选中态文字色:白 / 深灰
setTextColor(
ColorStateList(
arrayOf(
intArrayOf(android.R.attr.state_checked),
intArrayOf(-android.R.attr.state_checked)
),
intArrayOf(
ContextCompat.getColor(requireContext(), R.color.white),
ContextCompat.getColor(requireContext(), R.color.black333)
)
)
)
// 隐藏默认的左侧勾选图标,让填充色作为唯一选中反馈
isCheckedIconVisible = false
// 去除描边
chipStrokeWidth = 0f
// 若当前项已有选中值则默认勾选对应选项
isChecked = (typeName == checkedType)
}
addView(chip)
}
}
}
var clickIndex = -1 var clickIndex = -1
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
+3 -4
View File
@@ -4,10 +4,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="100dp" android:layout_height="100dp"
android:layout_marginHorizontal="14dp" android:layout_marginHorizontal="14dp"
android:layout_marginVertical="14dp" android:layout_marginVertical="14dp">
android:background="@drawable/bg_item_search"
android:paddingHorizontal="15dp"
android:paddingVertical="10dp">
<!-- 食材名称,选中状态通过 CheckedTextView 的 checked 驱动背景/文字色变化 --> <!-- 食材名称,选中状态通过 CheckedTextView 的 checked 驱动背景/文字色变化 -->
<CheckedTextView <CheckedTextView
@@ -21,6 +18,7 @@
android:textColor="@color/color_item_search" android:textColor="@color/color_item_search"
android:textSize="28sp" android:textSize="28sp"
android:textStyle="bold" android:textStyle="bold"
android:background="@drawable/bg_item_search"
tools:text="土豆丝" /> tools:text="土豆丝" />
<!-- 当前选中的净材种类,小字显示,无数据时隐藏 --> <!-- 当前选中的净材种类,小字显示,无数据时隐藏 -->
@@ -30,6 +28,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="center" android:gravity="center"
android:layout_gravity="center|bottom" android:layout_gravity="center|bottom"
android:layout_marginBottom="3dp"
android:textColor="@color/black999" android:textColor="@color/black999"
android:textSize="18sp" android:textSize="18sp"
android:visibility="gone" android:visibility="gone"