Author SHA1 Message Date
mazengfei 7b66652c0b 厨配秤: 统一采样数据结构及接口
- 将提交构成接口改为提交采样,废弃制作模式,调整请求参数结构
- 修改采样历史接口为已提交采样列表,调整接口路径和请求参数
- 更新数据模型,替换旧的审核状态为实验状态,新增实验编号字段
- 修改列表适配器显示逻辑,展示实验状态及熟重信息,调整颜色映射
- 调整相关网络请求及回调函数,删除无用参数,简化调用
- 更新UI页面文本,适配新版采样列表数据格式与提示信息
- 删除相关无用的旧状态定义及属性,保持代码整洁
2026-09-11 15:34:37 +08:00
mazengfei 9e4b2bd009 厨配秤: 统一采样数据结构及接口
- 将提交构成接口改为提交采样,废弃制作模式,调整请求参数结构
- 修改采样历史接口为已提交采样列表,调整接口路径和请求参数
- 更新数据模型,替换旧的审核状态为实验状态,新增实验编号字段
- 修改列表适配器显示逻辑,展示实验状态及熟重信息,调整颜色映射
- 调整相关网络请求及回调函数,删除无用参数,简化调用
- 更新UI页面文本,适配新版采样列表数据格式与提示信息
- 删除相关无用的旧状态定义及属性,保持代码整洁
2026-09-11 15:34:06 +08:00
mazengfei 6d3c5490a4 feat(food-prepare): 增加食材信息录入卡及相关接口支持
- 新增手动录入食材信息的UI布局,包含食材名称搜索、主辅材选择及用量输入
- 整合采样模式下的食材搜索弹窗,支持食材模糊查询与净材种类选择
- 新增相关网络接口调用,实现食材搜索、菜品搜索、菜品构成查询及采样历史等功能
- 优化食材列表展示,支持采样历史审核状态和熟重显示,增加颜色区分
- 修改PrepareFoodActivity,采样模式启用手动新增食材功能,包含添加、重置操作
- 调整Adapter和ViewModel,兼容新接口数据模型,支持分页加载和状态回调
- 修正接口路径和网络请求地址,统一使用nutrition/neglect路径前缀
- 删除冗余代码和未使用的旧接口代码,简化搜索逻辑及界面响应机制
2026-09-10 13:41:35 +08:00
mazengfei 45543295e3 Merge remote-tracking branch 'origin/dev_260316_新设备' into dev_260316_新设备 2026-09-08 14:13:05 +08:00
mazengfei 049c724303 config(base): 更新生产环境配置地址和设备ID
- 将appBaseUrl从UAT环境切换到PROD生产环境
- 更新PROD_BASE_URL为正式平台API地址
- 更换SCALE设备的正式环境DEVICE_ID_2
- 移除测试设备ID注释,启用正式设备ID配置
2026-09-08 14:12:48 +08:00
mazengfei acd1dd4605 fix(base): 更新生产环境接口地址
- 将PROD_BASE_URL从"api.dm.yixiong-tech.com:8443"替换为"platform-api.uat.shuziweidao.com"
2026-08-07 20:08:35 +08:00
mazengfei 17470bf12c feat(match): 添加烹饪开始时间和调料首次使用时间功能
- 在CookCompleteRequest中新增cookStart字段记录烹饪开始时间
- 在CookCompleteItem中新增putTime字段记录调料首次使用时间
- SubmitFoodActivity中初始化cookStartTime为进入页面时刻
- 记录调料首次拿取时间,存入seasoningFirstUseMap
- 提交接口时按调料名称匹配首次使用时间填充putTime字段
2026-08-06 18:08:51 +08:00
mazengfei 0a3185fc9e feat(cookOrder): 集成新配比秤接口和制作模式功能
- 新增接口查询当日烹饪任务列表及菜品食材构成(主材+辅材,不含调料)
- 增加调料模糊搜索功能,支持关键字分页查询
- 实现烹饪完成接口,支持提交主辅材用量、熟重及调料数据
- 在 PrepareFoodActivity 中添加烹制单 ID 传递和接口调用,区分采样和制作模式
- 在 SubmitFoodActivity 中实现制作模式烹饪完成数据组装与提交新接口逻辑
- 适配 FoodListFragment 和 FoodSearchActivity 使用熟重及烹制单 ID 关联映射
- RemoteRepository 和 NetViewModel 中新增对应接口调用方法及 UI 状态更新
- 优化 SeasoningSelectDialog 适配新接口数据结构及刷新逻辑
- 调整响应数据 ApiResponse 支持新接口标准成功码和分页字段
- 调整摄像头绑定逻辑,采样模式启用,制作模式禁用
- 统一将熟重字段用于菜品总重量计算,优化数据一致性及 UI 展示
2026-07-30 18:36:32 +08:00
mazengfei 9e41841086 feat(api): 增加当日烹饪任务列表新接口及相关数据模型支持
- 新增 CookOrderPageRequest、CookOrderMealGroup、CookOrderItem 等数据模型定义
- 在 ApiService 添加 getCookOrderPage 新接口,支持按餐次分组查询烹饪任务
- RemoteRepository 新增对应接口调用封装 getCookOrderPage
- NetViewModel 新增 cookOrderPageState 状态流及接口调用方法
- FoodListFragment 使用新接口替换旧搜索菜品接口,调整数据合并与展示逻辑
- FoodSearchActivity 使用新接口查询烹饪任务,并适配结果展示
- 更新请求拦截器添加 X-DEVICE-TOKEN 请求头支持设备登录Token传递
- 修改 GlobalData 默认基础URL为预发布环境地址
- 修正 ScaleDeviceConfig 中测试设备ID常量值
- SettingActivity 恢复环境切换入口显示,方便环境切换测试
- 补充配比秤-制作菜品新API接口完整文档,详细描述接口请求响应及业务逻辑
2026-07-29 17:59:14 +08:00
lvmeng 80d80e7e63 chore(utils): 注释掉 DecimalAdapter 类的实现代码
- 将整个 DecimalAdapter 类及其相关导入语句注释掉
- 包含 write 和 read 方法的完整实现都被注释
- 相关的包声明和使用方式注释也一并被注释掉
2026-05-25 18:01:18 +08:00
lvmengandClaude Sonnet 4.6 2d88ce1479 refactor(scale): 修复线程安全、生命周期与连接管理问题
- ScaleWebSocketServer:三个状态 Map 改为 ConcurrentHashMap,connectionCount 改为 AtomicInteger
- ScaleServiceManager:新增 serviceScope 统一管理协程生命周期,新增 @Volatile isStarted 防重复启动
- ScaleWebSocketClient:doConnect 写入新连接前先 close() 旧连接,防止 TCP 资源泄漏
- MdnsDiscoveryManager:移除 isResolving 上多余的 @Volatile 注解
- MasterScaleActivity:全限定类名改为 import + 短类名
- WeightUtil:删除 WeightListenerImpl 中注释掉的调试代码
- scale包架构分析.md:同步更新架构文档,已修复问题压缩为汇总表

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 17:20:15 +08:00
lvmengandClaude Sonnet 4.6 d580040c55 feat(fragment): 上传失败时透传接口错误原因至 toast 提示
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 18:17:26 +08:00
lvmengandClaude Sonnet 4.6 00bf478185 feat(fragment): 新增默认减重配置功能
- SpTool 新增 configWeight 属性,持久化存储默认减重克数
- 新增 WeightConfigFragment 及对应布局,支持数字输入和回填
- SingleFragmentActivity 注册 WEIGHT_CONFIG 页面类型
- SettingActivity 在食材采集下方新增减重配置入口
- PrepareFoodActivity 打开时从 SpTool.configWeight 初始化 initialWeight

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:39:38 +08:00
lvmeng ea79e584f3 refactor(utils): 简化 toast 扩展函数实现
- 移除多余的 null 检查代码块
- 删除已注释的旧版 toast 实现
- 简化条件判断语法
- 保留 ToastUtil 统一调用方式
2026-05-19 15:38:52 +08:00
lvmeng 1140d6bf91 refactor(ui): 提取 WeightUtil.getStateStr 统一秤状态文案,量程溢出时红色背景提示 2026-05-19 15:25:03 +08:00
lvmeng f3dbb3fe68 Merge remote-tracking branch 'origin/dev_260316_新设备' into dev_260316_新设备 2026-05-19 14:55:18 +08:00
mazengfei dc91cdfa2e 超量显示改为量程溢出 2026-05-19 14:42:29 +08:00
mazengfei a6ce3a1ed3 超量显示改为量程溢出 2026-05-19 14:41:51 +08:00
lvmeng 292fdade81 feat(ui): 添加用料明细预览功能和清空数据确认对话框
- 在 SubmitFoodActivity 中添加右侧图标显示并实现点击打开用料明细预览弹窗
- 新增 GoodsPreviewDialog 用于展示底部弹出的用料明细列表
- 创建 GoodsPreviewAdapter 和相关布局文件实现用料列表展示
- 添加 ic_list_view 图标用于右上角按钮
- 在 VectorCollectionFragment 中为清空数据按钮添加确认对话框
- 优化 WeightUtil 工具类添加可用性检查避免异常调用
2026-05-19 11:15:33 +08:00
lvmeng aa539d87d0 fix(base): 修复Loading弹窗管理和超时机制
- 添加了Loading超时自动关闭功能,默认15秒超时
- 实现了Activity销毁时只关闭自己的Loading,防止误关其他页面
- 优化了Loading重复显示时的节流处理,避免ANR问题
- 在BaseActivity中增加了showLoading方法的超时参数支持
- 为图片上传等长时间操作添加了60秒超时兜底机制
- 增加了相关日志便于调试Loading的显示和关闭流程
2026-05-18 18:11:26 +08:00
lvmeng e570707216 fix(seasoning): 移除调试用的空布局点击事件
- 注释掉loadMockData()方法调用
- 防止生产环境中误触加载模拟数据功能
2026-05-18 16:12:18 +08:00
lvmeng 87ca6abe1b fix(init): 解决Activity启动时序竞态问题
- 将finish()调用延迟到下一帧执行,避免与LaunchActivityItem事务竞态
- 修复"Activity client record must not be null"偶发崩溃问题
- 将goSampling方法改为suspend函数确保协程串行执行
- 保证startActivity在finish()之前完成,防止页面跳转失败
2026-05-18 16:05:26 +08:00
lvmeng 28784fa7c1 feat(db): 为数据库调试页面添加多字段动态筛选功能
- 在 CookFoodDao、CookFoodGoodsDao、SeasoningDao 和 SeasoningSlotDao 中添加 RawQuery 支持
- 为各数据访问对象添加动态条件分页查询和统计方法
- 在 DbRepository 中扩展动态查询相关方法
- 在 DbViewModel 中实现 DbInspectFilter 数据类和 buildQueries 动态 SQL 构建逻辑
- 更新 DbInspectFragment 添加筛选字段配置和 UI 生成逻辑
- 重构筛选条件收集和查询流程,支持精确匹配和模糊搜索
- 修改刷新按钮为查询按钮,更新 Tab 切换时的筛选框重建逻辑
- 调整设置页面标题从"设备设置"改为"设置"
2026-05-18 15:52:46 +08:00
lvmeng df01312577 feat(resources): 添加网格和列表布局图标资源
- 添加 ic_layout_grid.xml 矢量图标文件
- 添加 ic_layout_list.xml 矢量图标文件
- 配置图标尺寸为 24x24 dp
- 设置视口宽度和高度为 24
- 定义灰色填充颜色 #6666
- 实现网格布局图标的路径数据
- 实现列表布局图标的路径数据
2026-05-18 14:18:32 +08:00
lvmeng 7d3cb27b6e fix(ui): 修复调料列表布局切换功能
- 将布局容器从 LinearLayout 改为 FrameLayout 以支持更好的定位控制
- 调整标题文本的尺寸和对齐方式,设置固定高度 50dp
- 更新切换按钮的内边距并调整其在父容器中的位置
- 更改切换按钮的图标资源名称从 ic_switch_box 为 ic_layout_list
- 优化布局切换逻辑,图标始终显示目标布局样式
- 在切换时动态更新按钮图标以反映当前布局模式
2026-05-18 14:17:59 +08:00
lvmengandClaude Sonnet 4.6 ef6a29ffb0 fix(food): 修复并发竞态、双重刷新及搜索态丢失问题
- FoodListFragment:添加 currentJob 取消机制,防止快速切换时旧请求覆盖新数据
- FoodListFragment:缓存 currentFoodName,返回二级页面后 onResume 复用搜索词
- CookingModeActivity:RadioGroup+RadioButton 替换为 LinearLayout+CheckedTextView
- CookingModeActivity:新增 selectTab() 统一管理选中态,内置幂等保护
- CookingModeActivity:重构 onNewIntent,消除切换餐次时 getDishList 被双重调用的问题
- VectorCollectionFragment:Error/Success 消费后重置 StateFlow,防止 replay 导致 toast 重复弹出

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 12:09:21 +08:00
lvmeng d7f9fb05fb fix(food): 修复菜品列表刷新和净材对话框显示问题
- 在 Fragment 初始化时启用下拉刷新功能
- 将数据加载逻辑移至 onResume 方法中并添加异常处理
- 添加 onHiddenChanged 回调以在 Fragment 显示时重新加载数据
- 更新净材对话框为空时的提示文本和按钮样式
2026-05-18 11:49:57 +08:00
lvmeng d9cfd3007d fix(ui): 修复搜索列表状态管理和界面交互问题
- 添加了 clipToOutline 和 foreground 属性以改善列表项视觉效果
- 修复了 UiState 处理中未重置商品列表状态的问题
- 优化了 searchFood 方法中的状态重置逻辑,避免重复调用
- 修复了空视图点击监听器设置时机,确保在状态视图挂载完成后添加监听器
- 移除了重复的搜索调用,解决因 setText 触发的多次搜索问题
2026-05-18 09:43:42 +08:00
lvmeng c1f52c6337 fix(vector): 解决食材搜索列表状态管理问题
- 添加 resetGoodsListState 方法重置商品列表状态
- 添加 UiState.Error 状态处理并显示错误提示
- 搜索前重置商品列表状态避免状态残留
- 更新空数据显示文本为更明确的提示信息
- 点击空布局时触发重新搜索功能
2026-05-18 09:03:45 +08:00
lvmeng 5723209614 feat(app): 添加测试设备支持和芯片布局优化
- 在 BaseApp 中根据 BuildConfig.IS_TEST_DEVICE 配置设备 ID
- 更新 build.gradle 配置 IS_TEST_DEVICE 构建字段
- 添加 x86_64 架构支持用于 Windows 模拟器调试
- 优化 RawMaterialsDialogUtils 中的 Chip 布局和测量逻辑
- 在 WeightUtil 中添加架构检查避免非 ARM 设备崩溃
- 实现秤 SDK 可用性检测和异常处理机制
2026-05-15 17:16:00 +08:00
lvmeng d080ca0690 refactor(setting): 优化设置页面适配器点击事件处理
- 移除旧的直接点击监听器实现
- 集成防抖动点击事件处理机制
- 使用新的点击事件委托方式提升用户体验
- 改进适配器初始化逻辑结构
2026-05-15 15:21:44 +08:00
lvmengandClaude Sonnet 4.6 1024d82958 refactor(ui): 将设置页面由多个 LinearLayout 重构为 RecyclerView 实现
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 15:18:26 +08:00
lvmeng 8b19da2710 style(ui): 调整收藏食物页面界面样式
- 添加 dp 扩展函数导入用于尺寸转换
- 为删除图标设置左右 25dp 内边距以改善视觉效果
2026-05-15 14:52:28 +08:00
lvmengandClaude Sonnet 4.6 25167829cc feat(ui): 在已采集食材页添加清除全部数据功能
- ObjectBox 新增 removeAll() 方法清除所有向量数据
- DbViewModel 新增 clearAllCollectedFood() 方法并刷新列表
- CollectedFoodActivity 右上角添加垃圾桶图标,点击通过 CommonDialog 二次确认后清除

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 14:38:40 +08:00
lvmeng e38f19ac1b refactor(dialog): 移除食材搜索对话框中的测试代码
- 删除 buildMockGoodsList 扩展函数及相关导入
- 移除 FoodSearchDialog 中的假数据点击事件监听器
- 移除 VectorCollectionFragment 中的假数据填充逻辑
- 优化 RawMaterialsDialogUtils 中的 Chip 样式配置
- 清理相关文件的无用导入声明
2026-05-15 14:28:36 +08:00
lvmeng ada83b64e6 refactor(dialog): 优化 CommonDialog 代码质量
- 修复 setPositiveButton 中 lambda 隐式参数 it 被遮蔽的问题
- 提取 isMaster 为局部变量,消除重复计算
- addContentView 挂载前先 removeAllViews(),防止重复添加
2026-05-15 11:56:50 +08:00
lvmeng d3f02620af refactor(db): 将 entity 包拆分为 db/entity(数据库实体)和 model(数据模型)
- 新建 db/entity 包,迁入 4 个 Room 实体类:CookFoodEntity、CookFoodGoodsEntity、SeasoningEntity、SeasoningSlotEntity
- 新建 model 包,迁入 7 个普通数据模型:FoodRecord、GoodsItem、CookFoodDTO、CookFoodGoodsDTO、GoodsNameQueryDTO、CollectedFoodItem、DataBean
- 删除原 entity 包
- 更新全部引用文件的 import 路径(共 33 个文件)
2026-05-15 11:46:37 +08:00
lvmeng deb03a0c37 refactor(base): 重构基础组件和数据库访问层
- 修复 BaseActivity 中 rightIconActon 方法名拼写错误
- 重命名 appViewModel 为 dbViewModel 以统一数据库访问实例
- 优化 BaseApp 中的共享偏好设置获取逻辑
- 移除 GlobalData 中硬编码的应用ID和SDK密钥
- 重命名 NetworkUtils 为 NetworkUtil 并增强网络连接检测
- 重构 RemoteRepository 中的API调用方法结构
- 在多个Activity和Fragment中统一使用dbViewModel替代appViewModel
- 优化食品列表合并烹饪中条目的逻辑处理
- 调整文件上传时的媒体类型定义
- 清理注释和代码结构以提高可维护性
2026-05-15 11:24:44 +08:00
lvmeng b562694665 fix(ui): 修复子设备调料数据有时不显示的问题
将 observeScaleData 中的 collectLatest 改为 collect,
避免多个秤数据并发到来时 block 被取消导致 ts 已记录但
rawWeightMap 未更新,造成后续数据被永久跳过的竞态问题。
2026-05-15 10:14:48 +08:00
lvmeng 64586175ff fix(ui): 修复烹饪模式页面意图处理问题
- 移除了多余的空值检查操作符以简化代码
- 注释掉未使用的 isCooking 变量
- 使用 getOrNull 安全访问 fragmentList 避免越界异常
- 简化了 getIntentExtra 的调用方式
2026-05-15 09:55:51 +08:00
lvmeng e8431de53d refactor(ui): 将搜索框清空职责从getDishList移至调用方,修复下拉刷新误清输入框问题 2026-05-15 09:55:08 +08:00
lvmeng 4ccd4eaad0 fix(global): 将秤生命周期提升至进程级,修复子设备空白页和秤停止响应问题
- WeightUtil 初始化移至 BaseApp.onCreate(),通过 ActivityLifecycleCallbacks
  在最后一个 Activity 销毁时才调用 stopContinuousRead()
- InitActivity 删除 WeightUtil 的初始化/停止调用
- startNextPage() 跳转完成后执行 finish(),防止 InitActivity 残留任务栈
  导致返回时出现空白页,及被系统回收时触发 onDestroy 意外停止秤读取
2026-05-15 08:43:43 +08:00
lvmeng b44f4aae5a refactor(food): 统一商品数据模型字段映射逻辑
- 在 FoodSearchDialog 中添加 oldGoodsId 字段赋值逻辑
- 在 VectorCollectionFragment 中统一商品 ID 映射处理
- 将 goodsId 字段更新为 materId 的值或空字符串
- 保留原始 goodsId 值到 oldGoodsId 字段用于回溯
- 简化代码中的循环变量引用方式
2026-05-14 15:42:07 +08:00
lvmeng fee9598689 fix(ui): 修复初始化页面导航状态和设置页面跳转逻辑
- 在InitActivity中重写onNewIntent方法以重置导航状态
- 移除SettingActivity中的无用toast导入
- 简化SubmitFoodActivity启动语法
- 将设置页面清除数据后的跳转目标从HomeActivity改为InitActivity
2026-05-14 15:32:39 +08:00
lvmeng b24435744c feat(setting): 添加测试调料拿取功能并优化界面交互
- 在设置页面添加测试调料拿取入口并实现跳转功能
- 优化食材搜索对话框的网格布局管理器初始化逻辑
- 为未识别食材对话框添加重新识别和取消回调处理
- 优化准备食材页面标题文字显示效果
- 修复调料切换按钮点击防抖处理
2026-05-14 15:23:34 +08:00
lvmeng a257b03b9d refactor(sampling): 移除注释代码 2026-05-14 14:37:07 +08:00
lvmeng 89fff7c2b0 refactor(ui): 重构采样模式界面和数据处理逻辑
- 将 RadioButton 替换为 CheckedTextView 并调整布局结构
- 添加安全索引访问避免数组越界异常
- 优化数据库查询方法增加回调版本
- 更新崩溃处理器重启目标页面
- 添加单按钮对话框支持
- 优化采样模式页面数据加载和刷新逻辑
- 调整重量计算和数据处理流程
- 修复页面跳转和数据传递问题
2026-05-14 14:24:25 +08:00
lvmeng 6392b76ac2 fix(cooking): 解决搜索框内容清除和称重逻辑问题
- 在 CookingModeActivity 中添加 clearSearchText 方法用于清除搜索框内容
- 在 FoodListFragment 中添加协程调度器导入并实现空搜索时自动清除功能
- 在 SubmitFoodActivity 中新增初始重量和初始化状态变量
- 修改称重清零逻辑,清零后重置初始重量为 0
- 实现净重计算逻辑,使用秤读数减去初始重量得到实际净重
- 更新重量显示逻辑,使用净重数值进行单位转换和显示
2026-05-14 09:54:56 +08:00
lvmengandClaude Sonnet 4.6 8398bd4fd3 fix(global): App 启动时恢复本地保存的环境地址
- SpTool 新增 baseUrl 属性,统一管理环境地址的持久化读写
- EnvSwitchDialog 改用 SpTool.baseUrl 保存,移除直接调用 SpTool.put
- BaseApp.onCreate 启动时读取 SpTool.baseUrl 并赋值给 GlobalData.appBaseUrl

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 18:13:09 +08:00
lvmengandClaude Sonnet 4.6 535da33aab refactor(fragment): 改用回调方式查询菜品列表,修复刷新卡死问题
- FoodListFragment 改用 searchFoodListWithCallback 替代 SharedFlow collect,
  彻底解决下拉/上拉因数据相同导致不触发的问题
- NetViewModel 新增 searchFoodListWithCallback 回调版本
- CookingModeActivity loadFragment 改为 show/hide 方式,切换餐次不重建 Fragment
- 修复数据为空时未调用 finishRefresh 导致加载动画卡死的问题
- 新增 fragment-ktx 依赖,支持 by viewModels()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 17:54:59 +08:00
lvmengandClaude Sonnet 4.6 120f5432de refactor(fragment): FoodListFragment 独立持有 NetViewModel
每个 FoodListFragment 通过 by viewModels() 持有独立的 NetViewModel 实例,
searchFoodState 各自隔离,不再共享 Activity 的 netViewModel。

- 删除 pendingDinnerType 字段及餐次过滤逻辑
- initObserver 改为收 Fragment 自身的 netViewModel.searchFoodState
- getDishList 直接调 netViewModel.searchFoodList,不再经由 Activity 中转
- CookingModeActivity 删除 getFoodList() 透传方法

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 17:24:31 +08:00
lvmengandClaude Sonnet 4.6 61d11c6a78 fix/feat: 修复数据不稳定弹窗重复弹出 & 新增切换环境功能
fix: FoodRecognizeActivity 数据不稳定时弹窗会重复弹出
- 持有 unstableDialog 引用,通过 isShowing 防止重复弹出

feat: 新增切换环境功能(测试/UAT/生产)
- GlobalData 新增 KEY_BASE_URL,修正 UAT 端口为 8083
- SettingActivity 开发模式下显示「切换环境」入口
- 新增 EnvSwitchDialog 及其布局文件

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 17:18:30 +08:00
lvmengandClaude Sonnet 4.6 301f470274 fix(fragment): 修复切换餐次时数据串台问题
三个餐次 Fragment 共享同一个 searchFoodState,导致早餐的本地数据会被错误合并到午餐/晚餐列表中。

修复方案:
- 新增 pendingDinnerType 字段记录本次请求对应的餐次
- 响应回来时比对餐次,忽略非本 Fragment 发起的请求结果
- 确保每个 Fragment 只处理自己发起的请求数据

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 17:16:34 +08:00
lvmengandClaude Sonnet 4.6 67d64848de feat(activity): 搜索菜品直接刷新列表,切换餐次自动清空搜索框
- jumpSearch 改为调用 FoodListFragment.getDishList(foodName),不再跳转 FoodSearchActivity
- 切换餐次时清空输入框,用 isClearingByTabSwitch 标志避免重复请求
- 手动清空输入框时自动触发查全部数据
- FoodListFragment.getDishList 新增 foodName 参数,支持按名称过滤

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 16:22:46 +08:00
lvmeng ce7d2da6d8 feat(activity): 优化食材识别重量逻辑,修复主辅材重复保存问题
- PrepareFoodActivity:改为以首次秤读数为基准计算净重,注释清零按钮和测试按钮,修复 foodSelectCallback 中 isClicked 赋值对象错误导致选中状态异常的 bug,修复 getGoodsList 重复调用时主辅材累加的问题,向 FoodRecognizeActivity 传递 initialWeight
- FoodRecognizeActivity:启用秤重量监听,改为净重显示;用滑动窗口(最近10帧)替换延迟更新逻辑,关闭页面时从窗口中提取连续稳定值作为 useWeight,数据不稳定时弹窗提示而非直接关闭
- DbViewModel:saveCookFoodAndGoods 首次插入后回填 entity.id,防止重复进入时主辅材重复插入
- InitActivity:注释启动时自动清零秤的逻辑
- SubmitFoodActivity:将魔法数字 0.5 提取为常量 WEIGHT_THRESHOLD
2026-05-13 16:01:28 +08:00
lvmeng 65958bf637 feat(food): 优化食材识别功能并增加调料选择支持
- 将页面大小从30调整为100以提高加载效率
- 移除食材名称显示中的原始数据判断逻辑
- 在FoodRecognizeActivity中添加goodsId映射处理
- 修改GoodsItem实体类将goodsId和materId设为可变属性
- 新增oldGoodsId字段用于保存原始商品ID
- 创建新的调料搜索适配器和布局文件
- 更新调料选择对话框中的数据处理逻辑
2026-05-13 11:13:18 +08:00
lvmeng 7e72ce413b feat(food): 实现食材识别和净材类型功能
- 在FoodAdapter中添加选中状态背景和文字颜色变化
- 修改FoodMaterialAdapter显示食材名称时包含净材类型
- 在FoodModule中解析食材名称中的净材类型信息
- 移除食材识别页面的净材类型选择弹窗流程
- 优化食材识别结果处理逻辑和数据映射
- 添加未识别到食材时的提示对话框
- 实现向量采集页面的搜索功能和实时监听
- 优化网络请求的文件上传媒体类型设置
- 调整食材列表项的布局约束和样式配置
2026-05-12 18:15:40 +08:00
lvmengandClaude Sonnet 4.6 5745791041 feat(activity): 弹窗食材/调料查询使用独立 StateFlow,新增调料列表双布局切换功能
- NetViewModel 新增 foodSearchGoodsListState 和 queryFoodSearchGoodsList,与 goodsListState 隔离
- FoodSearchDialog、SeasoningSelectDialog 改用弹窗专用 State,避免影响 FoodRecognizeActivity 列表
- SeasoningWeightAdapter 支持 Grid(4列)/List(2列)双布局模式
- SubmitFoodActivity 添加调料列表布局切换按钮
- FoodModule 变量重命名并添加未初始化保护
- 注释掉 mock 食材数据,调整相关布局边距与文字截断

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 15:17:54 +08:00
lvmeng d412e03c6e perf(activity): 优化秤数据 StateFlow 重发时的无效遍历,添加 ts 去重跳过未变化帧;ScaleWebSocketServer 改为 trailing 节流并增加数据变化去重 2026-05-12 14:07:22 +08:00
lvmengandClaude Sonnet 4.6 b6389be898 feat(db): 支持早中晚三餐同一菜品独立保存及调料预填充优化
- 修复 getCookFoodById/deleteCookFood/markSubmittedAndDelete 查询条件缺少 dinnerType,
  导致同一 foodId 跨餐次保存时误判已存在、主记录和主辅材数据丢失的问题;
  查询键由三元组(canteenId+foodId+cookMode)升级为四元组(+dinnerType)
- SamplingModeActivity 构建 FoodRecord 时补充 dinnerType,确保删除操作能精确定位餐次
- SubmitFoodActivity 制作中模式下从数据库加载 goodsList 时预填充调料到 adapter,
  isCooking=false 时同样预填充前一页面带入的调料数据
- dm_cook_food 表新增 submitState 字段(DB v13),区分手动删除与接口提交删除
- 提交成功后改用 markSubmittedAndDelete 替代 deleteCookFoodAndGoods,正确置 submitState=1
- InitActivity onDestroy 不再停止秤服务,避免进程存活期间秤数据中断
- GoodsItem.isItemClicked 统一改为 isClicked,PrepareFoodActivity 食材选中逻辑同步更新
- AndroidManifest InitActivity 增加 singleTask 及 configChanges 配置

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 18:10:59 +08:00
lvmeng 919e43afba feat(dialog): 添加调料选择对话框的本地模拟数据功能
- 实现 loadMockData 函数用于加载本地模拟调料数据
- 在空布局上添加点击事件监听器,点击时加载模拟数据
- 将原有的 TODO 注释代码转换为正式的模拟数据加载功能
- 优化空布局的初始化逻辑并添加安全调用处理
2026-05-11 15:23:23 +08:00
lvmeng 0b9b3e8988 fix(activity): 修复调料 foodId 为空导致重新打开看不到调料数据 2026-05-11 14:39:12 +08:00
lvmeng 803ae75e40 fix(activity): 修复识图新增食材主辅材类型未显示
foodSelectCallback 新增 GoodsItem 时未将 isOriginalData 置为
false,导致 updateMaterialTypes 跳过计算,列表无法显示主辅材
2026-05-11 13:31:24 +08:00
lvmeng ec4c3f1232 refactor: 提取净材种类弹窗为工具类并统一各页面选择逻辑
- 新增 RawMaterialsDialogUtils,包含 showRawMaterialsDialog、
  buildSingleChoiceChipGroup、buildMockGoodsList 三个顶层函数
- FoodAdapter/list_item_food 新增净材种类选中结果展示
- VectorCollectionFragment、FoodRecognizeActivity、FoodSearchDialog
  的食材点击均改为弹出净材种类单选弹窗后再确认选中
- FoodSearchDialog 空布局补充点击加载测试数据
- setting_border 新增圆角
2026-05-11 12:07:06 +08:00
lvmeng 63911fe432 feat(network): queryGoodsList 入参 goodsNames 改为带净材种类的对象列表 2026-05-11 11:15:27 +08:00
lvmeng 8f15c0efb7 refactor(network): 菜品详情接口改用 DTO 与 Room 实体解耦 2026-05-11 11:10:45 +08:00
lvmeng 879af8426f feat(fragment): 食材采集页改用 ChipGroup 选择净材并新增列表空布局 2026-05-11 10:20:54 +08:00
lvmeng dc947df6e3 fix(network): 修复 uploadFoodVectorData 接口 PartMap 类型导致的 Retrofit 解析异常 2026-05-11 10:20:47 +08:00
lvmeng ef13aa8307 feat(fragment): 新增净材种类选择功能及相关数据结构支持
- 新增 rawMaterialsType 字段至 GoodsItem、CookFoodGoodsEntity、SeasoningEntity,DB 升级至 v12
- VectorCollectionFragment 重构食材搜索,点击食材弹出净材种类单选弹窗
- CommonDialog 新增 addContentView 和可拦截确认按钮(setPositiveButtonInterceptable)能力
- Food3Adapter 和 list_item_food3.xml 支持显示已选净材种类副标签
2026-05-09 18:10:07 +08:00
lvmeng a9663a21f0 style(ui): 调整食物识别页面布局间距和移除测试代码
- 调整 activity_food_recognize.xml 中多个组件的 margin 间距
- 移除 FoodRecognizeActivity.kt 中的测试数据相关注释代码
- 为 list_item_food2.xml 添加可点击背景效果
- 清理 PrepareFoodActivity.kt 中的无用导入和调整常量值
- 优化 PrepareFoodActivity.kt 中的条件判断换行格式
2026-05-09 14:45:18 +08:00
lvmeng d060305417 fix(food): 修复Food类的equals和hashCode方法
- 在equals方法中添加version字段比较
- 在hashCode方法中添加version字段哈希计算
- 确保对象比较的一致性
2026-05-09 13:53:00 +08:00
lvmeng bb319ee4ee feat(network): 新增菜品向量采集数据上传接口及调用链路
- ApiService/RemoteRepository/NetViewModel 新增 uploadFoodVectorData 三层实现,新增 uploadFoodVectorDataBatch 供分批循环调用直接 await 结果
- VectorCollectionFragment 启用 upload() 链路:调用 uploadFoodVectorDataBatch 分批上传,去掉 runBlocking/runOnUiThread,统一在协程主线程更新 UI
- ImageUploader 修正包路径并将 onProgress 改为 suspend lambda
- Food 实体新增 version 字段,同步 ObjectBox model
- SeasoningSelectDialog 切换为真实接口,FoodSearchDialog 注释模拟数据
- 调整删除按钮 drawable 为圆形并对列表 ItemView 增加 tint
2026-05-09 13:51:15 +08:00
lvmeng bb1ecbde43 feat: 新增 goodsCode 字段并同步迁移 DB v11,修复 IPv6 WebSocket 地址格式,重构 VectorCollectionFragment 相机回调为协程 2026-05-09 10:51:43 +08:00
lvmeng 7a03ab8108 refactor(activity): 统一 cook/submit 中调料数据处理逻辑,先清除 materialType==3 再从 adapter 覆盖写入 2026-05-09 10:51:04 +08:00
lvmeng 3b1dc0a03f refactor(activity): 统一各入口传 FoodRecord 跳转 PrepareFoodActivity,重构菜品详情加载逻辑 2026-05-08 18:38:14 +08:00
lvmeng f4d383dfe1 feat(adapter): FoodMaterialAdapter 无重量时 ic_dish_clear 染灰色并将点击转发为 item 选中 2026-05-08 16:12:03 +08:00
lvmengandClaude Sonnet 4.6 a14072af00 chore: 补充 SCALE_ORDER_1 配置、Weigher2 unInit 修复及调料列表模拟数据
- ScaleDeviceConfig:新增 SCALE_ORDER_1 单秤地址列表
- Weigher2:unInit() 补充调用 Weigher.unInit(),确保串口资源正确释放
- SeasoningSelectDialog:getGoodsList 临时改为模拟数据,待联调时切回真实接口

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:49:35 +08:00
lvmengandClaude Sonnet 4.6 436de88d73 fix(scale): 修复子设备重启后主设备看不到连接数据的问题,新增清除数据广播功能
- ScaleWebSocketClient:引入版本号机制防止 UDP 触发的新连接与指数退避重连任务并发建立两条连接;onFailure/onClosed 改用 remove(key, value) 原子操作,避免旧连接超时回调误删新连接引用
- ScaleWebSocketServer:新增 latestData 为空时延迟 3 秒补推机制,解决子设备刚重启时 WeightUtil 尚无读数导致主设备看不到数据的问题;新增 onClearData 回调处理清除数据事件;stop() 时同步关闭 scheduler
- ScaleEvent:新增 TYPE_CLEAR_DATA 事件类型
- ScaleServiceManager:新增 onClearData 属性和 sendClearData() 方法
- SlaveActivity:监听 onClearData 事件,收到后清空本机数据并重置 SP 状态;启动时预填空占位格子
- SettingActivity/DbViewModel/DbRepository:clearAllTestData 重命名为 clearAllData,清除完成后通知子设备并跳转 HomeActivity
- scale包架构分析.md:同步更新文档

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:48:59 +08:00
lvmengandClaude Sonnet 4.6 a0137d6979 feat(db): 新增测试数据清除功能,物理清空全部 4 张表并重置 SP 状态
- 各 DAO 新增 deleteAll() 物理清空方法
- DbRepository 新增 clearAllTestData(),按顺序删除食材→菜品→调料→槽位
- DbViewModel 暴露 clearAllTestData(onDone) 供 UI 层调用
- SettingActivity 新增「测试数据清除」入口(隐藏,右上角图标触发显示)
- 清除完成后重置 SpTool.cookMode=-1 和 SpTool.canteenId="0"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 11:58:32 +08:00
lvmeng c46264a4cf feat(activity): 新增 canteenId 持久化存储,HomeActivity 切换食堂时同步写入 SpTool 并恢复选中状态 2026-05-08 11:50:22 +08:00
lvmeng 979b93c53d feat(activity): 统一键盘收起逻辑,新增 RecyclerView 侧滑收起键盘支持 2026-05-08 11:10:13 +08:00
lvmeng 9880f2a5a8 feat(activity): 新增识别添加菜品时自动计算 materialType 逻辑 2026-05-08 10:26:13 +08:00
lvmeng 19b5d721a7 style(layout): 优化布局文件结构和属性配置
- 统一调整了 activity_setting.xml 中各个 LinearLayout 的属性顺序
- 为 fragment_seasoning_config.xml 和 fragment_vector_collection.xml
  添加了工具忽略属性
- 重新排列了 list_item_scale22_row.xml 和 list_item_scale_row.xml
  的命名空间和属性顺序
- 在 list_type_scale1.xml 中调整了组件的布局属性顺序
- 为设置页面添加了数据库查看和秤数据监控功能的可见性控制
- 修复了布局文件中的硬编码文本警告问题
2026-05-08 08:44:42 +08:00
lvmeng 0648f458a7 refactor(fragment): DbInspectFragment 改用嵌套 RecyclerView 展示字段,优化已删除记录视觉样式 2026-05-07 18:13:17 +08:00
lvmengandClaude Sonnet 4.6 c17402c3c2 feat(fragment): 新增 DbInspectFragment 本地数据库调试查看功能
- 新增 DbInspectFragment,支持按表切换(菜品/食材/调料/槽位)查看全量数据
- 分页加载(每页 50 条)+ isDel 过滤开关,避免大数据量时内存占用过高
- 切换 Tab 立即清空旧数据防止闪现,loadMoreDbInspect 加并发保护
- 槽位表字段少无 isDel,切换到该 Tab 时自动隐藏 isDel 相关控件
- 菜品/食材/调料表字段改为每行 3 列紧凑布局,槽位表保持逐行显示
- SettingActivity 新增"数据库查看"入口,复用 SingleFragmentActivity 承载
- 4 个 DAO 新增分页查询及计数方法,DbRepository/DbViewModel 同步扩展

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 13:44:03 +08:00
lvmengandClaude Sonnet 4.6 a64a00e099 refactor(seasoning): 调料数据改用 dm_seasoning 存储并统一逻辑删除
- SeasoningSelectDialog/SeasoningSearchAdapter 改用 GoodsItem 替代 SeasoningEntity
- SeasoningConfigFragment 选中调料时同步 upsert 完整信息到 dm_seasoning
- SubmitFoodActivity cook/submit 按 goodsId 查 dm_seasoning 补全调料字段
- SeasoningEntity 删除 @Ignore 的 pageType/isClicked 字段
- GoodsItem.toSeasoningEntity() 补全 materialType/relateionType 等字段映射
- DAO 物理删除改为逻辑删除(isDel=1),getSeasoningByGoodsId 参数改为 String
- AppDatabase version 9→10,删除 dm_cook_food.foodId 唯一索引以支持逻辑删除
- SamplingModeActivity 从 DeviceConfigFragment 返回时刷新本地列表
- ScaleWebSocketClient 修复 scheduler 关闭后重连抛出 RejectedExecutionException

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 10:47:32 +08:00
lvmeng 6595c4bde0 feat(dialog): FoodSearchDialog 新增空页面效果及 Window.Callback 手势冲突修复
- FoodSearchDialog:adapter 启用 isStateViewEnable,新增 loadEmptyView() 通过 adapter.stateView 展示空视图,替换原 toast 提示;覆写 show() 通过 Window.Callback.dispatchTouchEvent 动态控制 behavior.isDraggable,解决列表滑动与 BottomSheet 拖拽的手势冲突;注释旧 addOnScrollListener 方案保留备查
- SeasoningSelectDialog:同步补齐空页面效果,修复网络错误时也触发 loadEmptyView,优化 Handler 为 View.postDelayed
- FoodRecognizeActivity:修复识别结果回填后列表未显示的问题
- 新增 scale包架构分析.md 文档
2026-05-06 17:35:29 +08:00
lvmeng 2de5ec39b5 refactor(activity): 重构食材识别流程,改由接口查询识别结果,queryGoodsList 改为具名参数 2026-05-06 16:41:35 +08:00
lvmengandClaude Sonnet 4.6 e5fc0c265d refactor(entity): 新增 GoodsItem 统一物品数据模型,合并查询接口并迁移 UI 状态字段
- 新增 GoodsItem 纯数据类(DTO),整合接口字段与原 CookFoodGoodsEntity @Ignore UI 状态字段
- ApiService/RemoteRepository/NetViewModel 合并 queryGoodsList 与 querySeasoningList 为统一接口
- GoodsInfoSearchAdapter/FoodSearchAdapter/DishPartAdapter 实体类改为 GoodsItem
- FoodSearchDialog/FoodRecognizeDialog/FoodRecognizeActivity/VectorCollectionFragment 改用 GoodsItem
- PrepareFoodActivity.list 改为 GoodsItem,loadDishDetail 加 toGoodsItem() 转换,采集模式传 SubmitFoodActivity 前转回 CookFoodGoodsEntity
- CookFoodGoodsEntity 删除所有 @Ignore 字段,新增 toGoodsItem() 转换方法

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 15:00:32 +08:00
lvmeng 05063ee9de refactor(ui): 更新布局样式和清理无用代码
- 将活动主秤页面背景从颜色改为 drawable 背景
- 调整主秤页面标题的内边距、文字颜色和去除外边距
- 修改返回按钮尺寸并调整内边距为水平方向
- 更新设置页面三个组件的背景 drawable 样式
- 删除 ApiService 中大量注释掉的无用 API 接口代码
- 移除 CommonDialog 中不必要的导入和完整包名引用
- 清理 DataBean 中多个无用的数据类定义
- 新增白色带灰色边框圆角背景 drawable 资源
2026-05-06 11:13:42 +08:00
lvmengandClaude Sonnet 4.6 af737b910c fix(scale): 修复 UdpBroadcastSender 启动时 IP 为空导致广播失效
- UdpBroadcastSender 改为接收 ipProvider 函数替代静态 localIp 参数,每次广播时动态获取 IP
- IP 为空时跳过本次广播并等待下一周期重试,避免设备启动时网络未就绪导致广播携带空 IP
- ScaleServiceManager 更新调用方式,传入 lambda 动态获取 IP

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 10:01:26 +08:00
lvmengandClaude Sonnet 4.6 12c6b5cf94 fix(adapter): 修复 ScaleRowAdapter 点击事件偶发失效,改用同步更新替代 submitList
- 新增 updateItem / updateName / removeByAddress 同步更新方法,使用 set/add/removeAt 直接触发 notifyItemChanged
- 新增 onItemClick lambda 属性,点击时通过 bindingAdapterPosition 取最新数据
- SlaveActivity 中所有 submitList 调用替换为对应同步方法,消除 AsyncListDiffer 异步窗口期导致的点击丢失

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 10:01:19 +08:00
lvmeng e990652981 fix(scale): 修复子设备断线后重连慢及连接状态竞态问题
- onDeviceDisconnected 时同步清除 UDP 缓存,使子设备 5s 内即可重连,不再等待指数退避
- ScaleWebSocketServer 用 connectionCount 计数器替代 connections.isNullOrEmpty(),消除 onClose 竞态导致子设备 UI 状态不更新的问题
2026-05-06 09:03:40 +08:00
lvmeng 9cfc481cf3 fix(activity): 修复采样页越界崩溃、重复路由及搜索返回清空输入框问题 2026-04-30 15:42:44 +08:00
lvmengandClaude Sonnet 4.6 5e46c21469 fix(ui): 修复空态视图贴顶及刷新动画无法关闭问题
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 11:40:05 +08:00
lvmengandClaude Sonnet 4.6 858cc24046 fix(activity): 优化提交页返回未保存判断与采样页按钮样式
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 10:55:46 +08:00
lvmeng dc55d3fde5 refactor(swipe): 迁移侧滑删除至 SwipeRecyclerView,移除 SwipeRevealLayout
- 新增 SwipeRecyclerView.aar 及使用说明文档
- 删除 SwipeRevealLayout.java / ViewBinderHelper.java
- DishListFragment、SamplingModeActivity、PrepareFoodActivity 改用 setSwipeMenuCreator API
- FoodListAdapter、DishPartAdapter 移除 ViewBinderHelper 侧滑状态管理代码
- 布局文件将 RecyclerView 替换为 SwipeRecyclerView,item 布局移除 SwipeRevealLayout 根节点
2026-04-30 10:34:44 +08:00
lvmengandClaude Sonnet 4.6 3842391555 fix(fragment): 修复删除本地菜品后网络数据未恢复显示
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 09:07:05 +08:00
lvmengandClaude Sonnet 4.6 d1569faa85 feat(swipe): 接入 SwipeRevealLayout 并统一侧滑删除交互
引入 SwipeRevealLayout 替换原有滑动删除方案,修复点击冲突与开合状态问题,并在菜品列表与食材列表中统一删除确认与侧滑行为。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 18:18:32 +08:00
lvmeng a958262dff refactor(adapter): 新增 FoodListAdapter 合并 DishShowAdapter 与 SamplingAdapter 2026-04-29 15:16:59 +08:00
lvmeng fb691c508d refactor(entity): 重构数据实体类结构
- 将 FoodCollectionBean 从 objbox 包移动到 entity 包
- 在 DataBean.kt 中添加必要的 Android 图像处理相关导入
- 更新 VectorCollectionAdapter 中 FoodCollectionBean 的导入路径
- 更新 VectorCollectionFragment 中 FoodCollectionBean 的导入路径
- 重命名 DataBean.kt 为 FoodClassInfo.kt
- 移除 objbox 包中的重复 FoodCollectionBean 定义
2026-04-29 14:32:26 +08:00
lvmeng 8113c1d830 refactor(activity): 重构 BaseActivity 并优化相关页面
- 将 handler 改为 private,onDestroy 统一清理所有待执行任务
- 新增 protected postDelayed 方法供子类安全调度延时任务
- 将 DateTimeFormatter 提取为 companion object 常量,避免每秒重复创建
- setTitleBar 调用时自动显示标题栏
- 拆分 permissionCallback 为单权限和多权限独立回调,避免覆盖
- 将权限 launcher 改为 private,收敛访问入口
- 删除死代码 bgState/useBackground 及注释掉的旧代码
- InitActivity 倒计时改用私有 countdownHandler/countdownTask,解除对父类 handler 的依赖
- PrepareFoodActivity 替换 handler.postDelayed 为 BaseActivity.postDelayed
- FoodRecognizeActivity 持有弹窗引用防止重复打开
- BaseApp 使用 ScaleDeviceConfig.DEVICE_ID_2 替换硬编码设备 ID
2026-04-29 14:23:22 +08:00
lvmeng db839fd596 style(ui): 更新设置页面和调料列表界面样式
- 调整设置页面布局间距和背景样式
- 更换箭头图标资源文件
- 将调料列表改为网格布局显示
- 优化调料项目卡片样式和尺寸
- 修改RecyclerView为4列网格布局
2026-04-29 10:10:57 +08:00
lvmeng c2fd14cb6f fix(ui): 修复界面文本显示和布局问题
- 将"餐品模式"统一修改为"菜品模式"
- 优化列表项布局结构,使用ConstraintLayout替代LinearLayout
- 调整间距属性,将marginTop改为layout_marginVertical
- 重新设置文本和图标的位置约束关系
- 添加多点击检测器用于快速跳转到主页面的临时功能
2026-04-29 09:10:32 +08:00
lvmeng 95895e6390 remove(dish): 删除菜品采样相关功能代码
- 移除 activity_dish_sampling.xml 布局文件及其全部UI组件
- 删除 DishSamplingActivity 类的所有业务逻辑代码
- 移除 BaseReq 基础请求类定义
- 将 loading 图片资源引用从 loading_spinner 更新为 loading
2026-04-29 08:48:12 +08:00
lvmeng 9a0b066490 refactor(ui): 对页面名称进行统一重命名
- 将 SelectDishActivity 重命名为 CookingModeActivity
- 将 PrepareCookActivity 重命名为 PrepareFoodActivity
- 将 SamplingListActivity 重命名为 SamplingModeActivity
- 更新 AndroidManifest.xml 中的活动声明
- 修改 DishListFragment 中的相关引用
- 注释掉 DishSamplingActivity 的完整实现代码
- 更新 DeviceConfigFragment 中的活动跳转逻辑
- 优化 CookingModeActivity 的视图绑定方式
- 更新相关导入包路径和类引用
2026-04-28 18:13:50 +08:00
lvmengandClaude Sonnet 4.6 e8933fa578 fix(activity): 修复采样与菜品列表页异常场景的数据展示
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 17:46:19 +08:00
lvmeng 425f485b95 feat(ui): 将加载动画图标替换为 VectorDrawable,清理无用代码 2026-04-28 11:56:28 +08:00
lvmeng 264198463c style(ui): 优化界面布局和颜色配置
- 修改 RecyclerView 高度设置以改进布局
- 更新颜色值,移除 alpha 通道简化颜色定义
- 重构调味配置界面布局,提升视觉效果
- 调整项目条目样式,包括文字颜色和字体粗细
- 更新分割线为 Material Divider 组件
- 统一调整文本颜色和大小以增强可读性
- 优化调味网格适配器中的文字颜色显示
- 调整布局间距和尺寸参数以改善界面美观
- 优化重量数据显示,增加小数位数控制功能
2026-04-28 10:49:10 +08:00
lvmeng 89fa681722 style(ui): 优化食材识别页面界面布局和交互体验
- 将内边距设置从统一padding改为水平和底部独立设置
- 增加卡片圆角大小从8dp到12dp提升视觉效果
- 为商品列表添加白色圆角背景提升美观性
- 调整滚动条垂直边距和顶部间距
- 将选中食材和重量显示区域包裹在圆角容器中
- 添加食材名称文本的最大行数和省略效果
- 统一按钮边距使用水平和垂直边距设置
- 动态调整RecyclerView父容器高度适配内容
- 注释掉食材搜索弹窗的默认商品名称传递
- 在食材搜索弹窗中预设测试数据便于调试
2026-04-28 09:25:31 +08:00
183 changed files with 11383 additions and 5382 deletions
+356
View File
@@ -0,0 +1,356 @@
# SwipeRecyclerView
作者的主页:[https://www.yanzhenjie.com](https://www.yanzhenjie.com)
技术交流群:[46505645](https://jq.qq.com/?_wv=1027&k=5wY8UWl)
----
本库是基于RecyclerView的封装,提供了Item侧滑菜单、Item滑动删除、Item长按拖拽、添加HeaderView/FooterView、加载更多、Item点击监听等基本功能。
## 特性
1. Item侧滑菜单,支持水平分布、垂直分布
2. Item长按拖拽、侧滑删除
3. 添加/移除HeaderView/FooterView
4. **自动/点击**加载更多的功能
5. 支持二级列表,List形式、Grid形式、Staggered形式
6. Sticky普通布局黏贴和ReyclerView分组黏贴
7. 支持AndroidX
> 使用本库只需要使用SwipeRecyclerView即可,用法和原生RecyclerView一模一样,本库比原生的RecyclerView多了几个扩展方法。
## 截图
对上面提到的效果基本都有演示,但不是全部,更多效果可以下载Demo查看。
### Item侧滑菜单
<img src="./image/1.gif" width="180px"/> <img src="./image/2.gif" width="180px"/> <img src="./image/3.gif" width="180px"/>
### Item侧滑删除、拖拽
<img src="./image/4.gif" width="180px"/> <img src="./image/5.gif" width="180px"/> <img src="./image/6.gif" width="180px"/>
### 下拉刷新和加载更多
<img src="./image/7.gif" width="180px"/>
### HeaderView和FooterView
<img src="./image/8.gif" width="180px"/>
### Sticky效果和Item分组
<img src="./image/9.gif" width="180px"/> <img src="./image/10.gif" width="180px"/>
### 和DrawerLayout嵌套
<img src="./image/11.gif" width="180px"/>
## 如何使用
如果你使用的是android support库,那么请添加下述依赖:
```groovy
implementation 'com.yanzhenjie.recyclerview:support:1.3.2'
```
如果你使用的是android x库,那么请添加下述依赖:
```groovy
implementation 'com.yanzhenjie.recyclerview:x:1.3.2'
```
> **1. SwipeRecyclerView从1.3.0版本开始支持AndroidX和二级列表,因此相对于低版本的包名和类名有所改动,从低版本升级的开发者需要考量是否要升级。**
**2. 为了让开发者方便切换support库和x库,SwipeRecyclerView的support库和x库除了依赖时的名称不一样外,包名、控件名和类名都是一样的,因此两个库不能共存。**
### 加入布局
在布局的xml中加入`SwipeRecyclerView`
```xml
<com.yanzhenjie.recyclerview.SwipeRecyclerView
.../>
```
### ItemDecoration
也就是分割线,支持Grid形式和Linear形式,可以选择某个ViewType不画分割线:
```java
// 默认构造,传入颜色即可。
ItemDecoration itemDecoration = new DefaultDecoration(color);
// 或者:颜色,宽,高,最后一个参数是不画分割线的ViewType,可以传入多个。
itemDecoration = new DefaultDecoration(color, width, height, excludeViewType);
// 或者:例如下面的123都是不画分割线的ViewType
itemDecoration = new DefaultDecoration(color, width, height, 1, 2, 3);
SwipeRecyclerView recyclerView = ...;
recyclerView.setDecoration(itemDecoration);
```
### Item点击监听
```java
recyclerView.setOnItemClickListener(new OnItemClickListener() {
@Override
public void onItemClick(View view, int position) {
// TODO...
}
});
```
### 侧滑菜单
```java
// 设置监听器。
swipeRecyclerView.setSwipeMenuCreator(mSwipeMenuCreator);
// 创建菜单:
SwipeMenuCreator mSwipeMenuCreator = new SwipeMenuCreator() {
@Override
public void onCreateMenu(SwipeMenu leftMenu, SwipeMenu rightMenu, int position) {
SwipeMenuItem deleteItem = new SwipeMenuItem(mContext)
...; // 各种文字和图标属性设置。
leftMenu.addMenuItem(deleteItem); // 在Item左侧添加一个菜单。
SwipeMenuItem deleteItem = new SwipeMenuItem(mContext)
...; // 各种文字和图标属性设置。
leftMenu.addMenuItem(deleteItem); // 在Item右侧添加一个菜单。
// 注意:哪边不想要菜单,那么不要添加即可。
}
};
// 菜单点击监听。
swipeRecyclerView.setOnItemMenuClickListener(mItemMenuClickListener);
OnItemMenuClickListener mItemMenuClickListener = new OnItemMenuClickListener() {
@Override
public void onItemClick(SwipeMenuBridge menuBridge, int position) {
// 任何操作必须先关闭菜单,否则可能出现Item菜单打开状态错乱。
menuBridge.closeMenu();
// 左侧还是右侧菜单:
int direction = menuBridge.getDirection();
// 菜单在Item中的Position
int menuPosition = menuBridge.getPosition();
}
};
```
**注意**:菜单需要设置高度,关于菜单高度:
1. `MATCH_PARENT`,自动适应Item高度,保持和Item一样高,比较推荐;
2. 指定具体的高,比如80;
3. `WRAP_CONTENT`,自身高度,极不推荐;
### 侧滑删除和拖拽
拖拽和侧滑删除的功能默认关闭的,所以先要打开功能:
```java
recyclerView.setLongPressDragEnabled(true); // 拖拽排序,默认关闭。
recyclerView.setItemViewSwipeEnabled(true); // 侧滑删除,默认关闭。
```
只需要设置上面两个属性就可以进行相应的动作了,如果不需要哪个,不要打开就可以了。
然后监听拖拽和侧滑的动作,进行数据更新:
```java
recyclerView.setOnItemMoveListener(mItemMoveListener);// 监听拖拽,更新UI。
OnItemMoveListener mItemMoveListener = new OnItemMoveListener() {
@Override
public boolean onItemMove(ViewHolder srcHolder, ViewHolder targetHolder) {
// 此方法在Item拖拽交换位置时被调用。
// 第一个参数是要交换为之的Item,第二个是目标位置的Item。
// 交换数据,并更新adapter。
int fromPosition = srcHolder.getAdapterPosition();
int toPosition = targetHolder.getAdapterPosition();
Collections.swap(mDataList, fromPosition, toPosition);
adapter.notifyItemMoved(fromPosition, toPosition);
// 返回true,表示数据交换成功,ItemView可以交换位置。
return true;
}
@Override
public void onItemDismiss(ViewHolder srcHolder) {
// 此方法在Item在侧滑删除时被调用。
// 从数据源移除该Item对应的数据,并刷新Adapter。
int position = srcHolder.getAdapterPosition();
mDataList.remove(position);
adapter.notifyItemRemoved(position);
}
};
```
**特别注意**:如果`LayoutManager``List`形式,那么Item拖拽时只能从1-2-3-4这样走,如果你的`LayoutManager``Grid`形式的,那么Item可以从1直接到3或者5或者6...,这样数据就会错乱,所以**当`LayoutManager`是Grid形式时**这里要特别注意转换数据位置的算法:
```java
@Override
public boolean onItemMove(ViewHolder srcHolder, ViewHolder targetHolder) {
int fromPosition = srcHolder.getAdapterPosition();
int toPosition = targetHolder.getAdapterPosition();
if (fromPosition < toPosition) {
for (int i = fromPosition; i < toPosition; i++) {
Collections.swap(mDataList, i, i + 1);
}
} else {
for (int i = fromPosition; i > toPosition; i--) {
Collections.swap(mDataList, i, i - 1);
}
}
mMenuAdapter.notifyItemMoved(fromPosition, toPosition);
return true;
}
```
我们还可以监听用户的侧滑删除和拖拽Item时的手指状态:
```java
recyclerView.setOnItemStateChangedListener(mStateChangedListener);
...
private OnItemStateChangedListener mStateChangedListener = (viewHolder, actionState) -> {
if (actionState == OnItemStateChangedListener.ACTION_STATE_DRAG) {
// 状态:正在拖拽。
} else if (actionState == OnItemStateChangedListener.ACTION_STATE_SWIPE) {
// 状态:滑动删除。
} else if (actionState == OnItemStateChangedListener.ACTION_STATE_IDLE) {
// 状态:手指松开。
}
};
```
想用户触摸到某个`Item`时就开始拖拽或者侧滑删除时,只需要调用`startDrag()``startSwipe()`并转入当前`Item``ViewHoler`即可。
触摸拖拽:
```java
swipeRecyclerView.startDrag(ViewHolder);
```
触摸侧滑删除:
```java
swipeRecyclerView.startSwipe(ViewHolder);
```
### HeaderView和FooterView
主要方法:
```java
addHeaderView(View); // 添加HeaderView。
removeHeaderView(View); // 移除HeaderView。
addFooterView(View); // 添加FooterView。
removeFooterView(View); // 移除FooterView。
getHeaderItemCount(); // 获取HeaderView个数。
getFooterItemCount(); // 获取FooterView个数。
getItemViewType(int); // 获取Item的ViewType,包括HeaderView、FooterView、普通ItemView。
```
添加/移除`HeaderView`/`FooterView``setAdapter()`的调用不分先后顺序。
**特别注意**
1. 如果添加了`HeaderView`,凡是通过`ViewHolder`拿到的`position`都要减掉`HeaderView`的数量才能得到正确的`position`
### 加载更多
本库默认提供了加载更多的动画和View,开发者也可以自定义,默认支持`RecyclerView`自带的三种布局管理器。
默认加载更多:
```java
RecyclerView recyclerView = ...
...
recyclerView.useDefaultLoadMore(); // 使用默认的加载更多的View。
recyclerView.setLoadMoreListener(mLoadMoreListener); // 加载更多的监听。
LoadMoreListener mLoadMoreListener = new LoadMoreListener() {
@Override
public void onLoadMore() {
// 该加载更多啦。
... // 请求数据,并更新数据源操作。
mMainAdapter.notifyDataSetChanged();
// 数据完更多数据,一定要调用这个方法。
// 第一个参数:表示此次数据是否为空。
// 第二个参数:表示是否还有更多数据。
mRecyclerView.loadMoreFinish(false, true);
// 如果加载失败调用下面的方法,传入errorCode和errorMessage。
// errorCode随便传,你自定义LoadMoreView时可以根据errorCode判断错误类型。
// errorMessage是会显示到loadMoreView上的,用户可以看到。
// mRecyclerView.loadMoreError(0, "请求网络失败");
}
};
```
自定义加载更多View也很简单,自定义一个View,并实现一个接口即可:
```java
public class DefineLoadMoreView extends LinearLayout
implements SwipeRecyclerView.LoadMoreView,
View.OnClickListener {
private LoadMoreListener mLoadMoreListener;
public DefineLoadMoreView(Context context) {
super(context);
...
setOnClickListener(this);
}
/**
* 马上开始回调加载更多了,这里应该显示进度条。
*/
@Override
public void onLoading() {
// 展示加载更多的动画和提示信息。
...
}
/**
* 加载更多完成了。
*
* @param dataEmpty 是否请求到空数据。
* @param hasMore 是否还有更多数据等待请求。
*/
@Override
public void onLoadFinish(boolean dataEmpty, boolean hasMore) {
// 根据参数,显示没有数据的提示、没有更多数据的提示。
// 如果都不存在,则都不用显示。
}
/**
* 加载出错啦,下面的错误码和错误信息二选一。
*
* @param errorCode 错误码。
* @param errorMessage 错误信息。
*/
@Override
public void onLoadError(int errorCode, String errorMessage) {
}
/**
* 调用了setAutoLoadMore(false)后,在需要加载更多的时候,此方法被调用,并传入listener。
*/
@Override
public void onWaitToLoadMore(SwipeRecyclerView.LoadMoreListener loadMoreListener) {
this.mLoadMoreListener = loadMoreListener;
}
/**
* 非自动加载更多时mLoadMoreListener才不为空。
*/
@Override
public void onClick(View v) {
if (mLoadMoreListener != null) mLoadMoreListener.onLoadMore();
}
}
```
## 感谢与参考
* [cube-sdk](https://github.com/liaohuqiu/cube-sdk)
* [SwipeMenu](https://github.com/TUBB/SwipeMenu/)
* [HeaderAndFooterWrapper](https://github.com/hongyangAndroid/baseAdapter/blob/master/baseadapter-recyclerview/src/main/java/com/zhy/adapter/recyclerview/wrapper/HeaderAndFooterWrapper.java)
加载更多的灵感来自`cube-sdk`,侧滑菜单参考了`SwipeMenu`,添加`HeaderView`参考了`HeaderAndFooterWrapper`类,特别感谢上述开源库及其作者。
## License
```text
Copyright 2019 Zhenjie Yan
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.
```
+7 -7
View File
@@ -1,4 +1,3 @@
plugins {
// alias(libs.plugins.android.application)
// alias(libs.plugins.kotlin.android)
@@ -24,9 +23,8 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
ndk {
//设置支持的SO库架构
//设置支持的SO库架构x86_64 仅用于在 Windows 模拟器上调试(秤功能不可用)
abiFilters.addAll(listOf("armeabi-v7a"))
//, 'x86', 'armeabi-v7a', 'x86_64', 'arm64-v8a'
}
// 设置输出APK文件名格式
@@ -48,12 +46,13 @@ android {
"proguard-rules.pro"
)
signingConfig = signingConfigs.getByName("debug_507")
// buildConfigField("boolean", "DEBUG", "false")
buildConfigField("Boolean", "IS_TEST_DEVICE", "false")
}
debug {
//507扫码秤主板在android.buildTypes{}内增加
signingConfig = signingConfigs.getByName("debug_507")
// buildConfigField("boolean", "DEBUG", "true")
// 调试构建视为测试设备,使用固定的 DEVICE_ID_1 代替真实 UDID
buildConfigField("Boolean", "IS_TEST_DEVICE", "false")
}
}
compileOptions {
@@ -98,6 +97,7 @@ dependencies {
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
implementation(libs.androidx.activity.ktx)
implementation(libs.androidx.fragment.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
@@ -138,8 +138,8 @@ dependencies {
implementation(libs.androidx.camera.extensions)
//pytorch
implementation (libs.pytorch.android)
implementation (libs.pytorch.android.torchvision)
implementation(libs.pytorch.android)
implementation(libs.pytorch.android.torchvision)
// objectbox
if (isDebug) {
Binary file not shown.
+6 -1
View File
@@ -5,7 +5,7 @@
"entities": [
{
"id": "1:6465153867413579585",
"lastPropertyId": "4:3660727688686055762",
"lastPropertyId": "5:7592685238899911344",
"name": "Food",
"properties": [
{
@@ -30,6 +30,11 @@
"indexId": "1:5497952887792054610",
"type": 28,
"flags": 8
},
{
"id": "5:7592685238899911344",
"name": "version",
"type": 9
}
],
"relations": []
+10 -8
View File
@@ -45,6 +45,8 @@
<activity
android:name=".ui.InitActivity"
android:exported="true"
android:launchMode="singleTask"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden|keyboard|navigation|uiMode|locale|layoutDirection|fontScale|density"
tools:ignore="DiscouragedApi,LockedOrientationActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -58,23 +60,23 @@
tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<activity
android:name=".ui.SelectDishActivity"
android:name=".ui.CookingModeActivity"
android:theme="@style/Theme.DishMatch.NoSplash"
android:launchMode="singleTask"
android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<activity
android:name=".ui.PrepareCookActivity"
android:name=".ui.PrepareFoodActivity"
android:theme="@style/Theme.DishMatch.NoSplash"
android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<!-- <activity-->
<!-- android:name=".ui.DishSamplingActivity"-->
<!-- android:theme="@style/Theme.DishMatch.NoSplash"-->
<!-- android:screenOrientation="portrait"-->
<!-- tools:ignore="DiscouragedApi,LockedOrientationActivity" />-->
<activity
android:name=".ui.DishSamplingActivity"
android:theme="@style/Theme.DishMatch.NoSplash"
android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<activity
android:name=".ui.SamplingListActivity"
android:name=".ui.SamplingModeActivity"
android:theme="@style/Theme.DishMatch.NoSplash"
android:launchMode="singleTask"
android:screenOrientation="portrait"
@@ -6,7 +6,7 @@ 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
import com.shuwei.dish.match.model.CollectedFoodItem
/**
* 已采集食材列表适配器
@@ -1,62 +0,0 @@
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.ListItemDishCookBinding
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.visible
import java.text.DecimalFormat
class DishPartAdapter(list: MutableList<CookFoodGoodsEntity>) :
BaseQuickAdapter<CookFoodGoodsEntity, DishPartAdapter.VH>(list) {
inner class VH(var binding: ListItemDishCookBinding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val inflater = LayoutInflater.from(context)
val binding = ListItemDishCookBinding.inflate(inflater, parent, false)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int, item: CookFoodGoodsEntity?) {
holder.binding.run {
tvDishName.text = item!!.goodsName
tvDishType.text = if (item.materialType == 1) "主辅材:主材" else if (item.materialType == 2) "主辅材:辅材" else ""
//"${DecimalFormat("#").format(item.useWeight)}克"
tvDishWeight.text =
if (item.useWeight == null || item.useWeight == 0.toDouble()) "" else "${item.useWeight!!.roundedOneDecimalPlace()}"
tvDishWeight.setTextColor(
ContextCompat.getColor(
context,
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(
if (item.isSetFinished) R.drawable.ic_dish_selected
else R.drawable.ic_dish_unselected
)
root.run {
setBackgroundResource(
if (item.isItemClicked) R.drawable.shape_item_cook_dish
else R.drawable.shape_white_fb_15_corners
)
}
}
}
}
@@ -1,62 +0,0 @@
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.entity.FoodRecord
import com.shuwei.dish.match.databinding.ListItemDishBinding
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.visible
import java.text.DecimalFormat
class DishShowAdapter(list: MutableList<FoodRecord>) :
BaseQuickAdapter<FoodRecord, DishShowAdapter.VH>(list) {
inner class VH(var binding: ListItemDishBinding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val inflater = LayoutInflater.from(context)
val binding = ListItemDishBinding.inflate(inflater, parent, false)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int, item: FoodRecord?) {
holder.binding.run {
val isCooking = item!!.isCooking
val totalWeight = item.totalWeight ?: 0.toDouble()
tvDishName.text = item!!.foodName
if (isCooking) {
tvDishName.setTextColor(
ContextCompat.getColor(holder.itemView.context, R.color.black)
)
clBlock.setBackgroundResource(R.drawable.shape_white_fb_15_corners)
tvDishCount.visible()
tvDishCount.text = "制作统计:-kg"
tvShowState.text = "烹饪中"
} else if (totalWeight > 0.toDouble()) {
tvDishName.setTextColor(
ContextCompat.getColor(holder.itemView.context, R.color.dish_green)
)
clBlock.setBackgroundResource(R.drawable.shape_white_fb_15_corners)
tvDishCount.visible()
tvDishCount.text =
"累计统计:${df.format(totalWeight / 1000.0F)}kg(${item.count}次)"
tvShowState.text = "${df.format((item.foodWeight ?: 0.toDouble()) / 1000.0F)}kg"
} else {
tvDishName.setTextColor(
ContextCompat.getColor(holder.itemView.context, R.color.black999)
)
clBlock.setBackgroundResource(R.drawable.shape_white_dash_15_corners)
tvDishCount.gone()
tvShowState.text = "-"
}
}
}
val df = DecimalFormat("0.000")
}
@@ -5,13 +5,16 @@ 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.ListItemDishRecordBinding
import com.shuwei.dish.match.entity.FoodRecord
import com.shuwei.dish.match.databinding.ListItemFood2Binding
import com.shuwei.dish.match.model.FoodRecord
class FoodRecordAdapter(list: MutableList<FoodRecord>) :
BaseQuickAdapter<FoodRecord, FoodRecordAdapter.VH>(list) {
/**
* 菜品搜索页面的适配器
*/
class Food2Adapter(list: MutableList<FoodRecord>) :
BaseQuickAdapter<FoodRecord, Food2Adapter.VH>(list) {
inner class VH(var binding: ListItemDishRecordBinding) : QuickViewHolder(binding.root)
inner class VH(var binding: ListItemFood2Binding) : QuickViewHolder(binding.root)
override fun onBindViewHolder(
holder: VH,
@@ -27,7 +30,7 @@ class FoodRecordAdapter(list: MutableList<FoodRecord>) :
viewType: Int
): VH {
val inflater = LayoutInflater.from(context)
val binding = ListItemDishRecordBinding.inflate(inflater, parent, false)
val binding = ListItemFood2Binding.inflate(inflater, parent, false)
return VH(binding)
}
}
@@ -0,0 +1,50 @@
package com.shuwei.dish.match.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.dish.match.databinding.ListItemFood3Binding
import com.shuwei.dish.match.model.GoodsItem
/**
* 向量采集页面的适配器
*/
class Food3Adapter(list: MutableList<GoodsItem>) :
BaseQuickAdapter<GoodsItem, Food3Adapter.VH>(list) {
override fun onBindViewHolder(
holder: VH,
position: Int,
item: GoodsItem?
) {
item ?: return
holder.binding.tvFoodName.run {
text = item.goodsName
isChecked = item.isClicked
}
// 显示当前选中的净材种类,无数据时隐藏
holder.binding.tvRawMaterialsType.run {
if (item.rawMaterialsType.isNullOrEmpty()) {
visibility = View.GONE
} else {
visibility = View.VISIBLE
text = item.rawMaterialsType
}
}
}
override fun onCreateViewHolder(
context: Context,
parent: ViewGroup,
viewType: Int
): VH {
val binding = ListItemFood3Binding.inflate(LayoutInflater.from(context), parent, false)
return VH(binding)
}
inner class VH(var binding: ListItemFood3Binding) : QuickViewHolder(binding.root)
}
@@ -7,41 +7,56 @@ 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.ListItemSearchGoodsInfoBinding
import com.shuwei.dish.match.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.databinding.ListItemFoodBinding
import com.shuwei.dish.match.model.GoodsItem
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>) :
BaseQuickAdapter<CookFoodGoodsEntity, GoodsInfoSearchAdapter.VH>(list) {
/**
* 识别页面和搜索弹窗的适配器
*/
class FoodAdapter(list: MutableList<GoodsItem>) :
BaseQuickAdapter<GoodsItem, FoodAdapter.VH>(list) {
override fun onBindViewHolder(
holder: VH,
position: Int,
item: CookFoodGoodsEntity?
item: GoodsItem?
) {
item ?: return
holder.binding.tvGoodsInfo.run {
text = item.goodsName
holder.binding.root.run {
setBackgroundResource(
if (item.isClicked == true) R.drawable.shape_green_stroke
if (item.isClicked) R.drawable.shape_green_stroke
else R.drawable.shape_white_12_corners
)
}
holder.binding.tvGoodsInfo.run {
text = item.goodsName
setTextColor(
ContextCompat.getColor(
context,
if (item.isClicked == true) R.color.dish_green
if (item.isClicked) R.color.dish_green
else R.color.black666
)
)
}
if (item.foodScore == -1){
if (item.foodScore == -1) {
holder.binding.tvFoodScore.gone()
} else {
holder.binding.tvFoodScore.let {
it.visible()
it.text = "${(item.foodScore / 100.0).roundedDecimalPlace(2)}%"
val foodScoreText = "${(item.foodScore / 100.0).roundedDecimalPlace(2)}%"
it.text = foodScoreText
}
}
if (item.rawMaterialsType.isNullOrBlank()) {
holder.binding.tvRawMaterialsType.gone()
} else {
holder.binding.tvRawMaterialsType.let {
it.visible()
it.text = item.rawMaterialsType
}
}
}
@@ -52,11 +67,10 @@ class GoodsInfoSearchAdapter(private var list: MutableList<CookFoodGoodsEntity>)
viewType: Int
): VH {
val binding =
ListItemSearchGoodsInfoBinding.inflate(LayoutInflater.from(context), parent, false)
ListItemFoodBinding.inflate(LayoutInflater.from(context), parent, false)
return VH(binding)
}
inner class VH(var binding: ListItemSearchGoodsInfoBinding) : QuickViewHolder(binding.root)
inner class VH(var binding: ListItemFoodBinding) : QuickViewHolder(binding.root)
}
@@ -0,0 +1,125 @@
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.ListItemFoodListBinding
import com.shuwei.dish.match.model.FoodRecord
import com.shuwei.dish.match.model.SampleStatus
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.visible
import java.text.DecimalFormat
/**
* 菜品列表通用 Adapter,合并了 DishShowAdapter 与 SamplingAdapter 的功能。
*
* @param list 数据列表
* @param mode 显示模式:
* - [DisplayMode.COOKING_MODE]:制作模式,支持空状态(虚线边框、隐藏统计行、灰色文字)
* - [DisplayMode.SAMPLING_MODE]:采样模式,始终显示统计行,重量为零时显示 `-`
*/
class FoodListAdapter(
list: MutableList<FoodRecord>,
private val mode: DisplayMode = DisplayMode.COOKING_MODE
) : BaseQuickAdapter<FoodRecord, FoodListAdapter.VH>(list) {
/** 显示模式枚举 */
enum class DisplayMode {
/** 制作模式(原 DishShowAdapter 逻辑) */
COOKING_MODE,
/** 采样模式(原 SamplingAdapter 逻辑) */
SAMPLING_MODE
}
/** item 主体点击回调 */
var onItemClick: ((position: Int) -> Unit)? = null
inner class VH(var binding: ListItemFoodListBinding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val binding = ListItemFoodListBinding.inflate(LayoutInflater.from(context), parent, false)
return VH(binding)
}
/**
* - 烹饪中:黑色文字、实线背景、显示"烹饪中"
* - 有累计重量:绿色文字、实线背景、显示累计统计
* - 空状态:灰色文字、虚线背景、隐藏统计行
*/
override fun onBindViewHolder(holder: VH, position: Int, item: FoodRecord?) {
holder.binding.run {
val isCooking = item!!.isCooking
val totalWeight = item.totalWeight ?: 0.0
val foodWeight = item.foodWeight ?: 0.0
clBlock.setOnClickListener {
onItemClick?.invoke(holder.bindingAdapterPosition)
}
tvDishName.text = item.foodName
// 烹饪中
if (isCooking) {
tvDishName.setTextColor(ContextCompat.getColor(holder.itemView.context, R.color.black))
clBlock.setBackgroundResource(R.drawable.shape_white_fb_15_corners)
tvDishCount.visible()
tvDishCount.text = "制作统计:-kg"
tvShowState.text = "烹饪中"
return@run
}
// 非烹饪中,制作模式
if (mode == DisplayMode.COOKING_MODE) {
if (totalWeight > 0.0) {
tvDishName.setTextColor(ContextCompat.getColor(holder.itemView.context, R.color.dish_green))
clBlock.setBackgroundResource(R.drawable.shape_white_fb_15_corners)
tvDishCount.visible()
tvDishCount.text = "累计统计:${df.format(totalWeight / 1000.0F)}kg(${item.count}次)"
tvShowState.text = "${df.format(foodWeight / 1000.0F)}kg"
} else {
tvDishName.setTextColor(ContextCompat.getColor(holder.itemView.context, R.color.black999))
clBlock.setBackgroundResource(R.drawable.shape_white_dash_15_corners)
tvDishCount.gone()
tvShowState.text = "-"
}
return@run
}
// 非烹饪中,采样模式
tvDishName.setTextColor(ContextCompat.getColor(holder.itemView.context, R.color.dish_green))
clBlock.setBackgroundResource(R.drawable.shape_white_fb_15_corners)
// 已提交采样列表(sample-list):展示实验状态 + 熟重(g)
val statusName = item.statusName
if (!statusName.isNullOrBlank()) {
tvDishCount.text = "熟重:${formatGram(foodWeight)}"
tvShowState.text = statusName
tvShowState.setTextColor(statusColor(holder, item.status))
return@run
}
val realTotalWeight = if (totalWeight > 0.0) totalWeight else foodWeight
val showTotalWeight = if (realTotalWeight == 0.0) "-" else df.format(realTotalWeight / 1000.0F)
tvDishCount.text = "累计统计:${showTotalWeight}kg(${item.count ?: "-"}次)"
val showFoodWeight = if (foodWeight == 0.0) "-" else df.format(foodWeight / 1000.0F)
tvShowState.text = "${showFoodWeight}kg"
}
}
/** 熟重(g)格式化,整数不带小数,小数保留 2 位 */
private fun formatGram(weight: Double): String {
val gramDf = DecimalFormat("0.##")
return "${gramDf.format(weight)}g"
}
/** 实验状态着色:1=实验中(灰) / 2=已评测(绿) / 9=已转化(绿) */
private fun statusColor(holder: VH, status: Int?): Int {
val context = holder.itemView.context
return when (status) {
SampleStatus.EVALUATED, SampleStatus.CONVERTED ->
ContextCompat.getColor(context, R.color.dish_green)
else -> ContextCompat.getColor(context, R.color.black999)
}
}
private val df = DecimalFormat("0.000")
}
@@ -0,0 +1,81 @@
package com.shuwei.dish.match.adapter
import android.content.Context
import android.content.res.ColorStateList
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.ListItemFoodMaterialBinding
import com.shuwei.dish.match.model.GoodsItem
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.roundedOneDecimalPlace
import com.shuwei.dish.match.utils.ext.visible
import androidx.core.graphics.toColorInt
class FoodMaterialAdapter(list: MutableList<GoodsItem>) :
BaseQuickAdapter<GoodsItem, FoodMaterialAdapter.VH>(list) {
/** item 主体点击回调 */
var onItemClick: ((position: Int) -> Unit)? = null
inner class VH(var binding: ListItemFoodMaterialBinding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val inflater = LayoutInflater.from(context)
val binding = ListItemFoodMaterialBinding.inflate(inflater, parent, false)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int, item: GoodsItem?) {
holder.binding.run {
val data = item ?: return
clBlock.setOnClickListener {
onItemClick?.invoke(holder.bindingAdapterPosition)
}
tvDishName.text = data.goodsName
//if (data.isOriginalData) {
// tvDishName.text = data.goodsName
//} else {
// tvDishName.text = data.goodsName + "-${data.rawMaterialsType}"
//}
tvDishType.text = if (data.materialType == 1) "主辅材:主材" else if (data.materialType == 2) "主辅材:辅材" else ""
tvDishWeight.text =
if (data.useWeight == null || data.useWeight == 0.toDouble()) "" else "${data.useWeight!!.roundedOneDecimalPlace()}"
tvDishWeight.setTextColor(
ContextCompat.getColor(
context,
if (data.isSamplingPage) R.color.black999 else R.color.dish_green
)
)
ivOperateIcon.run {
if (data.isSamplingPage) gone() else visible()
}
ivClearIcon.visible()
ivClearIcon.setImageResource(
if (data.isOriginalData) R.drawable.ic_dish_clear
else R.drawable.ic_delete
)
// isOriginalData 时根据是否有重量信息控制图标颜色:无重量染灰色,有重量恢复原色
if (data.isOriginalData) {
val hasWeight = data.useWeight != null && data.useWeight != 0.0
ivClearIcon.imageTintList = if (hasWeight) null
else ColorStateList.valueOf("#BFBFBF".toColorInt())
} else {
ivClearIcon.imageTintList = null
}
ivOperateIcon.setImageResource(
if (data.isSetFinished) R.drawable.ic_dish_selected
else R.drawable.ic_dish_unselected
)
clBlock.setBackgroundResource(
if (data.isClicked) R.drawable.shape_item_cook_dish
else R.drawable.shape_white_fb_15_corners
)
}
}
}
@@ -1,43 +0,0 @@
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)
}
@@ -0,0 +1,46 @@
package com.shuwei.dish.match.adapter
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.shuwei.dish.match.databinding.ItemGoodsPreviewBinding
import com.shuwei.dish.match.db.entity.CookFoodGoodsEntity
/**
* 用料明细预览列表适配器
* 显示字段:foodId、goodsId、goodsCode、goodsName、materialType、useWeight
*/
class GoodsPreviewAdapter(
private val list: List<CookFoodGoodsEntity>
) : RecyclerView.Adapter<GoodsPreviewAdapter.VH>() {
inner class VH(val binding: ItemGoodsPreviewBinding) : RecyclerView.ViewHolder(binding.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
val binding = ItemGoodsPreviewBinding.inflate(
LayoutInflater.from(parent.context), parent, false
)
return VH(binding)
}
@SuppressLint("SetTextI18n")
override fun onBindViewHolder(holder: VH, position: Int) {
val item = list[position]
holder.binding.run {
tvGoodsId.text = item.goodsId
tvGoodsCode.text = item.goodsCode.orEmpty()
tvGoodsName.text = item.goodsName.orEmpty()
// materialType1主料 2辅料 3调料
tvMaterialType.text = when (item.materialType) {
1 -> "主料"
2 -> "辅料"
3 -> "调料"
else -> "${item.materialType}"
}
tvUseWeight.text = "${item.useWeight ?: 0.0}g"
}
}
override fun getItemCount() = list.size
}
@@ -8,7 +8,7 @@ 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.ListItemHomeModeBinding
import com.shuwei.dish.match.entity.HomeModeBean
import com.shuwei.dish.match.model.HomeModeBean
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.visible
@@ -1,58 +0,0 @@
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.entity.FoodRecord
import com.shuwei.dish.match.databinding.ListItemDishBinding
import com.shuwei.dish.match.utils.ext.visible
import java.text.DecimalFormat
class SamplingAdapter(list: MutableList<FoodRecord>) :
BaseQuickAdapter<FoodRecord, SamplingAdapter.VH>(list) {
inner class VH(var binding: ListItemDishBinding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val inflater = LayoutInflater.from(context)
val binding = ListItemDishBinding.inflate(inflater, parent, false)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int, item: FoodRecord?) {
holder.binding.run {
val isCooking = item!!.isCooking
clBlock.setBackgroundResource(R.drawable.shape_white_fb_15_corners)
val totalWeight = item.totalWeight ?: 0.toDouble()
tvDishName.run {
text = (item.foodName?:"").ifBlank { "--" }
setTextColor(
ContextCompat.getColor(
holder.itemView.context,
if (isCooking) R.color.black else R.color.dish_green
)
)
}
tvDishCount.visible()
if (isCooking) {
tvDishCount.text = "制作统计:-kg"
tvShowState.text = "烹饪中"
} else {
val realTotalWeight = if (totalWeight > 0.toDouble()) totalWeight else (item.foodWeight
?: 0.toDouble())
val showTotalWeight =
if (realTotalWeight == 0.toDouble()) "-" else df.format(realTotalWeight / 1000.0F)
tvDishCount.text = "累计统计:${showTotalWeight}kg(${item.count ?: "-"}次)"
val foodWeight = item.foodWeight ?: 0.toDouble()
val showFoodWeight = if (foodWeight == 0.toDouble()) "-" else df.format(foodWeight / 1000.0F)
tvShowState.text = "${showFoodWeight}kg"
}
}
}
val df = DecimalFormat("0.000")
}
@@ -3,14 +3,19 @@ package com.shuwei.dish.match.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.dish.match.R
import com.shuwei.dish.match.databinding.ListItemScaleRowBinding
import com.shuwei.dish.match.utils.WeightUtil
/**
* 线性列表秤数据 Adapter
* 用于 SlaveActivity 非22/18格子设备的秤列表展示
* 通过 updateItem / updateName / removeByAddress 同步更新,避免 submitList 的
* AsyncListDiffer 异步窗口期导致点击事件丢失
*/
class ScaleRowAdapter : BaseQuickAdapter<ScaleRowAdapter.ScaleItem, ScaleRowAdapter.VH>() {
@@ -23,6 +28,9 @@ class ScaleRowAdapter : BaseQuickAdapter<ScaleRowAdapter.ScaleItem, ScaleRowAdap
*/
data class ScaleItem(val address: Int, val weight: Double, val state: Int, val name: String = "")
/** item 点击回调 */
var onItemClick: ((ScaleItem) -> Unit)? = null
inner class VH(val b: ListItemScaleRowBinding) : QuickViewHolder(b.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
@@ -34,11 +42,63 @@ class ScaleRowAdapter : BaseQuickAdapter<ScaleRowAdapter.ScaleItem, ScaleRowAdap
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}"
holder.b.tvState.text = WeightUtil.getStateStr(item.state)
if (item.state == WeightUtil.STATE_OVER_WEIGHT) {
holder.b.root.setBackgroundResource(R.drawable.shape_scale_cell_over_weight)
val white = ContextCompat.getColor(holder.b.root.context, R.color.white)
holder.b.tvScaleLabel.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)
holder.b.tvScaleLabel.setTextColor(ContextCompat.getColor(holder.b.root.context, R.color.black))
holder.b.tvName.setTextColor(ContextCompat.getColor(holder.b.root.context, R.color.black))
holder.b.tvWeight.setTextColor(ContextCompat.getColor(holder.b.root.context, R.color.home_title))
holder.b.tvState.setTextColor(ContextCompat.getColor(holder.b.root.context, R.color.home_sub_title))
}
// 点击时通过 bindingAdapterPosition 取最新数据,避免闭包捕获过期 item
holder.b.root.setOnClickListener {
val pos = holder.bindingAdapterPosition
if (pos != RecyclerView.NO_POSITION) {
items.getOrNull(pos)?.let { onItemClick?.invoke(it) }
}
}
}
/**
* 同步更新单条秤数据:存在则定点刷新,不存在则按 address 升序插入
* 使用 set/add 替代 submitList,消除 AsyncListDiffer 异步窗口期
* @param item 最新秤数据
*/
fun updateItem(item: ScaleItem) {
val idx = items.indexOfFirst { it.address == item.address }
if (idx >= 0) {
set(idx, item)
} else {
// 找到按 address 升序的插入位置
val insertPos = items.indexOfFirst { it.address > item.address }
.let { if (it < 0) items.size else it }
add(insertPos, item)
}
}
/**
* 同步更新指定地址的调料名称
* @param address 秤地址
* @param name 新名称
*/
fun updateName(address: Int, name: String) {
val idx = items.indexOfFirst { it.address == address }
if (idx >= 0) set(idx, items[idx].copy(name = name))
}
/**
* 同步移除指定地址的条目
* @param address 秤地址
*/
fun removeByAddress(address: Int) {
val idx = items.indexOfFirst { it.address == address }
if (idx >= 0) removeAt(idx)
}
}
@@ -90,25 +90,36 @@ class Seasoning18GridAdapter(
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)
holder.b.tvState.text = WeightUtil.getStateStr(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)
when {
valid && item!!.state == WeightUtil.STATE_OVER_WEIGHT -> {
holder.b.root.setBackgroundResource(R.drawable.shape_scale_cell_over_weight)
holder.b.tvAddress.setTextColor(white)
holder.b.tvName.setTextColor(white)
holder.b.tvWeight.setTextColor(white)
holder.b.tvState.setTextColor(white)
} else {
}
position == highlightedPosition -> {
holder.b.root.setBackgroundResource(R.drawable.shape_scale_cell_highlight)
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)
val black = ContextCompat.getColor(holder.b.root.context, R.color.black)
holder.b.tvAddress.setTextColor(sub)
holder.b.tvName.setTextColor(sub)
holder.b.tvName.setTextColor(black)
holder.b.tvWeight.setTextColor(normal)
holder.b.tvState.setTextColor(sub)
}
}
}
/**
* 高亮指定位置的格子(红底白字),同时清除上一个高亮
@@ -163,11 +174,4 @@ class Seasoning18GridAdapter(
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 -> ""
}
}
@@ -95,26 +95,36 @@ class Seasoning22GridAdapter(
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)
holder.b.tvState.text = WeightUtil.getStateStr(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)
when {
valid && item!!.state == WeightUtil.STATE_OVER_WEIGHT -> {
holder.b.root.setBackgroundResource(R.drawable.shape_scale_cell_over_weight)
holder.b.tvAddress.setTextColor(white)
holder.b.tvName.setTextColor(white)
holder.b.tvWeight.setTextColor(white)
holder.b.tvState.setTextColor(white)
} else {
}
position == highlightedPosition -> {
holder.b.root.setBackgroundResource(R.drawable.shape_scale_cell_highlight)
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)
val black = ContextCompat.getColor(holder.b.root.context, R.color.black)
holder.b.tvAddress.setTextColor(sub)
holder.b.tvName.setTextColor(sub)
holder.b.tvName.setTextColor(black)
holder.b.tvWeight.setTextColor(normal)
holder.b.tvState.setTextColor(sub)
}
}
}
/**
* 高亮指定位置的格子(红底白字),同时清除上一个高亮
@@ -169,11 +179,4 @@ class Seasoning22GridAdapter(
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 -> ""
}
}
@@ -7,23 +7,22 @@ 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.ListItemSearchGoodsInfoBinding
import com.shuwei.dish.match.entity.SeasoningEntity
import com.shuwei.dish.match.databinding.ListItemSeasoningBinding
import com.shuwei.dish.match.model.GoodsItem
class SeasoningSearchAdapter(private var list: MutableList<SeasoningEntity>) :
BaseQuickAdapter<SeasoningEntity, SeasoningSearchAdapter.VH>(list) {
/**
* 调料搜索弹窗适配器
*/
class SeasoningSearchAdapter(private var list: MutableList<GoodsItem>) :
BaseQuickAdapter<GoodsItem, SeasoningSearchAdapter.VH>(list) {
override fun onBindViewHolder(
holder: VH,
position: Int,
item: SeasoningEntity?
item: GoodsItem?
) {
holder.binding.tvGoodsInfo.run {
text = item?.goodsName
setBackgroundResource(
if (item?.isClicked == true) R.drawable.shape_green_stroke
else R.drawable.shape_white_12_corners
)
setTextColor(
ContextCompat.getColor(
context,
@@ -32,6 +31,10 @@ class SeasoningSearchAdapter(private var list: MutableList<SeasoningEntity>) :
)
)
}
holder.binding.root.setBackgroundResource(
if (item?.isClicked == true) R.drawable.shape_green_stroke
else R.drawable.shape_white_12_corners
)
}
override fun onCreateViewHolder(
@@ -40,10 +43,10 @@ class SeasoningSearchAdapter(private var list: MutableList<SeasoningEntity>) :
viewType: Int
): VH {
val binding =
ListItemSearchGoodsInfoBinding.inflate(LayoutInflater.from(context), parent, false)
ListItemSeasoningBinding.inflate(LayoutInflater.from(context), parent, false)
return VH(binding)
}
inner class VH(var binding: ListItemSearchGoodsInfoBinding) : QuickViewHolder(binding.root)
inner class VH(var binding: ListItemSeasoningBinding) : QuickViewHolder(binding.root)
}
@@ -6,13 +6,25 @@ import android.view.ViewGroup
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.dish.match.databinding.ItemSeasoningWeightBinding
import com.shuwei.dish.match.databinding.ItemSeasoningWeightListBinding
import com.shuwei.dish.match.utils.ext.roundedDecimalPlace
/**
* 调料使用重量列表 Adapter
* 用于 SubmitFoodActivity 展示各调料槽位的实时用量
* 同名调料已在外部聚合,adapter 按 goodsName 作为唯一标识
* 支持两种布局模式:
* - VIEW_TYPE_GRID(默认):4 列卡片,名称与重量垂直排列
* - VIEW_TYPE_LIST:2 列,名称与重量水平排列,名称占剩余空间
*/
class SeasoningWeightAdapter : BaseQuickAdapter<SeasoningWeightAdapter.Item, SeasoningWeightAdapter.VH>() {
class SeasoningWeightAdapter : BaseQuickAdapter<SeasoningWeightAdapter.Item, QuickViewHolder>() {
companion object {
/** 4 列卡片模式 */
const val VIEW_TYPE_GRID = 0
/** 2 列水平模式 */
const val VIEW_TYPE_LIST = 1
}
/** 调料展示数据项,key = goodsName(同名调料已聚合) */
data class Item(
@@ -22,6 +34,13 @@ class SeasoningWeightAdapter : BaseQuickAdapter<SeasoningWeightAdapter.Item, Sea
val useWeight: Double
)
/** 当前布局模式,由外部切换后调用 notifyDataSetChanged 生效 */
var isListMode: Boolean = false
override fun getItemViewType(position: Int, list: List<Item>): Int {
return if (isListMode) VIEW_TYPE_LIST else VIEW_TYPE_GRID
}
/**
* 更新单个 item,按 goodsName 匹配;不存在则追加
*/
@@ -42,17 +61,21 @@ class SeasoningWeightAdapter : BaseQuickAdapter<SeasoningWeightAdapter.Item, Sea
if (idx >= 0) removeAt(idx)
}
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): QuickViewHolder {
return if (viewType == VIEW_TYPE_LIST) {
val binding = ItemSeasoningWeightListBinding.inflate(LayoutInflater.from(context), parent, false)
QuickViewHolder(binding.root)
} else {
val binding = ItemSeasoningWeightBinding.inflate(LayoutInflater.from(context), parent, false)
return VH(binding)
QuickViewHolder(binding.root)
}
}
override fun onBindViewHolder(holder: VH, position: Int, item: Item?) {
override fun onBindViewHolder(holder: QuickViewHolder, position: Int, item: Item?) {
item ?: return
holder.binding.tvSeasoningName.text = item.goodsName
holder.binding.tvSeasoningWeight.text = "${item.useWeight}g"
val showWeight = "${item.useWeight.roundedDecimalPlace(1)}g"
holder.setText(com.shuwei.dish.match.R.id.tvSeasoningName, item.goodsName)
holder.setText(com.shuwei.dish.match.R.id.tvSeasoningWeight, showWeight)
}
inner class VH(var binding: ItemSeasoningWeightBinding) : QuickViewHolder(binding.root)
}
@@ -0,0 +1,39 @@
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.ListItemSettingBinding
import com.shuwei.dish.match.model.SettingItem
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.visible
/**
* 设置页面列表适配器
* 点击事件直接调用 item.onClick(),无需在 Adapter 中分发业务逻辑
*/
class SettingAdapter : BaseQuickAdapter<SettingItem, SettingAdapter.VH>() {
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val binding = ListItemSettingBinding.inflate(LayoutInflater.from(context), parent, false)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int, item: SettingItem?) {
item ?: return
holder.binding.tvTitle.text = item.title
// 有副标题则显示,否则隐藏
val subtitle = item.subtitle
if (subtitle != null) {
holder.binding.tvSubtitle.visible()
holder.binding.tvSubtitle.text = subtitle
} else {
holder.binding.tvSubtitle.gone()
}
}
inner class VH(val binding: ListItemSettingBinding) : QuickViewHolder(binding.root)
}
@@ -1,136 +1,136 @@
package com.shuwei.dish.match.adapter
import android.graphics.Typeface
import android.icu.text.DecimalFormat
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 android.widget.FrameLayout
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.core.graphics.toColorInt
import androidx.recyclerview.widget.RecyclerView
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.dish.match.R
import com.shuwei.dish.match.databinding.ListItemCellBinding
import com.shuwei.dish.match.entity.SeasoningEntity
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.dp
import com.shuwei.dish.match.utils.ext.invisible
import com.shuwei.dish.match.utils.ext.roundedOneDecimalPlace
import com.shuwei.dish.match.utils.ext.visible
class TextCellAdapter(var list: MutableList<SeasoningEntity>) :
RecyclerView.Adapter<TextCellAdapter.VH>() {
inner class VH(var binding: ListItemCellBinding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
val inflater = LayoutInflater.from(parent.context)
val binding = ListItemCellBinding.inflate(inflater, parent, false)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int) {
val item = list[position]
holder.binding.run {
if (position == 10 || position == 11 || position == 13 || position == 14) {
root.invisible()
} else {
root.visible()
}
loadCell(tvCell, item)
//root.setBackgroundResource(R.color.dish_green)
(root.layoutParams as RecyclerView.LayoutParams).let {
// it.bottomMargin = 15.dp
it.width = 226.dp
it.height = 120.dp
// it.rightMargin = 0
// it.leftMargin = if (position % 3 == 0) 0 else 15.dp
it.leftMargin = 7.dp
it.rightMargin = 8.dp
it.topMargin = 7.dp
it.bottomMargin = 8.dp
}
if (item.pageType == 1) {
root.clickWithDebounce { onCellClick?.invoke(position) }
}
}
}
override fun getItemCount() = list.size
var onCellClick: ((positon: Int) -> Unit)? = null
companion object {
fun loadLayout(frameLayout: FrameLayout, item: SeasoningEntity) {
val child: TextView = frameLayout.getChildAt(0) as TextView
child.text = getTextSpan(item)
}
fun loadCell(tvCell: TextView, item: SeasoningEntity) {
tvCell.text = getTextSpan(item)
}
// fun loadCell2(tvCell: TextView, item: SeasoningEntity) {
// if (item.goodsName.isNullOrBlank()) {
// tvCell.run {
// text = buildSpannableString {
//package com.shuwei.dish.match.adapter
//
//import android.graphics.Typeface
//import android.icu.text.DecimalFormat
//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 android.widget.FrameLayout
//import android.widget.TextView
//import androidx.core.content.ContextCompat
//import androidx.core.graphics.toColorInt
//import androidx.recyclerview.widget.RecyclerView
//import com.chad.library.adapter4.viewholder.QuickViewHolder
//import com.shuwei.dish.match.R
//import com.shuwei.dish.match.databinding.ListItemCellBinding
//import com.shuwei.dish.match.entity.SeasoningEntity
//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.dp
//import com.shuwei.dish.match.utils.ext.invisible
//import com.shuwei.dish.match.utils.ext.roundedOneDecimalPlace
//import com.shuwei.dish.match.utils.ext.visible
//
//class TextCellAdapter(var list: MutableList<SeasoningEntity>) :
// RecyclerView.Adapter<TextCellAdapter.VH>() {
//
// inner class VH(var binding: ListItemCellBinding) : QuickViewHolder(binding.root)
//
// override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
// val inflater = LayoutInflater.from(parent.context)
// val binding = ListItemCellBinding.inflate(inflater, parent, false)
// return VH(binding)
// }
//
// override fun onBindViewHolder(holder: VH, position: Int) {
// val item = list[position]
// holder.binding.run {
// if (position == 10 || position == 11 || position == 13 || position == 14) {
// root.invisible()
// } else {
// root.visible()
// }
// loadCell(tvCell, item)
// //root.setBackgroundResource(R.color.dish_green)
// (root.layoutParams as RecyclerView.LayoutParams).let {
//// it.bottomMargin = 15.dp
// it.width = 226.dp
// it.height = 120.dp
//// it.rightMargin = 0
//// it.leftMargin = if (position % 3 == 0) 0 else 15.dp
//
// it.leftMargin = 7.dp
// it.rightMargin = 8.dp
// it.topMargin = 7.dp
// it.bottomMargin = 8.dp
// }
// if (item.pageType == 1) {
// root.clickWithDebounce { onCellClick?.invoke(position) }
// }
// }
// }
//
// override fun getItemCount() = list.size
//
// var onCellClick: ((positon: Int) -> Unit)? = null
//
// companion object {
// fun loadLayout(frameLayout: FrameLayout, item: SeasoningEntity) {
// val child: TextView = frameLayout.getChildAt(0) as TextView
// child.text = getTextSpan(item)
// }
// fun loadCell(tvCell: TextView, item: SeasoningEntity) {
// tvCell.text = getTextSpan(item)
// }
//// fun loadCell2(tvCell: TextView, item: SeasoningEntity) {
//// if (item.goodsName.isNullOrBlank()) {
//// tvCell.run {
//// text = buildSpannableString {
//// appendText(
//// "未配置",
//// ForegroundColorSpan("#999999".toColorInt()),
//// StyleSpan(Typeface.BOLD),
//// AbsoluteSizeSpan(28, true),
//// LineHeightSpan { text, start, end, spanstartv, v, fm ->
//// fm.descent += 10.dp // 增加行间距
//// }
//// )}
//// //setTextColor(ContextCompat.getColor(context, R.color.black999))
//// }
//// } else {
////// if (item.pageType == 1) {
////// tvCell.run {
////// text = item.goodsName
////// setTextColor(ContextCompat.getColor(context, R.color.black))
////// }
////// } else {
//// tvCell.text = getTextSpan(item)
////// }
//// }
//// }
//
// fun getTextSpan(item: SeasoningEntity): SpannableStringBuilder {
// val endIndex = if (item.sort == 9) 10 else 6
// var name = if (item.goodsName.isNullOrBlank()) "未配置" else item.goodsName!!
// if (name.length > endIndex) {
// name = "${name.substring(0, endIndex)}…"
// }
// return buildSpannableString {
// appendText(
// "未配置",
// ForegroundColorSpan("#999999".toColorInt()),
// name,
// ForegroundColorSpan(if (item.goodsName.isNullOrBlank()) "#999999".toColorInt() else "#000000".toColorInt()),
// StyleSpan(Typeface.BOLD),
// AbsoluteSizeSpan(28, true),
// LineHeightSpan { text, start, end, spanstartv, v, fm ->
// fm.descent += 10.dp // 增加行间距
// }
// )}
// //setTextColor(ContextCompat.getColor(context, R.color.black999))
// }
// } else {
//// if (item.pageType == 1) {
//// tvCell.run {
//// text = item.goodsName
//// setTextColor(ContextCompat.getColor(context, R.color.black))
//// }
//// } else {
// tvCell.text = getTextSpan(item)
//// }
// )
// append("\n")
// val weight = item.useWeight ?: 0.toDouble()
// val weightColor = if (weight == 0.toDouble()) "#999999" else "#00BC71"
// appendText(
//// "${DecimalFormat("#").format(weight)}g",
// "${weight.roundedOneDecimalPlace()}g",
// ForegroundColorSpan(weightColor.toColorInt()),
// AbsoluteSizeSpan(30, true)
// )
// }
// }
fun getTextSpan(item: SeasoningEntity): SpannableStringBuilder {
val endIndex = if (item.sort == 9) 10 else 6
var name = if (item.goodsName.isNullOrBlank()) "未配置" else item.goodsName!!
if (name.length > endIndex) {
name = "${name.substring(0, endIndex)}"
}
return buildSpannableString {
appendText(
name,
ForegroundColorSpan(if (item.goodsName.isNullOrBlank()) "#999999".toColorInt() else "#000000".toColorInt()),
StyleSpan(Typeface.BOLD),
AbsoluteSizeSpan(28, true),
LineHeightSpan { text, start, end, spanstartv, v, fm ->
fm.descent += 10.dp // 增加行间距
}
)
append("\n")
val weight = item.useWeight ?: 0.toDouble()
val weightColor = if (weight == 0.toDouble()) "#999999" else "#00BC71"
appendText(
// "${DecimalFormat("#").format(weight)}g",
"${weight.roundedOneDecimalPlace()}g",
ForegroundColorSpan(weightColor.toColorInt()),
AbsoluteSizeSpan(30, true)
)
}
}
}
}
// }
//
//}
@@ -9,7 +9,7 @@ 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.model.FoodCollectionBean
import com.shuwei.dish.match.utils.ext.load
class VectorCollectionAdapter (var list: MutableList<FoodCollectionBean>) :
@@ -5,12 +5,11 @@ import android.content.pm.ActivityInfo
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.View
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
@@ -25,6 +24,7 @@ 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.utils.ActivityManager
import com.shuwei.dish.match.utils.KeyboardUtil
import com.shuwei.dish.match.utils.ext.dp
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.invisible
@@ -35,9 +35,15 @@ import java.time.format.DateTimeFormatter
import java.util.Locale
import kotlin.getValue
@Suppress("DEPRECATION")
open class BaseActivity : AppCompatActivity() {
companion object {
const val TAG = "BaseActivity"
/** 日期时间格式化器,复用避免每秒重复创建 */
private val DATE_FORMATTER =
DateTimeFormatter.ofPattern("yyyy年MM月dd日 EEEE***HH:mm:ss", Locale.CHINA)
}
private lateinit var binding: ActivityBaseBinding
override fun onCreate(savedInstanceState: Bundle?) {
@@ -56,42 +62,28 @@ open class BaseActivity : AppCompatActivity() {
}
setContentView(binding.root)
statusBarDarkFont(enable = false)
// window.setDecorFitsSystemWindows(false) // 启用 Edge-to-Edge
// window.insetsController?.apply {
// hide(WindowInsets.Type.statusBars()) // 隐藏状态栏
// systemBarsBehavior =
// 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.ivBack.setOnClickListener { finish() }
}
private var bgState = true
fun useBackground(enable: Boolean) {
bgState = enable
binding.root.setOnClickListener { hideKeyboard() }
}
override fun onDestroy() {
dismissLoading()
handler.removeCallbacksAndMessages(null)
// 只关闭自己弹出的 Loading,防止误关其他 Activity 的 Loading
Loading.dismissIfOwnedBy(this)
super.onDestroy()
ActivityManager.removeActivity(this)
}
fun hideStatusBar() {
// enableEdgeToEdge()
//// val uiOptions = (View.SYSTEM_UI_FLAG_FULLSCREEN // 隐藏状态栏
//// or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION) // 隐藏导航栏(可选)
// window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN
hideSystemBars()
}
fun statusBarDarkFont(enable: Boolean) {
//window.decorView.systemUiVisibility = if (enable) View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR else 0
val color = ContextCompat.getColor(
this, if (enable) R.color.black else R.color.white
)
@@ -103,12 +95,13 @@ open class BaseActivity : AppCompatActivity() {
titleBarAction: ((FrameLayout) -> Unit)? = null,
backAction: ((ImageView) -> Unit)? = null,
titleAction: ((TextView) -> Unit)? = null,
rightIconActon: ((ImageView) -> Unit)? = null
rightIconAction: ((ImageView) -> Unit)? = null
) {
binding.llTitleBar.visible()
titleBarAction?.invoke(binding.llTitleBar)
backAction?.invoke(binding.ivBack)
titleAction?.invoke(binding.tvTitle)
rightIconActon?.invoke(binding.ivRightIcon)
rightIconAction?.invoke(binding.ivRightIcon)
}
fun setHeaderBgVisible(show: Boolean) {
@@ -117,7 +110,7 @@ open class BaseActivity : AppCompatActivity() {
}
}
public fun setHeaderBackground(isHomePage: Boolean = false) {
fun setHeaderBackground(isHomePage: Boolean = false) {
binding.ivHeaderBg.setImageResource(
if (isHomePage) R.drawable.bg_home_page else R.drawable.bg_other_page
)
@@ -135,14 +128,13 @@ open class BaseActivity : AppCompatActivity() {
}
private fun updateDateTime() {
val formatter = DateTimeFormatter.ofPattern("yyyy年MM月dd日 EEEE***HH:mm:ss", Locale.CHINA)
val dateTime = formatter.format(LocalDateTime.now(ZoneId.of("Asia/Shanghai")))
val dateTime = DATE_FORMATTER.format(LocalDateTime.now(ZoneId.of("Asia/Shanghai")))
val arr = dateTime.split("***")
binding.tvRightTime.text = arr[0]
binding.tvLeftTime.text = arr[1]
}
val handler = Handler(Looper.getMainLooper())
private val handler = Handler(Looper.getMainLooper())
private val updateTask = object : Runnable {
override fun run() {
updateDateTime()
@@ -161,48 +153,61 @@ open class BaseActivity : AppCompatActivity() {
handler.removeCallbacks(updateTask)
}
fun showLoading(msg: String = "加载中……") {
fun showLoading(msg: String = "加载中……", timeoutMs: Long = Loading.DEFAULT_TIMEOUT_MS) {
handler.post {
Loading.show(
context = this,
message = msg
message = msg,
timeoutMs = timeoutMs
)
}
}
fun dismissLoading() {
Log.d(TAG, "dismissLoading, ${this.javaClass.simpleName}")
handler.post {
Loading.dismiss()
}
}
fun delayDismissLoading() {
Log.d(TAG, "delayDismissLoading, ${this.javaClass.simpleName}")
handler.postDelayed({
Loading.dismiss()
}, 500)
}
/**
* 在主线程延迟执行任务,供子类使用
* @param delayMs 延迟毫秒数
* @param block 待执行的任务
*/
protected fun postDelayed(delayMs: Long, block: () -> Unit) {
handler.postDelayed(block, delayMs)
}
val netViewModel: NetViewModel by viewModels()
val appViewModel: DbViewModel by viewModels()
val dbViewModel: DbViewModel by viewModels()
private var permissionCallback: ((isGranted: Boolean) -> Unit)? = null
private var singlePermissionCallback: ((isGranted: Boolean) -> Unit)? = null
private var multiplePermissionsCallback: ((isGranted: Boolean) -> Unit)? = null
private var activityCallback: ((intent: Intent?) -> Unit)? = null
fun requestMultiplePermissions(
permissions: Array<String>,
callback: (isGranted: Boolean) -> Unit
) {
this.permissionCallback = callback
this.multiplePermissionsCallback = callback
requestMultiplePermissionsLauncher.launch(permissions)
}
fun requestPermission(permission: String, callback: (isGranted: Boolean) -> Unit) {
this.permissionCallback = callback
this.singlePermissionCallback = callback
requestPermissionLauncher.launch(permission)
}
val requestMultiplePermissionsLauncher = registerForActivityResult(
private val requestMultiplePermissionsLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
var isGranted = true
@@ -211,14 +216,14 @@ open class BaseActivity : AppCompatActivity() {
isGranted = false
}
}
permissionCallback?.invoke(isGranted)
multiplePermissionsCallback?.invoke(isGranted)
}
// 权限请求回调
val requestPermissionLauncher = registerForActivityResult(
private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
permissionCallback?.invoke(isGranted)
singlePermissionCallback?.invoke(isGranted)
}
fun startActivity(intent: Intent, callback: (Intent?) -> Unit) {
@@ -267,4 +272,8 @@ open class BaseActivity : AppCompatActivity() {
}
}
fun hideKeyboard() {
KeyboardUtil.hideKeyboard(binding.root)
}
}
@@ -1,22 +1,26 @@
package com.shuwei.dish.match.base
import android.app.Activity
import android.app.Application
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
import android.os.Bundle
import android.util.Log
import com.shuwei.dish.match.BuildConfig
import com.shuwei.dish.match.R
import com.shuwei.dish.match.db.DatabaseProvider
import com.shuwei.dish.match.objbox.ObjectBox
import com.shuwei.dish.match.scale.ScaleDeviceConfig
import com.shuwei.dish.match.scale.ScaleServiceManager
import com.shuwei.dish.match.ui.InitActivity.Companion.TAG
import com.shuwei.dish.match.utils.AppUtil
import com.shuwei.dish.match.utils.SpTool
import com.shuwei.dish.match.utils.BootReceiver
import com.shuwei.dish.match.utils.CrashHandler
import com.shuwei.dish.match.utils.WeightUtil
import com.shuwei.dish.match.utils.Weigher2
class BaseApp : Application() {
val database by lazy { DatabaseProvider(this).instance }
@@ -24,14 +28,19 @@ class BaseApp : Application() {
override fun onCreate() {
super.onCreate()
instance = this
// 读取本地保存的环境地址,覆盖默认值
val savedBaseUrl = SpTool.baseUrl
if (savedBaseUrl.isNotEmpty()) {
GlobalData.appBaseUrl = savedBaseUrl
}
CrashHandler.init(this)
val deviceId = AppUtil.getUDID(this)
Log.d(TAG, "onCreate: deviceId=$deviceId")
val deviceId = if (BuildConfig.IS_TEST_DEVICE) ScaleDeviceConfig.DEVICE_ID_2 else AppUtil.getUDID(this)
Log.d(TAG, "onCreate: deviceId=$deviceId, isTestDevice=${BuildConfig.IS_TEST_DEVICE}")
GlobalData.deviceId = deviceId
// 根据设备 ID 判断角色:指定 ID 为主设备,其余为子设备
// 主设备是/dev/ttyS7,子设备是/dev/ttyS4
if (deviceId == "8fc2ab34-2137-3112-acca-f884ea8736d4") {
if (deviceId == ScaleDeviceConfig.DEVICE_ID_2) {
GlobalData.deviceRole = DeviceRole.MASTER
Weigher2.setDevicePort("/dev/ttyS7")
} else {
@@ -42,6 +51,26 @@ class BaseApp : Application() {
// 启动秤服务(主设备多启动发现+聚合,子设备只启动服务端+注册)
ScaleServiceManager.start(this)
// 初始化秤,进程级生命周期管理:最后一个 Activity 销毁时才停止读取
WeightUtil.init()
WeightUtil.getWeight()
WeightUtil.startContinuousRead()
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
private var activityCount = 0
override fun onActivityCreated(a: Activity, b: Bundle?) { activityCount++ }
override fun onActivityDestroyed(a: Activity) {
if (--activityCount <= 0) {
WeightUtil.stopContinuousRead()
}
}
override fun onActivityStarted(a: Activity) {}
override fun onActivityResumed(a: Activity) {}
override fun onActivityPaused(a: Activity) {}
override fun onActivityStopped(a: Activity) {}
override fun onActivitySaveInstanceState(a: Activity, b: Bundle) {}
})
// GlobalData.deviceId = "39a7abdd06b3c7ab"
val filter = IntentFilter(Intent.ACTION_BOOT_COMPLETED)
registerReceiver(BootReceiver(), filter)
@@ -49,25 +78,22 @@ class BaseApp : Application() {
}
companion object {
// const val canteenId = "1678234139391512577"
private val TAG = "BaseApp"
var canteenId = "0"
// var configUrl = ""
// var token: String? = null
// var deviceId: String? = null
var appVersion: String = "1"
@Volatile
private var sharedPref: SharedPreferences? = null
public var instance: BaseApp? = null
fun getSharedPref(): SharedPreferences? {
if (sharedPref != null) {
return sharedPref
lateinit var instance: BaseApp
/** 双重检查锁,保证多线程下只初始化一次 */
fun getSharedPref(): SharedPreferences {
return sharedPref ?: synchronized(this) {
sharedPref ?: instance.getSharedPreferences(
instance.getString(R.string.sp_save_name),
Context.MODE_PRIVATE
).also { sharedPref = it }
}
val saveName = instance?.getString(R.string.sp_save_name)
sharedPref = instance?.getSharedPreferences(saveName, Context.MODE_PRIVATE)
return sharedPref
}
}
@@ -1,10 +0,0 @@
package com.shuwei.dish.match.base
class BaseReq<T> {
var code: Int? = null
var success: Boolean? = null
var data: T? = null
var message: String? = null
var result: T? = null
var msg: String? = null
}
@@ -8,14 +8,14 @@ package com.shuwei.dish.match.base
enum class DeviceRole { MASTER, SLAVE }
object GlobalData {
var appBaseUrl: String = TEST_BASE_URL
var appBaseUrl: String = PROD_BASE_URL
/**
* 具体业务 BaseUrl
*/
const val TEST_BASE_URL = "http://192.168.1.201:14801"
const val TEST_BASE_URL = "http://192.168.10.101:24801"
const val UAT_BASE_URL = "https://dev.yixiong-tech.com:8081"
const val PROD_BASE_URL = "https://api.dm.yixiong-tech.com:8443"
const val PROD_BASE_URL = "https://platform-api.uat.shuziweidao.com"
/**
* 设备id
*/
@@ -25,8 +25,6 @@ object GlobalData {
* 当前设备角色,启动时从 SpTool 读取,默认为 SLAVE
*/
var deviceRole: DeviceRole = DeviceRole.SLAVE
var appId = "Hkz1rBk6PZXbS8KwKr67K2eZtsz8bRoMHLg64bUdgCZj"
var sdkKey = "AabXs3sHM8UhhE7oCGf4LVMLrdkGb1nJNksbjjTdVt7k"
}
/**
@@ -37,4 +35,5 @@ object GlobalKey {
const val KEY_TOKEN = "tokenKey"
const val KEY_USER_INFO = "userInfoKey"
const val KEY_USER_NAME = "userNameKey"
const val KEY_BASE_URL = "baseUrl"
}
@@ -10,10 +10,10 @@ 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.CookFoodGoodsEntity
import com.shuwei.dish.match.entity.SeasoningEntity
import com.shuwei.dish.match.entity.SeasoningSlotEntity
import com.shuwei.dish.match.db.entity.CookFoodEntity
import com.shuwei.dish.match.db.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.db.entity.SeasoningEntity
import com.shuwei.dish.match.db.entity.SeasoningSlotEntity
// 步骤1:更新版本号
@@ -25,7 +25,7 @@ import com.shuwei.dish.match.entity.SeasoningSlotEntity
CookFoodGoodsEntity::class,
SeasoningSlotEntity::class,
],
version = 8,
version = 13,
exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
@@ -214,6 +214,48 @@ val MIGRATION_6_7 = MigrationImpl(6, 7) {db ->
}
}
/**
* 版本8→9:为 dm_cook_food_goods 和 dm_seasoning 表新增 zjmCode、materId 字段
*/
val MIGRATION_8_9 = MigrationImpl(8, 9) { db ->
db.execSQL("ALTER TABLE dm_cook_food_goods ADD COLUMN zjmCode TEXT DEFAULT ''")
db.execSQL("ALTER TABLE dm_cook_food_goods ADD COLUMN materId TEXT DEFAULT ''")
db.execSQL("ALTER TABLE dm_seasoning ADD COLUMN zjmCode TEXT DEFAULT ''")
db.execSQL("ALTER TABLE dm_seasoning ADD COLUMN materId TEXT DEFAULT ''")
}
/**
* 版本9→10:删除 dm_cook_food 表 foodId 唯一索引
* 改为由应用层保证同一 foodId 且 isDel=0 只有一条记录,以支持逻辑删除
*/
val MIGRATION_9_10 = MigrationImpl(9, 10) { db ->
db.execSQL("DROP INDEX IF EXISTS dm_cook_food_foodId_IDX")
}
/**
* 版本10→11:为 dm_cook_food_goods 和 dm_seasoning 表新增 goodsCode(物料编码)字段
*/
val MIGRATION_10_11 = MigrationImpl(10, 11) { db ->
db.execSQL("ALTER TABLE dm_cook_food_goods ADD COLUMN goodsCode TEXT DEFAULT ''")
db.execSQL("ALTER TABLE dm_seasoning ADD COLUMN goodsCode TEXT DEFAULT ''")
}
/**
* 版本11→12:为 dm_cook_food_goods 和 dm_seasoning 表新增 rawMaterialsType(净材种类)字段
*/
val MIGRATION_11_12 = MigrationImpl(11, 12) { db ->
db.execSQL("ALTER TABLE dm_cook_food_goods ADD COLUMN rawMaterialsType TEXT DEFAULT NULL")
db.execSQL("ALTER TABLE dm_seasoning ADD COLUMN rawMaterialsType TEXT DEFAULT NULL")
}
/**
* 版本12→13:为 dm_cook_food 表新增 submitState(接口提交状态)字段
* 0-未提交(本地草稿/手动删除),1-接口提交成功后删除
*/
val MIGRATION_12_13 = MigrationImpl(12, 13) { db ->
db.execSQL("ALTER TABLE dm_cook_food ADD COLUMN submitState INTEGER NOT NULL DEFAULT 0")
}
class DatabaseProvider(private val context: Context) {
// 单例模式确保全局唯一实例‌
val instance: AppDatabase by lazy {
@@ -233,6 +275,11 @@ class DatabaseProvider(private val context: Context) {
// .addMigrations(MIGRATION_5_6)
// .addMigrations(MIGRATION_6_7)
.addMigrations(MIGRATION_7_8)
.addMigrations(MIGRATION_8_9)
.addMigrations(MIGRATION_9_10)
.addMigrations(MIGRATION_10_11)
.addMigrations(MIGRATION_11_12)
.addMigrations(MIGRATION_12_13)
.build()
}
}
@@ -1,10 +1,11 @@
package com.shuwei.dish.match.db
import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.entity.CookFoodEntity
import com.shuwei.dish.match.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.entity.SeasoningEntity
import com.shuwei.dish.match.entity.SeasoningSlotEntity
import com.shuwei.dish.match.db.entity.CookFoodEntity
import com.shuwei.dish.match.db.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.db.entity.SeasoningEntity
import com.shuwei.dish.match.db.entity.SeasoningSlotEntity
import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -21,8 +22,8 @@ class DbRepository {
db.cookFoodDao().updateCookFood(item)
}
suspend fun getCookFoodById(foodId: String, cookMode: Int) = withContext(Dispatchers.IO) {
db.cookFoodDao().getCookFoodById(BaseApp.canteenId, foodId, cookMode)
suspend fun getCookFoodById(foodId: String, cookMode: Int, dinnerType: String) = withContext(Dispatchers.IO) {
db.cookFoodDao().getCookFoodById(BaseApp.canteenId, foodId, cookMode, dinnerType)
}
suspend fun getCookFoodList(cookMode: Int, dinnerType: String) = withContext(Dispatchers.IO) {
@@ -33,13 +34,23 @@ class DbRepository {
db.cookFoodDao().countCookFood(BaseApp.canteenId, cookMode)
}
suspend fun deleteCookFood(foodId: String, cookMode: Int) = withContext(Dispatchers.IO) {
db.cookFoodDao().deleteCookFood(BaseApp.canteenId, foodId, cookMode)
suspend fun deleteCookFood(foodId: String, cookMode: Int, dinnerType: String) = withContext(Dispatchers.IO) {
db.cookFoodDao().deleteCookFood(BaseApp.canteenId, foodId, cookMode, dinnerType)
}
/** 接口提交成功后逻辑删除菜品,同时把 submitState 置 1 */
suspend fun markSubmittedAndDelete(foodId: String, cookMode: Int, dinnerType: String) = withContext(Dispatchers.IO) {
db.cookFoodDao().markSubmittedAndDelete(BaseApp.canteenId, foodId, cookMode, dinnerType)
}
suspend fun deleteCookFoodGoodsList(pid: Long, foodId: String) = withContext(Dispatchers.IO) {
db.cookFoodGoodsDao().deleteCookFoodGoodsList(pid, foodId)
}
/** 仅逻辑删除调料行(materialType = 3),主辅材不动 */
suspend fun deleteSeasoningOnly(pid: Long, foodId: String) = withContext(Dispatchers.IO) {
db.cookFoodGoodsDao().deleteSeasoningOnly(pid, foodId)
}
//--------------------------------------------------------------------------------------------
suspend fun insertGoodsList(items: MutableList<CookFoodGoodsEntity>) =
@@ -67,7 +78,7 @@ class DbRepository {
db.seasoningDao().getSeasoningBySort(sort)
}
suspend fun getSeasoningByGoodsId(goodsId: Int) = withContext(Dispatchers.IO) {
suspend fun getSeasoningByGoodsId(goodsId: String) = withContext(Dispatchers.IO) {
db.seasoningDao().getSeasoningByGoodsId(goodsId)
}
@@ -100,6 +111,20 @@ class DbRepository {
db.seasoningDao().insertSeasoning(item)
}
/**
* 按 goodsId 查重后 upsert:存在则更新,不存在则插入
* 确保同一个 goodsId 在 dm_seasoning 中只有一条记录
*/
suspend fun upsertSeasoningByGoodsId(item: SeasoningEntity) = withContext(Dispatchers.IO) {
val existing = db.seasoningDao().getSeasoningByGoodsId(item.goodsId)
if (existing != null) {
db.seasoningDao().updateSeasoning(item.also { it.id = existing.id })
} else {
item.id = 0
db.seasoningDao().insertSeasoning(item)
}
}
/**
* 查询指定设备是否已配置调料槽位
* @param deviceId 目标设备 ID
@@ -154,6 +179,105 @@ class DbRepository {
suspend fun deleteAllSlotsByDeviceId(deviceId: String) = withContext(Dispatchers.IO) {
db.seasoningSlotDao().deleteAllByDeviceId(deviceId)
}
// ---- 调试用:查询各表全量数据(含已删除记录)----
/** 查询 dm_cook_food 全部记录 */
suspend fun getAllCookFood() = withContext(Dispatchers.IO) {
db.cookFoodDao().getAllCookFood()
}
/** 查询 dm_cook_food_goods 全部记录 */
suspend fun getAllCookFoodGoods() = withContext(Dispatchers.IO) {
db.cookFoodGoodsDao().getAllCookFoodGoods()
}
/** 查询 dm_seasoning 全部记录 */
suspend fun getAllSeasoning() = withContext(Dispatchers.IO) {
db.seasoningDao().getAllSeasoning()
}
// ---- 调试用:分页查询(含 isDel 过滤)----
suspend fun getCookFoodPaged(showDel: Boolean, limit: Int, offset: Int) =
withContext(Dispatchers.IO) {
db.cookFoodDao().getCookFoodPaged(if (showDel) 1 else 0, limit, offset)
}
suspend fun countCookFoodAll(showDel: Boolean) = withContext(Dispatchers.IO) {
db.cookFoodDao().countCookFoodAll(if (showDel) 1 else 0)
}
suspend fun getCookFoodGoodsPaged(showDel: Boolean, limit: Int, offset: Int) =
withContext(Dispatchers.IO) {
db.cookFoodGoodsDao().getCookFoodGoodsPaged(if (showDel) 1 else 0, limit, offset)
}
suspend fun countCookFoodGoodsAll(showDel: Boolean) = withContext(Dispatchers.IO) {
db.cookFoodGoodsDao().countCookFoodGoodsAll(if (showDel) 1 else 0)
}
suspend fun getSeasoningPaged(showDel: Boolean, limit: Int, offset: Int) =
withContext(Dispatchers.IO) {
db.seasoningDao().getSeasoningPaged(if (showDel) 1 else 0, limit, offset)
}
suspend fun countSeasoningAll(showDel: Boolean) = withContext(Dispatchers.IO) {
db.seasoningDao().countSeasoningAll(if (showDel) 1 else 0)
}
suspend fun getSlotPaged(limit: Int, offset: Int) = withContext(Dispatchers.IO) {
db.seasoningSlotDao().getSlotPaged(limit, offset)
}
suspend fun countSlotAll() = withContext(Dispatchers.IO) {
db.seasoningSlotDao().countSlotAll()
}
// ---- 调试用:动态条件过滤分页查询 ----
suspend fun getCookFoodPagedFiltered(query: SupportSQLiteQuery) = withContext(Dispatchers.IO) {
db.cookFoodDao().queryPagedFiltered(query)
}
suspend fun countCookFoodFiltered(query: SupportSQLiteQuery) = withContext(Dispatchers.IO) {
db.cookFoodDao().countFiltered(query)
}
suspend fun getCookFoodGoodsPagedFiltered(query: SupportSQLiteQuery) = withContext(Dispatchers.IO) {
db.cookFoodGoodsDao().queryPagedFiltered(query)
}
suspend fun countCookFoodGoodsFiltered(query: SupportSQLiteQuery) = withContext(Dispatchers.IO) {
db.cookFoodGoodsDao().countFiltered(query)
}
suspend fun getSeasoningPagedFiltered(query: SupportSQLiteQuery) = withContext(Dispatchers.IO) {
db.seasoningDao().queryPagedFiltered(query)
}
suspend fun countSeasoningFiltered(query: SupportSQLiteQuery) = withContext(Dispatchers.IO) {
db.seasoningDao().countFiltered(query)
}
suspend fun getSlotPagedFiltered(query: SupportSQLiteQuery) = withContext(Dispatchers.IO) {
db.seasoningSlotDao().queryPagedFiltered(query)
}
suspend fun countSlotFiltered(query: SupportSQLiteQuery) = withContext(Dispatchers.IO) {
db.seasoningSlotDao().countFiltered(query)
}
/**
* 物理清空全部 4 张表的测试数据
* 清除顺序:先删子表(食材),再删主表(菜品),最后删配置表(调料、槽位)
*/
suspend fun clearAllData() = withContext(Dispatchers.IO) {
db.cookFoodGoodsDao().deleteAll()
db.cookFoodDao().deleteAll()
db.seasoningDao().deleteAll()
db.seasoningSlotDao().deleteAll()
}
}
//
//class SeasoningRepository(val seasoningDao: SeasoningDao) {
@@ -2,12 +2,13 @@ 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 androidx.sqlite.db.SimpleSQLiteQuery
import com.shuwei.dish.match.model.CollectedFoodItem
import com.shuwei.dish.match.db.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 com.shuwei.dish.match.db.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.db.entity.SeasoningEntity
import com.shuwei.dish.match.db.entity.SeasoningSlotEntity
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -28,9 +29,9 @@ class DbViewModel : ViewModel() {
private val _cookFoodDetailState = MutableStateFlow<CookFoodEntity?>(null)
val cookFoodDetailState: StateFlow<CookFoodEntity?> = _cookFoodDetailState.asStateFlow()
fun getCookFoodById(foodId: String, cookMode: Int) {
fun getCookFoodById(foodId: String, cookMode: Int, dinnerType: String) {
viewModelScope.launch {
_cookFoodDetailState.value = rep.getCookFoodById(foodId, cookMode)
_cookFoodDetailState.value = rep.getCookFoodById(foodId, cookMode, dinnerType)
}
}
@@ -48,6 +49,30 @@ class DbViewModel : ViewModel() {
}
}
/**
* 查询本地烹饪中数据(回调版本),结果通过 onResult 直接回调,不经过 StateFlow
* 适合不需要跨生命周期持续监听的一次性查询场景
*/
fun getCookFoodListWithCallback(
cookMode: Int,
dinnerType: String = "0",
onResult: (MutableList<CookFoodEntity>?) -> Unit
) {
viewModelScope.launch {
onResult(rep.getCookFoodList(cookMode, dinnerType))
}
}
/**
* 直接返回本地烹饪中数据,供需要串行等待结果的场景使用(如先查库再发网络请求)
*/
suspend fun getCookFoodListDirect(
cookMode: Int,
dinnerType: String = "0"
): MutableList<CookFoodEntity>? {
return rep.getCookFoodList(cookMode, dinnerType)
}
suspend fun countCookFood(cookMode: Int): Int {
return rep.countCookFood(cookMode)
}
@@ -68,28 +93,62 @@ class DbViewModel : ViewModel() {
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)
}
// 重新保存数据
val data = rep.getCookFoodById(entity.foodId, cookMode, entity.dinnerType ?: "0")
if (data == null) {
// 首次保存:主表 + 主辅材 + 调料 全量 insert
rep.insertCookFood(entity)
val newData = rep.getCookFoodById(entity.foodId, cookMode)
list?.let { it ->
val newData = rep.getCookFoodById(entity.foodId, cookMode, entity.dinnerType ?: "0")
// 回填 id,防止下次调用时 getCookFoodById 查不到而重复插入主辅材
newData?.let { entity.id = it.id }
list?.let {
it.forEach { goods ->
goods.id = 0
goods.pid = newData?.id ?: 0
}
rep.insertGoodsList(it)
}
} else {
// 已存在:主表与主辅材保持不变,仅重建调料部分,避免主表/主辅材累积逻辑删除脏数据
rep.deleteSeasoningOnly(data.id, entity.foodId)
val seasoningList = list?.filter { it.materialType == 3 }?.toMutableList()
if (!seasoningList.isNullOrEmpty()) {
seasoningList.forEach { goods ->
goods.id = 0
goods.pid = data.id
goods.foodId = entity.foodId
}
rep.insertGoodsList(seasoningList)
}
}
}
suspend fun deleteCookFoodAndGoods(cookMode: Int, foodId: String) {
val data = rep.getCookFoodById(foodId, cookMode)
suspend fun deleteCookFoodAndGoods(cookMode: Int, foodId: String, dinnerType: String) {
val data = rep.getCookFoodById(foodId, cookMode, dinnerType)
if (data != null) {
rep.deleteCookFood(foodId = foodId, cookMode = cookMode)
rep.deleteCookFood(foodId = foodId, cookMode = cookMode, dinnerType = dinnerType)
rep.deleteCookFoodGoodsList(pid = data.id, foodId = foodId)
}
}
/**
* 接口提交成功后删除本地菜品草稿(异步版本,绑定 viewModelScope
* 与 [markSubmittedAndDelete] 的区别:不是 suspend,内部用 viewModelScope 启动协程,
* 不受调用方 Activity 生命周期影响,适合 singleTask 跳转前触发删除的场景
*/
fun markSubmittedAndDeleteAsync(cookMode: Int, foodId: String, dinnerType: String) {
viewModelScope.launch {
markSubmittedAndDelete(cookMode, foodId, dinnerType)
}
}
/**
* 接口提交成功后删除本地菜品草稿
* 与 [deleteCookFoodAndGoods] 的区别:主表 submitState 会被置 1,便于审计区分手动删除与接口删除
*/
suspend fun markSubmittedAndDelete(cookMode: Int, foodId: String, dinnerType: String) {
val data = rep.getCookFoodById(foodId, cookMode, dinnerType)
if (data != null) {
rep.markSubmittedAndDelete(foodId = foodId, cookMode = cookMode, dinnerType = dinnerType)
rep.deleteCookFoodGoodsList(pid = data.id, foodId = foodId)
}
}
@@ -98,9 +157,10 @@ class DbViewModel : ViewModel() {
suspend fun getCookFoodGoodsList(
foodId: String,
cookMode: Int
cookMode: Int,
dinnerType: String
): MutableList<CookFoodGoodsEntity> {
val cookFood = rep.getCookFoodById(foodId, cookMode)
val cookFood = rep.getCookFoodById(foodId, cookMode, dinnerType)
return if (cookFood != null) rep.getCookFoodGoodsList(pid = cookFood.id, foodId = cookFood.foodId)
else mutableListOf()
}
@@ -237,7 +297,240 @@ class DbViewModel : ViewModel() {
suspend fun deleteAllSlotsByDeviceId(deviceId: String) =
rep.deleteAllSlotsByDeviceId(deviceId)
// fun updateAll(list: MutableList<SeasoningEntity>) {
// ---- 数据库调试查看 ----
/**
* 物理清空全部 4 张表的测试数据,完成后通过回调通知 UI
* @param onDone 清除完成后在主线程执行的回调
*/
fun clearAllData(onDone: () -> Unit) {
viewModelScope.launch {
rep.clearAllData()
onDone()
}
}
companion object {
/** 每页加载条数 */
const val DB_INSPECT_PAGE_SIZE = 50
}
/**
* 单条数据库记录的展示模型
* @param isDel 是否已逻辑删除
* @param fields 字段名-值对列表,按顺序排列
* @param columnsPerRow 每行显示的列数,默认 3;槽位表传 1 保持单列
*/
data class DbRecord(
val isDel: Boolean,
val fields: List<Pair<String, String>>,
val columnsPerRow: Int = 3
)
/**
* 数据库调试页面 UI 状态
* @param items 当前已加载的记录列表
* @param totalCount 当前过滤条件下的总记录数
* @param hasMore 是否还有更多数据可加载
* @param isLoading 是否正在加载(首次加载或加载更多)
* @param filter 当前生效的筛选条件,供"加载更多"复用
*/
data class DbInspectUiState(
val items: List<DbRecord> = emptyList(),
val totalCount: Int = 0,
val hasMore: Boolean = false,
val isLoading: Boolean = false,
val filter: DbInspectFilter = DbInspectFilter()
)
/**
* 数据库调试页面筛选条件
* @param showDel 是否包含已删除记录
* @param exactFields 精确匹配字段:列名 → 值
* @param likeFields 模糊匹配字段:列名 → 值(自动加 %value%)
*/
data class DbInspectFilter(
val showDel: Boolean = true,
val exactFields: Map<String, String> = emptyMap(),
val likeFields: Map<String, String> = emptyMap()
)
private val _dbInspectState = MutableStateFlow(DbInspectUiState())
val dbInspectState: StateFlow<DbInspectUiState> = _dbInspectState.asStateFlow()
/**
* 加载指定表的第一页数据(切换 Tab / 修改筛选条件时调用)
* @param tableIndex 0=菜品 1=食材 2=调料 3=槽位
* @param filter 筛选条件,包含 showDel 和各字段过滤值
*/
fun loadDbInspect(tableIndex: Int, filter: DbInspectFilter) {
viewModelScope.launch {
// 立即清空旧数据并显示加载状态,避免切换 Tab 时旧数据闪现
_dbInspectState.value = DbInspectUiState(isLoading = true, filter = filter)
val (items, total) = queryPage(tableIndex, filter, offset = 0)
_dbInspectState.value = DbInspectUiState(
items = items,
totalCount = total,
hasMore = items.size < total,
isLoading = false,
filter = filter
)
}
}
/**
* 追加加载下一页数据(点击"加载更多"时调用)
* 筛选条件从当前 state 中复用,无需重新传入
* @param tableIndex 0=菜品 1=食材 2=调料 3=槽位
*/
fun loadMoreDbInspect(tableIndex: Int) {
viewModelScope.launch {
val current = _dbInspectState.value
// 已在加载中或无更多数据时直接返回,防止重复请求
if (!current.hasMore || current.isLoading) return@launch
_dbInspectState.value = current.copy(isLoading = true)
val (newItems, total) = queryPage(tableIndex, current.filter, offset = current.items.size)
val merged = current.items + newItems
_dbInspectState.value = DbInspectUiState(
items = merged,
totalCount = total,
hasMore = merged.size < total,
isLoading = false,
filter = current.filter
)
}
}
/**
* 执行分页查询,返回 DbRecord 列表和总记录数
* 通过 buildQueries 将 filter 转换为动态 SQL
*/
private suspend fun queryPage(
tableIndex: Int,
filter: DbInspectFilter,
offset: Int
): Pair<List<DbRecord>, Int> {
val limit = DB_INSPECT_PAGE_SIZE
return when (tableIndex) {
0 -> {
val (pageQ, countQ) = buildQueries("dm_cook_food", "createTime DESC", true, filter, limit, offset)
val total = rep.countCookFoodFiltered(countQ)
val rows = rep.getCookFoodPagedFiltered(pageQ).map { e ->
DbRecord(
isDel = e.isDel == 1,
fields = listOf(
"id" to "${e.id}", "foodId" to "${e.foodId}", "foodName" to "${e.foodName}",
"canteenId" to "${e.canteenId}", "foodWeight" to "${e.foodWeight}", "cookMode" to "${e.cookMode}",
"dinnerType" to "${e.dinnerType}", "isDel" to "${e.isDel}", "submitState" to "${e.submitState}",
"createTime" to "${e.createTime}"
)
)
}
rows to total
}
1 -> {
val (pageQ, countQ) = buildQueries("dm_cook_food_goods", "createTime DESC", true, filter, limit, offset)
val total = rep.countCookFoodGoodsFiltered(countQ)
val rows = rep.getCookFoodGoodsPagedFiltered(pageQ).map { e ->
DbRecord(
isDel = e.isDel == 1,
fields = listOf(
"id" to "${e.id}", "pid" to "${e.pid}", "goodsId" to "${e.goodsId}",
"goodsName" to "${e.goodsName}", "foodId" to "${e.foodId}", "materialType" to "${e.materialType}",
"relateionType" to "${e.relateionType}", "allEdible" to "${e.allEdible}", "useWeight" to "${e.useWeight}",
"sort" to "${e.sort}", "zjmCode" to "${e.zjmCode}", "materId" to "${e.materId}",
"isDel" to "${e.isDel}", "createTime" to "${e.createTime}"
)
)
}
rows to total
}
2 -> {
val (pageQ, countQ) = buildQueries("dm_seasoning", "sort ASC", true, filter, limit, offset)
val total = rep.countSeasoningFiltered(countQ)
val rows = rep.getSeasoningPagedFiltered(pageQ).map { e ->
DbRecord(
isDel = e.isDel == 1,
fields = listOf(
"id" to "${e.id}", "goodsId" to "${e.goodsId}", "goodsName" to "${e.goodsName}",
"sort" to "${e.sort}", "materialType" to "${e.materialType}", "useWeight" to "${e.useWeight}",
"zjmCode" to "${e.zjmCode}", "materId" to "${e.materId}", "isDel" to "${e.isDel}",
"createTime" to "${e.createTime}"
)
)
}
rows to total
}
3 -> {
val (pageQ, countQ) = buildQueries("dm_seasoning_slot", "deviceId ASC, address ASC", false, filter, limit, offset)
val total = rep.countSlotFiltered(countQ)
val rows = rep.getSlotPagedFiltered(pageQ).map { e ->
DbRecord(
isDel = false,
fields = listOf(
"deviceId" to e.deviceId, "address" to "${e.address}",
"goodsId" to e.goodsId, "goodsName" to e.goodsName
),
columnsPerRow = 1
)
}
rows to total
}
else -> emptyList<DbRecord>() to 0
}
}
/**
* 根据筛选条件动态构建分页查询和计数查询的 SQL
* @param tableName 表名
* @param orderBy ORDER BY 子句(不含关键字)
* @param hasIsDel 是否有 isDel 字段,无则忽略 showDel 参数
* @param filter 筛选条件
* @param limit 分页大小
* @param offset 偏移量
* @return Pair<分页查询, 计数查询>
*/
private fun buildQueries(
tableName: String,
orderBy: String,
hasIsDel: Boolean,
filter: DbInspectFilter,
limit: Int,
offset: Int
): Pair<SimpleSQLiteQuery, SimpleSQLiteQuery> {
val conditions = mutableListOf<String>()
val args = mutableListOf<Any>()
if (hasIsDel && !filter.showDel) {
conditions.add("isDel = 0")
}
filter.exactFields.forEach { (col, value) ->
if (value.isNotBlank()) {
conditions.add("$col = ?")
args.add(value)
}
}
filter.likeFields.forEach { (col, value) ->
if (value.isNotBlank()) {
conditions.add("$col LIKE ?")
args.add("%$value%")
}
}
val where = if (conditions.isEmpty()) "" else "WHERE ${conditions.joinToString(" AND ")}"
val pageQuery = SimpleSQLiteQuery(
"SELECT * FROM $tableName $where ORDER BY $orderBy LIMIT ? OFFSET ?",
(args + limit + offset).toTypedArray()
)
val countQuery = SimpleSQLiteQuery(
"SELECT COUNT(*) FROM $tableName $where",
args.toTypedArray()
)
return pageQuery to countQuery
}
//--------------------------------------------------------------------------------------------------------
// viewModelScope.launch {
// list.forEach {
// rep.updateSeasoning(it)
@@ -266,6 +559,20 @@ class DbViewModel : ViewModel() {
}
}
/**
* 按 goodsId 查重后 upsert 调料数据,确保同一 goodsId 只有一条记录
*/
suspend fun upsertSeasoningByGoodsId(entity: SeasoningEntity) {
rep.upsertSeasoningByGoodsId(entity)
}
/**
* 按 goodsId 查询调料详情
*/
suspend fun getSeasoningByGoodsId(goodsId: String): SeasoningEntity? {
return rep.getSeasoningByGoodsId(goodsId)
}
//--------------------------------------------------------------------------------------------------------
/**
@@ -301,5 +608,17 @@ class DbViewModel : ViewModel() {
}
}
/**
* 清除所有向量采集数据,完成后刷新列表
* @param onDone 清除完成后在主线程执行的回调
*/
fun clearAllCollectedFood(onDone: (() -> Unit)? = null) {
viewModelScope.launch {
ObjectBox.removeAll()
loadCollectedFoodList()
onDone?.invoke()
}
}
//--------------------------------------------------------------------------------------------------------
}
@@ -4,8 +4,10 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.RawQuery
import androidx.room.Update
import com.shuwei.dish.match.entity.CookFoodEntity
import androidx.sqlite.db.SupportSQLiteQuery
import com.shuwei.dish.match.db.entity.CookFoodEntity
@Dao
interface CookFoodDao {
@@ -16,8 +18,8 @@ interface CookFoodDao {
@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 foodId = :foodId AND cookMode = :cookMode AND dinnerType = :dinnerType")
suspend fun getCookFoodById(canteenId: String, foodId: String, cookMode: Int, dinnerType: String): 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>?
@@ -25,6 +27,35 @@ interface CookFoodDao {
@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)
// @Query("DELETE FROM dm_cook_food WHERE canteenId = :canteenId AND foodId = :foodId AND cookMode = :cookMode")
@Query("UPDATE dm_cook_food SET isDel = 1 WHERE isDel = 0 AND canteenId = :canteenId AND foodId = :foodId AND cookMode = :cookMode AND dinnerType = :dinnerType")
suspend fun deleteCookFood(canteenId: String, foodId: String, cookMode: Int, dinnerType: String)
/** 接口提交成功后逻辑删除菜品,同时把 submitState 置 1 以便在数据库审计中区分手动删除 */
@Query("UPDATE dm_cook_food SET isDel = 1, submitState = 1 WHERE isDel = 0 AND canteenId = :canteenId AND foodId = :foodId AND cookMode = :cookMode AND dinnerType = :dinnerType")
suspend fun markSubmittedAndDelete(canteenId: String, foodId: String, cookMode: Int, dinnerType: String)
/** 查询全部菜品记录(含已删除),用于数据库调试查看 */
@Query("SELECT * FROM dm_cook_food ORDER BY createTime DESC")
suspend fun getAllCookFood(): MutableList<CookFoodEntity>
/** 分页查询菜品记录,showDel=true 时包含已删除记录 */
@Query("SELECT * FROM dm_cook_food WHERE (:showDel = 1 OR isDel = 0) ORDER BY createTime DESC LIMIT :limit OFFSET :offset")
suspend fun getCookFoodPaged(showDel: Int, limit: Int, offset: Int): List<CookFoodEntity>
/** 统计菜品记录总数,showDel=true 时包含已删除记录 */
@Query("SELECT COUNT(*) FROM dm_cook_food WHERE (:showDel = 1 OR isDel = 0)")
suspend fun countCookFoodAll(showDel: Int): Int
/** 物理清空全表,用于清除测试数据 */
@Query("DELETE FROM dm_cook_food")
suspend fun deleteAll()
/** 动态条件分页查询菜品记录,由 ViewModel 构建 SQL */
@RawQuery
suspend fun queryPagedFiltered(query: SupportSQLiteQuery): List<CookFoodEntity>
/** 动态条件统计菜品记录总数,由 ViewModel 构建 SQL */
@RawQuery
suspend fun countFiltered(query: SupportSQLiteQuery): Int
}
@@ -4,8 +4,10 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.RawQuery
import androidx.room.Update
import com.shuwei.dish.match.entity.CookFoodGoodsEntity
import androidx.sqlite.db.SupportSQLiteQuery
import com.shuwei.dish.match.db.entity.CookFoodGoodsEntity
@Dao
interface CookFoodGoodsDao {
@@ -25,6 +27,35 @@ interface CookFoodGoodsDao {
@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")
// @Query("DELETE FROM dm_cook_food_goods WHERE pid = :pid AND foodId = :foodId")
@Query("UPDATE dm_cook_food_goods SET isDel = 1 WHERE isDel = 0 AND pid = :pid AND foodId = :foodId")
suspend fun deleteCookFoodGoodsList(pid: Long, foodId: String)
/** 仅逻辑删除调料行(materialType = 3),主辅材不动,用于 cook 时只重建调料部分 */
@Query("UPDATE dm_cook_food_goods SET isDel = 1 WHERE isDel = 0 AND pid = :pid AND foodId = :foodId AND materialType = 3")
suspend fun deleteSeasoningOnly(pid: Long, foodId: String)
/** 查询全部食材记录(含已删除),用于数据库调试查看 */
@Query("SELECT * FROM dm_cook_food_goods ORDER BY createTime DESC")
suspend fun getAllCookFoodGoods(): MutableList<CookFoodGoodsEntity>
/** 分页查询食材记录,showDel=true 时包含已删除记录 */
@Query("SELECT * FROM dm_cook_food_goods WHERE (:showDel = 1 OR isDel = 0) ORDER BY createTime DESC LIMIT :limit OFFSET :offset")
suspend fun getCookFoodGoodsPaged(showDel: Int, limit: Int, offset: Int): List<CookFoodGoodsEntity>
/** 统计食材记录总数,showDel=true 时包含已删除记录 */
@Query("SELECT COUNT(*) FROM dm_cook_food_goods WHERE (:showDel = 1 OR isDel = 0)")
suspend fun countCookFoodGoodsAll(showDel: Int): Int
/** 物理清空全表,用于清除测试数据 */
@Query("DELETE FROM dm_cook_food_goods")
suspend fun deleteAll()
/** 动态条件分页查询食材记录,由 ViewModel 构建 SQL */
@RawQuery
suspend fun queryPagedFiltered(query: SupportSQLiteQuery): List<CookFoodGoodsEntity>
/** 动态条件统计食材记录总数,由 ViewModel 构建 SQL */
@RawQuery
suspend fun countFiltered(query: SupportSQLiteQuery): Int
}
@@ -4,8 +4,10 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.RawQuery
import androidx.room.Update
import com.shuwei.dish.match.entity.SeasoningEntity
import androidx.sqlite.db.SupportSQLiteQuery
import com.shuwei.dish.match.db.entity.SeasoningEntity
@Dao
interface SeasoningDao {
@@ -26,7 +28,7 @@ interface SeasoningDao {
suspend fun getSeasoningBySort(sort: Int): MutableList<SeasoningEntity>?
@Query("SELECT * FROM dm_seasoning WHERE isDel = 0 AND goodsId = :goodsId")
suspend fun getSeasoningByGoodsId(goodsId: Int): SeasoningEntity?
suspend fun getSeasoningByGoodsId(goodsId: String): SeasoningEntity?
@Query("SELECT * FROM dm_seasoning WHERE isDel = 0 AND goodsId != -1 AND goodsName != '' ORDER BY sort ASC")
fun getAllStream(): MutableList<SeasoningEntity>
@@ -34,9 +36,35 @@ interface SeasoningDao {
@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")
// @Query("DELETE FROM dm_seasoning WHERE isDel = 0 AND sort = :sort")
@Query("UPDATE dm_seasoning SET isDel = 1 WHERE isDel = 0 AND sort = :sort")
suspend fun deleteSeasoningBySort(sort: Int): Int
@Query("DELETE FROM dm_seasoning WHERE isDel = 0")
// @Query("DELETE FROM dm_seasoning WHERE isDel = 0")
@Query("UPDATE dm_seasoning SET isDel = 1 WHERE isDel = 0")
suspend fun clearAllSeasoning(): Int
/** 查询全部调料记录(含已删除),用于数据库调试查看 */
@Query("SELECT * FROM dm_seasoning ORDER BY sort ASC")
suspend fun getAllSeasoning(): MutableList<SeasoningEntity>
/** 分页查询调料记录,showDel=true 时包含已删除记录 */
@Query("SELECT * FROM dm_seasoning WHERE (:showDel = 1 OR isDel = 0) ORDER BY sort ASC LIMIT :limit OFFSET :offset")
suspend fun getSeasoningPaged(showDel: Int, limit: Int, offset: Int): List<SeasoningEntity>
/** 统计调料记录总数,showDel=true 时包含已删除记录 */
@Query("SELECT COUNT(*) FROM dm_seasoning WHERE (:showDel = 1 OR isDel = 0)")
suspend fun countSeasoningAll(showDel: Int): Int
/** 物理清空全表,用于清除测试数据(比 clearAllSeasoning 的逻辑删除更彻底) */
@Query("DELETE FROM dm_seasoning")
suspend fun deleteAll()
/** 动态条件分页查询调料记录,由 ViewModel 构建 SQL */
@RawQuery
suspend fun queryPagedFiltered(query: SupportSQLiteQuery): List<SeasoningEntity>
/** 动态条件统计调料记录总数,由 ViewModel 构建 SQL */
@RawQuery
suspend fun countFiltered(query: SupportSQLiteQuery): Int
}
@@ -4,7 +4,9 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.shuwei.dish.match.entity.SeasoningSlotEntity
import androidx.room.RawQuery
import androidx.sqlite.db.SupportSQLiteQuery
import com.shuwei.dish.match.db.entity.SeasoningSlotEntity
/**
* 调料槽位配置 DAO
@@ -38,6 +40,14 @@ interface SeasoningSlotDao {
@Query("SELECT * FROM dm_seasoning_slot")
suspend fun queryAll(): List<SeasoningSlotEntity>
/** 分页查询槽位记录(槽位表无 isDel,showDel 参数忽略) */
@Query("SELECT * FROM dm_seasoning_slot ORDER BY deviceId ASC, address ASC LIMIT :limit OFFSET :offset")
suspend fun getSlotPaged(limit: Int, offset: Int): List<SeasoningSlotEntity>
/** 统计槽位记录总数 */
@Query("SELECT COUNT(*) FROM dm_seasoning_slot")
suspend fun countSlotAll(): Int
/**
* 删除指定设备的指定槽位配置
* @param deviceId 目标设备 ID
@@ -52,4 +62,16 @@ interface SeasoningSlotDao {
*/
@Query("DELETE FROM dm_seasoning_slot WHERE deviceId = :deviceId")
suspend fun deleteAllByDeviceId(deviceId: String)
/** 物理清空全表,用于清除测试数据 */
@Query("DELETE FROM dm_seasoning_slot")
suspend fun deleteAll()
/** 动态条件分页查询槽位记录,由 ViewModel 构建 SQL */
@RawQuery
suspend fun queryPagedFiltered(query: SupportSQLiteQuery): List<SeasoningSlotEntity>
/** 动态条件统计槽位记录总数,由 ViewModel 构建 SQL */
@RawQuery
suspend fun countFiltered(query: SupportSQLiteQuery): Int
}
@@ -1,4 +1,4 @@
package com.shuwei.dish.match.entity
package com.shuwei.dish.match.db.entity
import androidx.room.Entity
import androidx.room.Ignore
@@ -18,11 +18,13 @@ data class CookFoodEntity(
//0-制作模式,1-采样模式,2-品控模式
var cookMode:Int = 0,
var isDel: Int = 0,
/** 接口提交状态:0-未提交(本地草稿或手动删除),1-接口提交成功后删除 */
var submitState: Int = 0,
var createTime: String = DateTimeUtil.formatDateTime(LocalDateTime.now()),
var dinnerType: String? = "0",
) : BaseEntity {
@Ignore
var stFoodInfoConstituteList: MutableList<CookFoodGoodsEntity>? = null
// @Ignore
// var stFoodInfoConstituteList: MutableList<CookFoodGoodsEntity>? = null
@Ignore
var matchingConstituteInfoList: MutableList<CookFoodGoodsEntity>? = null
@Ignore
@@ -1,9 +1,9 @@
package com.shuwei.dish.match.entity
package com.shuwei.dish.match.db.entity
import androidx.room.Entity
import androidx.room.Ignore
import androidx.room.PrimaryKey
import com.shuwei.dish.match.db.BaseEntity
import com.shuwei.dish.match.model.GoodsItem
import com.shuwei.dish.match.utils.DateTimeUtil
import java.io.Serializable
import java.time.LocalDateTime
@@ -63,30 +63,36 @@ class CookFoodGoodsEntity(
var relateionType_dictText: String? = "",
var zjmCode: String? = "",
var materId: String? = "",
/** 物料编码 */
var goodsCode: String? = "",
/** 净材种类 */
var rawMaterialsType: String? = null,
var isDel: Int = 0,
var createTime: String = DateTimeUtil.formatDateTime(LocalDateTime.now())
) : BaseEntity, Serializable {
constructor() : this(goodsId = "") // 必需的空构造,
constructor() : this(goodsId = "") // 必需的空构造
@Ignore
var isSetFinished: Boolean = false
@Ignore
var isItemClicked: Boolean = false
@Ignore
var isSamplingPage: Boolean = false
@Ignore
var isNewDishType: Boolean = false
@Ignore
var isClicked: Boolean = false
//true-原始数据,false-非原始数据
@Ignore
var isOriginalData: Boolean = true
@Ignore
var foodScore: Int = -1
/**
* 转换为 GoodsItem用于 UI DishPartAdapterPrepareFoodActivity
*/
fun toGoodsItem(): GoodsItem = GoodsItem(
goodsId = goodsId,
goodsName = goodsName,
popularName = popularName,
zjmCode = zjmCode,
materId = materId,
materCode = goodsCode,
materialType = materialType,
useWeight = useWeight,
relateionType = relateionType,
allEdible = allEdible,
goodsOrRelationCode = goodsOrRelationCode,
rawMaterialsType = rawMaterialsType
)
}
@@ -1,7 +1,6 @@
package com.shuwei.dish.match.entity
package com.shuwei.dish.match.db.entity
import androidx.room.Entity
import androidx.room.Ignore
import androidx.room.PrimaryKey
import com.shuwei.dish.match.db.BaseEntity
import com.shuwei.dish.match.utils.DateTimeUtil
@@ -55,6 +54,16 @@ data class SeasoningEntity(
var relateionType_dictText: String? = "",
var zjmCode: String? = "",
var materId: String? = "",
/** 物料编码 */
var goodsCode: String? = "",
/** 净材种类 */
var rawMaterialsType: String? = null,
var sort: Int = 9999,
/**
* isDel表示数据是否已删除1-已删除为无效数据0-正常使用有效数据
@@ -62,11 +71,5 @@ data class SeasoningEntity(
var isDel: Int = 0,
var createTime: String = DateTimeUtil.formatDateTime(LocalDateTime.now())
) : BaseEntity {
@Ignore
var pageType: Int = 0
@Ignore
var isClicked: Boolean = false
constructor() : this(goodsId = "") // 必需的空构造
}
@@ -1,4 +1,4 @@
package com.shuwei.dish.match.entity
package com.shuwei.dish.match.db.entity
import androidx.room.Entity
@@ -1,6 +1,5 @@
package com.shuwei.dish.match.dialog
import android.app.Activity
import android.app.Dialog
import android.content.Context
import android.graphics.drawable.ColorDrawable
@@ -8,13 +7,13 @@ import android.os.Bundle
import android.view.View
import android.view.Window
import android.widget.LinearLayout
import androidx.annotation.StyleRes
import android.widget.TextView
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.utils.ext.dp
import java.lang.ref.WeakReference
import com.shuwei.dish.match.R
open class CommonDialog(
context: Context
@@ -27,10 +26,17 @@ open class CommonDialog(
private var negativeText = "取消"
private var positiveText = "确认"
private var neutralText: String? = null
private var singleText: String? = null
private var negativeClick: (() -> Unit)? = null
private var positiveClick: (() -> Unit)? = null
// 返回 true 时关闭弹窗,返回 false 时保持弹窗显示(用于校验不通过的场景)
private var positiveClick: (() -> Boolean)? = null
private var neutralClick: (() -> Unit)? = null
private var singleClick: (() -> Unit)? = null
private var dismissCallback: (() -> Unit)? = null
// 待添加到 llContent 的自定义 View,在 onCreate 后挂载
private var pendingContentView: View? = null
// pendingContentView 非空时对 tvContent 的灵活控制回调(可调整可见性、间距等)
private var tvContentConfigurator: ((TextView) -> Unit)? = null
/** 设置标题,为空时隐藏 */
fun setTitle(text: String): CommonDialog = apply { titleText = text }
@@ -44,8 +50,17 @@ open class CommonDialog(
negativeClick = onClick
}
/** 设置右侧确认按钮文字及点击回调(3按钮模式下对应底部按钮) */
/** 设置右侧确认按钮文字及点击回调(3按钮模式下对应底部按钮),点击后自动关闭弹窗 */
fun setPositiveButton(text: String, onClick: (() -> Unit)? = null): CommonDialog = apply {
positiveText = text
positiveClick = onClick?.let { cb -> { cb(); true } }
}
/**
* 设置右侧确认按钮文字及可拦截的点击回调
* 回调返回 true 时关闭弹窗,返回 false 时保持弹窗显示(用于校验不通过的场景)
*/
fun setPositiveButtonInterceptable(text: String, onClick: () -> Boolean): CommonDialog = apply {
positiveText = text
positiveClick = onClick
}
@@ -56,11 +71,27 @@ open class CommonDialog(
neutralClick = onClick
}
/** 设置单按钮文字及点击回调,调用后自动切换为单按钮布局(优先级最高) */
fun setSingleButton(text: String, onClick: (() -> Unit)? = null): CommonDialog = apply {
singleText = text
singleClick = onClick
}
/** 设置弹窗消失回调 */
fun setOnDismissCallback(callback: () -> Unit): CommonDialog = apply {
dismissCallback = callback
}
/**
* 向弹窗自定义内容区域添加 View,调用后 llContent 自动显示
* @param view 要嵌入的自定义布局(如单选列表)
* @param tvContentConfig 可选回调,用于在 pendingContentView 非空时灵活控制 tvContent 的可见性及间距等属性
*/
fun addContentView(view: View, tvContentConfig: ((TextView) -> Unit)? = null): CommonDialog = apply {
pendingContentView = view
tvContentConfigurator = tvContentConfig
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestWindowFeature(Window.FEATURE_NO_TITLE)
@@ -68,19 +99,19 @@ open class CommonDialog(
setContentView(binding.root)
// 弹窗不允许点击外部关闭
setCancelable(false)
val isMaster = GlobalData.deviceRole == DeviceRole.MASTER
window?.run {
val screenWidth = context.resources.displayMetrics.widthPixels
val isMaster = GlobalData.deviceRole == DeviceRole.MASTER
attributes = attributes.apply {
width = if (isMaster) (screenWidth * 0.72f).toInt() else (screenWidth * 0.60f).toInt()
}
// 透明背景,让 shape_dialog_bg 的阴影层正常显示
setBackgroundDrawable(ColorDrawable())
// 应用缩放+淡入淡出动画
setWindowAnimations(com.shuwei.dish.match.R.style.CommonDialogAnimation)
setWindowAnimations(R.style.CommonDialogAnimation)
}
// 主/从设备边距微调:主设备内容区域更大,适当增加上下留白
val verticalMargin = if (GlobalData.deviceRole == DeviceRole.MASTER) 48.dp else 36.dp
val verticalMargin = if (isMaster) 48.dp else 36.dp
binding.tvTitle.updateLayoutParams<LinearLayout.LayoutParams> { topMargin = verticalMargin }
binding.tvContent.updateLayoutParams<LinearLayout.LayoutParams> { bottomMargin = verticalMargin }
@@ -90,8 +121,8 @@ open class CommonDialog(
dismiss()
}
binding.btnRight.setOnClickListener {
positiveClick?.invoke()
dismiss()
// 回调返回 false 时阻止关闭,null 或 true 时正常关闭
if (positiveClick?.invoke() != false) dismiss()
}
// 3按钮点击事件(negativeClick/positiveClick 与2按钮模式共用)
@@ -104,11 +135,24 @@ open class CommonDialog(
dismiss()
}
binding.btnBottom.setOnClickListener {
positiveClick?.invoke()
if (positiveClick?.invoke() != false) dismiss()
}
// 单按钮点击事件
binding.btnSingle.setOnClickListener {
singleClick?.invoke()
dismiss()
}
setOnDismissListener { dismissCallback?.invoke() }
// 挂载自定义内容 View,并执行 tvContent 的灵活控制回调
pendingContentView?.let {
binding.llContent.removeAllViews()
binding.llContent.addView(it)
binding.llContent.visibility = View.VISIBLE
tvContentConfigurator?.invoke(binding.tvContent)
}
}
override fun show() {
@@ -119,10 +163,15 @@ open class CommonDialog(
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
val hasSingleButton = !singleText.isNullOrEmpty()
binding.btnSingle.visibility = if (hasSingleButton) View.VISIBLE else View.GONE
binding.layoutButtonsHorizontal.visibility = if (!hasSingleButton && !hasThreeButtons) View.VISIBLE else View.GONE
binding.layoutButtonsVertical.visibility = if (hasThreeButtons) View.VISIBLE else View.GONE
if (hasThreeButtons) {
if (hasSingleButton) {
// 单按钮模式
binding.btnSingle.text = singleText
} else if (hasThreeButtons) {
// 3按钮模式:顶部=取消,中间=中立,底部=确认
binding.btnTop.text = negativeText
binding.btnMiddle.text = neutralText
@@ -134,14 +183,3 @@ open class CommonDialog(
}
}
}
open class SafeDialog(activity: Activity, @StyleRes themeResId: Int) :
Dialog(activity, themeResId) {
private val weakContext = WeakReference(activity)
override fun show() {
weakContext.get()?.let {
if (!it.isFinishing && !it.isDestroyed) super.show()
}
}
}
@@ -0,0 +1,68 @@
package com.shuwei.dish.match.dialog
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import com.shuwei.dish.match.base.GlobalData
import com.shuwei.dish.match.databinding.DialogEnvSwitchContentBinding
import com.shuwei.dish.match.utils.SpTool
import com.shuwei.dish.match.utils.ext.toast
/**
* 环境切换弹窗
*
* 基于 [CommonDialog] 实现,通过 addContentView 注入 RadioGroup 内容区。
* 提供 TEST / UAT / PROD 三套环境的切换功能,初始化时自动根据
* [GlobalData.appBaseUrl] 选中当前环境,确认后更新 [GlobalData.appBaseUrl]
* 并通过 [onEnvChanged] 回调通知外部。
*
* @param context 上下文
* @param onEnvChanged 确认切换后的回调,参数为新的 baseUrl
*/
class EnvSwitchDialog(
context: Context,
private val onEnvChanged: (newBaseUrl: String) -> Unit = {}
) {
private val contentBinding = DialogEnvSwitchContentBinding.inflate(LayoutInflater.from(context))
private val dialog = CommonDialog(context)
.setTitle("切换环境")
.addContentView(contentBinding.root) { tvContent ->
// 内容区已由 RadioGroup 承载,隐藏默认文本
tvContent.visibility = View.GONE
}
.setNegativeButton("取消")
.setPositiveButtonInterceptable("确认") {
// 未选中任何选项时提示并阻止关闭
if (contentBinding.rgEnv.checkedRadioButtonId == -1) {
context.toast("请选择环境")
return@setPositiveButtonInterceptable false
}
val newUrl = when (contentBinding.rgEnv.checkedRadioButtonId) {
contentBinding.rbTest.id -> GlobalData.TEST_BASE_URL
contentBinding.rbUat.id -> GlobalData.UAT_BASE_URL
contentBinding.rbProd.id -> GlobalData.PROD_BASE_URL
else -> return@setPositiveButtonInterceptable false
}
GlobalData.appBaseUrl = newUrl
SpTool.baseUrl = newUrl
onEnvChanged(newUrl)
true
}
init {
// 根据当前 appBaseUrl 预选对应 RadioButton
when (GlobalData.appBaseUrl) {
GlobalData.TEST_BASE_URL -> contentBinding.rbTest.isChecked = true
GlobalData.UAT_BASE_URL -> contentBinding.rbUat.isChecked = true
GlobalData.PROD_BASE_URL -> contentBinding.rbProd.isChecked = true
}
}
/** 显示弹窗 */
fun show() = dialog.show()
/** 关闭弹窗 */
fun dismiss() = dialog.dismiss()
}
@@ -1,62 +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()
}
}
//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.FoodAdapter
//import com.shuwei.dish.match.base.BaseActivity
//import com.shuwei.dish.match.databinding.DialogFoodRecognizeBinding
//import com.shuwei.dish.match.entity.GoodsItem
//
///**
// * 菜品识别结果弹窗,继承 BottomSheetDialog 确保只初始化一次
// * @param activity 宿主 Activity
// * @param onDismiss 弹窗关闭时的额外回调(可选)
// * @param onItemSelected 用户点击某一菜品时的回调
// */
//class FoodRecognizeDialog(
// private val activity: BaseActivity,
// private val onDismiss: (() -> Unit)? = null,
// private val onItemSelected: (item: GoodsItem) -> Unit
//) : BottomSheetDialog(activity, R.style.BottomSheet) {
//
// private val binding = DialogFoodRecognizeBinding.inflate(LayoutInflater.from(activity))
// private val list = mutableListOf<GoodsItem>()
// private val adapter = FoodAdapter(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<GoodsItem>) {
// list.clear()
// list.addAll(items)
// adapter.notifyDataSetChanged()
// if (!isShowing) show()
// }
//}
@@ -1,30 +1,32 @@
package com.shuwei.dish.match.dialog
import android.annotation.SuppressLint
import android.os.Handler
import android.os.Looper
import android.graphics.Rect
import android.text.Editable
import android.view.KeyboardShortcutGroup
import android.view.LayoutInflater
import android.view.Menu
import android.view.MotionEvent
import android.view.View
import androidx.core.view.isEmpty
import android.view.Window
import android.widget.FrameLayout
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.RecyclerView
import androidx.recyclerview.widget.LinearLayoutManager
import com.chad.library.adapter4.util.setOnDebouncedItemClick
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.adapter.FoodAdapter
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.databinding.DialogFoodSearchBinding
import com.shuwei.dish.match.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.databinding.LayoutEmptyViewBinding
import com.shuwei.dish.match.model.GoodsItem
import com.shuwei.dish.match.model.MaterSearchItem
import com.shuwei.dish.match.net.UiState
import com.shuwei.dish.match.utils.KeyboardUtil
import com.shuwei.dish.match.utils.showRawMaterialsDialog
import com.shuwei.dish.match.utils.ext.addOnActionSearchListener
import com.shuwei.dish.match.utils.ext.toast
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
/**
@@ -36,25 +38,47 @@ import kotlinx.coroutines.launch
class FoodSearchDialog(
private val activity: BaseActivity,
private val defGoodsName: String? = null,
private val onItemSelected: (item: CookFoodGoodsEntity) -> Unit
private val onItemSelected: (item: GoodsItem) -> Unit
) : BottomSheetDialog(activity, R.style.BottomSheet) {
companion object {
const val PAGE_SIZE = 50
}
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
private val list = mutableListOf<GoodsItem>()
private val adapter = FoodAdapter(list).apply {
isStateViewEnable = true
setOnDebouncedItemClick { _, _, position ->
val item = list.getOrNull(position) ?: return@setOnDebouncedItemClick
item.isClicked = true
notifyItemChanged(position)
Handler(Looper.getMainLooper()).postDelayed({
onItemSelected(list[position])
// mater-search 不返回净材种类,rawMaterialsTypes 为空时直接选中返回,避免卡在净材弹窗
if (item.rawMaterialsTypes.isNullOrBlank()) {
binding.root.postDelayed({
onItemSelected(item)
dismiss()
}, 300)
return@setOnDebouncedItemClick
}
// 有净材种类时弹出单选弹窗,确认后再回调并关闭弹窗
showRawMaterialsDialog(
context = activity,
types = item.rawMaterialsTypes,
checkedType = item.rawMaterialsType
) { selectedType ->
item.rawMaterialsType = selectedType
binding.root.postDelayed({
onItemSelected(item)
dismiss()
}, 300)
}
}
}
private var goodsName: String? = null
private var pageNo = 1
private val pageSize = 50
init {
setContentView(binding.root)
@@ -79,22 +103,22 @@ class FoodSearchDialog(
addOnActionSearchListener { searchGoods(this) }
}
// RecyclerView 初始化及滑动冲突处理
// RecyclerView 初始化
binding.recyclerView.run {
layoutManager = GridLayoutManager(activity, 2, GridLayoutManager.VERTICAL, false)
adapter = this@FoodSearchDialog.adapter
addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(rv, dx, dy)
// 解决 RecyclerView 与 SmartRefreshLayout 滑动冲突
val topRowVerticalPosition = if (rv.isEmpty()) 0 else rv.getChildAt(0).top
binding.refreshLayout.setNestedScrollingEnabled(topRowVerticalPosition >= 0)
}
})
// addOnScrollListener(object : RecyclerView.OnScrollListener() {
// override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
// super.onScrolled(rv, dx, dy)
// // 解决 RecyclerView 与 SmartRefreshLayout 滑动冲突
// val topRowVerticalPosition = if (rv.isEmpty()) 0 else rv.getChildAt(0).top
// binding.refreshLayout.setNestedScrollingEnabled(topRowVerticalPosition >= 0)
// }
// })
}
binding.refreshLayout.run {
setEnableRefresh(false)
setEnableRefresh(true)
setEnableLoadMore(false)
setOnRefreshListener { pageNo = 1; getGoodsList() }
setOnLoadMoreListener { getGoodsList() }
@@ -103,31 +127,49 @@ class FoodSearchDialog(
binding.ivSearch.setOnClickListener { searchGoods(it) }
binding.root.setOnClickListener { KeyboardUtil.hideKeyboard(it) }
initObserver()
// 若有默认食材名称,自动填充并触发搜索
if (!defGoodsName.isNullOrBlank()) {
binding.etSheetInput.setText(defGoodsName)
searchGoods(binding.etSheetInput)
}
//if (!defGoodsName.isNullOrBlank()) {
//binding.etSheetInput.setText(defGoodsName)
pageNo = 1
getGoodsList()
KeyboardUtil.hideKeyboard(binding.root)
//}
}
/**
* 收集 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()
/** 弹窗显示时在 Window.Callback 层提前拦截触摸,解决 BottomSheet 拖拽与列表滑动的手势冲突 */
override fun show() {
super.show()
// Window.Callback.dispatchTouchEvent 在整个 View 树的 onInterceptTouchEvent 之前执行,
// 是最早能感知触摸的时机。在此处判断触摸点位置来控制 behavior.isDraggable
// 从而确保 BottomSheetBehavior 在 CoordinatorLayout.onInterceptTouchEvent 时
// 已经是禁用状态,不会拦截列表区域的滑动手势。
val originalCallback = window?.callback ?: return
window?.callback = object : Window.Callback by originalCallback {
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
val rect = Rect()
binding.refreshLayout.getGlobalVisibleRect(rect)
// 触摸点落在列表区域内则禁止 BottomSheet 拖拽
behavior.isDraggable = !rect.contains(event.rawX.toInt(), event.rawY.toInt())
}
else -> {}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
behavior.isDraggable = true
}
}
return originalCallback.dispatchTouchEvent(event)
}
override fun onPointerCaptureChanged(hasCapture: Boolean) {
originalCallback.onPointerCaptureChanged(hasCapture)
}
override fun onProvideKeyboardShortcuts(
data: List<KeyboardShortcutGroup?>?,
menu: Menu?,
deviceId: Int
) {
originalCallback.onProvideKeyboardShortcuts(data, menu, deviceId)
}
}
}
@@ -146,41 +188,104 @@ class FoodSearchDialog(
}
/**
* 请求食材列表
* 请求食材列表(采样模式新配比秤接口 mater-search,回调版本)
*/
private fun getGoodsList() {
val param = mutableMapOf<String, Any>(
"goodsType" to "0",
"placeId" to BaseApp.canteenId,
"pageNum" to pageNo,
"pageSize" to pageSize
)
if (!goodsName.isNullOrBlank()) {
param["goodsName"] = goodsName!!
activity.netViewModel.materSearch(
keyword = goodsName.takeUnless { it.isNullOrBlank() },
pageNum = pageNo,
pageSize = PAGE_SIZE,
onResult = { state ->
when (state) {
is UiState.Success -> loadGoodsList(state.data)
is UiState.Error -> {
activity.toast(state.msg)
finishRefresh()
if (pageNo == 1) loadEmptyView()
}
activity.netViewModel.queryGoodsList(param)
else -> {}
}
}
)
}
/**
* 将返回数据填充到列表
*/
@SuppressLint("NotifyDataSetChanged")
private fun loadGoodsList(records: MutableList<CookFoodGoodsEntity>?) {
private fun loadGoodsList(records: List<MaterSearchItem>?) {
finishRefresh()
if (records.isNullOrEmpty()) {
activity.toast("暂未搜索到食材信息")
if (pageNo == 1) {
loadEmptyView()
}
return
}
// 仅当当前不是 GridLayoutManager 时才重新赋值(空态切回网格时)
binding.recyclerView.run {
if (layoutManager !is GridLayoutManager) {
layoutManager = GridLayoutManager(activity, 2, GridLayoutManager.VERTICAL, false)
}
}
if (pageNo == 1) list.clear()
list.addAll(records)
// mater-search 不返回净材种类,rawMaterialsTypes 保持 null,点击时直接选中返回
val goodsItems = records.map { it.toGoodsItem() }
list.addAll(goodsItems)
adapter.notifyDataSetChanged()
val isLoadMoreEnable = records.size >= pageSize
val isLoadMoreEnable = records.size >= PAGE_SIZE
binding.refreshLayout.setEnableLoadMore(isLoadMoreEnable)
if (isLoadMoreEnable) pageNo++
}
/**
* MaterSearchItem → GoodsItem 映射
* id 即食材id,同时作为 goodsId 和 materId(提交 sample/save 时使用)
*/
private fun MaterSearchItem.toGoodsItem() = GoodsItem(
goodsId = id,
materId = id,
goodsName = materName,
materCode = materCode,
popularName = alias
)
private fun finishRefresh() {
if (pageNo == 1) binding.refreshLayout.finishRefresh()
else binding.refreshLayout.finishLoadMore()
}
private var emptyViewBinding: LayoutEmptyViewBinding? = null
@SuppressLint("NotifyDataSetChanged")
private fun loadEmptyView() {
try {
binding.refreshLayout.setEnableRefresh(true)
binding.refreshLayout.setEnableLoadMore(false)
list.clear()
adapter.notifyDataSetChanged()
if (emptyViewBinding == null) {
emptyViewBinding = LayoutEmptyViewBinding.inflate(
LayoutInflater.from(activity),
binding.recyclerView,
false
)
}
emptyViewBinding?.let {
it.tvContent.text = "暂无数据"
it.tvSubContent.text = "未查询到食材信息,请联系管理员添加"
// it.root.setOnClickListener { loadGoodsList(buildMockGoodsList().toMutableList()) }
it.root.layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
)
binding.recyclerView.layoutManager = LinearLayoutManager(activity)
binding.recyclerView.post {
it.root.minimumHeight = binding.recyclerView.height
adapter.stateView = it.root
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
@@ -0,0 +1,80 @@
package com.shuwei.dish.match.dialog
import android.graphics.Rect
import android.view.KeyboardShortcutGroup
import android.view.LayoutInflater
import android.view.Menu
import android.view.MotionEvent
import android.view.ViewGroup
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.shuwei.dish.match.R
import com.shuwei.dish.match.adapter.GoodsPreviewAdapter
import com.shuwei.dish.match.databinding.DialogGoodsPreviewBinding
import com.shuwei.dish.match.db.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.visible
/**
* 用料明细预览弹窗,以 BottomSheetDialog 形式展示当前 goodsList
* @param context 宿主 ContextActivity
* @param goodsList 需要展示的物品列表
*/
class GoodsPreviewDialog(
context: android.content.Context,
private val goodsList: List<CookFoodGoodsEntity>
) : BottomSheetDialog(context, R.style.BottomSheet) {
private val binding = DialogGoodsPreviewBinding.inflate(LayoutInflater.from(context))
init {
setContentView(binding.root)
setCancelable(true)
window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
binding.rvGoodsList.run {
layoutManager = LinearLayoutManager(context)
adapter = GoodsPreviewAdapter(goodsList)
}
// foodId 对同一菜品所有条目相同,统一显示在列表上方
val foodId = goodsList.firstOrNull()?.foodId.orEmpty()
binding.tvFoodId.text = "菜品ID${foodId.ifEmpty { "-" }}"
if (goodsList.isEmpty()) {
binding.rvGoodsList.gone()
binding.tvEmpty.visible()
}
}
/** 弹出时注入 Window.Callback,解决 RecyclerView 与 BottomSheet 拖拽的滑动冲突 */
override fun show() {
super.show()
val originalCallback = window?.callback ?: return
window?.callback = object : android.view.Window.Callback by originalCallback {
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
val rect = Rect()
binding.rvGoodsList.getGlobalVisibleRect(rect)
behavior.isDraggable = !rect.contains(event.rawX.toInt(), event.rawY.toInt())
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
behavior.isDraggable = true
}
}
return originalCallback.dispatchTouchEvent(event)
}
override fun onPointerCaptureChanged(hasCapture: Boolean) {
originalCallback.onPointerCaptureChanged(hasCapture)
}
override fun onProvideKeyboardShortcuts(
data: List<KeyboardShortcutGroup?>?,
menu: Menu?,
deviceId: Int
) {
originalCallback.onProvideKeyboardShortcuts(data, menu, deviceId)
}
}
}
}
@@ -1,17 +1,28 @@
package com.shuwei.dish.match.dialog
import android.annotation.SuppressLint
import android.app.Dialog
import android.content.Context
import android.graphics.Color
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater
import android.view.WindowManager
import androidx.core.graphics.drawable.toDrawable
import com.shuwei.dish.match.R
import com.shuwei.dish.match.databinding.DialogLoadingBinding
@SuppressLint("StaticFieldLeak")
object Loading {
/** 默认超时兜底时长(毫秒):业务方未传值时使用 */
const val DEFAULT_TIMEOUT_MS = 15_000L
private var dialog: LoadingDialog? = null
/** 当前持有 Loading 的 context,用于隔离 Activity 销毁时的误关 */
private var owner: Context? = null
private val timeoutHandler = Handler(Looper.getMainLooper())
private val timeoutRunnable = Runnable { dismiss() }
fun isShowing(): Boolean {
return dialog != null && dialog!!.isShowing
@@ -22,30 +33,71 @@ object Loading {
dialog!!.updateContent(content)
}
fun show(context: Context, message: String = "加载中……", onDismiss: () -> Unit = {}) {
/**
* 显示 Loading 弹窗
*
* @param context 上下文
* @param message 提示文案
* @param timeoutMs 超时兜底时长(毫秒):到期自动 dismiss;传 0 关闭兜底,由业务方自己负责
* @param onDismiss 关闭回调
*/
fun show(
context: Context,
message: String = "加载中……",
timeoutMs: Long = DEFAULT_TIMEOUT_MS,
onDismiss: () -> Unit = {}
) {
try {
// 节流:已显示中只更新文案 + 重置超时,不重新 attach 窗口
// 避免反复触发 FocusEvent 在主线程繁忙时引发 ANR
if (dialog?.isShowing == true) {
dialog?.dismiss()
dialog?.updateContent(message)
owner = context // 同一弹窗最后一次 show 决定 owner
resetTimeout(timeoutMs)
return
}
if (dialog == null) {
dialog = LoadingDialog(context = context, message = message, onDismiss = onDismiss)
}
owner = context
dialog?.show()
resetTimeout(timeoutMs)
} catch (e: Exception) {
e.printStackTrace()
}
}
fun dismiss() {
timeoutHandler.removeCallbacks(timeoutRunnable)
try {
dialog?.let {
if (it.isShowing) {
it.dismiss()
}
}
dialog = null
} catch (e: Exception) {
e.printStackTrace()
} finally {
dialog = null
owner = null
}
}
/**
* Activity 销毁专用:只关闭自己持有的 Loading,防止跨 Activity 误关
* 单例 Loading 在多 Activity 共享时,若每个 Activity 的 onDestroy 都无脑 dismiss
* 会误关后启动 Activity 弹出的 Loading;由本方法做 owner 校验后才允许关闭
*/
fun dismissIfOwnedBy(context: Context) {
if (owner === context) dismiss()
}
/**
* 重置超时兜底定时器
* @param timeoutMs <=0 表示不安排定时器(关闭兜底)
*/
private fun resetTimeout(timeoutMs: Long) {
timeoutHandler.removeCallbacks(timeoutRunnable)
if (timeoutMs > 0) {
timeoutHandler.postDelayed(timeoutRunnable, timeoutMs)
}
}
@@ -1,259 +1,259 @@
package com.shuwei.dish.match.dialog
import android.annotation.SuppressLint
import android.graphics.Typeface
import android.os.Handler
import android.os.Looper
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.util.Log
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import androidx.core.graphics.toColorInt
import androidx.core.view.isEmpty
import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
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.base.BaseApp
import com.shuwei.dish.match.databinding.DialogSeasoningSearchBinding
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.WeightUtil
import com.shuwei.dish.match.utils.ext.addOnActionSearchListener
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.roundedDecimalPlace
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(
private val activity: BaseActivity,
private val weighIndex: Int,
private val weighAddress: Int,
private val clickName: String? = null,
private val onItemSelected: (item: SeasoningEntity) -> Unit
) : BottomSheetDialog(activity, R.style.BottomSheet) {
companion object {
const val TAG = "BottomDialog2"
}
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 var goodsName: String? = null
private var currentWeight = 0.0
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 {
// 弹窗关闭时移除重量监听,避免内存泄漏
WeightUtil.removeWeightListener(TAG)
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.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) {
super.onScrolled(rv, dx, dy)
// 解决 RecyclerView 与 SmartRefreshLayout 滑动冲突
val topRowVerticalPosition = if (rv.isEmpty()) 0 else rv.getChildAt(0).top
binding.refreshLayout.setNestedScrollingEnabled(topRowVerticalPosition >= 0)
}
})
}
binding.refreshLayout.run {
setEnableRefresh(false)
setEnableLoadMore(false)
setOnRefreshListener { pageNo = 1; getGoodsList() }
setOnLoadMoreListener { getGoodsList() }
}
binding.ivSearch.setOnClickListener { searchGoods(it) }
binding.root.setOnClickListener { KeyboardUtil.hideKeyboard(it) }
// 去皮按钮
binding.tvClear.setOnClickListener {
Log.d(TAG, "tareTwo: weighAddress=$weighAddress")
WeightUtil.tareTwo(weighAddress)
}
// 监听对应秤的重量变化
WeightUtil.addWeightListener(
weightKey = TAG,
getWeight = { address, _, weight ->
if (weighAddress == address) {
currentWeight = weight.toDouble()
binding.tvWeight.text = getTextSpan(weight)
}
}
)
binding.tvWeight.text = getTextSpan(0.0)
initObserver()
// 若有默认调料名称,自动填充并触发搜索
if (!clickName.isNullOrBlank()) {
binding.etSheetInput.setText(clickName.trim())
pageNo = 1
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) {
if (goodsName.isNullOrBlank()) {
activity.toast("${binding.etSheetInput.hint}")
return
}
pageNo = 1
getGoodsList()
KeyboardUtil.hideKeyboard(v)
}
/**
* 请求调料列表
*/
private fun getGoodsList() {
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.netViewModel.querySeasoningList(param)
}
/**
* 将返回数据填充到列表
*/
@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()
}
/**
* 生成重量显示的富文本:数值大字加粗,单位小字灰色
*/
fun getTextSpan(weight: Double): SpannableStringBuilder {
val topWeight = if (weight >= 1000) "${(weight / 1000).roundedDecimalPlace(3)}" else "$weight"
val bottomUnit = if (weight >= 1000) "千克" else ""
return buildSpannableString {
appendText(
topWeight,
ForegroundColorSpan("#000000".toColorInt()),
StyleSpan(Typeface.BOLD),
AbsoluteSizeSpan(72, true),
LineHeightSpan { _, _, _, _, _, fm -> fm.descent += 10.dp }
)
append("\n")
appendText(
bottomUnit,
ForegroundColorSpan("#999999".toColorInt()),
AbsoluteSizeSpan(30, true)
)
}
}
}
//package com.shuwei.dish.match.dialog
//
//import android.annotation.SuppressLint
//import android.graphics.Typeface
//import android.os.Handler
//import android.os.Looper
//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.util.Log
//import android.view.Gravity
//import android.view.LayoutInflater
//import android.view.View
//import android.view.ViewGroup
//import android.view.WindowManager
//import androidx.core.graphics.toColorInt
//import androidx.core.view.isEmpty
//import androidx.core.widget.addTextChangedListener
//import androidx.lifecycle.Lifecycle
//import androidx.lifecycle.lifecycleScope
//import androidx.lifecycle.repeatOnLifecycle
//import androidx.recyclerview.widget.GridLayoutManager
//import androidx.recyclerview.widget.RecyclerView
//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.base.BaseApp
//import com.shuwei.dish.match.databinding.DialogSeasoningSearchBinding
//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.WeightUtil
//import com.shuwei.dish.match.utils.ext.addOnActionSearchListener
//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.roundedDecimalPlace
//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(
// private val activity: BaseActivity,
// private val weighIndex: Int,
// private val weighAddress: Int,
// private val clickName: String? = null,
// private val onItemSelected: (item: SeasoningEntity) -> Unit
//) : BottomSheetDialog(activity, R.style.BottomSheet) {
//
// companion object {
// const val TAG = "BottomDialog2"
// }
//
// 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 var goodsName: String? = null
// private var currentWeight = 0.0
// 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 {
// // 弹窗关闭时移除重量监听,避免内存泄漏
// WeightUtil.removeWeightListener(TAG)
// 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.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) {
// super.onScrolled(rv, dx, dy)
// // 解决 RecyclerView 与 SmartRefreshLayout 滑动冲突
// val topRowVerticalPosition = if (rv.isEmpty()) 0 else rv.getChildAt(0).top
// binding.refreshLayout.setNestedScrollingEnabled(topRowVerticalPosition >= 0)
// }
// })
// }
//
// binding.refreshLayout.run {
// setEnableRefresh(false)
// setEnableLoadMore(false)
// setOnRefreshListener { pageNo = 1; getGoodsList() }
// setOnLoadMoreListener { getGoodsList() }
// }
//
// binding.ivSearch.setOnClickListener { searchGoods(it) }
// binding.root.setOnClickListener { KeyboardUtil.hideKeyboard(it) }
//
// // 去皮按钮
// binding.tvClear.setOnClickListener {
// Log.d(TAG, "tareTwo: weighAddress=$weighAddress")
// WeightUtil.tareTwo(weighAddress)
// }
//
// // 监听对应秤的重量变化
// WeightUtil.addWeightListener(
// weightKey = TAG,
// getWeight = { address, _, weight ->
// if (weighAddress == address) {
// currentWeight = weight.toDouble()
// binding.tvWeight.text = getTextSpan(weight)
// }
// }
// )
// binding.tvWeight.text = getTextSpan(0.0)
//
// initObserver()
//
// // 若有默认调料名称,自动填充并触发搜索
// if (!clickName.isNullOrBlank()) {
// binding.etSheetInput.setText(clickName.trim())
// pageNo = 1
// 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) {
// if (goodsName.isNullOrBlank()) {
// activity.toast("请${binding.etSheetInput.hint}")
// return
// }
// pageNo = 1
// getGoodsList()
// KeyboardUtil.hideKeyboard(v)
// }
//
// /**
// * 请求调料列表
// */
// private fun getGoodsList() {
// 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.netViewModel.querySeasoningList(param)
// }
//
// /**
// * 将返回数据填充到列表
// */
// @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()
// }
//
// /**
// * 生成重量显示的富文本:数值大字加粗,单位小字灰色
// */
// fun getTextSpan(weight: Double): SpannableStringBuilder {
// val topWeight = if (weight >= 1000) "${(weight / 1000).roundedDecimalPlace(3)}" else "$weight"
// val bottomUnit = if (weight >= 1000) "千克" else "克"
// return buildSpannableString {
// appendText(
// topWeight,
// ForegroundColorSpan("#000000".toColorInt()),
// StyleSpan(Typeface.BOLD),
// AbsoluteSizeSpan(72, true),
// LineHeightSpan { _, _, _, _, _, fm -> fm.descent += 10.dp }
// )
// append("\n")
// appendText(
// bottomUnit,
// ForegroundColorSpan("#999999".toColorInt()),
// AbsoluteSizeSpan(30, true)
// )
// }
// }
//}
@@ -5,47 +5,55 @@ import android.graphics.Rect
import android.os.Handler
import android.os.Looper
import android.view.Gravity
import android.view.KeyboardShortcutGroup
import android.view.LayoutInflater
import android.view.Menu
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.Window
import android.view.WindowManager
import android.widget.FrameLayout
import androidx.core.widget.addTextChangedListener
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.shuwei.dish.match.R
import com.shuwei.dish.match.adapter.SeasoningSearchAdapter
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.databinding.DialogSeasoningSelectBinding
import com.shuwei.dish.match.entity.SeasoningEntity
import com.shuwei.dish.match.databinding.LayoutEmptyViewBinding
import com.shuwei.dish.match.model.GoodsItem
import com.shuwei.dish.match.model.SeasoningSearchItem
import com.shuwei.dish.match.net.UiState
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
private val onItemSelected: (item: GoodsItem) -> Unit
) : BottomSheetDialog(activity, R.style.BottomSheet) {
companion object {
const val TAG = "SeasoningSelectDialog"
const val PAGE_SIZE = 50
}
private val binding = DialogSeasoningSelectBinding.inflate(LayoutInflater.from(activity))
private val list = mutableListOf<SeasoningEntity>()
private val list = mutableListOf<GoodsItem>()
private val adapter = SeasoningSearchAdapter(list).apply {
setOnItemClickListener { _, _, position ->
list[position].isClicked = true
isStateViewEnable = true
setOnItemClickListener { _, v, position ->
val item = list.getOrNull(position) ?: return@setOnItemClickListener
item.isClicked = true
notifyItemChanged(position)
Handler(Looper.getMainLooper()).postDelayed({
onItemSelected(list[position])
v.postDelayed({
onItemSelected(item)
dismiss()
}, 300)
}
@@ -53,7 +61,6 @@ class SeasoningSelectDialog(
private var goodsName: String? = null
private var pageNo = 1
private val pageSize = 50
init {
setContentView(binding.root)
@@ -91,7 +98,7 @@ class SeasoningSelectDialog(
}
binding.refreshLayout.run {
setEnableRefresh(false)
setEnableRefresh(true)
setEnableLoadMore(false)
setOnRefreshListener { pageNo = 1; getGoodsList() }
setOnLoadMoreListener { getGoodsList() }
@@ -126,6 +133,18 @@ class SeasoningSelectDialog(
}
return originalCallback.dispatchTouchEvent(event)
}
override fun onPointerCaptureChanged(hasCapture: Boolean) {
originalCallback.onPointerCaptureChanged(hasCapture)
}
override fun onProvideKeyboardShortcuts(
data: List<KeyboardShortcutGroup?>?,
menu: Menu?,
deviceId: Int
) {
originalCallback.onProvideKeyboardShortcuts(data, menu, deviceId)
}
}
}
@@ -143,58 +162,117 @@ class SeasoningSelectDialog(
}
/**
* 请求调料列表(临时使用本地模拟数据,正式联调时替换为真实接口
* 请求调料列表(新配比秤接口,回调版本
*/
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))
activity.netViewModel.querySeasoningSearch(
keyword = goodsName.takeUnless { it.isNullOrBlank() },
pageNum = pageNo,
pageSize = PAGE_SIZE,
onResult = { state ->
when (state) {
is UiState.Success -> loadGoodsList(state.data)
is UiState.Error -> {
activity.toast(state.msg)
finishRefresh()
if (pageNo == 1) loadEmptyView()
}
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()
// })
else -> {}
}
}
)
}
/**
* 将返回数据填充到列表
* 加载本地模拟数据,仅用于联调时点击空布局快速验证 UI
*/
private fun loadMockData() {
val nameList = listOf(
"", "白砂糖", "冰糖", "鸡精", "味精", "陈醋", "老抽", "生抽", "番茄酱", "胡椒粉", "孜然", "十三香",
"料酒", "白醋", "蚝油", "辣椒面", "辣椒酱", "豆瓣酱", "淀粉", "", "", "", "香菜", ""
)
val mockData = nameList.mapIndexed { index, name ->
val padString = "${index + 1}".padStart(2, '0')
SeasoningSearchItem(id = "1000$padString", materName = name)
}
pageNo = 1
loadGoodsList(mockData)
}
/**
* 将新接口返回的 SeasoningSearchItem 映射为 GoodsItem 并填充到列表
* 新接口的 id 即食材id,同时用作 goodsId 和 materId,无需再交换
*/
@SuppressLint("NotifyDataSetChanged")
private fun loadGoodsList(records: MutableList<SeasoningEntity>?) {
private fun loadGoodsList(records: List<SeasoningSearchItem>?) {
finishRefresh()
if (records.isNullOrEmpty()) {
activity.toast("暂未搜索到调料信息")
if (pageNo == 1) {
loadEmptyView()
}
return
}
binding.recyclerView.layoutManager = GridLayoutManager(activity, 3, GridLayoutManager.VERTICAL, false)
if (pageNo == 1) list.clear()
list.addAll(records)
val goodsItems = records.map { it.toGoodsItem() }
list.addAll(goodsItems)
adapter.notifyDataSetChanged()
val isLoadMoreEnable = records.size >= pageSize
val isLoadMoreEnable = records.size >= PAGE_SIZE
binding.refreshLayout.setEnableLoadMore(isLoadMoreEnable)
if (isLoadMoreEnable) pageNo++
}
/**
* SeasoningSearchItem → GoodsItem 映射
* id 既是食材id也是提交时的 goodsId,不需要 oldGoodsId 交换
*/
private fun SeasoningSearchItem.toGoodsItem() = GoodsItem(
goodsId = id,
materId = id,
goodsName = materName,
materCode = materCode,
popularName = alias
)
private fun finishRefresh() {
if (pageNo == 1) binding.refreshLayout.finishRefresh()
else binding.refreshLayout.finishLoadMore()
}
private var emptyViewBinding: LayoutEmptyViewBinding? = null
@SuppressLint("NotifyDataSetChanged")
private fun loadEmptyView() {
try {
binding.refreshLayout.setEnableRefresh(true)
binding.refreshLayout.setEnableLoadMore(false)
list.clear()
adapter.notifyDataSetChanged()
if (emptyViewBinding == null) {
emptyViewBinding = LayoutEmptyViewBinding.inflate(
LayoutInflater.from(activity),
binding.recyclerView,
false
)
}
emptyViewBinding?.let {
it.tvContent.text = "暂无数据"
it.tvSubContent.text = "未查询到调料信息,请联系管理员添加"
// 点击空布局加载本地模拟数据,方便联调时验证 UI
//it.root.setOnClickListener { loadMockData() }
it.root.layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
)
binding.recyclerView.layoutManager = LinearLayoutManager(activity)
binding.recyclerView.post {
it.root.minimumHeight = binding.recyclerView.height
adapter.stateView = it.root
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
@@ -1,62 +0,0 @@
package com.shuwei.dish.match.entity
data class HomeModeBean(
var modeName: String = "",
var modeDesc: String = "",
var modeIconId: Int = 0,
var isLocked: Boolean = false,
var onClick: (() -> Unit)? = null
)
data class FoodRecordBean(
var records: MutableList<FoodRecord>? = null,
var total: Int,
var size: Int,
var current: Int,
var page: Int
)
data class GoodsRecordBean(
var records: MutableList<CookFoodGoodsEntity>? = null,
var total: Int,
var size: Int,
var current: Int,
var page: Int
)
data class SeasoningRecordBean(
var records: MutableList<SeasoningEntity>? = null,
var total: Int,
var size: Int,
var current: Int,
var page: Int
)
data class ResetReasoningRecord(
val list: MutableList<SeasoningEntity>
)
//data class DishDetailEntity(
// var foodId: String? = null,
// var foodWeight: Double? = null,
// var foodRealWeight: Double? = null,
// var voList: List<DishPartEntity>? = null
//)
//data class DishPartEntity(
// var foodId: String? = null,
// var foodName: String? = null,
// var foodWeight: Int? = null,
// var useWeight: Int? = null,
// var useRealWeight: Int? = null,
// var goodsId: Int? = null,
// var goodsName: String? = null,
// var materialType: Int? = null,
// var isSetFinished: Boolean = false,
// var isItemClicked: Boolean = false,
// var isSamplingPage: Boolean = false
//)
@@ -1,25 +0,0 @@
package com.shuwei.dish.match.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.shuwei.dish.match.db.BaseEntity
//@Entity(tableName = "dm_dish")
//data class DishEntity (
// /**
// * 菜品id
// */
// @PrimaryKey var id: Long,
// /**
// * 菜品名称
// */
// var name: String? = "",
// /**
// * 菜品重量
// */
// var weight: Int? = 0,
//
// var sort: Int = 0,
// @ColumnInfo(name = "create_time") val createTime: Long = System.currentTimeMillis()
//): BaseEntity
@@ -1,38 +0,0 @@
//package com.shuwei.dish.match.entity
//
//import androidx.room.Ignore
//import androidx.room.PrimaryKey
//import com.shuwei.dish.match.db.BaseEntity
//
//data class GoodsInfoEntity(
// @PrimaryKey(autoGenerate = true) var id: Long = 0,
// var goodsId: Int,
// var goodsName: String? = "",
// var popularName: String? = "",
// var canteenId: String? = "",
// var goodsOrRelationCode: String? = "",
// var relateionType: Int? = -1,
// var relateionType_dictText: String? = "",
//
// var sort: Int = 0,
//
// var swMaterBaseMaterial: String? = "",
// var preMaterClass: String? = "",
// var preUseWeight: Int? = 0,
// var preWeight: Int? = 0,
// var oil: String? = "",
// var sugar: String? = "",
// /**
// * isDel表示数据是否已删除,1-已删除,为无效数据,0-正常使用,有效数据
// */
// var isDel: Int = 0,
// val createTime: Long = System.currentTimeMillis()
//) : BaseEntity {
// @Ignore
// var pageType: Int = 0
//
// @Ignore
// var isClicked: Boolean = false
//
// constructor() : this(id = -1, goodsId = -1) // 必需的空构造
//}
@@ -1,4 +1,4 @@
package com.shuwei.dish.match.entity
package com.shuwei.dish.match.model
/**
* 已采集食材列表项数据模型
@@ -0,0 +1,43 @@
package com.shuwei.dish.match.model
import com.shuwei.dish.match.db.entity.CookFoodEntity
import com.shuwei.dish.match.db.entity.CookFoodGoodsEntity
import java.io.Serializable
/**
* 菜品详情接口传输对象(DTO),不依赖 Room
* 对应接口:getConstituteByFoodId / saveConstitute
*/
data class CookFoodDTO(
var foodId: String = "",
var foodName: String? = null,
var canteenId: String? = null,
var foodWeight: Double = 0.0,
// 0-制作模式,1-采样模式,2-品控模式
var cookMode: Int = 0,
var dinnerType: String? = "0",
// 接口返回的食材构成列表
var matchingConstituteInfoList: MutableList<CookFoodGoodsDTO>? = null,
var foodConstituteList: MutableList<CookFoodGoodsDTO>? = null
) : Serializable {
/**
* 转换为 Room 实体,用于写入本地数据库
* id/isDel/createTime 由 Room 自行管理,不从 DTO 携带
*/
fun toEntity(): CookFoodEntity = CookFoodEntity(
foodId = foodId,
foodName = foodName,
canteenId = canteenId,
foodWeight = foodWeight,
cookMode = cookMode,
dinnerType = dinnerType
).also { entity ->
entity.matchingConstituteInfoList = matchingConstituteInfoList
?.map { it.toEntity() }
?.toMutableList()
entity.foodConstituteList = foodConstituteList
?.map { it.toEntity() }
?.toMutableList()
}
}
@@ -0,0 +1,119 @@
package com.shuwei.dish.match.model
import com.shuwei.dish.match.db.entity.CookFoodEntity
import com.shuwei.dish.match.db.entity.CookFoodGoodsEntity
import java.io.Serializable
/**
* 菜品构成(主辅料/调料)接口传输对象(DTO),不依赖 Room
* 对应 CookFoodDTO 中 foodConstituteList、matchingConstituteInfoList 的元素
*/
data class CookFoodGoodsDTO(
var goodsId: String = "",
var goodsName: String? = null,
var foodId: String? = "",
var goodsOrRelationCode: String? = "",
// 调料数据顺序
var sort: Int = 0,
// 食材原材料:1,预制品:2
var relateionType: Int = 0,
// 物品类型:1主料 2辅料 3调料
var materialType: Int = 0,
// 物品 是否全部可食:true-全部可食,false-部分可食
var allEdible: Boolean = true,
// 物品 用料重量
var useWeight: Double? = 0.0,
var popularName: String? = "",
var canteenId: String? = "",
var relateionType_dictText: String? = "",
var zjmCode: String? = "",
var materId: String? = "",
// 物料编码
var goodsCode: String? = "",
// 净材种类
var rawMaterialsType: String? = null
) : Serializable {
/**
* 转换为 GoodsItem,用于 UI 层
*/
fun toGoodsItem(): GoodsItem = GoodsItem(
goodsId = goodsId,
goodsName = goodsName,
popularName = popularName,
zjmCode = zjmCode,
materId = materId,
materCode = goodsCode,
materialType = materialType,
useWeight = useWeight,
relateionType = relateionType,
allEdible = allEdible,
goodsOrRelationCode = goodsOrRelationCode,
rawMaterialsType = rawMaterialsType
)
/**
* 转换为 Room 实体,用于写入本地数据库
* id/pid/isDel/createTime 由 Room 自行管理,不从 DTO 携带
*/
fun toEntity(): CookFoodGoodsEntity = CookFoodGoodsEntity(
goodsId = goodsId,
goodsName = goodsName,
foodId = foodId,
goodsOrRelationCode = goodsOrRelationCode,
sort = sort,
relateionType = relateionType,
materialType = materialType,
allEdible = allEdible,
useWeight = useWeight,
popularName = popularName,
canteenId = canteenId,
relateionType_dictText = relateionType_dictText,
zjmCode = zjmCode,
materId = materId,
goodsCode = goodsCode,
rawMaterialsType = rawMaterialsType
)
}
/**
* Room 实体 → DTO 反向映射,用于将本地数据转为接口提交格式
* id/pid/isDel/createTime 为本地数据库字段,不参与提交
*/
fun CookFoodGoodsEntity.toDTO(): CookFoodGoodsDTO = CookFoodGoodsDTO(
goodsId = goodsId,
goodsName = goodsName,
foodId = foodId,
goodsOrRelationCode = goodsOrRelationCode,
sort = sort,
relateionType = relateionType,
materialType = materialType,
allEdible = allEdible,
useWeight = useWeight,
popularName = popularName,
canteenId = canteenId,
relateionType_dictText = relateionType_dictText,
zjmCode = zjmCode,
materId = materId,
goodsCode = goodsCode,
rawMaterialsType = rawMaterialsType
)
/**
* Room 实体 → DTO 反向映射,用于将本地数据转为接口提交格式
* id/isDel/createTime 为本地数据库字段,不参与提交
*/
fun CookFoodEntity.toDTO(): CookFoodDTO = CookFoodDTO(
foodId = foodId,
foodName = foodName,
canteenId = canteenId,
foodWeight = foodWeight,
cookMode = cookMode,
dinnerType = dinnerType,
matchingConstituteInfoList = matchingConstituteInfoList
?.map { it.toDTO() }
?.toMutableList(),
foodConstituteList = foodConstituteList
?.map { it.toDTO() }
?.toMutableList()
)
@@ -0,0 +1,146 @@
package com.shuwei.dish.match.model
/**
* 当日烹饪任务列表(按餐次分组)请求参数
* 对应接口: POST /neglect/ratio-scale/cook-orders/page
*/
data class CookOrderPageRequest(
/** 计划日期 yyyy-MM-dd,默认当天 */
val date: String? = null,
/** 餐次筛选: 1=早餐 / 2=午餐 / 3=晚餐 / 4=加餐,不传返回全部 */
val mealType: Int? = null,
/** 菜品名称模糊搜索 */
val keyword: String? = null,
/** 保留字段,当前不使用 */
val pageNum: Int? = null,
/** 保留字段,当前不使用 */
val pageSize: Int? = null
)
/**
* 餐次分组,包含该餐次下的烹制单列表
*/
data class CookOrderMealGroup(
/** 餐次: 1=早餐 / 2=午餐 / 3=晚餐 / 4=加餐 */
val mealType: Int = 0,
/** 餐次中文名称 */
val mealTypeName: String = "",
/** 该餐次下的烹制单列表 */
val cookOrders: List<CookOrderItem> = emptyList()
)
/**
* 单个烹制单,对应接口响应中 cookOrders 数组的条目
* 注意: Long 类型字段服务端序列化为 String
*/
data class CookOrderItem(
/** 烹制单idLong → String */
val cookOrderId: String = "",
/** 烹制单号 */
val cookNo: String = "",
/** 菜品名称 */
val dishName: String = "",
/** 菜品id(关联 nut_food */
val foodId: String = "",
/** 餐次: 1=早餐 / 2=午餐 / 3=晚餐 / 4=加餐 */
val mealType: Int = 0,
/** 餐次中文名称 */
val mealTypeName: String = "",
/** 烹制状态: 0=待烹制 / 1=烹制中 / 2=已完成 / 3=异常 */
val cookStatus: Int = 0,
/** 烹制状态中文 */
val cookStatusName: String = "",
/** 生重(kg),烹制完成后才有值 */
val rawWeight: Double? = null,
/** 熟重(kg),烹制完成后才有值 */
val cookedWeight: Double? = null,
/** 需求份数 */
val needPortions: Int = 0,
/** 烹制份数,烹制完成后才有值 */
val cookedPortions: Int? = null
)
/**
* 菜品食材构成(主材+辅材),对应接口响应中单个食材
* 对应接口: GET /neglect/ratio-scale/cook-orders/{cookOrderId}/composition
* 注意: 此接口仅返回主材(ingredientClass=1)和辅材(ingredientClass=2),不含调料
*/
data class CookOrderCompositionItem(
/** 食材id */
val materId: String = "",
/** 食材名称 */
val ingredientName: String = "",
/** 食材分类: 1=主材 / 2=辅材 */
val ingredientClass: Int = 0,
/** 食材分类中文名称 */
val ingredientClassName: String = "",
/** 标准用量(g) */
val useWeight: Double = 0.0,
/** 净菜类型id */
val vegTypeId: String = ""
)
// ==================== 调料模糊搜索 ====================
/**
* 调料模糊搜索请求参数
* 对应接口: POST /neglect/ratio-scale/seasoning-search
* 数据源: nut_mater_base 一级分类为 18(糖蜜饯)、19(油脂)、20(调味品)
*/
data class SeasoningSearchRequest(
/** 模糊匹配食材名称和别名,为空时返回全部 */
val keyword: String? = null,
/** 页码 */
val pageNum: Int = 1,
/** 每页条数 */
val pageSize: Int = 20
)
/**
* 调料模糊搜索结果条目
* 注意: Long 类型字段服务端序列化为 String
*/
data class SeasoningSearchItem(
/** 食材idLong → String */
val id: String = "",
/** 食材名称 */
val materName: String = "",
/** 食材编码 */
val materCode: String = "",
/** 别名 */
val alias: String = "",
/** 归属食堂idLong → String */
val canteenId: String = ""
)
// ==================== 烹饪完成 ====================
/**
* 烹饪完成 — 单个食材用量条目
* 对应接口: POST /neglect/ratio-scale/cook-orders/complete
*/
data class CookCompleteItem(
/** 食材id */
val materId: String = "",
/** 实际用量(g) */
val actualQty: Double = 0.0,
/** 调料首次使用时间 yyyy-MM-dd HH:mm:ss,仅调料需要,主辅材留空 */
val putTime: String? = null
)
/**
* 烹饪完成请求,一次性提交主辅材用量+熟重+调料
* 对应接口: POST /neglect/ratio-scale/cook-orders/complete
*/
data class CookCompleteRequest(
/** 烹制单id */
val cookOrderId: String = "",
/** 烹饪开始时间 yyyy-MM-dd HH:mm:ss,进入提交页面时刻 */
val cookStart: String? = null,
/** 主辅材实际用量列表,至少1条 */
val items: List<CookCompleteItem> = emptyList(),
/** 熟重(kg) */
val cookedWeight: Double = 0.0,
/** 调料用量列表,选填 */
val seasonings: List<CookCompleteItem>? = null
)
@@ -0,0 +1,52 @@
package com.shuwei.dish.match.model
import android.graphics.Bitmap
import android.net.Uri
import java.io.File
data class HomeModeBean(
var modeName: String = "",
var modeDesc: String = "",
var modeIconId: Int = 0,
var isLocked: Boolean = false,
var onClick: (() -> Unit)? = null
)
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
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as FoodCollectionBean
if (isShowCamera != other.isShowCamera) return false
if (isFinish != other.isFinish) return false
if (uploadSuccess != other.uploadSuccess) return false
if (imageUri != other.imageUri) return false
if (bitmap != other.bitmap) return false
if (imageFile != other.imageFile) return false
if (!imageVector.contentEquals(other.imageVector)) return false
return true
}
override fun hashCode(): Int {
var result = isShowCamera.hashCode()
result = 31 * result + isFinish.hashCode()
result = 31 * result + uploadSuccess.hashCode()
result = 31 * result + (imageUri?.hashCode() ?: 0)
result = 31 * result + (bitmap?.hashCode() ?: 0)
result = 31 * result + (imageFile?.hashCode() ?: 0)
result = 31 * result + (imageVector?.contentHashCode() ?: 0)
return result
}
}
@@ -1,4 +1,4 @@
package com.shuwei.dish.match.entity
package com.shuwei.dish.match.model
import java.io.Serializable
@@ -15,6 +15,12 @@ data class FoodRecord(
var dinnerType: String = "0",
//true-原始数据,false-非原始数据
var isOriginalData: Boolean = true,
var sort: Int = 0
var sort: Int = 0,
// ===== 已提交采样列表(sample-list)扩展字段,制作模式不涉及 =====
/** 实验编号(innovNo */
var innovNo: String? = null,
/** 实验状态: 1=实验中 / 2=已评测 / 9=已转化 */
var status: Int? = null,
/** 实验状态中文 */
var statusName: String? = null
) : Serializable
@@ -0,0 +1,85 @@
package com.shuwei.dish.match.model
import com.shuwei.dish.match.db.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.db.entity.SeasoningEntity
import java.io.Serializable
/**
* 物品信息接口返回的数据类(DTO),不依赖 Room,同时承载 UI 状态字段
* 对应接口:queryGoodsInfoList,食材(goodsType=0)和调料(goodsType=1)共用此结构
*/
data class GoodsItem(
// 接口返回字段
var goodsId: String = "",
val goodsName: String? = null,
val popularName: String? = null,
val zjmCode: String? = null,
var materId: String? = null,
val materCode: String? = null,
// 从 CookFoodGoodsEntity 迁移的数据字段(DishPartAdapter 显示及转换用)
var materialType: Int = 0,
var useWeight: Double? = 0.0,
var relateionType: Int = 0,
var allEdible: Boolean = true,
var goodsOrRelationCode: String? = "",
// 从 CookFoodGoodsEntity @Ignore 迁移的 UI 状态字段
var isSetFinished: Boolean = false,
// var isItemClicked: Boolean = false,
var isSamplingPage: Boolean = false,
var isNewDishType: Boolean = false,
var isClicked: Boolean = false,
// true-原始数据,false-非原始数据(识图新增)
var isOriginalData: Boolean = true,
// true-用户手动指定了主辅材,updateMaterialTypes 自动分配时跳过
var isMaterialTypeManual: Boolean = false,
// 识图匹配分数,-1 表示不显示
var foodScore: Int = -1,
// 净材种类(当前选中值)
var rawMaterialsType: String? = null,
// 净材种类候选列表(临时字段,逗号分隔,不持久化)
var rawMaterialsTypes: String? = null,
var oldGoodsId: String? = null
) : Serializable {
/**
* 物料编码,与查询接口返回的 materCode 同值,统一对外暴露为 goodsCode 供下游使用
*/
val goodsCode: String? get() = materCode
/**
* 转换为 CookFoodGoodsEntity,用于写入数据库或传给 SubmitFoodActivity
*/
fun toCookFoodGoodsEntity(): CookFoodGoodsEntity = CookFoodGoodsEntity(
goodsId = goodsId,
goodsName = goodsName,
popularName = popularName,
zjmCode = zjmCode,
materId = materId,
goodsCode = goodsCode,
materialType = materialType,
useWeight = useWeight,
relateionType = relateionType,
allEdible = allEdible,
goodsOrRelationCode = goodsOrRelationCode,
rawMaterialsType = rawMaterialsType
)
/**
* 转换为 SeasoningEntity,用于保存调料完整信息到 dm_seasoning
*/
fun toSeasoningEntity(): SeasoningEntity = SeasoningEntity(
goodsId = goodsId,
goodsName = goodsName,
popularName = popularName,
zjmCode = zjmCode,
materId = materId,
goodsCode = goodsCode,
materialType = materialType,
relateionType = relateionType,
allEdible = allEdible,
goodsOrRelationCode = goodsOrRelationCode,
rawMaterialsType = rawMaterialsType
)
}
@@ -0,0 +1,12 @@
package com.shuwei.dish.match.model
/**
* 物品名称批量查询入参 DTO
* 用于 queryGoodsInfoList 接口 goodsNames 字段的元素类型
* @param goodsName 物品名称
* @param rawMaterialsType 净材种类
*/
data class GoodsNameQueryDTO(
val goodsName: String,
val rawMaterialsType: String
)
@@ -0,0 +1,251 @@
package com.shuwei.dish.match.model
/**
* 采样与制作模式相关模型,对应配比秤接口(Controller: NutRatioScaleController
* 路径前缀: /neglect/ratio-scale
* 序列化规则: Long 字段服务端返回字符串;请求体字段按后端约定(id 类字段用 String 与现有代码一致)
*/
/** 采样实验状态(味养创新 nut_innovation.status */
object SampleStatus {
/** 实验中 */
const val EXPERIMENTING = 1
/** 已评测 */
const val EVALUATED = 2
/** 已转化 */
const val CONVERTED = 9
}
// ==================== 一、食材模糊搜索 ====================
/**
* 食材模糊搜索请求参数
* 对应接口: POST /neglect/ratio-scale/mater-search
* 数据源: nut_mater_base,限定设备对应食堂
*/
data class MaterSearchRequest(
/** 模糊匹配食材名称和别名,为空时返回全部 */
val keyword: String? = null,
/** 页码 */
val pageNum: Int = 1,
/** 每页条数 */
val pageSize: Int = 20
)
/**
* 食材模糊搜索结果条目
* 注意: id / canteenId 为 Long 类型,服务端序列化为 String
*/
data class MaterSearchItem(
/** 食材idLong → String,提交构成 items.materId 使用) */
val id: String = "",
/** 食材名称 */
val materName: String = "",
/** 食材编码(一级+二级+自编码,全局唯一) */
val materCode: String = "",
/** 别名,可能为 null */
val alias: String? = null,
/** 食材一级分类 */
val materFirstClass: String? = null,
/** 食材二级分类 */
val materSecondClass: String? = null,
/** 食材图片url */
val materUrl: String? = null,
/** 归属食堂idLong → String */
val canteenId: String = ""
)
// ==================== 二、搜菜品 ====================
/**
* 搜菜品请求参数(制作模式选已有菜品)
* 对应接口: POST /neglect/ratio-scale/food-search
* 数据源: nut_foodstatus=1 上架)
*/
data class FoodSearchRequest(
/** 菜品名称或编号模糊搜索 */
val keyword: String? = null,
/** 页码 */
val pageNum: Int = 1,
/** 每页条数 */
val pageSize: Int = 20
)
/**
* 搜菜品结果条目
* 注意: foodId 为 Long 类型,服务端序列化为 String
*/
data class FoodSearchItem(
/** 菜品idLong → String */
val foodId: String = "",
/** 菜品编号 */
val foodCode: String = "",
/** 菜品名称 */
val foodName: String = ""
)
// ==================== 三、查菜品构成 ====================
/**
* 查菜品构成响应 — 单个食材
* 对应接口: GET /neglect/ratio-scale/food/{foodId}/composition
*/
data class FoodCompositionIngredient(
/** 食材idLong → String */
val materId: String = "",
/** 食材名称 */
val materName: String = "",
/** 用量(g) */
val useWeight: Double = 0.0,
/** 1=主材 / 2=辅材 / 3=调料 */
val isMain: Int = 0,
/** 分类中文名 */
val isMainName: String = ""
)
/**
* 查菜品构成响应(主辅料+调料 + 熟重)
* 对应接口: GET /neglect/ratio-scale/food/{foodId}/composition
*/
data class FoodCompositionResponse(
/** 菜品idLong → String */
val foodId: String = "",
/** 菜品名称 */
val foodName: String = "",
/** 熟重(g),从 nut_food_cook.output_weight 反查 */
val foodWeight: Double = 0.0,
/** 食材构成列表 */
val ingredients: List<FoodCompositionIngredient> = emptyList()
)
// ==================== 四、提交采样 ====================
/**
* 提交采样 — 单个食材构成条目
* 对应接口: POST /neglect/ratio-scale/constitute/save
*/
data class ConstituteSaveItem(
/** 食材idnut_mater_base.id */
val materId: String = "",
/** 食材称量用量(g),必须 > 0 */
val useWeight: Double = 0.0,
/** 食材分类: 1=主材 / 2=辅材 / 3=调料 */
val isMain: Int = 0
)
/**
* 提交采样请求(落库味养创新实验,status=1 实验中)
* 对应接口: POST /neglect/ratio-scale/constitute/save
* 制作模式已废弃本接口,统一为提交采样,不再传 cookMode/foodId
*/
data class ConstituteSaveRequest(
/** 菜品名称 */
val foodName: String? = null,
/** 熟重(g),必须 > 0 */
val foodWeight: Double = 0.0,
/** 食材构成列表,至少 1 条 */
val items: List<ConstituteSaveItem> = emptyList()
)
// ==================== 五、已提交采样列表 ====================
/**
* 已提交采样列表查询请求参数
* 对应接口: POST /neglect/ratio-scale/sample-list
* 数据源: nut_innovationsource=2 配比秤采样),按提交时间倒序,不分日期
*/
data class SampleHistoryRequest(
/** 页码 */
val pageNum: Int = 1,
/** 每页条数 */
val pageSize: Int = 10
)
/**
* 已提交采样列表结果条目
* 注意: innovId 为 Long 类型,服务端序列化为 String
*/
data class SampleHistoryItem(
/** 味养创新实验idLong → String */
val innovId: String = "",
/** 实验编号 */
val innovNo: String = "",
/** 餐品名称 */
val foodName: String = "",
/** 熟重(g) */
val foodWeight: Double = 0.0,
/** 状态: 1=实验中 / 2=已评测 / 9=已转化 */
val status: Int = 0,
/** 状态中文 */
val statusName: String = "",
/** 提交时间 */
val createTime: String = ""
)
// ==================== 六、制作历史 ====================
/**
* 制作历史查询请求参数
* 对应接口: POST /neglect/ratio-scale/make/history
* 数据源: nut_food_matching_use,按设备过滤
*/
data class MakeHistoryRequest(
/** 页码 */
val pageNum: Int = 1,
/** 每页条数 */
val pageSize: Int = 10,
/** 制作日期 yyyy-MM-dd,不传查全部 */
val day: String? = null
)
/**
* 制作历史结果条目
* 注意: id / foodId 为 Long 类型,服务端序列化为 String
*/
data class MakeHistoryItem(
/** 制作记录idLong → String */
val id: String = "",
/** 菜品idLong → String */
val foodId: String = "",
/** 菜品名称 */
val foodName: String = "",
/** 熟重(g) */
val foodWeight: Double = 0.0,
/** 餐次: 1=早餐/2=午餐/3=晚餐/4=加餐 */
val mealType: Int = 0,
/** 餐次中文 */
val mealTypeName: String = "",
/** 制作日期 */
val day: String = "",
/** 制作时间 */
val createTime: String = ""
)
// ==================== 七、制作历史图表 ====================
/**
* 制作历史图表 — 横轴项
* 对应接口: GET /neglect/ratio-scale/make/history/{foodId}
*/
data class MakeHistoryChartAxis(
/** 食材id(时间/熟重项为空字符串) */
val goodId: String = "",
/** 项名称(时间/熟重/食材名称) */
val goodName: String = ""
)
/**
* 制作历史图表 — 纵轴值
* value 为 String(时间项) 或 Double(数值项)
*/
data class MakeHistoryChartValue(
val value: Any? = null
)
/**
* 制作历史图表 — 单条记录(xAxes 与 yAxes 一一对应)
*/
data class MakeHistoryChartRow(
val xAxes: List<MakeHistoryChartAxis> = emptyList(),
val yAxes: List<MakeHistoryChartValue> = emptyList()
)
@@ -0,0 +1,40 @@
package com.shuwei.dish.match.model
/**
* 设置页面的列表项实体类
*
* @param type 菜单项类型,用于唯一标识每一项
* @param title 主标题文字
* @param subtitle 副标题文字,仅部分项目(如菜品模式)有值
* @param isHidden 是否为隐藏项,隐藏项需触发开发者模式后才显示
* @param onClick 点击回调,初始化时直接注入对应的业务逻辑
*/
data class SettingItem(
val type: Type,
val title: String,
val subtitle: String? = null,
val isHidden: Boolean = false,
val onClick: () -> Unit
) {
/** 设置菜单项类型枚举 */
enum class Type {
/** 菜品模式切换 */
COOK_MODE,
/** 调料区设置 */
SEASONING_CONFIG,
/** 食材采集 */
FOOD_COLLECT,
/** 减重配置 */
WEIGHT_CONFIG,
/** 数据库查看(隐藏项) */
DB_INSPECT,
/** 秤数据监控(隐藏项) */
SCALE_OBSERVE,
/** 切换环境(隐藏项) */
ENV_SWITCH,
/** 数据清除(隐藏项) */
CLEAR_DATA,
/** 测试调料拿取(隐藏项) */
TEST_SEASONING,
}
}
@@ -1,13 +1,35 @@
package com.shuwei.dish.match.net
import com.shuwei.dish.match.base.GlobalData
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
import com.shuwei.dish.match.model.CookFoodDTO
import com.shuwei.dish.match.model.CookOrderCompositionItem
import com.shuwei.dish.match.model.CookOrderMealGroup
import com.shuwei.dish.match.model.CookOrderPageRequest
import com.shuwei.dish.match.model.FoodRecord
import com.shuwei.dish.match.model.GoodsItem
import com.shuwei.dish.match.model.ConstituteSaveRequest
import com.shuwei.dish.match.model.CookCompleteRequest
import com.shuwei.dish.match.model.FoodCompositionResponse
import com.shuwei.dish.match.model.FoodSearchItem
import com.shuwei.dish.match.model.FoodSearchRequest
import com.shuwei.dish.match.model.MakeHistoryChartRow
import com.shuwei.dish.match.model.MakeHistoryItem
import com.shuwei.dish.match.model.MakeHistoryRequest
import com.shuwei.dish.match.model.MaterSearchItem
import com.shuwei.dish.match.model.MaterSearchRequest
import com.shuwei.dish.match.model.SampleHistoryItem
import com.shuwei.dish.match.model.SampleHistoryRequest
import com.shuwei.dish.match.model.SeasoningSearchItem
import com.shuwei.dish.match.model.SeasoningSearchRequest
import okhttp3.MultipartBody
import okhttp3.RequestBody
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.PartMap
import retrofit2.http.Path
import retrofit2.http.Query
import retrofit2.http.Url
@@ -18,9 +40,9 @@ interface ApiService {
*/
@GET
suspend fun getFoodDetail(
@Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/getConstituteByFoodId",
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/matching/app/getConstituteByFoodId",
@Query("foodId") foodId: String
): ApiResponse<CookFoodEntity?>
): ApiResponse<CookFoodDTO?>
/**
@@ -28,16 +50,16 @@ interface ApiService {
*/
@POST
suspend fun submitCookFood(
@Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/saveConstitute",
@Body param: CookFoodEntity
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/matching/app/saveConstitute",
@Body param: CookFoodDTO
): ApiResponse<Any?>
/**
* 搜索菜品
* 搜索菜品(旧接口,采样模式仍在使用)
*/
@POST
suspend fun searchFoodList(
@Url url:String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/queryPageList",
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/matching/app/queryPageList",
@Body param: MutableMap<String, Any>
): ApiResponse<MutableList<FoodRecord>?>
@@ -46,68 +68,129 @@ interface ApiService {
*/
@POST
suspend fun getSamplingList(
@Url url:String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/queryHistoryGoodsInfoList",
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/matching/app/queryHistoryGoodsInfoList",
@Body param: MutableMap<String, Any>
): ApiResponse<MutableList<FoodRecord>?>
/**
* 物品信息
* 物品信息(食材 goodsType=0,调料 goodsType=1 共用此接口)
*/
@POST
suspend fun queryGoodsList(
@Url url:String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/queryGoodsInfoList",
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/matching/app/queryGoodsInfoList",
@Body param: MutableMap<String, Any>
): ApiResponse<MutableList<CookFoodGoodsEntity>?>
): ApiResponse<MutableList<GoodsItem>?>
/**
* 调料信息
* 提交采集图片数据
*/
@Multipart
@POST
suspend fun uploadFoodVectorData(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/matching/app/addWeighingCollectionData",
@PartMap params: MutableMap<String, RequestBody>,
@Part foodPics: List<MultipartBody.Part>
): ApiResponse<List<String>?>
/**
* 查询当日烹饪任务列表(按餐次分组),新配比秤接口
*/
@POST
suspend fun querySeasoningList(
@Url url:String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/queryGoodsInfoList",
@Body param: MutableMap<String, Any>
): ApiResponse<MutableList<SeasoningEntity>?>
suspend fun getCookOrderPage(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/ratio-scale/cook-orders/list",
@Body request: CookOrderPageRequest
): ApiResponse<List<CookOrderMealGroup>?>
/**
* 查询菜品食材构成(主材+辅材,不含调料),新配比秤接口
*/
@GET("nutrition/neglect/ratio-scale/cook-orders/{cookOrderId}/composition")
suspend fun getCookOrderComposition(
@Path("cookOrderId") cookOrderId: String
): ApiResponse<List<CookOrderCompositionItem>?>
/**
* 调料模糊搜索,新配比秤接口
* 数据源: nut_mater_base 一级分类为 18(糖蜜饯)、19(油脂)、20(调味品)
*/
@POST
suspend fun getSeasoningSearch(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/ratio-scale/seasoning-search",
@Body request: SeasoningSearchRequest
): ApiResponse<List<SeasoningSearchItem>?>
/**
* 烹饪完成:提交主辅材用量+熟重+调料,一次性完成烹制
* 新配比秤接口,替代旧 saveConstitute
*/
@POST
suspend fun cookOrderComplete(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/ratio-scale/cook-orders/complete",
@Body request: CookCompleteRequest
): ApiResponse<Any?>
/**
* 食材模糊搜索(采样/制作通用,称量前检索食材)
* 数据源: nut_mater_base,限定设备对应食堂,模糊匹配名称和别名
*/
@POST
suspend fun materSearch(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/ratio-scale/mater-search",
@Body request: MaterSearchRequest
): ApiResponse<MutableList<MaterSearchItem>?>
/**
* 搜菜品(制作模式选已有上架菜品)
*/
@POST
suspend fun foodSearch(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/ratio-scale/food-search",
@Body request: FoodSearchRequest
): ApiResponse<MutableList<FoodSearchItem>?>
/**
* 查菜品构成(制作模式查看已有菜品主辅料+调料 + 熟重)
*/
@GET("nutrition/neglect/ratio-scale/food/{foodId}/composition")
suspend fun getFoodComposition(
@Path("foodId") foodId: String
): ApiResponse<FoodCompositionResponse?>
// /**
// *获取配置信息
// */
// @GET("/equipment/stEquipment/queryByEquipmentCode")
// suspend fun getDeviceInfo(
// @Query("equipmentCode") equipmentCode: String,
// @Query("appVersion") appVersion: String = App.appVersion
// ): ApiResponse<DeviceConfigInfo>
//
// @GET
// suspend fun getAccessToken(
// @Url url: String = UrlConfig.GET_ACCESS_TOKEN,
// @Query("qrcodeId") qrcodeId: String
// ): ApiResponse<String?>
//
// @GET
// suspend fun getShelfList(
// @Url url: String = UrlConfig.GET_SHELF_LIST,
// @Query("deviceId") deviceId: String
// ): ApiResponse<ShelfResult>
//
// @GET
// suspend fun getGoodsList(
// @Url url: String = UrlConfig.GET_GOODS_LIST,
// @Query("canteenId") canteenId: String = "0",
// @Query("goodsName") goodsName: String? = null,
// @Query("pageNo") pageNo: Int = 1,
// @Query("pageSize") pageSize: Int = 50
// ): ApiResponse<MutableList<GoodsModel>?>
//
// @POST
// suspend fun saveShelfGoodsList(
// @Url url: String = UrlConfig.SAVE_SHELF_GOODS_LIST,
// @Body body: ShelfBody
// ): ApiResponse<Any?>
/**
* 提交采样(落库味养创新实验,制作模式已废弃本接口)
*/
@POST
suspend fun constituteSave(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/ratio-scale/constitute/save",
@Body request: ConstituteSaveRequest
): ApiResponse<Any?>
/**
* 已提交采样列表(本食堂 source=2,按提交时间倒序,不分日期)
*/
@POST
suspend fun sampleHistory(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/ratio-scale/sample-list",
@Body request: SampleHistoryRequest
): ApiResponse<MutableList<SampleHistoryItem>?>
/**
* 制作历史(本设备,按日期可选过滤)
*/
@POST
suspend fun makeHistory(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/ratio-scale/make/history",
@Body request: MakeHistoryRequest
): ApiResponse<MutableList<MakeHistoryItem>?>
/**
* 制作历史图表(按菜品id)
*/
@GET("nutrition/neglect/ratio-scale/make/history/{foodId}")
suspend fun makeHistoryChart(
@Path("foodId") foodId: String
): ApiResponse<MutableList<MakeHistoryChartRow>?>
}
@@ -2,14 +2,40 @@ package com.shuwei.dish.match.net
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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
import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.model.CookFoodDTO
import com.shuwei.dish.match.model.CookOrderCompositionItem
import com.shuwei.dish.match.model.CookOrderMealGroup
import com.shuwei.dish.match.model.CookOrderPageRequest
import com.shuwei.dish.match.model.FoodRecord
import com.shuwei.dish.match.model.GoodsItem
import com.shuwei.dish.match.model.GoodsNameQueryDTO
import com.shuwei.dish.match.model.ConstituteSaveRequest
import com.shuwei.dish.match.model.CookCompleteRequest
import com.shuwei.dish.match.model.FoodCompositionResponse
import com.shuwei.dish.match.model.FoodSearchItem
import com.shuwei.dish.match.model.FoodSearchRequest
import com.shuwei.dish.match.model.MakeHistoryChartRow
import com.shuwei.dish.match.model.MakeHistoryItem
import com.shuwei.dish.match.model.MakeHistoryRequest
import com.shuwei.dish.match.model.MaterSearchItem
import com.shuwei.dish.match.model.MaterSearchRequest
import com.shuwei.dish.match.model.SampleHistoryItem
import com.shuwei.dish.match.model.SampleHistoryRequest
import com.shuwei.dish.match.model.SeasoningSearchItem
import com.shuwei.dish.match.model.SeasoningSearchRequest
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.asRequestBody
import java.io.File
/**
* 网络请求 ViewModel,所有网络操作通过 RemoteRepository 发起,不直接依赖 apiService
@@ -28,7 +54,7 @@ class NetViewModel(
/**
* 提交制作菜品
*/
fun submitCookFood(entity: CookFoodEntity) {
fun submitCookFood(entity: CookFoodDTO) {
viewModelScope.launch {
_submitCookFoodState.value = UiState.Loading
_submitCookFoodState.value = repository.submitCookFood(entity)
@@ -38,8 +64,8 @@ class NetViewModel(
/**
* 查询菜品详情的 UI 状态流,UI 层通过 collect 监听
*/
private val _foodDetailState = MutableStateFlow<UiState<CookFoodEntity?>>(UiState.Idle)
val foodDetailState: StateFlow<UiState<CookFoodEntity?>> = _foodDetailState.asStateFlow()
private val _foodDetailState = MutableStateFlow<UiState<CookFoodDTO?>>(UiState.Idle)
val foodDetailState: StateFlow<UiState<CookFoodDTO?>> = _foodDetailState.asStateFlow()
/**
* 查询菜品详情
@@ -51,24 +77,104 @@ class NetViewModel(
}
}
fun resetFoodDetailState() {
_foodDetailState.value = UiState.Idle
}
/**
* 搜索菜品列表的 UI 状态流UI 层通过 collect 监听
* 查询菜品食材构成的 UI 状态流(新配比秤接口)UI 层通过 collect 监听
*/
private val _searchFoodState = MutableStateFlow<UiState<MutableList<FoodRecord>?>>(UiState.Idle)
val searchFoodState: StateFlow<UiState<MutableList<FoodRecord>?>> = _searchFoodState.asStateFlow()
private val _cookOrderCompositionState = MutableStateFlow<UiState<List<CookOrderCompositionItem>?>>(UiState.Idle)
val cookOrderCompositionState: StateFlow<UiState<List<CookOrderCompositionItem>?>> = _cookOrderCompositionState.asStateFlow()
/**
* 查询菜品食材构成(新配比秤接口,仅返回主材+辅材)
* @param cookOrderId 烹制单 ID
*/
fun getCookOrderComposition(cookOrderId: String) {
viewModelScope.launch {
_cookOrderCompositionState.value = UiState.Loading
_cookOrderCompositionState.value = repository.getCookOrderComposition(cookOrderId)
}
}
/**
* 搜索菜品列表的 UI 状态流。
* 使用 replay=1 保证 Fragment 重建后能收到最近一次结果;
* emit 时包一层 Event wrapper 打破 equals 去重,确保相同数据也能触发 collect。
*/
private val _searchFoodState = MutableSharedFlow<UiState<MutableList<FoodRecord>?>>(
replay = 1,
extraBufferCapacity = 1
)
val searchFoodState: SharedFlow<UiState<MutableList<FoodRecord>?>> = _searchFoodState.asSharedFlow()
/**
* 搜索菜品列表
*/
fun searchFoodList(param: MutableMap<String, Any>) {
viewModelScope.launch {
_searchFoodState.value = UiState.Loading
_searchFoodState.value = repository.searchFoodList(param)
// pageNum=1 时先 emit Idle 重置 replay 缓存,防止相同数据导致 SharedFlow 去重不触发
if (param["pageNum"] == 1) {
_searchFoodState.emit(UiState.Idle)
}
_searchFoodState.emit(UiState.Loading)
_searchFoodState.emit(repository.searchFoodList(param))
}
}
/**
* 搜索菜品列表(回调版本),结果通过 onResult 直接回调,不经过 StateFlow/SharedFlow
* 适合需要每次都能触发的场景(下拉刷新、上拉加载更多)
*/
fun searchFoodListWithCallback(
param: MutableMap<String, Any>,
onLoading: () -> Unit = {},
onResult: (UiState<MutableList<FoodRecord>?>) -> Unit
) {
viewModelScope.launch {
onLoading()
onResult(repository.searchFoodList(param))
}
}
/**
* 当日烹饪任务列表的 UI 状态流(新配比秤接口),UI 层通过 collect 监听
* 使用 SharedFlow(replay=1) 保证 Fragment 重建后收到最近一次结果
*/
private val _cookOrderPageState = MutableSharedFlow<UiState<List<CookOrderMealGroup>?>>(
replay = 1,
extraBufferCapacity = 1
)
val cookOrderPageState: SharedFlow<UiState<List<CookOrderMealGroup>?>> = _cookOrderPageState.asSharedFlow()
/**
* 查询当日烹饪任务列表(新配比秤接口,StateFlow 版本)
* 适合 FoodSearchActivity 等通过 collect 监听结果的场景
*/
fun getCookOrderPage(request: CookOrderPageRequest) {
viewModelScope.launch {
_cookOrderPageState.emit(UiState.Loading)
_cookOrderPageState.emit(repository.getCookOrderPage(request))
}
}
/**
* 查询当日烹饪任务列表(新配比秤接口,回调版本)
* 适合 FoodListFragment 下拉刷新、Tab 切换等需要每次都能触发的场景
*/
fun getCookOrderPageWithCallback(
request: CookOrderPageRequest,
onLoading: () -> Unit = {},
onResult: (UiState<List<CookOrderMealGroup>?>) -> Unit
) {
viewModelScope.launch {
onLoading()
onResult(repository.getCookOrderPage(request))
}
}
/**
* 查询采样数据列表的 UI 状态流,UI 层通过 collect 监听
*/
private val _samplingListState = MutableStateFlow<UiState<MutableList<FoodRecord>?>>(UiState.Idle)
val samplingListState: StateFlow<UiState<MutableList<FoodRecord>?>> = _samplingListState.asStateFlow()
@@ -87,15 +193,61 @@ class NetViewModel(
}
/**
* 查询物品信息列表的 UI 状态流,UI 层通过 collect 监听
* 查询采样数据列表(回调版本),结果通过 onResult 直接回调,不经过 StateFlow
* 适合不需要跨生命周期持续监听的一次性查询场景
*/
private val _goodsListState = MutableStateFlow<UiState<MutableList<CookFoodGoodsEntity>?>>(UiState.Idle)
val goodsListState: StateFlow<UiState<MutableList<CookFoodGoodsEntity>?>> = _goodsListState.asStateFlow()
fun getSamplingListWithCallback(
param: MutableMap<String, Any>,
onLoading: () -> Unit = {},
onResult: (UiState<MutableList<FoodRecord>?>) -> Unit
) {
viewModelScope.launch {
onLoading()
onResult(repository.getSamplingList(param))
}
}
/**
* 查询物品信息列表
* 查询物品信息列表的 UI 状态流(食材/调料共用),UI 层通过 collect 监听
*/
fun queryGoodsList(param: MutableMap<String, Any>) {
private val _goodsListState = MutableStateFlow<UiState<MutableList<GoodsItem>?>>(UiState.Idle)
val goodsListState: StateFlow<UiState<MutableList<GoodsItem>?>> = _goodsListState.asStateFlow()
/**
* 食材搜索弹窗专用的 UI 状态流,与 goodsListState 隔离,避免弹窗请求影响其他页面
*/
private val _foodSearchGoodsListState = MutableStateFlow<UiState<MutableList<GoodsItem>?>>(UiState.Idle)
val foodSearchGoodsListState: StateFlow<UiState<MutableList<GoodsItem>?>> = _foodSearchGoodsListState.asStateFlow()
fun resetGoodsListState() {
_goodsListState.value = UiState.Idle
}
/**
* 查询物品信息列表(食材 goodsType=0,调料 goodsType=1 共用)
* @param goodsType 物品类型:0=食材,1=调料
* @param placeId 场所 ID,默认取当前食堂 ID
* @param pageNum 页码,默认第 1 页
* @param pageSize 每页条数,默认 50
* @param goodsName 按名称模糊搜索,为 null 时不传该字段
* @param goodsNames 按名称+净材种类列表批量查询,为 null 时不传该字段
*/
fun queryGoodsList(
goodsType: String,
placeId: String = BaseApp.canteenId,
pageNum: Int = 1,
pageSize: Int = 50,
goodsName: String? = null,
goodsNames: List<GoodsNameQueryDTO>? = null
) {
val param = mutableMapOf<String, Any>(
"goodsType" to goodsType,
"placeId" to placeId,
"pageNum" to pageNum,
"pageSize" to pageSize
)
goodsName?.let { param["goodsName"] = it }
goodsNames?.let { param["goodsNames"] = it }
viewModelScope.launch {
_goodsListState.value = UiState.Loading
_goodsListState.value = repository.queryGoodsList(param)
@@ -103,18 +255,234 @@ class NetViewModel(
}
/**
* 查询调料信息列表的 UI 状态流,UI 层通过 collect 监听
* 食材搜索弹窗专用的查询方法,结果写入 foodSearchGoodsListState,不影响 goodsListState
* @param goodsType 物品类型:0=食材,1=调料
* @param placeId 场所 ID,默认取当前食堂 ID
* @param pageNum 页码,默认第 1 页
* @param pageSize 每页条数,默认 50
* @param goodsName 按名称模糊搜索,为 null 时不传该字段
* @param goodsNames 按名称+净材种类列表批量查询,为 null 时不传该字段
*/
private val _seasoningListState = MutableStateFlow<UiState<MutableList<SeasoningEntity>?>>(UiState.Idle)
val seasoningListState: StateFlow<UiState<MutableList<SeasoningEntity>?>> = _seasoningListState.asStateFlow()
fun queryFoodSearchGoodsList(
goodsType: String,
placeId: String = BaseApp.canteenId,
pageNum: Int = 1,
pageSize: Int = 50,
goodsName: String? = null,
goodsNames: List<GoodsNameQueryDTO>? = null
) {
val param = mutableMapOf<String, Any>(
"goodsType" to goodsType,
"placeId" to placeId,
"pageNum" to pageNum,
"pageSize" to pageSize
)
goodsName?.let { param["goodsName"] = it }
goodsNames?.let { param["goodsNames"] = it }
viewModelScope.launch {
_foodSearchGoodsListState.value = UiState.Loading
_foodSearchGoodsListState.value = repository.queryGoodsList(param)
}
}
/**
* 查询调料信息列表
* 调料模糊搜索(新配比秤接口,回调版本)
* 替代旧 queryGoodsList(goodsType=1) 调用
* @param keyword 模糊匹配食材名称和别名,为 null 时不传该字段
* @param pageNum 页码,默认第 1 页
* @param pageSize 每页条数,默认 50
* @param onLoading 请求发起前的回调
* @param onResult 结果回调,直接接收 UiState<List<SeasoningSearchItem>?>
*/
fun querySeasoningList(param: MutableMap<String, Any>) {
fun querySeasoningSearch(
keyword: String? = null,
pageNum: Int = 1,
pageSize: Int = 50,
onLoading: () -> Unit = {},
onResult: (UiState<List<SeasoningSearchItem>?>) -> Unit
) {
viewModelScope.launch {
_seasoningListState.value = UiState.Loading
_seasoningListState.value = repository.querySeasoningList(param)
onLoading()
onResult(repository.getSeasoningSearch(
SeasoningSearchRequest(keyword = keyword, pageNum = pageNum, pageSize = pageSize)
))
}
}
/**
* 烹饪完成(新配比秤接口,回调版本),替代旧 submitCookFood
* 仅在制作模式下 cookOrderId 可用的场景调用
*/
fun cookOrderComplete(
request: CookCompleteRequest,
onLoading: () -> Unit = {},
onResult: (UiState<Any?>) -> Unit
) {
viewModelScope.launch {
onLoading()
onResult(repository.cookOrderComplete(request))
}
}
/**
* 食材模糊搜索(回调版本)
* @param keyword 模糊匹配食材名称和别名,为 null 时不传该字段
* @param pageNum 页码,默认第 1 页
* @param pageSize 每页条数,默认 20
*/
fun materSearch(
keyword: String? = null,
pageNum: Int = 1,
pageSize: Int = 20,
onLoading: () -> Unit = {},
onResult: (UiState<MutableList<MaterSearchItem>?>) -> Unit
) {
viewModelScope.launch {
onLoading()
onResult(repository.materSearch(
MaterSearchRequest(keyword = keyword, pageNum = pageNum, pageSize = pageSize)
))
}
}
/**
* 搜菜品(回调版本,制作模式选已有上架菜品)
*/
fun foodSearch(
keyword: String? = null,
pageNum: Int = 1,
pageSize: Int = 20,
onLoading: () -> Unit = {},
onResult: (UiState<MutableList<FoodSearchItem>?>) -> Unit
) {
viewModelScope.launch {
onLoading()
onResult(repository.foodSearch(
FoodSearchRequest(keyword = keyword, pageNum = pageNum, pageSize = pageSize)
))
}
}
/**
* 查菜品构成(回调版本,制作模式)
*/
fun getFoodComposition(
foodId: String,
onLoading: () -> Unit = {},
onResult: (UiState<FoodCompositionResponse?>) -> Unit
) {
viewModelScope.launch {
onLoading()
onResult(repository.getFoodComposition(foodId))
}
}
/**
* 提交采样(回调版本,落库味养创新实验,制作模式已废弃本接口)
*/
fun constituteSave(
request: ConstituteSaveRequest,
onLoading: () -> Unit = {},
onResult: (UiState<Any?>) -> Unit
) {
viewModelScope.launch {
onLoading()
onResult(repository.constituteSave(request))
}
}
/**
* 已提交采样列表(回调版本,本食堂 source=2,不分日期)
* @param pageNum 页码,默认第 1 页
* @param pageSize 每页条数,默认 10
*/
fun sampleHistory(
pageNum: Int = 1,
pageSize: Int = 10,
onLoading: () -> Unit = {},
onResult: (UiState<MutableList<SampleHistoryItem>?>) -> Unit
) {
viewModelScope.launch {
onLoading()
onResult(repository.sampleHistory(
SampleHistoryRequest(
pageNum = pageNum,
pageSize = pageSize
)
))
}
}
/**
* 制作历史(回调版本,本设备按日期可选过滤)
*/
fun makeHistory(
pageNum: Int = 1,
pageSize: Int = 10,
day: String? = null,
onLoading: () -> Unit = {},
onResult: (UiState<MutableList<MakeHistoryItem>?>) -> Unit
) {
viewModelScope.launch {
onLoading()
onResult(repository.makeHistory(
MakeHistoryRequest(pageNum = pageNum, pageSize = pageSize, day = day)
))
}
}
/**
* 制作历史图表(回调版本,按菜品id)
*/
fun makeHistoryChart(
foodId: String,
onLoading: () -> Unit = {},
onResult: (UiState<MutableList<MakeHistoryChartRow>?>) -> Unit
) {
viewModelScope.launch {
onLoading()
onResult(repository.makeHistoryChart(foodId))
}
}
/**
* 上传采集菜品信息 UI 状态流,UI 层通过 collect 监听
*/
private val _uploadCollectState = MutableStateFlow<UiState<List<String>?>>(UiState.Idle)
val uploadCollectState: StateFlow<UiState<List<String>?>> = _uploadCollectState.asStateFlow()
/**
* 上传采集菜品信息(触发 StateFlow,适合单次上传场景)
*/
fun uploadFoodVectorData(
fileList: List<File>,
params: MutableMap<String, RequestBody>
) {
val fileParts = fileList.map { file ->
file.asRequestBody("multipart/form-data".toMediaTypeOrNull())
.let { MultipartBody.Part.createFormData("foodPics", file.name, it) }
}
viewModelScope.launch {
_uploadCollectState.value = UiState.Loading
_uploadCollectState.value = repository.uploadFoodVectorData(params, fileParts)
}
}
/**
* 上传采集菜品信息(直接返回结果,适合分批循环上传场景)
* @param fileList 图片文件列表
* @param params 附加参数
* @return UiState 包装的结果
*/
suspend fun uploadFoodVectorDataBatch(
fileList: List<File>,
params: MutableMap<String, RequestBody>
): UiState<List<String>?> {
val fileParts = fileList.map { file ->
//multipart/form-data image/jpeg
file.asRequestBody("multipart/form-data".toMediaTypeOrNull())
.let { MultipartBody.Part.createFormData("foodPics", file.name, it) }
}
return repository.uploadFoodVectorData(params, fileParts)
}
}
@@ -1,9 +1,27 @@
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
import com.shuwei.dish.match.model.CookFoodDTO
import com.shuwei.dish.match.model.CookOrderCompositionItem
import com.shuwei.dish.match.model.CookOrderMealGroup
import com.shuwei.dish.match.model.CookOrderPageRequest
import com.shuwei.dish.match.model.FoodRecord
import com.shuwei.dish.match.model.GoodsItem
import com.shuwei.dish.match.model.ConstituteSaveRequest
import com.shuwei.dish.match.model.CookCompleteRequest
import com.shuwei.dish.match.model.FoodCompositionResponse
import com.shuwei.dish.match.model.FoodSearchItem
import com.shuwei.dish.match.model.FoodSearchRequest
import com.shuwei.dish.match.model.MakeHistoryChartRow
import com.shuwei.dish.match.model.MakeHistoryItem
import com.shuwei.dish.match.model.MakeHistoryRequest
import com.shuwei.dish.match.model.MaterSearchItem
import com.shuwei.dish.match.model.MaterSearchRequest
import com.shuwei.dish.match.model.SampleHistoryItem
import com.shuwei.dish.match.model.SampleHistoryRequest
import com.shuwei.dish.match.model.SeasoningSearchItem
import com.shuwei.dish.match.model.SeasoningSearchRequest
import okhttp3.MultipartBody
import okhttp3.RequestBody
/**
* 网络数据仓库,封装所有 ApiService 调用
@@ -11,101 +29,86 @@ import com.shuwei.dish.match.entity.SeasoningEntity
*/
class RemoteRepository {
/**
* 提交制作菜品
* @param entity 菜品实体
* @return UiState 包装的结果,Success 表示提交成功,Error 携带错误信息
*/
suspend fun submitCookFood(entity: CookFoodEntity): UiState<Any?> {
/** 统一封装 API 调用:处理响应映射和异常转换,返回 UiState */
private suspend fun <T> safeApiCall(call: suspend () -> ApiResponse<T>): UiState<T?> {
return try {
val resp = apiService.submitCookFood(param = entity)
val resp = call()
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)
UiState.Error("-1", getApiException(e).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)
}
}
/** 提交制作菜品 */
suspend fun submitCookFood(entity: CookFoodDTO): UiState<Any?> =
safeApiCall { apiService.submitCookFood(param = entity) }
/**
* 搜索菜品列表
* @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)
}
}
/** 查询菜品详情 */
suspend fun getFoodDetail(foodId: String): UiState<CookFoodDTO?> =
safeApiCall { apiService.getFoodDetail(foodId = foodId) }
/**
* 查询采样数据列表
* @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)
}
}
/** 查询当日烹饪任务列表(按餐次分组),新配比秤接口 */
suspend fun getCookOrderPage(request: CookOrderPageRequest): UiState<List<CookOrderMealGroup>?> =
safeApiCall { apiService.getCookOrderPage(request = request) }
/**
* 查询物品信息列表
* @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)
}
}
/** 查询菜品食材构成(主材+辅材),新配比秤接口 */
suspend fun getCookOrderComposition(cookOrderId: String): UiState<List<CookOrderCompositionItem>?> =
safeApiCall { apiService.getCookOrderComposition(cookOrderId = cookOrderId) }
/** 搜索菜品列表 */
suspend fun searchFoodList(param: MutableMap<String, Any>): UiState<MutableList<FoodRecord>?> =
safeApiCall { apiService.searchFoodList(param = param) }
/**
* 查询调料信息列表
* @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)
}
}
/** 查询采样数据列表 */
suspend fun getSamplingList(param: MutableMap<String, Any>): UiState<MutableList<FoodRecord>?> =
safeApiCall { apiService.getSamplingList(param = param) }
/** 查询物品信息列表(食材 goodsType=0,调料 goodsType=1 共用) */
suspend fun queryGoodsList(param: MutableMap<String, Any>): UiState<MutableList<GoodsItem>?> =
safeApiCall { apiService.queryGoodsList(param = param) }
/** 调料模糊搜索,新配比秤接口 */
suspend fun getSeasoningSearch(request: SeasoningSearchRequest): UiState<List<SeasoningSearchItem>?> =
safeApiCall { apiService.getSeasoningSearch(request = request) }
/** 烹饪完成,新配比秤接口,替代旧 submitCookFood */
suspend fun cookOrderComplete(request: CookCompleteRequest): UiState<Any?> =
safeApiCall { apiService.cookOrderComplete(request = request) }
/** 食材模糊搜索 */
suspend fun materSearch(request: MaterSearchRequest): UiState<MutableList<MaterSearchItem>?> =
safeApiCall { apiService.materSearch(request = request) }
/** 搜菜品(制作模式选已有上架菜品) */
suspend fun foodSearch(request: FoodSearchRequest): UiState<MutableList<FoodSearchItem>?> =
safeApiCall { apiService.foodSearch(request = request) }
/** 查菜品构成(制作模式) */
suspend fun getFoodComposition(foodId: String): UiState<FoodCompositionResponse?> =
safeApiCall { apiService.getFoodComposition(foodId = foodId) }
/** 提交采样(落库味养创新实验,制作模式已废弃本接口) */
suspend fun constituteSave(request: ConstituteSaveRequest): UiState<Any?> =
safeApiCall { apiService.constituteSave(request = request) }
/** 已提交采样列表(本食堂 source=2,按提交时间倒序,不分日期) */
suspend fun sampleHistory(request: SampleHistoryRequest): UiState<MutableList<SampleHistoryItem>?> =
safeApiCall { apiService.sampleHistory(request = request) }
/** 制作历史(本设备,按日期可选过滤) */
suspend fun makeHistory(request: MakeHistoryRequest): UiState<MutableList<MakeHistoryItem>?> =
safeApiCall { apiService.makeHistory(request = request) }
/** 制作历史图表(按菜品id) */
suspend fun makeHistoryChart(foodId: String): UiState<MutableList<MakeHistoryChartRow>?> =
safeApiCall { apiService.makeHistoryChart(foodId = foodId) }
/** 上传采集数据 */
suspend fun uploadFoodVectorData(
params: MutableMap<String, RequestBody>,
foodPics: List<MultipartBody.Part>
): UiState<List<String>?> =
safeApiCall { apiService.uploadFoodVectorData(params = params, foodPics = foodPics) }
}
@@ -13,6 +13,7 @@ class RequestInterceptor : Interceptor {
val requestBuilder = originalRequest.newBuilder()
.header("X-Access-Token", "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJjYW50ZWVuSWQiOiJiZTE1NDgzMS0zNDY2LTNiYTItYTJlYS01NzY1MmM5MTlmZWQiLCJ0eXBlIjoiNCIsInVzZXJJZCI6IjAwMDAwMDAwMDAwMDAwMDAwMDEifQ.sN40cOC-O5WQFrF4IDUs8fFlkNdUKLbJt_rHyTsgYYM")
.header("X-DEVICE-CODE", GlobalData.deviceId)
.header("X-DEVICE-TOKEN", GlobalData.deviceId)
.header("authorization", "57ee87183f2a4fa59683ec9ef41c8f5d")
val newRequest = requestBuilder.build()
@@ -3,7 +3,10 @@ package com.shuwei.dish.match.net
data class ApiResponse<T>(
val code: String,
val msg: String? = "",
val data: T? = null
val data: T? = null,
/** 分页总条数,仅新配比秤接口返回(如 seasoning-search),旧接口为 null */
val total: Int? = null
) {
fun isSuccess() = code == "00000"
/** 兼容旧接口成功码 "00000" 和新配比秤接口成功码 "200" */
fun isSuccess() = code == "00000" || code == "200"
}
@@ -1,68 +0,0 @@
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
)
@@ -11,7 +11,8 @@ data class Food(
var name: String? = null,
var foodIdx: Int = 0,
@HnswIndex(dimensions = 512, distanceType = VectorDistanceType.DOT_PRODUCT)
var foodVector: FloatArray? = null
var foodVector: FloatArray? = null,
var version: String? = null
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
@@ -23,6 +24,7 @@ data class Food(
if (foodIdx != other.foodIdx) return false
if (name != other.name) return false
if (!foodVector.contentEquals(other.foodVector)) return false
if (version != other.version) return false
return true
}
@@ -32,6 +34,8 @@ data class Food(
result = 31 * result + foodIdx
result = 31 * result + (name?.hashCode() ?: 0)
result = 31 * result + (foodVector?.contentHashCode() ?: 0)
result = 31 * result + (version?.hashCode() ?: 0)
return result
}
}
@@ -0,0 +1,12 @@
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>
)
@@ -22,7 +22,7 @@ import java.io.InputStream
object FoodModule {
private lateinit var module_mobile: Module
private lateinit var module: 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)
@@ -37,8 +37,8 @@ object FoodModule {
suspend fun init(context: Context) {
withContext(Dispatchers.IO) {
module_mobile = Module.load(copyAssetToCache(context, "best_embedding_model_mobile.pt"))
initDefFoodData(context)
module = Module.load(copyAssetToCache(context, "best_embedding_model_mobile.pt"))
// initDefFoodData(context)
}
}
@@ -51,6 +51,11 @@ object FoodModule {
// }
fun bitmap2FloatArray(originBitmap: Bitmap, isRecycle: Boolean): FloatArray? {
// 模型未初始化时直接返回 null,避免 UninitializedPropertyAccessException
if (!::module.isInitialized) {
logInfo("bitmap2FloatArray: module 尚未初始化,跳过推理")
return null
}
var rgb565Bitmap: Bitmap? = null
try {
val scaledBitmap = originBitmap.scale(MODEL_INPUT_WIDTH, MODEL_INPUT_HEIGHT)
@@ -64,7 +69,7 @@ object FoodModule {
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()
val outputTensor = module.forward(IValue.from(inputTensor)).toTensor()
return outputTensor.dataAsFloatArray
} catch (e: OutOfMemoryError) {
e.printStackTrace()
@@ -124,7 +129,12 @@ object FoodModule {
val nameScoreList = mutableListOf<IdNameScore>()
idScoreList.forEach {
val name = foodMap[it.id]?.name ?: ""
nameScoreList.add(IdNameScore(id = it.id, name = name, score = it.score))
val arr = name.split("&&")
var rawMaterialsType = ""
if (arr.size > 1) {
rawMaterialsType = arr[1]
}
nameScoreList.add(IdNameScore(id = it.id, name = arr[0], score = it.score, rawMaterialsType = rawMaterialsType))
}
logInfo("queryFood数据:${nameScoreList.toJsonString()}")
return nameScoreList
@@ -176,7 +186,8 @@ object FoodModule {
data class IdNameScore(
val id: Long,
var name: String,
val score: Double
val score: Double,
val rawMaterialsType: String
)
suspend fun initDefFoodData(context: Context, action: () -> Unit = {}) {
@@ -213,6 +213,11 @@ object ObjectBox {
getBox<Food>()?.query(Food_.name.equal(name))?.build()?.remove()
}
// 清除所有向量采集数据
suspend fun removeAll() = safeDbOp {
getBox<Food>()?.removeAll()
}
/**
* 按名称分组统计采集数量
* 使用属性查询只加载 name 字段,不加载 foodVector,节省大量内存
@@ -28,7 +28,6 @@ class MdnsDiscoveryManager(private val context: Context) {
/** 待解析队列,串行处理避免并发解析失败 */
private val resolveQueue = LinkedList<NsdServiceInfo>()
@Volatile
private var isResolving = false
/**
@@ -6,7 +6,7 @@ package com.shuwei.dish.match.scale
*/
object ScaleDeviceConfig {
const val DEVICE_ID_2 = "8fc2ab34-2137-3112-acca-f884ea8736d4"
const val DEVICE_ID_2 = "0bd74d78-c221-3182-b5ee-55d86dd79283"//测试0bd74d78-c221-3182-b5ee-55d86dd79283 正式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"
@@ -17,6 +17,9 @@ object ScaleDeviceConfig {
/** 18个秤的显示顺序(按物理位置排列) */
val SCALE_ORDER_18 = listOf(6, 5, 4, 3, 2, 1, 12, 11, 10, 9, 8, 7, 18, 17, 16, 15, 14, 13)
/** 1个秤的地址列表 */
val SCALE_ORDER_1 = listOf(1)
/** 设备在列表中的显示顺序 */
val DEVICE_ORDER = listOf(DEVICE_ID_2, DEVICE_ID_22, DEVICE_ID_18, DEVICE_ID_1)
}
@@ -34,5 +34,7 @@ data class ScaleEvent(
const val TYPE_SEASONING_ADDED = "seasoning_added"
/** 调料槽位配置同步事件:主设备配置变更后广播给子设备 */
const val TYPE_SEASONING_CONFIG = "seasoning_config"
/** 清除数据事件:主设备通知所有子设备清除本机测试数据 */
const val TYPE_CLEAR_DATA = "clear_data"
}
}
@@ -9,6 +9,8 @@ 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.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
@@ -34,6 +36,9 @@ object ScaleServiceManager {
private const val TAG = "ScaleServiceManager"
@Volatile private var isStarted = false
private var serviceScope: CoroutineScope? = null
private var mdnsRegister: MdnsRegisterManager? = null
private var wsServer: ScaleWebSocketServer? = null
private var udpSender: UdpBroadcastSender? = null
@@ -71,6 +76,14 @@ object ScaleServiceManager {
get() = wsServer?.onSeasoningConfig
set(value) { wsServer?.onSeasoningConfig = value }
/**
* 收到主设备下发的清除数据事件时的回调,仅子设备有效
* 在子线程中调用,需自行切换到主线程更新 UI
*/
var onClearData: ((ScaleEvent) -> Unit)?
get() = wsServer?.onClearData
set(value) { wsServer?.onClearData = value }
/**
* 向所有已连接子设备广播调料配置(主设备调用)
* @param slots 全量槽位配置列表
@@ -79,6 +92,16 @@ object ScaleServiceManager {
wsClient?.sendToAllSlaves(buildConfigEvent(slots))
}
/**
* 通知所有已连接子设备清除本机数据(主设备调用)
* 子设备收到后执行与主设备相同的清除逻辑
*/
fun sendClearData() {
wsClient?.sendToAllSlaves(
ScaleEvent(type = ScaleEvent.TYPE_CLEAR_DATA, deviceId = GlobalData.deviceId)
)
}
/**
* 向单台刚连接的子设备推送调料配置(连接时按需调用)
* @param deviceId 目标子设备 ID
@@ -117,6 +140,9 @@ object ScaleServiceManager {
* 启动所有秤服务,在 Application.onCreate() 中调用
*/
fun start(context: Context) {
if (isStarted) return
isStarted = true
serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
val deviceId = GlobalData.deviceId
val role = GlobalData.deviceRole
Log.d(TAG, "启动秤服务, deviceId=$deviceId, role=$role")
@@ -128,9 +154,8 @@ object ScaleServiceManager {
if (role == DeviceRole.MASTER) {
startMasterServices(context, deviceId)
} else {
// 子设备:启动 UDP 广播,作为 mDNS 的兜底发现机制
val localIp = getLocalIp(context)
udpSender = UdpBroadcastSender(deviceId, localIp).also { it.start() }
// 子设备:启动 UDP 广播,作为 mDNS 的兜底发现机制;每次广播时动态获取 IP,避免启动时网络未就绪导致 IP 为空
udpSender = UdpBroadcastSender(deviceId = deviceId, ipProvider = { getLocalIp(context) }).also { it.start() }
}
}
@@ -156,17 +181,21 @@ object ScaleServiceManager {
val client = ScaleWebSocketClient().also {
it.onScaleData = { data -> aggregator?.onRemoteScaleData(data) }
it.onScaleEvent = { event -> _onScaleEvent?.invoke(event) }
it.onDeviceDisconnected = { remoteId -> aggregator?.removeDevice(remoteId) }
it.onDeviceDisconnected = { remoteId ->
aggregator?.removeDevice(remoteId)
// 清除 UDP 缓存,确保子设备下次广播时能重新触发 onDeviceFound,立即重连而非等待指数退避
udpReceiver?.removeDevice(remoteId)
}
it.onDeviceConnected = { remoteId ->
// 子设备连接成功时,从 Room 读取全量配置并单独推送给该设备
CoroutineScope(Dispatchers.IO).launch {
val slots = BaseApp.instance!!.database.seasoningSlotDao()
.queryAll()
.map { slot ->
serviceScope?.launch {
val slots = BaseApp.instance?.database?.seasoningSlotDao()
?.queryAll()
?.map { slot ->
ScaleEvent.SlotConfig(
slot.deviceId, slot.address, slot.goodsId, slot.goodsName
)
}
} ?: return@launch
if (slots.isNotEmpty()) sendSeasoningConfigTo(remoteId, slots)
}
}
@@ -225,6 +254,9 @@ object ScaleServiceManager {
* 停止所有秤服务,在 Application.onTerminate() 或退出时调用
*/
fun stop() {
serviceScope?.cancel()
serviceScope = null
isStarted = false
mdnsDiscovery?.stopDiscovery()
udpReceiver?.stop()
udpSender?.stop()
@@ -43,6 +43,11 @@ class ScaleWebSocketClient {
private val reconnectDelays = ConcurrentHashMap<String, Long>()
/** 已主动移除的设备集合,不对其触发重连 */
private val removedDevices = ConcurrentHashMap.newKeySet<String>()
/**
* 各设备的连接版本号,每次调用 connect() 时递增
* doConnect 执行前校验版本,版本不匹配说明已有更新的连接请求,直接放弃本次连接
*/
private val connectVersions = ConcurrentHashMap<String, Int>()
private val scheduler: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor()
@@ -66,14 +71,24 @@ class ScaleWebSocketClient {
*/
fun connect(deviceId: String, host: String, port: Int) {
removedDevices.remove(deviceId)
// 取消旧 IP 的重连任务,防止 IP 变化时新旧任务并发竞争
// 取消旧重连任务,防止 IP 变化时新旧任务并发竞争
reconnectTasks.remove(deviceId)?.cancel(false)
reconnectDelays[deviceId] = RECONNECT_BASE_MS
doConnect(deviceId, host, port)
// 递增版本号:doConnect 执行前会校验版本,旧任务版本不匹配时直接放弃
val version = (connectVersions[deviceId] ?: 0) + 1
connectVersions[deviceId] = version
doConnect(deviceId, host, port, version)
}
private fun doConnect(deviceId: String, host: String, port: Int) {
val url = "ws://$host:$port"
private fun doConnect(deviceId: String, host: String, port: Int, version: Int = connectVersions[deviceId] ?: 0) {
// 版本校验:若当前版本已被更新的 connect() 调用覆盖,放弃本次连接
if (connectVersions[deviceId] != version) {
Log.d(TAG, "放弃过期连接任务: $deviceId, version=$version")
return
}
// IPv6 地址在 URL 中需用方括号包裹,否则 OkHttp 会将冒号后内容误解析为端口
val formattedHost = if (host.contains(':')) "[$host]" else host
val url = "ws://$formattedHost:$port"
val request = Request.Builder().url(url).build()
val ws = httpClient.newWebSocket(request, object : WebSocketListener() {
@@ -103,34 +118,46 @@ class ScaleWebSocketClient {
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Log.w(TAG, "连接子设备失败: $deviceId, ${t.message}")
connections.remove(deviceId)
// 使用 remove(key, value) 确保只移除自己的实例
// 避免旧连接超时触发时误删新连接引用,导致 aggregator 错误清空新连接数据
val removed = connections.remove(deviceId, webSocket)
if (removed) {
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)
// 同上:只有移除的是自己,才触发断线回调和重连
val removed = connections.remove(deviceId, webSocket)
if (removed) {
onDeviceDisconnected?.invoke(deviceId)
scheduleReconnect(deviceId, host, port)
}
}
})
connections[deviceId] = ws
// 写入新连接前先关闭旧连接,避免 IP 变化时旧 ws 对象悬空造成 TCP 资源泄漏
connections.put(deviceId, ws)?.close(1000, "新连接替换")
}
/**
* 指数退避重连
* 入口不拦截 removedDevices,仅在延迟任务执行前检查,
* 确保 disconnect() 后已入队的任务不执行,但新断线事件始终能进入重连流程
* scheduler 已关闭(shutdown 后)时直接跳过,避免 RejectedExecutionException
*/
private fun scheduleReconnect(deviceId: String, host: String, port: Int) {
if (scheduler.isShutdown) return
val delay = reconnectDelays[deviceId] ?: RECONNECT_BASE_MS
Log.d(TAG, "将在 ${delay}ms 后重连子设备: $deviceId")
// 记录当前版本,延迟任务执行时再次校验,防止期间有新的 connect() 调用
val version = connectVersions[deviceId] ?: 0
val future = scheduler.schedule({
if (!removedDevices.contains(deviceId)) {
doConnect(deviceId, host, port)
doConnect(deviceId, host, port, version)
}
}, delay, TimeUnit.MILLISECONDS)
@@ -186,6 +213,7 @@ class ScaleWebSocketClient {
reconnectTasks.remove(deviceId)?.cancel(false)
connections.remove(deviceId)?.close(1000, "设备离线")
reconnectDelays.remove(deviceId)
connectVersions.remove(deviceId)
Log.d(TAG, "已断开子设备: $deviceId")
}
@@ -9,6 +9,11 @@ import org.java_websocket.WebSocket
import org.java_websocket.handshake.ClientHandshake
import org.java_websocket.server.WebSocketServer
import java.net.InetSocketAddress
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledFuture
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
/**
* 秤数据 WebSocket 服务端(主设备和子设备均运行)
@@ -20,10 +25,14 @@ class ScaleWebSocketServer(private val deviceId: String, private val context: Co
private const val TAG = "ScaleWebSocketServer"
/** 节流间隔:两次推送之间的最小间隔(毫秒) */
private const val THROTTLE_MS = 100L
/** latestData 为空时延迟补推的时间(毫秒),给 WeightUtil 时间产生第一次读数 */
private const val RETRY_PUSH_DELAY_MS = 3_000L
}
private val gson = Gson()
private var server: InternalServer? = null
/** 延迟补推任务的调度器 */
private val scheduler = Executors.newSingleThreadScheduledExecutor()
/** 主设备连接状态变化回调:true=已连接,false=已断开;在主线程外调用,需自行切换线程 */
var onConnectionChanged: ((connected: Boolean) -> Unit)? = null
@@ -31,15 +40,21 @@ class ScaleWebSocketServer(private val deviceId: String, private val context: Co
/** 收到主设备下发的调料配置同步事件时的回调,在子线程调用 */
var onSeasoningConfig: ((event: ScaleEvent) -> Unit)? = null
/** 收到主设备下发的清除数据事件时的回调,在子线程调用 */
var onClearData: ((event: ScaleEvent) -> Unit)? = null
/** 当前是否有主设备连接 */
val isConnected: Boolean
get() = server?.connections?.isNotEmpty() == true
/** 每个秤地址的最近一次推送时间,用于节流 */
private val lastPushTime = mutableMapOf<Int, Long>()
private val lastPushTime = ConcurrentHashMap<Int, Long>()
/** 缓存各秤最新数据,新客户端连接时立即同步当前状态 */
private val latestData = mutableMapOf<Int, ScaleData>()
private val latestData = ConcurrentHashMap<Int, ScaleData>()
/** 节流窗口内的延迟推送任务,key=address;保证窗口结束时最新数据被推送 */
private val pendingPushTasks = ConcurrentHashMap<Int, ScheduledFuture<*>>()
/**
* 启动 WebSocket 服务器,并注册 WeightUtil 回调
@@ -52,11 +67,12 @@ class ScaleWebSocketServer(private val deviceId: String, private val context: Co
// 注册本机秤数据监听
WeightUtil.addWeightListener(TAG) { address, state, weight ->
Log.d(TAG, "WebSocket 设备${deviceId}推送数据, address=$address, state=$state, weight=$weight")
val now = System.currentTimeMillis()
// 节流:同一地址 100ms 内只推送一次
if (now - (lastPushTime[address] ?: 0L) < THROTTLE_MS) return@addWeightListener
// 数据未变化时直接跳过,避免无效推送
val last = latestData[address]
if (last?.weight == weight && last.state == state) return@addWeightListener
lastPushTime[address] = now
val data = ScaleData(
deviceId = deviceId,
address = address,
@@ -65,8 +81,28 @@ class ScaleWebSocketServer(private val deviceId: String, private val context: Co
ts = now,
ip = NetworkUtil.getLocalIpAddress(context)
)
// 始终更新 latestData,保证最新一帧不丢失
latestData[address] = data
val lastPush = lastPushTime[address] ?: 0L
if (now - lastPush >= THROTTLE_MS) {
// 窗口已过:立即推送,并取消可能存在的延迟任务
lastPushTime[address] = now
pendingPushTasks.remove(address)?.cancel(false)
broadcast(gson.toJson(data))
} else if (pendingPushTasks[address] == null) {
// 窗口内首次触发:安排 trailing 推送,保证窗口末尾推最新值
val delay = THROTTLE_MS - (now - lastPush)
if (!scheduler.isShutdown) {
pendingPushTasks[address] = scheduler.schedule({
val latest = latestData[address] ?: return@schedule
lastPushTime[address] = System.currentTimeMillis()
pendingPushTasks.remove(address)
broadcast(gson.toJson(latest))
}, delay, TimeUnit.MILLISECONDS)
}
}
// 窗口内且已安排过 trailing 任务:仅更新 latestData,等 trailing 触发时推送
}
}
@@ -77,6 +113,8 @@ class ScaleWebSocketServer(private val deviceId: String, private val context: Co
WeightUtil.removeWeightListener(TAG)
onConnectionChanged = null
onSeasoningConfig = null
onClearData = null
scheduler.shutdownNow()
try {
server?.stop(1000)
} catch (e: Exception) {
@@ -85,6 +123,8 @@ class ScaleWebSocketServer(private val deviceId: String, private val context: Co
server = null
latestData.clear()
lastPushTime.clear()
pendingPushTasks.values.forEach { it.cancel(false) }
pendingPushTasks.clear()
Log.d(TAG, "WebSocket 服务端已停止")
}
@@ -117,10 +157,16 @@ class ScaleWebSocketServer(private val deviceId: String, private val context: Co
private inner class InternalServer(port: Int) :
WebSocketServer(InetSocketAddress(port)) {
/** 当前活跃连接计数,用于替代 connections.isNullOrEmpty() 避免竞态 */
private val connectionCount = AtomicInteger(0)
override fun onOpen(conn: WebSocket, handshake: ClientHandshake) {
connectionCount.incrementAndGet()
Log.d(TAG, "主设备已连接: ${conn.remoteSocketAddress}")
onConnectionChanged?.invoke(true)
// 新客户端连接后,立即推送所有秤的最新数据
if (latestData.isNotEmpty()) {
latestData.values.forEach { data ->
try {
conn.send(gson.toJson(data))
@@ -128,12 +174,30 @@ class ScaleWebSocketServer(private val deviceId: String, private val context: Co
Log.w(TAG, "初始推送失败: ${e.message}")
}
}
} else {
// latestData 为空说明刚重启,WeightUtil 还未产生读数
// 延迟 3 秒后补推一次,确保主设备能看到本机秤
Log.d(TAG, "latestData 为空,${RETRY_PUSH_DELAY_MS}ms 后补推")
scheduler.schedule({
if (conn.isOpen && latestData.isNotEmpty()) {
latestData.values.forEach { data ->
try {
conn.send(gson.toJson(data))
Log.d(TAG, "补推数据: address=${data.address}, weight=${data.weight}")
} catch (e: Exception) {
Log.w(TAG, "补推失败: ${e.message}")
}
}
}
}, RETRY_PUSH_DELAY_MS, TimeUnit.MILLISECONDS)
}
}
override fun onClose(conn: WebSocket, code: Int, reason: String, remote: Boolean) {
Log.d(TAG, "主设备已断开: ${conn.remoteSocketAddress}, reason=$reason")
// 若已无任何连接,通知子设备 UI 更新为未连接状
if (server?.connections.isNullOrEmpty()) {
// 用计数器判断是否还有活跃连接,避免依赖 connections 集合的移除时机导致竞
if (connectionCount.decrementAndGet() <= 0) {
connectionCount.set(0)
onConnectionChanged?.invoke(false)
}
}
@@ -146,6 +210,7 @@ class ScaleWebSocketServer(private val deviceId: String, private val context: Co
val event = gson.fromJson(message, ScaleEvent::class.java)
when (event.type) {
ScaleEvent.TYPE_SEASONING_CONFIG -> onSeasoningConfig?.invoke(event)
ScaleEvent.TYPE_CLEAR_DATA -> onClearData?.invoke(event)
}
return
}
@@ -13,10 +13,12 @@ import java.util.concurrent.TimeUnit
* UDP 广播发送器(子设备运行)
* 每隔固定间隔向局域网广播本机的 deviceId、IP、WebSocket 端口
* 作为 mDNS 的兜底发现机制,确保主设备能可靠发现子设备
*
* @param ipProvider 动态获取本机 IP 的函数,每次广播时调用;返回空字符串时跳过本次广播
*/
class UdpBroadcastSender(
private val deviceId: String,
private val localIp: String,
private val ipProvider: () -> String,
private val wsPort: Int = MdnsRegisterManager.WS_PORT
) {
@@ -43,12 +45,18 @@ class UdpBroadcastSender(
task = scheduler.scheduleWithFixedDelay({
sendBroadcast()
}, 0, INTERVAL_SEC, TimeUnit.SECONDS)
Log.d(TAG, "UDP 广播已启动, deviceId=$deviceId, ip=$localIp, port=$wsPort")
Log.d(TAG, "UDP 广播已启动, deviceId=$deviceId, port=$wsPort")
}
private fun sendBroadcast() {
try {
val payload = gson.toJson(Payload(deviceId, localIp, wsPort))
val ip = ipProvider()
// 网络未就绪时跳过本次广播,等待下一个周期重试
if (ip.isEmpty()) {
Log.d(TAG, "IP 未就绪,跳过本次广播")
return
}
val payload = gson.toJson(Payload(deviceId, ip, wsPort))
val data = payload.toByteArray(Charsets.UTF_8)
DatagramSocket().use { socket ->
socket.broadcast = true
@@ -19,14 +19,16 @@ 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.model.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.dp
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
import kotlin.collections.getOrNull
/**
* 已采集食材列表页面
@@ -45,14 +47,31 @@ class CollectedFoodActivity : BaseActivity() {
isStateViewEnable = true
// 点击删除按钮,移除对应食材
addOnItemChildClickListener(R.id.ivDelete) { _, _, position ->
showDeleteDialog(foodList[position].foodName)
val item = foodList.getOrNull(position) ?: return@addOnItemChildClickListener
showDeleteDialog(item.foodName)
}
setOnItemClickListener { _, view, _ ->
KeyboardUtil.hideKeyboard(view)
setOnItemClickListener { _, _, _ ->
hideKeyboard()
}
}
}
/**
* 清除所有采集数据确认弹窗
*/
private fun showClearAllDialog() {
CommonDialog(this)
.setTitle("清除提示")
.setContent("清除后无法恢复,确认要清除所有采集数据吗?")
.setNegativeButton("取消")
.setPositiveButton("确认") {
dbViewModel.clearAllCollectedFood {
toast("已清除所有采集数据")
}
}
.show()
}
/**
* 删除弹窗
*/
@@ -69,7 +88,7 @@ class CollectedFoodActivity : BaseActivity() {
.setContent(content)
.setNegativeButton("取消")
.setPositiveButton("确认") {
appViewModel.removeCollectedFood(
dbViewModel.removeCollectedFood(
foodName = foodName,
nameFilter = binding.editSearch.text.toString()
)
@@ -84,17 +103,22 @@ class CollectedFoodActivity : BaseActivity() {
setContentView(binding.root)
setHeaderBackground()
binding.root.setOnClickListener { v ->
KeyboardUtil.hideKeyboard(v)
hideKeyboard()
}
setTitleBar(titleBarAction = {
it.visible()
it.setOnClickListener { v ->
KeyboardUtil.hideKeyboard(v)
hideKeyboard()
}
}, titleAction = {
it.text = "已采集食材"
}, rightIconActon = {
it.gone()
}, rightIconAction = {
it.visible()
it.setPaddingRelative(25.dp ,0 ,25.dp ,0)
it.setImageResource(R.drawable.ic_trash_white)
it.setOnClickListener {
showClearAllDialog()
}
}, backAction = {
it.visible()
it.setOnClickListener {
@@ -108,7 +132,7 @@ class CollectedFoodActivity : BaseActivity() {
}
private fun loadCollectedFoodList(name: String = "") {
appViewModel.loadCollectedFoodList(nameFilter = name)
dbViewModel.loadCollectedFoodList(nameFilter = name)
}
/**
@@ -118,7 +142,7 @@ class CollectedFoodActivity : BaseActivity() {
private fun initObserver() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
appViewModel.collectedFoodListState.collect { items ->
dbViewModel.collectedFoodListState.collect { items ->
foodList.clear()
foodList.addAll(items)
adapter.notifyDataSetChanged()
@@ -156,7 +180,7 @@ class CollectedFoodActivity : BaseActivity() {
performSearch {
toast(binding.editSearch.hint.toString())
}
KeyboardUtil.hideKeyboard(v)
hideKeyboard()
}
binding.editSearch.run {
addTextChangedListener {
@@ -169,7 +193,7 @@ class CollectedFoodActivity : BaseActivity() {
performSearch {
toast(binding.editSearch.hint.toString())
}
KeyboardUtil.hideKeyboard(v)
hideKeyboard()
true
} else {
false
@@ -0,0 +1,210 @@
package com.shuwei.dish.match.ui
import android.content.Intent
import android.os.Bundle
import androidx.activity.addCallback
import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import com.shuwei.dish.match.R
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.databinding.ActivityCookingModeBinding
import com.shuwei.dish.match.dialog.CommonDialog
import com.shuwei.dish.match.ui.fragment.FoodListFragment
import com.shuwei.dish.match.utils.KeyboardUtil
import com.shuwei.dish.match.utils.ext.addOnActionSearchListener
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.startActivity
import com.shuwei.dish.match.utils.ext.toast
import com.shuwei.dish.match.utils.ext.visible
/**
* 制作模式
*/
class CookingModeActivity : BaseActivity() {
companion object {
const val IS_COOKING = "isCooking"
const val DINNER_TYPE = "dinnerType"
const val IS_CONFIG_PAGE = "isConfigPage"
}
private val binding by lazy { ActivityCookingModeBinding.inflate(layoutInflater) }
private var dinnerType: String = "1"
private val fragmentList = mutableListOf<FoodListFragment>().apply {
add(FoodListFragment.instance("1"))
add(FoodListFragment.instance("2"))
add(FoodListFragment.instance("3"))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
setHeaderBackground()
setTitleBar(titleBarAction = {
it.visible()
}, titleAction = {
it.text = "选择菜品"
}, rightIconAction = {
it.visible()
it.setImageResource(R.drawable.ic_setting)
it.setOnClickListener {
startActivity<SettingActivity>()
}
}, backAction = {
it.gone()
})
addViewListener()
loadFragment()
onBackPressedDispatcher.addCallback(this) {}
}
fun getCookFoodList() {
dbViewModel.getCookFoodList(cookMode = 0, dinnerType = dinnerType)
}
private var configFinished = false
fun judgeDeviceConfig(block: () -> Unit) {
// if (configFinished) {
// block()
// return
// }
// dbViewModel.loadSeasoning {
// if (it.isEmpty()) {
// showDeviceConfigDialog()
// return@loadSeasoning
// }
// configFinished = true
// block()
// }
block()
}
private fun showDeviceConfigDialog() {
CommonDialog(this)
.setTitle("配置提示")
.setContent("请先在设备配置页面进行调料设置")
.setNegativeButton("取消")
.setPositiveButton("去设置") { startActivity<SettingActivity>() }
.show()
}
/** 切换餐次时清空输入框,用此标志跳过 afterTextChanged 的查询触发 */
private var isClearingByTabSwitch = false
private fun addViewListener() {
binding.tvBreakfast.setOnClickListener { selectTab("1") }
binding.tvLunch.setOnClickListener { selectTab("2") }
binding.tvDinner.setOnClickListener { selectTab("3") }
binding.etInputDish.addOnActionSearchListener {
jumpSearch()
}
binding.ivDishSearch.setOnClickListener {
jumpSearch()
}
// 输入框清空时自动查询全部数据(切换餐次触发的清空除外,由 loadFragment 负责刷新)
binding.etInputDish.addTextChangedListener(afterTextChanged = { text ->
if (!isClearingByTabSwitch && text.isNullOrBlank()) {
currentFragment()?.getDishList(pageNo = 1, dinnerType = dinnerType)
}
})
}
/**
* 切换餐次 Tab:更新选中态、清空搜索框、切换 Fragment
* 由点击事件调用,不触发额外的 getDishList(),由 onHiddenChanged 负责刷新
*/
private fun selectTab(type: String) {
if (dinnerType == type) return
dinnerType = type
binding.tvBreakfast.isChecked = type == "1"
binding.tvLunch.isChecked = type == "2"
binding.tvDinner.isChecked = type == "3"
clearSearchText()
loadFragment()
}
/** 获取当前显示的 FoodListFragment */
private fun currentFragment(): FoodListFragment? {
return runCatching { fragmentList[dinnerType.toInt() - 1] }.getOrNull()
}
private fun jumpSearch() {
val searchText = binding.etInputDish.text.toString().trim()
if (searchText.isBlank()) {
toast(binding.etInputDish.hint.toString())
return
}
currentFragment()?.getDishList(pageNo = 1, dinnerType = dinnerType, foodName = searchText)
hideKeyboard()
}
private fun loadFragment() {
runCatching {
val target = fragmentList[dinnerType.toInt() - 1]
supportFragmentManager.beginTransaction().apply {
fragmentList.forEach { fragment ->
if (fragment.isAdded) {
if (fragment == target) show(fragment) else hide(fragment)
} else {
if (fragment == target) add(R.id.flSubPage, fragment)
}
}
commit()
}
}.onFailure {
it.printStackTrace()
}
}
fun deleteCookFoodAndGoods(foodId: String, dinnerType: String, action: () -> Unit) {
lifecycleScope.launch {
dbViewModel.deleteCookFoodAndGoods(cookMode = 0, foodId = foodId, dinnerType = dinnerType)
action()
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
runCatching {
val isConfigPage = intent.getBooleanExtra(IS_CONFIG_PAGE, false)
if (isConfigPage) {
return
}
val lastDinnerType = intent.getStringExtra(DINNER_TYPE) ?: "0"
if (lastDinnerType !in listOf("1", "2", "3")) return
if (lastDinnerType == dinnerType) {
// 同一 TabonHiddenChanged 不会触发,直接刷新
clearSearchText()
currentFragment()?.getDishList(1, lastDinnerType)
} else {
// 不同 TabselectTab → loadFragment → show → onHiddenChanged 负责刷新,无需直接调用
selectTab(lastDinnerType)
}
}.onFailure {
it.printStackTrace()
}
}
private var isVisible = false
override fun onResume() {
super.onResume()
isVisible = true
}
override fun onPause() {
super.onPause()
isVisible = false
}
fun clearSearchText() {
isClearingByTabSwitch = true
binding.etInputDish.text?.clear()
isClearingByTabSwitch = false
}
}
@@ -77,7 +77,7 @@
// it.visible()
// }, titleAction = {
// it.text = "设备配置"
// }, rightIconActon = {
// }, rightIconAction = {
// it.gone()
//// it.alpha = 0.0F
//// it.setImageResource(R.drawable.ic_setting)
@@ -1,291 +0,0 @@
package com.shuwei.dish.match.ui
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.View
import androidx.activity.addCallback
import androidx.activity.viewModels
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import com.shuwei.dish.match.R
import com.shuwei.dish.match.adapter.DishPartAdapter
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.databinding.ActivityDishSamplingBinding
import com.shuwei.dish.match.dialog.FoodSearchDialog
import com.shuwei.dish.match.dialog.CommonDialog
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.net.NetViewModel
import com.shuwei.dish.match.utils.AddressUtil
import com.shuwei.dish.match.utils.KeyboardUtil
import com.shuwei.dish.match.utils.SwipeCallback
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.gone
import com.shuwei.dish.match.utils.ext.startActivity
import com.shuwei.dish.match.utils.ext.toJsonString
import com.shuwei.dish.match.utils.ext.toast
import com.shuwei.dish.match.utils.ext.visible
import java.io.Serializable
class DishSamplingActivity : BaseActivity() {
private lateinit var binding: ActivityDishSamplingBinding
var foodName: String? = null
private var foodId: String? = null
companion object {
private const val TAG = "DishSamplingActivity"
public const val FOOD_NAME = "foodName"
public const val PAGE_FROM = "pageFrom"
public const val HOME = "home"
}
private var pageFrom: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityDishSamplingBinding.inflate(layoutInflater)
setContentView(binding.root)
pageFrom = intent.getStringExtra(PAGE_FROM)
setTitleBar()
setHeaderBackground()
addViewClickListener()
foodName = intent.getStringExtra(FOOD_NAME)
binding.etInputDish.setText(foodName)
WeightUtil.addWeightListener(
weightKey = TAG,
getWeight = { address, state, weight ->
//Log.d(TAG, "addViewClickListener: address=$address,state=$state,weight=$weight")
if (address == AddressUtil.ONE) {
tempDishEntity?.useWeight = weight.toDouble()
binding.tvDishPartWeight.text = "${weight}"
}
})
addBackKeyListener()
}
private fun setTitleBar() {
setTitleBar(titleBarAction = {
it.visible()
}, titleAction = {
it.text = "菜品采集"
}, rightIconActon = {
it.gone()
it.setImageResource(R.drawable.ic_setting)
it.setOnClickListener {
//startActivity<DeviceSettingActivity>()
}
}, backAction = {
it.visible()
it.setOnClickListener {
onBackPressedDispatcher.onBackPressed()
}
})
}
fun getCookFoodList() {
appViewModel.getCookFoodList(cookMode = 1)
}
/**
* 监听返回键
*/
fun addBackKeyListener() {
onBackPressedDispatcher.addCallback(this) {
if (list.isEmpty().not()) {
//说明有新增的食材
saveDataRemindDialog()
return@addCallback
}
if (pageFrom == HOME) {
startActivity<SamplingListActivity>()
}
onBackPressedDispatcher.onBackPressed()
}
}
private fun saveDataRemindDialog() {
CommonDialog(this)
.setTitle("返回提示")
.setContent("您好,当前页面存在未保存的数据,\n确认返回吗?")
.setNegativeButton("取消")
.setPositiveButton("确认") {
if (pageFrom == HOME) {
startActivity<SamplingListActivity>()
}
finish()
}
.setOnDismissCallback { hideStatusBar() }
.show()
}
val list: MutableList<CookFoodGoodsEntity> = mutableListOf()
private var tempDishEntity: CookFoodGoodsEntity? = null
private var dishMaterialType = 1
private val dishAdapter by lazy {
DishPartAdapter(list).apply {
addOnItemChildClickListener(R.id.ivOperateIcon) { _, _, position ->
//删除操作
list.removeAt(position)
notifyItemRemoved(position)
notifyItemRangeChanged(position, list.size - position)
}
}
}
@SuppressLint("NotifyDataSetChanged")
private fun addViewClickListener() {
binding.rvDishPartList.run {
layoutManager =
LinearLayoutManager(this@DishSamplingActivity, LinearLayoutManager.VERTICAL, false)
adapter = dishAdapter
val itemTouchHelper = ItemTouchHelper(SwipeCallback(dishAdapter) { position ->
list.removeAt(position)
dishAdapter.notifyItemRemoved(position)
dishAdapter.notifyItemRangeChanged(position, list.size - position)
toast("已删除")
})
itemTouchHelper.attachToRecyclerView(this)
}
binding.etInputDishType.addOnActionSearchListener {
searchDishType()
}
binding.ivDishTypeSearch.setOnClickListener {
searchDishType()
}
binding.ivWeightClear.setOnClickListener {
WeightUtil.tareTwo(AddressUtil.ONE)
}
binding.ivWeightAdd.setOnClickListener {
if (tempDishEntity == null) {
toast("请输入食材名称")
return@setOnClickListener
}
if (binding.rgSampling.checkedRadioButtonId == -1) {
toast("请选择主辅材类型")
return@setOnClickListener
}
val realWeight = tempDishEntity!!.useWeight ?: 0.0
if (realWeight <= 0.toDouble()) {
toast("食材用量需要大于0")
return@setOnClickListener
}
if (tempDishEntity?.goodsName != binding.etInputDishType.text.toString().trim()) {
toast("所选择的食材名称与输入框内容不一致")
return@setOnClickListener
}
tempDishEntity!!.apply {
isSamplingPage = true
materialType = dishMaterialType
}
val result = list.firstOrNull { it.goodsId == tempDishEntity!!.goodsId }
if (result != null) {
toast("${result.goodsName}食材已添加,请勿重复操作")
return@setOnClickListener
}
list.add(tempDishEntity!!)
dishAdapter.notifyDataSetChanged()
tempDishEntity = null
binding.etInputDishType.setText("")
binding.rgSampling.clearCheck()
}
binding.rgSampling.setOnCheckedChangeListener { group, checkedId ->
dishMaterialType = if (checkedId == R.id.rbDishTypeFirst) 1 else 2
}
binding.ivDishSearch.setOnClickListener { v ->
jumpSearch(v)
}
binding.btnCook.clickWithDebounce {
val showFoodName = binding.etInputDish.text.toString().trim()
if (showFoodName.isBlank()) {
toast("菜品名称为空")
return@clickWithDebounce
}
if (list.isEmpty()) {
toast("菜品构成信息未设置")
return@clickWithDebounce
}
val food = FoodRecord()
binding.etInputDish.let {
if (it.tag != null && it.tag.toString() == it.text.toString()) {
food.foodId = foodId
}
}
food.foodName = showFoodName
food.cookMode = 1
Log.d(TAG, "goToSubmit: goodsList:${list.toJsonString()}")
// startActivity<SubmitDishActivity> {
startActivity<SubmitFoodActivity> {
putExtra(SubmitFoodActivity.GOODS_LIST, list as java.io.Serializable)
putExtra(SubmitFoodActivity.FOOD_ITEM, food as Serializable)
}
}
binding.etInputDish.let { v ->
v.addOnActionSearchListener {
jumpSearch(v)
}
}
}
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)
foodName = it?.getStringExtra(FoodSearchActivity.FOOD_NAME)
binding.etInputDish.run {
setText(foodName)
tag = foodName
setSelection(text.length)
}
}
KeyboardUtil.hideKeyboard(v)
}
private fun searchDishType() {
val inputText = binding.etInputDishType.text.toString().trim()
FoodSearchDialog(
activity = this@DishSamplingActivity,
defGoodsName = inputText
) { item ->
val filterResult = list.firstOrNull { it.goodsName == item.goodsName }
if (filterResult != null) {
toast("不允许重复添加同一食材")
return@FoodSearchDialog
}
binding.etInputDishType.run {
setText(item.goodsName)
setSelection(length())
}
tempDishEntity = CookFoodGoodsEntity().apply {
goodsId = item.goodsId
goodsName = item.goodsName
relateionType = item.relateionType ?: 0
goodsOrRelationCode = item.goodsOrRelationCode
isNewDishType = true
// TODO: 待定
//allEdible = item.allEdible
}
}.show()
}
}
@@ -7,17 +7,28 @@ 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.adapter.FoodAdapter
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.databinding.ActivityFoodRecognizeBinding
import com.shuwei.dish.match.dialog.CommonDialog
import com.shuwei.dish.match.dialog.FoodSearchDialog
import com.shuwei.dish.match.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.model.GoodsItem
import com.shuwei.dish.match.model.GoodsNameQueryDTO
import com.shuwei.dish.match.utils.ActivityManager
import com.shuwei.dish.match.utils.AddressUtil
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
import androidx.core.view.updateLayoutParams
import androidx.lifecycle.lifecycleScope
import com.shuwei.dish.match.net.UiState
import com.shuwei.dish.match.objbox.FoodModule
import com.shuwei.dish.match.utils.ext.dp
import com.shuwei.dish.match.utils.ext.toast
import com.shuwei.dish.match.utils.showRawMaterialsDialog
import kotlinx.coroutines.launch
/**
* 食材识别结果页面
@@ -29,30 +40,32 @@ class FoodRecognizeActivity : BaseActivity() {
private const val TAG = "FoodRecognizeActivity"
private const val EXTRA_IMAGE_URI = "extra_image_uri"
private const val EXTRA_CURRENT_WEIGHT = "extra_current_weight"
private const val EXTRA_INITIAL_WEIGHT = "extra_initial_weight"
const val EXTRA_SELECTED_ITEM = "extra_selected_item"
const val IS_MANUAL_CANCEL = "isManualCancel"
/** 内存缓存食材列表,避免通过 Intent 序列化传递大数据 */
private var pendingGoodsList: ArrayList<CookFoodGoodsEntity>? = null
private var recognizeNameList: ArrayList<FoodModule.IdNameScore>? = null
/**
* 启动本页面,通过 onResult 回调返回用户选中的食材
* @param activity 宿主 Activity
* @param imageUri 食材照片路径(可为空)
* @param goodsList 识别到的食材列表
* @param nameScoreList 识别到的食材列表
* @param finishCallback 页面关闭后回调,携带用户选中的食材(未选中时为 null)
*/
fun start(
activity: BaseActivity,
imageUri: String? = null,
currentWeight: Double = 0.0,
goodsList: ArrayList<CookFoodGoodsEntity> = arrayListOf(),
finishCallback: (Boolean, CookFoodGoodsEntity?) -> Unit = { _, _ -> }
initialWeight: Double = 0.0,
nameScoreList: ArrayList<FoodModule.IdNameScore> = arrayListOf(),
finishCallback: (Boolean, GoodsItem?) -> Unit = { _, _ -> }
) {
pendingGoodsList = goodsList
recognizeNameList = nameScoreList
val launchIntent = Intent(activity, FoodRecognizeActivity::class.java).apply {
putExtra(EXTRA_IMAGE_URI, imageUri)
putExtra(EXTRA_CURRENT_WEIGHT, currentWeight)
putExtra(EXTRA_INITIAL_WEIGHT, initialWeight)
}
activity.startActivity(launchIntent) { resultIntent ->
val isManualCancel = resultIntent?.getBooleanExtra(IS_MANUAL_CANCEL, false) ?: false
@@ -60,7 +73,8 @@ class FoodRecognizeActivity : BaseActivity() {
IntentCompat.getSerializableExtra(
it,
EXTRA_SELECTED_ITEM,
CookFoodGoodsEntity::class.java)
GoodsItem::class.java
)
}
finishCallback(isManualCancel, entity)
}
@@ -80,37 +94,72 @@ class FoodRecognizeActivity : BaseActivity() {
private lateinit var binding: ActivityFoodRecognizeBinding
private val list = mutableListOf<CookFoodGoodsEntity>()
private val list = mutableListOf<GoodsItem>()
/** 当前选中的列表位置,-1 表示无选中 */
private var selectedPosition = -1
/** 秤1当前实时重量 */
/** 秤1当前实时净重(已减去初始读数) */
private var currentWeight = 0.0
/** 重量减少时延迟更新的任务,页面关闭前取消可防止用错误重量 */
private var pendingWeightRunnable: Runnable? = null
/** 初始秤读数,净重 = 秤读数 - initialWeight */
private var initialWeight = 0.0
/** 当前待延迟更新的目标重量,避免相同重量重复重置计时器 */
private var pendingWeight = 0.0
/**
* 滑动窗口:保存最近 10 条稳定帧净重,用于关闭页面时判断数据是否稳定
* 超过 10 条时移除队头,保持最新数据
*/
private val weightWindow = ArrayDeque<Double>(10)
/** 右侧自定义滚动指示条最小高度 */
private val scrollIndicatorMinHeightPx by lazy { (24 * resources.displayMetrics.density).toInt() }
/** 食材搜索弹窗,持有引用防止重复打开 */
private var foodSearchDialog: FoodSearchDialog? = null
/** 数据不稳定提示弹窗,持有引用通过 isShowing 防止重复弹出 */
private var unstableDialog: CommonDialog? = null
/** 通过name查询IdNameScore */
private val nameScoreMap = mutableMapOf<String, FoodModule.IdNameScore>()
/** 识别物品名称列表(名称 + 净材种类,用于批量查询接口) */
private val goodsNameList = mutableListOf<GoodsNameQueryDTO>()
private val scrollListener = object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
updateScrollIndicator()
}
}
private val adapter = GoodsInfoSearchAdapter(list).apply {
private val adapter = FoodAdapter(list).apply {
setOnItemClickListener { _, _, position ->
list.forEachIndexed { i, item -> item.isClicked = (i == position) }
// rawMaterialsRemind(position)
val item = list.getOrNull(position) ?: return@setOnItemClickListener
list.forEachIndexed { i, it -> it.isClicked = (i == position) }
// 点击选择时记录当前秤重量
list[position].useWeight = currentWeight
item.useWeight = currentWeight
notifyDataSetChanged()
selectedPosition = position
binding.tvSelectedFood.text = list[position].goodsName ?: "-"
binding.tvSelectedFood.text = item.goodsName ?: "-"
}
}
private fun rawMaterialsRemind(position: Int) {
val item = list.getOrNull(position) ?: return
// 弹出净材种类单选弹窗,确认后再选中该食材
showRawMaterialsDialog(
context = this@FoodRecognizeActivity,
types = item.rawMaterialsTypes,
checkedType = item.rawMaterialsType
) { selectedType ->
item.rawMaterialsType = selectedType
list.forEachIndexed { i, it -> it.isClicked = (i == position) }
// 点击选择时记录当前秤重量
item.useWeight = currentWeight
adapter.notifyDataSetChanged()
selectedPosition = position
binding.tvSelectedFood.text = item.goodsName ?: "-"
}
}
@@ -131,14 +180,15 @@ class FoodRecognizeActivity : BaseActivity() {
}
}
)
initData()
initRecyclerView()
updateRvParentLayoutHeight()
initClickListeners()
onBackPressedDispatcher.addCallback(this) {
manualBack()
}
initObserver()
initData()
}
/**
@@ -150,33 +200,117 @@ class FoodRecognizeActivity : BaseActivity() {
/**
* 从 Intent 读取数据并初始化页面
* Reads data from Intent and initializes the page
*/
private fun initData() {
// 加载食材照片
// Load food photo
val imageUri = intent.getStringExtra(EXTRA_IMAGE_URI)
if (!imageUri.isNullOrBlank()) {
binding.ivFoodPhoto.setImageURI(imageUri.toUri())
}
currentWeight = intent.getDoubleExtra(EXTRA_CURRENT_WEIGHT, 0.0)
initialWeight = intent.getDoubleExtra(EXTRA_INITIAL_WEIGHT, 0.0)
binding.tvWeight.text = "${currentWeight}g"
recognizeNameList?.apply {
forEach {
val goodsName = it.name.split("WP").first()
val rawMaterialsType = it.rawMaterialsType
goodsNameList.add(GoodsNameQueryDTO(goodsName, rawMaterialsType))
nameScoreMap.put(goodsName, it)
}
recognizeNameList = null
}
// 取出内存缓存的食材列表,取用后立即清空
val goodsList = pendingGoodsList?.also { pendingGoodsList = null }
if (!goodsList.isNullOrEmpty()) {
list.addAll(goodsList)
if (goodsNameList.isNotEmpty()) {
netViewModel.queryGoodsList(goodsType = "0", goodsNames = goodsNameList)
} else {
loadRecognizeList(emptyList())
}
}
private fun buildTestData(item: FoodModule.IdNameScore): GoodsItem {
var foodName = ""
var foodId = ""
item.name.split("WP").let {
foodName = it[0]
foodId = if (it.size > 1) {
"WP${it[1]}"
} else {
item.name
}
}
return GoodsItem(
goodsId = foodId,
goodsName = foodName
).also {
it.foodScore = ((1 - item.score) * 10000).toInt()
it.isOriginalData = false
}
}
private fun initObserver() {
lifecycleScope.launch {
netViewModel.goodsListState.collect { state ->
when (state) {
is UiState.Loading -> {
showLoading()
}
is UiState.Success -> {
delayDismissLoading()
loadRecognizeList(state.data)
}
is UiState.Error -> {
delayDismissLoading()
toast(state.msg)
}
is UiState.Idle -> {}
}
}
}
}
private fun loadRecognizeList(goodsList: List<GoodsItem>?) {
if (goodsList.isNullOrEmpty()) {
binding.flListContainer.updateLayoutParams {
height = 260.dp
}
binding.rvRecognizeList.gone()
binding.tvNoData.visible()
return
}
binding.rvRecognizeList.visible()
binding.tvNoData.gone()
list.clear()
goodsList.forEach { goods ->
var foodScore = -1
nameScoreMap[goods.goodsName]?.let { food ->
foodScore = ((1 - food.score) * 10000).toInt()
}
//保存原goodsId
goods.oldGoodsId = goods.goodsId
//PrepareFoodActivity中的goodsId就是materId
goods.goodsId = goods.materId ?: ""
list.add(goods.also { it.foodScore = foodScore })
}
adapter.notifyDataSetChanged()
updateRvParentLayoutHeight()
// 默认选中第一项
selectedPosition = 0
list[0].isClicked = true
list[0].useWeight = currentWeight
binding.tvSelectedFood.text = list[0].goodsName ?: "-"
}
}
/**
* 初始化 RecyclerView
*/
private fun initRecyclerView() {
binding.recyclerView.let {
binding.rvRecognizeList.let {
it.layoutManager = GridLayoutManager(this@FoodRecognizeActivity, 2)
it.adapter = adapter
it.isVerticalScrollBarEnabled = false
@@ -187,15 +321,21 @@ class FoodRecognizeActivity : BaseActivity() {
updateListVisibility()
}
/**
* 更新RecyclerView父布局高度
*/
private fun updateRvParentLayoutHeight() {
binding.flListContainer.updateLayoutParams {
height = if (adapter.items.size > 2) 260.dp else 130.dp
}
}
/**
* 根据列表是否有数据控制 RecyclerView 显隐
*/
private fun updateListVisibility() {
if (list.isEmpty()) {
binding.flListContainer.gone()
} else {
binding.flListContainer.visible()
binding.recyclerView.post { updateScrollIndicator() }
if (list.isNotEmpty()) {
binding.rvRecognizeList.post { updateScrollIndicator() }
}
}
@@ -203,7 +343,7 @@ class FoodRecognizeActivity : BaseActivity() {
* 根据 RecyclerView 滚动状态刷新右侧自定义指示条
*/
private fun updateScrollIndicator() {
val recyclerView = binding.recyclerView
val recyclerView = binding.rvRecognizeList
val track = binding.vScrollIndicatorTrack
val thumb = binding.vScrollIndicatorThumb
@@ -247,14 +387,15 @@ class FoodRecognizeActivity : BaseActivity() {
* 初始化按钮点击事件
*/
private fun initClickListeners() {
// 更换食材:打开食材搜索弹窗
// 更换食材:打开食材搜索弹窗,已打开时跳过
binding.btnChangeFood.clickWithDebounce {
val currentName = list.getOrNull(selectedPosition)?.goodsName
FoodSearchDialog(
// val currentName = list.getOrNull(selectedPosition)?.goodsName
if (foodSearchDialog?.isShowing == true) return@clickWithDebounce
foodSearchDialog = FoodSearchDialog(
activity = this,
defGoodsName = currentName,
// defGoodsName = currentName,
onItemSelected = { item -> updateSelectedFood(item) }
).show()
).also { it.show() }
}
// 取消:关闭页面
@@ -265,7 +406,7 @@ class FoodRecognizeActivity : BaseActivity() {
* 用新食材替换当前选中项(或追加到列表首位)
*/
@SuppressLint("NotifyDataSetChanged")
private fun updateSelectedFood(item: CookFoodGoodsEntity) {
private fun updateSelectedFood(item: GoodsItem) {
val existIndex = list.indexOfFirst { it.goodsId == item.goodsId }
if (existIndex >= 0) {
list.forEachIndexed { i, e -> e.isClicked = (i == existIndex) }
@@ -276,9 +417,14 @@ class FoodRecognizeActivity : BaseActivity() {
selectedPosition = 0
}
adapter.notifyDataSetChanged()
if (list.isNotEmpty()) {
binding.rvRecognizeList.visible()
binding.tvNoData.gone()
}
updateRvParentLayoutHeight()
binding.tvSelectedFood.text = item.goodsName ?: "-"
updateListVisibility()
binding.recyclerView.post { updateScrollIndicator() }
binding.rvRecognizeList.post { updateScrollIndicator() }
}
/**
@@ -287,58 +433,87 @@ class FoodRecognizeActivity : BaseActivity() {
* 低于阈值说明食材已被取走,保留上次有效值等待页面关闭
*/
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) }
// }
// }
// }
// }
// }
// }
WeightUtil.addWeightListener(TAG) { address, state, weight ->
if (address == AddressUtil.ONE && state == WeightUtil.STATE_STABLE) {
runOnUiThread {
// 净重 = 秤读数 - 初始读数
val netWeight = weight - initialWeight
currentWeight = netWeight
binding.tvWeight.run {
if (tag != netWeight) {
text = "${netWeight}g"
tag = netWeight
}
}
// 入队,超过 10 条移除队头
if (weightWindow.size >= 10) weightWindow.removeFirst()
weightWindow.addLast(netWeight)
// 实时更新选中食材重量
list.getOrNull(selectedPosition)?.useWeight = netWeight
}
}
}
}
/**
* 取消待执行的延迟重量更新任务
* 从滑动窗口尾部向前查找连续稳定值:
* 跳过尾部不稳定的过渡帧,找到第一段连续相同且大于阈值的序列
* 连续条数 >= 3 时返回该值,否则返回 null(数据不稳定)
*/
private fun cancelPendingWeightUpdate() {
// pendingWeightRunnable?.let { handler.removeCallbacks(it) }
// pendingWeightRunnable = null
private fun getStableWeightFromWindow(): Double? {
val threshold = PrepareFoodActivity.WEIGHT_RECOGNIZE_VALUE
var i = weightWindow.indices.last
while (i >= 0) {
val candidate = weightWindow[i]
// 跳过低于阈值的过渡帧
if (candidate <= threshold) {
i--
continue
}
// 以 candidate 为基准,向前统计连续相同条数
var count = 0
var j = i
while (j >= 0 && weightWindow[j] == candidate) {
count++
j--
}
if (count >= 3) return candidate
// 不足 3 条,继续向前找
i = j
}
return null
}
/**
* 携带当前选中食材关闭页面
* @param isManualCancel true=用户手动返回;false=被动关闭(重量低于阈值)
*/
private fun finishWithResult(isManualCancel: Boolean) {
cancelPendingWeightUpdate()
val item = list.getOrNull(selectedPosition)
if (!isManualCancel && item != null) {
// 被动关闭:从窗口取稳定值
val stableWeight = getStableWeightFromWindow()
if (stableWeight == null) {
// 数据不稳定,不关闭页面,弹窗提示(通过 isShowing 防止重复弹出)
if (unstableDialog?.isShowing == true) return
if (unstableDialog == null) {
unstableDialog = CommonDialog(this)
.setTitle("提示")
.setContent("秤数据不稳定,请放置食材进行重新识别")
.setSingleButton("确认") {
finishWithResult(true)
}
}
unstableDialog?.show()
return
}
item.useWeight = stableWeight
}
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)
}
if (!isManualCancel) {
item?.let { putExtra(EXTRA_SELECTED_ITEM, it) }
}
}
setResult(RESULT_OK, intent)
@@ -347,7 +522,6 @@ class FoodRecognizeActivity : BaseActivity() {
override fun onDestroy() {
super.onDestroy()
cancelPendingWeightUpdate()
WeightUtil.removeWeightListener(TAG)
}
}
@@ -4,56 +4,46 @@ import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
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.FoodRecordAdapter
import com.shuwei.dish.match.adapter.Food2Adapter
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.databinding.ActivityFoodSearchBinding
import com.shuwei.dish.match.databinding.LayoutEmptyViewBinding
import com.shuwei.dish.match.entity.FoodRecord
import com.shuwei.dish.match.model.FoodRecord
import com.shuwei.dish.match.model.FoodSearchItem
import com.shuwei.dish.match.net.UiState
import com.shuwei.dish.match.utils.KeyboardUtil
import com.shuwei.dish.match.utils.ext.addOnActionSearchListener
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.startActivity
import com.shuwei.dish.match.utils.ext.toast
import com.shuwei.dish.match.utils.ext.visible
import java.io.Serializable
import kotlinx.coroutines.launch
import kotlin.collections.getOrNull
/**
* 菜品搜索页面(采样模式:搜索已有上架菜品作为称量模板)
* 数据源: POST /neglect/ratio-scale/food-search
*/
class FoodSearchActivity : BaseActivity() {
companion object {
private const val TAG = "FoodSearchActivity"
const val PAGE_TYPE = "pageType"
const val FOOD_ITEM = "foodItem"
const val FOOD_ID = "foodId"
const val FOOD_NAME = "foodName"
const val DINNER_TYPE = "dinnerType"
}
private lateinit var binding: ActivityFoodSearchBinding
private var list: MutableList<FoodRecord> = mutableListOf()
private var pageType = 0
private var dinnerType = "1"
private val recordAdapter by lazy {
FoodRecordAdapter(list).apply {
Food2Adapter(list).apply {
isStateViewEnable = true
setOnItemClickListener { adapter, view, position ->
if (pageType == 1) {
val jumpItem = list[position].also { it.dinnerType = dinnerType }
startActivity<PrepareCookActivity> {
putExtra(PrepareCookActivity.FOOD_ITEM, jumpItem as Serializable)
}
return@setOnItemClickListener
}
val jumpItem = list.getOrNull(position) ?: return@setOnItemClickListener
setResult(RESULT_OK, Intent().apply {
putExtra(FOOD_ID, list[position].foodId)
putExtra(FOOD_NAME, list[position].foodName)
putExtra(FOOD_ITEM, jumpItem as Serializable)
})
finish()
}
@@ -66,8 +56,6 @@ class FoodSearchActivity : BaseActivity() {
super.onCreate(savedInstanceState)
binding = ActivityFoodSearchBinding.inflate(layoutInflater)
setContentView(binding.root)
pageType = intent.getIntExtra(PAGE_TYPE, 0)
dinnerType = intent.getStringExtra(DINNER_TYPE) ?: "0"
setHeaderBackground()
foodName = intent.getStringExtra(FOOD_NAME)
binding.etInputDish.setText(foodName)
@@ -75,7 +63,7 @@ class FoodSearchActivity : BaseActivity() {
it.visible()
}, titleAction = {
it.text = "菜品搜索"
}, rightIconActon = {
}, rightIconAction = {
it.gone()
it.setImageResource(R.drawable.ic_setting)
it.setOnClickListener {
@@ -88,38 +76,13 @@ class FoodSearchActivity : BaseActivity() {
adapter = recordAdapter
}
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() {
binding.ivDishSearch.setOnClickListener { v ->
pageNo = 1
getInputAndSearch()
KeyboardUtil.hideKeyboard(v)
hideKeyboard()
}
binding.refreshLayout.setEnableRefresh(true)
binding.refreshLayout.setEnableLoadMore(false)
@@ -136,13 +99,13 @@ class FoodSearchActivity : BaseActivity() {
queryListInfo(foodName!!)
}
binding.root.setOnClickListener { v ->
KeyboardUtil.hideKeyboard(v)
hideKeyboard()
}
binding.etInputDish.let { v ->
v.addOnActionSearchListener {
pageNo = 1
getInputAndSearch()
KeyboardUtil.hideKeyboard( v)
hideKeyboard()
}
}
}
@@ -158,54 +121,65 @@ class FoodSearchActivity : BaseActivity() {
}
private var pageNo = 1
private var pageSize = 10
fun getDinnerTypeText(): String {
return when (dinnerType) {
"1" -> "早餐"
"2" -> "午餐"
"3" -> "晚餐"
else -> "早餐"
}
}
private var pageSize = 20
@SuppressLint("NotifyDataSetChanged")
private fun queryListInfo(input: String) {
val map = mutableMapOf<String, Any>(
"foodName" to input.trim(),
"pageNum" to pageNo,
"pageSize" to pageSize,
"placeId" to BaseApp.canteenId,
"dinnerType" to getDinnerTypeText()
)
netViewModel.searchFoodList(param = map)
}
/**
* 处理搜索结果,填充列表或展示空视图
*/
@SuppressLint("NotifyDataSetChanged")
private fun handleSearchResult(records: MutableList<FoodRecord>?) {
netViewModel.foodSearch(
keyword = input.trim(),
pageNum = pageNo,
pageSize = pageSize,
onLoading = { showLoading() },
onResult = { state ->
when (state) {
is UiState.Success -> handleSearchResult(state.data)
is UiState.Error -> {
delayDismissLoading()
finishRefresh()
binding.refreshLayout.setEnableRefresh(true)
if (records.isNullOrEmpty()) {
toast(state.msg)
if (pageNo == 1) loadEmptyView()
}
else -> {}
}
}
)
}
/**
* FoodSearchItem → FoodRecord 映射,供 Food2Adapter 展示
* 采样模式下返回给 PrepareFoodActivity 作为称量模板
*/
private fun FoodSearchItem.toFoodRecord(): FoodRecord = FoodRecord(
foodId = foodId,
foodName = foodName,
cookMode = 1
)
/**
* 处理搜索结果:FoodSearchItem → FoodRecord 填充列表
*/
@SuppressLint("NotifyDataSetChanged")
private fun handleSearchResult(items: List<FoodSearchItem>?) {
delayDismissLoading()
finishRefresh()
binding.refreshLayout.setEnableRefresh(true)
if (items.isNullOrEmpty()) {
if (pageNo == 1) {
loadEmptyView()
}
return
}
if (pageNo == 1) list.clear()
list.addAll(records)
list.addAll(items.map { it.toFoodRecord() })
recordAdapter.notifyDataSetChanged()
val isLoadMoreEnable = records.size >= pageSize
val isLoadMoreEnable = items.size >= pageSize
binding.refreshLayout.setEnableLoadMore(isLoadMoreEnable)
if (isLoadMoreEnable) pageNo++
}
private fun finishRefresh() {
if (pageNo == 1) {
binding.refreshLayout.finishRefresh(1200)
} else {
binding.refreshLayout.finishLoadMore(1200)
}
}
private var emptyViewBinding: LayoutEmptyViewBinding? = null
@@ -10,12 +10,16 @@ import com.shuwei.dish.match.adapter.HomeModeAdapter
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.databinding.ActivityHomeBinding
import com.shuwei.dish.match.entity.HomeModeBean
import com.shuwei.dish.match.model.HomeModeBean
import com.shuwei.dish.match.utils.SpTool
import com.shuwei.dish.match.utils.ext.startActivity
import com.shuwei.dish.match.utils.ext.toast
import kotlinx.coroutines.launch
import kotlin.collections.getOrNull
/**
* 主页,包括三种模式:制作模式、采样模式、品控模式
*/
class HomeActivity : BaseActivity() {
companion object {
@@ -39,12 +43,14 @@ class HomeActivity : BaseActivity() {
binding = ActivityHomeBinding.inflate(layoutInflater)
setContentView(binding.root)
setHeaderBackground(isHomePage = true)
// 根据当前 canteenId 恢复 RadioButton 选中状态
binding.radioGroup.check(
if (BaseApp.canteenId == "1678234139391512577") R.id.radioSw else R.id.radioZk
)
binding.radioGroup.setOnCheckedChangeListener { group, checkedId ->
if (checkedId == R.id.radioSw) {
BaseApp.canteenId = "1678234139391512577"
} else {
BaseApp.canteenId = "0"
}
val id = if (checkedId == R.id.radioSw) "1678234139391512577" else "0"
SpTool.canteenId = id
BaseApp.canteenId = id
}
onBackPressedDispatcher.addCallback(this) {}
// binding.radioGroup.check(R.id.radioZk)
@@ -60,18 +66,18 @@ class HomeActivity : BaseActivity() {
// startActivity<SamplingListActivity>()
// }
// appViewModel.deleteSeasoningBySort(12)
// appViewModel.deleteSeasoningBySort(11)
// appViewModel.deleteSeasoningBySort(13)
// appViewModel.deleteSeasoningBySort(14)
// appViewModel.deleteCookFoodAndGoods(0,"") {}
// appViewModel.getCookFoodGoodsList("") {goodsList->
// dbViewModel.deleteSeasoningBySort(12)
// dbViewModel.deleteSeasoningBySort(11)
// dbViewModel.deleteSeasoningBySort(13)
// dbViewModel.deleteSeasoningBySort(14)
// dbViewModel.deleteCookFoodAndGoods(0,"") {}
// dbViewModel.getCookFoodGoodsList("") {goodsList->
// goodsList.forEach {
// it.foodId = "1594990392084779010"
// appViewModel.updateGoods(it)
// dbViewModel.updateGoods(it)
// }
// }
// appViewModel.clearAllSeasoning(){}
// dbViewModel.clearAllSeasoning(){}
// binding.btnDeviceConfig.setOnClickListener {
@@ -92,7 +98,7 @@ class HomeActivity : BaseActivity() {
private val modeAdapter by lazy {
HomeModeAdapter(modeList).apply {
addOnItemChildClickListener(R.id.layoutCard) { adapter, view, position ->
modeList[position].onClick?.invoke()
modeList.getOrNull(position)?.onClick?.invoke()
}
}
}
@@ -105,7 +111,7 @@ class HomeActivity : BaseActivity() {
modeIconId = R.drawable.ic_mode_cook,
onClick = {
SpTool.cookMode = 0
startActivity<SelectDishActivity>()
startActivity<CookingModeActivity>()
finish()
})
)
@@ -135,23 +141,23 @@ class HomeActivity : BaseActivity() {
private fun goSampling() {
lifecycleScope.launch {
val slots = appViewModel.loadSeasoningSlot()
val slots = dbViewModel.loadSeasoningSlot()
if (slots.isEmpty()) {
// 无调料信息,打开采样历史列表页面,点击设置配置调料信息
startActivity<SamplingListActivity>()
startActivity<SamplingModeActivity>()
finish()
return@launch
}
val count = appViewModel.countCookFood(cookMode = 1)
val count = dbViewModel.countCookFood(cookMode = 1)
if (count > 0) {
// 有烹饪中数据,打开采样历史列表页面
startActivity<SamplingListActivity>()
startActivity<SamplingModeActivity>()
finish()
return@launch
}
// 无烹饪数据,打开新增采样页面
startActivity<PrepareCookActivity> {
putExtra(PrepareCookActivity.PAGE_FROM, PrepareCookActivity.HOME)
startActivity<PrepareFoodActivity> {
putExtra(PrepareFoodActivity.PAGE_FROM, PrepareFoodActivity.HOME)
}
finish()
}
@@ -2,13 +2,12 @@ package com.shuwei.dish.match.ui
import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.Color
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.view.KeyEvent
import android.view.animation.RotateAnimation
import androidx.activity.addCallback
import androidx.core.graphics.toColorInt
import androidx.lifecycle.lifecycleScope
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.base.BaseApp
@@ -19,10 +18,8 @@ import com.shuwei.dish.match.objbox.FoodModule
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.NetworkUtils
import com.shuwei.dish.match.utils.NetworkUtil
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.ext.gone
import com.shuwei.dish.match.utils.ext.startActivity
import com.shuwei.dish.match.utils.ext.visible
@@ -35,8 +32,37 @@ class InitActivity : BaseActivity() {
}
private lateinit var binding: ActivityInitBinding
private var hasNavigated = false // 防止 onResume 多次触发重复路由
private var startTime = 0L // 倒计时开始时间
private var lastNetworkCheckTime = 0L // 上次检测网络的时间
private val countdownHandler = Handler(Looper.getMainLooper())
private val countdownTask = object : Runnable {
override fun run() {
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}"
countdownHandler.postDelayed(this, 100)
} else {
// 倒计时结束,最后检测一次网络
if (NetworkUtil.isNetworkConnected(this@InitActivity)) {
startNextPage()
} else {
// 网络未连接,显示"连接网络"按钮
showNetworkButton()
}
}
}
}
@SuppressLint("HardwareIds")
override fun onCreate(savedInstanceState: Bundle?) {
@@ -53,30 +79,29 @@ class InitActivity : BaseActivity() {
setHeaderBgVisible(false)
BaseApp.appVersion = AppUtil.getAppVersionCode(this).toString()
BaseApp.canteenId = "0"
BaseApp.canteenId = SpTool.canteenId
WeightUtil.init()
WeightUtil.getWeight()
WeightUtil.startContinuousRead()
//对净材秤1和熟食秤2称进行清零
WeightUtil.tareTwo(AddressUtil.ONE)
WeightUtil.tareTwo(AddressUtil.TWO)
initViews()
}
private fun startNextPage(withDelay: Boolean = false) {
if (hasNavigated) return
hasNavigated = true
lifecycleScope.launch {
if (withDelay) delay(2000)
// 子设备:直接进入小屏专属页面,不走原有大屏业务流程
if (GlobalData.deviceRole == DeviceRole.SLAVE) {
startActivity<SlaveActivity>()
// 延迟到下一帧 finish,避免与 LaunchActivityItem 事务竞态导致
// "Activity client record must not be null" 偶发崩溃
window.decorView.post { finish() }
return@launch
}
// 主设备:走原有路由逻辑
when (SpTool.cookMode) {
0 -> {
startActivity<SelectDishActivity>()
startActivity<CookingModeActivity>()
}
1 -> {
@@ -87,33 +112,38 @@ class InitActivity : BaseActivity() {
startActivity<HomeActivity>()
}
}
// 延迟到下一帧 finish,避免与 LaunchActivityItem 事务竞态导致
// "Activity client record must not be null" 偶发崩溃
window.decorView.post { finish() }
}
}
private fun goSampling() {
lifecycleScope.launch {
val slots = appViewModel.loadSeasoningSlot()
/**
* 采样模式启动路由:根据本地调料配置和烹饪中数据决定跳转目标
* 改为 suspend 后由外层协程串行等待,保证 startActivity 早于 finish() 执行,
* 避免内部再嵌一层 lifecycleScope.launch 导致 finish 抢跑、跳转失败
*/
private suspend fun goSampling() {
val slots = dbViewModel.loadSeasoningSlot()
if (slots.isEmpty()) {
// 无调料信息,打开采样历史列表页面,点击设置配置调料信息
startActivity<SamplingListActivity>()
return@launch
startActivity<SamplingModeActivity>()
return
}
val count = appViewModel.countCookFood(cookMode = 1)
val count = dbViewModel.countCookFood(cookMode = 1)
if (count > 0) {
// 有烹饪中数据,打开采样历史列表页面
startActivity<SamplingListActivity>()
return@launch
startActivity<SamplingModeActivity>()
return
}
// 无烹饪数据,打开新增采样页面
startActivity<PrepareCookActivity> {
putExtra(PrepareCookActivity.PAGE_FROM, PrepareCookActivity.HOME)
}
startActivity<PrepareFoodActivity> {
putExtra(PrepareFoodActivity.PAGE_FROM, PrepareFoodActivity.HOME)
}
}
override fun onDestroy() {
WeightUtil.stopContinuousRead()
ScaleServiceManager.stop()
countdownHandler.removeCallbacksAndMessages(null)
super.onDestroy()
}
@@ -146,7 +176,7 @@ class InitActivity : BaseActivity() {
override fun onResume() {
super.onResume()
if (NetworkUtils.isNetworkConnected(this)) {
if (NetworkUtil.isNetworkConnected(this)) {
// 有网络时隐藏网络检测区域,直接跳转
binding.llNetwork.gone()
startNextPage(withDelay = false)
@@ -159,7 +189,7 @@ class InitActivity : BaseActivity() {
override fun onPause() {
super.onPause()
// 页面不可见时停止倒计时
handler.removeCallbacksAndMessages(null)
countdownHandler.removeCallbacks(countdownTask)
}
/**
@@ -182,40 +212,17 @@ class InitActivity : BaseActivity() {
* 使用 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 检查一次,确保精确性
countdownHandler.removeCallbacks(countdownTask)
countdownHandler.postDelayed(countdownTask, 100)
}
/**
* 检测网络连接
*/
private fun checkNetworkConnection() {
if (NetworkUtils.isNetworkConnected(this)) {
if (NetworkUtil.isNetworkConnected(this)) {
// 网络连接成功,停止倒计时并跳转到 HomeActivity
handler.removeCallbacksAndMessages(null)
countdownHandler.removeCallbacks(countdownTask)
startNextPage()
}
}
@@ -244,4 +251,8 @@ class InitActivity : BaseActivity() {
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
hasNavigated = false
}
}
@@ -19,6 +19,7 @@ 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.base.GlobalData
import com.shuwei.dish.match.dialog.CommonDialog
import com.shuwei.dish.match.databinding.ActivityMasterScaleBinding
import com.shuwei.dish.match.databinding.ListTypeScale1Binding
@@ -81,7 +82,7 @@ class MasterScaleActivity : BaseActivity() {
/** 从 Room 加载槽位配置到 slotNameMap,加载完成后启动数据观测 */
private fun loadSlotNames() {
lifecycleScope.launch {
val slots = appViewModel.loadSeasoningSlot()
val slots = dbViewModel.loadSeasoningSlot()
slots.forEach { slot ->
slotNameMap["${slot.deviceId}#${slot.address}"] = slot.goodsName
}
@@ -99,7 +100,7 @@ class MasterScaleActivity : BaseActivity() {
val flow = ScaleServiceManager.allScales ?: return
lifecycleScope.launch {
flow.collectLatest { scaleMap ->
val localId = com.shuwei.dish.match.base.GlobalData.deviceId
val localId = GlobalData.deviceId
val localIp = NetworkUtil.getLocalIpAddress(this@MasterScaleActivity)
val groups = scaleMap.values
.groupBy { it.deviceId }
@@ -337,7 +338,7 @@ class MasterScaleActivity : BaseActivity() {
textSize = 22f
}
cell.tvState.run {
text = stateStr(scale?.state)
text = WeightUtil.getStateStr(scale?.state ?: -1)
textSize = 22f
}
cell.root.updateLayoutParams { height = 400.dp }
@@ -450,20 +451,12 @@ class MasterScaleActivity : BaseActivity() {
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)
b.tvState.text = scale?.let { WeightUtil.getStateStr(it.state) } ?: ""
}
}
// ── 工具方法 ───────────────────────────────────────────────────────────
private fun stateStr(state: Int?) = when (state) {
WeightUtil.STATE_STABLE -> "稳定"
WeightUtil.STATE_UNSTABLE -> "不稳定"
WeightUtil.STATE_OVER_WEIGHT -> "超量"
null -> ""
else -> "$state"
}
/**
* 弹出清零确认对话框
* @param deviceId 目标设备 ID
@@ -1,598 +0,0 @@
package com.shuwei.dish.match.ui
import android.annotation.SuppressLint
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.util.Log
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.LinearLayoutManager
import com.shuwei.dish.match.R
import com.shuwei.dish.match.adapter.DishPartAdapter
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.databinding.ActivityPrepareCookBinding
import com.shuwei.dish.match.databinding.LayoutCameraPreviewBinding
import com.shuwei.dish.match.dialog.CommonDialog
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.net.UiState
import com.shuwei.dish.match.objbox.FoodModule
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.WeightUtil
import com.shuwei.dish.match.utils.ext.addOnActionSearchListener
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.toJsonString
import com.shuwei.dish.match.utils.ext.toast
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.util.concurrent.atomic.AtomicBoolean
import kotlin.math.abs
@SuppressLint("NotifyDataSetChanged")
class PrepareCookActivity : BaseActivity() {
companion object {
const val TAG = "CookActivity"
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 val cameraUtils: CameraUtils by lazy { CameraUtils(this) }
private var food: FoodRecord? = null
/** 采集模式下通过搜索页回填的 foodId */
private var foodId: String? = null
/** 来源页面标识,HOME 时返回跳转 SamplingListActivity */
private var pageFrom: String? = null
private var goodsList: MutableList<CookFoodGoodsEntity>? = null
@Suppress("DEPRECATION")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityPrepareCookBinding.inflate(layoutInflater)
setContentView(binding.root)
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 = {
it.visible()
}, titleAction = {
it.text = titleText
}, rightIconActon = {
it.gone()
}, backAction = {
it.setOnClickListener {
onBackPressedDispatcher.onBackPressed()
}
})
// 根据 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()
addBackKeyListener()
initCamera(binding.flCameraContainer)
initRecyclerView()
initObserver()
if (!isSamplingMode) {
getDishDetail()
}
}
private fun addViewClickListener() {
binding.tvShowWeight.setOnClickListener {
showLoading("正在清零……")
WeightUtil.tareTwo(AddressUtil.ONE)
delayDismissLoading()
}
WeightUtil.addWeightListener(
weightKey = TAG,
getWeight = { address, state, weight ->
if (address == AddressUtil.ONE && state == WeightUtil.STATE_STABLE) {
runOnUiThread {
binding.tvShowWeight.run {
if (tag != weight) {
text = "称重:${weight}g"
tag = weight
}
}
recognizeFood(weight)
}
}
})
if (SpTool.cookMode == 1) {
// 采集模式:绑定搜索栏交互
binding.ivDishSearch.setOnClickListener { v -> jumpSearch(v) }
binding.etInputDish.addOnActionSearchListener { jumpSearch(binding.etInputDish) }
}
binding.btnCook.clickWithDebounce {
if (SpTool.cookMode == 1) {
// 采集模式:从输入框创建 FoodRecord
val showFoodName = binding.etInputDish.text.toString().trim()
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()
}
}
}
private val isTakingPhoto = AtomicBoolean(false)
// 识别成功并查询到数据打开识别页面
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
}
if (!pageVisible || manualCancelFlag || isTakingPhoto.get() || showRecognizePage || abs(lastWeight - weight) <= WEIGHT_CHANGE_VALUE) {
Log.d(TAG, "recognizeFood, lastWeight:$lastWeight, weight:$weight, pageVisible:$pageVisible, manualCancelFlag:$manualCancelFlag, isTakingPhoto:${isTakingPhoto.get()}")
return
}
showLoading("识别中……")
isTakingPhoto.set(true)
cameraUtils.takePhoto(
succCallback = cameraSuccessCallback,
failCallback = cameraFailureCallback
)
}
/**
* 菜品选择回调
* - list 中不含该 goodsId:直接新增,isNewDishType = true
* - list 中已含该 goodsId:累加 useWeight
*/
private val foodSelectCallback: (CookFoodGoodsEntity) -> Unit = { entity ->
val existing = list.firstOrNull { it.goodsId == entity.goodsId }
if (existing != null) {
existing.useWeight = (existing.useWeight ?: 0.0) + (entity.useWeight ?: 0.0)
existing.isSetFinished = true
dishPartAdapter.notifyItemChanged(list.indexOf(existing))
} else {
dishPartAdapter.add(entity.also {
it.isNewDishType = true
it.isSetFinished = true
})
binding.rvDishPartList.smoothScrollToPosition(list.size - 1)
}
}
private fun openSubmitPage() {
getGoodsList()
if (goodsList.isNullOrEmpty()) {
toast("无菜品构成信息")
return
}
Log.d(TAG, "goToSubmit: goodsList:${goodsList?.toJsonString()}")
startActivity<SubmitFoodActivity> {
putExtra(SubmitFoodActivity.GOODS_LIST, goodsList as Serializable)
putExtra(SubmitFoodActivity.FOOD_ITEM, food as Serializable)
}
}
private fun getGoodsList() {
if (goodsList == null) {
goodsList = mutableListOf()
}
list.filter { it.isSetFinished }.forEach {
goodsList?.add(CookFoodGoodsEntity().apply {
foodId = food!!.foodId
goodsId = it.goodsId
relateionType = it.relateionType
allEdible = it.allEdible
goodsName = it.goodsName
materialType = it.materialType
useWeight = it.useWeight ?: 0.toDouble()
goodsOrRelationCode = it.goodsOrRelationCode
})
}
}
private fun showRemindDialog() {
CommonDialog(this)
.setTitle(getString(R.string.food_remind_01))
.setContent(getString(R.string.food_remind_02))
.setNegativeButton("返回调整")
.setPositiveButton("确认无误") { openSubmitPage() }
.setOnDismissCallback { 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() {
// //649
// val url = "${UrlConfig.DISH_DETAIL}?foodId=${food?.foodId}"
// HttpUtil.get(
// url = url,
// doSuccess = {
// val json = it.toJsonString()
// Log.d(TAG, "getDishDetail: json:$json")
// val detail: CookFoodEntity? = json.toObject<CookFoodEntity>()
// if (detail == null) {
// toast("查询菜品信息为空")
// return@get
// }
// loadDishDetail(detail)
// }) { code, msg ->
// toast(msg)
// }
netViewModel.getFoodDetail(food?.foodId ?: "")
}
private fun loadDishDetail(detail: CookFoodEntity) {
// val voList = detail.stFoodInfoConstituteList
val voList = detail.foodConstituteList
if (voList.isNullOrEmpty()) {
return
}
//筛选出主材和辅材
val tempData = voList.filter { it.materialType == 1 || it.materialType == 2 }
.apply {
forEach { it.useWeight = 0.toDouble() }
}
list.clear()
firstReqSize = list.size
list.addAll(tempData)
dishPartAdapter.notifyDataSetChanged()
}
private var firstReqSize = 0
private var isDetailLoaded = false
private val list = mutableListOf<CookFoodGoodsEntity>()
private val dishPartAdapter by lazy {
DishPartAdapter(list = list).apply {
setOnItemClickListener { _, _, 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)
}
}
}
/**
* RecyclerView初始化
*/
private fun initRecyclerView() {
binding.rvDishPartList.run {
layoutManager =
LinearLayoutManager(this@PrepareCookActivity, LinearLayoutManager.VERTICAL, false)
adapter = dishPartAdapter
val itemTouchHelper = ItemTouchHelper(SwipeCallback(dishPartAdapter) { position ->
if (list[position].isNewDishType.not()) {
toast("只能删除刚添加的食材")
dishPartAdapter.notifyItemChanged(position)
return@SwipeCallback
}
list.removeAt(position)
dishPartAdapter.notifyItemRemoved(position)
dishPartAdapter.notifyItemRangeChanged(position, list.size - position)
toast("已删除")
})
itemTouchHelper.attachToRecyclerView(this)
}
}
override fun onResume() {
super.onResume()
pageVisible = true
showRecognizePage = false
cameraUtils.bind()
}
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() {
CommonDialog(this)
.setTitle("返回提示")
.setContent("您好,当前页面存在未保存的数据,\n确认返回吗?")
.setNegativeButton("取消")
.setPositiveButton("确认") {
if (SpTool.cookMode == 1 && pageFrom == HOME) {
startActivity<SamplingListActivity>()
}
finish()
}
.setOnDismissCallback { hideStatusBar() }
.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)
}
}
@@ -0,0 +1,905 @@
package com.shuwei.dish.match.ui
import android.annotation.SuppressLint
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.ViewGroup
import androidx.activity.addCallback
import androidx.core.content.IntentCompat
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.shuwei.dish.match.R
import com.shuwei.dish.match.adapter.FoodMaterialAdapter
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.databinding.ActivityPrepareFoodBinding
import com.shuwei.dish.match.databinding.LayoutCameraPreviewBinding
import com.shuwei.dish.match.dialog.CommonDialog
import com.shuwei.dish.match.dialog.FoodSearchDialog
import com.shuwei.dish.match.model.CookOrderCompositionItem
import com.shuwei.dish.match.model.FoodCompositionResponse
import com.shuwei.dish.match.db.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.model.FoodRecord
import com.shuwei.dish.match.model.GoodsItem
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.CameraUtils
import com.shuwei.dish.match.utils.ImageUtil
import com.shuwei.dish.match.utils.SpTool
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.dp
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.roundedOneDecimalPlace
import com.shuwei.dish.match.utils.ext.startActivity
import com.shuwei.dish.match.utils.ext.toJsonString
import com.shuwei.dish.match.utils.ext.toast
import com.shuwei.dish.match.utils.ext.visible
import com.yanzhenjie.recyclerview.SwipeMenuItem
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.Serializable
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.getValue
import kotlin.math.abs
@SuppressLint("NotifyDataSetChanged")
class PrepareFoodActivity : BaseActivity() {
companion object {
const val TAG = "PrepareFoodActivity"
const val FOOD_ITEM = "foodItem"
const val COOK_ORDER_ID = "cookOrderId"
const val PAGE_FROM = "pageFrom"
const val HOME = "home"
const val WEIGHT_CHANGE_VALUE = 5
const val WEIGHT_RECOGNIZE_VALUE = 10
/** 主材数量上限 */
const val MATERIAL_COUNT = 3
}
private val binding by lazy { ActivityPrepareFoodBinding.inflate(layoutInflater) }
private val cameraUtils: CameraUtils by lazy { CameraUtils(this) }
private var food: FoodRecord? = null
/** 烹制单 ID,制作模式从菜品列表传入,用于调用新配比秤接口 */
private var cookOrderId: String? = null
/** 来源页面标识,HOME 时返回跳转 SamplingListActivity */
private var pageFrom: String? = null
private var goodsList: MutableList<CookFoodGoodsEntity>? = null
/** 食材检索弹窗,手动新增构成时复用 */
private var foodSearchDialog: FoodSearchDialog? = null
/** 手动录入卡当前选中的食材(含 materId),点击搜索后回填 */
private var selectedIngredient: GoodsItem? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
setHeaderBackground()
pageFrom = intent.getStringExtra(PAGE_FROM)
//food = intent.extras?.getSerializable(FOOD_ITEM) as FoodRecord?
food = IntentCompat.getSerializableExtra(intent, FOOD_ITEM, FoodRecord::class.java)
cookOrderId = intent.getStringExtra(COOK_ORDER_ID)
val isSamplingMode = SpTool.cookMode == 1
val titleText = if (isSamplingMode) "菜品采集" else "菜品制作"
setTitleBar(titleBarAction = {
it.visible()
}, titleAction = {
it.text = titleText
}, rightIconAction = {
it.gone()
}, backAction = {
it.setOnClickListener {
onBackPressedDispatcher.onBackPressed()
}
})
food?.let {
binding.etInputDish.run {
setText(it.foodName)
tag = it.foodName
}
}
// 根据 cookMode 设置搜索栏可编辑性
if (isSamplingMode) {
// 采集模式:可编辑,支持搜索
binding.ivDishSearch.visible()
binding.etInputDish.let {
it.setSelection(it.length())
}
// 采集模式:启用食材信息录入卡(手动新增构成)
initIngredientInput()
} else {
// 制作模式:不可编辑,仅展示菜名
binding.etInputDish.isFocusable = false
binding.etInputDish.isFocusableInTouchMode = false
binding.ivDishSearch.gone()
// 制作模式:不展示手动新增卡
binding.llIngredientInput.gone()
}
addViewClickListener()
addBackKeyListener()
// 仅采样模式启用摄像头识别,制作模式不需要
// if (isSamplingMode) {
// initCamera(binding.flCameraContainer)
// }
initRecyclerView()
initObserver()
// 制作模式优先用 cookOrderId 调新接口,采样模式回退到旧 foodId
if (!cookOrderId.isNullOrBlank()) {
getCookOrderComposition(cookOrderId!!)
} else if (food?.foodId.isNullOrBlank().not()) {
getDishDetail(food!!.foodId!!)
}
}
private fun addViewClickListener() {
binding.tvShowWeight.setOnClickListener {
showLoading("正在清零……")
WeightUtil.tareTwo(AddressUtil.ONE)
delayDismissLoading()
}
// // TODO: 测试功能-------------------------------
// binding.btnTestConfirm.clickWithDebounce {
// if (currentWeight <= 0.0) return@clickWithDebounce
// val (index, item) = list.withIndex().find { (_, it) -> it.isOriginalData && it.isClicked && (it.useWeight?:0.0) <= 0.0 } ?: return@clickWithDebounce
// item.useWeight = currentWeight
// item.isSetFinished = true
// list.forEachIndexed { i, it -> it.isClicked = i == index }
// materialAdapter.notifyDataSetChanged()
// }
// // TODO: 测试功能-------------------------------
//binding.root.setOnClickListener { hideKeyboard() }
WeightUtil.addWeightListener(
weightKey = TAG,
getWeight = { address, state, weight ->
if (address == AddressUtil.ONE && state == WeightUtil.STATE_STABLE) {
runOnUiThread {
// 首次收到稳定读数时,将其作为初始值(去皮基准)
// if (!initialWeightReady) {
// initialWeight = weight
// initialWeightReady = true
// }
// 净重 = 秤读数 - 初始读数
val netWeight = weight - initialWeight
currentWeight = netWeight
binding.tvShowWeight.run {
if (tag != netWeight) {
text = "称重:${netWeight}g"
tag = netWeight
}
}
// 仅采样模式:用量自动回填秤重 + 触发食材识别
if (SpTool.cookMode == 1) {
val displayWeight =
if (netWeight > 0) netWeight.roundedOneDecimalPlace().toString() else ""
binding.etAmount.setText(displayWeight)
recognizeFood(netWeight)
}
}
}
})
if (SpTool.cookMode == 1) {
// 采集模式:绑定搜索栏交互
binding.ivDishSearch.setOnClickListener { jumpSearch() }
binding.etInputDish.addOnActionSearchListener { jumpSearch() }
}
binding.btnCook.clickWithDebounce {
if (SpTool.cookMode == 1) {
// 采集模式:从输入框创建 FoodRecord
val showFoodName = binding.etInputDish.text.toString().trim()
if (showFoodName.isBlank()) {
toast("菜品名称为空")
return@clickWithDebounce
}
if (list.isEmpty()) {
toast("菜品构成信息未设置")
return@clickWithDebounce
}
val foodRecord = food ?: FoodRecord()
binding.etInputDish.let {
if (it.tag == null || it.tag.toString() != it.text.toString()) {
// 输入框内容与回填名称不一致,说明用户手动修改了菜名,清除 foodId
foodRecord.foodId = null
}
}
foodRecord.foodName = showFoodName
foodRecord.cookMode = 1
Log.d(TAG, "goToSubmit: goodsList:${list.toJsonString()}")
// 采集模式传给 SubmitFoodActivity 时转换为 CookFoodGoodsEntity
val submitList = list.map { it.toCookFoodGoodsEntity() }.toMutableList()
startActivity<SubmitFoodActivity> {
putExtra(SubmitFoodActivity.GOODS_LIST, submitList as Serializable)
putExtra(SubmitFoodActivity.FOOD_ITEM, foodRecord as Serializable)
}
} else {
// 制作模式:检查食材是否全部设置完成
val count = list.count { it.isSetFinished.not() }
if (count > 0) {
showRemindDialog()
return@clickWithDebounce
}
openSubmitPage()
}
}
}
private val isTakingPhoto = AtomicBoolean(false)
// 识别成功并查询到数据打开识别页面
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
/** 初始秤读数,净重 = 秤读数 - initialWeight,从 SpTool.configWeight 读取 */
private var initialWeight = SpTool.configWeight.toDouble()
/** 初始读数是否已从秤获取 */
// private var initialWeightReady = false
/**
* 重量稳定时触发菜品识别
* @param weight 当前稳定重量
*/
private fun recognizeFood(weight: Double) {
this.currentWeight = weight
if (weight < WEIGHT_RECOGNIZE_VALUE) {
showRecognizePage = false
manualCancelFlag = false
lastWeight = weight
FoodRecognizeActivity.close()
return
}
if (
!pageVisible || manualCancelFlag || isTakingPhoto.get() || showRecognizePage ||
notRecognizeDialog?.isShowing == true ||
abs(lastWeight - weight) <= WEIGHT_CHANGE_VALUE
) {
Log.d(
TAG,
"recognizeFood, lastWeight:$lastWeight, weight:$weight, pageVisible:$pageVisible, manualCancelFlag:$manualCancelFlag, isTakingPhoto:${isTakingPhoto.get()}"
)
return
}
showLoading("识别中……")
isTakingPhoto.set(true)
cameraUtils.takePhoto(
succCallback = cameraSuccessCallback,
failCallback = cameraFailureCallback
)
}
/**
* 菜品选择回调
* - list 中不含该 goodsId:直接新增,isNewDishType = true
* - list 中已含该 goodsId:累加 useWeight
*/
private val foodSelectCallback: (GoodsItem) -> Unit = foodSelectCallback@{ entity ->
val filterValue = list.withIndex().find { (_, it) -> it.goodsId == entity.goodsId }
if (filterValue != null) {
val index = filterValue.index
val goods = filterValue.value
goods.useWeight = (goods.useWeight ?: 0.0) + (entity.useWeight ?: 0.0)
goods.isSetFinished = true
list.forEachIndexed { i, it ->
it.isClicked = i == index
}
} else {
list.forEach { it.isClicked = false }
list.add(entity.also {
it.isNewDishType = true
it.isSetFinished = true
// 标记为非接口数据,否则 updateMaterialTypes 不会为其计算 materialType
// 导致列表中不显示"主辅材:主材/辅材"
it.isOriginalData = false
it.isClicked = true
})
binding.rvMaterialList.smoothScrollToPosition(list.size - 1)
}
updateMaterialTypes()
}
/**
* 重新计算 list 中非接口数据(isOriginalData=false)的 materialType
*
* 规则:
* - 接口数据主材数量 n = list 中 isOriginalData=true 且 materialType=1 的数量
* - 有接口数据且 MATERIAL_COUNT - n <= 0:所有非接口数据均为辅材(2)
* - 无接口数据,或 MATERIAL_COUNT - n > 0m = MATERIAL_COUNT - n
* 非接口数据按 useWeight 从大到小排序,前 m 个为主材(1),其余为辅材(2)
*/
private fun updateMaterialTypes() {
val hasOriginalData = list.any { it.isOriginalData }
// 接口数据中主材数量
val n = list.count { it.isOriginalData && it.materialType == 1 }
// 待分配的非接口数据(跳过手动指定过主辅材的项)
val nonOriginalList = list.filter { !it.isOriginalData && !it.isMaterialTypeManual }
if (hasOriginalData && MATERIAL_COUNT - n <= 0) {
// 主材已满,所有非接口数据均为辅材
nonOriginalList.forEach { it.materialType = 2 }
} else {
// 剩余可分配主材名额
val m = MATERIAL_COUNT - n
// 按 useWeight 从大到小排序
val sorted = nonOriginalList.sortedByDescending { it.useWeight ?: 0.0 }
sorted.forEachIndexed { index, item ->
item.materialType = if (index < m) 1 else 2
}
}
materialAdapter.notifyDataSetChanged()
}
/**
* 食材信息录入卡初始化(仅采样模式):
* 搜索食材 → 选中回填名称并暂存 GoodsItem(含 materId)→ 选择主辅材 → 输入用量 → 添加
*/
private fun initIngredientInput() {
binding.ivIngredientSearch.setOnClickListener { openIngredientSearch() }
binding.etIngredientName.setOnClickListener { openIngredientSearch() }
binding.btnReset.setOnClickListener {
binding.etAmount.setText("")
}
binding.btnAdd.clickWithDebounce {
addManualIngredient()
}
}
/** 打开食材检索弹窗(食材名称不可手输,点击即搜索) */
private fun openIngredientSearch() {
if (foodSearchDialog?.isShowing == true) return
foodSearchDialog = FoodSearchDialog(
activity = this,
onItemSelected = { item -> onIngredientSelected(item) }
).also { it.show() }
}
/**
* 食材检索选中回调:回填名称,暂存 GoodsItemmaterId 用于提交)
*/
private fun onIngredientSelected(item: GoodsItem) {
selectedIngredient = item
binding.etIngredientName.setText(item.goodsName)
}
/**
* 手动添加食材到构成列表:名称(materId)+ 主辅材 + 用量(克,取自秤重)
*/
private fun addManualIngredient() {
val ingredient = selectedIngredient
if (ingredient == null || ingredient.materId.isNullOrBlank()) {
toast("请先搜索选择食材")
return
}
val amount = currentWeight.roundedOneDecimalPlace()
if (amount <= 0) {
toast("请先称重获取用量")
return
}
val type = if (binding.rbAux.isChecked) 2 else 1
val existing = list.firstOrNull { it.goodsId == ingredient.goodsId }
if (existing != null) {
// 已存在同食材:累加用量并同步主辅材
existing.useWeight = (existing.useWeight ?: 0.0) + amount
existing.materialType = type
existing.isMaterialTypeManual = true
existing.isSetFinished = true
existing.isNewDishType = true
} else {
ingredient.materialType = type
ingredient.useWeight = amount
ingredient.isMaterialTypeManual = true
ingredient.isNewDishType = true
ingredient.isOriginalData = false
ingredient.isSetFinished = true
ingredient.isClicked = false
list.add(ingredient)
binding.rvMaterialList.smoothScrollToPosition(list.size - 1)
}
// 清空录入区
selectedIngredient = null
binding.etIngredientName.setText("")
binding.etAmount.setText("")
updateMaterialTypes()
}
private fun openSubmitPage() {
getGoodsList()
if (goodsList.isNullOrEmpty()) {
toast("无菜品构成信息")
return
}
Log.d(TAG, "goToSubmit: goodsList:${goodsList?.toJsonString()}")
startActivity<SubmitFoodActivity> {
putExtra(SubmitFoodActivity.GOODS_LIST, goodsList as Serializable)
putExtra(SubmitFoodActivity.FOOD_ITEM, food as Serializable)
putExtra(SubmitFoodActivity.COOK_ORDER_ID, cookOrderId)
}
}
private fun getGoodsList() {
if (goodsList == null) {
goodsList = mutableListOf()
} else {
goodsList!!.clear()
}
// 将 GoodsItem 转换为 CookFoodGoodsEntity 并补充菜品相关字段
list.filter { it.isSetFinished }.forEach {
goodsList?.add(it.toCookFoodGoodsEntity().apply {
foodId = food!!.foodId
})
}
}
private fun showRemindDialog() {
CommonDialog(this)
.setTitle(getString(R.string.food_remind_01))
.setContent(getString(R.string.food_remind_02))
.setNegativeButton("返回调整")
.setPositiveButton("确认无误") { openSubmitPage() }
.setOnDismissCallback { hideStatusBar() }
.show()
}
private fun initObserver() {
// 新配比秤接口 observer(制作模式使用)
lifecycleScope.launch {
netViewModel.cookOrderCompositionState.collect { state ->
when (state) {
is UiState.Success -> {
val items = state.data
if (items.isNullOrEmpty()) {
toast("查询菜品构成为空")
return@collect
}
loadDishComposition(items)
}
is UiState.Error -> toast(state.msg)
else -> {}
}
}
}
}
/** 新接口:通过 foodId 查询菜品构成(采样模式称量模板) */
private fun getDishDetail(foodId: String) {
netViewModel.getFoodComposition(
foodId = foodId,
onResult = { state ->
when (state) {
is UiState.Success -> {
val response = state.data
if (response == null) {
toast("查询菜品构成为空")
} else {
loadFoodComposition(response)
}
}
is UiState.Error -> toast(state.msg)
else -> {}
}
}
)
}
/** 新接口:通过 cookOrderId 查询菜品食材构成(制作模式使用) */
private fun getCookOrderComposition(cookOrderId: String) {
netViewModel.getCookOrderComposition(cookOrderId)
}
/**
* 采样模式:将 food/{foodId}/composition 返回的构成映射为 GoodsItem 并刷新 UI
* 筛选主材(1)/辅材(2),调料(3)仍走槽位秤;useWeight 置零待重新称量
*/
private fun loadFoodComposition(response: FoodCompositionResponse) {
val tempData = response.ingredients
.filter { it.isMain == 1 || it.isMain == 2 }
.map {
GoodsItem(
goodsId = it.materId,
materId = it.materId,
goodsName = it.materName,
materialType = it.isMain,
useWeight = 0.0,
isOriginalData = true
)
}
list.clear()
list.addAll(tempData)
materialAdapter.items = list
materialAdapter.notifyDataSetChanged()
firstReqSize = list.size
}
/**
* 将新配比秤接口的食材构成列表映射为 GoodsItem 并刷新 UI
* 新接口天然仅返回主材(ingredientClass=1)和辅材(ingredientClass=2),不含调料
* useWeight 保留接口返回值,不再强制置零
*/
private fun loadDishComposition(items: List<CookOrderCompositionItem>) {
val tempData = items.map { item ->
GoodsItem(
goodsId = item.materId,
goodsName = item.ingredientName,
materId = item.materId,
materialType = item.ingredientClass,
useWeight = item.useWeight,
rawMaterialsType = item.vegTypeId,
isOriginalData = true
)
}
list.clear()
list.addAll(tempData)
materialAdapter.items = list
materialAdapter.notifyDataSetChanged()
firstReqSize = list.size
}
private var firstReqSize = 0
private val list = mutableListOf<GoodsItem>()
private val materialAdapter by lazy {
FoodMaterialAdapter(list).apply {
onItemClick = { position ->
list.forEachIndexed { index, entity ->
entity.isClicked = index == position
}
// 选中食材时同步当前秤重(制作/采样均支持)
val item = list.getOrNull(position)
if (item != null && currentWeight > 0) {
item.useWeight = currentWeight
item.isSetFinished = true
}
notifyDataSetChanged()
}
addOnItemChildClickListener(R.id.ivClearIcon) { _, _, position ->
val item = list.getOrNull(position) ?:return@addOnItemChildClickListener
Log.d(TAG, "onFoodItemClick: ${item.toJsonString()}")
if (item.isOriginalData) {
// 无重量信息时转为 item 整体点击(触发选中效果)
if (item.useWeight == null || item.useWeight == 0.0) {
onItemClick?.invoke(position)
return@addOnItemChildClickListener
}
deleteRemindDialog("清除确认", "确定清除食材「${item.goodsName}」添加的重量吗?") {
item.run {
isNewDishType = false
useWeight = 0.0
isSetFinished = false
}
notifyItemChanged(position)
}
return@addOnItemChildClickListener
}
deleteRemindDialog("删除确认", "确定删除食材「${item.goodsName}」吗?") {
removeFood(position)
}
}
}
}
/**
* RecyclerView初始化
*/
@SuppressLint("ClickableViewAccessibility")
private fun initRecyclerView() {
binding.rvMaterialList.let {
it.layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
// 采样模式下所有食材均可侧滑删除(必须在 setAdapter 之前调用)
it.setSwipeMenuCreator { _, rightMenu, position ->
if (SpTool.cookMode == 1) {
rightMenu.addMenuItem(buildDeleteMenuItem())
}
}
// 点击侧滑菜单项:先关闭菜单,再弹窗确认删除
it.setOnItemMenuClickListener { menuBridge, position ->
menuBridge.closeMenu()
val item = list.getOrNull(position) ?: return@setOnItemMenuClickListener
deleteRemindDialog("删除确认", "确定删除食材「${item.goodsName}」吗?") {
removeFood(position)
}
}
// item 点击事件
it.setOnItemClickListener { _, position ->
materialAdapter.onItemClick?.invoke(position)
}
handleRvTouchHideKeyboard()
it.adapter = materialAdapter
}
}
/**
* 删除提醒
*/
private fun deleteRemindDialog(title: String, content: String, action: () -> Unit) {
CommonDialog(this@PrepareFoodActivity)
.setTitle(title)
.setContent(content)
.setNegativeButton("取消")
.setPositiveButton("确认") {
action()
}.show()
}
/**
* 移除菜品
*/
private fun removeFood(position: Int) {
materialAdapter.removeAt(position)
toast("已删除")
updateMaterialTypes()
}
/**
* 构建侧滑删除菜单项
*/
private fun buildDeleteMenuItem(): SwipeMenuItem {
return SwipeMenuItem(this).apply {
setImage(R.drawable.ic_trash_white)
setBackground(R.drawable.bg_swipe_delete)
width = 160.dp
height = ViewGroup.LayoutParams.MATCH_PARENT
}
}
override fun onResume() {
super.onResume()
pageVisible = true
showRecognizePage = false
if (SpTool.cookMode == 1) {
cameraUtils.bind()
}
}
override fun onPause() {
super.onPause()
pageVisible = false
if (SpTool.cookMode == 1) {
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<SamplingModeActivity>()
}
} else {
// 制作模式:有未保存数据时提示
val count = list.count { !it.isOriginalData }
if (count > 0) {
saveDataRemindDialog()
return@addCallback
}
}
finish()
}
}
/**
* 未保存数据提醒
*/
private fun saveDataRemindDialog() {
CommonDialog(this)
.setTitle("返回提示")
.setContent("您好,当前页面存在未保存的数据,\n确认返回吗?")
.setNegativeButton("取消")
.setPositiveButton("确认") {
if (SpTool.cookMode == 1 && pageFrom == HOME) {
startActivity<SamplingModeActivity>()
}
finish()
}
.setOnDismissCallback { hideStatusBar() }
.show()
}
/**
* 拍照成功回调
*/
private val cameraSuccessCallback: (Uri) -> Unit = { uri ->
Log.d(TAG, "takePhoto success")
lastPhotoUri = uri
lifecycleScope.launch {
withContext(Dispatchers.IO) {
val bitmap = ImageUtil.uriToBitmap(this@PrepareFoodActivity, 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)
withContext(Dispatchers.Main) {
showNotRecognizeDialog()
}
return@withContext
}
withContext(Dispatchers.Main) {
Log.d(TAG, "takePhoto queryFood, list=$foodScoreList")
postDelayed(1000) {
dismissLoading()
loadRecognizeResultPage(foodScoreList)
}
}
}
}
}
private var notRecognizeDialog: CommonDialog? = null
private fun showNotRecognizeDialog() {
notRecognizeDialog = CommonDialog(this)
.setTitle("提示")
.setContent("未识别到食材,请稍后重试")
.setNegativeButton("取消") {
manualCancelFlag = true
}
.setNeutralButton("重新识别") {
recognizeFood(currentWeight)
}
.setPositiveButton("手动设置"){
loadRecognizeResultPage(emptyList())
}
.also {
it.show()
}
}
/**
* 拍照失败回调
*/
private val cameraFailureCallback: (String) -> Unit = { errMsg ->
Log.d(TAG, "takePhoto failure")
isTakingPhoto.set(false)
toast(errMsg)
dismissLoading()
}
/**
* 加载识别结果页面
*/
private fun loadRecognizeResultPage(nameScoreList: List<FoodModule.IdNameScore>) {
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,
initialWeight = initialWeight,
nameScoreList = ArrayList(nameScoreList),
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)
}
/**
* 跳转食物搜索页(采集模式专用)
*/
private fun jumpSearch() {
val searchContent = binding.etInputDish.text.toString().trim()
if (searchContent.isBlank()) {
toast(binding.etInputDish.hint.toString())
return
}
val launchIntent = Intent(this, FoodSearchActivity::class.java).apply {
putExtra(FoodSearchActivity.FOOD_NAME, searchContent)
}
startActivity(launchIntent) { resultIntent ->
if (resultIntent == null) return@startActivity
food = IntentCompat.getSerializableExtra(resultIntent, FOOD_ITEM, FoodRecord::class.java)
Log.d(TAG, "jumpSearch: record=$food")
if (food == null) return@startActivity
// 将搜索结果写入 food,保留 cookMode
food!!.cookMode = 1
binding.etInputDish.run {
setText(food!!.foodName)
tag = food!!.foodName
setSelection(text.length)
}
// 有 foodId 时请求菜品构成
if (!food!!.foodId.isNullOrBlank()) {
getDishDetail(food!!.foodId!!)
}
}
hideKeyboard()
}
private fun handleRvTouchHideKeyboard() {
val gestureDetector = GestureDetector(this, object : GestureDetector.SimpleOnGestureListener() {
override fun onSingleTapUp(e: MotionEvent): Boolean {
hideKeyboard()
// 返回 false,不消费事件,不影响 item 点击和滚动
return false
}
})
binding.rvMaterialList.addOnItemTouchListener(object : RecyclerView.SimpleOnItemTouchListener() {
private var startX = 0f
private var startY = 0f
override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean {
when (e.action) {
MotionEvent.ACTION_DOWN -> {
startX = e.x
startY = e.y
}
MotionEvent.ACTION_MOVE -> {
val dx = abs(e.x - startX)
val dy = abs(e.y - startY)
// 水平滑动超过阈值时(侧滑手势),关闭键盘
if (dx > dy && dx > 10) {
hideKeyboard()
}
}
}
gestureDetector.onTouchEvent(e)
return false
}
})
}
}
@@ -1,390 +0,0 @@
package com.shuwei.dish.match.ui
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
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.LinearLayoutManager
import com.shuwei.dish.match.R
import com.shuwei.dish.match.adapter.SamplingAdapter
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.databinding.ActivitySamplingBinding
import com.shuwei.dish.match.databinding.LayoutEmptyViewBinding
import com.shuwei.dish.match.dialog.CommonDialog
import com.shuwei.dish.match.entity.CookFoodEntity
import com.shuwei.dish.match.entity.FoodRecord
import com.shuwei.dish.match.utils.DateTimeUtil
import com.shuwei.dish.match.utils.SwipeCallback
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.startActivity
import com.shuwei.dish.match.utils.ext.toast
import com.shuwei.dish.match.utils.ext.visible
import java.io.Serializable
import kotlinx.coroutines.launch
import com.shuwei.dish.match.net.UiState
class SamplingListActivity : BaseActivity() {
private lateinit var binding: ActivitySamplingBinding
companion object {
private const val TAG = "SamplingListActivity"
const val IS_COOKING = "isCooking"
const val IS_CONFIG_PAGE = "isConfigPage"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivitySamplingBinding.inflate(layoutInflater)
setContentView(binding.root)
setTitleBar()
setHeaderBackground()
initRecyclerView()
addViewListener()
initObserver()
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 -> {}
}
}
}
}
}
private fun setTitleBar() {
setTitleBar(titleBarAction = {
it.visible()
}, titleAction = {
it.text = "采样历史"
}, rightIconActon = {
it.visible()
it.setImageResource(R.drawable.ic_setting)
it.setOnClickListener {
startActivity<SettingActivity>()
}
}, backAction = {
it.gone()
})
}
fun getCookFoodList() {
appViewModel.getCookFoodList(cookMode = 1)
}
fun deleteCookFoodAndGoods(foodId: String, action: () -> Unit) {
lifecycleScope.launch {
appViewModel.deleteCookFoodAndGoods(cookMode = 1, foodId = foodId)
action()
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
val isConfigPage = intent?.getBooleanExtra(IS_CONFIG_PAGE, false) ?: false
if (isConfigPage) {
return
}
val isCooking = intent?.getBooleanExtra(IS_COOKING, false) ?: false
//toast("isCooking=$isCooking")
refreshPage(isCooking)
}
private var list: MutableList<FoodRecord> = mutableListOf()
private val dishAdapter by lazy {
SamplingAdapter(list = list).apply {
isStateViewEnable = true
setOnItemClickListener { adapter, view, position ->
judgeDeviceConfig {
onItemClick(position)
}
}
}
}
private fun onItemClick(position: Int) {
val item = list[position]
if (item.isCooking) {
//烹饪中,跳到称熟重页面
startActivity<SubmitFoodActivity> {
putExtra(
SubmitFoodActivity.FOOD_ITEM, item as Serializable
)
}
return
}
startActivity<PrepareCookActivity> {
putExtra(PrepareCookActivity.FOOD_NAME, item.foodName)
}
}
@SuppressLint("NotifyDataSetChanged")
private fun initRecyclerView() {
binding.rvSamplingList.run {
layoutManager =
LinearLayoutManager(this@SamplingListActivity, LinearLayoutManager.VERTICAL, false)
adapter = dishAdapter
val itemTouchHelper = ItemTouchHelper(SwipeCallback(dishAdapter) { position ->
if (list[position].isCooking.not()) {
toast("只能删除烹饪中的菜品")
dishAdapter.notifyItemChanged(position)
return@SwipeCallback
}
deleteCookFoodAndGoods(foodId = list[position].foodId ?: "") {
if (list.size > 1) {
dishAdapter.removeAt(position)
} else {
loadEmptyView()
}
toast("已删除")
}
})
itemTouchHelper.attachToRecyclerView(this)
}
}
@SuppressLint("ClickableViewAccessibility")
private fun addViewListener() {
binding.btnAddSampling.setOnClickListener {
judgeDeviceConfig {
startActivity<PrepareCookActivity>()
}
}
binding.refreshLayout.setOnRefreshListener {
pageNo = 1
getSamplingList()
}
binding.refreshLayout.setOnLoadMoreListener {
getSamplingList()
}
binding.rbSamplingCooking.setOnClickListener {
// Log.d(TAG, "addViewListener: cooking:${binding.rbSamplingCooking.isChecked}")
if (it.tag == true) {
return@setOnClickListener
}
requestData(true)
binding.rbSamplingCooking.tag = binding.rbSamplingCooking.isChecked
binding.rbSamplingFinished.tag = binding.rbSamplingFinished.isChecked
}
binding.rbSamplingFinished.setOnClickListener {
// Log.d(TAG, "addViewListener: finish:${binding.rbSamplingFinished.isChecked}")
if (it.tag == true) {
return@setOnClickListener
}
requestData(false)
binding.rbSamplingCooking.tag = binding.rbSamplingCooking.isChecked
binding.rbSamplingFinished.tag = binding.rbSamplingFinished.isChecked
}
}
var pageNo = 1
private val pageSize = 30
@SuppressLint("NotifyDataSetChanged")
fun getSamplingList() {
netViewModel.getSamplingList(
param = mutableMapOf(
"pageNum" to pageNo,
"pageSize" to pageSize,
"placeId" to BaseApp.canteenId
)
)
}
private fun finishRefresh() {
if (pageNo == 1) {
binding.refreshLayout.finishRefresh(1200)
} else {
binding.refreshLayout.finishLoadMore(1200)
}
}
private var emptyViewBinding: LayoutEmptyViewBinding? = null
@SuppressLint("NotifyDataSetChanged")
private fun loadEmptyView() {
list.clear()
dishAdapter.notifyDataSetChanged()
if (emptyViewBinding == null) {
emptyViewBinding = LayoutEmptyViewBinding.inflate(
LayoutInflater.from(this), binding.rvSamplingList, false
)
}
emptyViewBinding!!.tvContent.text = "暂无数据"
emptyViewBinding!!.tvSubContent.text = "今日暂无采样数据,点击下方按钮新增采样"
dishAdapter.stateView = emptyViewBinding!!.root
//dishAdapter.setStateViewLayout(requireContext(), R.layout-sw800dp.layout_empty_view)
binding.refreshLayout.run {
setEnableRefresh(binding.dishRadioGroup.checkedRadioButtonId == R.id.rbSamplingFinished)
setEnableLoadMore(false)
}
}
fun refreshPage(isCooking: Boolean) {
val checkedId = binding.dishRadioGroup.checkedRadioButtonId
if (checkedId == R.id.rbSamplingFinished) {
//当前是已采样
if (isCooking) {
//需要切换到烹饪中
binding.dishRadioGroup.check(R.id.rbSamplingCooking)
requestData(true)
return
}
//需要切换到已采样,刷新数据
requestData(false)
return
}
//当前是烹饪中
if (isCooking.not()) {
//需要切换到已采样
binding.dishRadioGroup.check(R.id.rbSamplingFinished)
requestData(false)
return
}
//需要切换到烹饪中,刷新数据
requestData(true)
}
@SuppressLint("NotifyDataSetChanged")
fun refreshLocalData() {
showLoading()
appViewModel.resetCookFoodList()
getCookFoodList()
}
private fun requestData(isCooking: Boolean) {
if (isCooking) {
// 清除上次网络请求的残留状态
netViewModel.resetSamplingListState()
refreshLocalData()
} else {
// 同理,清除本地数据残留状态
appViewModel.resetCookFoodList()
pageNo = 1
getSamplingList()
}
}
@SuppressLint("NotifyDataSetChanged")
private fun loadDishList(records: MutableList<FoodRecord>?) {
binding.refreshLayout.setEnableRefresh(true)
delayDismissLoading()
finishRefresh()
if (records.isNullOrEmpty()) {
//toast("暂未搜索到相关菜品信息")
if (pageNo == 1) {
loadEmptyView()
}
return
}
if (pageNo == 1) {
list.clear()
}
list.addAll(records)
dishAdapter.notifyDataSetChanged()
val isLoadMoreEnable = records.size >= pageSize
binding.refreshLayout.setEnableLoadMore(isLoadMoreEnable)
binding.refreshLayout.setEnableRefresh(true)
if (isLoadMoreEnable) {
pageNo++
}
}
private var configFinished = false
fun judgeDeviceConfig(block: () -> Unit) {
if (configFinished) {
block()
return
}
lifecycleScope.launch {
val slots = appViewModel.loadSeasoningSlot()
if (slots.isEmpty()) {
showDeviceConfigDialog()
return@launch
}
configFinished = true
block()
}
}
private fun showDeviceConfigDialog() {
CommonDialog(this)
.setTitle("配置提示")
.setContent("请先在设备配置页面进行调料设置")
.setNegativeButton("取消")
.setPositiveButton("去设置") { startActivity<SettingActivity>() }
.show()
}
private var isVisible = false
override fun onResume() {
super.onResume()
isVisible = true
}
override fun onPause() {
super.onPause()
isVisible = false
}
}
@@ -0,0 +1,440 @@
package com.shuwei.dish.match.ui
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.activity.addCallback
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.shuwei.dish.match.R
import com.shuwei.dish.match.adapter.FoodListAdapter
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.databinding.ActivitySamplingModeBinding
import com.shuwei.dish.match.databinding.LayoutEmptyViewBinding
import com.shuwei.dish.match.dialog.CommonDialog
import com.shuwei.dish.match.model.FoodRecord
import com.shuwei.dish.match.model.SampleHistoryItem
import com.shuwei.dish.match.net.UiState
import com.shuwei.dish.match.utils.DateTimeUtil
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.toast
import com.shuwei.dish.match.utils.ext.visible
import com.yanzhenjie.recyclerview.SwipeMenuItem
import java.io.Serializable
import kotlinx.coroutines.launch
/**
* 采样模式
*/
class SamplingModeActivity : BaseActivity() {
private val binding by lazy { ActivitySamplingModeBinding.inflate(layoutInflater) }
companion object {
private const val TAG = "SamplingListActivity"
const val IS_COOKING = "isCooking"
const val IS_CONFIG_PAGE = "isConfigPage"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
setTitleBar()
setHeaderBackground()
initRecyclerView()
addViewListener()
requestData(true)
onBackPressedDispatcher.addCallback(this) { }
}
private fun setTitleBar() {
setTitleBar(titleBarAction = {
it.visible()
}, titleAction = {
it.text = "采样历史"
}, rightIconAction = {
it.visible()
it.setImageResource(R.drawable.ic_setting)
it.setOnClickListener {
startActivity<SettingActivity>()
}
}, backAction = {
it.gone()
})
}
@SuppressLint("NotifyDataSetChanged")
fun getCookFoodList() {
dbViewModel.getCookFoodListWithCallback(cookMode = 1) { cookFoodEntities ->
// 回调期间用户可能已切换 Tab,需再次校验
if (binding.rbSamplingCooking.isChecked.not()) return@getCookFoodListWithCallback
delayDismissLoading()
if (cookFoodEntities.isNullOrEmpty()) {
loadEmptyView()
return@getCookFoodListWithCallback
}
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
dinnerType = entity.dinnerType ?: "0"
})
}
list.clear()
list.addAll(tempList)
dishAdapter.notifyDataSetChanged()
finishRefresh()
binding.refreshLayout.run {
setEnableRefresh(false)
setEnableLoadMore(false)
}
}
// }
}
fun deleteCookFoodAndGoods(foodId: String, dinnerType: String, action: () -> Unit) {
lifecycleScope.launch {
dbViewModel.deleteCookFoodAndGoods(cookMode = 1, foodId = foodId, dinnerType = dinnerType)
action()
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
val isConfigPage = intent?.getBooleanExtra(IS_CONFIG_PAGE, false) ?: false
Log.d(TAG, "onNewIntent, isConfigPage=$isConfigPage")
if (isConfigPage) {
getCookFoodList()
return
}
val isCooking = intent?.getBooleanExtra(IS_COOKING, false) ?: false
Log.d(TAG, "onNewIntent, isCooking=$isCooking")
//toast("isCooking=$isCooking")
refreshPage(isCooking)
}
private var list: MutableList<FoodRecord> = mutableListOf()
private val dishAdapter by lazy {
FoodListAdapter(list = list, mode = FoodListAdapter.DisplayMode.SAMPLING_MODE).apply {
isStateViewEnable = true
onItemClick = onItemClick@{ position ->
// 在点击时立即捕获 item,避免协程挂起期间 list 刷新导致越界
val item = list.getOrNull(position) ?: return@onItemClick
judgeDeviceConfig { onItemClick(item) }
}
}
}
private fun onItemClick(item: FoodRecord) {
if (item.isCooking) {
// 烹饪中,跳到称熟重页面
startActivity<SubmitFoodActivity> {
putExtra(SubmitFoodActivity.FOOD_ITEM, item as Serializable)
}
return
}
startActivity<PrepareFoodActivity> {
putExtra(PrepareFoodActivity.FOOD_ITEM, item as Serializable)
}
}
@SuppressLint("NotifyDataSetChanged")
private fun initRecyclerView() {
binding.rvSamplingList.run {
layoutManager =
LinearLayoutManager(this@SamplingModeActivity, LinearLayoutManager.VERTICAL, false)
// 根据 isCooking 决定是否显示侧滑删除菜单(必须在 setAdapter 之前调用)
setSwipeMenuCreator { _, rightMenu, position ->
if (list.getOrNull(position)?.isCooking == true) {
rightMenu.addMenuItem(buildDeleteMenuItem())
}
}
// 点击侧滑菜单项:先关闭菜单,再弹窗确认删除
setOnItemMenuClickListener { menuBridge, position ->
menuBridge.closeMenu()
val item = list.getOrNull(position) ?: return@setOnItemMenuClickListener
CommonDialog(this@SamplingModeActivity)
.setTitle("删除确认")
.setContent("确定要删除「${item.foodName}」吗?")
.setNegativeButton("取消")
.setPositiveButton("删除") {
deleteCookFoodAndGoods(foodId = item.foodId ?: "", dinnerType = item.dinnerType) {
if (list.size > 1) {
dishAdapter.removeAt(position)
} else {
loadEmptyView()
}
toast("已删除")
}
}.show()
}
// item 点击事件
setOnItemClickListener { _, position ->
// 在点击时立即捕获 item,避免协程挂起期间 list 刷新导致越界
val item = list.getOrNull(position) ?: return@setOnItemClickListener
judgeDeviceConfig { onItemClick(item) }
}
adapter = dishAdapter
}
}
/**
* 构建侧滑删除菜单项
*/
private fun buildDeleteMenuItem(): SwipeMenuItem {
return SwipeMenuItem(this).apply {
setImage(R.drawable.ic_trash_white)
setBackground(R.drawable.bg_swipe_delete)
width = 160.dp
height = ViewGroup.LayoutParams.MATCH_PARENT
}
}
@SuppressLint("ClickableViewAccessibility")
private fun addViewListener() {
binding.btnAddSampling.setOnClickListener {
judgeDeviceConfig {
startActivity<PrepareFoodActivity>()
}
}
binding.refreshLayout.setOnRefreshListener {
pageNo = 1
if (binding.rbSamplingFinished.isChecked) {
getSamplingList()
} else {
finishRefresh()
refreshLocalData()
}
}
binding.refreshLayout.setOnLoadMoreListener {
if (binding.rbSamplingFinished.isChecked) {
getSamplingList()
} else {
finishRefresh()
}
}
binding.rbSamplingCooking.let {
it.setOnClickListener { _ ->
if (it.isChecked) {
return@setOnClickListener
}
binding.rbSamplingCooking.isChecked = true
binding.rbSamplingFinished.isChecked = false
requestData(true)
}
}
binding.rbSamplingFinished.let {
it.setOnClickListener { _ ->
// Log.d(TAG, "addViewListener: finish:${binding.rbSamplingFinished.isChecked}")
if (it.isChecked) {
return@setOnClickListener
}
binding.rbSamplingCooking.isChecked = false
binding.rbSamplingFinished.isChecked = true
requestData(false)
}
}
}
var pageNo = 1
private val pageSize = 30
@SuppressLint("NotifyDataSetChanged")
fun getSamplingList() {
netViewModel.sampleHistory(
pageNum = pageNo,
pageSize = pageSize,
onLoading = { showLoading() }
) { state ->
when (state) {
is UiState.Success -> {
// 回调期间用户可能已切换 Tab,需再次校验
if (binding.rbSamplingFinished.isChecked.not()) {
delayDismissLoading()
return@sampleHistory
}
loadDishList(state.data?.map { it.toFoodRecord() }?.toMutableList())
}
is UiState.Error -> {
if (binding.rbSamplingFinished.isChecked.not()) {
delayDismissLoading()
return@sampleHistory
}
binding.refreshLayout.setEnableRefresh(true)
toast(state.msg)
finishRefresh()
delayDismissLoading()
if (pageNo == 1) loadEmptyView()
}
else -> {}
}
}
}
/** 已提交采样列表条目 → FoodRecord 映射,供 FoodListAdapter(SAMPLING_MODE) 展示 */
private fun SampleHistoryItem.toFoodRecord(): FoodRecord = FoodRecord(
foodId = innovId,
foodName = foodName,
foodWeight = foodWeight,
cookMode = 1,
innovNo = innovNo,
status = status,
statusName = statusName
)
private fun finishRefresh() {
if (pageNo == 1) {
binding.refreshLayout.finishRefresh(1200)
} else {
binding.refreshLayout.finishLoadMore(1200)
}
}
private var emptyViewBinding: LayoutEmptyViewBinding? = null
@SuppressLint("NotifyDataSetChanged")
private fun loadEmptyView() {
list.clear()
dishAdapter.notifyDataSetChanged()
if (emptyViewBinding == null) {
emptyViewBinding = LayoutEmptyViewBinding.inflate(
LayoutInflater.from(this), binding.rvSamplingList, false
)
}
emptyViewBinding!!.tvContent.text = "暂无数据"
emptyViewBinding!!.tvSubContent.text = "暂无采样数据,点击下方按钮新增采样"
// 方案B:沿用 adapter stateView,使用 FrameLayout.LayoutParams + minimumHeight 避免空态贴顶
emptyViewBinding!!.root.layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
)
binding.rvSamplingList.post {
emptyViewBinding!!.root.minimumHeight = binding.rvSamplingList.height
dishAdapter.stateView = emptyViewBinding!!.root
finishRefresh()
}
//dishAdapter.setStateViewLayout(requireContext(), R.layout-sw800dp.layout_empty_view)
binding.refreshLayout.run {
setEnableRefresh(binding.rbSamplingFinished.isChecked)
setEnableLoadMore(false)
}
}
fun refreshPage(isCooking: Boolean) {
if (binding.rbSamplingFinished.isChecked) {
//当前是已采样
if (isCooking) {
//需要切换到烹饪中
binding.rbSamplingCooking.isChecked = true
binding.rbSamplingFinished.isChecked = false
requestData(true)
return
}
//需要切换到已采样,刷新数据
requestData(false)
return
}
//当前是烹饪中
if (isCooking.not()) {
//需要切换到已采样
binding.rbSamplingCooking.isChecked = false
binding.rbSamplingFinished.isChecked = true
requestData(false)
return
}
//需要切换到烹饪中,刷新数据
requestData(true)
}
fun refreshLocalData() {
showLoading()
getCookFoodList()
}
private fun requestData(isCooking: Boolean) {
list.clear()
dishAdapter.notifyDataSetChanged()
if (isCooking) {
refreshLocalData()
} else {
pageNo = 1
getSamplingList()
}
}
@SuppressLint("NotifyDataSetChanged")
private fun loadDishList(records: MutableList<FoodRecord>?) {
binding.refreshLayout.setEnableRefresh(true)
delayDismissLoading()
finishRefresh()
if (records.isNullOrEmpty()) {
//toast("暂未搜索到相关菜品信息")
if (pageNo == 1) {
loadEmptyView()
}
return
}
if (pageNo == 1) {
list.clear()
}
list.addAll(records)
dishAdapter.notifyDataSetChanged()
val isLoadMoreEnable = records.size >= pageSize
binding.refreshLayout.setEnableLoadMore(isLoadMoreEnable)
binding.refreshLayout.setEnableRefresh(true)
if (isLoadMoreEnable) {
pageNo++
}
}
private var configFinished = false
fun judgeDeviceConfig(block: () -> Unit) {
if (configFinished) {
block()
return
}
lifecycleScope.launch {
val slots = dbViewModel.loadSeasoningSlot()
if (slots.isEmpty()) {
showDeviceConfigDialog()
return@launch
}
configFinished = true
block()
}
}
private fun showDeviceConfigDialog() {
CommonDialog(this)
.setTitle("配置提示")
.setContent("请先在设备配置页面进行调料设置")
.setNegativeButton("取消")
.setPositiveButton("去设置") { startActivity<SettingActivity>() }
.show()
}
private var isVisible = false
override fun onResume() {
super.onResume()
isVisible = true
}
override fun onPause() {
super.onPause()
isVisible = false
}
}
@@ -1,187 +0,0 @@
package com.shuwei.dish.match.ui
import android.content.Intent
import android.os.Bundle
import androidx.activity.addCallback
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import com.shuwei.dish.match.R
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.databinding.ActivitySelectDishBinding
import com.shuwei.dish.match.dialog.CommonDialog
import com.shuwei.dish.match.ui.fragment.DishListFragment
import com.shuwei.dish.match.utils.KeyboardUtil
import com.shuwei.dish.match.utils.ext.addOnActionSearchListener
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.startActivity
import com.shuwei.dish.match.utils.ext.toast
import com.shuwei.dish.match.utils.ext.visible
class SelectDishActivity : BaseActivity() {
companion object {
const val IS_COOKING = "isCooking"
const val DINNER_TYPE = "dinnerType"
const val IS_CONFIG_PAGE = "isConfigPage"
}
private lateinit var binding: ActivitySelectDishBinding
private var dinnerType: String = "1"
private val fragmentList = mutableListOf<DishListFragment>().apply {
add(DishListFragment.instance("1"))
add(DishListFragment.instance("2"))
add(DishListFragment.instance("3"))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivitySelectDishBinding.inflate(layoutInflater)
setContentView(binding.root)
setHeaderBackground()
setTitleBar(titleBarAction = {
it.visible()
}, titleAction = {
it.text = "选择菜品"
}, rightIconActon = {
it.visible()
it.setImageResource(R.drawable.ic_setting)
it.setOnClickListener {
startActivity<SettingActivity>()
}
}, backAction = {
it.gone()
})
addViewListener()
loadFragment()
onBackPressedDispatcher.addCallback(this) {}
}
fun getCookFoodList() {
appViewModel.getCookFoodList(cookMode = 0, dinnerType = dinnerType)
}
private var configFinished = false
fun judgeDeviceConfig(block: () -> Unit) {
// if (configFinished) {
// block()
// return
// }
// appViewModel.loadSeasoning {
// if (it.isEmpty()) {
// showDeviceConfigDialog()
// return@loadSeasoning
// }
// configFinished = true
// block()
// }
block()
}
private fun showDeviceConfigDialog() {
CommonDialog(this)
.setTitle("配置提示")
.setContent("请先在设备配置页面进行调料设置")
.setNegativeButton("取消")
.setPositiveButton("去设置") { startActivity<SettingActivity>() }
.show()
}
fun addViewListener() {
binding.dishRadioGroup.setOnCheckedChangeListener { _, checkedId ->
dinnerType = when (checkedId) {
R.id.rbBreakfast -> "1"
R.id.rbLunch -> "2"
R.id.rbDinner -> "3"
else -> "1"
}
loadFragment()
}
binding.etInputDish.addOnActionSearchListener {
jumpSearch()
}
binding.ivDishSearch.setOnClickListener {
jumpSearch()
}
}
private fun jumpSearch() {
val searchText = binding.etInputDish.text.toString().trim()
if (searchText.isBlank()) {
toast(binding.etInputDish.hint.toString())
return
}
startActivity<FoodSearchActivity> {
putExtra(FoodSearchActivity.PAGE_TYPE, 1)
putExtra(FoodSearchActivity.DINNER_TYPE, dinnerType)
putExtra(FoodSearchActivity.FOOD_NAME, searchText)
}
KeyboardUtil.hideKeyboard(binding.etInputDish)
}
private fun loadFragment() {
runCatching {
fragmentList[dinnerType.toInt() - 1].let {
supportFragmentManager.beginTransaction().apply {
if (fragmentList.contains(it)) {
replace(R.id.flSubPage, it)
} else {
add(R.id.flSubPage, it)
}
commit()
}
}
}.onFailure {
it.printStackTrace()
}
}
fun deleteCookFoodAndGoods(foodId: String, action: () -> Unit) {
lifecycleScope.launch {
appViewModel.deleteCookFoodAndGoods(cookMode = 0, foodId = foodId)
action()
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
runCatching {
val isConfigPage = intent?.getBooleanExtra(IS_CONFIG_PAGE, false) ?: false
if (isConfigPage) {
return
}
val isCooking = intent?.getBooleanExtra(IS_COOKING, false) ?: false
val lastDinnerType = intent?.getStringExtra(DINNER_TYPE) ?: "0"
if (lastDinnerType == "1" || lastDinnerType == "2" || lastDinnerType == "3") {
fragmentList[lastDinnerType.toInt() - 1].getDishList(1, lastDinnerType)
}
when (lastDinnerType) {
"1" -> binding.dishRadioGroup.check(R.id.rbBreakfast)
"2" -> binding.dishRadioGroup.check(R.id.rbLunch)
"3" -> binding.dishRadioGroup.check(R.id.rbDinner)
}
}.onFailure {
it.printStackTrace()
}
}
private var isVisible = false
override fun onResume() {
super.onResume()
isVisible = true
}
override fun onPause() {
super.onPause()
isVisible = false
}
public fun getFoodList(param: MutableMap<String, Any>) {
netViewModel.searchFoodList(param = param)
}
}
@@ -9,18 +9,23 @@ import android.os.Build
import android.os.Bundle
import android.provider.Settings
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager
import com.chad.library.adapter4.util.setOnDebouncedItemClick
import com.shuwei.dish.match.adapter.SettingAdapter
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.dialog.EnvSwitchDialog
import com.shuwei.dish.match.model.SettingItem
import com.shuwei.dish.match.scale.ScaleServiceManager
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切换
* 设置页面
* 使用 RecyclerView 展示设置菜单项,点击逻辑内聚在各 SettingItem 的 onClick 中
*/
class SettingActivity : BaseActivity() {
@@ -29,10 +34,15 @@ class SettingActivity : BaseActivity() {
}
private lateinit var binding: ActivitySettingBinding
// private val cameraUtils: CameraUtils by lazy {
// CameraUtils(this)
// }
// private lateinit var previewView: PreviewView
/** 包含全部菜单项的完整列表(含隐藏项) */
private val allItems: List<SettingItem> by lazy { buildAllItems() }
private val adapter = SettingAdapter().apply {
setOnDebouncedItemClick { _, _, position ->
getItem(position)?.onClick()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -42,40 +52,111 @@ class SettingActivity : BaseActivity() {
setTitleBar(titleBarAction = {
it.visible()
}, titleAction = {
it.text = "备设"
}, rightIconActon = {
it.text = "设置"
}, rightIconAction = {
it.visible()
// 点击右上角图标,展示全部隐藏的开发者选项
it.clickWithDebounce {
startActivity<MasterScaleActivity>()
adapter.submitList(allItems.toList())
}
})
initView()
// // 默认显示设备配置Fragment
// if (savedInstanceState == null) {
// showDeviceConfigFragment()
// }
//
// initUI()
}
private fun initView() {
binding.tvCurrentMode.text = when (SpTool.cookMode) {
binding.rvSettings.layoutManager = LinearLayoutManager(this)
binding.rvSettings.adapter = adapter
// 默认只展示非隐藏项
adapter.submitList(allItems.filter { !it.isHidden })
}
/**
* 构建完整的设置菜单列表,每项的 onClick 直接注入对应业务逻辑
*/
private fun buildAllItems(): List<SettingItem> = listOf(
SettingItem(
type = SettingItem.Type.COOK_MODE,
title = "菜品模式",
subtitle = when (SpTool.cookMode) {
0 -> "当前:制作模式"
1 -> "当前:采样模式"
else -> "当前:未知模式"
}
binding.llCookMode.clickWithDebounce {
},
onClick = {
SingleFragmentActivity.start(this, SingleFragmentActivity.PageType.COOK_MODE)
}
binding.llSeasoningConfig.clickWithDebounce {
),
SettingItem(
type = SettingItem.Type.SEASONING_CONFIG,
title = "调料区设置",
onClick = {
SingleFragmentActivity.start(this, SingleFragmentActivity.PageType.SEASONING_CONFIG)
}
binding.llFoodCollect.clickWithDebounce {
checkCameraPermission()
),
SettingItem(
type = SettingItem.Type.FOOD_COLLECT,
title = "食材采集",
onClick = { checkCameraPermission() }
),
SettingItem(
type = SettingItem.Type.WEIGHT_CONFIG,
title = "减重配置",
onClick = {
SingleFragmentActivity.start(this, SingleFragmentActivity.PageType.WEIGHT_CONFIG)
}
),
SettingItem(
type = SettingItem.Type.DB_INSPECT,
title = "数据库查看",
isHidden = true,
onClick = {
SingleFragmentActivity.start(this, SingleFragmentActivity.PageType.DB_INSPECT)
}
),
SettingItem(
type = SettingItem.Type.SCALE_OBSERVE,
title = "秤数据监控",
isHidden = true,
onClick = { startActivity<MasterScaleActivity>() }
),
SettingItem(
type = SettingItem.Type.ENV_SWITCH,
title = "切换环境",
onClick = { EnvSwitchDialog(this).show() }
),
SettingItem(
type = SettingItem.Type.CLEAR_DATA,
title = "数据清除",
isHidden = true,
onClick = { showClearDataDialog() }
),
SettingItem(
type = SettingItem.Type.TEST_SEASONING,
title = "测试调料拿取",
isHidden = true,
onClick = { startActivity<SubmitFoodActivity> { } }
),
)
/**
* 弹出清除测试数据的二次确认弹窗
*/
private fun showClearDataDialog() {
CommonDialog(this)
.setTitle("数据清除")
.setContent("将物理删除全部 4 张表数据(菜品、食材、调料、槽位),此操作不可恢复,同时重置菜品模式和食堂id,确认继续?")
.setNegativeButton("取消")
.setPositiveButton("确认") {
dbViewModel.clearAllData {
SpTool.cookMode = -1
SpTool.canteenId = "0"
// 通知所有已连接子设备同步清除数据
ScaleServiceManager.sendClearData()
startActivity<InitActivity>()
}
}
.show()
}
private fun openCollectPage() {
@@ -83,12 +164,11 @@ class SettingActivity : BaseActivity() {
}
/**
* 检查相机权限并显示 CollectFragment
* 检查相机权限,有权限则直接打开采集页,否则发起授权请求
*/
@SuppressLint("ObsoleteSdkInt")
private fun checkCameraPermission() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
// Android 6.0 以下,直接打开
openCollectPage()
return
}
@@ -97,16 +177,12 @@ class SettingActivity : BaseActivity() {
Manifest.permission.CAMERA
) == PackageManager.PERMISSION_GRANTED
) {
// 权限已授予,直接打开
openCollectPage()
} else {
// 权限未授予,申请权限
requestPermission(Manifest.permission.CAMERA) { isGranted ->
if (isGranted) {
// 权限已授予,直接打开
openCollectPage()
} else {
// 权限被拒绝
showNotGrantedCameraPermissionDialog()
}
}
@@ -128,197 +204,4 @@ class SettingActivity : BaseActivity() {
}
.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()
// }
// }
}
@@ -5,10 +5,11 @@ 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.DbInspectFragment
import com.shuwei.dish.match.ui.fragment.VectorCollectionFragment
import com.shuwei.dish.match.ui.fragment.DeviceConfigFragment
import com.shuwei.dish.match.ui.fragment.SeasoningConfigFragment
import com.shuwei.dish.match.ui.fragment.WeightConfigFragment
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.visible
@@ -36,6 +37,12 @@ class SingleFragmentActivity : BaseActivity() {
/** 调料配置页 */
SEASONING_CONFIG,
/** 数据库调试查看页 */
DB_INSPECT,
/** 默认减重配置页 */
WEIGHT_CONFIG,
}
companion object {
@@ -78,7 +85,7 @@ class SingleFragmentActivity : BaseActivity() {
setTitleBar(
titleBarAction = { it.visible() },
titleAction = { it.text = getTitleForPage(pageType) },
rightIconActon = { it.gone() }
rightIconAction = { it.gone() }
)
// 首次创建时加载 Fragment,避免屏幕旋转重复添加
@@ -95,17 +102,21 @@ class SingleFragmentActivity : BaseActivity() {
*/
private fun getTitleForPage(pageType: PageType): String = when (pageType) {
PageType.FOOD_COLLECT -> "食材采集"
PageType.COOK_MODE -> "品模式"
PageType.COOK_MODE -> "品模式"
PageType.SEASONING_CONFIG -> "调料区设置"
PageType.DB_INSPECT -> "数据库查看"
PageType.WEIGHT_CONFIG -> "减重配置"
}
/**
* 根据页面类型创建对应 Fragment 实例
*/
private fun createFragment(pageType: PageType): Fragment = when (pageType) {
PageType.FOOD_COLLECT -> CollectFragment()
PageType.FOOD_COLLECT -> VectorCollectionFragment()
PageType.COOK_MODE -> DeviceConfigFragment()
PageType.SEASONING_CONFIG -> SeasoningConfigFragment()
PageType.DB_INSPECT -> DbInspectFragment()
PageType.WEIGHT_CONFIG -> WeightConfigFragment()
}
override fun onResume() {
@@ -18,16 +18,16 @@ 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.db.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.SpTool
import com.shuwei.dish.match.utils.WeightUtil
import com.shuwei.dish.match.utils.ext.dp
import kotlinx.coroutines.launch
@@ -77,6 +77,7 @@ class SlaveActivity : BaseActivity() {
listenLocalScales()
listenMasterConnection()
listenSeasoningConfig()
listenClearData()
addBackKeyListener()
}
@@ -109,6 +110,8 @@ class SlaveActivity : BaseActivity() {
it.onItemClick = { scale, _ -> showTareDialog(scale.address) }
}
binding.rvScaleList.adapter = scale22Adapter
// 启动时用空哨兵预填全部格子,不依赖 WeightUtil 回调才显示结构
scale22Adapter!!.updateByAddress(emptyList())
}
ScaleDeviceConfig.DEVICE_ID_18 -> {
binding.rvScaleList.updateLayoutParams<LinearLayout.LayoutParams> { topMargin = 0 }
@@ -121,15 +124,18 @@ class SlaveActivity : BaseActivity() {
it.onItemClick = { scale, _ -> showTareDialog(scale.address) }
}
binding.rvScaleList.adapter = scale18Adapter
// 启动时用空哨兵预填全部格子,不依赖 WeightUtil 回调才显示结构
scale18Adapter!!.updateByAddress(emptyList())
}
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)
}
linearAdapter.onItemClick = { item -> showTareDialog(item.address) }
binding.rvScaleList.adapter = linearAdapter
// 启动时预填空占位,不依赖 WeightUtil 回调才显示列表结构
ScaleDeviceConfig.SCALE_ORDER_1.forEach { addr ->
linearAdapter.updateItem(ScaleRowAdapter.ScaleItem(addr, 0.0, WeightUtil.STATE_STABLE))
}
}
}
}
@@ -156,7 +162,7 @@ class SlaveActivity : BaseActivity() {
*/
private fun loadSlotsFromDb() {
lifecycleScope.launch {
val slots = appViewModel.getSeasoningSlotsByDeviceId(GlobalData.deviceId)
val slots = dbViewModel.getSeasoningSlotsByDeviceId(GlobalData.deviceId)
applySlots(slots)
}
}
@@ -173,13 +179,30 @@ class SlaveActivity : BaseActivity() {
?: return@onSeasoningConfig
lifecycleScope.launch {
// 全量替换:先删除本机所有旧槽位,再写入最新配置
appViewModel.deleteAllSlotsByDeviceId(GlobalData.deviceId)
appViewModel.upsertAllSeasoningSlots(mySlots)
dbViewModel.deleteAllSlotsByDeviceId(GlobalData.deviceId)
dbViewModel.upsertAllSeasoningSlots(mySlots)
applySlots(mySlots)
}
}
}
/**
* 监听主设备下发的清除数据事件
* 收到后清空本机全部 4 张表数据并重置 SP 状态,与主设备保持一致
* 清除完成后刷新 UI,清空所有秤的调料名称显示
*/
private fun listenClearData() {
ScaleServiceManager.onClearData = {
lifecycleScope.launch {
dbViewModel.clearAllData {
SpTool.cookMode = -1
SpTool.canteenId = "0"
runOnUiThread { applySlots(emptyList()) }
}
}
}
}
/**
* 将槽位配置应用到对应 adapter(按地址匹配格子位置)
* 同时以传入列表为准全量刷新 slotNameMap
@@ -203,12 +226,8 @@ class SlaveActivity : BaseActivity() {
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)
}
val idx = linearAdapter.items.indexOfFirst { it.address == addr }
if (idx >= 0) linearAdapter.removeByAddress(addr)
}
}
}
@@ -225,14 +244,7 @@ class SlaveActivity : BaseActivity() {
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)
}
}
else -> linearAdapter.updateName(slot.address, slot.goodsName)
}
}
}
@@ -272,15 +284,7 @@ class SlaveActivity : BaseActivity() {
}
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)
linearAdapter.updateItem(item)
}
}
@@ -12,37 +12,38 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.GridLayoutManager
import com.google.gson.reflect.TypeToken
import com.shuwei.dish.match.R
import com.shuwei.dish.match.adapter.SeasoningWeightAdapter
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.databinding.ActivitySubmitFoodBinding
import com.shuwei.dish.match.dialog.CommonDialog
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.net.NetViewModel
import com.shuwei.dish.match.dialog.GoodsPreviewDialog
import com.shuwei.dish.match.db.entity.CookFoodEntity
import com.shuwei.dish.match.db.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.model.ConstituteSaveItem
import com.shuwei.dish.match.model.ConstituteSaveRequest
import com.shuwei.dish.match.model.CookCompleteItem
import com.shuwei.dish.match.model.CookCompleteRequest
import com.shuwei.dish.match.model.FoodRecord
import com.shuwei.dish.match.model.toDTO
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.DateTimeUtil
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.gone
import com.shuwei.dish.match.utils.ext.roundedDecimalPlace
import com.shuwei.dish.match.utils.ext.roundedOneDecimalPlace
import com.shuwei.dish.match.utils.ext.startActivity
import com.shuwei.dish.match.utils.ext.toJsonString
import com.shuwei.dish.match.utils.ext.toType
import com.shuwei.dish.match.utils.ext.toast
import com.shuwei.dish.match.utils.ext.visible
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.LocalDateTime
@SuppressLint("UseSparseArrays")
class SubmitFoodActivity : BaseActivity() {
@@ -51,6 +52,12 @@ class SubmitFoodActivity : BaseActivity() {
const val TAG = "SubmitFoodActivity"
const val FOOD_ITEM = "foodItem"
const val GOODS_LIST = "goodsList"
const val COOK_ORDER_ID = "cookOrderId"
/** 本地临时 foodId 前缀;以此开头说明菜品尚未在服务器登记,提交时需清空让服务器分配真实 ID */
const val LOCAL_ID_PREFIX = "localId_"
const val WEIGHT_THRESHOLD = 0.01
}
private lateinit var binding: ActivitySubmitFoodBinding
@@ -58,6 +65,9 @@ class SubmitFoodActivity : BaseActivity() {
private var food: FoodRecord? = null
private var goodsList: MutableList<CookFoodGoodsEntity>? = null
/** 烹制单 ID,制作模式从 PrepareFoodActivity 传入,用于新配比秤烹饪完成接口 */
private var cookOrderId: String? = null
private val cookFoodEntity by lazy {
CookFoodEntity().apply {
food?.let {
@@ -97,15 +107,38 @@ class SubmitFoodActivity : BaseActivity() {
*/
private val baseSeasoningMap = mutableMapOf<String, Pair<String, Double>>()
/**
* 各秤上一次处理过的 ts,用于跳过 StateFlow 因其他秤变化而重发的相同帧
*/
private val lastTsMap = mutableMapOf<String, Long>()
/** 主设备 2格秤的初始重量,首次收到数据时赋值;调用清零后重置为 0.0 */
private var initWeight: Double = 0.0
/** 烹饪开始时间(进入本页面的时刻),提交烹饪完成接口时上报 */
private var cookStartTime: String = ""
/**
* 调料首次使用时间,key = goodsNamevalue = 首次拿取时间 yyyy-MM-dd HH:mm:ss
* 秤首次检测到拿取(用量超阈值)时记录,提交时按 goodsName 匹配填入 putTime
*/
private val seasoningFirstUseMap = mutableMapOf<String, String>()
/** initWeight 是否已完成初始化 */
// private var initWeightSet: Boolean = false
@Suppress("unchecked_cast", "DEPRECATION")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivitySubmitFoodBinding.inflate(layoutInflater)
setContentView(binding.root)
// 以进入本页面的时刻作为烹饪开始时间
cookStartTime = DateTimeUtil.formatDateTime(LocalDateTime.now())
setHeaderBackground()
intent.extras?.apply {
food = getSerializable(FOOD_ITEM) as FoodRecord?
Log.d(TAG, "onCreate: cookMode=${food?.cookMode}")
cookOrderId = getString(COOK_ORDER_ID)
Log.d(TAG, "onCreate: cookMode=${food?.cookMode}, cookOrderId=$cookOrderId")
// isCooking=false:从前一页面带入主辅材数据;isCooking=true:从数据库查询
if (food?.isCooking == false) {
goodsList = getSerializable(GOODS_LIST) as MutableList<CookFoodGoodsEntity>?
@@ -116,8 +149,15 @@ class SubmitFoodActivity : BaseActivity() {
it.visible()
}, titleAction = {
it.text = food?.foodName
}, rightIconActon = {
it.gone()
}, rightIconAction = {
it.visible()
it.setImageResource(R.drawable.ic_list_view)
it.setOnClickListener {
lifecycleScope.launch {
val list = buildPreviewGoodsList()
GoodsPreviewDialog(this@SubmitFoodActivity, list).show()
}
}
}, backAction = {
it.setOnClickListener {
remindSaveDataDialog()
@@ -153,20 +193,23 @@ class SubmitFoodActivity : BaseActivity() {
}
}
/** 初始化 RecyclerView2列 GridLayoutManager */
/** 初始化 RecyclerView默认 4 列 GridLayoutManager */
private fun setupRecyclerView() {
binding.rvSeasoning.itemAnimator = null
binding.rvSeasoning.layoutManager = GridLayoutManager(this, 2)
binding.rvSeasoning.adapter = seasoningAdapter
binding.rvSeasoning.let {
it.itemAnimator = null
it.layoutManager = GridLayoutManager(this, 4, GridLayoutManager.VERTICAL, false)
it.adapter = seasoningAdapter
}
}
/**
* 从 Room 加载槽位配置后,开始订阅子设备秤数据
* isCooking=true 时从数据库查询 goodsList,筛选调料预填充 adapter,再开始观测
* isCooking=true 时从数据库查询 goodsList预填充调料 adapter
* isCooking=false 时从前一页面带入的 goodsList 中预填充调料 adapter,再开始观测
*/
private fun loadSlotsThenObserveScales() {
lifecycleScope.launch {
val slots = appViewModel.loadSeasoningSlot()
val slots = dbViewModel.loadSeasoningSlot()
slots.forEach { slot ->
slotMap["${slot.deviceId}#${slot.address}"] = slot.goodsId to slot.goodsName
}
@@ -174,12 +217,27 @@ class SubmitFoodActivity : BaseActivity() {
// 制作中:从数据库查询 goodsList,筛选调料数据预填充 adapter
val foodId = food?.foodId ?: return@launch
val cookMode = food?.cookMode ?: return@launch
val list = appViewModel.getCookFoodGoodsList(foodId, cookMode)
val list =
dbViewModel.getCookFoodGoodsList(foodId, cookMode, food?.dinnerType ?: "0")
goodsList = list
prefillSeasoningFromGoodsList(list)
} else {
// 非制作中:从前一页面带入的 goodsList 中筛选调料数据预填充 adapter
goodsList?.let { prefillSeasoningFromGoodsList(it) }
}
observeScaleData()
}
}
/**
* 从 goodsList 中筛选 materialType==3 的调料数据,
* 写入 baseSeasoningMap 作为历史用量基准,并预填充到 adapter
*/
private fun prefillSeasoningFromGoodsList(list: List<CookFoodGoodsEntity>) {
list.filter { it.materialType == 3 }.forEach { item ->
val name = item.goodsName ?: ""
val weight = item.useWeight ?: 0.0
baseSeasoningMap[name] = (item.goodsId) to weight
baseSeasoningMap[name] = item.goodsId to weight
seasoningAdapter.updateItem(
SeasoningWeightAdapter.Item(
goodsId = item.goodsId,
@@ -188,11 +246,6 @@ class SubmitFoodActivity : BaseActivity() {
)
)
}
observeScaleData()
} else {
observeScaleData()
}
}
}
/**
@@ -202,14 +255,21 @@ class SubmitFoodActivity : BaseActivity() {
private fun observeScaleData() {
val flow = ScaleServiceManager.allScales ?: return
lifecycleScope.launch {
flow.collectLatest { scaleMap ->
flow.collect { scaleMap ->
//Log.d(TAG, "observeScaleData,scaleMap=${scaleMap.toJsonString()}")
scaleMap.forEach { (key, data) ->
// ts 未变化说明是其他秤更新触发的 StateFlow 重发,跳过
if (lastTsMap[key] == data.ts) return@forEach
lastTsMap[key] = data.ts
// 排除主设备 2格秤
if (data.deviceId == ScaleDeviceConfig.DEVICE_ID_2) return@forEach
// 从槽位配置读取调料名,未配置则跳过
val (goodsId, goodsName) = slotMap[key] ?: return@forEach
Log.d(
TAG,
"observeScaleData,1key=$key, goodsId=$goodsId, goodsName=$goodsName, data=$data"
)
// 首次收到稳定数据时记录基准重量
val baseline = baselineMap[key]
if (baseline == null) {
@@ -218,20 +278,34 @@ class SubmitFoodActivity : BaseActivity() {
}
return@forEach
}
Log.d(TAG, "observeScaleData,2key=$key, baseline=$baseline, data.weight=${data.weight}")
// 用量 = 初始重量 - 当前重量,负值归零
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)
}
if (useWeight < WEIGHT_THRESHOLD) {
// 放回场景:必须稳定后才移除,避免拿取过程中误删
if (data.state != WeightUtil.STATE_STABLE) return@forEach
if (rawWeightMap.remove(key) != null) {
Log.d(TAG, "observeScaleData,移除key=${key}, goodsName=$goodsName")
refreshAdapterByName(goodsName)
}
} else {
// 拿取场景:实时响应,无需等待稳定
val newTriple = Triple(goodsId, goodsName, useWeight)
if (rawWeightMap[key] != newTriple) {
// 该调料首次检测到拿取时,记录首次使用时间(同名调料只记最早一次)
if (!seasoningFirstUseMap.containsKey(goodsName)) {
seasoningFirstUseMap[goodsName] =
DateTimeUtil.formatDateTime(LocalDateTime.now())
}
Log.d(TAG, "observeScaleData,新增:$newTriple")
rawWeightMap[key] = newTriple
refreshAdapterByName(goodsName)
}
}
}
}
}
}
@@ -239,39 +313,63 @@ class SubmitFoodActivity : BaseActivity() {
/**
* 按 goodsName 聚合 rawWeightMap,叠加历史用量后推给 adapter
* isCooking=true 时只新增,不移除(历史调料数据不可减少)
* 移除前检查 item 是否存在,更新前比较数据是否变化,避免无效操作
*/
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) {
Log.d(TAG, "refreshAdapterByName, goodsName=$goodsName, newWeight=$newWeight, baseWeight=$baseWeight, totalWeight=$totalWeight, isCooking=${food?.isCooking}")
if (totalWeight < WEIGHT_THRESHOLD) {
Log.d(TAG, "refreshAdapterByName,重量小于${WEIGHT_THRESHOLD}g, isCooking=${food?.isCooking}, goodsName=$goodsName, totalWeight=$totalWeight")
// 仅保护 baseSeasoningMap 中的历史调料;本次会话新加的调料可以移除
if (!baseSeasoningMap.containsKey(goodsName)) {
// 只有 item 确实存在时才调用 remove,避免无效遍历
if (seasoningAdapter.items.any { it.goodsName == goodsName }) {
seasoningAdapter.removeItem(goodsName)
}
}
return
}
val goodsId = grouped.firstOrNull()?.first?.takeIf { it.isNotEmpty() } ?: baseGoodsId
seasoningAdapter.updateItem(
SeasoningWeightAdapter.Item(
// 数据未变化时跳过,避免触发不必要的 RecyclerView 刷新
if (seasoningAdapter.items.any { it.goodsName == goodsName && it.useWeight == totalWeight && it.goodsId == goodsId }) return
val item = SeasoningWeightAdapter.Item(
goodsId = goodsId,
goodsName = goodsName,
useWeight = totalWeight
)
)
Log.d(TAG, "refreshAdapterByName,更新数据item=${item.toJsonString()}")
seasoningAdapter.updateItem(item)
}
private fun addViewListener() {
// 切换调料列表布局:4 列卡片 ↔ 2 列水平,图标始终显示"目标布局"样式
binding.ivSeasoningLayoutToggle.clickWithDebounce {
seasoningAdapter.isListMode = !seasoningAdapter.isListMode
val spanCount = if (seasoningAdapter.isListMode) 2 else 4
(binding.rvSeasoning.layoutManager as? GridLayoutManager)?.spanCount = spanCount
seasoningAdapter.notifyDataSetChanged()
binding.ivSeasoningLayoutToggle.setImageResource(
if (seasoningAdapter.isListMode) R.drawable.ic_layout_grid else R.drawable.ic_layout_list
)
}
binding.btnCook.clickWithDebounce {
val content = getRemindSpannable("开始制作")
showRemindDialog(content) { cook() }
}
binding.btnWeightClear.clickWithDebounce { WeightUtil.tareTwo(2) }
binding.btnWeightClear.clickWithDebounce {
WeightUtil.tareTwo(2)
// 清零后将初始重量重置为 0,后续以秤读数直接作为净重
initWeight = 0.0
}
binding.btnSubmit.clickWithDebounce {
if (cookFoodEntity.foodWeight <= 0.toDouble()) {
toast("未识别到菜品熟重")
return@clickWithDebounce
// 测试设备无秤硬件,使用模拟熟重 500g
cookFoodEntity.foodWeight = 500.0
binding.tvTotalWeight.text = "500.0"
binding.tvWeightUnit.text = ""
}
val content = getRemindSpannable("制作完成")
showRemindDialog(content) { submit() }
@@ -280,12 +378,19 @@ class SubmitFoodActivity : BaseActivity() {
// 监听主设备 2格秤的熟重
WeightUtil.addWeightListener(TAG) { address, _, weight ->
if (address == AddressUtil.TWO) {
cookFoodEntity.foodWeight = weight
if (weight > 1000) {
binding.tvTotalWeight.text = "${(weight / 1000f).roundedDecimalPlace(3)}"
// 首次收到数据时记录初始重量
// if (!initWeightSet) {
// initWeight = weight
// initWeightSet = true
// }
// 实际净重 = 秤读数 - 初始重量
val netWeight = weight - initWeight
cookFoodEntity.foodWeight = netWeight
if (netWeight > 1000) {
binding.tvTotalWeight.text = "${(netWeight / 1000f).roundedDecimalPlace(3)}"
binding.tvWeightUnit.text = "千克"
} else {
binding.tvTotalWeight.text = "$weight"
binding.tvTotalWeight.text = "$netWeight"
binding.tvWeightUnit.text = ""
}
}
@@ -297,25 +402,18 @@ class SubmitFoodActivity : BaseActivity() {
toast("未获取到菜品或构成信息")
return
}
val seasoningData = seasoningAdapter.items
// if (seasoningData.isEmpty()) {
// toast("未获取到调料信息")
// return
// }
showLoading()
// 将调料数据转为 CookFoodGoodsEntity 追加到 goodsList
// val typeToken = object : TypeToken<List<CookFoodGoodsEntity>>() {}
val tempSeasoningList = seasoningData.map { item ->
CookFoodGoodsEntity().also { entity ->
entity.goodsId = item.goodsId
entity.goodsName = item.goodsName
entity.useWeight = item.useWeight
entity.materialType = 3
val isSamplingData = food!!.cookMode == 1
if (isSamplingData) {
val tempFoodId = cookFoodEntity.foodId
.ifBlank { "$LOCAL_ID_PREFIX${System.currentTimeMillis()}" }
cookFoodEntity.foodId = tempFoodId
}
}
goodsList?.addAll(tempSeasoningList)
// 主辅材统一在此赋 foodId;新加的调料行 foodId 由 buildSeasoningEntity 内部设置
goodsList?.forEach { it.foodId = cookFoodEntity.foodId }
lifecycleScope.launch {
cookFoodEntity.let {
it.dinnerType = when (it.dinnerType) {
"早餐" -> "1"
@@ -324,16 +422,12 @@ class SubmitFoodActivity : BaseActivity() {
else -> it.dinnerType
}
}
val isSamplingData = food!!.cookMode == 1
if (isSamplingData) {
val tempFoodId = cookFoodEntity.foodId.ifBlank { "${System.currentTimeMillis()}" }
cookFoodEntity.foodId = tempFoodId
}
goodsList?.forEach { it.foodId = cookFoodEntity.foodId }
lifecycleScope.launch {
appViewModel.saveCookFoodAndGoods(
// 先移除 goodsList 中已有的调料数据(materialType==3),再以 adapter 中最新调料覆盖
goodsList?.removeAll { it.materialType == 3 }
seasoningAdapter.items
.filter { it.useWeight > 0.0 }
.forEach { goodsList?.add(buildSeasoningEntity(it)) }
dbViewModel.saveCookFoodAndGoods(
cookMode = food!!.cookMode,
entity = cookFoodEntity,
list = goodsList
@@ -348,21 +442,29 @@ class SubmitFoodActivity : BaseActivity() {
toast("未获取到菜品或构成信息")
return
}
val seasoningData = seasoningAdapter.items
showLoading()
// 将重量大于0且不重复的调料追加到 goodsList
seasoningData.forEach { item ->
if (item.useWeight > 0.0 && goodsList?.none { it.goodsId == item.goodsId } == true) {
goodsList?.add(CookFoodGoodsEntity().also { entity ->
entity.goodsId = item.goodsId
entity.goodsName = item.goodsName
entity.useWeight = item.useWeight
entity.materialType = 3
})
}
lifecycleScope.launch {
// 先移除 goodsList 中已有的调料数据(materialType==3),再以 adapter 中最新调料覆盖
goodsList?.removeAll { it.materialType == 3 }
goodsList?.forEach { it.foodId = cookFoodEntity.foodId }
seasoningAdapter.items
.filter { it.useWeight > 0.0 }
.forEach { goodsList?.add(buildSeasoningEntity(it)) }
// 制作模式 + 有烹制单 ID → 调新配比秤烹饪完成接口
if (!cookOrderId.isNullOrBlank() && food?.cookMode == 0) {
submitWithNewApi()
return@launch
}
// 采样模式 → 调新配比秤提交采样接口(落库味养创新实验,状态=实验中)
if (food?.cookMode == 1) {
submitConstitute()
return@launch
}
// 旧接口路径(无烹制单 ID 的制作模式兜底)
cookFoodEntity.let {
it.matchingConstituteInfoList = this@SubmitFoodActivity.goodsList
it.dinnerType = when (it.dinnerType) {
@@ -373,14 +475,162 @@ class SubmitFoodActivity : BaseActivity() {
}
}
val isSamplingData = food!!.cookMode == 1
if (isSamplingData) {
if (cookFoodEntity.foodId.startsWith(LOCAL_ID_PREFIX)) {
cookFoodEntity.foodId = ""
goodsList?.forEach { it.foodId = "" }
}
Log.d(TAG, "submit: json=${cookFoodEntity.toJsonString()}")
netViewModel.submitCookFood(entity = cookFoodEntity)
netViewModel.submitCookFood(entity = cookFoodEntity.toDTO())
}
}
/**
* 新配比秤烹饪完成接口:从 goodsList 提取主辅材和调料,组装 CookCompleteRequest 提交
* 熟重 foodWeight(g) → cookedWeight(kg)
*/
private fun submitWithNewApi() {
val allGoods = goodsList ?: return
// 主辅材:materialType 1(主材) 或 2(辅材)
val items = allGoods
.filter { it.materialType == 1 || it.materialType == 2 }
.map { CookCompleteItem(
materId = it.materId?.takeIf { mid -> mid.isNotEmpty() } ?: it.goodsId,
actualQty = it.useWeight ?: 0.0
) }
if (items.isEmpty()) {
toast("无主辅材构成信息")
dismissLoading()
return
}
// 调料:materialType 3,按 goodsName 匹配首次使用时间填入 putTime
val seasonings = allGoods
.filter { it.materialType == 3 }
.map { CookCompleteItem(
materId = it.materId?.takeIf { mid -> mid.isNotEmpty() } ?: it.goodsId,
actualQty = it.useWeight ?: 0.0,
putTime = it.goodsName?.let { name -> seasoningFirstUseMap[name] }
) }
.takeIf { it.isNotEmpty() }
// 熟重 g → kg,保留 3 位小数
val cookedWeightKg = (cookFoodEntity.foodWeight / 1000.0).roundedDecimalPlace(3)
val request = CookCompleteRequest(
cookOrderId = cookOrderId ?: "",
cookStart = cookStartTime,
items = items,
cookedWeight = cookedWeightKg,
seasonings = seasonings
)
Log.d(TAG, "submitWithNewApi: ${request.toJsonString()}")
val isSamplingData = food?.cookMode == 1
netViewModel.cookOrderComplete(
request = request,
onResult = { state ->
when (state) {
is UiState.Success -> submitSuccess(isSamplingData)
is UiState.Error -> {
toast(state.msg)
dismissLoading()
}
else -> {}
}
}
)
}
/**
* 提交采样:从 goodsList 提取全部构成(主材/辅材/调料),组装 ConstituteSaveRequest 提交
* 落库味养创新实验;熟重 foodWeight 单位为 gisMain 直接取 materialType(1主/2辅/3调)
*/
private fun submitConstitute() {
val allGoods = goodsList ?: run {
toast("未获取到菜品或构成信息")
dismissLoading()
return
}
val foodName = cookFoodEntity.foodName ?: food?.foodName ?: ""
if (foodName.isBlank()) {
toast("菜品名称为空")
dismissLoading()
return
}
// 仅保留用量 > 0 的食材;materId 优先取 materId,为空时回退 goodsId
val items = allGoods
.filter { (it.useWeight ?: 0.0) > 0.0 }
.map {
ConstituteSaveItem(
materId = it.materId?.takeIf { mid -> mid.isNotEmpty() } ?: it.goodsId,
useWeight = it.useWeight ?: 0.0,
isMain = it.materialType
)
}
if (items.isEmpty()) {
toast("请完善菜品构成信息")
dismissLoading()
return
}
if (items.any { it.materId.isBlank() }) {
toast("菜品食材不能为空")
dismissLoading()
return
}
val foodWeight = cookFoodEntity.foodWeight
if (foodWeight <= 0.0) {
toast("熟重必须大于0")
dismissLoading()
return
}
val request = ConstituteSaveRequest(
foodName = foodName,
foodWeight = foodWeight,
items = items
)
Log.d(TAG, "submitConstitute: ${request.toJsonString()}")
netViewModel.constituteSave(
request = request,
onResult = { state ->
when (state) {
is UiState.Success -> submitSuccess(isSamplingData = true)
is UiState.Error -> {
toast(state.msg)
dismissLoading()
}
else -> {}
}
}
)
}
/**
* 将 adapter 中的调料 item 转换为 CookFoodGoodsEntity
* 从本地 dm_seasoning 表补充完整字段(popularName、zjmCode、materId、goodsCode 等)
*/
private suspend fun buildSeasoningEntity(item: SeasoningWeightAdapter.Item): CookFoodGoodsEntity {
val seasoning = dbViewModel.getSeasoningByGoodsId(item.goodsId)
return CookFoodGoodsEntity().also { entity ->
entity.foodId = cookFoodEntity.foodId
entity.goodsId = item.goodsId
entity.goodsName = item.goodsName
entity.useWeight = item.useWeight
entity.materialType = 3
entity.popularName = seasoning?.popularName
entity.zjmCode = seasoning?.zjmCode
entity.materId = seasoning?.materId
entity.goodsCode = seasoning?.goodsCode
entity.relateionType = seasoning?.relateionType ?: 0
entity.allEdible = seasoning?.allEdible ?: true
entity.goodsOrRelationCode = seasoning?.goodsOrRelationCode
}
}
private fun submitSuccess(isSamplingData: Boolean) {
@@ -389,42 +639,72 @@ class SubmitFoodActivity : BaseActivity() {
jumpPage(isSamplingData, false)
return
}
lifecycleScope.launch {
appViewModel.deleteCookFoodAndGoods(
// 用 viewModelScope 执行删除,不受当前 Activity 生命周期影响
// singleTask 跳转会销毁本 Activity 并取消 lifecycleScope,导致删除被中断
dbViewModel.markSubmittedAndDeleteAsync(
cookMode = food!!.cookMode,
foodId = food!!.foodId!!
foodId = food!!.foodId!!,
dinnerType = food!!.dinnerType
)
dismissLoading()
jumpPage(isSamplingData, false)
}
}
private fun jumpPage(isSampling: Boolean, isCooking: Boolean) {
if (isSampling) {
startActivity<SamplingListActivity> {
putExtra(SelectDishActivity.IS_COOKING, isCooking)
startActivity<SamplingModeActivity> {
putExtra(CookingModeActivity.IS_COOKING, isCooking)
}
return
}
startActivity<SelectDishActivity> {
putExtra(SelectDishActivity.IS_COOKING, isCooking)
putExtra(SelectDishActivity.DINNER_TYPE, food?.dinnerType)
startActivity<CookingModeActivity> {
putExtra(CookingModeActivity.IS_COOKING, isCooking)
putExtra(CookingModeActivity.DINNER_TYPE, food?.dinnerType)
}
}
/**
* 构建用于预览弹窗的 goodsList:
* 取 goodsList 中的主辅材,再用 adapter 中最新调料覆盖原有调料数据(materialType==3
* 调料的完整字段(含 goodsCode)通过 buildSeasoningEntity 从 DB 补全
*/
private suspend fun buildPreviewGoodsList(): List<CookFoodGoodsEntity> {
val base = goodsList?.filter { it.materialType != 3 }?.toMutableList() ?: mutableListOf()
seasoningAdapter.items
.filter { it.useWeight > 0.0 }
.forEach { base.add(buildSeasoningEntity(it)) }
return base
}
override fun onDestroy() {
WeightUtil.removeWeightListener(TAG)
super.onDestroy()
}
/**
* 判断是否存在未保存的数据
* isCooking=true 时检查是否有新增秤数据;isCooking=false 时检查是否有调料数据
*/
private fun hasUnsavedData(): Boolean {
return if (food?.isCooking == true) {
rawWeightMap.isNotEmpty()
} else {
seasoningAdapter.items.isNotEmpty()
}
}
/**
* 未保存数据提醒dialog
*/
private fun remindSaveDataDialog() {
if (!hasUnsavedData()) {
finish()
return
}
CommonDialog(this)
.setTitle("保存提示")
.setContent("可能存在未保存的数据,请确认?")
.setContent("存在未保存的数据,请确认?")
.setNegativeButton("取消,留在页面")
.setNeutralButton("返回上页,不保存") { finish() }
.setPositiveButton("保存为烹饪中菜品") { cook() }
@@ -1,439 +0,0 @@
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,374 @@
package com.shuwei.dish.match.ui.fragment
import android.graphics.Color
import android.graphics.Typeface
import android.text.InputType
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.RadioButton
import android.widget.TextView
import androidx.cardview.widget.CardView
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.shuwei.dish.match.R
import com.shuwei.dish.match.base.BaseFragment
import com.shuwei.dish.match.databinding.FragmentDbInspectBinding
import com.shuwei.dish.match.db.DbViewModel
import com.shuwei.dish.match.ui.SingleFragmentActivity
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.visible
import kotlinx.coroutines.launch
import androidx.core.graphics.toColorInt
/**
* 数据库调试查看页面
* 支持按表切换、isDel 过滤开关、多字段筛选、分页加载
*/
class DbInspectFragment : BaseFragment<FragmentDbInspectBinding>() {
private lateinit var currentActivity: SingleFragmentActivity
/** 当前选中的表索引:0=菜品 1=食材 2=调料 3=槽位 */
private var currentTableIndex = 0
/** 是否显示已删除记录(槽位表忽略此参数) */
private var showDel = true
/** 防止代码中设置 Switch 状态时误触发监听器 */
private var ignoreSwitch = false
private val adapter = DbRecordAdapter()
/** Tab RadioButton 列表,用于统一更新选中样式 */
private val tabButtons = mutableListOf<RadioButton>()
/**
* 每张表对应的筛选字段配置
* @param key SQL 列名
* @param label 显示标签
* @param hint 输入框提示文字
* @param isLike true=模糊匹配(LIKE),false=精确匹配(=
*/
data class FilterField(
val key: String,
val label: String,
val hint: String,
val isLike: Boolean = false
)
/** 各表的筛选字段配置 */
private val filterConfigs = mapOf(
0 to listOf(
FilterField("foodId", "菜品ID", "精确匹配"),
FilterField("foodName", "菜品名", "模糊搜索", isLike = true),
FilterField("canteenId", "食堂ID", "精确匹配"),
FilterField("cookMode", "菜品模式", "0制作/1采样/2品控"),
FilterField("dinnerType", "餐次", "0默认无/1早餐/2午餐/3晚餐"),
FilterField("submitState", "提交状态", "0本地或手动删除/1接口提交删除")
),
1 to listOf(
FilterField("pid", "父ID", "精确匹配"),
FilterField("foodId", "菜品ID", "精确匹配"),
FilterField("goodsId", "食材ID", "精确匹配"),
FilterField("goodsName", "食材名", "模糊搜索", isLike = true),
FilterField("materialType", "物品类型", "1主料/2辅料/3调料"),
FilterField("rawMaterialsType", "净材种类", "精确匹配"),
FilterField("goodsCode", "物料编码", "1主料/2辅料/3调料")
),
2 to listOf(
FilterField("goodsId", "调料ID", "精确匹配"),
FilterField("goodsName", "调料名", "模糊搜索", isLike = true),
FilterField("goodsCode", "物料编码", "1主料/2辅料/3调料")
),
3 to listOf(
FilterField("deviceId", "设备ID", "精确匹配"),
FilterField("address", "秤硬件地址", "精确匹配"),
FilterField("goodsId", "调料ID", "精确匹配"),
FilterField("goodsName", "调料名", "模糊搜索", isLike = true)
)
)
/** key=列名,value=对应的 EditText,用于查询时收集筛选值 */
private val filterEditTexts = mutableMapOf<String, EditText>()
override fun inflateBinding(
inflater: LayoutInflater,
container: ViewGroup?
): FragmentDbInspectBinding = FragmentDbInspectBinding.inflate(inflater, container, false)
override fun initialize() {
currentActivity = requireActivity() as SingleFragmentActivity
binding.rvRecords.layoutManager = LinearLayoutManager(requireContext())
binding.rvRecords.adapter = adapter
tabButtons.addAll(
listOf(binding.rbCookFood, binding.rbGoods, binding.rbSeasoning, binding.rbSlot)
)
// Tab 切换:重置筛选框并加载第一页
binding.rgTabs.setOnCheckedChangeListener { _, checkedId ->
currentTableIndex = when (checkedId) {
R.id.rbCookFood -> 0
R.id.rbGoods -> 1
R.id.rbSeasoning -> 2
R.id.rbSlot -> 3
else -> 0
}
updateTabStyles()
moveIndicator(currentTableIndex)
updateDelControlsVisibility()
rebuildFilterUI()
loadFirstPage()
}
// isDel 开关:切换后重新加载第一页
binding.swShowDel.setOnCheckedChangeListener { _, isChecked ->
if (ignoreSwitch) return@setOnCheckedChangeListener
showDel = isChecked
loadFirstPage()
}
// 查询按钮:收集筛选条件并加载第一页
binding.tvRefresh.setOnClickListener { loadFirstPage() }
// 加载更多按钮:筛选条件从 state 中复用,无需重新传入
binding.tvLoadMore.setOnClickListener {
currentActivity.dbViewModel.loadMoreDbInspect(currentTableIndex)
}
// 订阅 UI 状态
viewLifecycleOwner.lifecycleScope.launch {
currentActivity.dbViewModel.dbInspectState.collect { state ->
renderState(state)
}
}
// 初始化样式并加载
updateTabStyles()
updateDelControlsVisibility()
rebuildFilterUI()
binding.vTabIndicator.post { moveIndicator(currentTableIndex) }
loadFirstPage()
}
/** 收集当前筛选框的值,构建 DbInspectFilter */
private fun collectFilter(): DbViewModel.DbInspectFilter {
val exactFields = mutableMapOf<String, String>()
val likeFields = mutableMapOf<String, String>()
filterConfigs[currentTableIndex]?.forEach { field ->
val value = filterEditTexts[field.key]?.text?.toString()?.trim() ?: ""
if (value.isNotEmpty()) {
if (field.isLike) likeFields[field.key] = value
else exactFields[field.key] = value
}
}
return DbViewModel.DbInspectFilter(
showDel = showDel,
exactFields = exactFields,
likeFields = likeFields
)
}
private fun loadFirstPage() {
currentActivity.dbViewModel.loadDbInspect(currentTableIndex, collectFilter())
}
/**
* 根据当前 Tab 重新生成筛选框区域
* 每行展示 2 个字段,最后一个为奇数时右侧补空占位
*/
private fun rebuildFilterUI() {
binding.llFilterContainer.removeAllViews()
filterEditTexts.clear()
val configs = filterConfigs[currentTableIndex] ?: return
val ctx = requireContext()
val hPad = 4.dp
val vPad = 2.dp
configs.chunked(2).forEach { rowFields ->
val row = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
}
rowFields.forEach { field ->
val cell = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
setPadding(hPad, vPad, hPad, vPad)
}
// 字段标签
cell.addView(TextView(ctx).apply {
text = field.label
textSize = 10f
setTextColor("#888888".toColorInt())
})
// 输入框
val et = EditText(ctx).apply {
hint = field.hint
textSize = 12f
setSingleLine(true)
inputType = InputType.TYPE_CLASS_TEXT
setPadding(hPad, vPad, hPad, vPad)
}
filterEditTexts[field.key] = et
cell.addView(et)
row.addView(cell, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f))
}
// 奇数字段时右侧补空 View 保持对齐
if (rowFields.size == 1) {
row.addView(View(ctx), LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f))
}
binding.llFilterContainer.addView(row)
}
}
private fun renderState(state: DbViewModel.DbInspectUiState) {
binding.tvCount.text = when {
state.isLoading && state.items.isEmpty() -> "加载中..."
state.hasMore -> "已加载 ${state.items.size} / 共 ${state.totalCount}"
else -> "${state.totalCount}"
}
when {
state.isLoading && state.hasMore -> {
binding.tvLoadMore.visible()
binding.tvLoadMore.text = "加载中..."
binding.tvLoadMore.isClickable = false
}
state.hasMore -> {
binding.tvLoadMore.visible()
binding.tvLoadMore.text = "加载更多"
binding.tvLoadMore.isClickable = true
}
else -> binding.tvLoadMore.gone()
}
adapter.setData(state.items)
}
/**
* 槽位表无 isDel 字段,切换到时隐藏相关控件;切换回其他 Tab 时恢复
*/
private fun updateDelControlsVisibility() {
val isSlot = currentTableIndex == 3
if (isSlot) {
binding.tvDelLabel.gone()
binding.swShowDel.gone()
} else {
binding.tvDelLabel.visible()
binding.swShowDel.visible()
ignoreSwitch = true
binding.swShowDel.isChecked = showDel
ignoreSwitch = false
}
}
private fun updateTabStyles() {
tabButtons.forEachIndexed { index, btn ->
val selected = index == currentTableIndex
btn.setTextColor(if (selected) Color.parseColor("#5C77F7") else Color.parseColor("#888888"))
btn.setTypeface(null, if (selected) Typeface.BOLD else Typeface.NORMAL)
}
}
private fun moveIndicator(index: Int) {
val btn = tabButtons.getOrNull(index) ?: return
val tabWidth = btn.width.toFloat()
val indicatorWidth = tabWidth / 2f
val indicatorX = btn.x + (tabWidth - indicatorWidth) / 2f
binding.vTabIndicator.animate().translationX(indicatorX).setDuration(150).start()
binding.vTabIndicator.post {
val lp = binding.vTabIndicator.layoutParams
lp.width = indicatorWidth.toInt()
binding.vTabIndicator.layoutParams = lp
}
}
/** dp 转 px 便捷扩展 */
private val Int.dp: Int get() = (this * resources.displayMetrics.density + 0.5f).toInt()
// ---- 外层 Adapter:每条数据库记录对应一个 CardView ----
/** 外层列表适配器,每个 item 内嵌一个 GridLayoutManager 的 RecyclerView 展示字段 */
private inner class DbRecordAdapter : RecyclerView.Adapter<DbRecordAdapter.VH>() {
private val data = mutableListOf<DbViewModel.DbRecord>()
fun setData(list: List<DbViewModel.DbRecord>) {
data.clear()
data.addAll(list)
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.list_item_db_record, parent, false)
return VH(view)
}
override fun onBindViewHolder(holder: VH, position: Int) {
val record = data[position]
if (record.isDel) {
holder.card.setCardBackgroundColor(Color.parseColor("#FFF3F3"))
holder.vDelMark.visibility = View.VISIBLE
} else {
holder.card.setCardBackgroundColor(Color.WHITE)
holder.vDelMark.visibility = View.GONE
}
val textColor = if (record.isDel) Color.parseColor("#C62828") else Color.parseColor("#333333")
holder.rvFields.layoutManager = GridLayoutManager(holder.rvFields.context, record.columnsPerRow)
holder.rvFields.adapter = DbFieldAdapter(record.fields, textColor)
}
override fun getItemCount() = data.size
inner class VH(itemView: View) : RecyclerView.ViewHolder(itemView) {
val card: CardView = itemView.findViewById(R.id.cardRecord)
val vDelMark: View = itemView.findViewById(R.id.vDelMark)
val rvFields: RecyclerView = itemView.findViewById(R.id.rvFields)
}
}
// ---- 内层 Adapter:每个字段对应一个 TextView,垂直显示字段名和值 ----
/** 内层字段适配器,每个 item 为单个 TextView,显示格式:字段名\n字段值 */
private inner class DbFieldAdapter(
private val fields: List<Pair<String, String>>,
private val textColor: Int
) : RecyclerView.Adapter<DbFieldAdapter.VH>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.list_item_db_field, parent, false)
return VH(view)
}
override fun onBindViewHolder(holder: VH, position: Int) {
val (key, value) = fields[position]
holder.tvField.text = "$key\n$value"
holder.tvField.setTextColor(textColor)
}
override fun getItemCount() = fields.size
inner class VH(itemView: View) : RecyclerView.ViewHolder(itemView) {
val tvField: TextView = itemView.findViewById(R.id.tvField)
}
}
}

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