Author SHA1 Message Date
lvmeng 7a75e943db fix(ui): 优化菜品制作界面布局和称重功能
- 调整 activity_prepare_cook.xml 中的边距和布局参数
- 更新称重显示文本为“称重:0g”并调整样式
- 在 BaseActivity 中将 loading 显示和隐藏操作移到主线程执行
- 为称重按钮添加防抖点击监听器和清零功能
- 在食物识别过程中添加加载提示和调试日志
- 修复食物识别流程中的加载框显示和隐藏逻辑
- 添加识别结果页面加载功能和取消判断
2026-04-27 14:32:03 +08:00
lvmeng 634f23a76a style(ui): 优化食物识别界面布局和对话框标题
- 将食物照片区域包装在 CardView 中并调整样式
- 为食材列表容器添加滚动指示条功能
- 替换 RecyclerView 的滚动条为自定义指示条
- 更新多个对话框的标题文本以提高一致性
- 在准备烹饪界面添加重量显示文本
- 优化适配器数据更新和列表滚动逻辑
- 防止 StateFlow 重放导致的重复数据加载
- 添加新的滚动条绘制资源文件
2026-04-27 13:50:56 +08:00
lvmeng b110cc97a0 refactor(collect): 重构食材采集功能界面和适配器
- 将按钮组件替换为TextView以支持更好的交互效果
- 添加selectableItemBackground实现点击反馈效果
- 重命名食材采样为食材采集保持术语一致性
- 替换FoodCollectionAdapter为VectorCollectionAdapter适配器
- 更新列表项布局从list_item_food_collection到list_item_vector_collection
- 添加FoodSearchAdapter适配器用于食材搜索功能
- 重构CollectFragment中的数据绑定和UI逻辑
- 修改相机容器布局位置优化界面结构
- 更新资源文件名和颜色定义提升代码可读性
- 调整保存按钮样式增强视觉效果
- 修改提示对话框内容精确表达功能意图
2026-04-27 10:21:37 +08:00
lvmeng fda88fff33 feat(ui): 添加地址显示功能到调味品网格适配器
- 在 Seasoning22GridAdapter 中启用地址显示
- 在 Seasoning18GridAdapter 中启用地址显示
- 更新点击事件以显示地址对话框
- 为网格项目添加地址显示支持
2026-04-27 08:57:30 +08:00
lvmeng c4fbf9dcd6 feat(ui): 优化通用弹窗样式并修复初始化清零逻辑
- 重构 CommonDialog 弹窗视觉效果:新增带阴影投影的背景、缩放淡入淡出动画、取消/确认按钮主次色区分
- 修复 setCancelable 调用位置错误(原写在 window.run 内不生效)
- 新增弹窗按钮圆角 ripple drawable,防止涟漪溢出圆角
- InitActivity 启动时对净材秤和熟食秤执行清零操作
- PrepareCookActivity 弹窗补充内容文案
- 修正 activity_dish_sampling.xml 中 MaterialDivider 颜色属性写法
2026-04-24 18:03:43 +08:00
lvmeng a2f8fb903a refactor(http): 移除旧的HTTP客户端配置并整合到网络管理器中
- 删除了废弃的HttpClient.kt文件中的自定义HTTP客户端实现
- 将基础URL配置从UrlConfig迁移到GlobalData统一管理
- 更新HttpManager使用GlobalData.appBaseUrl作为基础URL
- 移除了HttpUtil.kt文件中的遗留HTTP工具类
- 简化了网络层架构,移除重复的配置类
2026-04-24 17:23:27 +08:00
lvmengandClaude Sonnet 4.6 13fc7c7e0f fix(ui): 修复22格高宽比调整后列表高度显示不全
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 17:14:19 +08:00
lvmeng fed161b088 style(adapter): 更新网格适配器高亮样式
- 将 setBackgroundColor 替换为 setBackgroundResource 使用形状资源
- 为 Seasoning18GridAdapter 和 Seasoning22GridAdapter 统一高亮样式
- 添加 shape_scale_cell_highlight.xml 定义圆角橙色背景
- 移除硬编码颜色值,提高样式可维护性
2026-04-24 16:23:15 +08:00
lvmengandClaude Sonnet 4.6 594c551a98 fix(activity): 修复调料配置删除后子设备未同步问题
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 11:28:59 +08:00
lvmeng 617ccfb456 feat(dialog): 调料选择对话框增加滑动冲突处理和重量传递功能
- 修改 dialog_seasoning_select.xml 布局高度为 match_parent 并添加标题文本
- 调整 SmartRefreshLayout 高度限制并移除 RecyclerView 内边距
- 在 FoodRecognizeActivity 中新增 currentWeight 参数传递机制
- 移除 WeightUtil 监听器相关代码并初始化当前重量值
- 在 PrepareCookActivity 中传递当前重量到识别页面
- 从 SeasoningConfigFragment 和 SeasoningSelectDialog 中移除无用参数
- 添加 Window.Callback 触摸事件拦截解决 RecyclerView 滑动冲突
- 实现触摸点位置检测动态控制 BottomSheet 拖拽行为
2026-04-24 10:07:11 +08:00
lvmengandClaude Sonnet 4.6 f3d57177b8 feat(activity): 制作/提交按钮增加二次确认弹窗,调整弹窗标题颜色及列表项背景属性
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 17:41:26 +08:00
lvmengandClaude Sonnet 4.6 267628411a fix(viewmodel): 修复切换 tab 返回后数据消失及 loading 不消失的问题
切换 tab 时重置对方的 StateFlow 状态为初始值,避免 repeatOnLifecycle
重启后 replay 残留状态导致 UI 异常覆盖或 loading 无法关闭

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 17:41:12 +08:00
lvmeng 58fb78c7bd feat(activity): 新增已采集食材列表页,支持搜索、删除及空视图展示 2026-04-23 16:51:57 +08:00
lvmeng 9f0671410f feat(ui): 将采集页红色主题图标统一替换为绿色 2026-04-23 14:39:49 +08:00
lvmengandClaude Sonnet 4.6 0243fe0dc8 refactor(viewmodel): 将回调方式统一改为 StateFlow + UiState,消除竞态问题
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:01:24 +08:00
lvmengandClaude Sonnet 4.6 df0580d68f refactor(viewmodel): 统一 DbViewModel 初始化并拆分 DAO 结构
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 11:58:14 +08:00
lvmengandClaude Sonnet 4.6 deab289747 refactor(rename): 将 AppRepository 重命名为 DbRepository,AppViewModel 重命名为 DbViewModel
- DatabaseRepository.kt → DbRepository.kt,类名 AppRepository → DbRepository
- AppViewModel.kt → DbViewModel.kt,类名 AppViewModel → DbViewModel
- VMFactory 构造参数与创建类型同步更新
- 所有引用文件(UI 层、Fragment、ViewModel 工厂)批量更新 import 与使用处

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 11:06:59 +08:00
lvmeng debc7a0740 refactor(viewmodel): 封装调料槽位读写至 AppViewModel,消除页面直接调用 DAO 的情况 2026-04-23 10:54:59 +08:00
lvmeng 441cffe4da fix(submit): 移除调料数据为空的检查限制
- 注释掉调味料数据为空时的验证逻辑
- 允许在没有调料信息的情况下继续提交流程
- 保持原有的数据转换和追加功能不变
2026-04-23 10:31:02 +08:00
lvmeng 3581975ab0 refactor(viewmodel): 新增 seasoningSlotState 替代 loadSeasoning,将 getSamplingList 迁移至 StateFlow 2026-04-23 10:29:17 +08:00
lvmeng 9e99e6ef6d refactor(viewmodel): 将 searchFoodList/getCookFoodList 迁移至 StateFlow,DishListFragment 改用 combine 并行合并数据并修复分页本地数据混入问题 2026-04-23 09:54:34 +08:00
lvmengandClaude Sonnet 4.6 16fe64e9fe refactor(viewmodel): 修复 loadSeasoning 防重入缺陷并移除 BaseActivity 网络包装方法
- AppViewModel.loadSeasoning 用 Mutex 替换 isProcessing 标志位,避免异常时状态永久锁死
- 删除 BaseActivity 中 queryGoodsList / querySeasoningList 包装方法,减少隐式依赖
- FoodSearchDialog / SeasoningSearchDialog 改为直接调用 activity.netViewModel.xxx()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 08:56:13 +08:00
lvmengandClaude Sonnet 4.6 75830b9204 refactor(network): 引入 RemoteRepository 隔离网络层,AppRepository 支持调料槽位查询
- 新增 RemoteRepository,将 NetViewModel 中直接调用 apiService 的逻辑下沉至仓库层
- NetViewModel 改为依赖注入 RemoteRepository,不再直接接触 apiService
- AppRepository 构造函数增加 SeasoningSlotDao 参数,新增 hasSeasoningSlotConfig() 方法
- AppViewModel 新增 hasSeasoningSlotConfig() 方法,明确区分"调料数据"与"槽位配置"语义
- 同步更新 6 个 Activity 的 AppFactory 构造调用,传入 seasoningSlotDao

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 18:31:38 +08:00
lvmeng 0498501b33 style(ui): 统一界面组件样式并优化设置页面交互
- 为所有列表项和设置面板应用统一的白色圆角背景样式
- 为可点击区域添加波纹效果提升用户体验
- 修复布局文件中ID命名不规范的问题
- 在设置页面添加右上角功能按钮并实现跳转逻辑
- 导入页面跳转扩展函数支持快速导航到主秤配置页面
- 优化布局参数确保界面元素正确显示和交互响应
2026-04-22 17:35:26 +08:00
lvmeng 8c709a3f5f fix(fragment): 修复 CollectFragment 首次打开相机预览不显示的问题 2026-04-22 17:09:33 +08:00
lvmeng 9ba6443f4f refactor(setting): 重构设置页面界面和功能结构
- 将底部导航菜单替换为三个独立的功能卡片布局
- 添加餐品模式、调料区设置和食材采样功能入口
- 移除原有的Fragment容器和标签页切换逻辑
- 更新CollectFragment以使用新的SingleFragmentActivity架构
- 修改相机权限检查逻辑并添加权限拒绝提示对话框
- 简化DeviceConfigFragment和SeasoningConfigFragment的依赖导入
- 在设置页面添加当前模式显示和跳转功能
- 重构调料配置页面的日志记录和高亮逻辑
2026-04-22 14:55:09 +08:00
lvmeng 04e4efe1fc feat(activity): 新增 SingleFragmentActivity 通用 Fragment 容器页 2026-04-22 13:39:52 +08:00
lvmengandClaude Sonnet 4.6 bce9675b0f feat(activity): SubmitFoodActivity 支持制作中菜品调料历史数据加载与新增叠加
- SeasoningWeightAdapter 改继承 BaseQuickAdapter
- item_seasoning_weight 左右间距改为 32dp
- isCooking=true 时从数据库查询 goodsList 并预填充调料历史数据
- refreshAdapterByName 叠加历史用量,修复新增用量覆盖历史数据问题
- 返回键拦截,弹窗提示保存数据

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 11:47:13 +08:00
lvmeng f25108360b feat(ui): CommonDialog 支持垂直3按钮布局(iOS风格) 2026-04-22 11:39:37 +08:00
lvmeng 4ec02ac2bc feat(MasterScaleActivity): 添加秤名称显示功能
- 在UI界面上增加tvName文本视图用于显示秤的名称
- 当秤名称不为空时显示实际名称,否则显示默认值"-"
- 优化了秤信息展示的完整性,补充了名称字段的显示逻辑
2026-04-22 09:34:04 +08:00
lvmeng 97b357850c refactor(layout): 优化布局尺寸计算逻辑并提取公共工具类
- 添加 SizeTool 工具类统一管理 Flexbox 布局尺寸计算
- 在 MasterScaleActivity 中使用 SizeTool 替代硬编码计算
- 在 SeasoningConfigFragment 中使用 SizeTool 重构尺寸计算
- 在 SlaveActivity 中应用统一的尺寸计算方法
- 更新 Seasoning18GridAdapter 和 Seasoning22GridAdapter 使用现代化布局参数设置
- 调整 fragment_seasoning_config.xml 中的边距和内边距数值
- 为布局文件添加 tools:itemCount 属性支持预览功能
2026-04-22 09:28:07 +08:00
lvmengandClaude Sonnet 4.6 b6b5088107 feat(ui): 新增油盆配置区,子设备1调料名改为动态配置
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 18:18:22 +08:00
lvmeng bbdebc0c9c feat(activity): 新增同名调料用量聚合合并展示逻辑
- SubmitFoodActivity 新增 rawWeightMap 记录各秤槽位原始用量
- 每次秤数据更新后按 goodsName 聚合求和,同名调料只展示一条
- SeasoningWeightAdapter 改为按 goodsName 作为唯一标识匹配更新/移除
- SlaveActivity 简化高亮触发条件判断
2026-04-21 17:50:02 +08:00
lvmeng 0a90a8b368 feat(activity): 重构 SubmitFoodActivity 调料展示,改用子设备实时秤数据 2026-04-21 17:08:34 +08:00
lvmeng 07eb626e12 feat(layout): 更新秤数据列表项布局样式
- 将布局高度从120dp增加到340dp
- 调整布局方向为垂直排列并居中对齐
- 添加商品名称显示文本框
- 调整字体大小和颜色配置
- 修改权重布局为包裹内容模式
- 更新内边距设置以优化显示效果
2026-04-21 16:57:55 +08:00
lvmeng 736e4820dd fix(fragment): 修复 triggerHighlight 因设备判断用 != 导致主设备秤误触发调料格子高亮 2026-04-21 16:52:23 +08:00
lvmengandClaude Sonnet 4.6 3fc54d5f52 refactor(ui): 重构 Scale2VH 使用 XML 布局,优化秤总览界面样式
- list_type_scale2.xml 替换动态格子为 include list_item_scale22_row,Scale2VH 删除 makeScaleCell/updateScaleCell/makeCellText
- list_type_scale1.xml 布局调整为垂直居中,新增 tvName 展示调料名称
- list_type_scale18/22.xml 移除分割线
- SeasoningSelectDialog 搜索列表改为3列,扩充模拟调料数据

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 15:54:04 +08:00
lvmengandClaude Sonnet 4.6 b16d7d19cd feat(scale): 实现调料槽位配置持久化及主从设备数据同步
- 新增 SeasoningSlotEntity / SeasoningSlotDao,独立存储格子调料配置
- AppDatabase 升级至版本 8,添加 MIGRATION_7_8
- ScaleEvent 新增 TYPE_SEASONING_CONFIG 和 SlotConfig,用于主从同步
- SeasoningConfigFragment 选择调料后写 Room 并通过 wsClient 推送给子设备
- ScaleWebSocketClient 新增 sendToAllSlaves / sendToDevice / onDeviceConnected
- ScaleWebSocketServer 扩展 onMessage 支持接收主设备下发的配置事件
- ScaleServiceManager 暴露 sendSeasoningConfig / onSeasoningConfig;子设备连接时自动推送全量配置
- MasterScaleActivity 启动时从 Room 加载 slotNameMap,注入 ScaleData.name 展示调料名
- SlaveActivity 新增 slotNameMap 防止重量回调覆盖调料名;监听 onSeasoningConfig 写 Room 并刷新 adapter
- Adapter 新增 tvAddress 和 showAddress 参数,仅 MasterScaleActivity 显示秤地址行;tvName 空值统一显示"-"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 15:27:28 +08:00
lvmengandClaude Sonnet 4.6 a81e914b63 refactor(adapter): 合并 Scale18/22GridAdapter 为 Seasoning18/22GridAdapter,统一支持调料配置和秤总览双模式
- ScaleData 新增可空 name 字段,用于承载调料名称
- Seasoning18/22GridAdapter 改用 ScaleData,新增 showNameOnly 参数区分页面模式
- showNameOnly=true(调料配置):仅显示名称,无名称显示"-"
- showNameOnly=false(秤总览):显示名称+重量+状态,无名称显示秤地址编号
- update() 恢复差量更新逻辑,避免 submitList 动画期间点击失效
- MasterScaleActivity、SlaveActivity 改用新 Adapter,删除旧 Scale18/22GridAdapter
- 删除 Scale22GridActivity 及其布局和 Manifest 注册
- list_item_scale22_row.xml 中 tvAddr 重命名为 tvName

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 11:59:59 +08:00
lvmeng ffdf364ee8 style(ui): 调料配置界面布局优化
- 添加了整体容器的圆角背景样式
- 新增了顶部标题区域,包含主标题和操作提示文字
- 统一调整了上下层调料区标题的文字大小为28sp
- 优化了RecyclerView的边距设置
- 改善了整体界面的视觉层次和用户体验
2026-04-21 10:51:34 +08:00
lvmengandClaude Sonnet 4.6 6d3bb96d61 fix(activity): 修复 SlaveActivity 负值清零后误触发高亮的问题
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 10:41:42 +08:00
lvmengandClaude Sonnet 4.6 da712ed25a fix(activity): 修复 FoodRecognizeActivity 称重更新导致 useWeight 异常的问题
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 10:11:10 +08:00
lvmeng 6b312ec689 fix(prepareCook): 修复食材选择和数据处理逻辑
- 修复了食材选择回调中的变量命名问题,统一使用 entity 替代 item
- 改进了食材名称解析逻辑,正确提取 WP 标识符和食材名称
- 为食材 ID 提取添加了边界情况处理,避免数组越界异常
- 添加了调试日志以便追踪食材查询结果
- 优化了食材列表数据结构的构建过程
2026-04-21 09:46:15 +08:00
lvmeng e175893790 refactor(FoodRecognizeActivity): 重构食材识别页面返回逻辑和数据传递机制
- 移除废弃的 Uri 导入并添加 Activity 兼容性支持
- 新增手动取消标识常量 IS_MANUAL_CANCEL 用于区分正常选择和手动取消
- 将启动方法参数 onResult 重命名为 finishCallback 并支持取消状态回调
- 使用 IntentCompat 替代已弃用的 getSerializableExtra 方法
- 在标题栏和返回键添加手动返回功能,统一处理页面关闭逻辑
- 修改 finishWithResult 方法支持传递取消状态,仅在非取消状态下传递选中数据
- 更新 PrepareCookActivity 中的重量阈值从 5 改为 15
- 重构识别控制变量,使用 showRecognizePage 和 manualCancelFlag 替代原有变量
- 调整页面生命周期中的识别启用逻辑,确保正确的状态管理
- 修改食材识别启动回调,集成手动取消状态处理机制
2026-04-21 09:29:26 +08:00
lvmengandClaude Sonnet 4.6 82217b4fb1 feat(activity): 合并采集功能至 PrepareCookActivity 并优化识别页重量处理逻辑
- 将 DishSamplingActivity 逻辑合并至 PrepareCookActivity,根据 cookMode 区分采集/制作模式
- 布局替换:activity_prepare_cook 第一子 View 改为搜索栏(etInputDish + ivDishSearch),删除第二个子 LinearLayout
- InitActivity / HomeActivity / SamplingListActivity 改用 PrepareCookActivity 替代 DishSamplingActivity
- FoodRecognizeActivity 新增 selectedPosition 字段,避免通过 isClicked 线性查找索引
- 优化重量更新逻辑:重量增加立即同步,重量减少延迟 3 秒更新,防止关页前误覆盖用量
- foodSelectCallback 改为累加同 goodsId 的 useWeight,不再限制重复添加

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 18:26:20 +08:00
lvmengandClaude Sonnet 4.6 72ecc8fcd6 fix(scale): 修复 RS485 多联秤地址 ≥ 10 时清零命令发送到错误设备的问题
- 新增 FixedSensorScale 继承 SensorScale,通过反射重写 zeroTwo/tareTwo
- 修复根因:SDK 将 int 地址直接拼接为十进制字符串再按十六进制解析,
  导致地址 10 发送字节 0x10(=16号秤)而非 0x0A(=10号秤)
- 修复方式:String.format("%02X", address) 确保正确的十六进制编码
- Weigher2 替换 mSensorScale 字段类型为 FixedSensorScale
- WeightUtil.tareTwo 移除 isTaring 防重复逻辑,简化为直接调用 zeroTwo

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 14:58:27 +08:00
lvmeng 99d670390a fix(scale): 解决称重设备清零功能问题
- 修复了ScaleServiceManager中的WeightUtil引用路径错误
- 添加了WebSocket服务器中设备ID验证失败的日志记录
- 修复了Weigher2中字符串比较的错误写法,使用equals方法替代==
- 更新了WeightUtil中清零操作的日志记录和异常处理机制
2026-04-20 10:01:05 +08:00
lvmeng d3b69c4e46 fix(fragment): 修复 weightSnapshot key 因 Int 溢出导致主设备高亮失效的问题
将 snapshotKey 从 Int(address + hashCode*1000 易溢出)改为 String(deviceId#address),
避免不同设备的基准值互相覆盖,导致 delta 永远无法正确计算
2026-04-20 09:08:50 +08:00
lvmeng 193acff1e0 feat(activity): 为子设备 SlaveActivity 添加调料秤重量增加高亮提醒功能
检测本机秤稳定后重量增加超过10g,高亮对应格子(红底白字),15秒后自动恢复
2026-04-20 09:08:44 +08:00
lvmeng 87bf11454c feat(fragment): 实现调料秤重量增加高亮提醒功能
主设备订阅 allScales StateFlow 检测所有子设备秤重量变化,
超过10g且秤稳定时高亮对应格子,15秒后自动恢复
2026-04-17 16:56:36 +08:00
lvmengandClaude Sonnet 4.6 768bde79a1 feat(fragment): 新增调料选择弹窗并绑定格子点击交互
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 15:43:11 +08:00
lvmeng c61b1c8ef7 feat(seasoning): 添加调料配置网格适配器功能
- 创建调料配置格子布局文件,仅显示调料名称
- 实现调料配置下层18格网格适配器,使用GridLayoutManager排列
- 实现调料配置上层22格网格适配器,使用FlexboxLayoutManager排列
- 添加不同行列的边距控制和字体大小自适应
- 实现按预设顺序更新数据的功能,空位用哨兵填充
- 预留点击回调接口,暂不处理点击事件
2026-04-17 14:23:57 +08:00
lvmeng 77df4a4d82 refactor(ui): 移除设备配置页面并重构调料配置界面
- 移除 DeviceConfigActivity 页面及相关 AndroidManifest 注册
- 将调料配置从单页网格改为上下两层 RecyclerView 结构
- 上层使用 FlexboxLayoutManager 显示 22 格调料位
- 下层使用 GridLayoutManager 显示 18 格调料位
- 更新存储工具类 SpTool 中烹饪模式的访问方式
- 修改首页和初始化页面中烹饪模式的读取逻辑
- 更新 WebSocket 客户端连接逻辑中的重复连接处理
2026-04-17 14:23:44 +08:00
lvmeng c35572dc41 feat(setting): 添加调料配置功能并优化界面布局
- 在设置页面添加调料配置按钮和设备切换功能
- 将设备配置页面标题从"设备配置"改为"设备切换"
- 新增SeasoningConfigFragment调料配置片段
- 将秤数据监控按钮可见性从gone改为visible
- 优化collectFragment布局加载方式
- 在DeviceConfigActivity中添加调试日志输出
- 重构SettingActivity中的按钮样式管理逻辑
- 调整fragment列表顺序和点击事件处理
- 修改列表项高度以改善界面显示效果
- 注释掉DeviceConfigFragment中未使用的viewModel相关代码
2026-04-17 11:45:08 +08:00
lvmeng 1ca25e6a1d fix(ui): 修复菜品清空图标显示和日志记录问题
- 将菜品清空图标从tools:src改为android:src以确保正常显示
- 在点击清空图标时添加菜品数据的JSON日志输出便于调试
- 移除isOriginalData状态重置避免影响菜品原始数据标记
2026-04-17 09:00:42 +08:00
lvmeng 7373ccacbb feat(activity): 新增食材识别页面,重构识别流程并优化多设备布局适配 2026-04-16 18:05:46 +08:00
lvmeng ad63639f66 refactor(dialog): 重构 CommonDialog 为链式调用 API,内置 title/content,全量替换旧调用方 2026-04-16 15:02:33 +08:00
lvmeng c3f3b1215b refactor(scale): 提取 Scale22/18GridAdapter 到 adapter 包,SlaveActivity 复用网格布局 2026-04-16 14:26:34 +08:00
lvmeng cba5bda757 fix(scale): 子设备断线时从聚合器移除数据,修复列表不消失的问题
- ScaleWebSocketClient 新增 onDeviceDisconnected 回调,在 onFailure/onClosed 时触发
- ScaleServiceManager 绑定回调,断线时调用 aggregator.removeDevice() 清除缓存
- 重连成功后数据重新推送,UI 列表自动恢复
2026-04-16 13:52:11 +08:00
lvmeng 7fd2f02b87 feat(activity): 为 MasterScaleActivity 各秤格子新增清零点击事件,修复高频数据更新导致点击失效的问题 2026-04-16 13:45:39 +08:00
lvmeng b775b20ff1 refactor(activity): 重构 MasterScaleActivity 多类型秤布局,新增 Scale22GridActivity
- MasterScaleActivity 使用 BaseMultiItemAdapter 支持4种 viewType(scale1/2/18/22)
- 内嵌 adapter 改用 BaseQuickAdapter + 自定义 VH,替换原 RecyclerView.Adapter
- 新增 Scale22GridActivity,使用 FlexboxLayoutManager 实现22格混合网格布局
- 新增 list_type_scale1/2/18/22.xml 及 list_item_scale22_row.xml 布局文件
- 删除废弃的 list_item_scale_data.xml
- 新增 flexbox 依赖,注册 Scale22GridActivity
2026-04-15 16:44:33 +08:00
lvmeng 5ac35a9cab refactor(activity): 将 ScaleServiceManager.stop() 迁移至 InitActivity.onDestroy,优化18格子布局间距 2026-04-15 09:52:16 +08:00
lvmeng 94e49eb344 fix(scale): 修复 mDNS 抖动导致子设备永久无法重连的问题 2026-04-14 18:25:20 +08:00
lvmeng 69dc77f06e refactor(activity): 优化 MasterScaleActivity 布局常量与显示逻辑 2026-04-14 16:12:28 +08:00
lvmeng b69b8c10e8 feat(activity): 重构 MasterScaleActivity 秤数据网格布局,按设备号适配不同显示方案 2026-04-14 14:00:13 +08:00
lvmeng 0477fad57b fix(activity): 修复 PrepareCookActivity 相机未就绪时触发拍照导致报错的问题 2026-04-14 10:29:38 +08:00
lvmengandClaude Sonnet 4.6 da7b5b09a0 refactor(activity): 统一使用 onBackPressedDispatcher.addCallback 替代 onBackPressed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 09:45:58 +08:00
lvmeng f1ffc67b9b refactor(dialog): 重构 FoodSearchDialog 和 SeasoningSearchDialog 继承 BottomSheetDialog 2026-04-14 09:17:10 +08:00
lvmeng 910759c99e refactor(PrepareCookActivity): 优化菜品识别逻辑和代码结构
- 移除未使用的 Bitmap 和 PreviewView 导入
- 添加 abs 函数导入用于重量比较计算
- 新增 WEIGHT_CHANGE_VALUE 和 WEIGHT_RECOGNIZE_VALUE 常量定义
- 实现重量变化阈值检测,避免频繁识别拍照
- 添加 lastWeight 变量记录上次重量值
- 将菜品选择逻辑提取为独立回调函数 foodSelectCallback
- 重命名 onItemClick 为 onFoodItemClick 并添加注释
- 为多个方法添加 KDoc 注释说明功能
- 在拍照失败回调中显示错误提示
- 优化菜品识别条件判断逻辑
2026-04-14 09:01:15 +08:00
lvmeng 428852a07e refactor(activity): 优化启动页网络检测逻辑,有网络时直接跳转并隐藏检测区域 2026-04-13 18:17:17 +08:00
lvmeng 96863e2078 feat(activity): 新增菜品识别功能,重构FoodRecognizeDialog并临时跳过调料配置检查
- 新增 FoodRecognizeDialog(继承 BottomSheetDialog),只初始化一次,暴露 loadData() 方法
- 新增 dialog_food_recognize.xml 布局(标题 + 2列 GridLayoutManager RecyclerView)
- PrepareCookActivity 集成相机初始化、拍照识别流程(重量稳定 ≥10g 时触发)
- PrepareCookActivity 新增 flCameraContainer(1dp 隐藏容器)用于相机预览绑定
- SelectDishActivity 临时注释 judgeDeviceConfig 逻辑,直接调用 block()
- 修正 GpioUtils/L/LightManager 包名为 com.shuwei.dish.match.utils
2026-04-13 17:31:31 +08:00
lvmeng 41036cdbf9 feat(layout): 更新主秤监控界面布局并启用PyTorch模型功能
- 将主秤监控界面从LinearLayout改为ConstraintLayout布局
- 添加返回按钮并实现点击关闭页面功能
- 在BaseApp中初始化ObjectBox数据库
- 启用PyTorch模型依赖库并移除注释代码
- 在CollectFragment中激活食物图像向量化功能
- 初始化FoodModule中的PyTorch模型
- 临时忽略assets下的.pt模型文件到.gitignore
2026-04-13 15:11:47 +08:00
lvmengandClaude Sonnet 4.6 0d0fcd24b7 refactor(activity): 优化启动页跳转延迟及子设备页面稳定性,修复导航栏闪烁问题
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 14:31:29 +08:00
lvmeng bb32ed84fe refactor(device): 优化设备角色判断和串口配置逻辑
- 移除 Weigher2 的初始化代码从 InitActivity 到 BaseApp
- 将设备角色判断与串口配置逻辑合并到应用启动流程中
- 主设备配置为 /dev/ttyS7,子设备配置为 /dev/ttyS4
- 简化设备角色分配的条件判断逻辑
2026-04-13 09:40:28 +08:00
lvmeng 6a1a1f3c5c feat(activity): 新增启动页网络检测功能,60秒倒计时后显示连接网络入口 2026-04-13 09:36:06 +08:00
lvmeng 9311d13b90 refactor(activity): 清理 InitActivity 中的注释代码,保留有效初始化逻辑 2026-04-13 09:08:05 +08:00
lvmeng 143d24923b chore(global): 更新 .gitignore,忽略 .idea、.claude、app/debug、app/release 目录 2026-04-13 09:06:48 +08:00
lvmeng 9a77733d24 fix(scale): 修复主设备无法发现子设备及IP显示未知的问题
- UdpBroadcastReceiver 补充 socket.broadcast=true,修复广播包被内核丢弃
- MdnsDiscoveryManager 改为串行解析队列,修复多设备并发解析报 FAILURE_ALREADY_ACTIVE
- ScaleWebSocketServer 推送数据时动态填充本机 IP,解决子设备 IP 始终显示未知
2026-04-13 09:00:51 +08:00
lvmeng c72d30e88a feat(scale): 新增 UDP 广播兜底发现机制并恢复开机自启原始方案
- 新增 UdpBroadcastSender/UdpBroadcastReceiver,子设备定时广播,主设备监听,与 mDNS 互为备份
- 恢复 BootReceiver 为直接启动 Activity 的原始方式,删除 BootService
- 移除 InitActivity 开机自启相关 Window flags
- 移除 PyTorch 依赖,缩减安装包体积
- 删除仓库中的 release apk 和 pt 模型文件
2026-04-10 18:04:18 +08:00
lvmeng 904ccdbb06 feat(boot): 新增前台服务中转实现开机自启动
使用 BootService 前台服务绕过 Android 10+ 后台启动 Activity 限制,
通过 setFullScreenIntent 触发 InitActivity,兼容主从设备差异。
同时优化 MasterScaleActivity、SettingActivity 及相关布局。
2026-04-09 18:20:50 +08:00
lvmengandClaude Sonnet 4.6 9ae0d709f2 fix(scale): 修复子设备IP收到数据后仍显示"未知"的问题
setDeviceIp 新增缓存回填逻辑:若设备已有缓存数据且 ip 为空,
立即用新 IP 更新所有相关条目并触发 StateFlow,
解决早期数据包在 IP 到达前入缓存导致 UI 无法更新的时序问题

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 18:19:07 +08:00
lvmengandClaude Sonnet 4.6 34adc0fe56 fix(scale): 修复主从设备IP显示异常及RecyclerView滑动崩溃
- 新增 NetworkUtil 统一获取本机IP,兼容 Android 12+(ConnectivityManager)和低版本(WifiManager)
- 主设备IP改为实时读取,修复App启动时网络未就绪导致IP缓存为空的问题
- MasterScaleActivity/SlaveActivity 关闭RecyclerView默认ItemAnimator,消除高频刷新时的闪烁
- BaseActivity.hideSystemBars 加防重入标志,避免重复调用setDecorFitsSystemWindows触发布局重绘导致滑动崩溃
- 新增 CrashHandler,崩溃时记录日志并自动重启应用

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 18:09:41 +08:00
lvmengandClaude Sonnet 4.6 29228b069b feat(scale): 新增主从设备秤数据联网同步功能
- 新增 ScaleWebSocketServer/Client、ScaleDataAggregator、ScaleServiceManager 等秤服务模块
- 新增 MasterScaleActivity(主设备秤数据总览,按设备分组展示IP+秤列表)
- 新增 SlaveActivity(子设备专属页面,显示IP、主设备连接状态及本机秤数据)
- SlaveActivity 改为横屏,MasterScaleActivity 保持竖屏
- 子设备连接状态通过 WebSocket onOpen/onClose 回调实时更新
- GlobalData 新增 DeviceRole 枚举,BaseApp 启动时初始化角色并启动秤服务
- SettingActivity 新增设备角色展示和秤数据监控入口
- Weigher2 支持外部设置串口路径,适配不同硬件子设备

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 17:15:48 +08:00
lvmeng d6a9c3562d refactor(net): 优化API请求错误处理和基础组件功能
- 添加JsonSyntaxException异常处理,提供更友好的错误提示
- 修复API响应判断逻辑,移除不必要的空值检查
- 优化HttpException转换为ApiException时的数据解析
- 移除BaseActivity中废弃的launch方法和相关变量
- 重构BaseActivity中的权限请求和页面跳转回调机制
- 更新Loading对话框的显示方法,支持自定义消息和内容更新
- 修复CollectFragment中的语法错误和日志记录功能
- 优化DishSamplingActivity中的页面跳转实现方式
- 完善SettingActivity中的相机权限检查逻辑
2026-03-30 15:28:52 +08:00
lvmeng 28c7628321 提交昨天的修改:优化多个模块和UI效果 2026-03-17 13:42:09 +08:00
lvmeng 3cf9818dad 优化SettingActivity底部菜单按钮效果:选中状态显示白色字体和圆角下划线 2026-03-17 13:40:54 +08:00
lvmeng c8eb2b8f24 ttyS4改为ttyS7,删除manifest中sharedUserId配置 2026-03-16 11:07:15 +08:00
203 changed files with 13355 additions and 3450 deletions
+6 -6
View File
@@ -1,15 +1,15 @@
*.iml *.iml
.gradle .gradle
/local.properties /local.properties
/.idea/caches /.idea/
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store .DS_Store
/build /build
/captures /captures
.externalNativeBuild .externalNativeBuild
.cxx .cxx
local.properties local.properties
.claude/
app/debug/
app/release/
# 临时忽略 assets 下的 .pt 模型文件,正式文件就绪后取消此行
app/src/main/assets/*.pt
-3
View File
@@ -1,3 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>
-18
View File
@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-01-28T08:24:39.059017600Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="Default" identifier="serial=192.168.1.80:5555;connection=d6ad651e" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
</component>
</project>
-13
View File
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>
-19
View File
@@ -1,19 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>
-10
View File
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>
-9
View File
@@ -1,9 +0,0 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>
-17
View File
@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>
+4
View File
@@ -0,0 +1,4 @@
kotlin version: 2.0.21
error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output:
1. Kotlin compile daemon is ready
+61 -26
View File
@@ -1,8 +1,14 @@
plugins { plugins {
alias(libs.plugins.android.application) // alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) // alias(libs.plugins.kotlin.android)
id("com.google.devtools.ksp") version "2.0.21-1.0.27" // id("com.google.devtools.ksp") version "2.0.21-1.0.27"
id("com.android.application")
id("org.jetbrains.kotlin.android")
// id("com.google.dagger.hilt.android")
id("com.google.devtools.ksp")
id("kotlin-parcelize")
kotlin("kapt")
} }
android { android {
@@ -24,7 +30,7 @@ android {
} }
// 设置输出APK文件名格式 // 设置输出APK文件名格式
setProperty("archivesBaseName", "菜品配比终端v${versionName}") setProperty("archivesBaseName", "pbc_v${versionName}")
} }
signingConfigs { signingConfigs {
create("debug_507") { create("debug_507") {
@@ -42,10 +48,12 @@ android {
"proguard-rules.pro" "proguard-rules.pro"
) )
signingConfig = signingConfigs.getByName("debug_507") signingConfig = signingConfigs.getByName("debug_507")
// buildConfigField("boolean", "DEBUG", "false")
} }
debug { debug {
//507扫码秤主板在android.buildTypes{}内增加 //507扫码秤主板在android.buildTypes{}内增加
signingConfig = signingConfigs.getByName("debug_507") signingConfig = signingConfigs.getByName("debug_507")
// buildConfigField("boolean", "DEBUG", "true")
} }
} }
compileOptions { compileOptions {
@@ -57,6 +65,7 @@ android {
} }
buildFeatures { buildFeatures {
viewBinding = true viewBinding = true
buildConfig = true
} }
packagingOptions { packagingOptions {
pickFirst("lib/armeabi-v7a/libserial_port.so") pickFirst("lib/armeabi-v7a/libserial_port.so")
@@ -74,7 +83,9 @@ android {
} }
// li // li
} }
val isDebug = gradle.startParameter.taskNames.any {
it.contains("debug", ignoreCase = true)
}
dependencies { dependencies {
implementation(fileTree("libs") { include("*.jar", "*.aar") }) implementation(fileTree("libs") { include("*.jar", "*.aar") })
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
@@ -86,32 +97,56 @@ dependencies {
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)
implementation(libs.androidx.activity.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation("com.google.code.gson:gson:2.10.1") implementation(libs.gson)
implementation("com.squareup.okhttp3:okhttp:4.9.1") implementation(libs.okhttp)
implementation("com.squareup.okhttp3:logging-interceptor:4.9.1") implementation(libs.logging.interceptor)
implementation("com.squareup.retrofit2:retrofit:2.9.0") implementation(libs.retrofit)
implementation("com.squareup.retrofit2:converter-gson:2.9.0") implementation(libs.java.websocket)
implementation(libs.converter.gson)
implementation(libs.flexbox)
implementation("io.github.cymchad:BaseRecyclerViewAdapterHelper4:4.1.2") implementation(libs.baserecyclerviewadapterhelper4)
implementation("androidx.room:room-runtime:2.5.2") // room
ksp("androidx.room:room-compiler:2.5.2") implementation(libs.androidx.room.runtime)
implementation("androidx.room:room-ktx:2.5.2") // Kotlin扩展 ksp(libs.androidx.room.compiler)
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") implementation(libs.androidx.room.ktx)
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7")
implementation("androidx.activity:activity-ktx:1.8.0")
// implementation("biz.laenger.android:vpbs:0.0.6") // implementation("biz.laenger.android:vpbs:0.0.6")
//必须 1.0.0 以上 // refresh-layout
implementation("io.github.scwang90:refresh-layout-kernel:3.0.0-alpha") implementation(libs.refresh.layout.kernel)
//核心必须依赖 implementation(libs.refresh.header.classics)
implementation("io.github.scwang90:refresh-header-classics:3.0.0-alpha") implementation(libs.refresh.header.radar)
//经典刷新头 implementation(libs.refresh.footer.classics)
implementation("io.github.scwang90:refresh-header-radar:3.0.0-alpha")
implementation("io.github.scwang90:refresh-footer-classics:3.0.0-alpha")
//经典加载
//implementation("com.geyifeng.immersionbar:immersionbar:3.2.2") //implementation("com.geyifeng.immersionbar:immersionbar:3.2.2")
}
// glide
implementation(libs.glide)
annotationProcessor(libs.compiler)
// camerax
implementation(libs.androidx.camera.core)
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.view)
implementation(libs.androidx.camera.extensions)
//pytorch
implementation (libs.pytorch.android)
implementation (libs.pytorch.android.torchvision)
// objectbox
if (isDebug) {
debugImplementation(libs.objectbox.android.objectbrowser)
} else {
releaseImplementation(libs.objectbox.android)
}
}
apply(plugin = "io.objectbox")
+49
View File
@@ -0,0 +1,49 @@
{
"_note1": "KEEP THIS FILE! Check it into a version control system (VCS) like git.",
"_note2": "ObjectBox manages crucial IDs for your object model. See docs for details.",
"_note3": "If you have VCS merge conflicts, you must resolve them according to ObjectBox docs.",
"entities": [
{
"id": "1:6465153867413579585",
"lastPropertyId": "4:3660727688686055762",
"name": "Food",
"properties": [
{
"id": "1:3688350375714699356",
"name": "id",
"type": 6,
"flags": 1
},
{
"id": "2:6503828433160216329",
"name": "name",
"type": 9
},
{
"id": "3:1726525909323356793",
"name": "foodIdx",
"type": 5
},
{
"id": "4:3660727688686055762",
"name": "foodVector",
"indexId": "1:5497952887792054610",
"type": 28,
"flags": 8
}
],
"relations": []
}
],
"lastEntityId": "1:6465153867413579585",
"lastIndexId": "1:5497952887792054610",
"lastRelationId": "0:0",
"lastSequenceId": "0:0",
"modelVersion": 5,
"modelVersionParserMinimum": 5,
"retiredEntityUids": [],
"retiredIndexUids": [],
"retiredPropertyUids": [],
"retiredRelationUids": [],
"version": 1
}
+46 -6
View File
@@ -1,11 +1,15 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools">
android:sharedUserId="android.uid.system"> <!-- android:sharedUserId="android.uid.system" -->
<uses-feature android:name="android.hardware.usb.host" /> <uses-feature android:name="android.hardware.usb.host" />
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_PRIVILEGED_PHONE_STATE" <uses-permission android:name="android.permission.READ_PRIVILEGED_PHONE_STATE"
tools:ignore="ProtectedPermissions" /> tools:ignore="ProtectedPermissions" />
@@ -41,7 +45,6 @@
<activity <activity
android:name=".ui.InitActivity" android:name=".ui.InitActivity"
android:exported="true" android:exported="true"
android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity"> tools:ignore="DiscouragedApi,LockedOrientationActivity">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
@@ -50,24 +53,29 @@
</intent-filter> </intent-filter>
</activity> </activity>
<activity android:name=".ui.HomeActivity" <activity android:name=".ui.HomeActivity"
android:theme="@style/Theme.DishMatch.NoSplash"
android:screenOrientation="portrait" android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity" /> tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<activity <activity
android:name=".ui.SelectDishActivity" android:name=".ui.SelectDishActivity"
android:theme="@style/Theme.DishMatch.NoSplash"
android:launchMode="singleTask" android:launchMode="singleTask"
android:screenOrientation="portrait" android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity" /> tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<activity <activity
android:name=".ui.PrepareCookActivity" android:name=".ui.PrepareCookActivity"
android:theme="@style/Theme.DishMatch.NoSplash"
android:screenOrientation="portrait" android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity" /> tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<activity <activity
android:name=".ui.DishSamplingActivity" android:name=".ui.DishSamplingActivity"
android:theme="@style/Theme.DishMatch.NoSplash"
android:screenOrientation="portrait" android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity" /> tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<activity <activity
android:name=".ui.SamplingListActivity" android:name=".ui.SamplingListActivity"
android:theme="@style/Theme.DishMatch.NoSplash"
android:launchMode="singleTask" android:launchMode="singleTask"
android:screenOrientation="portrait" android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity" /> tools:ignore="DiscouragedApi,LockedOrientationActivity" />
@@ -82,10 +90,42 @@
<!-- tools:ignore="DiscouragedApi,LockedOrientationActivity" />--> <!-- tools:ignore="DiscouragedApi,LockedOrientationActivity" />-->
<activity <activity
android:name=".ui.FoodSearchActivity" android:name=".ui.FoodSearchActivity"
android:theme="@style/Theme.DishMatch.NoSplash"
android:screenOrientation="portrait" android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity" /> tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<activity android:name="com.shuwei.dish.match.ui.DeviceConfigActivity" /> <!-- <activity android:name="com.shuwei.dish.match.ui.DeviceConfigActivity"-->
<activity android:name="com.shuwei.dish.match.ui.SubmitFoodActivity" /> <!-- android:theme="@style/Theme.DishMatch.NoSplash" />-->
<activity android:name="com.shuwei.dish.match.ui.SettingActivity"
android:theme="@style/Theme.DishMatch.NoSplash"
android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<activity android:name="com.shuwei.dish.match.ui.SubmitFoodActivity"
android:theme="@style/Theme.DishMatch.NoSplash" />
<activity
android:name="com.shuwei.dish.match.ui.MasterScaleActivity"
android:theme="@style/Theme.DishMatch.NoSplash"
android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<activity
android:name="com.shuwei.dish.match.ui.SingleFragmentActivity"
android:theme="@style/Theme.DishMatch.NoSplash"
tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<activity
android:name="com.shuwei.dish.match.ui.FoodRecognizeActivity"
android:theme="@style/Theme.DishMatch.NoSplash"
android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<activity
android:name="com.shuwei.dish.match.ui.CollectedFoodActivity"
android:theme="@style/Theme.DishMatch.NoSplash"
android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<activity
android:name="com.shuwei.dish.match.ui.SlaveActivity"
android:theme="@style/Theme.DishMatch.NoSplash"
android:launchMode="singleTask"
android:screenOrientation="landscape"
tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<receiver <receiver
android:name=".utils.BootReceiver" android:name=".utils.BootReceiver"
+272
View File
@@ -0,0 +1,272 @@
{
"class_names": [
"三黄鸡",
"上海青WP2026020200041",
"冰草WP2026020900054",
"刀豆",
"北豆腐WP2025111900007",
"味精WP2026013000034",
"圆生菜WP2026020900053",
"圆白菜[卷心菜,包菜]WP2025111000063",
"基围虾WP2025110700019",
"多宝鱼WP2025112600011",
"大白菜(代表值)WP2026022800638",
"大葱WP2026013000025",
"姜[黄姜]WP2026022800956",
"娃娃菜WP2026020200005",
"小白菜WP2026022800031",
"小米辣椒WP2026013000027",
"小葱WP2026013000029",
"山药(带皮)WP2026020200008",
"巨人笋",
"广东菜心WP2025120100014",
"木耳(干)[黑木耳,云耳]WP2026022801182",
"杏鲍菇WP2026022700030",
"松花蛋",
"松茸菌WP2025120100016",
"沙葱",
"海带(浸)[江白菜,昆布]WP2026022800035",
"牛腩WP2025110700043",
"猪五花肉WP2026020300048",
"猪腿肉WP2025110700040",
"玉米WP2025120100017",
"玉米糁(黄)WP2026022800038",
"番茄[西红柿]WP2026021000028",
"白吉饼WP2026013000024",
"笋",
"粉丝",
"紫甘蓝",
"红心萝卜[心里美]WP2026022800368",
"红提子WP2025111900006",
"红椒WP2026030300055",
"红洋葱WP2026030600054",
"红美人椒WP2026020300049",
"红薯",
"红薯粉条(袋)",
"羊排WP2025120300021",
"羊血WP2026013000038",
"胡萝卜WP2025110600008",
"芋头",
"花卷",
"芹菜WP2025111000065",
"苦菊",
"茄子(紫皮,长)WP2025110700024",
"茼蒿WP2026020200045",
"莲藕(带皮)",
"莴笋(鲜)[莴苣]WP2026020200009",
"菜花(白色)[花椰菜]",
"菠菜(鲜)[赤根菜]WP2026022800700",
"蒜WP2025111400003",
"蒜苗WP2026013000028",
"蒜薹(圆)",
"蒸鱼豉油WP2026013000032",
"薏米[薏仁米,苡米]WP2026022800037",
"西兰花[绿菜花]WP2025110700029",
"西葫芦",
"豆腐皮WP2025111000059",
"豆角",
"辣椒(红,小)WP2025111000066",
"辣椒(青,尖)WP2026022800504",
"醪糟",
"金针菇[(鲜)[智力菇]WP2026030600051",
"青线椒WP2026013000031",
"韭菜WP2026020200044",
"韭黄",
"馒头(代表值)WP2026030600056",
"香椿[香椿芽]WP2026022801086",
"香菇(鲜)[香蕈,冬菇]WP2026030600050",
"香菜WP2026013000030",
"马铃薯[土豆、洋芋]WP2026012900001",
"鸡全腿(带骨)",
"鸡翅(带骨)",
"鸡腿肉WP2025110700048",
"鸡腿(带骨)",
"鸡蛋WP2026013000039",
"麦芹",
"黄瓜(鲜)[胡瓜]WP2025110700031",
"黄豆芽WP2026020200042",
"黑袋子",
"黑鱼(整条鱼)WP2025110600012"
],
"class_to_idx": {
"三黄鸡": 0,
"上海青WP2026020200041": 1,
"冰草WP2026020900054": 2,
"刀豆": 3,
"北豆腐WP2025111900007": 4,
"味精WP2026013000034": 5,
"圆生菜WP2026020900053": 6,
"圆白菜[卷心菜,包菜]WP2025111000063": 7,
"基围虾WP2025110700019": 8,
"多宝鱼WP2025112600011": 9,
"大白菜(代表值)WP2026022800638": 10,
"大葱WP2026013000025": 11,
"姜[黄姜]WP2026022800956": 12,
"娃娃菜WP2026020200005": 13,
"小白菜WP2026022800031": 14,
"小米辣椒WP2026013000027": 15,
"小葱WP2026013000029": 16,
"山药(带皮)WP2026020200008": 17,
"巨人笋": 18,
"广东菜心WP2025120100014": 19,
"木耳(干)[黑木耳,云耳]WP2026022801182": 20,
"杏鲍菇WP2026022700030": 21,
"松花蛋": 22,
"松茸菌WP2025120100016": 23,
"沙葱": 24,
"海带(浸)[江白菜,昆布]WP2026022800035": 25,
"牛腩WP2025110700043": 26,
"猪五花肉WP2026020300048": 27,
"猪腿肉WP2025110700040": 28,
"玉米WP2025120100017": 29,
"玉米糁(黄)WP2026022800038": 30,
"番茄[西红柿]WP2026021000028": 31,
"白吉饼WP2026013000024": 32,
"笋": 33,
"粉丝": 34,
"紫甘蓝": 35,
"红心萝卜[心里美]WP2026022800368": 36,
"红提子WP2025111900006": 37,
"红椒WP2026030300055": 38,
"红洋葱WP2026030600054": 39,
"红美人椒WP2026020300049": 40,
"红薯": 41,
"红薯粉条(袋)": 42,
"羊排WP2025120300021": 43,
"羊血WP2026013000038": 44,
"胡萝卜WP2025110600008": 45,
"芋头": 46,
"花卷": 47,
"芹菜WP2025111000065": 48,
"苦菊": 49,
"茄子(紫皮,长)WP2025110700024": 50,
"茼蒿WP2026020200045": 51,
"莲藕(带皮)": 52,
"莴笋(鲜)[莴苣]WP2026020200009": 53,
"菜花(白色)[花椰菜]": 54,
"菠菜(鲜)[赤根菜]WP2026022800700": 55,
"蒜WP2025111400003": 56,
"蒜苗WP2026013000028": 57,
"蒜薹(圆)": 58,
"蒸鱼豉油WP2026013000032": 59,
"薏米[薏仁米,苡米]WP2026022800037": 60,
"西兰花[绿菜花]WP2025110700029": 61,
"西葫芦": 62,
"豆腐皮WP2025111000059": 63,
"豆角": 64,
"辣椒(红,小)WP2025111000066": 65,
"辣椒(青,尖)WP2026022800504": 66,
"醪糟": 67,
"金针菇[(鲜)[智力菇]WP2026030600051": 68,
"青线椒WP2026013000031": 69,
"韭菜WP2026020200044": 70,
"韭黄": 71,
"馒头(代表值)WP2026030600056": 72,
"香椿[香椿芽]WP2026022801086": 73,
"香菇(鲜)[香蕈,冬菇]WP2026030600050": 74,
"香菜WP2026013000030": 75,
"马铃薯[土豆、洋芋]WP2026012900001": 76,
"鸡全腿(带骨)": 77,
"鸡翅(带骨)": 78,
"鸡腿肉WP2025110700048": 79,
"鸡腿(带骨)": 80,
"鸡蛋WP2026013000039": 81,
"麦芹": 82,
"黄瓜(鲜)[胡瓜]WP2025110700031": 83,
"黄豆芽WP2026020200042": 84,
"黑袋子": 85,
"黑鱼(整条鱼)WP2025110600012": 86
},
"idx_to_class": {
"0": "三黄鸡",
"1": "上海青WP2026020200041",
"2": "冰草WP2026020900054",
"3": "刀豆",
"4": "北豆腐WP2025111900007",
"5": "味精WP2026013000034",
"6": "圆生菜WP2026020900053",
"7": "圆白菜[卷心菜,包菜]WP2025111000063",
"8": "基围虾WP2025110700019",
"9": "多宝鱼WP2025112600011",
"10": "大白菜(代表值)WP2026022800638",
"11": "大葱WP2026013000025",
"12": "姜[黄姜]WP2026022800956",
"13": "娃娃菜WP2026020200005",
"14": "小白菜WP2026022800031",
"15": "小米辣椒WP2026013000027",
"16": "小葱WP2026013000029",
"17": "山药(带皮)WP2026020200008",
"18": "巨人笋",
"19": "广东菜心WP2025120100014",
"20": "木耳(干)[黑木耳,云耳]WP2026022801182",
"21": "杏鲍菇WP2026022700030",
"22": "松花蛋",
"23": "松茸菌WP2025120100016",
"24": "沙葱",
"25": "海带(浸)[江白菜,昆布]WP2026022800035",
"26": "牛腩WP2025110700043",
"27": "猪五花肉WP2026020300048",
"28": "猪腿肉WP2025110700040",
"29": "玉米WP2025120100017",
"30": "玉米糁(黄)WP2026022800038",
"31": "番茄[西红柿]WP2026021000028",
"32": "白吉饼WP2026013000024",
"33": "笋",
"34": "粉丝",
"35": "紫甘蓝",
"36": "红心萝卜[心里美]WP2026022800368",
"37": "红提子WP2025111900006",
"38": "红椒WP2026030300055",
"39": "红洋葱WP2026030600054",
"40": "红美人椒WP2026020300049",
"41": "红薯",
"42": "红薯粉条(袋)",
"43": "羊排WP2025120300021",
"44": "羊血WP2026013000038",
"45": "胡萝卜WP2025110600008",
"46": "芋头",
"47": "花卷",
"48": "芹菜WP2025111000065",
"49": "苦菊",
"50": "茄子(紫皮,长)WP2025110700024",
"51": "茼蒿WP2026020200045",
"52": "莲藕(带皮)",
"53": "莴笋(鲜)[莴苣]WP2026020200009",
"54": "菜花(白色)[花椰菜]",
"55": "菠菜(鲜)[赤根菜]WP2026022800700",
"56": "蒜WP2025111400003",
"57": "蒜苗WP2026013000028",
"58": "蒜薹(圆)",
"59": "蒸鱼豉油WP2026013000032",
"60": "薏米[薏仁米,苡米]WP2026022800037",
"61": "西兰花[绿菜花]WP2025110700029",
"62": "西葫芦",
"63": "豆腐皮WP2025111000059",
"64": "豆角",
"65": "辣椒(红,小)WP2025111000066",
"66": "辣椒(青,尖)WP2026022800504",
"67": "醪糟",
"68": "金针菇[(鲜)[智力菇]WP2026030600051",
"69": "青线椒WP2026013000031",
"70": "韭菜WP2026020200044",
"71": "韭黄",
"72": "馒头(代表值)WP2026030600056",
"73": "香椿[香椿芽]WP2026022801086",
"74": "香菇(鲜)[香蕈,冬菇]WP2026030600050",
"75": "香菜WP2026013000030",
"76": "马铃薯[土豆、洋芋]WP2026012900001",
"77": "鸡全腿(带骨)",
"78": "鸡翅(带骨)",
"79": "鸡腿肉WP2025110700048",
"80": "鸡腿(带骨)",
"81": "鸡蛋WP2026013000039",
"82": "麦芹",
"83": "黄瓜(鲜)[胡瓜]WP2025110700031",
"84": "黄豆芽WP2026020200042",
"85": "黑袋子",
"86": "黑鱼(整条鱼)WP2025110600012"
},
"embedding_dim": 512,
"total_images": 2093,
"build_time": "2026-03-10T10:33:47.104848"
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,31 @@
package com.shuwei.dish.match.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.dish.match.databinding.ListItemCollectedFoodBinding
import com.shuwei.dish.match.entity.CollectedFoodItem
/**
* 已采集食材列表适配器
* 展示食材名称和已采集数量,支持删除操作
*/
class CollectedFoodAdapter(list: MutableList<CollectedFoodItem>) :
BaseQuickAdapter<CollectedFoodItem, CollectedFoodAdapter.VH>(list) {
/** ViewHolder,持有列表项 ViewBinding */
inner class VH(val binding: ListItemCollectedFoodBinding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val binding = ListItemCollectedFoodBinding.inflate(LayoutInflater.from(context), parent, false)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int, item: CollectedFoodItem?) {
item ?: return
holder.binding.tvFoodName.text = item.foodName.split("WP").first()
holder.binding.tvCollectCount.text = "已采集${item.collectCount}"
}
}
@@ -9,7 +9,9 @@ import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.dish.match.R import com.shuwei.dish.match.R
import com.shuwei.dish.match.databinding.ListItemDishCookBinding import com.shuwei.dish.match.databinding.ListItemDishCookBinding
import com.shuwei.dish.match.entity.CookFoodGoodsEntity import com.shuwei.dish.match.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.roundedOneDecimalPlace import com.shuwei.dish.match.utils.ext.roundedOneDecimalPlace
import com.shuwei.dish.match.utils.ext.visible
import java.text.DecimalFormat import java.text.DecimalFormat
class DishPartAdapter(list: MutableList<CookFoodGoodsEntity>) : class DishPartAdapter(list: MutableList<CookFoodGoodsEntity>) :
@@ -36,12 +38,16 @@ class DishPartAdapter(list: MutableList<CookFoodGoodsEntity>) :
if (item.isSamplingPage) R.color.black999 else R.color.dish_green if (item.isSamplingPage) R.color.black999 else R.color.dish_green
) )
) )
ivOperateIcon.run {
if (item.isSamplingPage) gone() else visible()
}
ivClearIcon.visible()
ivClearIcon.setImageResource(
if (item.isOriginalData) R.drawable.ic_dish_clear
else R.drawable.ic_delete
)
ivOperateIcon.setImageResource( ivOperateIcon.setImageResource(
if (item.isSamplingPage)
//采集页面
R.drawable.ic_delete
else
//制作页面
if (item.isSetFinished) R.drawable.ic_dish_selected if (item.isSetFinished) R.drawable.ic_dish_selected
else R.drawable.ic_dish_unselected else R.drawable.ic_dish_unselected
) )
@@ -53,5 +59,4 @@ class DishPartAdapter(list: MutableList<CookFoodGoodsEntity>) :
} }
} }
} }
} }
@@ -0,0 +1,43 @@
package com.shuwei.dish.match.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.content.ContextCompat
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.ListItemSearchFoodBinding
import com.shuwei.dish.match.databinding.ListItemSearchGoodsInfoBinding
import com.shuwei.dish.match.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.roundedDecimalPlace
import com.shuwei.dish.match.utils.ext.visible
class FoodSearchAdapter(private var list: MutableList<CookFoodGoodsEntity>) :
BaseQuickAdapter<CookFoodGoodsEntity, FoodSearchAdapter.VH>(list) {
override fun onBindViewHolder(
holder: VH,
position: Int,
item: CookFoodGoodsEntity?
) {
item ?: return
holder.binding.tvFoodName.run {
text = item.goodsName
isChecked = item.isClicked
}
}
override fun onCreateViewHolder(
context: Context,
parent: ViewGroup,
viewType: Int
): VH {
val binding = ListItemSearchFoodBinding.inflate(LayoutInflater.from(context), parent, false)
return VH(binding)
}
inner class VH(var binding: ListItemSearchFoodBinding) : QuickViewHolder(binding.root)
}
@@ -9,6 +9,9 @@ import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.dish.match.R import com.shuwei.dish.match.R
import com.shuwei.dish.match.databinding.ListItemSearchGoodsInfoBinding import com.shuwei.dish.match.databinding.ListItemSearchGoodsInfoBinding
import com.shuwei.dish.match.entity.CookFoodGoodsEntity import com.shuwei.dish.match.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.roundedDecimalPlace
import com.shuwei.dish.match.utils.ext.visible
class GoodsInfoSearchAdapter(private var list: MutableList<CookFoodGoodsEntity>) : class GoodsInfoSearchAdapter(private var list: MutableList<CookFoodGoodsEntity>) :
BaseQuickAdapter<CookFoodGoodsEntity, GoodsInfoSearchAdapter.VH>(list) { BaseQuickAdapter<CookFoodGoodsEntity, GoodsInfoSearchAdapter.VH>(list) {
@@ -18,20 +21,29 @@ class GoodsInfoSearchAdapter(private var list: MutableList<CookFoodGoodsEntity>)
position: Int, position: Int,
item: CookFoodGoodsEntity? item: CookFoodGoodsEntity?
) { ) {
item ?: return
holder.binding.tvGoodsInfo.run { holder.binding.tvGoodsInfo.run {
text = item?.goodsName text = item.goodsName
setBackgroundResource( setBackgroundResource(
if (item?.isClicked == true) R.drawable.shape_green_stroke if (item.isClicked == true) R.drawable.shape_green_stroke
else R.drawable.shape_white_12_corners else R.drawable.shape_white_12_corners
) )
setTextColor( setTextColor(
ContextCompat.getColor( ContextCompat.getColor(
context, context,
if (item?.isClicked == true) R.color.dish_green if (item.isClicked == true) R.color.dish_green
else R.color.black666 else R.color.black666
) )
) )
} }
if (item.foodScore == -1){
holder.binding.tvFoodScore.gone()
} else {
holder.binding.tvFoodScore.let {
it.visible()
it.text = "${(item.foodScore / 100.0).roundedDecimalPlace(2)}%"
}
}
} }
override fun onCreateViewHolder( override fun onCreateViewHolder(
@@ -41,6 +53,7 @@ class GoodsInfoSearchAdapter(private var list: MutableList<CookFoodGoodsEntity>)
): VH { ): VH {
val binding = val binding =
ListItemSearchGoodsInfoBinding.inflate(LayoutInflater.from(context), parent, false) ListItemSearchGoodsInfoBinding.inflate(LayoutInflater.from(context), parent, false)
return VH(binding) return VH(binding)
} }
@@ -0,0 +1,44 @@
package com.shuwei.dish.match.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.dish.match.databinding.ListItemScaleRowBinding
import com.shuwei.dish.match.utils.WeightUtil
/**
* 线性列表秤数据 Adapter
* 用于 SlaveActivity 非22/18格子设备的秤列表展示
*/
class ScaleRowAdapter : BaseQuickAdapter<ScaleRowAdapter.ScaleItem, ScaleRowAdapter.VH>() {
/**
* 秤数据条目
* @param address 秤地址
* @param weight 重量(克)
* @param state 状态码
* @param name 调料名称,未配置时为空
*/
data class ScaleItem(val address: Int, val weight: Double, val state: Int, val name: String = "")
inner class VH(val b: ListItemScaleRowBinding) : QuickViewHolder(b.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
return VH(ListItemScaleRowBinding.inflate(LayoutInflater.from(context), parent, false))
}
override fun onBindViewHolder(holder: VH, position: Int, item: ScaleItem?) {
item ?: return
holder.b.tvScaleLabel.text = "${item.address}"
holder.b.tvName.text = item.name.ifEmpty { "-" }
holder.b.tvWeight.text = "${item.weight} g"
holder.b.tvState.text = when (item.state) {
WeightUtil.STATE_STABLE -> "稳定"
WeightUtil.STATE_UNSTABLE -> "不稳定"
WeightUtil.STATE_OVER_WEIGHT -> "超量"
else -> "${item.state}"
}
}
}
@@ -0,0 +1,173 @@
package com.shuwei.dish.match.adapter
import android.annotation.SuppressLint
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.core.view.updateLayoutParams
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.ListItemScale22RowBinding
import com.shuwei.dish.match.scale.ScaleData
import com.shuwei.dish.match.scale.ScaleDeviceConfig
import com.shuwei.dish.match.utils.WeightUtil
import com.shuwei.dish.match.utils.ext.dp
/**
* 18格网格 Adapter,同时服务于调料配置页和主设备秤总览页。
*
* @param showNameOnly true = 调料配置模式,仅显示 tvName,隐藏 tvWeight/tvState
* false = 秤总览模式,三行全部显示
* @param showAddress true = 显示秤地址行(tvAddress),仅 MasterScaleActivity 使用
*/
class Seasoning18GridAdapter(
private val showNameOnly: Boolean = false,
private val showAddress: Boolean = false,
private val leftRightSpace: Int = 10.dp
) : BaseQuickAdapter<ScaleData, Seasoning18GridAdapter.VH>(mutableListOf()) {
/** 空位哨兵,address=0 表示无效 */
private val empty = ScaleData("", 0, 0.0, -1, 0)
/** 当前高亮的格子位置,-1 表示无高亮 */
private var highlightedPosition: Int = -1
/** 点击回调 */
var onItemClick: ((ScaleData, Int) -> Unit)? = null
inner class VH(val b: ListItemScale22RowBinding) : QuickViewHolder(b.root)
/** 前6个位置为第1行(viewType=0margin大),其余为第2-3行(viewType=1margin小) */
override fun getItemViewType(position: Int, list: List<ScaleData>) =
if (position < 6) 0 else 1
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val b = ListItemScale22RowBinding.inflate(LayoutInflater.from(context), parent, false)
val margin = if (viewType == 0) 12.dp else 2.dp
val screenWidth = parent.resources.displayMetrics.widthPixels
val cellSize = (screenWidth - 6 * margin * 2 - leftRightSpace) / 6
b.root.updateLayoutParams<ViewGroup.MarginLayoutParams> {
width = cellSize
height = cellSize
if (viewType == 0) setMargins(margin, 0, margin, 2.dp)
else 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
b.tvState.visibility = View.GONE
}
val vh = VH(b)
b.root.setOnClickListener {
val pos = vh.bindingAdapterPosition
if (pos != RecyclerView.NO_POSITION) {
items.getOrNull(pos)?.takeIf { it.address != 0 }?.let { item -> onItemClick?.invoke(item, pos) }
}
}
return vh
}
@SuppressLint("SetTextI18n")
override fun onBindViewHolder(holder: VH, position: Int, item: ScaleData?) {
val valid = (item?.address ?: 0) != 0
if (!valid) {
holder.b.tvAddress.text = ""
holder.b.tvName.text = "-"
holder.b.tvWeight.text = ""
holder.b.tvState.text = ""
} else {
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.setBackgroundResource(R.drawable.shape_scale_cell_highlight)
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)
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(sub)
}
}
/**
* 高亮指定位置的格子(红底白字),同时清除上一个高亮
*/
fun highlightPosition(position: Int) {
val prev = highlightedPosition
highlightedPosition = position
if (prev >= 0) notifyItemChanged(prev)
if (position >= 0) notifyItemChanged(position)
}
/**
* 清除所有高亮,恢复默认样式
*/
fun clearHighlight() {
highlightPosition(-1)
}
/**
* 更新指定位置的名称并刷新对应格子
*/
fun updateItemName(position: Int, name: String) {
items.getOrNull(position)?.let {
set(position, it.copy(name = name))
}
}
/**
* 按 SCALE_ORDER_18 的 sort 顺序更新数据,空位用哨兵填充
* 只更新显示字段有变化的格子,避免全量刷新导致 ViewHolder 重建影响点击监听
*/
fun update(list: List<ScaleData>) {
val byAddress = list.associateBy { it.address }
val filled = ScaleDeviceConfig.SCALE_ORDER_18.map { addr ->
byAddress[addr] ?: empty.copy(address = addr)
}
if (items.isEmpty()) {
submitList(filled)
} else {
filled.forEachIndexed { i, scale ->
val cur = items.getOrNull(i)
if (cur == null || cur.address != scale.address || cur.weight != scale.weight || cur.state != scale.state || cur.name != scale.name) {
set(i, scale)
}
}
}
}
/**
* 将 ScaleData 列表按 SCALE_ORDER_18 重排后调用 update
*/
fun updateByAddress(scales: List<ScaleData>) {
update(scales)
}
private fun stateStr(state: Int) = when (state) {
WeightUtil.STATE_STABLE -> "稳定"
WeightUtil.STATE_UNSTABLE -> "不稳定"
WeightUtil.STATE_OVER_WEIGHT -> "超量"
else -> ""
}
}
@@ -0,0 +1,179 @@
package com.shuwei.dish.match.adapter
import android.annotation.SuppressLint
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.core.view.updateLayoutParams
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.ListItemScale22RowBinding
import com.shuwei.dish.match.scale.ScaleData
import com.shuwei.dish.match.scale.ScaleDeviceConfig
import com.shuwei.dish.match.utils.SizeTool
import com.shuwei.dish.match.utils.WeightUtil
import com.shuwei.dish.match.utils.ext.dp
/**
* 22格网格 Adapter,同时服务于调料配置页和主设备秤总览页。
*
* @param largeSize 前4个大格子的宽度(px)
* @param smallSize 其余小格子的宽度(px)
* @param cellAspectRatio 格子高宽比(height / width
* @param showNameOnly true = 调料配置模式,仅显示 tvName,隐藏 tvWeight/tvState
* false = 秤总览模式,三行全部显示
* @param showAddress true = 显示秤地址行(tvAddress),仅 MasterScaleActivity 使用
*/
class Seasoning22GridAdapter(
private val largeSize: Int,
private val smallSize: Int,
private val cellAspectRatio: Float = SizeTool.DEFAULT_CELL_ASPECT_RATIO,
private val showNameOnly: Boolean = false,
private val showAddress: Boolean = false
) : BaseQuickAdapter<ScaleData, Seasoning22GridAdapter.VH>(mutableListOf()) {
/** 空位哨兵,address=0 表示无效 */
private val empty = ScaleData("", 0, 0.0, -1, 0)
/** 当前高亮的格子位置,-1 表示无高亮 */
private var highlightedPosition: Int = -1
/** 点击回调 */
var onItemClick: ((ScaleData, Int) -> Unit)? = null
inner class VH(val b: ListItemScale22RowBinding) : QuickViewHolder(b.root)
/** 前4个位置为大格子(viewType=1),其余为小格子(viewType=0 */
override fun getItemViewType(position: Int, list: List<ScaleData>) =
if (position < 4) 1 else 0
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val b = ListItemScale22RowBinding.inflate(LayoutInflater.from(context), parent, false)
val cellWidth = if (viewType == 1) largeSize else smallSize
val cellHeight = (cellWidth * cellAspectRatio).toInt()
b.root.updateLayoutParams<FlexboxLayoutManager.LayoutParams> {
width = cellWidth
height = cellHeight
setMargins(2.dp, 2.dp, 2.dp, 2.dp)
}
val baseSp = (cellWidth / 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
b.tvState.visibility = View.GONE
}
val vh = VH(b)
b.root.setOnClickListener {
val pos = vh.bindingAdapterPosition
if (pos != RecyclerView.NO_POSITION) {
items.getOrNull(pos)?.takeIf { it.address != 0 }?.let { item -> onItemClick?.invoke(item, pos) }
}
}
return vh
}
@SuppressLint("SetTextI18n")
override fun onBindViewHolder(holder: VH, position: Int, item: ScaleData?) {
val valid = (item?.address ?: 0) != 0
if (!valid) {
holder.b.tvAddress.text = ""
holder.b.tvName.text = "-"
holder.b.tvWeight.text = ""
holder.b.tvState.text = ""
} else {
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.setBackgroundResource(R.drawable.shape_scale_cell_highlight)
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)
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(sub)
}
}
/**
* 高亮指定位置的格子(红底白字),同时清除上一个高亮
*/
fun highlightPosition(position: Int) {
val prev = highlightedPosition
highlightedPosition = position
if (prev >= 0) notifyItemChanged(prev)
if (position >= 0) notifyItemChanged(position)
}
/**
* 清除所有高亮,恢复默认样式
*/
fun clearHighlight() {
highlightPosition(-1)
}
/**
* 更新指定位置的名称并刷新对应格子
*/
fun updateItemName(position: Int, name: String) {
items.getOrNull(position)?.let {
set(position, it.copy(name = name))
}
}
/**
* 按 SCALE_ORDER_22 的 sort 顺序更新数据,空位用哨兵填充
* 只更新显示字段有变化的格子,避免全量刷新导致 ViewHolder 重建影响点击监听
*/
fun update(list: List<ScaleData>) {
val byAddress = list.associateBy { it.address }
val filled = ScaleDeviceConfig.SCALE_ORDER_22.map { addr ->
byAddress[addr] ?: empty.copy(address = addr)
}
if (items.isEmpty()) {
submitList(filled)
} else {
filled.forEachIndexed { i, scale ->
val cur = items.getOrNull(i)
if (cur == null || cur.address != scale.address || cur.weight != scale.weight || cur.state != scale.state || cur.name != scale.name) {
set(i, scale)
}
}
}
}
/**
* 将 ScaleData 列表按 SCALE_ORDER_22 重排后调用 update
*/
fun updateByAddress(scales: List<ScaleData>) {
update(scales)
}
private fun stateStr(state: Int) = when (state) {
WeightUtil.STATE_STABLE -> "稳定"
WeightUtil.STATE_UNSTABLE -> "不稳定"
WeightUtil.STATE_OVER_WEIGHT -> "超量"
else -> ""
}
}
@@ -0,0 +1,58 @@
package com.shuwei.dish.match.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.dish.match.databinding.ItemSeasoningWeightBinding
/**
* 调料使用重量列表 Adapter
* 用于 SubmitFoodActivity 展示各调料槽位的实时用量
* 同名调料已在外部聚合,adapter 按 goodsName 作为唯一标识
*/
class SeasoningWeightAdapter : BaseQuickAdapter<SeasoningWeightAdapter.Item, SeasoningWeightAdapter.VH>() {
/** 调料展示数据项,key = goodsName(同名调料已聚合) */
data class Item(
val goodsId: String,
val goodsName: String,
/** 使用重量(克),同名调料已累加 */
val useWeight: Double
)
/**
* 更新单个 item,按 goodsName 匹配;不存在则追加
*/
fun updateItem(item: Item) {
val idx = items.indexOfFirst { it.goodsName == item.goodsName }
if (idx >= 0) {
set(idx, item)
} else {
add(item)
}
}
/**
* 移除指定 goodsName 的 item(聚合后用量不足阈值时调用)
*/
fun removeItem(goodsName: String) {
val idx = items.indexOfFirst { it.goodsName == goodsName }
if (idx >= 0) removeAt(idx)
}
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val binding = ItemSeasoningWeightBinding.inflate(LayoutInflater.from(context), parent, false)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int, item: Item?) {
item ?: return
holder.binding.tvSeasoningName.text = item.goodsName
holder.binding.tvSeasoningWeight.text = "${item.useWeight}g"
}
inner class VH(var binding: ItemSeasoningWeightBinding) : QuickViewHolder(binding.root)
}
@@ -0,0 +1,58 @@
package com.shuwei.dish.match.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
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.ListItemVectorCollectionBinding
import com.shuwei.dish.match.objbox.FoodCollectionBean
import com.shuwei.dish.match.utils.ext.load
class VectorCollectionAdapter (var list: MutableList<FoodCollectionBean>) :
BaseQuickAdapter<FoodCollectionBean, VectorCollectionAdapter.VH>(list) {
inner class VH(var binding: ListItemVectorCollectionBinding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val inflater = LayoutInflater.from(context)
val binding = ListItemVectorCollectionBinding.inflate(inflater, parent, false)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int, item: FoodCollectionBean?) {
val binding = holder.binding
item?.let {
binding.ivFinish.visibility = if (it.isFinish) View.VISIBLE else View.GONE
binding.ivDelete.visibility = if (it.isShowCamera) View.GONE else View.VISIBLE
binding.imageView.run {
if (it.isShowCamera) {
scaleType = ImageView.ScaleType.CENTER
setImageResource(R.drawable.ic_camera_default)
} else {
//scaleType = ImageView.ScaleType.FIT_CENTER
scaleType = ImageView.ScaleType.CENTER_CROP
//setImageURI(it.imageUri)
//setImageBitmap(it.bitmap)
if (it.imageUri!=null) {
setImageURI(it.imageUri)
} else {
load(it.imageFile)
}
// it.bitmap?.let { bitmap ->
// GlideUtils.loadRoundCornerWitBitmap(
// context,
// url = bitmap,
// imageView = this,
// radius = 8.dp
// )
// }
}
}
}
}
}
@@ -1,6 +1,7 @@
package com.shuwei.dish.match.base package com.shuwei.dish.match.base
import android.content.Intent import android.content.Intent
import android.content.pm.ActivityInfo
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
@@ -14,14 +15,17 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import com.shuwei.dish.match.R import com.shuwei.dish.match.R
import com.shuwei.dish.match.databinding.ActivityBaseBinding import com.shuwei.dish.match.databinding.ActivityBaseBinding
import com.shuwei.dish.match.dialog.Loading import com.shuwei.dish.match.dialog.Loading
import com.shuwei.dish.match.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.entity.SeasoningEntity
import com.shuwei.dish.match.net.NetViewModel 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.ui.InitActivity
import com.shuwei.dish.match.utils.ActivityManager import com.shuwei.dish.match.utils.ActivityManager
import com.shuwei.dish.match.utils.ext.dp
import com.shuwei.dish.match.utils.ext.gone import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.invisible import com.shuwei.dish.match.utils.ext.invisible
import com.shuwei.dish.match.utils.ext.visible import com.shuwei.dish.match.utils.ext.visible
@@ -35,34 +39,23 @@ import kotlin.getValue
open class BaseActivity : AppCompatActivity() { open class BaseActivity : AppCompatActivity() {
private lateinit var binding: ActivityBaseBinding private lateinit var binding: ActivityBaseBinding
private var launcher: ActivityResultLauncher<Intent>? = null
private var launchCallback: ((Intent?) -> Unit)? = null
public fun launch(cls: Class<*>, launchCallback: (Intent?) -> Unit) {
this.launchCallback = launchCallback
launcher?.launch(Intent(this, cls))
}
public fun launch(intent: Intent, launchCallback: (Intent?) -> Unit) {
this.launchCallback = launchCallback
launcher?.launch(intent)
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// 提前隐藏系统栏,避免窗口创建时导航栏闪烁
hideSystemBars()
// 主设备竖屏,子设备横屏
requestedOrientation = if (GlobalData.deviceRole == DeviceRole.MASTER)
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
else
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
ActivityManager.addActivity(this) ActivityManager.addActivity(this)
binding = ActivityBaseBinding.inflate(layoutInflater) binding = ActivityBaseBinding.inflate(layoutInflater)
if (this !is InitActivity) { if (this !is InitActivity || GlobalData.deviceRole == DeviceRole.SLAVE) {
binding.root.setBackgroundColor(ContextCompat.getColor(this, R.color.bg_color)) binding.root.setBackgroundColor(ContextCompat.getColor(this, R.color.bg_color))
} }
setContentView(binding.root) setContentView(binding.root)
statusBarDarkFont(enable = false) statusBarDarkFont(enable = false)
launcher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
launchCallback?.invoke(result.data)
}
}
// window.setDecorFitsSystemWindows(false) // 启用 Edge-to-Edge // window.setDecorFitsSystemWindows(false) // 启用 Edge-to-Edge
// window.insetsController?.apply { // window.insetsController?.apply {
@@ -70,6 +63,10 @@ open class BaseActivity : AppCompatActivity() {
// systemBarsBehavior = // systemBarsBehavior =
// WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE // 滑动时临时显示 // WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE // 滑动时临时显示
// } // }
// 主设备垂直 padding 30dp,子设备 15dp
val verticalPadding = if (GlobalData.deviceRole == DeviceRole.MASTER) 30.dp else 10.dp
binding.tvLeftTime.setPadding(0, verticalPadding, 0, verticalPadding)
binding.tvRightTime.setPadding(0, verticalPadding, 0, verticalPadding)
binding.llTitleBar.gone() binding.llTitleBar.gone()
binding.ivBack.setOnClickListener { finish() } binding.ivBack.setOnClickListener { finish() }
} }
@@ -86,17 +83,17 @@ open class BaseActivity : AppCompatActivity() {
} }
fun hideStatusBar() { fun hideStatusBar() {
enableEdgeToEdge() // enableEdgeToEdge()
// val uiOptions = (View.SYSTEM_UI_FLAG_FULLSCREEN // 隐藏状态栏 //// val uiOptions = (View.SYSTEM_UI_FLAG_FULLSCREEN // 隐藏状态栏
// or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION) // 隐藏导航栏(可选) //// or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION) // 隐藏导航栏(可选)
window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN // window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN
hideSystemBars()
} }
fun statusBarDarkFont(enable: Boolean) { fun statusBarDarkFont(enable: Boolean) {
//window.decorView.systemUiVisibility = if (enable) View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR else 0 //window.decorView.systemUiVisibility = if (enable) View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR else 0
val color = ContextCompat.getColor( val color = ContextCompat.getColor(
this, this, if (enable) R.color.black else R.color.white
if (enable) R.color.black else R.color.white
) )
binding.tvLeftTime.setTextColor(color) binding.tvLeftTime.setTextColor(color)
binding.tvRightTime.setTextColor(color) binding.tvRightTime.setTextColor(color)
@@ -126,6 +123,9 @@ open class BaseActivity : AppCompatActivity() {
) )
} }
/** 返回 Fragment 容器的 View ID,供子类加载 Fragment 使用 */
protected fun getFragmentContainerId(): Int = R.id.flContainer
override fun setContentView(view: View?) { override fun setContentView(view: View?) {
if (view == binding.root) { if (view == binding.root) {
super.setContentView(view) super.setContentView(view)
@@ -142,7 +142,7 @@ open class BaseActivity : AppCompatActivity() {
binding.tvLeftTime.text = arr[1] binding.tvLeftTime.text = arr[1]
} }
private val handler = Handler(Looper.getMainLooper()) val handler = Handler(Looper.getMainLooper())
private val updateTask = object : Runnable { private val updateTask = object : Runnable {
override fun run() { override fun run() {
updateDateTime() updateDateTime()
@@ -161,36 +161,110 @@ open class BaseActivity : AppCompatActivity() {
handler.removeCallbacks(updateTask) handler.removeCallbacks(updateTask)
} }
fun showLoading() { fun showLoading(msg: String = "加载中……") {
Loading.show(this) handler.post {
Loading.show(
context = this,
message = msg
)
}
} }
fun dismissLoading() { fun dismissLoading() {
Loading.dismiss() handler.post {
Loading.dismiss()
}
} }
fun delayDismissLoading() { fun delayDismissLoading() {
window.decorView.postDelayed({ handler.postDelayed({
Loading.dismiss() Loading.dismiss()
}, 500) }, 500)
} }
val netViewModel: NetViewModel by viewModels() val netViewModel: NetViewModel by viewModels()
fun queryGoodsList( val appViewModel: DbViewModel by viewModels()
param: MutableMap<String, Any>,
onSuccess: (MutableList<CookFoodGoodsEntity>?) -> Unit, private var permissionCallback: ((isGranted: Boolean) -> Unit)? = null
onFailure: (String, String) -> Unit private var activityCallback: ((intent: Intent?) -> Unit)? = null
fun requestMultiplePermissions(
permissions: Array<String>,
callback: (isGranted: Boolean) -> Unit
) { ) {
netViewModel.queryGoodsList(param, onSuccess, onFailure) this.permissionCallback = callback
requestMultiplePermissionsLauncher.launch(permissions)
} }
fun querySeasoningList( fun requestPermission(permission: String, callback: (isGranted: Boolean) -> Unit) {
param: MutableMap<String, Any>, this.permissionCallback = callback
onSuccess: (MutableList<SeasoningEntity>?) -> Unit, requestPermissionLauncher.launch(permission)
onFailure: (String, String) -> Unit }
val requestMultiplePermissionsLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
var isGranted = true
permissions.entries.forEach {
if (!it.value) {
isGranted = false
}
}
permissionCallback?.invoke(isGranted)
}
// 权限请求回调
val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
permissionCallback?.invoke(isGranted)
}
fun startActivity(intent: Intent, callback: (Intent?) -> Unit) {
this.activityCallback = callback
startActivityLauncher.launch(intent)
}
// activity页面返回的回调
private val startActivityLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { ) {
netViewModel.querySeasoningList(param, onSuccess, onFailure) activityCallback?.invoke(it.data)
}
/**
* 窗口焦点变化时回调
* 场景:弹出 Dialog 后焦点丢失,Dialog 消失后焦点恢复,此时需重新隐藏系统栏,
* 避免系统栏在 Dialog 关闭后意外"复活"
*/
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) {
hideSystemBars()
}
}
/**
* 隐藏状态栏和导航栏
* 使用 WindowInsetsControllerCompatAndroidx 推荐方式,向下兼容性好)
* - setDecorFitsSystemWindows 只在首次调用时设置,避免反复触发布局重绘
* (重绘会导致 RecyclerView 滑动时抛出 "view is not a child, cannot hide" 异常)
* - 每次调用仍重新执行 hide(),保证系统栏在滑出后能被重新隐藏
*/
private var systemBarsConfigured = false
private fun hideSystemBars() {
if (!systemBarsConfigured) {
// 只执行一次:允许布局延伸到系统栏区域
WindowCompat.setDecorFitsSystemWindows(window, false)
systemBarsConfigured = true
}
WindowInsetsControllerCompat(window, window.decorView).apply {
hide(WindowInsetsCompat.Type.systemBars())
systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
} }
} }
@@ -8,9 +8,13 @@ import android.content.SharedPreferences
import android.util.Log import android.util.Log
import com.shuwei.dish.match.R import com.shuwei.dish.match.R
import com.shuwei.dish.match.db.DatabaseProvider import com.shuwei.dish.match.db.DatabaseProvider
import com.shuwei.dish.match.objbox.ObjectBox
import com.shuwei.dish.match.scale.ScaleServiceManager
import com.shuwei.dish.match.ui.InitActivity.Companion.TAG import com.shuwei.dish.match.ui.InitActivity.Companion.TAG
import com.shuwei.dish.match.utils.AppUtil import com.shuwei.dish.match.utils.AppUtil
import com.shuwei.dish.match.utils.BootReceiver import com.shuwei.dish.match.utils.BootReceiver
import com.shuwei.dish.match.utils.CrashHandler
import com.shuwei.dish.match.utils.Weigher2
class BaseApp : Application() { class BaseApp : Application() {
@@ -20,12 +24,28 @@ class BaseApp : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
instance = this instance = this
CrashHandler.init(this)
val deviceId = AppUtil.getUDID(this) val deviceId = AppUtil.getUDID(this)
Log.d(TAG, "onCreate: deviceId=$deviceId") Log.d(TAG, "onCreate: deviceId=$deviceId")
GlobalData.deviceId = deviceId GlobalData.deviceId = deviceId
// 根据设备 ID 判断角色:指定 ID 为主设备,其余为子设备
// 主设备是/dev/ttyS7,子设备是/dev/ttyS4
if (deviceId == "8fc2ab34-2137-3112-acca-f884ea8736d4") {
GlobalData.deviceRole = DeviceRole.MASTER
Weigher2.setDevicePort("/dev/ttyS7")
} else {
GlobalData.deviceRole = DeviceRole.SLAVE
Weigher2.setDevicePort("/dev/ttyS4")
}
Log.d(TAG, "设备角色: ${GlobalData.deviceRole}")
// 启动秤服务(主设备多启动发现+聚合,子设备只启动服务端+注册)
ScaleServiceManager.start(this)
// GlobalData.deviceId = "39a7abdd06b3c7ab" // GlobalData.deviceId = "39a7abdd06b3c7ab"
val filter = IntentFilter(Intent.ACTION_BOOT_COMPLETED) val filter = IntentFilter(Intent.ACTION_BOOT_COMPLETED)
registerReceiver(BootReceiver(), filter) registerReceiver(BootReceiver(), filter)
ObjectBox.init(this)
} }
companion object { companion object {
@@ -18,8 +18,16 @@ abstract class BaseFragment<VB : ViewBinding> : Fragment() {
abstract fun inflateBinding(inflater: LayoutInflater, container: ViewGroup?): VB abstract fun inflateBinding(inflater: LayoutInflater, container: ViewGroup?): VB
abstract fun initialize()
override fun onDestroyView() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
_binding = null _binding = null
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
initialize()
}
} }
@@ -1,12 +1,30 @@
package com.shuwei.dish.match.base package com.shuwei.dish.match.base
object GlobalData { /**
var appBaseUrl: String = "https://dev.yixiong-tech.com:8081" * 设备角色枚举
* MASTER:主设备,负责汇聚所有设备的秤数据并展示大屏界面
* SLAVE:子设备,负责推送本机秤数据给主设备
*/
enum class DeviceRole { MASTER, SLAVE }
object GlobalData {
var appBaseUrl: String = TEST_BASE_URL
/**
* 具体业务 BaseUrl
*/
const val TEST_BASE_URL = "http://192.168.1.201:14801"
const val UAT_BASE_URL = "https://dev.yixiong-tech.com:8081"
const val PROD_BASE_URL = "https://api.dm.yixiong-tech.com:8443"
/** /**
* 设备id * 设备id
*/ */
var deviceId: String = "" var deviceId: String = ""
/**
* 当前设备角色,启动时从 SpTool 读取,默认为 SLAVE
*/
var deviceRole: DeviceRole = DeviceRole.SLAVE
var appId = "Hkz1rBk6PZXbS8KwKr67K2eZtsz8bRoMHLg64bUdgCZj" var appId = "Hkz1rBk6PZXbS8KwKr67K2eZtsz8bRoMHLg64bUdgCZj"
var sdkKey = "AabXs3sHM8UhhE7oCGf4LVMLrdkGb1nJNksbjjTdVt7k" var sdkKey = "AabXs3sHM8UhhE7oCGf4LVMLrdkGb1nJNksbjjTdVt7k"
} }
@@ -6,10 +6,14 @@ import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.migration.Migration import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteDatabase
import com.shuwei.dish.match.db.dao.AppDao import com.shuwei.dish.match.db.dao.CookFoodDao
import com.shuwei.dish.match.db.dao.CookFoodGoodsDao
import com.shuwei.dish.match.db.dao.SeasoningDao
import com.shuwei.dish.match.db.dao.SeasoningSlotDao
import com.shuwei.dish.match.entity.CookFoodEntity import com.shuwei.dish.match.entity.CookFoodEntity
import com.shuwei.dish.match.entity.CookFoodGoodsEntity import com.shuwei.dish.match.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.entity.SeasoningEntity import com.shuwei.dish.match.entity.SeasoningEntity
import com.shuwei.dish.match.entity.SeasoningSlotEntity
// 步骤1:更新版本号 // 步骤1:更新版本号
@@ -19,16 +23,20 @@ import com.shuwei.dish.match.entity.SeasoningEntity
SeasoningEntity::class, SeasoningEntity::class,
CookFoodEntity::class, CookFoodEntity::class,
CookFoodGoodsEntity::class, CookFoodGoodsEntity::class,
SeasoningSlotEntity::class,
], ],
version = 7, version = 8,
exportSchema = true exportSchema = true
) )
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
// abstract fun seasoningDao(): SeasoningDao
// abstract fun cookFoodDao(): CookFoodDao
// abstract fun cookFoodGoodsDao(): CookFoodGoodsDao
abstract fun appDao(): AppDao abstract fun cookFoodDao(): CookFoodDao
abstract fun cookFoodGoodsDao(): CookFoodGoodsDao
abstract fun seasoningDao(): SeasoningDao
abstract fun seasoningSlotDao(): SeasoningSlotDao
} }
@@ -140,6 +148,20 @@ val MIGRATION_5_6 = MigrationImpl(5, 6) { db ->
db.endTransaction() 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 -> val MIGRATION_6_7 = MigrationImpl(6, 7) {db ->
runCatching { runCatching {
@@ -210,6 +232,7 @@ class DatabaseProvider(private val context: Context) {
// .addMigrations(MIGRATION_4_5) // .addMigrations(MIGRATION_4_5)
// .addMigrations(MIGRATION_5_6) // .addMigrations(MIGRATION_5_6)
// .addMigrations(MIGRATION_6_7) // .addMigrations(MIGRATION_6_7)
.addMigrations(MIGRATION_7_8)
.build() .build()
} }
} }
@@ -1,109 +1,158 @@
package com.shuwei.dish.match.db package com.shuwei.dish.match.db
import com.shuwei.dish.match.base.BaseApp import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.db.dao.AppDao
import com.shuwei.dish.match.entity.CookFoodEntity import com.shuwei.dish.match.entity.CookFoodEntity
import com.shuwei.dish.match.entity.CookFoodGoodsEntity import com.shuwei.dish.match.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.entity.SeasoningEntity import com.shuwei.dish.match.entity.SeasoningEntity
import com.shuwei.dish.match.entity.SeasoningSlotEntity
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class AppRepository(val appDao: AppDao) { class DbRepository {
private val db = BaseApp.instance!!.database
suspend fun insertCookFood(item: CookFoodEntity) = withContext(Dispatchers.IO) { suspend fun insertCookFood(item: CookFoodEntity) = withContext(Dispatchers.IO) {
appDao.insertCookFood(item) db.cookFoodDao().insertCookFood(item)
} }
suspend fun updateCookFood(item: CookFoodEntity) = withContext(Dispatchers.IO) { suspend fun updateCookFood(item: CookFoodEntity) = withContext(Dispatchers.IO) {
appDao.updateCookFood(item) db.cookFoodDao().updateCookFood(item)
} }
suspend fun getCookFoodById(foodId: String, cookMode: Int) = withContext(Dispatchers.IO) { suspend fun getCookFoodById(foodId: String, cookMode: Int) = withContext(Dispatchers.IO) {
appDao.getCookFoodById(BaseApp.canteenId, foodId, cookMode) db.cookFoodDao().getCookFoodById(BaseApp.canteenId, foodId, cookMode)
} }
suspend fun getCookFoodList(cookMode: Int, dinnerType: String) = withContext(Dispatchers.IO) { suspend fun getCookFoodList(cookMode: Int, dinnerType: String) = withContext(Dispatchers.IO) {
appDao.getCookFoodList(BaseApp.canteenId, cookMode, dinnerType) db.cookFoodDao().getCookFoodList(BaseApp.canteenId, cookMode, dinnerType)
} }
suspend fun countCookFood(cookMode: Int) = withContext(Dispatchers.IO) { suspend fun countCookFood(cookMode: Int) = withContext(Dispatchers.IO) {
appDao.countCookFood(BaseApp.canteenId, cookMode) db.cookFoodDao().countCookFood(BaseApp.canteenId, cookMode)
} }
suspend fun deleteCookFood(foodId: String, cookMode: Int) = withContext(Dispatchers.IO) { suspend fun deleteCookFood(foodId: String, cookMode: Int) = withContext(Dispatchers.IO) {
appDao.deleteCookFood(BaseApp.canteenId, foodId, cookMode) db.cookFoodDao().deleteCookFood(BaseApp.canteenId, foodId, cookMode)
} }
suspend fun deleteCookFoodGoodsList(pid:Long, foodId: String) = withContext(Dispatchers.IO) { suspend fun deleteCookFoodGoodsList(pid: Long, foodId: String) = withContext(Dispatchers.IO) {
appDao.deleteCookFoodGoodsList(pid, foodId) db.cookFoodGoodsDao().deleteCookFoodGoodsList(pid, foodId)
} }
//-------------------------------------------------------------------------------------------- //--------------------------------------------------------------------------------------------
// suspend fun insertGoods(item: CookFoodGoodsEntity) = withContext(Dispatchers.IO) {
// appDao.insertGoods(item)
// }
suspend fun insertGoodsList(items: MutableList<CookFoodGoodsEntity>) = suspend fun insertGoodsList(items: MutableList<CookFoodGoodsEntity>) =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
appDao.insertGoodsList(items) db.cookFoodGoodsDao().insertGoodsList(items)
} }
// suspend fun updateGoods(item: CookFoodGoodsEntity) = withContext(Dispatchers.IO) { suspend fun getCookFoodGoodsList(pid: Long, foodId: String) = withContext(Dispatchers.IO) {
// appDao.updateGoods(item) db.cookFoodGoodsDao().getCookFoodGoodsList(pid, foodId)
// }
suspend fun getCookFoodGoodsList(pid:Long, foodId: String) = withContext(Dispatchers.IO) {
appDao.getCookFoodGoodsList(pid, foodId)
} }
suspend fun getCookFoodGoodsListByType( suspend fun getCookFoodGoodsListByType(
foodId: String, foodId: String,
materialType: Int materialType: Int
) = withContext(Dispatchers.IO) { ) = withContext(Dispatchers.IO) {
appDao.getCookFoodGoodsListByType(foodId, materialType) db.cookFoodGoodsDao().getCookFoodGoodsListByType(foodId, materialType)
} }
//-------------------------------------------------------------------------------------------- //--------------------------------------------------------------------------------------------
suspend fun getSeasoningById(id: Long) = withContext(Dispatchers.IO) { suspend fun getSeasoningById(id: Long) = withContext(Dispatchers.IO) {
appDao.getSeasoningById(id) db.seasoningDao().getSeasoningById(id)
} }
suspend fun getSeasoningBySort(sort:Int) = withContext(Dispatchers.IO) { suspend fun getSeasoningBySort(sort: Int) = withContext(Dispatchers.IO) {
appDao.getSeasoningBySort(sort) db.seasoningDao().getSeasoningBySort(sort)
} }
suspend fun getSeasoningByGoodsId(goodsId: Int) = withContext(Dispatchers.IO) { suspend fun getSeasoningByGoodsId(goodsId: Int) = withContext(Dispatchers.IO) {
appDao.getSeasoningByGoodsId(goodsId) db.seasoningDao().getSeasoningByGoodsId(goodsId)
} }
suspend fun getAllStream() = withContext(Dispatchers.IO) { suspend fun getAllStream() = withContext(Dispatchers.IO) {
appDao.getAllStream() db.seasoningDao().getAllStream()
} }
suspend fun search(query: String) = withContext(Dispatchers.IO) { suspend fun search(query: String) = withContext(Dispatchers.IO) {
appDao.search(query) db.seasoningDao().search(query)
} }
suspend fun clearAllSeasoning() = withContext(Dispatchers.IO) { suspend fun clearAllSeasoning() = withContext(Dispatchers.IO) {
appDao.clearAllSeasoning() db.seasoningDao().clearAllSeasoning()
} }
suspend fun deleteSeasoningBySort(sort: Int) = withContext(Dispatchers.IO) { suspend fun deleteSeasoningBySort(sort: Int) = withContext(Dispatchers.IO) {
appDao.deleteSeasoningBySort(sort) db.seasoningDao().deleteSeasoningBySort(sort)
} }
suspend fun insertSeasoningList(items: MutableList<SeasoningEntity>) = suspend fun insertSeasoningList(items: MutableList<SeasoningEntity>) =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
appDao.insertSeasoningList(items) db.seasoningDao().insertSeasoningList(items)
} }
suspend fun updateSeasoning(item: SeasoningEntity) = withContext(Dispatchers.IO) { suspend fun updateSeasoning(item: SeasoningEntity) = withContext(Dispatchers.IO) {
appDao.updateSeasoning(item) db.seasoningDao().updateSeasoning(item)
} }
suspend fun insertSeasoning(item: SeasoningEntity) = withContext(Dispatchers.IO) { suspend fun insertSeasoning(item: SeasoningEntity) = withContext(Dispatchers.IO) {
appDao.insertSeasoning(item) db.seasoningDao().insertSeasoning(item)
}
/**
* 查询指定设备是否已配置调料槽位
* @param deviceId 目标设备 ID
* @return true 表示已有配置false 表示未配置
*/
suspend fun hasSeasoningSlotConfig(deviceId: String) = withContext(Dispatchers.IO) {
db.seasoningSlotDao().queryByDeviceId(deviceId).isNotEmpty()
}
/**
* 查询所有设备的调料槽位配置
*/
suspend fun getAllSeasoningSlots() = withContext(Dispatchers.IO) {
db.seasoningSlotDao().queryAll()
}
/**
* 查询指定设备的调料槽位配置
* @param deviceId 目标设备 ID
*/
suspend fun getSeasoningSlotsByDeviceId(deviceId: String) = withContext(Dispatchers.IO) {
db.seasoningSlotDao().queryByDeviceId(deviceId)
}
/**
* 插入或更新单个调料槽位配置
*/
suspend fun upsertSeasoningSlot(slot: SeasoningSlotEntity) = withContext(Dispatchers.IO) {
db.seasoningSlotDao().upsert(slot)
}
/**
* 批量插入或更新调料槽位配置
*/
suspend fun upsertAllSeasoningSlots(slots: List<SeasoningSlotEntity>) = withContext(Dispatchers.IO) {
db.seasoningSlotDao().upsertAll(slots)
}
/**
* 删除指定设备的指定槽位配置
* @param deviceId 目标设备 ID
* @param address 秤硬件地址
*/
suspend fun deleteSeasoningSlot(deviceId: String, address: Int) = withContext(Dispatchers.IO) {
db.seasoningSlotDao().deleteByDeviceIdAndAddress(deviceId, address)
}
/**
* 删除指定设备的全部槽位配置
* @param deviceId 目标设备 ID
*/
suspend fun deleteAllSlotsByDeviceId(deviceId: String) = withContext(Dispatchers.IO) {
db.seasoningSlotDao().deleteAllByDeviceId(deviceId)
} }
} }
// //
@@ -0,0 +1,305 @@
package com.shuwei.dish.match.db
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.shuwei.dish.match.entity.CollectedFoodItem
import com.shuwei.dish.match.entity.CookFoodEntity
import com.shuwei.dish.match.objbox.ObjectBox
import com.shuwei.dish.match.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.entity.SeasoningEntity
import com.shuwei.dish.match.entity.SeasoningSlotEntity
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class DbViewModel : ViewModel() {
private val rep = DbRepository()
fun updateCookFood(entity: CookFoodEntity) {
viewModelScope.launch {
rep.updateCookFood(entity)
}
}
private val _cookFoodDetailState = MutableStateFlow<CookFoodEntity?>(null)
val cookFoodDetailState: StateFlow<CookFoodEntity?> = _cookFoodDetailState.asStateFlow()
fun getCookFoodById(foodId: String, cookMode: Int) {
viewModelScope.launch {
_cookFoodDetailState.value = rep.getCookFoodById(foodId, cookMode)
}
}
private val _cookFoodListState = MutableStateFlow<MutableList<CookFoodEntity>?>(null)
val cookFoodListState: StateFlow<MutableList<CookFoodEntity>?> = _cookFoodListState.asStateFlow()
fun resetCookFoodList() {
_cookFoodListState.value = null
}
fun getCookFoodList(
cookMode: Int,
dinnerType: String = "0"
) {
viewModelScope.launch {
_cookFoodListState.value = rep.getCookFoodList(cookMode, dinnerType)
}
}
suspend fun countCookFood(cookMode: Int): Int {
return rep.countCookFood(cookMode)
}
// fun saveCookFood(cookMode: Int, entity: CookFoodEntity) {
// viewModelScope.launch {
// val data = rep.getCookFoodById(entity.foodId, cookMode)
// if (data == null) {
// rep.insertCookFood(entity)
// return@launch
// }
// rep.updateCookFood(entity)
// }
// }
suspend fun saveCookFoodAndGoods(
cookMode: Int,
entity: CookFoodEntity,
list: MutableList<CookFoodGoodsEntity>?
) {
val data = rep.getCookFoodById(entity.foodId, cookMode)
if (data != null) {
// 存在数据先删除
rep.deleteCookFood(entity.foodId, cookMode)
rep.deleteCookFoodGoodsList(data.id, entity.foodId)
}
// 重新保存数据
rep.insertCookFood(entity)
val newData = rep.getCookFoodById(entity.foodId, cookMode)
list?.let { it ->
it.forEach { goods ->
goods.id = 0
goods.pid = newData?.id ?: 0
}
rep.insertGoodsList(it)
}
}
suspend fun deleteCookFoodAndGoods(cookMode: Int, foodId: String) {
val data = rep.getCookFoodById(foodId, cookMode)
if (data != null) {
rep.deleteCookFood(foodId = foodId, cookMode = cookMode)
rep.deleteCookFoodGoodsList(pid = data.id, foodId = foodId)
}
}
//--------------------------------------------------------------------------------------------------------
suspend fun getCookFoodGoodsList(
foodId: String,
cookMode: Int
): MutableList<CookFoodGoodsEntity> {
val cookFood = rep.getCookFoodById(foodId, cookMode)
return if (cookFood != null) rep.getCookFoodGoodsList(pid = cookFood.id, foodId = cookFood.foodId)
else mutableListOf()
}
// fun updateGoods(entity: CookFoodGoodsEntity) {
// viewModelScope.launch {
// rep.updateGoods(entity)
// }
// }
// fun saveCookFoodGoods(list: MutableList<CookFoodGoodsEntity>) {
// viewModelScope.launch {
// val foodId = list[0].foodId
// val data: MutableList<CookFoodGoodsEntity>? =
// rep.getCookFoodGoodsList(foodId!!)
// if (data.isNullOrEmpty()) {
// rep.insertGoodsList(list)
// return@launch
// }
// val localDbSeasoningList = rep.getCookFoodGoodsListByType(foodId, 3)
// localDbSeasoningList.forEach {
// it.isDel = 1
// //更新本地调料数据为已删除的状态
// rep.updateGoods(it)
// }
// //筛选出调料数据保存,主辅材不会变更不需要再次处理
// val saveSeasoningList = list.filter { it.materialType == 3 }
// rep.insertGoodsList(saveSeasoningList.toMutableList())
// }
// }
//--------------------------------------------------------------------------------------------------------
// fun addSeasoning(list: MutableList<SeasoningEntity>, callback: () -> Unit) {
// viewModelScope.launch {
// rep.deleteBatch()
// rep.insertSeasoningList(items = list)
// callback()
// }
// }
private val loadSeasoningMutex = Mutex()
/**
* 本地调料列表状态流
*/
private val _seasoningState = MutableStateFlow<MutableList<SeasoningEntity>>(mutableListOf())
val seasoningState: StateFlow<MutableList<SeasoningEntity>> = _seasoningState.asStateFlow()
fun loadSeasoning() {
if (loadSeasoningMutex.isLocked) return
viewModelScope.launch {
loadSeasoningMutex.withLock {
_seasoningState.value = rep.getAllStream()
}
}
}
/**
* 所有设备调料槽位配置的 UI 状态流
*/
private val _seasoningSlotState = MutableStateFlow<List<SeasoningSlotEntity>>(emptyList())
val seasoningSlotState: StateFlow<List<SeasoningSlotEntity>> = _seasoningSlotState.asStateFlow()
/**
* 查询所有调料槽位配置,更新 seasoningSlotState 并返回结果
* 声明为 suspend,调用方可在同一协程中直接使用返回值,无需等待 StateFlow 更新
*/
suspend fun loadSeasoningSlot(): List<SeasoningSlotEntity> {
val slots = rep.getAllSeasoningSlots()
_seasoningSlotState.value = slots
return slots
}
fun deleteSeasoningBySort(sort: Int) {
viewModelScope.launch {
rep.deleteSeasoningBySort(sort)
}
}
private val _clearAllSeasoningState = MutableStateFlow(false)
val clearAllSeasoningState: StateFlow<Boolean> = _clearAllSeasoningState.asStateFlow()
fun clearAllSeasoning() {
viewModelScope.launch {
rep.clearAllSeasoning()
_clearAllSeasoningState.value = true
}
}
private val _hasSeasoningSlotConfigState = MutableStateFlow(false)
val hasSeasoningSlotConfigState: StateFlow<Boolean> = _hasSeasoningSlotConfigState.asStateFlow()
/**
* 查询指定设备是否已配置调料槽位
* @param deviceId 目标设备 ID
*/
fun hasSeasoningSlotConfig(deviceId: String) {
viewModelScope.launch {
_hasSeasoningSlotConfigState.value = rep.hasSeasoningSlotConfig(deviceId)
}
}
/**
* 查询指定设备的调料槽位配置
*/
suspend fun getSeasoningSlotsByDeviceId(deviceId: String) =
rep.getSeasoningSlotsByDeviceId(deviceId)
/**
* 插入或更新单个调料槽位配置
*/
suspend fun upsertSeasoningSlot(slot: SeasoningSlotEntity) =
rep.upsertSeasoningSlot(slot)
/**
* 批量插入或更新调料槽位配置
*/
suspend fun upsertAllSeasoningSlots(slots: List<SeasoningSlotEntity>) =
rep.upsertAllSeasoningSlots(slots)
/**
* 删除指定设备的指定槽位配置
* @param deviceId 目标设备 ID
* @param address 秤硬件地址
*/
suspend fun deleteSeasoningSlot(deviceId: String, address: Int) =
rep.deleteSeasoningSlot(deviceId, address)
/**
* 删除指定设备的全部槽位配置(子设备接收全量广播时先清空再写入)
* @param deviceId 目标设备 ID
*/
suspend fun deleteAllSlotsByDeviceId(deviceId: String) =
rep.deleteAllSlotsByDeviceId(deviceId)
// fun updateAll(list: MutableList<SeasoningEntity>) {
// viewModelScope.launch {
// list.forEach {
// rep.updateSeasoning(it)
// }
// }
// }
fun saveSeasoning(entity: SeasoningEntity, block: () -> Unit) {
viewModelScope.launch {
val list = rep.getSeasoningBySort(entity.sort)
val size = list?.size?:0
if (size == 1) {
rep.updateSeasoning(entity.also { it.id = list!![0].id })
block()
return@launch
}
if (size > 1) {
list?.forEach {
rep.deleteSeasoningBySort(it.sort)
}
}
entity.id = 0
val id = rep.insertSeasoning(entity)
entity.id = id
block()
}
}
//--------------------------------------------------------------------------------------------------------
/**
* 已采集食材列表 UI 状态流
* 元素为按名称分组后的(名称,数量)数据
*/
private val _collectedFoodListState = MutableStateFlow<List<CollectedFoodItem>>(emptyList())
val collectedFoodListState: StateFlow<List<CollectedFoodItem>> = _collectedFoodListState.asStateFlow()
/**
* 加载已采集食材列表,按名称分组统计数量
* 使用属性查询只读 name 字段,不加载 foodVector,节省内存
* @param nameFilter 名称过滤关键词,为空时加载全部
*/
fun loadCollectedFoodList(nameFilter: String? = null) {
viewModelScope.launch {
val result = ObjectBox.queryFoodNameCounts(nameFilter)
_collectedFoodListState.value = result.map { (name, count) ->
CollectedFoodItem(foodName = name, collectCount = count)
}
}
}
/**
* 删除指定名称的所有采集记录,删除完成后刷新列表
* @param foodName 食材名称
* @param nameFilter 当前搜索关键词,用于删除后刷新列表
*/
fun removeCollectedFood(foodName: String, nameFilter: String? = null) {
viewModelScope.launch {
ObjectBox.remove(foodName)
loadCollectedFoodList(nameFilter)
}
}
//--------------------------------------------------------------------------------------------------------
}
@@ -1,92 +0,0 @@
package com.shuwei.dish.match.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import com.shuwei.dish.match.entity.CookFoodEntity
import com.shuwei.dish.match.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.entity.SeasoningEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface AppDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertCookFood(item: CookFoodEntity): Long
@Update
suspend fun updateCookFood(item: CookFoodEntity)
@Query("SELECT * FROM dm_cook_food WHERE isDel = 0 AND canteenId = :canteenId AND foodId = :foodId AND cookMode = :cookMode")
suspend fun getCookFoodById(canteenId:String, foodId: String, cookMode: Int): CookFoodEntity?
@Query("SELECT * FROM dm_cook_food WHERE isDel = 0 AND canteenId = :canteenId AND cookMode = :cookMode AND dinnerType = :dinnerType ORDER BY createTime DESC")
suspend fun getCookFoodList(canteenId:String, cookMode: Int, dinnerType:String): MutableList<CookFoodEntity>?
@Query("SELECT count(1) FROM dm_cook_food WHERE isDel = 0 AND canteenId = :canteenId AND cookMode = :cookMode")
suspend fun countCookFood(canteenId:String, cookMode: Int): Int
@Query("DELETE FROM dm_cook_food WHERE canteenId = :canteenId AND foodId = :foodId AND cookMode = :cookMode")
suspend fun deleteCookFood(canteenId:String, foodId: String, cookMode: Int)
//--------------------------------------------------------------------------------------------
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertGoods(item: CookFoodGoodsEntity): Long
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertGoodsList(items: MutableList<CookFoodGoodsEntity>): Array<Long>
@Update
suspend fun updateGoods(item: CookFoodGoodsEntity)
@Query("SELECT * FROM dm_cook_food_goods WHERE isDel = 0 AND pid = :pid AND foodId = :foodId")
suspend fun getCookFoodGoodsList(pid:Long, foodId: String): MutableList<CookFoodGoodsEntity>
@Query("SELECT * FROM dm_cook_food_goods WHERE isDel = 0 AND foodId = :foodId AND materialType = :materialType")
suspend fun getCookFoodGoodsListByType(
foodId: String,
materialType: Int
): MutableList<CookFoodGoodsEntity>
@Query("DELETE FROM dm_cook_food_goods WHERE pid = :pid AND foodId = :foodId")
suspend fun deleteCookFoodGoodsList(pid:Long, foodId: String)
//--------------------------------------------------------------------------------------------
@Query("SELECT * FROM dm_seasoning WHERE isDel = 0 AND id = :id")
suspend fun getSeasoningById(id: Long): SeasoningEntity?
@Query("SELECT * FROM dm_seasoning WHERE isDel = 0 AND sort = :sort")
suspend fun getSeasoningBySort(sort: Int): MutableList<SeasoningEntity>?
@Query("SELECT * FROM dm_seasoning WHERE isDel = 0 AND goodsId = :goodsId")
suspend fun getSeasoningByGoodsId(goodsId: Int): SeasoningEntity?
// @Query("SELECT * FROM dm_seasoning WHERE isDel = 0 AND goodsId != -1 AND goodsName != '' ORDER BY sort ASC")
// fun getAllStream(): Flow<MutableList<SeasoningEntity>>
@Query("SELECT * FROM dm_seasoning WHERE isDel = 0 AND goodsId != -1 AND goodsName != '' ORDER BY sort ASC")
fun getAllStream(): MutableList<SeasoningEntity>
@Query("SELECT * FROM dm_seasoning WHERE isDel = 0 AND goodsName LIKE '%' || :query || '%'")
suspend fun search(query: String): MutableList<SeasoningEntity>
@Query("DELETE FROM dm_seasoning WHERE isDel = 0 AND sort = :sort")
suspend fun deleteSeasoningBySort(sort:Int): Int
@Query("DELETE FROM dm_seasoning WHERE isDel = 0")
suspend fun clearAllSeasoning(): Int
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertSeasoningList(items: MutableList<SeasoningEntity>): Array<Long>
@Update
suspend fun updateSeasoning(item: SeasoningEntity)
@Insert
suspend fun insertSeasoning(item: SeasoningEntity): Long
//--------------------------------------------------------------------------------------------
}
@@ -1,20 +1,30 @@
//package com.shuwei.dish.match.db.dao package com.shuwei.dish.match.db.dao
//
//import androidx.room.Dao import androidx.room.Dao
//import androidx.room.Query import androidx.room.Insert
//import com.shuwei.dish.match.db.BaseDao import androidx.room.OnConflictStrategy
//import com.shuwei.dish.match.entity.CookFoodEntity import androidx.room.Query
// import androidx.room.Update
//@Dao import com.shuwei.dish.match.entity.CookFoodEntity
//interface CookFoodDao : BaseDao<CookFoodEntity> {
// @Dao
// @Query("SELECT * FROM dm_cook_food WHERE isDel = 0 AND foodId = :foodId AND cookMode = :cookMode") interface CookFoodDao {
// suspend fun getCookFoodById(foodId: String, cookMode:Int): CookFoodEntity?
// @Insert(onConflict = OnConflictStrategy.REPLACE)
// @Query("SELECT * FROM dm_cook_food WHERE isDel = 0 AND cookMode = :cookMode") suspend fun insertCookFood(item: CookFoodEntity): Long
// suspend fun getCookFoodList(cookMode:Int): MutableList<CookFoodEntity>?
// @Update
// @Query("SELECT count(1) FROM dm_cook_food WHERE isDel = 0 AND cookMode = :cookMode") suspend fun updateCookFood(item: CookFoodEntity)
// suspend fun countCookFood(cookMode:Int): Int
// @Query("SELECT * FROM dm_cook_food WHERE isDel = 0 AND canteenId = :canteenId AND foodId = :foodId AND cookMode = :cookMode")
//} suspend fun getCookFoodById(canteenId: String, foodId: String, cookMode: Int): CookFoodEntity?
@Query("SELECT * FROM dm_cook_food WHERE isDel = 0 AND canteenId = :canteenId AND cookMode = :cookMode AND dinnerType = :dinnerType ORDER BY createTime DESC")
suspend fun getCookFoodList(canteenId: String, cookMode: Int, dinnerType: String): MutableList<CookFoodEntity>?
@Query("SELECT count(1) FROM dm_cook_food WHERE isDel = 0 AND canteenId = :canteenId AND cookMode = :cookMode")
suspend fun countCookFood(canteenId: String, cookMode: Int): Int
@Query("DELETE FROM dm_cook_food WHERE canteenId = :canteenId AND foodId = :foodId AND cookMode = :cookMode")
suspend fun deleteCookFood(canteenId: String, foodId: String, cookMode: Int)
}
@@ -1,29 +1,30 @@
//package com.shuwei.dish.match.db.dao package com.shuwei.dish.match.db.dao
//
//import androidx.room.Dao import androidx.room.Dao
//import androidx.room.Query import androidx.room.Insert
//import com.shuwei.dish.match.db.BaseDao import androidx.room.OnConflictStrategy
//import com.shuwei.dish.match.entity.CookFoodGoodsEntity import androidx.room.Query
// import androidx.room.Update
//@Dao import com.shuwei.dish.match.entity.CookFoodGoodsEntity
//interface CookFoodGoodsDao : BaseDao<CookFoodGoodsEntity> {
// @Dao
// @Query("SELECT * FROM dm_cook_food_goods WHERE isDel = 0 AND foodId = :foodId") interface CookFoodGoodsDao {
// suspend fun getCookFoodGoodsList(foodId: String): MutableList<CookFoodGoodsEntity>
// @Insert(onConflict = OnConflictStrategy.REPLACE)
// @Query("SELECT * FROM dm_cook_food_goods WHERE isDel = 0 AND foodId = :foodId AND materialType = :materialType") suspend fun insertGoods(item: CookFoodGoodsEntity): Long
// suspend fun getCookFoodGoodsListByType(foodId: String, materialType:Int): MutableList<CookFoodGoodsEntity>
// @Insert(onConflict = OnConflictStrategy.REPLACE)
//// @Query( suspend fun insertGoodsList(items: MutableList<CookFoodGoodsEntity>): Array<Long>
//// """
//// UPDATE dm_cook_food_goods @Update
//// SET goodsName=:entity.goodsName, foodId=:entity.foodId, relateionType=:entity.relateionType, sort=:entity.sort suspend fun updateGoods(item: CookFoodGoodsEntity)
//// allEdible=:entity.allEdible, useWeight=:entity.useWeight, isDel=:entity.isDel, createTime=:entity.createTime
//// WHERE isDel = 0 @Query("SELECT * FROM dm_cook_food_goods WHERE isDel = 0 AND pid = :pid AND foodId = :foodId")
//// AND foodId=:entity.foodId suspend fun getCookFoodGoodsList(pid: Long, foodId: String): MutableList<CookFoodGoodsEntity>
//// AND goodsId=:goodsId
//// """ @Query("SELECT * FROM dm_cook_food_goods WHERE isDel = 0 AND foodId = :foodId AND materialType = :materialType")
//// ) suspend fun getCookFoodGoodsListByType(foodId: String, materialType: Int): MutableList<CookFoodGoodsEntity>
//// fun updateByGoodsId(entity: CookFoodGoodsEntity)
// @Query("DELETE FROM dm_cook_food_goods WHERE pid = :pid AND foodId = :foodId")
//} suspend fun deleteCookFoodGoodsList(pid: Long, foodId: String)
}
@@ -1,25 +1,42 @@
//package com.shuwei.dish.match.db.dao package com.shuwei.dish.match.db.dao
//
//import androidx.room.Dao import androidx.room.Dao
//import androidx.room.Query import androidx.room.Insert
//import com.shuwei.dish.match.db.BaseDao import androidx.room.OnConflictStrategy
//import com.shuwei.dish.match.entity.SeasoningEntity import androidx.room.Query
//import kotlinx.coroutines.flow.Flow import androidx.room.Update
// import com.shuwei.dish.match.entity.SeasoningEntity
//@Dao
//interface SeasoningDao : BaseDao<SeasoningEntity> { @Dao
// interface SeasoningDao {
//// @Query("SELECT * FROM dm_seasoning WHERE isDel = 0 AND id = :id")
//// suspend fun getSeasoningById(id: Long): SeasoningEntity? @Insert(onConflict = OnConflictStrategy.REPLACE)
//// suspend fun insertSeasoning(item: SeasoningEntity): Long
//// @Query("SELECT * FROM dm_seasoning WHERE isDel = 0 ORDER BY sort ASC")
//// fun getAllStream(): Flow<MutableList<SeasoningEntity>> @Insert(onConflict = OnConflictStrategy.REPLACE)
//// suspend fun insertSeasoningList(items: MutableList<SeasoningEntity>): Array<Long>
//// @Query("SELECT * FROM dm_seasoning WHERE isDel = 0 AND name LIKE '%' || :query || '%'")
//// suspend fun search(query: String): MutableList<SeasoningEntity> @Update
//// suspend fun updateSeasoning(item: SeasoningEntity)
//// @Query("DELETE FROM dm_seasoning WHERE rowid IN " +
//// "(SELECT rowid FROM dm_seasoning LIMIT 1000)") @Query("SELECT * FROM dm_seasoning WHERE isDel = 0 AND id = :id")
//// suspend fun deleteBatch(): Int suspend fun getSeasoningById(id: Long): SeasoningEntity?
//
//} @Query("SELECT * FROM dm_seasoning WHERE isDel = 0 AND sort = :sort")
suspend fun getSeasoningBySort(sort: Int): MutableList<SeasoningEntity>?
@Query("SELECT * FROM dm_seasoning WHERE isDel = 0 AND goodsId = :goodsId")
suspend fun getSeasoningByGoodsId(goodsId: Int): SeasoningEntity?
@Query("SELECT * FROM dm_seasoning WHERE isDel = 0 AND goodsId != -1 AND goodsName != '' ORDER BY sort ASC")
fun getAllStream(): MutableList<SeasoningEntity>
@Query("SELECT * FROM dm_seasoning WHERE isDel = 0 AND goodsName LIKE '%' || :query || '%'")
suspend fun search(query: String): MutableList<SeasoningEntity>
@Query("DELETE FROM dm_seasoning WHERE isDel = 0 AND sort = :sort")
suspend fun deleteSeasoningBySort(sort: Int): Int
@Query("DELETE FROM dm_seasoning WHERE isDel = 0")
suspend fun clearAllSeasoning(): Int
}
@@ -0,0 +1,55 @@
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<SeasoningSlotEntity>)
/**
* 查询指定设备的全部槽位配置
* @param deviceId 目标设备 ID
*/
@Query("SELECT * FROM dm_seasoning_slot WHERE deviceId = :deviceId")
suspend fun queryByDeviceId(deviceId: String): List<SeasoningSlotEntity>
/**
* 查询所有设备的全部槽位配置(主设备广播时使用)
*/
@Query("SELECT * FROM dm_seasoning_slot")
suspend fun queryAll(): List<SeasoningSlotEntity>
/**
* 删除指定设备的指定槽位配置
* @param deviceId 目标设备 ID
* @param address 秤硬件地址
*/
@Query("DELETE FROM dm_seasoning_slot WHERE deviceId = :deviceId AND address = :address")
suspend fun deleteByDeviceIdAndAddress(deviceId: String, address: Int)
/**
* 删除指定设备的全部槽位配置(子设备接收全量广播时先清空再写入)
* @param deviceId 目标设备 ID
*/
@Query("DELETE FROM dm_seasoning_slot WHERE deviceId = :deviceId")
suspend fun deleteAllByDeviceId(deviceId: String)
}
@@ -7,9 +7,11 @@ import android.graphics.drawable.ColorDrawable
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import android.view.Window import android.view.Window
import android.view.WindowManager import android.widget.LinearLayout
import androidx.annotation.StyleRes import androidx.annotation.StyleRes
import androidx.fragment.app.FragmentActivity import androidx.core.view.updateLayoutParams
import com.shuwei.dish.match.base.DeviceRole
import com.shuwei.dish.match.base.GlobalData
import com.shuwei.dish.match.databinding.DialogCommonBinding import com.shuwei.dish.match.databinding.DialogCommonBinding
import com.shuwei.dish.match.utils.ext.dp import com.shuwei.dish.match.utils.ext.dp
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
@@ -20,53 +22,117 @@ open class CommonDialog(
private lateinit var binding: DialogCommonBinding private lateinit var binding: DialogCommonBinding
private var titleText: String? = null
private var contentText: CharSequence? = null
private var negativeText = "取消"
private var positiveText = "确认"
private var neutralText: String? = null
private var negativeClick: (() -> Unit)? = null
private var positiveClick: (() -> Unit)? = null
private var neutralClick: (() -> Unit)? = null
private var dismissCallback: (() -> Unit)? = null
/** 设置标题,为空时隐藏 */
fun setTitle(text: String): CommonDialog = apply { titleText = text }
/** 设置内容,为空时隐藏 */
fun setContent(text: CharSequence): CommonDialog = apply { contentText = text }
/** 设置左侧取消按钮文字及点击回调(3按钮模式下对应顶部按钮) */
fun setNegativeButton(text: String, onClick: (() -> Unit)? = null): CommonDialog = apply {
negativeText = text
negativeClick = onClick
}
/** 设置右侧确认按钮文字及点击回调(3按钮模式下对应底部按钮) */
fun setPositiveButton(text: String, onClick: (() -> Unit)? = null): CommonDialog = apply {
positiveText = text
positiveClick = onClick
}
/** 设置中间第三个按钮文字及点击回调,调用后自动切换为垂直3按钮布局 */
fun setNeutralButton(text: String, onClick: (() -> Unit)? = null): CommonDialog = apply {
neutralText = text
neutralClick = onClick
}
/** 设置弹窗消失回调 */
fun setOnDismissCallback(callback: () -> Unit): CommonDialog = apply {
dismissCallback = callback
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
requestWindowFeature(Window.FEATURE_NO_TITLE) requestWindowFeature(Window.FEATURE_NO_TITLE)
binding = DialogCommonBinding.inflate(layoutInflater) binding = DialogCommonBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
// 弹窗不允许点击外部关闭
setCancelable(false)
window?.run { window?.run {
// decorView.setPadding(0.dp, 0, 0.dp, 0) val screenWidth = context.resources.displayMetrics.widthPixels
val isMaster = GlobalData.deviceRole == DeviceRole.MASTER
attributes = attributes.apply { attributes = attributes.apply {
width = 708.dp width = if (isMaster) (screenWidth * 0.72f).toInt() else (screenWidth * 0.60f).toInt()
} }
// 透明背景,让 shape_dialog_bg 的阴影层正常显示
setBackgroundDrawable(ColorDrawable()) setBackgroundDrawable(ColorDrawable())
setCancelable(false) // 应用缩放+淡入淡出动画
// setFlags( setWindowAnimations(com.shuwei.dish.match.R.style.CommonDialogAnimation)
// WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, }
// WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE // 主/从设备边距微调:主设备内容区域更大,适当增加上下留白
// ) val verticalMargin = if (GlobalData.deviceRole == DeviceRole.MASTER) 48.dp else 36.dp
binding.tvTitle.updateLayoutParams<LinearLayout.LayoutParams> { topMargin = verticalMargin }
binding.tvContent.updateLayoutParams<LinearLayout.LayoutParams> { bottomMargin = verticalMargin }
// 2按钮点击事件
binding.btnLeft.setOnClickListener {
negativeClick?.invoke()
dismiss()
}
binding.btnRight.setOnClickListener {
positiveClick?.invoke()
dismiss()
} }
binding.llContent.run { // 3按钮点击事件(negativeClick/positiveClick 与2按钮模式共用)
removeAllViews() binding.btnTop.setOnClickListener {
addView(messageView) negativeClick?.invoke()
dismiss()
}
binding.btnMiddle.setOnClickListener {
neutralClick?.invoke()
dismiss()
}
binding.btnBottom.setOnClickListener {
positiveClick?.invoke()
dismiss()
} }
addListener()
}
private fun addListener() { setOnDismissListener { dismissCallback?.invoke() }
binding.btnLeft.setOnClickListener { onLeftClick?.invoke() }
binding.btnRight.setOnClickListener { onRightClick?.invoke() }
setOnDismissListener { onDismiss?.invoke() }
} }
override fun show() { override fun show() {
super.show() super.show()
binding.btnLeft.text = leftText binding.tvTitle.text = titleText
binding.btnRight.text = rightText binding.tvContent.text = contentText
binding.tvTitle.visibility = if (titleText.isNullOrEmpty()) View.GONE else View.VISIBLE
binding.tvContent.visibility = if (contentText.isNullOrEmpty()) View.GONE else View.VISIBLE
val hasThreeButtons = !neutralText.isNullOrEmpty()
binding.layoutButtonsHorizontal.visibility = if (hasThreeButtons) View.GONE else View.VISIBLE
binding.layoutButtonsVertical.visibility = if (hasThreeButtons) View.VISIBLE else View.GONE
if (hasThreeButtons) {
// 3按钮模式:顶部=取消,中间=中立,底部=确认
binding.btnTop.text = negativeText
binding.btnMiddle.text = neutralText
binding.btnBottom.text = positiveText
} else {
// 2按钮模式
binding.btnLeft.text = negativeText
binding.btnRight.text = positiveText
}
} }
var messageView: View? = null
var onLeftClick: (() -> Unit)? = null
var onRightClick: (() -> Unit)? = null
var leftText = ""
var rightText = ""
var onDismiss: (() -> Unit)? = null
} }
open class SafeDialog(activity: Activity, @StyleRes themeResId: Int) : open class SafeDialog(activity: Activity, @StyleRes themeResId: Int) :
@@ -1,38 +0,0 @@
package com.shuwei.dish.match.dialog
import android.content.Context
import android.view.LayoutInflater
import com.shuwei.dish.match.databinding.LayoutFoodRemindBinding
object DialogTool {
fun load(
context: Context,
title:String,
content:String,
leftBtnText: String,
rightBtnText: String,
leftBtnClick: () -> Unit = {},
rightBtnClick: () -> Unit = {}
) {
val layoutInflater = LayoutInflater.from(context)
val remindBinding = LayoutFoodRemindBinding.inflate(layoutInflater)
remindBinding.tvDialogTitle.text = title
remindBinding.tvDialogContent.text = content
CommonDialog(context).apply {
messageView = remindBinding.root
leftText = leftBtnText
rightText = rightBtnText
onLeftClick = {
leftBtnClick()
dismiss()
}
onRightClick = {
rightBtnClick()
dismiss()
}
onDismiss = { }
}.show()
}
}
@@ -0,0 +1,62 @@
package com.shuwei.dish.match.dialog
import android.annotation.SuppressLint
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater
import androidx.recyclerview.widget.GridLayoutManager
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.shuwei.dish.match.R
import com.shuwei.dish.match.adapter.GoodsInfoSearchAdapter
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.databinding.DialogFoodRecognizeBinding
import com.shuwei.dish.match.entity.CookFoodGoodsEntity
/**
* 菜品识别结果弹窗,继承 BottomSheetDialog 确保只初始化一次
* @param activity 宿主 Activity
* @param onDismiss 弹窗关闭时的额外回调(可选)
* @param onItemSelected 用户点击某一菜品时的回调
*/
class FoodRecognizeDialog(
private val activity: BaseActivity,
private val onDismiss: (() -> Unit)? = null,
private val onItemSelected: (item: CookFoodGoodsEntity) -> Unit
) : BottomSheetDialog(activity, R.style.BottomSheet) {
private val binding = DialogFoodRecognizeBinding.inflate(LayoutInflater.from(activity))
private val list = mutableListOf<CookFoodGoodsEntity>()
private val adapter = GoodsInfoSearchAdapter(list).apply {
setOnItemClickListener { _, _, position ->
list[position].isClicked = true
notifyItemChanged(position)
Handler(Looper.getMainLooper()).postDelayed({
onItemSelected(list[position])
dismiss()
}, 300)
}
}
init {
setContentView(binding.root)
setCancelable(true)
behavior.skipCollapsed = false
setOnDismissListener { activity.hideStatusBar(); onDismiss?.invoke() }
binding.recyclerView.run {
layoutManager = GridLayoutManager(activity, 2, GridLayoutManager.VERTICAL, false)
adapter = this@FoodRecognizeDialog.adapter
}
}
/**
* 加载识别结果数据并显示弹窗
* @param items 识别到的菜品列表
*/
@SuppressLint("NotifyDataSetChanged")
fun loadData(items: List<CookFoodGoodsEntity>) {
list.clear()
list.addAll(items)
adapter.notifyDataSetChanged()
if (!isShowing) show()
}
}
@@ -8,6 +8,9 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import androidx.core.view.isEmpty import androidx.core.view.isEmpty
import androidx.core.widget.addTextChangedListener 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.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialog
@@ -17,229 +20,167 @@ import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.base.BaseApp import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.databinding.DialogFoodSearchBinding import com.shuwei.dish.match.databinding.DialogFoodSearchBinding
import com.shuwei.dish.match.entity.CookFoodGoodsEntity import com.shuwei.dish.match.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.net.UiState
import com.shuwei.dish.match.utils.KeyboardUtil import com.shuwei.dish.match.utils.KeyboardUtil
import com.shuwei.dish.match.utils.ext.addOnActionSearchListener import com.shuwei.dish.match.utils.ext.addOnActionSearchListener
import com.shuwei.dish.match.utils.ext.toast import com.shuwei.dish.match.utils.ext.toast
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
/** /**
* 食材搜索弹窗 * 食材搜索弹窗,继承 BottomSheetDialog 确保只初始化一次
* @param activity 宿主 Activity
* @param defGoodsName 默认填充的食材名称(可选)
* @param onItemSelected 用户点击某一食材时的回调
*/ */
class FoodSearchDialog( class FoodSearchDialog(
private var defGoodsName: String? = null private val activity: BaseActivity,
) { private val defGoodsName: String? = null,
companion object { private val onItemSelected: (item: CookFoodGoodsEntity) -> Unit
const val TAG = "BottomDialog" ) : BottomSheetDialog(activity, R.style.BottomSheet) {
private val binding = DialogFoodSearchBinding.inflate(LayoutInflater.from(activity))
private val list = mutableListOf<CookFoodGoodsEntity>()
private val adapter = GoodsInfoSearchAdapter(list).apply {
setOnItemClickListener { _, _, position ->
list[position].isClicked = true
notifyItemChanged(position)
Handler(Looper.getMainLooper()).postDelayed({
onItemSelected(list[position])
dismiss()
}, 300)
}
} }
private var list = mutableListOf<CookFoodGoodsEntity>()
private lateinit var binding: DialogFoodSearchBinding
private lateinit var searchAdapter: GoodsInfoSearchAdapter
private var goodsName: String? = null private var goodsName: String? = null
private var pageNo = 1
private val pageSize = 50
private var activity: BaseActivity? = null init {
setContentView(binding.root)
setCancelable(true)
behavior.skipCollapsed = false
setOnDismissListener { activity.hideStatusBar() }
fun show(activity: BaseActivity, callback: (item: CookFoodGoodsEntity) -> Unit) {
this.activity = activity
//直接使用BottomSheetDialog
var dialog: BottomSheetDialog? = null
val inflater = LayoutInflater.from(activity)
binding = DialogFoodSearchBinding.inflate(inflater)
dialog = BottomSheetDialog(activity, R.style.BottomSheet).apply {
setContentView(binding.root)
setCancelable(true)
behavior.skipCollapsed = false // 跳过折叠状态
// behavior.peekHeight = 880.dp
// window?.setLayout(-1, 880.dp)
show()
setOnDismissListener {
activity.hideStatusBar()
}
binding.etSheetInput.requestFocus()
}
binding.tvSheetName.text = "食材检索" binding.tvSheetName.text = "食材检索"
// 输入框:清空时重置列表
binding.etSheetInput.run { binding.etSheetInput.run {
hint = "输入食材名称或速记码" hint = "输入食材名称或速记码"
addTextChangedListener(afterTextChanged = { it: Editable? -> addTextChangedListener(
if (it.isNullOrBlank()) { onTextChanged = { text, _, _, _ -> goodsName = text.toString() },
list.clear() afterTextChanged = { it: Editable? ->
searchAdapter.notifyDataSetChanged() if (it.isNullOrBlank()) {
list.clear()
adapter.notifyDataSetChanged()
}
} }
}) )
} addOnActionSearchListener { searchGoods(this) }
searchAdapter = GoodsInfoSearchAdapter(list).apply {
setOnItemClickListener { _, _, position ->
list[position].isClicked = true
notifyItemChanged(position)
Handler(Looper.getMainLooper()).postDelayed({
callback(list[position])
dialog?.dismiss()
}, 300)
}
} }
// RecyclerView 初始化及滑动冲突处理
binding.recyclerView.run { binding.recyclerView.run {
layoutManager = GridLayoutManager(activity, 2, GridLayoutManager.VERTICAL, false) layoutManager = GridLayoutManager(activity, 2, GridLayoutManager.VERTICAL, false)
adapter = searchAdapter adapter = this@FoodSearchDialog.adapter
addOnScrollListener(object : RecyclerView.OnScrollListener() { addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(rv: RecyclerView, newState: Int) {
super.onScrollStateChanged(rv, newState);
}
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) { override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
//解决RecyclerView和smartRefreshLayout滑动冲突问题
super.onScrolled(rv, dx, dy) super.onScrolled(rv, dx, dy)
// 解决 RecyclerView 与 SmartRefreshLayout 滑动冲突
val topRowVerticalPosition = if (rv.isEmpty()) 0 else rv.getChildAt(0).top val topRowVerticalPosition = if (rv.isEmpty()) 0 else rv.getChildAt(0).top
binding.refreshLayout.setNestedScrollingEnabled(topRowVerticalPosition >= 0) binding.refreshLayout.setNestedScrollingEnabled(topRowVerticalPosition >= 0)
} }
}) })
} }
binding.refreshLayout.setOnRefreshListener {
pageNo = 1 binding.refreshLayout.run {
getGoodsList() setEnableRefresh(false)
setEnableLoadMore(false)
setOnRefreshListener { pageNo = 1; getGoodsList() }
setOnLoadMoreListener { getGoodsList() }
} }
binding.refreshLayout.setOnLoadMoreListener { binding.ivSearch.setOnClickListener { searchGoods(it) }
getGoodsList() binding.root.setOnClickListener { KeyboardUtil.hideKeyboard(it) }
}
binding.etSheetInput.addTextChangedListener(
onTextChanged = { text, start, before, count ->
goodsName = text.toString()
})
binding.ivSearch.setOnClickListener { v ->
searchGoods(v)
}
binding.root.setOnClickListener { v ->
KeyboardUtil.hideKeyboard(v.context, v)
}
binding.etSheetInput.let { v ->
v.addOnActionSearchListener {
searchGoods(v)
}
}
binding.refreshLayout.setEnableRefresh(false)
binding.refreshLayout.setEnableLoadMore(false)
if (defGoodsName.isNullOrBlank().not()) { initObserver()
binding.etSheetInput.let {
it.setText(defGoodsName) // 若有默认食材名称,自动填充并触发搜索
searchGoods(it) if (!defGoodsName.isNullOrBlank()) {
binding.etSheetInput.setText(defGoodsName)
searchGoods(binding.etSheetInput)
}
}
/**
* 收集 goodsListState,统一处理 Loading / Success / Error 状态
*/
private fun initObserver() {
activity.lifecycleScope.launch {
activity.repeatOnLifecycle(Lifecycle.State.STARTED) {
activity.netViewModel.goodsListState.collect { state ->
when (state) {
is UiState.Success -> loadGoodsList(state.data)
is UiState.Error -> {
activity.toast(state.msg)
finishRefresh()
}
else -> {}
}
}
} }
} }
} }
/**
* 触发搜索:校验输入、隐藏键盘、发起请求
*/
private fun searchGoods(v: View) { private fun searchGoods(v: View) {
if (goodsName.isNullOrBlank()) { if (goodsName.isNullOrBlank()) {
activity?.toast("${binding.etSheetInput.hint}") activity.toast("${binding.etSheetInput.hint}")
return return
} }
pageNo = 1 pageNo = 1
getGoodsList() getGoodsList()
KeyboardUtil.hideKeyboard(v.context, v) KeyboardUtil.hideKeyboard(v)
} }
private var pageNo = 1
private val pageSize = 50
/** /**
* 请求食材列表
*/ */
@SuppressLint("NotifyDataSetChanged")
private fun getGoodsList() { private fun getGoodsList() {
// val pathSegmentList = UrlConfig.QUERY_GOODS_LIST.split(delimiters = arrayOf("/"), limit = 1)
// val queryParams = mutableMapOf(
// "goodsType" to "0",
// "canteenId" to BaseApp.canteenId,
// "pageNo" to "$pageNo",
// "pageSize" to "$pageSize"
// )
// if (goodsName.isNullOrBlank().not()) {
// queryParams.put("name", goodsName!!)
// }
// val sb = StringBuilder().apply {
// append(UrlConfig.QUERY_GOODS_LIST)
// append("?")
// queryParams.forEach { (key, value) ->
// append("$key=$value&")
// }
// }
// sb.deleteCharAt(sb.length - 1)
// Log.d(TAG, "getGoodsList: url = $sb")
// HttpUtil.get(
// url = sb.toString(),
// doSuccess = {
// val json = it.toJsonString()
// Log.d(TAG, "getGoodsList: json=$json")
// val recordBean: GoodsRecordBean? = json.toObject<GoodsRecordBean>()
// if (recordBean == null || recordBean.records.isNullOrEmpty()) {
// activity?.toast("暂未搜索到食材信息")
// return@get
// }
// val records = recordBean.records
// if (pageNo == 1) {
// list.clear()
// }
// list.addAll(records!!)
// searchAdapter.notifyDataSetChanged()
// if (pageNo == 1) {
// binding.refreshLayout.finishRefresh(1200)
// } else {
// binding.refreshLayout.finishLoadMore(1200)
// }
// val isLoadMoreEnable = records.size >= pageSize
// binding.refreshLayout.setEnableLoadMore(isLoadMoreEnable)
// if (isLoadMoreEnable) {
// pageNo++
// }
// }, doFailure = { code, msg ->
// activity?.toast(msg)
// if (pageNo == 1) {
// binding.refreshLayout.finishRefresh(1200)
// } else {
// binding.refreshLayout.finishLoadMore(1200)
// }
// })
val param = mutableMapOf<String, Any>( val param = mutableMapOf<String, Any>(
"goodsType" to "0", "goodsType" to "0",
"placeId" to BaseApp.canteenId, "placeId" to BaseApp.canteenId,
"pageNum" to pageNo, "pageNum" to pageNo,
"pageSize" to pageSize "pageSize" to pageSize
) )
if (goodsName.isNullOrBlank().not()) { if (!goodsName.isNullOrBlank()) {
param.put("goodsName", goodsName!!) param["goodsName"] = goodsName!!
}
activity?.queryGoodsList(param = param, onSuccess = {
loadGoodsList(it)
}, onFailure = { code, msg ->
activity?.toast(msg)
finishRefresh()
})
}
private fun finishRefresh() {
if (pageNo == 1) {
binding.refreshLayout.finishRefresh()
} else {
binding.refreshLayout.finishLoadMore()
} }
activity.netViewModel.queryGoodsList(param)
} }
/**
* 将返回数据填充到列表
*/
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
private fun loadGoodsList(records: MutableList<CookFoodGoodsEntity>?) { private fun loadGoodsList(records: MutableList<CookFoodGoodsEntity>?) {
finishRefresh() finishRefresh()
if (records.isNullOrEmpty()) { if (records.isNullOrEmpty()) {
activity?.toast("暂未搜索到食材信息") activity.toast("暂未搜索到食材信息")
return return
} }
if (pageNo == 1) { if (pageNo == 1) list.clear()
list.clear()
}
list.addAll(records) list.addAll(records)
searchAdapter.notifyDataSetChanged() adapter.notifyDataSetChanged()
val isLoadMoreEnable = records.size >= pageSize val isLoadMoreEnable = records.size >= pageSize
binding.refreshLayout.setEnableLoadMore(isLoadMoreEnable) binding.refreshLayout.setEnableLoadMore(isLoadMoreEnable)
if (isLoadMoreEnable) { if (isLoadMoreEnable) pageNo++
pageNo++
}
} }
private fun finishRefresh() {
if (pageNo == 1) binding.refreshLayout.finishRefresh()
else binding.refreshLayout.finishLoadMore()
}
} }
@@ -13,14 +13,22 @@ object Loading {
private var dialog: LoadingDialog? = null private var dialog: LoadingDialog? = null
fun show(context: Context) { fun isShowing(): Boolean {
return dialog != null && dialog!!.isShowing
}
fun updateContent(content: String) {
if (isShowing().not()) return
dialog!!.updateContent(content)
}
fun show(context: Context, message: String = "加载中……", onDismiss: () -> Unit = {}) {
try { try {
if (dialog?.isShowing == true) { if (dialog?.isShowing == true) {
dialog?.dismiss() dialog?.dismiss()
} }
if (dialog == null) { if (dialog == null) {
dialog = LoadingDialog(context) dialog = LoadingDialog(context = context, message = message, onDismiss = onDismiss)
} }
dialog?.show() dialog?.show()
} catch (e: Exception) { } catch (e: Exception) {
@@ -45,25 +53,32 @@ object Loading {
class LoadingDialog( class LoadingDialog(
context: Context, context: Context,
message: String = "加载中……", message: String,
private var onDismiss: () -> Unit = {} private var onDismiss: () -> Unit
) : Dialog(context, R.style.LoadingDialog) { ) : Dialog(context, R.style.LoadingDialog) {
private val binding: DialogLoadingBinding by lazy {
DialogLoadingBinding.inflate(LayoutInflater.from(context))
}
init { init {
val binding = DialogLoadingBinding.inflate(LayoutInflater.from(context))
binding.tvMessage.text = message binding.tvMessage.text = message
setContentView(binding.root) setContentView(binding.root)
setCancelable(true) setCancelable(true)
window?.run { window?.run {
setBackgroundDrawable(Color.TRANSPARENT.toDrawable()) setBackgroundDrawable(Color.TRANSPARENT.toDrawable())
setFlags( // setFlags(
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, // WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE // WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
); // );
} }
setOnDismissListener { setOnDismissListener {
onDismiss() onDismiss()
} }
} }
fun updateContent(content: String) {
binding.tvMessage.text = content
}
} }
//class LoadingDialog3( //class LoadingDialog3(
@@ -4,7 +4,6 @@ import android.annotation.SuppressLint
import android.graphics.Typeface import android.graphics.Typeface
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.text.Editable
import android.text.SpannableStringBuilder import android.text.SpannableStringBuilder
import android.text.style.AbsoluteSizeSpan import android.text.style.AbsoluteSizeSpan
import android.text.style.ForegroundColorSpan import android.text.style.ForegroundColorSpan
@@ -19,6 +18,9 @@ import android.view.WindowManager
import androidx.core.graphics.toColorInt import androidx.core.graphics.toColorInt
import androidx.core.view.isEmpty import androidx.core.view.isEmpty
import androidx.core.widget.addTextChangedListener 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.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialog
@@ -28,6 +30,7 @@ import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.base.BaseApp import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.databinding.DialogSeasoningSearchBinding import com.shuwei.dish.match.databinding.DialogSeasoningSearchBinding
import com.shuwei.dish.match.entity.SeasoningEntity import com.shuwei.dish.match.entity.SeasoningEntity
import com.shuwei.dish.match.net.UiState
import com.shuwei.dish.match.utils.KeyboardUtil import com.shuwei.dish.match.utils.KeyboardUtil
import com.shuwei.dish.match.utils.WeightUtil import com.shuwei.dish.match.utils.WeightUtil
import com.shuwei.dish.match.utils.ext.addOnActionSearchListener import com.shuwei.dish.match.utils.ext.addOnActionSearchListener
@@ -36,299 +39,214 @@ import com.shuwei.dish.match.utils.ext.buildSpannableString
import com.shuwei.dish.match.utils.ext.dp import com.shuwei.dish.match.utils.ext.dp
import com.shuwei.dish.match.utils.ext.roundedDecimalPlace import com.shuwei.dish.match.utils.ext.roundedDecimalPlace
import com.shuwei.dish.match.utils.ext.toast import com.shuwei.dish.match.utils.ext.toast
import kotlinx.coroutines.launch
/** /**
* 调料搜索弹窗 * 调料搜索弹窗,继承 BottomSheetDialog 确保只初始化一次
* @param activity 宿主 Activity
* @param weighIndex 秤的索引
* @param weighAddress 秤的地址,用于监听重量和去皮
* @param clickName 默认填充的调料名称(可选)
* @param onItemSelected 用户点击某一调料时的回调,携带当前重量
*/ */
class SeasoningSearchDialog( class SeasoningSearchDialog(
private var weighIndex: Int, private val activity: BaseActivity,
private var weighAddress: Int, private val weighIndex: Int,
private var clickName: String? = null private val weighAddress: Int,
) { private val clickName: String? = null,
private val onItemSelected: (item: SeasoningEntity) -> Unit
) : BottomSheetDialog(activity, R.style.BottomSheet) {
companion object { companion object {
const val TAG = "BottomDialog2" const val TAG = "BottomDialog2"
} }
private var list = mutableListOf<SeasoningEntity>() private val binding = DialogSeasoningSearchBinding.inflate(LayoutInflater.from(activity))
private val list = mutableListOf<SeasoningEntity>()
private val adapter = SeasoningSearchAdapter(list).apply {
setOnItemClickListener { _, _, position ->
list[position].isClicked = true
notifyItemChanged(position)
Handler(Looper.getMainLooper()).postDelayed({
onItemSelected(list[position].also { it.useWeight = currentWeight })
dismiss()
}, 300)
}
}
private lateinit var binding: DialogSeasoningSearchBinding
private lateinit var searchAdapter: SeasoningSearchAdapter
private var goodsName: String? = null private var goodsName: String? = null
private var currentWeight = 0.0
private var pageNo = 1
private val pageSize = 50
private var activity: BaseActivity? = null init {
setContentView(binding.root)
setCancelable(true)
behavior.skipCollapsed = false
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN)
window?.setGravity(Gravity.BOTTOM)
window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
window?.setWindowAnimations(R.style.DialogSoftInputAnimation)
window?.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
fun show(activity: BaseActivity, callback: (item: SeasoningEntity) -> Unit) { setOnDismissListener {
this.activity = activity // 弹窗关闭时移除重量监听,避免内存泄漏
//直接使用BottomSheetDialog WeightUtil.removeWeightListener(TAG)
var dialog: BottomSheetDialog? = null activity.hideStatusBar()
val inflater = LayoutInflater.from(activity) }
binding = DialogSeasoningSearchBinding.inflate(inflater)
// 输入框:清空时重置列表
binding.etSheetInput.run { binding.etSheetInput.run {
hint = "输入调料名称" hint = "输入调料名称"
addTextChangedListener(afterTextChanged = { it: Editable? -> addTextChangedListener(
if (it.isNullOrBlank()) { onTextChanged = { text, _, _, _ -> goodsName = text.toString() },
list.clear() afterTextChanged = {
searchAdapter.notifyDataSetChanged() if (it.isNullOrBlank()) {
list.clear()
adapter.notifyDataSetChanged()
}
} }
}) )
addOnActionSearchListener { searchGoods(this) }
} }
searchAdapter = SeasoningSearchAdapter(list).apply {
setOnItemClickListener { _, _, position ->
list[position].isClicked = true
notifyItemChanged(position)
Handler(Looper.getMainLooper()).postDelayed({
callback(list[position].also { it.useWeight = currentWeight })
dialog?.dismiss()
}, 300)
}
}
binding.recyclerView.run {
layoutManager = GridLayoutManager(activity, 2, GridLayoutManager.VERTICAL, false)
adapter = searchAdapter
addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(rv: RecyclerView, newState: Int) {
super.onScrollStateChanged(rv, newState);
}
// RecyclerView 初始化及滑动冲突处理
binding.recyclerView.let {
it.layoutManager = GridLayoutManager(activity, 2, GridLayoutManager.VERTICAL, false)
it.adapter = adapter
it.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) { override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
//解决RecyclerView和smartRefreshLayout滑动冲突问题
super.onScrolled(rv, dx, dy) super.onScrolled(rv, dx, dy)
// 解决 RecyclerView 与 SmartRefreshLayout 滑动冲突
val topRowVerticalPosition = if (rv.isEmpty()) 0 else rv.getChildAt(0).top val topRowVerticalPosition = if (rv.isEmpty()) 0 else rv.getChildAt(0).top
binding.refreshLayout.setNestedScrollingEnabled(topRowVerticalPosition >= 0) binding.refreshLayout.setNestedScrollingEnabled(topRowVerticalPosition >= 0)
} }
}) })
} }
dialog = BottomSheetDialog(activity, R.style.BottomSheet).apply {
setContentView(binding.root) binding.refreshLayout.run {
setCancelable(true) setEnableRefresh(false)
behavior.skipCollapsed = false // 跳过折叠状态 setEnableLoadMore(false)
//// behavior.peekHeight = 880.dp setOnRefreshListener { pageNo = 1; getGoodsList() }
//// window?.setLayout(-1, 880.dp) setOnLoadMoreListener { getGoodsList() }
// window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN)
//or WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN
//or WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING // 禁用额外平移
window?.setGravity(Gravity.BOTTOM)
window?.setLayout(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
window?.setWindowAnimations(R.style.DialogSoftInputAnimation)
window?.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
show()
// val onGlobalLayoutListener = ViewTreeObserver.OnGlobalLayoutListener {
// val r = Rect()
// binding.root.getWindowVisibleDisplayFrame(r)
// val screenHeight = binding.root.rootView.height
// val keyboardHeight = screenHeight - r.bottom
//
// // 软键盘弹出(高度>200dp
// if (keyboardHeight > 200.dp) {
// // 调整 Dialog 底部边距,避开软键盘
// val layoutParams = binding.root.layoutParams as ViewGroup.MarginLayoutParams
//// layoutParams.bottomMargin = keyboardHeight
// layoutParams.bottomMargin = 200.dp
// binding.root.layoutParams = layoutParams
// } else {
// // 软键盘收起,恢复边距
// val layoutParams = binding.root.layoutParams as ViewGroup.MarginLayoutParams
// layoutParams.bottomMargin = 0
// binding.root.layoutParams = layoutParams
// }
// }
setOnDismissListener {
activity.hideStatusBar()
// binding.root.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalLayoutListener)
}
// binding.root.viewTreeObserver.addOnGlobalLayoutListener(onGlobalLayoutListener)
}
binding.refreshLayout.setOnRefreshListener {
pageNo = 1
getGoodsList()
} }
binding.refreshLayout.setOnLoadMoreListener { binding.ivSearch.setOnClickListener { searchGoods(it) }
getGoodsList() binding.root.setOnClickListener { KeyboardUtil.hideKeyboard(it) }
}
binding.etSheetInput.addTextChangedListener( // 去皮按钮
onTextChanged = { text, start, before, count ->
goodsName = text.toString()
})
binding.ivSearch.setOnClickListener { v ->
searchGoods(v)
}
binding.root.setOnClickListener { v ->
KeyboardUtil.hideKeyboard(v.context, v)
}
binding.etSheetInput.let { v ->
v.addOnActionSearchListener {
searchGoods(v)
}
}
binding.refreshLayout.setEnableRefresh(false)
binding.refreshLayout.setEnableLoadMore(false)
binding.tvClear.setOnClickListener { binding.tvClear.setOnClickListener {
Log.d(TAG, "show: weighAddress=$weighAddress") Log.d(TAG, "tareTwo: weighAddress=$weighAddress")
WeightUtil.tareTwo(weighAddress) WeightUtil.tareTwo(weighAddress)
// repeat(14) { num->
// Log.d(TAG, "show: num=$num")
// WeightUtil.tareTwo(num+1)
// }
} }
// 监听对应秤的重量变化
WeightUtil.addWeightListener( WeightUtil.addWeightListener(
weightKey = TAG, weightKey = TAG,
getWeight = { address, state, weight -> getWeight = { address, _, weight ->
if (weighAddress == address) { if (weighAddress == address) {
this.currentWeight = weight.toDouble() currentWeight = weight.toDouble()
binding.tvWeight.text = getTextSpan(weight) binding.tvWeight.text = getTextSpan(weight)
} }
}) }
)
binding.tvWeight.text = getTextSpan(0.0) binding.tvWeight.text = getTextSpan(0.0)
if (clickName.isNullOrBlank().not()) { initObserver()
binding.etSheetInput.setText(clickName!!.trim())
// 若有默认调料名称,自动填充并触发搜索
if (!clickName.isNullOrBlank()) {
binding.etSheetInput.setText(clickName.trim())
pageNo = 1 pageNo = 1
getGoodsList() getGoodsList()
} }
} }
/**
* 收集 seasoningListState,统一处理 Loading / Success / Error 状态
*/
private fun initObserver() {
activity.lifecycleScope.launch {
activity.repeatOnLifecycle(Lifecycle.State.STARTED) {
activity.netViewModel.seasoningListState.collect { state ->
when (state) {
is UiState.Success -> loadGoodsList(state.data)
is UiState.Error -> {
activity.toast(state.msg)
finishRefresh()
}
else -> {}
}
}
}
}
}
/**
* 触发搜索:校验输入、隐藏键盘、发起请求
*/
private fun searchGoods(v: View) { private fun searchGoods(v: View) {
if (goodsName.isNullOrBlank()) { if (goodsName.isNullOrBlank()) {
activity?.toast("${binding.etSheetInput.hint}") activity.toast("${binding.etSheetInput.hint}")
return return
} }
pageNo = 1 pageNo = 1
getGoodsList() getGoodsList()
KeyboardUtil.hideKeyboard(v.context, v) KeyboardUtil.hideKeyboard(v)
} }
private var currentWeight = 0.toDouble()
private var pageNo = 1
private val pageSize = 50
/** /**
* 请求调料列表
*/ */
@SuppressLint("NotifyDataSetChanged")
private fun getGoodsList() { private fun getGoodsList() {
// val queryParams = mutableMapOf(
// "goodsType" to "1",
// "canteenId" to BaseApp.canteenId,
// "pageNo" to "$pageNo",
// "pageSize" to "$pageSize"
// )
// if (goodsName.isNullOrBlank().not()) {
// queryParams.put("name", goodsName!!)
// }
// val sb = StringBuilder().apply {
// append(UrlConfig.QUERY_GOODS_LIST)
// append("?")
// queryParams.forEach { (key, value) ->
// append("$key=$value&")
// }
// }
// sb.deleteCharAt(sb.length - 1)
// Log.d(TAG, "getGoodsList: url = $sb")
// HttpUtil.get(
// url = sb.toString(),
// doSuccess = {
// val json = it.toJsonString()
// Log.d(TAG, "getGoodsList: json=$json")
// val recordBean: SeasoningRecordBean? = json.toObject<SeasoningRecordBean>()
// if (recordBean == null || recordBean.records.isNullOrEmpty()) {
// activity?.toast( "暂未搜索到调料信息")
// return@get
// }
// val records = recordBean.records
// if (pageNo == 1) {
// list.clear()
// }
// list.addAll(records!!)
// searchAdapter.notifyDataSetChanged()
// if (pageNo == 1) {
// binding.refreshLayout.finishRefresh(1200)
// } else {
// binding.refreshLayout.finishLoadMore(1200)
// }
// val isLoadMoreEnable = records.size >= pageSize
// binding.refreshLayout.setEnableLoadMore(isLoadMoreEnable)
// if (isLoadMoreEnable) {
// pageNo++
// }
// }, doFailure = { code, msg ->
// activity?.toast(msg)
// if (pageNo == 1) {
// binding.refreshLayout.finishRefresh(1200)
// } else {
// binding.refreshLayout.finishLoadMore(1200)
// }
// })
val param = mutableMapOf<String, Any>( val param = mutableMapOf<String, Any>(
"goodsType" to "1", "goodsType" to "1",
"placeId" to BaseApp.canteenId, "placeId" to BaseApp.canteenId,
"pageNum" to pageNo, "pageNum" to pageNo,
"pageSize" to pageSize "pageSize" to pageSize
) )
if (goodsName.isNullOrBlank().not()) { if (!goodsName.isNullOrBlank()) {
param.put("goodsName", goodsName!!) param["goodsName"] = goodsName!!
} }
activity?.querySeasoningList( activity.netViewModel.querySeasoningList(param)
param = param,
onSuccess = {
loadGoodsList(it)
},
onFailure = { code, msg ->
activity?.toast(msg)
finishRefresh()
}
)
} }
/**
* 将返回数据填充到列表
*/
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
private fun loadGoodsList(records: MutableList<SeasoningEntity>?) { private fun loadGoodsList(records: MutableList<SeasoningEntity>?) {
finishRefresh() finishRefresh()
if (records.isNullOrEmpty()) { if (records.isNullOrEmpty()) {
activity?.toast("暂未搜索到调料信息") activity.toast("暂未搜索到调料信息")
return return
} }
if (pageNo == 1) { if (pageNo == 1) list.clear()
list.clear()
}
list.addAll(records) list.addAll(records)
searchAdapter.notifyDataSetChanged() adapter.notifyDataSetChanged()
val isLoadMoreEnable = records.size >= pageSize val isLoadMoreEnable = records.size >= pageSize
binding.refreshLayout.setEnableLoadMore(isLoadMoreEnable) binding.refreshLayout.setEnableLoadMore(isLoadMoreEnable)
if (isLoadMoreEnable) { if (isLoadMoreEnable) pageNo++
pageNo++
}
} }
private fun finishRefresh() { private fun finishRefresh() {
if (pageNo == 1) { if (pageNo == 1) binding.refreshLayout.finishRefresh()
binding.refreshLayout.finishRefresh() else binding.refreshLayout.finishLoadMore()
} else {
binding.refreshLayout.finishLoadMore()
}
} }
/**
* 生成重量显示的富文本:数值大字加粗,单位小字灰色
*/
fun getTextSpan(weight: Double): SpannableStringBuilder { fun getTextSpan(weight: Double): SpannableStringBuilder {
var topWeight = "$weight" val topWeight = if (weight >= 1000) "${(weight / 1000).roundedDecimalPlace(3)}" else "$weight"
var bottomUnit = "" val bottomUnit = if (weight >= 1000) "千克" else ""
if (weight >= 1000) {
topWeight = "${(weight / 1000).roundedDecimalPlace(3)}"
bottomUnit = "千克"
}
return buildSpannableString { return buildSpannableString {
appendText( appendText(
topWeight, topWeight,
ForegroundColorSpan("#000000".toColorInt()), ForegroundColorSpan("#000000".toColorInt()),
StyleSpan(Typeface.BOLD), StyleSpan(Typeface.BOLD),
AbsoluteSizeSpan(72, true), AbsoluteSizeSpan(72, true),
LineHeightSpan { text, start, end, spanstartv, v, fm -> LineHeightSpan { _, _, _, _, _, fm -> fm.descent += 10.dp }
fm.descent += 10.dp // 增加行间距
}
) )
append("\n") append("\n")
appendText( appendText(
@@ -339,6 +257,3 @@ class SeasoningSearchDialog(
} }
} }
} }
@@ -0,0 +1,200 @@
package com.shuwei.dish.match.dialog
import android.annotation.SuppressLint
import android.graphics.Rect
import android.os.Handler
import android.os.Looper
import android.view.Gravity
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.Window
import android.view.WindowManager
import androidx.core.widget.addTextChangedListener
import androidx.recyclerview.widget.GridLayoutManager
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.entity.SeasoningEntity
import com.shuwei.dish.match.utils.KeyboardUtil
import com.shuwei.dish.match.utils.ext.addOnActionSearchListener
import com.shuwei.dish.match.utils.ext.toast
/**
* 调料选择弹窗,不含重量显示,继承 BottomSheetDialog 确保只初始化一次
* @param activity 宿主 Activity
* @param clickIndex 点击的调料槽位索引
* @param onItemSelected 用户点击某一调料时的回调
*/
class SeasoningSelectDialog(
private val activity: BaseActivity,
private val onItemSelected: (item: SeasoningEntity) -> Unit
) : BottomSheetDialog(activity, R.style.BottomSheet) {
companion object {
const val TAG = "SeasoningSelectDialog"
}
private val binding = DialogSeasoningSelectBinding.inflate(LayoutInflater.from(activity))
private val list = mutableListOf<SeasoningEntity>()
private val adapter = SeasoningSearchAdapter(list).apply {
setOnItemClickListener { _, _, position ->
list[position].isClicked = true
notifyItemChanged(position)
Handler(Looper.getMainLooper()).postDelayed({
onItemSelected(list[position])
dismiss()
}, 300)
}
}
private var goodsName: String? = null
private var pageNo = 1
private val pageSize = 50
init {
setContentView(binding.root)
setCancelable(true)
behavior.skipCollapsed = false
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN)
window?.setGravity(Gravity.BOTTOM)
window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
window?.setWindowAnimations(R.style.DialogSoftInputAnimation)
window?.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
setOnDismissListener {
activity.hideStatusBar()
}
// 输入框:清空时重置列表
binding.etSheetInput.run {
hint = "输入调料名称"
addTextChangedListener(
onTextChanged = { text, _, _, _ -> goodsName = text.toString() },
afterTextChanged = {
if (it.isNullOrBlank()) {
list.clear()
adapter.notifyDataSetChanged()
}
}
)
addOnActionSearchListener { searchGoods(this) }
}
// RecyclerView 初始化
binding.recyclerView.run {
layoutManager = GridLayoutManager(activity, 3, GridLayoutManager.VERTICAL, false)
adapter = this@SeasoningSelectDialog.adapter
}
binding.refreshLayout.run {
setEnableRefresh(false)
setEnableLoadMore(false)
setOnRefreshListener { pageNo = 1; getGoodsList() }
setOnLoadMoreListener { getGoodsList() }
}
binding.ivSearch.setOnClickListener { searchGoods(it) }
binding.root.setOnClickListener { KeyboardUtil.hideKeyboard(it) }
}
/** 弹窗显示时自动加载默认调料列表,并在 Window.Callback 层提前拦截触摸以解决滑动冲突 */
override fun show() {
super.show()
pageNo = 1
getGoodsList()
// 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)
}
}
}
/**
* 触发搜索:校验输入、隐藏键盘、发起请求
*/
private fun searchGoods(v: View) {
if (goodsName.isNullOrBlank()) {
activity.toast("${binding.etSheetInput.hint}")
return
}
pageNo = 1
getGoodsList()
KeyboardUtil.hideKeyboard( v)
}
/**
* 请求调料列表(临时使用本地模拟数据,正式联调时替换为真实接口)
*/
private fun getGoodsList() {
// TODO: 联调时注释掉下方模拟数据,取消注释真实接口调用
val nameList = listOf(
"", "白砂糖","冰糖", "鸡精", "味精", "陈醋", "老抽", "生抽", "番茄酱", "胡椒粉", "孜然", "十三香",
"料酒", "白醋", "蚝油", "辣椒面", "辣椒酱", "豆瓣酱", "淀粉", "", "", "", "香菜", ""
)
val mockData = mutableListOf<SeasoningEntity>()
nameList.forEachIndexed { index, name ->
val padString = "${index + 1}".padStart(2, '0')
mockData.add(SeasoningEntity(goodsId = "1000$padString", goodsName = name))
}
loadGoodsList(mockData)
// val param = mutableMapOf<String, Any>(
// "goodsType" to "1",
// "placeId" to BaseApp.canteenId,
// "pageNum" to pageNo,
// "pageSize" to pageSize
// )
// if (!goodsName.isNullOrBlank()) {
// param["goodsName"] = goodsName!!
// }
// activity.querySeasoningList(param = param, onSuccess = {
// loadGoodsList(it)
// }, onFailure = { _, msg ->
// activity.toast(msg)
// finishRefresh()
// })
}
/**
* 将返回数据填充到列表
*/
@SuppressLint("NotifyDataSetChanged")
private fun loadGoodsList(records: MutableList<SeasoningEntity>?) {
finishRefresh()
if (records.isNullOrEmpty()) {
activity.toast("暂未搜索到调料信息")
return
}
if (pageNo == 1) list.clear()
list.addAll(records)
adapter.notifyDataSetChanged()
val isLoadMoreEnable = records.size >= pageSize
binding.refreshLayout.setEnableLoadMore(isLoadMoreEnable)
if (isLoadMoreEnable) pageNo++
}
private fun finishRefresh() {
if (pageNo == 1) binding.refreshLayout.finishRefresh()
else binding.refreshLayout.finishLoadMore()
}
}
@@ -0,0 +1,11 @@
package com.shuwei.dish.match.entity
/**
* 已采集食材列表项数据模型
* @param foodName 食材名称
* @param collectCount 已采集数量
*/
data class CollectedFoodItem(
val foodName: String = "",
val collectCount: Int = 0
)
@@ -86,4 +86,7 @@ class CookFoodGoodsEntity(
//true-原始数据,false-非原始数据 //true-原始数据,false-非原始数据
@Ignore @Ignore
var isOriginalData: Boolean = true var isOriginalData: Boolean = true
@Ignore
var foodScore: Int = -1
} }
@@ -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
)
@@ -1,70 +0,0 @@
//package com.shuwei.dish.match.http
//
//import android.annotation.SuppressLint
//import android.util.Log
//import okhttp3.Call
//import okhttp3.Interceptor
//import okhttp3.OkHttpClient
//import okhttp3.Request
//import okhttp3.Response
//import java.util.concurrent.TimeUnit
//
//import javax.net.ssl.*
//import java.security.SecureRandom
//import java.security.cert.X509Certificate
//
//class HttpClient private constructor() {
// private val client: OkHttpClient by lazy {
// OkHttpClient.Builder()
// .apply {
// connectTimeout(15, TimeUnit.SECONDS)
// readTimeout(30, TimeUnit.SECONDS)
// writeTimeout(15, TimeUnit.SECONDS)
// sslSocketFactory(createSSLSocketFactory(), TrustAllCerts())
// hostnameVerifier { _, _ -> true }
// //if (BuildConfig.Debug) {
// addInterceptor(LoggingInterceptor())
// // }
// }
// .build()
// }
//
// companion object {
// val instance by lazy { HttpClient() }
// }
//
// fun newCall(request: Request): Call = client.newCall(request)
//
// inner class LoggingInterceptor : Interceptor {
// override fun intercept(chain: Interceptor.Chain): Response {
// val request = chain.request()
// // 打印请求日志
// Log.d("OkHttp", "--> ${request.method} ${request.url}")
//
// val response = chain.proceed(request)
// // 打印响应日志
// Log.d("OkHttp", "<-- ${response.code} ${response.request.url}")
//
// return response
// }
// }
//
// // 信任所有证书的TrustManager实现
// @SuppressLint("CustomX509TrustManager")
// class TrustAllCerts : X509TrustManager {
// @SuppressLint("TrustAllX509TrustManager")
// override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {}
// @SuppressLint("TrustAllX509TrustManager")
// override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {}
// override fun getAcceptedIssuers(): Array<X509Certificate> = arrayOf()
// }
//
// // 创建信任所有证书的SSLSocketFactory
// fun createSSLSocketFactory(): SSLSocketFactory {
// val sslContext = SSLContext.getInstance("TLS").apply {
// init(null, arrayOf(TrustAllCerts()), SecureRandom())
// }
// return sslContext.socketFactory
// }
//
//}
@@ -1,199 +0,0 @@
//package com.shuwei.dish.match.http
//
//import android.os.Handler
//import android.os.Looper
//import android.util.Log
//import com.google.gson.reflect.TypeToken
//import com.shuwei.dish.match.base.BaseApp
//import com.shuwei.dish.match.base.BaseReq
//import com.shuwei.dish.match.utils.SpTool
//import com.shuwei.dish.match.utils.ext.toType
//import okhttp3.Call
//import okhttp3.Callback
//import okhttp3.MediaType.Companion.toMediaTypeOrNull
//import okhttp3.Request
//import okhttp3.RequestBody.Companion.toRequestBody
//import okhttp3.Response
//import java.io.IOException
//import java.util.concurrent.Executors
//import java.util.concurrent.TimeUnit
//
//
//object HttpUtil {
//
// var loopGetToken = true
//
// fun runMainThread(action: () -> Unit) {
// Handler(Looper.getMainLooper()).post {
// action()
// }
// }
//
// // GET请求(HTTPS
// fun get(
// url: String,
// header: Map<String, String>?=null,
// doSuccess: (data: Any) -> Unit,
// doFailure: (code: Int?, msg: String?) -> Unit
// ) {
// val request = Request.Builder()
// .url(if (url.isHttpApi()) url else UrlConfig.BASE_URL + url)
// .apply {
// if (header != null) {
// header.forEach {
// addHeader(it.key, it.value)
// }
// } else {
// val token = getToken()
// if (token.isNotBlank()) {
// addHeader("X-Access-Token", token)
// }
// }
// }
// .get()
// .build()
// HttpClient.instance.newCall(request).enqueue(CallbackImpl(doSuccess, doFailure))
// }
////
//// fun get(
//// isHttps: Boolean = false,
//// host: String = "vip.shuziweidao.com",
//// pathSegmentList: List<String>,
//// queryParams: Map<String, String>,
//// doSuccess: (data: Any) -> Unit,
//// doFailure: (code: Int?, msg: String?) -> Unit
//// ) {
//// val token = getToken()
//// val url = HttpUrl.Builder()
//// .scheme(if (isHttps) "https" else "http")
//// .host(host)
//// .apply {
//// pathSegmentList.forEach { addPathSegment(it) }
//// queryParams.forEach { (key, value) -> addQueryParameter(key, value) }
//// }
//// .build()
//// val request = Request.Builder()
//// .url(url)
//// .apply {
//// if (token.isNotBlank()) {
//// addHeader("Authorization", token)
//// }
//// }
//// .get()
//// .build()
//// HttpClient.instance.newCall(request).enqueue(CallbackImpl(doSuccess, doFailure))
//// }
//
// // POST表单(HTTPS
//// fun postForm(url: String, params: Map<String, String>, callback: Callback) {
//// val formBody = FormBody.Builder().apply {
//// params.forEach { (k, v) -> add(k, v) }
//// }.build()
////
//// Request.Builder()
//// .url(url)
//// .post(formBody)
//// .build().let { HttpClient.instance.newCall(it).enqueue(callback) }
//// }
//
// // POST JSONHTTPS
// fun postJson(
// url: String,
// json: String,
// doSuccess: (data: Any) -> Unit,
// doFailure: (code: Int?, msg: String?) -> Unit
// ) {
// val body = json
// .toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull())
// val token = getToken()
// Request.Builder()
// .url(if (url.isHttpApi()) url else UrlConfig.BASE_URL + url)
// .apply {
// if (token.isNotBlank()) {
// addHeader("X-Access-Token", token)
// }
// }
// .post(body)
// .build()
// .let { HttpClient.instance.newCall(it).enqueue(CallbackImpl(doSuccess, doFailure)) }
// }
//
//
// val executor = Executors.newSingleThreadScheduledExecutor()
// var getTokenIsRunning = false
// private fun exeTokenThread() {
// if (getTokenIsRunning.not()) {
// executor.scheduleWithFixedDelay(
// task, 0, 1, TimeUnit.MINUTES
// )
// getTokenIsRunning = true
// }
// }
//
// val task = Runnable {
// if (BaseApp.token.isNullOrBlank()) {
// getAppToken()
// } else {
// executor.shutdown()
// }
// }
//
// fun getToken() = SpTool.getString(SpTool.TOKEN)
////BaseApp.instance?.token ?:""
////BaseApp.getSharedPref()?.getString("token", "") ?: ""
//
// fun getAppToken() {
// val url = "${UrlConfig.GET_TOKEN}?qrcodeId=${SpTool.getString(SpTool.DEVICE_ID)}"
//// Log.d(TAG, "getToken: url = $url")
// get(url = url, doSuccess = {
// SpTool.put(SpTool.TOKEN, it)
// BaseApp.token = it.toString()
// }) { code, msg ->
// //toast(msg)
// }
// }
//
// class CallbackImpl(
// private val doSuccess: (data: Any) -> Unit,
// private val doFailure: (code: Int?, msg: String?) -> Unit
// ) :
// Callback {
// override fun onFailure(call: Call, e: IOException) {
// runMainThread {
// e.printStackTrace()
// doFailure(-1, "服务异常,${e.message}")
// }
// }
//
// override fun onResponse(call: Call, response: Response) {
// val respData = response.body?.string()
// runMainThread {
// Log.d("HttpUtil", respData ?: "")
// runCatching {
// val typeToken = object : TypeToken<BaseReq<Any>>() {}
// val baseReq = respData?.toType<BaseReq<Any>>(typeToken = typeToken)
// if (baseReq == null) {
// doFailure(-1, "查询数据失败")
// return@runCatching
// }
// if (baseReq.code != 200) {
// doFailure(baseReq.code, baseReq.message)
// //baseReq.code == 500 ||
// if (baseReq.code == 401 && loopGetToken) {
// exeTokenThread()
// }
// return@runCatching
// }
// doSuccess(baseReq.result ?: "")
// }.onFailure {
// it.printStackTrace()
// doFailure(-1, "解析异常")
// }
// }
// }
//
// }
//
//}
//
//fun String.isHttpApi() = this.startsWith("http://") || this.startsWith("https://")
@@ -1,37 +0,0 @@
package com.shuwei.dish.match.http
//import com.shuwei.dish.match.base.BaseApp
object UrlConfig {
// private const val DEVICE_BASE_URL = "http://device.shuziweidao.com:8889"
// /**
// * device获取token
// */
// const val DEVICE_TOKEN = "${DEVICE_BASE_URL}/sys/getEquipmentToken"
// /**
// *获取配置信息
// */
// const val DEVICE_CONFIG = "${DEVICE_BASE_URL}/equipment/stEquipment/queryByEquipmentCode"
// const val BASE_URL = "http://vip.shuziweidao.com/shuwei-zhct"
// const val DISH_DETAIL = "$BASE_URL/scales/goodsUseList?foodId={foodId}&foodWeight={foodWeight}"
// const val DISH_LIST = "$BASE_URL/scales/getRestInfoFoods?eaId=99&type=0&foodName"
// const val BASE_URL = "http://192.168.1.207:9102"
// const val BASE_URL = "https://yyjk.shuziweidao.com/gateway"
const val BASE_URL = "https://yyjk.shuziweidao.com/gateway/"
// const val DISH_LIST = "$BASE_URL/scales/getRestInfoFoods?eaId=99&type=0&foodName"
// var DISH_DETAIL = "${BaseApp.configUrl}/food/stFoodInfoMatching/queryById"
//
// // "$BASE_URL/scales/generateToken?deviceId=1111111111111111111111111111"
//
// var GET_TOKEN = "${BaseApp.configUrl}/restaurant/equipment/stEquipment/getEquipmentToken"
// var SUBMIT_DISH = "${BaseApp.configUrl}/food/stFoodInfoMatching/saveoredit"
//
// var QUERY_GOODS_LIST = "${BaseApp.configUrl}/food/stFoodInfoMatching/queryGoodsInfoList"
// var QUERY_FOOD_LIST = "${BaseApp.configUrl}/food/stFoodInfoMatching/list"
// var SAMPLING_LIST = "${BaseApp.configUrl}/food/stFoodInfoMatching/queryHistorical/goodsInfoList"
}
@@ -1,5 +1,6 @@
package com.shuwei.dish.match.net package com.shuwei.dish.match.net
import com.google.gson.JsonSyntaxException
import com.shuwei.dish.match.utils.ext.toJsonString import com.shuwei.dish.match.utils.ext.toJsonString
import com.shuwei.dish.match.utils.ext.toObject import com.shuwei.dish.match.utils.ext.toObject
import com.shuwei.dish.match.utils.ext.toType import com.shuwei.dish.match.utils.ext.toType
@@ -46,6 +47,7 @@ import java.net.UnknownHostException
* @param onSuccess 成功回调 * @param onSuccess 成功回调
* @param onFailure 自定义错误回调(可选,不传则用默认提示) * @param onFailure 自定义错误回调(可选,不传则用默认提示)
*/ */
@Suppress("UNCHECKED_CAST")
suspend fun <T> request( suspend fun <T> request(
onRequest: suspend () -> ApiResponse<T>, onRequest: suspend () -> ApiResponse<T>,
onSuccess: (T) -> Unit, onSuccess: (T) -> Unit,
@@ -60,10 +62,10 @@ suspend fun <T> request(
onFailure("-1", apiException.errorMsg) onFailure("-1", apiException.errorMsg)
return return
} }
if (response?.isSuccess() == true) { if (response.isSuccess()) {
onSuccess(response.data as T) onSuccess(response.data as T)
} else { } else {
onFailure(response?.code ?: "-1", response?.msg ?: "") onFailure(response.code, response.msg ?: "")
} }
} }
@@ -72,11 +74,17 @@ data class CodeMsg(
var msg: String? = null var msg: String? = null
) )
@Suppress("DEPRECATION")
fun getApiException(e: Exception): ApiException { fun getApiException(e: Exception): ApiException {
// 捕获各类网络异常并转换为自定义异常 // 捕获各类网络异常并转换为自定义异常
return when (e) { return when (e) {
is HttpException -> httpException2ApiException(e) is HttpException -> httpException2ApiException(e)
is JsonSyntaxException -> ApiException(
ErrorType.NETWORK_ERROR,
errorMsg = "服务器响应异常,请稍后重试"
)
is UnknownHostException -> ApiException( is UnknownHostException -> ApiException(
ErrorType.NETWORK_ERROR, ErrorType.NETWORK_ERROR,
errorMsg = "网络未连接,请检查网络" errorMsg = "网络未连接,请检查网络"
@@ -108,10 +116,15 @@ fun getApiException(e: Exception): ApiException {
fun httpException2ApiException(e: HttpException): ApiException { fun httpException2ApiException(e: HttpException): ApiException {
val body = e.response()?.errorBody()?.string() val body = e.response()?.errorBody()?.string()
val codeMsg: CodeMsg? = body?.toObject<CodeMsg>() val codeMsg: CodeMsg? = try {
body?.toObject<CodeMsg>()
} catch (_: Exception) {
null
}
return ApiException( return ApiException(
ErrorType.NETWORK_ERROR, ErrorType.NETWORK_ERROR,
errorMsg = codeMsg?.msg ?: "" errorMsg = if (codeMsg == null) "数据解析异常(${e.code()})"
else codeMsg.msg ?: "服务器错误(${e.code()})"
) )
} }
@@ -2,7 +2,7 @@ package com.shuwei.dish.match.net
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.util.Log import android.util.Log
import com.shuwei.dish.match.http.UrlConfig import com.shuwei.dish.match.base.GlobalData
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit import retrofit2.Retrofit
@@ -16,7 +16,7 @@ import kotlin.apply
import kotlin.jvm.java import kotlin.jvm.java
val apiService: ApiService = Retrofit.Builder() val apiService: ApiService = Retrofit.Builder()
.baseUrl(UrlConfig.BASE_URL) .baseUrl(GlobalData.appBaseUrl)
// .baseUrl(UrlConfig.DEVICE_BASE_URL) // .baseUrl(UrlConfig.DEVICE_BASE_URL)
.client(HttpManager.instance.client) .client(HttpManager.instance.client)
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(GsonConverterFactory.create())
@@ -6,106 +6,115 @@ import com.shuwei.dish.match.entity.CookFoodEntity
import com.shuwei.dish.match.entity.CookFoodGoodsEntity import com.shuwei.dish.match.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.entity.FoodRecord import com.shuwei.dish.match.entity.FoodRecord
import com.shuwei.dish.match.entity.SeasoningEntity import com.shuwei.dish.match.entity.SeasoningEntity
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class NetViewModel : ViewModel() { /**
* 网络请求 ViewModel,所有网络操作通过 RemoteRepository 发起,不直接依赖 apiService
* @param repository 网络数据仓库,默认使用单例实例
*/
class NetViewModel(
private val repository: RemoteRepository = RemoteRepository()
) : ViewModel() {
fun submitCookFood( /**
entity: CookFoodEntity, * 提交制作菜品的 UI 状态流,UI 层通过 collect 监听
onSuccess: (Any?) -> Unit, */
onFailure: (String, String) -> Unit private val _submitCookFoodState = MutableStateFlow<UiState<Any?>>(UiState.Idle)
) { val submitCookFoodState: StateFlow<UiState<Any?>> = _submitCookFoodState.asStateFlow()
/**
* 提交制作菜品
*/
fun submitCookFood(entity: CookFoodEntity) {
viewModelScope.launch { viewModelScope.launch {
request( _submitCookFoodState.value = UiState.Loading
onRequest = { _submitCookFoodState.value = repository.submitCookFood(entity)
apiService.submitCookFood(param = entity)
},
onSuccess = onSuccess,
onFailure = onFailure
)
} }
} }
fun getFoodDetail( /**
foodId: String, * 查询菜品详情的 UI 状态流,UI 层通过 collect 监听
onSuccess: (CookFoodEntity?) -> Unit, */
onFailure: (String, String) -> Unit private val _foodDetailState = MutableStateFlow<UiState<CookFoodEntity?>>(UiState.Idle)
) { val foodDetailState: StateFlow<UiState<CookFoodEntity?>> = _foodDetailState.asStateFlow()
/**
* 查询菜品详情
*/
fun getFoodDetail(foodId: String) {
viewModelScope.launch { viewModelScope.launch {
request( _foodDetailState.value = UiState.Loading
onRequest = { _foodDetailState.value = repository.getFoodDetail(foodId)
apiService.getFoodDetail(foodId = foodId)
},
onSuccess = onSuccess,
onFailure = onFailure
)
} }
} }
/**
* 搜索菜品列表的 UI 状态流,UI 层通过 collect 监听
*/
private val _searchFoodState = MutableStateFlow<UiState<MutableList<FoodRecord>?>>(UiState.Idle)
val searchFoodState: StateFlow<UiState<MutableList<FoodRecord>?>> = _searchFoodState.asStateFlow()
fun searchFoodList( /**
param: MutableMap<String, Any>, * 搜索菜品列表
onSuccess: (MutableList<FoodRecord>?) -> Unit, */
onFailure: (String, String) -> Unit fun searchFoodList(param: MutableMap<String, Any>) {
) {
viewModelScope.launch { viewModelScope.launch {
request( _searchFoodState.value = UiState.Loading
onRequest = { _searchFoodState.value = repository.searchFoodList(param)
apiService.searchFoodList(param = param)
},
onSuccess = onSuccess,
onFailure = onFailure
)
} }
} }
fun getSamplingList( /**
param: MutableMap<String, Any>, * 查询采样数据列表的 UI 状态流,UI 层通过 collect 监听
onSuccess: (MutableList<FoodRecord>?) -> Unit, */
onFailure: (String, String) -> Unit private val _samplingListState = MutableStateFlow<UiState<MutableList<FoodRecord>?>>(UiState.Idle)
) { val samplingListState: StateFlow<UiState<MutableList<FoodRecord>?>> = _samplingListState.asStateFlow()
fun resetSamplingListState() {
_samplingListState.value = UiState.Idle
}
/**
* 查询采样数据列表
*/
fun getSamplingList(param: MutableMap<String, Any>) {
viewModelScope.launch { viewModelScope.launch {
request( _samplingListState.value = UiState.Loading
onRequest = { _samplingListState.value = repository.getSamplingList(param)
apiService.getSamplingList(param = param)
},
onSuccess = onSuccess,
onFailure = onFailure
)
} }
} }
fun queryGoodsList( /**
param: MutableMap<String, Any>, * 查询物品信息列表的 UI 状态流,UI 层通过 collect 监听
onSuccess: (MutableList<CookFoodGoodsEntity>?) -> Unit, */
onFailure: (String, String) -> Unit private val _goodsListState = MutableStateFlow<UiState<MutableList<CookFoodGoodsEntity>?>>(UiState.Idle)
) { val goodsListState: StateFlow<UiState<MutableList<CookFoodGoodsEntity>?>> = _goodsListState.asStateFlow()
/**
* 查询物品信息列表
*/
fun queryGoodsList(param: MutableMap<String, Any>) {
viewModelScope.launch { viewModelScope.launch {
request( _goodsListState.value = UiState.Loading
onRequest = { _goodsListState.value = repository.queryGoodsList(param)
apiService.queryGoodsList(param = param)
},
onSuccess = onSuccess,
onFailure = onFailure
)
} }
} }
fun querySeasoningList( /**
param: MutableMap<String, Any>, * 查询调料信息列表的 UI 状态流,UI 层通过 collect 监听
onSuccess: (MutableList<SeasoningEntity>?) -> Unit, */
onFailure: (String, String) -> Unit private val _seasoningListState = MutableStateFlow<UiState<MutableList<SeasoningEntity>?>>(UiState.Idle)
) { val seasoningListState: StateFlow<UiState<MutableList<SeasoningEntity>?>> = _seasoningListState.asStateFlow()
/**
* 查询调料信息列表
*/
fun querySeasoningList(param: MutableMap<String, Any>) {
viewModelScope.launch { viewModelScope.launch {
request( _seasoningListState.value = UiState.Loading
onRequest = { _seasoningListState.value = repository.querySeasoningList(param)
apiService.querySeasoningList(param = param)
},
onSuccess = onSuccess,
onFailure = onFailure
)
} }
} }
} }
@@ -0,0 +1,111 @@
package com.shuwei.dish.match.net
import com.shuwei.dish.match.entity.CookFoodEntity
import com.shuwei.dish.match.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.entity.FoodRecord
import com.shuwei.dish.match.entity.SeasoningEntity
/**
* 网络数据仓库,封装所有 ApiService 调用
* ViewModel 不直接接触 apiService,统一通过此类发起网络请求
*/
class RemoteRepository {
/**
* 提交制作菜品
* @param entity 菜品实体
* @return UiState 包装的结果,Success 表示提交成功,Error 携带错误信息
*/
suspend fun submitCookFood(entity: CookFoodEntity): UiState<Any?> {
return try {
val resp = apiService.submitCookFood(param = entity)
if (resp.isSuccess()) UiState.Success(resp.data)
else UiState.Error(resp.code, resp.msg ?: "")
} catch (e: Exception) {
val ex = getApiException(e)
UiState.Error("-1", ex.errorMsg)
}
}
/**
* 查询菜品详情
* @param foodId 菜品 ID
* @return UiState 包装的结果,Success 携带详情数据,Error 携带错误信息
*/
suspend fun getFoodDetail(foodId: String): UiState<CookFoodEntity?> {
return try {
val resp = apiService.getFoodDetail(foodId = foodId)
if (resp.isSuccess()) UiState.Success(resp.data)
else UiState.Error(resp.code, resp.msg ?: "")
} catch (e: Exception) {
val ex = getApiException(e)
UiState.Error("-1", ex.errorMsg)
}
}
/**
* 搜索菜品列表
* @param param 查询参数
* @return UiState 包装的结果,Success 携带列表数据,Error 携带错误信息
*/
suspend fun searchFoodList(param: MutableMap<String, Any>): UiState<MutableList<FoodRecord>?> {
return try {
val resp = apiService.searchFoodList(param = param)
if (resp.isSuccess()) UiState.Success(resp.data)
else UiState.Error(resp.code, resp.msg ?: "")
} catch (e: Exception) {
val ex = getApiException(e)
UiState.Error("-1", ex.errorMsg)
}
}
/**
* 查询采样数据列表
* @param param 查询参数
* @return UiState 包装的结果,Success 携带列表数据,Error 携带错误信息
*/
suspend fun getSamplingList(param: MutableMap<String, Any>): UiState<MutableList<FoodRecord>?> {
return try {
val resp = apiService.getSamplingList(param = param)
if (resp.isSuccess()) UiState.Success(resp.data)
else UiState.Error(resp.code, resp.msg ?: "")
} catch (e: Exception) {
val ex = getApiException(e)
UiState.Error("-1", ex.errorMsg)
}
}
/**
* 查询物品信息列表
* @param param 查询参数
* @return UiState 包装的结果,Success 携带列表数据,Error 携带错误信息
*/
suspend fun queryGoodsList(param: MutableMap<String, Any>): UiState<MutableList<CookFoodGoodsEntity>?> {
return try {
val resp = apiService.queryGoodsList(param = param)
if (resp.isSuccess()) UiState.Success(resp.data)
else UiState.Error(resp.code, resp.msg ?: "")
} catch (e: Exception) {
val ex = getApiException(e)
UiState.Error("-1", ex.errorMsg)
}
}
/**
* 查询调料信息列表
* @param param 查询参数
* @return UiState 包装的结果,Success 携带列表数据,Error 携带错误信息
*/
suspend fun querySeasoningList(param: MutableMap<String, Any>): UiState<MutableList<SeasoningEntity>?> {
return try {
val resp = apiService.querySeasoningList(param = param)
if (resp.isSuccess()) UiState.Success(resp.data)
else UiState.Error(resp.code, resp.msg ?: "")
} catch (e: Exception) {
val ex = getApiException(e)
UiState.Error("-1", ex.errorMsg)
}
}
}
@@ -0,0 +1,15 @@
package com.shuwei.dish.match.net
/**
* 统一 UI 状态模型,用于 ViewModel 向 UI 层传递请求状态
* Idle — 初始状态,未发起请求
* Loading — 请求进行中
* Success — 请求成功,携带数据
* Error — 请求失败,携带错误码和错误信息
*/
sealed class UiState<out T> {
object Idle : UiState<Nothing>()
object Loading : UiState<Nothing>()
data class Success<T>(val data: T) : UiState<T>()
data class Error(val code: String, val msg: String) : UiState<Nothing>()
}
@@ -0,0 +1,68 @@
package com.shuwei.dish.match.objbox
import android.graphics.Bitmap
import android.net.Uri
import java.io.File
data class FoodClassInfo(
var class_names: List<String>,
var class_to_idx: Map<String, Int>,
var idx_to_class: Map<String, String>
)
data class FoodCollectionBean(
var imageUri: Uri? = null,
var bitmap: Bitmap? = null,
var imageFile: File? = null,
var imageVector: FloatArray? = null,
var isShowCamera: Boolean = false,
var isFinish:Boolean = false,
var uploadSuccess:Boolean = false
)
data class ReceiptGoodsInfo(
var goodsId: String? = "",
var goodsName: String? = "",
var procurementUnit: String? = "",
var procurementCount: Int? = 0,
var procurementPrice: Double? = 0.0,
var procurementAmount: Double? = 0.0,
var receiptCount: Int? = 0,
var goodsWeight: Int? = 0,
var goodsState: String? = "",
var isSelected: Boolean = false,
var isLocalGoods: Boolean = false
)
//data class DropdownInfo(
// var id: String,
// var name: String
//)
//data class RecognizeResult(
// var name: String? = null,
// var image: String? = null,
// var isSelected: Boolean = false
//)
//data class GoodsSearchInfo(
// var id: String? = null,
// var name: String? = null,
// var isSelected: Boolean = false
//)
data class OperateBean(
var obj: Any? = null,
var typeList: MutableList<OperateType> = mutableListOf(),
var isSelected: Boolean = false,
var isLocalGoods: Boolean = false,
var isReceiptPage: Boolean = true
)
data class OperateType(
var value: String = "",
var width: Int = 0,
var textColor: String = "#FF999999",
var isBoldFont: Boolean = false,
var isShowIcon: Boolean = false
)
@@ -0,0 +1,37 @@
package com.shuwei.dish.match.objbox
import io.objectbox.annotation.Entity
import io.objectbox.annotation.HnswIndex
import io.objectbox.annotation.Id
import io.objectbox.annotation.VectorDistanceType
@Entity
data class Food(
@Id var id: Long = 0,
var name: String? = null,
var foodIdx: Int = 0,
@HnswIndex(dimensions = 512, distanceType = VectorDistanceType.DOT_PRODUCT)
var foodVector: FloatArray? = null
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Food
if (id != other.id) return false
if (foodIdx != other.foodIdx) return false
if (name != other.name) return false
if (!foodVector.contentEquals(other.foodVector)) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + foodIdx
result = 31 * result + (name?.hashCode() ?: 0)
result = 31 * result + (foodVector?.contentHashCode() ?: 0)
return result
}
}
@@ -0,0 +1,308 @@
package com.shuwei.dish.match.objbox
import android.content.Context
import android.graphics.Bitmap
import android.util.Log
import androidx.core.graphics.scale
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.shuwei.dish.match.utils.AssetsTool
import com.shuwei.dish.match.utils.LogSaveUtil
import com.shuwei.dish.match.utils.ext.toJsonString
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.pytorch.IValue
import org.pytorch.Module
import org.pytorch.torchvision.TensorImageUtils
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
object FoodModule {
private lateinit var module_mobile: Module
private val NO_MEAN_RGB = floatArrayOf(0.0f, 0.0f, 0.0f)
private val NO_STD_RGB = floatArrayOf(1.0f, 1.0f, 1.0f)
// 1. 定义你的模型固定输入尺寸 (根据你的tflite模型修改,比如224x224)
private const val MODEL_INPUT_WIDTH = 300
private const val MODEL_INPUT_HEIGHT = 300
const val DEFAULT_FOOD_INDEX = -1
const val DEFAULT_QUERY_COUNT = 50
const val BAG_RATE = 0.05
suspend fun init(context: Context) {
withContext(Dispatchers.IO) {
module_mobile = Module.load(copyAssetToCache(context, "best_embedding_model_mobile.pt"))
initDefFoodData(context)
}
}
// fun uri2FloatArray(uri: Uri): FloatArray? {
// return MyApp.instance?.let { context ->
// ImageUtil.uriToBitmap(context, uri)?.let {
// bitmap2FloatArray(it)
// }
// }
// }
fun bitmap2FloatArray(originBitmap: Bitmap, isRecycle: Boolean): FloatArray? {
var rgb565Bitmap: Bitmap? = null
try {
val scaledBitmap = originBitmap.scale(MODEL_INPUT_WIDTH, MODEL_INPUT_HEIGHT)
if (isRecycle) {
originBitmap.recycle()
}
rgb565Bitmap = scaledBitmap.copy(Bitmap.Config.RGB_565, false)
scaledBitmap.recycle()
val inputTensor = TensorImageUtils.bitmapToFloat32Tensor(
rgb565Bitmap,
NO_MEAN_RGB, // [0.485, 0.456, 0.406] TORCHVISION_NORM_MEAN_RGB
NO_STD_RGB // [0.229, 0.224, 0.225] TORCHVISION_NORM_STD_RGB
)
val outputTensor = module_mobile.forward(IValue.from(inputTensor)).toTensor()
return outputTensor.dataAsFloatArray
} catch (e: OutOfMemoryError) {
e.printStackTrace()
logInfo("bitmap2FloatArray异常:${e.message}")
} finally {
if (rgb565Bitmap != null && rgb565Bitmap.isRecycled.not()) {
rgb565Bitmap.recycle()
}
//System.gc()
//System.runFinalization()
}
return null
}
// fun queryFood(uri: Uri, queryCount: Int = DEFAULT_QUERY_COUNT): List<String>? {
// return uri2FloatArray(uri)?.let {
// queryFood(it, queryCount)
// }
// }
// /**
// * 返回识别物品名称列表
// */
// fun queryFood(bitmap: Bitmap, queryCount: Int = DEFAULT_QUERY_COUNT): List<String> {
// val floatArray = bitmap2FloatArray(bitmap)
// return queryFood(floatArray, queryCount)
// }
// /**
// * 返回识别物品IdNameScore对象列表
// */
// fun queryFoodNameScore(bitmap: Bitmap, queryCount: Int = DEFAULT_QUERY_COUNT): List<IdNameScore> {
// val floatArray = bitmap2FloatArray(bitmap)
// return queryFoodNameScore(floatArray, queryCount)
// }
suspend fun queryFoodNameScore(
floatArray: FloatArray?,
queryCount: Int = DEFAULT_QUERY_COUNT
): List<IdNameScore> {
if (floatArray == null) return emptyList()
// val query: Query<Food> = box.query(Food_.foodVector.nearestNeighbors(floatArray, queryCount)).build()
// //查询比较分数
//// val tempList = query.findWithScores().sortedBy { it.score }.map { "${it.get().name}|${it.get().foodIdx}|${it.score}" }
// var idScoreList: List<IdWithScore>
// try {
// idScoreList = query.findIdsWithScores();
// } finally {
// // 先关闭Query,释放Cursor
// query.close()
// }
logInfo("queryFood向量:${floatArray.slice(0 until 50).toJsonString()}")
val idScoreList = ObjectBox.query(floatArray, queryCount)
// 批量加载所有实体,一次DB操作替代循环单条查询
val foodMap = ObjectBox.getByIds(idScoreList.map { it.id }).associateBy { it.id }
val nameScoreList = mutableListOf<IdNameScore>()
idScoreList.forEach {
val name = foodMap[it.id]?.name ?: ""
nameScoreList.add(IdNameScore(id = it.id, name = name, score = it.score))
}
logInfo("queryFood数据:${nameScoreList.toJsonString()}")
return nameScoreList
}
suspend fun getFoodScoreList(bitmap: Bitmap, queryCount: Int = DEFAULT_QUERY_COUNT): List<IdNameScore> {
val floatArray = bitmap2FloatArray(bitmap, false) ?: return emptyList()
logInfo("getFoodScoreList向量:${floatArray.toJsonString()}")
val nameScoreList = queryFoodNameScore(floatArray, queryCount)
if (nameScoreList.isEmpty()) {
return emptyList()
}
//暂时不过滤
val maxScoreList = nameScoreList
//.filter { it.score < 0.15 }
.groupBy { it.name }
.map { (_, value) -> value.minByOrNull { it.score }!! }
.toMutableList()
// val map = mutableMapOf<String, Int>()
// nameScoreList.forEach {
// val key = it.name
// val count = map[key] ?: 0
// map[key] = count + 1
// }
// val orderList = map.entries.sortedByDescending { it.value }.map { it.key }.toMutableList()
// val firstFood = nameScoreList[0].name
// orderList.remove(firstFood)
// orderList.add(0, firstFood)
//
// val sortedScoreList = maxScoreList.sortedWith(compareBy {
// orderList.indexOf(it.name)
// })
val sortedScoreList = maxScoreList.sortedBy { it.score }
logInfo("getFoodScoreList数据:${sortedScoreList.toJsonString()}")
return sortedScoreList
}
// suspend fun queryFood(floatArray: FloatArray, queryCount: Int = DEFAULT_QUERY_COUNT): List<String> {
// val map = mutableMapOf<String, Int>()
// val nameScoreList = queryFoodNameScore(floatArray, queryCount)
// nameScoreList.filter { it.score < 0.05 }.forEach {
// val count = map[it.name] ?: 0
// map[it.name] = count + 1
// }
// val list = map.entries.sortedByDescending { it.value }.map { it.key }
// return list
// }
data class IdNameScore(
val id: Long,
var name: String,
val score: Double
)
suspend fun initDefFoodData(context: Context, action: () -> Unit = {}) {
try {
val count = ObjectBox.countIndexField(DEFAULT_FOOD_INDEX)
if (count > 0) {
return
}
val embeddingsJson = AssetsTool.readAssetsFile(context, "data/embeddings.json")
val labelsJson = AssetsTool.readAssetsFile(context, "data/labels.json")
val classInfoJson = AssetsTool.readAssetsFile(context, "data/class_info.json")
val embeddingsList: List<List<Float>> =
Gson().fromJson(embeddingsJson, object : TypeToken<List<List<Float>>>() {}.type)
val labelsList: IntArray = Gson().fromJson(labelsJson, IntArray::class.java)
val classInfo: FoodClassInfo =
Gson().fromJson(classInfoJson, FoodClassInfo::class.java)
val foodMap = classInfo.idx_to_class
val list = mutableListOf<Food>()
val size = embeddingsList.size
val size2 = labelsList.size
val size3 = foodMap.size
logInfo("initDefFoodData: $size,$size2,$size3")
embeddingsList.forEachIndexed { index, floatList ->
val classIdx = labelsList[index]
val foodName = foodMap["$classIdx"]
val array = floatList.toFloatArray()
// ObjectBox.boxStore.runInTx {
// box.put(Food(name = foodName, foodVector = array, foodIdx = DEFAULT_FOOD_INDEX))
// }
val food = Food(name = foodName, foodVector = array, foodIdx = DEFAULT_FOOD_INDEX)
list.add(food)
}
// val tempList = list.chunked(10)
ObjectBox.putAll(list)
action()
} catch (e: Exception) {
e.printStackTrace()
logInfo("initDefFoodData: ---${e.message}--")
}
}
/**
* ,此方法的主要目的是:从assets 拷贝到 app的cache目录
* @param context
* @param fileName
* @return 例如是这样:/data/user/0/com.frizzle.pluginhookandroid9/cache/plugin-debug.apk
*
* 不可能反正SD
*/
// fun copyAssetToCache(context: Context, fileName: String): String? {
// // 此app的缓存目录 --> 会默认在 cache目录...,可以自己去看看哦
// val cacheDir = context.getCacheDir()
// if (!cacheDir.exists()) {
// cacheDir.mkdirs() // TODO 如果没有缓存目录,就创建
// }
// val outPath = File(cacheDir, fileName) // TODO 创建输出的文件位置
// if (outPath.exists()) {
// outPath.delete() // TODO 如果该文件已经存在,就删掉
// }
// var `is`: InputStream? = null // 读取
// var fos: FileOutputStream? = null // 写入
// try {
// // 创建文件,如果创建成功,就返回true
// val res = outPath.createNewFile()
// if (res) {
// `is` = context.getAssets().open(fileName) // 拿到main/assets目录的输入流,用于读取字节
// fos = FileOutputStream(outPath) // 读取出来的字节最终写到outPath
// val buf = ByteArray(`is`.available()) // 缓存区
// var byteCount: Int
//
// // 开始循环读取
// while ((`is`.read(buf).also { byteCount = it }) != -1) {
// fos.write(buf, 0, byteCount)
// }
// return outPath.getAbsolutePath()
// }
// } catch (e: IOException) {
// e.printStackTrace()
// } finally {
// try {
// // TODO 一定要记得关闭资源,为了不去性能的磨损
// fos?.flush()
// `is`?.close()
// fos?.close()
// } catch (e: IOException) {
// e.printStackTrace()
// }
// }
// return null
// }
fun copyAssetToCache(context: Context, fileName: String): String? {
val cacheFile = File(context.cacheDir, fileName)
val buffer = ByteArray(8 * 1024)
var inputStream: InputStream? = null
var outputStream: FileOutputStream? = null
try {
inputStream = context.assets.open(fileName)
outputStream = FileOutputStream(cacheFile)
var byteCount: Int
while (inputStream.read(buffer).also { byteCount = it } != -1) {
outputStream.write(buffer, 0, byteCount)
}
outputStream.channel.force(true) // 强制物理落盘,比flush更彻底
return cacheFile.absolutePath
} catch (e: IOException) {
e.printStackTrace()
logInfo("文件拷贝失败 fileName=$fileName, error=${e.message}")
// 拷贝失败时删除残缺文件,避免下次读取到损坏文件
if (cacheFile.exists()) {
cacheFile.delete()
}
} finally {
outputStream?.close()
inputStream?.close()
}
return null
}
private fun logInfo(msg:String) {
Log.d("FoodModule", msg)
LogSaveUtil.saveLogFile(msg)
}
}
@@ -0,0 +1,327 @@
/*
* Copyright 2024 ObjectBox Ltd. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.shuwei.dish.match.objbox
import android.annotation.SuppressLint
import android.content.Context
import android.util.Log
import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.utils.LogSaveUtil
import io.objectbox.Box
import io.objectbox.BoxStore
import io.objectbox.BoxStoreBuilder
import io.objectbox.exception.DbException
import io.objectbox.exception.FileCorruptException
import io.objectbox.kotlin.boxFor
import io.objectbox.query.Query
import io.objectbox.sync.Sync
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.util.zip.GZIPOutputStream
import com.shuwei.dish.match.BuildConfig
/**
* Singleton to keep BoxStore reference and provide current list of Notes Objects.
* Inserts demo data if no Objects are stored.
*/
object ObjectBox {
// 确保所有设备/版本使用相同的objectbox-models/default.json
// 避免直接修改自动生成的MyObjectBox类
// 跨版本升级时使用boxStore.runInTx执行数据迁移
private const val TAG = "ObjectBox"
// 添加 @Volatile 保证多线程下的可见性
@Volatile
var boxStore: BoxStore? = null
/**
* If building the [boxStore] failed, contains the thrown error message.
*/
var dbExceptionMessage: String? = null
private set
fun init(context: Context) {
if (boxStore != null && boxStore!!.isClosed.not()) return
// 优化:调大事务回收超时时间
System.setProperty("objectbox.finalizerTimeout", "30000");
// On Android make sure to pass a Context when building the Store.
boxStore = try {
MyObjectBox.builder()
.androidContext(context.applicationContext)
//.disableMultiProcess() // 开启多进程支持(按需关闭,单进程可删)
//.disableFileCompression() // 禁用文件压缩,避免存储异常
.build()
} catch (e: DbException) {
if (e.javaClass == DbException::class.java || e is FileCorruptException) {
// Failed to build BoxStore due to database file issue, store message;
// checked in NoteListActivity to notify user.
dbExceptionMessage = e.toString()
LogSaveUtil.saveLogFile("ObjectBox.init异常1:$dbExceptionMessage")
return
} else {
LogSaveUtil.saveLogFile("ObjectBox.init异常2:${e.message}")
// Failed to build BoxStore due to developer error.
throw e
}
}
if (BuildConfig.DEBUG) {
var syncAvailable = if (Sync.isAvailable()) "available" else "unavailable"
Log.d(
TAG,
"Using ObjectBox ${BoxStore.getVersion()} (${BoxStore.getVersionNative()}, sync $syncAvailable)"
)
// Enable ObjectBox Admin on debug builds.
// https://docs.objectbox.io/data-browser
io.objectbox.android.Admin(boxStore).start(context.applicationContext)
}
}
/**
* If the database file is not in use, compresses (GZIP) and copies it to the given [target].
* 增加 IOException 捕获,避免文件异常导致崩溃。
*/
fun copyAndGzipDatabaseFileTo(target: File, context: Context): Boolean {
if (BoxStore.isDatabaseOpen(context, null)) {
// Do not copy if database file is still in use.
// If it would be open, the copy will likely get corrupted
// as BoxStore may currently write data to the file.
Log.e(TAG, "Database file is still in use, can not copy.")
return false
}
// If a name was given when building BoxStore use that instead of the default below.
val dbName = BoxStoreBuilder.DEFAULT_NAME
return try {
File(context.filesDir, "objectbox/$dbName/data.mdb").inputStream().use { input ->
target.parentFile?.mkdirs()
GZIPOutputStream(target.outputStream()).use { output ->
input.copyTo(output, DEFAULT_BUFFER_SIZE)
}
}
true
} catch (e: IOException) {
Log.e(TAG, "备份数据库文件失败", e)
false
}
}
// 获取指定实体的Box,自动关联BoxStore
inline fun <reified T> getBox(): Box<T>? = boxStore?.boxFor()
// 协程安全执行数据库操作(推荐所有操作使用此方法)
suspend fun <T> safeDbOp(operation: suspend () -> T): T? {
return withContext(Dispatchers.IO) {
try {
dbMutex.withLock { operation() }
} catch (e: FileCorruptException) {
// 操作中触发损坏,尝试重建数据库
e.printStackTrace()
LogSaveUtil.saveLogFile("safeDbOp出现FileCorruptException异常:${e.message},${e.errorCode}")
// 使用安全调用替代 !! 断言,避免 NPE
val app = BaseApp.instance ?: return@withContext null
init(app)
null
} catch (e: Exception) {
e.printStackTrace()
LogSaveUtil.saveLogFile("safeDbOp出现其它异常:${e.message}")
null
} finally {
// 线程资源清理
boxStore?.closeThreadResources()
}
}
}
suspend fun query(floatArray: FloatArray, queryCount: Int) = safeDbOp {
val query: Query<Food>? = getBox<Food>()?.query(
Food_.foodVector
.nearestNeighbors(floatArray, queryCount)
)?.build()
try {
query?.findIdsWithScores()
} finally {
// 先关闭Query,释放Cursor
query?.close()
}
} ?: emptyList()
suspend fun get(id: Long) = safeDbOp {
getBox<Food>()?.get(id)
}
// 批量按ID加载,一次DB事务替代循环单条查询
suspend fun getByIds(ids: List<Long>): List<Food> = safeDbOp {
if (ids.isEmpty()) return@safeDbOp emptyList<Food>()
getBox<Food>()?.get(ids.toLongArray()) ?: emptyList<Food>()
} ?: emptyList()
suspend fun put(entity: Food) = safeDbOp {
getBox<Food>()?.put(entity)
}
suspend fun putAll(entities: List<Food>) = safeDbOp {
val box = getBox<Food>()
box?.put(entities)
}
// 使用 QueryBuilder 在数据库层计数,避免全量加载到内存
suspend fun countNameField(name: String) = safeDbOp {
getBox<Food>()?.query(Food_.name.equal(name))?.build()?.count()
} ?: 0L
suspend fun countIndexField(idx: Int) = safeDbOp {
getBox<Food>()?.query(Food_.foodIdx.equal(idx))?.build()?.count()
} ?: 0L
// 使用 QueryBuilder 在数据库层过滤,避免全量加载到内存
suspend fun filter(name: String?) = safeDbOp {
if (!name.isNullOrBlank()) {
getBox<Food>()?.query(Food_.name.contains(name))?.build()?.find()
?.distinctBy { it.name }
} else {
getBox<Food>()?.all?.distinctBy { it.name }
} ?: emptyList()
}
// 使用 QueryBuilder 在数据库层删除,避免全量加载到内存
suspend fun remove(name: String) = safeDbOp {
getBox<Food>()?.query(Food_.name.equal(name))?.build()?.remove()
}
/**
* 按名称分组统计采集数量
* 使用属性查询只加载 name 字段,不加载 foodVector,节省大量内存
* @param nameFilter 名称过滤关键词,为空时查询全部
* @return 按名称分组后的 (名称, 数量) 列表,按名称排序
*/
suspend fun queryFoodNameCounts(nameFilter: String? = null) = safeDbOp {
val box = getBox<Food>() ?: return@safeDbOp emptyList<Pair<String, Int>>()
val query = if (!nameFilter.isNullOrBlank()) {
box.query(Food_.name.contains(nameFilter)).build()
} else {
box.query().build()
}
try {
// 只查 name 字段,完全不加载 foodVector
query.property(Food_.name).findStrings()
.groupingBy { it }
.eachCount()
.map { (name, count) -> Pair(name, count) }
.sortedBy { it.first }
} finally {
query.close()
}
} ?: emptyList()
private val dbMutex = Mutex() // 协程并发锁,保证写入操作原子性
private const val DB_DIR_NAME = "objectbox" // ObjectBox 默认数据库目录
private const val BACKUP_DIR_NAME = "objectbox_backup" // 备份目录
// 检查内部存储是否可读写(操作数据库前调用)
// 修复:原逻辑错误地检查外部存储状态,数据库存于内部存储,与外部存储无关
fun isStorageAvailable(context: Context): Boolean {
return try {
context.filesDir.canRead() && context.filesDir.canWrite()
} catch (e: Exception) {
false
}
}
// 备份数据库到应用私有目录(无权限要求,推荐)
suspend fun backupDb(context: Context): Boolean = withContext(Dispatchers.IO) {
if (!isStorageAvailable(context)) return@withContext false
val dbDir = File(context.filesDir, DB_DIR_NAME)
val backupDir = File(context.filesDir, BACKUP_DIR_NAME)
return@withContext copyDir(dbDir, backupDir)
}
// 从备份恢复数据库(恢复后会重建BoxStore)
suspend fun restoreDb(context: Context): Boolean = withContext(Dispatchers.IO) {
if (!isStorageAvailable(context)) return@withContext false
val dbDir = File(context.filesDir, DB_DIR_NAME)
val backupDir = File(context.filesDir, BACKUP_DIR_NAME)
if (!backupDir.exists()) return@withContext false
// 先关闭旧的BoxStore,删除损坏文件,再恢复备份
boxStore?.close()
deleteDbFiles(context)
val isSuccess = copyDir(backupDir, dbDir)
// 重新初始化
init(context)
return@withContext isSuccess
}
// 递归删除数据库文件
private fun deleteDbFiles(context: Context) {
val dbDir = File(context.filesDir, DB_DIR_NAME)
if (dbDir.exists()) deleteDirRecursively(dbDir)
}
// 递归删除目录
private fun deleteDirRecursively(file: File) {
if (file.isDirectory) {
file.listFiles()?.forEach { deleteDirRecursively(it) }
}
file.delete()
}
// 递归复制目录(核心备份/恢复逻辑)
private fun copyDir(srcDir: File, destDir: File): Boolean {
return try {
if (!srcDir.exists()) return false
if (!destDir.exists()) destDir.mkdirs()
srcDir.listFiles()?.forEach { srcFile ->
val destFile = File(destDir, srcFile.name)
if (srcFile.isDirectory) {
copyDir(srcFile, destFile)
} else {
copyFile(srcFile, destFile)
}
}
true
} catch (e: IOException) {
e.printStackTrace()
false
}
}
// 复制单个文件(使用NIO,高效稳定)
private fun copyFile(srcFile: File, destFile: File) {
FileInputStream(srcFile).channel.use { srcChannel ->
FileOutputStream(destFile).channel.use { destChannel ->
destChannel.transferFrom(srcChannel, 0, srcChannel.size())
}
}
}
// 关闭BoxStore(应用退出时调用,可选)
fun close() {
boxStore?.close()
boxStore = null
}
}
@@ -0,0 +1,156 @@
package com.shuwei.dish.match.scale
import android.content.Context
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.util.Log
import java.util.LinkedList
/**
* mDNS 设备发现管理器(仅主设备运行)
* 持续扫描局域网,发现或丢失 DishMatch 子设备时通过回调通知连接管理器
*
* 注意:Android NsdManager.resolveService 不支持并发调用,多台子设备同时被发现时
* 会报 FAILURE_ALREADY_ACTIVE(3) 导致解析失败。此处使用串行队列逐一解析。
*/
class MdnsDiscoveryManager(private val context: Context) {
companion object {
private const val TAG = "MdnsDiscoveryManager"
}
private val nsdManager: NsdManager by lazy {
context.getSystemService(Context.NSD_SERVICE) as NsdManager
}
private var discoveryListener: NsdManager.DiscoveryListener? = null
private var isDiscovering = false
/** 待解析队列,串行处理避免并发解析失败 */
private val resolveQueue = LinkedList<NsdServiceInfo>()
@Volatile
private var isResolving = false
/**
* 发现新设备时的回调:(deviceId, host, port)
*/
var onDeviceFound: ((deviceId: String, host: String, port: Int) -> Unit)? = null
/**
* 设备离线时的回调:(deviceId)
*/
var onDeviceLost: ((deviceId: String) -> Unit)? = null
/**
* 开始扫描局域网中的 DishMatch 设备
*/
fun startDiscovery() {
if (isDiscovering) return
discoveryListener = object : NsdManager.DiscoveryListener {
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
Log.e(TAG, "开始扫描失败: errorCode=$errorCode")
isDiscovering = false
}
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
Log.e(TAG, "停止扫描失败: errorCode=$errorCode")
}
override fun onDiscoveryStarted(serviceType: String) {
Log.d(TAG, "mDNS 扫描已启动")
isDiscovering = true
}
override fun onDiscoveryStopped(serviceType: String) {
Log.d(TAG, "mDNS 扫描已停止")
isDiscovering = false
}
override fun onServiceFound(service: NsdServiceInfo) {
// 只处理 DishMatch 服务
if (!service.serviceName.startsWith("DishMatch-")) return
Log.d(TAG, "发现设备: ${service.serviceName}")
enqueueResolve(service)
}
override fun onServiceLost(service: NsdServiceInfo) {
if (!service.serviceName.startsWith("DishMatch-")) return
val deviceId = service.serviceName.removePrefix("DishMatch-")
Log.d(TAG, "设备离线: $deviceId")
onDeviceLost?.invoke(deviceId)
}
}
nsdManager.discoverServices(
MdnsRegisterManager.SERVICE_TYPE,
NsdManager.PROTOCOL_DNS_SD,
discoveryListener
)
}
/**
* 解析服务获取 IP 和端口
*/
private fun resolveService(service: NsdServiceInfo) {
nsdManager.resolveService(service, object : NsdManager.ResolveListener {
override fun onResolveFailed(info: NsdServiceInfo, errorCode: Int) {
Log.e(TAG, "解析设备地址失败: ${info.serviceName}, errorCode=$errorCode")
// 解析结束(失败),继续处理队列中下一个
processNextResolve()
}
override fun onServiceResolved(info: NsdServiceInfo) {
val deviceId = info.serviceName.removePrefix("DishMatch-")
val host = info.host?.hostAddress ?: run {
processNextResolve()
return
}
val port = info.port
Log.d(TAG, "设备地址已解析: $deviceId -> $host:$port")
onDeviceFound?.invoke(deviceId, host, port)
// 解析结束(成功),继续处理队列中下一个
processNextResolve()
}
})
}
/** 将待解析服务加入队列,若当前无解析任务则立即开始 */
@Synchronized
private fun enqueueResolve(service: NsdServiceInfo) {
resolveQueue.add(service)
if (!isResolving) {
processNextResolve()
}
}
/** 从队列取出下一个服务进行解析 */
@Synchronized
private fun processNextResolve() {
val next = resolveQueue.poll() ?: run {
isResolving = false
return
}
isResolving = true
resolveService(next)
}
/**
* 停止扫描,在应用退出时调用
*/
fun stopDiscovery() {
if (!isDiscovering) return
discoveryListener?.let {
try {
nsdManager.stopServiceDiscovery(it)
} catch (e: Exception) {
Log.e(TAG, "停止扫描异常: ${e.message}")
}
}
discoveryListener = null
synchronized(this) {
resolveQueue.clear()
isResolving = false
}
}
}
@@ -0,0 +1,80 @@
package com.shuwei.dish.match.scale
import android.content.Context
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.util.Log
/**
* mDNS 服务注册管理器(主设备和子设备均运行)
* 将本机 WebSocket 服务以 mDNS 形式广播到局域网,让主设备能发现本机 IP:PORT
*/
class MdnsRegisterManager(private val context: Context) {
companion object {
private const val TAG = "MdnsRegisterManager"
/** mDNS 服务类型,所有 DishMatch 设备使用统一类型 */
const val SERVICE_TYPE = "_dishmatch._tcp."
/** WebSocket 服务监听端口 */
const val WS_PORT = 8765
}
private val nsdManager: NsdManager by lazy {
context.getSystemService(Context.NSD_SERVICE) as NsdManager
}
private var registrationListener: NsdManager.RegistrationListener? = null
private var isRegistered = false
/**
* 向局域网注册 mDNS 服务
* @param deviceId 当前设备 ID,作为服务名称后缀,便于主设备识别
*/
fun register(deviceId: String) {
if (isRegistered) return
val serviceInfo = NsdServiceInfo().apply {
serviceName = "DishMatch-$deviceId"
serviceType = SERVICE_TYPE
port = WS_PORT
}
registrationListener = object : NsdManager.RegistrationListener {
override fun onRegistrationFailed(info: NsdServiceInfo, errorCode: Int) {
Log.e(TAG, "mDNS 注册失败, errorCode=$errorCode")
isRegistered = false
}
override fun onUnregistrationFailed(info: NsdServiceInfo, errorCode: Int) {
Log.e(TAG, "mDNS 注销失败, errorCode=$errorCode")
}
override fun onServiceRegistered(info: NsdServiceInfo) {
Log.d(TAG, "mDNS 注册成功: ${info.serviceName}")
isRegistered = true
}
override fun onServiceUnregistered(info: NsdServiceInfo) {
Log.d(TAG, "mDNS 已注销: ${info.serviceName}")
isRegistered = false
}
}
nsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, registrationListener)
}
/**
* 注销 mDNS 服务,在应用退出时调用
*/
fun unregister() {
if (!isRegistered) return
registrationListener?.let {
try {
nsdManager.unregisterService(it)
} catch (e: Exception) {
Log.e(TAG, "注销 mDNS 异常: ${e.message}")
}
}
registrationListener = null
}
}
@@ -0,0 +1,17 @@
package com.shuwei.dish.match.scale
/**
* 主设备向子设备发送的控制指令
* @param cmd 指令类型,如 "tare"(清零)
* @param deviceId 目标设备 ID,子设备收到后校验是否匹配自身
* @param address 目标秤地址
*/
data class ScaleCommand(
val cmd: String,
val deviceId: String,
val address: Int
) {
companion object {
const val CMD_TARE = "tare"
}
}
@@ -0,0 +1,20 @@
package com.shuwei.dish.match.scale
/**
* 单个秤的数据快照
* @param deviceId 所属设备 ID
* @param address 秤地址编号(硬件 address,对应 WeightUtil 回调的 address 参数)
* @param weight 重量,单位克
* @param state 秤状态:1=稳定,0=不稳定,2=量程溢出(对应 WeightUtil 中的 STATE_* 常量)
* @param ts 数据时间戳(毫秒)
* @param ip 所属设备的 IP 地址,用于 UI 展示;网络传输数据中可能为空
*/
data class ScaleData(
val deviceId: String,
val address: Int,
val weight: Double,
val state: Int,
val ts: Long,
val ip: String = "",
val name: String? = null
)
@@ -0,0 +1,122 @@
package com.shuwei.dish.match.scale
import android.util.Log
import com.shuwei.dish.match.utils.WeightUtil
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.concurrent.ConcurrentHashMap
/**
* 秤数据聚合器(仅主设备运行)
* 统一管理本机秤(直接读取 WeightUtil)和远端子设备秤(通过 WebSocket 接收)的数据
* 通过 StateFlow 将所有秤的最新快照暴露给 UI 层
*
* 数据 Map 的 key 格式:"{deviceId}#{address}",便于 UI 按设备分组展示
*/
class ScaleDataAggregator(private val localDeviceId: String) {
companion object {
private const val TAG = "ScaleDataAggregator"
/** 本机秤监听器的 key,防止与其他监听器冲突 */
private const val LOCAL_LISTENER_KEY = "ScaleDataAggregator_local"
}
/** 所有秤的最新数据,UI 层通过此 Flow 订阅更新 */
private val _allScales = MutableStateFlow<Map<String, ScaleData>>(emptyMap())
val allScales: StateFlow<Map<String, ScaleData>> = _allScales.asStateFlow()
/** 内部缓存,并发安全 */
private val cache = ConcurrentHashMap<String, ScaleData>()
/** deviceId → IP 地址映射,用于写入 ScaleData.ip 字段 */
private val deviceIpMap = ConcurrentHashMap<String, String>()
/**
* 设置指定设备的 IP 地址
* 若缓存中已有该设备的数据且 ip 为空,立即回填并触发 StateFlow 更新,
* 解决早期数据包在 IP 到达前入缓存导致一直显示"未知"的问题
* @param deviceId 设备 ID
* @param ip IP 地址字符串
*/
fun setDeviceIp(deviceId: String, ip: String) {
if (ip.isEmpty()) return
deviceIpMap[deviceId] = ip
// 回填该设备所有 ip 为空的缓存条目
val keys = cache.keys.filter { it.startsWith("$deviceId#") }
if (keys.isEmpty()) return
var updated = false
keys.forEach { key ->
val old = cache[key] ?: return@forEach
if (old.ip.isEmpty()) {
cache[key] = old.copy(ip = ip)
updated = true
}
}
if (updated) {
_allScales.value = HashMap(cache)
Log.d(TAG, "已回填设备IP: $deviceId -> $ip, 共 ${keys.size}")
}
}
/**
* 启动本机秤数据监听
* 主设备本机秤直接读取 WeightUtil,无需经过网络
*/
fun startLocalCollection() {
WeightUtil.addWeightListener(LOCAL_LISTENER_KEY) { address, state, weight ->
val data = ScaleData(
deviceId = localDeviceId,
address = address,
weight = weight,
state = state,
ts = System.currentTimeMillis(),
ip = deviceIpMap[localDeviceId] ?: ""
)
updateCache(data)
}
Log.d(TAG, "本机秤监听已启动, deviceId=$localDeviceId")
}
/**
* 处理来自 WebSocket 客户端的远端秤数据
* 由 ScaleWebSocketClient.onScaleData 回调触发
* 自动补充该设备已记录的 IP 信息
*/
fun onRemoteScaleData(data: ScaleData) {
val ip = deviceIpMap[data.deviceId] ?: data.ip
updateCache(if (ip.isNotEmpty() && data.ip.isEmpty()) data.copy(ip = ip) else data)
}
/**
* 移除指定设备的所有秤数据(设备离线时调用)
*/
fun removeDevice(deviceId: String) {
val keysToRemove = cache.keys.filter { it.startsWith("$deviceId#") }
if (keysToRemove.isEmpty()) return
keysToRemove.forEach { cache.remove(it) }
deviceIpMap.remove(deviceId)
_allScales.value = HashMap(cache)
Log.d(TAG, "已移除设备数据: $deviceId, 共 ${keysToRemove.size} 个秤")
}
/**
* 停止本机秤监听并清空所有数据
*/
fun stop() {
WeightUtil.removeWeightListener(LOCAL_LISTENER_KEY)
cache.clear()
deviceIpMap.clear()
_allScales.value = emptyMap()
Log.d(TAG, "ScaleDataAggregator 已停止")
}
/**
* 更新缓存并发布新快照到 StateFlow
*/
private fun updateCache(data: ScaleData) {
val key = "${data.deviceId}#${data.address}"
cache[key] = data
_allScales.value = HashMap(cache)
}
}
@@ -0,0 +1,22 @@
package com.shuwei.dish.match.scale
/**
* 各子设备的固定 ID 与秤地址排列顺序配置
* MasterScaleActivity 和 SlaveActivity 共用此配置,避免重复定义
*/
object ScaleDeviceConfig {
const val DEVICE_ID_2 = "8fc2ab34-2137-3112-acca-f884ea8736d4"
const val DEVICE_ID_22 = "a46fa55c-113c-3511-bb1f-41e5eff77c4b"
const val DEVICE_ID_18 = "1038da9f-c6eb-326e-a1d9-d6d3af978b22"
const val DEVICE_ID_1 = "7cc0f6ea-f13d-3013-a867-fc998eb554ac"
/** 22个秤的显示顺序(按物理位置排列) */
val SCALE_ORDER_22 = listOf(20, 22, 19, 21, 6, 12, 18, 5, 11, 17, 4, 10, 16, 3, 9, 15, 2, 8, 14, 1, 7, 13)
/** 18个秤的显示顺序(按物理位置排列) */
val SCALE_ORDER_18 = listOf(6, 5, 4, 3, 2, 1, 12, 11, 10, 9, 8, 7, 18, 17, 16, 15, 14, 13)
/** 设备在列表中的显示顺序 */
val DEVICE_ORDER = listOf(DEVICE_ID_2, DEVICE_ID_22, DEVICE_ID_18, DEVICE_ID_1)
}
@@ -0,0 +1,38 @@
package com.shuwei.dish.match.scale
/**
* 秤事件数据类,用于主设备和子设备之间的非重量类通知
* @param type 事件类型,参见 companion object 中的常量
* @param deviceId 发送方设备 ID
* @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 = 0,
val delta: Double = 0.0,
val slots: List<SlotConfig>? = 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"
}
}
@@ -0,0 +1,245 @@
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
/**
* 秤服务统一门面(单例)
* 根据当前设备角色(MASTER/SLAVE)启动对应的服务组合:
*
* 所有设备均运行:
* - MdnsRegisterManager:将本机 WebSocket 服务广播到局域网
* - ScaleWebSocketServer:对外提供本机秤数据推送服务
*
* 仅主设备(MASTER)额外运行:
* - MdnsDiscoveryManager:发现局域网内子设备
* - ScaleWebSocketClient:连接子设备 WebSocket
* - ScaleDataAggregator:聚合本机 + 远端所有秤数据
*
* 使用方式:
* 在 Application.onCreate() 中调用 ScaleServiceManager.start(context)
* 在 Application.onTerminate() 中调用 ScaleServiceManager.stop()
* 主设备 UI 通过 ScaleServiceManager.allScales 订阅全量秤数据
*/
object ScaleServiceManager {
private const val TAG = "ScaleServiceManager"
private var mdnsRegister: MdnsRegisterManager? = null
private var wsServer: ScaleWebSocketServer? = null
private var udpSender: UdpBroadcastSender? = null
// 以下仅主设备使用
private var mdnsDiscovery: MdnsDiscoveryManager? = null
private var udpReceiver: UdpBroadcastReceiver? = null
private var wsClient: ScaleWebSocketClient? = null
private var aggregator: ScaleDataAggregator? = null
/**
* 所有秤数据的实时流,仅主设备有效;子设备返回 null
* UI 层通过 lifecycleScope.launch { allScales?.collect { ... } } 订阅
*/
val allScales: StateFlow<Map<String, ScaleData>>?
get() = aggregator?.allScales
/**
* 收到子设备秤事件(如调料添加)时的回调,仅主设备有效
* 在子线程中调用,需自行切换到主线程更新 UI
*/
private var _onScaleEvent: ((ScaleEvent) -> Unit)? = null
var onScaleEvent: ((ScaleEvent) -> Unit)?
get() = _onScaleEvent
set(value) {
_onScaleEvent = value
wsClient?.onScaleEvent = value
}
/**
* 收到主设备下发的调料配置同步事件时的回调,仅子设备有效
* 在子线程中调用,需自行切换到主线程更新 UI
*/
var onSeasoningConfig: ((ScaleEvent) -> Unit)?
get() = wsServer?.onSeasoningConfig
set(value) { wsServer?.onSeasoningConfig = value }
/**
* 向所有已连接子设备广播调料配置(主设备调用)
* @param slots 全量槽位配置列表
*/
fun sendSeasoningConfig(slots: List<ScaleEvent.SlotConfig>) {
wsClient?.sendToAllSlaves(buildConfigEvent(slots))
}
/**
* 向单台刚连接的子设备推送调料配置(连接时按需调用)
* @param deviceId 目标子设备 ID
*/
private fun sendSeasoningConfigTo(deviceId: String, slots: List<ScaleEvent.SlotConfig>) {
wsClient?.sendToDevice(deviceId, buildConfigEvent(slots))
}
private fun buildConfigEvent(slots: List<ScaleEvent.SlotConfig>) = ScaleEvent(
type = ScaleEvent.TYPE_SEASONING_CONFIG,
deviceId = GlobalData.deviceId,
slots = slots
)
/**
* 向主设备广播秤事件(子设备调用)
* @param event 要广播的事件
*/
fun broadcastEvent(event: ScaleEvent) {
wsServer?.broadcastEvent(event)
}
/**
* 主设备连接状态回调(仅子设备使用)
* 设置后会立即转发给 wsServer;需在 start() 之后设置才能生效
*/
var onMasterConnectionChanged: ((connected: Boolean) -> Unit)?
get() = wsServer?.onConnectionChanged
set(value) { wsServer?.onConnectionChanged = value }
/** 当前是否有主设备连接(仅子设备使用) */
val isMasterConnected: Boolean
get() = wsServer?.isConnected == true
/**
* 启动所有秤服务,在 Application.onCreate() 中调用
*/
fun start(context: Context) {
val deviceId = GlobalData.deviceId
val role = GlobalData.deviceRole
Log.d(TAG, "启动秤服务, deviceId=$deviceId, role=$role")
// 所有设备:启动 WebSocket 服务端 + mDNS 注册
wsServer = ScaleWebSocketServer(deviceId, context).also { it.start() }
mdnsRegister = MdnsRegisterManager(context).also { it.register(deviceId) }
if (role == DeviceRole.MASTER) {
startMasterServices(context, deviceId)
} else {
// 子设备:启动 UDP 广播,作为 mDNS 的兜底发现机制
val localIp = getLocalIp(context)
udpSender = UdpBroadcastSender(deviceId, localIp).also { it.start() }
}
}
/**
* 获取本机局域网 IP,委托给 NetworkUtil 统一处理版本兼容
*/
private fun getLocalIp(context: Context): String {
return NetworkUtil.getLocalIpAddress(context)
}
/**
* 启动主设备专属服务
*/
private fun startMasterServices(context: Context, deviceId: String) {
// 聚合器:统一管理本机 + 远端秤数据
aggregator = ScaleDataAggregator(deviceId).also {
// 记录本机 IP,供本机秤数据展示使用
it.setDeviceIp(deviceId, getLocalIp(context))
it.startLocalCollection()
}
// WebSocket 客户端
val client = ScaleWebSocketClient().also {
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
// 统一的设备发现处理:mDNS 和 UDP 共用同一逻辑
fun onDeviceFound(remoteId: String, host: String, port: Int) {
if (remoteId == deviceId) return
Log.d(TAG, "发现子设备 $remoteId ($host:$port), 建立连接")
aggregator?.setDeviceIp(remoteId, host)
client.connect(remoteId, host, port)
}
// mDNS 发现:发现子设备 → 建立 WebSocket 连接;设备离线 → 仅清除 UDP 缓存,WebSocket 层自行重连
mdnsDiscovery = MdnsDiscoveryManager(context).also { discovery ->
discovery.onDeviceFound = { remoteId, host, port ->
onDeviceFound(remoteId, host, port)
}
discovery.onDeviceLost = { remoteId ->
// mDNS 抖动不永久断开,WebSocket 层会持续重连直到网络恢复
// 仅清除 UDP 缓存,确保 UDP 重新发现时能再次触发 onDeviceFound
udpReceiver?.removeDevice(remoteId)
Log.d(TAG, "子设备 mDNS 离线: $remoteId, 保持 WebSocket 重连")
}
discovery.startDiscovery()
}
// UDP 广播接收:作为 mDNS 的兜底,发现 mDNS 未能触发的子设备
udpReceiver = UdpBroadcastReceiver().also { receiver ->
receiver.onDeviceFound = { remoteId, host, port ->
onDeviceFound(remoteId, host, port)
}
receiver.start()
}
}
/**
* 向指定设备的指定秤发送清零指令
* - 本机秤:直接调用 WeightUtil.tareTwo
* - 子设备秤:通过 WebSocket 发送指令
* @param deviceId 目标设备 ID
* @param address 目标秤地址
*/
fun sendTare(deviceId: String, address: Int): Boolean {
return if (deviceId == GlobalData.deviceId) {
WeightUtil.tareTwo(address)
true
} else {
val cmd = ScaleCommand(ScaleCommand.CMD_TARE, deviceId, address)
wsClient?.sendCommand(deviceId, cmd) ?: false
}
}
/**
* 停止所有秤服务,在 Application.onTerminate() 或退出时调用
*/
fun stop() {
mdnsDiscovery?.stopDiscovery()
udpReceiver?.stop()
udpSender?.stop()
wsClient?.shutdown()
aggregator?.stop()
wsServer?.stop()
mdnsRegister?.unregister()
mdnsDiscovery = null
udpReceiver = null
udpSender = null
wsClient = null
aggregator = null
wsServer = null
mdnsRegister = null
Log.d(TAG, "所有秤服务已停止")
}
}
@@ -0,0 +1,202 @@
package com.shuwei.dish.match.scale
import android.util.Log
import com.google.gson.Gson
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.ScheduledFuture
import java.util.concurrent.TimeUnit
/**
* 秤数据 WebSocket 客户端连接管理器(仅主设备运行)
* 管理与多台子设备的 WebSocket 长连接,支持断线自动重连(指数退避)
*/
class ScaleWebSocketClient {
companion object {
private const val TAG = "ScaleWebSocketClient"
/** 初始重连间隔(毫秒) */
private const val RECONNECT_BASE_MS = 2_000L
/** 最大重连间隔(毫秒) */
private const val RECONNECT_MAX_MS = 30_000L
}
private val gson = Gson()
private val httpClient = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.SECONDS) // WebSocket 长连接不设置读取超时
.pingInterval(20, TimeUnit.SECONDS) // 心跳保活
.build()
/** 各设备当前 WebSocket 连接,key = deviceId */
private val connections = ConcurrentHashMap<String, WebSocket>()
/** 各设备当前重连任务,key = deviceId */
private val reconnectTasks = ConcurrentHashMap<String, ScheduledFuture<*>>()
/** 各设备当前重连延迟,key = deviceId */
private val reconnectDelays = ConcurrentHashMap<String, Long>()
/** 已主动移除的设备集合,不对其触发重连 */
private val removedDevices = ConcurrentHashMap.newKeySet<String>()
private val scheduler: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor()
/** 收到秤数据时的回调,在子线程调用 */
var onScaleData: ((data: ScaleData) -> Unit)? = null
/** 收到秤事件(如调料添加通知)时的回调,在子线程调用 */
var onScaleEvent: ((event: ScaleEvent) -> Unit)? = null
/** 设备断线时的回调(连接失败或关闭),在子线程调用 */
var onDeviceDisconnected: ((deviceId: String) -> Unit)? = null
/** 子设备首次连接成功时的回调,在子线程调用;可用于主设备主动推送全量配置 */
var onDeviceConnected: ((deviceId: String) -> Unit)? = null
/**
* 连接到指定子设备
* @param deviceId 子设备 ID
* @param host 子设备 IP
* @param port 子设备 WebSocket 端口
*/
fun connect(deviceId: String, host: String, port: Int) {
removedDevices.remove(deviceId)
// 取消旧 IP 的重连任务,防止 IP 变化时新旧任务并发竞争
reconnectTasks.remove(deviceId)?.cancel(false)
reconnectDelays[deviceId] = RECONNECT_BASE_MS
doConnect(deviceId, host, port)
}
private fun doConnect(deviceId: String, host: String, port: Int) {
val url = "ws://$host:$port"
val request = Request.Builder().url(url).build()
val ws = httpClient.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
Log.d(TAG, "已连接子设备: $deviceId ($url)")
connections[deviceId] = webSocket
reconnectDelays[deviceId] = RECONNECT_BASE_MS
reconnectTasks.remove(deviceId)?.cancel(false)
onDeviceConnected?.invoke(deviceId)
}
override fun onMessage(webSocket: WebSocket, text: String) {
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)
onScaleData?.invoke(data)
}
} catch (e: Exception) {
Log.w(TAG, "解析消息失败: ${e.message}")
}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Log.w(TAG, "连接子设备失败: $deviceId, ${t.message}")
connections.remove(deviceId)
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)
onDeviceDisconnected?.invoke(deviceId)
scheduleReconnect(deviceId, host, port)
}
})
connections[deviceId] = ws
}
/**
* 指数退避重连
* 入口不拦截 removedDevices,仅在延迟任务执行前检查,
* 确保 disconnect() 后已入队的任务不执行,但新断线事件始终能进入重连流程
*/
private fun scheduleReconnect(deviceId: String, host: String, port: Int) {
val delay = reconnectDelays[deviceId] ?: RECONNECT_BASE_MS
Log.d(TAG, "将在 ${delay}ms 后重连子设备: $deviceId")
val future = scheduler.schedule({
if (!removedDevices.contains(deviceId)) {
doConnect(deviceId, host, port)
}
}, delay, TimeUnit.MILLISECONDS)
reconnectTasks[deviceId]?.cancel(false)
reconnectTasks[deviceId] = future
// 下次重连延迟翻倍,上限 30s
reconnectDelays[deviceId] = minOf(delay * 2, RECONNECT_MAX_MS)
}
/**
* 向指定子设备发送控制指令
* @param deviceId 目标子设备 ID
* @param command 指令对象
* @return true=发送成功,false=设备未连接
*/
fun sendCommand(deviceId: String, command: ScaleCommand): Boolean {
val ws = connections[deviceId] ?: return false
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
*/
fun disconnect(deviceId: String) {
removedDevices.add(deviceId)
reconnectTasks.remove(deviceId)?.cancel(false)
connections.remove(deviceId)?.close(1000, "设备离线")
reconnectDelays.remove(deviceId)
Log.d(TAG, "已断开子设备: $deviceId")
}
/**
* 关闭所有连接并释放资源,在应用退出时调用
*/
fun shutdown() {
val ids = connections.keys.toList()
ids.forEach { disconnect(it) }
scheduler.shutdownNow()
httpClient.dispatcher.executorService.shutdown()
Log.d(TAG, "ScaleWebSocketClient 已关闭")
}
}
@@ -0,0 +1,178 @@
package com.shuwei.dish.match.scale
import android.content.Context
import android.util.Log
import com.google.gson.Gson
import com.shuwei.dish.match.utils.NetworkUtil
import com.shuwei.dish.match.utils.WeightUtil
import org.java_websocket.WebSocket
import org.java_websocket.handshake.ClientHandshake
import org.java_websocket.server.WebSocketServer
import java.net.InetSocketAddress
/**
* 秤数据 WebSocket 服务端(主设备和子设备均运行)
* 监听 WeightUtil 回调,将本机秤数据实时推送给所有已连接的主设备客户端
*/
class ScaleWebSocketServer(private val deviceId: String, private val context: Context) {
companion object {
private const val TAG = "ScaleWebSocketServer"
/** 节流间隔:两次推送之间的最小间隔(毫秒) */
private const val THROTTLE_MS = 100L
}
private val gson = Gson()
private var server: InternalServer? = null
/** 主设备连接状态变化回调:true=已连接,false=已断开;在主线程外调用,需自行切换线程 */
var onConnectionChanged: ((connected: Boolean) -> Unit)? = null
/** 收到主设备下发的调料配置同步事件时的回调,在子线程调用 */
var onSeasoningConfig: ((event: ScaleEvent) -> Unit)? = null
/** 当前是否有主设备连接 */
val isConnected: Boolean
get() = server?.connections?.isNotEmpty() == true
/** 每个秤地址的最近一次推送时间,用于节流 */
private val lastPushTime = mutableMapOf<Int, Long>()
/** 缓存各秤最新数据,新客户端连接时立即同步当前状态 */
private val latestData = mutableMapOf<Int, ScaleData>()
/**
* 启动 WebSocket 服务器,并注册 WeightUtil 回调
*/
fun start() {
server = InternalServer(MdnsRegisterManager.WS_PORT)
server?.isReuseAddr = true
server?.start()
Log.d(TAG, "WebSocket 服务端已启动,端口 ${MdnsRegisterManager.WS_PORT}")
// 注册本机秤数据监听
WeightUtil.addWeightListener(TAG) { address, state, weight ->
val now = System.currentTimeMillis()
// 节流:同一地址 100ms 内只推送一次
if (now - (lastPushTime[address] ?: 0L) < THROTTLE_MS) return@addWeightListener
lastPushTime[address] = now
val data = ScaleData(
deviceId = deviceId,
address = address,
weight = weight,
state = state,
ts = now,
ip = NetworkUtil.getLocalIpAddress(context)
)
latestData[address] = data
broadcast(gson.toJson(data))
}
}
/**
* 停止 WebSocket 服务器,移除秤监听
*/
fun stop() {
WeightUtil.removeWeightListener(TAG)
onConnectionChanged = null
onSeasoningConfig = null
try {
server?.stop(1000)
} catch (e: Exception) {
Log.e(TAG, "停止服务端异常: ${e.message}")
}
server = null
latestData.clear()
lastPushTime.clear()
Log.d(TAG, "WebSocket 服务端已停止")
}
/**
* 向所有已连接的主设备广播秤事件(如调料添加通知)
* @param event 要广播的事件
*/
fun broadcastEvent(event: ScaleEvent) {
broadcast(gson.toJson(event))
}
/**
* 向所有已连接客户端广播消息
*/
private fun broadcast(json: String) {
server?.connections?.forEach { conn ->
if (conn.isOpen) {
try {
conn.send(json)
} catch (e: Exception) {
Log.w(TAG, "推送失败: ${e.message}")
}
}
}
}
/**
* 内部 WebSocket 服务器实现
*/
private inner class InternalServer(port: Int) :
WebSocketServer(InetSocketAddress(port)) {
override fun onOpen(conn: WebSocket, handshake: ClientHandshake) {
Log.d(TAG, "主设备已连接: ${conn.remoteSocketAddress}")
onConnectionChanged?.invoke(true)
// 新客户端连接后,立即推送所有秤的最新数据
latestData.values.forEach { data ->
try {
conn.send(gson.toJson(data))
} catch (e: Exception) {
Log.w(TAG, "初始推送失败: ${e.message}")
}
}
}
override fun onClose(conn: WebSocket, code: Int, reason: String, remote: Boolean) {
Log.d(TAG, "主设备已断开: ${conn.remoteSocketAddress}, reason=$reason")
// 若已无任何连接,通知子设备 UI 更新为未连接状态
if (server?.connections.isNullOrEmpty()) {
onConnectionChanged?.invoke(false)
}
}
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) {
Log.d(TAG, "收到清零指令, 但设备号不正确, deviceId=${cmd.deviceId}")
return
}
when (cmd.cmd) {
ScaleCommand.CMD_TARE -> {
Log.d(TAG, "收到清零指令, address=${cmd.address}")
WeightUtil.tareTwo(cmd.address)
}
}
} catch (e: Exception) {
Log.w(TAG, "收到消息, 解析失败: ${e.message}")
}
}
override fun onError(conn: WebSocket?, ex: Exception) {
Log.e(TAG, "WebSocket 服务端错误: ${ex.message}")
}
override fun onStart() {
Log.d(TAG, "WebSocket 服务端 onStart")
connectionLostTimeout = 60
}
}
}
@@ -0,0 +1,95 @@
package com.shuwei.dish.match.scale
import android.util.Log
import com.google.gson.Gson
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
/**
* UDP 广播接收器(主设备运行)
* 监听子设备发来的 UDP 广播包,解析后通过回调通知连接管理器
* 与 mDNS 互为备份,任意一条路发现子设备均可建立 WebSocket 连接
*/
class UdpBroadcastReceiver {
companion object {
private const val TAG = "UdpBroadcastReceiver"
private const val BUFFER_SIZE = 1024
}
private val gson = Gson()
private val executor = Executors.newSingleThreadExecutor()
/** 已发现的设备集合,避免重复触发回调,key=deviceId, value="host:port" */
private val knownDevices = ConcurrentHashMap<String, String>()
@Volatile
private var running = false
private var socket: DatagramSocket? = null
/**
* 发现新设备时的回调:(deviceId, host, port)
* 与 MdnsDiscoveryManager.onDeviceFound 签名一致,可共用同一处理逻辑
*/
var onDeviceFound: ((deviceId: String, host: String, port: Int) -> Unit)? = null
/**
* 开始监听 UDP 广播
*/
fun start() {
if (running) return
running = true
executor.execute { listenLoop() }
Log.d(TAG, "UDP 广播接收已启动, 监听端口=${UdpBroadcastSender.BROADCAST_PORT}")
}
private fun listenLoop() {
try {
socket = DatagramSocket(UdpBroadcastSender.BROADCAST_PORT).also { it.broadcast = true }
val buf = ByteArray(BUFFER_SIZE)
while (running) {
val packet = DatagramPacket(buf, buf.size)
socket?.receive(packet) ?: break
val json = String(packet.data, 0, packet.length, Charsets.UTF_8)
handlePacket(json)
}
} catch (e: Exception) {
if (running) Log.w(TAG, "UDP 接收异常: ${e.message}")
} finally {
socket?.close()
socket = null
}
}
private fun handlePacket(json: String) {
try {
val payload = gson.fromJson(json, UdpBroadcastSender.Payload::class.java)
val key = "${payload.ip}:${payload.port}"
// 同一设备已通知过则跳过,避免每5秒重复触发连接
if (knownDevices.put(payload.deviceId, key) == key) return
Log.d(TAG, "UDP 发现设备: ${payload.deviceId} -> ${payload.ip}:${payload.port}")
onDeviceFound?.invoke(payload.deviceId, payload.ip, payload.port)
} catch (e: Exception) {
Log.w(TAG, "解析 UDP 广播包失败: ${e.message}")
}
}
/**
* 移除已知设备记录,设备离线时调用,确保设备重新上线后能再次触发回调
*/
fun removeDevice(deviceId: String) {
knownDevices.remove(deviceId)
}
/**
* 停止监听,释放资源
*/
fun stop() {
running = false
socket?.close()
executor.shutdownNow()
Log.d(TAG, "UDP 广播接收已停止")
}
}
@@ -0,0 +1,76 @@
package com.shuwei.dish.match.scale
import android.util.Log
import com.google.gson.Gson
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetAddress
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledFuture
import java.util.concurrent.TimeUnit
/**
* UDP 广播发送器(子设备运行)
* 每隔固定间隔向局域网广播本机的 deviceId、IP、WebSocket 端口
* 作为 mDNS 的兜底发现机制,确保主设备能可靠发现子设备
*/
class UdpBroadcastSender(
private val deviceId: String,
private val localIp: String,
private val wsPort: Int = MdnsRegisterManager.WS_PORT
) {
companion object {
private const val TAG = "UdpBroadcastSender"
/** UDP 广播目标端口,与 UdpBroadcastReceiver 保持一致 */
const val BROADCAST_PORT = 8766
/** 广播间隔(秒) */
private const val INTERVAL_SEC = 5L
}
private val gson = Gson()
private val scheduler = Executors.newSingleThreadScheduledExecutor()
private var task: ScheduledFuture<*>? = null
/** 广播数据包结构 */
data class Payload(val deviceId: String, val ip: String, val port: Int)
/**
* 开始定时广播
*/
fun start() {
if (task != null) return
task = scheduler.scheduleWithFixedDelay({
sendBroadcast()
}, 0, INTERVAL_SEC, TimeUnit.SECONDS)
Log.d(TAG, "UDP 广播已启动, deviceId=$deviceId, ip=$localIp, port=$wsPort")
}
private fun sendBroadcast() {
try {
val payload = gson.toJson(Payload(deviceId, localIp, wsPort))
val data = payload.toByteArray(Charsets.UTF_8)
DatagramSocket().use { socket ->
socket.broadcast = true
val packet = DatagramPacket(
data, data.size,
InetAddress.getByName("255.255.255.255"),
BROADCAST_PORT
)
socket.send(packet)
}
} catch (e: Exception) {
Log.w(TAG, "UDP 广播发送失败: ${e.message}")
}
}
/**
* 停止广播,释放资源
*/
fun stop() {
task?.cancel(false)
task = null
scheduler.shutdownNow()
Log.d(TAG, "UDP 广播已停止")
}
}
@@ -0,0 +1,192 @@
package com.shuwei.dish.match.ui
import android.annotation.SuppressLint
import android.graphics.Color
import android.graphics.Typeface
import android.os.Bundle
import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan
import android.view.LayoutInflater
import android.view.inputmethod.EditorInfo
import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import com.shuwei.dish.match.R
import com.shuwei.dish.match.adapter.CollectedFoodAdapter
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.databinding.ActivityCollectedFoodBinding
import com.shuwei.dish.match.databinding.LayoutEmptyViewBinding
import com.shuwei.dish.match.dialog.CommonDialog
import com.shuwei.dish.match.entity.CollectedFoodItem
import com.shuwei.dish.match.utils.KeyboardUtil
import com.shuwei.dish.match.utils.ext.appendText
import com.shuwei.dish.match.utils.ext.buildSpannableString
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.toast
import com.shuwei.dish.match.utils.ext.visible
import kotlinx.coroutines.launch
/**
* 已采集食材列表页面
* 展示所有已采集的食材及其采集数量,支持搜索和删除
*/
class CollectedFoodActivity : BaseActivity() {
private lateinit var binding: ActivityCollectedFoodBinding
private val foodList = mutableListOf<CollectedFoodItem>()
private var emptyViewBinding: LayoutEmptyViewBinding? = null
private val adapter by lazy {
CollectedFoodAdapter(foodList).apply {
isStateViewEnable = true
// 点击删除按钮,移除对应食材
addOnItemChildClickListener(R.id.ivDelete) { _, _, position ->
showDeleteDialog(foodList[position].foodName)
}
setOnItemClickListener { _, view, _ ->
KeyboardUtil.hideKeyboard(view)
}
}
}
/**
* 删除弹窗
*/
private fun showDeleteDialog(foodName: String) {
val showFoodName = foodName.split("WP").first()
val separator = if (showFoodName.length <= 10) "\n" else ""
val content = buildSpannableString {
appendText("食材:")
appendText(showFoodName, StyleSpan(Typeface.BOLD), ForegroundColorSpan(Color.BLACK))
appendText(" 删除后无法恢复,${separator}确认要删除吗?")
}
CommonDialog(this)
.setTitle("删除提示")
.setContent(content)
.setNegativeButton("取消")
.setPositiveButton("确认") {
appViewModel.removeCollectedFood(
foodName = foodName,
nameFilter = binding.editSearch.text.toString()
)
}
.show()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
statusBarDarkFont(enable = true)
binding = ActivityCollectedFoodBinding.inflate(layoutInflater)
setContentView(binding.root)
setHeaderBackground()
binding.root.setOnClickListener { v ->
KeyboardUtil.hideKeyboard(v)
}
setTitleBar(titleBarAction = {
it.visible()
it.setOnClickListener { v ->
KeyboardUtil.hideKeyboard(v)
}
}, titleAction = {
it.text = "已采集食材"
}, rightIconActon = {
it.gone()
}, backAction = {
it.visible()
it.setOnClickListener {
finish()
}
})
initRecyclerView()
initSearch()
initObserver()
loadCollectedFoodList()
}
private fun loadCollectedFoodList(name: String = "") {
appViewModel.loadCollectedFoodList(nameFilter = name)
}
/**
* 收集 searchFoodState,统一处理 Loading / Success / Error 状态
*/
@SuppressLint("NotifyDataSetChanged")
private fun initObserver() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
appViewModel.collectedFoodListState.collect { items ->
foodList.clear()
foodList.addAll(items)
adapter.notifyDataSetChanged()
if (items.isEmpty()) {
loadEmptyView()
}
}
}
}
}
/** 展示空视图 */
private fun loadEmptyView() {
if (emptyViewBinding == null) {
emptyViewBinding = LayoutEmptyViewBinding.inflate(
LayoutInflater.from(this), binding.rvCollectedFood, false
)
}
emptyViewBinding!!.tvContent.text = "暂无数据"
emptyViewBinding!!.tvSubContent.text = "未查询到食材信息"
adapter.stateView = emptyViewBinding!!.root
}
/** 初始化列表 */
private fun initRecyclerView() {
binding.rvCollectedFood.run {
layoutManager = LinearLayoutManager(this@CollectedFoodActivity)
adapter = this@CollectedFoodActivity.adapter
}
}
/** 初始化搜索栏交互 */
private fun initSearch() {
binding.btnSearch.setOnClickListener { v ->
performSearch {
toast(binding.editSearch.hint.toString())
}
KeyboardUtil.hideKeyboard(v)
}
binding.editSearch.run {
addTextChangedListener {
performSearch {
loadCollectedFoodList()
}
}
setOnEditorActionListener { v, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_SEARCH) {
performSearch {
toast(binding.editSearch.hint.toString())
}
KeyboardUtil.hideKeyboard(v)
true
} else {
false
}
}
}
}
/** 根据关键词过滤食材列表 */
@SuppressLint("NotifyDataSetChanged")
private fun performSearch(action: () -> Unit) {
val keyword = binding.editSearch.text.toString().trim()
if (keyword.isEmpty()) {
action()
return
}
// 根据关键词从数据库查询并刷新列表
loadCollectedFoodList(keyword)
}
}
@@ -1,353 +1,345 @@
package com.shuwei.dish.match.ui //package com.shuwei.dish.match.ui
//
import android.graphics.Typeface //import android.graphics.Typeface
import android.os.Bundle //import android.os.Bundle
import android.text.SpannableStringBuilder //import android.text.SpannableStringBuilder
import android.text.style.AbsoluteSizeSpan //import android.text.style.AbsoluteSizeSpan
import android.text.style.ForegroundColorSpan //import android.text.style.ForegroundColorSpan
import android.text.style.LineHeightSpan //import android.text.style.LineHeightSpan
import android.text.style.StyleSpan //import android.text.style.StyleSpan
import android.util.Log //import android.util.Log
import android.util.SparseArray //import android.util.SparseArray
import android.widget.FrameLayout //import android.widget.FrameLayout
import androidx.core.graphics.toColorInt //import androidx.core.graphics.toColorInt
import androidx.core.view.forEach //import androidx.core.view.forEach
import androidx.lifecycle.ViewModelProvider //import androidx.lifecycle.ViewModelProvider
import com.shuwei.dish.match.base.BaseActivity //import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.base.BaseApp //import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.databinding.LayoutFoodRemindBinding //import com.shuwei.dish.match.db.DbRepository
import com.shuwei.dish.match.db.AppRepository //import com.shuwei.dish.match.dialog.CommonDialog
import com.shuwei.dish.match.dialog.CommonDialog //import com.shuwei.dish.match.entity.SeasoningEntity
import com.shuwei.dish.match.entity.SeasoningEntity //import com.shuwei.dish.match.entity.ResetReasoningRecord
import com.shuwei.dish.match.entity.ResetReasoningRecord //import com.shuwei.dish.match.utils.AddressUtil
import com.shuwei.dish.match.utils.AddressUtil //import com.shuwei.dish.match.utils.JsonAssetsLoader
import com.shuwei.dish.match.utils.JsonAssetsLoader //import com.shuwei.dish.match.utils.MultiClickDetector
import com.shuwei.dish.match.utils.MultiClickDetector //import com.shuwei.dish.match.utils.WeightUtil
import com.shuwei.dish.match.utils.WeightUtil //import com.shuwei.dish.match.utils.ext.appendText
import com.shuwei.dish.match.utils.ext.appendText //import com.shuwei.dish.match.utils.ext.buildSpannableString
import com.shuwei.dish.match.utils.ext.buildSpannableString //import com.shuwei.dish.match.utils.ext.dp
import com.shuwei.dish.match.utils.ext.dp //import com.shuwei.dish.match.utils.ext.startActivity
import com.shuwei.dish.match.utils.ext.startActivity //import com.shuwei.dish.match.utils.ext.toast
import com.shuwei.dish.match.utils.ext.toast //import com.shuwei.dish.match.utils.ext.visible
import com.shuwei.dish.match.utils.ext.visible //import com.shuwei.dish.match.adapter.TextCellAdapter
import com.shuwei.dish.match.viewmodel.AppViewModel //import com.shuwei.dish.match.databinding.ActivityDeviceConfigBinding
import com.shuwei.dish.match.viewmodel.factory.AppFactory //import com.shuwei.dish.match.dialog.SeasoningSearchDialog
import com.shuwei.dish.match.adapter.TextCellAdapter //import com.shuwei.dish.match.utils.SpTool
import com.shuwei.dish.match.databinding.ActivityDeviceConfigBinding //import com.shuwei.dish.match.utils.ext.clickWithDebounce
import com.shuwei.dish.match.dialog.SeasoningSearchDialog //import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.SpTool //import com.shuwei.dish.match.utils.ext.toJsonString
import com.shuwei.dish.match.utils.ext.clickWithDebounce //
import com.shuwei.dish.match.utils.ext.gone //class DeviceConfigActivity : BaseActivity() {
//
class DeviceConfigActivity : BaseActivity() { // companion object {
//
companion object { // const val TAG = "DeviceConfigActivity"
//
const val TAG = "DeviceConfigActivity" // const val COOK_MODE = "cookMode"
//
const val COOK_MODE = "cookMode" // val modeList = listOf<ModeBean>(
// ModeBean("制作模式", "菜品快速制作,记录熟重"),
val modeList = listOf<ModeBean>( // ModeBean("采样模式", "菜品快速制作,记录熟重"),
ModeBean("制作模式", "菜品快速制作,记录熟重"), // ModeBean("品控模式", "菜品快速制作,记录熟重")
ModeBean("采样模式", "菜品快速制作,记录熟重"), // )
ModeBean("品控模式", "菜品快速制作,记录熟重") // }
) //
} // data class ModeBean(
// var modeName: String,
data class ModeBean( // var modeDesc: String
var modeName: String, // )
var modeDesc: String //
) // private lateinit var binding: ActivityDeviceConfigBinding
//
private lateinit var binding: ActivityDeviceConfigBinding // private lateinit var appViewModel: DbViewModel
//
private lateinit var appViewModel: AppViewModel // private var cookMode: Int = 0
//// private val weightArray = SparseIntArray()
private var cookMode: Int = 0 // private val weightArray = SparseArray<Double>()
// private val weightArray = SparseIntArray() // private val addressArray = AddressUtil.getWeighAddressArray()
private val weightArray = SparseArray<Double>() //
private val addressArray = AddressUtil.getWeighAddressArray() // override fun onCreate(savedInstanceState: Bundle?) {
// super.onCreate(savedInstanceState)
override fun onCreate(savedInstanceState: Bundle?) { // binding = ActivityDeviceConfigBinding.inflate(layoutInflater)
super.onCreate(savedInstanceState) // setContentView(binding.root)
binding = ActivityDeviceConfigBinding.inflate(layoutInflater) // cookMode = intent.getIntExtra(COOK_MODE, 0)
setContentView(binding.root) // initViewModel()
cookMode = intent.getIntExtra(COOK_MODE, 0) // setHeaderBackground()
initViewModel() // setTitleBar(titleBarAction = {
setHeaderBackground() // it.visible()
setTitleBar(titleBarAction = { // }, titleAction = {
it.visible() // it.text = "设备配置"
}, titleAction = { // }, rightIconActon = {
it.text = "设备配置" // it.gone()
}, rightIconActon = { //// it.alpha = 0.0F
it.gone() //// it.setImageResource(R.drawable.ic_setting)
// it.alpha = 0.0F //// it.setOnClickListener {_->
// it.setImageResource(R.drawable.ic_setting) //// detector.setOnDelayedMultiClickListener(it) {
// it.setOnClickListener {_-> //// defaultDataSettingDialog()
// detector.setOnDelayedMultiClickListener(it) { //// }
// defaultDataSettingDialog() //// }
// } // })
//
// loadQualitySpan(isEnable = false)
// when (cookMode) {
// 1 -> {
// binding.rbModeCook.isChecked = true
// binding.rbModeSampling.isChecked = false
// loadCookSpan(isClicked = true)
// loadSamplingSpan()
// } // }
}) //
// 2 -> {
loadQualitySpan(isEnable = false) // binding.rbModeCook.isChecked = false
when (cookMode) { // binding.rbModeSampling.isChecked = true
1 -> { // loadCookSpan()
binding.rbModeCook.isChecked = true // loadSamplingSpan(isClicked = true)
binding.rbModeSampling.isChecked = false // }
loadCookSpan(isClicked = true) //
loadSamplingSpan() // else -> {
} // binding.rbModeCook.isChecked = true
// binding.rbModeSampling.isChecked = false
2 -> { // loadCookSpan(isClicked = true)
binding.rbModeCook.isChecked = false // loadSamplingSpan()
binding.rbModeSampling.isChecked = true // }
loadCookSpan() // }
loadSamplingSpan(isClicked = true) //
} // binding.rbModeCook.setOnClickListener {
// SpTool.put(SpTool.LAUNCH_PAGE_TYPE, 0)
else -> { // binding.rbModeCook.isChecked = true
binding.rbModeCook.isChecked = true // binding.rbModeSampling.isChecked = false
binding.rbModeSampling.isChecked = false // loadCookSpan(isClicked = true)
loadCookSpan(isClicked = true) // loadSamplingSpan()
loadSamplingSpan() // startActivity<SelectDishActivity> {
} // putExtra(SelectDishActivity.IS_CONFIG_PAGE, true)
} // }
// finish()
binding.rbModeCook.setOnClickListener { // }
SpTool.put(SpTool.LAUNCH_PAGE_TYPE, 0) // binding.rbModeSampling.setOnClickListener {
binding.rbModeCook.isChecked = true // SpTool.put(SpTool.LAUNCH_PAGE_TYPE, 1)
binding.rbModeSampling.isChecked = false // binding.rbModeCook.isChecked = false
loadCookSpan(isClicked = true) // binding.rbModeSampling.isChecked = true
loadSamplingSpan() // loadCookSpan()
startActivity<SelectDishActivity> { // loadSamplingSpan(isClicked = true)
putExtra(SelectDishActivity.IS_CONFIG_PAGE, true) // startActivity<SamplingListActivity> {
} // putExtra(SamplingListActivity.IS_CONFIG_PAGE, true) }
finish() // finish()
} // }
binding.rbModeSampling.setOnClickListener { // addWeighListener()
SpTool.put(SpTool.LAUNCH_PAGE_TYPE, 1) // addGridItemListener()
binding.rbModeCook.isChecked = false // loadSeasoning()
binding.rbModeSampling.isChecked = true // }
loadCookSpan() //
loadSamplingSpan(isClicked = true) // private fun addWeighListener() {
startActivity<SamplingListActivity> { // WeightUtil.addWeightListener(
putExtra(SamplingListActivity.IS_CONFIG_PAGE, true) } // weightKey = TAG,
finish() // getWeight = { address, state, weight ->
} // Log.d(TAG, "addWeighListener: address=$address,stat=$state,weight=$weight")
addWeighListener() // weightArray.put(address, weight)
addGridItemListener() // val item = seasoningItems.firstOrNull { address == addressArray[it.sort] }
loadSeasoning() // item?.let {
} // it.useWeight = weight.toDouble()
// updateGridData(it)
private fun addWeighListener() { // }
WeightUtil.addWeightListener( // Log.d(TAG, "addWeighListener: update-----------------------")
weightKey = TAG, // })
getWeight = { address, state, weight -> // }
Log.d(TAG, "addWeighListener: address=$address,stat=$state,weight=$weight") //
weightArray.put(address, weight) // private var seasoningItems = mutableListOf<SeasoningEntity>()
val item = seasoningItems.firstOrNull { address == addressArray[it.sort] } // private fun addGridItemListener() {
item?.let { // binding.include.root.forEach { child ->
it.useWeight = weight.toDouble() // child.clickWithDebounce {
updateGridData(it) // val sort = child.tag.toString().toInt()
} // val entity = seasoningItems.firstOrNull { it.sort == sort } ?: SeasoningEntity()
Log.d(TAG, "addWeighListener: update-----------------------") // clickGridItem(sort, entity)
}) // }
} // }
// }
private var seasoningItems = mutableListOf<SeasoningEntity>() //
private fun addGridItemListener() { // val detector = MultiClickDetector(targetCount = 10, intervalMs = 800)
binding.include.root.forEach { child -> //
child.clickWithDebounce { // private fun defaultDataSettingDialog() {
val sort = child.tag.toString().toInt() // CommonDialog(this)
val entity = seasoningItems.firstOrNull { it.sort == sort } ?: SeasoningEntity() // .setTitle("温馨提示")
clickGridItem(sort, entity) // .setContent("您好,使用默认配置作为您的调料数据,则已有配置将被清除,确认吗?")
} // .setNegativeButton("取消")
} // .setPositiveButton("确认") { resetList() }
} // .setOnDismissCallback { hideStatusBar() }
// .show()
val detector = MultiClickDetector(targetCount = 10, intervalMs = 800) // }
//
private fun defaultDataSettingDialog() { // private fun resetList() {
val remindBinding = LayoutFoodRemindBinding.inflate(layoutInflater) // runCatching {
remindBinding.tvDialogTitle.text = "温馨提示" // val record = JsonAssetsLoader.parseJsonFromAssets<ResetReasoningRecord>(
remindBinding.tvDialogContent.text = // this,
"您好,使用默认配置作为您的调料数据,则已有配置将被清除,确认吗?" // "default_seasoning_list.json"
CommonDialog(this).apply { // )
messageView = remindBinding.root //// val weightArray = WeightUtil.weightArray
leftText = "取消" // appViewModel.clearAllSeasoning {
rightText = "确认" // record?.list?.forEach { entity ->
onLeftClick = { dismiss() } // entity.useWeight = weightArray[addressArray[entity.sort]].toDouble()
onRightClick = { // appViewModel.saveSeasoning(entity) {
dismiss() // updateGridData(entity)
resetList() // }
} // }
onDismiss = { hideStatusBar() } //// loadSeasoning()
}.show() // }
} // }.onFailure { it.printStackTrace() }
// }
private fun resetList() { //
runCatching { // private fun initViewModel() {
val record = JsonAssetsLoader.parseJsonFromAssets<ResetReasoningRecord>( // val db = BaseApp.instance!!.database
this, // val factory =
"default_seasoning_list.json" // AppFactory(DbRepository(db.appDao()))
) // appViewModel =
// val weightArray = WeightUtil.weightArray // ViewModelProvider(this, factory)[DbViewModel::class.java]
appViewModel.clearAllSeasoning { // }
record?.list?.forEach { entity -> //
entity.useWeight = weightArray[addressArray[entity.sort]].toDouble() // private fun loadSeasoning() {
appViewModel.saveSeasoning(entity) { // appViewModel.loadSeasoning {
updateGridData(entity) // seasoningItems.clear()
} // seasoningItems.addAll(it)
} // initConfigData()
// loadSeasoning() // setGridData(seasoningItems)
} // }
}.onFailure { it.printStackTrace() } // }
} //
// private fun initConfigData() {
private fun initViewModel() { // repeat(12) { num ->
val db = BaseApp.instance!!.database // val firstOne = seasoningItems.firstOrNull { it.sort == num }
val factory = // if (firstOne == null) {
AppFactory(AppRepository(db.appDao())) // seasoningItems.add(SeasoningEntity().also { it.sort = num })
appViewModel = // }
ViewModelProvider(this, factory)[AppViewModel::class.java] // }
} // }
//
private fun loadSeasoning() { // private fun setGridData(list: MutableList<SeasoningEntity>) {
appViewModel.loadSeasoning { // Log.d(TAG, "setGridData: ${list.toJsonString()}")
seasoningItems.clear() // val gridLayout = binding.include.root
seasoningItems.addAll(it) // list.forEach { entity ->
initConfigData() // val tag = entity.sort.toString()
setGridData(seasoningItems) // val frameLayout = gridLayout.findViewWithTag<FrameLayout>(tag)
} // TextCellAdapter.loadLayout(frameLayout, entity)
} // }
// }
private fun initConfigData() { //
repeat(12) { num -> // private fun updateGridData(entity: SeasoningEntity) {
val firstOne = seasoningItems.firstOrNull { it.sort == num } // val gridLayout = binding.include.root
if (firstOne == null) { // val tag = entity.sort.toString()
seasoningItems.add(SeasoningEntity().also { it.sort = num }) // val frameLayout = gridLayout.findViewWithTag<FrameLayout>(tag)
} // TextCellAdapter.loadLayout(frameLayout, entity)
} // }
} //
// private fun clickGridItem(sort: Int, entity: SeasoningEntity) {
private fun setGridData(list: MutableList<SeasoningEntity>) { // val currentAddress = AddressUtil.getWeighAddressArray().get(sort)
val gridLayout = binding.include.root //// val seasoningEntity = list.first{it.sort == sort}
list.forEach { entity -> // SeasoningSearchDialog(
val tag = entity.sort.toString() // activity = this,
val frameLayout = gridLayout.findViewWithTag<FrameLayout>(tag) // weighIndex = sort,
TextCellAdapter.loadLayout(frameLayout, entity) // weighAddress = currentAddress,
} // clickName = entity.goodsName
} // ) { item ->
// syncItem(entity, item)
private fun updateGridData(entity: SeasoningEntity) { // //3-调料
val gridLayout = binding.include.root // entity.materialType = 3
val tag = entity.sort.toString() // entity.sort = sort
val frameLayout = gridLayout.findViewWithTag<FrameLayout>(tag) // saveSeasoning(entity) {
TextCellAdapter.loadLayout(frameLayout, entity) // val tag = sort.toString()
} // val gridLayout = binding.include.root
// val frameLayout = gridLayout.findViewWithTag<FrameLayout>(tag)
private fun clickGridItem(sort: Int, entity: SeasoningEntity) { // TextCellAdapter.loadLayout(frameLayout, entity)
val currentAddress = AddressUtil.getWeighAddressArray().get(sort) // }
// val seasoningEntity = list.first{it.sort == sort} // }.show()
SeasoningSearchDialog( // }
weighIndex = sort, //
weighAddress = currentAddress, // private fun syncItem(oldItem: SeasoningEntity, newItem: SeasoningEntity) {
clickName = entity.goodsName // oldItem.run {
).show(this) { item -> // goodsId = newItem.goodsId
syncItem(entity, item) // goodsName = newItem.goodsName
//3-调料 // goodsOrRelationCode = newItem.goodsOrRelationCode
entity.materialType = 3 // relateionType = newItem.relateionType
entity.sort = sort // materialType = newItem.materialType
saveSeasoning(entity) { // allEdible = newItem.allEdible
val tag = sort.toString() // useWeight = newItem.useWeight
val gridLayout = binding.include.root // popularName = newItem.popularName
val frameLayout = gridLayout.findViewWithTag<FrameLayout>(tag) // canteenId = newItem.canteenId
TextCellAdapter.loadLayout(frameLayout, entity) // relateionType_dictText = newItem.relateionType_dictText
} //
} // foodId = newItem.foodId
} // sort = newItem.sort
// }
private fun syncItem(oldItem: SeasoningEntity, newItem: SeasoningEntity) { // }
oldItem.run { //
goodsId = newItem.goodsId // private fun loadQualitySpan(isClicked: Boolean = false, isEnable: Boolean = true) {
goodsName = newItem.goodsName // binding.rbModeQuality.text =
goodsOrRelationCode = newItem.goodsOrRelationCode // getModeTextSpan(
relateionType = newItem.relateionType // topText = modeList[2].modeName,
materialType = newItem.materialType // bottomText = modeList[2].modeDesc,
allEdible = newItem.allEdible // topColor = if (isEnable) if (isClicked) "#00BC71" else "#000000" else "#B4B4B4",
useWeight = newItem.useWeight // bottomColor = if (isEnable) "#8B8B8B" else "#B4B4B4"
popularName = newItem.popularName // )
canteenId = newItem.canteenId // }
relateionType_dictText = newItem.relateionType_dictText //
// private fun loadSamplingSpan(isClicked: Boolean = false, isEnable: Boolean = true) {
foodId = newItem.foodId // binding.rbModeSampling.text =
sort = newItem.sort // getModeTextSpan(
} // topText = modeList[1].modeName,
} // bottomText = modeList[1].modeDesc,
// topColor = if (isEnable) if (isClicked) "#00BC71" else "#000000" else "#B4B4B4",
private fun loadQualitySpan(isClicked: Boolean = false, isEnable: Boolean = true) { // bottomColor = if (isEnable) "#8B8B8B" else "#B4B4B4"
binding.rbModeQuality.text = // )
getModeTextSpan( // }
topText = modeList[2].modeName, //
bottomText = modeList[2].modeDesc, // private fun loadCookSpan(isClicked: Boolean = false, isEnable: Boolean = true) {
topColor = if (isEnable) if (isClicked) "#00BC71" else "#000000" else "#B4B4B4", // binding.rbModeCook.text =
bottomColor = if (isEnable) "#8B8B8B" else "#B4B4B4" // getModeTextSpan(
) // topText = modeList[0].modeName,
} // bottomText = modeList[0].modeDesc,
// topColor = if (isEnable) if (isClicked) "#00BC71" else "#000000" else "#B4B4B4",
private fun loadSamplingSpan(isClicked: Boolean = false, isEnable: Boolean = true) { // bottomColor = if (isEnable) "#8B8B8B" else "#B4B4B4"
binding.rbModeSampling.text = // )
getModeTextSpan( // }
topText = modeList[1].modeName, //
bottomText = modeList[1].modeDesc, // private fun getModeTextSpan(
topColor = if (isEnable) if (isClicked) "#00BC71" else "#000000" else "#B4B4B4", // topText: String,
bottomColor = if (isEnable) "#8B8B8B" else "#B4B4B4" // topColor: String,
) // bottomText: String,
} // bottomColor: String
// ): SpannableStringBuilder {
private fun loadCookSpan(isClicked: Boolean = false, isEnable: Boolean = true) { //
binding.rbModeCook.text = //
getModeTextSpan( // return buildSpannableString {
topText = modeList[0].modeName, // appendText(
bottomText = modeList[0].modeDesc, // topText,
topColor = if (isEnable) if (isClicked) "#00BC71" else "#000000" else "#B4B4B4", // ForegroundColorSpan(topColor.toColorInt()),
bottomColor = if (isEnable) "#8B8B8B" else "#B4B4B4" // StyleSpan(Typeface.BOLD),
) // AbsoluteSizeSpan(36, true),
} // LineHeightSpan { text, start, end, spanstartv, v, fm ->
// fm.descent += 10.dp // 增加行间距
private fun getModeTextSpan( // }
topText: String, // )
topColor: String, // append("\n")
bottomText: String, // appendText(
bottomColor: String // bottomText,
): SpannableStringBuilder { // ForegroundColorSpan(bottomColor.toColorInt()),
// AbsoluteSizeSpan(26, true)
// )
return buildSpannableString { // }
appendText( // }
topText, //
ForegroundColorSpan(topColor.toColorInt()), // fun saveSeasoning(entity: SeasoningEntity, action: () -> Unit = {}) {
StyleSpan(Typeface.BOLD), // appViewModel.saveSeasoning(entity) {
AbsoluteSizeSpan(36, true), // action()
LineHeightSpan { text, start, end, spanstartv, v, fm -> // toast("保存成功")
fm.descent += 10.dp // 增加行间距 // }
} // }
) //
append("\n") //}
appendText(
bottomText,
ForegroundColorSpan(bottomColor.toColorInt()),
AbsoluteSizeSpan(26, true)
)
}
}
fun saveSeasoning(entity: SeasoningEntity, action: () -> Unit = {}) {
appViewModel.saveSeasoning(entity) {
action()
toast("保存成功")
}
}
}
@@ -5,8 +5,8 @@ import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.View import android.view.View
import androidx.activity.addCallback
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.shuwei.dish.match.R import com.shuwei.dish.match.R
@@ -14,8 +14,6 @@ import com.shuwei.dish.match.adapter.DishPartAdapter
import com.shuwei.dish.match.base.BaseActivity import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.base.BaseApp import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.databinding.ActivityDishSamplingBinding import com.shuwei.dish.match.databinding.ActivityDishSamplingBinding
import com.shuwei.dish.match.databinding.LayoutFoodRemindBinding
import com.shuwei.dish.match.db.AppRepository
import com.shuwei.dish.match.dialog.FoodSearchDialog import com.shuwei.dish.match.dialog.FoodSearchDialog
import com.shuwei.dish.match.dialog.CommonDialog import com.shuwei.dish.match.dialog.CommonDialog
import com.shuwei.dish.match.entity.CookFoodEntity import com.shuwei.dish.match.entity.CookFoodEntity
@@ -33,8 +31,6 @@ import com.shuwei.dish.match.utils.ext.startActivity
import com.shuwei.dish.match.utils.ext.toJsonString import com.shuwei.dish.match.utils.ext.toJsonString
import com.shuwei.dish.match.utils.ext.toast import com.shuwei.dish.match.utils.ext.toast
import com.shuwei.dish.match.utils.ext.visible import com.shuwei.dish.match.utils.ext.visible
import com.shuwei.dish.match.viewmodel.AppViewModel
import com.shuwei.dish.match.viewmodel.factory.AppFactory
import java.io.Serializable import java.io.Serializable
class DishSamplingActivity : BaseActivity() { class DishSamplingActivity : BaseActivity() {
@@ -62,7 +58,6 @@ class DishSamplingActivity : BaseActivity() {
setTitleBar() setTitleBar()
setHeaderBackground() setHeaderBackground()
addViewClickListener() addViewClickListener()
initViewModel()
foodName = intent.getStringExtra(FOOD_NAME) foodName = intent.getStringExtra(FOOD_NAME)
binding.etInputDish.setText(foodName) binding.etInputDish.setText(foodName)
@@ -75,6 +70,7 @@ class DishSamplingActivity : BaseActivity() {
binding.tvDishPartWeight.text = "${weight}" binding.tvDishPartWeight.text = "${weight}"
} }
}) })
addBackKeyListener()
} }
private fun setTitleBar() { private fun setTitleBar() {
@@ -91,58 +87,47 @@ class DishSamplingActivity : BaseActivity() {
}, backAction = { }, backAction = {
it.visible() it.visible()
it.setOnClickListener { it.setOnClickListener {
onBackPressed() onBackPressedDispatcher.onBackPressed()
} }
}) })
} }
private lateinit var appViewModel: AppViewModel
private fun initViewModel() {
val db = BaseApp.instance!!.database fun getCookFoodList() {
val factory = appViewModel.getCookFoodList(cookMode = 1)
AppFactory(AppRepository(db.appDao()))
appViewModel =
ViewModelProvider(this, factory)[AppViewModel::class.java]
} }
fun getCookFoodList(action: (MutableList<CookFoodEntity>?) -> Unit) { /**
appViewModel.getCookFoodList(cookMode = 1) { action(it) } * 监听返回键
} */
fun addBackKeyListener() {
@Suppress("DEPRECATION") onBackPressedDispatcher.addCallback(this) {
@SuppressLint("GestureBackNavigation") if (list.isEmpty().not()) {
@Deprecated("Deprecated in Java") //说明有新增的食材
override fun onBackPressed() { saveDataRemindDialog()
if (list.isEmpty().not()) { return@addCallback
//说明有新增的食材 }
saveDataRemindDialog() if (pageFrom == HOME) {
return startActivity<SamplingListActivity>()
}
onBackPressedDispatcher.onBackPressed()
} }
if (pageFrom == HOME) {
startActivity<SamplingListActivity>()
}
super.onBackPressed()
} }
private fun saveDataRemindDialog() { private fun saveDataRemindDialog() {
val remindBinding = LayoutFoodRemindBinding.inflate(layoutInflater) CommonDialog(this)
remindBinding.tvDialogTitle.text = "温馨提示" .setTitle("返回提示")
remindBinding.tvDialogContent.text = "您好,当前页面存在未保存的数据,确认返回吗?" .setContent("您好,当前页面存在未保存的数据,\n确认返回吗?")
CommonDialog(this).apply { .setNegativeButton("取消")
messageView = remindBinding.root .setPositiveButton("确认") {
leftText = "取消"
rightText = "确认"
onLeftClick = { dismiss() }
onRightClick = {
dismiss()
if (pageFrom == HOME) { if (pageFrom == HOME) {
startActivity<SamplingListActivity>() startActivity<SamplingListActivity>()
} }
finish() finish()
} }
onDismiss = { hideStatusBar() } .setOnDismissCallback { hideStatusBar() }
}.show() .show()
} }
@@ -238,8 +223,8 @@ class DishSamplingActivity : BaseActivity() {
} }
val food = FoodRecord() val food = FoodRecord()
binding.etInputDish.let { binding.etInputDish.let {
if (it.tag!=null && it.tag.toString() == it.text.toString()) { if (it.tag != null && it.tag.toString() == it.text.toString()) {
food.foodId = foodId food.foodId = foodId
} }
} }
food.foodName = showFoodName food.foodName = showFoodName
@@ -259,29 +244,31 @@ class DishSamplingActivity : BaseActivity() {
} }
private fun jumpSearch(v: View) { private fun jumpSearch(v: View) {
launch(Intent(this, FoodSearchActivity::class.java).apply { val intent = Intent(this, FoodSearchActivity::class.java).apply {
putExtra(FoodSearchActivity.FOOD_NAME, binding.etInputDish.text.toString().trim()) putExtra(FoodSearchActivity.FOOD_NAME, binding.etInputDish.text.toString().trim())
}) { intent -> }
intent?.let { startActivity(intent) {
foodId = it.getStringExtra(FoodSearchActivity.FOOD_ID) foodId = it?.getStringExtra(FoodSearchActivity.FOOD_ID)
foodName = it.getStringExtra(FoodSearchActivity.FOOD_NAME) foodName = it?.getStringExtra(FoodSearchActivity.FOOD_NAME)
binding.etInputDish.run { binding.etInputDish.run {
setText(foodName) setText(foodName)
tag = foodName tag = foodName
setSelection(text.length) setSelection(text.length)
}
} }
} }
KeyboardUtil.hideKeyboard(v.context, v) KeyboardUtil.hideKeyboard(v)
} }
private fun searchDishType() { private fun searchDishType() {
val inputText = binding.etInputDishType.text.toString().trim() val inputText = binding.etInputDishType.text.toString().trim()
FoodSearchDialog(defGoodsName = inputText).show(this@DishSamplingActivity) { item -> FoodSearchDialog(
activity = this@DishSamplingActivity,
defGoodsName = inputText
) { item ->
val filterResult = list.firstOrNull { it.goodsName == item.goodsName } val filterResult = list.firstOrNull { it.goodsName == item.goodsName }
if (filterResult != null) { if (filterResult != null) {
toast("不允许重复添加同一食材") toast("不允许重复添加同一食材")
return@show return@FoodSearchDialog
} }
binding.etInputDishType.run { binding.etInputDishType.run {
setText(item.goodsName) setText(item.goodsName)
@@ -298,7 +285,7 @@ class DishSamplingActivity : BaseActivity() {
// TODO: 待定 // TODO: 待定
//allEdible = item.allEdible //allEdible = item.allEdible
} }
} }.show()
} }
} }
@@ -0,0 +1,353 @@
package com.shuwei.dish.match.ui
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import androidx.activity.addCallback
import androidx.core.content.IntentCompat
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.shuwei.dish.match.adapter.GoodsInfoSearchAdapter
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.databinding.ActivityFoodRecognizeBinding
import com.shuwei.dish.match.dialog.FoodSearchDialog
import com.shuwei.dish.match.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.utils.ActivityManager
import com.shuwei.dish.match.utils.WeightUtil
import com.shuwei.dish.match.utils.ext.clickWithDebounce
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.visible
import androidx.core.net.toUri
/**
* 食材识别结果页面
* 展示识别到的食材照片、食材列表、选中食材名称及秤1重量
*/
class FoodRecognizeActivity : BaseActivity() {
companion object {
private const val TAG = "FoodRecognizeActivity"
private const val EXTRA_IMAGE_URI = "extra_image_uri"
private const val EXTRA_CURRENT_WEIGHT = "extra_current_weight"
const val EXTRA_SELECTED_ITEM = "extra_selected_item"
const val IS_MANUAL_CANCEL = "isManualCancel"
/** 内存缓存食材列表,避免通过 Intent 序列化传递大数据 */
private var pendingGoodsList: ArrayList<CookFoodGoodsEntity>? = null
/**
* 启动本页面,通过 onResult 回调返回用户选中的食材
* @param activity 宿主 Activity
* @param imageUri 食材照片路径(可为空)
* @param goodsList 识别到的食材列表
* @param finishCallback 页面关闭后回调,携带用户选中的食材(未选中时为 null)
*/
fun start(
activity: BaseActivity,
imageUri: String? = null,
currentWeight: Double = 0.0,
goodsList: ArrayList<CookFoodGoodsEntity> = arrayListOf(),
finishCallback: (Boolean, CookFoodGoodsEntity?) -> Unit = { _, _ -> }
) {
pendingGoodsList = goodsList
val launchIntent = Intent(activity, FoodRecognizeActivity::class.java).apply {
putExtra(EXTRA_IMAGE_URI, imageUri)
putExtra(EXTRA_CURRENT_WEIGHT, currentWeight)
}
activity.startActivity(launchIntent) { resultIntent ->
val isManualCancel = resultIntent?.getBooleanExtra(IS_MANUAL_CANCEL, false) ?: false
val entity = resultIntent?.let {
IntentCompat.getSerializableExtra(
it,
EXTRA_SELECTED_ITEM,
CookFoodGoodsEntity::class.java)
}
finishCallback(isManualCancel, entity)
}
}
/**
* 关闭当前识别页面,并将选中食材通过 Activity Result 回传
* 由外部(如重量归零时)调用
*/
fun close() {
ActivityManager.getActivityStack()
.filterIsInstance<FoodRecognizeActivity>()
.lastOrNull()
?.finishWithResult(false)
}
}
private lateinit var binding: ActivityFoodRecognizeBinding
private val list = mutableListOf<CookFoodGoodsEntity>()
/** 当前选中的列表位置,-1 表示无选中 */
private var selectedPosition = -1
/** 秤1当前实时重量 */
private var currentWeight = 0.0
/** 重量减少时延迟更新的任务,页面关闭前取消可防止用错误重量 */
private var pendingWeightRunnable: Runnable? = null
/** 当前待延迟更新的目标重量,避免相同重量重复重置计时器 */
private var pendingWeight = 0.0
/** 右侧自定义滚动指示条最小高度 */
private val scrollIndicatorMinHeightPx by lazy { (24 * resources.displayMetrics.density).toInt() }
private val scrollListener = object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
updateScrollIndicator()
}
}
private val adapter = GoodsInfoSearchAdapter(list).apply {
setOnItemClickListener { _, _, position ->
list.forEachIndexed { i, item -> item.isClicked = (i == position) }
// 点击选择时记录当前秤重量
list[position].useWeight = currentWeight
notifyDataSetChanged()
selectedPosition = position
binding.tvSelectedFood.text = list[position].goodsName ?: "-"
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityFoodRecognizeBinding.inflate(layoutInflater)
setContentView(binding.root)
registerWeightListener()
setHeaderBackground()
// 显示标题栏
setTitleBar(
titleBarAction = { it.visible() },
titleAction = { it.text = "食材识别" },
backAction = {
it.setOnClickListener {
manualBack()
}
}
)
initData()
initRecyclerView()
initClickListeners()
onBackPressedDispatcher.addCallback(this) {
manualBack()
}
}
/**
* 手动返回
*/
private fun manualBack() {
finishWithResult(true)
}
/**
* 从 Intent 读取数据并初始化页面
*/
private fun initData() {
// 加载食材照片
val imageUri = intent.getStringExtra(EXTRA_IMAGE_URI)
if (!imageUri.isNullOrBlank()) {
binding.ivFoodPhoto.setImageURI(imageUri.toUri())
}
currentWeight = intent.getDoubleExtra(EXTRA_CURRENT_WEIGHT, 0.0)
binding.tvWeight.text = "${currentWeight}g"
// 取出内存缓存的食材列表,取用后立即清空
val goodsList = pendingGoodsList?.also { pendingGoodsList = null }
if (!goodsList.isNullOrEmpty()) {
list.addAll(goodsList)
// 默认选中第一项
selectedPosition = 0
list[0].isClicked = true
list[0].useWeight = currentWeight
binding.tvSelectedFood.text = list[0].goodsName ?: "-"
}
}
/**
* 初始化 RecyclerView
*/
private fun initRecyclerView() {
binding.recyclerView.let {
it.layoutManager = GridLayoutManager(this@FoodRecognizeActivity, 2)
it.adapter = adapter
it.isVerticalScrollBarEnabled = false
it.removeOnScrollListener(scrollListener)
it.addOnScrollListener(scrollListener)
it.post { updateScrollIndicator() }
}
updateListVisibility()
}
/**
* 根据列表是否有数据控制 RecyclerView 显隐
*/
private fun updateListVisibility() {
if (list.isEmpty()) {
binding.flListContainer.gone()
} else {
binding.flListContainer.visible()
binding.recyclerView.post { updateScrollIndicator() }
}
}
/**
* 根据 RecyclerView 滚动状态刷新右侧自定义指示条
*/
private fun updateScrollIndicator() {
val recyclerView = binding.recyclerView
val track = binding.vScrollIndicatorTrack
val thumb = binding.vScrollIndicatorThumb
if (!recyclerView.canScrollVertically(1) && !recyclerView.canScrollVertically(-1)) {
track.gone()
thumb.gone()
return
}
val range = recyclerView.computeVerticalScrollRange()
val extent = recyclerView.computeVerticalScrollExtent()
val offset = recyclerView.computeVerticalScrollOffset()
if (range <= 0 || extent <= 0 || range <= extent) {
track.gone()
thumb.gone()
return
}
val trackHeight = track.height.takeIf { it > 0 } ?: recyclerView.height
if (trackHeight <= 0) return
val thumbHeight = ((extent.toFloat() / range) * trackHeight)
.toInt()
.coerceAtLeast(scrollIndicatorMinHeightPx)
.coerceAtMost(trackHeight)
val maxTop = (trackHeight - thumbHeight).coerceAtLeast(0)
val scrollable = (range - extent).coerceAtLeast(1)
val thumbTop = ((offset.toFloat() / scrollable) * maxTop).toInt().coerceIn(0, maxTop)
if (thumb.layoutParams.height != thumbHeight) {
thumb.layoutParams = thumb.layoutParams.apply { height = thumbHeight }
}
thumb.translationY = thumbTop.toFloat()
track.visible()
thumb.visible()
}
/**
* 初始化按钮点击事件
*/
private fun initClickListeners() {
// 更换食材:打开食材搜索弹窗
binding.btnChangeFood.clickWithDebounce {
val currentName = list.getOrNull(selectedPosition)?.goodsName
FoodSearchDialog(
activity = this,
defGoodsName = currentName,
onItemSelected = { item -> updateSelectedFood(item) }
).show()
}
// 取消:关闭页面
binding.btnCancel.setOnClickListener { manualBack() }
}
/**
* 用新食材替换当前选中项(或追加到列表首位)
*/
@SuppressLint("NotifyDataSetChanged")
private fun updateSelectedFood(item: CookFoodGoodsEntity) {
val existIndex = list.indexOfFirst { it.goodsId == item.goodsId }
if (existIndex >= 0) {
list.forEachIndexed { i, e -> e.isClicked = (i == existIndex) }
selectedPosition = existIndex
} else {
list.add(0, item)
list.forEachIndexed { i, e -> e.isClicked = (i == 0) }
selectedPosition = 0
}
adapter.notifyDataSetChanged()
binding.tvSelectedFood.text = item.goodsName ?: "-"
updateListVisibility()
binding.recyclerView.post { updateScrollIndicator() }
}
/**
* 注册秤1重量监听
* 只有重量高于识别阈值时才更新选中食材的 useWeight,
* 低于阈值说明食材已被取走,保留上次有效值等待页面关闭
*/
private fun registerWeightListener() {
// WeightUtil.addWeightListener(TAG) { address, state, weight ->
// if (address == AddressUtil.ONE && state == WeightUtil.STATE_STABLE) {
// runOnUiThread {
// currentWeight = weight
// binding.tvWeight.run {
// if (tag != weight) {
// text = "$weight g"
// tag = weight
// }
// }
// if (weight >= PrepareCookActivity.WEIGHT_RECOGNIZE_VALUE) {
// adapter.items.firstOrNull { it.isClicked }?.let { item ->
// if (pendingWeight != weight) {
// // 无论增减,均延迟更新,避免手拿物品时的瞬时增重干扰
// cancelPendingWeightUpdate()
// pendingWeight = weight
// pendingWeightRunnable = Runnable {
// item.useWeight = weight
// pendingWeightRunnable = null
// }.also { handler.postDelayed(it, 3000) }
// }
// }
// }
// }
// }
// }
}
/**
* 取消待执行的延迟重量更新任务
*/
private fun cancelPendingWeightUpdate() {
// pendingWeightRunnable?.let { handler.removeCallbacks(it) }
// pendingWeightRunnable = null
}
/**
* 携带当前选中食材关闭页面
*/
private fun finishWithResult(isManualCancel: Boolean) {
cancelPendingWeightUpdate()
val intent = Intent().apply {
putExtra(IS_MANUAL_CANCEL, isManualCancel)
if (isManualCancel.not()) {
list.getOrNull(selectedPosition)?.let {
// 兜底:若 useWeight 仍为空或0,使用当前实时重量
if ((it.useWeight ?: 0.0) <= 0.0) {
it.useWeight = currentWeight
}
putExtra(EXTRA_SELECTED_ITEM, it)
}
}
}
setResult(RESULT_OK, intent)
finish()
}
override fun onDestroy() {
super.onDestroy()
cancelPendingWeightUpdate()
WeightUtil.removeWeightListener(TAG)
}
}
@@ -4,6 +4,9 @@ import android.annotation.SuppressLint
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.shuwei.dish.match.R import com.shuwei.dish.match.R
import com.shuwei.dish.match.adapter.FoodRecordAdapter import com.shuwei.dish.match.adapter.FoodRecordAdapter
@@ -12,6 +15,7 @@ import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.databinding.ActivityFoodSearchBinding import com.shuwei.dish.match.databinding.ActivityFoodSearchBinding
import com.shuwei.dish.match.databinding.LayoutEmptyViewBinding import com.shuwei.dish.match.databinding.LayoutEmptyViewBinding
import com.shuwei.dish.match.entity.FoodRecord 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.KeyboardUtil
import com.shuwei.dish.match.utils.ext.addOnActionSearchListener import com.shuwei.dish.match.utils.ext.addOnActionSearchListener
import com.shuwei.dish.match.utils.ext.gone import com.shuwei.dish.match.utils.ext.gone
@@ -19,6 +23,7 @@ import com.shuwei.dish.match.utils.ext.startActivity
import com.shuwei.dish.match.utils.ext.toast import com.shuwei.dish.match.utils.ext.toast
import com.shuwei.dish.match.utils.ext.visible import com.shuwei.dish.match.utils.ext.visible
import java.io.Serializable import java.io.Serializable
import kotlinx.coroutines.launch
class FoodSearchActivity : BaseActivity() { class FoodSearchActivity : BaseActivity() {
@@ -83,13 +88,38 @@ class FoodSearchActivity : BaseActivity() {
adapter = recordAdapter adapter = recordAdapter
} }
addViewListener() addViewListener()
initObserver()
}
/**
* 收集 searchFoodState,统一处理 Loading / Success / Error 状态
*/
private fun initObserver() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
netViewModel.searchFoodState.collect { state ->
when (state) {
is UiState.Loading -> showLoading()
is UiState.Success -> handleSearchResult(state.data)
is UiState.Error -> {
delayDismissLoading()
finishRefresh()
binding.refreshLayout.setEnableRefresh(true)
toast(state.msg)
if (pageNo == 1) loadEmptyView()
}
is UiState.Idle -> {}
}
}
}
}
} }
private fun addViewListener() { private fun addViewListener() {
binding.ivDishSearch.setOnClickListener { v -> binding.ivDishSearch.setOnClickListener { v ->
pageNo = 1 pageNo = 1
getInputAndSearch() getInputAndSearch()
KeyboardUtil.hideKeyboard(v.context, v) KeyboardUtil.hideKeyboard(v)
} }
binding.refreshLayout.setEnableRefresh(true) binding.refreshLayout.setEnableRefresh(true)
binding.refreshLayout.setEnableLoadMore(false) binding.refreshLayout.setEnableLoadMore(false)
@@ -106,13 +136,13 @@ class FoodSearchActivity : BaseActivity() {
queryListInfo(foodName!!) queryListInfo(foodName!!)
} }
binding.root.setOnClickListener { v -> binding.root.setOnClickListener { v ->
KeyboardUtil.hideKeyboard(v.context, v) KeyboardUtil.hideKeyboard(v)
} }
binding.etInputDish.let { v -> binding.etInputDish.let { v ->
v.addOnActionSearchListener { v.addOnActionSearchListener {
pageNo = 1 pageNo = 1
getInputAndSearch() getInputAndSearch()
KeyboardUtil.hideKeyboard(v.context, v) KeyboardUtil.hideKeyboard( v)
} }
} }
} }
@@ -140,61 +170,6 @@ class FoodSearchActivity : BaseActivity() {
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
private fun queryListInfo(input: String) { private fun queryListInfo(input: String) {
showLoading()
// val map = mutableMapOf(
// "foodName" to input.trim(),
// "pageNo" to "$pageNo",
// "pageSize" to "$pageSize",
// "canteenId" to BaseApp.canteenId
// "dinnerType" to getDinnerTypeText()
// )
// val sb = StringBuilder(UrlConfig.QUERY_FOOD_LIST).apply {
// append("?")
// map.forEach { (key, value) -> append("$key=$value&") }
// }
// sb.deleteCharAt(sb.length - 1)
// HttpUtil.get(
// url = sb.toString(),
// doSuccess = {
// delayDismissLoading()
// finishRefresh()
// binding.refreshLayout.run {
// setEnableRefresh(true)
// }
// val json = it.toJsonString()
// val recordBean: FoodRecordBean? = json.toObject<FoodRecordBean>()
// if (recordBean == null || recordBean.records.isNullOrEmpty()) {
//// toast("暂未搜索到相关菜品信息")
// if (pageNo == 1) {
// loadEmptyView()
// }
// return@get
// }
// val records = recordBean.records
// if (pageNo == 1) {
// list.clear()
// }
// list.addAll(records!!)
// recordAdapter.notifyDataSetChanged()
// val isLoadMoreEnable = records.size >= pageSize
// binding.refreshLayout.setEnableLoadMore(isLoadMoreEnable)
// if (isLoadMoreEnable) {
// pageNo++
// }
// },
// doFailure = { code, msg ->
// binding.refreshLayout.run {
// setEnableRefresh(true)
// }
// toast(msg)
// delayDismissLoading()
// finishRefresh()
// if (pageNo == 1) {
// loadEmptyView()
// }
// }
// )
val map = mutableMapOf<String, Any>( val map = mutableMapOf<String, Any>(
"foodName" to input.trim(), "foodName" to input.trim(),
"pageNum" to pageNo, "pageNum" to pageNo,
@@ -202,40 +177,27 @@ class FoodSearchActivity : BaseActivity() {
"placeId" to BaseApp.canteenId, "placeId" to BaseApp.canteenId,
"dinnerType" to getDinnerTypeText() "dinnerType" to getDinnerTypeText()
) )
netViewModel.searchFoodList( netViewModel.searchFoodList(param = map)
param = map, }
onSuccess = {
delayDismissLoading() /**
finishRefresh() * 处理搜索结果,填充列表或展示空视图
binding.refreshLayout.setEnableRefresh(true) */
if (it.isNullOrEmpty()) { @SuppressLint("NotifyDataSetChanged")
// toast("暂未搜索到相关菜品信息") private fun handleSearchResult(records: MutableList<FoodRecord>?) {
if (pageNo == 1) { delayDismissLoading()
loadEmptyView() finishRefresh()
} binding.refreshLayout.setEnableRefresh(true)
return@searchFoodList if (records.isNullOrEmpty()) {
} if (pageNo == 1) loadEmptyView()
if (pageNo == 1) { return
list.clear() }
} if (pageNo == 1) list.clear()
list.addAll(it) list.addAll(records)
recordAdapter.notifyDataSetChanged() recordAdapter.notifyDataSetChanged()
val isLoadMoreEnable = it.size >= pageSize val isLoadMoreEnable = records.size >= pageSize
binding.refreshLayout.setEnableLoadMore(isLoadMoreEnable) binding.refreshLayout.setEnableLoadMore(isLoadMoreEnable)
if (isLoadMoreEnable) { if (isLoadMoreEnable) pageNo++
pageNo++
}
}, onFailure = { code, msg ->
binding.refreshLayout.run {
setEnableRefresh(true)
}
toast(msg)
delayDismissLoading()
finishRefresh()
if (pageNo == 1) {
loadEmptyView()
}
})
} }
private fun finishRefresh() { private fun finishRefresh() {
@@ -2,20 +2,19 @@ package com.shuwei.dish.match.ui
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.os.Bundle import android.os.Bundle
import androidx.lifecycle.ViewModelProvider import androidx.activity.addCallback
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.shuwei.dish.match.R import com.shuwei.dish.match.R
import com.shuwei.dish.match.adapter.HomeModeAdapter import com.shuwei.dish.match.adapter.HomeModeAdapter
import com.shuwei.dish.match.base.BaseActivity import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.base.BaseApp import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.databinding.ActivityHomeBinding import com.shuwei.dish.match.databinding.ActivityHomeBinding
import com.shuwei.dish.match.db.AppRepository
import com.shuwei.dish.match.entity.HomeModeBean import com.shuwei.dish.match.entity.HomeModeBean
import com.shuwei.dish.match.utils.SpTool import com.shuwei.dish.match.utils.SpTool
import com.shuwei.dish.match.utils.ext.startActivity import com.shuwei.dish.match.utils.ext.startActivity
import com.shuwei.dish.match.utils.ext.toast import com.shuwei.dish.match.utils.ext.toast
import com.shuwei.dish.match.viewmodel.AppViewModel import kotlinx.coroutines.launch
import com.shuwei.dish.match.viewmodel.factory.AppFactory
class HomeActivity : BaseActivity() { class HomeActivity : BaseActivity() {
@@ -31,7 +30,6 @@ class HomeActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
statusBarDarkFont(enable = true) statusBarDarkFont(enable = true)
initViewModel()
// // 获取 ANDROID_ID // // 获取 ANDROID_ID
// val deviceId = // val deviceId =
// Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID) // Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID)
@@ -48,6 +46,7 @@ class HomeActivity : BaseActivity() {
BaseApp.canteenId = "0" BaseApp.canteenId = "0"
} }
} }
onBackPressedDispatcher.addCallback(this) {}
// binding.radioGroup.check(R.id.radioZk) // binding.radioGroup.check(R.id.radioZk)
initRecyclerView() initRecyclerView()
// HttpUtil.getAppToken() // HttpUtil.getAppToken()
@@ -105,7 +104,7 @@ class HomeActivity : BaseActivity() {
modeDesc = "菜品快速制作,记录熟重", modeDesc = "菜品快速制作,记录熟重",
modeIconId = R.drawable.ic_mode_cook, modeIconId = R.drawable.ic_mode_cook,
onClick = { onClick = {
SpTool.put(SpTool.LAUNCH_PAGE_TYPE, 0) SpTool.cookMode = 0
startActivity<SelectDishActivity>() startActivity<SelectDishActivity>()
finish() finish()
}) })
@@ -116,7 +115,7 @@ class HomeActivity : BaseActivity() {
modeDesc = "菜品快速制作,记录熟重", modeDesc = "菜品快速制作,记录熟重",
modeIconId = R.drawable.ic_mode_sampling, modeIconId = R.drawable.ic_mode_sampling,
onClick = { onClick = {
SpTool.put(SpTool.LAUNCH_PAGE_TYPE, 1) SpTool.cookMode = 1
goSampling() goSampling()
}) })
) )
@@ -132,44 +131,30 @@ class HomeActivity : BaseActivity() {
) )
} }
private lateinit var appViewModel: AppViewModel
private fun initViewModel() {
val db = BaseApp.instance!!.database
val factory = AppFactory(AppRepository(db.appDao()))
appViewModel = ViewModelProvider(this, factory)[AppViewModel::class.java]
}
private fun goSampling() { private fun goSampling() {
appViewModel.loadSeasoning { lifecycleScope.launch {
if (it.isEmpty()) { val slots = appViewModel.loadSeasoningSlot()
//无调料信息打开采样历史列表页面,点击设置配置调料信息 if (slots.isEmpty()) {
// 无调料信息,打开采样历史列表页面,点击设置配置调料信息
startActivity<SamplingListActivity>() startActivity<SamplingListActivity>()
finish() finish()
return@loadSeasoning return@launch
} }
appViewModel.countCookFood(cookMode = 1) { count -> val count = appViewModel.countCookFood(cookMode = 1)
if (count > 0) { if (count > 0) {
//有烹饪中数据打开采样历史列表页面 // 有烹饪中数据打开采样历史列表页面
startActivity<SamplingListActivity>() startActivity<SamplingListActivity>()
finish()
return@countCookFood
}
//无烹饪数据打开新增采样页面
startActivity<DishSamplingActivity> {
putExtra(DishSamplingActivity.PAGE_FROM, DishSamplingActivity.HOME)
}
finish() finish()
return@launch
} }
// 无烹饪数据,打开新增采样页面
startActivity<PrepareCookActivity> {
putExtra(PrepareCookActivity.PAGE_FROM, PrepareCookActivity.HOME)
}
finish()
} }
} }
@Deprecated("Deprecated in Java")
@SuppressLint("GestureBackNavigation", "MissingSuperCall")
override fun onBackPressed() {
//super.onBackPressed()
//当前页面不允许返回
//dialog = toast("不允许返回操作")
}
} }
@@ -1,26 +1,32 @@
package com.shuwei.dish.match.ui package com.shuwei.dish.match.ui
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.Color
import android.os.Bundle import android.os.Bundle
import android.provider.Settings import android.os.SystemClock
import androidx.lifecycle.ViewModelProvider import android.view.KeyEvent
import android.view.animation.RotateAnimation
import androidx.activity.addCallback
import androidx.core.graphics.toColorInt
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.shuwei.dish.match.base.BaseActivity import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.base.BaseApp 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.databinding.ActivityInitBinding import com.shuwei.dish.match.databinding.ActivityInitBinding
import com.shuwei.dish.match.db.AppRepository import com.shuwei.dish.match.objbox.FoodModule
import com.shuwei.dish.match.http.UrlConfig import com.shuwei.dish.match.scale.ScaleServiceManager
import com.shuwei.dish.match.utils.AddressUtil
import com.shuwei.dish.match.utils.AppUtil import com.shuwei.dish.match.utils.AppUtil
import com.shuwei.dish.match.utils.QRCodeUtil import com.shuwei.dish.match.utils.NetworkUtils
import com.shuwei.dish.match.utils.SpTool import com.shuwei.dish.match.utils.SpTool
import com.shuwei.dish.match.utils.Weigher2
import com.shuwei.dish.match.utils.WeightUtil import com.shuwei.dish.match.utils.WeightUtil
import com.shuwei.dish.match.utils.ext.dp import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.startActivity import com.shuwei.dish.match.utils.ext.startActivity
import com.shuwei.dish.match.utils.ext.toObject import com.shuwei.dish.match.utils.ext.visible
import com.shuwei.dish.match.viewmodel.AppViewModel
import com.shuwei.dish.match.viewmodel.factory.AppFactory
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class InitActivity : BaseActivity() { class InitActivity : BaseActivity() {
@@ -29,195 +35,213 @@ class InitActivity : BaseActivity() {
} }
private lateinit var binding: ActivityInitBinding private lateinit var binding: ActivityInitBinding
private lateinit var appViewModel: AppViewModel private var startTime = 0L // 倒计时开始时间
private var lastNetworkCheckTime = 0L // 上次检测网络的时间
@SuppressLint("HardwareIds") @SuppressLint("HardwareIds")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
addBackKeyListener()
statusBarDarkFont(enable = true) statusBarDarkFont(enable = true)
// useBackground(false) // useBackground(false)
binding = ActivityInitBinding.inflate(layoutInflater) binding = ActivityInitBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
lifecycleScope.launch {
FoodModule.init(this@InitActivity)
}
// setHeaderBackground(isHomePage = true) // setHeaderBackground(isHomePage = true)
setHeaderBgVisible(false) setHeaderBgVisible(false)
BaseApp.appVersion = AppUtil.getAppVersionCode(this).toString() BaseApp.appVersion = AppUtil.getAppVersionCode(this).toString()
// 获取 ANDROID_ID
// var androidId = Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID)
// androidId = "39a7abdd06b3c7ab"
// BaseApp.deviceId = androidId
// SpTool.put(SpTool.DEVICE_ID, androidId)
// BaseApp.configUrl = UrlConfig.BASE_URL
BaseApp.canteenId = "0" BaseApp.canteenId = "0"
// // TODO: 以上保存deviceId用于临时使用,后续改为下面注释方式
// var deviceId = AppUtil.getUDID(this)
// Log.d(TAG, "onCreate: deviceId=$deviceId")
//// deviceId = "39a7abdd06b3c7ab"
// deviceId = "3ea47dc0-3cf0-3c2f-909c-265a9a65572e"
//// deviceId = "6ce7cd59-b875-38b2-b73d-88a570be3212"
//
// BaseApp.deviceId = deviceId
//
// SpTool.put(SpTool.DEVICE_ID, deviceId)
// val deviceConfigCache = SpTool.getString(SpTool.DEVICE_CONFIG_CACHE)
// val checkResult = checkConfigData(deviceConfigCache)
// if (checkResult.not()) {
// binding.ivQrCode.visible()
// binding.btnInit.visible()
// //进行初始化操作
// initConfig()
// return
// }
// binding.ivQrCode.invisible()
// binding.btnInit.invisible()
initViewModel()
WeightUtil.init() WeightUtil.init()
WeightUtil.getWeight() WeightUtil.getWeight()
WeightUtil.startContinuousRead() WeightUtil.startContinuousRead()
// HttpUtil.getAppToken() //对净材秤1和熟食秤2称进行清零
WeightUtil.tareTwo(AddressUtil.ONE)
// HttpUtil.loopGetToken = true WeightUtil.tareTwo(AddressUtil.TWO)
countDown() initViews()
} }
private fun initViewModel() {
val db = BaseApp.instance!!.database
val factory = AppFactory(AppRepository(db.appDao()))
appViewModel = ViewModelProvider(this, factory)[AppViewModel::class.java]
}
private fun startNextPage() { private fun startNextPage(withDelay: Boolean = false) {
val launchPageType = SpTool.getInt(SpTool.LAUNCH_PAGE_TYPE, -1) lifecycleScope.launch {
when (launchPageType) { if (withDelay) delay(2000)
0 -> { // 子设备:直接进入小屏专属页面,不走原有大屏业务流程
startActivity<SelectDishActivity>() if (GlobalData.deviceRole == DeviceRole.SLAVE) {
startActivity<SlaveActivity>()
return@launch
} }
// 主设备:走原有路由逻辑
when (SpTool.cookMode) {
0 -> {
startActivity<SelectDishActivity>()
}
1 -> { 1 -> {
goSampling() goSampling()
} }
else -> { else -> {
startActivity<HomeActivity>() startActivity<HomeActivity>()
}
} }
} }
} }
private fun goSampling() { private fun goSampling() {
appViewModel.loadSeasoning {
if (it.isEmpty()) {
//无调料信息打开采样历史列表页面,点击设置配置调料信息
startActivity<SamplingListActivity>()
// finish()
return@loadSeasoning
}
appViewModel.countCookFood(cookMode = 1) { count ->
if (count > 0) {
//有烹饪中数据打开采样历史列表页面
startActivity<SamplingListActivity>()
// finish()
return@countCookFood
}
//无烹饪数据打开新增采样页面
startActivity<DishSamplingActivity> {
putExtra(DishSamplingActivity.PAGE_FROM, DishSamplingActivity.HOME)
}
// finish()
}
}
}
private fun countDown() {
lifecycleScope.launch { lifecycleScope.launch {
flow { val slots = appViewModel.loadSeasoningSlot()
(2 downTo 1).forEach { if (slots.isEmpty()) {
delay(1000) // 无调料信息,打开采样历史列表页面,点击设置配置调料信息
emit(it) startActivity<SamplingListActivity>()
} return@launch
}.collect { }
// 倒计时结束执行跳转 val count = appViewModel.countCookFood(cookMode = 1)
if (it == 1) { if (count > 0) {
startNextPage() // 有烹饪中数据,打开采样历史列表页面
//保留初始化页面,不关闭 startActivity<SamplingListActivity>()
// finish() return@launch
} }
// 无烹饪数据,打开新增采样页面
startActivity<PrepareCookActivity> {
putExtra(PrepareCookActivity.PAGE_FROM, PrepareCookActivity.HOME)
} }
} }
} }
// private fun initConfig() {
// binding.ivQrCode.setImageBitmap(
// QRCodeUtil.generateQRCode(
// content = BaseApp.deviceId ?: "",
// size = 200.dp
// )
// )
// binding.btnInit.setOnClickListener {
//// HttpUtil.loopGetToken = false
//// getDeviceConfig()
// }
// }
// private fun getDeviceConfig() {
// val tokenUrl =
// "${UrlConfig.DEVICE_TOKEN}?qrcodeId=${BaseApp.deviceId}&appVersion=${BaseApp.appVersion}"
// HttpUtil.get(url = tokenUrl, doSuccess = { token ->
// Log.d(TAG, "initConfig: $token")
// getConfig(token.toString())
// }, doFailure = { code, msg ->
// Log.d(TAG, "initConfig: $code,$msg")
// })
// }
// private fun getConfig(token: String) {
// val deviceConfigUrl =
// "${UrlConfig.DEVICE_CONFIG}?equipmentCode=${BaseApp.deviceId}&&appVersion=${BaseApp.appVersion}"
// HttpUtil.get(
// url = deviceConfigUrl, header = mutableMapOf(
// "X-Access-Token" to token
// ), doSuccess = {
// Log.d(TAG, "getDeviceConfig: $it")
// val data = it.toJsonString()
// val checkResult = checkConfigData(data)
// if (checkResult.not()) {
// toast("初始化设备失败,请稍后重试")
// return@get
// }
// HttpUtil.loopGetToken = true
// SpTool.put(SpTool.DEVICE_CONFIG_CACHE, data)
// startActivity<HomeActivity>()
//// finish()
// }, doFailure = { code, msg ->
// Log.d(TAG, "getDeviceConfig: $code,$msg")
// toast("初始化设备失败,请稍后重试,code=${code},msg=${msg}")
// })
// }
data class DeviceConfigBean(
var appPackageUrl: String? = null,
var canteenId: String? = null
)
// private fun checkConfigData(data: String): Boolean {
// if (data.isBlank()) {
// return false
// }
// val config = data.toObject<DeviceConfigBean?>()
// if (config == null) {
// return false
// }
// BaseApp.configUrl = config.appPackageUrl ?: ""
// BaseApp.canteenId = config.canteenId ?: ""
// return true
// }
override fun onDestroy() { override fun onDestroy() {
WeightUtil.stopContinuousRead() WeightUtil.stopContinuousRead()
ScaleServiceManager.stop()
super.onDestroy() super.onDestroy()
} }
/**
* 初始化控件事件
*/
private fun initViews() {
// 连接网络按钮点击事件
binding.btnConnectNetwork.setOnClickListener {
openNetworkSettings()
}
}
/**
* 启动加载动画 - ImageView 持续旋转
*/
private fun startLoadingAnimation() {
val rotateAnimation = RotateAnimation(
0f, 360f,
RotateAnimation.RELATIVE_TO_SELF, 0.5f,
RotateAnimation.RELATIVE_TO_SELF, 0.5f
).apply {
duration = 1200 // 旋转周期 1.2 秒
repeatCount = RotateAnimation.INFINITE // 无限循环
repeatMode = RotateAnimation.RESTART
}
binding.ivLoading.startAnimation(rotateAnimation)
}
override fun onResume() {
super.onResume()
if (NetworkUtils.isNetworkConnected(this)) {
// 有网络时隐藏网络检测区域,直接跳转
binding.llNetwork.gone()
startNextPage(withDelay = false)
return
}
binding.llNetwork.visible()
startCountdown()
}
override fun onPause() {
super.onPause()
// 页面不可见时停止倒计时
handler.removeCallbacksAndMessages(null)
}
/**
* 启动 60 秒倒计时
*/
private fun startCountdown() {
startTime = SystemClock.elapsedRealtime()
lastNetworkCheckTime = 0
// binding.llNetwork.setBackgroundColor(Color.TRANSPARENT)
binding.llLoading.visible()
binding.llNetworkButton.gone()
binding.tvCountdown.text = "60秒"
// 无网络时才启动加载动画
startLoadingAnimation()
scheduleCountdown()
}
/**
* 定时更新倒计时
* 使用 SystemClock.elapsedRealtime() 确保精确计时
*/
private fun scheduleCountdown() {
handler.postDelayed({
val elapsedTime = SystemClock.elapsedRealtime() - startTime
val remainingTime = 60 - (elapsedTime / 1000).toInt()
val currentNetworkCheckTime = (elapsedTime / 1000).toInt()
// 每 10 秒检测一次网络
if (currentNetworkCheckTime > 0 && currentNetworkCheckTime % 10 == 0 && currentNetworkCheckTime != lastNetworkCheckTime.toInt()) {
lastNetworkCheckTime = currentNetworkCheckTime.toLong()
checkNetworkConnection()
}
// 倒计时未结束,继续更新 UI
if (remainingTime > 0) {
binding.tvCountdown.text = "${remainingTime}"
scheduleCountdown()
} else {
// 倒计时结束,最后检测一次网络
if (NetworkUtils.isNetworkConnected(this)) {
startNextPage()
} else {
// 网络未连接,显示"连接网络"按钮
showNetworkButton()
}
}
}, 100) // 每 100ms 检查一次,确保精确性
}
/**
* 检测网络连接
*/
private fun checkNetworkConnection() {
if (NetworkUtils.isNetworkConnected(this)) {
// 网络连接成功,停止倒计时并跳转到 HomeActivity
handler.removeCallbacksAndMessages(null)
startNextPage()
}
}
/**
* 显示"连接网络"按钮
*/
private fun showNetworkButton() {
// binding.llNetwork.setBackgroundColor("#5C77F7".toColorInt())
binding.llLoading.gone()
binding.llNetworkButton.visible()
}
/**
* 打开系统网络设置页面
*/
private fun openNetworkSettings() {
val intent = Intent(android.provider.Settings.ACTION_WIFI_SETTINGS)
startActivity(intent) {}
}
private fun addBackKeyListener() {
onBackPressedDispatcher.addCallback(this) {
// 禁用返回键,不执行任何操作
}
}
} }
@@ -0,0 +1,485 @@
package com.shuwei.dish.match.ui
import android.annotation.SuppressLint
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.updateLayoutParams
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.chad.library.adapter4.BaseMultiItemAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.google.android.flexbox.AlignItems
import com.google.android.flexbox.FlexDirection
import com.google.android.flexbox.FlexWrap
import com.google.android.flexbox.FlexboxLayoutManager
import com.shuwei.dish.match.R
import com.shuwei.dish.match.adapter.Seasoning18GridAdapter
import com.shuwei.dish.match.adapter.Seasoning22GridAdapter
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.dialog.CommonDialog
import com.shuwei.dish.match.databinding.ActivityMasterScaleBinding
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.databinding.ListItemScale22RowBinding
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.SizeTool
import com.shuwei.dish.match.utils.WeightUtil
import com.shuwei.dish.match.utils.ext.dp
import com.shuwei.dish.match.utils.ext.toast
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
private const val VIEW_TYPE_SCALE2 = 0
private const val VIEW_TYPE_SCALE22 = 1
private const val VIEW_TYPE_SCALE18 = 2
private const val VIEW_TYPE_SCALE1 = 3
/**
* 主设备秤数据总览页面
* 按设备分组展示,每种设备对应独立的 item 布局和 viewType
*/
class MasterScaleActivity : BaseActivity() {
private lateinit var binding: ActivityMasterScaleBinding
private val size by lazy { SizeTool.getFlexLayoutSize(10.dp) }
private data class DeviceGroup(
val deviceId: String,
val ip: String,
val scales: List<ScaleData>
)
private val groupList = mutableListOf<DeviceGroup>()
private val adapter = DeviceGroupAdapter(groupList)
/** key = "$deviceId#$address"value = 调料名称,用于注入 ScaleData.name */
private val slotNameMap = mutableMapOf<String, String>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMasterScaleBinding.inflate(layoutInflater)
setContentView(binding.root)
setHeaderBackground()
binding.ivMasterBack.setOnClickListener { finish() }
binding.rvScaleList.layoutManager = androidx.recyclerview.widget.LinearLayoutManager(this)
binding.rvScaleList.itemAnimator = null
binding.rvScaleList.adapter = adapter
loadSlotNames()
}
/** 从 Room 加载槽位配置到 slotNameMap,加载完成后启动数据观测 */
private fun loadSlotNames() {
lifecycleScope.launch {
val slots = appViewModel.loadSeasoningSlot()
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() {
val flow = ScaleServiceManager.allScales ?: return
lifecycleScope.launch {
flow.collectLatest { scaleMap ->
val localId = com.shuwei.dish.match.base.GlobalData.deviceId
val localIp = NetworkUtil.getLocalIpAddress(this@MasterScaleActivity)
val groups = scaleMap.values
.groupBy { it.deviceId }
.entries
.sortedWith(compareBy { entry ->
ScaleDeviceConfig.DEVICE_ORDER.indexOf(entry.key)
.let { if (it == -1) Int.MAX_VALUE else it }
})
.map { (deviceId, scales) ->
DeviceGroup(
deviceId = deviceId,
ip = if (deviceId == localId) localIp
else scales.firstOrNull { it.ip.isNotEmpty() }?.ip ?: "",
scales = scales.map { it.withSlotName() }.sortedBy { it.address }
)
}
val structureChanged = groups.size != groupList.size ||
groups.indices.any { groups[it].deviceId != groupList[it].deviceId }
if (structureChanged) {
updateList(groups)
} else {
groups.forEach { group ->
adapter.updateScalesForDevice(
group.deviceId,
group.scales
)
}
}
updateStatusBar(scaleMap)
}
}
}
private fun updateList(groups: List<DeviceGroup>) {
if (groupList.isEmpty()) {
groupList.addAll(groups)
adapter.notifyDataSetChanged()
return
}
// 精确 diff,只 notify 真正变化的 item,避免全量刷新打断触摸事件
groups.forEachIndexed { i, group ->
if (i < groupList.size) {
if (groupList[i] != group) {
groupList[i] = group
adapter.notifyItemChanged(i)
}
} else {
groupList.add(group)
adapter.notifyItemInserted(i)
}
}
while (groupList.size > groups.size) {
groupList.removeAt(groupList.lastIndex)
adapter.notifyItemRemoved(groupList.size)
}
}
@SuppressLint("SetTextI18n")
private fun updateStatusBar(scaleMap: Map<String, ScaleData>) {
val deviceCount = scaleMap.values.map { it.deviceId }.distinct().size
val scaleCount = scaleMap.size
binding.tvConnectionStatus.text = "已发现设备:$deviceCount 台 / 共 $scaleCount 个秤"
}
private inner class DeviceGroupAdapter(data: MutableList<DeviceGroup>) :
BaseMultiItemAdapter<DeviceGroup>(data) {
private val scale1VHMap = mutableMapOf<String, Scale1VH>()
private val scale2VHMap = mutableMapOf<String, Scale2VH>()
private val scale22VHMap = mutableMapOf<String, Scale22VH>()
private val scale18VHMap = mutableMapOf<String, Scale18VH>()
fun updateScalesForDevice(deviceId: String, scales: List<ScaleData>) {
scale1VHMap[deviceId]?.updateScales(scales)
scale2VHMap[deviceId]?.updateScales(scales)
scale22VHMap[deviceId]?.updateScales(scales)
scale18VHMap[deviceId]?.updateScales(scales)
}
init {
addItemType(
VIEW_TYPE_SCALE2,
object :
BaseMultiItemAdapter.OnMultiItemAdapterListener<DeviceGroup, QuickViewHolder> {
override fun onCreate(context: Context, parent: ViewGroup, viewType: Int) =
QuickViewHolder(
ListTypeScale2Binding.inflate(
LayoutInflater.from(context),
parent,
false
).root
)
override fun onBind(
holder: QuickViewHolder,
position: Int,
item: DeviceGroup?
) {
item ?: return
val b = ListTypeScale2Binding.bind(holder.itemView)
val vh = Scale2VH(b)
scale2VHMap[item.deviceId] = vh
vh.bind(item)
}
})
addItemType(
VIEW_TYPE_SCALE22,
object :
BaseMultiItemAdapter.OnMultiItemAdapterListener<DeviceGroup, QuickViewHolder> {
override fun onCreate(context: Context, parent: ViewGroup, viewType: Int) =
QuickViewHolder(
ListTypeScale22Binding.inflate(
LayoutInflater.from(context),
parent,
false
).root
)
override fun onBind(
holder: QuickViewHolder,
position: Int,
item: DeviceGroup?
) {
item ?: return
val b = ListTypeScale22Binding.bind(holder.itemView)
// 每次 onBind 时注册/更新 VH 缓存,后续数据更新直接走 VH,不再触发 notify
val vh = Scale22VH(b)
scale22VHMap[item.deviceId] = vh
vh.bind(item)
}
})
addItemType(
VIEW_TYPE_SCALE18,
object :
BaseMultiItemAdapter.OnMultiItemAdapterListener<DeviceGroup, QuickViewHolder> {
override fun onCreate(context: Context, parent: ViewGroup, viewType: Int) =
QuickViewHolder(
ListTypeScale18Binding.inflate(
LayoutInflater.from(context),
parent,
false
).root
)
override fun onBind(
holder: QuickViewHolder,
position: Int,
item: DeviceGroup?
) {
item ?: return
val b = ListTypeScale18Binding.bind(holder.itemView)
val vh = Scale18VH(b)
scale18VHMap[item.deviceId] = vh
vh.bind(item)
}
})
addItemType(
VIEW_TYPE_SCALE1,
object :
BaseMultiItemAdapter.OnMultiItemAdapterListener<DeviceGroup, QuickViewHolder> {
override fun onCreate(context: Context, parent: ViewGroup, viewType: Int) =
QuickViewHolder(
ListTypeScale1Binding.inflate(
LayoutInflater.from(context),
parent,
false
).root
)
override fun onBind(
holder: QuickViewHolder,
position: Int,
item: DeviceGroup?
) {
item ?: return
val b = ListTypeScale1Binding.bind(holder.itemView)
val vh = Scale1VH(b)
scale1VHMap[item.deviceId] = vh
vh.bind(item)
}
})
onItemViewType { position, list ->
when (list.getOrNull(position)?.deviceId) {
ScaleDeviceConfig.DEVICE_ID_2 -> VIEW_TYPE_SCALE2
ScaleDeviceConfig.DEVICE_ID_22 -> VIEW_TYPE_SCALE22
ScaleDeviceConfig.DEVICE_ID_18 -> VIEW_TYPE_SCALE18
else -> VIEW_TYPE_SCALE1
}
}
}
}
// ── ViewHolder: 2个秤 ──────────────────────────────────────────────────
private inner class Scale2VH(val b: ListTypeScale2Binding) : RecyclerView.ViewHolder(b.root) {
@SuppressLint("SetTextI18n")
fun bind(group: DeviceGroup) {
b.tvDeviceIp.text = "IP${group.ip.ifEmpty { "未知" }}"
b.tvDeviceLabel.text = "设备:${group.deviceId}"
// 两个格子显示秤地址行,隐藏重量/状态(showAddress 通过 visibility 控制)
listOf(b.cell1, b.cell2).forEach { cell ->
cell.tvAddress.visibility = android.view.View.VISIBLE
cell.tvWeight.visibility = android.view.View.VISIBLE
cell.tvState.visibility = android.view.View.VISIBLE
cell.root.setOnClickListener {
val s = cell.root.tag as? ScaleData ?: return@setOnClickListener
showTareDialog(deviceId = s.deviceId, ip = s.ip, address = s.address)
}
}
updateScales(group.scales)
}
fun updateScales(scales: List<ScaleData>) {
bindCell(b.cell1, scales.getOrNull(0))
bindCell(b.cell2, scales.getOrNull(1))
}
@SuppressLint("SetTextI18n")
private fun bindCell(cell: ListItemScale22RowBinding, scale: ScaleData?) {
cell.root.tag = scale
cell.tvAddress.run {
text = scale?.let { "${it.address}" } ?: ""
textSize = 18f
}
cell.tvName.run {
text = if (scale?.address == 1) "净材" else "熟食"
textSize = 22f
}
cell.tvWeight.run {
text = scale?.let { "${it.weight}g" } ?: ""
textSize = 22f
}
cell.tvState.run {
text = stateStr(scale?.state)
textSize = 22f
}
cell.root.updateLayoutParams { height = 400.dp }
}
}
// ── ViewHolder: 22个秤 ─────────────────────────────────────────────────
private inner class Scale22VH(val b: ListTypeScale22Binding) : RecyclerView.ViewHolder(b.root) {
private var innerAdapter: Seasoning22GridAdapter? = null
@SuppressLint("SetTextI18n")
fun bind(group: DeviceGroup) {
b.tvDeviceIp.text = "IP${group.ip.ifEmpty { "未知" }}"
b.tvDeviceLabel.text = "设备:${group.deviceId}"
if (innerAdapter == null) {
b.rvScales.layoutParams = b.rvScales.layoutParams.also { it.height = size.rvHeight }
b.rvScales.layoutManager = FlexboxLayoutManager(b.root.context).apply {
flexDirection = FlexDirection.COLUMN
flexWrap = FlexWrap.WRAP
alignItems = AlignItems.FLEX_START
}
b.rvScales.itemAnimator = null
innerAdapter = Seasoning22GridAdapter(
size.largeSize,
size.smallSize,
showNameOnly = false,
showAddress = true
).apply {
onItemClick = { scale, _ ->
showTareDialog(
deviceId = scale.deviceId,
ip = scale.ip,
address = scale.address
)
}
}
b.rvScales.adapter = innerAdapter
}
updateScales(group.scales)
}
/** 仅更新秤数据,不触发外层任何 notify */
fun updateScales(scales: List<ScaleData>) {
innerAdapter?.updateByAddress(scales)
}
}
// ── ViewHolder: 18个秤 ─────────────────────────────────────────────────
private inner class Scale18VH(val b: ListTypeScale18Binding) : RecyclerView.ViewHolder(b.root) {
private var innerAdapter: Seasoning18GridAdapter? = null
@SuppressLint("SetTextI18n")
fun bind(group: DeviceGroup) {
b.tvDeviceIp.text = "IP${group.ip.ifEmpty { "未知" }}"
b.tvDeviceLabel.text = "设备:${group.deviceId}"
if (innerAdapter == null) {
val gridLayoutManager = GridLayoutManager(b.root.context, 6).apply {
spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int) = 1
}
}
b.rvScales.layoutManager = gridLayoutManager
b.rvScales.itemAnimator = null
innerAdapter =
Seasoning18GridAdapter(showNameOnly = false, showAddress = true).apply {
onItemClick = { scale, _ ->
showTareDialog(
deviceId = scale.deviceId,
ip = scale.ip,
address = scale.address
)
}
}
b.rvScales.adapter = innerAdapter
}
updateScales(group.scales)
}
/** 仅更新秤数据,不触发外层任何 notify */
fun updateScales(scales: List<ScaleData>) {
innerAdapter?.updateByAddress(scales)
}
}
// ── ViewHolder: 1个秤 ──────────────────────────────────────────────────
private inner class Scale1VH(val b: ListTypeScale1Binding) : RecyclerView.ViewHolder(b.root) {
init {
b.llScaleContainer.setOnClickListener {
val scale = b.llScaleContainer.tag as? ScaleData ?: return@setOnClickListener
showTareDialog(deviceId = scale.deviceId, ip = scale.ip, address = scale.address)
}
}
@SuppressLint("SetTextI18n")
fun bind(group: DeviceGroup) {
b.tvDeviceIp.text = "IP${group.ip.ifEmpty { "未知" }}"
b.tvDeviceLabel.text = "设备:${group.deviceId}"
updateScales(group.scales)
}
fun updateScales(scales: List<ScaleData>) {
val scale = scales.firstOrNull()
b.llScaleContainer.tag = scale
b.tvAddr.text = scale?.let { "${it.address}" } ?: ""
b.tvName.text = scale?.name?.takeIf { it.isNotBlank() } ?: "-"
b.tvWeight.text = scale?.let { "${it.weight} g" } ?: ""
b.tvState.text = stateStr(scale?.state)
}
}
// ── 工具方法 ───────────────────────────────────────────────────────────
private fun stateStr(state: Int?) = when (state) {
WeightUtil.STATE_STABLE -> "稳定"
WeightUtil.STATE_UNSTABLE -> "不稳定"
WeightUtil.STATE_OVER_WEIGHT -> "超量"
null -> ""
else -> "$state"
}
/**
* 弹出清零确认对话框
* @param deviceId 目标设备 ID
* @param ip 设备 IP
* @param address 目标秤地址
*/
private fun showTareDialog(deviceId: String, ip: String, address: Int) {
CommonDialog(this)
.setTitle("清零确认")
.setContent("设备 IP${ip.ifEmpty { "未知" }}\n确认对秤 $address 执行清零操作?")
.setNegativeButton("取消")
.setPositiveButton("确认") {
val sent = ScaleServiceManager.sendTare(deviceId, address)
if (!sent) toast("设备未连接,清零失败")
}
.show()
}
}
@@ -1,34 +1,50 @@
package com.shuwei.dish.match.ui package com.shuwei.dish.match.ui
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.graphics.Typeface import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import androidx.activity.viewModels import android.view.View
import android.view.ViewGroup
import androidx.activity.addCallback
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.shuwei.dish.match.R import com.shuwei.dish.match.R
import com.shuwei.dish.match.adapter.DishPartAdapter import com.shuwei.dish.match.adapter.DishPartAdapter
import com.shuwei.dish.match.base.BaseActivity import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.databinding.ActivityPrepareCookBinding import com.shuwei.dish.match.databinding.ActivityPrepareCookBinding
import com.shuwei.dish.match.databinding.LayoutFoodRemindBinding import com.shuwei.dish.match.databinding.LayoutCameraPreviewBinding
import com.shuwei.dish.match.dialog.FoodSearchDialog
import com.shuwei.dish.match.dialog.CommonDialog import com.shuwei.dish.match.dialog.CommonDialog
import com.shuwei.dish.match.entity.CookFoodEntity import com.shuwei.dish.match.entity.CookFoodEntity
import com.shuwei.dish.match.entity.CookFoodGoodsEntity import com.shuwei.dish.match.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.entity.FoodRecord import com.shuwei.dish.match.entity.FoodRecord
import com.shuwei.dish.match.net.NetViewModel import com.shuwei.dish.match.net.UiState
import com.shuwei.dish.match.objbox.FoodModule
import com.shuwei.dish.match.utils.AddressUtil import com.shuwei.dish.match.utils.AddressUtil
import com.shuwei.dish.match.utils.CameraUtils
import com.shuwei.dish.match.utils.ImageUtil
import com.shuwei.dish.match.utils.KeyboardUtil
import com.shuwei.dish.match.utils.SpTool
import com.shuwei.dish.match.utils.SwipeCallback import com.shuwei.dish.match.utils.SwipeCallback
import com.shuwei.dish.match.utils.WeightUtil import com.shuwei.dish.match.utils.WeightUtil
import com.shuwei.dish.match.utils.ext.addOnActionSearchListener
import com.shuwei.dish.match.utils.ext.clickWithDebounce import com.shuwei.dish.match.utils.ext.clickWithDebounce
import com.shuwei.dish.match.utils.ext.gone import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.startActivity import com.shuwei.dish.match.utils.ext.startActivity
import com.shuwei.dish.match.utils.ext.toJsonString import com.shuwei.dish.match.utils.ext.toJsonString
import com.shuwei.dish.match.utils.ext.toast import com.shuwei.dish.match.utils.ext.toast
import com.shuwei.dish.match.utils.ext.visible import com.shuwei.dish.match.utils.ext.visible
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.Serializable import java.io.Serializable
import kotlin.getValue import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.abs
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
class PrepareCookActivity : BaseActivity() { class PrepareCookActivity : BaseActivity() {
@@ -36,12 +52,25 @@ class PrepareCookActivity : BaseActivity() {
companion object { companion object {
const val TAG = "CookActivity" const val TAG = "CookActivity"
const val FOOD_ITEM = "foodItem" 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
} }
private lateinit var binding: ActivityPrepareCookBinding private lateinit var binding: ActivityPrepareCookBinding
private val cameraUtils: CameraUtils by lazy { CameraUtils(this) }
private var food: FoodRecord? = null private var food: FoodRecord? = null
/** 采集模式下通过搜索页回填的 foodId */
private var foodId: String? = null
/** 来源页面标识,HOME 时返回跳转 SamplingListActivity */
private var pageFrom: String? = null
private var goodsList: MutableList<CookFoodGoodsEntity>? = null private var goodsList: MutableList<CookFoodGoodsEntity>? = null
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
@@ -50,105 +79,167 @@ class PrepareCookActivity : BaseActivity() {
binding = ActivityPrepareCookBinding.inflate(layoutInflater) binding = ActivityPrepareCookBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
setHeaderBackground() setHeaderBackground()
pageFrom = intent.getStringExtra(PAGE_FROM)
food = intent.extras?.getSerializable(FOOD_ITEM) as FoodRecord?
val isSamplingMode = SpTool.cookMode == 1
val titleText = if (isSamplingMode) "菜品采集" else "菜品制作"
setTitleBar(titleBarAction = { setTitleBar(titleBarAction = {
it.visible() it.visible()
}, titleAction = { }, titleAction = {
it.text = "菜品制作" it.text = titleText
}, rightIconActon = { }, rightIconActon = {
it.gone() it.gone()
it.setImageResource(R.drawable.ic_setting)
it.setOnClickListener {
//startActivity<DeviceSettingActivity>()
}
}, backAction = { }, backAction = {
it.setOnClickListener { it.setOnClickListener {
onBackPressed() onBackPressedDispatcher.onBackPressed()
} }
}) })
food = intent.extras?.getSerializable(FOOD_ITEM) as FoodRecord?
binding.tvDishName.text = food?.foodName ?: "" // 根据 cookMode 设置搜索栏可编辑性
if (isSamplingMode) {
// 采集模式:可编辑,支持搜索
val foodName = intent.getStringExtra(FOOD_NAME)
binding.etInputDish.setText(foodName ?: food?.foodName ?: "")
binding.ivDishSearch.visible()
} else {
// 制作模式:不可编辑,仅展示菜名
binding.etInputDish.setText(food?.foodName ?: "")
binding.etInputDish.isFocusable = false
binding.etInputDish.isFocusableInTouchMode = false
binding.ivDishSearch.gone()
}
addViewClickListener() addViewClickListener()
addBackKeyListener()
initCamera(binding.flCameraContainer)
initRecyclerView() initRecyclerView()
initObserver()
getDishDetail() if (!isSamplingMode) {
getDishDetail()
}
} }
private fun addViewClickListener() { private fun addViewClickListener() {
binding.tvShowWeight.setOnClickListener {
showLoading("正在清零……")
WeightUtil.tareTwo(AddressUtil.ONE)
delayDismissLoading()
}
WeightUtil.addWeightListener( WeightUtil.addWeightListener(
weightKey = TAG, weightKey = TAG,
getWeight = { address, state, weight -> getWeight = { address, state, weight ->
//Log.d(TAG, "addViewClickListener: address=$address,state=$state,weight=$weight") if (address == AddressUtil.ONE && state == WeightUtil.STATE_STABLE) {
if (address == AddressUtil.ONE) { runOnUiThread {
binding.tvDishPartWeight.text = "${weight}" binding.tvShowWeight.run {
if (tag != weight) {
text = "称重:${weight}g"
tag = weight
}
}
recognizeFood(weight)
}
} }
}) })
binding.ivWeightClear.setOnClickListener {
WeightUtil.tareTwo(AddressUtil.ONE) if (SpTool.cookMode == 1) {
// 采集模式:绑定搜索栏交互
binding.ivDishSearch.setOnClickListener { v -> jumpSearch(v) }
binding.etInputDish.addOnActionSearchListener { jumpSearch(binding.etInputDish) }
} }
binding.ivWeightAdd.setOnClickListener { addWeight() }
binding.tvAddFood.clickWithDebounce { addFood() }
binding.btnCook.clickWithDebounce { binding.btnCook.clickWithDebounce {
val count = list.count { it.isSetFinished.not() } if (SpTool.cookMode == 1) {
if (count > 0) { // 采集模式:从输入框创建 FoodRecord
showRemindDialog() val showFoodName = binding.etInputDish.text.toString().trim()
return@clickWithDebounce if (showFoodName.isBlank()) {
toast("菜品名称为空")
return@clickWithDebounce
}
if (list.isEmpty()) {
toast("菜品构成信息未设置")
return@clickWithDebounce
}
val foodRecord = FoodRecord()
binding.etInputDish.let {
if (it.tag != null && it.tag.toString() == it.text.toString()) {
foodRecord.foodId = foodId
}
}
foodRecord.foodName = showFoodName
foodRecord.cookMode = 1
Log.d(TAG, "goToSubmit: goodsList:${list.toJsonString()}")
startActivity<SubmitFoodActivity> {
putExtra(SubmitFoodActivity.GOODS_LIST, list as Serializable)
putExtra(SubmitFoodActivity.FOOD_ITEM, foodRecord as Serializable)
}
} else {
// 制作模式:检查食材是否全部设置完成
val count = list.count { it.isSetFinished.not() }
if (count > 0) {
showRemindDialog()
return@clickWithDebounce
}
openSubmitPage()
} }
openSubmitPage()
}
binding.rgCook.setOnCheckedChangeListener { group, checkedId ->
if (clickIndex < 0 || clickIndex >= list.size) return@setOnCheckedChangeListener
val tempType =
if (checkedId == R.id.rbDishTypeFirst) 1 else if (checkedId == R.id.rbDishTypeSecond) 2 else -1
list[clickIndex].materialType = tempType
dishPartAdapter.notifyItemChanged(clickIndex)
binding.tvDishType.text =
if (tempType == 1) "主材" else if (tempType == 2) "辅材" else ""
} }
} }
private fun addWeight() { private val isTakingPhoto = AtomicBoolean(false)
if (clickIndex == -1) { // 识别成功并查询到数据打开识别页面
toast("请选择菜品构成") private var showRecognizePage = false
// 页面可见时允许识别,onPause 后禁止,onResume 后恢复
private var pageVisible = true
// 手动取消标识
private var manualCancelFlag = false
private var lastPhotoUri: Uri? = null
private var lastWeight = 0.0
private var currentWeight = 0.0
/**
* 重量稳定时触发菜品识别
* @param weight 当前稳定重量
*/
private fun recognizeFood(weight: Double) {
this.currentWeight = weight
if (weight < WEIGHT_RECOGNIZE_VALUE) {
showRecognizePage = false
manualCancelFlag = false
lastWeight = weight
FoodRecognizeActivity.close()
return return
} }
val clickItem = list[clickIndex] if (!pageVisible || manualCancelFlag || isTakingPhoto.get() || showRecognizePage || abs(lastWeight - weight) <= WEIGHT_CHANGE_VALUE) {
clickItem.run { Log.d(TAG, "recognizeFood, lastWeight:$lastWeight, weight:$weight, pageVisible:$pageVisible, manualCancelFlag:$manualCancelFlag, isTakingPhoto:${isTakingPhoto.get()}")
if (isNewDishType && materialType != 1 && materialType != 2) {
toast("请选择主辅材类型")
return
}
}
val weight = binding.tvDishPartWeight.text.toString().toDouble()
if (weight <= 0.toDouble()) {
toast("食材用量需要大于0")
return return
} }
Log.d(TAG, "addViewClickListener->useRealWeight=$weight") showLoading("识别中……")
clickItem.run { isTakingPhoto.set(true)
useWeight = weight cameraUtils.takePhoto(
isOriginalData = false succCallback = cameraSuccessCallback,
isSetFinished = true failCallback = cameraFailureCallback
dishPartAdapter.notifyItemChanged(clickIndex) )
}
} }
private fun addFood() { /**
FoodSearchDialog().show(this) { item -> * 菜品选择回调
val filterResult = list.firstOrNull { it.goodsId == item.goodsId } * - list 中不含该 goodsId:直接新增,isNewDishType = true
if (filterResult != null) { * - list 中已含该 goodsId:累加 useWeight
toast("不允许重复添加同一食材") */
return@show private val foodSelectCallback: (CookFoodGoodsEntity) -> Unit = { entity ->
} val existing = list.firstOrNull { it.goodsId == entity.goodsId }
list.add(CookFoodGoodsEntity().apply { if (existing != null) {
goodsId = item.goodsId existing.useWeight = (existing.useWeight ?: 0.0) + (entity.useWeight ?: 0.0)
goodsName = item.goodsName existing.isSetFinished = true
// materialType = dishPartAdapter.notifyItemChanged(list.indexOf(existing))
isNewDishType = true } else {
isOriginalData = false dishPartAdapter.add(entity.also {
it.isNewDishType = true
it.isSetFinished = true
}) })
onItemClick(list.size - 1)
binding.rvDishPartList.smoothScrollToPosition(list.size - 1) binding.rvDishPartList.smoothScrollToPosition(list.size - 1)
} }
} }
@@ -185,21 +276,41 @@ class PrepareCookActivity : BaseActivity() {
} }
private fun showRemindDialog() { private fun showRemindDialog() {
val remindBinding = LayoutFoodRemindBinding.inflate(layoutInflater) CommonDialog(this)
CommonDialog(this).apply { .setTitle(getString(R.string.food_remind_01))
messageView = remindBinding.root .setContent(getString(R.string.food_remind_02))
leftText = "返回调整" .setNegativeButton("返回调整")
rightText = "确认无误" .setPositiveButton("确认无误") { openSubmitPage() }
onLeftClick = { dismiss() } .setOnDismissCallback { hideStatusBar() }
onRightClick = { .show()
openSubmitPage()
dismiss()
}
onDismiss = { hideStatusBar() }
}.show()
} }
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() { private fun getDishDetail() {
// //649 // //649
// val url = "${UrlConfig.DISH_DETAIL}?foodId=${food?.foodId}" // val url = "${UrlConfig.DISH_DETAIL}?foodId=${food?.foodId}"
@@ -218,17 +329,7 @@ class PrepareCookActivity : BaseActivity() {
// toast(msg) // toast(msg)
// } // }
netViewModel.getFoodDetail( netViewModel.getFoodDetail(food?.foodId ?: "")
food?.foodId ?: "",
onSuccess = { detail ->
if (detail == null) {
toast("查询菜品信息为空")
return@getFoodDetail
}
loadDishDetail(detail)
}, onFailure = { code, msg ->
toast(msg)
})
} }
@@ -250,58 +351,35 @@ class PrepareCookActivity : BaseActivity() {
} }
private var firstReqSize = 0 private var firstReqSize = 0
private var isDetailLoaded = false
private val list = mutableListOf<CookFoodGoodsEntity>() private val list = mutableListOf<CookFoodGoodsEntity>()
private val dishPartAdapter by lazy { private val dishPartAdapter by lazy {
DishPartAdapter(list).apply { DishPartAdapter(list = list).apply {
setOnItemClickListener { _, _, positon -> setOnItemClickListener { _, _, positon ->
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 {
isNewDishType = false
useWeight = 0.0
isSetFinished = false
}
notifyItemChanged(positon)
return@addOnItemChildClickListener
}
removeAt(positon)
} }
} }
} }
private fun onItemClick(positon: Int) { /**
this@PrepareCookActivity.clickIndex = positon * RecyclerView初始化
list[clickIndex].let { it -> */
binding.tvDishShowName.run {
text = it.goodsName
setTypeface(typeface, Typeface.BOLD)
}
var materialType: String
when (it.materialType) {
1 -> {
materialType = "主材"
binding.rgCook.check(R.id.rbDishTypeFirst)
}
2 -> {
materialType = "辅材"
binding.rgCook.check(R.id.rbDishTypeSecond)
}
else -> {
materialType = ""
binding.rgCook.clearCheck()
}
}
binding.tvDishType.text = materialType
list.forEach { bean -> bean.isItemClicked = false }
it.isItemClicked = true
dishPartAdapter.notifyDataSetChanged()
if (it.isNewDishType) {
binding.dividerDishType.visible()
binding.llDishType.visible()
} else {
binding.dividerDishType.gone()
binding.llDishType.gone()
}
}
}
private var clickIndex = -1
private fun initRecyclerView() { private fun initRecyclerView() {
binding.rvDishPartList.run { binding.rvDishPartList.run {
layoutManager = layoutManager =
@@ -313,58 +391,208 @@ class PrepareCookActivity : BaseActivity() {
dishPartAdapter.notifyItemChanged(position) dishPartAdapter.notifyItemChanged(position)
return@SwipeCallback return@SwipeCallback
} }
// dishPartAdapter.removeAt(position)
list.removeAt(position) list.removeAt(position)
dishPartAdapter.notifyItemRemoved(position) dishPartAdapter.notifyItemRemoved(position)
dishPartAdapter.notifyItemRangeChanged(position, list.size - position) dishPartAdapter.notifyItemRangeChanged(position, list.size - position)
toast("已删除") toast("已删除")
if (list.isEmpty()) {
binding.tvDishShowName.text = ""
}
}) })
itemTouchHelper.attachToRecyclerView(this) itemTouchHelper.attachToRecyclerView(this)
} }
} }
@Suppress("DEPRECATION") override fun onResume() {
@SuppressLint("GestureBackNavigation") super.onResume()
@Deprecated("Deprecated in Java") pageVisible = true
override fun onBackPressed() { showRecognizePage = false
val count = list.count { !it.isOriginalData } cameraUtils.bind()
if (count > 0) {
//说明有新增或者设置重量
saveDataRemindDialog()
return
}
// if (list.size > firstReqSize) {
// //说明有新增的食材
// saveDataRemindDialog()
// return
// }
// val filterResult = list.firstOrNull { (it.useWeight ?: 0.toDouble()) > 0.toDouble() }
// if (filterResult != null) {
// //说明有称重的数据
// saveDataRemindDialog()
// return
// }
super.onBackPressed()
} }
override fun onPause() {
super.onPause()
pageVisible = false
cameraUtils.unbind()
}
/**
* 初始化相机并绑定预览容器
* @param container 相机预览容器
*/
fun initCamera(container: ViewGroup) {
cameraUtils.initCamera()
val previewBinding = LayoutCameraPreviewBinding.inflate(layoutInflater, container)
cameraUtils.setPreviewController(previewBinding.previewView)
}
/**
* 注册返回键监听,替代已废弃的 onBackPressed()
*/
private fun addBackKeyListener() {
onBackPressedDispatcher.addCallback(this) {
if (SpTool.cookMode == 1) {
// 采集模式:有新增食材时提示保存
if (list.isEmpty().not()) {
saveDataRemindDialog()
return@addCallback
}
if (pageFrom == HOME) {
startActivity<SamplingListActivity>()
}
} else {
// 制作模式:有未保存数据时提示
val count = list.count { !it.isOriginalData }
if (count > 0) {
saveDataRemindDialog()
return@addCallback
}
}
finish()
}
}
/**
* 未保存数据提醒
*/
private fun saveDataRemindDialog() { private fun saveDataRemindDialog() {
val remindBinding = LayoutFoodRemindBinding.inflate(layoutInflater) CommonDialog(this)
remindBinding.tvDialogTitle.text = "温馨提示" .setTitle("返回提示")
remindBinding.tvDialogContent.text = "您好,当前页面存在未保存的数据,确认返回上页吗?" .setContent("您好,当前页面存在未保存的数据,\n确认返回吗?")
CommonDialog(this).apply { .setNegativeButton("取消")
messageView = remindBinding.root .setPositiveButton("确认") {
leftText = "取消" if (SpTool.cookMode == 1 && pageFrom == HOME) {
rightText = "确认" startActivity<SamplingListActivity>()
onLeftClick = { dismiss() } }
onRightClick = {
dismiss()
finish() finish()
} }
onDismiss = { hideStatusBar() } .setOnDismissCallback { hideStatusBar() }
}.show() .show()
}
/**
* 拍照成功回调
*/
private val cameraSuccessCallback: (Uri) -> Unit = { uri ->
Log.d(TAG, "takePhoto success")
lastPhotoUri = uri
lifecycleScope.launch {
withContext(Dispatchers.IO) {
val bitmap = ImageUtil.uriToBitmap(this@PrepareCookActivity, uri)
if (bitmap == null) {
dismissLoading()
Log.d(TAG, "takePhoto bitmap is null")
isTakingPhoto.set(false)
return@withContext
}
val foodScoreList = FoodModule.getFoodScoreList(bitmap)
if (foodScoreList.isEmpty()) {
dismissLoading()
Log.d(TAG, "takePhoto foodScoreList is empty")
isTakingPhoto.set(false)
return@withContext
}
withContext(Dispatchers.Main) {
queryFood(foodScoreList)
}
}
}
}
/**
* 拍照失败回调
*/
private val cameraFailureCallback: (String) -> Unit = { errMsg ->
Log.d(TAG, "takePhoto failure")
isTakingPhoto.set(false)
toast(errMsg)
dismissLoading()
}
/**
* 调用接口查询识别到的菜品
* @param list 食材列表
*/
private fun queryFood(list: List<FoodModule.IdNameScore>) {
Log.d(TAG, "takePhoto queryFood, list=$list")
//调用接口成功,返回食材列表设置isTakingPhoto.set(false),暂时写死数据
val foodList = mutableListOf<CookFoodGoodsEntity>()
list.forEachIndexed { index, food ->
Log.d(TAG, "takePhoto queryFood, index=${index},food=$food")
var foodName = ""
var foodId = ""
food.name.split("WP").let {
foodName = it[0]
foodId = if (it.size > 1) {
"WP${it[1]}"
} else {
food.name
}
}
foodList.add(
CookFoodGoodsEntity(
goodsId = foodId,
goodsName = foodName,
foodId = index.toString()
).also {
it.foodScore = ((1 - food.score) * 10000).toInt()
it.isOriginalData = false
}
)
}
Log.d(TAG, "takePhoto queryFood, 数据添加完成")
handler.postDelayed({
dismissLoading()
loadRecognizeResultPage(foodList)
}, 1000)
}
/**
* 加载识别结果页面
*/
private fun loadRecognizeResultPage(foodList: List<CookFoodGoodsEntity>) {
if (currentWeight < WEIGHT_RECOGNIZE_VALUE) {
Log.d(TAG, "takePhoto queryFood, 已取消")
toast("已取消")
isTakingPhoto.set(false)
return
}
lastWeight = currentWeight
FoodRecognizeActivity.start(
activity = this,
imageUri = lastPhotoUri?.toString(),
currentWeight = currentWeight,
goodsList = ArrayList(foodList),
finishCallback = { isManualCancel, entity ->
Log.d(TAG, "queryFood: isManualCancel=$isManualCancel, entity=${entity.toJsonString()}")
manualCancelFlag = isManualCancel
if (isManualCancel) {
return@start
}
entity?.let {
foodSelectCallback(it)
}
}
)
showRecognizePage = true
isTakingPhoto.set(false)
}
/**
* 跳转食物搜索页(采集模式专用)
* @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())
}
startActivity(intent) {
foodId = it?.getStringExtra(FoodSearchActivity.FOOD_ID)
val name = it?.getStringExtra(FoodSearchActivity.FOOD_NAME)
binding.etInputDish.run {
setText(name)
tag = name
setSelection(text.length)
}
}
KeyboardUtil.hideKeyboard(v)
} }
} }
@@ -4,7 +4,10 @@ import android.annotation.SuppressLint
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import androidx.lifecycle.ViewModelProvider import androidx.activity.addCallback
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.shuwei.dish.match.R import com.shuwei.dish.match.R
@@ -13,8 +16,7 @@ import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.base.BaseApp import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.databinding.ActivitySamplingBinding import com.shuwei.dish.match.databinding.ActivitySamplingBinding
import com.shuwei.dish.match.databinding.LayoutEmptyViewBinding import com.shuwei.dish.match.databinding.LayoutEmptyViewBinding
import com.shuwei.dish.match.db.AppRepository import com.shuwei.dish.match.dialog.CommonDialog
import com.shuwei.dish.match.dialog.DialogTool
import com.shuwei.dish.match.entity.CookFoodEntity import com.shuwei.dish.match.entity.CookFoodEntity
import com.shuwei.dish.match.entity.FoodRecord import com.shuwei.dish.match.entity.FoodRecord
import com.shuwei.dish.match.utils.DateTimeUtil import com.shuwei.dish.match.utils.DateTimeUtil
@@ -23,9 +25,9 @@ import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.startActivity import com.shuwei.dish.match.utils.ext.startActivity
import com.shuwei.dish.match.utils.ext.toast import com.shuwei.dish.match.utils.ext.toast
import com.shuwei.dish.match.utils.ext.visible import com.shuwei.dish.match.utils.ext.visible
import com.shuwei.dish.match.viewmodel.AppViewModel
import com.shuwei.dish.match.viewmodel.factory.AppFactory
import java.io.Serializable import java.io.Serializable
import kotlinx.coroutines.launch
import com.shuwei.dish.match.net.UiState
class SamplingListActivity : BaseActivity() { class SamplingListActivity : BaseActivity() {
@@ -43,10 +45,63 @@ class SamplingListActivity : BaseActivity() {
setContentView(binding.root) setContentView(binding.root)
setTitleBar() setTitleBar()
setHeaderBackground() setHeaderBackground()
initViewModel()
initRecyclerView() initRecyclerView()
addViewListener() addViewListener()
initObserver()
requestData(true) requestData(true)
onBackPressedDispatcher.addCallback(this) { }
}
/**
* 收集 cookFoodListState,将本地烹饪中数据渲染到列表
*/
private fun initObserver() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
appViewModel.cookFoodListState.collect { cookFoodEntities ->
delayDismissLoading()
if (cookFoodEntities.isNullOrEmpty()) {
loadEmptyView()
return@collect
}
val tempList = mutableListOf<FoodRecord>()
cookFoodEntities.sortedByDescending { DateTimeUtil.convert(dateStr = it.createTime) }
.forEach { entity ->
tempList.add(FoodRecord().apply {
foodId = entity.foodId
foodName = entity.foodName
cookMode = 1
isCooking = true
})
}
list.clear()
list.addAll(tempList)
dishAdapter.notifyDataSetChanged()
binding.refreshLayout.run {
setEnableRefresh(false)
setEnableLoadMore(false)
}
}
}
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
netViewModel.samplingListState.collect { state ->
when (state) {
is UiState.Loading -> showLoading()
is UiState.Success -> loadDishList(state.data)
is UiState.Error -> {
binding.refreshLayout.setEnableRefresh(true)
toast(state.msg)
finishRefresh()
delayDismissLoading()
if (pageNo == 1) loadEmptyView()
}
is UiState.Idle -> {}
}
}
}
}
} }
@@ -59,9 +114,7 @@ class SamplingListActivity : BaseActivity() {
it.visible() it.visible()
it.setImageResource(R.drawable.ic_setting) it.setImageResource(R.drawable.ic_setting)
it.setOnClickListener { it.setOnClickListener {
startActivity<DeviceConfigActivity> { startActivity<SettingActivity>()
putExtra(DeviceConfigActivity.COOK_MODE, 2)
}
} }
}, backAction = { }, backAction = {
it.gone() it.gone()
@@ -70,23 +123,20 @@ class SamplingListActivity : BaseActivity() {
} }
private lateinit var appViewModel: AppViewModel
private fun initViewModel() {
val db = BaseApp.instance!!.database
val factory = AppFactory(AppRepository(db.appDao()))
appViewModel = ViewModelProvider(this, factory)[AppViewModel::class.java]
}
fun getCookFoodList(action: (MutableList<CookFoodEntity>?) -> Unit) { fun getCookFoodList() {
appViewModel.getCookFoodList(cookMode = 1) { action(it) } appViewModel.getCookFoodList(cookMode = 1)
} }
fun deleteCookFoodAndGoods(foodId: String, action: () -> Unit) { fun deleteCookFoodAndGoods(foodId: String, action: () -> Unit) {
appViewModel.deleteCookFoodAndGoods(cookMode = 1, foodId = foodId, onFinish = action) lifecycleScope.launch {
appViewModel.deleteCookFoodAndGoods(cookMode = 1, foodId = foodId)
action()
}
} }
override fun onNewIntent(intent: Intent?) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
val isConfigPage = intent?.getBooleanExtra(IS_CONFIG_PAGE, false) ?: false val isConfigPage = intent?.getBooleanExtra(IS_CONFIG_PAGE, false) ?: false
if (isConfigPage) { if (isConfigPage) {
@@ -97,14 +147,6 @@ class SamplingListActivity : BaseActivity() {
refreshPage(isCooking) refreshPage(isCooking)
} }
@Deprecated("Deprecated in Java")
@SuppressLint("GestureBackNavigation", "MissingSuperCall")
override fun onBackPressed() {
//super.onBackPressed()
//当前页面不允许返回
//dialog = toast("不允许返回操作")
}
private var list: MutableList<FoodRecord> = mutableListOf() private var list: MutableList<FoodRecord> = mutableListOf()
private val dishAdapter by lazy { private val dishAdapter by lazy {
SamplingAdapter(list = list).apply { SamplingAdapter(list = list).apply {
@@ -128,8 +170,8 @@ class SamplingListActivity : BaseActivity() {
} }
return return
} }
startActivity<DishSamplingActivity> { startActivity<PrepareCookActivity> {
putExtra(DishSamplingActivity.FOOD_NAME, item.foodName) putExtra(PrepareCookActivity.FOOD_NAME, item.foodName)
} }
} }
@@ -162,7 +204,7 @@ class SamplingListActivity : BaseActivity() {
private fun addViewListener() { private fun addViewListener() {
binding.btnAddSampling.setOnClickListener { binding.btnAddSampling.setOnClickListener {
judgeDeviceConfig { judgeDeviceConfig {
startActivity<DishSamplingActivity>() startActivity<PrepareCookActivity>()
} }
} }
binding.refreshLayout.setOnRefreshListener { binding.refreshLayout.setOnRefreshListener {
@@ -198,53 +240,12 @@ class SamplingListActivity : BaseActivity() {
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
fun getSamplingList() { fun getSamplingList() {
showLoading()
// val map = mapOf(
//// "foodName" to input,
// "pageNo" to "$pageNo",
// "pageSize" to "$pageSize",
// "canteenId" to BaseApp.canteenId
// )
// val sb = StringBuilder(UrlConfig.SAMPLING_LIST).apply {
// append("?")
// map.forEach { (key, value) -> append("$key=$value&") }
// }
// sb.deleteCharAt(sb.length - 1)
// HttpUtil.get(
// url = sb.toString(),
// doSuccess = {
// loadDishList(it)
// }, doFailure = { code, msg ->
// binding.refreshLayout.run {
// setEnableRefresh(true)
// }
// toast(msg)
// finishRefresh()
// binding.refreshLayout.setEnableRefresh(true)
// delayDismissLoading()
// if (pageNo == 1) {
// loadEmptyView()
// }
// })
netViewModel.getSamplingList( netViewModel.getSamplingList(
param = mutableMapOf( param = mutableMapOf(
"pageNum" to pageNo, "pageNum" to pageNo,
"pageSize" to pageSize, "pageSize" to pageSize,
"placeId" to BaseApp.canteenId "placeId" to BaseApp.canteenId
), )
onSuccess = {
loadDishList(it)
},
onFailure = { code, msg ->
binding.refreshLayout.setEnableRefresh(true)
toast(msg)
finishRefresh()
delayDismissLoading()
if (pageNo == 1) {
loadEmptyView()
}
}
) )
} }
@@ -306,38 +307,18 @@ class SamplingListActivity : BaseActivity() {
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
fun refreshLocalData() { fun refreshLocalData() {
showLoading() showLoading()
//查询本地数据 appViewModel.resetCookFoodList()
getCookFoodList { cookFoodEntities -> getCookFoodList()
delayDismissLoading()
if (cookFoodEntities.isNullOrEmpty()) {
loadEmptyView()
return@getCookFoodList
}
val tempList = mutableListOf<FoodRecord>()
cookFoodEntities.sortedByDescending { DateTimeUtil.convert(dateStr = it.createTime) }
.forEach { entity ->
tempList.add(FoodRecord().apply {
foodId = entity.foodId
foodName = entity.foodName
cookMode = 1
isCooking = true
})
}
list.clear()
list.addAll(tempList)
dishAdapter.notifyDataSetChanged()
binding.refreshLayout.run {
setEnableRefresh(false)
setEnableLoadMore(false)
}
}
} }
private fun requestData(isCooking: Boolean) { private fun requestData(isCooking: Boolean) {
if (isCooking) { if (isCooking) {
// 清除上次网络请求的残留状态
netViewModel.resetSamplingListState()
refreshLocalData() refreshLocalData()
} else { } else {
// 同理,清除本地数据残留状态
appViewModel.resetCookFoodList()
pageNo = 1 pageNo = 1
getSamplingList() getSamplingList()
} }
@@ -375,10 +356,11 @@ class SamplingListActivity : BaseActivity() {
block() block()
return return
} }
appViewModel.loadSeasoning { lifecycleScope.launch {
if (it.isEmpty()) { val slots = appViewModel.loadSeasoningSlot()
if (slots.isEmpty()) {
showDeviceConfigDialog() showDeviceConfigDialog()
return@loadSeasoning return@launch
} }
configFinished = true configFinished = true
block() block()
@@ -386,18 +368,12 @@ class SamplingListActivity : BaseActivity() {
} }
private fun showDeviceConfigDialog() { private fun showDeviceConfigDialog() {
DialogTool.load( CommonDialog(this)
context = this, .setTitle("配置提示")
title = "温馨提示", .setContent("请先在设备配置页面进行调料设置")
content = "请先在设备配置页面进行调料设置", .setNegativeButton("取消")
leftBtnText = "取消", .setPositiveButton("去设置") { startActivity<SettingActivity>() }
rightBtnText = "去设置", .show()
leftBtnClick = {},
rightBtnClick = {
startActivity<DeviceConfigActivity> {
putExtra(DeviceConfigActivity.COOK_MODE, 2)
}
})
} }
private var isVisible = false private var isVisible = false
@@ -1,20 +1,15 @@
package com.shuwei.dish.match.ui package com.shuwei.dish.match.ui
import android.annotation.SuppressLint
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.viewModels import androidx.activity.addCallback
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import com.shuwei.dish.match.R import com.shuwei.dish.match.R
import com.shuwei.dish.match.base.BaseActivity import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.base.BaseApp import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.databinding.ActivitySelectDishBinding import com.shuwei.dish.match.databinding.ActivitySelectDishBinding
import com.shuwei.dish.match.db.AppRepository import com.shuwei.dish.match.dialog.CommonDialog
import com.shuwei.dish.match.dialog.DialogTool
import com.shuwei.dish.match.entity.CookFoodEntity
import com.shuwei.dish.match.entity.FoodRecord
import com.shuwei.dish.match.entity.FoodRecordBean
import com.shuwei.dish.match.net.NetViewModel
import com.shuwei.dish.match.ui.fragment.DishListFragment import com.shuwei.dish.match.ui.fragment.DishListFragment
import com.shuwei.dish.match.utils.KeyboardUtil import com.shuwei.dish.match.utils.KeyboardUtil
import com.shuwei.dish.match.utils.ext.addOnActionSearchListener import com.shuwei.dish.match.utils.ext.addOnActionSearchListener
@@ -22,9 +17,6 @@ import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.startActivity import com.shuwei.dish.match.utils.ext.startActivity
import com.shuwei.dish.match.utils.ext.toast import com.shuwei.dish.match.utils.ext.toast
import com.shuwei.dish.match.utils.ext.visible import com.shuwei.dish.match.utils.ext.visible
import com.shuwei.dish.match.viewmodel.AppViewModel
import com.shuwei.dish.match.viewmodel.factory.AppFactory
import kotlin.getValue
class SelectDishActivity : BaseActivity() { class SelectDishActivity : BaseActivity() {
@@ -36,8 +28,6 @@ class SelectDishActivity : BaseActivity() {
private lateinit var binding: ActivitySelectDishBinding private lateinit var binding: ActivitySelectDishBinding
private lateinit var appViewModel: AppViewModel
private var dinnerType: String = "1" private var dinnerType: String = "1"
private val fragmentList = mutableListOf<DishListFragment>().apply { private val fragmentList = mutableListOf<DishListFragment>().apply {
@@ -59,59 +49,46 @@ class SelectDishActivity : BaseActivity() {
it.visible() it.visible()
it.setImageResource(R.drawable.ic_setting) it.setImageResource(R.drawable.ic_setting)
it.setOnClickListener { it.setOnClickListener {
startActivity<DeviceConfigActivity> { startActivity<SettingActivity>()
putExtra(DeviceConfigActivity.COOK_MODE, 1)
}
} }
}, backAction = { }, backAction = {
it.gone() it.gone()
}) })
initViewModel()
addViewListener() addViewListener()
loadFragment() loadFragment()
onBackPressedDispatcher.addCallback(this) {}
} }
private fun initViewModel() {
val db = BaseApp.instance!!.database
val factory = AppFactory(AppRepository(db.appDao()))
appViewModel = ViewModelProvider(this, factory)[AppViewModel::class.java]
}
fun getCookFoodList(action: (MutableList<CookFoodEntity>?) -> Unit) { fun getCookFoodList() {
appViewModel.getCookFoodList(cookMode = 0, dinnerType = dinnerType) { action(it) } appViewModel.getCookFoodList(cookMode = 0, dinnerType = dinnerType)
} }
private var configFinished = false private var configFinished = false
fun judgeDeviceConfig(block: () -> Unit) { fun judgeDeviceConfig(block: () -> Unit) {
if (configFinished) { // if (configFinished) {
block() // block()
return // return
} // }
appViewModel.loadSeasoning { // appViewModel.loadSeasoning {
if (it.isEmpty()) { // if (it.isEmpty()) {
showDeviceConfigDialog() // showDeviceConfigDialog()
return@loadSeasoning // return@loadSeasoning
} // }
configFinished = true // configFinished = true
block() // block()
} // }
block()
} }
private fun showDeviceConfigDialog() { private fun showDeviceConfigDialog() {
DialogTool.load( CommonDialog(this)
context = this, .setTitle("配置提示")
title = "温馨提示", .setContent("请先在设备配置页面进行调料设置")
content = "请先在设备配置页面进行调料设置", .setNegativeButton("取消")
leftBtnText = "取消", .setPositiveButton("去设置") { startActivity<SettingActivity>() }
rightBtnText = "去设置", .show()
leftBtnClick = {},
rightBtnClick = {
startActivity<DeviceConfigActivity> {
putExtra(DeviceConfigActivity.COOK_MODE, 1)
}
}
)
} }
fun addViewListener() { fun addViewListener() {
@@ -143,7 +120,7 @@ class SelectDishActivity : BaseActivity() {
putExtra(FoodSearchActivity.DINNER_TYPE, dinnerType) putExtra(FoodSearchActivity.DINNER_TYPE, dinnerType)
putExtra(FoodSearchActivity.FOOD_NAME, searchText) putExtra(FoodSearchActivity.FOOD_NAME, searchText)
} }
KeyboardUtil.hideKeyboard(this, binding.etInputDish) KeyboardUtil.hideKeyboard(binding.etInputDish)
} }
private fun loadFragment() { private fun loadFragment() {
@@ -164,10 +141,13 @@ class SelectDishActivity : BaseActivity() {
} }
fun deleteCookFoodAndGoods(foodId: String, action: () -> Unit) { fun deleteCookFoodAndGoods(foodId: String, action: () -> Unit) {
appViewModel.deleteCookFoodAndGoods(cookMode = 0, foodId = foodId, onFinish = action) lifecycleScope.launch {
appViewModel.deleteCookFoodAndGoods(cookMode = 0, foodId = foodId)
action()
}
} }
override fun onNewIntent(intent: Intent?) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
runCatching { runCatching {
val isConfigPage = intent?.getBooleanExtra(IS_CONFIG_PAGE, false) ?: false val isConfigPage = intent?.getBooleanExtra(IS_CONFIG_PAGE, false) ?: false
@@ -189,14 +169,6 @@ class SelectDishActivity : BaseActivity() {
} }
} }
@Deprecated("Deprecated in Java")
@SuppressLint("GestureBackNavigation", "MissingSuperCall")
override fun onBackPressed() {
//super.onBackPressed()
//当前页面不允许返回
// dialog = toast("不允许返回操作")
}
private var isVisible = false private var isVisible = false
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
@@ -208,16 +180,8 @@ class SelectDishActivity : BaseActivity() {
isVisible = false isVisible = false
} }
public fun getFoodList( public fun getFoodList(param: MutableMap<String, Any>) {
param: MutableMap<String, Any>, netViewModel.searchFoodList(param = param)
onSuccess: (MutableList<FoodRecord>?) -> Unit,
onFailure: (String, String) -> Unit
) {
netViewModel.searchFoodList(
param = param,
onSuccess = onSuccess,
onFailure = onFailure
)
} }
} }
@@ -0,0 +1,324 @@
package com.shuwei.dish.match.ui
import android.Manifest
import android.annotation.SuppressLint
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings
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.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.visible
/**
* 设置Activity
* 功能:管理多个设置相关的Fragment,支持Fragment切换
*/
class SettingActivity : BaseActivity() {
companion object {
const val TAG = "SettingActivity"
}
private lateinit var binding: ActivitySettingBinding
// private val cameraUtils: CameraUtils by lazy {
// CameraUtils(this)
// }
// private lateinit var previewView: PreviewView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivitySettingBinding.inflate(layoutInflater)
setContentView(binding.root)
setHeaderBackground()
setTitleBar(titleBarAction = {
it.visible()
}, titleAction = {
it.text = "设备设置"
}, rightIconActon = {
it.visible()
it.clickWithDebounce {
startActivity<MasterScaleActivity>()
}
})
initView()
// // 默认显示设备配置Fragment
// if (savedInstanceState == null) {
// showDeviceConfigFragment()
// }
//
// initUI()
}
private fun initView() {
binding.tvCurrentMode.text = when (SpTool.cookMode) {
0 -> "当前:制作模式"
1 -> "当前:采样模式"
else -> "当前:未知模式"
}
binding.llCookMode.clickWithDebounce {
SingleFragmentActivity.start(this, SingleFragmentActivity.PageType.COOK_MODE)
}
binding.llSeasoningConfig.clickWithDebounce {
SingleFragmentActivity.start(this, SingleFragmentActivity.PageType.SEASONING_CONFIG)
}
binding.llFoodCollect.clickWithDebounce {
checkCameraPermission()
}
}
private fun openCollectPage() {
SingleFragmentActivity.start(this, SingleFragmentActivity.PageType.FOOD_COLLECT)
}
/**
* 检查相机权限并显示 CollectFragment
*/
@SuppressLint("ObsoleteSdkInt")
private fun checkCameraPermission() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
// Android 6.0 以下,直接打开
openCollectPage()
return
}
if (ContextCompat.checkSelfPermission(
this,
Manifest.permission.CAMERA
) == PackageManager.PERMISSION_GRANTED
) {
// 权限已授予,直接打开
openCollectPage()
} else {
// 权限未授予,申请权限
requestPermission(Manifest.permission.CAMERA) { isGranted ->
if (isGranted) {
// 权限已授予,直接打开
openCollectPage()
} else {
// 权限被拒绝
showNotGrantedCameraPermissionDialog()
}
}
}
}
private fun showNotGrantedCameraPermissionDialog() {
CommonDialog(this)
.setTitle("权限提示")
.setContent("暂无相机权限,无法使用食材采集功能,请确认是否同意开始权限?")
.setNegativeButton("不同意")
.setPositiveButton("同意") {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", packageName, null)
}
startActivity(intent) {
checkCameraPermission()
}
}
.show()
}
//
// /**
// * 初始化UI和事件监听
// */
// private fun initUI() {
// // 设备配置按钮点击事件
// binding.btnDeviceConfig.setOnClickListener {
// showDeviceConfigFragment()
//
// loadTabStyle(button = binding.btnDeviceConfig, isSelected = true)
// loadTabStyle(button = binding.btnSeasoningConfig, isSelected = false)
// loadTabStyle(button = binding.btnFoodCollect, isSelected = false)
// }
//
// // 调料配置按钮点击事件
// binding.btnSeasoningConfig.setOnClickListener {
// showSeasoningConfigFragment()
//
// loadTabStyle(button = binding.btnDeviceConfig, isSelected = false)
// loadTabStyle(button = binding.btnSeasoningConfig, isSelected = true)
// loadTabStyle(button = binding.btnFoodCollect, isSelected = false)
// }
//
// // 菜品采集按钮点击事件
// binding.btnFoodCollect.setOnClickListener {
// checkCameraPermissionAndShowFragment()
//
// loadTabStyle(button = binding.btnDeviceConfig, isSelected = false)
// loadTabStyle(button = binding.btnSeasoningConfig, isSelected = false)
// loadTabStyle(button = binding.btnFoodCollect, isSelected = true)
// }
//
// // 主设备显示「秤数据监控」入口
// if (GlobalData.deviceRole == DeviceRole.MASTER) {
// binding.btnScaleMonitor.visible()
// binding.btnScaleMonitor.setOnClickListener {
// startActivity<MasterScaleActivity>()
// }
// }
//
// // 初始化按钮颜色(默认设备配置为选中状态)
//
// loadTabStyle(button = binding.btnDeviceConfig, isSelected = true)
// loadTabStyle(button = binding.btnSeasoningConfig, isSelected = false)
// loadTabStyle(button = binding.btnFoodCollect, isSelected = false)
// }
//
// /**
// * 加载顶部Tab按钮样式
// *
// * @param button 按钮
// * @param isSelected 是否选中
// */
// private fun loadTabStyle(button: AppCompatButton, isSelected: Boolean) {
// button.apply {
// if (isSelected){
// // 选中
// setTextColor(Color.WHITE)
// textSize = 28f
// setTypeface(null, android.graphics.Typeface.BOLD)
// background = ContextCompat.getDrawable(this@SettingActivity, R.drawable.bg_btn_underline)
// } else {
// // 未选中
// setTextColor("#5E7585".toColorInt())
// textSize = 26f
// setTypeface(null, android.graphics.Typeface.NORMAL)
// background = null
// }
// }
// }
//
// /**
// * 检查相机权限并显示 CollectFragment
// */
// @SuppressLint("ObsoleteSdkInt")
// private fun checkCameraPermissionAndShowFragment() {
// val collectFragment = fragmentList[2]
// if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
// // Android 6.0 以下,直接显示 Fragment
// showFragment(collectFragment)
// return
// }
// if (ContextCompat.checkSelfPermission(
// this,
// Manifest.permission.CAMERA
// ) == PackageManager.PERMISSION_GRANTED
// ) {
// // 权限已授予,直接显示 Fragment
// showFragment(collectFragment)
// } else {
// // 权限未授予,申请权限
// requestPermission(Manifest.permission.CAMERA) { isGranted ->
// if (isGranted) {
// // 权限已授予,显示 CollectFragment
// showFragment(collectFragment)
// } else {
// // 权限被拒绝
// toast("暂无相机权限,无法使用菜品采集功能")
// }
// }
// }
// }
//
// /**
// * 初始化相机
// * @param container 相机预览容器
// */
// fun initCamera(container: ViewGroup) {
// cameraUtils.initCamera()
// val previewBinding =
// LayoutCameraPreviewBinding.inflate(layoutInflater, container)
// previewView = previewBinding.previewView.also {
// it.updateLayoutParams {
// width = 456.dp
// height = 342.dp
// }
// }
// cameraUtils.setPreviewController(previewView)
// }
//
// /**
// * Activity 恢复时绑定相机
// */
// override fun onResume() {
// super.onResume()
// cameraUtils.bind()
// }
//
// /**
// * Activity 暂停时解绑相机
// */
// override fun onPause() {
// super.onPause()
// cameraUtils.unbind()
// }
//
// /**
// * 提供给 Fragment 调用的拍照方法
// */
// fun takePhoto(succCallback: (Uri) -> Unit, failCallback: (msg: String) -> Unit = {}) {
// cameraUtils.takePhoto(succCallback = succCallback, failCallback = failCallback)
// }
//
// /**
// * 显示设备切换Fragment
// */
// private fun showDeviceConfigFragment() {
// showFragment(fragmentList[0])
// }
//
// /**
// * 显示设调料配置Fragment
// */
// private fun showSeasoningConfigFragment() {
// showFragment(fragmentList[1])
// }
//
// private val fragmentList = mutableListOf<Fragment>().apply {
// add(DeviceConfigFragment())
// add(SeasoningConfigFragment())
// add(CollectFragment())
// }
//
// /**
// * 显示指定的 Fragment
// * 使用 add/show/hide 方式管理 Fragment,避免重复创建
// * 已添加的 Fragment 直接显示,未添加的 Fragment 先添加再显示
// */
// private fun showFragment(fragment: Fragment) {
// runCatching {
// supportFragmentManager.beginTransaction().apply {
// // 如果 Fragment 还未添加到容器中,则添加
// if (!fragment.isAdded) {
// add(binding.fragmentContainer.id, fragment)
// } else {
// // 已添加过,直接显示
// show(fragment)
// }
//
// // 隐藏其他 Fragment
// fragmentList.forEach { otherFragment ->
// if (otherFragment != fragment && otherFragment.isAdded) {
// hide(otherFragment)
// }
// }
//
// commit()
// }
// }.onFailure {
// it.printStackTrace()
// }
// }
}
@@ -0,0 +1,121 @@
package com.shuwei.dish.match.ui
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.fragment.app.Fragment
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.ui.fragment.CollectFragment
import com.shuwei.dish.match.ui.fragment.DeviceConfigFragment
import com.shuwei.dish.match.ui.fragment.SeasoningConfigFragment
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.visible
/**
* 通用单 Fragment 容器 Activity
* 通过 [PageType] 决定加载哪个 Fragment,避免为每个配置/功能页面单独创建 Activity
*/
class SingleFragmentActivity : BaseActivity() {
interface LifecycleCallback {
fun onActivityResume()
fun onActivityPause()
}
/**
* 支持的页面类型枚举
* 新增页面时在此追加枚举值,并在 [createFragment] 中补充对应分支
*/
enum class PageType {
/** 菜品采集页 */
FOOD_COLLECT,
/** 设备配置页 */
COOK_MODE,
/** 调料配置页 */
SEASONING_CONFIG,
}
companion object {
private const val EXTRA_PAGE_TYPE = "extra_page_type"
/**
* 启动 SingleFragmentActivity
* @param context 调用方 Context
* @param pageType 要显示的页面类型
*/
fun start(context: Context, pageType: PageType) {
Intent(context, SingleFragmentActivity::class.java).apply {
putExtra(EXTRA_PAGE_TYPE, pageType.name)
context.startActivity(this)
}
}
}
private var lifecycleCallback: LifecycleCallback? = null
fun setLifecycleCallback(callback: LifecycleCallback) {
lifecycleCallback = callback
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHeaderBackground()
// 解析页面类型,非法值直接关闭
val typeName = intent.getStringExtra(EXTRA_PAGE_TYPE)
val pageType = typeName?.let {
runCatching { PageType.valueOf(it) }.getOrNull()
}
if (pageType == null) {
finish()
return
}
// 配置标题栏
setTitleBar(
titleBarAction = { it.visible() },
titleAction = { it.text = getTitleForPage(pageType) },
rightIconActon = { it.gone() }
)
// 首次创建时加载 Fragment,避免屏幕旋转重复添加
if (savedInstanceState == null) {
val fragment = createFragment(pageType)
supportFragmentManager.beginTransaction()
.replace(getFragmentContainerId(), fragment)
.commit()
}
}
/**
* 根据页面类型返回对应标题文本
*/
private fun getTitleForPage(pageType: PageType): String = when (pageType) {
PageType.FOOD_COLLECT -> "食材采集"
PageType.COOK_MODE -> "餐品模式"
PageType.SEASONING_CONFIG -> "调料区设置"
}
/**
* 根据页面类型创建对应 Fragment 实例
*/
private fun createFragment(pageType: PageType): Fragment = when (pageType) {
PageType.FOOD_COLLECT -> CollectFragment()
PageType.COOK_MODE -> DeviceConfigFragment()
PageType.SEASONING_CONFIG -> SeasoningConfigFragment()
}
override fun onResume() {
super.onResume()
lifecycleCallback?.onActivityResume()
}
override fun onPause() {
super.onPause()
lifecycleCallback?.onActivityPause()
}
}
@@ -0,0 +1,354 @@
package com.shuwei.dish.match.ui
import android.annotation.SuppressLint
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import androidx.core.view.updateLayoutParams
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
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.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.SizeTool
import com.shuwei.dish.match.utils.WeightUtil
import com.shuwei.dish.match.utils.ext.dp
import kotlinx.coroutines.launch
/**
* 子设备专属页面(小屏幕设备使用)
* 仅展示本机秤的实时数据和主设备连接状态
* 22个秤和18个秤使用与 MasterScaleActivity 相同的网格布局,其余使用线性列表
*/
class SlaveActivity : BaseActivity() {
private lateinit var binding: ActivitySlaveBinding
/** 本机秤数据列表:线性列表模式使用 */
private val linearAdapter = ScaleRowAdapter()
/** 22个秤网格 adapter */
private var scale22Adapter: Seasoning22GridAdapter? = null
/** 18个秤网格 adapter */
private var scale18Adapter: Seasoning18GridAdapter? = null
/** 已连接的主设备数量(有连接即代表主设备在线) */
private var masterConnected = false
/** 各秤地址的上一次重量快照,用于计算 delta */
private val weightSnapshot = mutableMapOf<Int, Double>()
/** key = addressvalue = 调料名称,用于注入 ScaleData.name,避免重量更新时覆盖名称 */
private val slotNameMap = mutableMapOf<Int, String>()
/** 主线程 Handler,用于延迟清除高亮 */
private val mainHandler = Handler(Looper.getMainLooper())
/** 当前高亮清除任务,新高亮触发时取消旧任务 */
private var clearHighlightRunnable: Runnable? = null
private val size by lazy { SizeTool.getFlexLayoutSize(10.dp) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivitySlaveBinding.inflate(layoutInflater)
setContentView(binding.root)
setHeaderBackground()
binding.tvDeviceId.text = "设备:${GlobalData.deviceId}"
binding.tvDeviceIp.text = "IP${getLocalIpAddress()}"
setupScaleList()
updateMasterStatus(ScaleServiceManager.isMasterConnected)
loadSlotsFromDb()
listenLocalScales()
listenMasterConnection()
listenSeasoningConfig()
addBackKeyListener()
}
/**
* 根据 deviceId 选择对应的布局方式:
* - 22个秤:FlexboxLayoutManager + Scale22GridAdapter
* - 18个秤:GridLayoutManager(6列) + Scale18GridAdapter
* - 其他:LinearLayoutManager + 线性列表
*/
private fun setupScaleList() {
binding.rvScaleList.itemAnimator = null
when (GlobalData.deviceId) {
ScaleDeviceConfig.DEVICE_ID_22 -> {
binding.rvScaleList.updateLayoutParams<LinearLayout.LayoutParams> {
height = size.rvHeight
weight = 0f
topMargin = 20.dp
}
binding.rvScaleList.layoutManager = FlexboxLayoutManager(this).apply {
flexDirection = FlexDirection.COLUMN
flexWrap = FlexWrap.WRAP
alignItems = AlignItems.FLEX_START
}
scale22Adapter = Seasoning22GridAdapter(
size.largeSize,
size.smallSize,
showNameOnly = false,
showAddress = true
).also {
it.onItemClick = { scale, _ -> showTareDialog(scale.address) }
}
binding.rvScaleList.adapter = scale22Adapter
}
ScaleDeviceConfig.DEVICE_ID_18 -> {
binding.rvScaleList.updateLayoutParams<LinearLayout.LayoutParams> { topMargin = 0 }
binding.rvScaleList.layoutManager = GridLayoutManager(this, 6).apply {
spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int) = 1
}
}
scale18Adapter = Seasoning18GridAdapter(showNameOnly = false, showAddress = true).also {
it.onItemClick = { scale, _ -> showTareDialog(scale.address) }
}
binding.rvScaleList.adapter = scale18Adapter
}
else -> {
binding.rvScaleList.updateLayoutParams<LinearLayout.LayoutParams> { topMargin = 20.dp }
binding.rvScaleList.layoutManager = LinearLayoutManager(this)
linearAdapter.setOnItemClickListener { _, _, position ->
val item = linearAdapter.items.getOrNull(position) ?: return@setOnItemClickListener
showTareDialog(item.address)
}
binding.rvScaleList.adapter = linearAdapter
}
}
}
/**
* 禁用返回键,防止退回 InitActivity 后重复打开本页
*/
private fun addBackKeyListener() {
onBackPressedDispatcher.addCallback(this) { }
}
/**
* 订阅主设备连接状态,回调在非主线程,切换到主线程更新 UI
*/
private fun listenMasterConnection() {
ScaleServiceManager.onMasterConnectionChanged = { connected ->
runOnUiThread { updateMasterStatus(connected) }
}
}
/**
* 从本地 Room 加载本机设备的槽位配置并更新 adapter
* 主设备未开机时也能正常展示上次配置
*/
private fun loadSlotsFromDb() {
lifecycleScope.launch {
val slots = appViewModel.getSeasoningSlotsByDeviceId(GlobalData.deviceId)
applySlots(slots)
}
}
/**
* 监听主设备下发的调料配置同步事件
* 收到后以全量替换方式更新本机 DB(先删后插),再刷新 UI
*/
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 {
// 全量替换:先删除本机所有旧槽位,再写入最新配置
appViewModel.deleteAllSlotsByDeviceId(GlobalData.deviceId)
appViewModel.upsertAllSeasoningSlots(mySlots)
applySlots(mySlots)
}
}
}
/**
* 将槽位配置应用到对应 adapter(按地址匹配格子位置)
* 同时以传入列表为准全量刷新 slotNameMap
* - 新列表中存在的地址:更新名称
* - 新列表中不存在的地址:从 slotNameMap 移除,并清空对应格子名称
*/
private fun applySlots(slots: List<SeasoningSlotEntity>) {
val newAddresses = slots.map { it.address }.toSet()
// 清除已被删除的槽位:从 slotNameMap 移除并清空 adapter 对应格子
val removedAddresses = slotNameMap.keys.filter { it !in newAddresses }
removedAddresses.forEach { addr ->
slotNameMap.remove(addr)
when {
scale22Adapter != null -> {
val pos = ScaleDeviceConfig.SCALE_ORDER_22.indexOf(addr)
if (pos >= 0) scale22Adapter!!.updateItemName(pos, "")
}
scale18Adapter != null -> {
val pos = ScaleDeviceConfig.SCALE_ORDER_18.indexOf(addr)
if (pos >= 0) scale18Adapter!!.updateItemName(pos, "")
}
else -> {
val list = linearAdapter.items.toMutableList()
val idx = list.indexOfFirst { it.address == addr }
if (idx >= 0) {
list[idx] = list[idx].copy(name = "")
linearAdapter.submitList(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)
}
else -> {
val list = linearAdapter.items.toMutableList()
val idx = list.indexOfFirst { it.address == slot.address }
if (idx >= 0) {
list[idx] = list[idx].copy(name = slot.goodsName)
linearAdapter.submitList(list)
}
}
}
}
}
/**
* 监听本机 WeightUtil 回调,实时刷新秤列表
* 22/18个秤走网格 adapter,其余走线性列表
* 秤稳定且重量增加超过阈值时,高亮对应格子,15秒后自动恢复
*/
private fun listenLocalScales() {
WeightUtil.addWeightListener(TAG) { address, state, weight ->
// 重量快照与高亮判断(在回调线程计算,不涉及 UI)
val baseline = weightSnapshot[address]
val shouldHighlight = baseline != null
&& state == WeightUtil.STATE_STABLE
&& (weight - baseline) > WEIGHT_DELTA_THRESHOLD
if (baseline == null || state == WeightUtil.STATE_STABLE) {
weightSnapshot[address] = weight
}
runOnUiThread {
when {
scale22Adapter != null -> {
// 用最新数据更新缓存后整体刷新网格
val existing = scale22Adapter!!.items.toMutableList()
val idx = existing.indexOfFirst { it.address == address }
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(), name = slotNameMap[address])
if (idx >= 0) existing[idx] = newData else existing.add(newData)
scale18Adapter!!.updateByAddress(existing)
}
else -> {
val item = ScaleRowAdapter.ScaleItem(address, weight, state, slotNameMap[address] ?: "")
val list = linearAdapter.items.toMutableList()
val idx = list.indexOfFirst { it.address == address }
if (idx >= 0) {
list[idx] = item
} else {
list.add(item)
list.sortBy { it.address }
}
linearAdapter.submitList(list)
}
}
// 触发格子高亮
if (shouldHighlight) {
clearHighlightRunnable?.let { mainHandler.removeCallbacks(it) }
val pos22 = ScaleDeviceConfig.SCALE_ORDER_22.indexOf(address)
val pos18 = ScaleDeviceConfig.SCALE_ORDER_18.indexOf(address)
scale22Adapter?.highlightPosition(pos22)
scale18Adapter?.highlightPosition(pos18)
clearHighlightRunnable = Runnable {
scale22Adapter?.clearHighlight()
scale18Adapter?.clearHighlight()
}.also { mainHandler.postDelayed(it, HIGHLIGHT_DURATION_MS) }
}
}
}
}
/**
* 更新主设备连接状态文字
*/
@SuppressLint("SetTextI18n")
fun updateMasterStatus(connected: Boolean) {
masterConnected = connected
binding.tvMasterStatus.text = if (connected) "主设备:已连接 ✓" else "主设备:未连接"
binding.tvMasterStatus.setTextColor(
getColor(if (connected) android.R.color.holo_green_dark else android.R.color.holo_red_light)
)
}
override fun onDestroy() {
ScaleServiceManager.onMasterConnectionChanged = null
ScaleServiceManager.onSeasoningConfig = null
WeightUtil.removeWeightListener(TAG)
clearHighlightRunnable?.let { mainHandler.removeCallbacks(it) }
super.onDestroy()
}
companion object {
const val TAG = "SlaveActivity"
/** 重量增加阈值(克),超过此值才触发高亮 */
private const val WEIGHT_DELTA_THRESHOLD = 10.0
/** 高亮自动恢复时间(毫秒) */
private const val HIGHLIGHT_DURATION_MS = 5_000L
}
/**
* 弹出清零确认对话框
* @param address 目标秤地址
*/
private fun showTareDialog(address: Int) {
CommonDialog(this)
.setTitle("清零确认")
.setContent("确认对秤 $address 执行清零操作?")
.setNegativeButton("取消")
.setPositiveButton("确认") {
WeightUtil.tareTwo(address)
}
.show()
}
/**
* 获取本机局域网 IP,委托给 NetworkUtil 统一处理版本兼容
*/
private fun getLocalIpAddress(): String {
return NetworkUtil.getLocalIpAddress(this).ifEmpty { "未知" }
}
}
@@ -1,40 +1,48 @@
package com.shuwei.dish.match.ui package com.shuwei.dish.match.ui
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.graphics.Color
import android.graphics.Typeface
import android.os.Bundle import android.os.Bundle
import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan
import android.util.Log import android.util.Log
import android.util.SparseArray import androidx.activity.addCallback
import android.util.SparseIntArray import androidx.lifecycle.Lifecycle
import android.widget.FrameLayout import androidx.lifecycle.lifecycleScope
import androidx.activity.viewModels import androidx.lifecycle.repeatOnLifecycle
import androidx.core.util.forEach import androidx.recyclerview.widget.GridLayoutManager
import androidx.lifecycle.ViewModelProvider
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import com.shuwei.dish.match.R import com.shuwei.dish.match.R
import com.shuwei.dish.match.adapter.TextCellAdapter import com.shuwei.dish.match.adapter.SeasoningWeightAdapter
import com.shuwei.dish.match.base.BaseActivity import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.base.BaseApp import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.databinding.ActivitySubmitFoodBinding import com.shuwei.dish.match.databinding.ActivitySubmitFoodBinding
import com.shuwei.dish.match.db.AppRepository import com.shuwei.dish.match.dialog.CommonDialog
import com.shuwei.dish.match.entity.CookFoodEntity import com.shuwei.dish.match.entity.CookFoodEntity
import com.shuwei.dish.match.entity.CookFoodGoodsEntity import com.shuwei.dish.match.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.entity.FoodRecord import com.shuwei.dish.match.entity.FoodRecord
import com.shuwei.dish.match.entity.SeasoningEntity
import com.shuwei.dish.match.net.NetViewModel import com.shuwei.dish.match.net.NetViewModel
import com.shuwei.dish.match.net.UiState
import com.shuwei.dish.match.scale.ScaleDeviceConfig
import com.shuwei.dish.match.scale.ScaleServiceManager
import com.shuwei.dish.match.utils.AddressUtil import com.shuwei.dish.match.utils.AddressUtil
import com.shuwei.dish.match.utils.WeightUtil import com.shuwei.dish.match.utils.WeightUtil
import com.shuwei.dish.match.utils.ext.appendText
import com.shuwei.dish.match.utils.ext.buildSpannableString
import com.shuwei.dish.match.utils.ext.clickWithDebounce import com.shuwei.dish.match.utils.ext.clickWithDebounce
import com.shuwei.dish.match.utils.ext.gone import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.roundedDecimalPlace
import com.shuwei.dish.match.utils.ext.roundedOneDecimalPlace import com.shuwei.dish.match.utils.ext.roundedOneDecimalPlace
import com.shuwei.dish.match.utils.ext.startActivity import com.shuwei.dish.match.utils.ext.startActivity
import com.shuwei.dish.match.utils.ext.toJsonString import com.shuwei.dish.match.utils.ext.toJsonString
import com.shuwei.dish.match.utils.ext.toType import com.shuwei.dish.match.utils.ext.toType
import com.shuwei.dish.match.utils.ext.toast import com.shuwei.dish.match.utils.ext.toast
import com.shuwei.dish.match.utils.ext.visible import com.shuwei.dish.match.utils.ext.visible
import com.shuwei.dish.match.viewmodel.AppViewModel import kotlinx.coroutines.Dispatchers
import com.shuwei.dish.match.viewmodel.factory.AppFactory import kotlinx.coroutines.flow.collectLatest
import kotlin.getValue import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@SuppressLint("UseSparseArrays") @SuppressLint("UseSparseArrays")
class SubmitFoodActivity : BaseActivity() { class SubmitFoodActivity : BaseActivity() {
@@ -50,8 +58,6 @@ class SubmitFoodActivity : BaseActivity() {
private var food: FoodRecord? = null private var food: FoodRecord? = null
private var goodsList: MutableList<CookFoodGoodsEntity>? = null private var goodsList: MutableList<CookFoodGoodsEntity>? = null
private var isCooking = false
private val cookFoodEntity by lazy { private val cookFoodEntity by lazy {
CookFoodEntity().apply { CookFoodEntity().apply {
food?.let { food?.let {
@@ -64,23 +70,46 @@ class SubmitFoodActivity : BaseActivity() {
} }
} }
/** 调料列表 adapter */
private val seasoningAdapter = SeasoningWeightAdapter()
/**
* 各秤的初始重量快照,key = "deviceId#address"
* 首次收到稳定数据时记录,之后用于计算用量差值
*/
private val baselineMap = mutableMapOf<String, Double>()
/**
* 槽位配置:key = "deviceId#address"value = goodsId to goodsName
* 从 Room dm_seasoning_slot 加载
*/
private val slotMap = mutableMapOf<String, Pair<String, String>>()
/**
* 各秤原始用量,key = "deviceId#address"value = Triple(goodsId, goodsName, useWeight)
* 每次秤数据变化后更新,再按 goodsName 聚合推给 adapter
*/
private val rawWeightMap = mutableMapOf<String, Triple<String, String, Double>>()
/**
* isCooking=true 时从 goodsList 预填充的历史用量,key = goodsNamevalue = goodsId to useWeight
* 用于 refreshAdapterByName 时叠加到秤新增用量上
*/
private val baseSeasoningMap = mutableMapOf<String, Pair<String, Double>>()
@Suppress("unchecked_cast", "DEPRECATION") @Suppress("unchecked_cast", "DEPRECATION")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
binding = ActivitySubmitFoodBinding.inflate(layoutInflater) binding = ActivitySubmitFoodBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
initViewModel()
setHeaderBackground() setHeaderBackground()
intent.extras?.apply { intent.extras?.apply {
food = getSerializable(FOOD_ITEM) as FoodRecord? food = getSerializable(FOOD_ITEM) as FoodRecord?
Log.d(TAG, "onCreate: cookMode=${food?.cookMode}") Log.d(TAG, "onCreate: cookMode=${food?.cookMode}")
isCooking = food?.isCooking ?: false // isCooking=false:从前一页面带入主辅材数据;isCooking=true:从数据库查询
if (isCooking) { if (food?.isCooking == false) {
//烹饪中,从本地查询数据
loadSeasoningFromLocal()
} else {
//非烹饪中,从菜品主辅材配置页面带入数据
goodsList = getSerializable(GOODS_LIST) as MutableList<CookFoodGoodsEntity>? goodsList = getSerializable(GOODS_LIST) as MutableList<CookFoodGoodsEntity>?
Log.d(TAG, "onCreate: goodsList=${goodsList.toJsonString()}")
} }
} }
setTitleBar(titleBarAction = { setTitleBar(titleBarAction = {
@@ -89,197 +118,204 @@ class SubmitFoodActivity : BaseActivity() {
it.text = food?.foodName it.text = food?.foodName
}, rightIconActon = { }, rightIconActon = {
it.gone() it.gone()
it.setImageResource(R.drawable.ic_setting) }, backAction = {
it.setOnClickListener { it.setOnClickListener {
//startActivity<DeviceSettingActivity>() remindSaveDataDialog()
} }
}) })
setupRecyclerView()
addViewListener() addViewListener()
initObserver()
loadSlotsThenObserveScales()
onBackPressedDispatcher.addCallback(this) {
remindSaveDataDialog()
}
} }
private fun loadSeasoningFromLocal() { /**
appViewModel.getCookFoodGoodsList( * 收集 submitCookFoodState,处理提交成功/失败
foodId = food!!.foodId!!, */
cookMode = food!!.cookMode private fun initObserver() {
) { it -> lifecycleScope.launch {
goodsList = mutableListOf() repeatOnLifecycle(Lifecycle.State.STARTED) {
val goodsIdList = mutableListOf<String>() netViewModel.submitCookFoodState.collect { state ->
//设置主辅材数据 when (state) {
val tempList = it.filter { goods -> goods.materialType != 3 } is UiState.Success -> submitSuccess(food?.cookMode == 1)
tempList.forEach { is UiState.Error -> {
if (!goodsIdList.contains(it.goodsId)) { toast(state.msg)
goodsList?.add(it) dismissLoading()
goodsIdList.add(it.goodsId) }
}
}
//获取调料数据并展示 else -> {}
val filterList: List<SeasoningEntity>? = it.filter { goods -> goods.materialType == 3 }
.map { entity ->
SeasoningEntity().also { se ->
se.goodsId = entity.goodsId
se.goodsName = entity.goodsName
se.goodsOrRelationCode = entity.goodsOrRelationCode
se.relateionType = entity.relateionType
se.materialType = entity.materialType
se.allEdible = entity.allEdible
se.useWeight = entity.useWeight
se.popularName = entity.popularName
se.canteenId = entity.canteenId
se.relateionType_dictText = entity.relateionType_dictText
se.foodId = entity.foodId
se.sort = entity.sort
se.pageType = 0
} }
} }
filterList?.toMutableList()?.let { cellData ->
loadTextCell(cellData)
} }
} }
} }
/** 初始化 RecyclerView2列 GridLayoutManager */
// private lateinit var seasoningList: List<SeasoningEntity> private fun setupRecyclerView() {
// private var isFirstSave = true binding.rvSeasoning.itemAnimator = null
// fun updateSeasoningList(list: MutableList<SeasoningEntity>) { binding.rvSeasoning.layoutManager = GridLayoutManager(this, 2)
// if (isFirstSave) { binding.rvSeasoning.adapter = seasoningAdapter
// val json = list.toJsonString()
// val typeToken = object : TypeToken<List<SeasoningEntity>>() {}
// seasoningList = json.toType(typeToken = typeToken)
// isFirstSave = false
// }
// }
private val seasoningArray by lazy {
SparseArray<Double>()
} }
/**
* 从 Room 加载槽位配置后,开始订阅子设备秤数据
* isCooking=true 时先从数据库查询 goodsList,筛选调料预填充 adapter,再开始观测
*/
private fun loadSlotsThenObserveScales() {
lifecycleScope.launch {
val slots = appViewModel.loadSeasoningSlot()
slots.forEach { slot ->
slotMap["${slot.deviceId}#${slot.address}"] = slot.goodsId to slot.goodsName
}
if (food?.isCooking == true) {
// 制作中:从数据库查询 goodsList,筛选调料数据预填充 adapter
val foodId = food?.foodId ?: return@launch
val cookMode = food?.cookMode ?: return@launch
val list = appViewModel.getCookFoodGoodsList(foodId, cookMode)
goodsList = list
list.filter { it.materialType == 3 }.forEach { item ->
val name = item.goodsName ?: ""
val weight = item.useWeight ?: 0.0
baseSeasoningMap[name] = (item.goodsId) to weight
seasoningAdapter.updateItem(
SeasoningWeightAdapter.Item(
goodsId = item.goodsId,
goodsName = name,
useWeight = weight
)
)
}
observeScaleData()
} else {
observeScaleData()
}
}
}
private val firstGoodsArray = SparseArray<Double>() /**
* 订阅 ScaleServiceManager.allScales,过滤子设备数据并更新 adapter
* 排除主设备(DEVICE_ID_2),只处理 22格、18格、1格子设备
*/
private fun observeScaleData() {
val flow = ScaleServiceManager.allScales ?: return
lifecycleScope.launch {
flow.collectLatest { scaleMap ->
scaleMap.forEach { (key, data) ->
// 排除主设备 2格秤
if (data.deviceId == ScaleDeviceConfig.DEVICE_ID_2) return@forEach
// 从槽位配置读取调料名,未配置则跳过
val (goodsId, goodsName) = slotMap[key] ?: return@forEach
// 首次收到稳定数据时记录基准重量
val baseline = baselineMap[key]
if (baseline == null) {
if (data.state == WeightUtil.STATE_STABLE) {
baselineMap[key] = data.weight
}
return@forEach
}
// 用量 = 初始重量 - 当前重量,负值归零
val useWeight = (baseline - data.weight)
.coerceAtLeast(0.0)
.roundedOneDecimalPlace()
// 更新原始用量 map,再按 goodsName 聚合刷新 adapter
if (useWeight < 0.5) {
rawWeightMap.remove(key)
} else {
rawWeightMap[key] = Triple(goodsId, goodsName, useWeight)
}
refreshAdapterByName(goodsName)
}
}
}
}
/**
* 按 goodsName 聚合 rawWeightMap,叠加历史用量后推给 adapter
* isCooking=true 时只新增,不移除(历史调料数据不可减少)
*/
private fun refreshAdapterByName(goodsName: String) {
val grouped = rawWeightMap.values.filter { it.second == goodsName }
val newWeight = grouped.sumOf { it.third }.roundedOneDecimalPlace()
val (baseGoodsId, baseWeight) = baseSeasoningMap[goodsName] ?: ("" to 0.0)
val totalWeight = (newWeight + baseWeight).roundedOneDecimalPlace()
if (totalWeight < 0.5) {
// 制作中模式:已有历史数据,不允许移除
if (food?.isCooking != true) {
seasoningAdapter.removeItem(goodsName)
}
return
}
val goodsId = grouped.firstOrNull()?.first?.takeIf { it.isNotEmpty() } ?: baseGoodsId
seasoningAdapter.updateItem(
SeasoningWeightAdapter.Item(
goodsId = goodsId,
goodsName = goodsName,
useWeight = totalWeight
)
)
}
private fun addViewListener() { private fun addViewListener() {
addWeightListener()
binding.btnWeightClear.setOnClickListener { WeightUtil.tareTwo(AddressUtil.TWO) }
binding.btnCook.clickWithDebounce { binding.btnCook.clickWithDebounce {
cook() val content = getRemindSpannable("开始制作")
showRemindDialog(content) { cook() }
} }
binding.btnWeightClear.clickWithDebounce { WeightUtil.tareTwo(2) }
binding.btnSubmit.clickWithDebounce { binding.btnSubmit.clickWithDebounce {
if (cookFoodEntity.foodWeight <= 0.toDouble()) { if (cookFoodEntity.foodWeight <= 0.toDouble()) {
toast("未识别到菜品熟重") toast("未识别到菜品熟重")
return@clickWithDebounce return@clickWithDebounce
} }
submit() val content = getRemindSpannable("制作完成")
showRemindDialog(content) { submit() }
} }
}
private fun addWeightListener() { // 监听主设备 2格秤的熟重
WeightUtil.addWeightListener( WeightUtil.addWeightListener(TAG) { address, _, weight ->
weightKey = TAG, if (address == AddressUtil.TWO) {
getWeight = { address, state, weight -> cookFoodEntity.foodWeight = weight
if (address == AddressUtil.TWO) { if (weight > 1000) {
cookFoodEntity.foodWeight = weight.toDouble() binding.tvTotalWeight.text = "${(weight / 1000f).roundedDecimalPlace(3)}"
binding.tvTotalWeight.text = "${weight / 1000f}" binding.tvWeightUnit.text = "千克"
} else {
binding.tvTotalWeight.text = "$weight"
binding.tvWeightUnit.text = ""
} }
val firstWeight = firstGoodsArray.get(address, null)
if (firstWeight == null) {
//未设置数据
if (state != WeightUtil.STATE_STABLE) {
//首次记录数据需要稳定数据
return@addWeightListener
}
firstGoodsArray.put(address, weight)
return@addWeightListener
}
var realUseWeight = firstWeight - weight
realUseWeight = if (realUseWeight > 0) realUseWeight else 0.0
seasoningArray.put(address, realUseWeight.toDouble())
// refreshSeasoningWeight()
val item = seasoningItems.firstOrNull { address == addressArray[it.sort] }
item?.let {
val lastWeight = seasoningArray.get(address) ?: 0.toDouble()
it.useWeight =
(lastWeight + getCookingSeasoning(address)).roundedOneDecimalPlace()
updateGridData(it)
}
})
}
// private fun refreshSeasoningWeight() {
// if (seasoningItems.isEmpty()) {
// return
// }
// seasoningItems.let { items ->
// addressArray.forEach { key, value ->
// items[key].useWeight =
// (seasoningArray.get(value) ?: 0.toDouble()) + getCookingSeasoning(value)
// }
// }
// setGridData(seasoningItems)
// }
private fun updateGridData(entity: SeasoningEntity) {
val gridLayout = binding.include.root
val tag = entity.sort.toString()
val frameLayout = gridLayout.findViewWithTag<FrameLayout>(tag)
TextCellAdapter.loadLayout(frameLayout, entity)
}
private fun getCookingSeasoning(address: Int): Double {
if (isCooking.not()) return 0.toDouble()
if (seasoningItems.isEmpty()) return 0.toDouble()
val weight =
seasoningCookingItems?.get(weightRelateArray2.get(address))?.useWeight ?: 0.toDouble()
return weight
}
private val weightRelateArray2 by lazy {
SparseIntArray().apply {
addressArray.forEach { key, value ->
put(value, key)
} }
} }
} }
private val addressArray by lazy {
AddressUtil.getWeighAddressArray()
}
// private fun prepareData(acton: () -> Unit) {
//
// //只有制作和采样列表页面才能进入设置页面,提交页面无法进行设置,不存在调料数据被修改的问题
// val seasoningJson = realSeasoningData.toJsonString()
// val typeToken = object : TypeToken<List<CookFoodGoodsEntity>>() {}
// val tempSeasoningList = seasoningJson.toType(typeToken = typeToken)
// goodsList?.addAll(tempSeasoningList)
// acton()
//
// }
private fun cook() { private fun cook() {
if (food == null || goodsList.isNullOrEmpty()) { if (food == null || goodsList.isNullOrEmpty()) {
toast("未获取到菜品或构成信息") toast("未获取到菜品或构成信息")
return return
} }
val realSeasoningData = seasoningItems val seasoningData = seasoningAdapter.items
//(binding.rvSeasoning.adapter as TextCellAdapter).list // if (seasoningData.isEmpty()) {
if (realSeasoningData.isEmpty() && !isCooking) { // toast("未获取到调料信息")
toast("未获取到调料信息") // return
return // }
}
if (realSeasoningData.isNotEmpty() && realSeasoningData[0].goodsId.isNullOrBlank()) {
toast("还未设置调料信息,请去设置页面操作")
return
}
showLoading() showLoading()
//只有制作和采样列表页面才能进入设置页面,提交页面无法进行设置,不存在调料数据被修改的问题
val seasoningJson = realSeasoningData.toJsonString() // 将调料数据转为 CookFoodGoodsEntity 追加到 goodsList
val typeToken = object : TypeToken<List<CookFoodGoodsEntity>>() {} // val typeToken = object : TypeToken<List<CookFoodGoodsEntity>>() {}
val tempSeasoningList = seasoningJson.toType(typeToken = typeToken) val tempSeasoningList = seasoningData.map { item ->
CookFoodGoodsEntity().also { entity ->
entity.goodsId = item.goodsId
entity.goodsName = item.goodsName
entity.useWeight = item.useWeight
entity.materialType = 3
}
}
goodsList?.addAll(tempSeasoningList) goodsList?.addAll(tempSeasoningList)
cookFoodEntity.let { cookFoodEntity.let {
it.dinnerType = when (it.dinnerType) { it.dinnerType = when (it.dinnerType) {
"早餐" -> "1" "早餐" -> "1"
@@ -288,27 +324,23 @@ class SubmitFoodActivity : BaseActivity() {
else -> it.dinnerType else -> it.dinnerType
} }
} }
val isSamplingData = food!!.cookMode == 1 val isSamplingData = food!!.cookMode == 1
if (isSamplingData) { if (isSamplingData) {
//生成临时foodId,解决采样模式没有foodId,无法关联数据的问题
val tempFoodId = cookFoodEntity.foodId.ifBlank { "${System.currentTimeMillis()}" } val tempFoodId = cookFoodEntity.foodId.ifBlank { "${System.currentTimeMillis()}" }
cookFoodEntity.foodId = tempFoodId cookFoodEntity.foodId = tempFoodId
} }
//全部设置同一foodId goodsList?.forEach { it.foodId = cookFoodEntity.foodId }
goodsList?.forEach {
it.foodId = cookFoodEntity.foodId lifecycleScope.launch {
appViewModel.saveCookFoodAndGoods(
cookMode = food!!.cookMode,
entity = cookFoodEntity,
list = goodsList
)
dismissLoading()
jumpPage(isSamplingData, true)
} }
//保存菜品-保存主辅料调料
appViewModel.saveCookFoodAndGoods(
cookMode = food!!.cookMode,
entity = cookFoodEntity,
list = goodsList,
onFinish = {
runOnUiThread {
dismissLoading()
jumpPage(isSamplingData, true)
}
})
} }
private fun submit() { private fun submit() {
@@ -316,31 +348,22 @@ class SubmitFoodActivity : BaseActivity() {
toast("未获取到菜品或构成信息") toast("未获取到菜品或构成信息")
return return
} }
val realSeasoningData = seasoningItems val seasoningData = seasoningAdapter.items
if (realSeasoningData.isEmpty() && !isCooking) {
toast("未获取到调料信息")
return
}
if (realSeasoningData.isNotEmpty() && realSeasoningData[0].goodsId.isNullOrBlank()) {
toast("还未设置调料信息,请去设置页面操作")
return
}
showLoading() showLoading()
//只有制作和采样列表页面才能进入设置页面,提交页面无法进行设置,不存在调料数据被修改的问题
val seasoningJson = realSeasoningData.toJsonString() // 将重量大于0且不重复的调料追加到 goodsList
val typeToken = object : TypeToken<List<CookFoodGoodsEntity>>() {} seasoningData.forEach { item ->
val tempSeasoningList = seasoningJson.toType(typeToken = typeToken) if (item.useWeight > 0.0 && goodsList?.none { it.goodsId == item.goodsId } == true) {
tempSeasoningList.forEach { entity -> goodsList?.add(CookFoodGoodsEntity().also { entity ->
val realWeight = entity.useWeight ?: 0.toDouble() entity.goodsId = item.goodsId
val filterData = goodsList?.firstOrNull { it.goodsId == entity.goodsId } entity.goodsName = item.goodsName
//确保重量大于0,且无重复数据 entity.useWeight = item.useWeight
if (realWeight > 0.toDouble() && filterData == null) { entity.materialType = 3
goodsList?.add(entity) })
} }
} }
cookFoodEntity.let { cookFoodEntity.let {
// it.stFoodInfoConstituteList = this@SubmitFoodActivity.goodsList
it.matchingConstituteInfoList = this@SubmitFoodActivity.goodsList it.matchingConstituteInfoList = this@SubmitFoodActivity.goodsList
it.dinnerType = when (it.dinnerType) { it.dinnerType = when (it.dinnerType) {
"1" -> "早餐" "1" -> "早餐"
@@ -352,47 +375,25 @@ class SubmitFoodActivity : BaseActivity() {
val isSamplingData = food!!.cookMode == 1 val isSamplingData = food!!.cookMode == 1
if (isSamplingData) { if (isSamplingData) {
//采样模式没有foodId
cookFoodEntity.foodId = "" cookFoodEntity.foodId = ""
goodsList?.forEach { it.foodId = "" } goodsList?.forEach { it.foodId = "" }
} }
val json = cookFoodEntity.toJsonString()
Log.d(TAG, "submit: json=$json")
// HttpUtil.postJson(
// url = UrlConfig.SUBMIT_DISH,
// json = json,
// doSuccess = {
// Log.d(TAG, "submit: postJson=$it")
// submitSuccess(isSamplingData)
// }, doFailure = { code, msg ->
// Log.d(TAG, "submit: postJson:code=$code,msg=$msg")
// toast(msg)
// dismissLoading()
// })
netViewModel.submitCookFood( Log.d(TAG, "submit: json=${cookFoodEntity.toJsonString()}")
entity = cookFoodEntity, netViewModel.submitCookFood(entity = cookFoodEntity)
onSuccess = {
submitSuccess(isSamplingData)
},
onFailure = { code, msg ->
toast(msg)
dismissLoading()
})
} }
private fun submitSuccess(isSamplingData: Boolean) { private fun submitSuccess(isSamplingData: Boolean) {
if (food?.foodId.isNullOrBlank()) { if (food?.foodId.isNullOrBlank()) {
//本地无删除数据直接跳转页面
dismissLoading() dismissLoading()
jumpPage(isSamplingData, false) jumpPage(isSamplingData, false)
return return
} }
//删除本地数据 lifecycleScope.launch {
appViewModel.deleteCookFoodAndGoods( appViewModel.deleteCookFoodAndGoods(
cookMode = food!!.cookMode, cookMode = food!!.cookMode,
foodId = food!!.foodId!! foodId = food!!.foodId!!
) { )
dismissLoading() dismissLoading()
jumpPage(isSamplingData, false) jumpPage(isSamplingData, false)
} }
@@ -411,61 +412,41 @@ class SubmitFoodActivity : BaseActivity() {
} }
} }
private lateinit var appViewModel: AppViewModel
private fun initViewModel() { override fun onDestroy() {
val db = BaseApp.instance!!.database WeightUtil.removeWeightListener(TAG)
val factory = super.onDestroy()
AppFactory(AppRepository(db.appDao()))
appViewModel =
ViewModelProvider(this, factory)[AppViewModel::class.java]
} }
override fun onResume() { /**
super.onResume() * 未保存数据提醒dialog
if (isCooking.not()) { */
//烹饪中使用本地数据,未烹饪则使用默认的调料配置数据 private fun remindSaveDataDialog() {
appViewModel.loadSeasoning { CommonDialog(this)
Log.d(TAG, "onResume: loadSeasoning:${it.toJsonString()}") .setTitle("保存提示")
loadTextCell(it) .setContent("可能存在未保存的数据,请确认?")
} .setNegativeButton("取消,留在页面")
} .setNeutralButton("返回上页,不保存") { finish() }
.setPositiveButton("保存为烹饪中菜品") { cook() }
.show()
} }
private var seasoningItems = mutableListOf<SeasoningEntity>() /**
private var seasoningCookingItems: List<SeasoningEntity>? = null * 点击制作和提交按钮确认dialog
private fun loadTextCell(list: MutableList<SeasoningEntity>) { */
seasoningItems.clear() private fun showRemindDialog(content: CharSequence, action: () -> Unit) {
seasoningItems.addAll(list) CommonDialog(this)
initConfigData() .setTitle(food?.foodName ?: "温馨提示")
// setGridData(list) .setContent(content)
list.forEach { entity -> .setNegativeButton("取消")
//烹饪中重量数据从本地获取,否则显示0 .setPositiveButton("确认") { action() }
if (isCooking.not()) { .show()
entity.useWeight = 0.0
}
updateGridData(entity)
}
seasoningCookingItems = seasoningItems.map { it.copy() }.toList()
} }
private fun initConfigData() { private fun getRemindSpannable(cookStateText: String) = buildSpannableString {
repeat(12) { num -> appendText("请确认是否 ")
val firstOne = seasoningItems.firstOrNull { it.sort == num } appendText(cookStateText, StyleSpan(Typeface.BOLD), ForegroundColorSpan(Color.BLACK))
if (firstOne == null) { appendText(" ")
seasoningItems.add(SeasoningEntity().also { it.sort = num })
}
}
} }
private fun setGridData(list: MutableList<SeasoningEntity>) {
val gridLayout = binding.include.root
list.forEach { entity ->
val tag = entity.sort.toString()
val frameLayout = gridLayout.findViewWithTag<FrameLayout>(tag)
TextCellAdapter.loadLayout(frameLayout, entity)
}
}
} }
@@ -0,0 +1,439 @@
package com.shuwei.dish.match.ui.fragment
import android.annotation.SuppressLint
import android.content.Intent
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import androidx.camera.view.PreviewView
import androidx.core.view.doOnLayout
import androidx.core.view.updateLayoutParams
import androidx.recyclerview.widget.GridLayoutManager
import com.shuwei.dish.match.R
import com.shuwei.dish.match.adapter.VectorCollectionAdapter
import com.shuwei.dish.match.adapter.FoodSearchAdapter
import com.shuwei.dish.match.base.BaseFragment
import com.shuwei.dish.match.databinding.FragmentCollectBinding
import com.shuwei.dish.match.databinding.LayoutCameraPreviewBinding
import com.shuwei.dish.match.dialog.Loading
import com.shuwei.dish.match.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.objbox.FoodCollectionBean
import com.shuwei.dish.match.objbox.FoodModule
import com.shuwei.dish.match.ui.CollectedFoodActivity
import com.shuwei.dish.match.ui.SingleFragmentActivity
import com.shuwei.dish.match.utils.BitmapSaver
import com.shuwei.dish.match.utils.CameraUtils
import com.shuwei.dish.match.utils.Debouncer
import com.shuwei.dish.match.utils.ImageUtil
import com.shuwei.dish.match.utils.LogSaveUtil
import com.shuwei.dish.match.utils.ext.clickWithDebounce
import com.shuwei.dish.match.utils.ext.dp
import com.shuwei.dish.match.utils.ext.toast
@SuppressLint("NotifyDataSetChanged")
class CollectFragment : BaseFragment<FragmentCollectBinding>() {
companion object {
private const val TAG = "CollectFragment"
const val MAX_COUNT = 18
}
private var selectedFoodId: String? = ""
private var selectedFoodName: String? = ""
private val vectorList = mutableListOf<FoodCollectionBean>().apply {
repeat(MAX_COUNT) {
add(FoodCollectionBean(isShowCamera = true))
}
}
private val searchList = mutableListOf<CookFoodGoodsEntity>().apply {
add(CookFoodGoodsEntity(goodsId = "200001", goodsName = "土豆丝"))
add(CookFoodGoodsEntity(goodsId = "200002", goodsName = "土豆片"))
add(CookFoodGoodsEntity(goodsId = "200003", goodsName = "土豆丁"))
add(CookFoodGoodsEntity(goodsId = "200004", goodsName = "胡萝卜丝"))
add(CookFoodGoodsEntity(goodsId = "200005", goodsName = "胡萝卜片"))
add(CookFoodGoodsEntity(goodsId = "200006", goodsName = "胡萝卜丁"))
add(CookFoodGoodsEntity(goodsId = "200007", goodsName = "黄瓜丝"))
add(CookFoodGoodsEntity(goodsId = "200008", goodsName = "黄瓜片"))
add(CookFoodGoodsEntity(goodsId = "200009", goodsName = "黄瓜丁"))
add(CookFoodGoodsEntity(goodsId = "200010", goodsName = "洋葱丝"))
add(CookFoodGoodsEntity(goodsId = "200011", goodsName = "洋葱丁"))
}
private var checkedItem: CookFoodGoodsEntity? = null
private val searchAdapter by lazy {
FoodSearchAdapter(searchList).apply {
setOnItemClickListener { adapter, view, position ->
searchList.forEachIndexed { index, item -> item.isClicked = index == position }
checkedItem = searchList[position]
notifyDataSetChanged()
checkedItem?.let {
selectedFoodId = it.foodId
selectedFoodName = it.goodsName
}
}
}
}
private val debouncer = Debouncer(2000)
private lateinit var currentActivity: SingleFragmentActivity
private val vectorAdapter: VectorCollectionAdapter by lazy {
VectorCollectionAdapter(vectorList).apply {
addOnItemChildClickListener(R.id.ivDelete) { _, _, position ->
vectorList[position].let {
it.bitmap = null
it.imageVector = null
it.imageFile = null
it.imageUri = null
it.isShowCamera = true
it.isFinish = false
it.uploadSuccess = false
}
notifyItemChanged(position)
}
}
}
override fun inflateBinding(
inflater: LayoutInflater,
container: ViewGroup?
): FragmentCollectBinding {
return FragmentCollectBinding.inflate(inflater, container, false)
}
//typealias CameraCallback = (Uri) -> Unit
private val cameraCallback: (Uri) -> Unit = cameraCallback@{ uri ->
try {
val index = vectorList.indexOfFirst { it.imageFile == null }
if (index == -1) {
toast("每次只允许保存${MAX_COUNT}条数据")
hideWaitingDialog()
return@cameraCallback
}
activity?.runOnUiThread {
vectorList[index].let {
it.imageVector = null
it.bitmap = null
it.isShowCamera = false
it.imageFile = null
it.imageUri = uri
}
vectorAdapter.notifyItemChanged(index)
}
// hideWaitingDialog()
Thread {
ImageUtil.uriToBitmap(requireActivity(), uri)?.let { bitmap ->
getImageVector(index, bitmap)
}
}.start()
} catch (e: Exception) {
e.printStackTrace()
hideWaitingDialog()
toast("程序异常${e.message}")
log("程序异常${e.message}")
}
}
private fun getImageVector(index: Int, bitmap: Bitmap) {
// val bitmap = BitmapCropper.cropCenter(
// original = srcBmp,
// targetWidth = 900, targetHeight = 900,
//// offsetX = 30, offsetY = 100
// )
val imageVector = try {
FoodModule.bitmap2FloatArray(bitmap, false)
} catch (e: Exception) {
e.printStackTrace()
toast("操作失败")
log("操作失败:${e.message}")
hideWaitingDialog()
return
}
val file = BitmapSaver.saveToAppFilesDir(
bitmap, requireActivity(), "IMG_CROP_${System.currentTimeMillis()}.jpg"
)
log("${this.javaClass.simpleName}-cameraCallback-裁剪bitmap保存文件路径:${file?.absolutePath}")
activity?.runOnUiThread {
vectorList[index].let {
it.imageVector = imageVector
it.bitmap = null
it.isShowCamera = false
it.imageFile = file
}
vectorAdapter.notifyItemChanged(index)
}
if (bitmap.isRecycled.not()) {
bitmap.recycle()
}
hideWaitingDialog()
}
@SuppressLint("NotifyDataSetChanged")
private fun takePhoto() {
val count = vectorList.count { it.bitmap != null }
if (count >= MAX_COUNT) {
toast("每次只允许保存${MAX_COUNT}条数据")
return
}
val index = vectorList.indexOfFirst { it.imageFile == null }
if (index == -1) {
toast("每次只允许保存${MAX_COUNT}条数据")
hideWaitingDialog()
return
}
showWaitingDialog("采集中……")
cameraUtils.takePhoto(succCallback = cameraCallback, failCallback = { errMsg ->
hideWaitingDialog()
// toast("拍照异常,请重新操作")
// if (cameraErrorCount >= 10) {
// toast("拍照异常,请重新操作")
// log("拍照异常,请重新操作:$errMsg")
// cameraErrorCount = 0
// return@takePhoto
// }
// cameraErrorCount++
// takePhoto()
})
}
override fun initialize() {
currentActivity = activity as SingleFragmentActivity
// 首次显示 CollectFragment 时初始化相机
initCamera(binding.flCameraPreview)
currentActivity.setLifecycleCallback(object : SingleFragmentActivity.LifecycleCallback {
override fun onActivityResume() {
// 首次进入时 previewView 尚未 attach,由 doOnLayout 负责首次 bind
// 从后台切回时 previewView 已 attach,直接 bind 恢复预览
if (::previewView.isInitialized && previewView.isAttachedToWindow) {
cameraUtils.bind()
}
}
override fun onActivityPause() {
cameraUtils.unbind()
}
})
binding.rvFoodList.let {
it.layoutManager =
GridLayoutManager(requireActivity(), 3, GridLayoutManager.VERTICAL, false)
it.adapter = vectorAdapter
}
binding.btnFoodSearch.setOnClickListener {
searchFood()
}
binding.btnSave.setOnClickListener {
// if (checkedItem == null || checkedItem!!.isChecked.not()) {
// toast("请选择菜品名称")
// return@setOnClickListener
// }
// val count = foodCollectionList.count { it.imageFile != null }
// if (count == 0) {
// toast("请拍摄菜品照片")
// return@setOnClickListener
// }
// upload()
}
binding.editFoodName.setOnEditorActionListener { v, actionId, event ->
if (actionId == EditorInfo.IME_ACTION_SEARCH) {
searchFood()
val imm =
v.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(v.windowToken, 0)
true
} else {
false
}
}
binding.btnCollectedFood.setOnClickListener {
startActivity(Intent(requireActivity(), CollectedFoodActivity::class.java))
}
binding.btnTakePhoto.clickWithDebounce {
val count = vectorList.count { it.bitmap != null }
if (count >= MAX_COUNT) {
toast("每次只允许保存${MAX_COUNT}条数据")
return@clickWithDebounce
}
takePhoto()
}
binding.btnClearData.setOnClickListener { clearData() }
binding.rvSearchFood.let {
it.layoutManager = GridLayoutManager(context, 2)
it.adapter = searchAdapter
}
searchFood()
}
@SuppressLint("NotifyDataSetChanged")
private fun upload() {
// lifecycleScope.launch {
// val totalFileCount = foodCollectionList.count { it.imageFile != null }
// showWaitingDialog2("图片上传中0/$totalFileCount")
// val params = HashMap<String, RequestBody>()
// //params["placeId"] = restId.toRequestBody()
// params["foodId"] = checkedItem!!.foodId.toRequestBody()
// params["foodName"] = checkedItem!!.foodName!!.toRequestBody()
// params["version"] = GlobalData.foodModelVersion.toRequestBody()
//
// ImageUploader(totalList = foodCollectionList, uploadImage = { batch ->
// val files = batch.map { it.imageFile }
// val foodVectorList = batch.filter { it.imageVector != null }.map {
// it.imageVector!!.joinToString(
// separator = ",", prefix = "[", postfix = "]"
// )
// }
// val foodVectorJson =
// foodVectorList.joinToString(separator = ",", prefix = "[", postfix = "]")
// log("json=$foodVectorJson")
// params["foodVector"] = foodVectorJson.toRequestBody()
// viewModel?.uploadCollectFoodPics(files, params)
// }, onProgress = { count, batch, idList ->
// //batch.forEach {
// // it.uploadSuccess = true
// //}
// runBlocking {
// activity?.runOnUiThread {
// showWaitingDialog("图片上传中$count/$totalFileCount")
// }
// val foodList = batch.mapIndexed { index, it ->
// Food(
// collectId = if (index < idList.size) idList[index] else null,
// foodId = checkedItem!!.foodId,
// foodName = checkedItem!!.foodName,
// foodVector = it.imageVector,
// version = GlobalData.foodModelVersion
// )
// }
// ObjectBox.putAll(foodList)
// activity?.runOnUiThread {
// batch.forEach { it.isFinish = true }
// collectionAdapter.notifyDataSetChanged()
// }
// }
// }, onError = {
// activity?.runOnUiThread {
// binding.root.postDelayed({
// hideWaitingDialog()
// toast("上传失败,请稍后重试")
// }, 1000)
// }
// }, onComplete = {
// //vectorThread()
// binding.root.postDelayed({
// hideWaitingDialog()
// toast("上传成功")
// }, 1000)
// }).processUploads()
// }
}
@SuppressLint("NotifyDataSetChanged")
fun searchFood() {
debouncer.debounce {
searchByFoodName(binding.editFoodName.text.toString()) {
// searchFoodList.clear()
// searchFoodList.addAll(it)
// searchFoodAdapter.notifyDataSetChanged()
}
}
}
var clickIndex = -1
@SuppressLint("NotifyDataSetChanged")
fun clearData() {
vectorList.forEach {
it.bitmap = null
it.imageVector = null
it.imageFile = null
it.imageUri = null
it.isShowCamera = true
it.isFinish = false
it.uploadSuccess = false
}
vectorAdapter.notifyDataSetChanged()
clickIndex = -1
binding.editFoodName.setText("")
searchFood()
}
private var isFirstOpen = true
override fun onResume() {
super.onResume()
// 不使用 isVisible 判断,首次打开时 View 尚未 attach 到 WindowisVisible 会返回 false 导致遮罩永不隐藏
binding.llCameraFlag.run {
visibility = View.VISIBLE
postDelayed({
visibility = View.GONE
}, 1000)
}
}
override fun onPause() {
super.onPause()
binding.llCameraFlag.visibility = View.VISIBLE
}
private fun log(msg: String) {
Log.d(TAG, "log: $msg")
LogSaveUtil.saveLogFile(msg)
}
private fun searchByFoodName(name: String, block: (String) -> Unit) {}
private fun hideWaitingDialog() {
currentActivity.dismissLoading()
}
private fun showWaitingDialog(msg: String) {
if (Loading.isShowing()) {
Loading.updateContent(msg)
return
}
currentActivity.showLoading(msg)
}
private lateinit var previewView: PreviewView
private val cameraUtils: CameraUtils by lazy {
CameraUtils(requireActivity())
}
/**
* 初始化相机
* @param container 相机预览容器
*/
fun initCamera(container: ViewGroup) {
cameraUtils.initCamera()
val previewBinding =
LayoutCameraPreviewBinding.inflate(layoutInflater, container)
previewView = previewBinding.previewView.also {
it.updateLayoutParams {
width = 456.dp
height = 342.dp
}
}
cameraUtils.setPreviewController(previewView)
// doOnLayout 确保 previewView 测量完成(有有效尺寸和 Surface)后再 bind,解决首次打开无预览问题
previewView.doOnLayout {
cameraUtils.bind()
}
}
}
@@ -0,0 +1,319 @@
package com.shuwei.dish.match.ui.fragment
import android.graphics.Typeface
import android.text.SpannableStringBuilder
import android.text.style.AbsoluteSizeSpan
import android.text.style.ForegroundColorSpan
import android.text.style.LineHeightSpan
import android.text.style.StyleSpan
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.graphics.toColorInt
import com.shuwei.dish.match.base.BaseFragment
import com.shuwei.dish.match.databinding.FragmentDeviceConfigBinding
import com.shuwei.dish.match.ui.SamplingListActivity
import com.shuwei.dish.match.ui.SelectDishActivity
import com.shuwei.dish.match.utils.SpTool
import com.shuwei.dish.match.utils.ext.appendText
import com.shuwei.dish.match.utils.ext.buildSpannableString
import com.shuwei.dish.match.utils.ext.dp
import com.shuwei.dish.match.utils.ext.startActivity
/**
* 设备配置Fragment
* 功能:支持三种模式选择(制作、采样、品控),调料网格配置管理
*/
class DeviceConfigFragment : BaseFragment<FragmentDeviceConfigBinding>() {
companion object {
const val TAG = "DeviceConfigFragment"
val modeList = listOf<ModeBean>(
ModeBean("制作模式", "菜品快速制作,记录熟重"),
ModeBean("采样模式", "菜品快速制作,记录熟重"),
ModeBean("品控模式", "菜品快速制作,记录熟重")
)
}
data class ModeBean(
var modeName: String,
var modeDesc: String
)
// private lateinit var appViewModel: DbViewModel
// private val weightArray = SparseArray<Double>()
// private val addressArray = AddressUtil.getWeighAddressArray()
// private var seasoningItems = mutableListOf<SeasoningEntity>()
// private val detector = MultiClickDetector(targetCount = 10, intervalMs = 800)
override fun inflateBinding(
inflater: LayoutInflater,
container: ViewGroup?
): FragmentDeviceConfigBinding {
return FragmentDeviceConfigBinding.inflate(inflater, container, false)
}
override fun initialize() {
initUI()
// addWeighListener()
// addGridItemListener()
// loadSeasoning()
}
/**
* 初始化UI
*/
private fun initUI() {
loadQualitySpan(isEnable = false)
when (SpTool.cookMode) {
0 -> {
binding.rbModeCook.isChecked = true
binding.rbModeSampling.isChecked = false
loadCookSpan(isClicked = true)
loadSamplingSpan()
}
1 -> {
binding.rbModeCook.isChecked = false
binding.rbModeSampling.isChecked = true
loadCookSpan()
loadSamplingSpan(isClicked = true)
}
else -> {
binding.rbModeCook.isChecked = true
binding.rbModeSampling.isChecked = false
loadCookSpan(isClicked = true)
loadSamplingSpan()
}
}
binding.rbModeCook.setOnClickListener {
SpTool.cookMode = 0
binding.rbModeCook.isChecked = true
binding.rbModeSampling.isChecked = false
loadCookSpan(isClicked = true)
loadSamplingSpan()
activity?.startActivity<SelectDishActivity> {
putExtra(SelectDishActivity.IS_CONFIG_PAGE, true)
}
activity?.finish()
}
binding.rbModeSampling.setOnClickListener {
SpTool.cookMode = 1
binding.rbModeCook.isChecked = false
binding.rbModeSampling.isChecked = true
loadCookSpan()
loadSamplingSpan(isClicked = true)
activity?.startActivity<SamplingListActivity> {
putExtra(SamplingListActivity.IS_CONFIG_PAGE, true)
}
activity?.finish()
}
}
//
// /**
// * 添加称重监听
// */
// private fun addWeighListener() {
// WeightUtil.addWeightListener(
// weightKey = TAG,
// getWeight = { address, state, weight ->
// Log.d(TAG, "addWeighListener: address=$address,stat=$state,weight=$weight")
// weightArray.put(address, weight)
// val item = seasoningItems.firstOrNull { address == addressArray[it.sort] }
// item?.let {
// it.useWeight = weight.toDouble()
// updateGridData(it)
// }
// Log.d(TAG, "addWeighListener: update-----------------------")
// })
// }
//
// /**
// * 添加网格项点击监听
// */
// private fun addGridItemListener() {
// binding.include.root.forEach { child ->
// child.clickWithDebounce {
// val sort = child.tag.toString().toInt()
// val entity = seasoningItems.firstOrNull { it.sort == sort } ?: SeasoningEntity()
// clickGridItem(sort, entity)
// }
// }
// }
//
// /**
// * 加载调料数据
// */
// private fun loadSeasoning() {
//// appViewModel.loadSeasoning {
//// seasoningItems.clear()
//// seasoningItems.addAll(it)
//// initConfigData()
//// setGridData(seasoningItems)
//// }
// }
//
// /**
// * 初始化配置数据,确保有12个调料位置
// */
// private fun initConfigData() {
// repeat(12) { num ->
// val firstOne = seasoningItems.firstOrNull { it.sort == num }
// if (firstOne == null) {
// seasoningItems.add(SeasoningEntity().also { it.sort = num })
// }
// }
// }
//
// /**
// * 设置网格数据
// */
// private fun setGridData(list: MutableList<SeasoningEntity>) {
// val gridLayout = binding.include.root
// list.forEach { entity ->
// val tag = entity.sort.toString()
// val frameLayout = gridLayout.findViewWithTag<FrameLayout>(tag)
// TextCellAdapter.loadLayout(frameLayout, entity)
// }
// }
//
// /**
// * 更新网格数据
// */
// private fun updateGridData(entity: SeasoningEntity) {
// val gridLayout = binding.include.root
// val tag = entity.sort.toString()
// val frameLayout = gridLayout.findViewWithTag<FrameLayout>(tag)
// TextCellAdapter.loadLayout(frameLayout, entity)
// }
//
// /**
// * 点击网格项,打开调料搜索对话框
// */
// private fun clickGridItem(sort: Int, entity: SeasoningEntity) {
// val currentAddress = AddressUtil.getWeighAddressArray().get(sort)
// SeasoningSearchDialog(
// activity = requireActivity() as BaseActivity,
// weighIndex = sort,
// weighAddress = currentAddress,
// clickName = entity.goodsName
// ) { item ->
// syncItem(entity, item)
// // 3-调料
// entity.materialType = 3
// entity.sort = sort
// saveSeasoning(entity) {
// val tag = sort.toString()
// val gridLayout = binding.include.root
// val frameLayout = gridLayout.findViewWithTag<FrameLayout>(tag)
// TextCellAdapter.loadLayout(frameLayout, entity)
// }
// }.show()
// }
//
// /**
// * 同步调料项数据
// */
// private fun syncItem(oldItem: SeasoningEntity, newItem: SeasoningEntity) {
// oldItem.run {
// goodsId = newItem.goodsId
// goodsName = newItem.goodsName
// goodsOrRelationCode = newItem.goodsOrRelationCode
// relateionType = newItem.relateionType
// materialType = newItem.materialType
// allEdible = newItem.allEdible
// useWeight = newItem.useWeight
// popularName = newItem.popularName
// canteenId = newItem.canteenId
// relateionType_dictText = newItem.relateionType_dictText
//
// foodId = newItem.foodId
// sort = newItem.sort
// }
// }
//
// /**
// * 保存调料数据
// */
// fun saveSeasoning(entity: SeasoningEntity, action: () -> Unit = {}) {
// appViewModel.saveSeasoning(entity) {
// action()
// toast("保存成功")
// }
// }
/**
* 加载品控模式文本样式
*/
private fun loadQualitySpan(isClicked: Boolean = false, isEnable: Boolean = true) {
binding.rbModeQuality.text =
getModeTextSpan(
topText = modeList[2].modeName,
bottomText = modeList[2].modeDesc,
topColor = if (isEnable) if (isClicked) "#00BC71" else "#000000" else "#B4B4B4",
bottomColor = if (isEnable) "#8B8B8B" else "#B4B4B4"
)
}
/**
* 加载采样模式文本样式
*/
private fun loadSamplingSpan(isClicked: Boolean = false, isEnable: Boolean = true) {
binding.rbModeSampling.text =
getModeTextSpan(
topText = modeList[1].modeName,
bottomText = modeList[1].modeDesc,
topColor = if (isEnable) if (isClicked) "#00BC71" else "#000000" else "#B4B4B4",
bottomColor = if (isEnable) "#8B8B8B" else "#B4B4B4"
)
}
/**
* 加载制作模式文本样式
*/
private fun loadCookSpan(isClicked: Boolean = false, isEnable: Boolean = true) {
binding.rbModeCook.text =
getModeTextSpan(
topText = modeList[0].modeName,
bottomText = modeList[0].modeDesc,
topColor = if (isEnable) if (isClicked) "#00BC71" else "#000000" else "#B4B4B4",
bottomColor = if (isEnable) "#8B8B8B" else "#B4B4B4"
)
}
/**
* 生成模式文本样式
*/
private fun getModeTextSpan(
topText: String,
topColor: String,
bottomText: String,
bottomColor: String
): SpannableStringBuilder {
return buildSpannableString {
appendText(
topText,
ForegroundColorSpan(topColor.toColorInt()),
StyleSpan(Typeface.BOLD),
AbsoluteSizeSpan(36, true),
LineHeightSpan { text, start, end, spanstartv, v, fm ->
fm.descent += 10.dp // 增加行间距
}
)
append("\n")
appendText(
bottomText,
ForegroundColorSpan(bottomColor.toColorInt()),
AbsoluteSizeSpan(26, true)
)
}
}
override fun onDestroyView() {
super.onDestroyView()
// 移除称重监听
// WeightUtil.removeWeightListener(TAG)
}
}
@@ -5,6 +5,9 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.chad.library.adapter4.util.setOnDebouncedItemClick import com.chad.library.adapter4.util.setOnDebouncedItemClick
@@ -13,7 +16,9 @@ import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.base.BaseFragment import com.shuwei.dish.match.base.BaseFragment
import com.shuwei.dish.match.databinding.FragmentDishListBinding import com.shuwei.dish.match.databinding.FragmentDishListBinding
import com.shuwei.dish.match.databinding.LayoutEmptyViewBinding import com.shuwei.dish.match.databinding.LayoutEmptyViewBinding
import com.shuwei.dish.match.entity.CookFoodEntity
import com.shuwei.dish.match.entity.FoodRecord import com.shuwei.dish.match.entity.FoodRecord
import com.shuwei.dish.match.net.UiState
import com.shuwei.dish.match.ui.PrepareCookActivity import com.shuwei.dish.match.ui.PrepareCookActivity
import com.shuwei.dish.match.ui.SelectDishActivity import com.shuwei.dish.match.ui.SelectDishActivity
import com.shuwei.dish.match.ui.SubmitFoodActivity import com.shuwei.dish.match.ui.SubmitFoodActivity
@@ -21,6 +26,8 @@ import com.shuwei.dish.match.utils.SwipeCallback
import com.shuwei.dish.match.utils.ext.startActivity import com.shuwei.dish.match.utils.ext.startActivity
import com.shuwei.dish.match.utils.ext.toast import com.shuwei.dish.match.utils.ext.toast
import java.io.Serializable import java.io.Serializable
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
class DishListFragment : BaseFragment<FragmentDishListBinding>() { class DishListFragment : BaseFragment<FragmentDishListBinding>() {
@@ -87,8 +94,7 @@ class DishListFragment : BaseFragment<FragmentDishListBinding>() {
return FragmentDishListBinding.inflate(inflater, container, false) return FragmentDishListBinding.inflate(inflater, container, false)
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun initialize() {
super.onViewCreated(view, savedInstanceState)
activity = requireActivity() as SelectDishActivity activity = requireActivity() as SelectDishActivity
dinnerType = arguments?.getString(DINNER_TYPE, "0") ?: "0" dinnerType = arguments?.getString(DINNER_TYPE, "0") ?: "0"
binding.rvDishList.run { binding.rvDishList.run {
@@ -109,6 +115,7 @@ class DishListFragment : BaseFragment<FragmentDishListBinding>() {
itemTouchHelper.attachToRecyclerView(this) itemTouchHelper.attachToRecyclerView(this)
} }
addViewListener() addViewListener()
initObserver()
pageNo = 1 pageNo = 1
activity.showLoading() activity.showLoading()
@@ -116,6 +123,39 @@ class DishListFragment : BaseFragment<FragmentDishListBinding>() {
binding.refreshLayout.setEnableRefresh(true) binding.refreshLayout.setEnableRefresh(true)
} }
/**
* 用 combine 同时监听网络结果与本地数据,网络成功时统一合并渲染
* pageNo > 1(加载更多)时 cookFoodListState 不参与合并,直接追加网络数据
*/
private fun initObserver() {
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
combine(
activity.netViewModel.searchFoodState,
activity.appViewModel.cookFoodListState
) { networkState, localList -> Pair(networkState, localList) }
.collect { (networkState, localList) ->
when (networkState) {
is UiState.Loading -> activity.showLoading()
is UiState.Success -> {
activity.delayDismissLoading()
finishRefresh()
loadAndMergeDishList(networkState.data, localList)
}
is UiState.Error -> {
if (isAdded.not()) return@collect
toast(networkState.msg)
finishRefresh()
if (pageNo == 1) loadEmptyView()
activity.delayDismissLoading()
}
is UiState.Idle -> {}
}
}
}
}
}
private fun addViewListener() { private fun addViewListener() {
binding.refreshLayout.run { binding.refreshLayout.run {
setEnableRefresh(true) setEnableRefresh(true)
@@ -148,72 +188,15 @@ class DishListFragment : BaseFragment<FragmentDishListBinding>() {
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
@Suppress("unchecked_cast") @Suppress("unchecked_cast")
fun getDishList() { fun getDishList() {
activity.showLoading() val param = mutableMapOf<String, Any>(
activity.getFoodList( "pageNum" to pageNo,
param = mutableMapOf( "pageSize" to pageSize,
"pageNum" to pageNo, "placeId" to BaseApp.canteenId,
"pageSize" to pageSize, "dinnerType" to getDinnerTypeText()
"placeId" to BaseApp.canteenId,
"dinnerType" to getDinnerTypeText()
),
onSuccess = { records ->
activity.delayDismissLoading()
finishRefresh()
loadDishList(records)
},
onFailure = { code, msg ->
if (isAdded.not()) {
return@getFoodList
}
toast(msg)
finishRefresh()
if (pageNo == 1) {
loadEmptyView()
}
activity.delayDismissLoading()
}
) )
// val map = mapOf( // 网络与本地并行触发,pageNo > 1 时不重复查询本地数据
//// "foodName" to input, activity.getFoodList(param = param)
// "dinnerType" to getDinnerTypeText(), if (pageNo == 1) activity.getCookFoodList()
// "pageNo" to "$pageNo",
// "pageSize" to "$pageSize",
// "canteenId" to BaseApp.canteenId
// )
// val sb = StringBuilder(UrlConfig.QUERY_FOOD_LIST).apply {
// append("?")
// map.forEach { (key, value) -> append("$key=$value&") }
// }
// sb.deleteCharAt(sb.length - 1)
// HttpUtil.get(
// url = sb.toString(),
// doSuccess = { data ->
// binding.refreshLayout.run {
// setEnableRefresh(true)
// }
// activity.delayDismissLoading()
// finishRefresh()
// val json = data.toJsonString()
// val recordBean: FoodRecordBean? = json.toObject<FoodRecordBean>()
// loadDishList(recordBean)
// }, doFailure = { code, msg ->
// try {
// if (isAdded.not()) {
// return@get
// }
// binding.refreshLayout.run {
// setEnableRefresh(true)
// }
// toast(msg)
// finishRefresh()
// if (pageNo == 1) {
// loadEmptyView()
// }
// activity.delayDismissLoading()
// } catch (e: Exception) {
// e.printStackTrace()
// }
// })
} }
private var emptyViewBinding: LayoutEmptyViewBinding? = null private var emptyViewBinding: LayoutEmptyViewBinding? = null
@@ -249,64 +232,65 @@ class DishListFragment : BaseFragment<FragmentDishListBinding>() {
} }
} }
/**
* 将网络数据与本地烹饪中数据合并后渲染列表
* pageNo > 1 时忽略本地数据,直接追加网络数据,避免 combine 带入旧的本地状态
*/
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
private fun refreshLocalData(list: MutableList<FoodRecord>) { private fun loadAndMergeDishList(
activity.getCookFoodList { cookFoodEntities -> records: MutableList<FoodRecord>?,
if (cookFoodEntities.isNullOrEmpty()) { localList: MutableList<CookFoodEntity>?
return@getCookFoodList ) {
}
val tempList = mutableListOf<FoodRecord>()
cookFoodEntities.forEachIndexed { index, entity ->
val food = list.firstOrNull { it.foodId == entity.foodId }
if (food != null) {
food.isCooking = true
food.sort = index
food.dinnerType = entity.dinnerType ?: "0"
tempList.add(food)
} else {
//当前查询到的list不包括本地数据id,可能数据在很多页以后了,暂时只能直接构造数据
tempList.add(
FoodRecord(
foodId = entity.foodId,
foodName = entity.foodName,
sort = index,
dinnerType = entity.dinnerType ?: "0",
isCooking = true
)
)
}
}
list.removeAll(tempList)
list.addAll(0, tempList)
dishAdapter.notifyDataSetChanged()
}
}
@SuppressLint("NotifyDataSetChanged")
private fun loadDishList(records: MutableList<FoodRecord>?) {
try { try {
if (isAdded.not()) { if (isAdded.not()) return
// pageNo > 1 时不参与本地合并,避免 combine 带入旧的本地状态
val effectiveLocalList = if (pageNo == 1) localList else null
if (records.isNullOrEmpty() && effectiveLocalList.isNullOrEmpty()) {
if (pageNo == 1) loadEmptyView()
return return
} }
if (records.isNullOrEmpty()) { if (pageNo == 1) list.clear()
//toast("暂未搜索到相关菜品信息")
if (pageNo == 1) { val mergedList = records?.toMutableList() ?: mutableListOf()
loadEmptyView()
// 仅 pageNo == 1 时才有本地数据参与合并
if (!effectiveLocalList.isNullOrEmpty()) {
val cookingItems = mutableListOf<FoodRecord>()
effectiveLocalList.forEachIndexed { index, entity ->
val food = mergedList.firstOrNull { it.foodId == entity.foodId }
if (food != null) {
mergedList.remove(food)
food.isCooking = true
food.sort = index
food.dinnerType = entity.dinnerType ?: "0"
cookingItems.add(food)
} else {
// 当前网络数据不包含该本地记录,直接构造
cookingItems.add(
FoodRecord(
foodId = entity.foodId,
foodName = entity.foodName,
sort = index,
dinnerType = entity.dinnerType ?: "0",
isCooking = true
)
)
}
} }
return mergedList.addAll(0, cookingItems)
} }
if (pageNo == 1) {
list.clear() if (pageNo >= 2 && !localList.isNullOrEmpty()) {
//第2页起,从mergedList中移除localList中的同foodId数据
val localIds = localList.map { it.foodId }.toHashSet()
mergedList.removeAll { it.foodId in localIds }
} }
list.addAll(records)
list.addAll(mergedList)
dishAdapter.notifyDataSetChanged() dishAdapter.notifyDataSetChanged()
val isLoadMoreEnable = records.size >= pageSize val isLoadMoreEnable = (records?.size ?: 0) >= pageSize
binding.refreshLayout.setEnableLoadMore(isLoadMoreEnable) binding.refreshLayout.setEnableLoadMore(isLoadMoreEnable)
if (isLoadMoreEnable) { if (isLoadMoreEnable) pageNo++
pageNo++
}
refreshLocalData(list)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
} }
@@ -0,0 +1,466 @@
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
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.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.CommonDialog
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.SingleFragmentActivity
import com.shuwei.dish.match.utils.SizeTool
import com.shuwei.dish.match.utils.WeightUtil
import com.shuwei.dish.match.utils.ext.dp
import kotlinx.coroutines.launch
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 = 5_000L
}
override fun inflateBinding(
inflater: LayoutInflater,
container: ViewGroup?
) = FragmentSeasoningConfigBinding.inflate(inflater, container, false)
private lateinit var currentActivity: SingleFragmentActivity
// private lateinit var appViewModel: DbViewModel
private val adapter22 by lazy { buildAdapter22() }
private val adapter18 by lazy {
Seasoning18GridAdapter(
showNameOnly = true,
leftRightSpace = 20.dp
)
}
/** 各秤的上一次重量快照,key 格式 "{deviceId}#{address}",用于计算 delta */
private val weightSnapshot = mutableMapOf<String, Double>()
/** 主线程 Handler,用于延迟清除高亮 */
private val mainHandler = Handler(Looper.getMainLooper())
/** 当前高亮清除任务,新高亮触发时取消旧任务 */
private var clearHighlightRunnable: Runnable? = null
override fun initialize() {
currentActivity = activity as SingleFragmentActivity
// initViewModel()
setupRecyclerViews()
bindAdapterClicks()
bindOilSlotClick()
addWeightListener()
loadData()
}
/** 绑定油盆格子点击,弹出调料选择弹窗并保存到 DEVICE_ID_1 */
private fun bindOilSlotClick() {
binding.tvOilSlot.setOnClickListener {
handleSlotClick(
currentName = binding.tvOilSlot.text.toString().takeIf { it != "-" },
onSelect = {
selectAndSave(ScaleDeviceConfig.DEVICE_ID_1, 1) { name ->
binding.tvOilSlot.text = name.ifBlank { "-" }
}
},
onClear = {
binding.tvOilSlot.text = "-"
deleteSlotAndBroadcast(ScaleDeviceConfig.DEVICE_ID_1, 1)
}
)
}
}
/** 绑定两个 Adapter 的格子点击,弹出调料选择弹窗 */
private fun bindAdapterClicks() {
bindSlotAdapterClick(
adapter = adapter22,
deviceId = ScaleDeviceConfig.DEVICE_ID_22
)
bindSlotAdapterClick(
adapter = adapter18,
deviceId = ScaleDeviceConfig.DEVICE_ID_18
)
}
/** 为指定 Adapter 绑定格子点击逻辑(已配置弹操作弹窗,未配置直接选择) */
private fun bindSlotAdapterClick(
adapter: Any,
deviceId: String
) {
when (adapter) {
is Seasoning22GridAdapter -> adapter.onItemClick = { scaleData, position ->
handleSlotClick(
currentName = scaleData.name,
onSelect = {
selectAndSave(deviceId, scaleData.address) { name ->
adapter.updateItemName(position, name)
}
},
onClear = {
adapter.updateItemName(position, "")
deleteSlotAndBroadcast(deviceId, scaleData.address)
}
)
}
is Seasoning18GridAdapter -> adapter.onItemClick = { scaleData, position ->
handleSlotClick(
currentName = scaleData.name,
onSelect = {
selectAndSave(deviceId, scaleData.address) { name ->
adapter.updateItemName(position, name)
}
},
onClear = {
adapter.updateItemName(position, "")
deleteSlotAndBroadcast(deviceId, scaleData.address)
}
)
}
}
}
/**
* 格子点击分发:已配置则弹操作弹窗,未配置直接执行选择
* @param currentName 格子当前调料名称
* @param onSelect 执行选择(新增或重新选择)
* @param onClear 执行清除
*/
private fun handleSlotClick(
currentName: String?,
onSelect: () -> Unit,
onClear: () -> Unit
) {
if (!currentName.isNullOrBlank()) {
showSlotActionDialog(goodsName = currentName, onReselect = onSelect, onClear = onClear)
} else {
onSelect()
}
}
/**
* 打开调料选择弹窗
* @param callback 调料选择回调
*/
/**
* 打开选择弹窗,选中后更新 UI 并保存槽位到 Room 广播
* @param deviceId 目标设备 ID
* @param address 秤硬件地址
* @param onUpdateName 选中后更新格子显示名称的回调
*/
private fun selectAndSave(
deviceId: String,
address: Int,
onUpdateName: (String) -> Unit
) {
SeasoningSelectDialog(
activity = currentActivity,
onItemSelected = { item ->
val name = item.goodsName ?: ""
onUpdateName(name)
saveSlotAndBroadcast(
SeasoningSlotEntity(
deviceId = deviceId,
address = address,
goodsId = item.goodsId,
goodsName = name
)
)
}
).show()
}
/** 将单个槽位写入 Room,再广播全量配置给子设备 */
private fun saveSlotAndBroadcast(slot: SeasoningSlotEntity) {
lifecycleScope.launch {
currentActivity.appViewModel.upsertSeasoningSlot(slot)
broadcastAllSlots()
}
}
/**
* 弹出已配置格子的操作选择弹窗(3按钮)
* @param goodsName 当前格子已配置的调料名称
* @param onReselect 用户点击「重新选择」后的回调
* @param onClear 用户点击「清除配置」后的回调
*/
private fun showSlotActionDialog(
goodsName: String,
onReselect: () -> Unit,
onClear: () -> Unit
) {
CommonDialog(currentActivity)
.setTitle("调料操作")
.setContent("当前配置:$goodsName")
.setNegativeButton("取消")
.setNeutralButton("重新选择") { onReselect() }
.setPositiveButton("清除配置") { onClear() }
.show()
}
/** 从 Room 删除指定槽位,再广播全量配置给子设备 */
private fun deleteSlotAndBroadcast(deviceId: String, address: Int) {
lifecycleScope.launch {
currentActivity.appViewModel.deleteSeasoningSlot(deviceId, address)
broadcastAllSlots()
}
}
/** 读取全量槽位配置并通过 wsClient 推送给所有已连接子设备 */
private suspend fun broadcastAllSlots() {
val allSlots = currentActivity.appViewModel.loadSeasoningSlot().map {
SlotConfig(it.deviceId, it.address, it.goodsId, it.goodsName)
}
ScaleServiceManager.sendSeasoningConfig(allSlots)
}
// /** 初始化 ViewModel */
// private fun initViewModel() {
// val factory = AppFactory(DbRepository(BaseApp.instance!!.database.appDao()))
// appViewModel = ViewModelProvider(this, factory)[DbViewModel::class.java]
// }
private val size by lazy { SizeTool.getFlexLayoutSize(20.dp) }
/** 构建上层(22格)Adapter,需要先计算格子尺寸 */
private fun buildAdapter22(): Seasoning22GridAdapter {
return Seasoning22GridAdapter(
largeSize = size.largeSize,
smallSize = size.smallSize,
showNameOnly = true
)
}
/** 配置两个 RecyclerView */
private fun setupRecyclerViews() {
// 上层:FlexboxLayoutManager,仿 MasterScaleActivity 中 Scale22VH 的布局方式
// rvTop 高度 = 小格子尺寸 * 3行 + 3 * 间距,与 Scale22VH 保持一致
binding.rvTop.apply {
layoutParams = layoutParams.also { it.height = size.rvHeight }
layoutManager = FlexboxLayoutManager(requireContext()).apply {
flexDirection = FlexDirection.COLUMN
flexWrap = FlexWrap.WRAP
alignItems = AlignItems.FLEX_START
}
itemAnimator = null
adapter = adapter22
}
// 下层:GridLayoutManager 6列,仿 Scale18VH
binding.rvBottom.apply {
layoutManager = GridLayoutManager(requireContext(), 6)
itemAnimator = null
adapter = adapter18
}
}
/** 从 Room 加载已配置的槽位名称,填充到对应格子 */
private fun loadData() {
adapter22.update(emptyList())
adapter18.update(emptyList())
lifecycleScope.launch {
val slots22 = currentActivity.appViewModel.getSeasoningSlotsByDeviceId(ScaleDeviceConfig.DEVICE_ID_22)
val slots18 = currentActivity.appViewModel.getSeasoningSlotsByDeviceId(ScaleDeviceConfig.DEVICE_ID_18)
val slotsOil = currentActivity.appViewModel.getSeasoningSlotsByDeviceId(ScaleDeviceConfig.DEVICE_ID_1)
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)
}
// 加载油盆格子名称(address=1
val oilSlot = slotsOil.firstOrNull { it.address == 1 }
binding.tvOilSlot.text =
if (oilSlot != null && oilSlot.goodsName.isNotEmpty()) oilSlot.goodsName else "-"
}
}
/**
* 注册重量监听,检测重量增加超过阈值的秤,高亮对应格子并通知主设备
*
* 策略:
* - 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.deviceId}#${data.address}"
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 snapshotKey = "${GlobalData.deviceId}#$address"
val baseline = weightSnapshot[snapshotKey]
Log.d(
TAG,
"onWeight: address=$address state=$state weight=$weight baseline=$baseline"
)
if (baseline == null) {
weightSnapshot[snapshotKey] = 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[snapshotKey] = weight
Log.d(TAG, "onWeight: 触发高亮 address=$address delta=$delta")
triggerHighlight(GlobalData.deviceId, address, delta)
}
state == WeightUtil.STATE_STABLE -> {
weightSnapshot[snapshotKey] = weight
}
}
}
}
}
/**
* 根据来源设备ID和秤地址触发对应格子高亮,15 秒后自动恢复
* 通过 sourceDeviceId 精确匹配 SCALE_ORDER,解决 18格/22格 地址重叠问题
* @param sourceDeviceId 产生重量变化的设备 ID
* @param address 秤硬件地址
* @param delta 重量变化量(克)
*/
private fun triggerHighlight(sourceDeviceId: String, address: Int, delta: Double) {
// 只有对应设备的秤才查对应列表,避免主设备(DEVICE_ID_2)等其他设备误触发
val pos22 = if (sourceDeviceId == ScaleDeviceConfig.DEVICE_ID_22)
ScaleDeviceConfig.SCALE_ORDER_22.indexOf(address) else -1
val pos18 = if (sourceDeviceId == ScaleDeviceConfig.DEVICE_ID_18)
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) }
}
}
@@ -0,0 +1,76 @@
package com.shuwei.dish.match.utils
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import java.io.BufferedReader
import java.io.IOException
import java.io.InputStreamReader
object AssetsTool {
// fun readJson(context: Context, fileName: String): List<String> {
// val json = readAssetsFile(context, fileName)
// val json2 = json.replace("[[", "[").replaceAfterLast("]]", "]")
// val list = json2.split("],")
// val resultList = mutableListOf<String>()
// var count = 0
// val tempList = mutableListOf<String>()
// list.forEachIndexed { index, text ->
// if (count >= 500) {
// val tempJson = "[${tempList.joinToString (",")}]"
// resultList.add(tempJson)
// tempList.clear()
// count = 0
// }
// val newText = if (index == list.size - 1) text else "${text}]"
// tempList.add(newText)
// count++
// }
// if (count < 500) {
// val tempJson = "[${tempList.joinToString(",")}]"
// resultList.add(tempJson)
// }
// return resultList
// }
fun readAssetsFile(context: Context, fileName: String): String {
val stringBuilder = StringBuilder()
try {
val bf = BufferedReader(InputStreamReader(context.assets.open(fileName)))
bf.useLines { lines -> lines.forEach { stringBuilder.append(it) } }
} catch (e: IOException) {
e.printStackTrace()
}
return stringBuilder.toString()
}
fun loadImagesFromAssets(context: Context, subPath: String): MutableList<Bitmap> {
val bitmaps: MutableList<Bitmap> = mutableListOf()
val assetManager = context.assets
try {
val files = assetManager.list(subPath)
files?.forEach { file ->
assetManager.open("$subPath/$file").use { `is` ->
val bitmap = BitmapFactory.decodeStream(`is`)
if (bitmap != null) {
bitmaps.add(bitmap)
}
}
}
} catch (e: IOException) {
e.printStackTrace()
}
return bitmaps
}
fun loadImageBitmapFromAssets(context: Context, imagePath:String, action:(bmp: Bitmap)-> Unit) {
context.assets.open(imagePath).use { `is` ->
val bitmap = BitmapFactory.decodeStream(`is`)
action(bitmap)
}
}
}
@@ -0,0 +1,57 @@
package com.shuwei.dish.match.utils
import android.content.Context
import android.graphics.Bitmap
import android.os.Environment
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
object BitmapSaver {
// 保存到公共目录(需WRITE_EXTERNAL_STORAGE权限)
fun saveToPublicDirectory(
bitmap: Bitmap,
folderName: String = Environment.DIRECTORY_PICTURES,
fileName: String,
format: Bitmap.CompressFormat = Bitmap.CompressFormat.PNG,
quality: Int = 100
): File? {
val dir = Environment.getExternalStoragePublicDirectory(folderName)
if (!dir.exists()) dir.mkdirs()
return saveBitmap(bitmap, File(dir, fileName), format, quality)
}
// 保存到应用私有目录(无需权限)
fun saveToAppFilesDir(
bitmap: Bitmap,
context: Context,
fileName: String,
format: Bitmap.CompressFormat = Bitmap.CompressFormat.JPEG,
quality: Int = 100
): File? {
//val dir = context.getExternalFilesDir(null)
val dir = File(context.cacheDir, "crop")
if (dir.exists().not()) {
dir.mkdirs()
}
return saveBitmap(bitmap, File(dir, fileName), format, quality)
}
private fun saveBitmap(
bitmap: Bitmap,
outputFile: File,
format: Bitmap.CompressFormat,
quality: Int
): File? {
return try {
FileOutputStream(outputFile).use { fos ->
bitmap.compress(format, quality, fos)
fos.flush()
}
outputFile
} catch (e: IOException) {
e.printStackTrace()
null
}
}
}
@@ -0,0 +1,96 @@
package com.shuwei.dish.match.utils
import android.net.Uri
import androidx.activity.ComponentActivity
import androidx.camera.core.CameraSelector
import androidx.camera.view.CameraController
import androidx.camera.view.LifecycleCameraController
import androidx.camera.view.PreviewView
import androidx.core.content.ContextCompat
class CameraUtils(private var activity: ComponentActivity) {
private var cameraController: LifecycleCameraController? = null
private var photoCaptureHelper: PhotoCaptureHelper? = null
private var failCallback: ((msg: String) -> Unit)? = null
private var isCameraReady = false
fun takePhoto(succCallback: (Uri) -> Unit, failCallback: (msg: String) -> Unit = { }) {
this.failCallback = failCallback
// 相机未就绪时静默返回,避免触发错误提示
if (!isCameraReady) {
failCallback("")
return
}
cameraController?.let {
if (photoCaptureHelper == null) {
initCaptureHelper()
}
}
photoCaptureHelper?.let {
it.addSuccessCallback(succCallback)
it.bindCameraCallback {
bind()
}
it.takePhoto()
}
}
private fun initCaptureHelper() {
photoCaptureHelper = PhotoCaptureHelper(
context = activity,
cameraController = cameraController!!,
onSuccess = {},
onError = { msg ->
//toast(msg)
failCallback?.invoke(msg)
}
)
}
fun setPreviewController(previewView: PreviewView?) {
if (previewView?.controller == null) {
previewView?.controller = cameraController
}
}
fun initCamera() {
if (cameraController == null) {
cameraController = LifecycleCameraController(activity).apply {
// 必须设置有效的用例
setEnabledUseCases(
CameraController.IMAGE_CAPTURE
// or CameraController.VIDEO_CAPTURE
)
cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
}
// bind() 由外部在 previewView attach 到窗口后调用,避免首次无预览
}
// if (isCameraReady.not()) {
// try {
// cameraController!!.initializationFuture.addListener({
// isCameraReady = true
// Timber.d("Camera initialized successfully")
// }, ContextCompat.getMainExecutor(this))
// } catch (e: Exception) {
// Timber.d("Camera initialized error = ${e.message}")
// }
// }
}
fun bind() {
isCameraReady = false
cameraController?.bindToLifecycle(activity)
// 监听相机初始化完成,就绪后才允许拍照
cameraController?.initializationFuture?.addListener({
isCameraReady = true
}, ContextCompat.getMainExecutor(activity))
}
fun unbind() {
isCameraReady = false
cameraController?.unbind()
}
}
@@ -0,0 +1,235 @@
package com.shuwei.dish.match.utils
import android.app.ActivityManager
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.Process
import android.util.Log
import com.shuwei.dish.match.ui.HomeActivity
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.PrintWriter
import java.io.StringWriter
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.system.exitProcess
/**
* 崩溃处理
*/
class CrashHandler private constructor(private val context: Context) :
Thread.UncaughtExceptionHandler {
companion object {
private const val TAG = "CrashHandler"
private const val CRASH_REPORTS_DIR = "crash_reports"
private const val LOG_LINES = 500 // 收集最近500行日志
@Volatile
private var instance: CrashHandler? = null
fun init(context: Context) {
if (instance == null) {
synchronized(CrashHandler::class.java) {
if (instance == null) {
instance = CrashHandler(context.applicationContext)
}
}
}
}
fun getCrashReportFiles(context: Context): Array<File> {
val crashDir = File(context.getExternalFilesDir(null), CRASH_REPORTS_DIR)
return if (crashDir.exists() && crashDir.isDirectory) {
crashDir.listFiles { _, name -> name.endsWith(".log") } ?: emptyArray()
} else {
emptyArray()
}
}
fun clearCrashReports(context: Context) {
getCrashReportFiles(context).forEach { it.delete() }
}
}
private val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
init {
Thread.setDefaultUncaughtExceptionHandler(this)
}
override fun uncaughtException(thread: Thread, ex: Throwable) {
handleException(thread, ex)
}
/**
* 自动重启app
*/
private fun restartApp() {
// 延迟1秒后重启应用
Handler(Looper.getMainLooper()).postDelayed({
val intent = Intent(context, HomeActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
val pendingIntent = PendingIntent.getActivity(
context, 0, intent,
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
)
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
alarmManager.set(AlarmManager.RTC, System.currentTimeMillis() + 100, pendingIntent)
Process.killProcess(Process.myPid())
exitProcess(1)
}, 1000)
}
private fun handleException(thread: Thread, ex: Throwable) {
// 收集设备信息和异常信息
val crashInfo = collectCrashInfo(thread, ex)
// 保存日志文件
saveCrashInfoToFile(crashInfo)
// 这里可以添加其他处理逻辑,比如上传到服务器等
restartApp()
}
private fun collectCrashInfo(thread: Thread, ex: Throwable): String {
return buildString {
// 收集设备信息
collectDeviceInfo(this)
// 收集应用日志
try {
append("\n\n").append(collectLogs())
} catch (e: OutOfMemoryError) {
append("collectLogs发生OutOfMemoryError:${e.message}")
}
// 收集线程和异常信息
append("\n\n========== Thread & Exception Info ==========\n")
append("Thread: ${thread.name}\n")
append("Stack Trace:\n")
val sw = StringWriter()
val pw = PrintWriter(sw)
ex.printStackTrace(pw)
var cause: Throwable? = ex.cause
while (cause != null) {
cause.printStackTrace(pw)
cause = cause.cause
}
pw.close()
append(sw.toString())
}
}
private fun collectDeviceInfo(sb: StringBuilder) {
sb.append("========== Device Info ==========\n")
try {
// 应用信息
val pm = context.packageManager
val pi = pm.getPackageInfo(context.packageName, 0)
sb.append("App Version: ${pi.versionName}_${pi.versionCode}\n")
// Android 版本信息
sb.append("OS Version: ${Build.VERSION.RELEASE}_${Build.VERSION.SDK_INT}\n")
// 设备信息
sb.append("Vendor: ${Build.MANUFACTURER}\n")
sb.append("Model: ${Build.MODEL}\n")
sb.append("CPU ABI: ${Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown"}\n")
// 其他信息
sb.append("Locale: ${Locale.getDefault()}\n")
sb.append(
"Current Time: ${
SimpleDateFormat(
"yyyy-MM-dd HH:mm:ss",
Locale.getDefault()
).format(Date())
}\n"
)
// 内存信息
val memoryInfo = ActivityManager.MemoryInfo()
val activityManager =
context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
activityManager.getMemoryInfo(memoryInfo)
sb.append("Available Memory: ${memoryInfo.availMem / (1024 * 1024)}MB\n")
sb.append("Total Memory: ${memoryInfo.totalMem / (1024 * 1024)}MB\n")
sb.append("Low Memory: ${memoryInfo.lowMemory}\n")
} catch (e: Exception) {
Log.e(TAG, "Error while collecting device info", e)
sb.append("Error while collecting device info: ${e.message}\n")
}
}
private fun collectLogs(): String {
return buildString {
append("========== Application Logs ==========\n")
try {
val process = Runtime.getRuntime().exec("logcat -d -v threadtime")
val reader = process.inputStream.bufferedReader()
val logLines = reader.readLines()
val start = kotlin.comparisons.maxOf(0, logLines.size - LOG_LINES)
logLines.subList(start, logLines.size).forEach {
append(it).append("\n")
}
} catch (e: IOException) {
Log.e(TAG, "Error collecting logs", e)
append("Error collecting logs: ${e.message}\n")
}
}
}
private fun saveCrashInfoToFile(crashInfo: String) {
try {
val time = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.getDefault()).format(Date())
val fileName = "crash_$time.log"
val crashDir = File(context.getExternalFilesDir(null), CRASH_REPORTS_DIR)
if (!crashDir.exists() && !crashDir.mkdirs()) {
Log.d(TAG,"Failed to create crash report directory")
return
}
val crashFile = File(crashDir, fileName)
FileOutputStream(crashFile).use { it.write(crashInfo.toByteArray()) }
Log.d(TAG,"Crash info saved to: ${crashFile.absolutePath}")
} catch (e: Exception) {
Log.e(TAG, "Error saving crash info to file", e)
}
}
/**
* 清理旧的崩溃日志
*/
fun cleanupOldCrashReports(maxAgeDays: Int = 7) {
val crashDir = File(context.getExternalFilesDir(null), CRASH_REPORTS_DIR)
if (!crashDir.exists() || !crashDir.isDirectory) return
val now = System.currentTimeMillis()
val maxAgeMillis = maxAgeDays * 24 * 60 * 60 * 1000L
crashDir.listFiles()?.forEach { file ->
if (file.lastModified() < now - maxAgeMillis) {
file.delete()
}
}
}
}
@@ -0,0 +1,28 @@
package com.shuwei.dish.match.utils
/**
* 防抖工具
*/
class Debouncer(private val delayMillis: Long = 2000) {
private var lastActionTime = 0L
/**
* 执行防抖操作
* @param action 要执行的操作
* @return Boolean 是否执行了操作 (true=已执行, false=被防抖)
*/
fun debounce(action: () -> Unit): Boolean {
val currentTime = System.currentTimeMillis()
if (currentTime - lastActionTime >= delayMillis) {
lastActionTime = currentTime
action()
return true
}
return false
}
// 重置防抖计时
fun reset() {
lastActionTime = 0L
}
}
@@ -0,0 +1,91 @@
package com.shuwei.dish.match.utils;
import android.util.Log;
import com.wabon.wbintelligenthardwaresdk.api.SensorScale;
import java.lang.reflect.Method;
/**
* 修复 SensorScale 地址编码 bug 的子类。
*
* 问题根因:SensorScale.zeroTwo / tareTwo 内部使用
* String command = address + "630603";
* 将 int 地址直接拼接为十进制字符串,再整体按十六进制解析成字节。
* 对于地址 1-9,十进制 == 十六进制,偶然正确;
* 对于地址 10+,十进制 "10" 被解析为 0x10=16),命中错误的物理秤。
*
* 修复方式:重写这两个方法,将地址先格式化为两位十六进制字符串(如 10 → "0A"),
* 再拼接命令码,通过反射调用父类私有方法 suspendSendThread 和 sumHex 发送。
*/
public class FixedSensorScale extends SensorScale {
private static final String TAG = "FixedSensorScale";
public FixedSensorScale(OnScaleResult result) {
super(result);
}
/**
* 重写清零命令,修复地址编码:十进制地址 → 两位十六进制字符串。
* 例:address=10 → "0A630603",发送字节 0x0A(正确),而非原来的 0x10(错误)。
*
* @param listener 操作成功回调
* @param address 秤地址(来自 onGetWeight 回调的十进制值)
*/
@Override
public void zeroTwo(OperateSuccessListener listener, int address) {
sendFixedCommand(listener, address, "630603", "zeroTwo");
}
/**
* 重写去皮命令,修复地址编码,逻辑同 zeroTwo。
*
* @param listener 操作成功回调
* @param address 秤地址
*/
@Override
public void tareTwo(OperateSuccessListener listener, int address) {
sendFixedCommand(listener, address, "630601", "tareTwo");
}
/**
* 通用发送逻辑:将地址格式化为两位十六进制后拼接命令码,
* 通过反射调用父类私有方法 sumHex 计算校验和,再调用 suspendSendThread 发送。
*
* @param listener 操作成功回调
* @param address 秤地址(十进制)
* @param commandCode 命令码(十六进制字符串,如 "630603"
* @param methodName 调用方名称,仅用于日志
*/
private void sendFixedCommand(OperateSuccessListener listener, int address,
String commandCode, String methodName) {
try {
// 将十进制地址转为两位十六进制字符串:10 → "0A"1 → "01"
String hexAddress = String.format("%02X", address);
String command = hexAddress + commandCode;
Log.d(TAG, methodName + ", address=" + address
+ ", hexAddress=" + hexAddress + ", command=" + command);
// 反射调用 sumHex(String) 计算校验和
Method sumHexMethod = SensorScale.class.getDeclaredMethod("sumHex", String.class);
sumHexMethod.setAccessible(true);
String checksum = (String) sumHexMethod.invoke(this, command);
// 反射调用 suspendSendThread(String) 发送完整命令帧
Method suspendMethod = SensorScale.class.getDeclaredMethod(
"suspendSendThread", String.class);
suspendMethod.setAccessible(true);
suspendMethod.invoke(this, command + checksum);
// 保存回调(通过反射写入父类私有字段 mOperateSuccessListener
java.lang.reflect.Field listenerField = SensorScale.class
.getDeclaredField("mOperateSuccessListener");
listenerField.setAccessible(true);
listenerField.set(this, listener);
} catch (Exception e) {
Log.e(TAG, methodName + " 反射调用失败: address=" + address, e);
}
}
}
@@ -0,0 +1,251 @@
package com.shuwei.dish.match.utils
import android.content.Context
import android.graphics.Bitmap
import android.widget.ImageView
import androidx.annotation.DrawableRes
import com.bumptech.glide.Glide
import com.bumptech.glide.RequestBuilder
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.load.resource.bitmap.CircleCrop
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.request.target.Target
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.File
/**
* Glide图片加载工具类
*/
object GlideUtils {
// 默认配置
private val defaultOptions = RequestOptions()
.diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
.skipMemoryCache(false)
/**
* 普通加载图片
* @param context 上下文
* @param url 图片地址
* @param imageView 目标ImageView
* @param placeholderResId 占位图资源ID
* @param errorResId 错误图资源ID
*/
fun loadImage(
context: Context,
url: Any?,
imageView: ImageView,
@DrawableRes placeholderResId: Int = 0,
@DrawableRes errorResId: Int = 0
) {
val requestBuilder = Glide.with(context)
.load(url)
.apply(defaultOptions)
applyPlaceholder(requestBuilder, placeholderResId, errorResId)
.transition(DrawableTransitionOptions.withCrossFade())
.into(imageView)
}
/**
* 加载圆形图片
* @param context 上下文
* @param url 图片地址
* @param imageView 目标ImageView
* @param placeholderResId 占位图资源ID
* @param errorResId 错误图资源ID
*/
fun loadCircleImage(
context: Context,
url: Any?,
imageView: ImageView,
@DrawableRes placeholderResId: Int = 0,
@DrawableRes errorResId: Int = 0
) {
val requestBuilder = Glide.with(context)
.load(url)
.apply(defaultOptions)
.transform(CircleCrop())
applyPlaceholder(requestBuilder, placeholderResId, errorResId)
.into(imageView)
}
/**
* 加载圆角图片
* @param context 上下文
* @param url 图片地址
* @param imageView 目标ImageView
* @param radius 圆角半径(px)
* @param placeholderResId 占位图资源ID
* @param errorResId 错误图资源ID
*/
fun loadRoundCornerImage(
context: Context,
url: Any?,
imageView: ImageView,
radius: Int,
@DrawableRes placeholderResId: Int = 0,
@DrawableRes errorResId: Int = 0
) {
val requestBuilder = Glide.with(context)
.load(url)
.apply(defaultOptions)
.transform(RoundedCorners(radius))
applyPlaceholder(requestBuilder, placeholderResId, errorResId)
.into(imageView)
}
fun loadRoundCornerWitBitmap(
context: Context,
url: Bitmap,
imageView: ImageView,
radius: Int,
@DrawableRes placeholderResId: Int = 0,
@DrawableRes errorResId: Int = 0
) {
val requestBuilder = Glide.with(context)
.load(url)
.apply(RequestOptions.bitmapTransform(RoundedCorners(radius)))
applyPlaceholder(requestBuilder, placeholderResId, errorResId)
.into(imageView)
}
/**
* 加载图片并获取Bitmap
* @param context 上下文
* @param url 图片地址
* @param callback 加载回调
*/
fun loadImageAsBitmap(
context: Context,
url: Any?,
callback: (Bitmap?) -> Unit
) {
Glide.with(context)
.asBitmap()
.load(url)
.apply(defaultOptions)
.addListener(object : RequestListener<Bitmap> {
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Bitmap?>,
isFirstResource: Boolean
): Boolean {
callback(null)
return false
}
override fun onResourceReady(
resource: Bitmap,
model: Any,
target: Target<Bitmap?>?,
dataSource: DataSource,
isFirstResource: Boolean
): Boolean {
callback(resource)
return false
}
})
.submit()
}
/**
* 清除内存缓存
* @param context 上下文
*/
fun clearMemoryCache(context: Context) {
Glide.get(context).clearMemory()
}
/**
* 清除磁盘缓存
* @param context 上下文
*/
fun clearDiskCache(context: Context) {
CoroutineScope(Dispatchers.IO).launch {
Glide.get(context).clearDiskCache()
}
}
/**
* 获取缓存文件
* @param context 上下文
* @param url 图片地址
* @param callback 回调
*/
fun getImageCacheFile(
context: Context,
url: String,
callback: (File?) -> Unit
) {
Glide.with(context)
.downloadOnly()
.load(url)
.addListener(object : RequestListener<File> {
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<File?>,
isFirstResource: Boolean
): Boolean {
callback(null)
return false
}
override fun onResourceReady(
resource: File,
model: Any,
target: Target<File?>?,
dataSource: DataSource,
isFirstResource: Boolean
): Boolean {
callback(resource)
return false
}
})
.submit()
}
/**
* 暂停请求
* @param context 上下文
*/
fun pauseRequests(context: Context) {
Glide.with(context).pauseRequests()
}
/**
* 恢复请求
* @param context 上下文
*/
fun resumeRequests(context: Context) {
Glide.with(context).resumeRequests()
}
// 私有方法:应用占位图和错误图
private fun <T> applyPlaceholder(
requestBuilder: RequestBuilder<T>,
@DrawableRes placeholderResId: Int,
@DrawableRes errorResId: Int
): RequestBuilder<T> {
return requestBuilder.apply {
if (placeholderResId != 0) {
placeholder(placeholderResId)
}
if (errorResId != 0) {
error(errorResId)
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More