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 7512cc9..5da5beb 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 @@ -21,9 +21,11 @@ import com.shuwei.dish.match.utils.ext.dp * * @param showNameOnly true = 调料配置模式,仅显示 tvName,隐藏 tvWeight/tvState; * false = 秤总览模式,三行全部显示 + * @param showAddress true = 显示秤地址行(tvAddress),仅 MasterScaleActivity 使用 */ class Seasoning18GridAdapter( - private val showNameOnly: Boolean = false + private val showNameOnly: Boolean = false, + private val showAddress: Boolean = false ) : BaseQuickAdapter(mutableListOf()) { /** 空位哨兵,address=0 表示无效 */ @@ -53,9 +55,12 @@ class Seasoning18GridAdapter( else it.setMargins(margin, margin, margin, margin) } val baseSp = (cellSize / parent.resources.displayMetrics.density).toInt() + b.tvAddress.textSize = (baseSp * 0.14f).coerceIn(8f, 12f) b.tvName.textSize = (baseSp * 0.18f).coerceIn(10f, 16f) b.tvWeight.textSize = (baseSp * 0.22f).coerceIn(12f, 20f) b.tvState.textSize = (baseSp * 0.14f).coerceIn(8f, 12f) + // 秤地址行:仅 MasterScaleActivity 显示 + b.tvAddress.visibility = if (showAddress) View.VISIBLE else View.GONE // 调料配置模式隐藏重量和状态行 if (showNameOnly) { b.tvWeight.visibility = View.GONE @@ -75,31 +80,31 @@ class Seasoning18GridAdapter( override fun onBindViewHolder(holder: VH, position: Int, item: ScaleData?) { val valid = (item?.address ?: 0) != 0 if (!valid) { - holder.b.tvName.text = if (showNameOnly) "-" else "" + holder.b.tvAddress.text = "" + holder.b.tvName.text = "-" holder.b.tvWeight.text = "" holder.b.tvState.text = "" } else { - holder.b.tvName.text = when { - !item!!.name.isNullOrBlank() -> item.name - showNameOnly -> "-" - else -> "秤${item.address}" - } - holder.b.tvWeight.text = "${item!!.weight}g" + holder.b.tvAddress.text = "秤${item!!.address}" + holder.b.tvName.text = if (!item.name.isNullOrBlank()) item.name else "-" + holder.b.tvWeight.text = "${item.weight}g" holder.b.tvState.text = stateStr(item.state) } - // 高亮格子:红底白字;其余恢复默认 if (position == highlightedPosition) { holder.b.root.setBackgroundColor(ContextCompat.getColor(holder.b.root.context, R.color.red_ff4444)) val white = ContextCompat.getColor(holder.b.root.context, R.color.white) + holder.b.tvAddress.setTextColor(white) holder.b.tvName.setTextColor(white) holder.b.tvWeight.setTextColor(white) holder.b.tvState.setTextColor(white) } else { holder.b.root.setBackgroundResource(R.drawable.shape_scale_cell) val normal = ContextCompat.getColor(holder.b.root.context, R.color.home_title) - holder.b.tvName.setTextColor(normal) + val sub = ContextCompat.getColor(holder.b.root.context, R.color.home_sub_title) + holder.b.tvAddress.setTextColor(sub) + holder.b.tvName.setTextColor(sub) holder.b.tvWeight.setTextColor(normal) - holder.b.tvState.setTextColor(normal) + holder.b.tvState.setTextColor(sub) } } 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 51136b4..9552b90 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 @@ -24,11 +24,13 @@ import com.shuwei.dish.match.utils.ext.dp * @param smallSize 其余小格子的尺寸(px) * @param showNameOnly true = 调料配置模式,仅显示 tvName,隐藏 tvWeight/tvState; * false = 秤总览模式,三行全部显示 + * @param showAddress true = 显示秤地址行(tvAddress),仅 MasterScaleActivity 使用 */ class Seasoning22GridAdapter( private val largeSize: Int, private val smallSize: Int, - private val showNameOnly: Boolean = false + private val showNameOnly: Boolean = false, + private val showAddress: Boolean = false ) : BaseQuickAdapter(mutableListOf()) { /** 空位哨兵,address=0 表示无效 */ @@ -55,9 +57,12 @@ class Seasoning22GridAdapter( setMargins(2.dp, 2.dp, 2.dp, 2.dp) } val baseSp = (cellSize / parent.resources.displayMetrics.density).toInt() + b.tvAddress.textSize = (baseSp * 0.14f).coerceIn(8f, 12f) b.tvName.textSize = (baseSp * 0.18f).coerceIn(10f, 16f) b.tvWeight.textSize = (baseSp * 0.22f).coerceIn(12f, 20f) b.tvState.textSize = (baseSp * 0.14f).coerceIn(8f, 12f) + // 秤地址行:仅 MasterScaleActivity 显示 + b.tvAddress.visibility = if (showAddress) View.VISIBLE else View.GONE // 调料配置模式隐藏重量和状态行 if (showNameOnly) { b.tvWeight.visibility = View.GONE @@ -77,32 +82,32 @@ class Seasoning22GridAdapter( override fun onBindViewHolder(holder: VH, position: Int, item: ScaleData?) { val valid = (item?.address ?: 0) != 0 if (!valid) { - holder.b.tvName.text = if (showNameOnly) "-" else "" + holder.b.tvAddress.text = "" + holder.b.tvName.text = "-" holder.b.tvWeight.text = "" holder.b.tvState.text = "" } else { - // 有名称显示名称,无名称:调料配置显示"-",秤总览显示秤地址编号 - holder.b.tvName.text = when { - !item!!.name.isNullOrBlank() -> item.name - showNameOnly -> "-" - else -> "秤${item.address}" - } - holder.b.tvWeight.text = "${item!!.weight}g" + holder.b.tvAddress.text = "秤${item!!.address}" + holder.b.tvName.text = if (!item.name.isNullOrBlank()) item.name else "-" + holder.b.tvWeight.text = "${item.weight}g" holder.b.tvState.text = stateStr(item.state) } // 高亮格子:红底白字;其余恢复默认 if (position == highlightedPosition) { holder.b.root.setBackgroundColor(ContextCompat.getColor(holder.b.root.context, R.color.red_ff4444)) val white = ContextCompat.getColor(holder.b.root.context, R.color.white) + holder.b.tvAddress.setTextColor(white) holder.b.tvName.setTextColor(white) holder.b.tvWeight.setTextColor(white) holder.b.tvState.setTextColor(white) } else { holder.b.root.setBackgroundResource(R.drawable.shape_scale_cell) val normal = ContextCompat.getColor(holder.b.root.context, R.color.home_title) - holder.b.tvName.setTextColor(normal) + val sub = ContextCompat.getColor(holder.b.root.context, R.color.home_sub_title) + holder.b.tvAddress.setTextColor(sub) + holder.b.tvName.setTextColor(sub) holder.b.tvWeight.setTextColor(normal) - holder.b.tvState.setTextColor(normal) + holder.b.tvState.setTextColor(sub) } } diff --git a/app/src/main/java/com/shuwei/dish/match/db/AppDatabase.kt b/app/src/main/java/com/shuwei/dish/match/db/AppDatabase.kt index 319bf8c..81ddcb8 100644 --- a/app/src/main/java/com/shuwei/dish/match/db/AppDatabase.kt +++ b/app/src/main/java/com/shuwei/dish/match/db/AppDatabase.kt @@ -7,9 +7,11 @@ import androidx.room.RoomDatabase import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import com.shuwei.dish.match.db.dao.AppDao +import com.shuwei.dish.match.db.dao.SeasoningSlotDao import com.shuwei.dish.match.entity.CookFoodEntity import com.shuwei.dish.match.entity.CookFoodGoodsEntity import com.shuwei.dish.match.entity.SeasoningEntity +import com.shuwei.dish.match.entity.SeasoningSlotEntity // 步骤1:更新版本号 @@ -19,8 +21,9 @@ import com.shuwei.dish.match.entity.SeasoningEntity SeasoningEntity::class, CookFoodEntity::class, CookFoodGoodsEntity::class, + SeasoningSlotEntity::class, ], - version = 7, + version = 8, exportSchema = true ) abstract class AppDatabase : RoomDatabase() { @@ -30,6 +33,8 @@ abstract class AppDatabase : RoomDatabase() { abstract fun appDao(): AppDao + abstract fun seasoningSlotDao(): SeasoningSlotDao + } // 步骤2:创建Migration对象 @@ -140,6 +145,20 @@ val MIGRATION_5_6 = MigrationImpl(5, 6) { db -> db.endTransaction() } +val MIGRATION_7_8 = MigrationImpl(7, 8) { db -> + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `dm_seasoning_slot` ( + `deviceId` TEXT NOT NULL, + `address` INTEGER NOT NULL, + `goodsId` TEXT NOT NULL, + `goodsName` TEXT NOT NULL, + PRIMARY KEY(`deviceId`, `address`) + ) + """.trimIndent() + ) +} + val MIGRATION_6_7 = MigrationImpl(6, 7) {db -> runCatching { @@ -210,6 +229,7 @@ class DatabaseProvider(private val context: Context) { // .addMigrations(MIGRATION_4_5) // .addMigrations(MIGRATION_5_6) // .addMigrations(MIGRATION_6_7) + .addMigrations(MIGRATION_7_8) .build() } } diff --git a/app/src/main/java/com/shuwei/dish/match/db/dao/SeasoningSlotDao.kt b/app/src/main/java/com/shuwei/dish/match/db/dao/SeasoningSlotDao.kt new file mode 100644 index 0000000..6107491 --- /dev/null +++ b/app/src/main/java/com/shuwei/dish/match/db/dao/SeasoningSlotDao.kt @@ -0,0 +1,40 @@ +package com.shuwei.dish.match.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.shuwei.dish.match.entity.SeasoningSlotEntity + +/** + * 调料槽位配置 DAO + * 以 (deviceId, address) 为主键做 upsert,保证同一槽位只保留最新配置 + */ +@Dao +interface SeasoningSlotDao { + + /** + * 插入或更新单个槽位配置(主键冲突时覆盖) + */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(slot: SeasoningSlotEntity) + + /** + * 批量插入或更新槽位配置(主设备广播全量时使用) + */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertAll(slots: List) + + /** + * 查询指定设备的全部槽位配置 + * @param deviceId 目标设备 ID + */ + @Query("SELECT * FROM dm_seasoning_slot WHERE deviceId = :deviceId") + suspend fun queryByDeviceId(deviceId: String): List + + /** + * 查询所有设备的全部槽位配置(主设备广播时使用) + */ + @Query("SELECT * FROM dm_seasoning_slot") + suspend fun queryAll(): List +} diff --git a/app/src/main/java/com/shuwei/dish/match/entity/SeasoningSlotEntity.kt b/app/src/main/java/com/shuwei/dish/match/entity/SeasoningSlotEntity.kt new file mode 100644 index 0000000..08d06b0 --- /dev/null +++ b/app/src/main/java/com/shuwei/dish/match/entity/SeasoningSlotEntity.kt @@ -0,0 +1,23 @@ +package com.shuwei.dish.match.entity + +import androidx.room.Entity + +/** + * 调料槽位配置表,记录每个格子对应哪种调料 + * 主键为 (deviceId, address) 组合,唯一标识一个物理秤槽位 + * + * @param deviceId 所属设备 ID,用于区分 22格/18格 设备 + * @param address 秤硬件地址,对应 ScaleDeviceConfig.SCALE_ORDER_22/18 中的值 + * @param goodsId 调料 ID + * @param goodsName 调料名称,用于 adapter 展示 + */ +@Entity( + tableName = "dm_seasoning_slot", + primaryKeys = ["deviceId", "address"] +) +data class SeasoningSlotEntity( + val deviceId: String, + val address: Int, + val goodsId: String, + val goodsName: String +) 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 index 99ebdcd..4e4160c 100644 --- a/app/src/main/java/com/shuwei/dish/match/scale/ScaleEvent.kt +++ b/app/src/main/java/com/shuwei/dish/match/scale/ScaleEvent.kt @@ -1,20 +1,38 @@ package com.shuwei.dish.match.scale /** - * 秤事件数据类,用于子设备向主设备发送非重量类通知 + * 秤事件数据类,用于主设备和子设备之间的非重量类通知 * @param type 事件类型,参见 companion object 中的常量 * @param deviceId 发送方设备 ID - * @param address 触发事件的秤地址 + * @param address 触发事件的秤地址,TYPE_SEASONING_CONFIG 时为 0(无意义) * @param delta 重量变化量(克),仅 TYPE_SEASONING_ADDED 时有意义 + * @param slots 调料槽位配置列表,仅 TYPE_SEASONING_CONFIG 时有效 */ data class ScaleEvent( val type: String, val deviceId: String, - val address: Int, - val delta: Double = 0.0 + val address: Int = 0, + val delta: Double = 0.0, + val slots: List? = null ) { + /** + * 单个槽位的配置信息,随 TYPE_SEASONING_CONFIG 事件一起传输 + * @param deviceId 所属设备 ID + * @param address 秤硬件地址 + * @param goodsId 调料 ID + * @param goodsName 调料名称 + */ + data class SlotConfig( + val deviceId: String, + val address: Int, + val goodsId: String, + val goodsName: String + ) + companion object { /** 调料添加事件:某秤重量增加超过阈值 */ const val TYPE_SEASONING_ADDED = "seasoning_added" + /** 调料槽位配置同步事件:主设备配置变更后广播给子设备 */ + const val TYPE_SEASONING_CONFIG = "seasoning_config" } } 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 8ffe7d0..82ee0c8 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 @@ -2,11 +2,15 @@ package com.shuwei.dish.match.scale import android.content.Context import android.util.Log +import com.shuwei.dish.match.base.BaseApp import com.shuwei.dish.match.base.DeviceRole import com.shuwei.dish.match.base.GlobalData import com.shuwei.dish.match.utils.NetworkUtil import com.shuwei.dish.match.utils.WeightUtil +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch /** * 秤服务统一门面(单例) @@ -59,6 +63,36 @@ object ScaleServiceManager { wsClient?.onScaleEvent = value } + /** + * 收到主设备下发的调料配置同步事件时的回调,仅子设备有效 + * 在子线程中调用,需自行切换到主线程更新 UI + */ + var onSeasoningConfig: ((ScaleEvent) -> Unit)? + get() = wsServer?.onSeasoningConfig + set(value) { wsServer?.onSeasoningConfig = value } + + /** + * 向所有已连接子设备广播调料配置(主设备调用) + * @param slots 全量槽位配置列表 + */ + fun sendSeasoningConfig(slots: List) { + wsClient?.sendToAllSlaves(buildConfigEvent(slots)) + } + + /** + * 向单台刚连接的子设备推送调料配置(连接时按需调用) + * @param deviceId 目标子设备 ID + */ + private fun sendSeasoningConfigTo(deviceId: String, slots: List) { + wsClient?.sendToDevice(deviceId, buildConfigEvent(slots)) + } + + private fun buildConfigEvent(slots: List) = ScaleEvent( + type = ScaleEvent.TYPE_SEASONING_CONFIG, + deviceId = GlobalData.deviceId, + slots = slots + ) + /** * 向主设备广播秤事件(子设备调用) * @param event 要广播的事件 @@ -123,6 +157,19 @@ object ScaleServiceManager { it.onScaleData = { data -> aggregator?.onRemoteScaleData(data) } it.onScaleEvent = { event -> _onScaleEvent?.invoke(event) } it.onDeviceDisconnected = { remoteId -> aggregator?.removeDevice(remoteId) } + it.onDeviceConnected = { remoteId -> + // 子设备连接成功时,从 Room 读取全量配置并单独推送给该设备 + CoroutineScope(Dispatchers.IO).launch { + val slots = BaseApp.instance!!.database.seasoningSlotDao() + .queryAll() + .map { slot -> + ScaleEvent.SlotConfig( + slot.deviceId, slot.address, slot.goodsId, slot.goodsName + ) + } + if (slots.isNotEmpty()) sendSeasoningConfigTo(remoteId, slots) + } + } } 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 344e28c..a0fd704 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 @@ -55,6 +55,9 @@ class ScaleWebSocketClient { /** 设备断线时的回调(连接失败或关闭),在子线程调用 */ var onDeviceDisconnected: ((deviceId: String) -> Unit)? = null + /** 子设备首次连接成功时的回调,在子线程调用;可用于主设备主动推送全量配置 */ + var onDeviceConnected: ((deviceId: String) -> Unit)? = null + /** * 连接到指定子设备 * @param deviceId 子设备 ID @@ -79,6 +82,7 @@ class ScaleWebSocketClient { connections[deviceId] = webSocket reconnectDelays[deviceId] = RECONNECT_BASE_MS reconnectTasks.remove(deviceId)?.cancel(false) + onDeviceConnected?.invoke(deviceId) } override fun onMessage(webSocket: WebSocket, text: String) { @@ -148,6 +152,31 @@ class ScaleWebSocketClient { return ws.send(gson.toJson(command)) } + /** + * 向所有已连接的子设备广播事件(主设备调用,用于配置同步等场景) + * @param event 要广播的事件 + */ + fun sendToAllSlaves(event: ScaleEvent) { + val json = gson.toJson(event) + connections.values.forEach { ws -> + try { ws.send(json) } catch (e: Exception) { + Log.w(TAG, "sendToAllSlaves 失败: ${e.message}") + } + } + } + + /** + * 向指定子设备发送事件(用于连接时单独推送配置) + * @param deviceId 目标子设备 ID + * @param event 要发送的事件 + */ + fun sendToDevice(deviceId: String, event: ScaleEvent) { + val ws = connections[deviceId] ?: return + try { ws.send(gson.toJson(event)) } catch (e: Exception) { + Log.w(TAG, "sendToDevice $deviceId 失败: ${e.message}") + } + } + /** * 断开指定子设备连接(不再重连) * @param deviceId 子设备 ID 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 34d6512..e6f64d9 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 @@ -28,6 +28,9 @@ class ScaleWebSocketServer(private val deviceId: String, private val context: Co /** 主设备连接状态变化回调:true=已连接,false=已断开;在主线程外调用,需自行切换线程 */ var onConnectionChanged: ((connected: Boolean) -> Unit)? = null + /** 收到主设备下发的调料配置同步事件时的回调,在子线程调用 */ + var onSeasoningConfig: ((event: ScaleEvent) -> Unit)? = null + /** 当前是否有主设备连接 */ val isConnected: Boolean get() = server?.connections?.isNotEmpty() == true @@ -72,6 +75,8 @@ class ScaleWebSocketServer(private val deviceId: String, private val context: Co */ fun stop() { WeightUtil.removeWeightListener(TAG) + onConnectionChanged = null + onSeasoningConfig = null try { server?.stop(1000) } catch (e: Exception) { @@ -135,6 +140,15 @@ class ScaleWebSocketServer(private val deviceId: String, private val context: Co override fun onMessage(conn: WebSocket, message: String) { try { + val raw = gson.fromJson(message, Map::class.java) + if (raw.containsKey("type")) { + // 主设备下发的事件(如调料配置同步) + val event = gson.fromJson(message, ScaleEvent::class.java) + when (event.type) { + ScaleEvent.TYPE_SEASONING_CONFIG -> onSeasoningConfig?.invoke(event) + } + return + } val cmd = gson.fromJson(message, ScaleCommand::class.java) // 校验指令目标设备是否为本机 if (cmd.deviceId != deviceId) { @@ -148,7 +162,7 @@ class ScaleWebSocketServer(private val deviceId: String, private val context: Co } } } catch (e: Exception) { - Log.w(TAG, "收到清零指令, 解析指令失败: ${e.message}") + Log.w(TAG, "收到消息, 解析失败: ${e.message}") } } diff --git a/app/src/main/java/com/shuwei/dish/match/ui/MasterScaleActivity.kt b/app/src/main/java/com/shuwei/dish/match/ui/MasterScaleActivity.kt index b9af322..1fcd5ce 100644 --- a/app/src/main/java/com/shuwei/dish/match/ui/MasterScaleActivity.kt +++ b/app/src/main/java/com/shuwei/dish/match/ui/MasterScaleActivity.kt @@ -27,9 +27,12 @@ import com.shuwei.dish.match.databinding.ListTypeScale1Binding import com.shuwei.dish.match.databinding.ListTypeScale18Binding import com.shuwei.dish.match.databinding.ListTypeScale2Binding import com.shuwei.dish.match.databinding.ListTypeScale22Binding +import com.shuwei.dish.match.base.BaseApp import com.shuwei.dish.match.scale.ScaleData import com.shuwei.dish.match.scale.ScaleDeviceConfig import com.shuwei.dish.match.scale.ScaleServiceManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import com.shuwei.dish.match.utils.NetworkUtil import com.shuwei.dish.match.utils.WeightUtil import com.shuwei.dish.match.utils.ext.dp @@ -59,6 +62,9 @@ class MasterScaleActivity : BaseActivity() { private val groupList = mutableListOf() private val adapter = DeviceGroupAdapter(groupList) + /** key = "$deviceId#$address",value = 调料名称,用于注入 ScaleData.name */ + private val slotNameMap = mutableMapOf() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMasterScaleBinding.inflate(layoutInflater) @@ -70,7 +76,26 @@ class MasterScaleActivity : BaseActivity() { binding.rvScaleList.itemAnimator = null binding.rvScaleList.adapter = adapter - observeScaleData() + loadSlotNames() + } + + /** 从 Room 加载槽位配置到 slotNameMap,加载完成后启动数据观测 */ + private fun loadSlotNames() { + lifecycleScope.launch(Dispatchers.IO) { + val slots = BaseApp.instance!!.database.seasoningSlotDao().queryAll() + withContext(Dispatchers.Main) { + slots.forEach { slot -> + slotNameMap["${slot.deviceId}#${slot.address}"] = slot.goodsName + } + observeScaleData() + } + } + } + + /** 用 slotNameMap 为 ScaleData 注入名称 */ + private fun ScaleData.withSlotName(): ScaleData { + val name = slotNameMap["$deviceId#$address"] + return if (name != null) copy(name = name) else this } private fun observeScaleData() { @@ -90,7 +115,7 @@ class MasterScaleActivity : BaseActivity() { deviceId = deviceId, ip = if (deviceId == localId) localIp else scales.firstOrNull { it.ip.isNotEmpty() }?.ip ?: "", - scales = scales.sortedBy { it.address } + scales = scales.map { it.withSlotName() }.sortedBy { it.address } ) } @@ -269,7 +294,7 @@ class MasterScaleActivity : BaseActivity() { alignItems = AlignItems.FLEX_START } b.rvScales.itemAnimator = null - innerAdapter = Seasoning22GridAdapter(largeSize, smallSize, showNameOnly = false).apply { + innerAdapter = Seasoning22GridAdapter(largeSize, smallSize, showNameOnly = false, showAddress = true).apply { onItemClick = { scale, _ -> showTareDialog(deviceId = scale.deviceId, ip = scale.ip, address = scale.address) } @@ -303,7 +328,7 @@ class MasterScaleActivity : BaseActivity() { } b.rvScales.layoutManager = gridLayoutManager b.rvScales.itemAnimator = null - innerAdapter = Seasoning18GridAdapter(showNameOnly = false).apply { + innerAdapter = Seasoning18GridAdapter(showNameOnly = false, showAddress = true).apply { onItemClick = { scale, _ -> showTareDialog(deviceId = scale.deviceId, ip = scale.ip, address = scale.address) } @@ -369,6 +394,8 @@ class MasterScaleActivity : BaseActivity() { setBackgroundResource(R.drawable.shape_scale_cell) val baseSize = ((cellSize - margin * 2) / resources.displayMetrics.density).toInt() addView(makeCellText("秤${scale.address}", (baseSize * 0.16f).coerceIn(9f, 14f).toInt())) + val foodType = if (scale.address == 1) "净材" else "熟食" + addView(makeCellText(foodType, (baseSize * 0.16f).coerceIn(9f, 14f).toInt())) addView(makeCellText("${scale.weight}g", (baseSize * 0.22f).coerceIn(12f, 20f).toInt(), bold = true)) addView(makeCellText(stateStr(scale.state), (baseSize * 0.14f).coerceIn(8f, 12f).toInt())) tag = scale @@ -379,8 +406,9 @@ class MasterScaleActivity : BaseActivity() { @SuppressLint("SetTextI18n") private fun updateScaleCell(cell: LinearLayout, scale: ScaleData) { (cell.getChildAt(0) as? TextView)?.text = "秤${scale.address}" - (cell.getChildAt(1) as? TextView)?.text = "${scale.weight}g" - (cell.getChildAt(2) as? TextView)?.text = stateStr(scale.state) + (cell.getChildAt(1) as? TextView)?.text = if (scale.address == 1) "净材" else "熟食" + (cell.getChildAt(2) as? TextView)?.text = "${scale.weight}g" + (cell.getChildAt(3) as? TextView)?.text = stateStr(scale.state) } private fun makeCellText(text: String, spSize: Int, bold: Boolean = false): TextView = diff --git a/app/src/main/java/com/shuwei/dish/match/ui/SlaveActivity.kt b/app/src/main/java/com/shuwei/dish/match/ui/SlaveActivity.kt index 8a58657..c202974 100644 --- a/app/src/main/java/com/shuwei/dish/match/ui/SlaveActivity.kt +++ b/app/src/main/java/com/shuwei/dish/match/ui/SlaveActivity.kt @@ -9,6 +9,7 @@ import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager import android.widget.LinearLayout import androidx.activity.addCallback +import androidx.lifecycle.lifecycleScope import com.google.android.flexbox.AlignItems import com.google.android.flexbox.FlexDirection import com.google.android.flexbox.FlexWrap @@ -17,15 +18,20 @@ 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 +import com.shuwei.dish.match.entity.SeasoningSlotEntity import com.shuwei.dish.match.scale.ScaleData 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.WeightUtil import com.shuwei.dish.match.utils.ext.dp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext /** * 子设备专属页面(小屏幕设备使用) @@ -49,6 +55,8 @@ class SlaveActivity : BaseActivity() { /** 各秤地址的上一次重量快照,用于计算 delta */ private val weightSnapshot = mutableMapOf() + /** key = address,value = 调料名称,用于注入 ScaleData.name,避免重量更新时覆盖名称 */ + private val slotNameMap = mutableMapOf() /** 主线程 Handler,用于延迟清除高亮 */ private val mainHandler = Handler(Looper.getMainLooper()) /** 当前高亮清除任务,新高亮触发时取消旧任务 */ @@ -65,8 +73,10 @@ class SlaveActivity : BaseActivity() { setupScaleList() updateMasterStatus(ScaleServiceManager.isMasterConnected) + loadSlotsFromDb() listenLocalScales() listenMasterConnection() + listenSeasoningConfig() addBackKeyListener() } @@ -140,6 +150,55 @@ class SlaveActivity : BaseActivity() { } } + /** + * 从本地 Room 加载本机设备的槽位配置并更新 adapter + * 主设备未开机时也能正常展示上次配置 + */ + private fun loadSlotsFromDb() { + lifecycleScope.launch(Dispatchers.IO) { + val dao = BaseApp.instance!!.database.seasoningSlotDao() + val slots = dao.queryByDeviceId(GlobalData.deviceId) + withContext(Dispatchers.Main) { applySlots(slots) } + } + } + + /** + * 监听主设备下发的调料配置同步事件 + * 收到后更新 adapter 并写入本地 Room + */ + private fun listenSeasoningConfig() { + ScaleServiceManager.onSeasoningConfig = onSeasoningConfig@{ event -> + val mySlots = event.slots + ?.filter { it.deviceId == GlobalData.deviceId } + ?.map { SeasoningSlotEntity(it.deviceId, it.address, it.goodsId, it.goodsName) } + ?: return@onSeasoningConfig + lifecycleScope.launch(Dispatchers.IO) { + BaseApp.instance!!.database.seasoningSlotDao().upsertAll(mySlots) + withContext(Dispatchers.Main) { applySlots(mySlots) } + } + } + } + + /** + * 将槽位配置应用到对应 adapter(按地址匹配格子位置) + * 同时更新 slotNameMap,确保后续重量回调构造 ScaleData 时携带正确名称 + */ + private fun applySlots(slots: List) { + slots.forEach { slot -> + slotNameMap[slot.address] = slot.goodsName + when { + scale22Adapter != null -> { + val pos = ScaleDeviceConfig.SCALE_ORDER_22.indexOf(slot.address) + if (pos >= 0) scale22Adapter!!.updateItemName(pos, slot.goodsName) + } + scale18Adapter != null -> { + val pos = ScaleDeviceConfig.SCALE_ORDER_18.indexOf(slot.address) + if (pos >= 0) scale18Adapter!!.updateItemName(pos, slot.goodsName) + } + } + } + } + /** * 监听本机 WeightUtil 回调,实时刷新秤列表 * 22/18个秤走网格 adapter,其余走线性列表 @@ -164,14 +223,14 @@ class SlaveActivity : BaseActivity() { // 用最新数据更新缓存后整体刷新网格 val existing = scale22Adapter!!.items.toMutableList() val idx = existing.indexOfFirst { it.address == address } - val newData = ScaleData(GlobalData.deviceId, address, weight, state, System.currentTimeMillis()) + val newData = ScaleData(GlobalData.deviceId, address, weight, state, System.currentTimeMillis(), name = slotNameMap[address]) if (idx >= 0) existing[idx] = newData else existing.add(newData) scale22Adapter!!.updateByAddress(existing) } scale18Adapter != null -> { val existing = scale18Adapter!!.items.toMutableList() val idx = existing.indexOfFirst { it.address == address } - val newData = ScaleData(GlobalData.deviceId, address, weight, state, System.currentTimeMillis()) + val newData = ScaleData(GlobalData.deviceId, address, weight, state, System.currentTimeMillis(), name = slotNameMap[address]) if (idx >= 0) existing[idx] = newData else existing.add(newData) scale18Adapter!!.updateByAddress(existing) } @@ -219,6 +278,7 @@ class SlaveActivity : BaseActivity() { override fun onDestroy() { ScaleServiceManager.onMasterConnectionChanged = null + ScaleServiceManager.onSeasoningConfig = null WeightUtil.removeWeightListener(TAG) clearHighlightRunnable?.let { mainHandler.removeCallbacks(it) } super.onDestroy() 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 cb94fc7..a9910c6 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 @@ -13,18 +13,23 @@ import com.google.android.flexbox.FlexWrap 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.BaseApp 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.entity.SeasoningSlotEntity 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.scale.ScaleEvent.SlotConfig 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.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class SeasoningConfigFragment : BaseFragment() { @@ -65,26 +70,58 @@ class SeasoningConfigFragment : BaseFragment() { /** 绑定两个 Adapter 的格子点击,弹出调料选择弹窗 */ private fun bindAdapterClicks() { - adapter22.onItemClick = { _, position -> + adapter22.onItemClick = { scaleData, position -> SeasoningSelectDialog( activity = settingActivity, clickIndex = position, onItemSelected = { item -> adapter22.updateItemName(position, item.goodsName ?: "") + saveSlotAndBroadcast( + SeasoningSlotEntity( + deviceId = ScaleDeviceConfig.DEVICE_ID_22, + address = scaleData.address, + goodsId = item.goodsId, + goodsName = item.goodsName ?: "" + ) + ) } ).show() } - adapter18.onItemClick = { _, position -> + adapter18.onItemClick = { scaleData, position -> SeasoningSelectDialog( activity = settingActivity, clickIndex = position, onItemSelected = { item -> adapter18.updateItemName(position, item.goodsName ?: "") + saveSlotAndBroadcast( + SeasoningSlotEntity( + deviceId = ScaleDeviceConfig.DEVICE_ID_18, + address = scaleData.address, + goodsId = item.goodsId, + goodsName = item.goodsName ?: "" + ) + ) } ).show() } } + /** + * 将单个槽位写入 Room,再读取全量配置广播给子设备 + * 仅主设备执行广播,子设备不调用此方法 + */ + private fun saveSlotAndBroadcast(slot: SeasoningSlotEntity) { + lifecycleScope.launch(Dispatchers.IO) { + val dao = BaseApp.instance!!.database.seasoningSlotDao() + dao.upsert(slot) + // 读取全量配置,通过 wsClient 推送给所有已连接子设备 + val allSlots = dao.queryAll().map { + SlotConfig(it.deviceId, it.address, it.goodsId, it.goodsName) + } + ScaleServiceManager.sendSeasoningConfig(allSlots) + } + } + // /** 初始化 ViewModel */ // private fun initViewModel() { // val factory = AppFactory(AppRepository(BaseApp.instance!!.database.appDao())) @@ -126,10 +163,25 @@ class SeasoningConfigFragment : BaseFragment() { } } - /** 加载默认空数据,使两个列表完整展示所有格子 */ + /** 从 Room 加载已配置的槽位名称,填充到对应格子 */ private fun loadData() { adapter22.update(emptyList()) adapter18.update(emptyList()) + lifecycleScope.launch(Dispatchers.IO) { + val dao = BaseApp.instance!!.database.seasoningSlotDao() + val slots22 = dao.queryByDeviceId(ScaleDeviceConfig.DEVICE_ID_22) + val slots18 = dao.queryByDeviceId(ScaleDeviceConfig.DEVICE_ID_18) + withContext(Dispatchers.Main) { + slots22.forEach { slot -> + val pos = ScaleDeviceConfig.SCALE_ORDER_22.indexOf(slot.address) + if (pos >= 0) adapter22.updateItemName(pos, slot.goodsName) + } + slots18.forEach { slot -> + val pos = ScaleDeviceConfig.SCALE_ORDER_18.indexOf(slot.address) + if (pos >= 0) adapter18.updateItemName(pos, slot.goodsName) + } + } + } // appViewModel.loadSeasoning { list -> // adapter22.update(list ?: emptyList()) // adapter18.update(list ?: emptyList()) diff --git a/app/src/main/res/layout/list_item_scale22_row.xml b/app/src/main/res/layout/list_item_scale22_row.xml index 9376d52..52ba406 100644 --- a/app/src/main/res/layout/list_item_scale22_row.xml +++ b/app/src/main/res/layout/list_item_scale22_row.xml @@ -1,5 +1,5 @@ - + + +