feat(fragment): 实现调料秤重量增加高亮提醒功能

主设备订阅 allScales StateFlow 检测所有子设备秤重量变化,
超过10g且秤稳定时高亮对应格子,15秒后自动恢复
This commit is contained in:
2026-04-17 16:56:36 +08:00
parent 768bde79a1
commit 87bf11454c
9 changed files with 295 additions and 4 deletions
@@ -3,9 +3,11 @@ package com.shuwei.dish.match.adapter
import android.content.Context import android.content.Context
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.chad.library.adapter4.BaseQuickAdapter import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder 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.databinding.ListItemSeasoningCellBinding
import com.shuwei.dish.match.entity.SeasoningEntity import com.shuwei.dish.match.entity.SeasoningEntity
import com.shuwei.dish.match.scale.ScaleDeviceConfig import com.shuwei.dish.match.scale.ScaleDeviceConfig
@@ -21,6 +23,9 @@ class Seasoning18GridAdapter : BaseQuickAdapter<SeasoningEntity, Seasoning18Grid
/** 空位哨兵,sort=-1 表示无效 */ /** 空位哨兵,sort=-1 表示无效 */
private val empty = SeasoningEntity(goodsId = "").also { it.sort = -1 } private val empty = SeasoningEntity(goodsId = "").also { it.sort = -1 }
/** 当前高亮的格子位置,-1 表示无高亮 */
private var highlightedPosition: Int = -1
/** 点击回调,预留,暂不使用 */ /** 点击回调,预留,暂不使用 */
var onItemClick: ((SeasoningEntity, Int) -> Unit)? = null var onItemClick: ((SeasoningEntity, Int) -> Unit)? = null
@@ -57,6 +62,32 @@ class Seasoning18GridAdapter : BaseQuickAdapter<SeasoningEntity, Seasoning18Grid
override fun onBindViewHolder(holder: VH, position: Int, item: SeasoningEntity?) { override fun onBindViewHolder(holder: VH, position: Int, item: SeasoningEntity?) {
holder.b.tvGoodsName.text = item?.goodsName?.takeIf { it.isNotBlank() } ?: "-" 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)
} }
/** /**
@@ -3,10 +3,12 @@ package com.shuwei.dish.match.adapter
import android.content.Context import android.content.Context
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.chad.library.adapter4.BaseQuickAdapter import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.google.android.flexbox.FlexboxLayoutManager import com.google.android.flexbox.FlexboxLayoutManager
import com.shuwei.dish.match.R
import com.shuwei.dish.match.databinding.ListItemSeasoningCellBinding import com.shuwei.dish.match.databinding.ListItemSeasoningCellBinding
import com.shuwei.dish.match.entity.SeasoningEntity import com.shuwei.dish.match.entity.SeasoningEntity
import com.shuwei.dish.match.scale.ScaleDeviceConfig import com.shuwei.dish.match.scale.ScaleDeviceConfig
@@ -28,6 +30,9 @@ class Seasoning22GridAdapter(
/** 空位哨兵,sort=-1 表示无效 */ /** 空位哨兵,sort=-1 表示无效 */
private val empty = SeasoningEntity(goodsId = "").also { it.sort = -1 } private val empty = SeasoningEntity(goodsId = "").also { it.sort = -1 }
/** 当前高亮的格子位置,-1 表示无高亮 */
private var highlightedPosition: Int = -1
/** 点击回调,预留,暂不使用 */ /** 点击回调,预留,暂不使用 */
var onItemClick: ((SeasoningEntity, Int) -> Unit)? = null var onItemClick: ((SeasoningEntity, Int) -> Unit)? = null
@@ -60,6 +65,32 @@ class Seasoning22GridAdapter(
override fun onBindViewHolder(holder: VH, position: Int, item: SeasoningEntity?) { override fun onBindViewHolder(holder: VH, position: Int, item: SeasoningEntity?) {
holder.b.tvGoodsName.text = item?.goodsName?.takeIf { it.isNotBlank() } ?: "-" 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)
} }
/** /**
@@ -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"
}
}
@@ -46,6 +46,26 @@ object ScaleServiceManager {
val allScales: StateFlow<Map<String, ScaleData>>? val allScales: StateFlow<Map<String, ScaleData>>?
get() = aggregator?.allScales 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() 之后设置才能生效 * 设置后会立即转发给 wsServer;需在 start() 之后设置才能生效
@@ -100,6 +120,7 @@ object ScaleServiceManager {
// WebSocket 客户端 // WebSocket 客户端
val client = ScaleWebSocketClient().also { val client = ScaleWebSocketClient().also {
it.onScaleData = { data -> aggregator?.onRemoteScaleData(data) } it.onScaleData = { data -> aggregator?.onRemoteScaleData(data) }
it.onScaleEvent = { event -> _onScaleEvent?.invoke(event) }
it.onDeviceDisconnected = { remoteId -> aggregator?.removeDevice(remoteId) } it.onDeviceDisconnected = { remoteId -> aggregator?.removeDevice(remoteId) }
} }
wsClient = client wsClient = client
@@ -49,6 +49,9 @@ class ScaleWebSocketClient {
/** 收到秤数据时的回调,在子线程调用 */ /** 收到秤数据时的回调,在子线程调用 */
var onScaleData: ((data: ScaleData) -> Unit)? = null var onScaleData: ((data: ScaleData) -> Unit)? = null
/** 收到秤事件(如调料添加通知)时的回调,在子线程调用 */
var onScaleEvent: ((event: ScaleEvent) -> Unit)? = null
/** 设备断线时的回调(连接失败或关闭),在子线程调用 */ /** 设备断线时的回调(连接失败或关闭),在子线程调用 */
var onDeviceDisconnected: ((deviceId: String) -> Unit)? = null var onDeviceDisconnected: ((deviceId: String) -> Unit)? = null
@@ -80,10 +83,17 @@ class ScaleWebSocketClient {
override fun onMessage(webSocket: WebSocket, text: String) { override fun onMessage(webSocket: WebSocket, text: String) {
try { try {
// 通过 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) val data = gson.fromJson(text, ScaleData::class.java)
onScaleData?.invoke(data) onScaleData?.invoke(data)
}
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "解析秤数据失败: ${e.message}") Log.w(TAG, "解析消息失败: ${e.message}")
} }
} }
@@ -83,6 +83,14 @@ class ScaleWebSocketServer(private val deviceId: String, private val context: Co
Log.d(TAG, "WebSocket 服务端已停止") Log.d(TAG, "WebSocket 服务端已停止")
} }
/**
* 向所有已连接的主设备广播秤事件(如调料添加通知)
* @param event 要广播的事件
*/
fun broadcastEvent(event: ScaleEvent) {
broadcast(gson.toJson(event))
}
/** /**
* 向所有已连接客户端广播消息 * 向所有已连接客户端广播消息
*/ */
@@ -1,7 +1,11 @@
package com.shuwei.dish.match.ui.fragment 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.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import com.google.android.flexbox.AlignItems import com.google.android.flexbox.AlignItems
import com.google.android.flexbox.FlexDirection 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.Seasoning18GridAdapter
import com.shuwei.dish.match.adapter.Seasoning22GridAdapter import com.shuwei.dish.match.adapter.Seasoning22GridAdapter
import com.shuwei.dish.match.base.BaseFragment 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.databinding.FragmentSeasoningConfigBinding
import com.shuwei.dish.match.dialog.SeasoningSelectDialog 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.ui.SettingActivity
import com.shuwei.dish.match.utils.WeightUtil
import com.shuwei.dish.match.utils.ext.dp import com.shuwei.dish.match.utils.ext.dp
import kotlinx.coroutines.launch
class SeasoningConfigFragment : BaseFragment<FragmentSeasoningConfigBinding>() { class SeasoningConfigFragment : BaseFragment<FragmentSeasoningConfigBinding>() {
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( override fun inflateBinding(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup? container: ViewGroup?
@@ -28,11 +47,19 @@ class SeasoningConfigFragment : BaseFragment<FragmentSeasoningConfigBinding>() {
private val adapter22 by lazy { buildAdapter22() } private val adapter22 by lazy { buildAdapter22() }
private val adapter18 by lazy { Seasoning18GridAdapter() } private val adapter18 by lazy { Seasoning18GridAdapter() }
/** 各秤地址的上一次重量快照,用于计算 delta */
private val weightSnapshot = mutableMapOf<Int, Double>()
/** 主线程 Handler,用于延迟清除高亮 */
private val mainHandler = Handler(Looper.getMainLooper())
/** 当前高亮清除任务,新高亮触发时取消旧任务 */
private var clearHighlightRunnable: Runnable? = null
override fun initialize() { override fun initialize() {
settingActivity = activity as SettingActivity settingActivity = activity as SettingActivity
// initViewModel() // initViewModel()
setupRecyclerViews() setupRecyclerViews()
bindAdapterClicks() bindAdapterClicks()
addWeightListener()
loadData() loadData()
} }
@@ -108,4 +135,144 @@ class SeasoningConfigFragment : BaseFragment<FragmentSeasoningConfigBinding>() {
// adapter18.update(list ?: emptyList()) // 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) }
}
} }
@@ -5,10 +5,12 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="120dp" android:layout_height="120dp"
android:orientation="horizontal" android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingStart="16dp" android:paddingStart="16dp"
android:paddingEnd="16dp" android:paddingEnd="16dp"
android:paddingTop="8dp" android:paddingTop="8dp"
android:paddingBottom="8dp"> android:paddingBottom="8dp"
android:background="@drawable/shape_scale_cell">
<!-- 秤编号 --> <!-- 秤编号 -->
<TextView <TextView
+1
View File
@@ -29,5 +29,6 @@
<color name="black333">#333333</color> <color name="black333">#333333</color>
<color name="gray_c8">#C8C8C8</color> <color name="gray_c8">#C8C8C8</color>
<color name="gray_e6">#E6E6E6</color> <color name="gray_e6">#E6E6E6</color>
<color name="red_ff4444">#FF4444</color>
</resources> </resources>