diff --git a/app/src/main/java/com/shuwei/dish/match/adapter/Seasoning18GridAdapter.kt b/app/src/main/java/com/shuwei/dish/match/adapter/Seasoning18GridAdapter.kt index 2b4bf8d..f7d94cb 100644 --- a/app/src/main/java/com/shuwei/dish/match/adapter/Seasoning18GridAdapter.kt +++ b/app/src/main/java/com/shuwei/dish/match/adapter/Seasoning18GridAdapter.kt @@ -3,9 +3,11 @@ package com.shuwei.dish.match.adapter import android.content.Context import android.view.LayoutInflater import android.view.ViewGroup +import androidx.core.content.ContextCompat import androidx.recyclerview.widget.RecyclerView import com.chad.library.adapter4.BaseQuickAdapter import com.chad.library.adapter4.viewholder.QuickViewHolder +import com.shuwei.dish.match.R import com.shuwei.dish.match.databinding.ListItemSeasoningCellBinding import com.shuwei.dish.match.entity.SeasoningEntity import com.shuwei.dish.match.scale.ScaleDeviceConfig @@ -21,6 +23,9 @@ class Seasoning18GridAdapter : BaseQuickAdapter Unit)? = null @@ -57,6 +62,32 @@ class Seasoning18GridAdapter : BaseQuickAdapter= 0) notifyItemChanged(prev) + if (position >= 0) notifyItemChanged(position) + } + + /** + * 清除所有高亮,恢复默认样式 + */ + fun clearHighlight() { + highlightPosition(-1) } /** diff --git a/app/src/main/java/com/shuwei/dish/match/adapter/Seasoning22GridAdapter.kt b/app/src/main/java/com/shuwei/dish/match/adapter/Seasoning22GridAdapter.kt index 30c7a04..a089296 100644 --- a/app/src/main/java/com/shuwei/dish/match/adapter/Seasoning22GridAdapter.kt +++ b/app/src/main/java/com/shuwei/dish/match/adapter/Seasoning22GridAdapter.kt @@ -3,10 +3,12 @@ package com.shuwei.dish.match.adapter import android.content.Context import android.view.LayoutInflater import android.view.ViewGroup +import androidx.core.content.ContextCompat import androidx.recyclerview.widget.RecyclerView import com.chad.library.adapter4.BaseQuickAdapter import com.chad.library.adapter4.viewholder.QuickViewHolder import com.google.android.flexbox.FlexboxLayoutManager +import com.shuwei.dish.match.R import com.shuwei.dish.match.databinding.ListItemSeasoningCellBinding import com.shuwei.dish.match.entity.SeasoningEntity import com.shuwei.dish.match.scale.ScaleDeviceConfig @@ -28,6 +30,9 @@ class Seasoning22GridAdapter( /** 空位哨兵,sort=-1 表示无效 */ private val empty = SeasoningEntity(goodsId = "").also { it.sort = -1 } + /** 当前高亮的格子位置,-1 表示无高亮 */ + private var highlightedPosition: Int = -1 + /** 点击回调,预留,暂不使用 */ var onItemClick: ((SeasoningEntity, Int) -> Unit)? = null @@ -60,6 +65,32 @@ class Seasoning22GridAdapter( override fun onBindViewHolder(holder: VH, position: Int, item: SeasoningEntity?) { holder.b.tvGoodsName.text = item?.goodsName?.takeIf { it.isNotBlank() } ?: "-" + // 高亮格子:红底白字;其余恢复默认 + if (position == highlightedPosition) { + holder.b.tvGoodsName.setBackgroundColor(ContextCompat.getColor(holder.b.root.context, R.color.red_ff4444)) + holder.b.tvGoodsName.setTextColor(ContextCompat.getColor(holder.b.root.context, R.color.white)) + } else { + holder.b.tvGoodsName.setBackgroundResource(R.drawable.shape_scale_cell) + holder.b.tvGoodsName.setTextColor(ContextCompat.getColor(holder.b.root.context, R.color.home_title)) + } + } + + /** + * 高亮指定位置的格子(红底白字),同时清除上一个高亮 + * @param position 要高亮的格子索引 + */ + fun highlightPosition(position: Int) { + val prev = highlightedPosition + highlightedPosition = position + if (prev >= 0) notifyItemChanged(prev) + if (position >= 0) notifyItemChanged(position) + } + + /** + * 清除所有高亮,恢复默认样式 + */ + fun clearHighlight() { + highlightPosition(-1) } /** diff --git a/app/src/main/java/com/shuwei/dish/match/scale/ScaleEvent.kt b/app/src/main/java/com/shuwei/dish/match/scale/ScaleEvent.kt new file mode 100644 index 0000000..99ebdcd --- /dev/null +++ b/app/src/main/java/com/shuwei/dish/match/scale/ScaleEvent.kt @@ -0,0 +1,20 @@ +package com.shuwei.dish.match.scale + +/** + * 秤事件数据类,用于子设备向主设备发送非重量类通知 + * @param type 事件类型,参见 companion object 中的常量 + * @param deviceId 发送方设备 ID + * @param address 触发事件的秤地址 + * @param delta 重量变化量(克),仅 TYPE_SEASONING_ADDED 时有意义 + */ +data class ScaleEvent( + val type: String, + val deviceId: String, + val address: Int, + val delta: Double = 0.0 +) { + companion object { + /** 调料添加事件:某秤重量增加超过阈值 */ + const val TYPE_SEASONING_ADDED = "seasoning_added" + } +} diff --git a/app/src/main/java/com/shuwei/dish/match/scale/ScaleServiceManager.kt b/app/src/main/java/com/shuwei/dish/match/scale/ScaleServiceManager.kt index f687256..7301785 100644 --- a/app/src/main/java/com/shuwei/dish/match/scale/ScaleServiceManager.kt +++ b/app/src/main/java/com/shuwei/dish/match/scale/ScaleServiceManager.kt @@ -46,6 +46,26 @@ object ScaleServiceManager { val allScales: StateFlow>? get() = aggregator?.allScales + /** + * 收到子设备秤事件(如调料添加)时的回调,仅主设备有效 + * 在子线程中调用,需自行切换到主线程更新 UI + */ + private var _onScaleEvent: ((ScaleEvent) -> Unit)? = null + var onScaleEvent: ((ScaleEvent) -> Unit)? + get() = _onScaleEvent + set(value) { + _onScaleEvent = value + wsClient?.onScaleEvent = value + } + + /** + * 向主设备广播秤事件(子设备调用) + * @param event 要广播的事件 + */ + fun broadcastEvent(event: ScaleEvent) { + wsServer?.broadcastEvent(event) + } + /** * 主设备连接状态回调(仅子设备使用) * 设置后会立即转发给 wsServer;需在 start() 之后设置才能生效 @@ -100,6 +120,7 @@ object ScaleServiceManager { // WebSocket 客户端 val client = ScaleWebSocketClient().also { it.onScaleData = { data -> aggregator?.onRemoteScaleData(data) } + it.onScaleEvent = { event -> _onScaleEvent?.invoke(event) } it.onDeviceDisconnected = { remoteId -> aggregator?.removeDevice(remoteId) } } wsClient = client diff --git a/app/src/main/java/com/shuwei/dish/match/scale/ScaleWebSocketClient.kt b/app/src/main/java/com/shuwei/dish/match/scale/ScaleWebSocketClient.kt index 415720e..344e28c 100644 --- a/app/src/main/java/com/shuwei/dish/match/scale/ScaleWebSocketClient.kt +++ b/app/src/main/java/com/shuwei/dish/match/scale/ScaleWebSocketClient.kt @@ -49,6 +49,9 @@ class ScaleWebSocketClient { /** 收到秤数据时的回调,在子线程调用 */ var onScaleData: ((data: ScaleData) -> Unit)? = null + /** 收到秤事件(如调料添加通知)时的回调,在子线程调用 */ + var onScaleEvent: ((event: ScaleEvent) -> Unit)? = null + /** 设备断线时的回调(连接失败或关闭),在子线程调用 */ var onDeviceDisconnected: ((deviceId: String) -> Unit)? = null @@ -80,10 +83,17 @@ class ScaleWebSocketClient { override fun onMessage(webSocket: WebSocket, text: String) { try { - val data = gson.fromJson(text, ScaleData::class.java) - onScaleData?.invoke(data) + // 通过 type 字段区分 ScaleEvent 与 ScaleData + val raw = gson.fromJson(text, Map::class.java) + if (raw.containsKey("type")) { + val event = gson.fromJson(text, ScaleEvent::class.java) + onScaleEvent?.invoke(event) + } else { + val data = gson.fromJson(text, ScaleData::class.java) + onScaleData?.invoke(data) + } } catch (e: Exception) { - Log.w(TAG, "解析秤数据失败: ${e.message}") + Log.w(TAG, "解析消息失败: ${e.message}") } } diff --git a/app/src/main/java/com/shuwei/dish/match/scale/ScaleWebSocketServer.kt b/app/src/main/java/com/shuwei/dish/match/scale/ScaleWebSocketServer.kt index f8d8bef..e94ad3b 100644 --- a/app/src/main/java/com/shuwei/dish/match/scale/ScaleWebSocketServer.kt +++ b/app/src/main/java/com/shuwei/dish/match/scale/ScaleWebSocketServer.kt @@ -83,6 +83,14 @@ class ScaleWebSocketServer(private val deviceId: String, private val context: Co Log.d(TAG, "WebSocket 服务端已停止") } + /** + * 向所有已连接的主设备广播秤事件(如调料添加通知) + * @param event 要广播的事件 + */ + fun broadcastEvent(event: ScaleEvent) { + broadcast(gson.toJson(event)) + } + /** * 向所有已连接客户端广播消息 */ diff --git a/app/src/main/java/com/shuwei/dish/match/ui/fragment/SeasoningConfigFragment.kt b/app/src/main/java/com/shuwei/dish/match/ui/fragment/SeasoningConfigFragment.kt index e87d8dd..ff114c3 100644 --- a/app/src/main/java/com/shuwei/dish/match/ui/fragment/SeasoningConfigFragment.kt +++ b/app/src/main/java/com/shuwei/dish/match/ui/fragment/SeasoningConfigFragment.kt @@ -1,7 +1,11 @@ package com.shuwei.dish.match.ui.fragment +import android.os.Handler +import android.os.Looper +import android.util.Log import android.view.LayoutInflater import android.view.ViewGroup +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.GridLayoutManager import com.google.android.flexbox.AlignItems import com.google.android.flexbox.FlexDirection @@ -10,13 +14,28 @@ import com.google.android.flexbox.FlexboxLayoutManager import com.shuwei.dish.match.adapter.Seasoning18GridAdapter import com.shuwei.dish.match.adapter.Seasoning22GridAdapter import com.shuwei.dish.match.base.BaseFragment +import com.shuwei.dish.match.base.DeviceRole +import com.shuwei.dish.match.base.GlobalData import com.shuwei.dish.match.databinding.FragmentSeasoningConfigBinding import com.shuwei.dish.match.dialog.SeasoningSelectDialog +import com.shuwei.dish.match.scale.ScaleDeviceConfig +import com.shuwei.dish.match.scale.ScaleEvent +import com.shuwei.dish.match.scale.ScaleServiceManager import com.shuwei.dish.match.ui.SettingActivity +import com.shuwei.dish.match.utils.WeightUtil import com.shuwei.dish.match.utils.ext.dp +import kotlinx.coroutines.launch class SeasoningConfigFragment : BaseFragment() { + companion object { + private const val TAG = "SeasoningConfigFragment" + /** 重量增加阈值(克),超过此值才触发高亮 */ + private const val WEIGHT_DELTA_THRESHOLD = 10.0 + /** 高亮自动恢复时间(毫秒) */ + private const val HIGHLIGHT_DURATION_MS = 15_000L + } + override fun inflateBinding( inflater: LayoutInflater, container: ViewGroup? @@ -28,11 +47,19 @@ class SeasoningConfigFragment : BaseFragment() { private val adapter22 by lazy { buildAdapter22() } private val adapter18 by lazy { Seasoning18GridAdapter() } + /** 各秤地址的上一次重量快照,用于计算 delta */ + private val weightSnapshot = mutableMapOf() + /** 主线程 Handler,用于延迟清除高亮 */ + private val mainHandler = Handler(Looper.getMainLooper()) + /** 当前高亮清除任务,新高亮触发时取消旧任务 */ + private var clearHighlightRunnable: Runnable? = null + override fun initialize() { settingActivity = activity as SettingActivity // initViewModel() setupRecyclerViews() bindAdapterClicks() + addWeightListener() loadData() } @@ -108,4 +135,144 @@ class SeasoningConfigFragment : BaseFragment() { // adapter18.update(list ?: emptyList()) // } } + + /** + * 注册重量监听,检测重量增加超过阈值的秤,高亮对应格子并通知主设备 + * + * 策略: + * - MASTER 设备:订阅 ScaleServiceManager.allScales StateFlow,覆盖本机+远端所有秤数据 + * - SLAVE 设备:注册 WeightUtil 监听(仅本机秤)并向主设备广播事件 + * + * 基准更新策略: + * - 首次收到数据:直接记录基准,不触发高亮 + * - delta > 阈值:触发高亮,同时更新基准防止重复触发 + * - STATE_STABLE:无条件更新基准(覆盖取走调料/归零等场景) + */ + private fun addWeightListener() { + Log.d(TAG, "addWeightListener: 注册监听, deviceId=${GlobalData.deviceId}, role=${GlobalData.deviceRole}") + + if (GlobalData.deviceRole == DeviceRole.MASTER) { + // 主设备:通过 allScales StateFlow 观测所有秤(含远端子设备秤) + val allScalesFlow = ScaleServiceManager.allScales + if (allScalesFlow == null) { + Log.w(TAG, "allScales 为 null,主设备聚合器未启动") + return + } + viewLifecycleOwner.lifecycleScope.launch { + allScalesFlow.collect { scaleMap -> + scaleMap.forEach { (key, data) -> + val snapshotKey = data.address + data.deviceId.hashCode() * 1000 + val baseline = weightSnapshot[snapshotKey] + if (baseline == null) { + // 首次收到:记录基准 + weightSnapshot[snapshotKey] = data.weight + return@forEach + } + val delta = data.weight - baseline + Log.d(TAG, "allScales: key=$key address=${data.address} state=${data.state} weight=${data.weight} baseline=$baseline delta=$delta") + when { + // 仅在秤稳定后判断增量,避免取走物品时手部冲力造成误触发 + data.state == WeightUtil.STATE_STABLE && delta > WEIGHT_DELTA_THRESHOLD -> { + weightSnapshot[snapshotKey] = data.weight + Log.d(TAG, "allScales: 触发高亮 key=$key address=${data.address} delta=$delta") + triggerHighlight(data.deviceId, data.address, delta) + } + data.state == WeightUtil.STATE_STABLE -> { + weightSnapshot[snapshotKey] = data.weight + } + } + } + } + } + } else { + // 子设备:注册本机 WeightUtil 监听,触发后通过 broadcastEvent 通知主设备 + WeightUtil.addWeightListener(TAG) { address, state, weight -> + val baseline = weightSnapshot[address] + Log.d(TAG, "onWeight: address=$address state=$state weight=$weight baseline=$baseline") + + if (baseline == null) { + weightSnapshot[address] = weight + return@addWeightListener + } + + val delta = weight - baseline + Log.d(TAG, "onWeight: address=$address state=$state weight=$weight baseline=$baseline delta=$delta") + + when { + // 仅在秤稳定后判断增量,避免取走物品时手部冲力造成误触发 + state == WeightUtil.STATE_STABLE && delta > WEIGHT_DELTA_THRESHOLD -> { + weightSnapshot[address] = weight + Log.d(TAG, "onWeight: 触发高亮 address=$address delta=$delta") + triggerHighlight(GlobalData.deviceId, address, delta) + } + state == WeightUtil.STATE_STABLE -> { + weightSnapshot[address] = weight + } + } + } + } + } + + /** + * 根据来源设备ID和秤地址触发对应格子高亮,15 秒后自动恢复 + * 通过 sourceDeviceId 精确匹配 SCALE_ORDER,解决 18格/22格 地址重叠问题 + * @param sourceDeviceId 产生重量变化的设备 ID + * @param address 秤硬件地址 + * @param delta 重量变化量(克) + */ + private fun triggerHighlight(sourceDeviceId: String, address: Int, delta: Double) { + // 根据来源设备决定查哪个列表,而非查本机设备ID + val pos22 = if (sourceDeviceId != ScaleDeviceConfig.DEVICE_ID_18) + ScaleDeviceConfig.SCALE_ORDER_22.indexOf(address) else -1 + val pos18 = if (sourceDeviceId != ScaleDeviceConfig.DEVICE_ID_22) + ScaleDeviceConfig.SCALE_ORDER_18.indexOf(address) else -1 + + Log.d(TAG, "triggerHighlight: sourceDeviceId=$sourceDeviceId address=$address pos22=$pos22 pos18=$pos18") + + // 两个列表都不包含该地址,忽略 + if (pos22 < 0 && pos18 < 0) { + Log.d(TAG, "triggerHighlight: address=$address 不在任何 SCALE_ORDER 中,忽略") + return + } + + mainHandler.post { + clearHighlightRunnable?.let { mainHandler.removeCallbacks(it) } + + when { + pos22 >= 0 -> { + adapter22.highlightPosition(pos22) + adapter18.clearHighlight() + } + else -> { + adapter18.highlightPosition(pos18) + adapter22.clearHighlight() + } + } + + clearHighlightRunnable = Runnable { + adapter22.clearHighlight() + adapter18.clearHighlight() + }.also { mainHandler.postDelayed(it, HIGHLIGHT_DURATION_MS) } + + // 子设备:通知主设备(携带本机 deviceId 和地址) + if (GlobalData.deviceRole == DeviceRole.SLAVE) { + ScaleServiceManager.broadcastEvent( + ScaleEvent( + type = ScaleEvent.TYPE_SEASONING_ADDED, + deviceId = GlobalData.deviceId, + address = address, + delta = delta + ) + ) + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + // 子设备才注册了 WeightUtil 监听,移除时安全无副作用 + WeightUtil.removeWeightListener(TAG) + ScaleServiceManager.onScaleEvent = null + clearHighlightRunnable?.let { mainHandler.removeCallbacks(it) } + } } diff --git a/app/src/main/res/layout/list_item_scale_row.xml b/app/src/main/res/layout/list_item_scale_row.xml index 7c659b3..be682d7 100644 --- a/app/src/main/res/layout/list_item_scale_row.xml +++ b/app/src/main/res/layout/list_item_scale_row.xml @@ -5,10 +5,12 @@ android:layout_width="match_parent" android:layout_height="120dp" android:orientation="horizontal" + android:gravity="center_vertical" android:paddingStart="16dp" android:paddingEnd="16dp" android:paddingTop="8dp" - android:paddingBottom="8dp"> + android:paddingBottom="8dp" + android:background="@drawable/shape_scale_cell"> #333333 #C8C8C8 #E6E6E6 + #FF4444 \ No newline at end of file