feat(inbound): 新增入库秤毛菜/净材入库功能

- 新增入库专用网络层(InboundApiService、InboundRepository、InboundNetworkModule、InboundInterceptor)
- 新增入库相关数据模型(IngredientByTrace、SupplierOption、ZoneOption、GoodsSearchModel、CleanInboundParam、RawInboundParam、InboundApiResponse)
- 新增 FormField 动态表单模型(FieldType 枚举、apiKey 字段映射、submitValueId 控制提交值)
- 新增 FormFieldAdapter,基于 FlexboxLayoutManager 实现两列自动换行表单,支持五种字段类型
- 新增 InboundGoodsSearchDialog(溯源码检索食材)和 InboundGoodsStoreDialog(入库录入,表单转 Map 提交)
- 新增 StoreViewModel,提交接口入参改为 Map<String, Any>
- 重构 StoreActivity:注入 StoreViewModel,onResume 预加载供应商/区域列表到 GlobalData
- 修改 GoodsListActivity:抽取 onGoodsSearchRequested() 供子类重写
- 修改 GoodsRecognizeDialog:StoreActivity 场景下打开 InboundGoodsStoreDialog
- GlobalData 新增 inboundSupplierList / inboundZoneList 全局缓存
- build.gradle.kts 新增 flexbox:3.0.0 依赖

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 21:18:09 +08:00
co-authored by Claude Sonnet 4.6
parent ac9494a0f3
commit fabc4d780a
28 changed files with 2352 additions and 11 deletions
+3
View File
@@ -156,6 +156,9 @@ dependencies {
implementation("org.greenrobot:eventbus:3.3.1")
// flexbox 布局,用于动态表单两列排列
implementation("com.google.android.flexbox:flexbox:3.0.0")
}
apply(plugin = "io.objectbox")
@@ -2,7 +2,9 @@ package com.sw.inbound
import android.net.Uri
import com.sw.inbound.model.response.DictType
import com.sw.inbound.model.response.SupplierOption
import com.sw.inbound.model.response.User
import com.sw.inbound.model.response.ZoneOption
object GlobalData {
/**
@@ -40,6 +42,16 @@ object GlobalData {
*/
var unitTypeList: List<DictType> = arrayListOf()
/**
* 入库供应商列表(入库秤使用)
*/
var inboundSupplierList: List<SupplierOption> = emptyList()
/**
* 入库仓库区域列表(入库秤使用)
*/
var inboundZoneList: List<ZoneOption> = emptyList()
/**
* 具体业务 BaseUrl
*/
@@ -457,7 +457,7 @@ abstract class GoodsListActivity : BaseActivity() {
goodsFile = file,
goodsName = name
) {
GoodsSearchDialog(this).show()
onGoodsSearchRequested()
}.apply {
if (isFinishing.not() && isDestroyed.not()) {
//isShowRecognizeDialog = false
@@ -469,6 +469,11 @@ abstract class GoodsListActivity : BaseActivity() {
var goodsRecognizeDialog: GoodsRecognizeDialog? = null
/** 子类可重写以替换物品检索弹窗 */
protected open fun onGoodsSearchRequested() {
GoodsSearchDialog(this).show()
}
private fun replaceIncludeContent() {
binding.flContainer.removeAllViews()
//isReceiptPage
@@ -111,14 +111,14 @@ class HomeActivity : BaseActivity() {
// 净材按钮点击提示
binding.btnOrderPurchase.setOnClickListener {
startActivity<StoreActivity> {
//putExtra(GoodsListActivity.IS_RECEIPT_PAGE, false)
putExtra(StoreActivity.EXTRA_STORE_TYPE, 0)
}
}
// 毛菜按钮点击提示
binding.btnOrderSelfProcurement.setOnClickListener {
startActivity<StoreActivity> {
//putExtra(GoodsListActivity.IS_RECEIPT_PAGE, false)
putExtra(StoreActivity.EXTRA_STORE_TYPE, 1)
}
}
}
@@ -1,24 +1,44 @@
package com.sw.inbound.activity
import android.os.Bundle
import androidx.recyclerview.widget.RecyclerView
import androidx.activity.viewModels
import com.sw.inbound.GlobalData
import com.sw.inbound.dialog.InboundGoodsSearchDialog
import com.sw.inbound.model.response.DictType
import com.sw.inbound.utils.ext.gone
import com.sw.inbound.viewmodel.StoreViewModel
import dagger.hilt.android.AndroidEntryPoint
/**
* 入库称重页面
* 继承 GoodsListActivity 以复用秤监听、相机拍照、菜品识别弹窗等核心能力,
* 同时通过隐藏不需要的 View 精简界面,避免大量重复代码。
* 通过隐藏不需要的 View 精简界面,避免大量重复代码。
*/
@AndroidEntryPoint
class StoreActivity : GoodsListActivity() {
val storeViewModel: StoreViewModel by viewModels()
/** 0=净材,1=毛菜,由 HomeActivity 通过 Intent 传入 */
var storeType: Int = 0
override fun onCreate(savedInstanceState: Bundle?) {
storeType = intent.getIntExtra(EXTRA_STORE_TYPE, 0)
super.onCreate(savedInstanceState)
hideUnusedViews()
}
override fun onResume() {
super.onResume()
preloadInboundOptions()
}
/** 预加载供应商和仓库区域列表到 GlobalData,供弹窗直接使用 */
private fun preloadInboundOptions() {
storeViewModel.loadSupplierOptions(callback = { GlobalData.inboundSupplierList = it })
storeViewModel.loadZoneOptions(callback = { GlobalData.inboundZoneList = it })
}
/** 隐藏采购单列表、仓库下拉、提交按钮等本页面不需要的控件 */
private fun hideUnusedViews() {
binding.flWarehouseDropdown.gone()
@@ -31,13 +51,18 @@ class StoreActivity : GoodsListActivity() {
binding.rvGoodsList.gone()
}
override fun initRecyclerView() {
// 本页面无列表,空实现
}
override fun initRecyclerView() {}
override fun initView() {
// 本页面无额外初始化逻辑,空实现
}
override fun initView() {}
override fun getWarehouseList(): List<DictType> = emptyList()
/** 使用入库专用检索弹窗替代原有 GoodsSearchDialog */
override fun onGoodsSearchRequested() {
InboundGoodsSearchDialog(this).show()
}
companion object {
const val EXTRA_STORE_TYPE = "storeType"
}
}
@@ -0,0 +1,189 @@
package com.sw.inbound.adapter
import android.app.DatePickerDialog
import android.content.Context
import android.text.Editable
import android.text.InputType
import android.text.SpannableStringBuilder
import android.text.TextWatcher
import android.text.style.ForegroundColorSpan
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.graphics.toColorInt
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.google.android.flexbox.FlexboxLayoutManager
import com.sw.inbound.R
import com.sw.inbound.databinding.ListItemFormFieldBinding
import com.sw.inbound.dialog.DropdownPopup
import com.sw.inbound.model.FieldType
import com.sw.inbound.model.FormField
import com.sw.inbound.utils.ext.dp
import com.sw.inbound.utils.ext.gone
import com.sw.inbound.utils.ext.hideKeyboard
import com.sw.inbound.utils.ext.visible
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
import kotlin.math.min
/**
* 动态表单字段 Adapter,配合 FlexboxLayoutManager 实现两列自动换行布局
* 单一 item 布局,通过 visibility 控制 TEXT/NUMBER/FIXED/DROPDOWN/DATE_PICKER 五种类型渲染
*
* @param activityContext Activity context,用于创建 DatePickerDialog
* @param fields 表单字段列表(hidden=false 的字段,由外部过滤后传入)
*/
class FormFieldAdapter(
private val activityContext: Context,
fields: MutableList<FormField>
) : BaseQuickAdapter<FormField, FormFieldAdapter.VH>(fields) {
private val isoFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault())
private val displayDateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
inner class VH(val binding: ListItemFormFieldBinding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val binding = ListItemFormFieldBinding.inflate(LayoutInflater.from(context), parent, false)
// 设置每个 item 宽度占 FlexboxLayout 的 50%,实现两列布局
(binding.root.layoutParams as? FlexboxLayoutManager.LayoutParams)?.flexBasisPercent = 0.5f
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int, item: FormField?) {
item ?: return
val b = holder.binding
// 设置标签,必填项追加红色星号
b.tvLabel.text = buildLabelSpannable(item.label, item.required)
// 先全部隐藏,再按类型显示对应控件
b.etInput.gone()
b.flDropdown.gone()
b.flDatePicker.gone()
// 移除旧的 TextWatcher,防止 RecyclerView 复用时错误触发
(b.etInput.tag as? TextWatcher)?.let { b.etInput.removeTextChangedListener(it) }
b.etInput.tag = null
when (item.type) {
FieldType.TEXT -> bindEditText(b, item, InputType.TYPE_CLASS_TEXT)
FieldType.NUMBER -> bindEditText(
b, item,
InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL or InputType.TYPE_NUMBER_FLAG_SIGNED
)
FieldType.FIXED -> bindFixed(b, item)
FieldType.DROPDOWN -> bindDropdown(b, item)
FieldType.DATE_PICKER -> bindDatePicker(b, item)
}
}
/** 绑定可编辑文本框(TEXT / NUMBER */
private fun bindEditText(b: ListItemFormFieldBinding, item: FormField, inputType: Int) {
b.etInput.visible()
b.etInput.isEnabled = true
b.etInput.inputType = inputType
b.etInput.hint = item.hint.ifBlank { item.label }
b.etInput.setBackgroundResource(R.drawable.bg_white_radius10_stroke2)
b.etInput.setText(item.value)
b.etInput.setSelection(b.etInput.text.length)
val watcher = object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable?) {
item.value = s?.toString() ?: ""
}
}
b.etInput.addTextChangedListener(watcher)
b.etInput.tag = watcher
}
/** 绑定固定值(不可编辑,灰色背景区分) */
private fun bindFixed(b: ListItemFormFieldBinding, item: FormField) {
b.etInput.visible()
b.etInput.isEnabled = false
b.etInput.inputType = InputType.TYPE_CLASS_TEXT
b.etInput.hint = ""
b.etInput.setBackgroundResource(R.drawable.bg_gray_radius10)
b.etInput.setText(item.value)
}
/** 绑定下拉选择框,点击时创建 DropdownPopup */
private fun bindDropdown(b: ListItemFormFieldBinding, item: FormField) {
b.flDropdown.visible()
b.tvDropdownValue.hint = item.hint.ifBlank { "请选择${item.label}" }
b.tvDropdownValue.text = item.value.ifBlank { null }
b.flDropdown.setOnClickListener { v ->
v.hideKeyboard()
val popHeight = min(item.options.size.coerceAtLeast(1), 4) * 71.dp
DropdownPopup(
context = activityContext,
list = item.options.toMutableList(),
popWidth = v.width,
popHeight = popHeight
) { dictType ->
item.value = dictType.value ?: ""
item.valueId = dictType.id ?: ""
b.tvDropdownValue.text = item.value
}.also {
it.bgLayout = b.flDropdown
it.showAsDropDown(v)
}
}
}
/** 绑定日期选择器,点击时弹出 DatePickerDialog */
private fun bindDatePicker(b: ListItemFormFieldBinding, item: FormField) {
b.flDatePicker.visible()
b.tvDateValue.hint = item.hint.ifBlank { "请选择日期" }
b.tvDateValue.text = item.value.ifBlank { null }
b.flDatePicker.setOnClickListener { v ->
v.hideKeyboard()
val cal = Calendar.getInstance()
DatePickerDialog(
activityContext,
{ _, year, month, dayOfMonth ->
val selected = Calendar.getInstance().apply {
set(year, month, dayOfMonth, 0, 0, 0)
set(Calendar.MILLISECOND, 0)
}
// value 存显示格式,valueId 存 ISO 格式供提交使用
item.value = displayDateFormat.format(selected.time)
item.valueId = isoFormat.format(selected.time)
b.tvDateValue.text = item.value
},
cal.get(Calendar.YEAR),
cal.get(Calendar.MONTH),
cal.get(Calendar.DAY_OF_MONTH)
).show()
}
}
/** 构建带红色星号的必填标签 */
private fun buildLabelSpannable(label: String, required: Boolean): SpannableStringBuilder {
return SpannableStringBuilder().apply {
append(label)
if (required) {
val start = length
append(" *")
setSpan(
ForegroundColorSpan("#FFCC0000".toColorInt()),
start, length,
android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}
}
/** RecyclerView 回收 ViewHolder 时移除 TextWatcher,防止复用错误 */
override fun onViewRecycled(holder: VH) {
super.onViewRecycled(holder)
val watcher = holder.binding.etInput.tag as? TextWatcher
watcher?.let { holder.binding.etInput.removeTextChangedListener(it) }
holder.binding.etInput.tag = null
}
}
@@ -0,0 +1,56 @@
package com.sw.inbound.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.graphics.toColorInt
import androidx.recyclerview.widget.DiffUtil
import com.chad.library.adapter4.BaseDifferAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.sw.inbound.databinding.ListItemGoodsSearchBinding
import com.sw.inbound.model.response.GoodsSearchModel
import com.sw.inbound.utils.ext.dp
import com.sw.inbound.utils.ext.setShapeDrawable
class InboundSearchAdapter :
BaseDifferAdapter<GoodsSearchModel, InboundSearchAdapter.VH>(DIFF_CALLBACK) {
inner class VH(val binding: ListItemGoodsSearchBinding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val binding = ListItemGoodsSearchBinding.inflate(LayoutInflater.from(context), parent, false)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int, item: GoodsSearchModel?) {
holder.binding.tvGoodsName.run {
text = item?.materName
setTextColor(
if (item?.isSelected == true) "#FF0032C8".toColorInt()
else "#FF666666".toColorInt()
)
}
holder.binding.root.run {
if (item?.isSelected == true) {
setShapeDrawable(
solidColor = "#FFEAF0FF",
strokeColor = "#FF0033CC",
strokeWidth = 2.dp,
radius = 10.dp
)
} else {
setShapeDrawable(solidColor = "#FFF5F5F5", radius = 10.dp)
}
}
}
companion object {
private val DIFF_CALLBACK = object : DiffUtil.ItemCallback<GoodsSearchModel>() {
override fun areItemsTheSame(old: GoodsSearchModel, new: GoodsSearchModel) =
old.materId == new.materId
override fun areContentsTheSame(old: GoodsSearchModel, new: GoodsSearchModel) =
old == new
}
}
}
@@ -0,0 +1,62 @@
package com.sw.inbound.di
import com.google.gson.GsonBuilder
import com.sw.inbound.MyApp
import com.sw.inbound.network.api.InboundApiService
import com.sw.inbound.network.interceptor.InboundInterceptor
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import timber.log.Timber
import java.util.concurrent.TimeUnit
import javax.inject.Named
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object InboundNetworkModule {
private const val INBOUND_BASE_URL = "http://192.168.10.101:24801/"
private const val TIME_OUT = 30L
@Provides
@Singleton
@Named("inbound")
fun provideInboundHttpClient(): OkHttpClient {
return OkHttpClient.Builder()
.connectTimeout(TIME_OUT, TimeUnit.SECONDS)
.readTimeout(TIME_OUT, TimeUnit.SECONDS)
.writeTimeout(TIME_OUT, TimeUnit.SECONDS)
.addNetworkInterceptor(HttpLoggingInterceptor(logger = {
Timber.d("inbound okhttp ==> $it")
}).apply {
level = if (MyApp.DEBUG) HttpLoggingInterceptor.Level.BODY
else HttpLoggingInterceptor.Level.NONE
})
.addInterceptor(InboundInterceptor())
.build()
}
@Provides
@Singleton
@Named("inbound")
fun provideInboundRetrofit(@Named("inbound") okHttpClient: OkHttpClient): Retrofit {
val gson = GsonBuilder().setLenient().create()
return Retrofit.Builder()
.baseUrl(INBOUND_BASE_URL)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
}
@Provides
@Singleton
fun provideInboundApiService(@Named("inbound") retrofit: Retrofit): InboundApiService {
return retrofit.create(InboundApiService::class.java)
}
}
@@ -6,8 +6,10 @@ import android.view.LayoutInflater
import android.view.View
import androidx.recyclerview.widget.LinearLayoutManager
import com.sw.inbound.activity.GoodsListActivity
import com.sw.inbound.activity.StoreActivity
import com.sw.inbound.adapter.RecognizeAdapter
import com.sw.inbound.databinding.DialogGoodsRecognizeBinding
import com.sw.inbound.model.response.GoodsSearchModel
import com.sw.inbound.model.response.SearchGoodsInfo
import com.sw.inbound.utils.ext.clickWithDebounce
import com.sw.inbound.utils.ext.gone
@@ -57,6 +59,17 @@ class GoodsRecognizeDialog(
}
private fun showGoodsStoreDialog(position: Int) {
if (activity is StoreActivity) {
// 入库称重页面:识别结果无溯源码,直接用物品名称构造 GoodsSearchModel 打开入库录入弹窗
val goods = list[position]
InboundGoodsStoreDialog(
context = context,
goods = GoodsSearchModel(materName = goods.goodsName ?: "")
) {
dismiss()
}.show()
return
}
GoodsStoreDialog(
context = context,
funcType = 0,
@@ -0,0 +1,116 @@
package com.sw.inbound.dialog
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import com.sw.inbound.activity.StoreActivity
import com.sw.inbound.adapter.InboundSearchAdapter
import com.sw.inbound.databinding.DialogInboundGoodsSearchBinding
import com.sw.inbound.databinding.LayoutEmptySearchBinding
import com.sw.inbound.model.response.GoodsSearchModel
import com.sw.inbound.utils.ext.addOnActionSearchListener
import com.sw.inbound.utils.ext.hideKeyboard
import com.sw.inbound.utils.ext.toast
/** 入库物品检索弹窗,使用溯源码查询食材,供 StoreActivity 使用 */
class InboundGoodsSearchDialog(
context: Context
) : BaseDialog(context) {
private lateinit var inflater: LayoutInflater
private var emptyBinding: LayoutEmptySearchBinding? = null
private lateinit var binding: DialogInboundGoodsSearchBinding
override fun getRootView(): View {
inflater = LayoutInflater.from(context)
binding = DialogInboundGoodsSearchBinding.inflate(inflater)
return binding.root
}
private var storeActivity: StoreActivity? = null
override fun initView() {
storeActivity = getReceiptActivity() as? StoreActivity
binding.ivClose.setOnClickListener { dismiss() }
binding.ivGoodsSearch.setOnClickListener { searchGoods() }
binding.etInputGoods.addOnActionSearchListener { searchGoods() }
binding.btnConfirm.setOnClickListener {
if (list.isEmpty()) {
context.toast("请搜索物品")
return@setOnClickListener
}
if (clickIndex == -1) {
context.toast("请选择物品")
return@setOnClickListener
}
InboundGoodsStoreDialog(
context = context,
goods = list[clickIndex]
) {
storeActivity?.goodsRecognizeDialog?.dismiss()
dismiss()
}.show()
}
binding.rvSearch.let {
it.layoutManager = GridLayoutManager(context, 2, LinearLayoutManager.VERTICAL, false)
it.adapter = searchAdapter
}
binding.refreshLayout.let {
it.setEnableRefresh(false)
it.setEnableLoadMore(false)
}
loadEmptyView()
}
private fun searchGoods() {
val traceCode = binding.etInputGoods.text.toString().trim()
if (traceCode.isBlank()) {
context.toast("请输入溯源码")
return
}
storeActivity?.storeViewModel?.getIngredientByTrace(traceCode) { items ->
binding.etInputGoods.hideKeyboard()
val models = items.map { GoodsSearchModel.from(it) }
if (models.isEmpty()) {
loadEmptyView()
searchAdapter.submitList(emptyList())
return@getIngredientByTrace
}
list.clear()
list.addAll(models)
binding.rvSearch.layoutManager =
GridLayoutManager(context, 2, GridLayoutManager.VERTICAL, false)
searchAdapter.submitList(list.toList())
}
}
private fun loadEmptyView() {
binding.rvSearch.layoutManager =
LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
if (emptyBinding == null) {
emptyBinding = LayoutEmptySearchBinding.inflate(inflater, binding.rvSearch, false)
}
emptyBinding?.root?.let { layout ->
layout.setOnClickListener { layout.hideKeyboard() }
searchAdapter.stateView = layout
}
}
private var clickIndex = -1
private val list: MutableList<GoodsSearchModel> = mutableListOf()
private val searchAdapter by lazy {
InboundSearchAdapter().apply {
isStateViewEnable = true
setOnItemClickListener { _, _, position ->
clickIndex = position
if (list[position].isSelected) return@setOnItemClickListener
list.forEach { it.isSelected = false }
list[position].isSelected = true
submitList(list.toList())
}
}
}
}
@@ -0,0 +1,276 @@
package com.sw.inbound.dialog
import android.content.Context
import android.graphics.Typeface
import android.text.SpannableStringBuilder
import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan
import android.view.LayoutInflater
import android.view.View
import androidx.camera.view.PreviewView
import androidx.core.graphics.toColorInt
import com.google.android.flexbox.FlexDirection
import com.google.android.flexbox.FlexWrap
import com.google.android.flexbox.FlexboxLayoutManager
import com.sw.inbound.GlobalData
import com.sw.inbound.activity.StoreActivity
import com.sw.inbound.adapter.FormFieldAdapter
import com.sw.inbound.databinding.DialogInboundGoodsStoreBinding
import com.sw.inbound.databinding.LayoutTakePhotoBinding
import com.sw.inbound.model.FieldType
import com.sw.inbound.model.FormField
import com.sw.inbound.model.response.DictType
import com.sw.inbound.model.response.GoodsSearchModel
import com.sw.inbound.sdk.SensorScaleUtils
import com.sw.inbound.utils.CameraUtils
import com.sw.inbound.utils.ext.appendText
import com.sw.inbound.utils.ext.buildSpannableString
import com.sw.inbound.utils.ext.toast
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.math.abs
/** 入库录入弹窗,使用 FlexboxLayoutManager 动态表单,根据 storeType 展示净材/毛菜对应字段 */
class InboundGoodsStoreDialog(
context: Context,
private val goods: GoodsSearchModel,
private val confirmBlock: () -> Unit = {}
) : BaseDialog(context) {
private lateinit var binding: DialogInboundGoodsStoreBinding
private lateinit var inflater: LayoutInflater
private var storeActivity: StoreActivity? = null
/** 0=净材,1=毛菜 */
private var storeType: Int = 0
private var realWeight: Int = 0
private val isoFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault())
/** 当前渲染的表单字段列表 */
private lateinit var fields: MutableList<FormField>
private var previewView: PreviewView? = null
private var takePhotoBinding: LayoutTakePhotoBinding? = null
private val cameraUtils: CameraUtils by lazy { CameraUtils(storeActivity!!) }
override fun getRootView(): View {
inflater = LayoutInflater.from(context)
binding = DialogInboundGoodsStoreBinding.inflate(inflater)
return binding.root
}
override fun initView() {
storeActivity = getReceiptActivity() as? StoreActivity
storeType = storeActivity?.storeType ?: 0
buildFormFields()
setupRecyclerView()
setupButtons()
addChildToContainer()
addWeightListener()
}
private fun buildFormFields() {
fields = if (storeType == 1) buildRawFields() else buildCleanFields()
}
/**
* 毛菜字段列表
* 接口字段:traceCode, supplierType(额外注入), materialName, supplierId, spec, quantity,
* weight(额外注入), inboundTime, zoneId, operator
* 注:来源类型无对应接口字段,apiKey="" 不提交
*/
private fun buildRawFields(): MutableList<FormField> {
val supplierOptions = GlobalData.inboundSupplierList.mapNotNull { opt ->
val id = opt.id?.toString() ?: return@mapNotNull null
DictType(id = id, value = opt.name)
}
val zoneOptions = GlobalData.inboundZoneList.mapNotNull { zone ->
val id = zone.id?.toString() ?: return@mapNotNull null
DictType(id = id, value = zone.zoneName)
}
return mutableListOf(
FormField("溯源码", FieldType.TEXT, apiKey = "traceCode", value = goods.traceCode),
FormField("来源类型", FieldType.DROPDOWN, apiKey = "", options = listOf(
DictType("0", "手动录入"), DictType("1", "系统识别")
)),
FormField("食材名称", FieldType.FIXED, apiKey = "materialName", required = true, value = goods.materName),
FormField("供应商", FieldType.DROPDOWN, apiKey = "supplierId", required = true,
hint = "请选择供应商", options = supplierOptions, submitValueId = true),
FormField("入库规格", FieldType.TEXT, apiKey = "spec", required = true, hint = "请录入入库规格"),
FormField("入库数量", FieldType.NUMBER, apiKey = "quantity", required = true, hint = "请录入入库数量"),
FormField("入库时间", FieldType.DATE_PICKER, apiKey = "inboundTime", required = true,
hint = "请选择入库时间", submitValueId = true),
FormField("存放区域", FieldType.DROPDOWN, apiKey = "zoneId", required = true,
hint = "请选择存放区域", options = zoneOptions, submitValueId = true),
FormField("操作人", FieldType.TEXT, apiKey = "operator", value = GlobalData.user?.name ?: "")
)
}
/**
* 净材字段列表
* 接口字段:traceCode, ingredientName, cleanType, spec, quantity,
* weight(额外注入), inboundTime(额外注入当前时间), zoneId, operator,
* expiryDate, storageTemp
*/
private fun buildCleanFields(): MutableList<FormField> {
val vegTypes = goods.vegTypeList()
val cleanTypeOptions = vegTypes.mapIndexed { index, type ->
DictType(id = index.toString(), value = type)
}
val zoneOptions = GlobalData.inboundZoneList.mapNotNull { zone ->
val id = zone.id?.toString() ?: return@mapNotNull null
DictType(id = id, value = zone.zoneName)
}
return mutableListOf(
FormField("溯源码", FieldType.TEXT, apiKey = "traceCode", value = goods.traceCode),
FormField("食材名称", FieldType.FIXED, apiKey = "ingredientName", required = true, value = goods.materName),
FormField("净材类型", FieldType.DROPDOWN, apiKey = "cleanType",
required = cleanTypeOptions.isNotEmpty(), hint = "请选择净材类型", options = cleanTypeOptions),
FormField("入库规格", FieldType.TEXT, apiKey = "spec", required = true, hint = "请录入入库规格"),
FormField("入库数量", FieldType.NUMBER, apiKey = "quantity", required = true, hint = "请录入入库数量"),
FormField("保质期至", FieldType.DATE_PICKER, apiKey = "expiryDate",
hint = "请选择保质期", submitValueId = true),
FormField("存放区域", FieldType.DROPDOWN, apiKey = "zoneId", required = true,
hint = "请选择存放区域", options = zoneOptions, submitValueId = true),
FormField("存放温度(℃)", FieldType.NUMBER, apiKey = "storageTemp"),
FormField("操作人", FieldType.TEXT, apiKey = "operator", value = GlobalData.user?.name ?: "")
)
}
private fun setupRecyclerView() {
val flexboxLM = FlexboxLayoutManager(context).apply {
flexDirection = FlexDirection.ROW
flexWrap = FlexWrap.WRAP
}
binding.rvFormFields.layoutManager = flexboxLM
binding.rvFormFields.adapter = FormFieldAdapter(storeActivity ?: context, fields)
}
private fun setupButtons() {
binding.btnCancel.setOnClickListener { dismiss() }
binding.btnConfirm.setOnClickListener { confirmInputData() }
binding.tvTitle.setOnClickListener { SensorScaleUtils.startScale(autoScale = true) }
binding.btnClearZero.setOnClickListener { SensorScaleUtils.tare() }
}
private fun addWeightListener() {
storeActivity?.readWeightInfo { weight ->
if (abs(this.realWeight - weight) < 10) return@readWeightInfo
this.realWeight = weight
binding.tvTotalWeight.text = buildWeightSpannable(weight)
}
}
private fun buildWeightSpannable(num: Int): SpannableStringBuilder {
return buildSpannableString {
appendText(num.toString(), ForegroundColorSpan("#FF141428".toColorInt()), StyleSpan(Typeface.BOLD))
appendText("", ForegroundColorSpan("#FF000033".toColorInt()), StyleSpan(Typeface.BOLD))
}
}
/** 校验必填字段,通过后将 fields 转为 Map 提交 */
private fun confirmInputData() {
val invalid = fields.firstOrNull { it.required && it.value.isBlank() }
if (invalid != null) {
context.toast(invalid.hint.ifBlank { "${invalid.label}不能为空" })
return
}
if (realWeight <= 0) {
context.toast("物品重量需大于0")
return
}
if (storeType == 1) submitRawInbound() else submitCleanInbound()
}
/**
* 将 fields 转为接口 Map,规则:
* - apiKey 为空的字段跳过
* - submitValueId=true 时取 valueId,否则取 value
* - 空字符串跳过(可选字段无值不传)
* - quantity → IntzoneId/supplierId → LongstorageTemp → Double,其余 → String
*/
private fun buildParamMap(): MutableMap<String, Any> {
val map = mutableMapOf<String, Any>()
for (field in fields) {
if (field.apiKey.isBlank()) continue
val raw = if (field.submitValueId) field.valueId else field.value
if (raw.isBlank()) continue
val converted: Any = when (field.apiKey) {
"quantity" -> raw.toIntOrNull() ?: continue
"zoneId", "supplierId" -> raw.toLongOrNull() ?: continue
"storageTemp" -> raw.toDoubleOrNull() ?: continue
else -> raw
}
map[field.apiKey] = converted
}
return map
}
private fun submitRawInbound() {
val param = buildParamMap()
// weight 来自秤,不在表单中,额外注入
param["weight"] = realWeight / 1000.0
// supplierType 从 GlobalData 查找,不在表单中
val supplierId = param["supplierId"]?.toString()
val supplierOpt = GlobalData.inboundSupplierList.find { it.id?.toString() == supplierId }
param["supplierType"] = (supplierOpt?.type ?: 1)
Loading.show(storeActivity ?: return)
storeActivity?.storeViewModel?.submitRawInbound(param) { success, msg ->
Loading.dismiss()
if (success) {
context.toast("入库成功")
confirmBlock()
dismiss()
} else {
context.toast(msg ?: "入库失败")
}
}
}
private fun submitCleanInbound() {
val param = buildParamMap()
// weight 来自秤,不在表单中,额外注入
param["weight"] = realWeight / 1000.0
// inboundTime 净菜取当前时间,不在表单中
param["inboundTime"] = isoFormat.format(Date())
Loading.show(storeActivity ?: return)
storeActivity?.storeViewModel?.submitCleanInbound(param) { success, msg ->
Loading.dismiss()
if (success) {
context.toast("入库成功")
confirmBlock()
dismiss()
} else {
context.toast(msg ?: "入库失败")
}
}
}
private fun addChildToContainer() {
takePhotoBinding = LayoutTakePhotoBinding.inflate(inflater, binding.flContainer, true)
storeActivity?.unBindCamera()
setOnDismissListener { cameraUtils.unbind() }
cameraUtils.initCamera()
takePhotoBinding?.let {
previewView = it.previewView
it.flCameraPreview.visibility = View.VISIBLE
}
}
override fun show() {
super.show()
takePhotoBinding?.let {
cameraUtils.setPreviewController(previewView)
cameraUtils.bind()
it.llCameraFlag.postDelayed({ it.llCameraFlag.visibility = View.GONE }, 1000)
}
binding.root.postDelayed({ SensorScaleUtils.startScale() }, 2600)
}
}
@@ -0,0 +1,43 @@
package com.sw.inbound.model
import com.sw.inbound.model.response.DictType
/** 表单字段类型枚举 */
enum class FieldType {
/** 普通文本输入 */
TEXT,
/** 数字输入(支持小数和负数) */
NUMBER,
/** 下拉选择 */
DROPDOWN,
/** 日期选择弹窗 */
DATE_PICKER,
/** 固定值,不可编辑 */
FIXED
}
/**
* 动态表单字段数据模型
* @param label 字段标签名称
* @param type 字段类型,决定渲染哪种输入控件
* @param apiKey 对应接口的字段名,空字符串表示该字段不提交(如来源类型)
* @param required 是否必填,提交时校验
* @param hidden 是否隐藏,hidden=true 时不渲染该字段
* @param hint 校验失败提示文案,同时作为输入框 hint
* @param options 下拉框选项列表(DROPDOWN 类型使用)
* @param submitValueId 提交时使用 valueId 而非 valueDROPDOWN 选 id、DATE_PICKER 选 ISO 格式时设为 true
* @param value 当前显示值/输入值;DATE_PICKER 存 yyyy-MM-dd 显示格式
* @param valueId 下拉框选中项的 idDATE_PICKER 存 ISO 格式 yyyy-MM-dd'T'HH:mm:ss
*/
data class FormField(
val label: String,
val type: FieldType,
val apiKey: String = "",
val required: Boolean = false,
val hidden: Boolean = false,
val hint: String = "",
val options: List<DictType> = emptyList(),
val submitValueId: Boolean = false,
var value: String = "",
var valueId: String = ""
)
@@ -0,0 +1,26 @@
package com.sw.inbound.model.request
import com.google.gson.annotations.SerializedName
/** 净菜入库提交参数 */
data class CleanInboundParam(
@SerializedName("traceCode") val traceCode: String,
@SerializedName("ingredientName") val ingredientName: String,
/** 净菜类型,如"切丁"、"切片",参考 IngredientByTrace.vegTypes */
@SerializedName("cleanType") val cleanType: String,
/** 入库规格,如"袋装/500g"、"盒装/300g" */
@SerializedName("spec") val spec: String,
@SerializedName("quantity") val quantity: Int,
/** 入库重量(kg */
@SerializedName("weight") val weight: Double,
/** 入库时间,格式:yyyy-MM-dd'T'HH:mm:ss */
@SerializedName("inboundTime") val inboundTime: String,
@SerializedName("zoneId") val zoneId: Long,
@SerializedName("operator") val operator: String? = null,
/** 保质期至,格式同 inboundTime */
@SerializedName("expiryDate") val expiryDate: String? = null,
@SerializedName("storageTemp") val storageTemp: Double? = null,
@SerializedName("cameraCode") val cameraCode: String? = null,
@SerializedName("videoUrl") val videoUrl: String? = null,
@SerializedName("remark") val remark: String? = null
)
@@ -0,0 +1,26 @@
package com.sw.inbound.model.request
import com.google.gson.annotations.SerializedName
/** 毛菜入库提交参数 */
data class RawInboundParam(
@SerializedName("traceCode") val traceCode: String,
/** 供应商类型:1-内部,2-外部 */
@SerializedName("supplierType") val supplierType: Short,
@SerializedName("materialName") val materialName: String,
@SerializedName("supplierId") val supplierId: Long,
/** 入库规格,如"25kg/件"、"散装(kg)" */
@SerializedName("spec") val spec: String,
@SerializedName("quantity") val quantity: Int,
/** 入库重量(kg */
@SerializedName("weight") val weight: Double,
/** 入库时间,格式:yyyy-MM-dd'T'HH:mm:ss */
@SerializedName("inboundTime") val inboundTime: String,
@SerializedName("zoneId") val zoneId: Long,
@SerializedName("operator") val operator: String,
@SerializedName("storageArea") val storageArea: String? = null,
@SerializedName("reportNo") val reportNo: String? = null,
@SerializedName("cameraCode") val cameraCode: String? = null,
@SerializedName("videoUrl") val videoUrl: String? = null,
@SerializedName("remark") val remark: String? = null
)
@@ -0,0 +1,25 @@
package com.sw.inbound.model.response
/** 溯源码查食材结果,用于 InboundGoodsSearchDialog 列表展示 */
data class GoodsSearchModel(
val materId: Long = 0L,
val materName: String = "",
/** 净菜类型,逗号分隔,如"切丁,切片" */
val vegTypes: String = "",
val traceCode: String = "",
var isSelected: Boolean = false
) {
/** 将 vegTypes 拆分为列表,供净菜类型下拉使用 */
fun vegTypeList(): List<String> =
if (vegTypes.isBlank()) emptyList()
else vegTypes.split(",").map { it.trim() }.filter { it.isNotBlank() }
companion object {
fun from(item: IngredientByTrace): GoodsSearchModel = GoodsSearchModel(
materId = item.materId ?: 0L,
materName = item.materName ?: "",
vegTypes = item.vegTypes ?: "",
traceCode = item.traceCode ?: ""
)
}
}
@@ -0,0 +1,15 @@
package com.sw.inbound.model.response
import com.google.gson.annotations.SerializedName
/**
* 入库秤设备端接口统一响应包装
* 与旧 ApiResponse 区别:code 为 Int200 表示成功
*/
data class InboundApiResponse<T>(
@SerializedName("code") val code: Int = -1,
@SerializedName("msg") val msg: String? = null,
@SerializedName("data") val data: T? = null
) {
fun isSuccess(): Boolean = code == 200
}
@@ -0,0 +1,12 @@
package com.sw.inbound.model.response
import com.google.gson.annotations.SerializedName
/** 按溯源码查食材的响应条目 */
data class IngredientByTrace(
@SerializedName("traceCode") val traceCode: String? = null,
@SerializedName("materId") val materId: Long? = null,
@SerializedName("materName") val materName: String? = null,
/** 净菜类型,逗号分隔,如"切丁,切片" */
@SerializedName("vegTypes") val vegTypes: String? = null
)
@@ -0,0 +1,11 @@
package com.sw.inbound.model.response
import com.google.gson.annotations.SerializedName
/** 供应商下拉选项 */
data class SupplierOption(
@SerializedName("id") val id: Long? = null,
@SerializedName("name") val name: String? = null,
/** 1-内部供应商,2-外部供应商 */
@SerializedName("type") val type: Int? = null
)
@@ -0,0 +1,11 @@
package com.sw.inbound.model.response
import com.google.gson.annotations.SerializedName
/** 仓库区域下拉选项 */
data class ZoneOption(
@SerializedName("id") val id: Long? = null,
@SerializedName("zoneNo") val zoneNo: String? = null,
@SerializedName("zoneName") val zoneName: String? = null,
@SerializedName("warehouseId") val warehouseId: Long? = null
)
@@ -0,0 +1,50 @@
package com.sw.inbound.network.api
import com.sw.inbound.model.response.IngredientByTrace
import com.sw.inbound.model.response.InboundApiResponse
import com.sw.inbound.model.response.SupplierOption
import com.sw.inbound.model.response.ZoneOption
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Query
/**
* 入库秤设备端接口
* Base URL: http://192.168.10.101:24801/
* 鉴权:authorization header 由 InboundInterceptor 统一注入
*/
interface InboundApiService {
/** 按溯源码查食材,用于毛菜/净菜入库时联动选择食材 */
@GET("nutrition/neglect/inbound/ingredient-by-trace")
suspend fun getIngredientByTrace(
@Query("traceCode") traceCode: String
): InboundApiResponse<List<IngredientByTrace>>
/** 仓库区域下拉列表 */
@GET("nutrition/neglect/inbound/zone-options")
suspend fun getZoneOptions(
@Query("warehouseId") warehouseId: Long? = null,
@Query("keyword") keyword: String? = null
): InboundApiResponse<List<ZoneOption>>
/** 供应商下拉列表,type: 1-内部,2-外部,不传返回全部 */
@GET("nutrition/neglect/inbound/supplier-options")
suspend fun getSupplierOptions(
@Query("type") type: Int? = null,
@Query("keyword") keyword: String? = null
): InboundApiResponse<List<SupplierOption>>
/** 毛菜入库提交,入参为 Map 以便动态组装字段 */
@POST("nutrition/neglect/inbound/raw-inbound")
suspend fun submitRawInbound(
@Body param: Map<String, Any>
): InboundApiResponse<Unit>
/** 净菜入库提交,入参为 Map 以便动态组装字段 */
@POST("nutrition/neglect/inbound/clean-inbound")
suspend fun submitCleanInbound(
@Body param: Map<String, Any>
): InboundApiResponse<Unit>
}
@@ -0,0 +1,19 @@
package com.sw.inbound.network.interceptor
import okhttp3.Interceptor
import okhttp3.Response
/**
* 入库秤设备端接口拦截器
* 统一注入 authorization 固定鉴权值及 Content-Type
*/
class InboundInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().newBuilder()
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("authorization", "57ee87183f2a4fa59683ec9ef41c8f5d")
.build()
return chain.proceed(request)
}
}
@@ -0,0 +1,73 @@
package com.sw.inbound.repository
import com.google.gson.JsonParseException
import com.sw.inbound.model.response.IngredientByTrace
import com.sw.inbound.model.response.InboundApiResponse
import com.sw.inbound.model.response.SupplierOption
import com.sw.inbound.model.response.ZoneOption
import com.sw.inbound.network.api.InboundApiService
import com.sw.inbound.utils.LogSaveUtil
import retrofit2.HttpException
import timber.log.Timber
import java.io.IOException
import java.net.ConnectException
import java.net.SocketTimeoutException
import javax.inject.Inject
/**
* 入库秤设备端接口数据层
* 对应接口文档 Base URL: http://192.168.10.101:24801/nutrition/neglect/inbound
*/
class InboundRepository @Inject constructor(
private val apiService: InboundApiService
) {
/** 按溯源码查食材 */
suspend fun getIngredientByTrace(traceCode: String): InboundApiResponse<List<IngredientByTrace>> {
return safeCall { apiService.getIngredientByTrace(traceCode) }
}
/** 仓库区域下拉列表 */
suspend fun getZoneOptions(
warehouseId: Long? = null,
keyword: String? = null
): InboundApiResponse<List<ZoneOption>> {
return safeCall { apiService.getZoneOptions(warehouseId, keyword) }
}
/** 供应商下拉列表 */
suspend fun getSupplierOptions(
type: Int? = null,
keyword: String? = null
): InboundApiResponse<List<SupplierOption>> {
return safeCall { apiService.getSupplierOptions(type, keyword) }
}
/** 毛菜入库提交 */
suspend fun submitRawInbound(param: Map<String, Any>): InboundApiResponse<Unit> {
return safeCall { apiService.submitRawInbound(param) }
}
/** 净菜入库提交 */
suspend fun submitCleanInbound(param: Map<String, Any>): InboundApiResponse<Unit> {
return safeCall { apiService.submitCleanInbound(param) }
}
private suspend fun <T> safeCall(call: suspend () -> InboundApiResponse<T>): InboundApiResponse<T> {
return try {
call()
} catch (e: Exception) {
Timber.e("InboundRepository safeCall Exception: ${e.stackTraceToString()}")
LogSaveUtil.saveLogFile("InboundRepository safeCall Exception: ${e.stackTraceToString()}")
val msg = when (e) {
is HttpException -> "HTTP错误: ${e.code()} - ${e.message()}"
is SocketTimeoutException -> "请求超时: ${e.message}"
is ConnectException -> "连接失败: ${e.message}"
is JsonParseException -> "JSON解析错误: ${e.message}"
is IOException -> "网络IO错误: ${e.message}"
else -> "未知错误: ${e.message}"
}
InboundApiResponse(code = -1, msg = msg)
}
}
}
@@ -0,0 +1,96 @@
package com.sw.inbound.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sw.inbound.model.response.IngredientByTrace
import com.sw.inbound.model.response.SupplierOption
import com.sw.inbound.model.response.ZoneOption
import com.sw.inbound.repository.InboundRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class StoreViewModel @Inject constructor(
private val repository: InboundRepository
) : ViewModel() {
private val _zoneOptions = MutableStateFlow<List<ZoneOption>>(emptyList())
val zoneOptions: StateFlow<List<ZoneOption>> = _zoneOptions
private val _supplierOptions = MutableStateFlow<List<SupplierOption>>(emptyList())
val supplierOptions: StateFlow<List<SupplierOption>> = _supplierOptions
/** 按溯源码查食材,结果通过 callback 返回 */
fun getIngredientByTrace(
traceCode: String,
callback: (List<IngredientByTrace>) -> Unit
) {
viewModelScope.launch {
val response = repository.getIngredientByTrace(traceCode)
if (response.isSuccess()) {
callback(response.data ?: emptyList())
} else {
Timber.e("getIngredientByTrace failed: ${response.msg}")
callback(emptyList())
}
}
}
/** 加载仓库区域列表,结果同时更新 StateFlow 并通过 callback 返回 */
fun loadZoneOptions(
warehouseId: Long? = null,
keyword: String? = null,
callback: ((List<ZoneOption>) -> Unit)? = null
) {
viewModelScope.launch {
val response = repository.getZoneOptions(warehouseId, keyword)
if (response.isSuccess()) {
val list = response.data ?: emptyList()
_zoneOptions.value = list
callback?.invoke(list)
} else {
Timber.e("loadZoneOptions failed: ${response.msg}")
callback?.invoke(emptyList())
}
}
}
/** 加载供应商列表,结果同时更新 StateFlow 并通过 callback 返回 */
fun loadSupplierOptions(
type: Int? = null,
keyword: String? = null,
callback: ((List<SupplierOption>) -> Unit)? = null
) {
viewModelScope.launch {
val response = repository.getSupplierOptions(type, keyword)
if (response.isSuccess()) {
val list = response.data ?: emptyList()
_supplierOptions.value = list
callback?.invoke(list)
} else {
Timber.e("loadSupplierOptions failed: ${response.msg}")
callback?.invoke(emptyList())
}
}
}
/** 毛菜入库提交 */
fun submitRawInbound(param: Map<String, Any>, callback: (Boolean, String?) -> Unit) {
viewModelScope.launch {
val response = repository.submitRawInbound(param)
callback(response.isSuccess(), response.msg)
}
}
/** 净菜入库提交 */
fun submitCleanInbound(param: Map<String, Any>, callback: (Boolean, String?) -> Unit) {
viewModelScope.launch {
val response = repository.submitCleanInbound(param)
callback(response.isSuccess(), response.msg)
}
}
}
@@ -0,0 +1,144 @@
<?xml version="1.0" encoding="utf-8"?>
<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="1500dp"
android:layout_height="900dp"
android:background="@drawable/bg_white_radius12"
tools:ignore="HardcodedText">
<ImageView
android:id="@+id/ivClose"
android:layout_width="80dp"
android:layout_height="80dp"
android:layout_marginTop="30dp"
android:layout_marginEnd="30dp"
android:padding="10dp"
android:src="@mipmap/ic_close"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="ContentDescription" />
<TextView
android:id="@+id/tvDialogTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="物品检索"
android:textColor="#ff141428"
android:textSize="36sp"
android:textStyle="bold"
app:layout_constraintBottom_toTopOf="@+id/flSearchBlock"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<FrameLayout
android:id="@+id/flSearchBlock"
android:layout_width="match_parent"
android:layout_height="80dp"
android:layout_marginHorizontal="150dp"
android:layout_marginTop="180dp"
android:background="@drawable/bg_white_radius10_stroke2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<EditText
android:id="@+id/etInputGoods"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#00000000"
android:gravity="center"
android:hint="输入溯源码"
android:imeOptions="actionSearch"
android:inputType="text"
android:maxLines="1"
android:paddingStart="10dp"
android:paddingEnd="80dp"
android:textColor="#FF666666"
android:textColorHint="#FFC7C8DC"
android:textSize="30sp" />
<ImageView
android:id="@+id/ivGoodsSearch"
android:layout_width="60dp"
android:layout_height="60dp"
android:layout_gravity="end|center_vertical"
android:layout_marginEnd="16dp"
android:padding="14dp"
android:src="@mipmap/ic_goods_search"
tools:ignore="ContentDescription" />
</FrameLayout>
<com.scwang.smart.refresh.layout.SmartRefreshLayout
android:id="@+id/refreshLayout"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginHorizontal="130dp"
android:layout_marginTop="40dp"
android:layout_marginBottom="58dp"
app:layout_constraintBottom_toTopOf="@id/dividerLine"
app:layout_constraintTop_toBottomOf="@id/flSearchBlock">
<com.scwang.smart.refresh.header.ClassicsHeader
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvSearch"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:overScrollMode="never"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
app:spanCount="2"
tools:itemCount="8"
tools:listitem="@layout/list_item_goods_search" />
<com.scwang.smart.refresh.footer.ClassicsFooter
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.scwang.smart.refresh.layout.SmartRefreshLayout>
<View
android:id="@+id/dividerLine"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginHorizontal="40dp"
android:layout_marginBottom="160dp"
android:background="#FFDCDCF0"
app:layout_constraintBottom_toBottomOf="parent" />
<!-- btnAddGoods 在入库秤场景下隐藏 -->
<Button
android:id="@+id/btnAddGoods"
android:layout_width="240dp"
android:layout_height="80dp"
android:background="@drawable/bg_white_stroke_blue_ripple"
android:text="添加物品"
android:textColor="#FF0033CC"
android:textSize="30sp"
android:textStyle="bold"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/btnConfirm"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/dividerLine" />
<Button
android:id="@+id/btnConfirm"
android:layout_width="240dp"
android:layout_height="80dp"
android:background="@drawable/bg_blue_ripple"
android:text="确定"
android:textColor="@color/white"
android:textSize="30sp"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/dividerLine" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,153 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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="1500dp"
android:layout_height="900dp"
android:background="@drawable/bg_white_radius12"
android:gravity="center_horizontal"
android:orientation="vertical"
tools:ignore="HardcodedText">
<TextView
android:id="@+id/tvTitle"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_weight="14"
android:gravity="center"
android:text="物品入库"
android:textColor="#ff141428"
android:textSize="36sp"
android:textStyle="bold" />
<View
android:layout_width="match_parent"
android:layout_height="2dp"
android:layout_marginHorizontal="40dp"
android:background="#FFDCDCF0" />
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="70">
<androidx.constraintlayout.widget.Guideline
android:id="@+id/guideline"
android:layout_width="1dp"
android:layout_height="match_parent"
android:orientation="vertical"
app:layout_constraintGuide_percent="0.62" />
<!-- 左侧:FlexboxLayoutManager 驱动的动态表单,两列自动换行 -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvFormFields"
android:layout_width="0dp"
android:layout_height="match_parent"
android:clipToPadding="false"
android:descendantFocusability="afterDescendants"
android:paddingHorizontal="10dp"
android:paddingVertical="10dp"
app:layout_constraintEnd_toStartOf="@id/guideline"
app:layout_constraintStart_toStartOf="parent"
tools:listitem="@layout/list_item_form_field" />
<!-- 右侧:相机预览 + 秤重显示 -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:gravity="center_vertical"
android:orientation="vertical"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/guideline">
<!-- 相机预览容器,由代码动态 inflate LayoutTakePhotoBinding -->
<FrameLayout
android:id="@+id/flContainer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginBottom="15dp" />
<!-- 秤重显示条 -->
<FrameLayout
android:layout_width="488dp"
android:layout_height="70dp"
android:layout_gravity="center_horizontal"
android:background="@drawable/bg_gray_radius10">
<TextView
android:layout_width="120dp"
android:layout_height="match_parent"
android:layout_gravity="start"
android:background="@drawable/bg_white_radius10_stroke2"
android:gravity="center"
android:text="累计"
android:textColor="#FF141428"
android:textSize="24sp" />
<TextView
android:id="@+id/tvTotalWeight"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
android:layout_marginHorizontal="120dp"
android:gravity="center"
android:textColor="#FF141428"
android:textSize="24sp"
tools:text="6300 克" />
<TextView
android:id="@+id/btnClearZero"
android:layout_width="120dp"
android:layout_height="match_parent"
android:layout_gravity="end"
android:background="@drawable/bg_white_radius10_stroke2"
android:gravity="center"
android:text="清零"
android:textColor="#FF141428"
android:textSize="24sp" />
</FrameLayout>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
<View
android:layout_width="match_parent"
android:layout_height="2dp"
android:layout_marginHorizontal="40dp"
android:background="#FFDCDCF0" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_weight="16"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="50dp">
<Button
android:id="@+id/btnCancel"
style="?android:attr/buttonBarButtonStyle"
android:layout_width="240dp"
android:layout_height="80dp"
android:background="@drawable/bg_white_stroke_blue_ripple"
android:text="取消"
android:textColor="#FF0033CC"
android:textSize="30sp"
android:textStyle="bold" />
<Button
android:id="@+id/btnConfirm"
style="?android:attr/buttonBarButtonStyle"
android:layout_width="240dp"
android:layout_height="80dp"
android:layout_marginStart="30dp"
android:background="@drawable/bg_blue_ripple"
android:text="确定"
android:textColor="@color/white"
android:textSize="30sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,104 @@
<?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="wrap_content"
android:orientation="vertical"
android:paddingHorizontal="20dp"
android:paddingVertical="10dp">
<!-- 字段标签,必填项红色星号由 Adapter 通过 SpannableString 追加 -->
<TextView
android:id="@+id/tvLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="6dp"
android:textColor="#FF141428"
android:textSize="20sp"
android:textStyle="bold"
tools:text="食材名称" />
<!-- 输入区域容器,三种控件叠放,按 FieldType 切换 visibility -->
<FrameLayout
android:layout_width="match_parent"
android:layout_height="60dp">
<!-- TEXT / NUMBER / FIXED 类型:EditText -->
<EditText
android:id="@+id/etInput"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_white_radius10_stroke2"
android:focusableInTouchMode="true"
android:maxLines="1"
android:paddingHorizontal="20dp"
android:textColor="#FF141428"
android:textColorHint="#FF96A0AA"
android:textSize="22sp"
android:textStyle="bold"
android:visibility="gone"
tools:ignore="Autofill,TextFields" />
<!-- DROPDOWN 类型:点击触发 DropdownPopup -->
<FrameLayout
android:id="@+id/flDropdown"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_white_radius10_stroke2"
android:visibility="gone">
<TextView
android:id="@+id/tvDropdownValue"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginEnd="44dp"
android:layout_marginStart="20dp"
android:ellipsize="end"
android:gravity="center_vertical"
android:maxLines="1"
android:textColor="#FF141428"
android:textColorHint="#FF96A0AA"
android:textSize="22sp"
android:textStyle="bold" />
<ImageView
android:layout_width="32dp"
android:layout_height="wrap_content"
android:layout_gravity="end|center_vertical"
android:layout_marginEnd="16dp"
android:src="@mipmap/ic_triangle_down"
tools:ignore="ContentDescription" />
</FrameLayout>
<!-- DATE_PICKER 类型:点击触发 DatePickerDialog -->
<FrameLayout
android:id="@+id/flDatePicker"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_white_radius10_stroke2"
android:visibility="gone">
<TextView
android:id="@+id/tvDateValue"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginEnd="44dp"
android:layout_marginStart="20dp"
android:gravity="center_vertical"
android:textColor="#FF141428"
android:textColorHint="#FF96A0AA"
android:textSize="22sp"
android:textStyle="bold" />
<ImageView
android:layout_width="32dp"
android:layout_height="wrap_content"
android:layout_gravity="end|center_vertical"
android:layout_marginEnd="16dp"
android:src="@mipmap/ic_triangle_down"
tools:ignore="ContentDescription" />
</FrameLayout>
</FrameLayout>
</LinearLayout>