diff --git a/app/src/main/java/com/shuwei/dish/match/dialog/FoodSearchDialog.kt b/app/src/main/java/com/shuwei/dish/match/dialog/FoodSearchDialog.kt index baf63ce..cbdd754 100644 --- a/app/src/main/java/com/shuwei/dish/match/dialog/FoodSearchDialog.kt +++ b/app/src/main/java/com/shuwei/dish/match/dialog/FoodSearchDialog.kt @@ -1,21 +1,29 @@ package com.shuwei.dish.match.dialog import android.annotation.SuppressLint +import android.graphics.Rect import android.text.Editable +import android.view.KeyboardShortcutGroup import android.view.LayoutInflater +import android.view.Menu +import android.view.MotionEvent import android.view.View +import android.view.Window +import android.widget.FrameLayout import androidx.core.view.isEmpty import androidx.core.widget.addTextChangedListener import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.bottomsheet.BottomSheetDialog import com.shuwei.dish.match.R import com.shuwei.dish.match.adapter.FoodAdapter import com.shuwei.dish.match.base.BaseActivity import com.shuwei.dish.match.databinding.DialogFoodSearchBinding +import com.shuwei.dish.match.databinding.LayoutEmptyViewBinding import com.shuwei.dish.match.dialog.SeasoningSelectDialog.Companion.PAGE_SIZE import com.shuwei.dish.match.entity.GoodsItem import com.shuwei.dish.match.net.UiState @@ -42,20 +50,29 @@ class FoodSearchDialog( private val binding = DialogFoodSearchBinding.inflate(LayoutInflater.from(activity)) private val list = mutableListOf() -// .apply { -// add(GoodsItem(goodsId = "200001", goodsName = "土豆丝")) -// add(GoodsItem(goodsId = "200002", goodsName = "土豆片")) -// add(GoodsItem(goodsId = "200003", goodsName = "土豆丁")) -// add(GoodsItem(goodsId = "200004", goodsName = "胡萝卜丝")) -// add(GoodsItem(goodsId = "200005", goodsName = "胡萝卜片")) -// add(GoodsItem(goodsId = "200006", goodsName = "胡萝卜丁")) -// add(GoodsItem(goodsId = "200007", goodsName = "黄瓜丝")) -// add(GoodsItem(goodsId = "200008", goodsName = "黄瓜片")) -// add(GoodsItem(goodsId = "200009", goodsName = "黄瓜丁")) -// add(GoodsItem(goodsId = "200010", goodsName = "洋葱丝")) -// add(GoodsItem(goodsId = "200011", goodsName = "洋葱丁")) -// } + .apply { + add(GoodsItem(goodsId = "200001", goodsName = "土豆丝")) + add(GoodsItem(goodsId = "200002", goodsName = "土豆片")) + add(GoodsItem(goodsId = "200003", goodsName = "土豆丁")) + add(GoodsItem(goodsId = "200004", goodsName = "胡萝卜丝")) + add(GoodsItem(goodsId = "200005", goodsName = "胡萝卜片")) + add(GoodsItem(goodsId = "200006", goodsName = "胡萝卜丁")) + add(GoodsItem(goodsId = "200007", goodsName = "黄瓜丝")) + add(GoodsItem(goodsId = "200008", goodsName = "黄瓜片")) + add(GoodsItem(goodsId = "200009", goodsName = "黄瓜丁")) + add(GoodsItem(goodsId = "200010", goodsName = "洋葱丝")) + add(GoodsItem(goodsId = "200011", goodsName = "洋葱丁")) + add(GoodsItem(goodsId = "200012", goodsName = "A")) + add(GoodsItem(goodsId = "200013", goodsName = "B")) + add(GoodsItem(goodsId = "200014", goodsName = "C")) + add(GoodsItem(goodsId = "200015", goodsName = "D")) + add(GoodsItem(goodsId = "200016", goodsName = "E")) + add(GoodsItem(goodsId = "200017", goodsName = "F")) + add(GoodsItem(goodsId = "200018", goodsName = "G")) + add(GoodsItem(goodsId = "200019", goodsName = "H")) + } private val adapter = FoodAdapter(list).apply { + isStateViewEnable = true setOnItemClickListener { _, _, position -> list[position].isClicked = true notifyItemChanged(position) @@ -92,18 +109,18 @@ class FoodSearchDialog( addOnActionSearchListener { searchGoods(this) } } - // RecyclerView 初始化及滑动冲突处理 + // RecyclerView 初始化 binding.recyclerView.run { layoutManager = GridLayoutManager(activity, 2, GridLayoutManager.VERTICAL, false) adapter = this@FoodSearchDialog.adapter - addOnScrollListener(object : RecyclerView.OnScrollListener() { - override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) { - super.onScrolled(rv, dx, dy) - // 解决 RecyclerView 与 SmartRefreshLayout 滑动冲突 - val topRowVerticalPosition = if (rv.isEmpty()) 0 else rv.getChildAt(0).top - binding.refreshLayout.setNestedScrollingEnabled(topRowVerticalPosition >= 0) - } - }) +// addOnScrollListener(object : RecyclerView.OnScrollListener() { +// override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) { +// super.onScrolled(rv, dx, dy) +// // 解决 RecyclerView 与 SmartRefreshLayout 滑动冲突 +// val topRowVerticalPosition = if (rv.isEmpty()) 0 else rv.getChildAt(0).top +// binding.refreshLayout.setNestedScrollingEnabled(topRowVerticalPosition >= 0) +// } +// }) } binding.refreshLayout.run { @@ -118,13 +135,51 @@ class FoodSearchDialog( initObserver() - // 若有默认食材名称,自动填充并触发搜索 - //if (!defGoodsName.isNullOrBlank()) { - binding.etSheetInput.setText(defGoodsName) - pageNo = 1 - getGoodsList() - KeyboardUtil.hideKeyboard(binding.root) - //} +// // 若有默认食材名称,自动填充并触发搜索 +// //if (!defGoodsName.isNullOrBlank()) { +// binding.etSheetInput.setText(defGoodsName) +// pageNo = 1 +// getGoodsList() +// KeyboardUtil.hideKeyboard(binding.root) +// //} + } + + /** 弹窗显示时在 Window.Callback 层提前拦截触摸,解决 BottomSheet 拖拽与列表滑动的手势冲突 */ + override fun show() { + super.show() + // Window.Callback.dispatchTouchEvent 在整个 View 树的 onInterceptTouchEvent 之前执行, + // 是最早能感知触摸的时机。在此处判断触摸点位置来控制 behavior.isDraggable, + // 从而确保 BottomSheetBehavior 在 CoordinatorLayout.onInterceptTouchEvent 时 + // 已经是禁用状态,不会拦截列表区域的滑动手势。 + val originalCallback = window?.callback ?: return + window?.callback = object : Window.Callback by originalCallback { + override fun dispatchTouchEvent(event: MotionEvent): Boolean { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + val rect = Rect() + binding.refreshLayout.getGlobalVisibleRect(rect) + // 触摸点落在列表区域内则禁止 BottomSheet 拖拽 + behavior.isDraggable = !rect.contains(event.rawX.toInt(), event.rawY.toInt()) + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + behavior.isDraggable = true + } + } + return originalCallback.dispatchTouchEvent(event) + } + + override fun onPointerCaptureChanged(hasCapture: Boolean) { + originalCallback.onPointerCaptureChanged(hasCapture) + } + + override fun onProvideKeyboardShortcuts( + data: List?, + menu: Menu?, + deviceId: Int + ) { + originalCallback.onProvideKeyboardShortcuts(data, menu, deviceId) + } + } } /** @@ -179,9 +234,12 @@ class FoodSearchDialog( private fun loadGoodsList(records: MutableList?) { finishRefresh() if (records.isNullOrEmpty()) { - activity.toast("暂未搜索到食材信息") + if (pageNo == 1) { + loadEmptyView() + } return } + binding.recyclerView.layoutManager = GridLayoutManager(activity, 2, GridLayoutManager.VERTICAL, false) if (pageNo == 1) list.clear() list.addAll(records) adapter.notifyDataSetChanged() @@ -194,4 +252,36 @@ class FoodSearchDialog( if (pageNo == 1) binding.refreshLayout.finishRefresh() else binding.refreshLayout.finishLoadMore() } + + private var emptyViewBinding: LayoutEmptyViewBinding? = null + + @SuppressLint("NotifyDataSetChanged") + private fun loadEmptyView() { + try { + binding.refreshLayout.setEnableRefresh(true) + binding.refreshLayout.setEnableLoadMore(false) + list.clear() + adapter.notifyDataSetChanged() + if (emptyViewBinding == null) { + emptyViewBinding = LayoutEmptyViewBinding.inflate( + LayoutInflater.from(activity), + binding.recyclerView, + false + ) + } + emptyViewBinding!!.tvContent.text = "暂无数据" + emptyViewBinding!!.tvSubContent.text = "未查询到食材信息,请联系管理员添加" + emptyViewBinding!!.root.layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + binding.recyclerView.layoutManager = LinearLayoutManager(activity) + binding.recyclerView.post { + emptyViewBinding!!.root.minimumHeight = binding.recyclerView.height + adapter.stateView = emptyViewBinding!!.root + } + } catch (e: Exception) { + e.printStackTrace() + } + } } diff --git a/app/src/main/java/com/shuwei/dish/match/dialog/SeasoningSelectDialog.kt b/app/src/main/java/com/shuwei/dish/match/dialog/SeasoningSelectDialog.kt index 214aa34..588c57f 100644 --- a/app/src/main/java/com/shuwei/dish/match/dialog/SeasoningSelectDialog.kt +++ b/app/src/main/java/com/shuwei/dish/match/dialog/SeasoningSelectDialog.kt @@ -13,14 +13,17 @@ import android.view.View import android.view.ViewGroup import android.view.Window import android.view.WindowManager +import android.widget.FrameLayout import androidx.core.widget.addTextChangedListener import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.bottomsheet.BottomSheetDialog import com.shuwei.dish.match.R import com.shuwei.dish.match.adapter.SeasoningSearchAdapter import com.shuwei.dish.match.base.BaseActivity import com.shuwei.dish.match.databinding.DialogSeasoningSelectBinding +import com.shuwei.dish.match.databinding.LayoutEmptyViewBinding import com.shuwei.dish.match.entity.GoodsItem import com.shuwei.dish.match.entity.SeasoningEntity import com.shuwei.dish.match.net.UiState @@ -48,10 +51,11 @@ class SeasoningSelectDialog( private val binding = DialogSeasoningSelectBinding.inflate(LayoutInflater.from(activity)) private val list = mutableListOf() private val adapter = SeasoningSearchAdapter(list).apply { - setOnItemClickListener { _, _, position -> + isStateViewEnable = true + setOnItemClickListener { _, v, position -> list[position].isClicked = true notifyItemChanged(position) - Handler(Looper.getMainLooper()).postDelayed({ + v.postDelayed({ onItemSelected(list[position]) dismiss() }, 300) @@ -121,6 +125,9 @@ class SeasoningSelectDialog( is UiState.Error -> { activity.toast(state.msg) finishRefresh() + if (pageNo == 1) { + loadEmptyView() + } } is UiState.Idle -> {} } @@ -195,9 +202,13 @@ class SeasoningSelectDialog( private fun loadGoodsList(records: MutableList?) { finishRefresh() if (records.isNullOrEmpty()) { - activity.toast("暂未搜索到调料信息") + //activity.toast("暂未搜索到调料信息") + if (pageNo == 1) { + loadEmptyView() + } return } + binding.recyclerView.layoutManager = GridLayoutManager(activity, 3, GridLayoutManager.VERTICAL, false) if (pageNo == 1) list.clear() list.addAll(records.map { it.toSeasoningEntity() }) adapter.notifyDataSetChanged() @@ -210,4 +221,37 @@ class SeasoningSelectDialog( if (pageNo == 1) binding.refreshLayout.finishRefresh() else binding.refreshLayout.finishLoadMore() } + + + private var emptyViewBinding: LayoutEmptyViewBinding? = null + + @SuppressLint("NotifyDataSetChanged") + private fun loadEmptyView() { + try { + binding.refreshLayout.setEnableRefresh(true) + binding.refreshLayout.setEnableLoadMore(false) + list.clear() + adapter.notifyDataSetChanged() + if (emptyViewBinding == null) { + emptyViewBinding = LayoutEmptyViewBinding.inflate( + LayoutInflater.from(activity), + binding.recyclerView, + false + ) + } + emptyViewBinding!!.tvContent.text = "暂无数据" + emptyViewBinding!!.tvSubContent.text = "未查询到调料信息,请联系管理员添加" + emptyViewBinding!!.root.layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + binding.recyclerView.layoutManager = LinearLayoutManager(activity) + binding.recyclerView.post { + emptyViewBinding!!.root.minimumHeight = binding.recyclerView.height + adapter.stateView = emptyViewBinding!!.root + } + } catch (e: Exception) { + e.printStackTrace() + } + } } diff --git a/app/src/main/java/com/shuwei/dish/match/ui/FoodRecognizeActivity.kt b/app/src/main/java/com/shuwei/dish/match/ui/FoodRecognizeActivity.kt index 47e869f..9f3ea2c 100644 --- a/app/src/main/java/com/shuwei/dish/match/ui/FoodRecognizeActivity.kt +++ b/app/src/main/java/com/shuwei/dish/match/ui/FoodRecognizeActivity.kt @@ -352,6 +352,10 @@ class FoodRecognizeActivity : BaseActivity() { selectedPosition = 0 } adapter.notifyDataSetChanged() + if (list.isNotEmpty()) { + binding.rvRecognizeList.visible() + binding.tvNoData.gone() + } updateRvParentLayoutHeight() binding.tvSelectedFood.text = item.goodsName ?: "-" updateListVisibility() diff --git a/scale包架构分析.md b/scale包架构分析.md new file mode 100644 index 0000000..404a2da --- /dev/null +++ b/scale包架构分析.md @@ -0,0 +1,194 @@ +# scale 包架构分析 + +## 概览 + +scale 包负责多设备秤数据的采集、发现、传输与聚合,采用 **mDNS + UDP 双路冗余发现 + WebSocket 长连接**的架构。 + +根据设备角色(MASTER/SLAVE)运行不同的服务组合: + +| 类 | 子设备 | 主设备 | +|---|:---:|:---:| +| MdnsRegisterManager | ✅ | ✅ | +| ScaleWebSocketServer | ✅ | ✅ | +| UdpBroadcastSender | ✅ | ❌ | +| MdnsDiscoveryManager | ❌ | ✅ | +| UdpBroadcastReceiver | ❌ | ✅ | +| ScaleWebSocketClient | ❌ | ✅ | +| ScaleDataAggregator | ❌ | ✅ | + +--- + +## 整体数据流 + +``` +子设备 主设备 + │ │ + ├─ UdpBroadcastSender ──UDP广播(8766)──▶ UdpBroadcastReceiver ─┐ + ├─ MdnsRegisterManager ──mDNS注册──▶ MdnsDiscoveryManager ─┤ + │ │ │ + │ onDeviceFound │ + │ │ │ + ├─ ScaleWebSocketServer ◀──WS连接(8765)── ScaleWebSocketClient ◀┘ + │ │ 秤数据推送 │ + │ └──────────────────────────────▶ ScaleDataAggregator + │ │ + │ StateFlow → UI + │ + │ ◀── ScaleCommand(清零指令)─────────────────┤ + │ ◀── ScaleEvent(配置同步)──────────────────┤ + │ ──▶ ScaleEvent(调料添加通知)──────────────▶│ +``` + +--- + +## 数据模型(3个) + +### ScaleData +单个秤的数据快照,是整个包内流转的核心数据结构。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| deviceId | String | 所属设备 ID | +| address | Int | 秤硬件地址编号 | +| weight | Double | 重量(克) | +| state | Int | 1=稳定,0=不稳定,2=量程溢出 | +| ts | Long | 数据时间戳(毫秒) | +| ip | String | 所属设备 IP,用于 UI 展示;网络传输数据中可能为空 | +| name | String? | 秤槽位名称(可选),默认为 null | + +### ScaleEvent +主子设备之间的**非重量类**通知,通过 WebSocket 传输。 + +| 事件类型 | 方向 | 说明 | +|---|---|---| +| `seasoning_added` | 子设备 → 主设备 | 某秤检测到调料添加,携带 `delta`(重量变化量) | +| `seasoning_config` | 主设备 → 子设备 | 调料槽位配置同步,携带 `List` | + +### ScaleCommand +主设备向子设备发送的**控制指令**,子设备收到后校验 `deviceId` 是否匹配自身再执行。 + +| 指令 | 说明 | +|---|---| +| `tare` | 清零指定地址的秤 | + +--- + +## 设备发现(4个) + +采用 mDNS + UDP 双路冗余,任意一路发现子设备均可触发连接。 + +### MdnsRegisterManager(主设备和子设备均运行) +将本机 WebSocket 服务以 mDNS 形式注册到局域网。 + +- 服务名格式:`DishMatch-{deviceId}` +- 服务类型:`_dishmatch._tcp.` +- 端口:`8765` + +### MdnsDiscoveryManager(仅主设备运行) +持续扫描局域网中所有 `DishMatch-*` 的 mDNS 服务,解析出 IP:PORT 后触发 `onDeviceFound`。 + +> **关键细节**:Android `NsdManager.resolveService` 不支持并发调用,多台子设备同时被发现时会报 `FAILURE_ALREADY_ACTIVE(3)`。内部使用串行队列(`resolveQueue`)逐一解析,避免解析失败。 + +### UdpBroadcastSender(仅子设备运行) +每 5 秒向 `255.255.255.255:8766` 广播一个 JSON 包,作为 mDNS 的兜底发现机制。 + +广播包结构: +```json +{ "deviceId": "xxx", "ip": "192.168.1.x", "port": 8765 } +``` + +### UdpBroadcastReceiver(仅主设备运行) +监听 `8766` 端口,接收子设备的 UDP 广播包,解析后触发 `onDeviceFound`。 + +- 内部用 `knownDevices`(`ConcurrentHashMap`)缓存已发现的设备,避免每 5 秒重复触发连接 +- 设备断线时需调用 `removeDevice()` 清除缓存,才能在重连时重新触发 `onDeviceFound` + +--- + +## 数据传输(2个) + +### ScaleWebSocketServer(主设备和子设备均运行) +基于 `java-websocket` 的服务端,监听 `8765` 端口。 + +**职责:** +- 监听本机 `WeightUtil` 回调,将秤数据实时推送给所有已连接客户端(节流 100ms) +- 新客户端连接时,立即推送所有秤的最新快照(`latestData` 缓存) +- 接收主设备下发的 `ScaleCommand`(清零)和 `ScaleEvent`(配置同步) +- 通过 `connectionCount` 计数器跟踪连接数,避免 `connections` 集合竞态问题 + +### ScaleWebSocketClient(仅主设备运行) +管理主设备与多台子设备的 WebSocket 长连接。 + +**职责:** +- 多设备并发连接(`ConcurrentHashMap` 管理) +- 断线自动重连(指数退避:2s → 4s → 8s → ... → 30s) +- 向指定设备或全部设备发送指令/事件 +- 子设备首次连接成功时触发 `onDeviceConnected`,供主设备推送全量配置 + +--- + +## 数据聚合(1个) + +### ScaleDataAggregator(仅主设备运行) +将本机秤和所有子设备秤的数据统一汇总,以 `StateFlow` 暴露给 UI 层。 + +- **本机秤**:直接监听 `WeightUtil` 回调,无需经过网络 +- **子设备秤**:由 `ScaleWebSocketClient.onScaleData` 回调写入 +- **Map key 格式**:`{deviceId}#{address}`,便于 UI 按设备分组展示 +- **IP 回填**:若数据包先于 IP 信息到达,`setDeviceIp()` 会回填已缓存数据中的空 IP 字段 + +--- + +## 配置(1个) + +### ScaleDeviceConfig +硬编码各子设备的固定 UUID 和秤地址显示顺序。 + +| 常量 | 说明 | +|---|---| +| `DEVICE_ID_2/22/18/1` | 各子设备固定 UUID | +| `SCALE_ORDER_22` | 22个秤的物理位置排列顺序 | +| `SCALE_ORDER_18` | 18个秤的物理位置排列顺序 | +| `DEVICE_ORDER` | 设备在列表中的显示顺序 | + +--- + +## 门面(1个) + +### ScaleServiceManager +整个包的统一入口(单例),根据设备角色决定启动哪些服务,并将各组件串联起来。 + +**外部使用方式:** +```kotlin +// Application.onCreate() +ScaleServiceManager.start(context) + +// 主设备 UI 订阅全量秤数据 +ScaleServiceManager.allScales?.collect { scales -> ... } + +// 主设备发送清零指令 +ScaleServiceManager.sendTare(deviceId, address) + +// 主设备广播调料配置 +ScaleServiceManager.sendSeasoningConfig(slots) + +// 主设备监听子设备秤事件(如调料添加) +ScaleServiceManager.onScaleEvent = { event -> ... } + +// 子设备监听主设备下发的调料配置同步 +ScaleServiceManager.onSeasoningConfig = { event -> ... } + +// 子设备向主设备广播秤事件 +ScaleServiceManager.broadcastEvent(event) + +// 子设备监听主设备连接状态变化 +ScaleServiceManager.onMasterConnectionChanged = { connected -> ... } + +// 子设备查询当前是否有主设备连接 +ScaleServiceManager.isMasterConnected + +// Application.onTerminate() +ScaleServiceManager.stop() +``` + +外部代码只需与 `ScaleServiceManager` 交互,无需感知内部任何组件。