9 Commits
Author SHA1 Message Date
lvmeng 3b1dc0a03f refactor(activity): 统一各入口传 FoodRecord 跳转 PrepareFoodActivity,重构菜品详情加载逻辑 2026-05-08 18:38:14 +08:00
lvmeng f4d383dfe1 feat(adapter): FoodMaterialAdapter 无重量时 ic_dish_clear 染灰色并将点击转发为 item 选中 2026-05-08 16:12:03 +08:00
lvmengandClaude Sonnet 4.6 a14072af00 chore: 补充 SCALE_ORDER_1 配置、Weigher2 unInit 修复及调料列表模拟数据
- ScaleDeviceConfig:新增 SCALE_ORDER_1 单秤地址列表
- Weigher2:unInit() 补充调用 Weigher.unInit(),确保串口资源正确释放
- SeasoningSelectDialog:getGoodsList 临时改为模拟数据,待联调时切回真实接口

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:49:35 +08:00
lvmengandClaude Sonnet 4.6 436de88d73 fix(scale): 修复子设备重启后主设备看不到连接数据的问题,新增清除数据广播功能
- ScaleWebSocketClient:引入版本号机制防止 UDP 触发的新连接与指数退避重连任务并发建立两条连接;onFailure/onClosed 改用 remove(key, value) 原子操作,避免旧连接超时回调误删新连接引用
- ScaleWebSocketServer:新增 latestData 为空时延迟 3 秒补推机制,解决子设备刚重启时 WeightUtil 尚无读数导致主设备看不到数据的问题;新增 onClearData 回调处理清除数据事件;stop() 时同步关闭 scheduler
- ScaleEvent:新增 TYPE_CLEAR_DATA 事件类型
- ScaleServiceManager:新增 onClearData 属性和 sendClearData() 方法
- SlaveActivity:监听 onClearData 事件,收到后清空本机数据并重置 SP 状态;启动时预填空占位格子
- SettingActivity/DbViewModel/DbRepository:clearAllTestData 重命名为 clearAllData,清除完成后通知子设备并跳转 HomeActivity
- scale包架构分析.md:同步更新文档

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:48:59 +08:00
lvmengandClaude Sonnet 4.6 a0137d6979 feat(db): 新增测试数据清除功能,物理清空全部 4 张表并重置 SP 状态
- 各 DAO 新增 deleteAll() 物理清空方法
- DbRepository 新增 clearAllTestData(),按顺序删除食材→菜品→调料→槽位
- DbViewModel 暴露 clearAllTestData(onDone) 供 UI 层调用
- SettingActivity 新增「测试数据清除」入口(隐藏,右上角图标触发显示)
- 清除完成后重置 SpTool.cookMode=-1 和 SpTool.canteenId="0"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 11:58:32 +08:00
lvmeng c46264a4cf feat(activity): 新增 canteenId 持久化存储,HomeActivity 切换食堂时同步写入 SpTool 并恢复选中状态 2026-05-08 11:50:22 +08:00
lvmeng 979b93c53d feat(activity): 统一键盘收起逻辑,新增 RecyclerView 侧滑收起键盘支持 2026-05-08 11:10:13 +08:00
lvmeng 9880f2a5a8 feat(activity): 新增识别添加菜品时自动计算 materialType 逻辑 2026-05-08 10:26:13 +08:00
lvmeng 19b5d721a7 style(layout): 优化布局文件结构和属性配置
- 统一调整了 activity_setting.xml 中各个 LinearLayout 的属性顺序
- 为 fragment_seasoning_config.xml 和 fragment_vector_collection.xml
  添加了工具忽略属性
- 重新排列了 list_item_scale22_row.xml 和 list_item_scale_row.xml
  的命名空间和属性顺序
- 在 list_type_scale1.xml 中调整了组件的布局属性顺序
- 为设置页面添加了数据库查看和秤数据监控功能的可见性控制
- 修复了布局文件中的硬编码文本警告问题
2026-05-08 08:44:42 +08:00
34 changed files with 609 additions and 192 deletions
@@ -1,6 +1,7 @@
package com.shuwei.dish.match.adapter
import android.content.Context
import android.content.res.ColorStateList
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.content.ContextCompat
@@ -12,6 +13,7 @@ import com.shuwei.dish.match.entity.GoodsItem
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.roundedOneDecimalPlace
import com.shuwei.dish.match.utils.ext.visible
import androidx.core.graphics.toColorInt
class FoodMaterialAdapter(list: MutableList<GoodsItem>) :
BaseQuickAdapter<GoodsItem, FoodMaterialAdapter.VH>(list) {
@@ -54,6 +56,14 @@ class FoodMaterialAdapter(list: MutableList<GoodsItem>) :
if (data.isOriginalData) R.drawable.ic_dish_clear
else R.drawable.ic_delete
)
// isOriginalData 时根据是否有重量信息控制图标颜色:无重量染灰色,有重量恢复原色
if (data.isOriginalData) {
val hasWeight = data.useWeight != null && data.useWeight != 0.0
ivClearIcon.imageTintList = if (hasWeight) null
else ColorStateList.valueOf("#BFBFBF".toColorInt())
} else {
ivClearIcon.imageTintList = null
}
ivOperateIcon.setImageResource(
if (data.isSetFinished) R.drawable.ic_dish_selected
else R.drawable.ic_dish_unselected
@@ -23,6 +23,7 @@ import com.shuwei.dish.match.net.NetViewModel
import com.shuwei.dish.match.db.DbViewModel
import com.shuwei.dish.match.ui.InitActivity
import com.shuwei.dish.match.utils.ActivityManager
import com.shuwei.dish.match.utils.KeyboardUtil
import com.shuwei.dish.match.utils.ext.dp
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.invisible
@@ -65,6 +66,7 @@ open class BaseActivity : AppCompatActivity() {
binding.tvRightTime.setPadding(0, verticalPadding, 0, verticalPadding)
binding.llTitleBar.gone()
binding.ivBack.setOnClickListener { finish() }
binding.root.setOnClickListener { hideKeyboard() }
}
override fun onDestroy() {
@@ -264,4 +266,8 @@ open class BaseActivity : AppCompatActivity() {
}
}
fun hideKeyboard() {
KeyboardUtil.hideKeyboard(binding.root)
}
}
@@ -222,6 +222,17 @@ class DbRepository {
suspend fun countSlotAll() = withContext(Dispatchers.IO) {
db.seasoningSlotDao().countSlotAll()
}
/**
* 物理清空全部 4 张表的测试数据
* 清除顺序:先删子表(食材),再删主表(菜品),最后删配置表(调料、槽位)
*/
suspend fun clearAllData() = withContext(Dispatchers.IO) {
db.cookFoodGoodsDao().deleteAll()
db.cookFoodDao().deleteAll()
db.seasoningDao().deleteAll()
db.seasoningSlotDao().deleteAll()
}
}
//
//class SeasoningRepository(val seasoningDao: SeasoningDao) {
@@ -239,6 +239,17 @@ class DbViewModel : ViewModel() {
// ---- 数据库调试查看 ----
/**
* 物理清空全部 4 张表的测试数据,完成后通过回调通知 UI
* @param onDone 清除完成后在主线程执行的回调
*/
fun clearAllData(onDone: () -> Unit) {
viewModelScope.launch {
rep.clearAllData()
onDone()
}
}
companion object {
/** 每页加载条数 */
const val DB_INSPECT_PAGE_SIZE = 50
@@ -40,4 +40,8 @@ interface CookFoodDao {
/** 统计菜品记录总数,showDel=true 时包含已删除记录 */
@Query("SELECT COUNT(*) FROM dm_cook_food WHERE (:showDel = 1 OR isDel = 0)")
suspend fun countCookFoodAll(showDel: Int): Int
/** 物理清空全表,用于清除测试数据 */
@Query("DELETE FROM dm_cook_food")
suspend fun deleteAll()
}
@@ -40,4 +40,8 @@ interface CookFoodGoodsDao {
/** 统计食材记录总数,showDel=true 时包含已删除记录 */
@Query("SELECT COUNT(*) FROM dm_cook_food_goods WHERE (:showDel = 1 OR isDel = 0)")
suspend fun countCookFoodGoodsAll(showDel: Int): Int
/** 物理清空全表,用于清除测试数据 */
@Query("DELETE FROM dm_cook_food_goods")
suspend fun deleteAll()
}
@@ -53,4 +53,8 @@ interface SeasoningDao {
/** 统计调料记录总数,showDel=true 时包含已删除记录 */
@Query("SELECT COUNT(*) FROM dm_seasoning WHERE (:showDel = 1 OR isDel = 0)")
suspend fun countSeasoningAll(showDel: Int): Int
/** 物理清空全表,用于清除测试数据(比 clearAllSeasoning 的逻辑删除更彻底) */
@Query("DELETE FROM dm_seasoning")
suspend fun deleteAll()
}
@@ -60,4 +60,8 @@ interface SeasoningSlotDao {
*/
@Query("DELETE FROM dm_seasoning_slot WHERE deviceId = :deviceId")
suspend fun deleteAllByDeviceId(deviceId: String)
/** 物理清空全表,用于清除测试数据 */
@Query("DELETE FROM dm_seasoning_slot")
suspend fun deleteAll()
}
@@ -186,12 +186,25 @@ class SeasoningSelectDialog(
* 请求调料列表
*/
private fun getGoodsList() {
activity.netViewModel.queryGoodsList(
goodsType = "1",
pageNum = pageNo,
pageSize = PAGE_SIZE,
goodsName = goodsName.takeUnless { it.isNullOrBlank() }
// activity.netViewModel.queryGoodsList(
// 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)
}
/**
@@ -51,6 +51,10 @@ class NetViewModel(
}
}
fun resetFoodDetailState() {
_foodDetailState.value = UiState.Idle
}
/**
* 搜索菜品列表的 UI 状态流,UI 层通过 collect 监听
*/
@@ -17,6 +17,9 @@ object ScaleDeviceConfig {
/** 18个秤的显示顺序(按物理位置排列) */
val SCALE_ORDER_18 = listOf(6, 5, 4, 3, 2, 1, 12, 11, 10, 9, 8, 7, 18, 17, 16, 15, 14, 13)
/** 1个秤的地址列表 */
val SCALE_ORDER_1 = listOf(1)
/** 设备在列表中的显示顺序 */
val DEVICE_ORDER = listOf(DEVICE_ID_2, DEVICE_ID_22, DEVICE_ID_18, DEVICE_ID_1)
}
@@ -34,5 +34,7 @@ data class ScaleEvent(
const val TYPE_SEASONING_ADDED = "seasoning_added"
/** 调料槽位配置同步事件:主设备配置变更后广播给子设备 */
const val TYPE_SEASONING_CONFIG = "seasoning_config"
/** 清除数据事件:主设备通知所有子设备清除本机测试数据 */
const val TYPE_CLEAR_DATA = "clear_data"
}
}
@@ -71,6 +71,14 @@ object ScaleServiceManager {
get() = wsServer?.onSeasoningConfig
set(value) { wsServer?.onSeasoningConfig = value }
/**
* 收到主设备下发的清除数据事件时的回调,仅子设备有效
* 在子线程中调用,需自行切换到主线程更新 UI
*/
var onClearData: ((ScaleEvent) -> Unit)?
get() = wsServer?.onClearData
set(value) { wsServer?.onClearData = value }
/**
* 向所有已连接子设备广播调料配置(主设备调用)
* @param slots 全量槽位配置列表
@@ -79,6 +87,16 @@ object ScaleServiceManager {
wsClient?.sendToAllSlaves(buildConfigEvent(slots))
}
/**
* 通知所有已连接子设备清除本机数据(主设备调用)
* 子设备收到后执行与主设备相同的清除逻辑
*/
fun sendClearData() {
wsClient?.sendToAllSlaves(
ScaleEvent(type = ScaleEvent.TYPE_CLEAR_DATA, deviceId = GlobalData.deviceId)
)
}
/**
* 向单台刚连接的子设备推送调料配置(连接时按需调用)
* @param deviceId 目标子设备 ID
@@ -43,6 +43,11 @@ class ScaleWebSocketClient {
private val reconnectDelays = ConcurrentHashMap<String, Long>()
/** 已主动移除的设备集合,不对其触发重连 */
private val removedDevices = ConcurrentHashMap.newKeySet<String>()
/**
* 各设备的连接版本号,每次调用 connect() 时递增
* doConnect 执行前校验版本,版本不匹配说明已有更新的连接请求,直接放弃本次连接
*/
private val connectVersions = ConcurrentHashMap<String, Int>()
private val scheduler: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor()
@@ -66,13 +71,21 @@ class ScaleWebSocketClient {
*/
fun connect(deviceId: String, host: String, port: Int) {
removedDevices.remove(deviceId)
// 取消旧 IP 的重连任务,防止 IP 变化时新旧任务并发竞争
// 取消旧重连任务,防止 IP 变化时新旧任务并发竞争
reconnectTasks.remove(deviceId)?.cancel(false)
reconnectDelays[deviceId] = RECONNECT_BASE_MS
doConnect(deviceId, host, port)
// 递增版本号:doConnect 执行前会校验版本,旧任务版本不匹配时直接放弃
val version = (connectVersions[deviceId] ?: 0) + 1
connectVersions[deviceId] = version
doConnect(deviceId, host, port, version)
}
private fun doConnect(deviceId: String, host: String, port: Int) {
private fun doConnect(deviceId: String, host: String, port: Int, version: Int = connectVersions[deviceId] ?: 0) {
// 版本校验:若当前版本已被更新的 connect() 调用覆盖,放弃本次连接
if (connectVersions[deviceId] != version) {
Log.d(TAG, "放弃过期连接任务: $deviceId, version=$version")
return
}
val url = "ws://$host:$port"
val request = Request.Builder().url(url).build()
@@ -103,17 +116,24 @@ class ScaleWebSocketClient {
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Log.w(TAG, "连接子设备失败: $deviceId, ${t.message}")
connections.remove(deviceId)
// 使用 remove(key, value) 确保只移除自己的实例
// 避免旧连接超时触发时误删新连接引用,导致 aggregator 错误清空新连接数据
val removed = connections.remove(deviceId, webSocket)
if (removed) {
onDeviceDisconnected?.invoke(deviceId)
scheduleReconnect(deviceId, host, port)
}
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
Log.d(TAG, "子设备连接已关闭: $deviceId, reason=$reason")
connections.remove(deviceId)
// 同上:只有移除的是自己,才触发断线回调和重连
val removed = connections.remove(deviceId, webSocket)
if (removed) {
onDeviceDisconnected?.invoke(deviceId)
scheduleReconnect(deviceId, host, port)
}
}
})
connections[deviceId] = ws
@@ -129,10 +149,12 @@ class ScaleWebSocketClient {
if (scheduler.isShutdown) return
val delay = reconnectDelays[deviceId] ?: RECONNECT_BASE_MS
Log.d(TAG, "将在 ${delay}ms 后重连子设备: $deviceId")
// 记录当前版本,延迟任务执行时再次校验,防止期间有新的 connect() 调用
val version = connectVersions[deviceId] ?: 0
val future = scheduler.schedule({
if (!removedDevices.contains(deviceId)) {
doConnect(deviceId, host, port)
doConnect(deviceId, host, port, version)
}
}, delay, TimeUnit.MILLISECONDS)
@@ -188,6 +210,7 @@ class ScaleWebSocketClient {
reconnectTasks.remove(deviceId)?.cancel(false)
connections.remove(deviceId)?.close(1000, "设备离线")
reconnectDelays.remove(deviceId)
connectVersions.remove(deviceId)
Log.d(TAG, "已断开子设备: $deviceId")
}
@@ -9,6 +9,8 @@ import org.java_websocket.WebSocket
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.TimeUnit
/**
* 秤数据 WebSocket 服务端(主设备和子设备均运行)
@@ -20,10 +22,14 @@ class ScaleWebSocketServer(private val deviceId: String, private val context: Co
private const val TAG = "ScaleWebSocketServer"
/** 节流间隔:两次推送之间的最小间隔(毫秒) */
private const val THROTTLE_MS = 100L
/** latestData 为空时延迟补推的时间(毫秒),给 WeightUtil 时间产生第一次读数 */
private const val RETRY_PUSH_DELAY_MS = 3_000L
}
private val gson = Gson()
private var server: InternalServer? = null
/** 延迟补推任务的调度器 */
private val scheduler = Executors.newSingleThreadScheduledExecutor()
/** 主设备连接状态变化回调:true=已连接,false=已断开;在主线程外调用,需自行切换线程 */
var onConnectionChanged: ((connected: Boolean) -> Unit)? = null
@@ -31,6 +37,9 @@ class ScaleWebSocketServer(private val deviceId: String, private val context: Co
/** 收到主设备下发的调料配置同步事件时的回调,在子线程调用 */
var onSeasoningConfig: ((event: ScaleEvent) -> Unit)? = null
/** 收到主设备下发的清除数据事件时的回调,在子线程调用 */
var onClearData: ((event: ScaleEvent) -> Unit)? = null
/** 当前是否有主设备连接 */
val isConnected: Boolean
get() = server?.connections?.isNotEmpty() == true
@@ -77,6 +86,8 @@ class ScaleWebSocketServer(private val deviceId: String, private val context: Co
WeightUtil.removeWeightListener(TAG)
onConnectionChanged = null
onSeasoningConfig = null
onClearData = null
scheduler.shutdownNow()
try {
server?.stop(1000)
} catch (e: Exception) {
@@ -124,7 +135,9 @@ class ScaleWebSocketServer(private val deviceId: String, private val context: Co
connectionCount++
Log.d(TAG, "主设备已连接: ${conn.remoteSocketAddress}")
onConnectionChanged?.invoke(true)
// 新客户端连接后,立即推送所有秤的最新数据
if (latestData.isNotEmpty()) {
latestData.values.forEach { data ->
try {
conn.send(gson.toJson(data))
@@ -132,6 +145,23 @@ class ScaleWebSocketServer(private val deviceId: String, private val context: Co
Log.w(TAG, "初始推送失败: ${e.message}")
}
}
} else {
// latestData 为空说明刚重启,WeightUtil 还未产生读数
// 延迟 3 秒后补推一次,确保主设备能看到本机秤
Log.d(TAG, "latestData 为空,${RETRY_PUSH_DELAY_MS}ms 后补推")
scheduler.schedule({
if (conn.isOpen && latestData.isNotEmpty()) {
latestData.values.forEach { data ->
try {
conn.send(gson.toJson(data))
Log.d(TAG, "补推数据: address=${data.address}, weight=${data.weight}")
} catch (e: Exception) {
Log.w(TAG, "补推失败: ${e.message}")
}
}
}
}, RETRY_PUSH_DELAY_MS, TimeUnit.MILLISECONDS)
}
}
override fun onClose(conn: WebSocket, code: Int, reason: String, remote: Boolean) {
@@ -151,6 +181,7 @@ class ScaleWebSocketServer(private val deviceId: String, private val context: Co
val event = gson.fromJson(message, ScaleEvent::class.java)
when (event.type) {
ScaleEvent.TYPE_SEASONING_CONFIG -> onSeasoningConfig?.invoke(event)
ScaleEvent.TYPE_CLEAR_DATA -> onClearData?.invoke(event)
}
return
}
@@ -47,8 +47,8 @@ class CollectedFoodActivity : BaseActivity() {
addOnItemChildClickListener(R.id.ivDelete) { _, _, position ->
showDeleteDialog(foodList[position].foodName)
}
setOnItemClickListener { _, view, _ ->
KeyboardUtil.hideKeyboard(view)
setOnItemClickListener { _, _, _ ->
hideKeyboard()
}
}
}
@@ -84,12 +84,12 @@ class CollectedFoodActivity : BaseActivity() {
setContentView(binding.root)
setHeaderBackground()
binding.root.setOnClickListener { v ->
KeyboardUtil.hideKeyboard(v)
hideKeyboard()
}
setTitleBar(titleBarAction = {
it.visible()
it.setOnClickListener { v ->
KeyboardUtil.hideKeyboard(v)
hideKeyboard()
}
}, titleAction = {
it.text = "已采集食材"
@@ -156,7 +156,7 @@ class CollectedFoodActivity : BaseActivity() {
performSearch {
toast(binding.editSearch.hint.toString())
}
KeyboardUtil.hideKeyboard(v)
hideKeyboard()
}
binding.editSearch.run {
addTextChangedListener {
@@ -169,7 +169,7 @@ class CollectedFoodActivity : BaseActivity() {
performSearch {
toast(binding.editSearch.hint.toString())
}
KeyboardUtil.hideKeyboard(v)
hideKeyboard()
true
} else {
false
@@ -117,11 +117,10 @@ class CookingModeActivity : BaseActivity() {
return
}
startActivity<FoodSearchActivity> {
putExtra(FoodSearchActivity.PAGE_TYPE, 1)
putExtra(FoodSearchActivity.DINNER_TYPE, dinnerType)
putExtra(FoodSearchActivity.FOOD_NAME, searchText)
}
KeyboardUtil.hideKeyboard(binding.etInputDish)
hideKeyboard()
}
private fun loadFragment() {
@@ -17,6 +17,7 @@ import com.shuwei.dish.match.databinding.LayoutEmptyViewBinding
import com.shuwei.dish.match.entity.FoodRecord
import com.shuwei.dish.match.net.UiState
import com.shuwei.dish.match.utils.KeyboardUtil
import com.shuwei.dish.match.utils.SpTool
import com.shuwei.dish.match.utils.ext.addOnActionSearchListener
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.startActivity
@@ -32,7 +33,7 @@ class FoodSearchActivity : BaseActivity() {
companion object {
private const val TAG = "FoodSearchActivity"
const val PAGE_TYPE = "pageType"
const val FOOD_ITEM = "foodItem"
const val FOOD_ID = "foodId"
const val FOOD_NAME = "foodName"
const val DINNER_TYPE = "dinnerType"
@@ -41,13 +42,12 @@ class FoodSearchActivity : BaseActivity() {
private lateinit var binding: ActivityFoodSearchBinding
private var list: MutableList<FoodRecord> = mutableListOf()
private var pageType = 0
private var dinnerType = "1"
private val recordAdapter by lazy {
Food2Adapter(list).apply {
isStateViewEnable = true
setOnItemClickListener { adapter, view, position ->
if (pageType == 1) {
if (SpTool.cookMode == 0) {
val jumpItem = list[position].also { it.dinnerType = dinnerType }
startActivity<PrepareFoodActivity> {
putExtra(PrepareFoodActivity.FOOD_ITEM, jumpItem as Serializable)
@@ -55,8 +55,7 @@ class FoodSearchActivity : BaseActivity() {
return@setOnItemClickListener
}
setResult(RESULT_OK, Intent().apply {
putExtra(FOOD_ID, list[position].foodId)
putExtra(FOOD_NAME, list[position].foodName)
putExtra(FOOD_ITEM, list[position] as Serializable)
})
finish()
}
@@ -69,7 +68,6 @@ class FoodSearchActivity : BaseActivity() {
super.onCreate(savedInstanceState)
binding = ActivityFoodSearchBinding.inflate(layoutInflater)
setContentView(binding.root)
pageType = intent.getIntExtra(PAGE_TYPE, 0)
dinnerType = intent.getStringExtra(DINNER_TYPE) ?: "0"
setHeaderBackground()
foodName = intent.getStringExtra(FOOD_NAME)
@@ -122,7 +120,7 @@ class FoodSearchActivity : BaseActivity() {
binding.ivDishSearch.setOnClickListener { v ->
pageNo = 1
getInputAndSearch()
KeyboardUtil.hideKeyboard(v)
hideKeyboard()
}
binding.refreshLayout.setEnableRefresh(true)
binding.refreshLayout.setEnableLoadMore(false)
@@ -139,13 +137,13 @@ class FoodSearchActivity : BaseActivity() {
queryListInfo(foodName!!)
}
binding.root.setOnClickListener { v ->
KeyboardUtil.hideKeyboard(v)
hideKeyboard()
}
binding.etInputDish.let { v ->
v.addOnActionSearchListener {
pageNo = 1
getInputAndSearch()
KeyboardUtil.hideKeyboard( v)
hideKeyboard()
}
}
}
@@ -42,12 +42,14 @@ class HomeActivity : BaseActivity() {
binding = ActivityHomeBinding.inflate(layoutInflater)
setContentView(binding.root)
setHeaderBackground(isHomePage = true)
// 根据当前 canteenId 恢复 RadioButton 选中状态
binding.radioGroup.check(
if (BaseApp.canteenId == "1678234139391512577") R.id.radioSw else R.id.radioZk
)
binding.radioGroup.setOnCheckedChangeListener { group, checkedId ->
if (checkedId == R.id.radioSw) {
BaseApp.canteenId = "1678234139391512577"
} else {
BaseApp.canteenId = "0"
}
val id = if (checkedId == R.id.radioSw) "1678234139391512577" else "0"
SpTool.canteenId = id
BaseApp.canteenId = id
}
onBackPressedDispatcher.addCallback(this) {}
// binding.radioGroup.check(R.id.radioZk)
@@ -80,7 +80,7 @@ class InitActivity : BaseActivity() {
setHeaderBgVisible(false)
BaseApp.appVersion = AppUtil.getAppVersionCode(this).toString()
BaseApp.canteenId = "0"
BaseApp.canteenId = SpTool.canteenId
WeightUtil.init()
WeightUtil.getWeight()
@@ -5,13 +5,17 @@ import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import androidx.activity.addCallback
import androidx.core.content.IntentCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.shuwei.dish.match.R
import com.shuwei.dish.match.adapter.FoodMaterialAdapter
import com.shuwei.dish.match.base.BaseActivity
@@ -39,6 +43,7 @@ import com.shuwei.dish.match.utils.ext.toJsonString
import com.shuwei.dish.match.utils.ext.toast
import com.shuwei.dish.match.utils.ext.visible
import com.yanzhenjie.recyclerview.SwipeMenuItem
import com.yanzhenjie.recyclerview.SwipeRecyclerView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -53,11 +58,13 @@ class PrepareFoodActivity : BaseActivity() {
companion object {
const val TAG = "CookActivity"
const val FOOD_ITEM = "foodItem"
const val FOOD_NAME = "foodName"
const val PAGE_FROM = "pageFrom"
const val HOME = "home"
const val WEIGHT_CHANGE_VALUE = 15
const val WEIGHT_RECOGNIZE_VALUE = 10
/** 主材数量上限 */
const val MATERIAL_COUNT = 3
}
private val binding by lazy { ActivityPrepareFoodBinding.inflate(layoutInflater) }
@@ -66,9 +73,6 @@ class PrepareFoodActivity : BaseActivity() {
private var food: FoodRecord? = null
/** 采集模式下通过搜索页回填的 foodId */
private var foodId: String? = null
/** 来源页面标识,HOME 时返回跳转 SamplingListActivity */
private var pageFrom: String? = null
@@ -78,8 +82,8 @@ class PrepareFoodActivity : BaseActivity() {
setContentView(binding.root)
setHeaderBackground()
pageFrom = intent.getStringExtra(PAGE_FROM)
food = intent.extras?.getSerializable(FOOD_ITEM) as FoodRecord?
//food = intent.extras?.getSerializable(FOOD_ITEM) as FoodRecord?
food = IntentCompat.getSerializableExtra(intent, FOOD_ITEM, FoodRecord::class.java)
val isSamplingMode = SpTool.cookMode == 1
val titleText = if (isSamplingMode) "菜品采集" else "菜品制作"
setTitleBar(titleBarAction = {
@@ -93,16 +97,21 @@ class PrepareFoodActivity : BaseActivity() {
onBackPressedDispatcher.onBackPressed()
}
})
food?.let {
binding.etInputDish.run {
setText(it.foodName)
tag = it.foodName
}
}
// 根据 cookMode 设置搜索栏可编辑性
if (isSamplingMode) {
// 采集模式:可编辑,支持搜索
val foodName = intent.getStringExtra(FOOD_NAME)
binding.etInputDish.setText(foodName ?: food?.foodName ?: "")
binding.ivDishSearch.visible()
binding.etInputDish.let {
it.setSelection(it.length())
}
} else {
// 制作模式:不可编辑,仅展示菜名
binding.etInputDish.setText(food?.foodName ?: "")
binding.etInputDish.isFocusable = false
binding.etInputDish.isFocusableInTouchMode = false
binding.ivDishSearch.gone()
@@ -114,8 +123,8 @@ class PrepareFoodActivity : BaseActivity() {
initRecyclerView()
initObserver()
if (!isSamplingMode) {
getDishDetail()
if (food?.foodId.isNullOrBlank().not()) {
getDishDetail(food!!.foodId!!)
}
}
@@ -125,6 +134,7 @@ class PrepareFoodActivity : BaseActivity() {
WeightUtil.tareTwo(AddressUtil.ONE)
delayDismissLoading()
}
//binding.root.setOnClickListener { hideKeyboard() }
WeightUtil.addWeightListener(
weightKey = TAG,
getWeight = { address, state, weight ->
@@ -143,8 +153,8 @@ class PrepareFoodActivity : BaseActivity() {
if (SpTool.cookMode == 1) {
// 采集模式:绑定搜索栏交互
binding.ivDishSearch.setOnClickListener { v -> jumpSearch(v) }
binding.etInputDish.addOnActionSearchListener { jumpSearch(binding.etInputDish) }
binding.ivDishSearch.setOnClickListener { jumpSearch() }
binding.etInputDish.addOnActionSearchListener { jumpSearch() }
}
binding.btnCook.clickWithDebounce {
@@ -159,10 +169,11 @@ class PrepareFoodActivity : BaseActivity() {
toast("菜品构成信息未设置")
return@clickWithDebounce
}
val foodRecord = FoodRecord()
val foodRecord = food ?: FoodRecord()
binding.etInputDish.let {
if (it.tag != null && it.tag.toString() == it.text.toString()) {
foodRecord.foodId = foodId
if (it.tag == null || it.tag.toString() != it.text.toString()) {
// 输入框内容与回填名称不一致,说明用户手动修改了菜名,清除 foodId
foodRecord.foodId = null
}
}
foodRecord.foodName = showFoodName
@@ -187,11 +198,13 @@ class PrepareFoodActivity : BaseActivity() {
}
private val isTakingPhoto = AtomicBoolean(false)
// 识别成功并查询到数据打开识别页面
private var showRecognizePage = false
// 页面可见时允许识别,onPause 后禁止,onResume 后恢复
private var pageVisible = true
// 手动取消标识
private var manualCancelFlag = false
private var lastPhotoUri: Uri? = null
@@ -211,8 +224,14 @@ class PrepareFoodActivity : BaseActivity() {
FoodRecognizeActivity.close()
return
}
if (!pageVisible || manualCancelFlag || isTakingPhoto.get() || showRecognizePage || abs(lastWeight - weight) <= WEIGHT_CHANGE_VALUE) {
Log.d(TAG, "recognizeFood, lastWeight:$lastWeight, weight:$weight, pageVisible:$pageVisible, manualCancelFlag:$manualCancelFlag, isTakingPhoto:${isTakingPhoto.get()}")
if (!pageVisible || manualCancelFlag || isTakingPhoto.get() || showRecognizePage || abs(
lastWeight - weight
) <= WEIGHT_CHANGE_VALUE
) {
Log.d(
TAG,
"recognizeFood, lastWeight:$lastWeight, weight:$weight, pageVisible:$pageVisible, manualCancelFlag:$manualCancelFlag, isTakingPhoto:${isTakingPhoto.get()}"
)
return
}
showLoading("识别中……")
@@ -239,9 +258,40 @@ class PrepareFoodActivity : BaseActivity() {
it.isNewDishType = true
it.isSetFinished = true
})
binding.rvMaterialList.smoothScrollToPosition(list.size - 1)
}
updateMaterialTypes()
}
/**
* 重新计算 list 中非接口数据(isOriginalData=false)的 materialType
*
* 规则:
* - 接口数据主材数量 n = list 中 isOriginalData=true 且 materialType=1 的数量
* - 有接口数据且 MATERIAL_COUNT - n <= 0:所有非接口数据均为辅材(2)
* - 无接口数据,或 MATERIAL_COUNT - n > 0m = MATERIAL_COUNT - n
* 非接口数据按 useWeight 从大到小排序,前 m 个为主材(1),其余为辅材(2)
*/
private fun updateMaterialTypes() {
val hasOriginalData = list.any { it.isOriginalData }
// 接口数据中主材数量
val n = list.count { it.isOriginalData && it.materialType == 1 }
// 待分配的非接口数据
val nonOriginalList = list.filter { !it.isOriginalData }
if (hasOriginalData && MATERIAL_COUNT - n <= 0) {
// 主材已满,所有非接口数据均为辅材
nonOriginalList.forEach { it.materialType = 2 }
} else {
// 剩余可分配主材名额
val m = MATERIAL_COUNT - n
// 按 useWeight 从大到小排序
val sorted = nonOriginalList.sortedByDescending { it.useWeight ?: 0.0 }
sorted.forEachIndexed { index, item ->
item.materialType = if (index < m) 1 else 2
}
}
materialAdapter.notifyDataSetChanged()
}
private fun openSubmitPage() {
@@ -282,31 +332,25 @@ class PrepareFoodActivity : BaseActivity() {
private fun initObserver() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
netViewModel.foodDetailState.collect { state ->
when (state) {
is UiState.Success -> {
// 防止 StateFlow 重放导致 list 被重复清空
if (isDetailLoaded) return@collect
val detail = state.data
if (detail == null) {
toast("查询菜品信息为空")
return@collect
}
isDetailLoaded = true
loadDishDetail(detail)
}
is UiState.Error -> toast(state.msg)
else -> {}
}
}
}
}
}
private fun getDishDetail() {
netViewModel.getFoodDetail(food?.foodId ?: "")
private fun getDishDetail(foodId: String) {
netViewModel.getFoodDetail(foodId)
}
@@ -319,36 +363,48 @@ class PrepareFoodActivity : BaseActivity() {
// 筛选出主材和辅材,转换为 GoodsItemuseWeight 置零)
val tempData = voList.filter { it.materialType == 1 || it.materialType == 2 }
.map { it.toGoodsItem().also { item -> item.useWeight = 0.0 } }
// 直接替换 adapter.items,确保 adapter 数据源被完全替换
// 同时同步外部 list,保证后续 updateMaterialTypes/removeFood 等操作正确
list.clear()
firstReqSize = list.size
list.addAll(tempData)
materialAdapter.items = list
materialAdapter.notifyDataSetChanged()
firstReqSize = list.size
}
private var firstReqSize = 0
private var isDetailLoaded = false
private val list = mutableListOf<GoodsItem>()
private val materialAdapter by lazy {
FoodMaterialAdapter(list = list).apply {
val adapter = this
FoodMaterialAdapter(list).apply {
onItemClick = { positon ->
list.forEachIndexed { index, entity ->
entity.isItemClicked = index == positon
}
notifyDataSetChanged()
}
addOnItemChildClickListener(R.id.ivClearIcon) { _, _, positon ->
Log.d(TAG, "onFoodItemClick: ${list[positon].toJsonString()}")
if (list[positon].isOriginalData) {
list[positon].run {
addOnItemChildClickListener(R.id.ivClearIcon) { _, _, position ->
Log.d(TAG, "onFoodItemClick: ${list[position].toJsonString()}")
val item = list[position]
if (item.isOriginalData) {
// 无重量信息时转为 item 整体点击(触发选中效果)
if (item.useWeight == null || item.useWeight == 0.0) {
onItemClick?.invoke(position)
return@addOnItemChildClickListener
}
deleteRemindDialog("清除确认", "确定清除食材「${item.goodsName}」添加的重量吗?") {
item.run {
isNewDishType = false
useWeight = 0.0
isSetFinished = false
}
notifyItemChanged(positon)
notifyItemChanged(position)
}
return@addOnItemChildClickListener
}
adapter.removeAt(positon)
deleteRemindDialog("删除确认", "确定删除食材「${item.goodsName}」吗?") {
removeFood(position)
}
}
}
}
@@ -356,37 +412,55 @@ class PrepareFoodActivity : BaseActivity() {
/**
* RecyclerView初始化
*/
@SuppressLint("ClickableViewAccessibility")
private fun initRecyclerView() {
binding.rvMaterialList.run {
layoutManager =
LinearLayoutManager(this@PrepareFoodActivity, LinearLayoutManager.VERTICAL, false)
binding.rvMaterialList.let {
it.layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
// 仅 isNewDishType=true 的 item 显示侧滑删除菜单(必须在 setAdapter 之前调用)
setSwipeMenuCreator { _, rightMenu, position ->
it.setSwipeMenuCreator { _, rightMenu, position ->
if (list.getOrNull(position)?.isNewDishType == true) {
rightMenu.addMenuItem(buildDeleteMenuItem())
}
}
// 点击侧滑菜单项:先关闭菜单,再弹窗确认删除
setOnItemMenuClickListener { menuBridge, position ->
it.setOnItemMenuClickListener { menuBridge, position ->
menuBridge.closeMenu()
val item = list.getOrNull(position) ?: return@setOnItemMenuClickListener
CommonDialog(this@PrepareFoodActivity)
.setTitle("删除确认")
.setContent("确定删除食材「${item.goodsName}」吗?")
.setNegativeButton("取消")
.setPositiveButton("删除") {
materialAdapter.removeAt(position)
toast("已删除")
}.show()
deleteRemindDialog("删除确认", "确定删除食材「${item.goodsName}」吗?") {
removeFood(position)
}
}
// item 点击事件
setOnItemClickListener { _, position ->
it.setOnItemClickListener { _, position ->
materialAdapter.onItemClick?.invoke(position)
}
adapter = materialAdapter
handleRvTouchHideKeyboard()
it.adapter = materialAdapter
}
}
/**
* 删除提醒
*/
private fun deleteRemindDialog(title: String, content: String, action: () -> Unit) {
CommonDialog(this@PrepareFoodActivity)
.setTitle(title)
.setContent(content)
.setNegativeButton("取消")
.setPositiveButton("确认") {
action()
}.show()
}
/**
* 移除菜品
*/
private fun removeFood(position: Int) {
materialAdapter.removeAt(position)
toast("已删除")
updateMaterialTypes()
}
/**
* 构建侧滑删除菜单项
*/
@@ -526,7 +600,10 @@ class PrepareFoodActivity : BaseActivity() {
currentWeight = currentWeight,
nameScoreList = ArrayList(nameScoreList),
finishCallback = { isManualCancel, entity ->
Log.d(TAG, "queryFood: isManualCancel=$isManualCancel, entity=${entity.toJsonString()}")
Log.d(
TAG,
"queryFood: isManualCancel=$isManualCancel, entity=${entity.toJsonString()}"
)
manualCancelFlag = isManualCancel
if (isManualCancel) {
return@start
@@ -542,24 +619,67 @@ class PrepareFoodActivity : BaseActivity() {
/**
* 跳转食物搜索页(采集模式专用)
* @param v 触发搜索的 View,用于收起键盘
*/
private fun jumpSearch(v: View) {
val intent = Intent(this, FoodSearchActivity::class.java).apply {
putExtra(FoodSearchActivity.FOOD_NAME, binding.etInputDish.text.toString().trim())
private fun jumpSearch() {
val searchContent = binding.etInputDish.text.toString().trim()
if (searchContent.isBlank()) {
toast(binding.etInputDish.hint.toString())
return
}
startActivity(intent) {
foodId = it?.getStringExtra(FoodSearchActivity.FOOD_ID)
val name = it?.getStringExtra(FoodSearchActivity.FOOD_NAME)
Log.d(TAG, "jumpSearch: foodId=$foodId, name=$name")
if (name.isNullOrBlank()) return@startActivity
val launchIntent = Intent(this, FoodSearchActivity::class.java).apply {
putExtra(FoodSearchActivity.FOOD_NAME, searchContent)
}
startActivity(launchIntent) { resultIntent ->
if (resultIntent == null) return@startActivity
food = IntentCompat.getSerializableExtra(resultIntent, FOOD_ITEM, FoodRecord::class.java)
Log.d(TAG, "jumpSearch: record=$food")
if (food == null) return@startActivity
// 将搜索结果写入 food,保留 cookMode
food!!.cookMode = 1
binding.etInputDish.run {
setText(name)
tag = name
setText(food!!.foodName)
tag = food!!.foodName
setSelection(text.length)
}
// 有 foodId 时请求菜品构成
if (!food!!.foodId.isNullOrBlank()) {
getDishDetail(food!!.foodId!!)
}
KeyboardUtil.hideKeyboard(v)
}
hideKeyboard()
}
private fun handleRvTouchHideKeyboard() {
val gestureDetector = GestureDetector(this, object : GestureDetector.SimpleOnGestureListener() {
override fun onSingleTapUp(e: MotionEvent): Boolean {
hideKeyboard()
// 返回 false,不消费事件,不影响 item 点击和滚动
return false
}
})
binding.rvMaterialList.addOnItemTouchListener(object : RecyclerView.SimpleOnItemTouchListener() {
private var startX = 0f
private var startY = 0f
override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean {
when (e.action) {
MotionEvent.ACTION_DOWN -> {
startX = e.x
startY = e.y
}
MotionEvent.ACTION_MOVE -> {
val dx = abs(e.x - startX)
val dy = abs(e.y - startY)
// 水平滑动超过阈值时(侧滑手势),关闭键盘
if (dx > dy && dx > 10) {
hideKeyboard()
}
}
}
gestureDetector.onTouchEvent(e)
return false
}
})
}
}
@@ -186,7 +186,7 @@ class SamplingModeActivity : BaseActivity() {
return
}
startActivity<PrepareFoodActivity> {
putExtra(PrepareFoodActivity.FOOD_NAME, item.foodName)
putExtra(PrepareFoodActivity.FOOD_ITEM, item as Serializable)
}
}
@@ -12,10 +12,11 @@ import androidx.core.content.ContextCompat
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.databinding.ActivitySettingBinding
import com.shuwei.dish.match.dialog.CommonDialog
import com.shuwei.dish.match.scale.ScaleServiceManager
import com.shuwei.dish.match.utils.SpTool
import com.shuwei.dish.match.utils.ext.clickWithDebounce
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.startActivity
import com.shuwei.dish.match.utils.ext.toast
import com.shuwei.dish.match.utils.ext.visible
/**
@@ -46,7 +47,9 @@ class SettingActivity : BaseActivity() {
}, rightIconActon = {
it.visible()
it.clickWithDebounce {
startActivity<MasterScaleActivity>()
binding.llDbInspect.visible()
binding.llScaleObserve.visible()
binding.llClearData.visible()
}
})
@@ -79,6 +82,34 @@ class SettingActivity : BaseActivity() {
binding.llDbInspect.clickWithDebounce {
SingleFragmentActivity.start(this, SingleFragmentActivity.PageType.DB_INSPECT)
}
binding.llScaleObserve.clickWithDebounce {
startActivity<MasterScaleActivity>()
}
binding.llClearData.clickWithDebounce {
showClearDataDialog()
}
}
/**
* 弹出清除测试数据的二次确认弹窗
*/
private fun showClearDataDialog() {
CommonDialog(this)
.setTitle("数据清除")
.setContent("将物理删除全部 4 张表数据(菜品、食材、调料、槽位),此操作不可恢复,同时重置菜品模式和食堂id,确认继续?")
.setNegativeButton("取消")
.setPositiveButton("确认清除") {
appViewModel.clearAllData {
SpTool.cookMode = -1
SpTool.canteenId = "0"
// 通知所有已连接子设备同步清除数据
ScaleServiceManager.sendClearData()
startActivity<HomeActivity> {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
}
}
.show()
}
private fun openCollectPage() {
@@ -18,7 +18,6 @@ import com.shuwei.dish.match.adapter.Seasoning18GridAdapter
import com.shuwei.dish.match.adapter.Seasoning22GridAdapter
import com.shuwei.dish.match.adapter.ScaleRowAdapter
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.base.GlobalData
import com.shuwei.dish.match.databinding.ActivitySlaveBinding
import com.shuwei.dish.match.dialog.CommonDialog
@@ -28,6 +27,7 @@ import com.shuwei.dish.match.scale.ScaleDeviceConfig
import com.shuwei.dish.match.scale.ScaleServiceManager
import com.shuwei.dish.match.utils.NetworkUtil
import com.shuwei.dish.match.utils.SizeTool
import com.shuwei.dish.match.utils.SpTool
import com.shuwei.dish.match.utils.WeightUtil
import com.shuwei.dish.match.utils.ext.dp
import kotlinx.coroutines.launch
@@ -77,6 +77,7 @@ class SlaveActivity : BaseActivity() {
listenLocalScales()
listenMasterConnection()
listenSeasoningConfig()
listenClearData()
addBackKeyListener()
}
@@ -109,6 +110,8 @@ class SlaveActivity : BaseActivity() {
it.onItemClick = { scale, _ -> showTareDialog(scale.address) }
}
binding.rvScaleList.adapter = scale22Adapter
// 启动时用空哨兵预填全部格子,不依赖 WeightUtil 回调才显示结构
scale22Adapter!!.updateByAddress(emptyList())
}
ScaleDeviceConfig.DEVICE_ID_18 -> {
binding.rvScaleList.updateLayoutParams<LinearLayout.LayoutParams> { topMargin = 0 }
@@ -121,12 +124,18 @@ class SlaveActivity : BaseActivity() {
it.onItemClick = { scale, _ -> showTareDialog(scale.address) }
}
binding.rvScaleList.adapter = scale18Adapter
// 启动时用空哨兵预填全部格子,不依赖 WeightUtil 回调才显示结构
scale18Adapter!!.updateByAddress(emptyList())
}
else -> {
binding.rvScaleList.updateLayoutParams<LinearLayout.LayoutParams> { topMargin = 20.dp }
binding.rvScaleList.layoutManager = LinearLayoutManager(this)
linearAdapter.onItemClick = { item -> showTareDialog(item.address) }
binding.rvScaleList.adapter = linearAdapter
// 启动时预填空占位,不依赖 WeightUtil 回调才显示列表结构
ScaleDeviceConfig.SCALE_ORDER_1.forEach { addr ->
linearAdapter.updateItem(ScaleRowAdapter.ScaleItem(addr, 0.0, WeightUtil.STATE_STABLE))
}
}
}
}
@@ -177,6 +186,23 @@ class SlaveActivity : BaseActivity() {
}
}
/**
* 监听主设备下发的清除数据事件
* 收到后清空本机全部 4 张表数据并重置 SP 状态,与主设备保持一致
* 清除完成后刷新 UI,清空所有秤的调料名称显示
*/
private fun listenClearData() {
ScaleServiceManager.onClearData = {
lifecycleScope.launch {
appViewModel.clearAllData {
SpTool.cookMode = -1
SpTool.canteenId = "0"
runOnUiThread { applySlots(emptyList()) }
}
}
}
}
/**
* 将槽位配置应用到对应 adapter(按地址匹配格子位置)
* 同时以传入列表为准全量刷新 slotNameMap
@@ -78,7 +78,7 @@ class FoodListFragment : BaseFragment<FragmentFoodListBinding>() {
}
item.dinnerType = dinnerType
activity.startActivity<PrepareFoodActivity> {
putExtra(SubmitFoodActivity.FOOD_ITEM, item as Serializable)
putExtra(PrepareFoodActivity.FOOD_ITEM, item as Serializable)
}
}
@@ -13,6 +13,8 @@ object SpTool {
const val DEVICE_ROLE = "deviceRole"
/** 秤串口路径,默认 /dev/ttyS7,子设备可能需要配置为其他值 */
const val SCALE_DEVICE_PORT = "scaleDevicePort"
/** 食堂 ID,默认 "0" */
const val CANTEEN_ID = "canteenId"
val pref = BaseApp.getSharedPref()!!
@@ -37,4 +39,11 @@ object SpTool {
set(value) {
put(LAUNCH_PAGE_TYPE, value)
}
/** 食堂 ID,持久化存储,默认 "0" */
var canteenId: String
get() = getString(CANTEEN_ID, "0")
set(value) {
put(CANTEEN_ID, value)
}
}
@@ -8,6 +8,8 @@ package com.shuwei.dish.match.utils;
import android.os.Build;
import android.util.Log;
import com.aithings.Weigher;
import com.blankj.utilcode.util.Utils;
import com.t507.System;
import com.wabon.wbintelligenthardwaresdk.api.SensorScale;
@@ -73,6 +75,7 @@ public class Weigher2 {
public static void unInit() {
// $FF: Couldn't be decompiled
Weigher.unInit();
}
public static void config() {
+94 -29
View File
@@ -1,42 +1,42 @@
<?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="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:background="@color/bg_color"
android:orientation="vertical">
tools:ignore="HardcodedText,UnusedAttribute">
<LinearLayout
android:id="@+id/llCookMode"
android:layout_width="match_parent"
android:layout_height="120dp"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginTop="30dp"
android:layout_marginHorizontal="32dp"
android:layout_marginTop="30dp"
android:background="@drawable/shape_white_dc_15_corners"
android:clipToOutline="true"
android:foreground="?android:attr/selectableItemBackground"
android:clipToOutline="true">
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_weight="1"
android:text="菜品模式"
android:textColor="@color/black333"
android:textSize="32sp"
android:textStyle="bold"
android:layout_marginStart="32dp"
android:textColor="@color/black333" />
android:textStyle="bold" />
<TextView
android:id="@+id/tvCurrentMode"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="当前:制作模式"
android:textSize="26sp"
android:layout_marginStart="32dp"
android:textColor="@color/black999" />
android:textColor="@color/black999"
android:textSize="26sp"
tools:text="当前:制作模式" />
<ImageView
android:layout_width="40dp"
@@ -51,23 +51,23 @@
android:id="@+id/llSeasoningConfig"
android:layout_width="match_parent"
android:layout_height="120dp"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginHorizontal="32dp"
android:layout_marginTop="50dp"
android:background="@drawable/shape_white_dc_15_corners"
android:clipToOutline="true"
android:foreground="?android:attr/selectableItemBackground"
android:clipToOutline="true">
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_weight="1"
android:text="调料区设置"
android:textColor="@color/black333"
android:textSize="32sp"
android:textStyle="bold"
android:layout_marginStart="32dp"
android:textColor="@color/black333" />
android:textStyle="bold" />
<ImageView
android:layout_width="40dp"
@@ -82,23 +82,23 @@
android:id="@+id/llFoodCollect"
android:layout_width="match_parent"
android:layout_height="120dp"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginHorizontal="32dp"
android:layout_marginTop="50dp"
android:background="@drawable/shape_white_dc_15_corners"
android:clipToOutline="true"
android:foreground="?android:attr/selectableItemBackground"
android:clipToOutline="true">
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_weight="1"
android:text="食材采集"
android:textColor="@color/black333"
android:textSize="32sp"
android:textStyle="bold"
android:layout_marginStart="32dp"
android:textColor="@color/black333" />
android:textStyle="bold" />
<ImageView
android:layout_width="40dp"
@@ -113,23 +113,88 @@
android:id="@+id/llDbInspect"
android:layout_width="match_parent"
android:layout_height="120dp"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginHorizontal="32dp"
android:layout_marginTop="50dp"
android:background="@drawable/shape_white_dc_15_corners"
android:clipToOutline="true"
android:foreground="?android:attr/selectableItemBackground"
android:clipToOutline="true">
android:gravity="center_vertical"
android:orientation="horizontal"
android:visibility="gone">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_weight="1"
android:text="数据库查看"
android:textColor="@color/black333"
android:textSize="32sp"
android:textStyle="bold"
android:textStyle="bold" />
<ImageView
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginEnd="20dp"
android:adjustViewBounds="true"
android:src="@drawable/ic_arrow_right3"
tools:ignore="ContentDescription" />
</LinearLayout>
<LinearLayout
android:id="@+id/llScaleObserve"
android:layout_width="match_parent"
android:layout_height="120dp"
android:layout_marginHorizontal="32dp"
android:layout_marginTop="50dp"
android:background="@drawable/shape_white_dc_15_corners"
android:clipToOutline="true"
android:foreground="?android:attr/selectableItemBackground"
android:gravity="center_vertical"
android:orientation="horizontal"
android:visibility="gone">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:textColor="@color/black333" />
android:layout_weight="1"
android:text="秤数据监控"
android:textColor="@color/black333"
android:textSize="32sp"
android:textStyle="bold" />
<ImageView
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginEnd="20dp"
android:adjustViewBounds="true"
android:src="@drawable/ic_arrow_right3"
tools:ignore="ContentDescription" />
</LinearLayout>
<LinearLayout
android:id="@+id/llClearData"
android:layout_width="match_parent"
android:layout_height="120dp"
android:layout_marginHorizontal="32dp"
android:layout_marginTop="50dp"
android:background="@drawable/shape_white_dc_15_corners"
android:clipToOutline="true"
android:foreground="?android:attr/selectableItemBackground"
android:gravity="center_vertical"
android:orientation="horizontal"
android:visibility="gone">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_weight="1"
android:text="数据清除"
android:textColor="@color/black333"
android:textSize="32sp"
android:textStyle="bold" />
<ImageView
android:layout_width="40dp"
@@ -5,7 +5,8 @@
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingHorizontal="5dp"
tools:background="@color/bg_color">
tools:background="@color/bg_color"
tools:ignore="HardcodedText,UnusedAttribute">
<FrameLayout
android:layout_width="match_parent"
@@ -4,7 +4,8 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
android:orientation="vertical"
tools:ignore="HardcodedText,UnusedAttribute">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
@@ -1,14 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 单个秤格子:秤地址 + 调料名 + 重量 + 状态 -->
<?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:background="@drawable/shape_scale_cell"
android:clipToOutline="true"
android:foreground="?android:attr/selectableItemBackground"
android:gravity="center"
android:orientation="vertical"
android:padding="4dp"
android:foreground="?android:attr/selectableItemBackground"
android:background="@drawable/shape_scale_cell"
android:clipToOutline="true">
tools:ignore="HardcodedText,UnusedAttribute">
<TextView
android:id="@+id/tvAddress"
@@ -26,7 +27,7 @@
android:gravity="center"
android:includeFontPadding="false"
android:textColor="@color/black"
android:textStyle="bold"/>
android:textStyle="bold" />
<TextView
android:id="@+id/tvWeight"
@@ -34,7 +35,7 @@
android:layout_height="wrap_content"
android:gravity="center"
android:includeFontPadding="false"
android:textColor="@color/home_title"/>
android:textColor="@color/home_title" />
<TextView
android:id="@+id/tvState"
@@ -3,15 +3,16 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="340dp"
android:foreground="?android:attr/selectableItemBackground"
android:background="@drawable/shape_scale_cell"
android:clipToOutline="true"
android:foreground="?android:attr/selectableItemBackground"
android:gravity="center"
android:orientation="vertical"
android:paddingStart="16dp"
android:paddingTop="8dp"
android:paddingEnd="16dp"
android:paddingBottom="8dp"
android:clipToOutline="true">
tools:ignore="HardcodedText,UnusedAttribute">
<!-- 秤编号 -->
<TextView
@@ -26,10 +27,10 @@
android:id="@+id/tvName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="-"
android:textColor="@color/black"
android:textSize="26sp"
android:textStyle="bold"
android:text="-" />
android:textStyle="bold" />
<!-- 重量 -->
<TextView
+20 -20
View File
@@ -1,20 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 1个秤设备的卡片布局 -->
<?xml version="1.0" encoding="utf-8"?><!-- 1个秤设备的卡片布局 -->
<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:paddingTop="10dp">
android:paddingTop="10dp"
tools:ignore="HardcodedText,UnusedAttribute">
<TextView
android:id="@+id/tvDeviceIp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="20sp"
android:textStyle="bold"
android:includeFontPadding="false"
android:textColor="@color/home_title"
android:textSize="20sp"
android:textStyle="bold"
tools:text="IP192.168.1.100" />
<TextView
@@ -30,58 +30,58 @@
android:id="@+id/llScaleContainer"
android:layout_width="match_parent"
android:layout_height="200dp"
android:orientation="vertical"
android:gravity="center"
android:layout_marginTop="5dp"
android:layout_marginBottom="2dp"
android:background="@drawable/shape_scale_cell"
android:clipToOutline="true"
android:foreground="?android:attr/selectableItemBackground"
android:background="@drawable/shape_scale_cell">
android:gravity="center"
android:orientation="vertical">
<TextView
android:id="@+id/tvAddr"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="18sp"
android:includeFontPadding="false"
android:textColor="@color/home_sub_title"
android:textSize="18sp"
tools:text="秤 1" />
<TextView
android:id="@+id/tvName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="26sp"
android:textStyle="bold"
android:includeFontPadding="false"
android:text="-"
android:textColor="@color/black"
android:text="-" />
android:textSize="26sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvWeight"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="22sp"
android:includeFontPadding="false"
android:layout_marginVertical="5dp"
android:includeFontPadding="false"
android:textColor="@color/home_title"
android:textSize="22sp"
tools:text="100.0 g" />
<TextView
android:id="@+id/tvState"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="18sp"
android:includeFontPadding="false"
android:textColor="@color/home_sub_title"
android:textSize="18sp"
tools:text="稳定" />
</LinearLayout>
<!-- <com.google.android.material.divider.MaterialDivider-->
<!-- android:layout_width="match_parent"-->
<!-- android:layout_height="1dp"-->
<!-- android:background="@color/gray_eb"-->
<!-- android:layout_marginTop="10dp" />-->
<!-- <com.google.android.material.divider.MaterialDivider-->
<!-- android:layout_width="match_parent"-->
<!-- android:layout_height="1dp"-->
<!-- android:background="@color/gray_eb"-->
<!-- android:layout_marginTop="10dp" />-->
</LinearLayout>
+14 -2
View File
@@ -63,6 +63,7 @@ scale 包负责多设备秤数据的采集、发现、传输与聚合,采用 *
|---|---|---|
| `seasoning_added` | 子设备 → 主设备 | 某秤检测到调料添加,携带 `delta`(重量变化量) |
| `seasoning_config` | 主设备 → 子设备 | 调料槽位配置同步,携带 `List<SlotConfig>` |
| `clear_data` | 主设备 → 子设备 | 通知所有子设备清除本机全部测试数据并重置 SP 状态 |
### ScaleCommand
主设备向子设备发送的**控制指令**,子设备收到后校验 `deviceId` 是否匹配自身再执行。
@@ -112,9 +113,10 @@ scale 包负责多设备秤数据的采集、发现、传输与聚合,采用 *
**职责:**
- 监听本机 `WeightUtil` 回调,将秤数据实时推送给所有已连接客户端(节流 100ms)
- 新客户端连接时,立即推送所有秤的最新快照(`latestData` 缓存)
- 接收主设备下发的 `ScaleCommand`(清零)和 `ScaleEvent`(配置同步)
- 新客户端连接时,立即推送所有秤的最新快照(`latestData` 缓存);若 `latestData` 为空(刚重启尚无读数),延迟 3 秒后补推一次
- 接收主设备下发的 `ScaleCommand`(清零)和 `ScaleEvent`(配置同步、清除数据
- 通过 `connectionCount` 计数器跟踪连接数,避免 `connections` 集合竞态问题
- 内置单线程 `scheduler`,用于延迟补推任务调度;`stop()` 时同步关闭
### ScaleWebSocketClient(仅主设备运行)
管理主设备与多台子设备的 WebSocket 长连接。
@@ -125,6 +127,10 @@ scale 包负责多设备秤数据的采集、发现、传输与聚合,采用 *
- 向指定设备或全部设备发送指令/事件
- 子设备首次连接成功时触发 `onDeviceConnected`,供主设备推送全量配置
**并发安全机制:**
- **版本号防重复连接**:每次调用 `connect()` 时递增 `connectVersions[deviceId]``doConnect` 执行前校验版本号,版本不匹配(说明已有更新的连接请求)则直接放弃,避免 UDP 触发的新连接与指数退避重连任务并发建立两条连接
- **原子移除防误删**`onFailure`/`onClosed` 使用 `connections.remove(deviceId, webSocket)` 原子操作,只有移除的是自己的实例时才触发 `onDeviceDisconnected` 和重连,避免旧连接超时回调误删新连接引用
---
## 数据聚合(1个)
@@ -172,12 +178,18 @@ ScaleServiceManager.sendTare(deviceId, address)
// 主设备广播调料配置
ScaleServiceManager.sendSeasoningConfig(slots)
// 主设备通知所有子设备清除数据
ScaleServiceManager.sendClearData()
// 主设备监听子设备秤事件(如调料添加)
ScaleServiceManager.onScaleEvent = { event -> ... }
// 子设备监听主设备下发的调料配置同步
ScaleServiceManager.onSeasoningConfig = { event -> ... }
// 子设备监听主设备下发的清除数据指令
ScaleServiceManager.onClearData = { ... }
// 子设备向主设备广播秤事件
ScaleServiceManager.broadcastEvent(event)